@phnx-labs/agents-cli 1.20.36 → 1.20.38

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 (36) hide show
  1. package/dist/commands/computer-actions.d.ts +10 -0
  2. package/dist/commands/computer-actions.js +47 -17
  3. package/dist/commands/doctor.js +48 -1
  4. package/dist/commands/go.d.ts +28 -0
  5. package/dist/commands/go.js +238 -0
  6. package/dist/commands/sessions-picker.d.ts +2 -0
  7. package/dist/commands/sessions-picker.js +10 -1
  8. package/dist/commands/sessions-sync.d.ts +3 -0
  9. package/dist/commands/sessions-sync.js +44 -4
  10. package/dist/commands/sessions.d.ts +8 -1
  11. package/dist/commands/sessions.js +155 -36
  12. package/dist/index.js +59 -68
  13. package/dist/lib/daemon.js +4 -2
  14. package/dist/lib/devices/resolve-target.d.ts +24 -0
  15. package/dist/lib/devices/resolve-target.js +80 -0
  16. package/dist/lib/session/active.d.ts +25 -0
  17. package/dist/lib/session/active.js +11 -5
  18. package/dist/lib/session/db.d.ts +2 -1
  19. package/dist/lib/session/db.js +41 -5
  20. package/dist/lib/session/discover.d.ts +2 -0
  21. package/dist/lib/session/discover.js +16 -1
  22. package/dist/lib/session/ghostty-tabs.d.ts +33 -0
  23. package/dist/lib/session/ghostty-tabs.js +126 -0
  24. package/dist/lib/session/relative-time.js +6 -2
  25. package/dist/lib/session/remote-active.js +4 -14
  26. package/dist/lib/session/remote-list.js +4 -12
  27. package/dist/lib/session/remote.js +4 -2
  28. package/dist/lib/session/sync/config.d.ts +13 -0
  29. package/dist/lib/session/sync/config.js +56 -0
  30. package/dist/lib/session/types.d.ts +6 -0
  31. package/dist/lib/shims.d.ts +65 -1
  32. package/dist/lib/shims.js +237 -20
  33. package/dist/lib/sync-umbrella.js +4 -4
  34. package/dist/lib/tmux/session.d.ts +10 -0
  35. package/dist/lib/tmux/session.js +31 -0
  36. package/package.json +1 -1
@@ -3,9 +3,65 @@
3
3
  * machine's stable identity. Credentials come from the `r2.backups` secrets
4
4
  * bundle (OS keychain on macOS, libsecret on Linux) — never from env or disk.
5
5
  */
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
6
8
  import { readAndResolveBundleEnv } from '../../secrets/bundles.js';
9
+ import { getHistoryDir } from '../../state.js';
7
10
  /** Secrets bundle holding the R2 credentials. */
8
11
  export const SYNC_BUNDLE = 'r2.backups';
