@tshu-w/pi-agents 0.1.1 → 0.2.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.
@@ -10,6 +10,8 @@ import { loadCodemode, loadPiCodemode } from "./codemode.ts";
10
10
  import type { DetachedCallOptions } from "./host-patches.ts";
11
11
 
12
12
  export const PROGRAM_TOOL_NAME = "program";
13
+ /** How long a Program's work may take to stop once it ends. */
14
+ export const CLEANUP_TIMEOUT_MS = 15_000;
13
15
 
14
16
  export type ProgramOutcome = "completed" | "failed" | "stopped";
15
17
 
@@ -20,28 +22,70 @@ export interface ProgramFiles {
20
22
  edited: string[];
21
23
  }
22
24
 
25
+ /** A call in codemode's `details.calls`. */
26
+ interface CallRecord {
27
+ id: string;
28
+ name: string;
29
+ args: string;
30
+ status: "running" | "ok" | "error" | "cancelled";
31
+ durationMs?: number;
32
+ error?: string;
33
+ }
34
+
35
+ /**
36
+ * Records calls that a Program's own globals make, which codemode does not see, beside its tool calls.
37
+ * `start` returns the function that ends the call; a call still running when the script ends is cancelled.
38
+ */
39
+ export type CallTracker = (name: string, args: string) => (status: "ok" | "error" | "cancelled", error?: string) => void;
40
+
41
+ const ARGS_PREVIEW_CHARS = 200;
42
+ const ERROR_PREVIEW_CHARS = 500;
43
+ const NOTE_NAMES = 10;
44
+ const NOTE_NAME_CHARS = 60;
45
+
46
+ const preview = (text: string, max: number) => text.length > max ? `${text.slice(0, max - 3)}...` : text;
47
+
48
+ /** Names of calls for a note: at most `NOTE_NAMES` of them, each cut to `NOTE_NAME_CHARS`, since scripts name Agents. */
49
+ function callNames(calls: CallRecord[], status: boolean): string {
50
+ const shown = calls.slice(0, NOTE_NAMES).map((call) => {
51
+ const name = preview(call.name, NOTE_NAME_CHARS);
52
+ return status ? `${name} (${call.status})` : name;
53
+ });
54
+ return calls.length > NOTE_NAMES ? `${shown.join(", ")}, and ${calls.length - NOTE_NAMES} more` : shown.join(", ");
55
+ }
56
+
57
+ /** Adds a note after a blank line to the last text item, or as a new item after images. */
58
+ function appendNote(content: AgentToolResult<unknown>["content"], note: string): void {
59
+ const last = content.at(-1);
60
+ if (last?.type === "text") last.text += `\n\n${note}`;
61
+ else content.push({ type: "text", text: note });
62
+ }
63
+
23
64
  export interface ProgramRunResult {
24
65
  outcome: ProgramOutcome;
25
66
  result: AgentToolResult<unknown> & { isError?: boolean };
26
67
  files: ProgramFiles;
27
68
  /** Whether some of the Program's work did not stop during cleanup. */
28
69
  abandoned?: boolean;
70
+ /** Settles once the tool calls the script started have ended, which may be after it ends. */
71
+ toolCalls: Promise<unknown>;
29
72
  }
30
73
 
