@phnx-labs/agents-cli 1.22.31 → 1.22.32

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 (66) hide show
  1. package/CHANGELOG.md +66 -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.d.ts +1 -1
  9. package/dist/commands/sessions.js +44 -10
  10. package/dist/commands/update.d.ts +2 -0
  11. package/dist/commands/update.js +148 -0
  12. package/dist/index.js +3 -1
  13. package/dist/lib/catchup.js +4 -1
  14. package/dist/lib/daemon.d.ts +17 -0
  15. package/dist/lib/daemon.js +69 -3
  16. package/dist/lib/devices/doctor-findings.d.ts +7 -2
  17. package/dist/lib/devices/doctor-findings.js +53 -2
  18. package/dist/lib/devices/doctor-overview-cache.d.ts +7 -0
  19. package/dist/lib/devices/doctor-overview-cache.js +15 -0
  20. package/dist/lib/devices/fleet-divergence.d.ts +11 -0
  21. package/dist/lib/devices/fleet-divergence.js +6 -0
  22. package/dist/lib/devices/fleet-inventory.js +16 -2
  23. package/dist/lib/drift.d.ts +6 -1
  24. package/dist/lib/drift.js +9 -0
  25. package/dist/lib/hooks/cache.js +20 -1
  26. package/dist/lib/hooks.d.ts +91 -1
  27. package/dist/lib/hooks.js +289 -3
  28. package/dist/lib/hosts/passthrough.js +3 -0
  29. package/dist/lib/installations/index.d.ts +14 -0
  30. package/dist/lib/installations/index.js +14 -0
  31. package/dist/lib/installations/resolve.d.ts +43 -0
  32. package/dist/lib/installations/resolve.js +93 -0
  33. package/dist/lib/installations/store.d.ts +56 -0
  34. package/dist/lib/installations/store.js +196 -0
  35. package/dist/lib/installations/strategies.d.ts +73 -0
  36. package/dist/lib/installations/strategies.js +293 -0
  37. package/dist/lib/installations/types.d.ts +78 -0
  38. package/dist/lib/installations/types.js +8 -0
  39. package/dist/lib/installations/update.d.ts +40 -0
  40. package/dist/lib/installations/update.js +131 -0
  41. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  43. package/dist/lib/migrate.d.ts +27 -0
  44. package/dist/lib/migrate.js +112 -2
  45. package/dist/lib/routine-context.d.ts +144 -0
  46. package/dist/lib/routine-context.js +268 -0
  47. package/dist/lib/routine-readiness.d.ts +47 -0
  48. package/dist/lib/routine-readiness.js +239 -0
  49. package/dist/lib/routines.d.ts +97 -1
  50. package/dist/lib/routines.js +107 -1
  51. package/dist/lib/runner.d.ts +18 -4
  52. package/dist/lib/runner.js +291 -98
  53. package/dist/lib/scheduler.d.ts +7 -1
  54. package/dist/lib/scheduler.js +5 -2
  55. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  56. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  57. package/dist/lib/self-heal/checks/hook-runtime.d.ts +2 -0
  58. package/dist/lib/self-heal/checks/hook-runtime.js +16 -0
  59. package/dist/lib/self-heal/registry.js +5 -2
  60. package/dist/lib/self-heal/types.d.ts +1 -1
  61. package/dist/lib/session/state.js +4 -1
  62. package/dist/lib/startup/command-registry.d.ts +1 -0
  63. package/dist/lib/startup/command-registry.js +2 -0
  64. package/dist/lib/versions.d.ts +24 -0
  65. package/dist/lib/versions.js +49 -16
  66. package/package.json +2 -2
