@rynx-ai/runtime 0.1.0 → 0.1.9

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.
Files changed (53) hide show
  1. package/dist/claude/executor.d.ts +3 -5
  2. package/dist/claude/executor.js +3 -5
  3. package/dist/claude/native-bridge.d.ts +74 -17
  4. package/dist/claude/native-bridge.js +225 -30
  5. package/dist/claude/native-hook-main.js +291 -39
  6. package/dist/claude/native-hooks.d.ts +3 -2
  7. package/dist/claude/native-hooks.js +15 -6
  8. package/dist/claude/native-integration.d.ts +78 -5
  9. package/dist/claude/native-integration.js +417 -26
  10. package/dist/claude/settings.d.ts +8 -0
  11. package/dist/claude/settings.js +50 -0
  12. package/dist/claude/transcript.d.ts +2 -2
  13. package/dist/claude/transcript.js +3 -3
  14. package/dist/codex/rollout-synth.js +1 -1
  15. package/dist/codex-app-server/client.d.ts +26 -40
  16. package/dist/codex-app-server/client.js +1128 -99
  17. package/dist/codex-app-server/forwarder.d.ts +7 -7
  18. package/dist/codex-app-server/forwarder.js +11 -5
  19. package/dist/codex-app-server/mapping.d.ts +1 -1
  20. package/dist/codex-app-server/mapping.js +27 -2
  21. package/dist/codex-app-server/protocol.d.ts +238 -4
  22. package/dist/codex-app-server/transport.d.ts +20 -5
  23. package/dist/codex-app-server/transport.js +93 -40
  24. package/dist/codex-app-server/ws-channel.d.ts +3 -3
  25. package/dist/codex-app-server/ws-channel.js +23 -7
  26. package/dist/codex-child-env.js +33 -0
  27. package/dist/codex-home.d.ts +6 -6
  28. package/dist/codex-home.js +8 -9
  29. package/dist/codex-session-store.d.ts +2 -1
  30. package/dist/host.d.ts +34 -33
  31. package/dist/host.js +531 -91
  32. package/dist/index.d.ts +4 -3
  33. package/dist/index.js +1 -1
  34. package/dist/interactions.d.ts +61 -0
  35. package/dist/interactions.js +236 -0
  36. package/dist/models-catalog.d.ts +1 -1
  37. package/dist/models-catalog.js +1 -1
  38. package/dist/runner/child.d.ts +9 -1
  39. package/dist/runner/child.js +93 -15
  40. package/dist/runner/manager.d.ts +59 -10
  41. package/dist/runner/manager.js +385 -41
  42. package/dist/runner/protocol.d.ts +18 -7
  43. package/dist/runner-main.js +9 -6
  44. package/dist/runtime-status.js +1 -1
  45. package/dist/terminal/claude-tui.d.ts +8 -3
  46. package/dist/terminal/claude-tui.js +6 -2
  47. package/dist/terminal/codex-tui.d.ts +3 -3
  48. package/dist/terminal/codex-tui.js +1 -1
  49. package/dist/terminal/registry.d.ts +1 -1
  50. package/dist/terminal/registry.js +1 -1
  51. package/dist/terminal/tmux.d.ts +6 -6
  52. package/dist/terminal/tmux.js +10 -10
  53. package/package.json +3 -3
