happy-imou-cloud 2.0.0 → 2.0.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 (27) hide show
  1. package/dist/{BaseReasoningProcessor-BRCQXCZY.cjs → BaseReasoningProcessor-B6tJ_eL5.cjs} +96 -9
  2. package/dist/{BaseReasoningProcessor-BKLRCKTU.mjs → BaseReasoningProcessor-D8VhEbs2.mjs} +95 -10
  3. package/dist/{api-D7OK-mML.cjs → api-D2Njw9Im.cjs} +124 -6
  4. package/dist/{api-BGXYX0yH.mjs → api-MYhAGPLn.mjs} +122 -7
  5. package/dist/{command-G85giEAF.cjs → command-CVldr51S.cjs} +3 -3
  6. package/dist/{command-CnLtKtP-.mjs → command-nmK6O-ab.mjs} +3 -3
  7. package/dist/{index-C7Y0R-MI.mjs → index-B97L7qLD.mjs} +689 -229
  8. package/dist/{index-B_wlQBy2.cjs → index-Bg-YziG2.cjs} +691 -229
  9. package/dist/index.cjs +4 -4
  10. package/dist/index.mjs +4 -4
  11. package/dist/lib.cjs +1 -1
  12. package/dist/lib.d.cts +7 -0
  13. package/dist/lib.d.mts +7 -0
  14. package/dist/lib.mjs +1 -1
  15. package/dist/{persistence-DHgf1CTG.cjs → persistence-D_2GkJAO.cjs} +28 -6
  16. package/dist/{persistence-BA_unuca.mjs → persistence-Dkm7rm8k.mjs} +29 -7
  17. package/dist/{registerKillSessionHandler-C2-yHm1V.mjs → registerKillSessionHandler-5GbrO0FM.mjs} +6 -4
  18. package/dist/{registerKillSessionHandler-CLREXN11.cjs → registerKillSessionHandler-BAXmJQRt.cjs} +6 -4
  19. package/dist/{runClaude-CwAitpX-.cjs → runClaude-B-GNEkKg.cjs} +237 -45
  20. package/dist/{runClaude-uNC5Eym4.mjs → runClaude-Cii3R2Fv.mjs} +238 -46
  21. package/dist/{runCodex-B-05E-YZ.mjs → runCodex-C--ZwAhl.mjs} +636 -819
  22. package/dist/{runCodex-Cm0VTqw_.cjs → runCodex-CPHyGwj9.cjs} +639 -819
  23. package/dist/{runGemini-_biXvQAH.mjs → runGemini-CQp7Nuzn.mjs} +20 -16
  24. package/dist/{runGemini-CLWjwDYS.cjs → runGemini-DaDz1bzQ.cjs} +20 -16
  25. package/package.json +14 -15
  26. package/scripts/env-wrapper.cjs +11 -11
  27. package/scripts/setup-dev.cjs +4 -4
@@ -1,27 +1,24 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { l as logger, b as connectionState, A as ApiClient } from './api-BGXYX0yH.mjs';
3
- import { readSettings } from './persistence-BA_unuca.mjs';
4
- import { d as resolveCodexExecutable, e as shouldUseShellForCodex, b as stopCaffeinate, i as initialMachineMetadata, n as notifyDaemonSessionStarted } from './index-C7Y0R-MI.mjs';
5
- import { I as INTERACTION_SUPERSEDED_ERROR, B as BasePermissionHandler, a as BaseReasoningProcessor, c as createSessionMetadata, s as setupOfflineReconnection } from './BaseReasoningProcessor-BKLRCKTU.mjs';
6
- import { a as MessageBuffer, r as registerKillSessionHandler, M as MessageQueue2, h as hashObject } from './registerKillSessionHandler-C2-yHm1V.mjs';
2
+ import { l as logger, b as connectionState, A as ApiClient, i as isAuthenticationRequiredError } from './api-MYhAGPLn.mjs';
3
+ import { readSettings } from './persistence-Dkm7rm8k.mjs';
4
+ import { f as formatDisplayMessage, v as validateCodexAcpSpawn, d as createCodexBackend, t as truncateDisplayMessage, b as stopCaffeinate, i as initialMachineMetadata, n as notifyDaemonSessionStarted } from './index-B97L7qLD.mjs';
5
+ import { B as BasePermissionHandler, g as getPendingInteractionTimeoutMs, I as INTERACTION_SUPERSEDED_ERROR, b as INTERACTION_TIMED_OUT_ERROR, a as BaseReasoningProcessor, c as createSessionMetadata, s as setupOfflineReconnection } from './BaseReasoningProcessor-D8VhEbs2.mjs';
6
+ import { a as MessageBuffer, r as registerKillSessionHandler, M as MessageQueue2, h as hashObject } from './registerKillSessionHandler-5GbrO0FM.mjs';
7
7
  import { F as Future } from './future-xRdLl3vf.mjs';
8
8
  import { spawn, execFileSync } from 'node:child_process';
9
9
  import fs from 'node:fs';
10
10
  import os from 'node:os';
11
- import { join } from 'node:path';
11
+ import path, { join } from 'node:path';
12
12
  import React, { useState, useRef, useEffect, useCallback } from 'react';
13
13
  import { useStdout, useInput, Box, Text, render } from 'ink';
14
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
15
- import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
16
- import { z } from 'zod';
17
- import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
18
- import { execSync } from 'child_process';
19
14
  import 'axios';
20
15
  import 'chalk';
21
16
  import 'fs';
22
17
  import 'node:events';
23
18
  import 'socket.io-client';
19
+ import 'zod';
24
20
  import 'tweetnacl';
21
+ import 'child_process';
25
22
  import 'util';
26
23
  import 'fs/promises';
27
24
  import 'crypto';
