@phnx-labs/agents-cli 1.20.73 → 1.20.74

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.
package/dist/lib/state.js CHANGED
@@ -606,18 +606,25 @@ function writeIfChanged(filePath, content) {
606
606
  * `agents:` / `versions:` are not written (no empty committed files).
607
607
  */
608
608
  function writeMetaUnlocked(meta) {
609
- const { agents, versions, defaultBrowserProfile, ...central } = meta;
609
+ const { agents, isolatedAgents, versions, defaultBrowserProfile, ...central } = meta;
610
610
  // Write the machine-local files FIRST, then strip central — so a crash mid-write
611
611
  // never removes pins/versions from central before they're persisted elsewhere.
612
612
  const devicePath = getDeviceMetaPath();
613
613
  const hasAgents = !!agents && Object.keys(agents).length > 0;
614
+ // The isolated pointer names a version installed on THIS machine, exactly like a
615
+ // global pin, so it belongs beside `agents:` in the device file rather than in the
616
+ // central doc that syncs — otherwise another machine inherits a pointer to a copy
617
+ // it does not have.
618
+ const hasIsolatedAgents = !!isolatedAgents && Object.keys(isolatedAgents).length > 0;
614
619
  const hasDefaultBrowser = !!defaultBrowserProfile;
615
- if (hasAgents || hasDefaultBrowser) {
620
+ if (hasAgents || hasIsolatedAgents || hasDefaultBrowser) {
616
621
  // Device-local doc carries `agents:` pins and `defaultBrowserProfile:` — both
617
622
  // are per-machine and must never land in central agents.yaml (which syncs).
618
623
  const deviceDoc = {};
619
624
  if (hasAgents)
620
625
  deviceDoc.agents = agents;
626
+ if (hasIsolatedAgents)
627
+ deviceDoc.isolatedAgents = isolatedAgents;
621
628
  if (hasDefaultBrowser)
622
629
  deviceDoc.defaultBrowserProfile = defaultBrowserProfile;
623
630
  fs.mkdirSync(path.dirname(devicePath), { recursive: true });
@@ -640,8 +647,9 @@ function writeMetaUnlocked(meta) {
640
647
  }
641
648
  /**
642
649
  * Overlay this machine's local state onto a central-portable Meta:
643
- * - `agents:` from the device file (device wins; the union both preserves the
644
- * one-level merge and self-heals a pre-migration central that still has pins)
650
+ * - `agents:` and `isolatedAgents:` from the device file (device wins; the union
651
+ * both preserves the one-level merge and self-heals a pre-migration central that
652
+ * still has pins)
645
653
  * - `defaultBrowserProfile:` from the device file (device is the sole source;
646
654
  * the field is stripped from central on write, so nothing to merge against)
647
655
  * - `versions:` from the history JSON (wholesale replace; falls back to
@@ -654,6 +662,8 @@ function overlayMachineLocal(meta) {
654
662
  const dm = yaml.parse(fs.readFileSync(devicePath, 'utf-8'));
655
663
  if (dm?.agents)
656
664
  meta.agents = { ...meta.agents, ...dm.agents };
665
+ if (dm?.isolatedAgents)
666
+ meta.isolatedAgents = { ...meta.isolatedAgents, ...dm.isolatedAgents };
657
667
  if (dm?.defaultBrowserProfile)
658
668
  meta.defaultBrowserProfile = dm.defaultBrowserProfile;
659
669
  }
@@ -692,6 +692,17 @@ export interface BrandConfig {
692
692
  /** Top-level structure of ~/.agents/.system/agents.yaml -- the CLI's persistent state. */
693
693
  export interface Meta {
694
694
  agents?: Partial<Record<AgentId, string>>;
695
+ /**
696
+ * Per-agent preferred ISOLATED version — which copy a bare `agents run <agent>`
697
+ * resolves to when the agent has no global default.
698
+ *
699
+ * Kept separate from `agents` on purpose. An entry there is the global default,
700
+ * which owns the launcher, the bare shim and the real `~/.<agent>` config
701
+ * symlink, and arms the self-heal `shadowing` check. An isolated copy must never
702
+ * acquire any of that, so it cannot be recorded in the same place — the
703
+ * separation is what keeps `getGlobalDefault` incapable of returning one.
704
+ */
705
+ isolatedAgents?: Partial<Record<AgentId, string>>;
695
706
  run?: RunConfig;
696
707
  /**
697
708
  * `agents run --lease` config. `secretsBundle` names the keychain secrets bundle
@@ -20,6 +20,7 @@ import * as os from 'os';
20
20
  import * as path from 'path';
21
21
  import { AGENTS, ALL_AGENT_IDS } from './agents.js';
22
22
  import { getAgentConfigPath, getConfigSymlinkVersion, stripShimPathLines, releaseAdoptedLauncher, } from './shims.js';
23
+ import { moveDirCrossDevice, copyDirStrippingAgentsSymlinks } from './config-transfer.js';
23
24
  import { getUserAgentsDir, getBackupsDir, getHistoryDir, getShimsDir, getLegacySystemAgentsDir, } from './state.js';
24
25
  /** Home dir honoring the AGENTS_REAL_HOME test/override, mirroring shims.ts. */
25
26
  function realHome() {
@@ -60,51 +61,6 @@ function symlinkTarget(p) {
60
61
  function removeLink(p) {
61
62
  fs.unlinkSync(p);
62
63
  }
63
- /**
64
- * Move `source` onto `dest` across possibly-different volumes. `renameSync` is
65
- * atomic but throws EXDEV when `~/.agents` lives on a different filesystem than
66
- * `$HOME`; fall back to copy-then-remove so the restore still completes. The
67
- * source (a backup inside `~/.agents`) is removed only after the copy succeeds,
68
- * so a mid-copy failure never destroys the sole surviving copy.
69
- */
70
- function moveDirCrossDevice(source, dest) {
71
- try {
72
- fs.renameSync(source, dest);
73
- }
74
- catch (err) {
75
- if (err.code !== 'EXDEV')
76
- throw err;
77
- fs.cpSync(source, dest, { recursive: true });
78
- fs.rmSync(source, { recursive: true, force: true });
79
- }
80
- }
81
- /**
82
- * Copy `source` to `dest`, dropping any symlink whose target resolves back into
83
- * `~/.agents`. Adoption syncs managed resources (skills/commands) into the
84
- * version home as symlinks into `~/.agents`; copying them verbatim would leave
85
- * the restored config full of links that dangle the moment `~/.agents` is
86
- * disposed. Stripping them yields a clean, self-contained restore.
87
- */
88
- function copyDirStrippingAgentsSymlinks(source, dest, agentsDir) {
89
- const inside = agentsDir + path.sep;
90
- fs.cpSync(source, dest, {
91
- recursive: true,
92
- filter: (src) => {
93
- try {
94
- const st = fs.lstatSync(src);
95
- if (st.isSymbolicLink()) {
96
- const tgt = path.resolve(path.dirname(src), fs.readlinkSync(src));
97
- if (tgt === agentsDir || tgt.startsWith(inside))
98
- return false;
99
- }
100
- }
101
- catch {
102
- /* unreadable entry — let cpSync surface it on the real copy */
103
- }
104
- return true;
105
- },
106
- });
107
- }
108
64
  /** Classify one agent's config dir without mutating anything. */
109
65
  function planConfig(agent) {
110
66
  const realPath = getAgentConfigPath(agent);
@@ -216,6 +216,19 @@ export declare function getGlobalDefault(agent: AgentId): string | null;
216
216
  * Set the global default version for an agent.
217
217
  */
218
218
  export declare function setGlobalDefault(agent: AgentId, version: string | undefined): void;
219
+ /**
220
+ * Get the preferred ISOLATED version for an agent — the copy a bare
221
+ * `agents run <agent>` falls back to when there is no global default.
222
+ */
223
+ export declare function getIsolatedDefault(agent: AgentId): string | null;
224
+ /**
225
+ * Set (or clear, with `undefined`) the preferred isolated version.
226
+ *
227
+ * Deliberately does NOT touch the launcher, the bare shim, the `~/.<agent>` config
228
+ * symlink or the global default — the five things `setDefaultVersion` does. This is
229
+ * a pointer inside the sandbox, so it stays inside the sandbox.
230
+ */
231
+ export declare function setIsolatedDefault(agent: AgentId, version: string | undefined): void;
219
232
  /**
220
233
  * Mark an installed version as an isolated install (`agents add --isolated`).
221
234
  *
@@ -32,7 +32,7 @@ import { VERSION_RE, compareVersions } from './agent-spec/primitives.js';
32
32
  import { AGENTS, agentConfigDirName, getAccountEmail, resolveAgentName, formatAgentError, findInPath, isSelfUpdatingAgent } from './agents.js';
33
33
  import { discoverPermissionGroups, getActivePermissionPresetName, readPermissionPresetRecipe, PERMISSION_PRESET_ENV_VAR } from './permissions.js';
34
34
  import { parseMcpServerConfig, isProjectMcpTrusted } from './mcp.js';
35
- import { createVersionedAlias, removeVersionedAlias, getConfigSymlinkVersion, ensureClaudeInsideSymlink } from './shims.js';
35
+ import { createVersionedAlias, removeVersionedAlias, getConfigSymlinkVersion, ensureClaudeInsideSymlink, assertIsolationBoundary, } from './shims.js';
36
36
  import { importInstallScriptBinary } from './import.js';
37
37
  import { IS_WINDOWS, composeWin32CommandLine } from './platform/index.js';
38
38
  import { listInstalledSubagents } from './subagents.js';
@@ -1186,6 +1186,14 @@ export function getGlobalDefault(agent) {
1186
1186
  * Set the global default version for an agent.
1187
1187
  */
1188
1188
  export function setGlobalDefault(agent, version) {
1189
+ // A global default is what owns the launcher and arms the self-heal `shadowing`
1190
+ // check, so recording one for an isolated-only agent is the root of the original
1191
+ // breach. Clearing is always allowed — `removeVersion` legitimately clears a
1192
+ // default as the last non-isolated version goes away, which is the very moment
1193
+ // an agent BECOMES isolated-only.
1194
+ if (version !== undefined) {
1195
+ assertIsolationBoundary(agent, 'set a global default');
1196
+ }
1189
1197
  const meta = readMeta();
1190
1198
  if (!meta.agents) {
1191
1199
  meta.agents = {};
@@ -1199,6 +1207,34 @@ export function setGlobalDefault(agent, version) {
1199
1207
  }
1200
1208
  writeMeta(meta);
1201
1209
  }
1210
+ /**
1211
+ * Get the preferred ISOLATED version for an agent — the copy a bare
1212
+ * `agents run <agent>` falls back to when there is no global default.
1213
+ */
1214
+ export function getIsolatedDefault(agent) {
1215
+ const meta = readMeta();
1216
+ return meta.isolatedAgents?.[agent] || null;
1217
+ }
1218
+ /**
1219
+ * Set (or clear, with `undefined`) the preferred isolated version.
1220
+ *
1221
+ * Deliberately does NOT touch the launcher, the bare shim, the `~/.<agent>` config
1222
+ * symlink or the global default — the five things `setDefaultVersion` does. This is
1223
+ * a pointer inside the sandbox, so it stays inside the sandbox.
1224
+ */
1225
+ export function setIsolatedDefault(agent, version) {
1226
+ const meta = readMeta();
1227
+ if (!meta.isolatedAgents) {
1228
+ meta.isolatedAgents = {};
1229
+ }
1230
+ if (version === undefined) {
1231
+ delete meta.isolatedAgents[agent];
1232
+ }
1233
+ else {
1234
+ meta.isolatedAgents[agent] = version;
1235
+ }
1236
+ writeMeta(meta);
1237
+ }
1202
1238
  /**
1203
1239
  * Path to the sentinel file that marks a version as an isolated install.
1204
1240
  *
@@ -1784,6 +1820,13 @@ export function removeVersion(agent, version) {
1784
1820
  console.log(chalk.yellow(`Removed the last non-isolated ${agent} version and cleared its default. Reinstall with: agents add ${agent}, then set one with: agents use ${agent}@<version>`));
1785
1821
  }
1786
1822
  }
1823
+ // Same for the isolated pointer: a removed version must not stay the answer to a
1824
+ // bare `agents run <agent>`. Prefer the newest remaining isolated copy so removing
1825
+ // one of several does not silently drop the user back to their PATH binary.
1826
+ if (getIsolatedDefault(agent) === version) {
1827
+ const survivors = listInstalledVersions(agent).filter((v) => isVersionIsolated(agent, v));
1828
+ setIsolatedDefault(agent, survivors.length > 0 ? survivors[survivors.length - 1] : undefined);
1829
+ }
1787
1830
  // Clean up dangling config symlink if it pointed to the removed version
1788
1831
  const symlinkVersion = getConfigSymlinkVersion(agent);
1789
1832
  if (symlinkVersion === version) {
@@ -1855,7 +1898,23 @@ export function resolveVersion(agent, projectPath) {
1855
1898
  }
1856
1899
  }
1857
1900
  // Fall back to global default
1858
- return getGlobalDefault(agent);
1901
+ const globalDefault = getGlobalDefault(agent);
1902
+ if (globalDefault)
1903
+ return globalDefault;
1904
+ // Last resort: the preferred isolated copy. Strictly a fallback — a global
1905
+ // default always wins, so nothing changes for anyone who has one. Without this,
1906
+ // an isolated-only user cannot reach their installs by bare name at all: the
1907
+ // resolution chain ended here, so `agents run codex` fell through to whatever
1908
+ // `codex` meant on PATH, and only `agents run codex@<version>` worked.
1909
+ //
1910
+ // The pointer is verified on read. It survives in agents.yaml across a trash +
1911
+ // restore cycle, but a version removed for good would otherwise leave a dangling
1912
+ // pin that resolves to a directory that is not there.
1913
+ const isolated = getIsolatedDefault(agent);
1914
+ if (isolated && isVersionInstalled(agent, isolated) && isVersionIsolated(agent, isolated)) {
1915
+ return isolated;
1916
+ }
1917
+ return null;
1859
1918
  }
1860
1919
  /**
1861
1920
  * Normalize a user-supplied @version token across CLI subcommands.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.73",
3
+ "version": "1.20.74",
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",