@rynx-ai/server 0.1.0

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.
@@ -0,0 +1,6 @@
1
+ import type { Context } from "koa";
2
+ /** Absolute path to the built SPA dir, or null when it hasn't been built. */
3
+ export declare function resolveControlWebDist(): string | null;
4
+ /** Serve a static file from the SPA dir; missing non-asset paths fall back to
5
+ * index.html (client-side routing), missing assets return 404. */
6
+ export declare function sendSpaFile(ctx: Context, distDir: string): Promise<void>;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Locate + serve the built control-web SPA (`@rynx-ai/control-web/dist`). Resolving
3
+ * the package's own `package.json` is robust across pnpm symlinks; the daemon
4
+ * ships control-web as a dependency so the dist is present at runtime.
5
+ *
6
+ * A tiny hand-rolled static sender (no extra dep) covers the SPA's needs:
7
+ * `index.html` + hashed `assets/*`, with an index.html fallback for client paths.
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import { readFile } from "node:fs/promises";
11
+ import { createRequire } from "node:module";
12
+ import { dirname, extname, join, resolve } from "node:path";
13
+ const require = createRequire(import.meta.url);
14
+ const CONTENT_TYPES = {
15
+ ".html": "text/html; charset=utf-8",
16
+ ".js": "text/javascript; charset=utf-8",
17
+ ".css": "text/css; charset=utf-8",
18
+ ".svg": "image/svg+xml",
19
+ ".json": "application/json; charset=utf-8",
20
+ ".ico": "image/x-icon",
21
+ ".png": "image/png",
22
+ ".woff2": "font/woff2",
23
+ ".map": "application/json",
24
+ };
25
+ /** Absolute path to the built SPA dir, or null when it hasn't been built. */
26
+ export function resolveControlWebDist() {
27
+ try {
28
+ const pkgJson = require.resolve("@rynx-ai/control-web/package.json");
29
+ const dist = join(dirname(pkgJson), "dist");
30
+ return existsSync(join(dist, "index.html")) ? dist : null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ /** Serve a static file from the SPA dir; missing non-asset paths fall back to
37
+ * index.html (client-side routing), missing assets return 404. */
38
+ export async function sendSpaFile(ctx, distDir) {
39
+ const relative = ctx.path === "/" ? "index.html" : ctx.path.replace(/^\/+/, "");
40
+ const target = resolve(distDir, relative);
41
+ if (target !== distDir && !target.startsWith(distDir + "/")) {
42
+ ctx.status = 403;
43
+ return;
44
+ }
45
+ let filePath = target;
46
+ let ext = extname(filePath);
47
+ let data = await readFile(filePath).catch(() => null);
48
+ if (!data) {
49
+ if (ext) {
50
+ ctx.status = 404;
51
+ return;
52
+ }
53
+ filePath = join(distDir, "index.html");
54
+ ext = ".html";
55
+ data = await readFile(filePath).catch(() => null);
56
+ if (!data) {
57
+ ctx.status = 404;
58
+ return;
59
+ }
60
+ }
61
+ ctx.type = CONTENT_TYPES[ext] ?? "application/octet-stream";
62
+ ctx.body = data;
63
+ }
@@ -0,0 +1,8 @@
1
+ import type { Server } from "node:http";
2
+ import { WebSocketServer } from "ws";
3
+ import type { ControlEmulatorDeps } from "./control-api.js";
4
+ interface EmulatorTouchWsDeps {
5
+ emulator: ControlEmulatorDeps;
6
+ }
7
+ export declare function attachEmulatorTouchWs(server: Server, deps: EmulatorTouchWsDeps): WebSocketServer;
8
+ export {};
@@ -0,0 +1,87 @@
1
+ import { WebSocketServer } from "ws";
2
+ const EMULATOR_TOUCH_PATH = "/api/emulator/touch";
3
+ const WS_CLOSE_BAD_REQUEST = 4400;
4
+ const WS_CLOSE_UNAVAILABLE = 4404;
5
+ const WS_CLOSE_INTERNAL_ERROR = 4500;
6
+ function parseTouchPoint(data) {
7
+ try {
8
+ const parsed = JSON.parse(data.toString("utf8"));
9
+ if (parsed.type !== "begin" && parsed.type !== "move" && parsed.type !== "end")
10
+ return null;
11
+ if (!Number.isFinite(parsed.x) || !Number.isFinite(parsed.y))
12
+ return null;
13
+ return { type: parsed.type, x: parsed.x, y: parsed.y };
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ function sendText(ws, value) {
20
+ if (ws.readyState === ws.OPEN)
21
+ ws.send(JSON.stringify(value));
22
+ }
23
+ export function attachEmulatorTouchWs(server, deps) {
24
+ const wss = new WebSocketServer({ noServer: true });
25
+ server.on("upgrade", (req, socket, head) => {
26
+ let url;
27
+ try {
28
+ url = new URL(req.url ?? "", "http://localhost");
29
+ }
30
+ catch {
31
+ return;
32
+ }
33
+ if (url.pathname !== EMULATOR_TOUCH_PATH)
34
+ return;
35
+ wss.handleUpgrade(req, socket, head, (ws) => {
36
+ void handleConnection(ws, url, deps);
37
+ });
38
+ });
39
+ return wss;
40
+ }
41
+ async function handleConnection(ws, url, deps) {
42
+ if (!deps.emulator.liveTouch) {
43
+ sendText(ws, { t: "error", message: "emulator live touch API is not available on this server" });
44
+ ws.close(WS_CLOSE_UNAVAILABLE, "emulator live touch unavailable");
45
+ return;
46
+ }
47
+ const pending = [];
48
+ let controller = null;
49
+ let closed = false;
50
+ const close = () => {
51
+ closed = true;
52
+ controller?.close();
53
+ controller = null;
54
+ };
55
+ ws.on("message", (data, isBinary) => {
56
+ if (isBinary)
57
+ return;
58
+ const point = parseTouchPoint(data);
59
+ if (!point) {
60
+ sendText(ws, { t: "error", message: "invalid touch point" });
61
+ ws.close(WS_CLOSE_BAD_REQUEST, "invalid touch point");
62
+ return;
63
+ }
64
+ if (controller)
65
+ controller.send(point);
66
+ else
67
+ pending.push(point);
68
+ });
69
+ ws.on("close", close);
70
+ ws.on("error", close);
71
+ try {
72
+ controller = await deps.emulator.liveTouch({ device: url.searchParams.get("device") ?? undefined });
73
+ if (closed) {
74
+ controller.close();
75
+ controller = null;
76
+ return;
77
+ }
78
+ sendText(ws, { t: "opened", backend: controller.backend, device: controller.device });
79
+ for (const point of pending.splice(0))
80
+ controller.send(point);
81
+ }
82
+ catch (error) {
83
+ sendText(ws, { t: "error", message: error instanceof Error ? error.message : String(error) });
84
+ ws.close(WS_CLOSE_INTERNAL_ERROR, "emulator live touch error");
85
+ return;
86
+ }
87
+ }
@@ -0,0 +1,66 @@
1
+ import Koa from "koa";
2
+ import { ConversationRuntime, type AppConfig, type ChannelFactory, type ChannelInstanceDescriptor, type SessionBus, type SessionLogStore, type SessionRegistry } from "@rynx-ai/core";
3
+ import { RunnerManager, type CodexSessionStore } from "@rynx-ai/runtime";
4
+ import { ChannelManager } from "./channel-manager.js";
5
+ import { type ControlPlaneDeps } from "./control-api.js";
6
+ export { ChannelManager } from "./channel-manager.js";
7
+ export type { ChannelInstanceStatus } from "./channel-manager.js";
8
+ export type { ControlPlaneDeps, StoredSessionMeta } from "./control-api.js";
9
+ export type { ControlChannel, ControlChannelType, ControlInstanceConfig, ControlInstanceStatus, ControlAgentSummary, } from "@rynx-ai/protocol/control";
10
+ export interface CreateAppOptions {
11
+ config?: AppConfig;
12
+ /** When present, mount the control plane (page + JSON API) beside `/health`.
13
+ * `runtime`/`sessionBus`/`sessionLog`/`runnerManager` (from `startServer`'s
14
+ * scope) enable the channel-agnostic `/api/sessions` agent-run endpoints. */
15
+ control?: {
16
+ manager: ChannelManager;
17
+ deps: ControlPlaneDeps;
18
+ runtime?: ConversationRuntime;
19
+ sessionBus?: SessionBus;
20
+ sessionLog?: SessionLogStore;
21
+ runnerManager?: RunnerManager;
22
+ sessionStore?: CodexSessionStore;
23
+ };
24
+ }
25
+ export interface StartServerOptions {
26
+ config?: AppConfig;
27
+ /**
28
+ * Legacy: channel plugins to mount as one shared-context batch. Kept for
29
+ * back-compat (and tests); the daemon now uses {@link loadInstances} so each
30
+ * instance gets its own context. Empty ⇒ a pure `/health` server.
31
+ */
32
+ channelFactories?: ChannelFactory[];
33
+ /** Static set of channel instances to mount, each with its own context. */
34
+ channelInstances?: ChannelInstanceDescriptor[];
35
+ /**
36
+ * Dynamic source of channel instance descriptors, re-read on reloads. When
37
+ * provided, the server mounts them via a {@link ChannelManager} (the seam the
38
+ * control plane drives). Takes precedence over `channelInstances`.
39
+ */
40
+ loadInstances?: () => Promise<ChannelInstanceDescriptor[]>;
41
+ /**
42
+ * Control-plane deps (config + agent-spec mutators + log tail) injected by the
43
+ * daemon. When present (and instances are managed), the server serves the
44
+ * control page + JSON API. Requires `loadInstances`/`channelInstances`.
45
+ */
46
+ control?: ControlPlaneDeps;
47
+ /**
48
+ * Durable canonical session log (the daemon injects the SQLite-backed store).
49
+ * Absent ⇒ channels render from the live stream without persisting.
50
+ */
51
+ sessionLog?: SessionLogStore;
52
+ /**
53
+ * Unified machine-session registry (the daemon injects the SQLite-backed store
54
+ * over `sessions`). Handed to mounted channels so they register an identity row
55
+ * per session; the control plane lists every session uniformly from it.
56
+ */
57
+ sessionRegistry?: SessionRegistry;
58
+ }
59
+ /**
60
+ * The HTTP surface is just a liveness probe for container orchestration — all
61
+ * agent capabilities are forwarded through the mounted channel plugins (the
62
+ * Lark bot reaches its server over an outbound WebSocket long connection), so
63
+ * no agent request / response / SSE endpoints are exposed here.
64
+ */
65
+ export declare function createApp({ control }?: CreateAppOptions): Koa<Koa.DefaultState, Koa.DefaultContext>;
66
+ export declare function startServer({ config, channelFactories, channelInstances, loadInstances, control, sessionLog, sessionRegistry, }?: StartServerOptions): Promise<import("node:http").Server<typeof import("node:http").IncomingMessage, typeof import("node:http").ServerResponse>>;
package/dist/server.js ADDED
@@ -0,0 +1,224 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import Koa from "koa";
3
+ import Router from "@koa/router";
4
+ import { loadConfig, resolveRuntimeModel, ConversationRuntime, InMemorySessionBus, persistSessionEvent, } from "@rynx-ai/core";
5
+ import { RunnerManager, FileCodexSessionStore, resolveCodexSessionStorePath, } from "@rynx-ai/runtime";
6
+ import { ChannelManager } from "./channel-manager.js";
7
+ import { createControlRouter } from "./control-api.js";
8
+ import { attachEmulatorTouchWs } from "./emulator-touch-ws.js";
9
+ import { attachTerminalWs } from "./terminal-ws.js";
10
+ import { resolveControlWebDist, sendSpaFile } from "./control-web-dist.js";
11
+ export { ChannelManager } from "./channel-manager.js";
12
+ function toErrorMessage(error) {
13
+ if (error instanceof Error) {
14
+ return error.message;
15
+ }
16
+ return "Unknown error";
17
+ }
18
+ /**
19
+ * The HTTP surface is just a liveness probe for container orchestration — all
20
+ * agent capabilities are forwarded through the mounted channel plugins (the
21
+ * Lark bot reaches its server over an outbound WebSocket long connection), so
22
+ * no agent request / response / SSE endpoints are exposed here.
23
+ */
24
+ export function createApp({ control } = {}) {
25
+ const app = new Koa();
26
+ const router = new Router();
27
+ router.get("/health", (ctx) => {
28
+ ctx.body = { ok: true };
29
+ });
30
+ app.use(router.routes());
31
+ app.use(router.allowedMethods());
32
+ if (control) {
33
+ const controlRouter = createControlRouter(control);
34
+ app.use(controlRouter.routes());
35
+ app.use(controlRouter.allowedMethods());
36
+ // Serve the built control-web SPA for everything that isn't /health or /api.
37
+ const distDir = resolveControlWebDist();
38
+ app.use(async (ctx, next) => {
39
+ if (ctx.method !== "GET" && ctx.method !== "HEAD")
40
+ return next();
41
+ if (ctx.path === "/health" || ctx.path.startsWith("/api/"))
42
+ return next();
43
+ if (!distDir) {
44
+ if (ctx.path === "/") {
45
+ ctx.type = "html";
46
+ ctx.body =
47
+ "<!doctype html><meta charset=utf-8><body style='font:14px system-ui;padding:2rem'>" +
48
+ "<h1>rynx control</h1><p>The web console isn't built yet. Run " +
49
+ "<code>pnpm --filter @rynx-ai/control-web build</code> and restart.</p>";
50
+ }
51
+ return next();
52
+ }
53
+ await sendSpaFile(ctx, distDir);
54
+ });
55
+ }
56
+ return app;
57
+ }
58
+ export async function startServer({ config = loadConfig(), channelFactories = [], channelInstances, loadInstances, control, sessionLog, sessionRegistry, } = {}) {
59
+ // One shared session store, owned here and handed to the channels via the
60
+ // ChannelContext (mirror index / active-session reads). Runner children open
61
+ // the same on-disk store for their session bindings.
62
+ const sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config));
63
+ // The executor + capability surface. Holds no execution backend itself: it
64
+ // spawns a runner child per session for turns, forwards per-thread
65
+ // capabilities to a runner, and answers status/model-list locally.
66
+ const runnerManager = new RunnerManager({
67
+ config,
68
+ sessionStore,
69
+ // Give runner children the control-plane URL so a claude-native
70
+ // PermissionRequest hook subprocess can POST approvals back to this daemon.
71
+ childEnv: {
72
+ RYNX_CONTROL_URL: `http://127.0.0.1:${config.PORT}`,
73
+ },
74
+ });
75
+ // One canonical event bus shared by every mounted instance, so a future
76
+ // observer (web console) sees every channel's turns over one fan-out.
77
+ const sessionBus = new InMemorySessionBus();
78
+ const conversationRuntime = new ConversationRuntime({
79
+ config,
80
+ executor: runnerManager,
81
+ // The live co-drive path (`runLive`) reads a turn's mirrored events off this bus.
82
+ sessionBus,
83
+ });
84
+ // codex-native single-writer: a session's persistent forwarder mirrors EVERY
85
+ // turn (web- AND co-driving-TUI-initiated) as canonical SessionEvents; persist
86
+ // + publish each so the web console's /stream renders them uniformly.
87
+ runnerManager.onMirror((sessionId, event) => {
88
+ void persistSessionEvent(sessionId, event, { sessionLog, sessionBus });
89
+ });
90
+ // claude `/clear`·`/fork` rotates to a fresh machine-session; record its meta so
91
+ // the console list shows it with the carried-over agent/model + a source tag.
92
+ // (It also surfaces via the log-merge once its first turn mirrors.)
93
+ runnerManager.onRotate((r) => {
94
+ control?.createSessionMeta({
95
+ id: r.to,
96
+ source: r.kind === "fork" ? "fork" : "clear",
97
+ ...(r.agent ? { agent: r.agent } : {}),
98
+ ...(r.model ? { model: r.model } : {}),
99
+ createdAt: new Date().toISOString(),
100
+ });
101
+ });
102
+ // New path: per-instance contexts via the ChannelManager (the daemon supplies
103
+ // `loadInstances`; the control plane drives live start/stop/reload).
104
+ const resolveInstances = loadInstances ?? (channelInstances ? async () => channelInstances : null);
105
+ if (resolveInstances) {
106
+ const manager = new ChannelManager({
107
+ config,
108
+ conversationRuntime,
109
+ capabilities: runnerManager,
110
+ sessionStore,
111
+ sessionLog,
112
+ sessionBus,
113
+ sessionRegistry,
114
+ loadInstances: resolveInstances,
115
+ });
116
+ const app = createApp({
117
+ config,
118
+ control: control
119
+ ? {
120
+ manager,
121
+ deps: control,
122
+ runtime: conversationRuntime,
123
+ sessionBus,
124
+ sessionLog,
125
+ runnerManager,
126
+ sessionStore,
127
+ }
128
+ : undefined,
129
+ });
130
+ return new Promise((resolve) => {
131
+ const server = app.listen(config.PORT, config.HOST, () => {
132
+ console.log(JSON.stringify({
133
+ level: config.LOG_LEVEL,
134
+ msg: "Harness agent server listening",
135
+ host: config.HOST,
136
+ port: config.PORT,
137
+ runtime: config.AGENT_RUNTIME,
138
+ model: resolveRuntimeModel(config, config.AGENT_RUNTIME),
139
+ }));
140
+ void manager.startAll();
141
+ // Live WS bridges share this http.Server (no second port).
142
+ attachTerminalWs(server, { runnerManager });
143
+ if (control?.emulator)
144
+ attachEmulatorTouchWs(server, { emulator: control.emulator });
145
+ server.on("close", () => {
146
+ void manager.stopAll();
147
+ void runnerManager.stop();
148
+ });
149
+ resolve(server);
150
+ });
151
+ });
152
+ }
153
+ // Legacy path: one shared context for all factories (unchanged behavior).
154
+ const channelContext = {
155
+ config,
156
+ conversationRuntime,
157
+ capabilities: runnerManager,
158
+ sessionStore,
159
+ };
160
+ // Construct each channel defensively: a plugin that throws while building
161
+ // (e.g. Lark with missing credentials) is isolated and logged, so one broken
162
+ // channel can't take down the host or its sibling channels.
163
+ const channels = [];
164
+ for (const factory of channelFactories) {
165
+ try {
166
+ channels.push(factory(channelContext));
167
+ }
168
+ catch (error) {
169
+ console.error(JSON.stringify({
170
+ level: "error",
171
+ type: "channel",
172
+ event: "construct_failed",
173
+ error: toErrorMessage(error),
174
+ }));
175
+ }
176
+ }
177
+ const app = createApp({ config });
178
+ return new Promise((resolve) => {
179
+ const server = app.listen(config.PORT, config.HOST, () => {
180
+ console.log(JSON.stringify({
181
+ level: config.LOG_LEVEL,
182
+ msg: "Harness agent server listening",
183
+ host: config.HOST,
184
+ port: config.PORT,
185
+ runtime: config.AGENT_RUNTIME,
186
+ model: resolveRuntimeModel(config, config.AGENT_RUNTIME),
187
+ channels: channels.map((channel) => channel.id),
188
+ }));
189
+ for (const channel of channels) {
190
+ void channel.start().catch((error) => {
191
+ console.error(JSON.stringify({
192
+ level: "error",
193
+ type: "channel",
194
+ channel: channel.id,
195
+ event: "runtime.start_failed",
196
+ error: toErrorMessage(error),
197
+ }));
198
+ });
199
+ }
200
+ server.on("close", () => {
201
+ void runnerManager.stop();
202
+ for (const channel of channels) {
203
+ void channel.stop().catch((error) => {
204
+ console.error(JSON.stringify({
205
+ level: "error",
206
+ type: "channel",
207
+ channel: channel.id,
208
+ event: "runtime.stop_failed",
209
+ error: toErrorMessage(error),
210
+ }));
211
+ });
212
+ }
213
+ });
214
+ resolve(server);
215
+ });
216
+ });
217
+ }
218
+ const entrypoint = process.argv[1];
219
+ if (entrypoint && import.meta.url === pathToFileURL(entrypoint).href) {
220
+ startServer().catch((error) => {
221
+ console.error(error);
222
+ process.exitCode = 1;
223
+ });
224
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * WebSocket bridge for the live terminal (Phase C). Attaches to the existing
3
+ * daemon `http.Server` via the `upgrade` event — no second port — and tunnels a
4
+ * browser xterm session to a per-session terminal on the runner child through
5
+ * {@link RunnerManager.openTerminal}.
6
+ *
7
+ * Wire protocol (mirrors omnigent's terminal attach):
8
+ * - server → browser: BINARY frames = raw PTY output; TEXT frames = JSON
9
+ * control (`{t:"opened",role}`, `{t:"exit"}`, `{t:"error",message}`).
10
+ * - browser → server: BINARY frames = keystrokes; TEXT frames = JSON control
11
+ * (`{t:"resize",cols,rows}` / `{t:"input",data}`).
12
+ */
13
+ import type { Server } from "node:http";
14
+ import type { RunnerManager } from "@rynx-ai/runtime";
15
+ import { WebSocketServer } from "ws";
16
+ export interface TerminalWsDeps {
17
+ runnerManager: RunnerManager;
18
+ /** Resolve a session's terminal working directory (sync). Defaults to cwd. */
19
+ resolveCwd?: (sessionId: string) => string | undefined;
20
+ /** Inner command for the pane; defaults child-side to the login shell. */
21
+ command?: string;
22
+ args?: string[];
23
+ }
24
+ /** Attach the terminal WS upgrade handler to a running http.Server. */
25
+ export declare function attachTerminalWs(server: Server, deps: TerminalWsDeps): WebSocketServer;
@@ -0,0 +1,123 @@
1
+ import { WebSocketServer } from "ws";
2
+ const TERMINAL_PATH = /^\/api\/sessions\/([^/]+)\/terminal$/;
3
+ const MAX_DIM = 1000;
4
+ // PTY-output → WS coalescing, ported from omnigent's `_forward_pty_to_ws`
5
+ // (ws_bridge.py): merge PTY chunks that arrive in the same tick into bounded
6
+ // frames so a screen redraw is a few frames, not dozens. Cap each frame at
7
+ // 64 KiB normally; drop to 2 KiB within 750 ms of user input so a post-keystroke
8
+ // redraw stays in the browser terminal's synchronous-echo fast path (xterm falls
9
+ // back to a laggy async write for frames over ~2 KiB). Oversize accumulations are
10
+ // split across frames. Constants match omnigent's exactly.
11
+ const WS_COALESCE_MAX_BYTES = 64 * 1024;
12
+ const INTERACTIVE_WS_COALESCE_MAX_BYTES = 2048;
13
+ const INTERACTIVE_ECHO_WINDOW_MS = 750;
14
+ // Application WebSocket close codes, matching omnigent (ws_bridge.py). 4404 = the
15
+ // pane's inner process exited / session gone; 4500 = bridge could not open the
16
+ // terminal. The frontend's isUnexpectedTerminalClose auto-reconnects only on
17
+ // transport codes (1006 etc.), so these 4xxx codes correctly land on the
18
+ // dead-pane overlay instead of a reconnect loop. rynx's server-initiated close is
19
+ // always "gone" (a client *detach* is client-initiated and never reaches here),
20
+ // so omnigent's 4405 "detached" has no rynx counterpart.
21
+ const WS_CLOSE_TERMINAL_GONE = 4404;
22
+ const WS_CLOSE_INTERNAL_ERROR = 4500;
23
+ /** Attach the terminal WS upgrade handler to a running http.Server. */
24
+ export function attachTerminalWs(server, deps) {
25
+ const wss = new WebSocketServer({ noServer: true });
26
+ server.on("upgrade", (req, socket, head) => {
27
+ let url;
28
+ try {
29
+ url = new URL(req.url ?? "", "http://localhost");
30
+ }
31
+ catch {
32
+ return;
33
+ }
34
+ const match = TERMINAL_PATH.exec(url.pathname);
35
+ if (!match)
36
+ return; // Not our route — leave it for any other upgrade handler.
37
+ wss.handleUpgrade(req, socket, head, (ws) => {
38
+ handleConnection(ws, url, decodeURIComponent(match[1]), deps);
39
+ });
40
+ });
41
+ return wss;
42
+ }
43
+ function clampDim(raw, fallback) {
44
+ const n = raw ? Number.parseInt(raw, 10) : Number.NaN;
45
+ return Number.isFinite(n) && n > 0 && n <= MAX_DIM ? n : fallback;
46
+ }
47
+ function handleConnection(ws, url, sessionId, deps) {
48
+ const role = url.searchParams.get("role") === "read-only" ? "read-only" : "owner";
49
+ const cols = clampDim(url.searchParams.get("cols"), 80);
50
+ const rows = clampDim(url.searchParams.get("rows"), 24);
51
+ const cwd = deps.resolveCwd?.(sessionId) ?? process.cwd();
52
+ const term = deps.runnerManager.openTerminal(sessionId, {
53
+ role,
54
+ cwd,
55
+ cols,
56
+ rows,
57
+ ...(deps.command ? { command: deps.command } : {}),
58
+ ...(deps.args ? { args: deps.args } : {}),
59
+ });
60
+ const sendText = (obj) => {
61
+ if (ws.readyState === ws.OPEN)
62
+ ws.send(JSON.stringify(obj));
63
+ };
64
+ // Monotonic-ish stamp of the last client keystroke, so a redraw right after
65
+ // input takes the smaller interactive frame cap (omnigent `last_client_input_at`).
66
+ let lastClientInputAt = 0;
67
+ // Chunks buffered within the current tick, flushed coalesced on the next.
68
+ let pending = [];
69
+ let flushScheduled = false;
70
+ const flushOutput = () => {
71
+ flushScheduled = false;
72
+ if (ws.readyState !== ws.OPEN || pending.length === 0) {
73
+ pending = [];
74
+ return;
75
+ }
76
+ const merged = pending.length === 1 ? pending[0] : Buffer.concat(pending);
77
+ pending = [];
78
+ const limit = Date.now() - lastClientInputAt < INTERACTIVE_ECHO_WINDOW_MS
79
+ ? INTERACTIVE_WS_COALESCE_MAX_BYTES
80
+ : WS_COALESCE_MAX_BYTES;
81
+ for (let off = 0; off < merged.length; off += limit) {
82
+ ws.send(merged.subarray(off, Math.min(off + limit, merged.length)));
83
+ }
84
+ };
85
+ term.onData((chunk) => {
86
+ pending.push(Buffer.from(chunk, "utf8"));
87
+ if (!flushScheduled) {
88
+ flushScheduled = true;
89
+ setImmediate(flushOutput);
90
+ }
91
+ });
92
+ term.onExit(() => {
93
+ flushOutput(); // drain the pane's final bytes before the close frame
94
+ sendText({ t: "exit" });
95
+ ws.close(WS_CLOSE_TERMINAL_GONE, "terminal session ended");
96
+ });
97
+ term.ready.then((opened) => sendText({ t: "opened", role: opened.role }), (error) => {
98
+ sendText({ t: "error", message: error instanceof Error ? error.message : String(error) });
99
+ ws.close(WS_CLOSE_INTERNAL_ERROR, "terminal bridge error");
100
+ });
101
+ ws.on("message", (data, isBinary) => {
102
+ if (isBinary) {
103
+ lastClientInputAt = Date.now();
104
+ term.write(data.toString("utf8"));
105
+ return;
106
+ }
107
+ try {
108
+ const msg = JSON.parse(data.toString("utf8"));
109
+ if (msg.t === "resize" && typeof msg.cols === "number" && typeof msg.rows === "number") {
110
+ term.resize(msg.cols, msg.rows);
111
+ }
112
+ else if (msg.t === "input" && typeof msg.data === "string") {
113
+ lastClientInputAt = Date.now();
114
+ term.write(msg.data);
115
+ }
116
+ }
117
+ catch {
118
+ // Ignore malformed control frames.
119
+ }
120
+ });
121
+ ws.on("close", () => term.close());
122
+ ws.on("error", () => term.close());
123
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@rynx-ai/server",
3
+ "version": "0.1.0",
4
+ "description": "Composition root: wires the @rynx-ai/runtime host to channel plugins (injected by the daemon) and exposes a liveness HTTP probe.",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "registry": "https://registry.npmjs.org/",
8
+ "access": "public"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "main": "./dist/server.js",
14
+ "types": "./dist/server.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/server.d.ts",
18
+ "default": "./dist/server.js"
19
+ }
20
+ },
21
+ "dependencies": {
22
+ "@koa/router": "^15.4.0",
23
+ "koa": "^3.2.0",
24
+ "ws": "^8.21.0",
25
+ "@rynx-ai/control-web": "0.1.0",
26
+ "@rynx-ai/runtime": "0.1.0",
27
+ "@rynx-ai/core": "0.1.0",
28
+ "@rynx-ai/protocol": "0.1.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/ws": "^8.18.1"
32
+ },
33
+ "scripts": {
34
+ "build": "rm -rf dist && tsc -p tsconfig.json",
35
+ "start": "node dist/server.js",
36
+ "dev": "tsx watch --tsconfig ../../tsconfig.json src/server.ts"
37
+ }
38
+ }