pi-subagents 0.63.0 → 0.65.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 (107) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/README.md +2 -2
  3. package/agents/reviewer.md +1 -1
  4. package/agents/scout.md +1 -1
  5. package/docs/agents.md +18 -16
  6. package/docs/configuration.md +7 -15
  7. package/docs/extension-api.md +15 -6
  8. package/docs/missions.md +2 -0
  9. package/docs/observability.md +10 -12
  10. package/docs/tool-reference.md +7 -6
  11. package/docs/watchdog.md +92 -114
  12. package/docs/workflows.md +5 -3
  13. package/package.json +3 -4
  14. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
  15. package/skills/pi-subagents/references/execution-controls.md +6 -5
  16. package/src/agents/agent-management.ts +47 -15
  17. package/src/api/capability-ceiling.ts +0 -1
  18. package/src/api/{pi-args.ts → child-tool-plan.ts} +1 -1
  19. package/src/api/preflight.ts +2 -3
  20. package/src/extension/doctor.ts +2 -10
  21. package/src/extension/fanout-child.ts +9 -11
  22. package/src/extension/index.ts +27 -5
  23. package/src/extension/public-execution.ts +14 -0
  24. package/src/extension/rpc.ts +3 -2
  25. package/src/extension/schemas.ts +2 -1
  26. package/src/extension/tool-description.ts +9 -8
  27. package/src/intercom/native-supervisor-channel.ts +138 -60
  28. package/src/intercom/supervisor-ui.ts +243 -0
  29. package/src/runs/background/async-execution.ts +46 -24
  30. package/src/runs/background/async-job-tracker.ts +11 -0
  31. package/src/runs/background/async-resume.ts +11 -2
  32. package/src/runs/background/control-channel.ts +2 -204
  33. package/src/runs/background/notify.ts +41 -2
  34. package/src/runs/background/process-terminal.ts +1 -1
  35. package/src/runs/background/run-child-session.ts +613 -0
  36. package/src/runs/background/run-status.ts +0 -1
  37. package/src/runs/background/runner-aliases.ts +125 -0
  38. package/src/runs/background/runner-child-sessions.ts +31 -0
  39. package/src/runs/background/scheduled-runs.ts +18 -4
  40. package/src/runs/background/subagent-runner.ts +203 -898
  41. package/src/runs/foreground/async-steering-action.ts +1 -17
  42. package/src/runs/foreground/execution.ts +196 -378
  43. package/src/runs/foreground/foreground-control.ts +4 -0
  44. package/src/runs/foreground/subagent-executor.ts +121 -58
  45. package/src/runs/foreground/workflow-foreground-steering.ts +24 -98
  46. package/src/runs/shared/abort-recovery.ts +3 -3
  47. package/src/runs/shared/acceptance.ts +10 -0
  48. package/src/runs/shared/async-status-projection.ts +11 -43
  49. package/src/runs/shared/capability-ceiling.ts +1 -2
  50. package/src/runs/shared/child-hooks.ts +25 -0
  51. package/src/runs/shared/child-identity.ts +13 -2
  52. package/src/runs/shared/child-launch.ts +314 -0
  53. package/src/runs/shared/child-lifecycle.ts +25 -0
  54. package/src/runs/shared/child-runtime-config.ts +126 -0
  55. package/src/runs/shared/child-session.ts +342 -0
  56. package/src/runs/shared/child-tool-plan.ts +530 -0
  57. package/src/runs/shared/claude-code-adapter.ts +5 -1
  58. package/src/runs/shared/completion-guard.ts +1 -1
  59. package/src/runs/shared/external-cli-preflight.ts +16 -0
  60. package/src/runs/shared/mcp-direct-tool-allowlist.ts +5 -4
  61. package/src/runs/shared/model-exclusions.ts +82 -14
  62. package/src/runs/shared/model-fallback.ts +47 -4
  63. package/src/runs/shared/nested-events.ts +29 -45
  64. package/src/runs/shared/nested-path.ts +0 -14
  65. package/src/runs/shared/orca-progress-tabs.ts +12 -7
  66. package/src/runs/shared/parallel-utils.ts +0 -2
  67. package/src/runs/shared/permissions.ts +0 -13
  68. package/src/runs/shared/process-signal.ts +4 -1
  69. package/src/runs/shared/run-fanout-budget.ts +0 -13
  70. package/src/runs/shared/runtime-acknowledged-extensions.ts +0 -27
  71. package/src/runs/shared/structured-output.ts +17 -4
  72. package/src/runs/shared/subagent-control.ts +6 -2
  73. package/src/runs/shared/subagent-prompt-runtime.ts +87 -384
  74. package/src/runs/shared/tool-availability.ts +18 -62
  75. package/src/runs/shared/tool-budget.ts +0 -14
  76. package/src/runs/shared/worktree-cleanup-plan.ts +25 -6
  77. package/src/runs/shared/worktree.ts +117 -30
  78. package/src/shared/child-session-name.ts +1 -1
  79. package/src/shared/jsonl-writer.ts +11 -0
  80. package/src/shared/thinking-ceiling.ts +0 -6
  81. package/src/shared/types.ts +64 -30
  82. package/src/shared/utils.ts +3 -4
  83. package/src/slash/slash-commands.ts +0 -6
  84. package/src/tui/fleet.ts +0 -1
  85. package/src/tui/render.ts +238 -31
  86. package/src/watchdog/child-status.ts +54 -34
  87. package/src/watchdog/diff-tool.ts +77 -0
  88. package/src/watchdog/emission-guard.ts +5 -3
  89. package/src/watchdog/guidance.ts +20 -0
  90. package/src/watchdog/register-child.ts +28 -25
  91. package/src/watchdog/register-main.ts +10 -9
  92. package/src/watchdog/render.ts +4 -5
  93. package/src/watchdog/review.ts +15 -4
  94. package/src/watchdog/rules.ts +70 -0
  95. package/src/watchdog/runtime.ts +75 -92
  96. package/src/watchdog/scope.ts +0 -11
  97. package/src/watchdog/settings.ts +48 -104
  98. package/src/watchdog/types.ts +18 -32
  99. package/src/watchdog/warning-format.ts +0 -1
  100. package/src/workflows/chat-progress.ts +3 -2
  101. package/src/workflows/scripted-workflow.ts +48 -1
  102. package/src/workflows/workflow-checklist.ts +10 -12
  103. package/src/workflows/workflow-preflight.ts +28 -1
  104. package/src/runs/shared/child-protocol.ts +0 -415
  105. package/src/runs/shared/pi-args.ts +0 -1062
  106. package/src/runs/shared/subagent-startup-retry.ts +0 -116
  107. package/src/shared/post-exit-stdio-guard.ts +0 -85
