Archived
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user