@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.
Files changed (56) 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 +327 -38
  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 +123 -16
  9. package/dist/claude/native-integration.js +624 -81
  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 +14 -3
  14. package/dist/codex/rollout-synth.d.ts +8 -3
  15. package/dist/codex/rollout-synth.js +65 -32
  16. package/dist/codex-app-server/client.d.ts +27 -40
  17. package/dist/codex-app-server/client.js +1134 -99
  18. package/dist/codex-app-server/forwarder.d.ts +36 -10
  19. package/dist/codex-app-server/forwarder.js +146 -28
  20. package/dist/codex-app-server/mapping.d.ts +1 -1
  21. package/dist/codex-app-server/mapping.js +64 -5
  22. package/dist/codex-app-server/protocol.d.ts +269 -4
  23. package/dist/codex-app-server/transport.d.ts +20 -5
  24. package/dist/codex-app-server/transport.js +93 -40
  25. package/dist/codex-app-server/ws-channel.d.ts +3 -3
  26. package/dist/codex-app-server/ws-channel.js +23 -7
  27. package/dist/codex-child-env.js +33 -0
  28. package/dist/codex-home.d.ts +16 -6
  29. package/dist/codex-home.js +46 -15
  30. package/dist/codex-session-store.d.ts +2 -1
  31. package/dist/host.d.ts +38 -38
  32. package/dist/host.js +626 -121
  33. package/dist/index.d.ts +4 -3
  34. package/dist/index.js +1 -1
  35. package/dist/input-resources.d.ts +13 -0
  36. package/dist/input-resources.js +67 -0
  37. package/dist/interactions.d.ts +61 -0
  38. package/dist/interactions.js +236 -0
  39. package/dist/models-catalog.d.ts +5 -13
  40. package/dist/models-catalog.js +60 -9
  41. package/dist/runner/child.d.ts +9 -1
  42. package/dist/runner/child.js +100 -19
  43. package/dist/runner/manager.d.ts +79 -11
  44. package/dist/runner/manager.js +423 -43
  45. package/dist/runner/protocol.d.ts +30 -11
  46. package/dist/runner-main.js +9 -6
  47. package/dist/runtime-status.js +1 -1
  48. package/dist/terminal/claude-tui.d.ts +8 -3
  49. package/dist/terminal/claude-tui.js +6 -2
  50. package/dist/terminal/codex-tui.d.ts +3 -3
  51. package/dist/terminal/codex-tui.js +1 -1
  52. package/dist/terminal/registry.d.ts +1 -1
  53. package/dist/terminal/registry.js +1 -1
  54. package/dist/terminal/tmux.d.ts +6 -6
  55. package/dist/terminal/tmux.js +10 -10
  56. package/package.json +8 -3
@@ -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
- constructor(attachId, sendMsg) {
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.exitListener?.({ exitCode });
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.rejectReady(new Error(message));
76
- this.exitListener?.({ exitCode: 1 });
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
- handle.live.set(reqId, (res) => resolve(res.ok ?? false));
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 — omnigent's
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, text) {
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({ t: "inject", reqId, localThreadId, text });
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
- * 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) {
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.transport.send({ t: "approval.resolve", localThreadId, approvalId, decision });
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
- handle.transport.send({ t: "shutdown" });
258
- handle.child.kill("SIGTERM");
259
- this.failHandle(handle, "runner stopped");
402
+ void this.terminateHandle(handle, "runner stopped").catch((error) => {
403
+ logTerminationFailure(handle, error);
404
+ });
260
405
  }
261
- /** Kill every runner. Call on server shutdown. */
262
- async stop() {
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
- for (const handle of this.handles.values()) {
267
- handle.transport.send({ t: "shutdown" });
268
- handle.child.kill("SIGTERM");
269
- }
270
- this.handles.clear();
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 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
- });
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.dead = true;
445
- this.handles.delete(key);
446
- handle.transport.send({ t: "shutdown" });
447
- handle.child.kill("SIGTERM");
448
- handle.transport.close();
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
+ }