pi-subagents 0.65.1 → 0.67.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 (148) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +5 -4
  3. package/agents/evidence-auditor.md +34 -0
  4. package/agents/researcher.md +23 -13
  5. package/agents/reviewer.md +3 -2
  6. package/docs/agents.md +20 -3
  7. package/docs/configuration.md +25 -5
  8. package/docs/extension-api.md +124 -18
  9. package/docs/missions.md +8 -0
  10. package/docs/models.md +59 -2
  11. package/docs/observability.md +46 -6
  12. package/docs/standalone-background.md +49 -0
  13. package/docs/tool-reference.md +20 -10
  14. package/docs/watchdog.md +35 -4
  15. package/docs/workflows.md +40 -19
  16. package/inspector-runner.mjs +2 -2
  17. package/package.json +2 -1
  18. package/prompts/parallel-review.md +1 -1
  19. package/{runner-server-preload.mjs → runner-peer-preload.mjs} +8 -3
  20. package/skills/pi-subagents/SKILL.md +14 -0
  21. package/skills/pi-subagents/references/execution-controls.md +20 -5
  22. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  23. package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
  24. package/src/agents/advertised-agent-prompt.ts +94 -0
  25. package/src/agents/agent-management.ts +14 -1
  26. package/src/agents/agent-serializer.ts +2 -0
  27. package/src/agents/agents.ts +14 -0
  28. package/src/agents/builtin-names.ts +1 -0
  29. package/src/api/delegation.ts +4 -0
  30. package/src/api/preflight.ts +76 -45
  31. package/src/api/shared-types.ts +3 -1
  32. package/src/api/workflow-resources.ts +6 -0
  33. package/src/extension/fanout-child.ts +63 -4
  34. package/src/extension/index.ts +58 -8
  35. package/src/extension/public-execution.ts +4 -3
  36. package/src/extension/rpc.ts +8 -21
  37. package/src/extension/schemas.ts +71 -80
  38. package/src/extension/tool-description.ts +29 -81
  39. package/src/inspectors/actions.ts +148 -0
  40. package/src/inspectors/ghostty/actions.ts +74 -0
  41. package/src/inspectors/ghostty/plugin.ts +17 -0
  42. package/src/inspectors/herdr/actions.ts +99 -179
  43. package/src/inspectors/herdr/plugin.ts +20 -0
  44. package/src/inspectors/herdr/project-panes.ts +1 -1
  45. package/src/inspectors/{herdr/inspector-runner.ts → inspector-runner.ts} +12 -12
  46. package/src/inspectors/plugins.ts +8 -0
  47. package/src/inspectors/{herdr/session-roots-codec.ts → session-roots-codec.ts} +3 -14
  48. package/src/inspectors/types.ts +51 -0
  49. package/src/intercom/intercom-bridge.ts +50 -8
  50. package/src/intercom/native-supervisor-channel.ts +104 -67
  51. package/src/runs/background/active-async-capacity.ts +22 -18
  52. package/src/runs/background/async-execution.ts +45 -56
  53. package/src/runs/background/async-job-tracker.ts +35 -3
  54. package/src/runs/background/async-resume.ts +5 -9
  55. package/src/runs/background/async-status-snapshot.ts +10 -12
  56. package/src/runs/background/async-status.ts +17 -9
  57. package/src/runs/background/auto-drain.ts +44 -30
  58. package/src/runs/background/binary-bootstrap.ts +33 -0
  59. package/src/runs/background/chain-root-attachment.ts +8 -0
  60. package/src/runs/background/control-channel.ts +78 -44
  61. package/src/runs/background/fleet-view.ts +30 -2
  62. package/src/runs/background/notify.ts +117 -13
  63. package/src/runs/background/owned-process-tree.ts +35 -8
  64. package/src/runs/background/process-terminal.ts +23 -23
  65. package/src/runs/background/run-child-session.ts +121 -36
  66. package/src/runs/background/run-status.ts +78 -5
  67. package/src/runs/background/runner-aliases.ts +28 -9
  68. package/src/runs/background/runner-child-launch.ts +88 -0
  69. package/src/runs/background/runner-child-sessions.ts +5 -4
  70. package/src/runs/background/scheduled-runs.ts +40 -13
  71. package/src/runs/background/stale-run-reconciler.ts +3 -1
  72. package/src/runs/background/steering.ts +20 -2
  73. package/src/runs/background/subagent-runner.ts +458 -239
  74. package/src/runs/background/subagent-wait.ts +54 -8
  75. package/src/runs/background/wait-completions.ts +4 -0
  76. package/src/runs/background/wait-tool.ts +1 -1
  77. package/src/runs/foreground/async-steering-action.ts +37 -7
  78. package/src/runs/foreground/execution.ts +145 -56
  79. package/src/runs/foreground/prompt-audit.ts +3 -1
  80. package/src/runs/foreground/subagent-executor.ts +584 -297
  81. package/src/runs/foreground/workflow-detach-reconcile.ts +10 -5
  82. package/src/runs/foreground/workflow-foreground-steering.ts +57 -2
  83. package/src/runs/shared/acceptance.ts +7 -4
  84. package/src/runs/shared/agent-contract.ts +1 -1
  85. package/src/runs/shared/async-status-projection.ts +51 -47
  86. package/src/runs/shared/capability-ceiling.ts +2 -0
  87. package/src/runs/shared/child-hooks.ts +167 -3
  88. package/src/runs/shared/child-launch.ts +28 -13
  89. package/src/runs/shared/child-lifecycle.ts +6 -3
  90. package/src/runs/shared/child-runtime-config.ts +3 -1
  91. package/src/runs/shared/child-session.ts +75 -8
  92. package/src/runs/shared/child-tool-plan.ts +124 -5
  93. package/src/runs/shared/completion-evidence.ts +2 -2
  94. package/src/runs/shared/completion-guard.ts +6 -3
  95. package/src/runs/shared/effective-system-prompt.ts +33 -0
  96. package/src/runs/shared/external-cli-runner.ts +9 -7
  97. package/src/runs/shared/host-step-status.ts +11 -11
  98. package/src/runs/shared/llm-intent-arbiter.ts +21 -11
  99. package/src/runs/shared/model-fallback.ts +12 -6
  100. package/src/runs/shared/nested-events.ts +5 -5
  101. package/src/runs/shared/orca-progress-tabs.ts +7 -1
  102. package/src/runs/shared/parallel-handoff.ts +57 -12
  103. package/src/runs/shared/parallel-utils.ts +2 -2
  104. package/src/runs/shared/pi-spawn.ts +10 -0
  105. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  106. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  107. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  108. package/src/runs/shared/run-fanout-budget.ts +8 -8
  109. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  110. package/src/runs/shared/subagent-prompt-runtime.ts +20 -4
  111. package/src/runs/shared/task-intent.ts +46 -13
  112. package/src/runs/shared/workflow-async-child-guidance.ts +18 -0
  113. package/src/runs/shared/worktree-setup-command.ts +190 -0
  114. package/src/runs/shared/worktree.ts +366 -208
  115. package/src/shared/fork-context.ts +15 -72
  116. package/src/shared/launch-contract.ts +65 -2
  117. package/src/shared/opencode-session-headers.ts +30 -0
  118. package/src/shared/types.ts +85 -61
  119. package/src/shared/utils.ts +7 -2
  120. package/src/shared/workflow-child-permit.ts +18 -13
  121. package/src/slash/delegation-adapters.ts +3 -1
  122. package/src/slash/delegation-request.ts +14 -0
  123. package/src/slash/slash-commands.ts +2 -1
  124. package/src/slash/subagents-admin.ts +11 -4
  125. package/src/tui/fleet-status.ts +164 -19
  126. package/src/tui/fleet.ts +27 -19
  127. package/src/tui/render.ts +172 -33
  128. package/src/watchdog/child-status.ts +8 -0
  129. package/src/watchdog/model-selection.ts +20 -0
  130. package/src/watchdog/permission-arbiter.ts +3 -1
  131. package/src/watchdog/register-child.ts +1 -0
  132. package/src/watchdog/register-main.ts +31 -27
  133. package/src/watchdog/review.ts +132 -67
  134. package/src/watchdog/runtime.ts +82 -20
  135. package/src/watchdog/scope.ts +1 -1
  136. package/src/watchdog/settings.ts +9 -3
  137. package/src/watchdog/tool-actions.ts +13 -12
  138. package/src/watchdog/turn-delta.ts +23 -0
  139. package/src/watchdog/types.ts +4 -0
  140. package/src/workflows/chat-progress.ts +3 -3
  141. package/src/workflows/scripted-workflow.ts +275 -17
  142. package/src/workflows/workflow-checklist.ts +13 -17
  143. package/src/workflows/workflow-child-summary.ts +57 -8
  144. package/src/workflows/workflow-preflight.ts +19 -19
  145. package/src/workflows/workflow-receipt.ts +3 -3
  146. package/src/workflows/workflow-resources.ts +96 -21
  147. package/src/workflows/workflow-settlement.ts +3 -0
  148. /package/src/inspectors/{herdr/shell-command.ts → shell-command.ts} +0 -0