12
+ // ── Enable / disable switch ─────────────────────────────────────────────────
13
+ // Whether the daemon's automatic cross-machine sync (and `agents sync
14
+ // --sessions`) may run on THIS machine. Independent of credential presence
15
+ // (isSyncConfigured): a machine can hold valid R2 creds yet still opt out of the
16
+ // background push/pull — e.g. when on-demand `agents sessions --host` is
17
+ // preferred over the ad-hoc R2 mirror. Manual `agents sessions sync` is an
18
+ // explicit user action and is deliberately NOT gated by this switch.
19
+ //
20
+ // Resolution order: the AGENTS_SESSIONS_SYNC env var (a recognized on/off value
21
+ // wins outright, for ad-hoc overrides and tests), then a durable machine-local
22
+ // flag file, then the default (enabled). The flag lives in the durable
23
+ // ~/.agents/.history tree — NOT .cache — so a cache wipe can never silently
24
+ // re-enable a sync the operator turned off.
25
+ /** Env var that overrides the persisted enable flag (on/off/true/false/1/0/yes/no). */
26
+ export const SYNC_ENABLED_ENV = 'AGENTS_SESSIONS_SYNC';
27
+ const SYNC_ENABLED_FILE = 'sessions-sync.json';
28
+ const OFF_VALUES = new Set(['0', 'off', 'false', 'no', 'disabled']);
29
+ const ON_VALUES = new Set(['1', 'on', 'true', 'yes', 'enabled']);
30
+ /** Durable, machine-local path holding the sync enable flag. */
31
+ export function syncStateFilePath() {
32
+ return path.join(getHistoryDir(), SYNC_ENABLED_FILE);
33
+ }
34
+ /**
35
+ * Whether automatic session sync is enabled on this machine. Defaults to true;
36
+ * an unrecognized env value falls through to the file; an absent/unreadable file
37
+ * falls through to the default. Read fresh every call (no memoization) so a
38
+ * `--disable` takes effect on the daemon's next ~90s cycle without a restart.
39
+ */
40
+ export function isSyncEnabled() {
41
+ const envRaw = process.env[SYNC_ENABLED_ENV]?.trim().toLowerCase();
42
+ if (envRaw) {
43
+ if (OFF_VALUES.has(envRaw))
44
+ return false;
45
+ if (ON_VALUES.has(envRaw))
46
+ return true;
47
+ // Unrecognized value: ignore and consult the persisted flag.
48
+ }
49
+ try {
50
+ const parsed = JSON.parse(fs.readFileSync(syncStateFilePath(), 'utf-8'));
51
+ if (parsed && typeof parsed.enabled === 'boolean')
52
+ return parsed.enabled;
53
+ }
54
+ catch {
55
+ // Absent or unreadable → default enabled.
56
+ }
57
+ return true;
58
+ }
59
+ /** Persist the machine-local sync enable flag (durable across cache wipes). */
60
+ export function setSyncEnabled(enabled) {
61
+ const p = syncStateFilePath();
62
+ fs.mkdirSync(path.dirname(p), { recursive: true });
63
+ fs.writeFileSync(p, JSON.stringify({ enabled }, null, 2) + '\n', 'utf-8');
64
+ }
9
65
  /**
10
66
  * Resolve R2 credentials from the `r2.backups` bundle. Throws a clear,
11
67
  * actionable error if the bundle or any key is missing — sync cannot proceed
@@ -46,6 +46,12 @@ export interface SessionMeta {
46
46
  shortId: string;
47
47
  agent: SessionAgentId;
48
48
  timestamp: string;
49
+ /**
50
+ * Last-activity time (ISO): the last message timestamp when a parser computed
51
+ * it, else file mtime, else `timestamp`. This is the recency signal the
52
+ * listing sorts and labels by; `timestamp` stays the creation time.
53
+ */
54
+ lastActivity?: string;
49
55
  project?: string;
50
56
  cwd?: string;
51
57
  filePath: string;
