@termfleet/terminal 0.1.0

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