@alphacoder-v0/pi-loops 0.17.3

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 (68) hide show
  1. package/CHANGELOG.md +1989 -0
  2. package/LICENSE +21 -0
  3. package/README.md +370 -0
  4. package/README.zh-CN.md +606 -0
  5. package/docs/cli.md +252 -0
  6. package/docs/configuration.md +110 -0
  7. package/docs/design.md +155 -0
  8. package/docs/goal.md +59 -0
  9. package/docs/hooks.md +110 -0
  10. package/docs/loops.md +315 -0
  11. package/docs/mcp.md +90 -0
  12. package/docs/screenshot.png +0 -0
  13. package/docs/session-archive.md +64 -0
  14. package/docs/triggers.md +140 -0
  15. package/docs/troubleshooting.md +111 -0
  16. package/docs/web-ui-parity.md +305 -0
  17. package/examples/README.md +18 -0
  18. package/examples/mcp-notify-server.mjs +35 -0
  19. package/examples/mcp.toml +8 -0
  20. package/package.json +71 -0
  21. package/skills/pi-loops/SKILL.md +50 -0
  22. package/src/archive.ts +476 -0
  23. package/src/args.ts +217 -0
  24. package/src/cli-entry.mjs +23 -0
  25. package/src/cli.ts +610 -0
  26. package/src/config.ts +103 -0
  27. package/src/danger.ts +128 -0
  28. package/src/goal.ts +178 -0
  29. package/src/hooks.ts +436 -0
  30. package/src/host-control-channel.ts +243 -0
  31. package/src/host-control.ts +203 -0
  32. package/src/host-entry.mjs +14 -0
  33. package/src/host-runtime.ts +222 -0
  34. package/src/host.ts +329 -0
  35. package/src/inbox.ts +232 -0
  36. package/src/job-edit.ts +113 -0
  37. package/src/job-health.ts +29 -0
  38. package/src/lock.ts +193 -0
  39. package/src/log.ts +108 -0
  40. package/src/mcp-pool.ts +141 -0
  41. package/src/mcp.ts +885 -0
  42. package/src/paths.ts +33 -0
  43. package/src/pi-loops.ts +2292 -0
  44. package/src/pi-resolver.mjs +49 -0
  45. package/src/presence.ts +143 -0
  46. package/src/protocol.ts +198 -0
  47. package/src/redact.ts +61 -0
  48. package/src/register-pi.mjs +3 -0
  49. package/src/runner.ts +149 -0
  50. package/src/schedule.ts +431 -0
  51. package/src/scheduler.ts +981 -0
  52. package/src/sdk-runner.ts +536 -0
  53. package/src/share.ts +121 -0
  54. package/src/slots.ts +76 -0
  55. package/src/snapshot.ts +40 -0
  56. package/src/store.ts +599 -0
  57. package/src/subagent-guard.ts +47 -0
  58. package/src/thinking.ts +30 -0
  59. package/src/toml.ts +244 -0
  60. package/src/tools.ts +455 -0
  61. package/src/transcript.ts +84 -0
  62. package/src/trigger-runtime.ts +711 -0
  63. package/src/triggers.ts +558 -0
  64. package/src/trust.ts +47 -0
  65. package/src/ts-entry.mjs +67 -0
  66. package/src/ui-prefs.ts +84 -0
  67. package/src/version.ts +14 -0
  68. package/src/web.mjs +4484 -0
