@rynx-ai/runtime 0.1.0 → 0.1.9

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 (53) 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 +291 -39
  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 +78 -5
  9. package/dist/claude/native-integration.js +417 -26
  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 +3 -3
  14. package/dist/codex/rollout-synth.js +1 -1
  15. package/dist/codex-app-server/client.d.ts +26 -40
  16. package/dist/codex-app-server/client.js +1128 -99
  17. package/dist/codex-app-server/forwarder.d.ts +7 -7
  18. package/dist/codex-app-server/forwarder.js +11 -5
  19. package/dist/codex-app-server/mapping.d.ts +1 -1
  20. package/dist/codex-app-server/mapping.js +27 -2
  21. package/dist/codex-app-server/protocol.d.ts +238 -4
  22. package/dist/codex-app-server/transport.d.ts +20 -5
  23. package/dist/codex-app-server/transport.js +93 -40
  24. package/dist/codex-app-server/ws-channel.d.ts +3 -3
  25. package/dist/codex-app-server/ws-channel.js +23 -7
  26. package/dist/codex-child-env.js +33 -0
  27. package/dist/codex-home.d.ts +6 -6
  28. package/dist/codex-home.js +8 -9
  29. package/dist/codex-session-store.d.ts +2 -1
  30. package/dist/host.d.ts +34 -33
  31. package/dist/host.js +531 -91
  32. package/dist/index.d.ts +4 -3
  33. package/dist/index.js +1 -1
  34. package/dist/interactions.d.ts +61 -0
  35. package/dist/interactions.js +236 -0
  36. package/dist/models-catalog.d.ts +1 -1
  37. package/dist/models-catalog.js +1 -1
  38. package/dist/runner/child.d.ts +9 -1
  39. package/dist/runner/child.js +93 -15
  40. package/dist/runner/manager.d.ts +59 -10
  41. package/dist/runner/manager.js +385 -41
  42. package/dist/runner/protocol.d.ts +18 -7
  43. package/dist/runner-main.js +9 -6
  44. package/dist/runtime-status.js +1 -1
  45. package/dist/terminal/claude-tui.d.ts +8 -3
  46. package/dist/terminal/claude-tui.js +6 -2
  47. package/dist/terminal/codex-tui.d.ts +3 -3
  48. package/dist/terminal/codex-tui.js +1 -1
  49. package/dist/terminal/registry.d.ts +1 -1
  50. package/dist/terminal/registry.js +1 -1
  51. package/dist/terminal/tmux.d.ts +6 -6
  52. package/dist/terminal/tmux.js +10 -10
  53. package/package.json +3 -3
package/dist/host.js CHANGED
@@ -18,9 +18,10 @@ 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 { claudeBridgeDir, prepareClaudeBridgeDir, removeManagedClaudeSettings, writeManagedClaudeSettings, } from "./claude/native-bridge.js";
22
22
  import { ClaudeLiveSession, injectViaTerminal, } from "./claude/native-integration.js";
23
23
  import { buildClaudeHookSettings } from "./claude/native-hooks.js";
24
+ import { buildManagedClaudeSettings } from "./claude/settings.js";
24
25
  import { FileCodexSessionStore, resolveCodexSessionStorePath, } from "./codex-session-store.js";
25
26
  import { AgentRuntimeError as CodexRuntimeError } from "@rynx-ai/core";
26
27
  import { newSessionId as makeSessionId } from "@rynx-ai/core";
@@ -161,11 +162,12 @@ export class SpawnCodexCommandRunner {
161
162
  }
162
163
  }
163
164
  /** Fraction of an agent's `contextWindow` budget at which the orchestrator
164
- * emits a one-time `context.threshold` observability signal. Mirrors omnigent's
165
+ * emits a one-time `context.threshold` observability signal. Mirrors reference implementation's
165
166
  * compaction `trigger_threshold` (0.8). codex self-compacts via its token
166
167
  * budget (set proactively); claude has no programmatic compaction, so this
167
168
  * signal is the lever consumers surface to the user. */
168
169
  const CONTEXT_WARN_RATIO = 0.8;
170
+ const MAX_CODEX_SETTLED_INTERACTIONS = 512;
169
171
  /**
170
172
  * The `OPENAI_*` retry env for a codex-lineage app-server spawn, or `undefined`
171
173
  * when the budget declares no retry or the runtime is claude. claude applies
@@ -217,7 +219,7 @@ export class LocalAgentHost {
217
219
  injectedAppServerClient;
218
220
  clock;
219
221
  backendIdleTtlMs;
220
- // Builds the SEPARATE forwarder connection for a live codex session (omnigent's
222
+ // Builds the SEPARATE forwarder connection for a live codex session (reference implementation's
221
223
  // multi-connection model). Defaults to an ExternalWsChannel client attached to
222
224
  // the backend's app-server; tests inject a fake.
223
225
  forwarderClientFactory;
@@ -241,6 +243,9 @@ export class LocalAgentHost {
241
243
  // Per-session claude-native live forwarders (parallel to liveSessions; claude
242
244
  // has no app-server, so it tails the bridge + transcript instead of RPC).
243
245
  liveClaudeSessions = new Map();
246
+ /** Claude forwarders stopped before their terminal is killed. Runner shutdown
247
+ * finalizes these synchronously afterwards to scrub raw interaction answers. */
248
+ pendingClaudeFinalizers = new Set();
244
249
  // In-flight `ensureLiveCodexSession` calls, so concurrent triggers (a web
245
250
  // `live.ensure` racing a `term.open`) share ONE forwarder — never two.
246
251
  liveEnsuring = new Map();
