@rynx-ai/runtime 0.1.11-beta.24 → 0.1.11-beta.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/host.js CHANGED
@@ -11,6 +11,7 @@ import { createCodexChildEnv } from "./codex-child-env.js";
11
11
  import { prepareRuntimeHome, populateCodexSkills, runtimeHomePath, } from "./codex-home.js";
12
12
  import { materializeClaudePlugin, reuseClaudePlugin, } from "./claude/executor.js";
13
13
  import { listClaudeModels } from "./claude/models.js";
14
+ import { validateInteractionResolution } from "./interactions.js";
14
15
  import { CodexAppServerClient, buildRuntimeUserInput, } from "./codex-app-server/client.js";
15
16
  import { buildAppServerBaseArgs, CodexTransportError, } from "./codex-app-server/transport.js";
16
17
  import { WsRpcChannel, ExternalWsChannel } from "./codex-app-server/ws-channel.js";
@@ -192,6 +193,85 @@ function providerPluginSkillName(namespace, name) {
192
193
  return `${namespace.slice(0, 48)}-${name.slice(0, 65)}-${digest}`;
193
194
  }
194
195
  const MAX_CODEX_SETTLED_INTERACTIONS = 512;
196
+ const CODEX_PLAN_IMPLEMENTATION_YES = "Yes, implement this plan";
197
+ const CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT = "Yes, clear context and implement";
198
+ const CODEX_PLAN_IMPLEMENTATION_NO = "No, stay in Plan mode";
199
+ const CODEX_PLAN_IMPLEMENTATION_CODING_MESSAGE = "Implement the plan.";
200
+ const CODEX_PLAN_PROMPT_DISMISS_TIMEOUT_MS = 1_500;
201
+ const CODEX_PLAN_PROMPT_DISMISS_POLL_MS = 50;
202
+ const CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX = "A previous agent produced the plan below to accomplish the user's task. " +
203
+ "Implement the plan in a fresh context. Treat the plan as the source of " +
204
+ "user intent, re-read files as needed, and carry the work through " +
205
+ "implementation and verification.";
206
+ function codexPlanImplementationRequest(interactionId) {
207
+ return {
208
+ interactionId,
209
+ kind: "question",
210
+ title: "Plan",
211
+ fields: [{
212
+ id: "plan_implementation",
213
+ type: "select",
214
+ label: "Implement this plan?",
215
+ required: true,
216
+ options: [
217
+ {
218
+ value: CODEX_PLAN_IMPLEMENTATION_YES,
219
+ label: CODEX_PLAN_IMPLEMENTATION_YES,
220
+ description: "Switch to Default and start coding.",
221
+ },
222
+ {
223
+ value: CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT,
224
+ label: CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT,
225
+ description: "Fresh thread with this plan.",
226
+ },
227
+ {
228
+ value: CODEX_PLAN_IMPLEMENTATION_NO,
229
+ label: CODEX_PLAN_IMPLEMENTATION_NO,
230
+ description: "Continue planning with the model.",
231
+ },
232
+ ],
233
+ }],
234
+ actions: [{ id: "submit", label: "Submit", style: "primary", requiresAnswers: true }],
235
+ createdAt: Date.now(),
236
+ };
237
+ }
238
+ function codexPlanImplementationPromptVisible(pane) {
239
+ return pane.includes("Implement this plan?") &&
240
+ pane.includes(CODEX_PLAN_IMPLEMENTATION_YES) &&
241
+ pane.includes(CODEX_PLAN_IMPLEMENTATION_NO);
242
+ }
243
+ /** Codex's final Plan picker is owned by the TUI and is not an app-server
244
+ * request that Web can resolve. Once the synthetic Web form takes ownership,
245
+ * close only that exact picker with its documented Escape action. The owner
246
+ * predicate is checked both before and after capture: app-server notifications
247
+ * cannot interleave between the two synchronous tmux calls, so a newly observed
248
+ * Turn cannot receive a blind Escape. This is deliberately best-effort — a
249
+ * cosmetic Terminal redraw failure must not make an otherwise valid Web Plan
250
+ * decision fail when the structured app-server path can still proceed. */
251
+ async function dismissCodexPlanImplementationPrompt(injector, ownsPrompt) {
252
+ if (!injector || !ownsPrompt())
253
+ return "not_visible";
254
+ try {
255
+ const pane = injector.capturePane();
256
+ if (!ownsPrompt())
257
+ return "superseded";
258
+ if (!codexPlanImplementationPromptVisible(pane))
259
+ return "not_visible";
260
+ injector.interrupt();
261
+ const deadline = Date.now() + CODEX_PLAN_PROMPT_DISMISS_TIMEOUT_MS;
262
+ while (ownsPrompt()) {
263
+ if (!codexPlanImplementationPromptVisible(injector.capturePane()))
264
+ return "dismissed";
265
+ if (Date.now() >= deadline)
266
+ return "failed";
267
+ await new Promise((resolve) => setTimeout(resolve, CODEX_PLAN_PROMPT_DISMISS_POLL_MS));
268
+ }
269
+ return "superseded";
270
+ }
271
+ catch {
272
+ return "failed";
273
+ }
274
+ }
195
275
  /**
196
276
  * The `OPENAI_*` retry env for a codex-lineage app-server spawn, or `undefined`
197
277
  * when the budget declares no retry or the runtime is claude. claude applies
@@ -247,7 +327,7 @@ export class LocalAgentHost {
247
327
  // multi-connection model). Defaults to an ExternalWsChannel client attached to
248
328
  // the backend's app-server; tests inject a fake.
249
329
  forwarderClientFactory;
250
- // Builds Omnigent's short-lived cold-resume preload connection. This must be
330
+ // Builds reference implementation's short-lived cold-resume preload connection. This must be
251
331
  // distinct from both the app-server owner/injector and the background
252
332
  // observer: the owner's initialize handshake is the readiness probe, then a
253
333
  // freshly initialized connection performs the one resume and closes.
@@ -418,8 +498,51 @@ export class LocalAgentHost {
418
498
  if (live.settledInteractions.has(interactionId)) {
419
499
  return { disposition: "already_resolved" };
420
500
  }
501
+ const synthetic = live.syntheticInteractions.get(interactionId);
502
+ if (synthetic)
503
+ return synthetic.resolve(resolution);
421
504
  return live.forwarderClient.resolveInteraction(interactionId, resolution);
422
505
  }
506
+ /** Apply collaboration mode to an already-loaded Codex-lineage thread. The
507
+ * caller persists only after this native RPC succeeds. */
508
+ async updateCollaborationMode(localThreadId, mode) {
509
+ const live = this.liveSessions.get(localThreadId);
510
+ if (!live || live.stopped || live.rotationPending) {
511
+ throw new CodexRuntimeError("Native Session is not live", 409, "native_session_not_live");
512
+ }
513
+ const run = live.injectLock.then(async () => {
514
+ const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
515
+ const threadId = live.threadId ?? live.forwarder.threadId();
516
+ if (!bound || !threadId) {
517
+ throw new CodexRuntimeError("Native thread is not ready", 503, "native_thread_not_ready");
518
+ }
519
+ await this.applyLiveCollaborationMode(live, threadId, mode);
520
+ });
521
+ live.injectLock = run.then(() => undefined, () => undefined);
522
+ await run;
523
+ }
524
+ /** Apply one explicit collaboration mode to the current native thread and
525
+ * remember only a successful RPC. A supplied client is already initialized
526
+ * and remains owned by its caller (used by cold-resume preload). */
527
+ async applyLiveCollaborationMode(live, threadId, mode, suppliedClient) {
528
+ if (live.appliedCollaborationMode === mode)
529
+ return;
530
+ const client = suppliedClient ?? this.injectionClientFactory(live.appServerUrl);
531
+ try {
532
+ if (!suppliedClient)
533
+ await client.ensureInitialized();
534
+ await client.threadSettingsUpdate({
535
+ threadId,
536
+ collaborationMode: await buildCollaborationMode(client, mode, live.model, live.reasoningEffort),
537
+ });
538
+ live.appliedCollaborationMode = mode;
539
+ live.execution = { ...live.execution, collaborationMode: mode };
540
+ }
541
+ finally {
542
+ if (!suppliedClient)
543
+ await client.stop().catch(() => undefined);
544
+ }
545
+ }
423
546
  /**
424
547
  * The command to run in a session's live terminal so it co-drives the codex
425
548
  * app-server thread (Phase D). Returns `null` when live-terminal is off, the
@@ -634,7 +757,7 @@ export class LocalAgentHost {
634
757
  // notifications (the backend client injects; this one only observes).
635
758
  // Fresh sessions must connect this listener before the TUI launches so its
636
759
  // one-shot `thread/started` cannot race discovery. A known-thread cold resume
637
- // already has its durable id: like Omnigent's `_codex_forward_known_thread`,
760
+ // already has its durable id: like reference implementation's `_codex_forward_known_thread`,
638
761
  // its observer starts in the background only after the replacement TUI has
639
762
  // launched and is never part of the resume admission boundary.
640
763
  const forwarderClient = this.forwarderClientFactory(appServerUrl);
@@ -677,6 +800,7 @@ export class LocalAgentHost {
677
800
  reasoningEffort,
678
801
  appliedModel: model,
679
802
  appliedReasoningEffort: reasoningEffort,
803
+ appliedCollaborationMode: undefined,
680
804
  ...(execution.instructions ? { instructions: execution.instructions } : {}),
681
805
  threadId: record?.codexSessionId ?? null,
682
806
  ready,
@@ -699,6 +823,8 @@ export class LocalAgentHost {
699
823
  skillsCleanup: snapshotSkills.skillsCleanup,
700
824
  canonicalInteractions: new Set(),
701
825
  settledInteractions: new Set(),
826
+ syntheticInteractions: new Map(),
827
+ cancelSyntheticInteractions: () => undefined,
702
828
  };
703
829
  let currentSessionId = localThreadId;
704
830
  let pendingRotationEvents = null;
@@ -774,11 +900,168 @@ export class LocalAgentHost {
774
900
  if (oldest)
775
901
  live.settledInteractions.delete(oldest);
776
902
  };
903
+ const cancelSyntheticInteractions = (reason) => {
904
+ for (const interaction of live.syntheticInteractions.values())
905
+ interaction.cancel(reason);
906
+ };
907
+ live.cancelSyntheticInteractions = cancelSyntheticInteractions;
908
+ const publishPlanImplementationPrompt = (prompt) => {
909
+ const suffix = createHash("sha256")
910
+ .update(`${prompt.threadId}\0${prompt.turnId}`)
911
+ .digest("hex")
912
+ .slice(0, 24);
913
+ const interactionId = `codex_plan_implementation_${suffix}`;
914
+ if (live.syntheticInteractions.has(interactionId) ||
915
+ live.settledInteractions.has(interactionId))
916
+ return;
917
+ const responseId = `resp_codex_plan_approval_${suffix}`;
918
+ const request = codexPlanImplementationRequest(interactionId);
919
+ const interactionNormalizer = new SessionNormalizer({
920
+ sessionId: currentSessionId,
921
+ responseId,
922
+ model: live.model || live.runtime,
923
+ });
924
+ let submitted = false;
925
+ const finish = (kind, resolution, reason) => {
926
+ if (!live.syntheticInteractions.delete(interactionId))
927
+ return;
928
+ rememberSettledInteraction(interactionId);
929
+ const event = kind === "resolved"
930
+ ? {
931
+ type: "interaction_resolved",
932
+ interactionId,
933
+ resolution: resolution,
934
+ }
935
+ : {
936
+ type: "interaction_cancelled",
937
+ interactionId,
938
+ ...(reason ? { reason } : {}),
939
+ };
940
+ // This interaction belongs to the Session that produced the Plan. A
941
+ // clear-context choice can rotate the native thread before turn/start
942
+ // returns, so never retarget its resolution to the new Session.
943
+ for (const sessionEvent of interactionNormalizer.next(event))
944
+ emit(sessionEvent);
945
+ for (const sessionEvent of interactionNormalizer.next({ type: "done" })) {
946
+ emit(sessionEvent);
947
+ }
948
+ };
949
+ live.syntheticInteractions.set(interactionId, {
950
+ cancel: (reason) => {
951
+ // The selected implementation itself starts a native Turn and the
952
+ // clear-context variant also starts a new thread. Their observer
953
+ // notifications can beat the short-lived RPC response; they are not
954
+ // competing user actions and must not cancel the card being resolved.
955
+ if (submitted &&
956
+ (reason === "superseded_by_native_turn" || reason === "native_thread_rotated"))
957
+ return;
958
+ finish("cancelled", undefined, reason);
959
+ },
960
+ resolve: async (resolution) => {
961
+ if (submitted)
962
+ return { disposition: "already_resolved" };
963
+ const invalid = validateInteractionResolution(request, resolution);
964
+ if (invalid)
965
+ return { disposition: "invalid", message: invalid };
966
+ const choice = resolution.answers?.plan_implementation;
967
+ if (typeof choice !== "string") {
968
+ return { disposition: "invalid", message: "plan implementation choice is required" };
969
+ }
970
+ submitted = true;
971
+ const run = live.injectLock.then(async () => {
972
+ if (live.stopped || live.rotationPending) {
973
+ throw new CodexRuntimeError("Native Session is not available for plan implementation", 409, "native_session_not_live");
974
+ }
975
+ const promptDismissal = await dismissCodexPlanImplementationPrompt(live.injector, () => !live.stopped &&
976
+ !live.rotationPending &&
977
+ live.syntheticInteractions.has(interactionId) &&
978
+ live.forwarder.threadId() === prompt.threadId &&
979
+ !live.forwarder.isTurnOpen());
980
+ if (promptDismissal === "failed") {
981
+ console.warn(`[codex-live] session=${currentSessionId} native Plan picker remained visible after the Web decision; continuing through the app-server`);
982
+ }
983
+ if (choice === CODEX_PLAN_IMPLEMENTATION_NO) {
984
+ finish("resolved", resolution);
985
+ return;
986
+ }
987
+ const client = this.injectionClientFactory(live.appServerUrl);
988
+ try {
989
+ await client.ensureInitialized();
990
+ const collaborationMode = await buildCollaborationMode(client, "default", live.model, live.reasoningEffort);
991
+ let threadId = prompt.threadId;
992
+ let text = CODEX_PLAN_IMPLEMENTATION_CODING_MESSAGE;
993
+ if (choice === CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT) {
994
+ live.nextThreadCollaborationMode = "default";
995
+ let startedThread;
996
+ try {
997
+ startedThread = await client.threadStart({
998
+ ...(live.model ? { model: live.model } : {}),
999
+ ...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1000
+ approvalPolicy: live.approvalPolicy,
1001
+ ...(live.instructions ? { developerInstructions: live.instructions } : {}),
1002
+ sessionStartSource: "clear",
1003
+ });
1004
+ }
1005
+ catch (error) {
1006
+ live.nextThreadCollaborationMode = undefined;
1007
+ throw error;
1008
+ }
1009
+ threadId = startedThread.threadId;
1010
+ text = `${CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX}\n\n${prompt.text}`;
1011
+ }
1012
+ else if (choice !== CODEX_PLAN_IMPLEMENTATION_YES) {
1013
+ throw new CodexRuntimeError("Unknown plan implementation choice", 400, "invalid_interaction_resolution");
1014
+ }
1015
+ const started = await client.turnStart({
1016
+ threadId,
1017
+ input: [{ type: "text", text }],
1018
+ ...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1019
+ approvalPolicy: live.approvalPolicy,
1020
+ collaborationMode,
1021
+ });
1022
+ live.execution = { ...live.execution, collaborationMode: "default" };
1023
+ live.appliedCollaborationMode = "default";
1024
+ emitCurrent({
1025
+ type: "session.collaboration_mode",
1026
+ sessionId: currentSessionId,
1027
+ mode: "default",
1028
+ });
1029
+ live.forwarder.noteTurnAccepted(started.turnId);
1030
+ finish("resolved", resolution);
1031
+ }
1032
+ finally {
1033
+ await client.stop().catch(() => undefined);
1034
+ }
1035
+ });
1036
+ live.injectLock = run.then(() => undefined, () => undefined);
1037
+ try {
1038
+ await run;
1039
+ return { disposition: "applied" };
1040
+ }
1041
+ catch (error) {
1042
+ submitted = false;
1043
+ return {
1044
+ disposition: "invalid",
1045
+ message: error instanceof Error ? error.message : String(error),
1046
+ };
1047
+ }
1048
+ },
1049
+ });
1050
+ for (const sessionEvent of interactionNormalizer.next({
1051
+ type: "interaction_requested",
1052
+ interaction: request,
1053
+ }))
1054
+ emit(sessionEvent);
1055
+ };
777
1056
  const forwardInteraction = (event) => {
778
1057
  const interactionId = event.type === "requested"
779
1058
  ? event.request.interactionId
780
1059
  : event.interactionId;
781
1060
  if (event.type === "requested") {
1061
+ if (event.request.fields.some((field) => field.id === "plan_implementation")) {
1062
+ live.forwarder.noteNativePlanImplementationPrompt(event.turnId);
1063
+ cancelSyntheticInteractions("superseded_by_native_plan_prompt");
1064
+ }
782
1065
  if (live.canonicalInteractions.has(interactionId) ||
783
1066
  live.settledInteractions.has(interactionId))
784
1067
  return;
@@ -896,6 +1179,7 @@ export class LocalAgentHost {
896
1179
  const sink = {
897
1180
  onTurnStart: (turnId) => startNormalizer(turnId),
898
1181
  onTurnObserved: (turnId) => {
1182
+ cancelSyntheticInteractions("superseded_by_native_turn");
899
1183
  const n = startNormalizer(turnId);
900
1184
  for (const event of n.next({ type: "turn_started", ...(turnId ? { turnId } : {}) })) {
901
1185
  emitCurrent(event);
@@ -961,6 +1245,16 @@ export class LocalAgentHost {
961
1245
  shouldIgnoreThreadStarted: (threadId, forkedFromId) => this.shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId),
962
1246
  onThreadStarted: (threadId, forkedFromId) => this.onLiveThreadStarted(live, localThreadId, threadId, forkedFromId),
963
1247
  onThreadActive: () => live.releaseActive?.(),
1248
+ onCollaborationModeChanged: (mode) => {
1249
+ live.appliedCollaborationMode = mode;
1250
+ live.execution = { ...live.execution, collaborationMode: mode };
1251
+ emitCurrent({
1252
+ type: "session.collaboration_mode",
1253
+ sessionId: currentSessionId,
1254
+ mode,
1255
+ });
1256
+ },
1257
+ onPlanImplementationPrompt: publishPlanImplementationPrompt,
964
1258
  };
965
1259
  const forwarder = new CodexSessionForwarder(forwarderClient, sink, {
966
1260
  // Traex 0.200 can publish a final reasoning item immediately after
@@ -979,8 +1273,15 @@ export class LocalAgentHost {
979
1273
  live.stopped = true;
980
1274
  return;
981
1275
  }
1276
+ cancelSyntheticInteractions("native_thread_rotated");
982
1277
  const previousSessionId = currentSessionId;
983
1278
  const newSessionId = makeSessionId();
1279
+ const rotationMode = live.nextThreadCollaborationMode;
1280
+ live.nextThreadCollaborationMode = undefined;
1281
+ live.appliedCollaborationMode = undefined;
1282
+ if (rotationMode) {
1283
+ live.execution = { ...live.execution, collaborationMode: rotationMode };
1284
+ }
984
1285
  live.rotationPending = true;
985
1286
  currentSessionId = newSessionId;
986
1287
  normalizer = null;
@@ -1017,6 +1318,14 @@ export class LocalAgentHost {
1017
1318
  live.rotationPending = false;
1018
1319
  for (const event of queued)
1019
1320
  emit(event);
1321
+ const desiredMode = live.execution.collaborationMode;
1322
+ if (desiredMode) {
1323
+ const restore = live.injectLock.then(() => this.applyLiveCollaborationMode(live, threadId, desiredMode));
1324
+ live.injectLock = restore.then(() => undefined, () => undefined);
1325
+ void restore.catch((error) => {
1326
+ this.failObserverLifecycle(newSessionId, live, nativeLiveFailure(live.runtime, "native_collaboration_mode_restore_failed", "rotated native thread could not restore the Session collaboration mode", error));
1327
+ });
1328
+ }
1020
1329
  void this.subscribeUntilReady(newSessionId, live, threadId);
1021
1330
  })
1022
1331
  .catch(() => {
@@ -1035,7 +1344,7 @@ export class LocalAgentHost {
1035
1344
  // turn-less app-server thread resumable by the TUI.
1036
1345
  try {
1037
1346
  if (record?.codexSessionId) {
1038
- // Match Omnigent's cold-resume preload exactly: one history-free
1347
+ // Match reference implementation's cold-resume preload exactly: one history-free
1039
1348
  // `thread/resume` on a short-lived, separately initialized connection.
1040
1349
  // The backend owner's handshake above is the app-server readiness
1041
1350
  // probe; reusing that first connection here races Codex's startup state
@@ -1052,6 +1361,9 @@ export class LocalAgentHost {
1052
1361
  approvalPolicy,
1053
1362
  excludeTurns: true,
1054
1363
  });
1364
+ if (execution.collaborationMode) {
1365
+ await this.applyLiveCollaborationMode(live, resumed.threadId, execution.collaborationMode, preloadClient);
1366
+ }
1055
1367
  }
1056
1368
  finally {
1057
1369
  await preloadClient.stop().catch((error) => {
@@ -1138,16 +1450,38 @@ export class LocalAgentHost {
1138
1450
  });
1139
1451
  })
1140
1452
  .catch(() => undefined);
1141
- live.markReady(); // thread id known → injection can turn/start
1142
- if (live.observerAvailable) {
1143
- void this.subscribeUntilReady(localThreadId, live, threadId).then(live.markTerminalReady);
1144
- }
1145
- else {
1146
- // Known-thread cold resume has already completed the dedicated preload. Its
1147
- // observer is deliberately post-TUI background work, not Terminal
1148
- // readiness. RunnerSession calls startLiveCodexObserver after launch.
1149
- live.markTerminalReady(true);
1453
+ const finishBinding = () => {
1454
+ if (live.stopped)
1455
+ return;
1456
+ live.markReady(); // thread id + explicit Session mode are ready for turns
1457
+ if (live.observerAvailable) {
1458
+ void this.subscribeUntilReady(localThreadId, live, threadId).then(live.markTerminalReady);
1459
+ }
1460
+ else {
1461
+ // Known-thread cold resume has already completed the dedicated preload. Its
1462
+ // observer is deliberately post-TUI background work, not Terminal
1463
+ // readiness. RunnerSession calls startLiveCodexObserver after launch.
1464
+ live.markTerminalReady(true);
1465
+ }
1466
+ };
1467
+ const desiredMode = live.execution.collaborationMode;
1468
+ if (!desiredMode || live.appliedCollaborationMode === desiredMode) {
1469
+ finishBinding();
1470
+ return;
1150
1471
  }
1472
+ // A fresh thread does not exist until the TUI broadcasts thread/started.
1473
+ // Apply the Session-owned mode immediately at that boundary, before Web
1474
+ // injection is released. This also narrows the window in which a user can
1475
+ // type into the native pane before its explicit startup mode is installed.
1476
+ const restore = live.injectLock.then(() => this.applyLiveCollaborationMode(live, threadId, desiredMode));
1477
+ live.injectLock = restore.then(() => undefined, () => undefined);
1478
+ void restore.then(finishBinding).catch((error) => {
1479
+ const failure = nativeLiveFailure(live.runtime, "native_collaboration_mode_restore_failed", "native thread could not apply the Session collaboration mode", error);
1480
+ live.startupError = failure;
1481
+ live.markStartupFailed();
1482
+ this.liveStartupErrors.set(localThreadId, failure);
1483
+ this.teardownLiveCodexSession(localThreadId, failure);
1484
+ });
1151
1485
  }
1152
1486
  /** Start the known-thread observer after the replacement TUI has launched.
1153
1487
  * Fresh sessions already connected their discovery listener before launch;
@@ -1165,7 +1499,7 @@ export class LocalAgentHost {
1165
1499
  if (live.stopped)
1166
1500
  return;
1167
1501
  live.observerAvailable = true;
1168
- // Omnigent starts subscription as a sibling task after the transport
1502
+ // reference implementation starts subscription as a sibling task after the transport
1169
1503
  // connects. A no/empty rollout remains event-driven retryable; any
1170
1504
  // other subscription rejection is diagnostic only and must not tear
1171
1505
  // down the otherwise usable Terminal/injection lifecycle.
@@ -1176,7 +1510,7 @@ export class LocalAgentHost {
1176
1510
  }
1177
1511
  })();
1178
1512
  }
1179
- /** Omnigent treats the forwarder as a required component of one native
1513
+ /** reference implementation treats the forwarder as a required component of one native
1180
1514
  * lifecycle: if its transport dies, it closes the app-server instead of
1181
1515
  * accepting turns that can no longer reach the canonical mirror. */
