@phnx-labs/agents-cli 1.22.78 → 1.22.80

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 (90) hide show
  1. package/CHANGELOG.md +15 -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/config.js +37 -0
  7. package/dist/commands/exec.js +21 -10
  8. package/dist/commands/update.js +169 -18
  9. package/dist/commands/versions.d.ts +11 -0
  10. package/dist/commands/versions.js +30 -4
  11. package/dist/commands/view.d.ts +69 -3
  12. package/dist/commands/view.js +235 -75
  13. package/dist/index.js +35 -2
  14. package/dist/lib/account-catalog.d.ts +97 -1
  15. package/dist/lib/account-catalog.js +134 -6
  16. package/dist/lib/account-registry.d.ts +43 -0
  17. package/dist/lib/account-registry.js +97 -2
  18. package/dist/lib/accounting/rotate.d.ts +2 -2
  19. package/dist/lib/accounting/rotate.js +5 -5
  20. package/dist/lib/accounts/auth-operation-lock.d.ts +9 -0
  21. package/dist/lib/accounts/auth-operation-lock.js +55 -0
  22. package/dist/lib/accounts/connect.d.ts +170 -0
  23. package/dist/lib/accounts/connect.js +383 -0
  24. package/dist/lib/capabilities.js +2 -0
  25. package/dist/lib/commands.js +2 -0
  26. package/dist/lib/config-keys.d.ts +11 -2
  27. package/dist/lib/config-keys.js +21 -1
  28. package/dist/lib/daemon/daemon.js +5 -0
  29. package/dist/lib/daemon/harness-update-service.d.ts +110 -0
  30. package/dist/lib/daemon/harness-update-service.js +216 -0
  31. package/dist/lib/daemon-services.d.ts +1 -1
  32. package/dist/lib/daemon-services.js +5 -0
  33. package/dist/lib/device-config.d.ts +0 -1
  34. package/dist/lib/device-config.js +50 -5
  35. package/dist/lib/exec.js +26 -2
  36. package/dist/lib/fs-atomic.d.ts +2 -0
  37. package/dist/lib/fs-atomic.js +2 -0
  38. package/dist/lib/hooks/install.js +7 -2
  39. package/dist/lib/installations/active-check.d.ts +48 -0
  40. package/dist/lib/installations/active-check.js +84 -0
  41. package/dist/lib/installations/index.d.ts +5 -1
  42. package/dist/lib/installations/index.js +4 -0
  43. package/dist/lib/installations/installation-lock.d.ts +6 -0
  44. package/dist/lib/installations/installation-lock.js +29 -0
  45. package/dist/lib/installations/launch-gate.d.ts +69 -0
  46. package/dist/lib/installations/launch-gate.js +133 -0
  47. package/dist/lib/installations/native-command.d.ts +5 -0
  48. package/dist/lib/installations/native-command.js +52 -0
  49. package/dist/lib/installations/shims.d.ts +8 -2
  50. package/dist/lib/installations/shims.js +105 -2
  51. package/dist/lib/installations/store.d.ts +5 -1
  52. package/dist/lib/installations/store.js +24 -3
  53. package/dist/lib/installations/strategies.js +55 -35
  54. package/dist/lib/installations/types.d.ts +17 -0
  55. package/dist/lib/installations/update-cancellation.d.ts +82 -0
  56. package/dist/lib/installations/update-cancellation.js +122 -0
  57. package/dist/lib/installations/update-policy.d.ts +69 -0
  58. package/dist/lib/installations/update-policy.js +114 -0
  59. package/dist/lib/installations/update-runtime.d.ts +118 -0
  60. package/dist/lib/installations/update-runtime.js +321 -0
  61. package/dist/lib/installations/update.d.ts +25 -0
  62. package/dist/lib/installations/update.js +141 -2
  63. package/dist/lib/installations/versions.d.ts +1 -0
  64. package/dist/lib/installations/versions.js +166 -131
  65. package/dist/lib/platform/process.d.ts +3 -1
  66. package/dist/lib/platform/process.js +2 -2
  67. package/dist/lib/staleness/detectors/commands.d.ts +1 -2
  68. package/dist/lib/staleness/detectors/hooks.d.ts +1 -2
  69. package/dist/lib/staleness/detectors/mcp.d.ts +1 -2
  70. package/dist/lib/staleness/detectors/permissions.d.ts +1 -2
  71. package/dist/lib/staleness/detectors/plugins.d.ts +1 -7
  72. package/dist/lib/staleness/detectors/rules.d.ts +1 -2
  73. package/dist/lib/staleness/detectors/skills.d.ts +1 -2
  74. package/dist/lib/staleness/detectors/subagents.d.ts +1 -7
  75. package/dist/lib/staleness/detectors/workflows.d.ts +1 -2
  76. package/dist/lib/staleness/writers/commands.d.ts +1 -2
  77. package/dist/lib/staleness/writers/hooks.d.ts +1 -2
  78. package/dist/lib/staleness/writers/mcp.d.ts +1 -2
  79. package/dist/lib/staleness/writers/permissions.d.ts +1 -12
  80. package/dist/lib/staleness/writers/plugins.d.ts +1 -6
  81. package/dist/lib/staleness/writers/rules.d.ts +1 -2
  82. package/dist/lib/staleness/writers/skills.d.ts +1 -2
  83. package/dist/lib/staleness/writers/subagents.d.ts +1 -2
  84. package/dist/lib/staleness/writers/workflows.d.ts +1 -8
  85. package/dist/lib/state.d.ts +3 -1
  86. package/dist/lib/state.js +38 -13
  87. package/dist/lib/types.d.ts +26 -1
  88. package/dist/lib/types.js +5 -0
  89. package/dist/lib/view-types.d.ts +6 -0
  90. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Real-process-state "is this installation busy right now?" check (PHNX-3940).
