@tshu-w/pi-agents 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -20
- package/bin/daemon.mjs +32 -14
- package/bin/host.mjs +9 -1
- package/bin/pd.mjs +80 -32
- package/docs/{WORKBENCH.md → HOSTS.md} +48 -36
- package/docs/PROGRAM.md +31 -34
- package/docs/SPEC.md +76 -39
- package/package.json +7 -5
- package/src/agents/agents.ts +103 -30
- package/src/agents/registry.ts +30 -3
- package/src/agents/search.ts +21 -4
- package/src/agents/tool.ts +10 -5
- package/src/daemon/client.mjs +4 -1
- package/src/host/client.mjs +24 -4
- package/src/host/extension.ts +38 -14
- package/src/host/rpc-client.mjs +360 -0
- package/src/index.ts +36 -14
- package/src/programs/codemode.ts +2 -2
- package/src/programs/execute.ts +150 -34
- package/src/programs/host-patches.ts +56 -27
- package/src/programs/programs.ts +78 -11
- package/src/programs/run-output.mjs +11 -0
- package/src/programs/sandbox.ts +17 -4
- package/src/programs/scope.ts +11 -4
- package/src/programs/tool.ts +172 -16
- package/src/roots/guard.mjs +8 -2
- package/src/roots/queue.ts +52 -0
- package/src/roots/report.ts +12 -6
- package/src/roots/rpc.ts +618 -0
- package/src/roots/runtime.ts +17 -2
- package/src/roots/transport.mjs +41 -23
- package/src/ui/panel.ts +39 -7
- package/src/ui/queue.ts +46 -0
- package/src/ui/viewer.ts +119 -11
- package/src/workbench.mjs +48 -20
package/src/programs/scope.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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
3
|
import { label, messageText, nodes, treeChanged, visibleIds, type AgentNode } from "../agents/registry.ts";
|
|
4
|
+
import { CLEANUP_TIMEOUT_MS } from "./execute.ts";
|
|
4
5
|
import type { AgentOptions, ProgramAgentHost, SendOptions } from "./sandbox.ts";
|
|
5
6
|
|
|
6
|
-
const CLEANUP_TIMEOUT_MS = 15_000;
|
|
7
7
|
const DELIVERIES = ["followUp", "steer", "write"];
|
|
8
8
|
|
|
9
9
|
export interface ProgramScope {
|
|
@@ -32,6 +32,7 @@ export function programScope(
|
|
|
32
32
|
program: true,
|
|
33
33
|
background: options.background,
|
|
34
34
|
createdAt: new Date().toISOString(),
|
|
35
|
+
spent: { cost: 0, tokens: 0, unpriced: false },
|
|
35
36
|
agents,
|
|
36
37
|
cwd: () => ctx.cwd,
|
|
37
38
|
busy: () => true,
|
|
@@ -54,7 +55,7 @@ export function programScope(
|
|
|
54
55
|
},
|
|
55
56
|
send: async (agentId, message, sendOptions: SendOptions) => {
|
|
56
57
|
const delivery = sendOptions.deliverAs ?? "followUp";
|
|
57
|
-
const { schema } = sendOptions;
|
|
58
|
+
const { schema, timeout } = sendOptions;
|
|
58
59
|
if (typeof message !== "string" || !message.trim()) throw new Error("send() requires a non-empty message");
|
|
59
60
|
if (!DELIVERIES.includes(delivery)) throw new Error('send() deliverAs must be "followUp", "steer", or "write"');
|
|
60
61
|
if (schema !== undefined) {
|
|
@@ -63,6 +64,10 @@ export function programScope(
|
|
|
63
64
|
throw new Error("send() schema must be a JSON Schema object or boolean");
|
|
64
65
|
}
|
|
65
66
|
}
|
|
67
|
+
if (timeout !== undefined) {
|
|
68
|
+
if (delivery !== "followUp") throw new Error('send() accepts timeout only with deliverAs "followUp"');
|
|
69
|
+
if (typeof timeout !== "number" || !(timeout > 0) || !Number.isFinite(timeout)) throw new Error("send() timeout must be a positive number of seconds");
|
|
70
|
+
}
|
|
66
71
|
const agent = agents.ownedTarget(agentId).record;
|
|
67
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)}`;
|
|
68
73
|
const text = messageText(from, true, delivery, body, visibleIds(agentId, false));
|
|
@@ -70,9 +75,11 @@ export function programScope(
|
|
|
70
75
|
agents.accept(agentId, text, "write", { fromOwner: true, notification: false });
|
|
71
76
|
return undefined;
|
|
72
77
|
}
|
|
73
|
-
const record = await agents.request(agentId, text, delivery, schema);
|
|
78
|
+
const { record, timedOut } = await agents.request(agentId, text, delivery, schema, timeout === undefined ? undefined : timeout * 1000);
|
|
74
79
|
if (record.state === "completed") return schema === undefined ? record.result : record.value;
|
|
75
|
-
|
|
80
|
+
if (record.state === "failed") throw new Error(`Agent ${label(agent, agents.ids())} failed: ${record.result}`);
|
|
81
|
+
// An aborted input, timed out or not, shows as a cancelled call.
|
|
82
|
+
throw Object.assign(new Error(timedOut ? `Agent ${label(agent, agents.ids())} timed out after ${timeout} seconds.` : `Agent ${label(agent, agents.ids())} was aborted.`), { cancelled: true });
|
|
76
83
|
},
|
|
77
84
|
abort: async (agentId) => {
|
|
78
85
|
await agents.abort(agents.ownedTarget(agentId));
|
package/src/programs/tool.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
|
+
import { Box } from "@earendil-works/pi-tui";
|
|
4
|
+
import { writeSync } from "node:fs";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import * as path from "node:path";
|
|
3
7
|
import { Type } from "typebox";
|
|
4
|
-
import { SUBMIT_RESULT_TOOL_NAME, type Limits } from "../agents/agents.ts";
|
|
5
|
-
import { nodes, type AgentNode } from "../agents/registry.ts";
|
|
8
|
+
import { settlesBy, steerQueued, SUBMIT_RESULT_TOOL_NAME, type Limits } from "../agents/agents.ts";
|
|
9
|
+
import { nodes, trackTreeWork, type AgentNode } from "../agents/registry.ts";
|
|
6
10
|
import { rejectFields } from "../params.ts";
|
|
11
|
+
import { agentSession } from "../roots/rpc.ts";
|
|
7
12
|
import { programScope } from "./scope.ts";
|
|
8
13
|
import type { PiCodemode } from "./codemode.ts";
|
|
9
|
-
import { executeProgram, PROGRAM_TOOL_NAME, type ProgramRunOptions } from "./execute.ts";
|
|
14
|
+
import { CLEANUP_TIMEOUT_MS, executeProgram, PROGRAM_TOOL_NAME, type ProgramRunOptions } from "./execute.ts";
|
|
15
|
+
import { commandSession, runDetached } from "./host-patches.ts";
|
|
10
16
|
import { programDescription, programLoadout, programRenderers } from "./loadout.ts";
|
|
11
17
|
import { randomUUID } from "node:crypto";
|
|
12
18
|
import { CLEANUP_ERROR, programListLine, Programs, renderProgramWait } from "./programs.ts";
|
|
19
|
+
import { PROGRAM_MESSAGE, runOutput } from "./run-output.mjs";
|
|
13
20
|
import { agentGlobals, withAgentPrefix } from "./sandbox.ts";
|
|
14
21
|
|
|
15
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.
|
|
@@ -18,6 +25,19 @@ Program code is the body of an async function. It calls the caller's tools throu
|
|
|
18
25
|
|
|
19
26
|
\`program\` replaces the \`codemode\` tool; wherever \`codemode\` is mentioned, use \`program\`.`;
|
|
20
27
|
|
|
28
|
+
|
|
29
|
+
interface ProgramCall {
|
|
30
|
+
path: string;
|
|
31
|
+
args?: string;
|
|
32
|
+
code: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ProgramMessageDetails {
|
|
36
|
+
call: ProgramCall;
|
|
37
|
+
calls?: unknown[];
|
|
38
|
+
isError: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
const AGENT_API = `Agent API:
|
|
22
42
|
\`\`\`ts
|
|
23
43
|
type JsonSchema = boolean | Record<string, unknown>;
|
|
@@ -38,11 +58,13 @@ interface SendOptions {
|
|
|
38
58
|
deliverAs?: "followUp" | "steer" | "write";
|
|
39
59
|
/** \`followUp\` only. The Agent must submit a value matching it. */
|
|
40
60
|
schema?: JsonSchema;
|
|
61
|
+
/** \`followUp\` only. Seconds before the input is aborted. Default: none. */
|
|
62
|
+
timeout?: number;
|
|
41
63
|
}
|
|
42
64
|
interface AgentHandle {
|
|
43
65
|
readonly id: string;
|
|
44
66
|
readonly name: string;
|
|
45
|
-
/** Resolves with the answer, or with the submitted value when \`schema\` is set; a write resolves with undefined once accepted. Throws if the input fails or
|
|
67
|
+
/** Resolves with the answer, or with the submitted value when \`schema\` is set; a write resolves with undefined once accepted. Throws if the input fails, is aborted, or times out. */
|
|
46
68
|
send(message: string, options?: SendOptions): Promise<unknown>;
|
|
47
69
|
/** Aborts the Agent's current turn and queued inputs; the Agent stays usable. */
|
|
48
70
|
abort(): Promise<void>;
|
|
@@ -63,6 +85,8 @@ export function registerProgramTools(
|
|
|
63
85
|
programs: () => Programs | undefined;
|
|
64
86
|
},
|
|
65
87
|
): void {
|
|
88
|
+
const renderers = programRenderers(piCodemode);
|
|
89
|
+
|
|
66
90
|
/** Runs a Program whose Agents go offline when it ends. */
|
|
67
91
|
const runProgram = async (
|
|
68
92
|
id: string,
|
|
@@ -71,26 +95,32 @@ export function registerProgramTools(
|
|
|
71
95
|
code: string,
|
|
72
96
|
signal: AbortSignal,
|
|
73
97
|
current: Parameters<typeof executeProgram>[3],
|
|
74
|
-
options: Pick<ProgramRunOptions, "timeout" | "onUpdate" | "
|
|
98
|
+
{ background, args, ...options }: Pick<ProgramRunOptions, "timeout" | "onUpdate" | "detached" | "abort"> & { background?: boolean; args?: string },
|
|
75
99
|
) => {
|
|
76
|
-
const scope = programScope(id, caller, pi, current, { agentDir, limits: settings, extensions: settings.extensions, background:
|
|
100
|
+
const scope = programScope(id, caller, pi, current, { agentDir, limits: settings, extensions: settings.extensions, background: background === true });
|
|
77
101
|
let run: Awaited<ReturnType<typeof executeProgram>> | undefined;
|
|
78
102
|
try {
|
|
79
103
|
run = await executeProgram(toolCallId, code, signal, current, {
|
|
80
104
|
...options,
|
|
81
|
-
globals: agentGlobals(scope.host),
|
|
82
|
-
|
|
105
|
+
globals: (track) => agentGlobals(scope.host, track),
|
|
106
|
+
// On the first line, so line numbers in errors match the script as written.
|
|
107
|
+
prepare: (source) => withAgentPrefix(args === undefined ? source : `const args = ${JSON.stringify(args)};${source}`),
|
|
83
108
|
appendEntry: (customType, data) => pi.appendEntry(customType, data),
|
|
84
109
|
getToolNamespace: (name) => pi.getAllTools().find((tool) => tool.name === name)?.namespace,
|
|
85
110
|
});
|
|
86
111
|
} finally {
|
|
112
|
+
// Tool calls the script left running and the Program's Agents stop within the same timeout.
|
|
113
|
+
const deadline = Date.now() + CLEANUP_TIMEOUT_MS;
|
|
114
|
+
const stopped = await Promise.all([
|
|
115
|
+
run ? settlesBy(run.toolCalls, deadline) : true,
|
|
116
|
+
scope.close().then(() => true, () => false),
|
|
117
|
+
]);
|
|
87
118
|
// A cleanup failure ends the result, which keeps the Program's output and calls.
|
|
88
|
-
|
|
89
|
-
if (!run) return;
|
|
119
|
+
if (run && stopped.includes(false)) {
|
|
90
120
|
run.result.content.push({ type: "text", text: CLEANUP_ERROR });
|
|
91
121
|
run.result.isError = true;
|
|
92
122
|
run.abandoned = true;
|
|
93
|
-
}
|
|
123
|
+
}
|
|
94
124
|
}
|
|
95
125
|
return run;
|
|
96
126
|
};
|
|
@@ -114,6 +144,7 @@ export function registerProgramTools(
|
|
|
114
144
|
},
|
|
115
145
|
});
|
|
116
146
|
|
|
147
|
+
const codemodeReach = piCodemode.createCodemodeToolDefinition({}).reachesTools;
|
|
117
148
|
pi.registerTool({
|
|
118
149
|
name: PROGRAM_TOOL_NAME,
|
|
119
150
|
label: "Program",
|
|
@@ -121,7 +152,10 @@ export function registerProgramTools(
|
|
|
121
152
|
exposure: "model-only",
|
|
122
153
|
defaultActive: false,
|
|
123
154
|
prepareLoadout: programLoadout(pi, piCodemode, PROGRAM_DESCRIPTION, AGENT_API),
|
|
124
|
-
|
|
155
|
+
// Only `run` reaches tools, as a `codemode` script does; extensions that register tools late wait on it.
|
|
156
|
+
// MCP sees a run as a `codemode` call, without `action`.
|
|
157
|
+
...(codemodeReach && { reachesTools: (input: { action?: string; code?: unknown }) => (input.action ?? "run") === "run" ? codemodeReach({ code: input.code }) : [] }),
|
|
158
|
+
...renderers,
|
|
125
159
|
description: programDescription(piCodemode, PROGRAM_DESCRIPTION, AGENT_API),
|
|
126
160
|
promptSnippet: "Run JavaScript that composes tool calls and Agents",
|
|
127
161
|
promptGuidelines: [
|
|
@@ -149,7 +183,7 @@ export function registerProgramTools(
|
|
|
149
183
|
if (!params.code?.trim()) throw new Error("run requires code");
|
|
150
184
|
const code = params.code;
|
|
151
185
|
if (params.background) {
|
|
152
|
-
const record = programs.start((id, programSignal, abort) => runProgram(id, self, toolCallId, code, programSignal, current, { timeout: params.timeout, background: true, abort }));
|
|
186
|
+
const record = programs.start(code, (id, programSignal, abort, update) => runProgram(id, self, toolCallId, code, programSignal, current, { timeout: params.timeout, background: true, detached: true, abort, onUpdate: update }));
|
|
153
187
|
return { content: [{ type: "text", text: `Program ${programs.label(record)} started.` }], details: { id: record.id } };
|
|
154
188
|
}
|
|
155
189
|
const { result } = await self.agents.whileSuspended(() => runProgram(randomUUID(), self, toolCallId, code, signal ?? new AbortController().signal, current, {
|
|
@@ -164,11 +198,12 @@ export function registerProgramTools(
|
|
|
164
198
|
if (timeout < 10 || timeout > 3600) throw new Error("wait timeout must be between 10 and 3600 seconds");
|
|
165
199
|
const selected = programs.select(targets);
|
|
166
200
|
onUpdate?.({ content: [], details: undefined });
|
|
167
|
-
const
|
|
201
|
+
const session = agentSession(current.sessionManager);
|
|
202
|
+
const waitFor = () => programs!.wait(selected, timeout, signal, () => steerQueued(session?.agent));
|
|
168
203
|
const outcome = programs.running(selected) ? await self.agents.whileSuspended(waitFor, signal, true) : await waitFor();
|
|
169
204
|
const rendered = renderProgramWait(outcome, programs.ids());
|
|
170
205
|
programs.markReturned(rendered.returned);
|
|
171
|
-
return { content: rendered.content, details: { running: outcome.running.map((record) => record.id) } };
|
|
206
|
+
return { content: rendered.content, details: { running: outcome.running.map((record) => record.id), ...(outcome.interrupted && { interrupted: true }) } };
|
|
172
207
|
}
|
|
173
208
|
case "list": {
|
|
174
209
|
rejectFields(params, "list", ["code", "background", "timeout", "target"]);
|
|
@@ -195,4 +230,125 @@ export function registerProgramTools(
|
|
|
195
230
|
}
|
|
196
231
|
},
|
|
197
232
|
});
|
|
233
|
+
|
|
234
|
+
/** 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) => {
|
|
236
|
+
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;
|
|
238
|
+
component.addChild(renderers.renderCall!(call, theme, context));
|
|
239
|
+
component.addChild(renderers.renderResult!(result as never, { expanded, isPartial }, theme, context));
|
|
240
|
+
return component;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/** Runs `/program <path> [args]` in the foreground: shown above the editor while it runs, and added to the context like a `!` command. */
|
|
244
|
+
const runCommand = async (text: string, current: ExtensionCommandContext) => {
|
|
245
|
+
const self = requireNode(current);
|
|
246
|
+
const session = commandSession()!;
|
|
247
|
+
const [, file, args = ""] = /^(\S+)\s*([\s\S]*)$/.exec(text.trim()) ?? [];
|
|
248
|
+
if (!file) throw new Error("Usage: /program <path> [args]");
|
|
249
|
+
const code = await readFile(path.resolve(current.cwd, file), "utf8");
|
|
250
|
+
const toolCallId = `program-command-${randomUUID()}`;
|
|
251
|
+
const call: ProgramCall = { path: file, ...(args ? { args } : {}), code };
|
|
252
|
+
const controller = new AbortController();
|
|
253
|
+
// Pi's Esc cancels running `!` commands while no reply is streaming; it stops the Program likewise.
|
|
254
|
+
const stop = { abort: () => controller.abort(new Error("Program stopped")) };
|
|
255
|
+
session._bashAbortControllers.add(stop);
|
|
256
|
+
const release = trackTreeWork(self.rootId);
|
|
257
|
+
const widget = `${PROGRAM_MESSAGE}:${toolCallId}`;
|
|
258
|
+
const shown = current.mode === "tui";
|
|
259
|
+
let partial: { content: unknown[]; details?: unknown; isError?: boolean } = { content: [] };
|
|
260
|
+
let ended = false;
|
|
261
|
+
let tui: { requestRender(): void } | undefined;
|
|
262
|
+
if (shown) {
|
|
263
|
+
current.ui.setWidget(widget, (ui, theme) => {
|
|
264
|
+
tui = ui;
|
|
265
|
+
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() {} };
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
let run: Awaited<ReturnType<typeof runProgram>>;
|
|
270
|
+
try {
|
|
271
|
+
// Hooks see the run as a `run` call, so MCP waits for the servers the script names.
|
|
272
|
+
const blocked = await runDetached<{ block?: boolean; reason?: string } | undefined>(session, controller.signal, stop.abort, () => session._beforeToolCall({ toolCall: { name: PROGRAM_TOOL_NAME, id: toolCallId }, args: { action: "run", code } }));
|
|
273
|
+
if (blocked?.block) throw new Error(blocked.reason || "Tool execution was blocked");
|
|
274
|
+
const ctx = session._extensionRunner.createToolContext(toolCallId, controller.signal);
|
|
275
|
+
run = await runProgram(randomUUID(), self, toolCallId, code, controller.signal, ctx, {
|
|
276
|
+
detached: true,
|
|
277
|
+
abort: stop.abort,
|
|
278
|
+
args,
|
|
279
|
+
onUpdate: (partialResult) => {
|
|
280
|
+
partial = partialResult;
|
|
281
|
+
tui?.requestRender();
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (shown) current.ui.setWidget(widget, undefined);
|
|
286
|
+
throw error;
|
|
287
|
+
} finally {
|
|
288
|
+
session._bashAbortControllers.delete(stop);
|
|
289
|
+
release();
|
|
290
|
+
}
|
|
291
|
+
const { result } = run;
|
|
292
|
+
const details: ProgramMessageDetails = { call, calls: (result.details as { calls?: unknown[] } | undefined)?.calls, isError: result.isError === true };
|
|
293
|
+
if (shown) {
|
|
294
|
+
// During a reply, the message comes after the reply; until then the widget shows the result.
|
|
295
|
+
partial = result;
|
|
296
|
+
ended = true;
|
|
297
|
+
tui?.requestRender();
|
|
298
|
+
const unsubscribe = session.subscribe((event: { type: string; message?: { details?: unknown } }) => {
|
|
299
|
+
if (event.type !== "message_start" || event.message?.details !== details) return;
|
|
300
|
+
unsubscribe();
|
|
301
|
+
current.ui.setWidget(widget, undefined);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const send = () => pi.sendMessage({ customType: PROGRAM_MESSAGE, content: [{ type: "text", text: `Ran \`/program ${text.trim()}\`\n` }, ...result.content], display: true, details }, { triggerTurn: false });
|
|
305
|
+
// Like a `!` command that ends during a reply, the run joins the context once the reply has ended.
|
|
306
|
+
if (session.isStreaming) {
|
|
307
|
+
const unsubscribe = session.subscribe((event: { type: string }) => {
|
|
308
|
+
if (event.type !== "agent_settled") return;
|
|
309
|
+
unsubscribe();
|
|
310
|
+
send();
|
|
311
|
+
});
|
|
312
|
+
} else send();
|
|
313
|
+
if (current.mode !== "print") return;
|
|
314
|
+
// A Session in a host prints through its print client instead (src/host/rpc-client.mjs).
|
|
315
|
+
const body = runOutput(result.content);
|
|
316
|
+
if (result.isError) {
|
|
317
|
+
process.stderr.write(`${body}\n`);
|
|
318
|
+
process.exitCode = 1;
|
|
319
|
+
} else await writeStdout(`${body}\n`);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
pi.registerCommand(PROGRAM_TOOL_NAME, {
|
|
323
|
+
description: "Run the Program script in a file, with the text after the path as `args`",
|
|
324
|
+
handler: async (text, current) => {
|
|
325
|
+
try {
|
|
326
|
+
await runCommand(text, current);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
// Pi reports the error; `pi -p` also exits with status 1.
|
|
329
|
+
if (current.mode === "print") process.exitCode = 1;
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
pi.registerMessageRenderer<ProgramMessageDetails>(PROGRAM_MESSAGE, (message, { expanded, outputPad }, theme) => {
|
|
336
|
+
const { details } = message;
|
|
337
|
+
if (!details) return undefined;
|
|
338
|
+
const content = (message.content as unknown[]).slice(1);
|
|
339
|
+
return renderRun(details.call, { content, details: { calls: details.calls } }, false, details.isError, expanded, outputPad, theme);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Writes all of `text` to stdout, which print mode sends to stderr and Pi makes non-blocking. */
|
|
344
|
+
async function writeStdout(text: string): Promise<void> {
|
|
345
|
+
let rest = Buffer.from(text);
|
|
346
|
+
while (rest.length > 0) {
|
|
347
|
+
try {
|
|
348
|
+
rest = rest.subarray(writeSync(1, rest));
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if ((error as NodeJS.ErrnoException).code !== "EAGAIN") throw error;
|
|
351
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
198
354
|
}
|
package/src/roots/guard.mjs
CHANGED
|
@@ -3,8 +3,14 @@ import { closeSync, openSync, readFileSync, readSync, writeFileSync } from 'node
|
|
|
3
3
|
import { acquireLock, canonicalPath, SessionOccupiedError } from './locks.mjs';
|
|
4
4
|
import { resolvePath } from './paths.mjs';
|
|
5
5
|
|
|
6
|
-
/** The ID in a Session file's header
|
|
6
|
+
/** The ID in a Session file's header. */
|
|
7
7
|
export function readSessionId(path) {
|
|
8
|
+
const header = readSessionHeader(path);
|
|
9
|
+
return typeof header?.id === 'string' ? header.id : undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A Session file's header, its first parseable entry. Reads only up to that entry. */
|
|
13
|
+
export function readSessionHeader(path) {
|
|
8
14
|
let fd;
|
|
9
15
|
try { fd = openSync(path, 'r'); }
|
|
10
16
|
catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
@@ -19,7 +25,7 @@ export function readSessionId(path) {
|
|
|
19
25
|
for (const line of data.subarray(0, end).toString('utf8').split('\n')) {
|
|
20
26
|
let entry;
|
|
21
27
|
try { entry = JSON.parse(line); } catch { continue; }
|
|
22
|
-
if (entry) return entry.type === 'session'
|
|
28
|
+
if (entry) return entry.type === 'session' ? entry : undefined;
|
|
23
29
|
}
|
|
24
30
|
if (!bytes) return;
|
|
25
31
|
rest = data.subarray(end);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent messages queued in a root Session outlive the user's actions on Pi's queue. Relies on
|
|
3
|
+
* private members of `AgentSession`; check them when Pi is updated.
|
|
4
|
+
*/
|
|
5
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { takeQueued } from "../agents/agents.ts";
|
|
7
|
+
import { nodes } from "../agents/registry.ts";
|
|
8
|
+
|
|
9
|
+
const PATCHED = Symbol.for("pi-agents:queue-patched");
|
|
10
|
+
|
|
11
|
+
type Patchable = Record<PropertyKey, any>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Clearing the queue, as Pi's dequeue does, leaves Agent messages queued; stopping the turn adds
|
|
15
|
+
* them to the Session without starting a turn, as later ones are until the user's next input.
|
|
16
|
+
* Owned Agents withdraw their queued inputs on abort.
|
|
17
|
+
*/
|
|
18
|
+
export function installQueuePatches(Session: typeof AgentSession): void {
|
|
19
|
+
const session = Session.prototype as unknown as Patchable;
|
|
20
|
+
if (session[PATCHED]) return;
|
|
21
|
+
session[PATCHED] = true;
|
|
22
|
+
// Sessions without pi-agents, as other extensions may create, keep Pi's behavior.
|
|
23
|
+
const root = (self: AgentSession) => {
|
|
24
|
+
const node = nodes.get(self.sessionManager.getSessionId());
|
|
25
|
+
return node !== undefined && node.ownerId === undefined;
|
|
26
|
+
};
|
|
27
|
+
const clearQueue = session.clearQueue;
|
|
28
|
+
session.clearQueue = function (this: AgentSession) {
|
|
29
|
+
if (!root(this)) return clearQueue.call(this);
|
|
30
|
+
const kept = takeQueued(this.agent);
|
|
31
|
+
const cleared = clearQueue.call(this);
|
|
32
|
+
for (const message of kept) {
|
|
33
|
+
if (message.details.delivery === "followUp") this.agent.followUp(message);
|
|
34
|
+
else this.agent.steer(message);
|
|
35
|
+
}
|
|
36
|
+
return cleared;
|
|
37
|
+
};
|
|
38
|
+
const abort = session.abort;
|
|
39
|
+
session.abort = async function (this: AgentSession) {
|
|
40
|
+
if (!root(this)) return abort.call(this);
|
|
41
|
+
const node = nodes.get(this.sessionManager.getSessionId())!;
|
|
42
|
+
if (this.isStreaming) node.stopped = true;
|
|
43
|
+
const add = async () => {
|
|
44
|
+
for (const message of takeQueued(this.agent)) await this.sendCustomMessage(message, { triggerTurn: false });
|
|
45
|
+
};
|
|
46
|
+
await add();
|
|
47
|
+
const result = await abort.call(this);
|
|
48
|
+
// Messages that arrive while the turn stops are queued; add them too.
|
|
49
|
+
await add();
|
|
50
|
+
return result;
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/roots/report.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface ReportHooks {
|
|
|
13
13
|
treeIdle(): boolean;
|
|
14
14
|
/** The tree's running owned Agents and background Programs. */
|
|
15
15
|
liveWork(): number;
|
|
16
|
+
/** Whether `/tasks` is open, whose list and viewer Pi counts as prompts though they ask nothing. */
|
|
17
|
+
browsing(): boolean;
|
|
16
18
|
/** The host this Pi runs in, if any. */
|
|
17
19
|
host?: { socket: string; attached(): boolean; onChange(listener: () => void): void };
|
|
18
20
|
}
|
|
@@ -45,7 +47,7 @@ export function createReporter(pi: ExtensionAPI, hooks: ReportHooks): void {
|
|
|
45
47
|
let stream: { send(message: object): void; close(): void } | undefined;
|
|
46
48
|
let generation = 0;
|
|
47
49
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
48
|
-
let
|
|
50
|
+
let dialog = false;
|
|
49
51
|
let tools = new Map<string, string>();
|
|
50
52
|
let turn: { at: number; failed: boolean } | undefined;
|
|
51
53
|
let reply: string | undefined;
|
|
@@ -67,8 +69,8 @@ export function createReporter(pi: ExtensionAPI, hooks: ReportHooks): void {
|
|
|
67
69
|
title,
|
|
68
70
|
host: hooks.host?.socket,
|
|
69
71
|
attached: hooks.host ? hooks.host.attached() : true,
|
|
70
|
-
working: !current.isIdle() || current.hasPendingMessages() || !hooks.treeIdle()
|
|
71
|
-
blocked:
|
|
72
|
+
working: !current.isIdle() || current.hasPendingMessages() || !hooks.treeIdle(),
|
|
73
|
+
blocked: dialog,
|
|
72
74
|
tool: [...tools.values()].at(-1),
|
|
73
75
|
reply,
|
|
74
76
|
agents: hooks.liveWork(),
|
|
@@ -127,7 +129,7 @@ export function createReporter(pi: ExtensionAPI, hooks: ReportHooks): void {
|
|
|
127
129
|
ctx = undefined;
|
|
128
130
|
unsubscribe?.();
|
|
129
131
|
unsubscribe = undefined;
|
|
130
|
-
|
|
132
|
+
dialog = false;
|
|
131
133
|
tools = new Map();
|
|
132
134
|
turn = reply = title = undefined;
|
|
133
135
|
});
|
|
@@ -141,8 +143,12 @@ export function createReporter(pi: ExtensionAPI, hooks: ReportHooks): void {
|
|
|
141
143
|
pi.on("tool_execution_end", (event) => {
|
|
142
144
|
if (tools.delete(event.toolCallId)) changed();
|
|
143
145
|
});
|
|
144
|
-
pi.on("ui_prompt_start", () => {
|
|
145
|
-
|
|
146
|
+
pi.on("ui_prompt_start", () => {
|
|
147
|
+
if (hooks.browsing()) return;
|
|
148
|
+
dialog = true;
|
|
149
|
+
changed();
|
|
150
|
+
});
|
|
151
|
+
pi.on("ui_prompt_end", () => { dialog = false; changed(); });
|
|
146
152
|
pi.on("agent_settled", (event) => {
|
|
147
153
|
if (!ctx) return;
|
|
148
154
|
const last = ctx.sessionManager.getBranch().findLast((entry) => entry.type === "message" && entry.message.role === "assistant");
|