@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.
@@ -98,6 +98,24 @@ export declare function dedupeByMachineSession(sessions: ActiveSession[]): Activ
98
98
  * `localMachine` is injected so the ordering is testable without os.hostname().
99
99
  */
100
100
  export declare function mergeLocalFirst(sessions: SessionMeta[], localMachine: string): SessionMeta[];
101
+ /**
102
+ * Whether the local machine's sessions belong in an `--active` view. Local is
103
+ * included by default; an explicit `--host`/`--device` list scopes the view to
104
+ * exactly those machines, so local is dropped unless it is itself named (by
105
+ * alias or `user@host`, matched on the normalized machine id). Exported for
106
+ * unit testing without touching SSH or the live process table.
107
+ */
108
+ export declare function shouldIncludeLocal(hosts: string[] | undefined, self: string): boolean;
109
+ /**
110
+ * The peers to dial for an `--active` view. No `--host` → `undefined`, which
111
+ * tells `gatherRemoteActive` to sweep the registered online devices. An
112
+ * explicit list → exactly those, minus this machine (its sessions come from the
113
+ * local seed, so dialing self would be a wasted SSH and a spurious "unreachable"
114
+ * note). Returns `[]` when the only named host is self — the caller then skips
115
+ * the remote fan-out entirely rather than letting `[]` trigger the sweep.
116
+ * Exported for unit testing.
117
+ */
118
+ export declare function remoteHostsToDial(hosts: string[] | undefined, self: string): string[] | undefined;
101
119
  /**
102
120
  * Group key for the overview: prefer the indexed project name; else fold the cwd
103
121
  * to its repo — a worktree (`.../<repo>/.agents/worktrees/<slug>`) folds to the
@@ -164,7 +182,7 @@ export declare function formatPickerLabel(s: SessionMeta, query: string, cols?:
164
182
  * so it stays fixed across the picker's re-renders within a single run.
165
183
  */
166
184
  export declare function formatPickerTip(sessions: SessionMeta[]): string;
167
- export declare function pickSessionInteractive(sessions: SessionMeta[], message?: string, initialSearch?: string, hiddenCount?: number): Promise<PickedSession | null>;
185
+ export declare function pickSessionInteractive(sessions: SessionMeta[], message?: string, initialSearch?: string, hiddenCount?: number, enterHint?: string): Promise<PickedSession | null>;
168
186
  /**
169
187
  * Resume a session in the current terminal — a foreground takeover of this
170
188
  * process. Used by the single-select picker and by `sessions resume` when the
@@ -39,6 +39,7 @@ import { setHelpSections } from '../lib/help.js';
39
39
  import { registerSessionsTailCommand } from './sessions-tail.js';
40
40
  import { registerSessionsSyncCommand } from './sessions-sync.js';
41
41
  import { registerSessionsResumeCommand } from './sessions-resume.js';
42
+ import { registerGoCommand } from './go.js';
42
43
  import { registerSessionsInjectCommand } from './sessions-inject.js';
43
44
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
44
45
  /**
@@ -600,25 +601,65 @@ async function enrichLocalLocators(local) {
600
601
  }
601
602
  catch { /* non-fatal */ }
602
603
  }
