@phnx-labs/agents-cli 1.22.30 → 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 (83) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +13 -6
  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/focus.d.ts +4 -1
  8. package/dist/commands/focus.js +19 -4
  9. package/dist/commands/routines.js +164 -36
  10. package/dist/commands/secrets.d.ts +20 -0
  11. package/dist/commands/secrets.js +59 -93
  12. package/dist/commands/{sessions-favorite.d.ts → sessions-bookmark.d.ts} +7 -7
  13. package/dist/commands/{sessions-favorite.js → sessions-bookmark.js} +38 -38
  14. package/dist/commands/sessions-browser.d.ts +10 -8
  15. package/dist/commands/sessions-browser.js +61 -32
  16. package/dist/commands/sessions-stats.js +1 -1
  17. package/dist/commands/sessions.d.ts +11 -9
  18. package/dist/commands/sessions.js +114 -65
  19. package/dist/commands/update.d.ts +2 -0
  20. package/dist/commands/update.js +148 -0
  21. package/dist/index.js +4 -2
  22. package/dist/lib/catchup.js +4 -1
  23. package/dist/lib/daemon.d.ts +17 -0
  24. package/dist/lib/daemon.js +69 -3
  25. package/dist/lib/devices/doctor-findings.d.ts +7 -2
  26. package/dist/lib/devices/doctor-findings.js +53 -2
  27. package/dist/lib/devices/doctor-overview-cache.d.ts +7 -0
  28. package/dist/lib/devices/doctor-overview-cache.js +15 -0
  29. package/dist/lib/devices/fleet-divergence.d.ts +11 -0
  30. package/dist/lib/devices/fleet-divergence.js +6 -0
  31. package/dist/lib/devices/fleet-inventory.js +16 -2
  32. package/dist/lib/drift.d.ts +6 -1
  33. package/dist/lib/drift.js +9 -0
  34. package/dist/lib/hooks/cache.js +20 -1
  35. package/dist/lib/hooks.d.ts +91 -1
  36. package/dist/lib/hooks.js +289 -3
  37. package/dist/lib/hosts/passthrough.js +3 -0
  38. package/dist/lib/installations/index.d.ts +14 -0
  39. package/dist/lib/installations/index.js +14 -0
  40. package/dist/lib/installations/resolve.d.ts +43 -0
  41. package/dist/lib/installations/resolve.js +93 -0
  42. package/dist/lib/installations/store.d.ts +56 -0
  43. package/dist/lib/installations/store.js +196 -0
  44. package/dist/lib/installations/strategies.d.ts +73 -0
  45. package/dist/lib/installations/strategies.js +293 -0
  46. package/dist/lib/installations/types.d.ts +78 -0
  47. package/dist/lib/installations/types.js +8 -0
  48. package/dist/lib/installations/update.d.ts +40 -0
  49. package/dist/lib/installations/update.js +131 -0
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  52. package/dist/lib/migrate.d.ts +27 -0
  53. package/dist/lib/migrate.js +118 -2
  54. package/dist/lib/picker.d.ts +6 -3
  55. package/dist/lib/picker.js +7 -2
  56. package/dist/lib/routine-context.d.ts +144 -0
  57. package/dist/lib/routine-context.js +268 -0
  58. package/dist/lib/routine-readiness.d.ts +47 -0
  59. package/dist/lib/routine-readiness.js +239 -0
  60. package/dist/lib/routines.d.ts +97 -1
  61. package/dist/lib/routines.js +107 -1
  62. package/dist/lib/runner.d.ts +18 -4
  63. package/dist/lib/runner.js +291 -98
  64. package/dist/lib/scheduler.d.ts +7 -1
  65. package/dist/lib/scheduler.js +5 -2
  66. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  67. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  68. package/dist/lib/secrets/agent.d.ts +0 -1
  69. package/dist/lib/secrets/agent.js +0 -4
  70. package/dist/lib/secrets/session-store.d.ts +0 -4
  71. package/dist/lib/secrets/session-store.js +0 -5
  72. package/dist/lib/self-heal/checks/hook-runtime.d.ts +2 -0
  73. package/dist/lib/self-heal/checks/hook-runtime.js +16 -0
  74. package/dist/lib/self-heal/registry.js +5 -2
  75. package/dist/lib/self-heal/types.d.ts +1 -1
  76. package/dist/lib/session/{favorites.d.ts → bookmarks.d.ts} +14 -14
  77. package/dist/lib/session/{favorites.js → bookmarks.js} +22 -22
  78. package/dist/lib/session/state.js +4 -1
  79. package/dist/lib/startup/command-registry.d.ts +1 -0
  80. package/dist/lib/startup/command-registry.js +2 -0
  81. package/dist/lib/versions.d.ts +24 -0
  82. package/dist/lib/versions.js +49 -16
  83. package/package.json +2 -2