@@ -77,7 +77,7 @@ export interface ConflictInfo {
77
77
  * top-level entry add/remove — deep edits to plugin contents won't
78
78
  * trigger auto-resync, run `agents sync` for that.
79
79
  */
80
- export declare const SHIM_SCHEMA_VERSION = 22;
80
+ export declare const SHIM_SCHEMA_VERSION = 23;
81
81
  /**
82
82
  * Generate the full bash shim script for the given agent. The returned string
83
83
  * is written to ~/.agents/shims/{cliCommand} and made executable.
@@ -311,6 +311,70 @@ export declare function getPathShadowingExecutable(agent: AgentId): string | nul
311
311
  export declare function removeLegacyUserShim(agent: AgentId, overrides?: {
312
312
  homeDir?: string;
313
313
  }): boolean;
314
+ /**
315
+ * Where an adopted launcher's provenance is recorded. Lives under durable
316
+ * `.history` (NOT the regenerable `.cache`) so the reverse pointer to the native
317
+ * binary survives a cache wipe — the shim reads it to fall through to the native
318
+ * binary by absolute path when no managed version resolves. Two lines:
319
+ * line 1 = original binary, line 2 = launcher path (for `--release`).
320
+ */
321
+ export declare function getAdoptedRecordPath(agent: AgentId, historyDir?: string): string;
322
+ /**
323
+ * The launcher a harness's own installer drops in an early-PATH dir. Detection
324
+ * for adoption keys on the launcher *existing as a symlink resolving outside our
325
+ * shims dir* — NOT on current PATH order. That's deliberate: the shim only loses
326
+ * PATH races in non-interactive / GUI-launched shells, which an interactive
327
+ * `agents` run can't observe via its own PATH. Keying on the durable symlink lets
328
+ * auto-adoption fire for those users too. Returns the launcher path or null.
329
+ */
330
+ export declare function findAdoptableLauncher(agent: AgentId, overrides?: {
331
+ homeDir?: string;
332
+ shimsDir?: string;
333
+ }): string | null;
334
+ export type AdoptResult = {
335
+ adopted: true;
336
+ launcher: string;
337
+ original: string;
338
+ } | {
339
+ adopted: false;
340
+ reason: 'no-shadow' | 'already-adopted' | 'not-a-symlink' | 'unsafe-target' | 'error';
341
+ launcher?: string;
342
+ };
343
+ /**
344
+ * Adopt the harness's own launcher that shadows our shim on PATH.
345
+ *
346
+ * PATH-ordering fixes (editing rc files) can never reliably win: `~/.local/bin`
347
+ * (where grok/droid/etc. self-install) is prepended in `.zshenv`/`.zprofile`
348
+ * for *every* shell, while our shims prepend only lands in `.zshrc`
349
+ * (interactive). No single rc file guarantees "last prepend wins" across zsh's
350
+ * whole sourcing chain, so the shim loses in non-interactive / GUI-launched
351
+ * contexts. Instead of fighting PATH order, we *become* the launcher: replace
352
+ * the shadowing symlink with one pointing at our shim, and record the real
353
+ * original so the shim falls through to it when no managed version is selected.
354
+ *
355
+ * Regression bounds:
356
+ * - Only ever touches a **symlink** (never renames/deletes a real binary).
357
+ * - Records the resolved original + launcher path for lossless restore
358
+ * (`releaseAdoptedLauncher`), in durable `.history` so a cache wipe can't
359
+ * orphan the reverse pointer.
360
+ * - Idempotent: a no-op once the launcher already points at our shim.
361
+ * - Never records our own shim as the "original" (would loop).
362
+ */
363
+ export declare function adoptShadowingLauncher(agent: AgentId, overrides?: {
364
+ shadowedBy?: string;
365
+ shimsDir?: string;
366
+ historyDir?: string;
367
+ }): AdoptResult;
368
+ /**
369
+ * Undo `adoptShadowingLauncher`: repoint the launcher back at the recorded
370
+ * original and drop the record. Reversible escape hatch for users who want the
371
+ * native launcher to win. Returns the restored original path, or null if there
372
+ * was nothing to release.
373
+ */
374
+ export declare function releaseAdoptedLauncher(agent: AgentId, overrides?: {
375
+ shimsDir?: string;
376
+ historyDir?: string;
377
+ }): string | null;
314
378
  export declare function hasAliasShadowingShim(agent: AgentId, overrides?: {
315
379
  homeDir?: string;
316
380
  }): boolean;
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
@@ -1630,6 +1674,179 @@ export function removeLegacyUserShim(agent, overrides) {
1630
1674
  return false;
1631
1675
  }
1632
1676
  }
