@phnx-labs/agents-cli 1.22.45 → 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 (177) hide show
  1. package/CHANGELOG.md +303 -0
  2. package/LICENSE +102 -182
  3. package/README.md +54 -17
  4. package/dist/cli/command-registry.d.ts +2 -1
  5. package/dist/cli/command-registry.js +6 -2
  6. package/dist/commands/accounts.js +56 -12
  7. package/dist/commands/auth.d.ts +2 -0
  8. package/dist/commands/auth.js +229 -0
  9. package/dist/commands/browser-sessions-picker.js +9 -7
  10. package/dist/commands/browser.js +266 -15
  11. package/dist/commands/config.js +26 -4
  12. package/dist/commands/cost.d.ts +1 -1
  13. package/dist/commands/detach.d.ts +13 -0
  14. package/dist/commands/detach.js +11 -8
  15. package/dist/commands/exec.js +55 -38
  16. package/dist/commands/feed-watch.d.ts +2 -0
  17. package/dist/commands/feed-watch.js +28 -0
  18. package/dist/commands/feed.js +27 -0
  19. package/dist/commands/feedback.js +3 -19
  20. package/dist/commands/insights.d.ts +1 -1
  21. package/dist/commands/insights.js +2 -2
  22. package/dist/commands/lease.js +3 -3
  23. package/dist/commands/routines.js +1 -1
  24. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  25. package/dist/commands/sessions-picker-factory.js +1 -1
  26. package/dist/commands/sessions-stop.d.ts +24 -0
  27. package/dist/commands/sessions-stop.js +66 -0
  28. package/dist/commands/sessions-trace.d.ts +82 -0
  29. package/dist/commands/sessions-trace.js +387 -0
  30. package/dist/commands/sessions.js +4 -0
  31. package/dist/commands/ssh.d.ts +15 -0
  32. package/dist/commands/ssh.js +194 -16
  33. package/dist/commands/teams.js +34 -2
  34. package/dist/commands/view.d.ts +2 -0
  35. package/dist/commands/view.js +19 -32
  36. package/dist/commands/watchdog.js +9 -14
  37. package/dist/lib/account-capabilities.d.ts +4 -0
  38. package/dist/lib/account-capabilities.js +13 -9
  39. package/dist/lib/account-registry.d.ts +2 -0
  40. package/dist/lib/account-registry.js +30 -2
  41. package/dist/lib/accounting/rotate.d.ts +9 -0
  42. package/dist/lib/accounting/rotate.js +12 -2
  43. package/dist/lib/accounting/usage.d.ts +92 -6
  44. package/dist/lib/accounting/usage.js +329 -75
  45. package/dist/lib/agent-modes.js +29 -1
  46. package/dist/lib/agent-spec/agents.d.ts +4 -3
  47. package/dist/lib/agent-spec/agents.js +20 -7
  48. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  49. package/dist/lib/analytics/mix-commands.js +2 -2
  50. package/dist/lib/auth-health.d.ts +13 -3
  51. package/dist/lib/auth-health.js +50 -8
  52. package/dist/lib/browser/devices.d.ts +19 -0
  53. package/dist/lib/browser/devices.js +23 -0
  54. package/dist/lib/browser/ipc.d.ts +1 -1
  55. package/dist/lib/browser/ipc.js +33 -7
  56. package/dist/lib/browser/profiles.d.ts +106 -1
  57. package/dist/lib/browser/profiles.js +300 -17
  58. package/dist/lib/browser/remote-control.d.ts +26 -2
  59. package/dist/lib/browser/remote-control.js +33 -3
  60. package/dist/lib/browser/runtime-state.d.ts +9 -0
  61. package/dist/lib/browser/runtime-state.js +24 -8
  62. package/dist/lib/browser/service.d.ts +37 -0
  63. package/dist/lib/browser/service.js +78 -20
  64. package/dist/lib/browser/sessions-list.d.ts +1 -3
  65. package/dist/lib/browser/sessions-list.js +3 -16
  66. package/dist/lib/browser/types.d.ts +12 -1
  67. package/dist/lib/cli-resources.js +17 -15
  68. package/dist/lib/codex-policy.d.ts +11 -1
  69. package/dist/lib/codex-policy.js +20 -5
  70. package/dist/lib/config-keys.d.ts +4 -3
  71. package/dist/lib/config-keys.js +17 -7
  72. package/dist/lib/daemon/runner.js +15 -2
  73. package/dist/lib/daemon-ticks.js +2 -2
  74. package/dist/lib/device-config.d.ts +25 -8
  75. package/dist/lib/device-config.js +90 -17
  76. package/dist/lib/devices/config-migration.d.ts +7 -0
  77. package/dist/lib/devices/config-migration.js +67 -3
  78. package/dist/lib/devices/harness-inventory.js +20 -3
  79. package/dist/lib/devices/health.d.ts +27 -2
  80. package/dist/lib/devices/health.js +39 -9
  81. package/dist/lib/devices/interactive-host.d.ts +15 -0
  82. package/dist/lib/devices/interactive-host.js +53 -0
  83. package/dist/lib/devices/registry.d.ts +61 -4
  84. package/dist/lib/devices/registry.js +94 -38
  85. package/dist/lib/devices/stats-cache.d.ts +6 -0
  86. package/dist/lib/devices/stats-cache.js +10 -2
  87. package/dist/lib/exec.d.ts +25 -3
  88. package/dist/lib/exec.js +75 -22
  89. package/dist/lib/feed/answer.d.ts +29 -0
  90. package/dist/lib/feed/answer.js +145 -0
  91. package/dist/lib/feed/attention.d.ts +83 -0
  92. package/dist/lib/feed/attention.js +0 -0
  93. package/dist/lib/feed/feed.d.ts +109 -0
  94. package/dist/lib/feed/feed.js +154 -3
  95. package/dist/lib/feed/pr-status.d.ts +14 -0
  96. package/dist/lib/feed/pr-status.js +47 -0
  97. package/dist/lib/feed/watch.d.ts +59 -0
  98. package/dist/lib/feed/watch.js +180 -0
  99. package/dist/lib/fleet/capture.js +6 -0
  100. package/dist/lib/fleet/remote-login.js +8 -3
  101. package/dist/lib/fleet/types.d.ts +17 -0
  102. package/dist/lib/harness/adapter.d.ts +11 -0
  103. package/dist/lib/harness/adapters/claude.js +38 -24
  104. package/dist/lib/harness/adapters/codex.js +2 -4
  105. package/dist/lib/hooks/cache.js +8 -0
  106. package/dist/lib/hooks/match.d.ts +3 -0
  107. package/dist/lib/hooks/match.js +25 -0
  108. package/dist/lib/hosts/passthrough.js +16 -0
  109. package/dist/lib/hosts/registry.js +7 -0
  110. package/dist/lib/identity/client.d.ts +53 -0
  111. package/dist/lib/identity/client.js +106 -0
  112. package/dist/lib/identity/index.d.ts +115 -0
  113. package/dist/lib/identity/index.js +82 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  116. package/dist/lib/menubar/install-menubar.d.ts +13 -0
  117. package/dist/lib/menubar/install-menubar.js +26 -2
  118. package/dist/lib/open-url.d.ts +60 -2
  119. package/dist/lib/open-url.js +167 -12
  120. package/dist/lib/probe.d.ts +8 -0
  121. package/dist/lib/probe.js +105 -0
  122. package/dist/lib/refresh-coordinator.d.ts +1 -1
  123. package/dist/lib/refresh-coordinator.js +1 -1
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  126. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  127. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  128. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  129. package/dist/lib/session/active.d.ts +67 -0
  130. package/dist/lib/session/active.js +69 -1
  131. package/dist/lib/session/db.js +4 -2
  132. package/dist/lib/session/discover.d.ts +2 -0
  133. package/dist/lib/session/discover.js +12 -6
  134. package/dist/lib/session/prompt.d.ts +35 -0
  135. package/dist/lib/session/prompt.js +56 -0
  136. package/dist/lib/session/remote/watch.d.ts +1 -1
  137. package/dist/lib/session/remote/watch.js +6 -1
  138. package/dist/lib/session/render.js +7 -3
  139. package/dist/lib/session/session-cache.js +5 -1
  140. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  141. package/dist/lib/session/trajectory-compare.js +116 -0
  142. package/dist/lib/session/trajectory-html.d.ts +15 -0
  143. package/dist/lib/session/trajectory-html.js +750 -0
  144. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  145. package/dist/lib/session/trajectory-lineage.js +195 -0
  146. package/dist/lib/session/trajectory-text.d.ts +37 -0
  147. package/dist/lib/session/trajectory-text.js +277 -0
  148. package/dist/lib/session/trajectory.d.ts +102 -0
  149. package/dist/lib/session/trajectory.js +335 -0
  150. package/dist/lib/startup/command-registry.d.ts +5 -2
  151. package/dist/lib/startup/command-registry.js +8 -5
  152. package/dist/lib/state.d.ts +1 -1
  153. package/dist/lib/state.js +4 -2
  154. package/dist/lib/teams/agents.js +9 -0
  155. package/dist/lib/terminal/inject.d.ts +19 -0
  156. package/dist/lib/terminal/inject.js +19 -9
  157. package/dist/lib/tmux/session.d.ts +46 -13
  158. package/dist/lib/tmux/session.js +173 -27
  159. package/dist/lib/types.d.ts +16 -0
  160. package/dist/lib/usage-backoff.d.ts +2 -1
  161. package/dist/lib/usage-backoff.js +60 -19
  162. package/dist/lib/usage-refresh.d.ts +17 -3
  163. package/dist/lib/usage-refresh.js +57 -11
  164. package/dist/lib/view-types.d.ts +2 -2
  165. package/dist/lib/watchdog/log.d.ts +1 -1
  166. package/dist/lib/watchdog/log.js +1 -1
  167. package/dist/lib/watchdog/runner.d.ts +36 -45
  168. package/dist/lib/watchdog/runner.js +150 -119
  169. package/dist/lib/watchdog/service.d.ts +0 -1
  170. package/dist/lib/watchdog/service.js +0 -1
  171. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  172. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  173. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  174. package/dist/lib/watchdog/watchdog.js +37 -104
  175. package/package.json +2 -2
  176. package/dist/commands/usage.d.ts +0 -27
  177. package/dist/commands/usage.js +0 -96
