@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.
- package/dist/claude/native-hook-main.js +45 -8
- package/dist/claude/native-integration.d.ts +45 -11
- package/dist/claude/native-integration.js +211 -59
- package/dist/claude/transcript.js +11 -0
- package/dist/codex/rollout-synth.d.ts +8 -3
- package/dist/codex/rollout-synth.js +64 -31
- package/dist/codex-app-server/client.d.ts +2 -1
- package/dist/codex-app-server/client.js +6 -0
- package/dist/codex-app-server/forwarder.d.ts +29 -3
- package/dist/codex-app-server/forwarder.js +135 -23
- package/dist/codex-app-server/mapping.js +37 -3
- package/dist/codex-app-server/protocol.d.ts +32 -1
- package/dist/codex-home.d.ts +10 -0
- package/dist/codex-home.js +38 -6
- package/dist/host.d.ts +5 -6
- package/dist/host.js +101 -36
- package/dist/input-resources.d.ts +13 -0
- package/dist/input-resources.js +67 -0
- package/dist/models-catalog.d.ts +5 -13
- package/dist/models-catalog.js +59 -8
- package/dist/runner/child.js +7 -4
- package/dist/runner/manager.d.ts +21 -2
- package/dist/runner/manager.js +38 -2
- package/dist/runner/protocol.d.ts +13 -5
- package/package.json +7 -2
|
@@ -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
|
|
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
|
|
26
|
-
export declare function findCodexRollout(
|
|
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 {
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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(
|
|
53
|
-
const flag = process.env.
|
|
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 =
|
|
57
|
-
|
|
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(
|
|
63
|
-
const dst = join(
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
181
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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?(
|
|
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
|
|
11
|
+
function userMessageContent(item) {
|
|
11
12
|
if (item.type !== "userMessage")
|
|
12
13
|
return undefined;
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
.join("")
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
177
|
+
for (const event of canonicalEvents) {
|
|
140
178
|
this.sink.onEvent(event);
|
|
141
179
|
}
|
|
142
180
|
if (mapped.fatalError) {
|
|
143
|
-
this.
|
|
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.
|
|
149
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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) {
|