@phnx-labs/agents-cli 1.20.40 → 1.20.42

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 (45) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/commands/computer-actions.d.ts +2 -0
  3. package/dist/commands/computer-actions.js +60 -1
  4. package/dist/commands/computer.d.ts +2 -2
  5. package/dist/commands/computer.js +4 -4
  6. package/dist/commands/exec.js +2 -0
  7. package/dist/commands/focus.d.ts +31 -0
  8. package/dist/commands/focus.js +150 -0
  9. package/dist/commands/go.d.ts +34 -11
  10. package/dist/commands/go.js +50 -65
  11. package/dist/commands/secrets.js +49 -10
  12. package/dist/commands/sessions.d.ts +9 -0
  13. package/dist/commands/sessions.js +77 -20
  14. package/dist/lib/computer-rpc.js +3 -3
  15. package/dist/lib/exec.d.ts +52 -0
  16. package/dist/lib/exec.js +150 -0
  17. package/dist/lib/hooks/cache.d.ts +1 -1
  18. package/dist/lib/hooks/cache.js +4 -2
  19. package/dist/lib/hosts/option.js +1 -0
  20. package/dist/lib/hosts/passthrough.d.ts +3 -3
  21. package/dist/lib/hosts/passthrough.js +14 -4
  22. package/dist/lib/hosts/remote-cmd.d.ts +7 -1
  23. package/dist/lib/hosts/remote-cmd.js +8 -1
  24. package/dist/lib/menubar/install-menubar.js +2 -2
  25. package/dist/lib/secrets/agent.d.ts +18 -7
  26. package/dist/lib/secrets/agent.js +32 -15
  27. package/dist/lib/secrets/bundles.d.ts +8 -6
  28. package/dist/lib/secrets/bundles.js +14 -8
  29. package/dist/lib/secrets/remote.js +14 -0
  30. package/dist/lib/secrets/sync.js +13 -0
  31. package/dist/lib/session/active.d.ts +47 -3
  32. package/dist/lib/session/active.js +132 -10
  33. package/dist/lib/session/db.js +45 -31
  34. package/dist/lib/session/discover.d.ts +5 -0
  35. package/dist/lib/session/discover.js +9 -2
  36. package/dist/lib/session/viewing-in.d.ts +54 -0
  37. package/dist/lib/session/viewing-in.js +155 -0
  38. package/dist/lib/shims.d.ts +1 -1
  39. package/dist/lib/shims.js +32 -10
  40. package/dist/lib/ssh-tunnel.d.ts +1 -1
  41. package/dist/lib/ssh-tunnel.js +3 -3
  42. package/dist/lib/tmux/session.d.ts +46 -0
  43. package/dist/lib/tmux/session.js +84 -2
  44. package/dist/lib/types.d.ts +3 -3
  45. package/package.json +1 -1
@@ -1,17 +1,17 @@
1
1
  /**
2
- * `agents sessions go [id]` — jump to a LIVE agent session's terminal.
2
+ * `agents sessions go [id]` — DEPRECATED alias for `agents sessions focus --attach-only`.
3
3
  *
4
- * No id -> the SAME rich interactive picker as `agents sessions` (worktree, PR,
5
- * changed files, tools, tests, last response this-machine first),
6
- * filtered to sessions that are running right now.
7
- * With id -> jump directly.
4
+ * `go` was "attach or refuse" (never fork/resume). `focus --attach-only` is exactly
5
+ * that behavior, so `go` now prints a deprecation notice and delegates to `focusAction`.
8
6
  *
9
- * "Jump" is not "resume" (which spawns a new process from the transcript). It walks
10
- * you to the already-running terminal:
11
- * local tmux -> attach (switch-client when already inside tmux)
12
- * local Ghostty -> focus its tab (Cmd+<n> via System Events; tab # from ghostty-tabs)
13
- * remote tmux -> ssh -tt + tmux attach (pane->session resolved on the remote)
14
- * otherwise -> refuse with a reason + resume hint (cloud / no attach rail)
7
+ * This file still owns the shared reach engine that `focus` imports:
8
+ * - `gatherLiveTargets` / `pickLiveTarget` / `buildLivePool` — live-session discovery + picker
9
+ * - `jumpTo` the side-effecting jump: attach the already-running terminal
10
+ * local tmux -> attach (switch-client when already inside tmux)
11
+ * local Ghostty -> focus its tab (Cmd+<n> via System Events; tab # from ghostty-tabs)
12
+ * remote tmux -> ssh -tt + tmux attach (pane->session resolved on the remote)
13
+ * otherwise -> hand off to the `UnreachableFallback` (attach-only refuses; focus resumes)
14
+ * - `refuseFallback` — the attach-only fallback (remote -> login shell; local -> refuse)
15
15
  */
16
16
  import chalk from 'chalk';
17
17
  import path from 'path';
