pi-subagents 0.66.0 → 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 (115) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +4 -3
  3. package/agents/evidence-auditor.md +34 -0
  4. package/agents/reviewer.md +3 -2
  5. package/docs/agents.md +6 -3
  6. package/docs/configuration.md +7 -5
  7. package/docs/extension-api.md +33 -18
  8. package/docs/missions.md +8 -0
  9. package/docs/models.md +1 -1
  10. package/docs/observability.md +4 -4
  11. package/docs/standalone-background.md +49 -0
  12. package/docs/tool-reference.md +18 -8
  13. package/docs/watchdog.md +35 -4
  14. package/docs/workflows.md +26 -12
  15. package/inspector-runner.mjs +2 -2
  16. package/package.json +1 -1
  17. package/prompts/parallel-review.md +1 -1
  18. package/{runner-server-preload.mjs → runner-peer-preload.mjs} +8 -3
  19. package/skills/pi-subagents/SKILL.md +14 -0
  20. package/skills/pi-subagents/references/execution-controls.md +7 -5
  21. package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
  22. package/src/agents/advertised-agent-prompt.ts +34 -3
  23. package/src/agents/agents.ts +6 -0
  24. package/src/agents/builtin-names.ts +1 -0
  25. package/src/api/delegation.ts +4 -0
  26. package/src/api/preflight.ts +76 -45
  27. package/src/api/shared-types.ts +2 -0
  28. package/src/extension/fanout-child.ts +63 -4
  29. package/src/extension/index.ts +20 -8
  30. package/src/extension/public-execution.ts +4 -2
  31. package/src/extension/rpc.ts +4 -0
  32. package/src/extension/schemas.ts +67 -78
  33. package/src/extension/tool-description.ts +29 -82
  34. package/src/inspectors/actions.ts +148 -0
  35. package/src/inspectors/ghostty/actions.ts +74 -0
  36. package/src/inspectors/ghostty/plugin.ts +17 -0
  37. package/src/inspectors/herdr/actions.ts +99 -179
  38. package/src/inspectors/herdr/plugin.ts +20 -0
  39. package/src/inspectors/herdr/project-panes.ts +1 -1
  40. package/src/inspectors/{herdr/inspector-runner.ts → inspector-runner.ts} +12 -12
  41. package/src/inspectors/plugins.ts +8 -0
  42. package/src/inspectors/{herdr/session-roots-codec.ts → session-roots-codec.ts} +3 -14
  43. package/src/inspectors/types.ts +51 -0
  44. package/src/intercom/intercom-bridge.ts +50 -8
  45. package/src/intercom/native-supervisor-channel.ts +22 -13
  46. package/src/runs/background/active-async-capacity.ts +4 -0
  47. package/src/runs/background/async-execution.ts +45 -56
  48. package/src/runs/background/async-resume.ts +5 -9
  49. package/src/runs/background/auto-drain.ts +6 -3
  50. package/src/runs/background/binary-bootstrap.ts +33 -0
  51. package/src/runs/background/fleet-view.ts +30 -2
  52. package/src/runs/background/notify.ts +31 -1
  53. package/src/runs/background/owned-process-tree.ts +29 -2
  54. package/src/runs/background/run-child-session.ts +61 -4
  55. package/src/runs/background/run-status.ts +3 -0
  56. package/src/runs/background/runner-aliases.ts +11 -3
  57. package/src/runs/background/runner-child-launch.ts +2 -0
  58. package/src/runs/background/runner-child-sessions.ts +5 -4
  59. package/src/runs/background/scheduled-runs.ts +40 -13
  60. package/src/runs/background/steering.ts +20 -2
  61. package/src/runs/background/subagent-runner.ts +47 -31
  62. package/src/runs/background/subagent-wait.ts +51 -8
  63. package/src/runs/background/wait-tool.ts +1 -1
  64. package/src/runs/foreground/async-steering-action.ts +18 -7
  65. package/src/runs/foreground/execution.ts +44 -31
  66. package/src/runs/foreground/prompt-audit.ts +3 -1
  67. package/src/runs/foreground/subagent-executor.ts +110 -100
  68. package/src/runs/foreground/workflow-detach-reconcile.ts +2 -0
  69. package/src/runs/foreground/workflow-foreground-steering.ts +2 -1
  70. package/src/runs/shared/acceptance.ts +5 -2
  71. package/src/runs/shared/async-status-projection.ts +4 -0
  72. package/src/runs/shared/capability-ceiling.ts +2 -0
  73. package/src/runs/shared/child-hooks.ts +25 -10
  74. package/src/runs/shared/child-launch.ts +12 -2
  75. package/src/runs/shared/child-lifecycle.ts +6 -3
  76. package/src/runs/shared/child-runtime-config.ts +3 -1
  77. package/src/runs/shared/child-session.ts +33 -2
  78. package/src/runs/shared/child-tool-plan.ts +122 -3
  79. package/src/runs/shared/completion-guard.ts +5 -3
  80. package/src/runs/shared/effective-system-prompt.ts +33 -0
  81. package/src/runs/shared/external-cli-runner.ts +9 -7
  82. package/src/runs/shared/llm-intent-arbiter.ts +12 -3
  83. package/src/runs/shared/model-fallback.ts +2 -0
  84. package/src/runs/shared/orca-progress-tabs.ts +1 -1
  85. package/src/runs/shared/pi-spawn.ts +10 -0
  86. package/src/runs/shared/subagent-prompt-runtime.ts +9 -3
  87. package/src/runs/shared/task-intent.ts +46 -13
  88. package/src/runs/shared/workflow-async-child-guidance.ts +18 -0
  89. package/src/runs/shared/worktree.ts +42 -12
  90. package/src/shared/fork-context.ts +15 -72
  91. package/src/shared/launch-contract.ts +65 -2
  92. package/src/shared/opencode-session-headers.ts +30 -0
  93. package/src/shared/types.ts +4 -1
  94. package/src/slash/delegation-adapters.ts +3 -1
  95. package/src/slash/delegation-request.ts +14 -0
  96. package/src/slash/slash-commands.ts +2 -1
  97. package/src/slash/subagents-admin.ts +11 -4
  98. package/src/tui/fleet-status.ts +164 -19
  99. package/src/tui/fleet.ts +16 -14
  100. package/src/tui/render.ts +149 -28
  101. package/src/watchdog/child-status.ts +8 -0
  102. package/src/watchdog/model-selection.ts +20 -0
  103. package/src/watchdog/permission-arbiter.ts +3 -1
  104. package/src/watchdog/register-child.ts +1 -0
  105. package/src/watchdog/register-main.ts +31 -27
  106. package/src/watchdog/review.ts +132 -67
  107. package/src/watchdog/runtime.ts +82 -20
  108. package/src/watchdog/scope.ts +1 -1
  109. package/src/watchdog/settings.ts +9 -3
  110. package/src/watchdog/tool-actions.ts +13 -12
  111. package/src/watchdog/turn-delta.ts +23 -0
  112. package/src/watchdog/types.ts +4 -0
  113. package/src/workflows/scripted-workflow.ts +237 -7
  114. package/src/workflows/workflow-checklist.ts +2 -2
  115. /package/src/inspectors/{herdr/shell-command.ts → shell-command.ts} +0 -0
