@tt-a1i/openpi 0.4.0 → 0.5.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 (109) hide show
  1. package/README.md +102 -40
  2. package/SETUP.md +22 -6
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/background-terminals/index.ts +30 -2
  6. package/extensions/background-terminals/src/domain.ts +2 -0
  7. package/extensions/background-terminals/src/manager.ts +486 -106
  8. package/extensions/background-terminals/src/output.ts +33 -0
  9. package/extensions/background-terminals/src/prompt.ts +13 -5
  10. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  11. package/extensions/clear-context/index.ts +83 -0
  12. package/extensions/context-pivot/index.ts +16 -6
  13. package/extensions/cron/schedule.ts +7 -1
  14. package/extensions/file-mutation-display/render.ts +17 -257
  15. package/extensions/file-search/src/binaries.ts +57 -41
  16. package/extensions/git-read/index.ts +1 -3
  17. package/extensions/model-info/index.ts +21 -33
  18. package/extensions/model-info/session-metrics.ts +96 -0
  19. package/extensions/plan-mode/bash-policy.ts +54 -9
  20. package/extensions/plan-mode/index.ts +7 -2
  21. package/extensions/post-edit/index.ts +16 -6
  22. package/extensions/sessions/git-stats.ts +258 -72
  23. package/extensions/sessions/index.ts +153 -86
  24. package/extensions/sessions/preview-cache.ts +104 -0
  25. package/extensions/sessions/preview-loader.ts +856 -0
  26. package/extensions/sessions/sessions.ts +43 -4
  27. package/extensions/setup/index.ts +123 -127
  28. package/extensions/shared/activity-status.ts +30 -0
  29. package/extensions/shared/agent-session-page.ts +319 -0
  30. package/extensions/shared/agent-tool-renderer.ts +218 -0
  31. package/extensions/shared/agent-transcript.ts +524 -0
  32. package/extensions/shared/capability-intent.ts +1 -1
  33. package/extensions/shared/child-session.ts +437 -21
  34. package/extensions/shared/result-delivery.ts +34 -0
  35. package/extensions/shared/setup-config.ts +73 -33
  36. package/extensions/shared/setup-episode-state.ts +1 -1
  37. package/extensions/shared/terminal-text.ts +110 -23
  38. package/extensions/shared/text-projection.ts +72 -15
  39. package/extensions/shared/tool-activity.ts +382 -0
  40. package/extensions/shared/tool-surface.ts +29 -2
  41. package/extensions/shared/transcript-viewport.ts +46 -0
  42. package/extensions/shared/web-observer-registry.ts +390 -0
  43. package/extensions/shared/worktree.ts +11 -0
  44. package/extensions/subagents/index.ts +270 -59
  45. package/extensions/subagents/navigation.ts +34 -5
  46. package/extensions/subagents/src/backend.ts +12 -1
  47. package/extensions/subagents/src/backends/pi.ts +375 -66
  48. package/extensions/subagents/src/domain.ts +5 -0
  49. package/extensions/subagents/src/manager.ts +34 -2
  50. package/extensions/subagents/src/prompt.ts +32 -4
  51. package/extensions/subagents/src/result-artifact.ts +4 -0
  52. package/extensions/subagents/src/result-delivery.ts +7 -1
  53. package/extensions/subagents/src/runtime.ts +15 -1
  54. package/extensions/subagents/src/ui/takeover.ts +73 -257
  55. package/extensions/subagents/src/ui/transcript.ts +38 -535
  56. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  57. package/extensions/suggestions/src/ui.ts +10 -4
  58. package/extensions/tasks/index.ts +0 -3
  59. package/extensions/ui-customization/footer.ts +0 -40
  60. package/extensions/ui-customization/index.ts +0 -4
  61. package/extensions/user-input-fold/index.ts +1 -1
  62. package/extensions/web/index.ts +234 -0
  63. package/extensions/workflows/artifacts.ts +137 -47
  64. package/extensions/workflows/completion-projection.ts +457 -0
  65. package/extensions/workflows/coordinator.ts +8 -10
  66. package/extensions/workflows/dashboard.ts +167 -228
  67. package/extensions/workflows/handoff.ts +70 -16
  68. package/extensions/workflows/index.ts +488 -198
  69. package/extensions/workflows/journal.ts +148 -13
  70. package/extensions/workflows/model.ts +74 -4
  71. package/extensions/workflows/navigation.ts +32 -8
  72. package/extensions/workflows/progress-projection.ts +306 -0
  73. package/extensions/workflows/prompt.ts +66 -6
  74. package/extensions/workflows/replay-safety.ts +42 -21
  75. package/extensions/workflows/result-delivery.ts +128 -64
  76. package/extensions/workflows/retention.ts +593 -0
  77. package/extensions/workflows/runner.ts +388 -279
  78. package/extensions/workflows/sandbox-child.cjs +25 -3
  79. package/extensions/workflows/sandbox.ts +62 -8
  80. package/extensions/workflows/serialization.ts +325 -17
  81. package/extensions/workflows/tool-renderer.ts +22 -0
  82. package/extensions/workflows/transcript.ts +149 -0
  83. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  84. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  85. package/package.json +28 -8
  86. package/skills/subagents/REFERENCE.md +189 -0
  87. package/skills/subagents/SKILL.md +1 -1
  88. package/skills/workflows/REFERENCE.md +4 -2
  89. package/web/adapter/pi-adapter.ts +661 -0
  90. package/web/host/browser-launcher.ts +20 -0
  91. package/web/host/static-assets.ts +4 -0
  92. package/web/host/terminal-status.ts +38 -0
  93. package/web/host/web-host.ts +789 -0
  94. package/web/http-dispatcher.ts +125 -0
  95. package/web/protocol/types.ts +462 -0
  96. package/web/runtime/pi-runtime.ts +991 -0
  97. package/web/runtime/types.ts +71 -0
  98. package/web/runtime/web-host-lease.ts +497 -0
  99. package/web/trace.ts +18 -0
  100. package/web/ui/app.js +1398 -0
  101. package/web/ui/index.html +139 -0
  102. package/web/ui/styles.css +598 -0
  103. package/web/vite.config.mjs +34 -0
  104. package/extensions/execution-convergence/active-evidence.ts +0 -129
  105. package/extensions/execution-convergence/index.ts +0 -442
  106. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  107. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  108. package/extensions/setup/intercom.ts +0 -603
  109. package/extensions/subagents/src/backends/stub.ts +0 -303