@@ -0,0 +1,711 @@
1
+ /**
2
+ * The trigger runtime: takes Trigger envelopes from sources (the periodic dynamic
3
+ * checker, MCP pushes), dedups them, runs the dynamic-rule sub-agent (or delivers a
4
+ * notification straight into the chat), marks fire-once rules, promotes results
5
+ * when asked, and keeps audit + running state for /triggers.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as os from "node:os";
9
+ import * as path from "node:path";
10
+ import { createHash } from "node:crypto";
11
+ import { realpathish } from "./paths.ts";
12
+ import { type PresenceEntry, type PresenceSelf, chooseRuleOwner, isSelf, withinProject } from "./presence.ts";
13
+ import { capRedacted, previewRedacted } from "./redact.ts";
14
+ import { type RunnerResult, type SubagentRunner, failedRun } from "./runner.ts";
15
+ import { type SubagentSlot, SubagentSlots } from "./slots.ts";
16
+ import type { JobStore } from "./store.ts";
17
+ import { backoffWaitMs, stamp } from "./schedule.ts";
18
+ import {
19
+ DEFAULT_TRIGGER_POLL_INTERVAL_SECS,
20
+ DedupWindow,
21
+ NO_MATCH_SENTINEL,
22
+ PollLedger,
23
+ SUMMARY_CAP_CHARS,
24
+ type DynamicTriggerRule,
25
+ type Trigger,
26
+ TriggerStore,
27
+ buildPeriodicCheckTrigger,
28
+ extractDynamicRuleIds,
29
+ renderDynamicTriggerPrompt,
30
+ newTraceId } from "./triggers.ts";
31
+
32
+ export type TriggerDelivery = "sub_agent" | "inject_summary" | "inject_and_run";
33
+
34
+ export interface RunningTrigger {
35
+ traceId: string;
36
+ sourceLabel: string;
37
+ eventLabel: string;
38
+ startedAt: string;
39
+ promptPreview: string;
40
+ cwd: string;
41
+ ctrl: AbortController;
42
+ }
43
+
44
+ export interface TriggerOutcome {
45
+ trigger: Trigger;
46
+ delivery: TriggerDelivery;
47
+ ok: boolean;
48
+ matchedRules: DynamicTriggerRule[];
49
+ summary: string;
50
+ error?: string;
51
+ durationMs: number;
52
+ cost: number;
53
+ promoted: boolean;
54
+ sessionFile?: string;
55
+ }
56
+
57
+ /** Where a promotion / injection actually went: the chat of this process, or the inbox (wrong project here). */
58
+ export type PromoteTarget = "chat" | "inbox";
59
+
60
+ export interface TriggerRuntimeHooks {
61
+ /** Insert text into the parent chat context (no model turn). Already carries the `[Trigger …]` prefix. Returns where it went. */
62
+ onPromote?: (content: string, trigger: Trigger) => PromoteTarget | Promise<PromoteTarget>;
63
+ /** Inject `prompt` into the parent chat and run one model turn. Returns where it went. */
64
+ onInjectAndRun?: (prompt: string, trigger: Trigger) => PromoteTarget | Promise<PromoteTarget>;
65
+ onStarted?: (running: RunningTrigger) => void;
66
+ onFinished?: (outcome: TriggerOutcome) => void;
67
+ log?: (message: string) => void;
68
+ }
69
+
70
+ export interface TriggerRuntimeOptions {
71
+ store: TriggerStore;
72
+ jobStore: JobStore;
73
+ getSession: () => { sessionId?: string; cwd: string; model?: string; thinking?: string; trusted?: boolean };
74
+ hooks?: TriggerRuntimeHooks;
75
+ pollIntervalSecs?: number;
76
+ /** Runs check/action sub-agents (in-process through pi's SDK in production; tests inject a fake). */
77
+ runner: SubagentRunner;
78
+ now?: () => number;
79
+ /** Shared dedup file (`<dir>/dedup.json`); omit for an in-memory window. */
80
+ dedupFile?: string;
81
+ /** Trigger hop of this process (0 for the interactive pi); children get hop + 1. */
82
+ hop?: number;
83
+ /**
84
+ * Presence of every pi on the machine and this process's own identity. With both, a project's
85
+ * checks and push evaluations are run by a pi open in that project (preferring the session
86
+ * that created the rules); without them the machine leader does everything (tests).
87
+ */
88
+ presence?: () => PresenceEntry[];
89
+ self?: PresenceSelf;
90
+ /** Whether this process is the machine leader right now (used outside tick(), e.g. for pushes). */
91
+ isLeader?: () => boolean;
92
+ /** Default cap on a check/action sub-agent (`[triggers] run_timeout_secs`); a rule's `timeoutMs` overrides it. */
93
+ runTimeoutMs?: number;
94
+ /** How long a push deferred to another process waits for that process to claim it (see `admit`). */
95
+ deferredTakeoverMs?: number;
96
+ /**
97
+ * Checks and actions in flight at once. Spawning every accepted trigger concurrently is
98
+ * bounded in practice by a person watching the feed; a headless host has nobody watching, and a
99
+ * server pushing distinct events would otherwise open one sub-agent per event. Only used to size
100
+ * a private counter when `slots` is absent.
101
+ */
102
+ maxConcurrent?: number;
103
+ /**
104
+ * The process-wide sub-agent admission counter, shared with the scheduler and the /goal evaluator
105
+ * (src/slots.ts). Omitted, the trigger runtime bounds only its own checks against `maxConcurrent`
106
+ * — which is the bug this exists to close, so production always passes one.
107
+ */
108
+ slots?: SubagentSlots;
109
+ /** Today's automation spend against `[limits] daily_budget_usd`, shared with the scheduler. */
110
+ budget?: () => { spent: number; cap: number; over: boolean };
111
+ }
112
+
113
+ export const DEFAULT_MAX_CONCURRENT_CHECKS = 3;
114
+
115
+ /**
116
+ * Pushes held while every slot is busy. Retries drain at most `maxConcurrent` (3) per scheduler
117
+ * tick (30s), so 32 is about five minutes of backlog — the width of the dedup window that defines
118
+ * a push's identity. Past that the held events are older than the window they would collapse in,
119
+ * and holding more of them only delays the moment we admit the machine cannot keep up: the oldest
120
+ * is dropped, with an audit row, instead of the list growing until the process dies.
121
+ */
122
+ export const MAX_PENDING_PUSHES = 32;
123
+
124
+ /**
125
+ * Backoff for a rule whose check keeps failing — the same shape and numbers as the scheduler's job
126
+ * backoff (`FAILURE_BACKOFF_*` in scheduler.ts), which exists for exactly this: a check that failed
127
+ * three times in a row is failing for a reason the next poll will not fix, and each attempt bills.
128
+ */
129
+ export const CHECK_FAILURE_BACKOFF_AFTER = 3;
130
+ export const CHECK_FAILURE_BACKOFF_BASE_MS = 5 * 60_000;
131
+ export const CHECK_FAILURE_BACKOFF_MAX_MS = 6 * 60 * 60_000;
132
+
133
+ /**
134
+ * Transcripts are kept per project, not in one directory for the machine: a shared budget meant
135
+ * three projects polling every ten minutes exhausted it within hours, taking the evidence for
136
+ * "why did this rule not match?" with them.
137
+ *
138
+ * The basename alone was not per project: `~/a/web` and `~/b/web` landed in one directory, back to
139
+ * one shared budget of 40 — and each project's prune deleted the other's evidence. So the readable
140
+ * basename keeps its place and a short digest of the resolved path makes it unique. Resolved the way
141
+ * everything else here compares paths (`realpathish`), so a worktree reached through a symlink is
142
+ * still the same project.
143
+ */
144
+ export function triggerSessionKey(cwd: string): string {
145
+ const base = path.basename(cwd || "unknown").replace(/[^A-Za-z0-9._-]/g, "_") || "unknown";
146
+ const digest = createHash("sha256").update(realpathish(cwd || "unknown")).digest("hex").slice(0, 8);
147
+ return `triggers-${base}-${digest}`;
148
+ }
149
+
150
+ /** Check transcripts kept per project, as docs/configuration.md promises. */
151
+ export const TRIGGER_SESSIONS_KEPT = 40;
152
+
153
+ export const DEFAULT_TRIGGER_RUN_TIMEOUT_MS = 15 * 60_000;
154
+ /**
155
+ * A push handed to the presence-chosen owner is only really handled if that process has the MCP
156
+ * server connected — it authenticates per process, so it may not. The receiving process waits this
157
+ * long for the owner to claim the machine-wide dedup key and takes the push back if it does not.
158
+ */
159
+ export const DEFAULT_DEFERRED_TAKEOVER_MS = 5_000;
160
+
161
+ /** The engine prefixes `[Trigger <trace>] ` and injects the payload summary itself, capped. */
162
+ export function promotionBody(trigger: Trigger, summary: string): string {
163
+ // Capped, never reflowed: a promoted result is often a file, a diff or test output, and rewrapping
164
+ // it is how a diff stops applying and a stack trace stops pointing anywhere. Truncation respects
165
+ // character boundaries, so the cap cannot cut a multi-byte character in half.
166
+ return `[Trigger ${trigger.traceId}] ${capRedacted(summary, SUMMARY_CAP_CHARS)}`;
167
+ }
168
+
169
+ /**
170
+ * A sub-agent result promoted into the chat: `<source> fired <event>.\nResult: …`, so the chat says
171
+ * what fired and not only what came back — a result with no cause reads as the agent talking to
172
+ * itself. The rendered body is then capped at `SUMMARY_CAP_CHARS` characters — the same cap an audit
173
+ * row's summary gets, and characters rather than bytes, so it cannot cut a multi-byte one in half.
174
+ */
175
+ export function promotionSummaryBody(trigger: Trigger, summary: string): string {
176
+ return capRedacted(`[Trigger ${trigger.traceId}] ${trigger.sourceLabel} fired ${trigger.eventLabel}.\nResult: ${summary}`, SUMMARY_CAP_CHARS);
177
+ }
178
+
179
+ /**
180
+ * The envelope fields carried on every audit row, in every state a trigger passes through. Without
181
+ * the idempotency key on each of them, "which pushes collapsed into which" cannot be answered after
182
+ * the fact — and that question is the whole reason the dedup window is allowed to drop anything.
183
+ * Bounded and redacted: a key is caller-supplied text.
184
+ */
185
+ function envelopeOf(trigger: Trigger): Record<string, unknown> {
186
+ return { idempotency_key: capRedacted(trigger.idempotencyKey, 256), replacement_policy: trigger.replacementPolicy, received_at: trigger.receivedAt };
187
+ }
188
+
189
+ export class TriggerRuntime {
190
+ readonly store: TriggerStore;
191
+ private readonly jobStore: JobStore;
192
+ private readonly getSession: TriggerRuntimeOptions["getSession"];
193
+ private readonly hooks: TriggerRuntimeHooks;
194
+ private readonly runner: SubagentRunner;
195
+ private readonly now: () => number;
196
+ private readonly dedup: DedupWindow;
197
+ /** Pushes that inject into this window are deduplicated here only: every window reacts. */
198
+ private readonly localDedup = new DedupWindow();
199
+ private readonly ledger: PollLedger;
200
+ private readonly presence?: () => PresenceEntry[];
201
+ private readonly self?: PresenceSelf;
202
+ private readonly isLeader?: () => boolean;
203
+ private readonly hop: number;
204
+ /** Mutable so a config reload at session start takes effect. */
205
+ runTimeoutMs: number;
206
+ private readonly deferredTakeoverMs: number;
207
+ private readonly running = new Map<string, RunningTrigger>();
208
+ /** Pushes refused for want of a slot, waiting for the next tick to retry them (see `queuePush`). */
209
+ private readonly pending = new Map<string, { trigger: Trigger; delivery: TriggerDelivery }>();
210
+ /** Every sub-agent this process starts takes a slot from here, loop runs included. */
211
+ readonly slots: SubagentSlots;
212
+ private readonly budget: () => { spent: number; cap: number; over: boolean };
213
+ pollIntervalSecs: number;
214
+ lastCheckAt = 0;
215
+ /** Bounded, display-only status of the latest periodic check. */
216
+ lastPoll: { at: string; cwd: string; outcome: string; traceId: string; sourceLabel: string; eventLabel: string; summary: string } | undefined;
217
+ dedupedCount = 0;
218
+ cycleSuppressedCount = 0;
219
+
220
+ constructor(opts: TriggerRuntimeOptions) {
221
+ this.store = opts.store;
222
+ this.jobStore = opts.jobStore;
223
+ this.getSession = opts.getSession;
224
+ this.hooks = opts.hooks ?? {};
225
+ this.runner = opts.runner;
226
+ this.now = opts.now ?? Date.now;
227
+ this.pollIntervalSecs = opts.pollIntervalSecs ?? DEFAULT_TRIGGER_POLL_INTERVAL_SECS;
228
+ this.dedup = new DedupWindow(undefined, opts.dedupFile);
229
+ this.ledger = new PollLedger(opts.dedupFile ? path.join(path.dirname(opts.dedupFile), "polls.json") : undefined);
230
+ this.presence = opts.presence;
231
+ this.self = opts.self;
232
+ this.isLeader = opts.isLeader;
233
+ this.hop = opts.hop ?? 0;
234
+ this.runTimeoutMs = opts.runTimeoutMs ?? DEFAULT_TRIGGER_RUN_TIMEOUT_MS;
235
+ this.deferredTakeoverMs = opts.deferredTakeoverMs ?? DEFAULT_DEFERRED_TAKEOVER_MS;
236
+ this.slots = opts.slots ?? new SubagentSlots(opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_CHECKS);
237
+ this.budget = opts.budget ?? (() => ({ spent: 0, cap: 0, over: false }));
238
+ // Audit writes are best-effort — a failed write never fails a trigger; say so once per distinct error.
239
+ let lastReported: string | undefined;
240
+ this.store.onPersistenceError ??= (message) => {
241
+ if (message === lastReported) return;
242
+ lastReported = message;
243
+ this.log(message);
244
+ };
245
+ }
246
+
247
+ /** The log hook is the UI's notifier in pi-loops; it must never turn into a rejection here. */
248
+ private log(message: string): void {
249
+ try {
250
+ this.hooks.log?.(message);
251
+ } catch {
252
+ /* nothing left to report to */
253
+ }
254
+ }
255
+
256
+ runningList(): RunningTrigger[] {
257
+ return [...this.running.values()];
258
+ }
259
+
260
+ /** Pushes waiting for a free slot: work that is late, not work that was lost. */
261
+ get pendingPushCount(): number {
262
+ return this.pending.size;
263
+ }
264
+
265
+ abort(traceId: string): boolean {
266
+ const r = this.running.get(traceId);
267
+ if (!r) return false;
268
+ r.ctrl.abort();
269
+ return true;
270
+ }
271
+
272
+ abortAll(): number {
273
+ const n = this.running.size;
274
+ for (const r of this.running.values()) r.ctrl.abort();
275
+ return n;
276
+ }
277
+
278
+ async stop(): Promise<void> {
279
+ this.abortAll();
280
+ }
281
+
282
+ /** The process that must evaluate `rule`; `undefined` when nobody owns it and the leader steps in. */
283
+ private ownerOf(rule: DynamicTriggerRule): PresenceEntry | undefined {
284
+ if (!this.self) return undefined;
285
+ return chooseRuleOwner(this.presence?.() ?? [], rule.cwd, this.self.host, rule.createdBy?.sessionId);
286
+ }
287
+
288
+ /**
289
+ * Whether this process evaluates `rule`. A rule belongs to the session that created it, not to
290
+ * its directory, so two pi windows in one repo each check their own rules and a result can only
291
+ * be promoted into the chat that asked for it. `ownIfUnowned` covers only the rules nobody owns —
292
+ * their creating session has closed and their project has no pi open — which the machine leader
293
+ * checks, into the inbox. It never takes a rule away from the process that does own it.
294
+ */
295
+ ownsRule(rule: DynamicTriggerRule, ownIfUnowned: boolean): boolean {
296
+ if (!this.self) return ownIfUnowned;
297
+ const owner = this.ownerOf(rule);
298
+ return owner ? isSelf(owner, this.self) : ownIfUnowned;
299
+ }
300
+
301
+ /**
302
+ * The slot a rule's work occupies machine-wide (poll ledger, push dedup): its creating session
303
+ * while that session is open — nobody else may take it — else the project's shared slot, so a
304
+ * hand-over between processes still never double-checks.
305
+ */
306
+ private slotOf(rule: DynamicTriggerRule): string {
307
+ const sid = rule.createdBy?.sessionId;
308
+ return sid && this.ownerOf(rule)?.sessionId === sid ? sid : "";
309
+ }
310
+
311
+ /**
312
+ * This host's enabled rules that govern `cwd`, split into the ones this process owns. A rule
313
+ * created at `~/proj` still governs a pi opened at `~/proj/src` or through a symlink to it:
314
+ * comparing the cwd strings silently diverted those promotions to the inbox.
315
+ *
316
+ * `ownIfUnowned` is passed straight to `ownsRule`: it only adds the rules nobody owns.
317
+ */
318
+ private rulesFor(cwd: string | undefined, ownIfUnowned: boolean): { applicable: DynamicTriggerRule[]; owned: DynamicTriggerRule[] } {
319
+ const host = this.self?.host ?? os.hostname();
320
+ const all = this.store.load().filter((r) => r.enabled && (!r.host || r.host === host));
321
+ const applicable = cwd ? all.filter((r) => withinProject(r.cwd, cwd)) : all;
322
+ return { applicable, owned: applicable.filter((r) => this.ownsRule(r, ownIfUnowned)) };
323
+ }
324
+
325
+ /**
326
+ * Scheduler tick (every process): emit one periodic check per project whose rules this process
327
+ * owns, at most once per poll interval machine-wide. The ledger is claimed per ownership slot,
328
+ * not per project, so two windows in one repo each check their own rules once per interval while
329
+ * a hand-over of the project's own rules still never double-checks.
330
+ */
331
+ async tick(now: number, leader: boolean): Promise<void> {
332
+ // Before raising new work: the pushes this process refused while it was busy. They are its own
333
+ // backlog, not the leader's, so leadership does not gate them.
334
+ this.retryPending();
335
+ const host = this.self?.host ?? os.hostname();
336
+ const rules = this.store.load().filter((r) => r.enabled && (!r.host || r.host === host) && this.ownsRule(r, leader) && !this.inBackoff(r, now));
337
+ if (!rules.length) return;
338
+ const byCwd = new Map<string, DynamicTriggerRule[]>();
339
+ for (const r of rules) byCwd.set(r.cwd, [...(byCwd.get(r.cwd) ?? []), r]);
340
+ for (const [cwd, group] of byCwd) {
341
+ // A checkout that is gone cannot be checked. The scheduler disables such a cron job with
342
+ // an actionable message; without the same guard here the rules poll — and bill — forever.
343
+ if (cwd && !fs.existsSync(cwd)) {
344
+ for (const rule of group) {
345
+ await this.store.setEnabled(rule.id, false).catch(() => undefined);
346
+ this.store.appendAudit({ cwd, type: "trigger", traceId: newTraceId(), state: "disabled", sourceLabel: "local:dynamic", eventLabel: rule.id, summary: `cwd ${cwd} no longer exists`, details: { reason: "cwd missing" } });
347
+ }
348
+ this.log(`disabled ${group.length} rule(s): cwd ${cwd} no longer exists (re-enable after restoring it)`);
349
+ continue;
350
+ }
351
+ if ([...this.running.values()].some((r) => r.cwd === cwd && r.sourceLabel === "local:dynamic")) continue; // previous check still active
352
+ const bySlot = new Map<string, DynamicTriggerRule[]>();
353
+ for (const r of group) {
354
+ const slot = this.slotOf(r);
355
+ bySlot.set(slot, [...(bySlot.get(slot) ?? []), r]);
356
+ }
357
+ const claimed: DynamicTriggerRule[] = [];
358
+ for (const [slot, owned] of bySlot) {
359
+ if (await this.ledger.claim(`${host}:${cwd}${slot ? `#${slot}` : ""}`, now, this.pollIntervalSecs * 1000)) claimed.push(...owned);
360
+ }
361
+ if (!claimed.length) continue;
362
+ this.lastCheckAt = now;
363
+ // `handle` is documented not to reject; the catch is what makes that true rather than
364
+ // merely intended, since nothing above this would survive one.
365
+ void this.handle(buildPeriodicCheckTrigger(cwd, claimed.length, new Date(now), this.self ? `${this.self.pid}-${this.self.instance}` : ""), "sub_agent", claimed).catch((err: any) =>
366
+ this.log(`check failed: ${err?.message ?? err}`),
367
+ );
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Admit one trigger: dedup, audit, deliver. Resolves when the delivery has finished and
373
+ * never rejects: persistence or runner failures are audited (best effort) and logged.
374
+ */
375
+ async handle(trigger: Trigger, delivery: TriggerDelivery, rules?: DynamicTriggerRule[]): Promise<TriggerOutcome | undefined> {
376
+ // Sub-agents register no notification hooks and run no dynamic checker, so only the
377
+ // interactive process (hop 0) ever handles a trigger. Anything reaching a deeper hop is a
378
+ // cycle and is suppressed, audited as cycle-suppressed.
379
+ if (this.hop > 0) {
380
+ this.cycleSuppressedCount++;
381
+ this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger", traceId: trigger.traceId, state: "cycle_suppressed", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { hop_count: this.hop, delivery, ...envelopeOf(trigger) } });
382
+ this.log(`trigger ${trigger.traceId.slice(0, 8)} cycle_suppressed at hop ${this.hop} (${trigger.sourceLabel} / ${trigger.eventLabel})`);
383
+ return undefined;
384
+ }
385
+ try {
386
+ if (trigger.source.kind === "mcp" && delivery === "sub_agent" && !trigger.cwd && !this.getSession().cwd) {
387
+ // No project of our own (the headless host): evaluate the push once per project that has
388
+ // rules, each in that project, as tick() does — never all rules at once in $HOME.
389
+ const host = this.self?.host ?? os.hostname();
390
+ const cwds = [...new Set(this.store.load().filter((r) => r.enabled && (!r.host || r.host === host)).map((r) => r.cwd))];
391
+ if (!cwds.length) return await this.admit(trigger, delivery); // audited as no_rules
392
+ const outcomes = await Promise.all(cwds.map((cwd, i) => this.admit({ ...trigger, cwd, traceId: i ? newTraceId() : trigger.traceId }, delivery)));
393
+ return outcomes.find(Boolean);
394
+ }
395
+ return await this.admit(trigger, delivery, rules);
396
+ } catch (err: any) {
397
+ const message = err?.message ?? String(err);
398
+ this.running.delete(trigger.traceId);
399
+ this.log(`trigger ${trigger.traceId.slice(0, 8)} failed: ${message}`);
400
+ this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger_result", traceId: trigger.traceId, state: "failed", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: message, details: { delivery, error: message, ...envelopeOf(trigger) } });
401
+ return undefined;
402
+ }
403
+ }
404
+
405
+ private async admit(trigger: Trigger, delivery: TriggerDelivery, rules?: DynamicTriggerRule[]): Promise<TriggerOutcome | undefined> {
406
+ // A sub-agent costs money and a process slot. Both bounds are checked before the dedup claim,
407
+ // so a refused trigger is never marked handled — a periodic check is simply raised again by the
408
+ // next poll, and a push (an event that happened once, which no server repeats) is held below.
409
+ // `inject_summary` puts the push's own text into the chat and runs no model call — its audit
410
+ // row records `cost_usd: 0`. Refusing a free delivery for want of budget is backwards, and
411
+ // the day the cap trips is the day you still want to be told what is arriving.
412
+ const budget = this.budget();
413
+ const gate = delivery === "inject_summary" ? { over: false, spent: budget.spent, cap: budget.cap } : budget;
414
+ // Said out loud, because otherwise "why did this one arrive when everything else stopped" has
415
+ // no answer anywhere.
416
+ if (delivery === "inject_summary" && budget.over) this.log(`trigger ${trigger.traceId.slice(0, 8)} injected despite today's $${budget.cap.toFixed(2)} budget: an injected summary runs no model call`);
417
+ if (gate.over) {
418
+ // Not held: being too busy clears in minutes, but the daily cap can last until midnight,
419
+ // and acting on this morning's deploy event at 23:59 is worse than not acting at all.
420
+ this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger", traceId: trigger.traceId, state: "budget_exceeded", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { ...envelopeOf(trigger), delivery, spent_usd: gate.spent, cap_usd: gate.cap } });
421
+ this.log(`trigger ${trigger.traceId.slice(0, 8)} not run: today's automation has cost $${gate.spent.toFixed(2)} of the $${gate.cap.toFixed(2)} budget`);
422
+ return undefined;
423
+ }
424
+ // The counter is shared with loop runs and the /goal evaluator (src/slots.ts): the cap is on
425
+ // sub-agents, not on checks, so a machine full of loop runs refuses a check too. The slot is
426
+ // reserved here, before the dedup claim below, so the claim cannot outlive its admission.
427
+ let slot: SubagentSlot | undefined;
428
+ if (delivery === "sub_agent") {
429
+ slot = this.slots.acquire();
430
+ if (!slot) {
431
+ // Only a push is kept. Re-queueing a periodic check would be wrong: the next poll evaluates
432
+ // every rule against the world as it is then, so a held one could only run the same check
433
+ // twice. (`handle` has already fanned a project-less push out per project, so each entry
434
+ // here is one event for one project.)
435
+ const queued = trigger.source.kind === "mcp" ? this.queuePush(trigger, delivery) : undefined;
436
+ const { inUseCount, limit } = this.slots;
437
+ this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger", traceId: trigger.traceId, state: "deferred", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { ...envelopeOf(trigger), delivery, reason: `${inUseCount} sub-agent(s) already in flight (max ${limit})`, ...(queued ? { queued, pending: this.pending.size } : {}) } });
438
+ this.log(`trigger ${trigger.traceId.slice(0, 8)} deferred: ${inUseCount} sub-agent(s) already in flight${queued ? " (queued for the next tick)" : ""}`);
439
+ return undefined;
440
+ }
441
+ }
442
+ try {
443
+ let prev: Awaited<ReturnType<DedupWindow["check"]>>;
444
+ if (trigger.source.kind === "mcp" && delivery === "sub_agent") {
445
+ // A push evaluated against dynamic rules: by the process that owns them (each session
446
+ // evaluates its own registry), and once per ownership slot machine-wide.
447
+ const cwd = trigger.cwd ?? this.getSession().cwd;
448
+ const scope = this.rulesFor(cwd, this.isLeader?.() ?? true);
449
+ rules = scope.owned;
450
+ if (scope.applicable.length && !scope.owned.length) {
451
+ // The owner may not have this MCP server connected at all — servers authenticate per
452
+ // process — and a push nobody handles is simply lost. Record the hand-off, then take the
453
+ // push back if the owner has not claimed the slot below inside the takeover window; the
454
+ // claim is machine-wide, so at most one process ever evaluates it.
455
+ this.store.appendAudit({ cwd, type: "trigger", traceId: trigger.traceId, state: "deferred", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { delivery, reason: "another pi in this project owns rule evaluation", takeover_after_ms: this.deferredTakeoverMs, ...envelopeOf(trigger) } });
456
+ await new Promise((r) => setTimeout(r, this.deferredTakeoverMs));
457
+ rules = scope.applicable;
458
+ }
459
+ prev = await this.dedup.check(this.pushClaimKey(trigger, cwd, rules), trigger.traceId, this.now(), trigger.replacementPolicy);
460
+ if (!prev && !scope.owned.length && scope.applicable.length) {
461
+ this.store.appendAudit({ cwd, type: "trigger", traceId: trigger.traceId, state: "taken_over", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { delivery, reason: "the owning pi did not claim the push", ...envelopeOf(trigger) } });
462
+ }
463
+ } else if (trigger.source.kind === "mcp") {
464
+ // A push injected into the chat: every window that has the server reacts (every
465
+ // session), so the dedup window is this process's own.
466
+ prev = await this.localDedup.check(trigger.idempotencyKey, trigger.traceId, this.now(), trigger.replacementPolicy);
467
+ } else {
468
+ prev = await this.dedup.check(trigger.idempotencyKey, trigger.traceId, this.now(), trigger.replacementPolicy);
469
+ }
470
+ if (prev) {
471
+ this.dedupedCount++;
472
+ this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger", traceId: trigger.traceId, state: "deduped", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { previous_trace_id: prev.traceId, ...envelopeOf(trigger), replacement_policy: prev.replacementPolicy ?? trigger.replacementPolicy } });
473
+ // `[trigger deduped]` is a feed line; here the only trace was an audit row, so
474
+ // "my webhook fired and nothing happened" had no answer short of reading the JSONL.
475
+ this.log(`trigger ${trigger.traceId.slice(0, 8)} deduped (${trigger.sourceLabel} / ${trigger.eventLabel}): an identical event arrived within the dedup window`);
476
+ return undefined;
477
+ }
478
+ this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger", traceId: trigger.traceId, state: "accepted", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { delivery, evaluator_decision: { outcome: "accept", permission: "allow" }, ...envelopeOf(trigger) } });
479
+ // `return await`, not `return`: a bare return inside a try/finally hands the promise back and
480
+ // runs the finally at once, which would release the slot the moment the sub-agent started.
481
+ if (delivery === "inject_summary") return await this.deliverInjectSummary(trigger);
482
+ if (delivery === "inject_and_run") return await this.deliverInjectAndRun(trigger);
483
+ return await this.deliverSubAgent(trigger, rules);
484
+ } finally {
485
+ // Held for the whole delivery, `deliverSubAgent` included, and released on every path out of
486
+ // it — a torn dedup file, a runner that rejects, an abort — or the pool leaks a slot.
487
+ slot?.release();
488
+ }
489
+ }
490
+
491
+ /** When the event happened, which is what "oldest first" means for a push; `now` if the source sent us an unparseable stamp. */
492
+ private eventTimeOf(trigger: Trigger): number {
493
+ const at = Date.parse(trigger.receivedAt);
494
+ return Number.isNaN(at) ? this.now() : at;
495
+ }
496
+
497
+ /**
498
+ * Hold a push that found no free slot. Keyed by idempotency key and project, so a server pushing
499
+ * the same event again while we are busy collapses into the one already waiting instead of filling
500
+ * the list; which copy survives is the envelope's own replacement policy, as in the dedup window.
501
+ */
502
+ private queuePush(trigger: Trigger, delivery: TriggerDelivery): "queued" | "replaced" | "collapsed" {
503
+ const cwd = trigger.cwd ?? this.getSession().cwd;
504
+ const key = `${trigger.idempotencyKey}@${cwd}`;
505
+ const waiting = this.pending.get(key);
506
+ if (waiting) {
507
+ if (trigger.replacementPolicy !== "latest_replaces") return "collapsed";
508
+ this.pending.set(key, { trigger, delivery });
509
+ return "replaced";
510
+ }
511
+ if (this.pending.size >= MAX_PENDING_PUSHES) {
512
+ const oldest = [...this.pending.entries()].sort((a, b) => this.eventTimeOf(a[1].trigger) - this.eventTimeOf(b[1].trigger))[0];
513
+ this.pending.delete(oldest[0]);
514
+ const lost = oldest[1].trigger;
515
+ // The drop leaves a row of its own: "my webhook fired and nothing happened" must stay
516
+ // answerable from the audit, and the answer here is that the machine never caught up.
517
+ this.store.appendAudit({ cwd: lost.cwd ?? cwd, type: "trigger", traceId: lost.traceId, state: "dropped", sourceLabel: lost.sourceLabel, eventLabel: lost.eventLabel, summary: lost.payloadSummary, details: { ...envelopeOf(lost), delivery: oldest[1].delivery, reason: `${MAX_PENDING_PUSHES} pushes already waiting for a free slot` } });
518
+ this.log(`trigger ${lost.traceId.slice(0, 8)} dropped: ${MAX_PENDING_PUSHES} pushes already waiting for a free slot`);
519
+ }
520
+ this.pending.set(key, { trigger, delivery });
521
+ return "queued";
522
+ }
523
+
524
+ /**
525
+ * Retry the held pushes, oldest event first, as many as there are free slots. Each goes back
526
+ * through `handle`, so it is deduped, audited and delivered exactly as if it had just arrived —
527
+ * a deferral, not a bypass — and carries how long it waited so the check can weigh its age.
528
+ */
529
+ private retryPending(): void {
530
+ // A bound on how many are dequeued at once, not the admission test: `admit` reserves the slot
531
+ // itself, so a push dequeued past the free count is simply audited and queued again.
532
+ let slots = this.slots.free;
533
+ if (slots <= 0 || !this.pending.size) return;
534
+ for (const [key, entry] of [...this.pending.entries()].sort((a, b) => this.eventTimeOf(a[1].trigger) - this.eventTimeOf(b[1].trigger))) {
535
+ if (slots-- <= 0) break;
536
+ this.pending.delete(key);
537
+ const trigger: Trigger = { ...entry.trigger, deferredMs: Math.max(0, this.now() - this.eventTimeOf(entry.trigger)) };
538
+ // `handle` is documented not to reject; the catch is what makes that true rather than
539
+ // merely intended, since nothing above a tick would survive one.
540
+ void this.handle(trigger, entry.delivery).catch((err: any) => this.log(`deferred push ${trigger.traceId.slice(0, 8)} failed: ${err?.message ?? err}`));
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Whether `rule`'s next poll is still inside its failure backoff. A check that has failed three
546
+ * times in a row is failing for a reason ten minutes will not fix, and every attempt bills a
547
+ * sub-agent; pushes still reach the rule, because an event is worth one attempt.
548
+ */
549
+ private inBackoff(rule: DynamicTriggerRule, now: number): boolean {
550
+ const failures = rule.consecutiveFailures ?? 0;
551
+ if (failures < CHECK_FAILURE_BACKOFF_AFTER || !rule.lastCheckFailedAt) return false;
552
+ const wait = backoffWaitMs(failures, CHECK_FAILURE_BACKOFF_AFTER, CHECK_FAILURE_BACKOFF_BASE_MS, CHECK_FAILURE_BACKOFF_MAX_MS);
553
+ return now - Date.parse(rule.lastCheckFailedAt) < wait;
554
+ }
555
+
556
+ /** Record a check's outcome on the rules it evaluated, and say in the audit when that starts holding a poll back. */
557
+ private async recordCheckOutcome(rules: DynamicTriggerRule[], ok: boolean, cwd: string): Promise<void> {
558
+ const ids = rules.map((r) => r.id);
559
+ if (ok && !rules.some((r) => r.consecutiveFailures)) return; // nothing to clear: no lock, no write
560
+ const nowIso = stamp(this.now());
561
+ const failed = await this.store.mutate((all) => {
562
+ const out: DynamicTriggerRule[] = [];
563
+ for (const rule of all) {
564
+ if (!ids.includes(rule.id)) continue;
565
+ if (ok) {
566
+ rule.consecutiveFailures = undefined;
567
+ rule.lastCheckFailedAt = undefined;
568
+ continue;
569
+ }
570
+ rule.consecutiveFailures = (rule.consecutiveFailures ?? 0) + 1;
571
+ rule.lastCheckFailedAt = nowIso;
572
+ out.push(rule);
573
+ }
574
+ return out;
575
+ });
576
+ const backing = failed.filter((r) => (r.consecutiveFailures ?? 0) >= CHECK_FAILURE_BACKOFF_AFTER);
577
+ if (!backing.length) return;
578
+ const worst = Math.max(...backing.map((r) => r.consecutiveFailures ?? 0));
579
+ this.store.appendAudit({ cwd, type: "trigger", traceId: newTraceId(), state: "backoff", sourceLabel: "local:dynamic", eventLabel: backing.map((r) => r.id).join(" "), summary: `${backing.length} rule(s) checked with a widening gap after ${worst} failed check(s) in a row`, details: { rule_ids: backing.map((r) => r.id), consecutive_failures: worst } });
580
+ this.log(`${backing.length} trigger rule(s) failed ${worst} checks in a row; polling them with a widening gap`);
581
+ }
582
+
583
+ /**
584
+ * The machine-wide claim a pushed evaluation takes. Keyed by the project (realpath, so two
585
+ * windows reaching it by different paths agree) and by the ownership slots of the rules being
586
+ * evaluated, so windows checking *different* rules of one project both react while a deferred
587
+ * push and its owner still contend for the same key.
588
+ */
589
+ private pushClaimKey(trigger: Trigger, cwd: string, rules: DynamicTriggerRule[]): string {
590
+ const slots = [...new Set(rules.map((r) => this.slotOf(r)))].sort().join(",");
591
+ return `${trigger.idempotencyKey}@${this.self?.host ?? os.hostname()}:${realpathish(cwd)}${slots ? `#${slots}` : ""}`;
592
+ }
593
+
594
+ private async deliverInjectSummary(trigger: Trigger): Promise<TriggerOutcome> {
595
+ const start = this.now();
596
+ const summary = trigger.payloadSummary ?? "";
597
+ let promoted = false;
598
+ if (summary) {
599
+ const target = (await this.hooks.onPromote?.(promotionBody(trigger, summary), trigger)) ?? "chat";
600
+ promoted = target === "chat";
601
+ this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger_promotion", traceId: trigger.traceId, state: target === "chat" ? "promoted" : "redirected", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary, details: { prefix_injected: true, delivery: "inject_summary", to: target, ...envelopeOf(trigger) } });
602
+ }
603
+ // `over_budget` on a completed row is the audit's answer to "everything else stopped today —
604
+ // why did this run": because it cost nothing.
605
+ const spend = this.budget();
606
+ this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger_result", traceId: trigger.traceId, state: "completed", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary, details: { delivery: "inject_summary", cost_usd: 0, ...(spend.over ? { over_budget: true } : {}), ...envelopeOf(trigger) } });
607
+ const outcome: TriggerOutcome = { trigger, delivery: "inject_summary", ok: true, matchedRules: [], summary, durationMs: this.now() - start, cost: 0, promoted };
608
+ this.hooks.onFinished?.(outcome);
609
+ return outcome;
610
+ }
611
+
612
+ private async deliverInjectAndRun(trigger: Trigger): Promise<TriggerOutcome> {
613
+ const start = this.now();
614
+ const prompt = `[Trigger ${trigger.traceId}] ${trigger.payloadSummary ?? `${trigger.sourceLabel} fired: ${trigger.eventLabel}`}`;
615
+ const target = (await this.hooks.onInjectAndRun?.(prompt, trigger)) ?? "chat";
616
+ this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger_result", traceId: trigger.traceId, state: "completed", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { delivery: "inject_and_run", prefix_injected: true, cost_usd: 0, to: target, ...envelopeOf(trigger) } });
617
+ const outcome: TriggerOutcome = { trigger, delivery: "inject_and_run", ok: true, matchedRules: [], summary: trigger.payloadSummary ?? "", durationMs: this.now() - start, cost: 0, promoted: target === "chat" };
618
+ this.hooks.onFinished?.(outcome);
619
+ return outcome;
620
+ }
621
+
622
+ /**
623
+ * The project an audit row of this trigger belongs to. Resolved once per delivery: a check takes
624
+ * minutes, and the session's cwd can move under it (`/resume`, a project switch), which used to
625
+ * split one trace across two projects in `/triggers audit`.
626
+ */
627
+ private cwdOf(trigger: Trigger): string {
628
+ return trigger.cwd ?? this.getSession().cwd;
629
+ }
630
+
631
+ private async deliverSubAgent(trigger: Trigger, evaluate?: DynamicTriggerRule[]): Promise<TriggerOutcome> {
632
+ const start = this.now();
633
+ const session = this.getSession();
634
+ const cwd = this.cwdOf(trigger);
635
+ // `evaluate` is what the caller already resolved (the tick's claimed rules, a taken-over
636
+ // push); otherwise this is a direct handle() and we take the rules of this project we own.
637
+ const rules = evaluate ?? this.rulesFor(trigger.cwd, this.isLeader?.() ?? true).owned;
638
+ if (!rules.length) {
639
+ this.store.appendAudit({ cwd, type: "trigger_result", traceId: trigger.traceId, state: "no_rules", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { delivery: "sub_agent", ...envelopeOf(trigger) } });
640
+ const outcome: TriggerOutcome = { trigger, delivery: "sub_agent", ok: true, matchedRules: [], summary: "no enabled dynamic trigger rules", durationMs: 0, cost: 0, promoted: false };
641
+ this.hooks.onFinished?.(outcome);
642
+ return outcome;
643
+ }
644
+ const prompt = renderDynamicTriggerPrompt(trigger, rules);
645
+ const ctrl = new AbortController();
646
+ const running: RunningTrigger = { traceId: trigger.traceId, sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, startedAt: stamp(start), promptPreview: previewRedacted(prompt, 80), cwd, ctrl }; // 80 characters is a banner, not a prompt
647
+ this.running.set(trigger.traceId, running);
648
+ this.hooks.onStarted?.(running);
649
+ this.store.appendAudit({ cwd, type: "trigger_result", traceId: trigger.traceId, state: "running", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, details: { rule_count: rules.length, cwd, ...envelopeOf(trigger) } });
650
+
651
+ // The check runs with the model the rules were created under, not whatever the process that
652
+ // happens to own the timer is using. Both come from the first rule that recorded a model,
653
+ // because a thinking level belongs to the model it was chosen for: pairing them with a level
654
+ // from some other rule is how a check asks for a budget the model does not have.
655
+ const withModel = rules.find((r) => r.model);
656
+ const model = withModel?.model ?? session.model;
657
+ const thinking = withModel?.thinking ?? session.thinking;
658
+ let result: RunnerResult;
659
+ try {
660
+ // A check runs every rule of the project, so the longest per-rule cap wins.
661
+ const timeoutMs = rules.reduce((max, r) => Math.max(max, r.timeoutMs ?? 0), 0) || this.runTimeoutMs;
662
+ result = await this.runner({ cwd, prompt, model, thinking, timeoutMs, signal: ctrl.signal, sessionDir: this.jobStore.sessionDirFor(triggerSessionKey(cwd)), hop: this.hop + 1, parentSessionId: session.sessionId, parentCwd: session.cwd, kind: "trigger", traceId: trigger.traceId });
663
+ } catch (err: any) {
664
+ result = failedRun(err?.message ?? String(err));
665
+ } finally {
666
+ this.running.delete(trigger.traceId);
667
+ }
668
+ // Per project, not one budget for the machine: three projects polling every ten minutes used
669
+ // to exhaust a shared 40 within a couple of hours, taking the evidence with them. The key has
670
+ // to be the one the transcripts were written under, or the prune looks in a directory that
671
+ // does not exist and silently keeps everything.
672
+ this.jobStore.pruneSessions(triggerSessionKey(cwd), TRIGGER_SESSIONS_KEPT);
673
+
674
+ const summary = result.text.trim();
675
+ // A check killed by the run timeout (or aborted) has usually already *executed* the matching
676
+ // rule's action — posting the comment, kicking the deploy — so the matched ids in whatever it
677
+ // managed to say still have to disarm a fire-once rule; otherwise the next poll does it again.
678
+ const matchedIds = extractDynamicRuleIds(summary);
679
+ const matchedRules = rules.filter((r) => matchedIds.includes(r.id));
680
+ if (matchedRules.length) await this.store.markFired(matchedRules.map((r) => r.id));
681
+ const quiet = result.ok && matchedRules.length === 0;
682
+ const state = ctrl.signal.aborted ? "aborted" : result.ok ? "completed" : "failed";
683
+ this.store.appendAudit({
684
+ cwd,
685
+ type: "trigger_result",
686
+ traceId: trigger.traceId,
687
+ state,
688
+ sourceLabel: trigger.sourceLabel,
689
+ eventLabel: trigger.eventLabel,
690
+ summary: result.ok ? summary || NO_MATCH_SENTINEL : result.errorMessage,
691
+ details: { delivery: "sub_agent", matched_rule_ids: matchedIds, quiet, cost_usd: result.usage.cost, exit_code: result.exitCode, session_file: result.sessionFile, ...envelopeOf(trigger) },
692
+ });
693
+ // An abort is the user's decision about this run, not evidence that the rule's check is broken,
694
+ // so it neither counts against the rule nor clears what earlier failures recorded.
695
+ if (!ctrl.signal.aborted) await this.recordCheckOutcome(rules, result.ok, cwd);
696
+
697
+ let promoted = false;
698
+ const promoteRules = matchedRules.filter((r) => r.promoteToChat);
699
+ if (result.ok && promoteRules.length) {
700
+ const target = (await this.hooks.onPromote?.(promotionSummaryBody(trigger, summary), trigger)) ?? "chat";
701
+ promoted = target === "chat";
702
+ this.store.appendAudit({ cwd, type: "trigger_promotion", traceId: trigger.traceId, state: target === "chat" ? "promoted" : "redirected", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary, details: { prefix_injected: true, rule_ids: promoteRules.map((r) => r.id), to: target, template_name: "default", ...envelopeOf(trigger) } });
703
+ } else if (result.ok && matchedRules.length) {
704
+ this.store.appendAudit({ cwd, type: "trigger_promotion", traceId: trigger.traceId, state: "skipped", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, details: { reason: "no matched rule has promote_to_chat", ...envelopeOf(trigger) } });
705
+ }
706
+ if (trigger.sourceLabel === "local:dynamic") this.lastPoll = { at: stamp(this.now()), cwd, outcome: state === "completed" ? (quiet ? "no match" : `matched ${matchedRules.length}`) : state, traceId: trigger.traceId, sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: previewRedacted(result.ok ? summary || NO_MATCH_SENTINEL : (result.errorMessage ?? ""), 160) };
707
+ const outcome: TriggerOutcome = { trigger, delivery: "sub_agent", ok: result.ok, matchedRules, summary, error: result.ok ? undefined : result.errorMessage, durationMs: this.now() - start, cost: result.usage.cost, promoted, sessionFile: result.sessionFile };
708
+ this.hooks.onFinished?.(outcome);
709
+ return outcome;
710
+ }
711
+ }