oh-my-codex 0.18.1 → 0.18.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 (204) hide show
  1. package/Cargo.lock +6 -6
  2. package/Cargo.toml +1 -1
  3. package/README.md +4 -2
  4. package/dist/agents/__tests__/definitions.test.js +14 -0
  5. package/dist/agents/__tests__/definitions.test.js.map +1 -1
  6. package/dist/agents/__tests__/native-config.test.js +19 -0
  7. package/dist/agents/__tests__/native-config.test.js.map +1 -1
  8. package/dist/agents/definitions.d.ts.map +1 -1
  9. package/dist/agents/definitions.js +30 -0
  10. package/dist/agents/definitions.js.map +1 -1
  11. package/dist/agents/native-config.d.ts +1 -0
  12. package/dist/agents/native-config.d.ts.map +1 -1
  13. package/dist/agents/native-config.js +4 -0
  14. package/dist/agents/native-config.js.map +1 -1
  15. package/dist/catalog/__tests__/generator.test.js +4 -0
  16. package/dist/catalog/__tests__/generator.test.js.map +1 -1
  17. package/dist/cli/__tests__/doctor-warning-copy.test.js +61 -5
  18. package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
  19. package/dist/cli/__tests__/index.test.js +161 -21
  20. package/dist/cli/__tests__/index.test.js.map +1 -1
  21. package/dist/cli/__tests__/launch-fallback.test.js +51 -3
  22. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  23. package/dist/cli/__tests__/question.test.js +2 -2
  24. package/dist/cli/__tests__/question.test.js.map +1 -1
  25. package/dist/cli/doctor.d.ts.map +1 -1
  26. package/dist/cli/doctor.js +178 -7
  27. package/dist/cli/doctor.js.map +1 -1
  28. package/dist/cli/index.d.ts +7 -1
  29. package/dist/cli/index.d.ts.map +1 -1
  30. package/dist/cli/index.js +143 -43
  31. package/dist/cli/index.js.map +1 -1
  32. package/dist/config/__tests__/codex-hooks.test.js +3 -3
  33. package/dist/config/__tests__/codex-hooks.test.js.map +1 -1
  34. package/dist/config/codex-hooks.d.ts +1 -0
  35. package/dist/config/codex-hooks.d.ts.map +1 -1
  36. package/dist/config/codex-hooks.js +2 -4
  37. package/dist/config/codex-hooks.js.map +1 -1
  38. package/dist/config/generator.d.ts +14 -0
  39. package/dist/config/generator.d.ts.map +1 -1
  40. package/dist/config/generator.js +100 -1
  41. package/dist/config/generator.js.map +1 -1
  42. package/dist/goal-workflows/__tests__/codex-goal-snapshot.test.js +21 -0
  43. package/dist/goal-workflows/__tests__/codex-goal-snapshot.test.js.map +1 -1
  44. package/dist/goal-workflows/codex-goal-snapshot.d.ts +3 -0
  45. package/dist/goal-workflows/codex-goal-snapshot.d.ts.map +1 -1
  46. package/dist/goal-workflows/codex-goal-snapshot.js +45 -2
  47. package/dist/goal-workflows/codex-goal-snapshot.js.map +1 -1
  48. package/dist/hooks/__tests__/autopilot-skill-contract.test.js +17 -0
  49. package/dist/hooks/__tests__/autopilot-skill-contract.test.js.map +1 -1
  50. package/dist/hooks/__tests__/keyword-detector.test.js +170 -15
  51. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  52. package/dist/hooks/__tests__/prometheus-strict-contract.test.d.ts +2 -0
  53. package/dist/hooks/__tests__/prometheus-strict-contract.test.d.ts.map +1 -0
  54. package/dist/hooks/__tests__/prometheus-strict-contract.test.js +320 -0
  55. package/dist/hooks/__tests__/prometheus-strict-contract.test.js.map +1 -0
  56. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js +12 -0
  57. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js.map +1 -1
  58. package/dist/hooks/__tests__/research-workflow-boundaries.test.d.ts +2 -0
  59. package/dist/hooks/__tests__/research-workflow-boundaries.test.d.ts.map +1 -0
  60. package/dist/hooks/__tests__/research-workflow-boundaries.test.js +35 -0
  61. package/dist/hooks/__tests__/research-workflow-boundaries.test.js.map +1 -0
  62. package/dist/hooks/keyword-detector.d.ts +1 -1
  63. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  64. package/dist/hooks/keyword-detector.js +28 -6
  65. package/dist/hooks/keyword-detector.js.map +1 -1
  66. package/dist/hooks/keyword-registry.d.ts.map +1 -1
  67. package/dist/hooks/keyword-registry.js +1 -0
  68. package/dist/hooks/keyword-registry.js.map +1 -1
  69. package/dist/hooks/prompt-guidance-contract.d.ts.map +1 -1
  70. package/dist/hooks/prompt-guidance-contract.js +11 -0
  71. package/dist/hooks/prompt-guidance-contract.js.map +1 -1
  72. package/dist/hud/__tests__/hud-tmux-injection.test.js +22 -0
  73. package/dist/hud/__tests__/hud-tmux-injection.test.js.map +1 -1
  74. package/dist/hud/__tests__/reconcile.test.js +121 -10
  75. package/dist/hud/__tests__/reconcile.test.js.map +1 -1
  76. package/dist/hud/__tests__/render.test.js +84 -0
  77. package/dist/hud/__tests__/render.test.js.map +1 -1
  78. package/dist/hud/__tests__/state.test.js +51 -1
  79. package/dist/hud/__tests__/state.test.js.map +1 -1
  80. package/dist/hud/__tests__/tmux.test.js +69 -23
  81. package/dist/hud/__tests__/tmux.test.js.map +1 -1
  82. package/dist/hud/index.d.ts +1 -1
  83. package/dist/hud/index.d.ts.map +1 -1
  84. package/dist/hud/index.js +8 -3
  85. package/dist/hud/index.js.map +1 -1
  86. package/dist/hud/reconcile.d.ts.map +1 -1
  87. package/dist/hud/reconcile.js +6 -3
  88. package/dist/hud/reconcile.js.map +1 -1
  89. package/dist/hud/render.d.ts.map +1 -1
  90. package/dist/hud/render.js +26 -0
  91. package/dist/hud/render.js.map +1 -1
  92. package/dist/hud/state.d.ts +2 -1
  93. package/dist/hud/state.d.ts.map +1 -1
  94. package/dist/hud/state.js +62 -1
  95. package/dist/hud/state.js.map +1 -1
  96. package/dist/hud/tmux.d.ts +10 -3
  97. package/dist/hud/tmux.d.ts.map +1 -1
  98. package/dist/hud/tmux.js +59 -10
  99. package/dist/hud/tmux.js.map +1 -1
  100. package/dist/hud/types.d.ts +22 -0
  101. package/dist/hud/types.d.ts.map +1 -1
  102. package/dist/hud/types.js.map +1 -1
  103. package/dist/pipeline/__tests__/orchestrator.test.js +63 -1
  104. package/dist/pipeline/__tests__/orchestrator.test.js.map +1 -1
  105. package/dist/pipeline/__tests__/stages.test.js +410 -4
  106. package/dist/pipeline/__tests__/stages.test.js.map +1 -1
  107. package/dist/pipeline/orchestrator.d.ts.map +1 -1
  108. package/dist/pipeline/orchestrator.js +29 -2
  109. package/dist/pipeline/orchestrator.js.map +1 -1
  110. package/dist/pipeline/stages/ralplan.d.ts.map +1 -1
  111. package/dist/pipeline/stages/ralplan.js +41 -6
  112. package/dist/pipeline/stages/ralplan.js.map +1 -1
  113. package/dist/question/__tests__/ui.test.js +43 -10
  114. package/dist/question/__tests__/ui.test.js.map +1 -1
  115. package/dist/question/ui.d.ts +12 -0
  116. package/dist/question/ui.d.ts.map +1 -1
  117. package/dist/question/ui.js +83 -46
  118. package/dist/question/ui.js.map +1 -1
  119. package/dist/ralplan/__tests__/runtime.test.js +200 -10
  120. package/dist/ralplan/__tests__/runtime.test.js.map +1 -1
  121. package/dist/ralplan/consensus-gate.d.ts +23 -0
  122. package/dist/ralplan/consensus-gate.d.ts.map +1 -0
  123. package/dist/ralplan/consensus-gate.js +212 -0
  124. package/dist/ralplan/consensus-gate.js.map +1 -0
  125. package/dist/ralplan/runtime.d.ts +25 -0
  126. package/dist/ralplan/runtime.d.ts.map +1 -1
  127. package/dist/ralplan/runtime.js +144 -8
  128. package/dist/ralplan/runtime.js.map +1 -1
  129. package/dist/scripts/__tests__/codex-native-hook.test.js +626 -7
  130. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  131. package/dist/scripts/__tests__/docs-site-contract.test.d.ts +2 -0
  132. package/dist/scripts/__tests__/docs-site-contract.test.d.ts.map +1 -0
  133. package/dist/scripts/__tests__/docs-site-contract.test.js +42 -0
  134. package/dist/scripts/__tests__/docs-site-contract.test.js.map +1 -0
  135. package/dist/scripts/__tests__/notify-dispatcher.test.js +115 -2
  136. package/dist/scripts/__tests__/notify-dispatcher.test.js.map +1 -1
  137. package/dist/scripts/__tests__/run-test-files.test.js +57 -0
  138. package/dist/scripts/__tests__/run-test-files.test.js.map +1 -1
  139. package/dist/scripts/__tests__/verify-native-agents.test.js +2 -2
  140. package/dist/scripts/__tests__/verify-native-agents.test.js.map +1 -1
  141. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  142. package/dist/scripts/codex-native-hook.js +214 -34
  143. package/dist/scripts/codex-native-hook.js.map +1 -1
  144. package/dist/scripts/notify-dispatcher.js +188 -4
  145. package/dist/scripts/notify-dispatcher.js.map +1 -1
  146. package/dist/scripts/run-test-files.js +13 -0
  147. package/dist/scripts/run-test-files.js.map +1 -1
  148. package/dist/state/__tests__/workflow-transition.test.js +6 -0
  149. package/dist/state/__tests__/workflow-transition.test.js.map +1 -1
  150. package/dist/state/workflow-transition.d.ts +1 -1
  151. package/dist/state/workflow-transition.d.ts.map +1 -1
  152. package/dist/state/workflow-transition.js +7 -0
  153. package/dist/state/workflow-transition.js.map +1 -1
  154. package/dist/subagents/tracker.d.ts.map +1 -1
  155. package/dist/subagents/tracker.js +4 -3
  156. package/dist/subagents/tracker.js.map +1 -1
  157. package/dist/team/__tests__/runtime.test.js +36 -44
  158. package/dist/team/__tests__/runtime.test.js.map +1 -1
  159. package/dist/team/__tests__/tmux-session.test.js +58 -18
  160. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  161. package/dist/team/runtime.d.ts.map +1 -1
  162. package/dist/team/runtime.js +10 -20
  163. package/dist/team/runtime.js.map +1 -1
  164. package/dist/team/tmux-session.d.ts.map +1 -1
  165. package/dist/team/tmux-session.js +15 -6
  166. package/dist/team/tmux-session.js.map +1 -1
  167. package/dist/ultragoal/__tests__/artifacts.test.js +50 -0
  168. package/dist/ultragoal/__tests__/artifacts.test.js.map +1 -1
  169. package/dist/ultragoal/artifacts.d.ts.map +1 -1
  170. package/dist/ultragoal/artifacts.js +28 -2
  171. package/dist/ultragoal/artifacts.js.map +1 -1
  172. package/package.json +1 -1
  173. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
  174. package/plugins/oh-my-codex/skills/autopilot/SKILL.md +16 -4
  175. package/plugins/oh-my-codex/skills/autoresearch/SKILL.md +4 -0
  176. package/plugins/oh-my-codex/skills/autoresearch-goal/SKILL.md +1 -1
  177. package/plugins/oh-my-codex/skills/best-practice-research/SKILL.md +1 -1
  178. package/plugins/oh-my-codex/skills/pipeline/SKILL.md +1 -1
  179. package/plugins/oh-my-codex/skills/plan/SKILL.md +1 -1
  180. package/plugins/oh-my-codex/skills/prometheus-strict/README.md +35 -0
  181. package/plugins/oh-my-codex/skills/prometheus-strict/SKILL.md +219 -0
  182. package/plugins/oh-my-codex/skills/ralplan/SKILL.md +18 -3
  183. package/prompts/prometheus-strict-metis.md +274 -0
  184. package/prompts/prometheus-strict-momus.md +82 -0
  185. package/prompts/prometheus-strict-oracle.md +107 -0
  186. package/prompts/researcher.md +22 -3
  187. package/skills/autopilot/SKILL.md +16 -4
  188. package/skills/autoresearch/SKILL.md +4 -0
  189. package/skills/autoresearch-goal/SKILL.md +1 -1
  190. package/skills/best-practice-research/SKILL.md +1 -1
  191. package/skills/pipeline/SKILL.md +1 -1
  192. package/skills/plan/SKILL.md +1 -1
  193. package/skills/prometheus-strict/README.md +35 -0
  194. package/skills/prometheus-strict/SKILL.md +219 -0
  195. package/skills/ralplan/SKILL.md +18 -3
  196. package/src/scripts/__tests__/codex-native-hook.test.ts +769 -8
  197. package/src/scripts/__tests__/docs-site-contract.test.ts +47 -0
  198. package/src/scripts/__tests__/notify-dispatcher.test.ts +132 -3
  199. package/src/scripts/__tests__/run-test-files.test.ts +67 -0
  200. package/src/scripts/__tests__/verify-native-agents.test.ts +2 -2
  201. package/src/scripts/codex-native-hook.ts +237 -30
  202. package/src/scripts/notify-dispatcher.ts +202 -4
  203. package/src/scripts/run-test-files.ts +13 -0
  204. package/templates/catalog-manifest.json +22 -0
