@tshu-w/pi-agents 0.3.0 → 0.4.1

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.
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { deferred, settlesBy, STEER_POLL_MS, WAIT_INTERRUPTED, waitForChange, type Deferred } from "../agents/agents.ts";
4
- import { shortId, trackTreeWork } from "../agents/registry.ts";
5
- import { boundBlocks, boundText, type Truncation } from "../output.ts";
3
+ import { deferred, hold, release, settlesBy, STEER_POLL_MS, waitForChange, type Deferred, type Held, type WaitEnd } from "../agents/agents.ts";
4
+ import { shortId, trackTreeWork, type Notice } from "../agents/registry.ts";
5
+ import { boundText } from "../output.ts";
6
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";
@@ -23,9 +23,6 @@ export interface ProgramRecord {
23
23
  files?: ProgramFiles;
24
24
  /** The tool calls its result lists, which the task viewer shows after a restart. */
25
25
  calls?: unknown[];
26
- /** Whether `wait` has returned the result. */
27
- returned: boolean;
28
- notified: boolean;
29
26
  }
30
27
 
31
28
  /** What the task viewer shows of a Program, as its tool call shows in the foreground. */
@@ -46,14 +43,13 @@ interface Running {
46
43
  release?: () => void;
47
44
  /** Whether some of its work did not stop. */
48
45
  abandoned?: boolean;
49
- waiters: number;
46
+ /** Stopped by the caller, which is not notified. */
47
+ silent?: boolean;
50
48
  }
51
49
 
52
50
  export interface ProgramWaitOutcome {
53
- results: ProgramRecord[];
51
+ end: WaitEnd;
54
52
  running: ProgramRecord[];
55
- /** A steer ended the wait. */
56
- interrupted?: boolean;
57
53
  }
58
54
 