@@ -21,8 +21,8 @@ import { getActiveSessions, findSessionFileForKind } from '../lib/session/active
21
21
  import { gatherRemoteActive } from '../lib/session/remote-active.js';
22
22
  import { discoverSessions } from '../lib/session/discover.js';
23
23
  import { dedupeByMachineSession, mergeLocalFirst, pickSessionInteractive } from './sessions.js';
24
+ import { focusAction } from './focus.js';
24
25
  import { machineId } from '../lib/session/sync/config.js';
25
- import { isInteractiveTerminal } from './utils.js';
26
26
  import { attachTmux, runTmux } from '../lib/tmux/binary.js';
27
27
  import { getDefaultSocketPath } from '../lib/tmux/paths.js';
28
28
  import { sshStream, assertValidSshTarget, shellQuote } from '../lib/ssh-exec.js';
@@ -33,20 +33,21 @@ export function registerGoCommand(program) {
33
33
  .command('go')
34
34
  .argument('[id]', 'Short/full session id to jump to; omit for an interactive picker')
35
35
  .option('--local', 'Only this machine (skip the cross-host sweep)')
36
- .description('Jump to a live agent session — attach its tmux, or focus its terminal tab')
36
+ .description('Deprecated alias for `sessions focus --attach-only`')
37
37
  .action(async (id, opts) => {
38
- await goAction(id, opts);
38
+ console.error(chalk.yellow('`sessions go` is deprecated — use `sessions focus --attach-only`'));
39
+ await focusAction(id, { local: opts.local, attachOnly: true });
39
40
  });
40
41
  }