@@ -93,10 +93,6 @@ export declare function deleteBundleSessions(name: string): void;
93
93
  /** Delete one bundle's session blob and prune it from the index. */
94
94
  export declare function deleteSession(name: string, harness?: string): void;
95
95
  export declare function deleteLeaseSession(leaseId: string): number;
96
- export declare function activeLeaseSessions(now?: number): Array<{
97
- name: string;
98
- lease: SecretLease;
99
- }>;
100
96
  /** Delete every session blob + the index (for `secrets lock --all`). */
101
97
  export declare function deleteAllSessions(): void;
102
98
  /** Rehydrate every unexpired session into `[name, entry]` pairs for the broker to
@@ -208,11 +208,6 @@ export function deleteLeaseSession(leaseId) {
208
208
  }
209
209
  return deleted;
210
210
  }
211
- export function activeLeaseSessions(now = Date.now()) {
212
- return rehydrateSessions(now)
213
- .filter((item) => Boolean(item.entry.lease))
214
- .map(({ name, entry }) => ({ name, lease: entry.lease }));
215
- }
216
211
  /** Delete every session blob + the index (for `secrets lock --all`). */
217
212
  export function deleteAllSessions() {
218
213
  if (!shouldPersist())
@@ -0,0 +1,2 @@
1
+ import type { HealCheck } from '../types.js';
2
+ export declare const hookRuntimeCheck: HealCheck;
@@ -0,0 +1,16 @@
1
+ // hook-runtime check — detects and bounded-repairs agents-managed generated
2
+ // hook shims (~/.agents/.cache/shims/hooks/<name>.sh). A native hook command can
3
+ // look wired while its generated wrapper is missing, non-executable, non-file,
4
+ // or broken — silent breakage. Repair runs at most once per unique path per
5
+ // pass (no retry, no sync recursion); unresolved findings stay needsAttention.
6
+ import { resultOf } from '../types.js';
7
+ import { repairManagedHookRuntimeArtifacts } from '../../hooks.js';
8
+ export const hookRuntimeCheck = {
9
+ id: 'hook-runtime',
10
+ title: 'Generated hook runtime shims',
11
+ cadence: 'frequent',
12
+ async run(ctx) {
13
+ const report = repairManagedHookRuntimeArtifacts({ dryRun: ctx.dryRun });
14
+ return resultOf(report.fixed, report.needsAttention);
15
+ },
16
+ };
@@ -5,15 +5,18 @@
5
5
  // SelfHealReport. Both front doors — the daemon (by cadence) and `agents doctor`
6
6
  // (all, or by id) — call runSelfHeal.
7
7
  import { resourcesCheck } from './checks/resources.js';
8
+ import { hookRuntimeCheck } from './checks/hook-runtime.js';
8
9
  import { shimsCheck } from './checks/shims.js';
9
10
  import { shadowingCheck } from './checks/shadowing.js';
10
11
  import { pathCheck } from './checks/path.js';
11
- // Order matters: cheap structural fixes (shims, shadow adoption, PATH) before the
12
- // heavier resource reconciliation, so a freshly-repaired shim is in place first.
12
+ // Order matters: cheap structural fixes (shims, shadow adoption, PATH, generated
13
+ // hook wrappers) before the heavier resource reconciliation, so a freshly-
14
+ // repaired shim is in place first.
13
15
  export const HEAL_CHECKS = [
14
16
  shimsCheck,
15
17
  shadowingCheck,
16
18
  pathCheck,
19
+ hookRuntimeCheck,
17
20
  resourcesCheck,
18
21
  ];
19
22
  /** Run the selected checks, isolating per-check failures. */
@@ -1,4 +1,4 @@
1
- export type HealCheckId = 'resources' | 'shims' | 'shadowing' | 'path';
1
+ export type HealCheckId = 'resources' | 'hook-runtime' | 'shims' | 'shadowing' | 'path';
2
2
  /** When the daemon schedules a check. */
3
3
  export type HealCadence = 'startup' | 'frequent' | 'periodic';
4
4
  export interface HealCtx {
@@ -1,39 +1,39 @@
1
1
  /**
2
- * Favorited sessions — the durable "keep this one handy" mark a human puts on a
2
+ * Bookmarked sessions — the durable "keep this one handy" mark a human puts on a
3
3
  * session, deliberately kept OUT of the session index.
4
4
  *
5
5
  * `sessions.db` is a rebuildable CACHE: a reindex or a schema bump throws its
6
- * rows away and re-derives them from the transcripts on disk. A favorite is not
6
+ * rows away and re-derives them from the transcripts on disk. A bookmark is not
7
7
  * derivable from a transcript — it is a human's choice — so a column there would
8
8
  * be silently lost on the next rebuild. It lives in `~/.agents/.history/` instead,
9
9
  * next to the actor sidecars, which is never pruned.
10
10
  *
11
11
  * One flat set of session ids. The id is the transcript's own uuid, so it is
12
12
  * stable and machine-independent — but the file is NOT synced today: session sync
13
- * carries `.history/backups/` (`lib/session/sync/agents.ts`), not this. Favorites
13
+ * carries `.history/backups/` (`lib/session/sync/agents.ts`), not this. Bookmarks
14
14
  * are therefore per-machine; carrying them across the fleet would mean adding
15
15
  * them to the sync manifest, which this does not do.
16
16
  *
17
- * Reads are memoized against the file's mtime — the picker asks `isFavorite` once
17
+ * Reads are memoized against the file's mtime — the picker asks `isBookmarked` once
18
18
  * per rendered row, and re-reading a JSON file per row on every keystroke is the
19
19
  * kind of cost that makes a TUI feel broken.
20
20
  */
21
- export declare function favoritesFilePath(): string;
21
+ export declare function bookmarksFilePath(): string;
22
22
  /** Drop the memoized read. Tests that write the file directly need this; nothing
23
23
  * in the CLI does, because every mutation here refreshes the cache itself. */
24
- export declare function clearFavoritesCache(): void;
24
+ export declare function clearBookmarksCache(): void;
25
25
  /**
26
- * Every favorited session id. Empty (never throws) when the file is absent,
27
- * unreadable, or malformed — a corrupt favorites file must not take down
26
+ * Every bookmarked session id. Empty (never throws) when the file is absent,
27
+ * unreadable, or malformed — a corrupt bookmarks file must not take down
28
28
  * `agents sessions`.
29
29
  */
30
- export declare function listFavorites(): Set<string>;
31
- export declare function isFavorite(sessionId: string | undefined): boolean;
30
+ export declare function listBookmarks(): Set<string>;
31
+ export declare function isBookmarked(sessionId: string | undefined): boolean;
32
32
  /**
33
- * Set (or clear) the favorite mark on a session. Returns the resulting state, so
33
+ * Set (or clear) the bookmark mark on a session. Returns the resulting state, so
34
34
  * a caller can report it without a second read. A no-op write is skipped, which
35
35
  * keeps the file's mtime — and every other process's memoized read — untouched.
36
36
  */
37
- export declare function setFavorite(sessionId: string, on: boolean): boolean;
38
- /** Flip the mark; returns the new state (`true` = now favorited). */
39
- export declare function toggleFavorite(sessionId: string): boolean;
37
+ export declare function setBookmark(sessionId: string, on: boolean): boolean;
38
+ /** Flip the mark; returns the new state (`true` = now bookmarked). */
39
+ export declare function toggleBookmark(sessionId: string): boolean;
@@ -1,28 +1,28 @@
1
1
  /**
2
- * Favorited sessions — the durable "keep this one handy" mark a human puts on a
2
+ * Bookmarked sessions — the durable "keep this one handy" mark a human puts on a
3
3
  * session, deliberately kept OUT of the session index.
4
4
  *
5
5
  * `sessions.db` is a rebuildable CACHE: a reindex or a schema bump throws its
6
- * rows away and re-derives them from the transcripts on disk. A favorite is not
6
+ * rows away and re-derives them from the transcripts on disk. A bookmark is not
7
7
  * derivable from a transcript — it is a human's choice — so a column there would
8
8
  * be silently lost on the next rebuild. It lives in `~/.agents/.history/` instead,
9
9
  * next to the actor sidecars, which is never pruned.
10
10
  *
11
11
  * One flat set of session ids. The id is the transcript's own uuid, so it is
12
12
  * stable and machine-independent — but the file is NOT synced today: session sync
13
- * carries `.history/backups/` (`lib/session/sync/agents.ts`), not this. Favorites
13
+ * carries `.history/backups/` (`lib/session/sync/agents.ts`), not this. Bookmarks
14
14
  * are therefore per-machine; carrying them across the fleet would mean adding
15
15
  * them to the sync manifest, which this does not do.
16
16
  *
17
- * Reads are memoized against the file's mtime — the picker asks `isFavorite` once
17
+ * Reads are memoized against the file's mtime — the picker asks `isBookmarked` once
18
18
  * per rendered row, and re-reading a JSON file per row on every keystroke is the
19
19
  * kind of cost that makes a TUI feel broken.
20
20
  */
21
21
  import fs from 'node:fs';
22
22
  import path from 'node:path';
23
23
  import { getHistoryDir } from '../state.js';
24
- export function favoritesFilePath() {
25
- return path.join(getHistoryDir(), 'favorites.json');
24
+ export function bookmarksFilePath() {
25
+ return path.join(getHistoryDir(), 'bookmarks.json');
26
26
  }
27
27
  /** Memoized parse, invalidated by the file's mtime+size (another process — or
28
28
  * another machine's sync — can rewrite it under us). */
@@ -38,16 +38,16 @@ function statKey(file) {
38
38
  }
39
39
  /** Drop the memoized read. Tests that write the file directly need this; nothing
40
40
  * in the CLI does, because every mutation here refreshes the cache itself. */
41
- export function clearFavoritesCache() {
41
+ export function clearBookmarksCache() {
42
42
  cache = null;
43
43
  }
44
44
  /**
45
- * Every favorited session id. Empty (never throws) when the file is absent,
46
- * unreadable, or malformed — a corrupt favorites file must not take down
45
+ * Every bookmarked session id. Empty (never throws) when the file is absent,
46
+ * unreadable, or malformed — a corrupt bookmarks file must not take down
47
47
  * `agents sessions`.
48
48
  */
49
- export function listFavorites() {
50
- const file = favoritesFilePath();
49
+ export function listBookmarks() {
50
+ const file = bookmarksFilePath();
51
51
  const key = statKey(file);
52
52
  if (cache && cache.key === key)
53
53
  return cache.ids;
@@ -64,14 +64,14 @@ export function listFavorites() {
64
64
  cache = { key, ids };
65
65
  return ids;
66
66
  }
67
- export function isFavorite(sessionId) {
67
+ export function isBookmarked(sessionId) {
68
68
  if (!sessionId)
69
69
  return false;
70
- return listFavorites().has(sessionId);
70
+ return listBookmarks().has(sessionId);
71
71
  }
72
72
  /** Atomic write (tmp + rename) so a concurrent reader never sees a half file. */
73
- function writeFavorites(ids) {
74
- const file = favoritesFilePath();
73
+ function writeBookmarks(ids) {
74
+ const file = bookmarksFilePath();
75
75
  fs.mkdirSync(path.dirname(file), { recursive: true });
76
76
  const body = { version: 1, sessionIds: [...ids].sort() };
77
77
  const tmp = `${file}.${process.pid}.tmp`;
@@ -80,22 +80,22 @@ function writeFavorites(ids) {
80
80
  cache = { key: statKey(file), ids };
81
81
  }
82
82
  /**
83
- * Set (or clear) the favorite mark on a session. Returns the resulting state, so
83
+ * Set (or clear) the bookmark mark on a session. Returns the resulting state, so
84
84
  * a caller can report it without a second read. A no-op write is skipped, which
85
85
  * keeps the file's mtime — and every other process's memoized read — untouched.
86
86
  */
87
- export function setFavorite(sessionId, on) {
88
- const ids = new Set(listFavorites());
87
+ export function setBookmark(sessionId, on) {
88
+ const ids = new Set(listBookmarks());
89
89
  if (ids.has(sessionId) === on)
90
90
  return on;
91
91
  if (on)
92
92
  ids.add(sessionId);
93
93
  else
94
94
  ids.delete(sessionId);
95
- writeFavorites(ids);
95
+ writeBookmarks(ids);
96
96
  return on;
97
97
  }
98
- /** Flip the mark; returns the new state (`true` = now favorited). */
99
- export function toggleFavorite(sessionId) {
100
- return setFavorite(sessionId, !isFavorite(sessionId));
98
+ /** Flip the mark; returns the new state (`true` = now bookmarked). */
99
+ export function toggleBookmark(sessionId) {
100
+ return setBookmark(sessionId, !isBookmarked(sessionId));
101
101
  }
@@ -194,7 +194,10 @@ const TICKET_BRANCH_RE = /(?:^|[/_-])([a-z]{2,6})-(\d{2,6})(?=[/_-]|$)/;
194
194
  /** Keys that look like tickets but aren't — avoid false positives from branches. */
195
195
  const TICKET_DENYLIST = new Set(['UTF', 'SHA', 'ISO', 'RFC', 'IPV', 'X86', 'ARM', 'MP', 'H']);
196
196
  const PR_URL_RE = /https:\/\/github\.com\/[^\s"'()<>]+\/pull\/(\d+)/;
197
- export const WORKTREE_RE = /\/\.agents\/worktrees\/([^/]+)/;
197
+ // Either separator: a Windows session cwd is `…\.agents\worktrees\<slug>`, and a
198
+ // forward-slash-only pattern silently derived no slug there (the RUSH-2358
199
+ // worktree_slug parity test is red on the Windows CI leg for exactly this).
200
+ export const WORKTREE_RE = /[\\/]\.agents[\\/]worktrees[\\/]([^\\/]+)/;
198
201
  /** gh invocations that create/open a PR. */
199
202
  const GH_PR_CREATE_RE = /\bgh\s+pr\s+(?:create|new)\b/;
200
203
  /** gh invocation that opens an issue — the created number is read from its result. */
@@ -39,6 +39,7 @@ export declare const loadPlugins: ModuleLoader;
39
39
  export declare const loadWorkflows: ModuleLoader;
40
40
  export declare const loadWorktree: ModuleLoader;
41
41
  export declare const loadVersions: ModuleLoader;
42
+ export declare const loadUpdate: ModuleLoader;
42
43
  export declare const loadImport: ModuleLoader;
43
44
  export declare const loadExport: ModuleLoader;
44
45
  export declare const loadPackages: ModuleLoader;
@@ -38,6 +38,7 @@ export const loadPlugins = async () => (await import('../../commands/plugins.js'
38
38
  export const loadWorkflows = async () => (await import('../../commands/workflows.js')).registerWorkflowsCommands;
39
39
  export const loadWorktree = async () => (await import('../../commands/worktree.js')).registerWorktreeCommands;
40
40
  export const loadVersions = async () => (await import('../../commands/versions.js')).registerVersionsCommands;
41
+ export const loadUpdate = async () => (await import('../../commands/update.js')).registerUpdateCommand;
41
42
  export const loadImport = async () => (await import('../../commands/import.js')).registerImportCommand;
42
43
  export const loadExport = async () => (await import('../../commands/export.js')).registerExportCommand;
43
44
  export const loadPackages = async () => (await import('../../commands/packages.js')).registerPackagesCommands;
@@ -168,6 +169,7 @@ export const COMMAND_LOADERS = {
168
169
  remove: [loadVersions],
169
170
  rm: [loadVersions],
170
171
  purge: [loadVersions],
172
+ update: [loadUpdate],
171
173
  prune: [loadVersions, loadPrune],
172
174
  import: [loadImport],
173
175
  export: [loadExport],
@@ -414,6 +414,30 @@ export declare function verifyInstalledBinaryLaunches(agent: AgentId, version: s
414
414
  ok: boolean;
415
415
  detail?: string;
416
416
  }>;
417
+ /**
418
+ * The launch probe itself, addressed by PATH rather than by installed version.
419
+ *
420
+ * `verifyInstalledBinaryLaunches` is this function applied to a version dir that
421
+ * is already live. `agents update` needs the identical check applied to a release
422
+ * that is still STAGED — probing it before the swap is what makes the update
423
+ * transactional, since a staged release that cannot launch is discarded instead
424
+ * of replacing a working one. Both callers must agree byte-for-byte on what
425
+ * "launches" means, hence one implementation.
426
+ *
427
+ * `posixBinary` is the extensionless launch target. The real launch target
428
+ * differs by platform, so we probe whatever `agents run` actually execs: on
429
+ * Windows that is the npm `.cmd` wrapper beside it (exec.ts uses
430
+ * `absPath + '.cmd'`), which chains to the native `.exe`; a gutted install
431
+ * (renamed/missing `.exe`) makes that wrapper emit "is not recognized" — the
432
+ * exact win-mini failure a vendor auto-update leaves behind. Probing the
433
+ * extensionless `.bin/<cli>` instead would ENOENT even on a HEALTHY Windows
434
+ * install, so we DON'T. On POSIX the `.bin/<cli>` binary is the launch target
435
+ * and is probed directly.
436
+ */
437
+ export declare function verifyBinaryLaunches(posixBinary: string, home: string): Promise<{
438
+ ok: boolean;
439
+ detail?: string;
440
+ }>;
417
441
  /**
418
442
  * Launch-path self-heal. Given the concrete version `agents run` is about to
419
443
  * spawn, make sure it will actually run — and if not, repair it instead of
@@ -34,6 +34,8 @@ import { discoverPermissionGroups, getActivePermissionPresetName, readPermission
34
34
  import { parseMcpServerConfig, isProjectMcpTrusted } from './mcp.js';
35
35
  import { createVersionedAlias, removeVersionedAlias, getConfigSymlinkVersion, ensureClaudeInsideSymlink, assertIsolationBoundary, } from './shims.js';
36
36
  import { importInstallScriptBinary } from './import.js';
37
+ import { createInstallation } from './installations/store.js';
38
+ import { INSTALLATION_RECORD_FILE } from './installations/types.js';
37
39
  import { IS_WINDOWS, composeWin32CommandLine } from './platform/index.js';
38
40
  import { listInstalledSubagents } from './subagents.js';
39
41
  import { listInstalledWorkflows } from './workflows.js';
@@ -1579,6 +1581,10 @@ export async function installVersion(agent, version, onProgress, opts) {
1579
1581
  correctly reports it uninstalled. */
1580
1582
  }
1581
1583
  createVersionedAlias(agent, installedVersion);
1584
+ // Freeze this installation's identity. The dir name is its stable label from
1585
+ // here on; the release it carries is recorded separately so `agents update`
1586
+ // can move the release without invalidating any reference to the label.
1587
+ createInstallation(agent, installedVersion, installedVersion);
1582
1588
  // The self-updating binary just changed on disk — drop the cached
1583
1589
  // `--version` so `agents view` reflects the freshly-installed release.
1584
1590
  invalidateLiveVersionCache(agent);
@@ -1633,6 +1639,9 @@ export async function installVersion(agent, version, onProgress, opts) {
1633
1639
  // load-bearing: it ensures `version` (which VERSION_RE permits to start with
1634
1640
  // `-`) is never passed as a standalone npm CLI flag.
1635
1641
  const packageSpec = `${agentConfig.npmPackage}@${version}`;
1642
+ // Set once the install has passed its integrity gate; read after the try so
1643
+ // the success path's bookkeeping sits outside the catch's cleanup.
1644
+ let healthyVersion;
1636
1645
  try {
1637
1646
  // Check npm is available
1638
1647
  const winShell = process.platform === 'win32';
@@ -1719,8 +1728,10 @@ export async function installVersion(agent, version, onProgress, opts) {
1719
1728
  + `The install is incomplete — the platform binary is missing. Re-run: agents add ${agent}@${installedVersion}`,
1720
1729
  };
1721
1730
  }
1722
- emit('version.install', { agent, version: installedVersion });
1723
- return { success: true, installedVersion };
1731
+ // The install is healthy from here. Identity is frozen AFTER the try (see
1732
+ // below) so a bookkeeping write failure cannot fall into the catch and wipe
1733
+ // a working install.
1734
+ healthyVersion = installedVersion;
1724
1735
  }
1725
1736
  catch (err) {
1726
1737
  // Clean up on failure — preserve `home/` in case a prior install left
@@ -1731,13 +1742,20 @@ export async function installVersion(agent, version, onProgress, opts) {
1731
1742
  emit('version.install', { agent, version, error: err.message });
1732
1743
  return { success: false, installedVersion: version, error: err.message };
1733
1744
  }
1745
+ // Freeze this installation's identity (see the installScript branch above).
1746
+ createInstallation(agent, healthyVersion, healthyVersion);
1747
+ emit('version.install', { agent, version: healthyVersion });
1748
+ return { success: true, installedVersion: healthyVersion };
1734
1749
  }
1735
1750
  // Version-dir entries that are STATE, not install output, and so must survive a
1736
- // clean reinstall: `home/`, and the `.isolated` marker that is the single source
1737
- // of truth for "this copy is walled off". Losing the marker would silently demote
1751
+ // clean reinstall: `home/`, the `.isolated` marker that is the single source
1752
+ // of truth for "this copy is walled off", and `installation.json`, which carries
1753
+ // this installation's frozen identity. Losing the marker would silently demote
1738
1754
  // an isolated copy to a normal one on its first repair — after which shim
1739
- // self-heal would hand it a bare `<agent>` shim and a PATH entry.
1740
- const PRESERVED_ON_CLEAN_REINSTALL = new Set(['home', '.isolated']);
1755
+ // self-heal would hand it a bare `<agent>` shim and a PATH entry. Losing the
1756
+ // installation record would mint a NEW id for the same install on its first
1757
+ // repair, discarding its release history.
1758
+ const PRESERVED_ON_CLEAN_REINSTALL = new Set(['home', '.isolated', INSTALLATION_RECORD_FILE]);
1741
1759
  /**
1742
1760
  * Remove install artifacts from a version directory, preserving `home/` which
1743
1761
  * contains the user's conversation history, sessions, history.jsonl, tasks,
@@ -2233,16 +2251,31 @@ export function probeSpawnSpec(binary, isWin) {
2233
2251
  return { command: binary, args: ['--version'], shell: false };
2234
2252
  }
2235
2253
  export async function verifyInstalledBinaryLaunches(agent, version) {
2236
- // The real launch target differs by platform, so probe whatever `agents run`
2237
- // actually execs. On Windows that's the npm `.cmd` wrapper (exec.ts uses
2238
- // `absPath + '.cmd'`), which chains to the native `.exe`; a gutted install
2239
- // (renamed/missing `.exe`) makes that wrapper emit "is not recognized" the
2240
- // exact win-mini failure a vendor auto-update leaves behind. Probing the
2241
- // extensionless `.bin/<cli>` instead would ENOENT even on a HEALTHY Windows
2242
- // install, so we DON'T. On POSIX the `.bin/<cli>` binary is the launch target
2243
- // and is probed directly.
2254
+ return verifyBinaryLaunches(getBinaryPath(agent, version), getVersionHomePath(agent, version));
2255
+ }
2256
+ /**
2257
+ * The launch probe itself, addressed by PATH rather than by installed version.
2258
+ *
2259
+ * `verifyInstalledBinaryLaunches` is this function applied to a version dir that
2260
+ * is already live. `agents update` needs the identical check applied to a release
2261
+ * that is still STAGED — probing it before the swap is what makes the update
2262
+ * transactional, since a staged release that cannot launch is discarded instead
2263
+ * of replacing a working one. Both callers must agree byte-for-byte on what
2264
+ * "launches" means, hence one implementation.
2265
+ *
2266
+ * `posixBinary` is the extensionless launch target. The real launch target
2267
+ * differs by platform, so we probe whatever `agents run` actually execs: on
2268
+ * Windows that is the npm `.cmd` wrapper beside it (exec.ts uses
2269
+ * `absPath + '.cmd'`), which chains to the native `.exe`; a gutted install
2270
+ * (renamed/missing `.exe`) makes that wrapper emit "is not recognized" — the
2271
+ * exact win-mini failure a vendor auto-update leaves behind. Probing the
2272
+ * extensionless `.bin/<cli>` instead would ENOENT even on a HEALTHY Windows
2273
+ * install, so we DON'T. On POSIX the `.bin/<cli>` binary is the launch target
2274
+ * and is probed directly.
2275
+ */
2276
+ export async function verifyBinaryLaunches(posixBinary, home) {
2244
2277
  const isWin = process.platform === 'win32';
2245
- const binary = isWin ? getBinaryPath(agent, version) + '.cmd' : getBinaryPath(agent, version);
2278
+ const binary = isWin ? posixBinary + '.cmd' : posixBinary;
2246
2279
  if (!fs.existsSync(binary)) {
2247
2280
  // Windows: a missing `.cmd` means a non-npm/global agent (droid.exe) we can't
2248
2281
  // safely probe — treat as healthy (isVersionInstalled validates presence).
@@ -2259,7 +2292,7 @@ export async function verifyInstalledBinaryLaunches(agent, version) {
2259
2292
  await execFileAsync(spec.command, spec.args, {
2260
2293
  timeout: 15000,
2261
2294
  shell: spec.shell,
2262
- env: { ...process.env, HOME: getVersionHomePath(agent, version) },
2295
+ env: { ...process.env, HOME: home },
2263
2296
  });
2264
2297
  return { ok: true };
2265
2298
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.30",
3
+ "version": "1.22.32",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -58,7 +58,7 @@
58
58
  "test": "node ./node_modules/vitest/vitest.mjs run",
59
59
  "bench": "node ./node_modules/vitest/vitest.mjs bench --run",
60
60
  "typecheck:bench": "tsc --noEmit --ignoreConfig --skipLibCheck --module esnext --moduleResolution bundler --target es2022 --strict --esModuleInterop --lib es2022 --types node src/lib/*.bench.ts src/lib/**/*.bench.ts",
61
- "test:remote": "scripts/sandbox.sh 'bun install && bun run build && bun run test'",
61
+ "test:remote": "scripts/sandbox.sh 'cd apps/cli && bun install && bun run build && bun run test'",
62
62
  "verify-docs": "scripts/verify-docs.sh",
63
63
  "test:watch": "node ./node_modules/vitest/vitest.mjs"
64
64
  },