feat(sessions): add tracked subsessions behind a beta flag

Add spawn_subsession / list_subsessions / read_subsession tools that let an
agent start child sessions it stays attached to: the child records its parent
in the session tree, the parent is notified (as a system-authored custom
message that wakes an idle parent and queues behind in-flight work) when the
child stops working, and the parent can inspect children's status and result.

Gated behind a beta flag, off by default, mirroring spawnSessions: enable via
PI_WEB_SUBSESSIONS, the subsessions config key, or the Settings toggle. Also
requires spawnSessions.

Also fix the release skill so the version step resyncs package-lock.json
(npm install --package-lock-only) and the commit step refuses a release where
package.json and package-lock.json versions disagree.
This commit is contained in:
Federico Jaramillo Martinez
2026-06-17 12:13:23 +02:00
parent ef454f2b65
commit 355ebe8cf8
17 changed files with 658 additions and 13 deletions
@@ -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 <new-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
+7
View File
@@ -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.
+2 -2
View File
@@ -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 },
});
});
+2 -1
View File
@@ -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 {
@@ -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`
<div class="section-heading">
<div>
@@ -49,10 +52,28 @@ export class SettingsSessiondPanel extends LitElement {
</label>
<small>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.</small>
</div>
<div class="field">
<span class="field-heading">
<span>Allow agents to start tracked subsessions</span>
<span class="beta-badge">beta</span>
${subsessionsOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<label class="toggle">
<input
type="checkbox"
.checked=${effectiveSubsessions}
?disabled=${this.loading || this.saving || subsessionsOverridden || !effectiveSpawn}
@change=${(event: Event) => { void this.toggleSubsessions(event); }}
>
<span>Enable the <code>spawn_subsession</code> tools</span>
</label>
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
</div>
<section class="effective-card" aria-label="Effective configuration summary">
<h3>Effective after environment overrides</h3>
<dl>
<div><dt>Spawn sessions</dt><dd>${effectiveSpawn ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
<div><dt>Subsessions</dt><dd>${effectiveSubsessions ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
</dl>
</section>
`}
@@ -71,6 +92,12 @@ export class SettingsSessiondPanel extends LitElement {
await this.onSave?.({ ...baseConfig, spawnSessions: enabled });
}
private async toggleSubsessions(event: Event): Promise<void> {
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; }
@@ -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();
+16 -1
View File
@@ -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 } };
}
+24
View File
@@ -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<string, unknown> {
...(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<string, unknown>, 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;
+1 -1
View File
@@ -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 },
};
}
+1
View File
@@ -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"]),
};
}
+2 -1
View File
@@ -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);
@@ -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<PiSessionListEntry[]> {
+150 -1
View File
@@ -50,9 +50,10 @@ function sessionRef(id: string, cwd = "/workspace") {
function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
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<TestSession> = {})
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<string, { sessionId: string; cwd: string; archivedAt: string }>();
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();
});
});
});
+188 -4
View File
@@ -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<PiSessionListEntry[]>;
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<void>;
sendCustomMessage(message: { customType: string; content: string; display: boolean; details?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void>;
executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode: number | undefined; cancelled: boolean; truncated: boolean; fullOutputPath?: string }>;
abort(): Promise<void>;
clearQueue(): { steering: string[]; followUp: string[] };
@@ -208,12 +210,13 @@ function defaultCreateAgentRuntime(createRuntime: CreateAgentSessionRuntimeFacto
type SpawnSessionFn = (input: SpawnSessionInvocation) => Promise<SpawnSessionResult>;
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<string, QueuedPrompt[]>();
private readonly compactionDrainTimers = new Map<string, NodeJS.Timeout>();
private readonly authLossWarnings = new Set<string>();
/** Tracked subsession id -> the parent session id that spawned it. */
private readonly subsessionParents = new Map<string, string>();
/** Parent session id -> the set of tracked subsession ids it spawned. */
private readonly subsessionChildren = new Map<string, Set<string>>();
/**
* 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<string, boolean>();
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<ClientSession> {
const active = await this.create(this.sessionManager.create(cwd), cwd);
async start(cwd: string, parentSession?: string): Promise<ClientSession> {
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<SpawnSubsessionResult> {
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<SubsessionSummary[]> {
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<SubsessionReadResult> {
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<string>();
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<SubsessionStatus> {
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<void> {
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<unknown[] | ClientMessagePage> {
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");
@@ -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<SubsessionToolDeps>) {
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");
});
});
+125
View File
@@ -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<SpawnSubsessionResult>;
list(parentSessionId: string): Promise<SubsessionSummary[]>;
read(parentSessionId: string, sessionId: string): Promise<SubsessionReadResult>;
}
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<typeof SpawnSubsessionParams, SpawnSubsessionResult>({
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<typeof ListSubsessionsParams, { subsessions: SubsessionSummary[] }>({
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<typeof ReadSubsessionParams, SubsessionReadResult>({
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];
}
+7
View File
@@ -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 {