@phnx-labs/agents-cli 1.22.69 → 1.22.71

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 (130) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +34 -9
  3. package/dist/bootstrap.js +4 -4
  4. package/dist/commands/accounts.js +23 -7
  5. package/dist/commands/exec.js +2 -2
  6. package/dist/commands/import.js +2 -2
  7. package/dist/commands/models.js +2 -2
  8. package/dist/commands/permissions.js +2 -2
  9. package/dist/commands/repo.js +2 -2
  10. package/dist/commands/rules.js +1 -1
  11. package/dist/commands/send.js +9 -3
  12. package/dist/commands/sessions-export.d.ts +5 -1
  13. package/dist/commands/sessions-export.js +100 -24
  14. package/dist/commands/sessions-import.d.ts +2 -1
  15. package/dist/commands/sessions-import.js +85 -21
  16. package/dist/commands/traces.js +1 -1
  17. package/dist/lib/account-capabilities.js +0 -2
  18. package/dist/lib/account-registry.d.ts +3 -3
  19. package/dist/lib/account-registry.js +25 -7
  20. package/dist/lib/accounting/usage-sync.d.ts +1 -1
  21. package/dist/lib/accounting/usage-sync.js +3 -3
  22. package/dist/lib/acp/client.d.ts +1 -1
  23. package/dist/lib/acp/client.js +12 -1
  24. package/dist/lib/acp/harnesses.js +1 -1
  25. package/dist/lib/add-dir.js +0 -2
  26. package/dist/lib/agent-cli-commands.js +0 -2
  27. package/dist/lib/agent-spec/agents.d.ts +1 -1
  28. package/dist/lib/agent-spec/agents.js +2 -83
  29. package/dist/lib/browser/ipc.d.ts +34 -0
  30. package/dist/lib/browser/ipc.js +149 -20
  31. package/dist/lib/browser/remote-control.d.ts +6 -3
  32. package/dist/lib/browser/remote-control.js +6 -3
  33. package/dist/lib/browser/service.d.ts +4 -1
  34. package/dist/lib/browser/service.js +7 -1
  35. package/dist/lib/browser/types.d.ts +3 -1
  36. package/dist/lib/channels/registry.d.ts +6 -0
  37. package/dist/lib/channels/send.d.ts +11 -2
  38. package/dist/lib/channels/send.js +11 -2
  39. package/dist/lib/cloud/rush.d.ts +10 -2
  40. package/dist/lib/cloud/rush.js +15 -9
  41. package/dist/lib/daemon/auth-sync-service.js +1 -1
  42. package/dist/lib/daemon/browser-task-reap-service.js +1 -1
  43. package/dist/lib/daemon/daemon.js +41 -11
  44. package/dist/lib/daemon/heartbeat-service.js +3 -3
  45. package/dist/lib/daemon/keychain-reap-service.js +1 -1
  46. package/dist/lib/daemon/runner.d.ts +18 -1
  47. package/dist/lib/daemon/runner.js +237 -80
  48. package/dist/lib/daemon/self-heal-service.js +13 -3
  49. package/dist/lib/daemon/self-update-service.d.ts +174 -0
  50. package/dist/lib/daemon/self-update-service.js +353 -0
  51. package/dist/lib/daemon/state-dir-check-service.js +3 -3
  52. package/dist/lib/daemon/usage-sync-service.js +1 -1
  53. package/dist/lib/daemon/watchdog-service.js +4 -4
  54. package/dist/lib/daemon-services.d.ts +1 -1
  55. package/dist/lib/daemon-services.js +5 -0
  56. package/dist/lib/device-config.d.ts +12 -1
  57. package/dist/lib/device-config.js +63 -13
  58. package/dist/lib/exec-bounded.d.ts +52 -0
  59. package/dist/lib/exec-bounded.js +113 -0
  60. package/dist/lib/exec.d.ts +2 -2
  61. package/dist/lib/exec.js +3 -34
  62. package/dist/lib/feed/events.d.ts +22 -14
  63. package/dist/lib/feed/events.js +84 -44
  64. package/dist/lib/feed-broadcast.d.ts +12 -29
  65. package/dist/lib/feed-broadcast.js +28 -27
  66. package/dist/lib/fleet-shared-state.d.ts +12 -5
  67. package/dist/lib/fleet-shared-state.js +50 -20
  68. package/dist/lib/fs-atomic.d.ts +11 -0
  69. package/dist/lib/fs-atomic.js +60 -0
  70. package/dist/lib/hooks/install.js +0 -87
  71. package/dist/lib/hosts/reconcile.d.ts +11 -4
  72. package/dist/lib/hosts/reconcile.js +31 -5
  73. package/dist/lib/installations/strategies.js +1 -1
  74. package/dist/lib/mcp-registry.js +0 -13
  75. package/dist/lib/mcp.js +2 -2
  76. package/dist/lib/model-tiers.js +1 -1
  77. package/dist/lib/models.js +0 -63
  78. package/dist/lib/notify.d.ts +11 -0
  79. package/dist/lib/notify.js +17 -4
  80. package/dist/lib/owner-message.d.ts +46 -3
  81. package/dist/lib/owner-message.js +26 -6
  82. package/dist/lib/permissions-registry.d.ts +0 -2
  83. package/dist/lib/permissions-registry.js +3 -50
  84. package/dist/lib/permissions.d.ts +3 -17
  85. package/dist/lib/permissions.js +4 -73
  86. package/dist/lib/project-resources.d.ts +12 -0
  87. package/dist/lib/project-resources.js +129 -0
  88. package/dist/lib/routine-process-cleanup.d.ts +2 -2
  89. package/dist/lib/routine-process-cleanup.js +45 -34
  90. package/dist/lib/rush-session.d.ts +19 -0
  91. package/dist/lib/rush-session.js +24 -0
  92. package/dist/lib/secrets/drivers/rush.js +2 -1
  93. package/dist/lib/secrets/reaper.d.ts +2 -2
  94. package/dist/lib/secrets/reaper.js +13 -10
  95. package/dist/lib/secrets/reserved-sync.d.ts +1 -1
  96. package/dist/lib/secrets/reserved-sync.js +4 -4
  97. package/dist/lib/self-update.d.ts +21 -8
  98. package/dist/lib/self-update.js +54 -31
  99. package/dist/lib/session/cloud.js +2 -1
  100. package/dist/lib/session/sync/backend.d.ts +61 -0
  101. package/dist/lib/session/sync/backend.js +89 -0
  102. package/dist/lib/session/sync/managed-config.d.ts +29 -0
  103. package/dist/lib/session/sync/managed-config.js +23 -0
  104. package/dist/lib/session/sync/managed-key.d.ts +45 -0
  105. package/dist/lib/session/sync/managed-key.js +128 -0
  106. package/dist/lib/session/sync/net-client.d.ts +65 -0
  107. package/dist/lib/session/sync/net-client.js +117 -0
  108. package/dist/lib/session/sync/provision.d.ts +19 -0
  109. package/dist/lib/session/sync/provision.js +38 -0
  110. package/dist/lib/session/sync/r2.d.ts +5 -2
  111. package/dist/lib/session/sync/r2.js +5 -2
  112. package/dist/lib/session/sync/worker-template.d.ts +6 -0
  113. package/dist/lib/session/sync/worker-template.js +847 -0
  114. package/dist/lib/sink-format.d.ts +34 -0
  115. package/dist/lib/sink-format.js +17 -0
  116. package/dist/lib/staleness/detectors/permissions.js +0 -20
  117. package/dist/lib/staleness/writers/commands.js +1 -1
  118. package/dist/lib/staleness/writers/hooks.js +2 -2
  119. package/dist/lib/subagents-registry.js +2 -12
  120. package/dist/lib/subagents.d.ts +0 -10
  121. package/dist/lib/subagents.js +0 -37
  122. package/dist/lib/tmux/orphan-reap.js +6 -4
  123. package/dist/lib/tmux/session.js +4 -1
  124. package/dist/lib/traces/classify.d.ts +8 -1
  125. package/dist/lib/traces/insights.d.ts +13 -1
  126. package/dist/lib/traces/insights.js +78 -3
  127. package/dist/lib/traces/sync.js +8 -3
  128. package/dist/lib/traces/worker-template.js +9 -5
  129. package/dist/lib/types.d.ts +1 -1
  130. package/package.json +1 -1