@@ -337,7 +342,7 @@ export class LocalAgentHost {
337
342
  const policy = approvalPolicy === "never" ? "auto-approve-session" : "auto-decline";
338
343
  // Native co-drive: run the app-server on a loopback ws (multi-client) so a
339
344
  // `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.
345
+ // client). This is the sole transport — always native, aligning with reference implementation.
341
346
  // Point it at the private CODEX_HOME so it shares the TUI's login/settings and
342
347
  // never trips the real home's update/NUX state.
343
348
  const channel = new WsRpcChannel({
@@ -348,23 +353,50 @@ export class LocalAgentHost {
348
353
  return new CodexAppServerClient({
349
354
  channel,
350
355
  approvalDecisionPolicy: policy,
351
- interactiveApprovals: this.config.AGENT_INTERACTIVE_APPROVALS,
352
356
  });
353
357
  }
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;
358
+ /** Resolve a pending native question/approval without opening a new Turn. */
359
+ async resolveInteraction(localThreadId, interactionId, resolution) {
360
+ const claude = this.liveClaudeSessions.get(localThreadId);
361
+ if (claude) {
362
+ return claude.forwarder.resolveInteraction(interactionId, resolution);
365
363
  }
366
- const client = this.getBackend(runtime).appServerClient;
367
- return client ? client.resolveApproval(approvalId, decision) : false;
364
+ const live = this.liveSessions.get(localThreadId);
365
+ if (!live)
366
+ return { disposition: "not_found" };
367
+ if (live.settledInteractions.has(interactionId)) {
368
+ return { disposition: "already_resolved" };
369
+ }
370
+ if (live.interactionRecoveries.has(interactionId)) {
371
+ return {
372
+ disposition: "invalid",
373
+ message: "interaction delivery is recovering; retry shortly",
374
+ };
375
+ }
376
+ const submit = (client) => {
377
+ // Store before entering the client: a transport can report a synchronous
378
+ // write failure, and failover must already have the exact accepted answer
379
+ // available for replay. A synchronous terminal event may delete it again.
380
+ const hadPrevious = live.interactionSubmissions.has(interactionId);
381
+ const previous = live.interactionSubmissions.get(interactionId);
382
+ live.interactionSubmissions.set(interactionId, resolution);
383
+ const result = client.resolveInteraction(interactionId, resolution);
384
+ if (result.disposition !== "applied") {
385
+ if (hadPrevious && previous)
386
+ live.interactionSubmissions.set(interactionId, previous);
387
+ else
388
+ live.interactionSubmissions.delete(interactionId);
389
+ }
390
+ return result;
391
+ };
392
+ const owner = live.interactionOwners.get(interactionId);
393
+ if (owner)
394
+ return submit(owner);
395
+ const injected = submit(live.injectClient);
396
+ if (injected.disposition !== "not_found") {
397
+ return injected;
398
+ }
399
+ return submit(live.forwarderClient);
368
400
  }
369
401
  /**
370
402
  * The command to run in a session's live terminal so it co-drives the codex
@@ -382,7 +414,8 @@ export class LocalAgentHost {
382
414
  if (this.liveClaudeSessions.has(localThreadId)) {
383
415
  return this.claudeTerminalSpec(localThreadId, record);
384
416
  }
385
- const runtime = record?.runtime ?? this.defaultRuntime;
417
+ const live = this.liveSessions.get(localThreadId);
418
+ const runtime = record?.runtime ?? live?.runtime ?? this.defaultRuntime;
386
419
  if (runtime === "claude") {
387
420
  return this.claudeTerminalSpec(localThreadId, record);
388
421
  }
@@ -392,7 +425,7 @@ export class LocalAgentHost {
392
425
  throw new CodexRuntimeError(`no app-server for runtime ${runtime}`, 500, "app_server_unavailable");
393
426
  }
394
427
  // 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.
428
+ // silent bash fallback (aligns with reference implementation). ensureInitialized throws on its own.
396
429
  await client.ensureInitialized();
397
430
  const remoteUrl = client.terminalRemoteUrl();
398
431
  if (!remoteUrl) {
@@ -406,9 +439,15 @@ export class LocalAgentHost {
406
439
  : [];
407
440
  // Apply the agent-spec model to the TUI (the private CODEX_HOME config.toml is
408
441
  // shared per-runtime, so a per-session model rides a launch `-c model=` instead).
409
- const launchModel = this.liveSessions.get(localThreadId)?.model;
442
+ const launchModel = live?.model;
410
443
  if (launchModel)
411
444
  configOverrides.push(`model=${JSON.stringify(launchModel)}`);
445
+ const launchEffort = live?.reasoningEffort ?? record?.reasoningEffort;
446
+ if (launchEffort)
447
+ configOverrides.push(`model_reasoning_effort=${JSON.stringify(launchEffort)}`);
448
+ if (live?.instructions) {
449
+ configOverrides.push(`developer_instructions=${JSON.stringify(live.instructions)}`);
450
+ }
412
451
  return {
413
452
  command: resolveRuntimeBinary(runtime),
414
453
  args: buildCodexRemoteArgs({
@@ -416,7 +455,7 @@ export class LocalAgentHost {
416
455
  ...(record?.codexSessionId ? { threadId: record.codexSessionId } : {}),
417
456
  configOverrides,
418
457
  }),
419
- cwd: record?.cwd ?? process.cwd(),
458
+ cwd: record?.cwd ?? live?.cwd ?? process.cwd(),
420
459
  // Share the app-server's private CODEX_HOME so the TUI inherits the same
421
460
  // login/settings and skips the real home's update/NUX prompt. RYNX_SESSION_ID
422
461
  // scopes agent-run CLIs (rynx-emulator) to this session at the daemon.
@@ -429,7 +468,7 @@ export class LocalAgentHost {
429
468
  }
430
469
  /**
431
470
  * Bring up (idempotently) a session's persistent codex forwarder and emit its
432
- * mirrored {@link SessionEvent}s. omnigent's codex-native single-writer model:
471
+ * mirrored {@link SessionEvent}s. reference implementation's codex-native single-writer model:
433
472
  * this forwarder is the SOLE producer of the session's canonical events — every
434
473
  * turn on the thread, web-composer-injected AND co-driving-`codex --remote`-TUI
435
474
  * initiated. Each turn gets a {@link SessionNormalizer} whose `responseId` is
@@ -444,7 +483,10 @@ export class LocalAgentHost {
444
483
  * to display it. The thread id is persisted so `codexTerminalSpec` resumes it.
445
484
  */
446
485
  async ensureLiveCodexSession(localThreadId, emit, opts) {
447
- if (this.liveSessions.has(localThreadId) || this.liveClaudeSessions.has(localThreadId))
486
+ const live = this.liveSessions.get(localThreadId);
487
+ if (live)
488
+ return this.refreshLiveCodexSession(localThreadId, live, opts);
489
+ if (this.liveClaudeSessions.has(localThreadId))
448
490
  return true;
449
491
  // Dedupe concurrent triggers so only one forwarder is ever created per
450
492
  // session (two would double-mirror every turn).
@@ -461,7 +503,7 @@ export class LocalAgentHost {
461
503
  }
462
504
  }
463
505
  async startLiveCodexSession(localThreadId, emit, opts) {
464
- // cancel-before-recreate (omnigent Layer 2, runner/app.py `_cancel_auto_forwarder_task`):
506
+ // cancel-before-recreate (reference implementation Layer 2, runner/app.py `_cancel_auto_forwarder_task`):
465
507
  // never run a new forwarder alongside a stale one for this id. ensureLive's has()
466
508
  // fast-path normally makes this a no-op; it closes the stop→re-ensure race that
467
509
  // could otherwise leave a half-dead forwarder mirroring in parallel.
@@ -475,7 +517,8 @@ export class LocalAgentHost {
475
517
  const runtime = record?.runtime ?? opts?.runtime ?? this.defaultRuntime;
476
518
  if (runtime === "claude")
477
519
  return this.startLiveClaudeSession(localThreadId, emit, record, opts);
478
- const cwd = record?.cwd ?? process.cwd();
520
+ const cwd = record?.cwd ?? opts?.cwd ?? process.cwd();
521
+ const configSignature = liveConfigSignature(opts, runtime, cwd);
479
522
  // Spec-load errors (a rejected legacy `skills` form, malformed JSON, …) are
480
523
  // logged with the agent context and fail the live start loudly — never a
481
524
  // bare mystery `false`.
@@ -489,7 +532,7 @@ export class LocalAgentHost {
489
532
  }
490
533
  // Link this session's skills into its private CODEX_HOME's `skills/` BEFORE the
491
534
  // 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
535
+ // discovers them — reference implementation's filesystem mechanism. The live thread is created by
493
536
  // the TUI, not rynx, so skills can't ride `threadStart.developerInstructions` (as
494
537
  // the non-live run did); `$CODEX_HOME/skills/` is the only channel that reaches it.
495
538
  // The agent spec is the single skill source: its refs were replayed into a
@@ -510,25 +553,31 @@ export class LocalAgentHost {
510
553
  return false;
511
554
  };
512
555
  const injectClient = this.getBackend(runtime).appServerClient;
513
- if (!injectClient)
556
+ if (!injectClient) {
557
+ console.error(`[codex-live] session=${localThreadId} runtime=${runtime} no app-server client`);
514
558
  return abandon();
559
+ }
515
560
  try {
516
561
  await injectClient.ensureInitialized();
517
562
  }
518
- catch {
563
+ catch (err) {
564
+ console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server init failed: ${err instanceof Error ? err.message : String(err)}`);
519
565
  return abandon();
520
566
  }
521
567
  const appServerUrl = injectClient.terminalRemoteUrl();
522
- if (!appServerUrl)
568
+ if (!appServerUrl) {
569
+ console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server has no remote url`);
523
570
  return abandon(); // needs the ws app-server (live-terminal mode)
524
- // omnigent multi-connection: a SEPARATE forwarder connection to the SAME
571
+ }
572
+ // reference implementation multi-connection: a SEPARATE forwarder connection to the SAME
525
573
  // app-server, which `thread/resume`s to subscribe to the thread's item/turn
526
574
  // notifications (the backend client injects; this one only observes).
527
575
  const forwarderClient = this.forwarderClientFactory(appServerUrl);
528
576
  try {
529
577
  await forwarderClient.ensureInitialized();
530
578
  }
531
- catch {
579
+ catch (err) {
580
+ console.error(`[codex-live] session=${localThreadId} runtime=${runtime} forwarder init failed: ${err instanceof Error ? err.message : String(err)}`);
532
581
  return abandon();
533
582
  }
534
583
  // Apply the agent-spec model (skills were applied above via `$CODEX_HOME/skills/`).
@@ -536,6 +585,10 @@ export class LocalAgentHost {
536
585
  // the runtime default). The model rides a launch `-c model=` on the TUI (see
537
586
  // `codexTerminalSpec`) / `turn/start` on injection, since rynx doesn't create the thread.
538
587
  const model = getCodexModel(this.config, record?.model ?? liveCfg.model);
588
+ const hasCurrentEffortSource = Boolean(opts?.agentName || opts?.agentSpec || opts?.reasoningEffort);
589
+ const reasoningEffort = hasCurrentEffortSource
590
+ ? liveCfg.reasoningEffort
591
+ : record?.reasoningEffort ?? liveCfg.reasoningEffort;
539
592
  let markReady;
540
593
  const ready = new Promise((resolve) => {
541
594
  markReady = resolve;
@@ -547,6 +600,8 @@ export class LocalAgentHost {
547
600
  runtime,
548
601
  cwd,
549
602
  model,
603
+ reasoningEffort,
604
+ ...(liveCfg.instructions ? { instructions: liveCfg.instructions } : {}),
550
605
  threadId: record?.codexSessionId ?? null,
551
606
  ready,
552
607
  markReady,
@@ -554,19 +609,228 @@ export class LocalAgentHost {
554
609
  subscribing: false,
555
610
  stopped: false,
556
611
  skillsCleanup: liveCfg.skillsCleanup,
612
+ configSignature,
613
+ interactionOwners: new Map(),
614
+ interactionStandbys: new Map(),
615
+ interactionSubmissions: new Map(),
616
+ canonicalInteractions: new Set(),
617
+ settledInteractions: new Set(),
618
+ interactionRecoveries: new Map(),
619
+ interactionFailedClients: new Map(),
620
+ disconnectedClients: new Set(),
557
621
  };
558
622
  let normalizer = null;
623
+ let currentResponseId = null;
559
624
  const startNormalizer = (turnId) => {
625
+ const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
626
+ if (normalizer && currentResponseId === responseId)
627
+ return normalizer;
628
+ currentResponseId = responseId;
560
629
  normalizer = new SessionNormalizer({
561
630
  sessionId: localThreadId,
562
631
  // turnId unknown → fixed literal (NEVER random): items arriving before
563
632
  // `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",
633
+ // ids (the DB `resp_codex_<uuid>` doubling). Mirrors reference implementation `_response_id`.
634
+ responseId,
566
635
  model,
567
636
  });
568
637
  return normalizer;
569
638
  };
639
+ const rememberSettledInteraction = (interactionId) => {
640
+ live.settledInteractions.add(interactionId);
641
+ if (live.settledInteractions.size <= MAX_CODEX_SETTLED_INTERACTIONS)
642
+ return;
643
+ const oldest = live.settledInteractions.values().next().value;
644
+ if (oldest)
645
+ live.settledInteractions.delete(oldest);
646
+ };
647
+ const forwardInteraction = (event) => {
648
+ const interactionId = event.type === "requested"
649
+ ? event.request.interactionId
650
+ : event.interactionId;
651
+ if (event.type === "requested") {
652
+ if (live.canonicalInteractions.has(interactionId) ||
653
+ live.settledInteractions.has(interactionId))
654
+ return;
655
+ live.canonicalInteractions.add(interactionId);
656
+ }
657
+ else {
658
+ // The forwarder may have completed the Turn before another app-server
659
+ // connection publishes its terminal copy. Never create a fresh
660
+ // normalizer/response for that late duplicate.
661
+ if (!live.canonicalInteractions.delete(interactionId))
662
+ return;
663
+ rememberSettledInteraction(interactionId);
664
+ }
665
+ const turnId = event.turnId ?? live.forwarder?.currentTurnId() ?? undefined;
666
+ const n = normalizer ?? startNormalizer(turnId);
667
+ const agentEvent = event.type === "requested"
668
+ ? { type: "interaction_requested", interaction: event.request }
669
+ : event.type === "resolved"
670
+ ? {
671
+ type: "interaction_resolved",
672
+ interactionId: event.interactionId,
673
+ resolution: event.resolution,
674
+ }
675
+ : {
676
+ type: "interaction_cancelled",
677
+ interactionId: event.interactionId,
678
+ ...(event.reason ? { reason: event.reason } : {}),
679
+ };
680
+ for (const se of n.next(agentEvent))
681
+ emit(se);
682
+ };
683
+ const clearRecovery = (interactionId) => {
684
+ live.interactionRecoveries.delete(interactionId);
685
+ };
686
+ const clearInteractionRouting = (interactionId) => {
687
+ clearRecovery(interactionId);
688
+ live.interactionOwners.delete(interactionId);
689
+ live.interactionStandbys.delete(interactionId);
690
+ live.interactionSubmissions.delete(interactionId);
691
+ live.interactionFailedClients.delete(interactionId);
692
+ };
693
+ const markInteractionClientFailed = (interactionId, client) => {
694
+ const failed = live.interactionFailedClients.get(interactionId) ?? new Set();
695
+ failed.add(client);
696
+ live.interactionFailedClients.set(interactionId, failed);
697
+ };
698
+ const interactionClientUnavailable = (interactionId, client) => live.disconnectedClients.has(client) ||
699
+ live.interactionFailedClients.get(interactionId)?.has(client) === true;
700
+ const allInteractionClientsUnavailable = (interactionId) => [live.injectClient, live.forwarderClient].every((client) => interactionClientUnavailable(interactionId, client));
701
+ const finishRecovery = (interactionId, event) => {
702
+ clearInteractionRouting(interactionId);
703
+ forwardInteraction(event);
704
+ };
705
+ const beginRecovery = (interactionId, event) => {
706
+ live.interactionOwners.delete(interactionId);
707
+ live.interactionRecoveries.set(interactionId, { event });
708
+ };
709
+ const promoteInteractionOwner = (interactionId, replacement) => {
710
+ const recovery = live.interactionRecoveries.get(interactionId);
711
+ live.interactionOwners.set(interactionId, replacement);
712
+ const submission = live.interactionSubmissions.get(interactionId);
713
+ if (!submission) {
714
+ clearRecovery(interactionId);
715
+ return true;
716
+ }
717
+ const replayed = replacement.resolveInteraction(interactionId, submission);
718
+ // A write failure can synchronously re-enter the listener and remove this
719
+ // owner. Only clear recovery when the replacement still owns the request.
720
+ if (replayed.disposition === "applied" &&
721
+ live.interactionOwners.get(interactionId) === replacement) {
722
+ clearRecovery(interactionId);
723
+ return true;
724
+ }
725
+ if (live.interactionOwners.get(interactionId) === replacement) {
726
+ live.interactionOwners.delete(interactionId);
727
+ }
728
+ if (recovery &&
729
+ live.canonicalInteractions.has(interactionId) &&
730
+ !live.settledInteractions.has(interactionId) &&
731
+ !live.interactionRecoveries.has(interactionId)) {
732
+ live.interactionRecoveries.set(interactionId, recovery);
733
+ }
734
+ return false;
735
+ };
736
+ const bindInteractionClient = (client) => {
737
+ client.setInteractionListener((event) => {
738
+ const interactionId = event.type === "requested"
739
+ ? event.request.interactionId
740
+ : event.interactionId;
741
+ const owner = live.interactionOwners.get(interactionId);
742
+ if (event.type === "requested") {
743
+ // Receiving a native request proves this connection is alive and can
744
+ // be retried even if an earlier copy failed on it.
745
+ live.disconnectedClients.delete(client);
746
+ const failed = live.interactionFailedClients.get(interactionId);
747
+ failed?.delete(client);
748
+ if (failed?.size === 0)
749
+ live.interactionFailedClients.delete(interactionId);
750
+ if (live.settledInteractions.has(interactionId))
751
+ return;
752
+ const recovery = live.interactionRecoveries.get(interactionId);
753
+ if (recovery) {
754
+ promoteInteractionOwner(interactionId, client);
755
+ return;
756
+ }
757
+ if (owner && owner !== client) {
758
+ const standbys = live.interactionStandbys.get(interactionId) ?? new Set();
759
+ standbys.add(client);
760
+ live.interactionStandbys.set(interactionId, standbys);
761
+ return;
762
+ }
763
+ live.interactionOwners.set(interactionId, client);
764
+ forwardInteraction(event);
765
+ return;
766
+ }
767
+ const recoverableOwnerFailure = event.type === "cancelled" &&
768
+ (event.reason === "app_server_disconnected" || event.reason === "response_write_failed");
769
+ if (recoverableOwnerFailure)
770
+ markInteractionClientFailed(interactionId, client);
771
+ if (!owner || owner !== client) {
772
+ const standbys = live.interactionStandbys.get(interactionId);
773
+ standbys?.delete(client);
774
+ if (standbys?.size === 0)
775
+ live.interactionStandbys.delete(interactionId);
776
+ const recovery = live.interactionRecoveries.get(interactionId);
777
+ if (recoverableOwnerFailure &&
778
+ live.canonicalInteractions.has(interactionId) &&
779
+ allInteractionClientsUnavailable(interactionId)) {
780
+ finishRecovery(interactionId, recovery?.event ?? event);
781
+ }
782
+ return;
783
+ }
784
+ if (recoverableOwnerFailure) {
785
+ const standbys = live.interactionStandbys.get(interactionId);
786
+ while (standbys && standbys.size > 0) {
787
+ const replacement = standbys.values().next().value;
788
+ if (!replacement)
789
+ break;
790
+ standbys.delete(replacement);
791
+ if (interactionClientUnavailable(interactionId, replacement))
792
+ continue;
793
+ if (promoteInteractionOwner(interactionId, replacement)) {
794
+ if (standbys.size === 0)
795
+ live.interactionStandbys.delete(interactionId);
796
+ return;
797
+ }
798
+ if (live.settledInteractions.has(interactionId))
799
+ return;
800
+ }
801
+ if (!live.settledInteractions.has(interactionId)) {
802
+ if (allInteractionClientsUnavailable(interactionId)) {
803
+ finishRecovery(interactionId, event);
804
+ }
805
+ else {
806
+ beginRecovery(interactionId, event);
807
+ }
808
+ return;
809
+ }
810
+ }
811
+ clearInteractionRouting(interactionId);
812
+ forwardInteraction(event);
813
+ });
814
+ client.setConnectionListener((state) => {
815
+ if (state === "connected") {
816
+ live.disconnectedClients.delete(client);
817
+ return;
818
+ }
819
+ live.disconnectedClients.add(client);
820
+ for (const [interactionId, recovery] of [...live.interactionRecoveries]) {
821
+ if (allInteractionClientsUnavailable(interactionId)) {
822
+ finishRecovery(interactionId, recovery.event);
823
+ }
824
+ }
825
+ });
826
+ };
827
+ const closeCanonicalInteractions = () => {
828
+ for (const interactionId of live.canonicalInteractions) {
829
+ rememberSettledInteraction(interactionId);
830
+ clearInteractionRouting(interactionId);
831
+ }
832
+ live.canonicalInteractions.clear();
833
+ };
570
834
  const sink = {
571
835
  onTurnStart: (turnId) => startNormalizer(turnId),
572
836
  onUserMessage: (text) => {
@@ -580,6 +844,7 @@ export class LocalAgentHost {
580
844
  emit(se);
581
845
  },
582
846
  onTurnEnd: (usage) => {
847
+ closeCanonicalInteractions();
583
848
  if (!normalizer)
584
849
  return;
585
850
  if (usage)
@@ -588,13 +853,16 @@ export class LocalAgentHost {
588
853
  for (const se of normalizer.next({ type: "done" }))
589
854
  emit(se);
590
855
  normalizer = null;
856
+ currentResponseId = null;
591
857
  },
592
858
  onTurnError: (error) => {
859
+ closeCanonicalInteractions();
593
860
  if (!normalizer)
594
861
  return;
595
862
  for (const se of normalizer.fail({ code: "codex_error", message: error.message, source: "execution" }))
596
863
  emit(se);
597
864
  normalizer = null;
865
+ currentResponseId = null;
598
866
  },
599
867
  // The TUI creates the thread; the forwarder connection sees its broadcast
600
868
  // `thread/started`. Persist the id, unblock injection, and start the
@@ -604,6 +872,8 @@ export class LocalAgentHost {
604
872
  };
605
873
  const forwarder = new CodexSessionForwarder(forwarderClient, sink);
606
874
  live.forwarder = forwarder;
875
+ bindInteractionClient(injectClient);
876
+ bindInteractionClient(forwarderClient);
607
877
  this.liveSessions.set(localThreadId, live);
608
878
  forwarder.start();
609
879
  // A resumed session already has a thread id — bind immediately; a fresh
@@ -613,6 +883,65 @@ export class LocalAgentHost {
613
883
  }
614
884
  return true;
615
885
  }
886
+ async refreshLiveCodexSession(localThreadId, live, opts) {
887
+ const desiredRuntime = opts?.runtime ?? live.runtime;
888
+ if (desiredRuntime !== live.runtime)
889
+ return false;
890
+ const configSignature = liveConfigSignature(opts, live.runtime, live.cwd);
891
+ if (live.configSignature === configSignature)
892
+ return true;
893
+ let liveCfg;
894
+ try {
895
+ liveCfg = await this.resolveLiveAgentConfig(localThreadId, opts, live.runtime, live.cwd);
896
+ }
897
+ catch (err) {
898
+ console.error(`[agent-spec] session=${localThreadId} agent=${opts?.agentName ?? "-"} failed to refresh: ${err instanceof Error ? err.message : String(err)}`);
899
+ return false;
900
+ }
901
+ const previousCleanup = live.skillsCleanup;
902
+ try {
903
+ const liveSkills = liveCfg.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
904
+ populateCodexSkills(this.codexHome(), liveSkills);
905
+ void previousCleanup?.().catch(() => undefined);
906
+ this.sessionSandbox = liveCfg.sandbox;
907
+ this.sessionApprovalPolicy = liveCfg.approvalPolicy;
908
+ live.model = getCodexModel(this.config, liveCfg.model);
909
+ live.reasoningEffort = liveCfg.reasoningEffort;
910
+ live.instructions = liveCfg.instructions;
911
+ live.skillsCleanup = liveCfg.skillsCleanup;
912
+ live.configSignature = configSignature;
913
+ if (live.threadId) {
914
+ // Keep already-running TUI turns aligned too; turn/start below carries
915
+ // the same overrides for web injection. Older app-servers may not have
916
+ // thread/settings/update, so refresh remains best-effort.
917
+ await live.injectClient
918
+ .threadSettingsUpdate({
919
+ threadId: live.threadId,
920
+ model: live.model,
921
+ effort: live.reasoningEffort ?? null,
922
+ })
923
+ .catch(() => undefined);
924
+ void this.sessionStore
925
+ .set({
926
+ localThreadId,
927
+ codexSessionId: live.threadId,
928
+ cwd: live.cwd,
929
+ model: live.model,
930
+ reasoningEffort: live.reasoningEffort,
931
+ runtime: live.runtime,
932
+ agent: opts?.agentName,
933
+ updatedAt: new Date().toISOString(),
934
+ })
935
+ .catch(() => undefined);
936
+ }
937
+ return true;
938
+ }
939
+ catch (err) {
940
+ void liveCfg.skillsCleanup().catch(() => undefined);
941
+ console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} skill refresh failed: ${err instanceof Error ? err.message : String(err)}`);
942
+ return false;
943
+ }
944
+ }
616
945
  /** Bind a session's codex thread id once known (TUI broadcast or store): persist
617
946
  * it, unblock injection, and kick off the resume-subscribe loop (once). */
618
947
  onLiveThreadStarted(live, localThreadId, agent, threadId) {
@@ -628,6 +957,7 @@ export class LocalAgentHost {
628
957
  // Persist the agent-spec-resolved model (was the config default), so a
629
958
  // resume/reconnect keeps running the agent's model, not the global one.
630
959
  model: live.model,
960
+ reasoningEffort: live.reasoningEffort,
631
961
  runtime: live.runtime,
632
962
  agent,
633
963
  updatedAt: new Date().toISOString(),
@@ -637,7 +967,7 @@ export class LocalAgentHost {
637
967
  void this.subscribeUntilReady(live, threadId);
638
968
  }
639
969
  /**
640
- * Subscribe the forwarder connection to a thread (omnigent's
970
+ * Subscribe the forwarder connection to a thread (reference implementation's
641
971
  * `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
642
972
  * turn, so `thread/resume` is retried: park until the forwarder observes the
643
973
  * thread active, then retry WITHOUT `excludeTurns` so the response backfills the
@@ -692,7 +1022,7 @@ export class LocalAgentHost {
692
1022
  return ok;
693
1023
  }
694
1024
  /**
695
- * Inject a user turn into a session's live codex thread — omnigent's
1025
+ * Inject a user turn into a session's live codex thread — reference implementation's
696
1026
  * single-writer web send. `turn/steer` when a turn is open (mid-turn
697
1027
  * supplement), else `turn/start`. NEVER creates a thread (the TUI owns creation;
698
1028
  * this targets the id the forwarder captured). Serialized per session so two
@@ -701,7 +1031,7 @@ export class LocalAgentHost {
701
1031
  * Returns an {@link InjectOutcome}: `notLive` when this session has no live
702
1032
  * forwarder (caller may use the run path); `notReady`/`failed` are hard errors
703
1033
  * the caller reports WITHOUT re-running (re-running double-writes alongside the
704
- * forwarder). Park-until-ready (~60s), aligning omnigent's executor waiting for
1034
+ * forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
705
1035
  * the bridge instead of a short race that falls back to a second output path.
706
1036
  */
707
1037
  async injectMessage(localThreadId, text) {
@@ -712,7 +1042,7 @@ export class LocalAgentHost {
712
1042
  if (!live)
713
1043
  return "notLive";
714
1044
  const run = live.injectLock.then(async () => {
715
- // Park until the thread binds (~60s, omnigent codex_native_executor:177-186),
1045
+ // Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
716
1046
  // not a 20s race that returns false and lets the caller re-run on a 2nd path.
717
1047
  const bound = await this.waitLiveReady(localThreadId, 60_000);
718
1048
  const threadId = live.threadId ?? live.forwarder.threadId();
@@ -730,12 +1060,18 @@ export class LocalAgentHost {
730
1060
  }
731
1061
  // Carry the agent-spec model on the turn so a web-injected turn runs the
732
1062
  // agent's model even if the TUI's config default differs.
733
- await live.injectClient.turnStart({ threadId, input, cwd: live.cwd, model: live.model });
1063
+ await live.injectClient.turnStart({
1064
+ threadId,
1065
+ input,
1066
+ cwd: live.cwd,
1067
+ model: live.model,
1068
+ ...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
1069
+ });
734
1070
  return "injected";
735
1071
  }
736
1072
  catch {
737
1073
  // RPC failed → hard error; caller reports it and does NOT re-run
738
- // (omnigent: inject failure ⇒ response.failed, never local re-run).
1074
+ // (reference implementation: inject failure ⇒ response.failed, never local re-run).
739
1075
  return "failed";
740
1076
  }
741
1077
  });
@@ -745,8 +1081,8 @@ export class LocalAgentHost {
745
1081
  /**
746
1082
  * Interrupt the session's active turn — the web Stop button. codex: the
747
1083
  * 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
1084
+ * codex's own TUI interrupt key sends — reference implementation `_handle_codex_native_interrupt`).
1085
+ * claude: an Escape keystroke into the pane (no app-server — reference implementation
750
1086
  * `inject_interrupt`). Returns false as a no-op when there is no active turn.
751
1087
  */
752
1088
  async interruptLive(localThreadId) {
@@ -780,7 +1116,7 @@ export class LocalAgentHost {
780
1116
  if (!live)
781
1117
  return false;
782
1118
  // Only interrupt an OPEN turn — codex's app-server rejects a stale turnId, and
783
- // omnigent no-ops (204) when no turn is active.
1119
+ // reference implementation no-ops (204) when no turn is active.
784
1120
  if (!live.forwarder.isTurnOpen())
785
1121
  return false;
786
1122
  const threadId = live.threadId ?? live.forwarder.threadId();
@@ -797,12 +1133,19 @@ export class LocalAgentHost {
797
1133
  }
798
1134
  /** Stop + drop a session's live forwarder and its dedicated connection (session
799
1135
  * close / runner shutdown). The backend inject client is shared — left running. */
800
- stopLiveCodexSession(localThreadId) {
1136
+ stopLiveCodexSession(localThreadId, opts = {}) {
801
1137
  const claude = this.liveClaudeSessions.get(localThreadId);
802
1138
  if (claude) {
803
1139
  this.liveClaudeSessions.delete(localThreadId);
804
1140
  claude.stopped = true;
805
1141
  claude.forwarder.stop();
1142
+ if (opts.deferClaudeInteractionCleanup) {
1143
+ this.pendingClaudeFinalizers.add(claude.forwarder);
1144
+ }
1145
+ else {
1146
+ claude.forwarder.finalizeStop();
1147
+ }
1148
+ removeManagedClaudeSettings(claude.bridgeDir);
806
1149
  // Remove the throwaway skills --plugin-dir temp so it doesn't leak per launch.
807
1150
  void claude.skillCleanup?.();
808
1151
  return;
@@ -813,37 +1156,75 @@ export class LocalAgentHost {
813
1156
  this.liveSessions.delete(localThreadId);
814
1157
  live.stopped = true;
815
1158
  live.releaseActive?.();
1159
+ live.injectClient.cancelInteractions("session_stopped");
1160
+ live.forwarderClient.cancelInteractions("session_stopped");
1161
+ live.injectClient.setInteractionListener(null);
1162
+ live.forwarderClient.setInteractionListener(null);
1163
+ live.injectClient.setConnectionListener(null);
1164
+ live.forwarderClient.setConnectionListener(null);
1165
+ live.interactionOwners.clear();
1166
+ live.interactionStandbys.clear();
1167
+ live.interactionSubmissions.clear();
816
1168
  live.forwarder.stop();
1169
+ live.interactionRecoveries.clear();
1170
+ live.interactionFailedClients.clear();
1171
+ live.disconnectedClients.clear();
1172
+ live.canonicalInteractions.clear();
1173
+ live.settledInteractions.clear();
817
1174
  void live.forwarderClient.stop().catch(() => undefined);
818
1175
  // Remove the session-scoped skills dir — the machine keeps zero task residue.
819
1176
  void live.skillsCleanup?.();
820
1177
  }
1178
+ /** Complete the second shutdown phase after the runner has killed all native
1179
+ * terminals and hook subprocesses. Must run before the runner process exits. */
1180
+ finalizeStoppedLiveSessions() {
1181
+ for (const forwarder of this.pendingClaudeFinalizers)
1182
+ forwarder.finalizeStop();
1183
+ this.pendingClaudeFinalizers.clear();
1184
+ }
821
1185
  // --- claude-native live session (parallel to the codex methods above) -----
822
1186
  /** The interactive `claude` TUI spec for a claude-native live session: the real
823
1187
  * `claude` binary with rynx's hooks (`--settings`), resuming the bound session.
824
1188
  * 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.) */
1189
+ * prompt. Questions and PermissionRequest approvals use the runtime-local
1190
+ * generic interaction bridge and remain inside the active Turn. */
827
1191
  claudeTerminalSpec(localThreadId, record) {
828
- const cwd = record?.cwd ?? process.cwd();
1192
+ const live = this.liveClaudeSessions.get(localThreadId);
1193
+ const cwd = record?.cwd ?? live?.cwd ?? process.cwd();
829
1194
  try {
830
1195
  ensureProjectTrusted(cwd);
831
1196
  }
832
1197
  catch {
833
1198
  // A malformed ~/.claude.json shouldn't block launch; claude re-prompts.
834
1199
  }
835
- const settings = buildClaudeHookSettings({
1200
+ const inheritedSettings = buildManagedClaudeSettings(cwd, {});
1201
+ const inheritedPermissions = inheritedSettings.permissions;
1202
+ const inheritedPermissionMode = inheritedPermissions &&
1203
+ typeof inheritedPermissions === "object" &&
1204
+ !Array.isArray(inheritedPermissions) &&
1205
+ typeof inheritedPermissions.defaultMode === "string"
1206
+ ? inheritedPermissions.defaultMode
1207
+ : undefined;
1208
+ const hookPermissionMode = live?.permissionMode ?? inheritedPermissionMode;
1209
+ const hookSettings = buildClaudeHookSettings({
836
1210
  bridgeDir: claudeBridgeDir(localThreadId),
837
- permission: Boolean(process.env.RYNX_CONTROL_URL?.trim()),
1211
+ ...(hookPermissionMode ? { permissionMode: hookPermissionMode } : {}),
838
1212
  messageDisplay: true,
839
1213
  statusLine: true,
840
1214
  });
1215
+ const settings = { ...inheritedSettings, ...hookSettings };
1216
+ const settingsPath = writeManagedClaudeSettings(claudeBridgeDir(localThreadId), settings);
841
1217
  // 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);
1218
+ // carries the agent-spec launch config: model, instructions, and skills.
844
1219
  const args = buildClaudeTuiArgs({
845
- settingsJson: JSON.stringify(settings),
1220
+ settingsJson: settingsPath,
1221
+ // A rynx-managed Session must have one interaction owner. Loading host or
1222
+ // project hooks here would let a second PermissionRequest/PreToolUse hook
1223
+ // resolve the same native request while rynx still exposes it as pending.
1224
+ // Explicit rynx hooks and selected-skill plugin dirs remain enabled.
1225
+ settingSources: "",
846
1226
  model: live?.launchModel ?? resolveRuntimeModel(this.config, "claude", record?.model),
1227
+ ...(live?.launchInstructions ? { appendSystemPrompt: live.launchInstructions } : {}),
847
1228
  ...(record?.codexSessionId ? { resume: record.codexSessionId } : {}),
848
1229
  ...(live?.launchExtraArgs?.length ? { extraArgs: live.launchExtraArgs } : {}),
849
1230
  });
@@ -859,19 +1240,17 @@ export class LocalAgentHost {
859
1240
  /** Bring up a claude-native live forwarder: prepare the bridge dir (the TUI's
860
1241
  * hooks write into it), then tail bridge + transcript and drive a per-turn
861
1242
  * {@link SessionNormalizer} → `emit` (the SAME sink shape the codex path uses). */
862
- /** Resolve an agent spec's launch config — model + the session skill env —
1243
+ /** Resolve an agent spec's launch config — model, instructions, and the session skill env —
863
1244
  * for a LIVE native session, reusing the same core resolvers the non-live
864
1245
  * 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).
1246
+ * {@link resolveAgent} for instructions and skills).
868
1247
  *
869
1248
  * Skills are spec-rooted: each declared ref is replayed into a
870
1249
  * 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). */
1250
+ * role, and no spec / no `skills` means ZERO skills. Every declared skill
1251
+ * must resolve at its declared content hash; missing, failed, or drifted
1252
+ * materialization aborts launch before a native session starts.
1253
+ * `skillsCleanup` removes the session dir (call on session stop). */
875
1254
  async resolveLiveAgentConfig(localThreadId, opts, runtime, cwd) {
876
1255
  // Inline spec needs an id only so a relative instructions-file path can
877
1256
  // resolve (mirrors runLocked); a preset agent loads by name.
@@ -883,6 +1262,7 @@ export class LocalAgentHost {
883
1262
  ...(opts?.agentName ? { agentName: opts.agentName } : {}),
884
1263
  ...(opts?.agentSpec ? { spec: opts.agentSpec } : {}),
885
1264
  runtime,
1265
+ ...(opts?.reasoningEffort ? { reasoningEffort: opts.reasoningEffort } : {}),
886
1266
  });
887
1267
  const resolved = await resolveAgent({
888
1268
  config: this.config,
@@ -891,31 +1271,53 @@ export class LocalAgentHost {
891
1271
  ...(spec ? { spec } : {}),
892
1272
  });
893
1273
  const sessionSkillsDir = await mkdtemp(path.join(tmpdir(), "rynx-session-skills-"));
894
- const skillEnv = await buildAgentSkillEnvironment(resolved.skills, sessionSkillsDir);
1274
+ let skillEnv;
1275
+ try {
1276
+ skillEnv = await buildAgentSkillEnvironment(resolved.skills, sessionSkillsDir);
1277
+ const declaredNames = (resolved.skills ?? []).map((skill) => skill.name);
1278
+ const resolvedNames = skillEnv.resolved.map((skill) => skill.name);
1279
+ const { report } = skillEnv;
1280
+ if (report.errors.length > 0 ||
1281
+ report.missing.length > 0 ||
1282
+ report.drift.length > 0 ||
1283
+ resolvedNames.length !== declaredNames.length ||
1284
+ resolvedNames.some((name, index) => name !== declaredNames[index])) {
1285
+ const details = [
1286
+ `declared=[${declaredNames.join(", ")}]`,
1287
+ `resolved=[${resolvedNames.join(", ")}]`,
1288
+ ...(report.missing.length > 0 ? [`missing=[${report.missing.join(", ")}]`] : []),
1289
+ ...(report.drift.length > 0 ? [`drift=[${report.drift.join("; ")}]`] : []),
1290
+ ...report.errors,
1291
+ ].join("; ");
1292
+ throw new Error(`failed to provision declared skills: ${details}`);
1293
+ }
1294
+ }
1295
+ catch (error) {
1296
+ await rm(sessionSkillsDir, { recursive: true, force: true }).catch(() => undefined);
1297
+ throw error;
1298
+ }
895
1299
  const agent = opts?.agentName ?? resolved.name ?? "-";
896
1300
  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
1301
  if (report.installed.length > 0) {
901
1302
  console.error(`[skills] session=${localThreadId} agent=${agent} installed=[${report.installed.join(", ")}]`);
902
1303
  }
903
- for (const note of report.drift) {
904
- console.error(`[skills] session=${localThreadId} agent=${agent} drift: ${note}`);
905
- }
906
1304
  return {
907
1305
  model: exec.model,
1306
+ ...((opts?.agentName || opts?.agentSpec) && resolved.instructions
1307
+ ? { instructions: resolved.instructions }
1308
+ : {}),
908
1309
  selectedSkills: skillEnv.resolved,
909
1310
  skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
910
1311
  ...(resolved.sandbox ? { sandbox: resolved.sandbox } : {}),
911
1312
  ...(resolved.approvalPolicy ? { approvalPolicy: resolved.approvalPolicy } : {}),
912
1313
  ...(resolved.permissionMode ? { permissionMode: resolved.permissionMode } : {}),
1314
+ ...(exec.reasoningEffort ? { reasoningEffort: exec.reasoningEffort } : {}),
913
1315
  };
914
1316
  }
915
1317
  async startLiveClaudeSession(localThreadId, emit, record, opts) {
916
1318
  const retargetMirror = opts?.retargetMirror;
917
- const bridgeDir = prepareClaudeBridgeDir(localThreadId, this.claudePermissionConfig(localThreadId));
918
- const cwd = record?.cwd ?? process.cwd();
1319
+ const bridgeDir = prepareClaudeBridgeDir(localThreadId);
1320
+ const cwd = record?.cwd ?? opts?.cwd ?? process.cwd();
919
1321
  // Apply the agent spec (model / skills / instructions) to the live TUI launch —
920
1322
  // NOT just the config default. The stored record model wins if present (a
921
1323
  // resumed thread), else the agent-spec resolution. Spec-load errors are logged
@@ -929,18 +1331,16 @@ export class LocalAgentHost {
929
1331
  return false;
930
1332
  }
931
1333
  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.
1334
+ // Skills → throwaway `--plugin-dir`; instructions Claude Code's native
1335
+ // `--append-system-prompt`. The plugin dir gets its own copy, so the session
1336
+ // skills dir is disposable here.
937
1337
  const skillPlugin = await materializeSkillPlugin(liveCfg.selectedSkills);
938
1338
  void liveCfg.skillsCleanup();
939
1339
  // Agent-spec permission mode → claude's `--permission-mode` (claude-private
940
1340
  // approval axis). Unset sends no flag (claude's own default); any set value
941
1341
  // is passed through (`bypassPermissions` is how an agent opts out of the
942
1342
  // approval prompt). The shared `sandbox` axis is intentionally NOT applied
943
- // here — the live co-drive TUI runs unsandboxed (matches omnigent's native).
1343
+ // here — the live co-drive TUI runs unsandboxed (matches reference implementation's native).
944
1344
  const permissionMode = liveCfg.permissionMode;
945
1345
  const launchExtraArgs = [
946
1346
  ...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
@@ -952,15 +1352,36 @@ export class LocalAgentHost {
952
1352
  let normalizer = null;
953
1353
  let currentResponseId;
954
1354
  const startNormalizer = (turnId) => {
955
- // turnId unknown → fixed literal (never random), aligning omnigent `_response_id`.
956
- currentResponseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
1355
+ // turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
1356
+ const responseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
1357
+ if (normalizer && currentResponseId === responseId)
1358
+ return normalizer;
1359
+ currentResponseId = responseId;
957
1360
  normalizer = new SessionNormalizer({
958
1361
  sessionId: currentSessionId,
959
- responseId: currentResponseId,
1362
+ responseId,
960
1363
  model,
961
1364
  });
962
1365
  return normalizer;
963
1366
  };
1367
+ const forwardInteraction = (event) => {
1368
+ const n = normalizer ?? startNormalizer(event.turnId);
1369
+ const agentEvent = event.type === "requested"
1370
+ ? { type: "interaction_requested", interaction: event.request }
1371
+ : event.type === "resolved"
1372
+ ? {
1373
+ type: "interaction_resolved",
1374
+ interactionId: event.interactionId,
1375
+ resolution: event.resolution,
1376
+ }
1377
+ : {
1378
+ type: "interaction_cancelled",
1379
+ interactionId: event.interactionId,
1380
+ ...(event.reason ? { reason: event.reason } : {}),
1381
+ };
1382
+ for (const se of n.next(agentEvent))
1383
+ emit(se);
1384
+ };
964
1385
  let markReady;
965
1386
  const ready = new Promise((resolve) => {
966
1387
  markReady = resolve;
@@ -975,7 +1396,9 @@ export class LocalAgentHost {
975
1396
  discoveredReady: false,
976
1397
  stopped: false,
977
1398
  launchModel: model,
1399
+ ...(liveCfg.instructions ? { launchInstructions: liveCfg.instructions } : {}),
978
1400
  launchExtraArgs,
1401
+ ...(permissionMode ? { permissionMode } : {}),
979
1402
  ...(skillPlugin?.cleanup ? { skillCleanup: skillPlugin.cleanup } : {}),
980
1403
  };
981
1404
  const sink = {
@@ -996,6 +1419,7 @@ export class LocalAgentHost {
996
1419
  for (const se of n.next(event))
997
1420
  emit(se);
998
1421
  },
1422
+ onInteraction: forwardInteraction,
999
1423
  onTurnEnd: (usage) => {
1000
1424
  if (!normalizer)
1001
1425
  return;
@@ -1007,6 +1431,7 @@ export class LocalAgentHost {
1007
1431
  for (const se of normalizer.next({ type: "done" }))
1008
1432
  emit(se);
1009
1433
  normalizer = null;
1434
+ currentResponseId = undefined;
1010
1435
  // One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
1011
1436
  // cleared by the next turn's first item). statusLine pre-computes the %.
1012
1437
  const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
@@ -1039,6 +1464,7 @@ export class LocalAgentHost {
1039
1464
  for (const se of normalizer.fail({ code: "claude_error", message: error.message, source: "execution" }))
1040
1465
  emit(se);
1041
1466
  normalizer = null;
1467
+ currentResponseId = undefined;
1042
1468
  },
1043
1469
  onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, opts?.agentName ?? record?.agent, claudeSessionId),
1044
1470
  onSessionRotated: (kind, claudeSessionId) => {
@@ -1113,10 +1539,10 @@ export class LocalAgentHost {
1113
1539
  .catch(() => undefined);
1114
1540
  live.markReady();
1115
1541
  }
1116
- /** Inject a web message into a claude-native pane via tmux (omnigent recipe),
1542
+ /** Inject a web message into a claude-native pane via tmux (reference implementation recipe),
1117
1543
  * serialized per session. Parks until the thread is ready AND the tmux injector
1118
1544
  * 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
1545
+ * appears (reference implementation RAISE), so a not-ready pane is a hard error — NOT a
1120
1546
  * fall-through-to-run signal. Returns {@link InjectOutcome}. */
1121
1547
  injectClaude(live, localThreadId, text) {
1122
1548
  const run = live.injectLock.then(async () => {
@@ -1150,7 +1576,7 @@ export class LocalAgentHost {
1150
1576
  /** Park until the claude session's tmux injector is (re)attached by the
1151
1577
  * runner-child, or the deadline passes. Pane relaunch re-attaches it via
1152
1578
  * {@link attachTerminalInjector}; without this a message during that window
1153
- * would fail and (pre-omnigent) fall through to a second output path. */
1579
+ * would fail and (pre-reference implementation) fall through to a second output path. */
1154
1580
  async waitInjector(live, timeoutMs) {
1155
1581
  const deadline = Date.now() + timeoutMs;
1156
1582
  while (!live.injector && Date.now() < deadline) {
@@ -1166,15 +1592,6 @@ export class LocalAgentHost {
1166
1592
  if (live)
1167
1593
  live.injector = injector;
1168
1594
  }
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
1595
  /** List the models a runtime exposes (App Server for codex/traex; static for claude). */
1179
1596
  async listModels(runtime) {
1180
1597
  const resolved = runtime ?? this.defaultRuntime;
@@ -1251,6 +1668,7 @@ export class LocalAgentHost {
1251
1668
  codexSessionId: forkedThreadId,
1252
1669
  cwd: record.cwd,
1253
1670
  model: record.model,
1671
+ reasoningEffort: record.reasoningEffort,
1254
1672
  runtime,
1255
1673
  updatedAt: new Date().toISOString(),
1256
1674
  });
@@ -1299,6 +1717,28 @@ function raceReady(ready, timeoutMs) {
1299
1717
  clearTimeout(timer);
1300
1718
  });
1301
1719
  }
1720
+ function liveConfigSignature(opts, runtime, cwd) {
1721
+ return JSON.stringify(stableValue({
1722
+ runtime,
1723
+ cwd,
1724
+ agentName: opts?.agentName,
1725
+ agentSpec: opts?.agentSpec,
1726
+ }));
1727
+ }
1728
+ function stableValue(value) {
1729
+ if (Array.isArray(value))
1730
+ return value.map(stableValue);
1731
+ if (value && typeof value === "object") {
1732
+ const out = {};
1733
+ for (const key of Object.keys(value).sort()) {
1734
+ const item = value[key];
1735
+ if (item !== undefined)
1736
+ out[key] = stableValue(item);
1737
+ }
1738
+ return out;
1739
+ }
1740
+ return value;
1741
+ }
1302
1742
  export function getCodexModel(config, override) {
1303
1743
  return override?.trim() || config.CODEX_MODEL;
1304
1744
  }
@@ -1316,7 +1756,7 @@ export function isUnsupportedMethodError(error) {
1316
1756
  }
1317
1757
  /** A `thread/resume` failure meaning the thread has no (or an empty) rollout yet
1318
1758
  * — a fresh TUI thread before its first turn. Retryable (park until active).
1319
- * Mirrors omnigent's `_is_thread_not_ready_error`. */
1759
+ * Mirrors reference implementation's `_is_thread_not_ready_error`. */
1320
1760
  export function isThreadNotReadyError(error) {
1321
1761
  const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
1322
1762
  return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));