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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/claude/executor.d.ts +19 -5
  2. package/dist/claude/executor.js +56 -12
  3. package/dist/claude/models.d.ts +0 -5
  4. package/dist/claude/models.js +1 -7
  5. package/dist/claude/native-bridge.d.ts +103 -1
  6. package/dist/claude/native-bridge.js +445 -30
  7. package/dist/claude/native-hook-main.js +81 -1
  8. package/dist/claude/native-hooks.js +7 -0
  9. package/dist/claude/native-integration.d.ts +178 -26
  10. package/dist/claude/native-integration.js +1528 -170
  11. package/dist/claude/session-status.d.ts +39 -0
  12. package/dist/claude/session-status.js +163 -0
  13. package/dist/claude/transcript-clone.d.ts +18 -0
  14. package/dist/claude/transcript-clone.js +497 -0
  15. package/dist/claude/transcript.d.ts +27 -4
  16. package/dist/claude/transcript.js +158 -47
  17. package/dist/codex-app-server/client.d.ts +10 -6
  18. package/dist/codex-app-server/client.js +67 -15
  19. package/dist/codex-app-server/forwarder.d.ts +92 -3
  20. package/dist/codex-app-server/forwarder.js +532 -57
  21. package/dist/codex-app-server/mapping.d.ts +3 -6
  22. package/dist/codex-app-server/mapping.js +206 -36
  23. package/dist/codex-app-server/mcp-startup.d.ts +13 -0
  24. package/dist/codex-app-server/mcp-startup.js +63 -0
  25. package/dist/codex-app-server/process-registry.d.ts +36 -0
  26. package/dist/codex-app-server/process-registry.js +320 -0
  27. package/dist/codex-app-server/protocol.d.ts +64 -7
  28. package/dist/codex-app-server/ws-channel.d.ts +7 -0
  29. package/dist/codex-app-server/ws-channel.js +104 -28
  30. package/dist/codex-home.d.ts +35 -3
  31. package/dist/codex-home.js +323 -18
  32. package/dist/codex-session-store.d.ts +23 -0
  33. package/dist/codex-session-store.js +21 -0
  34. package/dist/host.d.ts +103 -46
  35. package/dist/host.js +1988 -634
  36. package/dist/index.d.ts +3 -3
  37. package/dist/index.js +1 -1
  38. package/dist/input-resources.d.ts +4 -0
  39. package/dist/input-resources.js +21 -5
  40. package/dist/models-catalog.d.ts +2 -1
  41. package/dist/models-catalog.js +94 -6
  42. package/dist/runner/child.d.ts +97 -28
  43. package/dist/runner/child.js +1486 -100
  44. package/dist/runner/manager.d.ts +110 -29
  45. package/dist/runner/manager.js +1481 -246
  46. package/dist/runner/protocol.d.ts +212 -24
  47. package/dist/runner/protocol.js +5 -0
  48. package/dist/runner/startup-policy.d.ts +7 -0
  49. package/dist/runner/startup-policy.js +10 -0
  50. package/dist/runner/transport.d.ts +18 -2
  51. package/dist/runner/transport.js +82 -3
  52. package/dist/runner-main.js +8 -3
  53. package/dist/terminal/claude-tui.d.ts +3 -1
  54. package/dist/terminal/claude-tui.js +3 -1
  55. package/dist/terminal/codex-tui.d.ts +4 -0
  56. package/dist/terminal/codex-tui.js +5 -0
  57. package/dist/terminal/control-parser.d.ts +39 -0
  58. package/dist/terminal/control-parser.js +172 -0
  59. package/dist/terminal/registry.d.ts +18 -15
  60. package/dist/terminal/registry.js +44 -23
  61. package/dist/terminal/spool.d.ts +47 -0
  62. package/dist/terminal/spool.js +231 -0
  63. package/dist/terminal/tmux.d.ts +126 -74
  64. package/dist/terminal/tmux.js +807 -211
  65. package/package.json +4 -4
@@ -15,6 +15,7 @@
15
15
  import { readFileSync } from "node:fs";
16
16
  import { open, stat } from "node:fs/promises";
17
17
  import { join } from "node:path";
18
+ import { setTimeout as sleep } from "node:timers/promises";
18
19
  /** A sub-agent (Task) writes its own transcript to
19
20
  * `<project>/<sessionId>/subagents/agent-<agentId>.jsonl`, alongside the parent
20
21
  * `<sessionId>.jsonl`. Derive that path from the parent transcript path. */
@@ -22,11 +23,32 @@ export function subagentTranscriptPath(parentTranscriptPath, agentId) {
22
23
  const dir = parentTranscriptPath.replace(/\.jsonl$/, "");
23
24
  return join(dir, "subagents", `agent-${agentId}.jsonl`);
24
25
  }