3
+ *
4
+ * A leaf module deliberately kept free of `update.js`/`update-runtime.js`
5
+ * imports so both can depend on it without an import cycle: `update.ts` needs
6
+ * it for the pre-commit re-check (narrowing the launch/update race), and
7
+ * `update-runtime.ts` (which itself calls into `update.ts`) needs it for the
8
+ * plan-time deferral decision.
9
+ *
10
+ * Two independent signals, either one is enough to defer:
11
+ * - A live OS process whose command line names this installation's own
12
+ * version-dir path — a real process-table scan, not this box's session
13
+ * registry, so a harness launched by a bare generated shim with no
14
+ * session bookkeeping still defers correctly.
15
+ * - A live launch lease (`shims.ts`) — catches a launch that started after
16
+ * the process-table scan ran but hasn't hit the process table yet, e.g.
17
+ * mid-staging of a long npm install. See `shims.ts`'s docblock for what
18
+ * this does and does not close (it narrows the race, not eliminates it).
19
+ */
20
+ import type { Installation } from './types.js';
21
+ /**
22
+ * Raw process-table snapshot, one command line per entry. Injectable so tests
23
+ * can drive real string matching without shelling out or requiring a live
24
+ * agent process on the test box.
25
+ */
26
+ export interface ProcessSnapshot {
27
+ listCommandLines(): Promise<string[]>;
28
+ }
29
+ export declare const realProcessSnapshot: ProcessSnapshot;
30
+ /**
31
+ * Does any live process reference this installation's own directory? Matches
32
+ * on the absolute version-dir path rather than the bare CLI command name, so
33
+ * it distinguishes between installations of the same agent (two Claude
34
+ * installs are two different directories) and catches every way the binary
35
+ * ends up running under that path: an `agents run` session, a bare generated
36
+ * PATH/`.cmd` shim exec'd directly with no session bookkeeping at all, or a
37
+ * routine/teammate process — all of them carry the resolved absolute binary
38
+ * path in their command line, since none of the launch surfaces exec a bare
39
+ * relative name.
40
+ */
41
+ export declare function installationLooksActive(installation: Pick<Installation, 'agent' | 'label'>, commandLines: string[]): boolean;
42
+ /**
43
+ * Whether this installation appears to be busy right now: a live launch
44
+ * lease, or a live process naming its directory per a fresh OS process-table
45
+ * scan. On a scan failure, defers (returns true) rather than risking an
46
+ * update to a harness this check simply failed to observe.
47
+ */
48
+ export declare function isInstallationLikelyActive(installation: Pick<Installation, 'agent' | 'label'>, snapshot?: ProcessSnapshot): Promise<boolean>;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Real-process-state "is this installation busy right now?" check (PHNX-3940).
3
+ *
4
+ * A leaf module deliberately kept free of `update.js`/`update-runtime.js`
5
+ * imports so both can depend on it without an import cycle: `update.ts` needs
6
+ * it for the pre-commit re-check (narrowing the launch/update race), and
7
+ * `update-runtime.ts` (which itself calls into `update.ts`) needs it for the
8
+ * plan-time deferral decision.
9
+ *
10
+ * Two independent signals, either one is enough to defer:
11
+ * - A live OS process whose command line names this installation's own
12
+ * version-dir path — a real process-table scan, not this box's session
13
+ * registry, so a harness launched by a bare generated shim with no
14
+ * session bookkeeping still defers correctly.
15
+ * - A live launch lease (`shims.ts`) — catches a launch that started after
16
+ * the process-table scan ran but hasn't hit the process table yet, e.g.
17
+ * mid-staging of a long npm install. See `shims.ts`'s docblock for what
18
+ * this does and does not close (it narrows the race, not eliminates it).
19
+ */
20
+ import { execFile } from 'child_process';
21
+ import { promisify } from 'util';
22
+ import { getVersionDir } from './store.js';
23
+ import { hasLiveLaunchLease } from './shims.js';
24
+ const execFileAsync = promisify(execFile);
25
+ async function listCommandLinesPosix() {
26
+ try {
27
+ const { stdout } = await execFileAsync('ps', ['-Ao', 'args'], {
28
+ timeout: 5_000,
29
+ maxBuffer: 16 * 1024 * 1024,
30
+ });
31
+ return stdout.split('\n');
32
+ }
33
+ catch {
34
+ // A ps failure must not silently mean "nothing is running" — that would let
35
+ // an update proceed against a harness this pass simply failed to observe.
36
+ // Callers treat a thrown scan as "assume active", the safe default.
37
+ throw new Error('could not read the process table (ps failed)');
38
+ }
39
+ }
40
+ async function listCommandLinesWindows() {
41
+ try {
42
+ const { stdout } = await execFileAsync('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command',
43
+ "$ErrorActionPreference = 'Stop'; Get-CimInstance Win32_Process | ForEach-Object { $_.CommandLine }"], { timeout: 5_000, maxBuffer: 16 * 1024 * 1024, windowsHide: true });
44
+ return stdout.split(/\r?\n/);
45
+ }
46
+ catch {
47
+ throw new Error('could not read the process table (PowerShell CIM query failed)');
48
+ }
49
+ }
50
+ export const realProcessSnapshot = {
51
+ listCommandLines: () => (process.platform === 'win32' ? listCommandLinesWindows() : listCommandLinesPosix()),
52
+ };
53
+ /**
54
+ * Does any live process reference this installation's own directory? Matches
55
+ * on the absolute version-dir path rather than the bare CLI command name, so
56
+ * it distinguishes between installations of the same agent (two Claude
57
+ * installs are two different directories) and catches every way the binary
58
+ * ends up running under that path: an `agents run` session, a bare generated
59
+ * PATH/`.cmd` shim exec'd directly with no session bookkeeping at all, or a
60
+ * routine/teammate process — all of them carry the resolved absolute binary
61
+ * path in their command line, since none of the launch surfaces exec a bare
62
+ * relative name.
63
+ */
64
+ export function installationLooksActive(installation, commandLines) {
65
+ const versionDir = getVersionDir(installation.agent, installation.label);
66
+ return commandLines.some((line) => line.includes(versionDir));
67
+ }
68
+ /**
69
+ * Whether this installation appears to be busy right now: a live launch
70
+ * lease, or a live process naming its directory per a fresh OS process-table
71
+ * scan. On a scan failure, defers (returns true) rather than risking an
72
+ * update to a harness this check simply failed to observe.
73
+ */
74
+ export async function isInstallationLikelyActive(installation, snapshot = realProcessSnapshot) {
75
+ if (hasLiveLaunchLease(installation.agent, installation.label))
76
+ return true;
77
+ try {
78
+ const lines = await snapshot.listCommandLines();
79
+ return installationLooksActive(installation, lines);
80
+ }
81
+ catch {
82
+ return true;
83
+ }
84
+ }
@@ -7,8 +7,12 @@
7
7
  * it, so the internal split can change without breaking callers (the Cursor
8
8
  * per-installation isolation track consumes exactly this).
