@phnx-labs/agents-cli 1.22.31 → 1.22.33

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 (69) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +8 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/daemon.js +52 -12
  5. package/dist/commands/doctor.d.ts +19 -0
  6. package/dist/commands/doctor.js +119 -17
  7. package/dist/commands/routines.js +164 -36
  8. package/dist/commands/sessions-browser.js +2 -2
  9. package/dist/commands/sessions.d.ts +1 -1
  10. package/dist/commands/sessions.js +66 -22
  11. package/dist/commands/update.d.ts +2 -0
  12. package/dist/commands/update.js +148 -0
  13. package/dist/index.js +3 -1
  14. package/dist/lib/catchup.js +4 -1
  15. package/dist/lib/daemon.d.ts +17 -0
  16. package/dist/lib/daemon.js +69 -3
  17. package/dist/lib/devices/doctor-findings.d.ts +7 -2
  18. package/dist/lib/devices/doctor-findings.js +53 -2
  19. package/dist/lib/devices/doctor-overview-cache.d.ts +7 -0
  20. package/dist/lib/devices/doctor-overview-cache.js +15 -0
  21. package/dist/lib/devices/fleet-divergence.d.ts +11 -0
  22. package/dist/lib/devices/fleet-divergence.js +6 -0
  23. package/dist/lib/devices/fleet-inventory.js +16 -2
  24. package/dist/lib/drift.d.ts +6 -1
  25. package/dist/lib/drift.js +9 -0
  26. package/dist/lib/hooks/cache.js +20 -1
  27. package/dist/lib/hooks.d.ts +91 -1
  28. package/dist/lib/hooks.js +289 -3
  29. package/dist/lib/hosts/passthrough.js +3 -0
  30. package/dist/lib/installations/index.d.ts +14 -0
  31. package/dist/lib/installations/index.js +14 -0
  32. package/dist/lib/installations/resolve.d.ts +43 -0
  33. package/dist/lib/installations/resolve.js +93 -0
  34. package/dist/lib/installations/store.d.ts +56 -0
  35. package/dist/lib/installations/store.js +196 -0
  36. package/dist/lib/installations/strategies.d.ts +73 -0
  37. package/dist/lib/installations/strategies.js +293 -0
  38. package/dist/lib/installations/types.d.ts +78 -0
  39. package/dist/lib/installations/types.js +8 -0
  40. package/dist/lib/installations/update.d.ts +40 -0
  41. package/dist/lib/installations/update.js +131 -0
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  44. package/dist/lib/migrate.d.ts +27 -0
  45. package/dist/lib/migrate.js +112 -2
  46. package/dist/lib/routine-context.d.ts +144 -0
  47. package/dist/lib/routine-context.js +268 -0
  48. package/dist/lib/routine-readiness.d.ts +47 -0
  49. package/dist/lib/routine-readiness.js +239 -0
  50. package/dist/lib/routines.d.ts +97 -1
  51. package/dist/lib/routines.js +107 -1
  52. package/dist/lib/runner.d.ts +18 -4
  53. package/dist/lib/runner.js +291 -98
  54. package/dist/lib/scheduler.d.ts +7 -1
  55. package/dist/lib/scheduler.js +5 -2
  56. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  57. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  58. package/dist/lib/self-heal/checks/hook-runtime.d.ts +2 -0
  59. package/dist/lib/self-heal/checks/hook-runtime.js +16 -0
  60. package/dist/lib/self-heal/registry.js +5 -2
  61. package/dist/lib/self-heal/types.d.ts +1 -1
  62. package/dist/lib/session/state.js +4 -1
  63. package/dist/lib/session/team-filter.d.ts +11 -0
  64. package/dist/lib/session/team-filter.js +10 -0
  65. package/dist/lib/startup/command-registry.d.ts +1 -0
  66. package/dist/lib/startup/command-registry.js +2 -0
  67. package/dist/lib/versions.d.ts +24 -0
  68. package/dist/lib/versions.js +49 -16
  69. package/package.json +2 -2