26
+ function strippedImagePlaceholder(source) {
27
+ const mediaType = source.media_type;
28
+ const label = typeof mediaType === "string" && mediaType ? `${mediaType} image` : "image";
29
+ return `[${label} omitted from history to save context — re-run the tool call above (e.g. Read the same path) to view it again]`;
30
+ }
31
+ /** Remove Claude's inline image bytes before tool output reaches canonical
32
+ * history. A Read image result can contain a full-resolution base64 payload;
33
+ * replaying it as text only bloats the transcript, while the model cannot use
34
+ * those encoded bytes as text. Keep a small, human-readable marker instead. */
35
+ function stripInlineImageData(value) {
36
+ if (Array.isArray(value))
37
+ return value.map(stripInlineImageData);
38
+ if (!isObject(value))
39
+ return value;
40
+ const source = isObject(value.source) ? value.source : undefined;
41
+ if (value.type === "image" && source) {
42
+ return { type: "text", text: strippedImagePlaceholder(source) };
43
+ }
44
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, stripInlineImageData(nested)]));
45
+ }
25
46
  function stringifyToolContent(content) {
26
- if (typeof content === "string")
27
- return content;
28
- if (Array.isArray(content)) {
29
- return content
47
+ const stripped = stripInlineImageData(content);
48
+ if (typeof stripped === "string")
49
+ return stripped;
50
+ if (Array.isArray(stripped)) {
51
+ return stripped
30
52
  .map((part) => part && typeof part === "object" && "text" in part
31
53
  ? String(part.text ?? "")
32
54
  : typeof part === "string"
@@ -34,22 +56,26 @@ function stringifyToolContent(content) {
34
56
  : JSON.stringify(part))
35
57
  .join("");
36
58
  }
37
- return content == null ? "" : JSON.stringify(content);
38
- }
39
- function toolLabel(name, input) {
40
- if (input && typeof input === "object") {
41
- const rec = input;
42
- for (const key of ["command", "cmd", "path", "file_path", "pattern", "query"]) {
43
- const v = rec[key];
44
- if (typeof v === "string" && v)
45
- return v;
46
- }
47
- }
48
- return name ?? "tool";
59
+ return stripped == null ? "" : JSON.stringify(stripped);
49
60
  }
50
61
  const BASH_INPUT_RE = /<bash-input>([\s\S]*?)<\/bash-input>/;
51
62
  const BASH_STDOUT_RE = /<bash-stdout>([\s\S]*?)<\/bash-stdout>/;
52
63
  const BASH_STDERR_RE = /<bash-stderr>([\s\S]*?)<\/bash-stderr>/;
64
+ /** Parse either half of Claude's shell-mode record. Newer and older Claude
65
+ * builds may split `<bash-input>` from the later stdout/stderr record. */
66
+ export function parseTerminalCommandFragments(content) {
67
+ const input = BASH_INPUT_RE.exec(content);
68
+ const stdout = BASH_STDOUT_RE.exec(content);
69
+ const stderr = BASH_STDERR_RE.exec(content);
70
+ if (!input && !stdout && !stderr)
71
+ return undefined;
72
+ return {
73
+ ...(input ? { command: input[1].trim() } : {}),
74
+ ...(stdout ? { stdout: stdout[1] } : {}),
75
+ ...(stderr ? { stderr: stderr[1] } : {}),
76
+ hasOutput: Boolean(stdout || stderr),
77
+ };
78
+ }
53
79
  /**
54
80
  * Parse a claude local-command (`!` bash mode) user record's string content into
55
81
  * a {@link TerminalCommandData}. Claude records the command and its captured
@@ -59,16 +85,14 @@ const BASH_STDERR_RE = /<bash-stderr>([\s\S]*?)<\/bash-stderr>/;
59
85
  * `<system-reminder>`) are bookkeeping, not terminal commands.
60
86
  */
61
87
  export function parseTerminalCommand(content) {
62
- const input = BASH_INPUT_RE.exec(content);
63
- if (!input)
88
+ const fragments = parseTerminalCommandFragments(content);
89
+ if (!fragments || fragments.command === undefined)
64
90
  return undefined;
65
- const data = { command: input[1].trim() };
66
- const stdout = BASH_STDOUT_RE.exec(content)?.[1];
67
- const stderr = BASH_STDERR_RE.exec(content)?.[1];
68
- if (stdout)
69
- data.stdout = stdout;
70
- if (stderr)
71
- data.stderr = stderr;
91
+ const data = { command: fragments.command };
92
+ if (fragments.stdout)
93
+ data.stdout = fragments.stdout;
94
+ if (fragments.stderr)
95
+ data.stderr = fragments.stderr;
72
96
  return data;
73
97
  }
74
98
  /**
@@ -103,26 +127,42 @@ export function parseTranscriptRecord(record, opts) {
103
127
  if (rec.isSidechain && !parent)
104
128
  return []; // sub-agent turns are replayed separately
105
129
  const message = rec.message;
106
- if (!message || !Array.isArray(message.content))
130
+ if (!message)
107
131
  return [];
108
132
  const out = [];
109
133
  if (rec.type === "assistant" && message.role === "assistant") {
134
+ const content = typeof message.content === "string"
135
+ ? [{ type: "text", text: message.content }]
136
+ : message.content;
137
+ if (!Array.isArray(content))
138
+ return [];
110
139
  // Claude writes this zero-token synthetic filler after an accidental empty
111
140
  // submit. It is transcript bookkeeping, not an assistant response. Filter
112
141
  // by provider provenance and exact shape here, before normalization loses
113
142
  // `model: "<synthetic>"`; a UI string filter could hide legitimate output.
114
143
  if (!rec.isApiErrorMessage &&
115
144
  message.model === "<synthetic>" &&
116
- message.content.length === 1 &&
117
- message.content[0]?.type === "text" &&
118
- message.content[0].text === "No response requested.") {
145
+ content.length === 1 &&
146
+ content[0]?.type === "text" &&
147
+ content[0].text === "No response requested.") {
119
148
  return [];
120
149
  }
121
150
  const itemId = message.id;
122
- const texts = [];
123
- for (const block of message.content) {
151
+ const textBlockCount = content.filter((block) => block.type === "text" && block.text).length;
152
+ for (const [blockIndex, block] of content.entries()) {
124
153
  if (block.type === "text" && block.text) {
125
- texts.push(block.text);
154
+ // Preserve Claude's block order. Keep a single text block's
155
+ // native message id for MessageDisplay correlation; only derive an id
156
+ // when one record contains multiple independently ordered text blocks.
157
+ const textItemId = itemId && textBlockCount > 1
158
+ ? `${itemId}:text:${blockIndex}`
159
+ : itemId;
160
+ out.push({
161
+ type: "message_completed",
162
+ text: block.text,
163
+ ...(textItemId ? { itemId: textItemId } : {}),
164
+ ...parentTag,
165
+ });
126
166
  }
127
167
  else if (block.type === "thinking" && block.thinking) {
128
168
  out.push({ type: "reasoning_completed", summary: [block.thinking], ...(itemId ? { itemId } : {}), ...parentTag });
@@ -132,18 +172,20 @@ export function parseTranscriptRecord(record, opts) {
132
172
  type: "tool",
133
173
  event: "on_tool_start",
134
174
  name: block.name,
135
- input: { ...(isObject(block.input) ? block.input : {}), id: block.id, command: toolLabel(block.name, block.input) },
136
- data: { id: block.id },
175
+ input: { ...(isObject(block.input) ? block.input : {}), id: block.id },
176
+ // A transcript tool_use is already a completed Provider record. Live
177
+ // runtimes still omit this marker and remain in_progress until their
178
+ // completion event arrives.
179
+ data: { id: block.id, itemStatus: "completed" },
137
180
  ...parentTag,
138
181
  });
139
182
  }
140
183
  }
141
- if (texts.length) {
142
- out.push({ type: "message_completed", text: texts.join(""), ...(itemId ? { itemId } : {}), ...parentTag });
143
- }
144
184
  return out;
145
185
  }
146
186
  if (rec.type === "user" && message.role === "user") {
187
+ if (!Array.isArray(message.content))
188
+ return [];
147
189
  for (const block of message.content) {
148
190
  if (block.type === "tool_result") {
149
191
  out.push({
@@ -156,7 +198,7 @@ export function parseTranscriptRecord(record, opts) {
156
198
  exitCode: null,
157
199
  },
158
200
  ...(block.is_error ? { error: "tool_error" } : {}),
159
- data: { tool_use_id: block.tool_use_id },
201
+ data: { tool_use_id: block.tool_use_id, itemStatus: "completed" },
160
202
  ...parentTag,
161
203
  });
162
204
  }
@@ -169,13 +211,14 @@ function isObject(value) {
169
211
  }
170
212
  /**
171
213
  * Whether a transcript is a `/fork` (branch) of another session: claude stamps a
172
- * `forkedFrom: { sessionId }` marker in an early record pointing at the source
214
+ * `forkedFrom: { sessionId }` marker on copied records pointing at the source
173
215
  * session (reference implementation's `transcript_has_forked_from_marker`). Used to distinguish a
174
216
  * fork from an ordinary `resume` (both arrive as `SessionStart source="resume"`).
175
- * Scans only the head of the file (the marker lands up front). NOTE: unverified on
176
- * this host no local transcript carries the marker so it follows reference implementation's shape.
217
+ * The marker must belong to the announced target and expected source; sample
218
+ * the first and last 200 records because long copied histories can place it at
219
+ * either edge.
177
220
  */
178
- export function transcriptHasForkedFrom(path, currentSessionId) {
221
+ export function transcriptHasForkedFrom(path, claudeSessionId, sourceClaudeSessionId) {
179
222
  let text;
180
223
  try {
181
224
  text = readFileSync(path, "utf8");
@@ -183,17 +226,60 @@ export function transcriptHasForkedFrom(path, currentSessionId) {
183
226
  catch {
184
227
  return false;
185
228
  }
186
- let scanned = 0;
187
- for (const line of text.split("\n")) {
188
- if (++scanned > 200)
189
- break;
229
+ const lines = text.split("\n");
230
+ const sampled = lines.length <= 400
231
+ ? lines
232
+ : [...lines.slice(0, 200), ...lines.slice(-200)];
233
+ for (const line of sampled) {
190
234
  const trimmed = line.trim();
191
235
  if (!trimmed || !trimmed.includes("forkedFrom"))
192
236
  continue;
193
237
  try {
194
238
  const rec = JSON.parse(trimmed);
239
+ if (rec.sessionId !== claudeSessionId)
240
+ continue;
195
241
  const from = rec.forkedFrom?.sessionId;
196
- if (typeof from === "string" && from && from !== currentSessionId)
242
+ if (typeof from === "string" &&
243
+ from &&
244
+ from !== claudeSessionId &&
245
+ (sourceClaudeSessionId === undefined || from === sourceClaudeSessionId))
246
+ return true;
247
+ }
248
+ catch {
249
+ // skip malformed
250
+ }
251
+ }
252
+ return false;
253
+ }
254
+ const RECENT_LOCAL_COMMAND_LINE_LIMIT = 200;
255
+ const RECENT_LOCAL_COMMAND_WINDOW_MS = 10_000;
256
+ /** Claude versions without a copied-record marker still persist `/fork` and
257
+ * `/branch` as a recent top-level local command. Match only the new native
258
+ * Session and the hook's narrow time window. */
259
+ export function transcriptHasRecentLocalCommand(path, claudeSessionId, recordedAtMs, commandNames = new Set(["/fork", "/branch"])) {
260
+ let text;
261
+ try {
262
+ text = readFileSync(path, "utf8");
263
+ }
264
+ catch {
265
+ return false;
266
+ }
267
+ for (const line of text.split("\n").slice(-RECENT_LOCAL_COMMAND_LINE_LIMIT)) {
268
+ const trimmed = line.trim();
269
+ if (!trimmed)
270
+ continue;
271
+ try {
272
+ const rec = JSON.parse(trimmed);
273
+ if (rec.sessionId !== claudeSessionId || rec.subtype !== "local_command")
274
+ continue;
275
+ const timestamp = transcriptTimestampMs(rec.timestamp);
276
+ if (timestamp === undefined ||
277
+ Math.abs(timestamp - recordedAtMs) > RECENT_LOCAL_COMMAND_WINDOW_MS ||
278
+ typeof rec.content !== "string")
279
+ continue;
280
+ const command = /<command-name>([\s\S]*?)<\/command-name>/
281
+ .exec(rec.content)?.[1]?.trim();
282
+ if (command && commandNames.has(command))
197
283
  return true;
198
284
  }
199
285
  catch {
@@ -202,6 +288,31 @@ export function transcriptHasForkedFrom(path, currentSessionId) {
202
288
  }
203
289
  return false;
204
290
  }
291
+ /** Wait briefly for Claude to flush either fork signal after SessionStart.
292
+ * The observer hook calls this before recording the edge, allowing a
293
+ * one-second late-marker window without delaying ordinary transcript polling. */
294
+ export async function waitForTranscriptForkSignal(path, claudeSessionId, sourceClaudeSessionId, recordedAtMs, options = {}) {
295
+ const timeoutMs = Math.max(0, options.timeoutMs ?? 1_000);
296
+ const pollMs = Math.max(1, options.pollMs ?? 50);
297
+ const deadline = Date.now() + timeoutMs;
298
+ do {
299
+ if (transcriptHasForkedFrom(path, claudeSessionId, sourceClaudeSessionId) ||
300
+ transcriptHasRecentLocalCommand(path, claudeSessionId, recordedAtMs))
301
+ return true;
302
+ if (Date.now() >= deadline)
303
+ return false;
304
+ await sleep(Math.min(pollMs, Math.max(1, deadline - Date.now())));
305
+ } while (true);
306
+ }
307
+ function transcriptTimestampMs(value) {
308
+ if (typeof value === "number" && Number.isFinite(value)) {
309
+ return value < 10_000_000_000 ? value * 1_000 : value;
310
+ }
311
+ if (typeof value !== "string" || !value)
312
+ return undefined;
313
+ const parsed = Date.parse(value);
314
+ return Number.isFinite(parsed) ? parsed : undefined;
315
+ }
205
316
  /**
206
317
  * Read a sub-agent (Task) transcript in full and map it to {@link AgentEvent}s
207
318
  * tagged with `parentToolUseId`. Called once the parent Task tool_result arrives
@@ -1,5 +1,5 @@
1
1
  import type { RuntimeUserInput, SessionInteractionResolution } from "@rynx-ai/core";
2
- import type { AskForApproval, ClientInfo, CollaborationModeListResponse, GetAuthStatusParams, GetAuthStatusResponse, InitializeResponse, ModelListParams, ModelListResponse, ReviewStartParams, ReviewStartResponse, SandboxMode, ThreadForkParams, ThreadGoalClearParams, ThreadGoalGetParams, ThreadGoalGetResponse, ThreadGoalSetParams, ThreadListParams, ThreadListResponse, ResumedThread, ThreadResumeParams, ThreadSettingsUpdateParams, ThreadStartParams, TurnInterruptParams, TurnStartParams, TurnSteerParams, UserInput } from "./protocol.js";
2
+ import type { AskForApproval, ClientInfo, CollaborationModeListResponse, ConfigReadParams, ConfigReadResponse, GetAuthStatusParams, GetAuthStatusResponse, InitializeResponse, ModelListParams, ModelListResponse, ReviewStartParams, ReviewStartResponse, SandboxMode, ThreadForkParams, ThreadGoalClearParams, ThreadGoalGetParams, ThreadGoalGetResponse, ThreadGoalSetParams, ThreadListParams, ThreadListResponse, ResumedThread, ThreadResumeParams, ThreadRuntimeSettings, ThreadSettingsUpdateParams, ThreadStartParams, TurnInterruptParams, TurnStartParams, TurnSteerParams, UserInput } from "./protocol.js";
3
3
  import { CodexAppServerTransport, type CodexAppServerProcessSpawner, type RpcChannel, type TransportLogger } from "./transport.js";
4
4
  import type { ResolveInteractionResult, RuntimeInteractionListener } from "../interactions.js";
5
5
  export type ApprovalDecisionPolicy = "auto-approve-session" | "auto-decline" | "auto-cancel";
@@ -29,9 +29,11 @@ export declare class CodexAppServerClient {
29
29
  private interactionListener;
30
30
  private connectionListener;
31
31
  private connectionState;
32
+ private connectionEstablished;
32
33
  private readonly pendingInteractions;
33
34
  private readonly settledInteractions;
34
35
  private initializeResponse;
36
+ private initializePromise;
35
37
  constructor({ spawner, channel, logger, clientInfo, approvalDecisionPolicy, }: CodexAppServerClientOptions);
36
38
  /**
37
39
  * The multi-client endpoint a `codex --remote` TUI can attach to, when this
@@ -42,13 +44,16 @@ export declare class CodexAppServerClient {
42
44
  terminalRemoteUrl(): string | undefined;
43
45
  ensureInitialized(): Promise<InitializeResponse>;
44
46
  getAuthStatus(params?: GetAuthStatusParams): Promise<GetAuthStatusResponse>;
47
+ /** Read the runtime's merged effective config. This is the stable seam for
48
+ * discovering a fresh thread's model across Traex YAML/TOML generations. */
49
+ configRead(params: ConfigReadParams): Promise<ConfigReadResponse>;
45
50
  threadStart(params: ThreadStartParams): Promise<{
46
51
  threadId: string;
47
- }>;
52
+ } & ThreadRuntimeSettings>;
48
53
  threadResume(params: ThreadResumeParams): Promise<{
49
54
  threadId: string;
50
55
  thread: ResumedThread;
51
- }>;
56
+ } & ThreadRuntimeSettings>;
52
57
  turnStart(params: TurnStartParams): Promise<{
53
58
  turnId: string;
54
59
  }>;
@@ -94,9 +99,8 @@ export declare class CodexAppServerClient {
94
99
  * resolver still wins correctly.
95
100
  */
96
101
  setInteractionListener(listener: RuntimeInteractionListener | null): void;
97
- /** Observe the underlying app-server connection independently from any one
98
- * interaction. A disconnected client that never received the duplicate
99
- * native request still has to count as unavailable during host failover. */
102
+ /** Observe the initialized connection lifecycle. Registration never reports
103
+ * disconnected for a client that has not connected yet. */
100
104
  setConnectionListener(listener: ((state: "connected" | "disconnected") => void) | null): void;
101
105
  private setConnectionState;
102
106
  resolveInteraction(interactionId: string, resolution: SessionInteractionResolution): ResolveInteractionResult;
@@ -953,9 +953,11 @@ export class CodexAppServerClient {
953
953
  interactionListener = null;
954
954
  connectionListener = null;
955
955
  connectionState = "disconnected";
956
+ connectionEstablished = false;
956
957
  pendingInteractions = new Map();
957
958
  settledInteractions = new Set();
958
959
  initializeResponse = null;
960
+ initializePromise = null;
959
961
  constructor({ spawner, channel, logger = defaultLogger, clientInfo = DEFAULT_CLIENT_INFO, approvalDecisionPolicy = "auto-approve-session", }) {
960
962
  this.logger = logger;
961
963
  this.clientInfo = clientInfo;
@@ -988,30 +990,66 @@ export class CodexAppServerClient {
988
990
  if (this.initializeResponse) {
989
991
  return this.initializeResponse;
990
992
  }
991
- await this.transport.ensureStarted();
992
- const response = await this.transport.sendRequest("initialize", {
993
- clientInfo: this.clientInfo,
994
- capabilities: { experimentalApi: true },
995
- });
996
- this.initializeResponse = response;
997
- this.setConnectionState("connected");
998
- return response;
993
+ if (this.initializePromise)
994
+ return this.initializePromise;
995
+ const initializing = (async () => {
996
+ await this.transport.ensureStarted();
997
+ const response = await this.transport.sendRequest("initialize", {
998
+ clientInfo: this.clientInfo,
999
+ capabilities: { experimentalApi: true },
1000
+ });
1001
+ // Codex app-server uses the full initialize handshake: it does not accept
1002
+ // capability requests after merely replying to `initialize`. The client
1003
+ // must acknowledge that response with the `initialized` notification
1004
+ // before `thread/resume`, `turn/start`, and the other APIs are legal.
1005
+ await this.transport.sendNotification("initialized");
1006
+ this.initializeResponse = response;
1007
+ this.setConnectionState("connected");
1008
+ return response;
1009
+ })();
1010
+ this.initializePromise = initializing;
1011
+ try {
1012
+ return await initializing;
1013
+ }
1014
+ finally {
1015
+ if (this.initializePromise === initializing)
1016
+ this.initializePromise = null;
1017
+ }
999
1018
  }
1000
1019
  async getAuthStatus(params = {}) {
1001
1020
  await this.ensureInitialized();
1002
1021
  return this.transport.sendRequest("getAuthStatus", params);
1003
1022
  }
1023
+ /** Read the runtime's merged effective config. This is the stable seam for
1024
+ * discovering a fresh thread's model across Traex YAML/TOML generations. */
1025
+ async configRead(params) {
1026
+ await this.ensureInitialized();
1027
+ return this.transport.sendRequest("config/read", params);
1028
+ }
1004
1029
  async threadStart(params) {
1005
1030
  await this.ensureInitialized();
1006
1031
  const response = await this.transport.sendRequest("thread/start", params);
1007
- return { threadId: response.thread.id };
1032
+ return {
1033
+ threadId: response.thread.id,
1034
+ ...(response.model ? { model: response.model } : {}),
1035
+ ...(response.reasoningEffort === undefined
1036
+ ? {}
1037
+ : { reasoningEffort: response.reasoningEffort }),
1038
+ };
1008
1039
  }
1009
1040
  async threadResume(params) {
1010
1041
  await this.ensureInitialized();
1011
1042
  const response = await this.transport.sendRequest("thread/resume", params);
1012
1043
  // Expose the whole `thread` (not just its id): its `turns[].items[]` are the
1013
1044
  // backfill the forwarder replays for a fresh thread's first turn.
1014
- return { threadId: response.thread.id, thread: response.thread };
1045
+ return {
1046
+ threadId: response.thread.id,
1047
+ thread: response.thread,
1048
+ ...(response.model ? { model: response.model } : {}),
1049
+ ...(response.reasoningEffort === undefined
1050
+ ? {}
1051
+ : { reasoningEffort: response.reasoningEffort }),
1052
+ };
1015
1053
  }
1016
1054
  async turnStart(params) {
1017
1055
  await this.ensureInitialized();
@@ -1146,14 +1184,18 @@ export class CodexAppServerClient {
1146
1184
  setInteractionListener(listener) {
1147
1185
  this.interactionListener = listener;
1148
1186
  }
1149
- /** Observe the underlying app-server connection independently from any one
1150
- * interaction. A disconnected client that never received the duplicate
1151
- * native request still has to count as unavailable during host failover. */
1187
+ /** Observe the initialized connection lifecycle. Registration never reports
1188
+ * disconnected for a client that has not connected yet. */
1152
1189
  setConnectionListener(listener) {
1153
1190
  this.connectionListener = listener;
1154
- listener?.(this.connectionState);
1191
+ if (listener && this.connectionEstablished)
1192
+ listener(this.connectionState);
1155
1193
  }
1156
1194
  setConnectionState(state) {
1195
+ if (state === "connected")
1196
+ this.connectionEstablished = true;
1197
+ if (state === "disconnected" && !this.connectionEstablished)
1198
+ return;
1157
1199
  if (this.connectionState === state)
1158
1200
  return;
1159
1201
  this.connectionState = state;
@@ -1363,7 +1405,17 @@ export function buildTextUserInput(message) {
1363
1405
  export function buildRuntimeUserInput(input) {
1364
1406
  return input.content.map((part) => part.type === "text"
1365
1407
  ? { type: "text", text: part.text, text_elements: [] }
1366
- : { type: "localImage", path: part.path });
1408
+ : part.type === "local_image"
1409
+ ? { type: "localImage", path: part.path }
1410
+ : {
1411
+ type: "text",
1412
+ text: `[[RYNX_FILE_RESOURCE ${JSON.stringify({
1413
+ path: part.path,
1414
+ ...(part.resource.filename ? { filename: part.resource.filename } : {}),
1415
+ mediaType: part.resource.mediaType,
1416
+ })}]]\nInspect this absolute file path with the available file-reading tools before answering.`,
1417
+ text_elements: [],
1418
+ });
1367
1419
  }
1368
1420
  const defaultLogger = {
1369
1421
  log(entry) {
@@ -23,20 +23,37 @@
23
23
  */
24
24
  import type { AgentEvent, UserContentPart } from "@rynx-ai/core";
25
25
  import type { CodexAppServerClient } from "./client.js";
26
- import type { ResumedTurn } from "./protocol.js";
26
+ import type { McpStartupPlan } from "./mcp-startup.js";
27
+ import type { CollaborationModeKind, ReasoningEffort, ResumedTurn } from "./protocol.js";
27
28
  export interface CodexForwarderSink {
28
29
  /** A turn began. `turnId` is codex's turn id, used to derive a stable
29
30
  * `responseId`. Start a fresh normalizer/response. */
30
31
  onTurnStart(turnId?: string): void;
32
+ /** The observer received the provider's authoritative `turn/started` edge.
33
+ * Unlike `onTurnStart`, this is not fired early by turn/start acceptance. */
34
+ onTurnObserved?(turnId?: string): void;
31
35
  /** One mapped event within the current turn. */
32
36
  onEvent(event: AgentEvent): void;
37
+ /** Turn-scoped content arrived while no Provider Turn is active. Attach it
38
+ * to that response without opening a Turn or changing Session status. */
39
+ onTurnContentEvent?(turnId: string | undefined, event: AgentEvent): void;
40
+ /** Provider startup is session status, not a model item. Hosts that already
41
+ * published the response can forward it without synthesizing another start. */
42
+ onStatus?(note: string | undefined, statusKind?: "startup"): void;
33
43
  /** The current turn finished; `usage` is the runtime's raw snapshot if any. */
34
- onTurnEnd(usage?: Record<string, unknown>): void;
44
+ onTurnEnd(usage?: Record<string, unknown>, reason?: "superseded"): void;
45
+ /** The provider confirmed that the active turn was explicitly interrupted. */
46
+ onTurnInterrupted?(usage?: Record<string, unknown>): void;
47
+ /** Resume proved that the newest turn is terminal even though its live edge
48
+ * was missed. This updates session state without replaying historical items. */
49
+ onRecoveredTurnStatus?(status: "idle" | "failed", turnId: string | undefined, error?: Error): void;
35
50
  /** A turn failed on the runtime. */
36
51
  onTurnError(error: Error): void;
37
52
  /** The user's turn text (sourced from codex's `userMessage` item), so a
38
53
  * co-driving TUI's prompt is recorded even though this process never injected it. */
39
54
  onUserMessage?(content: string | UserContentPart[]): void;
55
+ /** Out-of-lifecycle counterpart of `onUserMessage`, scoped when possible. */
56
+ onTurnContentUserMessage?(turnId: string | undefined, content: string | UserContentPart[]): void;
40
57
  /** A managed Core fork also broadcasts `thread/started`, but does not switch
41
58
  * the source TUI. Discard that notification before changing the bound thread. */
42
59
  shouldIgnoreThreadStarted?(threadId: string, forkedFromId?: string): boolean;
@@ -46,6 +63,22 @@ export interface CodexForwarderSink {
46
63
  /** The thread showed activity (a turn/item began, so its rollout now exists).
47
64
  * Fired once; lets a parked `thread/resume` retry (reference implementation's ready signal). */
48
65
  onThreadActive?(): void;
66
+ /** The native TUI or another app-server client changed collaboration mode. */
67
+ onCollaborationModeChanged?(mode: CollaborationModeKind): void;
68
+ /** Full mutable settings reported by the native thread. In particular, a
69
+ * TUI `/model` switch must become the model used by the next mode snapshot. */
70
+ onThreadSettingsChanged?(settings: {
71
+ model?: string;
72
+ reasoningEffort?: ReasoningEffort | null;
73
+ }): void;
74
+ /** Codex's terminal-local Plan picker is not emitted by app-server today.
75
+ * Synthesize it only after a live Plan item and its Turn both complete. */
76
+ onPlanImplementationPrompt?(prompt: CodexPlanImplementationPrompt): void;
77
+ }
78
+ export interface CodexPlanImplementationPrompt {
79
+ threadId: string;
80
+ turnId: string;
81
+ text: string;
49
82
  }
50
83
  export interface CodexSessionForwarderOptions {
51
84
  /** Some Codex-lineage runtimes publish the final item one frame after
@@ -58,6 +91,9 @@ export interface CodexSessionForwarderOptions {
58
91
  assistantMessageGraceMs?: number;
59
92
  /** Surface Traex's provider-capacity queue as a canonical running status. */
60
93
  surfaceQueueStatus?: boolean;
94
+ /** Provider-configured MCP servers. Their startup round is synthesized because
95
+ * Codex currently sends per-server edges only to the thread-owning TUI. */
96
+ mcpStartup?: McpStartupPlan | null;
61
97
  }
62
98
  export declare class CodexSessionForwarder {
63
99
  private readonly client;
@@ -69,9 +105,16 @@ export declare class CodexSessionForwarder {
69
105
  private currentThreadIdValue;
70
106
  private activeSignaled;
71
107
  private completionTimer;
108
+ /** Turn id retained only for late-item dedup while a terminal response waits
109
+ * for its bounded output-ordering grace. It is not an active provider turn. */
110
+ private pendingCompletionTurnId;
72
111
  private assistantMessageTimer;
73
112
  private deferredAssistantMessage;
74
113
  private pendingCompletion;
114
+ private readonly pendingMcpServers;
115
+ private readonly failedMcpServers;
116
+ private mcpStartupTimer;
117
+ private lastMcpStatusNote;
75
118
  /** Completed-item dedup keys already mirrored (live vs resume backfill). Key =
76
119
  * `threadId:turnId:item.id`; anonymous items use a per-(thread,turn) position
77
120
  * counter. Mirrors reference implementation `_completed_item_key` + `synced_item_keys`. */
@@ -79,16 +122,38 @@ export declare class CodexSessionForwarder {
79
122
  /** Per-(thread,turn) position counter for items lacking a stable codex id
80
123
  * (peek-then-advance; advanced only on a successful claim). reference implementation anon path. */
81
124
  private readonly anonCounters;
125
+ private pendingPlanImplementation;
126
+ /** Latest aggregate diff per Turn. Codex republishes the complete diff after
127
+ * every edit; only the terminal snapshot belongs in transcript history. */
128
+ private readonly turnDiffByTurn;
129
+ private replayingBackfill;
82
130
  constructor(client: CodexAppServerClient, sink: CodexForwarderSink, options?: CodexSessionForwarderOptions);
83
131
  /** Begin mirroring. Idempotent. */
84
132
  start(): void;
85
133
  stop(): void;
86
- /** True while a turn is open injection uses `turn/steer` then, else `turn/start`. */
134
+ /** True while the provider owns an active turn, including the short interval
135
+ * between injection acceptance and observer confirmation. */
87
136
  isTurnOpen(): boolean;
88
137
  /** The current turn's codex id (only meaningful while {@link isTurnOpen}). */
89
138
  currentTurnId(): string | null;
139
+ /**
140
+ * Record a turn accepted by the injection connection before the independent
141
+ * observer receives `turn/started`. This closes the read-decide-RPC-write race:
142
+ * another web message arriving in that window must steer this turn, not start
143
+ * a second one.
144
+ */
145
+ noteTurnAccepted(turnId: string): void;
146
+ hasPendingMcpStartup(): boolean;
147
+ /** Mark and return the startup servers cancelled by a web Stop. */
148
+ cancelMcpStartup(): string[];
149
+ /** Diagnostic suffix for an injection failure during Provider startup. */
150
+ mcpStartupDetail(): string | null;
90
151
  /** The bound codex thread id captured from `thread/started` (null until then). */
91
152
  threadId(): string | null;
153
+ /** Seed an already-persisted/resumed thread binding. The bridge retains this
154
+ * state even when app-server does not rebroadcast `thread/started`, so
155
+ * terminal-boundary recovery must know it too. */
156
+ noteThreadBound(threadId: string): void;
92
157
  /**
93
158
  * Replay the backlog turns from a `thread/resume` response as if they were live
94
159
  * `item/completed` notifications — the fresh-thread first-turn backfill. Each
@@ -97,11 +162,21 @@ export declare class CodexSessionForwarder {
97
162
  * not doubled.
98
163
  */
99
164
  replayBackfill(turns: ResumedTurn[]): void;
165
+ /** Suppress our synthesized picker when a future app-server emits the native
166
+ * `plan_implementation` request itself. */
167
+ noteNativePlanImplementationPrompt(turnId?: string): void;
168
+ /** Fail an open response exactly once when its observer or terminal exits. */
169
+ failOpenTurn(error: Error): boolean;
100
170
  private handle;
101
171
  private scheduleCompletion;
102
172
  private refreshCompletionGrace;
103
173
  private flushPendingCompletion;
104
174
  private settle;
175
+ private handleMcpStartupStatus;
176
+ private settleMcpStartup;
177
+ private clearMcpStartupTimer;
178
+ private emitMcpStartupStatus;
179
+ private emitMcpStatus;
105
180
  /** Map + emit one completed codex item, deduped by a TOTAL key and routing the
106
181
  * user echo to {@link CodexForwarderSink.onUserMessage}. Shared by live + backfill. */
107
182
  private processCompletedItem;
@@ -118,4 +193,18 @@ export declare class CodexSessionForwarder {
118
193
  private completedItemKey;
119
194
  private advanceAnonCounter;
120
195
  private ensureTurn;
196
+ private consumeTurnDiff;
197
+ /** Start (or confirm) the app-server's authoritative active turn. A newer
198
+ * start supersedes an older response whose terminal edge arrived late; a
199
+ * pending Traex completion is flushed first so its final item grace remains
200
+ * intact. */
201
+ private beginTurn;
202
+ /** Active-turn clearing contract:
203
+ *
204
+ * - an identified active turn is closed only by the same id;
205
+ * - an id-less boundary cannot close an identified active turn;
206
+ * - with no observed active turn, an identified boundary may recover a
207
+ * missed start only when it carries the currently-bound thread id. */
208
+ private terminalBoundaryMatchesActiveTurn;
209
+ private notificationMatchesCurrentThread;
121
210
  }