@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.
- package/dist/channel-manager.d.ts +60 -0
- package/dist/channel-manager.js +106 -0
- package/dist/control-api.d.ts +188 -0
- package/dist/control-api.js +834 -0
- package/dist/control-web-dist.d.ts +6 -0
- package/dist/control-web-dist.js +63 -0
- package/dist/emulator-touch-ws.d.ts +8 -0
- package/dist/emulator-touch-ws.js +87 -0
- package/dist/server.d.ts +66 -0
- package/dist/server.js +224 -0
- package/dist/terminal-ws.d.ts +25 -0
- package/dist/terminal-ws.js +123 -0
- package/package.json +38 -0
|
@@ -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
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -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
|
+
}
|