@@ -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
  }
@@ -12,7 +12,7 @@ import {
12
12
  import { ruleViolationWarning, type WatchdogRuleViolation } from "./rules.ts";
13
13
  import { WatchdogScopeArtifact } from "./scope.ts";
14
14
  import { resolveWatchdogConfig } from "./settings.ts";
15
- import { formatWatchdogTurnDelta } from "./turn-delta.ts";
15
+ import { formatWatchdogOrchestrationActivity, formatWatchdogTurnDelta } from "./turn-delta.ts";
16
16
  import {
17
17
  type ResolvedWatchdogConfig,
18
18
  type WatchdogEndpointConfig,
@@ -31,6 +31,7 @@ type ReviewStopReason = "stop" | "error" | "aborted" | "length";
31
31
  export interface WatchdogReviewResult {
32
32
  warnings?: WatchdogWarning[];
33
33
  stopReason?: ReviewStopReason;
34
+ clarification?: { question: string; evidence: string };
34
35
  }
35
36
 
36
37
  export interface WatchdogReviewRequest {
@@ -40,6 +41,7 @@ export interface WatchdogReviewRequest {
40
41
  reviewId: number;
41
42
  config: ResolvedWatchdogConfig;
42
43
  emitWarning(warning: WatchdogWarning): boolean;
44
+ allowClarification?: boolean;
43
45
  signal?: AbortSignal;
44
46
  }
45
47
 
@@ -81,6 +83,8 @@ interface MainWatchdogRuntimeOptions {
81
83
  review?: WatchdogReviewFunction;
82
84
  reviewDescription?: string;
83
85
  displayWarning?: (warning: WatchdogWarningDetails, options?: WatchdogWarningSendOptions) => void;
86
+ /** Supplying this main-session delivery capability gates clarification (children omit it). */
87
+ displayClarification?: (content: string) => void;
84
88
  reviewChangesOnly?: boolean;
85
89
  lspDiagnostics?: WatchdogLspDiagnosticsFunction;
86
90
  repoChangeSignature?: typeof computeWatchdogRepoChangeSignature;
@@ -88,8 +92,8 @@ interface MainWatchdogRuntimeOptions {
88
92
 
89
93
  export type WatchdogWarningSendOptions = { deliverAs: "steer" } | { triggerTurn: false };
90
94
 
91
- type ContextLike = Pick<ExtensionContext, "cwd">;
92
- type ReviewDeltaOutcome = "completed" | "timeout" | "stale";
95
+ type ContextLike = Pick<ExtensionContext, "cwd"> & { signal?: AbortSignal };
96
+ type ReviewDeltaOutcome = "completed" | "timeout" | "stale" | NonNullable<WatchdogReviewResult["clarification"]>;
93
97
 
94
98
  const DEFAULT_REVIEW: WatchdogReviewFunction = () => ({ warnings: [] });
95
99
  const MAX_REVIEW_INPUT_CHARS = 24_000;
@@ -170,6 +174,11 @@ export class MainWatchdogRuntime {
170
174
  private activeReviewAbortController: AbortController | undefined;
171
175
  private failedReviews = 0;
172
176
  private staleReviews = 0;
177
+ private readonly displayClarification: MainWatchdogRuntimeOptions["displayClarification"];
178
+ private askedThisPrompt = false;
179
+ private activityTail = "";
180
+ private activityPending = false;
181
+ private activityReviewUsed = false;
173
182
 
174
183
  constructor(options: MainWatchdogRuntimeOptions = {}) {
175
184
  this.cwd = options.cwd ?? process.cwd();
@@ -178,6 +187,7 @@ export class MainWatchdogRuntime {
178
187
  this.reviewConnected = Boolean(options.review);
179
188
  this.reviewDescription = options.reviewDescription ?? (options.review ? "injected seam" : "not wired");
180
189
  this.displayWarning = options.displayWarning;
190
+ this.displayClarification = options.displayClarification;
181
191
  this.reviewChangesOnly = options.reviewChangesOnly === true;
182
192
  this.lspDiagnostics = options.lspDiagnostics ?? collectWatchdogLspDiagnostics;
183
193
  this.repoChangeSignature = options.repoChangeSignature ?? computeWatchdogRepoChangeSignature;
@@ -200,6 +210,8 @@ export class MainWatchdogRuntime {
200
210
  refreshConfig(cwd = this.cwd): WatchdogSettingsResult {
201
211
  this.cwd = cwd;
202
212
  const wasEnabled = this.isEnabled();
213
+ const hadClarification = this.configResult.config.clarification;
214
+ const previousMain = this.configResult.config.main;
203
215
  const session = this.sessionOverrideEnabled === undefined && this.sessionModelOverride === undefined
204
216
  ? undefined
205
217
  : {
@@ -215,6 +227,10 @@ export class MainWatchdogRuntime {
215
227
  this.guard = new WatchdogEmissionGuard({ maxWarnings: this.guardMaxWarnings });
216
228
  }
217
229
  if (wasEnabled && !this.isEnabled()) this.invalidateActiveReview("watchdog disabled");
230
+ if (!this.isEnabled() || !this.configResult.config.clarification) this.clearActivity();
231
+ if (this.displayClarification && (hadClarification || this.configResult.config.clarification) && (this.reviewing || this.waitingAtAgentEnd)) {
232
+ if (!this.configResult.config.clarification || previousMain.model !== this.configResult.config.main.model || previousMain.thinking !== this.configResult.config.main.thinking) this.invalidateActiveReview("clarification configuration changed");
233
+ }
218
234
  return this.configResult;
219
235
  }
220
236
 
@@ -253,6 +269,7 @@ export class MainWatchdogRuntime {
253
269
  }
254
270
 
255
271
  reset(_reason = "reset", options: { clearReviewInputSignature?: boolean; resetChangeSignature?: boolean; clearLspLedger?: boolean; clearScope?: boolean } = {}): void {
272
+ this.activeReviewAbortController?.abort();
256
273
  this.abortActiveAgentEnd();
257
274
  this.epoch++;
258
275
  this.status = "idle";
@@ -273,7 +290,10 @@ export class MainWatchdogRuntime {
273
290
  this.lspLedger.reset();
274
291
  this.lastLspSnapshot = undefined;
275
292
  }
276
- if (options.clearScope) this.scope.reset();
293
+ if (options.clearScope) {
294
+ this.scope.reset();
295
+ this.clearActivity();
296
+ }
277
297
  if (options.clearReviewInputSignature) this.lastReviewInputSignature = undefined;
278
298
  if (options.resetChangeSignature) this.resetRepoChangeBaseline({ reviewed: true });
279
299
  this.guard.reset();
@@ -281,6 +301,8 @@ export class MainWatchdogRuntime {
281
301
  }
282
302
 
283
303
  dispose(): void {
304
+ this.clearActivity();
305
+ this.activeReviewAbortController?.abort();
284
306
  this.disposed = true;
285
307
  this.abortActiveAgentEnd();
286
308
  this.epoch++;
@@ -305,6 +327,8 @@ export class MainWatchdogRuntime {
305
327
  if (this.disposed) return;
306
328
  const incomingPrompt = promptFromBeforeAgentStart(event);
307
329
  this.reset("before_agent_start");
330
+ this.askedThisPrompt = false;
331
+ this.activityReviewUsed = false;
308
332
  this.resetBoundaryRepeats();
309
333
  this.refreshConfig(ctx.cwd);
310
334
  this.userPrompt = incomingPrompt;
@@ -322,6 +346,13 @@ export class MainWatchdogRuntime {
322
346
  this.refreshConfig(ctx.cwd);
323
347
  if (!this.isEnabled()) return;
324
348
  try {
349
+ if (this.displayClarification && this.configResult.config.clarification && this.boundaryRepeats === 0) {
350
+ const activity = formatWatchdogOrchestrationActivity(event);
351
+ if (activity) {
352
+ this.activityTail = [this.activityTail, boundWatchdogReviewText(activity, 3_000)].filter(Boolean).join(REVIEW_DELTA_SEPARATOR).slice(-6_000);
353
+ this.activityPending = true;
354
+ }
355
+ }
325
356
  this.observedRepoEditThisTurn ||= eventIndicatesRepoEdit(event);
326
357
  const delta = formatWatchdogTurnDelta({
327
358
  includeUserPrompt: this.includeUserPromptInNextDelta,
@@ -359,20 +390,19 @@ export class MainWatchdogRuntime {
359
390
  if (this.disposed) return;
360
391
  this.refreshConfig(ctx.cwd);
361
392
  if (!this.isEnabled()) return;
362
- const changeSignature = this.resolveReviewChangeSignature(ctx.cwd);
363
- if (this.reviewChangesOnly && !changeSignature) {
364
- this.clearPendingDeltas();
365
- if (this.status === "queued") this.status = "idle";
366
- this.resolveWaiters(true);
367
- return;
368
- }
369
- if (changeSignature && changeSignature.key === this.lastReviewedChangeSignature) {
393
+ if (ctx.signal?.aborted) return;
394
+ const changeSignature = this.resolveReviewChangeSignature(this.currentRepoChangeSignature(ctx.cwd), ctx.cwd);
395
+ const activityReview = Boolean(this.displayClarification && this.configResult.config.clarification && !this.activityReviewUsed && this.boundaryRepeats === 0 && this.activityPending);
396
+ const knownEvidence = changeSignature && changeSignature.key === this.lastReviewedChangeSignature;
397
+ if (!activityReview && (knownEvidence || (this.reviewChangesOnly && !changeSignature))) {
370
398
  this.clearPendingDeltas();
371
- this.status = "idle";
399
+ if (knownEvidence || this.status === "queued") this.status = "idle";
372
400
  this.resolveWaiters(true);
373
401
  return;
374
402
  }
375
403
  this.cancelMidRunReview();
404
+ if (activityReview && (!changeSignature || knownEvidence)) this.activityReviewUsed = true;
405
+ this.activityPending = false;
376
406
  this.waitingAtAgentEnd = true;
377
407
  const agentEndEpoch = this.epoch;
378
408
  const agentEndId = ++this.agentEndIdCounter;
@@ -403,7 +433,8 @@ export class MainWatchdogRuntime {
403
433
  this.resolveWaiters(true);
404
434
  return;
405
435
  }
406
- const outcome = await this.reviewDelta(delta, this.configResult.config.agentEndTimeoutMs);
436
+ const outcome = await this.reviewDelta(delta, this.configResult.config.agentEndTimeoutMs, { allowClarification: true });
437
+ if (!this.isAgentEndCurrent(agentEndEpoch, agentEndId)) return;
407
438
  this.waitingAtAgentEnd = false;
408
439
  if (outcome === "timeout") {
409
440
  this.staleReviews++;
@@ -413,11 +444,16 @@ export class MainWatchdogRuntime {
413
444
  this.resolveWaiters(true);
414
445
  return;
415
446
  }
416
- if (outcome === "completed" && this.status !== "failed" && this.status !== "stale") {
447
+ if ((outcome === "completed" || typeof outcome === "object") && this.status !== "failed" && this.status !== "stale") {
448
+ // A question consumes this evidence just like a completed review, not a pending exchange.
417
449
  this.lastReviewInputSignature = signature;
418
450
  if (changeSignature) this.lastReviewedChangeSignature = changeSignature.key;
419
451
  this.currentChangedPaths = changeSignature?.changedPaths;
420
452
  this.status = "idle";
453
+ if (typeof outcome === "object" && !ctx.signal?.aborted) this.displayClarification?.([
454
+ "Main watchdog clarification:", outcome.question, `Evidence: ${outcome.evidence}`,
455
+ "Consider this missing context as you continue the task. This is not approval, permission, or a warning. The reviewer has yielded; no reply or follow-up review is required.",
456
+ ].join("\n"));
421
457
  }
422
458
  this.resolveWaiters(true);
423
459
  } finally {
@@ -468,6 +504,15 @@ export class MainWatchdogRuntime {
468
504
  };
469
505
  }
470
506
 
507
+ /** A real mid-stream user input can steer without emitting before_agent_start. */
508
+ handleUserInput(): void {
509
+ if (this.displayClarification && this.configResult.config.clarification && (this.reviewing || this.waitingAtAgentEnd)) this.reset("new user input");
510
+ }
511
+
512
+ handleModelChange(): void {
513
+ if (this.displayClarification && this.configResult.config.clarification && (this.reviewing || this.waitingAtAgentEnd)) this.reset("model changed");
514
+ }
515
+
471
516
  async waitForIdle(timeoutMs = 1_000): Promise<boolean> {
472
517
  return this.waitForSettled(timeoutMs);
473
518
  }
@@ -523,6 +568,7 @@ export class MainWatchdogRuntime {
523
568
  }
524
569
 
525
570
  private invalidateActiveReview(_reason: string): void {
571
+ this.activeReviewAbortController?.abort();
526
572
  this.abortActiveAgentEnd();
527
573
  this.epoch++;
528
574
  this.status = "idle";
@@ -567,7 +613,7 @@ export class MainWatchdogRuntime {
567
613
  this.activeReviewWarning = undefined;
568
614
  }
569
615
 
570
- private async reviewDelta(delta: string, timeoutMs: number, options: { correction?: boolean } = {}): Promise<ReviewDeltaOutcome> {
616
+ private async reviewDelta(delta: string, timeoutMs: number, options: { correction?: boolean; allowClarification?: boolean } = {}): Promise<ReviewDeltaOutcome> {
571
617
  if (this.reviewing || this.disposed) return "stale";
572
618
  this.reviewing = true;
573
619
  const reviewEpoch = this.epoch;
@@ -578,6 +624,7 @@ export class MainWatchdogRuntime {
578
624
  let timeout: ReturnType<typeof setTimeout> | undefined;
579
625
  const abortController = new AbortController();
580
626
  this.activeReviewAbortController = abortController;
627
+ const allowClarification = Boolean(options.allowClarification && this.displayClarification && this.configResult.config.clarification && !this.askedThisPrompt && !this.stalemate);
581
628
  const reviewPromise = Promise.resolve().then(() => this.review({
582
629
  delta,
583
630
  epoch: reviewEpoch,
@@ -586,6 +633,7 @@ export class MainWatchdogRuntime {
586
633
  hasScope: this.scopeBlock().trim().length > 0,
587
634
  signal: abortController.signal,
588
635
  emitWarning: (warning) => this.acceptWarning(reviewEpoch, reviewId, warning),
636
+ ...(allowClarification ? { allowClarification: true } : {}),
589
637
  }));
590
638
  try {
591
639
  const result = await Promise.race([
@@ -600,6 +648,14 @@ export class MainWatchdogRuntime {
600
648
  }
601
649
  if (!this.isCurrent(reviewEpoch, reviewId)) return "stale";
602
650
  if (!result) return "stale";
651
+ if (allowClarification && this.configResult.config.clarification && result.clarification && (!result.stopReason || result.stopReason === "stop") && !this.activeReviewWarning && !result.warnings?.length && !abortController.signal.aborted) {
652
+ const question = result.clarification.question.trim();
653
+ const evidence = result.clarification.evidence.trim();
654
+ if (question && evidence) {
655
+ this.askedThisPrompt = true;
656
+ return { question: boundWatchdogReviewText(question, 1_000), evidence: boundWatchdogReviewText(evidence, 2_000) };
657
+ }
658
+ }
603
659
  for (const warning of result.warnings ?? []) this.acceptWarning(reviewEpoch, reviewId, warning);
604
660
  if (result.stopReason && result.stopReason !== "stop") {
605
661
  this.fail(`Watchdog review ended with stop reason '${result.stopReason}'.`);
@@ -681,9 +737,8 @@ export class MainWatchdogRuntime {
681
737
  this.observedRepoEditThisTurn = false;
682
738
  }
683
739
 
684
- private resolveReviewChangeSignature(cwd = this.cwd): WatchdogRepoChangeSignature | undefined {
740
+ private resolveReviewChangeSignature(current: WatchdogRepoChangeSignature | undefined, cwd: string): WatchdogRepoChangeSignature | undefined {
685
741
  if (!this.reviewChangesOnly) return undefined;
686
- const current = this.currentRepoChangeSignature(cwd);
687
742
  if (current) {
688
743
  this.currentChangedPaths = current.changedPaths;
689
744
  if (current.key === this.turnStartChangeSignature?.key) return undefined;
@@ -785,13 +840,14 @@ export class MainWatchdogRuntime {
785
840
  const changes = changeSignature?.changedPaths.length
786
841
  ? ["Changed repo paths:", ...changeSignature.changedPaths.slice(0, 200).map((file) => `- ${file}`)].join("\n")
787
842
  : "";
788
- const contextPieces = [scopeBlock, changes, lspBlock].filter(Boolean);
843
+ const activity = this.activityTail ? `Recent delivered orchestration activity (oldest first; bounded observations, not a task board; waits/holds are not evidence of neglect):\n${this.activityTail}` : "";
844
+ const contextPieces = [scopeBlock, changes, lspBlock, activity].filter(Boolean);
789
845
  if (!contextPieces.length) return boundWatchdogReviewText(input);
790
846
 
791
847
  const maxContextLength = Math.floor(MAX_REVIEW_INPUT_CHARS / 2);
792
848
  const maxPieceLength = Math.max(1_000, Math.floor(maxContextLength / contextPieces.length));
793
849
  const boundedContext = contextPieces.map((piece) => piece.length > maxPieceLength
794
- ? `${piece.slice(0, maxPieceLength - 6)}\n- ...`
850
+ ? piece === activity ? boundWatchdogReviewText(piece, maxPieceLength) : `${piece.slice(0, maxPieceLength - 6)}\n- ...`
795
851
  : piece).join(REVIEW_DELTA_SEPARATOR);
796
852
  const separatorLength = input ? REVIEW_DELTA_SEPARATOR.length : 0;
797
853
  const inputBudget = MAX_REVIEW_INPUT_CHARS - boundedContext.length - separatorLength;
@@ -808,6 +864,12 @@ export class MainWatchdogRuntime {
808
864
  this.pendingDeltaChars = 0;
809
865
  }
810
866
 
867
+ private clearActivity(): void {
868
+ if (!this.activityTail) return;
869
+ this.activityTail = "";
870
+ this.activityPending = false;
871
+ }
872
+
811
873
  private fail(message: string): void {
812
874
  this.failedReviews++;
813
875
  this.lastError = message;
@@ -32,7 +32,7 @@ export class WatchdogScopeArtifact {
32
32
  if (!this.entries.length) return "";
33
33
  return [
34
34
  "Current scope:",
35
- "The following real user prompts are the current scope record, newest last. Newer prompts supersede and mutate older prompts: they may add, modify, or remove requirements. Flag work that serves no current scope item as category 'scope-drift'.",
35
+ "The following real user prompts are the current scope record, newest last. Side questions are additive, not scope drift or cancellation of older objectives; only explicit changes supersede requirements. Use watchdog_warn for evidence-backed reminders of forgotten authorized work, not dependencies still pending or explicit holds. The orchestrator owns task tracking. Flag unauthorized work as category 'scope-drift'.",
36
36
  ...this.entries.map((entry, index) => [
37
37
  `Scope prompt ${index + 1} (${entry.createdAt}):`,
38
38
  entry.prompt,
@@ -61,6 +61,7 @@ interface ParseMeta {
61
61
 
62
62
  export const DEFAULT_WATCHDOG_CONFIG: ResolvedWatchdogConfig = {
63
63
  enabled: false,
64
+ clarification: false,
64
65
  agentEndTimeoutMs: 30_000,
65
66
  severityThreshold: "concern",
66
67
  maxWarnings: null,
@@ -92,6 +93,7 @@ export const DEFAULT_WATCHDOG_CONFIG: ResolvedWatchdogConfig = {
92
93
 
93
94
  const WATCHDOG_FIELDS = new Set([
94
95
  "enabled",
96
+ "clarification",
95
97
  "agentEndTimeoutMs",
96
98
  "severityThreshold",
97
99
  "maxWarnings",
@@ -109,9 +111,9 @@ const ROLE_MODEL_RULE_FIELDS = new Set(["allow", "deny", "note"]);
109
111
  const GUIDANCE_FIELDS = new Set(["watchdogMd"]);
110
112
  const SCOPE_FIELDS = new Set(["enabled"]);
111
113
  const CADENCE_FIELDS = new Set(["everyNTools"]);
112
- const ENDPOINT_FIELDS = new Set(["enabled", "model", "thinking"]);
113
- const CHILDREN_FIELDS = new Set(["enabled", "model", "thinking", "watchdogTailTimeoutMs", "cadence", "overrides"]);
114
- const CHILD_OVERRIDE_FIELDS = new Set(["enabled", "model", "thinking", "cadence"]);
114
+ const ENDPOINT_FIELDS = new Set(["enabled", "model", "fallbackModels", "thinking"]);
115
+ const CHILDREN_FIELDS = new Set(["enabled", "model", "fallbackModels", "thinking", "watchdogTailTimeoutMs", "cadence", "overrides"]);
116
+ const CHILD_OVERRIDE_FIELDS = new Set(["enabled", "model", "fallbackModels", "thinking", "cadence"]);
115
117
  const LSP_FIELDS = new Set(["enabled", "timeoutMs", "maxFiles", "maxDiagnostics"]);
116
118
 
117
119
  function cloneDefaultConfig(): ResolvedWatchdogConfig {
@@ -219,6 +221,7 @@ function parseEndpointPatch(value: unknown, field: string, meta: ParseMeta): Wat
219
221
  const input = parseObject(value, field, meta);
220
222
  assertKnownFields(input, ENDPOINT_FIELDS, field, meta);
221
223
  const patch: WatchdogEndpointPatch = {};
224
+ if ("fallbackModels" in input) patch.fallbackModels = parseStringList(input.fallbackModels, `${field}.fallbackModels`, meta);
222
225
  if ("enabled" in input) patch.enabled = parseBoolean(input.enabled, `${field}.enabled`, meta);
223
226
  if ("model" in input) patch.model = parseNonEmptyString(input.model, `${field}.model`, meta);
224
227
  if ("thinking" in input) patch.thinking = parseThinking(input.thinking, `${field}.thinking`, meta);
@@ -229,6 +232,7 @@ function parseChildOverridePatch(value: unknown, field: string, meta: ParseMeta)
229
232
  const input = parseObject(value, field, meta);
230
233
  assertKnownFields(input, CHILD_OVERRIDE_FIELDS, field, meta);
231
234
  const patch: WatchdogChildOverridePatch = {};
235
+ if ("fallbackModels" in input) patch.fallbackModels = parseStringList(input.fallbackModels, `${field}.fallbackModels`, meta);
232
236
  if ("enabled" in input) patch.enabled = parseBoolean(input.enabled, `${field}.enabled`, meta);
233
237
  if ("model" in input) patch.model = parseNonEmptyString(input.model, `${field}.model`, meta);
234
238
  if ("thinking" in input) patch.thinking = parseThinking(input.thinking, `${field}.thinking`, meta);
@@ -240,6 +244,7 @@ function parseChildrenPatch(value: unknown, field: string, meta: ParseMeta): Wat
240
244
  const input = parseObject(value, field, meta);
241
245
  assertKnownFields(input, CHILDREN_FIELDS, field, meta);
242
246
  const patch: WatchdogChildrenPatch = {};
247
+ if ("fallbackModels" in input) patch.fallbackModels = parseStringList(input.fallbackModels, `${field}.fallbackModels`, meta);
243
248
  if ("enabled" in input) patch.enabled = parseBoolean(input.enabled, `${field}.enabled`, meta);
244
249
  if ("model" in input) patch.model = parseNonEmptyString(input.model, `${field}.model`, meta);
245
250
  if ("thinking" in input) patch.thinking = parseThinking(input.thinking, `${field}.thinking`, meta);
@@ -303,6 +308,7 @@ function parseWatchdogPatch(value: unknown, field: string, meta: ParseMeta): Wat
303
308
  assertKnownFields(input, WATCHDOG_FIELDS, field, meta);
304
309
  const patch: WatchdogConfigPatch = {};
305
310
  if ("enabled" in input) patch.enabled = parseBoolean(input.enabled, `${field}.enabled`, meta);
311
+ if ("clarification" in input) patch.clarification = parseBoolean(input.clarification, `${field}.clarification`, meta);
306
312
  if ("agentEndTimeoutMs" in input) {
307
313
  patch.agentEndTimeoutMs = parseInteger(input.agentEndTimeoutMs, `${field}.agentEndTimeoutMs`, meta, "a positive integer", (candidate) => candidate >= 1);
308
314
  }