pi-subagents 0.63.0 → 0.64.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.
@@ -1,4 +1,5 @@
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";
@@ -23,11 +24,8 @@ export function childResolvedConfig(config: ChildWatchdogConfig): ResolvedWatchd
23
24
  ...(config.model ? { model: config.model } : {}),
24
25
  ...(config.thinking !== undefined ? { thinking: config.thinking } : {}),
25
26
  },
26
- autoFollow: {
27
- blockers: config.autoFollowBlockers,
28
- maxAttempts: config.autoFollowMaxAttempts,
29
- stalemateRepeats: config.stalemateRepeats,
30
- },
27
+ stalemateRepeats: config.stalemateRepeats,
28
+ cadence: { ...config.cadence },
31
29
  children: {
32
30
  ...DEFAULT_WATCHDOG_CONFIG.children,
33
31
  watchdogTailTimeoutMs: config.watchdogTailTimeoutMs,
@@ -55,10 +53,11 @@ function writeStatus(event: unknown): void {
55
53
 
56
54
  export function registerChildWatchdog(pi: ExtensionAPI, rawConfig = process.env[CHILD_WATCHDOG_CONFIG_ENV]): MainWatchdogRuntime | undefined {
57
55
  const childConfig = decodeChildWatchdogConfig(rawConfig);
58
- if (!childConfig?.enabled) return undefined;
56
+ if (!childConfig) return undefined;
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
+ }