@@ -0,0 +1,196 @@
1
+ import * as crypto from 'crypto';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { atomicWriteFileSync } from '../fs-atomic.js';
5
+ import { getVersionsDir } from '../state.js';
6
+ import { VERSION_RE } from '../agent-spec/primitives.js';
7
+ import { INSTALLATION_RECORD_FILE, INSTALLATION_SCHEMA } from './types.js';
8
+ /**
9
+ * Persistence for {@link Installation} records.
10
+ *
11
+ * The record lives at `<versionDir>/installation.json` rather than in one
12
+ * central index: the version dir is what `agents trash`/`agents prune` move,
13
+ * copy and restore wholesale, so keeping identity inside it means identity
14
+ * travels with the install instead of dangling in a registry that forgets to
15
+ * follow. It is also why the file name is registered in versions.ts's
16
+ * `PRESERVED_ON_CLEAN_REINSTALL` — a repair reinstall must not mint a new id.
17
+ *
18
+ * Deliberately depends on nothing but `state`/`fs-atomic`/`primitives` so
19
+ * versions.ts can import it without an import cycle.
20
+ */
21
+ /** Directory holding one installation. Mirrors versions.ts `getVersionDir`. */
22
+ export function installationDir(agent, label) {
23
+ return path.join(getVersionsDir(), agent, label);
24
+ }
25
+ export function installationRecordPath(agent, label) {
26
+ return path.join(installationDir(agent, label), INSTALLATION_RECORD_FILE);
27
+ }
28
+ /** Mint an opaque installation id. Random, never derived from the release. */
29
+ export function mintInstallationId() {
30
+ return `ins_${crypto.randomBytes(12).toString('hex')}`;
31
+ }
32
+ function nowIso() {
33
+ return new Date().toISOString();
34
+ }
35
+ function assertValidRecord(value, file) {
36
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
37
+ throw new Error(`Installation record corrupted at ${file}: expected a JSON object.`);
38
+ }
39
+ const record = value;
40
+ if (typeof record.schema !== 'number') {
41
+ throw new Error(`Installation record corrupted at ${file}: missing numeric "schema".`);
42
+ }
43
+ if (record.schema > INSTALLATION_SCHEMA) {
44
+ throw new Error(`Installation record at ${file} was written by a newer agents-cli (schema ${record.schema} > ${INSTALLATION_SCHEMA}). Upgrade agents-cli.`);
45
+ }
46
+ for (const key of ['id', 'agent', 'label', 'releaseVersion', 'createdAt', 'updatedAt']) {
47
+ if (typeof record[key] !== 'string' || !record[key]) {
48
+ throw new Error(`Installation record corrupted at ${file}: missing string "${key}".`);
49
+ }
50
+ }
51
+ if (!Array.isArray(record.history) || record.history.length === 0) {
52
+ throw new Error(`Installation record corrupted at ${file}: "history" must be a non-empty array.`);
53
+ }
54
+ return record;
55
+ }
56
+ /**
57
+ * Read the record for one installation, or null when the version dir has none.
58
+ * Never mints — use {@link ensureInstallation} for the migrating read.
59
+ */
60
+ export function readInstallation(agent, label) {
61
+ const file = installationRecordPath(agent, label);
62
+ let raw;
63
+ try {
64
+ raw = fs.readFileSync(file, 'utf-8');
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ let parsed;
70
+ try {
71
+ parsed = JSON.parse(raw);
72
+ }
73
+ catch {
74
+ throw new Error(`Installation record corrupted at ${file}: not valid JSON.`);
75
+ }
76
+ return assertValidRecord(parsed, file);
77
+ }
78
+ export function writeInstallation(installation) {
79
+ const file = installationRecordPath(installation.agent, installation.label);
80
+ fs.mkdirSync(path.dirname(file), { recursive: true });
81
+ atomicWriteFileSync(file, `${JSON.stringify(installation, null, 2)}\n`);
82
+ }
83
+ /**
84
+ * Read the record for an existing version dir, minting and persisting one on
85
+ * first sight. This is the migration path for every installation created before
86
+ * frozen identity existed: their directory name IS their release, so the
87
+ * migrated record seeds `label === releaseVersion` and dates the install from
88
+ * the directory's own mtime rather than pretending it was created now.
89
+ *
90
+ * Throws when the version dir does not exist — an installation record must never
91
+ * describe an install that isn't there.
92
+ */
93
+ export function ensureInstallation(agent, label) {
94
+ const existing = readInstallation(agent, label);
95
+ if (existing)
96
+ return existing;
97
+ const dir = installationDir(agent, label);
98
+ if (!fs.existsSync(dir)) {
99
+ throw new Error(`No installation directory for ${agent}@${label} at ${dir}.`);
100
+ }
101
+ let createdAt;
102
+ try {
103
+ createdAt = fs.statSync(dir).mtime.toISOString();
104
+ }
105
+ catch {
106
+ createdAt = nowIso();
107
+ }
108
+ const migrated = {
109
+ schema: INSTALLATION_SCHEMA,
110
+ id: mintInstallationId(),
111
+ agent,
112
+ label,
113
+ releaseVersion: label,
114
+ createdAt,
115
+ updatedAt: createdAt,
116
+ history: [{ releaseVersion: label, at: createdAt }],
117
+ };
118
+ writeInstallation(migrated);
119
+ return migrated;
120
+ }
121
+ /**
122
+ * Create the record for a freshly-installed version dir. Idempotent: a repeat
123
+ * `agents add` of the same label keeps the original id (identity is frozen) and
124
+ * only records the release if it actually moved.
125
+ */
126
+ export function createInstallation(agent, label, releaseVersion) {
127
+ if (!VERSION_RE.test(label)) {
128
+ throw new Error(`Invalid installation label: ${JSON.stringify(label)}`);
129
+ }
130
+ const existing = readInstallation(agent, label);
131
+ if (existing) {
132
+ return existing.releaseVersion === releaseVersion
133
+ ? existing
134
+ : recordRelease(existing, releaseVersion);
135
+ }
136
+ const at = nowIso();
137
+ const created = {
138
+ schema: INSTALLATION_SCHEMA,
139
+ id: mintInstallationId(),
140
+ agent,
141
+ label,
142
+ releaseVersion,
143
+ createdAt: at,
144
+ updatedAt: at,
145
+ history: [{ releaseVersion, at }],
146
+ };
147
+ writeInstallation(created);
148
+ return created;
149
+ }
150
+ /**
151
+ * Move an installation's recorded release forward, preserving identity. Returns
152
+ * the persisted record. Call only AFTER the new release is live on disk — the
153
+ * record is the claim that it is.
154
+ */
155
+ export function recordRelease(installation, releaseVersion) {
156
+ const at = nowIso();
157
+ const next = {
158
+ ...installation,
159
+ releaseVersion,
160
+ updatedAt: at,
161
+ history: [...installation.history, { releaseVersion, at }],
162
+ };
163
+ writeInstallation(next);
164
+ return next;
165
+ }
166
+ /** Version-dir basenames present for an agent, oldest-first by directory name. */
167
+ export function listInstallationLabels(agent) {
168
+ const agentDir = path.join(getVersionsDir(), agent);
169
+ let entries;
170
+ try {
171
+ entries = fs.readdirSync(agentDir, { withFileTypes: true });
172
+ }
173
+ catch {
174
+ return [];
175
+ }
176
+ return entries
177
+ .filter((entry) => entry.isDirectory() && VERSION_RE.test(entry.name))
178
+ .map((entry) => entry.name)
179
+ .sort();
180
+ }
181
+ /**
182
+ * Every installation of an agent, migrating records as needed. A version dir
183
+ * that disappears mid-scan is skipped rather than failing the whole listing.
184
+ */
185
+ export function listInstallations(agent) {
186
+ const out = [];
187
+ for (const label of listInstallationLabels(agent)) {
188
+ try {
189
+ out.push(ensureInstallation(agent, label));
190
+ }
191
+ catch {
192
+ /* dir vanished or unreadable — not an installation we can act on */
193
+ }
194
+ }
195
+ return out;
196
+ }
@@ -0,0 +1,73 @@
1
+ import type { AgentId } from '../types.js';
2
+ import type { Installation, UpdateStrategyId } from './types.js';
3
+ export interface UpdateContext {
4
+ agent: AgentId;
5
+ installation: Installation;
6
+ /** What the user asked for: `latest`, `oldest`, or a concrete release. */
7
+ requested: string;
8
+ onProgress?: (message: string) => void;
9
+ }
10
+ /** A release fetched but not yet live. */
11
+ export interface StagedRelease {
12
+ release: string;
13
+ /** Extensionless launch target to probe. Windows appends `.cmd` (see verifyBinaryLaunches). */
14
+ binary: string;
15
+ /** HOME the probe runs under — always the installation's own home. */
16
+ home: string;
17
+ /** Scratch dir to delete once the run finishes, or null when nothing was staged. */
18
+ stagingDir: string | null;
19
+ }
20
+ /** Undo/finish handles returned by a commit, so update.ts owns the transaction. */
21
+ export interface CommitHandles {
22
+ /** Put the previous release back. Must be safe to call once, immediately after commit. */
23
+ undo: () => void;
24
+ /** Discard the undo material. Called only once the update is durable. */
25
+ finalize: () => void;
26
+ }
27
+ /**
28
+ * How one class of harness replaces the release inside a frozen installation.
29
+ *
30
+ * Chosen from the registry's declared capabilities — never from an agent id — so
31
+ * a harness added to `AGENTS` is covered the day it lands. See
32
+ * {@link selectUpdateStrategy}.
33
+ */
34
+ export interface UpdateStrategy {
35
+ readonly id: UpdateStrategyId;
36
+ /**
37
+ * True only when `undo` can restore the PREVIOUS RELEASE in full — i.e. the
38
+ * vendor artifact lives inside this installation's own directory and was
39
+ * fetched without mutating anything global.
40
+ *
41
+ * It is not a switch for whether the orchestrator rolls back: `undo` always
42
+ * runs on a post-commit failure, because every strategy that displaces
43
+ * something must put it back. What this flag changes is what the user is
44
+ * told, since for an installer-driven harness the global binary the vendor
45
+ * replaced is not ours to restore.
46
+ */
47
+ readonly transactional: boolean;
48
+ /** True when several installations of this agent share one binary on disk. */
49
+ readonly sharedBinary: boolean;
50
+ /** Turn `requested` into the concrete release this run will install. */
51
+ resolveTarget(ctx: UpdateContext): Promise<string>;
52
+ /** Fetch the target release into a place that is not yet live. */
53
+ stage(ctx: UpdateContext, target: string): Promise<StagedRelease>;
54
+ /** Make the staged release the live one. */
55
+ commit(ctx: UpdateContext, staged: StagedRelease): Promise<CommitHandles>;
56
+ }
57
+ /**
58
+ * Pick the update strategy for an agent from the registry's declared shape.
59
+ *
60
+ * The ordering mirrors `installVersion`: an npm package wins whenever one is
61
+ * declared (kimi declares both a package and a script, and its package is what
62
+ * `agents add` installs), then a single shared global binary, then a per-install
63
+ * script. Anything else is an integration boundary we do not handle — it throws
64
+ * rather than silently no-opping and reporting success.
65
+ */
66
+ export declare function selectUpdateStrategy(agent: AgentId): UpdateStrategy;
67
+ /**
68
+ * Whether a concrete release can be requested for this agent at all. False for
69
+ * every self-updating harness — their installers carry no version token.
70
+ */
71
+ export declare function supportsPinnedUpdate(agent: AgentId): boolean;
72
+ /** Guard a user-supplied release token before it reaches a path or a package spec. */
73
+ export declare function assertValidRelease(requested: string): void;
@@ -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';