@phnx-labs/agents-cli 1.22.29 → 1.22.30

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 (73) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +39 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/accounts.d.ts +13 -0
  5. package/dist/commands/accounts.js +32 -0
  6. package/dist/commands/daemon.d.ts +18 -0
  7. package/dist/commands/daemon.js +581 -0
  8. package/dist/commands/exec.js +66 -20
  9. package/dist/commands/routines.js +29 -11
  10. package/dist/commands/secrets.d.ts +17 -0
  11. package/dist/commands/secrets.js +30 -15
  12. package/dist/commands/sessions-browser.js +6 -6
  13. package/dist/commands/sessions-favorite.d.ts +7 -7
  14. package/dist/commands/sessions-favorite.js +30 -30
  15. package/dist/commands/sessions-picker.d.ts +33 -1
  16. package/dist/commands/sessions-picker.js +102 -27
  17. package/dist/commands/sessions.d.ts +12 -1
  18. package/dist/commands/sessions.js +259 -20
  19. package/dist/commands/view.d.ts +11 -0
  20. package/dist/commands/view.js +56 -29
  21. package/dist/index.js +37 -2
  22. package/dist/lib/account-labels.d.ts +24 -0
  23. package/dist/lib/account-labels.js +72 -0
  24. package/dist/lib/agents.d.ts +32 -1
  25. package/dist/lib/agents.js +96 -31
  26. package/dist/lib/daemon-health.d.ts +24 -0
  27. package/dist/lib/daemon-health.js +84 -0
  28. package/dist/lib/daemon-ticks.d.ts +81 -0
  29. package/dist/lib/daemon-ticks.js +190 -0
  30. package/dist/lib/daemon.d.ts +68 -18
  31. package/dist/lib/daemon.js +303 -338
  32. package/dist/lib/device-config.d.ts +10 -0
  33. package/dist/lib/device-config.js +27 -0
  34. package/dist/lib/exec.d.ts +27 -0
  35. package/dist/lib/exec.js +49 -2
  36. package/dist/lib/hosts/dispatch.d.ts +4 -0
  37. package/dist/lib/hosts/dispatch.js +4 -0
  38. package/dist/lib/hosts/remote-cmd.js +1 -0
  39. package/dist/lib/hosts/run-target.d.ts +1 -0
  40. package/dist/lib/hosts/run-target.js +1 -0
  41. package/dist/lib/import.js +7 -6
  42. package/dist/lib/memory-cache.d.ts +19 -0
  43. package/dist/lib/memory-cache.js +31 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  46. package/dist/lib/migrate.d.ts +1 -1
  47. package/dist/lib/migrate.js +7 -2
  48. package/dist/lib/routine-activation.d.ts +2 -0
  49. package/dist/lib/routine-activation.js +16 -0
  50. package/dist/lib/runner.d.ts +18 -0
  51. package/dist/lib/runner.js +52 -0
  52. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  53. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  54. package/dist/lib/secrets/agent.d.ts +19 -0
  55. package/dist/lib/secrets/agent.js +32 -2
  56. package/dist/lib/secrets/scope.d.ts +3 -3
  57. package/dist/lib/secrets/scope.js +3 -3
  58. package/dist/lib/session/db.d.ts +15 -0
  59. package/dist/lib/session/db.js +90 -15
  60. package/dist/lib/session/discover.js +91 -39
  61. package/dist/lib/session/favorites.d.ts +2 -2
  62. package/dist/lib/session/favorites.js +2 -2
  63. package/dist/lib/session/parse.d.ts +63 -0
  64. package/dist/lib/session/parse.js +165 -20
  65. package/dist/lib/session/session-cache.d.ts +9 -6
  66. package/dist/lib/session/session-cache.js +23 -6
  67. package/dist/lib/shims.js +12 -0
  68. package/dist/lib/startup/command-registry.d.ts +15 -1
  69. package/dist/lib/startup/command-registry.js +49 -0
  70. package/dist/lib/usage-refresh.js +3 -2
  71. package/dist/lib/usage.d.ts +12 -10
  72. package/dist/lib/usage.js +63 -144
  73. package/package.json +4 -1
