@rynx-ai/runtime 0.1.11-beta.3 → 0.1.11-beta.30

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 (44) 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 +2 -0
  6. package/dist/claude/native-bridge.js +23 -0
  7. package/dist/claude/native-hook-main.js +62 -0
  8. package/dist/claude/native-integration.d.ts +50 -10
  9. package/dist/claude/native-integration.js +262 -37
  10. package/dist/claude/session-status.d.ts +39 -0
  11. package/dist/claude/session-status.js +163 -0
  12. package/dist/claude/transcript.js +27 -17
  13. package/dist/codex-app-server/client.d.ts +10 -6
  14. package/dist/codex-app-server/client.js +67 -15
  15. package/dist/codex-app-server/forwarder.d.ts +92 -3
  16. package/dist/codex-app-server/forwarder.js +509 -56
  17. package/dist/codex-app-server/mapping.d.ts +3 -6
  18. package/dist/codex-app-server/mapping.js +174 -28
  19. package/dist/codex-app-server/mcp-startup.d.ts +13 -0
  20. package/dist/codex-app-server/mcp-startup.js +63 -0
  21. package/dist/codex-app-server/protocol.d.ts +64 -7
  22. package/dist/codex-app-server/ws-channel.js +19 -19
  23. package/dist/codex-home.js +2 -4
  24. package/dist/host.d.ts +64 -21
  25. package/dist/host.js +1330 -441
  26. package/dist/index.d.ts +1 -1
  27. package/dist/input-resources.d.ts +4 -0
  28. package/dist/input-resources.js +21 -5
  29. package/dist/models-catalog.d.ts +2 -1
  30. package/dist/models-catalog.js +94 -6
  31. package/dist/runner/child.d.ts +48 -21
  32. package/dist/runner/child.js +550 -48
  33. package/dist/runner/manager.d.ts +54 -13
  34. package/dist/runner/manager.js +479 -114
  35. package/dist/runner/protocol.d.ts +62 -19
  36. package/dist/runner/protocol.js +5 -0
  37. package/dist/runner/startup-policy.d.ts +7 -0
  38. package/dist/runner/startup-policy.js +10 -0
  39. package/dist/terminal/claude-tui.d.ts +3 -1
  40. package/dist/terminal/claude-tui.js +3 -1
  41. package/dist/terminal/registry.js +3 -2
  42. package/dist/terminal/tmux.d.ts +50 -7
  43. package/dist/terminal/tmux.js +168 -47
  44. package/package.json +4 -3
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { execFile, execFileSync } from "node:child_process";
18
18
  import { createHash } from "node:crypto";
19
- import { existsSync, mkdirSync } from "node:fs";
19
+ import { existsSync, mkdirSync, unlinkSync } from "node:fs";
20
20
  import { tmpdir } from "node:os";
21
21
  import { join } from "node:path";
22
22
  const TMUX_TARGET = "main";
@@ -26,6 +26,15 @@ const DEFAULT_ROWS = 24;
26
26
  * wheel-scroll (tmux copy-mode, enabled by `mouse on`) reaches as far back as
27
27
  * the terminal shows. Mirrors reference implementation's `TerminalEnvSpec.scrollback`. */
28
28
  const DEFAULT_SCROLLBACK = 20000;
29
+ /** Resolve the exact tmux executable selected by the current Rynx distribution. */
30
+ export function resolveTmuxBin(explicit, env = process.env) {
31
+ if (explicit !== undefined)
32
+ return explicit;
33
+ if (Object.hasOwn(env, "RYNX_TMUX_BIN")) {
34
+ return env.RYNX_TMUX_BIN?.trim() ?? "";
35
+ }
36
+ return "tmux";
37
+ }
29
38
  /** Deterministic private socket owned by one terminal name. Parent-side
30
39
  * shutdown uses the same mapping when the runner child is too wedged to clean
31
40
  * up its own tmux server. */