41
- async function goAction(id, opts) {
42
+ /** Live jump targets (local + remote), keyed by session id. Cloud excluded (no pid). */
43
+ export async function gatherLiveTargets(local) {
42
44
  const self = machineId();
43
- // Live jump targets (local + remote), keyed by session id.
44
45
  const localActive = await getActiveSessions();
45
46
  for (const s of localActive)
46
47
  if (!s.machine)
47
48
  s.machine = self;
48
49
  let active = localActive;
49
- if (!opts.local) {
50
+ if (!local) {
50
51
  try {
51
52
  const remote = await gatherRemoteActive();
52
53
  active = dedupeByMachineSession([...localActive, ...remote.sessions]);
@@ -57,54 +58,24 @@ async function goAction(id, opts) {
57
58
  for (const s of active)
58
59
  if (s.context !== 'cloud' && s.sessionId)
59
60
  activeById.set(s.sessionId, s);
60
- if (activeById.size === 0) {
61
- console.log(chalk.gray('No live agent sessions to jump to.'));
62
- return;
63
- }
64
- // Direct jump by id — no picker.
65
- if (id) {
66
- const q = id.toLowerCase();
67
- const matches = [...activeById.values()].filter((s) => s.sessionId.toLowerCase().startsWith(q));
68
- if (matches.length === 0) {
69
- console.error(chalk.red(`No live session matching "${id}".`));
70
- process.exitCode = 1;
71
- return;
72
- }
73
- if (matches.length > 1) {
74
- console.error(chalk.red(`"${id}" is ambiguous (${matches.length} matches). Use more of the id.`));
75
- process.exitCode = 1;
76
- return;
77
- }
78
- await jumpTo(matches[0], self);
79
- return;
80
- }
81
- if (!isInteractiveTerminal()) {
82
- console.error(chalk.red('go needs an interactive terminal, or pass a session id.'));
83
- process.exitCode = 1;
84
- return;
85
- }
86
- // Reuse the rich `sessions` picker over the live sessions' full SessionMeta.
61
+ return { self, activeById };
62
+ }
63
+ /** Interactive pick over the live sessions' rich SessionMeta; returns the chosen live session. */
64
+ export async function pickLiveTarget(activeById, self, message, enterHint) {
87
65
  const pool = await buildLivePool(activeById, self);
88
- if (pool.length === 0) {
89
- console.log(chalk.gray('No live sessions to jump to.'));
90
- return;
91
- }
92
- const picked = await pickSessionInteractive(pool, 'Jump to a live session:', undefined, 0, 'jump');
66
+ if (pool.length === 0)
67
+ return null;
68
+ const picked = await pickSessionInteractive(pool, message, undefined, 0, enterHint);
93
69
  if (!picked)
94
- return;
95
- const target = activeById.get(picked.session.id);
96
- if (!target) {
97
- console.log(chalk.yellow(`${picked.session.shortId} is no longer live — try: `) + chalk.gray(`agents sessions resume ${picked.session.shortId}`));
98
- return;
99
- }
100
- await jumpTo(target, self);
70
+ return null;
71
+ return activeById.get(picked.session.id) ?? null;
101
72
  }
102
73
  /**
103
74
  * Map each live session to its rich SessionMeta (worktree/PR/changes/tools/tests
104
75
  * via the shared picker), reusing `discoverSessions`. Remote or unindexed live
105
76
  * sessions get a minimal synthesized meta so they still appear and jump.
106
77
  */
107
- async function buildLivePool(activeById, self) {
78
+ export async function buildLivePool(activeById, self) {
108
79
  let metas = [];
109
80
  try {
110
81
  metas = await discoverSessions({ all: true, since: '30d', limit: 1000 });
@@ -151,9 +122,14 @@ export function describeWhere(s, self) {
151
122
  const remote = s.machine && s.machine !== self ? s.machine : undefined;
152
123
  const mux = s.provenance?.mux;
153
124
  if (mux?.kind === 'tmux' && mux.pane) {
125
+ // When the renderer has resolved the current viewer, fold it into the label
126
+ // so `focus` reports "tmux %3 (viewing in codium tab 2)" / "(detached)".
127
+ const view = s.viewingIn
128
+ ? ` (viewing in ${s.viewingIn.app}${s.viewingIn.tab != null ? ` tab ${s.viewingIn.tab}` : ''})`
129
+ : '';
154
130
  return remote
155
131
  ? { label: `tmux ${mux.pane} on ${remote}`, action: `ssh + attach on ${remote}` }
156
- : { label: `tmux ${mux.pane}`, action: 'attach its tmux' };
132
+ : { label: `tmux ${mux.pane}${view}`, action: 'attach its tmux' };
157
133
  }
158
134
  if (!remote && s.host === 'ghostty')
159
135
  return { label: 'Ghostty', action: 'focus its Ghostty tab' };
@@ -161,7 +137,17 @@ export function describeWhere(s, self) {
161
137
  return { label: `${s.host ?? 'shell'} on ${remote}`, action: `open a shell on ${remote}` };
162
138
  return { label: s.host ?? 'unknown terminal', action: 'resume it (no live attach rail)' };
163
139
  }
164
- async function jumpTo(s, self) {
140
+ /** Default (attach-only): open a login shell on the remote, or refuse locally. */
141
+ export async function refuseFallback(s, remote) {
142
+ if (remote) {
143
+ console.log(chalk.yellow(`${shortId(s)} on ${remote} isn't inside tmux — opening a shell on ${remote} instead.`));
144
+ assertValidSshTarget(remote);
145
+ process.exit(sshStream(remote, 'exec "${SHELL:-/bin/sh}" -l', { tty: true }));
146
+ }
147
+ console.log(chalk.yellow(`Can't jump to ${shortId(s)} — it's in ${s.host ?? 'an unknown terminal'} with no attach rail (not tmux/Ghostty).`) +
148
+ chalk.gray(`\nTry: agents sessions resume ${shortId(s)}`));
149
+ }
150
+ export async function jumpTo(s, self, fallback = refuseFallback) {
165
151
  const remote = s.machine && s.machine !== self ? s.machine : undefined;
166
152
  const mux = s.provenance?.mux;
167
153
  // Path C: remote tmux — ssh in and attach, resolving the pane's session on the remote.
@@ -177,9 +163,9 @@ async function jumpTo(s, self) {
177
163
  console.log(chalk.gray(`Attaching ${shortId(s)} on ${remote} over SSH — Ctrl-b d to detach.`));
178
164
  process.exit(sshStream(remote, remoteCmd, { tty: true }));
179
165
  }
180
- console.log(chalk.yellow(`${shortId(s)} on ${remote} isn't inside tmux opening a shell on ${remote} instead.`));
181
- assertValidSshTarget(remote);
182
- process.exit(sshStream(remote, 'exec "${SHELL:-/bin/sh}" -l', { tty: true }));
166
+ // Remote, not in tmux hand off to the fallback (go: shell; focus: resume in a tab).
167
+ await fallback(s, remote);
168
+ return;
183
169
  }
184
170
  // Path B: local tmux — attach (or switch-client if we're already inside tmux).
185
171
  if (mux?.kind === 'tmux' && mux.pane) {
@@ -218,9 +204,8 @@ async function jumpTo(s, self) {
218
204
  chalk.gray(tab != null ? ` — switch to tab ${tab} (Cmd+${tab}).` : " — couldn't pinpoint its tab (same-repo forks are ambiguous); switch tabs manually."));
219
205
  return;
220
206
  }
221
- // Path D: refuse with a reason.
222
- console.log(chalk.yellow(`Can't jump to ${shortId(s)} — it's in ${s.host ?? 'an unknown terminal'} with no attach rail (not tmux/Ghostty).`) +
223
- chalk.gray(`\nTry: agents sessions resume ${shortId(s)}`));
207
+ // Path D: no attach rail (headless / plain terminal) → hand off to the fallback.
208
+ await fallback(s, undefined);
224
209
  }
225
210
  /** Resolve a local tmux pane id to its session name + window index. */
226
211
  async function resolveLocalPane(socket, pane) {
@@ -18,6 +18,7 @@ import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainI
18
18
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
19
19
  import { DEFAULT_TTL_MS, agentLoad, agentLock, agentStatus, ensureAgentRunning, installSecretsAgentService, runAgentLoadFromStdin, runSecretsAgent, secretsAgentServiceInstalled, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
20
20
  import { parseDuration } from '../lib/hooks/cache.js';
21
+ import { emit } from '../lib/events.js';
21
22
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
22
23
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
23
24
  import { registerSecretsSyncCommands } from './secrets-sync.js';
@@ -490,7 +491,7 @@ export function registerSecretsCommands(program) {
490
491
  # See what's in the bundle (values masked); shows its prompt policy
491
492
  agents secrets view prod
492
493
 
493
- # Stop a noisy automation bundle from prompting every run: ask once a day
494
+ # Stop a noisy automation bundle from prompting every run: ask once a week
494
495
  agents secrets policy prod daily
495
496
 
496
497
  # Eval the bundle into your current shell
@@ -510,15 +511,16 @@ export function registerSecretsCommands(program) {
510
511
  Touch ID noise: macOS pops a prompt per bundle per process. Each bundle has
511
512
  a prompt policy, shown in the POLICY column of 'agents secrets list':
512
513
  daily (default) ask once, then hold it silently in the local agent up
513
- to ~24h, until screen-lock / sleep / logout or 'lock'.
514
+ to ~7 days, until sleep / logout or 'lock' (a bare
515
+ screen-lock does NOT drop it). Name is historical.
514
516
  always ask for Touch ID every time — never auto-held.
515
- The default is 'daily' (one Touch ID per ~24h); change it globally with
517
+ The default is 'daily' (one Touch ID per ~7 days); change it globally with
516
518
  'secrets.policy' in agents.yaml, or per bundle with 'agents secrets policy
517
519
  <bundle> always'. 'agents secrets unlock <bundle>' holds any bundle after one
518
520
  prompt regardless of policy. Nothing on disk.
519
521
 
520
522
  See also:
521
- agents secrets policy <bundle> daily ask once a day, not every run
523
+ agents secrets policy <bundle> daily ask once a week, not every run
522
524
  agents secrets unlock <bundle> hold a bundle after one Touch ID
523
525
  agents secrets lock wipe held bundles (re-prompt next read)
524
526
  agents secrets status show held bundles + when they lock
@@ -623,7 +625,7 @@ export function registerSecretsCommands(program) {
623
625
  }
624
626
  else {
625
627
  console.log(bundlePolicy(bundle) === 'daily'
626
- ? chalk.gray('policy: daily (ask once, then held ~24h until screen-lock / sleep / logout)')
628
+ ? chalk.gray('policy: daily (ask once, then held ~7 days until sleep / logout — screen-lock does not drop it)')
627
629
  : chalk.gray('policy: always (asks for Touch ID every time — never auto-held)'));
628
630
  }
629
631
  if (bundle.created_at)
@@ -657,6 +659,26 @@ export function registerSecretsCommands(program) {
657
659
  catch {
658
660
  // Fall through to masked output on cancellation / batch failure.
659
661
  }
662
+ // Revealing plaintext bypasses readAndResolveBundleEnv (the usual
663
+ // audit chokepoint), so emit here — a `--reveal` exposes real values
664
+ // and must show up in `agents events --module secrets`. Count both the
665
+ // keychain values actually decrypted AND the inline literals (which
666
+ // `--reveal` always prints, even for a literal-only bundle with no
667
+ // keychain refs — see the entries loop below). Values are never
668
+ // included, only how many keys were exposed. Emit only when something
669
+ // was actually shown (a cancelled Touch ID + no literals reveals none).
670
+ const literalCount = entries.filter((e) => e.kind === 'literal').length;
671
+ const exposedCount = revealedValues.size + literalCount;
672
+ if (exposedCount > 0) {
673
+ emit('secrets.get', {
674
+ module: 'secrets',
675
+ bundle: bundle.name,
676
+ caller: 'view --reveal',
677
+ source: 'reveal',
678
+ status: 'success',
679
+ keyCount: exposedCount,
680
+ });
681
+ }
660
682
  }
661
683
  for (const e of entries) {
662
684
  if (e.kind === 'keychain') {
@@ -702,6 +724,9 @@ export function registerSecretsCommands(program) {
702
724
  // so `$(agents secrets get NAME)` captures it cleanly); diagnostics go
703
725
  // to stderr so they never pollute the captured value.
704
726
  const value = getKeychainToken(item);
727
+ // Raw item reads bypass readAndResolveBundleEnv, so audit here too.
728
+ // `item` is the keychain service name, never the value.
729
+ emit('secrets.get', { module: 'secrets', item, source: 'raw-item', status: 'success' });
705
730
  process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
706
731
  }
707
732
  catch {
@@ -733,6 +758,8 @@ export function registerSecretsCommands(program) {
733
758
  // so `agents secrets get` can read them back without a password sheet;
734
759
  // on Linux it goes through secret-tool / encrypted-file fallback.
735
760
  setKeychainToken(item, value);
761
+ // Raw item writes bypass writeBundle (the usual secrets.set chokepoint).
762
+ emit('secrets.set', { module: 'secrets', item, source: 'raw-item' });
736
763
  console.error(chalk.green(`Stored keychain item '${item}'.`));
737
764
  }
738
765
  catch (err) {
@@ -747,7 +774,7 @@ export function registerSecretsCommands(program) {
747
774
  .description('Create an empty bundle')
748
775
  .option('--description <text>', 'Free-form description')
749
776
  .option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
750
- .option('--policy <policy>', 'prompt policy: daily (default, ask once a day), always (ask every time), or never (silent, NO biometry ACL — needs --i-understand)')
777
+ .option('--policy <policy>', 'prompt policy: daily (default, ask once a week), always (ask every time), or never (silent, NO biometry ACL — needs --i-understand)')
751
778
  .addOption(new Option('--tier <policy>', 'deprecated alias for --policy').hideHelp())
752
779
  .option('--i-understand', 'Confirm creating a "never"-policy bundle (no biometry ACL) without an interactive prompt')
753
780
  .option('--backend <backend>', 'storage backend: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
@@ -1528,7 +1555,7 @@ Examples:
1528
1555
  cmd
1529
1556
  .command('unlock [names...]')
1530
1557
  .description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS).')
1531
- .option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h). Default 24h.')
1558
+ .option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
1532
1559
  .option('--all', 'Unlock every configured bundle')
1533
1560
  .action(async (names, opts) => {
1534
1561
  if (process.platform !== 'darwin') {
@@ -1544,7 +1571,7 @@ Examples:
1544
1571
  if (opts.ttl) {
1545
1572
  const secs = parseDuration(opts.ttl);
1546
1573
  if (!secs) {
1547
- console.error(chalk.red(`Invalid --ttl '${opts.ttl}'. Use e.g. 30m, 2h, 8h.`));
1574
+ console.error(chalk.red(`Invalid --ttl '${opts.ttl}'. Use e.g. 30m, 2h, 8h, 3d.`));
1548
1575
  process.exit(1);
1549
1576
  }
1550
1577
  ttlMs = secs * 1000;
@@ -1628,7 +1655,7 @@ Examples:
1628
1655
  cmd
1629
1656
  .command('policy <bundle> [policy]')
1630
1657
  .alias('tier')
1631
- .description("Show or set a bundle's prompt policy: daily (default, ask once a day), always (ask every time), or never (silent, NO biometry ACL).")
1658
+ .description("Show or set a bundle's prompt policy: daily (default, ask once a week), always (ask every time), or never (silent, NO biometry ACL).")
1632
1659
  .option('--i-understand', 'Confirm switching to the "never" policy (no biometry ACL) without an interactive prompt')
1633
1660
  .action(async (bundleName, policyArg, opts) => {
1634
1661
  try {
@@ -1644,11 +1671,23 @@ Examples:
1644
1671
  console.error(chalk.yellow('Aborted.'));
1645
1672
  return;
1646
1673
  }
1674
+ const wasDaily = bundlePolicy(bundle) === 'daily';
1647
1675
  bundle.policy = next;
1648
1676
  writeBundle(bundle);
1677
+ // Tightening daily -> always/never must take effect NOW, not up to the
1678
+ // ~7d hold later. If the broker is already serving this bundle silently
1679
+ // (auto-cached under the old `daily` policy), evict it so the next read
1680
+ // re-prompts (`always`) or reads its no-ACL item directly (`never`).
1681
+ // macOS-only + best-effort; agentLock no-ops off darwin / with no broker.
1682
+ if (wasDaily && next !== 'daily') {
1683
+ try {
1684
+ await agentLock(bundle.name);
1685
+ }
1686
+ catch { /* broker down — nothing held */ }
1687
+ }
1649
1688
  console.log(chalk.green(`${bundle.name} policy set to ${next}.`));
1650
1689
  if (next === 'daily') {
1651
- console.log(chalk.gray('Held by the secrets-agent for ~24h after one unlock (auto-cache is on by default; disable with `secrets.agent.auto: false` in agents.yaml).'));
1690
+ console.log(chalk.gray('Held by the secrets-agent for ~7 days after one unlock (auto-cache is on by default; disable with `secrets.agent.auto: false` in agents.yaml).'));
1652
1691
  }
1653
1692
  else if (next === 'always') {
1654
1693
  console.log(chalk.gray('Asks for Touch ID every time — never auto-held.'));
@@ -98,6 +98,15 @@ 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
+ * Serialize a `SessionMeta[]` to the clean JSON shape the `--json` listing
103
+ * emits: strip the internal-only scoring/provenance fields (`_matchedTerms`,
104
+ * `_bm25Score`, `_remote`) that are search/fan-out bookkeeping, never part of
105
+ * the public record, then pretty-print as a 2-space array with a trailing
106
+ * newline. The single seam shared by the local `--json` path and the
107
+ * `--json --host` remote fan-out so both emit byte-identical row shapes.
108
+ */
109
+ export declare function serializeSessionsJson(sessions: SessionMeta[]): string;
101
110
  /**
102
111
  * Whether the local machine's sessions belong in an `--active` view. Local is
103
112
  * included by default; an explicit `--host`/`--device` list scopes the view to
@@ -18,7 +18,8 @@ import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session
18
18
  import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecutable } from '../lib/platform/index.js';
19
19
  import { getActiveSessions } from '../lib/session/active.js';
20
20
  import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
21
- import { mapPanesToTargets } from '../lib/tmux/session.js';
21
+ import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
22
+ import { resolveViewingIn } from '../lib/session/viewing-in.js';
22
23
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
23
24
  import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
24
25
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
@@ -40,6 +41,7 @@ import { registerSessionsTailCommand } from './sessions-tail.js';
40
41
  import { registerSessionsSyncCommand } from './sessions-sync.js';
41
42
  import { registerSessionsResumeCommand } from './sessions-resume.js';
42
43
  import { registerGoCommand } from './go.js';
44
+ import { registerFocusCommand } from './focus.js';
43
45
  import { registerSessionsInjectCommand } from './sessions-inject.js';
44
46
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
45
47
  /**
@@ -313,6 +315,16 @@ function locatorBadge(s) {
313
315
  parts.push(chalk.red('ssh'));
314
316
  if (p?.mux?.kind === 'tmux' && (s.tmuxTarget || p.mux.pane)) {
315
317
  parts.push(chalk.green(s.tmuxTarget ?? p.mux.pane));
318
+ // For a tmux-hosted session, say which app+tab is looking at it right now
319
+ // (or that it's running detached). Only meaningful for tmux (the pane is the
320
+ // durable handle; the viewer is transient).
321
+ if (s.viewingIn) {
322
+ const tab = s.viewingIn.tab != null ? ` tab ${s.viewingIn.tab}` : '';
323
+ parts.push(chalk.gray(`viewing in ${s.viewingIn.app}${tab}`));
324
+ }
325
+ else {
326
+ parts.push(chalk.gray('detached'));
327
+ }
316
328
  }
317
329
  else if (p?.mux?.kind === 'screen') {
318
330
  parts.push(chalk.green('screen'));
@@ -507,6 +519,40 @@ export function mergeLocalFirst(sessions, localMachine) {
507
519
  });
508
520
  return keys.flatMap((k) => byMachine.get(k));
509
521
  }
522
+ /**
523
+ * Serialize a `SessionMeta[]` to the clean JSON shape the `--json` listing
524
+ * emits: strip the internal-only scoring/provenance fields (`_matchedTerms`,
525
+ * `_bm25Score`, `_remote`) that are search/fan-out bookkeeping, never part of
526
+ * the public record, then pretty-print as a 2-space array with a trailing
527
+ * newline. The single seam shared by the local `--json` path and the
528
+ * `--json --host` remote fan-out so both emit byte-identical row shapes.
529
+ */
530
+ export function serializeSessionsJson(sessions) {
531
+ const serializable = sessions.map((s) => {
532
+ const { _matchedTerms, _bm25Score, _remote, ...rest } = s;
533
+ return rest;
534
+ });
535
+ return JSON.stringify(serializable, null, 2) + '\n';
536
+ }
537
+ /**
538
+ * `agents sessions --json --host <h>` — fan the RECENT (non-active) listing out
539
+ * to the named host(s) and emit ONE clean merged `SessionMeta[]` JSON array,
540
+ * the same shape the local `--json` path emits. Reuses `gatherRemoteList` (the
541
+ * exact SSH fan-out the interactive cross-machine listing already uses) and
542
+ * serializes the merged, machine-tagged rows — instead of `runRemoteSessions`,
543
+ * which streams each remote's raw stdout under a per-host banner and so can
544
+ * never be JSON.parsed. A dead host contributes `[]` (with a stderr note from
545
+ * the fan-out), so stdout is always a valid array and the exit stays 0.
546
+ */
547
+ async function runRemoteSessionsJson(hosts) {
548
+ // Forward the caller's own filters (query, --limit, --since, …) minus --host,
549
+ // and guarantee --json so each peer answers with a parseable array.
550
+ const forwarded = buildForwardedArgs(process.argv, new Set(hosts));
551
+ if (!forwarded.includes('--json'))
552
+ forwarded.push('--json');
553
+ const { sessions } = await gatherRemoteList(forwarded, hosts);
554
+ process.stdout.write(serializeSessionsJson(sessions));
555
+ }
510
556
  /**
511
557
  * `running N · idle N · waiting N · queued N` for a bucket of sessions (zero
512
558
  * buckets omitted). Same bucketing as the grand-total summary so per-group
@@ -582,20 +628,27 @@ async function enrichLocalLocators(local) {
582
628
  }
583
629
  }
584
630
  catch { /* non-fatal */ }
585
- // tmux attach targets, one batched query per distinct socket.
631
+ // tmux attach targets + "viewing in <app> tab N", one batched query per socket.
586
632
  try {
587
633
  const tmux = local.filter(s => s.provenance?.mux?.kind === 'tmux' && s.provenance.mux.pane);
588
- const sockets = new Set(tmux.map(s => s.provenance.mux.socket));
589
- for (const socket of sockets) {
590
- const paneMap = await mapPanesToTargets(socket);
591
- if (paneMap.size === 0)
592
- continue;
593
- for (const s of tmux) {
594
- if (s.provenance.mux.socket !== socket)
634
+ if (tmux.length > 0) {
635
+ // One Ghostty enumeration shared across every socket's viewing-in resolve
636
+ // (a tmux client can be attached from a Ghostty tab).
637
+ const surfaces = await enumerateGhosttyTabs();
638
+ const sockets = new Set(tmux.map(s => s.provenance.mux.socket));
639
+ for (const socket of sockets) {
640
+ const paneMap = await mapPanesToTargets(socket);
641
+ if (paneMap.size === 0)
595
642
  continue;
596
- const target = paneMap.get(s.provenance.mux.pane);
597
- if (target)
598
- s.tmuxTarget = target;
643
+ const clients = await listClients(socket);
644
+ for (const s of tmux) {
645
+ if (s.provenance.mux.socket !== socket)
646
+ continue;
647
+ const target = paneMap.get(s.provenance.mux.pane);
648
+ if (target)
649
+ s.tmuxTarget = target;
650
+ s.viewingIn = await resolveViewingIn(s, clients, { paneToTarget: paneMap, ghosttySurfaces: surfaces });
651
+ }
599
652
  }
600
653
  }
601
654
  }
@@ -715,10 +768,17 @@ async function sessionsAction(query, options) {
715
768
  if (options.device && options.device.length > 0) {
716
769
  options.host = [...(options.host ?? []), ...options.device];
717
770
  }
718
- // --host WITHOUT --active keeps the legacy per-host stream (each remote's raw
719
- // stdout under a `── host ──` banner). With --active, the hosts are folded
720
- // into the merged machine-grouped view instead (handled below).
771
+ // --host WITHOUT --active. `--json` fans the recent listing out and emits ONE
772
+ // clean merged SessionMeta[] array (same shape as the local --json path), for
773
+ // scripts/extensions that JSON.parse a remote's history. Without --json it
774
+ // keeps the legacy per-host stream (each remote's raw stdout under a
775
+ // `── host ──` banner). With --active, the hosts are folded into the merged
776
+ // machine-grouped view instead (handled below).
721
777
  if (options.host && options.host.length > 0 && !options.active) {
778
+ if (options.json) {
779
+ await runRemoteSessionsJson(options.host);
780
+ return;
781
+ }
722
782
  try {
723
783
  runRemoteSessions(options.host);
724
784
  }
@@ -858,11 +918,7 @@ async function sessionsAction(query, options) {
858
918
  }
859
919
  if (options.json) {
860
920
  const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
861
- const serializable = filtered.map(s => {
862
- const { _matchedTerms, _bm25Score, _remote, ...rest } = s;
863
- return rest;
864
- });
865
- process.stdout.write(JSON.stringify(serializable, null, 2) + '\n');
921
+ process.stdout.write(serializeSessionsJson(filtered));
866
922
  return;
867
923
  }
868
924
  // Cross-machine fan-out: unless --local (or we ARE a peer answering a
@@ -2014,6 +2070,7 @@ export function registerSessionsCommands(program) {
2014
2070
  registerSessionsSyncCommand(sessionsCmd);
2015
2071
  registerSessionsResumeCommand(sessionsCmd);
2016
2072
  registerGoCommand(sessionsCmd);
2073
+ registerFocusCommand(sessionsCmd);
2017
2074
  registerSessionsInjectCommand(sessionsCmd);
2018
2075
  }
2019
2076
  function formatNoSessionsMessage(showAll, project) {
@@ -168,8 +168,8 @@ export function writeComputerPeers(allowedExecPaths) {
168
168
  export function resolveHelperExec() {
169
169
  const here = path.dirname(fileURLToPath(import.meta.url));
170
170
  const candidates = [
171
- // Local build (running from the agents-cli checkout).
172
- path.resolve(here, '..', '..', 'packages', 'computer-helper', 'dist', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
171
+ // Local build (running from the agents-cli checkout). apps/cli/dist/lib -> repo root (4 up) -> native/computer-mac.
172
+ path.resolve(here, '..', '..', '..', '..', 'native', 'computer-mac', 'dist', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
173
173
  // Bundled with the npm package (later: CDN download lands here).
174
174
  path.resolve(here, '..', 'computer-helper', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
175
175
  ];
@@ -218,7 +218,7 @@ export function openComputerClient() {
218
218
  }
219
219
  const helperExec = resolveHelperExec();
220
220
  if (!helperExec) {
221
- throw new Error('helper not built. Run: ./packages/computer-helper/scripts/build.sh debug');
221
+ throw new Error('helper not built. Run: ./native/computer-mac/scripts/build.sh debug');
222
222
  }
223
223
  return new StdioClient(helperExec);
224
224
  }
@@ -109,6 +109,12 @@ export interface ExecOptions {
109
109
  mcpConfigPath?: string;
110
110
  /** Raw args captured after `--` on the command line, forwarded verbatim to the underlying agent CLI. */
111
111
  passthroughArgs?: string[];
112
+ /**
113
+ * Escape hatch for the interactive tmux spawn-wrap (see shouldWrapInTmux):
114
+ * when true, spawn the agent directly instead of inside a shared-socket tmux
115
+ * session. Also forced off by AGENTS_NO_TMUX=1. No effect on headless runs.
116
+ */
117
+ raw?: boolean;
112
118
  }
113
119
  /**
114
120
  * Resolve interactive vs headless. Explicit flags are definitive and win over
@@ -213,6 +219,52 @@ export declare function resolveShimSpawn(platform: NodeJS.Platform, binary: stri
213
219
  * keeping version resolution in one place instead of reimplementing it in batch.
214
220
  */
215
221
  export declare function execShimPassthrough(agent: AgentId, rawArgs: string[], cwd: string, pinnedVersion?: string): Promise<number>;
222
+ /** Inputs that decide whether an interactive spawn is wrapped in a shared-socket tmux session. */
223
+ export interface TmuxWrapContext {
224
+ /** resolveInteractive() result — only interactive REPL launches are wrapped. */
225
+ interactive: boolean;
226
+ /** process.platform — Windows has no tmux path, always spawns bare. */
227
+ platform: NodeJS.Platform;
228
+ /** True when the launcher itself already runs inside tmux ($TMUX set) — never double-wrap. */
229
+ inTmux: boolean;
230
+ /** The `--raw` escape hatch. */
231
+ raw: boolean;
232
+ /** The AGENTS_NO_TMUX=1 escape hatch. */
233
+ noTmuxEnv: boolean;
234
+ /** Whether a tmux binary is on PATH. */
235
+ tmuxAvailable: boolean;
236
+ }
237
+ /**
238
+ * Decide whether to run an interactive agent INSIDE a detached tmux session on
239
+ * the shared socket (then attach the current TTY) instead of a bare spawn.
240
+ *
241
+ * tmux-wrapping gives every interactive agent an exact, unique `%pane` handle so
242
+ * `agents sessions --active` can tell co-located agents apart, and lets `agents
243
+ * focus` re-attach a live session without forking it. Pure so the gate is unit-
244
+ * tested independently of the (side-effecting) spawn.
245
+ *
246
+ * All five guards must pass:
247
+ * - interactive — a headless `-p` run has no TTY to attach; keep bare spawn.
248
+ * - not Windows — no tmux path on win32.
249
+ * - not already in tmux — nesting tmux-in-tmux is pointless and confusing.
250
+ * - not --raw — explicit opt-out.
251
+ * - not AGENTS_NO_TMUX=1 — env opt-out (CI, scripts, the shim passthrough path).
252
+ * - tmux installed — otherwise there is nothing to wrap with.
253
+ */
254
+ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
255
+ /**
256
+ * Build the shell command that runs an agent inside a tmux pane with the exact
257
+ * env the bare spawn would use. tmux runs it via `sh -c <cmd>`; we `exec env
258
+ * K=V … <agent> <args…>` so:
259
+ * - `env` materializes the full agent env INTO the pane, independent of the
260
+ * (possibly stale, shared) tmux server environment — additive, so tmux's own
261
+ * $TMUX / $TMUX_PANE still reach the agent for provenance detection;
262
+ * - `exec` replaces the shell so the agent is the pane's leaf process (clean
263
+ * `#{pane_pid}`, clean signal delivery on detach/kill).
264
+ * Keys are filtered to valid identifiers so exported shell functions
265
+ * (`BASH_FUNC_*%%`) can't make `env` choke.
266
+ */
267
+ export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv): string;
216
268
  /** Exit code spawnAgent resolves with when a run is killed for crossing a budget cap. */
217
269
  export declare const BUDGET_KILL_EXIT_CODE = 7;
218
270
  /**