@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.
- package/CAPABILITIES.md +57 -0
- package/LICENSE-SUBSTRATE +201 -0
- package/NOTICE-SUBSTRATE +14 -0
- package/README.md +190 -0
- package/client.d.ts +17 -0
- package/client.mjs +32 -0
- package/index.d.ts +195 -0
- package/index.mjs +981 -0
- package/package.json +156 -0
- package/react/stream.d.ts +3 -0
- package/react/stream.mjs +446 -0
- package/react/ui.d.ts +136 -0
- package/react/ui.mjs +892 -0
- package/stream.d.ts +3 -0
- package/stream.mjs +446 -0
- package/styles.css +160 -0
- package/substrate/attach.d.ts +29 -0
- package/substrate/attach.js +147 -0
- package/substrate/client.d.ts +62 -0
- package/substrate/client.js +138 -0
- package/substrate/docker-exec.d.ts +41 -0
- package/substrate/docker-exec.js +101 -0
- package/substrate/internal/exec.d.ts +15 -0
- package/substrate/internal/exec.js +192 -0
- package/substrate/internal/process-tree.d.ts +1 -0
- package/substrate/internal/process-tree.js +41 -0
- package/substrate/local-terminal.d.ts +13 -0
- package/substrate/local-terminal.js +41 -0
- package/substrate/native-host.d.ts +57 -0
- package/substrate/native-host.js +529 -0
- package/substrate/session-lifecycle.d.ts +162 -0
- package/substrate/session-lifecycle.js +1344 -0
- package/substrate/session-roll.d.ts +14 -0
- package/substrate/session-roll.js +407 -0
- package/substrate/tmux-stream.d.ts +32 -0
- package/substrate/tmux-stream.js +212 -0
- package/substrate/tmux.d.ts +144 -0
- package/substrate/tmux.js +899 -0
- package/ui.d.ts +136 -0
- package/ui.mjs +892 -0
|
@@ -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
|
+
}
|