@rynx-ai/runtime 0.1.11-beta.4 → 0.1.11-beta.41

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