@@ -3,7 +3,7 @@ import { Text } from "@earendil-works/pi-tui";
3
3
  import { resolveEffectiveThinking, splitKnownThinkingSuffix, THINKING_LEVELS, type ThinkingLevel } from "../shared/model-info.ts";
4
4
  import { SLASH_TEXT_RESULT_TYPE } from "../shared/types.ts";
5
5
  import { captureWatchdogDiffBaseline, type WatchdogDiffBaseline } from "./diff-tool.ts";
6
- import { recommendStrongWatchdogModel, resolveWatchdogModelInput, parseWatchdogThinkingInput } from "./model-selection.ts";
6
+ import { formatWatchdogRecommendation, recommendWatchdogModel, resolveWatchdogModelInput, parseWatchdogThinkingInput } from "./model-selection.ts";
7
7
  import { renderWatchdogWarning } from "./render.ts";
8
8
  import { createMainWatchdogReview } from "./review.ts";
9
9
  import { MainWatchdogRuntime, type WatchdogReviewFunction } from "./runtime.ts";
@@ -63,11 +63,16 @@ function mainThinkingLine(snapshot: ReturnType<MainWatchdogRuntime["getSnapshot"
63
63
  }
64
64
 
65
65
  function mainModelLine(snapshot: ReturnType<MainWatchdogRuntime["getSnapshot"]>, ctx: ExtensionContext): string {
66
+ const fallbacks = fallbackLine(snapshot.config.main.fallbackModels);
66
67
  if (snapshot.config.main.model) {
67
68
  const source = snapshot.sessionModelOverride?.model ? "session override" : "configured";
68
- return `Main model: ${splitKnownThinkingSuffix(snapshot.config.main.model).baseModel} (${source})`;
69
+ return `Main model: ${splitKnownThinkingSuffix(snapshot.config.main.model).baseModel} (${source})${fallbacks}`;
69
70
  }
70
- return `Main model: ${currentSessionModelLine(ctx)}`;
71
+ return `Main model: ${currentSessionModelLine(ctx)}${fallbacks}`;
72
+ }
73
+
74
+ function fallbackLine(models: string[] | undefined): string {
75
+ return models === undefined ? "" : ` · fallbacks ${models.length ? models.join(" → ") : "none"}`;
71
76
  }
72
77
 
73
78
  function childrenLine(snapshot: ReturnType<MainWatchdogRuntime["getSnapshot"]>): string {
@@ -80,19 +85,19 @@ function childrenLine(snapshot: ReturnType<MainWatchdogRuntime["getSnapshot"]>):
80
85
  const bits = [agent];
81
86
  if (override.enabled !== undefined) bits.push(boolLabel(override.enabled));
82
87
  if (override.model) bits.push(splitKnownThinkingSuffix(override.model).baseModel);
88
+ if (override.fallbackModels !== undefined) bits.push(fallbackLine(override.fallbackModels));
83
89
  if (override.thinking !== undefined) bits.push(`thinking ${override.thinking === false ? "off" : override.thinking}`);
84
90
  return bits.join(" ");
85
91
  }).join("; ")}`
86
92
  : "";
87
- return `Children: ${boolLabel(snapshot.config.enabled && children.enabled)} · model ${model} · thinking ${thinking}${overrideText}`;
93
+ return `Children: ${boolLabel(snapshot.config.enabled && children.enabled)} · model ${model}${fallbackLine(children.fallbackModels)} · thinking ${thinking}${overrideText}`;
88
94
  }
89
95
 
90
- function recommendationLine(ctx: ExtensionContext): string {
96
+ function recommendationLine(snapshot: ReturnType<MainWatchdogRuntime["getSnapshot"]>, ctx: ExtensionContext): string {
91
97
  try {
92
- const recommendation = recommendStrongWatchdogModel(ctx);
93
- return `Recommended strong watchdog: ${recommendation.model}:${recommendation.thinking} (${recommendation.label}, complementary reviewer)`;
98
+ return formatWatchdogRecommendation(recommendWatchdogModel(ctx, snapshot.config.main));
94
99
  } catch (error) {
95
- return `Recommended strong watchdog: unavailable (${messageFromError(error)})`;
100
+ return `Watchdog recommendation unavailable (${messageFromError(error)})`;
96
101
  }
97
102
  }
98
103
 
@@ -111,7 +116,8 @@ export function buildWatchdogStatus(snapshot: ReturnType<MainWatchdogRuntime["ge
111
116
  "Subagent watchdog",
112
117
  `Main: ${boolLabel(snapshot.enabled)}${!snapshot.config.enabled && snapshot.sessionOverride === undefined ? " (default off)" : ""}`,
113
118
  `Runtime: ${statusLabel(snapshot.status)}${snapshot.bufferedDeltas > 0 ? ` · buffered deltas ${snapshot.bufferedDeltas}` : ""}`,
114
- `Review trigger: ${snapshot.reviewTrigger === "repo-edits" ? "repo edits only" : "every non-empty turn delta"}`,
119
+ `Review trigger: ${snapshot.reviewTrigger === "repo-edits" ? snapshot.config.clarification ? "repo edits + bounded main orchestration activity" : "repo edits only" : "every non-empty turn delta"}`,
120
+ `Main clarification: ${snapshot.config.clarification ? "on" : "off"}`,
115
121
  `Scope context: ${snapshot.config.scope.enabled ? "on" : "off"}`,
116
122
  `Cadence: ${snapshot.config.cadence.everyNTools === null ? "boundary only" : `every ${snapshot.config.cadence.everyNTools} tools + boundary`}`,
117
123
  lspLine(snapshot),
@@ -119,7 +125,7 @@ export function buildWatchdogStatus(snapshot: ReturnType<MainWatchdogRuntime["ge
119
125
  mainModelLine(snapshot, ctx),
120
126
  `Main thinking: ${mainThinkingLine(snapshot, ctx)}`,
121
127
  childrenLine(snapshot),
122
- recommendationLine(ctx),
128
+ recommendationLine(snapshot, ctx),
123
129
  `Agent-end timeout: ${snapshot.config.agentEndTimeoutMs}ms`,
124
130
  `Stalemate: ${snapshot.boundaryRepeats}/${snapshot.config.stalemateRepeats}${snapshot.stalemate ? " · stopped" : ""}`,
125
131
  `Rules: ${snapshot.config.rules ? `${Object.keys(snapshot.config.rules.roleModels).length} role models · ${snapshot.config.rules.action}` : "none"}`,
@@ -145,7 +151,7 @@ export function buildWatchdogStatus(snapshot: ReturnType<MainWatchdogRuntime["ge
145
151
  "",
146
152
  "Model commands:",
147
153
  "- /subagents-watchdog recommend-model",
148
- "- /subagents-watchdog model recommended",
154
+ "- /subagents-watchdog model recommended (saves to user settings, not project settings)",
149
155
  "- /subagents-watchdog model <provider/model[:thinking]>",
150
156
  "- /subagents-watchdog model inherit",
151
157
  "- /subagents-watchdog session model recommended",
@@ -171,12 +177,12 @@ function parseThinkingCommand(raw: string): ThinkingLevel | false | null {
171
177
  return parseWatchdogThinkingInput(value, "/subagents-watchdog thinking") ?? null;
172
178
  }
173
179
 
174
- function resolveModelCommandValue(ctx: ExtensionCommandContext, raw: string): { model: string | null; thinking: ThinkingLevel | false | null; description: string } {
180
+ function resolveModelCommandValue(runtime: MainWatchdogRuntime, ctx: ExtensionCommandContext, raw: string): { model: string | null; thinking: ThinkingLevel | false | null; description: string } {
175
181
  const value = raw.trim();
176
182
  if (!value) throw new Error("Expected a model, 'recommended', or 'inherit'.");
177
183
  if (value === "inherit") return { model: null, thinking: null, description: "current session model and thinking" };
178
184
  if (value === "recommended") {
179
- const recommendation = recommendStrongWatchdogModel(ctx as ExtensionContext);
185
+ const recommendation = recommendWatchdogModel(ctx, runtime.getSnapshot(ctx.cwd).config.main);
180
186
  return {
181
187
  model: recommendation.model,
182
188
  thinking: recommendation.thinking,
@@ -191,18 +197,18 @@ function resolveModelCommandValue(ctx: ExtensionCommandContext, raw: string): {
191
197
  };
192
198
  }
193
199
 
194
- function buildRecommendationText(ctx: ExtensionCommandContext): string {
195
- const recommendation = recommendStrongWatchdogModel(ctx as ExtensionContext);
200
+ function buildRecommendationText(runtime: MainWatchdogRuntime, ctx: ExtensionCommandContext): string {
201
+ const recommendation = recommendWatchdogModel(ctx, runtime.getSnapshot(ctx.cwd).config.main);
196
202
  return [
197
203
  "Subagent watchdog recommended model",
198
204
  `Current session: ${currentSessionModelLine(ctx as ExtensionContext)}`,
199
- `Recommended: ${recommendation.model}:${recommendation.thinking}`,
205
+ recommendation.source === "configured" ? formatWatchdogRecommendation(recommendation) : `Recommended: ${recommendation.model}:${recommendation.thinking}`,
200
206
  `Reason: ${recommendation.reason}`,
201
207
  "",
202
208
  "Apply for this session:",
203
209
  "/subagents-watchdog session model recommended",
204
210
  "",
205
- "Save as your user default:",
211
+ "Save as your user default (affects other projects without overrides; does not change project settings):",
206
212
  "/subagents-watchdog model recommended",
207
213
  ].join("\n");
208
214
  }
@@ -221,12 +227,7 @@ function buildCheckText(runtime: MainWatchdogRuntime, ctx: ExtensionCommandConte
221
227
  }
222
228
  lines.push(`Main thinking: ${mainThinkingLine(snapshot, ctx as ExtensionContext)}`);
223
229
  lines.push(lspLine(snapshot));
224
- try {
225
- const recommendation = recommendStrongWatchdogModel(ctx as ExtensionContext);
226
- lines.push(`Recommended strong watchdog: ${recommendation.model}:${recommendation.thinking}`);
227
- } catch (error) {
228
- lines.push(`Recommended strong watchdog: unavailable (${messageFromError(error)})`);
229
- }
230
+ lines.push(recommendationLine(snapshot, ctx));
230
231
  return lines.join("\n");
231
232
  }
232
233
 
@@ -258,7 +259,7 @@ async function handleWatchdogCommand(
258
259
  }
259
260
  if (input === "recommend-model") {
260
261
  try {
261
- sendSlashText(pi, buildRecommendationText(ctx));
262
+ sendSlashText(pi, buildRecommendationText(runtime, ctx));
262
263
  } catch (error) {
263
264
  sendSlashText(pi, `Subagent watchdog recommended model\n\n${messageFromError(error)}`);
264
265
  }
@@ -301,7 +302,7 @@ async function handleWatchdogCommand(
301
302
  if (input.startsWith("session model ")) {
302
303
  const rawModel = input.slice("session model ".length);
303
304
  try {
304
- const value = resolveModelCommandValue(ctx, rawModel);
305
+ const value = resolveModelCommandValue(runtime, ctx, rawModel);
305
306
  const snapshot = value.model === null
306
307
  ? runtime.clearSessionModel(ctx.cwd)
307
308
  : runtime.setSessionModel({ model: value.model, thinking: value.thinking ?? null }, ctx.cwd);
@@ -319,7 +320,7 @@ async function handleWatchdogCommand(
319
320
  if (input.startsWith("model ")) {
320
321
  const rawModel = input.slice("model ".length);
321
322
  try {
322
- const value = resolveModelCommandValue(ctx, rawModel);
323
+ const value = resolveModelCommandValue(runtime, ctx, rawModel);
323
324
  const settingsPath = writeWatchdogModelSettings({
324
325
  scope: "user",
325
326
  target: { kind: "main" },
@@ -329,7 +330,7 @@ async function handleWatchdogCommand(
329
330
  runtime.refreshConfig(ctx.cwd);
330
331
  const snapshot = runtime.getSnapshot(ctx.cwd);
331
332
  sendSlashText(pi, [
332
- `Subagent watchdog model saved: ${value.description}.`,
333
+ `Subagent watchdog model saved to user settings: ${value.description}. Project and session overrides still take precedence.`,
333
334
  `Updated: ${settingsPath}`,
334
335
  `Main now: ${boolLabel(snapshot.enabled)}`,
335
336
  value.model === null ? "The watchdog now inherits the current session model and thinking." : "Run /subagents-watchdog on if the watchdog is still off.",
@@ -387,6 +388,7 @@ export function registerMainWatchdog(pi: ExtensionAPI, options: RegisterMainWatc
387
388
  reviewDescription: options.review ? "injected seam" : "real model review",
388
389
  reviewChangesOnly: true,
389
390
  displayWarning: (details, options) => pi.sendMessage(createWatchdogWarningMessage(details, { display: true, details }), options),
391
+ displayClarification: (content) => pi.sendMessage({ customType: "subagent_watchdog_clarification", content, display: true }, { deliverAs: "steer", triggerTurn: true }),
390
392
  });
391
393
 
392
394
  pi.registerMessageRenderer<WatchdogWarningDetails>(SUBAGENT_WATCHDOG_WARNING_TYPE, (message, renderOptions, theme) => {
@@ -421,6 +423,8 @@ export function registerMainWatchdog(pi: ExtensionAPI, options: RegisterMainWatc
421
423
  rememberContext(ctx);
422
424
  runtime.handleTurnEnd(event, ctx);
423
425
  });
426
+ pi.on("input", (event) => { if (event.source !== "extension") runtime.handleUserInput(); });
427
+ pi.on("model_select", () => runtime.handleModelChange());
424
428
  pi.on("tool_result", (_event, ctx) => {
425
429
  rememberContext(ctx);
426
430
  runtime.handleToolResult(ctx);
@@ -3,12 +3,13 @@ import { createReadOnlyTools, convertToLlm, type ExtensionContext } from "@earen
3
3
  import { streamSimple } from "@earendil-works/pi-ai/compat";
4
4
  import type { Model, ProviderHeaders } from "@earendil-works/pi-ai";
5
5
  import { Type, type Static } from "typebox";
6
- import { resolveModelCandidate } from "../runs/shared/model-fallback.ts";
6
+ import { buildModelCandidates, isContextOverflow, isRetryableModelFailureAttempt, resolveModelCandidate } from "../runs/shared/model-fallback.ts";
7
7
  import { agentStreamOptions } from "../shared/agent-stream-options.ts";
8
+ import { opencodeSessionHeaders } from "../shared/opencode-session-headers.ts";
8
9
  import { resolveEffectiveThinking, splitKnownThinkingSuffix, THINKING_LEVELS, toModelInfo } from "../shared/model-info.ts";
9
10
  import { createWatchdogDiffTool, WATCHDOG_DIFF_TOOL_NAME, type WatchdogDiffBaseline } from "./diff-tool.ts";
10
11
  import { loadWatchdogGuidance } from "./guidance.ts";
11
- import type { WatchdogReviewFunction, WatchdogReviewRequest } from "./runtime.ts";
12
+ import { boundWatchdogReviewText, type WatchdogReviewFunction, type WatchdogReviewRequest } from "./runtime.ts";
12
13
  import {
13
14
  WATCHDOG_WARNING_CATEGORIES,
14
15
  WATCHDOG_WARNING_CONFIDENCES,
@@ -22,6 +23,8 @@ import {
22
23
 
23
24
  const WATCHDOG_ALLOWED_TOOL_NAMES = new Set(["read", "grep", "find", "ls", "watchdog_warn", WATCHDOG_DIFF_TOOL_NAME]);
24
25
 
26
+ const WatchdogAskParams = Type.Object({ question: Type.String(), evidence: Type.String() }, { additionalProperties: false });
27
+
25
28
  const WatchdogWarnParams = Type.Object({
26
29
  severity: Type.String({ enum: WATCHDOG_WARNING_SEVERITIES, description: "concern for actionable risk, blocker for a likely wrong or unsafe outcome" }),
27
30
  summary: Type.String({ description: "One concise sentence naming the issue." }),
@@ -114,9 +117,16 @@ function resolveConfiguredModel(ctx: ExtensionContext, rawModel: string): { mode
114
117
  return { model, modelString: resolved };
115
118
  }
116
119
 
120
+ class WatchdogAuthError extends Error {}
121
+
117
122
  async function resolveReviewAuth(ctx: ExtensionContext, model: RegistryModel): Promise<WatchdogReviewAuth> {
118
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
119
- if (auth.ok === false) throw new Error(`Watchdog model auth failed for ${fullModelId(model)}: ${auth.error}`);
123
+ let auth;
124
+ try {
125
+ auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
126
+ } catch (error) {
127
+ throw new WatchdogAuthError(`Watchdog model auth failed for ${fullModelId(model)}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
128
+ }
129
+ if (auth.ok === false) throw new WatchdogAuthError(`Watchdog model auth failed for ${fullModelId(model)}: ${auth.error}`);
120
130
  return {
121
131
  ...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
122
132
  ...(auth.headers ? { headers: auth.headers } : {}),
@@ -214,7 +224,7 @@ export function buildWatchdogSystemPrompt(ctx: Pick<ExtensionContext, "cwd">, op
214
224
  "You are the main-session subagent watchdog for Pi.",
215
225
  `Working directory: ${ctx.cwd}`,
216
226
  "Review only the supplied parent turn delta. Inspect repository files only when needed to verify a concrete concern.",
217
- options.hasScope ? "When the review input includes a Current scope block, treat newer scope prompts as superseding/mutating older prompts and use category='scope-drift' for work that serves no current scope item." : undefined,
227
+ options.hasScope ? "Use the Current scope record alongside supplied activity evidence; an unrelated user question does not cancel older authorized work." : undefined,
218
228
  `You are read-only. You may use ${options.hasDiff ? "read, grep, find, ls, and watchdog_diff (the full repo diff since the session baseline; pass a path to narrow it)" : "read, grep, find, and ls"}. Do not edit files, run shell commands, spawn agents, or mutate state.`,
219
229
  "Emit warnings only by calling watchdog_warn. Freeform assistant text is ignored and must not be used to report warnings.",
220
230
  "Emit only medium/high confidence actionable concerns or blockers: missed user constraints, correctness risks, test gaps that matter, unsafe changes, stale facts, loop risks, or scope drift.",
@@ -236,18 +246,6 @@ function buildReviewPrompt(request: WatchdogReviewRequest, selection: WatchdogRe
236
246
  ].join("\n\n");
237
247
  }
238
248
 
239
- function finalStopReason(agent: Agent): "stop" | "error" | "aborted" | "length" {
240
- for (let index = agent.state.messages.length - 1; index >= 0; index--) {
241
- const message = agent.state.messages[index];
242
- if (message && typeof message === "object" && (message as { role?: unknown }).role === "assistant") {
243
- const stopReason = (message as { stopReason?: unknown }).stopReason;
244
- if (stopReason === "error" || stopReason === "aborted" || stopReason === "length") return stopReason;
245
- return "stop";
246
- }
247
- }
248
- return "stop";
249
- }
250
-
251
249
  function resolveContext(provider: WatchdogContextProvider): ExtensionContext | undefined {
252
250
  return typeof provider === "function" ? provider() : provider;
253
251
  }
@@ -256,59 +254,126 @@ export function createMainWatchdogReview(provider: WatchdogContextProvider, opti
256
254
  return async (request) => {
257
255
  const ctx = resolveContext(provider);
258
256
  if (!ctx) throw new Error("Main watchdog review cannot run without an active Pi extension context.");
259
- if (ctx.signal?.aborted || request.signal?.aborted) return { stopReason: "aborted" };
260
- const selection = await resolveWatchdogReviewModel(ctx, request.config, {
261
- currentThinkingLevel: options.getThinkingLevel?.(),
262
- });
263
- if (ctx.signal?.aborted || request.signal?.aborted) return { stopReason: "aborted" };
264
- const auth = selection.auth;
265
- const registeredProvider = (ctx.modelRegistry as {
266
- getRegisteredProviderConfig?: (provider: string) => { api?: string; streamSimple?: StreamFn } | undefined;
267
- }).getRegisteredProviderConfig?.(selection.model.provider);
268
- const baseStreamFn = options.streamFn ?? (registeredProvider?.streamSimple && registeredProvider.api === selection.model.api
269
- ? registeredProvider.streamSimple
270
- : streamSimple);
271
- const streamFn: StreamFn = (model, context, streamOptions) => baseStreamFn(model, context, {
257
+ const aborted = () => ctx.signal?.aborted || request.signal?.aborted;
258
+ if (aborted()) return { stopReason: "aborted" };
259
+ const inherited = !request.config.main.model && ctx.model ? fullModelId(ctx.model) : undefined;
260
+ const candidates = request.config.main.fallbackModels?.length
261
+ ? buildModelCandidates(request.config.main.model ?? inherited, request.config.main.fallbackModels,
262
+ ctx.modelRegistry.getAvailable().map(toModelInfo), ctx.model?.provider, { primaryModelFromParent: Boolean(inherited) })
263
+ : [request.config.main.model];
264
+ for (let index = 0; index < candidates.length; index++) {
265
+ if (aborted()) return { stopReason: "aborted" };
266
+ const candidate = candidates[index];
267
+ const config = { ...request.config, main: { ...request.config.main, model: candidate === inherited ? undefined : candidate } };
268
+ try {
269
+ const attempt = await runWatchdogAttempt(ctx, { ...request, config }, options);
270
+ if (aborted()) return { stopReason: "aborted" };
271
+ if (!attempt.retryable || index === candidates.length - 1) return attempt.result;
272
+ } catch (error) {
273
+ if (aborted()) return { stopReason: "aborted" };
274
+ if (!(error instanceof WatchdogAuthError)) throw error;
275
+ if (isContextOverflow(error.message) || !isRetryableModelFailureAttempt({ error: error.message }) || index === candidates.length - 1) throw error.cause ?? error;
276
+ }
277
+ }
278
+ throw new Error("No usable watchdog model candidates.");
279
+ };
280
+ }
281
+
282
+ async function runWatchdogAttempt(ctx: ExtensionContext, request: WatchdogReviewRequest, options: CreateMainWatchdogReviewOptions): Promise<{
283
+ result: Awaited<ReturnType<WatchdogReviewFunction>>;
284
+ retryable?: boolean;
285
+ }> {
286
+ const selection = await resolveWatchdogReviewModel(ctx, request.config, {
287
+ currentThinkingLevel: options.getThinkingLevel?.(),
288
+ });
289
+ if (ctx.signal?.aborted || request.signal?.aborted) return { result: { stopReason: "aborted" } };
290
+ const auth = selection.auth;
291
+ const registeredProvider = (ctx.modelRegistry as {
292
+ getRegisteredProviderConfig?: (provider: string) => { api?: string; streamSimple?: StreamFn } | undefined;
293
+ }).getRegisteredProviderConfig?.(selection.model.provider);
294
+ const baseStreamFn = options.streamFn ?? (registeredProvider?.streamSimple && registeredProvider.api === selection.model.api
295
+ ? registeredProvider.streamSimple
296
+ : streamSimple);
297
+ const sessionId = ctx.sessionManager.getSessionId();
298
+ const streamFn: StreamFn = (model, context, streamOptions) => {
299
+ // Agent may enter one final loop iteration after an aborted mixed tool batch.
300
+ // Never send that iteration to the provider after an intentional yield.
301
+ if (clarification) throw new Error("Watchdog review yielded for clarification.");
302
+ return baseStreamFn(model, context, {
272
303
  ...streamOptions,
273
304
  ...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
274
305
  env: auth.env || streamOptions?.env ? { ...(auth.env ?? {}), ...(streamOptions?.env ?? {}) } : undefined,
275
- headers: { ...(streamOptions?.headers ?? {}), ...(auth.headers ?? {}) },
276
- });
277
- const diffBaseline = options.diffBaseline?.();
278
- const tools = [
279
- ...(options.createReadOnlyTools ?? createReadOnlyTools)(ctx.cwd).filter((tool) => WATCHDOG_ALLOWED_TOOL_NAMES.has(tool.name) && tool.name !== "watchdog_warn"),
280
- createWatchdogWarnTool(request),
281
- ...(diffBaseline ? [createWatchdogDiffTool(diffBaseline)] : []),
282
- ];
283
- const agent = new Agent({
284
- initialState: {
285
- systemPrompt: buildWatchdogSystemPrompt(ctx, {
286
- hasScope: request.hasScope,
287
- guidance: loadWatchdogGuidance(ctx.cwd, request.config.guidance.watchdogMd),
288
- hasDiff: diffBaseline !== undefined,
289
- }),
290
- model: selection.model,
291
- thinkingLevel: selection.thinkingLevel,
292
- tools,
293
- },
294
- convertToLlm,
295
- ...agentStreamOptions(streamFn),
296
- getApiKey: (providerName) => providerName === selection.model.provider ? auth.apiKey : undefined,
297
- beforeToolCall: async ({ toolCall }) => WATCHDOG_ALLOWED_TOOL_NAMES.has(toolCall.name)
298
- ? undefined
299
- : { block: true, reason: `Watchdog reviews are read-only; tool '${toolCall.name}' is not allowed.` },
300
- toolExecution: "sequential",
306
+ headers: { ...opencodeSessionHeaders(model, sessionId), ...(streamOptions?.headers ?? {}), ...(auth.headers ?? {}) },
301
307
  });
302
- const abort = () => agent.abort();
303
- ctx.signal?.addEventListener("abort", abort, { once: true });
304
- request.signal?.addEventListener("abort", abort, { once: true });
305
- try {
306
- if (ctx.signal?.aborted || request.signal?.aborted) return { stopReason: "aborted" };
307
- await agent.prompt(buildReviewPrompt(request, selection));
308
- } finally {
309
- ctx.signal?.removeEventListener("abort", abort);
310
- request.signal?.removeEventListener("abort", abort);
311
- }
312
- return { stopReason: finalStopReason(agent) };
308
+ };
309
+ const diffBaseline = options.diffBaseline?.();
310
+ let clarification: { question: string; evidence: string } | undefined;
311
+ let warned = false;
312
+ let toolCount = 0;
313
+ const warnRequest = request.allowClarification ? { ...request, emitWarning: (warning: WatchdogWarning) => {
314
+ if (clarification) return false;
315
+ const accepted = request.emitWarning(warning);
316
+ warned ||= accepted;
317
+ return accepted;
318
+ } } : request;
319
+ const tools = [
320
+ ...(options.createReadOnlyTools ?? createReadOnlyTools)(ctx.cwd).filter((tool) => WATCHDOG_ALLOWED_TOOL_NAMES.has(tool.name) && tool.name !== "watchdog_warn"),
321
+ createWatchdogWarnTool(warnRequest),
322
+ ...(diffBaseline ? [createWatchdogDiffTool(diffBaseline)] : []),
323
+ ];
324
+ if (request.allowClarification) tools.push({
325
+ name: "watchdog_ask",
326
+ label: "Watchdog clarification",
327
+ description: "Send one focused question when missing task status, intent or other orchestrator context prevents a concrete review judgment. Yields and ends this review so the orchestrator can handle the message and continue; no answer or follow-up review is required. Never ask for approval or permission, or after recording a warning. Question and evidence are capped at 1000 and 2000 characters.",
328
+ parameters: WatchdogAskParams,
329
+ executionMode: "sequential",
330
+ async execute(_id, rawParams) {
331
+ const params = rawParams as Static<typeof WatchdogAskParams>;
332
+ if (warned || clarification || ctx.signal?.aborted || request.signal?.aborted) throw new Error("Clarification unavailable after warning, yield, or cancellation.");
333
+ if (!params.question.trim() || !params.evidence.trim()) throw new Error("A focused question and concrete evidence are required.");
334
+ clarification = { question: boundWatchdogReviewText(params.question.trim(), 1_000), evidence: boundWatchdogReviewText(params.evidence.trim(), 2_000) };
335
+ agent.abort(); // Intentional yield also stops mixed tool batches; terminate alone does not.
336
+ return { content: [{ type: "text", text: "Review yielded for clarification." }], details: {} };
337
+ },
338
+ });
339
+ const agent = new Agent({
340
+ initialState: {
341
+ systemPrompt: buildWatchdogSystemPrompt(ctx, {
342
+ hasScope: request.hasScope,
343
+ guidance: loadWatchdogGuidance(ctx.cwd, request.config.guidance.watchdogMd),
344
+ hasDiff: diffBaseline !== undefined,
345
+ }),
346
+ model: selection.model,
347
+ thinkingLevel: selection.thinkingLevel,
348
+ tools,
349
+ },
350
+ convertToLlm,
351
+ ...agentStreamOptions(streamFn),
352
+ getApiKey: (providerName) => providerName === selection.model.provider ? auth.apiKey : undefined,
353
+ beforeToolCall: async ({ toolCall }) => !clarification && (WATCHDOG_ALLOWED_TOOL_NAMES.has(toolCall.name) || (request.allowClarification && toolCall.name === "watchdog_ask"))
354
+ ? undefined
355
+ : { block: true, reason: `Watchdog reviews are read-only; tool '${toolCall.name}' is not allowed.` },
356
+ toolExecution: "sequential",
357
+ });
358
+ // Include rejected/invalid calls as well as read-only work and findings.
359
+ agent.subscribe((event) => { if (event.type === "tool_execution_start") toolCount++; });
360
+ const abort = () => agent.abort();
361
+ ctx.signal?.addEventListener("abort", abort, { once: true });
362
+ request.signal?.addEventListener("abort", abort, { once: true });
363
+ try {
364
+ if (ctx.signal?.aborted || request.signal?.aborted) return { result: { stopReason: "aborted" } };
365
+ await agent.prompt(buildReviewPrompt(request, selection));
366
+ } finally {
367
+ ctx.signal?.removeEventListener("abort", abort);
368
+ request.signal?.removeEventListener("abort", abort);
369
+ }
370
+ if (ctx.signal?.aborted || request.signal?.aborted) return { result: { stopReason: "aborted" } };
371
+ const terminal = agent.state.messages.findLast((message) => message.role === "assistant");
372
+ const reason = terminal && "stopReason" in terminal ? terminal.stopReason : undefined;
373
+ const stopReason = reason === "error" || reason === "aborted" || reason === "length" ? reason : "stop";
374
+ const error = terminal && "errorMessage" in terminal && typeof terminal.errorMessage === "string" ? terminal.errorMessage : undefined;
375
+ return {
376
+ result: clarification ? { clarification } : { stopReason },
377
+ retryable: !clarification && stopReason === "error" && !isContextOverflow(error) && isRetryableModelFailureAttempt({ error, messages: agent.state.messages, toolCount }),
313
378
  };
314
379
  }