@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.
- package/CHANGELOG.md +17 -0
- package/dist/commands/computer-actions.d.ts +2 -0
- package/dist/commands/computer-actions.js +60 -1
- package/dist/commands/computer.d.ts +2 -2
- package/dist/commands/computer.js +4 -4
- package/dist/commands/exec.js +2 -0
- package/dist/commands/focus.d.ts +31 -0
- package/dist/commands/focus.js +150 -0
- package/dist/commands/go.d.ts +34 -11
- package/dist/commands/go.js +50 -65
- package/dist/commands/secrets.js +49 -10
- package/dist/commands/sessions.d.ts +9 -0
- package/dist/commands/sessions.js +77 -20
- package/dist/lib/computer-rpc.js +3 -3
- package/dist/lib/exec.d.ts +52 -0
- package/dist/lib/exec.js +150 -0
- package/dist/lib/hooks/cache.d.ts +1 -1
- package/dist/lib/hooks/cache.js +4 -2
- package/dist/lib/hosts/option.js +1 -0
- package/dist/lib/hosts/passthrough.d.ts +3 -3
- package/dist/lib/hosts/passthrough.js +14 -4
- package/dist/lib/hosts/remote-cmd.d.ts +7 -1
- package/dist/lib/hosts/remote-cmd.js +8 -1
- package/dist/lib/menubar/install-menubar.js +2 -2
- package/dist/lib/secrets/agent.d.ts +18 -7
- package/dist/lib/secrets/agent.js +32 -15
- package/dist/lib/secrets/bundles.d.ts +8 -6
- package/dist/lib/secrets/bundles.js +14 -8
- package/dist/lib/secrets/remote.js +14 -0
- package/dist/lib/secrets/sync.js +13 -0
- package/dist/lib/session/active.d.ts +47 -3
- package/dist/lib/session/active.js +132 -10
- package/dist/lib/session/db.js +45 -31
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +9 -2
- package/dist/lib/session/viewing-in.d.ts +54 -0
- package/dist/lib/session/viewing-in.js +155 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +32 -10
- package/dist/lib/ssh-tunnel.d.ts +1 -1
- package/dist/lib/ssh-tunnel.js +3 -3
- package/dist/lib/tmux/session.d.ts +46 -0
- package/dist/lib/tmux/session.js +84 -2
- package/dist/lib/types.d.ts +3 -3
- 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: ./
|
|
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
|
-
* (
|
|
25
|
+
* (native/computer-mac/Sources/ComputerHelper/Screenshot.swift:207,212),
|
|
26
26
|
* the Windows helper returns PNG
|
|
27
|
-
* (
|
|
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
|
-
* (
|
|
56
|
+
* (native/computer-mac/Sources/ComputerHelper/Screenshot.swift:207,212),
|
|
57
57
|
* the Windows helper returns PNG
|
|
58
|
-
* (
|
|
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: ./
|
|
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: ./
|
|
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.
|
package/dist/commands/exec.js
CHANGED
|
@@ -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
|
+
};
|
package/dist/commands/go.d.ts
CHANGED
|
@@ -1,21 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `agents sessions go [id]` —
|
|
2
|
+
* `agents sessions go [id]` — DEPRECATED alias for `agents sessions focus --attach-only`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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>;
|