@phnx-labs/agents-cli 1.20.35 → 1.20.37

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 (236) 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-sync.d.ts +3 -0
  34. package/dist/commands/sessions-sync.js +44 -4
  35. package/dist/commands/sessions.d.ts +78 -1
  36. package/dist/commands/sessions.js +506 -66
  37. package/dist/commands/setup.js +4 -2
  38. package/dist/commands/sync.d.ts +3 -1
  39. package/dist/commands/sync.js +156 -4
  40. package/dist/commands/teams.js +217 -0
  41. package/dist/commands/versions.js +2 -4
  42. package/dist/commands/watchdog.d.ts +18 -0
  43. package/dist/commands/watchdog.js +238 -0
  44. package/dist/index.js +25 -2
  45. package/dist/lib/audit/log.d.ts +92 -0
  46. package/dist/lib/audit/log.js +177 -0
  47. package/dist/lib/auto-pull.js +2 -1
  48. package/dist/lib/browser/chrome.d.ts +10 -0
  49. package/dist/lib/browser/chrome.js +18 -7
  50. package/dist/lib/browser/drivers/ssh.js +2 -1
  51. package/dist/lib/browser/har.d.ts +84 -0
  52. package/dist/lib/browser/har.js +77 -0
  53. package/dist/lib/browser/ipc.js +24 -3
  54. package/dist/lib/browser/profiles.d.ts +1 -1
  55. package/dist/lib/browser/profiles.js +8 -10
  56. package/dist/lib/browser/refs.d.ts +65 -0
  57. package/dist/lib/browser/refs.js +73 -1
  58. package/dist/lib/browser/runtime-state.js +1 -0
  59. package/dist/lib/browser/service.d.ts +38 -2
  60. package/dist/lib/browser/service.js +112 -8
  61. package/dist/lib/browser/types.d.ts +14 -1
  62. package/dist/lib/budget/live-cloud.d.ts +42 -0
  63. package/dist/lib/budget/live-cloud.js +79 -0
  64. package/dist/lib/budget/live-team.d.ts +31 -0
  65. package/dist/lib/budget/live-team.js +115 -0
  66. package/dist/lib/cloud/codex.js +4 -0
  67. package/dist/lib/cloud/rush.d.ts +12 -1
  68. package/dist/lib/cloud/rush.js +13 -3
  69. package/dist/lib/cloud/types.d.ts +9 -0
  70. package/dist/lib/computer/dispatch.d.ts +8 -0
  71. package/dist/lib/computer/dispatch.js +125 -0
  72. package/dist/lib/computer/loop.d.ts +62 -0
  73. package/dist/lib/computer/loop.js +98 -0
  74. package/dist/lib/computer/model.d.ts +44 -0
  75. package/dist/lib/computer/model.js +157 -0
  76. package/dist/lib/concurrency.d.ts +19 -0
  77. package/dist/lib/concurrency.js +33 -0
  78. package/dist/lib/daemon.d.ts +57 -0
  79. package/dist/lib/daemon.js +196 -18
  80. package/dist/lib/devices/registry.d.ts +7 -0
  81. package/dist/lib/devices/registry.js +24 -0
  82. package/dist/lib/devices/resolve-target.d.ts +24 -0
  83. package/dist/lib/devices/resolve-target.js +80 -0
  84. package/dist/lib/devices/tailscale.js +1 -1
  85. package/dist/lib/drift.d.ts +52 -0
  86. package/dist/lib/drift.js +112 -0
  87. package/dist/lib/events.d.ts +1 -1
  88. package/dist/lib/events.js +31 -13
  89. package/dist/lib/exec.d.ts +17 -0
  90. package/dist/lib/exec.js +79 -13
  91. package/dist/lib/git.d.ts +27 -0
  92. package/dist/lib/git.js +56 -1
  93. package/dist/lib/hooks/cache.d.ts +6 -0
  94. package/dist/lib/hooks/cache.js +54 -12
  95. package/dist/lib/hooks.d.ts +27 -0
  96. package/dist/lib/hooks.js +127 -8
  97. package/dist/lib/hosts/dispatch.d.ts +15 -0
  98. package/dist/lib/hosts/dispatch.js +39 -6
  99. package/dist/lib/hosts/logs.js +30 -1
  100. package/dist/lib/hosts/option.js +1 -1
  101. package/dist/lib/hosts/passthrough.js +3 -1
  102. package/dist/lib/hosts/ready.d.ts +29 -6
  103. package/dist/lib/hosts/ready.js +66 -15
  104. package/dist/lib/hosts/registry.d.ts +19 -2
  105. package/dist/lib/hosts/registry.js +58 -2
  106. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  107. package/dist/lib/hosts/remote-cmd.js +70 -1
  108. package/dist/lib/hosts/remote-os.d.ts +17 -0
  109. package/dist/lib/hosts/remote-os.js +30 -0
  110. package/dist/lib/hosts/session-index.d.ts +34 -0
  111. package/dist/lib/hosts/session-index.js +56 -0
  112. package/dist/lib/hosts/tasks.d.ts +14 -0
  113. package/dist/lib/hosts/tasks.js +15 -0
  114. package/dist/lib/lock.d.ts +93 -0
  115. package/dist/lib/lock.js +207 -0
  116. package/dist/lib/loop.js +16 -1
  117. package/dist/lib/machine-id.d.ts +21 -0
  118. package/dist/lib/machine-id.js +26 -0
  119. package/dist/lib/mailbox-target.d.ts +36 -0
  120. package/dist/lib/mailbox-target.js +45 -0
  121. package/dist/lib/mailbox.d.ts +47 -0
  122. package/dist/lib/mailbox.js +194 -0
  123. package/dist/lib/mcp.d.ts +5 -0
  124. package/dist/lib/mcp.js +24 -8
  125. package/dist/lib/migrate.d.ts +19 -0
  126. package/dist/lib/migrate.js +134 -26
  127. package/dist/lib/overdue.js +3 -0
  128. package/dist/lib/picker.d.ts +2 -0
  129. package/dist/lib/picker.js +4 -1
  130. package/dist/lib/platform/exec.d.ts +46 -0
  131. package/dist/lib/platform/exec.js +74 -0
  132. package/dist/lib/platform/process.d.ts +31 -0
  133. package/dist/lib/platform/process.js +34 -1
  134. package/dist/lib/platform/winpath.js +2 -0
  135. package/dist/lib/plugins.js +16 -6
  136. package/dist/lib/profiles.d.ts +25 -0
  137. package/dist/lib/profiles.js +22 -6
  138. package/dist/lib/pty-client.js +2 -1
  139. package/dist/lib/rotate.d.ts +61 -0
  140. package/dist/lib/rotate.js +52 -0
  141. package/dist/lib/routines.d.ts +40 -2
  142. package/dist/lib/routines.js +66 -8
  143. package/dist/lib/runner.d.ts +11 -2
  144. package/dist/lib/runner.js +49 -7
  145. package/dist/lib/scheduler.js +6 -1
  146. package/dist/lib/secrets/bundles.d.ts +60 -4
  147. package/dist/lib/secrets/bundles.js +131 -12
  148. package/dist/lib/secrets/filestore.d.ts +3 -0
  149. package/dist/lib/secrets/filestore.js +42 -16
  150. package/dist/lib/secrets/index.d.ts +43 -2
  151. package/dist/lib/secrets/index.js +102 -3
  152. package/dist/lib/secrets/mcp.d.ts +93 -0
  153. package/dist/lib/secrets/mcp.js +205 -0
  154. package/dist/lib/secrets/remote.js +12 -5
  155. package/dist/lib/secrets/sync.js +83 -4
  156. package/dist/lib/secrets/windows.js +14 -3
  157. package/dist/lib/serve/data.d.ts +81 -0
  158. package/dist/lib/serve/data.js +91 -0
  159. package/dist/lib/serve/page.d.ts +7 -0
  160. package/dist/lib/serve/page.js +140 -0
  161. package/dist/lib/serve/server.d.ts +46 -0
  162. package/dist/lib/serve/server.js +115 -0
  163. package/dist/lib/session/active.d.ts +73 -0
  164. package/dist/lib/session/active.js +199 -22
  165. package/dist/lib/session/db.d.ts +2 -1
  166. package/dist/lib/session/db.js +41 -5
  167. package/dist/lib/session/discover.d.ts +39 -0
  168. package/dist/lib/session/discover.js +127 -29
  169. package/dist/lib/session/ghostty-tabs.d.ts +33 -0
  170. package/dist/lib/session/ghostty-tabs.js +126 -0
  171. package/dist/lib/session/inject.d.ts +18 -0
  172. package/dist/lib/session/inject.js +21 -0
  173. package/dist/lib/session/parse.js +23 -20
  174. package/dist/lib/session/pid-registry.d.ts +1 -0
  175. package/dist/lib/session/pid-registry.js +24 -0
  176. package/dist/lib/session/provenance.d.ts +14 -2
  177. package/dist/lib/session/provenance.js +39 -8
  178. package/dist/lib/session/relative-time.js +6 -2
  179. package/dist/lib/session/remote-active.js +19 -17
  180. package/dist/lib/session/remote-list.d.ts +51 -0
  181. package/dist/lib/session/remote-list.js +205 -0
  182. package/dist/lib/session/remote.d.ts +7 -1
  183. package/dist/lib/session/remote.js +20 -4
  184. package/dist/lib/session/sync/config.d.ts +14 -15
  185. package/dist/lib/session/sync/config.js +60 -20
  186. package/dist/lib/session/types.d.ts +23 -0
  187. package/dist/lib/shims.d.ts +36 -6
  188. package/dist/lib/shims.js +91 -29
  189. package/dist/lib/ssh-exec.js +2 -0
  190. package/dist/lib/ssh-tunnel.js +2 -1
  191. package/dist/lib/startup/command-registry.d.ts +6 -0
  192. package/dist/lib/startup/command-registry.js +13 -1
  193. package/dist/lib/state.d.ts +13 -0
  194. package/dist/lib/state.js +103 -9
  195. package/dist/lib/sync-umbrella.d.ts +14 -7
  196. package/dist/lib/sync-umbrella.js +21 -13
  197. package/dist/lib/teams/forEach.d.ts +110 -0
  198. package/dist/lib/teams/forEach.js +186 -0
  199. package/dist/lib/teams/index.d.ts +1 -0
  200. package/dist/lib/teams/index.js +1 -0
  201. package/dist/lib/teams/pr-watch.d.ts +226 -0
  202. package/dist/lib/teams/pr-watch.js +371 -0
  203. package/dist/lib/teams/supervisor.d.ts +14 -1
  204. package/dist/lib/teams/supervisor.js +19 -0
  205. package/dist/lib/teams/worktree.d.ts +9 -0
  206. package/dist/lib/teams/worktree.js +32 -0
  207. package/dist/lib/terminal/backends/index.d.ts +2 -1
  208. package/dist/lib/terminal/backends/index.js +3 -1
  209. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  210. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  211. package/dist/lib/terminal/index.d.ts +4 -1
  212. package/dist/lib/terminal/index.js +4 -1
  213. package/dist/lib/terminal/inject.d.ts +204 -0
  214. package/dist/lib/terminal/inject.js +247 -0
  215. package/dist/lib/terminal/resolve.d.ts +64 -0
  216. package/dist/lib/terminal/resolve.js +90 -0
  217. package/dist/lib/terminal/types.d.ts +1 -1
  218. package/dist/lib/tmux/session.d.ts +10 -0
  219. package/dist/lib/tmux/session.js +31 -0
  220. package/dist/lib/triggers/webhook.d.ts +85 -0
  221. package/dist/lib/triggers/webhook.js +141 -0
  222. package/dist/lib/versions.d.ts +23 -0
  223. package/dist/lib/versions.js +119 -13
  224. package/dist/lib/watchdog/index.d.ts +3 -0
  225. package/dist/lib/watchdog/index.js +5 -0
  226. package/dist/lib/watchdog/read.d.ts +35 -0
  227. package/dist/lib/watchdog/read.js +149 -0
  228. package/dist/lib/watchdog/runner.d.ts +127 -0
  229. package/dist/lib/watchdog/runner.js +322 -0
  230. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  231. package/dist/lib/watchdog/watchdog.js +166 -0
  232. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  233. package/dist/lib/watchdog/watchdogTail.js +154 -0
  234. package/dist/lib/workflows.d.ts +166 -0
  235. package/dist/lib/workflows.js +193 -0
  236. package/package.json +5 -4
