@rynx-ai/runtime 0.1.11-beta.5 → 0.1.11-beta.50

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 +460 -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 +1571 -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 +183 -46
  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 +96 -3
  20. package/dist/codex-app-server/forwarder.js +542 -57
  21. package/dist/codex-app-server/mapping.d.ts +3 -6
  22. package/dist/codex-app-server/mapping.js +228 -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 +68 -7
  28. package/dist/codex-app-server/transport.d.ts +2 -2
  29. package/dist/codex-app-server/transport.js +4 -0
  30. package/dist/codex-app-server/ws-channel.d.ts +8 -1
  31. package/dist/codex-app-server/ws-channel.js +113 -28
  32. package/dist/codex-child-env.js +3 -32
  33. package/dist/codex-home.d.ts +35 -3
  34. package/dist/codex-home.js +323 -18
  35. package/dist/codex-session-store.d.ts +23 -0
  36. package/dist/codex-session-store.js +21 -0
  37. package/dist/host.d.ts +106 -48
  38. package/dist/host.js +2024 -640
  39. package/dist/index.d.ts +3 -3
  40. package/dist/index.js +1 -1
  41. package/dist/input-resources.d.ts +4 -0
  42. package/dist/input-resources.js +21 -5
  43. package/dist/models-catalog.js +12 -3
  44. package/dist/runner/child.d.ts +105 -28
  45. package/dist/runner/child.js +1625 -100
  46. package/dist/runner/manager.d.ts +114 -46
  47. package/dist/runner/manager.js +1523 -446
  48. package/dist/runner/protocol.d.ts +212 -24
  49. package/dist/runner/protocol.js +5 -0
  50. package/dist/runner/startup-policy.d.ts +7 -0
  51. package/dist/runner/startup-policy.js +10 -0
  52. package/dist/runner/transport.d.ts +18 -2
  53. package/dist/runner/transport.js +82 -3
  54. package/dist/runner-main.js +8 -3
  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,37 @@ export function subagentTranscriptPath(parentTranscriptPath, agentId) {
22
23
  const dir = parentTranscriptPath.replace(/\.jsonl$/, "");
23
24
  return join(dir, "subagents", `agent-${agentId}.jsonl`);
24
25
  }