@@ -16,7 +16,17 @@
16
16
  */
17
17
  import { BasePeriodicService } from './service.js';
18
18
  import { getDaemonDir } from '../state.js';
19
- import * as fs from 'fs';
19
+ import * as fsp from 'fs/promises';
20
+ /** Async `existsSync` — never a synchronous stat on the daemon tick loop (PHNX-3695). */
21
+ async function pathExists(p) {
22
+ try {
23
+ await fsp.access(p);
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
20
30
  /** Matches the historical inline interval (daemon.ts SELF_HEAL_TICK_MS). Runs ~every 6h. */
21
31
  const SELF_HEAL_TICK_MS = 6 * 60 * 60_000;
22
32
  /** Hard cap per tick — a full resource repair sweep across every version home, short enough a hang never freezes the service for long relative to its 6h cadence. */
@@ -35,10 +45,10 @@ export class SelfHealService extends BasePeriodicService {
35
45
  // Nothing to release — the supervisor's timer teardown is the only cleanup needed.
36
46
  }
37
47
  async onTick(ctx) {
38
- if (!fs.existsSync(getDaemonDir()))
48
+ if (!(await pathExists(getDaemonDir())))
39
49
  return;
40
50
  const { runSelfHeal, selfHealChangedAnything, selfHealNeedsAttention, summarizeSelfHeal } = await import('../self-heal/registry.js');
41
- if (!fs.existsSync(getDaemonDir()))
51
+ if (!(await pathExists(getDaemonDir())))
42
52
  return;
43
53
  const report = await runSelfHeal({ mode: 'safe' });
44
54
  if (selfHealChangedAnything(report) || selfHealNeedsAttention(report)) {
@@ -0,0 +1,174 @@
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, type DaemonContext } from './service.js';
37
+ import type { DaemonServiceId } from '../daemon-services.js';
38
+ /**
39
+ * Hard cap per tick: a real download + npm/bun install + verify can
40
+ * legitimately take minutes on a slow link. 15 minutes matches the task's
41
+ * stated budget and is short relative to the ~75min cadence. Exported so the
42
+ * on-demand `request-self-update` IPC handler (`browser/ipc.ts`) can bound
43
+ * its own `AbortController` on the SAME budget the periodic tick runs under —
44
+ * one deadline, not two independently-tuned numbers that could drift apart.
45
+ */
46
+ export declare const SELF_UPDATE_DEADLINE_MS: number;
47
+ export interface SelfUpdateOutcome {
48
+ updated: boolean;
49
+ reason?: string;
50
+ }
51
+ interface NpmLatestMetadata {
52
+ version: string;
53
+ integrity: string;
54
+ tarball: string;
55
+ }
56
+ /**
57
+ * Dependency seam so `self-update-service.test.ts` can drive a REAL install
58
+ * against a fixture npm prefix/tarball without touching the real npm
59
+ * registry or the real running install. Production code always uses
60
+ * {@link defaultSelfUpdateDeps} (the default parameter below) — no test-only
61
+ * branch exists in the exported logic itself.
62
+ */
63
+ export interface SelfUpdateDeps {
64
+ currentVersion(): string;
65
+ isDevBuild(): boolean;
66
+ detectShadow(): boolean;
67
+ packageRoot(): string;
68
+ fetchLatestMetadata(signal: AbortSignal): Promise<NpmLatestMetadata>;
69
+ installAndVerify(metadata: NpmLatestMetadata, packageRoot: string, signal: AbortSignal): Promise<void>;
70
+ syncSystemRepo(): Promise<void>;
71
+ syncLocal(): Promise<void>;
72
+ }
73
+ export declare function fetchLatestNpmMetadata(signal: AbortSignal): Promise<NpmLatestMetadata>;
74
+ /**
75
+ * Install + byte-verify `metadata` into `packageRoot`'s install, exactly the
76
+ * sequence `bootstrap.ts`'s `installResolvedPackage` runs for `agents
77
+ * upgrade` (download+integrity-verify -> sweep stale staging -> package-
78
+ * manager install -> verify installed version -> refresh alias shims).
79
+ * `bootstrap.ts` cannot be imported here — it runs side-effecting top-level
80
+ * code (argv parsing, command registration) on import, which the daemon must
81
+ * never trigger — so this is the same primitives from `self-update.ts`
82
+ * composed directly, not a fork of the upgrade logic.
83
+ *
84
+ * `signal` is threaded into EVERY step's own `signal` option
85
+ * (`downloadVerifiedTarball` / `installPackageIntoPrefix` /
86
+ * `installPackageWithBun`, all in `self-update.ts`) rather than raced against
87
+ * from the outside: those primitives kill the underlying fetch/child process
88
+ * on abort and their promise rejects only once that real cancellation has
89
+ * happened. A wrapper that merely stopped AWAITING an unkillable operation
90
+ * (the prior approach here) left an orphaned `npm install -g`/`bun add -g`
91
+ * writing into the shared global prefix — which the very next tick's fresh
92
+ * install (started as soon as the supervisor's backoff fires, seconds later)
93
+ * would then race into the same directory. Real cancellation is what makes
94
+ * `attemptSelfUpdateAndExit`'s `inFlightAttempt` dedupe (below) an actual
95
+ * guarantee instead of a guard whose lifetime is shorter than the operation
96
+ * it's guarding (found in review, PHNX-3695).
97
+ */
98
+ export declare function installAndVerifyDefault(metadata: NpmLatestMetadata, packageRoot: string, signal: AbortSignal): Promise<void>;
99
+ export declare function defaultSelfUpdateDeps(): SelfUpdateDeps;
100
+ /**
101
+ * Core self-update decision + action, shared by the periodic tick
102
+ * ({@link SelfUpdateService.onTick}) and the on-demand IPC path
103
+ * (`request-self-update`, `browser/ipc.ts`) — one implementation, so a
104
+ * version-skew client asking "update now" runs exactly the same fail-closed
105
+ * logic as the scheduled sweep. Returns rather than throws so callers decide
106
+ * their own exit timing (the periodic service exits immediately; the IPC
107
+ * handler must respond to the client on the socket BEFORE exiting, or the
108
+ * client hangs on a socket that is closing mid-write). Concurrent callers
109
+ * share one in-flight attempt rather than racing separate installs.
110
+ */
111
+ export declare function attemptSelfUpdateAndExit(ctx: DaemonContext, signal: AbortSignal, deps?: SelfUpdateDeps): Promise<SelfUpdateOutcome>;
112
+ /**
113
+ * Schedule the process exit for a verified self-update, exactly once, no
114
+ * matter how many callers observe `outcome.updated` on the shared
115
+ * `inFlightAttempt` promise. The periodic tick and an on-demand
116
+ * `request-self-update` IPC call (`browser/ipc.ts`) can both be awaiting that
117
+ * SAME promise — if the tick's continuation ran an immediate `process.exit(0)`
118
+ * while the IPC handler's continuation had not yet reached `socket.write`,
119
+ * the tick's exit could win the race and the client would see a closed socket
120
+ * before any response (found in review, PHNX-3695). Routing every caller
121
+ * through this one guarded, always-delayed scheduling point means the delay
122
+ * protects EVERY caller's in-flight response, not just the IPC handler's own.
123
+ */
124
+ export declare function scheduleSelfUpdateExit(): void;
125
+ /**
126
+ * Fire the on-demand self-update in the BACKGROUND and return its (bounded)
127
+ * promise WITHOUT the caller having to await it. This is what keeps the
128
+ * `request-self-update` IPC handler (`browser/ipc.ts`) from parking a
129
+ * version-skewed `agents browser` verb behind the full
130
+ * check→download→install→verify: that handler routes through
131
+ * `reconcileDaemonVersion` on every version-skewed call, so awaiting the whole
132
+ * install there reintroduces exactly the client-stall PHNX-3605 was written to
133
+ * prevent (tens of seconds, worst case ~15 min). The handler instead responds
134
+ * "triggered" immediately and lets this run in the background — the daemon does
135
+ * install→verify→exit(0) on its own, the OS supervisor relaunches it, and the
136
+ * browser reconnects.
137
+ *
138
+ * The work still shares the module-level {@link attemptSelfUpdateAndExit}
139
+ * `inFlightAttempt` guard, so a concurrent trigger (or the periodic tick) can't
140
+ * race a second install into the same prefix. It is bounded by
141
+ * {@link SELF_UPDATE_DEADLINE_MS} via an `AbortController` nobody awaits (the
142
+ * timer is `unref`'d so it never keeps the daemon alive on its own and never
143
+ * dangles in a test). Fail-closed is preserved end to end:
144
+ * `runSelfUpdateAttempt` already turns an install/verify failure into a
145
+ * not-updated outcome that leaves the running daemon untouched. The caller
146
+ * schedules the one decoupled {@link scheduleSelfUpdateExit} off the returned
147
+ * promise once `updated` is true, so the exit still fires after the IPC
148
+ * response has flushed.
149
+ */
150
+ export declare function triggerSelfUpdateInBackground(ctx: DaemonContext, deps?: SelfUpdateDeps): Promise<SelfUpdateOutcome>;
151
+ /**
152
+ * The subset of self-update decline checks that are INSTANT and network-free —
153
+ * a dev build, or a shadowed install. The on-demand IPC handler
154
+ * (`request-self-update`) runs these SYNCHRONOUSLY so a version-skewed browser
155
+ * client gets the PHNX-3605 "nothing changed, not evicting" advisory
156
+ * immediately, instead of a "triggered" it would never act on. The remaining
157
+ * checks (registry probe, already-current, install/verify) stay inside the
158
+ * backgrounded {@link attemptSelfUpdateAndExit} so the handler never blocks on
159
+ * the network or the install. This is the single source of the two instant
160
+ * decline reasons — {@link runSelfUpdateAttempt} calls it too, so the on-demand
161
+ * decline text can never drift from the periodic tick's. Returns the decline
162
+ * reason, or `null` to proceed to the (backgrounded) install path.
163
+ */
164
+ export declare function selfUpdateSyncDeclineReason(deps?: SelfUpdateDeps): string | null;
165
+ export declare class SelfUpdateService extends BasePeriodicService {
166
+ readonly id: DaemonServiceId;
167
+ readonly intervalMs: number;
168
+ readonly deadlineMs: number;
169
+ readonly startupDelayMs: number;
170
+ protected onStart(_ctx: DaemonContext): Promise<void>;
171
+ protected onStop(): Promise<void>;
172
+ protected onTick(ctx: DaemonContext, signal: AbortSignal): Promise<void>;
173
+ }
174
+ export {};
@@ -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',