@tshu-w/pi-agents 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +81 -2
  3. package/bin/daemon.mjs +204 -0
  4. package/bin/host.mjs +170 -0
  5. package/bin/pd.mjs +139 -0
  6. package/docs/PROGRAM.md +310 -0
  7. package/docs/SPEC.md +427 -0
  8. package/docs/WORKBENCH.md +165 -0
  9. package/package.json +62 -4
  10. package/src/agents/agents.ts +882 -0
  11. package/src/agents/registry.ts +257 -0
  12. package/src/agents/scheduler.ts +204 -0
  13. package/src/agents/search.ts +131 -0
  14. package/src/agents/tool.ts +205 -0
  15. package/src/daemon/client.mjs +84 -0
  16. package/src/daemon/router.mjs +103 -0
  17. package/src/daemon/sessions.mjs +129 -0
  18. package/src/daemon/version.mjs +19 -0
  19. package/src/host/client.mjs +139 -0
  20. package/src/host/extension.ts +74 -0
  21. package/src/host/frames.mjs +34 -0
  22. package/src/host/keys.mjs +19 -0
  23. package/src/index.ts +208 -0
  24. package/src/output.ts +56 -0
  25. package/src/params.ts +4 -0
  26. package/src/pi.mjs +16 -0
  27. package/src/programs/codemode.ts +74 -0
  28. package/src/programs/execute.ts +131 -0
  29. package/src/programs/host-patches.ts +128 -0
  30. package/src/programs/loadout.ts +99 -0
  31. package/src/programs/programs.ts +284 -0
  32. package/src/programs/sandbox.ts +90 -0
  33. package/src/programs/scope.ts +94 -0
  34. package/src/programs/tool.ts +198 -0
  35. package/src/render-call.ts +90 -0
  36. package/src/roots/discovery.mjs +113 -0
  37. package/src/roots/guard.mjs +227 -0
  38. package/src/roots/lock-extension.mjs +167 -0
  39. package/src/roots/locks.mjs +167 -0
  40. package/src/roots/paths.mjs +66 -0
  41. package/src/roots/report.ts +156 -0
  42. package/src/roots/runtime.ts +174 -0
  43. package/src/roots/snapshot.mjs +46 -0
  44. package/src/roots/socket-recovery.mjs +21 -0
  45. package/src/roots/transport.mjs +341 -0
  46. package/src/ui/panel.ts +96 -0
  47. package/src/ui/viewer.ts +445 -0
  48. package/src/workbench.mjs +587 -0
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Runs one Program with Pi's `codemode` executor, so the script API, result format, and
3
+ * truncation are Pi's own. Programs add globals such as `agent()`, a `timeout`, and calls
4
+ * that outlive the `program` tool call; `withSandboxExtras` passes them to the sandbox.
5
+ */
6
+ import { AsyncLocalStorage } from "node:async_hooks";
7
+ import type { CodemodeResult, CodemodeTool } from "@earendil-works/pi-codemode";
8
+ import type { AgentToolResult, AgentToolUpdateCallback, ExtensionToolContext } from "@earendil-works/pi-coding-agent";
9
+ import { loadCodemode, loadPiCodemode } from "./codemode.ts";
10
+ import type { DetachedCallOptions } from "./host-patches.ts";
11
+
12
+ export const PROGRAM_TOOL_NAME = "program";
13
+
14
+ export type ProgramOutcome = "completed" | "failed" | "stopped";
15
+
16
+ /** Paths passed to `read`, `write`, and `edit`, which compaction lists like Pi's file operations. */
17
+ export interface ProgramFiles {
18
+ read: string[];
19
+ written: string[];
20
+ edited: string[];
21
+ }
22
+
23
+ export interface ProgramRunResult {
24
+ outcome: ProgramOutcome;
25
+ result: AgentToolResult<unknown> & { isError?: boolean };
26
+ files: ProgramFiles;
27
+ /** Whether some of the Program's work did not stop during cleanup. */
28
+ abandoned?: boolean;
29
+ }
30
+
31
+ export interface ProgramRunOptions {
32
+ /** Seconds until the Program stops; the script's `timeout_ms` option applies as well. */
33
+ timeout?: number;
34
+ /** Globals added to the script, such as the host side of `agent()`. */
35
+ globals?: CodemodeTool[];
36
+ /** Rewrites the code before it runs, such as prepending the `agent()` prelude. */
37
+ prepare?: (code: string) => string;
38
+ onUpdate?: AgentToolUpdateCallback<unknown>;
39
+ /**
40
+ * Whether the Program runs after its tool call returns. Its calls then get their own IDs,
41
+ * since Pi records nested calls only until the calling tool call returns.
42
+ */
43
+ background?: boolean;
44
+ /** Stops a background Program; `ctx.abort()` of its calls' hooks and tools calls it. */
45
+ abort?: () => void;
46
+ appendEntry(customType: string, data: unknown): void;
47
+ getToolNamespace(name: string): { name: string } | undefined;
48
+ }
49
+
50
+ interface SandboxExtras {
51
+ globals: CodemodeTool[];
52
+ prepare: (code: string) => string;
53
+ timeoutMs: number;
54
+ result?: CodemodeResult;
55
+ used: boolean;
56
+ }
57
+
58
+ const sandboxExtras = new AsyncLocalStorage<SandboxExtras>();
59
+ let sandboxPatched = false;
60
+
61
+ /**
62
+ * Pi's executor creates and runs one sandbox per script. Its first execution in `extras`' async
63
+ * context gets the extras; later sandboxes there, such as those of Agents the Program created,
64
+ * do not.
65
+ */
66
+ async function withSandboxExtras<T>(extras: SandboxExtras, run: () => Promise<T>): Promise<T> {
67
+ if (!sandboxPatched) {
68
+ sandboxPatched = true;
69
+ const proto = (await loadCodemode()).CodemodeSandbox.prototype as unknown as Record<string, any>;
70
+ const execute = proto.execute;
71
+ proto.execute = function (this: Record<string, any>, code: string, options: Record<string, unknown> = {}) {
72
+ const current = sandboxExtras.getStore();
73
+ if (!current || current.used) return execute.call(this, code, options);
74
+ current.used = true;
75
+ for (const global of current.globals) this.globalsByName.set(global.name, global);
76
+ const timeoutMs = Math.min(current.timeoutMs, (options.timeoutMs as number | undefined) ?? this.timeoutMs);
77
+ return execute.call(this, current.prepare(code), { ...options, timeoutMs }).then((result: CodemodeResult) => {
78
+ current.result = result;
79
+ return result;
80
+ });
81
+ };
82
+ }
83
+ return sandboxExtras.run(extras, run);
84
+ }
85
+
86
+ /**
87
+ * Runs one Program. The signal stops it; `ctx` must stay usable until the Program ends,
88
+ * which holds after the `program` tool call returns while the Session is loaded.
89
+ */
90
+ export async function executeProgram(
91
+ toolCallId: string,
92
+ source: string,
93
+ signal: AbortSignal,
94
+ ctx: ExtensionToolContext,
95
+ options: ProgramRunOptions,
96
+ ): Promise<ProgramRunResult> {
97
+ const { executeCodemode } = await loadPiCodemode();
98
+ const files: ProgramFiles = { read: [], written: [], edited: [] };
99
+ const addFile = (name: string, args: unknown) => {
100
+ const path = (args as { path?: unknown } | undefined)?.path;
101
+ const list = name === "read" ? files.read : name === "write" ? files.written : name === "edit" ? files.edited : undefined;
102
+ if (typeof path === "string" && list && !list.includes(path)) list.push(path);
103
+ };
104
+ let detachedCalls = 0;
105
+ const programCtx = Object.create(ctx, {
106
+ tools: { get: () => ctx.tools.filter((tool) => tool.name !== PROGRAM_TOOL_NAME) },
107
+ executeTool: {
108
+ value: (name: string, args: unknown, callOptions: { signal?: AbortSignal } = {}) => {
109
+ if (!options.background) return ctx.executeTool(name, args, callOptions);
110
+ addFile(name, args);
111
+ const detachedOptions = { ...callOptions, detachedCallerId: `${toolCallId}:${++detachedCalls}`, detachedAbort: () => options.abort?.() } satisfies DetachedCallOptions;
112
+ return ctx.executeTool(name, args, detachedOptions as never);
113
+ },
114
+ },
115
+ }) as ExtensionToolContext;
116
+ const extras: SandboxExtras = {
117
+ globals: options.globals ?? [],
118
+ prepare: options.prepare ?? ((code) => code),
119
+ timeoutMs: options.timeout === undefined ? Number.POSITIVE_INFINITY : options.timeout * 1000,
120
+ used: false,
121
+ };
122
+ const result = await withSandboxExtras(extras, () => executeCodemode(toolCallId, { code: source }, signal, options.onUpdate, programCtx, {
123
+ models: true,
124
+ appendEntry: options.appendEntry,
125
+ getToolNamespace: options.getToolNamespace,
126
+ }));
127
+ const outcome: ProgramOutcome = extras.result?.ok
128
+ ? "completed"
129
+ : extras.result?.error.kind === "timeout" || extras.result?.error.kind === "aborted" ? "stopped" : "failed";
130
+ return { outcome, result, files };
131
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Patches of Pi internals that background Programs need. They rely on private members of
3
+ * `AgentSession` and `ExtensionRunner`, so check them when Pi is updated.
4
+ */
5
+ import { AsyncLocalStorage } from "node:async_hooks";
6
+ import type { AgentSession, ExtensionRunner } from "@earendil-works/pi-coding-agent";
7
+ import { CODEMODE_TOOL_NAME } from "./codemode.ts";
8
+ import { PROGRAM_TOOL_NAME } from "./execute.ts";
9
+
10
+ const PATCHED = Symbol.for("pi-agents:patched");
11
+ const MCP_EXTENSION_PATH = "builtin:mcp";
12
+
13
+ interface DetachedCall {
14
+ signal?: AbortSignal;
15
+ abort?: () => void;
16
+ active: boolean;
17
+ }
18
+
19
+ const detached = new AsyncLocalStorage<DetachedCall>();
20
+ /** Set while the built-in MCP extension runs, including work it started. */
21
+ const inMcp = new AsyncLocalStorage<true>();
22
+
23
+ /** `ctx.executeTool()` options that run a call outside the calling tool call's record. */
24
+ export interface DetachedCallOptions {
25
+ /** Replaces the calling tool call's ID, so the call gets the ID `<callerId>/1`. */
26
+ detachedCallerId: string;
27
+ /** What `ctx.abort()` of hooks and tools does during the call. */
28
+ detachedAbort: () => void;
29
+ }
30
+
31
+ type Patchable = Record<PropertyKey, any>;
32
+
33
+ /**
34
+ * - A call with `detachedCallerId` runs under that ID: Pi numbers nested calls per caller and
35
+ * resets the numbers when the calling tool call returns and when the turn ends, which would
36
+ * repeat IDs across the calls of a background Program. While it runs, `ctx.signal` and
37
+ * `ctx.abort()` of hooks and tools act on the call instead of the caller's turn.
38
+ * - The built-in MCP extension sees `program` as `codemode`: it activates `program` when its tools
39
+ * need a script tool and neither is active, counts an active `program` as reaching its tools,
40
+ * and makes a Program wait for the servers its code names, as it does for `codemode` scripts.
41
+ */
42
+ export function installHostPatches(
43
+ session: typeof AgentSession,
44
+ runner: typeof ExtensionRunner,
45
+ codemodeSchema: unknown,
46
+ ): void {
47
+ const sessionProto = session.prototype as unknown as Patchable;
48
+ if (!sessionProto[PATCHED]) {
49
+ sessionProto[PATCHED] = true;
50
+ const execute = sessionProto._executeNestedToolCall;
51
+ sessionProto._executeNestedToolCall = async function (this: Patchable, callerId: string, name: string, args: unknown, options: Record<string, unknown> = {}) {
52
+ const { detachedCallerId, detachedAbort, ...rest } = options;
53
+ if (typeof detachedCallerId !== "string") return execute.call(this, callerId, name, args, options);
54
+ const extensions = this._extensionRunner as Patchable;
55
+ const getSignal = extensions.getSignalFn;
56
+ if (!getSignal[PATCHED]) {
57
+ const patched = () => {
58
+ const call = detached.getStore();
59
+ return call?.active ? call.signal : getSignal();
60
+ };
61
+ (patched as Patchable)[PATCHED] = true;
62
+ extensions.getSignalFn = patched;
63
+ }
64
+ const abort = extensions.abortFn;
65
+ if (!abort[PATCHED]) {
66
+ const patched = () => {
67
+ const call = detached.getStore();
68
+ return call?.active ? call.abort?.() : abort();
69
+ };
70
+ (patched as Patchable)[PATCHED] = true;
71
+ extensions.abortFn = patched;
72
+ }
73
+ const call: DetachedCall = { signal: rest.signal as AbortSignal | undefined, abort: detachedAbort as (() => void) | undefined, active: true };
74
+ try {
75
+ return await detached.run(call, () => execute.call(this, detachedCallerId, name, args, rest));
76
+ } finally {
77
+ // Work the call started, such as a turn it queued, keeps the store but not the signal.
78
+ call.active = false;
79
+ }
80
+ };
81
+ }
82
+ const runnerProto = runner.prototype as unknown as Patchable;
83
+ if (!runnerProto[PATCHED]) {
84
+ runnerProto[PATCHED] = true;
85
+ const bindCore = runnerProto.bindCore;
86
+ runnerProto.bindCore = function (this: Patchable, ...args: unknown[]) {
87
+ bindCore.apply(this, args);
88
+ const mcp = (this.extensions as Patchable[]).find((extension) => extension.path === MCP_EXTENSION_PATH);
89
+ if (mcp) aliasProgramForMcp(this, mcp, codemodeSchema);
90
+ };
91
+ }
92
+ }
93
+
94
+ function aliasProgramForMcp(runner: Patchable, mcp: Patchable, codemodeSchema: unknown): void {
95
+ if (!mcp[PATCHED]) {
96
+ mcp[PATCHED] = true;
97
+ const asCodemodeCall = (event: Patchable) => event.toolName === PROGRAM_TOOL_NAME && event.input?.action === "run"
98
+ ? { ...event, toolName: CODEMODE_TOOL_NAME, input: { code: event.input.code } }
99
+ : event;
100
+ for (const [type, handlers] of mcp.handlers as Map<string, Function[]>) {
101
+ handlers.forEach((handler, index) => {
102
+ handlers[index] = (event: Patchable, ctx: unknown) => inMcp.run(true, () => handler(type === "tool_call" ? asCodemodeCall(event) : event, ctx));
103
+ });
104
+ }
105
+ for (const command of (mcp.commands as Map<string, Patchable>).values()) {
106
+ const handler = command.handler;
107
+ command.handler = (args: unknown, ctx: unknown) => inMcp.run(true, () => handler(args, ctx));
108
+ }
109
+ }
110
+ // `bindCore` sets these each time, so wrap the current ones.
111
+ const runtime = runner.runtime as Patchable;
112
+ const { getActiveTools, getAllTools, setActiveTools } = runtime;
113
+ runtime.getActiveTools = () => {
114
+ const active: string[] = getActiveTools();
115
+ return inMcp.getStore() && active.includes(PROGRAM_TOOL_NAME) && !active.includes(CODEMODE_TOOL_NAME) ? [...active, CODEMODE_TOOL_NAME] : active;
116
+ };
117
+ runtime.getAllTools = () => {
118
+ const tools: Patchable[] = getAllTools();
119
+ const program = inMcp.getStore() && tools.find((tool) => tool.name === PROGRAM_TOOL_NAME);
120
+ return program ? [...tools, { ...program, name: CODEMODE_TOOL_NAME, parameters: codemodeSchema }] : tools;
121
+ };
122
+ runtime.setActiveTools = (names: string[]) => {
123
+ if (inMcp.getStore() && names.includes(CODEMODE_TOOL_NAME) && !getActiveTools().includes(CODEMODE_TOOL_NAME)) {
124
+ names = [...new Set(names.map((name) => name === CODEMODE_TOOL_NAME ? PROGRAM_TOOL_NAME : name))];
125
+ }
126
+ return setActiveTools(names);
127
+ };
128
+ }
@@ -0,0 +1,99 @@
1
+ import { highlightCode, type ExtensionAPI, type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { CODEMODE_TOOL_NAME, type PiCodemode } from "./codemode.ts";
4
+ import { PROGRAM_TOOL_NAME } from "./execute.ts";
5
+ import { formatToolCall, moreLines, renderTextResult, startDuration, type DurationState } from "../render-call.ts";
6
+
7
+ /** The first line of the `codemode` description, which the `program` intro replaces. */
8
+ const CODEMODE_FIRST_LINE = "Run JavaScript code to orchestrate/compose tool calls\n";
9
+ const MODEL_API_START = "\n\nModel API:";
10
+ const TOOL_LIST_START = "\n\nNested tools:";
11
+
12
+ type Loadout = Parameters<NonNullable<ToolDefinition["prepareLoadout"]>>[0];
13
+ type Changes = ReturnType<NonNullable<ToolDefinition["prepareLoadout"]>>;
14
+ type Renderers = Pick<ToolDefinition, "renderCall" | "renderResult">;
15
+
16
+ const CODE_PREVIEW_LINES = 10;
17
+
18
+ export const PROGRAM_ONLY_FLAG = "program-only";
19
+ // Owned Agents run in their owner's process without its CLI flags, so the flag holds for the process.
20
+ const PROGRAM_ONLY = Symbol.for("pi-agents:program-only");
21
+
22
+ /** Applies `--program-only` to this process once a Session sees it, and activates `program`. */
23
+ export function applyProgramOnlyFlag(pi: ExtensionAPI): void {
24
+ if (pi.getFlag(PROGRAM_ONLY_FLAG) === true) (globalThis as Record<symbol, unknown>)[PROGRAM_ONLY] = true;
25
+ const active = pi.getActiveTools();
26
+ if (programOnly(pi) && !active.includes(PROGRAM_TOOL_NAME)) pi.setActiveTools([...active, PROGRAM_TOOL_NAME]);
27
+ }
28
+
29
+ function programOnly(pi: ExtensionAPI): boolean {
30
+ return pi.getFlag(PROGRAM_ONLY_FLAG) === true || (globalThis as Record<symbol, unknown>)[PROGRAM_ONLY] === true;
31
+ }
32
+
33
+ function codemodeSettings(pi: ExtensionAPI): { mode: "on" | "only"; inlineBudget?: number } {
34
+ const settings = pi.getSettings().codemode;
35
+ const budget = settings?.inlineBudget;
36
+ return {
37
+ mode: programOnly(pi) || settings?.mode === "only" ? "only" : "on",
38
+ inlineBudget: typeof budget === "number" && Number.isFinite(budget) && budget >= 0 ? budget : undefined,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * The `program` description: the intro, then the `codemode` script API with the Agent API before
44
+ * its Model API, or before its tool list when it has no Model API.
45
+ */
46
+ function describeProgram(intro: string, agentApi: string, codemodeDescription: string): string {
47
+ const description = codemodeDescription.startsWith(CODEMODE_FIRST_LINE) ? codemodeDescription.slice(CODEMODE_FIRST_LINE.length) : codemodeDescription;
48
+ const modelApi = description.indexOf(MODEL_API_START);
49
+ const at = modelApi >= 0 ? modelApi : description.indexOf(TOOL_LIST_START);
50
+ const [before, after] = at < 0 ? [description, ""] : [description.slice(0, at), description.slice(at)];
51
+ return `${intro}\n\n${before}\n\n${agentApi}${after}`;
52
+ }
53
+
54
+ /**
55
+ * Presents the loadout with Pi's `codemode` `prepareLoadout`, following `codemode.mode`, and
56
+ * moves its `codemode` description to `program`.
57
+ */
58
+ export function programLoadout(pi: ExtensionAPI, piCodemode: PiCodemode, intro: string, agentApi: string): (loadout: Loadout) => Changes {
59
+ const { prepareLoadout } = piCodemode.createCodemodeToolDefinition({
60
+ models: true,
61
+ getMode: () => codemodeSettings(pi).mode,
62
+ getInlineBudget: () => codemodeSettings(pi).inlineBudget,
63
+ });
64
+ return (loadout) => {
65
+ const changes = prepareLoadout!(loadout) ?? {};
66
+ const { [CODEMODE_TOOL_NAME]: codemodeDescription, ...descriptions }: Record<string, string> = { ...changes.descriptions };
67
+ if (codemodeDescription !== undefined) descriptions[PROGRAM_TOOL_NAME] = describeProgram(intro, agentApi, codemodeDescription);
68
+ return { descriptions, hiddenDeclarations: changes.hiddenDeclarations };
69
+ };
70
+ }
71
+
72
+ /** The program description before the loadout is prepared. */
73
+ export function programDescription(piCodemode: PiCodemode, intro: string, agentApi: string): string {
74
+ return describeProgram(intro, agentApi, piCodemode.createCodemodeDescription([], { models: true }));
75
+ }
76
+
77
+ /** Calls render in function-call form, with the `run` script below it. Results render like `codemode`. */
78
+ export function programRenderers(piCodemode: PiCodemode): Renderers {
79
+ return {
80
+ renderCall(args, theme, context) {
81
+ const { code, ...rest } = (args ?? {}) as Record<string, unknown>;
82
+ startDuration(context.state as DurationState, rest.action === "wait", context.executionStarted);
83
+ let text = formatToolCall(PROGRAM_TOOL_NAME, rest, theme);
84
+ if (typeof code === "string" && code) {
85
+ const lines = highlightCode(code.replace(/\r/g, "").replace(/\t/g, " ").trimEnd(), "javascript");
86
+ const shown = context.expanded ? lines : lines.slice(0, CODE_PREVIEW_LINES);
87
+ text += `\n${shown.join("\n")}`;
88
+ if (shown.length < lines.length) text += `\n${moreLines(lines.length - shown.length, theme)}`;
89
+ }
90
+ const component = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
91
+ component.setText(text);
92
+ return component;
93
+ },
94
+ // `wait` shows how long it has waited; the other results render like `codemode`.
95
+ renderResult: (result, options, theme, context) => (context.args as { action?: string }).action === "wait"
96
+ ? renderTextResult(result, options, theme, context)
97
+ : piCodemode.renderResult(result, options, theme, context) as Text,
98
+ };
99
+ }
@@ -0,0 +1,284 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { deferred, settlesBy, waitForChange, type Deferred } from "../agents/agents.ts";
4
+ import { shortId, trackTreeWork } from "../agents/registry.ts";
5
+ import { boundBlocks, boundText } from "../output.ts";
6
+ import type { ProgramFiles, ProgramOutcome, ProgramRunResult } from "./execute.ts";
7
+
8
+ const PROGRAM_ENTRY = "pi-agents-program";
9
+ const CLEANUP_TIMEOUT_MS = 15_000;
10
+ /** Ends the result of a Program some of whose work did not stop. */
11
+ export const CLEANUP_ERROR = "Cleanup error: some of the Program's work did not stop within the timeout and may still be running.";
12
+
13
+ type Item = ProgramRunResult["result"]["content"][number];
14
+
15
+ export type ProgramState = "running" | ProgramOutcome;
16
+
17
+ export interface ProgramRecord {
18
+ id: string;
19
+ state: ProgramState;
20
+ startedAt: number;
21
+ content?: Item[];
22
+ isError?: boolean;
23
+ /** Files the Program's tool calls used, for compaction. */
24
+ files?: ProgramFiles;
25
+ /** Whether `wait` has returned the result. */
26
+ returned: boolean;
27
+ notified: boolean;
28
+ }
29
+
30
+ interface Running {
31
+ record: ProgramRecord;
32
+ controller?: AbortController;
33
+ done?: Promise<void>;
34
+ /** Stops counting the Program as the tree's work. */
35
+ release?: () => void;
36
+ /** Whether some of its work did not stop. */
37
+ abandoned?: boolean;
38
+ waiters: number;
39
+ }
40
+
41
+ export interface ProgramWaitOutcome {
42
+ results: ProgramRecord[];
43
+ running: ProgramRecord[];
44
+ }
45
+
46
+ function ended(record: ProgramRecord): boolean {
47
+ return record.state !== "running";
48
+ }
49
+
50
+ /** The text of a result: the "Script completed" header, then the output items on their own lines. */
51
+ function textOf(content: Item[] | undefined): string {
52
+ const [header = "", ...rest] = (content ?? []).flatMap((item) => item.type === "text" ? [item.text] : []);
53
+ return header.endsWith("\n") ? header + rest.join("\n") : [header, ...rest].join("\n");
54
+ }
55
+
56
+ /** A caller's background Programs: they run on after `run` returns and stop when the caller goes offline. */
57
+ export class Programs {
58
+ private programs = new Map<string, Running>();
59
+ private change: Deferred = deferred();
60
+ private closing = false;
61
+
62
+ constructor(
63
+ private rootId: string,
64
+ private hooks: {
65
+ appendEntry(customType: string, data: unknown): void;
66
+ notify(text: string): void;
67
+ /** Called when a Program starts, ends, or has its result returned. */
68
+ changed?(): void;
69
+ },
70
+ ) {}
71
+
72
+ restore(ctx: ExtensionContext): void {
73
+ for (const entry of ctx.sessionManager.getEntries()) {
74
+ if (entry.type !== "custom" || entry.customType !== PROGRAM_ENTRY) continue;
75
+ const record = entry.data as ProgramRecord;
76
+ this.programs.set(record.id, { record, waiters: 0 });
77
+ }
78
+ for (const program of this.programs.values()) {
79
+ const { record } = program;
80
+ if (ended(record)) continue;
81
+ record.state = "stopped";
82
+ record.content = [{ type: "text", text: "Script failed\nOutput:\nScript error:\nScript aborted: the caller went offline" }];
83
+ record.isError = true;
84
+ record.notified = true;
85
+ this.persist(record);
86
+ }
87
+ }
88
+
89
+ /** Starts a background Program, which `run` executes with its ID, and returns its record. */
90
+ start(run: (id: string, signal: AbortSignal, abort: () => void) => Promise<ProgramRunResult>): ProgramRecord {
91
+ const record: ProgramRecord = { id: randomUUID(), state: "running", startedAt: Date.now(), returned: false, notified: false };
92
+ const controller = new AbortController();
93
+ const program: Running = { record, controller, waiters: 0 };
94
+ this.programs.set(record.id, program);
95
+ // The tree stays busy until the Program ends and its notification is delivered.
96
+ const release = program.release = trackTreeWork(this.rootId);
97
+ this.persist(record);
98
+ this.hooks.changed?.();
99
+ program.done = run(record.id, controller.signal, () => controller.abort(new Error("Program stopped"))).then(
100
+ ({ outcome, result, files, abandoned }) => {
101
+ if (abandoned) program.abandoned = true;
102
+ this.end(program, outcome, result.content, result.isError === true, files);
103
+ },
104
+ (error: unknown) => this.end(program, "failed", [{ type: "text", text: `Script failed\nOutput:\nScript error:\n${error instanceof Error ? error.message : String(error)}` }], true),
105
+ ).finally(release);
106
+ return record;
107
+ }
108
+
109
+ /** Resolves an ID or a unique ID prefix. */
110
+ target(id: string): Running {
111
+ const exact = this.programs.get(id);
112
+ if (exact) return exact;
113
+ const matches = [...this.programs.values()].filter(({ record }) => record.id.startsWith(id));
114
+ if (matches.length === 0) throw new Error(`No Program matches "${id}". Use list to find Programs.`);
115
+ if (matches.length > 1) {
116
+ throw new Error(`"${id}" matches several Programs: ${matches.map(({ record }) => this.label(record)).join(", ")}. Retry with a longer ID prefix.`);
117
+ }
118
+ return matches[0]!;
119
+ }
120
+
121
+ ids(): string[] {
122
+ return [...this.programs.keys()];
123
+ }
124
+
125
+ /** A Program's short ID among this caller's Programs. */
126
+ label(record: ProgramRecord): string {
127
+ return shortId(record.id, this.ids());
128
+ }
129
+
130
+ list(): ProgramRecord[] {
131
+ return [...this.programs.values()].map((program) => program.record).sort((a, b) => b.startedAt - a.startedAt);
132
+ }
133
+
134
+ /**
135
+ * Returns whether the Program was running; it has ended when this resolves. A Program that has
136
+ * not stopped within the timeout is abandoned and ends as stopped; this throws when some of its
137
+ * work did not stop.
138
+ */
139
+ async stop(program: Running, reason = "Program stopped"): Promise<boolean> {
140
+ if (ended(program.record)) return false;
141
+ program.controller?.abort(new Error(reason));
142
+ if (!await settlesBy(program.done!, Date.now() + CLEANUP_TIMEOUT_MS)) {
143
+ program.abandoned = true;
144
+ this.end(program, "stopped", [{ type: "text", text: "Script failed\nOutput:\n" }, { type: "text", text: CLEANUP_ERROR }], true);
145
+ program.release?.();
146
+ }
147
+ if (program.abandoned) throw new Error(`Program ${this.label(program.record)} did not stop within the timeout and may still be running.`);
148
+ return true;
149
+ }
150
+
151
+ /** The given Programs, or running Programs and ended ones whose result has not been returned. */
152
+ select(ids: string[] | undefined): Running[] {
153
+ return [...new Set(ids?.map((id) => this.target(id)) ?? [...this.programs.values()].filter(({ record }) => !ended(record) || !record.returned))];
154
+ }
155
+
156
+ running(programs: Running[]): boolean {
157
+ return programs.some(({ record }) => !ended(record));
158
+ }
159
+
160
+ async wait(unique: Running[], timeoutSeconds: number, signal?: AbortSignal): Promise<ProgramWaitOutcome> {
161
+ for (const program of unique) program.waiters += 1;
162
+ let returned = false;
163
+ try {
164
+ const deadline = Date.now() + timeoutSeconds * 1000;
165
+ while (unique.some(({ record }) => !ended(record))) {
166
+ const remaining = deadline - Date.now();
167
+ if (remaining <= 0) break;
168
+ signal?.throwIfAborted();
169
+ await waitForChange(this.change.promise, remaining, signal);
170
+ }
171
+ signal?.throwIfAborted();
172
+ returned = true;
173
+ return {
174
+ results: unique.filter(({ record }) => ended(record)).map(({ record }) => record),
175
+ running: unique.filter(({ record }) => !ended(record)).map(({ record }) => record),
176
+ };
177
+ } finally {
178
+ for (const program of unique) {
179
+ program.waiters -= 1;
180
+ const { record } = program;
181
+ if (!ended(record) || record.notified) continue;
182
+ if (returned) {
183
+ record.notified = true;
184
+ this.persist(record);
185
+ } else if (program.waiters === 0) {
186
+ this.notify(record);
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ markReturned(records: ProgramRecord[]): void {
193
+ for (const record of records) {
194
+ if (record.returned) continue;
195
+ record.returned = true;
196
+ this.persist(record);
197
+ }
198
+ this.hooks.changed?.();
199
+ }
200
+
201
+ async shutdown(): Promise<void> {
202
+ this.closing = true;
203
+ const stopped = await Promise.allSettled([...this.programs.values()].map((program) => this.stop(program, "the caller went offline")));
204
+ const errors = stopped.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
205
+ if (errors.length > 0) throw new Error(errors.map((error) => error instanceof Error ? error.message : String(error)).join("\n"));
206
+ }
207
+
208
+ private end(program: Running, outcome: ProgramOutcome, content: Item[], isError: boolean, files?: ProgramFiles): void {
209
+ const { record } = program;
210
+ // An abandoned Program may end later.
211
+ if (ended(record)) return;
212
+ record.state = outcome;
213
+ record.content = content;
214
+ record.isError = isError;
215
+ if (files && (files.read.length > 0 || files.written.length > 0 || files.edited.length > 0)) record.files = files;
216
+ program.controller = undefined;
217
+ this.persist(record);
218
+ if (program.waiters === 0) this.notify(record);
219
+ const previous = this.change;
220
+ this.change = deferred();
221
+ previous.resolve();
222
+ this.hooks.changed?.();
223
+ }
224
+
225
+ private notify(record: ProgramRecord): void {
226
+ if (this.closing || record.notified) return;
227
+ record.notified = true;
228
+ this.persist(record);
229
+ this.hooks.notify(`Program ${this.label(record)} ${record.state}.`);
230
+ }
231
+
232
+ private persist(record: ProgramRecord): void {
233
+ this.hooks.appendEntry(PROGRAM_ENTRY, record);
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Adds the files of the background Programs that ended in the entries compaction summarizes: after
239
+ * the previous compaction's kept entries start and before `firstKeptEntryId`. Pi finds file
240
+ * operations in the nested calls of tool results, which do not hold calls made after `run` returns.
241
+ */
242
+ export function addProgramFiles(
243
+ entries: Array<{ id: string; type: string; customType?: string; data?: unknown; firstKeptEntryId?: string }>,
244
+ firstKeptEntryId: string,
245
+ fileOps: { read: Set<string>; written: Set<string>; edited: Set<string> },
246
+ ): void {
247
+ const end = entries.findIndex((entry) => entry.id === firstKeptEntryId);
248
+ const previous = entries.findLast((entry) => entry.type === "compaction");
249
+ const start = previous ? Math.max(0, entries.findIndex((entry) => entry.id === previous.firstKeptEntryId)) : 0;
250
+ for (const entry of entries.slice(start, end < 0 ? undefined : end)) {
251
+ if (entry.type !== "custom" || entry.customType !== PROGRAM_ENTRY) continue;
252
+ const files = (entry.data as ProgramRecord).files;
253
+ for (const path of files?.read ?? []) fileOps.read.add(path);
254
+ for (const path of files?.written ?? []) fileOps.written.add(path);
255
+ for (const path of files?.edited ?? []) fileOps.edited.add(path);
256
+ }
257
+ }
258
+
259
+ function resultBlock(record: ProgramRecord, ids: string[]): string {
260
+ return `<program-result id="${shortId(record.id, ids)}" status="${record.state}">\n${textOf(record.content)}\n</program-result>`;
261
+ }
262
+
263
+ /** Renders wait results; returns the content and the results the caller has now received. */
264
+ export function renderProgramWait({ results, running }: ProgramWaitOutcome, ids: string[]): { content: Item[]; returned: ProgramRecord[]; isError?: boolean } {
265
+ const runningLine = running.length > 0 ? `Still running: ${running.map((record) => shortId(record.id, ids)).join(", ")}` : "";
266
+ if (results.length === 0) return { content: [{ type: "text", text: runningLine || "No results." }], returned: [] };
267
+ const images = (shown: ProgramRecord[]) => shown.flatMap((record) => (record.content ?? []).filter((item) => item.type === "image"));
268
+ const only = results[0]!;
269
+ if (results.length === 1 && running.length === 0 && only.state === "completed") {
270
+ const bounded = boundText(textOf(only.content), "pi-agents-program-wait");
271
+ return { content: [{ type: "text", text: bounded.text }, ...images(results)], returned: results };
272
+ }
273
+ const bounded = boundBlocks(results.map((record) => resultBlock(record, ids)), "program-result", "pi-agents-program-wait");
274
+ let text = bounded.text;
275
+ const returned = results.slice(0, bounded.shown);
276
+ const omitted = results.slice(bounded.shown);
277
+ if (omitted.length > 0) text += `\n\n[Results omitted: ${omitted.map((record) => shortId(record.id, ids)).join(", ")}. Use wait with fewer targets.]`;
278
+ if (runningLine) text += `\n\n${runningLine}`;
279
+ return { content: [{ type: "text", text }, ...images(returned)], returned };
280
+ }
281
+
282
+ export function programListLine(record: ProgramRecord, ids: string[]): string {
283
+ return `${shortId(record.id, ids)} ${record.state}`;
284
+ }