@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/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,9 +18,11 @@ 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 { claudeBridgeDir, prepareClaudeBridgeDir } from "./claude/native-bridge.js";
21
+ import { claudeAttachmentToken, claudeInputText, runtimeUserContent, } from "./input-resources.js";
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";
25
+ import { buildManagedClaudeSettings } from "./claude/settings.js";
24
26
  import { FileCodexSessionStore, resolveCodexSessionStorePath, } from "./codex-session-store.js";
25
27
  import { AgentRuntimeError as CodexRuntimeError } from "@rynx-ai/core";
26
28
  import { newSessionId as makeSessionId } from "@rynx-ai/core";
@@ -161,11 +163,12 @@ export class SpawnCodexCommandRunner {
161
163
  }
162
164
  }
163
165
  /** Fraction of an agent's `contextWindow` budget at which the orchestrator
164
- * emits a one-time `context.threshold` observability signal. Mirrors omnigent's
166
+ * emits a one-time `context.threshold` observability signal. Mirrors reference implementation's
165
167
  * compaction `trigger_threshold` (0.8). codex self-compacts via its token
166
168
  * budget (set proactively); claude has no programmatic compaction, so this
167
169
  * signal is the lever consumers surface to the user. */
168
170
  const CONTEXT_WARN_RATIO = 0.8;
171
+ const MAX_CODEX_SETTLED_INTERACTIONS = 512;
169
172
  /**
170
173
  * The `OPENAI_*` retry env for a codex-lineage app-server spawn, or `undefined`
171
174
  * when the budget declares no retry or the runtime is claude. claude applies
@@ -217,7 +220,7 @@ export class LocalAgentHost {
217
220
  injectedAppServerClient;
218
221
  clock;
219
222
  backendIdleTtlMs;
220
- // Builds the SEPARATE forwarder connection for a live codex session (omnigent's
223
+ // Builds the SEPARATE forwarder connection for a live codex session (reference implementation's
221
224
  // multi-connection model). Defaults to an ExternalWsChannel client attached to
222
225
  // the backend's app-server; tests inject a fake.
223
226
  forwarderClientFactory;
@@ -232,15 +235,18 @@ export class LocalAgentHost {
232
235
  // this agent). Absent ⇒ fall back to the daemon `config.*` default.
233
236
  sessionSandbox;
234
237
  sessionApprovalPolicy;
235
- // A private CODEX_HOME (login symlinked, settings copied, update/NUX state NOT
236
- // inherited) for this session's codex app-server + TUI — lazily prepared.
237
- 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();
238
241
  // Per-session codex-native live forwarders, keyed by localThreadId. The
239
242
  // single-writer of the session's canonical events + the target of turn injection.
240
243
  liveSessions = new Map();
241
244
  // Per-session claude-native live forwarders (parallel to liveSessions; claude
242
245
  // has no app-server, so it tails the bridge + transcript instead of RPC).
243
246
  liveClaudeSessions = new Map();
247
+ /** Claude forwarders stopped before their terminal is killed. Runner shutdown
248
+ * finalizes these synchronously afterwards to scrub raw interaction answers. */
249
+ pendingClaudeFinalizers = new Set();
244
250
  // In-flight `ensureLiveCodexSession` calls, so concurrent triggers (a web
245
251
  // `live.ensure` racing a `term.open`) share ONE forwarder — never two.
246
252
  liveEnsuring = new Map();
@@ -324,9 +330,14 @@ export class LocalAgentHost {
324
330
  : this.createAppServerClient(runtime, retryEnv);
325
331
  return { runtime, profile, appServerClient, commandRunner };
326
332
  }
327
- /** This session's private CODEX_HOME (prepared once), shared by its app-server + TUI. */
328
- codexHome() {
329
- 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;
330
341
  }
331
342
  createAppServerClient(runtime, extraEnv) {
332
343
  const cliPath = resolveRuntimeBinary(runtime);
@@ -337,34 +348,65 @@ export class LocalAgentHost {
337
348
  const policy = approvalPolicy === "never" ? "auto-approve-session" : "auto-decline";
338
349
  // Native co-drive: run the app-server on a loopback ws (multi-client) so a
339
350
  // `codex --remote` TUI can attach to the same thread (stdio admits only one
340
- // client). This is the sole transport — always native, aligning with omnigent.
341
- // Point it at the private CODEX_HOME so it shares the TUI's login/settings and
342
- // never trips the real home's update/NUX state.
351
+ // client). This is the sole transport — always native, aligning with reference implementation.
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);
343
355
  const channel = new WsRpcChannel({
344
356
  cliPath,
345
357
  baseArgs: buildAppServerBaseArgs(sandbox),
346
- extraEnv: { ...(extraEnv ?? {}), CODEX_HOME: this.codexHome() },
358
+ extraEnv: {
359
+ ...(extraEnv ?? {}),
360
+ [profile.homeEnvVar]: this.runtimeHome(runtime),
361
+ },
347
362
  });
348
363
  return new CodexAppServerClient({
349
364
  channel,
350
365
  approvalDecisionPolicy: policy,
351
- interactiveApprovals: this.config.AGENT_INTERACTIVE_APPROVALS,
352
366
  });
353
367
  }
