@rynx-ai/runtime 0.1.9 → 0.1.10-beta.3

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.
@@ -121,6 +121,17 @@ export function parseTranscriptRecord(record, opts) {
121
121
  return [];
122
122
  const out = [];
123
123
  if (rec.type === "assistant" && message.role === "assistant") {
124
+ // Claude writes this zero-token synthetic filler after an accidental empty
125
+ // submit. It is transcript bookkeeping, not an assistant response. Filter
126
+ // by provider provenance and exact shape here, before normalization loses
127
+ // `model: "<synthetic>"`; a UI string filter could hide legitimate output.
128
+ if (!rec.isApiErrorMessage &&
129
+ message.model === "<synthetic>" &&
130
+ message.content.length === 1 &&
131
+ message.content[0]?.type === "text" &&
132
+ message.content[0].text === "No response requested.") {
133
+ return [];
134
+ }
124
135
  const itemId = message.id;
125
136
  const texts = [];
126
137
  for (const block of message.content) {
@@ -1,4 +1,5 @@
1
- import type { SessionItem } from "@rynx-ai/core";
1
+ import { type SessionItem } from "@rynx-ai/core";
2
+ import { type CodexLineageRuntime } from "../codex-home.js";
2
3
  export interface SynthesizeRolloutOptions {
3
4
  threadId: string;
4
5
  cwd: string;
@@ -8,6 +9,10 @@ export interface SynthesizeRolloutOptions {
8
9
  sessionId?: string;
9
10
  /** Override the private CODEX_HOME (tests, or a pre-resolved home). */
10
11
  codexHome?: string;
12
+ /** Codex-lineage runtime whose private home/session layout is being written. */
13
+ runtime?: CodexLineageRuntime;
14
+ /** Runtime-neutral alias for `codexHome`; preferred for Traex callers. */
15
+ runtimeHome?: string;
11
16
  /** codex CLI version for `session_meta` (informational for ≥0.133; presence
12
17
  * matters). */
13
18
  cliVersion?: string;
@@ -22,8 +27,8 @@ interface RolloutRecord {
22
27
  type: "session_meta" | "turn_context" | "response_item" | "event_msg";
23
28
  payload: Record<string, unknown>;
24
29
  }
25
- /** Locate an existing rollout for `threadId` under `<codexHome>/sessions/**`. */
26
- export declare function findCodexRollout(codexHome: string, threadId: string): string | null;
30
+ /** Locate an existing rollout for `threadId` under the runtime's sessions root. */
31
+ export declare function findCodexRollout(runtimeHome: string, threadId: string, runtime?: CodexLineageRuntime): string | null;
27
32
  /** Build the ordered rollout records (session_meta first, then per-turn
28
33
  * turn_context + per-item response_item + per-message event_msg). */
29
34
  export declare function buildRolloutRecords(opts: SynthesizeRolloutOptions): RolloutRecord[];
@@ -13,14 +13,19 @@
13
13
  * (codex ≥0.136 renders an empty thread without them). `turn_context` groups items
14
14
  * into turns.
15
15
  */
16
+ import { createHash } from "node:crypto";
16
17
  import { copyFileSync, mkdirSync, readdirSync, renameSync, writeFileSync } from "node:fs";
17
18
  import { join, relative } from "node:path";
18
- import { codexHomePath, legacyCodexHomePath } from "../codex-home.js";
19
+ import { getRuntimeProfile, resolveRuntimeHome, rynxHome, } from "@rynx-ai/core";
20
+ import { legacyCodexHomePath, runtimeHomePath, } from "../codex-home.js";
19
21
  /** codex validates the thread id straight into a filename + resume arg. */
20
22
  const THREAD_ID_RE = /^[0-9a-fA-F-]+$/;
21
- /** Locate an existing rollout for `threadId` under `<codexHome>/sessions/**`. */
22
- export function findCodexRollout(codexHome, threadId) {
23
- const root = join(codexHome, "sessions");
23
+ function sessionsRoot(runtimeHome, runtime) {
24
+ return join(runtimeHome, ...getRuntimeProfile(runtime).sessionsSubpath);
25
+ }
26
+ /** Locate an existing rollout for `threadId` under the runtime's sessions root. */
27
+ export function findCodexRollout(runtimeHome, threadId, runtime = "codex") {
28
+ const root = sessionsRoot(runtimeHome, runtime);
24
29
  const suffix = `-${threadId}.jsonl`;
25
30
  const stack = [root];
26
31
  while (stack.length) {
@@ -49,18 +54,21 @@ export function findCodexRollout(codexHome, threadId) {
49
54
  * expects) so the per-session app-server can resume it. Best-effort; gated by
50
55
  * `RYNX_CODEX_HOME_LEGACY_FALLBACK` (default on; set `0`/`false` to disable).
51
56
  */
52
- function adoptLegacyRollout(codexHome, threadId) {
53
- const flag = process.env.RYNX_CODEX_HOME_LEGACY_FALLBACK?.trim().toLowerCase();
57
+ function adoptLegacyRollout(runtimeHome, threadId, runtime) {
58
+ const flag = (process.env.RYNX_RUNTIME_HOME_LEGACY_FALLBACK
59
+ ?? process.env.RYNX_CODEX_HOME_LEGACY_FALLBACK)?.trim().toLowerCase();
54
60
  if (flag === "0" || flag === "false")
55
61
  return false;
56
- const legacy = legacyCodexHomePath();
57
- if (legacy === codexHome)
62
+ const legacy = runtime === "codex"
63
+ ? legacyCodexHomePath()
64
+ : resolveRuntimeHome(getRuntimeProfile(runtime));
65
+ if (legacy === runtimeHome)
58
66
  return false;
59
- const src = findCodexRollout(legacy, threadId);
67
+ const src = findCodexRollout(legacy, threadId, runtime);
60
68
  if (!src)
61
69
  return false;
62
- const rel = relative(join(legacy, "sessions"), src);
63
- const dst = join(codexHome, "sessions", rel);
70
+ const rel = relative(sessionsRoot(legacy, runtime), src);
71
+ const dst = join(sessionsRoot(runtimeHome, runtime), rel);
64
72
  try {
65
73
  mkdirSync(join(dst, ".."), { recursive: true, mode: 0o700 });
66
74
  copyFileSync(src, dst);
@@ -79,7 +87,10 @@ function turnIdOf(responseId) {
79
87
  return responseId.startsWith("resp_codex_") ? responseId.slice("resp_codex_".length) : responseId;
80
88
  }
81
89
  function textOf(item) {
82
- return item.data.content.map((p) => p.text).join("");
90
+ return item.data.content
91
+ .filter((part) => "text" in part)
92
+ .map((part) => part.text)
93
+ .join("");
83
94
  }
84
95
  /** Convert one canonical {@link SessionItem} to its codex `response_item` payload,
85
96
  * or null for types codex doesn't carry (reasoning/terminal_command/error). */
@@ -87,9 +98,9 @@ function responseItemPayload(item) {
87
98
  switch (item.type) {
88
99
  case "message": {
89
100
  const apiType = item.data.role === "assistant" ? "output_text" : "input_text";
90
- const content = item.data.content
91
- .filter((p) => p.text)
92
- .map((p) => ({ type: apiType, text: p.text }));
101
+ const content = item.data.content.flatMap((part) => "text" in part && part.text
102
+ ? [{ type: apiType, text: part.text }]
103
+ : []);
93
104
  if (content.length === 0)
94
105
  return null;
95
106
  return { type: "message", role: item.data.role, content };
@@ -108,23 +119,41 @@ function responseItemPayload(item) {
108
119
  }
109
120
  }
110
121
  /** The `event_msg` mirror for a message (required for a VISIBLE turn on codex ≥0.136). */
111
- function eventMsgPayload(item) {
122
+ function eventMsgPayload(item, sessionId) {
112
123
  const message = textOf(item).trim();
113
- if (!message)
124
+ const localImages = item.data.role === "user" && sessionId
125
+ ? item.data.content.flatMap((part) => part.type === "input_image" ? [resourcePath(sessionId, part)] : [])
126
+ : [];
127
+ if (!message && localImages.length === 0)
114
128
  return null;
115
129
  if (item.data.role === "user") {
116
- return { type: "user_message", message, images: [], local_images: [], text_elements: [] };
130
+ return {
131
+ type: "user_message",
132
+ message,
133
+ images: [],
134
+ local_images: localImages,
135
+ text_elements: [],
136
+ };
117
137
  }
118
138
  if (item.data.role === "assistant") {
119
139
  return { type: "agent_message", message, phase: "final_answer", memory_citation: null };
120
140
  }
121
141
  return null;
122
142
  }
143
+ function resourcePath(sessionId, part) {
144
+ const sessionKey = createHash("sha256").update(sessionId).digest("hex").slice(0, 32);
145
+ const extension = part.mediaType === "image/png"
146
+ ? "png"
147
+ : part.mediaType === "image/jpeg"
148
+ ? "jpg"
149
+ : "webp";
150
+ return join(rynxHome(), "resources", sessionKey, `${part.resourceId}.${extension}`);
151
+ }
123
152
  /** Build the ordered rollout records (session_meta first, then per-turn
124
153
  * turn_context + per-item response_item + per-message event_msg). */
125
154
  export function buildRolloutRecords(opts) {
126
155
  const now = opts.now ?? (() => Date.now());
127
- const modelProvider = opts.modelProvider ?? "openai";
156
+ const modelProvider = opts.modelProvider ?? (opts.runtime === "traex" ? "trae" : "openai");
128
157
  const cliVersion = opts.cliVersion ?? "0.0.0";
129
158
  const metaTs = iso(opts.items[0]?.createdAt ?? now());
130
159
  const records = [
@@ -147,7 +176,10 @@ export function buildRolloutRecords(opts) {
147
176
  const seenTurns = new Set();
148
177
  for (const item of opts.items) {
149
178
  const payload = responseItemPayload(item);
150
- if (!payload)
179
+ const eventPayload = item.type === "message"
180
+ ? eventMsgPayload(item, opts.sessionId)
181
+ : null;
182
+ if (!payload && !eventPayload)
151
183
  continue;
152
184
  const ts = iso(item.createdAt);
153
185
  const turnId = turnIdOf(item.responseId);
@@ -159,12 +191,10 @@ export function buildRolloutRecords(opts) {
159
191
  payload: { turn_id: turnId, cwd: opts.cwd, approval_policy: "on-request" },
160
192
  });
161
193
  }
162
- records.push({ timestamp: ts, type: "response_item", payload });
163
- if (item.type === "message") {
164
- const evt = eventMsgPayload(item);
165
- if (evt)
166
- records.push({ timestamp: ts, type: "event_msg", payload: evt });
167
- }
194
+ if (payload)
195
+ records.push({ timestamp: ts, type: "response_item", payload });
196
+ if (eventPayload)
197
+ records.push({ timestamp: ts, type: "event_msg", payload: eventPayload });
168
198
  }
169
199
  return records;
170
200
  }
@@ -177,14 +207,17 @@ export function buildRolloutRecords(opts) {
177
207
  export function ensureCodexResumeRollout(opts) {
178
208
  if (!THREAD_ID_RE.test(opts.threadId))
179
209
  return "skipped";
180
- const codexHome = opts.codexHome ?? (opts.sessionId ? codexHomePath(opts.sessionId) : undefined);
181
- if (!codexHome)
210
+ const runtime = opts.runtime ?? "codex";
211
+ const runtimeHome = opts.runtimeHome
212
+ ?? opts.codexHome
213
+ ?? (opts.sessionId ? runtimeHomePath(opts.sessionId, runtime) : undefined);
214
+ if (!runtimeHome)
182
215
  return "skipped"; // no session context → can't locate the home
183
- if (findCodexRollout(codexHome, opts.threadId))
216
+ if (findCodexRollout(runtimeHome, opts.threadId, runtime))
184
217
  return "exists";
185
218
  // Back-compat: a pre-per-session rollout may live under the OLD shared home. Copy
186
219
  // it forward into this session's home so the per-session app-server can resume it.
187
- if (adoptLegacyRollout(codexHome, opts.threadId))
220
+ if (adoptLegacyRollout(runtimeHome, opts.threadId, runtime))
188
221
  return "exists";
189
222
  const records = buildRolloutRecords(opts);
190
223
  // Nothing but the session_meta header → no history to carry; skip (a fresh
@@ -197,7 +230,7 @@ export function ensureCodexResumeRollout(opts) {
197
230
  const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
198
231
  const dd = String(d.getUTCDate()).padStart(2, "0");
199
232
  const stamp = iso(d.getTime()).slice(0, 19).replace(/:/g, "-"); // YYYY-MM-DDTHH-MM-SS
200
- const dir = join(codexHome, "sessions", yyyy, mm, dd);
233
+ const dir = join(sessionsRoot(runtimeHome, runtime), yyyy, mm, dd);
201
234
  const target = join(dir, `rollout-${stamp}-${opts.threadId}.jsonl`);
202
235
  const tmp = `${target}.tmp`;
203
236
  try {
@@ -1,4 +1,4 @@
1
- import type { SessionInteractionResolution } from "@rynx-ai/core";
1
+ import type { RuntimeUserInput, SessionInteractionResolution } from "@rynx-ai/core";
2
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";
3
3
  import { CodexAppServerTransport, type CodexAppServerProcessSpawner, type RpcChannel, type TransportLogger } from "./transport.js";
4
4
  import type { ResolveInteractionResult, RuntimeInteractionListener } from "../interactions.js";
@@ -121,4 +121,5 @@ export interface BuildSandboxPolicyOptions {
121
121
  */
122
122
  export declare function buildSandboxPolicy(options: BuildSandboxPolicyOptions): Record<string, unknown>;
123
123
  export declare function buildTextUserInput(message: string): UserInput[];
124
+ export declare function buildRuntimeUserInput(input: RuntimeUserInput): UserInput[];
124
125
  export type { AskForApproval };
@@ -549,6 +549,7 @@ function buildCodexInteraction(method, params, nativeRequestId) {
549
549
  label: question.question,
550
550
  description: question.header || undefined,
551
551
  required: true,
552
+ ...(question.multiSelect ? { multiple: true } : {}),
552
553
  allowOther: question.isOther,
553
554
  options: options.map((option) => ({
554
555
  value: option.label,
@@ -1359,6 +1360,11 @@ export function buildSandboxPolicy(options) {
1359
1360
  export function buildTextUserInput(message) {
1360
1361
  return [{ type: "text", text: message, text_elements: [] }];
1361
1362
  }
1363
+ export function buildRuntimeUserInput(input) {
1364
+ return input.content.map((part) => part.type === "text"
1365
+ ? { type: "text", text: part.text, text_elements: [] }
1366
+ : { type: "localImage", path: part.path });
1367
+ }
1362
1368
  const defaultLogger = {
1363
1369
  log(entry) {
1364
1370
  console.log(JSON.stringify({
@@ -21,7 +21,7 @@
21
21
  * {@link CodexSessionForwarder.replayBackfill}. Backfill and the live stream are
22
22
  * de-duplicated by codex item id.
23
23
  */
24
- import type { AgentEvent } from "@rynx-ai/core";
24
+ import type { AgentEvent, UserContentPart } from "@rynx-ai/core";
25
25
  import type { CodexAppServerClient } from "./client.js";
26
26
  import type { ResumedTurn } from "./protocol.js";
27
27
  export interface CodexForwarderSink {
@@ -36,7 +36,7 @@ export interface CodexForwarderSink {
36
36
  onTurnError(error: Error): void;
37
37
  /** The user's turn text (sourced from codex's `userMessage` item), so a
38
38
  * co-driving TUI's prompt is recorded even though this process never injected it. */
39
- onUserMessage?(text: string): void;
39
+ onUserMessage?(content: string | UserContentPart[]): void;
40
40
  /** A thread was announced on the app-server (a `--remote` TUI creating a thread,
41
41
  * or a resume). The host persists the id + starts the subscribe loop. */
42
42
  onThreadStarted?(threadId: string): void;
@@ -44,14 +44,31 @@ export interface CodexForwarderSink {
44
44
  * Fired once; lets a parked `thread/resume` retry (reference implementation's ready signal). */
45
45
  onThreadActive?(): void;
46
46
  }
47
+ export interface CodexSessionForwarderOptions {
48
+ /** Some Codex-lineage runtimes publish the final item one frame after
49
+ * `turn/completed`. Keep the Turn open briefly so that item cannot create a
50
+ * second, permanently-running response. */
51
+ turnCompletionGraceMs?: number;
52
+ /** Some runtimes complete the final assistant message before publishing the
53
+ * preceding reasoning item. Briefly hold that message so canonical item order
54
+ * remains reasoning -> final answer. */
55
+ assistantMessageGraceMs?: number;
56
+ /** Surface Traex's provider-capacity queue as a canonical running status. */
57
+ surfaceQueueStatus?: boolean;
58
+ }
47
59
  export declare class CodexSessionForwarder {
48
60
  private readonly client;
49
61
  private readonly sink;
62
+ private readonly options;
50
63
  private unsubscribe;
51
64
  private turnOpen;
52
65
  private currentTurnIdValue;
53
66
  private currentThreadIdValue;
54
67
  private activeSignaled;
68
+ private completionTimer;
69
+ private assistantMessageTimer;
70
+ private deferredAssistantMessage;
71
+ private pendingCompletion;
55
72
  /** Completed-item dedup keys already mirrored (live vs resume backfill). Key =
56
73
  * `threadId:turnId:item.id`; anonymous items use a per-(thread,turn) position
57
74
  * counter. Mirrors reference implementation `_completed_item_key` + `synced_item_keys`. */
@@ -59,7 +76,7 @@ export declare class CodexSessionForwarder {
59
76
  /** Per-(thread,turn) position counter for items lacking a stable codex id
60
77
  * (peek-then-advance; advanced only on a successful claim). reference implementation anon path. */
61
78
  private readonly anonCounters;
62
- constructor(client: CodexAppServerClient, sink: CodexForwarderSink);
79
+ constructor(client: CodexAppServerClient, sink: CodexForwarderSink, options?: CodexSessionForwarderOptions);
63
80
  /** Begin mirroring. Idempotent. */
64
81
  start(): void;
65
82
  stop(): void;
@@ -78,9 +95,18 @@ export declare class CodexSessionForwarder {
78
95
  */
79
96
  replayBackfill(turns: ResumedTurn[]): void;
80
97
  private handle;
98
+ private scheduleCompletion;
99
+ private refreshCompletionGrace;
100
+ private flushPendingCompletion;
101
+ private settle;
81
102
  /** Map + emit one completed codex item, deduped by a TOTAL key and routing the
82
103
  * user echo to {@link CodexForwarderSink.onUserMessage}. Shared by live + backfill. */
83
104
  private processCompletedItem;
105
+ private claimCompletedItem;
106
+ private emitCompletedItem;
107
+ private shouldDeferAssistantMessage;
108
+ private deferAssistantMessage;
109
+ private flushDeferredAssistantMessage;
84
110
  /** Build a total dedup key for one completed item. Stable-id items use
85
111
  * `threadId:turnId:item.id` — identical across replay + live, so the second
86
112
  * delivery is dropped. Items without a codex id fall back to a per-(thread,turn)
@@ -1,3 +1,4 @@
1
+ import { codexUserContent } from "../input-resources.js";
1
2
  import { mapCodexItem, mapCodexNotification } from "./mapping.js";
2
3
  function threadIdFrom(params) {
3
4
  const p = params;
@@ -7,16 +8,17 @@ function turnIdFrom(params) {
7
8
  const p = params;
8
9
  return p?.turnId ?? p?.turn?.id;
9
10
  }
10
- function userMessageText(item) {
11
+ function userMessageContent(item) {
11
12
  if (item.type !== "userMessage")
12
13
  return undefined;
13
- const content = item.content ?? [];
14
- const text = content
15
- .filter((c) => c.type === "text" && typeof c.text === "string")
16
- .map((c) => c.text)
17
- .join("")
18
- .trim();
19
- return text || undefined;
14
+ const parts = codexUserContent(item.content);
15
+ if (parts.length === 0)
16
+ return undefined;
17
+ if (parts.every((part) => part.type === "input_text")) {
18
+ const text = parts.map((part) => part.text).join("").trim();
19
+ return text || undefined;
20
+ }
21
+ return parts;
20
22
  }
21
23
  /** Whether a notification implies the thread is now active (its first turn has
22
24
  * begun, so the rollout exists). Mirrors reference implementation's `_event_indicates_thread_active`. */
@@ -32,11 +34,16 @@ function indicatesActive(method, params) {
32
34
  export class CodexSessionForwarder {
33
35
  client;
34
36
  sink;
37
+ options;
35
38
  unsubscribe = null;
36
39
  turnOpen = false;
37
40
  currentTurnIdValue = null;
38
41
  currentThreadIdValue = null;
39
42
  activeSignaled = false;
43
+ completionTimer = null;
44
+ assistantMessageTimer = null;
45
+ deferredAssistantMessage = null;
46
+ pendingCompletion = null;
40
47
  /** Completed-item dedup keys already mirrored (live vs resume backfill). Key =
41
48
  * `threadId:turnId:item.id`; anonymous items use a per-(thread,turn) position
42
49
  * counter. Mirrors reference implementation `_completed_item_key` + `synced_item_keys`. */
@@ -44,9 +51,10 @@ export class CodexSessionForwarder {
44
51
  /** Per-(thread,turn) position counter for items lacking a stable codex id
45
52
  * (peek-then-advance; advanced only on a successful claim). reference implementation anon path. */
46
53
  anonCounters = new Map();
47
- constructor(client, sink) {
54
+ constructor(client, sink, options = {}) {
48
55
  this.client = client;
49
56
  this.sink = sink;
57
+ this.options = options;
50
58
  }
51
59
  /** Begin mirroring. Idempotent. */
52
60
  start() {
@@ -59,6 +67,8 @@ export class CodexSessionForwarder {
59
67
  stop() {
60
68
  this.unsubscribe?.();
61
69
  this.unsubscribe = null;
70
+ this.flushPendingCompletion();
71
+ this.flushDeferredAssistantMessage();
62
72
  if (this.turnOpen) {
63
73
  this.turnOpen = false;
64
74
  this.sink.onTurnEnd();
@@ -116,58 +126,160 @@ export class CodexSessionForwarder {
116
126
  this.activeSignaled = true;
117
127
  this.sink.onThreadActive?.();
118
128
  }
129
+ // `queue/status` is a Traex extension. Keep the shared Codex forwarder
130
+ // behavior unchanged unless the Traex host explicitly opts in.
131
+ if (method === "queue/status" && !this.options.surfaceQueueStatus)
132
+ return;
119
133
  const carriedTurnId = turnIdFrom(params);
120
- if (carriedTurnId)
121
- this.currentTurnIdValue = carriedTurnId;
122
134
  if (method === "turn/started") {
135
+ this.flushPendingCompletion();
136
+ this.flushDeferredAssistantMessage();
137
+ if (carriedTurnId)
138
+ this.currentTurnIdValue = carriedTurnId;
123
139
  this.ensureTurn();
124
140
  return;
125
141
  }
142
+ if (carriedTurnId)
143
+ this.currentTurnIdValue = carriedTurnId;
126
144
  // Completed items (user echo, assistant, tool, reasoning) go through the
127
145
  // deduped path so a resume backfill and the live stream never double them.
128
146
  if (method === "item/completed") {
129
147
  const item = params?.item;
130
148
  if (item) {
149
+ if (this.shouldDeferAssistantMessage(item)) {
150
+ this.deferAssistantMessage(item);
151
+ this.refreshCompletionGrace();
152
+ return;
153
+ }
154
+ // A late reasoning item belongs before the held final answer. Any other
155
+ // completed item establishes that the held message was not final and
156
+ // must retain its original position.
157
+ if (item.type !== "reasoning")
158
+ this.flushDeferredAssistantMessage();
131
159
  this.processCompletedItem(item);
160
+ this.refreshCompletionGrace();
132
161
  return;
133
162
  }
134
163
  }
135
164
  const mapped = mapCodexNotification(method, params);
136
- if (mapped.events.length || mapped.turnCompleted || mapped.fatalError) {
165
+ // Provider startup/onboarding notifications that rynx does not model map
166
+ // to runtime_debug. They are telemetry, not evidence of a model Turn. In
167
+ // particular, Traex emits hook-trust lifecycle notifications while merely
168
+ // resuming a TUI; opening a response for those leaves Chat permanently
169
+ // running because no turn/completed will follow.
170
+ const canonicalEvents = mapped.events.filter((event) => event.type !== "runtime_debug");
171
+ if (canonicalEvents.some((event) => event.type !== "reasoning_delta" && event.type !== "reasoning_completed")) {
172
+ this.flushDeferredAssistantMessage();
173
+ }
174
+ if (canonicalEvents.length || mapped.turnCompleted || mapped.fatalError) {
137
175
  this.ensureTurn();
138
176
  }
139
- for (const event of mapped.events) {
177
+ for (const event of canonicalEvents) {
140
178
  this.sink.onEvent(event);
141
179
  }
142
180
  if (mapped.fatalError) {
143
- this.turnOpen = false;
144
- this.sink.onTurnError(mapped.fatalError);
181
+ this.scheduleCompletion({ kind: "error", error: mapped.fatalError });
145
182
  return;
146
183
  }
147
184
  if (mapped.turnCompleted) {
148
- this.turnOpen = false;
149
- this.sink.onTurnEnd(mapped.usage);
185
+ this.scheduleCompletion({ kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
186
+ }
187
+ else {
188
+ this.refreshCompletionGrace();
189
+ }
190
+ }
191
+ scheduleCompletion(completion) {
192
+ const graceMs = this.options.turnCompletionGraceMs ?? 0;
193
+ if (graceMs <= 0) {
194
+ this.settle(completion);
195
+ return;
150
196
  }
197
+ this.pendingCompletion = completion;
198
+ this.refreshCompletionGrace();
199
+ }
200
+ refreshCompletionGrace() {
201
+ if (!this.pendingCompletion)
202
+ return;
203
+ const graceMs = this.options.turnCompletionGraceMs ?? 0;
204
+ if (graceMs <= 0)
205
+ return;
206
+ if (this.completionTimer)
207
+ clearTimeout(this.completionTimer);
208
+ this.completionTimer = setTimeout(() => this.flushPendingCompletion(), graceMs);
209
+ this.completionTimer.unref?.();
210
+ }
211
+ flushPendingCompletion() {
212
+ if (this.completionTimer)
213
+ clearTimeout(this.completionTimer);
214
+ this.completionTimer = null;
215
+ const completion = this.pendingCompletion;
216
+ this.pendingCompletion = null;
217
+ if (completion)
218
+ this.settle(completion);
219
+ }
220
+ settle(completion) {
221
+ this.flushDeferredAssistantMessage();
222
+ this.turnOpen = false;
223
+ if (completion.kind === "error")
224
+ this.sink.onTurnError(completion.error);
225
+ else
226
+ this.sink.onTurnEnd(completion.usage);
151
227
  }
152
228
  /** Map + emit one completed codex item, deduped by a TOTAL key and routing the
153
229
  * user echo to {@link CodexForwarderSink.onUserMessage}. Shared by live + backfill. */
154
230
  processCompletedItem(item) {
231
+ if (!this.claimCompletedItem(item))
232
+ return;
233
+ this.emitCompletedItem(item);
234
+ }
235
+ claimCompletedItem(item) {
155
236
  const { key, isAnon } = this.completedItemKey(item);
156
237
  if (this.seenCompletedItems.has(key))
157
- return;
238
+ return false;
158
239
  this.seenCompletedItems.add(key);
159
240
  if (isAnon)
160
241
  this.advanceAnonCounter();
161
- this.ensureTurn();
162
- const userText = userMessageText(item);
163
- if (userText !== undefined) {
164
- this.sink.onUserMessage?.(userText);
242
+ return true;
243
+ }
244
+ emitCompletedItem(item) {
245
+ const userContent = userMessageContent(item);
246
+ if (userContent !== undefined) {
247
+ this.ensureTurn();
248
+ this.sink.onUserMessage?.(userContent);
165
249
  return;
166
250
  }
167
251
  const mapped = mapCodexItem("item/completed", item);
168
- for (const event of mapped.events)
252
+ const canonicalEvents = mapped.events.filter((event) => event.type !== "runtime_debug");
253
+ if (canonicalEvents.length === 0)
254
+ return;
255
+ this.ensureTurn();
256
+ for (const event of canonicalEvents)
169
257
  this.sink.onEvent(event);
170
258
  }
259
+ shouldDeferAssistantMessage(item) {
260
+ if ((this.options.assistantMessageGraceMs ?? 0) <= 0 || item.type !== "agentMessage") {
261
+ return false;
262
+ }
263
+ return Boolean(item.text?.trim());
264
+ }
265
+ deferAssistantMessage(item) {
266
+ if (!this.claimCompletedItem(item))
267
+ return;
268
+ this.flushDeferredAssistantMessage();
269
+ this.deferredAssistantMessage = item;
270
+ const graceMs = this.options.assistantMessageGraceMs ?? 0;
271
+ this.assistantMessageTimer = setTimeout(() => this.flushDeferredAssistantMessage(), graceMs);
272
+ this.assistantMessageTimer.unref?.();
273
+ }
274
+ flushDeferredAssistantMessage() {
275
+ if (this.assistantMessageTimer)
276
+ clearTimeout(this.assistantMessageTimer);
277
+ this.assistantMessageTimer = null;
278
+ const item = this.deferredAssistantMessage;
279
+ this.deferredAssistantMessage = null;
280
+ if (item)
281
+ this.emitCompletedItem(item);
282
+ }
171
283
  /** Build a total dedup key for one completed item. Stable-id items use
172
284
  * `threadId:turnId:item.id` — identical across replay + live, so the second
173
285
  * delivery is dropped. Items without a codex id fall back to a per-(thread,turn)
@@ -8,6 +8,27 @@ function turnError(error, fallback) {
8
8
  }
9
9
  return new Error(fallback);
10
10
  }
11
+ function webSearchInput(item) {
12
+ const action = item.action;
13
+ if (action?.type === "search") {
14
+ return {
15
+ id: item.id,
16
+ query: action.query || item.query,
17
+ ...(action.queries?.length ? { queries: action.queries } : {}),
18
+ };
19
+ }
20
+ if (action?.type === "openPage") {
21
+ return { id: item.id, url: action.url ?? "" };
22
+ }
23
+ if (action?.type === "findInPage") {
24
+ return {
25
+ id: item.id,
26
+ url: action.url ?? "",
27
+ pattern: action.pattern ?? "",
28
+ };
29
+ }
30
+ return { id: item.id, query: item.query };
31
+ }
11
32
  /** Map one thread item (`item/started` | `item/completed`) to events. */
12
33
  export function mapCodexItem(method, item) {
13
34
  const events = [];
@@ -78,13 +99,15 @@ export function mapCodexItem(method, item) {
78
99
  return { events };
79
100
  }
80
101
  case "webSearch": {
102
+ const webSearch = item;
81
103
  events.push({
82
104
  type: "tool",
83
105
  event: isStart ? "on_tool_start" : "on_tool_end",
84
106
  name: "web_search",
85
- input: isStart
86
- ? { id: item.id, query: item.query }
87
- : undefined,
107
+ // `item/started` commonly has an empty top-level query. The completed
108
+ // item carries the real operation in `action`; pass that richer input
109
+ // through so the canonical layer can append a same-call argument update.
110
+ input: isStart || isEnd ? webSearchInput(webSearch) : undefined,
88
111
  output: isEnd ? { id: item.id } : undefined,
89
112
  data: { method, item },
90
113
  });
@@ -199,6 +222,17 @@ export function mapCodexNotification(method, params) {
199
222
  const p = typed.params;
200
223
  return { events, usage: p.tokenUsage };
201
224
  }
225
+ case "queue/status": {
226
+ const p = typed.params;
227
+ if (p.state === "ready") {
228
+ events.push({ type: "status" });
229
+ return { events };
230
+ }
231
+ const note = p.message?.trim()
232
+ || (p.position === null ? "Waiting for provider capacity." : `Your queue position is ${p.position}.`);
233
+ events.push({ type: "status", statusKind: "queue", note });
234
+ return { events };
235
+ }
202
236
  case "error": {
203
237
  const p = typed.params;
204
238
  if (p.willRetry) {