@alphacoder-v0/pi-loops 0.22.7 → 0.24.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +681 -0
  2. package/CONTRACT.md +7 -7
  3. package/README.md +91 -63
  4. package/README.zh-CN.md +59 -39
  5. package/docs/cli.md +272 -83
  6. package/docs/configuration.md +86 -24
  7. package/docs/design.md +395 -56
  8. package/docs/downstream.md +2 -2
  9. package/docs/goal.md +12 -3
  10. package/docs/hooks.md +6 -7
  11. package/docs/loops.md +288 -59
  12. package/docs/mcp.md +22 -14
  13. package/docs/recipes.md +6 -0
  14. package/docs/screenshot.png +0 -0
  15. package/docs/triggers.md +120 -26
  16. package/docs/troubleshooting.md +37 -26
  17. package/docs/web-ui-parity.md +691 -65
  18. package/package.json +4 -3
  19. package/recipes/autoresearch/recipe.toml +1 -0
  20. package/recipes/changelog-draft/recipe.toml +1 -0
  21. package/recipes/ci-sweeper/recipe.toml +1 -0
  22. package/recipes/daily-digest/recipe.toml +1 -0
  23. package/recipes/deps-sweeper/recipe.toml +1 -0
  24. package/recipes/ecosystem/recipe.toml +1 -0
  25. package/recipes/issue-loop/recipe.toml +1 -0
  26. package/recipes/pr-watch/recipe.toml +1 -0
  27. package/skills/pi-loops/SKILL.md +6 -4
  28. package/src/activity.ts +199 -0
  29. package/src/archive.ts +21 -9
  30. package/src/args.ts +6 -2
  31. package/src/cli-entry.mjs +12 -3
  32. package/src/cli.ts +746 -125
  33. package/src/config.ts +53 -5
  34. package/src/conversation-rows.ts +469 -0
  35. package/src/danger.ts +227 -6
  36. package/src/engine.ts +1240 -0
  37. package/src/goal.ts +71 -0
  38. package/src/held-read.ts +27 -0
  39. package/src/hooks.ts +49 -18
  40. package/src/host-control-channel.ts +455 -40
  41. package/src/host-control.ts +602 -111
  42. package/src/host-entry.mjs +5 -3
  43. package/src/host-runtime.ts +455 -41
  44. package/src/host.ts +659 -123
  45. package/src/inbox-card.ts +278 -0
  46. package/src/inbox.ts +139 -62
  47. package/src/job-edit.ts +7 -1
  48. package/src/job-health.ts +14 -2
  49. package/src/job-lines.ts +201 -13
  50. package/src/job-owner.ts +74 -13
  51. package/src/job-tune.ts +110 -0
  52. package/src/lease.ts +537 -0
  53. package/src/lock.ts +7 -2
  54. package/src/mcp-pool.ts +66 -14
  55. package/src/mcp.ts +1 -1
  56. package/src/open-file.ts +54 -0
  57. package/src/page-children.ts +1507 -0
  58. package/src/page-devices.ts +192 -0
  59. package/src/page-server.ts +3150 -0
  60. package/src/path-precheck.ts +215 -0
  61. package/src/paths.ts +24 -0
  62. package/src/pi-floor.ts +10 -0
  63. package/src/pi-loops.ts +1301 -450
  64. package/src/pi-resolver.d.mts +1 -0
  65. package/src/pi-resolver.mjs +110 -10
  66. package/src/presence.ts +12 -63
  67. package/src/protocol.ts +10 -1
  68. package/src/recipe.ts +43 -1
  69. package/src/requests.ts +354 -0
  70. package/src/rule-lines.ts +68 -0
  71. package/src/run-card.ts +105 -0
  72. package/src/runner.ts +18 -1
  73. package/src/schedule.ts +1 -2
  74. package/src/scheduler.ts +598 -245
  75. package/src/sdk-runner.ts +321 -27
  76. package/src/seen.ts +278 -0
  77. package/src/session-head.ts +21 -0
  78. package/src/share.ts +12 -2
  79. package/src/snapshot.ts +3 -2
  80. package/src/store.ts +81 -8
  81. package/src/tools.ts +196 -65
  82. package/src/trigger-runtime.ts +164 -127
  83. package/src/triggers.ts +39 -7
  84. package/src/trust.ts +46 -2
  85. package/src/ui-prefs.ts +16 -5
  86. package/src/watch-plan.ts +174 -0
  87. package/src/web.d.mts +3 -0
  88. package/src/web.mjs +2260 -2340
  89. package/src/window-executor.ts +459 -0
  90. package/src/window-guard.ts +158 -0
  91. package/docs/agents/issue-tracker.md +0 -34
@@ -9,12 +9,14 @@ import * as os from "node:os";
9
9
  import * as path from "node:path";
10
10
  import { createHash } from "node:crypto";
11
11
  import { realpathish } from "./paths.ts";
12
- import { type PresenceEntry, type PresenceSelf, chooseRuleOwner, isSelf, withinProject } from "./presence.ts";
12
+ import { sameProject, withinProject } from "./paths.ts";
13
13
  import { capRedacted, previewRedacted } from "./redact.ts";
14
14
  import { type RunnerResult, type SubagentRunner, failedRun } from "./runner.ts";
15
15
  import { type SubagentSlot, SubagentSlots } from "./slots.ts";
16
16
  import type { JobStore } from "./store.ts";
17
+ import type { Inbox } from "./inbox.ts";
17
18
  import { backoffWaitMs, stamp } from "./schedule.ts";
