oh-my-codex 0.12.3 → 0.12.4

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 (176) hide show
  1. package/Cargo.lock +5 -5
  2. package/Cargo.toml +1 -1
  3. package/README.md +2 -0
  4. package/dist/cli/__tests__/index.test.js +73 -12
  5. package/dist/cli/__tests__/index.test.js.map +1 -1
  6. package/dist/cli/__tests__/launch-fallback.test.js +8 -27
  7. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  8. package/dist/cli/__tests__/mcp-parity.test.d.ts +2 -0
  9. package/dist/cli/__tests__/mcp-parity.test.d.ts.map +1 -0
  10. package/dist/cli/__tests__/mcp-parity.test.js +111 -0
  11. package/dist/cli/__tests__/mcp-parity.test.js.map +1 -0
  12. package/dist/cli/__tests__/nested-help-routing.test.js +13 -0
  13. package/dist/cli/__tests__/nested-help-routing.test.js.map +1 -1
  14. package/dist/cli/__tests__/package-bin-contract.test.js +6 -1
  15. package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
  16. package/dist/cli/__tests__/setup-hooks-shared-ownership.test.d.ts +2 -0
  17. package/dist/cli/__tests__/setup-hooks-shared-ownership.test.d.ts.map +1 -0
  18. package/dist/cli/__tests__/setup-hooks-shared-ownership.test.js +189 -0
  19. package/dist/cli/__tests__/setup-hooks-shared-ownership.test.js.map +1 -0
  20. package/dist/cli/__tests__/setup-scope.test.js +48 -0
  21. package/dist/cli/__tests__/setup-scope.test.js.map +1 -1
  22. package/dist/cli/__tests__/state.test.d.ts +2 -0
  23. package/dist/cli/__tests__/state.test.d.ts.map +1 -0
  24. package/dist/cli/__tests__/state.test.js +46 -0
  25. package/dist/cli/__tests__/state.test.js.map +1 -0
  26. package/dist/cli/__tests__/team.test.js +238 -2
  27. package/dist/cli/__tests__/team.test.js.map +1 -1
  28. package/dist/cli/__tests__/uninstall.test.js +37 -2
  29. package/dist/cli/__tests__/uninstall.test.js.map +1 -1
  30. package/dist/cli/index.d.ts +6 -13
  31. package/dist/cli/index.d.ts.map +1 -1
  32. package/dist/cli/index.js +47 -60
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/mcp-parity.d.ts +22 -0
  35. package/dist/cli/mcp-parity.d.ts.map +1 -0
  36. package/dist/cli/mcp-parity.js +227 -0
  37. package/dist/cli/mcp-parity.js.map +1 -0
  38. package/dist/cli/setup.d.ts.map +1 -1
  39. package/dist/cli/setup.js +5 -2
  40. package/dist/cli/setup.js.map +1 -1
  41. package/dist/cli/state.d.ts +8 -0
  42. package/dist/cli/state.d.ts.map +1 -0
  43. package/dist/cli/state.js +71 -0
  44. package/dist/cli/state.js.map +1 -0
  45. package/dist/cli/team.d.ts.map +1 -1
  46. package/dist/cli/team.js +6 -5
  47. package/dist/cli/team.js.map +1 -1
  48. package/dist/cli/uninstall.d.ts.map +1 -1
  49. package/dist/cli/uninstall.js +18 -4
  50. package/dist/cli/uninstall.js.map +1 -1
  51. package/dist/config/__tests__/codex-hooks.test.d.ts +2 -0
  52. package/dist/config/__tests__/codex-hooks.test.d.ts.map +1 -0
  53. package/dist/config/__tests__/codex-hooks.test.js +53 -0
  54. package/dist/config/__tests__/codex-hooks.test.js.map +1 -0
  55. package/dist/config/codex-hooks.d.ts +16 -7
  56. package/dist/config/codex-hooks.d.ts.map +1 -1
  57. package/dist/config/codex-hooks.js +134 -2
  58. package/dist/config/codex-hooks.js.map +1 -1
  59. package/dist/hooks/__tests__/keyword-detector.test.js +6 -0
  60. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  61. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  62. package/dist/hooks/keyword-detector.js +6 -0
  63. package/dist/hooks/keyword-detector.js.map +1 -1
  64. package/dist/hud/__tests__/reconcile.test.d.ts +2 -0
  65. package/dist/hud/__tests__/reconcile.test.d.ts.map +1 -0
  66. package/dist/hud/__tests__/reconcile.test.js +83 -0
  67. package/dist/hud/__tests__/reconcile.test.js.map +1 -0
  68. package/dist/hud/__tests__/render.test.js +43 -0
  69. package/dist/hud/__tests__/render.test.js.map +1 -1
  70. package/dist/hud/constants.d.ts +2 -1
  71. package/dist/hud/constants.d.ts.map +1 -1
  72. package/dist/hud/constants.js +2 -1
  73. package/dist/hud/constants.js.map +1 -1
  74. package/dist/hud/index.d.ts +4 -1
  75. package/dist/hud/index.d.ts.map +1 -1
  76. package/dist/hud/index.js +11 -5
  77. package/dist/hud/index.js.map +1 -1
  78. package/dist/hud/reconcile.d.ts +23 -0
  79. package/dist/hud/reconcile.d.ts.map +1 -0
  80. package/dist/hud/reconcile.js +71 -0
  81. package/dist/hud/reconcile.js.map +1 -0
  82. package/dist/hud/render.d.ts +6 -1
  83. package/dist/hud/render.d.ts.map +1 -1
  84. package/dist/hud/render.js +77 -3
  85. package/dist/hud/render.js.map +1 -1
  86. package/dist/hud/tmux.d.ts +26 -0
  87. package/dist/hud/tmux.d.ts.map +1 -0
  88. package/dist/hud/tmux.js +126 -0
  89. package/dist/hud/tmux.js.map +1 -0
  90. package/dist/mcp/bootstrap.d.ts.map +1 -1
  91. package/dist/mcp/bootstrap.js +16 -6
  92. package/dist/mcp/bootstrap.js.map +1 -1
  93. package/dist/mcp/code-intel-server.d.ts +298 -0
  94. package/dist/mcp/code-intel-server.d.ts.map +1 -1
  95. package/dist/mcp/code-intel-server.js +9 -5
  96. package/dist/mcp/code-intel-server.js.map +1 -1
  97. package/dist/mcp/memory-server.d.ts +195 -1
  98. package/dist/mcp/memory-server.d.ts.map +1 -1
  99. package/dist/mcp/memory-server.js +9 -5
  100. package/dist/mcp/memory-server.js.map +1 -1
  101. package/dist/mcp/trace-server.d.ts +51 -0
  102. package/dist/mcp/trace-server.d.ts.map +1 -1
  103. package/dist/mcp/trace-server.js +9 -5
  104. package/dist/mcp/trace-server.js.map +1 -1
  105. package/dist/scripts/__tests__/codex-native-hook.test.js +455 -8
  106. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  107. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  108. package/dist/scripts/codex-native-hook.js +159 -52
  109. package/dist/scripts/codex-native-hook.js.map +1 -1
  110. package/dist/scripts/codex-native-pre-post.d.ts +5 -0
  111. package/dist/scripts/codex-native-pre-post.d.ts.map +1 -1
  112. package/dist/scripts/codex-native-pre-post.js +86 -0
  113. package/dist/scripts/codex-native-pre-post.js.map +1 -1
  114. package/dist/scripts/notify-hook/operational-events.d.ts.map +1 -1
  115. package/dist/scripts/notify-hook/operational-events.js +7 -2
  116. package/dist/scripts/notify-hook/operational-events.js.map +1 -1
  117. package/dist/state/__tests__/operations-ralph-phase.test.d.ts +2 -0
  118. package/dist/state/__tests__/operations-ralph-phase.test.d.ts.map +1 -0
  119. package/dist/state/__tests__/operations-ralph-phase.test.js +82 -0
  120. package/dist/state/__tests__/operations-ralph-phase.test.js.map +1 -0
  121. package/dist/state/__tests__/operations.test.d.ts +2 -0
  122. package/dist/state/__tests__/operations.test.d.ts.map +1 -0
  123. package/dist/state/__tests__/operations.test.js +200 -0
  124. package/dist/state/__tests__/operations.test.js.map +1 -0
  125. package/dist/state/__tests__/path-traversal.test.d.ts +2 -0
  126. package/dist/state/__tests__/path-traversal.test.d.ts.map +1 -0
  127. package/dist/state/__tests__/path-traversal.test.js +49 -0
  128. package/dist/state/__tests__/path-traversal.test.js.map +1 -0
  129. package/dist/state/operations.d.ts +11 -0
  130. package/dist/state/operations.d.ts.map +1 -0
  131. package/dist/state/operations.js +233 -0
  132. package/dist/state/operations.js.map +1 -0
  133. package/dist/team/__tests__/api-interop.test.js +24 -2
  134. package/dist/team/__tests__/api-interop.test.js.map +1 -1
  135. package/dist/team/__tests__/delivery-e2e-smoke.test.js +9 -1
  136. package/dist/team/__tests__/delivery-e2e-smoke.test.js.map +1 -1
  137. package/dist/team/__tests__/runtime-cli.test.js +45 -0
  138. package/dist/team/__tests__/runtime-cli.test.js.map +1 -1
  139. package/dist/team/__tests__/runtime.test.js +191 -66
  140. package/dist/team/__tests__/runtime.test.js.map +1 -1
  141. package/dist/team/__tests__/tmux-session.test.js +33 -0
  142. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  143. package/dist/team/api-interop.d.ts.map +1 -1
  144. package/dist/team/api-interop.js +2 -1
  145. package/dist/team/api-interop.js.map +1 -1
  146. package/dist/team/runtime-cli.d.ts.map +1 -1
  147. package/dist/team/runtime-cli.js +21 -2
  148. package/dist/team/runtime-cli.js.map +1 -1
  149. package/dist/team/runtime.d.ts +8 -0
  150. package/dist/team/runtime.d.ts.map +1 -1
  151. package/dist/team/runtime.js +179 -78
  152. package/dist/team/runtime.js.map +1 -1
  153. package/dist/team/state/dispatch.d.ts.map +1 -1
  154. package/dist/team/state/dispatch.js +9 -0
  155. package/dist/team/state/dispatch.js.map +1 -1
  156. package/dist/team/tmux-session.js +3 -3
  157. package/dist/team/tmux-session.js.map +1 -1
  158. package/dist/team/worktree.d.ts +2 -0
  159. package/dist/team/worktree.d.ts.map +1 -1
  160. package/dist/team/worktree.js +7 -1
  161. package/dist/team/worktree.js.map +1 -1
  162. package/dist/utils/__tests__/paths.test.js +76 -1
  163. package/dist/utils/__tests__/paths.test.js.map +1 -1
  164. package/dist/utils/paths.d.ts +6 -0
  165. package/dist/utils/paths.d.ts.map +1 -1
  166. package/dist/utils/paths.js +14 -0
  167. package/dist/utils/paths.js.map +1 -1
  168. package/dist/verification/__tests__/ci-rust-gates.test.js +59 -11
  169. package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
  170. package/dist/verification/__tests__/ralph-persistence-gate.test.js +1 -4
  171. package/dist/verification/__tests__/ralph-persistence-gate.test.js.map +1 -1
  172. package/package.json +6 -1
  173. package/src/scripts/__tests__/codex-native-hook.test.ts +600 -8
  174. package/src/scripts/codex-native-hook.ts +236 -60
  175. package/src/scripts/codex-native-pre-post.ts +104 -0
  176. package/src/scripts/notify-hook/operational-events.ts +6 -2
