@rynx-ai/runtime 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/claude/executor.d.ts +17 -0
- package/dist/claude/executor.js +28 -0
- package/dist/claude/models.d.ts +10 -0
- package/dist/claude/models.js +33 -0
- package/dist/claude/native-bridge.d.ts +133 -0
- package/dist/claude/native-bridge.js +299 -0
- package/dist/claude/native-hook-main.d.ts +2 -0
- package/dist/claude/native-hook-main.js +74 -0
- package/dist/claude/native-hooks.d.ts +41 -0
- package/dist/claude/native-hooks.js +73 -0
- package/dist/claude/native-integration.d.ts +213 -0
- package/dist/claude/native-integration.js +665 -0
- package/dist/claude/native-message-display-main.d.ts +2 -0
- package/dist/claude/native-message-display-main.js +51 -0
- package/dist/claude/native-status-main.d.ts +2 -0
- package/dist/claude/native-status-main.js +105 -0
- package/dist/claude/status.d.ts +23 -0
- package/dist/claude/status.js +118 -0
- package/dist/claude/transcript.d.ts +79 -0
- package/dist/claude/transcript.js +272 -0
- package/dist/claude/trust.d.ts +6 -0
- package/dist/claude/trust.js +85 -0
- package/dist/codex/rollout-synth.d.ts +37 -0
- package/dist/codex/rollout-synth.js +212 -0
- package/dist/codex-app-server/client.d.ts +138 -0
- package/dist/codex-app-server/client.js +341 -0
- package/dist/codex-app-server/forwarder.d.ts +92 -0
- package/dist/codex-app-server/forwarder.js +188 -0
- package/dist/codex-app-server/mapping.d.ts +19 -0
- package/dist/codex-app-server/mapping.js +189 -0
- package/dist/codex-app-server/protocol.d.ts +472 -0
- package/dist/codex-app-server/protocol.js +12 -0
- package/dist/codex-app-server/transport.d.ts +139 -0
- package/dist/codex-app-server/transport.js +422 -0
- package/dist/codex-app-server/ws-channel.d.ts +72 -0
- package/dist/codex-app-server/ws-channel.js +233 -0
- package/dist/codex-child-env.d.ts +1 -0
- package/dist/codex-child-env.js +27 -0
- package/dist/codex-home.d.ts +47 -0
- package/dist/codex-home.js +135 -0
- package/dist/codex-session-store.d.ts +42 -0
- package/dist/codex-session-store.js +126 -0
- package/dist/host.d.ts +324 -0
- package/dist/host.js +1323 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/models-catalog.d.ts +18 -0
- package/dist/models-catalog.js +27 -0
- package/dist/runner/child.d.ts +58 -0
- package/dist/runner/child.js +268 -0
- package/dist/runner/manager.d.ts +175 -0
- package/dist/runner/manager.js +458 -0
- package/dist/runner/protocol.d.ts +195 -0
- package/dist/runner/protocol.js +41 -0
- package/dist/runner/transport.d.ts +36 -0
- package/dist/runner/transport.js +72 -0
- package/dist/runner-main.d.ts +2 -0
- package/dist/runner-main.js +61 -0
- package/dist/runtime-status.d.ts +16 -0
- package/dist/runtime-status.js +80 -0
- package/dist/terminal/claude-tui.d.ts +27 -0
- package/dist/terminal/claude-tui.js +13 -0
- package/dist/terminal/codex-tui.d.ts +54 -0
- package/dist/terminal/codex-tui.js +26 -0
- package/dist/terminal/registry.d.ts +42 -0
- package/dist/terminal/registry.js +70 -0
- package/dist/terminal/tmux.d.ts +150 -0
- package/dist/terminal/tmux.js +364 -0
- package/package.json +32 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parent-side runner orchestrator. Replaces {@link LocalAgentHost} at the
|
|
3
|
+
* composition root as the live co-drive + capability surface, but holds **no
|
|
4
|
+
* execution backend** of its own: it spawns one runner child per session
|
|
5
|
+
* (`localThreadId`) that brings up the session's live forwarder + vendor TUI,
|
|
6
|
+
* forwards per-thread capabilities to a shared capability runner, and answers
|
|
7
|
+
* status / model-list locally without a backend.
|
|
8
|
+
*
|
|
9
|
+
* The process boundary carries no live objects: the parent drives a session over
|
|
10
|
+
* the runner transport (`live.ensure` / `inject` / `live.interrupt` + terminal
|
|
11
|
+
* attach), and the child mirrors that session's canonical events back as
|
|
12
|
+
* {@link SessionEvent}s.
|
|
13
|
+
*
|
|
14
|
+
* Today the only transport is local stdio (a spawned child); a remote tunnel
|
|
15
|
+
* transport drops in here later without touching `ConversationRuntime` or any
|
|
16
|
+
* channel.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import readline from "node:readline";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { AgentRuntimeError, } from "@rynx-ai/core";
|
|
23
|
+
import { listRuntimeModels } from "../models-catalog.js";
|
|
24
|
+
import { probeRuntimeStatus } from "../runtime-status.js";
|
|
25
|
+
import { fromWireError, } from "./protocol.js";
|
|
26
|
+
import { StdioRunnerTransport } from "./transport.js";
|
|
27
|
+
/** Routing key for the shared capability runner (slash-command RPCs). */
|
|
28
|
+
const CAP_KEY = "__cap__";
|
|
29
|
+
/** Max stderr lines retained per handle for the crash exit-report tail. */
|
|
30
|
+
const STDERR_TAIL_LINES = 40;
|
|
31
|
+
class ManagedTerminal {
|
|
32
|
+
attachId;
|
|
33
|
+
sendMsg;
|
|
34
|
+
ready;
|
|
35
|
+
dataListener;
|
|
36
|
+
exitListener;
|
|
37
|
+
resolveReady;
|
|
38
|
+
rejectReady;
|
|
39
|
+
constructor(attachId, sendMsg) {
|
|
40
|
+
this.attachId = attachId;
|
|
41
|
+
this.sendMsg = sendMsg;
|
|
42
|
+
this.ready = new Promise((resolve, reject) => {
|
|
43
|
+
this.resolveReady = resolve;
|
|
44
|
+
this.rejectReady = reject;
|
|
45
|
+
});
|
|
46
|
+
// Never surface an unhandled rejection if no caller awaits `ready`.
|
|
47
|
+
void this.ready.catch(() => undefined);
|
|
48
|
+
}
|
|
49
|
+
onData(listener) {
|
|
50
|
+
this.dataListener = listener;
|
|
51
|
+
}
|
|
52
|
+
onExit(listener) {
|
|
53
|
+
this.exitListener = listener;
|
|
54
|
+
}
|
|
55
|
+
write(data) {
|
|
56
|
+
this.sendMsg({ t: "term.input", attachId: this.attachId, dataB64: Buffer.from(data, "utf8").toString("base64") });
|
|
57
|
+
}
|
|
58
|
+
resize(cols, rows) {
|
|
59
|
+
this.sendMsg({ t: "term.resize", attachId: this.attachId, cols, rows });
|
|
60
|
+
}
|
|
61
|
+
close() {
|
|
62
|
+
this.sendMsg({ t: "term.close", attachId: this.attachId });
|
|
63
|
+
}
|
|
64
|
+
// ── internal (driven by onChildMessage) ──
|
|
65
|
+
_opened(role) {
|
|
66
|
+
this.resolveReady({ role });
|
|
67
|
+
}
|
|
68
|
+
_data(dataB64) {
|
|
69
|
+
this.dataListener?.(Buffer.from(dataB64, "base64").toString("utf8"));
|
|
70
|
+
}
|
|
71
|
+
_exit(exitCode) {
|
|
72
|
+
this.exitListener?.({ exitCode });
|
|
73
|
+
}
|
|
74
|
+
_fail(message) {
|
|
75
|
+
this.rejectReady(new Error(message));
|
|
76
|
+
this.exitListener?.({ exitCode: 1 });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export class RunnerManager {
|
|
80
|
+
config;
|
|
81
|
+
sessionStore;
|
|
82
|
+
runnerEntry;
|
|
83
|
+
idleTtlMs;
|
|
84
|
+
spawn;
|
|
85
|
+
childEnv;
|
|
86
|
+
now;
|
|
87
|
+
defaultRuntime;
|
|
88
|
+
handles = new Map();
|
|
89
|
+
reapTimer;
|
|
90
|
+
/** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
|
|
91
|
+
mirrorListener = null;
|
|
92
|
+
/** Sink for session rotations (claude `/clear`·`/fork`) — server records meta. */
|
|
93
|
+
rotateListener = null;
|
|
94
|
+
/** Session keys with a live codex forwarder — never reaped while present. */
|
|
95
|
+
liveSessionKeys = new Set();
|
|
96
|
+
constructor(opts) {
|
|
97
|
+
this.config = opts.config;
|
|
98
|
+
this.sessionStore = opts.sessionStore;
|
|
99
|
+
this.runnerEntry = opts.runnerEntry ?? defaultRunnerEntry();
|
|
100
|
+
this.idleTtlMs = opts.idleTtlMs ?? 300_000;
|
|
101
|
+
this.spawn = opts.spawn ?? nodeSpawn;
|
|
102
|
+
this.childEnv = opts.childEnv ?? {};
|
|
103
|
+
this.now = opts.now ?? (() => Date.now());
|
|
104
|
+
this.defaultRuntime = opts.config.AGENT_RUNTIME ?? "codex";
|
|
105
|
+
const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
|
|
106
|
+
if (reapIntervalMs > 0) {
|
|
107
|
+
this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
|
|
108
|
+
this.reapTimer.unref?.();
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
this.reapTimer = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// ── Live terminal ───────────────────────────────────────────────────────────
|
|
115
|
+
/**
|
|
116
|
+
* Open a live terminal on the session's runner child (spawning it if needed).
|
|
117
|
+
* Phase C hosts one terminal ("main") per session; the returned handle is a
|
|
118
|
+
* single attach client — `owner` (read-write) or a downgraded `read-only`
|
|
119
|
+
* viewer per the child's ownership rule. Not part of `AgentExecutor`; the WS
|
|
120
|
+
* bridge calls it directly.
|
|
121
|
+
*/
|
|
122
|
+
openTerminal(localThreadId, opts) {
|
|
123
|
+
const handle = this.getOrSpawn(localThreadId);
|
|
124
|
+
handle.lastUsedAt = this.now();
|
|
125
|
+
const attachId = randomUUID();
|
|
126
|
+
const terminal = new ManagedTerminal(attachId, (msg) => handle.transport.send(msg));
|
|
127
|
+
handle.terminals.set(attachId, terminal);
|
|
128
|
+
handle.transport.send({
|
|
129
|
+
t: "term.open",
|
|
130
|
+
attachId,
|
|
131
|
+
// Unique per session so two sessions' tmux servers never share a socket.
|
|
132
|
+
terminalId: `${localThreadId}-main`,
|
|
133
|
+
localThreadId,
|
|
134
|
+
role: opts.role,
|
|
135
|
+
cwd: opts.cwd,
|
|
136
|
+
cols: opts.cols,
|
|
137
|
+
rows: opts.rows,
|
|
138
|
+
...(opts.command ? { command: opts.command } : {}),
|
|
139
|
+
...(opts.args ? { args: opts.args } : {}),
|
|
140
|
+
});
|
|
141
|
+
return terminal;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Register the sink for mirrored {@link SessionEvent}s produced by every
|
|
145
|
+
* session's persistent codex forwarder (web- AND TUI-initiated turns). The
|
|
146
|
+
* server wires this to `persistSessionEvent` (bus + canonical log).
|
|
147
|
+
*/
|
|
148
|
+
onMirror(listener) {
|
|
149
|
+
this.mirrorListener = listener;
|
|
150
|
+
}
|
|
151
|
+
/** Register the sink for session rotations (claude `/clear`·`/fork`): the server
|
|
152
|
+
* records the new session's meta (carry-over agent/model/title). */
|
|
153
|
+
onRotate(listener) {
|
|
154
|
+
this.rotateListener = listener;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Eagerly bring up a session's codex-native live view (persistent forwarder +
|
|
158
|
+
* detached `codex --remote` TUI) in its runner child, spawning the runner if
|
|
159
|
+
* needed. Idempotent. Resolves true once the codex thread is bound; false for a
|
|
160
|
+
* non-codex / non-live session (the caller then uses the normal run path).
|
|
161
|
+
*/
|
|
162
|
+
ensureLiveSession(localThreadId, opts) {
|
|
163
|
+
const handle = this.getOrSpawn(localThreadId);
|
|
164
|
+
handle.lastUsedAt = this.now();
|
|
165
|
+
this.liveSessionKeys.add(localThreadId);
|
|
166
|
+
const reqId = randomUUID();
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
handle.live.set(reqId, (res) => resolve(res.ok ?? false));
|
|
169
|
+
handle.transport.send({
|
|
170
|
+
t: "live.ensure",
|
|
171
|
+
reqId,
|
|
172
|
+
localThreadId,
|
|
173
|
+
...(opts?.cwd ? { cwd: opts.cwd } : {}),
|
|
174
|
+
...(opts?.cols ? { cols: opts.cols } : {}),
|
|
175
|
+
...(opts?.rows ? { rows: opts.rows } : {}),
|
|
176
|
+
...(opts?.runtime ? { runtime: opts.runtime } : {}),
|
|
177
|
+
...(opts?.agentName ? { agentName: opts.agentName } : {}),
|
|
178
|
+
...(opts?.agentSpec ? { agentSpec: opts.agentSpec } : {}),
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Inject a user turn into a session's live codex thread — omnigent's
|
|
184
|
+
* single-writer web send (`turn/start` / `turn/steer`); the forwarder mirrors
|
|
185
|
+
* all output. Resolves true when the app-server accepted the turn, false when
|
|
186
|
+
* the session has no live forwarder (caller falls back to the run path).
|
|
187
|
+
*/
|
|
188
|
+
injectMessage(localThreadId, text) {
|
|
189
|
+
const handle = this.getOrSpawn(localThreadId);
|
|
190
|
+
handle.lastUsedAt = this.now();
|
|
191
|
+
const reqId = randomUUID();
|
|
192
|
+
return new Promise((resolve) => {
|
|
193
|
+
handle.live.set(reqId, (res) => resolve(res.outcome ?? "failed"));
|
|
194
|
+
handle.transport.send({ t: "inject", reqId, localThreadId, text });
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Interrupt a session's active live turn — the web Stop button (codex
|
|
199
|
+
* `turn/interrupt`, claude Escape). Best-effort: resolves false with NO spawn
|
|
200
|
+
* when the session has no live runner (nothing to interrupt).
|
|
201
|
+
*/
|
|
202
|
+
interruptLiveSession(localThreadId) {
|
|
203
|
+
const handle = this.handles.get(localThreadId);
|
|
204
|
+
if (!handle)
|
|
205
|
+
return Promise.resolve(false);
|
|
206
|
+
handle.lastUsedAt = this.now();
|
|
207
|
+
const reqId = randomUUID();
|
|
208
|
+
return new Promise((resolve) => {
|
|
209
|
+
handle.live.set(reqId, (res) => resolve(res.ok ?? false));
|
|
210
|
+
handle.transport.send({ t: "live.interrupt", reqId, localThreadId });
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Deliver a user's interactive-approval decision to the session's runner
|
|
215
|
+
* child (Phase D). Best-effort: no-op if the runner isn't live (the pending
|
|
216
|
+
* approval would have died with it). The child routes it to its codex client.
|
|
217
|
+
*/
|
|
218
|
+
resolveApproval(localThreadId, approvalId, decision) {
|
|
219
|
+
const handle = this.handles.get(localThreadId);
|
|
220
|
+
if (!handle || handle.dead) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
handle.transport.send({ t: "approval.resolve", localThreadId, approvalId, decision });
|
|
224
|
+
}
|
|
225
|
+
// ── AgentCapabilities ──────────────────────────────────────────────────────
|
|
226
|
+
async listModels(runtime) {
|
|
227
|
+
return listRuntimeModels(this.config, runtime ?? this.defaultRuntime);
|
|
228
|
+
}
|
|
229
|
+
// Per-thread caps run on the SESSION's own runner child (not the shared cap
|
|
230
|
+
// child) so they see that session's private CODEX_HOME (its rollout/thread).
|
|
231
|
+
getGoal(localThreadId) {
|
|
232
|
+
return this.forwardCap("getGoal", [localThreadId], localThreadId);
|
|
233
|
+
}
|
|
234
|
+
setGoal(localThreadId, objective) {
|
|
235
|
+
return this.forwardCap("setGoal", [localThreadId, objective], localThreadId);
|
|
236
|
+
}
|
|
237
|
+
clearGoal(localThreadId) {
|
|
238
|
+
return this.forwardCap("clearGoal", [localThreadId], localThreadId);
|
|
239
|
+
}
|
|
240
|
+
forkSession(currentLocalThreadId, newLocalThreadId) {
|
|
241
|
+
// Runs on the SOURCE session's child (its home has the source rollout to fork).
|
|
242
|
+
return this.forwardCap("forkSession", [currentLocalThreadId, newLocalThreadId], currentLocalThreadId);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Backend-free runtime readiness (not part of `AgentCapabilities`; surfaced for
|
|
246
|
+
* the control console). Never spawns a runner.
|
|
247
|
+
*/
|
|
248
|
+
getStatus(options = {}) {
|
|
249
|
+
return probeRuntimeStatus(options.runtime ?? this.defaultRuntime, this.config, this.sessionStore);
|
|
250
|
+
}
|
|
251
|
+
/** Stop the runner bound to one session (if any), rejecting its in-flight work. */
|
|
252
|
+
stopRunner(localThreadId) {
|
|
253
|
+
const handle = this.handles.get(localThreadId);
|
|
254
|
+
if (!handle) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
handle.transport.send({ t: "shutdown" });
|
|
258
|
+
handle.child.kill("SIGTERM");
|
|
259
|
+
this.failHandle(handle, "runner stopped");
|
|
260
|
+
}
|
|
261
|
+
/** Kill every runner. Call on server shutdown. */
|
|
262
|
+
async stop() {
|
|
263
|
+
if (this.reapTimer) {
|
|
264
|
+
clearInterval(this.reapTimer);
|
|
265
|
+
}
|
|
266
|
+
for (const handle of this.handles.values()) {
|
|
267
|
+
handle.transport.send({ t: "shutdown" });
|
|
268
|
+
handle.child.kill("SIGTERM");
|
|
269
|
+
}
|
|
270
|
+
this.handles.clear();
|
|
271
|
+
}
|
|
272
|
+
// ── internals ──────────────────────────────────────────────────────────────
|
|
273
|
+
/** Forward a capability to a runner child. Session-less caps (listModels/status)
|
|
274
|
+
* use the shared `CAP_KEY` child; per-thread caps pass the session's key so they
|
|
275
|
+
* run on that session's child (which owns its private CODEX_HOME). */
|
|
276
|
+
forwardCap(name, args, key = CAP_KEY) {
|
|
277
|
+
const handle = this.getOrSpawn(key);
|
|
278
|
+
const capId = randomUUID();
|
|
279
|
+
handle.lastUsedAt = this.now();
|
|
280
|
+
return new Promise((resolve, reject) => {
|
|
281
|
+
handle.caps.set(capId, { resolve: resolve, reject });
|
|
282
|
+
handle.transport.send({ t: "cap", capId, name, args });
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
getOrSpawn(key) {
|
|
286
|
+
this.reapIdle();
|
|
287
|
+
const existing = this.handles.get(key);
|
|
288
|
+
if (existing && !existing.dead) {
|
|
289
|
+
existing.lastUsedAt = this.now();
|
|
290
|
+
return existing;
|
|
291
|
+
}
|
|
292
|
+
const handle = this.spawnHandle(key);
|
|
293
|
+
this.handles.set(key, handle);
|
|
294
|
+
return handle;
|
|
295
|
+
}
|
|
296
|
+
spawnHandle(key) {
|
|
297
|
+
const args = this.runnerEntry.endsWith(".ts")
|
|
298
|
+
? ["--import", "tsx", this.runnerEntry]
|
|
299
|
+
: [this.runnerEntry];
|
|
300
|
+
const child = this.spawn(process.execPath, args, {
|
|
301
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
302
|
+
// `RYNX_RUNNER_SESSION` = the child's session key (localThreadId, or `__cap__`
|
|
303
|
+
// for the shared capability child) so its LocalAgentHost scopes the private
|
|
304
|
+
// CODEX_HOME to this session.
|
|
305
|
+
env: { ...process.env, ...this.childEnv, RYNX_RUNNER_SESSION: key },
|
|
306
|
+
});
|
|
307
|
+
const transport = new StdioRunnerTransport(child.stdout, child.stdin);
|
|
308
|
+
const handle = {
|
|
309
|
+
key,
|
|
310
|
+
child,
|
|
311
|
+
transport,
|
|
312
|
+
stderr: [],
|
|
313
|
+
lastUsedAt: this.now(),
|
|
314
|
+
dead: false,
|
|
315
|
+
caps: new Map(),
|
|
316
|
+
terminals: new Map(),
|
|
317
|
+
live: new Map(),
|
|
318
|
+
};
|
|
319
|
+
transport.onMessage((msg) => this.onChildMessage(handle, msg));
|
|
320
|
+
if (child.stderr) {
|
|
321
|
+
const rl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity });
|
|
322
|
+
rl.on("line", (line) => {
|
|
323
|
+
handle.stderr.push(line);
|
|
324
|
+
if (handle.stderr.length > STDERR_TAIL_LINES) {
|
|
325
|
+
handle.stderr.shift();
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
child.on("error", (error) => this.failHandle(handle, error.message));
|
|
330
|
+
child.on("exit", (code, signal) => this.failHandle(handle, `runner exited (code=${code ?? "null"}${signal ? `, signal=${signal}` : ""})`));
|
|
331
|
+
return handle;
|
|
332
|
+
}
|
|
333
|
+
onChildMessage(handle, msg) {
|
|
334
|
+
switch (msg.t) {
|
|
335
|
+
case "ready":
|
|
336
|
+
return;
|
|
337
|
+
case "capResult": {
|
|
338
|
+
const pending = handle.caps.get(msg.capId);
|
|
339
|
+
handle.caps.delete(msg.capId);
|
|
340
|
+
if (!pending) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (msg.ok) {
|
|
344
|
+
pending.resolve(msg.data);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
pending.reject(fromWireError(msg.error));
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
case "term.opened":
|
|
352
|
+
handle.terminals.get(msg.attachId)?._opened(msg.role);
|
|
353
|
+
return;
|
|
354
|
+
case "term.data":
|
|
355
|
+
handle.terminals.get(msg.attachId)?._data(msg.dataB64);
|
|
356
|
+
return;
|
|
357
|
+
case "term.exit": {
|
|
358
|
+
const terminal = handle.terminals.get(msg.attachId);
|
|
359
|
+
handle.terminals.delete(msg.attachId);
|
|
360
|
+
terminal?._exit(msg.exitCode);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
case "term.error": {
|
|
364
|
+
const terminal = handle.terminals.get(msg.attachId);
|
|
365
|
+
handle.terminals.delete(msg.attachId);
|
|
366
|
+
terminal?._fail(msg.message);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
case "mirror":
|
|
370
|
+
this.mirrorListener?.(msg.sessionId, msg.event);
|
|
371
|
+
return;
|
|
372
|
+
case "rotate": {
|
|
373
|
+
// Terminal transfer: alias the new session to THIS runner so its injection
|
|
374
|
+
// (and live/approval) route to the same child that still owns the pane.
|
|
375
|
+
this.handles.set(msg.to, handle);
|
|
376
|
+
this.liveSessionKeys.add(msg.to);
|
|
377
|
+
this.rotateListener?.({
|
|
378
|
+
from: msg.from,
|
|
379
|
+
to: msg.to,
|
|
380
|
+
kind: msg.kind,
|
|
381
|
+
...(msg.agent ? { agent: msg.agent } : {}),
|
|
382
|
+
...(msg.model ? { model: msg.model } : {}),
|
|
383
|
+
...(msg.cwd ? { cwd: msg.cwd } : {}),
|
|
384
|
+
...(msg.parentSessionId ? { parentSessionId: msg.parentSessionId } : {}),
|
|
385
|
+
});
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
case "live.ready":
|
|
389
|
+
case "interrupted":
|
|
390
|
+
case "injected": {
|
|
391
|
+
const resolve = handle.live.get(msg.reqId);
|
|
392
|
+
handle.live.delete(msg.reqId);
|
|
393
|
+
resolve?.(msg.t === "injected"
|
|
394
|
+
? { outcome: msg.outcome, error: msg.error }
|
|
395
|
+
: { ok: msg.ok, error: msg.error });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/** Mark a handle dead and reject every pending run/cap with the exit reason. */
|
|
401
|
+
failHandle(handle, reason) {
|
|
402
|
+
if (handle.dead) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
handle.dead = true;
|
|
406
|
+
// Drop every key mapping to this handle — its launch key AND any rotation
|
|
407
|
+
// aliases (claude `/clear`·`/fork` terminal transfer).
|
|
408
|
+
for (const [key, h] of this.handles) {
|
|
409
|
+
if (h === handle)
|
|
410
|
+
this.handles.delete(key);
|
|
411
|
+
}
|
|
412
|
+
const tail = handle.stderr.join("\n");
|
|
413
|
+
const message = tail ? `${reason}\n--- runner log tail ---\n${tail}` : reason;
|
|
414
|
+
const error = new AgentRuntimeError(message, 500, "runner_crashed");
|
|
415
|
+
for (const pending of handle.caps.values()) {
|
|
416
|
+
pending.reject(error);
|
|
417
|
+
}
|
|
418
|
+
for (const terminal of handle.terminals.values()) {
|
|
419
|
+
terminal._fail(message);
|
|
420
|
+
}
|
|
421
|
+
for (const resolve of handle.live.values()) {
|
|
422
|
+
resolve({ ok: false, error: message });
|
|
423
|
+
}
|
|
424
|
+
handle.caps.clear();
|
|
425
|
+
handle.terminals.clear();
|
|
426
|
+
handle.live.clear();
|
|
427
|
+
this.liveSessionKeys.delete(handle.key);
|
|
428
|
+
handle.transport.close();
|
|
429
|
+
}
|
|
430
|
+
reapIdle() {
|
|
431
|
+
const now = this.now();
|
|
432
|
+
for (const [key, handle] of this.handles) {
|
|
433
|
+
if (handle.caps.size > 0 || handle.terminals.size > 0) {
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
// A live codex forwarder must outlive terminal detach / idle turns — it is
|
|
437
|
+
// the session's single event writer, so never reap a session that has one.
|
|
438
|
+
if (this.liveSessionKeys.has(key)) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (now - handle.lastUsedAt < this.idleTtlMs) {
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
handle.dead = true;
|
|
445
|
+
this.handles.delete(key);
|
|
446
|
+
handle.transport.send({ t: "shutdown" });
|
|
447
|
+
handle.child.kill("SIGTERM");
|
|
448
|
+
handle.transport.close();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function defaultRunnerEntry() {
|
|
453
|
+
if (process.env.RYNX_RUNNER_ENTRY?.trim()) {
|
|
454
|
+
return process.env.RYNX_RUNNER_ENTRY.trim();
|
|
455
|
+
}
|
|
456
|
+
// dist/runner/manager.js → dist/runner-main.js
|
|
457
|
+
return fileURLToPath(new URL("../runner-main.js", import.meta.url));
|
|
458
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol between the parent {@link import("./manager.js").RunnerManager}
|
|
3
|
+
* and a per-session runner child process. Transport-agnostic: the messages here
|
|
4
|
+
* carry no live objects, so the same protocol runs over local stdio today
|
|
5
|
+
* (NDJSON) and an outbound tunnel later. The seam is the live co-drive surface
|
|
6
|
+
* (`live.ensure` / `inject` / `live.interrupt`) plus per-thread capabilities and
|
|
7
|
+
* terminal attachment; the reply channels mirror each request's `reqId`.
|
|
8
|
+
*/
|
|
9
|
+
import { AgentRuntimeError, type AgentRuntimeId, type AgentSpec, type SessionEvent } from "@rynx-ai/core";
|
|
10
|
+
/** A runtime error flattened for the wire; rebuilt parent-side as `AgentRuntimeError`. */
|
|
11
|
+
export interface WireError {
|
|
12
|
+
message: string;
|
|
13
|
+
/** Stable error code (e.g. `codex_timeout`); preserved so `toNormalizerError` maps it. */
|
|
14
|
+
code: string;
|
|
15
|
+
statusCode: number;
|
|
16
|
+
}
|
|
17
|
+
/** Capabilities forwarded to a runner (parent serves status/listModels itself). */
|
|
18
|
+
export type CapName = "getGoal" | "setGoal" | "clearGoal" | "forkSession";
|
|
19
|
+
/** A live terminal role: `owner` is read-write, `read-only` a viewer. */
|
|
20
|
+
export type TerminalRole = "owner" | "read-only";
|
|
21
|
+
/** A codex approval decision the user returns for an interactive approval. */
|
|
22
|
+
export type ApprovalDecisionWire = "acceptForSession" | "accept" | "decline" | "cancel";
|
|
23
|
+
/** Outcome of an `inject`. `injected` = the app-server / tmux accepted the turn.
|
|
24
|
+
* `notLive` = no live forwarder for this session (caller may use the run path).
|
|
25
|
+
* `notReady` = live but not ready within the park window; `failed` = injection
|
|
26
|
+
* threw. For BOTH notReady and failed the caller reports an error and MUST NOT
|
|
27
|
+
* re-run the turn (re-running would double-write alongside the forwarder).
|
|
28
|
+
* Mirrors omnigent's "inject failure ⇒ response.failed, never re-run locally". */
|
|
29
|
+
export type InjectOutcome = "injected" | "notLive" | "notReady" | "failed";
|
|
30
|
+
/** Parent → child. Terminal PTY bytes ride `term.input` base64-encoded so the
|
|
31
|
+
* NDJSON line framing stays intact (raw bytes contain newlines). */
|
|
32
|
+
export type ToChild = {
|
|
33
|
+
t: "cap";
|
|
34
|
+
capId: string;
|
|
35
|
+
name: CapName;
|
|
36
|
+
args: unknown[];
|
|
37
|
+
} | {
|
|
38
|
+
t: "term.open";
|
|
39
|
+
attachId: string;
|
|
40
|
+
terminalId: string;
|
|
41
|
+
/** Session id — lets the child derive a codex-native TUI command from its
|
|
42
|
+
* executor when no explicit `command` is given. */
|
|
43
|
+
localThreadId?: string;
|
|
44
|
+
role: TerminalRole;
|
|
45
|
+
cwd: string;
|
|
46
|
+
cols: number;
|
|
47
|
+
rows: number;
|
|
48
|
+
command?: string;
|
|
49
|
+
args?: string[];
|
|
50
|
+
} | {
|
|
51
|
+
t: "term.input";
|
|
52
|
+
attachId: string;
|
|
53
|
+
dataB64: string;
|
|
54
|
+
} | {
|
|
55
|
+
t: "term.resize";
|
|
56
|
+
attachId: string;
|
|
57
|
+
cols: number;
|
|
58
|
+
rows: number;
|
|
59
|
+
} | {
|
|
60
|
+
t: "term.close";
|
|
61
|
+
attachId: string;
|
|
62
|
+
} | {
|
|
63
|
+
t: "approval.resolve";
|
|
64
|
+
localThreadId: string;
|
|
65
|
+
approvalId: string;
|
|
66
|
+
decision: ApprovalDecisionWire;
|
|
67
|
+
}
|
|
68
|
+
/** Eagerly bring up a session's live codex TUI + forwarder (codex-native), so
|
|
69
|
+
* its turns mirror to the bus regardless of whether the web has attached the
|
|
70
|
+
* terminal. Idempotent; the child replies `live.ready` (echoing `reqId`) once
|
|
71
|
+
* the thread is bound. */
|
|
72
|
+
| {
|
|
73
|
+
t: "live.ensure";
|
|
74
|
+
reqId: string;
|
|
75
|
+
localThreadId: string;
|
|
76
|
+
cwd?: string;
|
|
77
|
+
cols?: number;
|
|
78
|
+
rows?: number;
|
|
79
|
+
/** Runtime this session co-drives (resolved from the agent spec by the
|
|
80
|
+
* control plane), so live picks the right backend per agent — not the
|
|
81
|
+
* global default. Absent ⇒ host falls back to the store record / default. */
|
|
82
|
+
runtime?: AgentRuntimeId;
|
|
83
|
+
/** The session's preset agent id, so the host can load its spec and apply
|
|
84
|
+
* the agent's model / skills / instructions to the live launch (not just
|
|
85
|
+
* the runtime). Absent for inline-config or agent-less sessions. */
|
|
86
|
+
agentName?: string;
|
|
87
|
+
/** An inline agent spec (console session created from an inline config
|
|
88
|
+
* rather than a preset id) — same purpose as `agentName`. */
|
|
89
|
+
agentSpec?: AgentSpec;
|
|
90
|
+
}
|
|
91
|
+
/** Inject a user turn into the session's live codex thread (`turn/start` or
|
|
92
|
+
* `turn/steer`). The forwarder mirrors the output — this is the single-writer
|
|
93
|
+
* web send path. The child replies `injected` (echoing `reqId`). */
|
|
94
|
+
| {
|
|
95
|
+
t: "inject";
|
|
96
|
+
reqId: string;
|
|
97
|
+
localThreadId: string;
|
|
98
|
+
text: string;
|
|
99
|
+
}
|
|
100
|
+
/** Interrupt the session's active turn — the web Stop button. codex: app-server
|
|
101
|
+
* `turn/interrupt`; claude: an Escape keystroke to the pane. The child replies
|
|
102
|
+
* `interrupted` (echoing `reqId`). */
|
|
103
|
+
| {
|
|
104
|
+
t: "live.interrupt";
|
|
105
|
+
reqId: string;
|
|
106
|
+
localThreadId: string;
|
|
107
|
+
} | {
|
|
108
|
+
t: "shutdown";
|
|
109
|
+
};
|
|
110
|
+
/** Child → parent. `term.data` carries base64 PTY output (line-safe NDJSON). */
|
|
111
|
+
export type FromChild = {
|
|
112
|
+
t: "ready";
|
|
113
|
+
} | {
|
|
114
|
+
t: "capResult";
|
|
115
|
+
capId: string;
|
|
116
|
+
ok: true;
|
|
117
|
+
data: unknown;
|
|
118
|
+
} | {
|
|
119
|
+
t: "capResult";
|
|
120
|
+
capId: string;
|
|
121
|
+
ok: false;
|
|
122
|
+
error: WireError;
|
|
123
|
+
} | {
|
|
124
|
+
t: "term.opened";
|
|
125
|
+
attachId: string;
|
|
126
|
+
role: TerminalRole;
|
|
127
|
+
} | {
|
|
128
|
+
t: "term.data";
|
|
129
|
+
attachId: string;
|
|
130
|
+
dataB64: string;
|
|
131
|
+
} | {
|
|
132
|
+
t: "term.exit";
|
|
133
|
+
attachId: string;
|
|
134
|
+
exitCode: number;
|
|
135
|
+
} | {
|
|
136
|
+
t: "term.error";
|
|
137
|
+
attachId: string;
|
|
138
|
+
message: string;
|
|
139
|
+
}
|
|
140
|
+
/** A canonical {@link SessionEvent} mirrored by the session's persistent codex
|
|
141
|
+
* forwarder (web- AND TUI-initiated turns). Not bound to any single `run`. */
|
|
142
|
+
| {
|
|
143
|
+
t: "mirror";
|
|
144
|
+
sessionId: string;
|
|
145
|
+
event: SessionEvent;
|
|
146
|
+
}
|
|
147
|
+
/** The session rotated to a fresh machine-session (claude `/clear`·`/fork`): the
|
|
148
|
+
* child re-pointed its mirror to `to` and asks the daemon to alias the runner
|
|
149
|
+
* (terminal transfer, so `to` stays injectable) and record its meta. */
|
|
150
|
+
| {
|
|
151
|
+
t: "rotate";
|
|
152
|
+
from: string;
|
|
153
|
+
to: string;
|
|
154
|
+
kind: "clear" | "fork";
|
|
155
|
+
agent?: string;
|
|
156
|
+
model?: string;
|
|
157
|
+
cwd?: string;
|
|
158
|
+
parentSessionId?: string;
|
|
159
|
+
}
|
|
160
|
+
/** Result of a `live.ensure`: `ok` once the codex thread is bound (or a no-op
|
|
161
|
+
* for a non-codex / non-live session). Echoes the request `reqId`. */
|
|
162
|
+
| {
|
|
163
|
+
t: "live.ready";
|
|
164
|
+
reqId: string;
|
|
165
|
+
localThreadId: string;
|
|
166
|
+
ok: boolean;
|
|
167
|
+
error?: string;
|
|
168
|
+
}
|
|
169
|
+
/** Result of an `inject`: `outcome` distinguishes injected / notLive / notReady /
|
|
170
|
+
* failed so the caller never falls back to a second output path on a live
|
|
171
|
+
* session. Echoes the request `reqId`. */
|
|
172
|
+
| {
|
|
173
|
+
t: "injected";
|
|
174
|
+
reqId: string;
|
|
175
|
+
localThreadId: string;
|
|
176
|
+
outcome: InjectOutcome;
|
|
177
|
+
error?: string;
|
|
178
|
+
}
|
|
179
|
+
/** Result of a `live.interrupt`: `ok` when an active turn was interrupted (false
|
|
180
|
+
* when there was none). Echoes the request `reqId`. */
|
|
181
|
+
| {
|
|
182
|
+
t: "interrupted";
|
|
183
|
+
reqId: string;
|
|
184
|
+
localThreadId: string;
|
|
185
|
+
ok: boolean;
|
|
186
|
+
error?: string;
|
|
187
|
+
};
|
|
188
|
+
/** Encode a message as a single NDJSON line (newline included). */
|
|
189
|
+
export declare function encodeMessage(msg: ToChild | FromChild): string;
|
|
190
|
+
/** Parse one NDJSON line; returns `null` for blank lines or malformed JSON. */
|
|
191
|
+
export declare function decodeMessage<T extends ToChild | FromChild>(line: string): T | null;
|
|
192
|
+
/** Flatten an error for the wire, preserving `AgentRuntimeError`'s code/status. */
|
|
193
|
+
export declare function toWireError(error: unknown): WireError;
|
|
194
|
+
/** Rebuild an `AgentRuntimeError` parent-side so `code` survives the boundary. */
|
|
195
|
+
export declare function fromWireError(error: WireError): AgentRuntimeError;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol between the parent {@link import("./manager.js").RunnerManager}
|
|
3
|
+
* and a per-session runner child process. Transport-agnostic: the messages here
|
|
4
|
+
* carry no live objects, so the same protocol runs over local stdio today
|
|
5
|
+
* (NDJSON) and an outbound tunnel later. The seam is the live co-drive surface
|
|
6
|
+
* (`live.ensure` / `inject` / `live.interrupt`) plus per-thread capabilities and
|
|
7
|
+
* terminal attachment; the reply channels mirror each request's `reqId`.
|
|
8
|
+
*/
|
|
9
|
+
import { AgentRuntimeError, } from "@rynx-ai/core";
|
|
10
|
+
/** Encode a message as a single NDJSON line (newline included). */
|
|
11
|
+
export function encodeMessage(msg) {
|
|
12
|
+
return `${JSON.stringify(msg)}\n`;
|
|
13
|
+
}
|
|
14
|
+
/** Parse one NDJSON line; returns `null` for blank lines or malformed JSON. */
|
|
15
|
+
export function decodeMessage(line) {
|
|
16
|
+
const trimmed = line.trim();
|
|
17
|
+
if (!trimmed) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(trimmed);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Flatten an error for the wire, preserving `AgentRuntimeError`'s code/status. */
|
|
28
|
+
export function toWireError(error) {
|
|
29
|
+
if (error instanceof AgentRuntimeError) {
|
|
30
|
+
return { message: error.message, code: error.code, statusCode: error.statusCode };
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
message: error instanceof Error ? error.message : String(error),
|
|
34
|
+
code: "runtime_error",
|
|
35
|
+
statusCode: 500,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Rebuild an `AgentRuntimeError` parent-side so `code` survives the boundary. */
|
|
39
|
+
export function fromWireError(error) {
|
|
40
|
+
return new AgentRuntimeError(error.message, error.statusCode, error.code);
|
|
41
|
+
}
|