@rynx-ai/runtime 0.1.11-beta.32 → 0.1.11-beta.34

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.
@@ -20,6 +20,8 @@ import { loadConfig } from "@rynx-ai/core";
20
20
  import { FileCodexSessionStore, LocalAgentHost, resolveCodexSessionStorePath, } from "./host.js";
21
21
  import { RunnerSession } from "./runner/child.js";
22
22
  import { StdioRunnerTransport } from "./runner/transport.js";
23
+ import { reapOrphanedTerminals } from "./terminal/tmux.js";
24
+ import { reconcileRuntimeProcesses } from "./codex-app-server/process-registry.js";
23
25
  // stdout is the protocol channel — keep all logging on stderr.
24
26
  console.log = console.error.bind(console);
25
27
  console.info = console.error.bind(console);
@@ -34,6 +36,8 @@ for (const stream of [process.stdout, process.stderr]) {
34
36
  });
35
37
  }
36
38
  async function main() {
39
+ reapOrphanedTerminals();
40
+ reconcileRuntimeProcesses();
37
41
  const config = loadConfig();
38
42
  const sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config));
39
43
  const sessionId = process.env.RYNX_RUNNER_SESSION;
@@ -51,8 +55,7 @@ async function main() {
51
55
  const transport = new StdioRunnerTransport(process.stdin, process.stdout);
52
56
  let runner;
53
57
  transport.onClose(() => {
54
- runner?.shutdown();
55
- process.exit(0);
58
+ void runner?.shutdown().finally(() => process.exit(0));
56
59
  });
57
60
  runner = new RunnerSession({
58
61
  transport,
@@ -64,7 +67,9 @@ async function main() {
64
67
  });
65
68
  // A parent SIGTERM (idle reap / shutdown) uses the same ordered cleanup path
66
69
  // as the protocol shutdown before ending the process.
67
- process.on("SIGTERM", () => runner?.shutdown());
70
+ process.on("SIGTERM", () => {
71
+ void runner?.shutdown();
72
+ });
68
73
  }
69
74
  void main().catch((error) => {
70
75
  console.error(error);
@@ -49,6 +49,10 @@ export interface LaunchCodexTuiOptions extends CodexRemoteArgs {
49
49
  env?: Record<string, string>;
50
50
  cols?: number;
51
51
  rows?: number;
52
+ scrollback?: number;
53
+ tmuxAllowPassthrough?: boolean;
54
+ tmuxStartOnAttach?: boolean;
55
+ keepAliveAfterExit?: boolean;
52
56
  }
53
57
  /**
54
58
  * Create (idempotently) the codex TUI terminal in the registry, running the real
@@ -25,11 +25,16 @@ export function buildCodexRemoteArgs({ remoteUrl, threadId, configOverrides = []
25
25
  */
26
26
  export function launchCodexTui(opts) {
27
27
  opts.registry.getOrCreate(opts.terminalId, {
28
+ lifecycle: "auxiliary",
28
29
  cwd: opts.cwd,
29
30
  command: opts.codexBin,
30
31
  args: buildCodexRemoteArgs(opts),
31
32
  env: opts.env,
32
33
  cols: opts.cols,
33
34
  rows: opts.rows,
35
+ scrollback: opts.scrollback ?? 100_000,
36
+ tmuxAllowPassthrough: opts.tmuxAllowPassthrough ?? true,
37
+ tmuxStartOnAttach: opts.tmuxStartOnAttach ?? false,
38
+ keepAliveAfterExit: opts.keepAliveAfterExit ?? false,
34
39
  });
35
40
  }
@@ -0,0 +1,39 @@
1
+ export declare class TmuxControlProtocolError extends Error {
2
+ readonly name = "TmuxControlProtocolError";
3
+ }
4
+ export interface TmuxControlParserOptions {
5
+ onOutput(chunk: Uint8Array): void;
6
+ onControlLine(line: Uint8Array): void;
7
+ outputChunkBytes?: number;
8
+ maxControlLineBytes?: number;
9
+ }
10
+ /** Incremental parser for tmux control mode.
11
+ *
12
+ * `%output` payloads are decoded directly into fixed-size output chunks. Only
13
+ * the possible tail of one `\ooo` escape survives between writes. Other
14
+ * control notifications stay line-oriented and are capped independently.
15
+ */
16
+ export declare class TmuxControlParser {
17
+ private readonly onOutput;
18
+ private readonly onControlLine;
19
+ private readonly outputChunkBytes;
20
+ private readonly maxControlLineBytes;
21
+ private mode;
22
+ private line;
23
+ private pane;
24
+ private escape;
25
+ private output;
26
+ private outputLength;
27
+ private ended;
28
+ constructor(options: TmuxControlParserOptions);
29
+ get retainedBytes(): number;
30
+ write(input: Uint8Array): void;
31
+ end(): void;
32
+ private consume;
33
+ private consumeGeneric;
34
+ private finishControlLine;
35
+ private consumePayload;
36
+ private flushEscapeLiteral;
37
+ private pushOutput;
38
+ private flushOutput;
39
+ }
@@ -0,0 +1,172 @@
1
+ const OUTPUT_PREFIX = Buffer.from("%output ", "ascii");
2
+ const DEFAULT_OUTPUT_CHUNK_BYTES = 64 * 1024;
3
+ const DEFAULT_MAX_CONTROL_LINE_BYTES = 16 * 1024;
4
+ const MAX_PANE_TOKEN_BYTES = 256;
5
+ export class TmuxControlProtocolError extends Error {
6
+ name = "TmuxControlProtocolError";
7
+ }
8
+ /** Incremental parser for tmux control mode.
9
+ *
10
+ * `%output` payloads are decoded directly into fixed-size output chunks. Only
11
+ * the possible tail of one `\ooo` escape survives between writes. Other
12
+ * control notifications stay line-oriented and are capped independently.
13
+ */
14
+ export class TmuxControlParser {
15
+ onOutput;
16
+ onControlLine;
17
+ outputChunkBytes;
18
+ maxControlLineBytes;
19
+ mode = "prefix";
20
+ line = [];
21
+ pane = [];
22
+ escape = [];
23
+ output;
24
+ outputLength = 0;
25
+ ended = false;
26
+ constructor(options) {
27
+ this.onOutput = options.onOutput;
28
+ this.onControlLine = options.onControlLine;
29
+ this.outputChunkBytes = positiveInteger(options.outputChunkBytes ?? DEFAULT_OUTPUT_CHUNK_BYTES, "outputChunkBytes");
30
+ this.maxControlLineBytes = positiveInteger(options.maxControlLineBytes ?? DEFAULT_MAX_CONTROL_LINE_BYTES, "maxControlLineBytes");
31
+ this.output = Buffer.allocUnsafe(this.outputChunkBytes);
32
+ }
33
+ get retainedBytes() {
34
+ return this.line.length + this.pane.length + this.escape.length + this.outputLength;
35
+ }
36
+ write(input) {
37
+ if (this.ended)
38
+ throw new TmuxControlProtocolError("tmux control parser has ended");
39
+ const bytes = Buffer.from(input.buffer, input.byteOffset, input.byteLength);
40
+ for (const byte of bytes)
41
+ this.consume(byte);
42
+ }
43
+ end() {
44
+ if (this.ended)
45
+ return;
46
+ this.ended = true;
47
+ if (this.mode === "payload") {
48
+ this.flushEscapeLiteral();
49
+ this.flushOutput();
50
+ }
51
+ this.line = [];
52
+ this.pane = [];
53
+ this.escape = [];
54
+ }
55
+ consume(byte) {
56
+ if (this.mode === "payload") {
57
+ this.consumePayload(byte);
58
+ return;
59
+ }
60
+ if (this.mode === "pane") {
61
+ if (byte === 0x20) {
62
+ if (this.pane.length === 0) {
63
+ this.line = [...OUTPUT_PREFIX];
64
+ this.consumeGeneric(byte);
65
+ }
66
+ else {
67
+ this.mode = "payload";
68
+ this.pane = [];
69
+ }
70
+ return;
71
+ }
72
+ if (byte === 0x0a) {
73
+ this.line = [...OUTPUT_PREFIX, ...this.pane];
74
+ this.pane = [];
75
+ this.finishControlLine();
76
+ return;
77
+ }
78
+ this.pane.push(byte);
79
+ if (this.pane.length > MAX_PANE_TOKEN_BYTES) {
80
+ throw new TmuxControlProtocolError("tmux output pane token exceeded its limit");
81
+ }
82
+ return;
83
+ }
84
+ this.consumeGeneric(byte);
85
+ }
86
+ consumeGeneric(byte) {
87
+ if (byte === 0x0a) {
88
+ this.finishControlLine();
89
+ return;
90
+ }
91
+ this.line.push(byte);
92
+ if (this.line.length > this.maxControlLineBytes) {
93
+ throw new TmuxControlProtocolError(`tmux control line exceeded ${this.maxControlLineBytes} bytes`);
94
+ }
95
+ if (this.line.length === OUTPUT_PREFIX.length) {
96
+ let outputPrefix = true;
97
+ for (let index = 0; index < OUTPUT_PREFIX.length; index += 1) {
98
+ if (this.line[index] !== OUTPUT_PREFIX[index]) {
99
+ outputPrefix = false;
100
+ break;
101
+ }
102
+ }
103
+ if (outputPrefix) {
104
+ this.line = [];
105
+ this.mode = "pane";
106
+ }
107
+ }
108
+ }
109
+ finishControlLine() {
110
+ if (this.line.at(-1) === 0x0d)
111
+ this.line.pop();
112
+ this.onControlLine(Uint8Array.from(this.line));
113
+ this.line = [];
114
+ this.pane = [];
115
+ this.mode = "prefix";
116
+ }
117
+ consumePayload(byte) {
118
+ if (byte === 0x0a) {
119
+ this.flushEscapeLiteral();
120
+ this.flushOutput();
121
+ this.mode = "prefix";
122
+ return;
123
+ }
124
+ if (this.escape.length > 0) {
125
+ this.escape.push(byte);
126
+ if (this.escape.length === 4) {
127
+ if (this.escape.slice(1).every(isOctalDigit)) {
128
+ this.pushOutput((this.escape[1] - 0x30) * 64 +
129
+ (this.escape[2] - 0x30) * 8 +
130
+ (this.escape[3] - 0x30));
131
+ }
132
+ else {
133
+ for (const escapedByte of this.escape)
134
+ this.pushOutput(escapedByte);
135
+ }
136
+ this.escape = [];
137
+ }
138
+ return;
139
+ }
140
+ if (byte === 0x5c) {
141
+ this.escape = [byte];
142
+ return;
143
+ }
144
+ this.pushOutput(byte);
145
+ }
146
+ flushEscapeLiteral() {
147
+ for (const byte of this.escape)
148
+ this.pushOutput(byte);
149
+ this.escape = [];
150
+ }
151
+ pushOutput(byte) {
152
+ this.output[this.outputLength] = byte;
153
+ this.outputLength += 1;
154
+ if (this.outputLength === this.output.byteLength)
155
+ this.flushOutput();
156
+ }
157
+ flushOutput() {
158
+ if (this.outputLength === 0)
159
+ return;
160
+ this.onOutput(Uint8Array.from(this.output.subarray(0, this.outputLength)));
161
+ this.outputLength = 0;
162
+ }
163
+ }
164
+ function isOctalDigit(value) {
165
+ return value >= 0x30 && value <= 0x37;
166
+ }
167
+ function positiveInteger(value, field) {
168
+ if (!Number.isSafeInteger(value) || value < 1) {
169
+ throw new TypeError(`${field} must be a positive integer`);
170
+ }
171
+ return value;
172
+ }
@@ -2,39 +2,41 @@
2
2
  * Per-runner registry of live {@link TmuxTerminal}s, keyed by terminal id.
3
3
  *
4
4
  * Owns terminal lifecycle. Read-only vs read-write is decided PURELY by the
5
- * requested role (the caller derives it from the viewer's permission level),
6
- * exactly like reference implementation's terminal attach (`terminal_attach.py`: `read_only`
7
- * from the URL → `-r`). There is deliberately NO stateful "first owner wins,
5
+ * requested role (the caller derives it from the viewer's permission level).
6
+ * There is deliberately NO stateful "first owner wins,
8
7
  * later owners downgraded" slot: that rynx-only mechanism leaked its owner slot
9
8
  * across a detach (the release ran on the attachment's `onExit`, which the
10
9
  * runner child's own `onExit` clobbered), so a tab switch away then back
11
10
  * re-attached read-only and silently dropped every keystroke. Multiple
12
11
  * read-write owners coexisting is fine — tmux merges their input.
13
12
  */
14
- import { TmuxTerminal, type TerminalAttachment, type TmuxTerminalOptions } from "./tmux.js";
13
+ import { TmuxTerminal, type PreparedTerminalAttachment, type TmuxTerminalOptions } from "./tmux.js";
15
14
  export type TerminalRole = "owner" | "read-only";
16
- export interface AttachResult {
17
- attachment: TerminalAttachment;
18
- /** The granted role — always the requested role (no downgrade). */
15
+ export type TerminalLifecycle = "required" | "auxiliary";
16
+ export type TerminalLaunchOptions = Omit<TmuxTerminalOptions, "name"> & {
17
+ lifecycle: TerminalLifecycle;
18
+ };
19
+ export interface PrepareResult {
20
+ preparation: PreparedTerminalAttachment;
19
21
  role: TerminalRole;
20
22
  }
21
23
  export declare class TerminalRegistry {
22
24
  private readonly terminals;
23
25
  get(id: string): TmuxTerminal | undefined;
26
+ lifecycle(id: string): TerminalLifecycle | undefined;
24
27
  has(id: string): boolean;
25
28
  /** Create + start a terminal under `id`, reusing a LIVE existing one. If the
26
29
  * existing terminal's tmux session died (its TUI process exited), drop it and
27
30
  * relaunch — so a reconnect after the pane died restarts the terminal instead
28
31
  * of attaching to a dead session. `name` is derived from the id so the tmux
29
32
  * socket is unique per terminal. */
30
- getOrCreate(id: string, opts: Omit<TmuxTerminalOptions, "name">): TmuxTerminal;
31
- /** Attach a client at the requested role (`owner` → read-write, `read-only`
32
- * → `tmux attach -r`). Throws if the terminal id is unknown (create it
33
- * first). */
34
- attach(id: string, requestedRole: TerminalRole, dims?: {
33
+ getOrCreate(id: string, opts: TerminalLaunchOptions): TmuxTerminal;
34
+ /** Move a live resource to a new logical id. Target collision is atomic. */
35
+ transfer(sourceId: string, targetId: string): void;
36
+ prepare(id: string, requestedRole: TerminalRole, dims?: {
35
37
  cols?: number;
36
38
  rows?: number;
37
- }): Promise<AttachResult>;
39
+ }): Promise<PrepareResult>;
38
40
  /** Kill a terminal's tmux server and drop it. */
39
41
  close(id: string): void;
40
42
  /** Kill every terminal (runner shutdown). */
@@ -2,20 +2,22 @@
2
2
  * Per-runner registry of live {@link TmuxTerminal}s, keyed by terminal id.
3
3
  *
4
4
  * Owns terminal lifecycle. Read-only vs read-write is decided PURELY by the
5
- * requested role (the caller derives it from the viewer's permission level),
6
- * exactly like reference implementation's terminal attach (`terminal_attach.py`: `read_only`
7
- * from the URL → `-r`). There is deliberately NO stateful "first owner wins,
5
+ * requested role (the caller derives it from the viewer's permission level).
6
+ * There is deliberately NO stateful "first owner wins,
8
7
  * later owners downgraded" slot: that rynx-only mechanism leaked its owner slot
9
8
  * across a detach (the release ran on the attachment's `onExit`, which the
10
9
  * runner child's own `onExit` clobbered), so a tab switch away then back
11
10
  * re-attached read-only and silently dropped every keystroke. Multiple
12
11
  * read-write owners coexisting is fine — tmux merges their input.
13
12
  */
14
- import { TmuxTerminal } from "./tmux.js";
13
+ import { TmuxTerminal, } from "./tmux.js";
15
14
  export class TerminalRegistry {
16
15
  terminals = new Map();
17
16
  get(id) {
18
- return this.terminals.get(id);
17
+ return this.terminals.get(id)?.terminal;
18
+ }
19
+ lifecycle(id) {
20
+ return this.terminals.get(id)?.lifecycle;
19
21
  }
20
22
  has(id) {
21
23
  return this.terminals.has(id);
@@ -27,41 +29,57 @@ export class TerminalRegistry {
27
29
  * socket is unique per terminal. */
28
30
  getOrCreate(id, opts) {
29
31
  const existing = this.terminals.get(id);
30
- if (existing && existing.isAlive())
31
- return existing;
32
+ if (existing && existing.terminal.isAlive()) {
33
+ if (existing.lifecycle !== opts.lifecycle) {
34
+ throw new Error(`terminal ${id} lifecycle does not match its live resource`);
35
+ }
36
+ return existing.terminal;
37
+ }
32
38
  if (existing) {
33
39
  // Dead pane (its TUI exited) — kill the husk + drop it before relaunching.
34
40
  this.terminals.delete(id);
35
41
  try {
36
- existing.kill();
42
+ existing.terminal.kill();
37
43
  }
38
44
  catch {
39
45
  // already gone — fine
40
46
  }
41
47
  }
42
- const terminal = new TmuxTerminal({ ...opts, name: id });
48
+ const { lifecycle, ...terminalOptions } = opts;
49
+ const terminal = new TmuxTerminal({ ...terminalOptions, name: id });
43
50
  terminal.start();
44
- this.terminals.set(id, terminal);
51
+ this.terminals.set(id, { terminal, lifecycle });
45
52
  return terminal;
46
53
  }
47
- /** Attach a client at the requested role (`owner` → read-write, `read-only`
48
- * → `tmux attach -r`). Throws if the terminal id is unknown (create it
49
- * first). */
50
- async attach(id, requestedRole, dims) {
54
+ /** Move a live resource to a new logical id. Target collision is atomic. */
55
+ transfer(sourceId, targetId) {
56
+ if (sourceId === targetId)
57
+ return;
58
+ if (this.terminals.has(targetId)) {
59
+ throw new Error(`terminal ${targetId} already exists`);
60
+ }
61
+ const source = this.terminals.get(sourceId);
62
+ if (!source)
63
+ throw new Error(`terminal ${sourceId} does not exist`);
64
+ this.terminals.delete(sourceId);
65
+ this.terminals.set(targetId, source);
66
+ }
67
+ async prepare(id, requestedRole, dims) {
51
68
  const terminal = this.terminals.get(id);
52
69
  if (!terminal)
53
70
  throw new Error(`terminal ${id} does not exist`);
54
- const attachment = await terminal.attach(requestedRole, dims);
55
- return { attachment, role: requestedRole };
71
+ const preparation = await terminal.terminal.prepare(requestedRole, dims);
72
+ return { preparation, role: requestedRole };
56
73
  }
57
74
  /** Kill a terminal's tmux server and drop it. */
58
75
  close(id) {
59
- const terminal = this.terminals.get(id);
60
- if (!terminal)
76
+ const entry = this.terminals.get(id);
77
+ if (!entry)
61
78
  return;
62
- // reference implementation removes the resource from its registry before best-effort close.
79
+ // Remove the resource before best-effort close so a failed kill cannot
80
+ // leave a dead registry entry that later attaches will reuse.
63
81
  this.terminals.delete(id);
64
- terminal.kill();
82
+ entry.terminal.kill();
65
83
  }
66
84
  /** Kill every terminal (runner shutdown). */
67
85
  closeAll() {
@@ -0,0 +1,47 @@
1
+ export interface TerminalSpoolRead {
2
+ data: Uint8Array;
3
+ nextOffset: number;
4
+ done: boolean;
5
+ finalOffset?: number;
6
+ }
7
+ export declare class TerminalSpoolCapacityError extends Error {
8
+ readonly name = "TerminalSpoolCapacityError";
9
+ }
10
+ export declare class TerminalSpoolProtocolError extends Error {
11
+ readonly name = "TerminalSpoolProtocolError";
12
+ }
13
+ /**
14
+ * A single-reader byte log backed by disposable fixed-size files. Producers
15
+ * append while the consumer pulls with an exact monotonic offset. Fully
16
+ * consumed segments are unlinked immediately, so retained disk is bounded by
17
+ * viewer lag rather than the lifetime output volume.
18
+ */
19
+ export declare class SegmentedTerminalSpool {
20
+ readonly directory: string;
21
+ readonly segmentBytes: number;
22
+ readonly quotaBytes: number | undefined;
23
+ private readonly segments;
24
+ private writeOffset;
25
+ private readOffset;
26
+ private ended;
27
+ private closed;
28
+ private failure;
29
+ private pending;
30
+ constructor(options?: {
31
+ segmentBytes?: number;
32
+ quotaBytes?: number;
33
+ directory?: string;
34
+ });
35
+ get retainedBytes(): number;
36
+ get finalOffset(): number | undefined;
37
+ append(input: Uint8Array): void;
38
+ end(): void;
39
+ fail(error: Error): void;
40
+ read(offset: number, maxBytes: number): Promise<TerminalSpoolRead>;
41
+ close(): void;
42
+ private assertWritable;
43
+ private createSegment;
44
+ private settlePending;
45
+ private readNow;
46
+ private discardConsumedSegments;
47
+ }