1677
+ /**
1678
+ * Where an adopted launcher's provenance is recorded. Lives under durable
1679
+ * `.history` (NOT the regenerable `.cache`) so the reverse pointer to the native
1680
+ * binary survives a cache wipe — the shim reads it to fall through to the native
1681
+ * binary by absolute path when no managed version resolves. Two lines:
1682
+ * line 1 = original binary, line 2 = launcher path (for `--release`).
1683
+ */
1684
+ export function getAdoptedRecordPath(agent, historyDir = getHistoryDir()) {
1685
+ return path.join(historyDir, 'adopted-launchers', AGENTS[agent].cliCommand);
1686
+ }
1687
+ /**
1688
+ * The launcher a harness's own installer drops in an early-PATH dir. Detection
1689
+ * for adoption keys on the launcher *existing as a symlink resolving outside our
1690
+ * shims dir* — NOT on current PATH order. That's deliberate: the shim only loses
1691
+ * PATH races in non-interactive / GUI-launched shells, which an interactive
1692
+ * `agents` run can't observe via its own PATH. Keying on the durable symlink lets
1693
+ * auto-adoption fire for those users too. Returns the launcher path or null.
1694
+ */
1695
+ export function findAdoptableLauncher(agent, overrides) {
1696
+ const cliCommand = AGENTS[agent].cliCommand;
1697
+ const homeDir = overrides?.homeDir ?? os.homedir();
1698
+ const shimsDirReal = canonical(overrides?.shimsDir ?? getShimsDir());
1699
+ // ~/.local/bin is where grok/kimi/antigravity/claude/codex/droid self-install.
1700
+ const candidate = path.join(homeDir, '.local', 'bin', cliCommand);
1701
+ let stat;
1702
+ try {
1703
+ stat = fs.lstatSync(candidate);
1704
+ }
1705
+ catch {
1706
+ return null;
1707
+ }
1708
+ if (!stat.isSymbolicLink())
1709
+ return null; // real binaries are never auto-adopted
1710
+ let resolved;
1711
+ try {
1712
+ resolved = fs.realpathSync(candidate); // broken symlink throws → skip
1713
+ }
1714
+ catch {
1715
+ return null;
1716
+ }
1717
+ // Already ours, or resolves into our shims dir → not adoptable.
1718
+ if (resolved === shimsDirReal || resolved.startsWith(shimsDirReal + path.sep))
1719
+ return null;
1720
+ return candidate;
1721
+ }
1722
+ /** Canonical path for identity comparison — realpath when it exists (resolves
1723
+ * symlinks AND platform aliases like macOS /var → /private/var), else resolve. */
1724
+ function canonical(p) {
1725
+ try {
1726
+ return fs.realpathSync(p);
1727
+ }
1728
+ catch {
1729
+ return path.resolve(p);
1730
+ }
1731
+ }
1732
+ /**
1733
+ * Adopt the harness's own launcher that shadows our shim on PATH.
1734
+ *
1735
+ * PATH-ordering fixes (editing rc files) can never reliably win: `~/.local/bin`
1736
+ * (where grok/droid/etc. self-install) is prepended in `.zshenv`/`.zprofile`
1737
+ * for *every* shell, while our shims prepend only lands in `.zshrc`
1738
+ * (interactive). No single rc file guarantees "last prepend wins" across zsh's
1739
+ * whole sourcing chain, so the shim loses in non-interactive / GUI-launched
1740
+ * contexts. Instead of fighting PATH order, we *become* the launcher: replace
1741
+ * the shadowing symlink with one pointing at our shim, and record the real
1742
+ * original so the shim falls through to it when no managed version is selected.
1743
+ *
1744
+ * Regression bounds:
1745
+ * - Only ever touches a **symlink** (never renames/deletes a real binary).
1746
+ * - Records the resolved original + launcher path for lossless restore
1747
+ * (`releaseAdoptedLauncher`), in durable `.history` so a cache wipe can't
1748
+ * orphan the reverse pointer.
1749
+ * - Idempotent: a no-op once the launcher already points at our shim.
1750
+ * - Never records our own shim as the "original" (would loop).
1751
+ */
1752
+ export function adoptShadowingLauncher(agent, overrides) {
1753
+ const shimsDir = overrides?.shimsDir ?? getShimsDir();
1754
+ const shimPath = path.join(shimsDir, AGENTS[agent].cliCommand);
1755
+ const shimReal = canonical(shimPath);
1756
+ const shimsDirReal = canonical(shimsDir);
1757
+ const launcher = overrides?.shadowedBy ?? getPathShadowingExecutable(agent) ?? findAdoptableLauncher(agent, { shimsDir });
1758
+ if (!launcher)
1759
+ return { adopted: false, reason: 'no-shadow' };
1760
+ let stat;
1761
+ try {
1762
+ stat = fs.lstatSync(launcher);
1763
+ }
1764
+ catch {
1765
+ return { adopted: false, reason: 'error', launcher };
1766
+ }
1767
+ // Only adopt symlinks. A real binary in an early-PATH dir is left untouched —
1768
+ // renaming a multi-hundred-MB native binary is exactly the kind of surprise
1769
+ // this feature must avoid. (Its shim stays reachable via the versioned name.)
1770
+ if (!stat.isSymbolicLink()) {
1771
+ return { adopted: false, reason: 'not-a-symlink', launcher };
1772
+ }
1773
+ const resolved = canonical(launcher);
1774
+ // Already ours → nothing to do.
1775
+ if (resolved === shimReal) {
1776
+ return { adopted: false, reason: 'already-adopted', launcher };
1777
+ }
1778
+ // Never record a target that resolves back into our shims dir: exec-ing it
1779
+ // from the shim would re-enter this dispatcher and spin forever.
1780
+ if (resolved === shimsDirReal || resolved.startsWith(shimsDirReal + path.sep)) {
1781
+ return { adopted: false, reason: 'unsafe-target', launcher };
1782
+ }
1783
+ try {
1784
+ const recordPath = getAdoptedRecordPath(agent, overrides?.historyDir);
1785
+ fs.mkdirSync(path.dirname(recordPath), { recursive: true });
1786
+ // Line 1: original binary (shim fall-through target). Line 2: launcher path
1787
+ // (release restores this exact symlink, independent of PATH order at release
1788
+ // time — the M3 fix). Absolute launcher path so release never has to
1789
+ // re-derive it from a PATH scan that may miss.
1790
+ fs.writeFileSync(recordPath, `${resolved}\n${path.resolve(launcher)}\n`, 'utf-8');
1791
+ // Repoint the launcher at our shim. rm + symlink (not atomic rename) is fine
1792
+ // here: the record is already written, so a crash between the two leaves a
1793
+ // recoverable state and the next run re-adopts idempotently.
1794
+ fs.rmSync(launcher);
1795
+ fs.symlinkSync(shimPath, launcher);
1796
+ return { adopted: true, launcher, original: resolved };
1797
+ }
1798
+ catch {
1799
+ return { adopted: false, reason: 'error', launcher };
1800
+ }
1801
+ }
1802
+ /**
1803
+ * Undo `adoptShadowingLauncher`: repoint the launcher back at the recorded
1804
+ * original and drop the record. Reversible escape hatch for users who want the
1805
+ * native launcher to win. Returns the restored original path, or null if there
1806
+ * was nothing to release.
1807
+ */
1808
+ export function releaseAdoptedLauncher(agent, overrides) {
1809
+ const shimsDir = overrides?.shimsDir ?? getShimsDir();
1810
+ const recordPath = getAdoptedRecordPath(agent, overrides?.historyDir);
1811
+ let lines;
1812
+ try {
1813
+ lines = fs.readFileSync(recordPath, 'utf-8').split('\n').map((l) => l.trim());
1814
+ }
1815
+ catch {
1816
+ return null;
1817
+ }
1818
+ const original = lines[0] ?? '';
1819
+ if (!original)
1820
+ return null;
1821
+ // Line 2 is the exact launcher we rewrote at adopt time. Restoring it directly
1822
+ // (rather than re-deriving from PATH) means release works regardless of the
1823
+ // current shell's PATH order — the M3 fix. Fall back to a PATH scan only for
1824
+ // records written before this format existed.
1825
+ const launcher = lines[1] || getPathShadowingExecutable(agent) || original;
1826
+ const shimReal = canonical(path.join(shimsDir, AGENTS[agent].cliCommand));
1827
+ try {
1828
+ // Only rewrite the launcher if it currently points at our shim (i.e. we own
1829
+ // it). If the user has since replaced it themselves, leave it alone.
1830
+ let pointsAtShim = false;
1831
+ try {
1832
+ pointsAtShim = fs.lstatSync(launcher).isSymbolicLink()
1833
+ && canonical(launcher) === shimReal;
1834
+ }
1835
+ catch { /* launcher gone — recreate below */ }
1836
+ if (pointsAtShim || !fs.existsSync(launcher)) {
1837
+ try {
1838
+ fs.rmSync(launcher);
1839
+ }
1840
+ catch { /* may not exist */ }
1841
+ fs.symlinkSync(original, launcher);
1842
+ }
1843
+ fs.rmSync(recordPath);
1844
+ return original;
1845
+ }
1846
+ catch {
1847
+ return null;
1848
+ }
1849
+ }
1633
1850
  /**
1634
1851
  * Check if the agent's CLI command is shadowed by a shell alias.
1635
1852
  *
@@ -111,10 +111,10 @@ export async function runUmbrellaSync(args) {
111
111
  }
112
112
  }
113
113
  if (plan.fetchSessions) {
114
- // Gate exactly like the daemon: a missing r2.backups bundle is a clean no-op,
115
- // not an error that fails the whole sync.
116
- const { isSyncConfigured } = await import('./session/sync/config.js');
117
- if (isSyncConfigured()) {
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()) {
118
118
  const { syncSessions } = await import('./session/sync/sync.js');
119
119
  const r = await syncSessions();
120
120
  result.sessions = { ran: true, pushed: r.pushed, pulled: r.pulled, merged: r.merged };
@@ -70,6 +70,16 @@ export declare function killSession(name: string, socket?: string): Promise<bool
70
70
  * meta files. Wipes the socket so the next `new` starts from a clean slate.
71
71
  */
