@phnx-labs/agents-cli 1.20.37 → 1.20.39

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/shims.js CHANGED
@@ -14,7 +14,7 @@ import * as os from 'os';
14
14
  import { fileURLToPath } from 'url';
15
15
  import { confirm, select } from '@inquirer/prompts';
16
16
  import { IS_WINDOWS, prependToWindowsUserPath } from './platform/index.js';
17
- import { getShimsDir, getVersionsDir, getBackupsDir, ensureAgentsDir } from './state.js';
17
+ import { getShimsDir, getVersionsDir, getBackupsDir, getHistoryDir, ensureAgentsDir } from './state.js';
18
18
  export { getShimsDir };
19
19
  import { AGENTS, agentConfigDirName } from './agents.js';
20
20
  /**
@@ -211,7 +211,7 @@ async function promptConflictStrategy(conflictInfos) {
211
211
  // v22 — export DISABLE_AUTOUPDATER=1 for claude shims so a pinned per-version
212
212
  // install can't self-mutate: Claude Code's background auto-updater would
213
213
  // otherwise rewrite the pinned binary in place. Explicit user value wins.
214
- export const SHIM_SCHEMA_VERSION = 22;
214
+ export const SHIM_SCHEMA_VERSION = 23;
215
215
  /** Internal marker string used to embed the schema version in shim scripts. */
216
216
  const SHIM_VERSION_MARKER = 'agents-shim-version:';
217
217
  function shellQuote(value) {
@@ -295,6 +295,33 @@ if [ -z "$AGENTS_BIN" ] || [ ! -x "$AGENTS_BIN" ]; then
295
295
  exit 127
296
296
  fi
297
297
 
298
+ # When agents-cli "adopts" a harness's own launcher (symlinks the native binary
299
+ # in ~/.local/bin to this dispatcher so version management wins regardless of
300
+ # PATH order), it records the real original here. Durable (.history, not the
301
+ # regenerable .cache) so the reverse pointer survives a cache wipe. Line 1 is
302
+ # the original binary (what we fall through to); line 2 is the launcher path
303
+ # (used by --release). It is the only safe fall-through target: exec it by
304
+ # ABSOLUTE PATH so we never re-resolve through PATH (which now points back at
305
+ # this dispatcher → infinite re-exec loop).
306
+ ADOPTED_ORIGINAL="$AGENTS_USER_DIR/.history/adopted-launchers/$CLI_COMMAND"
307
+ # Print the recorded original binary iff it is an executable file, else nothing.
308
+ adopted_original_bin() {
309
+ [ -f "$ADOPTED_ORIGINAL" ] || return 1
310
+ local orig
311
+ # First line only — line 2 (launcher path) is for --release, not exec.
312
+ IFS= read -r orig < "$ADOPTED_ORIGINAL" 2>/dev/null || return 1
313
+ [ -n "$orig" ] && [ -x "$orig" ] || return 1
314
+ printf '%s' "$orig"
315
+ }
316
+ # Last-resort fall-through: if a managed version can't be resolved but we've
317
+ # adopted this command's native launcher, run the original so the user's command
318
+ # never breaks. Replaces the process; returns non-zero only when no usable record.
319
+ exec_adopted_original() {
320
+ local orig
321
+ orig=$(adopted_original_bin) || return 1
322
+ exec "$orig" "$@"
323
+ }
324
+
298
325
  # Find project agents.yaml walking up from cwd (skip $HOME/.agents/agents.yaml)
299
326
  find_project_version() {
300
327
  local dir="$PWD"
@@ -378,15 +405,20 @@ if [ -z "$VERSION" ]; then
378
405
  VERSION_SOURCE="default"
379
406
  ;;
380
407
  *)
408
+ exec_adopted_original "$@"
381
409
  echo " Run: agents use $AGENT <version>" >&2
382
410
  exit 1
383
411
  ;;
384
412
  esac
385
413
  else
414
+ exec_adopted_original "$@"
386
415
  echo " Run: agents use $AGENT <version>" >&2
387
416
  exit 1
388
417
  fi
389
418
  else
419
+ # No managed version at all. If we adopted this command's native launcher,
420
+ # run it so the command keeps working; otherwise report it's unconfigured.
421
+ exec_adopted_original "$@"
390
422
  echo "agents: no version of $AGENT configured" >&2
391
423
  echo " Run: agents add $AGENT@<version>" >&2
392
424
  exit 1
@@ -414,16 +446,18 @@ if [ "$AGENT" = "grok" ]; then
414
446
  fi
415
447
  fi
416
448
  if [ -z "$BINARY" ] || [ ! -x "$BINARY" ]; then
417
- # Last resort: whatever is on PATH (user may have installed grok globally).
418
- # Refuse anything under our own shims dir: the shims dir sits ahead of
419
- # ~/.local/bin on PATH, so "command -v grok" resolves to THIS dispatcher.
420
- # exec-ing it would re-enter and spin in an infinite re-exec loop (the same
421
- # bug the droid branch below guards against). Fall through to the clean
422
- # "not installed" error instead.
423
- BINARY=$(command -v grok 2>/dev/null || echo "")
424
- case "$BINARY" in
425
- "$AGENTS_USER_DIR/.cache/shims/"*) BINARY="" ;;
426
- esac
449
+ # Last resort: the adopted native launcher (recorded absolute path) if we
450
+ # adopted grok, else whatever is on PATH. Prefer the adopted record after
451
+ # adoption, "command -v grok" resolves to the ~/.local/bin symlink that now
452
+ # points at THIS dispatcher, so exec-ing it would re-enter and spin forever.
453
+ BINARY=$(adopted_original_bin || echo "")
454
+ if [ -z "$BINARY" ]; then
455
+ BINARY=$(command -v grok 2>/dev/null || echo "")
456
+ # Refuse anything that resolves into our own shims dir (the dispatcher).
457
+ case "$(command -v "$BINARY" 2>/dev/null; readlink -f "$BINARY" 2>/dev/null)" in
458
+ *"$AGENTS_USER_DIR/.cache/shims/"*) BINARY="" ;;
459
+ esac
460
+ fi
427
461
  fi