@@ -0,0 +1,149 @@
1
+ // Watchdog tail reader: locate a session transcript from its id + agent and
2
+ // pull the last N raw JSONL lines. The raw lines feed the pure detectors in
3
+ // watchdog.ts (isLikelyTrulyBlocked / renderWatchdogPrompt) and the pure
4
+ // summarizer in watchdogTail.ts.
5
+ //
6
+ // Adapted from Swarmify's readTailLines (extension/src/vscode/sessions.vscode.ts)
7
+ // to agents-cli's session layout. Path resolution REUSES getAgentSessionDirs()
8
+ // from src/lib/session/discover.ts — the same resolver the rest of the CLI uses
9
+ // to enumerate per-version transcript roots — instead of hardcoding
10
+ // ~/.agents/.history/versions/<agent>/.../projects/<enc>/<sessionId>.jsonl.
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { getAgentSessionDirs } from '../session/discover.js';
14
+ import { walkForFiles } from '../fs-walk.js';
15
+ /** Default watchdog thresholds, mirrored from the Swarmify VS Code runtime. */
16
+ export const WATCHDOG_TAIL_LINES = 20;
17
+ export const WATCHDOG_STALL_MS = 300_000; // 5m — stallSeconds default
18
+ export const WATCHDOG_COOLDOWN_MS = 1_200_000; // 20m — cooldownSeconds default
19
+ export const WATCHDOG_DORMANT_MS = 3_600_000; // 1h — DORMANT_MS
20
+ const CHUNK_SIZE = 64 * 1024;
21
+ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
22
+ /** Cap on files walked per transcript root — matches session/discover.ts. */
23
+ const WATCHDOG_WALK_CAP = 100_000;
24
+ /**
25
+ * Per-agent transcript layout, mirroring the `getAgentSessionDirs()` call sites
26
+ * in session/discover.ts so the resolver never diverges from the rest of the CLI:
27
+ * Claude keeps per-project folders under `projects/` (discover.ts:486); Codex and
28
+ * Droid date-/work-partition rollout `.jsonl` deep under `sessions/`
29
+ * (discover.ts:665, :1673); Gemini nests chat `.json` under `tmp/<hash>/chats/`
30
+ * (discover.ts:820). Subdir + extension are driven from this table instead of the
31
+ * old hardcoded `codex ? 'sessions' : 'projects'` ternary, which sent every
32
+ * non-Codex agent (Gemini included) to the wrong root.
33
+ */
34
+ const WATCHDOG_SESSION_LAYOUT = {
35
+ claude: { subdir: 'projects', ext: '.jsonl' },
36
+ codex: { subdir: 'sessions', ext: '.jsonl' },
37
+ droid: { subdir: 'sessions', ext: '.jsonl' },
38
+ gemini: { subdir: 'tmp', ext: '.json' },
39
+ };
40
+ const WATCHDOG_SESSION_LAYOUT_DEFAULT = { subdir: 'projects', ext: '.jsonl' };
41
+ /**
42
+ * Read the last `maxLines` non-empty lines of a JSONL transcript by seeking
43
+ * backward from EOF in 64KB chunks. A tail that begins mid-line yields one
44
+ * malformed leading line, which the callers' per-line JSON try/catch tolerates.
45
+ * Returns `[]` on any read error or empty file.
46
+ */
47
+ export function readTailLines(filePath, maxLines) {
48
+ let fd;
49
+ try {
50
+ fd = fs.openSync(filePath, 'r');
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ try {
56
+ const fileSize = fs.fstatSync(fd).size;
57
+ if (fileSize === 0)
58
+ return [];
59
+ let position = fileSize;
60
+ let buffer = '';
61
+ let collected = [];
62
+ while (position > 0 && collected.length <= maxLines) {
63
+ const readSize = Math.min(CHUNK_SIZE, position);
64
+ position -= readSize;
65
+ const chunk = Buffer.alloc(readSize);
66
+ fs.readSync(fd, chunk, 0, readSize, position);
67
+ buffer = chunk.toString('utf-8') + buffer;
68
+ collected = buffer.split(/\r?\n/).filter((l) => l.trim());
69
+ }
70
+ return collected.slice(-maxLines);
71
+ }
72
+ catch {
73
+ return [];
74
+ }
75
+ finally {
76
+ try {
77
+ fs.closeSync(fd);
78
+ }
79
+ catch {
80
+ /* fd already gone */
81
+ }
82
+ }
83
+ }
84
+ /**
85
+ * Given candidate transcript-root directories, find the transcript file for a
86
+ * session. Handles the flat Claude layout (`<sessionId>.jsonl` inside a
87
+ * per-project `<enc>/` subfolder), Codex-style names that merely embed the uuid
88
+ * (`rollout-…-<sessionId>.jsonl`), and — critically — the DEEP date partitions
89
+ * Codex/Droid use (`sessions/YYYY/MM/DD/rollout-…-<uuid>.jsonl`). The scan is
90
+ * fully recursive via `walkForFiles` (the same walker session/discover.ts uses),
91
+ * so no fixed number of subdir levels is assumed — a hand-rolled one-level scan
92
+ * silently missed every Codex transcript. Newest mtime wins when a uuid appears
93
+ * in more than one root (e.g. across version homes). Pure over its `dirs`
94
+ * argument, so it is testable without touching the real home directory.
95
+ */
96
+ export function findSessionJsonlIn(dirs, sessionId, ext = '.jsonl') {
97
+ if (!sessionId)
98
+ return undefined;
99
+ const matches = (name) => {
100
+ if (!name.endsWith(ext))
101
+ return false;
102
+ const stem = name.slice(0, -ext.length);
103
+ if (stem === sessionId)
104
+ return true;
105
+ // Codex embeds the uuid in a longer filename (rollout-<ts>-<uuid>.jsonl).
106
+ return name.includes(sessionId) || (UUID_RE.test(sessionId) && stem.includes(sessionId));
107
+ };
108
+ let best;
109
+ for (const dir of dirs) {
110
+ for (const file of walkForFiles(dir, ext, WATCHDOG_WALK_CAP)) {
111
+ if (!matches(path.basename(file)))
112
+ continue;
113
+ let mtime;
114
+ try {
115
+ mtime = fs.statSync(file).mtimeMs;
116
+ }
117
+ catch {
118
+ continue;
119
+ }
120
+ if (!best || mtime > best.mtime)
121
+ best = { file, mtime };
122
+ }
123
+ }
124
+ return best?.file;
125
+ }
126
+ /**
127
+ * Resolve a session transcript path from its id + agent, reusing the CLI's
128
+ * per-version transcript roots. Returns `undefined` if no transcript is found.
129
+ */
130
+ export function resolveWatchdogSessionPath(sessionId, agent) {
131
+ // Subdir + transcript extension are driven per-agent from WATCHDOG_SESSION_LAYOUT
132
+ // (which mirrors the getAgentSessionDirs() convention in session/discover.ts),
133
+ // not a hardcoded `codex ? 'sessions' : 'projects'` — that ternary sent Codex to
134
+ // a root it only scanned one level deep, and every other agent (Gemini included)
135
+ // to the wrong subdir entirely.
136
+ const layout = WATCHDOG_SESSION_LAYOUT[agent] ?? WATCHDOG_SESSION_LAYOUT_DEFAULT;
137
+ const dirs = getAgentSessionDirs(agent, layout.subdir);
138
+ return findSessionJsonlIn(dirs, sessionId, layout.ext);
139
+ }
140
+ /**
141
+ * Read the last `maxLines` JSONL lines for a session by id + agent. Returns
142
+ * `[]` when the transcript cannot be located or read.
143
+ */
144
+ export function readWatchdogTail(sessionId, agent, maxLines = WATCHDOG_TAIL_LINES) {
145
+ const filePath = resolveWatchdogSessionPath(sessionId, agent);
146
+ if (!filePath)
147
+ return [];
148
+ return readTailLines(filePath, maxLines);
149
+ }
@@ -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;