19
+ import { type Look, PathLooks, watchOf } from "./path-precheck.ts";
18
20
  import {
19
21
  DEFAULT_TRIGGER_POLL_INTERVAL_SECS,
20
22
  DedupWindow,
@@ -24,11 +26,15 @@ import {
24
26
  type DynamicTriggerRule,
25
27
  type Trigger,
26
28
  TriggerStore,
29
+ checkDecidedFinding,
27
30
  buildPeriodicCheckTrigger,
28
31
  extractDynamicRuleIds,
29
32
  renderDynamicTriggerPrompt,
30
33
  newTraceId } from "./triggers.ts";
31
34
 
35
+ /** Bounded, display-only status of a periodic check. */
36
+ export type LastPoll = { at: string; cwd: string; outcome: string; traceId: string; sourceLabel: string; eventLabel: string; summary: string };
37
+
32
38
  export type TriggerDelivery = "sub_agent" | "inject_summary" | "inject_and_run";
33
39
 
34
40
  export interface RunningTrigger {
@@ -54,12 +60,15 @@ export interface TriggerOutcome {
54
60
  sessionFile?: string;
55
61
  }
56
62
 
57
- /** Where a promotion / injection actually went: the chat of this process, or the inbox (wrong project here). */
58
- export type PromoteTarget = "chat" | "inbox";
63
+ /** Where a promotion / injection went: the chat of this process, the queue of the conversation that created the rule (src/requests.ts), the inbox, or nowhere because an earlier one with its key is still queued. */
64
+ export type PromoteTarget = "chat" | "queued" | "inbox" | "collapsed";
59
65
 
60
66
  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>;
67
+ /**
68
+ * Insert text into the parent chat context (no model turn). Already carries the `[Trigger …]` prefix.
69
+ * `forSession` is the conversation that created the promoting rules, when they agree on one. Returns where it went.
70
+ */
71
+ onPromote?: (content: string, trigger: Trigger, forSession?: string) => PromoteTarget | Promise<PromoteTarget>;
63
72
  /** Inject `prompt` into the parent chat and run one model turn. Returns where it went. */
64
73
  onInjectAndRun?: (prompt: string, trigger: Trigger) => PromoteTarget | Promise<PromoteTarget>;
65
74
  onStarted?: (running: RunningTrigger) => void;
@@ -80,19 +89,8 @@ export interface TriggerRuntimeOptions {
80
89
  dedupFile?: string;
81
90
  /** Trigger hop of this process (0 for the interactive pi); children get hop + 1. */
82
91
  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
92
  /** Default cap on a check/action sub-agent (`[triggers] run_timeout_secs`); a rule's `timeoutMs` overrides it. */
93
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
94
  /**
97
95
  * Checks and actions in flight at once. Spawning every accepted trigger concurrently is
98
96
  * bounded in practice by a person watching the feed; a headless host has nobody watching, and a
@@ -108,10 +106,20 @@ export interface TriggerRuntimeOptions {
108
106
  slots?: SubagentSlots;
109
107
  /** Today's automation spend against `[limits] daily_budget_usd`, shared with the scheduler. */
110
108
  budget?: () => { spent: number; cap: number; over: boolean };
109
+ /**
110
+ * Where a matched rule's result is filed, whatever `promote_to_chat` says (docs/triggers.md).
111
+ * Both processes pass the scheduler's inbox; omitted, a result is only audited.
112
+ */
113
+ inbox?: Pick<Inbox, "appendOnce">;
114
+ /** The `stat` a waiting rule's look uses; tests inject one that hangs. */
115
+ stat?: (p: string) => Promise<unknown>;
111
116
  }
112
117
 
113
118
  export const DEFAULT_MAX_CONCURRENT_CHECKS = 3;
114
119
 
120
+ /** The scheduler's tick, which `tick` here runs on (`DEFAULT_TICK_MS` in scheduler.ts): no poll comes sooner. */
121
+ const POLL_TICK_MS = 30_000;
122
+
115
123
  /**
116
124
  * Pushes held while every slot is busy. Retries drain at most `maxConcurrent` (3) per scheduler
117
125
  * tick (30s), so 32 is about five minutes of backlog — the width of the dedup window that defines
@@ -151,12 +159,6 @@ export function triggerSessionKey(cwd: string): string {
151
159
  export const TRIGGER_SESSIONS_KEPT = 40;
152
160
 
153
161
  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
162
 
161
163
  /** The engine prefixes `[Trigger <trace>] ` and injects the payload summary itself, capped. */
162
164
  export function promotionBody(trigger: Trigger, summary: string): string {
@@ -176,12 +178,30 @@ export function promotionSummaryBody(trigger: Trigger, summary: string): string
176
178
  return capRedacted(`[Trigger ${trigger.traceId}] ${trigger.sourceLabel} fired ${trigger.eventLabel}.\nResult: ${summary}`, SUMMARY_CAP_CHARS);
177
179
  }
178
180
 
181
+ /**
182
+ * A promoted result no conversation can take, filed in the inbox: what both processes do when the
183
+ * promotion falls back. A rule check decided its finding already (checkDecidedFinding), filed or
184
+ * skipped as a repeat; a push with no rule did not, and is filed once by its run.
185
+ */
186
+ export async function promotionToInbox(inbox: Pick<Inbox, "appendOnce">, audit: Pick<TriggerStore, "listAudit">, content: string, trigger: { sourceLabel: string; traceId: string }, where: { cwd: string; sessionId?: string }): Promise<void> {
187
+ if (checkDecidedFinding(audit, trigger.traceId)) return;
188
+ const source = `trigger:${trigger.sourceLabel}`;
189
+ await inbox.appendOnce({ source, text: content.replace(/^\[Trigger [^\]]+\]\s*/, ""), run_id: trigger.traceId, job_id: trigger.sourceLabel, cwd: where.cwd, session_id: where.sessionId }, { run_id: trigger.traceId, source });
190
+ }
191
+
179
192
  /**
180
193
  * The envelope fields carried on every audit row, in every state a trigger passes through. Without
181
194
  * the idempotency key on each of them, "which pushes collapsed into which" cannot be answered after
182
195
  * the fact — and that question is the whole reason the dedup window is allowed to drop anything.
183
196
  * Bounded and redacted: a key is caller-supplied text.
184
197
  */
198
+ /** Rules by the conversation that created them: one check's result goes to each of those once. */
199
+ function byCreator(rules: readonly DynamicTriggerRule[]): Map<string | undefined, DynamicTriggerRule[]> {
200
+ const out = new Map<string | undefined, DynamicTriggerRule[]>();
201
+ for (const r of rules) out.set(r.createdBy?.sessionId, [...(out.get(r.createdBy?.sessionId) ?? []), r]);
202
+ return out;
203
+ }
204
+
185
205
  function envelopeOf(trigger: Trigger): Record<string, unknown> {
186
206
  return { idempotency_key: capRedacted(trigger.idempotencyKey, 256), replacement_policy: trigger.replacementPolicy, received_at: trigger.receivedAt };
187
207
  }
@@ -197,25 +217,28 @@ export class TriggerRuntime {
197
217
  /** Pushes that inject into this window are deduplicated here only: every window reacts. */
198
218
  private readonly localDedup = new DedupWindow();
199
219
  private readonly ledger: PollLedger;
200
- private readonly presence?: () => PresenceEntry[];
201
- private readonly self?: PresenceSelf;
202
- private readonly isLeader?: () => boolean;
203
220
  private readonly hop: number;
204
221
  /** Mutable so a config reload at session start takes effect. */
205
222
  runTimeoutMs: number;
206
- private readonly deferredTakeoverMs: number;
207
223
  private readonly running = new Map<string, RunningTrigger>();
208
224
  /** Pushes refused for want of a slot, waiting for the next tick to retry them (see `queuePush`). */
209
225
  private readonly pending = new Map<string, { trigger: Trigger; delivery: TriggerDelivery }>();
210
226
  /** Every sub-agent this process starts takes a slot from here, loop runs included. */
211
227
  readonly slots: SubagentSlots;
212
228
  private readonly budget: () => { spent: number; cap: number; over: boolean };
229
+ private readonly inbox: Pick<Inbox, "appendOnce"> | undefined;
213
230
  pollIntervalSecs: number;
214
231
  lastCheckAt = 0;
215
232
  /** 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;
233
+ lastPoll: LastPoll | undefined;
234
+ /** The same, per project: the host checks every project's rules, and a window shows its own. */
235
+ readonly lastPolls = new Map<string, LastPoll>();
217
236
  dedupedCount = 0;
218
237
  cycleSuppressedCount = 0;
238
+ /** The `stat`s of rules that wait for a path (src/path-precheck.ts): one in flight per rule. */
239
+ private readonly looks: PathLooks;
240
+ /** Each waiting rule's last look in this process and when it was made; the audit row is written on a change only. */
241
+ private readonly lastLooks = new Map<string, { look: Look; at: number }>();
219
242
 
220
243
  constructor(opts: TriggerRuntimeOptions) {
221
244
  this.store = opts.store;
@@ -227,14 +250,12 @@ export class TriggerRuntime {
227
250
  this.pollIntervalSecs = opts.pollIntervalSecs ?? DEFAULT_TRIGGER_POLL_INTERVAL_SECS;
228
251
  this.dedup = new DedupWindow(undefined, opts.dedupFile);
229
252
  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
253
  this.hop = opts.hop ?? 0;
234
254
  this.runTimeoutMs = opts.runTimeoutMs ?? DEFAULT_TRIGGER_RUN_TIMEOUT_MS;
235
- this.deferredTakeoverMs = opts.deferredTakeoverMs ?? DEFAULT_DEFERRED_TAKEOVER_MS;
236
255
  this.slots = opts.slots ?? new SubagentSlots(opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_CHECKS);
237
256
  this.budget = opts.budget ?? (() => ({ spent: 0, cap: 0, over: false }));
257
+ this.inbox = opts.inbox;
258
+ this.looks = new PathLooks({ stat: opts.stat });
238
259
  // Audit writes are best-effort — a failed write never fails a trigger; say so once per distinct error.
239
260
  let lastReported: string | undefined;
240
261
  this.store.onPersistenceError ??= (message) => {
@@ -279,60 +300,30 @@ export class TriggerRuntime {
279
300
  this.abortAll();
280
301
  }
281
302
 
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
303
  /**
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.
304
+ * The enabled rules that govern `cwd`. A rule created at `~/proj` still governs a push received
305
+ * at `~/proj/src` or through a symlink to it: comparing the cwd strings silently diverted those
306
+ * promotions to the inbox.
317
307
  */
318
- private rulesFor(cwd: string | undefined, ownIfUnowned: boolean): { applicable: DynamicTriggerRule[]; owned: DynamicTriggerRule[] } {
308
+ private rulesFor(cwd: string | undefined): DynamicTriggerRule[] {
319
309
  const all = this.store.load().filter((r) => r.enabled);
320
- const applicable = cwd ? all.filter((r) => withinProject(r.cwd, cwd)) : all;
321
- return { applicable, owned: applicable.filter((r) => this.ownsRule(r, ownIfUnowned)) };
310
+ return cwd ? all.filter((r) => withinProject(r.cwd, cwd)) : all;
322
311
  }
323
312
 
324
313
  /**
325
- * Scheduler tick (every process): emit one periodic check per project whose rules this process
326
- * owns, at most once per poll interval machine-wide. The ledger is claimed per ownership slot,
327
- * not per project, so two windows in one repo each check their own rules once per interval while
328
- * a hand-over of the project's own rules still never double-checks.
314
+ * Scheduler tick of the host, the one process that checks rules: one periodic check per project,
315
+ * at most once per poll interval.
316
+ *
317
+ * The poll ledger is shared with pi-loops 0.22 windows during an upgrade. Those claim
318
+ * `<host>:<cwd>` for a project's unowned rules and `<host>:<cwd>#<session>` for the rules of a
319
+ * session they have open, so this claims the same keys: a project another process has just
320
+ * checked is skipped, and so are the rules of a session an old window checked itself.
329
321
  */
330
- async tick(now: number, leader: boolean): Promise<void> {
331
- // Before raising new work: the pushes this process refused while it was busy. They are its own
332
- // backlog, not the leader's, so leadership does not gate them.
322
+ async tick(now: number): Promise<void> {
323
+ // Before raising new work: the pushes this process refused while it was busy.
333
324
  this.retryPending();
334
- const host = this.self?.host ?? os.hostname();
335
- const rules = this.store.load().filter((r) => r.enabled && this.ownsRule(r, leader) && !this.inBackoff(r, now));
325
+ const host = os.hostname();
326
+ const rules = this.store.load().filter((r) => r.enabled && !this.inBackoff(r, now));
336
327
  if (!rules.length) return;
337
328
  const byCwd = new Map<string, DynamicTriggerRule[]>();
338
329
  for (const r of rules) byCwd.set(r.cwd, [...(byCwd.get(r.cwd) ?? []), r]);
@@ -348,30 +339,75 @@ export class TriggerRuntime {
348
339
  continue;
349
340
  }
350
341
  if ([...this.running.values()].some((r) => r.cwd === cwd && r.sourceLabel === "local:dynamic")) continue; // previous check still active
351
- const bySlot = new Map<string, DynamicTriggerRule[]>();
342
+ const interval = this.pollIntervalSecs * 1000;
343
+ if (!(await this.ledger.claim(`${host}:${cwd}`, now, interval))) continue;
344
+ const claimed: DynamicTriggerRule[] = [];
345
+ const bySession = new Map<string, DynamicTriggerRule[]>();
352
346
  for (const r of group) {
353
- const slot = this.slotOf(r);
354
- bySlot.set(slot, [...(bySlot.get(slot) ?? []), r]);
347
+ const sid = r.createdBy?.sessionId;
348
+ if (sid) bySession.set(sid, [...(bySession.get(sid) ?? []), r]);
349
+ else claimed.push(r);
355
350
  }
356
- const claimed: DynamicTriggerRule[] = [];
357
- for (const [slot, owned] of bySlot) {
358
- if (await this.ledger.claim(`${host}:${cwd}${slot ? `#${slot}` : ""}`, now, this.pollIntervalSecs * 1000)) claimed.push(...owned);
351
+ for (const [sid, own] of bySession) {
352
+ if (await this.ledger.claim(`${host}:${cwd}#${sid}`, now, interval)) claimed.push(...own);
359
353
  }
360
354
  if (!claimed.length) continue;
361
355
  this.lastCheckAt = now;
356
+ // After the claim, so a rule that waits for a path is looked at once per poll, as a model
357
+ // check would be. A path that is absent keeps its rule out of the prompt; an `unknown` look
358
+ // (no permission, a hung mount) asks the model, as before there was a pre-check.
359
+ const looks = await Promise.all(claimed.map((r) => this.lookFor(r)));
360
+ this.noteLooks(cwd, claimed, looks, now);
361
+ const due = claimed.filter((_, i) => looks[i] !== "absent");
362
+ if (!due.length) continue;
363
+ const prechecked = claimed.filter((_, i) => looks[i] === "present").map((r) => r.id);
362
364
  // `handle` is documented not to reject; the catch is what makes that true rather than
363
365
  // merely intended, since nothing above this would survive one.
364
- 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
+ void this.handle(buildPeriodicCheckTrigger(cwd, due.length, new Date(now), String(process.pid)), "sub_agent", due, prechecked.length ? { prechecked } : undefined).catch((err: any) =>
365
367
  this.log(`check failed: ${err?.message ?? err}`),
366
368
  );
367
369
  }
368
370
  }
369
371
 
372
+ /** A waiting rule's look, or undefined for a rule the model checks as it always did. */
373
+ private lookFor(rule: DynamicTriggerRule): Promise<Look | undefined> {
374
+ const w = watchOf(rule);
375
+ return w ? this.looks.look(rule.id, w) : Promise.resolve(undefined);
376
+ }
377
+
378
+ /**
379
+ * One `trigger` audit row per waiting rule on a change only: `waiting` at the first look this
380
+ * process makes and when the path goes away, `appeared` when it comes back. A rule whose path stays
381
+ * absent writes nothing more, which is the point: no `running` row and no model call either.
382
+ */
383
+ private noteLooks(cwd: string, rules: DynamicTriggerRule[], looks: Array<Look | undefined>, now: number): void {
384
+ rules.forEach((rule, i) => {
385
+ const look = looks[i];
386
+ const w = watchOf(rule);
387
+ if (!look || !w) return;
388
+ const prev = this.lastLooks.get(rule.id)?.look;
389
+ this.lastLooks.set(rule.id, { look, at: now });
390
+ const row = (state: string, summary: string) =>
391
+ this.store.appendAudit({ cwd, type: "trigger", traceId: newTraceId(), state, sourceLabel: "local:dynamic", eventLabel: rule.id, summary, details: { rule_id: rule.id, path: w.raw, resolved: w.resolved } });
392
+ if (look === "absent" && prev !== "absent") row("waiting", `no model call until ${w.raw} exists`);
393
+ else if (look === "present" && prev === "absent") row("appeared", `${w.raw} exists; the model checks the rule`);
394
+ });
395
+ }
396
+
397
+ /**
398
+ * What the page's rule row says about a rule that waits for a path: its last look here, and when
399
+ * the next one is. Polls ride the scheduler's 30-second tick, so no look comes sooner than that.
400
+ */
401
+ ruleLook(ruleId: string): { look: Look; nextAt: number } | undefined {
402
+ const last = this.lastLooks.get(ruleId);
403
+ return last && { look: last.look, nextAt: last.at + Math.max(this.pollIntervalSecs * 1000, POLL_TICK_MS) };
404
+ }
405
+
370
406
  /**
371
407
  * Admit one trigger: dedup, audit, deliver. Resolves when the delivery has finished and
372
408
  * never rejects: persistence or runner failures are audited (best effort) and logged.
373
409
  */
374
- async handle(trigger: Trigger, delivery: TriggerDelivery, rules?: DynamicTriggerRule[]): Promise<TriggerOutcome | undefined> {
410
+ async handle(trigger: Trigger, delivery: TriggerDelivery, rules?: DynamicTriggerRule[], opts?: { prechecked?: string[] }): Promise<TriggerOutcome | undefined> {
375
411
  // Sub-agents register no notification hooks and run no dynamic checker, so only the
376
412
  // interactive process (hop 0) ever handles a trigger. Anything reaching a deeper hop is a
377
413
  // cycle and is suppressed, audited as cycle-suppressed.
@@ -390,7 +426,7 @@ export class TriggerRuntime {
390
426
  const outcomes = await Promise.all(cwds.map((cwd, i) => this.admit({ ...trigger, cwd, traceId: i ? newTraceId() : trigger.traceId }, delivery)));
391
427
  return outcomes.find(Boolean);
392
428
  }
393
- return await this.admit(trigger, delivery, rules);
429
+ return await this.admit(trigger, delivery, rules, opts);
394
430
  } catch (err: any) {
395
431
  const message = err?.message ?? String(err);
396
432
  this.running.delete(trigger.traceId);
@@ -400,7 +436,7 @@ export class TriggerRuntime {
400
436
  }
401
437
  }
402
438
 
403
- private async admit(trigger: Trigger, delivery: TriggerDelivery, rules?: DynamicTriggerRule[]): Promise<TriggerOutcome | undefined> {
439
+ private async admit(trigger: Trigger, delivery: TriggerDelivery, rules?: DynamicTriggerRule[], opts?: { prechecked?: string[] }): Promise<TriggerOutcome | undefined> {
404
440
  // A sub-agent costs money and a process slot. Both bounds are checked before the dedup claim,
405
441
  // so a refused trigger is never marked handled — a periodic check is simply raised again by the
406
442
  // next poll, and a push (an event that happened once, which no server repeats) is held below.
@@ -440,24 +476,10 @@ export class TriggerRuntime {
440
476
  try {
441
477
  let prev: Awaited<ReturnType<DedupWindow["check"]>>;
442
478
  if (trigger.source.kind === "mcp" && delivery === "sub_agent") {
443
- // A push evaluated against dynamic rules: by the process that owns them (each session
444
- // evaluates its own registry), and once per ownership slot machine-wide.
479
+ // A push evaluated against dynamic rules: by the host alone, once per project machine-wide.
445
480
  const cwd = trigger.cwd ?? this.getSession().cwd;
446
- const scope = this.rulesFor(cwd, this.isLeader?.() ?? true);
447
- rules = scope.owned;
448
- if (scope.applicable.length && !scope.owned.length) {
449
- // The owner may not have this MCP server connected at all — servers authenticate per
450
- // process — and a push nobody handles is simply lost. Record the hand-off, then take the
451
- // push back if the owner has not claimed the slot below inside the takeover window; the
452
- // claim is machine-wide, so at most one process ever evaluates it.
453
- 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) } });
454
- await new Promise((r) => setTimeout(r, this.deferredTakeoverMs));
455
- rules = scope.applicable;
456
- }
457
- prev = await this.dedup.check(this.pushClaimKey(trigger, cwd, rules), trigger.traceId, this.now(), trigger.replacementPolicy);
458
- if (!prev && !scope.owned.length && scope.applicable.length) {
459
- 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) } });
460
- }
481
+ rules = this.rulesFor(cwd);
482
+ prev = await this.dedup.check(`${trigger.idempotencyKey}@${os.hostname()}:${realpathish(cwd)}`, trigger.traceId, this.now(), trigger.replacementPolicy);
461
483
  } else if (trigger.source.kind === "mcp") {
462
484
  // A push injected into the chat: every window that has the server reacts (every
463
485
  // session), so the dedup window is this process's own.
@@ -478,7 +500,7 @@ export class TriggerRuntime {
478
500
  // runs the finally at once, which would release the slot the moment the sub-agent started.
479
501
  if (delivery === "inject_summary") return await this.deliverInjectSummary(trigger);
480
502
  if (delivery === "inject_and_run") return await this.deliverInjectAndRun(trigger);
481
- return await this.deliverSubAgent(trigger, rules);
503
+ return await this.deliverSubAgent(trigger, rules, opts?.prechecked);
482
504
  } finally {
483
505
  // Held for the whole delivery, `deliverSubAgent` included, and released on every path out of
484
506
  // it — a torn dedup file, a runner that rejects, an abort — or the pool leaks a slot.
@@ -578,25 +600,14 @@ export class TriggerRuntime {
578
600
  this.log(`${backing.length} trigger rule(s) failed ${worst} checks in a row; polling them with a widening gap`);
579
601
  }
580
602
 
581
- /**
582
- * The machine-wide claim a pushed evaluation takes. Keyed by the project (realpath, so two
583
- * windows reaching it by different paths agree) and by the ownership slots of the rules being
584
- * evaluated, so windows checking *different* rules of one project both react while a deferred
585
- * push and its owner still contend for the same key.
586
- */
587
- private pushClaimKey(trigger: Trigger, cwd: string, rules: DynamicTriggerRule[]): string {
588
- const slots = [...new Set(rules.map((r) => this.slotOf(r)))].sort().join(",");
589
- return `${trigger.idempotencyKey}@${this.self?.host ?? os.hostname()}:${realpathish(cwd)}${slots ? `#${slots}` : ""}`;
590
- }
591
-
592
603
  private async deliverInjectSummary(trigger: Trigger): Promise<TriggerOutcome> {
593
604
  const start = this.now();
594
605
  const summary = trigger.payloadSummary ?? "";
595
606
  let promoted = false;
596
607
  if (summary) {
597
608
  const target = (await this.hooks.onPromote?.(promotionBody(trigger, summary), trigger)) ?? "chat";
598
- promoted = target === "chat";
599
- 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) } });
609
+ promoted = target !== "inbox";
610
+ this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger_promotion", traceId: trigger.traceId, state: target === "inbox" ? "redirected" : "promoted", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary, details: { prefix_injected: true, delivery: "inject_summary", to: target, ...envelopeOf(trigger) } });
600
611
  }
601
612
  // `over_budget` on a completed row is the audit's answer to "everything else stopped today —
602
613
  // why did this run": because it cost nothing.
@@ -612,7 +623,7 @@ export class TriggerRuntime {
612
623
  const prompt = `[Trigger ${trigger.traceId}] ${trigger.payloadSummary ?? `${trigger.sourceLabel} fired: ${trigger.eventLabel}`}`;
613
624
  const target = (await this.hooks.onInjectAndRun?.(prompt, trigger)) ?? "chat";
614
625
  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) } });
615
- const outcome: TriggerOutcome = { trigger, delivery: "inject_and_run", ok: true, matchedRules: [], summary: trigger.payloadSummary ?? "", durationMs: this.now() - start, cost: 0, promoted: target === "chat" };
626
+ const outcome: TriggerOutcome = { trigger, delivery: "inject_and_run", ok: true, matchedRules: [], summary: trigger.payloadSummary ?? "", durationMs: this.now() - start, cost: 0, promoted: target !== "inbox" };
616
627
  this.hooks.onFinished?.(outcome);
617
628
  return outcome;
618
629
  }
@@ -626,13 +637,13 @@ export class TriggerRuntime {
626
637
  return trigger.cwd ?? this.getSession().cwd;
627
638
  }
628
639
 
629
- private async deliverSubAgent(trigger: Trigger, evaluate?: DynamicTriggerRule[]): Promise<TriggerOutcome> {
640
+ private async deliverSubAgent(trigger: Trigger, evaluate?: DynamicTriggerRule[], prechecked?: string[]): Promise<TriggerOutcome> {
630
641
  const start = this.now();
631
642
  const session = this.getSession();
632
643
  const cwd = this.cwdOf(trigger);
633
644
  // `evaluate` is what the caller already resolved (the tick's claimed rules, a taken-over
634
645
  // push); otherwise this is a direct handle() and we take the rules of this project we own.
635
- const rules = evaluate ?? this.rulesFor(trigger.cwd, this.isLeader?.() ?? true).owned;
646
+ const rules = evaluate ?? this.rulesFor(trigger.cwd);
636
647
  if (!rules.length) {
637
648
  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) } });
