@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.
@@ -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 is aborted. */
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" | "background" | "abort">,
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: options.background === true });
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
- prepare: withAgentPrefix,
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
- await scope.close().catch(() => {
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
- ...programRenderers(piCodemode),
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 waitFor = () => programs!.wait(selected, timeout, signal);
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
  }
@@ -1,5 +1,15 @@
1
- import { keyHint } from "@earendil-works/pi-coding-agent";
2
- import { Text } from "@earendil-works/pi-tui";
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
- function formatDuration(ms: number): string {
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
- ): Text {
76
- const output = result.content.flatMap((part) => part.type === "text" && part.text ? [part.text] : []).join("\n").trim();
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 sections: string[] = [];
80
- if (shown.length > 0) {
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 Text ? context.lastComponent : new Text("", 0, 0);
88
- component.setText(sections.map((section) => `\n${section}`).join("\n"));
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
  }
@@ -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, its first parseable entry. Reads only up to that entry. */
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' && typeof entry.id === 'string' ? entry.id : undefined;
28
+ if (entry) return entry.type === 'session' ? entry : undefined;
23
29
  }
24
30
  if (!bytes) return;
25
31
  rest = data.subarray(end);
@@ -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
- export function configuredSessionDir(env = process.env) {
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, env = process.env) {
57
- return join(agentDir(env), 'sessions', `--${resolve(cwd).replace(/^\//, '').replace(/[/\\:]/g, '-')}--`);
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
+ }
@@ -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 dialogs = 0;
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() || dialogs > 0,
71
- blocked: dialogs > 0,
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
- dialogs = 0;
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", () => { dialogs += 1; changed(); });
145
- pi.on("ui_prompt_end", () => { dialogs = Math.max(0, dialogs - 1); changed(); });
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");