@tt-a1i/openpi 0.5.0 → 0.6.1

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 (80) hide show
  1. package/README.md +30 -20
  2. package/SETUP.md +10 -4
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/bin/openpi.js +25 -15
  5. package/extensions/ai-providers/LICENSE.upstream +23 -0
  6. package/extensions/ai-providers/README.md +65 -0
  7. package/extensions/ai-providers/antigravity/credentials.ts +52 -0
  8. package/extensions/ai-providers/antigravity/discovery.ts +130 -0
  9. package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
  10. package/extensions/ai-providers/antigravity/models.ts +84 -0
  11. package/extensions/ai-providers/antigravity/oauth.ts +700 -0
  12. package/extensions/ai-providers/antigravity/provider.ts +1116 -0
  13. package/extensions/ai-providers/antigravity/routing.ts +340 -0
  14. package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
  15. package/extensions/ai-providers/cursor/constants.ts +5 -0
  16. package/extensions/ai-providers/cursor/credentials.ts +14 -0
  17. package/extensions/ai-providers/cursor/discovery.ts +291 -0
  18. package/extensions/ai-providers/cursor/input-images.ts +105 -0
  19. package/extensions/ai-providers/cursor/models.ts +45 -0
  20. package/extensions/ai-providers/cursor/oauth.ts +263 -0
  21. package/extensions/ai-providers/cursor/proto.ts +1271 -0
  22. package/extensions/ai-providers/cursor/protobuf.ts +1181 -0
  23. package/extensions/ai-providers/cursor/provider.ts +1431 -0
  24. package/extensions/ai-providers/cursor/proxy.ts +213 -0
  25. package/extensions/ai-providers/cursor/tool-bridge.ts +68 -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 +8 -1
  31. package/extensions/background-terminals/src/manager.ts +3 -5
  32. package/extensions/background-terminals/src/result-delivery.ts +43 -23
  33. package/extensions/cron/index.ts +68 -27
  34. package/extensions/cron/schedule.ts +5 -1
  35. package/extensions/model-info/cache-diagnostics.ts +220 -0
  36. package/extensions/model-info/index.ts +45 -1
  37. package/extensions/plan-mode/index.ts +75 -4
  38. package/extensions/setup/index.ts +15 -3
  39. package/extensions/shared/child-session.ts +39 -5
  40. package/extensions/shared/completion-inbox.ts +193 -0
  41. package/extensions/shared/setup-config.ts +10 -1
  42. package/extensions/shared/structured-output.ts +154 -0
  43. package/extensions/subagents/index.ts +64 -7
  44. package/extensions/subagents/src/agent-types.ts +5 -17
  45. package/extensions/subagents/src/backends/pi.ts +130 -48
  46. package/extensions/subagents/src/backends/tool-preview.ts +29 -0
  47. package/extensions/subagents/src/domain.ts +16 -1
  48. package/extensions/subagents/src/manager.ts +7 -71
  49. package/extensions/subagents/src/prompt.ts +19 -5
  50. package/extensions/subagents/src/result-artifact.ts +32 -0
  51. package/extensions/subagents/src/result-delivery.ts +33 -14
  52. package/extensions/subagents/src/runtime.ts +10 -3
  53. package/extensions/ui-customization/footer.ts +16 -5
  54. package/extensions/user-input-fold/index.ts +42 -6
  55. package/extensions/web/index.ts +25 -2
  56. package/extensions/workflows/acceptance.ts +43 -19
  57. package/extensions/workflows/completion-projection.ts +3 -1
  58. package/extensions/workflows/dashboard.ts +147 -21
  59. package/extensions/workflows/index.ts +75 -20
  60. package/extensions/workflows/model.ts +5 -1
  61. package/extensions/workflows/progress-projection.ts +7 -1
  62. package/extensions/workflows/prompt.ts +4 -10
  63. package/extensions/workflows/result-delivery.ts +96 -22
  64. package/extensions/workflows/retention.ts +6 -0
  65. package/extensions/workflows/runner.ts +11 -233
  66. package/extensions/workflows/sandbox.ts +4 -0
  67. package/package.json +7 -7
  68. package/skills/subagents/REFERENCE.md +9 -9
  69. package/skills/subagents/SKILL.md +2 -1
  70. package/skills/workflows/REFERENCE.md +5 -3
  71. package/skills/workflows/SKILL.md +1 -1
  72. package/web/adapter/pi-adapter.ts +3 -0
  73. package/web/host/pi-coding-agent-entry.ts +162 -0
  74. package/web/host/web-host.ts +330 -50
  75. package/web/protocol/types.ts +5 -0
  76. package/web/runtime/pi-runtime.ts +240 -25
  77. package/web/runtime/types.ts +32 -1
  78. package/web/ui/app.js +343 -41
  79. package/web/ui/index.html +3 -0
  80. package/web/ui/styles.css +119 -37
