@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,321 @@
1
+ /**
2
+ * The automatic-update pass (PHNX-3940): decide which installations may be
3
+ * moved to their harness's latest release with no operator in the loop, then
4
+ * (optionally) move them.
5
+ *
6
+ * Split into a PLAN (`planAutoUpdates` — the `--check` dry-run and the
7
+ * daemon's own decision step both read it; it reads through
8
+ * {@link listInstallationSnapshots}, which NEVER mutates disk, so a preview is
9
+ * genuinely a preview) and a RUN (`runAutoUpdatePass`, which migrates a
10
+ * legacy installation for real, under its own lock, and regenerates its
11
+ * already-owned shim/versioned-alias, before driving eligible plan entries
12
+ * through the existing `updateInstallation` transaction). Both share one
13
+ * eligibility computation so a dry-run can never report "would update" for
14
+ * something the real run would skip, or vice versa.
15
+ *
16
+ * Eligibility is deliberately narrow:
17
+ * - Only the `npm-package` strategy is transactional AND isolated per
18
+ * installation (stage into a sibling dir, launch-probe it, swap, keep the
19
+ * displaced tree until the swap is proven) — the shape every existing
20
+ * safety property in `update.ts` depends on. A global-binary or
21
+ * install-script harness has no reversible per-installation swap to offer,
22
+ * so it is reported honestly as manual/vendor-managed rather than silently
23
+ * skipped or, worse, "updated" via an irreversible reinstall with no
24
+ * operator watching.
25
+ * - The operator switch (`updates.auto` / `updates.<agent>.auto`,
26
+ * `update-policy.ts`) and the per-installation policy
27
+ * (`Installation.updatePolicy`) must both allow it.
28
+ * - The installation must not look ACTIVE right now (see
29
+ * {@link isInstallationLikelyActive}) — a real OS process-table scan, not
30
+ * just this box's session registry, so a harness launched by a bare
31
+ * generated shim with no session bookkeeping still defers correctly.
32
+ *
33
+ * The latest release for a harness is resolved ONCE per pass (not once per
34
+ * installation) — `resolveSharedTargets` below — so a fleet with a dozen
35
+ * pinned-to-latest Claude installations issues one npm registry read, not a
36
+ * dozen.
37
+ */
38
+ import * as fs from 'fs';
39
+ import { AGENTS, isAgentHardDeprecated } from '../agents.js';
40
+ import { MANAGED_AGENT_IDS } from '../agent-spec/agents.js';
41
+ import { withFileLockAsync } from '../fs-atomic.js';
42
+ import { ensureInstallationLocked, installationDir, listInstallationLabels, readInstallation, } from './store.js';
43
+ import { refreshOwnedLaunchers, hasLiveLaunchLease } from './shims.js';
44
+ import { installationLooksActive, realProcessSnapshot } from './active-check.js';
45
+ import { selectUpdateStrategy } from './strategies.js';
46
+ import { updateInstallation } from './update.js';
47
+ import { effectiveUpdatePolicy, isAutoUpdateEnabledForAgent } from './update-policy.js';
48
+ import { installationLockTarget, INSTALLATION_LOCK_OPTIONS } from './installation-lock.js';
49
+ import { withGuardedUpdateCancellation } from './update-cancellation.js';
50
+ import { INSTALLATION_SCHEMA } from './types.js';
51
+ /** The only strategy shape the automatic pass will ever touch — see the module docblock. */
52
+ function autoUpdateStrategyFor(agent) {
53
+ let strategy;
54
+ try {
55
+ strategy = selectUpdateStrategy(agent);
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ return strategy.id === 'npm-package' && strategy.transactional ? strategy : null;
61
+ }
62
+ /**
63
+ * Build the record a real migration (`ensureInstallation`, `store.ts`) would
64
+ * mint for a legacy version dir that has no `installation.json` yet — the
65
+ * same fields (schema, label-as-release, history seeded from the directory's
66
+ * own mtime) — but never persisted, and never carrying the real
67
+ * `mintInstallationId()` format, so nothing downstream can mistake it for an
68
+ * id that survived a lock-protected migration. Returns null when the
69
+ * directory itself is gone mid-scan, tolerated the same way
70
+ * `listInstallations` tolerates that.
71
+ */
72
+ function ephemeralInstallationSnapshot(agent, label) {
73
+ const dir = installationDir(agent, label);
74
+ let createdAt;
75
+ try {
76
+ createdAt = fs.statSync(dir).mtime.toISOString();
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ return {
82
+ schema: INSTALLATION_SCHEMA,
83
+ id: `preview:${agent}:${label}`,
84
+ agent,
85
+ label,
86
+ releaseVersion: label,
87
+ createdAt,
88
+ updatedAt: createdAt,
89
+ history: [{ releaseVersion: label, at: createdAt }],
90
+ };
91
+ }
92
+ /**
93
+ * Read-only enumeration of installations — the ONLY listing {@link planAutoUpdates}
94
+ * may use. `listInstallations` (`store.ts`) migrates a legacy version dir's
95
+ * `installation.json` into existence via `ensureInstallation` as a side
96
+ * effect of merely being READ, which made `agents update --check` (a
97
+ * "preview") write to disk. A legacy dir with no persisted record yet gets an
98
+ * {@link ephemeralInstallationSnapshot} instead — real fields, but never
99
+ * written and never a real id. The real run migrates it for real, under this
100
+ * installation's own lock, immediately before acting on it — see
101
+ * {@link runAutoUpdatePass}.
102
+ */
103
+ export function listInstallationSnapshots(agent) {
104
+ const out = [];
105
+ for (const label of listInstallationLabels(agent)) {
106
+ let record;
107
+ try {
108
+ record = readInstallation(agent, label);
109
+ }
110
+ catch {
111
+ continue; // corrupted record — not an installation this pass can act on
112
+ }
113
+ const snapshot = record ?? ephemeralInstallationSnapshot(agent, label);
114
+ if (snapshot)
115
+ out.push(snapshot);
116
+ }
117
+ return out;
118
+ }
119
+ /**
120
+ * Build the automatic-update plan: one entry per installation of every scoped
121
+ * agent, with eligibility, deferral, and the resolved target release already
122
+ * computed. Never mutates anything — reads only through
123
+ * {@link listInstallationSnapshots} — so it is genuinely safe to call from
124
+ * `--check` or before every real pass.
125
+ */
126
+ export async function planAutoUpdates(opts = {}) {
127
+ const agents = (opts.agents ?? MANAGED_AGENT_IDS).filter((agent) => !isAgentHardDeprecated(agent));
128
+ let commandLines = null;
129
+ let processScanError;
130
+ try {
131
+ commandLines = await realProcessSnapshot.listCommandLines();
132
+ }
133
+ catch (err) {
134
+ processScanError = err instanceof Error ? err.message : String(err);
135
+ }
136
+ const plan = [];
137
+ for (const agent of agents) {
138
+ const installations = listInstallationSnapshots(agent);
139
+ if (installations.length === 0)
140
+ continue;
141
+ const strategy = autoUpdateStrategyFor(agent);
142
+ const agentAutoEnabled = isAutoUpdateEnabledForAgent(agent);
143
+ // Resolve the latest release ONCE per agent — not once per installation —
144
+ // and only when at least the strategy/switch preconditions hold, so a
145
+ // harness nobody enabled auto-updates for never triggers a registry read.
146
+ let target = null;
147
+ let resolveError;
148
+ if (strategy && agentAutoEnabled) {
149
+ try {
150
+ const ctx = {
151
+ agent,
152
+ installation: installations[0],
153
+ requested: 'latest',
154
+ onProgress: opts.onProgress,
155
+ };
156
+ target = await strategy.resolveTarget(ctx);
157
+ }
158
+ catch (err) {
159
+ resolveError = err instanceof Error ? err.message : String(err);
160
+ }
161
+ }
162
+ for (const installation of installations) {
163
+ const policy = effectiveUpdatePolicy(installation);
164
+ let eligible = true;
165
+ let reason;
166
+ if (!strategy) {
167
+ eligible = false;
168
+ reason = `${AGENTS[agent].name} is manual/vendor-managed for updates (no isolated, reversible per-installation swap) — update it yourself.`;
169
+ }
170
+ else if (!agentAutoEnabled) {
171
+ eligible = false;
172
+ reason = 'automatic updates are disabled for this harness (updates.auto / updates.<agent>.auto).';
173
+ }
174
+ else if (policy === 'pinned') {
175
+ eligible = false;
176
+ reason = 'installation is pinned to a concrete release (agents update … --to <release> unpins with --to latest).';
177
+ }
178
+ else if (resolveError) {
179
+ eligible = false;
180
+ reason = `could not resolve the latest release: ${resolveError}`;
181
+ }
182
+ let deferred = false;
183
+ if (eligible) {
184
+ if (hasLiveLaunchLease(installation.agent, installation.label)) {
185
+ deferred = true;
186
+ reason = 'a launch is in flight for this installation (live launch lease); deferring.';
187
+ }
188
+ else if (commandLines) {
189
+ deferred = installationLooksActive(installation, commandLines);
190
+ if (deferred)
191
+ reason = 'the installation appears to have a process running right now; deferring.';
192
+ }
193
+ else {
194
+ // The process scan itself failed — fail closed for every installation
195
+ // this pass would otherwise touch, not just the ones a scan would have
196
+ // flagged as active.
197
+ deferred = true;
198
+ reason = `could not confirm no process is running (${processScanError}); deferring.`;
199
+ }
200
+ }
201
+ plan.push({
202
+ agent,
203
+ installation,
204
+ currentRelease: installation.releaseVersion,
205
+ targetRelease: target,
206
+ policy,
207
+ eligible,
208
+ deferred,
209
+ reason,
210
+ });
211
+ }
212
+ }
213
+ return plan;
214
+ }
215
+ /**
216
+ * Run the automatic-update pass: plan, then drive every eligible,
217
+ * non-deferred, actually-behind entry through {@link updateInstallation}.
218
+ * Installations update sequentially — this runs from a bounded daemon-spawned
219
+ * child process (`harness-update-service.ts`) on its own schedule, not a
220
+ * user-facing wait, so there is no reason to parallelize and every reason not
221
+ * to (concurrent npm installs sharing this box's npm cache have a history of
222
+ * corrupting each other).
223
+ *
224
+ * `abortIfPinnedBeforeCommit` / `abortIfAutoDisabledBeforeCommit: true` on
225
+ * every call: this is the ONE caller for whom a policy or switch change
226
+ * mid-staging must cancel the commit (see `update.ts`'s docblock on those
227
+ * options) — a manual `agents update` never routes through here.
228
+ */
229
+ export async function runAutoUpdatePass(opts = {}) {
230
+ // Cancellation is wired from IPC (the daemon's cross-platform request),
231
+ // channel `disconnect` (daemon gone), and SIGTERM/SIGINT — never a forced
232
+ // process kill (see `update-cancellation.ts`). The guard it holds is what lets
233
+ // `index.ts` defer its SIGINT hard-exit while a swap is in flight.
234
+ return withGuardedUpdateCancellation(async (cancelled) => {
235
+ const result = await runAutoUpdatePassUntilCancelled(opts, cancelled);
236
+ return { ...result, cancelled: cancelled() };
237
+ });
238
+ }
239
+ /**
240
+ * The hidden `__harness-update-run` verb the daemon spawns with an IPC channel
241
+ * (dispatched in `index.ts`). Runs ONE auto-update pass with the cooperative,
242
+ * cross-platform cancellation above, writes a compact JSON summary to stdout for
243
+ * the daemon's log, and returns the process exit code.
244
+ *
245
+ * Exit code mirrors the manual `agents update --auto` path
246
+ * (`commands/update.ts`): a per-installation error is a NORMAL tick outcome (a
247
+ * bad vendor release) surfaced as a non-zero exit the daemon logs as a warning,
248
+ * not a service failure. A cooperative cancel is not itself an error, so it does
249
+ * not force a non-zero exit — the child exits on its own and the daemon observes
250
+ * that true completion rather than reading a killed process.
251
+ */
252
+ export async function runHarnessUpdateChild() {
253
+ const result = await runAutoUpdatePass({});
254
+ const anyError = result.outcomes.some((o) => o.error);
255
+ const summary = {
256
+ v: 1,
257
+ cancelled: result.cancelled ?? false,
258
+ outcomes: result.outcomes.map((o) => ({
259
+ agent: o.entry.agent,
260
+ label: o.entry.installation.label,
261
+ fromRelease: o.outcome?.fromRelease ?? null,
262
+ toRelease: o.outcome?.toRelease ?? null,
263
+ unchanged: o.outcome?.unchanged ?? null,
264
+ deferred: o.outcome?.deferred ?? null,
265
+ error: o.error ?? null,
266
+ })),
267
+ };
268
+ process.stdout.write(JSON.stringify(summary));
269
+ return anyError ? 1 : 0;
270
+ }
271
+ async function runAutoUpdatePassUntilCancelled(opts, cancelled) {
272
+ const plan = await planAutoUpdates(opts);
273
+ const outcomes = [];
274
+ // One shim resolves every installation of an agent dynamically at launch
275
+ // time, so regenerating it once per agent (not once per installation) this
276
+ // pass touches is enough — a second entry for the same agent would just
277
+ // redo `ensureShimCurrent`'s own no-op "already current" check.
278
+ for (const entry of plan) {
279
+ if (cancelled())
280
+ break;
281
+ if (!entry.eligible || entry.deferred)
282
+ continue;
283
+ if (!entry.targetRelease || entry.targetRelease === entry.currentRelease)
284
+ continue;
285
+ try {
286
+ // The plan above is deliberately read-only (`listInstallationSnapshots`),
287
+ // so a legacy version dir with no persisted `installation.json` yet is
288
+ // represented there by an ephemeral, never-written snapshot. A REAL
289
+ // pass must migrate it for real before acting on it — done here, under
290
+ // the SAME per-installation lock `updateInstallation` itself takes
291
+ // (`INSTALLATION_LOCK_OPTIONS`), so two concurrent real passes can never
292
+ // mint two different ids for the same legacy install. Already-migrated
293
+ // installations round-trip through this unchanged (`ensureInstallation`
294
+ // is a plain read when a record already exists).
295
+ const installation = await withFileLockAsync(installationLockTarget(entry.agent, entry.installation.label), () => ensureInstallationLocked(entry.agent, entry.installation.label, entry.installation.createdAt), INSTALLATION_LOCK_OPTIONS);
296
+ // Regenerate only what this pass already owns — the agent's generated
297
+ // shim, and this installation's versioned alias if it was installed
298
+ // isolated — using the existing upgrade-in-place helpers
299
+ // (`ensureShimCurrent`/`ensureVersionedAliasCurrent`). Deliberately
300
+ // NEVER `adoptShadowingLauncher`: that seizes a launcher this pass does
301
+ // not own (a user's own PATH entry, or another install's), and is an
302
+ // operator-triggered `doctor --fix` action, not something an unattended
303
+ // background pass may do on its own. Real-pass-only, same reason as the
304
+ // migration above — a `--check` preview must not touch PATH or a
305
+ // config-dir symlink.
306
+ refreshOwnedLaunchers(entry.agent, installation.label);
307
+ const outcome = await updateInstallation(installation, {
308
+ to: entry.targetRelease,
309
+ onProgress: opts.onProgress,
310
+ abortIfPinnedBeforeCommit: true,
311
+ abortIfAutoDisabledBeforeCommit: true,
312
+ shouldCancel: cancelled,
313
+ });
314
+ outcomes.push({ entry, outcome });
315
+ }
316
+ catch (err) {
317
+ outcomes.push({ entry, error: err instanceof Error ? err.message : String(err) });
318
+ }
319
+ }
320
+ return { plan, outcomes };
321
+ }
@@ -1,6 +1,10 @@
1
1
  import { type UpdateStrategy } from './strategies.js';
2
2
  import type { Installation, UpdateOutcome } from './types.js';
3
3
  export interface UpdateInstallationOptions {
4
+ /** Persist together with a successfully selected release, under this transaction's lock. */
5
+ updatePolicy?: Installation['updatePolicy'];
6
+ /** Cooperative cancellation is honored before the swap, never during record/rollback. */
7
+ shouldCancel?: () => boolean;
4
8
  /** `latest` (default), `oldest`, or a concrete release. */
5
9
  to?: string;
6
10
  onProgress?: (message: string) => void;
@@ -12,6 +16,27 @@ export interface UpdateInstallationOptions {
12
16
  * Omitted in every normal call — `selectUpdateStrategy` is the default.
13
17
  */
14
18
  strategy?: UpdateStrategy;
19
+ /**
20
+ * Abort just before `commit()` if the installation's update policy has
21
+ * flipped to `'pinned'` since staging began, instead of making the staged
22
+ * release live. Set only by the automatic-update pass
23
+ * (`update-runtime.ts`): a manual `agents update` is itself the user's
24
+ * request and must never be undercut by a policy read mid-flight, but an
25
+ * automatic run must not commit an update the operator pinned away from
26
+ * while the (potentially minutes-long) stage was in flight.
27
+ */
28
+ abortIfPinnedBeforeCommit?: boolean;
29
+ /**
30
+ * Set only by the automatic-update pass (`update-runtime.ts`). Right before
31
+ * `commit()`, re-reads the global/per-harness `updates.auto` switches
32
+ * (`update-policy.ts`) in addition to the pin check above, aborting the
33
+ * commit if automatic updates were turned off for this harness while the
34
+ * (potentially minutes-long) stage was in flight. Same reasoning as
35
+ * {@link abortIfPinnedBeforeCommit}: a manual `agents update` is itself the
36
+ * user's own current request and must never be undercut by a policy read
37
+ * mid-flight, so it never sets this.
38
+ */
39
+ abortIfAutoDisabledBeforeCommit?: boolean;
15
40
  }
16
41
  /**
17
42
  * Move one frozen installation to a new vendor release, preserving its identity.
@@ -1,9 +1,13 @@
1
1
  import * as fs from 'fs';
2
2
  import { AGENTS, isAgentHardDeprecated, hardDeprecationError } from '../agents.js';
3
3
  import { emit } from '../feed/events.js';
4
+ import { withFileLockAsync } from '../fs-atomic.js';
4
5
  import { getBinaryPath, invalidateInstalledVersionsCache, invalidateLiveVersionCache, verifyBinaryLaunches, } from './versions.js';
5
6
  import { assertValidRelease, selectUpdateStrategy, } from './strategies.js';
6
- import { listInstallations, recordRelease } from './store.js';
7
+ import { listInstallations, readInstallation, recordRelease, writeInstallation } from './store.js';
8
+ import { effectiveUpdatePolicy, isAutoUpdateEnabledForAgent } from './update-policy.js';
9
+ import { isInstallationLikelyActive } from './active-check.js';
10
+ import { installationLockTarget, INSTALLATION_LOCK_OPTIONS } from './installation-lock.js';
7
11
  /**
8
12
  * Move one frozen installation to a new vendor release, preserving its identity.
9
13
  *
@@ -32,12 +36,32 @@ export async function updateInstallation(installation, options = {}) {
32
36
  const agent = installation.agent;
33
37
  if (isAgentHardDeprecated(agent))
34
38
  throw new Error(hardDeprecationError(agent));
39
+ // Serialize every update of this SAME installation — manual and automatic
40
+ // alike — behind a cross-process lock keyed on its own record file. Without
41
+ // it, an operator's `agents update claude@2.0.65` racing the automatic pass
42
+ // (or two automatic passes on two boxes sharing this ~/.agents, which
43
+ // shouldn't happen but isn't prevented at this layer) could stage two
44
+ // releases into the same version dir and interleave their swaps. The record
45
+ // is re-read fresh under the lock (never trusting the possibly-stale
46
+ // `installation` the caller passed in) so a concurrent update that already
47
+ // landed is observed before this one decides what to do; a corrupted record
48
+ // makes `readInstallation` throw, which propagates out of the locked section
49
+ // and fails the update closed rather than proceeding on unreadable state.
50
+ return withFileLockAsync(installationLockTarget(agent, installation.label), () => runUpdateInstallation(agent, installation, options), INSTALLATION_LOCK_OPTIONS);
51
+ }
52
+ async function runUpdateInstallation(agent, requestedInstallation, options) {
53
+ const installation = readInstallation(agent, requestedInstallation.label) ?? requestedInstallation;
35
54
  const requested = options.to ?? 'latest';
36
55
  assertValidRelease(requested);
37
56
  const strategy = options.strategy ?? selectUpdateStrategy(agent);
38
57
  const ctx = { agent, installation, requested, onProgress: options.onProgress };
39
58
  const target = await strategy.resolveTarget(ctx);
40
59
  if (target === installation.releaseVersion) {
60
+ if (options.updatePolicy) {
61
+ installation.updatePolicy = options.updatePolicy;
62
+ installation.updatedAt = new Date().toISOString();
63
+ writeInstallation(installation);
64
+ }
41
65
  options.onProgress?.(`${AGENTS[agent].name}@${installation.label} is already on release ${target}; nothing to update.`);
42
66
  return {
43
67
  installation,
@@ -48,6 +72,35 @@ export async function updateInstallation(installation, options = {}) {
48
72
  alsoUpdated: [],
49
73
  };
50
74
  }
75
+ // Mandatory for every transactional strategy, every caller — manual
76
+ // `agents update` included, not just the automatic pass. A transactional
77
+ // strategy's commit is a swap of THIS installation's own directory, so a
78
+ // live process or launch lease naming it (`active-check.ts`) means staging
79
+ // right now risks a launch racing the swap. This is the pre-STAGE half of
80
+ // the check, taken under the same lock this whole transaction holds — the
81
+ // identical check runs again immediately before commit below, since staging
82
+ // an npm package can take the better part of `INSTALL_TIMEOUT_MS` and a
83
+ // launch can start during that window. Non-transactional strategies (a
84
+ // global-binary/install-script harness) have no reversible swap to protect,
85
+ // so they are unaffected by either check.
86
+ if (options.shouldCancel?.() || (options.abortIfPinnedBeforeCommit && effectiveUpdatePolicy(installation) === 'pinned')
87
+ || (options.abortIfAutoDisabledBeforeCommit && !isAutoUpdateEnabledForAgent(agent))) {
88
+ return { installation, strategy: strategy.id, fromRelease: installation.releaseVersion, toRelease: target,
89
+ unchanged: true, deferred: 'Update cancelled or automatic update policy changed.', alsoUpdated: [] };
90
+ }
91
+ if (strategy.transactional && await isInstallationLikelyActive(installation)) {
92
+ options.onProgress?.(`${AGENTS[agent].name}@${installation.label} looks active right now (a process or launch lease); `
93
+ + `not staging release ${target}.`);
94
+ return {
95
+ installation,
96
+ strategy: strategy.id,
97
+ fromRelease: installation.releaseVersion,
98
+ toRelease: target,
99
+ unchanged: true,
100
+ deferred: 'Account home is in use; retry after its sessions finish.',
101
+ alsoUpdated: [],
102
+ };
103
+ }
51
104
  let staged = null;
52
105
  try {
53
106
  staged = await strategy.stage(ctx, target);
@@ -61,6 +114,11 @@ export async function updateInstallation(installation, options = {}) {
61
114
  // (a self-updating binary that was already current). Recording it would
62
115
  // claim a change that did not happen and append a bogus history entry.
63
116
  if (staged.release === installation.releaseVersion) {
117
+ if (options.updatePolicy) {
118
+ installation.updatePolicy = options.updatePolicy;
119
+ installation.updatedAt = new Date().toISOString();
120
+ writeInstallation(installation);
121
+ }
64
122
  options.onProgress?.(`${AGENTS[agent].name}@${installation.label} is already on release ${staged.release}; nothing to update.`);
65
123
  // Deliberately NOT committed: there is no new release to make live, and
66
124
  // for a strategy that swaps the version dir a commit here would displace
@@ -75,6 +133,65 @@ export async function updateInstallation(installation, options = {}) {
75
133
  alsoUpdated: [],
76
134
  };
77
135
  }
136
+ // The automatic pass may have started staging while the installation was
137
+ // still eligible, then had it pinned out from under it — staging an npm
138
+ // package can legitimately take the better part of INSTALL_TIMEOUT_MS. Check
139
+ // ONE more time, right before the point of no return, so a policy change
140
+ // that lands mid-staging is honored instead of committed anyway. A manual
141
+ // `agents update` never sets this option: the user's own invocation IS their
142
+ // current intent, so there is nothing to defer to.
143
+ if (options.abortIfPinnedBeforeCommit) {
144
+ const fresh = readInstallation(agent, installation.label);
145
+ if (fresh && effectiveUpdatePolicy(fresh) === 'pinned') {
146
+ options.onProgress?.(`${AGENTS[agent].name}@${installation.label} was pinned while ${staged.release} was staging; not committing it.`);
147
+ return {
148
+ installation: fresh,
149
+ strategy: strategy.id,
150
+ fromRelease: fresh.releaseVersion,
151
+ toRelease: staged.release,
152
+ unchanged: true,
153
+ deferred: 'Installation was pinned while the update was being prepared.',
154
+ alsoUpdated: [],
155
+ };
156
+ }
157
+ }
158
+ // Automatic-only, same reasoning as the pin recheck above: the operator
159
+ // may have flipped `updates.auto` / `updates.<agent>.auto` off while this
160
+ // release was staging (minutes for a real npm install), and an automatic
161
+ // run must honor that instead of committing an update the operator just
162
+ // asked to stop. A manual `agents update` never sets this — the user's
163
+ // own invocation already IS the decision to update, regardless of the
164
+ // switch that only gates the UNATTENDED pass.
165
+ if (options.abortIfAutoDisabledBeforeCommit && !isAutoUpdateEnabledForAgent(agent)) {
166
+ options.onProgress?.(`${AGENTS[agent].name}@${installation.label}: automatic updates were turned off while ${staged.release} `
167
+ + `was staging; not committing it.`);
168
+ return {
169
+ installation,
170
+ strategy: strategy.id,
171
+ fromRelease: installation.releaseVersion,
172
+ toRelease: staged.release,
173
+ unchanged: true,
174
+ deferred: 'Automatic updates were turned off while the update was being prepared.',
175
+ alsoUpdated: [],
176
+ };
177
+ }
178
+ // Mandatory for every transactional strategy, every caller — see the
179
+ // identical pre-stage check above for why this cannot be opt-in: a launch
180
+ // that starts AFTER that first check but before this swap is exactly the
181
+ // window this closes.
182
+ if (options.shouldCancel?.() || (strategy.transactional && await isInstallationLikelyActive(installation))) {
183
+ options.onProgress?.(`${AGENTS[agent].name}@${installation.label} looks active now (a process or launch lease appeared while `
184
+ + `${staged.release} was staging); not committing it.`);
185
+ return {
186
+ installation,
187
+ strategy: strategy.id,
188
+ fromRelease: installation.releaseVersion,
189
+ toRelease: staged.release,
190
+ unchanged: true,
191
+ deferred: 'Update cancelled or the account home became active.',
192
+ alsoUpdated: [],
193
+ };
194
+ }
78
195
  const handles = await strategy.commit(ctx, staged);
79
196
  try {
80
197
  // Probe what will actually execute — `getBinaryPath` is the same resolver
@@ -98,8 +215,30 @@ export async function updateInstallation(installation, options = {}) {
98
215
  : `${err.message} The version directory was restored, but ${AGENTS[agent].name}'s installer `
99
216
  + `had already replaced the binary it manages globally — repair it with: agents add ${agent}@latest`);
100
217
  }
218
+ // Record BEFORE finalizing the commit's rollback material — not after.
219
+ // `finalize()` discards the only thing that can put the previous release
220
+ // back (deletes the rollback dir / no-ops for a non-transactional
221
+ // strategy). If `recordRelease` throws (e.g. disk full, an unwritable
222
+ // installation.json) AFTER finalize, the new release is live on disk with
223
+ // no rollback path AND no record of it — an installation whose directory
224
+ // and its own metadata now disagree, with no way back. Recording first
225
+ // means a record-write failure still has the rollback material available
226
+ // to undo the live swap, so the failure mode stays "nothing changed"
227
+ // instead of "half updated, unrecoverable."
228
+ let updated;
229
+ try {
230
+ updated = recordRelease({ ...installation, ...(options.updatePolicy ? { updatePolicy: options.updatePolicy } : {}) }, staged.release);
231
+ }
232
+ catch (err) {
233
+ handles.undo();
234
+ throw new Error(strategy.transactional
235
+ ? `${err.message} ${AGENTS[agent].name} release ${staged.release} could not be recorded. `
236
+ + `Rolled back to ${installation.releaseVersion}.`
237
+ : `${err.message} ${AGENTS[agent].name} release ${staged.release} could not be recorded. `
238
+ + `The version directory was restored, but ${AGENTS[agent].name}'s installer had already replaced the `
239
+ + `binary it manages globally — repair it with: agents add ${agent}@latest`);
240
+ }
101
241
  handles.finalize();
102
- const updated = recordRelease(installation, staged.release);
103
242
  // Several installations of a global-binary harness point at the same file,
104
243
  // so the one we just replaced is live for all of them. Recording the release
105
244
  // only on the target would leave the others claiming a release that is no
@@ -87,6 +87,7 @@ export declare function setIsolatedDefault(agent: AgentId, version: string | und
87
87
  /** Install a specific version of an agent. */
88
88
  export declare function installVersion(agent: AgentId, version: string, onProgress?: (message: string) => void, opts?: {
89
89
  clean?: boolean;
90
+ installationLabel?: string;
90
91
  }): Promise<{
91
92
  success: boolean;
92
93
  installedVersion: string;