354
- /**
355
- * Deliver a user's decision for a pending interactive approval (Phase D).
356
- * Routes to the codex app-server client for the thread's runtime. Returns
357
- * false when no matching pending approval exists (unknown id / already
358
- * resolved / claude runtime).
359
- */
360
- async resolveApproval(localThreadId, approvalId, decision) {
361
- const record = await this.sessionStore.get(localThreadId);
362
- const runtime = record?.runtime ?? this.defaultRuntime;
363
- if (runtime === "claude") {
364
- return false;
368
+ /** Resolve a pending native question/approval without opening a new Turn. */
369
+ async resolveInteraction(localThreadId, interactionId, resolution) {
370
+ const claude = this.liveClaudeSessions.get(localThreadId);
371
+ if (claude) {
372
+ return claude.forwarder.resolveInteraction(interactionId, resolution);
373
+ }
374
+ const live = this.liveSessions.get(localThreadId);
375
+ if (!live)
376
+ return { disposition: "not_found" };
377
+ if (live.settledInteractions.has(interactionId)) {
378
+ return { disposition: "already_resolved" };
379
+ }
380
+ if (live.interactionRecoveries.has(interactionId)) {
381
+ return {
382
+ disposition: "invalid",
383
+ message: "interaction delivery is recovering; retry shortly",
384
+ };
365
385
  }
366
- const client = this.getBackend(runtime).appServerClient;
367
- return client ? client.resolveApproval(approvalId, decision) : false;
386
+ const submit = (client) => {
387
+ // Store before entering the client: a transport can report a synchronous
388
+ // write failure, and failover must already have the exact accepted answer
389
+ // available for replay. A synchronous terminal event may delete it again.
390
+ const hadPrevious = live.interactionSubmissions.has(interactionId);
391
+ const previous = live.interactionSubmissions.get(interactionId);
392
+ live.interactionSubmissions.set(interactionId, resolution);
393
+ const result = client.resolveInteraction(interactionId, resolution);
394
+ if (result.disposition !== "applied") {
395
+ if (hadPrevious && previous)
396
+ live.interactionSubmissions.set(interactionId, previous);
397
+ else
398
+ live.interactionSubmissions.delete(interactionId);
399
+ }
400
+ return result;
401
+ };
402
+ const owner = live.interactionOwners.get(interactionId);
403
+ if (owner)
404
+ return submit(owner);
405
+ const injected = submit(live.injectClient);
406
+ if (injected.disposition !== "not_found") {
407
+ return injected;
408
+ }
409
+ return submit(live.forwarderClient);
368
410
  }
369
411
  /**
370
412
  * The command to run in a session's live terminal so it co-drives the codex
@@ -382,7 +424,8 @@ export class LocalAgentHost {
382
424
  if (this.liveClaudeSessions.has(localThreadId)) {
383
425
  return this.claudeTerminalSpec(localThreadId, record);
384
426
  }
385
- const runtime = record?.runtime ?? this.defaultRuntime;
427
+ const live = this.liveSessions.get(localThreadId);
428
+ const runtime = record?.runtime ?? live?.runtime ?? this.defaultRuntime;
386
429
  if (runtime === "claude") {
387
430
  return this.claudeTerminalSpec(localThreadId, record);
388
431
  }
@@ -392,7 +435,7 @@ export class LocalAgentHost {
392
435
  throw new CodexRuntimeError(`no app-server for runtime ${runtime}`, 500, "app_server_unavailable");
393
436
  }
394
437
  // Native-only: an app-server failure surfaces as a terminal error, never a
395
- // silent bash fallback (aligns with omnigent). ensureInitialized throws on its own.
438
+ // silent bash fallback (aligns with reference implementation). ensureInitialized throws on its own.
396
439
  await client.ensureInitialized();
397
440
  const remoteUrl = client.terminalRemoteUrl();
398
441
  if (!remoteUrl) {
@@ -406,9 +449,16 @@ export class LocalAgentHost {
406
449
  : [];
407
450
  // Apply the agent-spec model to the TUI (the private CODEX_HOME config.toml is
408
451
  // shared per-runtime, so a per-session model rides a launch `-c model=` instead).
409
- const launchModel = this.liveSessions.get(localThreadId)?.model;
452
+ const launchModel = live?.model;
410
453
  if (launchModel)
411
454
  configOverrides.push(`model=${JSON.stringify(launchModel)}`);
455
+ const launchEffort = live?.reasoningEffort ?? record?.reasoningEffort;
456
+ if (launchEffort)
457
+ configOverrides.push(`model_reasoning_effort=${JSON.stringify(launchEffort)}`);
458
+ if (live?.instructions) {
459
+ configOverrides.push(`developer_instructions=${JSON.stringify(live.instructions)}`);
460
+ }
461
+ const profile = getRuntimeProfile(runtime);
412
462
  return {
413
463
  command: resolveRuntimeBinary(runtime),
414
464
  args: buildCodexRemoteArgs({
@@ -416,20 +466,20 @@ export class LocalAgentHost {
416
466
  ...(record?.codexSessionId ? { threadId: record.codexSessionId } : {}),
417
467
  configOverrides,
418
468
  }),
419
- cwd: record?.cwd ?? process.cwd(),
469
+ cwd: record?.cwd ?? live?.cwd ?? process.cwd(),
420
470
  // Share the app-server's private CODEX_HOME so the TUI inherits the same
421
471
  // login/settings and skips the real home's update/NUX prompt. RYNX_SESSION_ID
422
472
  // scopes agent-run CLIs (rynx-emulator) to this session at the daemon.
423
473
  env: {
424
474
  ...createCodexChildEnv(process.env),
425
- CODEX_HOME: this.codexHome(),
475
+ [profile.homeEnvVar]: this.runtimeHome(runtime),
426
476
  RYNX_SESSION_ID: localThreadId,
427
477
  },
428
478
  };
429
479
  }
430
480
  /**
431
481
  * Bring up (idempotently) a session's persistent codex forwarder and emit its
432
- * mirrored {@link SessionEvent}s. omnigent's codex-native single-writer model:
482
+ * mirrored {@link SessionEvent}s. reference implementation's codex-native single-writer model:
433
483
  * this forwarder is the SOLE producer of the session's canonical events — every
434
484
  * turn on the thread, web-composer-injected AND co-driving-`codex --remote`-TUI
435
485
  * initiated. Each turn gets a {@link SessionNormalizer} whose `responseId` is
@@ -444,7 +494,10 @@ export class LocalAgentHost {
444
494
  * to display it. The thread id is persisted so `codexTerminalSpec` resumes it.
445
495
  */
446
496
  async ensureLiveCodexSession(localThreadId, emit, opts) {
447
- if (this.liveSessions.has(localThreadId) || this.liveClaudeSessions.has(localThreadId))
497
+ const live = this.liveSessions.get(localThreadId);
498
+ if (live)
499
+ return this.refreshLiveCodexSession(localThreadId, live, opts);
500
+ if (this.liveClaudeSessions.has(localThreadId))
448
501
  return true;
449
502
  // Dedupe concurrent triggers so only one forwarder is ever created per
450
503
  // session (two would double-mirror every turn).
@@ -461,7 +514,7 @@ export class LocalAgentHost {
461
514
  }
462
515
  }
463
516
  async startLiveCodexSession(localThreadId, emit, opts) {
464
- // cancel-before-recreate (omnigent Layer 2, runner/app.py `_cancel_auto_forwarder_task`):
517
+ // cancel-before-recreate (reference implementation Layer 2, runner/app.py `_cancel_auto_forwarder_task`):
465
518
  // never run a new forwarder alongside a stale one for this id. ensureLive's has()
466
519
  // fast-path normally makes this a no-op; it closes the stop→re-ensure race that
467
520
  // could otherwise leave a half-dead forwarder mirroring in parallel.
@@ -475,7 +528,8 @@ export class LocalAgentHost {
475
528
  const runtime = record?.runtime ?? opts?.runtime ?? this.defaultRuntime;
476
529
  if (runtime === "claude")
477
530
  return this.startLiveClaudeSession(localThreadId, emit, record, opts);
478
- const cwd = record?.cwd ?? process.cwd();
531
+ const cwd = record?.cwd ?? opts?.cwd ?? process.cwd();
532
+ const configSignature = liveConfigSignature(opts, runtime, cwd);
479
533
  // Spec-load errors (a rejected legacy `skills` form, malformed JSON, …) are
480
534
  // logged with the agent context and fail the live start loudly — never a
481
535
  // bare mystery `false`.
@@ -489,14 +543,14 @@ export class LocalAgentHost {
489
543
  }
490
544
  // Link this session's skills into its private CODEX_HOME's `skills/` BEFORE the
491
545
  // app-server boots (and before the `--remote` TUI attaches) so the native Codex
492
- // discovers them — omnigent's filesystem mechanism. The live thread is created by
546
+ // discovers them — reference implementation's filesystem mechanism. The live thread is created by
493
547
  // the TUI, not rynx, so skills can't ride `threadStart.developerInstructions` (as
494
548
  // the non-live run did); `$CODEX_HOME/skills/` is the only channel that reaches it.
495
549
  // The agent spec is the single skill source: its refs were replayed into a
496
550
  // session-scoped dir (see resolveLiveAgentConfig); no spec / no `skills` means
497
551
  // zero skills — the owner's catalog is never consulted.
498
552
  const liveSkills = liveCfg.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
499
- populateCodexSkills(this.codexHome(), liveSkills);
553
+ populateCodexSkills(this.runtimeHome(runtime), liveSkills);
500
554
  // Apply this agent's sandbox / approval policy to the session's app-server
501
555
  // BEFORE it boots (getBackend below lazily creates it). Host is per-session,
502
556
  // so these knobs are the agent's own — no cross-session bleed. Set even when
@@ -510,32 +564,42 @@ export class LocalAgentHost {
510
564
  return false;
511
565
  };
512
566
  const injectClient = this.getBackend(runtime).appServerClient;
513
- if (!injectClient)
567
+ if (!injectClient) {
568
+ console.error(`[codex-live] session=${localThreadId} runtime=${runtime} no app-server client`);
514
569
  return abandon();
570
+ }
515
571
  try {
516
572
  await injectClient.ensureInitialized();
517
573
  }
518
- catch {
574
+ catch (err) {
575
+ console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server init failed: ${err instanceof Error ? err.message : String(err)}`);
519
576
  return abandon();
520
577
  }
521
578
  const appServerUrl = injectClient.terminalRemoteUrl();
522
- if (!appServerUrl)
579
+ if (!appServerUrl) {
580
+ console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server has no remote url`);
523
581
  return abandon(); // needs the ws app-server (live-terminal mode)
524
- // omnigent multi-connection: a SEPARATE forwarder connection to the SAME
582
+ }
583
+ // reference implementation multi-connection: a SEPARATE forwarder connection to the SAME
525
584
  // app-server, which `thread/resume`s to subscribe to the thread's item/turn
526
585
  // notifications (the backend client injects; this one only observes).
527
586
  const forwarderClient = this.forwarderClientFactory(appServerUrl);
528
587
  try {
529
588
  await forwarderClient.ensureInitialized();
530
589
  }
531
- catch {
590
+ catch (err) {
591
+ console.error(`[codex-live] session=${localThreadId} runtime=${runtime} forwarder init failed: ${err instanceof Error ? err.message : String(err)}`);
532
592
  return abandon();
533
593
  }
534
594
  // Apply the agent-spec model (skills were applied above via `$CODEX_HOME/skills/`).
535
595
  // A resumed thread keeps its stored model; otherwise use the agent-spec model (or
536
596
  // the runtime default). The model rides a launch `-c model=` on the TUI (see
537
597
  // `codexTerminalSpec`) / `turn/start` on injection, since rynx doesn't create the thread.
538
- const model = getCodexModel(this.config, record?.model ?? liveCfg.model);
598
+ const model = resolveRuntimeModel(this.config, runtime, record?.model ?? liveCfg.model);
599
+ const hasCurrentEffortSource = Boolean(opts?.agentName || opts?.agentSpec || opts?.reasoningEffort);
600
+ const reasoningEffort = hasCurrentEffortSource
601
+ ? liveCfg.reasoningEffort
602
+ : record?.reasoningEffort ?? liveCfg.reasoningEffort;
539
603
  let markReady;
540
604
  const ready = new Promise((resolve) => {
541
605
  markReady = resolve;
@@ -547,6 +611,8 @@ export class LocalAgentHost {
547
611
  runtime,
548
612
  cwd,
549
613
  model,
614
+ reasoningEffort,
615
+ ...(liveCfg.instructions ? { instructions: liveCfg.instructions } : {}),
550
616
  threadId: record?.codexSessionId ?? null,
551
617
  ready,
552
618
  markReady,
@@ -554,24 +620,236 @@ export class LocalAgentHost {
554
620
  subscribing: false,
555
621
  stopped: false,
556
622
  skillsCleanup: liveCfg.skillsCleanup,
623
+ configSignature,
624
+ interactionOwners: new Map(),
625
+ interactionStandbys: new Map(),
626
+ interactionSubmissions: new Map(),
627
+ canonicalInteractions: new Set(),
628
+ settledInteractions: new Set(),
629
+ interactionRecoveries: new Map(),
630
+ interactionFailedClients: new Map(),
631
+ disconnectedClients: new Set(),
557
632
  };
558
633
  let normalizer = null;
634
+ let currentResponseId = null;
559
635
  const startNormalizer = (turnId) => {
636
+ const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
637
+ if (normalizer && currentResponseId === responseId)
638
+ return normalizer;
639
+ currentResponseId = responseId;
560
640
  normalizer = new SessionNormalizer({
561
641
  sessionId: localThreadId,
562
642
  // turnId unknown → fixed literal (NEVER random): items arriving before
563
643
  // `turn/started` land on ONE response instead of splitting into random
564
- // ids (the DB `resp_codex_<uuid>` doubling). Mirrors omnigent `_response_id`.
565
- responseId: turnId ? `resp_codex_${turnId}` : "resp_codex_native",
566
- model,
644
+ // ids (the DB `resp_codex_<uuid>` doubling). Mirrors reference implementation `_response_id`.
645
+ responseId,
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,
567
650
  });
568
651
  return normalizer;
569
652
  };
653
+ const rememberSettledInteraction = (interactionId) => {
654
+ live.settledInteractions.add(interactionId);
655
+ if (live.settledInteractions.size <= MAX_CODEX_SETTLED_INTERACTIONS)
656
+ return;
657
+ const oldest = live.settledInteractions.values().next().value;
658
+ if (oldest)
659
+ live.settledInteractions.delete(oldest);
660
+ };
661
+ const forwardInteraction = (event) => {
662
+ const interactionId = event.type === "requested"
663
+ ? event.request.interactionId
664
+ : event.interactionId;
665
+ if (event.type === "requested") {
666
+ if (live.canonicalInteractions.has(interactionId) ||
667
+ live.settledInteractions.has(interactionId))
668
+ return;
669
+ live.canonicalInteractions.add(interactionId);
670
+ }
671
+ else {
672
+ // The forwarder may have completed the Turn before another app-server
673
+ // connection publishes its terminal copy. Never create a fresh
674
+ // normalizer/response for that late duplicate.
675
+ if (!live.canonicalInteractions.delete(interactionId))
676
+ return;
677
+ rememberSettledInteraction(interactionId);
678
+ }
679
+ const turnId = event.turnId ?? live.forwarder?.currentTurnId() ?? undefined;
680
+ const n = normalizer ?? startNormalizer(turnId);
681
+ const agentEvent = event.type === "requested"
682
+ ? { type: "interaction_requested", interaction: event.request }
683
+ : event.type === "resolved"
684
+ ? {
685
+ type: "interaction_resolved",
686
+ interactionId: event.interactionId,
687
+ resolution: event.resolution,
688
+ }
689
+ : {
690
+ type: "interaction_cancelled",
691
+ interactionId: event.interactionId,
692
+ ...(event.reason ? { reason: event.reason } : {}),
693
+ };
694
+ for (const se of n.next(agentEvent))
695
+ emit(se);
696
+ };
697
+ const clearRecovery = (interactionId) => {
698
+ live.interactionRecoveries.delete(interactionId);
699
+ };
700
+ const clearInteractionRouting = (interactionId) => {
701
+ clearRecovery(interactionId);
702
+ live.interactionOwners.delete(interactionId);
703
+ live.interactionStandbys.delete(interactionId);
704
+ live.interactionSubmissions.delete(interactionId);
705
+ live.interactionFailedClients.delete(interactionId);
706
+ };
707
+ const markInteractionClientFailed = (interactionId, client) => {
708
+ const failed = live.interactionFailedClients.get(interactionId) ?? new Set();
709
+ failed.add(client);
710
+ live.interactionFailedClients.set(interactionId, failed);
711
+ };
712
+ const interactionClientUnavailable = (interactionId, client) => live.disconnectedClients.has(client) ||
713
+ live.interactionFailedClients.get(interactionId)?.has(client) === true;
714
+ const allInteractionClientsUnavailable = (interactionId) => [live.injectClient, live.forwarderClient].every((client) => interactionClientUnavailable(interactionId, client));
715
+ const finishRecovery = (interactionId, event) => {
716
+ clearInteractionRouting(interactionId);
717
+ forwardInteraction(event);
718
+ };
719
+ const beginRecovery = (interactionId, event) => {
720
+ live.interactionOwners.delete(interactionId);
721
+ live.interactionRecoveries.set(interactionId, { event });
722
+ };
723
+ const promoteInteractionOwner = (interactionId, replacement) => {
724
+ const recovery = live.interactionRecoveries.get(interactionId);
725
+ live.interactionOwners.set(interactionId, replacement);
726
+ const submission = live.interactionSubmissions.get(interactionId);
727
+ if (!submission) {
728
+ clearRecovery(interactionId);
729
+ return true;
730
+ }
731
+ const replayed = replacement.resolveInteraction(interactionId, submission);
732
+ // A write failure can synchronously re-enter the listener and remove this
733
+ // owner. Only clear recovery when the replacement still owns the request.
734
+ if (replayed.disposition === "applied" &&
735
+ live.interactionOwners.get(interactionId) === replacement) {
736
+ clearRecovery(interactionId);
737
+ return true;
738
+ }
739
+ if (live.interactionOwners.get(interactionId) === replacement) {
740
+ live.interactionOwners.delete(interactionId);
741
+ }
742
+ if (recovery &&
743
+ live.canonicalInteractions.has(interactionId) &&
744
+ !live.settledInteractions.has(interactionId) &&
745
+ !live.interactionRecoveries.has(interactionId)) {
746
+ live.interactionRecoveries.set(interactionId, recovery);
747
+ }
748
+ return false;
749
+ };
750
+ const bindInteractionClient = (client) => {
751
+ client.setInteractionListener((event) => {
752
+ const interactionId = event.type === "requested"
753
+ ? event.request.interactionId
754
+ : event.interactionId;
755
+ const owner = live.interactionOwners.get(interactionId);
756
+ if (event.type === "requested") {
757
+ // Receiving a native request proves this connection is alive and can
758
+ // be retried even if an earlier copy failed on it.
759
+ live.disconnectedClients.delete(client);
760
+ const failed = live.interactionFailedClients.get(interactionId);
761
+ failed?.delete(client);
762
+ if (failed?.size === 0)
763
+ live.interactionFailedClients.delete(interactionId);
764
+ if (live.settledInteractions.has(interactionId))
765
+ return;
766
+ const recovery = live.interactionRecoveries.get(interactionId);
767
+ if (recovery) {
768
+ promoteInteractionOwner(interactionId, client);
769
+ return;
770
+ }
771
+ if (owner && owner !== client) {
772
+ const standbys = live.interactionStandbys.get(interactionId) ?? new Set();
773
+ standbys.add(client);
774
+ live.interactionStandbys.set(interactionId, standbys);
775
+ return;
776
+ }
777
+ live.interactionOwners.set(interactionId, client);
778
+ forwardInteraction(event);
779
+ return;
780
+ }
781
+ const recoverableOwnerFailure = event.type === "cancelled" &&
782
+ (event.reason === "app_server_disconnected" || event.reason === "response_write_failed");
783
+ if (recoverableOwnerFailure)
784
+ markInteractionClientFailed(interactionId, client);
785
+ if (!owner || owner !== client) {
786
+ const standbys = live.interactionStandbys.get(interactionId);
787
+ standbys?.delete(client);
788
+ if (standbys?.size === 0)
789
+ live.interactionStandbys.delete(interactionId);
790
+ const recovery = live.interactionRecoveries.get(interactionId);
791
+ if (recoverableOwnerFailure &&
792
+ live.canonicalInteractions.has(interactionId) &&
793
+ allInteractionClientsUnavailable(interactionId)) {
794
+ finishRecovery(interactionId, recovery?.event ?? event);
795
+ }
796
+ return;
797
+ }
798
+ if (recoverableOwnerFailure) {
799
+ const standbys = live.interactionStandbys.get(interactionId);
800
+ while (standbys && standbys.size > 0) {
801
+ const replacement = standbys.values().next().value;
802
+ if (!replacement)
803
+ break;
804
+ standbys.delete(replacement);
805
+ if (interactionClientUnavailable(interactionId, replacement))
806
+ continue;
807
+ if (promoteInteractionOwner(interactionId, replacement)) {
808
+ if (standbys.size === 0)
809
+ live.interactionStandbys.delete(interactionId);
810
+ return;
811
+ }
812
+ if (live.settledInteractions.has(interactionId))
813
+ return;
814
+ }
815
+ if (!live.settledInteractions.has(interactionId)) {
816
+ if (allInteractionClientsUnavailable(interactionId)) {
817
+ finishRecovery(interactionId, event);
818
+ }
819
+ else {
820
+ beginRecovery(interactionId, event);
821
+ }
822
+ return;
823
+ }
824
+ }
825
+ clearInteractionRouting(interactionId);
826
+ forwardInteraction(event);
827
+ });
828
+ client.setConnectionListener((state) => {
829
+ if (state === "connected") {
830
+ live.disconnectedClients.delete(client);
831
+ return;
832
+ }
833
+ live.disconnectedClients.add(client);
834
+ for (const [interactionId, recovery] of [...live.interactionRecoveries]) {
835
+ if (allInteractionClientsUnavailable(interactionId)) {
836
+ finishRecovery(interactionId, recovery.event);
837
+ }
838
+ }
839
+ });
840
+ };
841
+ const closeCanonicalInteractions = () => {
842
+ for (const interactionId of live.canonicalInteractions) {
843
+ rememberSettledInteraction(interactionId);
844
+ clearInteractionRouting(interactionId);
845
+ }
846
+ live.canonicalInteractions.clear();
847
+ };
570
848
  const sink = {
571
849
  onTurnStart: (turnId) => startNormalizer(turnId),
572
- onUserMessage: (text) => {
850
+ onUserMessage: (content) => {
573
851
  const n = normalizer ?? startNormalizer();
574
- for (const se of n.userInput(text))
852
+ for (const se of n.userInput(content))
575
853
  emit(se);
576
854
  },
577
855
  onEvent: (event) => {
@@ -580,6 +858,7 @@ export class LocalAgentHost {
580
858
  emit(se);
581
859
  },
582
860
  onTurnEnd: (usage) => {
861
+ closeCanonicalInteractions();
583
862
  if (!normalizer)
584
863
  return;
585
864
  if (usage)
@@ -588,13 +867,29 @@ export class LocalAgentHost {
588
867
  for (const se of normalizer.next({ type: "done" }))
589
868
  emit(se);
590
869
  normalizer = null;
870
+ currentResponseId = null;
591
871
  },
592
872
  onTurnError: (error) => {
873
+ closeCanonicalInteractions();
593
874
  if (!normalizer)
594
875
  return;
595
- 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
+ }))
596
890
  emit(se);
597
891
  normalizer = null;
892
+ currentResponseId = null;
598
893
  },
599
894
  // The TUI creates the thread; the forwarder connection sees its broadcast
600
895
  // `thread/started`. Persist the id, unblock injection, and start the
@@ -602,8 +897,19 @@ export class LocalAgentHost {
602
897
  onThreadStarted: (threadId) => this.onLiveThreadStarted(live, localThreadId, opts?.agentName ?? record?.agent, threadId),
603
898
  onThreadActive: () => live.releaseActive?.(),
604
899
  };
605
- 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
+ });
606
910
  live.forwarder = forwarder;
911
+ bindInteractionClient(injectClient);
912
+ bindInteractionClient(forwarderClient);
607
913
  this.liveSessions.set(localThreadId, live);
608
914
  forwarder.start();
609
915
  // A resumed session already has a thread id — bind immediately; a fresh
@@ -613,6 +919,65 @@ export class LocalAgentHost {
613
919
  }
614
920
  return true;
615
921
  }
922
+ async refreshLiveCodexSession(localThreadId, live, opts) {
923
+ const desiredRuntime = opts?.runtime ?? live.runtime;
924
+ if (desiredRuntime !== live.runtime)
925
+ return false;
926
+ const configSignature = liveConfigSignature(opts, live.runtime, live.cwd);
927
+ if (live.configSignature === configSignature)
928
+ return true;
929
+ let liveCfg;
930
+ try {
931
+ liveCfg = await this.resolveLiveAgentConfig(localThreadId, opts, live.runtime, live.cwd);
932
+ }
933
+ catch (err) {
934
+ console.error(`[agent-spec] session=${localThreadId} agent=${opts?.agentName ?? "-"} failed to refresh: ${err instanceof Error ? err.message : String(err)}`);
935
+ return false;
936
+ }
937
+ const previousCleanup = live.skillsCleanup;
938
+ try {
939
+ const liveSkills = liveCfg.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
940
+ populateCodexSkills(this.runtimeHome(live.runtime), liveSkills);
941
+ void previousCleanup?.().catch(() => undefined);
942
+ this.sessionSandbox = liveCfg.sandbox;
943
+ this.sessionApprovalPolicy = liveCfg.approvalPolicy;
944
+ live.model = resolveRuntimeModel(this.config, live.runtime, liveCfg.model);
945
+ live.reasoningEffort = liveCfg.reasoningEffort;
946
+ live.instructions = liveCfg.instructions;
947
+ live.skillsCleanup = liveCfg.skillsCleanup;
948
+ live.configSignature = configSignature;
949
+ if (live.threadId) {
950
+ // Keep already-running TUI turns aligned too; turn/start below carries
951
+ // the same overrides for web injection. Older app-servers may not have
952
+ // thread/settings/update, so refresh remains best-effort.
953
+ await live.injectClient
954
+ .threadSettingsUpdate({
955
+ threadId: live.threadId,
956
+ ...(live.model ? { model: live.model } : {}),
957
+ effort: live.reasoningEffort ?? null,
958
+ })
959
+ .catch(() => undefined);
960
+ void this.sessionStore
961
+ .set({
962
+ localThreadId,
963
+ codexSessionId: live.threadId,
964
+ cwd: live.cwd,
965
+ model: live.model,
966
+ reasoningEffort: live.reasoningEffort,
967
+ runtime: live.runtime,
968
+ agent: opts?.agentName,
969
+ updatedAt: new Date().toISOString(),
970
+ })
971
+ .catch(() => undefined);
972
+ }
973
+ return true;
974
+ }
975
+ catch (err) {
976
+ void liveCfg.skillsCleanup().catch(() => undefined);
977
+ console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} skill refresh failed: ${err instanceof Error ? err.message : String(err)}`);
978
+ return false;
979
+ }
980
+ }
616
981
  /** Bind a session's codex thread id once known (TUI broadcast or store): persist
617
982
  * it, unblock injection, and kick off the resume-subscribe loop (once). */
618
983
  onLiveThreadStarted(live, localThreadId, agent, threadId) {
@@ -628,6 +993,7 @@ export class LocalAgentHost {
628
993
  // Persist the agent-spec-resolved model (was the config default), so a
629
994
  // resume/reconnect keeps running the agent's model, not the global one.
630
995
  model: live.model,
996
+ reasoningEffort: live.reasoningEffort,
631
997
  runtime: live.runtime,
632
998
  agent,
633
999
  updatedAt: new Date().toISOString(),
@@ -637,7 +1003,7 @@ export class LocalAgentHost {
637
1003
  void this.subscribeUntilReady(live, threadId);
638
1004
  }
639
1005
  /**
640
- * Subscribe the forwarder connection to a thread (omnigent's
1006
+ * Subscribe the forwarder connection to a thread (reference implementation's
641
1007
  * `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
642
1008
  * turn, so `thread/resume` is retried: park until the forwarder observes the
643
1009
  * thread active, then retry WITHOUT `excludeTurns` so the response backfills the
@@ -692,7 +1058,7 @@ export class LocalAgentHost {
692
1058
  return ok;
693
1059
  }
694
1060
  /**
695
- * Inject a user turn into a session's live codex thread — omnigent's
1061
+ * Inject a user turn into a session's live codex thread — reference implementation's
696
1062
  * single-writer web send. `turn/steer` when a turn is open (mid-turn
697
1063
  * supplement), else `turn/start`. NEVER creates a thread (the TUI owns creation;
698
1064
  * this targets the id the forwarder captured). Serialized per session so two
@@ -701,41 +1067,66 @@ export class LocalAgentHost {
701
1067
  * Returns an {@link InjectOutcome}: `notLive` when this session has no live
702
1068
  * forwarder (caller may use the run path); `notReady`/`failed` are hard errors
703
1069
  * the caller reports WITHOUT re-running (re-running double-writes alongside the
704
- * forwarder). Park-until-ready (~60s), aligning omnigent's executor waiting for
1070
+ * forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
705
1071
  * the bridge instead of a short race that falls back to a second output path.
706
1072
  */
707
- async injectMessage(localThreadId, text) {
1073
+ async injectMessage(localThreadId, input) {
1074
+ const runtimeInput = typeof input === "string"
1075
+ ? { content: [{ type: "text", text: input }] }
1076
+ : input;
708
1077
  const claude = this.liveClaudeSessions.get(localThreadId);
709
- if (claude)
710
- 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
+ }
711
1092
  const live = this.liveSessions.get(localThreadId);
712
1093
  if (!live)
713
1094
  return "notLive";
714
1095
  const run = live.injectLock.then(async () => {
715
- // Park until the thread binds (~60s, omnigent codex_native_executor:177-186),
1096
+ // Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
716
1097
  // not a 20s race that returns false and lets the caller re-run on a 2nd path.
717
1098
  const bound = await this.waitLiveReady(localThreadId, 60_000);
718
1099
  const threadId = live.threadId ?? live.forwarder.threadId();
719
1100
  if (!bound || !threadId)
720
1101
  return "notReady";
721
- const input = buildTextUserInput(text);
1102
+ const nativeInput = buildRuntimeUserInput(runtimeInput);
722
1103
  try {
723
1104
  // Inject via the BACKEND client (the forwarder connection only observes).
724
1105
  if (live.forwarder.isTurnOpen()) {
725
1106
  const turnId = live.forwarder.currentTurnId();
726
1107
  if (turnId) {
727
- await live.injectClient.turnSteer({ threadId, expectedTurnId: turnId, input });
1108
+ await live.injectClient.turnSteer({
1109
+ threadId,
1110
+ expectedTurnId: turnId,
1111
+ input: nativeInput,
1112
+ });
728
1113
  return "injected";
729
1114
  }
730
1115
  }
731
1116
  // Carry the agent-spec model on the turn so a web-injected turn runs the
732
1117
  // agent's model even if the TUI's config default differs.
733
- await live.injectClient.turnStart({ threadId, input, cwd: live.cwd, model: live.model });
1118
+ await live.injectClient.turnStart({
1119
+ threadId,
1120
+ input: nativeInput,
1121
+ cwd: live.cwd,
1122
+ ...(live.model ? { model: live.model } : {}),
1123
+ ...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
1124
+ });
734
1125
  return "injected";
735
1126
  }
736
1127
  catch {
737
1128
  // RPC failed → hard error; caller reports it and does NOT re-run
738
- // (omnigent: inject failure ⇒ response.failed, never local re-run).
1129
+ // (reference implementation: inject failure ⇒ response.failed, never local re-run).
739
1130
  return "failed";
740
1131
  }
741
1132
  });
@@ -745,8 +1136,8 @@ export class LocalAgentHost {
745
1136
  /**
746
1137
  * Interrupt the session's active turn — the web Stop button. codex: the
747
1138
  * app-server `turn/interrupt` on the active `{threadId, turnId}` (exactly what
748
- * codex's own TUI interrupt key sends — omnigent `_handle_codex_native_interrupt`).
749
- * claude: an Escape keystroke into the pane (no app-server — omnigent
1139
+ * codex's own TUI interrupt key sends — reference implementation `_handle_codex_native_interrupt`).
1140
+ * claude: an Escape keystroke into the pane (no app-server — reference implementation
750
1141
  * `inject_interrupt`). Returns false as a no-op when there is no active turn.
751
1142
  */
752
1143
  async interruptLive(localThreadId) {
@@ -780,7 +1171,7 @@ export class LocalAgentHost {
780
1171
  if (!live)
781
1172
  return false;
782
1173
  // Only interrupt an OPEN turn — codex's app-server rejects a stale turnId, and
783
- // omnigent no-ops (204) when no turn is active.
1174
+ // reference implementation no-ops (204) when no turn is active.
784
1175
  if (!live.forwarder.isTurnOpen())
785
1176
  return false;
786
1177
  const threadId = live.threadId ?? live.forwarder.threadId();
@@ -797,12 +1188,19 @@ export class LocalAgentHost {
797
1188
  }
798
1189
  /** Stop + drop a session's live forwarder and its dedicated connection (session
799
1190
  * close / runner shutdown). The backend inject client is shared — left running. */
800
- stopLiveCodexSession(localThreadId) {
1191
+ stopLiveCodexSession(localThreadId, opts = {}) {
801
1192
  const claude = this.liveClaudeSessions.get(localThreadId);
802
1193
  if (claude) {
803
1194
  this.liveClaudeSessions.delete(localThreadId);
804
1195
  claude.stopped = true;
805
1196
  claude.forwarder.stop();
1197
+ if (opts.deferClaudeInteractionCleanup) {
1198
+ this.pendingClaudeFinalizers.add(claude.forwarder);
1199
+ }
1200
+ else {
1201
+ claude.forwarder.finalizeStop();
1202
+ }
1203
+ removeManagedClaudeSettings(claude.bridgeDir);
806
1204
  // Remove the throwaway skills --plugin-dir temp so it doesn't leak per launch.
807
1205
  void claude.skillCleanup?.();
808
1206
  return;
@@ -813,37 +1211,75 @@ export class LocalAgentHost {
813
1211
  this.liveSessions.delete(localThreadId);
814
1212
  live.stopped = true;
815
1213
  live.releaseActive?.();
1214
+ live.injectClient.cancelInteractions("session_stopped");
1215
+ live.forwarderClient.cancelInteractions("session_stopped");
1216
+ live.injectClient.setInteractionListener(null);
1217
+ live.forwarderClient.setInteractionListener(null);
1218
+ live.injectClient.setConnectionListener(null);
1219
+ live.forwarderClient.setConnectionListener(null);
1220
+ live.interactionOwners.clear();
1221
+ live.interactionStandbys.clear();
1222
+ live.interactionSubmissions.clear();
816
1223
  live.forwarder.stop();
1224
+ live.interactionRecoveries.clear();
1225
+ live.interactionFailedClients.clear();
1226
+ live.disconnectedClients.clear();
1227
+ live.canonicalInteractions.clear();
1228
+ live.settledInteractions.clear();
817
1229
  void live.forwarderClient.stop().catch(() => undefined);
818
1230
  // Remove the session-scoped skills dir — the machine keeps zero task residue.
819
1231
  void live.skillsCleanup?.();
820
1232
  }
1233
+ /** Complete the second shutdown phase after the runner has killed all native
1234
+ * terminals and hook subprocesses. Must run before the runner process exits. */
1235
+ finalizeStoppedLiveSessions() {
1236
+ for (const forwarder of this.pendingClaudeFinalizers)
1237
+ forwarder.finalizeStop();
1238
+ this.pendingClaudeFinalizers.clear();
1239
+ }
821
1240
  // --- claude-native live session (parallel to the codex methods above) -----
822
1241
  /** The interactive `claude` TUI spec for a claude-native live session: the real
823
1242
  * `claude` binary with rynx's hooks (`--settings`), resuming the bound session.
824
1243
  * Trust is pre-seeded so the daemon-spawned TUI never blocks on a first-run
825
- * prompt. (PermissionRequest approvals land in Phase 6; until then the TUI
826
- * surfaces its own permission prompt in the co-driven pane.) */
1244
+ * prompt. Questions and PermissionRequest approvals use the runtime-local
1245
+ * generic interaction bridge and remain inside the active Turn. */
827
1246
  claudeTerminalSpec(localThreadId, record) {
828
- const cwd = record?.cwd ?? process.cwd();
1247
+ const live = this.liveClaudeSessions.get(localThreadId);
1248
+ const cwd = record?.cwd ?? live?.cwd ?? process.cwd();
829
1249
  try {
830
1250
  ensureProjectTrusted(cwd);
831
1251
  }
832
1252
  catch {
833
1253
  // A malformed ~/.claude.json shouldn't block launch; claude re-prompts.
834
1254
  }
835
- const settings = buildClaudeHookSettings({
1255
+ const inheritedSettings = buildManagedClaudeSettings(cwd, {});
1256
+ const inheritedPermissions = inheritedSettings.permissions;
1257
+ const inheritedPermissionMode = inheritedPermissions &&
1258
+ typeof inheritedPermissions === "object" &&
1259
+ !Array.isArray(inheritedPermissions) &&
1260
+ typeof inheritedPermissions.defaultMode === "string"
1261
+ ? inheritedPermissions.defaultMode
1262
+ : undefined;
1263
+ const hookPermissionMode = live?.permissionMode ?? inheritedPermissionMode;
1264
+ const hookSettings = buildClaudeHookSettings({
836
1265
  bridgeDir: claudeBridgeDir(localThreadId),
837
- permission: Boolean(process.env.RYNX_CONTROL_URL?.trim()),
1266
+ ...(hookPermissionMode ? { permissionMode: hookPermissionMode } : {}),
838
1267
  messageDisplay: true,
839
1268
  statusLine: true,
840
1269
  });
1270
+ const settings = { ...inheritedSettings, ...hookSettings };
1271
+ const settingsPath = writeManagedClaudeSettings(claudeBridgeDir(localThreadId), settings);
841
1272
  // The live session (created by startLiveClaudeSession before the pane launches)
842
- // carries the agent-spec launch config: model + `--plugin-dir` (skills; no persona).
843
- const live = this.liveClaudeSessions.get(localThreadId);
1273
+ // carries the agent-spec launch config: model, instructions, and skills.
844
1274
  const args = buildClaudeTuiArgs({
845
- settingsJson: JSON.stringify(settings),
1275
+ settingsJson: settingsPath,
1276
+ // A rynx-managed Session must have one interaction owner. Loading host or
1277
+ // project hooks here would let a second PermissionRequest/PreToolUse hook
1278
+ // resolve the same native request while rynx still exposes it as pending.
1279
+ // Explicit rynx hooks and selected-skill plugin dirs remain enabled.
1280
+ settingSources: "",
846
1281
  model: live?.launchModel ?? resolveRuntimeModel(this.config, "claude", record?.model),
1282
+ ...(live?.launchInstructions ? { appendSystemPrompt: live.launchInstructions } : {}),
847
1283
  ...(record?.codexSessionId ? { resume: record.codexSessionId } : {}),
848
1284
  ...(live?.launchExtraArgs?.length ? { extraArgs: live.launchExtraArgs } : {}),
849
1285
  });
@@ -859,19 +1295,17 @@ export class LocalAgentHost {
859
1295
  /** Bring up a claude-native live forwarder: prepare the bridge dir (the TUI's
860
1296
  * hooks write into it), then tail bridge + transcript and drive a per-turn
861
1297
  * {@link SessionNormalizer} → `emit` (the SAME sink shape the codex path uses). */
862
- /** Resolve an agent spec's launch config — model + the session skill env —
1298
+ /** Resolve an agent spec's launch config — model, instructions, and the session skill env —
863
1299
  * for a LIVE native session, reusing the same core resolvers the non-live
864
1300
  * run path uses ({@link resolveAgentExecution} for the model,
865
- * {@link resolveAgent} for skills). Instructions are intentionally NOT
866
- * returned: the co-driven TUI runs on vendor defaults (see
867
- * startLiveClaudeSession).
1301
+ * {@link resolveAgent} for instructions and skills).
868
1302
  *
869
1303
  * Skills are spec-rooted: each declared ref is replayed into a
870
1304
  * SESSION-scoped temp dir (cache-accelerated); the owner's catalog plays no
871
- * role, and no spec / no `skills` means ZERO skills. The env-build report
872
- * (installs, failures, content drift) is logged a declared skill that
873
- * can't be provisioned is loudly absent, never silent; the session still
874
- * starts. `skillsCleanup` removes the session dir (call on session stop). */
1305
+ * role, and no spec / no `skills` means ZERO skills. Every declared skill
1306
+ * must resolve at its declared content hash; missing, failed, or drifted
1307
+ * materialization aborts launch before a native session starts.
1308
+ * `skillsCleanup` removes the session dir (call on session stop). */
875
1309
  async resolveLiveAgentConfig(localThreadId, opts, runtime, cwd) {
876
1310
  // Inline spec needs an id only so a relative instructions-file path can
877
1311
  // resolve (mirrors runLocked); a preset agent loads by name.
@@ -883,6 +1317,7 @@ export class LocalAgentHost {
883
1317
  ...(opts?.agentName ? { agentName: opts.agentName } : {}),
884
1318
  ...(opts?.agentSpec ? { spec: opts.agentSpec } : {}),
885
1319
  runtime,
1320
+ ...(opts?.reasoningEffort ? { reasoningEffort: opts.reasoningEffort } : {}),
886
1321
  });
887
1322
  const resolved = await resolveAgent({
888
1323
  config: this.config,
@@ -891,31 +1326,53 @@ export class LocalAgentHost {
891
1326
  ...(spec ? { spec } : {}),
892
1327
  });
893
1328
  const sessionSkillsDir = await mkdtemp(path.join(tmpdir(), "rynx-session-skills-"));
894
- const skillEnv = await buildAgentSkillEnvironment(resolved.skills, sessionSkillsDir);
1329
+ let skillEnv;
1330
+ try {
1331
+ skillEnv = await buildAgentSkillEnvironment(resolved.skills, sessionSkillsDir);
1332
+ const declaredNames = (resolved.skills ?? []).map((skill) => skill.name);
1333
+ const resolvedNames = skillEnv.resolved.map((skill) => skill.name);
1334
+ const { report } = skillEnv;
1335
+ if (report.errors.length > 0 ||
1336
+ report.missing.length > 0 ||
1337
+ report.drift.length > 0 ||
1338
+ resolvedNames.length !== declaredNames.length ||
1339
+ resolvedNames.some((name, index) => name !== declaredNames[index])) {
1340
+ const details = [
1341
+ `declared=[${declaredNames.join(", ")}]`,
1342
+ `resolved=[${resolvedNames.join(", ")}]`,
1343
+ ...(report.missing.length > 0 ? [`missing=[${report.missing.join(", ")}]`] : []),
1344
+ ...(report.drift.length > 0 ? [`drift=[${report.drift.join("; ")}]`] : []),
1345
+ ...report.errors,
1346
+ ].join("; ");
1347
+ throw new Error(`failed to provision declared skills: ${details}`);
1348
+ }
1349
+ }
1350
+ catch (error) {
1351
+ await rm(sessionSkillsDir, { recursive: true, force: true }).catch(() => undefined);
1352
+ throw error;
1353
+ }
895
1354
  const agent = opts?.agentName ?? resolved.name ?? "-";
896
1355
  const { report } = skillEnv;
897
- if (report.errors.length > 0 || report.missing.length > 0) {
898
- console.error(`[skills] session=${localThreadId} agent=${agent} missing=[${report.missing.join(", ")}] ${report.errors.join("; ")}`);
899
- }
900
1356
  if (report.installed.length > 0) {
901
1357
  console.error(`[skills] session=${localThreadId} agent=${agent} installed=[${report.installed.join(", ")}]`);
902
1358
  }
903
- for (const note of report.drift) {
904
- console.error(`[skills] session=${localThreadId} agent=${agent} drift: ${note}`);
905
- }
906
1359
  return {
907
1360
  model: exec.model,
1361
+ ...((opts?.agentName || opts?.agentSpec) && resolved.instructions
1362
+ ? { instructions: resolved.instructions }
1363
+ : {}),
908
1364
  selectedSkills: skillEnv.resolved,
909
1365
  skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
910
1366
  ...(resolved.sandbox ? { sandbox: resolved.sandbox } : {}),
911
1367
  ...(resolved.approvalPolicy ? { approvalPolicy: resolved.approvalPolicy } : {}),
912
1368
  ...(resolved.permissionMode ? { permissionMode: resolved.permissionMode } : {}),
1369
+ ...(exec.reasoningEffort ? { reasoningEffort: exec.reasoningEffort } : {}),
913
1370
  };
914
1371
  }
915
1372
  async startLiveClaudeSession(localThreadId, emit, record, opts) {
916
1373
  const retargetMirror = opts?.retargetMirror;
917
- const bridgeDir = prepareClaudeBridgeDir(localThreadId, this.claudePermissionConfig(localThreadId));
918
- const cwd = record?.cwd ?? process.cwd();
1374
+ const bridgeDir = prepareClaudeBridgeDir(localThreadId);
1375
+ const cwd = record?.cwd ?? opts?.cwd ?? process.cwd();
919
1376
  // Apply the agent spec (model / skills / instructions) to the live TUI launch —
920
1377
  // NOT just the config default. The stored record model wins if present (a
921
1378
  // resumed thread), else the agent-spec resolution. Spec-load errors are logged
@@ -929,18 +1386,16 @@ export class LocalAgentHost {
929
1386
  return false;
930
1387
  }
931
1388
  const model = resolveRuntimeModel(this.config, "claude", record?.model ?? liveCfg.model);
932
- // Skills → throwaway `--plugin-dir`. Persona/instructions are deliberately NOT
933
- // injected into the co-driven TUI: omnigent's claude-native and codex-native both
934
- // leave the live thread on the vendor's built-in defaults (+ workspace AGENTS.md),
935
- // so rynx matches (the non-live run path still injects persona for both runtimes).
936
- // The plugin dir gets its own copy, so the session skills dir is disposable here.
1389
+ // Skills → throwaway `--plugin-dir`; instructions Claude Code's native
1390
+ // `--append-system-prompt`. The plugin dir gets its own copy, so the session
1391
+ // skills dir is disposable here.
937
1392
  const skillPlugin = await materializeSkillPlugin(liveCfg.selectedSkills);
938
1393
  void liveCfg.skillsCleanup();
939
1394
  // Agent-spec permission mode → claude's `--permission-mode` (claude-private
940
1395
  // approval axis). Unset sends no flag (claude's own default); any set value
941
1396
  // is passed through (`bypassPermissions` is how an agent opts out of the
942
1397
  // approval prompt). The shared `sandbox` axis is intentionally NOT applied
943
- // here — the live co-drive TUI runs unsandboxed (matches omnigent's native).
1398
+ // here — the live co-drive TUI runs unsandboxed (matches reference implementation's native).
944
1399
  const permissionMode = liveCfg.permissionMode;
945
1400
  const launchExtraArgs = [
946
1401
  ...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
@@ -952,15 +1407,36 @@ export class LocalAgentHost {
952
1407
  let normalizer = null;
953
1408
  let currentResponseId;
954
1409
  const startNormalizer = (turnId) => {
955
- // turnId unknown → fixed literal (never random), aligning omnigent `_response_id`.
956
- currentResponseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
1410
+ // turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
1411
+ const responseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
1412
+ if (normalizer && currentResponseId === responseId)
1413
+ return normalizer;
1414
+ currentResponseId = responseId;
957
1415
  normalizer = new SessionNormalizer({
958
1416
  sessionId: currentSessionId,
959
- responseId: currentResponseId,
1417
+ responseId,
960
1418
  model,
961
1419
  });
962
1420
  return normalizer;
963
1421
  };
1422
+ const forwardInteraction = (event) => {
1423
+ const n = normalizer ?? startNormalizer(event.turnId);
1424
+ const agentEvent = event.type === "requested"
1425
+ ? { type: "interaction_requested", interaction: event.request }
1426
+ : event.type === "resolved"
1427
+ ? {
1428
+ type: "interaction_resolved",
1429
+ interactionId: event.interactionId,
1430
+ resolution: event.resolution,
1431
+ }
1432
+ : {
1433
+ type: "interaction_cancelled",
1434
+ interactionId: event.interactionId,
1435
+ ...(event.reason ? { reason: event.reason } : {}),
1436
+ };
1437
+ for (const se of n.next(agentEvent))
1438
+ emit(se);
1439
+ };
964
1440
  let markReady;
965
1441
  const ready = new Promise((resolve) => {
966
1442
  markReady = resolve;
@@ -970,19 +1446,26 @@ export class LocalAgentHost {
970
1446
  cwd,
971
1447
  bridgeDir,
972
1448
  injectLock: Promise.resolve(),
1449
+ pendingImageInputs: new Map(),
973
1450
  ready,
974
1451
  markReady,
975
1452
  discoveredReady: false,
976
1453
  stopped: false,
977
1454
  launchModel: model,
1455
+ ...(liveCfg.instructions ? { launchInstructions: liveCfg.instructions } : {}),
978
1456
  launchExtraArgs,
1457
+ ...(permissionMode ? { permissionMode } : {}),
979
1458
  ...(skillPlugin?.cleanup ? { skillCleanup: skillPlugin.cleanup } : {}),
980
1459
  };
981
1460
  const sink = {
982
1461
  onTurnStart: (turnId) => startNormalizer(turnId),
983
1462
  onUserMessage: (text) => {
984
1463
  const n = normalizer ?? startNormalizer();
985
- 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))
986
1469
  emit(se);
987
1470
  },
988
1471
  onTerminalCommand: (cmd) => {
@@ -996,6 +1479,7 @@ export class LocalAgentHost {
996
1479
  for (const se of n.next(event))
997
1480
  emit(se);
998
1481
  },
1482
+ onInteraction: forwardInteraction,
999
1483
  onTurnEnd: (usage) => {
1000
1484
  if (!normalizer)
1001
1485
  return;
@@ -1007,6 +1491,7 @@ export class LocalAgentHost {
1007
1491
  for (const se of normalizer.next({ type: "done" }))
1008
1492
  emit(se);
1009
1493
  normalizer = null;
1494
+ currentResponseId = undefined;
1010
1495
  // One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
1011
1496
  // cleared by the next turn's first item). statusLine pre-computes the %.
1012
1497
  const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
@@ -1033,12 +1518,17 @@ export class LocalAgentHost {
1033
1518
  });
1034
1519
  }
1035
1520
  },
1036
- onTurnError: (error) => {
1521
+ onTurnError: () => {
1037
1522
  if (!normalizer)
1038
1523
  return;
1039
- 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
+ }))
1040
1529
  emit(se);
1041
1530
  normalizer = null;
1531
+ currentResponseId = undefined;
1042
1532
  },
1043
1533
  onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, opts?.agentName ?? record?.agent, claudeSessionId),
1044
1534
  onSessionRotated: (kind, claudeSessionId) => {
@@ -1113,10 +1603,10 @@ export class LocalAgentHost {
1113
1603
  .catch(() => undefined);
1114
1604
  live.markReady();
1115
1605
  }
1116
- /** Inject a web message into a claude-native pane via tmux (omnigent recipe),
1606
+ /** Inject a web message into a claude-native pane via tmux (reference implementation recipe),
1117
1607
  * serialized per session. Parks until the thread is ready AND the tmux injector
1118
1608
  * is (re)attached, then pastes; `injectViaTerminal` RAISES if the prompt never
1119
- * appears (omnigent RAISE), so a not-ready pane is a hard error — NOT a
1609
+ * appears (reference implementation RAISE), so a not-ready pane is a hard error — NOT a
1120
1610
  * fall-through-to-run signal. Returns {@link InjectOutcome}. */
1121
1611
  injectClaude(live, localThreadId, text) {
1122
1612
  const run = live.injectLock.then(async () => {
@@ -1133,7 +1623,11 @@ export class LocalAgentHost {
1133
1623
  const abort = new AbortController();
1134
1624
  live.injectAbort = abort;
1135
1625
  try {
1136
- 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
+ });
1137
1631
  return ok ? "injected" : "failed";
1138
1632
  }
1139
1633
  catch {
@@ -1150,7 +1644,7 @@ export class LocalAgentHost {
1150
1644
  /** Park until the claude session's tmux injector is (re)attached by the
1151
1645
  * runner-child, or the deadline passes. Pane relaunch re-attaches it via
1152
1646
  * {@link attachTerminalInjector}; without this a message during that window
1153
- * would fail and (pre-omnigent) fall through to a second output path. */
1647
+ * would fail and (pre-reference implementation) fall through to a second output path. */
1154
1648
  async waitInjector(live, timeoutMs) {
1155
1649
  const deadline = Date.now() + timeoutMs;
1156
1650
  while (!live.injector && Date.now() < deadline) {
@@ -1166,15 +1660,6 @@ export class LocalAgentHost {
1166
1660
  if (live)
1167
1661
  live.injector = injector;
1168
1662
  }
1169
- /** The PermissionRequest hook's server target from the runner-child env (the
1170
- * daemon control URL + token), or undefined when unset — then no permission
1171
- * hook is registered and claude surfaces its own TUI prompt in the pane. */
1172
- claudePermissionConfig(localThreadId) {
1173
- const serverUrl = process.env.RYNX_CONTROL_URL?.trim();
1174
- if (!serverUrl)
1175
- return undefined;
1176
- return { sessionId: localThreadId, serverUrl };
1177
- }
1178
1663
  /** List the models a runtime exposes (App Server for codex/traex; static for claude). */
1179
1664
  async listModels(runtime) {
1180
1665
  const resolved = runtime ?? this.defaultRuntime;
@@ -1251,6 +1736,7 @@ export class LocalAgentHost {
1251
1736
  codexSessionId: forkedThreadId,
1252
1737
  cwd: record.cwd,
1253
1738
  model: record.model,
1739
+ reasoningEffort: record.reasoningEffort,
1254
1740
  runtime,
1255
1741
  updatedAt: new Date().toISOString(),
1256
1742
  });
@@ -1299,8 +1785,27 @@ function raceReady(ready, timeoutMs) {
1299
1785
  clearTimeout(timer);
1300
1786
  });
1301
1787
  }
1302
- export function getCodexModel(config, override) {
1303
- return override?.trim() || config.CODEX_MODEL;
1788
+ function liveConfigSignature(opts, runtime, cwd) {
1789
+ return JSON.stringify(stableValue({
1790
+ runtime,
1791
+ cwd,
1792
+ agentName: opts?.agentName,
1793
+ agentSpec: opts?.agentSpec,
1794
+ }));
1795
+ }
1796
+ function stableValue(value) {
1797
+ if (Array.isArray(value))
1798
+ return value.map(stableValue);
1799
+ if (value && typeof value === "object") {
1800
+ const out = {};
1801
+ for (const key of Object.keys(value).sort()) {
1802
+ const item = value[key];
1803
+ if (item !== undefined)
1804
+ out[key] = stableValue(item);
1805
+ }
1806
+ return out;
1807
+ }
1808
+ return value;
1304
1809
  }
1305
1810
  export { createCodexChildEnv };
1306
1811
  export function isUnsupportedMethodError(error) {
@@ -1316,7 +1821,7 @@ export function isUnsupportedMethodError(error) {
1316
1821
  }
1317
1822
  /** A `thread/resume` failure meaning the thread has no (or an empty) rollout yet
1318
1823
  * — a fresh TUI thread before its first turn. Retryable (park until active).
1319
- * Mirrors omnigent's `_is_thread_not_ready_error`. */
1824
+ * Mirrors reference implementation's `_is_thread_not_ready_error`. */
1320
1825
  export function isThreadNotReadyError(error) {
1321
1826
  const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
1322
1827
  return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));