638
649
  const outcome: TriggerOutcome = { trigger, delivery: "sub_agent", ok: true, matchedRules: [], summary: "no enabled dynamic trigger rules", durationMs: 0, cost: 0, promoted: false };
@@ -644,7 +655,7 @@ export class TriggerRuntime {
644
655
  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
645
656
  this.running.set(trigger.traceId, running);
646
657
  this.hooks.onStarted?.(running);
647
- 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) } });
658
+ this.store.appendAudit({ cwd, type: "trigger_result", traceId: trigger.traceId, state: "running", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, details: { rule_count: rules.length, cwd, ...(prechecked?.length ? { prechecked } : {}), ...envelopeOf(trigger) } });
648
659
 
649
660
  // The check runs with the model the rules were created under, not whatever the process that
650
661
  // happens to own the timer is using. Both come from the first rule that recorded a model,
@@ -678,6 +689,23 @@ export class TriggerRuntime {
678
689
  if (matchedRules.length) await this.store.markFired(matchedRules.map((r) => r.id));
679
690
  const quiet = result.ok && matchedRules.length === 0;
680
691
  const state = ctrl.signal.aborted ? "aborted" : result.ok ? "completed" : "failed";
692
+ // Every match is a finding, before any promotion and before the result's row, which records what
693
+ // was decided (`details.inbox`): the promotion's inbox fallback and the row's dead letter read it
694
+ // and file nothing more (checkDecidedFinding). The text is what the check said, unedited. It is
695
+ // news: what a check says comes from outside, and `· waits:` in it must not make it a decision.
696
+ // A repeating rule that says again what its latest new finding says files nothing.
697
+ let inboxDecision: "filed" | "repeat" | undefined;
698
+ if (result.ok && matchedRules.length && this.inbox) {
699
+ const source = `trigger:${trigger.sourceLabel}`;
700
+ for (const [forSession, group] of byCreator(matchedRules)) {
701
+ try {
702
+ const there = await this.inbox.appendOnce({ source, text: summary, run_id: trigger.traceId, job_id: group[0].id, cwd, session_id: forSession }, { run_id: trigger.traceId, source, session_id: forSession }, { unlessRepeat: true });
703
+ inboxDecision = there.run_id === trigger.traceId ? "filed" : (inboxDecision ?? "repeat");
704
+ } catch (err: any) {
705
+ this.log(`trigger ${trigger.traceId.slice(0, 8)}: inbox append failed: ${err?.message ?? err}`);
706
+ }
707
+ }
708
+ }
681
709
  this.store.appendAudit({
682
710
  cwd,
683
711
  type: "trigger_result",
@@ -686,7 +714,9 @@ export class TriggerRuntime {
686
714
  sourceLabel: trigger.sourceLabel,
687
715
  eventLabel: trigger.eventLabel,
688
716
  summary: result.ok ? summary || NO_MATCH_SENTINEL : result.errorMessage,
689
- details: { delivery: "sub_agent", matched_rule_ids: matchedIds, quiet, cost_usd: result.usage.cost, exit_code: result.exitCode, session_file: result.sessionFile, ...envelopeOf(trigger) },
717
+ // `matched_rule_ids` are this project's rules the check named; `named_rule_ids` is every id it
718
+ // wrote, a made-up one or another project's included, which only `/triggers audit` shows.
719
+ details: { delivery: "sub_agent", matched_rule_ids: matchedRules.map((r) => r.id), named_rule_ids: matchedIds, quiet, cost_usd: result.usage.cost, exit_code: result.exitCode, session_file: result.sessionFile, ...(inboxDecision ? { inbox: inboxDecision } : {}), ...envelopeOf(trigger) },
690
720
  });
691
721
  // An abort is the user's decision about this run, not evidence that the rule's check is broken,
692
722
  // so it neither counts against the rule nor clears what earlier failures recorded.
@@ -695,13 +725,20 @@ export class TriggerRuntime {
695
725
  let promoted = false;
696
726
  const promoteRules = matchedRules.filter((r) => r.promoteToChat);
697
727
  if (result.ok && promoteRules.length) {
698
- const target = (await this.hooks.onPromote?.(promotionSummaryBody(trigger, summary), trigger)) ?? "chat";
699
- promoted = target === "chat";
700
- 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) } });
728
+ // One check covers every rule of the project, and two conversations in one repo each get the
729
+ // result of their own rules: one promotion per creating conversation.
730
+ for (const [forSession, group] of byCreator(promoteRules)) {
731
+ const target = (await this.hooks.onPromote?.(promotionSummaryBody(trigger, summary), trigger, forSession)) ?? "chat";
732
+ promoted ||= target !== "inbox";
733
+ this.store.appendAudit({ cwd, type: "trigger_promotion", traceId: trigger.traceId, state: target === "inbox" ? "redirected" : "promoted", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary, details: { prefix_injected: true, rule_ids: group.map((r) => r.id), to: target, template_name: "default", ...envelopeOf(trigger) } });
734
+ }
701
735
  } else if (result.ok && matchedRules.length) {
702
736
  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) } });
