@phnx-labs/agents-cli 1.22.28 → 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.
- package/CHANGELOG.md +88 -0
- package/README.md +39 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/accounts.d.ts +13 -0
- package/dist/commands/accounts.js +32 -0
- package/dist/commands/daemon.d.ts +18 -0
- package/dist/commands/daemon.js +581 -0
- package/dist/commands/exec.js +66 -20
- package/dist/commands/routines.js +29 -11
- package/dist/commands/secrets.d.ts +17 -0
- package/dist/commands/secrets.js +30 -15
- package/dist/commands/sessions-browser.js +6 -6
- package/dist/commands/sessions-favorite.d.ts +7 -7
- package/dist/commands/sessions-favorite.js +30 -30
- package/dist/commands/sessions-picker.d.ts +33 -1
- package/dist/commands/sessions-picker.js +102 -27
- package/dist/commands/sessions.d.ts +12 -1
- package/dist/commands/sessions.js +259 -20
- package/dist/commands/view.d.ts +11 -0
- package/dist/commands/view.js +56 -29
- package/dist/index.js +37 -2
- package/dist/lib/account-labels.d.ts +24 -0
- package/dist/lib/account-labels.js +72 -0
- package/dist/lib/agents.d.ts +32 -1
- package/dist/lib/agents.js +96 -31
- package/dist/lib/daemon-health.d.ts +24 -0
- package/dist/lib/daemon-health.js +84 -0
- package/dist/lib/daemon-ticks.d.ts +81 -0
- package/dist/lib/daemon-ticks.js +190 -0
- package/dist/lib/daemon.d.ts +68 -18
- package/dist/lib/daemon.js +303 -338
- package/dist/lib/device-config.d.ts +10 -0
- package/dist/lib/device-config.js +27 -0
- package/dist/lib/exec.d.ts +27 -0
- package/dist/lib/exec.js +49 -2
- package/dist/lib/hosts/dispatch.d.ts +4 -0
- package/dist/lib/hosts/dispatch.js +4 -0
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/hosts/run-target.d.ts +1 -0
- package/dist/lib/hosts/run-target.js +1 -0
- package/dist/lib/import.js +7 -6
- package/dist/lib/memory-cache.d.ts +19 -0
- package/dist/lib/memory-cache.js +31 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/snapshot.d.ts +16 -0
- package/dist/lib/menubar/snapshot.js +22 -1
- package/dist/lib/migrate.d.ts +1 -1
- package/dist/lib/migrate.js +7 -2
- package/dist/lib/routine-activation.d.ts +2 -0
- package/dist/lib/routine-activation.js +16 -0
- package/dist/lib/runner.d.ts +18 -0
- package/dist/lib/runner.js +52 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +19 -0
- package/dist/lib/secrets/agent.js +32 -2
- package/dist/lib/secrets/scope.d.ts +3 -3
- package/dist/lib/secrets/scope.js +3 -3
- package/dist/lib/session/db.d.ts +15 -0
- package/dist/lib/session/db.js +90 -15
- package/dist/lib/session/discover.js +91 -39
- package/dist/lib/session/favorites.d.ts +2 -2
- package/dist/lib/session/favorites.js +2 -2
- package/dist/lib/session/parse.d.ts +63 -0
- package/dist/lib/session/parse.js +165 -20
- package/dist/lib/session/session-cache.d.ts +9 -6
- package/dist/lib/session/session-cache.js +23 -6
- package/dist/lib/shims.js +12 -0
- package/dist/lib/startup/command-registry.d.ts +15 -1
- package/dist/lib/startup/command-registry.js +49 -0
- package/dist/lib/usage-refresh.js +3 -2
- package/dist/lib/usage.d.ts +12 -10
- package/dist/lib/usage.js +81 -154
- 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
|
+
}
|