604
+ /** Normalize a `--host`/`--device` token (`alias`, `user@host`, `host.domain`)
605
+ * to the machine id the fan-out and registry key off. */
606
+ function hostToken(h) {
607
+ return normalizeHost(h.split('@').pop() || h);
608
+ }
603
609
  /**
604
- * Render the unified active-session view, grouped by machine. Local sessions
605
- * come from `getActiveSessions()`; unless `--local`, sessions from other
606
- * machines are folded in over SSH (explicit `--host` targets, else the
607
- * registered online devices from `ag devices`). A tip is shown when there are
608
- * no other machines to include.
610
+ * Whether the local machine's sessions belong in an `--active` view. Local is
611
+ * included by default; an explicit `--host`/`--device` list scopes the view to
612
+ * exactly those machines, so local is dropped unless it is itself named (by
613
+ * alias or `user@host`, matched on the normalized machine id). Exported for
614
+ * unit testing without touching SSH or the live process table.
615
+ */
616
+ export function shouldIncludeLocal(hosts, self) {
617
+ if (!hosts || hosts.length === 0)
618
+ return true;
619
+ return hosts.some(h => hostToken(h) === self);
620
+ }
621
+ /**
622
+ * The peers to dial for an `--active` view. No `--host` → `undefined`, which
623
+ * tells `gatherRemoteActive` to sweep the registered online devices. An
624
+ * explicit list → exactly those, minus this machine (its sessions come from the
625
+ * local seed, so dialing self would be a wasted SSH and a spurious "unreachable"
626
+ * note). Returns `[]` when the only named host is self — the caller then skips
627
+ * the remote fan-out entirely rather than letting `[]` trigger the sweep.
628
+ * Exported for unit testing.
629
+ */
630
+ export function remoteHostsToDial(hosts, self) {
631
+ if (!hosts || hosts.length === 0)
632
+ return undefined;
633
+ return hosts.filter(h => hostToken(h) !== self);
634
+ }
635
+ /**
636
+ * Render the unified active-session view, grouped by machine. With no `--host`,
637
+ * local sessions come from `getActiveSessions()` and (unless `--local`) the
638
+ * registered online devices from `ag devices` are folded in over SSH. An
639
+ * explicit `--host`/`--device` list SCOPES the view to exactly those machines —
640
+ * the local machine is included only when it is itself named — so `--host` is a
641
+ * filter, not an addition (matching the non-`--active` listing path). A tip is
642
+ * shown when there are no other machines to include.
609
643
  */
610
644
  async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
611
645
  const self = machineId();
612
- const local = await getActiveSessions();
646
+ // An explicit --host/--device list scopes the view: seed local sessions only
647
+ // when no hosts are named, or when this machine is one of the named targets.
648
+ const local = shouldIncludeLocal(opts.hosts, self) ? await getActiveSessions() : [];
613
649
  for (const s of local)
614
650
  if (!s.machine)
615
651
  s.machine = self;
616
652
  let remoteDeviceCount = 0;
617
653
  let merged = local;
618
654
  if (!opts.local) {
619
- const remote = await gatherRemoteActive(opts.hosts);
620
- remoteDeviceCount = remote.deviceCount;
621
- merged = dedupeByMachineSession([...local, ...remote.sessions]);
655
+ const remoteHosts = remoteHostsToDial(opts.hosts, self);
656
+ // An explicit list naming only self leaves nothing remote to dial — skip the
657
+ // fan-out rather than let an empty list fall through to the device sweep.
658
+ if (!opts.hosts?.length || (remoteHosts && remoteHosts.length > 0)) {
659
+ const remote = await gatherRemoteActive(remoteHosts);
660
+ remoteDeviceCount = remote.deviceCount;
661
+ merged = dedupeByMachineSession([...local, ...remote.sessions]);
662
+ }
622
663
  }
623
664
  // --waiting: only sessions blocked on the user. Exits non-zero when any are
624
665
  // present so a supervising agent or hook can poll it as a gate.
@@ -664,6 +705,9 @@ function printCrossMachineTip() {
664
705
  }