9
9
  */
10
- export { type Installation, type InstallationRelease, type UpdateOutcome, type UpdateStrategyId, } from './types.js';
10
+ export { type Installation, type InstallationRelease, type UpdateOutcome, type UpdateStrategyId, type UpdatePolicy, } from './types.js';
11
11
  export { createInstallation, listInstallations, recordRelease, } from './store.js';
12
12
  export { describeInstallation, resolveInstallation, type ResolveInstallationOptions, } from './resolve.js';
13
13
  export { selectUpdateStrategy, supportsPinnedUpdate, type UpdateStrategy, } from './strategies.js';
14
14
  export { updateInstallation, type UpdateInstallationOptions } from './update.js';
15
+ export { effectiveUpdatePolicy, isAutoUpdateEnabledForAgent, isGlobalAutoUpdateEnabled, rawAgentAutoUpdateSetting, rawGlobalAutoUpdateSetting, setAgentAutoUpdateEnabled, setGlobalAutoUpdateEnabled, setInstallationUpdatePolicy, unsetAgentAutoUpdateEnabled, unsetGlobalAutoUpdateEnabled, } from './update-policy.js';
16
+ export { planAutoUpdates, runAutoUpdatePass, listInstallationSnapshots, type AutoUpdatePlanEntry, type AutoUpdatePassOutcome, type AutoUpdatePassOptions, type AutoUpdatePassResult, } from './update-runtime.js';
17
+ export { installationLooksActive, isInstallationLikelyActive, realProcessSnapshot, type ProcessSnapshot, } from './active-check.js';
18
+ export { recordLaunchLease, hasLiveLaunchLease } from './shims.js';
@@ -11,3 +11,7 @@ export { createInstallation, listInstallations, recordRelease, } from './store.j
11
11
  export { describeInstallation, resolveInstallation, } from './resolve.js';
