@rynx-ai/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude/executor.d.ts +17 -0
- package/dist/claude/executor.js +28 -0
- package/dist/claude/models.d.ts +10 -0
- package/dist/claude/models.js +33 -0
- package/dist/claude/native-bridge.d.ts +133 -0
- package/dist/claude/native-bridge.js +299 -0
- package/dist/claude/native-hook-main.d.ts +2 -0
- package/dist/claude/native-hook-main.js +74 -0
- package/dist/claude/native-hooks.d.ts +41 -0
- package/dist/claude/native-hooks.js +73 -0
- package/dist/claude/native-integration.d.ts +213 -0
- package/dist/claude/native-integration.js +665 -0
- package/dist/claude/native-message-display-main.d.ts +2 -0
- package/dist/claude/native-message-display-main.js +51 -0
- package/dist/claude/native-status-main.d.ts +2 -0
- package/dist/claude/native-status-main.js +105 -0
- package/dist/claude/status.d.ts +23 -0
- package/dist/claude/status.js +118 -0
- package/dist/claude/transcript.d.ts +79 -0
- package/dist/claude/transcript.js +272 -0
- package/dist/claude/trust.d.ts +6 -0
- package/dist/claude/trust.js +85 -0
- package/dist/codex/rollout-synth.d.ts +37 -0
- package/dist/codex/rollout-synth.js +212 -0
- package/dist/codex-app-server/client.d.ts +138 -0
- package/dist/codex-app-server/client.js +341 -0
- package/dist/codex-app-server/forwarder.d.ts +92 -0
- package/dist/codex-app-server/forwarder.js +188 -0
- package/dist/codex-app-server/mapping.d.ts +19 -0
- package/dist/codex-app-server/mapping.js +189 -0
- package/dist/codex-app-server/protocol.d.ts +472 -0
- package/dist/codex-app-server/protocol.js +12 -0
- package/dist/codex-app-server/transport.d.ts +139 -0
- package/dist/codex-app-server/transport.js +422 -0
- package/dist/codex-app-server/ws-channel.d.ts +72 -0
- package/dist/codex-app-server/ws-channel.js +233 -0
- package/dist/codex-child-env.d.ts +1 -0
- package/dist/codex-child-env.js +27 -0
- package/dist/codex-home.d.ts +47 -0
- package/dist/codex-home.js +135 -0
- package/dist/codex-session-store.d.ts +42 -0
- package/dist/codex-session-store.js +126 -0
- package/dist/host.d.ts +324 -0
- package/dist/host.js +1323 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/models-catalog.d.ts +18 -0
- package/dist/models-catalog.js +27 -0
- package/dist/runner/child.d.ts +58 -0
- package/dist/runner/child.js +268 -0
- package/dist/runner/manager.d.ts +175 -0
- package/dist/runner/manager.js +458 -0
- package/dist/runner/protocol.d.ts +195 -0
- package/dist/runner/protocol.js +41 -0
- package/dist/runner/transport.d.ts +36 -0
- package/dist/runner/transport.js +72 -0
- package/dist/runner-main.d.ts +2 -0
- package/dist/runner-main.js +61 -0
- package/dist/runtime-status.d.ts +16 -0
- package/dist/runtime-status.js +80 -0
- package/dist/terminal/claude-tui.d.ts +27 -0
- package/dist/terminal/claude-tui.js +13 -0
- package/dist/terminal/codex-tui.d.ts +54 -0
- package/dist/terminal/codex-tui.js +26 -0
- package/dist/terminal/registry.d.ts +42 -0
- package/dist/terminal/registry.js +70 -0
- package/dist/terminal/tmux.d.ts +150 -0
- package/dist/terminal/tmux.js +364 -0
- package/package.json +32 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tmux-backed live terminal — the durable half of rynx's terminal subsystem
|
|
3
|
+
* (Phase C of the native-integration refactor).
|
|
4
|
+
*
|
|
5
|
+
* The tmux SERVER (private socket, one per terminal) holds the real pane PTY and
|
|
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.
|
|
10
|
+
*
|
|
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).
|
|
16
|
+
*/
|
|
17
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { mkdirSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
const TMUX_TARGET = "main";
|
|
23
|
+
const DEFAULT_COLS = 80;
|
|
24
|
+
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 omnigent's `TerminalEnvSpec.scrollback`. */
|
|
28
|
+
const DEFAULT_SCROLLBACK = 20000;
|
|
29
|
+
/** Is a usable `tmux` on PATH? Cheap probe for the capability gate. */
|
|
30
|
+
export function isTmuxAvailable(tmuxBin = "tmux") {
|
|
31
|
+
try {
|
|
32
|
+
execFileSync(tmuxBin, ["-V"], { stdio: "ignore" });
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function loadPtySpawn() {
|
|
40
|
+
const mod = (await import("node-pty"));
|
|
41
|
+
return mod.spawn;
|
|
42
|
+
}
|
|
43
|
+
/** Ensure a UTF-8 locale so tmux renders multibyte (CJK) output correctly. A
|
|
44
|
+
* pm2/systemd-spawned daemon often has no LANG/LC_*, so tmux treats CJK bytes as
|
|
45
|
+
* single-byte and mangles Chinese. omnigent inherits a UTF-8 locale from its launch
|
|
46
|
+
* env; a pm2 daemon may not — so inject one when none is present. */
|
|
47
|
+
function withUtf8Locale(env) {
|
|
48
|
+
const hasUtf8 = [env.LC_ALL, env.LC_CTYPE, env.LANG].some((v) => v != null && /utf-?8/i.test(v));
|
|
49
|
+
return hasUtf8 ? env : { ...env, LANG: "en_US.UTF-8", LC_CTYPE: "en_US.UTF-8" };
|
|
50
|
+
}
|
|
51
|
+
export class TmuxTerminal {
|
|
52
|
+
name;
|
|
53
|
+
socketPath;
|
|
54
|
+
cwd;
|
|
55
|
+
command;
|
|
56
|
+
args;
|
|
57
|
+
env;
|
|
58
|
+
cols;
|
|
59
|
+
rows;
|
|
60
|
+
tmuxBin;
|
|
61
|
+
injectedSpawn;
|
|
62
|
+
started = false;
|
|
63
|
+
constructor(opts) {
|
|
64
|
+
this.name = opts.name;
|
|
65
|
+
this.cwd = opts.cwd;
|
|
66
|
+
this.command = opts.command ?? process.env.SHELL ?? "/bin/bash";
|
|
67
|
+
this.args = opts.args ?? [];
|
|
68
|
+
this.env = withUtf8Locale(opts.env ?? process.env);
|
|
69
|
+
this.cols = opts.cols ?? DEFAULT_COLS;
|
|
70
|
+
this.rows = opts.rows ?? DEFAULT_ROWS;
|
|
71
|
+
this.tmuxBin = opts.tmuxBin ?? "tmux";
|
|
72
|
+
this.injectedSpawn = opts.ptySpawn;
|
|
73
|
+
const dir = join(tmpdir(), "rynx-term");
|
|
74
|
+
mkdirSync(dir, { recursive: true });
|
|
75
|
+
// A short hash of the (per-session-unique) name keeps the socket path well
|
|
76
|
+
// under the unix-domain path limit (~104 chars on macOS) — a full session id
|
|
77
|
+
// like `sess_<uuid>-main` under $TMPDIR would overflow it and fail with
|
|
78
|
+
// "File name too long". The hash stays unique per terminal.
|
|
79
|
+
const key = createHash("sha256").update(opts.name).digest("hex").slice(0, 16);
|
|
80
|
+
this.socketPath = join(dir, `${key}.sock`);
|
|
81
|
+
}
|
|
82
|
+
/** tmux argv prefix targeting this terminal's private server. */
|
|
83
|
+
base() {
|
|
84
|
+
// `-u` forces tmux to emit UTF-8 even when the launch env has no UTF-8 locale (a
|
|
85
|
+
// pm2/systemd-spawned daemon often has no LANG) — without it multibyte CJK output
|
|
86
|
+
// is mangled. Belt-and-suspenders with the UTF-8 locale injected into `this.env`.
|
|
87
|
+
return ["-u", "-S", this.socketPath];
|
|
88
|
+
}
|
|
89
|
+
/** Create the private tmux server + detached session running the inner
|
|
90
|
+
* command. Idempotent: a second call is a no-op once the session exists. */
|
|
91
|
+
start() {
|
|
92
|
+
if (this.started)
|
|
93
|
+
return;
|
|
94
|
+
// Clear any stale server on this socket (a crashed prior run / a test rerun
|
|
95
|
+
// reusing the name); the socket is unique per session, so this only ever
|
|
96
|
+
// reaps our own leftover, never a live sibling.
|
|
97
|
+
try {
|
|
98
|
+
execFileSync(this.tmuxBin, [...this.base(), "kill-server"], { stdio: "ignore" });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// No stale server — expected on a fresh start.
|
|
102
|
+
}
|
|
103
|
+
// `new-session -d` starts detached; `-x/-y` seed the pane size so output
|
|
104
|
+
// wraps sanely before the first client attaches and resizes it.
|
|
105
|
+
execFileSync(this.tmuxBin, [
|
|
106
|
+
...this.base(),
|
|
107
|
+
"new-session",
|
|
108
|
+
"-d",
|
|
109
|
+
"-s",
|
|
110
|
+
TMUX_TARGET,
|
|
111
|
+
"-x",
|
|
112
|
+
String(this.cols),
|
|
113
|
+
"-y",
|
|
114
|
+
String(this.rows),
|
|
115
|
+
"-c",
|
|
116
|
+
this.cwd,
|
|
117
|
+
this.command,
|
|
118
|
+
...this.args,
|
|
119
|
+
], { env: this.env, stdio: "ignore" });
|
|
120
|
+
this.configureSession();
|
|
121
|
+
this.started = true;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Apply omnigent's tmux option suite to the private server (inner/terminal.py).
|
|
125
|
+
* These are NOT cosmetic — several fix real co-drive behavior that the tmux
|
|
126
|
+
* defaults break:
|
|
127
|
+
* - `mouse on`: the web terminal's wheel scrolls the pane's scrollback (and
|
|
128
|
+
* mouse events reach a TUI that requests them). Without it, no scrolling.
|
|
129
|
+
* - `extended-keys on` + `csi-u`: tmux forwards Kitty Keyboard Protocol / CSI-u
|
|
130
|
+
* keys (Ctrl+C, Shift+Enter, modified keys) that codex/claude TUIs request —
|
|
131
|
+
* without it tmux downgrades them and the vendor TUI mis-reads modifiers.
|
|
132
|
+
* - `escape-time 0`: kills tmux's default 500 ms wait after ESC, which otherwise
|
|
133
|
+
* makes arrow keys / Alt-combos / pasted CSI feel laggy or mis-parse.
|
|
134
|
+
* - `prefix None` + `prefix2 None` + unbind the prefix table: the user's
|
|
135
|
+
* keystrokes (notably C-b) go to the pane, never tmux — a co-drive terminal
|
|
136
|
+
* must not intercept a prefix.
|
|
137
|
+
* - `focus-events on`, `allow-passthrough on`, `history-limit`: focus reporting,
|
|
138
|
+
* passthrough sequences, scrollback depth.
|
|
139
|
+
* - `remain-on-exit on` + `exit-empty off`: keep the dead pane + server after the
|
|
140
|
+
* inner CLI exits so its last output stays capturable and `#{pane_dead}` reads
|
|
141
|
+
* the exit (liveness probe), instead of the server vanishing.
|
|
142
|
+
* - `MouseDown3*` unbinds: no right-click menu to spawn extra panes/windows.
|
|
143
|
+
* - `status off`: hide tmux chrome. omnigent keeps the status line only to show
|
|
144
|
+
* a conversation link; rynx has none, so the whole line (and its
|
|
145
|
+
* `[main] 0:node*` window list) is hidden.
|
|
146
|
+
* `-q`/`-gq`/`-sq` keep an older tmux that lacks an option from failing launch.
|
|
147
|
+
* Batched into one invocation with `;` command separators (one spawn).
|
|
148
|
+
*/
|
|
149
|
+
configureSession() {
|
|
150
|
+
const commands = [
|
|
151
|
+
// input + scrollback
|
|
152
|
+
["set-option", "-g", "history-limit", String(DEFAULT_SCROLLBACK)],
|
|
153
|
+
["set-option", "-sq", "extended-keys", "on"],
|
|
154
|
+
["set-option", "-sq", "extended-keys-format", "csi-u"],
|
|
155
|
+
["set-option", "-g", "mouse", "on"],
|
|
156
|
+
["set-option", "-g", "focus-events", "on"],
|
|
157
|
+
["set-option", "-g", "escape-time", "0"],
|
|
158
|
+
// persistence: outlive the inner CLI's exit (see `#{pane_dead}` liveness)
|
|
159
|
+
["set-option", "-gq", "remain-on-exit", "on"],
|
|
160
|
+
["set-option", "-sq", "exit-empty", "off"],
|
|
161
|
+
// passthrough escape sequences
|
|
162
|
+
["set-option", "-g", "allow-passthrough", "on"],
|
|
163
|
+
// lockdown: no prefix, no right-click pane/window creation
|
|
164
|
+
["set-option", "-g", "prefix", "None"],
|
|
165
|
+
["set-option", "-g", "prefix2", "None"],
|
|
166
|
+
["unbind-key", "-a", "-T", "prefix"],
|
|
167
|
+
["unbind-key", "-q", "-T", "root", "MouseDown3Pane"],
|
|
168
|
+
["unbind-key", "-q", "-T", "root", "M-MouseDown3Pane"],
|
|
169
|
+
["unbind-key", "-q", "-T", "root", "MouseDown3Status"],
|
|
170
|
+
["unbind-key", "-q", "-T", "root", "M-MouseDown3Status"],
|
|
171
|
+
["unbind-key", "-q", "-T", "root", "MouseDown3StatusLeft"],
|
|
172
|
+
["unbind-key", "-q", "-T", "root", "M-MouseDown3StatusLeft"],
|
|
173
|
+
// hide tmux chrome (no conversation link to show, unlike omnigent)
|
|
174
|
+
["set-option", "-g", "status", "off"],
|
|
175
|
+
];
|
|
176
|
+
const argv = [...this.base()];
|
|
177
|
+
commands.forEach((cmd, i) => {
|
|
178
|
+
if (i > 0)
|
|
179
|
+
argv.push(";");
|
|
180
|
+
argv.push(...cmd);
|
|
181
|
+
});
|
|
182
|
+
execFileSync(this.tmuxBin, argv, { env: this.env, stdio: "ignore" });
|
|
183
|
+
}
|
|
184
|
+
/** Whether the terminal's INNER PROCESS is still running. Probes the pane's
|
|
185
|
+
* `#{pane_dead}` flag rather than mere session existence: with
|
|
186
|
+
* `remain-on-exit on` the session/server deliberately outlive the inner CLI's
|
|
187
|
+
* exit (a dead pane shows tmux's "Pane is dead"), so `has-session` succeeding
|
|
188
|
+
* no longer implies a live process. Alive only when the session exists AND its
|
|
189
|
+
* pane process has not exited. Mirrors omnigent's `_terminal.is_alive`. */
|
|
190
|
+
isAlive() {
|
|
191
|
+
if (!this.started)
|
|
192
|
+
return false;
|
|
193
|
+
try {
|
|
194
|
+
const out = execFileSync(this.tmuxBin, [...this.base(), "list-panes", "-t", TMUX_TARGET, "-F", "#{pane_dead}"], { stdio: ["ignore", "pipe", "ignore"] })
|
|
195
|
+
.toString()
|
|
196
|
+
.split(/\s+/)
|
|
197
|
+
.filter(Boolean);
|
|
198
|
+
// No panes, or any pane reporting dead (its process exited), → not alive.
|
|
199
|
+
return out.length > 0 && !out.includes("1");
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return false; // session / server gone
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** Async pane-liveness probe — MUST NOT block the event loop. The attach
|
|
206
|
+
* pane-death watcher polls this on an interval; a synchronous `execFileSync`
|
|
207
|
+
* there stalls the runner child's event loop (freezing the PTY stream → the
|
|
208
|
+
* terminal appears "stuck"). omnigent's `_tmux_session_alive` uses an async
|
|
209
|
+
* subprocess + timeout for exactly this reason. */
|
|
210
|
+
isAliveAsync() {
|
|
211
|
+
if (!this.started)
|
|
212
|
+
return Promise.resolve(false);
|
|
213
|
+
return new Promise((resolve) => {
|
|
214
|
+
execFile(this.tmuxBin, [...this.base(), "list-panes", "-t", TMUX_TARGET, "-F", "#{pane_dead}"], { timeout: 2000 }, (err, stdout) => {
|
|
215
|
+
if (err) {
|
|
216
|
+
resolve(false);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const panes = stdout.toString().split(/\s+/).filter(Boolean);
|
|
220
|
+
resolve(panes.length > 0 && !panes.includes("1"));
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/** Type literal text into the pane (agent injection / co-drive from a
|
|
225
|
+
* non-PTY caller). `-l` sends the text literally rather than as key names. */
|
|
226
|
+
sendKeys(text) {
|
|
227
|
+
execFile(this.tmuxBin, [...this.base(), "send-keys", "-t", TMUX_TARGET, "-l", "--", text], { env: this.env }, () => undefined);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The visible pane text (synchronous). Used by the claude-native injection
|
|
231
|
+
* path as a ready-gate (poll for the `❯` prompt glyph before typing) and to
|
|
232
|
+
* verify a pasted draft landed / left the input box. Returns "" on failure so
|
|
233
|
+
* a dead pane just reads as empty rather than throwing mid-poll.
|
|
234
|
+
*/
|
|
235
|
+
capturePane() {
|
|
236
|
+
try {
|
|
237
|
+
return execFileSync(this.tmuxBin, [...this.base(), "capture-pane", "-t", TMUX_TARGET, "-p"], {
|
|
238
|
+
env: this.env,
|
|
239
|
+
encoding: "utf8",
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return "";
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** Send a submit Enter as a KEY NAME (no `-l`), committing the input line.
|
|
247
|
+
* Kept separate from the paste so a multi-line paste isn't folded into a
|
|
248
|
+
* single submit (claude coalesces a rapid stdin burst into a paste). */
|
|
249
|
+
sendEnter() {
|
|
250
|
+
this.sendKeyNames("Enter");
|
|
251
|
+
}
|
|
252
|
+
/** Interrupt the pane's running TUI turn with an Escape key — codex/claude both
|
|
253
|
+
* cancel an in-flight response on a single Esc ("esc to interrupt"). A key NAME
|
|
254
|
+
* (no `-l`) so tmux interprets it. Mirrors omnigent's `inject_interrupt`. */
|
|
255
|
+
interrupt() {
|
|
256
|
+
this.sendKeyNames("Escape");
|
|
257
|
+
}
|
|
258
|
+
/** Clear the current input line before an injection so leftover keystrokes
|
|
259
|
+
* can't prepend to the pasted draft. `C-a` (Home) + `C-k` (kill-to-end) is
|
|
260
|
+
* the safe pair — `C-u` only clears backwards from the cursor (omnigent). */
|
|
261
|
+
clearInputLine() {
|
|
262
|
+
this.sendKeyNames("C-a", "C-k");
|
|
263
|
+
}
|
|
264
|
+
/** Send one or more tmux key NAMES (e.g. `Enter`, `C-a`) to the pane. */
|
|
265
|
+
sendKeyNames(...keys) {
|
|
266
|
+
execFileSync(this.tmuxBin, [...this.base(), "send-keys", "-t", TMUX_TARGET, ...keys], {
|
|
267
|
+
env: this.env,
|
|
268
|
+
stdio: "ignore",
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Deliver `text` into the pane as a bracketed paste — `load-buffer` (from
|
|
273
|
+
* stdin, no temp file) then `paste-buffer -p` (bracketed markers keep newlines
|
|
274
|
+
* as data, so claude treats it as one pasted draft, not N submits) and `-d`
|
|
275
|
+
* (drop the buffer after). This is the injection body; the caller sends a
|
|
276
|
+
* separate {@link sendEnter} to submit. Handles multi-line + large messages
|
|
277
|
+
* that would overflow a `send-keys` argv.
|
|
278
|
+
*/
|
|
279
|
+
paste(text, bufferName = "rynx-paste") {
|
|
280
|
+
execFileSync(this.tmuxBin, [...this.base(), "load-buffer", "-b", bufferName, "-"], {
|
|
281
|
+
env: this.env,
|
|
282
|
+
input: text,
|
|
283
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
284
|
+
});
|
|
285
|
+
execFileSync(this.tmuxBin, [...this.base(), "paste-buffer", "-p", "-d", "-b", bufferName, "-t", TMUX_TARGET], { env: this.env, stdio: "ignore" });
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Attach a client. `role: "read-only"` passes tmux `-r` so the viewer cannot
|
|
289
|
+
* type (defense-in-depth on top of the WS bridge dropping input frames).
|
|
290
|
+
*/
|
|
291
|
+
async attach(role, dims) {
|
|
292
|
+
this.start();
|
|
293
|
+
const spawn = this.injectedSpawn ?? (await loadPtySpawn());
|
|
294
|
+
const args = [...this.base(), "attach-session", "-t", TMUX_TARGET];
|
|
295
|
+
if (role === "read-only")
|
|
296
|
+
args.push("-r");
|
|
297
|
+
const proc = spawn(this.tmuxBin, args, {
|
|
298
|
+
name: "xterm-256color",
|
|
299
|
+
cols: dims?.cols ?? this.cols,
|
|
300
|
+
rows: dims?.rows ?? this.rows,
|
|
301
|
+
cwd: this.cwd,
|
|
302
|
+
env: this.env,
|
|
303
|
+
});
|
|
304
|
+
// Pane-death watcher: with `remain-on-exit on` a dead pane keeps the attach
|
|
305
|
+
// process alive (so `proc.onExit` never fires) while tmux paints "Pane is dead".
|
|
306
|
+
// Poll `#{pane_dead}` and synthesize an exit so the web bridge closes the WS and
|
|
307
|
+
// shows the dead-pane overlay instead of leaking tmux's "Pane is dead" text
|
|
308
|
+
// (omnigent: the server closes the socket on pane death → StatusOverlay).
|
|
309
|
+
// Multi-listener, not a single slot: the runner child (and any future
|
|
310
|
+
// subscriber) register `onExit`; a single-slot setter would let a later
|
|
311
|
+
// `onExit` silently CLOBBER an earlier one (that clobber is exactly how the
|
|
312
|
+
// old owner-slot release was lost — a tab switch then re-attached read-only).
|
|
313
|
+
const exitListeners = [];
|
|
314
|
+
let exited = false;
|
|
315
|
+
const fireExit = (exitCode) => {
|
|
316
|
+
if (exited)
|
|
317
|
+
return;
|
|
318
|
+
exited = true;
|
|
319
|
+
if (deadTimer)
|
|
320
|
+
clearInterval(deadTimer);
|
|
321
|
+
for (const listener of exitListeners)
|
|
322
|
+
listener({ exitCode });
|
|
323
|
+
};
|
|
324
|
+
// Skip the poll under injectedSpawn (tests use a fake pane; `isAlive` shells out
|
|
325
|
+
// to real tmux). `unref` so a lingering attach never keeps the process alive.
|
|
326
|
+
const deadTimer = this.injectedSpawn
|
|
327
|
+
? undefined
|
|
328
|
+
: setInterval(() => {
|
|
329
|
+
if (exited)
|
|
330
|
+
return;
|
|
331
|
+
// ASYNC probe (never execFileSync here — that would block the event loop
|
|
332
|
+
// and freeze the PTY stream, making the terminal unresponsive).
|
|
333
|
+
void this.isAliveAsync().then((alive) => {
|
|
334
|
+
if (!alive && !exited)
|
|
335
|
+
fireExit(0);
|
|
336
|
+
});
|
|
337
|
+
}, 1000);
|
|
338
|
+
deadTimer?.unref?.();
|
|
339
|
+
proc.onExit((info) => fireExit(info.exitCode));
|
|
340
|
+
return {
|
|
341
|
+
onData: (listener) => proc.onData(listener),
|
|
342
|
+
onExit: (listener) => {
|
|
343
|
+
exitListeners.push(listener);
|
|
344
|
+
},
|
|
345
|
+
write: (data) => proc.write(data),
|
|
346
|
+
resize: (cols, rows) => proc.resize(cols, rows),
|
|
347
|
+
kill: () => {
|
|
348
|
+
if (deadTimer)
|
|
349
|
+
clearInterval(deadTimer);
|
|
350
|
+
proc.kill();
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
/** Kill the tmux server (ends the session and all attaches). */
|
|
355
|
+
kill() {
|
|
356
|
+
try {
|
|
357
|
+
execFileSync(this.tmuxBin, [...this.base(), "kill-server"], { stdio: "ignore" });
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
// Already gone — nothing to clean up.
|
|
361
|
+
}
|
|
362
|
+
this.started = false;
|
|
363
|
+
}
|
|
364
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rynx-ai/runtime",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Runtime host that drives local coding-agent CLIs (codex / traex / claude) behind one AgentExecutor + AgentCapabilities contract.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"registry": "https://registry.npmjs.org/",
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"node-pty": "^1.0.0",
|
|
23
|
+
"ws": "^8.21.0",
|
|
24
|
+
"@rynx-ai/core": "0.1.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/ws": "^8.18.1"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/runner-main.js dist/claude/native-hook-main.js dist/claude/native-message-display-main.js dist/claude/native-status-main.js"
|
|
31
|
+
}
|
|
32
|
+
}
|