@rynx-ai/runtime 0.1.11-beta.2 → 0.1.11-beta.4

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/host.d.ts CHANGED
@@ -191,6 +191,7 @@ export declare class LocalAgentHost implements CodexCapabilities {
191
191
  args: string[];
192
192
  cwd: string;
193
193
  env?: Record<string, string>;
194
+ skipTraexStartupPrompts?: boolean;
194
195
  } | null>;
195
196
  /**
196
197
  * Bring up (idempotently) a session's persistent codex forwarder and emit its
package/dist/host.js CHANGED
@@ -279,7 +279,7 @@ export class LocalAgentHost {
279
279
  this.runtimeHomeSessionId = runtimeHomeSessionId ?? this.sessionId;
280
280
  this.sessionStore = sessionStore;
281
281
  this.allowedRoots = allowedRoots;
282
- this.defaultRuntime = config.AGENT_RUNTIME ?? "codex";
282
+ this.defaultRuntime = config.DEFAULT_RUNTIME ?? "codex";
283
283
  this.injectedCommandRunner = commandRunner;
284
284
  this.injectedAppServerClient = appServerClient;
285
285
  this.clock = now;
@@ -472,6 +472,9 @@ export class LocalAgentHost {
472
472
  // to the same effective sandbox.
473
473
  const sandbox = live?.sandbox ?? this.sessionSandbox ?? this.config.AGENT_SANDBOX;
474
474
  const approvalPolicy = live?.approvalPolicy ?? this.sessionApprovalPolicy ?? this.config.AGENT_APPROVAL_POLICY;
475
+ const traexYolo = runtime === "traex" &&
476
+ sandbox === "danger-full-access" &&
477
+ approvalPolicy === "never";
475
478
  const configOverrides = sandbox === "workspace-write"
476
479
  ? ["sandbox_workspace_write.network_access=true"]
477
480
  : [];
@@ -500,9 +503,11 @@ export class LocalAgentHost {
500
503
  remoteUrl,
501
504
  ...(activeThreadId ? { threadId: activeThreadId } : {}),
502
505
  configOverrides,
506
+ codexArgs: traexYolo ? ["--dangerously-bypass-hook-trust"] : [],
503
507
  additionalDirs: providerAdditionalDirs(live.workspace),
504
508
  }),
505
509
  cwd: live.workspace.cwd,
510
+ ...(runtime === "traex" ? { skipTraexStartupPrompts: true } : {}),
506
511
  // Share the app-server's private CODEX_HOME so the TUI inherits the same
507
512
  // login/settings and skips the real home's update/NUX prompt. RYNX_SESSION_ID
508
513
  // scopes agent-run CLIs (rynx-emulator) to this session at the daemon.
@@ -1633,8 +1638,16 @@ export class LocalAgentHost {
1633
1638
  throw failedPluginSkill.reason;
1634
1639
  }
1635
1640
  const pluginSkills = materializedPluginSkills.flatMap((result) => result.status === "fulfilled" ? result.value : []);
1641
+ const selectedSkills = [...skillEnv.resolved, ...pluginSkills];
1642
+ const selectedNames = new Set();
1643
+ for (const skill of selectedSkills) {
1644
+ if (selectedNames.has(skill.name)) {
1645
+ throw new Error(`duplicate final Session skill name: ${skill.name}`);
1646
+ }
1647
+ selectedNames.add(skill.name);
1648
+ }
1636
1649
  return {
1637
- selectedSkills: [...skillEnv.resolved, ...pluginSkills],
1650
+ selectedSkills,
1638
1651
  skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
1639
1652
  };
1640
1653
  }
package/dist/index.d.ts CHANGED
@@ -13,6 +13,6 @@ export type { InjectOutcome, TerminalOpenErrorCode, TerminalRole } from "./runne
13
13
  export type { ResolveInteractionResult, RuntimeInteractionEvent, RuntimeInteractionListener, } from "./interactions.js";
14
14
  export { probeRuntimeStatus } from "./runtime-status.js";
15
15
  export { listRuntimeModels } from "./models-catalog.js";
16
- export { TmuxTerminal, isTmuxAvailable } from "./terminal/tmux.js";
16
+ export { TmuxTerminal, isTmuxAvailable, terminateTmuxServer, tmuxSocketPath, } from "./terminal/tmux.js";
17
17
  export type { TerminalAttachment, TmuxTerminalOptions } from "./terminal/tmux.js";
18
18
  export { TerminalRegistry } from "./terminal/registry.js";
package/dist/index.js CHANGED
@@ -12,5 +12,5 @@ export { RunnerManager, TerminalOpenError } from "./runner/manager.js";
12
12
  export { probeRuntimeStatus } from "./runtime-status.js";
13
13
  export { listRuntimeModels } from "./models-catalog.js";
14
14
  // Live-terminal subsystem (Phase C): tmux-backed terminals + per-runner registry.
15
- export { TmuxTerminal, isTmuxAvailable } from "./terminal/tmux.js";
15
+ export { TmuxTerminal, isTmuxAvailable, terminateTmuxServer, tmuxSocketPath, } from "./terminal/tmux.js";
16
16
  export { TerminalRegistry } from "./terminal/registry.js";
@@ -31,6 +31,7 @@ interface LiveCodexProvider {
31
31
  args: string[];
32
32
  cwd: string;
33
33
  env?: Record<string, string>;
34
+ skipTraexStartupPrompts?: boolean;
34
35
  } | null>;
35
36
  ensureLiveCodexSession?(localThreadId: string, emit: (event: SessionEvent) => void, opts: {
36
37
  workspace: SessionWorkspaceSnapshot;
@@ -71,6 +72,8 @@ export declare class RunnerSession {
71
72
  /** Live terminals hosted by this session, and per-attach client handles. */
72
73
  private readonly terminals;
73
74
  private readonly attachments;
75
+ private readonly attachmentThreadIds;
76
+ private readonly traexStartupWatchers;
74
77
  /** Opens are async; a close received before attach resolves tombstones the id. */
75
78
  private readonly pendingTerminalOpens;
76
79
  private readonly cancelledTerminalOpens;
@@ -101,6 +104,8 @@ export declare class RunnerSession {
101
104
  * `codexTerminalSpec`. Shares the `${id}-main` terminal id with `term.open`,
102
105
  * so the web attach reuses the same detached pane. */
103
106
  private launchCodexPane;
107
+ private cancelTraexStartupWatcher;
108
+ private skipTraexStartupPrompts;
104
109
  private stopLive;
105
110
  /** Stop event forwarding, kill native terminals/hooks, then synchronously
106
111
  * scrub provider handoff files before the child process is allowed to exit. */
@@ -7,6 +7,8 @@ export class RunnerSession {
7
7
  /** Live terminals hosted by this session, and per-attach client handles. */
8
8
  terminals = new TerminalRegistry();
9
9
  attachments = new Map();
10
+ attachmentThreadIds = new Map();
11
+ traexStartupWatchers = new Map();
10
12
  /** Opens are async; a close received before attach resolves tombstones the id. */
11
13
  pendingTerminalOpens = new Set();
12
14
  cancelledTerminalOpens = new Set();
@@ -46,6 +48,7 @@ export class RunnerSession {
46
48
  });
47
49
  return;
48
50
  case "term.input":
51
+ this.cancelTraexStartupWatcher(this.attachmentThreadIds.get(msg.attachId));
49
52
  this.attachments.get(msg.attachId)?.write(Buffer.from(msg.dataB64, "base64").toString("utf8"));
50
53
  return;
51
54
  case "term.resize":
@@ -57,6 +60,7 @@ export class RunnerSession {
57
60
  }
58
61
  const attachment = this.attachments.get(msg.attachId);
59
62
  this.attachments.delete(msg.attachId);
63
+ this.attachmentThreadIds.delete(msg.attachId);
60
64
  attachment?.kill();
61
65
  return;
62
66
  }
