@tt-a1i/openpi 0.6.0 → 0.7.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 (60) hide show
  1. package/README.md +13 -11
  2. package/SETUP.md +2 -2
  3. package/THIRD_PARTY_NOTICES.md +242 -0
  4. package/extensions/ai-providers/README.md +12 -6
  5. package/extensions/ai-providers/cursor/connect-frame-reader.ts +76 -0
  6. package/extensions/ai-providers/cursor/input-images.ts +2 -3
  7. package/extensions/ai-providers/cursor/proto.ts +218 -11
  8. package/extensions/ai-providers/cursor/protobuf.ts +12 -2
  9. package/extensions/ai-providers/cursor/provider.ts +280 -37
  10. package/extensions/ai-providers/cursor/tool-bridge.ts +68 -0
  11. package/extensions/ai-providers/index.ts +3 -3
  12. package/extensions/file-mutation-display/index.ts +13 -9
  13. package/extensions/shared/agent-transcript.ts +3 -2
  14. package/extensions/shared/child-session.ts +14 -0
  15. package/extensions/subagents/index.ts +20 -3
  16. package/extensions/subagents/src/agent-types.ts +5 -17
  17. package/extensions/subagents/src/backends/pi.ts +70 -59
  18. package/extensions/subagents/src/backends/tool-preview.ts +29 -0
  19. package/extensions/subagents/src/manager.ts +2 -71
  20. package/extensions/subagents/src/prompt.ts +2 -2
  21. package/extensions/subagents/src/runtime.ts +10 -3
  22. package/extensions/web/index.ts +69 -5
  23. package/extensions/workflows/artifacts.ts +362 -23
  24. package/extensions/workflows/dashboard.ts +141 -21
  25. package/extensions/workflows/index.ts +62 -20
  26. package/extensions/workflows/progress-projection.ts +7 -1
  27. package/extensions/workflows/runner.ts +5 -162
  28. package/extensions/workflows/sandbox.ts +4 -0
  29. package/package.json +28 -4
  30. package/skills/subagents/REFERENCE.md +6 -7
  31. package/skills/subagents/SKILL.md +1 -1
  32. package/skills/workflows/REFERENCE.md +3 -1
  33. package/web/dist/app.js +87 -0
  34. package/web/dist/favicon.svg +9 -0
  35. package/web/dist/index.html +15 -0
  36. package/web/dist/styles.css +3 -0
  37. package/web/host/web-host.ts +6 -9
  38. package/web/ui/index.html +3 -131
  39. package/web/ui/public/favicon.svg +9 -0
  40. package/web/ui/src/app/App.tsx +134 -0
  41. package/web/ui/src/app/providers.tsx +38 -0
  42. package/web/ui/src/components/Markdown.tsx +58 -0
  43. package/web/ui/src/components/OpenPiLogo.tsx +41 -0
  44. package/web/ui/src/features/activity/ActivityBar.tsx +120 -0
  45. package/web/ui/src/features/composer/Composer.tsx +237 -0
  46. package/web/ui/src/features/sessions/SessionSidebar.tsx +418 -0
  47. package/web/ui/src/features/transcript/Transcript.tsx +860 -0
  48. package/web/ui/src/i18n.ts +159 -0
  49. package/web/ui/src/lib/format.ts +57 -0
  50. package/web/ui/src/main.tsx +16 -0
  51. package/web/ui/src/protocol/client.ts +199 -0
  52. package/web/ui/src/protocol/event-stream.ts +88 -0
  53. package/web/ui/src/store/web-store.ts +926 -0
  54. package/web/ui/src/styles.css +420 -0
  55. package/web/ui/tsconfig.json +12 -0
  56. package/web/ui/vite-env.d.ts +1 -0
  57. package/web/vite.config.mjs +21 -1
  58. package/web/host/static-assets.ts +0 -4
  59. package/web/ui/app.js +0 -1700
  60. package/web/ui/styles.css +0 -680
@@ -57,6 +57,7 @@ import {
57
57
  } from "../shared/below-editor-navigation.ts";
58
58
  import {
59
59
  effectiveChildToolAllowlist,
60
+ inheritedChildToolAllowlist,
60
61
  resolveStandaloneChildProjectTrust,
61
62
  } from "../shared/child-session.ts";
