@tt-a1i/openpi 0.4.0 → 0.6.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.
Files changed (141) hide show
  1. package/README.md +116 -46
  2. package/SETUP.md +29 -7
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-launch-card-v1.webp +0 -0
  5. package/bin/openpi.js +155 -0
  6. package/extensions/ai-providers/LICENSE.upstream +23 -0
  7. package/extensions/ai-providers/README.md +59 -0
  8. package/extensions/ai-providers/antigravity/credentials.ts +52 -0
  9. package/extensions/ai-providers/antigravity/discovery.ts +130 -0
  10. package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
  11. package/extensions/ai-providers/antigravity/models.ts +84 -0
  12. package/extensions/ai-providers/antigravity/oauth.ts +700 -0
  13. package/extensions/ai-providers/antigravity/provider.ts +1116 -0
  14. package/extensions/ai-providers/antigravity/routing.ts +340 -0
  15. package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
  16. package/extensions/ai-providers/cursor/constants.ts +5 -0
  17. package/extensions/ai-providers/cursor/credentials.ts +14 -0
  18. package/extensions/ai-providers/cursor/discovery.ts +291 -0
  19. package/extensions/ai-providers/cursor/input-images.ts +106 -0
  20. package/extensions/ai-providers/cursor/models.ts +45 -0
  21. package/extensions/ai-providers/cursor/oauth.ts +263 -0
  22. package/extensions/ai-providers/cursor/proto.ts +1064 -0
  23. package/extensions/ai-providers/cursor/protobuf.ts +1171 -0
  24. package/extensions/ai-providers/cursor/provider.ts +1175 -0
  25. package/extensions/ai-providers/cursor/proxy.ts +213 -0
  26. package/extensions/ai-providers/cursor/with-resolvers.d.ts +12 -0
  27. package/extensions/ai-providers/index.ts +86 -0
  28. package/extensions/ai-providers/oauth-adapter.ts +81 -0
  29. package/extensions/ai-providers/usage.ts +10 -0
  30. package/extensions/background-terminals/index.ts +38 -3
  31. package/extensions/background-terminals/src/domain.ts +2 -0
  32. package/extensions/background-terminals/src/manager.ts +484 -106
  33. package/extensions/background-terminals/src/output.ts +33 -0
  34. package/extensions/background-terminals/src/prompt.ts +13 -5
  35. package/extensions/background-terminals/src/result-delivery.ts +47 -24
  36. package/extensions/clear-context/index.ts +83 -0
  37. package/extensions/context-pivot/index.ts +16 -6
  38. package/extensions/cron/index.ts +68 -27
  39. package/extensions/cron/schedule.ts +12 -2
  40. package/extensions/file-mutation-display/render.ts +17 -257
  41. package/extensions/file-search/src/binaries.ts +57 -41
  42. package/extensions/git-read/index.ts +1 -3
  43. package/extensions/model-info/cache-diagnostics.ts +220 -0
  44. package/extensions/model-info/index.ts +65 -33
  45. package/extensions/model-info/session-metrics.ts +96 -0
  46. package/extensions/plan-mode/bash-policy.ts +54 -9
  47. package/extensions/plan-mode/index.ts +82 -6
  48. package/extensions/post-edit/index.ts +16 -6
  49. package/extensions/sessions/git-stats.ts +258 -72
  50. package/extensions/sessions/index.ts +153 -86
  51. package/extensions/sessions/preview-cache.ts +104 -0
  52. package/extensions/sessions/preview-loader.ts +856 -0
  53. package/extensions/sessions/sessions.ts +43 -4
  54. package/extensions/setup/index.ts +138 -130
  55. package/extensions/shared/activity-status.ts +30 -0
  56. package/extensions/shared/agent-session-page.ts +319 -0
  57. package/extensions/shared/agent-tool-renderer.ts +218 -0
  58. package/extensions/shared/agent-transcript.ts +524 -0
  59. package/extensions/shared/capability-intent.ts +1 -1
  60. package/extensions/shared/child-session.ts +457 -21
  61. package/extensions/shared/completion-inbox.ts +193 -0
  62. package/extensions/shared/result-delivery.ts +34 -0
  63. package/extensions/shared/setup-config.ts +83 -34
  64. package/extensions/shared/setup-episode-state.ts +1 -1
  65. package/extensions/shared/structured-output.ts +154 -0
  66. package/extensions/shared/terminal-text.ts +110 -23
  67. package/extensions/shared/text-projection.ts +72 -15
  68. package/extensions/shared/tool-activity.ts +382 -0
  69. package/extensions/shared/tool-surface.ts +29 -2
  70. package/extensions/shared/transcript-viewport.ts +46 -0
  71. package/extensions/shared/web-observer-registry.ts +390 -0
  72. package/extensions/shared/worktree.ts +11 -0
  73. package/extensions/subagents/index.ts +313 -62
  74. package/extensions/subagents/navigation.ts +34 -5
  75. package/extensions/subagents/src/backend.ts +12 -1
  76. package/extensions/subagents/src/backends/pi.ts +450 -70
  77. package/extensions/subagents/src/domain.ts +21 -1
  78. package/extensions/subagents/src/manager.ts +39 -2
  79. package/extensions/subagents/src/prompt.ts +49 -7
  80. package/extensions/subagents/src/result-artifact.ts +36 -0
  81. package/extensions/subagents/src/result-delivery.ts +39 -14
  82. package/extensions/subagents/src/runtime.ts +15 -1
  83. package/extensions/subagents/src/ui/takeover.ts +73 -257
  84. package/extensions/subagents/src/ui/transcript.ts +38 -535
  85. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  86. package/extensions/suggestions/src/ui.ts +10 -4
  87. package/extensions/tasks/index.ts +0 -3
  88. package/extensions/ui-customization/footer.ts +16 -45
  89. package/extensions/ui-customization/index.ts +0 -4
  90. package/extensions/user-input-fold/index.ts +42 -6
  91. package/extensions/web/index.ts +257 -0
  92. package/extensions/workflows/acceptance.ts +43 -19
  93. package/extensions/workflows/artifacts.ts +137 -47
  94. package/extensions/workflows/completion-projection.ts +459 -0
  95. package/extensions/workflows/coordinator.ts +8 -10
  96. package/extensions/workflows/dashboard.ts +175 -228
  97. package/extensions/workflows/handoff.ts +70 -16
  98. package/extensions/workflows/index.ts +501 -198
  99. package/extensions/workflows/journal.ts +148 -13
  100. package/extensions/workflows/model.ts +79 -5
  101. package/extensions/workflows/navigation.ts +32 -8
  102. package/extensions/workflows/progress-projection.ts +306 -0
  103. package/extensions/workflows/prompt.ts +70 -16
  104. package/extensions/workflows/replay-safety.ts +42 -21
  105. package/extensions/workflows/result-delivery.ts +214 -76
  106. package/extensions/workflows/retention.ts +599 -0
  107. package/extensions/workflows/runner.ts +389 -345
  108. package/extensions/workflows/sandbox-child.cjs +25 -3
  109. package/extensions/workflows/sandbox.ts +62 -8
  110. package/extensions/workflows/serialization.ts +325 -17
  111. package/extensions/workflows/tool-renderer.ts +22 -0
  112. package/extensions/workflows/transcript.ts +149 -0
  113. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  114. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  115. package/package.json +34 -14
  116. package/skills/subagents/REFERENCE.md +190 -0
  117. package/skills/subagents/SKILL.md +2 -1
  118. package/skills/workflows/REFERENCE.md +6 -4
  119. package/skills/workflows/SKILL.md +1 -1
  120. package/web/adapter/pi-adapter.ts +664 -0
  121. package/web/host/browser-launcher.ts +20 -0
  122. package/web/host/pi-coding-agent-entry.ts +162 -0
  123. package/web/host/static-assets.ts +4 -0
  124. package/web/host/terminal-status.ts +38 -0
  125. package/web/host/web-host.ts +1069 -0
  126. package/web/http-dispatcher.ts +125 -0
  127. package/web/protocol/types.ts +467 -0
  128. package/web/runtime/pi-runtime.ts +1206 -0
  129. package/web/runtime/types.ts +102 -0
  130. package/web/runtime/web-host-lease.ts +497 -0
  131. package/web/trace.ts +18 -0
  132. package/web/ui/app.js +1700 -0
  133. package/web/ui/index.html +142 -0
  134. package/web/ui/styles.css +680 -0
  135. package/web/vite.config.mjs +34 -0
  136. package/extensions/execution-convergence/active-evidence.ts +0 -129
  137. package/extensions/execution-convergence/index.ts +0 -442
  138. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  139. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  140. package/extensions/setup/intercom.ts +0 -603
  141. package/extensions/subagents/src/backends/stub.ts +0 -303
