@tshu-w/pi-agents 0.2.0 → 0.2.2

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.
@@ -31,7 +31,7 @@ export interface AgentNode {
31
31
  sessionFile(): string | undefined;
32
32
  agents: Agents;
33
33
  /** Delivers a message to this node; used only for root Agents. */
34
- receive(text: string, delivery: Delivery, messageId?: string): void;
34
+ receive(text: string, delivery: Delivery, messageId?: string, sender?: Sender): void;
35
35
  /** A root Agent's turn was stopped: messages from other Agents become writes until the user's next input. */
36
36
  stopped?: boolean;
37
37
  }
@@ -242,31 +242,56 @@ export function deliver(
242
242
  const fromOwner = from !== undefined && target.ownerId === from.id;
243
243
  const ids = recipientIds(target);
244
244
  const text = from === undefined ? body : messageText(from, fromOwner, delivery, body, ids);
245
+ const by = from && sender(from, ids);
245
246
  if (target.ownerId === undefined) {
246
247
  const node = shared.nodes.get(target.id);
247
248
  if (!node) throw new Error(`${label(target, ids)} is offline.`);
248
- node.receive(text, delivery);
249
+ node.receive(text, delivery, undefined, by);
249
250
  return { queued: false };
250
251
  }
251
252
  const owner = shared.nodes.get(target.ownerId);
252
253
  if (!owner) throw new Error(`${label(target, ids)} is offline.`);
253
- return owner.agents.accept(target.id, text, delivery, { fromOwner, notification: from === undefined });
254
+ return owner.agents.accept(target.id, text, delivery, { fromOwner, notification: from === undefined, sender: by });
254
255
  }
255
256
 
256
257
  export const MESSAGE_TAG = "agent-message";
258
+ const REPLY_NOTE = ' note="Reply with send"';
259
+
260
+ /** The sender of a message as its element names it: its name and its short ID among the recipient's visible Agents. */
261
+ export interface Sender {
262
+ id: string;
263
+ name?: string;
264
+ }
265
+
266
+ export function sender(from: { id: string; name?: string }, ids: Iterable<string>): Sender {
267
+ return { id: shortId(from.id, ids), ...(from.name === undefined ? {} : { name: from.name }) };
268
+ }
269
+
270
+ /** The element a sender's message opens with, naming the sender as `agent-result` names its Agent. */
271
+ function messageOpen({ id, name }: Sender): string {
272
+ return `<${MESSAGE_TAG}${name === undefined ? "" : ` name="${name}"`} id="${id}"`;
273
+ }
257
274
 
258
275
  /** A message in an element naming its sender; an input from a sender other than the owner asks for a reply. */
259
276
  export function messageText(from: { id: string; name?: string }, fromOwner: boolean, delivery: Delivery, body: string, ids: Iterable<string>): string {
260
- const note = !fromOwner && delivery !== "write" ? ' note="Reply with send"' : "";
261
- return `<${MESSAGE_TAG} from="${from.name ?? shortId(from.id, ids)}" id="${shortId(from.id, ids)}"${note}>\n${body}\n</${MESSAGE_TAG}>`;
277
+ const note = !fromOwner && delivery !== "write" ? REPLY_NOTE : "";
278
+ return `${messageOpen(sender(from, ids))}${note}>\n${body}\n</${MESSAGE_TAG}>`;
279
+ }
280
+
281
+ /** The body of a message `messageText` made for `sender`, or undefined if `text` is not one. */
282
+ export function messageBody(text: string, sender: Sender): string | undefined {
283
+ const open = messageOpen(sender);
284
+ const close = `\n</${MESSAGE_TAG}>`;
285
+ const header = [`${open}>\n`, `${open}${REPLY_NOTE}>\n`].find((start) => text.startsWith(start));
286
+ return header && text.length >= header.length + close.length && text.endsWith(close) ? text.slice(header.length, -close.length) : undefined;
262
287
  }
263
288
 
264
289
  /**
265
290
  * `messageId` marks a message from a root Agent in another process, so it is added once; `user`
266
- * marks the user's input from the task viewer.
291
+ * marks the user's input from the task viewer, and `sender` the Agent that sent it.
267
292
  */
