@phnx-labs/agents-cli 1.22.46 → 1.22.47

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 (167) hide show
  1. package/CHANGELOG.md +279 -0
  2. package/LICENSE +102 -182
  3. package/README.md +31 -18
  4. package/dist/cli/command-registry.d.ts +1 -1
  5. package/dist/cli/command-registry.js +4 -2
  6. package/dist/commands/accounts.js +56 -12
  7. package/dist/commands/auth.js +7 -5
  8. package/dist/commands/browser-sessions-picker.js +9 -7
  9. package/dist/commands/browser.js +266 -15
  10. package/dist/commands/config.js +26 -4
  11. package/dist/commands/cost.d.ts +1 -1
  12. package/dist/commands/detach.d.ts +13 -0
  13. package/dist/commands/detach.js +11 -8
  14. package/dist/commands/exec.js +55 -38
  15. package/dist/commands/feed-watch.d.ts +2 -0
  16. package/dist/commands/feed-watch.js +28 -0
  17. package/dist/commands/feed.js +27 -0
  18. package/dist/commands/feedback.js +3 -19
  19. package/dist/commands/insights.d.ts +1 -1
  20. package/dist/commands/insights.js +2 -2
  21. package/dist/commands/lease.js +3 -3
  22. package/dist/commands/routines.js +1 -1
  23. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  24. package/dist/commands/sessions-picker-factory.js +1 -1
  25. package/dist/commands/sessions-stop.d.ts +24 -0
  26. package/dist/commands/sessions-stop.js +66 -0
  27. package/dist/commands/sessions-trace.d.ts +82 -0
  28. package/dist/commands/sessions-trace.js +387 -0
  29. package/dist/commands/sessions.js +4 -0
  30. package/dist/commands/ssh.d.ts +15 -0
  31. package/dist/commands/ssh.js +194 -16
  32. package/dist/commands/teams.js +34 -2
  33. package/dist/commands/view.d.ts +2 -0
  34. package/dist/commands/view.js +18 -31
  35. package/dist/commands/watchdog.js +9 -14
  36. package/dist/lib/account-capabilities.d.ts +4 -0
  37. package/dist/lib/account-capabilities.js +13 -9
  38. package/dist/lib/account-registry.d.ts +2 -0
  39. package/dist/lib/account-registry.js +30 -2
  40. package/dist/lib/accounting/rotate.d.ts +9 -0
  41. package/dist/lib/accounting/rotate.js +12 -2
  42. package/dist/lib/accounting/usage.d.ts +70 -3
  43. package/dist/lib/accounting/usage.js +235 -63
  44. package/dist/lib/agent-modes.js +29 -1
  45. package/dist/lib/agent-spec/agents.d.ts +4 -3
  46. package/dist/lib/agent-spec/agents.js +14 -6
  47. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  48. package/dist/lib/analytics/mix-commands.js +2 -2
  49. package/dist/lib/auth-health.d.ts +13 -3
  50. package/dist/lib/auth-health.js +50 -8
  51. package/dist/lib/browser/devices.d.ts +19 -0
  52. package/dist/lib/browser/devices.js +23 -0
  53. package/dist/lib/browser/ipc.d.ts +1 -1
  54. package/dist/lib/browser/ipc.js +33 -7
  55. package/dist/lib/browser/profiles.d.ts +106 -1
  56. package/dist/lib/browser/profiles.js +300 -17
  57. package/dist/lib/browser/remote-control.d.ts +26 -2
  58. package/dist/lib/browser/remote-control.js +33 -3
  59. package/dist/lib/browser/runtime-state.d.ts +9 -0
  60. package/dist/lib/browser/runtime-state.js +24 -8
  61. package/dist/lib/browser/service.d.ts +37 -0
  62. package/dist/lib/browser/service.js +78 -20
  63. package/dist/lib/browser/sessions-list.d.ts +1 -3
  64. package/dist/lib/browser/sessions-list.js +3 -16
  65. package/dist/lib/browser/types.d.ts +12 -1
  66. package/dist/lib/codex-policy.d.ts +11 -1
  67. package/dist/lib/codex-policy.js +20 -5
  68. package/dist/lib/config-keys.d.ts +4 -3
  69. package/dist/lib/config-keys.js +17 -7
  70. package/dist/lib/daemon/runner.js +15 -2
  71. package/dist/lib/daemon-ticks.js +2 -2
  72. package/dist/lib/device-config.d.ts +25 -8
  73. package/dist/lib/device-config.js +90 -17
  74. package/dist/lib/devices/config-migration.d.ts +7 -0
  75. package/dist/lib/devices/config-migration.js +67 -3
  76. package/dist/lib/devices/health.d.ts +27 -2
  77. package/dist/lib/devices/health.js +39 -9
  78. package/dist/lib/devices/interactive-host.d.ts +15 -0
  79. package/dist/lib/devices/interactive-host.js +53 -0
  80. package/dist/lib/devices/registry.d.ts +61 -4
  81. package/dist/lib/devices/registry.js +94 -38
  82. package/dist/lib/devices/stats-cache.d.ts +6 -0
  83. package/dist/lib/devices/stats-cache.js +10 -2
  84. package/dist/lib/exec.d.ts +5 -3
  85. package/dist/lib/exec.js +32 -16
  86. package/dist/lib/feed/answer.d.ts +29 -0
  87. package/dist/lib/feed/answer.js +145 -0
  88. package/dist/lib/feed/attention.d.ts +83 -0
  89. package/dist/lib/feed/attention.js +0 -0
  90. package/dist/lib/feed/feed.d.ts +109 -0
  91. package/dist/lib/feed/feed.js +154 -3
  92. package/dist/lib/feed/pr-status.d.ts +14 -0
  93. package/dist/lib/feed/pr-status.js +47 -0
  94. package/dist/lib/feed/watch.d.ts +59 -0
  95. package/dist/lib/feed/watch.js +180 -0
  96. package/dist/lib/fleet/capture.js +6 -0
  97. package/dist/lib/fleet/remote-login.js +8 -3
  98. package/dist/lib/fleet/types.d.ts +17 -0
  99. package/dist/lib/harness/adapter.d.ts +11 -0
  100. package/dist/lib/harness/adapters/claude.js +38 -24
  101. package/dist/lib/harness/adapters/codex.js +2 -4
  102. package/dist/lib/hooks/cache.js +8 -0
  103. package/dist/lib/hooks/match.d.ts +3 -0
  104. package/dist/lib/hooks/match.js +25 -0
  105. package/dist/lib/hosts/passthrough.js +16 -0
  106. package/dist/lib/hosts/registry.js +7 -0
  107. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  108. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  109. package/dist/lib/menubar/install-menubar.d.ts +13 -0
  110. package/dist/lib/menubar/install-menubar.js +26 -2
  111. package/dist/lib/open-url.d.ts +60 -2
  112. package/dist/lib/open-url.js +167 -12
  113. package/dist/lib/refresh-coordinator.d.ts +1 -1
  114. package/dist/lib/refresh-coordinator.js +1 -1
  115. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  116. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  117. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  118. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  119. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  120. package/dist/lib/session/active.d.ts +67 -0
  121. package/dist/lib/session/active.js +69 -1
  122. package/dist/lib/session/db.js +4 -2
  123. package/dist/lib/session/discover.d.ts +2 -0
  124. package/dist/lib/session/discover.js +12 -6
  125. package/dist/lib/session/prompt.d.ts +35 -0
  126. package/dist/lib/session/prompt.js +56 -0
  127. package/dist/lib/session/remote/watch.d.ts +1 -1
  128. package/dist/lib/session/remote/watch.js +6 -1
  129. package/dist/lib/session/render.js +7 -3
  130. package/dist/lib/session/session-cache.js +5 -1
  131. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  132. package/dist/lib/session/trajectory-compare.js +116 -0
  133. package/dist/lib/session/trajectory-html.d.ts +15 -0
  134. package/dist/lib/session/trajectory-html.js +750 -0
  135. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  136. package/dist/lib/session/trajectory-lineage.js +195 -0
  137. package/dist/lib/session/trajectory-text.d.ts +37 -0
  138. package/dist/lib/session/trajectory-text.js +277 -0
  139. package/dist/lib/session/trajectory.d.ts +102 -0
  140. package/dist/lib/session/trajectory.js +335 -0
  141. package/dist/lib/startup/command-registry.d.ts +3 -1
  142. package/dist/lib/startup/command-registry.js +5 -2
  143. package/dist/lib/state.d.ts +1 -1
  144. package/dist/lib/state.js +4 -2
  145. package/dist/lib/teams/agents.js +9 -0
  146. package/dist/lib/terminal/inject.d.ts +19 -0
  147. package/dist/lib/terminal/inject.js +19 -9
  148. package/dist/lib/tmux/session.d.ts +46 -13
  149. package/dist/lib/tmux/session.js +173 -27
  150. package/dist/lib/types.d.ts +16 -0
  151. package/dist/lib/usage-backoff.d.ts +2 -1
  152. package/dist/lib/usage-backoff.js +60 -19
  153. package/dist/lib/usage-refresh.d.ts +17 -3
  154. package/dist/lib/usage-refresh.js +57 -11
  155. package/dist/lib/watchdog/log.d.ts +1 -1
  156. package/dist/lib/watchdog/log.js +1 -1
  157. package/dist/lib/watchdog/runner.d.ts +36 -45
  158. package/dist/lib/watchdog/runner.js +150 -119
  159. package/dist/lib/watchdog/service.d.ts +0 -1
  160. package/dist/lib/watchdog/service.js +0 -1
  161. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  162. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  163. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  164. package/dist/lib/watchdog/watchdog.js +37 -104
  165. package/package.json +2 -2
  166. package/dist/commands/usage.d.ts +0 -27
  167. package/dist/commands/usage.js +0 -96
