@volter/supercode-terminal 0.2.30

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.
@@ -0,0 +1,899 @@
1
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { requireCommand, run, runAsync, runWithInputAsync, spawnInherited } from "./internal/exec.js";
5
+ import { snapshotDescendantPids } from "./internal/process-tree.js";
6
+ const TMUX_INSTALL_HINT = "Install it with: brew install tmux";
7
+ const PTY_ALLOCATION_HINT = [
8
+ "The tmux host could not create a pane because the OS could not allocate a pseudo-terminal.",
9
+ "This usually means local terminal/pty capacity is exhausted by existing terminal or tmux sessions.",
10
+ "No sessions were automatically closed. Inspect existing sessions and close only work that is safe to stop."
11
+ ].join(" ");
12
+ // Session-scoped tmux user option that records which provider created a session.
13
+ const ownerOption = "@supercode-owner";
14
+ // Optional dedicated tmux server (`tmux -L <socket>`). A `-L` server is fully
15
+ // separate from the user's DEFAULT server — the default can't see it and
16
+ // `kill-server` on it can't escape — so a provider (or a test) can run in
17
+ // complete isolation. `undefined` = the default server, i.e. production behavior
18
+ // unchanged. Always passed explicitly (never global/ambient state) so concurrent
19
+ // providers and tests can't race it.
20
+ function tmuxArgs(socket, args) {
21
+ return socket ? ["-L", socket, ...args] : args;
22
+ }
23
+ export function assertTmux() {
24
+ requireCommand("tmux", TMUX_INSTALL_HINT);
25
+ }
26
+ export function targetForSession(sessionName) {
27
+ if (!sessionName) {
28
+ throw new Error("A session name is required.");
29
+ }
30
+ return `${sessionName}:0`;
31
+ }
32
+ export function assertSession(name, socket) {
33
+ assertTmux();
34
+ if (!name) {
35
+ throw new Error("--name is required.");
36
+ }
37
+ run("tmux", tmuxArgs(socket, ["has-session", "-t", name]));
38
+ }
39
+ // Async: window creation runs on the provider event loop (createWindow →
40
+ // createWindowSession), so every tmux invocation here uses runAsync (spawn, not
41
+ // spawnSync) and the pane-ready poll below uses sleepAsync — see waitForPanesReady.
42
+ // Nothing in this function may block the loop.
43
+ export async function createSession({ command, cwd, env, name, owner, ownerOption: ownershipOption = ownerOption, panes, sessionOptions = {}, settings = {}, socket }) {
44
+ assertTmux();
45
+ if (!name) {
46
+ throw new Error("--name is required.");
47
+ }
48
+ if (!Number.isInteger(panes) || panes < 1) {
49
+ throw new Error("--panes must be a positive integer.");
50
+ }
51
+ if (command !== undefined && !command.program) {
52
+ throw new Error("A command program is required.");
53
+ }
54
+ // Global server options must be in place before the first pane spawns —
55
+ // history-limit is read at pane creation. On a fresh dedicated `-L` socket no
56
+ // server exists yet and `set-option -g` will NOT start one (only commands like
57
+ // new-session do), so chain the option-set and the create into ONE tmux
58
+ // invocation: the trailing new-session keeps alive the server its preceding
59
+ // set-options just configured. On an already-running server they are simply
60
+ // reapplied.
61
+ const sessionEnv = env ?? {};
62
+ const bootstrapDirectories = [];
63
+ const paneBootstrapArgs = (pane) => {
64
+ const commandArgs = command === undefined ? [] : [command.program, ...(command.arguments ?? [])];
65
+ if (Object.keys(sessionEnv).length === 0)
66
+ return commandArgs;
67
+ const directory = mkdtempSync(join(tmpdir(), "tmux-session-env-"));
68
+ bootstrapDirectories.push(directory);
69
+ const shell = process.env.SHELL ?? "/bin/sh";
70
+ return writeSessionEnvironmentBootstrap(directory, pane, sessionEnv, commandArgs.length === 0 ? [shell, "-l"] : commandArgs);
71
+ };
72
+ try {
73
+ const startArgs = [...sessionServerOptionArgs(), "new-session", "-d", "-s", name];
74
+ if (cwd) {
75
+ startArgs.push("-c", cwd);
76
+ }
77
+ startArgs.push(...paneBootstrapArgs(0));
78
+ await runTmuxSessionCreateAsync(startArgs, { cwd, operation: "create tmux session", session: name, socket });
79
+ if (owner) {
80
+ await runAsync("tmux", tmuxArgs(socket, ["set-option", "-t", name, ownershipOption, owner]));
81
+ }
82
+ for (const [option, value] of Object.entries(sessionOptions).sort(([left], [right]) => left.localeCompare(right))) {
83
+ await setSessionOptionAsync(name, option, value, socket);
84
+ }
85
+ for (const [option, value] of Object.entries(settings).sort(([left], [right]) => left.localeCompare(right))) {
86
+ await setSessionSettingAsync(name, option, value, socket);
87
+ }
88
+ await declareSessionColorEnvironmentAsync(socket);
89
+ const target = targetForSession(name);
90
+ for (let i = 1; i < panes; i += 1) {
91
+ const splitArgs = ["split-window", "-t", target];
92
+ if (cwd) {
93
+ splitArgs.push("-c", cwd);
94
+ }
95
+ splitArgs.push(...paneBootstrapArgs(i));
96
+ await runTmuxSessionCreateAsync(splitArgs, { cwd, operation: "split tmux window", session: name, socket });
97
+ await runAsync("tmux", tmuxArgs(socket, ["select-layout", "-t", target, "tiled"]));
98
+ }
99
+ await waitForPanesReady({ expectedPanes: panes, socket, target });
100
+ for (const directory of bootstrapDirectories)
101
+ await waitForSessionBootstrapCleanup(directory);
102
+ return { name, panes };
103
+ }
104
+ catch (error) {
105
+ for (const directory of bootstrapDirectories)
106
+ rmSync(directory, { force: true, recursive: true });
107
+ throw error;
108
+ }
109
+ }
110
+ function assertUserOption(option) {
111
+ if (!/^@[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(option)) {
112
+ throw new Error("A tmux user option must start with @ and contain only letters, numbers, underscores, or hyphens.");
113
+ }
114
+ }
115
+ // The boundary between a session name and a per-session value in one `-F` line.
116
+ // tmux 3.3a (Debian bookworm, Ubuntu 22.04 — every stock Linux container) rewrites
117
+ // EVERY control character in format output to `_`, so a tab comes back
118
+ // indistinguishable from data and every value parses as missing: the session's
119
+ // context key and owner both read as unset, `panes.list` reports `pane: null`, and
120
+ // attach/share by context refuse a pane that is right there. A colon survives, and
121
+ // tmux itself guarantees a session name never holds one — it rewrites `:` and `.` in
122
+ // a name to `_` at creation — so the FIRST colon is always the field boundary and the
123
+ // value (opaque, colons allowed) takes the whole remainder. Same rule as
124
+ // `listPanesFormat`: printable separator, free-text field last.
125
+ const sessionValueSeparator = ":";
126
+ /** Read one namespaced user option for every live session in one tmux call. */
127
+ export async function sessionOptionValuesAsync(option, socket) {
128
+ assertTmux();
129
+ assertUserOption(option);
130
+ let output;
131
+ try {
132
+ output = await runAsync("tmux", tmuxArgs(socket, ["list-sessions", "-F", `#{session_name}${sessionValueSeparator}#{${option}}`]));
133
+ }
134
+ catch {
135
+ return new Map();
136
+ }
137
+ const values = new Map();
138
+ for (const line of output.split("\n")) {
139
+ const separator = line.indexOf(sessionValueSeparator);
140
+ if (separator < 1)
141
+ continue;
142
+ const name = line.slice(0, separator);
143
+ const value = line.slice(separator + 1);
144
+ if (value)
145
+ values.set(name, value);
146
+ }
147
+ return values;
148
+ }
149
+ /** Set one of tmux's own session options on one session. */
150
+ export async function setSessionSettingAsync(session, option, value, socket) {
151
+ if (!session)
152
+ throw new Error("A session name is required.");
153
+ if (!/^[a-z][a-z0-9-]*$/.test(option))
154
+ throw new Error(`Not a tmux session option name: ${option}`);
155
+ if (/\0|\t|\r|\n/.test(value))
156
+ throw new Error("A tmux option value cannot contain control separators.");
157
+ await runAsync("tmux", tmuxArgs(socket, ["set-option", "-t", session, option, value]));
158
+ }
159
+ /** Set one session-scoped user option without exposing it in the session name. */
160
+ export async function setSessionOptionAsync(session, option, value, socket) {
161
+ if (!session)
162
+ throw new Error("A session name is required.");
163
+ assertUserOption(option);
164
+ if (/\0|\t|\r|\n/.test(value))
165
+ throw new Error("A tmux user option value cannot contain control separators.");
166
+ await runAsync("tmux", tmuxArgs(socket, ["set-option", "-t", session, option, value]));
167
+ }
168
+ // Names of the live tmux sessions stamped with exactly this owner. Used to
169
+ // reclaim a provider's own sessions without name-guessing: an unset `@supercode-owner`
170
+ // reads as empty, so untagged (user/foreign) sessions never match, and exact
171
+ // equality means owner `host` never sweeps owner `host-2`'s sessions.
172
+ // The `@supercode-owner` mark on one session (the value, or undefined if the session
173
+ // doesn't exist or is untagged). Lets a caller kill ONLY a session it owns —
174
+ // never a user's own same-named session on the shared default server.
175
+ export function sessionOwner(session, socket, ownershipOption = ownerOption) {
176
+ if (!session) {
177
+ return undefined;
178
+ }
179
+ try {
180
+ const value = run("tmux", tmuxArgs(socket, ["show-options", "-t", session, "-v", ownershipOption])).trim();
181
+ return value || undefined;
182
+ }
183
+ catch {
184
+ return undefined; // no such session
185
+ }
186
+ }
187
+ export function sessionsOwnedBy(owner, socket, ownershipOption = ownerOption) {
188
+ assertTmux();
189
+ if (!owner) {
190
+ return [];
191
+ }
192
+ let output;
193
+ try {
194
+ output = run("tmux", tmuxArgs(socket, ["list-sessions", "-F", `#{session_name}${sessionValueSeparator}#{${ownershipOption}}`]));
195
+ }
196
+ catch {
197
+ return []; // no server / no sessions
198
+ }
199
+ const owned = [];
200
+ for (const line of output.split("\n")) {
201
+ const separator = line.indexOf(sessionValueSeparator);
202
+ if (separator < 1)
203
+ continue;
204
+ const name = line.slice(0, separator);
205
+ const tag = line.slice(separator + 1);
206
+ if (name && tag === owner) {
207
+ owned.push(name);
208
+ }
209
+ }
210
+ return owned;
211
+ }
212
+ // Kill a session and the processes it spawned. tmux kill-session SIGHUPs the
213
+ // pane's foreground group, but agents fork detached helpers (MCP servers, node
214
+ // children) that survive that signal, reparent to init, and pile up until the
215
+ // host is out of resources (issue #10 — the same mechanism that, on the iTerm
216
+ // driver, let `killall iTerm2` orphan dozens of live claude processes: closing
217
+ // the GUI window never touched these). Snapshot the live descendant tree BEFORE
218
+ // killing the session — once the pane's shell exits its children reparent and
219
+ // can no longer be found from the (gone) root pid — then SIGKILL whatever the
220
+ // snapshot held. Best-effort throughout; the shared home for every driver that
221
+ // backs a window with a tmux session (virtual-tmux, iTerm).
222
+ export function killSessionProcessTree({ session, socket }) {
223
+ // Scope the pid lookup to this session (not the global `-a` list) and run it
224
+ // only on an explicit close/reap/dispose (event-driven, not the per-cycle
225
+ // observe loop). The sync `ps` below (via snapshotDescendantPids) is likewise
226
+ // occasional, never on a hot path.
227
+ let rootPids = [];
228
+ try {
229
+ rootPids = run("tmux", tmuxArgs(socket, ["list-panes", "-t", session, "-F", "#{pane_pid}"]))
230
+ .split("\n")
231
+ .map((line) => Number(line.trim()))
232
+ .filter((pid) => Number.isInteger(pid) && pid > 0);
233
+ }
234
+ catch {
235
+ // tmux may already be down; still attempt the kill-session below.
236
+ }
237
+ // Capture ONLY this session's descendant pids — snapshotDescendantPids walks
238
+ // down from the pane pids. (The whole machine's process table is NOT a safe
239
+ // input: SIGKILLing every row would kill every process the user owns — every
240
+ // terminal, the tmux server, the console. That was the all-terminals-die bug.)
241
+ const descendantPids = rootPids.length > 0 ? snapshotDescendantPids(rootPids) : [];
242
+ try {
243
+ run("tmux", tmuxArgs(socket, ["kill-session", "-t", session]));
244
+ }
245
+ catch {
246
+ // session may already be gone
247
+ }
248
+ for (const pid of descendantPids) {
249
+ try {
250
+ process.kill(pid, "SIGKILL");
251
+ }
252
+ catch {
253
+ // already exited
254
+ }
255
+ }
256
+ }
257
+ // Live tmux session names. Throws if there is no server / no sessions, which
258
+ // callers treat as "nothing to reconcile".
259
+ export function listTmuxSessionNames(socket) {
260
+ return run("tmux", tmuxArgs(socket, ["list-sessions", "-F", "#{session_name}"])).split("\n").map((line) => line.trim()).filter(Boolean);
261
+ }
262
+ // A session belongs to a provider's prefix iff it is the bare `prefix` or a
263
+ // `prefix-*` child. The exact `-` boundary is load-bearing: prefix `worker` must
264
+ // not claim `worker-pool-1`. The single home for that rule.
265
+ function matchesProviderPrefix(name, prefix) {
266
+ return name === prefix || name.startsWith(`${prefix}-`);
267
+ }
268
+ // `prefix-*` (and the bare `prefix`) sessions that a provider does not track.
269
+ // Pure so the orphan-reclaim decision is unit-tested without a live tmux server.
270
+ export function selectOrphanSessions({ allSessions, ownedSessions, prefix }) {
271
+ const owned = ownedSessions instanceof Set ? ownedSessions : new Set(ownedSessions);
272
+ return allSessions.filter((name) => matchesProviderPrefix(name, prefix) && !owned.has(name));
273
+ }
274
+ // Split the live `prefix-*` sessions into what a provider still owns and what it
275
+ // orphaned. `discovered`/`tracked`/`kept` are sorted for a stable summary;
276
+ // `orphans` follows allSessions order (the kill order). Pure — the classification
277
+ // is unit-tested without a live tmux server, mirroring selectOrphanSessions.
278
+ // `tracked` is every owned prefix session (some may be dead — not in `discovered`);
279
+ // `kept` is the owned sessions still live (`discovered` ∩ owned). Orphans are
280
+ // never owned, so they never appear in `kept`.
281
+ export function classifyPrefixSessions({ allSessions, ownedSessions, prefix }) {
282
+ const owned = ownedSessions instanceof Set ? ownedSessions : new Set(ownedSessions);
283
+ const discovered = allSessions.filter((name) => matchesProviderPrefix(name, prefix)).sort();
284
+ const tracked = [...owned].filter((name) => matchesProviderPrefix(name, prefix)).sort();
285
+ const kept = discovered.filter((name) => owned.has(name));
286
+ const orphans = selectOrphanSessions({ allSessions, ownedSessions: owned, prefix });
287
+ return { discovered, kept, orphans, tracked };
288
+ }
289
+ // The tmux server daemonizes with the environment of whichever process first
290
+ // touches it; a provider launched from a CI or agent shell carries NO_COLOR=1
291
+ // (plus npm's COLOR=0 and CODEX_CI=1), every pane inherits it, and programs
292
+ // render monochrome. Declare the color environment in the server's global
293
+ // environment before any pane spawns instead of inheriting that accident.
294
+ // (The first set-environment call also starts the server, so a fresh server
295
+ // is cleaned before its first session exists.)
296
+ async function declareSessionColorEnvironmentAsync(socket) {
297
+ for (const variable of ["NO_COLOR", "COLOR", "CODEX_CI"]) {
298
+ await runAsync("tmux", tmuxArgs(socket, ["set-environment", "-gu", variable]));
299
+ }
300
+ await runAsync("tmux", tmuxArgs(socket, ["set-environment", "-g", "COLORTERM", "truecolor"]));
301
+ }
302
+ // Server-wide options every managed session should carry, emitted as a chained-
303
+ // command prefix for the session-create invocation (see createSession for why
304
+ // they must share one tmux invocation). The iTerm/WezTerm/virtual-tmux browser
305
+ // mirrors run `tmux attach-session`, so they are full-screen tmux clients:
306
+ // native scrollback is bypassed and, with tmux's default `mouse off`, the wheel
307
+ // is unbound — nothing scrolls. `mouse on` binds the wheel to copy-mode in every
308
+ // client; `history-limit` (read at pane creation) gives every pane a deep
309
+ // scrollback. Each command ends with a standalone `;` so the trailing new-session
310
+ // runs in the same invocation — `run` uses spawnSync without a shell, so the `;`
311
+ // reaches tmux as a literal command separator, not a shell metacharacter.
312
+ // A supercode pane (p_*) that ends leaves one line in the pane-exit log: its exit
313
+ // status or signal when it failed (the session keeps a failed pane just long
314
+ // enough for tmux to report it, then closes it), `closed` when it ended cleanly.
315
+ const PANE_EXIT_LOG = '"${SUPERCODE_HOME:-$HOME/.config/supercode}/pane-exits.log"';
316
+ const PANE_DIED_HOOK = "if-shell -F '#{m:p_*,#{session_name}}' { run-shell 'printf \"%s %s died status=%s signal=%s\\n\" \"$(date +%FT%T)\" \"#{session_name}\" \"#{pane_dead_status}\" \"#{pane_dead_signal}\" >> " + PANE_EXIT_LOG + "' ; kill-pane }";
317
+ const SESSION_CLOSED_HOOK = "if-shell -F '#{m:p_*,#{hook_session_name}}' { run-shell 'printf \"%s %s closed\\n\" \"$(date +%FT%T)\" \"#{hook_session_name}\" >> " + PANE_EXIT_LOG + "' }";
318
+ function sessionServerOptionArgs() {
319
+ return [
320
+ "set-option", "-g", "history-limit", "50000", ";",
321
+ "set-option", "-g", "mouse", "on", ";",
322
+ // The relay clients (tmux-stream attaches as screen-256color; a person's own
323
+ // terminal attaches as something else) draw into the viewer's normal screen,
324
+ // so lines that scroll off land in the viewer's own scrollback, and they
325
+ // pass clipboard, focus and extended keys through. Indexed, so re-applying
326
+ // on every create replaces one entry instead of appending.
327
+ "set-option", "-s", "terminal-overrides[90]", "screen-256color:smcup@:rmcup@", ";",
328
+ "set-option", "-s", "terminal-features[90]", "screen-256color:clipboard:focus:extkeys:title", ";",
329
+ // Harness TUIs (Claude Code) track focus; tmux forwards focus in/out only with this on.
330
+ "set-option", "-s", "focus-events", "on", ";",
331
+ "set-hook", "-g", "pane-died[90]", PANE_DIED_HOOK, ";",
332
+ "set-hook", "-g", "session-closed[90]", SESSION_CLOSED_HOOK, ";"
333
+ ];
334
+ }
335
+ const tmuxClientEnvironmentNames = [
336
+ "COLORTERM",
337
+ "DISPLAY",
338
+ "HOME",
339
+ "LANG",
340
+ "LANGUAGE",
341
+ "LC_ALL",
342
+ "LC_CTYPE",
343
+ "LOGNAME",
344
+ "PATH",
345
+ "SHELL",
346
+ "TERM",
347
+ "TERM_PROGRAM",
348
+ "TMPDIR",
349
+ "TMUX_TMPDIR",
350
+ "USER",
351
+ "WAYLAND_DISPLAY",
352
+ "XAUTHORITY",
353
+ "XDG_RUNTIME_DIR"
354
+ ];
355
+ function tmuxClientEnvironment(source = process.env) {
356
+ const environment = {};
357
+ for (const name of tmuxClientEnvironmentNames) {
358
+ const value = source[name];
359
+ if (value !== undefined)
360
+ environment[name] = value;
361
+ }
362
+ return environment;
363
+ }
364
+ function writeSessionEnvironmentBootstrap(directory, pane, environment, command) {
365
+ const environmentPath = join(directory, `pane-${pane}.env`);
366
+ const wrapperPath = join(directory, `pane-${pane}.sh`);
367
+ const lines = Object.entries(environment).map(([name, value]) => {
368
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
369
+ throw new Error(`Invalid session environment variable name: ${JSON.stringify(name)}.`);
370
+ }
371
+ return `export ${name}=${shellQuote(value)}`;
372
+ });
373
+ writeFileSync(environmentPath, `${lines.join("\n")}\n`, { flag: "wx", mode: 0o600 });
374
+ writeFileSync(wrapperPath, [
375
+ "#!/bin/sh",
376
+ 'environment_path="$1"',
377
+ "shift",
378
+ "set -a",
379
+ '. "$environment_path"',
380
+ "set +a",
381
+ 'rm -f -- "$environment_path" "$0"',
382
+ 'rmdir -- "$(dirname -- "$0")" 2>/dev/null || true',
383
+ 'exec "$@"',
384
+ ""
385
+ ].join("\n"), { flag: "wx", mode: 0o700 });
386
+ return [wrapperPath, environmentPath, ...command];
387
+ }
388
+ function shellQuote(value) {
389
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
390
+ }
391
+ async function waitForSessionBootstrapCleanup(directory, timeoutMs = 10_000) {
392
+ const startedAt = Date.now();
393
+ while (existsSync(directory) && Date.now() - startedAt < timeoutMs) {
394
+ await sleepAsync(10);
395
+ }
396
+ if (existsSync(directory)) {
397
+ throw new Error(`Timed out waiting for the tmux pane environment bootstrap to self-delete: ${directory}.`);
398
+ }
399
+ }
400
+ async function runTmuxSessionCreateAsync(args, context) {
401
+ try {
402
+ return await runAsync("tmux", tmuxArgs(context.socket, args), { env: tmuxClientEnvironment(), inheritEnv: false });
403
+ }
404
+ catch (error) {
405
+ const message = error instanceof Error ? error.message : String(error);
406
+ if (isPtyAllocationFailure(message)) {
407
+ throw new Error([
408
+ `${context.operation} failed for session "${context.session}".`,
409
+ context.cwd ? `cwd: ${context.cwd}.` : undefined,
410
+ PTY_ALLOCATION_HINT,
411
+ `Original tmux error: ${message}`
412
+ ].filter(Boolean).join(" "));
413
+ }
414
+ throw error;
415
+ }
416
+ }
417
+ function isPtyAllocationFailure(message) {
418
+ return /fork failed:\s*Device not configured/i.test(message)
419
+ || /openpty:\s*Device not configured/i.test(message)
420
+ || /create window failed/i.test(message) && /Device not configured/i.test(message);
421
+ }
422
+ // Async: this poll runs inside createSession, on the createWindow path (the
423
+ // provider event loop). Both the delay (sleepAsync, a real setTimeout) and the
424
+ // per-iteration status check (inspectPanesAsync, a non-blocking spawn) yield the
425
+ // loop between iterations — nothing here uses spawnSync or Atomics.wait, so
426
+ // concurrent window creates never stall socket heartbeats. See CLAUDE.md's
427
+ // event-loop invariant.
428
+ export async function waitForPanesReady({ expectedPanes, socket, target, timeoutMs = 3000 }) {
429
+ const startedAt = Date.now();
430
+ while (Date.now() - startedAt < timeoutMs) {
431
+ const panes = await inspectPanesAsync(target, socket);
432
+ const ready = panes.length === expectedPanes
433
+ && panes.every((pane) => pane.dead === false && pane.id);
434
+ if (ready) {
435
+ return panes;
436
+ }
437
+ await sleepAsync(50);
438
+ }
439
+ throw new Error(`Timed out waiting for ${expectedPanes} tmux panes to become ready in ${target}.`);
440
+ }
441
+ const inspectPanesSeparator = "|";
442
+ const inspectPanesFormat = [
443
+ "#{pane_index}",
444
+ "#{pane_id}",
445
+ "#{pane_dead}",
446
+ "#{pane_current_command}"
447
+ ].join(inspectPanesSeparator);
448
+ function parseInspectPanesOutput(output) {
449
+ const trimmed = output.trim();
450
+ if (!trimmed) {
451
+ return [];
452
+ }
453
+ return trimmed.split("\n").map((line) => {
454
+ const [pane, id, dead, currentCommand] = line.split(inspectPanesSeparator);
455
+ if (pane === undefined || id === undefined || dead === undefined) {
456
+ throw new Error(`Could not parse tmux pane line: ${line}`);
457
+ }
458
+ return {
459
+ currentCommand: currentCommand ?? "",
460
+ dead: dead === "1",
461
+ id,
462
+ pane: Number(pane)
463
+ };
464
+ });
465
+ }
466
+ export function inspectPanes(target, socket) {
467
+ return parseInspectPanesOutput(run("tmux", tmuxArgs(socket, ["list-panes", "-t", target, "-F", inspectPanesFormat])));
468
+ }
469
+ // Async twin used by waitForPanesReady (the createWindow/createSession path) so
470
+ // the readiness poll never blocks the provider event loop with a synchronous
471
+ // spawn — see CLAUDE.md's event-loop invariant.
472
+ export async function inspectPanesAsync(target, socket) {
473
+ return parseInspectPanesOutput(await runAsync("tmux", tmuxArgs(socket, ["list-panes", "-t", target, "-F", inspectPanesFormat])));
474
+ }
475
+ const listPanesSeparator = "|";
476
+ const listPanesFormat = [
477
+ "#{session_name}",
478
+ "#{window_index}",
479
+ "#{pane_index}",
480
+ "#{pane_id}",
481
+ "#{pane_pid}",
482
+ "#{pane_current_path}",
483
+ "#{pane_active}",
484
+ // The process in the foreground of the pane's tty: the pane's own shell when
485
+ // nothing is running, the command's name while one is. `inspectPanes` has
486
+ // always read it per-target; enumerating it here is what lets a caller
487
+ // listing every pane at once tell a prompt from work in progress without a
488
+ // second tmux call per pane.
489
+ "#{pane_current_command}",
490
+ // Last, and last for a reason: the title is free text a user controls, and
491
+ // the separator is a bare "|". A field after it would be lost to the first
492
+ // pipe somebody types into a pane title.
493
+ "#{pane_title}"
494
+ ].join(listPanesSeparator);
495
+ function parseListPanesOutput(output) {
496
+ const trimmed = output.trim();
497
+ if (!trimmed) {
498
+ return [];
499
+ }
500
+ return trimmed.split("\n").map((line) => {
501
+ const [session, window, pane, id, rootPid, cwd, active, currentCommand, title] = line.split(listPanesSeparator);
502
+ if (session === undefined || window === undefined || pane === undefined || id === undefined || rootPid === undefined || cwd === undefined || active === undefined) {
503
+ throw new Error(`Could not parse tmux pane line: ${line}`);
504
+ }
505
+ const parsedRootPid = Number(rootPid);
506
+ if (!Number.isInteger(parsedRootPid) || parsedRootPid < 1) {
507
+ throw new Error(`Could not parse tmux pane pid: ${line}`);
508
+ }
509
+ return {
510
+ active: active === "1",
511
+ currentCommand: currentCommand ?? "",
512
+ cwd,
513
+ id,
514
+ pane: Number(pane),
515
+ rootPid: parsedRootPid,
516
+ session,
517
+ title: title ?? "",
518
+ window: Number(window)
519
+ };
520
+ });
521
+ }
522
+ export function listPanes(socket) {
523
+ assertTmux();
524
+ return parseListPanesOutput(run("tmux", tmuxArgs(socket, ["list-panes", "-a", "-F", listPanesFormat])));
525
+ }
526
+ // Async twin used by the observe loop so the (frequent) pane enumeration never
527
+ // blocks the provider event loop.
528
+ export async function listPanesAsync(socket) {
529
+ assertTmux();
530
+ return parseListPanesOutput(await runAsync("tmux", tmuxArgs(socket, ["list-panes", "-a", "-F", listPanesFormat])));
531
+ }
532
+ const clientForTtySeparator = "|";
533
+ const clientForTtyFormat = [
534
+ "#{client_tty}",
535
+ "#{session_name}",
536
+ "#{window_index}",
537
+ "#{pane_index}",
538
+ "#{client_width}",
539
+ "#{client_height}"
540
+ ].join(clientForTtySeparator);
541
+ function parseClientForTty(output, tty) {
542
+ if (!output) {
543
+ return undefined;
544
+ }
545
+ for (const line of output.split("\n")) {
546
+ const [clientTty, session, window, pane, widthText, heightText] = line.split(clientForTtySeparator);
547
+ if (clientTty === undefined || session === undefined || window === undefined || pane === undefined || widthText === undefined || heightText === undefined) {
548
+ throw new Error(`Could not parse tmux client line: ${line}`);
549
+ }
550
+ if (clientTty === tty) {
551
+ const width = Number(widthText);
552
+ const height = Number(heightText);
553
+ if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) {
554
+ throw new Error(`Could not parse tmux client size: ${line}`);
555
+ }
556
+ return { height, target: `${session}:${window}.${pane}`, width };
557
+ }
558
+ }
559
+ return undefined;
560
+ }
561
+ export function clientForTty(tty, socket) {
562
+ assertTmux();
563
+ if (!tty) {
564
+ throw new Error("A client tty is required.");
565
+ }
566
+ return parseClientForTty(run("tmux", tmuxArgs(socket, ["list-clients", "-F", clientForTtyFormat])).trim(), tty);
567
+ }
568
+ // Async twin of clientForTty for the observe loop (the iTerm unmanaged path) — a
569
+ // per-window client lookup off the event loop instead of a synchronous fan-out.
570
+ export async function clientForTtyAsync(tty, socket) {
571
+ assertTmux();
572
+ if (!tty) {
573
+ throw new Error("A client tty is required.");
574
+ }
575
+ return parseClientForTty((await runAsync("tmux", tmuxArgs(socket, ["list-clients", "-F", clientForTtyFormat]))).trim(), tty);
576
+ }
577
+ // The reverse lookup: the ttys of every client attached to a session (0 or 1
578
+ // for a managed iTerm window). Lets the iTerm close path resolve a window by
579
+ // its attached tty instead of a recorded window id that may have gone stale.
580
+ // Returns [] when the session does not exist or nothing is attached — both are
581
+ // ordinary states on the close path, not errors.
582
+ export function clientTtysForSession(session, socket) {
583
+ assertTmux();
584
+ if (!session) {
585
+ throw new Error("A session name is required.");
586
+ }
587
+ let output;
588
+ try {
589
+ output = run("tmux", tmuxArgs(socket, ["list-clients", "-t", session, "-F", "#{client_tty}"])).trim();
590
+ }
591
+ catch {
592
+ return [];
593
+ }
594
+ return output.split("\n").filter((tty) => tty.length > 0);
595
+ }
596
+ export function getWindowSize({ name, socket, window = 0 }) {
597
+ assertSession(name, socket);
598
+ const separator = "|";
599
+ const output = run("tmux", tmuxArgs(socket, [
600
+ "display-message",
601
+ "-p",
602
+ "-t",
603
+ `${name}:${window}`,
604
+ `#{window_width}${separator}#{window_height}`
605
+ ])).trim();
606
+ const [width, height] = output.split(separator).map(Number);
607
+ if (width === undefined || height === undefined || !Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
608
+ throw new Error(`Could not determine tmux window size for ${name}:${window}.`);
609
+ }
610
+ return { height, width };
611
+ }
612
+ export function getStatusLineCount(name, socket) {
613
+ assertSession(name, socket);
614
+ const value = run("tmux", tmuxArgs(socket, ["show-options", "-Aqv", "-t", name, "status"])).trim();
615
+ if (value === "off")
616
+ return 0;
617
+ if (value === "on")
618
+ return 1;
619
+ const lines = Number(value);
620
+ if (!Number.isInteger(lines) || lines < 0 || lines > 5) {
621
+ throw new Error(`Could not determine tmux status height for ${name}.`);
622
+ }
623
+ return lines;
624
+ }
625
+ // Every window-0 size in ONE async exec, keyed by session. The observe loop uses
626
+ // this to size windows off the event loop instead of a synchronous getWindowSize
627
+ // per window — that per-window fan-out blocked the provider loop under a large fleet
628
+ // (see CLAUDE.md: nothing sync on the observe path). Sessions absent here (transient /
629
+ // not-yet-created) just fall to the caller's default and self-correct next observe.
630
+ export async function listWindowSizesAsync(socket) {
631
+ assertTmux();
632
+ const separator = "|";
633
+ let output;
634
+ try {
635
+ output = (await runAsync("tmux", tmuxArgs(socket, [
636
+ "list-windows",
637
+ "-a",
638
+ "-F",
639
+ `#{session_name}${separator}#{window_index}${separator}#{window_width}${separator}#{window_height}`
640
+ ]))).trim();
641
+ }
642
+ catch (error) {
643
+ // No tmux server = no sessions = no sizes. A tmux server exits with its
644
+ // last session, so an idle provider (--count 0, nothing launched yet, or
645
+ // everything reaped) has no server to ask — that is the empty state, not
646
+ // an observe failure. Without this, every observe on an idle provider
647
+ // throws, the snapshot reports observation:failed forever, and the console
648
+ // paints a perfectly healthy machine as degraded. Genuine tmux breakage
649
+ // (binary missing, socket permission) still throws.
650
+ const message = error instanceof Error ? error.message : String(error);
651
+ if (message.includes("no server running") || message.includes("error connecting to")) {
652
+ return new Map();
653
+ }
654
+ throw error;
655
+ }
656
+ const sizes = new Map();
657
+ if (!output) {
658
+ return sizes;
659
+ }
660
+ for (const line of output.split("\n")) {
661
+ const [session, windowIndex, width, height] = line.split(separator);
662
+ // Match getWindowSize, which reads window 0; ignore extra windows of a session.
663
+ if (!session || windowIndex !== "0") {
664
+ continue;
665
+ }
666
+ const parsedWidth = Number(width);
667
+ const parsedHeight = Number(height);
668
+ if (Number.isInteger(parsedWidth) && Number.isInteger(parsedHeight) && parsedWidth >= 1 && parsedHeight >= 1) {
669
+ sizes.set(session, { height: parsedHeight, width: parsedWidth });
670
+ }
671
+ }
672
+ return sizes;
673
+ }
674
+ // Out-of-band pane styling via window-active-style. tmux applies this when it
675
+ // draws, so every native client of the pane (a direct attach, the iTerm2 /
676
+ // WezTerm windows) renders it without writing into the cell grid — scrollback
677
+ // and the mirror's capture-pane output are untouched. Best-effort: a vanished
678
+ // session must not throw into the caller's effect timer.
679
+ export async function setPaneStyle(target, style, socket) {
680
+ assertTmux();
681
+ await runAsync("tmux", tmuxArgs(socket, ["set-option", "-p", "-t", target, "window-active-style", style]));
682
+ }
683
+ export async function clearPaneStyle(target, socket) {
684
+ assertTmux();
685
+ await runAsync("tmux", tmuxArgs(socket, ["set-option", "-p", "-u", "-t", target, "window-active-style"]));
686
+ }
687
+ export function capturePane({ lines, preserveEscapes = false, socket, target }) {
688
+ assertTmux();
689
+ if (!target) {
690
+ throw new Error("--target is required.");
691
+ }
692
+ return run("tmux", tmuxArgs(socket, capturePaneArgs({ lines, preserveEscapes, target })));
693
+ }
694
+ function capturePaneArgs({ lines, preserveEscapes, target }) {
695
+ const args = ["capture-pane", "-p", "-t", target];
696
+ if (preserveEscapes) {
697
+ args.splice(1, 0, "-e");
698
+ }
699
+ if (lines !== undefined) {
700
+ if (!Number.isInteger(lines) || lines < 1) {
701
+ throw new Error("--lines must be a positive integer.");
702
+ }
703
+ args.push("-S", `-${lines}`);
704
+ }
705
+ return args;
706
+ }
707
+ // Async twin used by the observe loop. The N per-pane captures can then run
708
+ // concurrently (Promise.all) instead of blocking the event loop in sequence.
709
+ export async function capturePaneAsync({ lines, preserveEscapes = false, socket, target }) {
710
+ assertTmux();
711
+ if (!target) {
712
+ throw new Error("--target is required.");
713
+ }
714
+ return runAsync("tmux", tmuxArgs(socket, capturePaneArgs({ lines, preserveEscapes, target })));
715
+ }
716
+ export async function sendInterruptAsync({ socket, target }) {
717
+ assertTmux();
718
+ if (!target) {
719
+ throw new Error("A pane target is required.");
720
+ }
721
+ await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "Escape"]));
722
+ }
723
+ export async function sendInputAsync({ data, deadlineMs, socket, submitMode = "retry", target }) {
724
+ assertTmux();
725
+ if (!target) {
726
+ throw new Error("A pane target is required.");
727
+ }
728
+ if (typeof data !== "string") {
729
+ throw new Error("Input data must be a string.");
730
+ }
731
+ const submittedText = submittedTextFromInput(data);
732
+ if (submittedText !== undefined) {
733
+ // A completed prompt is one semantic payload, regardless of whether it
734
+ // happens to contain newlines. Sending a long single line as thousands of
735
+ // synthetic key events can leave an interactive composer holding the
736
+ // draft even though tmux acknowledged every byte. A bracketed paste gives
737
+ // the editor one bounded payload and an explicit paste-complete boundary
738
+ // before the separate submit key.
739
+ if (submittedText.length > 0) {
740
+ await sendSubmittedPasteAsync({ deadlineMs, socket, submitMode, target, text: submittedText });
741
+ return "submitted-paste";
742
+ }
743
+ await sendSubmitKeysAsync({ deadlineMs, socket, submitMode, target });
744
+ return "submitted-line";
745
+ }
746
+ for (const token of tokenizeTerminalInput(data)) {
747
+ if ("literal" in token) {
748
+ await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "-l", "--", token.literal]), terminalInputRunOptions(deadlineMs));
749
+ }
750
+ else {
751
+ await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, token.key]), terminalInputRunOptions(deadlineMs));
752
+ }
753
+ }
754
+ return "keystream";
755
+ }
756
+ export function attachSession({ name, socket, window }) {
757
+ assertTmux();
758
+ if (!name) {
759
+ throw new Error("--name is required.");
760
+ }
761
+ const target = window === undefined ? name : `${name}:${window}`;
762
+ spawnInherited("tmux", tmuxArgs(socket, ["attach-session", "-t", target]));
763
+ }
764
+ const SINGLE_SUBMIT_DELAY_MS = 500;
765
+ const SUBMITTED_LINE_DELAYS_MS = [500, 1500, 3500];
766
+ async function sleepAsync(ms) {
767
+ await new Promise((resolve) => setTimeout(resolve, ms));
768
+ }
769
+ // Submitted text is a payload the caller finished with Enter (a launch
770
+ // command or a chat message), as opposed to a raw keystroke stream. It must
771
+ // contain only pasteable characters: anything else (ESC sequences, control
772
+ // characters, DEL, a bare CR mid-body) is key forwarding and has to go
773
+ // through the tokenizer.
774
+ export function submittedTextFromInput(data) {
775
+ if (!data.endsWith("\n") && !data.endsWith("\r")) {
776
+ return undefined;
777
+ }
778
+ const text = data.replace(/(?:\r\n|\n|\r)$/, "");
779
+ for (const char of text) {
780
+ if (char === "\n" || char === "\t") {
781
+ continue;
782
+ }
783
+ if (char === "\u007f" || char < " ") {
784
+ return undefined;
785
+ }
786
+ }
787
+ return text;
788
+ }
789
+ let pasteBufferCounter = 0;
790
+ async function sendSubmittedPasteAsync({ deadlineMs, socket, submitMode, target, text }) {
791
+ pasteBufferCounter += 1;
792
+ const buffer = `terminal-input-${process.pid}-${pasteBufferCounter}`;
793
+ await runWithInputAsync("tmux", tmuxArgs(socket, ["load-buffer", "-b", buffer, "-"]), text, terminalInputRunOptions(deadlineMs));
794
+ // -p wraps the paste in bracketed-paste markers when the application has
795
+ // requested them, so line editors insert the content literally instead of
796
+ // interpreting newlines and tabs as keystrokes.
797
+ if (submitMode === "single") {
798
+ await runAsync("tmux", tmuxArgs(socket, ["paste-buffer", "-d", "-p", "-b", buffer, "-t", target]), terminalInputRunOptions(deadlineMs));
799
+ // Interactive TUIs process bracketed-paste completion asynchronously. A
800
+ // submit key in the same tmux command can arrive before the editor has
801
+ // committed the paste, while tmux still reports successful delivery.
802
+ await sendSubmitKeysAsync({ deadlineMs, socket, submitMode, target });
803
+ return;
804
+ }
805
+ await runAsync("tmux", tmuxArgs(socket, ["paste-buffer", "-d", "-p", "-b", buffer, "-t", target]), terminalInputRunOptions(deadlineMs));
806
+ await sendSubmitKeysAsync({ deadlineMs, socket, submitMode, target });
807
+ }
808
+ async function sendSubmitKeysAsync({ deadlineMs, socket, submitMode, target }) {
809
+ if (submitMode === "single") {
810
+ await sleepAsync(SINGLE_SUBMIT_DELAY_MS);
811
+ await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "Enter"]), terminalInputRunOptions(deadlineMs));
812
+ return;
813
+ }
814
+ for (const delayMs of SUBMITTED_LINE_DELAYS_MS) {
815
+ await sleepAsync(delayMs);
816
+ await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "Enter"]), terminalInputRunOptions(deadlineMs));
817
+ }
818
+ }
819
+ function terminalInputRunOptions(deadlineMs) {
820
+ if (deadlineMs === undefined)
821
+ return {};
822
+ const timeoutMs = deadlineMs - Date.now();
823
+ if (timeoutMs <= 0)
824
+ throw new Error("Terminal input expired before delivery.");
825
+ return { timeoutMs };
826
+ }
827
+ function tokenizeTerminalInput(data) {
828
+ const tokens = [];
829
+ for (let i = 0; i < data.length; i += 1) {
830
+ const char = data[i];
831
+ const next3 = data.slice(i, i + 3);
832
+ if (next3 === "\u001b[A") {
833
+ tokens.push({ key: "Up" });
834
+ i += 2;
835
+ continue;
836
+ }
837
+ if (next3 === "\u001b[B") {
838
+ tokens.push({ key: "Down" });
839
+ i += 2;
840
+ continue;
841
+ }
842
+ if (next3 === "\u001b[C") {
843
+ tokens.push({ key: "Right" });
844
+ i += 2;
845
+ continue;
846
+ }
847
+ if (next3 === "\u001b[D") {
848
+ tokens.push({ key: "Left" });
849
+ i += 2;
850
+ continue;
851
+ }
852
+ if (char === "\r" || char === "\n") {
853
+ tokens.push({ key: "Enter" });
854
+ continue;
855
+ }
856
+ if (char === "\u007f") {
857
+ tokens.push({ key: "BSpace" });
858
+ continue;
859
+ }
860
+ if (char === "\t") {
861
+ tokens.push({ key: "Tab" });
862
+ continue;
863
+ }
864
+ if (char === "\u0003") {
865
+ tokens.push({ key: "C-c" });
866
+ continue;
867
+ }
868
+ if (char === "\u0015") {
869
+ tokens.push({ key: "C-u" });
870
+ continue;
871
+ }
872
+ if (char === undefined) {
873
+ throw new Error("Unexpected end of input.");
874
+ }
875
+ const controlKey = controlKeyName(char);
876
+ if (controlKey) {
877
+ tokens.push({ key: controlKey });
878
+ continue;
879
+ }
880
+ if (char < " ") {
881
+ throw new Error(`Unsupported control input: ${JSON.stringify(char)}.`);
882
+ }
883
+ const previous = tokens.at(-1);
884
+ if (previous && "literal" in previous) {
885
+ previous.literal += char;
886
+ }
887
+ else {
888
+ tokens.push({ literal: char });
889
+ }
890
+ }
891
+ return tokens;
892
+ }
893
+ function controlKeyName(char) {
894
+ const code = char.charCodeAt(0);
895
+ if (code < 1 || code > 26) {
896
+ return undefined;
897
+ }
898
+ return `C-${String.fromCharCode(96 + code)}`;
899
+ }