62
63
  import { formatContextUtilization } from "../shared/context-utilization.ts";
@@ -144,6 +145,7 @@ import { createSubagentResultDelivery } from "./src/result-delivery.ts";
144
145
  import {
145
146
  createSubagentRuntime,
146
147
  runTool,
148
+ SubagentToolInterruptedError,
147
149
  type SubagentRuntime,
148
150
  } from "./src/runtime.ts";
149
151
  import { openSubagentPicker, openSubagentTakeover } from "./src/ui/takeover.ts";
@@ -886,6 +888,7 @@ export default function (
886
888
  if (
887
889
  planning &&
888
890
  agentType &&
891
+ !agentType.planningCompatible &&
889
892
  !planModeAllowsDeclaredTools(declaredChildTools)
890
893
  ) {
891
894
  throw new Error(
@@ -934,7 +937,10 @@ export default function (
934
937
  const requestedChildTools = planning
935
938
  ? planModeChildTools(declaredChildTools)
936
939
  : declaredChildTools;
937
- const childTools = effectiveChildToolAllowlist(requestedChildTools);
940
+ const childTools = inheritedChildToolAllowlist(
941
+ pi.getActiveTools(),
942
+ requestedChildTools,
943
+ );
938
944
  // Read at spawn time so `/openpi-setup` changes affect the next child
939
945
  // without reloading this extension. Undefined preserves parent-model
940
946
  // inheritance in the backend.
@@ -980,11 +986,22 @@ export default function (
980
986
  interruptMessage: "Subagent spawn aborted.",
981
987
  });
982
988
  } catch (error) {
983
- // The session scope owns reclamation, but it never opened, so this
984
- // worktree would otherwise be orphaned on disk.
989
+ // Known startup failures can reclaim their empty checkout. Interrupted
990
+ // startup must preserve it while asynchronous acquisition may continue.
985
991
  if (worktree) {
986
992
  const spawnError =
987
993
  error instanceof Error ? error.message : String(error);
994
+ // Cancelling Effect acquisition does not prove an asynchronous
995
+ // factory or extension hook has quiesced. It may still use this cwd.
996
+ if (
997
+ signal?.aborted ||
998
+ error instanceof SubagentToolInterruptedError
999
+ ) {
1000
+ throw new Error(
1001
+ `${spawnError}; startup quiescence is unknown; checkout preserved at ${worktree.path} (branch ${worktree.branch})`,
1002
+ { cause: error },
1003
+ );
1004
+ }
988
1005
  let cleanupWarning: string | undefined;
989
1006
  let cleanupError: unknown;
990
1007
  try {
@@ -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
  },
@@ -25,6 +25,7 @@ import {
25
25
  import type { Cause, Scope } from "effect";
26
26
  import { Effect, Queue, Stream } from "effect";
27
27
  import { resolveAgentModel } from "../agent-types.ts";
28
+ import { toolPreview } from "./tool-preview.ts";
28
29
  import type {
29
30
  SubagentBackend,
30
31
  SubagentCleanupReceipt,
@@ -118,27 +119,6 @@ function safeJson(value: unknown): string | undefined {
118
119
  }
119
120
  }
120
121
 
121
- /** First non-empty line of a tool result-ish value (v1 liveToolPreview). */
122
- function toolPreview(value: unknown): string | undefined {
123
- if (typeof value === "string") {
124
- return value
125
- .split("\n")
126
- .find((line) => line.trim())
127
- ?.trim();
128
- }
129
- if (!value || typeof value !== "object") return undefined;
130
- const content = (value as { content?: unknown }).content;
131
- if (!Array.isArray(content)) return undefined;
132
- for (const part of content) {
133
- if (!part || typeof part !== "object") continue;
134
- const record = part as { type?: unknown; text?: unknown };
135
- if (record.type !== "text" || typeof record.text !== "string") continue;
136
- const firstLine = record.text.split("\n").find((line) => line.trim());
137
- if (firstLine) return firstLine.trim();
138
- }
139
- return undefined;
140
- }
141
-
142
122
  function assistantParts(msg: AssistantMessage): TranscriptPart[] {
143
123
  const parts: TranscriptPart[] = [];
144
124
  for (const part of msg.content) {
@@ -215,42 +195,70 @@ const makePiSession = (
215
195
  capturedStructured = encodeStructuredResult(value);
216
196
  });
217
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
+
218
215
  const session = yield* Effect.tryPromise({
219
- try: async () => {
220
- const appendSystemPrompt = [
221
- ...(task.appendSystemPrompt ?? []),
222
- ...(structuredOutputTool
223
- ? [STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION]
224
- : []),
225
- ];
226
- const { loader, settingsManager } = await createChildResources({
227
- cwd: task.cwd,
228
- projectTrusted: task.parent.projectTrusted,
229
- ...(appendSystemPrompt.length > 0 ? { appendSystemPrompt } : {}),
230
- });
231
- const { session } = await (
232
- options.sessionFactory ?? createAgentSession
233
- )({
234
- cwd: task.cwd,
235
- sessionManager: SessionManager.create(task.cwd),
236
- settingsManager,
237
- resourceLoader: loader,
238
- model,
239
- thinkingLevel,
240
- ...(structuredOutputTool
241
- ? { customTools: [structuredOutputTool] }
242
- : {}),
243
- ...childToolPolicy(
244
- childToolsWithStructuredOutput(
245
- task.tools,
246
- structuredOutputTool !== undefined,
247
- ),
248
- ),
249
- });
250
- // Start child extension session hooks/resources in headless mode.
251
- // A rejection here would otherwise leak the freshly created session:
252
- // 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 });
253
225
  try {
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
+ ),
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.
254
262
  await bindChildSessionExtensions(
255
263
  session,
256
264
  childToolsWithStructuredOutput(
@@ -258,13 +266,14 @@ const makePiSession = (
258
266
  structuredOutputTool !== undefined,
259
267
  ),
260
268
  );
269
+ checkCancelled();
270
+ return session;
261
271
  } catch (error) {
262
- await shutdownAndDisposeChildSession(session, {
263
- timeoutMs: options.shutdownTimeoutMs,
264
- });
272
+ await cleanupStartup();
265
273
  throw error;
274
+ } finally {
275
+ signal.removeEventListener("abort", onCancelled);
266
276
  }
267
- return session;
268
277
  },
269
278
  catch: (error) => new SpawnError({ message: boundedError(error) }),
270
279
  });
@@ -650,6 +659,8 @@ const makePiSession = (
650
659
  }),
651
660
  );
652
661
 
662
+ startupOwned = false;
663
+
653
664
  /** Start a fresh run (v1 manager.run): fire-and-forget, errors -> events. */
654
665
  const startRun = (text: string) => {
655
666
  if (state.activePrompt) {
@@ -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
+ }
@@ -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
  }
@@ -133,8 +120,6 @@ interface Entry {
133
120
  scope: Scope.Closeable;
134
121
  pump?: Fiber.Fiber<void>;
135
122
  liveToolMap: Map<string, LiveToolState>;
136
- /** First-response watchdog timer for the active (or just-armed) run. */
137
- watchdogTimer?: ReturnType<typeof setTimeout>;
138
123
  /** Idle restart dispatched but RunStarted not folded yet; counts as running
139
124
  * so concurrent restarts cannot race past the cap. */
140
125
  restarting?: boolean;
@@ -214,8 +199,6 @@ export class SubagentManager extends Context.Service<
214
199
 
215
200
  const makeManager = (config: SubagentManagerConfig = {}) =>
216
201
  Effect.gen(function* () {
217
- const firstResponseTimeoutMs =
218
- config.firstResponseTimeoutMs ?? FIRST_RESPONSE_TIMEOUT_MS;
219
202
  const registry = yield* BackendRegistry;
220
203
  // Detached forker for sync contexts (read-model commands, pruning) that
221
204
  // preserves the manager's services instead of using the global runtime.
@@ -344,7 +327,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
344
327
  };
345
328
 
346
329
  const settle = (entry: Entry, outcome: RunOutcome) => {
347
- clearWatchdog(entry);
348
330
  const s = entry.snapshot;
349
331
  const wasRestarting = entry.restarting === true;
350
332
  entry.restarting = false;
@@ -403,44 +385,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
403
385
  pruneSettled();
404
386
  };
405
387
 
406
- /** Stop the first-response watchdog (first response arrived / run settled). */
407
- const clearWatchdog = (entry: Entry) => {
408
- if (entry.watchdogTimer !== undefined) {
409
- clearTimeout(entry.watchdogTimer);
410
- entry.watchdogTimer = undefined;
411
- }
412
- };
413
-
414
- /** Settle a run whose provider never emitted a first assistant response. */
415
- const watchdogExpired = (entry: Entry) => {
416
- entry.watchdogTimer = undefined;
417
- if (!isBusy(entry)) return;
418
- const model = entry.snapshot.meta.modelLabel;
419
- settle(entry, {
420
- _tag: "Failed",
421
- errorText: `Agent received no assistant response event${model ? ` for ${model}` : ""} within ${formatWatchdogTimeout(firstResponseTimeoutMs)}; the provider request may be stalled. Retry the subagent.`,
422
- });
423
- // The stalled session cannot be trusted to abort cooperatively; dispose
424
- // it like the abort-deadline path so it cannot revive into a zombie run.
425
- const fiber = runDetached(
426
- closeEntryScope(entry).pipe(
427
- Effect.timeout(ENTRY_CLOSE_TIMEOUT_MS),
428
- Effect.ignore,
429
- ),
430
- );
431
- cleanups.add(fiber);
432
- fiber.addObserver(() => cleanups.delete(fiber));
433
- };
434
-
435
- /** Arm the first-response watchdog for the entry's current run. */
436
- const armWatchdog = (entry: Entry) => {
437
- clearWatchdog(entry);
438
- entry.watchdogTimer = setTimeout(
439
- () => watchdogExpired(entry),
440
- firstResponseTimeoutMs,
441
- );
442
- };
443
-
444
388
  const foldEvent = (entry: Entry, event: SubagentEvent) => {
445
389
  const s = entry.snapshot;
446
390
  switch (event._tag) {
@@ -451,7 +395,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
451
395
  s.settledAt = undefined;
452
396
  s.errorText = undefined;
453
397
  s.structuredResult = undefined;
454
- armWatchdog(entry);
455
398
  break;
456
399
  case "RunSettled":
457
400
  settle(entry, event.outcome);
@@ -463,7 +406,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
463
406
  });
464
407
  break;
465
408
  case "AssistantDelta": {
466
- clearWatchdog(entry);
467
409
  const live = s.liveAssistant ?? { text: "", thinking: "" };
468
410
  s.liveAssistant =
469
411
  event.kind === "text"
@@ -482,7 +424,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
482
424
  break;
483
425
  }
484
426
  case "AssistantMessage":
485
- clearWatchdog(entry);
486
427
  appendTranscript(s, {
487
428
  kind: "assistant",
488
429
  parts: event.parts.map((part) =>
@@ -628,9 +569,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
628
569
  liveToolMap: new Map(),
629
570
  };
630
571
  entries.set(id, entry);
631
- // The run is live from the caller's perspective before RunStarted
632
- // reaches the pump; guard that window too.
633
- armWatchdog(entry);
634
572
 
635
573
  // Pump: fold the event stream into the snapshot. Tied to the entry
636
574
  // scope, so closing the scope stops it. If the stream ends while the
@@ -781,10 +719,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
781
719
  // both pass the check in that window. Cleared by RunStarted/settle,
782
720
  // or here when the backend rejects the send.
783
721
  entry.restarting = true;
784
- // A backend that accepts the send but never starts the run would
785
- // hold the slot forever; guard the restart window the same way the
786
- // spawn path guards its pre-RunStarted window.
787
- armWatchdog(entry);
788
722
  return entry.session.send(text).pipe(
789
723
  Effect.onError(() =>
790
724
  Effect.sync(() => {
@@ -800,7 +734,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
800
734
  const disposeAll = Effect.gen(function* () {
801
735
  disposed = true;
802
736
  const all = [...entries.values()];
803
- for (const entry of all) clearWatchdog(entry);
804
737
  entries.clear();
805
738
  yield* Effect.forEach(
806
739
  all,
@@ -878,8 +811,6 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
878
811
  });
879
812
 
880
813
  export interface SubagentManagerConfig {
881
- /** Test-only override for the first-response watchdog timeout. */
882
- firstResponseTimeoutMs?: number;
883
814
  /** Session-branch high-water marks restored by the extension host. */
884
815
  initialModelCounter?: number;
885
816
  initialBtwCounter?: number;
@@ -141,11 +141,11 @@ 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:
@@ -45,6 +45,9 @@ export function createSubagentRuntime(config: SubagentManagerConfig = {}) {
45
45
 
46
46
  export type SubagentRuntime = ReturnType<typeof createSubagentRuntime>;
47
47
 
48
+ /** Canonical interruption, distinct from a known startup failure. */
49
+ export class SubagentToolInterruptedError extends Error {}
50
+
48
51
  /**
49
52
  * Run an effect from an async tool handler. Typed failures and defects are
50
53
  * converted to thrown Errors (what pi's tool contract expects); interruption
@@ -60,9 +63,13 @@ export async function runTool<A, E>(
60
63
  options.signal ? { signal: options.signal } : undefined,
61
64
  );
62
65
  if (Exit.isSuccess(exit)) return exit.value;
63
- if (Cause.hasInterruptsOnly(exit.cause)) {
64
- throw new Error(options.interruptMessage ?? "Operation was aborted.");
65
- }
66
66
  const [first] = Cause.prettyErrors(exit.cause);
67
+ if (Cause.hasInterrupts(exit.cause)) {
68
+ const interrupted = options.interruptMessage ?? "Operation was aborted.";
69
+ const detail = Cause.hasInterruptsOnly(exit.cause)
70
+ ? ""
71
+ : ` ${first?.message ?? Cause.pretty(exit.cause)}`;
72
+ throw new SubagentToolInterruptedError(`${interrupted}${detail}`);
73
+ }
67
74
  throw new Error(first?.message ?? Cause.pretty(exit.cause));
68
75
  }
@@ -10,12 +10,23 @@ import {
10
10
  PI_CODING_AGENT_ENTRY_ENV,
11
11
  resolvePiCodingAgentEntry,
12
12
  } from "../../web/host/pi-coding-agent-entry.ts";
13
+ import { TerminalTextSanitizer } from "../shared/terminal-text.ts";
13
14
 
14
15
  const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000;
16
+ const WEB_ERROR_TAIL_MAX_BYTES = 8 * 1024;
17
+
18
+ interface WebProcessErrorStream {
19
+ on(event: "data", listener: (chunk: Buffer | string) => void): this;
20
+ removeListener(
21
+ event: "data",
22
+ listener: (chunk: Buffer | string) => void,
23
+ ): this;
24
+ }
15
25
 
16
26
  export interface WebProcess {
17
27
  readonly exitCode: number | null;
18
28
  readonly signalCode: NodeJS.Signals | null;
29
+ readonly stderr: WebProcessErrorStream | null;
19
30
  once(event: "error", listener: (error: Error) => void): this;
20
31
  once(
21
32
  event: "close",
@@ -28,7 +39,7 @@ interface SpawnWebOptions {
28
39
  cwd: string;
29
40
  env: NodeJS.ProcessEnv;
30
41
  shell: false;
31
- stdio: "inherit";
42
+ stdio: ["inherit", "inherit", "pipe"];
32
43
  }
33
44
 
34
45
  function webProcessEnvironment(
@@ -51,6 +62,7 @@ function webProcessEnvironment(
51
62
  export interface WebCommandDependencies {
52
63
  entrypoint: string;
53
64
  spawn(command: string, args: string[], options: SpawnWebOptions): WebProcess;
65
+ writeStderr(chunk: Buffer | string): void;
54
66
  clearTerminal(): void;
55
67
  holdParentSigint(): () => void;
56
68
  resolvePiCodingAgentEntry(): string | undefined;
@@ -58,7 +70,12 @@ export interface WebCommandDependencies {
58
70
  }
59
71
 
60
72
  type WebExit =
61
- | { kind: "close"; code: number | null; signal: NodeJS.Signals | null }
73
+ | {
74
+ kind: "close";
75
+ code: number | null;
76
+ signal: NodeJS.Signals | null;
77
+ errorDetail?: string;
78
+ }
62
79
  | { kind: "error"; error: Error };
63
80
 
64
81
  interface ActiveWebProcess {
@@ -71,6 +88,9 @@ const defaultDependencies: WebCommandDependencies = {
71
88
  spawn(command, args, options) {
72
89
  return nodeSpawn(command, args, options);
73
90
  },
91
+ writeStderr(chunk) {
92
+ process.stderr.write(chunk);
93
+ },
74
94
  clearTerminal() {
75
95
  process.stdout.write("\u001b[2J\u001b[H");
76
96
  },
@@ -91,6 +111,31 @@ function delay(milliseconds: number) {
91
111
  });
92
112
  }
93
113
 
114
+ function boundedUtf8Tail(text: string, maxBytes: number) {
115
+ let bytes = 0;
116
+ let start = text.length;
117
+ for (const character of Array.from(text).reverse()) {
118
+ const characterBytes = Buffer.byteLength(character, "utf8");
119
+ if (bytes + characterBytes > maxBytes) break;
120
+ bytes += characterBytes;
121
+ start -= character.length;
122
+ }
123
+ return text.slice(start);
124
+ }
125
+
126
+ function actionableWebError(stderr: string) {
127
+ const lines = stderr
128
+ .split(/\r?\n/u)
129
+ .map((line) => line.trim())
130
+ .filter(Boolean);
131
+ const newestFirst = [...lines].reverse();
132
+ return (
133
+ newestFirst.find((line) =>
134
+ line.includes("Failed to start OpenPI Web Workbench:"),
135
+ ) ?? newestFirst.find((line) => /^(?:Error|Failed):/u.test(line))
136
+ );
137
+ }
138
+
94
139
  async function stopWebProcess(active: ActiveWebProcess, timeoutMs: number) {
95
140
  if (active.child.exitCode !== null || active.child.signalCode !== null)
96
141
  return;
@@ -116,15 +161,26 @@ function runWebInForeground(
116
161
  return ctx.ui.custom<WebExit>((tui, _theme, _keybindings, done) => {
117
162
  let finished = false;
118
163
  let tuiStopped = false;
164
+ let stderrTail = "";
165
+ let childStderr: WebProcessErrorStream | null | undefined;
119
166
  let resolveClosed = () => {};
120
167
  const closed = new Promise<void>((resolve) => {
121
168
  resolveClosed = resolve;
122
169
  });
123
170
  const releaseParentSigint = dependencies.holdParentSigint();
171
+ const sanitizer = new TerminalTextSanitizer();
172
+ const captureStderr = (chunk: Buffer | string) => {
173
+ dependencies.writeStderr(chunk);
174
+ stderrTail = boundedUtf8Tail(
175
+ `${stderrTail}${sanitizer.push(String(chunk))}`,
176
+ WEB_ERROR_TAIL_MAX_BYTES,
177
+ );
178
+ };
124
179
 
125
180
  const finish = (result: WebExit) => {
126
181
  if (finished) return;
127
182
  finished = true;
183
+ childStderr?.removeListener("data", captureStderr);
128
184
  releaseParentSigint();
129
185
  setActive(undefined);
130
186
  resolveClosed();
@@ -147,13 +203,20 @@ function runWebInForeground(
147
203
  cwd: childCwd,
148
204
  env: webProcessEnvironment(childCwd, piCodingAgentEntry),
149
205
  shell: false,
150
- stdio: "inherit",
206
+ stdio: ["inherit", "inherit", "pipe"],
151
207
  },
152
208
  );
209
+ childStderr = child.stderr;
210
+ childStderr?.on("data", captureStderr);
153
211
  setActive({ child, closed });
154
212
  child.once("error", (error) => finish({ kind: "error", error }));
155
213
  child.once("close", (code, signal) =>
156
- finish({ kind: "close", code, signal }),
214
+ finish({
215
+ kind: "close",
216
+ code,
217
+ signal,
218
+ errorDetail: actionableWebError(stderrTail),
219
+ }),
157
220
  );
158
221
  } catch (error) {
159
222
  finish({
@@ -242,7 +305,8 @@ export default function web(
242
305
  }
243
306
  if (result.code !== 0) {
244
307
  ctx.ui.notify(
245
- `OpenPI Web Workbench exited with code ${result.code ?? "unknown"}.`,
308
+ result.errorDetail ??
309
+ `OpenPI Web Workbench exited with code ${result.code ?? "unknown"}.`,
246
310
  "error",
247
311
  );
248
312
  return;