@rynx-ai/runtime 0.1.9 → 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.
@@ -200,6 +200,20 @@ export interface DynamicToolCallItem extends ThreadItemBase {
200
200
  export interface WebSearchItem extends ThreadItemBase {
201
201
  type: "webSearch";
202
202
  query: string;
203
+ action?: {
204
+ type: "search";
205
+ query: string | null;
206
+ queries: string[] | null;
207
+ } | {
208
+ type: "openPage";
209
+ url: string | null;
210
+ } | {
211
+ type: "findInPage";
212
+ url: string | null;
213
+ pattern: string | null;
214
+ } | {
215
+ type: "other";
216
+ } | null;
203
217
  }
204
218
  export type ThreadItem = UserMessageItem | AgentMessageItem | ReasoningItem | PlanItem | CommandExecutionItem | FileChangeItem | McpToolCallItem | DynamicToolCallItem | WebSearchItem | (ThreadItemBase & Record<string, unknown>);
205
219
  export interface ThreadSummary {
@@ -406,6 +420,15 @@ export interface ErrorNotificationParams {
406
420
  threadId: string;
407
421
  turnId: string;
408
422
  }
423
+ /** Traex backend-capacity queue update. */
424
+ export interface QueueStatusNotificationParams {
425
+ threadId: string;
426
+ turnId: string;
427
+ /** `queued` | `waiting` | `ready`. */
428
+ state: string;
429
+ position: number | null;
430
+ message: string | null;
431
+ }
409
432
  export type ServerNotification = {
410
433
  method: "thread/started";
411
434
  params: ThreadStartedNotificationParams;
@@ -445,6 +468,9 @@ export type ServerNotification = {
445
468
  } | {
446
469
  method: "thread/tokenUsage/updated";
447
470
  params: ThreadTokenUsageUpdatedNotificationParams;
471
+ } | {
472
+ method: "queue/status";
473
+ params: QueueStatusNotificationParams;
448
474
  } | {
449
475
  method: "error";
450
476
  params: ErrorNotificationParams;
@@ -528,6 +554,8 @@ export interface ToolRequestUserInputQuestion {
528
554
  question: string;
529
555
  isOther: boolean;
530
556
  isSecret: boolean;
557
+ /** Traex extension; absent in Codex 0.144. */
558
+ multiSelect?: boolean;
531
559
  options: Array<{
532
560
  label: string;
533
561
  description: string;
@@ -538,7 +566,10 @@ export interface ToolRequestUserInputParams {
538
566
  turnId: string;
539
567
  itemId: string;
540
568
  questions: ToolRequestUserInputQuestion[];
541
- autoResolutionMs: number | null;
569
+ /** Codex supplies an optional auto-resolution deadline. */
570
+ autoResolutionMs?: number | null;
571
+ /** Traex 0.200 supplies blocking semantics instead of a deadline. */
572
+ isBlocking?: boolean;
542
573
  }
543
574
  export interface ToolRequestUserInputResponse {
544
575
  answers: Record<string, {
@@ -1,3 +1,5 @@
1
+ import { type AgentRuntimeId } from "@rynx-ai/core";
2
+ export type CodexLineageRuntime = Exclude<AgentRuntimeId, "claude">;
1
3
  /**
2
4
  * The deterministic private CODEX_HOME path for a rynx session. PER-SESSION
3
5
  * (uid-scoped + `sha256(sessionId)[:32]`), mirroring reference implementation's per-session
@@ -6,6 +8,8 @@
6
8
  * (app-server) both locate the SAME session's home from `sessionId` alone.
7
9
  */
8
10
  export declare function codexHomePath(sessionId: string): string;
11
+ /** Deterministic private home for a Codex-lineage runtime. */
12
+ export declare function runtimeHomePath(sessionId: string, runtime: CodexLineageRuntime): string;
9
13
  /** The OLD uid-scoped shared home (pre per-session). Kept ONLY for back-compat
10
14
  * resume fallback — a session's rollout may still live under here. */
11
15
  export declare function legacyCodexHomePath(): string;
@@ -22,6 +26,12 @@ export declare function legacyCodexHomePath(): string;
22
26
  * call so a re-login/config change propagates. Returns the private home dir.
23
27
  */
24
28
  export declare function prepareCodexHome(sessionId: string, realHome?: string): string;
29
+ /**
30
+ * Prepare a private runtime home while inheriting only the login and settings
31
+ * files required by the selected CLI. Mutable update/NUX state remains in the
32
+ * real home and cannot wedge a managed app-server/TUI pair.
33
+ */
34
+ export declare function prepareRuntimeHome(sessionId: string, runtime: CodexLineageRuntime, realHome?: string): string;
25
35
  /**
26
36
  * Link a resolved skill set into `<codexHome>/skills/<name>/` so the native Codex
27
37
  * discovers them at `$CODEX_HOME/skills/` — the SAME filesystem mechanism reference implementation
@@ -1,11 +1,22 @@
1
1
  import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, rmSync, symlinkSync } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { homedir, tmpdir } from "node:os";
4
- import { join } from "node:path";
4
+ import { dirname, join } from "node:path";
5
+ import { getRuntimeProfile, resolveRuntimeHome, } from "@rynx-ai/core";
5
6
  /** Inherit the user's LIVE login by symlink (stays in sync). */
6
7
  const SYMLINK_FILES = ["auth.json"];
7
8
  /** Inherit the user's settings by snapshot copy (not the mutable NUX/update state). */
8
9
  const COPY_FILES = ["config.toml"];
10
+ const RUNTIME_HOME_FILES = {
11
+ codex: { symlink: SYMLINK_FILES, copy: COPY_FILES },
12
+ traex: {
13
+ // Traex keeps credentials below `cli/`, while its user configuration lives
14
+ // at the TRAE_HOME root. Keep both the current TOML name and the legacy YAML
15
+ // name so existing installations remain usable without an implicit migrate.
16
+ symlink: ["cli/auth.json"],
17
+ copy: ["traecli.toml", "traecli.yaml"],
18
+ },
19
+ };
9
20
  /** The user's real CODEX_HOME (env override, else `~/.codex`). */
10
21
  function realCodexHome() {
11
22
  return process.env.CODEX_HOME?.trim() || join(homedir(), ".codex");
@@ -26,6 +37,13 @@ export function codexHomePath(sessionId) {
26
37
  const digest = createHash("sha256").update(sessionId).digest("hex").slice(0, 32);
27
38
  return join(rynxUidRoot(), "codex-native", digest, "codex-home");
28
39
  }
40
+ /** Deterministic private home for a Codex-lineage runtime. */
41
+ export function runtimeHomePath(sessionId, runtime) {
42
+ if (runtime === "codex")
43
+ return codexHomePath(sessionId);
44
+ const digest = createHash("sha256").update(sessionId).digest("hex").slice(0, 32);
45
+ return join(rynxUidRoot(), "traex-native", digest, "trae-home");
46
+ }
29
47
  /** The OLD uid-scoped shared home (pre per-session). Kept ONLY for back-compat
30
48
  * resume fallback — a session's rollout may still live under here. */
31
49
  export function legacyCodexHomePath() {
@@ -44,13 +62,25 @@ export function legacyCodexHomePath() {
44
62
  * call so a re-login/config change propagates. Returns the private home dir.
45
63
  */
46
64
  export function prepareCodexHome(sessionId, realHome = realCodexHome()) {
47
- const dir = codexHomePath(sessionId);
65
+ return prepareRuntimeHome(sessionId, "codex", realHome);
66
+ }
67
+ /**
68
+ * Prepare a private runtime home while inheriting only the login and settings
69
+ * files required by the selected CLI. Mutable update/NUX state remains in the
70
+ * real home and cannot wedge a managed app-server/TUI pair.
71
+ */
72
+ export function prepareRuntimeHome(sessionId, runtime, realHome = runtime === "codex"
73
+ ? realCodexHome()
74
+ : resolveRuntimeHome(getRuntimeProfile(runtime))) {
75
+ const dir = runtimeHomePath(sessionId, runtime);
48
76
  mkdirSync(dir, { recursive: true, mode: 0o700 });
49
- for (const name of SYMLINK_FILES) {
77
+ const files = RUNTIME_HOME_FILES[runtime];
78
+ for (const name of files.symlink) {
50
79
  const src = join(realHome, name);
51
80
  const dst = join(dir, name);
81
+ mkdirSync(dirname(dst), { recursive: true, mode: 0o700 });
52
82
  try {
53
- rmSync(dst, { force: true });
83
+ rmSync(dst, { recursive: true, force: true });
54
84
  }
55
85
  catch {
56
86
  // absent — fine
@@ -64,11 +94,13 @@ export function prepareCodexHome(sessionId, realHome = realCodexHome()) {
64
94
  }
65
95
  }
66
96
  }
67
- for (const name of COPY_FILES) {
97
+ for (const name of files.copy) {
68
98
  const src = join(realHome, name);
69
99
  if (existsSync(src)) {
70
100
  try {
71
- copyFileSync(src, join(dir, name));
101
+ const dst = join(dir, name);
102
+ mkdirSync(dirname(dst), { recursive: true, mode: 0o700 });
103
+ copyFileSync(src, dst);
72
104
  }
73
105
  catch {
74
106
  // best-effort
package/dist/host.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type AgentSpec, type ReasoningEffort, type ResolvedExecutionBudget, type SessionInteractionResolution, type SessionEvent } from "@rynx-ai/core";
1
+ import { type AgentSpec, type ReasoningEffort, type ResolvedExecutionBudget, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent } from "@rynx-ai/core";
2
2
  import { type AgentRuntimeId } from "@rynx-ai/core";
3
3
  import { type AppConfig } from "@rynx-ai/core";
4
4
  import { createCodexChildEnv } from "./codex-child-env.js";
@@ -140,7 +140,7 @@ export declare class LocalAgentHost implements CodexCapabilities {
140
140
  private readonly sessionId;
141
141
  private sessionSandbox?;
142
142
  private sessionApprovalPolicy?;
143
- private cachedCodexHome?;
143
+ private readonly runtimeHomes;
144
144
  private readonly liveSessions;
145
145
  private readonly liveClaudeSessions;
146
146
  /** Claude forwarders stopped before their terminal is killed. Runner shutdown
@@ -175,8 +175,8 @@ export declare class LocalAgentHost implements CodexCapabilities {
175
175
  */
176
176
  private reapIdleBackends;
177
177
  private createBackend;
178
- /** This session's private CODEX_HOME (prepared once), shared by its app-server + TUI. */
179
- private codexHome;
178
+ /** This session's private native home, shared by its app-server + TUI. */
179
+ private runtimeHome;
180
180
  private createAppServerClient;
181
181
  /** Resolve a pending native question/approval without opening a new Turn. */
182
182
  resolveInteraction(localThreadId: string, interactionId: string, resolution: SessionInteractionResolution): Promise<ResolveInteractionResult>;
@@ -241,7 +241,7 @@ export declare class LocalAgentHost implements CodexCapabilities {
241
241
  * forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
242
242
  * the bridge instead of a short race that falls back to a second output path.
243
243
  */
244
- injectMessage(localThreadId: string, text: string): Promise<InjectOutcome>;
244
+ injectMessage(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectOutcome>;
245
245
  /**
246
246
  * Interrupt the session's active turn — the web Stop button. codex: the
247
247
  * app-server `turn/interrupt` on the active `{threadId, turnId}` (exactly what
@@ -316,7 +316,6 @@ export declare function parseCodexLoginStatus(exitCode: number, output: string):
316
316
  authMode: string | null;
317
317
  issues: string[];
318
318
  };
319
- export declare function getCodexModel(config: AppConfig, override?: string): string;
320
319
  export { createCodexChildEnv };
321
320
  export declare function isUnsupportedMethodError(error: unknown): boolean;
322
321
  /** A `thread/resume` failure meaning the thread has no (or an empty) rollout yet
package/dist/host.js CHANGED
@@ -7,10 +7,10 @@ import { resolveAgent, resolveAgentExecution, buildAgentSkillEnvironment, Sessio
7
7
  import { getRuntimeProfile, } from "@rynx-ai/core";
8
8
  import { resolveRuntimeBinary, resolveRuntimeModel, } from "@rynx-ai/core";
9
9
  import { createCodexChildEnv } from "./codex-child-env.js";
10
- import { prepareCodexHome, populateCodexSkills } from "./codex-home.js";
10
+ import { prepareRuntimeHome, populateCodexSkills, } from "./codex-home.js";
11
11
  import { materializeSkillPlugin } from "./claude/executor.js";
12
12
  import { listClaudeModels } from "./claude/models.js";
13
- import { CodexAppServerClient, buildTextUserInput, } from "./codex-app-server/client.js";
13
+ import { CodexAppServerClient, buildRuntimeUserInput, } from "./codex-app-server/client.js";
14
14
  import { buildAppServerBaseArgs } from "./codex-app-server/transport.js";
15
15
  import { WsRpcChannel, ExternalWsChannel } from "./codex-app-server/ws-channel.js";
16
16
  import { CodexSessionForwarder } from "./codex-app-server/forwarder.js";
@@ -18,6 +18,7 @@ import { buildCodexRemoteArgs } from "./terminal/codex-tui.js";
18
18
  import { buildClaudeTuiArgs } from "./terminal/claude-tui.js";
19
19
  import { ensureProjectTrusted } from "./claude/trust.js";
20
20
  import { claudeTranscriptPath } from "./claude/transcript.js";
21
+ import { claudeAttachmentToken, claudeInputText, runtimeUserContent, } from "./input-resources.js";
21
22
  import { claudeBridgeDir, prepareClaudeBridgeDir, removeManagedClaudeSettings, writeManagedClaudeSettings, } from "./claude/native-bridge.js";
22
23
  import { ClaudeLiveSession, injectViaTerminal, } from "./claude/native-integration.js";
23
24
  import { buildClaudeHookSettings } from "./claude/native-hooks.js";
@@ -234,9 +235,9 @@ export class LocalAgentHost {
234
235
  // this agent). Absent ⇒ fall back to the daemon `config.*` default.
235
236
  sessionSandbox;
236
237
  sessionApprovalPolicy;
237
- // A private CODEX_HOME (login symlinked, settings copied, update/NUX state NOT
238
- // inherited) for this session's codex app-server + TUI — lazily prepared.
239
- cachedCodexHome;
238
+ // Private CODEX_HOME / TRAE_HOME directories (login symlinked, settings
239
+ // copied, update/NUX state NOT inherited) for this session's app-server + TUI.
240
+ runtimeHomes = new Map();
240
241
  // Per-session codex-native live forwarders, keyed by localThreadId. The
241
242
  // single-writer of the session's canonical events + the target of turn injection.
242
243
  liveSessions = new Map();
@@ -329,9 +330,14 @@ export class LocalAgentHost {
329
330
  : this.createAppServerClient(runtime, retryEnv);
330
331
  return { runtime, profile, appServerClient, commandRunner };
331
332
  }
332
- /** This session's private CODEX_HOME (prepared once), shared by its app-server + TUI. */
333
- codexHome() {
334
- return (this.cachedCodexHome ??= prepareCodexHome(this.sessionId));
333
+ /** This session's private native home, shared by its app-server + TUI. */
334
+ runtimeHome(runtime) {
335
+ const existing = this.runtimeHomes.get(runtime);
336
+ if (existing)
337
+ return existing;
338
+ const prepared = prepareRuntimeHome(this.sessionId, runtime);
339
+ this.runtimeHomes.set(runtime, prepared);
340
+ return prepared;
335
341
  }
336
342
  createAppServerClient(runtime, extraEnv) {
337
343
  const cliPath = resolveRuntimeBinary(runtime);
@@ -343,12 +349,16 @@ export class LocalAgentHost {
343
349
  // Native co-drive: run the app-server on a loopback ws (multi-client) so a
344
350
  // `codex --remote` TUI can attach to the same thread (stdio admits only one
345
351
  // client). This is the sole transport — always native, aligning with reference implementation.
346
- // Point it at the private CODEX_HOME so it shares the TUI's login/settings and
347
- // never trips the real home's update/NUX state.
352
+ // Point it at the runtime's private home so it shares the TUI's
353
+ // login/settings and never trips the real home's update/NUX state.
354
+ const profile = getRuntimeProfile(runtime);
348
355
  const channel = new WsRpcChannel({
349
356
  cliPath,
350
357
  baseArgs: buildAppServerBaseArgs(sandbox),
351
- extraEnv: { ...(extraEnv ?? {}), CODEX_HOME: this.codexHome() },
358
+ extraEnv: {
359
+ ...(extraEnv ?? {}),
360
+ [profile.homeEnvVar]: this.runtimeHome(runtime),
361
+ },
352
362
  });
353
363
  return new CodexAppServerClient({
354
364
  channel,
@@ -448,6 +458,7 @@ export class LocalAgentHost {
448
458
  if (live?.instructions) {
449
459
  configOverrides.push(`developer_instructions=${JSON.stringify(live.instructions)}`);
450
460
  }
461
+ const profile = getRuntimeProfile(runtime);
451
462
  return {
452
463
  command: resolveRuntimeBinary(runtime),
453
464
  args: buildCodexRemoteArgs({
@@ -461,7 +472,7 @@ export class LocalAgentHost {
461
472
  // scopes agent-run CLIs (rynx-emulator) to this session at the daemon.
462
473
  env: {
463
474
  ...createCodexChildEnv(process.env),
464
- CODEX_HOME: this.codexHome(),
475
+ [profile.homeEnvVar]: this.runtimeHome(runtime),
465
476
  RYNX_SESSION_ID: localThreadId,
466
477
  },
467
478
  };
@@ -539,7 +550,7 @@ export class LocalAgentHost {
539
550
  // session-scoped dir (see resolveLiveAgentConfig); no spec / no `skills` means
540
551
  // zero skills — the owner's catalog is never consulted.
541
552
  const liveSkills = liveCfg.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
542
- populateCodexSkills(this.codexHome(), liveSkills);
553
+ populateCodexSkills(this.runtimeHome(runtime), liveSkills);
543
554
  // Apply this agent's sandbox / approval policy to the session's app-server
544
555
  // BEFORE it boots (getBackend below lazily creates it). Host is per-session,
545
556
  // so these knobs are the agent's own — no cross-session bleed. Set even when
@@ -584,7 +595,7 @@ export class LocalAgentHost {
584
595
  // A resumed thread keeps its stored model; otherwise use the agent-spec model (or
585
596
  // the runtime default). The model rides a launch `-c model=` on the TUI (see
586
597
  // `codexTerminalSpec`) / `turn/start` on injection, since rynx doesn't create the thread.
587
- const model = getCodexModel(this.config, record?.model ?? liveCfg.model);
598
+ const model = resolveRuntimeModel(this.config, runtime, record?.model ?? liveCfg.model);
588
599
  const hasCurrentEffortSource = Boolean(opts?.agentName || opts?.agentSpec || opts?.reasoningEffort);
589
600
  const reasoningEffort = hasCurrentEffortSource
590
601
  ? liveCfg.reasoningEffort
@@ -632,7 +643,10 @@ export class LocalAgentHost {
632
643
  // `turn/started` land on ONE response instead of splitting into random
633
644
  // ids (the DB `resp_codex_<uuid>` doubling). Mirrors reference implementation `_response_id`.
634
645
  responseId,
635
- model,
646
+ // Traex intentionally leaves the launch model empty to use its own
647
+ // config. Canonical/Direct events still require a printable producer
648
+ // label, so report the runtime when the exact model is unknown.
649
+ model: model || runtime,
636
650
  });
637
651
  return normalizer;
638
652
  };
@@ -833,9 +847,9 @@ export class LocalAgentHost {
833
847
  };
834
848
  const sink = {
835
849
  onTurnStart: (turnId) => startNormalizer(turnId),
836
- onUserMessage: (text) => {
850
+ onUserMessage: (content) => {
837
851
  const n = normalizer ?? startNormalizer();
838
- for (const se of n.userInput(text))
852
+ for (const se of n.userInput(content))
839
853
  emit(se);
840
854
  },
841
855
  onEvent: (event) => {
@@ -859,7 +873,20 @@ export class LocalAgentHost {
859
873
  closeCanonicalInteractions();
860
874
  if (!normalizer)
861
875
  return;
862
- for (const se of normalizer.fail({ code: "codex_error", message: error.message, source: "execution" }))
876
+ const responseStopped = error.message === "Codex turn was interrupted";
877
+ const providerName = runtime === "traex" ? "Traex" : "Codex";
878
+ const message = responseStopped
879
+ ? "Response stopped"
880
+ : runtime === "traex"
881
+ ? error.message.replace(/^Codex\b/, providerName)
882
+ : error.message;
883
+ for (const se of normalizer.fail({
884
+ code: responseStopped
885
+ ? "response_stopped"
886
+ : runtime === "traex" ? "traex_error" : "codex_error",
887
+ message,
888
+ source: "execution",
889
+ }))
863
890
  emit(se);
864
891
  normalizer = null;
865
892
  currentResponseId = null;
@@ -870,7 +897,16 @@ export class LocalAgentHost {
870
897
  onThreadStarted: (threadId) => this.onLiveThreadStarted(live, localThreadId, opts?.agentName ?? record?.agent, threadId),
871
898
  onThreadActive: () => live.releaseActive?.(),
872
899
  };
873
- const forwarder = new CodexSessionForwarder(forwarderClient, sink);
900
+ const forwarder = new CodexSessionForwarder(forwarderClient, sink, {
901
+ // Traex 0.200 can publish a final reasoning item immediately after
902
+ // `turn/completed`; without this grace frame it reopens the response and
903
+ // leaves Chat permanently running even though the TUI is idle. It also
904
+ // completes the final message before that preceding reasoning item, so
905
+ // hold the message over the same window to preserve semantic order.
906
+ turnCompletionGraceMs: runtime === "traex" ? 150 : 0,
907
+ assistantMessageGraceMs: runtime === "traex" ? 150 : 0,
908
+ surfaceQueueStatus: runtime === "traex",
909
+ });
874
910
  live.forwarder = forwarder;
875
911
  bindInteractionClient(injectClient);
876
912
  bindInteractionClient(forwarderClient);
@@ -901,11 +937,11 @@ export class LocalAgentHost {
901
937
  const previousCleanup = live.skillsCleanup;
902
938
  try {
903
939
  const liveSkills = liveCfg.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
904
- populateCodexSkills(this.codexHome(), liveSkills);
940
+ populateCodexSkills(this.runtimeHome(live.runtime), liveSkills);
905
941
  void previousCleanup?.().catch(() => undefined);
906
942
  this.sessionSandbox = liveCfg.sandbox;
907
943
  this.sessionApprovalPolicy = liveCfg.approvalPolicy;
908
- live.model = getCodexModel(this.config, liveCfg.model);
944
+ live.model = resolveRuntimeModel(this.config, live.runtime, liveCfg.model);
909
945
  live.reasoningEffort = liveCfg.reasoningEffort;
910
946
  live.instructions = liveCfg.instructions;
911
947
  live.skillsCleanup = liveCfg.skillsCleanup;
@@ -917,7 +953,7 @@ export class LocalAgentHost {
917
953
  await live.injectClient
918
954
  .threadSettingsUpdate({
919
955
  threadId: live.threadId,
920
- model: live.model,
956
+ ...(live.model ? { model: live.model } : {}),
921
957
  effort: live.reasoningEffort ?? null,
922
958
  })
923
959
  .catch(() => undefined);
@@ -1034,10 +1070,25 @@ export class LocalAgentHost {
1034
1070
  * forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
1035
1071
  * the bridge instead of a short race that falls back to a second output path.
1036
1072
  */
1037
- async injectMessage(localThreadId, text) {
1073
+ async injectMessage(localThreadId, input) {
1074
+ const runtimeInput = typeof input === "string"
1075
+ ? { content: [{ type: "text", text: input }] }
1076
+ : input;
1038
1077
  const claude = this.liveClaudeSessions.get(localThreadId);
1039
- if (claude)
1040
- return this.injectClaude(claude, localThreadId, text);
1078
+ if (claude) {
1079
+ const text = claudeInputText(runtimeInput);
1080
+ const token = claudeAttachmentToken(text);
1081
+ if (token) {
1082
+ claude.pendingImageInputs.set(token, runtimeUserContent(runtimeInput));
1083
+ const expiry = setTimeout(() => claude.pendingImageInputs.delete(token), 5 * 60_000);
1084
+ expiry.unref?.();
1085
+ }
1086
+ const outcome = await this.injectClaude(claude, localThreadId, text);
1087
+ if (token && (outcome === "notLive" || outcome === "notReady")) {
1088
+ claude.pendingImageInputs.delete(token);
1089
+ }
1090
+ return outcome;
1091
+ }
1041
1092
  const live = this.liveSessions.get(localThreadId);
1042
1093
  if (!live)
1043
1094
  return "notLive";
@@ -1048,13 +1099,17 @@ export class LocalAgentHost {
1048
1099
  const threadId = live.threadId ?? live.forwarder.threadId();
1049
1100
  if (!bound || !threadId)
1050
1101
  return "notReady";
1051
- const input = buildTextUserInput(text);
1102
+ const nativeInput = buildRuntimeUserInput(runtimeInput);
1052
1103
  try {
1053
1104
  // Inject via the BACKEND client (the forwarder connection only observes).
1054
1105
  if (live.forwarder.isTurnOpen()) {
1055
1106
  const turnId = live.forwarder.currentTurnId();
1056
1107
  if (turnId) {
1057
- await live.injectClient.turnSteer({ threadId, expectedTurnId: turnId, input });
1108
+ await live.injectClient.turnSteer({
1109
+ threadId,
1110
+ expectedTurnId: turnId,
1111
+ input: nativeInput,
1112
+ });
1058
1113
  return "injected";
1059
1114
  }
1060
1115
  }
@@ -1062,9 +1117,9 @@ export class LocalAgentHost {
1062
1117
  // agent's model even if the TUI's config default differs.
1063
1118
  await live.injectClient.turnStart({
1064
1119
  threadId,
1065
- input,
1120
+ input: nativeInput,
1066
1121
  cwd: live.cwd,
1067
- model: live.model,
1122
+ ...(live.model ? { model: live.model } : {}),
1068
1123
  ...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
1069
1124
  });
1070
1125
  return "injected";
@@ -1391,6 +1446,7 @@ export class LocalAgentHost {
1391
1446
  cwd,
1392
1447
  bridgeDir,
1393
1448
  injectLock: Promise.resolve(),
1449
+ pendingImageInputs: new Map(),
1394
1450
  ready,
1395
1451
  markReady,
1396
1452
  discoveredReady: false,
@@ -1405,7 +1461,11 @@ export class LocalAgentHost {
1405
1461
  onTurnStart: (turnId) => startNormalizer(turnId),
1406
1462
  onUserMessage: (text) => {
1407
1463
  const n = normalizer ?? startNormalizer();
1408
- for (const se of n.userInput(text))
1464
+ const token = claudeAttachmentToken(text);
1465
+ const content = token ? live.pendingImageInputs.get(token) : undefined;
1466
+ if (token && content)
1467
+ live.pendingImageInputs.delete(token);
1468
+ for (const se of n.userInput(content ?? text))
1409
1469
  emit(se);
1410
1470
  },
1411
1471
  onTerminalCommand: (cmd) => {
@@ -1458,10 +1518,14 @@ export class LocalAgentHost {
1458
1518
  });
1459
1519
  }
1460
1520
  },
1461
- onTurnError: (error) => {
1521
+ onTurnError: () => {
1462
1522
  if (!normalizer)
1463
1523
  return;
1464
- for (const se of normalizer.fail({ code: "claude_error", message: error.message, source: "execution" }))
1524
+ for (const se of normalizer.fail({
1525
+ code: "agent_error",
1526
+ message: "Agent turn failed",
1527
+ source: "execution",
1528
+ }))
1465
1529
  emit(se);
1466
1530
  normalizer = null;
1467
1531
  currentResponseId = undefined;
@@ -1559,7 +1623,11 @@ export class LocalAgentHost {
1559
1623
  const abort = new AbortController();
1560
1624
  live.injectAbort = abort;
1561
1625
  try {
1562
- const ok = await injectViaTerminal(injector, text, { signal: abort.signal });
1626
+ const submissionCheckpoint = live.forwarder.beginSubmissionObservation();
1627
+ const ok = await injectViaTerminal(injector, text, {
1628
+ signal: abort.signal,
1629
+ submissionObserved: () => live.forwarder.hasObservedSubmissionAfter(submissionCheckpoint, text),
1630
+ });
1563
1631
  return ok ? "injected" : "failed";
1564
1632
  }
1565
1633
  catch {
@@ -1739,9 +1807,6 @@ function stableValue(value) {
1739
1807
  }
1740
1808
  return value;
1741
1809
  }
1742
- export function getCodexModel(config, override) {
1743
- return override?.trim() || config.CODEX_MODEL;
1744
- }
1745
1810
  export { createCodexChildEnv };
1746
1811
  export function isUnsupportedMethodError(error) {
1747
1812
  if (!error || typeof error !== "object") {
@@ -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
+ }
@@ -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 reference implementation'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>;