@tshu-w/pi-agents 0.2.2 → 0.4.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.
- package/CHANGELOG.md +84 -0
- package/README.md +2 -1
- package/bin/pd.mjs +23 -3
- package/docs/HOSTS.md +2 -0
- package/docs/PROGRAM.md +17 -28
- package/docs/SPEC.md +28 -39
- package/package.json +3 -2
- package/src/agents/agents.ts +126 -82
- package/src/agents/registry.ts +69 -17
- package/src/agents/tool.ts +31 -87
- package/src/host/client.mjs +38 -5
- package/src/index.ts +7 -4
- package/src/main-agent.md +6 -0
- package/src/main-agent.ts +47 -0
- package/src/output.ts +0 -18
- package/src/programs/execute.ts +25 -5
- package/src/programs/loadout.ts +14 -19
- package/src/programs/programs.ts +39 -115
- package/src/programs/scope.ts +1 -1
- package/src/programs/tool.ts +16 -12
- package/src/roots/guard.mjs +5 -0
- package/src/roots/paths.mjs +1 -0
- package/src/ui/message.ts +41 -7
- package/src/ui/panel.ts +25 -40
- package/src/ui/viewer.ts +278 -152
- package/src/workbench.mjs +10 -6
package/src/programs/programs.ts
CHANGED
|
@@ -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,
|
|
4
|
-
import { shortId, trackTreeWork } from "../agents/registry.ts";
|
|
5
|
-
import {
|
|
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
|
-
|
|
46
|
+
/** Stopped by the caller, which is not notified. */
|
|
47
|
+
silent?: boolean;
|
|
50
48
|
}
|
|
51
49
|
|
|
52
50
|
export interface ProgramWaitOutcome {
|
|
53
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
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()
|
|
118
|
+
const record: ProgramRecord = { id: randomUUID(), state: "running", startedAt: Date.now() };
|
|
122
119
|
const controller = new AbortController();
|
|
123
|
-
const program: Running = { record, code, controller
|
|
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.
|
|
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
|
|
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)) ??
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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.
|
|
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
|
|
294
|
-
record.
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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}`;
|
package/src/programs/scope.ts
CHANGED
|
@@ -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,
|
|
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);
|
package/src/programs/tool.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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
|
-
|
|
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>>;
|
package/src/roots/guard.mjs
CHANGED
|
@@ -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;
|
package/src/roots/paths.mjs
CHANGED
|
@@ -26,6 +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
|
+
mainSession: join(state, 'main.json'),
|
|
29
30
|
host(pid) { return join(runtime, `h-${pid}.sock`); },
|
|
30
31
|
locks: join(runtime, 'locks'),
|
|
31
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 {
|
|
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
|
|
12
|
-
const details = message.details as { user?: boolean; sender?: Sender } | undefined;
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
if (
|
|
16
|
-
|
|
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));
|
package/src/ui/panel.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Container, TruncatedText } from "@earendil-works/pi-tui";
|
|
3
|
-
import { label,
|
|
3
|
+
import { label, liveWork, onTreeChange, programLabel, taskLive, tasksOf, type AgentNode, type Spend, type Task } from "../agents/registry.ts";
|
|
4
4
|
|
|
5
5
|
const WIDGET_KEY = "pi-agents";
|
|
6
6
|
|
|
@@ -23,53 +23,38 @@ function cost({ cost, tokens, unpriced }: Spend): string {
|
|
|
23
23
|
return tokens < 10_000_000 ? `${(tokens / 1_000_000).toFixed(1)}M tok` : `${Math.round(tokens / 1_000_000)}M tok`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** The cost of `spent`, once there is any. */
|
|
27
|
+
function spend(spent: Spend | undefined): string[] {
|
|
28
|
+
return spent && (spent.cost > 0 || spent.tokens > 0) ? [cost(spent)] : [];
|
|
29
|
+
}
|
|
30
|
+
|
|
26
31
|
/**
|
|
27
|
-
* The task panel: the Agent's live
|
|
28
|
-
*
|
|
29
|
-
* it has cost. Empty when none.
|
|
32
|
+
* The task panel: the Agent's live work as a tree, in the order it started, with how long each has run
|
|
33
|
+
* and what it has cost. Empty when none.
|
|
30
34
|
*/
|
|
31
35
|
export function panelLines(self: AgentNode, now = Date.now()): string[] {
|
|
32
|
-
const rows: Array<{ line: string; parent?: number; shown: boolean }> = [];
|
|
33
36
|
const ids = self.agents.ids();
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const visit = (node: AgentNode, level: number, parent?: number) => {
|
|
40
|
-
const children = [
|
|
41
|
-
...node.agents.owned().map((entry) => ({ at: entry.createdAt ?? "", entry })),
|
|
42
|
-
...programsOf(node).filter((program) => program.background).map((program) => ({ at: program.createdAt ?? "", program })),
|
|
43
|
-
].sort((a, b) => a.at.localeCompare(b.at));
|
|
44
|
-
for (const child of children) {
|
|
45
|
-
const index = rows.length;
|
|
46
|
-
if ("program" in child) {
|
|
47
|
-
const since = Date.parse(child.program.createdAt ?? "");
|
|
48
|
-
const time = since ? [elapsed(since, now), ...(child.program.spent ? [cost(child.program.spent)] : [])] : [];
|
|
49
|
-
rows.push({ line: `${" ".repeat(level)}${[programLabel(child.program), "running", ...time].join(" ")}`, parent, shown: false });
|
|
50
|
-
live += 1;
|
|
51
|
-
show(index);
|
|
52
|
-
visit(child.program, level + 1, index);
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
const { entry } = child;
|
|
56
|
-
const own = node.agents.counts(entry.id);
|
|
57
|
-
const pending = own?.pending ? [`${own.pending} pending`] : [];
|
|
58
|
-
const time = own?.since ? [elapsed(own.since, now), ...(own.spent ? [cost(own.spent)] : [])] : [];
|
|
59
|
-
rows.push({ line: `${" ".repeat(level)}Agent ${[label(entry, ids), entry.state, ...time, ...pending].join(" ")}`, parent, shown: false });
|
|
60
|
-
if (entry.state !== "idle") {
|
|
61
|
-
live += 1;
|
|
62
|
-
show(index);
|
|
63
|
-
}
|
|
64
|
-
const loaded = nodes.get(entry.id);
|
|
65
|
-
if (loaded) visit(loaded, level + 1, index);
|
|
37
|
+
const line = (task: Task): string => {
|
|
38
|
+
if ("program" in task) {
|
|
39
|
+
const since = Date.parse(task.program.createdAt ?? "");
|
|
40
|
+
const time = since ? [elapsed(since, now), ...spend(task.program.spent)] : [];
|
|
41
|
+
return [programLabel(task.program), "running", ...time].join(" ");
|
|
66
42
|
}
|
|
43
|
+
const { entry, owner } = task;
|
|
44
|
+
const own = owner.agents.counts(entry.id);
|
|
45
|
+
const pending = own?.pending ? [`${own.pending} pending`] : [];
|
|
46
|
+
const time = own?.since ? [elapsed(own.since, now), ...spend(own.spent)] : [];
|
|
47
|
+
return `Agent ${[label(entry, ids), entry.state, ...time, ...pending].join(" ")}`;
|
|
67
48
|
};
|
|
68
|
-
|
|
69
|
-
const
|
|
49
|
+
// A row is listed while it is live or has live work under it, so the tree stays readable.
|
|
50
|
+
const rows = (tasks: Task[], level: number): string[] => tasks.flatMap((task) => {
|
|
51
|
+
const under = rows(task.tasks, level + 1);
|
|
52
|
+
return taskLive(task) || under.length > 0 ? [`${" ".repeat(level)}${line(task)}`, ...under] : [];
|
|
53
|
+
});
|
|
54
|
+
const lines = rows(tasksOf(self), 0);
|
|
70
55
|
if (lines.length === 0) return [];
|
|
71
56
|
if (lines.length > MAX_LINES - 1) lines.splice(MAX_LINES - 2, Infinity, `+${lines.length - (MAX_LINES - 2)} more`);
|
|
72
|
-
return [`Tasks (${
|
|
57
|
+
return [`Tasks (${liveWork(self)} live, /tasks to view)`, ...lines.map((line) => ` ${line}`)];
|
|
73
58
|
}
|
|
74
59
|
|
|
75
60
|
/**
|