31
74
  export interface ProgramRunOptions {
32
75
  /** Seconds until the Program stops; the script's `timeout_ms` option applies as well. */
33
76
  timeout?: number;
34
- /** Globals added to the script, such as the host side of `agent()`. */
35
- globals?: CodemodeTool[];
77
+ /** Globals added to the script, such as the host side of `agent()`; `track` records their calls. */
78
+ globals?: (track: CallTracker) => CodemodeTool[];
36
79
  /** Rewrites the code before it runs, such as prepending the `agent()` prelude. */
37
80
  prepare?: (code: string) => string;
38
81
  onUpdate?: AgentToolUpdateCallback<unknown>;
39
82
  /**
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.
83
+ * Whether the Program runs outside a tool call: after its tool call returns, or from `/program`.
84
+ * Its calls then get their own IDs, since Pi records nested calls only until the calling tool
85
+ * call returns.
42
86
  */
43
- background?: boolean;
44
- /** Stops a background Program; `ctx.abort()` of its calls' hooks and tools calls it. */
87
+ detached?: boolean;
88
+ /** Stops a detached Program; `ctx.abort()` of its calls' hooks and tools calls it. */
45
89
  abort?: () => void;
46
90
  appendEntry(customType: string, data: unknown): void;
47
91
  getToolNamespace(name: string): { name: string } | undefined;
@@ -53,10 +97,32 @@ interface SandboxExtras {
53
97
  timeoutMs: number;
54
98
  result?: CodemodeResult;
55
99
  used: boolean;
100
+ /** Called when the script ends, before the sandbox closes. */
101
+ ended(): void;
56
102
  }
57
103
 
58
104
  const sandboxExtras = new AsyncLocalStorage<SandboxExtras>();
59
- let sandboxPatched = false;
105
+ let sandboxPatch: Promise<void> | undefined;
106
+
107
+ async function patchSandbox(): Promise<void> {
108
+ const proto = (await loadCodemode()).CodemodeSandbox.prototype as unknown as Record<string, any>;
109
+ const execute = proto.execute;
110
+ proto.execute = function (this: Record<string, any>, code: string, options: Record<string, unknown> = {}) {
111
+ const current = sandboxExtras.getStore();
112
+ if (!current || current.used) return execute.call(this, code, options);
113
+ current.used = true;
114
+ for (const global of current.globals) this.globalsByName.set(global.name, global);
115
+ const timeoutMs = Math.min(current.timeoutMs, (options.timeoutMs as number | undefined) ?? this.timeoutMs);
116
+ return execute.call(this, current.prepare(code), { ...options, timeoutMs }).then((result: CodemodeResult) => {
117
+ current.result = result;
118
+ current.ended();
119
+ return result;
120
+ }, (error: unknown) => {
121
+ current.ended();
122
+ throw error;
123
+ });
124
+ };
125
+ }
60
126
 
61
127
  /**
62
128
  * Pi's executor creates and runs one sandbox per script. Its first execution in `extras`' async
@@ -64,22 +130,8 @@ let sandboxPatched = false;
64
130
  * do not.
65
131
  */
66
132
  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
- }
133
+ // Programs started together wait for the same patch.
134
+ await (sandboxPatch ??= patchSandbox());
83
135
  return sandboxExtras.run(extras, run);
84
136
  }
85
137
 
@@ -102,30 +154,94 @@ export async function executeProgram(
102
154
  if (typeof path === "string" && list && !list.includes(path)) list.push(path);
103
155
  };
104
156
  let detachedCalls = 0;
157
+ const toolCalls: Promise<unknown>[] = [];
158
+ const executeTool = (name: string, args: unknown, callOptions: { signal?: AbortSignal }) => {
159
+ if (!options.detached) return ctx.executeTool(name, args, callOptions);
160
+ addFile(name, args);
161
+ const detachedOptions = { ...callOptions, detachedCallerId: `${toolCallId}:${++detachedCalls}`, detachedAbort: () => options.abort?.() } satisfies DetachedCallOptions;
162
+ return ctx.executeTool(name, args, detachedOptions as never);
163
+ };
105
164
  const programCtx = Object.create(ctx, {
106
165
  tools: { get: () => ctx.tools.filter((tool) => tool.name !== PROGRAM_TOOL_NAME) },
107
166
  executeTool: {
108
167
  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);
168
+ const call = executeTool(name, args, callOptions);
169
+ toolCalls.push(call);
170
+ return call;
113
171
  },
114
172
  },
115
173
  }) as ExtensionToolContext;
