@phnx-labs/agents-cli 1.20.35 → 1.20.36

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 (225) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/LICENSE +185 -21
  3. package/README.md +8 -4
  4. package/dist/commands/audit.d.ts +14 -0
  5. package/dist/commands/audit.js +68 -0
  6. package/dist/commands/browser.js +82 -8
  7. package/dist/commands/check.d.ts +15 -0
  8. package/dist/commands/check.js +84 -0
  9. package/dist/commands/cloud.js +143 -3
  10. package/dist/commands/computer.js +81 -0
  11. package/dist/commands/daemon.js +4 -1
  12. package/dist/commands/doctor.js +1 -89
  13. package/dist/commands/events.js +3 -3
  14. package/dist/commands/exec.d.ts +19 -0
  15. package/dist/commands/exec.js +277 -11
  16. package/dist/commands/hosts.js +10 -6
  17. package/dist/commands/inspect.js +8 -9
  18. package/dist/commands/lock.d.ts +12 -0
  19. package/dist/commands/lock.js +70 -0
  20. package/dist/commands/message.d.ts +15 -0
  21. package/dist/commands/message.js +56 -0
  22. package/dist/commands/routines.js +101 -5
  23. package/dist/commands/secrets-migrate.js +106 -57
  24. package/dist/commands/secrets.d.ts +31 -18
  25. package/dist/commands/secrets.js +156 -75
  26. package/dist/commands/serve.d.ts +10 -0
  27. package/dist/commands/serve.js +37 -0
  28. package/dist/commands/sessions-inject.d.ts +14 -0
  29. package/dist/commands/sessions-inject.js +111 -0
  30. package/dist/commands/sessions-picker.d.ts +2 -0
  31. package/dist/commands/sessions-picker.js +24 -3
  32. package/dist/commands/sessions-resume.js +20 -8
  33. package/dist/commands/sessions.d.ts +71 -1
  34. package/dist/commands/sessions.js +365 -37
  35. package/dist/commands/setup.js +4 -2
  36. package/dist/commands/sync.d.ts +3 -1
  37. package/dist/commands/sync.js +156 -4
  38. package/dist/commands/teams.js +217 -0
  39. package/dist/commands/versions.js +2 -4
  40. package/dist/commands/watchdog.d.ts +18 -0
  41. package/dist/commands/watchdog.js +238 -0
  42. package/dist/index.js +25 -2
  43. package/dist/lib/audit/log.d.ts +92 -0
  44. package/dist/lib/audit/log.js +177 -0
  45. package/dist/lib/auto-pull.js +2 -1
  46. package/dist/lib/browser/chrome.d.ts +10 -0
  47. package/dist/lib/browser/chrome.js +18 -7
  48. package/dist/lib/browser/drivers/ssh.js +2 -1
  49. package/dist/lib/browser/har.d.ts +84 -0
  50. package/dist/lib/browser/har.js +77 -0
  51. package/dist/lib/browser/ipc.js +24 -3
  52. package/dist/lib/browser/profiles.d.ts +1 -1
  53. package/dist/lib/browser/profiles.js +8 -10
  54. package/dist/lib/browser/refs.d.ts +65 -0
  55. package/dist/lib/browser/refs.js +73 -1
  56. package/dist/lib/browser/runtime-state.js +1 -0
  57. package/dist/lib/browser/service.d.ts +38 -2
  58. package/dist/lib/browser/service.js +112 -8
  59. package/dist/lib/browser/types.d.ts +14 -1
  60. package/dist/lib/budget/live-cloud.d.ts +42 -0
  61. package/dist/lib/budget/live-cloud.js +79 -0
  62. package/dist/lib/budget/live-team.d.ts +31 -0
  63. package/dist/lib/budget/live-team.js +115 -0
  64. package/dist/lib/cloud/codex.js +4 -0
  65. package/dist/lib/cloud/rush.d.ts +12 -1
  66. package/dist/lib/cloud/rush.js +13 -3
  67. package/dist/lib/cloud/types.d.ts +9 -0
  68. package/dist/lib/computer/dispatch.d.ts +8 -0
  69. package/dist/lib/computer/dispatch.js +125 -0
  70. package/dist/lib/computer/loop.d.ts +62 -0
  71. package/dist/lib/computer/loop.js +98 -0
  72. package/dist/lib/computer/model.d.ts +44 -0
  73. package/dist/lib/computer/model.js +157 -0
  74. package/dist/lib/concurrency.d.ts +19 -0
  75. package/dist/lib/concurrency.js +33 -0
  76. package/dist/lib/daemon.d.ts +57 -0
  77. package/dist/lib/daemon.js +192 -16
  78. package/dist/lib/devices/registry.d.ts +7 -0
  79. package/dist/lib/devices/registry.js +24 -0
  80. package/dist/lib/devices/tailscale.js +1 -1
  81. package/dist/lib/drift.d.ts +52 -0
  82. package/dist/lib/drift.js +112 -0
  83. package/dist/lib/events.d.ts +1 -1
  84. package/dist/lib/events.js +31 -13
  85. package/dist/lib/exec.d.ts +17 -0
  86. package/dist/lib/exec.js +79 -13
  87. package/dist/lib/git.d.ts +27 -0
  88. package/dist/lib/git.js +56 -1
  89. package/dist/lib/hooks/cache.d.ts +6 -0
  90. package/dist/lib/hooks/cache.js +54 -12
  91. package/dist/lib/hooks.d.ts +27 -0
  92. package/dist/lib/hooks.js +127 -8
  93. package/dist/lib/hosts/dispatch.d.ts +15 -0
  94. package/dist/lib/hosts/dispatch.js +39 -6
  95. package/dist/lib/hosts/logs.js +30 -1
  96. package/dist/lib/hosts/option.js +1 -1
  97. package/dist/lib/hosts/passthrough.js +3 -1
  98. package/dist/lib/hosts/ready.d.ts +29 -6
  99. package/dist/lib/hosts/ready.js +66 -15
  100. package/dist/lib/hosts/registry.d.ts +19 -2
  101. package/dist/lib/hosts/registry.js +58 -2
  102. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  103. package/dist/lib/hosts/remote-cmd.js +70 -1
  104. package/dist/lib/hosts/remote-os.d.ts +17 -0
  105. package/dist/lib/hosts/remote-os.js +30 -0
  106. package/dist/lib/hosts/session-index.d.ts +34 -0
  107. package/dist/lib/hosts/session-index.js +56 -0
  108. package/dist/lib/hosts/tasks.d.ts +14 -0
  109. package/dist/lib/hosts/tasks.js +15 -0
  110. package/dist/lib/lock.d.ts +93 -0
  111. package/dist/lib/lock.js +207 -0
  112. package/dist/lib/loop.js +16 -1
  113. package/dist/lib/machine-id.d.ts +21 -0
  114. package/dist/lib/machine-id.js +26 -0
  115. package/dist/lib/mailbox-target.d.ts +36 -0
  116. package/dist/lib/mailbox-target.js +45 -0
  117. package/dist/lib/mailbox.d.ts +47 -0
  118. package/dist/lib/mailbox.js +194 -0
  119. package/dist/lib/mcp.d.ts +5 -0
  120. package/dist/lib/mcp.js +24 -8
  121. package/dist/lib/migrate.d.ts +19 -0
  122. package/dist/lib/migrate.js +134 -26
  123. package/dist/lib/overdue.js +3 -0
  124. package/dist/lib/picker.d.ts +2 -0
  125. package/dist/lib/picker.js +4 -1
  126. package/dist/lib/platform/exec.d.ts +46 -0
  127. package/dist/lib/platform/exec.js +74 -0
  128. package/dist/lib/platform/process.d.ts +31 -0
  129. package/dist/lib/platform/process.js +34 -1
  130. package/dist/lib/platform/winpath.js +2 -0
  131. package/dist/lib/plugins.js +16 -6
  132. package/dist/lib/profiles.d.ts +25 -0
  133. package/dist/lib/profiles.js +22 -6
  134. package/dist/lib/pty-client.js +2 -1
  135. package/dist/lib/rotate.d.ts +61 -0
  136. package/dist/lib/rotate.js +52 -0
  137. package/dist/lib/routines.d.ts +40 -2
  138. package/dist/lib/routines.js +66 -8
  139. package/dist/lib/runner.d.ts +11 -2
  140. package/dist/lib/runner.js +49 -7
  141. package/dist/lib/scheduler.js +6 -1
  142. package/dist/lib/secrets/bundles.d.ts +60 -4
  143. package/dist/lib/secrets/bundles.js +131 -12
  144. package/dist/lib/secrets/filestore.d.ts +3 -0
  145. package/dist/lib/secrets/filestore.js +42 -16
  146. package/dist/lib/secrets/index.d.ts +43 -2
  147. package/dist/lib/secrets/index.js +102 -3
  148. package/dist/lib/secrets/mcp.d.ts +93 -0
  149. package/dist/lib/secrets/mcp.js +205 -0
  150. package/dist/lib/secrets/remote.js +12 -5
  151. package/dist/lib/secrets/sync.js +83 -4
  152. package/dist/lib/secrets/windows.js +14 -3
  153. package/dist/lib/serve/data.d.ts +81 -0
  154. package/dist/lib/serve/data.js +91 -0
  155. package/dist/lib/serve/page.d.ts +7 -0
  156. package/dist/lib/serve/page.js +140 -0
  157. package/dist/lib/serve/server.d.ts +46 -0
  158. package/dist/lib/serve/server.js +115 -0
  159. package/dist/lib/session/active.d.ts +54 -0
  160. package/dist/lib/session/active.js +190 -19
  161. package/dist/lib/session/discover.d.ts +37 -0
  162. package/dist/lib/session/discover.js +111 -28
  163. package/dist/lib/session/inject.d.ts +18 -0
  164. package/dist/lib/session/inject.js +21 -0
  165. package/dist/lib/session/parse.js +23 -20
  166. package/dist/lib/session/pid-registry.d.ts +1 -0
  167. package/dist/lib/session/pid-registry.js +24 -0
  168. package/dist/lib/session/provenance.d.ts +14 -2
  169. package/dist/lib/session/provenance.js +39 -8
  170. package/dist/lib/session/remote-active.js +19 -7
  171. package/dist/lib/session/remote-list.d.ts +51 -0
  172. package/dist/lib/session/remote-list.js +213 -0
  173. package/dist/lib/session/remote.d.ts +7 -1
  174. package/dist/lib/session/remote.js +16 -2
  175. package/dist/lib/session/sync/config.d.ts +1 -15
  176. package/dist/lib/session/sync/config.js +4 -20
  177. package/dist/lib/session/types.d.ts +17 -0
  178. package/dist/lib/shims.d.ts +36 -6
  179. package/dist/lib/shims.js +91 -29
  180. package/dist/lib/ssh-exec.js +2 -0
  181. package/dist/lib/ssh-tunnel.js +2 -1
  182. package/dist/lib/startup/command-registry.d.ts +6 -0
  183. package/dist/lib/startup/command-registry.js +13 -1
  184. package/dist/lib/state.d.ts +13 -0
  185. package/dist/lib/state.js +103 -9
  186. package/dist/lib/sync-umbrella.d.ts +14 -7
  187. package/dist/lib/sync-umbrella.js +17 -9
  188. package/dist/lib/teams/forEach.d.ts +110 -0
  189. package/dist/lib/teams/forEach.js +186 -0
  190. package/dist/lib/teams/index.d.ts +1 -0
  191. package/dist/lib/teams/index.js +1 -0
  192. package/dist/lib/teams/pr-watch.d.ts +226 -0
  193. package/dist/lib/teams/pr-watch.js +371 -0
  194. package/dist/lib/teams/supervisor.d.ts +14 -1
  195. package/dist/lib/teams/supervisor.js +19 -0
  196. package/dist/lib/teams/worktree.d.ts +9 -0
  197. package/dist/lib/teams/worktree.js +32 -0
  198. package/dist/lib/terminal/backends/index.d.ts +2 -1
  199. package/dist/lib/terminal/backends/index.js +3 -1
  200. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  201. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  202. package/dist/lib/terminal/index.d.ts +4 -1
  203. package/dist/lib/terminal/index.js +4 -1
  204. package/dist/lib/terminal/inject.d.ts +204 -0
  205. package/dist/lib/terminal/inject.js +247 -0
  206. package/dist/lib/terminal/resolve.d.ts +64 -0
  207. package/dist/lib/terminal/resolve.js +90 -0
  208. package/dist/lib/terminal/types.d.ts +1 -1
  209. package/dist/lib/triggers/webhook.d.ts +85 -0
  210. package/dist/lib/triggers/webhook.js +141 -0
  211. package/dist/lib/versions.d.ts +23 -0
  212. package/dist/lib/versions.js +119 -13
  213. package/dist/lib/watchdog/index.d.ts +3 -0
  214. package/dist/lib/watchdog/index.js +5 -0
  215. package/dist/lib/watchdog/read.d.ts +35 -0
  216. package/dist/lib/watchdog/read.js +149 -0
  217. package/dist/lib/watchdog/runner.d.ts +127 -0
  218. package/dist/lib/watchdog/runner.js +322 -0
  219. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  220. package/dist/lib/watchdog/watchdog.js +166 -0
  221. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  222. package/dist/lib/watchdog/watchdogTail.js +154 -0
  223. package/dist/lib/workflows.d.ts +166 -0
  224. package/dist/lib/workflows.js +193 -0
  225. package/package.json +5 -4
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Watchdog runner — the CONSUMER that wires the merged pure pieces into a
3
+ * working auto-nudge (RUSH-1415). The `agents watchdog` command drives it, so
4
+ * the whole loop runs WITHOUT the Swift menu-bar.
5
+ *
6
+ * One tick, per session:
7
+ *
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
12
+ * -> 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
+ *
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.
19
+ *
20
+ * Persistence (all under ~/.agents/.cache/state/watchdog/, tray-readable):
21
+ * - nudges.json — { [sessionId]: lastNudgeMs } — enforces the cooldown.
22
+ * - flags.json — { [sessionId]: { reason, host, atMs } } — un-addressable stalls.
23
+ * - last-tick.json — the full outcome list from the most recent tick.
24
+ * - policy/<sessionId> — per-session sentinel: off | keep | handsoff.
25
+ *
26
+ * The pure logic (classifyTerminal / isLikelyTrulyBlocked) is imported and never
27
+ * re-implemented; the runner only supplies its I/O (sessions, tails, clock,
28
+ * policy, injection) — each an injectable seam so runner.test.ts drives real
29
+ * synthetic sessions without a live terminal.
30
+ */
31
+ import type { ActiveSession } from '../session/active.js';
32
+ import { type InjectRail } from '../terminal/resolve.js';
33
+ import { type StallStatus } from './watchdog.js';
34
+ /** Per-session policy sentinel. `keep` is the default (watchdog may nudge). */
35
+ export type WatchdogPolicy = 'off' | 'keep' | 'handsoff';
36
+ /** Stall / cooldown / dormant thresholds (ms). Defaults mirror read.ts. */
37
+ export interface WatchdogThresholds {
38
+ stallMs: number;
39
+ cooldownMs: number;
40
+ dormantMs: number;
41
+ }
42
+ export declare const DEFAULT_THRESHOLDS: WatchdogThresholds;
43
+ /** The default nudge text — a short imperative, configurable via opts.nudgeText. */
44
+ export declare const DEFAULT_NUDGE_TEXT = "Continue.";
45
+ export interface WatchdogTickOptions {
46
+ /** Actually inject when a nudge is decided. Default false (dry status). */
47
+ nudge?: boolean;
48
+ /** Nudge text delivered into the terminal. Default "Continue." */
49
+ nudgeText?: string;
50
+ /**
51
+ * Use the LLM decider (`agents run`) to decide + choose nudge text instead of
52
+ * the deterministic promise-without-toolcall path. Default false so ticks are
53
+ * reproducible. Best-effort: a decider failure falls back to skip.
54
+ */
55
+ smart?: boolean;
56
+ /** Agent the smart decider runs as. Default 'claude'. */
57
+ smartAgent?: string;
58
+ /** Threshold overrides. Missing fields fall back to DEFAULT_THRESHOLDS. */
59
+ thresholds?: Partial<WatchdogThresholds>;
60
+ /** Permit the coarse, focus-stealing Ghostty path. Off by default. */
61
+ allowGhosttyFocus?: boolean;
62
+ /** Pass dryRun through to injectIntoTerminal (tests set true — no real terminal). */
63
+ injectDryRun?: boolean;
64
+ /** Session list. Default getActiveSessions(). Tests pass synthetic sessions. */
65
+ sessions?: ActiveSession[];
66
+ /** Clock. Default Date.now(). Tests pin it. */
67
+ nowMs?: number;
68
+ /** Override the state directory (tests point at a tmpdir). */
69
+ stateDir?: string;
70
+ /** lastActivity (ms) for a session. Default = its transcript mtime. */
71
+ lastActivityFor?: (s: ActiveSession) => number | undefined;
72
+ /** Transcript tail lines for a session. Default readWatchdogTail(). */
73
+ tailFor?: (s: ActiveSession) => string[];
74
+ /** Per-session policy. Default = the on-disk sentinel. */
75
+ policyFor?: (s: ActiveSession) => WatchdogPolicy;
76
+ }
77
+ /** What the tick decided for a single session — the row `--json` / the tray reads. */
78
+ export interface SessionOutcome {
79
+ sessionId?: string;
80
+ kind: string;
81
+ host?: string;
82
+ cwd?: string;
83
+ label?: string;
84
+ /** classifyTerminal's verdict for this session. */
85
+ stall: StallStatus['kind'];
86
+ /** stalled duration (ms), when stalled. */
87
+ stalledForMs?: number;
88
+ policy: WatchdogPolicy;
89
+ decision: 'nudge' | 'skip';
90
+ reason: string;
91
+ /** The resolved rail, when addressable. */
92
+ rail?: InjectRail;
93
+ /** True when resolveInjectTarget said addressable (only meaningful once we'd nudge). */
94
+ addressable?: boolean;
95
+ /** True when a nudge was actually delivered this tick. */
96
+ injected?: boolean;
97
+ /** The text that was (or would be) delivered. */
98
+ nudgeText?: string;
99
+ }
100
+ export interface WatchdogTickResult {
101
+ atMs: number;
102
+ /** Whether this tick was allowed to inject (opts.nudge). */
103
+ didNudge: boolean;
104
+ outcomes: SessionOutcome[];
105
+ /** Convenience counts for the menu-bar / status line. */
106
+ counts: {
107
+ total: number;
108
+ stalled: number;
109
+ nudged: number;
110
+ unaddressable: number;
111
+ skipped: number;
112
+ };
113
+ }
114
+ /**
115
+ * On-disk per-session policy sentinel: `<stateDir>/policy/<sessionId>` whose
116
+ * contents are `off` | `keep` | `handsoff`. Absent / unreadable / unknown → keep.
117
+ */
118
+ export declare function readPolicySentinel(dir: string, sessionId: string): WatchdogPolicy;
119
+ /** Write a per-session policy sentinel (used by the CLI `agents watchdog policy`). */
120
+ export declare function writePolicySentinel(dir: string, sessionId: string, policy: WatchdogPolicy): void;
121
+ /**
122
+ * Run ONE watchdog pass. Returns a structured outcome per live session; injects
123
+ * only when `opts.nudge` is set AND the safety gate says addressable AND policy
124
+ * permits. Persists the cooldown ledger, un-addressable flags, and a last-tick
125
+ * snapshot to the tray-readable state dir.
126
+ */
127
+ export declare function runWatchdogTick(opts?: WatchdogTickOptions): Promise<WatchdogTickResult>;
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Watchdog runner — the CONSUMER that wires the merged pure pieces into a
3
+ * working auto-nudge (RUSH-1415). The `agents watchdog` command drives it, so
4
+ * the whole loop runs WITHOUT the Swift menu-bar.
5
+ *
6
+ * One tick, per session:
7
+ *
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
12
+ * -> 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
+ *
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.
19
+ *
20
+ * Persistence (all under ~/.agents/.cache/state/watchdog/, tray-readable):
21
+ * - nudges.json — { [sessionId]: lastNudgeMs } — enforces the cooldown.
22
+ * - flags.json — { [sessionId]: { reason, host, atMs } } — un-addressable stalls.
23
+ * - last-tick.json — the full outcome list from the most recent tick.
24
+ * - policy/<sessionId> — per-session sentinel: off | keep | handsoff.
25
+ *
26
+ * The pure logic (classifyTerminal / isLikelyTrulyBlocked) is imported and never
27
+ * re-implemented; the runner only supplies its I/O (sessions, tails, clock,
28
+ * policy, injection) — each an injectable seam so runner.test.ts drives real
29
+ * synthetic sessions without a live terminal.
30
+ */
31
+ import * as fs from 'fs';
32
+ import * as path from 'path';
33
+ import { getActiveSessions } from '../session/active.js';
34
+ import { resolveInjectTargetForSession, } from '../terminal/resolve.js';
35
+ import { injectIntoTerminal } from '../terminal/inject.js';
36
+ import { classifyTerminal, isLikelyTrulyBlocked, renderWatchdogPrompt, parseWatchdogResponse, } from './watchdog.js';
37
+ import { readWatchdogTail, WATCHDOG_STALL_MS, WATCHDOG_COOLDOWN_MS, WATCHDOG_DORMANT_MS, WATCHDOG_TAIL_LINES, } from './read.js';
38
+ import { getRuntimeStateDir } from '../state.js';
39
+ export const DEFAULT_THRESHOLDS = {
40
+ stallMs: WATCHDOG_STALL_MS,
41
+ cooldownMs: WATCHDOG_COOLDOWN_MS,
42
+ dormantMs: WATCHDOG_DORMANT_MS,
43
+ };
44
+ /** The default nudge text — a short imperative, configurable via opts.nudgeText. */
45
+ export const DEFAULT_NUDGE_TEXT = 'Continue.';
46
+ // --- state persistence ------------------------------------------------------
47
+ function watchdogStateDir(opts) {
48
+ return opts.stateDir ?? path.join(getRuntimeStateDir(), 'watchdog');
49
+ }
50
+ function readJsonFile(file, fallback) {
51
+ try {
52
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
53
+ }
54
+ catch {
55
+ return fallback;
56
+ }
57
+ }
58
+ function writeJsonFile(file, value) {
59
+ try {
60
+ fs.mkdirSync(path.dirname(file), { recursive: true });
61
+ fs.writeFileSync(file, JSON.stringify(value, null, 2));
62
+ }
63
+ catch {
64
+ /* best-effort: the tray tolerates a missing/partial state file */
65
+ }
66
+ }
67
+ /** Last-nudge timestamps keyed by sessionId (the cooldown ledger). */
68
+ function readNudgeLedger(dir) {
69
+ return readJsonFile(path.join(dir, 'nudges.json'), {});
70
+ }
71
+ /**
72
+ * On-disk per-session policy sentinel: `<stateDir>/policy/<sessionId>` whose
73
+ * contents are `off` | `keep` | `handsoff`. Absent / unreadable / unknown → keep.
74
+ */
75
+ export function readPolicySentinel(dir, sessionId) {
76
+ if (!sessionId)
77
+ return 'keep';
78
+ let raw;
79
+ try {
80
+ raw = fs.readFileSync(path.join(dir, 'policy', sessionId), 'utf8').trim().toLowerCase();
81
+ }
82
+ catch {
83
+ return 'keep';
84
+ }
85
+ return raw === 'off' || raw === 'handsoff' ? raw : 'keep';
86
+ }
87
+ /** Write a per-session policy sentinel (used by the CLI `agents watchdog policy`). */
88
+ export function writePolicySentinel(dir, sessionId, policy) {
89
+ const file = path.join(dir, 'policy', sessionId);
90
+ fs.mkdirSync(path.dirname(file), { recursive: true });
91
+ fs.writeFileSync(file, policy + '\n');
92
+ }
93
+ // --- helpers ----------------------------------------------------------------
94
+ function defaultLastActivity(s) {
95
+ if (s.sessionFile) {
96
+ try {
97
+ return fs.statSync(s.sessionFile).mtimeMs;
98
+ }
99
+ catch {
100
+ /* file vanished */
101
+ }
102
+ }
103
+ return s.startedAtMs;
104
+ }
105
+ /**
106
+ * Minimal local mirror of COMPLETION_HINTS in watchdog.ts:42-47. We cannot lean
107
+ * on isLikelyTrulyBlocked to screen completions out: its FORCE_REVIEW_STALL_MS
108
+ * short-circuit (watchdog.ts:171 — `stalledForMs >= 15m` returns true) fires
109
+ * BEFORE its own COMPLETION_HINTS check (watchdog.ts:176), so a session idle
110
+ * 15m-60m whose tail says "done"/"finished" would be reported as blocked. Keep
111
+ * this list in sync with the core.
112
+ */
113
+ const COMPLETION_HINTS = ['done', 'completed', 'all set', 'finished'];
114
+ function tailShowsCompletion(candidate) {
115
+ if (candidate.tailLines.length === 0)
116
+ return false;
117
+ const lowerTail = candidate.tailLines.join('\n').toLowerCase();
118
+ return COMPLETION_HINTS.some((hint) => lowerTail.includes(hint));
119
+ }
120
+ function deterministicDecision(session, candidate) {
121
+ if (session.activity === 'waiting_input') {
122
+ return { nudge: false, reason: `waiting on user${session.awaitingReason ? ` (${session.awaitingReason})` : ''}` };
123
+ }
124
+ // Completion is checked EXPLICITLY here — never nudge a finished session, even
125
+ // past the 15m force-review window where isLikelyTrulyBlocked would return true
126
+ // before reaching its completion check (see COMPLETION_HINTS note above).
127
+ if (tailShowsCompletion(candidate)) {
128
+ return { nudge: false, reason: 'tail shows completion (done / finished / all set) — skip regardless of stall age' };
129
+ }
130
+ if (isLikelyTrulyBlocked(candidate)) {
131
+ return { nudge: true, reason: 'stalled after announcing an action with no follow-through' };
132
+ }
133
+ return { nudge: false, reason: 'no promise-without-toolcall signal in tail (completion / question / unclear)' };
134
+ }
135
+ /**
136
+ * The optional LLM decider. Shells out to `agents run <agent>` with the watchdog
137
+ * prompt and parses its JSON verdict. Best-effort and NON-deterministic — the
138
+ * default path is deterministicDecision. Isolated here so the tick stays pure
139
+ * over its seams; the command layer opts in with --smart.
140
+ */
141
+ async function smartDecision(candidate, agent) {
142
+ const prompt = renderWatchdogPrompt([candidate]);
143
+ try {
144
+ const { execFile } = await import('child_process');
145
+ const { promisify } = await import('util');
146
+ const execFileAsync = promisify(execFile);
147
+ const { stdout } = await execFileAsync('agents', ['run', agent, '--mode', 'plan', prompt], {
148
+ encoding: 'utf8',
149
+ maxBuffer: 4 * 1024 * 1024,
150
+ timeout: 120_000,
151
+ });
152
+ const decisions = parseWatchdogResponse(stdout);
153
+ const d = decisions.find((x) => x.terminalId === candidate.terminalId) ?? decisions[0];
154
+ if (!d)
155
+ return { nudge: false, reason: 'smart decider returned no verdict' };
156
+ return { nudge: d.action === 'nudge', reason: d.reason || `smart: ${d.action}`, text: d.text || undefined };
157
+ }
158
+ catch (err) {
159
+ return { nudge: false, reason: `smart decider unavailable: ${err instanceof Error ? err.message : String(err)}` };
160
+ }
161
+ }
162
+ // --- the tick ---------------------------------------------------------------
163
+ /**
164
+ * Run ONE watchdog pass. Returns a structured outcome per live session; injects
165
+ * only when `opts.nudge` is set AND the safety gate says addressable AND policy
166
+ * permits. Persists the cooldown ledger, un-addressable flags, and a last-tick
167
+ * snapshot to the tray-readable state dir.
168
+ */
169
+ export async function runWatchdogTick(opts = {}) {
170
+ const nowMs = opts.nowMs ?? Date.now();
171
+ const nudgeText = opts.nudgeText ?? DEFAULT_NUDGE_TEXT;
172
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...opts.thresholds };
173
+ const dir = watchdogStateDir(opts);
174
+ const lastActivityFor = opts.lastActivityFor ?? defaultLastActivity;
175
+ const tailFor = opts.tailFor ?? ((s) => (s.sessionId ? readWatchdogTail(s.sessionId, s.kind, WATCHDOG_TAIL_LINES) : []));
176
+ const policyFor = opts.policyFor ?? ((s) => (s.sessionId ? readPolicySentinel(dir, s.sessionId) : 'keep'));
177
+ const sessions = opts.sessions ?? (await getActiveSessions());
178
+ const ledger = readNudgeLedger(dir);
179
+ const flags = {};
180
+ const outcomes = [];
181
+ for (const session of sessions) {
182
+ const policy = policyFor(session);
183
+ const base = {
184
+ sessionId: session.sessionId,
185
+ kind: session.kind,
186
+ host: session.host,
187
+ cwd: session.cwd,
188
+ label: session.label,
189
+ policy,
190
+ stall: 'active',
191
+ decision: 'skip',
192
+ reason: '',
193
+ nudgeText,
194
+ };
195
+ // A session with no id can neither be addressed nor cooldown-tracked.
196
+ if (!session.sessionId) {
197
+ outcomes.push({ ...base, reason: 'no session id (cannot address or track)' });
198
+ continue;
199
+ }
200
+ // `off` = fully opted out. We short-circuit here rather than relying on
201
+ // classifyTerminal (which is always called with optedOut: false below), so the
202
+ // policy reason is reported explicitly.
203
+ if (policy === 'off') {
204
+ outcomes.push({ ...base, stall: 'opted_out', reason: 'policy: off (opted out)' });
205
+ continue;
206
+ }
207
+ const lastActivityMs = lastActivityFor(session);
208
+ if (lastActivityMs === undefined) {
209
+ outcomes.push({ ...base, reason: 'no activity timestamp (no transcript / start time)' });
210
+ continue;
211
+ }
212
+ const status = classifyTerminal({
213
+ lastActivityMs,
214
+ nowMs,
215
+ lastNudgeMs: ledger[session.sessionId] ?? null,
216
+ optedOut: false,
217
+ stallMs: thresholds.stallMs,
218
+ cooldownMs: thresholds.cooldownMs,
219
+ dormantMs: thresholds.dormantMs,
220
+ });
221
+ base.stall = status.kind;
222
+ if (status.kind !== 'stalled') {
223
+ const reason = status.kind === 'active' ? `active (last activity ${Math.round((nowMs - lastActivityMs) / 1000)}s ago)`
224
+ : status.kind === 'dormant' ? 'dormant (idle past the dormant window)'
225
+ : status.kind === 'rate_limited' ? `cooling down (${Math.round(status.cooldownRemainingMs / 1000)}s left)`
226
+ : 'opted out';
227
+ outcomes.push({ ...base, reason });
228
+ continue;
229
+ }
230
+ base.stalledForMs = status.stalledForMs;
231
+ // Stalled — read the tail and decide.
232
+ const tailLines = tailFor(session);
233
+ const candidate = {
234
+ terminalId: session.sessionId,
235
+ agentType: (session.kind === 'codex' || session.kind === 'gemini' ? session.kind : 'claude'),
236
+ tailLines,
237
+ stalledForMs: status.stalledForMs,
238
+ };
239
+ const decision = opts.smart
240
+ ? await smartDecision(candidate, opts.smartAgent ?? 'claude')
241
+ : deterministicDecision(session, candidate);
242
+ const chosenText = decision.text ?? nudgeText;
243
+ if (!decision.nudge) {
244
+ outcomes.push({ ...base, decision: 'skip', reason: decision.reason });
245
+ continue;
246
+ }
247
+ // A nudge is warranted — run it past the safety gate BEFORE any delivery.
248
+ const resolution = resolveInjectTargetForSession(session, {
249
+ allowGhosttyFocus: opts.allowGhosttyFocus,
250
+ });
251
+ if (!resolution.addressable) {
252
+ // Flag the un-addressable stall for the tray; NEVER guess a target.
253
+ flags[session.sessionId] = { reason: resolution.reason, host: session.host, atMs: nowMs };
254
+ outcomes.push({
255
+ ...base, decision: 'skip', addressable: false,
256
+ reason: `nudge-worthy but un-addressable — ${resolution.reason}`,
257
+ nudgeText: chosenText,
258
+ });
259
+ continue;
260
+ }
261
+ // handsoff = detect + flag, but never inject. Record a flag so the tray can
262
+ // surface "would-nudge but hands-off" alongside the un-addressable flags.
263
+ if (policy === 'handsoff') {
264
+ flags[session.sessionId] = {
265
+ reason: `handsoff: would nudge via ${resolution.rail} but policy is hands-off`,
266
+ host: session.host,
267
+ atMs: nowMs,
268
+ };
269
+ outcomes.push({
270
+ ...base, decision: 'nudge', addressable: true, rail: resolution.rail, injected: false,
271
+ reason: `handsoff: flagged, not injected (would nudge via ${resolution.rail})`,
272
+ nudgeText: chosenText,
273
+ });
274
+ continue;
275
+ }
276
+ // Dry status (no --nudge): report what WOULD happen, deliver nothing.
277
+ if (!opts.nudge) {
278
+ outcomes.push({
279
+ ...base, decision: 'nudge', addressable: true, rail: resolution.rail, injected: false,
280
+ reason: `would nudge via ${resolution.rail} (dry — pass --nudge to inject)`,
281
+ nudgeText: chosenText,
282
+ });
283
+ continue;
284
+ }
285
+ // Deliver into the EXACT resolved split.
286
+ let result;
287
+ try {
288
+ result = await injectIntoTerminal(resolution.target, chosenText, { dryRun: opts.injectDryRun });
289
+ }
290
+ catch (err) {
291
+ result = { ok: false, backend: resolution.target.backend, writes: 0, error: err instanceof Error ? err.message : String(err) };
292
+ }
293
+ if (result.ok) {
294
+ ledger[session.sessionId] = nowMs; // start the cooldown clock
295
+ outcomes.push({
296
+ ...base, decision: 'nudge', addressable: true, rail: resolution.rail, injected: true,
297
+ reason: `nudged via ${resolution.rail}`,
298
+ nudgeText: chosenText,
299
+ });
300
+ }
301
+ else {
302
+ outcomes.push({
303
+ ...base, decision: 'skip', addressable: true, rail: resolution.rail, injected: false,
304
+ reason: `inject failed via ${resolution.rail}: ${result.error ?? 'unknown error'}`,
305
+ nudgeText: chosenText,
306
+ });
307
+ }
308
+ }
309
+ // Persist the tray-readable state.
310
+ writeJsonFile(path.join(dir, 'nudges.json'), ledger);
311
+ writeJsonFile(path.join(dir, 'flags.json'), flags);
312
+ const counts = {
313
+ total: outcomes.length,
314
+ stalled: outcomes.filter((o) => o.stall === 'stalled').length,
315
+ nudged: outcomes.filter((o) => o.injected).length,
316
+ unaddressable: outcomes.filter((o) => o.addressable === false).length,
317
+ skipped: outcomes.filter((o) => o.decision === 'skip').length,
318
+ };
319
+ const result = { atMs: nowMs, didNudge: opts.nudge === true, outcomes, counts };
320
+ writeJsonFile(path.join(dir, 'last-tick.json'), result);
321
+ return result;
322
+ }
@@ -0,0 +1,40 @@
1
+ export interface WatchdogCandidate {
2
+ terminalId: string;
3
+ agentType: 'claude' | 'codex' | 'gemini';
4
+ tailLines: string[];
5
+ stalledForMs: number;
6
+ }
7
+ export interface Decision {
8
+ terminalId: string;
9
+ action: 'nudge' | 'skip';
10
+ text: string;
11
+ reason: string;
12
+ }
13
+ export type StallStatus = {
14
+ kind: 'active';
15
+ } | {
16
+ kind: 'dormant';
17
+ } | {
18
+ kind: 'opted_out';
19
+ } | {
20
+ kind: 'rate_limited';
21
+ cooldownRemainingMs: number;
22
+ } | {
23
+ kind: 'stalled';
24
+ stalledForMs: number;
25
+ };
26
+ export interface ClassifyInput {
27
+ lastActivityMs: number;
28
+ nowMs: number;
29
+ lastNudgeMs: number | null;
30
+ optedOut: boolean;
31
+ stallMs: number;
32
+ cooldownMs: number;
33
+ dormantMs: number;
34
+ }
35
+ export declare function classifyTerminal(input: ClassifyInput): StallStatus;
36
+ export declare const WATCHDOG_SYSTEM_PROMPT = "You are a watchdog monitoring AI coding agents that run in terminals.\nFor each stalled terminal below, decide: NUDGE (send a short message to unstick it) or SKIP.\n\nNudge when:\n- The last assistant turn announced an action (\"I'll write X\", \"let me run Y\")\n but no matching tool call followed.\n- The agent appears stuck mid-task with no recent progress and the task is incomplete.\n\nSkip when:\n- The agent asked the user a direct question (waiting on human input).\n- The task looks complete.\n- You cannot tell what the agent is doing.\n\nNudge text must be:\n- One sentence, imperative (\"Show me the file.\", \"Run the tests.\").\n- No emojis. No apologies. Under 120 characters.\n\nRespond with ONLY a JSON array (no prose, no code fence):\n[{\"terminalId\":\"<id>\",\"action\":\"nudge\"|\"skip\",\"text\":\"<message or empty>\",\"reason\":\"<brief>\"}]";
37
+ export declare function composePromptWithPlaybook(basePrompt: string, playbook: string): string;
38
+ export declare function renderWatchdogPrompt(candidates: WatchdogCandidate[], playbook?: string): string;
39
+ export declare function parseWatchdogResponse(stdout: string): Decision[];
40
+ export declare function isLikelyTrulyBlocked(candidate: WatchdogCandidate): boolean;
@@ -0,0 +1,166 @@
1
+ // Watchdog: pure logic for detecting stalled agent terminals and rendering
2
+ // prompts to a headless decider instance that decides whether to nudge them.
3
+ // Ported from Swarmify (extension/src/core/watchdog.ts) — behavior verbatim.
4
+ // Terminal/session delivery lives elsewhere; this module reads no files and
5
+ // touches no host APIs so it can be unit-tested in isolation.
6
+ const FORCE_REVIEW_STALL_MS = 15 * 60 * 1000;
7
+ const BLOCKED_HINTS = [
8
+ 'blocked',
9
+ 'stuck',
10
+ "can't",
11
+ 'cannot',
12
+ 'unable',
13
+ 'failed',
14
+ 'error',
15
+ 'exception',
16
+ 'traceback',
17
+ 'timed out',
18
+ 'timeout',
19
+ 'rate limit',
20
+ 'permission denied',
21
+ ];
22
+ const WAITING_HINTS = [
23
+ 'waiting on user',
24
+ 'awaiting user',
25
+ 'askuserquestion',
26
+ ];
27
+ const COMPLETION_HINTS = [
28
+ 'done',
29
+ 'completed',
30
+ 'all set',
31
+ 'finished',
32
+ ];
33
+ const TOOL_CALL_HINTS = [
34
+ '"type":"tool_use"',
35
+ '"type":"tool_call"',
36
+ '"type":"function_call"',
37
+ ];
38
+ const ASSISTANT_LINE_HINTS = [
39
+ '"type":"assistant"',
40
+ '"role":"assistant"',
41
+ '"payload":{"type":"message"',
42
+ ];
43
+ const PROMISE_HINTS = [
44
+ "i'll",
45
+ 'i will',
46
+ 'let me',
47
+ 'going to',
48
+ "next i'll",
49
+ 'next i will',
50
+ ];
51
+ export function classifyTerminal(input) {
52
+ if (input.optedOut)
53
+ return { kind: 'opted_out' };
54
+ const age = input.nowMs - input.lastActivityMs;
55
+ if (age < input.stallMs)
56
+ return { kind: 'active' };
57
+ if (age > input.dormantMs)
58
+ return { kind: 'dormant' };
59
+ if (input.lastNudgeMs !== null) {
60
+ const sinceNudge = input.nowMs - input.lastNudgeMs;
61
+ if (sinceNudge < input.cooldownMs) {
62
+ return { kind: 'rate_limited', cooldownRemainingMs: input.cooldownMs - sinceNudge };
63
+ }
64
+ }
65
+ return { kind: 'stalled', stalledForMs: age };
66
+ }
67
+ export const WATCHDOG_SYSTEM_PROMPT = `You are a watchdog monitoring AI coding agents that run in terminals.
68
+ For each stalled terminal below, decide: NUDGE (send a short message to unstick it) or SKIP.
69
+
70
+ Nudge when:
71
+ - The last assistant turn announced an action ("I'll write X", "let me run Y")
72
+ but no matching tool call followed.
73
+ - The agent appears stuck mid-task with no recent progress and the task is incomplete.
74
+
75
+ Skip when:
76
+ - The agent asked the user a direct question (waiting on human input).
77
+ - The task looks complete.
78
+ - You cannot tell what the agent is doing.
79
+
80
+ Nudge text must be:
81
+ - One sentence, imperative ("Show me the file.", "Run the tests.").
82
+ - No emojis. No apologies. Under 120 characters.
83
+
84
+ Respond with ONLY a JSON array (no prose, no code fence):
85
+ [{"terminalId":"<id>","action":"nudge"|"skip","text":"<message or empty>","reason":"<brief>"}]`;
86
+ // User-editable playbook appended below the built-in prompt. The user maintains
87
+ // the source at ~/.agents/playbooks/watchdog.md (read by the delivery layer);
88
+ // this function is pure so it can be tested without filesystem access.
89
+ export function composePromptWithPlaybook(basePrompt, playbook) {
90
+ const trimmed = playbook.trim();
91
+ if (!trimmed)
92
+ return basePrompt;
93
+ return `${basePrompt}\n\n## House Rules (user playbook)\n\n${trimmed}`;
94
+ }
95
+ export function renderWatchdogPrompt(candidates, playbook = '') {
96
+ const systemPrompt = composePromptWithPlaybook(WATCHDOG_SYSTEM_PROMPT, playbook);
97
+ const parts = [systemPrompt, '', 'STALLED TERMINALS:', ''];
98
+ for (const c of candidates) {
99
+ const seconds = Math.round(c.stalledForMs / 1000);
100
+ parts.push(`--- terminal ${c.terminalId} (${c.agentType}, idle ${seconds}s) ---`);
101
+ parts.push('last JSONL lines:');
102
+ for (const line of c.tailLines) {
103
+ parts.push(line);
104
+ }
105
+ parts.push('');
106
+ }
107
+ return parts.join('\n');
108
+ }
109
+ export function parseWatchdogResponse(stdout) {
110
+ if (!stdout || !stdout.trim())
111
+ return [];
112
+ const arrayMatch = stdout.match(/\[[\s\S]*\]/);
113
+ if (!arrayMatch)
114
+ return [];
115
+ let parsed;
116
+ try {
117
+ parsed = JSON.parse(arrayMatch[0]);
118
+ }
119
+ catch {
120
+ return [];
121
+ }
122
+ if (!Array.isArray(parsed))
123
+ return [];
124
+ const decisions = [];
125
+ for (const d of parsed) {
126
+ if (!d || typeof d !== 'object')
127
+ continue;
128
+ const obj = d;
129
+ const terminalId = typeof obj.terminalId === 'string' ? obj.terminalId : '';
130
+ const action = obj.action === 'nudge' || obj.action === 'skip' ? obj.action : null;
131
+ const text = typeof obj.text === 'string' ? obj.text : '';
132
+ const reason = typeof obj.reason === 'string' ? obj.reason : '';
133
+ if (!terminalId || !action)
134
+ continue;
135
+ decisions.push({ terminalId, action, text, reason });
136
+ }
137
+ return decisions;
138
+ }
139
+ export function isLikelyTrulyBlocked(candidate) {
140
+ if (candidate.stalledForMs >= FORCE_REVIEW_STALL_MS)
141
+ return true;
142
+ if (candidate.tailLines.length === 0)
143
+ return false;
144
+ const lowerTail = candidate.tailLines.join('\n').toLowerCase();
145
+ if (WAITING_HINTS.some((hint) => lowerTail.includes(hint)))
146
+ return false;
147
+ if (COMPLETION_HINTS.some((hint) => lowerTail.includes(hint)))
148
+ return false;
149
+ if (BLOCKED_HINTS.some((hint) => lowerTail.includes(hint)))
150
+ return true;
151
+ let sawToolAfter = false;
152
+ for (let i = candidate.tailLines.length - 1; i >= 0; i--) {
153
+ const line = candidate.tailLines[i].toLowerCase();
154
+ if (TOOL_CALL_HINTS.some((hint) => line.includes(hint))) {
155
+ sawToolAfter = true;
156
+ continue;
157
+ }
158
+ if (!sawToolAfter) {
159
+ const isAssistantLine = ASSISTANT_LINE_HINTS.some((hint) => line.includes(hint));
160
+ const hasPromise = PROMISE_HINTS.some((hint) => line.includes(hint));
161
+ if (isAssistantLine && hasPromise)
162
+ return true;
163
+ }
164
+ }
165
+ return false;
166
+ }
@@ -0,0 +1,5 @@
1
+ export interface TailSummary {
2
+ lastUserMessage?: string;
3
+ lastAssistantMessage?: string;
4
+ }
5
+ export declare function summarizeWatchdogTail(tailLines: string[], agentType: string | undefined): TailSummary;