25
- function stringifyToolContent(content) {
26
- if (typeof content === "string")
27
- return content;
28
- if (Array.isArray(content)) {
29
- return content
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, preview) {
36
+ if (Array.isArray(value))
37
+ return value.map((part) => stripInlineImageData(part, preview));
38
+ if (!isObject(value))
39
+ return value;
40
+ const source = isObject(value.source) ? value.source : undefined;
41
+ if (value.type === "image" && source) {
42
+ return {
43
+ type: "text",
44
+ text: value === preview
45
+ ? `[${source.media_type} image attached]`
46
+ : strippedImagePlaceholder(source),
47
+ };
48
+ }
49
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, stripInlineImageData(nested, preview)]));
50
+ }
51
+ function stringifyToolContent(content, preview) {
52
+ const stripped = stripInlineImageData(content, preview);
53
+ if (typeof stripped === "string")
54
+ return stripped;
55
+ if (Array.isArray(stripped)) {
56
+ return stripped
30
57
  .map((part) => part && typeof part === "object" && "text" in part
31
58
  ? String(part.text ?? "")
32
59
  : typeof part === "string"
@@ -34,22 +61,45 @@ function stringifyToolContent(content) {
34
61
  : JSON.stringify(part))
35
62
  .join("");
36
63
  }
37
- return content == null ? "" : JSON.stringify(content);
64
+ return stripped == null ? "" : JSON.stringify(stripped);
38
65
  }
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;
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}` };
46
81
  }
47
82
  }
48
- return name ?? "tool";
83
+ return undefined;
49
84
  }
50
85
  const BASH_INPUT_RE = /<bash-input>([\s\S]*?)<\/bash-input>/;
51
86
  const BASH_STDOUT_RE = /<bash-stdout>([\s\S]*?)<\/bash-stdout>/;
52
87
  const BASH_STDERR_RE = /<bash-stderr>([\s\S]*?)<\/bash-stderr>/;
88
+ /** Parse either half of Claude's shell-mode record. Newer and older Claude
89
+ * builds may split `<bash-input>` from the later stdout/stderr record. */
90
+ export function parseTerminalCommandFragments(content) {
91
+ const input = BASH_INPUT_RE.exec(content);
92
+ const stdout = BASH_STDOUT_RE.exec(content);
93
+ const stderr = BASH_STDERR_RE.exec(content);
94
+ if (!input && !stdout && !stderr)
95
+ return undefined;
96
+ return {
97
+ ...(input ? { command: input[1].trim() } : {}),
98
+ ...(stdout ? { stdout: stdout[1] } : {}),
99
+ ...(stderr ? { stderr: stderr[1] } : {}),
100
+ hasOutput: Boolean(stdout || stderr),
101
+ };
102
+ }
53
103
  /**
54
104
  * Parse a claude local-command (`!` bash mode) user record's string content into
55
105
  * a {@link TerminalCommandData}. Claude records the command and its captured
@@ -59,16 +109,14 @@ const BASH_STDERR_RE = /<bash-stderr>([\s\S]*?)<\/bash-stderr>/;
59
109
  * `<system-reminder>`) are bookkeeping, not terminal commands.
60
110
  */
61
111
  export function parseTerminalCommand(content) {
62
- const input = BASH_INPUT_RE.exec(content);
63
- if (!input)
112
+ const fragments = parseTerminalCommandFragments(content);
113
+ if (!fragments || fragments.command === undefined)
64
114
  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;
115
+ const data = { command: fragments.command };
116
+ if (fragments.stdout)
117
+ data.stdout = fragments.stdout;
118
+ if (fragments.stderr)
119
+ data.stderr = fragments.stderr;
72
120
  return data;
73
121
  }
74
122
  /**
@@ -103,26 +151,42 @@ export function parseTranscriptRecord(record, opts) {
103
151
  if (rec.isSidechain && !parent)
104
152
  return []; // sub-agent turns are replayed separately
105
153
  const message = rec.message;
106
- if (!message || !Array.isArray(message.content))
154
+ if (!message)
107
155
  return [];
108
156
  const out = [];
109
157
  if (rec.type === "assistant" && message.role === "assistant") {
158
+ const content = typeof message.content === "string"
159
+ ? [{ type: "text", text: message.content }]
160
+ : message.content;
161
+ if (!Array.isArray(content))
162
+ return [];
110
163
  // Claude writes this zero-token synthetic filler after an accidental empty
111
164
  // submit. It is transcript bookkeeping, not an assistant response. Filter
112
165
  // by provider provenance and exact shape here, before normalization loses
113
166
  // `model: "<synthetic>"`; a UI string filter could hide legitimate output.
114
167
  if (!rec.isApiErrorMessage &&
115
168
  message.model === "<synthetic>" &&
116
- message.content.length === 1 &&
117
- message.content[0]?.type === "text" &&
118
- message.content[0].text === "No response requested.") {
169
+ content.length === 1 &&
170
+ content[0]?.type === "text" &&
171
+ content[0].text === "No response requested.") {
119
172
  return [];
120
173
  }
121
174
  const itemId = message.id;
122
- const texts = [];
123
- for (const block of message.content) {
175
+ const textBlockCount = content.filter((block) => block.type === "text" && block.text).length;
176
+ for (const [blockIndex, block] of content.entries()) {
124
177
  if (block.type === "text" && block.text) {
125
- texts.push(block.text);
178
+ // Preserve Claude's block order. Keep a single text block's
179
+ // native message id for MessageDisplay correlation; only derive an id
180
+ // when one record contains multiple independently ordered text blocks.
181
+ const textItemId = itemId && textBlockCount > 1
182
+ ? `${itemId}:text:${blockIndex}`
183
+ : itemId;
184
+ out.push({
185
+ type: "message_completed",
186
+ text: block.text,
187
+ ...(textItemId ? { itemId: textItemId } : {}),
188
+ ...parentTag,
189
+ });
126
190
  }
127
191
  else if (block.type === "thinking" && block.thinking) {
128
192
  out.push({ type: "reasoning_completed", summary: [block.thinking], ...(itemId ? { itemId } : {}), ...parentTag });
@@ -132,31 +196,35 @@ export function parseTranscriptRecord(record, opts) {
132
196
  type: "tool",
133
197
  event: "on_tool_start",
134
198
  name: block.name,
135
- input: { ...(isObject(block.input) ? block.input : {}), id: block.id, command: toolLabel(block.name, block.input) },
136
- data: { id: block.id },
199
+ input: { ...(isObject(block.input) ? block.input : {}), id: block.id },
200
+ // A transcript tool_use is already a completed Provider record. Live
201
+ // runtimes still omit this marker and remain in_progress until their
202
+ // completion event arrives.
203
+ data: { id: block.id, itemStatus: "completed" },
137
204
  ...parentTag,
138
205
  });
139
206
  }
140
207
  }
141
- if (texts.length) {
142
- out.push({ type: "message_completed", text: texts.join(""), ...(itemId ? { itemId } : {}), ...parentTag });
143
- }
144
208
  return out;
145
209
  }
146
210
  if (rec.type === "user" && message.role === "user") {
211
+ if (!Array.isArray(message.content))
212
+ return [];
147
213
  for (const block of message.content) {
148
214
  if (block.type === "tool_result") {
215
+ const preview = block.is_error ? undefined : inlineImagePreview(block.content);
149
216
  out.push({
150
217
  type: "tool",
151
218
  event: "on_tool_end",
152
219
  output: {
153
220
  id: block.tool_use_id,
154
221
  status: block.is_error ? "failed" : "completed",
155
- aggregatedOutput: stringifyToolContent(block.content),
222
+ aggregatedOutput: stringifyToolContent(block.content, preview?.block),
156
223
  exitCode: null,
224
+ ...(preview ? { generatedImage: { result: preview.result } } : {}),
157
225
  },
158
226
  ...(block.is_error ? { error: "tool_error" } : {}),
159
- data: { tool_use_id: block.tool_use_id },
227
+ data: { tool_use_id: block.tool_use_id, itemStatus: "completed" },
160
228
  ...parentTag,
161
229
  });
162
230
  }
@@ -169,13 +237,14 @@ function isObject(value) {
169
237
  }
170
238
  /**
171
239
  * 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
240
+ * `forkedFrom: { sessionId }` marker on copied records pointing at the source
173
241
  * session (reference implementation's `transcript_has_forked_from_marker`). Used to distinguish a
174
242
  * 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.
243
+ * The marker must belong to the announced target and expected source; sample
244
+ * the first and last 200 records because long copied histories can place it at
245
+ * either edge.
177
246
  */
178
- export function transcriptHasForkedFrom(path, currentSessionId) {
247
+ export function transcriptHasForkedFrom(path, claudeSessionId, sourceClaudeSessionId) {
179
248
  let text;
180
249
  try {
181
250
  text = readFileSync(path, "utf8");
@@ -183,17 +252,23 @@ export function transcriptHasForkedFrom(path, currentSessionId) {
183
252
  catch {
184
253
  return false;
185
254
  }
186
- let scanned = 0;
187
- for (const line of text.split("\n")) {
188
- if (++scanned > 200)
189
- break;
255
+ const lines = text.split("\n");
256
+ const sampled = lines.length <= 400
257
+ ? lines
258
+ : [...lines.slice(0, 200), ...lines.slice(-200)];
259
+ for (const line of sampled) {
190
260
  const trimmed = line.trim();
191
261
  if (!trimmed || !trimmed.includes("forkedFrom"))
192
262
  continue;
193
263
  try {
194
264
  const rec = JSON.parse(trimmed);
265
+ if (rec.sessionId !== claudeSessionId)
266
+ continue;
195
267
  const from = rec.forkedFrom?.sessionId;
196
- if (typeof from === "string" && from && from !== currentSessionId)
268
+ if (typeof from === "string" &&
269
+ from &&
270
+ from !== claudeSessionId &&
271
+ (sourceClaudeSessionId === undefined || from === sourceClaudeSessionId))
197
272
  return true;
198
273
  }
199
274
  catch {
@@ -202,6 +277,68 @@ export function transcriptHasForkedFrom(path, currentSessionId) {
202
277
  }
203
278
  return false;
204
279
  }
280
+ const RECENT_LOCAL_COMMAND_LINE_LIMIT = 200;
281
+ const RECENT_LOCAL_COMMAND_WINDOW_MS = 10_000;
282
+ /** Claude versions without a copied-record marker still persist `/fork` and
283
+ * `/branch` as a recent top-level local command. Match only the new native
284
+ * Session and the hook's narrow time window. */
285
+ export function transcriptHasRecentLocalCommand(path, claudeSessionId, recordedAtMs, commandNames = new Set(["/fork", "/branch"])) {
286
+ let text;
287
+ try {
288
+ text = readFileSync(path, "utf8");
289
+ }
290
+ catch {
291
+ return false;
292
+ }
293
+ for (const line of text.split("\n").slice(-RECENT_LOCAL_COMMAND_LINE_LIMIT)) {
294
+ const trimmed = line.trim();
295
+ if (!trimmed)
296
+ continue;
297
+ try {
298
+ const rec = JSON.parse(trimmed);
299
+ if (rec.sessionId !== claudeSessionId || rec.subtype !== "local_command")
300
+ continue;
301
+ const timestamp = transcriptTimestampMs(rec.timestamp);
302
+ if (timestamp === undefined ||
303
+ Math.abs(timestamp - recordedAtMs) > RECENT_LOCAL_COMMAND_WINDOW_MS ||
304
+ typeof rec.content !== "string")
305
+ continue;
306
+ const command = /<command-name>([\s\S]*?)<\/command-name>/
307
+ .exec(rec.content)?.[1]?.trim();
308
+ if (command && commandNames.has(command))
309
+ return true;
310
+ }
311
+ catch {
312
+ // skip malformed
313
+ }
314
+ }
315
+ return false;
316
+ }
317
+ /** Wait briefly for Claude to flush either fork signal after SessionStart.
318
+ * The observer hook calls this before recording the edge, allowing a
319
+ * one-second late-marker window without delaying ordinary transcript polling. */
320
+ export async function waitForTranscriptForkSignal(path, claudeSessionId, sourceClaudeSessionId, recordedAtMs, options = {}) {
321
+ const timeoutMs = Math.max(0, options.timeoutMs ?? 1_000);
322
+ const pollMs = Math.max(1, options.pollMs ?? 50);
323
+ const deadline = Date.now() + timeoutMs;
324
+ do {
325
+ if (transcriptHasForkedFrom(path, claudeSessionId, sourceClaudeSessionId) ||
326
+ transcriptHasRecentLocalCommand(path, claudeSessionId, recordedAtMs))
327
+ return true;
328
+ if (Date.now() >= deadline)
329
+ return false;
330
+ await sleep(Math.min(pollMs, Math.max(1, deadline - Date.now())));
331
+ } while (true);
332
+ }
333
+ function transcriptTimestampMs(value) {
334
+ if (typeof value === "number" && Number.isFinite(value)) {
335
+ return value < 10_000_000_000 ? value * 1_000 : value;
336
+ }
337
+ if (typeof value !== "string" || !value)
338
+ return undefined;
339
+ const parsed = Date.parse(value);
340
+ return Number.isFinite(parsed) ? parsed : undefined;
341
+ }
205
342
  /**
206
343
  * Read a sub-agent (Task) transcript in full and map it to {@link AgentEvent}s
207
344
  * 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) {