@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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.42
6
+
7
+ - **Fix: exiting a split pane inside an interactive `ag run` session kicked you out of tmux entirely.** When you split the window of an interactive agent session (`ag run claude`) with Ctrl-b `"`/`%` and then `exit`ed *your* split, the whole tmux client detached and dumped you back to the parent shell — even though the agent was still running in the other pane. Cause: `runInTmux` installed a session-wide `pane-died` hook (`detach-client`) meant to fire only when the AGENT pane exits (so the attach returns and the exit status is read), but with no `#{hook_pane}` guard it fired for *any* pane's death. The hook is now scoped to the agent pane; a user split that exits is closed in place (`kill-pane`, no lingering dead husk) and the agent keeps running full-window. Source: `apps/cli/src/lib/exec.ts`, `apps/cli/src/lib/tmux/session.test.ts`.
8
+ - **Every secret-value read is now audited, not just the ones that flowed through the resolver.** `agents events --module secrets` (or `--event secrets.get`) is meant to show "every secret accessed or revealed", but several paths read plaintext values without going through `readAndResolveBundleEnv` (the only place that emitted `secrets.get`), so they were invisible: `secrets push` (which reads the whole bundle to upload it — the most sensitive silent read), `secrets view --reveal`, the raw `secrets get <item>`, `secrets set <item>` (a raw write, no `secrets.set`), and the *initiating* side of `secrets exec --host` / `run --secrets bundle@host` (only the remote host logged it). Each now emits with a `source` telling you HOW it was read — `keychain`, `agent` (served from the unlocked broker), `reveal`, `raw-item`, `sync-push`, or `remote` (with the target `host`) — alongside the bundle, caller, keyCount, and OS-user/host/transport. The resolved **value is never written to the log**, only names and counts. All `secrets.*` events are now tagged `module: 'secrets'` so `--module secrets` actually surfaces the value reads (previously it matched only the coarse command events). Note: the event log has a 7-day retention, so export what you need for long-term records. Source: `src/lib/secrets/bundles.ts`, `src/lib/secrets/sync.ts`, `src/lib/secrets/remote.ts`, `src/commands/secrets.ts`, `docs/06-observability.md`.
9
+ - **Fix: `sessions --active` showed the SAME preview + topic for every co-located session.** Multiple Claude sessions in one cwd (e.g. several editor tabs, or two worktree siblings) all rendered identical activity — they looked like duplicate cards. `findClaudeSessionFile` fell back to the newest `.jsonl` in the cwd whenever a session's `<id>.jsonl` wasn't found, so every distinct session collapsed onto ONE file's preview/topic. The stale-id trigger: an editor caches the launch uuid in `live-terminals.json`, but Claude rotates its transcript uuid on resume/compact, so the cached id no longer matches any file. Now the terminal path resolves each tab's EXACT id from the pid registry (mirroring the headless path), the newest-file fallback is gated to the no-id case (`pickSessionFile`), and an unresolvable file reads as `idle` rather than `running`. Source: `apps/cli/src/lib/session/active.ts`.
10
+ - **Fix: one malformed Kimi session blanked the WHOLE `agents sessions` listing.** A Kimi `state.json` with neither `createdAt` nor `updatedAt` made `readKimiMeta` return an `undefined` timestamp, which binds `NULL` into the `timestamp TEXT NOT NULL` column and aborts the entire batch index — so a single bad session took down the listing for every session, not just itself. Two layers: `readKimiMeta` now coerces the timestamp to never-null, falling back to the `state.json` mtime (matching how the listing already ranks Kimi via `last_activity`, like every other parser); and `upsertSessionsBatch` wraps each row in a per-row guard so a future constraint-violating row skips itself (ledger deliberately not stamped, so the next scan re-tries it) instead of rolling back the whole batch. Source: `apps/cli/src/lib/session/discover.ts`, `apps/cli/src/lib/session/db.ts`.
11
+
12
+ ## 1.20.41
13
+
14
+ - **NEW: `agents sessions focus [id]`** — one command to get back to a session, however it's reachable. It **attaches** a live session in place (tmux `switch-client`/`attach-session`, a remote tmux over `ssh -tt`, or a Ghostty tab — joining the live process without forking); where there's **no live terminal to attach**, it **opens a new tab and resumes** the session — locally, or on the remote peer over SSH (`runOnPeer`, so the peer resolves the version-pinned binary). No id opens the rich live-session picker (this-machine first). Reuses the live-session detection and the terminal launch engine (`openSurfaces`), and folds `go`'s attach paths in. Source: `src/commands/focus.ts`, `src/commands/go.ts`.
15
+ - **`--device` is now a first-class alias of `--host`** on every host-routable command (`sessions`, `run`, …), registered centrally on `addHostOption` so a local fall-through no longer errors. Source: `src/lib/hosts/`.
16
+ - **`agents computer` steers Electron/webview targets over CDP** instead of reporting a fake success when the native-automation path can't reach them (#716).
17
+ - **Secrets: the "remember" policy hold now lasts 7 days and survives screen-lock**, instead of re-prompting after every lock/sleep; stale copies are evicted when a policy is tightened. Source: `src/lib/secrets/`.
18
+ - **Fixes:** shim `machine_id()` normalizes to match `normalizeHost()`, and shim resolution honors the per-device default pin (not just the central `agents.yaml`).
19
+ - **`agents sessions go` is retired as a deprecated alias for `agents sessions focus --attach-only`.** `go` was already a strict subset of `focus` — its only unique behavior was "attach the live terminal or refuse, never fork/resume." That behavior is now a first-class `--attach-only` flag on `focus` (`focus.ts`: `selectFallback()` picks `refuseFallback` under `--attach-only`, else the resume-in-a-new-tab fallback). `go` now prints a one-line deprecation notice and delegates to `focusAction(id, { attachOnly: true })`; the shared reach engine (`jumpTo`/`gatherLiveTargets`/`pickLiveTarget`/`refuseFallback`) still lives in `go.ts` and is imported by `focus.ts`. Source: `src/commands/go.ts`, `src/commands/focus.ts`.
20
+ - **`agents sessions --json --host <h>` now emits a clean JSON array** of recent (non-active) sessions instead of the legacy per-host raw banner stream, so a UI can fetch a remote device's recent sessions when it has no live agents. `serializeSessionsJson()` is shared by the local and remote `--json` paths; `runRemoteSessionsJson()` reuses the existing `gatherRemoteList` SSH fan-out. The non-JSON banner path and `--active` are unchanged (#711).
21
+
5
22
  ## 1.20.36
