@rynx-ai/runtime 0.1.11-beta.32 → 0.1.11-beta.34

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.
@@ -4,28 +4,121 @@
4
4
  *
5
5
  * The tmux SERVER (private socket, one per terminal) holds the real pane PTY and
6
6
  * survives client detach / browser reload / a brief runner reconnect. A client
7
- * ATTACH is a short-lived node-pty process bridging the pane's bytes to a caller
8
- * (the WS bridge). Multiple attaches can coexist: one owner (read-write) plus
9
- * any number of read-only viewers (`tmux attach -r`) — the co-drive model.
7
+ * ATTACH is a short-lived `tmux -C` control-mode process. The browser owns its
8
+ * character grid and scrollback: connect-time history comes from `capture-pane`
9
+ * and subsequent pane bytes come from `%output` notifications. Browser input is
10
+ * injected byte-exactly with `send-keys -H`.
10
11
  *
11
- * Control commands (new-session / send-keys / has-session / kill) run through
12
- * plain `execFile`; only the interactive attach needs a PTY. node-pty is loaded
13
- * lazily so importing this module never hard-fails when the native binding is
14
- * absent — {@link isTmuxAvailable} + a caller-side capability gate keep the
15
- * terminal overlay optional (the structured layer works without it).
12
+ * It deliberately does not attach tmux through a second PTY: tmux
13
+ * copy-mode/status chrome would otherwise own scrolling and selection instead
14
+ * of xterm.js.
16
15
  */
17
- import { execFile, execFileSync } from "node:child_process";
16
+ import { execFile, execFileSync, spawn, } from "node:child_process";
18
17
  import { createHash } from "node:crypto";
19
- import { existsSync, mkdirSync, unlinkSync } from "node:fs";
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, } from "node:fs";
20
19
  import { tmpdir } from "node:os";
21
20
  import { join } from "node:path";
21
+ import { TmuxControlParser } from "./control-parser.js";
22
+ import { SegmentedTerminalSpool, } from "./spool.js";
22
23
  const TMUX_TARGET = "main";
23
24
  const DEFAULT_COLS = 80;
24
25
  const DEFAULT_ROWS = 24;
25
- /** tmux pane scrollback depth. Matches the browser xterm `scrollback` so a
26
- * wheel-scroll (tmux copy-mode, enabled by `mouse on`) reaches as far back as
27
- * the terminal shows. Mirrors reference implementation's `TerminalEnvSpec.scrollback`. */
28
- const DEFAULT_SCROLLBACK = 20000;
26
+ const DEFAULT_SCROLLBACK = 10_000;
27
+ const SEND_KEYS_HEX_BYTES_PER_CALL = 1024;
28
+ const PANE_METADATA_FORMAT = [
29
+ "#{cursor_x}",
30
+ "#{cursor_y}",
31
+ "#{cursor_flag}",
32
+ "#{alternate_on}",
33
+ "#{mouse_standard_flag}",
34
+ "#{mouse_button_flag}",
35
+ "#{mouse_all_flag}",
36
+ "#{mouse_sgr_flag}",
37
+ "#{mouse_utf8_flag}",
38
+ "#{keypad_cursor_flag}",
39
+ "#{pane_width}",
40
+ "#{pane_height}",
41
+ ].join(",");
42
+ /** Decode tmux's three-digit octal escapes in a `%output` payload. */
43
+ export function unescapeControlOutput(value) {
44
+ const source = Buffer.from(value);
45
+ const output = [];
46
+ for (let i = 0; i < source.length; i += 1) {
47
+ if (source[i] === 0x5c &&
48
+ i + 3 < source.length &&
49
+ source[i + 1] >= 0x30 && source[i + 1] <= 0x37 &&
50
+ source[i + 2] >= 0x30 && source[i + 2] <= 0x37 &&
51
+ source[i + 3] >= 0x30 && source[i + 3] <= 0x37) {
52
+ output.push((source[i + 1] - 0x30) * 64 +
53
+ (source[i + 2] - 0x30) * 8 +
54
+ (source[i + 3] - 0x30));
55
+ i += 3;
56
+ }
57
+ else {
58
+ output.push(source[i]);
59
+ }
60
+ }
61
+ return Uint8Array.from(output);
62
+ }
63
+ /** Byte-exact control commands for one browser input frame. */
64
+ export function hexSendKeysCommands(target, data) {
65
+ const source = Buffer.from(data);
66
+ const commands = [];
67
+ for (let start = 0; start < source.length; start += SEND_KEYS_HEX_BYTES_PER_CALL) {
68
+ const chunk = source.subarray(start, start + SEND_KEYS_HEX_BYTES_PER_CALL);
69
+ const hex = Array.from(chunk, (byte) => byte.toString(16).padStart(2, "0")).join(" ");
70
+ commands.push(Buffer.from(`send-keys -t ${target} -H ${hex}\n`, "ascii"));
71
+ }
72
+ return commands;
73
+ }
74
+ /** Global tmux argv for a control-mode attach. Exported to pin role semantics. */
75
+ export function controlAttachArgs(socketPath, role) {
76
+ const args = ["-u", "-S", socketPath, "-f", "/dev/null", "-C", "attach"];
77
+ if (role === "read-only")
78
+ args.push("-r");
79
+ args.push("-t", TMUX_TARGET);
80
+ return args;
81
+ }
82
+ /** Options applied before pane creation so per-pane values take effect. */
83
+ export function tmuxManagedOptionCommands(spec) {
84
+ const commands = [
85
+ ["set-option", "-g", "history-limit", String(spec.scrollback)],
86
+ ["set-option", "-sq", "extended-keys", "on"],
87
+ ["set-option", "-sq", "extended-keys-format", "csi-u"],
88
+ ["set-option", "-g", "mouse", "off"],
89
+ ["set-option", "-g", "focus-events", "on"],
90
+ ["set-option", "-g", "escape-time", "0"],
91
+ ["set-option", "-g", "prefix", "None"],
92
+ ["set-option", "-g", "prefix2", "None"],
93
+ ["unbind-key", "-a", "-T", "prefix"],
94
+ ["unbind-key", "-q", "-T", "root", "MouseDown3Pane"],
95
+ ["unbind-key", "-q", "-T", "root", "M-MouseDown3Pane"],
96
+ ["unbind-key", "-q", "-T", "root", "MouseDown3Status"],
97
+ ["unbind-key", "-q", "-T", "root", "M-MouseDown3Status"],
98
+ ["unbind-key", "-q", "-T", "root", "MouseDown3StatusLeft"],
99
+ ["unbind-key", "-q", "-T", "root", "M-MouseDown3StatusLeft"],
100
+ ["set-option", "-g", "status", "off"],
101
+ ];
102
+ if (spec.keepAliveAfterExit) {
103
+ commands.push(["set-option", "-gq", "remain-on-exit", "on"], ["set-option", "-sq", "exit-empty", "off"]);
104
+ }
105
+ if (spec.allowPassthrough) {
106
+ commands.push(["set-option", "-g", "allow-passthrough", "on"]);
107
+ }
108
+ return commands;
109
+ }
110
+ /** Extract the pane size from tmux's `%layout-change` notification. */
111
+ export function controlLayoutDimensions(line) {
112
+ const match = Buffer.from(line).toString("ascii").match(/^%layout-change\s+\S+\s+\S+,(\d+)x(\d+),/);
113
+ if (!match)
114
+ return undefined;
115
+ const cols = Number(match[1]);
116
+ const rows = Number(match[2]);
117
+ if (!Number.isSafeInteger(cols) || !Number.isSafeInteger(rows) || cols <= 0 || rows <= 0) {
118
+ return undefined;
119
+ }
120
+ return { cols, rows };
121
+ }
29
122
  /** Resolve the exact tmux executable selected by the current Rynx distribution. */