@@ -131,6 +128,7 @@ class CodexSession {
131
128
  sessionId;
132
129
  mode;
133
130
  thinking = false;
131
+ localModePinned = false;
134
132
  keepAliveInterval;
135
133
  onModeChangeCallback;
136
134
  clientSwapCallbacks = [];
@@ -185,11 +183,23 @@ class CodexSession {
185
183
  this.thinking = thinking;
186
184
  this.client.keepAlive(thinking, this.mode);
187
185
  };
188
- onModeChange = (mode) => {
186
+ onModeChange = async (mode) => {
189
187
  this.mode = mode;
190
188
  this.client.keepAlive(this.thinking, mode);
191
- this.onModeChangeCallback(mode);
189
+ await this.onModeChangeCallback(mode);
192
190
  };
191
+ pinLocalMode = () => {
192
+ this.localModePinned = true;
193
+ logger.debug("[CodexSession] Local mode pinned");
194
+ };
195
+ clearLocalModePin = () => {
196
+ if (!this.localModePinned) {
197
+ return;
198
+ }
199
+ this.localModePinned = false;
200
+ logger.debug("[CodexSession] Local mode pin cleared");
201
+ };
202
+ isLocalModePinned = () => this.localModePinned;
193
203
  onSessionFound = (sessionId) => {
194
204
  if (this.sessionId === sessionId) {
195
205
  return;
@@ -232,21 +242,6 @@ function extractCodexSessionIdFromPath(filePath) {
232
242
  const match = filePath.match(/-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.jsonl$/);
233
243
  return match?.[1] ?? null;
234
244
  }
235
- function findCodexResumeFile(sessionId) {
236
- if (!sessionId) return null;
237
- try {
238
- const candidates = collectFilesRecursive(getCodexSessionsRoot()).filter((full) => full.endsWith(`-${sessionId}.jsonl`)).filter((full) => {
239
- try {
240
- return fs.statSync(full).isFile();
241
- } catch {
242
- return false;
243
- }
244
- }).sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
245
- return candidates[0] || null;
246
- } catch {
247
- return null;
248
- }
249
- }
250
245
  function findLatestCodexSessionIdSince(startTimeMs) {
251
246
  try {
252
247
  const candidates = collectFilesRecursive(getCodexSessionsRoot()).filter((full) => full.endsWith(".jsonl")).filter((full) => {
@@ -268,6 +263,36 @@ function findLatestCodexSessionIdSince(startTimeMs) {
268
263
  return null;
269
264
  }
270
265
 
266
+ function firstExistingPath(candidates) {
267
+ for (const candidate of candidates) {
268
+ try {
269
+ if (fs.existsSync(candidate)) {
270
+ return candidate;
271
+ }
272
+ } catch {
273
+ }
274
+ }
275
+ return null;
276
+ }
277
+ function resolveCodexExecutable() {
278
+ if (process.platform === "win32") {
279
+ const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
280
+ const npmGlobalBin = path.join(appData, "npm");
281
+ const resolved = firstExistingPath([
282
+ path.join(npmGlobalBin, "codex.cmd"),
283
+ path.join(npmGlobalBin, "codex.ps1"),
284
+ path.join(npmGlobalBin, "codex")
285
+ ]);
286
+ if (resolved) {
287
+ return resolved;
288
+ }
289
+ }
290
+ return "codex";
291
+ }
292
+ function shouldUseShellForCodex(executable) {
293
+ return process.platform === "win32" && /\.(cmd|bat|ps1)$/i.test(executable);
294
+ }
295
+
271
296
  class CodexExitCodeError extends Error {
272
297
  exitCode;
273
298
  constructor(exitCode) {
@@ -385,10 +410,18 @@ async function codexLocalLauncher(session) {
385
410
  session.client.rpcHandlerManager.registerHandler("abort", doAbort);
386
411
  session.client.rpcHandlerManager.registerHandler("switch", doSwitch);
387
412
  session.queue.setOnMessage(() => {
413
+ if (session.isLocalModePinned()) {
414
+ logger.debug("[codex-local]: message arrived while local mode is pinned; staying local");
415
+ return;
416
+ }
388
417
  void doSwitch();
389
418
  });
390
419
  if (session.queue.size() > 0) {
391
- return { type: "switch" };
420
+ if (session.isLocalModePinned()) {
421
+ logger.debug("[codex-local]: pending queued messages detected, but local mode is pinned; staying local");
422
+ } else {
423
+ return { type: "switch" };
424
+ }
392
425
  }
393
426
  while (true) {
394
427
  if (exitReason) {
@@ -522,7 +555,7 @@ const CodexDisplay = ({ messageBuffer, logPath, onExit, onSwitchToLocal, title }
522
555
  }
523
556
  };
524
557
  const formatMessage = (msg) => {
525
- const lines = msg.content.split("\n");
558
+ const lines = formatDisplayMessage(msg.content).split("\n");
526
559
  const maxLineLength = terminalWidth - 10;
527
560
  return lines.map((line) => {
528
561
  if (line.length <= maxLineLength) return line;
@@ -564,462 +597,6 @@ const CodexDisplay = ({ messageBuffer, logPath, onExit, onSwitchToLocal, title }
564
597
  ));
565
598
  };
566
599
 
567
- const DEFAULT_TIMEOUT = 14 * 24 * 60 * 60 * 1e3;
568
- function getCodexMcpCommand() {
569
- const codexExecutable = resolveCodexExecutable();
570
- try {
571
- const version = execSync(`"${codexExecutable}" --version`, { encoding: "utf8" }).trim();
572
- const match = version.match(/codex-cli\s+(\d+\.\d+\.\d+(?:-alpha\.\d+)?)/);
573
- if (!match) {
574
- logger.debug("[CodexMCP] Could not parse codex version:", version);
575
- return null;
576
- }
577
- const versionStr = match[1];
578
- const [major, minor, patch] = versionStr.split(/[-.]/).map(Number);
579
- if (major > 0 || minor > 43) return "mcp-server";
580
- if (minor === 43 && patch === 0) {
581
- if (versionStr.includes("-alpha.")) {
582
- const alphaNum = parseInt(versionStr.split("-alpha.")[1]);
583
- return alphaNum >= 5 ? "mcp-server" : "mcp";
584
- }
585
- return "mcp-server";
586
- }
587
- return "mcp";
588
- } catch (error) {
589
- logger.debug("[CodexMCP] Codex CLI not found or not executable:", error);
590
- return null;
591
- }
592
- }
593
- function resolveCodexMcpTransport(executable, mcpCommand) {
594
- return {
595
- // Preserve the resolved executable path so daemon/service environments do not
596
- // depend on APPDATA npm shims being present on PATH.
597
- command: executable,
598
- args: [mcpCommand]
599
- };
600
- }
601
- function extractCodexIdentifiers(response) {
602
- const meta = response?.meta || {};
603
- const structured = response?.structuredContent || response?.structured_content || response?.structured_output || {};
604
- const sessionId = meta.sessionId || meta.session_id || response?.sessionId || response?.session_id || meta.threadId || meta.thread_id || response?.threadId || response?.thread_id || structured.threadId || structured.thread_id || null;
605
- const conversationId = meta.conversationId || meta.conversation_id || response?.conversationId || response?.conversation_id || meta.threadId || meta.thread_id || response?.threadId || response?.thread_id || structured.threadId || structured.thread_id || structured.conversationId || structured.conversation_id || null;
606
- return { sessionId, conversationId };
607
- }
608
- function normalizeCodexPermissionRequest(params) {
609
- return {
610
- requestId: params.codex_call_id || params.call_id || params.codex_mcp_tool_call_id || params.codex_event_id || `codex-permission-${Date.now()}`,
611
- toolName: "CodexBash",
612
- input: {
613
- command: params.codex_command || params.command,
614
- cwd: params.codex_cwd || params.cwd,
615
- proposed_execpolicy_amendment: params.proposed_execpolicy_amendment,
616
- parsed_cmd: params.parsed_cmd
617
- }
618
- };
619
- }
620
- function resolveCodexPermissionRequest(params, fallback) {
621
- const normalized = normalizeCodexPermissionRequest(params);
622
- const hasInlinePayload = Boolean(
623
- normalized.input.command || normalized.input.cwd || normalized.input.proposed_execpolicy_amendment || normalized.input.parsed_cmd
624
- );
625
- const hasInlineId = Boolean(
626
- params.codex_call_id || params.call_id || params.codex_mcp_tool_call_id || params.codex_event_id
627
- );
628
- if (!fallback || hasInlinePayload && hasInlineId) {
629
- return normalized;
630
- }
631
- return {
632
- requestId: hasInlineId ? normalized.requestId : fallback.call_id || normalized.requestId,
633
- toolName: normalized.toolName,
634
- input: {
635
- command: normalized.input.command || fallback.command,
636
- cwd: normalized.input.cwd || fallback.cwd,
637
- proposed_execpolicy_amendment: normalized.input.proposed_execpolicy_amendment || fallback.proposed_execpolicy_amendment,
638
- parsed_cmd: normalized.input.parsed_cmd || fallback.parsed_cmd
639
- }
640
- };
641
- }
642
- class CodexMcpClient {
643
- client;
644
- transport = null;
645
- connected = false;
646
- sessionId = null;
647
- conversationId = null;
648
- handler = null;
649
- permissionHandler = null;
650
- selectionHandler = null;
651
- pendingExecApprovalContexts = [];
652
- constructor() {
653
- this.client = this.createClient();
654
- }
655
- createClient() {
656
- const client = new Client(
657
- { name: "happy-codex-client", version: "1.0.0" },
658
- { capabilities: { elicitation: {} } }
659
- );
660
- client.setNotificationHandler(z.object({
661
- method: z.literal("codex/event"),
662
- params: z.object({
663
- msg: z.any()
664
- })
665
- }).passthrough(), (data) => {
666
- const msg = data.params.msg;
667
- if (msg?.type === "exec_approval_request") {
668
- this.pendingExecApprovalContexts.push({
669
- call_id: msg.call_id,
670
- command: msg.command,
671
- cwd: msg.cwd,
672
- proposed_execpolicy_amendment: msg.proposed_execpolicy_amendment,
673
- parsed_cmd: msg.parsed_cmd
674
- });
675
- }
676
- this.updateIdentifiersFromEvent(msg);
677
- this.handler?.(msg);
678
- });
679
- return client;
680
- }
681
- setHandler(handler) {
682
- this.handler = handler;
683
- }
684
- /**
685
- * Set the permission handler for tool approval
686
- */
687
- setPermissionHandler(handler) {
688
- this.permissionHandler = handler;
689
- }
690
- setSelectionHandler(handler) {
691
- this.selectionHandler = handler;
692
- }
693
- async connect() {
694
- if (this.connected) return;
695
- const mcpCommand = getCodexMcpCommand();
696
- if (mcpCommand === null) {
697
- throw new Error(
698
- "Codex CLI not found or not executable.\n\nTo install codex:\n npm install -g @openai/codex\n\nAlternatively, use Claude:\n happy claude"
699
- );
700
- }
701
- logger.debug(`[CodexMCP] Connecting to Codex MCP server using command: codex ${mcpCommand}`);
702
- const codexExecutable = resolveCodexExecutable();
703
- const transport = resolveCodexMcpTransport(codexExecutable, mcpCommand);
704
- this.transport = new StdioClientTransport({
705
- command: transport.command,
706
- args: transport.args,
707
- env: Object.keys(process.env).reduce((acc, key) => {
708
- const value = process.env[key];
709
- if (typeof value === "string") acc[key] = value;
710
- return acc;
711
- }, {})
712
- });
713
- this.registerPermissionHandlers();
714
- await this.client.connect(this.transport);
715
- this.connected = true;
716
- logger.debug("[CodexMCP] Connected to Codex");
717
- }
718
- isClosedTransportError(error) {
719
- const record = error;
720
- const code = typeof record?.code === "number" ? record.code : null;
721
- if (code === -32e3) {
722
- return true;
723
- }
724
- const message = typeof record?.message === "string" ? record.message.toLowerCase() : "";
725
- if (code === -32001 && message.includes("aborterror")) {
726
- return true;
727
- }
728
- return message.includes("connection closed") || message.includes("not connected");
729
- }
730
- async callToolWithReconnectRetry(call) {
731
- try {
732
- return await call();
733
- } catch (error) {
734
- if (!this.isClosedTransportError(error)) {
735
- throw error;
736
- }
737
- logger.debug("[CodexMCP] Transport closed during tool call; reconnecting and retrying once");
738
- try {
739
- await this.disconnect();
740
- } catch {
741
- }
742
- await this.connect();
743
- return await call();
744
- }
745
- }
746
- registerPermissionHandlers() {
747
- this.client.setRequestHandler(
748
- ElicitRequestSchema,
749
- async (request) => {
750
- const params = request.params;
751
- logger.debug("[CodexMCP] Received elicitation request:", params);
752
- try {
753
- if (this.isSelectionRequest(params)) {
754
- return await this.handleSelectionRequest(params);
755
- }
756
- if (!this.permissionHandler) {
757
- logger.debug("[CodexMCP] No permission handler set, denying by default");
758
- return {
759
- action: "decline",
760
- decision: "denied",
761
- content: {
762
- decision: "denied"
763
- }
764
- };
765
- }
766
- const normalized = resolveCodexPermissionRequest(
767
- params,
768
- this.takeExecApprovalContextForPermission(params)
769
- );
770
- const result = await this.permissionHandler.handleToolCall(
771
- normalized.requestId,
772
- normalized.toolName,
773
- normalized.input
774
- );
775
- logger.debug("[CodexMCP] Permission result:", result);
776
- return {
777
- action: result.decision === "abort" ? "cancel" : result.decision === "denied" ? "decline" : "accept",
778
- decision: result.decision,
779
- content: {
780
- decision: result.decision
781
- }
782
- };
783
- } catch (error) {
784
- if (error instanceof Error && error.message === "Session reset") {
785
- logger.debug("[CodexMCP] Permission request cancelled during session reset");
786
- return {
787
- action: "cancel"
788
- };
789
- }
790
- if (error instanceof Error && error.message === INTERACTION_SUPERSEDED_ERROR) {
791
- logger.debug("[CodexMCP] Selection request cancelled because a new user message arrived");
792
- return {
793
- action: "cancel"
794
- };
795
- }
796
- logger.debug("[CodexMCP] Error handling permission request:", error);
797
- return {
798
- action: "decline",
799
- decision: "denied",
800
- reason: error instanceof Error ? error.message : "Permission request failed"
801
- };
802
- }
803
- }
804
- );
805
- logger.debug("[CodexMCP] Permission handlers registered");
806
- }
807
- isSelectionRequest(params) {
808
- if (Array.isArray(params.codex_command) && params.codex_command.length > 0) {
809
- return false;
810
- }
811
- if (Array.isArray(params.options) && params.options.length > 0) {
812
- return true;
813
- }
814
- return Boolean(params.requestedSchema?.properties?.optionId);
815
- }
816
- async handleSelectionRequest(params) {
817
- const optionMap = (params.options || []).filter((option) => Boolean(option.optionId));
818
- const options = optionMap.map((option) => ({
819
- optionId: option.optionId,
820
- label: option.label || option.name || option.optionId,
821
- description: option.description || option.name || option.optionId
822
- }));
823
- const defaultOptionId = params.requestedSchema?.properties?.optionId?.default;
824
- if (options.length === 0) {
825
- logger.debug("[CodexMCP] Selection request has no options, cancelling");
826
- return {
827
- action: "cancel"
828
- };
829
- }
830
- if (!this.selectionHandler) {
831
- logger.debug("[CodexMCP] No selection handler set, cancelling selection request");
832
- return {
833
- action: "cancel"
834
- };
835
- }
836
- const requestId = params.codex_event_id || params.codex_elicitation || `selection-${Date.now()}`;
837
- const response = await this.selectionHandler.requestSelection({
838
- id: requestId,
839
- message: params.message,
840
- options,
841
- defaultOptionId
842
- });
843
- return {
844
- action: "accept",
845
- optionId: response.optionId,
846
- content: {
847
- optionId: response.optionId
848
- }
849
- };
850
- }
851
- takeExecApprovalContextForPermission(params) {
852
- const hasInlinePayload = Boolean(
853
- params.codex_command || params.command || params.codex_cwd || params.cwd || params.proposed_execpolicy_amendment || params.parsed_cmd
854
- );
855
- const explicitId = params.codex_call_id || params.call_id || params.codex_mcp_tool_call_id || params.codex_event_id;
856
- if (hasInlinePayload && explicitId) {
857
- return null;
858
- }
859
- if (!this.pendingExecApprovalContexts.length) {
860
- return null;
861
- }
862
- if (explicitId) {
863
- const index = this.pendingExecApprovalContexts.findIndex((context) => context.call_id === explicitId);
864
- if (index >= 0) {
865
- return this.pendingExecApprovalContexts.splice(index, 1)[0] || null;
866
- }
867
- }
868
- return this.pendingExecApprovalContexts.shift() || null;
869
- }
870
- async startSession(config, options) {
871
- if (!this.connected) await this.connect();
872
- logger.debug("[CodexMCP] Starting Codex session:", config);
873
- const response = await this.callToolWithReconnectRetry(
874
- () => this.client.callTool({
875
- name: "codex",
876
- arguments: config
877
- }, void 0, {
878
- signal: options?.signal,
879
- timeout: DEFAULT_TIMEOUT
880
- })
881
- );
882
- logger.debug("[CodexMCP] startSession response:", response);
883
- this.extractIdentifiers(response);
884
- return response;
885
- }
886
- async continueSession(prompt, options) {
887
- if (!this.connected) await this.connect();
888
- if (!this.sessionId) {
889
- throw new Error("No active session. Call startSession first.");
890
- }
891
- if (!this.conversationId) {
892
- this.conversationId = this.sessionId;
893
- logger.debug("[CodexMCP] conversationId missing, defaulting to sessionId:", this.conversationId);
894
- }
895
- const args = { sessionId: this.sessionId, conversationId: this.conversationId, prompt };
896
- logger.debug("[CodexMCP] Continuing Codex session:", args);
897
- const response = await this.callToolWithReconnectRetry(
898
- () => this.client.callTool({
899
- name: "codex-reply",
900
- arguments: args
901
- }, void 0, {
902
- signal: options?.signal,
903
- timeout: DEFAULT_TIMEOUT
904
- })
905
- );
906
- logger.debug("[CodexMCP] continueSession response:", response);
907
- this.extractIdentifiers(response);
908
- return response;
909
- }
910
- updateIdentifiersFromEvent(event) {
911
- if (!event || typeof event !== "object") {
912
- return;
913
- }
914
- const candidates = [event];
915
- if (event.data && typeof event.data === "object") {
916
- candidates.push(event.data);
917
- }
918
- for (const candidate of candidates) {
919
- const sessionId = candidate.session_id ?? candidate.sessionId ?? candidate.thread_id ?? candidate.threadId;
920
- if (sessionId) {
921
- this.sessionId = sessionId;
922
- logger.debug("[CodexMCP] Session ID extracted from event:", this.sessionId);
923
- }
924
- const conversationId = candidate.conversation_id ?? candidate.conversationId;
925
- if (conversationId) {
926
- this.conversationId = conversationId;
927
- logger.debug("[CodexMCP] Conversation ID extracted from event:", this.conversationId);
928
- }
929
- }
930
- }
931
- extractIdentifiers(response) {
932
- const identifiers = extractCodexIdentifiers(response);
933
- if (identifiers.sessionId) {
934
- this.sessionId = identifiers.sessionId;
935
- logger.debug("[CodexMCP] Session ID extracted:", this.sessionId);
936
- }
937
- if (identifiers.conversationId) {
938
- this.conversationId = identifiers.conversationId;
939
- logger.debug("[CodexMCP] Conversation ID extracted:", this.conversationId);
940
- }
941
- const content = response?.content;
942
- if (Array.isArray(content)) {
943
- for (const item of content) {
944
- if (!this.sessionId && item?.sessionId) {
945
- this.sessionId = item.sessionId;
946
- logger.debug("[CodexMCP] Session ID extracted from content:", this.sessionId);
947
- }
948
- if (!this.conversationId && item && typeof item === "object" && "conversationId" in item && item.conversationId) {
949
- this.conversationId = item.conversationId;
950
- logger.debug("[CodexMCP] Conversation ID extracted from content:", this.conversationId);
951
- }
952
- }
953
- }
954
- }
955
- getSessionId() {
956
- return this.sessionId;
957
- }
958
- hasActiveSession() {
959
- return this.sessionId !== null;
960
- }
961
- clearSession() {
962
- const previousSessionId = this.sessionId;
963
- this.sessionId = null;
964
- this.conversationId = null;
965
- logger.debug("[CodexMCP] Session cleared, previous sessionId:", previousSessionId);
966
- }
967
- /**
968
- * Store the current session ID without clearing it, useful for abort handling
969
- */
970
- storeSessionForResume() {
971
- logger.debug("[CodexMCP] Storing session for potential resume:", this.sessionId);
972
- return this.sessionId;
973
- }
974
- /**
975
- * Force close the Codex MCP transport and clear all session identifiers.
976
- * Use this for permanent shutdown (e.g. kill/exit). Prefer `disconnect()` for
977
- * transient connection resets where you may want to keep the session id.
978
- */
979
- async forceCloseSession() {
980
- logger.debug("[CodexMCP] Force closing session");
981
- try {
982
- await this.disconnect();
983
- } finally {
984
- this.clearSession();
985
- }
986
- logger.debug("[CodexMCP] Session force-closed");
987
- }
988
- async disconnect() {
989
- if (!this.connected) return;
990
- const pid = this.transport?.pid ?? null;
991
- logger.debug(`[CodexMCP] Disconnecting; child pid=${pid ?? "none"}`);
992
- try {
993
- logger.debug("[CodexMCP] client.close begin");
994
- await this.client.close();
995
- logger.debug("[CodexMCP] client.close done");
996
- } catch (e) {
997
- logger.debug("[CodexMCP] Error closing client, attempting transport close directly", e);
998
- try {
999
- logger.debug("[CodexMCP] transport.close begin");
1000
- await this.transport?.close?.();
1001
- logger.debug("[CodexMCP] transport.close done");
1002
- } catch {
1003
- }
1004
- }
1005
- if (pid) {
1006
- try {
1007
- process.kill(pid, 0);
1008
- logger.debug("[CodexMCP] Child still alive, sending SIGKILL");
1009
- try {
1010
- process.kill(pid, "SIGKILL");
1011
- } catch {
1012
- }
1013
- } catch {
1014
- }
1015
- }
1016
- this.transport = null;
1017
- this.connected = false;
1018
- this.client = this.createClient();
1019
- logger.debug(`[CodexMCP] Disconnected; session ${this.sessionId ?? "none"} preserved`);
1020
- }
1021
- }
1022
-
1023
600
  class CodexPermissionHandler extends BasePermissionHandler {
1024
601
  constructor(session) {
1025
602
  super(session);
@@ -1035,16 +612,7 @@ class CodexPermissionHandler extends BasePermissionHandler {
1035
612
  * @returns Promise resolving to permission result
1036
613
  */
1037
614
  async handleToolCall(toolCallId, toolName, input) {
1038
- return new Promise((resolve, reject) => {
1039
- this.pendingRequests.set(toolCallId, {
1040
- resolve,
1041
- reject,
1042
- toolName,
1043
- input
1044
- });
1045
- this.addPendingRequestToState(toolCallId, toolName, input);
1046
- logger.debug(`${this.getLogPrefix()} Permission request sent for tool: ${toolName} (${toolCallId})`);
1047
- });
615
+ return this.registerPendingRequest(toolCallId, toolName, input);
1048
616
  }
1049
617
  }
1050
618
 
@@ -1061,11 +629,15 @@ class CodexSelectionHandler {
1061
629
  }
1062
630
  async requestSelection(request) {
1063
631
  return new Promise((resolve, reject) => {
1064
- this.pendingRequests.set(request.id, {
632
+ const pending = {
1065
633
  resolve,
1066
634
  reject,
1067
635
  request
1068
- });
636
+ };
637
+ pending.timeoutHandle = setTimeout(() => {
638
+ this.handleSelectionTimeout(request.id, pending);
639
+ }, getPendingInteractionTimeoutMs());
640
+ this.pendingRequests.set(request.id, pending);
1069
641
  this.session.updateAgentState((currentState) => ({
1070
642
  ...currentState,
1071
643
  requests: {
@@ -1108,6 +680,7 @@ class CodexSelectionHandler {
1108
680
  this.pendingRequests.clear();
1109
681
  const completedAt = Date.now();
1110
682
  for (const [, pending] of pendingSnapshot) {
683
+ this.clearPendingRequestTimeout(pending);
1111
684
  pending.reject(new Error(reason));
1112
685
  }
1113
686
  this.session.updateAgentState((currentState) => {
@@ -1139,6 +712,7 @@ class CodexSelectionHandler {
1139
712
  const pendingSnapshot = Array.from(this.pendingRequests.entries());
1140
713
  this.pendingRequests.clear();
1141
714
  for (const [, pending] of pendingSnapshot) {
715
+ this.clearPendingRequestTimeout(pending);
1142
716
  pending.reject(new Error(reason));
1143
717
  }
1144
718
  this.session.updateAgentState((currentState) => {
@@ -1172,6 +746,7 @@ class CodexSelectionHandler {
1172
746
  return;
1173
747
  }
1174
748
  this.pendingRequests.delete(response.id);
749
+ this.clearPendingRequestTimeout(pending);
1175
750
  pending.resolve(response);
1176
751
  this.session.updateAgentState((currentState) => {
1177
752
  const request = currentState.requests?.[response.id];
@@ -1196,6 +771,54 @@ class CodexSelectionHandler {
1196
771
  });
1197
772
  });
1198
773
  }
774
+ clearPendingRequestTimeout(pending) {
775
+ if (pending?.timeoutHandle) {
776
+ clearTimeout(pending.timeoutHandle);
777
+ pending.timeoutHandle = void 0;
778
+ }
779
+ }
780
+ handleSelectionTimeout(requestId, pending) {
781
+ const active = this.pendingRequests.get(requestId);
782
+ if (!active || active !== pending) {
783
+ return;
784
+ }
785
+ this.pendingRequests.delete(requestId);
786
+ this.clearPendingRequestTimeout(active);
787
+ active.reject(new Error(INTERACTION_TIMED_OUT_ERROR));
788
+ this.session.updateAgentState((currentState) => {
789
+ const request = currentState.requests?.[requestId] || {
790
+ tool: "AskUserQuestion",
791
+ arguments: {
792
+ requestKind: "selection",
793
+ questions: []
794
+ },
795
+ createdAt: Date.now(),
796
+ requestKind: "selection",
797
+ options: active.request.options,
798
+ defaultOptionId: active.request.defaultOptionId
799
+ };
800
+ const { [requestId]: _, ...remainingRequests } = currentState.requests || {};
801
+ return {
802
+ ...currentState,
803
+ requests: remainingRequests,
804
+ completedRequests: {
805
+ ...currentState.completedRequests,
806
+ [requestId]: {
807
+ ...request,
808
+ completedAt: Date.now(),
809
+ status: "canceled",
810
+ reason: INTERACTION_TIMED_OUT_ERROR,
811
+ requestKind: "selection"
812
+ }
813
+ }
814
+ };
815
+ });
816
+ this.session.sendSessionEvent({
817
+ type: "message",
818
+ message: "Pending interaction timed out waiting for a response. Send a new message to continue."
819
+ });
820
+ logger.debug(`[Codex] Selection request timed out (${requestId})`);
821
+ }
1199
822
  }
1200
823
 
1201
824
  class ReasoningProcessor extends BaseReasoningProcessor {
@@ -1217,67 +840,62 @@ class ReasoningProcessor extends BaseReasoningProcessor {
1217
840
  complete(fullText) {
1218
841
  this.completeReasoning(fullText);
1219
842
  }
843
+ completeCurrent() {
844
+ return this.completeReasoning();
845
+ }
1220
846
  }
1221
847
 
1222
- class DiffProcessor {
1223
- previousDiff = null;
1224
- onMessage = null;
1225
- constructor(onMessage) {
1226
- this.onMessage = onMessage || null;
1227
- }
1228
- /**
1229
- * Process a turn_diff message and check if the unified_diff has changed
1230
- */
1231
- processDiff(unifiedDiff) {
1232
- if (this.previousDiff !== unifiedDiff) {
1233
- logger.debug("[DiffProcessor] Unified diff changed, sending CodexDiff tool call");
1234
- const callId = randomUUID();
1235
- const toolCall = {
1236
- type: "tool-call",
1237
- name: "CodexDiff",
1238
- callId,
1239
- input: {
1240
- unified_diff: unifiedDiff
1241
- },
1242
- id: randomUUID()
1243
- };
1244
- this.onMessage?.(toolCall);
1245
- const toolResult = {
1246
- type: "tool-call-result",
1247
- callId,
1248
- output: {
1249
- status: "completed"
1250
- },
1251
- id: randomUUID()
1252
- };
1253
- this.onMessage?.(toolResult);
1254
- }
1255
- this.previousDiff = unifiedDiff;
1256
- logger.debug("[DiffProcessor] Updated stored diff");
1257
- }
1258
- /**
1259
- * Reset the processor state (called on task_complete or turn_aborted)
1260
- */
1261
- reset() {
1262
- logger.debug("[DiffProcessor] Resetting diff state");
1263
- this.previousDiff = null;
1264
- }
1265
- /**
1266
- * Set the message callback for sending messages directly
1267
- */
1268
- setMessageCallback(callback) {
1269
- this.onMessage = callback;
848
+ function createAbortError() {
849
+ const error = new Error("Operation aborted");
850
+ error.name = "AbortError";
851
+ return error;
852
+ }
853
+ async function waitForResponseCompleteWithAbort(backend, signal, timeoutMs = 12e4) {
854
+ if (!backend.waitForResponseComplete) {
855
+ return;
1270
856
  }
1271
- /**
1272
- * Get the current diff value
1273
- */
1274
- getCurrentDiff() {
1275
- return this.previousDiff;
857
+ if (signal.aborted) {
858
+ throw createAbortError();
1276
859
  }
860
+ await new Promise((resolve, reject) => {
861
+ const onAbort = () => reject(createAbortError());
862
+ signal.addEventListener("abort", onAbort, { once: true });
863
+ backend.waitForResponseComplete(timeoutMs).then(resolve).catch(reject).finally(() => {
864
+ signal.removeEventListener("abort", onAbort);
865
+ });
866
+ });
1277
867
  }
1278
-
1279
- function shouldEmitCodexReadyOnTurnEnd(opts) {
1280
- return !opts.shouldExit && opts.exitReason !== "switch";
868
+ function normalizeCodexBackendError(error) {
869
+ const record = typeof error === "object" && error !== null ? error : null;
870
+ const text = formatDisplayMessage(error).trim();
871
+ const stderrText = record ? formatDisplayMessage(record.stderr).trim() : "";
872
+ const detailText = record ? formatDisplayMessage(record.detail).trim() : "";
873
+ const searchableText = [text, stderrText, detailText].filter(Boolean).join("\n");
874
+ const prefix = typeof error === "object" && error !== null ? [
875
+ record?.code !== void 0 && record?.code !== null ? `[code=${String(record.code)}]` : "",
876
+ record?.status !== void 0 && record?.status !== null ? `[status=${String(record.status)}]` : ""
877
+ ].filter(Boolean).join(" ") : "";
878
+ if (searchableText.includes("Failed to locate @zed-industries/codex-acp-")) {
879
+ const hint = "Codex ACP could not start because the @zed-industries/codex-acp platform binary is missing. Reinstall codex-acp for this machine, or set HAPPY_CODEX_ACP_BIN to a working codex-acp executable.";
880
+ return prefix ? `${prefix} ${hint}` : hint;
881
+ }
882
+ if (searchableText.includes("method not found: initialize") || searchableText.includes("method not found: session/new")) {
883
+ const hint = "The configured Codex ACP command does not speak the ACP protocol. Make sure HAPPY_CODEX_ACP_BIN points to codex-acp, not the codex CLI.";
884
+ return prefix ? `${prefix} ${hint}` : hint;
885
+ }
886
+ if (error instanceof Error && text) {
887
+ return text;
888
+ }
889
+ if (typeof error === "object" && error !== null && !Array.isArray(error) && Object.keys(error).length === 0) {
890
+ return "Codex backend exited unexpectedly";
891
+ }
892
+ if (typeof error === "object" && error !== null) {
893
+ const message = text;
894
+ if (message) {
895
+ return prefix ? `${prefix} ${message}` : message;
896
+ }
897
+ }
898
+ return text || "Codex backend exited unexpectedly";
1281
899
  }
1282
900
  function shouldEmitCodexReadyAfterWait(opts) {
1283
901
  if (opts.readyAlreadySent) {
@@ -1290,6 +908,68 @@ function registerCodexRemoteRpcHandlers(clientSession, handlers) {
1290
908
  clientSession.rpcHandlerManager.registerHandler("switch", handlers.handleSwitchToLocal);
1291
909
  registerKillSessionHandler(clientSession.rpcHandlerManager, handlers.handleKillSession);
1292
910
  }
911
+ function handleIncomingCodexMessageDuringRemoteTurn(opts) {
912
+ const reason = opts.reason ?? INTERACTION_SUPERSEDED_ERROR;
913
+ const supersededPermissions = opts.supersedePermissions(reason);
914
+ const supersededSelections = opts.supersedeSelections(reason);
915
+ const interruptedTurn = opts.turnInFlight;
916
+ const total = supersededPermissions + supersededSelections;
917
+ if (interruptedTurn) {
918
+ opts.abortActiveTurn();
919
+ }
920
+ if (total > 0 || interruptedTurn) {
921
+ const message = interruptedTurn ? "Current Codex task was canceled because a new user message arrived. Processing the latest message instead." : "Previous pending interaction was canceled because a new user message arrived. Processing the latest message instead.";
922
+ opts.onInterrupted(message);
923
+ }
924
+ return total > 0 || interruptedTurn;
925
+ }
926
+ function resolveCodexAcpExecutionMode(mode) {
927
+ const approvalPolicy = (() => {
928
+ switch (mode.permissionMode) {
929
+ case "default":
930
+ return "on-request";
931
+ case "read-only":
932
+ return "on-request";
933
+ case "safe-yolo":
934
+ return "on-request";
935
+ case "yolo":
936
+ return "never";
937
+ case "bypassPermissions":
938
+ return "never";
939
+ case "acceptEdits":
940
+ return "on-request";
941
+ case "plan":
942
+ return "on-request";
943
+ default:
944
+ return "on-request";
945
+ }
946
+ })();
947
+ const sandbox = (() => {
948
+ switch (mode.permissionMode) {
949
+ case "default":
950
+ return "workspace-write";
951
+ case "read-only":
952
+ return "read-only";
953
+ case "safe-yolo":
954
+ return "workspace-write";
955
+ case "yolo":
956
+ return "danger-full-access";
957
+ case "bypassPermissions":
958
+ return "danger-full-access";
959
+ case "acceptEdits":
960
+ return "workspace-write";
961
+ case "plan":
962
+ return "read-only";
963
+ default:
964
+ return "read-only";
965
+ }
966
+ })();
967
+ return {
968
+ approvalPolicy,
969
+ sandbox,
970
+ model: mode.model
971
+ };
972
+ }
1293
973
  async function codexRemoteLauncher(session) {
1294
974
  const messageBuffer = new MessageBuffer();
1295
975
  const hasTTY = process.stdout.isTTY && process.stdin.isTTY;
@@ -1297,46 +977,26 @@ async function codexRemoteLauncher(session) {
1297
977
  let exitReason = null;
1298
978
  let shouldExit = false;
1299
979
  let abortController = new AbortController();
1300
- let storedSessionIdForResume = null;
1301
- const client = new CodexMcpClient();
1302
- let pendingModeResumeFile = null;
980
+ let turnInFlight = false;
981
+ let backend = null;
982
+ let acpSessionId = null;
983
+ let wasSessionCreated = false;
984
+ let currentModeHash = null;
985
+ let pending = null;
986
+ let readyAlreadySent = false;
987
+ let accumulatedResponse = "";
988
+ let isResponseInProgress = false;
989
+ let taskStartedSent = false;
1303
990
  const permissionHandler = new CodexPermissionHandler(session.client);
1304
991
  const selectionHandler = new CodexSelectionHandler(session.client);
1305
992
  const reasoningProcessor = new ReasoningProcessor((message) => {
1306
993
  session.runtimeSession.sendCodexMessage(message);
1307
994
  });
1308
- const diffProcessor = new DiffProcessor((message) => {
1309
- session.runtimeSession.sendCodexMessage(message);
1310
- });
1311
- const handleSwitchToLocal = async () => {
1312
- logger.debug("[Codex] Switching to local from RPC");
1313
- exitReason = "switch";
1314
- shouldExit = true;
1315
- await handleAbort();
1316
- };
1317
- client.setPermissionHandler(permissionHandler);
1318
- client.setSelectionHandler(selectionHandler);
1319
- session.setPendingInteractionSuperseder((reason = INTERACTION_SUPERSEDED_ERROR) => {
1320
- const supersededPermissions = permissionHandler.supersedePendingRequests(reason);
1321
- const supersededSelections = selectionHandler.supersedePendingRequests(reason);
1322
- const total = supersededPermissions + supersededSelections;
1323
- if (total > 0) {
1324
- const message = "Previous pending interaction was canceled because a new user message arrived. Processing the latest message instead.";
1325
- messageBuffer.addMessage(message, "status");
1326
- session.runtimeSession.sendSessionEvent({ type: "message", message });
1327
- }
1328
- return total > 0;
1329
- });
1330
- const handleClientSwap = (clientSession) => {
1331
- permissionHandler.updateSession(clientSession);
1332
- selectionHandler.updateSession(clientSession);
1333
- registerCodexRemoteRpcHandlers(clientSession, {
1334
- handleAbort,
1335
- handleSwitchToLocal,
1336
- handleKillSession
1337
- });
995
+ const rotateAbortController = () => {
996
+ const activeController = abortController;
997
+ abortController = new AbortController();
998
+ return activeController;
1338
999
  };
1339
- session.addClientSwapCallback(handleClientSwap);
1340
1000
  const sendReady = () => {
1341
1001
  session.runtimeSession.sendSessionEvent({ type: "ready" });
1342
1002
  try {
@@ -1349,18 +1009,282 @@ async function codexRemoteLauncher(session) {
1349
1009
  logger.debug("[Codex] Failed to send ready push", pushError);
1350
1010
  }
1351
1011
  };
1012
+ const resetTurnState = () => {
1013
+ reasoningProcessor.abort();
1014
+ accumulatedResponse = "";
1015
+ isResponseInProgress = false;
1016
+ taskStartedSent = false;
1017
+ session.onThinkingChange(false);
1018
+ };
1019
+ const abortActiveTurn = () => {
1020
+ const activeController = rotateAbortController();
1021
+ activeController.abort();
1022
+ reasoningProcessor.abort();
1023
+ session.onThinkingChange(false);
1024
+ if (backend && acpSessionId) {
1025
+ void backend.cancel(acpSessionId).catch((error) => {
1026
+ logger.debug("[Codex] Error cancelling ACP session:", error);
1027
+ });
1028
+ }
1029
+ };
1030
+ const disposeBackend = async () => {
1031
+ if (!backend) {
1032
+ return;
1033
+ }
1034
+ const currentBackend = backend;
1035
+ backend = null;
1036
+ acpSessionId = null;
1037
+ wasSessionCreated = false;
1038
+ try {
1039
+ await currentBackend.dispose();
1040
+ } catch (error) {
1041
+ logger.debug("[Codex] Error disposing ACP backend:", error);
1042
+ }
1043
+ };
1044
+ const emitFinalAssistantMessage = () => {
1045
+ if (!accumulatedResponse.trim()) {
1046
+ return;
1047
+ }
1048
+ session.runtimeSession.sendCodexMessage({
1049
+ type: "message",
1050
+ message: accumulatedResponse,
1051
+ id: randomUUID()
1052
+ });
1053
+ accumulatedResponse = "";
1054
+ isResponseInProgress = false;
1055
+ };
1056
+ const setupBackendMessageHandler = (currentBackend) => {
1057
+ currentBackend.onMessage((msg) => {
1058
+ switch (msg.type) {
1059
+ case "model-output": {
1060
+ const text = msg.textDelta ?? msg.fullText ?? "";
1061
+ if (!text) {
1062
+ return;
1063
+ }
1064
+ if (!isResponseInProgress) {
1065
+ messageBuffer.removeLastMessage("system");
1066
+ messageBuffer.addMessage(text, "assistant");
1067
+ isResponseInProgress = true;
1068
+ } else {
1069
+ messageBuffer.updateLastMessage(text, "assistant");
1070
+ }
1071
+ accumulatedResponse += text;
1072
+ return;
1073
+ }
1074
+ case "status": {
1075
+ if (msg.status === "running") {
1076
+ session.onThinkingChange(true);
1077
+ if (!taskStartedSent) {
1078
+ session.runtimeSession.sendCodexMessage({
1079
+ type: "task_started",
1080
+ id: randomUUID()
1081
+ });
1082
+ taskStartedSent = true;
1083
+ }
1084
+ if (!isResponseInProgress) {
1085
+ messageBuffer.addMessage("Thinking...", "system");
1086
+ }
1087
+ return;
1088
+ }
1089
+ if (msg.status === "idle" || msg.status === "stopped") {
1090
+ reasoningProcessor.completeCurrent();
1091
+ return;
1092
+ }
1093
+ if (msg.status === "error") {
1094
+ const errorMessage = normalizeCodexBackendError(msg.detail);
1095
+ messageBuffer.addMessage(`Error: ${errorMessage}`, "status");
1096
+ return;
1097
+ }
1098
+ return;
1099
+ }
1100
+ case "tool-call": {
1101
+ const toolArgs = msg.args ? truncateDisplayMessage(msg.args, 100) : "";
1102
+ messageBuffer.addMessage(
1103
+ `Executing: ${msg.toolName}${toolArgs ? ` ${toolArgs}` : ""}`,
1104
+ "tool"
1105
+ );
1106
+ session.runtimeSession.sendCodexMessage({
1107
+ type: "tool-call",
1108
+ name: msg.toolName,
1109
+ callId: msg.callId,
1110
+ input: msg.args,
1111
+ id: randomUUID()
1112
+ });
1113
+ return;
1114
+ }
1115
+ case "tool-result": {
1116
+ const isError = msg.result && typeof msg.result === "object" && "error" in msg.result;
1117
+ const resultText = truncateDisplayMessage(msg.result, 200) || (isError ? "Unknown error" : "");
1118
+ messageBuffer.addMessage(
1119
+ `${isError ? "Error:" : "Result:"} ${resultText}`.trim(),
1120
+ "result"
1121
+ );
1122
+ session.runtimeSession.sendCodexMessage({
1123
+ type: "tool-call-result",
1124
+ callId: msg.callId,
1125
+ output: msg.result,
1126
+ id: randomUUID()
1127
+ });
1128
+ return;
1129
+ }
1130
+ case "fs-edit": {
1131
+ messageBuffer.addMessage(`File edit: ${msg.description}`, "tool");
1132
+ session.runtimeSession.sendCodexMessage({
1133
+ type: "file-edit",
1134
+ description: msg.description,
1135
+ filePath: msg.path || "unknown",
1136
+ diff: msg.diff,
1137
+ id: randomUUID()
1138
+ });
1139
+ return;
1140
+ }
1141
+ case "terminal-output": {
1142
+ const terminalOutput = formatDisplayMessage(msg.data);
1143
+ messageBuffer.addMessage(terminalOutput, "result");
1144
+ session.runtimeSession.sendCodexMessage({
1145
+ type: "terminal-output",
1146
+ data: terminalOutput,
1147
+ callId: randomUUID()
1148
+ });
1149
+ return;
1150
+ }
1151
+ case "permission-request": {
1152
+ const payload = msg.payload || {};
1153
+ session.runtimeSession.sendCodexMessage({
1154
+ type: "permission-request",
1155
+ permissionId: msg.id,
1156
+ toolName: typeof payload.toolName === "string" ? payload.toolName : msg.reason,
1157
+ description: msg.reason,
1158
+ options: payload
1159
+ });
1160
+ return;
1161
+ }
1162
+ case "exec-approval-request": {
1163
+ const { call_id, type, ...inputs } = msg;
1164
+ messageBuffer.addMessage(`Exec approval requested: ${call_id}`, "tool");
1165
+ session.runtimeSession.sendCodexMessage({
1166
+ type: "tool-call",
1167
+ name: "CodexBash",
1168
+ callId: call_id,
1169
+ input: inputs,
1170
+ id: randomUUID()
1171
+ });
1172
+ return;
1173
+ }
1174
+ case "patch-apply-begin": {
1175
+ const changeCount = Object.keys(msg.changes || {}).length;
1176
+ const filesMsg = changeCount === 1 ? "1 file" : `${changeCount} files`;
1177
+ messageBuffer.addMessage(`Modifying ${filesMsg}...`, "tool");
1178
+ session.runtimeSession.sendCodexMessage({
1179
+ type: "tool-call",
1180
+ name: "CodexPatch",
1181
+ callId: msg.call_id,
1182
+ input: {
1183
+ auto_approved: msg.auto_approved,
1184
+ changes: msg.changes
1185
+ },
1186
+ id: randomUUID()
1187
+ });
1188
+ return;
1189
+ }
1190
+ case "patch-apply-end": {
1191
+ if (msg.success) {
1192
+ messageBuffer.addMessage(truncateDisplayMessage(msg.stdout || "Files modified successfully", 200), "result");
1193
+ } else {
1194
+ messageBuffer.addMessage(`Error: ${truncateDisplayMessage(msg.stderr || "Failed to modify files", 200)}`, "result");
1195
+ }
1196
+ session.runtimeSession.sendCodexMessage({
1197
+ type: "tool-call-result",
1198
+ callId: msg.call_id,
1199
+ output: {
1200
+ stdout: msg.stdout,
1201
+ stderr: msg.stderr,
1202
+ success: msg.success
1203
+ },
1204
+ id: randomUUID()
1205
+ });
1206
+ return;
1207
+ }
1208
+ case "token-count": {
1209
+ session.runtimeSession.sendCodexMessage({
1210
+ ...msg,
1211
+ id: randomUUID()
1212
+ });
1213
+ return;
1214
+ }
1215
+ case "event": {
1216
+ if (msg.name === "thinking") {
1217
+ const thinkingPayload = msg.payload;
1218
+ const thinkingText = typeof thinkingPayload?.text === "string" ? thinkingPayload.text : "";
1219
+ if (thinkingText) {
1220
+ reasoningProcessor.processDelta(thinkingText);
1221
+ if (!thinkingText.startsWith("**")) {
1222
+ messageBuffer.updateLastMessage(`[Thinking] ${thinkingText.substring(0, 100)}...`, "system");
1223
+ }
1224
+ }
1225
+ }
1226
+ return;
1227
+ }
1228
+ default:
1229
+ return;
1230
+ }
1231
+ });
1232
+ };
1233
+ const createBackend = (mode) => {
1234
+ const executionMode = resolveCodexAcpExecutionMode(mode);
1235
+ const validation = validateCodexAcpSpawn({
1236
+ command: process.env.HAPPY_CODEX_ACP_COMMAND,
1237
+ model: executionMode.model,
1238
+ sandbox: executionMode.sandbox,
1239
+ approvalPolicy: executionMode.approvalPolicy,
1240
+ baseArgs: session.codexArgs
1241
+ });
1242
+ if (!validation.ok) {
1243
+ throw new Error(validation.errorMessage);
1244
+ }
1245
+ const result = createCodexBackend({
1246
+ cwd: session.path,
1247
+ baseArgs: session.codexArgs,
1248
+ model: executionMode.model,
1249
+ sandbox: executionMode.sandbox,
1250
+ approvalPolicy: executionMode.approvalPolicy,
1251
+ mcpServers: {},
1252
+ permissionHandler,
1253
+ selectionHandler: {
1254
+ handleSelection: (request) => selectionHandler.requestSelection(request)
1255
+ }
1256
+ });
1257
+ backend = result.backend;
1258
+ setupBackendMessageHandler(result.backend);
1259
+ return result.backend;
1260
+ };
1261
+ const handleSwitchToLocal = async () => {
1262
+ logger.debug("[Codex] Switching to local from RPC");
1263
+ session.pinLocalMode();
1264
+ exitReason = "switch";
1265
+ shouldExit = true;
1266
+ await handleAbort();
1267
+ };
1268
+ session.setPendingInteractionSuperseder((reason = INTERACTION_SUPERSEDED_ERROR) => {
1269
+ return handleIncomingCodexMessageDuringRemoteTurn({
1270
+ reason,
1271
+ supersedePermissions: (value) => permissionHandler.supersedePendingRequests(value),
1272
+ supersedeSelections: (value) => selectionHandler.supersedePendingRequests(value),
1273
+ turnInFlight,
1274
+ abortActiveTurn,
1275
+ onInterrupted: (message) => {
1276
+ logger.debug("[Codex] Incoming user message interrupted ACP remote turn");
1277
+ messageBuffer.addMessage(message, "status");
1278
+ session.runtimeSession.sendSessionEvent({ type: "message", message });
1279
+ }
1280
+ });
1281
+ });
1352
1282
  async function handleAbort() {
1353
1283
  logger.debug("[Codex] Abort requested - stopping current task");
1354
1284
  try {
1355
- if (client.hasActiveSession()) {
1356
- storedSessionIdForResume = client.storeSessionForResume();
1357
- }
1358
- abortController.abort();
1359
- reasoningProcessor.abort();
1285
+ abortActiveTurn();
1360
1286
  } catch (error) {
1361
1287
  logger.debug("[Codex] Error during abort:", error);
1362
- } finally {
1363
- abortController = new AbortController();
1364
1288
  }
1365
1289
  }
1366
1290
  async function handleKillSession() {
@@ -1377,7 +1301,7 @@ async function codexRemoteLauncher(session) {
1377
1301
  session.runtimeSession.sendSessionDeath();
1378
1302
  await session.runtimeSession.flush();
1379
1303
  await session.runtimeSession.close();
1380
- await client.forceCloseSession();
1304
+ await disposeBackend();
1381
1305
  stopCaffeinate();
1382
1306
  process.exit(0);
1383
1307
  } catch (error) {
@@ -1385,6 +1309,16 @@ async function codexRemoteLauncher(session) {
1385
1309
  process.exit(1);
1386
1310
  }
1387
1311
  }
1312
+ const handleClientSwap = (clientSession) => {
1313
+ permissionHandler.updateSession(clientSession);
1314
+ selectionHandler.updateSession(clientSession);
1315
+ registerCodexRemoteRpcHandlers(clientSession, {
1316
+ handleAbort,
1317
+ handleSwitchToLocal,
1318
+ handleKillSession
1319
+ });
1320
+ };
1321
+ session.addClientSwapCallback(handleClientSwap);
1388
1322
  if (hasTTY) {
1389
1323
  console.clear();
1390
1324
  inkInstance = render(React.createElement(CodexDisplay, {
@@ -1399,6 +1333,7 @@ async function codexRemoteLauncher(session) {
1399
1333
  },
1400
1334
  onSwitchToLocal: async () => {
1401
1335
  logger.debug("[Codex] Switching to local from local keyboard");
1336
+ session.pinLocalMode();
1402
1337
  exitReason = "switch";
1403
1338
  shouldExit = true;
1404
1339
  await handleAbort();
@@ -1420,116 +1355,7 @@ async function codexRemoteLauncher(session) {
1420
1355
  handleSwitchToLocal,
1421
1356
  handleKillSession
1422
1357
  });
1423
- client.setHandler((msg) => {
1424
- logger.debug(`[Codex] Message: ${JSON.stringify(msg)}`);
1425
- if (msg.type === "agent_message") {
1426
- messageBuffer.addMessage(msg.message, "assistant");
1427
- } else if (msg.type === "agent_reasoning") {
1428
- messageBuffer.addMessage(`[Thinking] ${msg.text.substring(0, 100)}...`, "system");
1429
- } else if (msg.type === "exec_command_begin") {
1430
- messageBuffer.addMessage(`Executing: ${msg.command}`, "tool");
1431
- } else if (msg.type === "exec_command_end") {
1432
- const output = msg.output || msg.error || "Command completed";
1433
- const truncatedOutput = output.substring(0, 200);
1434
- messageBuffer.addMessage(`Result: ${truncatedOutput}${output.length > 200 ? "..." : ""}`, "result");
1435
- } else if (msg.type === "task_started") {
1436
- messageBuffer.addMessage("Starting task...", "status");
1437
- session.onThinkingChange(true);
1438
- } else if (msg.type === "task_complete") {
1439
- messageBuffer.addMessage("Task completed", "status");
1440
- session.onThinkingChange(false);
1441
- diffProcessor.reset();
1442
- if (shouldEmitCodexReadyOnTurnEnd({ shouldExit, exitReason })) {
1443
- sendReady();
1444
- readyAlreadySent = true;
1445
- }
1446
- } else if (msg.type === "turn_aborted") {
1447
- messageBuffer.addMessage("Turn aborted", "status");
1448
- session.onThinkingChange(false);
1449
- diffProcessor.reset();
1450
- if (shouldEmitCodexReadyOnTurnEnd({ shouldExit, exitReason })) {
1451
- sendReady();
1452
- readyAlreadySent = true;
1453
- }
1454
- }
1455
- if (msg.type === "agent_reasoning_section_break") {
1456
- reasoningProcessor.handleSectionBreak();
1457
- }
1458
- if (msg.type === "agent_reasoning_delta") {
1459
- reasoningProcessor.processDelta(msg.delta);
1460
- }
1461
- if (msg.type === "agent_reasoning") {
1462
- reasoningProcessor.complete(msg.text);
1463
- }
1464
- if (msg.type === "agent_message") {
1465
- session.runtimeSession.sendCodexMessage({
1466
- type: "message",
1467
- message: msg.message,
1468
- id: randomUUID()
1469
- });
1470
- }
1471
- if (msg.type === "exec_command_begin" || msg.type === "exec_approval_request") {
1472
- const { call_id, type, ...inputs } = msg;
1473
- session.runtimeSession.sendCodexMessage({
1474
- type: "tool-call",
1475
- name: "CodexBash",
1476
- callId: call_id,
1477
- input: inputs,
1478
- id: randomUUID()
1479
- });
1480
- }
1481
- if (msg.type === "exec_command_end") {
1482
- const { call_id, type, ...output } = msg;
1483
- session.runtimeSession.sendCodexMessage({
1484
- type: "tool-call-result",
1485
- callId: call_id,
1486
- output,
1487
- id: randomUUID()
1488
- });
1489
- }
1490
- if (msg.type === "token_count") {
1491
- session.runtimeSession.sendCodexMessage({
1492
- ...msg,
1493
- id: randomUUID()
1494
- });
1495
- }
1496
- if (msg.type === "patch_apply_begin") {
1497
- const { call_id, auto_approved, changes } = msg;
1498
- const changeCount = Object.keys(changes).length;
1499
- const filesMsg = changeCount === 1 ? "1 file" : `${changeCount} files`;
1500
- messageBuffer.addMessage(`Modifying ${filesMsg}...`, "tool");
1501
- session.runtimeSession.sendCodexMessage({
1502
- type: "tool-call",
1503
- name: "CodexPatch",
1504
- callId: call_id,
1505
- input: { auto_approved, changes },
1506
- id: randomUUID()
1507
- });
1508
- }
1509
- if (msg.type === "patch_apply_end") {
1510
- const { call_id, stdout, stderr, success } = msg;
1511
- if (success) {
1512
- messageBuffer.addMessage((stdout || "Files modified successfully").substring(0, 200), "result");
1513
- } else {
1514
- messageBuffer.addMessage(`Error: ${(stderr || "Failed to modify files").substring(0, 200)}`, "result");
1515
- }
1516
- session.runtimeSession.sendCodexMessage({
1517
- type: "tool-call-result",
1518
- callId: call_id,
1519
- output: { stdout, stderr, success },
1520
- id: randomUUID()
1521
- });
1522
- }
1523
- if (msg.type === "turn_diff" && msg.unified_diff) {
1524
- diffProcessor.processDiff(msg.unified_diff);
1525
- }
1526
- });
1527
- let readyAlreadySent = false;
1528
1358
  try {
1529
- await client.connect();
1530
- let wasCreated = false;
1531
- let currentModeHash = null;
1532
- let pending = null;
1533
1359
  while (!shouldExit) {
1534
1360
  let message = pending;
1535
1361
  pending = null;
@@ -1547,122 +1373,71 @@ async function codexRemoteLauncher(session) {
1547
1373
  if (!message) {
1548
1374
  break;
1549
1375
  }
1550
- if (wasCreated && currentModeHash && message.hash !== currentModeHash) {
1376
+ if (wasSessionCreated && currentModeHash && message.hash !== currentModeHash) {
1551
1377
  messageBuffer.addMessage("\u2550".repeat(40), "status");
1552
1378
  messageBuffer.addMessage("Starting new Codex session (mode changed)...", "status");
1553
- const prevSessionId = client.getSessionId();
1554
- pendingModeResumeFile = findCodexResumeFile(prevSessionId);
1555
- client.clearSession();
1379
+ await disposeBackend();
1556
1380
  session.clearSessionId();
1557
- wasCreated = false;
1558
1381
  currentModeHash = null;
1559
1382
  pending = message;
1560
1383
  permissionHandler.reset();
1561
1384
  selectionHandler.reset();
1562
- reasoningProcessor.abort();
1563
- diffProcessor.reset();
1564
- session.onThinkingChange(false);
1385
+ resetTurnState();
1565
1386
  continue;
1566
1387
  }
1567
1388
  currentModeHash = message.hash;
1568
1389
  readyAlreadySent = false;
1569
1390
  messageBuffer.addMessage(message.message, "user");
1391
+ const turnSignal = abortController.signal;
1570
1392
  try {
1571
- const approvalPolicy = (() => {
1572
- switch (message.mode.permissionMode) {
1573
- case "default":
1574
- return "untrusted";
1575
- case "read-only":
1576
- return "never";
1577
- case "safe-yolo":
1578
- return "on-failure";
1579
- case "yolo":
1580
- return "on-failure";
1581
- case "bypassPermissions":
1582
- return "on-failure";
1583
- case "acceptEdits":
1584
- return "on-request";
1585
- case "plan":
1586
- return "untrusted";
1587
- default:
1588
- return "untrusted";
1589
- }
1590
- })();
1591
- const sandbox = (() => {
1592
- switch (message.mode.permissionMode) {
1593
- case "default":
1594
- return "workspace-write";
1595
- case "read-only":
1596
- return "read-only";
1597
- case "safe-yolo":
1598
- return "workspace-write";
1599
- case "yolo":
1600
- return "danger-full-access";
1601
- case "bypassPermissions":
1602
- return "danger-full-access";
1603
- case "acceptEdits":
1604
- return "workspace-write";
1605
- case "plan":
1606
- return "workspace-write";
1607
- default:
1608
- return "workspace-write";
1609
- }
1610
- })();
1611
- if (!wasCreated) {
1612
- const startConfig = {
1613
- prompt: message.message,
1614
- sandbox,
1615
- "approval-policy": approvalPolicy,
1616
- config: {}
1617
- };
1618
- if (message.mode.model) {
1619
- startConfig.model = message.mode.model;
1620
- }
1621
- let resumeFile = null;
1622
- if (pendingModeResumeFile) {
1623
- resumeFile = pendingModeResumeFile;
1624
- pendingModeResumeFile = null;
1625
- } else if (storedSessionIdForResume) {
1626
- resumeFile = findCodexResumeFile(storedSessionIdForResume);
1627
- storedSessionIdForResume = null;
1628
- } else if (session.sessionId) {
1629
- resumeFile = findCodexResumeFile(session.sessionId);
1630
- }
1631
- if (resumeFile) {
1632
- startConfig.config = {
1633
- ...startConfig.config || {},
1634
- experimental_resume: resumeFile
1635
- };
1636
- }
1637
- await client.startSession(startConfig, { signal: abortController.signal });
1638
- const currentSessionId = client.getSessionId();
1639
- if (currentSessionId) {
1640
- session.onSessionFound(currentSessionId);
1641
- }
1642
- wasCreated = true;
1643
- } else {
1644
- await client.continueSession(message.message, { signal: abortController.signal });
1393
+ turnInFlight = true;
1394
+ const activeBackend = backend ?? createBackend(message.mode);
1395
+ if (!activeBackend) {
1396
+ throw new Error("Failed to create Codex ACP backend");
1645
1397
  }
1398
+ if (!acpSessionId) {
1399
+ const { sessionId } = await activeBackend.startSession();
1400
+ acpSessionId = sessionId;
1401
+ wasSessionCreated = true;
1402
+ session.onSessionFound(sessionId);
1403
+ }
1404
+ session.onThinkingChange(true);
1405
+ await activeBackend.sendPrompt(acpSessionId, message.message);
1406
+ await waitForResponseCompleteWithAbort(activeBackend, turnSignal);
1407
+ reasoningProcessor.completeCurrent();
1646
1408
  } catch (error) {
1647
- logger.warn("Error in codex session:", error);
1409
+ logger.warn("Error in codex ACP session:", error);
1648
1410
  const isAbortError = error instanceof Error && error.name === "AbortError";
1649
- const isExpectedInterruption = isAbortError || abortController.signal.aborted || shouldExit || exitReason === "switch";
1650
- if (isExpectedInterruption) {
1411
+ const isExpectedInterruption = isAbortError || turnSignal.aborted || shouldExit || exitReason === "switch";
1412
+ if (exitReason === "switch") {
1413
+ messageBuffer.addMessage("Switching to local mode...", "status");
1414
+ session.runtimeSession.sendSessionEvent({ type: "message", message: "Switching to local mode..." });
1415
+ } else if (isExpectedInterruption) {
1416
+ session.runtimeSession.sendCodexMessage({
1417
+ type: "turn_aborted",
1418
+ id: randomUUID()
1419
+ });
1651
1420
  messageBuffer.addMessage("Aborted by user", "status");
1652
1421
  session.runtimeSession.sendSessionEvent({ type: "message", message: "Aborted by user" });
1653
1422
  } else {
1654
- messageBuffer.addMessage("Process exited unexpectedly", "status");
1655
- session.runtimeSession.sendSessionEvent({ type: "message", message: "Process exited unexpectedly" });
1656
- if (client.hasActiveSession()) {
1657
- storedSessionIdForResume = client.storeSessionForResume();
1658
- }
1423
+ const errorMessage = normalizeCodexBackendError(error);
1424
+ messageBuffer.addMessage(errorMessage, "status");
1425
+ session.runtimeSession.sendSessionEvent({ type: "message", message: errorMessage });
1426
+ await disposeBackend();
1427
+ session.clearSessionId();
1659
1428
  }
1660
1429
  } finally {
1430
+ turnInFlight = false;
1431
+ emitFinalAssistantMessage();
1432
+ if (!shouldExit) {
1433
+ session.runtimeSession.sendCodexMessage({
1434
+ type: "task_complete",
1435
+ id: randomUUID()
1436
+ });
1437
+ }
1661
1438
  permissionHandler.reset();
1662
1439
  selectionHandler.reset();
1663
- reasoningProcessor.abort();
1664
- diffProcessor.reset();
1665
- session.onThinkingChange(false);
1440
+ resetTurnState();
1666
1441
  if (shouldEmitCodexReadyAfterWait({
1667
1442
  pending: Boolean(pending),
1668
1443
  queueSize: session.queue.size(),
@@ -1675,15 +1450,7 @@ async function codexRemoteLauncher(session) {
1675
1450
  }
1676
1451
  }
1677
1452
  } finally {
1678
- client.setHandler(null);
1679
- client.setPermissionHandler(null);
1680
- client.setSelectionHandler(null);
1681
- if (exitReason === "switch") {
1682
- logger.debug("[Codex] Remote launcher exiting via switch; waiting for MCP transport disconnect");
1683
- await client.disconnect();
1684
- } else {
1685
- await client.forceCloseSession();
1686
- }
1453
+ await disposeBackend();
1687
1454
  permissionHandler.reset();
1688
1455
  selectionHandler.reset();
1689
1456
  session.setPendingInteractionSuperseder(null);
@@ -1709,20 +1476,21 @@ async function codexRemoteLauncher(session) {
1709
1476
  }
1710
1477
  messageBuffer.clear();
1711
1478
  }
1712
- logger.debug(`[Codex] Remote launcher returning: ${exitReason || "exit"}`);
1479
+ logger.debug(`[Codex] ACP remote launcher returning: ${exitReason || "exit"}`);
1713
1480
  return exitReason || "exit";
1714
1481
  }
1715
1482
 
1716
1483
  async function codexLoop(opts) {
1717
1484
  let mode = opts.startingMode ?? "local";
1718
- opts.session.onModeChange(mode);
1485
+ await opts.session.onModeChange(mode);
1719
1486
  while (true) {
1720
1487
  logger.debug(`[codex-loop] Iteration with mode: ${mode}`);
1721
1488
  if (mode === "local") {
1722
1489
  const result = await codexLocalLauncher(opts.session);
1723
1490
  if (result.type === "switch") {
1491
+ opts.session.clearLocalModePin();
1724
1492
  mode = "remote";
1725
- opts.session.onModeChange(mode);
1493
+ await opts.session.onModeChange(mode);
1726
1494
  continue;
1727
1495
  }
1728
1496
  return result.code;
@@ -1730,13 +1498,53 @@ async function codexLoop(opts) {
1730
1498
  const reason = await codexRemoteLauncher(opts.session);
1731
1499
  if (reason === "switch") {
1732
1500
  mode = "local";
1733
- opts.session.onModeChange(mode);
1501
+ await opts.session.onModeChange(mode);
1734
1502
  continue;
1735
1503
  }
1736
1504
  return 0;
1737
1505
  }
1738
1506
  }
1739
1507
 
1508
+ function supportsAgentStateUpdateEvents(sessionClient) {
1509
+ return typeof sessionClient.once === "function" && typeof sessionClient.off === "function";
1510
+ }
1511
+ async function syncControlledByUserState(sessionClient, controlledByUser) {
1512
+ if (!supportsAgentStateUpdateEvents(sessionClient)) {
1513
+ sessionClient.updateAgentState((currentState) => ({
1514
+ ...currentState,
1515
+ controlledByUser
1516
+ }));
1517
+ return;
1518
+ }
1519
+ await new Promise((resolve) => {
1520
+ let settled = false;
1521
+ const handleUpdated = () => {
1522
+ if (settled) {
1523
+ return;
1524
+ }
1525
+ settled = true;
1526
+ clearTimeout(timeout);
1527
+ sessionClient.off("agent-state-updated", handleUpdated);
1528
+ resolve();
1529
+ };
1530
+ const timeout = setTimeout(() => {
1531
+ if (settled) {
1532
+ return;
1533
+ }
1534
+ settled = true;
1535
+ sessionClient.off("agent-state-updated", handleUpdated);
1536
+ resolve();
1537
+ }, 1500);
1538
+ sessionClient.once("agent-state-updated", handleUpdated);
1539
+ sessionClient.updateAgentState((currentState) => ({
1540
+ ...currentState,
1541
+ controlledByUser
1542
+ }));
1543
+ });
1544
+ }
1545
+ function shouldSupersedeCodexPendingInteractions(opts) {
1546
+ return (opts.currentMode ?? opts.startingMode) === "remote";
1547
+ }
1740
1548
  async function runCodex(opts) {
1741
1549
  const sessionTag = randomUUID();
1742
1550
  connectionState.setBackend("Codex");
@@ -1760,7 +1568,15 @@ async function runCodex(opts) {
1760
1568
  machineId,
1761
1569
  startedBy: opts.startedBy
1762
1570
  });
1763
- const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
1571
+ let response = null;
1572
+ try {
1573
+ response = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
1574
+ } catch (error) {
1575
+ if (!isAuthenticationRequiredError(error)) {
1576
+ throw error;
1577
+ }
1578
+ logger.debug("[codex] Falling back to offline session stub after authentication failure");
1579
+ }
1764
1580
  let sessionClient;
1765
1581
  let codexSession = null;
1766
1582
  const { session: initialSession, reconnectionHandle } = setupOfflineReconnection({
@@ -1802,7 +1618,11 @@ async function runCodex(opts) {
1802
1618
  messageModel = message.meta.model || void 0;
1803
1619
  currentModel = messageModel;
1804
1620
  }
1805
- if (opts.startingMode === "remote") {
1621
+ if (shouldSupersedeCodexPendingInteractions({
1622
+ startingMode: opts.startingMode,
1623
+ currentMode: codexSession?.mode
1624
+ })) {
1625
+ logger.debug("[codex] Incoming user message superseding active remote interaction");
1806
1626
  codexSession?.supersedePendingInteractions(INTERACTION_SUPERSEDED_ERROR);
1807
1627
  }
1808
1628
  messageQueue.push(message.content.text, {
@@ -1819,12 +1639,9 @@ async function runCodex(opts) {
1819
1639
  mode: opts.startingMode ?? "local",
1820
1640
  messageQueue,
1821
1641
  codexArgs: opts.codexArgs,
1822
- onModeChange: (mode) => {
1642
+ onModeChange: async (mode) => {
1823
1643
  sessionClient.sendSessionEvent({ type: "switch", mode });
1824
- sessionClient.updateAgentState((currentState) => ({
1825
- ...currentState,
1826
- controlledByUser: mode === "local"
1827
- }));
1644
+ await syncControlledByUserState(sessionClient, mode === "local");
1828
1645
  }
1829
1646
  });
1830
1647
  try {
@@ -1843,4 +1660,4 @@ async function runCodex(opts) {
1843
1660
  }
1844
1661
  }
1845
1662
 
1846
- export { runCodex };
1663
+ export { runCodex, shouldSupersedeCodexPendingInteractions, supportsAgentStateUpdateEvents, syncControlledByUserState };