@@ -0,0 +1,293 @@
1
+ import * as crypto from 'crypto';
2
+ import { promisify } from 'util';
3
+ import { exec, execFile } from 'child_process';
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import { AGENTS, findInPath, isSelfUpdatingAgent } from '../agents.js';
7
+ import { VERSION_RE } from '../agent-spec/primitives.js';
8
+ import { importInstallScriptBinary } from '../import.js';
9
+ import { getBinaryPath, getLatestNpmVersion, getOldestNpmVersion, getLiveVersion, getVersionHomePath, invalidateLiveVersionCache, isGlobalBinaryAgent, } from '../versions.js';
10
+ import { installationDir } from './store.js';
11
+ const execAsync = promisify(exec);
12
+ const execFileAsync = promisify(execFile);
13
+ /** npm install timeout, matching the install path's own installer budget. */
14
+ const INSTALL_TIMEOUT_MS = 120_000;
15
+ function runId() {
16
+ return `${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
17
+ }
18
+ function moveDir(from, to) {
19
+ fs.mkdirSync(path.dirname(to), { recursive: true });
20
+ try {
21
+ fs.renameSync(from, to);
22
+ }
23
+ catch (err) {
24
+ // Windows refuses a rename while any file in the tree is open, and a
25
+ // cross-device staging dir cannot be renamed at all. Copy+remove is the same
26
+ // observable move; it is slower, so it is the fallback, not the default.
27
+ const code = err.code;
28
+ if (code !== 'EPERM' && code !== 'EACCES' && code !== 'EXDEV')
29
+ throw err;
30
+ fs.cpSync(from, to, { recursive: true });
31
+ fs.rmSync(from, { recursive: true, force: true });
32
+ }
33
+ }
34
+ /**
35
+ * Entries a swap replaces: everything npm owns inside a version dir. The lockfile
36
+ * is included deliberately — leaving the previous release's `package-lock.json`
37
+ * beside the new `node_modules` would make the directory describe a release it no
38
+ * longer contains, and the next repair reinstall would resolve from that stale lock.
39
+ * Entries absent on either side are skipped, so a dir without a lockfile is fine.
40
+ */
41
+ const NPM_LIVE_ENTRIES = ['node_modules', 'package.json', 'package-lock.json'];
42
+ /**
43
+ * npm-packaged harnesses (claude, codex, kimi, opencode, …). The only fully
44
+ * transactional class: a pinned release can be fetched into a sibling directory,
45
+ * probed there, and swapped in, with the displaced tree kept until the swap is
46
+ * proven — so a failed update leaves the previous release running.
47
+ */
48
+ const npmPackageStrategy = {
49
+ id: 'npm-package',
50
+ transactional: true,
51
+ sharedBinary: false,
52
+ async resolveTarget(ctx) {
53
+ if (ctx.requested === 'latest' || ctx.requested === 'oldest') {
54
+ const resolved = ctx.requested === 'latest'
55
+ ? await getLatestNpmVersion(ctx.agent)
56
+ : await getOldestNpmVersion(ctx.agent);
57
+ if (!resolved) {
58
+ throw new Error(`Could not resolve the ${ctx.requested} published version for ${AGENTS[ctx.agent].name} from npm.`);
59
+ }
60
+ return resolved;
61
+ }
62
+ return ctx.requested;
63
+ },
64
+ async stage(ctx, target) {
65
+ const pkg = AGENTS[ctx.agent].npmPackage;
66
+ const dir = installationDir(ctx.agent, ctx.installation.label);
67
+ const stagingDir = path.join(dir, `.staging-${runId()}`);
68
+ fs.mkdirSync(stagingDir, { recursive: true });
69
+ fs.writeFileSync(path.join(stagingDir, 'package.json'), JSON.stringify({ name: `agents-${ctx.agent}-${target}`, version: '1.0.0', private: true }, null, 2));
70
+ const winShell = process.platform === 'win32';
71
+ ctx.onProgress?.(`Staging ${pkg}@${target}...`);
72
+ // `--ignore-scripts` for the dependency tree; the first-party package's own
73
+ // postinstall is re-run below, exactly as the install path does — several
74
+ // harnesses ship their native binary via that script and are unlaunchable
75
+ // without it.
76
+ await execFileAsync('npm', ['install', `${pkg}@${target}`, '--ignore-scripts'], {
77
+ cwd: stagingDir,
78
+ shell: winShell,
79
+ timeout: INSTALL_TIMEOUT_MS,
80
+ });
81
+ const pkgRoot = path.join(stagingDir, 'node_modules', pkg);
82
+ try {
83
+ const manifest = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf-8'));
84
+ const postinstall = manifest?.scripts?.postinstall;
85
+ if (typeof postinstall === 'string' && postinstall.trim()) {
86
+ ctx.onProgress?.(`Running ${AGENTS[ctx.agent].name} postinstall...`);
87
+ await execFileAsync(postinstall, [], { cwd: pkgRoot, shell: true, timeout: INSTALL_TIMEOUT_MS });
88
+ }
89
+ }
90
+ catch {
91
+ /* non-fatal: the launch probe in update.ts is the real gate */
92
+ }
93
+ return {
94
+ release: target,
95
+ binary: path.join(stagingDir, 'node_modules', '.bin', AGENTS[ctx.agent].cliCommand),
96
+ home: getVersionHomePath(ctx.agent, ctx.installation.label),
97
+ stagingDir,
98
+ };
99
+ },
100
+ async commit(ctx, staged) {
101
+ const dir = installationDir(ctx.agent, ctx.installation.label);
102
+ const rollbackDir = path.join(dir, `.rollback-${runId()}`);
103
+ const displaced = [];
104
+ // Move the live tree aside first, then move the staged tree in. Doing it in
105
+ // this order means the failure window contains no half-merged tree: either
106
+ // the old entries are all aside (undo restores them) or the new ones are all
107
+ // in place.
108
+ for (const entry of NPM_LIVE_ENTRIES) {
109
+ const live = path.join(dir, entry);
110
+ if (!fs.existsSync(live))
111
+ continue;
112
+ moveDir(live, path.join(rollbackDir, entry));
113
+ displaced.push(entry);
114
+ }
115
+ for (const entry of NPM_LIVE_ENTRIES) {
116
+ const from = path.join(staged.stagingDir, entry);
117
+ if (fs.existsSync(from))
118
+ moveDir(from, path.join(dir, entry));
119
+ }
120
+ return {
121
+ undo: () => {
122
+ for (const entry of NPM_LIVE_ENTRIES) {
123
+ fs.rmSync(path.join(dir, entry), { recursive: true, force: true });
124
+ }
125
+ for (const entry of displaced) {
126
+ moveDir(path.join(rollbackDir, entry), path.join(dir, entry));
127
+ }
128
+ fs.rmSync(rollbackDir, { recursive: true, force: true });
129
+ },
130
+ finalize: () => fs.rmSync(rollbackDir, { recursive: true, force: true }),
131
+ };
132
+ },
133
+ };
134
+ /**
135
+ * Harnesses that are ONE global self-updating binary (droid, muse, warp): every
136
+ * installation of the agent points at the same file, so there is nothing
137
+ * per-installation to stage or swap, and updating one necessarily updates all.
138
+ * The honest model is therefore: run the official installer, probe the live
139
+ * binary, and record the new release on every installation that shares it.
140
+ */
141
+ const globalBinaryStrategy = {
142
+ id: 'global-binary',
143
+ transactional: false,
144
+ sharedBinary: true,
145
+ async resolveTarget(ctx) {
146
+ // The installer for these carries no version token, so a requested release
147
+ // cannot be honoured. Fail loud rather than install something else and
148
+ // report it as the pin the user asked for.
149
+ if (ctx.requested !== 'latest') {
150
+ throw new Error(`${AGENTS[ctx.agent].name} is a single self-updating binary with no pinnable releases — `
151
+ + `it can only be updated to the current one. Re-run: agents update ${ctx.agent}@${ctx.installation.label} --to latest`);
152
+ }
153
+ // Resolved after the installer runs — `latest` here is whatever it fetches.
154
+ return 'latest';
155
+ },
156
+ async stage(ctx) {
157
+ const script = AGENTS[ctx.agent].installScript;
158
+ ctx.onProgress?.(`Updating ${AGENTS[ctx.agent].name} via official installer...`);
159
+ await execAsync(script, { timeout: INSTALL_TIMEOUT_MS });
160
+ invalidateLiveVersionCache(ctx.agent);
161
+ const live = await getLiveVersion(ctx.agent);
162
+ if (!live) {
163
+ throw new Error(`${AGENTS[ctx.agent].name} installer finished but its version could not be determined.`);
164
+ }
165
+ return {
166
+ release: live,
167
+ binary: getBinaryPath(ctx.agent, ctx.installation.label),
168
+ home: getVersionHomePath(ctx.agent, ctx.installation.label),
169
+ stagingDir: null,
170
+ };
171
+ },
172
+ async commit() {
173
+ // The installer already replaced the shared binary; there is no per-install
174
+ // swap to perform and no previous copy to restore.
175
+ return { undo: () => { }, finalize: () => { } };
176
+ },
177
+ };
178
+ /**
179
+ * Harnesses installed by an official script that keeps a per-installation copy
180
+ * or symlink farm (grok, cursor, antigravity, hermes, kiro, goose, …). The
181
+ * vendor artifact lands in a global location the installer owns, so the fetch
182
+ * itself is not reversible; what IS per-installation — the version dir's binary
183
+ * link farm — is staged and swapped so a failed re-import cannot strand the
184
+ * installation without a launch target.
185
+ */
186
+ const installScriptStrategy = {
187
+ id: 'install-script',
188
+ transactional: false,
189
+ sharedBinary: false,
190
+ async resolveTarget(ctx) {
191
+ const script = AGENTS[ctx.agent].installScript;
192
+ if (!script.includes('VERSION') && ctx.requested !== 'latest') {
193
+ throw new Error(`${AGENTS[ctx.agent].name}'s installer takes no version, so it can only be updated to the current release. `
194
+ + `Re-run: agents update ${ctx.agent}@${ctx.installation.label} --to latest`);
195
+ }
196
+ return ctx.requested;
197
+ },
198
+ async stage(ctx, target) {
199
+ const config = AGENTS[ctx.agent];
200
+ const script = config.installScript.replaceAll('VERSION', target === 'latest' ? 'latest' : target);
201
+ ctx.onProgress?.(`Updating ${config.name} via official installer...`);
202
+ await execAsync(script, { timeout: INSTALL_TIMEOUT_MS });
203
+ invalidateLiveVersionCache(ctx.agent);
204
+ // findInPath skips our own shims dir, so this is the genuine vendor binary
205
+ // and never our dispatcher (which would produce a self-execing link farm).
206
+ const installed = findInPath(config.cliCommand);
207
+ if (!installed) {
208
+ throw new Error(`${config.name} installer finished but ${config.cliCommand} is not on PATH — the install did not complete.`);
209
+ }
210
+ // On Windows there is no `.cmd` wrapper beside an imported install-script
211
+ // binary, so the staged launch probe cannot run and reports healthy. The
212
+ // gate is therefore weaker here than on POSIX; the unconditional undo in
213
+ // update.ts is what keeps a bad swap recoverable.
214
+ const release = target === 'latest'
215
+ ? (await getLiveVersion(ctx.agent)) ?? target
216
+ : target;
217
+ const dir = installationDir(ctx.agent, ctx.installation.label);
218
+ const stagingDir = path.join(dir, `.staging-${runId()}`);
219
+ fs.mkdirSync(stagingDir, { recursive: true });
220
+ const imported = importInstallScriptBinary({ agentId: ctx.agent, npmPackage: config.npmPackage, cliCommand: config.cliCommand }, ctx.installation.label, installed, stagingDir);
221
+ if (!imported.success) {
222
+ // Swallowing this reported the launch probe's generic "binary not found"
223
+ // instead of the real reason the import failed.
224
+ throw new Error(`${config.name} ${release} was installed but could not be linked into the version directory: ${imported.error ?? 'unknown error'}`);
225
+ }
226
+ return {
227
+ release,
228
+ binary: path.join(stagingDir, 'node_modules', '.bin', config.cliCommand),
229
+ home: getVersionHomePath(ctx.agent, ctx.installation.label),
230
+ stagingDir,
231
+ };
232
+ },
233
+ commit: npmPackageStrategy.commit,
234
+ };
235
+ /**
236
+ * Pick the update strategy for an agent from the registry's declared shape.
237
+ *
238
+ * The ordering mirrors `installVersion`: an npm package wins whenever one is
239
+ * declared (kimi declares both a package and a script, and its package is what
240
+ * `agents add` installs), then a single shared global binary, then a per-install
241
+ * script. Anything else is an integration boundary we do not handle — it throws
242
+ * rather than silently no-opping and reporting success.
243
+ */
244
+ export function selectUpdateStrategy(agent) {
245
+ const config = AGENTS[agent];
246
+ if (config.npmPackage)
247
+ return npmPackageStrategy;
248
+ if (config.installScript && isGlobalBinaryAgent(agent))
249
+ return globalBinaryStrategy;
250
+ if (config.installScript) {
251
+ if (!usesVersionDirLinkFarm(agent)) {
252
+ // The install path resolves this harness's binary somewhere the version
253
+ // dir's link farm does not describe (grok keeps a real per-release copy
254
+ // under its version home). Staging and swapping the link farm would leave
255
+ // the launch target untouched, so the update would record a release that
256
+ // is not actually installed. Refuse instead of reporting a false success.
257
+ throw new Error(`${config.name} keeps its binary outside the managed version directory, so agents-cli cannot yet update an `
258
+ + `installation in place. Install the current release as a new installation: agents add ${agent}@latest`);
259
+ }
260
+ return installScriptStrategy;
261
+ }
262
+ throw new Error(`${config.name} is not installed by agents-cli (it declares no npm package and no installer), so there is nothing to update. `
263
+ + `Update it with its own tooling.`);
264
+ }
265
+ /**
266
+ * Does this harness's launch target live in the version dir's own
267
+ * `node_modules/.bin` link farm — the thing an installation can stage and swap?
268
+ *
269
+ * Probed through `getBinaryPath`, the single resolver the shims and `agents run`
270
+ * use, rather than tested against an agent id, so a harness that resolves its
271
+ * binary elsewhere is recognised without being enumerated here.
272
+ */
273
+ function usesVersionDirLinkFarm(agent) {
274
+ const probe = '0.0.0-probe';
275
+ const expected = path.join(installationDir(agent, probe), 'node_modules', '.bin', AGENTS[agent].cliCommand);
276
+ return getBinaryPath(agent, probe) === expected;
277
+ }
278
+ /**
279
+ * Whether a concrete release can be requested for this agent at all. False for
280
+ * every self-updating harness — their installers carry no version token.
281
+ */
282
+ export function supportsPinnedUpdate(agent) {
283
+ const config = AGENTS[agent];
284
+ if (config.npmPackage)
285
+ return true;
286
+ return !isSelfUpdatingAgent(agent);
287
+ }
288
+ /** Guard a user-supplied release token before it reaches a path or a package spec. */
289
+ export function assertValidRelease(requested) {
290
+ if (!VERSION_RE.test(requested)) {
291
+ throw new Error(`Invalid release: ${JSON.stringify(requested)}`);
292
+ }
293
+ }
@@ -0,0 +1,78 @@
1
+ import type { AgentId } from '../types.js';
2
+ /**
3
+ * Schema version of the on-disk installation record. Bump only for a change a
4
+ * previous CLI could not read; {@link INSTALLATION_SCHEMA} is asserted on read
5
+ * so a newer record fails loud instead of being silently misinterpreted.
6
+ */
7
+ export declare const INSTALLATION_SCHEMA = 1;
8
+ /** File name of the record, written at the root of a version dir. */
9
+ export declare const INSTALLATION_RECORD_FILE = "installation.json";
10
+ /**
11
+ * One entry in an installation's release history — appended on every successful
12
+ * update so `agents update --json` can report where a frozen installation came
13
+ * from without consulting the vendor.
14
+ */
15
+ export interface InstallationRelease {
16
+ /** The vendor release that was live for this span. */
17
+ releaseVersion: string;
18
+ /** ISO-8601 timestamp at which this release became live. */
19
+ at: string;
20
+ }
21
+ /**
22
+ * A frozen agent installation.
23
+ *
24
+ * The load-bearing idea: an installation's IDENTITY ({@link id}, {@link label})
25
+ * is stable for the life of the install, while the vendor release it carries
26
+ * ({@link releaseVersion}) moves only on an explicit `agents update`. Every
27
+ * persisted reference — the global default, an isolated default, a project pin,
28
+ * a routine's agent spec — names the {@link label}, so a release change never
29
+ * invalidates a reference.
30
+ *
31
+ * Before this record existed the version-dir NAME was the only identity, which
32
+ * made those two concepts the same string: updating a release necessarily
33
+ * renamed the directory and broke every reference pointing at it, and two
34
+ * installations of the same release could not coexist at all. Splitting them is
35
+ * what makes both possible.
36
+ */
37
+ export interface Installation {
38
+ schema: number;
39
+ /** Opaque, stable, never reused. Survives every update. */
40
+ id: string;
41
+ agent: AgentId;
42
+ /**
43
+ * The addressable name of this installation — the version-dir basename, and
44
+ * the token users type in `agents update <agent>@<label>`. Frozen at creation.
45
+ */
46
+ label: string;
47
+ /** The vendor release currently installed on disk. Moves on update. */
48
+ releaseVersion: string;
49
+ createdAt: string;
50
+ updatedAt: string;
51
+ /** Newest last. Always non-empty: creation seeds it with the first release. */
52
+ history: InstallationRelease[];
53
+ }
54
+ /**
55
+ * How an installation's release is replaced. Selected from the agent registry's
56
+ * capabilities, never from an agent id — see `selectUpdateStrategy`.
57
+ */
58
+ export type UpdateStrategyId =
59
+ /** Agent ships an npm package: a pinnable release staged into the version dir. */
60
+ 'npm-package'
61
+ /** One global self-updating binary shared by every installation of the agent. */
62
+ | 'global-binary'
63
+ /** An official install script with no pinnable version, re-imported per install. */
64
+ | 'install-script';
65
+ /** Outcome of a single `agents update` run against one installation. */
66
+ export interface UpdateOutcome {
67
+ installation: Installation;
68
+ strategy: UpdateStrategyId;
69
+ fromRelease: string;
70
+ toRelease: string;
71
+ /** True when the resolved target already matched the installed release. */
72
+ unchanged: boolean;
73
+ /**
74
+ * Installations other than the target whose recorded release also moved,
75
+ * because the strategy replaced a binary they share (global-binary only).
76
+ */
77
+ alsoUpdated: Installation[];
78
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Schema version of the on-disk installation record. Bump only for a change a
3
+ * previous CLI could not read; {@link INSTALLATION_SCHEMA} is asserted on read
4
+ * so a newer record fails loud instead of being silently misinterpreted.
5
+ */
6
+ export const INSTALLATION_SCHEMA = 1;
7
+ /** File name of the record, written at the root of a version dir. */
8
+ export const INSTALLATION_RECORD_FILE = 'installation.json';
@@ -0,0 +1,40 @@
1
+ import { type UpdateStrategy } from './strategies.js';
2
+ import type { Installation, UpdateOutcome } from './types.js';
3
+ export interface UpdateInstallationOptions {
4
+ /** `latest` (default), `oldest`, or a concrete release. */
5
+ to?: string;
6
+ onProgress?: (message: string) => void;
7
+ /**
8
+ * Replace the registry-selected strategy. The seam exists so the transaction
9
+ * below can be exercised against a real filesystem without a vendor fetch, and
10
+ * so a track that installs a harness differently (per-installation Cursor
11
+ * isolation) can reuse this orchestration instead of re-implementing it.
12
+ * Omitted in every normal call — `selectUpdateStrategy` is the default.
13
+ */
14
+ strategy?: UpdateStrategy;
15
+ }
16
+ /**
17
+ * Move one frozen installation to a new vendor release, preserving its identity.
18
+ *
19
+ * The transaction is stage → verify → commit → record, with rollback on any
20
+ * failure after the swap:
21
+ *
22
+ * 1. **stage** — fetch the target release somewhere that is not yet live.
23
+ * 2. **verify** — launch the STAGED binary. This is the gate that makes the
24
+ * update safe: a release that cannot start is discarded while
25
+ * the working one is still in place, so the failure mode is
26
+ * "nothing changed", not "the agent no longer runs".
27
+ * 3. **commit** — swap it in, keeping the displaced release until step 4.
28
+ * 4. **record** — re-verify in place, then write the new release into the
29
+ * installation record and drop the rollback material.
30
+ *
31
+ * The installation's `id` and `label` are never touched, so the global default,
32
+ * an isolated default, a project pin, a routine's `version:`, and a profile's
33
+ * `host.version` all keep resolving to this installation across the update —
34
+ * that reference preservation is the whole point of freezing the label.
35
+ *
36
+ * Strategies whose vendor artifact is global (a self-updating binary) report
37
+ * `transactional: false`; for those, step 3 is a no-op and a failed verify is
38
+ * surfaced as a failed update rather than pretended to be reversible.
39
+ */
40
+ export declare function updateInstallation(installation: Installation, options?: UpdateInstallationOptions): Promise<UpdateOutcome>;
@@ -0,0 +1,131 @@
1
+ import * as fs from 'fs';
2
+ import { AGENTS, isAgentHardDeprecated, hardDeprecationError } from '../agents.js';
3
+ import { emit } from '../events.js';
4
+ import { getBinaryPath, invalidateInstalledVersionsCache, invalidateLiveVersionCache, verifyBinaryLaunches, } from '../versions.js';
5
+ import { assertValidRelease, selectUpdateStrategy, } from './strategies.js';
6
+ import { listInstallations, recordRelease } from './store.js';
7
+ /**
8
+ * Move one frozen installation to a new vendor release, preserving its identity.
9
+ *
10
+ * The transaction is stage → verify → commit → record, with rollback on any
11
+ * failure after the swap:
12
+ *
13
+ * 1. **stage** — fetch the target release somewhere that is not yet live.
14
+ * 2. **verify** — launch the STAGED binary. This is the gate that makes the
15
+ * update safe: a release that cannot start is discarded while
16
+ * the working one is still in place, so the failure mode is
17
+ * "nothing changed", not "the agent no longer runs".
18
+ * 3. **commit** — swap it in, keeping the displaced release until step 4.
19
+ * 4. **record** — re-verify in place, then write the new release into the
20
+ * installation record and drop the rollback material.
21
+ *
22
+ * The installation's `id` and `label` are never touched, so the global default,
23
+ * an isolated default, a project pin, a routine's `version:`, and a profile's
24
+ * `host.version` all keep resolving to this installation across the update —
25
+ * that reference preservation is the whole point of freezing the label.
26
+ *
27
+ * Strategies whose vendor artifact is global (a self-updating binary) report
28
+ * `transactional: false`; for those, step 3 is a no-op and a failed verify is
29
+ * surfaced as a failed update rather than pretended to be reversible.
30
+ */
31
+ export async function updateInstallation(installation, options = {}) {
32
+ const agent = installation.agent;
33
+ if (isAgentHardDeprecated(agent))
34
+ throw new Error(hardDeprecationError(agent));
35
+ const requested = options.to ?? 'latest';
36
+ assertValidRelease(requested);
37
+ const strategy = options.strategy ?? selectUpdateStrategy(agent);
38
+ const ctx = { agent, installation, requested, onProgress: options.onProgress };
39
+ const target = await strategy.resolveTarget(ctx);
40
+ if (target === installation.releaseVersion) {
41
+ options.onProgress?.(`${AGENTS[agent].name}@${installation.label} is already on release ${target}; nothing to update.`);
42
+ return {
43
+ installation,
44
+ strategy: strategy.id,
45
+ fromRelease: installation.releaseVersion,
46
+ toRelease: target,
47
+ unchanged: true,
48
+ alsoUpdated: [],
49
+ };
50
+ }
51
+ let staged = null;
52
+ try {
53
+ staged = await strategy.stage(ctx, target);
54
+ const stagedHealth = await verifyBinaryLaunches(staged.binary, staged.home);
55
+ if (!stagedHealth.ok) {
56
+ throw new Error(`${AGENTS[agent].name} release ${staged.release} was fetched but its binary failed to launch`
57
+ + `${stagedHealth.detail ? ` (${stagedHealth.detail})` : ''}. `
58
+ + `${installation.label} is unchanged and still on ${installation.releaseVersion}.`);
59
+ }
60
+ // The installer may have reported a release the installation already has
61
+ // (a self-updating binary that was already current). Recording it would
62
+ // claim a change that did not happen and append a bogus history entry.
63
+ if (staged.release === installation.releaseVersion) {
64
+ options.onProgress?.(`${AGENTS[agent].name}@${installation.label} is already on release ${staged.release}; nothing to update.`);
65
+ // Deliberately NOT committed: there is no new release to make live, and
66
+ // for a strategy that swaps the version dir a commit here would displace
67
+ // a working tree, discard its rollback material, and skip the live probe —
68
+ // all while reporting that nothing changed. The `finally` clears staging.
69
+ return {
70
+ installation,
71
+ strategy: strategy.id,
72
+ fromRelease: installation.releaseVersion,
73
+ toRelease: staged.release,
74
+ unchanged: true,
75
+ alsoUpdated: [],
76
+ };
77
+ }
78
+ const handles = await strategy.commit(ctx, staged);
79
+ try {
80
+ // Probe what will actually execute — `getBinaryPath` is the same resolver
81
+ // the shims and `agents run` use — not the staging copy probed above.
82
+ const liveBinary = getBinaryPath(agent, installation.label);
83
+ const liveHealth = await verifyBinaryLaunches(liveBinary, staged.home);
84
+ if (!liveHealth.ok) {
85
+ throw new Error(`${AGENTS[agent].name} release ${staged.release} failed to launch after being installed`
86
+ + `${liveHealth.detail ? ` (${liveHealth.detail})` : ''}.`);
87
+ }
88
+ }
89
+ catch (err) {
90
+ // Undo unconditionally. `transactional` describes whether the VENDOR
91
+ // artifact can be put back, not whether this directory can — gating the
92
+ // undo on it left an installer-driven harness with the broken tree live
93
+ // AND the previous one orphaned in rollback material nothing deletes.
94
+ // A strategy with nothing to restore returns a no-op undo.
95
+ handles.undo();
96
+ throw new Error(strategy.transactional
97
+ ? `${err.message} Rolled back to ${installation.releaseVersion}.`
98
+ : `${err.message} The version directory was restored, but ${AGENTS[agent].name}'s installer `
99
+ + `had already replaced the binary it manages globally — repair it with: agents add ${agent}@latest`);
100
+ }
101
+ handles.finalize();
102
+ const updated = recordRelease(installation, staged.release);
103
+ // Several installations of a global-binary harness point at the same file,
104
+ // so the one we just replaced is live for all of them. Recording the release
105
+ // only on the target would leave the others claiming a release that is no
106
+ // longer on disk.
107
+ const alsoUpdated = strategy.sharedBinary
108
+ ? listInstallations(agent)
109
+ .filter((other) => other.label !== installation.label && other.releaseVersion !== staged.release)
110
+ .map((other) => recordRelease(other, staged.release))
111
+ : [];
112
+ invalidateInstalledVersionsCache(agent);
113
+ invalidateLiveVersionCache(agent);
114
+ // A release really was installed, so this is the right event; `installation`
115
+ // says WHICH frozen install received it, since the label no longer equals
116
+ // the release.
117
+ emit('version.install', { agent, version: staged.release, installation: installation.label });
118
+ return {
119
+ installation: updated,
120
+ strategy: strategy.id,
121
+ fromRelease: installation.releaseVersion,
122
+ toRelease: staged.release,
123
+ unchanged: false,
124
+ alsoUpdated,
125
+ };
126
+ }
127
+ finally {
128
+ if (staged?.stagingDir)
129
+ fs.rmSync(staged.stagingDir, { recursive: true, force: true });
130
+ }
131
+ }
@@ -117,6 +117,33 @@ export declare function migrateExtrasExtrasToAgentsExtras(historyDir?: string):
117
117
  * Params default to the real routines dir; injectable for tests.
118
118
  */
119
119
  export declare function migrateRoutineDeviceToDevices(routinesDir?: string): void;
120
+ /**
121
+ * Fold the legacy host-placement `remoteCwd` field into the canonical portable
122
+ * `cwd` (RUSH-2290). Host dispatch used to read `remoteCwd` while a local run
123
+ * inferred its cwd from `repo` — two path semantics for one concept. The runner
124
+ * now resolves every placement from `cwd`, so this idempotently rewrites the
125
+ * field:
126
+ *
127
+ * - `remoteCwd` present, no `cwd` → rename to `cwd`.
128
+ * - both present and equal → drop the duplicate `remoteCwd`.
129
+ * - both present and DIFFERENT → conflict: leave BOTH fields untouched so
130
+ * the migration never silently chooses one; `validateJob`/`doctor` then flag the
131
+ * pair and the routine stays paused rather than running against a guessed path.
132
+ *
133
+ * Idempotent: a file with only `cwd` (already migrated) is skipped.
134
+ */
135
+ export declare function migrateRoutineRemoteCwdToCwd(routinesDir?: string): void;
136
+ /**
137
+ * Pause every currently-active routine whose execution context no longer
138
+ * resolves ready (RUSH-2290). An agent/workflow routine with no project/cwd, a
139
+ * missing directory, or a non-portable path used to fire and fail every tick —
140
+ * the mass auth_failed / untrusted-home storm this ticket exists to stop. After
141
+ * the fold, such a routine is deactivated on THIS device (only), preventing it
142
+ * from being scheduled until `agents routines doctor --all --fix` (or a repair +
143
+ * `resume`) makes it ready. Never materializes a device manifest that does not
144
+ * yet exist, and never touches command routines (they run in the target home).
145
+ */
146
+ export declare function pauseUnreadyEnabledRoutines(): void;
120
147
  /**
121
148
  * Fold the legacy watchdog enable sentinel into the watchdog routine.
122
149
  *