@phnx-labs/agents-cli 1.22.77 → 1.22.79

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 (95) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +28 -1
  3. package/dist/bootstrap.js +40 -12
  4. package/dist/commands/accounts.d.ts +22 -0
  5. package/dist/commands/accounts.js +158 -35
  6. package/dist/commands/auth.js +4 -1
  7. package/dist/commands/config.js +37 -0
  8. package/dist/commands/exec.js +21 -10
  9. package/dist/commands/update.js +169 -18
  10. package/dist/commands/versions.d.ts +11 -0
  11. package/dist/commands/versions.js +30 -4
  12. package/dist/commands/view.d.ts +4 -0
  13. package/dist/commands/view.js +114 -19
  14. package/dist/index.js +35 -2
  15. package/dist/lib/account-catalog.d.ts +97 -1
  16. package/dist/lib/account-catalog.js +134 -6
  17. package/dist/lib/account-registry.d.ts +43 -0
  18. package/dist/lib/account-registry.js +97 -2
  19. package/dist/lib/accounting/rotate.d.ts +2 -2
  20. package/dist/lib/accounting/rotate.js +5 -5
  21. package/dist/lib/accounts/auth-operation-lock.d.ts +9 -0
  22. package/dist/lib/accounts/auth-operation-lock.js +55 -0
  23. package/dist/lib/accounts/connect.d.ts +170 -0
  24. package/dist/lib/accounts/connect.js +383 -0
  25. package/dist/lib/actor.d.ts +17 -0
  26. package/dist/lib/actor.js +26 -2
  27. package/dist/lib/capabilities.js +2 -0
  28. package/dist/lib/commands.js +2 -0
  29. package/dist/lib/config-keys.d.ts +11 -2
  30. package/dist/lib/config-keys.js +21 -1
  31. package/dist/lib/daemon/daemon.js +5 -0
  32. package/dist/lib/daemon/harness-update-service.d.ts +110 -0
  33. package/dist/lib/daemon/harness-update-service.js +216 -0
  34. package/dist/lib/daemon-services.d.ts +1 -1
  35. package/dist/lib/daemon-services.js +5 -0
  36. package/dist/lib/device-config.d.ts +0 -1
  37. package/dist/lib/device-config.js +50 -5
  38. package/dist/lib/exec.js +26 -2
  39. package/dist/lib/fs-atomic.d.ts +2 -0
  40. package/dist/lib/fs-atomic.js +2 -0
  41. package/dist/lib/hooks/install.js +7 -2
  42. package/dist/lib/identity/index.d.ts +10 -8
  43. package/dist/lib/identity/index.js +15 -11
  44. package/dist/lib/installations/active-check.d.ts +48 -0
  45. package/dist/lib/installations/active-check.js +84 -0
  46. package/dist/lib/installations/index.d.ts +5 -1
  47. package/dist/lib/installations/index.js +4 -0
  48. package/dist/lib/installations/installation-lock.d.ts +6 -0
  49. package/dist/lib/installations/installation-lock.js +29 -0
  50. package/dist/lib/installations/launch-gate.d.ts +69 -0
  51. package/dist/lib/installations/launch-gate.js +133 -0
  52. package/dist/lib/installations/native-command.d.ts +5 -0
  53. package/dist/lib/installations/native-command.js +52 -0
  54. package/dist/lib/installations/shims.d.ts +8 -2
  55. package/dist/lib/installations/shims.js +105 -2
  56. package/dist/lib/installations/store.d.ts +5 -1
  57. package/dist/lib/installations/store.js +24 -3
  58. package/dist/lib/installations/strategies.js +55 -35
  59. package/dist/lib/installations/types.d.ts +17 -0
  60. package/dist/lib/installations/update-cancellation.d.ts +82 -0
  61. package/dist/lib/installations/update-cancellation.js +122 -0
  62. package/dist/lib/installations/update-policy.d.ts +69 -0
  63. package/dist/lib/installations/update-policy.js +114 -0
  64. package/dist/lib/installations/update-runtime.d.ts +118 -0
  65. package/dist/lib/installations/update-runtime.js +321 -0
  66. package/dist/lib/installations/update.d.ts +25 -0
  67. package/dist/lib/installations/update.js +141 -2
  68. package/dist/lib/installations/versions.d.ts +1 -0
  69. package/dist/lib/installations/versions.js +166 -131
  70. package/dist/lib/platform/process.d.ts +3 -1
  71. package/dist/lib/platform/process.js +2 -2
  72. package/dist/lib/staleness/detectors/commands.d.ts +1 -2
  73. package/dist/lib/staleness/detectors/hooks.d.ts +1 -2
  74. package/dist/lib/staleness/detectors/mcp.d.ts +1 -2
  75. package/dist/lib/staleness/detectors/permissions.d.ts +1 -2
  76. package/dist/lib/staleness/detectors/plugins.d.ts +1 -7
  77. package/dist/lib/staleness/detectors/rules.d.ts +1 -2
  78. package/dist/lib/staleness/detectors/skills.d.ts +1 -2
  79. package/dist/lib/staleness/detectors/subagents.d.ts +1 -7
  80. package/dist/lib/staleness/detectors/workflows.d.ts +1 -2
  81. package/dist/lib/staleness/writers/commands.d.ts +1 -2
  82. package/dist/lib/staleness/writers/hooks.d.ts +1 -2
  83. package/dist/lib/staleness/writers/mcp.d.ts +1 -2
  84. package/dist/lib/staleness/writers/permissions.d.ts +1 -12
  85. package/dist/lib/staleness/writers/plugins.d.ts +1 -6
  86. package/dist/lib/staleness/writers/rules.d.ts +1 -2
  87. package/dist/lib/staleness/writers/skills.d.ts +1 -2
  88. package/dist/lib/staleness/writers/subagents.d.ts +1 -2
  89. package/dist/lib/staleness/writers/workflows.d.ts +1 -8
  90. package/dist/lib/state.d.ts +3 -1
  91. package/dist/lib/state.js +38 -13
  92. package/dist/lib/types.d.ts +26 -1
  93. package/dist/lib/types.js +5 -0
  94. package/dist/lib/view-types.d.ts +6 -0
  95. package/package.json +1 -1
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Daemon harness-update service (PHNX-3940).
3
+ *
4
+ * Runs the automatic-update pass (`installations/update-runtime.ts`) on a
5
+ * schedule so a managed, transactional npm harness (Claude, Codex, …) stays
6
+ * current with no operator action, subject to the `updates.auto` /
7
+ * `updates.<agent>.auto` switches and each installation's own update policy.
8
+ *
9
+ * The pass itself does real, synchronous filesystem work per installation —
10
+ * staging an npm install into a sibling directory, `fs.renameSync`/`fs.cpSync`
11
+ * swaps, `fs.rmSync` cleanup — potentially across several installations in one
12
+ * tick. Running that inline on the daemon's own event loop would stall every
13
+ * other service (secrets broker, browser IPC, the scheduler) for the
14
+ * duration, the same class of problem `self-update-service.ts` solves for the
15
+ * CLI's OWN upgrade by installing into a fresh process it then exits into.
16
+ * Here there is no "exit and let the supervisor restart" option (the daemon
17
+ * itself isn't what's being updated), so instead this tick SPAWNS a bounded
18
+ * child (`agents __harness-update-run`) over a Node IPC channel and only waits
19
+ * on that child — every sync fs call happens in the child's own event loop /
20
+ * thread pool, never this one.
21
+ *
22
+ * Cancellation is COOPERATIVE and cross-platform (PHNX-3940). On the tick's
23
+ * deadline or daemon shutdown — both delivered as the supervisor aborting the
24
+ * tick's `AbortSignal` — the daemon does NOT force-kill the child (execFile's
25
+ * `timeout`/`signal` would, unconditionally on Windows, mid-swap). It SENDS the
26
+ * child an IPC cancel message; the child stops at its next safe boundary (see
27
+ * `installations/update-cancellation.ts` + `update.ts`'s `shouldCancel`) and
28
+ * exits on its own, and the daemon waits for that TRUE exit. A wedged child that
29
+ * ignores the request past a generous grace — never the normal path, since the
30
+ * child's own npm/probe work is bounded — is force-reaped only as an
31
+ * orphan-prevention backstop, and that abnormal case is reported as a failure,
32
+ * never as a clean pass.
33
+ */
34
+ import { BasePeriodicService, type DaemonContext } from './service.js';
35
+ import type { DaemonServiceId } from '../daemon-services.js';
36
+ /**
37
+ * Cancellation deadline per tick. A real pass can touch several installations, each an npm
38
+ * install (`INSTALL_TIMEOUT_MS` = 120s in strategies.ts) plus two launch
39
+ * probes; 10 minutes leaves headroom for a handful of harnesses in one pass
40
+ * while staying comfortably under the 15-minute cadence above.
41
+ */
42
+ export declare const HARNESS_UPDATE_DEADLINE_MS: number;
43
+ /**
44
+ * How long to wait for the child to exit AFTER a cooperative cancel before
45
+ * force-reaping it as an orphan backstop. The child's own work is bounded — one
46
+ * npm install (`INSTALL_TIMEOUT_MS` = 120s in strategies.ts) plus two launch
47
+ * probes, then a fast synchronous swap — so a healthy child stops well within
48
+ * this window. A slow staging/postinstall can also exhaust it, but a delivered
49
+ * cancel prevents that stage from committing. The backstop kills the process
50
+ * group on POSIX and the worker on Windows; the tick reports a failure.
51
+ */
52
+ export declare const HARNESS_UPDATE_CANCEL_GRACE_MS: number;
53
+ export interface HarnessUpdateOutcome {
54
+ ran: boolean;
55
+ reason?: string;
56
+ exitCode?: number | null;
57
+ stdout?: string;
58
+ /** True when the pass was asked to stop (deadline/shutdown) and did so cooperatively. */
59
+ cancelled?: boolean;
60
+ }
61
+ export interface CooperativeChildResult {
62
+ exitCode: number | null;
63
+ stdout: string;
64
+ /** True when a cancel was requested during this child's life (deadline/shutdown). */
65
+ cancelled: boolean;
66
+ }
67
+ /**
68
+ * Dependency seam so tests can assert the tick's decision/spawn logic without
69
+ * actually installing anything. Production always uses
70
+ * {@link defaultHarnessUpdateDeps}.
71
+ */
72
+ export interface HarnessUpdateDeps {
73
+ runAutoUpdatePass(signal: AbortSignal): Promise<CooperativeChildResult>;
74
+ }
75
+ export declare function defaultHarnessUpdateDeps(): HarnessUpdateDeps;
76
+ /**
77
+ * Spawn `command args` as a child over a Node IPC channel and drive it to a TRUE
78
+ * completion, requesting a cooperative stop (never a kill) when `signal` aborts —
79
+ * the supervisor aborts it on the tick's deadline OR on daemon shutdown.
80
+ *
81
+ * Resolves with the child's real exit code and captured output. Rejects only on:
82
+ * - a spawn failure (missing binary) — a genuine service failure; and
83
+ * - the child having to be force-reaped past the grace window, or dying to an
84
+ * external signal — never a clean pass, so the tick logs it as ERROR instead
85
+ * of reading a killed process as a completed update.
86
+ *
87
+ * A non-zero exit with no kill is resolved (not rejected): the pass exits
88
+ * non-zero for a per-installation vendor error, which is a normal tick outcome.
89
+ *
90
+ * Exported for the real-subprocess cancellation tests.
91
+ */
92
+ export declare function driveCooperativeChild(command: string, args: string[], signal: AbortSignal, graceMs: number, spawnOpts?: {
93
+ env?: NodeJS.ProcessEnv;
94
+ cwd?: string;
95
+ }): Promise<CooperativeChildResult>;
96
+ /**
97
+ * Core decision + spawn, shared so a future on-demand trigger (mirroring
98
+ * `triggerSelfUpdateInBackground`) can reuse it. Returns rather than throws —
99
+ * the periodic tick logs the outcome and moves on either way.
100
+ */
101
+ export declare function runHarnessUpdateTick(ctx: DaemonContext, signal: AbortSignal, deps?: HarnessUpdateDeps): Promise<HarnessUpdateOutcome>;
102
+ export declare class HarnessUpdateService extends BasePeriodicService {
103
+ readonly id: DaemonServiceId;
104
+ readonly intervalMs: number;
105
+ readonly deadlineMs: number;
106
+ readonly startupDelayMs = 60000;
107
+ protected onStart(_ctx: DaemonContext): Promise<void>;
108
+ protected onStop(): Promise<void>;
109
+ protected onTick(ctx: DaemonContext, signal: AbortSignal): Promise<void>;
110
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Daemon harness-update service (PHNX-3940).
3
+ *
4
+ * Runs the automatic-update pass (`installations/update-runtime.ts`) on a
5
+ * schedule so a managed, transactional npm harness (Claude, Codex, …) stays
6
+ * current with no operator action, subject to the `updates.auto` /
7
+ * `updates.<agent>.auto` switches and each installation's own update policy.
8
+ *
9
+ * The pass itself does real, synchronous filesystem work per installation —
10
+ * staging an npm install into a sibling directory, `fs.renameSync`/`fs.cpSync`
11
+ * swaps, `fs.rmSync` cleanup — potentially across several installations in one
12
+ * tick. Running that inline on the daemon's own event loop would stall every
13
+ * other service (secrets broker, browser IPC, the scheduler) for the
14
+ * duration, the same class of problem `self-update-service.ts` solves for the
15
+ * CLI's OWN upgrade by installing into a fresh process it then exits into.
16
+ * Here there is no "exit and let the supervisor restart" option (the daemon
17
+ * itself isn't what's being updated), so instead this tick SPAWNS a bounded
18
+ * child (`agents __harness-update-run`) over a Node IPC channel and only waits
19
+ * on that child — every sync fs call happens in the child's own event loop /
20
+ * thread pool, never this one.
21
+ *
22
+ * Cancellation is COOPERATIVE and cross-platform (PHNX-3940). On the tick's
23
+ * deadline or daemon shutdown — both delivered as the supervisor aborting the
24
+ * tick's `AbortSignal` — the daemon does NOT force-kill the child (execFile's
25
+ * `timeout`/`signal` would, unconditionally on Windows, mid-swap). It SENDS the
26
+ * child an IPC cancel message; the child stops at its next safe boundary (see
27
+ * `installations/update-cancellation.ts` + `update.ts`'s `shouldCancel`) and
28
+ * exits on its own, and the daemon waits for that TRUE exit. A wedged child that
29
+ * ignores the request past a generous grace — never the normal path, since the
30
+ * child's own npm/probe work is bounded — is force-reaped only as an
31
+ * orphan-prevention backstop, and that abnormal case is reported as a failure,
32
+ * never as a clean pass.
33
+ */
34
+ import { spawn } from 'child_process';
35
+ import { BasePeriodicService } from './service.js';
36
+ import { getCliLaunch, getAgentsBinPath } from '../cli-entry.js';
37
+ import { HARNESS_UPDATE_CHILD_CMD, cancelMessage } from '../installations/update-cancellation.js';
38
+ /** Runs every 15 minutes — a design decision, not an externally-fixed cadence (PHNX-3940). */
39
+ const HARNESS_UPDATE_TICK_MS = 15 * 60_000;
40
+ /**
41
+ * Cancellation deadline per tick. A real pass can touch several installations, each an npm
42
+ * install (`INSTALL_TIMEOUT_MS` = 120s in strategies.ts) plus two launch
43
+ * probes; 10 minutes leaves headroom for a handful of harnesses in one pass
44
+ * while staying comfortably under the 15-minute cadence above.
45
+ */
46
+ export const HARNESS_UPDATE_DEADLINE_MS = 10 * 60_000;
47
+ /** First tick fires 60 seconds after daemon boot — long enough for shims/PATH to settle, short enough that a fresh box doesn't wait a full interval for its first check. */
48
+ const HARNESS_UPDATE_STARTUP_DELAY_MS = 60_000;
49
+ /**
50
+ * How long to wait for the child to exit AFTER a cooperative cancel before
51
+ * force-reaping it as an orphan backstop. The child's own work is bounded — one
52
+ * npm install (`INSTALL_TIMEOUT_MS` = 120s in strategies.ts) plus two launch
53
+ * probes, then a fast synchronous swap — so a healthy child stops well within
54
+ * this window. A slow staging/postinstall can also exhaust it, but a delivered
55
+ * cancel prevents that stage from committing. The backstop kills the process
56
+ * group on POSIX and the worker on Windows; the tick reports a failure.
57
+ */
58
+ export const HARNESS_UPDATE_CANCEL_GRACE_MS = 3 * 60_000;
59
+ export function defaultHarnessUpdateDeps() {
60
+ return {
61
+ runAutoUpdatePass(signal) {
62
+ const { command, args } = getCliLaunch([HARNESS_UPDATE_CHILD_CMD], getAgentsBinPath());
63
+ return driveCooperativeChild(command, args, signal, HARNESS_UPDATE_CANCEL_GRACE_MS);
64
+ },
65
+ };
66
+ }
67
+ /**
68
+ * Spawn `command args` as a child over a Node IPC channel and drive it to a TRUE
69
+ * completion, requesting a cooperative stop (never a kill) when `signal` aborts —
70
+ * the supervisor aborts it on the tick's deadline OR on daemon shutdown.
71
+ *
72
+ * Resolves with the child's real exit code and captured output. Rejects only on:
73
+ * - a spawn failure (missing binary) — a genuine service failure; and
74
+ * - the child having to be force-reaped past the grace window, or dying to an
75
+ * external signal — never a clean pass, so the tick logs it as ERROR instead
76
+ * of reading a killed process as a completed update.
77
+ *
78
+ * A non-zero exit with no kill is resolved (not rejected): the pass exits
79
+ * non-zero for a per-installation vendor error, which is a normal tick outcome.
80
+ *
81
+ * Exported for the real-subprocess cancellation tests.
82
+ */
83
+ export function driveCooperativeChild(command, args, signal, graceMs, spawnOpts = {}) {
84
+ return new Promise((resolve, reject) => {
85
+ let child;
86
+ try {
87
+ child = spawn(command, args, {
88
+ // stdio[3] = 'ipc' is the cancel channel. Detached on POSIX so the child
89
+ // leads its own process group and the backstop below can reap its whole
90
+ // npm subtree; the parent still waits on it (never unref'd).
91
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
92
+ detached: process.platform !== 'win32',
93
+ windowsHide: true,
94
+ env: spawnOpts.env,
95
+ cwd: spawnOpts.cwd,
96
+ });
97
+ }
98
+ catch (err) {
99
+ reject(err instanceof Error ? err : new Error(String(err)));
100
+ return;
101
+ }
102
+ const MAX = 16 * 1024 * 1024;
103
+ let stdout = '';
104
+ let stderr = '';
105
+ let settled = false;
106
+ let cancelRequested = false;
107
+ let forceReaped = false;
108
+ let graceTimer;
109
+ const cleanup = () => {
110
+ if (graceTimer)
111
+ clearTimeout(graceTimer);
112
+ signal.removeEventListener('abort', requestCancel);
113
+ };
114
+ function requestCancel() {
115
+ if (cancelRequested || settled)
116
+ return;
117
+ cancelRequested = true;
118
+ // Cooperative: a message the child reads at its next safe boundary. NEVER a
119
+ // signal — that would interrupt a swap (and is fatal on Windows, which has
120
+ // no cooperative SIGTERM). If the channel is already gone the child's own
121
+ // `disconnect` handler cancels it, so a failed send is not an error.
122
+ try {
123
+ child.send(cancelMessage(), () => { });
124
+ }
125
+ catch { /* channel closed; disconnect handles it */ }
126
+ graceTimer = setTimeout(() => {
127
+ if (settled)
128
+ return;
129
+ forceReaped = true;
130
+ try {
131
+ if (process.platform !== 'win32' && typeof child.pid === 'number')
132
+ process.kill(-child.pid, 'SIGKILL');
133
+ else
134
+ child.kill('SIGKILL');
135
+ }
136
+ catch {
137
+ try {
138
+ child.kill('SIGKILL');
139
+ }
140
+ catch { /* already gone */ }
141
+ }
142
+ }, graceMs);
143
+ graceTimer.unref?.();
144
+ }
145
+ if (signal.aborted)
146
+ requestCancel();
147
+ else
148
+ signal.addEventListener('abort', requestCancel, { once: true });
149
+ child.stdout?.on('data', (d) => { if (stdout.length < MAX)
150
+ stdout += d.toString(); });
151
+ child.stderr?.on('data', (d) => { if (stderr.length < MAX)
152
+ stderr += d.toString(); });
153
+ child.on('error', (err) => {
154
+ if (settled)
155
+ return;
156
+ settled = true;
157
+ cleanup();
158
+ reject(err);
159
+ });
160
+ child.on('close', (code, sigName) => {
161
+ if (settled)
162
+ return;
163
+ settled = true;
164
+ cleanup();
165
+ if (forceReaped || sigName) {
166
+ reject(new Error(`harness-update child did not exit cooperatively after cancel `
167
+ + `(${forceReaped ? `force-reaped after ${graceMs}ms grace` : `died to ${sigName}`}). `
168
+ + (stderr.slice(0, 500) || stdout.slice(0, 500) || 'no output')));
169
+ return;
170
+ }
171
+ resolve({ exitCode: code, stdout: stdout || stderr, cancelled: cancelRequested });
172
+ });
173
+ });
174
+ }
175
+ /**
176
+ * Core decision + spawn, shared so a future on-demand trigger (mirroring
177
+ * `triggerSelfUpdateInBackground`) can reuse it. Returns rather than throws —
178
+ * the periodic tick logs the outcome and moves on either way.
179
+ */
180
+ export async function runHarnessUpdateTick(ctx, signal, deps = defaultHarnessUpdateDeps()) {
181
+ try {
182
+ const { exitCode, stdout, cancelled } = await deps.runAutoUpdatePass(signal);
183
+ if (exitCode !== 0) {
184
+ ctx.log('WARN', `harness-update: pass exited ${exitCode} — see stdout for per-installation errors: ${stdout.slice(0, 2000)}`);
185
+ }
186
+ else if (cancelled) {
187
+ ctx.log('INFO', 'harness-update: pass cancelled at a safe boundary (deadline or daemon shutdown); no work left half-done');
188
+ }
189
+ else {
190
+ ctx.log('INFO', 'harness-update: pass completed');
191
+ }
192
+ return { ran: true, exitCode, stdout, cancelled };
193
+ }
194
+ catch (err) {
195
+ const message = err instanceof Error ? err.message : String(err);
196
+ ctx.log('ERROR', `harness-update: pass failed to run: ${message}`);
197
+ return { ran: false, reason: message };
198
+ }
199
+ }
200
+ export class HarnessUpdateService extends BasePeriodicService {
201
+ id = 'harness-update';
202
+ intervalMs = HARNESS_UPDATE_TICK_MS;
203
+ deadlineMs = HARNESS_UPDATE_DEADLINE_MS;
204
+ startupDelayMs = HARNESS_UPDATE_STARTUP_DELAY_MS;
205
+ async onStart(_ctx) {
206
+ // No connections/handles to open — each tick spawns its own bounded child.
207
+ }
208
+ async onStop() {
209
+ // The in-flight child (if any) is asked to stop COOPERATIVELY: the supervisor
210
+ // aborts `signal` on the current tick, which `driveCooperativeChild` turns
211
+ // into an IPC cancel message, never a kill. Nothing else to release here.
212
+ }
213
+ async onTick(ctx, signal) {
214
+ await runHarnessUpdateTick(ctx, signal);
215
+ }
216
+ }
@@ -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' | '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' | 'session-summarizer';
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' | 'session-summarizer' | 'harness-update';
11
11
  /** Human-readable metadata for each service. */
12
12
  export interface DaemonServiceDef {
13
13
  id: DaemonServiceId;
@@ -122,6 +122,11 @@ export const DAEMON_SERVICES = [
122
122
  title: 'Usage snapshot sync',
123
123
  description: 'Bidirectional per-account usage sync: a headed personal/desktop box pushes its snapshot to worker peers, and a worker with a stale cache pulls from the primary — so workers that cannot read the endpoint themselves still route by real capacity.',
124
124
  },
125
+ {
126
+ id: 'harness-update',
127
+ title: 'Harness auto-update',
128
+ description: 'Moves eligible, non-pinned npm-package harness installations (Claude, Codex, …) to their latest release via a bounded child process, subject to updates.auto / updates.<agent>.auto and each installation\'s own update policy (PHNX-3940).',
129
+ },
125
130
  ];
126
131
  /** Stable order of service IDs. */
127
132
  export const DAEMON_SERVICE_IDS = DAEMON_SERVICES.map((s) => s.id);
@@ -91,7 +91,6 @@ declare const DEVICE_ROLES: readonly ["worker", "personal", "desktop"];
91
91
  /** Which devices automatic placement may pick — see the `auto.pool` key below. */
92
92
  declare const AUTO_POOL_MODES: readonly ["workers", "all"];
93
93
  export declare const CONFIG_KEYS: readonly ConfigKeySpec[];
94
- /** Look up a key spec by CLI dotted name, or throw listing the known keys. */
95
94
  export declare function configKeySpec(name: string): ConfigKeySpec;
96
95
  /**
97
96
  * Fold the legacy config/pins stores into the current layout, once per
@@ -39,6 +39,7 @@ import { atomicWriteFileSync } from './fs-atomic.js';
39
39
  import { machineId } from './machine-id.js';
40
40
  import { assertValidDeviceName, assertRegistrableDeviceName } from './devices/registry.js';
41
41
  import { migrateDeviceConfigStores } from './devices/config-migration.js';
42
+ import { isAgentId } from './types.js';
42
43
  const DEVICE_PLATFORMS = ['windows', 'linux', 'macos', 'unknown'];
43
44
  const SSH_AUTH_METHODS = ['key', 'password'];
44
45
  /** Roles a device can be marked with — see the `role` key below. */
@@ -100,6 +101,18 @@ export const CONFIG_KEYS = [
100
101
  type: 'string',
101
102
  description: 'Model the summarizer requests from summarizer.baseUrl, e.g. qwen2.5:3b. Overridden per-process by AGENTS_SUMMARIZER_MODEL.',
102
103
  },
104
+ {
105
+ name: 'updates.auto',
106
+ yamlKey: 'updatesAuto',
107
+ scope: 'user',
108
+ type: 'bool',
109
+ defaultValue: true,
110
+ description: 'Whether the daemon\'s automatic-update pass (PHNX-3940) may move any safely-transactional npm harness ' +
111
+ '(Claude, Codex, …) to its latest release with no operator action. This is the fleet-wide KILL SWITCH: ' +
112
+ 'off, no harness auto-updates regardless of any updates.<agent>.auto override. On (the default), each ' +
113
+ "harness's own updates.<agent>.auto refines it. Per-installation pins (agents update … --to <release>) " +
114
+ 'apply on top of this either way.',
115
+ },
103
116
  {
104
117
  name: 'browser.viewer',
105
118
  yamlKey: 'browserViewer',
@@ -333,8 +346,31 @@ export const CONFIG_KEYS = [
333
346
  },
334
347
  ];
335
348
  /** Look up a key spec by CLI dotted name, or throw listing the known keys. */
349
+ /**
350
+ * Per-harness override of `updates.auto` (PHNX-3940) — `updates.<agent>.auto`,
351
+ * one boolean key per registered agent id. Use the lightweight canonical ID
352
+ * catalog: importing the installation/runtime registry here creates a cycle
353
+ * through the harness adapters and makes ordinary config readers load it all.
354
+ */
355
+ function dynamicAgentAutoUpdateSpec(name) {
356
+ const match = name.match(/^updates\.(.+)\.auto$/);
357
+ if (!match)
358
+ return null;
359
+ const agent = match[1];
360
+ if (!isAgentId(agent))
361
+ return null;
362
+ return {
363
+ name,
364
+ yamlKey: `updatesAgentAuto.${agent}`,
365
+ scope: 'user',
366
+ type: 'bool',
367
+ description: `Per-harness override of updates.auto for ${agent}. Only takes effect while ` +
368
+ 'updates.auto is on (the global switch is a hard kill switch, not a default this can override). Unset ' +
369
+ 'defers to updates.auto.',
370
+ };
371
+ }
336
372
  export function configKeySpec(name) {
337
- const spec = CONFIG_KEYS.find((k) => k.name === name);
373
+ const spec = CONFIG_KEYS.find((k) => k.name === name) ?? dynamicAgentAutoUpdateSpec(name);
338
374
  if (!spec) {
339
375
  throw new Error(`Unknown config key '${name}'. Known keys: ${CONFIG_KEYS.map((k) => k.name).join(', ')}.`);
340
376
  }
@@ -500,12 +536,19 @@ function assertLocalTarget(spec, device) {
500
536
  }
501
537
  /** Get one config key's effective value and the layer that set it. */
502
538
  export function getConfigValue(name, opts) {
503
- ensureDeviceConfigMigrated();
504
539
  const spec = configKeySpec(name);
505
540
  if (spec.scope === 'user') {
506
- const value = readMeta().config?.[spec.yamlKey];
541
+ // A user-scope key reads purely from central `agents.yaml` (readMeta) and
542
+ // is untouched by the device-config fold, which only relocates DEVICE-scope
543
+ // legacy stores (config-migration.ts) — none of them a user key. So a
544
+ // user-scope read stays a PURE read and MUST NOT trigger the migration
545
+ // write: a read-only `agents update --check` reads the `updates.auto` /
546
+ // `updates.<agent>.auto` policy through here, and migrating disk on that
547
+ // read is exactly the unsolicited startup write --check must not do.
548
+ const value = readMeta({ migrate: false }).config?.[spec.yamlKey];
507
549
  return { spec, value, source: value !== undefined ? 'user' : 'default' };
508
550
  }
551
+ ensureDeviceConfigMigrated();
509
552
  if (opts?.fleet) {
510
553
  const value = readFleetConfigDefaults()[spec.yamlKey];
511
554
  return { spec, value, source: value !== undefined ? 'fleet' : 'default' };
@@ -530,12 +573,14 @@ export function getConfigValue(name, opts) {
530
573
  * after the first process-wide fold — neither blocks the loop meaningfully.
531
574
  */
532
575
  export async function getConfigValueAsync(name, opts) {
533
- ensureDeviceConfigMigrated();
534
576
  const spec = configKeySpec(name);
535
577
  if (spec.scope === 'user') {
536
- const value = readMeta().config?.[spec.yamlKey];
578
+ // Pure user-scope read — same reasoning as the sync twin: no device-config
579
+ // fold, so a user read never migrates disk.
580
+ const value = readMeta({ migrate: false }).config?.[spec.yamlKey];
537
581
  return { spec, value, source: value !== undefined ? 'user' : 'default' };
538
582
  }
583
+ ensureDeviceConfigMigrated();
539
584
  if (opts?.fleet) {
540
585
  const value = readFleetConfigDefaults()[spec.yamlKey];
541
586
  return { spec, value, source: value !== undefined ? 'fleet' : 'default' };
package/dist/lib/exec.js CHANGED
@@ -20,6 +20,9 @@ import { resolveActor, actorEnv } from './actor.js';
20
20
  import { expandLocalHome } from './project-root.js';
21
21
  import { getShimsDir, getHistoryDir, getRuntimeStateDir } from './state.js';
22
22
  import { readCodexConfiguredModel } from './installations/shims.js';
23
+ import { withInstallationLease } from './installations/launch-gate.js';
24
+ import { getCliLaunch, getAgentsBinPath } from './cli-entry.js';
25
+ import { installedReleaseFor } from './installations/store.js';
23
26
  import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
24
27
  import { writeSessionActorRecord, writeSessionAliasRecord } from './session/actor-sidecar.js';
25
28
  import { loadHookSessionIndex, resolveHookSessionId } from './session/hook-sessions.js';
@@ -706,7 +709,7 @@ export function nativeResume(agent, version) {
706
709
  return false;
707
710
  if (!resume.since)
708
711
  return true;
709
- return !!version && compareVersions(version, resume.since) >= 0;
712
+ return !!version && compareVersions(installedReleaseFor(agent, version), resume.since) >= 0;
710
713
  }
711
714
  /**
712
715
  * Build the `-c` value that adds `dir` to codex's workspace-write writable
@@ -1111,6 +1114,12 @@ export function resolveShimSpawn(platform, binary, extraArgs) {
1111
1114
  * keeping version resolution in one place instead of reimplementing it in batch.
1112
1115
  */
1113
1116
  export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
1117
+ const version = pinnedVersion ?? resolveVersion(agent, cwd) ?? undefined;
1118
+ if (!version || !isVersionInstalled(agent, version))
1119
+ return execShimPassthroughLeased(agent, rawArgs, cwd, version);
1120
+ return withInstallationLease(agent, version, () => execShimPassthroughLeased(agent, rawArgs, cwd, version));
1121
+ }
1122
+ async function execShimPassthroughLeased(agent, rawArgs, cwd, pinnedVersion) {
1114
1123
  const version = pinnedVersion ?? resolveVersion(agent, cwd) ?? undefined;
1115
1124
  if (!version || !isVersionInstalled(agent, version)) {
1116
1125
  process.stderr.write(`agents: no installed default for ${agent}. Set one with: agents use ${agent} <version>\n`);
@@ -1153,6 +1162,8 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
1153
1162
  // signed-in verdict from the pinned version home (no rotated pick here).
1154
1163
  await emitRunLaunch({ agent, version, resolvedVia: 'shim' });
1155
1164
  return new Promise((resolve) => {
1165
+ // Register listeners in the same synchronous turn as spawn: awaiting a lock
1166
+ // release after spawn can miss a fast child's exit/error event.
1156
1167
  const child = spawn(command, args, { cwd, stdio: 'inherit', env, shell });
1157
1168
  // Record the launch so `ag sessions --active` can attribute the agent
1158
1169
  // process to its cwd (and exact session when the caller passed
@@ -1507,7 +1518,14 @@ async function runInTmux(options, executable, args) {
1507
1518
  // which never carried real values anyway (RUSH-1758).
1508
1519
  const envFile = path.join(getRuntimeStateDir(), 'tmux-env', `${name}-${randomUUID().slice(0, 8)}.env`);
1509
1520
  writeTmuxEnvFile(execEnv, envFile);
1510
- const cmd = buildTmuxAgentCommand(executable, args, execEnv, { envFile });
1521
+ let cmd = buildTmuxAgentCommand(executable, args, execEnv, { envFile });
1522
+ const leaseVersion = options.version ?? resolveVersion(options.agent, cwd);
1523
+ if (leaseVersion && isVersionInstalled(options.agent, leaseVersion)) {
1524
+ const leaseCli = getCliLaunch(['__launch-lease', options.agent, leaseVersion], getAgentsBinPath());
1525
+ // The pane outlives a detached launcher. Lease its own exec-replaced shell
1526
+ // before launching, so detaching cannot open an unprotected launch gap.
1527
+ cmd = `${[leaseCli.command, ...leaseCli.args].map(shellQuote).join(' ')} "$$" || exit 1; ${cmd}`;
1528
+ }
1511
1529
  const metaCmd = buildTmuxAgentCommand(executable, args, execEnv, { redactEnvValues: true });
1512
1530
  const labels = { agent: options.agent };
1513
1531
  // Only publish an id the harness actually received. createSession turns this
@@ -1719,6 +1737,12 @@ async function emitRunLaunch(ctx) {
1719
1737
  }
1720
1738
  }
1721
1739
  async function spawnAgent(options) {
1740
+ const version = options.version ?? resolveVersion(options.agent, options.cwd || process.cwd());
1741
+ if (!version || !isVersionInstalled(options.agent, version))
1742
+ return spawnAgentLeased(options);
1743
+ return withInstallationLease(options.agent, version, () => spawnAgentLeased(options));
1744
+ }
1745
+ async function spawnAgentLeased(options) {
1722
1746
  bootMark('spawn-agent:enter');
1723
1747
  // Assign a known session id up front for agents that accept one, so the
1724
1748
  // launcher can record an EXACT pid -> session mapping (see pid-registry) —
@@ -40,6 +40,8 @@ export declare function atomicWriteJsonSync(filePath: string, data: unknown): vo
40
40
  export interface FileLockOptions {
41
41
  staleMs?: number;
42
42
  acquireTimeoutMs?: number;
43
+ /** Lock a canonical absolute path before its file exists (e.g. a new installation record). */
44
+ realpath?: boolean;
43
45
  }
44
46
  export declare function withFileLock<T>(filePath: string, fn: (heartbeat: () => void) => T, opts?: FileLockOptions): T;
45
47
  /**
@@ -79,6 +79,7 @@ export function withFileLock(filePath, fn, opts = {}) {
79
79
  try {
80
80
  release = lockfile.lockSync(filePath, {
81
81
  stale: staleMs,
82
+ realpath: opts.realpath ?? true,
82
83
  onCompromised: (err) => { compromised = err; },
83
84
  });
84
85
  break;
@@ -147,6 +148,7 @@ export async function withFileLockAsync(filePath, fn, opts = {}) {
147
148
  try {
148
149
  release = await lockfile.lock(filePath, {
149
150
  stale: staleMs,
151
+ realpath: opts.realpath ?? true,
150
152
  onCompromised: (err) => { compromised = err; },
151
153
  });
152
154
  break;
@@ -3284,7 +3284,7 @@ export async function installSessionTrackerHook(agent, version, home) {
3284
3284
  }
3285
3285
  try {
3286
3286
  await execFileAsync(invocation.command, invocation.args, {
3287
- env: { ...process.env, HOME: home ?? process.env.HOME },
3287
+ env: sessionTrackerInstallEnv(agent, version, home),
3288
3288
  encoding: 'utf8',
3289
3289
  });
3290
3290
  return { installed: true };
@@ -3326,7 +3326,7 @@ export function installSessionTrackerHookSync(agent, version, home) {
3326
3326
  }
3327
3327
  try {
3328
3328
  execFileSync(invocation.command, invocation.args, {
3329
- env: { ...process.env, HOME: home ?? process.env.HOME },
3329
+ env: sessionTrackerInstallEnv(agent, version, home),
3330
3330
  stdio: ['ignore', 'pipe', 'pipe'],
3331
3331
  encoding: 'utf8',
3332
3332
  });
@@ -3336,3 +3336,8 @@ export function installSessionTrackerHookSync(agent, version, home) {
3336
3336
  return { installed: false, error: installFailureMessage(err) };
3337
3337
  }
3338
3338
  }
3339
+ /** The hook installer must target this account, not the current global home. */
3340
+ function sessionTrackerInstallEnv(agent, version, home) {
3341
+ const target = home ?? (version ? getVersionHomePath(agent, version) : undefined);
3342
+ return target ? { ...process.env, HOME: target, USERPROFILE: target } : { ...process.env };
3343
+ }
@@ -48,15 +48,17 @@ export declare function startDeviceAuthorization(): Promise<DeviceAuthorization>
48
48
  export declare function pollDeviceToken(deviceCode: string): Promise<DevicePoll>;
49
49
  export declare function fetchWhoAmI(token?: string): Promise<WhoAmI>;
50
50
  /**
51
- * Best-effort fill of the session's hosted avatar (PHNX-3547): sessions written
52
- * before the CLI tracked avatars have none, so ask `/api/v1/auth/me` and merge
53
- * a hosted `avatar_url` into the persisted session — future publishes then
54
- * stamp the OAuth profile image instead of only a Gravatar hash. A no-op when
55
- * signed out, when an avatar is already stored, or when the server exposes
56
- * none; network/server failures are swallowed (the share attribution falls
57
- * back to Gravatar/initials either way).
51
+ * Keep the session's hosted avatar current (PHNX-3547): merge the `avatar_url`
52
+ * Phoenix ID reports on `/api/v1/auth/me` into the persisted session, so the
53
+ * actor env and share attribution can stamp the profile image instead of only
54
+ * a Gravatar hash. Pass `known` when the caller already fetched `/auth/me`
55
+ * (whoami) — then a changed picture is written too. Without it, a session that
56
+ * already carries a picture is left alone rather than spending a network round
57
+ * trip to re-check it (the share publish path). A no-op when signed out or when
58
+ * the server exposes none; network/server failures are swallowed (the Gravatar
59
+ * fallback covers attribution either way).
58
60
  */
59
- export declare function refreshSessionAvatar(): Promise<void>;
61
+ export declare function refreshSessionAvatar(known?: WhoAmI): Promise<void>;
60
62
  export interface SpaceSummary {
61
63
  id: string;
62
64
  slug: string;
@@ -39,22 +39,26 @@ export function fetchWhoAmI(token) {
39
39
  return phoenixRequest('GET', '/api/v1/auth/me', { token });
40
40
  }
41
41
  /**
42
- * Best-effort fill of the session's hosted avatar (PHNX-3547): sessions written
43
- * before the CLI tracked avatars have none, so ask `/api/v1/auth/me` and merge
44
- * a hosted `avatar_url` into the persisted session — future publishes then
45
- * stamp the OAuth profile image instead of only a Gravatar hash. A no-op when
46
- * signed out, when an avatar is already stored, or when the server exposes
47
- * none; network/server failures are swallowed (the share attribution falls
48
- * back to Gravatar/initials either way).
42
+ * Keep the session's hosted avatar current (PHNX-3547): merge the `avatar_url`
43
+ * Phoenix ID reports on `/api/v1/auth/me` into the persisted session, so the
44
+ * actor env and share attribution can stamp the profile image instead of only
45
+ * a Gravatar hash. Pass `known` when the caller already fetched `/auth/me`
46
+ * (whoami) — then a changed picture is written too. Without it, a session that
47
+ * already carries a picture is left alone rather than spending a network round
48
+ * trip to re-check it (the share publish path). A no-op when signed out or when
49
+ * the server exposes none; network/server failures are swallowed (the Gravatar
50
+ * fallback covers attribution either way).
49
51
  */
50
- export async function refreshSessionAvatar() {
52
+ export async function refreshSessionAvatar(known) {
51
53
  const session = readSession();
52
- if (!session || session.avatarUrl)
54
+ if (!session)
55
+ return;
56
+ if (!known && session.avatarUrl)
53
57
  return;
54
58
  try {
55
- const me = await fetchWhoAmI();
59
+ const me = known ?? (await fetchWhoAmI());
56
60
  const hosted = me.avatar_url?.trim();
57
- if (hosted && /^https:\/\//i.test(hosted)) {
61
+ if (hosted && /^https:\/\//i.test(hosted) && hosted !== session.avatarUrl) {
58
62
  writeSession({ ...session, avatarUrl: hosted });
59
63
  }
60
64
  }