@rynx-ai/runtime 0.1.0 → 0.1.10-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/claude/executor.d.ts +3 -5
  2. package/dist/claude/executor.js +3 -5
  3. package/dist/claude/native-bridge.d.ts +74 -17
  4. package/dist/claude/native-bridge.js +225 -30
  5. package/dist/claude/native-hook-main.js +327 -38
  6. package/dist/claude/native-hooks.d.ts +3 -2
  7. package/dist/claude/native-hooks.js +15 -6
  8. package/dist/claude/native-integration.d.ts +123 -16
  9. package/dist/claude/native-integration.js +624 -81
  10. package/dist/claude/settings.d.ts +8 -0
  11. package/dist/claude/settings.js +50 -0
  12. package/dist/claude/transcript.d.ts +2 -2
  13. package/dist/claude/transcript.js +14 -3
  14. package/dist/codex/rollout-synth.d.ts +8 -3
  15. package/dist/codex/rollout-synth.js +65 -32
  16. package/dist/codex-app-server/client.d.ts +27 -40
  17. package/dist/codex-app-server/client.js +1134 -99
  18. package/dist/codex-app-server/forwarder.d.ts +36 -10
  19. package/dist/codex-app-server/forwarder.js +146 -28
  20. package/dist/codex-app-server/mapping.d.ts +1 -1
  21. package/dist/codex-app-server/mapping.js +64 -5
  22. package/dist/codex-app-server/protocol.d.ts +269 -4
  23. package/dist/codex-app-server/transport.d.ts +20 -5
  24. package/dist/codex-app-server/transport.js +93 -40
  25. package/dist/codex-app-server/ws-channel.d.ts +3 -3
  26. package/dist/codex-app-server/ws-channel.js +23 -7
  27. package/dist/codex-child-env.js +33 -0
  28. package/dist/codex-home.d.ts +16 -6
  29. package/dist/codex-home.js +46 -15
  30. package/dist/codex-session-store.d.ts +2 -1
  31. package/dist/host.d.ts +38 -38
  32. package/dist/host.js +626 -121
  33. package/dist/index.d.ts +4 -3
  34. package/dist/index.js +1 -1
  35. package/dist/input-resources.d.ts +13 -0
  36. package/dist/input-resources.js +67 -0
  37. package/dist/interactions.d.ts +61 -0
  38. package/dist/interactions.js +236 -0
  39. package/dist/models-catalog.d.ts +5 -13
  40. package/dist/models-catalog.js +60 -9
  41. package/dist/runner/child.d.ts +9 -1
  42. package/dist/runner/child.js +100 -19
  43. package/dist/runner/manager.d.ts +79 -11
  44. package/dist/runner/manager.js +423 -43
  45. package/dist/runner/protocol.d.ts +30 -11
  46. package/dist/runner-main.js +9 -6
  47. package/dist/runtime-status.js +1 -1
  48. package/dist/terminal/claude-tui.d.ts +8 -3
  49. package/dist/terminal/claude-tui.js +6 -2
  50. package/dist/terminal/codex-tui.d.ts +3 -3
  51. package/dist/terminal/codex-tui.js +1 -1
  52. package/dist/terminal/registry.d.ts +1 -1
  53. package/dist/terminal/registry.js +1 -1
  54. package/dist/terminal/tmux.d.ts +6 -6
  55. package/dist/terminal/tmux.js +10 -10
  56. package/package.json +8 -3
