-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathfocus.ts
More file actions
173 lines (159 loc) · 7.33 KB
/
Copy pathfocus.ts
File metadata and controls
173 lines (159 loc) · 7.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
/**
* `agents sessions focus [id]` — take me to a live session, however it's reachable.
*
* Same detection as `go`, but where `go` *refuses* an un-attachable session,
* `focus` **opens a new tab and resumes it** — locally, or on the remote over SSH
* (via the terminal launch engine's `openSurfaces`, `host` = the peer). So:
* - in tmux (local/remote) -> attach the live pane (join it, no fork)
* - in Ghostty -> focus its tab
* - headless / plain / etc. -> new tab + `resume` (a copy if it's mid-run — the
* original keeps going; a clean continue if it's idle)
*
* NOTE: joining a live process without forking is only possible via tmux — that's
* why `--tmux`-wrapped launches are worth it for sessions you'll want back live.
*/
import type { Command } from 'commander';
import fs from 'node:fs';
import chalk from 'chalk';
import { gatherLiveTargets, pickLiveTarget, jumpTo, refuseFallback, type UnreachableFallback } from './go.js';
import type { ActiveSession } from '../lib/session/active.js';
import type { SessionMeta, SessionAgentId } from '../lib/session/types.js';
import { buildResumeCommand, resumeSessionInPlace } from './sessions.js';
import { runOnPeer } from '../lib/session/remote-list.js';
import { discoverSessions } from '../lib/session/discover.js';
import {
openSurfaces,
currentContext,
availableBackends,
detectCurrentBackend,
type Backend,
} from '../lib/terminal/index.js';
import { isInteractiveTerminal } from './utils.js';
export function registerFocusCommand(program: Command): void {
program
.command('focus')
.argument('[id]', 'Short/full session id to focus; omit for an interactive picker')
.option('--local', 'Only this machine (skip the cross-host sweep)')
.option('--attach-only', 'Attach only — never open a new tab / resume a copy (the old `go` behavior)')
.description('Focus a live session — attach its terminal, or open a new tab and resume it')
.action(async (id: string | undefined, opts: { local?: boolean; attachOnly?: boolean }) => {
await focusAction(id, opts);
});
}
/**
* Which fallback fires when a session has no attach rail. `--attach-only` (the old
* `go`) refuses; the default opens a new tab and resumes a copy. Pure so it's testable
* without touching `jumpTo`'s side effects.
*/
export function selectFallback(attachOnly: boolean | undefined): UnreachableFallback {
return attachOnly ? refuseFallback : resumeInNewTab;
}
export async function focusAction(id: string | undefined, opts: { local?: boolean; attachOnly?: boolean }): Promise<void> {
const { self, activeById } = await gatherLiveTargets(!!opts.local);
const fallback = selectFallback(opts.attachOnly);
if (id) {
const q = id.toLowerCase();
const matches = [...activeById.values()].filter((s) => s.sessionId!.toLowerCase().startsWith(q));
if (matches.length === 1) {
await jumpTo(matches[0], self, fallback);
return;
}
if (matches.length > 1) {
console.error(chalk.red(`"${id}" is ambiguous (${matches.length} live matches). Use more of the id.`));
process.exitCode = 1;
return;
}
// Not live — it's a past session; resume is the right tool (multi-select + placement).
console.log(
chalk.yellow(`No live session matching "${id}".`) +
chalk.gray(`\nTo resume a past session: agents sessions resume ${id}`),
);
process.exitCode = 1;
return;
}
if (!isInteractiveTerminal()) {
console.error(chalk.red('focus needs an interactive terminal, or pass a session id.'));
process.exitCode = 1;
return;
}
if (activeById.size === 0) {
console.log(chalk.gray('No live sessions to focus. To resume a past one: agents sessions resume'));
return;
}
const target = await pickLiveTarget(activeById, self, 'Focus a live session:', 'focus');
if (!target) return;
await jumpTo(target, self, fallback);
}
function shortId(s: ActiveSession): string {
return (s.sessionId ?? '').slice(0, 8) || '-';
}
/** Minimal SessionMeta for a live session, enough for `buildResumeCommand` + placement. */
export function metaFromActive(s: ActiveSession): SessionMeta {
return {
id: s.sessionId ?? '',
shortId: shortId(s),
agent: s.kind as SessionAgentId,
timestamp: new Date(s.startedAtMs ?? Date.now()).toISOString(),
filePath: '',
cwd: s.cwd,
};
}
/** Look up the rich indexed SessionMeta by id so `version` survives (version-pinned resume). */
async function richMetaById(id: string): Promise<SessionMeta | undefined> {
try {
const metas = await discoverSessions({ all: true, since: '90d', limit: 2000 });
return metas.find((m) => m.id === id) ?? metas.find((m) => m.id.startsWith(id));
} catch {
return undefined;
}
}
/**
* `focus`'s fallback for a session with no attach rail: reopen it and hand you to it.
* - remote → resume ON the peer over SSH (foreground) — the peer resolves the pinned
* version and holds the transcript, and `-tt` delivers you there.
* - local → resume in a new tab in your terminal, version-pinned via the indexed meta.
* Note: for a session that's still mid-run, this opens a COPY (the original keeps going);
* only tmux can *join* a live one without forking (see the header).
*/
const resumeInNewTab: UnreachableFallback = async (s, remote) => {
const id = s.sessionId ?? '';
if (!id) {
console.log(chalk.yellow('This session has no id to resume.'));
return;
}
// Remote: the transcript + pinned version live on the peer, so resume THERE over SSH.
// runOnPeer runs `agents sessions resume <id>` with a real TTY (`-tt`) in the foreground —
// it actually delivers you to the session (the peer picks the right version + HOME).
if (remote) {
console.log(chalk.gray(`${shortId(s)} has no live terminal on ${remote} — resuming it there over SSH…`));
const rc = await runOnPeer(['sessions', 'resume', id], remote, { tty: true });
if (rc === 'no-target') {
console.log(chalk.red(`${remote} isn't reachable as a device. Try: agents devices sync`));
console.log(chalk.gray(` or run it yourself: ssh ${remote} 'agents sessions resume ${shortId(s)}'`));
}
return;
}
// Local: resume in a new tab. Use the indexed meta so the version-pinned binary
// resumes in the same isolated HOME the transcript was written in.
const meta = (await richMetaById(id)) ?? metaFromActive(s);
const command = buildResumeCommand(meta);
if (!command) {
console.log(chalk.yellow(`${meta.shortId} — ${meta.agent} sessions aren't resumable, so there's no way to reopen it.`));
return;
}
const cwd = meta.cwd && fs.existsSync(meta.cwd) ? meta.cwd : process.cwd();
const ctx = currentContext();
const backend: Backend | undefined = detectCurrentBackend(ctx) ?? availableBackends(ctx)[0]?.id;
if (!backend) {
// No tab-capable surface (off-macOS, not in tmux) — resume in this process.
await resumeSessionInPlace(meta);
return;
}
console.log(chalk.gray(`${shortId(s)} has no live terminal to attach — opening a new ${backend} tab and resuming a copy.`));
const results = await openSurfaces([{ cwd, command }], { backend, packing: 'tabs' });
const r = results[0];
if (!r || !r.ok) {
console.log(chalk.red(` failed to open — ${r?.error ?? 'unknown error'}`));
console.log(chalk.gray(` try: agents sessions resume ${meta.shortId}`));
}
};