diff --git a/.agents/skills/npm-release-via-github-actions/SKILL.md b/.agents/skills/npm-release-via-github-actions/SKILL.md index d174817..5b3b3cf 100644 --- a/.agents/skills/npm-release-via-github-actions/SKILL.md +++ b/.agents/skills/npm-release-via-github-actions/SKILL.md @@ -97,6 +97,15 @@ If there is no GitHub Actions publish workflow, stop and explain that one must b - Update the newly generated `CHANGELOG.md` heading to match the computed CalVer version if Changesets used a different heading. This manual changelog heading edit is acceptable during release prep; normal development should still use changeset fragments instead. - Review the generated `CHANGELOG.md` section. It should be suitable for GitHub Release notes. - Do not use plain `npm version ` because it creates a local git tag as a side effect; releases should be controlled via GitHub. + - **Sync the lockfile to the final version.** `npm run release:version` (Changesets) updates `package.json` but does not reliably rewrite `package-lock.json`, and the CalVer-enforcing `npm version --no-git-tag-version` only touches the lock when it actually runs. Either path can leave the committed `package-lock.json` behind at the previous version, which then resurfaces as an unexpected diff after the next `npm install`. After the version is finalized, always resync the lockfile without touching `node_modules`: + ```bash + npm install --package-lock-only + ``` + - Confirm the lockfile now matches `package.json` before continuing: + ```bash + node -e "const v=require('./package.json').version, l=require('./package-lock.json'); if (l.version!==v || l.packages[''].version!==v) { console.error('lockfile version mismatch:', l.version, l.packages[''].version, 'expected', v); process.exit(1); } console.log('lockfile in sync at', v);" + ``` + - If the lockfile mismatch persists, stop and resolve it before committing; do not ship a release whose `package-lock.json` version disagrees with `package.json`. 5. **Run checks before creating the release** - Run the repository's normal verification commands, for example: @@ -113,6 +122,7 @@ If there is no GitHub Actions publish workflow, stop and explain that one must b - `package-lock.json` - `CHANGELOG.md` - consumed/deleted `.changeset/*.md` fragments + - Before staging, confirm `package-lock.json` is actually in the diff and carries the new version. If `git status --short` does not show `package-lock.json` as modified while `package.json` changed version, the lockfile sync in step 4 was missed — go back and run `npm install --package-lock-only`. Never commit a release where `package.json` advanced but `package-lock.json` did not. - Use: ```bash git add package.json package-lock.json CHANGELOG.md .changeset diff --git a/.changeset/tracked-subsessions.md b/.changeset/tracked-subsessions.md new file mode 100644 index 0000000..75df729 --- /dev/null +++ b/.changeset/tracked-subsessions.md @@ -0,0 +1,7 @@ +--- +"@jmfederico/pi-web": minor +--- + +Add tracked subsessions (beta, off by default): agents can spawn child sessions they stay attached to. The new `spawn_subsession` tool starts a child session linked to its parent (recorded in the session tree), notifies the parent when the child stops working, and lets the parent inspect children via `list_subsessions` and `read_subsession`. The completion notice is delivered as a system-authored message (not attributed to the human), and still wakes an idle parent while queueing behind any in-flight work. Unlike the fire-and-forget `spawn_session`, subsessions are observable by their spawner. + +The capability is gated behind a beta flag so it can ship without being exposed in releases: enable it with the `PI_WEB_SUBSESSIONS` env var, the `subsessions` config key, or the "Allow agents to start tracked subsessions" toggle in Settings → Session daemon. It also requires `spawnSessions` to be enabled. Requires a manual session daemon restart to take effect. diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 10f75f7..7e8beff 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -9,13 +9,13 @@ describe("API parsers", () => { exists: true, config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } }, effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true }, - envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false }, + envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, })).toEqual({ path: "/tmp/config.json", exists: true, config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } }, effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true }, - envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false }, + envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, }); }); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index a32946a..53923cb 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -446,6 +446,7 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues { ...optionalField("shortcuts", optionalShortcuts(record["shortcuts"])), ...optionalField("plugins", optionalPlugins(record["plugins"])), ...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")), + ...optionalField("subsessions", optionalBoolean(record, "subsessions")), }; } @@ -480,7 +481,7 @@ function optionalPlugins(value: unknown): PiWebPluginConfigMap | undefined { function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides { const record = requireRecord(value); - return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions") }; + return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions"), subsessions: requireBoolean(record, "subsessions") }; } export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse { diff --git a/src/client/src/components/settings/SettingsSessiondPanel.ts b/src/client/src/components/settings/SettingsSessiondPanel.ts index e8fa98e..1f58f01 100644 --- a/src/client/src/components/settings/SettingsSessiondPanel.ts +++ b/src/client/src/components/settings/SettingsSessiondPanel.ts @@ -18,6 +18,9 @@ export class SettingsSessiondPanel extends LitElement { // On by default: the effective config is the source of truth for the toggle // state, so an unset config file still shows the feature as enabled. const effectiveSpawn = config?.effectiveConfig.spawnSessions !== false; + const subsessionsOverridden = config?.envOverrides.subsessions === true; + // Beta, off by default; also requires spawn to be enabled. + const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn; return html`
@@ -49,10 +52,28 @@ export class SettingsSessiondPanel extends LitElement { When enabled, LLMs can start new sessions, constrained to a workspace (any worktree) of the same registered project so every spawned session stays visible here. On by default.
+
+ + Allow agents to start tracked subsessions + beta + ${subsessionsOverridden ? html`environment override` : null} + + + Beta: agents can start child sessions they stay attached to (spawn_subsession, list_subsessions, read_subsession) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default. +

Effective after environment overrides

Spawn sessions
${effectiveSpawn ? "Enabled" : html`Disabled`}
+
Subsessions
${effectiveSubsessions ? "Enabled" : html`Disabled`}
`} @@ -71,6 +92,12 @@ export class SettingsSessiondPanel extends LitElement { await this.onSave?.({ ...baseConfig, spawnSessions: enabled }); } + private async toggleSubsessions(event: Event): Promise { + const enabled = event.target instanceof HTMLInputElement && event.target.checked; + const baseConfig = this.configResponse?.config ?? {}; + await this.onSave?.({ ...baseConfig, subsessions: enabled }); + } + static override styles = css` :host { display: block; } .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } @@ -99,6 +126,7 @@ export class SettingsSessiondPanel extends LitElement { .toggle input { width: 16px; height: 16px; } .toggle input:disabled { cursor: not-allowed; } .override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; } + .beta-badge { border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); background: var(--pi-bg); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } .effective-card { display: grid; gap: 10px; } .effective-card dl { display: grid; gap: 8px; margin: 0; } .effective-card dl > div { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 12px; align-items: baseline; } diff --git a/src/client/src/components/settings/settingsConfigDraft.ts b/src/client/src/components/settings/settingsConfigDraft.ts index ed778b3..3499255 100644 --- a/src/client/src/components/settings/settingsConfigDraft.ts +++ b/src/client/src/components/settings/settingsConfigDraft.ts @@ -25,6 +25,7 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue ...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }), ...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }), ...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }), + ...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }), }; const host = draft.host.trim(); const port = draft.port.trim(); diff --git a/src/config.test.ts b/src/config.test.ts index 32f20e5..8a28651 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DEFAULT_MAX_UPLOAD_BYTES, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled } from "./config.js"; +import { DEFAULT_MAX_UPLOAD_BYTES, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; let tempDir: string; let configPath: string; @@ -73,6 +73,21 @@ describe("spawnSessionsEnabled", () => { }); }); +describe("subsessionsEnabled", () => { + it("is off by default while the capability is in beta", () => { + expect(subsessionsEnabled({}, {})).toBe(false); + }); + + it("honors an explicit config opt-in", () => { + expect(subsessionsEnabled({}, { subsessions: true })).toBe(true); + }); + + it("lets the env var override the config in both directions", () => { + expect(subsessionsEnabled({ PI_WEB_SUBSESSIONS: "1" }, { subsessions: false })).toBe(true); + expect(subsessionsEnabled({ PI_WEB_SUBSESSIONS: "0" }, { subsessions: true })).toBe(false); + }); +}); + function testOptions(): { env: NodeJS.ProcessEnv } { return { env: { PI_WEB_CONFIG: configPath } }; } diff --git a/src/config.ts b/src/config.ts index c069150..be66598 100644 --- a/src/config.ts +++ b/src/config.ts @@ -85,6 +85,8 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf // Always resolved (on by default) so the effective config is the single // source of truth for the runtime state and the settings UI toggle. spawnSessions: spawnSessionsEnabled(env, loaded.config), + // Beta capability, resolved off by default. + subsessions: subsessionsEnabled(env, loaded.config), }, }; } @@ -101,6 +103,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): delete existing["plugins"]; delete existing["maxUploadBytes"]; delete existing["spawnSessions"]; + delete existing["subsessions"]; const merged = { ...existing, ...piWebConfigRecord(normalized) }; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8"); @@ -123,6 +126,7 @@ function piWebConfigRecord(config: PiWebConfig): Record { ...(config.plugins !== undefined ? { plugins: config.plugins } : {}), ...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}), ...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}), + ...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}), }; } @@ -135,6 +139,7 @@ function parsePiWebConfig(value: Record, path: string): PiWebCo ...(value["plugins"] !== undefined ? { plugins: parsePlugins(value["plugins"], path) } : {}), ...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}), ...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}), + ...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}), }; } @@ -161,6 +166,25 @@ export function spawnSessionsEnabled(env: NodeJS.ProcessEnv = process.env, confi return config.spawnSessions ?? true; } +function parseSubsessions(value: unknown, path: string): boolean { + if (typeof value !== "boolean") throw new Error(`PI WEB config subsessions must be a boolean: ${path}`); + return value; +} + +/** + * Beta: whether LLMs may start tracked child sessions via the spawn_subsession + * family of tools. Off by default while the capability stabilizes, so it can + * ship in main without affecting releases; enable with the env var + * `PI_WEB_SUBSESSIONS` or the `subsessions` config key. The env var takes + * precedence over the config file. Subsessions also require spawnSessions to be + * enabled (they share the same project-scope resolver). + */ +export function subsessionsEnabled(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): boolean { + const fromEnv = env["PI_WEB_SUBSESSIONS"]; + if (fromEnv !== undefined && fromEnv !== "") return fromEnv === "1" || fromEnv.toLowerCase() === "true"; + return config.subsessions ?? false; +} + function parseString(value: unknown, key: string, path: string): string { if (typeof value !== "string" || value === "") throw new Error(`PI WEB config ${key} must be a non-empty string: ${path}`); return value; diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts index 6a4a3f7..34ce545 100644 --- a/src/server/configRoutes.test.ts +++ b/src/server/configRoutes.test.ts @@ -64,6 +64,6 @@ function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigRes exists, config, effectiveConfig: config, - envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false }, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, }; } diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts index 1d341f3..afc05f7 100644 --- a/src/server/configRoutes.ts +++ b/src/server/configRoutes.ts @@ -112,6 +112,7 @@ function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverride port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]), allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]), spawnSessions: isEnvSet(env["PI_WEB_SPAWN_SESSIONS"]), + subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]), }; } diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index 4e7e9fa..62a17b3 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -19,7 +19,7 @@ import { TerminalService } from "./terminals/terminalService.js"; import { registerTerminalRoutes } from "./terminals/terminalRoutes.js"; import { getPiWebRuntimeComponent } from "./piWebStatus.js"; import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js"; -import { effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled } from "../config.js"; +import { effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled, subsessionsEnabled } from "../config.js"; const app = Fastify({ logger: true, bodyLimit: maxUploadBytes() }); await app.register(fastifyWebsocket); @@ -36,6 +36,7 @@ const sessions = new PiSessionService(eventHub, { workspaceActivity, logger: app.log, ...(spawnTargets === undefined ? {} : { spawnTargets }), + subsessionsEnabled: spawnTargets !== undefined && subsessionsEnabled(process.env, config), }); auth.subscribe((change) => { sessions.applyAuthChange(change); }); const terminals = new TerminalService(eventHub, workspaceActivity); diff --git a/src/server/sessions/piSessionManagerGateway.ts b/src/server/sessions/piSessionManagerGateway.ts index edebfbe..2282b1b 100644 --- a/src/server/sessions/piSessionManagerGateway.ts +++ b/src/server/sessions/piSessionManagerGateway.ts @@ -63,9 +63,9 @@ class SettingsAwarePiSessionManagerGateway implements PiSessionManagerGateway { return filterSessionsForCwd(await listSessionsInDir(resolution.sessionDir), cwd); } - create(cwd: string): PiSessionManager { + create(cwd: string, options?: { parentSession?: string }): PiSessionManager { const resolution = this.resolver.resolve(cwd); - return SessionManager.create(cwd, resolution.sessionDir); + return SessionManager.create(cwd, resolution.sessionDir, options?.parentSession === undefined ? undefined : { parentSession: options.parentSession }); } listAll(): Promise { diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index 22d2d9a..1063d2a 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -50,9 +50,10 @@ function sessionRef(id: string, cwd = "/workspace") { function fakeRuntime(sessionId = "session-1", patch: Partial = {}) { const promptCalls: { text: string; options: unknown }[] = []; + const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = []; const bindExtensionCalls: unknown[] = []; const listeners: ((event: unknown) => void)[] = []; - const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls }; + const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, sendCustomMessage: customMessageCalls }; const session: TestSession = { sessionId, sessionFile: `/tmp/${sessionId}.jsonl`, @@ -87,6 +88,10 @@ function fakeRuntime(sessionId = "session-1", patch: Partial = {}) calls.prompt.push({ text, options }); return Promise.resolve(); }, + sendCustomMessage: (message: { customType: string; content: string; display: boolean; details?: unknown }, options: unknown) => { + calls.sendCustomMessage.push({ message, options }); + return Promise.resolve(); + }, executeBash: () => Promise.resolve({ output: "", exitCode: 0, cancelled: false, truncated: false }), abort: () => { calls.abort += 1; @@ -832,4 +837,148 @@ describe("PiSessionService", () => { await service.dispose(); }); }); + + describe("spawnSubsession", () => { + function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) { + const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" }); + const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") }); + const created = [parent.runtime, child.runtime]; + let index = 0; + const createAgentRuntime: RuntimeCreator = async () => { + await Promise.resolve(); + const runtime = created[Math.min(index, created.length - 1)] ?? child.runtime; + index += 1; + return runtime; + }; + const archived = new Map(); + const archiveStore = { + list: () => Promise.resolve([...archived.values()]), + get: (sessionId: string) => Promise.resolve(archived.get(sessionId)), + archive: (input: { sessionId: string; cwd: string }) => { + const record = { sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-01T00:00:00.000Z" }; + archived.set(input.sessionId, record); + return Promise.resolve(record); + }, + restore: (sessionId: string) => { archived.delete(sessionId); return Promise.resolve(); }, + isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)), + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: sessionGateway([]), + archiveStore, + spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) }, + heartbeatIntervalMs, + }); + return { parent, child, service }; + } + + it("records the parent, delivers the prompt, and lists the tracked child", async () => { + const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + await service.start("/workspace"); // bring the parent online so it can be notified + + const result = await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" }); + + expect(result).toEqual({ sessionId: "child-1", cwd: "/workspace-feature" }); + expect(child.calls.prompt).toEqual([{ text: "do the slice", options: undefined }]); + await expect(service.listSubsessions("parent-1")).resolves.toEqual([ + { sessionId: "child-1", cwd: "/workspace-feature", status: "idle" }, + ]); + void parent; + await service.dispose(); + }); + + it("notifies the parent once when the tracked child stops working", async () => { + const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); + parent.calls.prompt.length = 0; // ignore the spawn prompt to the child; focus on the parent notification + + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); // arm the notification + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); // fire once + child.emit({ type: "turn_end" }); // must not re-notify + await new Promise((resolve) => setTimeout(resolve, 20)); // the parent notification is delivered via the async custom-message path + + expect(parent.calls.sendCustomMessage).toHaveLength(1); + expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); + expect(parent.calls.sendCustomMessage[0]?.message.customType).toBe("subsession.completion"); + expect(parent.calls.sendCustomMessage[0]?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" }); + expect(parent.calls.prompt).toHaveLength(0); // not a user-authored message + await service.dispose(); + }); + + it("notifies via the heartbeat when the child settles without a further event", async () => { + const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }, 10); + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); + parent.calls.prompt.length = 0; + + // The child works, then settles silently: agent_end arrives while it still + // reports active work, so the event-driven latch does not fire here. + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.emit({ type: "agent_end" }); + expect(parent.calls.sendCustomMessage).toHaveLength(0); + + // Once the session settles, the periodic heartbeat re-check notifies. + child.session.isStreaming = false; + await new Promise((resolve) => setTimeout(resolve, 40)); + + expect(parent.calls.sendCustomMessage).toHaveLength(1); + expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); + await service.dispose(); + }); + + it("does not notify the parent when a tracked child is archived", async () => { + const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); + // Arm the notification, as a real working child would. + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + parent.calls.sendCustomMessage.length = 0; + + await service.archive("child-1"); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(parent.calls.sendCustomMessage).toHaveLength(0); + await service.dispose(); + }); + + it("reports an archived child's status in the subsession list", async () => { + const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); + + await service.archive("child-1"); + + await expect(service.listSubsessions("parent-1")).resolves.toEqual([ + { sessionId: "child-1", cwd: "/workspace-feature", status: "archived" }, + ]); + await service.dispose(); + }); + + it("read_subsession refuses sessions that are not the caller's children", async () => { + const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); + + await expect(service.readSubsession("someone-else", "child-1")).rejects.toThrow("not one of your subsessions"); + await service.dispose(); + }); + + it("is disabled when no spawn target resolver is configured", async () => { + const fake = fakeRuntime("nope"); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + await expect(service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "p", parentSessionFile: undefined, prompt: "go", cwd: undefined })) + .rejects.toThrow("Spawning sessions is disabled"); + await service.dispose(); + }); + }); }); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 5152e45..f1a096d 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -32,6 +32,7 @@ import type { SavedPromptAttachment } from "../../shared/apiTypes.js"; import { cwdPathsEqual } from "../workingDirectory.js"; import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js"; import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js"; +import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js"; import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js"; /** @@ -127,7 +128,7 @@ export interface PiSessionManager { export interface PiSessionManagerGateway { list(cwd: string): Promise; - create(cwd: string): PiSessionManager; + create(cwd: string, options?: { parentSession?: string }): PiSessionManager; /** * Legacy id-only lookup surface for older clients. This intentionally searches * only Pi's default session store, because custom session directories require @@ -172,6 +173,7 @@ export interface PiAgentSession { getSessionStats(): { sessionId: string; totalMessages: number; userMessages: number; assistantMessages: number; toolCalls: number; tokens: ClientSessionStatus["tokens"]; cost: number }; getContextUsage(): ClientSessionStatus["contextUsage"] | undefined; prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] }): Promise; + sendCustomMessage(message: { customType: string; content: string; display: boolean; details?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise; executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode: number | undefined; cancelled: boolean; truncated: boolean; fullOutputPath?: string }>; abort(): Promise; clearQueue(): { steering: string[]; followUp: string[] }; @@ -208,12 +210,13 @@ function defaultCreateAgentRuntime(createRuntime: CreateAgentSessionRuntimeFacto type SpawnSessionFn = (input: SpawnSessionInvocation) => Promise; -function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance, spawn?: SpawnSessionFn): CreateAgentSessionRuntimeFactory { +function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance, spawn?: SpawnSessionFn, subsessions?: SubsessionToolDeps): CreateAgentSessionRuntimeFactory { return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => { const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry }); const customTools = [ createPiWebEditToolDefinition(cwd), ...(spawn === undefined ? [] : [createSpawnSessionToolDefinition(cwd, { spawn })]), + ...(subsessions === undefined ? [] : createSubsessionToolDefinitions(cwd, subsessions)), ]; const options = sessionStartEvent === undefined ? { services, sessionManager, customTools } @@ -262,6 +265,13 @@ export interface PiSessionServiceDependencies { * Omit to keep the capability disabled (the tool is never registered). */ spawnTargets?: SpawnTargetResolver; + /** + * Beta: when true (and `spawnTargets` is provided), the tracked-subsession + * tools (`spawn_subsession`, `list_subsessions`, `read_subsession`) are + * registered on every session. Off by default so the capability can ship in + * main without being exposed in releases. + */ + subsessionsEnabled?: boolean; /** Structured logger for notable runtime events (e.g. spawns). */ logger?: PiSessionLogger; } @@ -274,6 +284,16 @@ export class PiSessionService { private readonly compactionPromptQueues = new Map(); private readonly compactionDrainTimers = new Map(); private readonly authLossWarnings = new Set(); + /** Tracked subsession id -> the parent session id that spawned it. */ + private readonly subsessionParents = new Map(); + /** Parent session id -> the set of tracked subsession ids it spawned. */ + private readonly subsessionChildren = new Map>(); + /** + * Tracked subsession id -> whether a completion notification is armed. + * Armed when the child starts working; firing on completion disarms it so a + * child that works again (and stops again) notifies the parent each time. + */ + private readonly subsessionNotifyArmed = new Map(); private readonly archiveStore: SessionArchiveRepository; private readonly agentDir: string; private readonly sessionManager: PiSessionManagerGateway; @@ -291,10 +311,18 @@ export class PiSessionService { this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create()); this.spawnTargets = deps.spawnTargets; this.logger = deps.logger ?? noopLogger; + // Subsessions are a beta capability gated behind their own flag, and they + // also require the spawn capability (they share its project-scope resolver). + const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true; this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory( this.modelRegistry.authStorage, this.modelRegistry, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), + !subsessionsActive ? undefined : { + spawn: (input) => this.spawnSubsession(input), + list: (parentSessionId) => this.listSubsessions(parentSessionId), + read: (parentSessionId, sessionId) => this.readSubsession(parentSessionId, sessionId), + }, ); this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime; this.workspaceActivity = deps.workspaceActivity; @@ -329,6 +357,9 @@ export class PiSessionService { this.activities.clear(); this.compactionPromptQueues.clear(); this.authLossWarnings.clear(); + this.subsessionParents.clear(); + this.subsessionChildren.clear(); + this.subsessionNotifyArmed.clear(); await Promise.all(activeSessions.map(async (active) => { active.unsubscribe(); this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd()); @@ -355,8 +386,8 @@ export class PiSessionService { return [...unarchivedSessions, ...archivedSessions]; } - async start(cwd: string): Promise { - const active = await this.create(this.sessionManager.create(cwd), cwd); + async start(cwd: string, parentSession?: string): Promise { + const active = await this.create(this.sessionManager.create(cwd, parentSession === undefined ? undefined : { parentSession }), cwd); const { session } = active.runtime; const created: ClientSession = { id: session.sessionId, @@ -366,6 +397,9 @@ export class PiSessionService { modified: new Date().toISOString(), messageCount: session.messages.length, firstMessage: "", + // Include the parent so listeners can nest the new session in the tree + // immediately, instead of showing it flat until the next reload. + ...(parentSession === undefined ? {} : { parentSessionPath: parentSession }), }; // Broadcast so other clients (and the spawning agent's UI) can add the new // session to their list without a manual reload. @@ -391,6 +425,120 @@ export class PiSessionService { return { sessionId: created.id, cwd: decision.cwd }; } + /** + * Start a *tracked* child session on behalf of a LLM. Identical to + * {@link spawnSession} in how the target cwd is resolved, but the child + * records its parent (so it shows in the session tree) and is registered so + * the parent is notified when it stops working and can inspect it later. + */ + async spawnSubsession(input: SpawnSubsessionInvocation): Promise { + if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled"); + const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd); + if (!decision.allowed) throw spawnTargetError(decision); + const created = await this.start(decision.cwd, input.parentSessionFile); + this.registerSubsession(input.parentSessionId, created.id); + await this.prompt(created.id, input.prompt); + this.logger.info( + { parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, + "spawn_subsession started a tracked child session", + ); + return { sessionId: created.id, cwd: decision.cwd }; + } + + /** Summaries of the tracked subsessions spawned by `parentSessionId`. */ + async listSubsessions(parentSessionId: string): Promise { + const childIds = this.subsessionChildren.get(parentSessionId); + if (childIds === undefined) return []; + return Promise.all([...childIds].map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) }))); + } + + /** Status and final result of a subsession, scoped to the caller's children. */ + async readSubsession(parentSessionId: string, sessionId: string): Promise { + if (this.subsessionParents.get(sessionId) !== parentSessionId) { + throw new Error(`Session ${sessionId} is not one of your subsessions`); + } + const session = await this.getOrOpen(sessionId); + const messages = historyMessages(session); + return { + sessionId, + cwd: session.sessionManager.getCwd(), + status: await this.subsessionStatus(session), + finalText: finalAssistantText(messages), + messageCount: messages.length, + }; + } + + private registerSubsession(parentSessionId: string, childSessionId: string): void { + this.subsessionParents.set(childSessionId, parentSessionId); + const children = this.subsessionChildren.get(parentSessionId) ?? new Set(); + children.add(childSessionId); + this.subsessionChildren.set(parentSessionId, children); + this.subsessionNotifyArmed.set(childSessionId, false); + } + + private async subsessionSummaryFields(childSessionId: string): Promise<{ cwd: string; status: SubsessionStatus }> { + const active = this.active.get(childSessionId); + if (active !== undefined) { + return { cwd: active.runtime.cwd, status: await this.subsessionStatus(active.runtime.session) }; + } + const archived = await this.archiveStore.get(childSessionId); + if (archived !== undefined) return { cwd: archived.cwd, status: "archived" }; + return { cwd: "", status: "unknown" }; + } + + private async subsessionStatus(session: PiAgentSession): Promise { + if (await this.archiveStore.isArchived(session.sessionId)) return "archived"; + if (this.hasActiveWork(session)) return "working"; + if (this.activities.get(session.sessionId)?.phase === "error") return "error"; + return "idle"; + } + + /** + * Drive parent notifications from a tracked child's status. Arms a pending + * notification while the child is working, and when it stops fires a single + * follow-up message to the parent via {@link prompt} (which queues if the + * parent is busy and delivers immediately when it is idle). + */ + private updateSubsessionTracking(session: PiAgentSession): void { + const childId = session.sessionId; + const parentId = this.subsessionParents.get(childId); + if (parentId === undefined) return; + if (this.hasActiveWork(session)) { + this.subsessionNotifyArmed.set(childId, true); + return; + } + if (this.subsessionNotifyArmed.get(childId) !== true) return; + this.subsessionNotifyArmed.set(childId, false); + const status: SubsessionStatus = this.activities.get(childId)?.phase === "error" ? "error" : "idle"; + const finalText = finalAssistantText(historyMessages(session)); + const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText); + const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nUse read_subsession with sessionId "${childId}" for the full result.`; + void this.notifyParentOfSubsession(parentId, childId, text); + } + + /** + * Deliver a subsession-completion notice to the parent as a system-authored + * custom message rather than a user message, so it is not attributed to the + * human in the transcript. It still wakes an idle parent (`triggerTurn`) and + * queues behind in-flight work (`deliverAs: "followUp"`), preserving the + * established "queue if busy, send and act if idle" behavior. + */ + private async notifyParentOfSubsession(parentId: string, childId: string, text: string): Promise { + try { + const session = await this.getOrOpen(parentId); + await session.sendCustomMessage( + { customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } }, + { triggerTurn: true, deliverAs: "followUp" }, + ); + this.publishStatus(session); + } catch (error: unknown) { + this.logger.info( + { parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, + "failed to notify parent of subsession completion", + ); + } + } + async messages(ref: PiSessionLookup, page?: { before?: number; limit?: number }): Promise { const session = await this.getOrOpen(ref); return pageMessagesAtSafeBoundary(historyMessages(session), page); @@ -751,6 +899,10 @@ export class PiSessionService { this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd()); this.clearAuthLossWarningsForSession(sessionId); this.clearCompactionPromptQueue(sessionId); + // Disarm subsession notification before teardown so the abort below cannot + // emit a "stopped working" event that notifies the parent (e.g. on archive). + // The parent/children link is kept so the parent can still see the child. + this.subsessionNotifyArmed.delete(sessionId); clearSessionQueue(active.runtime.session); active.unsubscribe(); try { @@ -839,6 +991,7 @@ export class PiSessionService { if (eventType === "compaction_end") this.scheduleCompactionQueueDrain(session.sessionId); if (eventType === "agent_start" || eventType === "agent_end") this.scheduleCompactionQueueDrain(session.sessionId); this.publishStatus(session); + this.updateSubsessionTracking(session); }); this.active.set(session.sessionId, active); } @@ -969,6 +1122,10 @@ export class PiSessionService { private publishHeartbeats(): void { for (const active of this.active.values()) { const { session } = active.runtime; + // Re-evaluate subsession completion here too: agent_end can arrive while + // the session still reports active work transiently, so the event-driven + // latch may not fire. The heartbeat re-checks once the session settles. + this.updateSubsessionTracking(session); const activity = this.activities.get(session.sessionId); if (!this.hasActiveWork(session)) { if (activity?.phase === "active") this.publishStatus(session); @@ -1298,6 +1455,33 @@ function historyMessages(session: PiAgentSession): unknown[] { return messages; } +/** customType marking a parent-facing subsession-completion notice. */ +const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion"; + +const SUBSESSION_NOTIFICATION_PREVIEW_CHARS = 2000; + +function truncateForNotification(text: string): string { + if (text.length <= SUBSESSION_NOTIFICATION_PREVIEW_CHARS) return text; + return `${text.slice(0, SUBSESSION_NOTIFICATION_PREVIEW_CHARS)}…`; +} + +/** Most recent assistant text from a history message list, or "" if none. */ +function finalAssistantText(messages: readonly unknown[]): string { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (!isRecord(message) || message["role"] !== "assistant") continue; + const content = message["content"]; + if (typeof content === "string") return content; + if (!Array.isArray(content)) continue; + const texts: string[] = []; + for (const part of content) { + if (isRecord(part) && part["type"] === "text" && typeof part["text"] === "string") texts.push(part["text"]); + } + if (texts.length > 0) return texts.join("\n").trim(); + } + return ""; +} + function toClientEvent(event: unknown): SessionUiEvent { const eventType = getString(event, "type"); const assistantMessageEvent = getProperty(event, "assistantMessageEvent"); diff --git a/src/server/sessions/spawnSubsessionTool.test.ts b/src/server/sessions/spawnSubsessionTool.test.ts new file mode 100644 index 0000000..4d5e180 --- /dev/null +++ b/src/server/sessions/spawnSubsessionTool.test.ts @@ -0,0 +1,92 @@ +import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it, vi } from "vitest"; +import { createSubsessionToolDefinitions, type SubsessionToolDeps } from "./spawnSubsessionTool.js"; + +function ctxFor(sessionId: string, sessionFile: string | undefined): ExtensionContext { + const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => sessionFile }; + // The subsession tools only read sessionManager.getSessionId/getSessionFile. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tools use. + return { sessionManager } as unknown as ExtensionContext; +} + +function tools(deps: Partial) { + const full: SubsessionToolDeps = { + spawn: deps.spawn ?? vi.fn(() => Promise.resolve({ sessionId: "x", cwd: "/repos/a" })), + list: deps.list ?? vi.fn(() => Promise.resolve([])), + read: deps.read ?? vi.fn(() => Promise.resolve({ sessionId: "x", cwd: "/repos/a", status: "idle" as const, finalText: "", messageCount: 0 })), + }; + const definitions = createSubsessionToolDefinitions("/repos/a", full); + const find = (name: string) => { + const tool = definitions.find((definition) => definition.name === name); + if (tool === undefined) throw new Error(`missing tool ${name}`); + return tool; + }; + return { spawn: find("spawn_subsession"), list: find("list_subsessions"), read: find("read_subsession") }; +} + +function firstText(content: readonly (TextContent | ImageContent)[]): string { + const first = content[0]; + return first?.type === "text" ? first.text : ""; +} + +describe("createSubsessionToolDefinitions", () => { + it("spawn_subsession forwards parent identity and params from the live context", async () => { + const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" })); + const { spawn: spawnTool } = tools({ spawn }); + + const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl")); + + expect(spawn).toHaveBeenCalledWith({ + spawningCwd: "/repos/a", + parentSessionId: "parent-1", + parentSessionFile: "/sessions/parent-1.jsonl", + prompt: "do it", + cwd: "/repos/a-feature", + }); + expect(result.details).toEqual({ sessionId: "child-1", cwd: "/repos/a-feature" }); + expect(firstText(result.content)).toContain("Started subsession child-1"); + }); + + it("list_subsessions reports the caller's subsessions and their status", async () => { + const list = vi.fn(() => Promise.resolve([ + { sessionId: "child-1", cwd: "/repos/a", status: "working" as const }, + { sessionId: "child-2", cwd: "/repos/a", status: "idle" as const }, + ])); + const { list: listTool } = tools({ list }); + + const result = await listTool.execute("call-2", {}, undefined, undefined, ctxFor("parent-1", undefined)); + + expect(list).toHaveBeenCalledWith("parent-1"); + expect(result.details).toEqual({ subsessions: [ + { sessionId: "child-1", cwd: "/repos/a", status: "working" }, + { sessionId: "child-2", cwd: "/repos/a", status: "idle" }, + ] }); + expect(firstText(result.content)).toContain("child-1 [working]"); + }); + + it("list_subsessions reports an empty state", async () => { + const { list: listTool } = tools({ list: vi.fn(() => Promise.resolve([])) }); + const result = await listTool.execute("call-3", {}, undefined, undefined, ctxFor("parent-1", undefined)); + expect(result.content[0]).toMatchObject({ type: "text", text: "You have not spawned any subsessions." }); + }); + + it("read_subsession scopes by parent and returns the final result", async () => { + const read = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a", status: "idle" as const, finalText: "all done", messageCount: 4 })); + const { read: readTool } = tools({ read }); + + const result = await readTool.execute("call-4", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", undefined)); + + expect(read).toHaveBeenCalledWith("parent-1", "child-1"); + expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" }); + expect(firstText(result.content)).toContain("all done"); + }); + + it("read_subsession propagates scope errors so the agent loop reports them", async () => { + const read = vi.fn(() => Promise.reject(new Error("Session child-9 is not one of your subsessions"))); + const { read: readTool } = tools({ read }); + + await expect(readTool.execute("call-5", { sessionId: "child-9" }, undefined, undefined, ctxFor("parent-1", undefined))) + .rejects.toThrow("not one of your subsessions"); + }); +}); diff --git a/src/server/sessions/spawnSubsessionTool.ts b/src/server/sessions/spawnSubsessionTool.ts new file mode 100644 index 0000000..a5f447b --- /dev/null +++ b/src/server/sessions/spawnSubsessionTool.ts @@ -0,0 +1,125 @@ +import { Type } from "typebox"; +import { defineTool } from "@earendil-works/pi-coding-agent"; + +/** Lifecycle phase of a tracked subsession as seen by its parent. */ +export type SubsessionStatus = "working" | "idle" | "error" | "archived" | "unknown"; + +export interface SpawnSubsessionResult { + sessionId: string; + cwd: string; +} + +export interface SpawnSubsessionInvocation { + /** cwd of the session that invoked the tool (used for project-scope checks). */ + spawningCwd: string; + /** Session id of the parent; the spawned session is tracked against it. */ + parentSessionId: string; + /** Session file of the parent, recorded in the child's `parentSession` header. */ + parentSessionFile: string | undefined; + prompt: string; + cwd: string | undefined; +} + +export interface SubsessionSummary { + sessionId: string; + cwd: string; + status: SubsessionStatus; +} + +export interface SubsessionReadResult { + sessionId: string; + cwd: string; + status: SubsessionStatus; + finalText: string; + messageCount: number; +} + +export interface SubsessionToolDeps { + spawn(input: SpawnSubsessionInvocation): Promise; + list(parentSessionId: string): Promise; + read(parentSessionId: string, sessionId: string): Promise; +} + +const SpawnSubsessionParams = Type.Object({ + prompt: Type.String({ + description: "The first instruction to send to the new tracked subsession.", + }), + cwd: Type.Optional(Type.String({ + description: "Working directory for the subsession. Must be a workspace (worktree, or root) of the same project as this session. Defaults to this session's working directory.", + })), +}); + +const ListSubsessionsParams = Type.Object({}); + +const ReadSubsessionParams = Type.Object({ + sessionId: Type.String({ + description: "Id of a subsession you spawned (as returned by spawn_subsession or list_subsessions).", + }), +}); + +function statusLine(summary: SubsessionSummary): string { + return `- ${summary.sessionId} [${summary.status}] in ${summary.cwd}`; +} + +/** + * Tools that let an agent spawn *tracked* child sessions and inspect them. + * + * Unlike `spawn_session` (fire-and-forget peers), a subsession records its + * parent in its session header, the parent is notified when it stops working, + * and the parent may read its transcript/result. The tools are constructed + * per-session, carrying the spawning cwd for project-scope validation; the + * parent's identity is taken from the live extension context at call time. + */ +export function createSubsessionToolDefinitions(spawningCwd: string, deps: SubsessionToolDeps) { + const spawnTool = defineTool({ + name: "spawn_subsession", + label: "Spawn subsession", + description: "Start a tracked child session and send it an initial prompt. The subsession runs independently and a human can interact with it, but unlike spawn_session it is linked to you: you are notified when it stops working (finished, idle, or errored), and you can inspect it with list_subsessions and read_subsession. Use this to delegate work you intend to follow up on.", + promptSnippet: "spawn_subsession: start a tracked child session you will be notified about", + parameters: SpawnSubsessionParams, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const parentSessionId = ctx.sessionManager.getSessionId(); + const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined; + const result = await deps.spawn({ spawningCwd, parentSessionId, parentSessionFile, prompt: params.prompt, cwd: params.cwd }); + return { + content: [{ type: "text", text: `Started subsession ${result.sessionId} in ${result.cwd}. You will be notified when it stops working.` }], + details: result, + }; + }, + }); + + const listTool = defineTool({ + name: "list_subsessions", + label: "List subsessions", + description: "List the tracked subsessions you spawned, with their current status (working, idle, error, or unknown).", + promptSnippet: "list_subsessions: see the tracked child sessions you spawned", + parameters: ListSubsessionsParams, + async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { + const parentSessionId = ctx.sessionManager.getSessionId(); + const subsessions = await deps.list(parentSessionId); + const text = subsessions.length === 0 + ? "You have not spawned any subsessions." + : `Your subsessions:\n${subsessions.map(statusLine).join("\n")}`; + return { content: [{ type: "text", text }], details: { subsessions } }; + }, + }); + + const readTool = defineTool({ + name: "read_subsession", + label: "Read subsession", + description: "Read a subsession you spawned: its status and final result. Returns the subsession's most recent assistant output so you can react to what it produced.", + promptSnippet: "read_subsession: read the result of a subsession you spawned", + parameters: ReadSubsessionParams, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const parentSessionId = ctx.sessionManager.getSessionId(); + const result = await deps.read(parentSessionId, params.sessionId); + const body = result.finalText === "" ? "(no output yet)" : result.finalText; + return { + content: [{ type: "text", text: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}` }], + details: result, + }; + }, + }); + + return [spawnTool, listTool, readTool]; +} diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 8c8549f..c14fe1e 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -61,6 +61,12 @@ export interface PiWebConfigValues { maxUploadBytes?: number; /** When true, LLMs can start new sessions via the spawn_session tool. */ spawnSessions?: boolean; + /** + * Beta: when true, LLMs can start tracked child sessions via the + * spawn_subsession / list_subsessions / read_subsession tools. Off by default + * while the capability stabilizes. Requires spawnSessions to be enabled. + */ + subsessions?: boolean; } export type PiWebPluginScope = "bundled" | "local" | "user" | "project"; @@ -83,6 +89,7 @@ export interface PiWebConfigEnvOverrides { port: boolean; allowedHosts: boolean; spawnSessions: boolean; + subsessions: boolean; } export interface PiWebConfigResponse {