package/dist/index.d.ts CHANGED
@@ -8,9 +8,10 @@ export { LocalAgentHost, CodexRuntimeError, SpawnCodexCommandRunner, FileCodexSe
8
8
  export type { CodexCapabilities, CapabilityResult, CodexRuntimeStatus, } from "./host.js";
9
9
  export type { CodexSessionStore, CodexSessionRecord } from "./codex-session-store.js";
10
10
  export { ensureCodexResumeRollout } from "./codex/rollout-synth.js";
11
- export { RunnerManager } from "./runner/manager.js";
12
- export type { RunnerManagerOptions, OpenTerminalOptions, ParentTerminal, } from "./runner/manager.js";
13
- export type { InjectOutcome, TerminalRole } from "./runner/protocol.js";
11
+ export { RunnerManager, TerminalOpenError } from "./runner/manager.js";
12
+ export type { RunnerManagerOptions, RunnerSessionContext, RunnerSessionContextProvider, OpenTerminalOptions, ParentTerminal, } from "./runner/manager.js";
13
+ export type { InjectOutcome, TerminalOpenErrorCode, TerminalRole } from "./runner/protocol.js";
14
+ export type { ResolveInteractionResult, RuntimeInteractionEvent, RuntimeInteractionListener, } from "./interactions.js";
14
15
  export { probeRuntimeStatus } from "./runtime-status.js";
15
16
  export { listRuntimeModels } from "./models-catalog.js";
16
17
  export { TmuxTerminal, isTmuxAvailable } from "./terminal/tmux.js";
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ export { ensureCodexResumeRollout } from "./codex/rollout-synth.js";
9
9
  // Runner subprocess layer: the parent-side manager (an `AgentExecutor` +
10
10
  // `AgentCapabilities` that spawns per-session runner children) plus the wire
11
11
  // types. The composition root uses `RunnerManager` in place of `LocalAgentHost`.
12
- export { RunnerManager } from "./runner/manager.js";
12
+ export { RunnerManager, TerminalOpenError } from "./runner/manager.js";
13
13
  export { probeRuntimeStatus } from "./runtime-status.js";
14
14
  export { listRuntimeModels } from "./models-catalog.js";
15
15
  // Live-terminal subsystem (Phase C): tmux-backed terminals + per-runner registry.
@@ -0,0 +1,13 @@
1
+ import type { InputImageContentPart, RuntimeUserInput, UserContentPart } from "@rynx-ai/core";
2
+ import type { UserInput } from "./codex-app-server/protocol.js";
3
+ /** Convert the provider-native user echo back to resource references. Unknown
4
+ * local paths and remote URLs are deliberately omitted rather than exposed. */
5
+ export declare function codexUserContent(input: readonly UserInput[]): UserContentPart[];
6
+ /** Claude's native TUI has no structured image RPC. The target daemon supplies
7
+ * only managed local paths and the marker makes the transcript echo reversible. */
8
+ export declare function claudeInputText(input: RuntimeUserInput, attachmentToken?: string): string;
9
+ /** Extract only the one-time token. The host resolves it through its in-memory
10
+ * pending-injection map; user-authored lookalikes have no entry and remain text. */
11
+ export declare function claudeAttachmentToken(text: string): string | undefined;
12
+ export declare function runtimeUserContent(input: RuntimeUserInput): UserContentPart[];
13
+ export declare function managedImagePart(path: string): InputImageContentPart | undefined;
@@ -0,0 +1,67 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { basename, extname } from "node:path";
3
+ const CLAUDE_ATTACHMENT_TOKEN = "RYNX_ATTACHMENT_SET";
4
+ const CLAUDE_ATTACHMENT_TOKEN_RE = /^\[RYNX_ATTACHMENT_SET (att_[0-9a-f-]{36}): inspect each RYNX_IMAGE_RESOURCE path with the Read tool before answering\.\]$/m;
5
+ /** Convert the provider-native user echo back to resource references. Unknown
6
+ * local paths and remote URLs are deliberately omitted rather than exposed. */
7
+ export function codexUserContent(input) {
8
+ const parts = [];
9
+ for (const part of input) {
10
+ if (part.type === "text") {
11
+ if (part.text)
12
+ parts.push({ type: "input_text", text: part.text });
13
+ continue;
14
+ }
15
+ if (part.type === "localImage") {
16
+ const image = managedImagePart(part.path);
17
+ if (image)
18
+ parts.push(image);
19
+ }
20
+ }
21
+ return parts;
22
+ }
23
+ /** Claude's native TUI has no structured image RPC. The target daemon supplies
24
+ * only managed local paths and the marker makes the transcript echo reversible. */
25
+ export function claudeInputText(input, attachmentToken = `att_${randomUUID()}`) {
26
+ const images = input.content.filter((part) => part.type === "local_image");
27
+ const text = input.content
28
+ .filter((part) => part.type === "text")
29
+ .map((part) => part.text)
30
+ .join("");
31
+ if (images.length === 0)
32
+ return text;
33
+ if (!/^att_[0-9a-f-]{36}$/.test(attachmentToken)) {
34
+ throw new Error("Claude attachment token is invalid");
35
+ }
36
+ const preamble = `[${CLAUDE_ATTACHMENT_TOKEN} ${attachmentToken}: inspect each RYNX_IMAGE_RESOURCE path with the Read tool before answering.]`;
37
+ const markers = images.map((part) => `[[RYNX_IMAGE_RESOURCE ${JSON.stringify({ path: part.path })}]]`);
38
+ return [preamble, ...markers, text]
39
+ .filter((part, index) => index <= markers.length || part.length > 0)
40
+ .join("\n");
41
+ }
42
+ /** Extract only the one-time token. The host resolves it through its in-memory
43
+ * pending-injection map; user-authored lookalikes have no entry and remain text. */
44
+ export function claudeAttachmentToken(text) {
45
+ return text.match(CLAUDE_ATTACHMENT_TOKEN_RE)?.[1];
46
+ }
47
+ export function runtimeUserContent(input) {
48
+ return input.content.map((part) => part.type === "text"
49
+ ? { type: "input_text", text: part.text }
50
+ : { ...part.resource });
51
+ }
52
+ export function managedImagePart(path) {
53
+ const extension = extname(path).toLowerCase();
54
+ const mediaType = extension === ".png"
55
+ ? "image/png"
56
+ : extension === ".jpg" || extension === ".jpeg"
57
+ ? "image/jpeg"
58
+ : extension === ".webp"
59
+ ? "image/webp"
60
+ : undefined;
61
+ if (!mediaType)
62
+ return undefined;
63
+ const resourceId = basename(path, extension);
64
+ if (!/^res_[0-9a-f-]{36}$/i.test(resourceId))
65
+ return undefined;
66
+ return { type: "input_image", resourceId, mediaType };
67
+ }
@@ -0,0 +1,61 @@
1
+ import { type SessionInteractionRequest, type SessionInteractionResolution } from "@rynx-ai/core";
2
+ /** Provider-neutral lifecycle emitted by a native runtime adapter. */
3
+ export type RuntimeInteractionEvent = {
4
+ type: "requested";
5
+ request: SessionInteractionRequest;
6
+ /** Native Turn correlation, when the provider exposes it. */
7
+ turnId?: string;
8
+ } | {
9
+ type: "resolved";
10
+ interactionId: string;
11
+ resolution: SessionInteractionResolution;
12
+ turnId?: string;
13
+ } | {
14
+ type: "cancelled";
15
+ interactionId: string;
16
+ reason?: string;
17
+ turnId?: string;
18
+ };
19
+ export type RuntimeInteractionListener = (event: RuntimeInteractionEvent) => void;
20
+ /** Result of attempting to answer a pending native interaction. */
21
+ export type ResolveInteractionResult = {
22
+ disposition: "applied";
23
+ } | {
24
+ disposition: "already_resolved";
25
+ } | {
26
+ disposition: "not_found";
27
+ } | {
28
+ disposition: "invalid";
29
+ message: string;
30
+ };
31
+ export type InteractionResolution = SessionInteractionResolution;
32
+ export declare const INTERACTION_LIMITS: {
33
+ readonly idBytes: 256;
34
+ readonly titleBytes: 1024;
35
+ readonly labelBytes: 1024;
36
+ readonly descriptionBytes: number;
37
+ readonly contextBytes: number;
38
+ readonly requestBytes: number;
39
+ readonly fields: 64;
40
+ readonly optionsPerField: 128;
41
+ readonly actions: 16;
42
+ readonly routingSkeletonBytes: number;
43
+ };
44
+ export type BoundInteractionRequestResult = {
45
+ ok: true;
46
+ request: SessionInteractionRequest;
47
+ } | {
48
+ ok: false;
49
+ reason: string;
50
+ };
51
+ /** Bound an untrusted provider request before it enters the canonical event
52
+ * stream. Lists that affect native answer routing are rejected when oversized;
53
+ * option lists may be truncated to a still-answerable subset. */
54
+ export declare function boundInteractionRequest(request: SessionInteractionRequest): BoundInteractionRequestResult;
55
+ /** Validate a provider-neutral resolution against the exact advertised form.
56
+ * Unknown keys and wrong answer shapes are rejected even for deny/cancel; the
57
+ * selected action's `requiresAnswers` controls only whether required fields may
58
+ * be omitted. */
59
+ export declare function validateInteractionResolution(request: SessionInteractionRequest, resolution: SessionInteractionResolution): string | undefined;
60
+ /** Secret values reach the native runtime but never enter canonical history. */
61
+ export declare function redactInteractionResolution(request: SessionInteractionRequest, resolution: SessionInteractionResolution): SessionInteractionResolution;
@@ -0,0 +1,236 @@
1
+ import { boundSessionInteractionRouting, sessionInteractionRoutingSkeletonBytes, SESSION_INTERACTION_STRUCTURAL_LIMITS, } from "@rynx-ai/core";
2
+ export const INTERACTION_LIMITS = {
3
+ ...SESSION_INTERACTION_STRUCTURAL_LIMITS,
4
+ idBytes: 256,
5
+ titleBytes: 1024,
6
+ labelBytes: 1024,
7
+ descriptionBytes: 8 * 1024,
8
+ contextBytes: 64 * 1024,
9
+ // SessionNormalizer carries the request twice (event + durable item). 240 KiB
10
+ // leaves 32 KiB for both wrappers under the daemon's 512 KiB event limit.
11
+ requestBytes: 240 * 1024,
12
+ };
13
+ /** UTF-8 byte-safe truncation: iteration is by Unicode code point, so neither a
14
+ * surrogate pair nor a multi-byte sequence is split. */
15
+ function truncateUtf8(value, maxBytes) {
16
+ if (Buffer.byteLength(value, "utf8") <= maxBytes)
17
+ return value;
18
+ const suffix = "…";
19
+ const budget = Math.max(0, maxBytes - Buffer.byteLength(suffix, "utf8"));
20
+ let result = "";
21
+ let bytes = 0;
22
+ for (const char of value) {
23
+ const width = Buffer.byteLength(char, "utf8");
24
+ if (bytes + width > budget)
25
+ break;
26
+ result += char;
27
+ bytes += width;
28
+ }
29
+ return `${result}${suffix}`;
30
+ }
31
+ function validId(id) {
32
+ return id.length > 0 && Buffer.byteLength(id, "utf8") <= INTERACTION_LIMITS.idBytes;
33
+ }
34
+ /** Bound an untrusted provider request before it enters the canonical event
35
+ * stream. Lists that affect native answer routing are rejected when oversized;
36
+ * option lists may be truncated to a still-answerable subset. */
37
+ export function boundInteractionRequest(request) {
38
+ if (!validId(request.interactionId)) {
39
+ return { ok: false, reason: "interaction id exceeds the canonical limit" };
40
+ }
41
+ if (request.fields.length > INTERACTION_LIMITS.fields) {
42
+ return { ok: false, reason: "interaction has too many fields" };
43
+ }
44
+ if (request.actions.length === 0 || request.actions.length > INTERACTION_LIMITS.actions) {
45
+ return { ok: false, reason: "interaction has an invalid number of actions" };
46
+ }
47
+ const seenFields = new Set();
48
+ const fields = [];
49
+ for (const field of request.fields) {
50
+ if (!validId(field.id) || seenFields.has(field.id)) {
51
+ return { ok: false, reason: "interaction has an invalid or duplicate field id" };
52
+ }
53
+ seenFields.add(field.id);
54
+ const label = truncateUtf8(field.label, INTERACTION_LIMITS.labelBytes);
55
+ const description = field.description !== undefined
56
+ ? truncateUtf8(field.description, INTERACTION_LIMITS.descriptionBytes)
57
+ : undefined;
58
+ if (field.type === "text") {
59
+ fields.push({
60
+ id: field.id,
61
+ type: "text",
62
+ label,
63
+ ...(description !== undefined ? { description } : {}),
64
+ ...(field.required !== undefined ? { required: field.required } : {}),
65
+ ...(field.secret !== undefined ? { secret: field.secret } : {}),
66
+ ...(field.multiline !== undefined ? { multiline: field.multiline } : {}),
67
+ ...(field.placeholder !== undefined
68
+ ? { placeholder: truncateUtf8(field.placeholder, INTERACTION_LIMITS.labelBytes) }
69
+ : {}),
70
+ });
71
+ continue;
72
+ }
73
+ const options = [];
74
+ const seenOptions = new Set();
75
+ for (const option of field.options.slice(0, INTERACTION_LIMITS.optionsPerField)) {
76
+ if (!validId(option.value) || seenOptions.has(option.value)) {
77
+ return { ok: false, reason: `interaction field has an invalid option id: ${field.id}` };
78
+ }
79
+ seenOptions.add(option.value);
80
+ options.push({
81
+ ...option,
82
+ label: truncateUtf8(option.label, INTERACTION_LIMITS.labelBytes),
83
+ ...(option.description !== undefined
84
+ ? { description: truncateUtf8(option.description, INTERACTION_LIMITS.descriptionBytes) }
85
+ : {}),
86
+ });
87
+ }
88
+ fields.push({
89
+ id: field.id,
90
+ type: "select",
91
+ label,
92
+ ...(description !== undefined ? { description } : {}),
93
+ ...(field.required !== undefined ? { required: field.required } : {}),
94
+ ...(field.multiple !== undefined ? { multiple: field.multiple } : {}),
95
+ ...(field.allowOther !== undefined ? { allowOther: field.allowOther } : {}),
96
+ options,
97
+ });
98
+ }
99
+ const seenActions = new Set();
100
+ const actions = [];
101
+ for (const action of request.actions) {
102
+ if (!validId(action.id) || seenActions.has(action.id)) {
103
+ return { ok: false, reason: "interaction has an invalid or duplicate action id" };
104
+ }
105
+ seenActions.add(action.id);
106
+ actions.push({
107
+ ...action,
108
+ label: truncateUtf8(action.label, INTERACTION_LIMITS.labelBytes),
109
+ ...(action.description !== undefined
110
+ ? { description: truncateUtf8(action.description, INTERACTION_LIMITS.descriptionBytes) }
111
+ : {}),
112
+ });
113
+ }
114
+ const context = request.context
115
+ ? {
116
+ ...(request.context.summary !== undefined
117
+ ? { summary: truncateUtf8(request.context.summary, INTERACTION_LIMITS.descriptionBytes) }
118
+ : {}),
119
+ ...(request.context.toolName !== undefined
120
+ ? { toolName: truncateUtf8(request.context.toolName, INTERACTION_LIMITS.labelBytes) }
121
+ : {}),
122
+ ...(request.context.command !== undefined
123
+ ? { command: truncateUtf8(request.context.command, INTERACTION_LIMITS.contextBytes) }
124
+ : {}),
125
+ ...(request.context.cwd !== undefined
126
+ ? { cwd: truncateUtf8(request.context.cwd, INTERACTION_LIMITS.contextBytes) }
127
+ : {}),
128
+ ...(request.context.diff !== undefined
129
+ ? { diff: truncateUtf8(request.context.diff, INTERACTION_LIMITS.contextBytes) }
130
+ : {}),
131
+ }
132
+ : undefined;
133
+ const boundedPresentation = {
134
+ ...request,
135
+ title: truncateUtf8(request.title, INTERACTION_LIMITS.titleBytes),
136
+ ...(request.description !== undefined
137
+ ? { description: truncateUtf8(request.description, INTERACTION_LIMITS.descriptionBytes) }
138
+ : {}),
139
+ fields,
140
+ actions,
141
+ ...(context ? { context } : {}),
142
+ };
143
+ const bounded = boundSessionInteractionRouting(boundedPresentation);
144
+ const lostOptionCoverage = boundedPresentation.fields.some((source, index) => source.type === "select" &&
145
+ source.options.length > 0 &&
146
+ bounded.fields[index]?.type === "select" &&
147
+ bounded.fields[index].options.length === 0);
148
+ if (lostOptionCoverage) {
149
+ return { ok: false, reason: "interaction routing minimum coverage exceeds the canonical budget" };
150
+ }
151
+ if (sessionInteractionRoutingSkeletonBytes(bounded) > INTERACTION_LIMITS.routingSkeletonBytes) {
152
+ return { ok: false, reason: "interaction routing structure exceeds the canonical budget" };
153
+ }
154
+ if (Buffer.byteLength(JSON.stringify(bounded), "utf8") > INTERACTION_LIMITS.requestBytes) {
155
+ return { ok: false, reason: "interaction exceeds the canonical request budget" };
156
+ }
157
+ return { ok: true, request: bounded };
158
+ }
159
+ function isRecord(value) {
160
+ return typeof value === "object" && value !== null && !Array.isArray(value);
161
+ }
162
+ /** Validate a provider-neutral resolution against the exact advertised form.
163
+ * Unknown keys and wrong answer shapes are rejected even for deny/cancel; the
164
+ * selected action's `requiresAnswers` controls only whether required fields may
165
+ * be omitted. */
166
+ export function validateInteractionResolution(request, resolution) {
167
+ const action = request.actions.find((candidate) => candidate.id === resolution.actionId);
168
+ if (!action)
169
+ return `unknown interaction action: ${resolution.actionId}`;
170
+ const rawAnswers = resolution.answers;
171
+ if (rawAnswers !== undefined && !isRecord(rawAnswers)) {
172
+ return "interaction answers must be an object";
173
+ }
174
+ const answers = rawAnswers ?? {};
175
+ const fields = new Map(request.fields.map((field) => [field.id, field]));
176
+ for (const [id, answer] of Object.entries(answers)) {
177
+ const field = fields.get(id);
178
+ if (!field)
179
+ return `unknown interaction answer: ${id}`;
180
+ if (field.type === "text") {
181
+ if (typeof answer !== "string")
182
+ return `answer must be a string: ${id}`;
183
+ continue;
184
+ }
185
+ const values = field.multiple
186
+ ? Array.isArray(answer) && answer.every((value) => typeof value === "string")
187
+ ? answer
188
+ : null
189
+ : typeof answer === "string"
190
+ ? [answer]
191
+ : null;
192
+ if (!values) {
193
+ return field.multiple
194
+ ? `answer must be a string array: ${id}`
195
+ : `answer must be a string: ${id}`;
196
+ }
197
+ const allowed = new Set(field.options.map((option) => option.value));
198
+ for (const value of values) {
199
+ if (allowed.has(value))
200
+ continue;
201
+ if (field.allowOther && value.trim().length > 0)
202
+ continue;
203
+ return `answer is not an allowed option: ${id}`;
204
+ }
205
+ }
206
+ if (!action.requiresAnswers)
207
+ return undefined;
208
+ for (const field of request.fields) {
209
+ if (!field.required)
210
+ continue;
211
+ const answer = answers[field.id];
212
+ const empty = answer === undefined ||
213
+ (typeof answer === "string" && answer.trim().length === 0) ||
214
+ (Array.isArray(answer) &&
215
+ (answer.length === 0 || answer.some((value) => value.trim().length === 0)));
216
+ if (empty)
217
+ return `answer is required: ${field.id}`;
218
+ }
219
+ return undefined;
220
+ }
221
+ /** Secret values reach the native runtime but never enter canonical history. */
222
+ export function redactInteractionResolution(request, resolution) {
223
+ if (!resolution.answers)
224
+ return resolution;
225
+ const secretIds = new Set(request.fields
226
+ .filter((field) => field.type === "text" && field.secret)
227
+ .map((field) => field.id));
228
+ if (secretIds.size === 0)
229
+ return resolution;
230
+ const answers = { ...resolution.answers };
231
+ for (const id of secretIds) {
232
+ if (answers[id] !== undefined)
233
+ answers[id] = "[redacted]";
234
+ }
235
+ return { ...resolution, answers };
236
+ }
@@ -1,18 +1,10 @@
1
- /**
2
- * Backend-free model listing for codex/traex.
3
- *
4
- * The parent control plane no longer holds an app-server, so `/models` can't be
5
- * a live `model/list` RPC anymore. Following omnigent's static-catalog model, we
6
- * serve a config-derived list: the runtime's configured default model (the one
7
- * `resolveRuntimeModel` would pick), marked `isDefault`. This is intentionally
8
- * minimal — a fuller curated catalogue can be added here later without touching
9
- * any caller. claude already has its own static list ({@link listClaudeModels}).
10
- */
11
1
  import { type AgentRuntimeId, type AppConfig } from "@rynx-ai/core";
12
2
  import type { ModelListResponse } from "./codex-app-server/protocol.js";
3
+ export interface RuntimeModelCatalogDeps {
4
+ readTraexModels?: () => Promise<unknown>;
5
+ }
13
6
  /**
14
7
  * The model list for a runtime, without an execution backend.
15
- * Returns `null` when the runtime exposes no resolvable model (e.g. traex with
16
- * no `TRAEX_MODEL`), matching the prior "unsupported" semantics of `listModels`.
8
+ * Falls back to the configured model if live Traex discovery is unavailable.
17
9
  */
18
- export declare function listRuntimeModels(config: AppConfig, runtime: AgentRuntimeId): ModelListResponse | null;
10
+ export declare function listRuntimeModels(config: AppConfig, runtime: AgentRuntimeId, deps?: RuntimeModelCatalogDeps): Promise<ModelListResponse | null>;
@@ -2,26 +2,77 @@
2
2
  * Backend-free model listing for codex/traex.
3
3
  *
4
4
  * The parent control plane no longer holds an app-server, so `/models` can't be
5
- * a live `model/list` RPC anymore. Following omnigent's static-catalog model, we
6
- * serve a config-derived list: the runtime's configured default model (the one
7
- * `resolveRuntimeModel` would pick), marked `isDefault`. This is intentionally
8
- * minimal — a fuller curated catalogue can be added here later without touching
9
- * any caller. claude already has its own static list ({@link listClaudeModels}).
5
+ * a live `model/list` RPC anymore. Following reference implementation's static-catalog model, we
6
+ * serve a config-derived Codex list and Traex's native `models --json` catalog.
7
+ * claude already has its own static list ({@link listClaudeModels}).
10
8
  */