@@ -211,6 +215,8 @@ export class RunnerSession {
211
215
  try {
212
216
  const input = msg.input ?? msg.text;
213
217
  const outcome = (await provider.injectMessage?.(msg.localThreadId, input)) ?? "notLive";
218
+ if (outcome === "injected")
219
+ this.cancelTraexStartupWatcher(msg.localThreadId);
214
220
  this.transport.send({ t: "injected", reqId: msg.reqId, localThreadId: msg.localThreadId, outcome });
215
221
  }
216
222
  catch (error) {
@@ -248,7 +254,9 @@ export class RunnerSession {
248
254
  const spec = await this.liveProvider.codexTerminalSpec?.(localThreadId);
249
255
  if (!spec)
250
256
  return;
251
- const term = this.terminals.getOrCreate(`${localThreadId}-main`, {
257
+ const terminalId = `${localThreadId}-main`;
258
+ const coldStart = !this.terminals.has(terminalId);
259
+ const term = this.terminals.getOrCreate(terminalId, {
252
260
  cwd: spec.cwd,
253
261
  command: spec.command,
254
262
  args: spec.args,
@@ -256,8 +264,68 @@ export class RunnerSession {
256
264
  rows: rows ?? 40,
257
265
  ...(spec.env ? { env: spec.env } : {}),
258
266
  });
267
+ if (coldStart && spec.skipTraexStartupPrompts) {
268
+ const watcher = Symbol(localThreadId);
269
+ this.traexStartupWatchers.set(localThreadId, watcher);
270
+ void this.skipTraexStartupPrompts(localThreadId, term, watcher);
271
+ }
259
272
  this.liveProvider.attachTerminalInjector?.(localThreadId, term);
260
273
  }
274
+ cancelTraexStartupWatcher(localThreadId) {
275
+ if (localThreadId)
276
+ this.traexStartupWatchers.delete(localThreadId);
277
+ }
278
+ async skipTraexStartupPrompts(localThreadId, terminal, watcher) {
279
+ const prompts = [
280
+ {
281
+ id: "welcome",
282
+ matches: (pane) => pane.includes("Welcome to TRAE CLI") && pane.includes("Press enter to continue"),
283
+ dismiss: () => terminal.sendEnter(),
284
+ },
285
+ {
286
+ id: "migration",
287
+ matches: (pane) => pane.includes("Legacy TRAE CLI data detected") &&
288
+ pane.includes("Would you like to migrate detected data to the new TRAE CLI storage?") &&
289
+ pane.includes("Select what to import:"),
290
+ dismiss: () => terminal.interrupt(),
291
+ },
292
+ {
293
+ id: "hooks",
294
+ matches: (pane) => pane.includes("Hooks need review") &&
295
+ pane.includes("hooks are new or changed.") &&
296
+ pane.includes("Trust all and continue") &&
297
+ pane.includes("Continue without trusting"),
298
+ dismiss: () => terminal.interrupt(),
299
+ },
300
+ ];
301
+ const dismissed = new Set();
302
+ const deadline = Date.now() + 20_000;
303
+ const terminalId = `${localThreadId}-main`;
304
+ while (!this.shuttingDown &&
305
+ Date.now() < deadline &&
306
+ this.traexStartupWatchers.get(localThreadId) === watcher &&
307
+ this.terminals.get(terminalId) === terminal) {
308
+ const pane = terminal.capturePane();
309
+ // Once the normal composer is visible, startup is complete and there is
310
+ // no reason to keep polling the tmux pane for the remainder of the window.
311
+ if (pane.includes("TRAE CLI Next"))
312
+ break;
313
+ const prompt = prompts.find((candidate) => candidate.matches(pane));
314
+ if (prompt && !dismissed.has(prompt.id)) {
315
+ prompt.dismiss();
316
+ dismissed.add(prompt.id);
317
+ if (dismissed.size === prompts.length)
318
+ break;
319
+ }
320
+ await new Promise((resolve) => {
321
+ const timer = setTimeout(resolve, 100);
322
+ timer.unref();
323
+ });
324
+ }
325
+ if (this.traexStartupWatchers.get(localThreadId) === watcher) {
326
+ this.traexStartupWatchers.delete(localThreadId);
327
+ }
328
+ }
261
329
  stopLive() {
262
330
  for (const id of this.liveIds) {
263
331
  this.liveProvider.stopLiveCodexSession?.(id, {
@@ -336,6 +404,8 @@ export class RunnerSession {
336
404
  return;
337
405
  }
338
406
  this.attachments.set(msg.attachId, attachment);
407
+ if (msg.localThreadId)
408
+ this.attachmentThreadIds.set(msg.attachId, msg.localThreadId);
339
409
  attachment.onData((chunk) => this.transport.send({
340
410
  t: "term.data",
341
411
  attachId: msg.attachId,
@@ -343,6 +413,7 @@ export class RunnerSession {
343
413
  }));
344
414
  attachment.onExit((info) => {
345
415
  this.attachments.delete(msg.attachId);
416
+ this.attachmentThreadIds.delete(msg.attachId);
346
417
  this.transport.send({ t: "term.exit", attachId: msg.attachId, exitCode: info.exitCode });
347
418
  });
348
419
  this.transport.send({ t: "term.opened", attachId: msg.attachId, role });
@@ -15,8 +15,8 @@
15
15
  * transport drops in here later without touching `ConversationRuntime` or any
16
16
  * channel.
17
17
  */
18
- import { spawn as nodeSpawn } from "node:child_process";
19
- import { type AgentCapabilities, type AgentRuntimeId, type AppConfig, type CapabilityResult, type ModelListResponse, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot, type ThreadGoal } from "@rynx-ai/core";
18
+ import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
19
+ import { type AdmissionReservation, type AgentCapabilities, type AgentRuntimeId, type AppConfig, type CapabilityResult, type ModelListResponse, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot, type ThreadGoal } from "@rynx-ai/core";
20
20
  import { type CodexSessionStore } from "../host.js";
21
21
  import type { ResolveInteractionResult } from "../interactions.js";
22
22
  import { type InjectOutcome, type TerminalOpenErrorCode, type TerminalRole } from "./protocol.js";
@@ -53,8 +53,22 @@ export interface RunnerManagerOptions {
53
53
  liveStartTimeoutMs?: number;
54
54
  /** Max wait for a Provider-native thread to become ready. */
55
55
  liveReadyTimeoutMs?: number;
56
+ /** Max wait for a native interrupt acknowledgement before reporting it
57
+ * unproven. Callers may then use the explicit force-stop path. */
58
+ liveInterruptTimeoutMs?: number;
59
+ /** Max wait for an accepted owner TUI submission to become observable as a
60
+ * mirrored response or a published native rotation. */
61
+ terminalInputHandoffTimeoutMs?: number;
62
+ /** Initial exponential backoff for retrying unproven terminal cleanup. */
63
+ terminalInputCleanupRetryMs?: number;
56
64
  /** Injected for tests. */
57
65
  spawn?: typeof nodeSpawn;
66
+ /** Injected for tests. Defaults to signaling the whole POSIX process group
67
+ * created for the runner child, with direct-child fallback. */
68
+ signalChild?: (child: ChildProcess, signal: NodeJS.Signals, processGroup: boolean) => void;
69
+ /** Injected for tests. Kills and verifies the deterministic private tmux
70
+ * server owned by a Session runner. */
71
+ terminateTerminalServer?: (terminalName: string) => boolean | Promise<boolean>;
58
72
  now?: () => number;
59
73
  /** Extra env merged into every spawned runner child — e.g. the control-plane
60
74
  * URL + token so a claude-native PermissionRequest hook can POST back. */
@@ -62,6 +76,13 @@ export interface RunnerManagerOptions {
62
76
  /** Optional lifecycle context for Session runners. The shared `__cap__`
63
77
  * runner never opens one. */
64
78
  sessionContextProvider?: RunnerSessionContextProvider;
79
+ /** Dynamic process-wide admission fence. Existing cancellation and read-only
80
+ * inspection remain available; every path that may spawn or attach execution
81
+ * checks this immediately before admission. */
82
+ admissionOpen?: () => boolean;
83
+ /** Atomically reserves one work-producing admission while it crosses the
84
+ * asynchronous boundary into an observable live runner/turn. */
85
+ admissionReserve?: () => AdmissionReservation | undefined;
65
86
  }
66
87
  /** A session rotation reported by a runner child (claude `/clear`·`/fork`): the
67
88
  * new session `to` is now aliased to `from`'s runner; carries meta to carry over. */
@@ -118,6 +139,8 @@ export declare class RunnerManager implements AgentCapabilities {
118
139
  private readonly spawn;
119
140
  private readonly childEnv;
120
141
  private readonly sessionContextProvider;
142
+ private readonly admissionOpen;
143
+ private readonly admissionReserve;
121
144
  private readonly now;
122
145
  private readonly defaultRuntime;
123
146
  private readonly handles;
@@ -127,8 +150,13 @@ export declare class RunnerManager implements AgentCapabilities {
127
150
  private readonly reapTimer;
128
151
  private readonly shutdownGraceMs;
129
152
  private readonly shutdownKillGraceMs;
153
+ private readonly signalChild;
154
+ private readonly terminateTerminalServer;
130
155
  private readonly liveStartTimeoutMs;
131
156
  private readonly liveReadyTimeoutMs;
157
+ private readonly liveInterruptTimeoutMs;
158
+ private readonly terminalInputHandoffTimeoutMs;
159
+ private readonly terminalInputCleanupRetryMs;
132
160
  private stopping;
133
161
  private stopPromise;
134
162
  /** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
@@ -154,6 +182,9 @@ export declare class RunnerManager implements AgentCapabilities {
154
182
  private readonly forkOperations;
155
183
  private readonly forkBufferedMessages;
156
184
  private readonly forkBufferedTerminalInputs;
185
+ /** Owner TUI submissions accepted by the parent but not yet represented by a
186
+ * mirrored response or a durably published native rotation. */
187
+ private readonly terminalInputHandoffs;
157
188
  constructor(opts: RunnerManagerOptions);
158
189
  /**
159
190
  * Open a live terminal on the session's runner child (spawning it if needed).
@@ -181,6 +212,18 @@ export declare class RunnerManager implements AgentCapabilities {
181
212
  /** Register the sink for session rotations (claude `/clear`·`/fork`): the server
182
213
  * records the new session's meta (carry-over agent/model/title). */
183
214
  onRotate(listener: (rotation: RotateInfo) => void | Promise<void>): void;
215
+ /** Accepted TUI submissions that have not crossed into an observable runtime
216
+ * state. The daemon folds this into runningTurns after closing admission. */
217
+ pendingTerminalInputCount(): number;
218
+ private beginTerminalInputHandoffs;
219
+ private settleTerminalInputHandoff;
220
+ private settleTerminalRotationHandoff;
221
+ private currentTerminalSessionId;
222
+ private finishTerminalInputHandoff;
223
+ private finishAllTerminalInputHandoffs;
224
+ private preserveTerminalInputHandoffsAsActivity;
225
+ private scheduleTerminalInputCleanupRetry;
226
+ private expireTerminalInputHandoffs;
184
227
  /**
185
228
  * Eagerly bring up a session's codex-native live view (persistent forwarder +
186
229
  * detached `codex --remote` TUI) in its runner child, spawning the runner if
@@ -211,6 +254,7 @@ export declare class RunnerManager implements AgentCapabilities {
211
254
  * the session has no live forwarder (caller falls back to the run path).
212
255
  */
213
256
  injectMessage(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectOutcome>;
257
+ private injectMessageAdmitted;
214
258
  /**
215
259
  * Interrupt a session's active live turn — the web Stop button (codex
216
260
  * `turn/interrupt`, claude Escape). Best-effort: resolves false with NO spawn
@@ -243,13 +287,19 @@ export declare class RunnerManager implements AgentCapabilities {
243
287
  }): Promise<import("../host.js").CodexRuntimeStatus>;
244
288
  /** Stop the runner bound to one session (if any), rejecting its in-flight work. */
245
289
  stopRunner(localThreadId: string): void;
290
+ /** Force-stop and join the runner bound to one Session. Unlike stopRunner,
291
+ * completion proves that the child process has exited. */
292
+ terminateLiveSession(localThreadId: string): Promise<boolean>;
246
293
  /** Stop every runner and join all child exits. Idempotent across concurrent calls. */
247
294
  stop(): Promise<void>;
295
+ private handleForCleanup;
248
296
  /** Forward a capability to a runner child. Session-less caps (listModels/status)
249
297
  * use the shared `CAP_KEY` child; per-thread caps pass the session's key so they
250
298
  * run on that session's child (which owns its private CODEX_HOME). */
251
299
  private forwardCap;
252
300
  private getOrSpawn;
301
+ private assertAdmissionOpen;
302
+ private reserveAdmission;
253
303
  private performManagedFork;
254
304
  private performClaudeManagedFork;
255
305
  private bufferForkTargetMessage;
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
22
22
  import { AgentRuntimeError, } from "@rynx-ai/core";
23
23
  import { listRuntimeModels } from "../models-catalog.js";
24
24
  import { probeRuntimeStatus } from "../runtime-status.js";
25
+ import { terminateTmuxServer } from "../terminal/tmux.js";
25
26
  import { fromWireError, } from "./protocol.js";
26
27
  import { StdioRunnerTransport } from "./transport.js";
27
28
  /** Routing key for the shared capability runner (slash-command RPCs). */
@@ -36,6 +37,13 @@ const DEFAULT_SHUTDOWN_KILL_GRACE_MS = 5_000;
36
37
  const DEFAULT_LIVE_START_TIMEOUT_MS = 10_000;
37
38
  /** Thread readiness may legitimately wait through Provider startup. */
38
39
  const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
40
+ /** A submitted TUI command should become a mirrored turn or native rotation
41
+ * quickly. If it does not, the runner is fenced by a verified process-tree
42
+ * shutdown before maintenance may treat the submission as settled. */
43
+ const DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS = 30_000;
44
+ /** Retry transient terminal-server cleanup without spinning forever. */
45
+ const DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS = 1_000;
46
+ const MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS = 30_000;
39
47
  /** Keep per-Session spawn context small enough to remain an environment handoff,
40
48
  * not an unbounded transport. */
41
49
  const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
@@ -139,6 +147,8 @@ export class RunnerManager {
139
147
  spawn;
140
148
  childEnv;
141
149
  sessionContextProvider;
150
+ admissionOpen;
151
+ admissionReserve;
142
152
  now;
143
153
  defaultRuntime;
144
154
  handles = new Map();
@@ -148,8 +158,13 @@ export class RunnerManager {
148
158
  reapTimer;
149
159
  shutdownGraceMs;
150
160
  shutdownKillGraceMs;
161
+ signalChild;
162
+ terminateTerminalServer;
151
163
  liveStartTimeoutMs;
152
164
  liveReadyTimeoutMs;
165
+ liveInterruptTimeoutMs;
166
+ terminalInputHandoffTimeoutMs;
167
+ terminalInputCleanupRetryMs;
153
168
  stopping = false;
154
169
  stopPromise;
155
170
  /** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
@@ -175,6 +190,9 @@ export class RunnerManager {
175
190
  forkOperations = new Map();
176
191
  forkBufferedMessages = new Map();
177
192
  forkBufferedTerminalInputs = new Map();
193
+ /** Owner TUI submissions accepted by the parent but not yet represented by a
194
+ * mirrored response or a durably published native rotation. */
195
+ terminalInputHandoffs = new Map();
178
196
  constructor(opts) {
179
197
  this.config = opts.config;
180
198
  this.sessionStore = opts.sessionStore;
@@ -183,12 +201,19 @@ export class RunnerManager {
183
201
  this.spawn = opts.spawn ?? nodeSpawn;
184
202
  this.childEnv = opts.childEnv ?? {};
185
203
  this.sessionContextProvider = opts.sessionContextProvider;
204
+ this.admissionOpen = opts.admissionOpen ?? (() => true);
205
+ this.admissionReserve = opts.admissionReserve;
186
206
  this.now = opts.now ?? (() => Date.now());
187
- this.defaultRuntime = opts.config.AGENT_RUNTIME ?? "codex";
207
+ this.defaultRuntime = opts.config.DEFAULT_RUNTIME ?? "codex";
188
208
  this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
189
209
  this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
210
+ this.signalChild = opts.signalChild ?? signalRunnerChild;
211
+ this.terminateTerminalServer = opts.terminateTerminalServer ?? terminateTmuxServer;
190
212
  this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
191
213
  this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
214
+ this.liveInterruptTimeoutMs = Math.max(1, opts.liveInterruptTimeoutMs ?? 10_000);
215
+ this.terminalInputHandoffTimeoutMs = Math.max(1, opts.terminalInputHandoffTimeoutMs ?? DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS);
216
+ this.terminalInputCleanupRetryMs = Math.max(1, opts.terminalInputCleanupRetryMs ?? DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS);
192
217
  const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
193
218
  if (reapIntervalMs > 0) {
194
219
  this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
@@ -216,6 +241,7 @@ export class RunnerManager {
216
241
  * and attach.
217
242
  */
218
243
  openLiveTerminal(localThreadId, opts) {
244
+ this.assertAdmissionOpen();
219
245
  if (this.forkReservations.has(localThreadId)) {
220
246
  throw new TerminalOpenError("Session fork is still being committed", "terminal_not_live");
221
247
  }
@@ -228,12 +254,36 @@ export class RunnerManager {
228
254
  openTerminalOnHandle(handle, localThreadId, opts) {
229
255
  handle.lastUsedAt = this.now();
230
256
  const attachId = randomUUID();
257
+ const inputTracker = {
258
+ buffer: "",
259
+ previousWasCarriageReturn: false,
260
+ bracketedPaste: false,
261
+ pendingEscape: "",
262
+ };
231
263
  const terminal = new ManagedTerminal(attachId, (msg) => {
264
+ const handoffs = msg.t === "term.input" && opts.role === "owner"
265
+ ? this.beginTerminalInputHandoffs(handle, this.currentTerminalSessionId(handle, localThreadId), trackedTerminalSubmissions(inputTracker, Buffer.from(msg.dataB64, "base64").toString("utf8")))
266
+ : [];
267
+ const send = () => {
268
+ try {
269
+ handle.transport.send(msg);
270
+ }
271
+ catch (error) {
272
+ for (const handoff of handoffs) {
273
+ this.finishTerminalInputHandoff(handle, handoff);
274
+ }
275
+ throw error;
276
+ }
277
+ };
232
278
  const sourceReservation = this.sourceForkReservations.get(localThreadId);
233
279
  if (msg.t === "term.input" && sourceReservation) {
234
280
  void sourceReservation.then(() => {
235
281
  if (!handle.dead)
236
- handle.transport.send(msg);
282
+ send();
283
+ }).catch((error) => {
284
+ void this.terminateHandle(handle, `terminal input delivery failed: ${errorMessage(error)}`).catch((terminationError) => {
285
+ logTerminationFailure(handle, terminationError);
286
+ });
237
287
  });
238
288
  return;
239
289
  }
@@ -246,7 +296,7 @@ export class RunnerManager {
246
296
  return;
247
297
  }
248
298
  }
249
- handle.transport.send(msg);
299
+ send();
250
300
  }, () => handle.terminals.delete(attachId));
251
301
  handle.terminals.set(attachId, terminal);
252
302
  handle.transport.send({
@@ -284,6 +334,152 @@ export class RunnerManager {
284
334
  onRotate(listener) {
285
335
  this.rotateListener = listener;
286
336
  }
337
+ /** Accepted TUI submissions that have not crossed into an observable runtime
338
+ * state. The daemon folds this into runningTurns after closing admission. */
339
+ pendingTerminalInputCount() {
340
+ let count = 0;
341
+ for (const handoffs of this.terminalInputHandoffs.values()) {
342
+ count += handoffs.length;
343
+ }
344
+ return count;
345
+ }
346
+ beginTerminalInputHandoffs(handle, sessionId, kinds) {
347
+ if (kinds.length === 0)
348
+ return [];
349
+ const created = [];
350
+ try {
351
+ for (const kind of kinds) {
352
+ const handoff = {
353
+ sessionId,
354
+ kind,
355
+ reservation: this.reserveAdmission(),
356
+ timer: undefined,
357
+ };
358
+ handoff.timer = setTimeout(() => {
359
+ this.expireTerminalInputHandoffs(handle);
360
+ }, this.terminalInputHandoffTimeoutMs);
361
+ handoff.timer.unref?.();
362
+ const handoffs = this.terminalInputHandoffs.get(handle);
363
+ if (handoffs)
364
+ handoffs.push(handoff);
365
+ else
366
+ this.terminalInputHandoffs.set(handle, [handoff]);
367
+ created.push(handoff);
368
+ }
369
+ return created;
370
+ }
371
+ catch (error) {
372
+ for (const handoff of created) {
373
+ this.finishTerminalInputHandoff(handle, handoff);
374
+ }
375
+ throw error;
376
+ }
377
+ }
378
+ settleTerminalInputHandoff(handle, sessionId, kind) {
379
+ const handoff = this.terminalInputHandoffs
380
+ .get(handle)
381
+ ?.find((candidate) => candidate.sessionId === sessionId);
382
+ // Runtime observations settle accepted input in submission order. In
383
+ // particular, a response from the source Session while /clear or /fork is
384
+ // still publishing must not skip that rotation and release a later turn
385
+ // which has not yet been rebound or delivered.
386
+ if (handoff?.kind === kind)
387
+ this.finishTerminalInputHandoff(handle, handoff);
388
+ }
389
+ settleTerminalRotationHandoff(handle, sourceSessionId, targetSessionId) {
390
+ const handoffs = this.terminalInputHandoffs.get(handle);
391
+ const rotationIndex = handoffs?.findIndex((handoff) => handoff.sessionId === sourceSessionId && handoff.kind === "rotation") ?? -1;
392
+ if (!handoffs || rotationIndex < 0)
393
+ return;
394
+ // Turns submitted before /clear or /fork are superseded once the rotation
395
+ // is published. Inputs accepted afterwards belong to the transferred pane
396
+ // and must follow it to the target Session.
397
+ const rotation = handoffs[rotationIndex];
398
+ for (const handoff of [...handoffs.slice(0, rotationIndex)]) {
399
+ if (handoff.sessionId === sourceSessionId && handoff.kind === "turn") {
400
+ this.finishTerminalInputHandoff(handle, handoff);
401
+ }
402
+ }
403
+ if (rotation)
404
+ this.finishTerminalInputHandoff(handle, rotation);
405
+ for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
406
+ if (handoff.sessionId === sourceSessionId)
407
+ handoff.sessionId = targetSessionId;
408
+ }
409
+ }
410
+ currentTerminalSessionId(handle, fallback) {
411
+ for (const [sessionId, candidate] of this.handles) {
412
+ if (candidate === handle && this.liveSessionKeys.has(sessionId))
413
+ return sessionId;
414
+ }
415
+ return fallback;
416
+ }
417
+ finishTerminalInputHandoff(handle, handoff) {
418
+ if (handoff.timer) {
419
+ clearTimeout(handoff.timer);
420
+ handoff.timer = undefined;
421
+ }
422
+ handoff.reservation?.release();
423
+ handoff.reservation = undefined;
424
+ const handoffs = this.terminalInputHandoffs.get(handle);
425
+ if (!handoffs)
426
+ return;
427
+ const index = handoffs.indexOf(handoff);
428
+ if (index >= 0)
429
+ handoffs.splice(index, 1);
430
+ if (handoffs.length === 0)
431
+ this.terminalInputHandoffs.delete(handle);
432
+ }
433
+ finishAllTerminalInputHandoffs(handle) {
434
+ if (handle.terminalCleanupRetryTimer) {
435
+ clearTimeout(handle.terminalCleanupRetryTimer);
436
+ delete handle.terminalCleanupRetryTimer;
437
+ }
438
+ handle.terminalCleanupRetryFailures = 0;
439
+ for (const handoff of [...(this.terminalInputHandoffs.get(handle) ?? [])]) {
440
+ this.finishTerminalInputHandoff(handle, handoff);
441
+ }
442
+ }
443
+ preserveTerminalInputHandoffsAsActivity(handle) {
444
+ for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
445
+ if (handoff.timer) {
446
+ clearTimeout(handoff.timer);
447
+ handoff.timer = undefined;
448
+ }
449
+ handoff.reservation?.release();
450
+ handoff.reservation = undefined;
451
+ }
452
+ }
453
+ scheduleTerminalInputCleanupRetry(handle) {
454
+ if (handle.terminalCleanupRetryTimer ||
455
+ !this.terminalInputHandoffs.has(handle))
456
+ return;
457
+ const exponent = Math.min(handle.terminalCleanupRetryFailures - 1, 10);
458
+ const delay = Math.min(this.terminalInputCleanupRetryMs * (2 ** Math.max(0, exponent)), MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS);
459
+ handle.terminalCleanupRetryTimer = setTimeout(() => {
460
+ delete handle.terminalCleanupRetryTimer;
461
+ if (!this.terminalInputHandoffs.has(handle))
462
+ return;
463
+ void this.terminateHandle(handle, "retrying unproven terminal input cleanup").catch((error) => {
464
+ logTerminationFailure(handle, error);
465
+ });
466
+ }, delay);
467
+ handle.terminalCleanupRetryTimer.unref?.();
468
+ }
469
+ expireTerminalInputHandoffs(handle) {
470
+ const handoffs = this.terminalInputHandoffs.get(handle);
471
+ if (!handoffs?.length)
472
+ return;
473
+ for (const handoff of handoffs) {
474
+ if (handoff.timer) {
475
+ clearTimeout(handoff.timer);
476
+ handoff.timer = undefined;
477
+ }
478
+ }
479
+ void this.terminateHandle(handle, "accepted terminal input did not become observable before maintenance timeout").catch((error) => {
480
+ logTerminationFailure(handle, error);
481
+ });
482
+ }
287
483
  /**
288
484
  * Eagerly bring up a session's codex-native live view (persistent forwarder +
289
485
  * detached `codex --remote` TUI) in its runner child, spawning the runner if
@@ -291,13 +487,17 @@ export class RunnerManager {
291
487
  * non-codex / non-live session (the caller then uses the normal run path).
292
488
  */
293
489
  ensureLiveSession(localThreadId, opts) {
294
- return this.requestLiveSession(localThreadId, opts, true);
490
+ const admission = this.reserveAdmission();
491
+ return this.requestLiveSession(localThreadId, opts, true)
492
+ .finally(() => admission.release());
295
493
  }
296
494
  /** Start the Provider pane without waiting for login/onboarding to create a
297
495
  * native thread. This is the setup-terminal gate; callers may attach as soon
298
496
  * as it resolves, while normal message delivery still uses ensureLiveSession. */
299
497
  startLiveSession(localThreadId, opts) {
300
- return this.requestLiveSession(localThreadId, opts, false);
498
+ const admission = this.reserveAdmission();
499
+ return this.requestLiveSession(localThreadId, opts, false)
500
+ .finally(() => admission.release());
301
501
  }
302
502
  requestLiveSession(localThreadId, opts, waitForReady, allowReservedForkTarget = false) {
303
503
  const sourceReservation = allowReservedForkTarget
@@ -395,13 +595,18 @@ export class RunnerManager {
395
595
  * the session has no live forwarder (caller falls back to the run path).
396
596
  */
397
597
  injectMessage(localThreadId, input) {
598
+ const admission = this.reserveAdmission();
599
+ return this.injectMessageAdmitted(localThreadId, input)
600
+ .finally(() => admission.release());
601
+ }
602
+ injectMessageAdmitted(localThreadId, input) {
398
603
  const sourceReservation = this.sourceForkReservations.get(localThreadId);
399
604
  if (sourceReservation) {
400
- return sourceReservation.then(() => this.injectMessage(localThreadId, input));
605
+ return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input));
401
606
  }
402
607
  const reservation = this.forkReservations.get(localThreadId);
403
608
  if (reservation) {
404
- return reservation.then(() => this.injectMessage(localThreadId, input));
609
+ return reservation.then(() => this.injectMessageAdmitted(localThreadId, input));
405
610
  }
406
611
  const handle = this.getOrSpawn(localThreadId);
407
612
  handle.lastUsedAt = this.now();
@@ -444,7 +649,16 @@ export class RunnerManager {
444
649
  handle.lastUsedAt = this.now();
445
650
  const reqId = randomUUID();
446
651
  return new Promise((resolve) => {
447
- handle.live.set(reqId, (res) => resolve(res.ok ?? false));
652
+ const timeout = setTimeout(() => {
653
+ if (!handle.live.delete(reqId))
654
+ return;
655
+ resolve(false);
656
+ }, this.liveInterruptTimeoutMs);
657
+ timeout.unref?.();
658
+ handle.live.set(reqId, (res) => {
659
+ clearTimeout(timeout);
660
+ resolve(res.ok ?? false);
661
+ });
448
662
  handle.transport.send({ t: "live.interrupt", reqId, localThreadId });
449
663
  });
450
664
  }
@@ -488,6 +702,7 @@ export class RunnerManager {
488
702
  return this.forwardCap("clearGoal", [localThreadId], localThreadId);
489
703
  }
490
704
  forkSession(currentLocalThreadId, newLocalThreadId, options) {
705
+ this.assertAdmissionOpen();
491
706
  if (currentLocalThreadId === newLocalThreadId) {
492
707
  return Promise.resolve({
493
708
  ok: false,
@@ -573,7 +788,7 @@ export class RunnerManager {
573
788
  }
574
789
  /** Stop the runner bound to one session (if any), rejecting its in-flight work. */
575
790
  stopRunner(localThreadId) {
576
- const handle = this.handles.get(localThreadId);
791
+ const handle = this.handleForCleanup(localThreadId);
577
792
  if (!handle) {
578
793
  return;
579
794
  }
@@ -581,6 +796,15 @@ export class RunnerManager {
581
796
  logTerminationFailure(handle, error);
582
797
  });
583
798
  }
799
+ /** Force-stop and join the runner bound to one Session. Unlike stopRunner,
800
+ * completion proves that the child process has exited. */
801
+ async terminateLiveSession(localThreadId) {
802
+ const handle = this.handleForCleanup(localThreadId);
803
+ if (!handle)
804
+ return false;
805
+ await this.terminateHandle(handle, "runner force-stopped");
806
+ return true;
807
+ }
584
808
  /** Stop every runner and join all child exits. Idempotent across concurrent calls. */
585
809
  stop() {
586
810
  if (this.stopPromise)
@@ -589,9 +813,12 @@ export class RunnerManager {
589
813
  if (this.reapTimer) {
590
814
  clearInterval(this.reapTimer);
591
815
  }
592
- const children = [...this.childHandles];
593
- this.stopPromise = (async () => {
594
- const results = await Promise.allSettled(children.map((handle) => this.terminateHandle(handle, "runner manager stopped")));
816
+ const children = new Set([
817
+ ...this.childHandles,
818
+ ...this.terminalInputHandoffs.keys(),
819
+ ]);
820
+ const attempt = (async () => {
821
+ const results = await Promise.allSettled([...children].map((handle) => this.terminateHandle(handle, "runner manager stopped")));
595
822
  this.handles.clear();
596
823
  this.liveSessionKeys.clear();
597
824
  this.liveErrors.clear();
@@ -606,9 +833,19 @@ export class RunnerManager {
606
833
  }
607
834
  this.childHandles.clear();
608
835
  })();
609
- return this.stopPromise;
836
+ this.stopPromise = attempt;
837
+ void attempt.catch(() => {
838
+ if (this.stopPromise === attempt)
839
+ this.stopPromise = undefined;
840
+ });
841
+ return attempt;
610
842
  }
611
843
  // ── internals ──────────────────────────────────────────────────────────────
844
+ handleForCleanup(localThreadId) {
845
+ return this.handles.get(localThreadId) ??
846
+ [...this.terminalInputHandoffs].find(([handle, handoffs]) => handle.key === localThreadId ||
847
+ handoffs.some((handoff) => handoff.sessionId === localThreadId))?.[0];
848
+ }
612
849
  /** Forward a capability to a runner child. Session-less caps (listModels/status)
613
850
  * use the shared `CAP_KEY` child; per-thread caps pass the session's key so they
614
851
  * run on that session's child (which owns its private CODEX_HOME). */
@@ -622,23 +859,48 @@ export class RunnerManager {
622
859
  });
623
860
  }
624
861
  getOrSpawn(key, allowReservedForkTarget = false) {
625
- if (this.stopping) {
626
- throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
627
- }
628
- if (key !== CAP_KEY &&
629
- this.forkReservations.has(key) &&
630
- !allowReservedForkTarget) {
631
- throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
632
- }
633
- this.reapIdle();
634
- const existing = this.handles.get(key);
635
- if (existing && !existing.dead) {
636
- existing.lastUsedAt = this.now();
637
- return existing;
638
- }
639
- const handle = this.spawnHandle(key);
640
- this.handles.set(key, handle);
641
- return handle;
862
+ const admission = this.reserveAdmission();
863
+ try {
864
+ if (this.stopping) {
865
+ throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
866
+ }
867
+ if (key !== CAP_KEY &&
868
+ this.forkReservations.has(key) &&
869
+ !allowReservedForkTarget) {
870
+ throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
871
+ }
872
+ this.reapIdle();
873
+ const existing = this.handles.get(key);
874
+ if (existing && !existing.dead) {
875
+ existing.lastUsedAt = this.now();
876
+ return existing;
877
+ }
878
+ const handle = this.spawnHandle(key);
879
+ this.handles.set(key, handle);
880
+ return handle;
881
+ }
882
+ finally {
883
+ admission.release();
884
+ }
885
+ }
886
+ assertAdmissionOpen() {
887
+ const reservation = this.admissionReserve?.();
888
+ if (reservation) {
889
+ reservation.release();
890
+ return;
891
+ }
892
+ if (!this.admissionReserve && this.admissionOpen())
893
+ return;
894
+ throw new AgentRuntimeError("daemon maintenance is in progress", 503, "daemon_maintenance");
895
+ }
896
+ reserveAdmission() {
897
+ const reservation = this.admissionReserve?.();
898
+ if (reservation)
899
+ return reservation;
900
+ if (!this.admissionReserve && this.admissionOpen()) {
901
+ return { release() { } };
902
+ }
903
+ throw new AgentRuntimeError("daemon maintenance is in progress", 503, "daemon_maintenance");
642
904
  }
643
905
  async performManagedFork(currentLocalThreadId, newLocalThreadId, options) {
644
906
  const existingTarget = await this.sessionStore.get(newLocalThreadId);
@@ -750,6 +1012,9 @@ export class RunnerManager {
750
1012
  return;
751
1013
  if (message.t === "mirror") {
752
1014
  this.mirrorListener?.(message.sessionId, message.event);
1015
+ if (message.event.type === "response.created") {
1016
+ this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
1017
+ }
753
1018
  return;
754
1019
  }
755
1020
  this.deliverRotateMessage(handle, message);
@@ -806,6 +1071,11 @@ export class RunnerManager {
806
1071
  };
807
1072
  void Promise.resolve(this.rotateListener?.(rotation))
808
1073
  .then(() => {
1074
+ // The server's rotation listener resolves only after the target Session
1075
+ // is published. Release the terminal admission handoff afterwards so a
1076
+ // maintenance snapshot cannot observe a gap with neither source work
1077
+ // nor the target Session.
1078
+ this.settleTerminalRotationHandoff(handle, message.from, message.to);
809
1079
  if (handle.dead)
810
1080
  return;
811
1081
  this.handles.set(message.to, handle);
@@ -845,10 +1115,12 @@ export class RunnerManager {
845
1115
  const sessionContext = key === CAP_KEY
846
1116
  ? undefined
847
1117
  : this.openSessionContext(key);
1118
+ const processGroup = process.platform !== "win32";
848
1119
  let child;
849
1120
  try {
850
1121
  child = this.spawn(process.execPath, args, {
851
1122
  stdio: ["pipe", "pipe", "pipe"],
1123
+ detached: processGroup,
852
1124
  // `RYNX_RUNNER_SESSION` = the child's session key (localThreadId, or `__cap__`
853
1125
  // for the shared capability child) so its LocalAgentHost scopes the private
854
1126
  // CODEX_HOME to this session. It is deliberately applied last.
@@ -874,6 +1146,8 @@ export class RunnerManager {
874
1146
  lastUsedAt: this.now(),
875
1147
  dead: false,
876
1148
  completion,
1149
+ terminalCleanupRetryFailures: 0,
1150
+ processGroup,
877
1151
  ...(sessionContext ? { sessionContext } : {}),
878
1152
  caps: new Map(),
879
1153
  terminals: new Map(),
@@ -937,6 +1211,12 @@ export class RunnerManager {
937
1211
  if (this.bufferForkTargetMessage(handle, msg))
938
1212
  return;
939
1213
  this.mirrorListener?.(msg.sessionId, msg.event);
1214
+ if (msg.event.type === "response.created") {
1215
+ // The listener projects response.created into SessionRuntimeIndex
1216
+ // synchronously. Only then may the accepted terminal reservation
1217
+ // drain into a maintenance activity snapshot.
1218
+ this.settleTerminalInputHandoff(handle, msg.sessionId, "turn");
1219
+ }
940
1220
  return;
941
1221
  case "rotate": {
942
1222
  if (handle.dead)
@@ -1002,7 +1282,11 @@ export class RunnerManager {
1002
1282
  terminateHandle(handle, reason) {
1003
1283
  if (handle.termination)
1004
1284
  return handle.termination;
1005
- handle.termination = (async () => {
1285
+ if (handle.terminalCleanupRetryTimer) {
1286
+ clearTimeout(handle.terminalCleanupRetryTimer);
1287
+ delete handle.terminalCleanupRetryTimer;
1288
+ }
1289
+ const attempt = (async () => {
1006
1290
  if (!handle.dead) {
1007
1291
  try {
1008
1292
  handle.transport.send({ t: "shutdown" });
@@ -1012,16 +1296,37 @@ export class RunnerManager {
1012
1296
  }
1013
1297
  this.failHandle(handle, reason);
1014
1298
  }
1015
- signalChild(handle.child, "SIGTERM");
1016
- if (!(await waitForChildExit(handle.completion, this.shutdownGraceMs))) {
1017
- signalChild(handle.child, "SIGKILL");
1018
- if (!(await waitForChildExit(handle.completion, this.shutdownKillGraceMs))) {
1019
- throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
1020
- }
1299
+ this.signalChild(handle.child, "SIGTERM", handle.processGroup);
1300
+ let childExited = await waitForChildExit(handle.completion, this.shutdownGraceMs);
1301
+ if (!childExited) {
1302
+ this.signalChild(handle.child, "SIGKILL", handle.processGroup);
1303
+ childExited = await waitForChildExit(handle.completion, this.shutdownKillGraceMs);
1304
+ }
1305
+ const terminalStopped = handle.key === CAP_KEY
1306
+ ? true
1307
+ : await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`)).catch(() => false);
1308
+ if (!childExited) {
1309
+ throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
1310
+ }
1311
+ if (!terminalStopped) {
1312
+ throw new Error(`runner terminal ${handle.key}-main remained live after child exit`);
1021
1313
  }
1022
1314
  this.childHandles.delete(handle);
1023
1315
  })();
1024
- return handle.termination;
1316
+ handle.termination = attempt;
1317
+ void attempt.then(() => {
1318
+ this.finishAllTerminalInputHandoffs(handle);
1319
+ }, () => {
1320
+ if (handle.termination === attempt)
1321
+ delete handle.termination;
1322
+ handle.terminalCleanupRetryFailures += 1;
1323
+ // A failed cleanup must not pin closeAndDrain forever. Keep the
1324
+ // submission in the daemon activity snapshot, but release its gate
1325
+ // reservation so maintenance returns a structured busy result.
1326
+ this.preserveTerminalInputHandoffsAsActivity(handle);
1327
+ this.scheduleTerminalInputCleanupRetry(handle);
1328
+ });
1329
+ return attempt;
1025
1330
  }
1026
1331
  reapIdle() {
1027
1332
  const now = this.now();
@@ -1060,6 +1365,70 @@ export class RunnerManager {
1060
1365
  }
1061
1366
  }
1062
1367
  }
1368
+ function trackedTerminalSubmissions(tracker, data) {
1369
+ const pasteStart = "\u001b[200~";
1370
+ const pasteEnd = "\u001b[201~";
1371
+ const input = tracker.pendingEscape + data;
1372
+ tracker.pendingEscape = "";
1373
+ const submissions = [];
1374
+ for (let index = 0; index < input.length; index += 1) {
1375
+ const character = input[index] ?? "";
1376
+ if (character === "\u001b") {
1377
+ const remaining = input.slice(index);
1378
+ if (remaining.startsWith(pasteStart)) {
1379
+ tracker.bracketedPaste = true;
1380
+ tracker.previousWasCarriageReturn = false;
1381
+ index += pasteStart.length - 1;
1382
+ continue;
1383
+ }
1384
+ if (remaining.startsWith(pasteEnd)) {
1385
+ tracker.bracketedPaste = false;
1386
+ tracker.previousWasCarriageReturn = false;
1387
+ index += pasteEnd.length - 1;
1388
+ continue;
1389
+ }
1390
+ if (pasteStart.startsWith(remaining) || pasteEnd.startsWith(remaining)) {
1391
+ tracker.pendingEscape = remaining;
1392
+ break;
1393
+ }
1394
+ }
1395
+ if (tracker.bracketedPaste) {
1396
+ tracker.buffer += character;
1397
+ tracker.previousWasCarriageReturn = false;
1398
+ continue;
1399
+ }
1400
+ if (character === "\n" && tracker.previousWasCarriageReturn) {
1401
+ tracker.previousWasCarriageReturn = false;
1402
+ continue;
1403
+ }
1404
+ tracker.previousWasCarriageReturn = character === "\r";
1405
+ if (character === "\r" || character === "\n") {
1406
+ const command = tracker.buffer.trim();
1407
+ tracker.buffer = "";
1408
+ if (command.length > 0 &&
1409
+ (command.includes("\u001b") ||
1410
+ !command.startsWith("/") ||
1411
+ /^\/(?:clear|fork)(?:\s|$)/u.test(command))) {
1412
+ submissions.push(/^\/(?:clear|fork)(?:\s|$)/u.test(command) ? "rotation" : "turn");
1413
+ }
1414
+ continue;
1415
+ }
1416
+ if (character === "\b" || character === "\u007f") {
1417
+ tracker.buffer = tracker.buffer.slice(0, -1);
1418
+ continue;
1419
+ }
1420
+ if (character === "\u0015" || character === "\u0003") {
1421
+ tracker.buffer = "";
1422
+ continue;
1423
+ }
1424
+ // Printable text and tabs are enough to distinguish work-producing prompts
1425
+ // from local TUI slash commands. Other control sequences are ignored.
1426
+ if (character === "\u001b" || character === "\t" || character >= " ") {
1427
+ tracker.buffer += character;
1428
+ }
1429
+ }
1430
+ return submissions;
1431
+ }
1063
1432
  function validateSessionContext(context) {
1064
1433
  if (!context || typeof context !== "object") {
1065
1434
  throw new Error("Session context environment provider must return an object");
@@ -1164,9 +1533,18 @@ function observeChildCompletion(child) {
1164
1533
  child.once("exit", onExit);
1165
1534
  });
1166
1535
  }
1167
- function signalChild(child, signal) {
1536
+ function signalRunnerChild(child, signal, processGroup) {
1168
1537
  if (childHasExited(child))
1169
1538
  return;
1539
+ if (processGroup && child.pid) {
1540
+ try {
1541
+ process.kill(-child.pid, signal);
1542
+ return;
1543
+ }
1544
+ catch {
1545
+ // The group may have exited between the completion check and the signal.
1546
+ }
1547
+ }
1170
1548
  try {
1171
1549
  child.kill(signal);
1172
1550
  }
@@ -43,6 +43,14 @@ export interface TmuxTerminalOptions {
43
43
  /** Injectable for tests; defaults to the real `tmux` binary path. */
44
44
  tmuxBin?: string;
45
45
  }
46
+ /** Deterministic private socket owned by one terminal name. Parent-side
47
+ * shutdown uses the same mapping when the runner child is too wedged to clean
48
+ * up its own tmux server. */
49
+ export declare function tmuxSocketPath(name: string): string;
50
+ /** Kill one private tmux server and verify it no longer answers. Missing
51
+ * sockets are already stopped; an existing socket with an unusable tmux
52
+ * command is unproven and returns false. */
53
+ export declare function terminateTmuxServer(name: string, tmuxBin?: string): boolean;
46
54
  /** Is a usable `tmux` on PATH? Cheap probe for the capability gate. */
47
55
  export declare function isTmuxAvailable(tmuxBin?: string): boolean;
48
56
  export declare class TmuxTerminal {
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { execFile, execFileSync } from "node:child_process";
18
18
  import { createHash } from "node:crypto";
19
- import { mkdirSync } from "node:fs";
19
+ import { existsSync, mkdirSync } from "node:fs";
20
20
  import { tmpdir } from "node:os";
21
21
  import { join } from "node:path";
22
22
  const TMUX_TARGET = "main";
@@ -26,6 +26,40 @@ const DEFAULT_ROWS = 24;
26
26
  * wheel-scroll (tmux copy-mode, enabled by `mouse on`) reaches as far back as
27
27
  * the terminal shows. Mirrors reference implementation's `TerminalEnvSpec.scrollback`. */
28
28
  const DEFAULT_SCROLLBACK = 20000;
29
+ /** Deterministic private socket owned by one terminal name. Parent-side
30
+ * shutdown uses the same mapping when the runner child is too wedged to clean
31
+ * up its own tmux server. */
32
+ export function tmuxSocketPath(name) {
33
+ const key = createHash("sha256").update(name).digest("hex").slice(0, 16);
34
+ return join(tmpdir(), "rynx-term", `${key}.sock`);
35
+ }
36
+ /** Kill one private tmux server and verify it no longer answers. Missing
37
+ * sockets are already stopped; an existing socket with an unusable tmux
38
+ * command is unproven and returns false. */
39
+ export function terminateTmuxServer(name, tmuxBin = "tmux") {
40
+ const socketPath = tmuxSocketPath(name);
41
+ if (!existsSync(socketPath))
42
+ return true;
43
+ const base = ["-u", "-S", socketPath];
44
+ try {
45
+ execFileSync(tmuxBin, [...base, "kill-server"], { stdio: "ignore" });
46
+ }
47
+ catch {
48
+ // A server may have exited between existsSync and kill-server. Verify below.
49
+ }
50
+ try {
51
+ execFileSync(tmuxBin, [...base, "has-session", "-t", TMUX_TARGET], {
52
+ stdio: "ignore",
53
+ });
54
+ return false;
55
+ }
56
+ catch (error) {
57
+ return !(error &&
58
+ typeof error === "object" &&
59
+ "code" in error &&
60
+ error.code === "ENOENT");
61
+ }
62
+ }
29
63
  /** Is a usable `tmux` on PATH? Cheap probe for the capability gate. */
30
64
  export function isTmuxAvailable(tmuxBin = "tmux") {
31
65
  try {
@@ -76,8 +110,7 @@ export class TmuxTerminal {
76
110
  // under the unix-domain path limit (~104 chars on macOS) — a full session id
77
111
  // like `sess_<uuid>-main` under $TMPDIR would overflow it and fail with
78
112
  // "File name too long". The hash stays unique per terminal.
79
- const key = createHash("sha256").update(opts.name).digest("hex").slice(0, 16);
80
- this.socketPath = join(dir, `${key}.sock`);
113
+ this.socketPath = tmuxSocketPath(opts.name);
81
114
  }
82
115
  /** tmux argv prefix targeting this terminal's private server. */
83
116
  base() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/runtime",
3
- "version": "0.1.11-beta.2",
3
+ "version": "0.1.11-beta.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/rynx-ai/rynx.git",
@@ -26,7 +26,7 @@
26
26
  "dependencies": {
27
27
  "node-pty": "^1.0.0",
28
28
  "ws": "^8.21.0",
29
- "@rynx-ai/core": "0.1.11-beta.2"
29
+ "@rynx-ai/core": "0.1.11-beta.4"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/ws": "^8.18.1"