@phnx-labs/agents-cli 1.22.70 → 1.22.72

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 (72) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +31 -1
  3. package/dist/bootstrap.js +4 -4
  4. package/dist/commands/repo.js +2 -2
  5. package/dist/commands/sessions-export.d.ts +5 -1
  6. package/dist/commands/sessions-export.js +100 -24
  7. package/dist/commands/sessions-import.d.ts +2 -1
  8. package/dist/commands/sessions-import.js +85 -21
  9. package/dist/lib/accounting/usage-sync.d.ts +1 -1
  10. package/dist/lib/accounting/usage-sync.js +3 -3
  11. package/dist/lib/browser/ipc.d.ts +34 -0
  12. package/dist/lib/browser/ipc.js +140 -19
  13. package/dist/lib/browser/types.d.ts +3 -1
  14. package/dist/lib/daemon/auth-sync-service.js +1 -1
  15. package/dist/lib/daemon/browser-task-reap-service.js +1 -1
  16. package/dist/lib/daemon/daemon.js +13 -3
  17. package/dist/lib/daemon/heartbeat-service.js +3 -3
  18. package/dist/lib/daemon/keychain-reap-service.js +1 -1
  19. package/dist/lib/daemon/runner.d.ts +18 -1
  20. package/dist/lib/daemon/runner.js +231 -78
  21. package/dist/lib/daemon/self-heal-service.js +13 -3
  22. package/dist/lib/daemon/self-update-service.d.ts +174 -0
  23. package/dist/lib/daemon/self-update-service.js +353 -0
  24. package/dist/lib/daemon/state-dir-check-service.js +3 -3
  25. package/dist/lib/daemon/usage-sync-service.js +1 -1
  26. package/dist/lib/daemon/watchdog-service.js +4 -4
  27. package/dist/lib/daemon-services.d.ts +1 -1
  28. package/dist/lib/daemon-services.js +5 -0
  29. package/dist/lib/device-config.d.ts +12 -1
  30. package/dist/lib/device-config.js +63 -13
  31. package/dist/lib/exec-bounded.d.ts +52 -0
  32. package/dist/lib/exec-bounded.js +113 -0
  33. package/dist/lib/feed/events.d.ts +22 -14
  34. package/dist/lib/feed/events.js +84 -44
  35. package/dist/lib/fleet-shared-state.d.ts +12 -5
  36. package/dist/lib/fleet-shared-state.js +50 -20
  37. package/dist/lib/fs-atomic.d.ts +11 -0
  38. package/dist/lib/fs-atomic.js +60 -0
  39. package/dist/lib/hosts/reconcile.d.ts +11 -4
  40. package/dist/lib/hosts/reconcile.js +31 -5
  41. package/dist/lib/project-resources.d.ts +12 -0
  42. package/dist/lib/project-resources.js +138 -0
  43. package/dist/lib/routine-process-cleanup.d.ts +2 -2
  44. package/dist/lib/routine-process-cleanup.js +45 -34
  45. package/dist/lib/secrets/reaper.d.ts +2 -2
  46. package/dist/lib/secrets/reaper.js +13 -10
  47. package/dist/lib/secrets/reserved-sync.d.ts +1 -1
  48. package/dist/lib/secrets/reserved-sync.js +4 -4
  49. package/dist/lib/self-update.d.ts +21 -8
  50. package/dist/lib/self-update.js +54 -31
  51. package/dist/lib/session/sync/backend.d.ts +61 -0
  52. package/dist/lib/session/sync/backend.js +89 -0
  53. package/dist/lib/session/sync/managed-config.d.ts +29 -0
  54. package/dist/lib/session/sync/managed-config.js +23 -0
  55. package/dist/lib/session/sync/managed-key.d.ts +45 -0
  56. package/dist/lib/session/sync/managed-key.js +128 -0
  57. package/dist/lib/session/sync/net-client.d.ts +65 -0
  58. package/dist/lib/session/sync/net-client.js +117 -0
  59. package/dist/lib/session/sync/provision.d.ts +19 -0
  60. package/dist/lib/session/sync/provision.js +38 -0
  61. package/dist/lib/session/sync/r2.d.ts +5 -2
  62. package/dist/lib/session/sync/r2.js +5 -2
  63. package/dist/lib/session/sync/worker-template.d.ts +6 -0
  64. package/dist/lib/session/sync/worker-template.js +847 -0
  65. package/dist/lib/tmux/orphan-reap.js +6 -4
  66. package/dist/lib/tmux/session.js +4 -1
  67. package/dist/lib/traces/classify.d.ts +8 -1
  68. package/dist/lib/traces/insights.d.ts +13 -1
  69. package/dist/lib/traces/insights.js +78 -3
  70. package/dist/lib/traces/sync.js +8 -3
  71. package/dist/lib/traces/worker-template.js +9 -5
  72. package/package.json +1 -1
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Daemon self-update service (PHNX-3695, "Fix 2").
3
+ *
4
+ * The daemon (`agents __daemon-run`) is a long-running background process that
5
+ * historically opted OUT of the CLI's interactive auto-update: `bootstrap.ts`
6
+ * force-sets `AGENTS_CLI_DISABLE_AUTO_UPDATE=1` for `__daemon-run`, so a
7
+ * running daemon never picked up new agents-cli code until a human ran
8
+ * `agents daemon restart`. R5 in the root CLAUDE.md ("An installed CLI and its
9
+ * installed helpers auto-update from the public channel") is binding for the
10
+ * interactive CLI path (`self-update.ts` / `agents upgrade`) but was silently
11
+ * NOT held for the one process that runs unattended for days. This service
12
+ * closes that gap for the daemon specifically, reusing the exact same
13
+ * verified-install primitives `agents upgrade` already uses — it does not
14
+ * fork or reimplement them.
15
+ *
16
+ * Model: verify-then-exit, not swap-in-place. A daemon cannot safely hot-swap
17
+ * its own loaded JS mid-process (in-flight ticks, open sockets, a live
18
+ * ServiceSupervisor). Instead this tick installs + BYTE-VERIFIES the new
19
+ * package on disk, and only once that succeeds does it `process.exit(0)` —
20
+ * the OS supervisor (launchd `KeepAlive` / systemd `Restart=always`, see
21
+ * `daemon/AGENTS.md`'s crash-recovery model) relaunches the daemon, which
22
+ * then boots the new code. Clients do not need to be told anything: browser
23
+ * IPC clients re-probe the socket via `waitForBrowserService`
24
+ * (`browser/ipc.ts`), and the scheduler's atomic `(routine, scheduledFor)`
25
+ * claim (see `docs/specifications.md` §Scheduling & execution singularity)
26
+ * means a routine mid-fire at the moment of exit is deduped safely across the
27
+ * restart rather than double-fired.
28
+ *
29
+ * Fail-closed is the whole point: every step below that can fail — the
30
+ * registry check, the install, the post-install verify — leaves the OLD
31
+ * daemon running untouched and logs a WARN/ERROR for the next tick to retry.
32
+ * The daemon must never exit into code it has not proven is the real,
33
+ * verified, requested version; an unverified exit would let the OS supervisor
34
+ * relaunch-loop on a broken install.
35
+ */
36
+ import { BasePeriodicService } from './service.js';
37
+ import { getCliVersion } from '../version.js';
38
+ import { isDevVersionStamp } from '../startup/dev-build.js';
39
+ import { detectAgentsBinaryShadows } from '../binary-shadow.js';
40
+ import { compareVersions } from '../agent-spec/primitives.js';
41
+ import { NPM_PACKAGE_NAME, deriveGlobalPrefix, detectPackageManager, downloadVerifiedTarball, ensureGlobalBinLinks, installPackageIntoPrefix, installPackageWithBun, refreshAliasShims, resolveRunningPackageRoot, sweepStaleInstallStaging, verifyInstalledVersion, } from '../self-update.js';
42
+ import { tryAutoPullSystemRepo } from '../git.js';
43
+ import { getSystemAgentsDir } from '../state.js';
44
+ import { runUmbrellaSync } from '../sync-umbrella.js';
45
+ import * as fs from 'fs';
46
+ import * as path from 'path';
47
+ import { fileURLToPath } from 'url';
48
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
49
+ /** Runs roughly every 75 minutes — self-update is not urgent (unlike self-heal's 6h drift repair, it changes running code, so it stays well under a day but still infrequent). */
50
+ const SELF_UPDATE_TICK_MS = 75 * 60_000;
51
+ /**
52
+ * Hard cap per tick: a real download + npm/bun install + verify can
53
+ * legitimately take minutes on a slow link. 15 minutes matches the task's
54
+ * stated budget and is short relative to the ~75min cadence. Exported so the
55
+ * on-demand `request-self-update` IPC handler (`browser/ipc.ts`) can bound
56
+ * its own `AbortController` on the SAME budget the periodic tick runs under —
57
+ * one deadline, not two independently-tuned numbers that could drift apart.
58
+ */
59
+ export const SELF_UPDATE_DEADLINE_MS = 15 * 60_000;
60
+ /**
61
+ * First tick fires 5 minutes after daemon boot — deliberately longer than
62
+ * self-heal's 30s stagger (`self-heal-service.ts`): self-heal repairs local
63
+ * drift and is cheap/safe to run immediately, while self-update can replace
64
+ * the running package and exit the process, which should never be the very
65
+ * first thing a freshly-started daemon does (give the box a moment to finish
66
+ * settling — shims, PATH, other services' startup ticks — before considering
67
+ * a restart). Every later tick still fires on the normal cadence.
68
+ */
69
+ const SELF_UPDATE_STARTUP_DELAY_MS = 5 * 60_000;
70
+ export async function fetchLatestNpmMetadata(signal) {
71
+ const response = await fetch(`https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`, { signal });
72
+ if (!response.ok) {
73
+ throw new Error(`registry.npmjs.org responded ${response.status}`);
74
+ }
75
+ const data = await response.json();
76
+ if (typeof data.version !== 'string'
77
+ || typeof data.dist?.integrity !== 'string'
78
+ || typeof data.dist?.tarball !== 'string') {
79
+ throw new Error('npm registry response did not include version, integrity, and tarball');
80
+ }
81
+ return { version: data.version, integrity: data.dist.integrity, tarball: data.dist.tarball };
82
+ }
83
+ /**
84
+ * Install + byte-verify `metadata` into `packageRoot`'s install, exactly the
85
+ * sequence `bootstrap.ts`'s `installResolvedPackage` runs for `agents
86
+ * upgrade` (download+integrity-verify -> sweep stale staging -> package-
87
+ * manager install -> verify installed version -> refresh alias shims).
88
+ * `bootstrap.ts` cannot be imported here — it runs side-effecting top-level
89
+ * code (argv parsing, command registration) on import, which the daemon must
90
+ * never trigger — so this is the same primitives from `self-update.ts`
91
+ * composed directly, not a fork of the upgrade logic.
92
+ *
93
+ * `signal` is threaded into EVERY step's own `signal` option
94
+ * (`downloadVerifiedTarball` / `installPackageIntoPrefix` /
95
+ * `installPackageWithBun`, all in `self-update.ts`) rather than raced against
96
+ * from the outside: those primitives kill the underlying fetch/child process
97
+ * on abort and their promise rejects only once that real cancellation has
98
+ * happened. A wrapper that merely stopped AWAITING an unkillable operation
99
+ * (the prior approach here) left an orphaned `npm install -g`/`bun add -g`
100
+ * writing into the shared global prefix — which the very next tick's fresh
101
+ * install (started as soon as the supervisor's backoff fires, seconds later)
102
+ * would then race into the same directory. Real cancellation is what makes
103
+ * `attemptSelfUpdateAndExit`'s `inFlightAttempt` dedupe (below) an actual
104
+ * guarantee instead of a guard whose lifetime is shorter than the operation
105
+ * it's guarding (found in review, PHNX-3695).
106
+ */
107
+ export async function installAndVerifyDefault(metadata, packageRoot, signal) {
108
+ const tarball = await downloadVerifiedTarball(metadata.tarball, metadata.integrity, 60_000, signal);
109
+ try {
110
+ await sweepStaleInstallStaging(packageRoot);
111
+ if (detectPackageManager(packageRoot) === 'bun') {
112
+ await installPackageWithBun(tarball, signal);
113
+ }
114
+ else {
115
+ await installPackageIntoPrefix(tarball, deriveGlobalPrefix(packageRoot), signal);
116
+ }
117
+ }
118
+ finally {
119
+ try {
120
+ await fs.promises.rm(path.dirname(tarball), { recursive: true, force: true });
121
+ }
122
+ catch {
123
+ /* leave it for the OS temp sweep */
124
+ }
125
+ }
126
+ await verifyInstalledVersion(packageRoot, metadata.version);
127
+ await refreshAliasShims(packageRoot, signal);
128
+ // PHNX-2768: mirror `bootstrap.ts`'s `installResolvedPackage` — an
129
+ // `--ignore-scripts` install (both package-manager paths above) can leave
130
+ // the package.json at the new version but the global bin links
131
+ // (agents/ag/browser/computer) GONE. Without this, a self-update tick could
132
+ // exit `updated: true` while every operator-typed `agents` command on that
133
+ // box now reads "command not found," with no signal anywhere pointing at
134
+ // why. Same fail-loud contract as the interactive path: a link that cannot
135
+ // be made to resolve fails the whole attempt rather than reporting success.
136
+ if (detectPackageManager(packageRoot) !== 'bun' && process.platform !== 'win32') {
137
+ const prefix = deriveGlobalPrefix(packageRoot);
138
+ const repairs = await ensureGlobalBinLinks(packageRoot, prefix);
139
+ const failed = repairs.filter((r) => r.action === 'failed');
140
+ if (failed.length > 0) {
141
+ const relink = failed
142
+ .map((r) => `ln -sf ${path.relative(path.dirname(r.linkPath), r.target)} ${r.linkPath}`)
143
+ .join(' && ');
144
+ throw new Error(`upgraded to ${metadata.version} but could not restore the ` +
145
+ `${failed.map((r) => r.name).join(', ')} command link${failed.length === 1 ? '' : 's'} in ` +
146
+ `${path.join(prefix, 'bin')} (${failed.map((r) => r.error).join('; ')}). ` +
147
+ `The box has the new package but no working \`agents\` — relink manually: ${relink}`);
148
+ }
149
+ }
150
+ }
151
+ export function defaultSelfUpdateDeps() {
152
+ return {
153
+ currentVersion: () => getCliVersion(),
154
+ isDevBuild: () => isDevVersionStamp(getCliVersion()),
155
+ detectShadow: () => detectAgentsBinaryShadows().length > 0,
156
+ packageRoot: () => resolveRunningPackageRoot(__dirname),
157
+ fetchLatestMetadata: fetchLatestNpmMetadata,
158
+ installAndVerify: installAndVerifyDefault,
159
+ syncSystemRepo: async () => {
160
+ const result = await tryAutoPullSystemRepo(getSystemAgentsDir());
161
+ if (result.refused) {
162
+ throw new Error(`system repo origin '${result.actualRemote}' is not the expected system remote — refused`);
163
+ }
164
+ if (result.error) {
165
+ throw new Error(result.error);
166
+ }
167
+ },
168
+ syncLocal: async () => {
169
+ // Reconcile-only (no fetch — the .system pull above already fetched);
170
+ // best-effort, same as the system-repo pull: a declined resource here
171
+ // is not a self-update failure.
172
+ await runUmbrellaSync({
173
+ flags: { local: true },
174
+ log: () => { },
175
+ yes: true,
176
+ quiet: true,
177
+ });
178
+ },
179
+ };
180
+ }
181
+ /**
182
+ * Dedupes concurrent callers onto ONE in-flight attempt. The periodic tick
183
+ * and an on-demand `request-self-update` IPC call (possibly several, if more
184
+ * than one version-skewed client reconnects at once) can overlap in the same
185
+ * process — without this, two concurrent `installAndVerify` calls race on the
186
+ * same package-manager install directory. Keyed process-wide (not per-deps)
187
+ * since production always shares one `defaultSelfUpdateDeps()` install target;
188
+ * tests inject distinct `deps` per case and don't run concurrently with each
189
+ * other, so this never cross-contaminates test outcomes.
190
+ */
191
+ let inFlightAttempt = null;
192
+ /**
193
+ * Core self-update decision + action, shared by the periodic tick
194
+ * ({@link SelfUpdateService.onTick}) and the on-demand IPC path
195
+ * (`request-self-update`, `browser/ipc.ts`) — one implementation, so a
196
+ * version-skew client asking "update now" runs exactly the same fail-closed
197
+ * logic as the scheduled sweep. Returns rather than throws so callers decide
198
+ * their own exit timing (the periodic service exits immediately; the IPC
199
+ * handler must respond to the client on the socket BEFORE exiting, or the
200
+ * client hangs on a socket that is closing mid-write). Concurrent callers
201
+ * share one in-flight attempt rather than racing separate installs.
202
+ */
203
+ export async function attemptSelfUpdateAndExit(ctx, signal, deps = defaultSelfUpdateDeps()) {
204
+ if (inFlightAttempt)
205
+ return inFlightAttempt;
206
+ const attempt = runSelfUpdateAttempt(ctx, signal, deps);
207
+ inFlightAttempt = attempt;
208
+ try {
209
+ return await attempt;
210
+ }
211
+ finally {
212
+ if (inFlightAttempt === attempt)
213
+ inFlightAttempt = null;
214
+ }
215
+ }
216
+ async function runSelfUpdateAttempt(ctx, signal, deps) {
217
+ const syncDecline = selfUpdateSyncDeclineReason(deps);
218
+ if (syncDecline)
219
+ return { updated: false, reason: syncDecline };
220
+ const current = deps.currentVersion();
221
+ let metadata;
222
+ try {
223
+ metadata = await deps.fetchLatestMetadata(signal);
224
+ }
225
+ catch (err) {
226
+ const message = err instanceof Error ? err.message : String(err);
227
+ ctx.log('WARN', `self-update: registry check failed, staying on ${current}: ${message}`);
228
+ return { updated: false, reason: 'registry check failed' };
229
+ }
230
+ if (compareVersions(metadata.version, current) <= 0) {
231
+ return { updated: false, reason: `already current (${current})` };
232
+ }
233
+ const packageRoot = deps.packageRoot();
234
+ try {
235
+ await deps.installAndVerify(metadata, packageRoot, signal);
236
+ }
237
+ catch (err) {
238
+ const message = err instanceof Error ? err.message : String(err);
239
+ ctx.log('ERROR', `self-update: install/verify of ${metadata.version} failed, staying on ${current}: ${message}`);
240
+ return { updated: false, reason: 'install or verify failed' };
241
+ }
242
+ // Best-effort from here: the CLI package itself is already installed and
243
+ // byte-verified, so a failure pulling the companion .system repo or
244
+ // reconciling resources must not undo a good CLI upgrade or block the exit
245
+ // that lets the OS supervisor relaunch onto it — it is logged and left for
246
+ // the NEXT tick (which runs on the new code) to retry.
247
+ try {
248
+ await deps.syncSystemRepo();
249
+ }
250
+ catch (err) {
251
+ const message = err instanceof Error ? err.message : String(err);
252
+ ctx.log('WARN', `self-update: .system repo pull failed: ${message}`);
253
+ }
254
+ try {
255
+ await deps.syncLocal();
256
+ }
257
+ catch (err) {
258
+ const message = err instanceof Error ? err.message : String(err);
259
+ ctx.log('WARN', `self-update: local reconcile ('agents sync --local') failed: ${message}`);
260
+ }
261
+ ctx.log('INFO', `self-update: verified ${current} -> ${metadata.version}; exiting for OS-supervisor relaunch`);
262
+ return { updated: true };
263
+ }
264
+ /** How long `scheduleSelfUpdateExit` waits before `process.exit(0)` — long enough for an IPC handler's `socket.write` to flush to the OS. */
265
+ const SELF_UPDATE_EXIT_DELAY_MS = 250;
266
+ let exitScheduled = false;
267
+ /**
268
+ * Schedule the process exit for a verified self-update, exactly once, no
269
+ * matter how many callers observe `outcome.updated` on the shared
270
+ * `inFlightAttempt` promise. The periodic tick and an on-demand
271
+ * `request-self-update` IPC call (`browser/ipc.ts`) can both be awaiting that
272
+ * SAME promise — if the tick's continuation ran an immediate `process.exit(0)`
273
+ * while the IPC handler's continuation had not yet reached `socket.write`,
274
+ * the tick's exit could win the race and the client would see a closed socket
275
+ * before any response (found in review, PHNX-3695). Routing every caller
276
+ * through this one guarded, always-delayed scheduling point means the delay
277
+ * protects EVERY caller's in-flight response, not just the IPC handler's own.
278
+ */
279
+ export function scheduleSelfUpdateExit() {
280
+ if (exitScheduled)
281
+ return;
282
+ exitScheduled = true;
283
+ setTimeout(() => process.exit(0), SELF_UPDATE_EXIT_DELAY_MS);
284
+ }
285
+ /**
286
+ * Fire the on-demand self-update in the BACKGROUND and return its (bounded)
287
+ * promise WITHOUT the caller having to await it. This is what keeps the
288
+ * `request-self-update` IPC handler (`browser/ipc.ts`) from parking a
289
+ * version-skewed `agents browser` verb behind the full
290
+ * check→download→install→verify: that handler routes through
291
+ * `reconcileDaemonVersion` on every version-skewed call, so awaiting the whole
292
+ * install there reintroduces exactly the client-stall PHNX-3605 was written to
293
+ * prevent (tens of seconds, worst case ~15 min). The handler instead responds
294
+ * "triggered" immediately and lets this run in the background — the daemon does
295
+ * install→verify→exit(0) on its own, the OS supervisor relaunches it, and the
296
+ * browser reconnects.
297
+ *
298
+ * The work still shares the module-level {@link attemptSelfUpdateAndExit}
299
+ * `inFlightAttempt` guard, so a concurrent trigger (or the periodic tick) can't
300
+ * race a second install into the same prefix. It is bounded by
301
+ * {@link SELF_UPDATE_DEADLINE_MS} via an `AbortController` nobody awaits (the
302
+ * timer is `unref`'d so it never keeps the daemon alive on its own and never
303
+ * dangles in a test). Fail-closed is preserved end to end:
304
+ * `runSelfUpdateAttempt` already turns an install/verify failure into a
305
+ * not-updated outcome that leaves the running daemon untouched. The caller
306
+ * schedules the one decoupled {@link scheduleSelfUpdateExit} off the returned
307
+ * promise once `updated` is true, so the exit still fires after the IPC
308
+ * response has flushed.
309
+ */
310
+ export function triggerSelfUpdateInBackground(ctx, deps = defaultSelfUpdateDeps()) {
311
+ const controller = new AbortController();
312
+ const timeout = setTimeout(() => controller.abort(), SELF_UPDATE_DEADLINE_MS);
313
+ if (typeof timeout.unref === 'function')
314
+ timeout.unref();
315
+ return attemptSelfUpdateAndExit(ctx, controller.signal, deps).finally(() => clearTimeout(timeout));
316
+ }
317
+ /**
318
+ * The subset of self-update decline checks that are INSTANT and network-free —
319
+ * a dev build, or a shadowed install. The on-demand IPC handler
320
+ * (`request-self-update`) runs these SYNCHRONOUSLY so a version-skewed browser
321
+ * client gets the PHNX-3605 "nothing changed, not evicting" advisory
322
+ * immediately, instead of a "triggered" it would never act on. The remaining
323
+ * checks (registry probe, already-current, install/verify) stay inside the
324
+ * backgrounded {@link attemptSelfUpdateAndExit} so the handler never blocks on
325
+ * the network or the install. This is the single source of the two instant
326
+ * decline reasons — {@link runSelfUpdateAttempt} calls it too, so the on-demand
327
+ * decline text can never drift from the periodic tick's. Returns the decline
328
+ * reason, or `null` to proceed to the (backgrounded) install path.
329
+ */
330
+ export function selfUpdateSyncDeclineReason(deps = defaultSelfUpdateDeps()) {
331
+ if (deps.isDevBuild())
332
+ return 'dev build — self-update is a no-op';
333
+ if (deps.detectShadow())
334
+ return 'another agents binary shadows this install — self-update is a no-op';
335
+ return null;
336
+ }
337
+ export class SelfUpdateService extends BasePeriodicService {
338
+ id = 'self-update';
339
+ intervalMs = SELF_UPDATE_TICK_MS;
340
+ deadlineMs = SELF_UPDATE_DEADLINE_MS;
341
+ startupDelayMs = SELF_UPDATE_STARTUP_DELAY_MS;
342
+ async onStart(_ctx) {
343
+ // No connections/handles to open — each tick checks the registry fresh.
344
+ }
345
+ async onStop() {
346
+ // Nothing to release — the supervisor's timer teardown is the only cleanup needed.
347
+ }
348
+ async onTick(ctx, signal) {
349
+ const outcome = await attemptSelfUpdateAndExit(ctx, signal);
350
+ if (outcome.updated)
351
+ scheduleSelfUpdateExit();
352
+ }
353
+ }
@@ -23,10 +23,10 @@
23
23
  * `supervisor.start()` pairing this requires.
24
24
  */
25
25
  import { BasePeriodicService } from './service.js';
26
- import * as fs from 'fs';
26
+ import * as fsp from 'fs/promises';
27
27
  /** Matches the historical inline interval (daemon.ts STATE_DIR_CHECK_TICK_MS), overridable for tests. */
28
28
  const STATE_DIR_CHECK_TICK_MS = 60_000;
29
- /** Hard cap per tick — a single synchronous file read, far above what it could ever need. */
29
+ /** Hard cap per tick — a single async file read, far above what it could ever need. */
30
30
  const STATE_DIR_CHECK_DEADLINE_MS = 5_000;
31
31
  export class StateDirCheckService extends BasePeriodicService {
32
32
  id = 'state-dir-check';
@@ -50,7 +50,7 @@ export class StateDirCheckService extends BasePeriodicService {
50
50
  async onTick(ctx) {
51
51
  let markerMatches = false;
52
52
  try {
53
- markerMatches = fs.readFileSync(this.lifetimePath, 'utf-8') === this.lifetimeToken;
53
+ markerMatches = (await fsp.readFile(this.lifetimePath, 'utf-8')) === this.lifetimeToken;
54
54
  }
55
55
  catch {
56
56
  // A missing state dir or marker is the condition this guard detects.
@@ -23,7 +23,7 @@ export class UsageSyncService extends BasePeriodicService {
23
23
  }
24
24
  async onTick(ctx) {
25
25
  const { consumeUsageSnapshotsFromSharedStore, publishUsageSnapshotToSharedStore } = await import('../accounting/usage-sync.js');
26
- const published = publishUsageSnapshotToSharedStore();
26
+ const published = await publishUsageSnapshotToSharedStore();
27
27
  if (published.changed)
28
28
  ctx.log('INFO', `usage-sync: published usage snapshot to ${published.path}`);
29
29
  if (published.error)
@@ -8,8 +8,8 @@
8
8
  * inline behavior (daemon.ts previously `WATCHDOG_TICK_MS`-interval closure).
9
9
  */
10
10
  import { BasePeriodicService } from './service.js';
11
- import { getConfigValue } from '../device-config.js';
12
- import { emit } from '../feed/events.js';
11
+ import { getConfigValueAsync } from '../device-config.js';
12
+ import { emitAsync } from '../feed/events.js';
13
13
  /** Matches the historical inline interval (daemon.ts WATCHDOG_TICK_MS). */
14
14
  const WATCHDOG_TICK_MS = 3 * 60_000;
15
15
  /** Hard cap per tick — `runWatchdogPass` is host-local (adds no SSH fan-out of its own, `watchdog/runner.ts:580`); short enough that a hang never freezes the service for long. */
@@ -25,12 +25,12 @@ export class WatchdogService extends BasePeriodicService {
25
25
  // Nothing to release — the supervisor's timer teardown is the only cleanup needed.
26
26
  }
27
27
  async onTick(ctx) {
28
- if (getConfigValue('watchdog.enabled').value !== true)
28
+ if ((await getConfigValueAsync('watchdog.enabled')).value !== true)
29
29
  return;
30
30
  const { runWatchdogPass } = await import('../watchdog/service.js');
31
31
  const result = await runWatchdogPass({ nudge: true });
32
32
  ctx.log('INFO', `watchdog: ${result.counts.total} live, ${result.counts.stalled} stalled, ${result.counts.nudged} nudged`);
33
- emit('watchdog.action', {
33
+ await emitAsync('watchdog.action', {
34
34
  module: 'watchdog',
35
35
  total: result.counts.total,
36
36
  stalled: result.counts.stalled,
@@ -7,7 +7,7 @@
7
7
  * enabled without pulling in the whole daemon lifecycle.
8
8
  */
9
9
  /** Every service the daemon can host. IDs are kebab-case and stable. */
10
- export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'catchup' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'keychain-reap' | 'account-state' | 'account-auth' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state';
10
+ export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'catchup' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'self-update' | 'keychain-reap' | 'account-state' | 'account-auth' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state';
11
11
  /** Human-readable metadata for each service. */
12
12
  export interface DaemonServiceDef {
13
13
  id: DaemonServiceId;
@@ -47,6 +47,11 @@ export const DAEMON_SERVICES = [
47
47
  title: 'Self-heal registry',
48
48
  description: 'Repairs shims, PATH, shadowing, and resource drift on a schedule.',
49
49
  },
50
+ {
51
+ id: 'self-update',
52
+ title: 'Self-update',
53
+ description: 'Checks npm for a newer agents-cli, installs + verifies it, then exits so the OS supervisor relaunches onto the new code (PHNX-3695).',
54
+ },
50
55
  {
51
56
  id: 'keychain-reap',
52
57
  title: 'Keychain reap',
@@ -113,6 +113,16 @@ export declare function readFleetConfigDefaults(): Record<string, unknown>;
113
113
  export declare function readDeviceConfigValues(device: string): Record<string, unknown>;
114
114
  /** Get one config key's effective value and the layer that set it. */
115
115
  export declare function getConfigValue(name: string, opts?: ConfigTarget): ConfigEntry;
116
+ /**
117
+ * Async, non-blocking twin of {@link getConfigValue} for the daemon's tick paths
118
+ * (PHNX-3695) — e.g. the watchdog tick reading `watchdog.enabled` every ~3min.
119
+ * Same layer precedence (device doc → fleet defaults → default), but the
120
+ * per-device doc READ is async. The user/fleet layers go through `readMeta`,
121
+ * which serves from an mtime-stamped in-memory cache (≈ 2 stat syscalls on the
122
+ * hot path, no file read), and `ensureDeviceConfigMigrated` is a one-shot no-op
123
+ * after the first process-wide fold — neither blocks the loop meaningfully.
124
+ */
125
+ export declare function getConfigValueAsync(name: string, opts?: ConfigTarget): Promise<ConfigEntry>;
116
126
  /**
117
127
  * List every known key with its effective value and the layer that set it.
118
128
  *
@@ -250,7 +260,8 @@ export declare function assertDaemonEnabled(): void;
250
260
  * minutes. Read by the daemon's periodic tick and, as the fallback when a
251
261
  * caller omits `--idle-minutes`, by the `gc` IPC action.
252
262
  */
253
- export declare function resolveBrowserTaskIdleMs(): number | null;
263
+ /** Async so the daemon's browser-task-reap tick reads the config off the shared event loop (PHNX-3695). */
264
+ export declare function resolveBrowserTaskIdleMs(): Promise<number | null>;
254
265
  /**
255
266
  * Read the effective `agents.max-concurrent` cap for each named device (fleet
256
267
  * defaults layered under the per-device doc; no SSH). Devices without a cap
@@ -31,6 +31,7 @@
31
31
  * Unset always means today's behavior (the documented default).
32
32
  */
33
33
  import * as fs from 'fs';
34
+ import * as fsp from 'fs/promises';
34
35
  import * as path from 'path';
35
36
  import * as yaml from 'yaml';
36
37
  import { META_HEADER, getUserAgentsDir, readMeta, updateMeta, withMetaLock } from './state.js';
@@ -372,17 +373,8 @@ function deviceDocPath(device) {
372
373
  * file is a hard error — silently returning null would let the next write wipe
373
374
  * the device's routines/config (same contract as routine-activation's reader).
374
375
  */
375
- function readDeviceDoc(device) {
376
- const p = deviceDocPath(device);
377
- let raw;
378
- try {
379
- raw = fs.readFileSync(p, 'utf-8');
380
- }
381
- catch (err) {
382
- if (err && err.code === 'ENOENT')
383
- return null;
384
- throw err;
385
- }
376
+ /** Parse + validate a device doc's raw YAML. Shared by the sync and async readers. */
377
+ function parseDeviceDoc(raw, p) {
386
378
  const corrupted = (detail) => new Error(`Device config corrupted at ${p}: ${detail}. Inspect and restore from backup.`);
387
379
  let parsed;
388
380
  try {
@@ -402,6 +394,33 @@ function readDeviceDoc(device) {
402
394
  }
403
395
  return doc;
404
396
  }
397
+ function readDeviceDoc(device) {
398
+ const p = deviceDocPath(device);
399
+ let raw;
400
+ try {
401
+ raw = fs.readFileSync(p, 'utf-8');
402
+ }
403
+ catch (err) {
404
+ if (err && err.code === 'ENOENT')
405
+ return null;
406
+ throw err;
407
+ }
408
+ return parseDeviceDoc(raw, p);
409
+ }
410
+ /** Async twin of {@link readDeviceDoc} for the daemon's tick paths (PHNX-3695) — the device-doc read must not block the shared event loop. */
411
+ async function readDeviceDocAsync(device) {
412
+ const p = deviceDocPath(device);
413
+ let raw;
414
+ try {
415
+ raw = await fsp.readFile(p, 'utf-8');
416
+ }
417
+ catch (err) {
418
+ if (err && err.code === 'ENOENT')
419
+ return null;
420
+ throw err;
421
+ }
422
+ return parseDeviceDoc(raw, p);
423
+ }
405
424
  /** Write a device doc (atomic), preserving keys this module does not own
406
425
  * (`routines:`). A doc left empty is removed instead of leaving an empty
407
426
  * tracked file behind. */
@@ -476,6 +495,36 @@ export function getConfigValue(name, opts) {
476
495
  return { spec, value: fleetConfig[spec.yamlKey], source: 'fleet' };
477
496
  return { spec, value: undefined, source: 'default' };
478
497
  }
498
+ /**
499
+ * Async, non-blocking twin of {@link getConfigValue} for the daemon's tick paths
500
+ * (PHNX-3695) — e.g. the watchdog tick reading `watchdog.enabled` every ~3min.
501
+ * Same layer precedence (device doc → fleet defaults → default), but the
502
+ * per-device doc READ is async. The user/fleet layers go through `readMeta`,
503
+ * which serves from an mtime-stamped in-memory cache (≈ 2 stat syscalls on the
504
+ * hot path, no file read), and `ensureDeviceConfigMigrated` is a one-shot no-op
505
+ * after the first process-wide fold — neither blocks the loop meaningfully.
506
+ */
507
+ export async function getConfigValueAsync(name, opts) {
508
+ ensureDeviceConfigMigrated();
509
+ const spec = configKeySpec(name);
510
+ if (spec.scope === 'user') {
511
+ const value = readMeta().config?.[spec.yamlKey];
512
+ return { spec, value, source: value !== undefined ? 'user' : 'default' };
513
+ }
514
+ if (opts?.fleet) {
515
+ const value = readFleetConfigDefaults()[spec.yamlKey];
516
+ return { spec, value, source: value !== undefined ? 'fleet' : 'default' };
517
+ }
518
+ const device = targetDevice(opts);
519
+ assertLocalTarget(spec, device);
520
+ const docConfig = (await readDeviceDocAsync(device))?.config ?? {};
521
+ if (spec.yamlKey in docConfig)
522
+ return { spec, value: docConfig[spec.yamlKey], source: 'device' };
523
+ const fleetConfig = readFleetConfigDefaults();
524
+ if (spec.yamlKey in fleetConfig)
525
+ return { spec, value: fleetConfig[spec.yamlKey], source: 'fleet' };
526
+ return { spec, value: undefined, source: 'default' };
527
+ }
479
528
  /**
480
529
  * List every known key with its effective value and the layer that set it.
481
530
  *
@@ -844,8 +893,9 @@ export function assertDaemonEnabled() {
844
893
  * minutes. Read by the daemon's periodic tick and, as the fallback when a
845
894
  * caller omits `--idle-minutes`, by the `gc` IPC action.
846
895
  */
847
- export function resolveBrowserTaskIdleMs() {
848
- const minutes = getConfigValue('browser.task-idle-minutes').value ?? 30;
896
+ /** Async so the daemon's browser-task-reap tick reads the config off the shared event loop (PHNX-3695). */
897
+ export async function resolveBrowserTaskIdleMs() {
898
+ const minutes = (await getConfigValueAsync('browser.task-idle-minutes')).value ?? 30;
849
899
  return minutes === 0 ? null : minutes * 60_000;
850
900
  }
851
901
  /**
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Async, deadline-bounded, process-group-killable subprocess exec (PHNX-3695).
3
+ *
4
+ * The daemon runs every background service on ONE Node event loop. A
5
+ * synchronous `execFileSync`/`spawnSync` on a service tick blocks that loop for
6
+ * the whole life of the child — and while it is blocked NOTHING else on the loop
7
+ * runs, including the supervisor's per-tick deadline timer and the browser IPC
8
+ * server's socket handlers. That is the wedge PHNX-3411 fixed the *symptom* of:
9
+ * the browser `version` probe (a trivial synchronous handler) "accepts but never
10
+ * replies" because the loop that would reply is frozen inside a sync spawn.
11
+ *
12
+ * `execFileBounded` is the non-blocking replacement for those tick-path spawns.
13
+ * It spawns with async `child_process.spawn`, so the event loop keeps serving
14
+ * other work while the child runs, and it bounds the child two ways:
15
+ *
16
+ * - a `timeoutMs` deadline: on expiry it SIGTERMs the child, then SIGKILLs it
17
+ * after {@link KILL_GRACE_MS} if it ignored the term — the same escalation
18
+ * `sshExecAsync` uses (ssh-exec.ts).
19
+ * - a process GROUP kill: the child is spawned as its own group leader
20
+ * (`detached` on POSIX) so the signal reaches the whole subtree, not just the
21
+ * direct child. A bare `child.kill()` would leave grandchildren running — the
22
+ * orphaned-`ps`/`powershell` leak class. Windows uses `taskkill /T`.
23
+ *
24
+ * It never throws for a non-zero exit, a signal, or a spawn error: every outcome
25
+ * is reported in the returned {@link BoundedExecResult} so a caller on a tick
26
+ * path can branch on it instead of wrapping every call in try/catch.
27
+ */
28
+ /** Grace between SIGTERM and SIGKILL for a child that overran its deadline. */
29
+ export declare const KILL_GRACE_MS = 250;
30
+ export interface ExecFileBoundedOptions {
31
+ /** Hard wall-clock cap. On expiry the child's process group is SIGTERMed, then SIGKILLed after {@link KILL_GRACE_MS}. Required — an unbounded tick-path spawn is the bug this helper exists to prevent. */
32
+ timeoutMs: number;
33
+ /** Working directory for the child. */
34
+ cwd?: string;
35
+ /** Environment for the child (defaults to the current process env). */
36
+ env?: NodeJS.ProcessEnv;
37
+ /** Optional stdin to write to the child. */
38
+ input?: string;
39
+ }
40
+ export interface BoundedExecResult {
41
+ stdout: string;
42
+ stderr: string;
43
+ /** Exit code, or null when the process was killed by a signal (including our timeout kill) or never spawned. */
44
+ code: number | null;
45
+ /** True when the deadline elapsed and we killed the child, distinguishing a timeout from an ordinary non-zero exit. */
46
+ timedOut: boolean;
47
+ }
48
+ /**
49
+ * Run `file args` with a hard deadline, killing the whole process group on
50
+ * timeout. Resolves (never rejects) with the captured output and outcome.
51
+ */
52
+ export declare function execFileBounded(file: string, args: string[], opts: ExecFileBoundedOptions): Promise<BoundedExecResult>;