@@ -33,35 +42,72 @@ export function tmuxSocketPath(name) {
33
42
  const key = createHash("sha256").update(name).digest("hex").slice(0, 16);
34
43
  return join(tmpdir(), "rynx-term", `${key}.sock`);
35
44
  }
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") {
45
+ /** Read tmux's own output-activity clock for a private terminal window.
46
+ *
47
+ * `#{window_activity}` is an epoch timestamp updated by tmux whenever the pane
48
+ * emits bytes. It is deliberately queried out-of-process rather than inferred
49
+ * from Rynx's forwarded PTY stream: the forwarder/status path is exactly what
50
+ * may have stalled when the idle reaper needs an independent liveness signal.
51
+ * Missing servers, command failures, timeouts, and unparseable output are all
52
+ * treated as unknown (`null`). */
53
+ export function tmuxWindowActivityAt(name, tmuxBin = resolveTmuxBin()) {
54
+ const socketPath = tmuxSocketPath(name);
55
+ return new Promise((resolve) => {
56
+ execFile(tmuxBin, ["-S", socketPath, "display-message", "-p", "-t", TMUX_TARGET, "#{window_activity}"], { timeout: 2_000 }, (error, stdout) => {
57
+ if (error) {
58
+ resolve(null);
59
+ return;
60
+ }
61
+ const raw = stdout.toString().trim();
62
+ if (!raw) {
63
+ resolve(null);
64
+ return;
65
+ }
66
+ const value = Number(raw);
67
+ resolve(Number.isFinite(value) ? value : null);
68
+ });
69
+ });
70
+ }
71
+ /** Whether tmux itself reports an attached client for this private terminal.
72
+ * This remains authoritative if a parent-side attachment bookkeeping edge was
73
+ * missed during a transport failure. */
74
+ export function tmuxHasAttachedClient(name, tmuxBin = resolveTmuxBin()) {
75
+ const socketPath = tmuxSocketPath(name);
76
+ return new Promise((resolve) => {
77
+ execFile(tmuxBin, ["-S", socketPath, "list-clients", "-t", TMUX_TARGET, "-F", "#{client_name}"], { timeout: 2_000 }, (error, stdout) => {
78
+ resolve(!error && stdout.toString().split("\n").some((line) => line.trim().length > 0));
79
+ });
80
+ });
81
+ }
82
+ /** Best-effort close of one private tmux server. Mirrors reference implementation's bounded
83
+ * `TerminalInstance.close`: attempt `kill-server`, then retire the private
84
+ * socket regardless of command outcome. The registry has already forgotten
85
+ * the resource, so cleanup failure is diagnostic rather than a second
86
+ * lifecycle state. */
87
+ export function terminateTmuxServer(name, tmuxBin = resolveTmuxBin()) {
40
88
  const socketPath = tmuxSocketPath(name);
41
89
  if (!existsSync(socketPath))
42
90
  return true;
43
91
  const base = ["-u", "-S", socketPath];
44
92
  try {
45
- execFileSync(tmuxBin, [...base, "kill-server"], { stdio: "ignore" });
93
+ execFileSync(tmuxBin, [...base, "kill-server"], {
94
+ stdio: "ignore",
95
+ timeout: 5_000,
96
+ });
46
97
  }
47
98
  catch {
48
- // A server may have exited between existsSync and kill-server. Verify below.
99
+ // reference implementation treats close as best-effort and still retires the resource.
49
100
  }
50
101
  try {
51
- execFileSync(tmuxBin, [...base, "has-session", "-t", TMUX_TARGET], {
52
- stdio: "ignore",
53
- });
54
- return false;
102
+ unlinkSync(socketPath);
55
103
  }
56
- catch (error) {
57
- return !(error &&
58
- typeof error === "object" &&
59
- "code" in error &&
60
- error.code === "ENOENT");
104
+ catch {
105
+ // Gone already, or best-effort filesystem cleanup failed.
61
106
  }
107
+ return true;
62
108
  }
63
109
  /** Is a usable `tmux` on PATH? Cheap probe for the capability gate. */
64
- export function isTmuxAvailable(tmuxBin = "tmux") {
110
+ export function isTmuxAvailable(tmuxBin = resolveTmuxBin()) {
65
111
  try {
66
112
  execFileSync(tmuxBin, ["-V"], { stdio: "ignore" });
67
113
  return true;
@@ -94,6 +140,14 @@ export class TmuxTerminal {
94
140
  tmuxBin;
95
141
  injectedSpawn;
96
142
  started = false;
143
+ lastPaneSnapshot = "";
144
+ /** Shared by every attachment watcher and by the lifecycle watcher's
145
+ * pane-dead stage. One Terminal must never fan the same tmux control probe
146
+ * out once per attached client. */
147
+ paneLivenessFlight;
148
+ /** Shared by lifecycle callers so capture + pane-dead remains one ordered
149
+ * reference implementation-style observation per Terminal. */
150
+ lifecycleLivenessFlight;
97
151
  constructor(opts) {
98
152
  this.name = opts.name;
99
153
  this.cwd = opts.cwd;
@@ -102,7 +156,7 @@ export class TmuxTerminal {
102
156
  this.env = withUtf8Locale(opts.env ?? process.env);
103
157
  this.cols = opts.cols ?? DEFAULT_COLS;
104
158
  this.rows = opts.rows ?? DEFAULT_ROWS;
105
- this.tmuxBin = opts.tmuxBin ?? "tmux";
159
+ this.tmuxBin = resolveTmuxBin(opts.tmuxBin);
106
160
  this.injectedSpawn = opts.ptySpawn;
107
161
  const dir = join(tmpdir(), "rynx-term");
108
162
  mkdirSync(dir, { recursive: true });
@@ -124,15 +178,8 @@ export class TmuxTerminal {
124
178
  start() {
125
179
  if (this.started)
126
180
  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" });
132
- }
133
- catch {
134
- // No stale server — expected on a fresh start.
135
- }
181
+ // Clear any stale server on this deterministic private socket before launch.
182
+ terminateTmuxServer(this.name, this.tmuxBin);
136
183
  // `new-session -d` starts detached; `-x/-y` seed the pane size so output
137
184
  // wraps sanely before the first client attaches and resizes it.
138
185
  execFileSync(this.tmuxBin, [
@@ -238,21 +285,95 @@ export class TmuxTerminal {
238
285
  /** Async pane-liveness probe — MUST NOT block the event loop. The attach
239
286
  * pane-death watcher polls this on an interval; a synchronous `execFileSync`
240
287
  * 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() {
288
+ * terminal appears "stuck"). Exactly like reference implementation's definitive pane probe,
289
+ * every command error is `unknown`; only `#{pane_dead}=1` is `dead`. */
290
+ livenessAsync() {
244
291
  if (!this.started)
245
- return Promise.resolve(false);
246
- return new Promise((resolve) => {
292
+ return Promise.resolve("dead");
293
+ if (this.paneLivenessFlight)
294
+ return this.paneLivenessFlight;
295
+ const flight = new Promise((resolve) => {
247
296
  execFile(this.tmuxBin, [...this.base(), "list-panes", "-t", TMUX_TARGET, "-F", "#{pane_dead}"], { timeout: 2000 }, (err, stdout) => {
248
297
  if (err) {
249
- resolve(false);
298
+ resolve("unknown");
250
299
  return;
251
300
  }
252
301
  const panes = stdout.toString().split(/\s+/).filter(Boolean);
253
- resolve(panes.length > 0 && !panes.includes("1"));
302
+ if (panes.includes("1")) {
303
+ resolve("dead");
304
+ }
305
+ else if (panes.length > 0) {
306
+ resolve("alive");
307
+ }
308
+ else {
309
+ resolve("unknown");
310
+ }
254
311
  });
255
312
  });
313
+ this.paneLivenessFlight = flight;
314
+ void flight.then(() => {
315
+ if (this.paneLivenessFlight === flight)
316
+ delete this.paneLivenessFlight;
317
+ }, () => {
318
+ if (this.paneLivenessFlight === flight)
319
+ delete this.paneLivenessFlight;
320
+ });
321
+ return flight;
322
+ }
323
+ /** reference implementation's always-on terminal lifecycle watcher first captures the pane:
324
+ * a control command that ran and reports the target missing is terminal exit;
325
+ * a probe that cannot spawn is inconclusive. If capture succeeds, the normal
326
+ * definitive `pane_dead` probe distinguishes live from exited. */
327
+ lifecycleLivenessAsync() {
328
+ if (!this.started)
329
+ return Promise.resolve("dead");
330
+ if (this.lifecycleLivenessFlight)
331
+ return this.lifecycleLivenessFlight;
332
+ const flight = new Promise((resolve) => {
333
+ execFile(this.tmuxBin, [...this.base(), "capture-pane", "-t", TMUX_TARGET, "-p", "-e"], (error, stdout) => {
334
+ if (error) {
335
+ const code = typeof error === "object" && error && "code" in error
336
+ ? error.code
337
+ : undefined;
338
+ resolve(typeof code === "number" ? "dead" : "unknown");
339
+ return;
340
+ }
341
+ this.lastPaneSnapshot = stdout.toString();
342
+ void this.livenessAsync().then((liveness) => {
343
+ // A successful capture proves the target exists. reference implementation treats a
344
+ // subsequent pane-dead probe failure as non-dead for this tick.
345
+ resolve(liveness === "dead" ? "dead" : "alive");
346
+ });
347
+ });
348
+ });
349
+ this.lifecycleLivenessFlight = flight;
350
+ void flight.then(() => {
351
+ if (this.lifecycleLivenessFlight === flight)
352
+ delete this.lifecycleLivenessFlight;
353
+ }, () => {
354
+ if (this.lifecycleLivenessFlight === flight)
355
+ delete this.lifecycleLivenessFlight;
356
+ });
357
+ return flight;
358
+ }
359
+ /** Compatibility boolean for callers that cannot represent an inconclusive
360
+ * probe. Unknown must remain live so a transient tmux failure cannot tear down
361
+ * a healthy native Session. */
362
+ async isAliveAsync() {
363
+ return await this.livenessAsync() !== "dead";
364
+ }
365
+ /** PID of the process currently owning the pane. */
366
+ panePid() {
367
+ if (!this.started)
368
+ return undefined;
369
+ try {
370
+ const value = execFileSync(this.tmuxBin, [...this.base(), "list-panes", "-t", TMUX_TARGET, "-F", "#{pane_pid}"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim().split(/\s+/)[0];
371
+ const pid = Number(value);
372
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
373
+ }
374
+ catch {
375
+ return undefined;
376
+ }
256
377
  }
257
378
  /** Type literal text into the pane (agent injection / co-drive from a
258
379
  * non-PTY caller). `-l` sends the text literally rather than as key names. */
@@ -267,13 +388,15 @@ export class TmuxTerminal {
267
388
  */
268
389
  capturePane() {
269
390
  try {
270
- return execFileSync(this.tmuxBin, [...this.base(), "capture-pane", "-t", TMUX_TARGET, "-p"], {
391
+ const snapshot = execFileSync(this.tmuxBin, [...this.base(), "capture-pane", "-t", TMUX_TARGET, "-p"], {
271
392
  env: this.env,
272
393
  encoding: "utf8",
273
394
  });
395
+ this.lastPaneSnapshot = snapshot;
396
+ return snapshot;
274
397
  }
275
398
  catch {
276
- return "";
399
+ return this.lastPaneSnapshot;
277
400
  }
278
401
  }
279
402
  /** Send a submit Enter as a KEY NAME (no `-l`), committing the input line.
@@ -319,14 +442,17 @@ export class TmuxTerminal {
319
442
  }
320
443
  /**
321
444
  * 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).
445
+ * type and `ignore-size` so even its initial PTY dimensions cannot resize the
446
+ * owner's pane (defense-in-depth on top of the WS bridge dropping input and
447
+ * resize frames).
323
448
  */
324
449
  async attach(role, dims) {
325
450
  this.start();
326
451
  const spawn = this.injectedSpawn ?? (await loadPtySpawn());
327
- const args = [...this.base(), "attach-session", "-t", TMUX_TARGET];
452
+ const args = [...this.base(), "attach-session"];
328
453
  if (role === "read-only")
329
- args.push("-r");
454
+ args.push("-r", "-f", "ignore-size");
455
+ args.push("-t", TMUX_TARGET);
330
456
  const proc = spawn(this.tmuxBin, args, {
331
457
  name: "xterm-256color",
332
458
  cols: dims?.cols ?? this.cols,
@@ -363,8 +489,8 @@ export class TmuxTerminal {
363
489
  return;
364
490
  // ASYNC probe (never execFileSync here — that would block the event loop
365
491
  // and freeze the PTY stream, making the terminal unresponsive).
366
- void this.isAliveAsync().then((alive) => {
367
- if (!alive && !exited)
492
+ void this.livenessAsync().then((liveness) => {
493
+ if (liveness === "dead" && !exited)
368
494
  fireExit(0);
369
495
  });
370
496
  }, 1000);
@@ -386,12 +512,7 @@ export class TmuxTerminal {
386
512
  }
387
513
  /** Kill the tmux server (ends the session and all attaches). */
388
514
  kill() {
389
- try {
390
- execFileSync(this.tmuxBin, [...this.base(), "kill-server"], { stdio: "ignore" });
391
- }
392
- catch {
393
- // Already gone — nothing to clean up.
394
- }
515
+ terminateTmuxServer(this.name, this.tmuxBin);
395
516
  this.started = false;
396
517
  }
397
518
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/runtime",
3
- "version": "0.1.11-beta.3",
3
+ "version": "0.1.11-beta.30",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/rynx-ai/rynx.git",
@@ -24,9 +24,10 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "node-pty": "^1.0.0",
27
+ "node-pty": "1.2.0-beta.15",
28
+ "smol-toml": "1.7.1",
28
29
  "ws": "^8.21.0",
29
- "@rynx-ai/core": "0.1.11-beta.3"
30
+ "@rynx-ai/core": "0.1.11-beta.30"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@types/ws": "^8.18.1"