72
72
  export declare function killAll(socket?: string): Promise<number>;
73
+ /**
74
+ * Map every pane id (`%N`) on a socket to its `session:window.pane` attach
75
+ * target, in one batched `tmux list-panes -a` call. `%116 -> main:2.0` is a
76
+ * valid `tmux attach -t main:2` / `tmux select-window -t main:2` target — a
77
+ * human jump target, unlike the bare `%pane` send-keys id. Because it walks
78
+ * every pane (not just one-per-session), it also surfaces multiple agents that
79
+ * share a session across windows. Best-effort: returns an empty map on any
80
+ * failure (tmux gone, foreign socket) so callers fall back to the raw pane id.
81
+ */
82
+ export declare function mapPanesToTargets(socket?: string): Promise<Map<string, string>>;
73
83
  /**
74
84
  * List live sessions on the socket. Reconciles meta JSONs against tmux's view:
75
85
  * - tmux session with no meta → returned without `meta` (external session)
@@ -156,6 +156,37 @@ export async function killAll(socket) {
156
156
  catch { /* may not exist */ }
157
157
  return count;
158
158
  }
159
+ /**
160
+ * Map every pane id (`%N`) on a socket to its `session:window.pane` attach
161
+ * target, in one batched `tmux list-panes -a` call. `%116 -> main:2.0` is a
162
+ * valid `tmux attach -t main:2` / `tmux select-window -t main:2` target — a
163
+ * human jump target, unlike the bare `%pane` send-keys id. Because it walks
164
+ * every pane (not just one-per-session), it also surfaces multiple agents that
165
+ * share a session across windows. Best-effort: returns an empty map on any
166
+ * failure (tmux gone, foreign socket) so callers fall back to the raw pane id.
167
+ */
168
+ export async function mapPanesToTargets(socket) {
169
+ const out = new Map();
170
+ let res;
171
+ try {
172
+ res = await runTmux({
173
+ socket,
174
+ args: ['list-panes', '-a', '-F', '#{pane_id} #{session_name}:#{window_index}.#{pane_index}'],
175
+ throwOnError: false,
176
+ });
177
+ }
178
+ catch {
179
+ return out;
180
+ }
181
+ if (res.code !== 0)
182
+ return out;
183
+ for (const line of res.stdout.split('\n')) {
184
+ const sp = line.indexOf(' ');
185
+ if (sp > 0)
186
+ out.set(line.slice(0, sp), line.slice(sp + 1).trim());
187
+ }
188
+ return out;
189
+ }
159
190
  /**
160
191
  * List live sessions on the socket. Reconciles meta JSONs against tmux's view:
161
192
  * - tmux session with no meta → returned without `meta` (external session)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.36",
3
+ "version": "1.20.38",
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",