59
55
  function ended(record: ProgramRecord): boolean {
@@ -72,13 +68,15 @@ export class Programs {
72
68
  private change: Deferred = deferred();
73
69
  private closing = false;
74
70
  private viewers = new Set<() => void>();
71
+ private held: Held[] = [];
75
72
 
76
73
  constructor(
77
74
  private rootId: string,
78
75
  private hooks: {
79
76
  appendEntry(customType: string, data: unknown): void;
80
- notify(text: string): void;
81
- /** Called when a Program starts, ends, or has its result returned. */
77
+ /** Delivers a notification with Programs' results and their images. */
78
+ notify(text: string, notice: Notice): void;
79
+ /** Called when a Program starts or ends. */
82
80
  changed?(): void;
83
81
  },
84
82
  ) {}
@@ -99,7 +97,7 @@ export class Programs {
99
97
  }
100
98
  if (entry.type !== "custom" || entry.customType !== PROGRAM_ENTRY) continue;
101
99
  const record = entry.data as ProgramRecord;
102
- this.programs.set(record.id, { record, waiters: 0 });
100
+ this.programs.set(record.id, { record });
103
101
  }
104
102
  for (const program of this.programs.values()) {
105
103
  const callId = callIds.get(program.record.id);
@@ -111,16 +109,15 @@ export class Programs {
111
109
  record.state = "stopped";
112
110
  record.content = [{ type: "text", text: "Script failed\nOutput:\nScript error:\nScript aborted: the caller went offline" }];
113
111
  record.isError = true;
114
- record.notified = true;
115
112
  this.persist(record);
116
113
  }
117
114
  }
118
115
 
119
116
  /** Starts a background Program, which `run` executes with its ID and reports its partial results to `update`, and returns its record. */
120
117
  start(code: string, run: (id: string, signal: AbortSignal, abort: () => void, update: (result: unknown) => void) => Promise<ProgramRunResult>): ProgramRecord {
121
- const record: ProgramRecord = { id: randomUUID(), state: "running", startedAt: Date.now(), returned: false, notified: false };
118
+ const record: ProgramRecord = { id: randomUUID(), state: "running", startedAt: Date.now() };
122
119
  const controller = new AbortController();
123
- const program: Running = { record, code, controller, waiters: 0 };
120
+ const program: Running = { record, code, controller };
124
121
  const update = (result: unknown) => {
125
122
  if (ended(record)) return;
126
123
  program.result = result as Running["result"];
@@ -189,7 +186,7 @@ export class Programs {
189
186
  async stop(program: Running, reason = "Program stopped"): Promise<boolean> {
190
187
  if (ended(program.record)) return false;
191
188
  // As with `abort`, the caller that stops a Program is not notified.
192
- program.record.notified = true;
189
+ program.silent = true;
193
190
  program.controller?.abort(new Error(reason));
194
191
  if (!await settlesBy(program.done!, Date.now() + CLEANUP_TIMEOUT_MS)) {
195
192
  program.abandoned = true;
@@ -200,20 +197,23 @@ export class Programs {
200
197
  return true;
201
198
  }
202
199
 
203
- /** The given Programs, or running Programs and ended ones whose result has not been returned. */
200
+ /** The given Programs, or the running ones. */
204
201
  select(ids: string[] | undefined): Running[] {
205
- return [...new Set(ids?.map((id) => this.target(id)) ?? [...this.programs.values()].filter(({ record }) => !ended(record) || !record.returned))];
202
+ return [...new Set(ids?.map((id) => this.target(id)) ?? this.programs.values())];
206
203
  }
207
204
 
208
205
  running(programs: Running[]): boolean {
209
206
  return programs.some(({ record }) => !ended(record));
210
207
  }
211
208
 
212
- async wait(unique: Running[], timeoutSeconds: number, signal?: AbortSignal, steered?: () => boolean): Promise<ProgramWaitOutcome> {
213
- for (const program of unique) program.waiters += 1;
214
- let returned = false;
215
- let interrupted = false;
209
+ /** Waits until the Programs have ended; a steer ends it early. Their notifications are held until it ends, then delivered as one. */
210
+ async wait(programs: Running[], timeoutSeconds: number, signal?: AbortSignal, steered?: () => boolean): Promise<ProgramWaitOutcome> {
211
+ const unique = programs.filter(({ record }) => !ended(record));
212
+ if (unique.length === 0) return { end: "nothing", running: [] };
213
+ const held: Held = { ids: new Set(unique.map(({ record }) => record.id)), notices: [] };
214
+ this.held.push(held);
216
215
  try {
216
+ let interrupted = false;
217
217
  const deadline = Date.now() + timeoutSeconds * 1000;
218
218
  while (unique.some(({ record }) => !ended(record))) {
219
219
  const remaining = deadline - Date.now();
@@ -226,36 +226,15 @@ export class Programs {
226
226
  await waitForChange(this.change.promise, steered ? Math.min(remaining, STEER_POLL_MS) : remaining, signal);
227
227
  }
228
228
  signal?.throwIfAborted();
229
- returned = true;
230
- return {
231
- results: unique.filter(({ record }) => ended(record)).map(({ record }) => record),
232
- running: unique.filter(({ record }) => !ended(record)).map(({ record }) => record),
233
- ...(interrupted && { interrupted }),
234
- };
229
+ const running = unique.filter(({ record }) => !ended(record)).map(({ record }) => record);
230
+ return { end: running.length === 0 ? "completed" : interrupted ? "interrupted" : "timedOut", running };
235
231
  } finally {
236
- for (const program of unique) {
237
- program.waiters -= 1;
238
- const { record } = program;
239
- if (!ended(record) || record.notified) continue;
240
- if (returned) {
241
- record.notified = true;
242
- this.persist(record);
243
- } else if (program.waiters === 0) {
244
- this.notify(record);
245
- }
246
- }
232
+ this.held.splice(this.held.indexOf(held), 1);
233
+ const released = this.closing ? undefined : release(held);
234
+ if (released) this.hooks.notify(released.text, released.notice);
247
235
  }
248
236
  }
249
237
 
250
- markReturned(records: ProgramRecord[]): void {
251
- for (const record of records) {
252
- if (record.returned) continue;
253
- record.returned = true;
254
- this.persist(record);
255
- }
256
- this.hooks.changed?.();
257
- }
258
-
259
238
  async shutdown(): Promise<void> {
260
239
  this.closing = true;
261
240
  const stopped = await Promise.allSettled([...this.programs.values()].map((program) => this.stop(program, "the caller went offline")));
@@ -277,7 +256,7 @@ export class Programs {
277
256
  if (files && (files.read.length > 0 || files.written.length > 0 || files.edited.length > 0)) record.files = files;
278
257
  program.controller = undefined;
279
258
  this.persist(record);
280
- if (program.waiters === 0) this.notify(record);
259
+ if (!program.silent) this.notify(record);
281
260
  const previous = this.change;
282
261
  this.change = deferred();
283
262
  previous.resolve();
@@ -290,10 +269,14 @@ export class Programs {
290
269
  }
291
270
 
292
271
  private notify(record: ProgramRecord): void {
293
- if (this.closing || record.notified) return;
294
- record.notified = true;
295
- this.persist(record);
296
- this.hooks.notify(`Program ${this.label(record)} ${record.state}.`);
272
+ if (this.closing) return;
273
+ const images = (record.content ?? []).filter((item): item is Extract<Item, { type: "image" }> => item.type === "image");
274
+ const { kept, notice } = boundText(textOf(record.content), "pi-agents-program-notification");
275
+ const id = this.label(record);
276
+ const header = SCRIPT_HEADER.exec(kept);
277
+ const text = `<${PROGRAM_RESULT_TAG} id="${id}" status="${record.state}">\n${kept}\n</${PROGRAM_RESULT_TAG}>${notice ? `\n\n${notice}` : ""}`;
278
+ const result = { id, status: record.state, ...(header?.[1] !== undefined && { seconds: Number(header[1]) }), body: kept.slice(header?.[0].length ?? 0), ...(notice && { notice }) };
279
+ if (!hold(this.held, record.id, { text, result, ...(images.length ? { images } : {}) })) this.hooks.notify(text, { results: [result], ...(images.length ? { images } : {}) });
297
280
  }
298
281
 
299
282
  private persist(record: ProgramRecord): void {
@@ -323,69 +306,10 @@ export function addProgramFiles(
323
306
  }
324
307
  }
325
308
 
326
- function resultBlock(record: ProgramRecord, ids: string[]): string {
327
- return `<program-result id="${shortId(record.id, ids)}" status="${record.state}">\n${textOf(record.content)}\n</program-result>`;
328
- }
309
+ export const PROGRAM_RESULT_TAG = "program-result";
329
310
 
330
311
  // The header codemode puts before a Program's output; a Program that ended before its run did has no wall time.
331
- const SCRIPT_HEADER = /^Script (?:completed|failed)\n(?:Wall time ([\d.]+) seconds\n)?Output:\n/;
332
-
333
- export interface ProgramResultDetails {
334
- id: string;
335
- state: ProgramState;
336
- wallTimeSeconds?: number;
337
- /** The output the result shows, without the header. */
338
- output?: string;
339
- }
340
-
341
- /** A result's details, with the output in the first `available` characters of its text, if shown. */
342
- function resultDetails(record: ProgramRecord, available?: number): ProgramResultDetails {
343
- const text = textOf(record.content);
344
- const header = SCRIPT_HEADER.exec(text);
345
- return {
346
- id: record.id,
347
- state: record.state,
348
- ...(header?.[1] !== undefined && { wallTimeSeconds: Number(header[1]) }),
349
- ...(available !== undefined && { output: text.slice(0, available).slice(header?.[0].length ?? 0) }),
350
- };
351
- }
352
-
353
- /** Renders wait results; returns the content, its details, and the results the caller has now received. */
354
- /** The line naming the results left out to keep within Pi's output limits. */
355
- export function omittedLine(omitted: string[], ids: string[]): string {
356
- return omitted.length > 0 ? `[Results omitted: ${omitted.map((id) => shortId(id, ids)).join(", ")}. Use wait with fewer targets.]` : "";
357
- }
358
-
359
- /** The line naming the Programs still running, and that a steer ended the wait if one did. */
360
- export function runningLine(running: string[], interrupted: boolean | undefined, ids: string[]): string {
361
- return running.length > 0 ? `Still running: ${running.map((id) => shortId(id, ids)).join(", ")}${interrupted ? `\n${WAIT_INTERRUPTED}` : ""}` : "";
362
- }
363
-
364
- export function renderProgramWait({ results, running, interrupted }: ProgramWaitOutcome, ids: string[]): { content: Item[]; details: { results: ProgramResultDetails[] } & Partial<Truncation>; returned: ProgramRecord[]; isError?: boolean } {
365
- const runningText = runningLine(running.map((record) => record.id), interrupted, ids);
366
- if (results.length === 0) return { content: [{ type: "text", text: runningText || "No results." }], details: { results: [] }, returned: [] };
367
- const images = (shown: ProgramRecord[]) => shown.flatMap((record) => (record.content ?? []).filter((item) => item.type === "image"));
368
- const only = results[0]!;
369
- if (results.length === 1 && running.length === 0 && only.state === "completed") {
370
- const bounded = boundText(textOf(only.content), "pi-agents-program-wait");
371
- return { content: [{ type: "text", text: bounded.text }, ...images(results)], details: { results: [resultDetails(only)], ...bounded.details }, returned: results };
372
- }
373
- const blocks = results.map((record) => resultBlock(record, ids));
374
- const bounded = boundBlocks(blocks, "program-result", "pi-agents-program-wait");
375
- let offset = 0;
376
- const details = results.map((record, index) => {
377
- const block = blocks[index]!;
378
- const available = Math.max(0, bounded.kept.length - offset - block.indexOf("\n") - 1);
379
- offset += block.length + 2;
380
- return resultDetails(record, index < bounded.shown ? available : undefined);
381
- });
382
- let text = bounded.text;
383
- const returned = results.slice(0, bounded.shown);
384
- const omittedText = omittedLine(results.slice(bounded.shown).map((record) => record.id), ids);
385
- if (omittedText) text += `\n\n${omittedText}`;
386
- if (runningText) text += `\n\n${runningText}`;
387
- return { content: [{ type: "text", text }, ...images(returned)], details: { results: details, ...bounded.details }, returned };
388
- }
312
+ export const SCRIPT_HEADER = /^Script (?:completed|failed)\n(?:Wall time ([\d.]+) seconds\n)?Output:\n/;
389
313
 
390
314
  export function programListLine(record: ProgramRecord, ids: string[]): string {
391
315
  return `${shortId(record.id, ids)} ${record.state}`;
@@ -73,7 +73,7 @@ export function programScope(
73
73
  const ids = visibleIds(agentId, false);
74
74
  const text = messageText(from, true, delivery, body, ids);
75
75
  if (delivery === "write") {
76
- agents.accept(agentId, text, "write", { fromOwner: true, notification: false, sender: sender(from, ids) });
76
+ agents.accept(agentId, text, "write", { fromOwner: true, sender: sender(from, ids) });
77
77
  return undefined;
78
78
  }
79
79
  const { record, timedOut } = await agents.request(agentId, text, delivery, sender(from, ids), schema, timeout === undefined ? undefined : timeout * 1000);
@@ -5,7 +5,7 @@ import { writeSync } from "node:fs";
5
5
  import { readFile } from "node:fs/promises";
6
6
  import * as path from "node:path";
7
7
  import { Type } from "typebox";
8
- import { settlesBy, steerQueued, SUBMIT_RESULT_TOOL_NAME, type Limits } from "../agents/agents.ts";
8
+ import { settlesBy, steerQueued, SUBMIT_RESULT_TOOL_NAME, waitText, type Limits } from "../agents/agents.ts";
9
9
  import { nodes, trackTreeWork, type AgentNode } from "../agents/registry.ts";
10
10
  import { rejectFields } from "../params.ts";
11
11
  import { agentSession } from "../roots/rpc.ts";
@@ -15,11 +15,11 @@ import { CLEANUP_TIMEOUT_MS, executeProgram, PROGRAM_TOOL_NAME, type ProgramRunO
15
15
  import { commandSession, runDetached } from "./host-patches.ts";
16
16
  import { programDescription, programLoadout, programRenderers } from "./loadout.ts";
17
17
  import { randomUUID } from "node:crypto";
18
- import { CLEANUP_ERROR, programListLine, Programs, renderProgramWait } from "./programs.ts";
18
+ import { CLEANUP_ERROR, programListLine, Programs } from "./programs.ts";
19
19
  import { PROGRAM_MESSAGE, runOutput } from "./run-output.mjs";
20
20
  import { agentGlobals, withAgentPrefix } from "./sandbox.ts";
21
21
 
22
- const PROGRAM_DESCRIPTION = `Run JavaScript that composes tool calls and Agents; only its output and return value reach the caller. \`run\` runs a Program in the foreground, or in the background with \`background\`; \`wait\` waits for background Programs and returns their results; \`list\` lists background Programs; \`stop\` stops a running Program. When a background Program ends while its caller is not waiting for it, the caller receives a notification; \`wait\` returns the result.
22
+ const PROGRAM_DESCRIPTION = `Run JavaScript that composes tool calls and Agents; only its output and return value reach the caller. \`run\` runs a Program in the foreground, or in the background with \`background\`; \`wait\` waits for background Programs to end; \`list\` lists background Programs; \`stop\` stops a running Program. When a background Program ends, the caller receives a notification with the result.
23
23
 
24
24
  Program code is the body of an async function. It calls the caller's tools through \`tools.*\`, which excludes \`program\`, and creates Agents through \`agent()\`. These Agents belong to the Program, are isolated from other Agents, and go offline when it ends.
25
25
 
@@ -161,14 +161,14 @@ export function registerProgramTools(
161
161
  promptGuidelines: [
162
162
  "Use `program(action='run', code=...)` to call tools or Agents several times without a model turn between the calls, for example to read many files, filter large tool output, fan out Agents, or loop until a condition holds.",
163
163
  "Use `Promise.allSettled` for independent calls. Return only what the caller needs; use `text()` for progress worth reading.",
164
- "Use `background=true` for long Programs, and `program(action='wait', ...)` when the next step depends on their results.",
164
+ "Use `background=true` for long Programs, and `program(action='wait', ...)` when your next step or answer depends on their results.",
165
165
  ],
166
166
  parameters: Type.Object({
167
167
  action: StringEnum(["run", "wait", "list", "stop"] as const, { description: "Operation and applicable parameters: `run(code, background?, timeout?)`, `wait(target?, timeout?)`, `list(limit?, offset?)`, or `stop(target)`." }),
168
168
  code: Type.Optional(Type.String({ description: "JavaScript async-function body for `run`." })),
169
169
  background: Type.Optional(Type.Boolean({ description: "Whether `run` returns the Program ID at once (default: false)." })),
170
170
  timeout: Type.Optional(Type.Number({ exclusiveMinimum: 0, description: "Maximum seconds. For `run` (default: none), expiry stops the Program. For `wait` (default: 30, min: 10, max: 3600), expiry does not stop Programs." })),
171
- target: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], { description: "Program ID or unique ID prefix for `wait` or `stop`. `wait` also accepts an array. When omitted for `wait`, selects running Programs and ended Programs whose result has not been returned." })),
171
+ target: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], { description: "Program ID or unique ID prefix for `wait` or `stop`. `wait` also accepts an array. When omitted for `wait`, selects running Programs." })),
172
172
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200, description: "Maximum Programs returned by `list` (default: 20, max: 200)." })),
173
173
  offset: Type.Optional(Type.Integer({ minimum: 0, description: "Number of Programs to skip for `list` (default: 0)." })),
174
174
  }),
@@ -200,10 +200,12 @@ export function registerProgramTools(
200
200
  onUpdate?.({ content: [], details: undefined });
201
201
  const session = agentSession(current.sessionManager);
202
202
  const waitFor = () => programs!.wait(selected, timeout, signal, () => steerQueued(session?.agent));
203
- const outcome = programs.running(selected) ? await self.agents.whileSuspended(waitFor, signal, true) : await waitFor();
204
- const rendered = renderProgramWait(outcome, programs.ids());
205
- programs.markReturned(rendered.returned);
206
- return { content: rendered.content, details: { ...rendered.details, running: outcome.running.map((record) => record.id), ...(outcome.interrupted && { interrupted: true }) } };
203
+ const { end, running } = programs.running(selected) ? await self.agents.whileSuspended(waitFor, signal, true) : await waitFor();
204
+ const text = waitText(end, `Still running: ${running.map((record) => programs!.label(record)).join(", ")}`);
205
+ return {
206
+ content: [{ type: "text", text }],
207
+ details: { running: running.map((record) => record.id), ...(end === "timedOut" && { timedOut: true }), ...(end === "interrupted" && { interrupted: true }) },
208
+ };
207
209
  }
208
210
  case "list": {
209
211
  rejectFields(params, "list", ["code", "background", "timeout", "target"]);
@@ -232,9 +234,9 @@ export function registerProgramTools(
232
234
  });
233
235
 
234
236
  /** A run as Pi shows a `run` call: the script, then its calls and output. */
235
- const renderRun = (call: ProgramCall, result: { content: unknown[]; details?: unknown }, isPartial: boolean, isError: boolean, expanded: boolean, outputPad: number, theme: Theme) => {
237
+ const renderRun = (call: ProgramCall, result: { content: unknown[]; details?: unknown }, isPartial: boolean, isError: boolean, expanded: boolean, outputPad: number, theme: Theme, state = {}) => {
236
238
  const component = new Box(outputPad, 1, (text) => theme.bg(isPartial ? "toolPendingBg" : isError ? "toolErrorBg" : "toolSuccessBg", text));
237
- const context = { args: call, state: {}, executionStarted: true, expanded, isError, showImages: false, isPartial } as never;
239
+ const context = { args: call, state, executionStarted: true, expanded, isError, showImages: false, isPartial } as never;
238
240
  component.addChild(renderers.renderCall!(call, theme, context));
239
241
  component.addChild(renderers.renderResult!(result as never, { expanded, isPartial }, theme, context));
240
242
  return component;
@@ -263,7 +265,9 @@ export function registerProgramTools(
263
265
  current.ui.setWidget(widget, (ui, theme) => {
264
266
  tui = ui;
265
267
  const outputPad = pi.getSettings().outputPad === 0 ? 0 : 1;
266
- return { render: (width: number) => renderRun(call, partial, !ended, partial.isError === true, current.ui.getToolsExpanded(), outputPad, theme).render(width), invalidate() {} };
268
+ // The widget redraws on each update, so it keeps its state as a tool row does.
269
+ const state = {};
270
+ return { render: (width: number) => renderRun(call, partial, !ended, partial.isError === true, current.ui.getToolsExpanded(), outputPad, theme, state).render(width), invalidate() {} };
267
271
  });
268
272
  }
269
273
  let run: Awaited<ReturnType<typeof runProgram>>;
@@ -35,6 +35,11 @@ export function readSessionHeader(path) {
35
35
 
36
36
  const installed = Symbol.for('pi-agents.guard');
37
37
 
38
+ /** Releases the locks a Session in this process holds, so that another process can open it. */
39
+ export function releaseSession(sm) {
40
+ Object.getPrototypeOf(sm)[installed]?.release(sm);
41
+ }
42
+
38
43
  /** Patches Pi's Session classes so every persisted Session holds its locks. Idempotent across /reload. */
39
44
  export function installGuard({ SessionManager, AgentSession, AgentSessionRuntime, parseSessionEntries, stateDir }) {
40
45
  const p = SessionManager.prototype;
@@ -26,7 +26,7 @@ export function statePaths(env = process.env) {
26
26
  workbench: join(state, 'workbench.json'),
27
27
  hostLog: join(state, 'host.log'),
28
28
  daemonLog: join(state, 'daemon.log'),
29
- main: join(state, 'main'),
29
+ mainSession: join(state, 'main.json'),
30
30
  host(pid) { return join(runtime, `h-${pid}.sock`); },
31
31
  locks: join(runtime, 'locks'),
32
32
  daemon: join(runtime, 'daemon.sock'),
package/src/ui/message.ts CHANGED
@@ -1,6 +1,28 @@
1
1
  import { getMarkdownTheme, UserMessageComponent, type MessageRenderer } from "@earendil-works/pi-coding-agent";
2
2
  import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
3
- import { messageBody, type Sender } from "../agents/registry.ts";
3
+ import { RESULT_TAG } from "../agents/agents.ts";
4
+ import { messageBody, type NotifiedResult, type Sender } from "../agents/registry.ts";
5
+ import { PROGRAM_RESULT_TAG, SCRIPT_HEADER } from "../programs/programs.ts";
6
+ import { formatDuration, renderResultList } from "../render-call.ts";
7
+
8
+ const AGENT_NOTIFICATION = new RegExp(`^<${RESULT_TAG} name="([^"]*)" id="([^"]*)" status="([^"]*)">\\n([\\s\\S]*)\\n</${RESULT_TAG}>(?:\\n+([\\s\\S]*))?$`);
9
+ const PROGRAM_NOTIFICATION = new RegExp(`^<${PROGRAM_RESULT_TAG} id="([^"]*)" status="([^"]*)">\\n([\\s\\S]*)\\n</${PROGRAM_RESULT_TAG}>(?:\\n+([\\s\\S]*))?$`);
10
+
11
+ /** The result of a notification made before notifications recorded their results, parsed from its text. */
12
+ function parsedResult(text: string): NotifiedResult | undefined {
13
+ const agent = AGENT_NOTIFICATION.exec(text);
14
+ if (agent) return { name: agent[1], id: agent[2]!, status: agent[3]!, body: agent[4]!, notice: agent[5] };
15
+ const program = PROGRAM_NOTIFICATION.exec(text);
16
+ if (!program) return undefined;
17
+ const header = SCRIPT_HEADER.exec(program[3]!);
18
+ return { id: program[1]!, status: program[2]!, ...(header?.[1] !== undefined && { seconds: Number(header[1]) }), body: program[3]!.slice(header?.[0].length ?? 0), notice: program[4] };
19
+ }
20
+
21
+ /** A result's line: an Agent's name and ID, or a Program's ID and wall time, with its status. */
22
+ function resultLine({ name, id, status, seconds }: NotifiedResult): string {
23
+ if (name !== undefined) return `${name} (${id}) ${status}`;
24
+ return [`Program ${id}`, status, ...(seconds === undefined ? [] : [formatDuration(seconds * 1000)])].join(" ");
25
+ }
4
26
 
5
27
  /**
6
28
  * Collapsed, a message from another Agent shows its sender after the label, as Pi shows a collapsed skill, and the
@@ -8,14 +30,26 @@ import { messageBody, type Sender } from "../agents/registry.ts";
8
30
  * model received.
9
31
  */
10
32
  export const renderAgentMessage: MessageRenderer = (message, { expanded, outputPad }, theme) => {
11
- if (expanded || typeof message.content !== "string") return undefined;
12
- const details = message.details as { user?: boolean; sender?: Sender } | undefined;
13
- if (details?.user) return new UserMessageComponent(message.content, getMarkdownTheme(), outputPad);
14
- const body = details?.sender && messageBody(message.content, details.sender);
15
- if (body === undefined) return undefined;
16
- const { id, name } = details!.sender!;
33
+ if (expanded) return undefined;
34
+ const details = message.details as { user?: boolean; sender?: Sender; results?: NotifiedResult[] } | undefined;
35
+ // A Program's notification also holds the images of its result, which show when expanded.
36
+ const content = typeof message.content === "string" ? message.content : message.content.find((part) => part.type === "text")?.text;
37
+ if (content === undefined) return undefined;
38
+ if (details?.user) return new UserMessageComponent(content, getMarkdownTheme(), outputPad);
17
39
  const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
18
40
  const label = theme.fg("customMessageLabel", `\x1b[1m[${message.customType}]\x1b[22m`);
41
+ // A notification shows each of its results as a line with the first lines of its body.
42
+ const parsed = !details?.sender && !details?.results && parsedResult(content);
43
+ const results = details?.results ?? (parsed ? [parsed] : undefined);
44
+ if (results) {
45
+ box.addChild(new Text(label, 0, 0));
46
+ box.addChild(new Spacer(1));
47
+ box.addChild(renderResultList(results.map((result) => ({ line: resultLine(result), body: result.body })), results.flatMap((result) => result.notice ? [result.notice] : []), {}, theme));
48
+ return box;
49
+ }
50
+ const body = details?.sender && messageBody(content, details.sender);
51
+ if (body === undefined) return undefined;
52
+ const { id, name } = details!.sender!;
19
53
  const sender = name === undefined ? theme.fg("customMessageText", ` from ${id}`) : theme.fg("customMessageText", ` from ${name}`) + theme.fg("dim", ` (${id})`);
20
54
  box.addChild(new Text(label + sender, 0, 0));
21
55
  box.addChild(new Spacer(1));