1182
1516
  failObserverLifecycle(localThreadId, live, error) {
@@ -1208,7 +1542,7 @@ export class LocalAgentHost {
1208
1542
  * Subscribe the forwarder connection to a thread (reference implementation's
1209
1543
  * `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
1210
1544
  * turn, so `thread/resume` is retried: park until the forwarder observes the
1211
- * thread active, then retry. Like Omnigent, the first attempt always uses
1545
+ * thread active, then retry. Like reference implementation, the first attempt always uses
1212
1546
  * `excludeTurns`: a known-session cold resume therefore never reconstructs
1213
1547
  * historical running/completed state. Only a fresh thread whose first attempt
1214
1548
  * failed as not-ready retries without `excludeTurns`, backfilling the newly
@@ -1345,7 +1679,7 @@ export class LocalAgentHost {
1345
1679
  * forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
1346
1680
  * the bridge instead of a short race that falls back to a second output path.
1347
1681
  */
1348
- async injectMessage(localThreadId, input) {
1682
+ async injectMessage(localThreadId, input, options) {
1349
1683
  const runtimeInput = typeof input === "string"
1350
1684
  ? { content: [{ type: "text", text: input }] }
1351
1685
  : input;
@@ -1427,14 +1761,14 @@ export class LocalAgentHost {
1427
1761
  };
1428
1762
  const injectionClient = this.injectionClientFactory(live.appServerUrl);
1429
1763
  try {
1430
- // Omnigent publishes codex-native running only after the native Terminal
1764
+ // reference implementation publishes codex-native running only after the native Terminal
1431
1765
  // is ready and the runner has accepted the message, but before the
1432
1766
  // short-lived app-server client initializes and starts the Turn. Claude
1433
1767
  // deliberately has no matching synthesized edge.
1434
1768
  if (!live.forwarder.isTurnOpen())
1435
1769
  publishAdmission();
1436
1770
  await injectionClient.ensureInitialized();
1437
- // Match Omnigent's executor: each message uses one initialized client
1771
+ // Match reference implementation's executor: each message uses one initialized client
1438
1772
  // that closes as soon as turn/start or turn/steer is acknowledged.
1439
1773
  if (live.forwarder.isTurnOpen()) {
1440
1774
  const turnId = live.forwarder.currentTurnId();
@@ -1456,11 +1790,20 @@ export class LocalAgentHost {
1456
1790
  return { outcome: "steered", responseId: `resp_codex_${steered.turnId}` };
1457
1791
  }
1458
1792
  }
1459
- // Match Omnigent's turn boundary: change the native thread settings
1793
+ // Match reference implementation's turn boundary: change the native thread settings
1460
1794
  // under the same lock immediately before starting the next Turn. Never
1461
1795
  // put settings on turn/start or mutate a Turn that is already open.
1462
1796
  const desiredModel = live.model;
1463
1797
  const desiredReasoningEffort = live.reasoningEffort;
1798
+ // A Session-owned mode survives runner restarts. Normal Web messages do
1799
+ // not carry per-Turn options, so a cold-resumed native thread must fall
1800
+ // back to the persisted execution snapshot before its first new Turn.
1801
+ // An explicit session.run option still wins for that invocation.
1802
+ const desiredCollaborationMode = options?.collaborationMode ?? live.execution.collaborationMode;
1803
+ const collaborationMode = desiredCollaborationMode &&
1804
+ desiredCollaborationMode !== live.appliedCollaborationMode
1805
+ ? await buildCollaborationMode(injectionClient, desiredCollaborationMode, desiredModel, desiredReasoningEffort)
1806
+ : undefined;
1464
1807
  const settings = {
1465
1808
  threadId,
1466
1809
  ...(desiredModel !== live.appliedModel
@@ -1469,6 +1812,7 @@ export class LocalAgentHost {
1469
1812
  ...(desiredReasoningEffort !== live.appliedReasoningEffort
1470
1813
  ? { effort: desiredReasoningEffort ?? null }
1471
1814
  : {}),
1815
+ ...(collaborationMode ? { collaborationMode } : {}),
1472
1816
  };
1473
1817
  if (Object.keys(settings).length > 1) {
1474
1818
  await injectionClient.threadSettingsUpdate(settings);
@@ -1477,6 +1821,13 @@ export class LocalAgentHost {
1477
1821
  // the following new Turn.
1478
1822
  live.appliedModel = desiredModel;
1479
1823
  live.appliedReasoningEffort = desiredReasoningEffort;
1824
+ if (desiredCollaborationMode) {
1825
+ live.appliedCollaborationMode = desiredCollaborationMode;
1826
+ live.execution = {
1827
+ ...live.execution,
1828
+ collaborationMode: desiredCollaborationMode,
1829
+ };
1830
+ }
1480
1831
  }
1481
1832
  publishAdmission();
1482
1833
  const started = await injectionClient.turnStart({
@@ -1500,6 +1851,10 @@ export class LocalAgentHost {
1500
1851
  }
1501
1852
  catch (error) {
1502
1853
  forgetPendingInput();
1854
+ if (error instanceof CodexRuntimeError &&
1855
+ error.code === "collaboration_mode_model_unknown") {
1856
+ throw error;
1857
+ }
1503
1858
  // Preserve the app-server's error instead of collapsing every failure
1504
1859
  // into the unactionable `live injection failed` string.
1505
1860
  const baseDetail = codexRpcError(error, injectionMethod);
@@ -1626,6 +1981,7 @@ export class LocalAgentHost {
1626
1981
  live.markStartupFailed();
1627
1982
  }
1628
1983
  live.releaseActive?.();
1984
+ live.cancelSyntheticInteractions("session_stopped");
1629
1985
  live.appServerOwner.cancelInteractions("session_stopped");
1630
1986
  live.forwarderClient.cancelInteractions("session_stopped");
1631
1987
  live.appServerOwner.setInteractionListener(null);
@@ -1635,12 +1991,13 @@ export class LocalAgentHost {
1635
1991
  live.forwarder.stop();
1636
1992
  live.canonicalInteractions.clear();
1637
1993
  live.settledInteractions.clear();
1994
+ live.syntheticInteractions.clear();
1638
1995
  void live.forwarderClient.stop().catch(() => undefined);
1639
1996
  // Remove the session-scoped skills dir — the machine keeps zero task residue.
1640
1997
  void live.skillsCleanup?.();
1641
1998
  }
1642
1999
  /** Tear down one codex-lineage native runtime without deleting its durable
1643
- * session-store binding. Omnigent couples its auxiliary Terminal, observer,
2000
+ * session-store binding. reference implementation couples its auxiliary Terminal, observer,
1644
2001
  * forwarder and per-session app-server as one disposable runtime envelope;
1645
2002
  * the next message recreates that envelope and cold-resumes the native id. */
1646
2003
  teardownLiveCodexSession(localThreadId, error) {
@@ -2236,8 +2593,9 @@ export class LocalAgentHost {
2236
2593
  return live.injector ?? null;
2237
2594
  }
2238
2595
  /** Attach a session's tmux pane injector (from the runner-child, which owns the
2239
- * terminal registry — the host does not hold tmux). Ignored for codex sessions
2240
- * (they inject via the app-server). Idempotent. */
2596
+ * terminal registry — the host does not hold tmux). Claude uses it for normal
2597
+ * message injection; Codex uses it only to dismiss the exact TUI-local Plan
2598
+ * picker after its synthetic Web form is submitted. Idempotent. */
2241
2599
  attachTerminalInjector(localThreadId, injector) {
2242
2600
  const live = this.liveClaudeSessions.get(localThreadId);
2243
2601
  if (live) {
@@ -2245,7 +2603,11 @@ export class LocalAgentHost {
2245
2603
  return;
2246
2604
  live.injector = injector;
2247
2605
  live.forwarder.attachStatusSource({ panePid: () => injector.panePid?.() });
2606
+ return;
2248
2607
  }
2608
+ const codex = this.liveSessions.get(localThreadId);
2609
+ if (codex)
2610
+ codex.injector = injector;
2249
2611
  }
2250
2612
  /** Close an active native response when its terminal or runner disappears. */
2251
2613
  failLiveSession(localThreadId, error) {
@@ -2472,6 +2834,40 @@ function raceReady(ready, timeoutMs, failed) {
2472
2834
  clearTimeout(timer);
2473
2835
  });
2474
2836
  }
2837
+ /** Expand the public mode enum into the App Server's required settings
2838
+ * snapshot. This mirrors Omnigent: an explicit Session model wins; otherwise
2839
+ * the Provider's advertised native default is required, and an unknown model
2840
+ * fails the update instead of silently dropping collaboration mode. */
2841
+ async function buildCollaborationMode(client, mode, desiredModel, desiredReasoningEffort) {
2842
+ let model = desiredModel.trim();
2843
+ if (!model) {
2844
+ let catalog;
2845
+ try {
2846
+ catalog = await client.modelList();
2847
+ }
2848
+ catch (error) {
2849
+ throw new CodexRuntimeError(`collaboration mode requires the current model: ${error instanceof Error ? error.message : String(error)}`, 503, "collaboration_mode_model_unknown");
2850
+ }
2851
+ const defaults = [...new Set(catalog.data
2852
+ .filter((candidate) => candidate.isDefault === true)
2853
+ .map((candidate) => (candidate.model || candidate.id).trim())
2854
+ .filter(Boolean))];
2855
+ if (defaults.length !== 1) {
2856
+ throw new CodexRuntimeError(`collaboration mode requires exactly one current default model; Provider advertised ${defaults.length}`, 503, "collaboration_mode_model_unknown");
2857
+ }
2858
+ model = defaults[0];
2859
+ }
2860
+ return {
2861
+ mode,
2862
+ settings: {
2863
+ model,
2864
+ reasoning_effort: desiredReasoningEffort ?? null,
2865
+ // `null` asks the App Server to install the built-in instructions for
2866
+ // the selected mode; thread-level Agent instructions remain separate.
2867
+ developer_instructions: null,
2868
+ },
2869
+ };
2870
+ }
2475
2871
  function sameSessionSnapshots(workspace, execution, opts) {
2476
2872
  return JSON.stringify(stableValue({
2477
2873
  workspace,
@@ -2482,8 +2878,18 @@ function sameSessionSnapshots(workspace, execution, opts) {
2482
2878
  }));
2483
2879
  }
2484
2880
  function sameImmutableSessionSnapshots(workspace, execution, opts) {
2485
- const current = { ...execution, model: null, reasoningEffort: null };
2486
- const requested = { ...opts.execution, model: null, reasoningEffort: null };
2881
+ const current = {
2882
+ ...execution,
2883
+ model: null,
2884
+ reasoningEffort: null,
2885
+ collaborationMode: undefined,
2886
+ };
2887
+ const requested = {
2888
+ ...opts.execution,
2889
+ model: null,
2890
+ reasoningEffort: null,
2891
+ collaborationMode: undefined,
2892
+ };
2487
2893
  return JSON.stringify(stableValue({ workspace, execution: current })) ===
2488
2894
  JSON.stringify(stableValue({ workspace: opts.workspace, execution: requested }));
2489
2895
  }