pi-subagents 0.34.0 → 0.35.1

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 (98) hide show
  1. package/CHANGELOG.md +83 -9
  2. package/README.md +213 -32
  3. package/index.ts +1 -0
  4. package/install.mjs +1 -1
  5. package/package.json +23 -8
  6. package/prompts/review-loop.md +3 -1
  7. package/skills/pi-subagents/SKILL.md +87 -25
  8. package/src/agents/agent-management.ts +82 -15
  9. package/src/agents/agent-serializer.ts +19 -0
  10. package/src/agents/agents.ts +91 -49
  11. package/src/agents/frontmatter.ts +67 -13
  12. package/src/agents/skills.ts +25 -12
  13. package/src/api/background-work.ts +197 -0
  14. package/src/api/delegation.ts +158 -0
  15. package/src/extension/chain-validation.ts +165 -0
  16. package/src/extension/doctor.ts +15 -0
  17. package/src/extension/fanout-child.ts +3 -1
  18. package/src/extension/index.ts +65 -124
  19. package/src/extension/rpc.ts +10 -2
  20. package/src/extension/schemas.ts +18 -14
  21. package/src/extension/steering-notices.ts +35 -0
  22. package/src/extension/tool-description.ts +20 -9
  23. package/src/intercom/intercom-bridge.ts +3 -2
  24. package/src/intercom/native-supervisor-channel.ts +9 -1
  25. package/src/intercom/result-intercom.ts +4 -0
  26. package/src/runs/background/async-execution.ts +293 -44
  27. package/src/runs/background/async-job-tracker.ts +56 -9
  28. package/src/runs/background/async-resume.ts +159 -52
  29. package/src/runs/background/async-status.ts +25 -18
  30. package/src/runs/background/auto-drain.ts +67 -0
  31. package/src/runs/background/chain-root-attachment.ts +16 -8
  32. package/src/runs/background/control-channel.ts +260 -13
  33. package/src/runs/background/fleet-view.ts +23 -2
  34. package/src/runs/background/notify.ts +79 -10
  35. package/src/runs/background/result-watcher.ts +12 -9
  36. package/src/runs/background/run-id-resolver.ts +14 -2
  37. package/src/runs/background/run-status.ts +23 -15
  38. package/src/runs/background/scheduled-runs.ts +3 -0
  39. package/src/runs/background/stale-run-reconciler.ts +32 -10
  40. package/src/runs/background/steering.ts +237 -0
  41. package/src/runs/background/subagent-runner.ts +898 -236
  42. package/src/runs/background/subagent-wait.ts +484 -0
  43. package/src/runs/background/top-level-async.ts +2 -1
  44. package/src/runs/background/wait-config.ts +36 -0
  45. package/src/runs/background/wait-tool.ts +26 -0
  46. package/src/runs/foreground/async-steering-action.ts +230 -0
  47. package/src/runs/foreground/chain-clarify.ts +22 -6
  48. package/src/runs/foreground/chain-execution.ts +50 -32
  49. package/src/runs/foreground/execution.ts +308 -94
  50. package/src/runs/foreground/subagent-executor.ts +592 -268
  51. package/src/runs/shared/acceptance.ts +355 -97
  52. package/src/runs/shared/child-protocol.ts +121 -0
  53. package/src/runs/shared/completion-guard.ts +8 -127
  54. package/src/runs/shared/dynamic-fanout.ts +6 -4
  55. package/src/runs/shared/model-fallback.ts +36 -0
  56. package/src/runs/shared/nested-events.ts +9 -4
  57. package/src/runs/shared/nested-render.ts +4 -1
  58. package/src/runs/shared/parallel-utils.ts +7 -0
  59. package/src/runs/shared/pi-args.ts +34 -7
  60. package/src/runs/shared/pi-spawn.ts +18 -12
  61. package/src/runs/shared/session-lease.ts +279 -0
  62. package/src/runs/shared/single-output.ts +61 -6
  63. package/src/runs/shared/spawn-budget.ts +128 -0
  64. package/src/runs/shared/subagent-control.ts +10 -6
  65. package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
  66. package/src/runs/shared/task-intent.ts +176 -0
  67. package/src/runs/shared/tool-availability.ts +65 -0
  68. package/src/runs/shared/turn-budget.ts +49 -4
  69. package/src/shared/atomic-json.ts +4 -1
  70. package/src/shared/fork-context.ts +28 -3
  71. package/src/shared/model-info.ts +7 -4
  72. package/src/shared/status-format.ts +7 -1
  73. package/src/shared/types.ts +203 -25
  74. package/src/shared/utils.ts +35 -7
  75. package/src/slash/delegation-adapters.ts +457 -0
  76. package/src/slash/delegation-request.ts +103 -0
  77. package/src/slash/prompt-template-bridge.ts +167 -344
  78. package/src/slash/slash-commands.ts +239 -6
  79. package/src/slash/subagents-admin.ts +428 -0
  80. package/src/slash/subagents-editor.ts +86 -0
  81. package/src/tui/fleet.ts +405 -0
  82. package/src/tui/render.ts +98 -16
  83. package/src/watchdog/change-signature.ts +127 -0
  84. package/src/watchdog/child-status.ts +205 -0
  85. package/src/watchdog/emission-guard.ts +123 -0
  86. package/src/watchdog/lsp-diagnostics.ts +532 -0
  87. package/src/watchdog/model-selection.ts +167 -0
  88. package/src/watchdog/register-child.ts +117 -0
  89. package/src/watchdog/register-main.ts +433 -0
  90. package/src/watchdog/render.ts +54 -0
  91. package/src/watchdog/review.ts +293 -0
  92. package/src/watchdog/runtime.ts +712 -0
  93. package/src/watchdog/settings.ts +528 -0
  94. package/src/watchdog/tool-actions.ts +155 -0
  95. package/src/watchdog/turn-delta.ts +161 -0
  96. package/src/watchdog/types.ts +188 -0
  97. package/src/watchdog/warning-format.ts +73 -0
  98. package/src/runs/background/wait.ts +0 -394
