@rynx-ai/runtime 0.1.11-beta.4 → 0.1.11-beta.41

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 (65) 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 +103 -1
  6. package/dist/claude/native-bridge.js +445 -30
  7. package/dist/claude/native-hook-main.js +81 -1
  8. package/dist/claude/native-hooks.js +7 -0
  9. package/dist/claude/native-integration.d.ts +178 -26
  10. package/dist/claude/native-integration.js +1528 -170
  11. package/dist/claude/session-status.d.ts +39 -0
  12. package/dist/claude/session-status.js +163 -0
  13. package/dist/claude/transcript-clone.d.ts +18 -0
  14. package/dist/claude/transcript-clone.js +497 -0
  15. package/dist/claude/transcript.d.ts +27 -4
  16. package/dist/claude/transcript.js +158 -47
  17. package/dist/codex-app-server/client.d.ts +10 -6
  18. package/dist/codex-app-server/client.js +67 -15
  19. package/dist/codex-app-server/forwarder.d.ts +92 -3
  20. package/dist/codex-app-server/forwarder.js +532 -57
  21. package/dist/codex-app-server/mapping.d.ts +3 -6
  22. package/dist/codex-app-server/mapping.js +206 -36
  23. package/dist/codex-app-server/mcp-startup.d.ts +13 -0
  24. package/dist/codex-app-server/mcp-startup.js +63 -0
  25. package/dist/codex-app-server/process-registry.d.ts +36 -0
  26. package/dist/codex-app-server/process-registry.js +320 -0
  27. package/dist/codex-app-server/protocol.d.ts +64 -7
  28. package/dist/codex-app-server/ws-channel.d.ts +7 -0
  29. package/dist/codex-app-server/ws-channel.js +104 -28
  30. package/dist/codex-home.d.ts +35 -3
  31. package/dist/codex-home.js +323 -18
  32. package/dist/codex-session-store.d.ts +23 -0
  33. package/dist/codex-session-store.js +21 -0
  34. package/dist/host.d.ts +103 -46
  35. package/dist/host.js +1988 -634
  36. package/dist/index.d.ts +3 -3
  37. package/dist/index.js +1 -1
  38. package/dist/input-resources.d.ts +4 -0
  39. package/dist/input-resources.js +21 -5
  40. package/dist/models-catalog.d.ts +2 -1
  41. package/dist/models-catalog.js +94 -6
  42. package/dist/runner/child.d.ts +97 -28
  43. package/dist/runner/child.js +1486 -100
  44. package/dist/runner/manager.d.ts +110 -29
  45. package/dist/runner/manager.js +1481 -246
  46. package/dist/runner/protocol.d.ts +212 -24
  47. package/dist/runner/protocol.js +5 -0
  48. package/dist/runner/startup-policy.d.ts +7 -0
  49. package/dist/runner/startup-policy.js +10 -0
  50. package/dist/runner/transport.d.ts +18 -2
  51. package/dist/runner/transport.js +82 -3
  52. package/dist/runner-main.js +8 -3
  53. package/dist/terminal/claude-tui.d.ts +3 -1
  54. package/dist/terminal/claude-tui.js +3 -1
  55. package/dist/terminal/codex-tui.d.ts +4 -0
  56. package/dist/terminal/codex-tui.js +5 -0
  57. package/dist/terminal/control-parser.d.ts +39 -0
  58. package/dist/terminal/control-parser.js +172 -0
  59. package/dist/terminal/registry.d.ts +18 -15
  60. package/dist/terminal/registry.js +44 -23
  61. package/dist/terminal/spool.d.ts +47 -0
  62. package/dist/terminal/spool.js +231 -0
  63. package/dist/terminal/tmux.d.ts +126 -74
  64. package/dist/terminal/tmux.js +807 -211
  65. package/package.json +4 -4
@@ -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,41 +2,44 @@
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>;
38
- /** Kill a terminal's tmux server and drop it. */
39
- close(id: string): void;
39
+ }): Promise<PrepareResult>;
40
+ /** Kill a terminal's tmux server and drop it. When `expected` is supplied,
41
+ * close only if that exact instance still owns the id. */
42
+ close(id: string, expected?: TmuxTerminal): boolean;
40
43
  /** Kill every terminal (runner shutdown). */
41
44
  closeAll(): void;
42
45
  }
@@ -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,40 +29,59 @@ 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.
40
+ this.terminals.delete(id);
34
41
  try {
35
- existing.kill();
42
+ existing.terminal.kill();
36
43
  }
37
44
  catch {
38
45
  // already gone — fine
39
46
  }
