@rynx-ai/runtime 0.1.0 → 0.1.10-beta.2
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 +3 -5
- package/dist/claude/executor.js +3 -5
- package/dist/claude/native-bridge.d.ts +74 -17
- package/dist/claude/native-bridge.js +225 -30
- package/dist/claude/native-hook-main.js +327 -38
- package/dist/claude/native-hooks.d.ts +3 -2
- package/dist/claude/native-hooks.js +15 -6
- package/dist/claude/native-integration.d.ts +123 -16
- package/dist/claude/native-integration.js +624 -81
- package/dist/claude/settings.d.ts +8 -0
- package/dist/claude/settings.js +50 -0
- package/dist/claude/transcript.d.ts +2 -2
- package/dist/claude/transcript.js +14 -3
- package/dist/codex/rollout-synth.d.ts +8 -3
- package/dist/codex/rollout-synth.js +65 -32
- package/dist/codex-app-server/client.d.ts +27 -40
- package/dist/codex-app-server/client.js +1134 -99
- package/dist/codex-app-server/forwarder.d.ts +36 -10
- package/dist/codex-app-server/forwarder.js +146 -28
- package/dist/codex-app-server/mapping.d.ts +1 -1
- package/dist/codex-app-server/mapping.js +64 -5
- package/dist/codex-app-server/protocol.d.ts +269 -4
- package/dist/codex-app-server/transport.d.ts +20 -5
- package/dist/codex-app-server/transport.js +93 -40
- package/dist/codex-app-server/ws-channel.d.ts +3 -3
- package/dist/codex-app-server/ws-channel.js +23 -7
- package/dist/codex-child-env.js +33 -0
- package/dist/codex-home.d.ts +16 -6
- package/dist/codex-home.js +46 -15
- package/dist/codex-session-store.d.ts +2 -1
- package/dist/host.d.ts +38 -38
- package/dist/host.js +626 -121
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/dist/input-resources.d.ts +13 -0
- package/dist/input-resources.js +67 -0
- package/dist/interactions.d.ts +61 -0
- package/dist/interactions.js +236 -0
- package/dist/models-catalog.d.ts +5 -13
- package/dist/models-catalog.js +60 -9
- package/dist/runner/child.d.ts +9 -1
- package/dist/runner/child.js +100 -19
- package/dist/runner/manager.d.ts +79 -11
- package/dist/runner/manager.js +423 -43
- package/dist/runner/protocol.d.ts +30 -11
- package/dist/runner-main.js +9 -6
- package/dist/runtime-status.js +1 -1
- package/dist/terminal/claude-tui.d.ts +8 -3
- package/dist/terminal/claude-tui.js +6 -2
- package/dist/terminal/codex-tui.d.ts +3 -3
- package/dist/terminal/codex-tui.js +1 -1
- package/dist/terminal/registry.d.ts +1 -1
- package/dist/terminal/registry.js +1 -1
- package/dist/terminal/tmux.d.ts +6 -6
- package/dist/terminal/tmux.js +10 -10
- package/package.json +8 -3
package/dist/runner/manager.js
CHANGED
|
@@ -28,17 +28,44 @@ import { StdioRunnerTransport } from "./transport.js";
|
|
|
28
28
|
const CAP_KEY = "__cap__";
|
|
29
29
|
/** Max stderr lines retained per handle for the crash exit-report tail. */
|
|
30
30
|
const STDERR_TAIL_LINES = 40;
|
|
31
|
+
/** Time allowed for a runner to exit after SIGTERM before SIGKILL escalation. */
|
|
32
|
+
const DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
|
|
33
|
+
/** Time allowed for exit after SIGKILL before shutdown reports failure. */
|
|
34
|
+
const DEFAULT_SHUTDOWN_KILL_GRACE_MS = 5_000;
|
|
35
|
+
/** Setup-pane launch should acknowledge quickly; never pin its HTTP request. */
|
|
36
|
+
const DEFAULT_LIVE_START_TIMEOUT_MS = 10_000;
|
|
37
|
+
/** Thread readiness may legitimately wait through Provider startup. */
|
|
38
|
+
const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
|
|
39
|
+
/** Keep per-Session spawn context small enough to remain an environment handoff,
|
|
40
|
+
* not an unbounded transport. */
|
|
41
|
+
const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
|
|
42
|
+
const MAX_SESSION_CONTEXT_ENV_KEY_BYTES = 128;
|
|
43
|
+
const MAX_SESSION_CONTEXT_ENV_VALUE_BYTES = 8_192;
|
|
44
|
+
const MAX_SESSION_CONTEXT_ENV_TOTAL_BYTES = 32_768;
|
|
45
|
+
/** A typed attach failure so transport adapters can distinguish an expected
|
|
46
|
+
* absent pane from an infrastructure failure without matching error strings. */
|
|
47
|
+
export class TerminalOpenError extends Error {
|
|
48
|
+
code;
|
|
49
|
+
name = "TerminalOpenError";
|
|
50
|
+
constructor(message, code) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.code = code;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
31
55
|
class ManagedTerminal {
|
|
32
56
|
attachId;
|
|
33
57
|
sendMsg;
|
|
58
|
+
onClose;
|
|
34
59
|
ready;
|
|
35
60
|
dataListener;
|
|
36
61
|
exitListener;
|
|
37
62
|
resolveReady;
|
|
38
63
|
rejectReady;
|
|
39
|
-
|
|
64
|
+
state = "opening";
|
|
65
|
+
constructor(attachId, sendMsg, onClose) {
|
|
40
66
|
this.attachId = attachId;
|
|
41
67
|
this.sendMsg = sendMsg;
|
|
68
|
+
this.onClose = onClose;
|
|
42
69
|
this.ready = new Promise((resolve, reject) => {
|
|
43
70
|
this.resolveReady = resolve;
|
|
44
71
|
this.rejectReady = reject;
|
|
@@ -59,21 +86,49 @@ class ManagedTerminal {
|
|
|
59
86
|
this.sendMsg({ t: "term.resize", attachId: this.attachId, cols, rows });
|
|
60
87
|
}
|
|
61
88
|
close() {
|
|
89
|
+
if (this.state === "closed")
|
|
90
|
+
return;
|
|
91
|
+
const wasOpening = this.state === "opening";
|
|
92
|
+
this.state = "closed";
|
|
62
93
|
this.sendMsg({ t: "term.close", attachId: this.attachId });
|
|
94
|
+
this.onClose();
|
|
95
|
+
if (wasOpening) {
|
|
96
|
+
this.rejectReady(new TerminalOpenError("terminal attachment was closed while opening", "terminal_open_failed"));
|
|
97
|
+
}
|
|
63
98
|
}
|
|
64
99
|
// ── internal (driven by onChildMessage) ──
|
|
65
100
|
_opened(role) {
|
|
101
|
+
if (this.state !== "opening")
|
|
102
|
+
return;
|
|
103
|
+
this.state = "opened";
|
|
66
104
|
this.resolveReady({ role });
|
|
67
105
|
}
|
|
68
106
|
_data(dataB64) {
|
|
69
107
|
this.dataListener?.(Buffer.from(dataB64, "base64").toString("utf8"));
|
|
70
108
|
}
|
|
71
109
|
_exit(exitCode) {
|
|
72
|
-
this.
|
|
110
|
+
if (this.state === "closed")
|
|
111
|
+
return;
|
|
112
|
+
const wasOpening = this.state === "opening";
|
|
113
|
+
this.state = "closed";
|
|
114
|
+
if (wasOpening) {
|
|
115
|
+
this.rejectReady(new TerminalOpenError(`terminal exited before opening (code=${exitCode})`, "terminal_open_failed"));
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
this.exitListener?.({ exitCode });
|
|
119
|
+
}
|
|
73
120
|
}
|
|
74
|
-
_fail(message) {
|
|
75
|
-
this.
|
|
76
|
-
|
|
121
|
+
_fail(message, code = "terminal_open_failed") {
|
|
122
|
+
if (this.state === "closed")
|
|
123
|
+
return;
|
|
124
|
+
const wasOpening = this.state === "opening";
|
|
125
|
+
this.state = "closed";
|
|
126
|
+
if (wasOpening) {
|
|
127
|
+
this.rejectReady(new TerminalOpenError(message, code));
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
this.exitListener?.({ exitCode: 1 });
|
|
131
|
+
}
|
|
77
132
|
}
|
|
78
133
|
}
|
|
79
134
|
export class RunnerManager {
|
|
@@ -83,16 +138,28 @@ export class RunnerManager {
|
|
|
83
138
|
idleTtlMs;
|
|
84
139
|
spawn;
|
|
85
140
|
childEnv;
|
|
141
|
+
sessionContextProvider;
|
|
86
142
|
now;
|
|
87
143
|
defaultRuntime;
|
|
88
144
|
handles = new Map();
|
|
145
|
+
/** Every spawned child that has not exited (or failed to spawn), including
|
|
146
|
+
* handles already removed from routing by stopRunner/idle reap. */
|
|
147
|
+
childHandles = new Set();
|
|
89
148
|
reapTimer;
|
|
149
|
+
shutdownGraceMs;
|
|
150
|
+
shutdownKillGraceMs;
|
|
151
|
+
liveStartTimeoutMs;
|
|
152
|
+
liveReadyTimeoutMs;
|
|
153
|
+
stopping = false;
|
|
154
|
+
stopPromise;
|
|
90
155
|
/** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
|
|
91
156
|
mirrorListener = null;
|
|
92
157
|
/** Sink for session rotations (claude `/clear`·`/fork`) — server records meta. */
|
|
93
158
|
rotateListener = null;
|
|
94
159
|
/** Session keys with a live codex forwarder — never reaped while present. */
|
|
95
160
|
liveSessionKeys = new Set();
|
|
161
|
+
/** Last live-start error per local session, surfaced by the control API. */
|
|
162
|
+
liveErrors = new Map();
|
|
96
163
|
constructor(opts) {
|
|
97
164
|
this.config = opts.config;
|
|
98
165
|
this.sessionStore = opts.sessionStore;
|
|
@@ -100,8 +167,13 @@ export class RunnerManager {
|
|
|
100
167
|
this.idleTtlMs = opts.idleTtlMs ?? 300_000;
|
|
101
168
|
this.spawn = opts.spawn ?? nodeSpawn;
|
|
102
169
|
this.childEnv = opts.childEnv ?? {};
|
|
170
|
+
this.sessionContextProvider = opts.sessionContextProvider;
|
|
103
171
|
this.now = opts.now ?? (() => Date.now());
|
|
104
172
|
this.defaultRuntime = opts.config.AGENT_RUNTIME ?? "codex";
|
|
173
|
+
this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
|
|
174
|
+
this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
|
|
175
|
+
this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
|
|
176
|
+
this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
|
|
105
177
|
const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
|
|
106
178
|
if (reapIntervalMs > 0) {
|
|
107
179
|
this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
|
|
@@ -121,9 +193,24 @@ export class RunnerManager {
|
|
|
121
193
|
*/
|
|
122
194
|
openTerminal(localThreadId, opts) {
|
|
123
195
|
const handle = this.getOrSpawn(localThreadId);
|
|
196
|
+
return this.openTerminalOnHandle(handle, localThreadId, opts);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Attach to a Session runner that is already live. Unlike {@link openTerminal},
|
|
200
|
+
* this never creates a child, including when liveness changes between lookup
|
|
201
|
+
* and attach.
|
|
202
|
+
*/
|
|
203
|
+
openLiveTerminal(localThreadId, opts) {
|
|
204
|
+
const handle = this.handles.get(localThreadId);
|
|
205
|
+
if (!handle || handle.dead || !this.liveSessionKeys.has(localThreadId)) {
|
|
206
|
+
throw new TerminalOpenError("terminal not live", "terminal_not_live");
|
|
207
|
+
}
|
|
208
|
+
return this.openTerminalOnHandle(handle, localThreadId, opts);
|
|
209
|
+
}
|
|
210
|
+
openTerminalOnHandle(handle, localThreadId, opts) {
|
|
124
211
|
handle.lastUsedAt = this.now();
|
|
125
212
|
const attachId = randomUUID();
|
|
126
|
-
const terminal = new ManagedTerminal(attachId, (msg) => handle.transport.send(msg));
|
|
213
|
+
const terminal = new ManagedTerminal(attachId, (msg) => handle.transport.send(msg), () => handle.terminals.delete(attachId));
|
|
127
214
|
handle.terminals.set(attachId, terminal);
|
|
128
215
|
handle.transport.send({
|
|
129
216
|
t: "term.open",
|
|
@@ -140,6 +227,11 @@ export class RunnerManager {
|
|
|
140
227
|
});
|
|
141
228
|
return terminal;
|
|
142
229
|
}
|
|
230
|
+
/** Whether this process already owns a live runner for the Session. Read-only; never spawns. */
|
|
231
|
+
hasLiveSession(localThreadId) {
|
|
232
|
+
const handle = this.handles.get(localThreadId);
|
|
233
|
+
return Boolean(handle && !handle.dead && this.liveSessionKeys.has(localThreadId));
|
|
234
|
+
}
|
|
143
235
|
/**
|
|
144
236
|
* Register the sink for mirrored {@link SessionEvent}s produced by every
|
|
145
237
|
* session's persistent codex forwarder (web- AND TUI-initiated turns). The
|
|
@@ -160,12 +252,46 @@ export class RunnerManager {
|
|
|
160
252
|
* non-codex / non-live session (the caller then uses the normal run path).
|
|
161
253
|
*/
|
|
162
254
|
ensureLiveSession(localThreadId, opts) {
|
|
255
|
+
return this.requestLiveSession(localThreadId, opts, true);
|
|
256
|
+
}
|
|
257
|
+
/** Start the Provider pane without waiting for login/onboarding to create a
|
|
258
|
+
* native thread. This is the setup-terminal gate; callers may attach as soon
|
|
259
|
+
* as it resolves, while normal message delivery still uses ensureLiveSession. */
|
|
260
|
+
startLiveSession(localThreadId, opts) {
|
|
261
|
+
return this.requestLiveSession(localThreadId, opts, false);
|
|
262
|
+
}
|
|
263
|
+
requestLiveSession(localThreadId, opts, waitForReady) {
|
|
163
264
|
const handle = this.getOrSpawn(localThreadId);
|
|
164
265
|
handle.lastUsedAt = this.now();
|
|
165
266
|
this.liveSessionKeys.add(localThreadId);
|
|
166
267
|
const reqId = randomUUID();
|
|
167
268
|
return new Promise((resolve) => {
|
|
168
|
-
|
|
269
|
+
const timeoutMs = waitForReady ? this.liveReadyTimeoutMs : this.liveStartTimeoutMs;
|
|
270
|
+
const timeout = setTimeout(() => {
|
|
271
|
+
if (!handle.live.delete(reqId))
|
|
272
|
+
return;
|
|
273
|
+
const reason = `runner did not acknowledge ${waitForReady ? "live readiness" : "terminal start"} within ${timeoutMs}ms`;
|
|
274
|
+
this.liveErrors.set(localThreadId, reason);
|
|
275
|
+
this.liveSessionKeys.delete(localThreadId);
|
|
276
|
+
resolve(false);
|
|
277
|
+
// A child that cannot answer a bounded control round-trip is unsafe to
|
|
278
|
+
// reuse. Reap it so the next click gets a fresh runner.
|
|
279
|
+
void this.terminateHandle(handle, reason).catch((error) => {
|
|
280
|
+
logTerminationFailure(handle, error);
|
|
281
|
+
});
|
|
282
|
+
}, timeoutMs);
|
|
283
|
+
timeout.unref?.();
|
|
284
|
+
handle.live.set(reqId, (res) => {
|
|
285
|
+
clearTimeout(timeout);
|
|
286
|
+
const ok = res.ok ?? false;
|
|
287
|
+
if (ok) {
|
|
288
|
+
this.liveErrors.delete(localThreadId);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
this.liveErrors.set(localThreadId, res.error ?? "live session did not become ready");
|
|
292
|
+
}
|
|
293
|
+
resolve(ok);
|
|
294
|
+
});
|
|
169
295
|
handle.transport.send({
|
|
170
296
|
t: "live.ensure",
|
|
171
297
|
reqId,
|
|
@@ -174,24 +300,31 @@ export class RunnerManager {
|
|
|
174
300
|
...(opts?.cols ? { cols: opts.cols } : {}),
|
|
175
301
|
...(opts?.rows ? { rows: opts.rows } : {}),
|
|
176
302
|
...(opts?.runtime ? { runtime: opts.runtime } : {}),
|
|
303
|
+
...(opts?.reasoningEffort ? { reasoningEffort: opts.reasoningEffort } : {}),
|
|
177
304
|
...(opts?.agentName ? { agentName: opts.agentName } : {}),
|
|
178
305
|
...(opts?.agentSpec ? { agentSpec: opts.agentSpec } : {}),
|
|
306
|
+
waitForReady,
|
|
179
307
|
});
|
|
180
308
|
});
|
|
181
309
|
}
|
|
310
|
+
lastLiveSessionError(localThreadId) {
|
|
311
|
+
return this.liveErrors.get(localThreadId);
|
|
312
|
+
}
|
|
182
313
|
/**
|
|
183
|
-
* Inject a user turn into a session's live codex thread —
|
|
314
|
+
* Inject a user turn into a session's live codex thread — reference implementation's
|
|
184
315
|
* single-writer web send (`turn/start` / `turn/steer`); the forwarder mirrors
|
|
185
316
|
* all output. Resolves true when the app-server accepted the turn, false when
|
|
186
317
|
* the session has no live forwarder (caller falls back to the run path).
|
|
187
318
|
*/
|
|
188
|
-
injectMessage(localThreadId,
|
|
319
|
+
injectMessage(localThreadId, input) {
|
|
189
320
|
const handle = this.getOrSpawn(localThreadId);
|
|
190
321
|
handle.lastUsedAt = this.now();
|
|
191
322
|
const reqId = randomUUID();
|
|
192
323
|
return new Promise((resolve) => {
|
|
193
324
|
handle.live.set(reqId, (res) => resolve(res.outcome ?? "failed"));
|
|
194
|
-
handle.transport.send(
|
|
325
|
+
handle.transport.send(typeof input === "string"
|
|
326
|
+
? { t: "inject", reqId, localThreadId, text: input }
|
|
327
|
+
: { t: "inject", reqId, localThreadId, input });
|
|
195
328
|
});
|
|
196
329
|
}
|
|
197
330
|
/**
|
|
@@ -210,17 +343,29 @@ export class RunnerManager {
|
|
|
210
343
|
handle.transport.send({ t: "live.interrupt", reqId, localThreadId });
|
|
211
344
|
});
|
|
212
345
|
}
|
|
213
|
-
/**
|
|
214
|
-
|
|
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) {
|
|
346
|
+
/** Resolve a native question/approval without ever spawning a new runner. */
|
|
347
|
+
resolveInteraction(localThreadId, interactionId, resolution) {
|
|
219
348
|
const handle = this.handles.get(localThreadId);
|
|
220
349
|
if (!handle || handle.dead) {
|
|
221
|
-
return;
|
|
350
|
+
return Promise.resolve({ disposition: "not_found" });
|
|
222
351
|
}
|
|
223
|
-
handle.
|
|
352
|
+
handle.lastUsedAt = this.now();
|
|
353
|
+
const reqId = randomUUID();
|
|
354
|
+
return new Promise((resolve) => {
|
|
355
|
+
handle.live.set(reqId, (reply) => {
|
|
356
|
+
resolve(reply.interactionResult ?? {
|
|
357
|
+
disposition: "invalid",
|
|
358
|
+
message: reply.error ?? "runner did not return an interaction result",
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
handle.transport.send({
|
|
362
|
+
t: "interaction.resolve",
|
|
363
|
+
reqId,
|
|
364
|
+
localThreadId,
|
|
365
|
+
interactionId,
|
|
366
|
+
resolution,
|
|
367
|
+
});
|
|
368
|
+
});
|
|
224
369
|
}
|
|
225
370
|
// ── AgentCapabilities ──────────────────────────────────────────────────────
|
|
226
371
|
async listModels(runtime) {
|
|
@@ -254,20 +399,35 @@ export class RunnerManager {
|
|
|
254
399
|
if (!handle) {
|
|
255
400
|
return;
|
|
256
401
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
402
|
+
void this.terminateHandle(handle, "runner stopped").catch((error) => {
|
|
403
|
+
logTerminationFailure(handle, error);
|
|
404
|
+
});
|
|
260
405
|
}
|
|
261
|
-
/**
|
|
262
|
-
|
|
406
|
+
/** Stop every runner and join all child exits. Idempotent across concurrent calls. */
|
|
407
|
+
stop() {
|
|
408
|
+
if (this.stopPromise)
|
|
409
|
+
return this.stopPromise;
|
|
410
|
+
this.stopping = true;
|
|
263
411
|
if (this.reapTimer) {
|
|
264
412
|
clearInterval(this.reapTimer);
|
|
265
413
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
414
|
+
const children = [...this.childHandles];
|
|
415
|
+
this.stopPromise = (async () => {
|
|
416
|
+
const results = await Promise.allSettled(children.map((handle) => this.terminateHandle(handle, "runner manager stopped")));
|
|
417
|
+
this.handles.clear();
|
|
418
|
+
this.liveSessionKeys.clear();
|
|
419
|
+
this.liveErrors.clear();
|
|
420
|
+
this.mirrorListener = null;
|
|
421
|
+
this.rotateListener = null;
|
|
422
|
+
const errors = results
|
|
423
|
+
.filter((result) => result.status === "rejected")
|
|
424
|
+
.map((result) => result.reason);
|
|
425
|
+
if (errors.length > 0) {
|
|
426
|
+
throw new AggregateError(errors, "runner manager shutdown failed");
|
|
427
|
+
}
|
|
428
|
+
this.childHandles.clear();
|
|
429
|
+
})();
|
|
430
|
+
return this.stopPromise;
|
|
271
431
|
}
|
|
272
432
|
// ── internals ──────────────────────────────────────────────────────────────
|
|
273
433
|
/** Forward a capability to a runner child. Session-less caps (listModels/status)
|
|
@@ -283,6 +443,9 @@ export class RunnerManager {
|
|
|
283
443
|
});
|
|
284
444
|
}
|
|
285
445
|
getOrSpawn(key) {
|
|
446
|
+
if (this.stopping) {
|
|
447
|
+
throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
|
|
448
|
+
}
|
|
286
449
|
this.reapIdle();
|
|
287
450
|
const existing = this.handles.get(key);
|
|
288
451
|
if (existing && !existing.dead) {
|
|
@@ -297,13 +460,29 @@ export class RunnerManager {
|
|
|
297
460
|
const args = this.runnerEntry.endsWith(".ts")
|
|
298
461
|
? ["--import", "tsx", this.runnerEntry]
|
|
299
462
|
: [this.runnerEntry];
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
463
|
+
const sessionContext = key === CAP_KEY
|
|
464
|
+
? undefined
|
|
465
|
+
: this.openSessionContext(key);
|
|
466
|
+
let child;
|
|
467
|
+
try {
|
|
468
|
+
child = this.spawn(process.execPath, args, {
|
|
469
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
470
|
+
// `RYNX_RUNNER_SESSION` = the child's session key (localThreadId, or `__cap__`
|
|
471
|
+
// for the shared capability child) so its LocalAgentHost scopes the private
|
|
472
|
+
// CODEX_HOME to this session. It is deliberately applied last.
|
|
473
|
+
env: {
|
|
474
|
+
...process.env,
|
|
475
|
+
...this.childEnv,
|
|
476
|
+
...(sessionContext?.childEnv ?? {}),
|
|
477
|
+
RYNX_RUNNER_SESSION: key,
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
closeSessionContext(sessionContext);
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
const completion = observeChildCompletion(child);
|
|
307
486
|
const transport = new StdioRunnerTransport(child.stdout, child.stdin);
|
|
308
487
|
const handle = {
|
|
309
488
|
key,
|
|
@@ -312,10 +491,14 @@ export class RunnerManager {
|
|
|
312
491
|
stderr: [],
|
|
313
492
|
lastUsedAt: this.now(),
|
|
314
493
|
dead: false,
|
|
494
|
+
completion,
|
|
495
|
+
...(sessionContext ? { sessionContext } : {}),
|
|
315
496
|
caps: new Map(),
|
|
316
497
|
terminals: new Map(),
|
|
317
498
|
live: new Map(),
|
|
318
499
|
};
|
|
500
|
+
this.childHandles.add(handle);
|
|
501
|
+
void completion.then(() => this.childHandles.delete(handle));
|
|
319
502
|
transport.onMessage((msg) => this.onChildMessage(handle, msg));
|
|
320
503
|
if (child.stderr) {
|
|
321
504
|
const rl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity });
|
|
@@ -363,13 +546,28 @@ export class RunnerManager {
|
|
|
363
546
|
case "term.error": {
|
|
364
547
|
const terminal = handle.terminals.get(msg.attachId);
|
|
365
548
|
handle.terminals.delete(msg.attachId);
|
|
366
|
-
terminal?._fail(msg.message);
|
|
549
|
+
terminal?._fail(msg.message, msg.code);
|
|
367
550
|
return;
|
|
368
551
|
}
|
|
369
552
|
case "mirror":
|
|
370
553
|
this.mirrorListener?.(msg.sessionId, msg.event);
|
|
371
554
|
return;
|
|
372
555
|
case "rotate": {
|
|
556
|
+
if (handle.dead)
|
|
557
|
+
return;
|
|
558
|
+
try {
|
|
559
|
+
// Rotate the runtime-local context before publishing the new Session
|
|
560
|
+
// alias to routing or observers. This prevents the new identity from
|
|
561
|
+
// briefly inheriting stale context after `/clear` or `/fork`.
|
|
562
|
+
handle.sessionContext?.rotate(msg.to);
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
|
|
566
|
+
void this.terminateHandle(handle, reason).catch((terminationError) => {
|
|
567
|
+
logTerminationFailure(handle, terminationError);
|
|
568
|
+
});
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
373
571
|
// Terminal transfer: alias the new session to THIS runner so its injection
|
|
374
572
|
// (and live/approval) route to the same child that still owns the pane.
|
|
375
573
|
this.handles.set(msg.to, handle);
|
|
@@ -395,6 +593,12 @@ export class RunnerManager {
|
|
|
395
593
|
: { ok: msg.ok, error: msg.error });
|
|
396
594
|
return;
|
|
397
595
|
}
|
|
596
|
+
case "interaction.resolved": {
|
|
597
|
+
const resolve = handle.live.get(msg.reqId);
|
|
598
|
+
handle.live.delete(msg.reqId);
|
|
599
|
+
resolve?.({ interactionResult: msg.result });
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
398
602
|
}
|
|
399
603
|
}
|
|
400
604
|
/** Mark a handle dead and reject every pending run/cap with the exit reason. */
|
|
@@ -403,11 +607,14 @@ export class RunnerManager {
|
|
|
403
607
|
return;
|
|
404
608
|
}
|
|
405
609
|
handle.dead = true;
|
|
610
|
+
closeOwnedSessionContext(handle);
|
|
406
611
|
// Drop every key mapping to this handle — its launch key AND any rotation
|
|
407
612
|
// aliases (claude `/clear`·`/fork` terminal transfer).
|
|
408
613
|
for (const [key, h] of this.handles) {
|
|
409
|
-
if (h === handle)
|
|
614
|
+
if (h === handle) {
|
|
410
615
|
this.handles.delete(key);
|
|
616
|
+
this.liveSessionKeys.delete(key);
|
|
617
|
+
}
|
|
411
618
|
}
|
|
412
619
|
const tail = handle.stderr.join("\n");
|
|
413
620
|
const message = tail ? `${reason}\n--- runner log tail ---\n${tail}` : reason;
|
|
@@ -424,9 +631,32 @@ export class RunnerManager {
|
|
|
424
631
|
handle.caps.clear();
|
|
425
632
|
handle.terminals.clear();
|
|
426
633
|
handle.live.clear();
|
|
427
|
-
this.liveSessionKeys.delete(handle.key);
|
|
428
634
|
handle.transport.close();
|
|
429
635
|
}
|
|
636
|
+
terminateHandle(handle, reason) {
|
|
637
|
+
if (handle.termination)
|
|
638
|
+
return handle.termination;
|
|
639
|
+
handle.termination = (async () => {
|
|
640
|
+
if (!handle.dead) {
|
|
641
|
+
try {
|
|
642
|
+
handle.transport.send({ t: "shutdown" });
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
// The process signal below remains the authoritative shutdown path.
|
|
646
|
+
}
|
|
647
|
+
this.failHandle(handle, reason);
|
|
648
|
+
}
|
|
649
|
+
signalChild(handle.child, "SIGTERM");
|
|
650
|
+
if (!(await waitForChildExit(handle.completion, this.shutdownGraceMs))) {
|
|
651
|
+
signalChild(handle.child, "SIGKILL");
|
|
652
|
+
if (!(await waitForChildExit(handle.completion, this.shutdownKillGraceMs))) {
|
|
653
|
+
throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
this.childHandles.delete(handle);
|
|
657
|
+
})();
|
|
658
|
+
return handle.termination;
|
|
659
|
+
}
|
|
430
660
|
reapIdle() {
|
|
431
661
|
const now = this.now();
|
|
432
662
|
for (const [key, handle] of this.handles) {
|
|
@@ -441,14 +671,106 @@ export class RunnerManager {
|
|
|
441
671
|
if (now - handle.lastUsedAt < this.idleTtlMs) {
|
|
442
672
|
continue;
|
|
443
673
|
}
|
|
444
|
-
handle.
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
674
|
+
void this.terminateHandle(handle, "idle runner reaped").catch((error) => {
|
|
675
|
+
logTerminationFailure(handle, error);
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
openSessionContext(sessionId) {
|
|
680
|
+
if (!this.sessionContextProvider)
|
|
681
|
+
return undefined;
|
|
682
|
+
const context = this.sessionContextProvider.open(sessionId);
|
|
683
|
+
try {
|
|
684
|
+
validateSessionContext(context);
|
|
685
|
+
return {
|
|
686
|
+
childEnv: Object.freeze({ ...context.childEnv }),
|
|
687
|
+
rotate: (newSessionId) => context.rotate(newSessionId),
|
|
688
|
+
close: () => context.close(),
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
catch (error) {
|
|
692
|
+
closeSessionContext(context);
|
|
693
|
+
throw error;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
function validateSessionContext(context) {
|
|
698
|
+
if (!context || typeof context !== "object") {
|
|
699
|
+
throw new Error("Session context environment provider must return an object");
|
|
700
|
+
}
|
|
701
|
+
if (typeof context.rotate !== "function" || typeof context.close !== "function") {
|
|
702
|
+
throw new Error("Session context environment must provide rotate() and close()");
|
|
703
|
+
}
|
|
704
|
+
if (!context.childEnv || typeof context.childEnv !== "object" || Array.isArray(context.childEnv)) {
|
|
705
|
+
throw new Error("Session context environment must be a record");
|
|
706
|
+
}
|
|
707
|
+
const entries = Object.entries(context.childEnv);
|
|
708
|
+
if (entries.length > MAX_SESSION_CONTEXT_ENV_ENTRIES) {
|
|
709
|
+
throw new Error(`Session context environment exceeds ${MAX_SESSION_CONTEXT_ENV_ENTRIES} entries`);
|
|
710
|
+
}
|
|
711
|
+
let totalBytes = 0;
|
|
712
|
+
for (const [key, value] of entries) {
|
|
713
|
+
const normalizedKey = key.toUpperCase();
|
|
714
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
715
|
+
throw new Error(`Session context environment key is invalid: ${JSON.stringify(key)}`);
|
|
716
|
+
}
|
|
717
|
+
if (isReservedSessionContextEnvKey(normalizedKey)) {
|
|
718
|
+
throw new Error(`Session context environment key is reserved: ${key}`);
|
|
719
|
+
}
|
|
720
|
+
if (typeof value !== "string" || value.includes("\0")) {
|
|
721
|
+
throw new Error(`Session context environment value is invalid for ${key}`);
|
|
722
|
+
}
|
|
723
|
+
const keyBytes = Buffer.byteLength(key);
|
|
724
|
+
const valueBytes = Buffer.byteLength(value);
|
|
725
|
+
if (keyBytes > MAX_SESSION_CONTEXT_ENV_KEY_BYTES) {
|
|
726
|
+
throw new Error(`Session context environment key is too large: ${key}`);
|
|
727
|
+
}
|
|
728
|
+
if (valueBytes > MAX_SESSION_CONTEXT_ENV_VALUE_BYTES) {
|
|
729
|
+
throw new Error(`Session context environment value is too large for ${key}`);
|
|
449
730
|
}
|
|
731
|
+
totalBytes += keyBytes + valueBytes;
|
|
732
|
+
if (totalBytes > MAX_SESSION_CONTEXT_ENV_TOTAL_BYTES) {
|
|
733
|
+
throw new Error("Session context environment is too large");
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
function isReservedSessionContextEnvKey(key) {
|
|
738
|
+
return (key.startsWith("RYNX_RUNNER_") ||
|
|
739
|
+
key === "NODE_OPTIONS" ||
|
|
740
|
+
key === "NODE_PATH" ||
|
|
741
|
+
key === "NODE_UNIQUE_ID" ||
|
|
742
|
+
key.startsWith("NODE_CHANNEL_") ||
|
|
743
|
+
key === "ELECTRON_RUN_AS_NODE" ||
|
|
744
|
+
key === "LD_PRELOAD" ||
|
|
745
|
+
key === "LD_LIBRARY_PATH" ||
|
|
746
|
+
key === "DYLD_INSERT_LIBRARIES" ||
|
|
747
|
+
key === "DYLD_LIBRARY_PATH");
|
|
748
|
+
}
|
|
749
|
+
function closeOwnedSessionContext(handle) {
|
|
750
|
+
const context = handle.sessionContext;
|
|
751
|
+
if (!context)
|
|
752
|
+
return;
|
|
753
|
+
handle.sessionContext = undefined;
|
|
754
|
+
closeSessionContext(context);
|
|
755
|
+
}
|
|
756
|
+
function closeSessionContext(context) {
|
|
757
|
+
if (!context || typeof context.close !== "function")
|
|
758
|
+
return;
|
|
759
|
+
try {
|
|
760
|
+
context.close();
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
console.error(JSON.stringify({
|
|
764
|
+
level: "error",
|
|
765
|
+
type: "runner",
|
|
766
|
+
event: "session_context_close_failed",
|
|
767
|
+
error: errorMessage(error),
|
|
768
|
+
}));
|
|
450
769
|
}
|
|
451
770
|
}
|
|
771
|
+
function errorMessage(error) {
|
|
772
|
+
return error instanceof Error ? error.message : String(error);
|
|
773
|
+
}
|
|
452
774
|
function defaultRunnerEntry() {
|
|
453
775
|
if (process.env.RYNX_RUNNER_ENTRY?.trim()) {
|
|
454
776
|
return process.env.RYNX_RUNNER_ENTRY.trim();
|
|
@@ -456,3 +778,61 @@ function defaultRunnerEntry() {
|
|
|
456
778
|
// dist/runner/manager.js → dist/runner-main.js
|
|
457
779
|
return fileURLToPath(new URL("../runner-main.js", import.meta.url));
|
|
458
780
|
}
|
|
781
|
+
function observeChildCompletion(child) {
|
|
782
|
+
if (childHasExited(child))
|
|
783
|
+
return Promise.resolve();
|
|
784
|
+
return new Promise((resolve) => {
|
|
785
|
+
const onExit = () => {
|
|
786
|
+
child.off("error", onSpawnError);
|
|
787
|
+
resolve();
|
|
788
|
+
};
|
|
789
|
+
const onSpawnError = () => {
|
|
790
|
+
// A successfully spawned process may emit `error` when a later kill fails;
|
|
791
|
+
// that is not completion. A spawn failure has no pid and no future exit.
|
|
792
|
+
if (child.pid !== undefined)
|
|
793
|
+
return;
|
|
794
|
+
child.off("exit", onExit);
|
|
795
|
+
resolve();
|
|
796
|
+
};
|
|
797
|
+
child.once("error", onSpawnError);
|
|
798
|
+
child.once("exit", onExit);
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
function signalChild(child, signal) {
|
|
802
|
+
if (childHasExited(child))
|
|
803
|
+
return;
|
|
804
|
+
try {
|
|
805
|
+
child.kill(signal);
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
// The bounded completion wait distinguishes an exit race from a stuck child.
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
function childHasExited(child) {
|
|
812
|
+
return ((child.exitCode !== null && child.exitCode !== undefined) ||
|
|
813
|
+
(child.signalCode !== null && child.signalCode !== undefined));
|
|
814
|
+
}
|
|
815
|
+
function waitForChildExit(completion, timeoutMs) {
|
|
816
|
+
return new Promise((resolve) => {
|
|
817
|
+
let settled = false;
|
|
818
|
+
const finish = (exited) => {
|
|
819
|
+
if (settled)
|
|
820
|
+
return;
|
|
821
|
+
settled = true;
|
|
822
|
+
clearTimeout(timer);
|
|
823
|
+
resolve(exited);
|
|
824
|
+
};
|
|
825
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
826
|
+
void completion.then(() => finish(true));
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
function logTerminationFailure(handle, error) {
|
|
830
|
+
console.error(JSON.stringify({
|
|
831
|
+
level: "error",
|
|
832
|
+
type: "runner",
|
|
833
|
+
event: "termination_failed",
|
|
834
|
+
key: handle.key,
|
|
835
|
+
pid: handle.child.pid,
|
|
836
|
+
error: error instanceof Error ? error.message : String(error),
|
|
837
|
+
}));
|
|
838
|
+
}
|