@@ -2,12 +2,22 @@ import { execFileSync } from "child_process";
2
2
  import { existsSync, readFileSync } from "fs";
3
3
  import { mkdir, readFile, readdir, writeFile } from "fs/promises";
4
4
  import { join, resolve } from "path";
5
- import { readModeState } from "../modes/base.js";
6
- import { getReadScopedStateDirs } from "../mcp/state-paths.js";
5
+ import { readModeState, updateModeState } from "../modes/base.js";
6
+ import {
7
+ listActiveSkills,
8
+ readVisibleSkillActiveState,
9
+ } from "../state/skill-active.js";
7
10
  import { readSubagentSessionSummary } from "../subagents/tracker.js";
8
11
  import { resolveCanonicalTeamStateRoot } from "../team/state-root.js";
9
- import { readTeamManifestV2, readTeamPhase } from "../team/state.js";
10
- import { omxNotepadPath, omxProjectMemoryPath } from "../utils/paths.js";
12
+ import {
13
+ appendTeamEvent,
14
+ readTeamLeaderAttention,
15
+ readTeamManifestV2,
16
+ readTeamPhase,
17
+ writeTeamLeaderAttention,
18
+ writeTeamPhase,
19
+ } from "../team/state.js";
20
+ import { omxNotepadPath, omxProjectMemoryPath, omxStateDir } from "../utils/paths.js";
11
21
  import {
12
22
  detectPrimaryKeyword,
13
23
  recordSkillActivation,
@@ -15,14 +25,13 @@ import {
15
25
  } from "../hooks/keyword-detector.js";
16
26
  import {
17
27
  detectStallPattern,
18
- isDeepInterviewInputLockActive,
19
- isDeepInterviewStateActive,
20
28
  loadAutoNudgeConfig,
21
29
  normalizeAutoNudgeSignatureText,
22
30
  } from "./notify-hook/auto-nudge.js";
23
31
  import {
24
32
  buildNativePostToolUseOutput,
25
33
  buildNativePreToolUseOutput,
34
+ detectMcpTransportFailure,
26
35
  } from "./codex-native-pre-post.js";
27
36
  import {
28
37
  buildNativeHookEvent,
@@ -30,6 +39,7 @@ import {
30
39
  import type { HookEventEnvelope } from "../hooks/extensibility/types.js";
31
40
  import { dispatchHookEvent } from "../hooks/extensibility/dispatcher.js";
32
41
  import { writeSessionStart } from "../hooks/session.js";
42
+ import { reconcileHudForPromptSubmit } from "../hud/reconcile.js";
33
43
 
34
44
  type CodexHookEventName =
35
45
  | "SessionStart"
@@ -154,19 +164,6 @@ async function readJsonIfExists(path: string): Promise<Record<string, unknown> |
154
164
  }
155
165
  }
156
166
 
157
- async function readScopedJsonState(
158
- fileName: string,
159
- cwd: string,
160
- sessionId?: string,
161
- ): Promise<Record<string, unknown> | null> {
162
- const dirs = await getReadScopedStateDirs(cwd, sessionId);
163
- for (const dir of dirs) {
164
- const candidate = await readJsonIfExists(join(dir, fileName));
165
- if (candidate) return candidate;
166
- }
167
- return null;
168
- }
169
-
170
167
  function isNonTerminalPhase(value: unknown): boolean {
171
168
  const phase = safeString(value).trim().toLowerCase();
172
169
  return phase !== "" && !TERMINAL_MODE_PHASES.has(phase);
@@ -178,26 +175,28 @@ function formatPhase(value: unknown, fallback = "active"): string {
178
175
  }
179
176
 
180
177
  async function readActiveRalphState(stateDir: string): Promise<Record<string, unknown> | null> {
178
+ const sessionInfo = await readJsonIfExists(join(stateDir, "session.json"));
179
+ const currentOmxSessionId = safeString(sessionInfo?.session_id).trim();
180
+ if (currentOmxSessionId) {
181
+ const sessionScoped = await readJsonIfExists(
182
+ join(stateDir, "sessions", currentOmxSessionId, "ralph-state.json"),
183
+ );
184
+ if (
185
+ sessionScoped?.active === true
186
+ && !TERMINAL_RALPH_PHASES.has(
187
+ safeString(sessionScoped.current_phase).trim().toLowerCase(),
188
+ )
189
+ ) {
190
+ return sessionScoped;
191
+ }
192
+ }
193
+
181
194
  const direct = await readJsonIfExists(join(stateDir, "ralph-state.json"));
182
195
  if (direct?.active === true && !TERMINAL_RALPH_PHASES.has(safeString(direct.current_phase).trim().toLowerCase())) {
183
196
  return direct;
184
197
  }
185
198
 
186
- const sessionInfo = await readJsonIfExists(join(stateDir, "session.json"));
187
- const currentOmxSessionId = safeString(sessionInfo?.session_id).trim();
188
- if (!currentOmxSessionId) return null;
189
-
190
- const sessionScoped = await readJsonIfExists(
191
- join(stateDir, "sessions", currentOmxSessionId, "ralph-state.json"),
192
- );
193
- if (
194
- sessionScoped?.active === true
195
- && !TERMINAL_RALPH_PHASES.has(
196
- safeString(sessionScoped.current_phase).trim().toLowerCase(),
197
- )
198
- ) {
199
- return sessionScoped;
200
- }
199
+ if (currentOmxSessionId) return null;
201
200
 
202
201
  const sessionsRoot = join(stateDir, "sessions");
203
202
  if (!existsSync(sessionsRoot)) return null;
@@ -621,22 +620,100 @@ function readPayloadTurnId(payload: CodexHookPayload): string {
621
620
 
622
621
  async function isDeepInterviewSuppressedForStop(
623
622
  cwd: string,
624
- stateDir: string,
625
623
  sessionId: string,
624
+ threadId: string,
626
625
  ): Promise<boolean> {
627
- if (await isDeepInterviewStateActive(stateDir)) return true;
628
- if (await isDeepInterviewInputLockActive(stateDir)) return true;
629
-
630
- const scopedModeState = sessionId
631
- ? await readScopedJsonState("deep-interview-state.json", cwd, sessionId)
632
- : null;
626
+ const scopedModeState = await readStopSessionPinnedState("deep-interview-state.json", cwd, sessionId);
633
627
  if (scopedModeState?.active === true) return true;
634
628
 
635
- const scopedSkillState = sessionId
636
- ? await readScopedJsonState("skill-active-state.json", cwd, sessionId)
629
+ const canonicalState = await readVisibleSkillActiveState(cwd, sessionId);
630
+ const deepInterviewEntry = canonicalState
631
+ ? listActiveSkills(canonicalState).find((entry) => (
632
+ entry.skill === "deep-interview"
633
+ && matchesSkillStopContext(entry, canonicalState, sessionId, threadId)
634
+ ))
637
635
  : null;
638
- if (!scopedSkillState || scopedSkillState.active !== true) return false;
639
- return safeString(scopedSkillState.skill).trim() === "deep-interview";
636
+ if (
637
+ deepInterviewEntry
638
+ && safeObject(canonicalState?.input_lock).active === true
639
+ ) {
640
+ return true;
641
+ }
642
+
643
+ return (await readBlockingSkillForStop(cwd, sessionId, threadId, "deep-interview")) !== null;
644
+ }
645
+
646
+ async function readStopSessionPinnedState(
647
+ fileName: string,
648
+ cwd: string,
649
+ sessionId: string,
650
+ ): Promise<Record<string, unknown> | null> {
651
+ const stateDir = omxStateDir(cwd);
652
+ const statePath = sessionId
653
+ ? join(stateDir, "sessions", sessionId, fileName)
654
+ : join(stateDir, fileName);
655
+ return readJsonIfExists(statePath);
656
+ }
657
+
658
+ function matchesSkillStopContext(
659
+ entry: { session_id?: string; thread_id?: string },
660
+ state: { session_id?: string; thread_id?: string },
661
+ sessionId: string,
662
+ threadId: string,
663
+ ): boolean {
664
+ const entrySessionId = safeString(entry.session_id ?? state.session_id).trim();
665
+ const entryThreadId = safeString(entry.thread_id ?? state.thread_id).trim();
666
+ if (sessionId && entrySessionId && entrySessionId !== sessionId) return false;
667
+ if (sessionId && !entrySessionId && threadId && entryThreadId && entryThreadId !== threadId) {
668
+ return false;
669
+ }
670
+ return true;
671
+ }
672
+
673
+ async function readBlockingSkillForStop(
674
+ cwd: string,
675
+ sessionId: string,
676
+ threadId: string,
677
+ requiredSkill?: string,
678
+ ): Promise<{ skill: string; phase: string } | null> {
679
+ const canonicalState = await readVisibleSkillActiveState(cwd, sessionId);
680
+ const visibleEntries = canonicalState ? listActiveSkills(canonicalState) : [];
681
+ const candidateSkills = requiredSkill
682
+ ? [requiredSkill]
683
+ : [...SKILL_STOP_BLOCKERS];
684
+
685
+ for (const skill of candidateSkills) {
686
+ const modeState = await readStopSessionPinnedState(`${skill}-state.json`, cwd, sessionId);
687
+ if (!modeState || modeState.active !== true) continue;
688
+
689
+ const phase = formatPhase(
690
+ modeState.current_phase,
691
+ formatPhase(
692
+ visibleEntries.find((entry) => entry.skill === skill)?.phase,
693
+ "planning",
694
+ ),
695
+ );
696
+ if (TERMINAL_MODE_PHASES.has(phase.toLowerCase()) || phase === "completing") {
697
+ continue;
698
+ }
699
+
700
+ if (!canonicalState) {
701
+ return { skill, phase };
702
+ }
703
+
704
+ const blocker = visibleEntries.find((entry) => (
705
+ entry.skill === skill
706
+ && matchesSkillStopContext(entry, canonicalState, sessionId, threadId)
707
+ ));
708
+ if (!blocker) continue;
709
+
710
+ return {
711
+ skill,
712
+ phase: formatPhase(modeState.current_phase ?? blocker.phase ?? canonicalState.phase, "planning"),
713
+ };
714
+ }
715
+
716
+ return null;
640
717
  }
641
718
 
642
719
  function buildRepeatableStopSignature(
@@ -766,17 +843,8 @@ async function buildSkillStopOutput(
766
843
  sessionId: string,
767
844
  threadId: string,
768
845
  ): Promise<Record<string, unknown> | null> {
769
- const state = await readScopedJsonState("skill-active-state.json", cwd, sessionId);
770
- if (!state || state.active !== true) return null;
771
- const stateSessionId = safeString(state.session_id).trim();
772
- const stateThreadId = safeString(state.thread_id).trim();
773
- if (sessionId && stateSessionId && stateSessionId !== sessionId) return null;
774
- if (sessionId && !stateSessionId && threadId && stateThreadId && stateThreadId !== threadId) {
775
- return null;
776
- }
777
- const skill = safeString(state.skill).trim();
778
- const phase = formatPhase(state.phase, "planning");
779
- if (!SKILL_STOP_BLOCKERS.has(skill) || phase === "completing") return null;
846
+ const blocker = await readBlockingSkillForStop(cwd, sessionId, threadId);
847
+ if (!blocker) return null;
780
848
 
781
849
  const subagentSummary = await readSubagentSessionSummary(cwd, sessionId).catch(() => null);
782
850
  if (subagentSummary && subagentSummary.activeSubagentThreadIds.length > 0) {
@@ -785,12 +853,116 @@ async function buildSkillStopOutput(
785
853
 
786
854
  return {
787
855
  decision: "block",
788
- reason: `OMX skill ${skill} is still active (phase: ${phase}); continue until the current ${skill} workflow reaches a terminal state.`,
789
- stopReason: `skill_${skill}_${phase}`,
790
- systemMessage: `OMX skill ${skill} is still active (phase: ${phase}).`,
856
+ reason: `OMX skill ${blocker.skill} is still active (phase: ${blocker.phase}); continue until the current ${blocker.skill} workflow reaches a terminal state.`,
857
+ stopReason: `skill_${blocker.skill}_${blocker.phase}`,
858
+ systemMessage: `OMX skill ${blocker.skill} is still active (phase: ${blocker.phase}).`,
791
859
  };
792
860
  }
793
861
 
862
+ async function findActiveTeamForTransportFailure(
863
+ cwd: string,
864
+ sessionId: string,
865
+ ): Promise<{ teamName: string; phase: string } | null> {
866
+ const teamState = await readModeState("team", cwd);
867
+ if (teamState?.active === true) {
868
+ const teamName = safeString(teamState.team_name).trim();
869
+ const coarsePhase = formatPhase(teamState.current_phase);
870
+ if (teamName) {
871
+ const canonicalPhase = (await readTeamPhase(teamName, cwd))?.current_phase ?? coarsePhase;
872
+ if (isNonTerminalPhase(canonicalPhase)) {
873
+ return { teamName, phase: formatPhase(canonicalPhase) };
874
+ }
875
+ }
876
+ }
877
+
878
+ return await findCanonicalActiveTeamForSession(cwd, sessionId);
879
+ }
880
+
881
+ async function markTeamTransportFailure(
882
+ cwd: string,
883
+ payload: CodexHookPayload,
884
+ ): Promise<void> {
885
+ const sessionId = readPayloadSessionId(payload);
886
+ const activeTeam = await findActiveTeamForTransportFailure(cwd, sessionId);
887
+ if (!activeTeam) return;
888
+
889
+ const nowIso = new Date().toISOString();
890
+ const existingPhase = await readTeamPhase(activeTeam.teamName, cwd);
891
+ const currentPhase = existingPhase?.current_phase ?? activeTeam.phase;
892
+ if (!isNonTerminalPhase(currentPhase)) return;
893
+
894
+ await writeTeamPhase(
895
+ activeTeam.teamName,
896
+ {
897
+ current_phase: "failed",
898
+ max_fix_attempts: existingPhase?.max_fix_attempts ?? 3,
899
+ current_fix_attempt: existingPhase?.current_fix_attempt ?? 0,
900
+ transitions: [
901
+ ...(existingPhase?.transitions ?? []),
902
+ {
903
+ from: formatPhase(currentPhase),
904
+ to: "failed",
905
+ at: nowIso,
906
+ reason: "mcp_transport_dead",
907
+ },
908
+ ],
909
+ updated_at: nowIso,
910
+ },
911
+ cwd,
912
+ );
913
+
914
+ const existingAttention = await readTeamLeaderAttention(activeTeam.teamName, cwd);
915
+ await writeTeamLeaderAttention(
916
+ activeTeam.teamName,
917
+ {
918
+ team_name: activeTeam.teamName,
919
+ updated_at: nowIso,
920
+ source: "notify_hook",
921
+ leader_decision_state: existingAttention?.leader_decision_state ?? "still_actionable",
922
+ leader_attention_pending: true,
923
+ leader_attention_reason: "mcp_transport_dead",
924
+ attention_reasons: [
925
+ ...new Set([...(existingAttention?.attention_reasons ?? []), "mcp_transport_dead"]),
926
+ ],
927
+ leader_stale: existingAttention?.leader_stale ?? false,
928
+ leader_session_active: existingAttention?.leader_session_active ?? true,
929
+ leader_session_id: existingAttention?.leader_session_id ?? (sessionId || null),
930
+ leader_session_stopped_at: existingAttention?.leader_session_stopped_at ?? null,
931
+ unread_leader_message_count: existingAttention?.unread_leader_message_count ?? 0,
932
+ work_remaining: existingAttention?.work_remaining ?? true,
933
+ stalled_for_ms: existingAttention?.stalled_for_ms ?? null,
934
+ },
935
+ cwd,
936
+ );
937
+
938
+ await appendTeamEvent(
939
+ activeTeam.teamName,
940
+ {
941
+ type: "leader_attention",
942
+ worker: "leader-fixed",
943
+ reason: "mcp_transport_dead",
944
+ metadata: {
945
+ phase_before: formatPhase(currentPhase),
946
+ },
947
+ },
948
+ cwd,
949
+ ).catch(() => {});
950
+
951
+ try {
952
+ await updateModeState(
953
+ "team",
954
+ {
955
+ current_phase: "failed",
956
+ error: "mcp_transport_dead",
957
+ last_turn_at: nowIso,
958
+ },
959
+ cwd,
960
+ );
961
+ } catch {
962
+ // Canonical team state already carries the preserved failure for coarse-state-missing sessions.
963
+ }
964
+ }
965
+
794
966
  async function buildStopHookOutput(
795
967
  payload: CodexHookPayload,
796
968
  cwd: string,
@@ -844,7 +1016,7 @@ async function buildStopHookOutput(
844
1016
  if (!stopHookActive && skillOutput) return skillOutput;
845
1017
  }
846
1018
 
847
- const deepInterviewActive = await isDeepInterviewSuppressedForStop(cwd, stateDir, sessionId);
1019
+ const deepInterviewActive = await isDeepInterviewSuppressedForStop(cwd, sessionId, threadId);
848
1020
  const lastAssistantMessage = safeString(
849
1021
  payload.last_assistant_message ?? payload.lastAssistantMessage,
850
1022
  );
@@ -922,6 +1094,7 @@ export async function dispatchCodexNativeHook(
922
1094
  turnId,
923
1095
  });
924
1096
  }
1097
+ await reconcileHudForPromptSubmit(cwd).catch(() => {});
925
1098
  }
926
1099
 
927
1100
  if (omxEventName) {
@@ -954,6 +1127,9 @@ export async function dispatchCodexNativeHook(
954
1127
  } else if (hookEventName === "PreToolUse") {
955
1128
  outputJson = buildNativePreToolUseOutput(payload);
956
1129
  } else if (hookEventName === "PostToolUse") {
1130
+ if (detectMcpTransportFailure(payload)) {
1131
+ await markTeamTransportFailure(cwd, payload);
1132
+ }
957
1133
  outputJson = buildNativePostToolUseOutput(payload);
958
1134
  } else if (hookEventName === "Stop") {
959
1135
  outputJson = await buildStopHookOutput(payload, cwd, stateDir);
@@ -21,6 +21,11 @@ export interface NormalizedPostToolUsePayload {
21
21
  stderrText: string;
22
22
  }
23
23
 
24
+ export interface McpTransportFailureSignal {
25
+ toolName: string;
26
+ summary: string;
27
+ }
28
+
24
29
  function safeString(value: unknown): string {
25
30
  return typeof value === "string" ? value : "";
26
31
  }
@@ -101,6 +106,88 @@ function matchesDestructiveFixture(command: string): boolean {
101
106
  return /^\s*rm\s+-rf\s+dist(?:\s|$)/.test(command);
102
107
  }
103
108
 
109
+ function isMcpLikeToolName(toolName: string): boolean {
110
+ return /^(mcp__|omx_(?:state|memory|trace|code_intel)\b|state_|project_memory_|notepad_|trace_)/i.test(toolName);
111
+ }
112
+
113
+ const MCP_TRANSPORT_FAILURE_PATTERNS = [
114
+ /transport (?:closed|error|failed)/i,
115
+ /server disconnected/i,
116
+ /connection (?:closed|reset|lost)/i,
117
+ /\beconnreset\b/i,
118
+ /\bepipe\b/i,
119
+ /broken pipe/i,
120
+ /stream ended unexpectedly/i,
121
+ /stdio .*closed/i,
122
+ /pipe closed/i,
123
+ /mcp(?: server)? .*closed/i,
124
+ ];
125
+
126
+ type OmxParityCommand =
127
+ | "state"
128
+ | "notepad"
129
+ | "project-memory"
130
+ | "trace"
131
+ | "code-intel";
132
+
133
+ export function detectMcpTransportFailure(
134
+ payload: CodexHookPayload,
135
+ ): McpTransportFailureSignal | null {
136
+ const normalized = normalizePostToolUsePayload(payload);
137
+ const combined = [
138
+ normalized.stderrText,
139
+ normalized.stdoutText,
140
+ safeString(normalized.parsedToolResponse?.error),
141
+ safeString(normalized.parsedToolResponse?.message),
142
+ safeString(normalized.parsedToolResponse?.details),
143
+ ]
144
+ .filter(Boolean)
145
+ .join("\n")
146
+ .trim();
147
+
148
+ const mcpContextDetected = isMcpLikeToolName(normalized.toolName)
149
+ || /\bmcp\b/i.test(combined)
150
+ || /\bomx-(?:state|memory|trace|code-intel)-server\b/i.test(combined);
151
+ if (!mcpContextDetected) return null;
152
+ if (!combined) return null;
153
+ if (!MCP_TRANSPORT_FAILURE_PATTERNS.some((pattern) => pattern.test(combined))) {
154
+ return null;
155
+ }
156
+
157
+ return {
158
+ toolName: normalized.toolName,
159
+ summary: combined,
160
+ };
161
+ }
162
+
163
+ function resolveOmxParityTarget(toolName: string): { command: OmxParityCommand; tool: string } | null {
164
+ const match = toolName.match(/^mcp__omx_(state|memory|trace|code_intel)__([a-z0-9_]+)$/i);
165
+ if (!match) return null;
166
+
167
+ const [, server, tool] = match;
168
+ if (server === "state") return { command: "state", tool };
169
+ if (server === "trace") return { command: "trace", tool };
170
+ if (server === "code_intel") return { command: "code-intel", tool };
171
+ if (server === "memory" && tool.startsWith("notepad_")) {
172
+ return { command: "notepad", tool };
173
+ }
174
+ if (server === "memory" && tool.startsWith("project_memory_")) {
175
+ return { command: "project-memory", tool };
176
+ }
177
+ return null;
178
+ }
179
+
180
+ function shellSingleQuote(value: string): string {
181
+ return `'${value.replace(/'/g, `'\"'\"'`)}'`;
182
+ }
183
+
184
+ function buildOmxParityFallbackCommand(payload: CodexHookPayload, toolName: string): string | null {
185
+ const target = resolveOmxParityTarget(toolName);
186
+ if (!target) return null;
187
+ const input = safeObject(payload.tool_input) ?? {};
188
+ return `omx ${target.command} ${target.tool} --input ${shellSingleQuote(JSON.stringify(input))} --json`;
189
+ }
190
+
104
191
  export function buildNativePreToolUseOutput(
105
192
  payload: CodexHookPayload,
106
193
  ): Record<string, unknown> | null {
@@ -124,6 +211,23 @@ function containsHardFailure(text: string): boolean {
124
211
  export function buildNativePostToolUseOutput(
125
212
  payload: CodexHookPayload,
126
213
  ): Record<string, unknown> | null {
214
+ const mcpTransportFailure = detectMcpTransportFailure(payload);
215
+ if (mcpTransportFailure) {
216
+ const fallbackCommand = buildOmxParityFallbackCommand(payload, mcpTransportFailure.toolName);
217
+ const fallbackText = fallbackCommand
218
+ ? `Retry via CLI parity with \`${fallbackCommand}\`.`
219
+ : "Retry via the matching OMX CLI parity surface instead of retrying the MCP transport blindly.";
220
+ return {
221
+ decision: "block",
222
+ reason: "The MCP tool appears to have lost its transport/server connection. Preserve state, debug the transport failure, and use OMX CLI/file-backed fallbacks instead of retrying blindly.",
223
+ hookSpecificOutput: {
224
+ hookEventName: "PostToolUse",
225
+ additionalContext:
226
+ `Clear MCP transport-death signal detected. Preserve current team/runtime state. ${fallbackText} OMX MCP servers are plain Node stdio processes, so they still shut down when stdin/transport closes. If this happened during team runtime, inspect first with \`omx team status <team>\` or \`omx team api read-stall-state --input '{"team_name":"<team>"}' --json\`, and only force cleanup after capturing needed state. For root-cause debugging, rerun with \`OMX_MCP_TRANSPORT_DEBUG=1\` to log why the stdio transport closed.`,
227
+ },
228
+ };
229
+ }
230
+
127
231
  const normalized = normalizePostToolUsePayload(payload);
128
232
  if (!normalized.isBash) return null;
129
233
 
@@ -70,8 +70,12 @@ function buildTmuxSessionName(cwd: any, sessionId: any): string {
70
70
  const branch = gitValue(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
71
71
  const branchToken = branch ? sanitizeTmuxToken(branch) : 'detached';
72
72
  const sessionToken = sanitizeTmuxToken(safeString(sessionId).replace(/^omx-/, ''));
73
- const name = `omx-${dirToken}-${branchToken}-${sessionToken}`;
74
- return name.length > 120 ? name.slice(0, 120) : name;
73
+ const prefix = `omx-${dirToken}-${branchToken}`;
74
+ const name = `${prefix}-${sessionToken}`;
75
+ if (name.length <= 120) return name;
76
+ const prefixBudget = Math.max(4, 120 - sessionToken.length - 1);
77
+ const trimmedPrefix = prefix.slice(0, prefixBudget).replace(/-+$/g, '');
78
+ return `${trimmedPrefix}-${sessionToken}`.slice(0, 120);
75
79
  }
76
80
 
77
81
  export function resolveOperationalSessionName(cwd: any, sessionId = '', sessionName = ''): string | undefined {