@@ -1,13 +1,14 @@
1
- import type { ResolvedWatchdogConfig, WatchdogLspConfig } from "./types.ts";
1
+ import type { ChildWatchdogProgress, ChildWatchdogWarningSummary } from "../shared/types.ts";
2
+ import { SUBAGENT_WATCHDOG_WARNING_TYPE, type ResolvedWatchdogConfig, type WatchdogCadenceConfig, type WatchdogCategory, type WatchdogLspConfig } from "./types.ts";
3
+
4
+ export const CHILD_WATCHDOG_WARNING_LIMIT = 20;
2
5
 
3
- export const CHILD_WATCHDOG_CONFIG_ENV = "PI_SUBAGENT_WATCHDOG_CHILD_CONFIG";
4
6
  export const CHILD_WATCHDOG_STATUS_EVENT = "subagent.watchdog.status";
5
7
 
6
- export const CHILD_WATCHDOG_PHASES = ["idle", "reviewing", "autofollow", "settling", "stale", "failed"] as const;
8
+ export const CHILD_WATCHDOG_PHASES = ["idle", "reviewing", "stale", "failed"] as const;
7
9
  export type ChildWatchdogPhase = typeof CHILD_WATCHDOG_PHASES[number];
8
10
 
9
11
  export interface ChildWatchdogConfig {
10
- enabled: boolean;
11
12
  runId?: string;
12
13
  agent?: string;
13
14
  childIndex?: number;
@@ -17,9 +18,9 @@ export interface ChildWatchdogConfig {
17
18
  model?: string;
18
19
  thinking?: string | false;
19
20
  lsp: WatchdogLspConfig;
20
- autoFollowBlockers: boolean;
21
- autoFollowMaxAttempts: number | null;
22
21
  stalemateRepeats: number;
22
+ /** Mid-run review cadence; everyNTools null means boundary reviews only. */
23
+ cadence: WatchdogCadenceConfig;
23
24
  }
24
25
 
25
26
  export interface ChildWatchdogStatusEvent {
@@ -31,18 +32,10 @@ export interface ChildWatchdogStatusEvent {
31
32
  seq: number;
32
33
  phase: ChildWatchdogPhase;
33
34
  ts: number;
34
- followUpPending: boolean;
35
35
  reason?: string;
36
36
  }
37
37
 
38
- export interface ChildWatchdogStateSnapshot {
39
- phase: ChildWatchdogPhase;
40
- seq: number;
41
- lastUpdate: number;
42
- followUpPending: boolean;
43
- reason?: string;
44
- timedOut?: boolean;
45
- }
38
+ export type ChildWatchdogStateSnapshot = ChildWatchdogProgress;
46
39
 
47
40
  export function resolveChildWatchdogConfig(input: {
48
41
  config: ResolvedWatchdogConfig;
@@ -55,8 +48,8 @@ export function resolveChildWatchdogConfig(input: {
55
48
  if (!enabled) return undefined;
56
49
  const model = override?.model ?? input.config.children.model;
57
50
  const thinking = override?.thinking ?? input.config.children.thinking;
51
+ const cadence = override?.cadence ?? input.config.children.cadence ?? input.config.cadence;
58
52
  return {
59
- enabled: true,
60
53
  ...(input.runId ? { runId: input.runId } : {}),
61
54
  ...(input.agent ? { agent: input.agent } : {}),
62
55
  ...(input.childIndex !== undefined ? { childIndex: input.childIndex } : {}),
@@ -66,16 +59,11 @@ export function resolveChildWatchdogConfig(input: {
66
59
  ...(model ? { model } : {}),
67
60
  ...(thinking !== undefined ? { thinking } : {}),
68
61
  lsp: { ...input.config.lsp },
69
- autoFollowBlockers: input.config.children.autoFollow.blockers,
70
- autoFollowMaxAttempts: input.config.children.autoFollow.maxAttempts,
71
- stalemateRepeats: input.config.children.autoFollow.stalemateRepeats,
62
+ stalemateRepeats: input.config.stalemateRepeats,
63
+ cadence: { everyNTools: cadence.everyNTools ?? null },
72
64
  };
73
65
  }
74
66
 
75
- export function encodeChildWatchdogConfig(config: ChildWatchdogConfig | undefined): string | undefined {
76
- return config ? JSON.stringify(config) : undefined;
77
- }
78
-
79
67
  function childConfigObject(value: unknown, field: string): Record<string, unknown> {
80
68
  if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
81
69
  throw new Error(`Invalid child watchdog config: ${field} must be an object.`);
@@ -108,10 +96,12 @@ function childConfigNullableNonNegativeInteger(input: Record<string, unknown>, f
108
96
  throw new Error(`Invalid child watchdog config: ${field} must be null or a non-negative integer.`);
109
97
  }
110
98
 
111
- function childConfigBoolean(input: Record<string, unknown>, field: string): boolean {
112
- const value = input[field];
113
- if (typeof value === "boolean") return value;
114
- throw new Error(`Invalid child watchdog config: ${field} must be a boolean.`);
99
+ function childConfigCadence(value: unknown): WatchdogCadenceConfig {
100
+ const input = childConfigObject(value, "cadence");
101
+ const everyNTools = input.everyNTools;
102
+ if (everyNTools === null) return { everyNTools: null };
103
+ if (typeof everyNTools === "number" && Number.isInteger(everyNTools) && everyNTools >= 5) return { everyNTools };
104
+ throw new Error("Invalid child watchdog config: cadence.everyNTools must be null or an integer >= 5.");
115
105
  }
116
106
 
117
107
  function childConfigLsp(value: unknown): WatchdogLspConfig {
@@ -138,7 +128,7 @@ export function decodeChildWatchdogConfig(raw: string | undefined): ChildWatchdo
138
128
  if (!raw) return undefined;
139
129
  const parsed = childConfigObject(JSON.parse(raw), "root");
140
130
  if (parsed.enabled === false) return undefined;
141
- if (parsed.enabled !== true) throw new Error("Invalid child watchdog config: enabled must be true or false.");
131
+ if ("enabled" in parsed && parsed.enabled !== true) throw new Error("Invalid child watchdog config: enabled must be true or false.");
142
132
  const thinking = parsed.thinking;
143
133
  if (thinking !== undefined && typeof thinking !== "string" && thinking !== false) {
144
134
  throw new Error("Invalid child watchdog config: thinking must be a string or false.");
@@ -148,7 +138,6 @@ export function decodeChildWatchdogConfig(raw: string | undefined): ChildWatchdo
148
138
  const childIndex = childConfigOptionalIndex(parsed, "childIndex");
149
139
  const model = childConfigOptionalString(parsed, "model");
150
140
  return {
151
- enabled: true,
152
141
  ...(runId ? { runId } : {}),
153
142
  ...(agent ? { agent } : {}),
154
143
  ...(childIndex !== undefined ? { childIndex } : {}),
@@ -158,9 +147,8 @@ export function decodeChildWatchdogConfig(raw: string | undefined): ChildWatchdo
158
147
  ...(model ? { model } : {}),
159
148
  ...(thinking !== undefined ? { thinking: thinking as string | false } : {}),
160
149
  lsp: childConfigLsp(parsed.lsp),
161
- autoFollowBlockers: childConfigBoolean(parsed, "autoFollowBlockers"),
162
- autoFollowMaxAttempts: childConfigNullableNonNegativeInteger(parsed, "autoFollowMaxAttempts"),
163
150
  stalemateRepeats: childConfigPositiveInteger(parsed, "stalemateRepeats"),
151
+ cadence: childConfigCadence(parsed.cadence),
164
152
  };
165
153
  }
166
154
 
@@ -173,14 +161,13 @@ export function isChildWatchdogStatusEvent(value: unknown): value is ChildWatchd
173
161
  && event.seq >= 0
174
162
  && typeof event.ts === "number"
175
163
  && Number.isFinite(event.ts)
176
- && typeof event.followUpPending === "boolean"
177
164
  && typeof event.phase === "string"
178
165
  && (CHILD_WATCHDOG_PHASES as readonly string[]).includes(event.phase);
179
166
  }
180
167
 
181
168
  export function childWatchdogIsActive(snapshot: ChildWatchdogStateSnapshot | undefined): boolean {
182
169
  if (!snapshot) return false;
183
- return snapshot.followUpPending || snapshot.phase === "reviewing" || snapshot.phase === "autofollow" || snapshot.phase === "settling";
170
+ return snapshot.phase === "reviewing";
184
171
  }
185
172
 
186
173
  export function acceptChildWatchdogEvent(input: {
@@ -199,7 +186,40 @@ export function acceptChildWatchdogEvent(input: {
199
186
  phase: input.event.phase,
200
187
  seq: input.event.seq,
201
188
  lastUpdate: input.event.ts,
202
- followUpPending: input.event.followUpPending,
203
189
  ...(input.event.reason ? { reason: input.event.reason } : {}),
190
+ ...(input.current?.warnings?.length ? { warnings: input.current.warnings } : {}),
204
191
  };
205
192
  }
193
+
194
+ function childWatchdogWarningFromMessage(message: unknown): ChildWatchdogWarningSummary | undefined {
195
+ const candidate = message as { role?: unknown; customType?: unknown; details?: Record<string, unknown> } | undefined;
196
+ if (candidate?.role !== "custom" || candidate.customType !== SUBAGENT_WATCHDOG_WARNING_TYPE) return undefined;
197
+ const details = candidate.details ?? {};
198
+ if (details.severity !== "concern" && details.severity !== "blocker") return undefined;
199
+ if (typeof details.summary !== "string" || typeof details.evidence !== "string" || typeof details.recommendedAction !== "string") return undefined;
200
+ return {
201
+ severity: details.severity,
202
+ category: details.category as WatchdogCategory,
203
+ summary: details.summary,
204
+ evidence: details.evidence,
205
+ recommendedAction: details.recommendedAction,
206
+ ...(typeof details.displayedAt === "string" ? { displayedAt: details.displayedAt } : {}),
207
+ addressed: false,
208
+ stalemate: details.state === "stalemate",
209
+ };
210
+ }
211
+
212
+ /** A watchdog warning message is appended; an assistant turn marks earlier warnings addressed. Undefined when unchanged. */
213
+ export function applyChildWatchdogMessage(current: ChildWatchdogStateSnapshot | undefined, message: unknown, now = Date.now()): ChildWatchdogStateSnapshot | undefined {
214
+ const warning = childWatchdogWarningFromMessage(message);
215
+ if (warning) {
216
+ const warnings = [...(current?.warnings ?? []), warning].slice(-CHILD_WATCHDOG_WARNING_LIMIT);
217
+ return current ? { ...current, warnings } : { phase: "idle", seq: 0, lastUpdate: now, warnings };
218
+ }
219
+ if ((message as { role?: unknown } | undefined)?.role !== "assistant" || !current?.warnings?.some((entry) => !entry.addressed)) return undefined;
220
+ return { ...current, warnings: current.warnings.map((entry) => entry.addressed ? entry : { ...entry, addressed: true }) };
221
+ }
222
+
223
+ export function unresolvedChildWatchdogBlockers(progress: Pick<ChildWatchdogProgress, "warnings"> | undefined): ChildWatchdogWarningSummary[] {
224
+ return (progress?.warnings ?? []).filter((warning) => warning.severity === "blocker" && (!warning.addressed || warning.stalemate));
225
+ }
@@ -0,0 +1,77 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import * as path from "node:path";
3
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
4
+ import { Type, type Static } from "typebox";
5
+
6
+ export const WATCHDOG_DIFF_TOOL_NAME = "watchdog_diff";
7
+ export const WATCHDOG_DIFF_MAX_CHARS = 24_000;
8
+ const MAX_UNTRACKED_FILES = 50;
9
+
10
+ export interface WatchdogDiffBaseline {
11
+ root: string;
12
+ ref: string;
13
+ }
14
+
15
+ const WatchdogDiffParams = Type.Object({
16
+ path: Type.Optional(Type.String({ description: "Restrict the diff to one file or directory, relative to the repo root." })),
17
+ stat: Type.Optional(Type.Boolean({ description: "Return per-file change counts instead of the full diff." })),
18
+ }, { additionalProperties: false });
19
+
20
+ type WatchdogDiffParams = Static<typeof WatchdogDiffParams>;
21
+
22
+ function runGit(root: string, args: string[]): { ok: boolean; stdout: string; stderr: string } {
23
+ const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf-8", maxBuffer: 16 * 1024 * 1024, windowsHide: true });
24
+ return { ok: result.status === 0, stdout: result.stdout ?? "", stderr: (result.stderr ?? "").trim() };
25
+ }
26
+
27
+ /** HEAD at session start, so later child commits still show in the diff. */
28
+ export function captureWatchdogDiffBaseline(cwd: string): WatchdogDiffBaseline | undefined {
29
+ const toplevel = runGit(cwd, ["rev-parse", "--show-toplevel"]);
30
+ if (!toplevel.ok) return undefined;
31
+ const head = runGit(cwd, ["rev-parse", "HEAD"]);
32
+ if (!head.ok) return undefined;
33
+ const root = toplevel.stdout.trim();
34
+ const ref = head.stdout.trim();
35
+ return root && ref ? { root, ref } : undefined;
36
+ }
37
+
38
+ function validatePath(value: string | undefined): string | undefined {
39
+ const trimmed = value?.trim();
40
+ if (!trimmed) return undefined;
41
+ if (trimmed.startsWith("-")) throw new Error("watchdog_diff path must not start with '-'.");
42
+ if (path.isAbsolute(trimmed)) throw new Error("watchdog_diff path must be relative to the repo root.");
43
+ if (trimmed.split(/[\\/]/).includes("..")) throw new Error("watchdog_diff path must not contain '..'.");
44
+ return trimmed;
45
+ }
46
+
47
+ function bound(text: string): string {
48
+ if (text.length <= WATCHDOG_DIFF_MAX_CHARS) return text;
49
+ const marker = `\n\n[... ${text.length - WATCHDOG_DIFF_MAX_CHARS} characters omitted; call again with a narrower path ...]`;
50
+ return `${text.slice(0, WATCHDOG_DIFF_MAX_CHARS - marker.length)}${marker}`;
51
+ }
52
+
53
+ /** In a shared cwd, changes already pending when the session started also appear. */
54
+ export function createWatchdogDiffTool(baseline: WatchdogDiffBaseline): AgentTool<typeof WatchdogDiffParams, { chars: number }> {
55
+ return {
56
+ name: WATCHDOG_DIFF_TOOL_NAME,
57
+ label: "Watchdog diff",
58
+ description: "Show the repository diff since the review baseline, plus untracked file paths. Optional path narrows it; stat:true returns per-file counts only.",
59
+ parameters: WatchdogDiffParams,
60
+ executionMode: "sequential",
61
+ async execute(_toolCallId, params: WatchdogDiffParams) {
62
+ const pathFilter = validatePath(params.path);
63
+ const diff = runGit(baseline.root, ["diff", "--no-color", "--no-ext-diff", ...(params.stat === true ? ["--stat"] : []), baseline.ref, "--", ...(pathFilter ? [pathFilter] : [])]);
64
+ if (!diff.ok) throw new Error(`git diff failed: ${diff.stderr || "unknown error"}`);
65
+ const untrackedResult = runGit(baseline.root, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...(pathFilter ? [pathFilter] : [])]);
66
+ const untracked = untrackedResult.ok ? untrackedResult.stdout.split("\0").filter(Boolean) : [];
67
+ const sections = [diff.stdout.trimEnd()];
68
+ if (untracked.length) {
69
+ const shown = untracked.slice(0, MAX_UNTRACKED_FILES);
70
+ sections.push(["Untracked files (use read to inspect):", ...shown.map((file) => ` ${file}`)].join("\n"));
71
+ if (untracked.length > shown.length) sections.push(`... ${untracked.length - shown.length} more untracked files`);
72
+ }
73
+ const text = bound(sections.filter(Boolean).join("\n\n")) || `No changes since baseline ${baseline.ref.slice(0, 12)}.`;
74
+ return { content: [{ type: "text", text }], details: { chars: text.length } };
75
+ },
76
+ };
77
+ }
@@ -88,7 +88,8 @@ export class WatchdogEmissionGuard {
88
88
  this.startModelUpdate();
89
89
  }
90
90
 
91
- evaluate(warning: WatchdogWarning): WatchdogEmissionDecision {
91
+ /** `allowRepeatOf`: one already-accepted identity that may repeat (boundary re-findings before stalemate). */
92
+ evaluate(warning: WatchdogWarning, options: { allowRepeatOf?: string } = {}): WatchdogEmissionDecision {
92
93
  if (isContentFree(warning.summary) || isContentFree(warning.evidence) || isContentFree(warning.recommendedAction)) {
93
94
  return { accepted: false, reason: "content-free" };
94
95
  }
@@ -102,8 +103,9 @@ export class WatchdogEmissionGuard {
102
103
  && warning.severity === "blocker";
103
104
  if (!updateEscalation) return { accepted: false, reason: "update-budget", identity, underlyingIdentity };
104
105
  }
105
- if (priorSeverity !== undefined && !escalation) return { accepted: false, reason: "duplicate", identity, underlyingIdentity };
106
- if (this.maxWarnings !== null && this.acceptedCount >= this.maxWarnings && !escalation) {
106
+ const repeat = priorSeverity !== undefined && options.allowRepeatOf === identity;
107
+ if (priorSeverity !== undefined && !escalation && !repeat) return { accepted: false, reason: "duplicate", identity, underlyingIdentity };
108
+ if (this.maxWarnings !== null && this.acceptedCount >= this.maxWarnings && !escalation && !repeat) {
107
109
  return { accepted: false, reason: "max-warnings", identity, underlyingIdentity };
108
110
  }
109
111
 
@@ -0,0 +1,20 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getAgentDir, getProjectConfigDir } from "../shared/utils.ts";
4
+
5
+ export const WATCHDOG_GUIDANCE_MAX_CHARS = 8_000;
6
+
7
+ function readOptional(filePath: string): string {
8
+ try {
9
+ return fs.readFileSync(filePath, "utf-8").trim();
10
+ } catch {
11
+ return "";
12
+ }
13
+ }
14
+
15
+ /** Read fresh on every review; project file first, then user file. */
16
+ export function loadWatchdogGuidance(cwd: string, enabled: boolean): string {
17
+ if (!enabled) return "";
18
+ const sections = [getProjectConfigDir(cwd), getAgentDir()].map((dir) => readOptional(path.join(dir, "WATCHDOG.md"))).filter(Boolean);
19
+ return sections.join("\n\n").slice(0, WATCHDOG_GUIDANCE_MAX_CHARS);
20
+ }
@@ -1,14 +1,14 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { captureWatchdogDiffBaseline, type WatchdogDiffBaseline } from "./diff-tool.ts";
2
3
  import { MainWatchdogRuntime } from "./runtime.ts";
3
4
  import { createMainWatchdogReview } from "./review.ts";
4
5
  import { DEFAULT_WATCHDOG_CONFIG } from "./settings.ts";
5
6
  import { createWatchdogWarningMessage } from "./warning-format.ts";
6
7
  import {
7
- CHILD_WATCHDOG_CONFIG_ENV,
8
8
  CHILD_WATCHDOG_STATUS_EVENT,
9
- decodeChildWatchdogConfig,
10
9
  type ChildWatchdogConfig,
11
10
  type ChildWatchdogPhase,
11
+ type ChildWatchdogStatusEvent,
12
12
  } from "./child-status.ts";
13
13
  import type { ResolvedWatchdogConfig, WatchdogWarningDetails } from "./types.ts";
14
14
 
@@ -23,11 +23,8 @@ export function childResolvedConfig(config: ChildWatchdogConfig): ResolvedWatchd
23
23
  ...(config.model ? { model: config.model } : {}),
24
24
  ...(config.thinking !== undefined ? { thinking: config.thinking } : {}),
25
25
  },
26
- autoFollow: {
27
- blockers: config.autoFollowBlockers,
28
- maxAttempts: config.autoFollowMaxAttempts,
29
- stalemateRepeats: config.stalemateRepeats,
30
- },
26
+ stalemateRepeats: config.stalemateRepeats,
27
+ cadence: { ...config.cadence },
31
28
  children: {
32
29
  ...DEFAULT_WATCHDOG_CONFIG.children,
33
30
  watchdogTailTimeoutMs: config.watchdogTailTimeoutMs,
@@ -45,20 +42,22 @@ function childWarningDetails(details: WatchdogWarningDetails, config: ChildWatch
45
42
  };
46
43
  }
47
44
 
48
- function writeStatus(event: unknown): void {
49
- try {
50
- process.stdout.write(`${JSON.stringify(event)}\n`);
51
- } catch {
52
- // Child watchdog status is advisory; stdout failures are handled by the child process itself.
53
- }
54
- }
55
-
56
- export function registerChildWatchdog(pi: ExtensionAPI, rawConfig = process.env[CHILD_WATCHDOG_CONFIG_ENV]): MainWatchdogRuntime | undefined {
57
- const childConfig = decodeChildWatchdogConfig(rawConfig);
58
- if (!childConfig?.enabled) return undefined;
45
+ /**
46
+ * Register the child-side watchdog. Status events go to the sink the hosting
47
+ * process passed in the child runtime config; the host folds them into the
48
+ * child's event stream.
49
+ */
50
+ export function registerChildWatchdog(
51
+ pi: ExtensionAPI,
52
+ childConfig: ChildWatchdogConfig | undefined,
53
+ writeStatus: ((event: ChildWatchdogStatusEvent) => void) | undefined,
54
+ ): MainWatchdogRuntime | undefined {
55
+ if (!childConfig) return undefined;
56
+ if (!writeStatus) throw new Error("Child watchdog status sink is missing; the host must pass ChildRuntimeConfig.watchdogStatus.");
59
57
  let currentContext: ExtensionContext | undefined;
58
+ let diffBaseline: WatchdogDiffBaseline | undefined;
60
59
  let seq = 0;
61
- const emitStatus = (phase: ChildWatchdogPhase, followUpPending = false, reason?: string): void => {
60
+ const emitStatus = (phase: ChildWatchdogPhase, reason?: string): void => {
62
61
  writeStatus({
63
62
  type: CHILD_WATCHDOG_STATUS_EVENT,
64
63
  ...(childConfig.runId ? { runId: childConfig.runId } : {}),
@@ -67,19 +66,18 @@ export function registerChildWatchdog(pi: ExtensionAPI, rawConfig = process.env[
67
66
  seq: ++seq,
68
67
  phase,
69
68
  ts: Date.now(),
70
- followUpPending,
71
69
  ...(reason ? { reason } : {}),
72
70
  });
73
71
  };
74
72
  const resolved = childResolvedConfig(childConfig);
75
73
  const runtime = new MainWatchdogRuntime({
76
74
  resolveConfig: () => ({ ok: true, config: resolved, errors: [], sources: [{ scope: "session", exists: true }] }),
77
- review: createMainWatchdogReview(() => currentContext, { getThinkingLevel: () => pi.getThinkingLevel() }),
75
+ review: createMainWatchdogReview(() => currentContext, { getThinkingLevel: () => pi.getThinkingLevel(), diffBaseline: () => diffBaseline }),
78
76
  reviewDescription: "child model review",
79
77
  reviewChangesOnly: true,
80
- displayWarning: (details) => {
78
+ displayWarning: (details, options) => {
81
79
  const childDetails = childWarningDetails(details, childConfig);
82
- pi.sendMessage(createWatchdogWarningMessage(childDetails, { display: true, details: childDetails }));
80
+ pi.sendMessage(createWatchdogWarningMessage(childDetails, { display: true, details: childDetails }), options);
83
81
  },
84
82
  });
85
83
  const rememberContext = (ctx: ExtensionContext) => {
@@ -88,6 +86,7 @@ export function registerChildWatchdog(pi: ExtensionAPI, rawConfig = process.env[
88
86
  const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown, ctx: ExtensionContext) => unknown) => void;
89
87
  onRuntimeEvent("session_start", (_event, ctx) => {
90
88
  rememberContext(ctx);
89
+ diffBaseline = captureWatchdogDiffBaseline(ctx.cwd);
91
90
  runtime.bindSession(ctx);
92
91
  emitStatus("idle");
93
92
  });
@@ -99,13 +98,17 @@ export function registerChildWatchdog(pi: ExtensionAPI, rawConfig = process.env[
99
98
  rememberContext(ctx);
100
99
  runtime.handleTurnEnd(event, ctx);
101
100
  });
101
+ onRuntimeEvent("tool_result", (_event, ctx) => {
102
+ rememberContext(ctx);
103
+ runtime.handleToolResult(ctx);
104
+ });
102
105
  onRuntimeEvent("agent_end", async (event, ctx) => {
103
106
  rememberContext(ctx);
104
107
  emitStatus("reviewing");
105
108
  await runtime.handleAgentEnd(event, ctx);
106
109
  const snapshot = runtime.getSnapshot(ctx.cwd);
107
- if (snapshot.status === "failed") emitStatus("failed", false, snapshot.lastError);
108
- else if (snapshot.status === "stale") emitStatus("stale", false, "review stale");
110
+ if (snapshot.status === "failed") emitStatus("failed", snapshot.lastError);
111
+ else if (snapshot.status === "stale") emitStatus("stale", "review stale");
109
112
  else emitStatus("idle");
110
113
  });
111
114
  onRuntimeEvent("session_shutdown", () => {
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@e
2
2
  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
+ import { captureWatchdogDiffBaseline, type WatchdogDiffBaseline } from "./diff-tool.ts";
5
6
  import { recommendStrongWatchdogModel, resolveWatchdogModelInput, parseWatchdogThinkingInput } from "./model-selection.ts";
6
7
  import { renderWatchdogWarning } from "./render.ts";
7
8
  import { createMainWatchdogReview } from "./review.ts";
@@ -120,7 +121,8 @@ export function buildWatchdogStatus(snapshot: ReturnType<MainWatchdogRuntime["ge
120
121
  childrenLine(snapshot),
121
122
  recommendationLine(ctx),
122
123
  `Agent-end timeout: ${snapshot.config.agentEndTimeoutMs}ms`,
123
- `Auto-follow: ${snapshot.enabled && snapshot.config.autoFollow.blockers ? "on for blockers" : "off"} · attempts ${snapshot.autoFollowAttempts}${snapshot.config.autoFollow.maxAttempts === null ? "" : `/${snapshot.config.autoFollow.maxAttempts}`}${snapshot.autoFollowQueued ? " · queued" : ""}${snapshot.autoFollowStalemate ? " · stalemate" : ""}`,
124
+ `Stalemate: ${snapshot.boundaryRepeats}/${snapshot.config.stalemateRepeats}${snapshot.stalemate ? " · stopped" : ""}`,
125
+ `Rules: ${snapshot.config.rules ? `${Object.keys(snapshot.config.rules.roleModels).length} role models · ${snapshot.config.rules.action}` : "none"}`,
124
126
  `Review model call: ${snapshot.reviewDescription}`,
125
127
  ];
126
128
  if (snapshot.failedReviews > 0) lines.push(`Failed reviews: ${snapshot.failedReviews}`);
@@ -238,7 +240,7 @@ function createTestWarning(severity: "concern" | "blocker", text: string): Watch
238
240
  summary: text,
239
241
  evidence: `Manual /subagents-watchdog test ${severity} message from the main session.`,
240
242
  recommendedAction: severity === "blocker"
241
- ? "Verify the renderer, transcript delivery, and auto-follow policy."
243
+ ? "Verify the renderer and transcript delivery."
242
244
  : "Verify the renderer and transcript delivery; decide manually whether any action is needed.",
243
245
  };
244
246
  }
@@ -376,17 +378,15 @@ async function handleWatchdogCommand(
376
378
 
377
379
  export function registerMainWatchdog(pi: ExtensionAPI, options: RegisterMainWatchdogOptions = {}): MainWatchdogRuntime {
378
380
  let currentContext: ExtensionContext | undefined;
381
+ let diffBaseline: WatchdogDiffBaseline | undefined;
379
382
  const rememberContext = (ctx: ExtensionContext) => {
380
383
  currentContext = ctx;
381
384
  };
382
385
  const runtime = options.runtime ?? new MainWatchdogRuntime({
383
- review: options.review ?? createMainWatchdogReview(() => currentContext, { getThinkingLevel: () => pi.getThinkingLevel() }),
386
+ review: options.review ?? createMainWatchdogReview(() => currentContext, { getThinkingLevel: () => pi.getThinkingLevel(), diffBaseline: () => diffBaseline }),
384
387
  reviewDescription: options.review ? "injected seam" : "real model review",
385
388
  reviewChangesOnly: true,
386
- displayWarning: (details, delivery) => {
387
- pi.sendMessage(createWatchdogWarningMessage(details, { display: true, details }), delivery?.deliverAs === "steer" ? { deliverAs: "steer" } : undefined);
388
- },
389
- sendUserMessage: (message) => pi.sendUserMessage(message),
389
+ displayWarning: (details, options) => pi.sendMessage(createWatchdogWarningMessage(details, { display: true, details }), options),
390
390
  });
391
391
 
392
392
  pi.registerMessageRenderer<WatchdogWarningDetails>(SUBAGENT_WATCHDOG_WARNING_TYPE, (message, renderOptions, theme) => {
@@ -410,6 +410,7 @@ export function registerMainWatchdog(pi: ExtensionAPI, options: RegisterMainWatc
410
410
 
411
411
  pi.on("session_start", (_event, ctx) => {
412
412
  rememberContext(ctx);
413
+ diffBaseline = captureWatchdogDiffBaseline(ctx.cwd);
413
414
  runtime.bindSession(ctx);
414
415
  });
415
416
  pi.on("before_agent_start", (event, ctx) => {
@@ -428,8 +429,8 @@ export function registerMainWatchdog(pi: ExtensionAPI, options: RegisterMainWatc
428
429
  rememberContext(ctx);
429
430
  return runtime.handleAgentEnd(event, ctx);
430
431
  });
431
- pi.on("session_before_switch", () => runtime.reset("session switch", { clearReviewInputSignature: true, clearLspLedger: true, clearScope: true, resetAutoFollow: true }));
432
- pi.on("session_before_fork", () => runtime.reset("session fork", { clearReviewInputSignature: true, clearLspLedger: true, clearScope: true, resetAutoFollow: true }));
432
+ pi.on("session_before_switch", () => runtime.reset("session switch", { clearReviewInputSignature: true, clearLspLedger: true, clearScope: true }));
433
+ pi.on("session_before_fork", () => runtime.reset("session fork", { clearReviewInputSignature: true, clearLspLedger: true, clearScope: true }));
433
434
  pi.on("session_compact", () => runtime.reset("session compact", { clearScope: true }));
434
435
  pi.on("session_shutdown", () => {
435
436
  currentContext = undefined;
@@ -13,10 +13,9 @@ function titleCase(value: string): string {
13
13
  function stateLabels(warning: WatchdogWarningDetails): string[] {
14
14
  const labels: string[] = [];
15
15
  if (warning.state === "displayed") labels.push("displayed");
16
- if (warning.stale || warning.state === "stale") labels.push("stale · no auto-follow");
16
+ if (warning.stale || warning.state === "stale") labels.push("stale");
17
17
  if (warning.state === "failed") labels.push("failed review");
18
- if (warning.state === "stalemate") labels.push("stalemate · auto-follow stopped");
19
- if (warning.autoFollowAttempt !== undefined) labels.push(`auto-follow attempt ${warning.autoFollowAttempt}`);
18
+ if (warning.state === "stalemate") labels.push("stalemate");
20
19
  return labels;
21
20
  }
22
21
 
@@ -31,9 +30,9 @@ export function formatWatchdogWarningRenderText(warning: WatchdogWarningDetails)
31
30
  ];
32
31
  if (warning.state === "failed" && warning.error) lines.push(`Failure: ${warning.error}`);
33
32
  if (warning.state === "stalemate" && warning.stalemateRepeats !== undefined) {
34
- lines.push(`Auto-follow stopped after ${warning.stalemateRepeats} repeated blocker warning${warning.stalemateRepeats === 1 ? "" : "s"}.`);
33
+ lines.push(`Same warning ${warning.stalemateRepeats} time${warning.stalemateRepeats === 1 ? "" : "s"} in a row; the watchdog stopped continuing the run.`);
35
34
  }
36
- if (warning.stale || warning.state === "stale") lines.push("This warning arrived after the watchdog catch-up timeout and must not auto-follow.");
35
+ if (warning.stale || warning.state === "stale") lines.push("This warning arrived after the watchdog catch-up timeout.");
37
36
  return lines.join("\n");
38
37
  }
39
38
 
@@ -6,6 +6,8 @@ import { Type, type Static } from "typebox";
6
6
  import { resolveModelCandidate } from "../runs/shared/model-fallback.ts";
7
7
  import { agentStreamOptions } from "../shared/agent-stream-options.ts";
8
8
  import { resolveEffectiveThinking, splitKnownThinkingSuffix, THINKING_LEVELS, toModelInfo } from "../shared/model-info.ts";
9
+ import { createWatchdogDiffTool, WATCHDOG_DIFF_TOOL_NAME, type WatchdogDiffBaseline } from "./diff-tool.ts";
10
+ import { loadWatchdogGuidance } from "./guidance.ts";
9
11
  import type { WatchdogReviewFunction, WatchdogReviewRequest } from "./runtime.ts";
10
12
  import {
11
13
  WATCHDOG_WARNING_CATEGORIES,
@@ -18,7 +20,7 @@ import {
18
20
  type WatchdogWarning,
19
21
  } from "./types.ts";
20
22
 
21
- const WATCHDOG_ALLOWED_TOOL_NAMES = new Set(["read", "grep", "find", "ls", "watchdog_warn"]);
23
+ const WATCHDOG_ALLOWED_TOOL_NAMES = new Set(["read", "grep", "find", "ls", "watchdog_warn", WATCHDOG_DIFF_TOOL_NAME]);
22
24
 
23
25
  const WatchdogWarnParams = Type.Object({
24
26
  severity: Type.String({ enum: WATCHDOG_WARNING_SEVERITIES, description: "concern for actionable risk, blocker for a likely wrong or unsafe outcome" }),
@@ -52,6 +54,7 @@ export interface CreateMainWatchdogReviewOptions {
52
54
  streamFn?: StreamFn;
53
55
  createReadOnlyTools?: (cwd: string) => AgentTool[];
54
56
  getThinkingLevel?: () => ThinkingLevel | undefined;
57
+ diffBaseline?: () => WatchdogDiffBaseline | undefined;
55
58
  }
56
59
 
57
60
  function fullModelId(model: Pick<RegistryModel, "provider" | "id">): string {
@@ -205,18 +208,20 @@ function createWatchdogWarnTool(request: WatchdogReviewRequest): AgentTool<typeo
205
208
  };
206
209
  }
207
210
 
208
- function buildWatchdogSystemPrompt(ctx: ExtensionContext, options: { hasScope?: boolean } = {}): string {
211
+ export function buildWatchdogSystemPrompt(ctx: Pick<ExtensionContext, "cwd">, options: { hasScope?: boolean; guidance?: string; hasDiff?: boolean } = {}): string {
212
+ const guidance = options.guidance?.trim();
209
213
  return [
210
214
  "You are the main-session subagent watchdog for Pi.",
211
215
  `Working directory: ${ctx.cwd}`,
212
216
  "Review only the supplied parent turn delta. Inspect repository files only when needed to verify a concrete concern.",
213
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,
214
- "You are read-only. You may use read, grep, find, and ls. Do not edit files, run shell commands, spawn agents, or mutate state.",
218
+ `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.`,
215
219
  "Emit warnings only by calling watchdog_warn. Freeform assistant text is ignored and must not be used to report warnings.",
216
220
  "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.",
217
221
  "Do not emit nits, style preferences, low-confidence guesses, informational notes, praise, or summaries.",
218
222
  "If the turn is clean, call no tools and end normally.",
219
223
  "Use severity='blocker' only when the issue should stop acceptance until addressed; otherwise use severity='concern'.",
224
+ guidance ? `\nStanding instructions from WATCHDOG.md (project first, then user):\n${guidance}` : undefined,
220
225
  ].filter((line): line is string => Boolean(line)).join("\n");
221
226
  }
222
227
 
@@ -269,13 +274,19 @@ export function createMainWatchdogReview(provider: WatchdogContextProvider, opti
269
274
  env: auth.env || streamOptions?.env ? { ...(auth.env ?? {}), ...(streamOptions?.env ?? {}) } : undefined,
270
275
  headers: { ...(streamOptions?.headers ?? {}), ...(auth.headers ?? {}) },
271
276
  });
277
+ const diffBaseline = options.diffBaseline?.();
272
278
  const tools = [
273
279
  ...(options.createReadOnlyTools ?? createReadOnlyTools)(ctx.cwd).filter((tool) => WATCHDOG_ALLOWED_TOOL_NAMES.has(tool.name) && tool.name !== "watchdog_warn"),
274
280
  createWatchdogWarnTool(request),
281
+ ...(diffBaseline ? [createWatchdogDiffTool(diffBaseline)] : []),
275
282
  ];
276
283
  const agent = new Agent({
277
284
  initialState: {
278
- systemPrompt: buildWatchdogSystemPrompt(ctx, { hasScope: request.hasScope }),
285
+ systemPrompt: buildWatchdogSystemPrompt(ctx, {
286
+ hasScope: request.hasScope,
287
+ guidance: loadWatchdogGuidance(ctx.cwd, request.config.guidance.watchdogMd),
288
+ hasDiff: diffBaseline !== undefined,
289
+ }),
279
290
  model: selection.model,
280
291
  thinkingLevel: selection.thinkingLevel,
281
292
  tools,
@@ -0,0 +1,70 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { splitKnownThinkingSuffix } from "../shared/model-info.ts";
3
+ import { resolveWatchdogConfig } from "./settings.ts";
4
+ import type { WatchdogRulesConfig, WatchdogWarning } from "./types.ts";
5
+ import { createWatchdogWarningMessage } from "./warning-format.ts";
6
+
7
+ export interface WatchdogRuleViolation {
8
+ agent: string;
9
+ summary: string;
10
+ evidence: string;
11
+ recommendedAction: string;
12
+ }
13
+
14
+ function loadWatchdogLaunchRules(cwd: string): WatchdogRulesConfig | undefined {
15
+ const result = resolveWatchdogConfig(cwd);
16
+ return result.ok ? result.config.rules : undefined;
17
+ }
18
+
19
+ /** `*` matches any run of characters, `?` one character; anchored, case-sensitive. */
20
+ export function watchdogGlobMatch(pattern: string, value: string): boolean {
21
+ const source = pattern.split("").map((char) => char === "*" ? ".*" : char === "?" ? "." : char.replace(/[.+^${}()|[\]\\]/g, "\\$&")).join("");
22
+ return new RegExp(`^${source}$`).test(value);
23
+ }
24
+
25
+ function modelMatches(patterns: string[], model: string): string | undefined {
26
+ const base = splitKnownThinkingSuffix(model).baseModel;
27
+ return patterns.find((pattern) => watchdogGlobMatch(pattern, model) || watchdogGlobMatch(pattern, base));
28
+ }
29
+
30
+ /** Deny wins over allow; an unknown model cannot be judged. */
31
+ export function evaluateLaunchRule(rules: WatchdogRulesConfig | undefined, agent: string, model: string | undefined): WatchdogRuleViolation | undefined {
32
+ const roleRule = rules?.roleModels[agent];
33
+ if (!roleRule || !model) return undefined;
34
+ const note = roleRule.note ? ` ${roleRule.note}` : "";
35
+ const denied = roleRule.deny?.length ? modelMatches(roleRule.deny, model) : undefined;
36
+ if (denied !== undefined) {
37
+ return {
38
+ agent,
39
+ summary: `Agent '${agent}' was launched with denied model '${model}'.`,
40
+ evidence: `subagents.watchdog.rules.roleModels.${agent}.deny matches '${denied}'.${note}`,
41
+ recommendedAction: roleRule.allow?.length ? `Use one of: ${roleRule.allow.join(", ")}.` : "Choose a different model for this role.",
42
+ };
43
+ }
44
+ if (!roleRule.allow?.length || modelMatches(roleRule.allow, model) !== undefined) return undefined;
45
+ return {
46
+ agent,
47
+ summary: `Agent '${agent}' was launched with model '${model}', which is not in its allowed list.`,
48
+ evidence: `subagents.watchdog.rules.roleModels.${agent}.allow is [${roleRule.allow.join(", ")}].${note}`,
49
+ recommendedAction: `Use one of: ${roleRule.allow.join(", ")}.`,
50
+ };
51
+ }
52
+
53
+ export function ruleViolationWarning(violation: WatchdogRuleViolation): WatchdogWarning {
54
+ return { severity: "concern", category: "missed-constraint", confidence: "high", source: "main", ...violation };
55
+ }
56
+
57
+ export function applyWatchdogLaunchRules(input: { cwd: string; agent: string; model?: string; warn?: (violation: WatchdogRuleViolation) => void }): string | undefined {
58
+ const rules = loadWatchdogLaunchRules(input.cwd);
59
+ const violation = evaluateLaunchRule(rules, input.agent, input.model);
60
+ if (!violation) return undefined;
61
+ if (rules?.action === "block") return `Launch blocked by subagents.watchdog.rules: ${violation.summary}`;
62
+ input.warn?.(violation);
63
+ return undefined;
64
+ }
65
+
66
+ /** For launch paths without a main watchdog runtime (background chain steps). */
67
+ export function sendRuleViolationWarning(pi: Pick<ExtensionAPI, "sendMessage">, violation: WatchdogRuleViolation): void {
68
+ const warning = ruleViolationWarning(violation);
69
+ pi.sendMessage(createWatchdogWarningMessage(warning, { display: true, details: { state: "displayed", displayedAt: new Date().toISOString() } }), { deliverAs: "steer" });
70
+ }