@tshu-w/pi-agents 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -20
- package/bin/daemon.mjs +32 -14
- package/bin/host.mjs +9 -1
- package/bin/pd.mjs +87 -42
- package/docs/HOSTS.md +111 -0
- package/docs/PROGRAM.md +39 -114
- package/docs/SPEC.md +109 -161
- package/package.json +7 -5
- package/src/agents/agents.ts +129 -55
- package/src/agents/registry.ts +62 -10
- package/src/agents/search.ts +21 -4
- package/src/agents/tool.ts +41 -13
- package/src/daemon/client.mjs +4 -1
- package/src/host/client.mjs +30 -5
- package/src/host/extension.ts +38 -14
- package/src/host/rpc-client.mjs +380 -0
- package/src/index.ts +44 -16
- package/src/output.ts +5 -1
- 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/loadout.ts +18 -3
- package/src/programs/programs.ts +128 -20
- package/src/programs/run-output.mjs +11 -0
- package/src/programs/sandbox.ts +17 -4
- package/src/programs/scope.ts +15 -7
- package/src/programs/tool.ts +172 -16
- package/src/render-call.ts +113 -15
- package/src/roots/guard.mjs +8 -2
- package/src/roots/paths.mjs +4 -4
- 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/message.ts +24 -0
- package/src/ui/panel.ts +39 -7
- package/src/ui/queue.ts +47 -0
- package/src/ui/viewer.ts +182 -38
- package/src/workbench.mjs +91 -51
- package/docs/WORKBENCH.md +0 -167
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: { ...rendered.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/render-call.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import { keyHint } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
1
|
+
import { DEFAULT_MAX_BYTES, formatSize, keyHint, truncateToVisualLines } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
Container,
|
|
5
|
+
getCapabilities,
|
|
6
|
+
getImageDimensions,
|
|
7
|
+
imageFallback,
|
|
8
|
+
Spacer,
|
|
9
|
+
Text,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
import type { Truncation } from "./output.ts";
|
|
3
13
|
|
|
4
14
|
type ToolCallTheme = {
|
|
5
15
|
bold(text: string): string;
|
|
@@ -37,7 +47,8 @@ export function startDuration(state: DurationState, enabled: boolean, executionS
|
|
|
37
47
|
if (enabled && executionStarted && state.startedAt === undefined) state.startedAt = Date.now();
|
|
38
48
|
}
|
|
39
49
|
|
|
40
|
-
|
|
50
|
+
/** Durations as Pi shows them: `4.2s` below a minute, then `1m 30s` and `2h 3m 4s`. */
|
|
51
|
+
export function formatDuration(ms: number): string {
|
|
41
52
|
const seconds = ms / 1000;
|
|
42
53
|
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
43
54
|
const totalSeconds = Math.floor(seconds);
|
|
@@ -60,31 +71,118 @@ function updateDuration(state: DurationState, isPartial: boolean, durationMs: nu
|
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
interface ResultTheme {
|
|
63
|
-
fg(color: "toolOutput" | "muted", text: string): string;
|
|
74
|
+
fg(color: "toolOutput" | "muted" | "warning", text: string): string;
|
|
75
|
+
bold(text: string): string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// As in Pi's `codemode` output.
|
|
79
|
+
const BLOCK_PREVIEW_LINES = 5;
|
|
80
|
+
|
|
81
|
+
// Pi's `stripAnsi` pattern, derived from ansi-regex (MIT), which Pi does not export: OSC sequences ended by ST, and CSI sequences.
|
|
82
|
+
const ANSI = /\x1b\][\s\S]*?(?:\x07|\x1b\\|\x9c)|[\x1b\x9b][[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]/g;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `text` as Pi shows tool output: without ANSI sequences, carriage returns, or the control characters Pi's
|
|
86
|
+
* `sanitizeBinaryOutput` removes, which it does not export; and with tabs as 3 spaces.
|
|
87
|
+
*/
|
|
88
|
+
function displayText(text: string): string {
|
|
89
|
+
return text.replace(ANSI, "").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\uFFF9-\uFFFB]/g, "").replace(/\r/g, "").replace(/\t/g, " ");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Placeholders for a result's images where Pi shows them as text: when the terminal cannot show images or they are hidden. */
|
|
93
|
+
function imageText(content: Array<{ type: string; data?: string; mimeType?: string }>, showImages: boolean | undefined): string {
|
|
94
|
+
if (getCapabilities().images && showImages) return "";
|
|
95
|
+
return content.flatMap((part) => part.type !== "image" ? [] : [imageFallback(
|
|
96
|
+
part.mimeType ?? "image/unknown",
|
|
97
|
+
part.data && part.mimeType ? getImageDimensions(part.data, part.mimeType) ?? undefined : undefined,
|
|
98
|
+
)]).join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Styled non-blank lines of `text`. */
|
|
102
|
+
function outputText(text: string, theme: ResultTheme): string {
|
|
103
|
+
return displayText(text).split("\n").filter((line) => line.trim()).map((line) => theme.fg("toolOutput", line)).join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The warning Pi's bash renderer shows for truncated output. */
|
|
107
|
+
function truncationWarning({ truncation, fullOutputPath }: Partial<Truncation>, theme: ResultTheme): string | undefined {
|
|
108
|
+
const warnings: string[] = [];
|
|
109
|
+
if (fullOutputPath) warnings.push(`Full output: ${fullOutputPath}`);
|
|
110
|
+
if (truncation?.truncated) {
|
|
111
|
+
warnings.push(truncation.truncatedBy === "lines"
|
|
112
|
+
? `Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`
|
|
113
|
+
: `Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`);
|
|
114
|
+
}
|
|
115
|
+
return warnings.length > 0 ? theme.fg("warning", `[${warnings.join(". ")}]`) : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The collapsed view of `wait` results: each result as its line in bold, then the first visual lines of its body without
|
|
120
|
+
* blank lines, indented, and the number of lines after them; then the notes, without blank lines, and the truncation
|
|
121
|
+
* warning as Pi's bash renderer shows it. Like Pi's `codemode` output preview, it counts wrapped lines and caches its
|
|
122
|
+
* lines per width.
|
|
123
|
+
*/
|
|
124
|
+
export function renderResultList(results: { line: string; body: string }[], notes: string[], truncated: Partial<Truncation>, theme: ResultTheme): Component {
|
|
125
|
+
let cached: { width: number; lines: string[] } | undefined;
|
|
126
|
+
return {
|
|
127
|
+
render(width) {
|
|
128
|
+
if (cached?.width !== width) {
|
|
129
|
+
const lines: string[] = [];
|
|
130
|
+
for (const { line, body } of results) {
|
|
131
|
+
lines.push(...new Text(theme.fg("toolOutput", theme.bold(displayText(line))), 0, 0).render(width));
|
|
132
|
+
const preview = truncateToVisualLines(outputText(body, theme), BLOCK_PREVIEW_LINES, width - 2, 0, "start");
|
|
133
|
+
lines.push(...preview.visualLines.map((visual) => ` ${visual}`));
|
|
134
|
+
if (preview.skippedCount > 0) lines.push(truncateToWidth(` ${moreLines(preview.skippedCount, theme)}`, width, "..."));
|
|
135
|
+
}
|
|
136
|
+
lines.push(...new Text(outputText(notes.join("\n"), theme), 0, 0).render(width));
|
|
137
|
+
const warning = truncationWarning(truncated, theme);
|
|
138
|
+
if (warning) lines.push(...new Text(warning, 0, 0).render(width));
|
|
139
|
+
cached = { width, lines };
|
|
140
|
+
}
|
|
141
|
+
return cached.lines;
|
|
142
|
+
},
|
|
143
|
+
invalidate() {
|
|
144
|
+
cached = undefined;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
64
147
|
}
|
|
65
148
|
|
|
66
149
|
/**
|
|
67
150
|
* The text output like Pi's default result, with the duration of a timed call below it.
|
|
68
|
-
* Like Pi's `bash` result, a blank line precedes the output and the duration.
|
|
151
|
+
* Like Pi's `bash` result, a blank line precedes the output and the duration. When collapsed, `tidy` may show its view
|
|
152
|
+
* instead, followed by the placeholders of the result's images.
|
|
69
153
|
*/
|
|
70
154
|
export function renderTextResult(
|
|
71
|
-
result: { content: Array<{ type: string; text?: string }> },
|
|
155
|
+
result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> },
|
|
72
156
|
options: { expanded: boolean; isPartial: boolean },
|
|
73
157
|
theme: ResultTheme,
|
|
74
|
-
context: { state: unknown; invalidate: () => void; lastComponent?: unknown; durationMs?: number },
|
|
75
|
-
)
|
|
76
|
-
|
|
158
|
+
context: { state: unknown; invalidate: () => void; lastComponent?: unknown; durationMs?: number; showImages?: boolean },
|
|
159
|
+
tidy?: () => Component | undefined,
|
|
160
|
+
): Component {
|
|
161
|
+
const images = imageText(result.content, context.showImages);
|
|
162
|
+
// As Pi's text output of a result: its text items, then its image placeholders.
|
|
163
|
+
const text = result.content.flatMap((part) => part.type === "text" && part.text ? [displayText(part.text)] : []).join("\n");
|
|
164
|
+
const output = [text, images].filter(Boolean).join("\n").trim();
|
|
77
165
|
const lines = output ? output.split("\n") : [];
|
|
78
166
|
const shown = options.expanded ? lines : lines.slice(0, RESULT_PREVIEW_LINES);
|
|
79
|
-
const
|
|
80
|
-
|
|
167
|
+
const tidied = options.expanded ? undefined : tidy?.();
|
|
168
|
+
const sections: Component[] = [];
|
|
169
|
+
if (tidied) {
|
|
170
|
+
const section = new Container();
|
|
171
|
+
section.addChild(tidied);
|
|
172
|
+
if (images) section.addChild(new Text(theme.fg("toolOutput", images), 0, 0));
|
|
173
|
+
sections.push(section);
|
|
174
|
+
} else if (shown.length > 0) {
|
|
81
175
|
let text = shown.map((line) => theme.fg("toolOutput", line)).join("\n");
|
|
82
176
|
if (shown.length < lines.length) text += `\n${moreLines(lines.length - shown.length, theme)}`;
|
|
83
|
-
sections.push(text);
|
|
177
|
+
sections.push(new Text(text, 0, 0));
|
|
84
178
|
}
|
|
85
179
|
const duration = updateDuration(context.state as DurationState, options.isPartial, context.durationMs, context.invalidate);
|
|
86
|
-
if (duration) sections.push(theme.fg("muted", duration));
|
|
87
|
-
const component = context.lastComponent instanceof
|
|
88
|
-
component.
|
|
180
|
+
if (duration) sections.push(new Text(theme.fg("muted", duration), 0, 0));
|
|
181
|
+
const component = context.lastComponent instanceof Container ? context.lastComponent : new Container();
|
|
182
|
+
component.clear();
|
|
183
|
+
for (const section of sections) {
|
|
184
|
+
component.addChild(new Spacer(1));
|
|
185
|
+
component.addChild(section);
|
|
186
|
+
}
|
|
89
187
|
return component;
|
|
90
188
|
}
|
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);
|
package/src/roots/paths.mjs
CHANGED
|
@@ -39,7 +39,7 @@ export function agentDir(env = process.env) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** The session directory configured without `--session-dir`: PI_CODING_AGENT_SESSION_DIR, then the global `sessionDir` setting. */
|
|
42
|
-
|
|
42
|
+
function configuredSessionDir(env = process.env) {
|
|
43
43
|
if (env.PI_CODING_AGENT_SESSION_DIR) return resolvePath(env.PI_CODING_AGENT_SESSION_DIR);
|
|
44
44
|
let setting;
|
|
45
45
|
try { setting = JSON.parse(readFileSync(join(agentDir(env), 'settings.json'), 'utf8').replace(/^\uFEFF/, '')).sessionDir; }
|
|
@@ -52,9 +52,9 @@ export function sessionDir(env = process.env) {
|
|
|
52
52
|
return configuredSessionDir(env) ?? join(agentDir(env), 'sessions');
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/** The directory Pi keeps a cwd's Sessions in when none is configured
|
|
56
|
-
export function defaultSessionDir(cwd,
|
|
57
|
-
return join(
|
|
55
|
+
/** The directory Pi keeps a cwd's Sessions in when none is configured, as Pi's unexported `getDefaultSessionDirPath`. */
|
|
56
|
+
export function defaultSessionDir(cwd, agentDirectory) {
|
|
57
|
+
return join(resolve(agentDirectory), 'sessions', `--${resolve(cwd).replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export async function prepareDirectory(directory) {
|
|
@@ -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");
|