@rynx-ai/runtime 0.1.11-beta.2 → 0.1.11-beta.21

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.
@@ -6,7 +6,8 @@
6
6
  * (`live.ensure` / `inject` / `live.interrupt`) plus per-thread capabilities and
7
7
  * terminal attachment; the reply channels mirror each request's `reqId`.
8
8
  */
9
- import { AgentRuntimeError, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot } from "@rynx-ai/core";
9
+ import { AgentRuntimeError, type InjectOutcome, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot } from "@rynx-ai/core";
10
+ export type { InjectOutcome } from "@rynx-ai/core";
10
11
  import type { ResolveInteractionResult } from "../interactions.js";
11
12
  /** A runtime error flattened for the wire; rebuilt parent-side as `AgentRuntimeError`. */
12
13
  export interface WireError {
@@ -22,13 +23,6 @@ export type TerminalRole = "owner" | "read-only";
22
23
  /** Stable reason for a terminal attach failure. `terminal_not_live` is an
23
24
  * expected, retryable absence; `terminal_open_failed` is an infrastructure error. */
24
25
  export type TerminalOpenErrorCode = "terminal_not_live" | "terminal_open_failed";
25
- /** Outcome of an `inject`. `injected` = the app-server / tmux accepted the turn.
26
- * `notLive` = no live forwarder for this session (caller may use the run path).
27
- * `notReady` = live but not ready within the park window; `failed` = injection
28
- * threw. For BOTH notReady and failed the caller reports an error and MUST NOT
29
- * re-run the turn (re-running would double-write alongside the forwarder).
30
- * Mirrors reference implementation's "inject failure ⇒ response.failed, never re-run locally". */
31
- export type InjectOutcome = "injected" | "notLive" | "notReady" | "failed";
32
26
  /** Parent → child. Terminal PTY bytes ride `term.input` base64-encoded so the
33
27
  * NDJSON line framing stays intact (raw bytes contain newlines). */
34
28
  export type ToChild = {
@@ -70,8 +64,9 @@ export type ToChild = {
70
64
  }
71
65
  /** Eagerly bring up a session's live codex TUI + forwarder (codex-native), so
72
66
  * its turns mirror to the bus regardless of whether the web has attached the
73
- * terminal. Idempotent; the child replies `live.ready` (echoing `reqId`) once
74
- * the thread is bound. */
67
+ * terminal. For Codex-lineage fresh sessions, `live.ready` acknowledges that
68
+ * the pane and observer exist; background thread discovery races the
69
+ * executor's bridge wait. */
75
70
  | {
76
71
  t: "live.ensure";
77
72
  reqId: string;
@@ -160,8 +155,9 @@ export type FromChild = {
160
155
  execution: ResolvedExecutionSnapshot;
161
156
  parentSessionId?: string;
162
157
  }
163
- /** Result of a `live.ensure`: `ok` once the requested gate is reached (pane
164
- * started for setup, otherwise native thread bound). Echoes `reqId`. */
158
+ /** Result of a `live.ensure`: for Codex-lineage sessions `ok` means the pane
159
+ * and observer started; thread discovery continues in parallel with
160
+ * injection. Other Providers retain their own readiness gate. */
165
161
  | {
166
162
  t: "live.ready";
167
163
  reqId: string;
@@ -169,9 +165,9 @@ export type FromChild = {
169
165
  ok: boolean;
170
166
  error?: string;
171
167
  }
172
- /** Result of an `inject`: `outcome` distinguishes injected / notLive / notReady /
173
- * failed so the caller never falls back to a second output path on a live
174
- * session. Echoes the request `reqId`. */
168
+ /** Result of an `inject`: `outcome` distinguishes a new turn, an active-turn
169
+ * steer, and failures so the caller never falls back to a second output path.
170
+ * Echoes the request `reqId`. */
175
171
  | {
176
172
  t: "injected";
177
173
  reqId: string;
@@ -0,0 +1,4 @@
1
+ import type { AgentRuntimeId } from "@rynx-ai/core";
2
+ /** Current live providers are native CLI sessions. Keep this explicit so a
3
+ * future non-native provider does not silently inherit their startup budget. */
4
+ export declare function isManagedNativeProvider(provider: AgentRuntimeId | undefined): provider is AgentRuntimeId;
@@ -0,0 +1,5 @@
1
+ /** Current live providers are native CLI sessions. Keep this explicit so a
2
+ * future non-native provider does not silently inherit their startup budget. */
3
+ export function isManagedNativeProvider(provider) {
4
+ return provider === "codex" || provider === "traex" || provider === "claude";
5
+ }
@@ -18,6 +18,8 @@ export interface ClaudeTuiArgs {
18
18
  settingSources?: string;
19
19
  /** Launch model (`--model`); omit to use claude's default. */
20
20
  model?: string;
21
+ /** Native Claude Code effort (`--effort`); omit to use its default. */
22
+ reasoningEffort?: string;
21
23
  /** Agent instructions appended to Claude Code's native system prompt. */
22
24
  appendSystemPrompt?: string;
23
25
  /** Resume a specific prior claude session (`--resume <id>`). */
@@ -36,4 +38,4 @@ export interface ClaudeTuiArgs {
36
38
  * Build the `claude` argv for an interactive, co-drivable TUI:
37
39
  * `[..extra] [--resume <id>] [--model <m>] [--append-system-prompt <text>] --settings <json>`.
38
40
  */
39
- export declare function buildClaudeTuiArgs({ settingsJson, settingSources, model, appendSystemPrompt, resume, forkSession, sessionId, additionalDirs, extraArgs, }: ClaudeTuiArgs): string[];
41
+ export declare function buildClaudeTuiArgs({ settingsJson, settingSources, model, reasoningEffort, appendSystemPrompt, resume, forkSession, sessionId, additionalDirs, extraArgs, }: ClaudeTuiArgs): string[];
@@ -2,7 +2,7 @@
2
2
  * Build the `claude` argv for an interactive, co-drivable TUI:
3
3
  * `[..extra] [--resume <id>] [--model <m>] [--append-system-prompt <text>] --settings <json>`.
4
4
  */
5
- export function buildClaudeTuiArgs({ settingsJson, settingSources, model, appendSystemPrompt, resume, forkSession = false, sessionId, additionalDirs = [], extraArgs = [], }) {
5
+ export function buildClaudeTuiArgs({ settingsJson, settingSources, model, reasoningEffort, appendSystemPrompt, resume, forkSession = false, sessionId, additionalDirs = [], extraArgs = [], }) {
6
6
  const args = [...extraArgs];
7
7
  if (additionalDirs.length > 0)
8
8
  args.push("--add-dir", ...additionalDirs);
@@ -16,6 +16,8 @@ export function buildClaudeTuiArgs({ settingsJson, settingSources, model, append
16
16
  args.push("--session-id", sessionId);
17
17
  if (model)
18
18
  args.push("--model", model);
19
+ if (reasoningEffort)
20
+ args.push("--effort", reasoningEffort);
19
21
  if (appendSystemPrompt)
20
22
  args.push("--append-system-prompt", appendSystemPrompt);
21
23
  args.push("--settings", settingsJson);
@@ -43,6 +43,29 @@ export interface TmuxTerminalOptions {
43
43
  /** Injectable for tests; defaults to the real `tmux` binary path. */
44
44
  tmuxBin?: string;
45
45
  }
46
+ /** Resolve the exact tmux executable selected by the current Rynx distribution. */
47
+ export declare function resolveTmuxBin(explicit?: string, env?: NodeJS.ProcessEnv): string;
48
+ /** Deterministic private socket owned by one terminal name. Parent-side
49
+ * shutdown uses the same mapping when the runner child is too wedged to clean
50
+ * up its own tmux server. */
51
+ export declare function tmuxSocketPath(name: string): string;
52
+ /** Read tmux's own output-activity clock for a private terminal window.
53
+ *
54
+ * `#{window_activity}` is an epoch timestamp updated by tmux whenever the pane
55
+ * emits bytes. It is deliberately queried out-of-process rather than inferred
56
+ * from Rynx's forwarded PTY stream: the forwarder/status path is exactly what
57
+ * may have stalled when the idle reaper needs an independent liveness signal.
58
+ * Missing servers, command failures, timeouts, and unparseable output are all
59
+ * treated as unknown (`null`). */
60
+ export declare function tmuxWindowActivityAt(name: string, tmuxBin?: string): Promise<number | null>;
61
+ /** Whether tmux itself reports an attached client for this private terminal.
62
+ * This remains authoritative if a parent-side attachment bookkeeping edge was
63
+ * missed during a transport failure. */
64
+ export declare function tmuxHasAttachedClient(name: string, tmuxBin?: string): Promise<boolean>;
65
+ /** Kill one private tmux server and verify it no longer answers. Missing
66
+ * sockets are already stopped; an existing socket with an unusable tmux
67
+ * command is unproven and returns false. */
68
+ export declare function terminateTmuxServer(name: string, tmuxBin?: string): boolean;
46
69
  /** Is a usable `tmux` on PATH? Cheap probe for the capability gate. */
47
70
  export declare function isTmuxAvailable(tmuxBin?: string): boolean;
48
71
  export declare class TmuxTerminal {
@@ -102,7 +125,9 @@ export declare class TmuxTerminal {
102
125
  * there stalls the runner child's event loop (freezing the PTY stream → the
103
126
  * terminal appears "stuck"). reference implementation's `_tmux_session_alive` uses an async
104
127
  * subprocess + timeout for exactly this reason. */
105
- private isAliveAsync;
128
+ isAliveAsync(): Promise<boolean>;
129
+ /** PID of the process currently owning the pane. */
130
+ panePid(): number | undefined;
106
131
  /** Type literal text into the pane (agent injection / co-drive from a
107
132
  * non-PTY caller). `-l` sends the text literally rather than as key names. */
108
133
  sendKeys(text: string): void;
@@ -138,7 +163,9 @@ export declare class TmuxTerminal {
138
163
  paste(text: string, bufferName?: string): void;
139
164
  /**
140
165
  * Attach a client. `role: "read-only"` passes tmux `-r` so the viewer cannot
141
- * type (defense-in-depth on top of the WS bridge dropping input frames).
166
+ * type and `ignore-size` so even its initial PTY dimensions cannot resize the
167
+ * owner's pane (defense-in-depth on top of the WS bridge dropping input and
168
+ * resize frames).
142
169
  */
143
170
  attach(role: "owner" | "read-only", dims?: {
144
171
  cols?: number;
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { execFile, execFileSync } from "node:child_process";
18
18
  import { createHash } from "node:crypto";
19
- import { 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,8 +26,106 @@ 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
+ }
38
+ /** Deterministic private socket owned by one terminal name. Parent-side
39
+ * shutdown uses the same mapping when the runner child is too wedged to clean
40
+ * up its own tmux server. */
41
+ export function tmuxSocketPath(name) {
42
+ const key = createHash("sha256").update(name).digest("hex").slice(0, 16);
43
+ return join(tmpdir(), "rynx-term", `${key}.sock`);
44
+ }
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
+ /** Kill one private tmux server and verify it no longer answers. Missing
83
+ * sockets are already stopped; an existing socket with an unusable tmux
84
+ * command is unproven and returns false. */
85
+ export function terminateTmuxServer(name, tmuxBin = resolveTmuxBin()) {
86
+ const socketPath = tmuxSocketPath(name);
87
+ if (!existsSync(socketPath))
88
+ return true;
89
+ const base = ["-u", "-S", socketPath];
90
+ try {
91
+ execFileSync(tmuxBin, [...base, "kill-server"], { stdio: "ignore" });
92
+ }
93
+ catch {
94
+ // A server may have exited between existsSync and kill-server. Verify below.
95
+ }
96
+ try {
97
+ execFileSync(tmuxBin, [...base, "has-session", "-t", TMUX_TARGET], {
98
+ stdio: "ignore",
99
+ });
100
+ return false;
101
+ }
102
+ catch (error) {
103
+ // Only a real tmux exit status proves the server/session is absent. Spawn
104
+ // failures (missing/unexecutable tmux) leave liveness unproven and must not
105
+ // unlink a socket that could still belong to a live server.
106
+ if (!error ||
107
+ typeof error !== "object" ||
108
+ !("status" in error) ||
109
+ typeof error.status !== "number") {
110
+ return false;
111
+ }
112
+ try {
113
+ // tmux custom-socket servers can exit while leaving the filesystem entry
114
+ // behind. Remove that verified-stale socket so process/socket inventories
115
+ // do not accumulate dead terminals forever.
116
+ unlinkSync(socketPath);
117
+ return true;
118
+ }
119
+ catch (unlinkError) {
120
+ return Boolean(unlinkError &&
121
+ typeof unlinkError === "object" &&
122
+ "code" in unlinkError &&
123
+ unlinkError.code === "ENOENT");
124
+ }
125
+ }
126
+ }
29
127
  /** Is a usable `tmux` on PATH? Cheap probe for the capability gate. */
30
- export function isTmuxAvailable(tmuxBin = "tmux") {
128
+ export function isTmuxAvailable(tmuxBin = resolveTmuxBin()) {
31
129
  try {
32
130
  execFileSync(tmuxBin, ["-V"], { stdio: "ignore" });
33
131
  return true;
@@ -68,7 +166,7 @@ export class TmuxTerminal {
68
166
  this.env = withUtf8Locale(opts.env ?? process.env);
69
167
  this.cols = opts.cols ?? DEFAULT_COLS;
70
168
  this.rows = opts.rows ?? DEFAULT_ROWS;
71
- this.tmuxBin = opts.tmuxBin ?? "tmux";
169
+ this.tmuxBin = resolveTmuxBin(opts.tmuxBin);
72
170
  this.injectedSpawn = opts.ptySpawn;
73
171
  const dir = join(tmpdir(), "rynx-term");
74
172
  mkdirSync(dir, { recursive: true });
@@ -76,8 +174,7 @@ export class TmuxTerminal {
76
174
  // under the unix-domain path limit (~104 chars on macOS) — a full session id
77
175
  // like `sess_<uuid>-main` under $TMPDIR would overflow it and fail with
78
176
  // "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`);
177
+ this.socketPath = tmuxSocketPath(opts.name);
81
178
  }
82
179
  /** tmux argv prefix targeting this terminal's private server. */
83
180
  base() {
@@ -221,6 +318,19 @@ export class TmuxTerminal {
221
318
  });
222
319
  });
223
320
  }
321
+ /** PID of the process currently owning the pane. */
322
+ panePid() {
323
+ if (!this.started)
324
+ return undefined;
325
+ try {
326
+ const value = execFileSync(this.tmuxBin, [...this.base(), "list-panes", "-t", TMUX_TARGET, "-F", "#{pane_pid}"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim().split(/\s+/)[0];
327
+ const pid = Number(value);
328
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
329
+ }
330
+ catch {
331
+ return undefined;
332
+ }
333
+ }
224
334
  /** Type literal text into the pane (agent injection / co-drive from a
225
335
  * non-PTY caller). `-l` sends the text literally rather than as key names. */
226
336
  sendKeys(text) {
@@ -286,14 +396,17 @@ export class TmuxTerminal {
286
396
  }
287
397
  /**
288
398
  * 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).
399
+ * type and `ignore-size` so even its initial PTY dimensions cannot resize the
400
+ * owner's pane (defense-in-depth on top of the WS bridge dropping input and
401
+ * resize frames).
290
402
  */
291
403
  async attach(role, dims) {
292
404
  this.start();
293
405
  const spawn = this.injectedSpawn ?? (await loadPtySpawn());
294
- const args = [...this.base(), "attach-session", "-t", TMUX_TARGET];
406
+ const args = [...this.base(), "attach-session"];
295
407
  if (role === "read-only")
296
- args.push("-r");
408
+ args.push("-r", "-f", "ignore-size");
409
+ args.push("-t", TMUX_TARGET);
297
410
  const proc = spawn(this.tmuxBin, args, {
298
411
  name: "xterm-256color",
299
412
  cols: dims?.cols ?? this.cols,
@@ -353,12 +466,7 @@ export class TmuxTerminal {
353
466
  }
354
467
  /** Kill the tmux server (ends the session and all attaches). */
355
468
  kill() {
356
- try {
357
- execFileSync(this.tmuxBin, [...this.base(), "kill-server"], { stdio: "ignore" });
358
- }
359
- catch {
360
- // Already gone — nothing to clean up.
361
- }
469
+ terminateTmuxServer(this.name, this.tmuxBin);
362
470
  this.started = false;
363
471
  }
364
472
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/runtime",
3
- "version": "0.1.11-beta.2",
3
+ "version": "0.1.11-beta.21",
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.2"
30
+ "@rynx-ai/core": "0.1.11-beta.21"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@types/ws": "^8.18.1"