ruicode 0.0.0-stage → 0.67.1

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.
package/README.md CHANGED
@@ -1,3 +1,151 @@
1
- # Temporary Holding Version
1
+ # ruicode (TypeScript client for Rui)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Starts a Rui Host as a child of your process and talks to it over its local socket.
4
+
5
+ ```ts
6
+ import { Rui } from "ruicode";
7
+
8
+ const rui = await Rui.connect({
9
+ database: "/var/lib/myapp/rui-store",
10
+ provider: { baseUrl: "https://api.openai.com/v1", key: process.env.OPENAI_API_KEY },
11
+ tools: [{ name: "drift", url: "http://127.0.0.1:3000/mcp", bearerToken: process.env.MCP_TOKEN, callTimeoutMs: 30000 }],
12
+ });
13
+ rui.on("exit", ({ code, signal, restarting }) => console.warn("rui exited", { code, signal, restarting }));
14
+ // ...
15
+ await rui.close();
16
+ ```
17
+
18
+ ## Install
19
+
20
+ Run `npm install ruicode`.
21
+ The `ruicode` package carries the client.
22
+ The `rui` executable comes from the platform package beside it
23
+ (`ruicode-linux-x64`, `ruicode-linux-arm64` or `ruicode-darwin-arm64`, installed as optional dependencies), else from `rui` on `PATH`.
24
+ Pass `binary` to `Rui.connect` to name one yourself.
25
+
26
+ Bundled and custom binaries and their child Host launch are application-owned.
27
+ Standalone client Upgrade does not mutate npm packages or blindly stop an application-owned Host, and reports installation separately from activation.
28
+ There is no HTTP installer endpoint or background update polling; update the application's dependencies and lifecycle explicitly.
29
+
30
+ `Rui.connect` writes the Store, the provider settings, MCP bearer tokens and a private runtime directory to the child's stdin.
31
+ The optional `provider.caFile` names a PEM file to trust exclusively for that provider instead of system CAs, on macOS as on Linux (for example `provider: { baseUrl: "https://localhost:8443/v1", key: "local-key", caFile: "/path/to/ca.pem" }`).
32
+ It travels in the launch frame, not argv or the environment; do not add `--provider-ca-file` to a launched Host.
33
+ The path must be nonempty, contain no NUL bytes and fit the platform's path limit; relative paths use the Host's working directory, and a file that cannot be loaded or a peer that cannot be verified fails without falling back to system trust.
34
+ `database` accepts a SQLite directory or a `mysql://USER:PASSWORD@HOST[:PORT]/DATABASE` URL.
35
+ For a Unix socket, use its percent-encoded absolute path in place of HOST; TCP verifies TLS.
36
+ For MySQL TCP URLs, the only query option is `ssl-ca=/absolute/path/to/ca.pem` (the path may be percent-encoded), which replaces system trust with that PEM bundle alone; socket URLs and verification-disabling options are refused.
37
+ The MySQL database must already contain the DDL from `rui store schema --engine mysql`.
38
+ The key, tokens and MySQL URL never reach the child's argv, environment or disk.
39
+ Readiness discovers the MySQL Store's stable opaque id, which the client uses on requests instead of the URL.
40
+ MySQL readiness also reports `innodb_doublewrite`, including `OFF`.
41
+ The child gets only `PATH`, `HOME` and `TMPDIR` from your environment.
42
+ Shell commands the agent runs execute as your user; Rui is not a sandbox.
43
+
44
+ The child lives as long as its stdin stays open, so it stops when your process ends.
45
+ If it exits on its own, the client restarts it, waiting 0.5 s and doubling up to 30 s, and sends calls in flight again once the new Host is ready.
46
+
47
+ Errors:
48
+
49
+ - `RuiStartError { code, message }`: Rui refused to open, for example `store_locked` once `lockWaitMs` (default 120 s) has passed.
50
+ - `RuiRequestError { code }`: Rui rejected one request.
51
+ - `RuiUnavailableError`: the child is gone.
52
+
53
+ For `sessions.create` and `session.update`, `mcp_server_unavailable` means a requested tool is unknown and at least one configured MCP server has no tools, including no saved copies.
54
+ The error's `message` names the unavailable server(s) that may provide the name; it does not assert ownership.
55
+ If every configured server has tools, an unknown name gets `unknown_tool` instead.
56
+ Saved tool copies remain usable in specs even when their server is unreachable; calling such a tool can still fail with `mcp_server_unavailable` as a tool result.
57
+ A creating Message uses the same request rule, and `start_agent` reports it as a tool error naming `spec.tools` rather than starting an agent.
58
+
59
+ ## Session feed
60
+
61
+ `session.listActivity()` reads to the current end; `{ follow: true }` follows new entries.
62
+ Feed entries are a discriminated union: `switch (entry.kind)` narrows to that wire row's fields.
63
+ Only top-level field names become camelCase (for example `callOrdinal`); nested objects keep their wire spelling (for example `presentation.publication_position`).
64
+ Text-bearing kinds add `text`, or `truncated: true` when the text exceeds 16 KiB; `contentBytes` gives its byte length.
65
+ Use `session.readContent(entry)` or `readContentBytes(entry)` for the whole text.
66
+
67
+ Following polls with backoff from 250 ms to 2 s only while the Session is busy.
68
+ An empty page in `idle`, `finished`, `failed`, or `stopped` state with nothing queued puts the iterator to sleep **without a timer**.
69
+ It wakes after a successful `send`, `update`, `decide`, `interrupt`, `stop`, `archive`, or `unarchive` through any handle for that Session ID on the same Rui instance, then starts polling again at 250 ms.
70
+ Writes from another process to the same Store are **not noticed while idle** (nor are writes through another Rui instance).
71
+ Pass an AbortSignal as `signal` to end the iterator immediately even while it sleeps.
72
+
73
+ ## Development
74
+
75
+ `src/wire.ts` is generated from the repository's `openapi.json`, which the Host build emits from its wire types.
76
+
77
+ ```sh
78
+ npm ci
79
+ npm run generate # after openapi.json changes
80
+ npm run check # wire.ts is fresh, and the package type-checks
81
+ RUI_BINARY=../../zig-out/bin/rui npm test
82
+ ```
83
+
84
+ `node scripts/pack.mjs <version> <outdir> <platform>=<executable> ...` builds the release tarballs (the compiled client and one package per platform).
85
+ It publishes nothing; the first npm publish waits for the maintainer.
86
+
87
+ The repository's `zig build ts-client` runs the tests against the Rui it builds.
88
+ The real-Host feed test uses a local HTTPS provider and the `openssl` command to generate its temporary test certificate; no live provider credentials are needed.
89
+
90
+ ## Session settings
91
+
92
+ `create` needs `model`, `effort`, `compactAt`, `tools` and `permissionMode`, and `workspace` when `tools` includes `bash`, or a `preset` name with optional overrides.
93
+ `provider` may be left out when the Host serves one provider.
94
+ `effort: null` sends no effort, so the provider uses its own; it is accepted only for a model Rui does not list.
95
+ Updates may change the model within the Session's fixed provider; the Workspace also remains fixed.
96
+ An omitted setting stays as it is.
97
+
98
+ ```ts
99
+ const session = await rui.sessions.create({
100
+ model: "gpt-6.1-sol",
101
+ effort: "high",
102
+ compactAt: 244800,
103
+ tools: ["bash"],
104
+ permissionMode: "ask",
105
+ workspace: "/work/project",
106
+ }, { idempotencyKey: "project:1" });
107
+ await session.update({ compactAt: 200000 });
108
+ await session.update({ model: "gpt-5.5" });
109
+ ```
110
+
111
+ `await session.status()` reads the Current report: work `state` and `since`, `turn`, `operation`, `pendingMessages`, `parent`, `label`, `model`, `effort`, `tools`, `permissionMode`, `latestOutcome` and `archiveState`.
112
+ Counts, times and Turn/Operation IDs stay decimal strings; absent targets, parents and outcomes are `null`.
113
+ A separate ending is not in the report, so status does not invent one.
114
+ `interrupt()` uses this status snapshot to select its exact model target.
115
+
116
+ Presets are complete agent specs passed at connect and kept only in Host memory.
117
+ Creation copies the preset, with every supplied field (including `null`) overriding it.
118
+
119
+ ```ts
120
+ const rui = await Rui.connect({
121
+ database: "/var/lib/myapp/rui-store",
122
+ provider: { baseUrl: "https://api.openai.com/v1", key: process.env.OPENAI_API_KEY! },
123
+ presets: {
124
+ review: { model: "gpt-6.1-sol", effort: "low", compactAt: 100000, tools: [], permissionMode: "ask" },
125
+ },
126
+ });
127
+ const review = await rui.sessions.create({ preset: "review", instructions: "Review this patch" }, { idempotencyKey: "review:1" });
128
+ await rui.close();
129
+ ```
130
+
131
+ ## Archive and restore
132
+
133
+ Archive metadata is independent of work state. `archiveState` is `active`, `archiving` or `archived` in Session summaries and status.
134
+
135
+ ```ts
136
+ const result = await session.archive({ idempotencyKey: "archive:1" });
137
+ // result: { archived: true, completion: "pending" | "completed" }
138
+ await session.unarchive({ idempotencyKey: "restore:1" });
139
+
140
+ await rui.sessions.list(); // active, including archiving
141
+ await rui.sessions.list({ archive: "archived" }); // archived only
142
+ await rui.sessions.list({ archive: "all" }); // all Sessions
143
+ ```
144
+
145
+ `archive()` submits one durable intent that stops running and queued work, then archives after real settlement.
146
+ An accepted intent can return `completion: "pending"`; acceptance does not mean stopped processes have retired.
147
+ New Messages are rejected with `session_archiving` during settlement, and `unarchive()` is rejected with `archive_pending`.
148
+ A later Message to an archived Session restores it transactionally before admitting new work.
149
+ `unarchive()` restores only organization and never resumes stopped work.
150
+ Replaying an archive request with its original key does not archive again after a later restore.
151
+ Both methods notify local activity followers without submitting a Message or starting work.
@@ -0,0 +1,10 @@
1
+ /** The platform packages, by `process.platform` and `process.arch` (D127, D186). */
2
+ export declare const PLATFORM_PACKAGES: Record<string, Record<string, string>>;
3
+ export interface BinaryLookup {
4
+ platform?: string;
5
+ arch?: string;
6
+ /** A file URL or path that module resolution starts from. Default: this module. */
7
+ from?: string;
8
+ }
9
+ /** The executable to start: `binary` if given, else the installed platform package's, else `rui` on PATH. */
10
+ export declare function resolveBinary(binary: string | undefined, lookup?: BinaryLookup): string;
package/dist/binary.js ADDED
@@ -0,0 +1,23 @@
1
+ // Where the `rui` executable comes from when the caller names none: the
2
+ // platform package installed beside this one (D125, D127), else PATH.
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join } from "node:path";
5
+ /** The platform packages, by `process.platform` and `process.arch` (D127, D186). */
6
+ export const PLATFORM_PACKAGES = {
7
+ linux: { x64: "ruicode-linux-x64", arm64: "ruicode-linux-arm64" },
8
+ darwin: { arm64: "ruicode-darwin-arm64" },
9
+ };
10
+ /** The executable to start: `binary` if given, else the installed platform package's, else `rui` on PATH. */
11
+ export function resolveBinary(binary, lookup = {}) {
12
+ if (binary !== undefined)
13
+ return binary;
14
+ const name = PLATFORM_PACKAGES[lookup.platform ?? process.platform]?.[lookup.arch ?? process.arch];
15
+ if (name === undefined)
16
+ return "rui";
17
+ try {
18
+ return join(dirname(createRequire(lookup.from ?? import.meta.url).resolve(`${name}/package.json`)), "bin", "rui");
19
+ }
20
+ catch {
21
+ return "rui";
22
+ }
23
+ }
@@ -0,0 +1,97 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { SessionNotifications } from "./notifications.ts";
3
+ import { RuiStartError } from "./errors.ts";
4
+ import { Session, Sessions, type AgentSpec, type ModelSummary } from "./sessions.ts";
5
+ import { type ReplyLimits } from "./transport.ts";
6
+ import type { components } from "./wire.ts";
7
+ type Schemas = components["schemas"];
8
+ export interface McpServer {
9
+ name: string;
10
+ url: string;
11
+ bearerToken: string;
12
+ callTimeoutMs: number;
13
+ }
14
+ export interface ConnectOptions {
15
+ /** The MCP servers whose tools Sessions may name. */
16
+ tools?: McpServer[];
17
+ /** The Store: a SQLite directory or a mysql:// URL, sent only in the stdin launch frame.
18
+ * MySQL TCP accepts only ssl-ca=<absolute PEM path> (percent encoding allowed) for exclusive CA trust; sockets refuse it and verification cannot be disabled. */
19
+ database: string;
20
+ /** The OpenAI-compatible provider. Only the key Adapter is offered to a launched Host (D149). */
21
+ provider: {
22
+ baseUrl: string;
23
+ key: string;
24
+ /** Trust only this PEM file for this provider, instead of system CAs. Sent in the launch frame. */
25
+ caFile?: string;
26
+ };
27
+ /** How many Turns may run at once. Left out, the Host picks its default. */
28
+ activeCapacity?: number;
29
+ /** Named creation specs, held only by this Host. No default preset is selected. */
30
+ presets?: Record<string, AgentSpec>;
31
+ /** How long connect keeps retrying while another Host holds the Store (D183, D198). Default 120 000. */
32
+ lockWaitMs?: number;
33
+ /** Opens the Host with Turns held, as if `rui.pause()` had just run (D196); `rui.resume()` lifts it. A restarted Host opens paused again. */
34
+ paused?: boolean;
35
+ /** The `rui` executable. Default: the one from the installed platform package, else `rui` on PATH. */
36
+ binary?: string;
37
+ }
38
+ /** Fires on every child exit, including one the client restarts from (D129). */
39
+ export interface ExitEvent {
40
+ code: number | null;
41
+ signal: NodeJS.Signals | null;
42
+ restarting: boolean;
43
+ }
44
+ type HostInfo = components["schemas"]["HostInfoReply"];
45
+ interface Launch {
46
+ binary: string;
47
+ frame: Schemas["LaunchFrame"];
48
+ socketPath: string;
49
+ store: string;
50
+ env: NodeJS.ProcessEnv;
51
+ }
52
+ export declare class Rui extends EventEmitter<{
53
+ exit: [ExitEvent];
54
+ }> {
55
+ #private;
56
+ /** Local Session write notifications, shared by every handle. Internal. */
57
+ readonly activity: SessionNotifications;
58
+ /** Create and list Sessions. */
59
+ readonly sessions: Sessions;
60
+ private constructor();
61
+ /** Starts Rui as a child of this process and resolves once it is ready. */
62
+ static connect(options: ConnectOptions): Promise<Rui>;
63
+ /** A handle on an existing Session. Does no I/O: an unknown id fails on the first call (D16). */
64
+ getSession(id: string): Session;
65
+ /** The models each provider offers. */
66
+ models(): Promise<ModelSummary[]>;
67
+ /** Holds every new Turn until `resume`. Running work continues. */
68
+ pause(): Promise<void>;
69
+ resume(): Promise<void>;
70
+ /**
71
+ * Closes the child's stdin and resolves once it has exited, bounded by the
72
+ * Host's 10 s shutdown (D129). Later calls fail with RuiUnavailableError.
73
+ */
74
+ close(): Promise<void>;
75
+ /**
76
+ * Sends one request to the ready Host. A call that finds the child gone
77
+ * waits for the restarted Host and is sent once more. Internal: the
78
+ * package's calls are built on it.
79
+ */
80
+ call(path: string, body: string, environment?: Buffer, limits?: ReplyLimits): Promise<Buffer>;
81
+ /**
82
+ * Sends one request built for the Host's Store. `build` receives the
83
+ * canonical Store name the Host reported at readiness. Internal: the
84
+ * package's calls are built on it.
85
+ */
86
+ exchange(path: string, build: (store: string) => string, environment?: Buffer, limits?: ReplyLimits): Promise<Buffer>;
87
+ /** The running child's process id. Internal. */
88
+ get pid(): number | undefined;
89
+ /** The latest host-info reply. Internal. */
90
+ get hostInfo(): HostInfo | undefined;
91
+ }
92
+ export declare function launchFor(options: ConnectOptions, runtimeDir: string): Launch;
93
+ /** One length-prefixed JSON frame: a 4-byte big-endian length, then UTF-8 JSON (D130). */
94
+ export declare function encodeFrame(frame: Schemas["LaunchFrame"]): Buffer;
95
+ /** The refusal a child wrote as its one stdout line, `{"rui_start_error":{code,message}}` (D211). */
96
+ export declare function startError(output: string): RuiStartError | undefined;
97
+ export {};
package/dist/client.js ADDED
@@ -0,0 +1,370 @@
1
+ // Rui.connect starts the Host as a child of this process, hands it the launch
2
+ // frame on stdin and waits until it answers host-info on the socket in its
3
+ // private runtimeDir (D107, D112, D130, D131). The child lives as long as its
4
+ // stdin stays open (D5, D118). A child that exits on its own is restarted with
5
+ // capped backoff, and calls in flight are retried once the new Host is ready;
6
+ // every write carries a request key, so a retry is safe (D118, D129).
7
+ import { spawn } from "node:child_process";
8
+ import { EventEmitter } from "node:events";
9
+ import { mkdtemp, realpath, rm, stat } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join, resolve } from "node:path";
12
+ import { setTimeout as sleep } from "node:timers/promises";
13
+ import { SessionNotifications } from "./notifications.js";
14
+ import { resolveBinary } from "./binary.js";
15
+ import { RuiStartError, RuiUnavailableError } from "./errors.js";
16
+ import { agentSpec, models, Session, Sessions, setPaused } from "./sessions.js";
17
+ import { ConnectionError, post, WIRE_VERSION } from "./transport.js";
18
+ const FRAME_VERSION = 1;
19
+ const SOCKET_NAME = "rui.sock";
20
+ const DEFAULT_LOCK_WAIT_MS = 120_000;
21
+ const LOCK_RETRY_MS = 500;
22
+ const FIRST_RESTART_MS = 500;
23
+ const MAX_RESTART_MS = 30_000;
24
+ /** The Host bounds its own shutdown at 10 s (D118); this leaves room for the exit to arrive. */
25
+ const CLOSE_GRACE_MS = 15_000;
26
+ const READY_POLL_MS = 25;
27
+ /** How long a refused connection waits for the child's exit before it counts as unanswered. */
28
+ const EXIT_NOTICE_MS = 2_000;
29
+ /** Refusals a restart cannot fix: the same frame would be refused again. */
30
+ const RETRYABLE_START_CODES = new Set(["store_locked"]);
31
+ /** A failed start, with the child's exit when there was a child. */
32
+ class AttemptFailed extends Error {
33
+ error;
34
+ exit;
35
+ constructor(error, exit) {
36
+ super(error.message);
37
+ this.error = error;
38
+ this.exit = exit;
39
+ }
40
+ }
41
+ export class Rui extends EventEmitter {
42
+ #launch;
43
+ #runtimeDir;
44
+ #host;
45
+ /** The child of the latest start, ready or not. */
46
+ #child;
47
+ #waiters = [];
48
+ #backoff = FIRST_RESTART_MS;
49
+ #restart;
50
+ #closing;
51
+ #dead;
52
+ /** Local Session write notifications, shared by every handle. Internal. */
53
+ activity = new SessionNotifications();
54
+ /** Create and list Sessions. */
55
+ sessions = new Sessions(this);
56
+ constructor(launch, runtimeDir, host) {
57
+ super();
58
+ this.#launch = launch;
59
+ this.#runtimeDir = runtimeDir;
60
+ this.#adopt(host);
61
+ }
62
+ /** Starts Rui as a child of this process and resolves once it is ready. */
63
+ static async connect(options) {
64
+ const runtimeDir = await mkdtemp(join(tmpdir(), "rui-"));
65
+ try {
66
+ const launch = launchFor(options, runtimeDir);
67
+ const deadline = Date.now() + (options.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS);
68
+ for (;;) {
69
+ try {
70
+ return new Rui(launch, runtimeDir, await start(launch, () => { }));
71
+ }
72
+ catch (failure) {
73
+ const error = failure instanceof AttemptFailed ? failure.error : failure;
74
+ const remaining = deadline - Date.now();
75
+ if (!(error instanceof RuiStartError && error.code === "store_locked") || remaining <= 0)
76
+ throw error;
77
+ await sleep(Math.min(LOCK_RETRY_MS, remaining));
78
+ }
79
+ }
80
+ }
81
+ catch (error) {
82
+ await rm(runtimeDir, { recursive: true, force: true });
83
+ throw error;
84
+ }
85
+ }
86
+ /** A handle on an existing Session. Does no I/O: an unknown id fails on the first call (D16). */
87
+ getSession(id) {
88
+ return new Session(this, id);
89
+ }
90
+ /** The models each provider offers. */
91
+ models() {
92
+ return models(this);
93
+ }
94
+ /** Holds every new Turn until `resume`. Running work continues. */
95
+ pause() {
96
+ return setPaused(this, true);
97
+ }
98
+ resume() {
99
+ return setPaused(this, false);
100
+ }
101
+ /**
102
+ * Closes the child's stdin and resolves once it has exited, bounded by the
103
+ * Host's 10 s shutdown (D129). Later calls fail with RuiUnavailableError.
104
+ */
105
+ close() {
106
+ this.#closing ??= this.#close();
107
+ return this.#closing;
108
+ }
109
+ async #close() {
110
+ clearTimeout(this.#restart);
111
+ this.#fail(new RuiUnavailableError("the Rui client is closed"));
112
+ const child = this.#child;
113
+ this.#host = undefined;
114
+ if (child && child.exitCode === null && child.signalCode === null) {
115
+ const exited = exitOf(child);
116
+ child.stdin?.end();
117
+ const timer = setTimeout(() => child.kill("SIGKILL"), CLOSE_GRACE_MS);
118
+ const exit = await exited;
119
+ clearTimeout(timer);
120
+ this.emit("exit", { ...exit, restarting: false });
121
+ }
122
+ await rm(this.#runtimeDir, { recursive: true, force: true });
123
+ }
124
+ /**
125
+ * Sends one request to the ready Host. A call that finds the child gone
126
+ * waits for the restarted Host and is sent once more. Internal: the
127
+ * package's calls are built on it.
128
+ */
129
+ async call(path, body, environment, limits) {
130
+ let retried = false;
131
+ for (;;) {
132
+ const host = await this.#ready();
133
+ try {
134
+ return await post({
135
+ socketPath: this.#launch.socketPath,
136
+ path,
137
+ body,
138
+ instance: host.instance,
139
+ ...(environment ? { environment } : {}),
140
+ ...(limits ? { limits } : {}),
141
+ });
142
+ }
143
+ catch (error) {
144
+ if (!(error instanceof ConnectionError))
145
+ throw error;
146
+ const gone = await Promise.race([host.exited.then(() => true), sleep(EXIT_NOTICE_MS, false)]);
147
+ if (!gone || retried)
148
+ throw new RuiUnavailableError("the Rui Host did not answer", { cause: error });
149
+ retried = true;
150
+ }
151
+ }
152
+ }
153
+ /**
154
+ * Sends one request built for the Host's Store. `build` receives the
155
+ * canonical Store name the Host reported at readiness. Internal: the
156
+ * package's calls are built on it.
157
+ */
158
+ async exchange(path, build, environment, limits) {
159
+ const host = await this.#ready();
160
+ return this.call(path, build(host.info.store), environment, limits);
161
+ }
162
+ /** The running child's process id. Internal. */
163
+ get pid() {
164
+ return this.#host?.child.pid;
165
+ }
166
+ /** The latest host-info reply. Internal. */
167
+ get hostInfo() {
168
+ return this.#host?.info;
169
+ }
170
+ #ready() {
171
+ if (this.#host)
172
+ return Promise.resolve(this.#host);
173
+ const failed = this.#dead ?? (this.#closing ? new RuiUnavailableError("the Rui client is closed") : undefined);
174
+ if (failed)
175
+ return Promise.reject(failed);
176
+ this.#restart?.ref();
177
+ return new Promise((resolve, reject) => this.#waiters.push({ resolve, reject }));
178
+ }
179
+ #adopt(host) {
180
+ this.#host = host;
181
+ this.#child = host.child;
182
+ this.#backoff = FIRST_RESTART_MS;
183
+ for (const waiter of this.#waiters.splice(0))
184
+ waiter.resolve(host);
185
+ void host.exited.then((exit) => {
186
+ if (this.#closing || this.#host !== host)
187
+ return;
188
+ this.#host = undefined;
189
+ this.emit("exit", { ...exit, restarting: true });
190
+ this.#scheduleRestart();
191
+ });
192
+ }
193
+ #fail(error) {
194
+ this.activity.fail(error);
195
+ for (const waiter of this.#waiters.splice(0))
196
+ waiter.reject(error);
197
+ }
198
+ #scheduleRestart() {
199
+ // Once close has begun nothing may start again, whoever asked (an exit listener may close).
200
+ if (this.#closing)
201
+ return;
202
+ const delay = this.#backoff;
203
+ this.#backoff = Math.min(this.#backoff * 2, MAX_RESTART_MS);
204
+ this.#restart = setTimeout(() => void this.#restartNow(), delay);
205
+ // An idle client does not keep the process alive; a call waiting for the restart does (see #ready).
206
+ if (this.#waiters.length === 0)
207
+ this.#restart.unref();
208
+ }
209
+ async #restartNow() {
210
+ try {
211
+ const host = await start(this.#launch, (child) => {
212
+ this.#child = child;
213
+ });
214
+ if (this.#closing) {
215
+ host.child.stdin?.end();
216
+ return;
217
+ }
218
+ this.#adopt(host);
219
+ }
220
+ catch (failure) {
221
+ if (this.#closing)
222
+ return;
223
+ const error = failure instanceof AttemptFailed ? failure.error : failure;
224
+ const exit = failure instanceof AttemptFailed ? failure.exit : undefined;
225
+ const retryable = error instanceof RuiUnavailableError || (error instanceof RuiStartError && RETRYABLE_START_CODES.has(error.code));
226
+ if (exit)
227
+ this.emit("exit", { ...exit, restarting: retryable });
228
+ this.#fail(new RuiUnavailableError(`the Rui Host could not restart: ${error.message}`, { cause: error }));
229
+ if (retryable)
230
+ return this.#scheduleRestart();
231
+ this.#dead = new RuiUnavailableError(`the Rui Host cannot restart: ${error.message}`, { cause: error });
232
+ }
233
+ }
234
+ }
235
+ export function launchFor(options, runtimeDir) {
236
+ // A URL is passed through; anything else is a SQLite Store directory.
237
+ const store = /^[a-z][a-z0-9+.-]*:\/\//i.test(options.database) ? options.database : resolve(options.database);
238
+ const frame = {
239
+ v: FRAME_VERSION,
240
+ store,
241
+ runtimeDir,
242
+ ...(options.activeCapacity === undefined ? {} : { activeCapacity: options.activeCapacity }),
243
+ ...(options.paused ? { paused: true } : {}),
244
+ ...(options.presets === undefined ? {} : {
245
+ presets: Object.fromEntries(Object.entries(options.presets).map(([name, spec]) => [name, presetFor(spec)])),
246
+ }),
247
+ provider: {
248
+ kind: "openai_key", baseUrl: options.provider.baseUrl, key: options.provider.key,
249
+ ...(options.provider.caFile === undefined ? {} : { caFile: options.provider.caFile }),
250
+ },
251
+ // Only the Host's fields are copied: it refuses unknown ones.
252
+ mcpServers: (options.tools ?? []).map(({ name, url, bearerToken, callTimeoutMs }) => ({ name, url, bearerToken, callTimeoutMs })),
253
+ };
254
+ // The child gets only what it needs to find programs and its home (D185).
255
+ const env = {};
256
+ for (const name of ["PATH", "HOME", "TMPDIR"]) {
257
+ const value = process.env[name];
258
+ if (value !== undefined)
259
+ env[name] = value;
260
+ }
261
+ return { binary: resolveBinary(options.binary), frame, socketPath: join(runtimeDir, SOCKET_NAME), store, env };
262
+ }
263
+ /** Presets and Session mutation requests use the same wire writer. */
264
+ function presetFor(spec) {
265
+ return agentSpec(spec);
266
+ }
267
+ /** Starts one child and resolves once it answers host-info, or throws AttemptFailed. */
268
+ async function start(launch, spawned) {
269
+ let child;
270
+ try {
271
+ child = spawn(launch.binary, ["host", "serve", "--launch-frame"], {
272
+ env: launch.env,
273
+ stdio: ["pipe", "pipe", "inherit"],
274
+ });
275
+ }
276
+ catch (error) {
277
+ throw new AttemptFailed(new RuiUnavailableError(`could not start ${launch.binary}`, { cause: error }), undefined);
278
+ }
279
+ spawned(child);
280
+ const stdout = [];
281
+ child.stdout?.on("data", (chunk) => stdout.push(chunk));
282
+ const outputEnded = new Promise((done) => child.stdout?.once("close", done));
283
+ const launchError = new Promise((done) => child.once("error", done));
284
+ const exited = exitOf(child);
285
+ // The child must not keep this process alive; its stdin closing is how it learns this process ended.
286
+ child.unref();
287
+ child.stdout?.unref();
288
+ child.stdin?.unref();
289
+ child.stdin?.on("error", () => { });
290
+ child.stdin?.write(encodeFrame(launch.frame));
291
+ let ended;
292
+ void exited.then((exit) => (ended ??= exit));
293
+ void launchError.then((error) => (ended ??= error));
294
+ while (ended === undefined) {
295
+ const info = await probe(launch).catch((error) => {
296
+ if (error instanceof ConnectionError)
297
+ return undefined;
298
+ child.kill("SIGKILL");
299
+ throw new AttemptFailed(error, undefined);
300
+ });
301
+ if (info)
302
+ return { child, instance: info.instance, info, exited };
303
+ await sleep(READY_POLL_MS);
304
+ }
305
+ if (ended instanceof Error) {
306
+ throw new AttemptFailed(new RuiUnavailableError(`could not start ${launch.binary}: ${ended.message}`, { cause: ended }), undefined);
307
+ }
308
+ // The refusal line is written before the exit; give the pipe a moment to deliver it.
309
+ await Promise.race([outputEnded, sleep(1_000)]);
310
+ const refusal = startError(Buffer.concat(stdout).toString("utf8"));
311
+ throw new AttemptFailed(refusal ?? new RuiUnavailableError(`rui exited before it was ready (code ${ended.code}, signal ${ended.signal})`), ended);
312
+ }
313
+ async function probe(launch) {
314
+ try {
315
+ await stat(launch.socketPath);
316
+ }
317
+ catch {
318
+ return undefined;
319
+ }
320
+ // The Host names a SQLite Store by its canonical path, which exists once the socket does.
321
+ let store = launch.store.startsWith("mysql://") ? "" : launch.store;
322
+ if (store !== "" && !store.includes("://")) {
323
+ try {
324
+ store = await realpath(store);
325
+ }
326
+ catch {
327
+ return undefined;
328
+ }
329
+ }
330
+ const body = JSON.stringify({
331
+ version: WIRE_VERSION,
332
+ kind: "host_info",
333
+ store,
334
+ });
335
+ const reply = await post({ socketPath: launch.socketPath, path: "/v1/host-info", body });
336
+ return JSON.parse(reply.toString("utf8"));
337
+ }
338
+ /** One length-prefixed JSON frame: a 4-byte big-endian length, then UTF-8 JSON (D130). */
339
+ export function encodeFrame(frame) {
340
+ const json = Buffer.from(JSON.stringify(frame));
341
+ const out = Buffer.alloc(4 + json.length);
342
+ out.writeUInt32BE(json.length, 0);
343
+ json.copy(out, 4);
344
+ json.fill(0);
345
+ return out;
346
+ }
347
+ /** The refusal a child wrote as its one stdout line, `{"rui_start_error":{code,message}}` (D211). */
348
+ export function startError(output) {
349
+ for (const line of output.split("\n").reverse()) {
350
+ if (!line.trim())
351
+ continue;
352
+ try {
353
+ const parsed = JSON.parse(line);
354
+ const refusal = parsed?.rui_start_error;
355
+ if (refusal && typeof refusal.code === "string") {
356
+ return new RuiStartError(refusal.code, typeof refusal.message === "string" ? refusal.message : refusal.code);
357
+ }
358
+ }
359
+ catch {
360
+ // Not the refusal line.
361
+ }
362
+ }
363
+ return undefined;
364
+ }
365
+ function exitOf(child) {
366
+ if (child.exitCode !== null || child.signalCode !== null) {
367
+ return Promise.resolve({ code: child.exitCode, signal: child.signalCode });
368
+ }
369
+ return new Promise((done) => child.once("exit", (code, signal) => done({ code, signal })));
370
+ }