@@ -0,0 +1,581 @@
1
+ /**
2
+ * `agents daemon` — runtime, hosted services, and failure visibility for the
3
+ * always-on daemon (RUSH-2354).
4
+ *
5
+ * The daemon holds the routines scheduler, the secrets broker, the browser IPC
6
+ * server, and the watchdog pass — but until this command group existed it had
7
+ * no user-facing surface: no way to see it, restart it, or turn it off.
8
+ * `daemon.ts` (the runtime) has always implemented every mechanism this file
9
+ * wires up; nothing here is new machinery, only the missing CLI surface.
10
+ *
11
+ * There is deliberately no `agents daemon jobs` — scheduled work is
12
+ * `agents routines`, always (see RUSH-2353, which migrates the daemon's
13
+ * hardcoded timers onto routines). `status`/`services` point at
14
+ * `agents routines stats` for per-routine failure detail instead of
15
+ * duplicating it.
16
+ */
17
+ import chalk from 'chalk';
18
+ import { execFileSync } from 'child_process';
19
+ import * as fs from 'fs';
20
+ import * as path from 'path';
21
+ import { setHelpSections } from '../lib/help.js';
22
+ import { getDaemonStatus, isDaemonRunning, isDaemonWedged, readDaemonLog, startDaemon, stopDaemon, signalDaemonReload, } from '../lib/daemon.js';
23
+ import { getConfigValue, setConfigValue, isDaemonEnabled } from '../lib/device-config.js';
24
+ import { readSubsystemHealth, SUBSYSTEM_SECRETS_BROKER, SUBSYSTEM_BROWSER_IPC, } from '../lib/daemon-health.js';
25
+ import { listJobs, getLatestRun } from '../lib/routines.js';
26
+ import { JobScheduler } from '../lib/scheduler.js';
27
+ import { getDaemonDir } from '../lib/state.js';
28
+ import { followFile } from '../lib/log-follow.js';
29
+ import { parseDuration } from '../lib/hooks/cache.js';
30
+ /**
31
+ * Resolve the working directory a live process was started from, or null if
32
+ * unavailable. Linux only (`/proc/<pid>/cwd`) — there is no equivalent
33
+ * zero-dependency primitive on macOS/BSD.
34
+ */
35
+ function processCwd(pid) {
36
+ try {
37
+ return fs.realpathSync(`/proc/${pid}/cwd`);
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ /**
44
+ * Walk up from `entryPath` looking for the nearest `package.json` and read its
45
+ * version. A relative `entryPath` (the common shape for a dev `node --import
46
+ * tsx <entry> __daemon-run` invocation) is meaningless resolved against the
47
+ * CALLING process's cwd — it must be anchored to the OWNING process's own cwd
48
+ * instead, via `processCwd(pid)`. Getting this wrong silently reports another
49
+ * process's version as this one's (observed live: a relative `src/index.ts`
50
+ * resolved against the caller's cwd instead of the stray daemon's actual
51
+ * ephemeral `/tmp` cwd, reporting a version that process was not running).
52
+ * Absolute entries (every production launch — `getAgentsBinPath()` always
53
+ * returns one) need no anchoring and resolve the same either way.
54
+ */
55
+ function resolveVersionNear(entryPath, pid) {
56
+ let resolved = entryPath;
57
+ if (!path.isAbsolute(resolved)) {
58
+ const cwd = processCwd(pid);
59
+ if (!cwd)
60
+ return null; // cannot anchor a relative entry — do not guess
61
+ resolved = path.join(cwd, resolved);
62
+ }
63
+ try {
64
+ resolved = fs.realpathSync(resolved);
65
+ }
66
+ catch { /* shim/symlink may be broken or entry may not exist locally */ }
67
+ let dir = path.dirname(resolved);
68
+ for (let i = 0; i < 6; i++) {
69
+ const candidate = path.join(dir, 'package.json');
70
+ try {
71
+ const pkg = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
72
+ if (typeof pkg.version === 'string')
73
+ return pkg.version;
74
+ }
75
+ catch { /* keep walking */ }
76
+ const parent = path.dirname(dir);
77
+ if (parent === dir)
78
+ break;
79
+ dir = parent;
80
+ }
81
+ return null;
82
+ }
83
+ /**
84
+ * Extract the launch entry from a tokenized `ps` args line ending in
85
+ * `__daemon-run`: the token immediately before it is always the entry —
86
+ * `<node> [node flags...] <entry> __daemon-run` (a dev `node --import tsx
87
+ * <entry> __daemon-run` or the production `node <entry> __daemon-run`) or a
88
+ * compiled standalone binary (`<binary> __daemon-run`, 2 tokens). Reading the
89
+ * second-to-last token is robust to however many node flags precede the entry,
90
+ * unlike guessing a fixed position from the front.
91
+ */
92
+ function entryFromTokens(tokens) {
93
+ return tokens.length >= 2 ? tokens[tokens.length - 2] : null;
94
+ }
95
+ /**
96
+ * Every live `__daemon-run` process on this box, regardless of which install
97
+ * launched it. POSIX-only (uses `ps`), mirroring `reapStrayDaemons`'s scope —
98
+ * a no-op on Windows.
99
+ *
100
+ * `getDaemonLaunch` always spawns `<node> <entry> __daemon-run` with nothing
101
+ * after it — the ONLY argv `__daemon-run` ever appears in for a real daemon.
102
+ * A substring/regex test anywhere in the full command line is not enough: an
103
+ * `agents run claude "<prompt>"` invocation whose prompt happens to quote the
104
+ * literal text `__daemon-run` (this ticket's own brief does) matches that test
105
+ * too, and was observed producing false "duplicate daemon" rows. Requiring it
106
+ * to be the LAST whitespace-delimited token is the actual invariant.
107
+ */
108
+ function scanDaemonProcesses() {
109
+ if (process.platform === 'win32')
110
+ return [];
111
+ let out;
112
+ try {
113
+ out = execFileSync('ps', ['-eo', 'pid=,args='], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
114
+ }
115
+ catch {
116
+ return [];
117
+ }
118
+ const found = [];
119
+ for (const line of out.split('\n')) {
120
+ const m = line.trim().match(/^(\d+)\s+(.*)$/);
121
+ if (!m)
122
+ continue;
123
+ const args = m[2].trim();
124
+ const tokens = args.split(/\s+/);
125
+ if (tokens.length === 0 || tokens[tokens.length - 1] !== '__daemon-run')
126
+ continue;
127
+ const pid = parseInt(m[1], 10);
128
+ if (isNaN(pid))
129
+ continue;
130
+ const entry = entryFromTokens(tokens);
131
+ const version = entry ? resolveVersionNear(entry, pid) : null;
132
+ found.push({ pid, entry, version });
133
+ }
134
+ return found;
135
+ }
136
+ /** Elapsed wall-clock seconds since `pid` started, or null if unavailable (best-effort, POSIX only). */
137
+ function uptimeSeconds(pid) {
138
+ if (process.platform === 'win32')
139
+ return null;
140
+ try {
141
+ const out = execFileSync('ps', ['-o', 'etimes=', '-p', String(pid)], { encoding: 'utf-8' }).trim();
142
+ const n = parseInt(out, 10);
143
+ return isNaN(n) ? null : n;
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
149
+ function humanDuration(seconds) {
150
+ if (seconds < 60)
151
+ return `${seconds}s`;
152
+ if (seconds < 3600)
153
+ return `${Math.round(seconds / 60)}m`;
154
+ if (seconds < 86400)
155
+ return `${Math.round(seconds / 3600)}h`;
156
+ return `${Math.round(seconds / 86400)}d`;
157
+ }
158
+ async function probeSecretsBroker() {
159
+ const record = readSubsystemHealth(SUBSYSTEM_SECRETS_BROKER);
160
+ try {
161
+ const { agentPing, agentStatus, secretsBrokerSocketPath } = await import('../lib/secrets/agent.js');
162
+ const ping = await agentPing();
163
+ if (!ping.reachable)
164
+ return { reachable: false, socketPath: secretsBrokerSocketPath(), heldBundles: null, record };
165
+ const entries = await agentStatus();
166
+ return { reachable: true, socketPath: secretsBrokerSocketPath(), heldBundles: entries.length, record };
167
+ }
168
+ catch {
169
+ return { reachable: false, socketPath: null, heldBundles: null, record };
170
+ }
171
+ }
172
+ async function probeBrowserIPC() {
173
+ const record = readSubsystemHealth(SUBSYSTEM_BROWSER_IPC);
174
+ const { isDaemonReachable, getSocketPath } = await import('../lib/browser/ipc.js');
175
+ const { listAllProfileSnapshots } = await import('../lib/browser/runtime-state.js');
176
+ const bound = await isDaemonReachable();
177
+ const sessionCount = listAllProfileSnapshots().filter((s) => s.pidAlive && s.daemonAlive).length;
178
+ return { bound, socketPath: getSocketPath(), sessionCount, record };
179
+ }
180
+ function schedulerSummary() {
181
+ const jobs = listJobs();
182
+ const enabled = jobs.filter((j) => j.enabled);
183
+ let nextFire = null;
184
+ try {
185
+ const scheduler = new JobScheduler(async () => { });
186
+ scheduler.loadAll();
187
+ for (const job of scheduler.listScheduled()) {
188
+ if (job.nextRun && (!nextFire || job.nextRun < nextFire))
189
+ nextFire = job.nextRun;
190
+ }
191
+ scheduler.stopAll();
192
+ }
193
+ catch { /* best-effort */ }
194
+ const failingCount = enabled.filter((j) => {
195
+ const last = getLatestRun(j.name);
196
+ return last?.status === 'failed' || last?.status === 'timeout';
197
+ }).length;
198
+ return { routineCount: jobs.length, enabledCount: enabled.length, nextFire, failingCount };
199
+ }
200
+ // ─── Rendering ────────────────────────────────────────────────────────────
201
+ function healthLine(label, record) {
202
+ if (!record || record.consecutiveFailures === 0) {
203
+ const ok = record?.lastOkAt ? chalk.gray(`(last ok ${record.lastOkAt})`) : '';
204
+ return ` ${chalk.green('healthy')} ${label} ${ok}`;
205
+ }
206
+ return ` ${chalk.red(`${record.consecutiveFailures} consecutive failure(s)`)} ${label} ${chalk.gray(`— ${record.lastError}`)}`;
207
+ }
208
+ async function runStatus(opts) {
209
+ const status = getDaemonStatus();
210
+ const enabled = isDaemonEnabled();
211
+ const state = !status.running && !enabled ? 'disabled' : status.state;
212
+ const pid = status.pid;
213
+ const uptime = pid ? uptimeSeconds(pid) : null;
214
+ const heartbeatAgeMs = status.heartbeat ? Date.now() - Date.parse(status.heartbeat.lastTick) : null;
215
+ const processes = scanDaemonProcesses();
216
+ const owner = pid ? processes.find((p) => p.pid === pid) : undefined;
217
+ const duplicates = processes.filter((p) => p.pid !== pid);
218
+ const [secrets, browserIpc] = await Promise.all([probeSecretsBroker(), probeBrowserIPC()]);
219
+ const scheduler = schedulerSummary();
220
+ if (opts.json) {
221
+ console.log(JSON.stringify({
222
+ state,
223
+ pid,
224
+ uptimeSeconds: uptime,
225
+ heartbeatAgeMs,
226
+ logPath: status.logPath,
227
+ binaryPath: owner?.entry ?? status.binaryPath,
228
+ binaryVersion: owner?.version ?? null,
229
+ duplicates: duplicates.map((d) => ({ pid: d.pid, entry: d.entry, version: d.version })),
230
+ daemonEnabled: enabled,
231
+ services: {
232
+ secretsBroker: {
233
+ reachable: secrets.reachable,
234
+ socketPath: secrets.socketPath,
235
+ heldBundles: secrets.heldBundles,
236
+ health: secrets.record,
237
+ },
238
+ browserIpc: {
239
+ bound: browserIpc.bound,
240
+ socketPath: browserIpc.socketPath,
241
+ sessionCount: browserIpc.sessionCount,
242
+ health: browserIpc.record,
243
+ },
244
+ },
245
+ scheduler: {
246
+ enabled: getConfigValue('scheduler.enabled').value !== false,
247
+ routineCount: scheduler.routineCount,
248
+ enabledCount: scheduler.enabledCount,
249
+ nextFire: scheduler.nextFire ? scheduler.nextFire.toISOString() : null,
250
+ failingCount: scheduler.failingCount,
251
+ },
252
+ }, null, 2));
253
+ return;
254
+ }
255
+ const stateLabel = state === 'running' ? chalk.green('running')
256
+ : state === 'wedged' ? chalk.red('wedged')
257
+ : state === 'disabled' ? chalk.yellow('disabled')
258
+ : chalk.gray('stopped');
259
+ console.log(chalk.bold('Identity\n'));
260
+ console.log(` State: ${stateLabel}`);
261
+ if (pid)
262
+ console.log(` PID: ${pid}`);
263
+ if (uptime !== null)
264
+ console.log(` Uptime: ${humanDuration(uptime)}`);
265
+ if (heartbeatAgeMs !== null)
266
+ console.log(` Heartbeat: ${Math.round(heartbeatAgeMs / 1000)}s ago`);
267
+ console.log(` Binary: ${chalk.gray(owner?.entry ?? status.binaryPath ?? 'unknown')}`);
268
+ console.log(` Version: ${chalk.gray(owner?.version ?? 'unknown')}`);
269
+ console.log(` Log: ${chalk.gray(status.logPath)}`);
270
+ if (!enabled)
271
+ console.log(chalk.yellow(` daemon.enabled is false — nothing auto-starts it. Explicit start: agents daemon start`));
272
+ if (duplicates.length > 0) {
273
+ console.log(chalk.red(`\nDuplicates (${duplicates.length})\n`));
274
+ for (const d of duplicates) {
275
+ console.log(` PID ${d.pid} ${chalk.gray(d.entry ?? 'unknown entry')} ${d.version ? chalk.gray(`(v${d.version})`) : ''}`);
276
+ }
277
+ console.log(chalk.gray('\n Only one install should own the daemon. Stop the stray(s): kill <pid>'));
278
+ }
279
+ console.log(chalk.bold('\nHealth\n'));
280
+ console.log(healthLine(`secrets broker ${secrets.reachable ? `(${secrets.socketPath}, ${secrets.heldBundles} bundle(s) held)` : '(unreachable)'}`, secrets.record));
281
+ console.log(healthLine(`browser IPC ${browserIpc.bound ? `(${browserIpc.socketPath}, ${browserIpc.sessionCount} session(s))` : '(unbound)'}`, browserIpc.record));
282
+ const schedulerEnabled = getConfigValue('scheduler.enabled').value !== false;
283
+ console.log(` ${schedulerEnabled ? chalk.green('enabled') : chalk.yellow('disabled')} scheduler — ${scheduler.enabledCount}/${scheduler.routineCount} routine(s) enabled` +
284
+ (scheduler.nextFire ? `, next ${scheduler.nextFire.toLocaleString()}` : ''));
285
+ if (scheduler.failingCount > 0) {
286
+ console.log(chalk.red(` ${scheduler.failingCount} routine(s) failing their last run — see: agents routines stats`));
287
+ }
288
+ if (state === 'wedged') {
289
+ console.log(chalk.red('\nThe daemon is wedged (heartbeat stale). Restart: agents daemon restart'));
290
+ }
291
+ }
292
+ async function runServices(opts) {
293
+ const [secrets, browserIpc] = await Promise.all([probeSecretsBroker(), probeBrowserIPC()]);
294
+ if (opts.json) {
295
+ console.log(JSON.stringify({
296
+ secretsBroker: { reachable: secrets.reachable, socketPath: secrets.socketPath, heldBundles: secrets.heldBundles, health: secrets.record },
297
+ browserIpc: { bound: browserIpc.bound, socketPath: browserIpc.socketPath, sessionCount: browserIpc.sessionCount, health: browserIpc.record },
298
+ }, null, 2));
299
+ return;
300
+ }
301
+ console.log(chalk.bold('Hosted services\n'));
302
+ console.log(healthLine(`secrets broker ${secrets.reachable ? `(${secrets.socketPath}, ${secrets.heldBundles} bundle(s) held)` : '(unreachable)'}`, secrets.record));
303
+ console.log(healthLine(`browser IPC ${browserIpc.bound ? `(${browserIpc.socketPath}, ${browserIpc.sessionCount} session(s))` : '(unbound)'}`, browserIpc.record));
304
+ console.log(chalk.gray('\nScheduled routines run through `agents routines` — see: agents routines stats'));
305
+ }
306
+ function parseLogLines(raw) {
307
+ const out = [];
308
+ for (const line of raw.split('\n')) {
309
+ if (!line.trim())
310
+ continue;
311
+ try {
312
+ const entry = JSON.parse(line);
313
+ if (entry && typeof entry.ts === 'string' && typeof entry.level === 'string')
314
+ out.push(entry);
315
+ }
316
+ catch { /* skip malformed line */ }
317
+ }
318
+ return out;
319
+ }
320
+ const LEVEL_RANK = { INFO: 0, WARN: 1, ERROR: 2 };
321
+ function passesFilters(entry, minLevel, sinceMs) {
322
+ if (minLevel) {
323
+ const min = LEVEL_RANK[minLevel.toUpperCase()] ?? 0;
324
+ const level = LEVEL_RANK[entry.level.toUpperCase()] ?? 0;
325
+ if (level < min)
326
+ return false;
327
+ }
328
+ if (sinceMs !== undefined && Date.parse(entry.ts) < sinceMs)
329
+ return false;
330
+ return true;
331
+ }
332
+ function printLogEntry(entry) {
333
+ const color = entry.level === 'ERROR' ? chalk.red : entry.level === 'WARN' ? chalk.yellow : chalk.gray;
334
+ console.log(`${chalk.gray(entry.ts)} ${color(entry.level.padEnd(5))} ${entry.message}`);
335
+ }
336
+ async function runLogs(opts) {
337
+ const sinceMs = opts.since ? Date.now() - (parseDuration(opts.since) ?? 0) * 1000 : undefined;
338
+ const lineCount = opts.lines ? parseInt(opts.lines, 10) : 50;
339
+ if (opts.follow) {
340
+ const logPath = path.join(getDaemonDir(), 'logs.jsonl');
341
+ for (const entry of parseLogLines(readDaemonLog(lineCount)).filter((e) => passesFilters(e, opts.level, sinceMs))) {
342
+ if (opts.json)
343
+ console.log(JSON.stringify(entry));
344
+ else
345
+ printLogEntry(entry);
346
+ }
347
+ const stop = followFile(logPath, (text) => {
348
+ for (const entry of parseLogLines(text).filter((e) => passesFilters(e, opts.level, sinceMs))) {
349
+ if (opts.json)
350
+ console.log(JSON.stringify(entry));
351
+ else
352
+ printLogEntry(entry);
353
+ }
354
+ }, { fromEnd: true });
355
+ process.on('SIGINT', () => { stop(); process.exit(0); });
356
+ return;
357
+ }
358
+ const entries = parseLogLines(readDaemonLog()).filter((e) => passesFilters(e, opts.level, sinceMs)).slice(-lineCount);
359
+ if (entries.length === 0) {
360
+ if (opts.json)
361
+ console.log('[]');
362
+ else
363
+ console.log(chalk.gray('No matching log lines'));
364
+ return;
365
+ }
366
+ if (opts.json) {
367
+ console.log(JSON.stringify(entries));
368
+ return;
369
+ }
370
+ for (const entry of entries)
371
+ printLogEntry(entry);
372
+ }
373
+ // ─── Doctor ──────────────────────────────────────────────────────────────
374
+ async function runDoctor(opts) {
375
+ const status = getDaemonStatus();
376
+ const enabled = isDaemonEnabled();
377
+ const problems = [];
378
+ if (!status.running && enabled)
379
+ problems.push('Daemon is not running. Start it: agents daemon start');
380
+ if (status.running && isDaemonWedged())
381
+ problems.push('Daemon is wedged (heartbeat stale). Restart: agents daemon restart');
382
+ const duplicates = scanDaemonProcesses().filter((p) => p.pid !== status.pid);
383
+ if (duplicates.length > 0) {
384
+ problems.push(`${duplicates.length} duplicate daemon process(es) running: ${duplicates.map((d) => d.pid).join(', ')}. Stop the stray(s).`);
385
+ }
386
+ const secrets = await probeSecretsBroker();
387
+ if (!secrets.reachable)
388
+ problems.push('Secrets broker is unreachable.');
389
+ if (secrets.record && secrets.record.consecutiveFailures > 0) {
390
+ problems.push(`Secrets broker has ${secrets.record.consecutiveFailures} consecutive failure(s): ${secrets.record.lastError}`);
391
+ }
392
+ const browserIpc = await probeBrowserIPC();
393
+ if (!browserIpc.bound)
394
+ problems.push('Browser IPC is unbound.');
395
+ if (browserIpc.record && browserIpc.record.consecutiveFailures > 0) {
396
+ problems.push(`Browser IPC has ${browserIpc.record.consecutiveFailures} consecutive failure(s): ${browserIpc.record.lastError}`);
397
+ }
398
+ const scheduler = schedulerSummary();
399
+ if (scheduler.failingCount > 0) {
400
+ problems.push(`${scheduler.failingCount} routine(s) failing their last run. See: agents routines stats`);
401
+ }
402
+ if (opts.json) {
403
+ console.log(JSON.stringify({ healthy: problems.length === 0, problems }));
404
+ if (problems.length > 0)
405
+ process.exitCode = 1;
406
+ return;
407
+ }
408
+ if (problems.length === 0) {
409
+ console.log(chalk.green('daemon: healthy'));
410
+ return;
411
+ }
412
+ console.log(chalk.bold(`daemon: ${problems.length} problem(s)\n`));
413
+ for (const p of problems)
414
+ console.log(` ${chalk.red('✗')} ${p}`);
415
+ process.exitCode = 1;
416
+ }
417
+ // ─── Command registration ────────────────────────────────────────────────
418
+ export function registerDaemonCommand(program) {
419
+ const cmd = program
420
+ .command('daemon')
421
+ .description('The always-on daemon: secrets broker, browser IPC, watchdog, and the routines scheduler. Bare `agents daemon` shows status.')
422
+ .option('--json', 'Emit as JSON')
423
+ .action(async (opts, command) => {
424
+ await runStatus({ json: command.optsWithGlobals().json === true });
425
+ });
426
+ setHelpSections(cmd, {
427
+ examples: `
428
+ # Identity, duplicates, and per-service health in one view
429
+ agents daemon status
430
+
431
+ # Machine-readable status (for scripts / Factory)
432
+ agents daemon status --json
433
+
434
+ # Start / stop / restart the daemon process
435
+ agents daemon start
436
+ agents daemon stop
437
+ agents daemon restart
438
+
439
+ # Persist the daemon off — nothing auto-starts it until re-enabled
440
+ agents daemon disable
441
+ agents daemon enable
442
+
443
+ # Reload config (SIGHUP) without restarting — picks up routine/scheduler-gate changes
444
+ agents daemon reload
445
+
446
+ # Just the two hosted services (secrets broker, browser IPC)
447
+ agents daemon services
448
+
449
+ # Tail the daemon's own log, warnings and up, from the last hour
450
+ agents daemon logs -f --level warn --since 1h
451
+
452
+ # One-shot health check for scripts (non-zero exit on problems)
453
+ agents daemon doctor
454
+ `,
455
+ notes: `
456
+ There is no 'agents daemon jobs' — scheduled work is 'agents routines',
457
+ always. Use 'agents routines stats' for per-routine failure detail.
458
+
459
+ 'disable' is a persisted kill switch: it stops routines/add,
460
+ routines/start, routines/catchup, and webhook triggers from auto-starting
461
+ the daemon (daemon.enabled: false in ~/.agents/devices/<host>/agents.yaml).
462
+ 'agents daemon start' still starts it explicitly, same as
463
+ 'systemctl start' on a disabled unit.
464
+ `,
465
+ });
466
+ cmd.command('status')
467
+ .description('Identity (state/pid/uptime/binary), duplicate daemon processes, and per-service health.')
468
+ .option('--json', 'Emit as JSON')
469
+ .action(async (opts, command) => {
470
+ await runStatus({ json: command.optsWithGlobals().json === true });
471
+ });
472
+ cmd.command('start')
473
+ .description('Start the daemon. Bypasses daemon.enabled — this is the deliberate override.')
474
+ .action(() => {
475
+ const result = startDaemon();
476
+ if (result.method === 'already-running') {
477
+ console.log(chalk.yellow(`Daemon already running (PID: ${result.pid})`));
478
+ }
479
+ else if (result.pid) {
480
+ console.log(chalk.green(`Daemon started (PID: ${result.pid}, ${result.method})`));
481
+ }
482
+ else {
483
+ console.log(chalk.yellow('Daemon start dispatched but no PID surfaced. Check: agents daemon status'));
484
+ }
485
+ });
486
+ cmd.command('stop')
487
+ .description('Stop the daemon.')
488
+ .option('--json', 'Emit the structured stop result (released/surviving resources, detached children).')
489
+ .action((_opts, command) => {
490
+ const asJson = command.optsWithGlobals().json === true;
491
+ if (!isDaemonRunning()) {
492
+ if (asJson) {
493
+ console.log(JSON.stringify({ ok: true, stoppedPid: null, escalated: false, released: [], surviving: [], detachedChildren: [] }, null, 2));
494
+ }
495
+ else {
496
+ console.log(chalk.yellow('Daemon is not running'));
497
+ }
498
+ return;
499
+ }
500
+ // SING-12 / RUSH-2355: stop asserts its postcondition and returns what
501
+ // released vs survived — surface it and exit non-zero on an unclean stop.
502
+ const result = stopDaemon();
503
+ if (asJson) {
504
+ console.log(JSON.stringify(result, null, 2));
505
+ }
506
+ else {
507
+ console.log(result.ok ? chalk.green('Daemon stopped') : chalk.red('Daemon stop incomplete'));
508
+ for (const r of result.released)
509
+ console.log(chalk.gray(` released: ${r}`));
510
+ for (const s of result.surviving)
511
+ console.log(chalk.red(` surviving: ${s}`));
512
+ if (result.detachedChildren.length > 0) {
513
+ console.log(chalk.gray(` detached routine children left running (adopted on next daemon start): ${result.detachedChildren.join(', ')}`));
514
+ }
515
+ }
516
+ if (!result.ok)
517
+ process.exitCode = 1;
518
+ });
519
+ cmd.command('restart')
520
+ .description('Stop then start the daemon.')
521
+ .action(() => {
522
+ if (isDaemonRunning()) {
523
+ const stop = stopDaemon();
524
+ console.log(stop.ok ? chalk.gray('Daemon stopped') : chalk.red('Daemon stop incomplete'));
525
+ for (const s of stop.surviving)
526
+ console.log(chalk.red(` surviving: ${s}`));
527
+ }
528
+ const result = startDaemon();
529
+ if (result.pid)
530
+ console.log(chalk.green(`Daemon started (PID: ${result.pid}, ${result.method})`));
531
+ else
532
+ console.log(chalk.yellow('Daemon start dispatched but no PID surfaced. Check: agents daemon status'));
533
+ });
534
+ cmd.command('enable')
535
+ .description('Clear the daemon.enabled kill switch. Does not start the daemon by itself.')
536
+ .action(() => {
537
+ setConfigValue('daemon.enabled', true);
538
+ console.log(chalk.green('daemon.enabled: true') + chalk.gray(' — auto-start surfaces (routines add/start/catchup, webhooks) may bring the daemon up again'));
539
+ });
540
+ cmd.command('disable')
541
+ .description('Persist daemon.enabled: false — nothing auto-starts the daemon until re-enabled. Does not stop a running daemon.')
542
+ .action(() => {
543
+ setConfigValue('daemon.enabled', false);
544
+ console.log(chalk.yellow('daemon.enabled: false') + chalk.gray(' — auto-start is off. Explicit start still works: agents daemon start'));
545
+ if (isDaemonRunning())
546
+ console.log(chalk.gray('(the daemon is still running — stop it explicitly if you want it down: agents daemon stop)'));
547
+ });
548
+ cmd.command('reload')
549
+ .description('Send SIGHUP to reload jobs and re-evaluate the scheduler.enabled gate, without a restart.')
550
+ .action(() => {
551
+ if (!isDaemonRunning()) {
552
+ console.log(chalk.yellow('Daemon is not running — nothing to reload. Start it: agents daemon start'));
553
+ return;
554
+ }
555
+ const ok = signalDaemonReload();
556
+ console.log(ok ? chalk.green('Daemon reloaded') : chalk.yellow('Reload signal not delivered (unsupported on this platform, or the daemon just exited)'));
557
+ });
558
+ cmd.command('services')
559
+ .description('The two hosted services (secrets broker, browser IPC): bound state, socket path, and health.')
560
+ .option('--json', 'Emit as JSON')
561
+ .action(async (opts, command) => {
562
+ await runServices({ json: command.optsWithGlobals().json === true });
563
+ });
564
+ cmd.command('logs')
565
+ .description('Read the daemon\'s own log (lifecycle + subsystem errors — not routine run output).')
566
+ .option('-n, --lines <number>', 'Show this many recent lines', '50')
567
+ .option('-f, --follow', 'Stream new lines as they are written (like tail -f)')
568
+ .option('--level <level>', 'Minimum level to show: info | warn | error')
569
+ .option('--since <dur>', 'Only lines newer than this (e.g. 1h, 30m)')
570
+ .option('--json', 'Emit each line as JSON')
571
+ .action(async (opts, command) => {
572
+ const merged = command.optsWithGlobals();
573
+ await runLogs({ lines: opts.lines, follow: opts.follow, level: opts.level, since: opts.since, json: merged.json === true || opts.json === true });
574
+ });
575
+ cmd.command('doctor')
576
+ .description('One-shot health check: identity, duplicates, hosted services, scheduler. Non-zero exit on problems.')
577
+ .option('--json', 'Emit as JSON')
578
+ .action(async (opts, command) => {
579
+ await runDoctor({ json: command.optsWithGlobals().json === true });
580
+ });
581
+ }