@@ -11,19 +11,20 @@
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,
19
+ defineTool,
22
20
  type ExtensionAPI,
23
21
  type ExtensionContext,
22
+ SessionManager,
23
+ SettingsManager,
24
24
  type ToolDefinition,
25
25
  } from "@earendil-works/pi-coding-agent";
26
- import { Type, type TSchema } from "typebox";
26
+ import { type TSchema, Type } from "typebox";
27
+ import { AgentToolRenderLedger } from "../shared/agent-tool-renderer.ts";
27
28
  import {
28
29
  bindChildSessionExtensions,
29
30
  childToolPolicy,
@@ -31,23 +32,26 @@ import {
31
32
  shutdownAndDisposeChildSession,
32
33
  } from "../shared/child-session.ts";
33
34
  import { createToolCallTimeoutGuard } from "../shared/tool-call-timeout.ts";
34
- import { emptyUsage, type AgentUsage, type TranscriptEntry } from "./model.ts";
35
- import {
36
- createReplayFilesystemBoundary,
37
- type ReplayFilesystemBoundaryOptions,
38
- } from "./replay-safety.ts";
35
+ import { type AgentUsage, emptyUsage, type TranscriptEntry } from "./model.ts";
39
36
  import {
40
37
  buildWorkflowAgentPrompt,
41
38
  STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION,
42
39
  STRUCTURED_OUTPUT_TOOL_DESCRIPTION,
43
40
  } from "./prompt.ts";
44
- import { safeStringify, truncateUtf8 } from "./serialization.ts";
41
+ import {
42
+ AgentProgressProjection,
43
+ type ProgressAssistantMessage,
44
+ transcriptFromMessages,
45
+ } from "./progress-projection.ts";
46
+ import {
47
+ createReplayFilesystemBoundary,
48
+ type ReplayFilesystemBoundaryOptions,
49
+ } from "./replay-safety.ts";
50
+ import { truncateUtf8 } from "./serialization.ts";
51
+ import { bindWorkflowToolRenderer } from "./tool-renderer.ts";
45
52
 
46
53
  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;
54
+ export const MODEL_PROGRESS_TIMEOUT_MS = 45_000;
51
55
 
52
56
  export type WorkflowModel = NonNullable<ExtensionContext["model"]>;
53
57
  export type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
@@ -108,8 +112,8 @@ export interface RunAgentOptions {
108
112
  replayFilesystemBoundary?: ReplayFilesystemBoundaryOptions;
109
113
  /** Test-only override for the per-tool execution timeout. */
110
114
  toolCallTimeoutMs?: number;
111
- /** Test-only override for the first assistant response-event timeout. */
112
- firstResponseTimeoutMs?: number;
115
+ /** Test-only override for the per-provider-turn model-progress timeout. */
116
+ modelProgressTimeoutMs?: number;
113
117
  /** Test-only override for the end-to-end abort/shutdown deadline. */
114
118
  shutdownTimeoutMs?: number;
115
119
  /** Test seam for lifecycle races; production always uses createAgentSession. */
@@ -237,7 +241,9 @@ function makeStructuredOutputTool(
237
241
  });
238
242
  }