12
12
  export { selectUpdateStrategy, supportsPinnedUpdate, } from './strategies.js';
13
13
  export { updateInstallation } from './update.js';
14
+ export { effectiveUpdatePolicy, isAutoUpdateEnabledForAgent, isGlobalAutoUpdateEnabled, rawAgentAutoUpdateSetting, rawGlobalAutoUpdateSetting, setAgentAutoUpdateEnabled, setGlobalAutoUpdateEnabled, setInstallationUpdatePolicy, unsetAgentAutoUpdateEnabled, unsetGlobalAutoUpdateEnabled, } from './update-policy.js';
15
+ export { planAutoUpdates, runAutoUpdatePass, listInstallationSnapshots, } from './update-runtime.js';
16
+ export { installationLooksActive, isInstallationLikelyActive, realProcessSnapshot, } from './active-check.js';
17
+ export { recordLaunchLease, hasLiveLaunchLease } from './shims.js';
@@ -0,0 +1,6 @@
1
+ import { type FileLockOptions } from '../fs-atomic.js';
2
+ import { type AgentId } from '../types.js';
3
+ export declare function installationLockTarget(agent: AgentId, label: string): string;
4
+ export declare const INSTALLATION_LOCK_STALE_MS: number;
5
+ export declare const INSTALLATION_LOCK_ACQUIRE_TIMEOUT_MS: number;
6
+ export declare const INSTALLATION_LOCK_OPTIONS: Required<FileLockOptions>;