@@ -0,0 +1,712 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { computeWatchdogRepoChangeSignature, eventIndicatesRepoEdit, type WatchdogRepoChangeSignature } from "./change-signature.ts";
4
+ import { WatchdogEmissionGuard } from "./emission-guard.ts";
5
+ import {
6
+ collectWatchdogLspDiagnostics,
7
+ formatWatchdogLspDiagnosticsBlock,
8
+ WatchdogLspDiagnosticsLedger,
9
+ watchdogWarningFromLspDiagnostics,
10
+ type WatchdogLspDiagnosticsFunction,
11
+ } from "./lsp-diagnostics.ts";
12
+ import { resolveWatchdogConfig } from "./settings.ts";
13
+ import { formatWatchdogTurnDelta } from "./turn-delta.ts";
14
+ import {
15
+ type ResolvedWatchdogConfig,
16
+ type WatchdogEndpointConfig,
17
+ type WatchdogLspRuntimeSnapshot,
18
+ type WatchdogRuntimeStatus,
19
+ type WatchdogSettingsError,
20
+ type WatchdogSettingsResult,
21
+ type WatchdogSettingsSource,
22
+ type WatchdogWarning,
23
+ type WatchdogWarningDetails,
24
+ } from "./types.ts";
25
+ import { normalizeWatchdogWarningDetails } from "./warning-format.ts";
26
+
27
+ type ReviewStopReason = "stop" | "error" | "aborted" | "length";
28
+
29
+ export interface WatchdogReviewResult {
30
+ warnings?: WatchdogWarning[];
31
+ stopReason?: ReviewStopReason;
32
+ }
33
+
34
+ export interface WatchdogReviewRequest {
35
+ delta: string;
36
+ epoch: number;
37
+ reviewId: number;
38
+ config: ResolvedWatchdogConfig;
39
+ emitWarning(warning: WatchdogWarning): boolean;
40
+ signal?: AbortSignal;
41
+ }
42
+
43
+ export type WatchdogReviewFunction = (request: WatchdogReviewRequest) => Promise<WatchdogReviewResult | void> | WatchdogReviewResult | void;
44
+
45
+ export interface WatchdogRuntimeSnapshot {
46
+ status: WatchdogRuntimeStatus;
47
+ enabled: boolean;
48
+ config: ResolvedWatchdogConfig;
49
+ configOk: boolean;
50
+ errors: WatchdogSettingsError[];
51
+ sources: WatchdogSettingsSource[];
52
+ bufferedDeltas: number;
53
+ epoch: number;
54
+ activeReviewId?: number;
55
+ sessionOverride?: boolean;
56
+ sessionModelOverride?: Partial<Pick<WatchdogEndpointConfig, "model" | "thinking">>;
57
+ lastWarning?: WatchdogWarningDetails;
58
+ lastError?: string;
59
+ failedReviews: number;
60
+ staleReviews: number;
61
+ reviewConnected: boolean;
62
+ reviewDescription: string;
63
+ autoFollowQueued: false;
64
+ reviewTrigger: "turn-delta" | "repo-edits";
65
+ changedPaths?: string[];
66
+ lsp: WatchdogLspRuntimeSnapshot;
67
+ }
68
+
69
+ interface Waiter {
70
+ resolve(settled: boolean): void;
71
+ timer: ReturnType<typeof setTimeout>;
72
+ }
73
+
74
+ interface MainWatchdogRuntimeOptions {
75
+ cwd?: string;
76
+ resolveConfig?: (cwd: string, options?: { session?: Record<string, unknown> }) => WatchdogSettingsResult;
77
+ review?: WatchdogReviewFunction;
78
+ reviewDescription?: string;
79
+ displayWarning?: (warning: WatchdogWarningDetails) => void;
80
+ reviewChangesOnly?: boolean;
81
+ lspDiagnostics?: WatchdogLspDiagnosticsFunction;
82
+ }
83
+
84
+ type ContextLike = Pick<ExtensionContext, "cwd">;
85
+ type ReviewDeltaOutcome = "completed" | "timeout" | "stale";
86
+
87
+ const DEFAULT_REVIEW: WatchdogReviewFunction = () => ({ warnings: [] });
88
+ const MAX_REVIEW_INPUT_CHARS = 24_000;
89
+ const REVIEW_DELTA_SEPARATOR = "\n\n---\n\n";
90
+
91
+ function errorMessage(error: unknown): string {
92
+ return error instanceof Error ? error.message : String(error);
93
+ }
94
+
95
+ function promptFromBeforeAgentStart(event: unknown): string | undefined {
96
+ if (!event || typeof event !== "object") return undefined;
97
+ const input = event as { prompt?: unknown; systemPrompt?: unknown };
98
+ if (typeof input.prompt === "string") return input.prompt;
99
+ if (typeof input.systemPrompt === "string") return input.systemPrompt;
100
+ return undefined;
101
+ }
102
+
103
+ function reviewInputSignature(input: string): string {
104
+ return createHash("sha256").update(input).digest("hex");
105
+ }
106
+
107
+ export class MainWatchdogRuntime {
108
+ private cwd: string;
109
+ private readonly resolveConfig: (cwd: string, options?: { session?: Record<string, unknown> }) => WatchdogSettingsResult;
110
+ private readonly review: WatchdogReviewFunction;
111
+ private readonly reviewConnected: boolean;
112
+ private readonly reviewDescription: string;
113
+ private readonly displayWarning: ((warning: WatchdogWarningDetails) => void) | undefined;
114
+ private readonly reviewChangesOnly: boolean;
115
+ private readonly lspDiagnostics: WatchdogLspDiagnosticsFunction;
116
+ private readonly lspLedger = new WatchdogLspDiagnosticsLedger();
117
+ private configResult: WatchdogSettingsResult;
118
+ private sessionOverrideEnabled: boolean | undefined;
119
+ private sessionModelOverride: Partial<Pick<WatchdogEndpointConfig, "model" | "thinking">> | undefined;
120
+ private status: WatchdogRuntimeStatus = "idle";
121
+ private pendingDeltas: string[] = [];
122
+ private pendingDeltaChars = 0;
123
+ private guard = new WatchdogEmissionGuard();
124
+ private guardMaxWarnings: number | null = null;
125
+ private epoch = 0;
126
+ private reviewIdCounter = 0;
127
+ private agentEndIdCounter = 0;
128
+ private activeAgentEndId: number | undefined;
129
+ private activeAgentEndAbortController: AbortController | undefined;
130
+ private activeReviewId: number | undefined;
131
+ private activeReviewWarning: WatchdogWarningDetails | undefined;
132
+ private reviewing = false;
133
+ private waitingAtAgentEnd = false;
134
+ private disposed = false;
135
+ private includeUserPromptInNextDelta = false;
136
+ private userPrompt: string | undefined;
137
+ private waiters: Waiter[] = [];
138
+ private lastWarning: WatchdogWarningDetails | undefined;
139
+ private lastError: string | undefined;
140
+ private lastReviewInputSignature: string | undefined;
141
+ private turnStartChangeSignature: WatchdogRepoChangeSignature | undefined;
142
+ private lastReviewedChangeSignature: string | undefined;
143
+ private currentChangedPaths: string[] | undefined;
144
+ private lastLspSnapshot: WatchdogLspRuntimeSnapshot | undefined;
145
+ private observedRepoEditThisTurn = false;
146
+ private failedReviews = 0;
147
+ private staleReviews = 0;
148
+
149
+ constructor(options: MainWatchdogRuntimeOptions = {}) {
150
+ this.cwd = options.cwd ?? process.cwd();
151
+ this.resolveConfig = options.resolveConfig ?? resolveWatchdogConfig;
152
+ this.review = options.review ?? DEFAULT_REVIEW;
153
+ this.reviewConnected = Boolean(options.review);
154
+ this.reviewDescription = options.reviewDescription ?? (options.review ? "injected seam" : "not wired");
155
+ this.displayWarning = options.displayWarning;
156
+ this.reviewChangesOnly = options.reviewChangesOnly === true;
157
+ this.lspDiagnostics = options.lspDiagnostics ?? collectWatchdogLspDiagnostics;
158
+ this.configResult = this.resolveConfig(this.cwd);
159
+ this.guardMaxWarnings = this.configResult.config.maxWarnings;
160
+ this.guard = new WatchdogEmissionGuard({ maxWarnings: this.guardMaxWarnings });
161
+ this.turnStartChangeSignature = this.currentRepoChangeSignature();
162
+ this.lastReviewedChangeSignature = this.turnStartChangeSignature?.key;
163
+ }
164
+
165
+ bindSession(ctx: ContextLike): void {
166
+ this.cwd = ctx.cwd;
167
+ this.sessionOverrideEnabled = undefined;
168
+ this.sessionModelOverride = undefined;
169
+ this.reset("session_start", { clearReviewInputSignature: true, resetChangeSignature: true, clearLspLedger: true });
170
+ this.refreshConfig(ctx.cwd);
171
+ this.resetRepoChangeBaseline();
172
+ }
173
+
174
+ refreshConfig(cwd = this.cwd): WatchdogSettingsResult {
175
+ this.cwd = cwd;
176
+ const wasEnabled = this.isEnabled();
177
+ const session = this.sessionOverrideEnabled === undefined && this.sessionModelOverride === undefined
178
+ ? undefined
179
+ : {
180
+ ...(this.sessionOverrideEnabled === undefined ? {} : { enabled: this.sessionOverrideEnabled }),
181
+ main: {
182
+ ...(this.sessionOverrideEnabled === undefined ? {} : { enabled: this.sessionOverrideEnabled }),
183
+ ...(this.sessionModelOverride ?? {}),
184
+ },
185
+ };
186
+ this.configResult = this.resolveConfig(this.cwd, session === undefined ? undefined : { session });
187
+ if (this.configResult.config.maxWarnings !== this.guardMaxWarnings) {
188
+ this.guardMaxWarnings = this.configResult.config.maxWarnings;
189
+ this.guard = new WatchdogEmissionGuard({ maxWarnings: this.guardMaxWarnings });
190
+ }
191
+ if (wasEnabled && !this.isEnabled()) this.invalidateActiveReview("watchdog disabled");
192
+ return this.configResult;
193
+ }
194
+
195
+ setSessionEnabled(enabled: boolean, cwd = this.cwd): WatchdogRuntimeSnapshot {
196
+ this.sessionOverrideEnabled = enabled;
197
+ this.reset("session override");
198
+ this.refreshConfig(cwd);
199
+ return this.getSnapshot();
200
+ }
201
+
202
+ setSessionModel(patch: { model?: string | null; thinking?: WatchdogEndpointConfig["thinking"] | null }, cwd = this.cwd): WatchdogRuntimeSnapshot {
203
+ const next = { ...(this.sessionModelOverride ?? {}) };
204
+ if (patch.model === null) delete next.model;
205
+ else if (patch.model !== undefined) next.model = patch.model;
206
+ if (patch.thinking === null) delete next.thinking;
207
+ else if (patch.thinking !== undefined) next.thinking = patch.thinking;
208
+ this.sessionModelOverride = next.model === undefined && next.thinking === undefined ? undefined : next;
209
+ this.reset("session model override");
210
+ this.refreshConfig(cwd);
211
+ return this.getSnapshot();
212
+ }
213
+
214
+ clearSessionModel(cwd = this.cwd): WatchdogRuntimeSnapshot {
215
+ this.sessionModelOverride = undefined;
216
+ this.reset("session model override cleared");
217
+ this.refreshConfig(cwd);
218
+ return this.getSnapshot();
219
+ }
220
+
221
+ clearSessionOverride(cwd = this.cwd): WatchdogRuntimeSnapshot {
222
+ this.sessionOverrideEnabled = undefined;
223
+ this.sessionModelOverride = undefined;
224
+ this.reset("session override cleared");
225
+ this.refreshConfig(cwd);
226
+ return this.getSnapshot();
227
+ }
228
+
229
+ reset(_reason = "reset", options: { clearReviewInputSignature?: boolean; resetChangeSignature?: boolean; clearLspLedger?: boolean } = {}): void {
230
+ this.abortActiveAgentEnd();
231
+ this.epoch++;
232
+ this.status = "idle";
233
+ this.clearPendingDeltas();
234
+ this.reviewing = false;
235
+ this.waitingAtAgentEnd = false;
236
+ this.activeReviewId = undefined;
237
+ this.activeReviewWarning = undefined;
238
+ this.includeUserPromptInNextDelta = false;
239
+ this.userPrompt = undefined;
240
+ this.lastError = undefined;
241
+ this.currentChangedPaths = undefined;
242
+ this.observedRepoEditThisTurn = false;
243
+ if (options.clearLspLedger) {
244
+ this.lspLedger.reset();
245
+ this.lastLspSnapshot = undefined;
246
+ }
247
+ if (options.clearReviewInputSignature) this.lastReviewInputSignature = undefined;
248
+ if (options.resetChangeSignature) this.resetRepoChangeBaseline({ reviewed: true });
249
+ this.guard.reset();
250
+ this.resolveWaiters(true);
251
+ }
252
+
253
+ dispose(): void {
254
+ this.disposed = true;
255
+ this.abortActiveAgentEnd();
256
+ this.epoch++;
257
+ this.status = "idle";
258
+ this.clearPendingDeltas();
259
+ this.reviewing = false;
260
+ this.waitingAtAgentEnd = false;
261
+ this.activeReviewId = undefined;
262
+ this.activeReviewWarning = undefined;
263
+ this.lastReviewInputSignature = undefined;
264
+ this.currentChangedPaths = undefined;
265
+ this.lastLspSnapshot = undefined;
266
+ this.lspLedger.reset();
267
+ this.observedRepoEditThisTurn = false;
268
+ this.resolveWaiters(false);
269
+ }
270
+
271
+ handleBeforeAgentStart(event: unknown, ctx: ContextLike): void {
272
+ if (this.disposed) return;
273
+ this.reset("before_agent_start");
274
+ this.refreshConfig(ctx.cwd);
275
+ this.userPrompt = promptFromBeforeAgentStart(event);
276
+ this.includeUserPromptInNextDelta = Boolean(this.userPrompt?.trim());
277
+ this.resetRepoChangeBaseline();
278
+ }
279
+
280
+ handleTurnEnd(event: unknown, ctx: ContextLike): void {
281
+ if (this.disposed) return;
282
+ this.refreshConfig(ctx.cwd);
283
+ if (!this.isEnabled()) return;
284
+ try {
285
+ this.observedRepoEditThisTurn ||= eventIndicatesRepoEdit(event);
286
+ const delta = formatWatchdogTurnDelta({
287
+ includeUserPrompt: this.includeUserPromptInNextDelta,
288
+ userPrompt: this.userPrompt,
289
+ events: [event],
290
+ });
291
+ this.includeUserPromptInNextDelta = false;
292
+ this.enqueueDelta(delta);
293
+ } catch (error) {
294
+ this.fail(`Failed to format watchdog turn delta: ${errorMessage(error)}`);
295
+ }
296
+ }
297
+
298
+ enqueueDelta(delta: string): void {
299
+ if (this.disposed || !delta.trim() || !this.isEnabled()) return;
300
+ this.appendBoundedDelta(delta);
301
+ if (!this.reviewing && !this.waitingAtAgentEnd) this.status = "queued";
302
+ }
303
+
304
+ async handleAgentEnd(_event: unknown, ctx: ContextLike): Promise<void> {
305
+ if (this.disposed) return;
306
+ this.refreshConfig(ctx.cwd);
307
+ if (!this.isEnabled()) return;
308
+ const changeSignature = this.resolveReviewChangeSignature(ctx.cwd);
309
+ if (this.reviewChangesOnly && !changeSignature) {
310
+ this.clearPendingDeltas();
311
+ if (this.status === "queued") this.status = "idle";
312
+ this.resolveWaiters(true);
313
+ return;
314
+ }
315
+ if (changeSignature && changeSignature.key === this.lastReviewedChangeSignature) {
316
+ this.clearPendingDeltas();
317
+ this.status = "idle";
318
+ this.resolveWaiters(true);
319
+ return;
320
+ }
321
+ this.waitingAtAgentEnd = true;
322
+ const agentEndEpoch = this.epoch;
323
+ const agentEndId = ++this.agentEndIdCounter;
324
+ const lspAbortController = new AbortController();
325
+ this.activeAgentEndId = agentEndId;
326
+ this.activeAgentEndAbortController = lspAbortController;
327
+ try {
328
+ this.guard.startModelUpdate();
329
+ const lspBlock = await this.collectLspDiagnostics(changeSignature, {
330
+ epoch: agentEndEpoch,
331
+ agentEndId,
332
+ signal: lspAbortController.signal,
333
+ });
334
+ if (this.activeAgentEndAbortController === lspAbortController) this.activeAgentEndAbortController = undefined;
335
+ if (!this.isAgentEndCurrent(agentEndEpoch, agentEndId)) return;
336
+ const delta = this.buildReviewInput(changeSignature, lspBlock);
337
+ this.clearPendingDeltas();
338
+ if (!delta.trim()) {
339
+ this.waitingAtAgentEnd = false;
340
+ if (this.status === "queued") this.status = "idle";
341
+ this.resolveWaiters(true);
342
+ return;
343
+ }
344
+ const signature = reviewInputSignature(delta);
345
+ if (!this.reviewChangesOnly && signature === this.lastReviewInputSignature) {
346
+ this.waitingAtAgentEnd = false;
347
+ this.status = "idle";
348
+ this.resolveWaiters(true);
349
+ return;
350
+ }
351
+ const outcome = await this.reviewDelta(delta, this.configResult.config.agentEndTimeoutMs);
352
+ this.waitingAtAgentEnd = false;
353
+ if (outcome === "timeout") {
354
+ this.staleReviews++;
355
+ this.invalidateActiveReview("agent-end timeout");
356
+ this.status = "stale";
357
+ this.markLastWarningStale();
358
+ this.resolveWaiters(true);
359
+ return;
360
+ }
361
+ if (outcome === "completed" && this.status !== "failed" && this.status !== "stale") {
362
+ this.lastReviewInputSignature = signature;
363
+ if (changeSignature) this.lastReviewedChangeSignature = changeSignature.key;
364
+ this.currentChangedPaths = changeSignature?.changedPaths;
365
+ this.status = "idle";
366
+ }
367
+ this.resolveWaiters(true);
368
+ } finally {
369
+ if (this.activeAgentEndAbortController === lspAbortController) this.activeAgentEndAbortController = undefined;
370
+ if (this.activeAgentEndId === agentEndId) this.activeAgentEndId = undefined;
371
+ }
372
+ }
373
+
374
+ recordDisplayedWarning(warning: WatchdogWarning): WatchdogWarningDetails {
375
+ const details = normalizeWatchdogWarningDetails(warning, { state: "displayed", source: warning.source ?? "main" });
376
+ this.lastWarning = details;
377
+ return details;
378
+ }
379
+
380
+ getSnapshot(cwd?: string): WatchdogRuntimeSnapshot {
381
+ if (cwd) this.refreshConfig(cwd);
382
+ return {
383
+ status: this.status,
384
+ enabled: this.isEnabled(),
385
+ config: this.configResult.config,
386
+ configOk: this.configResult.ok,
387
+ errors: [...this.configResult.errors],
388
+ sources: [...this.configResult.sources],
389
+ bufferedDeltas: this.pendingDeltas.length,
390
+ epoch: this.epoch,
391
+ ...(this.activeReviewId !== undefined ? { activeReviewId: this.activeReviewId } : {}),
392
+ ...(this.sessionOverrideEnabled !== undefined ? { sessionOverride: this.sessionOverrideEnabled } : {}),
393
+ ...(this.sessionModelOverride !== undefined ? { sessionModelOverride: { ...this.sessionModelOverride } } : {}),
394
+ ...(this.lastWarning ? { lastWarning: this.lastWarning } : {}),
395
+ ...(this.lastError ? { lastError: this.lastError } : {}),
396
+ failedReviews: this.failedReviews,
397
+ staleReviews: this.staleReviews,
398
+ reviewConnected: this.reviewConnected,
399
+ reviewDescription: this.reviewDescription,
400
+ autoFollowQueued: false,
401
+ reviewTrigger: this.reviewChangesOnly ? "repo-edits" : "turn-delta",
402
+ ...(this.currentChangedPaths?.length ? { changedPaths: [...this.currentChangedPaths] } : {}),
403
+ lsp: this.lspSnapshot(),
404
+ };
405
+ }
406
+
407
+ async waitForIdle(timeoutMs = 1_000): Promise<boolean> {
408
+ return this.waitForSettled(timeoutMs);
409
+ }
410
+
411
+ private isEnabled(): boolean {
412
+ return this.configResult.ok && this.configResult.config.main.enabled;
413
+ }
414
+
415
+ private abortActiveAgentEnd(): void {
416
+ this.activeAgentEndAbortController?.abort();
417
+ this.activeAgentEndAbortController = undefined;
418
+ this.activeAgentEndId = undefined;
419
+ }
420
+
421
+ private isAgentEndCurrent(epoch: number, agentEndId: number): boolean {
422
+ return !this.disposed && this.epoch === epoch && this.activeAgentEndId === agentEndId && this.waitingAtAgentEnd && this.isEnabled();
423
+ }
424
+
425
+ private isCurrent(epoch: number, reviewId: number): boolean {
426
+ return !this.disposed && this.epoch === epoch && this.activeReviewId === reviewId;
427
+ }
428
+
429
+ private acceptWarning(epoch: number, reviewId: number, warning: WatchdogWarning): boolean {
430
+ if (!this.isCurrent(epoch, reviewId) || !this.isEnabled()) return false;
431
+ const decision = this.guard.evaluate(warning);
432
+ if (!decision.accepted) return false;
433
+ const details = normalizeWatchdogWarningDetails(warning, {
434
+ state: "candidate",
435
+ source: warning.source ?? "main",
436
+ identity: decision.identity,
437
+ });
438
+ this.lastWarning = details;
439
+ this.activeReviewWarning = details;
440
+ return true;
441
+ }
442
+
443
+ private displayBoundaryWarning(warning: WatchdogWarning): boolean {
444
+ if (!this.isEnabled()) return false;
445
+ const decision = this.guard.evaluate(warning);
446
+ if (!decision.accepted) return false;
447
+ const details = normalizeWatchdogWarningDetails(warning, {
448
+ state: "displayed",
449
+ source: warning.source ?? "main",
450
+ identity: decision.identity,
451
+ displayedAt: new Date().toISOString(),
452
+ });
453
+ this.lastWarning = details;
454
+ this.displayWarning?.(details);
455
+ return true;
456
+ }
457
+
458
+ private invalidateActiveReview(_reason: string): void {
459
+ this.abortActiveAgentEnd();
460
+ this.epoch++;
461
+ this.status = "idle";
462
+ this.clearPendingDeltas();
463
+ this.reviewing = false;
464
+ this.waitingAtAgentEnd = false;
465
+ this.activeReviewId = undefined;
466
+ this.activeReviewWarning = undefined;
467
+ }
468
+
469
+ private async reviewDelta(delta: string, timeoutMs: number): Promise<ReviewDeltaOutcome> {
470
+ if (this.reviewing || this.disposed) return "stale";
471
+ this.reviewing = true;
472
+ const reviewEpoch = this.epoch;
473
+ const reviewId = ++this.reviewIdCounter;
474
+ this.activeReviewId = reviewId;
475
+ this.activeReviewWarning = undefined;
476
+ this.status = "reviewing";
477
+ let timeout: ReturnType<typeof setTimeout> | undefined;
478
+ const abortController = new AbortController();
479
+ const reviewPromise = Promise.resolve().then(() => this.review({
480
+ delta,
481
+ epoch: reviewEpoch,
482
+ reviewId,
483
+ config: this.configResult.config,
484
+ signal: abortController.signal,
485
+ emitWarning: (warning) => this.acceptWarning(reviewEpoch, reviewId, warning),
486
+ }));
487
+ try {
488
+ const result = await Promise.race([
489
+ reviewPromise,
490
+ new Promise<"timeout">((resolve) => {
491
+ timeout = setTimeout(() => resolve("timeout"), timeoutMs);
492
+ }),
493
+ ]);
494
+ if (result === "timeout") {
495
+ abortController.abort();
496
+ return "timeout";
497
+ }
498
+ if (!this.isCurrent(reviewEpoch, reviewId)) return "stale";
499
+ for (const warning of result?.warnings ?? []) this.acceptWarning(reviewEpoch, reviewId, warning);
500
+ if (result?.stopReason && result.stopReason !== "stop") {
501
+ this.fail(`Watchdog review ended with stop reason '${result.stopReason}'.`);
502
+ return "completed";
503
+ }
504
+ this.displayAcceptedReviewWarning();
505
+ return "completed";
506
+ } catch (error) {
507
+ if (this.isCurrent(reviewEpoch, reviewId)) {
508
+ this.fail(`Watchdog review failed: ${errorMessage(error)}`);
509
+ return "completed";
510
+ }
511
+ return "stale";
512
+ } finally {
513
+ if (timeout) clearTimeout(timeout);
514
+ if (this.epoch === reviewEpoch && this.activeReviewId === reviewId) {
515
+ this.reviewing = false;
516
+ this.activeReviewId = undefined;
517
+ this.activeReviewWarning = undefined;
518
+ }
519
+ this.resolveWaiters(this.isSettled());
520
+ }
521
+ }
522
+
523
+ private displayAcceptedReviewWarning(): void {
524
+ if (!this.activeReviewWarning) return;
525
+ const details: WatchdogWarningDetails = {
526
+ ...this.activeReviewWarning,
527
+ state: "displayed",
528
+ displayedAt: new Date().toISOString(),
529
+ };
530
+ this.lastWarning = details;
531
+ this.displayWarning?.(details);
532
+ }
533
+
534
+ private currentRepoChangeSignature(cwd = this.cwd): WatchdogRepoChangeSignature | undefined {
535
+ return this.reviewChangesOnly ? computeWatchdogRepoChangeSignature(cwd) : undefined;
536
+ }
537
+
538
+ private resetRepoChangeBaseline(options: { cwd?: string; reviewed?: boolean } = {}): void {
539
+ this.turnStartChangeSignature = this.currentRepoChangeSignature(options.cwd ?? this.cwd);
540
+ if (options.reviewed) this.lastReviewedChangeSignature = this.turnStartChangeSignature?.key;
541
+ else this.lastReviewedChangeSignature ??= this.turnStartChangeSignature?.key;
542
+ this.currentChangedPaths = this.turnStartChangeSignature?.changedPaths;
543
+ this.observedRepoEditThisTurn = false;
544
+ }
545
+
546
+ private resolveReviewChangeSignature(cwd = this.cwd): WatchdogRepoChangeSignature | undefined {
547
+ if (!this.reviewChangesOnly) return undefined;
548
+ const current = this.currentRepoChangeSignature(cwd);
549
+ if (current) {
550
+ this.currentChangedPaths = current.changedPaths;
551
+ if (current.key === this.turnStartChangeSignature?.key) return undefined;
552
+ if (current.changedPaths.length === 0) return undefined;
553
+ return current;
554
+ }
555
+ return this.observedRepoEditThisTurn
556
+ ? { root: cwd, key: `observed-edit:${this.epoch}:${this.reviewIdCounter}:${this.pendingDeltaChars}`, changedPaths: [] }
557
+ : undefined;
558
+ }
559
+
560
+ private async collectLspDiagnostics(changeSignature: WatchdogRepoChangeSignature | undefined, current: { epoch: number; agentEndId: number; signal: AbortSignal }): Promise<string> {
561
+ const config = this.configResult.config.lsp;
562
+ if (!config.enabled || !changeSignature?.changedPaths.length) {
563
+ this.lastLspSnapshot = {
564
+ enabled: config.enabled,
565
+ status: config.enabled ? "skipped" : "disabled",
566
+ checkedPaths: [],
567
+ skippedPaths: [],
568
+ diagnostics: [],
569
+ diagnosticCount: 0,
570
+ freshDiagnosticCount: 0,
571
+ updatedAt: new Date().toISOString(),
572
+ };
573
+ return "";
574
+ }
575
+ try {
576
+ const raw = await this.lspDiagnostics({
577
+ cwd: this.cwd,
578
+ root: changeSignature.root,
579
+ changedPaths: changeSignature.changedPaths,
580
+ config,
581
+ signal: current.signal,
582
+ });
583
+ if (!this.isAgentEndCurrent(current.epoch, current.agentEndId)) return "";
584
+ const diagnosticCount = raw.diagnostics.length;
585
+ const fresh = this.lspLedger.reduce(raw);
586
+ this.lastLspSnapshot = {
587
+ ...fresh,
588
+ enabled: true,
589
+ diagnosticCount,
590
+ freshDiagnosticCount: fresh.diagnostics.length,
591
+ updatedAt: new Date().toISOString(),
592
+ };
593
+ const warning = watchdogWarningFromLspDiagnostics(fresh);
594
+ if (warning) this.displayBoundaryWarning(warning);
595
+ return formatWatchdogLspDiagnosticsBlock(fresh);
596
+ } catch (error) {
597
+ if (!this.isAgentEndCurrent(current.epoch, current.agentEndId)) return "";
598
+ this.lastLspSnapshot = {
599
+ enabled: true,
600
+ status: "failed",
601
+ checkedPaths: [],
602
+ skippedPaths: changeSignature.changedPaths,
603
+ diagnostics: [],
604
+ diagnosticCount: 0,
605
+ freshDiagnosticCount: 0,
606
+ message: `LSP diagnostics failed: ${errorMessage(error)}`,
607
+ updatedAt: new Date().toISOString(),
608
+ };
609
+ return "";
610
+ }
611
+ }
612
+
613
+ private lspSnapshot(): WatchdogLspRuntimeSnapshot {
614
+ if (this.lastLspSnapshot) return {
615
+ ...this.lastLspSnapshot,
616
+ checkedPaths: [...this.lastLspSnapshot.checkedPaths],
617
+ skippedPaths: [...this.lastLspSnapshot.skippedPaths],
618
+ diagnostics: [...this.lastLspSnapshot.diagnostics],
619
+ };
620
+ const enabled = this.isEnabled() && this.configResult.config.lsp.enabled;
621
+ return {
622
+ enabled,
623
+ status: enabled ? "skipped" : "disabled",
624
+ checkedPaths: [],
625
+ skippedPaths: [],
626
+ diagnostics: [],
627
+ diagnosticCount: 0,
628
+ freshDiagnosticCount: 0,
629
+ };
630
+ }
631
+
632
+ private appendBoundedDelta(delta: string): void {
633
+ let entry = delta.trim();
634
+ if (!entry) return;
635
+ if (entry.length > MAX_REVIEW_INPUT_CHARS) entry = entry.slice(-MAX_REVIEW_INPUT_CHARS);
636
+ this.pendingDeltas.push(entry);
637
+ this.pendingDeltaChars += entry.length;
638
+ while (this.pendingDeltas.length > 1 && this.pendingDeltaChars + (this.pendingDeltas.length - 1) * REVIEW_DELTA_SEPARATOR.length > MAX_REVIEW_INPUT_CHARS) {
639
+ const removed = this.pendingDeltas.shift();
640
+ if (removed) this.pendingDeltaChars -= removed.length;
641
+ }
642
+ }
643
+
644
+ private buildReviewInput(changeSignature?: WatchdogRepoChangeSignature, lspBlock = ""): string {
645
+ const input = this.pendingDeltas.join(REVIEW_DELTA_SEPARATOR);
646
+ const changes = changeSignature?.changedPaths.length
647
+ ? ["Changed repo paths:", ...changeSignature.changedPaths.slice(0, 200).map((file) => `- ${file}`)].join("\n")
648
+ : "";
649
+ const contextPieces = [changes, lspBlock].filter(Boolean);
650
+ if (!contextPieces.length) return input.length > MAX_REVIEW_INPUT_CHARS ? input.slice(-MAX_REVIEW_INPUT_CHARS) : input;
651
+
652
+ const maxContextLength = Math.floor(MAX_REVIEW_INPUT_CHARS / 2);
653
+ const maxPieceLength = Math.max(1_000, Math.floor(maxContextLength / contextPieces.length));
654
+ const boundedContext = contextPieces.map((piece) => piece.length > maxPieceLength
655
+ ? `${piece.slice(0, maxPieceLength - 6)}\n- ...`
656
+ : piece).join(REVIEW_DELTA_SEPARATOR);
657
+ const separatorLength = input ? REVIEW_DELTA_SEPARATOR.length : 0;
658
+ const inputBudget = MAX_REVIEW_INPUT_CHARS - boundedContext.length - separatorLength;
659
+ const boundedInput = inputBudget <= 0
660
+ ? ""
661
+ : input.length > inputBudget
662
+ ? input.slice(-inputBudget)
663
+ : input;
664
+ return [boundedContext, boundedInput].filter(Boolean).join(REVIEW_DELTA_SEPARATOR);
665
+ }
666
+
667
+ private clearPendingDeltas(): void {
668
+ this.pendingDeltas = [];
669
+ this.pendingDeltaChars = 0;
670
+ }
671
+
672
+ private fail(message: string): void {
673
+ this.failedReviews++;
674
+ this.lastError = message;
675
+ this.status = "failed";
676
+ this.clearPendingDeltas();
677
+ this.resolveWaiters(true);
678
+ }
679
+
680
+ private markLastWarningStale(): void {
681
+ if (!this.lastWarning || this.lastWarning.state === "displayed") return;
682
+ this.lastWarning = { ...this.lastWarning, stale: true, state: "stale" };
683
+ }
684
+
685
+ private isSettled(): boolean {
686
+ return !this.reviewing && this.pendingDeltas.length === 0;
687
+ }
688
+
689
+ private waitForSettled(timeoutMs: number): Promise<boolean> {
690
+ if (this.isSettled()) return Promise.resolve(true);
691
+ return new Promise((resolve) => {
692
+ const waiter: Waiter = {
693
+ resolve,
694
+ timer: setTimeout(() => {
695
+ this.waiters = this.waiters.filter((entry) => entry !== waiter);
696
+ resolve(false);
697
+ }, timeoutMs),
698
+ };
699
+ this.waiters.push(waiter);
700
+ });
701
+ }
702
+
703
+ private resolveWaiters(settled: boolean): void {
704
+ if (!settled && !this.disposed) return;
705
+ const waiters = this.waiters;
706
+ this.waiters = [];
707
+ for (const waiter of waiters) {
708
+ clearTimeout(waiter.timer);
709
+ waiter.resolve(settled);
710
+ }
711
+ }
712
+ }