@rynx-ai/runtime 0.1.11-beta.45 → 0.1.11-beta.49

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.
@@ -11,7 +11,7 @@
11
11
  * Dependency-light on purpose (node builtins plus erased protocol types) so the
12
12
  * standalone hook entrypoints do not pull the whole runtime into every process.
13
13
  */
14
- import { appendFileSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
14
+ import { appendFileSync, closeSync, copyFileSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
15
15
  import { createHash, randomUUID } from "node:crypto";
16
16
  import { join } from "node:path";
17
17
  import { adoptLegacyRuntimeDirectory, legacyRuntimeStateRoot, runtimeSessionDigest, runtimeSessionStateDir, } from "../runtime-state-paths.js";
@@ -69,6 +69,21 @@ export function prepareClaudeBridgeDir(sessionId) {
69
69
  const dir = claudeBridgeDir(sessionId);
70
70
  adoptLegacyRuntimeDirectory(join(legacyRuntimeStateRoot(), "claude-native", runtimeSessionDigest(sessionId)), dir);
71
71
  mkdirSync(dir, { recursive: true, mode: 0o700 });
72
+ // Preserve original bridge logs before the normal runtime refresh clears its
73
+ // rendezvous. Collection itself never calls this function or changes files.
74
+ const history = join(dir, "history", randomUUID());
75
+ for (const file of [HOOKS_FILE, STATE_FILE, DELTAS_FILE, INTERACTIONS_FILE, INTERACTION_ACKS_FILE]) {
76
+ try {
77
+ if (!statSync(join(dir, file)).isFile())
78
+ continue;
79
+ mkdirSync(history, { recursive: true, mode: 0o700 });
80
+ copyFileSync(join(dir, file), join(history, file));
81
+ }
82
+ catch (error) {
83
+ if (error.code !== "ENOENT")
84
+ process.stderr.write(`Claude bridge log preservation failed: ${String(error)}\n`);
85
+ }
86
+ }
72
87
  for (const file of [
73
88
  HOOKS_FILE,
74
89
  STATE_FILE,
@@ -2407,6 +2407,28 @@ const CLAUDE_PASTE_COMMIT_TIMEOUT_MS = 5_000;
2407
2407
  const CLAUDE_PASTE_SETTLE_MS = 100;
2408
2408
  const CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS = 10_000;
2409
2409
  const CLAUDE_SUBMIT_RETRY_MS = 1_000;
2410
+ const CLAUDE_UNKNOWN_COMMAND_WATCH_MS = 3_000;
2411
+ // Watch guessed skills, leaving known native commands on their existing
2412
+ // delivery path. This list does not change which commands may run.
2413
+ const CLAUDE_NATIVE_BUILTIN_COMMANDS = new Set([
2414
+ "add-dir", "agents", "bug", "config", "cost", "doctor", "exit", "fast",
2415
+ "feedback", "help", "hooks", "ide", "login", "logout", "mcp", "memory",
2416
+ "onboarding", "permissions", "plugin", "quiet", "quit", "release-notes",
2417
+ "resume", "save", "status", "terminal-setup", "upgrade", "verbose",
2418
+ "clear", "compact", "effort", "model", "ultrareview", "branch", "fork",
2419
+ ]);
2420
+ /** Count rejection messages: omit composer/echo rows and ignore
2421
+ * wrapping, including a narrow pane wrapping inside the command name. */
2422
+ function unknownCommandRejections(pane, command, glyph) {
2423
+ const collapsed = pane.split(/\r?\n/)
2424
+ .filter((line) => !line.includes(glyph))
2425
+ .join("")
2426
+ .replace(/\s/g, "");
2427
+ // Unlike a bare substring count, keep /foo-bar's rejection from authorizing
2428
+ // a second delivery of /foo. Retain namespaced, path-like and Unicode names.
2429
+ return collapsed.split(`Unknowncommand:/${command}`).slice(1)
2430
+ .filter((suffix) => !/^[\p{L}\p{M}\p{N}_:./-]/u.test(suffix)).length;
2431
+ }
2410
2432
  function selectedMenuRow(line, glyph) {
2411
2433
  const index = line.indexOf(glyph);
2412
2434
  if (index < 0)
@@ -2494,6 +2516,27 @@ async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
2494
2516
  * recorded neither a direct user prompt nor a type-ahead enqueue.
2495
2517
  */
2496
2518
  export async function injectViaTerminal(injector, text, opts = {}) {
2519
+ const command = /^\s*\/(\S+)/u.exec(text)?.[1];
2520
+ if (!command || CLAUDE_NATIVE_BUILTIN_COMMANDS.has(command)) {
2521
+ return pasteAndSubmitViaTerminal(injector, text, opts);
2522
+ }
2523
+ const glyph = opts.promptGlyph ?? CLAUDE_PROMPT_GLYPH;
2524
+ const baseline = unknownCommandRejections(injector.capturePane(), command, glyph);
2525
+ if (!await pasteAndSubmitViaTerminal(injector, text, opts))
2526
+ return false;
2527
+ // A fresh, explicit rejection proves Claude dropped this guessed skill.
2528
+ // Stale scrollback and an unacknowledged/slow submission never justify a
2529
+ // second paste. Keep the watch and recovery within the Host's injectLock.
2530
+ const rejected = await pollUntil(() => unknownCommandRejections(injector.capturePane(), command, glyph) > baseline, CLAUDE_UNKNOWN_COMMAND_WATCH_MS, opts.pollMs ?? 150, opts.now ?? (() => Date.now()), opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))), opts.signal);
2531
+ if (opts.signal?.aborted)
2532
+ return false;
2533
+ if (!rejected)
2534
+ return true;
2535
+ // Exactly one recovery, using a zero-width prefix. Reuse the
2536
+ // existing paste/submit recipe without changing newlines or attachments.
2537
+ return pasteAndSubmitViaTerminal(injector, text.replace(/^(\s*)\//u, "$1\ufeff/"), opts);
2538
+ }
2539
+ async function pasteAndSubmitViaTerminal(injector, text, opts) {
2497
2540
  const glyph = opts.promptGlyph ?? CLAUDE_PROMPT_GLYPH;
2498
2541
  const pollMs = opts.pollMs ?? 150;
2499
2542
  const now = opts.now ?? (() => Date.now());
@@ -32,19 +32,24 @@ function strippedImagePlaceholder(source) {
32
32
  * history. A Read image result can contain a full-resolution base64 payload;
33
33
  * replaying it as text only bloats the transcript, while the model cannot use
34
34
  * those encoded bytes as text. Keep a small, human-readable marker instead. */
35
- function stripInlineImageData(value) {
35
+ function stripInlineImageData(value, preview) {
36
36
  if (Array.isArray(value))
37
- return value.map(stripInlineImageData);
37
+ return value.map((part) => stripInlineImageData(part, preview));
38
38
  if (!isObject(value))
39
39
  return value;
40
40
  const source = isObject(value.source) ? value.source : undefined;
41
41
  if (value.type === "image" && source) {
42
- return { type: "text", text: strippedImagePlaceholder(source) };
42
+ return {
43
+ type: "text",
44
+ text: value === preview
45
+ ? `[${source.media_type} image attached]`
46
+ : strippedImagePlaceholder(source),
47
+ };
43
48
  }
44
- return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, stripInlineImageData(nested)]));
49
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, stripInlineImageData(nested, preview)]));
45
50
  }
46
- function stringifyToolContent(content) {
47
- const stripped = stripInlineImageData(content);
51
+ function stringifyToolContent(content, preview) {
52
+ const stripped = stripInlineImageData(content, preview);
48
53
  if (typeof stripped === "string")
49
54
  return stripped;
50
55
  if (Array.isArray(stripped)) {
@@ -58,6 +63,25 @@ function stringifyToolContent(content) {
58
63
  }
59
64
  return stripped == null ? "" : JSON.stringify(stripped);
60
65
  }
66
+ /** Native Read returns inline image bytes, sometimes transcoded from the file.
67
+ * Reuse the tool-image resource pipeline so the preview preserves those bytes
68
+ * while the canonical output text stays small. Remaining images keep markers
69
+ * until the shared tool-output contract supports more than one preview. */
70
+ function inlineImagePreview(content) {
71
+ if (!Array.isArray(content))
72
+ return undefined;
73
+ for (const block of content) {
74
+ if (!isObject(block) || block.type !== "image" || !isObject(block.source))
75
+ continue;
76
+ const { type, media_type: mediaType, data } = block.source;
77
+ if (type === "base64" &&
78
+ typeof mediaType === "string" && /^image\/[a-z0-9+.-]+$/i.test(mediaType) &&
79
+ typeof data === "string" && /^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
80
+ return { block, result: `data:${mediaType};base64,${data}` };
81
+ }
82
+ }
83
+ return undefined;
84
+ }
61
85
  const BASH_INPUT_RE = /<bash-input>([\s\S]*?)<\/bash-input>/;
62
86
  const BASH_STDOUT_RE = /<bash-stdout>([\s\S]*?)<\/bash-stdout>/;
63
87
  const BASH_STDERR_RE = /<bash-stderr>([\s\S]*?)<\/bash-stderr>/;
@@ -188,14 +212,16 @@ export function parseTranscriptRecord(record, opts) {
188
212
  return [];
189
213
  for (const block of message.content) {
190
214
  if (block.type === "tool_result") {
215
+ const preview = block.is_error ? undefined : inlineImagePreview(block.content);
191
216
  out.push({
192
217
  type: "tool",
193
218
  event: "on_tool_end",
194
219
  output: {
195
220
  id: block.tool_use_id,
196
221
  status: block.is_error ? "failed" : "completed",
197
- aggregatedOutput: stringifyToolContent(block.content),
222
+ aggregatedOutput: stringifyToolContent(block.content, preview?.block),
198
223
  exitCode: null,
224
+ ...(preview ? { generatedImage: { result: preview.result } } : {}),
199
225
  },
200
226
  ...(block.is_error ? { error: "tool_error" } : {}),
201
227
  data: { tool_use_id: block.tool_use_id, itemStatus: "completed" },
@@ -49,6 +49,8 @@ export interface CodexForwarderSink {
49
49
  onRecoveredTurnStatus?(status: "idle" | "failed", turnId: string | undefined, error?: Error): void;
50
50
  /** A turn failed on the runtime. */
51
51
  onTurnError(error: Error): void;
52
+ /** A steer rejection proves a recorded turn is inactive, without its outcome. */
53
+ onTurnInactive?(turnId: string): void;
52
54
  /** The user's turn text (sourced from codex's `userMessage` item), so a
53
55
  * co-driving TUI's prompt is recorded even though this process never injected it. */
54
56
  onUserMessage?(content: string | UserContentPart[]): void;
@@ -136,6 +138,8 @@ export declare class CodexSessionForwarder {
136
138
  isTurnOpen(): boolean;
137
139
  /** The current turn's codex id (only meaningful while {@link isTurnOpen}). */
138
140
  currentTurnId(): string | null;
141
+ /** Reconcile a proven stale steer without clearing a newer observed turn. */
142
+ clearActiveTurnIfMatches(turnId: string): void;
139
143
  /**
140
144
  * Record a turn accepted by the injection connection before the independent
141
145
  * observer receives `turn/started`. This closes the read-decide-RPC-write race:
@@ -162,6 +162,16 @@ export class CodexSessionForwarder {
162
162
  currentTurnId() {
163
163
  return this.currentTurnIdValue;
164
164
  }
165
+ /** Reconcile a proven stale steer without clearing a newer observed turn. */
166
+ clearActiveTurnIfMatches(turnId) {
167
+ if (this.currentTurnIdValue !== turnId)
168
+ return;
169
+ this.flushPendingCompletion();
170
+ this.flushDeferredAssistantMessage();
171
+ this.currentTurnIdValue = null;
172
+ this.turnOpen = false;
173
+ this.sink.onTurnInactive?.(turnId);
174
+ }
165
175
  /**
166
176
  * Record a turn accepted by the injection connection before the independent
167
177
  * observer receives `turn/started`. This closes the read-decide-RPC-write race:
@@ -249,6 +249,28 @@ export function mapCodexItem(method, item) {
249
249
  });
250
250
  return { events };
251
251
  }
252
+ case "imageView": {
253
+ const image = item;
254
+ if (typeof image.id !== "string" || !image.id ||
255
+ typeof image.path !== "string" || !image.path)
256
+ return { events };
257
+ events.push({
258
+ type: "tool",
259
+ event: isStart ? "on_tool_start" : "on_tool_end",
260
+ name: "view_image",
261
+ // Image views may arrive only as completed items, including backfill.
262
+ input: isStart || isEnd ? { id: image.id, path: image.path } : undefined,
263
+ output: isEnd
264
+ ? {
265
+ id: image.id,
266
+ aggregatedOutput: `Viewed image at ${image.path}`,
267
+ generatedImage: { savedPath: image.path },
268
+ }
269
+ : undefined,
270
+ data: { method, item },
271
+ });
272
+ return { events };
273
+ }
252
274
  case "imageGeneration": {
253
275
  const image = item;
254
276
  events.push({
@@ -298,7 +298,11 @@ export interface ImageGenerationItem extends ThreadItemBase {
298
298
  /** Codex App Server guarantees this is absolute when present. */
299
299
  savedPath?: string;
300
300
  }
301
- export type ThreadItem = UserMessageItem | AgentMessageItem | ReasoningItem | PlanItem | CommandExecutionItem | FileChangeItem | McpToolCallItem | DynamicToolCallItem | WebSearchItem | ImageGenerationItem | (ThreadItemBase & Record<string, unknown>);
301
+ export interface ImageViewItem extends ThreadItemBase {
302
+ type: "imageView";
303
+ path: string;
304
+ }
305
+ export type ThreadItem = UserMessageItem | AgentMessageItem | ReasoningItem | PlanItem | CommandExecutionItem | FileChangeItem | McpToolCallItem | DynamicToolCallItem | WebSearchItem | ImageGenerationItem | ImageViewItem | (ThreadItemBase & Record<string, unknown>);
302
306
  export interface ThreadSummary {
303
307
  id?: string;
304
308
  threadId?: string;
@@ -1,6 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { EventEmitter } from "node:events";
3
3
  import readline from "node:readline";
4
+ import { join } from "node:path";
5
+ import { teeDiagnosticStream } from "@rynx-ai/core";
4
6
  import { createCodexChildEnv } from "../codex-child-env.js";
5
7
  const DEFAULT_SPAWN_ARGS = ["app-server", "--listen", "stdio://"];
6
8
  /**
@@ -51,6 +53,8 @@ export class DefaultCodexAppServerProcessSpawner {
51
53
  stdio: ["pipe", "pipe", "pipe"],
52
54
  env: { ...createCodexChildEnv(process.env), ...this.extraEnv },
53
55
  });
56
+ if (process.env.RYNX_RUNTIME_LOG_DIR)
57
+ teeDiagnosticStream(child.stderr, join(process.env.RYNX_RUNTIME_LOG_DIR, "app-server.stderr.log"));
54
58
  if (!child.stdin || !child.stdout) {
55
59
  throw new Error("Codex app-server child process is missing stdio handles");
56
60
  }
@@ -14,6 +14,8 @@
14
14
  */
15
15
  import { spawn } from "node:child_process";
16
16
  import { createServer } from "node:net";
17
+ import { join } from "node:path";
18
+ import { diagnosticEvents, teeDiagnosticStream } from "@rynx-ai/core";
17
19
  import { WebSocket } from "ws";
18
20
  import { createCodexChildEnv } from "../codex-child-env.js";
19
21
  import { newRuntimeProcessTag, reconcileRuntimeProcesses, registerRuntimeProcess, runtimeProcessArgv0, runtimeProcessOwnerIdentity, unregisterRuntimeProcess, withRuntimeProcessStateArg, } from "./process-registry.js";
@@ -76,6 +78,13 @@ export class WsRpcChannel {
76
78
  ...(processTag ? { argv0: runtimeProcessArgv0(this.opts.cliPath, processTag) } : {}),
77
79
  });
78
80
  const childPid = this.child.pid;
81
+ if (process.env.RYNX_RUNTIME_LOG_DIR) {
82
+ const logDir = process.env.RYNX_RUNTIME_LOG_DIR;
83
+ teeDiagnosticStream(this.child.stderr, join(logDir, "app-server.stderr.log"));
84
+ const event = diagnosticEvents(join(logDir, "process-events.jsonl"));
85
+ event("app-server.spawn", { pid: childPid });
86
+ this.child.on("exit", (code, signal) => event("app-server.exit", { code, signal }));
87
+ }
79
88
  if (processTag && this.opts.stateDir && childPid) {
80
89
  registerRuntimeProcess({
81
90
  pid: childPid,
package/dist/host.js CHANGED
@@ -1299,6 +1299,16 @@ export class LocalAgentHost {
1299
1299
  // transition.
1300
1300
  onTurnEnd: completeCurrentTurn,
1301
1301
  onTurnInterrupted: interruptCurrentTurn,
1302
+ onTurnInactive: (turnId) => {
1303
+ const responseId = `resp_codex_${turnId}`;
1304
+ if (currentResponseId === responseId) {
1305
+ closeCanonicalInteractions();
1306
+ normalizer = null;
1307
+ currentResponseId = null;
1308
+ }
1309
+ clearPendingInputsForResponse(responseId);
1310
+ emitCurrent({ type: "session.status", sessionId: currentSessionId, responseId, status: "idle" });
1311
+ },
1302
1312
  onRecoveredTurnStatus: (status, turnId, error) => {
1303
1313
  const responseId = turnId ? `resp_codex_${turnId}` : undefined;
1304
1314
  if (normalizer && (!responseId || currentResponseId === responseId)) {
@@ -1901,20 +1911,35 @@ export class LocalAgentHost {
1901
1911
  const turnId = live.forwarder.currentTurnId();
1902
1912
  if (turnId) {
1903
1913
  injectionMethod = "turn/steer";
1904
- const steered = await injectionClient.turnSteer({
1905
- threadId,
1906
- expectedTurnId: turnId,
1907
- input: nativeInput,
1908
- });
1909
- pendingInput.responseId = `resp_codex_${steered.turnId}`;
1910
- if (pendingInput.observed)
1911
- forgetPendingInput();
1912
- else
1913
- pendingInput.state = "accepted";
1914
- live.forwarder.noteTurnAccepted(steered.turnId);
1915
- return { outcome: "steered", responseId: `resp_codex_${steered.turnId}` };
1914
+ const steer = async (expectedTurnId) => {
1915
+ const steered = await injectionClient.turnSteer({ threadId, expectedTurnId, input: nativeInput });
1916
+ pendingInput.responseId = `resp_codex_${steered.turnId}`;
1917
+ if (pendingInput.observed)
1918
+ forgetPendingInput();
1919
+ else
1920
+ pendingInput.state = "accepted";
1921
+ live.forwarder.noteTurnAccepted(steered.turnId);
1922
+ return { outcome: "steered", responseId: pendingInput.responseId };
1923
+ };
1924
+ try {
1925
+ return await steer(turnId);
1926
+ }
1927
+ catch (error) {
1928
+ if (!(error instanceof CodexTransportError) || error.code !== -32600 ||
1929
+ error.message.trim().toLowerCase() !== "no active turn to steer")
1930
+ throw error;
1931
+ // Only this explicit rejection proves that resubmitting cannot
1932
+ // duplicate the input. A timeout or unrelated error never retries.
1933
+ if (live.rotationPending || live.stopped || live.threadId !== threadId)
1934
+ throw error;
1935
+ live.forwarder.clearActiveTurnIfMatches(turnId);
1936
+ }
1937
+ const currentTurnId = live.forwarder.currentTurnId();
1938
+ if (currentTurnId)
1939
+ return await steer(currentTurnId);
1916
1940
  }
1917
1941
  }
1942
+ injectionMethod = "turn/start";
1918
1943
  // Match reference implementation's turn boundary: change the native thread settings
1919
1944
  // under the same lock immediately before starting the next Turn. Never
1920
1945
  // put settings on turn/start or mutate a Turn that is already open.
@@ -3187,7 +3212,7 @@ export class LocalAgentHost {
3187
3212
  }
3188
3213
  catch (error) {
3189
3214
  live.error = error instanceof Error ? error.message : String(error);
3190
- return { outcome: "failed" };
3215
+ throw new CodexRuntimeError(`Claude message injection via terminal failed: ${live.error}`, 503, "native_message_injection_failed");
3191
3216
  }
3192
3217
  finally {
3193
3218
  if (live.injectAbort === abort)
@@ -64,9 +64,6 @@ export interface RunnerManagerOptions {
64
64
  /** Max wait for a native interrupt acknowledgement before reporting it
65
65
  * unproven. Callers may then use the explicit force-stop path. */
66
66
  liveInterruptTimeoutMs?: number;
67
- /** Max wait for an accepted owner TUI submission to become observable as a
68
- * mirrored response or a published native rotation. */
69
- terminalInputHandoffTimeoutMs?: number;
70
67
  /** Injected for tests. */
71
68
  spawn?: typeof nodeSpawn;
72
69
  /** Injected for tests. Defaults to signaling the whole POSIX process group
@@ -188,7 +185,6 @@ export declare class RunnerManager implements AgentCapabilities {
188
185
  private readonly liveReadyTimeoutMs;
189
186
  private readonly nativeLiveStartTimeoutMs;
190
187
  private readonly liveInterruptTimeoutMs;
191
- private readonly terminalInputHandoffTimeoutMs;
192
188
  private reapPromise;
193
189
  private stopping;
194
190
  private stopPromise;
@@ -224,9 +220,6 @@ export declare class RunnerManager implements AgentCapabilities {
224
220
  private readonly forkOperations;
225
221
  private readonly forkBufferedMessages;
226
222
  private readonly forkBufferedTerminalInputs;
227
- /** Owner TUI submissions accepted by the parent but not yet represented by a
228
- * mirrored response or a durably published native rotation. */
229
- private readonly terminalInputHandoffs;
230
223
  constructor(opts: RunnerManagerOptions);
231
224
  /**
232
225
  * Open a live terminal on the session's runner child (spawning it if needed).
@@ -256,17 +249,11 @@ export declare class RunnerManager implements AgentCapabilities {
256
249
  /** Register the sink for session rotations (claude `/clear`·`/fork`): the server
257
250
  * records the new session's meta (carry-over agent/model/title). */
258
251
  onRotate(listener: (rotation: RotateInfo) => void | Promise<void>): void;
259
- /** Accepted TUI submissions that have not crossed into an observable runtime
260
- * state. The daemon folds this into runningTurns after closing admission. */
261
- pendingTerminalInputCount(): number;
262
- private beginTerminalInputHandoffs;
263
- private settleTerminalInputHandoff;
264
- private settleTerminalRotationHandoff;
252
+ /** Actual transport writes and ownership transfers, independent of turn lifecycle. */
253
+ pendingNativeOperationCount(): number;
254
+ private finishTerminalWrite;
255
+ private finishTerminalWrites;
265
256
  private currentTerminalSessionId;
266
- private finishTerminalInputHandoff;
267
- private finishAllTerminalInputHandoffs;
268
- private preserveTerminalInputHandoffsAsActivity;
269
- private expireTerminalInputHandoffs;
270
257
  /**
271
258
  * Eagerly bring up a session's codex-native live view (persistent forwarder +
272
259
  * detached `codex --remote` TUI) in its runner child, spawning the runner if
@@ -18,6 +18,8 @@
18
18
  import { spawn as nodeSpawn } from "node:child_process";
19
19
  import { randomUUID } from "node:crypto";
20
20
  import readline from "node:readline";
21
+ import { join } from "node:path";
22
+ import { diagnosticEvents, sessionDiagnosticDir, teeDiagnosticStream } from "@rynx-ai/core";
21
23
  import { fileURLToPath } from "node:url";
22
24
  import { AgentRuntimeError, } from "@rynx-ai/core";
23
25
  import { listRuntimeModels } from "../models-catalog.js";
@@ -44,10 +46,6 @@ const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
44
46
  * deadline. Codex-lineage startup instead acknowledges pane/observer startup
45
47
  * and lets thread discovery race injection. */
46
48
  const DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS = 60_000;
47
- /** A submitted TUI command should become a mirrored turn or native rotation
48
- * quickly. If it does not, the runner is fenced by a verified process-tree
49
- * shutdown before maintenance may treat the submission as settled. */
50
- const DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS = 30_000;
51
49
  /** Full idle window before an inactive native pane becomes reapable. */
52
50
  const DEFAULT_NATIVE_PANE_IDLE_TTL_MS = 60 * 60_000;
53
51
  /** tmux output this recent independently proves that a native pane is busy. */
@@ -291,7 +289,6 @@ export class RunnerManager {
291
289
  liveReadyTimeoutMs;
292
290
  nativeLiveStartTimeoutMs;
293
291
  liveInterruptTimeoutMs;
294
- terminalInputHandoffTimeoutMs;
295
292
  reapPromise = null;
296
293
  stopping = false;
297
294
  stopPromise;
@@ -327,9 +324,6 @@ export class RunnerManager {
327
324
  forkOperations = new Map();
328
325
  forkBufferedMessages = new Map();
329
326
  forkBufferedTerminalInputs = new Map();
330
- /** Owner TUI submissions accepted by the parent but not yet represented by a
331
- * mirrored response or a durably published native rotation. */
332
- terminalInputHandoffs = new Map();
333
327
  constructor(opts) {
334
328
  this.config = opts.config;
335
329
  this.sessionStore = opts.sessionStore;
@@ -358,7 +352,6 @@ export class RunnerManager {
358
352
  this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
359
353
  this.nativeLiveStartTimeoutMs = Math.max(1, opts.nativeLiveStartTimeoutMs ?? DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS);
360
354
  this.liveInterruptTimeoutMs = Math.max(1, opts.liveInterruptTimeoutMs ?? 10_000);
361
- this.terminalInputHandoffTimeoutMs = Math.max(1, opts.terminalInputHandoffTimeoutMs ?? DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS);
362
355
  const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
363
356
  if (reapIntervalMs > 0) {
364
357
  this.reapTimer = setInterval(() => void this.reapIdle(), reapIntervalMs);
@@ -398,26 +391,22 @@ export class RunnerManager {
398
391
  openTerminalOnHandle(handle, localThreadId, opts) {
399
392
  handle.lastUsedAt = this.now();
400
393
  const attachId = randomUUID();
401
- const inputTracker = {
402
- buffer: "",
403
- previousWasCarriageReturn: false,
404
- bracketedPaste: false,
405
- pendingEscape: "",
406
- };
407
394
  const terminal = new ManagedTerminal(attachId, (msg) => {
408
395
  if (msg.t === "term.input")
409
396
  handle.lastUsedAt = this.now();
410
- const handoffs = msg.t === "term.input" && opts.role === "owner"
411
- ? this.beginTerminalInputHandoffs(handle, this.currentTerminalSessionId(handle, localThreadId), trackedTerminalSubmissions(inputTracker, Buffer.from(msg.dataB64, "base64").toString("utf8")))
412
- : [];
397
+ if (msg.t === "term.input" && opts.role === "owner") {
398
+ handle.terminalWrites.set(msg.reqId, {
399
+ attachId,
400
+ reservation: this.reserveAdmission(),
401
+ });
402
+ }
413
403
  const send = () => {
414
404
  try {
415
405
  handle.transport.send(msg);
416
406
  }
417
407
  catch (error) {
418
- for (const handoff of handoffs) {
419
- this.finishTerminalInputHandoff(handle, handoff);
420
- }
408
+ if (msg.t === "term.input")
409
+ this.finishTerminalWrite(handle, msg.reqId);
421
410
  throw error;
422
411
  }
423
412
  };
@@ -489,78 +478,29 @@ export class RunnerManager {
489
478
  onRotate(listener) {
490
479
  this.rotateListener = listener;
491
480
  }
492
- /** Accepted TUI submissions that have not crossed into an observable runtime
493
- * state. The daemon folds this into runningTurns after closing admission. */
494
- pendingTerminalInputCount() {
481
+ /** Actual transport writes and ownership transfers, independent of turn lifecycle. */
482
+ pendingNativeOperationCount() {
495
483
  let count = 0;
496
- for (const handoffs of this.terminalInputHandoffs.values()) {
497
- count += handoffs.length;
484
+ for (const handle of this.childHandles) {
485
+ count += handle.terminalWrites.size;
486
+ if (handle.pendingRotation)
487
+ count += 1;
488
+ if (handle.dead)
489
+ count += 1;
498
490
  }
499
491
  return count;
500
492
  }
501
- beginTerminalInputHandoffs(handle, sessionId, kinds) {
502
- if (kinds.length === 0)
503
- return [];
504
- const created = [];
505
- try {
506
- for (const kind of kinds) {
507
- const handoff = {
508
- sessionId,
509
- kind,
510
- reservation: this.reserveAdmission(),
511
- timer: undefined,
512
- };
513
- handoff.timer = setTimeout(() => {
514
- this.expireTerminalInputHandoffs(handle);
515
- }, this.terminalInputHandoffTimeoutMs);
516
- handoff.timer.unref?.();
517
- const handoffs = this.terminalInputHandoffs.get(handle);
518
- if (handoffs)
519
- handoffs.push(handoff);
520
- else
521
- this.terminalInputHandoffs.set(handle, [handoff]);
522
- created.push(handoff);
523
- }
524
- return created;
525
- }
526
- catch (error) {
527
- for (const handoff of created) {
528
- this.finishTerminalInputHandoff(handle, handoff);
529
- }
530
- throw error;
531
- }
493
+ finishTerminalWrite(handle, reqId) {
494
+ const write = handle.terminalWrites.get(reqId);
495
+ handle.terminalWrites.delete(reqId);
496
+ write?.reservation.release();
532
497
  }
533
- settleTerminalInputHandoff(handle, sessionId, kind) {
534
- const handoff = this.terminalInputHandoffs
535
- .get(handle)
536
- ?.find((candidate) => candidate.sessionId === sessionId);
537
- // Runtime observations settle accepted input in submission order. In
538
- // particular, a response from the source Session while /clear or /fork is
539
- // still publishing must not skip that rotation and release a later turn
540
- // which has not yet been rebound or delivered.
541
- if (handoff?.kind === kind)
542
- this.finishTerminalInputHandoff(handle, handoff);
543
- }
544
- settleTerminalRotationHandoff(handle, sourceSessionId, targetSessionId) {
545
- const handoffs = this.terminalInputHandoffs.get(handle);
546
- const rotationIndex = handoffs?.findIndex((handoff) => handoff.sessionId === sourceSessionId && handoff.kind === "rotation") ?? -1;
547
- if (!handoffs || rotationIndex < 0)
548
- return;
549
- // Turns submitted before /clear or /fork are superseded once the rotation
550
- // is published. Inputs accepted afterwards belong to the transferred pane
551
- // and must follow it to the target Session.
552
- const rotation = handoffs[rotationIndex];
553
- for (const handoff of [...handoffs.slice(0, rotationIndex)]) {
554
- if (handoff.sessionId === sourceSessionId && handoff.kind === "turn") {
555
- this.finishTerminalInputHandoff(handle, handoff);
498
+ finishTerminalWrites(handle, attachId) {
499
+ for (const [reqId, write] of handle.terminalWrites) {
500
+ if (attachId === undefined || write.attachId === attachId) {
501
+ this.finishTerminalWrite(handle, reqId);
556
502
  }
557
503
  }
558
- if (rotation)
559
- this.finishTerminalInputHandoff(handle, rotation);
560
- for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
561
- if (handoff.sessionId === sourceSessionId)
562
- handoff.sessionId = targetSessionId;
563
- }
564
504
  }
565
505
  currentTerminalSessionId(handle, fallback) {
566
506
  for (const [sessionId, candidate] of this.handles) {
@@ -569,51 +509,6 @@ export class RunnerManager {
569
509
  }
570
510
  return handle.activeSessionId || fallback;
571
511
  }
572
- finishTerminalInputHandoff(handle, handoff) {
573
- if (handoff.timer) {
574
- clearTimeout(handoff.timer);
575
- handoff.timer = undefined;
576
- }
577
- handoff.reservation?.release();
578
- handoff.reservation = undefined;
579
- const handoffs = this.terminalInputHandoffs.get(handle);
580
- if (!handoffs)
581
- return;
582
- const index = handoffs.indexOf(handoff);
583
- if (index >= 0)
584
- handoffs.splice(index, 1);
585
- if (handoffs.length === 0)
586
- this.terminalInputHandoffs.delete(handle);
587
- }
588
- finishAllTerminalInputHandoffs(handle) {
589
- for (const handoff of [...(this.terminalInputHandoffs.get(handle) ?? [])]) {
590
- this.finishTerminalInputHandoff(handle, handoff);
591
- }
592
- }
593
- preserveTerminalInputHandoffsAsActivity(handle) {
594
- for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
595
- if (handoff.timer) {
596
- clearTimeout(handoff.timer);
597
- handoff.timer = undefined;
598
- }
599
- handoff.reservation?.release();
600
- handoff.reservation = undefined;
601
- }
602
- }
603
- expireTerminalInputHandoffs(handle) {
604
- const handoffs = this.terminalInputHandoffs.get(handle);
605
- if (!handoffs?.length)
606
- return;
607
- for (const handoff of handoffs) {
608
- if (handoff.timer) {
609
- clearTimeout(handoff.timer);
610
- handoff.timer = undefined;
611
- }
612
- }
613
- void this.terminateHandle(handle, "accepted terminal input did not become observable before maintenance timeout").catch((error) => {
614
- logTerminationFailure(handle, error);
615
- });
616
- }
617
512
  /**
618
513
  * Eagerly bring up a session's codex-native live view (persistent forwarder +
619
514
  * detached `codex --remote` TUI) in its runner child, spawning the runner if
@@ -1021,10 +916,7 @@ export class RunnerManager {
1021
916
  if (this.reapTimer) {
1022
917
  clearInterval(this.reapTimer);
1023
918
  }
1024
- const children = new Set([
1025
- ...this.childHandles,
1026
- ...this.terminalInputHandoffs.keys(),
1027
- ]);
919
+ const children = new Set(this.childHandles);
1028
920
  const attempt = (async () => {
1029
921
  const results = await Promise.allSettled([...children].map((handle) => this.terminateHandle(handle, "runner manager stopped")));
1030
922
  this.handles.clear();
@@ -1052,8 +944,7 @@ export class RunnerManager {
1052
944
  // ── internals ──────────────────────────────────────────────────────────────
1053
945
  handleForCleanup(localThreadId) {
1054
946
  return this.handles.get(localThreadId) ??
1055
- [...this.terminalInputHandoffs].find(([handle, handoffs]) => handle.key === localThreadId ||
1056
- handoffs.some((handoff) => handoff.sessionId === localThreadId))?.[0];
947
+ [...this.childHandles].find((handle) => handle.key === localThreadId || handle.activeSessionId === localThreadId);
1057
948
  }
1058
949
  /** Forward a capability to a runner child. Session-less caps (listModels/status)
1059
950
  * use the shared `CAP_KEY` child; per-thread caps pass the session's key so they
@@ -1306,9 +1197,6 @@ export class RunnerManager {
1306
1197
  this.collaborationModeListener?.(message.sessionId, message.event.mode);
1307
1198
  }
1308
1199
  this.observeHandleRuntimeEvent(handle, message.event);
1309
- if (message.event.type === "response.created") {
1310
- this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
1311
- }
1312
1200
  };
1313
1201
  const commitDeliveredObservation = () => {
1314
1202
  commitObservation();
@@ -1525,6 +1413,7 @@ export class RunnerManager {
1525
1413
  inFlight: false,
1526
1414
  logicalTargetPublished: false,
1527
1415
  publicationComplete: false,
1416
+ admission: this.admissionReserve?.(),
1528
1417
  };
1529
1418
  handle.pendingRotation = pending;
1530
1419
  this.forkReservations.set(pending.to, targetReservation);
@@ -1657,7 +1546,6 @@ export class RunnerManager {
1657
1546
  try {
1658
1547
  // Change the active Session only after ownership transfer. In
1659
1548
  // this IPC topology rotate.applied is the child-owned equivalent proof.
1660
- this.settleTerminalRotationHandoff(handle, pending.from, pending.to);
1661
1549
  handle.activeResponseIds.clear();
1662
1550
  for (const [key, candidate] of this.handles) {
1663
1551
  if (candidate !== handle)
@@ -1736,6 +1624,7 @@ export class RunnerManager {
1736
1624
  this.forkBufferedTerminalInputs.delete(pending.to);
1737
1625
  pending.releaseTarget();
1738
1626
  pending.releaseSource();
1627
+ pending.admission?.release();
1739
1628
  }
1740
1629
  async publishNativeRotationNotice(pending) {
1741
1630
  // Only /clear supersedes the old Session. A /fork keeps
@@ -1800,6 +1689,9 @@ export class RunnerManager {
1800
1689
  await Promise.all(publications);
1801
1690
  }
1802
1691
  spawnHandle(key) {
1692
+ const logDir = join(sessionDiagnosticDir("runtime", key), "instances", randomUUID());
1693
+ const event = diagnosticEvents(join(logDir, "process-events.jsonl"), { sessionId: key });
1694
+ event("runner.spawn");
1803
1695
  const args = this.runnerEntry.endsWith(".ts")
1804
1696
  ? ["--import", "tsx", this.runnerEntry]
1805
1697
  : [this.runnerEntry];
@@ -1820,6 +1712,7 @@ export class RunnerManager {
1820
1712
  ...this.childEnv,
1821
1713
  ...(sessionContext?.childEnv ?? {}),
1822
1714
  RYNX_RUNNER_SESSION: key,
1715
+ RYNX_RUNTIME_LOG_DIR: logDir,
1823
1716
  },
1824
1717
  });
1825
1718
  }
@@ -1828,6 +1721,10 @@ export class RunnerManager {
1828
1721
  throw error;
1829
1722
  }
1830
1723
  const completion = observeChildCompletion(child);
1724
+ teeDiagnosticStream(child.stderr, join(logDir, "runner.stderr.log"));
1725
+ child.on("spawn", () => event("runner.started", { pid: child.pid }));
1726
+ child.on("error", (error) => event("runner.error", { message: error.message }));
1727
+ child.on("exit", (code, signal) => event("runner.exit", { code, signal }));
1831
1728
  const transport = new StdioRunnerTransport(child.stdout, child.stdin);
1832
1729
  const handle = {
1833
1730
  key,
@@ -1838,6 +1735,7 @@ export class RunnerManager {
1838
1735
  lastUsedAt: this.now(),
1839
1736
  activeResponseIds: new Set(),
1840
1737
  terminalResponseIds: new Set(),
1738
+ terminalWrites: new Map(),
1841
1739
  dead: false,
1842
1740
  completion,
1843
1741
  processGroup,
@@ -1849,7 +1747,10 @@ export class RunnerManager {
1849
1747
  mirrorDeliveries: new Map(),
1850
1748
  };
1851
1749
  this.childHandles.add(handle);
1852
- void completion.then(() => this.childHandles.delete(handle));
1750
+ void completion.then(() => {
1751
+ this.finishTerminalWrites(handle);
1752
+ this.childHandles.delete(handle);
1753
+ });
1853
1754
  transport.onMessage((msg) => this.onChildMessage(handle, msg));
1854
1755
  if (child.stderr) {
1855
1756
  const rl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity });
@@ -1911,9 +1812,15 @@ export class RunnerManager {
1911
1812
  });
1912
1813
  return;
1913
1814
  case "term.ack":
1815
+ if (msg.operation === "input")
1816
+ this.finishTerminalWrite(handle, msg.reqId);
1914
1817
  handle.terminals.get(msg.attachId)?._ack(msg.reqId, msg.operation);
1915
1818
  return;
1916
1819
  case "term.error": {
1820
+ if (msg.reqId)
1821
+ this.finishTerminalWrite(handle, msg.reqId);
1822
+ else
1823
+ this.finishTerminalWrites(handle, msg.attachId);
1917
1824
  const terminal = handle.terminals.get(msg.attachId);
1918
1825
  if (!msg.reqId)
1919
1826
  handle.terminals.delete(msg.attachId);
@@ -2264,13 +2171,12 @@ export class RunnerManager {
2264
2171
  })();
2265
2172
  handle.termination = attempt;
2266
2173
  void attempt.then(() => {
2267
- this.finishAllTerminalInputHandoffs(handle);
2174
+ this.finishTerminalWrites(handle);
2268
2175
  }, () => {
2269
2176
  if (handle.termination === attempt)
2270
2177
  delete handle.termination;
2271
- // A child that survives SIGKILL is still real activity. Release the
2272
- // gate reservation but retain the handoff until a later explicit stop.
2273
- this.preserveTerminalInputHandoffsAsActivity(handle);
2178
+ // An unexited dead handle remains visible in pendingNativeOperationCount.
2179
+ this.finishTerminalWrites(handle);
2274
2180
  });
2275
2181
  return attempt;
2276
2182
  }
@@ -2536,70 +2442,6 @@ function rememberTerminalResponse(handle, responseId) {
2536
2442
  handle.terminalResponseIds.delete(oldest);
2537
2443
  }
2538
2444
  }
2539
- function trackedTerminalSubmissions(tracker, data) {
2540
- const pasteStart = "\u001b[200~";
2541
- const pasteEnd = "\u001b[201~";
2542
- const input = tracker.pendingEscape + data;
2543
- tracker.pendingEscape = "";
2544
- const submissions = [];
2545
- for (let index = 0; index < input.length; index += 1) {
2546
- const character = input[index] ?? "";
2547
- if (character === "\u001b") {
2548
- const remaining = input.slice(index);
2549
- if (remaining.startsWith(pasteStart)) {
2550
- tracker.bracketedPaste = true;
2551
- tracker.previousWasCarriageReturn = false;
2552
- index += pasteStart.length - 1;
2553
- continue;
2554
- }
2555
- if (remaining.startsWith(pasteEnd)) {
2556
- tracker.bracketedPaste = false;
2557
- tracker.previousWasCarriageReturn = false;
2558
- index += pasteEnd.length - 1;
2559
- continue;
2560
- }
2561
- if (pasteStart.startsWith(remaining) || pasteEnd.startsWith(remaining)) {
2562
- tracker.pendingEscape = remaining;
2563
- break;
2564
- }
2565
- }
2566
- if (tracker.bracketedPaste) {
2567
- tracker.buffer += character;
2568
- tracker.previousWasCarriageReturn = false;
2569
- continue;
2570
- }
2571
- if (character === "\n" && tracker.previousWasCarriageReturn) {
2572
- tracker.previousWasCarriageReturn = false;
2573
- continue;
2574
- }
2575
- tracker.previousWasCarriageReturn = character === "\r";
2576
- if (character === "\r" || character === "\n") {
2577
- const command = tracker.buffer.trim();
2578
- tracker.buffer = "";
2579
- if (command.length > 0 &&
2580
- (command.includes("\u001b") ||
2581
- !command.startsWith("/") ||
2582
- /^\/(?:branch|clear|fork)(?:\s|$)/u.test(command))) {
2583
- submissions.push(/^\/(?:branch|clear|fork)(?:\s|$)/u.test(command) ? "rotation" : "turn");
2584
- }
2585
- continue;
2586
- }
2587
- if (character === "\b" || character === "\u007f") {
2588
- tracker.buffer = tracker.buffer.slice(0, -1);
2589
- continue;
2590
- }
2591
- if (character === "\u0015" || character === "\u0003") {
2592
- tracker.buffer = "";
2593
- continue;
2594
- }
2595
- // Printable text and tabs are enough to distinguish work-producing prompts
2596
- // from local TUI slash commands. Other control sequences are ignored.
2597
- if (character === "\u001b" || character === "\t" || character >= " ") {
2598
- tracker.buffer += character;
2599
- }
2600
- }
2601
- return submissions;
2602
- }
2603
2445
  function validateSessionContext(context) {
2604
2446
  if (!context || typeof context !== "object") {
2605
2447
  throw new Error("Session context environment provider must return an object");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/runtime",
3
- "version": "0.1.11-beta.45",
3
+ "version": "0.1.11-beta.49",
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
  "smol-toml": "1.7.1",
28
28
  "ws": "^8.21.0",
29
- "@rynx-ai/core": "0.1.11-beta.45"
29
+ "@rynx-ai/core": "0.1.11-beta.49"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/ws": "^8.18.1"