@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.
@@ -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
- throw new Error(record.state === "failed" ? `Agent ${label(agent, agents.ids())} failed: ${record.result}` : `Agent ${label(agent, agents.ids())} was aborted.`);
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));
@@ -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: { 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
  }
@@ -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);
@@ -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");