@@ -19,8 +19,8 @@
19
19
  * floor and ceiling are both 5 minutes.
20
20
  * - **Hard hourly cap** (`HOURLY_CALL_CAP`) so a stuck "due" loop cannot
21
21
  * hammer an endpoint past ~12 calls/account/hour.
22
- * - **429 backoff.** A provider under `usageRateLimitedUntil` is skipped
23
- * entirely — no live fetch, no Touch ID, no re-armed penalty.
22
+ * - **429 backoff.** An account (or provider-wide scope) under
23
+ * `usageRateLimitedUntil` is skipped — no live fetch or re-armed penalty.
24
24
  * - **File-only credentials on the daemon path.** Refresh never opens the
25
25
  * ACL-bound macOS keychain item (Touch ID storm). It uses the no-ACL
26
26
  * access-token cache / setup-token / `.credentials.json` only
@@ -37,8 +37,8 @@
37
37
  * serializes read-modify-write; no lost updates.
38
38
  * 3. **macOS keychain ACL / Touch ID** — fileOnly refresh never calls
39
39
  * `security find-generic-password` on Claude's ACL item.
40
- * 4. **Provider 429** — whole provider skipped until Retry-After; cache
41
- * freezes (visible as aged `capturedAt`) rather than hammering.
40
+ * 4. **Account 429** — that account is skipped until Retry-After while its
41
+ * siblings continue; a provider-wide penalty still skips all accounts.
42
42
  * 5. **Expired access token (no refresh)** — usage path never rotates
43
43
  * single-use refresh tokens; counts as `failed`, reschedules 5m later.
44
44
  * 6. **No file credential on this host** — account skipped / failed; no
@@ -71,6 +71,12 @@ export const REFRESH_BURN_DIVISOR = 4;
71
71
  export const HOURLY_CALL_CAP = 12;
72
72
  /** How often the daemon wakes to *consider* a refresh pass (due accounts only). */
73
73
  export const USAGE_REFRESH_TICK_MS = 60 * 1000;
74
+ /** Consecutive failed live reads before one broken account is quarantined. */
75
+ export const FAILURE_QUARANTINE_THRESHOLD = 3;
76
+ /** A chronic offender waits this long while healthy siblings keep their cadence. */
77
+ export const FAILURE_QUARANTINE_MS = 30 * 60 * 1000;
78
+ const SKIP_JITTER_MIN_MS = 2_000;
79
+ const SKIP_JITTER_RANGE_MS = 3_001;
74
80
  const HOUR_MS = 60 * 60 * 1000;
75
81
  /** Test seam for the headroom cache path (see usage.ts `setClaudeUsageCachePathForTest`). */
76
82
  let headroomCachePathOverride = null;