6
23
 
7
24
  **[windows] `agents sessions --active` detects sessions on Windows, and shim launches carry cwd + session identity everywhere**
@@ -80,4 +80,6 @@ export declare function shouldRaise(opts: {
80
80
  id?: string;
81
81
  raise?: boolean;
82
82
  }): boolean;
83
+ export declare function appPathIsElectron(appPath: string | null, exists?: (p: string) => boolean): boolean;
84
+ export declare function electronWebviewTip(appLabel: string): string;
83
85
  export declare function registerActionCommands(program: Command): void;
@@ -6,6 +6,7 @@
6
6
  // The daemon already implements every method; this file is the thin, typed
7
7
  // CLI skin over it plus a shared target resolver so callers stay in bundle-id
8
8
  // space and never hand-manage pids.
9
+ import { execFileSync } from 'child_process';
9
10
  import * as fs from 'fs';
10
11
  import * as path from 'path';
11
12
  import { openComputerClient, describeTransport, resolvePolicyPath, } from '../lib/computer-rpc.js';
@@ -199,7 +200,7 @@ function warnIfNotFrontmost(res) {
199
200
  }
200
201
  }
201
202
  function reportMissingHelper() {
202
- console.error('helper not built. Run: ./packages/computer-helper/scripts/build.sh debug');
203
+ console.error('helper not built. Run: ./native/computer-mac/scripts/build.sh debug');
203
204
  process.exit(1);
204
205
  }
205
206
  // Open a client, run fn, always close. Fails fast if no helper is present.
@@ -288,6 +289,59 @@ async function applyFocusPolicy(client, pid, opts) {
288
289
  if (shouldRaise(opts))
289
290
  unwrap(await client.call('focus_window', { pid }));
290
291
  }