30
123
  export function resolveTmuxBin(explicit, env = process.env) {
31
124
  if (explicit !== undefined)
@@ -38,9 +131,12 @@ export function resolveTmuxBin(explicit, env = process.env) {
38
131
  /** Deterministic private socket owned by one terminal name. Parent-side
39
132
  * shutdown uses the same mapping when the runner child is too wedged to clean
40
133
  * up its own tmux server. */
41
- export function tmuxSocketPath(name) {
134
+ export function tmuxSocketPath(name, ownerPid = process.pid) {
135
+ return join(tmuxInstanceDir(name, ownerPid), "tmux.sock");
136
+ }
137
+ export function tmuxInstanceDir(name, ownerPid = process.pid) {
42
138
  const key = createHash("sha256").update(name).digest("hex").slice(0, 16);
43
- return join(tmpdir(), "rynx-term", `${key}.sock`);
139
+ return join(tmpdir(), "rynx-term", `${ownerPid}-${key}`);
44
140
  }
45
141
  /** Read tmux's own output-activity clock for a private terminal window.
46
142
  *
@@ -50,10 +146,13 @@ export function tmuxSocketPath(name) {
50
146
  * may have stalled when the idle reaper needs an independent liveness signal.
51
147
  * Missing servers, command failures, timeouts, and unparseable output are all
52
148
  * treated as unknown (`null`). */
53
- export function tmuxWindowActivityAt(name, tmuxBin = resolveTmuxBin()) {
54
- const socketPath = tmuxSocketPath(name);
149
+ export function tmuxWindowActivityAt(name, tmuxBin = resolveTmuxBin(), ownerPid = process.pid) {
150
+ const socketPath = tmuxSocketPath(name, ownerPid);
55
151
  return new Promise((resolve) => {
56
- execFile(tmuxBin, ["-S", socketPath, "display-message", "-p", "-t", TMUX_TARGET, "#{window_activity}"], { timeout: 2_000 }, (error, stdout) => {
152
+ execFile(tmuxBin, [
153
+ "-S", socketPath, "-f", "/dev/null",
154
+ "display-message", "-p", "-t", TMUX_TARGET, "#{window_activity}",
155
+ ], { timeout: 2_000 }, (error, stdout) => {
57
156
  if (error) {
58
157
  resolve(null);
59
158
  return;
@@ -71,40 +170,91 @@ export function tmuxWindowActivityAt(name, tmuxBin = resolveTmuxBin()) {
71
170
  /** Whether tmux itself reports an attached client for this private terminal.
72
171
  * This remains authoritative if a parent-side attachment bookkeeping edge was
73
172
  * missed during a transport failure. */
74
- export function tmuxHasAttachedClient(name, tmuxBin = resolveTmuxBin()) {
75
- const socketPath = tmuxSocketPath(name);
173
+ export function tmuxHasAttachedClient(name, tmuxBin = resolveTmuxBin(), ownerPid = process.pid) {
174
+ const socketPath = tmuxSocketPath(name, ownerPid);
76
175
  return new Promise((resolve) => {
77
- execFile(tmuxBin, ["-S", socketPath, "list-clients", "-t", TMUX_TARGET, "-F", "#{client_name}"], { timeout: 2_000 }, (error, stdout) => {
176
+ execFile(tmuxBin, [
177
+ "-S", socketPath, "-f", "/dev/null",
178
+ "list-clients", "-t", TMUX_TARGET, "-F", "#{client_name}",
179
+ ], { timeout: 2_000 }, (error, stdout) => {
78
180
  resolve(!error && stdout.toString().split("\n").some((line) => line.trim().length > 0));
79
181
  });
80
182
  });
81
183
  }
82
- /** Best-effort close of one private tmux server. Mirrors reference implementation's bounded
83
- * `TerminalInstance.close`: attempt `kill-server`, then retire the private
84
- * socket regardless of command outcome. The registry has already forgotten
85
- * the resource, so cleanup failure is diagnostic rather than a second
86
- * lifecycle state. */
87
- export function terminateTmuxServer(name, tmuxBin = resolveTmuxBin()) {
88
- const socketPath = tmuxSocketPath(name);
89
- if (!existsSync(socketPath))
90
- return true;
91
- const base = ["-u", "-S", socketPath];
184
+ /** Best-effort close of one private tmux server. Attempt `kill-server`, then
185
+ * retire the private socket regardless of command outcome. The registry has
186
+ * already forgotten the resource, so cleanup failure is diagnostic rather than
187
+ * a second lifecycle state. */
188
+ export function terminateTmuxServer(name, tmuxBin = resolveTmuxBin(), ownerPid = process.pid) {
189
+ const instanceDir = tmuxInstanceDir(name, ownerPid);
190
+ const socketPath = join(instanceDir, "tmux.sock");
191
+ const base = ["-u", "-S", socketPath, "-f", "/dev/null"];
192
+ if (existsSync(socketPath)) {
193
+ try {
194
+ execFileSync(tmuxBin, [...base, "kill-server"], {
195
+ stdio: "ignore",
196
+ timeout: 5_000,
197
+ });
198
+ }
199
+ catch {
200
+ // Close is best-effort; still retire the resource if tmux does not respond.
201
+ }
202
+ }
203
+ rmSync(instanceDir, { recursive: true, force: true });
204
+ return true;
205
+ }
206
+ function processAlive(pid) {
92
207
  try {
93
- execFileSync(tmuxBin, [...base, "kill-server"], {
94
- stdio: "ignore",
95
- timeout: 5_000,
96
- });
208
+ process.kill(pid, 0);
209
+ return true;
97
210
  }
98
- catch {
99
- // reference implementation treats close as best-effort and still retires the resource.
211
+ catch (error) {
212
+ return error.code === "EPERM";
100
213
  }
214
+ }
215
+ /** Reap private terminal servers whose owning runner process is gone. Dirs
216
+ * without an owner marker are deliberately ignored. */
217
+ export function reapOrphanedTerminals(opts = {}) {
218
+ const root = opts.root ?? join(tmpdir(), "rynx-term");
219
+ const tmuxBin = resolveTmuxBin(opts.tmuxBin);
220
+ const isProcessAlive = opts.isProcessAlive ?? processAlive;
221
+ let entries;
101
222
  try {
102
- unlinkSync(socketPath);
223
+ entries = readdirSync(root, { encoding: "utf8" });
103
224
  }
104
225
  catch {
105
- // Gone already, or best-effort filesystem cleanup failed.
226
+ return 0;
106
227
  }
107
- return true;
228
+ let reaped = 0;
229
+ for (const entry of entries) {
230
+ const instanceDir = join(root, entry);
231
+ let ownerPid;
232
+ try {
233
+ ownerPid = Number.parseInt(readFileSync(join(instanceDir, "owner.pid"), "utf8").trim(), 10);
234
+ if (!Number.isSafeInteger(ownerPid) || ownerPid <= 0)
235
+ continue;
236
+ }
237
+ catch {
238
+ continue;
239
+ }
240
+ if (isProcessAlive(ownerPid))
241
+ continue;
242
+ const socketPath = join(instanceDir, "tmux.sock");
243
+ if (existsSync(socketPath)) {
244
+ try {
245
+ execFileSync(tmuxBin, ["-u", "-S", socketPath, "-f", "/dev/null", "kill-server"], {
246
+ stdio: "ignore",
247
+ timeout: 10_000,
248
+ });
249
+ }
250
+ catch {
251
+ // An already-dead or wedged server is still an orphaned instance.
252
+ }
253
+ }
254
+ rmSync(instanceDir, { recursive: true, force: true });
255
+ reaped += 1;
256
+ }
257
+ return reaped;
108
258
  }
109
259
  /** Is a usable `tmux` on PATH? Cheap probe for the capability gate. */
110
260
  export function isTmuxAvailable(tmuxBin = resolveTmuxBin()) {
@@ -116,18 +266,138 @@ export function isTmuxAvailable(tmuxBin = resolveTmuxBin()) {
116
266
  return false;
117
267
  }
118
268
  }
119
- async function loadPtySpawn() {
120
- const mod = (await import("node-pty"));
121
- return mod.spawn;
122
- }
123
269
  /** Ensure a UTF-8 locale so tmux renders multibyte (CJK) output correctly. A
124
270
  * pm2/systemd-spawned daemon often has no LANG/LC_*, so tmux treats CJK bytes as
125
- * single-byte and mangles Chinese. reference implementation inherits a UTF-8 locale from its launch
126
- * env; a pm2 daemon may not — so inject one when none is present. */
271
+ * single-byte and mangles Chinese. Inject a UTF-8 locale when none is present. */
127
272
  function withUtf8Locale(env) {
128
273
  const hasUtf8 = [env.LC_ALL, env.LC_CTYPE, env.LANG].some((v) => v != null && /utf-?8/i.test(v));
129
274
  return hasUtf8 ? env : { ...env, LANG: "en_US.UTF-8", LC_CTYPE: "en_US.UTF-8" };
130
275
  }
276
+ /** Detached launchers commonly supply no TERM, or TERM=dumb. Native TUIs
277
+ * reject that environment before they can render into the tmux pane. Preserve
278
+ * an explicit usable value and provide the same terminal class used by the
279
+ * browser surface otherwise. */
280
+ function withTerminalEnvironment(env) {
281
+ const localized = withUtf8Locale(env);
282
+ const term = localized.TERM?.trim();
283
+ return !term || term === "dumb" ? { ...localized, TERM: "xterm-256color" } : localized;
284
+ }
285
+ function parsePaneMetadata(value) {
286
+ const fields = value.toString("utf8").trim().split(",");
287
+ if (fields.length < 4)
288
+ return undefined;
289
+ while (fields.length < 12)
290
+ fields.push("0");
291
+ const cursorX = Number(fields[0]);
292
+ const cursorY = Number(fields[1]);
293
+ const paneWidth = Number(fields[10]);
294
+ const paneHeight = Number(fields[11]);
295
+ if (!Number.isSafeInteger(cursorX) ||
296
+ !Number.isSafeInteger(cursorY) ||
297
+ !Number.isSafeInteger(paneWidth) ||
298
+ !Number.isSafeInteger(paneHeight) ||
299
+ paneWidth <= 0 ||
300
+ paneHeight <= 0)
301
+ return undefined;
302
+ return {
303
+ cursorX,
304
+ cursorY,
305
+ cursorVisible: fields[2] === "1",
306
+ alternateOn: fields[3] === "1",
307
+ mouseStandard: fields[4] === "1",
308
+ mouseButton: fields[5] === "1",
309
+ mouseAll: fields[6] === "1",
310
+ mouseSgr: fields[7] === "1",
311
+ mouseUtf8: fields[8] === "1",
312
+ appCursorKeys: fields[9] === "1",
313
+ paneWidth,
314
+ paneHeight,
315
+ };
316
+ }
317
+ function captureSeedEnvelope(metadata) {
318
+ const prefix = [];
319
+ if (metadata?.alternateOn)
320
+ prefix.push(Buffer.from("\x1b[?1049h", "binary"));
321
+ prefix.push(Buffer.from("\x1b[H\x1b[2J", "binary"));
322
+ const suffix = [];
323
+ if (metadata) {
324
+ suffix.push(Buffer.from(`\x1b[${metadata.cursorY + 1};${metadata.cursorX + 1}H`, "binary"));
325
+ suffix.push(Buffer.from(metadata.cursorVisible ? "\x1b[?25h" : "\x1b[?25l", "binary"));
326
+ if (metadata.mouseStandard)
327
+ suffix.push(Buffer.from("\x1b[?1000h", "binary"));
328
+ if (metadata.mouseButton)
329
+ suffix.push(Buffer.from("\x1b[?1002h", "binary"));
330
+ if (metadata.mouseAll)
331
+ suffix.push(Buffer.from("\x1b[?1003h", "binary"));
332
+ if (metadata.mouseUtf8)
333
+ suffix.push(Buffer.from("\x1b[?1005h", "binary"));
334
+ if (metadata.mouseSgr)
335
+ suffix.push(Buffer.from("\x1b[?1006h", "binary"));
336
+ if (metadata.appCursorKeys)
337
+ suffix.push(Buffer.from("\x1b[?1h", "binary"));
338
+ }
339
+ return { prefix: Buffer.concat(prefix), suffix: Buffer.concat(suffix) };
340
+ }
341
+ function waitForSpawn(proc) {
342
+ return new Promise((resolve, reject) => {
343
+ const onSpawn = () => {
344
+ proc.off("error", onError);
345
+ resolve();
346
+ };
347
+ const onError = (error) => {
348
+ proc.off("spawn", onSpawn);
349
+ reject(error);
350
+ };
351
+ proc.once("spawn", onSpawn);
352
+ proc.once("error", onError);
353
+ });
354
+ }
355
+ function writeWithDrain(proc, data) {
356
+ return new Promise((resolve, reject) => {
357
+ let callbackDone = false;
358
+ let drainDone = false;
359
+ let settled = false;
360
+ const cleanup = () => {
361
+ proc.stdin.off("drain", onDrain);
362
+ proc.stdin.off("error", onError);
363
+ proc.off("exit", onExit);
364
+ };
365
+ const finish = () => {
366
+ if (settled || !callbackDone || !drainDone)
367
+ return;
368
+ settled = true;
369
+ cleanup();
370
+ resolve();
371
+ };
372
+ const fail = (error) => {
373
+ if (settled)
374
+ return;
375
+ settled = true;
376
+ cleanup();
377
+ reject(error);
378
+ };
379
+ const onDrain = () => {
380
+ drainDone = true;
381
+ finish();
382
+ };
383
+ const onError = (error) => fail(error);
384
+ const onExit = () => fail(new Error("tmux control client exited before input drained"));
385
+ proc.stdin.once("error", onError);
386
+ proc.once("exit", onExit);
387
+ const accepted = proc.stdin.write(data, (error) => {
388
+ if (error) {
389
+ fail(error);
390
+ return;
391
+ }
392
+ callbackDone = true;
393
+ finish();
394
+ });
395
+ drainDone = accepted;
396
+ if (!accepted)
397
+ proc.stdin.once("drain", onDrain);
398
+ finish();
399
+ });
400
+ }
131
401
  export class TmuxTerminal {
132
402
  name;
133
403
  socketPath;
@@ -137,8 +407,11 @@ export class TmuxTerminal {
137
407
  env;
138
408
  cols;
139
409
  rows;
410
+ scrollback;
411
+ tmuxAllowPassthrough;
412
+ tmuxStartOnAttach;
413
+ keepAliveAfterExit;
140
414
  tmuxBin;
141
- injectedSpawn;
142
415
  started = false;
143
416
  lastPaneSnapshot = "";
144
417
  /** Shared by every attachment watcher and by the lifecycle watcher's
@@ -146,20 +419,21 @@ export class TmuxTerminal {
146
419
  * out once per attached client. */
147
420
  paneLivenessFlight;
148
421
  /** Shared by lifecycle callers so capture + pane-dead remains one ordered
149
- * reference implementation-style observation per Terminal. */
422
+ * observation per Terminal. */
150
423
  lifecycleLivenessFlight;
151
424
  constructor(opts) {
152
425
  this.name = opts.name;
153
426
  this.cwd = opts.cwd;
154
427
  this.command = opts.command ?? process.env.SHELL ?? "/bin/bash";
155
428
  this.args = opts.args ?? [];
156
- this.env = withUtf8Locale(opts.env ?? process.env);
429
+ this.env = withTerminalEnvironment(opts.env ?? process.env);
157
430
  this.cols = opts.cols ?? DEFAULT_COLS;
158
431
  this.rows = opts.rows ?? DEFAULT_ROWS;
432
+ this.scrollback = opts.scrollback ?? DEFAULT_SCROLLBACK;
433
+ this.tmuxAllowPassthrough = opts.tmuxAllowPassthrough ?? false;
434
+ this.tmuxStartOnAttach = opts.tmuxStartOnAttach ?? false;
435
+ this.keepAliveAfterExit = opts.keepAliveAfterExit ?? false;
159
436
  this.tmuxBin = resolveTmuxBin(opts.tmuxBin);
160
- this.injectedSpawn = opts.ptySpawn;
161
- const dir = join(tmpdir(), "rynx-term");
162
- mkdirSync(dir, { recursive: true });
163
437
  // A short hash of the (per-session-unique) name keeps the socket path well
164
438
  // under the unix-domain path limit (~104 chars on macOS) — a full session id
165
439
  // like `sess_<uuid>-main` under $TMPDIR would overflow it and fail with
@@ -171,7 +445,10 @@ export class TmuxTerminal {
171
445
  // `-u` forces tmux to emit UTF-8 even when the launch env has no UTF-8 locale (a
172
446
  // pm2/systemd-spawned daemon often has no LANG) — without it multibyte CJK output
173
447
  // is mangled. Belt-and-suspenders with the UTF-8 locale injected into `this.env`.
174
- return ["-u", "-S", this.socketPath];
448
+ // The private server is configured entirely by this class. Ignoring the
449
+ // user's tmux.conf keeps keyboard, mouse, status and plugin behavior
450
+ // deterministic across every command and control attach.
451
+ return ["-u", "-S", this.socketPath, "-f", "/dev/null"];
175
452
  }
176
453
  /** Create the private tmux server + detached session running the inner
177
454
  * command. Idempotent: a second call is a no-op once the session exists. */
@@ -180,93 +457,49 @@ export class TmuxTerminal {
180
457
  return;
181
458
  // Clear any stale server on this deterministic private socket before launch.
182
459
  terminateTmuxServer(this.name, this.tmuxBin);
183
- // `new-session -d` starts detached; `-x/-y` seed the pane size so output
184
- // wraps sanely before the first client attaches and resizes it.
185
- execFileSync(this.tmuxBin, [
186
- ...this.base(),
187
- "new-session",
188
- "-d",
189
- "-s",
190
- TMUX_TARGET,
191
- "-x",
192
- String(this.cols),
193
- "-y",
194
- String(this.rows),
195
- "-c",
196
- this.cwd,
197
- this.command,
198
- ...this.args,
199
- ], { env: this.env, stdio: "ignore" });
200
- this.configureSession();
201
- this.started = true;
202
- }
203
- /**
204
- * Apply reference implementation's tmux option suite to the private server (inner/terminal.py).
205
- * These are NOT cosmetic — several fix real co-drive behavior that the tmux
206
- * defaults break:
207
- * - `mouse on`: the web terminal's wheel scrolls the pane's scrollback (and
208
- * mouse events reach a TUI that requests them). Without it, no scrolling.
209
- * - `extended-keys on` + `csi-u`: tmux forwards Kitty Keyboard Protocol / CSI-u
210
- * keys (Ctrl+C, Shift+Enter, modified keys) that codex/claude TUIs request —
211
- * without it tmux downgrades them and the vendor TUI mis-reads modifiers.
212
- * - `escape-time 0`: kills tmux's default 500 ms wait after ESC, which otherwise
213
- * makes arrow keys / Alt-combos / pasted CSI feel laggy or mis-parse.
214
- * - `prefix None` + `prefix2 None` + unbind the prefix table: the user's
215
- * keystrokes (notably C-b) go to the pane, never tmux — a co-drive terminal
216
- * must not intercept a prefix.
217
- * - `focus-events on`, `allow-passthrough on`, `history-limit`: focus reporting,
218
- * passthrough sequences, scrollback depth.
219
- * - `remain-on-exit on` + `exit-empty off`: keep the dead pane + server after the
220
- * inner CLI exits so its last output stays capturable and `#{pane_dead}` reads
221
- * the exit (liveness probe), instead of the server vanishing.
222
- * - `MouseDown3*` unbinds: no right-click menu to spawn extra panes/windows.
223
- * - `status off`: hide tmux chrome. reference implementation keeps the status line only to show
224
- * a conversation link; rynx has none, so the whole line (and its
225
- * `[main] 0:node*` window list) is hidden.
226
- * `-q`/`-gq`/`-sq` keep an older tmux that lacks an option from failing launch.
227
- * Batched into one invocation with `;` command separators (one spawn).
228
- */
229
- configureSession() {
230
- const commands = [
231
- // input + scrollback
232
- ["set-option", "-g", "history-limit", String(DEFAULT_SCROLLBACK)],
233
- ["set-option", "-sq", "extended-keys", "on"],
234
- ["set-option", "-sq", "extended-keys-format", "csi-u"],
235
- ["set-option", "-g", "mouse", "on"],
236
- ["set-option", "-g", "focus-events", "on"],
237
- ["set-option", "-g", "escape-time", "0"],
238
- // persistence: outlive the inner CLI's exit (see `#{pane_dead}` liveness)
239
- ["set-option", "-gq", "remain-on-exit", "on"],
240
- ["set-option", "-sq", "exit-empty", "off"],
241
- // passthrough escape sequences
242
- ["set-option", "-g", "allow-passthrough", "on"],
243
- // lockdown: no prefix, no right-click pane/window creation
244
- ["set-option", "-g", "prefix", "None"],
245
- ["set-option", "-g", "prefix2", "None"],
246
- ["unbind-key", "-a", "-T", "prefix"],
247
- ["unbind-key", "-q", "-T", "root", "MouseDown3Pane"],
248
- ["unbind-key", "-q", "-T", "root", "M-MouseDown3Pane"],
249
- ["unbind-key", "-q", "-T", "root", "MouseDown3Status"],
250
- ["unbind-key", "-q", "-T", "root", "M-MouseDown3Status"],
251
- ["unbind-key", "-q", "-T", "root", "MouseDown3StatusLeft"],
252
- ["unbind-key", "-q", "-T", "root", "M-MouseDown3StatusLeft"],
253
- // hide tmux chrome (no conversation link to show, unlike reference implementation)
254
- ["set-option", "-g", "status", "off"],
255
- ];
256
- const argv = [...this.base()];
257
- commands.forEach((cmd, i) => {
258
- if (i > 0)
259
- argv.push(";");
260
- argv.push(...cmd);
460
+ const instanceDir = tmuxInstanceDir(this.name);
461
+ mkdirSync(instanceDir, { recursive: true, mode: 0o700 });
462
+ writeFileSync(join(instanceDir, "owner.pid"), String(process.pid), { mode: 0o600 });
463
+ const commands = tmuxManagedOptionCommands({
464
+ scrollback: this.scrollback,
465
+ allowPassthrough: this.tmuxAllowPassthrough,
466
+ keepAliveAfterExit: this.keepAliveAfterExit,
261
467
  });
468
+ const startChannel = "rynx-terminal-start";
469
+ if (this.tmuxStartOnAttach) {
470
+ commands.push(["set-hook", "-g", "client-attached", `wait-for -S ${startChannel}`]);
471
+ }
472
+ const paneCommand = this.tmuxStartOnAttach
473
+ ? [
474
+ "/bin/sh",
475
+ "-lc",
476
+ `tmux wait-for ${startChannel}; exec \"$@\"`,
477
+ "sh",
478
+ this.command,
479
+ ...this.args,
480
+ ]
481
+ : [this.command, ...this.args];
482
+ commands.push([
483
+ "new-session", "-d", "-s", TMUX_TARGET,
484
+ "-x", String(this.cols), "-y", String(this.rows),
485
+ "-c", this.cwd,
486
+ ...paneCommand,
487
+ ]);
488
+ if (this.keepAliveAfterExit) {
489
+ commands.push(["set-hook", "-w", "pane-died", "detach-client -a"]);
490
+ }
491
+ const argv = [...this.base(), "start-server"];
492
+ for (const command of commands)
493
+ argv.push(";", ...command);
262
494
  execFileSync(this.tmuxBin, argv, { env: this.env, stdio: "ignore" });
495
+ this.started = true;
263
496
  }
264
497
  /** Whether the terminal's INNER PROCESS is still running. Probes the pane's
265
498
  * `#{pane_dead}` flag rather than mere session existence: with
266
499
  * `remain-on-exit on` the session/server deliberately outlive the inner CLI's
267
500
  * exit (a dead pane shows tmux's "Pane is dead"), so `has-session` succeeding
268
501
  * no longer implies a live process. Alive only when the session exists AND its
269
- * pane process has not exited. Mirrors reference implementation's `_terminal.is_alive`. */
502
+ * pane process has not exited. */
270
503
  isAlive() {
271
504
  if (!this.started)
272
505
  return false;
@@ -285,8 +518,8 @@ export class TmuxTerminal {
285
518
  /** Async pane-liveness probe — MUST NOT block the event loop. The attach
286
519
  * pane-death watcher polls this on an interval; a synchronous `execFileSync`
287
520
  * there stalls the runner child's event loop (freezing the PTY stream → the
288
- * terminal appears "stuck"). Exactly like reference implementation's definitive pane probe,
289
- * every command error is `unknown`; only `#{pane_dead}=1` is `dead`. */
521
+ * terminal appears "stuck"). Every command error is `unknown`; only
522
+ * `#{pane_dead}=1` is definitive evidence of `dead`. */
290
523
  livenessAsync() {
291
524
  if (!this.started)
292
525
  return Promise.resolve("dead");
@@ -320,7 +553,7 @@ export class TmuxTerminal {
320
553
  });
321
554
  return flight;
322
555
  }
323
- /** reference implementation's always-on terminal lifecycle watcher first captures the pane:
556
+ /** The always-on terminal lifecycle watcher first captures the pane:
324
557
  * a control command that ran and reports the target missing is terminal exit;
325
558
  * a probe that cannot spawn is inconclusive. If capture succeeds, the normal
326
559
  * definitive `pane_dead` probe distinguishes live from exited. */
@@ -340,8 +573,8 @@ export class TmuxTerminal {
340
573
  }
341
574
  this.lastPaneSnapshot = stdout.toString();
342
575
  void this.livenessAsync().then((liveness) => {
343
- // A successful capture proves the target exists. reference implementation treats a
344
- // subsequent pane-dead probe failure as non-dead for this tick.
576
+ // A successful capture proves the target exists. A subsequent
577
+ // pane-dead probe failure is non-dead for this tick.
345
578
  resolve(liveness === "dead" ? "dead" : "alive");
346
579
  });
347
580
  });
@@ -378,7 +611,7 @@ export class TmuxTerminal {
378
611
  /** Type literal text into the pane (agent injection / co-drive from a
379
612
  * non-PTY caller). `-l` sends the text literally rather than as key names. */
380
613
  sendKeys(text) {
381
- execFile(this.tmuxBin, [...this.base(), "send-keys", "-t", TMUX_TARGET, "-l", "--", text], { env: this.env }, () => undefined);
614
+ execFileSync(this.tmuxBin, [...this.base(), "send-keys", "-c", "", "-t", TMUX_TARGET, "-l", "--", text], { env: this.env, stdio: "ignore" });
382
615
  }
383
616
  /**
384
617
  * The visible pane text (synchronous). Used by the claude-native injection
@@ -407,19 +640,19 @@ export class TmuxTerminal {
407
640
  }
408
641
  /** Interrupt the pane's running TUI turn with an Escape key — codex/claude both
409
642
  * cancel an in-flight response on a single Esc ("esc to interrupt"). A key NAME
410
- * (no `-l`) so tmux interprets it. Mirrors reference implementation's `inject_interrupt`. */
643
+ * (no `-l`) so tmux interprets it. */
411
644
  interrupt() {
412
645
  this.sendKeyNames("Escape");
413
646
  }
414
647
  /** Clear the current input line before an injection so leftover keystrokes
415
648
  * can't prepend to the pasted draft. `C-a` (Home) + `C-k` (kill-to-end) is
416
- * the safe pair — `C-u` only clears backwards from the cursor (reference implementation). */
649
+ * the safe pair because `C-u` only clears backwards from the cursor. */
417
650
  clearInputLine() {
418
651
  this.sendKeyNames("C-a", "C-k");
419
652
  }
420
653
  /** Send one or more tmux key NAMES (e.g. `Enter`, `C-a`) to the pane. */
421
654
  sendKeyNames(...keys) {
422
- execFileSync(this.tmuxBin, [...this.base(), "send-keys", "-t", TMUX_TARGET, ...keys], {
655
+ execFileSync(this.tmuxBin, [...this.base(), "send-keys", "-c", "", "-t", TMUX_TARGET, ...keys], {
423
656
  env: this.env,
424
657
  stdio: "ignore",
425
658
  });
@@ -440,73 +673,307 @@ export class TmuxTerminal {
440
673
  });
441
674
  execFileSync(this.tmuxBin, [...this.base(), "paste-buffer", "-p", "-d", "-b", bufferName, "-t", TMUX_TARGET], { env: this.env, stdio: "ignore" });
442
675
  }
443
- /**
444
- * Attach a client. `role: "read-only"` passes tmux `-r` so the viewer cannot
445
- * type and `ignore-size` so even its initial PTY dimensions cannot resize the
446
- * owner's pane (defense-in-depth on top of the WS bridge dropping input and
447
- * resize frames).
448
- */
449
- async attach(role, dims) {
676
+ execTmuxBuffer(args) {
677
+ return new Promise((resolve) => {
678
+ const proc = spawn(this.tmuxBin, [...this.base(), ...args], {
679
+ env: this.env,
680
+ stdio: ["ignore", "pipe", "pipe"],
681
+ });
682
+ const chunks = [];
683
+ let byteLength = 0;
684
+ let settled = false;
685
+ const finish = (value) => {
686
+ if (settled)
687
+ return;
688
+ settled = true;
689
+ resolve(value);
690
+ };
691
+ proc.stdout.on("data", (chunk) => {
692
+ chunks.push(Buffer.from(chunk));
693
+ byteLength += chunk.byteLength;
694
+ });
695
+ proc.stderr.resume();
696
+ proc.once("error", () => finish(undefined));
697
+ proc.once("close", (code) => {
698
+ finish(code === 0 ? Buffer.concat(chunks, byteLength) : undefined);
699
+ });
700
+ });
701
+ }
702
+ /** Prepare connect-time history without creating a live tmux client. */
703
+ async prepare(role, dims) {
450
704
  this.start();
451
- const spawn = this.injectedSpawn ?? (await loadPtySpawn());
452
- const args = [...this.base(), "attach-session"];
453
- if (role === "read-only")
454
- args.push("-r", "-f", "ignore-size");
455
- args.push("-t", TMUX_TARGET);
456
- const proc = spawn(this.tmuxBin, args, {
457
- name: "xterm-256color",
458
- cols: dims?.cols ?? this.cols,
459
- rows: dims?.rows ?? this.rows,
705
+ const captured = await this.controlModeSeedSpool(role);
706
+ let state = "prepared";
707
+ return {
708
+ seedBytes: captured.spool.finalOffset ?? 0,
709
+ ...(captured.dimensions ? { dimensions: captured.dimensions } : {}),
710
+ readSeed: (offset, maxBytes) => captured.spool.read(offset, maxBytes),
711
+ start: async () => {
712
+ if (state !== "prepared")
713
+ throw new Error("terminal preparation is no longer startable");
714
+ if (captured.spool.retainedBytes !== 0) {
715
+ throw new Error("terminal history must be consumed before live attach starts");
716
+ }
717
+ state = "started";
718
+ captured.spool.close();
719
+ return await this.startPullAttachment(role, dims, captured.dimensions);
720
+ },
721
+ kill: () => {
722
+ if (state !== "prepared")
723
+ return;
724
+ state = "closed";
725
+ captured.spool.close();
726
+ },
727
+ };
728
+ }
729
+ async controlModeSeedSpool(role) {
730
+ const metadata = parsePaneMetadata(await this.execTmuxBuffer([
731
+ "display-message",
732
+ "-p",
733
+ "-t",
734
+ TMUX_TARGET,
735
+ PANE_METADATA_FORMAT,
736
+ ]) ?? Buffer.alloc(0));
737
+ let spool = new SegmentedTerminalSpool();
738
+ const captureArgs = ["capture-pane", "-e", "-p", "-t", TMUX_TARGET];
739
+ if (metadata && !metadata.alternateOn)
740
+ captureArgs.push("-S", "-");
741
+ const envelope = captureSeedEnvelope(metadata);
742
+ spool.append(envelope.prefix);
743
+ let captureError;
744
+ let pendingByte;
745
+ let previousByte;
746
+ let normalized = [];
747
+ const flushNormalized = () => {
748
+ if (normalized.length === 0)
749
+ return;
750
+ spool.append(Uint8Array.from(normalized));
751
+ normalized = [];
752
+ };
753
+ const emitBodyByte = (byte) => {
754
+ if (byte === 0x0a && previousByte !== 0x0d)
755
+ normalized.push(0x0d);
756
+ normalized.push(byte);
757
+ previousByte = byte;
758
+ if (normalized.length >= 64 * 1024)
759
+ flushNormalized();
760
+ };
761
+ const captureSucceeded = await new Promise((resolve) => {
762
+ const proc = spawn(this.tmuxBin, [...this.base(), ...captureArgs], {
763
+ env: this.env,
764
+ stdio: ["ignore", "pipe", "pipe"],
765
+ });
766
+ proc.stdout.on("data", (chunk) => {
767
+ if (captureError)
768
+ return;
769
+ try {
770
+ for (const byte of chunk) {
771
+ if (pendingByte !== undefined)
772
+ emitBodyByte(pendingByte);
773
+ pendingByte = byte;
774
+ }
775
+ }
776
+ catch (error) {
777
+ captureError = error instanceof Error ? error : new Error(String(error));
778
+ proc.kill("SIGTERM");
779
+ }
780
+ });
781
+ proc.stderr.resume();
782
+ proc.once("error", (error) => {
783
+ captureError = error;
784
+ resolve(false);
785
+ });
786
+ proc.once("close", (code) => resolve(code === 0 && !captureError));
787
+ });
788
+ if (!captureSucceeded) {
789
+ spool.close();
790
+ spool = new SegmentedTerminalSpool();
791
+ spool.end();
792
+ }
793
+ else {
794
+ // `capture-pane -p` contributes one formatting newline which was not
795
+ // present in the pane. Keep every other byte, including an actual CRLF.
796
+ if (pendingByte !== undefined && pendingByte !== 0x0a)
797
+ emitBodyByte(pendingByte);
798
+ flushNormalized();
799
+ spool.append(envelope.suffix);
800
+ spool.end();
801
+ }
802
+ return {
803
+ spool,
804
+ ...(metadata
805
+ ? { dimensions: { cols: metadata.paneWidth, rows: metadata.paneHeight } }
806
+ : {}),
807
+ };
808
+ }
809
+ async startPullAttachment(role, _dims, initialDimensions) {
810
+ const proc = spawn(this.tmuxBin, controlAttachArgs(this.socketPath, role), {
460
811
  cwd: this.cwd,
461
812
  env: this.env,
813
+ stdio: ["pipe", "pipe", "pipe"],
814
+ });
815
+ await waitForSpawn(proc);
816
+ const spool = new SegmentedTerminalSpool();
817
+ const resizeListeners = [];
818
+ let currentDimensions = initialDimensions;
819
+ let requestedReason;
820
+ let exitCode = 0;
821
+ let readerFinished = false;
822
+ let discardOutput = false;
823
+ let terminateTimer;
824
+ let forceKillTimer;
825
+ let commandTail = Promise.resolve();
826
+ let resolveReaderDone;
827
+ const readerDone = new Promise((resolve) => {
828
+ resolveReaderDone = resolve;
462
829
  });
463
- // Pane-death watcher: with `remain-on-exit on` a dead pane keeps the attach
464
- // process alive (so `proc.onExit` never fires) while tmux paints "Pane is dead".
465
- // Poll `#{pane_dead}` and synthesize an exit so the web bridge closes the WS and
466
- // shows the dead-pane overlay instead of leaking tmux's "Pane is dead" text
467
- // (reference implementation: the server closes the socket on pane death → StatusOverlay).
468
- // Multi-listener, not a single slot: the runner child (and any future
469
- // subscriber) register `onExit`; a single-slot setter would let a later
470
- // `onExit` silently CLOBBER an earlier one (that clobber is exactly how the
471
- // old owner-slot release was lost — a tab switch then re-attached read-only).
472
- const exitListeners = [];
473
- let exited = false;
474
- const fireExit = (exitCode) => {
475
- if (exited)
830
+ const processRunning = () => proc.exitCode === null && proc.signalCode === null;
831
+ const clearTerminationTimers = () => {
832
+ if (terminateTimer)
833
+ clearTimeout(terminateTimer);
834
+ if (forceKillTimer)
835
+ clearTimeout(forceKillTimer);
836
+ terminateTimer = undefined;
837
+ forceKillTimer = undefined;
838
+ };
839
+ const requestTeardown = (reason, graceMs) => {
840
+ requestedReason ??= reason;
841
+ if (!proc.stdin.destroyed)
842
+ proc.stdin.end();
843
+ if (terminateTimer || forceKillTimer || !processRunning())
844
+ return;
845
+ terminateTimer = setTimeout(() => {
846
+ terminateTimer = undefined;
847
+ if (processRunning())
848
+ proc.kill("SIGTERM");
849
+ }, graceMs);
850
+ terminateTimer.unref?.();
851
+ forceKillTimer = setTimeout(() => {
852
+ forceKillTimer = undefined;
853
+ if (processRunning())
854
+ proc.kill("SIGKILL");
855
+ }, graceMs + 750);
856
+ forceKillTimer.unref?.();
857
+ };
858
+ const failReader = (error) => {
859
+ if (discardOutput)
476
860
  return;
477
- exited = true;
478
- if (deadTimer)
479
- clearInterval(deadTimer);
480
- for (const listener of exitListeners)
481
- listener({ exitCode });
861
+ discardOutput = true;
862
+ const failure = error instanceof Error ? error : new Error(String(error));
863
+ requestedReason = "internal";
864
+ spool.fail(failure);
865
+ requestTeardown(requestedReason, 0);
482
866
  };
483
- // Skip the poll under injectedSpawn (tests use a fake pane; `isAlive` shells out
484
- // to real tmux). `unref` so a lingering attach never keeps the process alive.
485
- const deadTimer = this.injectedSpawn
486
- ? undefined
487
- : setInterval(() => {
488
- if (exited)
867
+ const parser = new TmuxControlParser({
868
+ onOutput: (chunk) => {
869
+ if (discardOutput)
489
870
  return;
490
- // ASYNC probe (never execFileSync here — that would block the event loop
491
- // and freeze the PTY stream, making the terminal unresponsive).
492
- void this.livenessAsync().then((liveness) => {
493
- if (liveness === "dead" && !exited)
494
- fireExit(0);
495
- });
496
- }, 1000);
497
- deadTimer?.unref?.();
498
- proc.onExit((info) => fireExit(info.exitCode));
871
+ try {
872
+ spool.append(chunk);
873
+ }
874
+ catch (error) {
875
+ failReader(error);
876
+ }
877
+ },
878
+ onControlLine: (line) => {
879
+ const dimensions = controlLayoutDimensions(line);
880
+ if (dimensions &&
881
+ (dimensions.cols !== currentDimensions?.cols || dimensions.rows !== currentDimensions?.rows)) {
882
+ currentDimensions = dimensions;
883
+ for (const listener of resizeListeners)
884
+ listener(dimensions);
885
+ }
886
+ const text = Buffer.from(line);
887
+ if (text.subarray(0, 5).equals(Buffer.from("%exit", "ascii")) ||
888
+ text.subarray(0, 13).equals(Buffer.from("%window-close", "ascii"))) {
889
+ requestTeardown("terminal_exited", 0);
890
+ }
891
+ },
892
+ });
893
+ const finishReader = () => {
894
+ if (readerFinished)
895
+ return;
896
+ readerFinished = true;
897
+ clearInterval(deadTimer);
898
+ clearTerminationTimers();
899
+ try {
900
+ parser.end();
901
+ if (!discardOutput)
902
+ spool.end();
903
+ }
904
+ catch (error) {
905
+ failReader(error);
906
+ }
907
+ const reason = requestedReason ?? "terminal_exited";
908
+ resolveReaderDone({
909
+ reason,
910
+ finalOffset: spool.finalOffset ?? 0,
911
+ exitCode,
912
+ });
913
+ };
914
+ const enqueueCommands = (commands) => {
915
+ const operation = commandTail.then(async () => {
916
+ if (!processRunning() || proc.stdin.destroyed)
917
+ throw new Error("tmux control client is closed");
918
+ for (const command of commands)
919
+ await writeWithDrain(proc, command);
920
+ });
921
+ commandTail = operation.catch(() => undefined);
922
+ return operation;
923
+ };
924
+ const deadTimer = setInterval(() => {
925
+ if (readerFinished)
926
+ return;
927
+ void this.livenessAsync().then((liveness) => {
928
+ if (liveness === "dead" && !readerFinished)
929
+ requestTeardown("terminal_exited", 0);
930
+ });
931
+ }, 1000);
932
+ deadTimer.unref?.();
933
+ proc.stdout.on("data", (chunk) => {
934
+ try {
935
+ parser.write(chunk);
936
+ }
937
+ catch (error) {
938
+ failReader(error);
939
+ }
940
+ });
941
+ proc.stdout.once("end", finishReader);
942
+ proc.stdout.once("close", finishReader);
943
+ proc.stdin.on("error", () => undefined);
944
+ proc.once("exit", (code) => {
945
+ exitCode = code ?? 0;
946
+ requestedReason ??= "terminal_exited";
947
+ clearTerminationTimers();
948
+ });
949
+ proc.once("error", (error) => {
950
+ exitCode = 1;
951
+ failReader(error);
952
+ });
953
+ proc.stderr.resume();
499
954
  return {
500
- onData: (listener) => proc.onData(listener),
501
- onExit: (listener) => {
502
- exitListeners.push(listener);
955
+ ...(initialDimensions ? { dimensions: initialDimensions } : {}),
956
+ readerDone,
957
+ read: (offset, maxBytes) => spool.read(offset, maxBytes),
958
+ onResize: (listener) => {
959
+ resizeListeners.push(listener);
960
+ if (currentDimensions)
961
+ queueMicrotask(() => listener(currentDimensions));
962
+ },
963
+ write: async (data) => {
964
+ if (role !== "owner" || readerFinished || data.byteLength === 0)
965
+ return;
966
+ await enqueueCommands(hexSendKeysCommands(TMUX_TARGET, data));
967
+ },
968
+ resize: async (cols, rows) => {
969
+ if (readerFinished)
970
+ return;
971
+ await enqueueCommands([`refresh-client -C ${cols}x${rows}\n`]);
503
972
  },
504
- write: (data) => proc.write(data),
505
- resize: (cols, rows) => proc.resize(cols, rows),
506
973
  kill: () => {
507
- if (deadTimer)
508
- clearInterval(deadTimer);
509
- proc.kill();
974
+ if (!readerFinished)
975
+ requestTeardown("client_closed", 250);
976
+ void readerDone.finally(() => spool.close());
510
977
  },
511
978
  };
512
979
  }