@tt-a1i/openpi 0.3.1 → 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 (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. package/extensions/subagents/src/format.ts +0 -48
@@ -24,7 +24,11 @@ import {
24
24
  import type { Cause, Scope } from "effect";
25
25
  import { Effect, Queue, Stream } from "effect";
26
26
  import { resolveAgentModel } from "../agent-types.ts";
27
- import type { SubagentBackend, SubagentSession } from "../backend.ts";
27
+ import type {
28
+ SubagentBackend,
29
+ SubagentCleanupReceipt,
30
+ SubagentSession,
31
+ } from "../backend.ts";
28
32
  import type {
29
33
  SpawnTask,
30
34
  SubagentEvent,
@@ -40,9 +44,14 @@ import {
40
44
  createChildResources,
41
45
  shutdownAndDisposeChildSession,
42
46
  } from "../../../shared/child-session.ts";
43
- import { reclaimWorktree } from "../../../shared/worktree.ts";
47
+ import {
48
+ formatWorktreeCleanupWarning,
49
+ reclaimWorktree,
50
+ } from "../../../shared/worktree.ts";
51
+ import { AgentToolRenderLedger } from "../../../shared/agent-tool-renderer.ts";
44
52
 
45
53
  const DIRECT_WORKTREE_CLEANUP_TIMEOUT_MS = 4_000;
54
+ const PARTIAL_TEXT_MAX_LENGTH = 128 * 1_024;
46
55
 
47
56
  // --- Model + effort resolution -----------------------------------------------
48
57
 
@@ -50,6 +59,19 @@ type ThinkingLevel = NonNullable<
50
59
  NonNullable<Parameters<typeof createAgentSession>[0]>["thinkingLevel"]
51
60
  >;
52
61
 
62
+ export type PiAgentSessionFactory = (
63
+ options: Parameters<typeof createAgentSession>[0],
64
+ ) => Promise<{ session: AgentSession }>;
65
+
66
+ export interface PiBackendOptions {
67
+ /** Test seam for production-adapter lifecycle coverage. */
68
+ readonly sessionFactory?: PiAgentSessionFactory;
69
+ /** Test-only override for bounded child shutdown. */
70
+ readonly shutdownTimeoutMs?: number;
71
+ /** Test seam for observing whether unsafe worktree reclamation is attempted. */
72
+ readonly worktreeCleanup?: typeof reclaimWorktree;
73
+ }
74
+
53
75
  // --- Event translation ----------------------------------------------------------
54
76
 
55
77
  function messageRole(msg: unknown): Message["role"] | undefined {
@@ -70,20 +92,12 @@ function lastAssistantMessage(
70
92
  return undefined;
71
93
  }
72
94
 
73
- /** Final assistant text output (last assistant message with text), v1 semantics. */
74
- function finalOutput(session: AgentSession): string {
75
- const messages = session.messages;
76
- for (let i = messages.length - 1; i >= 0; i--) {
77
- const msg = messages[i];
78
- if (messageRole(msg) !== "assistant") continue;
79
- const text = (msg as AssistantMessage).content
80
- .filter((part) => part.type === "text")
81
- .map((part) => part.text)
82
- .join("\n")
83
- .trim();
84
- if (text) return text;
85
- }
86
- return "";
95
+ function assistantText(message: AssistantMessage) {
96
+ return message.content
97
+ .filter((part) => part.type === "text")
98
+ .map((part) => part.text)
99
+ .join("\n")
100
+ .trim();
87
101
  }
88
102
 
89
103
  function safeJson(value: unknown): string | undefined {
@@ -165,6 +179,7 @@ function boundedError(error: unknown) {
165
179
 
166
180
  const makePiSession = (
167
181
  task: SpawnTask,
182
+ options: PiBackendOptions,
168
183
  ): Effect.Effect<SubagentSession, SpawnError, Scope.Scope> =>
169
184
  Effect.gen(function* () {
170
185
  const registry = task.parent.modelRegistry;
@@ -192,7 +207,9 @@ const makePiSession = (
192
207
  ? { appendSystemPrompt: [...task.appendSystemPrompt] }
193
208
  : {}),
194
209
  });
195
- const { session } = await createAgentSession({
210
+ const { session } = await (
211
+ options.sessionFactory ?? createAgentSession
212
+ )({
196
213
  cwd: task.cwd,
197
214
  sessionManager: SessionManager.create(task.cwd),
198
215
  settingsManager,
@@ -207,7 +224,9 @@ const makePiSession = (
207
224
  try {
208
225
  await bindChildSessionExtensions(session, task.tools);
209
226
  } catch (error) {
210
- await shutdownAndDisposeChildSession(session);
227
+ await shutdownAndDisposeChildSession(session, {
228
+ timeoutMs: options.shutdownTimeoutMs,
229
+ });
211
230
  throw error;
212
231
  }
213
232
  return session;
@@ -215,14 +234,35 @@ const makePiSession = (
215
234
  catch: (error) => new SpawnError({ message: boundedError(error) }),
216
235
  });
217
236
 
237
+ interface ActivePrompt {
238
+ cancelled: boolean;
239
+ lifecycleStarted: boolean;
240
+ lifecycleSettled: boolean;
241
+ promptSettled: boolean;
242
+ promptError?: string;
243
+ lastAssistant?: AssistantMessage;
244
+ finalText: string;
245
+ liveText: string;
246
+ partialTextAtCancel?: string;
247
+ promise: Promise<void>;
248
+ }
249
+
250
+ interface PendingRestart {
251
+ cancelled: boolean;
252
+ terminalEmitted: boolean;
253
+ }
254
+
218
255
  const state = {
219
256
  closed: false,
220
- /** prompt() rejection for the active run; folded into RunSettled. */
221
- runError: undefined as string | undefined,
222
257
  /** One terminal event per run: lifecycle, prompt-rejection, and abort
223
258
  * fallbacks can all race to settle; the first wins. */
224
259
  settled: false,
260
+ /** Pi events have no run identity, so the exact prompt Promise is the
261
+ * only safe lease for deciding when another run may start. */
262
+ activePrompt: undefined as ActivePrompt | undefined,
263
+ pendingRestart: undefined as PendingRestart | undefined,
225
264
  };
265
+ let cleanupReceipt: SubagentCleanupReceipt | undefined;
226
266
 
227
267
  const events = yield* Queue.make<SubagentEvent, Cause.Done>();
228
268
  const emit = (event: SubagentEvent) => {
@@ -231,6 +271,7 @@ const makePiSession = (
231
271
 
232
272
  const toolTimeout = createToolCallTimeoutGuard();
233
273
  toolTimeout.apply(session);
274
+ const toolRenderer = new AgentToolRenderLedger();
234
275
 
235
276
  const activeModel = (): Model<any> | undefined => {
236
277
  const sessionModel = session.model;
@@ -269,20 +310,13 @@ const makePiSession = (
269
310
  });
270
311
  };
271
312
 
272
- const settle = () => {
273
- if (state.settled) return;
313
+ const settle = (prompt = state.activePrompt) => {
314
+ if (!prompt || state.activePrompt !== prompt || state.settled) return;
274
315
  state.settled = true;
275
- const last = lastAssistantMessage(session);
276
- const partialText = finalOutput(session) || undefined;
277
- if (last?.stopReason === "aborted") {
278
- emit({
279
- _tag: "RunSettled",
280
- outcome: { _tag: "Interrupted", partialText },
281
- });
282
- return;
283
- }
316
+ const last = prompt.lastAssistant;
317
+ const partialText = prompt.liveText || prompt.finalText || undefined;
284
318
  const errorText =
285
- state.runError ??
319
+ prompt.promptError ??
286
320
  (last?.stopReason === "error"
287
321
  ? (last.errorMessage ?? "Run failed")
288
322
  : undefined);
@@ -297,24 +331,86 @@ const makePiSession = (
297
331
  });
298
332
  return;
299
333
  }
334
+ if (last?.stopReason === "aborted") {
335
+ emit({
336
+ _tag: "RunSettled",
337
+ outcome: { _tag: "Interrupted", partialText },
338
+ });
339
+ return;
340
+ }
300
341
  emit({
301
342
  _tag: "RunSettled",
302
- outcome: { _tag: "Completed", finalText: finalOutput(session) },
343
+ outcome: {
344
+ _tag: "Completed",
345
+ finalText: prompt.finalText,
346
+ },
303
347
  });
304
348
  };
305
349
 
350
+ /**
351
+ * A Pi lifecycle event is evidence, not the lease itself. The exact
352
+ * prompt() Promise must also settle before a terminal outcome is trusted;
353
+ * otherwise agent_settled can publish Completed and hide a later reject.
354
+ */
355
+ const maybeSettle = (prompt: ActivePrompt) => {
356
+ if (
357
+ state.closed ||
358
+ state.activePrompt !== prompt ||
359
+ prompt.cancelled ||
360
+ state.settled ||
361
+ !prompt.promptSettled
362
+ ) {
363
+ return;
364
+ }
365
+ if (prompt.promptError !== undefined) {
366
+ settle(prompt);
367
+ } else if (!prompt.lifecycleStarted) {
368
+ prompt.promptError =
369
+ "Pi prompt completed without lifecycle events; no agent response was observed.";
370
+ settle(prompt);
371
+ } else if (!prompt.lifecycleSettled) {
372
+ prompt.promptError =
373
+ "Pi prompt completed without an agent_settled lifecycle event.";
374
+ settle(prompt);
375
+ } else {
376
+ settle(prompt);
377
+ }
378
+ if (state.activePrompt === prompt && prompt.promptSettled) {
379
+ state.activePrompt = undefined;
380
+ }
381
+ };
382
+
306
383
  const handleEvent = (event: AgentSessionEvent) => {
307
384
  if (state.closed) return;
385
+ const activePrompt = state.activePrompt;
386
+ if (!activePrompt) return;
387
+ if (activePrompt.cancelled) {
388
+ if (event.type === "agent_start") {
389
+ // abort() can return during Pi's preflight while prompt() remains
390
+ // pending. If that preflight later enters the agent lifecycle,
391
+ // abort again before it can reach a provider or tool. Never reopen
392
+ // the event gate: native events carry no generation identity.
393
+ try {
394
+ void session.abort().catch(() => undefined);
395
+ } catch {
396
+ // The original interrupt still owns bounded cleanup.
397
+ }
398
+ }
399
+ return;
400
+ }
308
401
  switch (event.type) {
309
402
  case "agent_start":
310
403
  // Extensions may register tools between runs; guard new ones too.
311
404
  toolTimeout.apply(session);
312
- state.settled = false;
405
+ activePrompt.lifecycleStarted = true;
313
406
  emit({ _tag: "RunStarted" });
314
407
  break;
315
408
  case "message_update": {
316
409
  const streamEvent = event.assistantMessageEvent;
317
410
  if (streamEvent.type === "text_delta") {
411
+ activePrompt.liveText = (
412
+ activePrompt.liveText + streamEvent.delta
413
+ ).slice(-PARTIAL_TEXT_MAX_LENGTH);
318
414
  emit({
319
415
  _tag: "AssistantDelta",
320
416
  kind: "text",
@@ -335,9 +431,14 @@ const makePiSession = (
335
431
  const text = userText(event.message as Message);
336
432
  if (text.trim()) emit({ _tag: "UserMessage", text });
337
433
  } else if (role === "assistant") {
434
+ const assistant = event.message as AssistantMessage;
435
+ activePrompt.lastAssistant = assistant;
436
+ const text = assistantText(assistant);
437
+ if (text) activePrompt.finalText = text;
438
+ activePrompt.liveText = "";
338
439
  emit({
339
440
  _tag: "AssistantMessage",
340
- parts: assistantParts(event.message as AssistantMessage),
441
+ parts: assistantParts(assistant),
341
442
  });
342
443
  emitUsage();
343
444
  emit({ _tag: "MetaChanged", meta: currentMeta() });
@@ -346,6 +447,12 @@ const makePiSession = (
346
447
  break;
347
448
  }
348
449
  case "tool_execution_start":
450
+ toolRenderer.start(
451
+ event.toolCallId,
452
+ event.toolName,
453
+ event.args,
454
+ session.getToolDefinition(event.toolName),
455
+ );
349
456
  emit({
350
457
  _tag: "ToolStart",
351
458
  toolId: event.toolCallId,
@@ -354,6 +461,12 @@ const makePiSession = (
354
461
  });
355
462
  break;
356
463
  case "tool_execution_update":
464
+ toolRenderer.update(
465
+ event.toolCallId,
466
+ event.toolName,
467
+ event.args,
468
+ event.partialResult,
469
+ );
357
470
  emit({
358
471
  _tag: "ToolUpdate",
359
472
  toolId: event.toolCallId,
@@ -361,6 +474,12 @@ const makePiSession = (
361
474
  });
362
475
  break;
363
476
  case "tool_execution_end":
477
+ toolRenderer.end(
478
+ event.toolCallId,
479
+ event.toolName,
480
+ event.result,
481
+ event.isError,
482
+ );
364
483
  emit({
365
484
  _tag: "ToolEnd",
366
485
  toolId: event.toolCallId,
@@ -385,7 +504,8 @@ const makePiSession = (
385
504
  });
386
505
  break;
387
506
  case "agent_settled":
388
- settle();
507
+ activePrompt.lifecycleSettled = true;
508
+ maybeSettle(activePrompt);
389
509
  break;
390
510
  }
391
511
  };
@@ -400,17 +520,61 @@ const makePiSession = (
400
520
  } catch {
401
521
  // Continue with abort/dispose.
402
522
  }
403
- await shutdownAndDisposeChildSession(session, {
523
+ const shutdown = await shutdownAndDisposeChildSession(session, {
404
524
  abort: true,
405
- timeoutMs: CHILD_SHUTDOWN_TIMEOUT_MS,
525
+ timeoutMs: options.shutdownTimeoutMs ?? CHILD_SHUTDOWN_TIMEOUT_MS,
406
526
  });
407
- // A direct child can run multiple turns, so its worktree lives until
408
- // this session scope closes. Cleanup is fail-closed and shares a hard
409
- // deadline; unknown/dirty/ignored/detached work is preserved.
410
- if (task.worktree) {
411
- await reclaimWorktree(task.worktree.repoCwd, task.worktree, {
412
- timeoutMs: DIRECT_WORKTREE_CLEANUP_TIMEOUT_MS,
413
- }).catch(() => {});
527
+ const promptQuiescent =
528
+ state.activePrompt === undefined || state.activePrompt.promptSettled;
529
+ const cleanupMessages = [...shutdown.errors];
530
+ let cleanupUncertain =
531
+ shutdown.errors.length > 0 || shutdown.timedOut || !promptQuiescent;
532
+ if (shutdown.timedOut)
533
+ cleanupMessages.push("cleanup deadline exceeded");
534
+ if (!promptQuiescent) {
535
+ cleanupMessages.push(
536
+ "active prompt did not quiesce before session shutdown",
537
+ );
538
+ }
539
+
540
+ // A preflight hook can outlive abort() and continue touching the child
541
+ // cwd. Never reclaim a worktree until both the prompt and session
542
+ // cleanup have reached a known-safe terminal state.
543
+ if (
544
+ task.worktree &&
545
+ promptQuiescent &&
546
+ shutdown.ok &&
547
+ !shutdown.timedOut
548
+ ) {
549
+ try {
550
+ const cleanup = await (options.worktreeCleanup ?? reclaimWorktree)(
551
+ task.worktree.repoCwd,
552
+ task.worktree,
553
+ {
554
+ timeoutMs: DIRECT_WORKTREE_CLEANUP_TIMEOUT_MS,
555
+ },
556
+ );
557
+ const warning = formatWorktreeCleanupWarning(
558
+ cleanup,
559
+ task.worktree.path,
560
+ );
561
+ if (warning) cleanupMessages.push(`worktree cleanup: ${warning}`);
562
+ } catch (error) {
563
+ cleanupUncertain = true;
564
+ cleanupMessages.push(
565
+ `worktree cleanup failed: ${boundedError(error)}; checkout preserved at ${task.worktree.path}`,
566
+ );
567
+ }
568
+ } else if (task.worktree) {
569
+ cleanupMessages.push(
570
+ `worktree cleanup skipped; checkout preserved at ${task.worktree.path}`,
571
+ );
572
+ }
573
+ if (cleanupMessages.length > 0) {
574
+ cleanupReceipt = {
575
+ uncertain: cleanupUncertain,
576
+ message: `${cleanupUncertain ? "Subagent cleanup is uncertain" : "Subagent cleanup"}: ${cleanupMessages.join("; ")}`,
577
+ };
414
578
  }
415
579
  Queue.endUnsafe(events);
416
580
  }),
@@ -418,15 +582,63 @@ const makePiSession = (
418
582
 
419
583
  /** Start a fresh run (v1 manager.run): fire-and-forget, errors -> events. */
420
584
  const startRun = (text: string) => {
421
- state.runError = undefined;
585
+ if (state.activePrompt) {
586
+ throw new Error(
587
+ "Cannot start a Pi prompt while another prompt is active.",
588
+ );
589
+ }
590
+ const activePrompt: ActivePrompt = {
591
+ cancelled: false,
592
+ lifecycleStarted: false,
593
+ lifecycleSettled: false,
594
+ promptSettled: false,
595
+ finalText: "",
596
+ liveText: "",
597
+ promise: Promise.resolve(),
598
+ };
599
+ state.activePrompt = activePrompt;
422
600
  state.settled = false;
423
601
  emit({ _tag: "RunStarted" });
424
- void session.prompt(text).catch((error) => {
425
- state.runError = boundedError(error);
426
- // Preflight failures may never start the agent lifecycle, so no
427
- // agent_settled will arrive for them.
428
- if (!session.isStreaming) settle();
429
- });
602
+ let prompt: Promise<void>;
603
+ try {
604
+ prompt = session.prompt(text, {
605
+ preflightResult: (accepted) => {
606
+ if (accepted && (activePrompt.cancelled || state.closed)) {
607
+ // This callback runs immediately before Pi enters
608
+ // _runAgentPrompt. Throwing here is the only synchronous seam
609
+ // that prevents a cancelled preflight from reviving after the
610
+ // adapter subscription and worktree ownership are gone.
611
+ throw new Error(
612
+ "Subagent prompt was cancelled during preflight.",
613
+ );
614
+ }
615
+ },
616
+ });
617
+ } catch (error) {
618
+ prompt = Promise.reject(error);
619
+ }
620
+ activePrompt.promise = prompt
621
+ .then(
622
+ () => undefined,
623
+ (error) => {
624
+ if (
625
+ !state.closed &&
626
+ state.activePrompt === activePrompt &&
627
+ !activePrompt.cancelled
628
+ ) {
629
+ activePrompt.promptError = boundedError(error);
630
+ }
631
+ },
632
+ )
633
+ .finally(() => {
634
+ activePrompt.promptSettled = true;
635
+ maybeSettle(activePrompt);
636
+ if (state.activePrompt === activePrompt) {
637
+ if (activePrompt.cancelled || state.settled) {
638
+ state.activePrompt = undefined;
639
+ }
640
+ }
641
+ });
430
642
  };
431
643
 
432
644
  // Session naming is best-effort.
@@ -444,6 +656,8 @@ const makePiSession = (
444
656
  startRun(task.prompt);
445
657
 
446
658
  return {
659
+ toolRenderer,
660
+ cleanupReceipt: () => cleanupReceipt,
447
661
  meta: Effect.sync(currentMeta),
448
662
  events: Stream.fromQueue(events),
449
663
  send: (text) =>
@@ -451,7 +665,21 @@ const makePiSession = (
451
665
  if (state.closed) {
452
666
  return new SendError({ message: "Subagent session is closed." });
453
667
  }
454
- if (session.isStreaming) {
668
+ const pendingRestart = state.pendingRestart;
669
+ if (pendingRestart) {
670
+ return new SendError({
671
+ message: pendingRestart.cancelled
672
+ ? "Subagent cancellation is still in progress."
673
+ : "A subagent restart is already pending.",
674
+ });
675
+ }
676
+ const activePrompt = state.activePrompt;
677
+ if (activePrompt?.cancelled) {
678
+ return new SendError({
679
+ message: "Subagent cancellation is still in progress.",
680
+ });
681
+ }
682
+ if (activePrompt && session.isStreaming) {
455
683
  // Steer the active run via the SDK's queue; queue_update events
456
684
  // render it, message_end(user) lands it in the transcript. A
457
685
  // rejected steer is a real send failure, not a diagnostic.
@@ -460,34 +688,115 @@ const makePiSession = (
460
688
  catch: (error) => new SendError({ message: boundedError(error) }),
461
689
  }).pipe(Effect.asVoid);
462
690
  }
691
+ if (
692
+ activePrompt &&
693
+ !activePrompt.lifecycleSettled &&
694
+ !activePrompt.promptSettled
695
+ ) {
696
+ return new SendError({
697
+ message:
698
+ "Subagent prompt is still starting; wait for it to run or settle before sending.",
699
+ });
700
+ }
701
+ if (activePrompt) {
702
+ // Pi emits agent_settled before prompt() resolves. Preserve the
703
+ // same-session restart contract while closing that microtask gap
704
+ // without ever overlapping two native prompts.
705
+ const restart: PendingRestart = {
706
+ cancelled: false,
707
+ terminalEmitted: false,
708
+ };
709
+ state.pendingRestart = restart;
710
+ return Effect.tryPromise({
711
+ try: async (signal) => {
712
+ const cancelPendingRestart = () => {
713
+ restart.cancelled = true;
714
+ if (state.pendingRestart === restart) {
715
+ state.pendingRestart = undefined;
716
+ }
717
+ };
718
+ signal.addEventListener("abort", cancelPendingRestart, {
719
+ once: true,
720
+ });
721
+ if (signal.aborted) cancelPendingRestart();
722
+ try {
723
+ await activePrompt.promise;
724
+ if (state.closed) {
725
+ throw new Error("Subagent session is closed.");
726
+ }
727
+ if (restart.cancelled) {
728
+ // interrupt emits the terminal event for this accepted
729
+ // restart. Resolve the send without starting a prompt so
730
+ // the manager keeps its restarting marker until that
731
+ // terminal event is folded.
732
+ return;
733
+ }
734
+ state.pendingRestart = undefined;
735
+ startRun(text);
736
+ } finally {
737
+ signal.removeEventListener("abort", cancelPendingRestart);
738
+ if (state.pendingRestart === restart) {
739
+ state.pendingRestart = undefined;
740
+ }
741
+ }
742
+ },
743
+ catch: (error) => new SendError({ message: boundedError(error) }),
744
+ }).pipe(Effect.asVoid);
745
+ }
463
746
  return Effect.sync(() => startRun(text));
464
747
  }),
465
748
  interrupt: Effect.promise(async () => {
466
749
  if (state.closed) return;
750
+ const pendingRestart = state.pendingRestart;
751
+ if (pendingRestart) pendingRestart.cancelled = true;
752
+ const activePrompt = state.activePrompt;
753
+ if (activePrompt) {
754
+ activePrompt.cancelled = true;
755
+ activePrompt.partialTextAtCancel = pendingRestart
756
+ ? undefined
757
+ : activePrompt.liveText || activePrompt.finalText || undefined;
758
+ }
467
759
  try {
468
760
  session.clearQueue();
469
761
  } catch {
470
762
  // Abort regardless.
471
763
  }
472
764
  await session.abort().catch(() => undefined);
473
- // Only resolve once streaming has actually stopped: reporting the
474
- // interrupt as complete while the run keeps working would let the
475
- // manager settle a run that is still mutating the workspace. The
476
- // manager bounds this effect at 5s and force-disposes on timeout.
477
- while (!state.closed && session.isStreaming) {
478
- await new Promise((resolve) => setTimeout(resolve, 50));
765
+ // isStreaming is false during Pi preflight, so it cannot prove the
766
+ // invocation is quiescent. Wait for the exact prompt Promise instead;
767
+ // the manager bounds this effect at 5s and force-disposes on timeout.
768
+ await activePrompt?.promise;
769
+ if (
770
+ !state.closed &&
771
+ pendingRestart &&
772
+ !pendingRestart.terminalEmitted
773
+ ) {
774
+ pendingRestart.terminalEmitted = true;
775
+ if (state.pendingRestart === pendingRestart) {
776
+ state.pendingRestart = undefined;
777
+ }
778
+ state.settled = true;
779
+ emit({ _tag: "RunSettled", outcome: { _tag: "Interrupted" } });
780
+ return;
479
781
  }
480
- // No streaming run means no agent_settled will arrive; emit the
481
- // terminal event (once) so the run cannot look running forever.
482
782
  if (!state.closed && !state.settled) {
483
783
  state.settled = true;
484
- emit({ _tag: "RunSettled", outcome: { _tag: "Interrupted" } });
784
+ emit({
785
+ _tag: "RunSettled",
786
+ outcome: {
787
+ _tag: "Interrupted",
788
+ partialText: activePrompt?.partialTextAtCancel,
789
+ },
790
+ });
485
791
  }
486
792
  }),
487
793
  } satisfies SubagentSession;
488
794
  });
489
795
 
490
- export const piBackend: SubagentBackend = {
491
- name: "pi",
492
- spawn: makePiSession,
493
- };
796
+ export const makePiBackend = (options: PiBackendOptions = {}) =>
797
+ ({
798
+ name: "pi",
799
+ spawn: (task) => makePiSession(task, options),
800
+ }) satisfies SubagentBackend;
801
+
802
+ export const piBackend = makePiBackend();
@@ -31,6 +31,7 @@ export const REASONING_EFFORTS = [
31
31
  export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
32
32
 
33
33
  export type SubagentStatus = "running" | "done" | "error";
34
+ export type SubagentOutcome = "completed" | "failed" | "interrupted";
34
35
 
35
36
  /** Parent-session context resolved by the tool layer and passed opaquely. */
36
37
  export interface ParentContext {
@@ -215,12 +216,16 @@ export interface SubagentSnapshot {
215
216
  readonly prompt: string;
216
217
  readonly cwd: string;
217
218
  readonly status: SubagentStatus;
219
+ readonly outcome?: SubagentOutcome;
220
+ readonly worktreeBranch?: string;
218
221
  readonly createdAt: number;
219
222
  readonly settledAt?: number;
220
223
  readonly errorText?: string;
221
224
  readonly meta: SubagentMeta;
222
225
  readonly usage: { readonly tokens?: number; readonly contextWindow?: number };
223
226
  readonly transcript: ReadonlyArray<TranscriptItem>;
227
+ /** Monotonic version bumped on every transcript mutation (see manager). */
228
+ readonly transcriptVersion: number;
224
229
  /** Streaming assistant buffers, cleared when the finalized message lands. */
225
230
  readonly liveAssistant?: { readonly text: string; readonly thinking: string };
226
231
  readonly liveTools: ReadonlyArray<LiveToolState>;