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.
- package/CHANGELOG.md +57 -1
- package/README.md +2 -2
- package/agents/reviewer.md +1 -1
- package/agents/scout.md +1 -1
- package/docs/agents.md +18 -16
- package/docs/configuration.md +7 -15
- package/docs/extension-api.md +15 -6
- package/docs/missions.md +2 -0
- package/docs/observability.md +10 -12
- package/docs/tool-reference.md +7 -6
- package/docs/watchdog.md +92 -114
- package/docs/workflows.md +5 -3
- package/package.json +3 -4
- package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
- package/skills/pi-subagents/references/execution-controls.md +6 -5
- package/src/agents/agent-management.ts +47 -15
- package/src/api/capability-ceiling.ts +0 -1
- package/src/api/{pi-args.ts → child-tool-plan.ts} +1 -1
- package/src/api/preflight.ts +2 -3
- package/src/extension/doctor.ts +2 -10
- package/src/extension/fanout-child.ts +9 -11
- package/src/extension/index.ts +27 -5
- package/src/extension/public-execution.ts +14 -0
- package/src/extension/rpc.ts +3 -2
- package/src/extension/schemas.ts +2 -1
- package/src/extension/tool-description.ts +9 -8
- package/src/intercom/native-supervisor-channel.ts +138 -60
- package/src/intercom/supervisor-ui.ts +243 -0
- package/src/runs/background/async-execution.ts +46 -24
- package/src/runs/background/async-job-tracker.ts +11 -0
- package/src/runs/background/async-resume.ts +11 -2
- package/src/runs/background/control-channel.ts +2 -204
- package/src/runs/background/notify.ts +41 -2
- package/src/runs/background/process-terminal.ts +1 -1
- package/src/runs/background/run-child-session.ts +613 -0
- package/src/runs/background/run-status.ts +0 -1
- package/src/runs/background/runner-aliases.ts +125 -0
- package/src/runs/background/runner-child-sessions.ts +31 -0
- package/src/runs/background/scheduled-runs.ts +18 -4
- package/src/runs/background/subagent-runner.ts +203 -898
- package/src/runs/foreground/async-steering-action.ts +1 -17
- package/src/runs/foreground/execution.ts +196 -378
- package/src/runs/foreground/foreground-control.ts +4 -0
- package/src/runs/foreground/subagent-executor.ts +121 -58
- package/src/runs/foreground/workflow-foreground-steering.ts +24 -98
- package/src/runs/shared/abort-recovery.ts +3 -3
- package/src/runs/shared/acceptance.ts +10 -0
- package/src/runs/shared/async-status-projection.ts +11 -43
- package/src/runs/shared/capability-ceiling.ts +1 -2
- package/src/runs/shared/child-hooks.ts +25 -0
- package/src/runs/shared/child-identity.ts +13 -2
- package/src/runs/shared/child-launch.ts +314 -0
- package/src/runs/shared/child-lifecycle.ts +25 -0
- package/src/runs/shared/child-runtime-config.ts +126 -0
- package/src/runs/shared/child-session.ts +342 -0
- package/src/runs/shared/child-tool-plan.ts +530 -0
- package/src/runs/shared/claude-code-adapter.ts +5 -1
- package/src/runs/shared/completion-guard.ts +1 -1
- package/src/runs/shared/external-cli-preflight.ts +16 -0
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +5 -4
- package/src/runs/shared/model-exclusions.ts +82 -14
- package/src/runs/shared/model-fallback.ts +47 -4
- package/src/runs/shared/nested-events.ts +29 -45
- package/src/runs/shared/nested-path.ts +0 -14
- package/src/runs/shared/orca-progress-tabs.ts +12 -7
- package/src/runs/shared/parallel-utils.ts +0 -2
- package/src/runs/shared/permissions.ts +0 -13
- package/src/runs/shared/process-signal.ts +4 -1
- package/src/runs/shared/run-fanout-budget.ts +0 -13
- package/src/runs/shared/runtime-acknowledged-extensions.ts +0 -27
- package/src/runs/shared/structured-output.ts +17 -4
- package/src/runs/shared/subagent-control.ts +6 -2
- package/src/runs/shared/subagent-prompt-runtime.ts +87 -384
- package/src/runs/shared/tool-availability.ts +18 -62
- package/src/runs/shared/tool-budget.ts +0 -14
- package/src/runs/shared/worktree-cleanup-plan.ts +25 -6
- package/src/runs/shared/worktree.ts +117 -30
- package/src/shared/child-session-name.ts +1 -1
- package/src/shared/jsonl-writer.ts +11 -0
- package/src/shared/thinking-ceiling.ts +0 -6
- package/src/shared/types.ts +64 -30
- package/src/shared/utils.ts +3 -4
- package/src/slash/slash-commands.ts +0 -6
- package/src/tui/fleet.ts +0 -1
- package/src/tui/render.ts +238 -31
- package/src/watchdog/child-status.ts +54 -34
- package/src/watchdog/diff-tool.ts +77 -0
- package/src/watchdog/emission-guard.ts +5 -3
- package/src/watchdog/guidance.ts +20 -0
- package/src/watchdog/register-child.ts +28 -25
- package/src/watchdog/register-main.ts +10 -9
- package/src/watchdog/render.ts +4 -5
- package/src/watchdog/review.ts +15 -4
- package/src/watchdog/rules.ts +70 -0
- package/src/watchdog/runtime.ts +75 -92
- package/src/watchdog/scope.ts +0 -11
- package/src/watchdog/settings.ts +48 -104
- package/src/watchdog/types.ts +18 -32
- package/src/watchdog/warning-format.ts +0 -1
- package/src/workflows/chat-progress.ts +3 -2
- package/src/workflows/scripted-workflow.ts +48 -1
- package/src/workflows/workflow-checklist.ts +10 -12
- package/src/workflows/workflow-preflight.ts +28 -1
- package/src/runs/shared/child-protocol.ts +0 -415
- package/src/runs/shared/pi-args.ts +0 -1062
- package/src/runs/shared/subagent-startup-retry.ts +0 -116
- package/src/shared/post-exit-stdio-guard.ts +0 -85
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import type {
|
|
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", "
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
if (
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (!childConfig
|
|
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,
|
|
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",
|
|
108
|
-
else if (snapshot.status === "stale") emitStatus("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
|
-
`
|
|
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
|
|
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,
|
|
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
|
|
432
|
-
pi.on("session_before_fork", () => runtime.reset("session fork", { clearReviewInputSignature: true, clearLspLedger: true, clearScope: 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;
|
package/src/watchdog/render.ts
CHANGED
|
@@ -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
|
|
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
|
|
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(`
|
|
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
|
|
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
|
|
package/src/watchdog/review.ts
CHANGED
|
@@ -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
|
-
|
|
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, {
|
|
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
|
+
}
|