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.
- package/Cargo.lock +6 -6
- package/Cargo.toml +1 -1
- package/README.md +4 -2
- package/dist/agents/__tests__/definitions.test.js +14 -0
- package/dist/agents/__tests__/definitions.test.js.map +1 -1
- package/dist/agents/__tests__/native-config.test.js +19 -0
- package/dist/agents/__tests__/native-config.test.js.map +1 -1
- package/dist/agents/definitions.d.ts.map +1 -1
- package/dist/agents/definitions.js +30 -0
- package/dist/agents/definitions.js.map +1 -1
- package/dist/agents/native-config.d.ts +1 -0
- package/dist/agents/native-config.d.ts.map +1 -1
- package/dist/agents/native-config.js +4 -0
- package/dist/agents/native-config.js.map +1 -1
- package/dist/catalog/__tests__/generator.test.js +4 -0
- package/dist/catalog/__tests__/generator.test.js.map +1 -1
- package/dist/cli/__tests__/doctor-warning-copy.test.js +61 -5
- package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
- package/dist/cli/__tests__/index.test.js +161 -21
- package/dist/cli/__tests__/index.test.js.map +1 -1
- package/dist/cli/__tests__/launch-fallback.test.js +51 -3
- package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
- package/dist/cli/__tests__/question.test.js +2 -2
- package/dist/cli/__tests__/question.test.js.map +1 -1
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +178 -7
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.d.ts +7 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +143 -43
- package/dist/cli/index.js.map +1 -1
- package/dist/config/__tests__/codex-hooks.test.js +3 -3
- package/dist/config/__tests__/codex-hooks.test.js.map +1 -1
- package/dist/config/codex-hooks.d.ts +1 -0
- package/dist/config/codex-hooks.d.ts.map +1 -1
- package/dist/config/codex-hooks.js +2 -4
- package/dist/config/codex-hooks.js.map +1 -1
- package/dist/config/generator.d.ts +14 -0
- package/dist/config/generator.d.ts.map +1 -1
- package/dist/config/generator.js +100 -1
- package/dist/config/generator.js.map +1 -1
- package/dist/goal-workflows/__tests__/codex-goal-snapshot.test.js +21 -0
- package/dist/goal-workflows/__tests__/codex-goal-snapshot.test.js.map +1 -1
- package/dist/goal-workflows/codex-goal-snapshot.d.ts +3 -0
- package/dist/goal-workflows/codex-goal-snapshot.d.ts.map +1 -1
- package/dist/goal-workflows/codex-goal-snapshot.js +45 -2
- package/dist/goal-workflows/codex-goal-snapshot.js.map +1 -1
- package/dist/hooks/__tests__/autopilot-skill-contract.test.js +17 -0
- package/dist/hooks/__tests__/autopilot-skill-contract.test.js.map +1 -1
- package/dist/hooks/__tests__/keyword-detector.test.js +170 -15
- package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
- package/dist/hooks/__tests__/prometheus-strict-contract.test.d.ts +2 -0
- package/dist/hooks/__tests__/prometheus-strict-contract.test.d.ts.map +1 -0
- package/dist/hooks/__tests__/prometheus-strict-contract.test.js +320 -0
- package/dist/hooks/__tests__/prometheus-strict-contract.test.js.map +1 -0
- package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js +12 -0
- package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js.map +1 -1
- package/dist/hooks/__tests__/research-workflow-boundaries.test.d.ts +2 -0
- package/dist/hooks/__tests__/research-workflow-boundaries.test.d.ts.map +1 -0
- package/dist/hooks/__tests__/research-workflow-boundaries.test.js +35 -0
- package/dist/hooks/__tests__/research-workflow-boundaries.test.js.map +1 -0
- package/dist/hooks/keyword-detector.d.ts +1 -1
- package/dist/hooks/keyword-detector.d.ts.map +1 -1
- package/dist/hooks/keyword-detector.js +28 -6
- package/dist/hooks/keyword-detector.js.map +1 -1
- package/dist/hooks/keyword-registry.d.ts.map +1 -1
- package/dist/hooks/keyword-registry.js +1 -0
- package/dist/hooks/keyword-registry.js.map +1 -1
- package/dist/hooks/prompt-guidance-contract.d.ts.map +1 -1
- package/dist/hooks/prompt-guidance-contract.js +11 -0
- package/dist/hooks/prompt-guidance-contract.js.map +1 -1
- package/dist/hud/__tests__/hud-tmux-injection.test.js +22 -0
- package/dist/hud/__tests__/hud-tmux-injection.test.js.map +1 -1
- package/dist/hud/__tests__/reconcile.test.js +121 -10
- package/dist/hud/__tests__/reconcile.test.js.map +1 -1
- package/dist/hud/__tests__/render.test.js +84 -0
- package/dist/hud/__tests__/render.test.js.map +1 -1
- package/dist/hud/__tests__/state.test.js +51 -1
- package/dist/hud/__tests__/state.test.js.map +1 -1
- package/dist/hud/__tests__/tmux.test.js +69 -23
- package/dist/hud/__tests__/tmux.test.js.map +1 -1
- package/dist/hud/index.d.ts +1 -1
- package/dist/hud/index.d.ts.map +1 -1
- package/dist/hud/index.js +8 -3
- package/dist/hud/index.js.map +1 -1
- package/dist/hud/reconcile.d.ts.map +1 -1
- package/dist/hud/reconcile.js +6 -3
- package/dist/hud/reconcile.js.map +1 -1
- package/dist/hud/render.d.ts.map +1 -1
- package/dist/hud/render.js +26 -0
- package/dist/hud/render.js.map +1 -1
- package/dist/hud/state.d.ts +2 -1
- package/dist/hud/state.d.ts.map +1 -1
- package/dist/hud/state.js +62 -1
- package/dist/hud/state.js.map +1 -1
- package/dist/hud/tmux.d.ts +10 -3
- package/dist/hud/tmux.d.ts.map +1 -1
- package/dist/hud/tmux.js +59 -10
- package/dist/hud/tmux.js.map +1 -1
- package/dist/hud/types.d.ts +22 -0
- package/dist/hud/types.d.ts.map +1 -1
- package/dist/hud/types.js.map +1 -1
- package/dist/pipeline/__tests__/orchestrator.test.js +63 -1
- package/dist/pipeline/__tests__/orchestrator.test.js.map +1 -1
- package/dist/pipeline/__tests__/stages.test.js +410 -4
- package/dist/pipeline/__tests__/stages.test.js.map +1 -1
- package/dist/pipeline/orchestrator.d.ts.map +1 -1
- package/dist/pipeline/orchestrator.js +29 -2
- package/dist/pipeline/orchestrator.js.map +1 -1
- package/dist/pipeline/stages/ralplan.d.ts.map +1 -1
- package/dist/pipeline/stages/ralplan.js +41 -6
- package/dist/pipeline/stages/ralplan.js.map +1 -1
- package/dist/question/__tests__/ui.test.js +43 -10
- package/dist/question/__tests__/ui.test.js.map +1 -1
- package/dist/question/ui.d.ts +12 -0
- package/dist/question/ui.d.ts.map +1 -1
- package/dist/question/ui.js +83 -46
- package/dist/question/ui.js.map +1 -1
- package/dist/ralplan/__tests__/runtime.test.js +200 -10
- package/dist/ralplan/__tests__/runtime.test.js.map +1 -1
- package/dist/ralplan/consensus-gate.d.ts +23 -0
- package/dist/ralplan/consensus-gate.d.ts.map +1 -0
- package/dist/ralplan/consensus-gate.js +212 -0
- package/dist/ralplan/consensus-gate.js.map +1 -0
- package/dist/ralplan/runtime.d.ts +25 -0
- package/dist/ralplan/runtime.d.ts.map +1 -1
- package/dist/ralplan/runtime.js +144 -8
- package/dist/ralplan/runtime.js.map +1 -1
- package/dist/scripts/__tests__/codex-native-hook.test.js +626 -7
- package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
- package/dist/scripts/__tests__/docs-site-contract.test.d.ts +2 -0
- package/dist/scripts/__tests__/docs-site-contract.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/docs-site-contract.test.js +42 -0
- package/dist/scripts/__tests__/docs-site-contract.test.js.map +1 -0
- package/dist/scripts/__tests__/notify-dispatcher.test.js +115 -2
- package/dist/scripts/__tests__/notify-dispatcher.test.js.map +1 -1
- package/dist/scripts/__tests__/run-test-files.test.js +57 -0
- package/dist/scripts/__tests__/run-test-files.test.js.map +1 -1
- package/dist/scripts/__tests__/verify-native-agents.test.js +2 -2
- package/dist/scripts/__tests__/verify-native-agents.test.js.map +1 -1
- package/dist/scripts/codex-native-hook.d.ts.map +1 -1
- package/dist/scripts/codex-native-hook.js +214 -34
- package/dist/scripts/codex-native-hook.js.map +1 -1
- package/dist/scripts/notify-dispatcher.js +188 -4
- package/dist/scripts/notify-dispatcher.js.map +1 -1
- package/dist/scripts/run-test-files.js +13 -0
- package/dist/scripts/run-test-files.js.map +1 -1
- package/dist/state/__tests__/workflow-transition.test.js +6 -0
- package/dist/state/__tests__/workflow-transition.test.js.map +1 -1
- package/dist/state/workflow-transition.d.ts +1 -1
- package/dist/state/workflow-transition.d.ts.map +1 -1
- package/dist/state/workflow-transition.js +7 -0
- package/dist/state/workflow-transition.js.map +1 -1
- package/dist/subagents/tracker.d.ts.map +1 -1
- package/dist/subagents/tracker.js +4 -3
- package/dist/subagents/tracker.js.map +1 -1
- package/dist/team/__tests__/runtime.test.js +36 -44
- package/dist/team/__tests__/runtime.test.js.map +1 -1
- package/dist/team/__tests__/tmux-session.test.js +58 -18
- package/dist/team/__tests__/tmux-session.test.js.map +1 -1
- package/dist/team/runtime.d.ts.map +1 -1
- package/dist/team/runtime.js +10 -20
- package/dist/team/runtime.js.map +1 -1
- package/dist/team/tmux-session.d.ts.map +1 -1
- package/dist/team/tmux-session.js +15 -6
- package/dist/team/tmux-session.js.map +1 -1
- package/dist/ultragoal/__tests__/artifacts.test.js +50 -0
- package/dist/ultragoal/__tests__/artifacts.test.js.map +1 -1
- package/dist/ultragoal/artifacts.d.ts.map +1 -1
- package/dist/ultragoal/artifacts.js +28 -2
- package/dist/ultragoal/artifacts.js.map +1 -1
- package/package.json +1 -1
- package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/oh-my-codex/skills/autopilot/SKILL.md +16 -4
- package/plugins/oh-my-codex/skills/autoresearch/SKILL.md +4 -0
- package/plugins/oh-my-codex/skills/autoresearch-goal/SKILL.md +1 -1
- package/plugins/oh-my-codex/skills/best-practice-research/SKILL.md +1 -1
- package/plugins/oh-my-codex/skills/pipeline/SKILL.md +1 -1
- package/plugins/oh-my-codex/skills/plan/SKILL.md +1 -1
- package/plugins/oh-my-codex/skills/prometheus-strict/README.md +35 -0
- package/plugins/oh-my-codex/skills/prometheus-strict/SKILL.md +219 -0
- package/plugins/oh-my-codex/skills/ralplan/SKILL.md +18 -3
- package/prompts/prometheus-strict-metis.md +274 -0
- package/prompts/prometheus-strict-momus.md +82 -0
- package/prompts/prometheus-strict-oracle.md +107 -0
- package/prompts/researcher.md +22 -3
- package/skills/autopilot/SKILL.md +16 -4
- package/skills/autoresearch/SKILL.md +4 -0
- package/skills/autoresearch-goal/SKILL.md +1 -1
- package/skills/best-practice-research/SKILL.md +1 -1
- package/skills/pipeline/SKILL.md +1 -1
- package/skills/plan/SKILL.md +1 -1
- package/skills/prometheus-strict/README.md +35 -0
- package/skills/prometheus-strict/SKILL.md +219 -0
- package/skills/ralplan/SKILL.md +18 -3
- package/src/scripts/__tests__/codex-native-hook.test.ts +769 -8
- package/src/scripts/__tests__/docs-site-contract.test.ts +47 -0
- package/src/scripts/__tests__/notify-dispatcher.test.ts +132 -3
- package/src/scripts/__tests__/run-test-files.test.ts +67 -0
- package/src/scripts/__tests__/verify-native-agents.test.ts +2 -2
- package/src/scripts/codex-native-hook.ts +237 -30
- package/src/scripts/notify-dispatcher.ts +202 -4
- package/src/scripts/run-test-files.ts +13 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
2148
|
-
|
|
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
|
|
2159
|
-
if (
|
|
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
|
|
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 =
|
|
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
|
|
204
|
-
if (!
|
|
205
|
-
|
|
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',
|