@@ -169,8 +175,47 @@ export function nextHeadroomEntry(prev, snapshot, now) {
169
175
  nextRefreshAt: now + computeNextRefreshDelayMs(headroom.minutesToLimit),
170
176
  callTimestamps,
171
177
  computedAt: now,
178
+ consecutiveFailures: snapshot ? 0 : (prev?.consecutiveFailures ?? 0) + 1,
172
179
  };
173
180
  }
181
+ /** Stable per-account delay in [2s, 5s], used to spread skipped accounts. */
182
+ function skipJitterMs(usageKey) {
183
+ let hash = 0;
184
+ for (const char of usageKey)
185
+ hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
186
+ return SKIP_JITTER_MIN_MS + (hash % SKIP_JITTER_RANGE_MS);
187
+ }
188
+ function skippedHeadroomEntry(prev, usageKey, now, index) {
189
+ return {
190
+ status: prev?.status ?? null,
191
+ minutesToLimit: prev?.minutesToLimit ?? null,
192
+ sessionUsedPercent: prev?.sessionUsedPercent ?? null,
193
+ capturedAt: prev?.capturedAt ?? null,
194
+ nextRefreshAt: now + (index + 1) * skipJitterMs(usageKey),
195
+ callTimestamps: pruneCallTimestamps(prev?.callTimestamps ?? [], now),
196
+ computedAt: now,
197
+ consecutiveFailures: prev?.consecutiveFailures ?? 0,
198
+ };
199
+ }
200
+ function failedHeadroomEntry(prev, now) {
201
+ const next = nextHeadroomEntry(prev, null, now);
202
+ if ((next.consecutiveFailures ?? 0) >= FAILURE_QUARANTINE_THRESHOLD) {
203
+ next.nextRefreshAt = now + FAILURE_QUARANTINE_MS;
204
+ }
205
+ return next;
206
+ }
207
+ /** Cold accounts lead each pass; both cold and cached groups rotate every tick. */
208
+ export function orderUsageAccounts(accounts, cache, tick) {
209
+ const rotate = (group) => {
210
+ if (group.length < 2)
211
+ return group;
212
+ const start = tick % group.length;
213
+ return [...group.slice(start), ...group.slice(0, start)];
214
+ };
215
+ const cold = accounts.filter((account) => cache[account.usageKey] == null);
216
+ const cached = accounts.filter((account) => cache[account.usageKey] != null);
217
+ return [...rotate(cold), ...rotate(cached)];
218
+ }
174
219
  /**
175
220
  * Enumerate the usage accounts whose credentials live on THIS host — one
176
221
  * per unique usage key, deduped to the most-recently-active version (the same
@@ -217,7 +262,7 @@ export async function buildLocalUsageAccounts() {
217
262
  }
218
263
  /**
219
264
  * One refresher tick: for each local account that is due, under its hourly cap,
220
- * and not provider-backed-off, live-fetch its usage, update the cache, and
265
+ * and not backed off, live-fetch its usage, update the cache, and
221
266
  * reschedule from the new burn projection. Never throws — a single account's
222
267
  * failed fetch leaves its cache untouched and counts as `failed`.
223
268
  */
@@ -230,14 +275,15 @@ export async function runUsageRefresh(deps) {
230
275
  skippedCap: 0,
231
276
  failed: 0,
232
277
  };
233
- const accounts = await deps.listAccounts();
234
278
  const cache = readHeadroomCache();
279
+ const accounts = orderUsageAccounts(await deps.listAccounts(), cache, Math.floor(now / USAGE_REFRESH_TICK_MS));
235
280
  const updates = {};