40
- this.terminals.delete(id);
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
- /** Kill a terminal's tmux server and drop it. */
58
- close(id) {
59
- const terminal = this.terminals.get(id);
60
- if (!terminal)
61
- return;
62
- terminal.kill();
74
+ /** Kill a terminal's tmux server and drop it. When `expected` is supplied,
75
+ * close only if that exact instance still owns the id. */
76
+ close(id, expected) {
77
+ const entry = this.terminals.get(id);
78
+ if (!entry || (expected && entry.terminal !== expected))
79
+ return false;
80
+ // Remove the resource before best-effort close so a failed kill cannot
81
+ // leave a dead registry entry that later attaches will reuse.
63
82
  this.terminals.delete(id);
83
+ entry.terminal.kill();
84
+ return true;
64
85
  }
65
86
  /** Kill every terminal (runner shutdown). */
66
87
  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
+ }
@@ -0,0 +1,231 @@
1
+ import { closeSync, mkdirSync, mkdtempSync, openSync, readSync, rmSync, unlinkSync, writeSync, } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ const DEFAULT_SEGMENT_BYTES = 1024 * 1024;
5
+ const MAX_READ_BYTES = 1024 * 1024;
6
+ export class TerminalSpoolCapacityError extends Error {
7
+ name = "TerminalSpoolCapacityError";
8
+ }
9
+ export class TerminalSpoolProtocolError extends Error {
10
+ name = "TerminalSpoolProtocolError";
11
+ }
12
+ /**
13
+ * A single-reader byte log backed by disposable fixed-size files. Producers
14
+ * append while the consumer pulls with an exact monotonic offset. Fully
15
+ * consumed segments are unlinked immediately, so retained disk is bounded by
16
+ * viewer lag rather than the lifetime output volume.
17
+ */
18
+ export class SegmentedTerminalSpool {
19
+ directory;
20
+ segmentBytes;
21
+ quotaBytes;
22
+ segments = [];
23
+ writeOffset = 0;
24
+ readOffset = 0;
25
+ ended = false;
26
+ closed = false;
27
+ failure;
28
+ pending;
29
+ constructor(options = {}) {
30
+ this.segmentBytes = positiveInteger(options.segmentBytes ?? DEFAULT_SEGMENT_BYTES, "segmentBytes");
31
+ this.quotaBytes = options.quotaBytes === undefined
32
+ ? undefined
33
+ : positiveInteger(options.quotaBytes, "quotaBytes");
34
+ const parent = options.directory ?? tmpdir();
35
+ mkdirSync(parent, { recursive: true });
36
+ this.directory = mkdtempSync(join(parent, "rynx-terminal-spool-"));
37
+ }
38
+ get retainedBytes() {
39
+ return this.writeOffset - this.readOffset;
40
+ }
41
+ get finalOffset() {
42
+ return this.ended ? this.writeOffset : undefined;
43
+ }
44
+ append(input) {
45
+ this.assertWritable();
46
+ if (input.byteLength === 0)
47
+ return;
48
+ if (this.quotaBytes !== undefined &&
49
+ this.retainedBytes + input.byteLength > this.quotaBytes) {
50
+ const error = new TerminalSpoolCapacityError(`Terminal viewer lag exceeded ${this.quotaBytes} bytes`);
51
+ this.fail(error);
52
+ throw error;
53
+ }
54
+ const source = Buffer.from(input.buffer, input.byteOffset, input.byteLength);
55
+ let sourceOffset = 0;
56
+ while (sourceOffset < source.byteLength) {
57
+ let segment = this.segments.at(-1);
58
+ if (!segment || segment.sealed)
59
+ segment = this.createSegment();
60
+ const writable = Math.min(this.segmentBytes - segment.length, source.byteLength - sourceOffset);
61
+ writeSync(segment.fd, source, sourceOffset, writable, segment.length);
62
+ segment.length += writable;
63
+ sourceOffset += writable;
64
+ this.writeOffset += writable;
65
+ if (segment.length === this.segmentBytes) {
66
+ segment.sealed = true;
67
+ closeSync(segment.fd);
68
+ segment.fd = -1;
69
+ }
70
+ }
71
+ this.settlePending();
72
+ }
73
+ end() {
74
+ if (this.closed || this.ended)
75
+ return;
76
+ this.ended = true;
77
+ const segment = this.segments.at(-1);
78
+ if (segment && !segment.sealed) {
79
+ segment.sealed = true;
80
+ closeSync(segment.fd);
81
+ segment.fd = -1;
82
+ }
83
+ this.settlePending();
84
+ }
85
+ fail(error) {
86
+ if (this.closed || this.failure)
87
+ return;
88
+ this.failure = error;
89
+ this.ended = true;
90
+ const pending = this.pending;
91
+ this.pending = undefined;
92
+ pending?.reject(error);
93
+ }
94
+ read(offset, maxBytes) {
95
+ if (this.closed)
96
+ return Promise.reject(new TerminalSpoolProtocolError("Terminal spool is closed"));
97
+ if (this.failure)
98
+ return Promise.reject(this.failure);
99
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset !== this.readOffset) {
100
+ return Promise.reject(new TerminalSpoolProtocolError(`Terminal spool expected offset ${this.readOffset}, received ${String(offset)}`));
101
+ }
102
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_READ_BYTES) {
103
+ return Promise.reject(new TerminalSpoolProtocolError(`Terminal spool maxBytes must be within 1..${MAX_READ_BYTES}`));
104
+ }
105
+ if (this.pending) {
106
+ return Promise.reject(new TerminalSpoolProtocolError("Concurrent reads from one terminal spool are not allowed"));
107
+ }
108
+ const available = this.writeOffset - this.readOffset;
109
+ if (available > 0 || this.ended)
110
+ return Promise.resolve(this.readNow(maxBytes));
111
+ return new Promise((resolve, reject) => {
112
+ this.pending = { offset, maxBytes, resolve, reject };
113
+ });
114
+ }
115
+ close() {
116
+ if (this.closed)
117
+ return;
118
+ this.closed = true;
119
+ const error = new TerminalSpoolProtocolError("Terminal spool is closed");
120
+ const pending = this.pending;
121
+ this.pending = undefined;
122
+ pending?.reject(error);
123
+ for (const segment of this.segments) {
124
+ if (segment.fd !== -1) {
125
+ try {
126
+ closeSync(segment.fd);
127
+ }
128
+ catch {
129
+ // Best-effort cleanup continues through every segment.
130
+ }
131
+ }
132
+ }
133
+ this.segments.length = 0;
134
+ rmSync(this.directory, { recursive: true, force: true });
135
+ }
136
+ assertWritable() {
137
+ if (this.closed)
138
+ throw new TerminalSpoolProtocolError("Terminal spool is closed");
139
+ if (this.failure)
140
+ throw this.failure;
141
+ if (this.ended)
142
+ throw new TerminalSpoolProtocolError("Terminal spool has ended");
143
+ }
144
+ createSegment() {
145
+ const start = this.writeOffset;
146
+ const path = join(this.directory, `${String(start).padStart(20, "0")}.bin`);
147
+ const segment = {
148
+ path,
149
+ start,
150
+ fd: openSync(path, "wx+", 0o600),
151
+ length: 0,
152
+ sealed: false,
153
+ };
154
+ this.segments.push(segment);
155
+ return segment;
156
+ }
157
+ settlePending() {
158
+ const pending = this.pending;
159
+ if (!pending || (this.writeOffset === pending.offset && !this.ended))
160
+ return;
161
+ this.pending = undefined;
162
+ if (this.failure)
163
+ pending.reject(this.failure);
164
+ else
165
+ pending.resolve(this.readNow(pending.maxBytes));
166
+ }
167
+ readNow(maxBytes) {
168
+ if (this.failure)
169
+ throw this.failure;
170
+ const available = this.writeOffset - this.readOffset;
171
+ const length = Math.min(maxBytes, available);
172
+ const data = Buffer.allocUnsafe(length);
173
+ let copied = 0;
174
+ let cursor = this.readOffset;
175
+ for (const segment of this.segments) {
176
+ const segmentEnd = segment.start + segment.length;
177
+ if (cursor >= segmentEnd)
178
+ continue;
179
+ if (cursor < segment.start) {
180
+ throw new TerminalSpoolProtocolError("Terminal spool contains a byte gap");
181
+ }
182
+ const within = cursor - segment.start;
183
+ const count = Math.min(segment.length - within, length - copied);
184
+ if (count <= 0)
185
+ break;
186
+ const fd = segment.fd === -1 ? openSync(segment.path, "r") : segment.fd;
187
+ try {
188
+ const actual = readSync(fd, data, copied, count, within);
189
+ if (actual !== count)
190
+ throw new TerminalSpoolProtocolError("Terminal spool segment was truncated");
191
+ }
192
+ finally {
193
+ if (segment.fd === -1)
194
+ closeSync(fd);
195
+ }
196
+ copied += count;
197
+ cursor += count;
198
+ if (copied === length)
199
+ break;
200
+ }
201
+ this.readOffset += copied;
202
+ this.discardConsumedSegments();
203
+ const done = this.ended && this.readOffset === this.writeOffset;
204
+ return {
205
+ data: Uint8Array.from(data.subarray(0, copied)),
206
+ nextOffset: this.readOffset,
207
+ done,
208
+ ...(done ? { finalOffset: this.writeOffset } : {}),
209
+ };
210
+ }
211
+ discardConsumedSegments() {
212
+ while (this.segments.length > 0) {
213
+ const segment = this.segments[0];
214
+ if (!segment.sealed || segment.start + segment.length > this.readOffset)
215
+ return;
216
+ this.segments.shift();
217
+ try {
218
+ unlinkSync(segment.path);
219
+ }
220
+ catch {
221
+ // The directory-level close remains the final cleanup fence.
222
+ }
223
+ }
224
+ }
225
+ }
226
+ function positiveInteger(value, field) {
227
+ if (!Number.isSafeInteger(value) || value < 1) {
228
+ throw new TypeError(`${field} must be a positive integer`);
229
+ }
230
+ return value;
231
+ }