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