236
- for (const account of accounts) {
281
+ for (const [index, account] of accounts.entries()) {
237
282
  const entry = cache[account.usageKey] ?? null;
238
- // A provider under a 429 penalty is off-limits — poking it re-arms the
283
+ // A penalized account/provider is off-limits — poking it re-arms the
239
284
  // penalty (the whole reason usage-backoff exists).
240
- if ((deps.backoffUntil(account.agentId) ?? 0) > now) {
285
+ if ((deps.backoffUntil(account.agentId, account.usageKey) ?? 0) > now) {
286
+ updates[account.usageKey] = skippedHeadroomEntry(entry, account.usageKey, now, index);
241
287
  result.skippedBackoff += 1;
242
288
  continue;
243
289
  }
@@ -259,12 +305,12 @@ export async function runUsageRefresh(deps) {
259
305
  // No live snapshot (expired token / fetch miss): don't rewrite the usage
260
306
  // cache, but still record the call + reschedule so a broken account
261
307
  // isn't retried every tick.
262
- updates[account.usageKey] = nextHeadroomEntry(entry, null, now);
308
+ updates[account.usageKey] = failedHeadroomEntry(entry, now);
263
309
  result.failed += 1;
264
310
  }
265
311
  }
266
312
  catch {
267
- updates[account.usageKey] = nextHeadroomEntry(entry, null, now);
313
+ updates[account.usageKey] = failedHeadroomEntry(entry, now);
268
314
  result.failed += 1;
269
315
  }
270
316
  }
@@ -1,7 +1,7 @@
1
1
  /** Same path the Fleet reader pulls (apps/ext/src/core/watchdogLog.ts). */
2
2
  export declare const WATCHDOG_LOG_PATH: string;
3
3
  /** Kinds the Factory reader accepts. Keep in lockstep with watchdogLog.ts. */
4
- export type WatchdogEventKind = 'tick' | 'decision' | 'nudge' | 'rotate' | 'error';
4
+ export type WatchdogEventKind = 'tick' | 'decision' | 'nudge' | 'undelivered' | 'rotate' | 'error';
5
5
  /** JSONL row shape — a verbatim replica of the Factory reader's WatchdogEvent. */
6
6
  export interface WatchdogEvent {
7
7
  ts: number;
@@ -18,7 +18,7 @@ import * as path from 'path';
18
18
  import { withFileLock, atomicWriteFileSync, ensureLockTarget } from '../fs-atomic.js';
19
19
  /** Same path the Fleet reader pulls (apps/ext/src/core/watchdogLog.ts). */
20
20
  export const WATCHDOG_LOG_PATH = path.join(os.homedir(), '.agents', '.cache', 'logs', 'watchdog.log');
21
- const WATCHDOG_EVENT_KINDS = new Set(['tick', 'decision', 'nudge', 'rotate', 'error']);
21
+ const WATCHDOG_EVENT_KINDS = new Set(['tick', 'decision', 'nudge', 'undelivered', 'rotate', 'error']);
22
22
  /** Parse the audit log without letting a partial final append hide valid rows. */
23
23
  export function parseWatchdogEvents(text) {
24
24
  const events = [];
@@ -3,19 +3,23 @@
3
3
  * working auto-nudge (RUSH-1415). The `agents watchdog` command drives it, so
4
4
  * the whole loop runs WITHOUT the Swift menu-bar.
5
5
  *
6
- * One tick, per session:
6
+ * One tick:
7
7
  *
8
8
  * getActiveSessions() (session/active.ts)
9
- * -> classifyTerminal(...) (watchdog/watchdog.ts) — stalled?
10
- * -> readWatchdogTail(...) (watchdog/read.ts) — the transcript tail
11
- * -> isLikelyTrulyBlocked(...) (watchdog/watchdog.ts) — promise-without-toolcall, NOT waiting-on-user
9
+ * -> classifyTerminal(...) per session (watchdog/watchdog.ts) — which are idle?
10
+ * -> readWatchdogTail(...) for the idle ones (watchdog/read.ts) — task + transcript tail
11
+ * -> the watchdog AGENT judges them ALL at once (watchdog/watchdog-agent.ts)
12
+ * — idle-but-unfinished -> nudge; idle-and-done / needs-human -> skip
12
13
  * -> resolveInjectTargetForSession(...) (terminal/resolve.ts) — THE safety gate: addressable or an honest refusal
13
- * -> injectIntoTerminal(target,text) (terminal/inject.ts) — deliver "Continue." into the EXACT split
14
+ * -> injectIntoTerminal(target,text) (terminal/inject.ts) — deliver into the EXACT split, then CONFIRM
14
15
  *
15
- * The safety gate is absolute: a nudge is delivered ONLY when the resolver
16
- * returns `addressable: true`. On `addressable: false` the reason is recorded to
17
- * a state file the menu-bar can surface later and the session is SKIPPED never
18
- * a guessed / frontmost target.
16
+ * There is no heuristic decider no regex over the tail guessing done-vs-stuck.
17
+ * The agent is the whole decider, given every idle session's task + tail in ONE
18
+ * `agents run --mode plan` call per tick. The safety gate is absolute: a nudge is
19
+ * delivered ONLY when the resolver returns `addressable: true`, and it is booked in
20
+ * the cooldown ledger ONLY when delivery is CONFIRMED. On `addressable: false` the
21
+ * reason is recorded to a state file the menu-bar can surface and the session is
22
+ * SKIPPED — never a guessed / frontmost target.
19
23
  *
20
24
  * Persistence (all under ~/.agents/.cache/state/watchdog/, tray-readable):
21
25
  * - nudges.json — { [sessionId]: lastNudgeMs } — enforces the cooldown.
@@ -25,15 +29,16 @@
25
29
  * - rotate/<sessionId>.json — the in-place rotate state machine (rotate.ts).
26
30
  * - rotate-skips.json — zero-healthy skip suppression: { [sessionId]: suppressUntilMs }.
27
31
  *
28
- * The pure logic (classifyTerminal / isLikelyTrulyBlocked) is imported and never
29
- * re-implemented; the runner only supplies its I/O (sessions, tails, clock,
30
- * policy, injection) — each an injectable seam so runner.test.ts drives real
31
- * synthetic sessions without a live terminal.
32
+ * The pure logic (classifyTerminal) is imported and never re-implemented; the
33
+ * runner only supplies its I/O (sessions, tails, clock, policy, the agent decider,
34
+ * injection) — each an injectable seam so runner.test.ts drives real synthetic
35
+ * sessions without a live terminal or a real `agents run`.
32
36
  */
33
37
  import type { ActiveSession } from '../session/active.js';
34
38
  import { type PresenceTransition } from '../session/presence.js';
35
39
  import { type InjectRail, type InjectResult, type InjectTarget } from '../terminal/index.js';
36
40
  import { type StallStatus, type WatchdogCandidate } from './watchdog.js';
41
+ import { type WatchdogAgentDecider } from './watchdog-agent.js';
37
42
  import { type OpenBlock } from '../feed/feed.js';
38
43
  import { type RotateGateResult, type RotatePhase, type RotateState } from './rotate.js';
39
44
  /** Per-session policy sentinel. `keep` is the default (watchdog may nudge). */
@@ -52,16 +57,7 @@ export interface WatchdogTickOptions {
52
57
  nudge?: boolean;
53
58
  /** Nudge text delivered into the terminal. Default "Continue." */
54
59
  nudgeText?: string;
55
- /**
56
- * Force the LLM decider (`agents run`) on EVERY stalled candidate instead of the
57
- * hybrid path. Default false. Even when false the tick still ESCALATES the
58
- * judgment-heavy cases (parked-on-question, ambiguous stalls) to the smart brain
59
- * — `smart: true` additionally routes the obvious promise-without-toolcall
60
- * nudges through it. Non-reproducible; the deterministic pre-filter is the
61
- * reproducible default.
62
- */
63
- smart?: boolean;
64
- /** Agent the smart decider runs as (and the built-in fallback prompt). Default 'claude'. */
60
+ /** Agent the watchdog decider runs as (and the built-in prompt). Default 'claude'. */
65
61
  smartAgent?: string;
66
62
  /** Threshold overrides. Missing fields fall back to DEFAULT_THRESHOLDS. */
67
63
  thresholds?: Partial<WatchdogThresholds>;
@@ -82,13 +78,20 @@ export interface WatchdogTickOptions {
82
78
  /** Per-session policy. Default = the on-disk sentinel. */
83
79
  policyFor?: (s: ActiveSession) => WatchdogPolicy;
84
80
  /**
85
- * The smart brain: given a stalled candidate, decide drive-forward vs
86
- * leave-for-human and craft the message. Default resolves a `watchdog` workflow
87
- * (repo/user override + `model:` frontmatter) and, failing that, the improved
88
- * built-in prompt both via `agents run --mode plan`. Tests inject a
89
- * synthetic decider so escalation is exercised without shelling out.
81
+ * The decider seam: given an idle candidate, decide nudge vs skip and craft the
82
+ * message. Production leaves this unset — the tick judges every idle session in
83
+ * ONE batched `agents run … --mode plan` call (watchdog-agent.ts). Tests inject a
84
+ * synthetic per-candidate decider so the decision path is exercised without
85
+ * shelling out.
90
86
  */
91
87
  smartDecider?: SmartDecider;
88
+ /**
89
+ * The batched agent decider used in production (all idle candidates → one call).
90
+ * Default `makeWatchdogAgentDecider`. Tests inject one (e.g. returning an empty
91
+ * map) to exercise the batched path and the no-verdict fallback without shelling
92
+ * out. Ignored when `smartDecider` (the per-candidate test seam) is set.
93
+ */
94
+ agentDecider?: WatchdogAgentDecider;
92
95
  /** Open feed block for a session (parked-on-question detection). Default reads the feed. */
93
96
  openBlockFor?: (s: ActiveSession) => OpenBlock | null;
94
97
  /** Inject primitive. Default injectIntoTerminal — tests capture the resolved target. */
@@ -206,31 +209,19 @@ export interface WatchdogTickResult {
206
209
  export declare function readPolicySentinel(dir: string, sessionId: string): WatchdogPolicy;
207
210
  /** Write a per-session policy sentinel (used by the CLI `agents watchdog policy`). */
208
211
  export declare function writePolicySentinel(dir: string, sessionId: string, policy: WatchdogPolicy): void;
209
- /** A decide result shared by the deterministic and smart paths. `text` overrides the default nudge text. */
212
+ /** A decision for one idle session. `text` overrides the default nudge text. */
210
213
  export interface NudgeDecision {
211
214
  nudge: boolean;
212
215
  reason: string;
213
216
  text?: string;
214
217
  /**
215
- * Set to `true` when the brain (smart decider) explicitly concluded the session
216
- * needs the human as opposed to a cheap deterministic skip (session is done,
217
- * not stalled, etc.). Only `true` when the brain escalation path ran and returned
218
- * `nudge: false`. The watchdog uses this to gate the self-file reminder: a
219
- * "session is done" skip does NOT trigger a reminder.
218
+ * On a skip, `true` = the agent judged the session genuinely needs the human
219
+ * (surface itself-file reminder / owner page); `false` / absent = idle-and-
220
+ * done, leave it alone. Gates the self-file reminder so a finished session is
221
+ * never poked.
220
222
  */
221
223
  needsHuman?: boolean;
222
224
  }
223
- /**
224
- * The smart brain. Resolves a `watchdog` workflow for the session's cwd
225
- * (project > user > system precedence, via resolveWorkflowRef) so a repo/user
226
- * override AND `model:` frontmatter come for free; when a workflow resolves it
227
- * runs `agents run watchdog --mode plan <prompt>`, else it falls back to the
228
- * improved built-in prompt via `agents run <agent> --mode plan <prompt>`. Plan
229
- * mode keeps the decider read-only. Best-effort and NON-deterministic: any
230
- * failure (decider unavailable, no verdict) returns a SAFE skip — a parked
231
- * question we cannot judge is left for the human, never blindly nudged.
232
- */
233
- export declare function makeDefaultSmartDecider(agent: string): SmartDecider;
234
225
  /**
235
226
  * Run ONE watchdog pass. Returns a structured outcome per live session; injects
236
227
  * only when `opts.nudge` is set AND the safety gate says addressable AND policy
@@ -3,19 +3,23 @@
3
3
  * working auto-nudge (RUSH-1415). The `agents watchdog` command drives it, so
4
4
  * the whole loop runs WITHOUT the Swift menu-bar.
5
5
  *
6
- * One tick, per session:
6
+ * One tick:
7
7
  *
8
8
  * getActiveSessions() (session/active.ts)
9
- * -> classifyTerminal(...) (watchdog/watchdog.ts) — stalled?
10
- * -> readWatchdogTail(...) (watchdog/read.ts) — the transcript tail
11
- * -> isLikelyTrulyBlocked(...) (watchdog/watchdog.ts) — promise-without-toolcall, NOT waiting-on-user
9
+ * -> classifyTerminal(...) per session (watchdog/watchdog.ts) — which are idle?
10
+ * -> readWatchdogTail(...) for the idle ones (watchdog/read.ts) — task + transcript tail
11
+ * -> the watchdog AGENT judges them ALL at once (watchdog/watchdog-agent.ts)
12
+ * — idle-but-unfinished -> nudge; idle-and-done / needs-human -> skip
12
13
  * -> resolveInjectTargetForSession(...) (terminal/resolve.ts) — THE safety gate: addressable or an honest refusal
13
- * -> injectIntoTerminal(target,text) (terminal/inject.ts) — deliver "Continue." into the EXACT split
14
+ * -> injectIntoTerminal(target,text) (terminal/inject.ts) — deliver into the EXACT split, then CONFIRM
14
15
  *
15
- * The safety gate is absolute: a nudge is delivered ONLY when the resolver
16
- * returns `addressable: true`. On `addressable: false` the reason is recorded to
17
- * a state file the menu-bar can surface later and the session is SKIPPED never
18
- * a guessed / frontmost target.
16
+ * There is no heuristic decider no regex over the tail guessing done-vs-stuck.
17
+ * The agent is the whole decider, given every idle session's task + tail in ONE
18
+ * `agents run --mode plan` call per tick. The safety gate is absolute: a nudge is
19
+ * delivered ONLY when the resolver returns `addressable: true`, and it is booked in
20
+ * the cooldown ledger ONLY when delivery is CONFIRMED. On `addressable: false` the
21
+ * reason is recorded to a state file the menu-bar can surface and the session is
22
+ * SKIPPED — never a guessed / frontmost target.
19
23
  *
20
24
  * Persistence (all under ~/.agents/.cache/state/watchdog/, tray-readable):
21
25
  * - nudges.json — { [sessionId]: lastNudgeMs } — enforces the cooldown.
@@ -25,10 +29,10 @@
25
29
  * - rotate/<sessionId>.json — the in-place rotate state machine (rotate.ts).
26
30
  * - rotate-skips.json — zero-healthy skip suppression: { [sessionId]: suppressUntilMs }.
27
31
  *
28
- * The pure logic (classifyTerminal / isLikelyTrulyBlocked) is imported and never
29
- * re-implemented; the runner only supplies its I/O (sessions, tails, clock,
30
- * policy, injection) — each an injectable seam so runner.test.ts drives real
31
- * synthetic sessions without a live terminal.
32
+ * The pure logic (classifyTerminal) is imported and never re-implemented; the
33
+ * runner only supplies its I/O (sessions, tails, clock, policy, the agent decider,
34
+ * injection) — each an injectable seam so runner.test.ts drives real synthetic
35
+ * sessions without a live terminal or a real `agents run`.
32
36
  */
33
37
  import * as fs from 'fs';
34
38
  import * as path from 'path';
@@ -36,7 +40,8 @@ import * as crypto from 'crypto';
36
40
  import { getActiveSessions } from '../session/active.js';
37
41
  import { reconcilePresence, loadPresence, savePresence, observedFromActive, } from '../session/presence.js';
38
42
  import { resolveInjectTargetForSession, injectIntoTerminal, } from '../terminal/index.js';
39
- import { classifyTerminal, isLikelyTrulyBlocked, renderWatchdogPrompt, parseWatchdogResponse, } from './watchdog.js';
43
+ import { classifyTerminal, } from './watchdog.js';
44
+ import { makeWatchdogAgentDecider } from './watchdog-agent.js';
40
45
  import { readWatchdogTail, WATCHDOG_STALL_MS, WATCHDOG_COOLDOWN_MS, WATCHDOG_DORMANT_MS, WATCHDOG_TAIL_LINES, } from './read.js';
41
46
  import { getRuntimeStateDir } from '../state.js';
42
47
  import { withFileLock, atomicWriteFileSync, ensureLockTarget } from '../fs-atomic.js';
@@ -113,80 +118,6 @@ function defaultLastActivity(s) {
113
118
  }
114
119
  return s.startedAtMs;
115
120
  }
116
- /**
117
- * COMPLETION_HINTS mirror of watchdog.ts. The deterministic pre-filter screens
118
- * completions to `skip` before the promise check so a finished-but-idle session
119
- * is never nudged. (isLikelyTrulyBlocked also guards completion now that its 15m
120
- * precedence is fixed; this explicit check keeps the pre-filter self-contained.)
121
- */
122
- const COMPLETION_HINTS = ['done', 'completed', 'all set', 'finished'];
123
- function tailShowsCompletion(candidate) {
124
- if (candidate.tailLines.length === 0)
125
- return false;
126
- const lowerTail = candidate.tailLines.join('\n').toLowerCase();
127
- return COMPLETION_HINTS.some((hint) => lowerTail.includes(hint));
128
- }
129
- function deterministicDecision(session, candidate) {
130
- // Parked on a question — the case Muqsit cares about most. Do NOT drop it:
131
- // escalate to the brain, which decides drive-forward vs leave-for-human.
132
- if (session.activity === 'waiting_input') {
133
- return {
134
- kind: 'escalate',
135
- reason: `parked on a question${session.awaitingReason ? ` (${session.awaitingReason})` : ''} — escalate to the brain`,
136
- };
137
- }
138
- // Clearly complete — cheap skip, no LLM.
139
- if (tailShowsCompletion(candidate)) {
140
- return { kind: 'skip', reason: 'tail shows completion (done / finished / all set) — skip' };
141
- }
142
- // Clear promise-without-toolcall — cheap nudge, no LLM.
143
- if (isLikelyTrulyBlocked(candidate)) {
144
- return { kind: 'nudge', reason: 'stalled after announcing an action with no follow-through' };
145
- }
146
- // Ambiguous stall — let the brain judge rather than blindly skip.
147
- return { kind: 'escalate', reason: 'ambiguous stall — escalate to the brain' };
148
- }
149
- /**
150
- * The smart brain. Resolves a `watchdog` workflow for the session's cwd
151
- * (project > user > system precedence, via resolveWorkflowRef) so a repo/user
152
- * override AND `model:` frontmatter come for free; when a workflow resolves it
153
- * runs `agents run watchdog --mode plan <prompt>`, else it falls back to the
154
- * improved built-in prompt via `agents run <agent> --mode plan <prompt>`. Plan
155
- * mode keeps the decider read-only. Best-effort and NON-deterministic: any
156
- * failure (decider unavailable, no verdict) returns a SAFE skip — a parked
157
- * question we cannot judge is left for the human, never blindly nudged.
158
- */
159
- export function makeDefaultSmartDecider(agent) {
160
- return async (session, candidate) => {
161
- const prompt = renderWatchdogPrompt([candidate]);
162
- try {
163
- const [{ resolveWorkflowRef }, { execFile }, { promisify }] = await Promise.all([
164
- import('../workflows.js'),
165
- import('child_process'),
166
- import('util'),
167
- ]);
168
- const cwd = session.cwd || process.cwd();
169
- const workflowPath = resolveWorkflowRef('watchdog', cwd);
170
- // A resolved `watchdog` workflow runs by name so its WORKFLOW.md body + model
171
- // frontmatter apply; otherwise the bare agent runs the built-in prompt.
172
- const runTarget = workflowPath ? 'watchdog' : agent;
173
- const execFileAsync = promisify(execFile);
174
- const { stdout } = await execFileAsync('agents', ['run', runTarget, '--mode', 'plan', prompt], {
175
- encoding: 'utf8',
176
- maxBuffer: 4 * 1024 * 1024,
177
- timeout: 120_000,
178
- });
179
- const decisions = parseWatchdogResponse(stdout);
180
- const d = decisions.find((x) => x.terminalId === candidate.terminalId) ?? decisions[0];
181
- if (!d)
182
- return { nudge: false, reason: 'smart decider returned no verdict' };
183
- return { nudge: d.action === 'nudge', reason: d.reason || `smart: ${d.action}`, text: d.text || undefined };
184
- }
185
- catch (err) {
186
- return { nudge: false, reason: `smart decider unavailable: ${err instanceof Error ? err.message : String(err)}` };
187
- }
188
- };
189
- }
190
121
  /**
191
122
  * Pick the delivery mechanism. resolveAnswerRoute (answer-router.ts) chooses
192
123
  * mailbox vs resume vs refuse; resolveInjectTargetForSession (resolve.ts) supplies
@@ -349,7 +280,6 @@ export async function runWatchdogTick(opts = {}) {
349
280
  const lastActivityFor = opts.lastActivityFor ?? defaultLastActivity;
350
281
  const tailFor = opts.tailFor ?? ((s) => (s.sessionId ? readWatchdogTail(s.sessionId, s.kind, WATCHDOG_TAIL_LINES) : []));
351
282
  const policyFor = opts.policyFor ?? ((s) => (s.sessionId ? readPolicySentinel(dir, s.sessionId) : 'keep'));
352
- const smartDecider = opts.smartDecider ?? makeDefaultSmartDecider(opts.smartAgent ?? 'claude');
353
283
  const openBlockFor = opts.openBlockFor ?? defaultOpenBlockFor;
354
284
  const injectFn = opts.injectFn ?? injectIntoTerminal;
355
285
  const publishBlockFn = opts.publishBlockFn ?? publishBlock;
@@ -401,6 +331,103 @@ export async function runWatchdogTick(opts = {}) {
401
331
  injectDryRun: opts.injectDryRun,
402
332
  logEvents, flags,
403
333
  };
334
+ // --- decide, once, with the AGENT ------------------------------------------
335
+ // Classify every session up front, collect the idle ones (with their task +
336
+ // tail), and hand the WHOLE idle set to the watchdog agent in ONE call
337
+ // (WD-GAP-1: the decider sees the fleet at once, not a lone tail). Tails are
338
+ // cached so the main loop below does not re-read them. Tests inject a
339
+ // per-candidate `smartDecider`; production runs one batched
340
+ // `agents run --mode plan` (watchdog-agent.ts). A session the agent returns no
341
+ // verdict for is a SAFE skip, never a blind nudge.
342
+ const tailCache = new Map();
343
+ const idleCandidates = [];
344
+ for (const session of sessions) {
345
+ const sid = session.sessionId;
346
+ if (!sid)
347
+ continue;
348
+ if (policyFor(session) === 'off')
349
+ continue;
350
+ const la = lastActivityFor(session);
351
+ if (la === undefined)
352
+ continue;
353
+ const st = classifyTerminal({
354
+ lastActivityMs: la, nowMs, lastNudgeMs: ledger[sid] ?? null, optedOut: false,
355
+ stallMs: thresholds.stallMs, cooldownMs: thresholds.cooldownMs, dormantMs: thresholds.dormantMs,
356
+ });
357
+ if (st.kind !== 'stalled')
358
+ continue;
359
+ const tail = tailFor(session);
360
+ tailCache.set(sid, tail);
361
+ // Rotate takes precedence over the nudge agent: a session with an in-flight
362
+ // rotate (or in the failed-rotate cooldown) or a HARD account-limit tail is
363
+ // owned by the rotate machine in the loop below — a capped account cannot be
364
+ // "Continue."d, so the agent must not judge it (and is never even consulted).
365
+ if (rotateEnabled) {
366
+ const inflight = readRotateState(dir, sid);
367
+ if (inflight && (isInflightPhase(inflight.phase) || (inflight.phase === 'failed' && (inflight.suppressUntilMs ?? 0) > nowMs)))
368
+ continue;
369
+ if (classifyTailForRotate(tail, nowMs).kind === 'rate_limited')
370
+ continue;
371
+ }
372
+ idleCandidates.push({
373
+ session,
374
+ candidate: {
375
+ terminalId: sid,
376
+ agentType: session.kind === 'codex' || session.kind === 'gemini' ? session.kind : 'claude',
377
+ tailLines: tail,
378
+ stalledForMs: st.stalledForMs,
379
+ task: session.topic ?? session.label ?? session.name,
380
+ cwd: session.cwd,
381
+ },
382
+ });
383
+ }
384
+ const decisionByTerminal = new Map();
385
+ if (idleCandidates.length > 0) {
386
+ if (opts.smartDecider) {
387
+ // Test seam: a synthetic per-candidate decider, called once per candidate. A
388
+ // skip with no explicit needsHuman defaults to surfacing it (same rule as the
389
+ // agent path) so an unfinished session is never silently abandoned.
390
+ for (const { session, candidate } of idleCandidates) {
391
+ const raw = await opts.smartDecider(session, candidate);
392
+ decisionByTerminal.set(candidate.terminalId, raw.nudge ? raw : { ...raw, needsHuman: raw.needsHuman ?? true });
393
+ }
394
+ }
395
+ else {
396
+ // Production: ONE batched agent call for every idle session this tick.
397
+ const decide = opts.agentDecider ?? makeWatchdogAgentDecider(opts.smartAgent ?? 'claude');
398
+ const verdicts = await decide(idleCandidates.map((e) => e.candidate));
399
+ // A decider outage (agent unavailable / timeout) returns no verdicts while
400
+ // idle sessions exist — surface it as an error so it is not an invisible
401
+ // no-op tick (the watchdog silently steering nothing is the failure mode
402
+ // this whole subsystem exists to prevent).
403
+ if (verdicts.size === 0) {
404
+ logEvents.push({
405
+ ts: nowMs, kind: 'error',
406
+ message: `watchdog agent returned no verdicts for ${idleCandidates.length} idle session(s) — decider unavailable this tick`,
407
+ });
408
+ }
409
+ for (const { candidate } of idleCandidates) {
410
+ const d = verdicts.get(candidate.terminalId);
411
+ decisionByTerminal.set(candidate.terminalId, d
412
+ ? {
413
+ nudge: d.action === 'nudge',
414
+ reason: d.reason || `agent: ${d.action}`,
415
+ text: d.text || undefined,
416
+ // A skip with no explicit needsHuman defaults to surfacing it —
417
+ // never silently abandon an unfinished session (the highest-risk state).
418
+ needsHuman: d.action === 'skip' ? d.needsHuman ?? true : undefined,
419
+ }
420
+ // No verdict for this session — the agent couldn't decide (partial or a
421
+ // decider outage). A NEUTRAL safe-skip: NOT "done" (needsHuman stays
422
+ // undefined, so it is never logged as finished and the reminder never
423
+ // fires), nothing booked, so the next tick re-evaluates it once the
424
+ // decider is back. Marking it done (needsHuman:false) would let an
425
+ // outage abandon every idle session; marking it needsHuman would spam a
426
+ // "you're stuck" reminder into every idle terminal on any outage.
427
+ : { nudge: false, reason: 'watchdog agent returned no verdict — retry next tick' });
428
+ }
429
+ }
430
+ }
404
431
  for (const session of sessions) {
405
432
  const policy = policyFor(session);
406
433
  const base = {
@@ -464,8 +491,8 @@ export async function runWatchdogTick(opts = {}) {
464
491
  continue;
465
492
  }
466
493
  base.stalledForMs = status.stalledForMs;
467
- // Stalled — read the tail and decide.
468
- const tailLines = tailFor(session);
494
+ // Stalled — reuse the tail the pre-pass already read for the agent.
495
+ const tailLines = tailCache.get(session.sessionId) ?? tailFor(session);
469
496
  const candidate = {
470
497
  terminalId: session.sessionId,
471
498
  agentType: (session.kind === 'codex' || session.kind === 'gemini' ? session.kind : 'claude'),
@@ -653,27 +680,10 @@ export async function runWatchdogTick(opts = {}) {
653
680
  continue;
654
681
  }
655
682
  }
656
- // The brain. The cheap deterministic pre-filter resolves the obvious cases;
657
- // parked-on-question and ambiguous stalls ESCALATE to the smart brain. `smart`
658
- // forces every stalled candidate through the brain.
659
- let decision;
660
- if (opts.smart) {
661
- const d = await smartDecider(session, candidate);
662
- // Mark needsHuman when the brain explicitly concluded "leave for human".
663
- decision = d.nudge ? d : { ...d, needsHuman: true };
664
- }
665
- else {
666
- const det = deterministicDecision(session, candidate);
667
- if (det.kind === 'escalate') {
668
- const d = await smartDecider(session, candidate);
669
- // Mark needsHuman when the brain escalation path concluded "leave for human".
670
- decision = d.nudge ? d : { ...d, needsHuman: true };
671
- }
672
- else {
673
- // Cheap deterministic path — completion or clear stall. Never "needs human".
674
- decision = { nudge: det.kind === 'nudge', reason: det.reason };
675
- }
676
- }
683
+ // The decision was made up front by the watchdog agent over the whole idle
684
+ // set (see the pre-pass). A session with no verdict is a safe skip.
685
+ const decision = decisionByTerminal.get(session.sessionId) ??
686
+ { nudge: false, reason: 'not evaluated by the watchdog agent', needsHuman: false };
677
687
  const chosenText = decision.text ?? nudgeText;
678
688
  // Log the decision for the Factory watchdog card.
679
689
  const summary = summarizeWatchdogTail(tailLines, candidate.agentType);
@@ -796,34 +806,37 @@ export async function runWatchdogTick(opts = {}) {
796
806
  }
797
807
  // Deliver. injectDryRun exercises the path without a real side effect: inject
798
808
  // still calls injectFn (which honors dryRun), mailbox/resume are short-circuited.
809
+ // `confirmed` distinguishes a delivery we KNOW reached the agent (tmux/iterm/pty,
810
+ // mailbox, resume) from one merely dispatched (vscodium's fire-and-forget
811
+ // --open-url). Only a CONFIRMED delivery is booked as a landed nudge.
799
812
  let delivered;
800
813
  if (plan.via === 'inject') {
801
814
  try {
802
815
  const r = await injectFn(plan.target, chosenText, { dryRun: opts.injectDryRun });
803
- delivered = { ok: r.ok, error: r.error };
816
+ delivered = { ok: r.ok, confirmed: r.confirmed, error: r.error };
804
817
  }
805
818
  catch (err) {
806
- delivered = { ok: false, error: err instanceof Error ? err.message : String(err) };
819
+ delivered = { ok: false, confirmed: false, error: err instanceof Error ? err.message : String(err) };
807
820
  }
808
821
  }
809
822
  else if (plan.via === 'mailbox') {
810
823
  if (opts.injectDryRun) {
811
- delivered = { ok: true };
824
+ delivered = { ok: true, confirmed: true };
812
825
  }
813
826
  else {
814
827
  try {
815
828
  deliverViaMailbox(plan.mailboxId, chosenText, block);
816
- delivered = { ok: true };
829
+ delivered = { ok: true, confirmed: true };
817
830
  }
818
831
  catch (err) {
819
- delivered = { ok: false, error: err instanceof Error ? err.message : String(err) };
832
+ delivered = { ok: false, confirmed: false, error: err instanceof Error ? err.message : String(err) };
820
833
  }
821
834
  }
822
835
  }
823
836
  else {
824
- delivered = opts.injectDryRun ? { ok: true } : await deliverViaResume(session, chosenText);
837
+ delivered = opts.injectDryRun ? { ok: true, confirmed: true } : { ...(await deliverViaResume(session, chosenText)), confirmed: true };
825
838
  }
826
- if (delivered.ok) {
839
+ if (delivered.ok && delivered.confirmed) {
827
840
  ledgerUpdates[session.sessionId] = nowMs; // start the cooldown clock
828
841
  logEvents.push({
829
842
  ts: nowMs, kind: 'nudge', terminalId: session.sessionId, agentType: candidate.agentType,
@@ -835,6 +848,24 @@ export async function runWatchdogTick(opts = {}) {
835
848
  nudgeText: chosenText,
836
849
  });
837
850
  }
851
+ else if (delivered.ok && !delivered.confirmed) {
852
+ // Dispatched but UNCONFIRMED (vscodium --open-url handed off, but the ext may
853
+ // have no-op'd the verb). Do NOT claim it landed — record `undelivered` in
854
+ // history so the phantom-nudge is visible. Still start the cooldown so a
855
+ // possibly-working ext session is not re-nudged every tick; the honest
856
+ // `undelivered` signal tells the operator to ship the swarm-ext ack.
857
+ ledgerUpdates[session.sessionId] = nowMs;
858
+ logEvents.push({
859
+ ts: nowMs, kind: 'undelivered', terminalId: session.sessionId, agentType: candidate.agentType,
860
+ message: `dispatched via ${viaLabel(plan)} but UNCONFIRMED (needs swarm-ext ack)`,
861
+ reason: decision.reason, nudgeText: chosenText,
862
+ });
863
+ outcomes.push({
864
+ ...base, decision: 'skip', addressable, rail, via: plan.via, injected: false,
865
+ reason: `nudge dispatched via ${viaLabel(plan)} but delivery is UNCONFIRMED (swarm-ext ack pending)`,
866
+ nudgeText: chosenText,
867
+ });
868
+ }
838
869
  else {
839
870
  outcomes.push({
840
871
  ...base, decision: 'skip', addressable, rail, via: plan.via, injected: false,