@@ -92,6 +92,8 @@ export interface AgentType {
92
92
  readonly description: string;
93
93
  /** Omitted = the child keeps the normal tool set. Present = allowlist. */
94
94
  readonly tools?: readonly string[];
95
+ /** Only built-in investigator definitions carry this planning compatibility. */
96
+ readonly planningCompatible?: boolean;
95
97
  /** "provider/model-id" or a bare id; resolved by the pi backend. */
96
98
  readonly model?: string;
97
99
  readonly reasoningEffort?: ReasoningEffort;
@@ -121,9 +123,9 @@ export const READ_ONLY_AGENT_TOOLS = [
121
123
  export const BUILT_IN_AGENT_TYPES: readonly AgentType[] = [
122
124
  {
123
125
  name: "explorer",
126
+ planningCompatible: true,
124
127
  description:
125
128
  "Read-only codebase exploration. Usually use moderate reasoning, increasing it for harder tasks.",
126
- tools: READ_ONLY_AGENT_TOOLS,
127
129
  body: "Explore the codebase read-only. Trace the real flow, inspect related callers, and report concise evidence with file paths and line references.",
128
130
  source: "built-in:explorer",
129
131
  },
@@ -131,36 +133,22 @@ export const BUILT_IN_AGENT_TYPES: readonly AgentType[] = [
131
133
  name: "implementer",
132
134
  description:
133
135
  "Focused implementation with repository checks. Usually use medium-high reasoning, adjusted for scope, risk, and task difficulty.",
134
- tools: [
135
- "read",
136
- "bash",
137
- "edit",
138
- "write",
139
- "grep",
140
- "find",
141
- "ls",
142
- "fd",
143
- "rg",
144
- "git_show",
145
- "git_diff",
146
- "git_log",
147
- ],
148
136
  body: "Implement the requested change carefully. Trace the affected flow first, make the smallest correct edit, and run relevant checks before reporting results.",
149
137
  source: "built-in:implementer",
150
138
  },
151
139
  {
152
140
  name: "reviewer",
141
+ planningCompatible: true,
153
142
  description:
154
143
  "Read-only review for correctness, safety, and regressions. Usually use high reasoning, adjusted for task difficulty.",
155
- tools: READ_ONLY_AGENT_TOOLS,
156
144
  body: "Review the requested code or change read-only. Identify concrete correctness, security, and regression risks with evidence; do not modify files.",
157
145
  source: "built-in:reviewer",
158
146
  },
159
147
  {
160
148
  name: "advisor",
149
+ planningCompatible: true,
161
150
  description:
162
151
  "Deep read-only analysis and technical advice. Usually use high reasoning, adjusted for task difficulty.",
163
- tools: READ_ONLY_AGENT_TOOLS,
164
152
  body: "Analyze the problem deeply without modifying files. Explain the relevant tradeoffs, risks, and recommended next step using repository evidence.",
165
153
  source: "built-in:advisor",
166
154
  },
@@ -19,11 +19,13 @@ 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";
28
+ import { toolPreview } from "./tool-preview.ts";
27
29
  import type {
28
30
  SubagentBackend,
29
31
  SubagentCleanupReceipt,
@@ -49,6 +51,14 @@ import {
49
51
  reclaimWorktree,
50
52
  } from "../../../shared/worktree.ts";
51
53
  import { AgentToolRenderLedger } from "../../../shared/agent-tool-renderer.ts";
54
+ import {
55
+ childToolsWithStructuredOutput,
56
+ createStructuredOutputTool,
57
+ encodeStructuredResult,
58
+ type EncodedStructuredResult,
59
+ STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION,
60
+ } from "../../../shared/structured-output.ts";
61
+ import { persistStructuredResultArtifact } from "../result-artifact.ts";
52
62
 
53
63
  const DIRECT_WORKTREE_CLEANUP_TIMEOUT_MS = 4_000;
54
64
  const PARTIAL_TEXT_MAX_LENGTH = 128 * 1_024;
@@ -109,27 +119,6 @@ function safeJson(value: unknown): string | undefined {
109
119
  }
110
120
  }
111
121
 
112
- /** First non-empty line of a tool result-ish value (v1 liveToolPreview). */
113
- function toolPreview(value: unknown): string | undefined {
114
- if (typeof value === "string") {
115
- return value
116
- .split("\n")
117
- .find((line) => line.trim())
118
- ?.trim();
119
- }
120
- if (!value || typeof value !== "object") return undefined;
121
- const content = (value as { content?: unknown }).content;
122
- if (!Array.isArray(content)) return undefined;
123
- for (const part of content) {
124
- if (!part || typeof part !== "object") continue;
125
- const record = part as { type?: unknown; text?: unknown };
126
- if (record.type !== "text" || typeof record.text !== "string") continue;
127
- const firstLine = record.text.split("\n").find((line) => line.trim());
128
- if (firstLine) return firstLine.trim();
129
- }
130
- return undefined;
131
- }
132
-
133
122
  function assistantParts(msg: AssistantMessage): TranscriptPart[] {
134
123
  const parts: TranscriptPart[] = [];
135
124
  for (const part of msg.content) {
@@ -198,38 +187,93 @@ const makePiSession = (
198
187
  const thinkingLevel = (task.reasoningEffort ??
199
188
  task.parent.inheritedThinkingLevel) as ThinkingLevel | undefined;
200
189
 
190
+ let capturedStructured: EncodedStructuredResult | undefined;
191
+ const structuredOutputTool =
192
+ task.outputSchema === undefined
193
+ ? undefined
194
+ : createStructuredOutputTool(task.outputSchema, (value) => {
195
+ capturedStructured = encodeStructuredResult(value);
196
+ });
197
+
198
+ // Own the session before asynchronous startup. Interruption can happen
199
+ // before the normal backend finalizer has been installed.
200
+ let acquiringSession: AgentSession | undefined;
201
+ let startupOwned = true;
202
+ const cleanupStartup = () =>
203
+ acquiringSession
204
+ ? shutdownAndDisposeChildSession(acquiringSession, {
205
+ abort: true,
206
+ timeoutMs: options.shutdownTimeoutMs,
207
+ })
208
+ : Promise.resolve();
209
+ yield* Effect.addFinalizer(() =>
210
+ Effect.promise(async () => {
211
+ if (startupOwned) await cleanupStartup();
212
+ }),
213
+ );
214
+
201
215
  const session = yield* Effect.tryPromise({
202
- try: async () => {
203
- const { loader, settingsManager } = await createChildResources({
204
- cwd: task.cwd,
205
- projectTrusted: task.parent.projectTrusted,
206
- ...(task.appendSystemPrompt
207
- ? { appendSystemPrompt: [...task.appendSystemPrompt] }
208
- : {}),
209
- });
210
- const { session } = await (
211
- options.sessionFactory ?? createAgentSession
212
- )({
213
- cwd: task.cwd,
214
- sessionManager: SessionManager.create(task.cwd),
215
- settingsManager,
216
- resourceLoader: loader,
217
- model,
218
- thinkingLevel,
219
- ...childToolPolicy(task.tools),
220
- });
221
- // Start child extension session hooks/resources in headless mode.
222
- // A rejection here would otherwise leak the freshly created session:
223
- // the scope finalizer that owns cleanup is only registered later.
216
+ try: async (signal) => {
217
+ const checkCancelled = () => {
218
+ if (signal.aborted)
219
+ throw signal.reason ?? new Error("Subagent startup cancelled");
220
+ };
221
+ const onCancelled = () => {
222
+ void cleanupStartup().catch(() => {});
223
+ };
224
+ signal.addEventListener("abort", onCancelled, { once: true });
224
225
  try {
225
- await bindChildSessionExtensions(session, task.tools);
226
- } catch (error) {
227
- await shutdownAndDisposeChildSession(session, {
228
- timeoutMs: options.shutdownTimeoutMs,
226
+ checkCancelled();
227
+ const appendSystemPrompt = [
228
+ ...(task.appendSystemPrompt ?? []),
229
+ ...(structuredOutputTool
230
+ ? [STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION]
231
+ : []),
232
+ ];
233
+ const { loader, settingsManager } = await createChildResources({
234
+ cwd: task.cwd,
235
+ projectTrusted: task.parent.projectTrusted,
236
+ ...(appendSystemPrompt.length > 0 ? { appendSystemPrompt } : {}),
237
+ });
238
+ checkCancelled();
239
+ const { session } = await (
240
+ options.sessionFactory ?? createAgentSession
241
+ )({
242
+ cwd: task.cwd,
243
+ sessionManager: SessionManager.create(task.cwd),
244
+ settingsManager,
245
+ resourceLoader: loader,
246
+ model,
247
+ thinkingLevel,
248
+ ...(structuredOutputTool
249
+ ? { customTools: [structuredOutputTool] }
250
+ : {}),
251
+ ...childToolPolicy(
252
+ childToolsWithStructuredOutput(
253
+ task.tools,
254
+ structuredOutputTool !== undefined,
255
+ ),
256
+ ),
229
257
  });
258
+ acquiringSession = session;
259
+ checkCancelled();
260
+ // Never start extension binding for a factory that completed after
261
+ // cancellation. Already-running hooks retain bounded cleanup ownership.
262
+ await bindChildSessionExtensions(
263
+ session,
264
+ childToolsWithStructuredOutput(
265
+ task.tools,
266
+ structuredOutputTool !== undefined,
267
+ ),
268
+ );
269
+ checkCancelled();
270
+ return session;
271
+ } catch (error) {
272
+ await cleanupStartup();
230
273
  throw error;
274
+ } finally {
275
+ signal.removeEventListener("abort", onCancelled);
231
276
  }
232
- return session;
233
277
  },
234
278
  catch: (error) => new SpawnError({ message: boundedError(error) }),
235
279
  });
@@ -338,11 +382,46 @@ const makePiSession = (
338
382
  });
339
383
  return;
340
384
  }
385
+ if (task.outputSchema !== undefined && capturedStructured === undefined) {
386
+ emit({
387
+ _tag: "RunSettled",
388
+ outcome: {
389
+ _tag: "Failed",
390
+ errorText:
391
+ "Agent finished without calling structured_output; no structured result matching output_schema was produced.",
392
+ partialText,
393
+ },
394
+ });
395
+ return;
396
+ }
397
+ let structuredResult;
398
+ if (capturedStructured) {
399
+ try {
400
+ structuredResult = {
401
+ ...capturedStructured,
402
+ artifactPath: persistStructuredResultArtifact(
403
+ getAgentDir(),
404
+ capturedStructured.json,
405
+ ),
406
+ };
407
+ } catch (error) {
408
+ emit({
409
+ _tag: "RunSettled",
410
+ outcome: {
411
+ _tag: "Failed",
412
+ errorText: `Structured result artifact could not be persisted: ${boundedError(error)}`,
413
+ partialText,
414
+ },
415
+ });
416
+ return;
417
+ }
418
+ }
341
419
  emit({
342
420
  _tag: "RunSettled",
343
421
  outcome: {
344
422
  _tag: "Completed",
345
423
  finalText: prompt.finalText,
424
+ ...(structuredResult ? { structuredResult } : {}),
346
425
  },
347
426
  });
348
427
  };
@@ -580,6 +659,8 @@ const makePiSession = (
580
659
  }),
581
660
  );
582
661
 
662
+ startupOwned = false;
663
+
583
664
  /** Start a fresh run (v1 manager.run): fire-and-forget, errors -> events. */
584
665
  const startRun = (text: string) => {
585
666
  if (state.activePrompt) {
@@ -597,6 +678,7 @@ const makePiSession = (
597
678
  promise: Promise.resolve(),
598
679
  };
599
680
  state.activePrompt = activePrompt;
681
+ capturedStructured = undefined;
600
682
  state.settled = false;
601
683
  emit({ _tag: "RunStarted" });
602
684
  let prompt: Promise<void>;
@@ -0,0 +1,29 @@
1
+ // Match the manager's existing transcript text limit; canonical Pi tool results
2
+ // remain intact. Only the normalized event's single-line preview is bounded.
3
+ const TOOL_PREVIEW_MAX_LENGTH = 64 * 1_024;
4
+
5
+ function firstMeaningfulLine(text: string) {
6
+ // Search only until the first non-whitespace character. Unlike splitting the
7
+ // entire log, this preserves blank-line behavior without visiting its tail.
8
+ const start = text.search(/\S/);
9
+ if (start < 0) return undefined;
10
+ const prefix = text.slice(start, start + TOOL_PREVIEW_MAX_LENGTH);
11
+ const newline = prefix.indexOf("\n");
12
+ return (newline < 0 ? prefix : prefix.slice(0, newline)).trimEnd();
13
+ }
14
+
15
+ /** First meaningful line of a tool result, without splitting accumulated logs. */
16
+ export function toolPreview(value: unknown) {
17
+ if (typeof value === "string") return firstMeaningfulLine(value);
18
+ if (!value || typeof value !== "object") return undefined;
19
+ const content = (value as { content?: unknown }).content;
20
+ if (!Array.isArray(content)) return undefined;
21
+ for (const part of content) {
22
+ if (!part || typeof part !== "object") continue;
23
+ const record = part as { type?: unknown; text?: unknown };
24
+ if (record.type !== "text" || typeof record.text !== "string") continue;
25
+ const firstLine = firstMeaningfulLine(record.text);
26
+ if (firstLine) return firstLine;
27
+ }
28
+ return undefined;
29
+ }
@@ -69,6 +69,8 @@ export interface SpawnTask {
69
69
  readonly tools?: readonly string[];
70
70
  /** Agent type that supplied the above, for the session label. */
71
71
  readonly agentTypeName?: string;
72
+ /** Optional JSON Schema for one terminating, validated child result. */
73
+ readonly outputSchema?: unknown;
72
74
  /**
73
75
  * Isolated git worktree this child runs in, created by the tool layer. The
74
76
  * backend only reclaims it when the session scope closes; it does not know
@@ -142,7 +144,11 @@ export interface QueuedMessage {
142
144
  // --- Events ------------------------------------------------------------------
143
145
 
144
146
  export type RunOutcome =
145
- | { readonly _tag: "Completed"; readonly finalText: string }
147
+ | {
148
+ readonly _tag: "Completed";
149
+ readonly finalText: string;
150
+ readonly structuredResult?: StructuredSubagentResult;
151
+ }
146
152
  | {
147
153
  readonly _tag: "Failed";
148
154
  readonly errorText: string;
@@ -232,10 +238,19 @@ export interface SubagentSnapshot {
232
238
  readonly queued: ReadonlyArray<QueuedMessage>;
233
239
  /** Final text of the most recent completed run (v1 `finalOutput`). */
234
240
  readonly finalText: string;
241
+ /** Present only when this run supplied and satisfied output_schema. */
242
+ readonly structuredResult?: StructuredSubagentResult;
235
243
  /** Count of finalized assistant messages (for subagent_check). */
236
244
  readonly turns: number;
237
245
  }
238
246
 
247
+ export interface StructuredSubagentResult {
248
+ readonly value: unknown;
249
+ readonly json: string;
250
+ readonly byteLength: number;
251
+ readonly artifactPath: string;
252
+ }
253
+
239
254
  /** Final text, or the live streaming buffer while a run is active (v1 `latestOutput`). */
240
255
  export function latestText(snap: SubagentSnapshot) {
241
256
  const live = snap.liveAssistant?.text.trim();
@@ -10,10 +10,8 @@
10
10
  * imperative TUI components (which render synchronously) can read snapshots
11
11
  * and issue fire-and-forget commands without touching the Effect runtime.
12
12
  *
13
- * Every run is guarded by a first-response watchdog: a provider that accepts
14
- * the request but never emits its first assistant event is settled as a
15
- * failure (releasing its concurrency slot) instead of hanging forever,
16
- * mirroring the workflow runner's watchdog.
13
+ * Pi owns provider transport timeouts and retries. This manager owns explicit
14
+ * cancellation, settlement, and bounded cleanup, not model-output deadlines.
17
15
  */
18
16
 
19
17
  import {
@@ -60,13 +58,6 @@ export const MAX_TRACKED = 64;
60
58
  const STOP_TIMEOUT_MS = 5_000;
61
59
  /** Session abort/shutdown (5s) plus bounded direct-worktree cleanup (4s). */
62
60
  const ENTRY_CLOSE_TIMEOUT_MS = 10_000;
63
- /**
64
- * First-response watchdog: a run whose provider accepts the request but
65
- * never emits an assistant event is settled as a failure so it cannot
66
- * occupy a concurrency slot forever. Matches the workflow runner's
67
- * MODEL_PROGRESS_TIMEOUT_MS (extensions/workflows/runner.ts).
68
- */
69
- export const FIRST_RESPONSE_TIMEOUT_MS = 45_000;
70
61
  const ERROR_TEXT_MAX_LENGTH = 4_096;
71
62
  const TRANSCRIPT_TEXT_MAX_LENGTH = 64 * 1_024;
72
63
  const LIVE_ASSISTANT_MAX_LENGTH = 128 * 1_024;
@@ -77,10 +68,6 @@ function bounded(text: string) {
77
68
  return text.slice(0, ERROR_TEXT_MAX_LENGTH);
78
69
  }
79
70
 
80
- function formatWatchdogTimeout(ms: number) {
81
- return ms % 1_000 === 0 ? `${ms / 1_000} seconds` : `${ms} ms`;
82
- }
83
-
84
71
  function boundedTranscriptText(text: string) {
85
72
  return text.slice(0, TRANSCRIPT_TEXT_MAX_LENGTH);
86
73
  }
@@ -123,6 +110,7 @@ interface MutableSnapshot {
123
110
  liveTools: LiveToolState[];
124
111
  queued: SubagentSnapshot["queued"];
125
112
  finalText: string;
113
+ structuredResult?: SubagentSnapshot["structuredResult"];
126
114
  turns: number;
127
115
  }
128
116
 
@@ -132,8 +120,6 @@ interface Entry {
132
120
  scope: Scope.Closeable;
133
121
  pump?: Fiber.Fiber<void>;
134
122
  liveToolMap: Map<string, LiveToolState>;
135
- /** First-response watchdog timer for the active (or just-armed) run. */
136
- watchdogTimer?: ReturnType<typeof setTimeout>;
137
123
  /** Idle restart dispatched but RunStarted not folded yet; counts as running
138
124
  * so concurrent restarts cannot race past the cap. */
139
125
  restarting?: boolean;
@@ -213,8 +199,6 @@ export class SubagentManager extends Context.Service<
213
199
 
214
200
  const makeManager = (config: SubagentManagerConfig = {}) =>
215
201
  Effect.gen(function* () {
216
- const firstResponseTimeoutMs =
217
- config.firstResponseTimeoutMs ?? FIRST_RESPONSE_TIMEOUT_MS;
218
202
  const registry = yield* BackendRegistry;
219
203
  // Detached forker for sync contexts (read-model commands, pruning) that
220
204
  // preserves the manager's services instead of using the global runtime.
@@ -343,7 +327,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
343
327
  };
344
328
 
345
329
  const settle = (entry: Entry, outcome: RunOutcome) => {
346
- clearWatchdog(entry);
347
330
  const s = entry.snapshot;
348
331
  const wasRestarting = entry.restarting === true;
349
332
  entry.restarting = false;
@@ -363,6 +346,7 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
363
346
  s.outcome = "completed";
364
347
  s.errorText = undefined;
365
348
  s.finalText = outcome.finalText.slice(0, FINAL_TEXT_MAX_LENGTH);
349
+ s.structuredResult = outcome.structuredResult;
366
350
  break;
367
351
  case "Failed":
368
352
  s.status = "error";
@@ -373,6 +357,7 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
373
357
  0,
374
358
  FINAL_TEXT_MAX_LENGTH,
375
359
  );
360
+ s.structuredResult = undefined;
376
361
  break;
377
362
  case "Interrupted":
378
363
  s.status = "error";
@@ -382,6 +367,7 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
382
367
  0,
383
368
  FINAL_TEXT_MAX_LENGTH,
384
369
  );
370
+ s.structuredResult = undefined;
385
371
  break;
386
372
  }
387
373
  s.liveAssistant = undefined;
@@ -399,44 +385,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
399
385
  pruneSettled();
400
386
  };
401
387
 
402
- /** Stop the first-response watchdog (first response arrived / run settled). */
403
- const clearWatchdog = (entry: Entry) => {
404
- if (entry.watchdogTimer !== undefined) {
405
- clearTimeout(entry.watchdogTimer);
406
- entry.watchdogTimer = undefined;
407
- }
408
- };
409
-
410
- /** Settle a run whose provider never emitted a first assistant response. */
411
- const watchdogExpired = (entry: Entry) => {
412
- entry.watchdogTimer = undefined;
413
- if (!isBusy(entry)) return;
414
- const model = entry.snapshot.meta.modelLabel;
415
- settle(entry, {
416
- _tag: "Failed",
417
- errorText: `Agent received no assistant response event${model ? ` for ${model}` : ""} within ${formatWatchdogTimeout(firstResponseTimeoutMs)}; the provider request may be stalled. Retry the subagent.`,
418
- });
419
- // The stalled session cannot be trusted to abort cooperatively; dispose
420
- // it like the abort-deadline path so it cannot revive into a zombie run.
421
- const fiber = runDetached(
422
- closeEntryScope(entry).pipe(
423
- Effect.timeout(ENTRY_CLOSE_TIMEOUT_MS),
424
- Effect.ignore,
425
- ),
426
- );
427
- cleanups.add(fiber);
428
- fiber.addObserver(() => cleanups.delete(fiber));
429
- };
430
-
431
- /** Arm the first-response watchdog for the entry's current run. */
432
- const armWatchdog = (entry: Entry) => {
433
- clearWatchdog(entry);
434
- entry.watchdogTimer = setTimeout(
435
- () => watchdogExpired(entry),
436
- firstResponseTimeoutMs,
437
- );
438
- };
439
-
440
388
  const foldEvent = (entry: Entry, event: SubagentEvent) => {
441
389
  const s = entry.snapshot;
442
390
  switch (event._tag) {
@@ -446,7 +394,7 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
446
394
  s.outcome = undefined;
447
395
  s.settledAt = undefined;
448
396
  s.errorText = undefined;
449
- armWatchdog(entry);
397
+ s.structuredResult = undefined;
450
398
  break;
451
399
  case "RunSettled":
452
400
  settle(entry, event.outcome);
@@ -458,7 +406,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
458
406
  });
459
407
  break;
460
408
  case "AssistantDelta": {
461
- clearWatchdog(entry);
462
409
  const live = s.liveAssistant ?? { text: "", thinking: "" };
463
410
  s.liveAssistant =
464
411
  event.kind === "text"
@@ -477,7 +424,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
477
424
  break;
478
425
  }
479
426
  case "AssistantMessage":
480
- clearWatchdog(entry);
481
427
  appendTranscript(s, {
482
428
  kind: "assistant",
483
429
  parts: event.parts.map((part) =>
@@ -623,9 +569,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
623
569
  liveToolMap: new Map(),
624
570
  };
625
571
  entries.set(id, entry);
626
- // The run is live from the caller's perspective before RunStarted
627
- // reaches the pump; guard that window too.
628
- armWatchdog(entry);
629
572
 
630
573
  // Pump: fold the event stream into the snapshot. Tied to the entry
631
574
  // scope, so closing the scope stops it. If the stream ends while the
@@ -776,10 +719,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
776
719
  // both pass the check in that window. Cleared by RunStarted/settle,
777
720
  // or here when the backend rejects the send.
778
721
  entry.restarting = true;
779
- // A backend that accepts the send but never starts the run would
780
- // hold the slot forever; guard the restart window the same way the
781
- // spawn path guards its pre-RunStarted window.
782
- armWatchdog(entry);
783
722
  return entry.session.send(text).pipe(
784
723
  Effect.onError(() =>
785
724
  Effect.sync(() => {
@@ -795,7 +734,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
795
734
  const disposeAll = Effect.gen(function* () {
796
735
  disposed = true;
797
736
  const all = [...entries.values()];
798
- for (const entry of all) clearWatchdog(entry);
799
737
  entries.clear();
800
738
  yield* Effect.forEach(
801
739
  all,
@@ -873,8 +811,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
873
811
  });
874
812
 
875
813
  export interface SubagentManagerConfig {
876
- /** Test-only override for the first-response watchdog timeout. */
877
- firstResponseTimeoutMs?: number;
878
814
  /** Session-branch high-water marks restored by the extension host. */
879
815
  initialModelCounter?: number;
880
816
  initialBtwCounter?: number;
@@ -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) {
@@ -141,17 +141,18 @@ export const SUBAGENT_SPAWN_PROMPT_GUIDELINES = [
141
141
  /** Model-facing schema descriptions for subagent_spawn task and execution options. */
142
142
  export const SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS = {
143
143
  prompt:
144
- "Task prompt for the subagent. Must be self-contained: include all needed context, file paths, and what to report back.",
144
+ "Self-contained task: include needed context, file paths, and expected report.",
145
145
  name: "Short human-readable name shown in listings and the UI",
146
146
  harness: 'Optional; "pi" is the only harness and the default.',
147
147
  workingDir:
148
- "Trusted child working directory; defaults to the current directory",
148
+ "Child cwd, absolute or relative to parent. Target project trust is checked separately.",
149
149
  isolation:
150
150
  'Use "worktree" for concurrent writers and tell the child to commit. See the Subagents Skill for lifecycle details.',
151
151
  model:
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
  );
@@ -77,6 +77,38 @@ export function persistResultArtifact(agentDir: string, content: string) {
77
77
  return artifactPath;
78
78
  }
79
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
+
80
112
  /**
81
113
  * Build the single model-visible projection used by automatic delivery and
82
114
  * explicit waits. Short answers pass through byte-for-byte. Long answers keep