174
+ // The globals' calls, each placed after the codemode calls seen when it started.
175
+ const own: Array<{ record: CallRecord; after: number; startedAt: number }> = [];
176
+ let seen: CallRecord[] = [];
177
+ let last: Parameters<AgentToolUpdateCallback<unknown>>[0] = { content: [], details: undefined };
178
+ let scriptEnded = false;
179
+ const merged = (): CallRecord[] => {
180
+ const calls: CallRecord[] = [];
181
+ let next = 0;
182
+ for (const { record, after } of own) {
183
+ while (next < after && next < seen.length) calls.push({ ...seen[next++]! });
184
+ calls.push({ ...record });
185
+ }
186
+ while (next < seen.length) calls.push({ ...seen[next++]! });
187
+ return calls;
188
+ };
189
+ const publish = () => options.onUpdate?.({ ...last, details: { ...(last.details as object | undefined), calls: merged() } });
190
+ const onUpdate: AgentToolUpdateCallback<unknown> = (update) => {
191
+ last = update;
192
+ seen = (update.details as { calls?: CallRecord[] } | undefined)?.calls ?? seen;
193
+ publish();
194
+ };
195
+ const finish = (entry: (typeof own)[number], status: "ok" | "error" | "cancelled", error?: string) => {
196
+ if (entry.record.status !== "running") return;
197
+ entry.record.status = status;
198
+ entry.record.durationMs = performance.now() - entry.startedAt;
199
+ if (error !== undefined) entry.record.error = preview(error, ERROR_PREVIEW_CHARS);
200
+ publish();
201
+ };
202
+ const track: CallTracker = (name, args) => {
203
+ const entry = { record: { id: `${toolCallId}/${name}-${own.length + 1}`, name, args: preview(args, ARGS_PREVIEW_CHARS), status: "running" as const }, after: seen.length, startedAt: performance.now() };
204
+ own.push(entry);
205
+ if (scriptEnded) finish(entry, "cancelled");
206
+ else publish();
207
+ return (status, error) => finish(entry, status, error);
208
+ };
209
+ // Calls still running when the script ends are cut off by it.
210
+ let cutOff: CallRecord[] = [];
116
211
  const extras: SandboxExtras = {
117
- globals: options.globals ?? [],
212
+ globals: options.globals?.(track) ?? [],
118
213
  prepare: options.prepare ?? ((code) => code),
119
214
  timeoutMs: options.timeout === undefined ? Number.POSITIVE_INFINITY : options.timeout * 1000,
120
215
  used: false,
216
+ ended: () => {
217
+ scriptEnded = true;
218
+ const running = own.filter((entry) => entry.record.status === "running");
219
+ for (const entry of running) finish(entry, "cancelled");
220
+ cutOff = running.map((entry) => entry.record);
221
+ },
121
222
  };
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
- }));
223
+ let result: Awaited<ReturnType<typeof executeCodemode>>;
224
+ try {
225
+ result = await withSandboxExtras(extras, () => executeCodemode(toolCallId, { code: source }, signal, onUpdate, programCtx, {
226
+ models: true,
227
+ appendEntry: options.appendEntry,
228
+ getToolNamespace: options.getToolNamespace,
229
+ }));
230
+ } finally {
231
+ if (!scriptEnded) extras.ended();
232
+ }
127
233
  const outcome: ProgramOutcome = extras.result?.ok
128
234
  ? "completed"
129
235
  : extras.result?.error.kind === "timeout" || extras.result?.error.kind === "aborted" ? "stopped" : "failed";
130
- return { outcome, result, files };
236
+ // codemode marks calls cut off by the script ending only in its result, after the last update.
237
+ seen = (result.details as { calls?: CallRecord[] } | undefined)?.calls ?? seen;
238
+ if (own.length > 0) result.details = { ...(result.details as object | undefined), calls: merged() };
239
+ // codemode lists the tool calls made before a failure; the globals' calls get a line of their own.
240
+ if (outcome !== "completed" && own.length > 0) {
241
+ appendNote(result.content, `Agent calls made before the failure (they are not undone): ${callNames(own.map((entry) => entry.record), true)}`);
242
+ } else if (outcome === "completed") {
243
+ const stopped = [...seen.filter((call) => call.status === "cancelled"), ...cutOff];
244
+ if (stopped.length > 0) appendNote(result.content, `Calls still running when the script returned were cancelled; their effects are not undone: ${callNames(stopped, false)}`);
245
+ }
246
+ return { outcome, result, files, toolCalls: Promise.allSettled(toolCalls) };
131
247
  }
