todo-enforcer 1.0.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/src/index.ts ADDED
@@ -0,0 +1,1022 @@
1
+ /**
2
+ * todo-enforcer — pi extension
3
+ *
4
+ * Monitors the agent's todo state on each agent_end. When the agent goes idle
5
+ * with incomplete tasks, evaluates a configurable rule set and injects a
6
+ * message to keep the agent working. Works in both TUI and non-TUI (headless) modes.
7
+ *
8
+ * Inspired by oh-my-opencode's "Todo Continuation Enforcer" hook.
9
+ *
10
+ * Configuration (todo-enforcer.json):
11
+ * - ~/.todo-enforcer.json (global)
12
+ * - <cwd>/.todo-enforcer.json (project override)
13
+ *
14
+ * Delivery modes (config: messageDelivery.mode):
15
+ * - "userMessage" (default) — pi.sendUserMessage(text, opts)
16
+ * - "customMessage" — pi.sendMessage({ customType, content, display }, opts)
17
+ *
18
+ * Structure:
19
+ * todo-enforcer/
20
+ * ├── index.ts ← THIS FILE (entry point)
21
+ * ├── config.ts ← config loader + types + template interpolation
22
+ * ├── conditions.ts ← built-in + custom condition evaluator
23
+ * ├── external-caller.ts ← external command executor
24
+ * ├── session-state.ts ← per-session state tracking
25
+ * └── todo-snapshot.ts ← reads rpiv-todo state
26
+ *
27
+ * @see flow/requirements/todo-enforcer.md
28
+ */
29
+ // @ts-nocheck
30
+
31
+ //
32
+
33
+
34
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
35
+ import { createPluginLogger } from "./lib/plugin-logger";
36
+ import { registerHook, isEnabled } from "./lib/hooks-manager";
37
+ import { evaluateCondition } from "./conditions";
38
+ import type {
39
+ EnforcerRule,
40
+ MessageDeliveryConfig,
41
+ SessionContext,
42
+ SpawnConfig,
43
+ TodoEnforcerConfig,
44
+ TodoSnapshot,
45
+ } from "./config";
46
+ import { DEFAULT_CONFIG, interpolateTemplate, loadConfigAsync } from "./config";
47
+ import { dummyExternalCall, executeExternalCall } from "./external-caller";
48
+ import {
49
+ checkSimilarError,
50
+ clearSessionIdentity,
51
+ setSessionState,
52
+ getCachedBranch,
53
+ setCachedBranch,
54
+ getCachedSessionId,
55
+ getState,
56
+ hasProgress,
57
+ incrementBackoff,
58
+ isCooldownElapsed,
59
+ isUnderLimit,
60
+ markCancelled,
61
+ markEvaluating,
62
+ markInFlight,
63
+ markInjection,
64
+ recordBranchLength,
65
+ resetBackoff,
66
+ resetConsecutive,
67
+ resetErrorTracking,
68
+ resetStagnation,
69
+ resetState,
70
+ setSpawnInFlight,
71
+ trackStagnation,
72
+ } from "./session-state";
73
+ import { checkMessageStall, resetStallState } from "./message-stall";
74
+ import { spawn as spawnProcess } from "node:child_process";
75
+ import { type SessionEntry, buildSessionContext, buildTodoSnapshot } from "./todo-snapshot";
76
+
77
+ /** Default timeout for spawned pi child processes (2 hours) */
78
+ const DEFAULT_SPAWN_TIMEOUT_MS = 7_200_000;
79
+
80
+ const HOOK_NAME = "todo-enforcer";
81
+ const logger = createPluginLogger(HOOK_NAME);
82
+
83
+ function safeWrap<T>(label: string, fn: () => T): T | null {
84
+ try {
85
+ return fn();
86
+ } catch (err) {
87
+ const message = err instanceof Error ? err.message : String(err);
88
+ logger.error(`${label}: ${message}`);
89
+ return null;
90
+ }
91
+ }
92
+
93
+ async function safeWrapAsync<T>(
94
+ label: string,
95
+ fn: () => Promise<T>,
96
+ ): Promise<T | null> {
97
+ try {
98
+ return await fn();
99
+ } catch (err) {
100
+ const message = err instanceof Error ? err.message : String(err);
101
+ logger.error(`${label}: ${message}`);
102
+ return null;
103
+ }
104
+ }
105
+
106
+ // Command handlers use getCachedSessionId() — session identity is
107
+ // already captured in session_start before any command can run.
108
+
109
+ function getProgressBranchLength(branch: unknown[]): number {
110
+ return branch.filter((entry) => {
111
+ if (!entry || typeof entry !== "object") return true;
112
+ const message = (
113
+ entry as {
114
+ message?: { customType?: string; role?: string; toolName?: string; content?: unknown };
115
+ }
116
+ ).message;
117
+ if (!message) return true;
118
+ // Exclude enforcer's own injected custom messages from progress measurement
119
+ if (message.customType === HOOK_NAME) return false;
120
+ // Exclude todo tool results (status queries, not real work)
121
+ if (message.role === "toolResult" && message.toolName === "todo") return false;
122
+ // Exclude user messages injected by the enforcer via sendUserMessage
123
+ if (message.role === "user" && typeof message.content === "string") {
124
+ const text = (message.content as string).substring(0, 200);
125
+ if (
126
+ text.includes("You have incomplete tasks. Continue working on them.") ||
127
+ text.includes("Pick up where you left off.")
128
+ ) return false;
129
+ }
130
+ return true;
131
+ }).length;
132
+ }
133
+
134
+ function findMatchingRule(
135
+ rules: EnforcerRule[],
136
+ snapshot: TodoSnapshot,
137
+ ): EnforcerRule | null {
138
+ for (const rule of rules) {
139
+ const matched = safeWrap(`condition "${rule.name}"`, () =>
140
+ evaluateCondition(rule.condition, snapshot),
141
+ );
142
+ if (matched) return rule;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ async function executeRule(
148
+ rule: EnforcerRule,
149
+ snapshot: TodoSnapshot,
150
+ context: SessionContext,
151
+ useDummyExternal: boolean,
152
+ ): Promise<string | null> {
153
+ switch (rule.action) {
154
+ case "prompt": {
155
+ if (!rule.prompt) {
156
+ logger.warn(
157
+ `Rule "${rule.name}" has action=prompt but no prompt defined`,
158
+ );
159
+ return null;
160
+ }
161
+ return interpolateTemplate(rule.prompt, snapshot);
162
+ }
163
+
164
+ case "external": {
165
+ if (!rule.external) {
166
+ logger.warn(
167
+ `Rule "${rule.name}" has action=external but no external config`,
168
+ );
169
+ return null;
170
+ }
171
+ const caller = useDummyExternal ? dummyExternalCall : executeExternalCall;
172
+ const result = await caller(rule.external, snapshot, context);
173
+ if (!result.success) {
174
+ const fallback = rule.external.errorFallback ?? "default_prompt";
175
+ if (fallback === "skip") {
176
+ return null;
177
+ }
178
+ // default_prompt: fall through — no matching fallback rule available
179
+ return null;
180
+ }
181
+ return result.output;
182
+ }
183
+
184
+ case "noop":
185
+ return null;
186
+
187
+ default: {
188
+ const _exhaustiveCheck: never = rule.action;
189
+ logger.warn(`Unknown action: ${String(_exhaustiveCheck)}`);
190
+ return null;
191
+ }
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Deliver the enforcer message using the configured mode.
197
+ *
198
+ * - "userMessage" (default): uses pi.sendUserMessage() — appears as a user
199
+ * message in the conversation. Works in both TUI and non-TUI modes.
200
+ * - "customMessage": uses pi.sendMessage() with a structured customType —
201
+ * appears as a custom message with configurable display/triggerTurn/deliverAs.
202
+ *
203
+ * Both modes work in TUI and non-TUI. Neither gates on hasUI.
204
+ */
205
+ /**
206
+ * Deliver the enforcer message using the configured mode.
207
+ *
208
+ * CRITICAL: When called from agent_end, the agent is already idle.
209
+ * - userMessage mode: do NOT pass deliverAs — sendUserMessage without options
210
+ * triggers a new turn immediately. Passing deliverAs="followUp" causes the
211
+ * message to be queued for "after agent finishes" but agent already finished,
212
+ * so the message sits forever and no turn is triggered.
213
+ * - customMessage mode: use triggerTurn: true + deliverAs: "steer" to ensure
214
+ * the idle agent picks it up and starts a new turn.
215
+ */
216
+ function deliverMessage(
217
+ pi: ExtensionAPI,
218
+ delivery: MessageDeliveryConfig,
219
+ message: string,
220
+ ): void {
221
+ const mode = delivery.mode ?? "userMessage";
222
+
223
+ if (mode === "userMessage") {
224
+ // No deliverAs — let sendUserMessage trigger a turn immediately.
225
+ // This is the correct behavior when agent is idle at agent_end.
226
+ pi.sendUserMessage(message);
227
+ } else {
228
+ pi.sendMessage(
229
+ {
230
+ customType: delivery.customType ?? HOOK_NAME,
231
+ content: message,
232
+ display: delivery.display ?? true,
233
+ },
234
+ {
235
+ triggerTurn: true,
236
+ deliverAs: "steer",
237
+ },
238
+ );
239
+ }
240
+ }
241
+
242
+ export default function (pi: ExtensionAPI) {
243
+ type ConfigCacheState = {
244
+ cwd: string | null;
245
+ value: TodoEnforcerConfig | null;
246
+ promise: Promise<TodoEnforcerConfig> | null;
247
+ };
248
+
249
+ const configState: ConfigCacheState = {
250
+ cwd: null,
251
+ value: null,
252
+ promise: null,
253
+ };
254
+
255
+ let config: TodoEnforcerConfig | null = null;
256
+
257
+ function startConfigLoad(cwd: string): Promise<TodoEnforcerConfig> {
258
+ if (configState.cwd === cwd && configState.value) {
259
+ return Promise.resolve(configState.value);
260
+ }
261
+ if (configState.cwd === cwd && configState.promise) {
262
+ return configState.promise;
263
+ }
264
+
265
+ configState.cwd = cwd;
266
+ configState.value = null;
267
+ const promise = loadConfigAsync(cwd)
268
+ .then((loaded: TodoEnforcerConfig) => {
269
+ configState.value = loaded;
270
+ config = loaded;
271
+ logger.info("config-loaded", {
272
+ rules: loaded.rules.length,
273
+ maxInjections: loaded.maxInjections,
274
+ cooldownMs: loaded.cooldownMs,
275
+ logFile: logger.filePath,
276
+ });
277
+ return loaded;
278
+ })
279
+ .catch((error) => {
280
+ const msg = error instanceof Error ? error.message : String(error);
281
+ logger.error("config-load failed", { error: msg });
282
+ configState.value = null;
283
+ config = null;
284
+ return DEFAULT_CONFIG;
285
+ })
286
+ .finally(() => {
287
+ configState.promise = null;
288
+ });
289
+ configState.promise = promise;
290
+ return promise;
291
+ }
292
+
293
+ function getConfigWhenNeeded(cwd: string): Promise<TodoEnforcerConfig> {
294
+ if (configState.cwd === cwd && configState.value) {
295
+ config = configState.value;
296
+ return Promise.resolve(configState.value);
297
+ }
298
+ return startConfigLoad(cwd);
299
+ }
300
+
301
+ // ── Polling: timer-based re-evaluation after injection ────────────────
302
+ //
303
+ // After an injection, schedule a timer to re-check conditions once the
304
+ // cooldown expires. This ensures the enforcer can fire even if the agent
305
+ // goes idle without producing another agent_end event.
306
+ //
307
+ // Cancelled on: natural agent_end, session shutdown, all tasks done,
308
+ // max injections reached.
309
+
310
+ const pollTimers = new Map<string, ReturnType<typeof setTimeout>>();
311
+
312
+ function cancelPoll(sessionId: string): void {
313
+ const existing = pollTimers.get(sessionId);
314
+ if (existing) {
315
+ clearTimeout(existing);
316
+ pollTimers.delete(sessionId);
317
+ }
318
+ }
319
+
320
+ function schedulePoll(
321
+ sessionId: string,
322
+ cwd: string,
323
+ overrideDelayMs?: number,
324
+ ): void {
325
+ cancelPoll(sessionId);
326
+ const baseDelay = config?.cooldownMs ?? 5_000;
327
+ const delay =
328
+ overrideDelayMs !== undefined ? overrideDelayMs : baseDelay + 1_000;
329
+ if (delay <= 0) {
330
+ void runPoll(sessionId, cwd);
331
+ return;
332
+ }
333
+ const timer = setTimeout(() => {
334
+ pollTimers.delete(sessionId);
335
+ void runPoll(sessionId, cwd);
336
+ }, delay);
337
+ pollTimers.set(sessionId, timer);
338
+ }
339
+
340
+ async function runPoll(sessionId: string, cwd: string): Promise<void> {
341
+ const state = getState(sessionId);
342
+ if (state.spawnInFlight || state.wasCancelled || state.isEvaluating) return;
343
+
344
+ const currentCfg = await getConfigWhenNeeded(cwd);
345
+ if (!currentCfg.enabled) return;
346
+
347
+ markEvaluating(sessionId, true);
348
+ try {
349
+ // Progress detection using cached branch
350
+ const branch = getCachedBranch();
351
+ const branchLength = Array.isArray(branch)
352
+ ? getProgressBranchLength(branch)
353
+ : 0;
354
+ if (hasProgress(sessionId, branchLength)) {
355
+ resetConsecutive(sessionId);
356
+ resetErrorTracking(sessionId);
357
+ resetStagnation(sessionId);
358
+ }
359
+ recordBranchLength(sessionId, branchLength);
360
+
361
+ const injected = await pollEvaluate(sessionId, currentCfg, cwd);
362
+ if (injected) {
363
+ schedulePoll(sessionId, cwd);
364
+ }
365
+ } finally {
366
+ markEvaluating(sessionId, false);
367
+ }
368
+ }
369
+
370
+ /** Shared evaluation logic for poll timers (subset of agent_end). */
371
+ async function pollEvaluate(
372
+ sessionId: string,
373
+ cfg: TodoEnforcerConfig,
374
+ cwd: string,
375
+ ): Promise<boolean> {
376
+ const state = getState(sessionId);
377
+ if (
378
+ state.spawnInFlight ||
379
+ state.isRecovering ||
380
+ state.wasCancelled ||
381
+ state.inFlight
382
+ ) {
383
+ return false;
384
+ }
385
+ if (!isUnderLimit(sessionId, cfg.maxInjections ?? 5)) return false;
386
+ if (!isCooldownElapsed(sessionId, cfg.cooldownMs ?? 5_000, cfg.backoff))
387
+ return false;
388
+
389
+ const context = safeWrap("buildSessionContext", () =>
390
+ buildSessionContext(
391
+ sessionId,
392
+ cwd,
393
+ () => getCachedBranch() as SessionEntry[],
394
+ cfg.contextFeed ?? {},
395
+ ),
396
+ );
397
+ if (!context) return false;
398
+
399
+ const snapshotResult = safeWrap("buildSnapshot", () =>
400
+ buildTodoSnapshot(sessionId, () => getCachedBranch() as SessionEntry[], context),
401
+ );
402
+ if (!snapshotResult || !snapshotResult.available) return false;
403
+
404
+ if (
405
+ cfg.detectStagnation !== false &&
406
+ snapshotResult.snapshot.incompleteCount > 0
407
+ ) {
408
+ const stagnant = trackStagnation(
409
+ sessionId,
410
+ snapshotResult.snapshot.incompleteCount,
411
+ cfg.stagnationThreshold ?? 3,
412
+ );
413
+ if (stagnant) return false;
414
+ }
415
+
416
+ let activeRules = getActiveRules(sessionId);
417
+
418
+ // Suppress all_complete rules when completionSummary is disabled (default)
419
+ if (!cfg.completionSummary) {
420
+ activeRules = activeRules.filter(
421
+ (rule) => rule.condition !== "all_complete",
422
+ );
423
+ }
424
+
425
+ const rule = findMatchingRule(activeRules, snapshotResult.snapshot);
426
+ if (!rule) return false;
427
+
428
+ logger.info("rule-matched (poll)", {
429
+ sessionId,
430
+ rule: rule.name,
431
+ action: rule.action,
432
+ });
433
+
434
+ // Handle spawn action
435
+ if (rule.action === "spawn" && rule.spawn) {
436
+ setSpawnInFlight(sessionId, true);
437
+ markInjection(sessionId);
438
+ resetStagnation(sessionId);
439
+ executeSpawnAction(
440
+ rule.spawn,
441
+ snapshotResult.snapshot,
442
+ context,
443
+ sessionId,
444
+ cfg.messageDelivery ?? {},
445
+ cwd,
446
+ );
447
+ return true;
448
+ }
449
+
450
+ const message = await safeWrapAsync(`rule "${rule.name}"`, () =>
451
+ executeRule(rule, snapshotResult.snapshot, context, false),
452
+ );
453
+ if (!message) return false;
454
+
455
+ // ── Stall guard: repeated message + rate limit ───────────────────
456
+ const stallPoll = checkMessageStall(sessionId, message);
457
+ if (stallPoll.stalled) {
458
+ logger.warn("message-stalled (poll)", {
459
+ sessionId,
460
+ reason: stallPoll.reason,
461
+ rule: rule.name,
462
+ });
463
+ return false;
464
+ }
465
+
466
+ markInFlight(sessionId, true);
467
+ try {
468
+ deliverMessage(pi, cfg.messageDelivery ?? {}, message);
469
+ markInjection(sessionId);
470
+ resetStagnation(sessionId);
471
+
472
+ if (cfg.backoff?.enabled !== false) {
473
+ const matched = (cfg.backoff?.errorPatterns ?? []).some((p) =>
474
+ new RegExp(p, "i").test(message),
475
+ );
476
+ if (matched) incrementBackoff(sessionId);
477
+ else resetBackoff(sessionId);
478
+ }
479
+
480
+ logger.info("injection-delivered (poll)", {
481
+ sessionId,
482
+ injectionCount: getState(sessionId).injectionCount,
483
+ rule: rule.name,
484
+ });
485
+ } catch (err) {
486
+ const msg = err instanceof Error ? err.message : String(err);
487
+ logger.error(`Poll injection failed: ${msg}`, {
488
+ sessionId,
489
+ rule: rule.name,
490
+ });
491
+ } finally {
492
+ markInFlight(sessionId, false);
493
+ }
494
+
495
+ return true;
496
+ }
497
+
498
+ // ── Spawn action: run pi -p in background ─────────────────────────────
499
+
500
+ function executeSpawnAction(
501
+ spawnConfig: SpawnConfig,
502
+ snapshot: TodoSnapshot,
503
+ _context: SessionContext,
504
+ sessionId: string,
505
+ delivery: MessageDeliveryConfig,
506
+ cwd: string,
507
+ ): void {
508
+ const prompt = interpolateTemplate(spawnConfig.template, snapshot);
509
+ const timeout = spawnConfig.timeoutMs ?? DEFAULT_SPAWN_TIMEOUT_MS; // 2 hours
510
+
511
+ logger.info("spawn-started", {
512
+ sessionId,
513
+ timeout,
514
+ promptLength: prompt.length,
515
+ });
516
+
517
+ try {
518
+ const child = spawnProcess("pi", ["-p", prompt], {
519
+ cwd: spawnConfig.cwd ?? cwd,
520
+ stdio: ["ignore", "pipe", "pipe"],
521
+ });
522
+
523
+ let stdout = "";
524
+ let stderr = "";
525
+
526
+ const killTimer = setTimeout(() => {
527
+ child.kill("SIGTERM");
528
+ logger.warn("spawn-timeout", { sessionId, timeout });
529
+ }, timeout);
530
+
531
+ child.stdout?.on("data", (data: Buffer) => {
532
+ stdout += data.toString();
533
+ });
534
+ child.stderr?.on("data", (data: Buffer) => {
535
+ stderr += data.toString();
536
+ });
537
+
538
+ child.on("close", (code) => {
539
+ clearTimeout(killTimer);
540
+ setSpawnInFlight(sessionId, false);
541
+
542
+ if (code === 0 && stdout.trim()) {
543
+ logger.info("spawn-completed", {
544
+ sessionId,
545
+ outputLength: stdout.length,
546
+ });
547
+ deliverMessage(pi, delivery, stdout.trim());
548
+ schedulePoll(sessionId, cwd, 0);
549
+ } else {
550
+ logger.warn("spawn-failed", {
551
+ sessionId,
552
+ code,
553
+ stderr: stderr.substring(0, 500),
554
+ });
555
+ schedulePoll(sessionId, cwd);
556
+ }
557
+ });
558
+
559
+ child.on("error", (err) => {
560
+ clearTimeout(killTimer);
561
+ setSpawnInFlight(sessionId, false);
562
+ logger.error("spawn-error", { sessionId, err: err.message });
563
+ schedulePoll(sessionId, cwd);
564
+ });
565
+ } catch (err) {
566
+ setSpawnInFlight(sessionId, false);
567
+ logger.error(
568
+ `spawn-exception: ${err instanceof Error ? err.message : String(err)}`,
569
+ { sessionId },
570
+ );
571
+ }
572
+ }
573
+
574
+ registerHook("todo-enforcer", "session_start", { blocking: false, source: "pi", origin: "global" });
575
+ registerHook("todo-enforcer", "session_shutdown", { blocking: false, source: "pi", origin: "global" });
576
+ registerHook("todo-enforcer", "agent_end", { blocking: false, source: "pi", origin: "global" });
577
+
578
+ pi.on("session_start", (_event, ctx) => {
579
+ if (!isEnabled("todo-enforcer", "session_start")) return;
580
+
581
+ // Extract cwd immediately — ctx becomes stale after session replacement.
582
+ const sessionCwd = ctx.cwd;
583
+ try {
584
+ // Capture session identity while ctx is fresh — all other hooks
585
+ // use the cached value to avoid stale-ctx crashes.
586
+ const sm = ctx.sessionManager;
587
+ setSessionState(sm.getSessionFile(), sm.getBranch());
588
+ const sessionId = getCachedSessionId();
589
+ resetState(sessionId);
590
+ resetStallState(sessionId);
591
+ void startConfigLoad(sessionCwd);
592
+ logger.info("session-start", { sessionId, cwd: sessionCwd });
593
+ } catch (error) {
594
+ const msg = error instanceof Error ? error.message : String(error);
595
+ logger.error("session_start error", { error: msg });
596
+ }
597
+ });
598
+
599
+ pi.on("session_shutdown", () => {
600
+ if (!isEnabled("todo-enforcer", "session_shutdown")) return;
601
+
602
+ try {
603
+ const sessionId = getCachedSessionId();
604
+ cancelPoll(sessionId);
605
+ clearSessionIdentity();
606
+ sessionActiveRules.delete(sessionId);
607
+ // Individual state is cleaned on next session_start.
608
+ } catch (error) {
609
+ const msg = error instanceof Error ? error.message : String(error);
610
+ logger.error("session_shutdown error", { error: msg });
611
+ }
612
+ });
613
+
614
+ pi.on("agent_end", async (event, ctx) => {
615
+ if (!isEnabled("todo-enforcer", "agent_end")) return;
616
+
617
+ // Extract cwd immediately — ctx becomes stale after session replacement.
618
+ const sessionCwd = ctx.cwd;
619
+ const sm = ctx.sessionManager;
620
+ setCachedBranch(sm.getBranch());
621
+ const sessionId = getCachedSessionId();
622
+ const cfg = await getConfigWhenNeeded(sessionCwd);
623
+
624
+ if (!cfg.enabled) return;
625
+
626
+ const state = getState(sessionId);
627
+ if (state.isEvaluating) {
628
+ logger.debug("skipped", { sessionId, reason: "evaluation-in-flight" });
629
+ return;
630
+ }
631
+ markEvaluating(sessionId, true);
632
+
633
+ try {
634
+ // Detect user-initiated abort (Esc key) — suppress all future injections
635
+ if (
636
+ event.messages &&
637
+ Array.isArray(event.messages) &&
638
+ event.messages.length > 0
639
+ ) {
640
+ const lastAssistant = [...event.messages]
641
+ .reverse()
642
+ .find(
643
+ (m): m is Extract<typeof m, { role: "assistant" }> =>
644
+ "role" in m && (m as { role?: string }).role === "assistant",
645
+ );
646
+ if (
647
+ lastAssistant &&
648
+ "stopReason" in lastAssistant &&
649
+ (lastAssistant as { stopReason?: string }).stopReason === "aborted"
650
+ ) {
651
+ markCancelled(sessionId);
652
+ logger.info("agent-aborted", { sessionId, reason: "user-esc" });
653
+ return;
654
+ }
655
+ }
656
+
657
+ // ── Detect LLM errors (red line / quota) → fuzzy compare → backoff ──
658
+ if (
659
+ event.messages &&
660
+ Array.isArray(event.messages) &&
661
+ event.messages.length > 0
662
+ ) {
663
+ const lastErr = [...event.messages]
664
+ .reverse()
665
+ .find(
666
+ (
667
+ m,
668
+ ): m is Extract<
669
+ typeof m,
670
+ { role: "assistant"; stopReason: string; errorMessage?: string }
671
+ > =>
672
+ "role" in m &&
673
+ (m as { role?: string }).role === "assistant" &&
674
+ "stopReason" in m &&
675
+ ((m as { stopReason?: string }).stopReason === "error" ||
676
+ (m as { stopReason?: string }).stopReason === "aborted"),
677
+ );
678
+ if (lastErr?.errorMessage) {
679
+ const { isSimilar, similarCount } = checkSimilarError(
680
+ sessionId,
681
+ lastErr.errorMessage,
682
+ );
683
+ if (isSimilar) {
684
+ incrementBackoff(sessionId);
685
+ logger.warn("similar-llm-error", {
686
+ sessionId,
687
+ similarCount,
688
+ backoffCount: getState(sessionId).backoffCount,
689
+ });
690
+ }
691
+ }
692
+ }
693
+
694
+ // ── Progress detection: reset consecutive count if meaningful branch grew ──
695
+ const branch = getCachedBranch();
696
+ const branchLength = Array.isArray(branch)
697
+ ? getProgressBranchLength(branch)
698
+ : 0;
699
+ if (hasProgress(sessionId, branchLength)) {
700
+ logger.debug("progress-detected", { sessionId, branchLength });
701
+ resetConsecutive(sessionId);
702
+ resetErrorTracking(sessionId);
703
+ resetStagnation(sessionId);
704
+ }
705
+ recordBranchLength(sessionId, branchLength);
706
+
707
+ if (state.isRecovering) {
708
+ logger.debug("skipped", { sessionId, reason: "recovering" });
709
+ return;
710
+ }
711
+ if (state.wasCancelled) {
712
+ logger.debug("skipped", { sessionId, reason: "cancelled" });
713
+ return;
714
+ }
715
+ if (state.inFlight) {
716
+ logger.debug("skipped", { sessionId, reason: "in-flight" });
717
+ return;
718
+ }
719
+
720
+ if (!isUnderLimit(sessionId, cfg.maxInjections ?? 5)) {
721
+ logger.info("skipped", {
722
+ sessionId,
723
+ reason: "max-consecutive-injections",
724
+ consecutiveCount: state.consecutiveCount,
725
+ maxInjections: cfg.maxInjections ?? 5,
726
+ injectionCount: state.injectionCount,
727
+ });
728
+ return;
729
+ }
730
+
731
+ if (
732
+ !isCooldownElapsed(sessionId, cfg.cooldownMs ?? 60_000, cfg.backoff)
733
+ ) {
734
+ const s = getState(sessionId);
735
+ const delay = Math.min(
736
+ (cfg.cooldownMs ?? 60_000) *
737
+ (cfg.backoff?.factor ?? 2) ** s.backoffCount,
738
+ cfg.backoff?.maxDelayMs ?? 3_600_000,
739
+ );
740
+ const remaining = Math.ceil(
741
+ (delay - (Date.now() - (s.lastInjectedAt ?? 0))) / 1000,
742
+ );
743
+ logger.debug("skipped", {
744
+ sessionId,
745
+ reason: "cooldown-active",
746
+ remainingSeconds: remaining,
747
+ backoffCount: s.backoffCount,
748
+ });
749
+ return;
750
+ }
751
+
752
+ const context = safeWrap("buildSessionContext", () =>
753
+ buildSessionContext(
754
+ sessionId,
755
+ sessionCwd,
756
+ () => getCachedBranch() as SessionEntry[],
757
+ cfg.contextFeed ?? {},
758
+ ),
759
+ );
760
+ if (!context) return;
761
+
762
+ const snapshotResult = safeWrap("buildSnapshot", () =>
763
+ buildTodoSnapshot(
764
+ sessionId,
765
+ () => getCachedBranch() as SessionEntry[],
766
+ context,
767
+ ),
768
+ );
769
+ if (!snapshotResult || !snapshotResult.available) {
770
+ return;
771
+ }
772
+
773
+ if (
774
+ cfg.detectStagnation !== false &&
775
+ snapshotResult.snapshot.incompleteCount > 0
776
+ ) {
777
+ const threshold = cfg.stagnationThreshold ?? 3;
778
+ const stagnant = trackStagnation(
779
+ sessionId,
780
+ snapshotResult.snapshot.incompleteCount,
781
+ threshold,
782
+ );
783
+ if (stagnant) {
784
+ logger.info("stagnation-detected", {
785
+ sessionId,
786
+ incompleteCount: snapshotResult.snapshot.incompleteCount,
787
+ threshold: cfg.stagnationThreshold ?? 3,
788
+ });
789
+ return;
790
+ }
791
+ }
792
+
793
+ let activeRules = getActiveRules(sessionId);
794
+
795
+ // Suppress all_complete rules when completionSummary is disabled (default)
796
+ if (!cfg.completionSummary) {
797
+ activeRules = activeRules.filter(
798
+ (rule) => rule.condition !== "all_complete",
799
+ );
800
+ }
801
+
802
+ const rule = findMatchingRule(activeRules, snapshotResult.snapshot);
803
+ if (!rule) {
804
+ logger.debug("skipped", { sessionId, reason: "no-matching-rule" });
805
+ return;
806
+ }
807
+
808
+ logger.info("rule-matched", {
809
+ sessionId,
810
+ rule: rule.name,
811
+ condition: rule.condition,
812
+ action: rule.action,
813
+ });
814
+
815
+ const message = await safeWrapAsync(`rule "${rule.name}"`, () =>
816
+ executeRule(rule, snapshotResult.snapshot, context, false),
817
+ );
818
+ if (!message) return;
819
+
820
+ // ── Stall guard: repeated message + rate limit ───────────────────
821
+ const stallAgent = checkMessageStall(sessionId, message);
822
+ if (stallAgent.stalled) {
823
+ logger.warn("message-stalled", {
824
+ sessionId,
825
+ reason: stallAgent.reason,
826
+ rule: rule.name,
827
+ });
828
+ return;
829
+ }
830
+
831
+ markInFlight(sessionId, true);
832
+ try {
833
+ deliverMessage(pi, cfg.messageDelivery ?? {}, message);
834
+
835
+ markInjection(sessionId);
836
+ resetStagnation(sessionId);
837
+
838
+ if (cfg.backoff?.enabled !== false) {
839
+ const patterns = cfg.backoff?.errorPatterns ?? [];
840
+ const matched = patterns.some((pattern) =>
841
+ new RegExp(pattern, "i").test(message),
842
+ );
843
+ if (matched) {
844
+ incrementBackoff(sessionId);
845
+ logger.warn("output-error-pattern-matched", {
846
+ sessionId,
847
+ backoffCount: getState(sessionId).backoffCount,
848
+ });
849
+ } else {
850
+ resetBackoff(sessionId);
851
+ }
852
+ }
853
+
854
+ // Schedule poll for re-evaluation after cooldown
855
+ schedulePoll(sessionId, sessionCwd);
856
+
857
+ logger.info("injection-delivered", {
858
+ sessionId,
859
+ injectionCount: state.injectionCount,
860
+ rule: rule.name,
861
+ });
862
+ } catch (err) {
863
+ const msg = err instanceof Error ? err.message : String(err);
864
+ logger.error(`Injection failed: ${msg}`, {
865
+ sessionId,
866
+ rule: rule.name,
867
+ });
868
+ } finally {
869
+ markInFlight(sessionId, false);
870
+ }
871
+ } finally {
872
+ markEvaluating(sessionId, false);
873
+ }
874
+ });
875
+
876
+ // No context hook needed — delivery is direct via sendUserMessage/sendMessage.
877
+ // Both APIs work in TUI and non-TUI modes.
878
+
879
+ const sessionActiveRules: Map<string, Set<string> | null> = new Map();
880
+
881
+ function getActiveRules(sessionId: string): EnforcerRule[] {
882
+ const override = sessionActiveRules.get(sessionId);
883
+ if (override === null) return config?.rules ?? [];
884
+ if (override === undefined) return config?.rules ?? [];
885
+ return (config?.rules ?? []).filter((rule) => override.has(rule.name));
886
+ }
887
+
888
+ pi.registerCommand("enforcer-status", {
889
+ description:
890
+ "Show todo-enforcer state and loaded rules for current session",
891
+ handler: async (_args, ctx) => {
892
+ const cfg = await getConfigWhenNeeded(ctx.cwd);
893
+ const sessionId = getCachedSessionId();
894
+ const state = getState(sessionId);
895
+ const active = getActiveRules(sessionId);
896
+ const allRules = cfg.rules ?? [];
897
+ const delivery = cfg.messageDelivery ?? {};
898
+
899
+ const baseCooldown = cfg.cooldownMs ?? 60_000;
900
+ const currentDelay = Math.min(
901
+ baseCooldown * (cfg.backoff?.factor ?? 2) ** state.backoffCount,
902
+ cfg.backoff?.maxDelayMs ?? 3_600_000,
903
+ );
904
+
905
+ const lines: string[] = [
906
+ `todo-enforcer: ${cfg.enabled ? "enabled" : "disabled"}`,
907
+ ` Injections: ${state.injectionCount}/${cfg.maxInjections ?? "?"}`,
908
+ ` Stagnation: ${state.stagnationCount}/${cfg.stagnationThreshold ?? "?"}`,
909
+ ` Backoff: ${state.backoffCount} (delay: ${currentDelay / 1000}s, base: ${baseCooldown / 1000}s)`,
910
+ ` In-flight: ${state.inFlight}`,
911
+ ` Spawn in-flight: ${state.spawnInFlight}`,
912
+ ` Poll pending: ${pollTimers.has(sessionId)}`,
913
+ ` Cancelled: ${state.wasCancelled}`,
914
+ ` Rules: ${active.length}/${allRules.length} active`,
915
+ ` Delivery: mode=${delivery.mode ?? "userMessage"} display=${delivery.display ?? true} deliverAs=${delivery.deliverAs ?? "followUp"}`,
916
+ ];
917
+
918
+ for (let i = 0; i < allRules.length; i++) {
919
+ const rule = allRules[i];
920
+ const isActive = active.some(
921
+ (activeRule) => activeRule.name === rule.name,
922
+ );
923
+ const marker = isActive ? "●" : "○";
924
+ const idx = String(i + 1).padStart(2, " ");
925
+ lines.push(
926
+ ` ${marker} ${idx}. ${rule.name} [${rule.condition}] → ${rule.action}`,
927
+ );
928
+ }
929
+
930
+ const override = sessionActiveRules.get(sessionId);
931
+ if (override !== undefined && override !== null) {
932
+ lines.push(
933
+ ` (session override active — resets on /enforcer-switch reset)`,
934
+ );
935
+ }
936
+
937
+ ctx.ui.notify(lines.join("\n"), "info");
938
+ },
939
+ });
940
+
941
+ pi.registerCommand("enforcer-switch", {
942
+ description:
943
+ "Switch active rules for this session: /enforcer-switch <rule1,rule2,...> or /enforcer-switch reset",
944
+ getArgumentCompletions: (prefix) => {
945
+ const allRules = config?.rules ?? [];
946
+ const items = allRules
947
+ .filter((rule) => rule.name.startsWith(prefix))
948
+ .map((rule) => ({
949
+ value: rule.name,
950
+ label: `${rule.name} (${rule.condition} → ${rule.action})`,
951
+ }));
952
+ if ("reset".startsWith(prefix)) {
953
+ items.push({ value: "reset", label: "reset — restore all rules" });
954
+ }
955
+ return items.length > 0 ? items : null;
956
+ },
957
+ handler: async (args, ctx) => {
958
+ const cfg = await getConfigWhenNeeded(ctx.cwd);
959
+ const sessionId = getCachedSessionId();
960
+ const trimmed = args.trim().toLowerCase();
961
+
962
+ if (trimmed === "reset") {
963
+ sessionActiveRules.set(sessionId, null);
964
+ ctx.ui.notify("Rules reset to config defaults", "info");
965
+ return;
966
+ }
967
+
968
+ if (!trimmed) {
969
+ ctx.ui.notify(
970
+ "Usage: /enforcer-switch <rule1,rule2,...> or /enforcer-switch reset",
971
+ "warning",
972
+ );
973
+ return;
974
+ }
975
+
976
+ const allRules = cfg.rules ?? [];
977
+ const allNames = new Set(allRules.map((rule) => rule.name.toLowerCase()));
978
+ const requested = trimmed.split(/[\s,]+/).filter(Boolean);
979
+
980
+ const unknown = requested.filter((name) => !allNames.has(name));
981
+ if (unknown.length > 0) {
982
+ ctx.ui.notify(`Unknown rules: ${unknown.join(", ")}`, "error");
983
+ return;
984
+ }
985
+
986
+ const exactNames = new Set<string>();
987
+ for (const name of requested) {
988
+ const found = allRules.find((rule) => rule.name.toLowerCase() === name);
989
+ if (found) {
990
+ exactNames.add(found.name);
991
+ }
992
+ }
993
+ sessionActiveRules.set(sessionId, exactNames);
994
+ ctx.ui.notify(
995
+ `Active rules: ${Array.from(exactNames).join(", ")}`,
996
+ "info",
997
+ );
998
+ },
999
+ });
1000
+
1001
+ pi.registerCommand("enforcer-reset", {
1002
+ description: "Reset todo-enforcer state for current session",
1003
+ handler: async (_args, ctx) => {
1004
+ const sessionId = getCachedSessionId();
1005
+ resetState(sessionId);
1006
+ sessionActiveRules.delete(sessionId);
1007
+ ctx.ui.notify("todo-enforcer state + rule override reset", "info");
1008
+ },
1009
+ });
1010
+
1011
+ pi.registerShortcut("ctrl+shift+t", {
1012
+ description: "Toggle todo-enforcer enabled/disabled",
1013
+ handler: async (ctx) => {
1014
+ const cfg = await getConfigWhenNeeded(ctx.cwd);
1015
+ config = { ...cfg, enabled: !cfg.enabled };
1016
+ ctx.ui.notify(
1017
+ `todo-enforcer: ${config.enabled ? "enabled" : "disabled"}`,
1018
+ config.enabled ? "info" : "warning",
1019
+ );
1020
+ },
1021
+ });
1022
+ }