292
+ // Electron/webview steering. macOS accepts an AX action (AXPress / set-AXValue)
293
+ // on an Electron/Chromium window, but it does NOT run the web app's real DOM
294
+ // handlers — React ignores it — so a reported `clicked`/`typed` on a webview can
295
+ // be a silent no-op. Detect Electron targets and steer the caller to CDP
296
+ // (`agents browser --electron`), which drives the webview for real. We warn, not
297
+ // block: the caller may still want the raw action (e.g. to focus + coordinate).
298
+ const electronCache = new Map();
299
+ // Resolve a bundle id to its .app path via Spotlight. Best-effort; null on miss.
300
+ function appPathForBundle(bundleId) {
301
+ try {
302
+ const out = execFileSync('mdfind', [`kMDItemCFBundleIdentifier == '${bundleId}'`], {
303
+ encoding: 'utf-8',
304
+ timeout: 3000,
305
+ });
306
+ return out.split('\n').map((s) => s.trim()).find((s) => s.endsWith('.app')) ?? null;
307
+ }
308
+ catch {
309
+ return null;
310
+ }
311
+ }
312
+ // Pure + unit-tested: does the .app at this path bundle the Electron framework?
313
+ export function appPathIsElectron(appPath, exists = fs.existsSync) {
314
+ if (!appPath)
315
+ return false;
316
+ return exists(path.join(appPath, 'Contents', 'Frameworks', 'Electron Framework.framework'));
317
+ }
318
+ // Is the app for this bundle id an Electron/webview app? macOS-only; memoized so
319
+ // the mdfind lookup runs at most once per bundle id per process.
320
+ function isElectronApp(bundleId) {
321
+ if (!bundleId || process.platform !== 'darwin')
322
+ return false;
323
+ const cached = electronCache.get(bundleId);
324
+ if (cached !== undefined)
325
+ return cached;
326
+ const result = appPathIsElectron(appPathForBundle(bundleId));
327
+ electronCache.set(bundleId, result);
328
+ return result;
329
+ }
330
+ // Pure + unit-tested: the CDP-steer note printed for a webview target.
331
+ export function electronWebviewTip(appLabel) {
332
+ return `note: ${appLabel} is an Electron/web UI — an AX click/type may not reach the webview `
333
+ + `(a reported success can be a no-op). To drive it reliably, relaunch it with `
334
+ + '`--remote-debugging-port=9222` and use `agents browser --electron` (CDP).';
335
+ }
336
+ // Print the CDP steer when the target is a known Electron app. Keyed off --bundle
337
+ // (the recommended way to target); a frontmost-resolved target without --bundle is
338
+ // left alone to avoid a second RPC on the hot path. Skipped for remote --host.
339
+ function warnIfElectronWebview(opts) {
340
+ if (opts.host)
341
+ return;
342
+ if (opts.bundle && isElectronApp(opts.bundle))
343
+ console.error(electronWebviewTip(opts.bundle));
344
+ }
291
345
  function emit(result, json, human) {
292
346
  if (json) {
293
347
  console.log(JSON.stringify(result, null, 2));
@@ -355,6 +409,7 @@ export function registerActionCommands(program) {
355
409
  .option('--json', 'Emit JSON'))).action(async (opts) => {
356
410
  await withClient(async (client) => {
357
411
  const pid = await resolveTargetPid(client, opts, { verb: 'click' });
412
+ warnIfElectronWebview(opts);
358
413
  const spec = buildElementOrCoords(opts);
359
414
  if (!spec.ok) {
360
415
  console.error(spec.error);
@@ -377,6 +432,7 @@ export function registerActionCommands(program) {
377
432
  .option('--json', 'Emit JSON'))).action(async (opts) => {
378
433
  await withClient(async (client) => {
379
434
  const pid = await resolveTargetPid(client, opts, { verb: 'right-click' });
435
+ warnIfElectronWebview(opts);
380
436
  const spec = buildElementOrCoords(opts);
381
437
  if (!spec.ok) {
382
438
  console.error(spec.error);
@@ -397,6 +453,7 @@ export function registerActionCommands(program) {
397
453
  .option('--json', 'Emit JSON'))).action(async (opts) => {
398
454
  await withClient(async (client) => {
399
455
  const pid = await resolveTargetPid(client, opts, { verb: 'type' });
456
+ warnIfElectronWebview(opts);
400
457
  const spec = buildElementOrCoords(opts);
401
458
  if (!spec.ok) {
402
459
  console.error(spec.error);
@@ -424,6 +481,7 @@ export function registerActionCommands(program) {
424
481
  .option('--json', 'Emit JSON')).action(async (opts) => {
425
482
  await withClient(async (client) => {
426
483
  const pid = await resolveTargetPid(client, opts, { verb: 'type-text' });
484
+ warnIfElectronWebview(opts);
427
485
  await applyFocusPolicy(client, pid, opts);
428
486
  const params = { pid, text: opts.text };
429
487
  if (opts.commit)
@@ -448,6 +506,7 @@ export function registerActionCommands(program) {
448
506
  .option('--json', 'Emit JSON')).action(async (opts) => {
449
507
  await withClient(async (client) => {
450
508
  const pid = await resolveTargetPid(client, opts, { verb: 'key' });
509
+ warnIfElectronWebview(opts);
451
510
  await applyFocusPolicy(client, pid, opts);
452
511
  const params = { pid, keys: opts.keys };
453
512
  if (opts.requireFrontmost)
@@ -22,9 +22,9 @@ export declare function detectImageFormat(buf: Buffer): '.png' | '.jpg' | null;
22
22
  * Make the screenshot filename honest about its bytes. The two helper backends
23
23
  * encode DIFFERENT formats and neither re-encodes to match the requested name:
24
24
  * the macOS helper (ScreenCaptureKit) returns JPEG
25
- * (packages/computer-helper/Sources/ComputerHelper/Screenshot.swift:207,212),
25
+ * (native/computer-mac/Sources/ComputerHelper/Screenshot.swift:207,212),
26
26
  * the Windows helper returns PNG
27
- * (packages/computer-helper-win/Screenshot.cs:33). So a fixed default extension
27
+ * (native/computer-win/Screenshot.cs:33). So a fixed default extension
28
28
  * cannot be correct for both — the only honest path is to sniff the real format
29
29
  * and swap the extension to match. Pure so it's unit-testable.
30
30
  *
@@ -53,9 +53,9 @@ export function detectImageFormat(buf) {
53
53
  * Make the screenshot filename honest about its bytes. The two helper backends
54
54
  * encode DIFFERENT formats and neither re-encodes to match the requested name:
55
55
  * the macOS helper (ScreenCaptureKit) returns JPEG
56
- * (packages/computer-helper/Sources/ComputerHelper/Screenshot.swift:207,212),
56
+ * (native/computer-mac/Sources/ComputerHelper/Screenshot.swift:207,212),
57
57
  * the Windows helper returns PNG
58
- * (packages/computer-helper-win/Screenshot.cs:33). So a fixed default extension
58
+ * (native/computer-win/Screenshot.cs:33). So a fixed default extension
59
59
  * cannot be correct for both — the only honest path is to sniff the real format
60
60
  * and swap the extension to match. Pure so it's unit-testable.
61
61
  *
@@ -354,7 +354,7 @@ function registerSetupCommand(program) {
354
354
  }
355
355
  const srcApp = resolveHelperApp();
356
356
  if (!srcApp || !fs.existsSync(srcApp)) {
357
- console.error('helper not built. Run: ./packages/computer-helper/scripts/build.sh debug');
357
+ console.error('helper not built. Run: ./native/computer-mac/scripts/build.sh debug');
358
358
  process.exit(1);
359
359
  }
360
360
  const home = os.homedir();
@@ -392,7 +392,7 @@ function registerSetupCommand(program) {
392
392
  }
393
393
  catch {
394
394
  console.error('codesign verify FAILED. The destination .app is unsigned or its signature was stripped.');
395
- console.error('rebuild the helper with a Developer ID cert: ./packages/computer-helper/scripts/build.sh release');
395
+ console.error('rebuild the helper with a Developer ID cert: ./native/computer-mac/scripts/build.sh release');
396
396
  process.exit(1);
397
397
  }
398
398
  // 3. Ensure socket + log parent dirs exist.
@@ -219,6 +219,7 @@ export function registerRunCommand(program) {
219
219
  .option('--resume [id]', 'Resume a previous conversation. Accepts a full or partial session id (prefix-matched against the index); omit the id to pick from recent sessions interactively. Resumes under the version that started the session. claude/codex resume natively; other agents replay via a /continue first message. Pair with a prompt to continue headlessly.')
220
220
  .option('--session-id <id>', 'Force a NEW conversation to use this exact session UUID (Claude only). This CREATES a session — to resume an existing one, use --resume.')
221
221
  .option('--verbose', 'Show detailed execution logs')
222
+ .option('--raw', 'Interactive runs on macOS/Linux launch inside a shared tmux session (for %pane addressing + re-attach). Pass --raw to spawn the agent directly instead. Also disabled by AGENTS_NO_TMUX=1.')
222
223
  .option('--timeout <duration>', 'Kill the agent after this duration (e.g., 30m, 1h, 2h30m)')
223
224
  .option('--fallback <agents>', 'Comma-separated agents to try on rate-limit failure. Each entry accepts an optional @version pin (e.g., codex@0.116.0,gemini). The primary runs first; if it exits with a rate-limit error, the next agent picks up via /continue handoff.')
224
225
  .option('-b, --balanced', 'Shortcut for --strategy balanced. Ignored when @version is pinned.')
@@ -1063,6 +1064,7 @@ export function registerRunCommand(program) {
1063
1064
  sessionId: resumeSessionId ?? options.sessionId,
1064
1065
  resume: resumeNative,
1065
1066
  verbose: options.verbose,
1067
+ raw: options.raw,
1066
1068
  timeout: options.timeout,
1067
1069
  env,
1068
1070
  toolsRestrict: workflowToolsRestrict,
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `agents sessions focus [id]` — take me to a live session, however it's reachable.
3
+ *
4
+ * Same detection as `go`, but where `go` *refuses* an un-attachable session,
5
+ * `focus` **opens a new tab and resumes it** — locally, or on the remote over SSH
6
+ * (via the terminal launch engine's `openSurfaces`, `host` = the peer). So:
7
+ * - in tmux (local/remote) -> attach the live pane (join it, no fork)
8
+ * - in Ghostty -> focus its tab
9
+ * - headless / plain / etc. -> new tab + `resume` (a copy if it's mid-run — the
10
+ * original keeps going; a clean continue if it's idle)
11
+ *
12
+ * NOTE: joining a live process without forking is only possible via tmux — that's
13
+ * why `--tmux`-wrapped launches are worth it for sessions you'll want back live.
14
+ */
15
+ import type { Command } from 'commander';
16
+ import { type UnreachableFallback } from './go.js';
17
+ import type { ActiveSession } from '../lib/session/active.js';
18
+ import type { SessionMeta } from '../lib/session/types.js';
19
+ export declare function registerFocusCommand(program: Command): void;
20
+ /**
21
+ * Which fallback fires when a session has no attach rail. `--attach-only` (the old
22
+ * `go`) refuses; the default opens a new tab and resumes a copy. Pure so it's testable
23
+ * without touching `jumpTo`'s side effects.
24
+ */
25
+ export declare function selectFallback(attachOnly: boolean | undefined): UnreachableFallback;
26
+ export declare function focusAction(id: string | undefined, opts: {
27
+ local?: boolean;
28
+ attachOnly?: boolean;
29
+ }): Promise<void>;
30
+ /** Minimal SessionMeta for a live session, enough for `buildResumeCommand` + placement. */
31
+ export declare function metaFromActive(s: ActiveSession): SessionMeta;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `agents sessions focus [id]` — take me to a live session, however it's reachable.
3
+ *
4
+ * Same detection as `go`, but where `go` *refuses* an un-attachable session,
5
+ * `focus` **opens a new tab and resumes it** — locally, or on the remote over SSH
6
+ * (via the terminal launch engine's `openSurfaces`, `host` = the peer). So:
7
+ * - in tmux (local/remote) -> attach the live pane (join it, no fork)
8
+ * - in Ghostty -> focus its tab
9
+ * - headless / plain / etc. -> new tab + `resume` (a copy if it's mid-run — the
10
+ * original keeps going; a clean continue if it's idle)
11
+ *
12
+ * NOTE: joining a live process without forking is only possible via tmux — that's
13
+ * why `--tmux`-wrapped launches are worth it for sessions you'll want back live.
14
+ */
15
+ import fs from 'node:fs';
16
+ import chalk from 'chalk';
17
+ import { gatherLiveTargets, pickLiveTarget, jumpTo, refuseFallback } from './go.js';
18
+ import { buildResumeCommand, resumeSessionInPlace } from './sessions.js';
19
+ import { runOnPeer } from '../lib/session/remote-list.js';
20
+ import { discoverSessions } from '../lib/session/discover.js';
21
+ import { openSurfaces, currentContext, availableBackends, detectCurrentBackend, } from '../lib/terminal/index.js';
22
+ import { isInteractiveTerminal } from './utils.js';
23
+ export function registerFocusCommand(program) {
24
+ program
25
+ .command('focus')
26
+ .argument('[id]', 'Short/full session id to focus; omit for an interactive picker')
27
+ .option('--local', 'Only this machine (skip the cross-host sweep)')
28
+ .option('--attach-only', 'Attach only — never open a new tab / resume a copy (the old `go` behavior)')
29
+ .description('Focus a live session — attach its terminal, or open a new tab and resume it')
30
+ .action(async (id, opts) => {
31
+ await focusAction(id, opts);
32
+ });
33
+ }
34
+ /**
35
+ * Which fallback fires when a session has no attach rail. `--attach-only` (the old
36
+ * `go`) refuses; the default opens a new tab and resumes a copy. Pure so it's testable
37
+ * without touching `jumpTo`'s side effects.
38
+ */
39
+ export function selectFallback(attachOnly) {
40
+ return attachOnly ? refuseFallback : resumeInNewTab;
41
+ }
42
+ export async function focusAction(id, opts) {
43
+ const { self, activeById } = await gatherLiveTargets(!!opts.local);
44
+ const fallback = selectFallback(opts.attachOnly);
45
+ if (id) {
46
+ const q = id.toLowerCase();
47
+ const matches = [...activeById.values()].filter((s) => s.sessionId.toLowerCase().startsWith(q));
48
+ if (matches.length === 1) {
49
+ await jumpTo(matches[0], self, fallback);
50
+ return;
51
+ }
52
+ if (matches.length > 1) {
53
+ console.error(chalk.red(`"${id}" is ambiguous (${matches.length} live matches). Use more of the id.`));
54
+ process.exitCode = 1;
55
+ return;
56
+ }
57
+ // Not live — it's a past session; resume is the right tool (multi-select + placement).
58
+ console.log(chalk.yellow(`No live session matching "${id}".`) +
59
+ chalk.gray(`\nTo resume a past session: agents sessions resume ${id}`));
60
+ process.exitCode = 1;
61
+ return;
62
+ }
63
+ if (!isInteractiveTerminal()) {
64
+ console.error(chalk.red('focus needs an interactive terminal, or pass a session id.'));
65
+ process.exitCode = 1;
66
+ return;
67
+ }
68
+ if (activeById.size === 0) {
69
+ console.log(chalk.gray('No live sessions to focus. To resume a past one: agents sessions resume'));
70
+ return;
71
+ }
72
+ const target = await pickLiveTarget(activeById, self, 'Focus a live session:', 'focus');
73
+ if (!target)
74
+ return;
75
+ await jumpTo(target, self, fallback);
76
+ }
77
+ function shortId(s) {
78
+ return (s.sessionId ?? '').slice(0, 8) || '-';
79
+ }
80
+ /** Minimal SessionMeta for a live session, enough for `buildResumeCommand` + placement. */
81
+ export function metaFromActive(s) {
82
+ return {
83
+ id: s.sessionId ?? '',
84
+ shortId: shortId(s),
85
+ agent: s.kind,
86
+ timestamp: new Date(s.startedAtMs ?? Date.now()).toISOString(),
87
+ filePath: '',
88
+ cwd: s.cwd,
89
+ };
90
+ }
91
+ /** Look up the rich indexed SessionMeta by id so `version` survives (version-pinned resume). */
92
+ async function richMetaById(id) {
93
+ try {
94
+ const metas = await discoverSessions({ all: true, since: '90d', limit: 2000 });
95
+ return metas.find((m) => m.id === id) ?? metas.find((m) => m.id.startsWith(id));
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ }
101
+ /**
102
+ * `focus`'s fallback for a session with no attach rail: reopen it and hand you to it.
103
+ * - remote → resume ON the peer over SSH (foreground) — the peer resolves the pinned
104
+ * version and holds the transcript, and `-tt` delivers you there.
105
+ * - local → resume in a new tab in your terminal, version-pinned via the indexed meta.
106
+ * Note: for a session that's still mid-run, this opens a COPY (the original keeps going);
107
+ * only tmux can *join* a live one without forking (see the header).
108
+ */
109
+ const resumeInNewTab = async (s, remote) => {
110
+ const id = s.sessionId ?? '';
111
+ if (!id) {
112
+ console.log(chalk.yellow('This session has no id to resume.'));
113
+ return;
114
+ }
115
+ // Remote: the transcript + pinned version live on the peer, so resume THERE over SSH.
116
+ // runOnPeer runs `agents sessions resume <id>` with a real TTY (`-tt`) in the foreground —
117
+ // it actually delivers you to the session (the peer picks the right version + HOME).
118
+ if (remote) {
119
+ console.log(chalk.gray(`${shortId(s)} has no live terminal on ${remote} — resuming it there over SSH…`));
120
+ const rc = await runOnPeer(['sessions', 'resume', id], remote, { tty: true });
121
+ if (rc === 'no-target') {
122
+ console.log(chalk.red(`${remote} isn't reachable as a device. Try: agents devices sync`));
123
+ console.log(chalk.gray(` or run it yourself: ssh ${remote} 'agents sessions resume ${shortId(s)}'`));
124
+ }
125
+ return;
126
+ }
127
+ // Local: resume in a new tab. Use the indexed meta so the version-pinned binary
128
+ // resumes in the same isolated HOME the transcript was written in.
129
+ const meta = (await richMetaById(id)) ?? metaFromActive(s);
130
+ const command = buildResumeCommand(meta);
131
+ if (!command) {
132
+ console.log(chalk.yellow(`${meta.shortId} — ${meta.agent} sessions aren't resumable, so there's no way to reopen it.`));
133
+ return;
134
+ }
135
+ const cwd = meta.cwd && fs.existsSync(meta.cwd) ? meta.cwd : process.cwd();
136
+ const ctx = currentContext();
137
+ const backend = detectCurrentBackend(ctx) ?? availableBackends(ctx)[0]?.id;
138
+ if (!backend) {
139
+ // No tab-capable surface (off-macOS, not in tmux) — resume in this process.
140
+ await resumeSessionInPlace(meta);
141
+ return;
142
+ }
143
+ console.log(chalk.gray(`${shortId(s)} has no live terminal to attach — opening a new ${backend} tab and resuming a copy.`));
144
+ const results = await openSurfaces([{ cwd, command }], { backend, packing: 'tabs' });
145
+ const r = results[0];
146
+ if (!r || !r.ok) {
147
+ console.log(chalk.red(` failed to open — ${r?.error ?? 'unknown error'}`));
148
+ console.log(chalk.gray(` try: agents sessions resume ${meta.shortId}`));
149
+ }
150
+ };
@@ -1,21 +1,35 @@
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 type { Command } from 'commander';
17
17
  import { type ActiveSession } from '../lib/session/active.js';
18
+ import type { SessionMeta } from '../lib/session/types.js';
18
19
  export declare function registerGoCommand(program: Command): void;
20
+ /** Live jump targets (local + remote), keyed by session id. Cloud excluded (no pid). */
21
+ export declare function gatherLiveTargets(local: boolean): Promise<{
22
+ self: string;
23
+ activeById: Map<string, ActiveSession>;
24
+ }>;
25
+ /** Interactive pick over the live sessions' rich SessionMeta; returns the chosen live session. */
26
+ export declare function pickLiveTarget(activeById: Map<string, ActiveSession>, self: string, message: string, enterHint: string): Promise<ActiveSession | null>;
27
+ /**
28
+ * Map each live session to its rich SessionMeta (worktree/PR/changes/tools/tests
29
+ * via the shared picker), reusing `discoverSessions`. Remote or unindexed live
30
+ * sessions get a minimal synthesized meta so they still appear and jump.
31
+ */
32
+ export declare function buildLivePool(activeById: Map<string, ActiveSession>, self: string): Promise<SessionMeta[]>;
19
33
  export interface Where {
20
34
  label: string;
21
35
  action: string;
@@ -26,3 +40,12 @@ export interface Where {
26
40
  * `jumpTo` below: remote-tmux, then local-tmux, then ghostty, then refuse.
27
41
  */
28
42
  export declare function describeWhere(s: ActiveSession, self: string): Where;
43
+ /**
44
+ * What to do when a session can't be *attached* (no tmux/Ghostty rail). `go`
45
+ * refuses; `focus` opens a new tab and resumes. `remote` is the peer name when
46
+ * the session lives on another machine, else undefined.
47
+ */
48
+ export type UnreachableFallback = (s: ActiveSession, remote: string | undefined) => void | Promise<void>;
49
+ /** Default (attach-only): open a login shell on the remote, or refuse locally. */
50
+ export declare function refuseFallback(s: ActiveSession, remote: string | undefined): Promise<void>;
51
+ export declare function jumpTo(s: ActiveSession, self: string, fallback?: UnreachableFallback): Promise<void>;