@@ -16,7 +16,10 @@ interface DetachedCall {
16
16
  active: boolean;
17
17
  }
18
18
 
19
- const detached = new AsyncLocalStorage<DetachedCall>();
19
+ // These two are shared with later copies of this module, such as after a reload, since the patches stay those of the first copy.
20
+ const detached = ((globalThis as Record<symbol, unknown>)[Symbol.for("pi-agents:detached")] ??= new AsyncLocalStorage<DetachedCall>()) as AsyncLocalStorage<DetachedCall>;
21
+ /** The Session whose extension command is running, including work the command started. */
22
+ const inCommand = ((globalThis as Record<symbol, unknown>)[Symbol.for("pi-agents:in-command")] ??= new AsyncLocalStorage<object>()) as AsyncLocalStorage<object>;
20
23
  /** Set while the built-in MCP extension runs, including work it started. */
21
24
  const inMcp = new AsyncLocalStorage<true>();
22
25
 
@@ -30,11 +33,54 @@ export interface DetachedCallOptions {
30
33
 
31
34
  type Patchable = Record<PropertyKey, any>;
32
35
 
36
+ /** The Session running the current extension command. */
37
+ export function commandSession(): Patchable | undefined {
38
+ return inCommand.getStore() as Patchable | undefined;
39
+ }
40
+
41
+ /** Runs `work` as a detached call: `ctx.signal` and `ctx.abort()` of the Session's hooks and tools act on `signal` and `abort`. */
42
+ export async function runDetached<T>(session: Patchable, signal: AbortSignal | undefined, abort: (() => void) | undefined, work: () => Promise<T>): Promise<T> {
43
+ const extensions = session._extensionRunner as Patchable;
44
+ const getSignal = extensions.getSignalFn;
45
+ if (!getSignal[PATCHED]) {
46
+ const patched = () => {
47
+ const call = detached.getStore();
48
+ return call?.active ? call.signal : getSignal();
49
+ };
50
+ (patched as Patchable)[PATCHED] = true;
51
+ extensions.getSignalFn = patched;
52
+ }
53
+ const abortTurn = extensions.abortFn;
54
+ if (!abortTurn[PATCHED]) {
55
+ const patched = () => {
56
+ const call = detached.getStore();
57
+ return call?.active ? call.abort?.() : abortTurn();
58
+ };
59
+ (patched as Patchable)[PATCHED] = true;
60
+ extensions.abortFn = patched;
61
+ }
62
+ const call: DetachedCall = { signal, abort, active: true };
63
+ try {
64
+ return await detached.run(call, work);
65
+ } finally {
66
+ // Work the call started, such as a turn it queued, keeps the store but not the signal.
67
+ call.active = false;
68
+ }
69
+ }
70
+
71
+ /** Stands in for the assistant message Pi passes to hooks of nested calls, which a command's Session may lack. */
72
+ function commandMessage() {
73
+ const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
74
+ return { role: "assistant", content: [], api: "none", provider: "none", model: "none", usage, stopReason: "stop", timestamp: Date.now() };
75
+ }
76
+
33
77
  /**
34
78
  * - A call with `detachedCallerId` runs under that ID: Pi numbers nested calls per caller and
35
79
  * resets the numbers when the calling tool call returns and when the turn ends, which would
36
80
  * repeat IDs across the calls of a background Program. While it runs, `ctx.signal` and
37
81
  * `ctx.abort()` of hooks and tools act on the call instead of the caller's turn.
82
+ * - An extension command knows its Session through `commandSession()`, and its detached calls run
83
+ * before the Session has an assistant message, which Pi requires of nested calls.
38
84
  * - The built-in MCP extension sees `program` as `codemode`: it activates `program` when its tools
39
85
  * need a script tool and neither is active, counts an active `program` as reaching its tools,
40
86
  * and makes a Program wait for the servers its code names, as it does for `codemode` scripts.
@@ -47,36 +93,19 @@ export function installHostPatches(
47
93
  const sessionProto = session.prototype as unknown as Patchable;
48
94
  if (!sessionProto[PATCHED]) {
49
95
  sessionProto[PATCHED] = true;
96
+ const tryCommand = sessionProto._tryExecuteExtensionCommand;
97
+ sessionProto._tryExecuteExtensionCommand = function (this: Patchable, text: string) {
98
+ return inCommand.run(this, () => tryCommand.call(this, text));
99
+ };
100
+ const findAssistant = sessionProto._findLastAssistantMessage;
101
+ sessionProto._findLastAssistantMessage = function (this: Patchable) {
102
+ return findAssistant.call(this) ?? (inCommand.getStore() === this && detached.getStore()?.active ? commandMessage() : undefined);
103
+ };
50
104
  const execute = sessionProto._executeNestedToolCall;
51
105
  sessionProto._executeNestedToolCall = async function (this: Patchable, callerId: string, name: string, args: unknown, options: Record<string, unknown> = {}) {
52
106
  const { detachedCallerId, detachedAbort, ...rest } = options;
53
107
  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
- }
108
+ return runDetached(this, rest.signal as AbortSignal | undefined, detachedAbort as (() => void) | undefined, () => execute.call(this, detachedCallerId, name, args, rest));
80
109
  };
81
110
  }
82
111
  const runnerProto = runner.prototype as unknown as Patchable;
@@ -1,12 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { deferred, settlesBy, waitForChange, type Deferred } from "../agents/agents.ts";
3
+ import { deferred, settlesBy, STEER_POLL_MS, WAIT_INTERRUPTED, waitForChange, type Deferred } from "../agents/agents.ts";
4
4
  import { shortId, trackTreeWork } from "../agents/registry.ts";
5
5
  import { boundBlocks, boundText } from "../output.ts";
6
- import type { ProgramFiles, ProgramOutcome, ProgramRunResult } from "./execute.ts";
6
+ import { CLEANUP_TIMEOUT_MS, PROGRAM_TOOL_NAME, type ProgramFiles, type ProgramOutcome, type ProgramRunResult } from "./execute.ts";
7
7
 
8
8
  const PROGRAM_ENTRY = "pi-agents-program";
9
- const CLEANUP_TIMEOUT_MS = 15_000;
10
9
  /** Ends the result of a Program some of whose work did not stop. */
11
10
  export const CLEANUP_ERROR = "Cleanup error: some of the Program's work did not stop within the timeout and may still be running.";
12
11
 
@@ -22,13 +21,25 @@ export interface ProgramRecord {
22
21
  isError?: boolean;
23
22
  /** Files the Program's tool calls used, for compaction. */
24
23
  files?: ProgramFiles;
24
+ /** The tool calls its result lists, which the task viewer shows after a restart. */
25
+ calls?: unknown[];
25
26
  /** Whether `wait` has returned the result. */
26
27
  returned: boolean;
27
28
  notified: boolean;
28
29
  }
29
30
 
31
+ /** What the task viewer shows of a Program, as its tool call shows in the foreground. */
32
+ export interface ProgramView {
33
+ record: ProgramRecord;
34
+ code?: string;
35
+ /** The latest partial result while it runs, then its result. */
36
+ result?: ProgramRunResult["result"];
37
+ }
38
+
30
39
  interface Running {
31
40
  record: ProgramRecord;
41
+ code?: string;
42
+ result?: ProgramRunResult["result"];
32
43
  controller?: AbortController;
33
44
  done?: Promise<void>;
34
45
  /** Stops counting the Program as the tree's work. */
@@ -41,6 +52,8 @@ interface Running {
41
52
  export interface ProgramWaitOutcome {
42
53
  results: ProgramRecord[];
43
54
  running: ProgramRecord[];
55
+ /** A steer ended the wait. */
56
+ interrupted?: boolean;
44
57
  }
45
58
 
46
59
  function ended(record: ProgramRecord): boolean {
@@ -58,6 +71,7 @@ export class Programs {
58
71
  private programs = new Map<string, Running>();
59
72
  private change: Deferred = deferred();
60
73
  private closing = false;
74
+ private viewers = new Set<() => void>();
61
75
 
62
76
  constructor(
63
77
  private rootId: string,
@@ -70,11 +84,27 @@ export class Programs {
70
84
  ) {}
71
85
 
72
86
  restore(ctx: ExtensionContext): void {
87
+ // A Program's code is in the `run` call that started it, whose result names the Program.
88
+ const codes = new Map<string, string>();
89
+ const callIds = new Map<string, string>();
73
90
  for (const entry of ctx.sessionManager.getEntries()) {
91
+ if (entry.type === "message" && entry.message.role === "assistant") {
92
+ for (const part of entry.message.content) {
93
+ const code = part.type === "toolCall" && part.name === PROGRAM_TOOL_NAME ? (part.arguments as { code?: unknown }).code : undefined;
94
+ if (typeof code === "string") codes.set((part as { id: string }).id, code);
95
+ }
96
+ } else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === PROGRAM_TOOL_NAME) {
97
+ const id = (entry.message.details as { id?: unknown } | undefined)?.id;
98
+ if (typeof id === "string") callIds.set(id, entry.message.toolCallId);
99
+ }
74
100
  if (entry.type !== "custom" || entry.customType !== PROGRAM_ENTRY) continue;
75
101
  const record = entry.data as ProgramRecord;
76
102
  this.programs.set(record.id, { record, waiters: 0 });
77
103
  }
104
+ for (const program of this.programs.values()) {
105
+ const callId = callIds.get(program.record.id);
106
+ program.code = callId === undefined ? undefined : codes.get(callId);
107
+ }
78
108
  for (const program of this.programs.values()) {
79
109
  const { record } = program;
80
110
  if (ended(record)) continue;
@@ -86,19 +116,25 @@ export class Programs {
86
116
  }
87
117
  }
88
118
 
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 {
119
+ /** Starts a background Program, which `run` executes with its ID and reports its partial results to `update`, and returns its record. */
120
+ start(code: string, run: (id: string, signal: AbortSignal, abort: () => void, update: (result: unknown) => void) => Promise<ProgramRunResult>): ProgramRecord {
91
121
  const record: ProgramRecord = { id: randomUUID(), state: "running", startedAt: Date.now(), returned: false, notified: false };
92
122
  const controller = new AbortController();
93
- const program: Running = { record, controller, waiters: 0 };
123
+ const program: Running = { record, code, controller, waiters: 0 };
124
+ const update = (result: unknown) => {
125
+ if (ended(record)) return;
126
+ program.result = result as Running["result"];
127
+ this.viewed();
128
+ };
94
129
  this.programs.set(record.id, program);
95
130
  // The tree stays busy until the Program ends and its notification is delivered.
96
131
  const release = program.release = trackTreeWork(this.rootId);
97
132
  this.persist(record);
98
133
  this.hooks.changed?.();
99
- program.done = run(record.id, controller.signal, () => controller.abort(new Error("Program stopped"))).then(
134
+ program.done = run(record.id, controller.signal, () => controller.abort(new Error("Program stopped")), update).then(
100
135
  ({ outcome, result, files, abandoned }) => {
101
136
  if (abandoned) program.abandoned = true;
137
+ if (!ended(record)) program.result = result;
102
138
  this.end(program, outcome, result.content, result.isError === true, files);
103
139
  },
104
140
  (error: unknown) => this.end(program, "failed", [{ type: "text", text: `Script failed\nOutput:\nScript error:\n${error instanceof Error ? error.message : String(error)}` }], true),
@@ -131,6 +167,20 @@ export class Programs {
131
167
  return [...this.programs.values()].map((program) => program.record).sort((a, b) => b.startedAt - a.startedAt);
132
168
  }
133
169
 
170
+ view(id: string): ProgramView | undefined {
171
+ const program = this.programs.get(id);
172
+ if (!program) return undefined;
173
+ const { record, code } = program;
174
+ const restored = record.content && { content: record.content, details: record.calls && { calls: record.calls }, isError: record.isError };
175
+ return { record, code, result: program.result ?? restored as ProgramView["result"] };
176
+ }
177
+
178
+ /** Calls `listener` when a Program's view changes; returns a function that stops it. */
179
+ onView(listener: () => void): () => void {
180
+ this.viewers.add(listener);
181
+ return () => this.viewers.delete(listener);
182
+ }
183
+
134
184
  /**
135
185
  * Returns whether the Program was running; it has ended when this resolves. A Program that has
136
186
  * not stopped within the timeout is abandoned and ends as stopped; this throws when some of its
@@ -138,6 +188,8 @@ export class Programs {
138
188
  */
139
189
  async stop(program: Running, reason = "Program stopped"): Promise<boolean> {
140
190
  if (ended(program.record)) return false;
191
+ // As with `abort`, the caller that stops a Program is not notified.
192
+ program.record.notified = true;
141
193
  program.controller?.abort(new Error(reason));
142
194
  if (!await settlesBy(program.done!, Date.now() + CLEANUP_TIMEOUT_MS)) {
143
195
  program.abandoned = true;
@@ -157,22 +209,28 @@ export class Programs {
157
209
  return programs.some(({ record }) => !ended(record));
158
210
  }
159
211
 
160
- async wait(unique: Running[], timeoutSeconds: number, signal?: AbortSignal): Promise<ProgramWaitOutcome> {
212
+ async wait(unique: Running[], timeoutSeconds: number, signal?: AbortSignal, steered?: () => boolean): Promise<ProgramWaitOutcome> {
161
213
  for (const program of unique) program.waiters += 1;
162
214
  let returned = false;
215
+ let interrupted = false;
163
216
  try {
164
217
  const deadline = Date.now() + timeoutSeconds * 1000;
165
218
  while (unique.some(({ record }) => !ended(record))) {
166
219
  const remaining = deadline - Date.now();
167
220
  if (remaining <= 0) break;
221
+ if (steered?.()) {
222
+ interrupted = true;
223
+ break;
224
+ }
168
225
  signal?.throwIfAborted();
169
- await waitForChange(this.change.promise, remaining, signal);
226
+ await waitForChange(this.change.promise, steered ? Math.min(remaining, STEER_POLL_MS) : remaining, signal);
170
227
  }
171
228
  signal?.throwIfAborted();
172
229
  returned = true;
173
230
  return {
174
231
  results: unique.filter(({ record }) => ended(record)).map(({ record }) => record),
175
232
  running: unique.filter(({ record }) => !ended(record)).map(({ record }) => record),
233
+ ...(interrupted && { interrupted }),
176
234
  };
177
235
  } finally {
178
236
  for (const program of unique) {
@@ -212,6 +270,10 @@ export class Programs {
212
270
  record.state = outcome;
213
271
  record.content = content;
214
272
  record.isError = isError;
273
+ // Keeps the calls the result's details list, from the result or the last partial one.
274
+ program.result = { content, details: program.result?.details, isError };
275
+ const calls = (program.result.details as { calls?: unknown[] } | undefined)?.calls;
276
+ if (calls?.length) record.calls = calls;
215
277
  if (files && (files.read.length > 0 || files.written.length > 0 || files.edited.length > 0)) record.files = files;
216
278
  program.controller = undefined;
217
279
  this.persist(record);
@@ -220,6 +282,11 @@ export class Programs {
220
282
  this.change = deferred();
221
283
  previous.resolve();
222
284
  this.hooks.changed?.();
285
+ this.viewed();
286
+ }
287
+
288
+ private viewed(): void {
289
+ for (const listener of this.viewers) listener();
223
290
  }
224
291
 
225
292
  private notify(record: ProgramRecord): void {
@@ -261,8 +328,8 @@ function resultBlock(record: ProgramRecord, ids: string[]): string {
261
328
  }
262
329
 
263
330
  /** 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(", ")}` : "";
331
+ export function renderProgramWait({ results, running, interrupted }: ProgramWaitOutcome, ids: string[]): { content: Item[]; returned: ProgramRecord[]; isError?: boolean } {
332
+ const runningLine = running.length > 0 ? `Still running: ${running.map((record) => shortId(record.id, ids)).join(", ")}${interrupted ? `\n${WAIT_INTERRUPTED}` : ""}` : "";
266
333
  if (results.length === 0) return { content: [{ type: "text", text: runningLine || "No results." }], returned: [] };
267
334
  const images = (shown: ProgramRecord[]) => shown.flatMap((record) => (record.content ?? []).filter((item) => item.type === "image"));
268
335
  const only = results[0]!;
@@ -0,0 +1,11 @@
1
+ /** The message a `/program` run adds to the Session: `Ran …`, then the Program's result. */
2
+ export const PROGRAM_MESSAGE = 'pi-agents-program-run';
3
+
4
+ const SCRIPT_HEADER = /^Script (completed|failed)\nWall time [\d.]+ seconds\nOutput:\n$/;
5
+
6
+ /** What `pi -p` prints for a Program result: its text items without codemode's header. */
7
+ export function runOutput(content) {
8
+ const [first, ...rest] = content;
9
+ const output = first?.type === 'text' && SCRIPT_HEADER.test(first.text) ? rest : content;
10
+ return output.flatMap(item => item.type === 'text' ? [item.text] : []).join('\n');
11
+ }
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import type { CodemodeTool } from "@earendil-works/pi-codemode";
3
+ import type { CallTracker } from "./execute.ts";
3
4
 
4
5
  export interface AgentOptions {
5
6
  name?: string;
@@ -12,6 +13,7 @@ export interface AgentOptions {
12
13
  export interface SendOptions {
13
14
  deliverAs?: "followUp" | "steer" | "write";
14
15
  schema?: unknown;
16
+ timeout?: number;
15
17
  }
16
18
 
17
19
  /** What `agent()` and its handles do on the host. */
@@ -53,9 +55,10 @@ export function withAgentPrefix(code: string): string {
53
55
  return `${agentPrefix()}${code}`;
54
56
  }
55
57
 
56
- /** Globals behind `agent()`. A handle's calls wait for its Agent to be created. */
57
- export function agentGlobals(host: ProgramAgentHost): CodemodeTool[] {
58
+ /** Globals behind `agent()`. A handle's calls wait for its Agent to be created; `track` records each `send()`. */
59
+ export function agentGlobals(host: ProgramAgentHost, track: CallTracker): CodemodeTool[] {
58
60
  const created = new Map<string, Promise<void>>();
61
+ const names = new Map<string, string>();
59
62
  const ready = (id: string) => created.get(id) ?? Promise.reject(new Error(`Unknown Agent ${id}`));
60
63
  return [
61
64
  {
@@ -63,6 +66,7 @@ export function agentGlobals(host: ProgramAgentHost): CodemodeTool[] {
63
66
  spread: true,
64
67
  execute: (args) => {
65
68
  const [id, name, options] = args as [string, string, AgentOptions];
69
+ names.set(id, name);
66
70
  const creation = host.create(id, name, options ?? {});
67
71
  created.set(id, creation);
68
72
  return creation;
@@ -73,8 +77,17 @@ export function agentGlobals(host: ProgramAgentHost): CodemodeTool[] {
73
77
  spread: true,
74
78
  execute: async (args) => {
75
79
  const [id, message, options] = args as [string, string, SendOptions | undefined];
76
- await ready(id);
77
- return host.send(id, message, options ?? {});
80
+ // Recorded from the start, so a send whose Agent fails to be created shows too.
81
+ const end = track(`agent ${names.get(id) ?? id}`, JSON.stringify(message) ?? "");
82
+ try {
83
+ await ready(id);
84
+ const value = await host.send(id, message, options ?? {});
85
+ end("ok");
86
+ return value;
87
+ } catch (error) {
88
+ end((error as { cancelled?: boolean }).cancelled ? "cancelled" : "error", error instanceof Error ? error.message : String(error));
89
+ throw error;
90
+ }
78
91
  },
79
92
  },
80
93
  {