11
- import { resolveRuntimeModel, } from "@rynx-ai/core";
9
+ import { execFile } from "node:child_process";
10
+ import { promisify } from "node:util";
11
+ import { resolveRuntimeBinary, resolveRuntimeModel, } from "@rynx-ai/core";
12
12
  import { listClaudeModels } from "./claude/models.js";
13
+ const execFileAsync = promisify(execFile);
14
+ const TRAEX_MODELS_TIMEOUT_MS = 8_000;
15
+ const TRAEX_MODELS_MAX_BYTES = 2 * 1024 * 1024;
13
16
  /**
14
17
  * The model list for a runtime, without an execution backend.
15
- * Returns `null` when the runtime exposes no resolvable model (e.g. traex with
16
- * no `TRAEX_MODEL`), matching the prior "unsupported" semantics of `listModels`.
18
+ * Falls back to the configured model if live Traex discovery is unavailable.
17
19
  */
18
- export function listRuntimeModels(config, runtime) {
20
+ export async function listRuntimeModels(config, runtime, deps = {}) {
19
21
  if (runtime === "claude") {
20
22
  return listClaudeModels();
21
23
  }
24
+ if (runtime === "traex") {
25
+ try {
26
+ const value = await (deps.readTraexModels ?? readTraexModels)();
27
+ const models = normalizeTraexModels(value, resolveRuntimeModel(config, runtime).trim());
28
+ if (models.length > 0)
29
+ return { data: models };
30
+ }
31
+ catch {
32
+ // Keep the configured fallback usable when Traex is missing/logged out.
33
+ }
34
+ }
22
35
  const model = resolveRuntimeModel(config, runtime).trim();
23
36
  if (!model) {
24
37
  return null;
25
38
  }
26
39
  return { data: [{ id: model, model, isDefault: true }] };
27
40
  }
41
+ async function readTraexModels() {
42
+ const { stdout } = await execFileAsync(resolveRuntimeBinary("traex"), ["models", "--json"], {
43
+ encoding: "utf8",
44
+ timeout: TRAEX_MODELS_TIMEOUT_MS,
45
+ maxBuffer: TRAEX_MODELS_MAX_BYTES,
46
+ });
47
+ return JSON.parse(stdout);
48
+ }
49
+ function normalizeTraexModels(value, configuredDefault) {
50
+ if (!Array.isArray(value))
51
+ return [];
52
+ const models = [];
53
+ const seen = new Set();
54
+ for (const item of value) {
55
+ if (!item || typeof item !== "object" || Array.isArray(item))
56
+ continue;
57
+ const record = item;
58
+ const id = typeof record.name === "string" ? record.name.trim() : "";
59
+ if (!id || seen.has(id))
60
+ continue;
61
+ seen.add(id);
62
+ models.push({
63
+ id,
64
+ model: id,
65
+ ...(typeof record.description === "string" ? { description: record.description } : {}),
66
+ isDefault: configuredDefault ? id === configuredDefault : models.length === 0,
67
+ ...(typeof record.context_window === "number" ? { contextWindow: record.context_window } : {}),
68
+ ...(Array.isArray(record.supported_mime_types)
69
+ ? { supportedMimeTypes: record.supported_mime_types }
70
+ : {}),
71
+ ...(record._meta && typeof record._meta === "object" ? { _meta: record._meta } : {}),
72
+ });
73
+ }
74
+ if (configuredDefault && !seen.has(configuredDefault)) {
75
+ models.unshift({ id: configuredDefault, model: configuredDefault, isDefault: true });
76
+ }
77
+ return models;
78
+ }
@@ -23,11 +23,16 @@ export declare class RunnerSession {
23
23
  /** Live terminals hosted by this session, and per-attach client handles. */
24
24
  private readonly terminals;
25
25
  private readonly attachments;
26
+ /** Opens are async; a close received before attach resolves tombstones the id. */
27
+ private readonly pendingTerminalOpens;
28
+ private readonly cancelledTerminalOpens;
26
29
  /** Session ids with a live codex forwarder started here (stopped on shutdown). */
27
30
  private readonly liveIds;
31
+ private shuttingDown;
28
32
  constructor({ transport, executor, onShutdown }: RunnerSessionDeps);
29
33
  private handle;
30
34
  private get liveProvider();
35
+ private resolveInteraction;
31
36
  /** The mirror channel for a session: an `emit` that forwards every canonical
32
37
  * event to the parent under the CURRENT session id (mutable), plus a `retarget`
33
38
  * the host calls on a `/clear`·`/fork` rotation — it re-points the mirror to the
@@ -40,7 +45,7 @@ export declare class RunnerSession {
40
45
  * launch the detached `codex --remote` TUI, which CREATES the codex thread. The
41
46
  * forwarder's connection sees the TUI's broadcast `thread/started`, binds, and
42
47
  * subscribes — so the TUI is usable immediately (a fresh `--remote` needs no
43
- * rollout) and its turns mirror to chat (omnigent's model).
48
+ * rollout) and its turns mirror to chat (reference implementation's model).
44
49
  */
45
50
  private ensureLive;
46
51
  private inject;
@@ -50,6 +55,9 @@ export declare class RunnerSession {
50
55
  * so the web attach reuses the same detached pane. */
51
56
  private launchCodexPane;
52
57
  private stopLive;
58
+ /** Stop event forwarding, kill native terminals/hooks, then synchronously
59
+ * scrub provider handoff files before the child process is allowed to exit. */
60
+ shutdown(): void;
53
61
  private openTerminal;
54
62
  /** Attach an already-created terminal and forward its data/exit to the parent. */
55
63
  private attachExisting;