@@ -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,
@@ -3,7 +3,6 @@ import { type WatchdogThresholds, type WatchdogTickResult } from './runner.js';
3
3
  export interface WatchdogPassOptions {
4
4
  nudge: boolean;
5
5
  nudgeText?: string;
6
- smart?: boolean;
7
6
  smartAgent?: string;
8
7
  thresholds?: WatchdogThresholds;
9
8
  allowGhosttyFocus?: boolean;
@@ -26,7 +26,6 @@ export async function runWatchdogPass(opts) {
26
26
  const result = await runWatchdogTick({
27
27
  nudge: opts.nudge,
28
28
  nudgeText: opts.nudgeText,
29
- smart: opts.smart,
30
29
  smartAgent: opts.smartAgent,
31
30
  thresholds: opts.thresholds,
32
31
  allowGhosttyFocus: opts.allowGhosttyFocus,
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The watchdog AGENT — the whole decider, in ONE call per tick.
3
+ *
4
+ * The watchdog does not use a heuristic script to guess idle-vs-unfinished. It
5
+ * hands every idle session's task + tail to an agent (via `agents run <target>
6
+ * --mode plan`, read-only) and asks it, for each, whether it is idle-but-unfinished
7
+ * (→ nudge) or idle-and-done / needs-human (→ skip). The whole idle set goes in ONE
8
+ * invocation, so the cost is one bounded plan-mode call per tick regardless of how
9
+ * many sessions are idle — not one agent per session, and only when something is
10
+ * actually idle (the caller does not invoke this with an empty list).
11
+ *
12
+ * A resolved `watchdog` workflow (repo > user > system, via resolveWorkflowRef)
13
+ * runs by name so its WORKFLOW.md body + `model:` frontmatter apply; otherwise the
14
+ * bare agent runs the built-in WATCHDOG_SYSTEM_PROMPT. Best-effort: any failure
15
+ * (agent unavailable, timeout, no verdict) yields an empty map, and the caller
16
+ * treats an unlisted terminal as a SAFE skip — never a blind nudge.
17
+ */
18
+ import { type WatchdogCandidate, type Decision } from './watchdog.js';
19
+ /** Judge every idle candidate at once; returns decisions keyed by terminalId. */
20
+ export type WatchdogAgentDecider = (candidates: WatchdogCandidate[]) => Promise<Map<string, Decision>>;
21
+ /**
22
+ * Runs the agent once and returns its raw stdout. Injectable so a test can assert
23
+ * the ONE-call-per-tick property and the assembled prompt without shelling out.
24
+ * `runTarget` is the resolved `agents run` target (a `watchdog` workflow or the
25
+ * bare agent id).
26
+ */
27
+ export type WatchdogAgentRunner = (runTarget: string, prompt: string) => Promise<string>;
28
+ /**
29
+ * The default agent decider. `agent` is the harness the built-in prompt runs as
30
+ * (default 'claude'); `workflowCwd` is where a `watchdog` workflow override is
31
+ * resolved from (the daemon's cwd — the batch spans many projects, so there is no
32
+ * single per-session cwd to key on); `run` is the injectable subprocess seam
33
+ * (tests pass a synthetic one). The whole idle set goes to the runner in ONE call.
34
+ */
35
+ export declare function makeWatchdogAgentDecider(agent: string, opts?: {
36
+ workflowCwd?: string;
37
+ run?: WatchdogAgentRunner;
38
+ }): WatchdogAgentDecider;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The watchdog AGENT — the whole decider, in ONE call per tick.
3
+ *
4
+ * The watchdog does not use a heuristic script to guess idle-vs-unfinished. It
5
+ * hands every idle session's task + tail to an agent (via `agents run <target>
6
+ * --mode plan`, read-only) and asks it, for each, whether it is idle-but-unfinished
7
+ * (→ nudge) or idle-and-done / needs-human (→ skip). The whole idle set goes in ONE
8
+ * invocation, so the cost is one bounded plan-mode call per tick regardless of how
9
+ * many sessions are idle — not one agent per session, and only when something is
10
+ * actually idle (the caller does not invoke this with an empty list).
11
+ *
12
+ * A resolved `watchdog` workflow (repo > user > system, via resolveWorkflowRef)
13
+ * runs by name so its WORKFLOW.md body + `model:` frontmatter apply; otherwise the
14
+ * bare agent runs the built-in WATCHDOG_SYSTEM_PROMPT. Best-effort: any failure
15
+ * (agent unavailable, timeout, no verdict) yields an empty map, and the caller
16
+ * treats an unlisted terminal as a SAFE skip — never a blind nudge.
17
+ */
18
+ import { renderWatchdogPrompt, parseWatchdogResponse } from './watchdog.js';
19
+ /** The real runner: one `agents run <target> --mode plan <prompt>` subprocess. */
20
+ async function defaultAgentRunner(runTarget, prompt) {
21
+ const [{ execFile }, { promisify }] = await Promise.all([import('child_process'), import('util')]);
22
+ const execFileAsync = promisify(execFile);
23
+ const { stdout } = await execFileAsync('agents', ['run', runTarget, '--mode', 'plan', prompt], {
24
+ encoding: 'utf8',
25
+ maxBuffer: 4 * 1024 * 1024,
26
+ timeout: 120_000,
27
+ });
28
+ return stdout;
29
+ }
30
+ /**
31
+ * The default agent decider. `agent` is the harness the built-in prompt runs as
32
+ * (default 'claude'); `workflowCwd` is where a `watchdog` workflow override is
33
+ * resolved from (the daemon's cwd — the batch spans many projects, so there is no
34
+ * single per-session cwd to key on); `run` is the injectable subprocess seam
35
+ * (tests pass a synthetic one). The whole idle set goes to the runner in ONE call.
36
+ */
37
+ export function makeWatchdogAgentDecider(agent, opts = {}) {
38
+ return async (candidates) => {
39
+ const result = new Map();
40
+ if (candidates.length === 0)
41
+ return result;
42
+ try {
43
+ const { resolveWorkflowRef } = await import('../workflows.js');
44
+ const cwd = opts.workflowCwd || process.cwd();
45
+ const workflowPath = resolveWorkflowRef('watchdog', cwd);
46
+ const runTarget = workflowPath ? 'watchdog' : agent;
47
+ const prompt = renderWatchdogPrompt(candidates);
48
+ const run = opts.run ?? defaultAgentRunner;
49
+ const stdout = await run(runTarget, prompt);
50
+ for (const d of parseWatchdogResponse(stdout))
51
+ result.set(d.terminalId, d);
52
+ }
53
+ catch {
54
+ // Agent unavailable / timed out — return what we have (possibly empty); the
55
+ // caller safe-skips any terminal with no verdict. Never a blind nudge.
56
+ }
57
+ return result;
58
+ };
59
+ }
@@ -3,12 +3,22 @@ export interface WatchdogCandidate {
3
3
  agentType: 'claude' | 'codex' | 'gemini';
4
4
  tailLines: string[];
5
5
  stalledForMs: number;
6
+ /** The originating task / first prompt / topic — so the agent can judge "was given a task but hasn't finished it". */
7
+ task?: string;
8
+ /** Working directory of the session, for context. */
9
+ cwd?: string;
6
10
  }
7
11
  export interface Decision {
8
12
  terminalId: string;
9
13
  action: 'nudge' | 'skip';
10
14
  text: string;
11
15
  reason: string;
16
+ /**
17
+ * Set by the agent on a SKIP to distinguish "genuinely needs the human"
18
+ * (true → surface it) from "the task is actually done" (false/absent → leave
19
+ * it alone, do not poke). `done` is a distinct terminal state from `idle`.
20
+ */
21
+ needsHuman?: boolean;
12
22
  }
13
23
  export type StallStatus = {
14
24
  kind: 'active';
@@ -33,8 +43,7 @@ export interface ClassifyInput {
33
43
  dormantMs: number;
34
44
  }
35
45
  export declare function classifyTerminal(input: ClassifyInput): StallStatus;
36
- export declare const WATCHDOG_SYSTEM_PROMPT = "You are the watchdog for AI coding agents running in terminals. Your one job is to get\nIDLE agents moving to completion: each agent has a goal and is expected to DRIVE TO\nCOMPLETION end-to-end, but the terminals below have gone idle. Read each one's goal and\nWHY it stopped, then decide NUDGE (send a message that unsticks it and drives it to\nfinish) or SKIP (it genuinely needs the human).\n\nRead the transcript before judging \u2014 an agent that already reached a decision needs \"do\nit,\" not \"decide.\"\n\nNUDGE when the agent went idle and could keep going on its own:\n- It asked permission for an obvious or already-authorized next step\n (\"should I proceed?\", \"want me to continue?\", \"shall I run the tests?\").\n- It asked a question it could answer itself from the available context or a\n reasonable default, or by using a tool it already has.\n- It announced an action (\"I'll run X\", \"let me write Y\") but no tool call followed.\n- It already decided what to do, then stalled without doing it.\n- It paused with the task incomplete and no real blocker.\n\nThe nudge text MUST carry context, not shove:\n- Restate the goal and reference the conclusion the agent ALREADY reached.\n- Give ONE concrete next step \u2014 the specific action, the sensible default, or a TOOL it\n forgot it has (e.g. \"agents computer\" to drive the Mac, \"agents browser\" for the web,\n \"agents ssh <mac> \\\"agents computer \u2026\\\"\" to drive a Mac from another box).\n- Split the ask: drive the reversible, goal-advancing part now; flag only a genuinely\n disruptive sub-step for the human.\n- Tell it to use best judgment and finish end-to-end WITHOUT asking again.\n- Imperative, 1-2 sentences, no emojis, under 240 characters.\n\nSKIP when the agent genuinely needs the human (these belong in the user's feed, not a\nnudge):\n- Credentials, auth, login, 2FA, or biometric.\n- An irreversible or outward-facing action that needs sign-off (force-push, delete\n prod data, publish/release, spend money, send an external message) \u2014 UNLESS the House\n Rules below authorize it.\n- A real product or intent decision with genuine ambiguity (not a trivial default).\n- The task is actually complete.\n- You cannot tell what the agent is doing.\n\nRespond with ONLY a JSON array (no prose, no code fence):\n[{\"terminalId\":\"<id>\",\"action\":\"nudge\"|\"skip\",\"text\":\"<message or empty>\",\"reason\":\"<brief>\"}]";
46
+ export declare const WATCHDOG_SYSTEM_PROMPT = "You are the watchdog for AI coding agents running in terminals. You are given the idle\nsessions on this machine \u2014 each with its originating TASK, how long it has been idle, and\nthe tail of its transcript. Your one job is to tell, for each one, whether it is\nIDLE-BUT-UNFINISHED (it was given a task, went quiet, and has NOT finished or handed it\noff) or IDLE-AND-DONE (it finished, or it genuinely needs the human). Idle-but-unfinished\nis the dangerous state \u2014 the work is most likely to be silently abandoned \u2014 so those get a\nNUDGE that drives them to finish. Everything else is a SKIP.\n\nRead each transcript before judging \u2014 an agent that already reached a decision needs \"do\nit,\" not \"decide.\" Judge from the task + tail, not from keywords.\n\nNUDGE when the agent went idle and could keep going on its own:\n- It asked permission for an obvious or already-authorized next step\n (\"should I proceed?\", \"want me to continue?\", \"shall I run the tests?\").\n- It asked a question it could answer itself from the available context or a\n reasonable default, or by using a tool it already has.\n- It announced an action (\"I'll run X\", \"let me write Y\") but no tool call followed.\n- It already decided what to do, then stalled without doing it.\n- It paused with the task incomplete and no real blocker.\n\nThe nudge text MUST carry context, not shove:\n- Restate the goal and reference the conclusion the agent ALREADY reached.\n- Give ONE concrete next step \u2014 the specific action, the sensible default, or a TOOL it\n forgot it has (e.g. \"agents computer\" to drive the Mac, \"agents browser\" for the web,\n \"agents ssh <mac> \\\"agents computer \u2026\\\"\" to drive a Mac from another box).\n- Split the ask: drive the reversible, goal-advancing part now; flag only a genuinely\n disruptive sub-step for the human.\n- Tell it to use best judgment and finish end-to-end WITHOUT asking again.\n- Imperative, 1-2 sentences, no emojis, under 240 characters.\n\nSKIP in two distinct cases \u2014 mark which with \"needsHuman\":\n- needsHuman=true \u2014 the agent is genuinely blocked on a human (these belong in the user's\n feed): credentials, auth, login, 2FA, or biometric; an irreversible or outward-facing\n action needing sign-off (force-push, delete prod data, publish/release, spend money,\n send an external message) UNLESS the House Rules below authorize it; a real product or\n intent decision with genuine ambiguity (not a trivial default); or you cannot tell what\n the agent is doing.\n- needsHuman=false \u2014 the task is actually complete (idle-and-done). Leave it alone; do NOT\n poke a finished session.\n\nRespond with ONLY a JSON array (no prose, no code fence). Include \"needsHuman\" on every\nskip:\n[{\"terminalId\":\"<id>\",\"action\":\"nudge\"|\"skip\",\"text\":\"<message or empty>\",\"reason\":\"<brief>\",\"needsHuman\":true|false}]";
37
47
  export declare function composePromptWithPlaybook(basePrompt: string, playbook: string): string;
38
48
  export declare function renderWatchdogPrompt(candidates: WatchdogCandidate[], playbook?: string): string;
39
49
  export declare function parseWatchdogResponse(stdout: string): Decision[];
40
- export declare function isLikelyTrulyBlocked(candidate: WatchdogCandidate): boolean;