@@ -11,19 +11,18 @@
11
11
  */
12
12
 
13
13
  import {
14
- createAgentSession,
15
- DefaultResourceLoader,
16
- defineTool,
17
- SessionManager,
18
- SettingsManager,
19
14
  type AgentSession,
20
15
  type AgentSessionEvent,
21
16
  type AgentSessionEventListener,
17
+ createAgentSession,
18
+ DefaultResourceLoader,
22
19
  type ExtensionAPI,
23
20
  type ExtensionContext,
21
+ SessionManager,
22
+ SettingsManager,
24
23
  type ToolDefinition,
25
24
  } from "@earendil-works/pi-coding-agent";
26
- import { Type, type TSchema } from "typebox";
25
+ import { AgentToolRenderLedger } from "../shared/agent-tool-renderer.ts";
27
26
  import {
28
27
  bindChildSessionExtensions,
29
28
  childToolPolicy,
@@ -31,23 +30,27 @@ import {
31
30
  shutdownAndDisposeChildSession,
32
31
  } from "../shared/child-session.ts";
33
32
  import { createToolCallTimeoutGuard } from "../shared/tool-call-timeout.ts";
34
- import { emptyUsage, type AgentUsage, type TranscriptEntry } from "./model.ts";
33
+ import { type AgentUsage, emptyUsage, type TranscriptEntry } from "./model.ts";
34
+ import {
35
+ childToolsWithStructuredOutput,
36
+ createStructuredOutputTool,
37
+ STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION,
38
+ } from "../shared/structured-output.ts";
39
+ import { buildWorkflowAgentPrompt } from "./prompt.ts";
40
+ import {
41
+ AgentProgressProjection,
42
+ type ProgressAssistantMessage,
43
+ transcriptFromMessages,
44
+ } from "./progress-projection.ts";
35
45
  import {
36
46
  createReplayFilesystemBoundary,
37
47
  type ReplayFilesystemBoundaryOptions,
38
48
  } from "./replay-safety.ts";
39
- import {
40
- buildWorkflowAgentPrompt,
41
- STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION,
42
- STRUCTURED_OUTPUT_TOOL_DESCRIPTION,
43
- } from "./prompt.ts";
44
- import { safeStringify, truncateUtf8 } from "./serialization.ts";
49
+ import { truncateUtf8 } from "./serialization.ts";
50
+ import { bindWorkflowToolRenderer } from "./tool-renderer.ts";
45
51
 
46
52
  const AGENT_OUTPUT_MAX_BYTES = 64 * 1024;
47
- export const FIRST_RESPONSE_TIMEOUT_MS = 45_000;
48
- const TRANSCRIPT_ENTRY_MAX_BYTES = 16 * 1024;
49
- const TRANSCRIPT_TOTAL_MAX_BYTES = 256 * 1024;
50
- const TRANSCRIPT_MAX_ENTRIES = 200;
53
+ export const MODEL_PROGRESS_TIMEOUT_MS = 45_000;
51
54
 
52
55
  export type WorkflowModel = NonNullable<ExtensionContext["model"]>;
53
56
  export type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
@@ -108,8 +111,8 @@ export interface RunAgentOptions {
108
111
  replayFilesystemBoundary?: ReplayFilesystemBoundaryOptions;
109
112
  /** Test-only override for the per-tool execution timeout. */
110
113
  toolCallTimeoutMs?: number;
111
- /** Test-only override for the first assistant response-event timeout. */
112
- firstResponseTimeoutMs?: number;
114
+ /** Test-only override for the per-provider-turn model-progress timeout. */
115
+ modelProgressTimeoutMs?: number;
113
116
  /** Test-only override for the end-to-end abort/shutdown deadline. */
114
117
  shutdownTimeoutMs?: number;
115
118
  /** Test seam for lifecycle races; production always uses createAgentSession. */
@@ -138,9 +141,7 @@ export function workflowChildTools(
138
141
  tools: readonly string[] | undefined,
139
142
  structured: boolean,
140
143
  ) {
141
- return tools
142
- ? [...new Set([...tools, ...(structured ? ["structured_output"] : [])])]
143
- : undefined;
144
+ return childToolsWithStructuredOutput(tools, structured);
144
145
  }
145
146
 
146
147
  interface WorkflowToolSession {
@@ -175,69 +176,9 @@ export function guardWorkflowChildTools(
175
176
  });
176
177
  }
177
178
 
178
- function isJsonSchema(value: unknown): value is TSchema {
179
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
180
- const seen = new WeakSet<object>();
181
- let nodes = 0;
182
- const validate = (current: unknown, depth: number): boolean => {
183
- if (++nodes > 10_000 || depth > 24) return false;
184
- if (
185
- current === null ||
186
- typeof current === "string" ||
187
- typeof current === "boolean"
188
- ) {
189
- return true;
190
- }
191
- if (typeof current === "number") return Number.isFinite(current);
192
- if (Array.isArray(current)) {
193
- return current.every((item) => validate(item, depth + 1));
194
- }
195
- if (typeof current !== "object") return false;
196
- if (seen.has(current)) return false;
197
- seen.add(current);
198
- return Object.keys(current).every((key) => {
199
- if (key === "__proto__" || key === "constructor" || key === "prototype") {
200
- return false;
201
- }
202
- return validate((current as Record<string, unknown>)[key], depth + 1);
203
- });
204
- };
205
- return validate(value, 0);
206
- }
179
+ type AssistantMessage = ProgressAssistantMessage;
207
180
 
208
- /** Preserve the caller's full JSON Schema instead of lossy keyword conversion. */
209
- function jsonSchemaToTypebox(schema: unknown): TSchema {
210
- if (!isJsonSchema(schema)) {
211
- throw new Error("structured output schema must be a bounded JSON object");
212
- }
213
- return Type.Unsafe(schema);
214
- }
215
-
216
- /**
217
- * One-shot terminating tool injected when a schema is supplied: the subagent
218
- * calls it as its final action and we capture the validated object.
219
- */
220
- function makeStructuredOutputTool(
221
- schema: unknown,
222
- capture: (value: unknown) => void,
223
- ): ToolDefinition {
224
- return defineTool({
225
- name: "structured_output",
226
- label: "Structured Output",
227
- description: STRUCTURED_OUTPUT_TOOL_DESCRIPTION,
228
- parameters: jsonSchemaToTypebox(schema),
229
- async execute(_toolCallId, params) {
230
- capture(params);
231
- return {
232
- content: [{ type: "text", text: "Recorded structured result." }],
233
- details: params,
234
- terminate: true,
235
- };
236
- },
237
- });
238
- }
239
-
240
- type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
181
+ export { transcriptFromMessages };
241
182
 
242
183
  export interface AssistantSettlement {
243
184
  stopReason: AssistantMessage["stopReason"];
@@ -273,28 +214,6 @@ export function agentFailureMessage(
273
214
  return settlement?.errorMessage ?? promptErrorMessage ?? "Agent failed";
274
215
  }
275
216
 
276
- function finalOutput(messages: AgentMessage[]): string {
277
- for (let i = messages.length - 1; i >= 0; i--) {
278
- const msg = messages[i];
279
- if (msg.role !== "assistant") continue;
280
- const text = msg.content
281
- .filter((part) => part.type === "text")
282
- .map((part) => part.text)
283
- .join("\n")
284
- .trim();
285
- if (text) return text;
286
- }
287
- return "";
288
- }
289
-
290
- function safeJson(value: unknown): string {
291
- return safeStringify(value, {
292
- maxBytes: TRANSCRIPT_ENTRY_MAX_BYTES,
293
- maxDepth: 12,
294
- maxNodes: 2_000,
295
- });
296
- }
297
-
298
217
  /** Record lifecycle timings without inferring completion from message timestamps. */
299
218
  export function recordToolExecutionTiming(
300
219
  timings: Map<string, ToolExecutionTiming>,
@@ -319,133 +238,6 @@ export function recordToolExecutionTiming(
319
238
  });
320
239
  }
321
240
 
322
- function toolMetadata(
323
- toolCallId: string,
324
- timings: ReadonlyMap<string, ToolExecutionTiming>,
325
- ) {
326
- const timing = timings.get(toolCallId);
327
- return {
328
- toolCallId: truncateUtf8(toolCallId, 1024),
329
- ...(timing?.startedAt === undefined ? {} : { startedAt: timing.startedAt }),
330
- ...(timing?.finishedAt === undefined
331
- ? {}
332
- : { finishedAt: timing.finishedAt }),
333
- ...(timing?.durationMs === undefined
334
- ? {}
335
- : { durationMs: timing.durationMs }),
336
- };
337
- }
338
-
339
- /** Convert pi messages into a compact, serializable transcript for the UI. */
340
- export function transcriptFromMessages(
341
- messages: AgentMessage[],
342
- toolTimings: ReadonlyMap<string, ToolExecutionTiming> = new Map(),
343
- ): TranscriptEntry[] {
344
- const entries: TranscriptEntry[] = [];
345
- for (const message of messages) {
346
- if (message.role === "user") {
347
- const text =
348
- typeof message.content === "string"
349
- ? message.content
350
- : message.content
351
- .map((part) =>
352
- part.type === "text" ? part.text : `[image: ${part.mimeType}]`,
353
- )
354
- .join("\n");
355
- if (text.trim()) {
356
- entries.push({ role: "user", text, timestamp: message.timestamp });
357
- }
358
- continue;
359
- }
360
-
361
- if (message.role === "assistant") {
362
- for (const part of message.content) {
363
- if (part.type === "text" && part.text.trim()) {
364
- entries.push({
365
- role: "assistant",
366
- text: part.text,
367
- timestamp: message.timestamp,
368
- });
369
- } else if (part.type === "thinking" && part.thinking.trim()) {
370
- entries.push({
371
- role: "thinking",
372
- text: part.thinking,
373
- timestamp: message.timestamp,
374
- });
375
- } else if (part.type === "toolCall") {
376
- entries.push({
377
- role: "tool",
378
- name: part.name,
379
- text: safeJson(part.arguments),
380
- timestamp: message.timestamp,
381
- ...toolMetadata(part.id, toolTimings),
382
- });
383
- }
384
- }
385
- continue;
386
- }
387
-
388
- if (message.role !== "toolResult") continue;
389
- const text = message.content
390
- .map((part) =>
391
- part.type === "text" ? part.text : `[image: ${part.mimeType}]`,
392
- )
393
- .join("\n");
394
- entries.push({
395
- role: "toolResult",
396
- name: message.toolName,
397
- text,
398
- isError: message.isError,
399
- timestamp: message.timestamp,
400
- ...toolMetadata(message.toolCallId, toolTimings),
401
- });
402
- }
403
- const selected =
404
- entries.length <= TRANSCRIPT_MAX_ENTRIES
405
- ? entries
406
- : [entries[0], ...entries.slice(-(TRANSCRIPT_MAX_ENTRIES - 1))];
407
- const bounded: TranscriptEntry[] = [];
408
- let totalBytes = 0;
409
- for (const entry of selected) {
410
- const remaining = TRANSCRIPT_TOTAL_MAX_BYTES - totalBytes;
411
- if (remaining <= 0) break;
412
- const text = truncateUtf8(
413
- entry.text,
414
- Math.min(TRANSCRIPT_ENTRY_MAX_BYTES, remaining),
415
- );
416
- totalBytes += Buffer.byteLength(text, "utf8");
417
- bounded.push({
418
- ...entry,
419
- text:
420
- text === entry.text ? text : `${text}\n[transcript entry truncated]`,
421
- });
422
- }
423
- if (bounded.length < entries.length) {
424
- bounded.push({
425
- role: "toolResult",
426
- name: "transcript",
427
- text: `[transcript truncated: retained ${bounded.length} of ${entries.length} entries]`,
428
- });
429
- }
430
- return bounded;
431
- }
432
-
433
- function computeUsage(messages: AgentMessage[]): AgentUsage {
434
- const usage = emptyUsage();
435
- for (const msg of messages) {
436
- if (msg.role !== "assistant") continue;
437
- usage.turns++;
438
- const u = msg.usage;
439
- if (!u) continue;
440
- usage.input += u.input || 0;
441
- usage.output += u.output || 0;
442
- usage.cacheRead += u.cacheRead || 0;
443
- usage.cacheWrite += u.cacheWrite || 0;
444
- usage.cost += u.cost?.total || 0;
445
- }
446
- return usage;
447
- }
448
-
449
241
  function errorText(error: unknown): string {
450
242
  return (error instanceof Error ? error.message : String(error)).slice(
451
243
  0,
@@ -459,35 +251,82 @@ function formatTimeout(timeoutMs: number) {
459
251
  : `${timeoutMs} ms`;
460
252
  }
461
253
 
462
- /** Abort a provider call that opens but never emits its first assistant event. */
463
- export function createFirstResponseWatchdog(
464
- onTimeout: () => Promise<unknown>,
254
+ export function resolveModelProgressTimeoutMs(
255
+ settingsManager: SettingsManager,
256
+ override?: number,
257
+ ) {
258
+ if (override !== undefined) return override;
259
+ const configured =
260
+ settingsManager.getProjectSettings().httpIdleTimeoutMs ??
261
+ settingsManager.getGlobalSettings().httpIdleTimeoutMs;
262
+ return typeof configured === "number" && Number.isFinite(configured)
263
+ ? Math.max(MODEL_PROGRESS_TIMEOUT_MS, Math.floor(configured))
264
+ : MODEL_PROGRESS_TIMEOUT_MS;
265
+ }
266
+
267
+ /** Abort any provider turn that stops producing model-visible progress. */
268
+ export function createModelProgressWatchdog(
269
+ onTimeout: (error: Error) => Promise<unknown>,
465
270
  options: { timeoutMs?: number; model?: string } = {},
466
271
  ) {
467
- const timeoutMs = options.timeoutMs ?? FIRST_RESPONSE_TIMEOUT_MS;
272
+ const timeoutMs = options.timeoutMs ?? MODEL_PROGRESS_TIMEOUT_MS;
468
273
  let timer: ReturnType<typeof setTimeout> | undefined;
274
+ let activeTurn = false;
275
+ let closed = false;
276
+ let rejectTimeout!: (error: Error) => void;
469
277
  const timeout = new Promise<never>((_resolve, reject) => {
278
+ rejectTimeout = reject;
279
+ });
280
+
281
+ const clear = () => {
282
+ if (timer) clearTimeout(timer);
283
+ timer = undefined;
284
+ };
285
+ const schedule = () => {
286
+ clear();
287
+ if (!activeTurn || closed) return;
470
288
  // This timer owns the awaited watchdog outcome. Keep it referenced so a
471
289
  // short-lived Node 22 process cannot exit with the promise still pending.
472
290
  timer = setTimeout(() => {
473
291
  timer = undefined;
292
+ activeTurn = false;
293
+ closed = true;
474
294
  const model = options.model ? ` for ${options.model}` : "";
475
- reject(
476
- new Error(
477
- `Agent received no assistant response event${model} within ${formatTimeout(timeoutMs)}; the provider request may be stalled. Retry the workflow.`,
478
- ),
295
+ const error = new Error(
296
+ `Agent provider turn${model} produced no model-visible progress for ${formatTimeout(timeoutMs)}; the provider request may be stalled. Retry the workflow.`,
479
297
  );
480
- void onTimeout().catch(() => {});
298
+ rejectTimeout(error);
299
+ try {
300
+ void onTimeout(error).catch(() => {});
301
+ } catch {
302
+ // The timeout result remains authoritative even if abort throws before
303
+ // returning its promise; bounded shutdown below gets another chance.
304
+ }
481
305
  }, timeoutMs);
482
- });
483
-
306
+ };
307
+ const armTurn = () => {
308
+ if (closed) return;
309
+ activeTurn = true;
310
+ schedule();
311
+ };
312
+ const markProgress = () => {
313
+ if (!activeTurn || closed) return;
314
+ schedule();
315
+ };
316
+ const completeTurn = () => {
317
+ activeTurn = false;
318
+ clear();
319
+ };
484
320
  const cancel = () => {
485
- if (timer) clearTimeout(timer);
486
- timer = undefined;
321
+ closed = true;
322
+ activeTurn = false;
323
+ clear();
487
324
  };
488
325
 
489
326
  return {
490
- markResponse: cancel,
327
+ armTurn,
328
+ markProgress,
329
+ completeTurn,
491
330
  cancel,
492
331
  async waitFor<T>(operation: Promise<T>) {
493
332
  try {
@@ -499,13 +338,24 @@ export function createFirstResponseWatchdog(
499
338
  };
500
339
  }
501
340
 
502
- function isAssistantResponseEvent(event: AgentSessionEvent) {
503
- return (
504
- (event.type === "message_start" ||
505
- event.type === "message_update" ||
506
- event.type === "message_end") &&
507
- event.message.role === "assistant"
508
- );
341
+ function isModelVisibleProgress(event: AgentSessionEvent) {
342
+ if (event.type !== "message_update" || event.message.role !== "assistant") {
343
+ return false;
344
+ }
345
+ // Raw transport heartbeats never become AgentSession events. Empty stream,
346
+ // text, and thinking starts likewise cannot keep a provider turn alive.
347
+ const update = event.assistantMessageEvent;
348
+ if (
349
+ update.type === "text_delta" ||
350
+ update.type === "thinking_delta" ||
351
+ update.type === "toolcall_delta"
352
+ ) {
353
+ return update.delta.length > 0;
354
+ }
355
+ if (update.type === "text_end" || update.type === "thinking_end") {
356
+ return update.content.length > 0;
357
+ }
358
+ return update.type === "toolcall_start" || update.type === "toolcall_end";
509
359
  }
510
360
 
511
361
  export async function runAgent(
@@ -516,11 +366,51 @@ export async function runAgent(
516
366
  let customTools: ToolDefinition[] | undefined;
517
367
  let session: AgentSession | undefined;
518
368
  let unsubscribeToolGuards: (() => void) | undefined;
369
+ let aborted = false;
370
+ let terminalCause: "abort" | "model-progress-timeout" | undefined;
371
+ let modelProgressTimeoutMessage: string | undefined;
372
+ let abortOperation: Promise<unknown> | undefined;
373
+ let rejectForAbort: ((error: Error) => void) | undefined;
374
+ let rejectForProjectionFailure: ((error: Error) => void) | undefined;
375
+ const abortRace = new Promise<never>((_resolve, reject) => {
376
+ rejectForAbort = reject;
377
+ });
378
+ const projectionFailureRace = new Promise<never>((_resolve, reject) => {
379
+ rejectForProjectionFailure = reject;
380
+ });
381
+ // The same promise covers startup and prompting. Keep it observed even when
382
+ // a pre-aborted run returns before either race is installed.
383
+ void abortRace.catch(() => {});
384
+ void projectionFailureRace.catch(() => {});
385
+ const abortError = () =>
386
+ options.signal?.reason instanceof Error
387
+ ? options.signal.reason
388
+ : new Error("Agent was aborted");
389
+ const onAbort = () => {
390
+ if (aborted) return;
391
+ aborted = true;
392
+ terminalCause ??= "abort";
393
+ if (session) {
394
+ try {
395
+ abortOperation ??= session.abort();
396
+ void abortOperation.catch(() => {});
397
+ } catch (error) {
398
+ abortOperation = Promise.reject(error);
399
+ void abortOperation.catch(() => {});
400
+ }
401
+ }
402
+ rejectForAbort?.(abortError());
403
+ };
404
+ if (options.signal) {
405
+ if (options.signal.aborted) onAbort();
406
+ else options.signal.addEventListener("abort", onAbort, { once: true });
407
+ }
408
+
519
409
  try {
520
410
  customTools =
521
411
  options.schema !== undefined
522
412
  ? [
523
- makeStructuredOutputTool(options.schema, (value) => {
413
+ createStructuredOutputTool(options.schema, (value) => {
524
414
  if (!settled) structured = value;
525
415
  }),
526
416
  ]
@@ -529,7 +419,8 @@ export async function runAgent(
529
419
  options.tools,
530
420
  customTools !== undefined,
531
421
  );
532
- ({ session } = await (options.sessionFactory ?? createAgentSession)({
422
+ if (aborted) throw abortError();
423
+ const sessionCreation = (options.sessionFactory ?? createAgentSession)({
533
424
  cwd: options.cwd,
534
425
  ...(options.model ? { model: options.model } : {}),
535
426
  ...(options.thinkingLevel
@@ -541,8 +432,28 @@ export async function runAgent(
541
432
  options.sessionManager ?? SessionManager.inMemory(options.cwd),
542
433
  ...(customTools ? { customTools } : {}),
543
434
  ...childToolPolicy(childTools),
544
- }));
545
- await bindChildSessionExtensions(session, childTools);
435
+ });
436
+ // Promise.race cannot cancel the factory. If cancellation wins, retain
437
+ // ownership of a late session and dispose it without re-entering startup.
438
+ void sessionCreation
439
+ .then(
440
+ ({ session: lateSession }) => {
441
+ if (!aborted) return;
442
+ return shutdownAndDisposeChildSession(lateSession, {
443
+ abort: true,
444
+ timeoutMs: options.shutdownTimeoutMs,
445
+ });
446
+ },
447
+ () => undefined,
448
+ )
449
+ .catch(() => {});
450
+ ({ session } = await Promise.race([sessionCreation, abortRace]));
451
+ if (aborted) throw abortError();
452
+ await Promise.race([
453
+ bindChildSessionExtensions(session, childTools),
454
+ abortRace,
455
+ ]);
456
+ if (aborted) throw abortError();
546
457
  unsubscribeToolGuards = guardWorkflowChildTools(
547
458
  session,
548
459
  options.toolCallTimeoutMs,
@@ -551,13 +462,29 @@ export async function runAgent(
551
462
  } catch (error) {
552
463
  settled = true;
553
464
  unsubscribeToolGuards?.();
465
+ options.signal?.removeEventListener("abort", onAbort);
554
466
  const cleanup = session
555
467
  ? await shutdownAndDisposeChildSession(session, {
556
468
  abort: true,
469
+ abortOperation,
557
470
  timeoutMs: options.shutdownTimeoutMs,
558
471
  })
559
472
  : undefined;
560
473
  const cleanupError = cleanup?.errors.join("; ");
474
+ if (aborted) {
475
+ return {
476
+ ok: false,
477
+ output: "",
478
+ error: cleanupError
479
+ ? `Agent was aborted; Cleanup failed: ${cleanupError}`
480
+ : "Agent was aborted",
481
+ aborted: true,
482
+ usage: emptyUsage(),
483
+ model: options.model?.id,
484
+ contextWindow: options.model?.contextWindow,
485
+ transcript: [],
486
+ };
487
+ }
561
488
  return {
562
489
  ok: false,
563
490
  output: "",
@@ -571,6 +498,7 @@ export async function runAgent(
571
498
  }
572
499
 
573
500
  const childSession = session;
501
+ const toolRenderer = new AgentToolRenderLedger();
574
502
  let usage = emptyUsage();
575
503
  let modelId = childSession.model?.id ?? options.model?.id;
576
504
  let contextWindow = childSession.model?.contextWindow;
@@ -578,21 +506,50 @@ export async function runAgent(
578
506
  let promptErrorMessage: string | undefined;
579
507
  const toolTimings = new Map<string, ToolExecutionTiming>();
580
508
 
581
- const sync = () => {
582
- const messages = childSession.messages;
583
- usage = computeUsage(messages);
509
+ const captureToolRenderData = (messages: readonly AgentMessage[]) => {
510
+ for (const message of messages) {
511
+ if (message.role === "assistant") {
512
+ for (const part of message.content) {
513
+ if (part.type !== "toolCall") continue;
514
+ toolRenderer.start(
515
+ part.id,
516
+ part.name,
517
+ part.arguments,
518
+ childSession.getToolDefinition(part.name),
519
+ );
520
+ }
521
+ } else if (message.role === "toolResult") {
522
+ toolRenderer.end(
523
+ message.toolCallId,
524
+ message.toolName,
525
+ message,
526
+ message.isError,
527
+ );
528
+ }
529
+ }
530
+ };
584
531
 
532
+ const refreshModel = (latestAssistant?: AssistantMessage) => {
585
533
  const sessionModel = childSession.model;
586
534
  modelId = sessionModel?.id ?? modelId;
587
535
  contextWindow = sessionModel?.contextWindow ?? contextWindow;
588
- const context = childSession.getContextUsage();
589
- if (
590
- typeof context?.tokens === "number" &&
591
- Number.isFinite(context.tokens) &&
592
- context.tokens >= 0
593
- ) {
594
- usage.contextTokens = context.tokens;
536
+ if (!latestAssistant) return;
537
+ const responseMatchesSession =
538
+ !sessionModel ||
539
+ (latestAssistant.provider === sessionModel.provider &&
540
+ latestAssistant.model === sessionModel.id);
541
+ const reportedId = latestAssistant.responseModel ?? latestAssistant.model;
542
+ const reportedModel = responseMatchesSession
543
+ ? options.modelRegistry.find(latestAssistant.provider, reportedId)
544
+ : undefined;
545
+ if (reportedModel) {
546
+ modelId = reportedModel.id;
547
+ contextWindow = reportedModel.contextWindow;
595
548
  }
549
+ };
550
+
551
+ const sampleContextTokens = () => {
552
+ const context = childSession.getContextUsage();
596
553
  if (
597
554
  typeof context?.contextWindow === "number" &&
598
555
  Number.isFinite(context.contextWindow) &&
@@ -600,127 +557,194 @@ export async function runAgent(
600
557
  ) {
601
558
  contextWindow = context.contextWindow;
602
559
  }
603
-
604
- for (let i = messages.length - 1; i >= 0; i--) {
605
- const msg = messages[i];
606
- if (msg.role !== "assistant") continue;
607
- // Some gateways report a concrete fallback model. Prefer its registry
608
- // metadata when available so capacity tracks the model that served the
609
- // latest response rather than a hardcoded/configured guess.
610
- const responseMatchesSession =
611
- !sessionModel ||
612
- (msg.provider === sessionModel.provider &&
613
- msg.model === sessionModel.id);
614
- const reportedId = msg.responseModel ?? msg.model;
615
- const reportedModel = responseMatchesSession
616
- ? options.modelRegistry.find(msg.provider, reportedId)
617
- : undefined;
618
- if (reportedModel) {
619
- modelId = reportedModel.id;
620
- contextWindow = reportedModel.contextWindow;
621
- }
622
- break;
560
+ if (
561
+ typeof context?.tokens === "number" &&
562
+ Number.isFinite(context.tokens) &&
563
+ context.tokens >= 0
564
+ ) {
565
+ return context.tokens;
623
566
  }
567
+ return context?.tokens === null ? null : undefined;
568
+ };
569
+
570
+ const projection = new AgentProgressProjection();
571
+ let lastProjection = projection.snapshot(toolTimings);
572
+
573
+ const snapshotProjection = () => {
574
+ const snapshot = projection.snapshot(toolTimings);
575
+ usage = snapshot.usage;
576
+ refreshModel(snapshot.latestAssistant);
577
+ lastProjection = snapshot;
578
+ return snapshot;
579
+ };
580
+
581
+ const reconcileProjection = () => {
582
+ projection.replace(childSession.messages, sampleContextTokens());
583
+ return snapshotProjection();
624
584
  };
625
585
 
626
- let markFirstResponse = () => {};
627
- let cancelFirstResponseWatchdog = () => {};
586
+ const emitProgress = () => {
587
+ const snapshot = snapshotProjection();
588
+ options.onProgress?.({
589
+ preview: snapshot.preview,
590
+ usage,
591
+ model: modelId,
592
+ contextWindow,
593
+ transcript: bindWorkflowToolRenderer(snapshot.transcript, toolRenderer),
594
+ });
595
+ };
596
+
597
+ let armModelProgress = () => {};
598
+ let markModelProgress = () => {};
599
+ let completeModelTurn = () => {};
600
+ let cancelModelProgressWatchdog = () => {};
601
+ let compactionReconcileQueued = false;
602
+ const queueCompactionReconcile = () => {
603
+ if (compactionReconcileQueued) return;
604
+ compactionReconcileQueued = true;
605
+ queueMicrotask(() => {
606
+ compactionReconcileQueued = false;
607
+ if (settled) return;
608
+ // Pi may synchronously remove a restored overflow assistant immediately
609
+ // after compaction_end. Read canonical state after that mutation.
610
+ try {
611
+ reconcileProjection();
612
+ emitProgress();
613
+ } catch (error) {
614
+ promptErrorMessage ??= `Failed to reconcile agent progress after compaction: ${errorText(error)}`;
615
+ rejectForProjectionFailure?.(new Error(promptErrorMessage));
616
+ try {
617
+ abortOperation ??= childSession.abort();
618
+ void abortOperation.catch(() => {});
619
+ } catch {
620
+ // The projection failure remains authoritative; bounded cleanup gets
621
+ // another chance to abort and dispose the child below.
622
+ }
623
+ }
624
+ });
625
+ };
628
626
  const unsubscribe = childSession.subscribe((event) => {
629
627
  if (settled) return;
630
- if (isAssistantResponseEvent(event)) markFirstResponse();
628
+ if (event.type === "turn_start") armModelProgress();
629
+ if (event.type === "tool_execution_start") {
630
+ toolRenderer.start(
631
+ event.toolCallId,
632
+ event.toolName,
633
+ event.args,
634
+ childSession.getToolDefinition(event.toolName),
635
+ );
636
+ } else if (event.type === "tool_execution_update") {
637
+ toolRenderer.update(
638
+ event.toolCallId,
639
+ event.toolName,
640
+ event.args,
641
+ event.partialResult,
642
+ );
643
+ } else if (event.type === "tool_execution_end") {
644
+ toolRenderer.end(
645
+ event.toolCallId,
646
+ event.toolName,
647
+ event.result,
648
+ event.isError,
649
+ );
650
+ }
651
+ if (isModelVisibleProgress(event)) markModelProgress();
652
+ if (event.type === "message_end" && event.message.role === "assistant") {
653
+ completeModelTurn();
654
+ }
631
655
  if (event.type === "message_end") {
632
656
  assistantSettlement = observeAssistantSettlement(
633
657
  assistantSettlement,
634
658
  event.message,
635
659
  );
660
+ // Pi publishes the finalized message before tool lifecycle delivery.
661
+ // Hydrate native rendering from this one message without reading history.
662
+ captureToolRenderData([event.message]);
663
+ projection.append(event.message);
636
664
  }
637
665
  if (
638
666
  event.type === "tool_execution_start" ||
639
667
  event.type === "tool_execution_end"
640
668
  ) {
641
669
  recordToolExecutionTiming(toolTimings, event);
642
- } else if (
643
- event.type !== "message_end" &&
644
- event.type !== "compaction_end"
645
- ) {
670
+ } else if (event.type === "compaction_end") {
671
+ queueCompactionReconcile();
672
+ return;
673
+ } else if (event.type !== "message_end") {
646
674
  return;
647
675
  }
648
- sync();
649
- options.onProgress?.({
650
- preview: finalOutput(childSession.messages),
651
- usage,
652
- model: modelId,
653
- contextWindow,
654
- transcript: transcriptFromMessages(childSession.messages, toolTimings),
655
- });
676
+ emitProgress();
656
677
  });
657
678
 
658
- let aborted = false;
659
- let abortOperation: Promise<unknown> | undefined;
660
- let rejectForAbort: ((error: Error) => void) | undefined;
661
- const abortRace = new Promise<never>((_resolve, reject) => {
662
- rejectForAbort = reject;
663
- });
664
- const onAbort = () => {
665
- if (aborted) return;
666
- aborted = true;
667
- try {
668
- abortOperation ??= childSession.abort();
669
- void abortOperation.catch(() => {});
670
- } catch (error) {
671
- abortOperation = Promise.reject(error);
672
- void abortOperation.catch(() => {});
673
- }
674
- rejectForAbort?.(
675
- options.signal?.reason instanceof Error
676
- ? options.signal.reason
677
- : new Error("Agent was aborted"),
678
- );
679
- };
680
- if (options.signal) {
681
- if (options.signal.aborted) onAbort();
682
- else options.signal.addEventListener("abort", onAbort, { once: true });
683
- }
684
-
685
679
  let output = "";
686
680
  let transcript: TranscriptEntry[] = [];
687
681
  let cleanupErrors: string[] = [];
688
682
  try {
683
+ // Operator reuse may restore messages before this activation subscribes.
684
+ // Hydrate both bounded projections once; ordinary progress never rescans it.
685
+ projection.replace(childSession.messages, sampleContextTokens());
686
+ captureToolRenderData(childSession.messages);
687
+ snapshotProjection();
689
688
  if (!aborted) {
690
- const watchdog = createFirstResponseWatchdog(
691
- () => {
689
+ const watchdog = createModelProgressWatchdog(
690
+ (error) => {
691
+ terminalCause ??= "model-progress-timeout";
692
+ if (terminalCause === "model-progress-timeout") {
693
+ modelProgressTimeoutMessage ??= error.message;
694
+ }
692
695
  abortOperation ??= childSession.abort();
693
696
  void abortOperation.catch(() => {});
694
697
  return abortOperation;
695
698
  },
696
699
  {
697
- timeoutMs: options.firstResponseTimeoutMs,
700
+ timeoutMs: resolveModelProgressTimeoutMs(
701
+ options.settingsManager,
702
+ options.modelProgressTimeoutMs,
703
+ ),
698
704
  model: modelId,
699
705
  },
700
706
  );
701
- markFirstResponse = watchdog.markResponse;
702
- cancelFirstResponseWatchdog = watchdog.cancel;
707
+ armModelProgress = watchdog.armTurn;
708
+ markModelProgress = watchdog.markProgress;
709
+ completeModelTurn = watchdog.completeTurn;
710
+ cancelModelProgressWatchdog = watchdog.cancel;
703
711
  await Promise.race([
704
712
  watchdog.waitFor(
705
713
  childSession.prompt(buildWorkflowAgentPrompt(options.prompt)),
706
714
  ),
707
715
  abortRace,
716
+ projectionFailureRace,
708
717
  ]);
709
718
  }
710
719
  } catch (error) {
711
- promptErrorMessage = errorText(error);
720
+ promptErrorMessage ??= errorText(error);
712
721
  } finally {
713
- cancelFirstResponseWatchdog();
722
+ cancelModelProgressWatchdog();
714
723
  options.signal?.removeEventListener("abort", onAbort);
715
724
  settled = true;
716
725
  unsubscribe();
717
726
  unsubscribeToolGuards?.();
718
- sync();
719
- output = truncateUtf8(
720
- finalOutput(childSession.messages),
721
- AGENT_OUTPUT_MAX_BYTES,
722
- );
723
- transcript = transcriptFromMessages(childSession.messages, toolTimings);
727
+ try {
728
+ const finalProjection = reconcileProjection();
729
+ output = truncateUtf8(finalProjection.preview, AGENT_OUTPUT_MAX_BYTES);
730
+ transcript = bindWorkflowToolRenderer(
731
+ finalProjection.transcript,
732
+ toolRenderer,
733
+ );
734
+ } catch (error) {
735
+ promptErrorMessage ??= `Failed to reconcile final agent progress: ${errorText(error)}`;
736
+ // A broken canonical accessor must not prevent owned-session cleanup.
737
+ // Preserve the last projection that was fully observed instead.
738
+ try {
739
+ output = truncateUtf8(lastProjection.preview, AGENT_OUTPUT_MAX_BYTES);
740
+ transcript = bindWorkflowToolRenderer(
741
+ lastProjection.transcript,
742
+ toolRenderer,
743
+ );
744
+ } catch (fallbackError) {
745
+ promptErrorMessage += `; failed to render last progress: ${errorText(fallbackError)}`;
746
+ }
747
+ }
724
748
  const cleanup = await shutdownAndDisposeChildSession(childSession, {
725
749
  abort: aborted || promptErrorMessage !== undefined,
726
750
  abortOperation,
@@ -734,7 +758,11 @@ export async function runAgent(
734
758
  ? `Cleanup failed: ${cleanupErrors.join("; ")}`
735
759
  : undefined;
736
760
 
737
- if (aborted || assistantSettlement?.stopReason === "aborted") {
761
+ if (
762
+ terminalCause === "abort" ||
763
+ (terminalCause === undefined &&
764
+ assistantSettlement?.stopReason === "aborted")
765
+ ) {
738
766
  return {
739
767
  ok: false,
740
768
  output,
@@ -751,7 +779,9 @@ export async function runAgent(
751
779
  }
752
780
 
753
781
  const failureMessage =
754
- agentFailureMessage(assistantSettlement, promptErrorMessage) ??
782
+ (terminalCause === "model-progress-timeout"
783
+ ? modelProgressTimeoutMessage
784
+ : agentFailureMessage(assistantSettlement, promptErrorMessage)) ??
755
785
  cleanupError;
756
786
  if (failureMessage !== undefined) {
757
787
  return {
@@ -781,6 +811,20 @@ export async function runAgent(
781
811
  };
782
812
  }
783
813
 
814
+ if (options.schema === undefined && assistantSettlement === undefined) {
815
+ return {
816
+ ok: false,
817
+ output,
818
+ structured,
819
+ error: "Agent finished without an assistant response.",
820
+ aborted: false,
821
+ usage,
822
+ model: modelId,
823
+ contextWindow,
824
+ transcript,
825
+ };
826
+ }
827
+
784
828
  return {
785
829
  ok: true,
786
830
  output,