@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
@@ -19,12 +19,17 @@ import type {
19
19
  } from "@earendil-works/pi-coding-agent";
20
20
  import {
21
21
  createAgentSession,
22
+ getAgentDir,
22
23
  SessionManager,
23
24
  } from "@earendil-works/pi-coding-agent";
24
25
  import type { Cause, Scope } from "effect";
25
26
  import { Effect, Queue, Stream } from "effect";
26
27
  import { resolveAgentModel } from "../agent-types.ts";
27
- import type { SubagentBackend, SubagentSession } from "../backend.ts";
28
+ import type {
29
+ SubagentBackend,
30
+ SubagentCleanupReceipt,
31
+ SubagentSession,
32
+ } from "../backend.ts";
28
33
  import type {
29
34
  SpawnTask,
30
35
  SubagentEvent,
@@ -40,9 +45,22 @@ import {
40
45
  createChildResources,
41
46
  shutdownAndDisposeChildSession,
42
47
  } from "../../../shared/child-session.ts";
43
- import { reclaimWorktree } from "../../../shared/worktree.ts";
48
+ import {
49
+ formatWorktreeCleanupWarning,
50
+ reclaimWorktree,
51
+ } from "../../../shared/worktree.ts";
52
+ import { AgentToolRenderLedger } from "../../../shared/agent-tool-renderer.ts";
53
+ import {
54
+ childToolsWithStructuredOutput,
55
+ createStructuredOutputTool,
56
+ encodeStructuredResult,
57
+ type EncodedStructuredResult,
58
+ STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION,
59
+ } from "../../../shared/structured-output.ts";
60
+ import { persistStructuredResultArtifact } from "../result-artifact.ts";
44
61
 
45
62
  const DIRECT_WORKTREE_CLEANUP_TIMEOUT_MS = 4_000;
63
+ const PARTIAL_TEXT_MAX_LENGTH = 128 * 1_024;
46
64
 
47
65
  // --- Model + effort resolution -----------------------------------------------
48
66
 
@@ -50,6 +68,19 @@ type ThinkingLevel = NonNullable<
50
68
  NonNullable<Parameters<typeof createAgentSession>[0]>["thinkingLevel"]
51
69
  >;
52
70
 
71
+ export type PiAgentSessionFactory = (
72
+ options: Parameters<typeof createAgentSession>[0],
73
+ ) => Promise<{ session: AgentSession }>;
74
+
75
+ export interface PiBackendOptions {
76
+ /** Test seam for production-adapter lifecycle coverage. */
77
+ readonly sessionFactory?: PiAgentSessionFactory;
78
+ /** Test-only override for bounded child shutdown. */
79
+ readonly shutdownTimeoutMs?: number;
80
+ /** Test seam for observing whether unsafe worktree reclamation is attempted. */
81
+ readonly worktreeCleanup?: typeof reclaimWorktree;
82
+ }
83
+
53
84
  // --- Event translation ----------------------------------------------------------
54
85
 
55
86
  function messageRole(msg: unknown): Message["role"] | undefined {
@@ -70,20 +101,12 @@ function lastAssistantMessage(
70
101
  return undefined;
71
102
  }
72
103
 
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 "";
104
+ function assistantText(message: AssistantMessage) {
105
+ return message.content
106
+ .filter((part) => part.type === "text")
107
+ .map((part) => part.text)
108
+ .join("\n")
109
+ .trim();
87
110
  }
88
111
 
89
112
  function safeJson(value: unknown): string | undefined {
@@ -165,6 +188,7 @@ function boundedError(error: unknown) {
165
188
 
166
189
  const makePiSession = (
167
190
  task: SpawnTask,
191
+ options: PiBackendOptions,
168
192
  ): Effect.Effect<SubagentSession, SpawnError, Scope.Scope> =>
169
193
  Effect.gen(function* () {
170
194
  const registry = task.parent.modelRegistry;
@@ -183,31 +207,61 @@ const makePiSession = (
183
207
  const thinkingLevel = (task.reasoningEffort ??
184
208
  task.parent.inheritedThinkingLevel) as ThinkingLevel | undefined;
185
209
 
210
+ let capturedStructured: EncodedStructuredResult | undefined;
211
+ const structuredOutputTool =
212
+ task.outputSchema === undefined
213
+ ? undefined
214
+ : createStructuredOutputTool(task.outputSchema, (value) => {
215
+ capturedStructured = encodeStructuredResult(value);
216
+ });
217
+
186
218
  const session = yield* Effect.tryPromise({
187
219
  try: async () => {
220
+ const appendSystemPrompt = [
221
+ ...(task.appendSystemPrompt ?? []),
222
+ ...(structuredOutputTool
223
+ ? [STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION]
224
+ : []),
225
+ ];
188
226
  const { loader, settingsManager } = await createChildResources({
189
227
  cwd: task.cwd,
190
228
  projectTrusted: task.parent.projectTrusted,
191
- ...(task.appendSystemPrompt
192
- ? { appendSystemPrompt: [...task.appendSystemPrompt] }
193
- : {}),
229
+ ...(appendSystemPrompt.length > 0 ? { appendSystemPrompt } : {}),
194
230
  });
195
- const { session } = await createAgentSession({
231
+ const { session } = await (
232
+ options.sessionFactory ?? createAgentSession
233
+ )({
196
234
  cwd: task.cwd,
197
235
  sessionManager: SessionManager.create(task.cwd),
198
236
  settingsManager,
199
237
  resourceLoader: loader,
200
238
  model,
201
239
  thinkingLevel,
202
- ...childToolPolicy(task.tools),
240
+ ...(structuredOutputTool
241
+ ? { customTools: [structuredOutputTool] }
242
+ : {}),
243
+ ...childToolPolicy(
244
+ childToolsWithStructuredOutput(
245
+ task.tools,
246
+ structuredOutputTool !== undefined,
247
+ ),
248
+ ),
203
249
  });
204
250
  // Start child extension session hooks/resources in headless mode.
205
251
  // A rejection here would otherwise leak the freshly created session:
206
252
  // the scope finalizer that owns cleanup is only registered later.
207
253
  try {
208
- await bindChildSessionExtensions(session, task.tools);
254
+ await bindChildSessionExtensions(
255
+ session,
256
+ childToolsWithStructuredOutput(
257
+ task.tools,
258
+ structuredOutputTool !== undefined,
259
+ ),
260
+ );
209
261
  } catch (error) {
210
- await shutdownAndDisposeChildSession(session);
262
+ await shutdownAndDisposeChildSession(session, {
263
+ timeoutMs: options.shutdownTimeoutMs,
264
+ });
211
265
  throw error;
212
266
  }
213
267
  return session;
@@ -215,14 +269,35 @@ const makePiSession = (
215
269
  catch: (error) => new SpawnError({ message: boundedError(error) }),
216
270
  });
217
271
 
272
+ interface ActivePrompt {
273
+ cancelled: boolean;
274
+ lifecycleStarted: boolean;
275
+ lifecycleSettled: boolean;
276
+ promptSettled: boolean;
277
+ promptError?: string;
278
+ lastAssistant?: AssistantMessage;
279
+ finalText: string;
280
+ liveText: string;
281
+ partialTextAtCancel?: string;
282
+ promise: Promise<void>;
283
+ }
284
+
285
+ interface PendingRestart {
286
+ cancelled: boolean;
287
+ terminalEmitted: boolean;
288
+ }
289
+
218
290
  const state = {
219
291
  closed: false,
220
- /** prompt() rejection for the active run; folded into RunSettled. */
221
- runError: undefined as string | undefined,
222
292
  /** One terminal event per run: lifecycle, prompt-rejection, and abort
223
293
  * fallbacks can all race to settle; the first wins. */
224
294
  settled: false,
295
+ /** Pi events have no run identity, so the exact prompt Promise is the
296
+ * only safe lease for deciding when another run may start. */
297
+ activePrompt: undefined as ActivePrompt | undefined,
298
+ pendingRestart: undefined as PendingRestart | undefined,
225
299
  };
300
+ let cleanupReceipt: SubagentCleanupReceipt | undefined;
226
301
 
227
302
  const events = yield* Queue.make<SubagentEvent, Cause.Done>();
228
303
  const emit = (event: SubagentEvent) => {
@@ -231,6 +306,7 @@ const makePiSession = (
231
306
 
232
307
  const toolTimeout = createToolCallTimeoutGuard();
233
308
  toolTimeout.apply(session);
309
+ const toolRenderer = new AgentToolRenderLedger();
234
310
 
235
311
  const activeModel = (): Model<any> | undefined => {
236
312
  const sessionModel = session.model;
@@ -269,11 +345,27 @@ const makePiSession = (
269
345
  });
270
346
  };
271
347
 
272
- const settle = () => {
273
- if (state.settled) return;
348
+ const settle = (prompt = state.activePrompt) => {
349
+ if (!prompt || state.activePrompt !== prompt || state.settled) return;
274
350
  state.settled = true;
275
- const last = lastAssistantMessage(session);
276
- const partialText = finalOutput(session) || undefined;
351
+ const last = prompt.lastAssistant;
352
+ const partialText = prompt.liveText || prompt.finalText || undefined;
353
+ const errorText =
354
+ prompt.promptError ??
355
+ (last?.stopReason === "error"
356
+ ? (last.errorMessage ?? "Run failed")
357
+ : undefined);
358
+ if (errorText !== undefined) {
359
+ emit({
360
+ _tag: "RunSettled",
361
+ outcome: {
362
+ _tag: "Failed",
363
+ errorText: boundedError(errorText),
364
+ partialText,
365
+ },
366
+ });
367
+ return;
368
+ }
277
369
  if (last?.stopReason === "aborted") {
278
370
  emit({
279
371
  _tag: "RunSettled",
@@ -281,40 +373,114 @@ const makePiSession = (
281
373
  });
282
374
  return;
283
375
  }
284
- const errorText =
285
- state.runError ??
286
- (last?.stopReason === "error"
287
- ? (last.errorMessage ?? "Run failed")
288
- : undefined);
289
- if (errorText !== undefined) {
376
+ if (task.outputSchema !== undefined && capturedStructured === undefined) {
290
377
  emit({
291
378
  _tag: "RunSettled",
292
379
  outcome: {
293
380
  _tag: "Failed",
294
- errorText: boundedError(errorText),
381
+ errorText:
382
+ "Agent finished without calling structured_output; no structured result matching output_schema was produced.",
295
383
  partialText,
296
384
  },
297
385
  });
298
386
  return;
299
387
  }
388
+ let structuredResult;
389
+ if (capturedStructured) {
390
+ try {
391
+ structuredResult = {
392
+ ...capturedStructured,
393
+ artifactPath: persistStructuredResultArtifact(
394
+ getAgentDir(),
395
+ capturedStructured.json,
396
+ ),
397
+ };
398
+ } catch (error) {
399
+ emit({
400
+ _tag: "RunSettled",
401
+ outcome: {
402
+ _tag: "Failed",
403
+ errorText: `Structured result artifact could not be persisted: ${boundedError(error)}`,
404
+ partialText,
405
+ },
406
+ });
407
+ return;
408
+ }
409
+ }
300
410
  emit({
301
411
  _tag: "RunSettled",
302
- outcome: { _tag: "Completed", finalText: finalOutput(session) },
412
+ outcome: {
413
+ _tag: "Completed",
414
+ finalText: prompt.finalText,
415
+ ...(structuredResult ? { structuredResult } : {}),
416
+ },
303
417
  });
304
418
  };
305
419
 
420
+ /**
421
+ * A Pi lifecycle event is evidence, not the lease itself. The exact
422
+ * prompt() Promise must also settle before a terminal outcome is trusted;
423
+ * otherwise agent_settled can publish Completed and hide a later reject.
424
+ */
425
+ const maybeSettle = (prompt: ActivePrompt) => {
426
+ if (
427
+ state.closed ||
428
+ state.activePrompt !== prompt ||
429
+ prompt.cancelled ||
430
+ state.settled ||
431
+ !prompt.promptSettled
432
+ ) {
433
+ return;
434
+ }
435
+ if (prompt.promptError !== undefined) {
436
+ settle(prompt);
437
+ } else if (!prompt.lifecycleStarted) {
438
+ prompt.promptError =
439
+ "Pi prompt completed without lifecycle events; no agent response was observed.";
440
+ settle(prompt);
441
+ } else if (!prompt.lifecycleSettled) {
442
+ prompt.promptError =
443
+ "Pi prompt completed without an agent_settled lifecycle event.";
444
+ settle(prompt);
445
+ } else {
446
+ settle(prompt);
447
+ }
448
+ if (state.activePrompt === prompt && prompt.promptSettled) {
449
+ state.activePrompt = undefined;
450
+ }
451
+ };
452
+
306
453
  const handleEvent = (event: AgentSessionEvent) => {
307
454
  if (state.closed) return;
455
+ const activePrompt = state.activePrompt;
456
+ if (!activePrompt) return;
457
+ if (activePrompt.cancelled) {
458
+ if (event.type === "agent_start") {
459
+ // abort() can return during Pi's preflight while prompt() remains
460
+ // pending. If that preflight later enters the agent lifecycle,
461
+ // abort again before it can reach a provider or tool. Never reopen
462
+ // the event gate: native events carry no generation identity.
463
+ try {
464
+ void session.abort().catch(() => undefined);
465
+ } catch {
466
+ // The original interrupt still owns bounded cleanup.
467
+ }
468
+ }
469
+ return;
470
+ }
308
471
  switch (event.type) {
309
472
  case "agent_start":
310
473
  // Extensions may register tools between runs; guard new ones too.
311
474
  toolTimeout.apply(session);
312
- state.settled = false;
475
+ activePrompt.lifecycleStarted = true;
313
476
  emit({ _tag: "RunStarted" });
314
477
  break;
315
478
  case "message_update": {
316
479
  const streamEvent = event.assistantMessageEvent;
317
480
  if (streamEvent.type === "text_delta") {
481
+ activePrompt.liveText = (
482
+ activePrompt.liveText + streamEvent.delta
483
+ ).slice(-PARTIAL_TEXT_MAX_LENGTH);
318
484
  emit({
319
485
  _tag: "AssistantDelta",
320
486
  kind: "text",
@@ -335,9 +501,14 @@ const makePiSession = (
335
501
  const text = userText(event.message as Message);
336
502
  if (text.trim()) emit({ _tag: "UserMessage", text });
337
503
  } else if (role === "assistant") {
504
+ const assistant = event.message as AssistantMessage;
505
+ activePrompt.lastAssistant = assistant;
506
+ const text = assistantText(assistant);
507
+ if (text) activePrompt.finalText = text;
508
+ activePrompt.liveText = "";
338
509
  emit({
339
510
  _tag: "AssistantMessage",
340
- parts: assistantParts(event.message as AssistantMessage),
511
+ parts: assistantParts(assistant),
341
512
  });
342
513
  emitUsage();
343
514
  emit({ _tag: "MetaChanged", meta: currentMeta() });
@@ -346,6 +517,12 @@ const makePiSession = (
346
517
  break;
347
518
  }
348
519
  case "tool_execution_start":
520
+ toolRenderer.start(
521
+ event.toolCallId,
522
+ event.toolName,
523
+ event.args,
524
+ session.getToolDefinition(event.toolName),
525
+ );
349
526
  emit({
350
527
  _tag: "ToolStart",
351
528
  toolId: event.toolCallId,
@@ -354,6 +531,12 @@ const makePiSession = (
354
531
  });
355
532
  break;
356
533
  case "tool_execution_update":
534
+ toolRenderer.update(
535
+ event.toolCallId,
536
+ event.toolName,
537
+ event.args,
538
+ event.partialResult,
539
+ );
357
540
  emit({
358
541
  _tag: "ToolUpdate",
359
542
  toolId: event.toolCallId,
@@ -361,6 +544,12 @@ const makePiSession = (
361
544
  });
362
545
  break;
363
546
  case "tool_execution_end":
547
+ toolRenderer.end(
548
+ event.toolCallId,
549
+ event.toolName,
550
+ event.result,
551
+ event.isError,
552
+ );
364
553
  emit({
365
554
  _tag: "ToolEnd",
366
555
  toolId: event.toolCallId,
@@ -385,7 +574,8 @@ const makePiSession = (
385
574
  });
386
575
  break;
387
576
  case "agent_settled":
388
- settle();
577
+ activePrompt.lifecycleSettled = true;
578
+ maybeSettle(activePrompt);
389
579
  break;
390
580
  }
391
581
  };
@@ -400,17 +590,61 @@ const makePiSession = (
400
590
  } catch {
401
591
  // Continue with abort/dispose.
402
592
  }
403
- await shutdownAndDisposeChildSession(session, {
593
+ const shutdown = await shutdownAndDisposeChildSession(session, {
404
594
  abort: true,
405
- timeoutMs: CHILD_SHUTDOWN_TIMEOUT_MS,
595
+ timeoutMs: options.shutdownTimeoutMs ?? CHILD_SHUTDOWN_TIMEOUT_MS,
406
596
  });
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(() => {});
597
+ const promptQuiescent =
598
+ state.activePrompt === undefined || state.activePrompt.promptSettled;
599
+ const cleanupMessages = [...shutdown.errors];
600
+ let cleanupUncertain =
601
+ shutdown.errors.length > 0 || shutdown.timedOut || !promptQuiescent;
602
+ if (shutdown.timedOut)
603
+ cleanupMessages.push("cleanup deadline exceeded");
604
+ if (!promptQuiescent) {
605
+ cleanupMessages.push(
606
+ "active prompt did not quiesce before session shutdown",
607
+ );
608
+ }
609
+
610
+ // A preflight hook can outlive abort() and continue touching the child
611
+ // cwd. Never reclaim a worktree until both the prompt and session
612
+ // cleanup have reached a known-safe terminal state.
613
+ if (
614
+ task.worktree &&
615
+ promptQuiescent &&
616
+ shutdown.ok &&
617
+ !shutdown.timedOut
618
+ ) {
619
+ try {
620
+ const cleanup = await (options.worktreeCleanup ?? reclaimWorktree)(
621
+ task.worktree.repoCwd,
622
+ task.worktree,
623
+ {
624
+ timeoutMs: DIRECT_WORKTREE_CLEANUP_TIMEOUT_MS,
625
+ },
626
+ );
627
+ const warning = formatWorktreeCleanupWarning(
628
+ cleanup,
629
+ task.worktree.path,
630
+ );
631
+ if (warning) cleanupMessages.push(`worktree cleanup: ${warning}`);
632
+ } catch (error) {
633
+ cleanupUncertain = true;
634
+ cleanupMessages.push(
635
+ `worktree cleanup failed: ${boundedError(error)}; checkout preserved at ${task.worktree.path}`,
636
+ );
637
+ }
638
+ } else if (task.worktree) {
639
+ cleanupMessages.push(
640
+ `worktree cleanup skipped; checkout preserved at ${task.worktree.path}`,
641
+ );
642
+ }
643
+ if (cleanupMessages.length > 0) {
644
+ cleanupReceipt = {
645
+ uncertain: cleanupUncertain,
646
+ message: `${cleanupUncertain ? "Subagent cleanup is uncertain" : "Subagent cleanup"}: ${cleanupMessages.join("; ")}`,
647
+ };
414
648
  }
415
649
  Queue.endUnsafe(events);
416
650
  }),
@@ -418,15 +652,64 @@ const makePiSession = (
418
652
 
419
653
  /** Start a fresh run (v1 manager.run): fire-and-forget, errors -> events. */
420
654
  const startRun = (text: string) => {
421
- state.runError = undefined;
655
+ if (state.activePrompt) {
656
+ throw new Error(
657
+ "Cannot start a Pi prompt while another prompt is active.",
658
+ );
659
+ }
660
+ const activePrompt: ActivePrompt = {
661
+ cancelled: false,
662
+ lifecycleStarted: false,
663
+ lifecycleSettled: false,
664
+ promptSettled: false,
665
+ finalText: "",
666
+ liveText: "",
667
+ promise: Promise.resolve(),
668
+ };
669
+ state.activePrompt = activePrompt;
670
+ capturedStructured = undefined;
422
671
  state.settled = false;
423
672
  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
- });
673
+ let prompt: Promise<void>;
674
+ try {
675
+ prompt = session.prompt(text, {
676
+ preflightResult: (accepted) => {
677
+ if (accepted && (activePrompt.cancelled || state.closed)) {
678
+ // This callback runs immediately before Pi enters
679
+ // _runAgentPrompt. Throwing here is the only synchronous seam
680
+ // that prevents a cancelled preflight from reviving after the
681
+ // adapter subscription and worktree ownership are gone.
682
+ throw new Error(
683
+ "Subagent prompt was cancelled during preflight.",
684
+ );
685
+ }
686
+ },
687
+ });
688
+ } catch (error) {
689
+ prompt = Promise.reject(error);
690
+ }
691
+ activePrompt.promise = prompt
692
+ .then(
693
+ () => undefined,
694
+ (error) => {
695
+ if (
696
+ !state.closed &&
697
+ state.activePrompt === activePrompt &&
698
+ !activePrompt.cancelled
699
+ ) {
700
+ activePrompt.promptError = boundedError(error);
701
+ }
702
+ },
703
+ )
704
+ .finally(() => {
705
+ activePrompt.promptSettled = true;
706
+ maybeSettle(activePrompt);
707
+ if (state.activePrompt === activePrompt) {
708
+ if (activePrompt.cancelled || state.settled) {
709
+ state.activePrompt = undefined;
710
+ }
711
+ }
712
+ });
430
713
  };
431
714
 
432
715
  // Session naming is best-effort.
@@ -444,6 +727,8 @@ const makePiSession = (
444
727
  startRun(task.prompt);
445
728
 
446
729
  return {
730
+ toolRenderer,
731
+ cleanupReceipt: () => cleanupReceipt,
447
732
  meta: Effect.sync(currentMeta),
448
733
  events: Stream.fromQueue(events),
449
734
  send: (text) =>
@@ -451,7 +736,21 @@ const makePiSession = (
451
736
  if (state.closed) {
452
737
  return new SendError({ message: "Subagent session is closed." });
453
738
  }
454
- if (session.isStreaming) {
739
+ const pendingRestart = state.pendingRestart;
740
+ if (pendingRestart) {
741
+ return new SendError({
742
+ message: pendingRestart.cancelled
743
+ ? "Subagent cancellation is still in progress."
744
+ : "A subagent restart is already pending.",
745
+ });
746
+ }
747
+ const activePrompt = state.activePrompt;
748
+ if (activePrompt?.cancelled) {
749
+ return new SendError({
750
+ message: "Subagent cancellation is still in progress.",
751
+ });
752
+ }
753
+ if (activePrompt && session.isStreaming) {
455
754
  // Steer the active run via the SDK's queue; queue_update events
456
755
  // render it, message_end(user) lands it in the transcript. A
457
756
  // rejected steer is a real send failure, not a diagnostic.
@@ -460,34 +759,115 @@ const makePiSession = (
460
759
  catch: (error) => new SendError({ message: boundedError(error) }),
461
760
  }).pipe(Effect.asVoid);
462
761
  }
762
+ if (
763
+ activePrompt &&
764
+ !activePrompt.lifecycleSettled &&
765
+ !activePrompt.promptSettled
766
+ ) {
767
+ return new SendError({
768
+ message:
769
+ "Subagent prompt is still starting; wait for it to run or settle before sending.",
770
+ });
771
+ }
772
+ if (activePrompt) {
773
+ // Pi emits agent_settled before prompt() resolves. Preserve the
774
+ // same-session restart contract while closing that microtask gap
775
+ // without ever overlapping two native prompts.
776
+ const restart: PendingRestart = {
777
+ cancelled: false,
778
+ terminalEmitted: false,
779
+ };
780
+ state.pendingRestart = restart;
781
+ return Effect.tryPromise({
782
+ try: async (signal) => {
783
+ const cancelPendingRestart = () => {
784
+ restart.cancelled = true;
785
+ if (state.pendingRestart === restart) {
786
+ state.pendingRestart = undefined;
787
+ }
788
+ };
789
+ signal.addEventListener("abort", cancelPendingRestart, {
790
+ once: true,
791
+ });
792
+ if (signal.aborted) cancelPendingRestart();
793
+ try {
794
+ await activePrompt.promise;
795
+ if (state.closed) {
796
+ throw new Error("Subagent session is closed.");
797
+ }
798
+ if (restart.cancelled) {
799
+ // interrupt emits the terminal event for this accepted
800
+ // restart. Resolve the send without starting a prompt so
801
+ // the manager keeps its restarting marker until that
802
+ // terminal event is folded.
803
+ return;
804
+ }
805
+ state.pendingRestart = undefined;
806
+ startRun(text);
807
+ } finally {
808
+ signal.removeEventListener("abort", cancelPendingRestart);
809
+ if (state.pendingRestart === restart) {
810
+ state.pendingRestart = undefined;
811
+ }
812
+ }
813
+ },
814
+ catch: (error) => new SendError({ message: boundedError(error) }),
815
+ }).pipe(Effect.asVoid);
816
+ }
463
817
  return Effect.sync(() => startRun(text));
464
818
  }),
465
819
  interrupt: Effect.promise(async () => {
466
820
  if (state.closed) return;
821
+ const pendingRestart = state.pendingRestart;
822
+ if (pendingRestart) pendingRestart.cancelled = true;
823
+ const activePrompt = state.activePrompt;
824
+ if (activePrompt) {
825
+ activePrompt.cancelled = true;
826
+ activePrompt.partialTextAtCancel = pendingRestart
827
+ ? undefined
828
+ : activePrompt.liveText || activePrompt.finalText || undefined;
829
+ }
467
830
  try {
468
831
  session.clearQueue();
469
832
  } catch {
470
833
  // Abort regardless.
471
834
  }
472
835
  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));
836
+ // isStreaming is false during Pi preflight, so it cannot prove the
837
+ // invocation is quiescent. Wait for the exact prompt Promise instead;
838
+ // the manager bounds this effect at 5s and force-disposes on timeout.
839
+ await activePrompt?.promise;
840
+ if (
841
+ !state.closed &&
842
+ pendingRestart &&
843
+ !pendingRestart.terminalEmitted
844
+ ) {
845
+ pendingRestart.terminalEmitted = true;
846
+ if (state.pendingRestart === pendingRestart) {
847
+ state.pendingRestart = undefined;
848
+ }
849
+ state.settled = true;
850
+ emit({ _tag: "RunSettled", outcome: { _tag: "Interrupted" } });
851
+ return;
479
852
  }
480
- // No streaming run means no agent_settled will arrive; emit the
481
- // terminal event (once) so the run cannot look running forever.
482
853
  if (!state.closed && !state.settled) {
483
854
  state.settled = true;
484
- emit({ _tag: "RunSettled", outcome: { _tag: "Interrupted" } });
855
+ emit({
856
+ _tag: "RunSettled",
857
+ outcome: {
858
+ _tag: "Interrupted",
859
+ partialText: activePrompt?.partialTextAtCancel,
860
+ },
861
+ });
485
862
  }
486
863
  }),
487
864
  } satisfies SubagentSession;
488
865
  });
489
866
 
490
- export const piBackend: SubagentBackend = {
491
- name: "pi",
492
- spawn: makePiSession,
493
- };
867
+ export const makePiBackend = (options: PiBackendOptions = {}) =>
868
+ ({
869
+ name: "pi",
870
+ spawn: (task) => makePiSession(task, options),
871
+ }) satisfies SubagentBackend;
872
+
873
+ export const piBackend = makePiBackend();