@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.
Files changed (69) hide show
  1. package/dist/claude/executor.d.ts +17 -0
  2. package/dist/claude/executor.js +28 -0
  3. package/dist/claude/models.d.ts +10 -0
  4. package/dist/claude/models.js +33 -0
  5. package/dist/claude/native-bridge.d.ts +133 -0
  6. package/dist/claude/native-bridge.js +299 -0
  7. package/dist/claude/native-hook-main.d.ts +2 -0
  8. package/dist/claude/native-hook-main.js +74 -0
  9. package/dist/claude/native-hooks.d.ts +41 -0
  10. package/dist/claude/native-hooks.js +73 -0
  11. package/dist/claude/native-integration.d.ts +213 -0
  12. package/dist/claude/native-integration.js +665 -0
  13. package/dist/claude/native-message-display-main.d.ts +2 -0
  14. package/dist/claude/native-message-display-main.js +51 -0
  15. package/dist/claude/native-status-main.d.ts +2 -0
  16. package/dist/claude/native-status-main.js +105 -0
  17. package/dist/claude/status.d.ts +23 -0
  18. package/dist/claude/status.js +118 -0
  19. package/dist/claude/transcript.d.ts +79 -0
  20. package/dist/claude/transcript.js +272 -0
  21. package/dist/claude/trust.d.ts +6 -0
  22. package/dist/claude/trust.js +85 -0
  23. package/dist/codex/rollout-synth.d.ts +37 -0
  24. package/dist/codex/rollout-synth.js +212 -0
  25. package/dist/codex-app-server/client.d.ts +138 -0
  26. package/dist/codex-app-server/client.js +341 -0
  27. package/dist/codex-app-server/forwarder.d.ts +92 -0
  28. package/dist/codex-app-server/forwarder.js +188 -0
  29. package/dist/codex-app-server/mapping.d.ts +19 -0
  30. package/dist/codex-app-server/mapping.js +189 -0
  31. package/dist/codex-app-server/protocol.d.ts +472 -0
  32. package/dist/codex-app-server/protocol.js +12 -0
  33. package/dist/codex-app-server/transport.d.ts +139 -0
  34. package/dist/codex-app-server/transport.js +422 -0
  35. package/dist/codex-app-server/ws-channel.d.ts +72 -0
  36. package/dist/codex-app-server/ws-channel.js +233 -0
  37. package/dist/codex-child-env.d.ts +1 -0
  38. package/dist/codex-child-env.js +27 -0
  39. package/dist/codex-home.d.ts +47 -0
  40. package/dist/codex-home.js +135 -0
  41. package/dist/codex-session-store.d.ts +42 -0
  42. package/dist/codex-session-store.js +126 -0
  43. package/dist/host.d.ts +324 -0
  44. package/dist/host.js +1323 -0
  45. package/dist/index.d.ts +18 -0
  46. package/dist/index.js +17 -0
  47. package/dist/models-catalog.d.ts +18 -0
  48. package/dist/models-catalog.js +27 -0
  49. package/dist/runner/child.d.ts +58 -0
  50. package/dist/runner/child.js +268 -0
  51. package/dist/runner/manager.d.ts +175 -0
  52. package/dist/runner/manager.js +458 -0
  53. package/dist/runner/protocol.d.ts +195 -0
  54. package/dist/runner/protocol.js +41 -0
  55. package/dist/runner/transport.d.ts +36 -0
  56. package/dist/runner/transport.js +72 -0
  57. package/dist/runner-main.d.ts +2 -0
  58. package/dist/runner-main.js +61 -0
  59. package/dist/runtime-status.d.ts +16 -0
  60. package/dist/runtime-status.js +80 -0
  61. package/dist/terminal/claude-tui.d.ts +27 -0
  62. package/dist/terminal/claude-tui.js +13 -0
  63. package/dist/terminal/codex-tui.d.ts +54 -0
  64. package/dist/terminal/codex-tui.js +26 -0
  65. package/dist/terminal/registry.d.ts +42 -0
  66. package/dist/terminal/registry.js +70 -0
  67. package/dist/terminal/tmux.d.ts +150 -0
  68. package/dist/terminal/tmux.js +364 -0
  69. 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
+ }