428
462
  # Kimi is a normal npm agent: "agents add kimi" npm-installs
429
463
  # @moonshot-ai/kimi-code into the version dir and the binary lands at
@@ -440,14 +474,20 @@ if [ "$AGENT" = "grok" ]; then
440
474
  # IS this dispatcher, so exec'ing it would re-enter and spin in an infinite
441
475
  # re-exec loop (the bug this branch fixes).
442
476
  elif [ "$AGENT" = "droid" ]; then
443
- DROID_BINARY="$HOME/.local/bin/droid"
444
- if [ -x "$DROID_BINARY" ]; then
445
- BINARY="$DROID_BINARY"
446
- else
447
- BINARY=$(command -v droid 2>/dev/null || echo "")
448
- case "$BINARY" in
449
- "$AGENTS_USER_DIR/.cache/shims/"*) BINARY="" ;;
450
- esac
477
+ # Prefer the adopted record first: if droid's ~/.local/bin/droid launcher was
478
+ # adopted, that fixed path now points at THIS dispatcher, so using it directly
479
+ # would infinite-loop. The record holds the real original binary.
480
+ BINARY=$(adopted_original_bin || echo "")
481
+ if [ -z "$BINARY" ]; then
482
+ DROID_BINARY="$HOME/.local/bin/droid"
483
+ if [ -x "$DROID_BINARY" ] && [ "$(readlink -f "$DROID_BINARY" 2>/dev/null)" != "$(readlink -f "$AGENTS_USER_DIR/.cache/shims/$CLI_COMMAND" 2>/dev/null)" ]; then
484
+ BINARY="$DROID_BINARY"
485
+ else
486
+ BINARY=$(command -v droid 2>/dev/null || echo "")
487
+ case "$(readlink -f "$BINARY" 2>/dev/null)" in
488
+ "$AGENTS_USER_DIR/.cache/shims/"*) BINARY="" ;;
489
+ esac
490
+ fi
451
491
  fi
452
492
  else
453
493
  BINARY="$VERSION_DIR/node_modules/.bin/$CLI_COMMAND"
@@ -481,6 +521,7 @@ if [ ! -x "$BINARY" ]; then
481
521
  echo " ✔ Installed $AGENT@$VERSION" >&2
