@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
@@ -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 {
@@ -68,6 +69,8 @@ export interface SpawnTask {
68
69
  readonly tools?: readonly string[];
69
70
  /** Agent type that supplied the above, for the session label. */
70
71
  readonly agentTypeName?: string;
72
+ /** Optional JSON Schema for one terminating, validated child result. */
73
+ readonly outputSchema?: unknown;
71
74
  /**
72
75
  * Isolated git worktree this child runs in, created by the tool layer. The
73
76
  * backend only reclaims it when the session scope closes; it does not know
@@ -141,7 +144,11 @@ export interface QueuedMessage {
141
144
  // --- Events ------------------------------------------------------------------
142
145
 
143
146
  export type RunOutcome =
144
- | { readonly _tag: "Completed"; readonly finalText: string }
147
+ | {
148
+ readonly _tag: "Completed";
149
+ readonly finalText: string;
150
+ readonly structuredResult?: StructuredSubagentResult;
151
+ }
145
152
  | {
146
153
  readonly _tag: "Failed";
147
154
  readonly errorText: string;
@@ -215,22 +222,35 @@ export interface SubagentSnapshot {
215
222
  readonly prompt: string;
216
223
  readonly cwd: string;
217
224
  readonly status: SubagentStatus;
225
+ readonly outcome?: SubagentOutcome;
226
+ readonly worktreeBranch?: string;
218
227
  readonly createdAt: number;
219
228
  readonly settledAt?: number;
220
229
  readonly errorText?: string;
221
230
  readonly meta: SubagentMeta;
222
231
  readonly usage: { readonly tokens?: number; readonly contextWindow?: number };
223
232
  readonly transcript: ReadonlyArray<TranscriptItem>;
233
+ /** Monotonic version bumped on every transcript mutation (see manager). */
234
+ readonly transcriptVersion: number;
224
235
  /** Streaming assistant buffers, cleared when the finalized message lands. */
225
236
  readonly liveAssistant?: { readonly text: string; readonly thinking: string };
226
237
  readonly liveTools: ReadonlyArray<LiveToolState>;
227
238
  readonly queued: ReadonlyArray<QueuedMessage>;
228
239
  /** Final text of the most recent completed run (v1 `finalOutput`). */
229
240
  readonly finalText: string;
241
+ /** Present only when this run supplied and satisfied output_schema. */
242
+ readonly structuredResult?: StructuredSubagentResult;
230
243
  /** Count of finalized assistant messages (for subagent_check). */
231
244
  readonly turns: number;
232
245
  }
233
246
 
247
+ export interface StructuredSubagentResult {
248
+ readonly value: unknown;
249
+ readonly json: string;
250
+ readonly byteLength: number;
251
+ readonly artifactPath: string;
252
+ }
253
+
234
254
  /** Final text, or the live streaming buffer while a run is active (v1 `latestOutput`). */
235
255
  export function latestText(snap: SubagentSnapshot) {
236
256
  const live = snap.liveAssistant?.text.trim();
@@ -27,6 +27,7 @@ import {
27
27
  Stream,
28
28
  } from "effect";
29
29
  import type { SubagentBackend, SubagentSession } from "./backend.ts";
30
+ import type { AgentToolRenderer } from "../../shared/agent-tool-renderer.ts";
30
31
  import { BackendRegistry } from "./backend.ts";
31
32
  import type {
32
33
  BackendName,
@@ -63,7 +64,7 @@ const ENTRY_CLOSE_TIMEOUT_MS = 10_000;
63
64
  * First-response watchdog: a run whose provider accepts the request but
64
65
  * never emits an assistant event is settled as a failure so it cannot
65
66
  * occupy a concurrency slot forever. Matches the workflow runner's
66
- * FIRST_RESPONSE_TIMEOUT_MS (extensions/workflows/runner.ts).
67
+ * MODEL_PROGRESS_TIMEOUT_MS (extensions/workflows/runner.ts).
67
68
  */
68
69
  export const FIRST_RESPONSE_TIMEOUT_MS = 45_000;
69
70
  const ERROR_TEXT_MAX_LENGTH = 4_096;
@@ -92,6 +93,9 @@ function appendTranscript(snapshot: MutableSnapshot, item: TranscriptItem) {
92
93
  snapshot.transcript.length - MAX_TRANSCRIPT_ITEMS,
93
94
  );
94
95
  }
96
+ // Monotonic version so UI projections can detect transcript changes by
97
+ // O(1) comparison instead of scanning the mutable array.
98
+ snapshot.transcriptVersion++;
95
99
  }
96
100
 
97
101
  // --- Internal state -----------------------------------------------------------
@@ -105,16 +109,21 @@ interface MutableSnapshot {
105
109
  prompt: string;
106
110
  cwd: string;
107
111
  status: SubagentStatus;
112
+ outcome?: SubagentSnapshot["outcome"];
113
+ worktreeBranch?: string;
108
114
  createdAt: number;
109
115
  settledAt?: number;
110
116
  errorText?: string;
111
117
  meta: SubagentMeta;
112
118
  usage: { tokens?: number; contextWindow?: number };
113
119
  transcript: TranscriptItem[];
120
+ /** Bumped on every transcript mutation; UI caches key on this. */
121
+ transcriptVersion: number;
114
122
  liveAssistant?: { text: string; thinking: string };
115
123
  liveTools: LiveToolState[];
116
124
  queued: SubagentSnapshot["queued"];
117
125
  finalText: string;
126
+ structuredResult?: SubagentSnapshot["structuredResult"];
118
127
  turns: number;
119
128
  }
120
129
 
@@ -137,6 +146,8 @@ interface Entry {
137
146
  export interface SubagentReadModel {
138
147
  list(): ReadonlyArray<SubagentSnapshot>;
139
148
  get(id: string): SubagentSnapshot | undefined;
149
+ /** Native tool projection retained by the live child session, when present. */
150
+ getToolRenderer?(id: string): AgentToolRenderer | undefined;
140
151
  size(): number;
141
152
  /** Any-change notification (footer status, dashboard). */
142
153
  subscribe(listener: () => void): () => void;
@@ -299,7 +310,20 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
299
310
  };
300
311
 
301
312
  const closeEntryScope = (entry: Entry) =>
302
- Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
313
+ Scope.close(entry.scope, Exit.void).pipe(
314
+ Effect.tap(() =>
315
+ Effect.sync(() => {
316
+ const receipt = entry.session.cleanupReceipt?.();
317
+ if (!receipt?.uncertain) return;
318
+ const current = entry.snapshot.errorText;
319
+ entry.snapshot.errorText = current
320
+ ? `${current}; ${receipt.message}`
321
+ : receipt.message;
322
+ notify(entry.snapshot.id);
323
+ }),
324
+ ),
325
+ Effect.ignore,
326
+ );
303
327
 
304
328
  const pruneSettled = () => {
305
329
  if (entries.size <= MAX_TRACKED) return;
@@ -337,25 +361,31 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
337
361
  switch (outcome._tag) {
338
362
  case "Completed":
339
363
  s.status = "done";
364
+ s.outcome = "completed";
340
365
  s.errorText = undefined;
341
366
  s.finalText = outcome.finalText.slice(0, FINAL_TEXT_MAX_LENGTH);
367
+ s.structuredResult = outcome.structuredResult;
342
368
  break;
343
369
  case "Failed":
344
370
  s.status = "error";
371
+ s.outcome = "failed";
345
372
  s.errorText = bounded(outcome.errorText);
346
373
  // Never let a failed run report the previous run's successful output.
347
374
  s.finalText = (outcome.partialText ?? "").slice(
348
375
  0,
349
376
  FINAL_TEXT_MAX_LENGTH,
350
377
  );
378
+ s.structuredResult = undefined;
351
379
  break;
352
380
  case "Interrupted":
353
381
  s.status = "error";
382
+ s.outcome = "interrupted";
354
383
  s.errorText = "Run was aborted";
355
384
  s.finalText = (outcome.partialText ?? "").slice(
356
385
  0,
357
386
  FINAL_TEXT_MAX_LENGTH,
358
387
  );
388
+ s.structuredResult = undefined;
359
389
  break;
360
390
  }
361
391
  s.liveAssistant = undefined;
@@ -417,8 +447,10 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
417
447
  case "RunStarted":
418
448
  entry.restarting = false;
419
449
  s.status = "running";
450
+ s.outcome = undefined;
420
451
  s.settledAt = undefined;
421
452
  s.errorText = undefined;
453
+ s.structuredResult = undefined;
422
454
  armWatchdog(entry);
423
455
  break;
424
456
  case "RunSettled":
@@ -578,10 +610,14 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
578
610
  prompt: task.prompt,
579
611
  cwd: task.cwd,
580
612
  status: "running",
613
+ ...(task.worktree
614
+ ? { worktreeBranch: task.worktree.branch }
615
+ : {}),
581
616
  createdAt: Date.now(),
582
617
  meta,
583
618
  usage: { contextWindow: meta.contextWindow },
584
619
  transcript: [],
620
+ transcriptVersion: 0,
585
621
  liveTools: [],
586
622
  queued: [],
587
623
  finalText: "",
@@ -792,6 +828,7 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
792
828
  const view: SubagentReadModel = {
793
829
  list: () => [...entries.values()].map((entry) => entry.snapshot),
794
830
  get: (id) => entries.get(id)?.snapshot,
831
+ getToolRenderer: (id) => entries.get(id)?.session.toolRenderer,
795
832
  size: () => entries.size,
796
833
  subscribe: (listener) => {
797
834
  listeners.add(listener);
@@ -17,8 +17,8 @@ export const SUBAGENT_SCHEMA_BUDGETS = Object.freeze({
17
17
 
18
18
  /** Describes subagent_spawn, including the fixed concurrency cap. */
19
19
  export const SUBAGENT_SPAWN_TOOL_DESCRIPTION =
20
- "Spawn a background in-process Pi subagent with its own context, child-safe tools, and normal host permissions. Returns immediately; its final result is delivered automatically. The child cannot see this conversation, ask the user, or orchestrate agents/workflows. Use only trusted working directories. " +
21
- `Max ${MAX_RUNNING} subagents can be running at once.`;
20
+ "Spawn a background Pi subagent with isolated context and child-safe tools. Returns immediately; its result arrives automatically. It cannot see this chat, ask the user, or orchestrate. Use trusted directories. " +
21
+ `Max ${MAX_RUNNING} subagents can run at once.`;
22
22
 
23
23
  /** UTF-8 bounded, whitespace-normalized text for the parent-facing roster. */
24
24
  function boundedPurpose(description: string) {
@@ -152,6 +152,7 @@ export const SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS = {
152
152
  'Optional "provider/model-id" or current-provider model override. Omit to use the preset, configured role, or parent default. Never guess a model name.',
153
153
  reasoningEffort:
154
154
  "Optional child thinking level. Honor the user's requested level. Otherwise choose a level supported by the resolved child model based on the selected role and task difficulty. An explicit value overrides a role default.",
155
+ outputSchema: "Optional result JSON Schema.",
155
156
  };
156
157
 
157
158
  /** The exact name/description/wire-schema source used by registration/tests. */
@@ -193,6 +194,15 @@ export function createSubagentSpawnToolSurface(
193
194
  description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.reasoningEffort,
194
195
  }),
195
196
  ),
197
+ output_schema: Type.Optional(
198
+ Type.Object(
199
+ {},
200
+ {
201
+ additionalProperties: true,
202
+ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.outputSchema,
203
+ },
204
+ ),
205
+ ),
196
206
  }),
197
207
  };
198
208
  }
@@ -207,6 +217,7 @@ export function buildSubagentSpawnResult(options: {
207
217
  agentTypeName?: string;
208
218
  tools?: readonly string[];
209
219
  worktreeBranch?: string;
220
+ structured?: boolean;
210
221
  }) {
211
222
  const typeNote = options.agentTypeName
212
223
  ? ` Agent type "${options.agentTypeName}" applied.`
@@ -226,8 +237,11 @@ export function buildSubagentSpawnResult(options: {
226
237
  const worktreeNote = options.worktreeBranch
227
238
  ? ` Isolated in its own worktree on branch "${options.worktreeBranch}" — its edits are invisible here until you merge that branch. The checkout stays available for later send/review and is reclaimed on Session retirement only when bounded inspection proves it empty.`
228
239
  : "";
240
+ const structuredNote = options.structured
241
+ ? " This run must finish with the requested validated structured result."
242
+ : "";
229
243
  return (
230
- `Spawned subagent ${options.id} "${options.title}" (${options.harness}: ${options.modelLabel}, ${options.cwd}).${typeNote}${toolNote}${worktreeNote}\n` +
244
+ `Spawned subagent ${options.id} "${options.title}" (${options.harness}: ${options.modelLabel}, ${options.cwd}).${typeNote}${toolNote}${worktreeNote}${structuredNote}\n` +
231
245
  `It runs in the background — keep working on independent work. If none remains in an interactive session, briefly tell the user it is still running and end your turn; its result is delivered automatically and you are automatically re-invoked when it finishes. Do not poll or call subagent_wait merely because a later step depends on it. ` +
232
246
  `Use subagent_wait(ids: ["${options.id}"]) only if the user explicitly asked you to keep the current response open for this result, or a non-interactive automation must return it in the same invocation; subagent_cancel stops it, subagent_check peeks at a running one, subagent_list shows all.`
233
247
  );
@@ -285,8 +299,11 @@ export const SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS = {
285
299
  export const SUBAGENT_LIST_TOOL_DESCRIPTION =
286
300
  "List all subagents (running and finished) with their status.";
287
301
 
288
- /** Builds the child completion/failure wrapper injected into the parent model's context. */
289
- export function buildSubagentResultMessage(options: {
302
+ const SUBAGENT_RESULT_TRANSPORT_INSTRUCTION =
303
+ "(This result is already shown to the user. Act on it and relay only the decisions or next steps — do not repeat it verbatim.)";
304
+
305
+ /** Builds the user-visible child completion/failure projection. */
306
+ export function buildSubagentResultDisplayMessage(options: {
290
307
  id: string;
291
308
  title: string;
292
309
  status: "running" | "done" | "error";
@@ -297,9 +314,34 @@ export function buildSubagentResultMessage(options: {
297
314
  let text = `Subagent ${options.id} "${options.title}" ${verb}.`;
298
315
  if (options.errorText) text += `\nError: ${options.errorText}`;
299
316
  text += `\n\n${options.output}`;
317
+ return text;
318
+ }
319
+
320
+ /** Builds the child completion/failure wrapper injected into the parent model's context. */
321
+ export function buildSubagentResultMessage(options: {
322
+ id: string;
323
+ title: string;
324
+ status: "running" | "done" | "error";
325
+ errorText?: string;
326
+ output: string;
327
+ }) {
328
+ let text = buildSubagentResultDisplayMessage(options);
300
329
  // This message is already displayed to the user, so tell the parent to act on
301
330
  // it rather than reprint it verbatim.
302
- text +=
303
- "\n\n(This result is already shown to the user. Act on it and relay only the decisions or next steps — do not repeat it verbatim.)";
331
+ text += `\n\n${SUBAGENT_RESULT_TRANSPORT_INSTRUCTION}`;
304
332
  return text;
305
333
  }
334
+
335
+ /** Remove the transport-only suffix from results persisted before the split. */
336
+ export function stripSubagentResultTransportInstruction(content: string) {
337
+ const separator = `\n\n${SUBAGENT_RESULT_TRANSPORT_INSTRUCTION}`;
338
+ const withoutBatchedSeparators = content.replaceAll(
339
+ `${separator}\n\nSubagent `,
340
+ "\n\nSubagent ",
341
+ );
342
+ return (
343
+ withoutBatchedSeparators.endsWith(separator)
344
+ ? withoutBatchedSeparators.slice(0, -separator.length)
345
+ : withoutBatchedSeparators
346
+ ).trimEnd();
347
+ }
@@ -20,6 +20,7 @@ export interface ResultProjection {
20
20
  readonly text: string;
21
21
  readonly truncated: boolean;
22
22
  readonly artifactPath?: string;
23
+ readonly artifactSaveFailed?: boolean;
23
24
  }
24
25
 
25
26
  function sliceStartToUtf8Bytes(content: string, maxBytes: number) {
@@ -76,6 +77,38 @@ export function persistResultArtifact(agentDir: string, content: string) {
76
77
  return artifactPath;
77
78
  }
78
79
 
80
+ /** Persist one complete validated structured value under a JSON identity. */
81
+ export function persistStructuredResultArtifact(
82
+ agentDir: string,
83
+ content: string,
84
+ ) {
85
+ let directory = path.resolve(agentDir);
86
+ for (const segment of RESULT_ARTIFACT_DIR) {
87
+ directory = ensureDirectory(directory, segment);
88
+ }
89
+
90
+ const digest = createHash("sha256").update(content).digest("hex");
91
+ const artifactPath = path.join(directory, `${digest}.json`);
92
+ try {
93
+ writeFileSync(artifactPath, content, {
94
+ encoding: "utf8",
95
+ flag: "wx",
96
+ mode: 0o600,
97
+ });
98
+ } catch (error) {
99
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
100
+ const stat = lstatSync(artifactPath);
101
+ if (
102
+ !stat.isFile() ||
103
+ stat.isSymbolicLink() ||
104
+ readFileSync(artifactPath, "utf8") !== content
105
+ ) {
106
+ throw new Error(`Structured result artifact collision: ${artifactPath}`);
107
+ }
108
+ }
109
+ return artifactPath;
110
+ }
111
+
79
112
  /**
80
113
  * Build the single model-visible projection used by automatic delivery and
81
114
  * explicit waits. Short answers pass through byte-for-byte. Long answers keep
@@ -96,11 +129,13 @@ export function projectResult(
96
129
  const tailLines = Math.max(1, options.maxLines - headLines);
97
130
 
98
131
  let artifactPath: string | undefined;
132
+ let artifactSaveFailed = false;
99
133
  try {
100
134
  artifactPath = options.writeArtifact(content);
101
135
  } catch {
102
136
  // Delivery is more important than the optional recovery cache. The footer
103
137
  // below stays explicit so a failed write never advertises a false path.
138
+ artifactSaveFailed = true;
104
139
  }
105
140
 
106
141
  let bodyBudget = options.maxBytes;
@@ -138,5 +173,6 @@ export function projectResult(
138
173
  text,
139
174
  truncated: true,
140
175
  ...(artifactPath ? { artifactPath } : {}),
176
+ ...(artifactSaveFailed ? { artifactSaveFailed: true } : {}),
141
177
  };
142
178
  }
@@ -1,8 +1,16 @@
1
+ import type { ConsumableResultDeliveryQueue } from "../../shared/result-delivery.ts";
2
+ import {
3
+ type CompletionOwner,
4
+ createCompletionInbox,
5
+ } from "../../shared/completion-inbox.ts";
6
+
1
7
  export interface SubagentResultDeliveryOptions<T> {
2
8
  /** True only when the parent has no run or queued continuation in flight. */
3
9
  readonly isIdle: () => boolean;
4
10
  /** Deliver one drained batch and wake the parent. */
5
11
  readonly deliver: (results: readonly T[]) => void;
12
+ /** Current Pi Session transcript owner. */
13
+ readonly owner?: () => CompletionOwner | undefined;
6
14
  }
7
15
 
8
16
  /**
@@ -20,46 +28,63 @@ export interface SubagentResultDeliveryOptions<T> {
20
28
  * The parent boundary wakes even if an earlier extension handler has already
21
29
  * started another turn: Pi queues the follow-up into that active run.
22
30
  *
23
- * The Map is the one-shot gate: `subagent_wait` may consume a result before it
24
- * is delivered, and whichever path drains first prevents duplicate delivery.
31
+ * The shared inbox is the one-shot gate: `subagent_wait` may consume a result
32
+ * before it is delivered, and whichever path claims first prevents duplicate
33
+ * delivery.
25
34
  */
26
35
  export function createSubagentResultDelivery<T extends { id: string }>(
27
36
  options: SubagentResultDeliveryOptions<T>,
28
37
  ) {
29
- const pending = new Map<string, T>();
38
+ const inbox = createCompletionInbox<T>();
39
+ const owner = options.owner ?? (() => ({ sessionId: "test", epoch: 0 }));
30
40
 
31
41
  const flush = () => {
32
- if (pending.size === 0) return;
33
- const results = [...pending.values()];
34
- pending.clear();
42
+ const envelopes = inbox.claim(owner());
43
+ if (envelopes.length === 0) return;
44
+ const results = envelopes.map((envelope) => envelope.payload);
35
45
  try {
36
46
  options.deliver(results);
47
+ inbox.acknowledge(envelopes.map((envelope) => envelope.deliveryId));
37
48
  } catch (error) {
38
49
  // A synchronous session teardown may reject append/send. Preserve the
39
50
  // original batch ahead of anything deferred re-entrantly while delivery
40
51
  // ran, so a later boundary can retry without loss or reordering.
41
- const current = [...pending.values()];
42
- pending.clear();
43
- for (const result of results) pending.set(result.id, result);
44
- for (const result of current) pending.set(result.id, result);
52
+ inbox.retry(envelopes, owner());
45
53
  throw error;
46
54
  }
47
55
  };
48
56
 
49
- return {
57
+ const queue = {
50
58
  defer(result: T) {
51
- pending.set(result.id, result);
59
+ const currentOwner = owner();
60
+ inbox.defer(
61
+ {
62
+ deliveryId: `subagent:${result.id}`,
63
+ owner: currentOwner ?? { sessionId: "unowned", epoch: 0 },
64
+ producer: "subagent",
65
+ producerId: result.id,
66
+ terminalRef: { kind: "subagent-snapshot", id: result.id },
67
+ wake: "follow-up",
68
+ payload: result,
69
+ },
70
+ currentOwner,
71
+ );
52
72
  if (options.isIdle()) flush();
53
73
  },
54
74
  consume(ids: Iterable<string>) {
55
- for (const id of ids) pending.delete(id);
75
+ inbox.consume("subagent", ids);
56
76
  },
57
77
  /** Flush at the authoritative parent boundary. */
58
78
  parentSettled() {
59
79
  flush();
60
80
  },
61
81
  clear() {
62
- pending.clear();
82
+ inbox.clear();
83
+ },
84
+ size() {
85
+ return inbox.size();
63
86
  },
87
+ inspectDeadLetters: inbox.inspectDeadLetters,
64
88
  };
89
+ return queue satisfies ConsumableResultDeliveryQueue<T>;
65
90
  }
@@ -11,8 +11,22 @@ import { BackendRegistry, type SubagentBackend } from "./backend.ts";
11
11
  import { piBackend } from "./backends/pi.ts";
12
12
  import type { BackendName } from "./domain.ts";
13
13
 
14
+ /**
15
+ * Test-only injection seam for extension-level tests that drive real spawns
16
+ * without a child pi session: production never sets it. The underscore-prefixed
17
+ * setter name makes any accidental production use self-evidently wrong.
18
+ */
19
+ let testBackends: readonly SubagentBackend[] | undefined;
20
+
21
+ /** Test-only: replace the backends the manager can spawn against. */
22
+ export function __setSubagentTestBackends(
23
+ backends: readonly SubagentBackend[] | undefined,
24
+ ) {
25
+ testBackends = backends;
26
+ }
27
+
14
28
  const BackendRegistryLive = Layer.sync(BackendRegistry, () => {
15
- const backends: SubagentBackend[] = [piBackend];
29
+ const backends: readonly SubagentBackend[] = testBackends ?? [piBackend];
16
30
  return new Map<BackendName, SubagentBackend>(
17
31
  backends.map((backend) => [backend.name, backend]),
18
32
  );