@@ -3,8 +3,9 @@ import { closeSync, existsSync, openSync, readFileSync, readSync } from "fs";
3
3
  import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "fs/promises";
4
4
  import { extname, join, relative, resolve } from "path";
5
5
  import { pathToFileURL } from "url";
6
- import { readModeState, readModeStateForActiveDecision, readModeStateForSession, updateModeState } from "../modes/base.js";
6
+ import { readModeStateForActiveDecision, readModeStateForSession, updateModeState } from "../modes/base.js";
7
7
  import {
8
+ SKILL_ACTIVE_STATE_FILE,
8
9
  extractSessionIdFromInitializedStatePath,
9
10
  getSkillActiveStatePathsForStateDir,
10
11
  listActiveSkills,
@@ -14,6 +15,7 @@ import {
14
15
  } from "../state/skill-active.js";
15
16
  import {
16
17
  readSubagentSessionSummary,
18
+ readSubagentTrackingState,
17
19
  recordSubagentTurnForSession,
18
20
  } from "../subagents/tracker.js";
19
21
  import { resolveCanonicalTeamStateRoot, resolveWorkerNotifyTeamStateRootPath } from "../team/state-root.js";
@@ -297,18 +299,32 @@ async function isNativeSubagentHook(
297
299
  nativeSessionId: string,
298
300
  threadId: string,
299
301
  ): Promise<boolean> {
300
- const sessionId = canonicalSessionId.trim();
301
- if (!sessionId) return false;
302
-
303
- const summary = await readSubagentSessionSummary(cwd, sessionId).catch(() => null);
304
- if (!summary) return false;
305
-
306
302
  const candidateIds = [nativeSessionId, threadId]
307
303
  .map((value) => value.trim())
308
304
  .filter(Boolean);
309
305
  if (candidateIds.length === 0) return false;
310
306
 
311
- return candidateIds.some((id) => summary.allSubagentThreadIds.includes(id));
307
+ const sessionId = canonicalSessionId.trim();
308
+ if (sessionId) {
309
+ const summary = await readSubagentSessionSummary(cwd, sessionId).catch(() => null);
310
+ if (summary && candidateIds.some((id) => summary.allSubagentThreadIds.includes(id))) {
311
+ return true;
312
+ }
313
+ }
314
+
315
+ // Native Codex resume can report the child native session as the canonical
316
+ // session id before OMX reconciles it back to the owning session. In that
317
+ // window the per-session summary lookup above misses the child and a
318
+ // subagent UserPromptSubmit can accidentally activate workflow keywords from
319
+ // quoted review context. Fall back to the global tracking index so any known
320
+ // subagent thread is treated as subagent-scoped, regardless of the current
321
+ // hook payload's session-id mapping.
322
+ const trackingState = await readSubagentTrackingState(cwd).catch(() => null);
323
+ if (!trackingState) return false;
324
+
325
+ return Object.values(trackingState.sessions).some((session) => (
326
+ candidateIds.some((id) => session.threads[id]?.kind === "subagent")
327
+ ));
312
328
  }
313
329
 
314
330
  function shouldSuppressSubagentLifecycleHookDispatch(): boolean {
@@ -1683,6 +1699,17 @@ function buildSkillStateCliInstruction(mode: string, statePath: string): string
1683
1699
  return `skill: ${mode} activated and initial state initialized at ${statePath}; use CLI-first state updates via \`omx state write/read/clear --input '<json>' --json\`; use omx_state MCP only when explicit MCP compatibility is enabled.`;
1684
1700
  }
1685
1701
 
1702
+ function buildAutopilotPromptActivationNote(skillState?: SkillActiveState | null): string | null {
1703
+ if (skillState?.initialized_mode !== "autopilot") return null;
1704
+ return [
1705
+ "Autopilot protocol: the durable default chain is $deep-interview -> $ralplan -> $ultragoal (+ $team if needed) -> $code-review -> $ultraqa (deep-interview -> ralplan -> ultragoal -> code-review -> ultraqa).",
1706
+ "Start/resume at current_phase=deep-interview unless the task is clear and bounded; if deep-interview is intentionally skipped, persist and state an explicit deep_interview_gate.skip_reason before moving to ralplan.",
1707
+ "The ralplan phase is not complete until Planner output has been reviewed sequentially by Architect and then Critic; do not hand off to Ultragoal or implementation until the ralplan state/artifact records both ralplan_architect_review and ralplan_critic_review with approval or an explicit blocker.",
1708
+ "Do not silently fall back to ordinary $plan/ralplan-only handling; keep autopilot-state.json, skill-active-state.json, HUD/statusline, and Codex goal-mode handoff guidance visible while the workflow is active.",
1709
+ "When Codex goal tools are available, call get_goal/create_goal only from the active thread handoff and treat the active goal as the completion contract until code-review and ultraqa are clean.",
1710
+ ].join(" ");
1711
+ }
1712
+
1686
1713
  function buildAdditionalContextMessage(
1687
1714
  prompt: string,
1688
1715
  skillState?: SkillActiveState | null,
@@ -1716,6 +1743,7 @@ function buildAdditionalContextMessage(
1716
1743
  const ultragoalPromptActivationNote = match.skill === "ultragoal"
1717
1744
  ? "Ultragoal protocol: use `omx ultragoal create-goals` / `complete-goals` / `checkpoint` for `.omx/ultragoal` artifacts, then use Codex goal model tools only from the active agent handoff (`get_goal`, `create_goal`, `update_goal`) and never overwrite a different active Codex goal. Ultragoal does not call `/goal clear`; for multiple sequential ultragoal runs in one Codex session/thread, manually clear the completed Codex goal in the UI before creating the next aggregate goal."
1718
1745
  : null;
1746
+ const autopilotPromptActivationNote = buildAutopilotPromptActivationNote(skillState);
1719
1747
  const combinedTransitionMessage = (() => {
1720
1748
  if (!skillState?.transition_message) return null;
1721
1749
  if (matches.length <= 1 || activeSkills.length <= 1) return skillState.transition_message;
@@ -1743,6 +1771,7 @@ function buildAdditionalContextMessage(
1743
1771
  : null,
1744
1772
  promptPriorityMessage,
1745
1773
  ultragoalPromptActivationNote,
1774
+ autopilotPromptActivationNote,
1746
1775
  skillState.initialized_mode && skillState.initialized_state_path
1747
1776
  ? buildSkillStateCliInstruction(skillState.initialized_mode, skillState.initialized_state_path)
1748
1777
  : null,
@@ -1769,6 +1798,7 @@ function buildAdditionalContextMessage(
1769
1798
  deepInterviewPromptActivationNote,
1770
1799
  ultraworkPromptActivationNote,
1771
1800
  ultragoalPromptActivationNote,
1801
+ autopilotPromptActivationNote,
1772
1802
  buildTeamRuntimeInstruction(cwd, payload),
1773
1803
  buildTeamHelpInstruction(cwd, payload),
1774
1804
  "Follow AGENTS.md routing and preserve workflow transition and planning-safety rules.",
@@ -1787,12 +1817,13 @@ function buildAdditionalContextMessage(
1787
1817
  deepInterviewPromptActivationNote,
1788
1818
  ultraworkPromptActivationNote,
1789
1819
  ultragoalPromptActivationNote,
1820
+ autopilotPromptActivationNote,
1790
1821
  ralphPromptActivationNote,
1791
1822
  "Follow AGENTS.md routing and preserve workflow transition and planning-safety rules.",
1792
1823
  ].join(" ");
1793
1824
  }
1794
1825
 
1795
- return [detectedKeywordMessage, promptPriorityMessage, ultragoalPromptActivationNote, "Follow AGENTS.md routing and preserve workflow transition and planning-safety rules."].filter(Boolean).join(" ");
1826
+ return [detectedKeywordMessage, promptPriorityMessage, ultragoalPromptActivationNote, autopilotPromptActivationNote, "Follow AGENTS.md routing and preserve workflow transition and planning-safety rules."].filter(Boolean).join(" ");
1796
1827
  }
1797
1828
 
1798
1829
  function parseTeamWorkerEnv(rawValue: string): { teamName: string; workerName: string } | null {
@@ -2041,6 +2072,7 @@ async function findActiveGoalWorkflowReconciliationRequirement(cwd: string): Pro
2041
2072
  `If get_goal returns a completed task-scoped objective for the same aggregate ultragoal plan, checkpoint ${goalId} with evidence naming ${goalId} plus .omx/ultragoal/goals.json or ledger.jsonl and pass final quality-gate JSON; OMX will reconcile the completed planned scope without mutating Codex goal state.`,
2042
2073
  `If get_goal instead returns a different completed legacy objective and complete checkpointing fails, do not repeat --status complete in this thread.`,
2043
2074
  `Record the non-terminal blocker with: omx ultragoal checkpoint --goal-id ${goalId} --status blocked --codex-goal-json '<different completed get_goal JSON or path>' --evidence '<completed legacy Codex goal blocks create_goal in this thread>'.`,
2075
+ `If get_goal itself is unavailable with a Codex DB/schema/context error such as "no such table: thread_goals", record an auditable safe-recovery blocker instead: omx ultragoal checkpoint --goal-id ${goalId} --status blocked --codex-goal-json '<unavailable get_goal error JSON or path>' --evidence '<get_goal unavailable due to Codex DB/schema/context error; safe recovery requires a working Codex goal context>'.`,
2044
2076
  "Then continue only from a Codex goal context with no active/completed conflicting goal in the same repo/worktree and create the intended goal there.",
2045
2077
  ].join(" "),
2046
2078
  };
@@ -2127,36 +2159,60 @@ async function buildGoalWorkflowReconciliationStopOutput(
2127
2159
  };
2128
2160
  }
2129
2161
 
2162
+ interface TeamModeStateForStop {
2163
+ state: Record<string, unknown>;
2164
+ scope: "session" | "root";
2165
+ }
2166
+
2167
+ function teamStateMatchesThreadForStop(
2168
+ state: Record<string, unknown>,
2169
+ threadId?: string,
2170
+ options: { requireOwnerThread?: boolean } = {},
2171
+ ): boolean {
2172
+ const normalizedThreadId = safeString(threadId).trim();
2173
+ if (!normalizedThreadId) return true;
2174
+
2175
+ const ownerThreadId = safeString(state.owner_codex_thread_id ?? state.thread_id).trim();
2176
+ if (!ownerThreadId) return options.requireOwnerThread !== true;
2177
+ return ownerThreadId === normalizedThreadId;
2178
+ }
2179
+
2130
2180
  async function readTeamModeStateForStop(
2131
2181
  cwd: string,
2132
2182
  stateDir: string,
2133
2183
  sessionId?: string,
2134
- ): Promise<Record<string, unknown> | null> {
2184
+ threadId?: string,
2185
+ ): Promise<TeamModeStateForStop | null> {
2135
2186
  const normalizedSessionId = safeString(sessionId).trim();
2136
- if (!normalizedSessionId) {
2137
- return await readModeState("team", cwd);
2138
- }
2187
+ if (!normalizedSessionId) return null;
2139
2188
 
2140
2189
  const scopedState = await readStopSessionPinnedState("team-state.json", cwd, normalizedSessionId, stateDir);
2141
- if (scopedState) return scopedState;
2190
+ if (scopedState) {
2191
+ return teamStateMatchesThreadForStop(scopedState, threadId)
2192
+ ? { state: scopedState, scope: "session" }
2193
+ : null;
2194
+ }
2142
2195
 
2143
2196
  const rootState = await readJsonIfExists(join(stateDir, "team-state.json"));
2144
2197
  if (rootState?.active !== true) return null;
2145
2198
 
2199
+ const teamName = safeString(rootState.team_name).trim();
2200
+ if (!teamName) return null;
2201
+
2146
2202
  const ownerSessionId = safeString(rootState.session_id).trim();
2147
- if (ownerSessionId && ownerSessionId !== normalizedSessionId) {
2148
- return null;
2149
- }
2203
+ if (!ownerSessionId || ownerSessionId !== normalizedSessionId) return null;
2204
+ if (!teamStateMatchesThreadForStop(rootState, threadId, { requireOwnerThread: true })) return null;
2150
2205
 
2151
- return rootState;
2206
+ return { state: rootState, scope: "root" };
2152
2207
  }
2153
2208
 
2154
- async function buildTeamStopOutput(cwd: string, sessionId?: string): Promise<Record<string, unknown> | null> {
2209
+ async function buildTeamStopOutput(cwd: string, sessionId?: string, threadId?: string): Promise<Record<string, unknown> | null> {
2155
2210
  if (await readCanonicalTerminalRunStateForStop(cwd, sessionId, "team")) {
2156
2211
  return null;
2157
2212
  }
2158
- const teamState = await readTeamModeStateForStop(cwd, getBaseStateDir(cwd), sessionId);
2159
- if (teamState?.active !== true) return null;
2213
+ const teamStateForStop = await readTeamModeStateForStop(cwd, getBaseStateDir(cwd), sessionId, threadId);
2214
+ if (!teamStateForStop || teamStateForStop.state.active !== true) return null;
2215
+ const teamState = teamStateForStop.state;
2160
2216
  const teamName = safeString(teamState.team_name).trim();
2161
2217
  if (teamName) {
2162
2218
  const canonicalTeamDir = join(resolveCanonicalTeamStateRoot(cwd), "team", teamName);
@@ -2165,7 +2221,9 @@ async function buildTeamStopOutput(cwd: string, sessionId?: string): Promise<Rec
2165
2221
  }
2166
2222
  }
2167
2223
  const coarsePhase = teamState.current_phase;
2168
- const canonicalPhase = teamName ? (await readTeamPhase(teamName, cwd))?.current_phase ?? coarsePhase : coarsePhase;
2224
+ const canonicalPhaseState = teamName ? await readTeamPhase(teamName, cwd) : null;
2225
+ if (teamStateForStop.scope === "root" && !canonicalPhaseState) return null;
2226
+ const canonicalPhase = canonicalPhaseState?.current_phase ?? coarsePhase;
2169
2227
  if (!isNonTerminalPhase(canonicalPhase)) return null;
2170
2228
  return buildTeamStopOutputForPhase(teamName, formatPhase(canonicalPhase));
2171
2229
  }
@@ -2272,6 +2330,151 @@ async function readStopSessionPinnedState(
2272
2330
  return readJsonIfExists(statePath);
2273
2331
  }
2274
2332
 
2333
+ const DEEP_INTERVIEW_ALLOWED_WRITE_PREFIXES = [
2334
+ ".omx/context",
2335
+ ".omx/interviews",
2336
+ ".omx/specs",
2337
+ ".omx/state",
2338
+ ] as const;
2339
+
2340
+ const DEEP_INTERVIEW_IMPLEMENTATION_TOOL_NAMES = new Set([
2341
+ "Write",
2342
+ "Edit",
2343
+ "MultiEdit",
2344
+ "apply_patch",
2345
+ "ApplyPatch",
2346
+ ]);
2347
+
2348
+ function isActiveDeepInterviewPhase(state: Record<string, unknown> | null): boolean {
2349
+ if (!state || state.active !== true) return false;
2350
+ const mode = safeString(state.mode).trim();
2351
+ if (mode && mode !== "deep-interview") return false;
2352
+ const phase = safeString(state.current_phase ?? state.currentPhase).trim().toLowerCase();
2353
+ if (phase && (TERMINAL_MODE_PHASES.has(phase) || phase === "completing")) return false;
2354
+ return true;
2355
+ }
2356
+
2357
+ function isAllowedDeepInterviewArtifactPath(cwd: string, rawPath: string): boolean {
2358
+ const trimmed = rawPath.trim().replace(/^['"]|['"]$/g, "");
2359
+ if (!trimmed || trimmed.includes("\0")) return false;
2360
+ let relativePath: string;
2361
+ try {
2362
+ const absolute = resolve(cwd, trimmed);
2363
+ relativePath = relative(cwd, absolute).replace(/\\/g, "/");
2364
+ } catch {
2365
+ return false;
2366
+ }
2367
+ if (!relativePath || relativePath.startsWith("..") || relativePath.startsWith("/")) return false;
2368
+ return DEEP_INTERVIEW_ALLOWED_WRITE_PREFIXES.some((prefix) => (
2369
+ relativePath === prefix || relativePath.startsWith(`${prefix}/`)
2370
+ ));
2371
+ }
2372
+
2373
+ function readPreToolUseCommand(payload: CodexHookPayload): string {
2374
+ const toolInput = safeObject(payload.tool_input);
2375
+ return safeString(toolInput.command).trim();
2376
+ }
2377
+
2378
+ function readPreToolUsePathCandidates(payload: CodexHookPayload): string[] {
2379
+ const input = safeObject(payload.tool_input);
2380
+ const candidates = [
2381
+ input.file_path,
2382
+ input.filePath,
2383
+ input.path,
2384
+ input.target_path,
2385
+ input.targetPath,
2386
+ ];
2387
+ return candidates.map((candidate) => safeString(candidate).trim()).filter(Boolean);
2388
+ }
2389
+
2390
+ function commandHasDeepInterviewWriteIntent(command: string): boolean {
2391
+ return /\bapply_patch\b/.test(command)
2392
+ || /(?:^|[;&|]\s*)(?:cat|printf|echo)\b[\s\S]{0,240}>\s*[^\s&|;]+/.test(command)
2393
+ || /\btee\s+(?:-a\s+)?[^\s&|;]+/.test(command)
2394
+ || /\bsed\s+(?:[^\n;&|]*\s)?-i(?:\b|['"])/.test(command)
2395
+ || /\b(?:python3?|node|perl|ruby)\b[\s\S]{0,260}\b(?:writeFileSync|writeFile|write_text|open\([^)]*["']w|File\.write|Path\()/.test(command)
2396
+ || /\b(?:git\s+(?:checkout|switch|restore|reset|apply|am|merge|rebase)|npm\s+(?:install|i|ci)|pnpm\s+(?:install|i)|yarn\s+(?:install|add))\b/.test(command);
2397
+ }
2398
+
2399
+ function extractDeepInterviewCommandWriteTargets(command: string): string[] {
2400
+ const targets: string[] = [];
2401
+ for (const match of command.matchAll(/(?:^|[^>])>{1,2}\s*(["']?)([^\s&|;<>]+)\1/g)) {
2402
+ const candidate = safeString(match[2]).trim();
2403
+ if (candidate) targets.push(candidate);
2404
+ }
2405
+ for (const match of command.matchAll(/\btee\s+(?:-a\s+)?(["']?)([^\s&|;<>]+)\1/g)) {
2406
+ const candidate = safeString(match[2]).trim();
2407
+ if (candidate) targets.push(candidate);
2408
+ }
2409
+ return targets;
2410
+ }
2411
+
2412
+ function isAllowedDeepInterviewBashWrite(cwd: string, command: string): boolean {
2413
+ if (!commandHasDeepInterviewWriteIntent(command)) return true;
2414
+ if (/\bomx\s+(?:state\s+(?:write|read|clear)|question)\b/.test(command)) return true;
2415
+ const targets = extractDeepInterviewCommandWriteTargets(command);
2416
+ return targets.length > 0 && targets.every((target) => isAllowedDeepInterviewArtifactPath(cwd, target));
2417
+ }
2418
+
2419
+ async function readActiveDeepInterviewStateForPreToolUse(
2420
+ cwd: string,
2421
+ stateDir: string,
2422
+ sessionId: string,
2423
+ threadId: string,
2424
+ ): Promise<Record<string, unknown> | null> {
2425
+ const modeState = sessionId
2426
+ ? await readStopSessionPinnedState("deep-interview-state.json", cwd, sessionId, stateDir)
2427
+ : await readJsonIfExists(join(stateDir, "deep-interview-state.json"));
2428
+ if (!isActiveDeepInterviewPhase(modeState) || !modeState) return null;
2429
+ if (!modeStateMatchesSkillStopContext(modeState, cwd, sessionId)) return null;
2430
+
2431
+ const canonicalState = sessionId
2432
+ ? await readVisibleSkillActiveStateForStateDir(stateDir, sessionId)
2433
+ : await readSkillActiveState(join(stateDir, SKILL_ACTIVE_STATE_FILE));
2434
+ if (!canonicalState) return modeState;
2435
+ const hasActiveDeepInterviewSkill = listActiveSkills(canonicalState).some((entry) => (
2436
+ entry.skill === "deep-interview"
2437
+ && matchesSkillStopContext(entry, canonicalState, sessionId, threadId)
2438
+ ));
2439
+ return hasActiveDeepInterviewSkill ? modeState : null;
2440
+ }
2441
+
2442
+ async function buildDeepInterviewPreToolUseBoundaryOutput(
2443
+ payload: CodexHookPayload,
2444
+ cwd: string,
2445
+ stateDir: string,
2446
+ ): Promise<Record<string, unknown> | null> {
2447
+ const sessionId = readPayloadSessionId(payload);
2448
+ const threadId = readPayloadThreadId(payload);
2449
+ const activeState = await readActiveDeepInterviewStateForPreToolUse(cwd, stateDir, sessionId, threadId);
2450
+ if (!activeState) return null;
2451
+
2452
+ const toolName = safeString(payload.tool_name).trim();
2453
+ const command = readPreToolUseCommand(payload);
2454
+ const pathCandidates = readPreToolUsePathCandidates(payload);
2455
+ let blocked = false;
2456
+
2457
+ if (toolName === "Bash") {
2458
+ blocked = !isAllowedDeepInterviewBashWrite(cwd, command);
2459
+ } else if (DEEP_INTERVIEW_IMPLEMENTATION_TOOL_NAMES.has(toolName)) {
2460
+ blocked = pathCandidates.length === 0
2461
+ || !pathCandidates.every((candidate) => isAllowedDeepInterviewArtifactPath(cwd, candidate));
2462
+ }
2463
+
2464
+ if (!blocked) return null;
2465
+
2466
+ const phase = formatPhase(activeState.current_phase ?? activeState.currentPhase, "planning");
2467
+ return {
2468
+ decision: "block",
2469
+ reason: `Deep-interview is active (phase: ${phase}); implementation/write tools are blocked until an explicit handoff workflow is activated.`,
2470
+ hookSpecificOutput: {
2471
+ hookEventName: "PreToolUse",
2472
+ additionalContext:
2473
+ "Deep-interview is requirements/spec mode. Treat detailed user answers as interview/spec material, not implicit implementation authorization. You may write only deep-interview artifacts under `.omx/context/`, `.omx/interviews/`, `.omx/specs/`, or required `.omx/state/` files. To implement, first ask for or process an explicit transition such as `$ralplan`, `$autopilot`, `$ralph`, `$team`, or `$ultragoal`.",
2474
+ },
2475
+ };
2476
+ }
2477
+
2275
2478
  function matchesSkillStopContext(
2276
2479
  entry: { session_id?: string; thread_id?: string },
2277
2480
  state: { session_id?: string; thread_id?: string },
@@ -2909,6 +3112,7 @@ async function returnPersistentStopBlock(
2909
3112
  async function findCanonicalActiveTeamForSession(
2910
3113
  cwd: string,
2911
3114
  sessionId: string,
3115
+ threadId?: string,
2912
3116
  ): Promise<{ teamName: string; phase: string } | null> {
2913
3117
  if (!sessionId.trim()) return null;
2914
3118
  const teamsRoot = join(resolveCanonicalTeamStateRoot(cwd), "team");
@@ -2927,6 +3131,7 @@ async function findCanonicalActiveTeamForSession(
2927
3131
  if (!manifest || !phaseState) continue;
2928
3132
  const ownerSessionId = (manifest.leader?.session_id ?? "").trim();
2929
3133
  if (ownerSessionId && ownerSessionId !== sessionId.trim()) continue;
3134
+ if (!teamStateMatchesThreadForStop(manifest.leader as unknown as Record<string, unknown>, threadId)) continue;
2930
3135
  if (!isNonTerminalPhase(phaseState.current_phase)) continue;
2931
3136
 
2932
3137
  return {
@@ -2942,12 +3147,13 @@ async function resolveActiveTeamNameForStop(
2942
3147
  cwd: string,
2943
3148
  stateDir: string,
2944
3149
  sessionId: string,
3150
+ threadId?: string,
2945
3151
  ): Promise<string> {
2946
- const directState = await readTeamModeStateForStop(cwd, stateDir, sessionId);
2947
- const directTeamName = safeString(directState?.team_name).trim();
2948
- if (directState?.active === true && directTeamName) return directTeamName;
3152
+ const directState = await readTeamModeStateForStop(cwd, stateDir, sessionId, threadId);
3153
+ const directTeamName = safeString(directState?.state.team_name).trim();
3154
+ if (directState?.state.active === true && directTeamName) return directTeamName;
2949
3155
 
2950
- const canonicalTeam = await findCanonicalActiveTeamForSession(cwd, sessionId);
3156
+ const canonicalTeam = await findCanonicalActiveTeamForSession(cwd, sessionId, threadId);
2951
3157
  return canonicalTeam?.teamName ?? "";
2952
3158
  }
2953
3159
 
@@ -2959,7 +3165,7 @@ async function maybeBuildReleaseReadinessFinalizeStopOutput(
2959
3165
  ): Promise<{ matched: boolean; output: Record<string, unknown> | null }> {
2960
3166
  if (!sessionId) return { matched: false, output: null };
2961
3167
 
2962
- const teamName = await resolveActiveTeamNameForStop(cwd, stateDir, sessionId);
3168
+ const teamName = await resolveActiveTeamNameForStop(cwd, stateDir, sessionId, readPayloadThreadId(payload));
2963
3169
  if (!teamName) return { matched: false, output: null };
2964
3170
 
2965
3171
  const explicitReleaseReadinessContext =
@@ -3288,7 +3494,7 @@ async function buildStopHookOutput(
3288
3494
  );
3289
3495
  if (releaseReadinessFinalizeResult.matched) return releaseReadinessFinalizeResult.output;
3290
3496
 
3291
- const teamOutput = await buildTeamStopOutput(cwd, canonicalSessionId);
3497
+ const teamOutput = await buildTeamStopOutput(cwd, canonicalSessionId, threadId);
3292
3498
  if (teamOutput) {
3293
3499
  return await returnPersistentStopBlock(
3294
3500
  payload,
@@ -3320,7 +3526,7 @@ async function buildStopHookOutput(
3320
3526
 
3321
3527
  const canonicalTeam = await readCanonicalTerminalRunStateForStop(cwd, canonicalSessionId, "team")
3322
3528
  ? null
3323
- : await findCanonicalActiveTeamForSession(cwd, canonicalSessionId);
3529
+ : await findCanonicalActiveTeamForSession(cwd, canonicalSessionId, threadId);
3324
3530
  if (canonicalTeam) {
3325
3531
  const canonicalTeamOutput = buildTeamStopOutputForPhase(
3326
3532
  canonicalTeam.teamName,
@@ -3710,7 +3916,8 @@ export async function dispatchCodexNativeHook(
3710
3916
  };
3711
3917
  }
3712
3918
  } else if (hookEventName === "PreToolUse") {
3713
- outputJson = buildNativePreToolUseOutput(payload);
3919
+ outputJson = await buildDeepInterviewPreToolUseBoundaryOutput(payload, cwd, stateDir)
3920
+ ?? buildNativePreToolUseOutput(payload);
3714
3921
  } else if (hookEventName === "PostToolUse") {
3715
3922
  if (detectMcpTransportFailure(payload)) {
3716
3923
  await markTeamTransportFailure(cwd, payload);
@@ -7,6 +7,9 @@
7
7
 
8
8
  import { readFile } from "fs/promises";
9
9
  import { spawnSync } from "child_process";
10
+ import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
11
+ import { dirname, join } from "path";
12
+ import { tmpdir } from "os";
10
13
 
11
14
  interface NotifyDispatcherMetadata {
12
15
  managedBy?: string;
@@ -16,6 +19,195 @@ interface NotifyDispatcherMetadata {
16
19
  dispatcherNotify?: string[];
17
20
  }
18
21
 
22
+ const DISPATCH_LOCK_STALE_MS = 45_000;
23
+ // Codex Desktop can replay a backlog of turn-ended callbacks well after the UI
24
+ // window has gone away. Keep the default same-turn coalescing window longer
25
+ // than a heavily loaded notification hook invocation so sequential queued
26
+ // callbacks from one thread do not slip through merely because the first
27
+ // dispatch was slow. Payloads with different thread/session identity retain
28
+ // independent notification cadence.
29
+ const DEFAULT_TURN_DISPATCH_MIN_INTERVAL_MS = 10_000;
30
+ const DEFAULT_STALE_EVENT_AGE_MS = 5 * 60_000;
31
+
32
+ interface DispatchGuard {
33
+ ok: boolean;
34
+ release?: () => void;
35
+ }
36
+
37
+ interface DispatchGuardState {
38
+ lastDispatchAt?: unknown;
39
+ lastDispatchByIdentity?: Record<string, unknown>;
40
+ }
41
+
42
+ function parseNonNegativeEnvMs(name: string, fallback: number): number {
43
+ const raw = process.env[name];
44
+ if (typeof raw !== "string" || raw.trim() === "") return fallback;
45
+ const parsed = Number(raw);
46
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
47
+ }
48
+
49
+ function parsePayloadObject(payloadArg: string): Record<string, unknown> | null {
50
+ try {
51
+ const parsed = JSON.parse(payloadArg) as unknown;
52
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
53
+ ? (parsed as Record<string, unknown>)
54
+ : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function isTurnEndedPayload(payload: Record<string, unknown> | null): boolean {
61
+ if (!payload) return false;
62
+ const type = String(payload.type ?? payload.event ?? payload.hook_event_name ?? "")
63
+ .trim()
64
+ .toLowerCase();
65
+ return type === ""
66
+ || type === "agent-turn-complete"
67
+ || type === "turn-complete"
68
+ || type === "turn-ended";
69
+ }
70
+
71
+ function readPayloadTimestampMs(payload: Record<string, unknown>): number | null {
72
+ for (const key of ["timestamp", "created_at", "createdAt", "event_time", "eventTime", "time"]) {
73
+ const value = payload[key];
74
+ if (typeof value === "number" && Number.isFinite(value)) {
75
+ return value > 1_000_000_000_000 ? value : value * 1000;
76
+ }
77
+ if (typeof value === "string" && value.trim()) {
78
+ const parsed = Date.parse(value);
79
+ if (Number.isFinite(parsed)) return parsed;
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function readPayloadIdentity(payload: Record<string, unknown> | null): string {
86
+ if (!payload) return "global";
87
+ for (const key of [
88
+ "thread_id",
89
+ "threadId",
90
+ "conversation_id",
91
+ "conversationId",
92
+ "session_id",
93
+ "sessionId",
94
+ ]) {
95
+ const value = payload[key];
96
+ if (typeof value === "string" && value.trim()) {
97
+ return `${key}:${value.trim()}`;
98
+ }
99
+ }
100
+ return "global";
101
+ }
102
+
103
+ function dispatchGuardDir(metadataPath: string): string {
104
+ if (metadataPath) return dirname(metadataPath);
105
+ return join(tmpdir(), "oh-my-codex-notify-dispatch");
106
+ }
107
+
108
+ function writeDispatchGuardState(
109
+ statePath: string,
110
+ state: DispatchGuardState,
111
+ identity: string,
112
+ minIntervalMs: number,
113
+ staleEventAgeMs: number,
114
+ ): void {
115
+ const previousByIdentity = state.lastDispatchByIdentity && typeof state.lastDispatchByIdentity === "object"
116
+ ? state.lastDispatchByIdentity
117
+ : {};
118
+ const retainedByIdentity: Record<string, number> = {};
119
+ const now = Date.now();
120
+ const retentionMs = Math.max(minIntervalMs, staleEventAgeMs, DEFAULT_TURN_DISPATCH_MIN_INTERVAL_MS);
121
+ for (const [key, value] of Object.entries(previousByIdentity)) {
122
+ if (typeof value === "number" && now - value <= retentionMs) {
123
+ retainedByIdentity[key] = value;
124
+ }
125
+ }
126
+ retainedByIdentity[identity] = now;
127
+ writeFileSync(statePath, JSON.stringify({
128
+ lastDispatchAt: identity === "global" ? now : (typeof state.lastDispatchAt === "number" ? state.lastDispatchAt : undefined),
129
+ lastDispatchByIdentity: retainedByIdentity,
130
+ pid: process.pid,
131
+ }));
132
+ }
133
+
134
+ function acquireTurnDispatchGuard(metadataPath: string, payloadArg: string): DispatchGuard {
135
+ const payload = parsePayloadObject(payloadArg);
136
+ if (!isTurnEndedPayload(payload)) return { ok: true };
137
+
138
+ const now = Date.now();
139
+ const staleEventAgeMs = parseNonNegativeEnvMs("OMX_NOTIFY_DISPATCH_STALE_EVENT_AGE_MS", DEFAULT_STALE_EVENT_AGE_MS);
140
+ const eventTimestampMs = payload ? readPayloadTimestampMs(payload) : null;
141
+ if (eventTimestampMs !== null && staleEventAgeMs > 0 && now - eventTimestampMs > staleEventAgeMs) {
142
+ return { ok: false };
143
+ }
144
+
145
+ const dir = dispatchGuardDir(metadataPath);
146
+ mkdirSync(dir, { recursive: true });
147
+ const lockPath = join(dir, "notify-dispatch.lock");
148
+ const statePath = join(dir, "notify-dispatch.guard.json");
149
+ try {
150
+ const lockStat = statSync(lockPath);
151
+ if (now - lockStat.mtimeMs > DISPATCH_LOCK_STALE_MS) unlinkSync(lockPath);
152
+ } catch {
153
+ // Missing or unreadable lock: try to acquire below.
154
+ }
155
+
156
+ let fd: number;
157
+ try {
158
+ fd = openSync(lockPath, "wx");
159
+ writeFileSync(fd, String(process.pid));
160
+ closeSync(fd);
161
+ } catch {
162
+ return { ok: false };
163
+ }
164
+
165
+ const release = () => {
166
+ try {
167
+ unlinkSync(lockPath);
168
+ } catch {
169
+ // Best effort.
170
+ }
171
+ };
172
+
173
+ try {
174
+ const minIntervalMs = parseNonNegativeEnvMs("OMX_NOTIFY_DISPATCH_MIN_INTERVAL_MS", DEFAULT_TURN_DISPATCH_MIN_INTERVAL_MS);
175
+ const identity = readPayloadIdentity(payload);
176
+ let state: DispatchGuardState = {};
177
+ try {
178
+ state = JSON.parse(readFileSync(statePath, "utf-8")) as typeof state;
179
+ } catch {
180
+ // No prior guard state.
181
+ }
182
+ if (minIntervalMs > 0) {
183
+ const byIdentity = state.lastDispatchByIdentity && typeof state.lastDispatchByIdentity === "object"
184
+ ? state.lastDispatchByIdentity
185
+ : {};
186
+ const identityLastDispatchAt = typeof byIdentity[identity] === "number" ? byIdentity[identity] : 0;
187
+ const legacyLastDispatchAt = identity === "global" && typeof state.lastDispatchAt === "number" ? state.lastDispatchAt : 0;
188
+ const lastDispatchAt = Math.max(identityLastDispatchAt, legacyLastDispatchAt);
189
+ if (lastDispatchAt > 0 && now - lastDispatchAt < minIntervalMs) {
190
+ release();
191
+ return { ok: false };
192
+ }
193
+ }
194
+ return {
195
+ ok: true,
196
+ release: () => {
197
+ try {
198
+ writeDispatchGuardState(statePath, state, identity, minIntervalMs, staleEventAgeMs);
199
+ } catch {
200
+ // Guard state is best effort; the lock still prevents concurrent duplicate dispatch.
201
+ }
202
+ release();
203
+ },
204
+ };
205
+ } catch {
206
+ release();
207
+ return { ok: false };
208
+ }
209
+ }
210
+
19
211
  function parseArgs(): { metadataPath: string; payloadArg: string } {
20
212
  let metadataPath = "";
21
213
  const args = process.argv.slice(2);
@@ -200,11 +392,17 @@ function runNotify(
200
392
  async function main(): Promise<void> {
201
393
  const { metadataPath, payloadArg } = parseArgs();
202
394
  if (!payloadArg || payloadArg.startsWith("-")) return;
203
- const metadata = await readMetadata(metadataPath);
204
- if (!isManagedPreviousNotify(metadata?.previousNotify, metadata)) {
205
- runNotify(metadata?.previousNotify, payloadArg);
395
+ const guard = acquireTurnDispatchGuard(metadataPath, payloadArg);
396
+ if (!guard.ok) return;
397
+ try {
398
+ const metadata = await readMetadata(metadataPath);
399
+ if (!isManagedPreviousNotify(metadata?.previousNotify, metadata)) {
400
+ runNotify(metadata?.previousNotify, payloadArg);
401
+ }
402
+ runNotify(metadata?.omxNotify, payloadArg);
403
+ } finally {
404
+ guard.release?.();
206
405
  }
207
- runNotify(metadata?.omxNotify, payloadArg);
208
406
  }
209
407
 
210
408
  main().catch(() => {});
@@ -5,6 +5,14 @@ import { join, resolve } from 'node:path';
5
5
  const DEFAULT_TEST_TIMEOUT_MS = 0;
6
6
  const DEFAULT_RUNNER_TIMEOUT_MS = 30 * 60 * 1_000;
7
7
  const DEFAULT_CI_TEST_CONCURRENCY = 1;
8
+ const RUNTIME_STATE_ENV_KEYS = [
9
+ 'OMX_ROOT',
10
+ 'OMX_STATE_ROOT',
11
+ 'OMX_TEAM_STATE_ROOT',
12
+ 'OMX_SESSION_ID',
13
+ 'CODEX_SESSION_ID',
14
+ 'SESSION_ID',
15
+ ] as const;
8
16
 
9
17
  function parseBooleanEnv(value: string | undefined): boolean {
10
18
  if (!value) return false;
@@ -91,6 +99,11 @@ console.error(
91
99
  const childEnv = { ...process.env };
92
100
  delete childEnv.NODE_TEST_CONTEXT;
93
101
  childEnv.OMX_TEST_RELAX_TMUX_TIMEOUT = '1';
102
+ if (!parseBooleanEnv(process.env.OMX_NODE_TEST_PRESERVE_RUNTIME_ENV)) {
103
+ for (const key of RUNTIME_STATE_ENV_KEYS) {
104
+ delete childEnv[key];
105
+ }
106
+ }
94
107
 
95
108
  const result = spawnSync(process.execPath, testArgs, {
96
109
  stdio: 'inherit',