703
737
  }
704
- 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) };
738
+ if (trigger.sourceLabel === "local:dynamic") {
739
+ 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) };
740
+ this.lastPolls.set(cwd, this.lastPoll);
741
+ }
705
742
  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 };
706
743
  this.hooks.onFinished?.(outcome);
707
744
  return outcome;
package/src/triggers.ts CHANGED
@@ -6,11 +6,13 @@
6
6
  * reports the matched `dyn-…` ids, which the runtime marks fired.
7
7
  */
8
8
  import * as fs from "node:fs";
9
+ import * as os from "node:os";
9
10
  import * as path from "node:path";
10
11
  import { randomBytes, randomUUID } from "node:crypto";
11
12
  import { clampFuture, stamp } from "./schedule.ts";
12
13
  import { withFileLock, writeFileAtomic } from "./lock.ts";
13
14
  import { capRedacted, previewRedacted } from "./redact.ts";
15
+ import { type PathWatch, pathOnlyCondition } from "./path-precheck.ts";
14
16
 
15
17
  export const DEFAULT_TRIGGER_POLL_INTERVAL_SECS = 10 * 60;
16
18
  export const DEDUP_WINDOW_MS = 5 * 60_000;
@@ -40,6 +42,12 @@ export interface DynamicTriggerRule {
40
42
  consecutiveFailures?: number;
41
43
  /** When the last failing check ran: the backoff is measured from here, so a rule that has never been checked is never held back. */
42
44
  lastCheckFailedAt?: string;
45
+ /**
46
+ * Set when the condition is only "<path> exists / appears" (src/path-precheck.ts): each poll
47
+ * `stat`s the path first, and no model is asked while it is absent. Recorded at creation only;
48
+ * a rule without it is model-checked as before.
49
+ */
50
+ precheck?: PathWatch;
43
51
  }