665
706
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
666
707
  async function sessionsAction(query, options) {
708
+ // Explicit --query is interchangeable with the positional; it's how you search
709
+ // for text that collides with a subcommand name (e.g. `sessions --query go`).
710
+ query = query ?? options.query;
667
711
  // Normalize convenience flags before any routing reads them: per-agent
668
712
  // shorthands fold into --agent, and --device is an alias for --host (both
669
713
  // resolve against the same device registry).
@@ -1365,7 +1409,7 @@ const PICKER_TIPS = [
1365
1409
  export function formatPickerTip(sessions) {
1366
1410
  return chalk.gray(PICKER_TIPS[sessions.length % PICKER_TIPS.length]);
1367
1411
  }
1368
- export async function pickSessionInteractive(sessions, message = 'Search sessions:', initialSearch, hiddenCount = 0) {
1412
+ export async function pickSessionInteractive(sessions, message = 'Search sessions:', initialSearch, hiddenCount = 0, enterHint) {
1369
1413
  if (hiddenCount > 0) {
1370
1414
  console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
1371
1415
  }
@@ -1385,6 +1429,7 @@ export async function pickSessionInteractive(sessions, message = 'Search session
1385
1429
  labelFor: (s, query) => formatPickerLabel(s, query, cols),
1386
1430
  pageSize: PICKER_RECENT_COUNT,
1387
1431
  initialSearch,
1432
+ enterHint,
1388
1433
  });
1389
1434
  }
1390
1435
  catch (err) {
@@ -1888,6 +1933,7 @@ export function registerSessionsCommands(program) {
1888
1933
  const sessionsCmd = program
1889
1934
  .command('sessions')
1890
1935
  .argument('[query]', 'Session ID, search query, or path (., ../, /path) to filter by project')
1936
+ .option('--query <text>', 'Search text — use when the term collides with a subcommand name (e.g. "go")')
1891
1937
  .description('Find, browse, and read agent conversation transcripts across Claude, Codex, Gemini, and OpenCode.')
1892
1938
  .option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
1893
1939
  .option('--claude', 'Shorthand for --agent claude')
@@ -1967,6 +2013,7 @@ export function registerSessionsCommands(program) {
1967
2013
  registerSessionsTailCommand(sessionsCmd);
1968
2014
  registerSessionsSyncCommand(sessionsCmd);
1969
2015
  registerSessionsResumeCommand(sessionsCmd);
2016
+ registerGoCommand(sessionsCmd);
1970
2017
  registerSessionsInjectCommand(sessionsCmd);
1971
2018
  }
1972
2019
  function formatNoSessionsMessage(showAll, project) {
package/dist/index.js CHANGED
@@ -483,7 +483,7 @@ async function maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequ
483
483
  const { confirm } = await import('@inquirer/prompts');
484
484
  const { AGENTS } = await import('./lib/agents.js');
485
485
  const { getGlobalDefault, listInstalledVersions } = await import('./lib/versions.js');
486
- const { addShimsToPath, ensureShimCurrent, ensureVersionedAliasCurrent, getPathShadowingExecutable, getPathSetupInstructions, getShimsDir, isShimsInPath, listAgentsWithInstalledVersions, removeLegacyUserShim, } = await import('./lib/shims.js');
486
+ const { addShimsToPath, adoptShadowingLauncher, ensureShimCurrent, ensureVersionedAliasCurrent, getPathShadowingExecutable, getPathSetupInstructions, getShimsDir, isShimsInPath, listAgentsWithInstalledVersions, removeLegacyUserShim, } = await import('./lib/shims.js');
487
487
  const installedAgents = listAgentsWithInstalledVersions();
488
488
  if (installedAgents.length === 0) {
489
489
  return;
@@ -519,87 +519,78 @@ async function maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequ
519
519
  return;
520
520
  }
521
521
  const defaultAgents = installedAgents.filter((agent) => getGlobalDefault(agent));
522
+ // Auto-adopt any harness launcher that shadows our shim. PATH-order repair
523
+ // (below) cannot win against `~/.local/bin` — it's prepended in .zshenv for
524
+ // every shell while our prepend only lands in .zshrc — so for symlink
525
+ // launchers we *become* the launcher instead. Detection keys on the launcher
526
+ // symlink EXISTING (via adoptShadowingLauncher's own fallback), not on this
527
+ // shell's PATH order, so it also heals the GUI/non-interactive shadow an
528
+ // interactive run can't see. Reversible; only ever rewrites a symlink.
529
+ for (const agent of defaultAgents) {
530
+ const result = adoptShadowingLauncher(agent);
531
+ if (result.adopted) {
532
+ console.log(chalk.green(`Adopted ${AGENTS[agent].cliCommand} launcher (${result.launcher}) — version management now wins regardless of PATH order.`));
533
+ }
534
+ }
535
+ // Recompute AFTER adoption so anything we just took over drops out. What
536
+ // remains is a real binary we deliberately don't touch (adoption is
537
+ // symlink-only) — those get an honest one-time note, never a looping prompt.
522
538
  const shadowed = defaultAgents
523
539
  .map((agent) => ({ agent, shadowedBy: getPathShadowingExecutable(agent) }))
524
540
  .filter((item) => Boolean(item.shadowedBy));
525
- // Shell aliases that call the same command with extra flags are intentional
526
- // customization and don't break shim integration `addShimsToPath` cannot
527
- // touch them, so they don't belong in the repair prompt. We previously
528
- // computed an `aliased` list here and inserted it into `affected`, which
529
- // contradicted the comment below and surfaced false positives (e.g. an
530
- // earlier `alias codex=...` cancelled by a later `unalias codex` was
531
- // reported because the detector did a static rc-file regex).
532
- if (shadowed.length === 0 && isShimsInPath()) {
541
+ // After adoption, the only things left are (a) real-binary shadows we won't
542
+ // touch, and (b) a genuinely missing PATH entry. Nothing else needs the user.
543
+ const pathMissing = !isShimsInPath();
544
+ if (shadowed.length === 0 && !pathMissing) {
533
545
  return;
534
546
  }
535
- // Suppress repeated prompts within the same shell. A successful rc-file
536
- // edit doesn't reload the parent shell, so the next invocation sees the
537
- // same PATH and re-fires detection. The sentinel survives only as long as
538
- // the parent shell process once the user opens a new terminal, the
539
- // PPID changes and the prompt is allowed again.
547
+ // Suppress repeated notices within the same shell. A successful rc-file edit
548
+ // doesn't reload the parent shell, so the next invocation re-fires detection.
549
+ // The sentinel survives only as long as the parent shell process — a new
550
+ // terminal (new PPID) is allowed to surface it again.
540
551
  const sentinelPath = path.join(os.tmpdir(), `agents-shim-prompted-${process.ppid}`);
541
552
  if (fs.existsSync(sentinelPath)) {
542
553
  return;
543
554
  }
544
- const affected = [];
545
- for (const { agent, shadowedBy } of shadowed) {
546
- affected.push(`${AGENTS[agent].cliCommand} -> ${shadowedBy}`);
547
- }
548
- if (affected.length === 0) {
549
- // Pure PATH-not-loaded case: rc may already have the shim block, but the
550
- // running shell hasn't sourced it. Don't list agents here — they aren't
551
- // broken; only the PATH is stale. The prompt + post-message handle it.
552
- affected.push('PATH entry missing');
553
- }
554
- const shouldRepair = await confirm({
555
- message: `Repair shim integration now? ${affected.join(', ')}`,
556
- default: true,
557
- });
558
- if (!shouldRepair) {
559
- console.log(chalk.yellow('Shim integration still needs attention.'));
560
- console.log(chalk.gray(getPathSetupInstructions()));
561
- try {
562
- fs.writeFileSync(sentinelPath, '1');
563
- }
564
- catch { /* best-effort */ }
565
- return;
566
- }
567
- const pathResult = addShimsToPath();
568
- if (!pathResult.success) {
569
- console.log(chalk.yellow('Could not repair shim PATH setup automatically.'));
570
- console.log(chalk.gray(pathResult.error || getPathSetupInstructions()));
571
- // Write the sentinel even on failure — otherwise an unwritable rc file
572
- // re-prompts every invocation in the same shell. The user opens a new
573
- // terminal (new PPID) to retry.
574
- try {
575
- fs.writeFileSync(sentinelPath, '1');
576
- }
577
- catch { /* best-effort */ }
578
- return;
555
+ // Real-binary shadows: adoption is symlink-only (we never rename a real native
556
+ // binary), and `addShimsToPath` provably can't outrank an early-PATH dir like
557
+ // ~/.local/bin across zsh's whole sourcing chain. So DON'T offer a "Repair?"
558
+ // prompt here — that was the infinite-loop bug (Yes was always a no-op).
559
+ // Inform once and point at the real levers.
560
+ if (shadowed.length > 0) {
561
+ const targets = shadowed
562
+ .map(({ agent, shadowedBy }) => ` ${AGENTS[agent].cliCommand}: ${shadowedBy}`)
563
+ .join('\n');
564
+ console.log(chalk.yellow('These agent commands run a native binary instead of the version-managed shim:'));
565
+ console.log(chalk.gray(targets));
566
+ console.log(chalk.gray(`It's a real binary (not a symlink), so agents-cli won't move it. To hand it to agents-cli, remove/reorder it, or put ${getShimsDir()} earlier in PATH.`));
579
567
  }
580
- // When the rc file already has the canonical shim block, `addShimsToPath`
581
- // is a no-op re-emitting produced byte-identical content. In this branch
582
- // the user clicked "Yes" but nothing changed on disk, AND the underlying
583
- // cause (a real binary shadow, or a stale shell PATH) is unaffected by
584
- // this command. Be honest about it and point at the actual action.
585
- if (pathResult.alreadyPresent) {
586
- if (shadowed.length > 0) {
587
- const targets = shadowed
588
- .map(({ agent, shadowedBy }) => ` ${AGENTS[agent].cliCommand}: ${shadowedBy}`)
589
- .join('\n');
590
- console.log(chalk.yellow('Repair could not change anything — the shim is shadowed by another binary on PATH:'));
591
- console.log(chalk.gray(targets));
592
- console.log(chalk.gray(`Fix it by removing or reordering that binary, or making sure ${getShimsDir()} appears earlier in PATH than its parent dir.`));
568
+ // Genuinely-missing PATH entry is the one thing addShimsToPath actually fixes,
569
+ // so it's the only case that still earns an interactive prompt.
570
+ if (pathMissing) {
571
+ const shouldRepair = await confirm({
572
+ message: 'Add the agents-cli shims directory to your PATH now?',
573
+ default: true,
574
+ });
575
+ if (!shouldRepair) {
576
+ console.log(chalk.gray(getPathSetupInstructions()));
593
577
  }
594
578
  else {
595
- console.log(chalk.yellow(`Shim PATH entry is already in ~/${pathResult.rcFile} this shell just needs to reload it.`));
596
- console.log(chalk.gray(`Run: source ~/${pathResult.rcFile} (or open a new terminal)`));
579
+ const pathResult = addShimsToPath();
580
+ if (!pathResult.success) {
581
+ console.log(chalk.yellow('Could not update PATH automatically.'));
582
+ console.log(chalk.gray(pathResult.error || getPathSetupInstructions()));
583
+ }
584
+ else if (pathResult.alreadyPresent) {
585
+ console.log(chalk.yellow(`Shim PATH entry is already in ~/${pathResult.rcFile} — this shell just needs to reload it.`));
586
+ console.log(chalk.gray(`Run: source ~/${pathResult.rcFile} (or open a new terminal)`));
587
+ }
588
+ else {
589
+ console.log(chalk.green(`Added shims to PATH in ~/${pathResult.rcFile}`));
590
+ console.log(chalk.gray(getPathSetupInstructions()));
591
+ }
597
592
  }
598
593
  }
599
- else {
600
- console.log(chalk.green(`Repaired shim PATH setup in ~/${pathResult.rcFile}`));
601
- console.log(chalk.gray(getPathSetupInstructions()));
602
- }
603
594
  try {
604
595
  fs.writeFileSync(sentinelPath, '1');
605
596
  }
@@ -8,7 +8,7 @@
8
8
  * checks.
9
9
  */
10
10
  import type { BetaFeatureName } from './types.js';
11
- export declare const ALL_BETA_FEATURES: readonly ["drive", "factory"];
11
+ export declare const ALL_BETA_FEATURES: readonly ["drive", "factory", "session-sync"];
12
12
  export declare function getEnabledBetaFeatures(): BetaFeatureName[];
13
13
  export declare function isBetaEnabled(feature: BetaFeatureName): boolean;
14
14
  export declare function getBetaConfigLocation(): {
package/dist/lib/beta.js CHANGED
@@ -10,7 +10,7 @@
10
10
  import * as path from 'path';
11
11
  import { getAgentsDir, getOptionalUserAgentsDir, readMeta, writeMeta } from './state.js';
12
12
  import { readManifest, writeManifest } from './manifest.js';
13
- export const ALL_BETA_FEATURES = ['drive', 'factory'];
13
+ export const ALL_BETA_FEATURES = ['drive', 'factory', 'session-sync'];
14
14
  function isBetaFeatureName(value) {
15
15
  return typeof value === 'string' && ALL_BETA_FEATURES.includes(value);
16
16
  }
@@ -343,10 +343,14 @@ export async function runDaemon() {
343
343
  return;
344
344
  syncing = true;
345
345
  try {
346
- const { isSyncConfigured, isSyncEnabled } = await import('./session/sync/config.js');
347
- // isSyncEnabled() first: a machine the operator turned off must skip the
348
- // keychain read entirely, not just the network cycle.
349
- if (!isSyncEnabled() || !isSyncConfigured())
346
+ const { isBetaEnabled } = await import('./beta.js');
347
+ // Off by default: session sync is an opt-in beta feature. Check the beta
348
+ // flag FIRST so a machine that hasn't opted in skips the keychain read
349
+ // (isSyncConfigured) entirely, not just the network cycle.
350
+ if (!isBetaEnabled('session-sync'))
351
+ return;
352
+ const { isSyncConfigured } = await import('./session/sync/config.js');
353
+ if (!isSyncConfigured())
350
354
  return;
351
355
  const { syncSessions } = await import('./session/sync/sync.js');
352
356
  const r = await syncSessions();
@@ -89,6 +89,12 @@ export interface ActiveQueryOptions {
89
89
  /** Skip the `ps` scan for ad-hoc headless agents. */
90
90
  skipHeadless?: boolean;
91
91
  }
92
+ /**
93
+ * Locate the live transcript for an agent process. Claude files are keyed by
94
+ * cwd (+ optional session uuid); Codex files are date-partitioned, so we resolve
95
+ * the newest indexed Codex session for the cwd instead.
96
+ */
97
+ export declare function findSessionFileForKind(kind: string, cwd?: string, sessionId?: string): string | undefined;
92
98
  /** Live teams teammates. Reuses AgentManager which already polls PIDs via `kill -0`. */
93
99
  export declare function listTeamsActive(): Promise<ActiveSession[]>;
94
100
  /** Live editor-terminal agents across every IDE window. */
@@ -161,7 +161,7 @@ function classifyActivity(sessionFile) {
161
161
  * cwd (+ optional session uuid); Codex files are date-partitioned, so we resolve
162
162
  * the newest indexed Codex session for the cwd instead.
163
163
  */
164
- function findSessionFileForKind(kind, cwd, sessionId) {
164
+ export function findSessionFileForKind(kind, cwd, sessionId) {
165
165
  if (!cwd)
166
166
  return undefined;
167
167
  if (kind === 'claude')
@@ -5,19 +5,6 @@
5
5
  */
6
6
  /** Secrets bundle holding the R2 credentials. */
7
7
  export declare const SYNC_BUNDLE = "r2.backups";
8
- /** Env var that overrides the persisted enable flag (on/off/true/false/1/0/yes/no). */
9
- export declare const SYNC_ENABLED_ENV = "AGENTS_SESSIONS_SYNC";
10
- /** Durable, machine-local path holding the sync enable flag. */
11
- export declare function syncStateFilePath(): string;
12
- /**
13
- * Whether automatic session sync is enabled on this machine. Defaults to true;
14
- * an unrecognized env value falls through to the file; an absent/unreadable file
15
- * falls through to the default. Read fresh every call (no memoization) so a
16
- * `--disable` takes effect on the daemon's next ~90s cycle without a restart.
17
- */
18
- export declare function isSyncEnabled(): boolean;
19
- /** Persist the machine-local sync enable flag (durable across cache wipes). */
20
- export declare function setSyncEnabled(enabled: boolean): void;
21
8
  export interface R2Config {
22
9
  accountId: string;
23
10
  bucket: string;
@@ -3,65 +3,9 @@
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';
8
6
  import { readAndResolveBundleEnv } from '../../secrets/bundles.js';
9
- import { getHistoryDir } from '../../state.js';
10
7
  /** Secrets bundle holding the R2 credentials. */
11
8
  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
- }
65
9
  /**
66
10
  * Resolve R2 credentials from the `r2.backups` bundle. Throws a clear,
67
11
  * actionable error if the bundle or any key is missing — sync cannot proceed
@@ -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.
@@ -299,7 +299,10 @@ export declare function getShimPath(agent: AgentId): string;
299
299
  * loop because addShimsToPath() only edits the rc file, never the legacy
300
300
  * shim file itself.
301
301
  */
302
- export declare function getPathShadowingExecutable(agent: AgentId): string | null;
302
+ export declare function getPathShadowingExecutable(agent: AgentId, overrides?: {
303
+ pathDirs?: string[];
304
+ shimPath?: string;
305
+ }): string | null;
303
306
  /**
304
307
  * Delete the legacy ~/.agents/shims/<cli> file if it exists, returning whether
305
308
  * anything was removed. Pre-split installs put shims under ~/.agents/shims/;
@@ -311,6 +314,70 @@ export declare function getPathShadowingExecutable(agent: AgentId): string | nul
311
314
  export declare function removeLegacyUserShim(agent: AgentId, overrides?: {
312
315
  homeDir?: string;
313
316
  }): boolean;
317
+ /**
318
+ * Where an adopted launcher's provenance is recorded. Lives under durable
319
+ * `.history` (NOT the regenerable `.cache`) so the reverse pointer to the native
320
+ * binary survives a cache wipe — the shim reads it to fall through to the native
321
+ * binary by absolute path when no managed version resolves. Two lines:
322
+ * line 1 = original binary, line 2 = launcher path (for `--release`).
323
+ */
324
+ export declare function getAdoptedRecordPath(agent: AgentId, historyDir?: string): string;
325
+ /**
326
+ * The launcher a harness's own installer drops in an early-PATH dir. Detection
327
+ * for adoption keys on the launcher *existing as a symlink resolving outside our
328
+ * shims dir* — NOT on current PATH order. That's deliberate: the shim only loses
329
+ * PATH races in non-interactive / GUI-launched shells, which an interactive
330
+ * `agents` run can't observe via its own PATH. Keying on the durable symlink lets
331
+ * auto-adoption fire for those users too. Returns the launcher path or null.
332
+ */
333
+ export declare function findAdoptableLauncher(agent: AgentId, overrides?: {
334
+ homeDir?: string;
335
+ shimsDir?: string;
336
+ }): string | null;
337
+ export type AdoptResult = {
338
+ adopted: true;
339
+ launcher: string;
340
+ original: string;
341
+ } | {
342
+ adopted: false;
343
+ reason: 'no-shadow' | 'already-adopted' | 'not-a-symlink' | 'unsafe-target' | 'error';
344
+ launcher?: string;
345
+ };
346
+ /**
347
+ * Adopt the harness's own launcher that shadows our shim on PATH.
348
+ *
349
+ * PATH-ordering fixes (editing rc files) can never reliably win: `~/.local/bin`
350
+ * (where grok/droid/etc. self-install) is prepended in `.zshenv`/`.zprofile`
351
+ * for *every* shell, while our shims prepend only lands in `.zshrc`
352
+ * (interactive). No single rc file guarantees "last prepend wins" across zsh's
353
+ * whole sourcing chain, so the shim loses in non-interactive / GUI-launched
354
+ * contexts. Instead of fighting PATH order, we *become* the launcher: replace
355
+ * the shadowing symlink with one pointing at our shim, and record the real
356
+ * original so the shim falls through to it when no managed version is selected.
357
+ *
358
+ * Regression bounds:
359
+ * - Only ever touches a **symlink** (never renames/deletes a real binary).
360
+ * - Records the resolved original + launcher path for lossless restore
361
+ * (`releaseAdoptedLauncher`), in durable `.history` so a cache wipe can't
362
+ * orphan the reverse pointer.
363
+ * - Idempotent: a no-op once the launcher already points at our shim.
364
+ * - Never records our own shim as the "original" (would loop).
365
+ */
366
+ export declare function adoptShadowingLauncher(agent: AgentId, overrides?: {
367
+ shadowedBy?: string;
368
+ shimsDir?: string;
369
+ historyDir?: string;
370
+ }): AdoptResult;
371
+ /**
372
+ * Undo `adoptShadowingLauncher`: repoint the launcher back at the recorded
373
+ * original and drop the record. Reversible escape hatch for users who want the
374
+ * native launcher to win. Returns the restored original path, or null if there
375
+ * was nothing to release.
376
+ */
377
+ export declare function releaseAdoptedLauncher(agent: AgentId, overrides?: {
378
+ shimsDir?: string;
379
+ historyDir?: string;
380
+ }): string | null;
314
381
  export declare function hasAliasShadowingShim(agent: AgentId, overrides?: {
315
382
  homeDir?: string;
316
383
  }): boolean;