@@ -28,17 +28,40 @@ 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
+ /** Keep per-Session spawn context small enough to remain an environment handoff,
36
+ * not an unbounded transport. */
37
+ const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
38
+ const MAX_SESSION_CONTEXT_ENV_KEY_BYTES = 128;
39
+ const MAX_SESSION_CONTEXT_ENV_VALUE_BYTES = 8_192;
40
+ const MAX_SESSION_CONTEXT_ENV_TOTAL_BYTES = 32_768;
41
+ /** A typed attach failure so transport adapters can distinguish an expected
42
+ * absent pane from an infrastructure failure without matching error strings. */
43
+ export class TerminalOpenError extends Error {
44
+ code;
45
+ name = "TerminalOpenError";
46
+ constructor(message, code) {
47
+ super(message);
48
+ this.code = code;
49
+ }
50
+ }
31
51
  class ManagedTerminal {
32
52
  attachId;
33
53
  sendMsg;
54
+ onClose;
34
55
  ready;
35
56
  dataListener;
36
57
  exitListener;
37
58
  resolveReady;
38
59
  rejectReady;
39
- constructor(attachId, sendMsg) {
60
+ state = "opening";
61
+ constructor(attachId, sendMsg, onClose) {
40
62
  this.attachId = attachId;
41
63
  this.sendMsg = sendMsg;
64
+ this.onClose = onClose;
42
65
  this.ready = new Promise((resolve, reject) => {
43
66
  this.resolveReady = resolve;
44
67
  this.rejectReady = reject;
@@ -59,21 +82,49 @@ class ManagedTerminal {
59
82
  this.sendMsg({ t: "term.resize", attachId: this.attachId, cols, rows });
60
83
  }
61
84
  close() {
85
+ if (this.state === "closed")
86
+ return;
87
+ const wasOpening = this.state === "opening";
88
+ this.state = "closed";
62
89
  this.sendMsg({ t: "term.close", attachId: this.attachId });
90
+ this.onClose();
91
+ if (wasOpening) {
92
+ this.rejectReady(new TerminalOpenError("terminal attachment was closed while opening", "terminal_open_failed"));
93
+ }
63
94
  }
64
95
  // ── internal (driven by onChildMessage) ──
65
96
  _opened(role) {
97
+ if (this.state !== "opening")
98
+ return;
99
+ this.state = "opened";
66
100
  this.resolveReady({ role });
67
101
  }
68
102
  _data(dataB64) {
69
103
  this.dataListener?.(Buffer.from(dataB64, "base64").toString("utf8"));
70
104
  }
71
105
  _exit(exitCode) {
72
- this.exitListener?.({ exitCode });
106
+ if (this.state === "closed")
107
+ return;
108
+ const wasOpening = this.state === "opening";
109
+ this.state = "closed";
110
+ if (wasOpening) {
111
+ this.rejectReady(new TerminalOpenError(`terminal exited before opening (code=${exitCode})`, "terminal_open_failed"));
112
+ }
113
+ else {
114
+ this.exitListener?.({ exitCode });
115
+ }
73
116
  }
74
- _fail(message) {
75
- this.rejectReady(new Error(message));
76
- this.exitListener?.({ exitCode: 1 });
117
+ _fail(message, code = "terminal_open_failed") {
118
+ if (this.state === "closed")
119
+ return;
120
+ const wasOpening = this.state === "opening";
121
+ this.state = "closed";
122
+ if (wasOpening) {
123
+ this.rejectReady(new TerminalOpenError(message, code));
124
+ }
125
+ else {
126
+ this.exitListener?.({ exitCode: 1 });
127
+ }
77
128
  }
78
129
  }
79
130
  export class RunnerManager {
@@ -83,16 +134,26 @@ export class RunnerManager {
83
134
  idleTtlMs;
84
135
  spawn;
85
136
  childEnv;
137
+ sessionContextProvider;
86
138
  now;
87
139
  defaultRuntime;
88
140
  handles = new Map();
141
+ /** Every spawned child that has not exited (or failed to spawn), including
142
+ * handles already removed from routing by stopRunner/idle reap. */
143
+ childHandles = new Set();
89
144
  reapTimer;
145
+ shutdownGraceMs;
146
+ shutdownKillGraceMs;
147
+ stopping = false;
148
+ stopPromise;
90
149
  /** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
91
150
  mirrorListener = null;
92
151
  /** Sink for session rotations (claude `/clear`·`/fork`) — server records meta. */
93
152
  rotateListener = null;
94
153
  /** Session keys with a live codex forwarder — never reaped while present. */
95
154
  liveSessionKeys = new Set();
155
+ /** Last live-start error per local session, surfaced by the control API. */
156
+ liveErrors = new Map();
96
157
  constructor(opts) {
97
158
  this.config = opts.config;
98
159
  this.sessionStore = opts.sessionStore;
@@ -100,8 +161,11 @@ export class RunnerManager {
100
161
  this.idleTtlMs = opts.idleTtlMs ?? 300_000;
101
162
  this.spawn = opts.spawn ?? nodeSpawn;
102
163
  this.childEnv = opts.childEnv ?? {};
164
+ this.sessionContextProvider = opts.sessionContextProvider;
103
165
  this.now = opts.now ?? (() => Date.now());
104
166
  this.defaultRuntime = opts.config.AGENT_RUNTIME ?? "codex";
167
+ this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
168
+ this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
105
169
  const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
106
170
  if (reapIntervalMs > 0) {
107
171
  this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
@@ -121,9 +185,24 @@ export class RunnerManager {
121
185
  */
122
186
  openTerminal(localThreadId, opts) {
123
187
  const handle = this.getOrSpawn(localThreadId);
188
+ return this.openTerminalOnHandle(handle, localThreadId, opts);
189
+ }
190
+ /**
191
+ * Attach to a Session runner that is already live. Unlike {@link openTerminal},
192
+ * this never creates a child, including when liveness changes between lookup
193
+ * and attach.
194
+ */
195
+ openLiveTerminal(localThreadId, opts) {
196
+ const handle = this.handles.get(localThreadId);
197
+ if (!handle || handle.dead || !this.liveSessionKeys.has(localThreadId)) {
198
+ throw new TerminalOpenError("terminal not live", "terminal_not_live");
199
+ }
200
+ return this.openTerminalOnHandle(handle, localThreadId, opts);
201
+ }
202
+ openTerminalOnHandle(handle, localThreadId, opts) {
124
203
  handle.lastUsedAt = this.now();
125
204
  const attachId = randomUUID();
126
- const terminal = new ManagedTerminal(attachId, (msg) => handle.transport.send(msg));
205
+ const terminal = new ManagedTerminal(attachId, (msg) => handle.transport.send(msg), () => handle.terminals.delete(attachId));
127
206
  handle.terminals.set(attachId, terminal);
128
207
  handle.transport.send({
129
208
  t: "term.open",
@@ -140,6 +219,11 @@ export class RunnerManager {
140
219
  });
141
220
  return terminal;
142
221
  }
222
+ /** Whether this process already owns a live runner for the Session. Read-only; never spawns. */
223
+ hasLiveSession(localThreadId) {
224
+ const handle = this.handles.get(localThreadId);
225
+ return Boolean(handle && !handle.dead && this.liveSessionKeys.has(localThreadId));
226
+ }
143
227
  /**
144
228
  * Register the sink for mirrored {@link SessionEvent}s produced by every
145
229
  * session's persistent codex forwarder (web- AND TUI-initiated turns). The
@@ -165,7 +249,16 @@ export class RunnerManager {
165
249
  this.liveSessionKeys.add(localThreadId);
166
250
  const reqId = randomUUID();
167
251
  return new Promise((resolve) => {
168
- handle.live.set(reqId, (res) => resolve(res.ok ?? false));
252
+ handle.live.set(reqId, (res) => {
253
+ const ok = res.ok ?? false;
254
+ if (ok) {
255
+ this.liveErrors.delete(localThreadId);
256
+ }
257
+ else {
258
+ this.liveErrors.set(localThreadId, res.error ?? "live session did not become ready");
259
+ }
260
+ resolve(ok);
261
+ });
169
262
  handle.transport.send({
170
263
  t: "live.ensure",
171
264
  reqId,
@@ -174,13 +267,17 @@ export class RunnerManager {
174
267
  ...(opts?.cols ? { cols: opts.cols } : {}),
175
268
  ...(opts?.rows ? { rows: opts.rows } : {}),
176
269
  ...(opts?.runtime ? { runtime: opts.runtime } : {}),
270
+ ...(opts?.reasoningEffort ? { reasoningEffort: opts.reasoningEffort } : {}),
177
271
  ...(opts?.agentName ? { agentName: opts.agentName } : {}),
178
272
  ...(opts?.agentSpec ? { agentSpec: opts.agentSpec } : {}),
179
273
  });
180
274
  });
181
275
  }
276
+ lastLiveSessionError(localThreadId) {
277
+ return this.liveErrors.get(localThreadId);
278
+ }
182
279
  /**
183
- * Inject a user turn into a session's live codex thread — omnigent's
280
+ * Inject a user turn into a session's live codex thread — reference implementation's
184
281
  * single-writer web send (`turn/start` / `turn/steer`); the forwarder mirrors
185
282
  * all output. Resolves true when the app-server accepted the turn, false when
186
283
  * the session has no live forwarder (caller falls back to the run path).
@@ -210,17 +307,29 @@ export class RunnerManager {
210
307
  handle.transport.send({ t: "live.interrupt", reqId, localThreadId });
211
308
  });
212
309
  }
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) {
310
+ /** Resolve a native question/approval without ever spawning a new runner. */
311
+ resolveInteraction(localThreadId, interactionId, resolution) {
219
312
  const handle = this.handles.get(localThreadId);
220
313
  if (!handle || handle.dead) {
221
- return;
314
+ return Promise.resolve({ disposition: "not_found" });
222
315
  }
223
- handle.transport.send({ t: "approval.resolve", localThreadId, approvalId, decision });
316
+ handle.lastUsedAt = this.now();
317
+ const reqId = randomUUID();
318
+ return new Promise((resolve) => {
319
+ handle.live.set(reqId, (reply) => {
320
+ resolve(reply.interactionResult ?? {
321
+ disposition: "invalid",
322
+ message: reply.error ?? "runner did not return an interaction result",
323
+ });
324
+ });
325
+ handle.transport.send({
326
+ t: "interaction.resolve",
327
+ reqId,
328
+ localThreadId,
329
+ interactionId,
330
+ resolution,
331
+ });
332
+ });
224
333
  }
225
334
  // ── AgentCapabilities ──────────────────────────────────────────────────────
226
335
  async listModels(runtime) {
@@ -254,20 +363,35 @@ export class RunnerManager {
254
363
  if (!handle) {
255
364
  return;
256
365
  }
257
- handle.transport.send({ t: "shutdown" });
258
- handle.child.kill("SIGTERM");
259
- this.failHandle(handle, "runner stopped");
366
+ void this.terminateHandle(handle, "runner stopped").catch((error) => {
367
+ logTerminationFailure(handle, error);
368
+ });
260
369
  }
261
- /** Kill every runner. Call on server shutdown. */
262
- async stop() {
370
+ /** Stop every runner and join all child exits. Idempotent across concurrent calls. */
371
+ stop() {
372
+ if (this.stopPromise)
373
+ return this.stopPromise;
374
+ this.stopping = true;
263
375
  if (this.reapTimer) {
264
376
  clearInterval(this.reapTimer);
265
377
  }
266
- for (const handle of this.handles.values()) {
267
- handle.transport.send({ t: "shutdown" });
268
- handle.child.kill("SIGTERM");
269
- }
270
- this.handles.clear();
378
+ const children = [...this.childHandles];
379
+ this.stopPromise = (async () => {
380
+ const results = await Promise.allSettled(children.map((handle) => this.terminateHandle(handle, "runner manager stopped")));
381
+ this.handles.clear();
382
+ this.liveSessionKeys.clear();
383
+ this.liveErrors.clear();
384
+ this.mirrorListener = null;
385
+ this.rotateListener = null;
386
+ const errors = results
387
+ .filter((result) => result.status === "rejected")
388
+ .map((result) => result.reason);
389
+ if (errors.length > 0) {
390
+ throw new AggregateError(errors, "runner manager shutdown failed");
391
+ }
392
+ this.childHandles.clear();
393
+ })();
394
+ return this.stopPromise;
271
395
  }
272
396
  // ── internals ──────────────────────────────────────────────────────────────
273
397
  /** Forward a capability to a runner child. Session-less caps (listModels/status)
@@ -283,6 +407,9 @@ export class RunnerManager {
283
407
  });
284
408
  }
285
409
  getOrSpawn(key) {
410
+ if (this.stopping) {
411
+ throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
412
+ }
286
413
  this.reapIdle();
287
414
  const existing = this.handles.get(key);
288
415
  if (existing && !existing.dead) {
@@ -297,13 +424,29 @@ export class RunnerManager {
297
424
  const args = this.runnerEntry.endsWith(".ts")
298
425
  ? ["--import", "tsx", this.runnerEntry]
299
426
  : [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
- });
427
+ const sessionContext = key === CAP_KEY
428
+ ? undefined
429
+ : this.openSessionContext(key);
430
+ let child;
431
+ try {
432
+ child = this.spawn(process.execPath, args, {
433
+ stdio: ["pipe", "pipe", "pipe"],
434
+ // `RYNX_RUNNER_SESSION` = the child's session key (localThreadId, or `__cap__`
435
+ // for the shared capability child) so its LocalAgentHost scopes the private
436
+ // CODEX_HOME to this session. It is deliberately applied last.
437
+ env: {
438
+ ...process.env,
439
+ ...this.childEnv,
440
+ ...(sessionContext?.childEnv ?? {}),
441
+ RYNX_RUNNER_SESSION: key,
442
+ },
443
+ });
444
+ }
445
+ catch (error) {
446
+ closeSessionContext(sessionContext);
447
+ throw error;
448
+ }
449
+ const completion = observeChildCompletion(child);
307
450
  const transport = new StdioRunnerTransport(child.stdout, child.stdin);
308
451
  const handle = {
309
452
  key,
@@ -312,10 +455,14 @@ export class RunnerManager {
312
455
  stderr: [],
313
456
  lastUsedAt: this.now(),
314
457
  dead: false,
458
+ completion,
459
+ ...(sessionContext ? { sessionContext } : {}),
315
460
  caps: new Map(),
316
461
  terminals: new Map(),
317
462
  live: new Map(),
318
463
  };
464
+ this.childHandles.add(handle);
465
+ void completion.then(() => this.childHandles.delete(handle));
319
466
  transport.onMessage((msg) => this.onChildMessage(handle, msg));
320
467
  if (child.stderr) {
321
468
  const rl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity });
@@ -363,13 +510,28 @@ export class RunnerManager {
363
510
  case "term.error": {
364
511
  const terminal = handle.terminals.get(msg.attachId);
365
512
  handle.terminals.delete(msg.attachId);
366
- terminal?._fail(msg.message);
513
+ terminal?._fail(msg.message, msg.code);
367
514
  return;
368
515
  }
369
516
  case "mirror":
370
517
  this.mirrorListener?.(msg.sessionId, msg.event);
371
518
  return;
372
519
  case "rotate": {
520
+ if (handle.dead)
521
+ return;
522
+ try {
523
+ // Rotate the runtime-local context before publishing the new Session
524
+ // alias to routing or observers. This prevents the new identity from
525
+ // briefly inheriting stale context after `/clear` or `/fork`.
526
+ handle.sessionContext?.rotate(msg.to);
527
+ }
528
+ catch (error) {
529
+ const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
530
+ void this.terminateHandle(handle, reason).catch((terminationError) => {
531
+ logTerminationFailure(handle, terminationError);
532
+ });
533
+ return;
534
+ }
373
535
  // Terminal transfer: alias the new session to THIS runner so its injection
374
536
  // (and live/approval) route to the same child that still owns the pane.
375
537
  this.handles.set(msg.to, handle);
@@ -395,6 +557,12 @@ export class RunnerManager {
395
557
  : { ok: msg.ok, error: msg.error });
396
558
  return;
397
559
  }
560
+ case "interaction.resolved": {
561
+ const resolve = handle.live.get(msg.reqId);
562
+ handle.live.delete(msg.reqId);
563
+ resolve?.({ interactionResult: msg.result });
564
+ return;
565
+ }
398
566
  }
399
567
  }
400
568
  /** Mark a handle dead and reject every pending run/cap with the exit reason. */
@@ -403,11 +571,14 @@ export class RunnerManager {
403
571
  return;
404
572
  }
405
573
  handle.dead = true;
574
+ closeOwnedSessionContext(handle);
406
575
  // Drop every key mapping to this handle — its launch key AND any rotation
407
576
  // aliases (claude `/clear`·`/fork` terminal transfer).
408
577
  for (const [key, h] of this.handles) {
409
- if (h === handle)
578
+ if (h === handle) {
410
579
  this.handles.delete(key);
580
+ this.liveSessionKeys.delete(key);
581
+ }
411
582
  }
412
583
  const tail = handle.stderr.join("\n");
413
584
  const message = tail ? `${reason}\n--- runner log tail ---\n${tail}` : reason;
@@ -424,9 +595,32 @@ export class RunnerManager {
424
595
  handle.caps.clear();
425
596
  handle.terminals.clear();
426
597
  handle.live.clear();
427
- this.liveSessionKeys.delete(handle.key);
428
598
  handle.transport.close();
429
599
  }
600
+ terminateHandle(handle, reason) {
601
+ if (handle.termination)
602
+ return handle.termination;
603
+ handle.termination = (async () => {
604
+ if (!handle.dead) {
605
+ try {
606
+ handle.transport.send({ t: "shutdown" });
607
+ }
608
+ catch {
609
+ // The process signal below remains the authoritative shutdown path.
610
+ }
611
+ this.failHandle(handle, reason);
612
+ }
613
+ signalChild(handle.child, "SIGTERM");
614
+ if (!(await waitForChildExit(handle.completion, this.shutdownGraceMs))) {
615
+ signalChild(handle.child, "SIGKILL");
616
+ if (!(await waitForChildExit(handle.completion, this.shutdownKillGraceMs))) {
617
+ throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
618
+ }
619
+ }
620
+ this.childHandles.delete(handle);
621
+ })();
622
+ return handle.termination;
623
+ }
430
624
  reapIdle() {
431
625
  const now = this.now();
432
626
  for (const [key, handle] of this.handles) {
@@ -441,14 +635,106 @@ export class RunnerManager {
441
635
  if (now - handle.lastUsedAt < this.idleTtlMs) {
442
636
  continue;
443
637
  }
444
- handle.dead = true;
445
- this.handles.delete(key);
446
- handle.transport.send({ t: "shutdown" });
447
- handle.child.kill("SIGTERM");
448
- handle.transport.close();
638
+ void this.terminateHandle(handle, "idle runner reaped").catch((error) => {
639
+ logTerminationFailure(handle, error);
640
+ });
641
+ }
642
+ }
643
+ openSessionContext(sessionId) {
644
+ if (!this.sessionContextProvider)
645
+ return undefined;
646
+ const context = this.sessionContextProvider.open(sessionId);
647
+ try {
648
+ validateSessionContext(context);
649
+ return {
650
+ childEnv: Object.freeze({ ...context.childEnv }),
651
+ rotate: (newSessionId) => context.rotate(newSessionId),
652
+ close: () => context.close(),
653
+ };
654
+ }
655
+ catch (error) {
656
+ closeSessionContext(context);
657
+ throw error;
658
+ }
659
+ }
660
+ }
661
+ function validateSessionContext(context) {
662
+ if (!context || typeof context !== "object") {
663
+ throw new Error("Session context environment provider must return an object");
664
+ }
665
+ if (typeof context.rotate !== "function" || typeof context.close !== "function") {
666
+ throw new Error("Session context environment must provide rotate() and close()");
667
+ }
668
+ if (!context.childEnv || typeof context.childEnv !== "object" || Array.isArray(context.childEnv)) {
669
+ throw new Error("Session context environment must be a record");
670
+ }
671
+ const entries = Object.entries(context.childEnv);
672
+ if (entries.length > MAX_SESSION_CONTEXT_ENV_ENTRIES) {
673
+ throw new Error(`Session context environment exceeds ${MAX_SESSION_CONTEXT_ENV_ENTRIES} entries`);
674
+ }
675
+ let totalBytes = 0;
676
+ for (const [key, value] of entries) {
677
+ const normalizedKey = key.toUpperCase();
678
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
679
+ throw new Error(`Session context environment key is invalid: ${JSON.stringify(key)}`);
680
+ }
681
+ if (isReservedSessionContextEnvKey(normalizedKey)) {
682
+ throw new Error(`Session context environment key is reserved: ${key}`);
683
+ }
684
+ if (typeof value !== "string" || value.includes("\0")) {
685
+ throw new Error(`Session context environment value is invalid for ${key}`);
686
+ }
687
+ const keyBytes = Buffer.byteLength(key);
688
+ const valueBytes = Buffer.byteLength(value);
689
+ if (keyBytes > MAX_SESSION_CONTEXT_ENV_KEY_BYTES) {
690
+ throw new Error(`Session context environment key is too large: ${key}`);
691
+ }
692
+ if (valueBytes > MAX_SESSION_CONTEXT_ENV_VALUE_BYTES) {
693
+ throw new Error(`Session context environment value is too large for ${key}`);
449
694
  }
695
+ totalBytes += keyBytes + valueBytes;
696
+ if (totalBytes > MAX_SESSION_CONTEXT_ENV_TOTAL_BYTES) {
697
+ throw new Error("Session context environment is too large");
698
+ }
699
+ }
700
+ }
701
+ function isReservedSessionContextEnvKey(key) {
702
+ return (key.startsWith("RYNX_RUNNER_") ||
703
+ key === "NODE_OPTIONS" ||
704
+ key === "NODE_PATH" ||
705
+ key === "NODE_UNIQUE_ID" ||
706
+ key.startsWith("NODE_CHANNEL_") ||
707
+ key === "ELECTRON_RUN_AS_NODE" ||
708
+ key === "LD_PRELOAD" ||
709
+ key === "LD_LIBRARY_PATH" ||
710
+ key === "DYLD_INSERT_LIBRARIES" ||
711
+ key === "DYLD_LIBRARY_PATH");
712
+ }
713
+ function closeOwnedSessionContext(handle) {
714
+ const context = handle.sessionContext;
715
+ if (!context)
716
+ return;
717
+ handle.sessionContext = undefined;
718
+ closeSessionContext(context);
719
+ }
720
+ function closeSessionContext(context) {
721
+ if (!context || typeof context.close !== "function")
722
+ return;
723
+ try {
724
+ context.close();
725
+ }
726
+ catch (error) {
727
+ console.error(JSON.stringify({
728
+ level: "error",
729
+ type: "runner",
730
+ event: "session_context_close_failed",
731
+ error: errorMessage(error),
732
+ }));
450
733
  }
451
734
  }
735
+ function errorMessage(error) {
736
+ return error instanceof Error ? error.message : String(error);
737
+ }
452
738
  function defaultRunnerEntry() {
453
739
  if (process.env.RYNX_RUNNER_ENTRY?.trim()) {
454
740
  return process.env.RYNX_RUNNER_ENTRY.trim();
@@ -456,3 +742,61 @@ function defaultRunnerEntry() {
456
742
  // dist/runner/manager.js → dist/runner-main.js
457
743
  return fileURLToPath(new URL("../runner-main.js", import.meta.url));
458
744
  }
745
+ function observeChildCompletion(child) {
746
+ if (childHasExited(child))
747
+ return Promise.resolve();
748
+ return new Promise((resolve) => {
749
+ const onExit = () => {
750
+ child.off("error", onSpawnError);
751
+ resolve();
752
+ };
753
+ const onSpawnError = () => {
754
+ // A successfully spawned process may emit `error` when a later kill fails;
755
+ // that is not completion. A spawn failure has no pid and no future exit.
756
+ if (child.pid !== undefined)
757
+ return;
758
+ child.off("exit", onExit);
759
+ resolve();
760
+ };
761
+ child.once("error", onSpawnError);
762
+ child.once("exit", onExit);
763
+ });
764
+ }
765
+ function signalChild(child, signal) {
766
+ if (childHasExited(child))
767
+ return;
768
+ try {
769
+ child.kill(signal);
770
+ }
771
+ catch {
772
+ // The bounded completion wait distinguishes an exit race from a stuck child.
773
+ }
774
+ }
775
+ function childHasExited(child) {
776
+ return ((child.exitCode !== null && child.exitCode !== undefined) ||
777
+ (child.signalCode !== null && child.signalCode !== undefined));
778
+ }
779
+ function waitForChildExit(completion, timeoutMs) {
780
+ return new Promise((resolve) => {
781
+ let settled = false;
782
+ const finish = (exited) => {
783
+ if (settled)
784
+ return;
785
+ settled = true;
786
+ clearTimeout(timer);
787
+ resolve(exited);
788
+ };
789
+ const timer = setTimeout(() => finish(false), timeoutMs);
790
+ void completion.then(() => finish(true));
791
+ });
792
+ }
793
+ function logTerminationFailure(handle, error) {
794
+ console.error(JSON.stringify({
795
+ level: "error",
796
+ type: "runner",
797
+ event: "termination_failed",
798
+ key: handle.key,
799
+ pid: handle.child.pid,
800
+ error: error instanceof Error ? error.message : String(error),
801
+ }));
802
+ }