44
52
 
45
53
  export type SourceKind = "local" | "mcp";
@@ -112,7 +120,8 @@ export function parseTriggerRule(spec: string): ParsedTriggerRule {
112
120
  return { condition, action };
113
121
  }
114
122
 
115
- function cleanCondition(raw: string): string {
123
+ /** A condition as the store keeps it: no leading "when" or "if", so a row that says `when …` says it once. */
124
+ export function cleanCondition(raw: string): string {
116
125
  let s = raw.trim();
117
126
  if (s.startsWith(ZH_WHEN)) s = s.slice(ZH_WHEN.length).trim();
118
127
  if (s.startsWith(ZH_IF)) s = s.slice(ZH_IF.length).trim();
@@ -215,7 +224,7 @@ interface RulesFile {
215
224
  rules: DynamicTriggerRule[];
216
225
  }
217
226
 
218
- export type AuditType = "trigger" | "trigger_result" | "trigger_promotion" | "cron_control_plane";
227
+ export type AuditType = "trigger" | "trigger_result" | "trigger_promotion" | "cron_control_plane" | "request";
219
228
 
220
229
  export interface AuditRecord {
221
230
  ts: string;
@@ -258,7 +267,12 @@ export class TriggerStore {
258
267
  throw err;
259
268
  }
260
269
  if (!text.trim()) return [];
261
- const parsed = JSON.parse(text) as RulesFile;
270
+ let parsed: RulesFile;
271
+ try {
272
+ parsed = JSON.parse(text) as RulesFile;
273
+ } catch (err: any) {
274
+ throw new Error(`${this.rulesFile}: ${err?.message ?? err}`);
275
+ }
262
276
  if (!Array.isArray(parsed?.rules)) throw new Error(`${this.rulesFile}: missing "rules" array`);
263
277
  // The pre-0.19.0 host stamp, dropped the way jobs.json drops it.
264
278
  for (const r of parsed.rules) delete (r as { host?: unknown }).host;
@@ -291,7 +305,13 @@ export class TriggerStore {
291
305
  thinking: input.thinking,
292
306
  createdBy: { sessionId: input.sessionId },
293
307
  };
294
- await this.mutate((rules) => rules.push(rule));
308
+ // Here, in the process that creates the rule: `~` is the creator's home, which the background
309
+ // process started by a service manager may not share.
310
+ const only = pathOnlyCondition(condition, { cwd: input.cwd, home: os.homedir() });
311
+ if ("watch" in only) rule.precheck = only.watch;
312
+ await this.mutate((rules) => {
313
+ rules.push(rule);
314
+ });
295
315
  return rule;
296
316
  }
297
317
 
@@ -406,6 +426,16 @@ export class TriggerStore {
406
426
  }
407
427
  }
408
428
 
429
+ /**
430
+ * Whether the rule check of run `traceId` made its inbox decision: it filed the result, or skipped
431
+ * it as a repeat of its rule's latest new finding (`details.inbox` on its `trigger_result` row). The
432
+ * promotion's inbox fallback and the row's dead letter then file nothing: the check's decision for
433
+ * the run stands, whatever words the promotion wrapped the result in.
434
+ */
435
+ export function checkDecidedFinding(audit: Pick<TriggerStore, "listAudit">, traceId: string): boolean {
436
+ return audit.listAudit(1, (r) => r.type === "trigger_result" && r.traceId === traceId && (r.details?.inbox === "filed" || r.details?.inbox === "repeat")).length > 0;
437
+ }
438
+
409
439
  export const AUDIT_USAGE = "usage: /triggers audit [N] [--all] (N is the number of rows, default 10)";
410
440
 
411
441
  /**
@@ -437,9 +467,11 @@ export function auditArgs(rest: string): { limit: number; all: boolean } | { usa
437
467
  * on_control_plane_prompt hook configured"); a UI-less interactive process is refused; an
438
468
  * interactive UI gets to ask. Returns the denial text, or undefined when the caller should ask.
439
469
  */
440
- export function controlPlanePreflight(proc: { hop: number; hasUI: boolean }, reason: string): string | undefined {
441
- if (proc.hop > 0) return `${reason} requires user confirmation; sub-agents have no control-plane prompt channel (fail-closed deny)`;
442
- if (!proc.hasUI) return `${reason} requires interactive confirmation; use the slash command instead`;
470
+ export function controlPlanePreflight(proc: { hop: number; hasUI: boolean; held?: boolean }, reason: string): string | undefined {
471
+ // A plain job's turn in a closed conversation: the person's own turn, run where they were not.
472
+ if (proc.held) return `${reason} requires user confirmation; this turn ran in the background with nobody to ask (fail-closed deny): report what you would have done, so it can be approved when the conversation is open`;
473
+ if (proc.hop > 0) return `${reason} requires user confirmation; sub-agents have no control-plane prompt channel (fail-closed deny): report what you would have done, so a person can do it`;
474
+ if (!proc.hasUI) return `${reason} requires interactive confirmation; a person can do it with the slash command`;
443
475
  return undefined;
444
476
  }
445
477