239
243
 
240
- type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
244
+ type AssistantMessage = ProgressAssistantMessage;
245
+
246
+ export { transcriptFromMessages };
241
247
 
242
248
  export interface AssistantSettlement {
243
249
  stopReason: AssistantMessage["stopReason"];
@@ -273,28 +279,6 @@ export function agentFailureMessage(
273
279
  return settlement?.errorMessage ?? promptErrorMessage ?? "Agent failed";
274
280
  }
275
281
 
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
282
  /** Record lifecycle timings without inferring completion from message timestamps. */
299
283
  export function recordToolExecutionTiming(
300
284
  timings: Map<string, ToolExecutionTiming>,
@@ -319,133 +303,6 @@ export function recordToolExecutionTiming(
319
303
  });
320
304
  }
321
305
 
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
306
  function errorText(error: unknown): string {
450
307
  return (error instanceof Error ? error.message : String(error)).slice(
451
308
  0,
@@ -459,35 +316,82 @@ function formatTimeout(timeoutMs: number) {
459
316
  : `${timeoutMs} ms`;
460
317
  }
461
318
 
462
- /** Abort a provider call that opens but never emits its first assistant event. */
463
- export function createFirstResponseWatchdog(
464
- onTimeout: () => Promise<unknown>,
319
+ export function resolveModelProgressTimeoutMs(
320
+ settingsManager: SettingsManager,
321
+ override?: number,
322
+ ) {
323
+ if (override !== undefined) return override;
324
+ const configured =
325
+ settingsManager.getProjectSettings().httpIdleTimeoutMs ??
326
+ settingsManager.getGlobalSettings().httpIdleTimeoutMs;
327
+ return typeof configured === "number" && Number.isFinite(configured)
328
+ ? Math.max(MODEL_PROGRESS_TIMEOUT_MS, Math.floor(configured))
329
+ : MODEL_PROGRESS_TIMEOUT_MS;
330
+ }
331
+
332
+ /** Abort any provider turn that stops producing model-visible progress. */
333
+ export function createModelProgressWatchdog(
334
+ onTimeout: (error: Error) => Promise<unknown>,
465
335
  options: { timeoutMs?: number; model?: string } = {},
466
336
  ) {
467
- const timeoutMs = options.timeoutMs ?? FIRST_RESPONSE_TIMEOUT_MS;
337
+ const timeoutMs = options.timeoutMs ?? MODEL_PROGRESS_TIMEOUT_MS;
468
338
  let timer: ReturnType<typeof setTimeout> | undefined;
339
+ let activeTurn = false;
340
+ let closed = false;
341
+ let rejectTimeout!: (error: Error) => void;
469
342
  const timeout = new Promise<never>((_resolve, reject) => {
343
+ rejectTimeout = reject;
344
+ });
345
+
346
+ const clear = () => {
347
+ if (timer) clearTimeout(timer);
348
+ timer = undefined;
349
+ };
350
+ const schedule = () => {
351
+ clear();
352
+ if (!activeTurn || closed) return;
470
353
  // This timer owns the awaited watchdog outcome. Keep it referenced so a
471
354
  // short-lived Node 22 process cannot exit with the promise still pending.
472
355
  timer = setTimeout(() => {
473
356
  timer = undefined;
357
+ activeTurn = false;
358
+ closed = true;
474
359
  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
- ),
360
+ const error = new Error(
361
+ `Agent provider turn${model} produced no model-visible progress for ${formatTimeout(timeoutMs)}; the provider request may be stalled. Retry the workflow.`,
479
362
  );
480
- void onTimeout().catch(() => {});
363
+ rejectTimeout(error);
364
+ try {
365
+ void onTimeout(error).catch(() => {});
366
+ } catch {
367
+ // The timeout result remains authoritative even if abort throws before
368
+ // returning its promise; bounded shutdown below gets another chance.
369
+ }
481
370
  }, timeoutMs);
482
- });
483
-
371
+ };
372
+ const armTurn = () => {
373
+ if (closed) return;
374
+ activeTurn = true;
375
+ schedule();
376
+ };
377
+ const markProgress = () => {
378
+ if (!activeTurn || closed) return;
379
+ schedule();
380
+ };
381
+ const completeTurn = () => {
382
+ activeTurn = false;
383
+ clear();
384
+ };
484
385
  const cancel = () => {
485
- if (timer) clearTimeout(timer);
486
- timer = undefined;
386
+ closed = true;
387
+ activeTurn = false;
388
+ clear();
487
389
  };
488
390
 
489
391
  return {
490
- markResponse: cancel,
392
+ armTurn,
393
+ markProgress,
394
+ completeTurn,
491
395
  cancel,
492
396
  async waitFor<T>(operation: Promise<T>) {
493
397
  try {
@@ -499,13 +403,24 @@ export function createFirstResponseWatchdog(
499
403
  };
500
404
  }
501
405
 
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
- );
406
+ function isModelVisibleProgress(event: AgentSessionEvent) {
407
+ if (event.type !== "message_update" || event.message.role !== "assistant") {
408
+ return false;
409
+ }
410
+ // Raw transport heartbeats never become AgentSession events. Empty stream,
411
+ // text, and thinking starts likewise cannot keep a provider turn alive.
412
+ const update = event.assistantMessageEvent;
413
+ if (
414
+ update.type === "text_delta" ||
415
+ update.type === "thinking_delta" ||
416
+ update.type === "toolcall_delta"
417
+ ) {
418
+ return update.delta.length > 0;
419
+ }
420
+ if (update.type === "text_end" || update.type === "thinking_end") {
421
+ return update.content.length > 0;
422
+ }
423
+ return update.type === "toolcall_start" || update.type === "toolcall_end";
509
424
  }
510
425
 
511
426
  export async function runAgent(
@@ -516,6 +431,46 @@ export async function runAgent(
516
431
  let customTools: ToolDefinition[] | undefined;
517
432
  let session: AgentSession | undefined;
518
433
  let unsubscribeToolGuards: (() => void) | undefined;
434
+ let aborted = false;
435
+ let terminalCause: "abort" | "model-progress-timeout" | undefined;
436
+ let modelProgressTimeoutMessage: string | undefined;
437
+ let abortOperation: Promise<unknown> | undefined;
438
+ let rejectForAbort: ((error: Error) => void) | undefined;
439
+ let rejectForProjectionFailure: ((error: Error) => void) | undefined;
440
+ const abortRace = new Promise<never>((_resolve, reject) => {
441
+ rejectForAbort = reject;
442
+ });
443
+ const projectionFailureRace = new Promise<never>((_resolve, reject) => {
444
+ rejectForProjectionFailure = reject;
445
+ });
446
+ // The same promise covers startup and prompting. Keep it observed even when
447
+ // a pre-aborted run returns before either race is installed.
448
+ void abortRace.catch(() => {});
449
+ void projectionFailureRace.catch(() => {});
450
+ const abortError = () =>
451
+ options.signal?.reason instanceof Error
452
+ ? options.signal.reason
453
+ : new Error("Agent was aborted");
454
+ const onAbort = () => {
455
+ if (aborted) return;
456
+ aborted = true;
457
+ terminalCause ??= "abort";
458
+ if (session) {
459
+ try {
460
+ abortOperation ??= session.abort();
461
+ void abortOperation.catch(() => {});
462
+ } catch (error) {
463
+ abortOperation = Promise.reject(error);
464
+ void abortOperation.catch(() => {});
465
+ }
466
+ }
467
+ rejectForAbort?.(abortError());
468
+ };
469
+ if (options.signal) {
470
+ if (options.signal.aborted) onAbort();
471
+ else options.signal.addEventListener("abort", onAbort, { once: true });
472
+ }
473
+
519
474
  try {
520
475
  customTools =
521
476
  options.schema !== undefined
@@ -529,7 +484,8 @@ export async function runAgent(
529
484
  options.tools,
530
485
  customTools !== undefined,
531
486
  );
532
- ({ session } = await (options.sessionFactory ?? createAgentSession)({
487
+ if (aborted) throw abortError();
488
+ const sessionCreation = (options.sessionFactory ?? createAgentSession)({
533
489
  cwd: options.cwd,
534
490
  ...(options.model ? { model: options.model } : {}),
535
491
  ...(options.thinkingLevel
@@ -541,8 +497,28 @@ export async function runAgent(
541
497
  options.sessionManager ?? SessionManager.inMemory(options.cwd),
542
498
  ...(customTools ? { customTools } : {}),
543
499
  ...childToolPolicy(childTools),
544
- }));
545
- await bindChildSessionExtensions(session, childTools);
500
+ });
501
+ // Promise.race cannot cancel the factory. If cancellation wins, retain
502
+ // ownership of a late session and dispose it without re-entering startup.
503
+ void sessionCreation
504
+ .then(
505
+ ({ session: lateSession }) => {
506
+ if (!aborted) return;
507
+ return shutdownAndDisposeChildSession(lateSession, {
508
+ abort: true,
509
+ timeoutMs: options.shutdownTimeoutMs,
510
+ });
511
+ },
512
+ () => undefined,
513
+ )
514
+ .catch(() => {});
515
+ ({ session } = await Promise.race([sessionCreation, abortRace]));
516
+ if (aborted) throw abortError();
517
+ await Promise.race([
518
+ bindChildSessionExtensions(session, childTools),
519
+ abortRace,
520
+ ]);
521
+ if (aborted) throw abortError();
546
522
  unsubscribeToolGuards = guardWorkflowChildTools(
547
523
  session,
548
524
  options.toolCallTimeoutMs,
@@ -551,13 +527,29 @@ export async function runAgent(
551
527
  } catch (error) {
552
528
  settled = true;
553
529
  unsubscribeToolGuards?.();
530
+ options.signal?.removeEventListener("abort", onAbort);
554
531
  const cleanup = session
555
532
  ? await shutdownAndDisposeChildSession(session, {
556
533
  abort: true,
534
+ abortOperation,
557
535
  timeoutMs: options.shutdownTimeoutMs,
558
536
  })
559
537
  : undefined;
560
538
  const cleanupError = cleanup?.errors.join("; ");
539
+ if (aborted) {
540
+ return {
541
+ ok: false,
542
+ output: "",
543
+ error: cleanupError
544
+ ? `Agent was aborted; Cleanup failed: ${cleanupError}`
545
+ : "Agent was aborted",
546
+ aborted: true,
547
+ usage: emptyUsage(),
548
+ model: options.model?.id,
549
+ contextWindow: options.model?.contextWindow,
550
+ transcript: [],
551
+ };
552
+ }
561
553
  return {
562
554
  ok: false,
563
555
  output: "",
@@ -571,6 +563,7 @@ export async function runAgent(
571
563
  }
572
564
 
573
565
  const childSession = session;
566
+ const toolRenderer = new AgentToolRenderLedger();
574
567
  let usage = emptyUsage();
575
568
  let modelId = childSession.model?.id ?? options.model?.id;
576
569
  let contextWindow = childSession.model?.contextWindow;
@@ -578,21 +571,50 @@ export async function runAgent(
578
571
  let promptErrorMessage: string | undefined;
579
572
  const toolTimings = new Map<string, ToolExecutionTiming>();
580
573
 
581
- const sync = () => {
582
- const messages = childSession.messages;
583
- usage = computeUsage(messages);
574
+ const captureToolRenderData = (messages: readonly AgentMessage[]) => {
575
+ for (const message of messages) {
576
+ if (message.role === "assistant") {
577
+ for (const part of message.content) {
578
+ if (part.type !== "toolCall") continue;
579
+ toolRenderer.start(
580
+ part.id,
581
+ part.name,
582
+ part.arguments,
583
+ childSession.getToolDefinition(part.name),
584
+ );
585
+ }
586
+ } else if (message.role === "toolResult") {
587
+ toolRenderer.end(
588
+ message.toolCallId,
589
+ message.toolName,
590
+ message,
591
+ message.isError,
592
+ );
593
+ }
594
+ }
595
+ };
584
596
 
597
+ const refreshModel = (latestAssistant?: AssistantMessage) => {
585
598
  const sessionModel = childSession.model;
586
599
  modelId = sessionModel?.id ?? modelId;
587
600
  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;
601
+ if (!latestAssistant) return;
602
+ const responseMatchesSession =
603
+ !sessionModel ||
604
+ (latestAssistant.provider === sessionModel.provider &&
605
+ latestAssistant.model === sessionModel.id);
606
+ const reportedId = latestAssistant.responseModel ?? latestAssistant.model;
607
+ const reportedModel = responseMatchesSession
608
+ ? options.modelRegistry.find(latestAssistant.provider, reportedId)
609
+ : undefined;
610
+ if (reportedModel) {
611
+ modelId = reportedModel.id;
612
+ contextWindow = reportedModel.contextWindow;
595
613
  }
614
+ };
615
+
616
+ const sampleContextTokens = () => {
617
+ const context = childSession.getContextUsage();
596
618
  if (
597
619
  typeof context?.contextWindow === "number" &&
598
620
  Number.isFinite(context.contextWindow) &&
@@ -600,127 +622,194 @@ export async function runAgent(
600
622
  ) {
601
623
  contextWindow = context.contextWindow;
602
624
  }
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;
625
+ if (
626
+ typeof context?.tokens === "number" &&
627
+ Number.isFinite(context.tokens) &&
628
+ context.tokens >= 0
629
+ ) {
630
+ return context.tokens;
623
631
  }
632
+ return context?.tokens === null ? null : undefined;
624
633
  };
625
634
 
626
- let markFirstResponse = () => {};
627
- let cancelFirstResponseWatchdog = () => {};
635
+ const projection = new AgentProgressProjection();
636
+ let lastProjection = projection.snapshot(toolTimings);
637
+
638
+ const snapshotProjection = () => {
639
+ const snapshot = projection.snapshot(toolTimings);
640
+ usage = snapshot.usage;
641
+ refreshModel(snapshot.latestAssistant);
642
+ lastProjection = snapshot;
643
+ return snapshot;
644
+ };
645
+
646
+ const reconcileProjection = () => {
647
+ projection.replace(childSession.messages, sampleContextTokens());
648
+ return snapshotProjection();
649
+ };
650
+
651
+ const emitProgress = () => {
652
+ const snapshot = snapshotProjection();
653
+ options.onProgress?.({
654
+ preview: snapshot.preview,
655
+ usage,
656
+ model: modelId,
657
+ contextWindow,
658
+ transcript: bindWorkflowToolRenderer(snapshot.transcript, toolRenderer),
659
+ });
660
+ };
661
+
662
+ let armModelProgress = () => {};
663
+ let markModelProgress = () => {};
664
+ let completeModelTurn = () => {};
665
+ let cancelModelProgressWatchdog = () => {};
666
+ let compactionReconcileQueued = false;
667
+ const queueCompactionReconcile = () => {
668
+ if (compactionReconcileQueued) return;
669
+ compactionReconcileQueued = true;
670
+ queueMicrotask(() => {
671
+ compactionReconcileQueued = false;
672
+ if (settled) return;
673
+ // Pi may synchronously remove a restored overflow assistant immediately
674
+ // after compaction_end. Read canonical state after that mutation.
675
+ try {
676
+ reconcileProjection();
677
+ emitProgress();
678
+ } catch (error) {
679
+ promptErrorMessage ??= `Failed to reconcile agent progress after compaction: ${errorText(error)}`;
680
+ rejectForProjectionFailure?.(new Error(promptErrorMessage));
681
+ try {
682
+ abortOperation ??= childSession.abort();
683
+ void abortOperation.catch(() => {});
684
+ } catch {
685
+ // The projection failure remains authoritative; bounded cleanup gets
686
+ // another chance to abort and dispose the child below.
687
+ }
688
+ }
689
+ });
690
+ };
628
691
  const unsubscribe = childSession.subscribe((event) => {
629
692
  if (settled) return;
630
- if (isAssistantResponseEvent(event)) markFirstResponse();
693
+ if (event.type === "turn_start") armModelProgress();
694
+ if (event.type === "tool_execution_start") {
695
+ toolRenderer.start(
696
+ event.toolCallId,
697
+ event.toolName,
698
+ event.args,
699
+ childSession.getToolDefinition(event.toolName),
700
+ );
701
+ } else if (event.type === "tool_execution_update") {
702
+ toolRenderer.update(
703
+ event.toolCallId,
704
+ event.toolName,
705
+ event.args,
706
+ event.partialResult,
707
+ );
708
+ } else if (event.type === "tool_execution_end") {
709
+ toolRenderer.end(
710
+ event.toolCallId,
711
+ event.toolName,
712
+ event.result,
713
+ event.isError,
714
+ );
715
+ }
716
+ if (isModelVisibleProgress(event)) markModelProgress();
717
+ if (event.type === "message_end" && event.message.role === "assistant") {
718
+ completeModelTurn();
719
+ }
631
720
  if (event.type === "message_end") {
632
721
  assistantSettlement = observeAssistantSettlement(
633
722
  assistantSettlement,
634
723
  event.message,
635
724
  );
725
+ // Pi publishes the finalized message before tool lifecycle delivery.
726
+ // Hydrate native rendering from this one message without reading history.
727
+ captureToolRenderData([event.message]);
728
+ projection.append(event.message);
636
729
  }
637
730
  if (
638
731
  event.type === "tool_execution_start" ||
639
732
  event.type === "tool_execution_end"
640
733
  ) {
641
734
  recordToolExecutionTiming(toolTimings, event);
642
- } else if (
643
- event.type !== "message_end" &&
644
- event.type !== "compaction_end"
645
- ) {
735
+ } else if (event.type === "compaction_end") {
736
+ queueCompactionReconcile();
737
+ return;
738
+ } else if (event.type !== "message_end") {
646
739
  return;
647
740
  }
648
- sync();
649
- options.onProgress?.({
650
- preview: finalOutput(childSession.messages),
651
- usage,
652
- model: modelId,
653
- contextWindow,
654
- transcript: transcriptFromMessages(childSession.messages, toolTimings),
655
- });
741
+ emitProgress();
656
742
  });
657
743
 
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
744
  let output = "";
686
745
  let transcript: TranscriptEntry[] = [];
687
746
  let cleanupErrors: string[] = [];
688
747
  try {
748
+ // Operator reuse may restore messages before this activation subscribes.
749
+ // Hydrate both bounded projections once; ordinary progress never rescans it.
750
+ projection.replace(childSession.messages, sampleContextTokens());
751
+ captureToolRenderData(childSession.messages);
752
+ snapshotProjection();
689
753
  if (!aborted) {
690
- const watchdog = createFirstResponseWatchdog(
691
- () => {
754
+ const watchdog = createModelProgressWatchdog(
755
+ (error) => {
756
+ terminalCause ??= "model-progress-timeout";
757
+ if (terminalCause === "model-progress-timeout") {
758
+ modelProgressTimeoutMessage ??= error.message;
759
+ }
692
760
  abortOperation ??= childSession.abort();
693
761
  void abortOperation.catch(() => {});
694
762
  return abortOperation;
695
763
  },
696
764
  {
697
- timeoutMs: options.firstResponseTimeoutMs,
765
+ timeoutMs: resolveModelProgressTimeoutMs(
766
+ options.settingsManager,
767
+ options.modelProgressTimeoutMs,
768
+ ),
698
769
  model: modelId,
699
770
  },
700
771
  );
701
- markFirstResponse = watchdog.markResponse;
702
- cancelFirstResponseWatchdog = watchdog.cancel;
772
+ armModelProgress = watchdog.armTurn;
773
+ markModelProgress = watchdog.markProgress;
774
+ completeModelTurn = watchdog.completeTurn;
775
+ cancelModelProgressWatchdog = watchdog.cancel;
703
776
  await Promise.race([
704
777
  watchdog.waitFor(
705
778
  childSession.prompt(buildWorkflowAgentPrompt(options.prompt)),
706
779
  ),
707
780
  abortRace,
781
+ projectionFailureRace,
708
782
  ]);
709
783
  }
710
784
  } catch (error) {
711
- promptErrorMessage = errorText(error);
785
+ promptErrorMessage ??= errorText(error);
712
786
  } finally {
713
- cancelFirstResponseWatchdog();
787
+ cancelModelProgressWatchdog();
714
788
  options.signal?.removeEventListener("abort", onAbort);
715
789
  settled = true;
716
790
  unsubscribe();
717
791
  unsubscribeToolGuards?.();
718
- sync();
719
- output = truncateUtf8(
720
- finalOutput(childSession.messages),
721
- AGENT_OUTPUT_MAX_BYTES,
722
- );
723
- transcript = transcriptFromMessages(childSession.messages, toolTimings);
792
+ try {
793
+ const finalProjection = reconcileProjection();
794
+ output = truncateUtf8(finalProjection.preview, AGENT_OUTPUT_MAX_BYTES);
795
+ transcript = bindWorkflowToolRenderer(
796
+ finalProjection.transcript,
797
+ toolRenderer,
798
+ );
799
+ } catch (error) {
800
+ promptErrorMessage ??= `Failed to reconcile final agent progress: ${errorText(error)}`;
801
+ // A broken canonical accessor must not prevent owned-session cleanup.
802
+ // Preserve the last projection that was fully observed instead.
803
+ try {
804
+ output = truncateUtf8(lastProjection.preview, AGENT_OUTPUT_MAX_BYTES);
805
+ transcript = bindWorkflowToolRenderer(
806
+ lastProjection.transcript,
807
+ toolRenderer,
808
+ );
809
+ } catch (fallbackError) {
810
+ promptErrorMessage += `; failed to render last progress: ${errorText(fallbackError)}`;
811
+ }
812
+ }
724
813
  const cleanup = await shutdownAndDisposeChildSession(childSession, {
725
814
  abort: aborted || promptErrorMessage !== undefined,
726
815
  abortOperation,
@@ -734,7 +823,11 @@ export async function runAgent(
734
823
  ? `Cleanup failed: ${cleanupErrors.join("; ")}`
735
824
  : undefined;
736
825
 
737
- if (aborted || assistantSettlement?.stopReason === "aborted") {
826
+ if (
827
+ terminalCause === "abort" ||
828
+ (terminalCause === undefined &&
829
+ assistantSettlement?.stopReason === "aborted")
830
+ ) {
738
831
  return {
739
832
  ok: false,
740
833
  output,
@@ -751,7 +844,9 @@ export async function runAgent(
751
844
  }
752
845
 
753
846
  const failureMessage =
754
- agentFailureMessage(assistantSettlement, promptErrorMessage) ??
847
+ (terminalCause === "model-progress-timeout"
848
+ ? modelProgressTimeoutMessage
849
+ : agentFailureMessage(assistantSettlement, promptErrorMessage)) ??
755
850
  cleanupError;
756
851
  if (failureMessage !== undefined) {
757
852
  return {
@@ -781,6 +876,20 @@ export async function runAgent(
781
876
  };
782
877
  }
783
878
 
879
+ if (options.schema === undefined && assistantSettlement === undefined) {
880
+ return {
881
+ ok: false,
882
+ output,
883
+ structured,
884
+ error: "Agent finished without an assistant response.",
885
+ aborted: false,
886
+ usage,
887
+ model: modelId,
888
+ contextWindow,
889
+ transcript,
890
+ };
891
+ }
892
+
784
893
  return {
785
894
  ok: true,
786
895
  output,