482
522
  else
483
523
  echo " ✗ Failed to install $AGENT@$VERSION" >&2
524
+ exec_adopted_original "$@"
484
525
  exit 1
485
526
  fi
486
527
  else
@@ -498,15 +539,18 @@ if [ ! -x "$BINARY" ]; then
498
539
  BINARY="$VERSION_DIR/node_modules/.bin/$CLI_COMMAND"
499
540
  ;;
500
541
  *)
542
+ exec_adopted_original "$@"
501
543
  echo " Run: agents add $AGENT@$VERSION" >&2
502
544
  exit 1
503
545
  ;;
504
546
  esac
505
547
  else
548
+ exec_adopted_original "$@"
506
549
  echo " Run: agents add $AGENT@$VERSION" >&2
507
550
  exit 1
508
551
  fi
509
552
  else
553
+ exec_adopted_original "$@"
510
554
  echo "agents: $AGENT@$VERSION not installed" >&2
511
555
  echo " Run: agents add $AGENT@$VERSION" >&2
512
556
  exit 1
@@ -1572,12 +1616,16 @@ export function getShimPath(agent) {
1572
1616
  * loop because addShimsToPath() only edits the rc file, never the legacy
1573
1617
  * shim file itself.
1574
1618
  */
1575
- export function getPathShadowingExecutable(agent) {
1576
- const pathDirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
1577
- const shimPath = path.resolve(getShimPath(agent));
1619
+ export function getPathShadowingExecutable(agent, overrides) {
1620
+ const pathDirs = overrides?.pathDirs ?? (process.env.PATH || '').split(path.delimiter).filter(Boolean);
1621
+ const shimPath = path.resolve(overrides?.shimPath ?? getShimPath(agent));
1578
1622
  const cliCommand = AGENTS[agent].cliCommand;
1579
1623
  const legacyUserShim = path.resolve(path.join(os.homedir(), '.agents', 'shims', cliCommand));
1580
1624
  const managedShimExists = fs.existsSync(shimPath);
1625
+ // The shim's own realpath — an adopted launcher is a symlink at a DIFFERENT
1626
+ // path that resolves here, so identity must be by resolved target, not the
1627
+ // literal path string.
1628
+ const shimReal = managedShimExists ? canonicalOrNull(shimPath) : null;
1581
1629
  for (const dir of pathDirs) {
1582
1630
  const candidate = path.resolve(dir, cliCommand);
1583
1631
  if (!fs.existsSync(candidate)) {
@@ -1585,6 +1633,12 @@ export function getPathShadowingExecutable(agent) {
1585
1633
  }
1586
1634
  if (candidate === shimPath)
1587
1635
  return null;
1636
+ // Adopted launcher: a symlink we repointed at our shim. Its path differs
1637
+ // from shimPath but it resolves to the same file, so it is NOT a shadow —
1638
+ // otherwise every adopted default would be re-flagged forever, resurfacing
1639
+ // the false "runs a native binary" note this whole feature set out to kill.
1640
+ if (shimReal && canonicalOrNull(candidate) === shimReal)
1641
+ return null;
1588
1642
  if (candidate === legacyUserShim && managedShimExists) {
1589
1643
  // Legacy file from the pre-split layout. Don't treat as shadow — the
1590
1644
  // repair flow deletes it via removeLegacyUserShim instead. Continue
@@ -1630,6 +1684,189 @@ export function removeLegacyUserShim(agent, overrides) {
1630
1684
  return false;
1631
1685
  }
1632
1686
  }
1687
+ /**
1688
+ * Where an adopted launcher's provenance is recorded. Lives under durable
1689
+ * `.history` (NOT the regenerable `.cache`) so the reverse pointer to the native
1690
+ * binary survives a cache wipe — the shim reads it to fall through to the native
1691
+ * binary by absolute path when no managed version resolves. Two lines:
1692
+ * line 1 = original binary, line 2 = launcher path (for `--release`).
1693
+ */
1694
+ export function getAdoptedRecordPath(agent, historyDir = getHistoryDir()) {
1695
+ return path.join(historyDir, 'adopted-launchers', AGENTS[agent].cliCommand);
1696
+ }
1697
+ /**
1698
+ * The launcher a harness's own installer drops in an early-PATH dir. Detection
1699
+ * for adoption keys on the launcher *existing as a symlink resolving outside our
1700
+ * shims dir* — NOT on current PATH order. That's deliberate: the shim only loses
1701
+ * PATH races in non-interactive / GUI-launched shells, which an interactive
1702
+ * `agents` run can't observe via its own PATH. Keying on the durable symlink lets
1703
+ * auto-adoption fire for those users too. Returns the launcher path or null.
1704
+ */
1705
+ export function findAdoptableLauncher(agent, overrides) {
1706
+ const cliCommand = AGENTS[agent].cliCommand;
1707
+ const homeDir = overrides?.homeDir ?? os.homedir();
1708
+ const shimsDirReal = canonical(overrides?.shimsDir ?? getShimsDir());
1709
+ // ~/.local/bin is where grok/kimi/antigravity/claude/codex/droid self-install.
1710
+ const candidate = path.join(homeDir, '.local', 'bin', cliCommand);
1711
+ let stat;
1712
+ try {
1713
+ stat = fs.lstatSync(candidate);
1714
+ }
1715
+ catch {
1716
+ return null;
1717
+ }
1718
+ if (!stat.isSymbolicLink())
1719
+ return null; // real binaries are never auto-adopted
1720
+ let resolved;
1721
+ try {
1722
+ resolved = fs.realpathSync(candidate); // broken symlink throws → skip
1723
+ }
1724
+ catch {
1725
+ return null;
1726
+ }
1727
+ // Already ours, or resolves into our shims dir → not adoptable.
1728
+ if (resolved === shimsDirReal || resolved.startsWith(shimsDirReal + path.sep))
1729
+ return null;
1730
+ return candidate;
1731
+ }
1732
+ /** Canonical path for identity comparison — realpath when it exists (resolves
1733
+ * symlinks AND platform aliases like macOS /var → /private/var), else resolve. */
1734
+ function canonical(p) {
1735
+ try {
1736
+ return fs.realpathSync(p);
1737
+ }
1738
+ catch {
1739
+ return path.resolve(p);
1740
+ }
1741
+ }
1742
+ /** Like canonical(), but null when the path can't be resolved (broken/racy
1743
+ * symlink) — used where a failed resolve must NOT collapse to the input path. */
1744
+ function canonicalOrNull(p) {
1745
+ try {
1746
+ return fs.realpathSync(p);
1747
+ }
1748
+ catch {
1749
+ return null;
1750
+ }
1751
+ }
1752
+ /**
1753
+ * Adopt the harness's own launcher that shadows our shim on PATH.
1754
+ *
1755
+ * PATH-ordering fixes (editing rc files) can never reliably win: `~/.local/bin`
1756
+ * (where grok/droid/etc. self-install) is prepended in `.zshenv`/`.zprofile`
1757
+ * for *every* shell, while our shims prepend only lands in `.zshrc`
1758
+ * (interactive). No single rc file guarantees "last prepend wins" across zsh's
1759
+ * whole sourcing chain, so the shim loses in non-interactive / GUI-launched
1760
+ * contexts. Instead of fighting PATH order, we *become* the launcher: replace
1761
+ * the shadowing symlink with one pointing at our shim, and record the real
1762
+ * original so the shim falls through to it when no managed version is selected.
1763
+ *
1764
+ * Regression bounds:
1765
+ * - Only ever touches a **symlink** (never renames/deletes a real binary).
1766
+ * - Records the resolved original + launcher path for lossless restore
1767
+ * (`releaseAdoptedLauncher`), in durable `.history` so a cache wipe can't
1768
+ * orphan the reverse pointer.
1769
+ * - Idempotent: a no-op once the launcher already points at our shim.
1770
+ * - Never records our own shim as the "original" (would loop).
1771
+ */
1772
+ export function adoptShadowingLauncher(agent, overrides) {
1773
+ const shimsDir = overrides?.shimsDir ?? getShimsDir();
1774
+ const shimPath = path.join(shimsDir, AGENTS[agent].cliCommand);
1775
+ const shimReal = canonical(shimPath);
1776
+ const shimsDirReal = canonical(shimsDir);
1777
+ const launcher = overrides?.shadowedBy ?? getPathShadowingExecutable(agent) ?? findAdoptableLauncher(agent, { shimsDir });
1778
+ if (!launcher)
1779
+ return { adopted: false, reason: 'no-shadow' };
1780
+ let stat;
1781
+ try {
1782
+ stat = fs.lstatSync(launcher);
1783
+ }
1784
+ catch {
1785
+ return { adopted: false, reason: 'error', launcher };
1786
+ }
1787
+ // Only adopt symlinks. A real binary in an early-PATH dir is left untouched —
1788
+ // renaming a multi-hundred-MB native binary is exactly the kind of surprise
1789
+ // this feature must avoid. (Its shim stays reachable via the versioned name.)
1790
+ if (!stat.isSymbolicLink()) {
1791
+ return { adopted: false, reason: 'not-a-symlink', launcher };
1792
+ }
1793
+ const resolved = canonical(launcher);
1794
+ // Already ours → nothing to do.
1795
+ if (resolved === shimReal) {
1796
+ return { adopted: false, reason: 'already-adopted', launcher };
1797
+ }
1798
+ // Never record a target that resolves back into our shims dir: exec-ing it
1799
+ // from the shim would re-enter this dispatcher and spin forever.
1800
+ if (resolved === shimsDirReal || resolved.startsWith(shimsDirReal + path.sep)) {
1801
+ return { adopted: false, reason: 'unsafe-target', launcher };
1802
+ }
1803
+ try {
1804
+ const recordPath = getAdoptedRecordPath(agent, overrides?.historyDir);
1805
+ fs.mkdirSync(path.dirname(recordPath), { recursive: true });
1806
+ // Line 1: original binary (shim fall-through target). Line 2: launcher path
1807
+ // (release restores this exact symlink, independent of PATH order at release
1808
+ // time — the M3 fix). Absolute launcher path so release never has to
1809
+ // re-derive it from a PATH scan that may miss.
1810
+ fs.writeFileSync(recordPath, `${resolved}\n${path.resolve(launcher)}\n`, 'utf-8');
1811
+ // Repoint the launcher at our shim. rm + symlink (not atomic rename) is fine
1812
+ // here: the record is already written, so a crash between the two leaves a
1813
+ // recoverable state and the next run re-adopts idempotently.
1814
+ fs.rmSync(launcher);
1815
+ fs.symlinkSync(shimPath, launcher);
1816
+ return { adopted: true, launcher, original: resolved };
1817
+ }
1818
+ catch {
1819
+ return { adopted: false, reason: 'error', launcher };
1820
+ }
1821
+ }
1822
+ /**
1823
+ * Undo `adoptShadowingLauncher`: repoint the launcher back at the recorded
1824
+ * original and drop the record. Reversible escape hatch for users who want the
1825
+ * native launcher to win. Returns the restored original path, or null if there
1826
+ * was nothing to release.
1827
+ */
1828
+ export function releaseAdoptedLauncher(agent, overrides) {
1829
+ const shimsDir = overrides?.shimsDir ?? getShimsDir();
1830
+ const recordPath = getAdoptedRecordPath(agent, overrides?.historyDir);
1831
+ let lines;
1832
+ try {
1833
+ lines = fs.readFileSync(recordPath, 'utf-8').split('\n').map((l) => l.trim());
1834
+ }
1835
+ catch {
1836
+ return null;
1837
+ }
1838
+ const original = lines[0] ?? '';
1839
+ if (!original)
1840
+ return null;
1841
+ // Line 2 is the exact launcher we rewrote at adopt time. Restoring it directly
1842
+ // (rather than re-deriving from PATH) means release works regardless of the
1843
+ // current shell's PATH order — the M3 fix. Fall back to a PATH scan only for
1844
+ // records written before this format existed.
1845
+ const launcher = lines[1] || getPathShadowingExecutable(agent) || original;
1846
+ const shimReal = canonical(path.join(shimsDir, AGENTS[agent].cliCommand));
1847
+ try {
1848
+ // Only rewrite the launcher if it currently points at our shim (i.e. we own
1849
+ // it). If the user has since replaced it themselves, leave it alone.
1850
+ let pointsAtShim = false;
1851
+ try {
1852
+ pointsAtShim = fs.lstatSync(launcher).isSymbolicLink()
1853
+ && canonical(launcher) === shimReal;
1854
+ }
1855
+ catch { /* launcher gone — recreate below */ }
1856
+ if (pointsAtShim || !fs.existsSync(launcher)) {
1857
+ try {
1858
+ fs.rmSync(launcher);
1859
+ }
1860
+ catch { /* may not exist */ }
1861
+ fs.symlinkSync(original, launcher);
1862
+ }
1863
+ fs.rmSync(recordPath);
1864
+ return original;
1865
+ }
1866
+ catch {
1867
+ return null;
1868
+ }
1869
+ }
1633
1870
  /**
1634
1871
  * Check if the agent's CLI command is shadowed by a shell alias.
1635
1872
  *
@@ -13,7 +13,7 @@
13
13
  * secrets -> listRemoteBundles + pullBundle (needs a passphrase; skipped
14
14
  * cleanly when none is available — tokenized non-interactive auth
15
15
  * arrives with `agents login`, #366/#367)
16
- * sessions -> syncSessions(), gated by isSyncConfigured() exactly like the daemon
16
+ * sessions -> syncSessions(), gated by the session-sync beta opt-in + isSyncConfigured(), like the daemon
17
17
  * reconcile-> refresh({ skipPrompts }) — re-materialize resources into homes
18
18
  */
19
19
  /** The five umbrella flags off `agents sync`. */
@@ -13,7 +13,7 @@
13
13
  * secrets -> listRemoteBundles + pullBundle (needs a passphrase; skipped
14
14
  * cleanly when none is available — tokenized non-interactive auth
15
15
  * arrives with `agents login`, #366/#367)
16
- * sessions -> syncSessions(), gated by isSyncConfigured() exactly like the daemon
16
+ * sessions -> syncSessions(), gated by the session-sync beta opt-in + isSyncConfigured(), like the daemon
17
17
  * reconcile-> refresh({ skipPrompts }) — re-materialize resources into homes
18
18
  */
19
19
  import { pullRepo } from './git.js';
@@ -111,10 +111,12 @@ export async function runUmbrellaSync(args) {
111
111
  }
112
112
  }
113
113
  if (plan.fetchSessions) {
114
- // Gate exactly like the daemon: an off switch or a missing r2.backups bundle
115
- // is a clean no-op, not an error that fails the whole sync.
116
- const { isSyncConfigured, isSyncEnabled } = await import('./session/sync/config.js');
117
- if (isSyncEnabled() && isSyncConfigured()) {
114
+ // Gate exactly like the daemon: without the `session-sync` beta opt-in (or
115
+ // with a missing r2.backups bundle) this is a clean no-op, not an error that
116
+ // fails the whole sync.
117
+ const { isBetaEnabled } = await import('./beta.js');
118
+ const { isSyncConfigured } = await import('./session/sync/config.js');
119
+ if (isBetaEnabled('session-sync') && isSyncConfigured()) {
118
120
  const { syncSessions } = await import('./session/sync/sync.js');
119
121
  const r = await syncSessions();
120
122
  result.sessions = { ran: true, pushed: r.pushed, pulled: r.pulled, merged: r.merged };
@@ -61,7 +61,7 @@ export interface BudgetConfig {
61
61
  require_confirm_over?: number;
62
62
  }
63
63
  /** Preview features that users can opt into via `agents beta`. */
64
- export type BetaFeatureName = 'drive' | 'factory';
64
+ export type BetaFeatureName = 'drive' | 'factory' | 'session-sync';
65
65
  /** Subset of chalk color names used for agent-specific terminal output. */
66
66
  export type ChalkColor = 'magenta' | 'green' | 'blue' | 'cyan' | 'yellowBright' | 'redBright' | 'whiteBright' | 'blueBright' | 'greenBright' | 'magentaBright' | 'cyanBright';
67
67
  /** Static configuration for a single agent -- paths, capabilities, and format conventions. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.37",
3
+ "version": "1.20.39",
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",