268
- export function customMessage(text: string, delivery: Delivery, messageId?: string, user?: boolean) {
269
- return { customType: MESSAGE_TYPE, content: text, display: true, details: { delivery, ...(messageId ? { messageId } : {}), ...(user ? { user } : {}) } };
293
+ export function customMessage(text: string, delivery: Delivery, { messageId, user, sender }: { messageId?: string; user?: boolean; sender?: Sender } = {}) {
294
+ return { customType: MESSAGE_TYPE, content: text, display: true, details: { delivery, ...(messageId ? { messageId } : {}), ...(user ? { user } : {}), ...(sender ? { sender } : {}) } };
270
295
  }
271
296
 
272
297
  export function receivedMessageIds(ctx: ExtensionContext): string[] {
@@ -1,9 +1,9 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { StringEnum } from "@earendil-works/pi-ai";
3
3
  import { Type } from "typebox";
4
- import { boundBlocks, boundText } from "../output.ts";
4
+ import { boundBlocks, boundText, type Truncation } from "../output.ts";
5
5
  import { rejectFields } from "../params.ts";
6
- import { renderTextResult, renderToolCall, startDuration, type DurationState } from "../render-call.ts";
6
+ import { renderResultList, renderTextResult, renderToolCall, startDuration, type DurationState } from "../render-call.ts";
7
7
  import { agentSession } from "../roots/rpc.ts";
8
8
  import type { RootRuntime } from "../roots/runtime.ts";
9
9
  import {
@@ -35,12 +35,25 @@ function resultDetails({ agent, input, history }: WaitResult, result?: string) {
35
35
  return { id: agent.id, name: agent.name, state: input.state, history, ...(result === undefined ? {} : { result }) };
36
36
  }
37
37
 
38
+ type Named = { id: string; name?: string };
39
+
40
+ /** The line naming the results left out to keep within Pi's output limits. */
41
+ function omittedLine(omitted: Named[], ids: string[]): string {
42
+ return omitted.length > 0 ? `[Results omitted: ${omitted.map((agent) => label(agent, ids)).join(", ")}. Use wait with fewer targets.]` : "";
43
+ }
44
+
45
+ /** The line naming the Agents still pending, and that a steer ended the wait if one did. */
46
+ function pendingLine(pending: Named[], interrupted: boolean | undefined, ids: string[]): string {
47
+ return pending.length > 0 ? `Still pending: ${pending.map((agent) => label(agent, ids)).join(", ")}${interrupted ? `\n${WAIT_INTERRUPTED}` : ""}` : "";
48
+ }
49
+
38
50
  /** Renders wait results; returns the text and the results the caller has now read. */
39
51
  function renderWait(outcome: WaitOutcome, ids: string[]): { text: string; read: WaitResult[]; details: Record<string, unknown> } {
40
52
  const { results, pending, interrupted } = outcome;
41
- const pendingLine = pending.length > 0 ? `Still pending: ${pending.map((agent) => label(agent, ids)).join(", ")}${interrupted ? `\n${WAIT_INTERRUPTED}` : ""}` : "";
53
+ const pendingText = pendingLine(pending, interrupted, ids);
42
54
  const flag = interrupted ? { interrupted } : {};
43
- if (results.length === 0) return { text: pendingLine || "No results.", read: [], details: { results: [], pending: pending.map((agent) => agent.id), ...flag } };
55
+ const named = pending.map(({ id, name }) => ({ id, name }));
56
+ if (results.length === 0) return { text: pendingText || "No results.", read: [], details: { results: [], pending: named, ...flag } };
44
57
  const only = results[0]!;
45
58
  if (results.length === 1 && pending.length === 0 && !only.history && only.input.state === "completed") {
46
59
  const bounded = boundText(only.input.result ?? "", "pi-agents-wait");
@@ -57,10 +70,10 @@ function renderWait(outcome: WaitOutcome, ids: string[]): { text: string; read:
57
70
  });
58
71
  let text = bounded.text;
59
72
  const read = results.slice(0, bounded.shown);
60
- const omitted = results.slice(bounded.shown);
61
- if (omitted.length > 0) text += `\n\n[Results omitted: ${omitted.map((result) => label(result.agent, ids)).join(", ")}. Use wait with fewer targets.]`;
62
- if (pendingLine) text += `\n\n${pendingLine}`;
63
- return { text, read, details: { results: details, pending: pending.map((agent) => agent.id), ...flag, ...bounded.details } };
73
+ const omittedText = omittedLine(results.slice(bounded.shown).map((result) => result.agent), ids);
74
+ if (omittedText) text += `\n\n${omittedText}`;
75
+ if (pendingText) text += `\n\n${pendingText}`;
76
+ return { text, read, details: { results: details, pending: named, ...flag, ...bounded.details } };
64
77
  }
65
78
 
66
79
  /** Registers the `agent` tool. */
@@ -89,8 +102,18 @@ export function registerAgentTool(
89
102
  startDuration(context.state as DurationState, args.action === "wait", context.executionStarted);
90
103
  return renderToolCall("agent", args, theme, context.lastComponent);
91
104
  },
92
- // `wait` shows how long it has waited.
93
- renderResult: (result, options, theme, context) => renderTextResult(result, options, theme, context),
105
+ // `wait` shows how long it has waited, and when collapsed, each result as a line with the first lines of its body.
106
+ renderResult: (result, options, theme, context) => renderTextResult(result, options, theme, context, (context.args as { action?: string }).action === "wait"
107
+ ? () => {
108
+ const { results = [], pending = [], interrupted, ...truncated } = (result.details ?? {}) as { results?: ReturnType<typeof resultDetails>[]; pending?: Named[]; interrupted?: boolean } & Partial<Truncation>;
109
+ const only = results[0];
110
+ // As `renderWait` returns them, no results, or a single completed result alone, show as they are.
111
+ if (!only || (results.length === 1 && pending.length === 0 && !only.history && only.state === "completed")) return undefined;
112
+ const ids = [...results.map((entry) => entry.id), ...pending.map((agent) => agent.id)];
113
+ const notes = [omittedLine(results.filter((entry) => entry.result === undefined), ids), pendingLine(pending, interrupted, ids)];
114
+ return renderResultList(results.flatMap((entry) => entry.result === undefined ? [] : [{ line: `${label(entry, ids)} ${entry.state}`, body: entry.result }]), notes, truncated, theme);
115
+ }
116
+ : undefined),
94
117
  description: `Create, message, and coordinate Agents. \`spawn\` creates an owned Agent and sends its first input; \`send\` sends an input or a write to a visible Agent; \`wait\` waits for owned Agents and returns unread results; \`list\` finds visible Agents; \`abort\` stops an owned Agent's current turn and queued inputs while keeping it available. When an owned Agent finishes an input while its owner is not waiting for it, the owner receives a notification naming the Agent and outcome; \`wait\` returns the result. Owned Agents under the same root share \`${maxConcurrent}\` execution slots and a limit of \`${maxOutstanding}\` inputs that have not ended. Inputs that start a new turn queue when all slots are busy; new inputs are rejected at the input limit.`,
95
118
  promptSnippet: "Create, message, and coordinate Agents",
96
119
  promptGuidelines: [
@@ -105,7 +128,7 @@ export function registerAgentTool(
105
128
  cwd: Type.Optional(Type.String({ description: "Working directory for `spawn` (default: the caller's current working directory)." })),
106
129
  context: Type.Optional(StringEnum(["fresh", "fork"] as const, { description: "Context for `spawn` (default: fresh). fresh starts without the caller's conversation; fork snapshots it." })),
107
130
  model: Type.Optional(Type.String({ description: "Model for `spawn` as provider/modelId (default: the caller's model)." })),
108
- thinkingLevel: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level for `spawn`: off, minimal, low, medium, high, or xhigh (default: the caller's level)." })),
131
+ thinkingLevel: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level for `spawn`: off, minimal, low, medium, high, xhigh, or max (default: the caller's level)." })),
109
132
  target: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], { description: "Agent ID, name, or unique ID prefix for `send`, `wait`, or `abort`. `wait` and `send` with `deliverAs='write'` also accept an array. When omitted for `wait`, selects all owned Agents with pending or unread results." })),
110
133
  deliverAs: Type.Optional(StringEnum(["followUp", "steer", "write"] as const, { description: "Delivery for `send` (default: followUp). followUp waits until the recipient's current turn ends; steer delivers after its current tool calls, before the next model call; write delivers like steer but never starts a turn and has no result." })),
111
134
  history: Type.Optional(Type.Integer({ minimum: 0, description: "Number of most recent read results to return again per selected Agent for `wait` (default: 0)." })),
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { constants } from 'node:fs';
3
3
  import { open, readdir, stat, unlink } from 'node:fs/promises';
4
4
  import net from 'node:net';
5
- import { join } from 'node:path';
5
+ import { join, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { setTimeout as delay } from 'node:timers/promises';
8
8
  import { FRAME, frame, json, readFrames } from './frames.mjs';
@@ -27,6 +27,11 @@ export function hostStatus(path) {
27
27
  });
28
28
  }
29
29
 
30
+ /** The file of the Session a host runs; Pi keeps a relative session directory relative, so it resolves against the host's cwd. */
31
+ export function hostSessionFile(host) {
32
+ return host.session?.file && resolve(host.cwd, host.session.file);
33
+ }
34
+
30
35
  export function alive(pid) {
31
36
  try { process.kill(pid, 0); return true; }
32
37
  catch (error) { return error.code !== 'ESRCH'; }
@@ -3,7 +3,7 @@
3
3
  import { resolve } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
6
- import { alive, hostStatus, settledHosts, startHost } from './client.mjs';
6
+ import { alive, hostSessionFile, hostStatus, settledHosts, startHost } from './client.mjs';
7
7
  import { readSessionHeader, readSessionId } from '../roots/guard.mjs';
8
8
  import { openStream, request } from '../roots/transport.mjs';
9
9
  import { piPackage } from '../pi.mjs';
@@ -13,8 +13,7 @@ const TIMEOUT_MS = 30000;
13
13
  const NOT_READY = ['ENOENT', 'ECONNREFUSED', 'SOCKET_MODE', 'NOT_READY'];
14
14
  const GONE = ['ENOENT', 'ECONNREFUSED', 'ECONNRESET'];
15
15
  // Options that choose the Session; another Session opens with the rest.
16
- const SESSION_FLAGS = new Set(['--session', '--session-id', '--fork', '--name', '-n']);
17
- const SESSION_SWITCHES = new Set(['-c', '--continue', '-r', '--resume']);
16
+ const SESSION_OPTIONS = new Set(['--session', '--session-id', '--fork', '--name', '-n', '-c', '--continue', '-r', '--resume']);
18
17
 
19
18
  function fault(code, message) {
20
19
  return Object.assign(new Error(message), { code });
@@ -42,20 +41,39 @@ async function connect(paths, id, handlers, live, role = 'rpc') {
42
41
  }
43
42
  }
44
43
 
44
+ /**
45
+ * Whether Pi's `parseArgs` reads `next` as the value of `arg`. It decides by the form of `next`, so a probe of that form
46
+ * shows it: the probe is consumed, or parsed on its own as a message, a file, an unknown short option, or an unknown flag.
47
+ */
48
+ function takesValue(parseArgs, arg, next) {
49
+ const probe = next.startsWith('---') ? '---\0' : next.startsWith('-') ? '-\0' : next.startsWith('@') ? '@\0' : '\0';
50
+ const parsed = parseArgs([arg, probe]);
51
+ if (probe === '\0') return !parsed.messages.includes(probe);
52
+ if (probe === '@\0') return !parsed.fileArgs.includes('\0');
53
+ // Only Pi's own diagnostic for the probe alone; an option may consume it and report it as an invalid value.
54
+ if (probe === '-\0') return !parsed.diagnostics.some(({ message }) => message === `Unknown option: ${probe}`);
55
+ return !parsed.unknownFlags.has('-\0');
56
+ }
57
+
58
+ /** `args` in the groups Pi reads: an option with its value, or an argument alone; from `--` on, one group. */
59
+ export function argGroups(parseArgs, args) {
60
+ const groups = [];
61
+ for (let i = 0; i < args.length; i++) {
62
+ if (args[i] === '--') {
63
+ groups.push(args.slice(i));
64
+ break;
65
+ }
66
+ groups.push(i + 1 < args.length && takesValue(parseArgs, args[i], args[i + 1]) ? [args[i], args[++i]] : [args[i]]);
67
+ }
68
+ return groups;
69
+ }
70
+
45
71
  /** `args` without the prompts, and without the options that make Pi non-interactive. */
46
72
  function withoutPrompts(parseArgs, args) {
47
- const options = [];
48
- let separated = false;
49
- for (let i = 0, before = parseArgs([]); i < args.length; i++) {
50
- const after = parseArgs(args.slice(0, i + 1));
51
- const positional = after.messages.length > before.messages.length || after.fileArgs.length > before.fileArgs.length;
52
- before = after;
53
- if (separated || positional) continue;
54
- if (args[i] === '--') separated = true;
55
- else if (args[i] === '--mode') i++;
56
- else if (args[i] !== '-p' && args[i] !== '--print') options.push(args[i]);
57
- }
58
- return options;
73
+ return argGroups(parseArgs, args).filter(group => {
74
+ const parsed = parseArgs(group);
75
+ return !parsed.messages.length && !parsed.fileArgs.length && !['-p', '--print', '--mode'].includes(group[0]);
76
+ }).flat();
59
77
  }
60
78
 
61
79
  /** The prompts Pi sends on startup for `parsed`: its initial message, then each further message. */
@@ -90,7 +108,7 @@ export async function runHeadless({ pi, paths, args, mode, findRunning, warnIgno
90
108
 
91
109
  // The host runs Pi without the prompts, which arrive as RPC commands instead.
92
110
  const hostArgs = withoutPrompts(parseArgs, args);
93
- const sessionArgs = hostArgs.filter((arg, i) => !SESSION_FLAGS.has(arg) && !SESSION_SWITCHES.has(arg) && !SESSION_FLAGS.has(hostArgs[i - 1]));
111
+ const sessionArgs = argGroups(parseArgs, hostArgs).filter(([option]) => !SESSION_OPTIONS.has(option)).flat();
94
112
 
95
113
  /** Starts a host for `hostArgs` in `cwd`; resolves once its Pi reports the Session. */
96
114
  async function host(hostArgs, cwd) {
@@ -108,10 +126,12 @@ export async function runHeadless({ pi, paths, args, mode, findRunning, warnIgno
108
126
 
109
127
  /** Connects to the Session in `file`, starting it in a host in `cwd` if no Pi runs it. */
110
128
  async function open(file, cwd, handlers) {
129
+ // A Session Pi created in a relative session directory has a relative file.
130
+ file = resolve(cwd, file);
111
131
  const deadline = performance.now() + TIMEOUT_MS;
112
132
  for (;;) {
113
133
  const id = readSessionId(file);
114
- const holds = h => h.session && (h.session.id === id || h.session.file === file);
134
+ const holds = h => h.session && (h.session.id === id || hostSessionFile(h) === file);
115
135
  try {
116
136
  const held = (await settledHosts(paths, holds)).find(holds);
117
137
  if (held) return await connect(paths, held.session.id, handlers, () => alive(held.pid));
package/src/index.ts CHANGED
@@ -15,8 +15,10 @@ import { Agents, type Limits } from "./agents/agents.ts";
15
15
  import {
16
16
  customMessage,
17
17
  liveWork,
18
+ MESSAGE_TYPE,
18
19
  messageText,
19
20
  rememberRoots,
21
+ sender,
20
22
  visibleIds,
21
23
  nodes,
22
24
  receivedMessageIds,
@@ -39,6 +41,7 @@ import { createReporter } from "./roots/report.ts";
39
41
  import { installQueuePatches } from "./roots/queue.ts";
40
42
  import { agentSession, installRpcPatches } from "./roots/rpc.ts";
41
43
  import { createRootRuntime } from "./roots/runtime.ts";
44
+ import { renderAgentMessage } from "./ui/message.ts";
42
45
  import { createPanel } from "./ui/panel.ts";
43
46
  import { showQueue } from "./ui/queue.ts";
44
47
  import { openTaskViewer } from "./ui/viewer.ts";
@@ -105,7 +108,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
105
108
  if (message.user) return pi.sendUserMessage(message.body, { deliverAs: delivery === "steer" ? "steer" : "followUp" });
106
109
  // The header names the sender by a short ID unique among the roots this root sees.
107
110
  rememberRoots((await roots.roots()).map((root) => root.id));
108
- if (node) node.receive(messageText(message.sender!, false, delivery, message.body, visibleIds(node.scopeId, true)), delivery, message.id);
111
+ if (!node) return;
112
+ const ids = visibleIds(node.scopeId, true);
113
+ node.receive(messageText(message.sender!, false, delivery, message.body, ids), delivery, message.id, sender(message.sender!, ids));
109
114
  },
110
115
  treeIdle: () => node === undefined || treeIdle(node.rootId),
111
116
  receivedIds: receivedMessageIds,
@@ -150,9 +155,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
150
155
  cwd: () => ctx?.cwd ?? current.cwd,
151
156
  busy: () => !(ctx?.isIdle() ?? true),
152
157
  sessionFile: () => ctx?.sessionManager.getSessionFile(),
153
- receive: (text, delivery, messageId) => {
158
+ receive: (text, delivery, messageId, from) => {
154
159
  const as = created.stopped ? "write" : delivery;
155
- pi.sendMessage(customMessage(text, as, messageId), as === "write"
160
+ pi.sendMessage(customMessage(text, as, { messageId, sender: from }), as === "write"
156
161
  ? { deliverAs: "steer" }
157
162
  : { deliverAs: as, triggerTurn: true });
158
163
  queue.update();
@@ -209,6 +214,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
209
214
 
210
215
  registerAgentTool(pi, { limits: settings, roots, requireNode });
211
216
  registerProgramTools(pi, { agentDir, settings, piCodemode, requireNode, programs: () => programs });
217
+ pi.registerMessageRenderer(MESSAGE_TYPE, renderAgentMessage);
212
218
 
213
219
  pi.registerCommand("tasks", {
214
220
  description: "Watch an Agent or Program; `/tasks panel` toggles the panel",
package/src/output.ts CHANGED
@@ -18,6 +18,8 @@ export interface BoundedText {
18
18
  text: string;
19
19
  /** The kept prefix of the text, without the notice. */
20
20
  kept: string;
21
+ /** The notice naming the file with the full text. */
22
+ notice?: string;
21
23
  details?: Truncation;
22
24
  }
23
25
 
@@ -30,9 +32,11 @@ export function boundText(text: string, prefix: string): BoundedText {
30
32
  const summary = truncation.firstLineExceedsLimit
31
33
  ? `Line 1 is ${formatSize(Buffer.byteLength(text.split("\n")[0]!, "utf8"))}, exceeds ${formatSize(truncation.maxBytes)} limit.`
32
34
  : `Showing lines 1-${truncation.outputLines} of ${truncation.totalLines}${truncation.truncatedBy === "bytes" ? ` (${formatSize(truncation.maxBytes)} limit)` : ""}.`;
35
+ const notice = `[${summary} Full output: ${fullOutputPath}]`;
33
36
  return {
34
- text: `${truncation.content}\n\n[${summary} Full output: ${fullOutputPath}]`,
37
+ text: `${truncation.content}\n\n${notice}`,
35
38
  kept: truncation.content,
39
+ notice,
36
40
  details: { truncation, fullOutputPath },
37
41
  };
38
42
  }
@@ -25,6 +25,20 @@ export function loadCodemode(): Promise<Codemode> {
25
25
  return loaded;
26
26
  }
27
27
 
28
+ /** Where Pi's executor starts codemode workers from. */
29
+ export interface PiWorkerSource {
30
+ getQuickJSWasmPath(): string;
31
+ getCodemodeWorkerSpecifier(): string | URL | undefined;
32
+ }
33
+
34
+ let workerSource: Promise<PiWorkerSource> | undefined;
35
+
36
+ /** Loads Pi's `dist/config.js`, the module its executor reads them from. */
37
+ export function loadPiWorkerSource(): Promise<PiWorkerSource> {
38
+ workerSource ??= import(pathToFileURL(join(getPackageDir(), "dist", "config.js")).href);
39
+ return workerSource;
40
+ }
41
+
28
42
  /** Parts of Pi's `codemode` tool that its package entry does not export. */
29
43
  export interface PiCodemode {
30
44
  /** Pi's `codemode` tool definition; `program` reuses its `prepareLoadout` and, since Pi 1.2.0, `reachesTools`. */
@@ -6,7 +6,7 @@
6
6
  import { AsyncLocalStorage } from "node:async_hooks";
7
7
  import type { CodemodeResult, CodemodeTool } from "@earendil-works/pi-codemode";
8
8
  import type { AgentToolResult, AgentToolUpdateCallback, ExtensionToolContext } from "@earendil-works/pi-coding-agent";
9
- import { loadCodemode, loadPiCodemode } from "./codemode.ts";
9
+ import { loadCodemode, loadPiCodemode, loadPiWorkerSource } from "./codemode.ts";
10
10
  import type { DetachedCallOptions } from "./host-patches.ts";
11
11
 
12
12
  export const PROGRAM_TOOL_NAME = "program";
@@ -104,20 +104,67 @@ interface SandboxExtras {
104
104
  const sandboxExtras = new AsyncLocalStorage<SandboxExtras>();
105
105
  let sandboxPatch: Promise<void> | undefined;
106
106
 
107
+ /**
108
+ * Since Pi 1.3.0, its executor admits at most two codemode workers per process, which background
109
+ * Programs waiting on Agents use up. A Program's worker is admitted on its own terms instead: any
110
+ * number may run, and none starts while this many are still exiting after their scripts ended.
111
+ */
112
+ const MAX_EXITING_WORKERS = 2;
113
+ const exitingWorkers = new Set<{ exited: boolean }>();
114
+
115
+ /** A launcher for one Program's worker, from where Pi's executor starts its own. */
116
+ function programWorker(codemode: Awaited<ReturnType<typeof loadCodemode>>, wasmPath: string, workerUrl: string | URL | undefined) {
117
+ let worker: { exited: boolean } | undefined;
118
+ let ended = false;
119
+ const options = {
120
+ wasm: codemode.loadQuickJSWasm(wasmPath),
121
+ workerUrl,
122
+ acquireWorker: () => {
123
+ // The script can end while the QuickJS module loads, before its worker starts.
124
+ if (ended) throw new Error("The Program ended before its worker started.");
125
+ if (exitingWorkers.size >= MAX_EXITING_WORKERS) {
126
+ throw new Error(`${exitingWorkers.size} workers of ended Programs are still shutting down. Retry after they exit.`);
127
+ }
128
+ const started = { exited: false };
129
+ worker = started;
130
+ return () => {
131
+ started.exited = true;
132
+ exitingWorkers.delete(started);
133
+ };
134
+ },
135
+ };
136
+ const sandbox = new codemode.CodemodeSandbox(options) as unknown as Record<string, unknown>;
137
+ return {
138
+ launch: sandbox.launch,
139
+ ended() {
140
+ ended = true;
141
+ if (worker && !worker.exited) exitingWorkers.add(worker);
142
+ },
143
+ };
144
+ }
145
+
107
146
  async function patchSandbox(): Promise<void> {
108
- const proto = (await loadCodemode()).CodemodeSandbox.prototype as unknown as Record<string, any>;
147
+ const [codemode, source] = await Promise.all([loadCodemode(), loadPiWorkerSource()]);
148
+ const wasmPath = source.getQuickJSWasmPath();
149
+ const workerUrl = source.getCodemodeWorkerSpecifier();
150
+ const proto = codemode.CodemodeSandbox.prototype as unknown as Record<string, any>;
109
151
  const execute = proto.execute;
110
152
  proto.execute = function (this: Record<string, any>, code: string, options: Record<string, unknown> = {}) {
111
153
  const current = sandboxExtras.getStore();
112
154
  if (!current || current.used) return execute.call(this, code, options);
113
155
  current.used = true;
114
156
  for (const global of current.globals) this.globalsByName.set(global.name, global);
157
+ // Before Pi 1.2.0, the sandbox starts its worker itself, with no limit.
158
+ const worker = typeof this.launch === "function" ? programWorker(codemode, wasmPath, workerUrl) : undefined;
159
+ if (worker) this.launch = worker.launch;
115
160
  const timeoutMs = Math.min(current.timeoutMs, (options.timeoutMs as number | undefined) ?? this.timeoutMs);
116
161
  return execute.call(this, current.prepare(code), { ...options, timeoutMs }).then((result: CodemodeResult) => {
162
+ worker?.ended();
117
163
  current.result = result;
118
164
  current.ended();
119
165
  return result;
120
166
  }, (error: unknown) => {
167
+ worker?.ended();
121
168
  current.ended();
122
169
  throw error;
123
170
  });
@@ -2,7 +2,10 @@ import { highlightCode, type ExtensionAPI, type ToolDefinition } from "@earendil
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { CODEMODE_TOOL_NAME, type PiCodemode } from "./codemode.ts";
4
4
  import { PROGRAM_TOOL_NAME } from "./execute.ts";
5
- import { formatToolCall, moreLines, renderTextResult, startDuration, type DurationState } from "../render-call.ts";
5
+ import { shortId } from "../agents/registry.ts";
6
+ import { formatDuration, formatToolCall, moreLines, renderResultList, renderTextResult, startDuration, type DurationState } from "../render-call.ts";
7
+ import type { Truncation } from "../output.ts";
8
+ import { omittedLine, runningLine, type ProgramResultDetails } from "./programs.ts";
6
9
 
7
10
  /** The first line of the `codemode` description, which the `program` intro replaces. */
8
11
  const CODEMODE_FIRST_LINE = "Run JavaScript code to orchestrate/compose tool calls\n";
@@ -91,9 +94,21 @@ export function programRenderers(piCodemode: PiCodemode): Renderers {
91
94
  component.setText(text);
92
95
  return component;
93
96
  },
94
- // `wait` shows how long it has waited; the other results render like `codemode`.
97
+ // `wait` shows how long it has waited, and when collapsed, each result as a line with the first lines of its output;
98
+ // the other results render like `codemode`.
95
99
  renderResult: (result, options, theme, context) => (context.args as { action?: string }).action === "wait"
96
- ? renderTextResult(result, options, theme, context)
100
+ ? renderTextResult(result, options, theme, context, () => {
101
+ const { results = [], running = [], interrupted, ...truncated } = (result.details ?? {}) as { results?: ProgramResultDetails[]; running?: string[]; interrupted?: boolean } & Partial<Truncation>;
102
+ const only = results[0];
103
+ // As `renderProgramWait` returns them, no results, or a single completed result alone, show as they are.
104
+ if (!only || (results.length === 1 && running.length === 0 && only.state === "completed")) return undefined;
105
+ const ids = [...results.map((entry) => entry.id), ...running];
106
+ const notes = [omittedLine(results.flatMap((entry) => entry.output === undefined ? [entry.id] : []), ids), runningLine(running, interrupted, ids)];
107
+ return renderResultList(results.flatMap(({ id, state, wallTimeSeconds, output }) => output === undefined ? [] : [{
108
+ line: [`Program ${shortId(id, ids)}`, state, ...(wallTimeSeconds === undefined ? [] : [formatDuration(wallTimeSeconds * 1000)])].join(" "),
109
+ body: output,
110
+ }]), notes, truncated, theme);
111
+ })
97
112
  : piCodemode.renderResult(result, options, theme, context) as Text,
98
113
  };
99
114
  }
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
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
- import { boundBlocks, boundText } from "../output.ts";
5
+ import { boundBlocks, boundText, type Truncation } 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";
@@ -327,23 +327,64 @@ function resultBlock(record: ProgramRecord, ids: string[]): string {
327
327
  return `<program-result id="${shortId(record.id, ids)}" status="${record.state}">\n${textOf(record.content)}\n</program-result>`;
328
328
  }
329
329
 
330
- /** Renders wait results; returns the content and the results the caller has now received. */
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}` : ""}` : "";
333
- if (results.length === 0) return { content: [{ type: "text", text: runningLine || "No results." }], returned: [] };
330
+ // 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: [] };
334
367
  const images = (shown: ProgramRecord[]) => shown.flatMap((record) => (record.content ?? []).filter((item) => item.type === "image"));
335
368
  const only = results[0]!;
336
369
  if (results.length === 1 && running.length === 0 && only.state === "completed") {
337
370
  const bounded = boundText(textOf(only.content), "pi-agents-program-wait");
338
- return { content: [{ type: "text", text: bounded.text }, ...images(results)], returned: results };
371
+ return { content: [{ type: "text", text: bounded.text }, ...images(results)], details: { results: [resultDetails(only)], ...bounded.details }, returned: results };
339
372
  }
340
- const bounded = boundBlocks(results.map((record) => resultBlock(record, ids)), "program-result", "pi-agents-program-wait");
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
+ });
341
382
  let text = bounded.text;
342
383
  const returned = results.slice(0, bounded.shown);
343
- const omitted = results.slice(bounded.shown);
344
- if (omitted.length > 0) text += `\n\n[Results omitted: ${omitted.map((record) => shortId(record.id, ids)).join(", ")}. Use wait with fewer targets.]`;
345
- if (runningLine) text += `\n\n${runningLine}`;
346
- return { content: [{ type: "text", text }, ...images(returned)], returned };
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 };
347
388
  }
348
389
 
349
390
  export function programListLine(record: ProgramRecord, ids: string[]): string {
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Agents, settlesBy, SUBMIT_RESULT_TOOL_NAME, THINKING_LEVELS, type Limits } from "../agents/agents.ts";
3
- import { label, messageText, nodes, treeChanged, visibleIds, type AgentNode } from "../agents/registry.ts";
3
+ import { label, messageText, nodes, sender, treeChanged, visibleIds, type AgentNode } from "../agents/registry.ts";
4
4
  import { CLEANUP_TIMEOUT_MS } from "./execute.ts";
5
5
  import type { AgentOptions, ProgramAgentHost, SendOptions } from "./sandbox.ts";
6
6
 
@@ -70,12 +70,13 @@ export function programScope(
70
70
  }
71
71
  const agent = agents.ownedTarget(agentId).record;
72
72
  const body = schema === undefined ? message : `${message}\n\nWhen done, call \`${SUBMIT_RESULT_TOOL_NAME}\` with a \`value\` that matches this JSON Schema:\n${JSON.stringify(schema)}`;
73
- const text = messageText(from, true, delivery, body, visibleIds(agentId, false));
73
+ const ids = visibleIds(agentId, false);
74
+ const text = messageText(from, true, delivery, body, ids);
74
75
  if (delivery === "write") {
75
- agents.accept(agentId, text, "write", { fromOwner: true, notification: false });
76
+ agents.accept(agentId, text, "write", { fromOwner: true, notification: false, sender: sender(from, ids) });
76
77
  return undefined;
77
78
  }
78
- const { record, timedOut } = await agents.request(agentId, text, delivery, schema, timeout === undefined ? undefined : timeout * 1000);
79
+ const { record, timedOut } = await agents.request(agentId, text, delivery, sender(from, ids), schema, timeout === undefined ? undefined : timeout * 1000);
79
80
  if (record.state === "completed") return schema === undefined ? record.result : record.value;
80
81
  if (record.state === "failed") throw new Error(`Agent ${label(agent, agents.ids())} failed: ${record.result}`);
81
82
  // An aborted input, timed out or not, shows as a cancelled call.
@@ -203,7 +203,7 @@ export function registerProgramTools(
203
203
  const outcome = programs.running(selected) ? await self.agents.whileSuspended(waitFor, signal, true) : await waitFor();
204
204
  const rendered = renderProgramWait(outcome, programs.ids());
205
205
  programs.markReturned(rendered.returned);
206
- return { content: rendered.content, details: { running: outcome.running.map((record) => record.id), ...(outcome.interrupted && { interrupted: true }) } };
206
+ return { content: rendered.content, details: { ...rendered.details, running: outcome.running.map((record) => record.id), ...(outcome.interrupted && { interrupted: true }) } };
207
207
  }
208
208
  case "list": {
209
209
  rejectFields(params, "list", ["code", "background", "timeout", "target"]);