@rynx-ai/runtime 0.1.11-beta.33 → 0.1.11-beta.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude/native-bridge.d.ts +16 -1
- package/dist/claude/native-bridge.js +91 -17
- package/dist/claude/native-hook-main.js +1 -0
- package/dist/claude/native-integration.d.ts +10 -0
- package/dist/claude/native-integration.js +100 -6
- package/dist/codex-app-server/forwarder.js +23 -1
- package/dist/codex-app-server/mapping.js +34 -10
- package/dist/codex-app-server/process-registry.d.ts +36 -0
- package/dist/codex-app-server/process-registry.js +320 -0
- package/dist/codex-app-server/ws-channel.d.ts +7 -0
- package/dist/codex-app-server/ws-channel.js +85 -9
- package/dist/codex-home.d.ts +35 -3
- package/dist/codex-home.js +321 -14
- package/dist/host.d.ts +11 -25
- package/dist/host.js +147 -132
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/runner/child.d.ts +25 -3
- package/dist/runner/child.js +359 -64
- package/dist/runner/manager.d.ts +33 -18
- package/dist/runner/manager.js +229 -49
- package/dist/runner/protocol.d.ts +79 -7
- package/dist/runner/transport.d.ts +9 -2
- package/dist/runner/transport.js +55 -3
- package/dist/runner-main.js +8 -3
- package/dist/terminal/codex-tui.d.ts +4 -0
- package/dist/terminal/codex-tui.js +5 -0
- package/dist/terminal/control-parser.d.ts +39 -0
- package/dist/terminal/control-parser.js +172 -0
- package/dist/terminal/registry.d.ts +18 -15
- package/dist/terminal/registry.js +43 -23
- package/dist/terminal/spool.d.ts +47 -0
- package/dist/terminal/spool.js +231 -0
- package/dist/terminal/tmux.d.ts +93 -84
- package/dist/terminal/tmux.js +675 -200
- package/package.json +3 -4
package/dist/terminal/tmux.js
CHANGED
|
@@ -4,28 +4,123 @@
|
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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,
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
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
|
+
}
|
|
29
124
|
/** Resolve the exact tmux executable selected by the current Rynx distribution. */
|
|
30
125
|
export function resolveTmuxBin(explicit, env = process.env) {
|
|
31
126
|
if (explicit !== undefined)
|
|
@@ -38,9 +133,12 @@ export function resolveTmuxBin(explicit, env = process.env) {
|
|
|
38
133
|
/** Deterministic private socket owned by one terminal name. Parent-side
|
|
39
134
|
* shutdown uses the same mapping when the runner child is too wedged to clean
|
|
40
135
|
* up its own tmux server. */
|
|
41
|
-
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) {
|
|
42
140
|
const key = createHash("sha256").update(name).digest("hex").slice(0, 16);
|
|
43
|
-
return join(tmpdir(), "rynx-term", `${key}
|
|
141
|
+
return join(tmpdir(), "rynx-term", `${ownerPid}-${key}`);
|
|
44
142
|
}
|
|
45
143
|
/** Read tmux's own output-activity clock for a private terminal window.
|
|
46
144
|
*
|
|
@@ -50,10 +148,13 @@ export function tmuxSocketPath(name) {
|
|
|
50
148
|
* may have stalled when the idle reaper needs an independent liveness signal.
|
|
51
149
|
* Missing servers, command failures, timeouts, and unparseable output are all
|
|
52
150
|
* treated as unknown (`null`). */
|
|
53
|
-
export function tmuxWindowActivityAt(name, tmuxBin = resolveTmuxBin()) {
|
|
54
|
-
const socketPath = tmuxSocketPath(name);
|
|
151
|
+
export function tmuxWindowActivityAt(name, tmuxBin = resolveTmuxBin(), ownerPid = process.pid) {
|
|
152
|
+
const socketPath = tmuxSocketPath(name, ownerPid);
|
|
55
153
|
return new Promise((resolve) => {
|
|
56
|
-
execFile(tmuxBin, [
|
|
154
|
+
execFile(tmuxBin, [
|
|
155
|
+
"-S", socketPath, "-f", "/dev/null",
|
|
156
|
+
"display-message", "-p", "-t", TMUX_TARGET, "#{window_activity}",
|
|
157
|
+
], { timeout: 2_000 }, (error, stdout) => {
|
|
57
158
|
if (error) {
|
|
58
159
|
resolve(null);
|
|
59
160
|
return;
|
|
@@ -71,40 +172,91 @@ export function tmuxWindowActivityAt(name, tmuxBin = resolveTmuxBin()) {
|
|
|
71
172
|
/** Whether tmux itself reports an attached client for this private terminal.
|
|
72
173
|
* This remains authoritative if a parent-side attachment bookkeeping edge was
|
|
73
174
|
* missed during a transport failure. */
|
|
74
|
-
export function tmuxHasAttachedClient(name, tmuxBin = resolveTmuxBin()) {
|
|
75
|
-
const socketPath = tmuxSocketPath(name);
|
|
175
|
+
export function tmuxHasAttachedClient(name, tmuxBin = resolveTmuxBin(), ownerPid = process.pid) {
|
|
176
|
+
const socketPath = tmuxSocketPath(name, ownerPid);
|
|
76
177
|
return new Promise((resolve) => {
|
|
77
|
-
execFile(tmuxBin, [
|
|
178
|
+
execFile(tmuxBin, [
|
|
179
|
+
"-S", socketPath, "-f", "/dev/null",
|
|
180
|
+
"list-clients", "-t", TMUX_TARGET, "-F", "#{client_name}",
|
|
181
|
+
], { timeout: 2_000 }, (error, stdout) => {
|
|
78
182
|
resolve(!error && stdout.toString().split("\n").some((line) => line.trim().length > 0));
|
|
79
183
|
});
|
|
80
184
|
});
|
|
81
185
|
}
|
|
82
|
-
/** Best-effort close of one private tmux server.
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const socketPath =
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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) {
|
|
92
209
|
try {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
timeout: 5_000,
|
|
96
|
-
});
|
|
210
|
+
process.kill(pid, 0);
|
|
211
|
+
return true;
|
|
97
212
|
}
|
|
98
|
-
catch {
|
|
99
|
-
|
|
213
|
+
catch (error) {
|
|
214
|
+
return error.code === "EPERM";
|
|
100
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;
|
|
101
224
|
try {
|
|
102
|
-
|
|
225
|
+
entries = readdirSync(root, { encoding: "utf8" });
|
|
103
226
|
}
|
|
104
227
|
catch {
|
|
105
|
-
|
|
228
|
+
return 0;
|
|
106
229
|
}
|
|
107
|
-
|
|
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;
|
|
108
260
|
}
|
|
109
261
|
/** Is a usable `tmux` on PATH? Cheap probe for the capability gate. */
|
|
110
262
|
export function isTmuxAvailable(tmuxBin = resolveTmuxBin()) {
|
|
@@ -116,18 +268,141 @@ export function isTmuxAvailable(tmuxBin = resolveTmuxBin()) {
|
|
|
116
268
|
return false;
|
|
117
269
|
}
|
|
118
270
|
}
|
|
119
|
-
async function loadPtySpawn() {
|
|
120
|
-
const mod = (await import("node-pty"));
|
|
121
|
-
return mod.spawn;
|
|
122
|
-
}
|
|
123
271
|
/** Ensure a UTF-8 locale so tmux renders multibyte (CJK) output correctly. A
|
|
124
272
|
* pm2/systemd-spawned daemon often has no LANG/LC_*, so tmux treats CJK bytes as
|
|
125
|
-
* single-byte and mangles Chinese.
|
|
126
|
-
* 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. */
|
|
127
274
|
function withUtf8Locale(env) {
|
|
128
275
|
const hasUtf8 = [env.LC_ALL, env.LC_CTYPE, env.LANG].some((v) => v != null && /utf-?8/i.test(v));
|
|
129
276
|
return hasUtf8 ? env : { ...env, LANG: "en_US.UTF-8", LC_CTYPE: "en_US.UTF-8" };
|
|
130
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
|
+
}
|
|
131
406
|
export class TmuxTerminal {
|
|
132
407
|
name;
|
|
133
408
|
socketPath;
|
|
@@ -137,8 +412,11 @@ export class TmuxTerminal {
|
|
|
137
412
|
env;
|
|
138
413
|
cols;
|
|
139
414
|
rows;
|
|
415
|
+
scrollback;
|
|
416
|
+
tmuxAllowPassthrough;
|
|
417
|
+
tmuxStartOnAttach;
|
|
418
|
+
keepAliveAfterExit;
|
|
140
419
|
tmuxBin;
|
|
141
|
-
injectedSpawn;
|
|
142
420
|
started = false;
|
|
143
421
|
lastPaneSnapshot = "";
|
|
144
422
|
/** Shared by every attachment watcher and by the lifecycle watcher's
|
|
@@ -146,20 +424,21 @@ export class TmuxTerminal {
|
|
|
146
424
|
* out once per attached client. */
|
|
147
425
|
paneLivenessFlight;
|
|
148
426
|
/** Shared by lifecycle callers so capture + pane-dead remains one ordered
|
|
149
|
-
*
|
|
427
|
+
* observation per Terminal. */
|
|
150
428
|
lifecycleLivenessFlight;
|
|
151
429
|
constructor(opts) {
|
|
152
430
|
this.name = opts.name;
|
|
153
431
|
this.cwd = opts.cwd;
|
|
154
432
|
this.command = opts.command ?? process.env.SHELL ?? "/bin/bash";
|
|
155
433
|
this.args = opts.args ?? [];
|
|
156
|
-
this.env =
|
|
434
|
+
this.env = withTerminalEnvironment(opts.env ?? process.env);
|
|
157
435
|
this.cols = opts.cols ?? DEFAULT_COLS;
|
|
158
436
|
this.rows = opts.rows ?? DEFAULT_ROWS;
|
|
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;
|
|
159
441
|
this.tmuxBin = resolveTmuxBin(opts.tmuxBin);
|
|
160
|
-
this.injectedSpawn = opts.ptySpawn;
|
|
161
|
-
const dir = join(tmpdir(), "rynx-term");
|
|
162
|
-
mkdirSync(dir, { recursive: true });
|
|
163
442
|
// A short hash of the (per-session-unique) name keeps the socket path well
|
|
164
443
|
// under the unix-domain path limit (~104 chars on macOS) — a full session id
|
|
165
444
|
// like `sess_<uuid>-main` under $TMPDIR would overflow it and fail with
|
|
@@ -171,7 +450,10 @@ export class TmuxTerminal {
|
|
|
171
450
|
// `-u` forces tmux to emit UTF-8 even when the launch env has no UTF-8 locale (a
|
|
172
451
|
// pm2/systemd-spawned daemon often has no LANG) — without it multibyte CJK output
|
|
173
452
|
// is mangled. Belt-and-suspenders with the UTF-8 locale injected into `this.env`.
|
|
174
|
-
|
|
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"];
|
|
175
457
|
}
|
|
176
458
|
/** Create the private tmux server + detached session running the inner
|
|
177
459
|
* command. Idempotent: a second call is a no-op once the session exists. */
|
|
@@ -180,93 +462,49 @@ export class TmuxTerminal {
|
|
|
180
462
|
return;
|
|
181
463
|
// Clear any stale server on this deterministic private socket before launch.
|
|
182
464
|
terminateTmuxServer(this.name, this.tmuxBin);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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);
|
|
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,
|
|
261
472
|
});
|
|
473
|
+
const startChannel = "rynx-terminal-start";
|
|
474
|
+
if (this.tmuxStartOnAttach) {
|
|
475
|
+
commands.push(["set-hook", "-g", "client-attached", `wait-for -S ${startChannel}`]);
|
|
476
|
+
}
|
|
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"]);
|
|
495
|
+
}
|
|
496
|
+
const argv = [...this.base(), "start-server"];
|
|
497
|
+
for (const command of commands)
|
|
498
|
+
argv.push(";", ...command);
|
|
262
499
|
execFileSync(this.tmuxBin, argv, { env: this.env, stdio: "ignore" });
|
|
500
|
+
this.started = true;
|
|
263
501
|
}
|
|
264
502
|
/** Whether the terminal's INNER PROCESS is still running. Probes the pane's
|
|
265
503
|
* `#{pane_dead}` flag rather than mere session existence: with
|
|
266
504
|
* `remain-on-exit on` the session/server deliberately outlive the inner CLI's
|
|
267
505
|
* exit (a dead pane shows tmux's "Pane is dead"), so `has-session` succeeding
|
|
268
506
|
* no longer implies a live process. Alive only when the session exists AND its
|
|
269
|
-
* pane process has not exited.
|
|
507
|
+
* pane process has not exited. */
|
|
270
508
|
isAlive() {
|
|
271
509
|
if (!this.started)
|
|
272
510
|
return false;
|
|
@@ -285,8 +523,8 @@ export class TmuxTerminal {
|
|
|
285
523
|
/** Async pane-liveness probe — MUST NOT block the event loop. The attach
|
|
286
524
|
* pane-death watcher polls this on an interval; a synchronous `execFileSync`
|
|
287
525
|
* there stalls the runner child's event loop (freezing the PTY stream → the
|
|
288
|
-
* terminal appears "stuck").
|
|
289
|
-
*
|
|
526
|
+
* terminal appears "stuck"). Every command error is `unknown`; only
|
|
527
|
+
* `#{pane_dead}=1` is definitive evidence of `dead`. */
|
|
290
528
|
livenessAsync() {
|
|
291
529
|
if (!this.started)
|
|
292
530
|
return Promise.resolve("dead");
|
|
@@ -320,7 +558,7 @@ export class TmuxTerminal {
|
|
|
320
558
|
});
|
|
321
559
|
return flight;
|
|
322
560
|
}
|
|
323
|
-
/**
|
|
561
|
+
/** The always-on terminal lifecycle watcher first captures the pane:
|
|
324
562
|
* a control command that ran and reports the target missing is terminal exit;
|
|
325
563
|
* a probe that cannot spawn is inconclusive. If capture succeeds, the normal
|
|
326
564
|
* definitive `pane_dead` probe distinguishes live from exited. */
|
|
@@ -340,8 +578,8 @@ export class TmuxTerminal {
|
|
|
340
578
|
}
|
|
341
579
|
this.lastPaneSnapshot = stdout.toString();
|
|
342
580
|
void this.livenessAsync().then((liveness) => {
|
|
343
|
-
// A successful capture proves the target exists.
|
|
344
|
-
//
|
|
581
|
+
// A successful capture proves the target exists. A subsequent
|
|
582
|
+
// pane-dead probe failure is non-dead for this tick.
|
|
345
583
|
resolve(liveness === "dead" ? "dead" : "alive");
|
|
346
584
|
});
|
|
347
585
|
});
|
|
@@ -378,7 +616,7 @@ export class TmuxTerminal {
|
|
|
378
616
|
/** Type literal text into the pane (agent injection / co-drive from a
|
|
379
617
|
* non-PTY caller). `-l` sends the text literally rather than as key names. */
|
|
380
618
|
sendKeys(text) {
|
|
381
|
-
|
|
619
|
+
execFileSync(this.tmuxBin, [...this.base(), "send-keys", "-c", "", "-t", TMUX_TARGET, "-l", "--", text], { env: this.env, stdio: "ignore", timeout: TMUX_SEND_TIMEOUT_MS });
|
|
382
620
|
}
|
|
383
621
|
/**
|
|
384
622
|
* The visible pane text (synchronous). Used by the claude-native injection
|
|
@@ -391,6 +629,7 @@ export class TmuxTerminal {
|
|
|
391
629
|
const snapshot = execFileSync(this.tmuxBin, [...this.base(), "capture-pane", "-t", TMUX_TARGET, "-p"], {
|
|
392
630
|
env: this.env,
|
|
393
631
|
encoding: "utf8",
|
|
632
|
+
timeout: TMUX_SEND_TIMEOUT_MS,
|
|
394
633
|
});
|
|
395
634
|
this.lastPaneSnapshot = snapshot;
|
|
396
635
|
return snapshot;
|
|
@@ -407,21 +646,22 @@ export class TmuxTerminal {
|
|
|
407
646
|
}
|
|
408
647
|
/** Interrupt the pane's running TUI turn with an Escape key — codex/claude both
|
|
409
648
|
* cancel an in-flight response on a single Esc ("esc to interrupt"). A key NAME
|
|
410
|
-
* (no `-l`) so tmux interprets it.
|
|
649
|
+
* (no `-l`) so tmux interprets it. */
|
|
411
650
|
interrupt() {
|
|
412
651
|
this.sendKeyNames("Escape");
|
|
413
652
|
}
|
|
414
653
|
/** Clear the current input line before an injection so leftover keystrokes
|
|
415
654
|
* can't prepend to the pasted draft. `C-a` (Home) + `C-k` (kill-to-end) is
|
|
416
|
-
* the safe pair
|
|
655
|
+
* the safe pair because `C-u` only clears backwards from the cursor. */
|
|
417
656
|
clearInputLine() {
|
|
418
657
|
this.sendKeyNames("C-a", "C-k");
|
|
419
658
|
}
|
|
420
659
|
/** Send one or more tmux key NAMES (e.g. `Enter`, `C-a`) to the pane. */
|
|
421
660
|
sendKeyNames(...keys) {
|
|
422
|
-
execFileSync(this.tmuxBin, [...this.base(), "send-keys", "-t", TMUX_TARGET, ...keys], {
|
|
661
|
+
execFileSync(this.tmuxBin, [...this.base(), "send-keys", "-c", "", "-t", TMUX_TARGET, ...keys], {
|
|
423
662
|
env: this.env,
|
|
424
663
|
stdio: "ignore",
|
|
664
|
+
timeout: TMUX_SEND_TIMEOUT_MS,
|
|
425
665
|
});
|
|
426
666
|
}
|
|
427
667
|
/**
|
|
@@ -437,76 +677,311 @@ export class TmuxTerminal {
|
|
|
437
677
|
env: this.env,
|
|
438
678
|
input: text,
|
|
439
679
|
stdio: ["pipe", "ignore", "ignore"],
|
|
680
|
+
timeout: TMUX_SEND_TIMEOUT_MS,
|
|
440
681
|
});
|
|
441
|
-
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 });
|
|
442
683
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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) {
|
|
450
712
|
this.start();
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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), {
|
|
460
819
|
cwd: this.cwd,
|
|
461
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;
|
|
462
837
|
});
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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)
|
|
476
868
|
return;
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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);
|
|
482
874
|
};
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
? undefined
|
|
487
|
-
: setInterval(() => {
|
|
488
|
-
if (exited)
|
|
875
|
+
const parser = new TmuxControlParser({
|
|
876
|
+
onOutput: (chunk) => {
|
|
877
|
+
if (discardOutput)
|
|
489
878
|
return;
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
}
|
|
496
|
-
},
|
|
497
|
-
|
|
498
|
-
|
|
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();
|
|
499
962
|
return {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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`]);
|
|
503
980
|
},
|
|
504
|
-
write: (data) => proc.write(data),
|
|
505
|
-
resize: (cols, rows) => proc.resize(cols, rows),
|
|
506
981
|
kill: () => {
|
|
507
|
-
if (
|
|
508
|
-
|
|
509
|
-
|
|
982
|
+
if (!readerFinished)
|
|
983
|
+
requestTeardown("client_closed", 250);
|
|
984
|
+
void readerDone.finally(() => spool.close());
|
|
510
985
|
},
|
|
511
986
|
};
|
|
512
987
|
}
|