oh-my-codex 0.14.0 → 0.14.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 (185) hide show
  1. package/Cargo.lock +5 -5
  2. package/Cargo.toml +1 -1
  3. package/README.md +14 -8
  4. package/crates/omx-explore/src/main.rs +94 -1
  5. package/crates/omx-sparkshell/src/codex_bridge.rs +59 -12
  6. package/crates/omx-sparkshell/tests/execution.rs +48 -0
  7. package/dist/cli/__tests__/explore.test.js +33 -1
  8. package/dist/cli/__tests__/explore.test.js.map +1 -1
  9. package/dist/cli/__tests__/index.test.js +11 -2
  10. package/dist/cli/__tests__/index.test.js.map +1 -1
  11. package/dist/cli/__tests__/package-bin-contract.test.js +5 -0
  12. package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
  13. package/dist/cli/__tests__/question.test.js +139 -25
  14. package/dist/cli/__tests__/question.test.js.map +1 -1
  15. package/dist/cli/__tests__/session-scoped-runtime.test.js +30 -0
  16. package/dist/cli/__tests__/session-scoped-runtime.test.js.map +1 -1
  17. package/dist/cli/__tests__/setup-agents-overwrite.test.js +32 -7
  18. package/dist/cli/__tests__/setup-agents-overwrite.test.js.map +1 -1
  19. package/dist/cli/__tests__/setup-refresh.test.js +8 -6
  20. package/dist/cli/__tests__/setup-refresh.test.js.map +1 -1
  21. package/dist/cli/__tests__/sparkshell-cli.test.js +23 -0
  22. package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
  23. package/dist/cli/__tests__/uninstall.test.js +65 -5
  24. package/dist/cli/__tests__/uninstall.test.js.map +1 -1
  25. package/dist/cli/__tests__/update.test.js +360 -26
  26. package/dist/cli/__tests__/update.test.js.map +1 -1
  27. package/dist/cli/explore.d.ts.map +1 -1
  28. package/dist/cli/explore.js +18 -3
  29. package/dist/cli/explore.js.map +1 -1
  30. package/dist/cli/index.d.ts +2 -1
  31. package/dist/cli/index.d.ts.map +1 -1
  32. package/dist/cli/index.js +7 -2
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/setup.d.ts.map +1 -1
  35. package/dist/cli/setup.js +25 -3
  36. package/dist/cli/setup.js.map +1 -1
  37. package/dist/cli/sparkshell.d.ts.map +1 -1
  38. package/dist/cli/sparkshell.js +11 -1
  39. package/dist/cli/sparkshell.js.map +1 -1
  40. package/dist/cli/team.d.ts.map +1 -1
  41. package/dist/cli/team.js +159 -394
  42. package/dist/cli/team.js.map +1 -1
  43. package/dist/cli/uninstall.d.ts.map +1 -1
  44. package/dist/cli/uninstall.js +3 -1
  45. package/dist/cli/uninstall.js.map +1 -1
  46. package/dist/cli/update.d.ts +37 -9
  47. package/dist/cli/update.d.ts.map +1 -1
  48. package/dist/cli/update.js +204 -26
  49. package/dist/cli/update.js.map +1 -1
  50. package/dist/config/__tests__/generator-idempotent.test.js +51 -14
  51. package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
  52. package/dist/config/__tests__/generator-notify.test.js +35 -10
  53. package/dist/config/__tests__/generator-notify.test.js.map +1 -1
  54. package/dist/config/generator.d.ts +1 -0
  55. package/dist/config/generator.d.ts.map +1 -1
  56. package/dist/config/generator.js +61 -7
  57. package/dist/config/generator.js.map +1 -1
  58. package/dist/hooks/__tests__/code-review-skill-contract.test.d.ts +2 -0
  59. package/dist/hooks/__tests__/code-review-skill-contract.test.d.ts.map +1 -0
  60. package/dist/hooks/__tests__/code-review-skill-contract.test.js +56 -0
  61. package/dist/hooks/__tests__/code-review-skill-contract.test.js.map +1 -0
  62. package/dist/hooks/__tests__/deep-interview-contract.test.js +31 -0
  63. package/dist/hooks/__tests__/deep-interview-contract.test.js.map +1 -1
  64. package/dist/hooks/__tests__/explicit-terminal-stop-docs-contract.test.d.ts +2 -0
  65. package/dist/hooks/__tests__/explicit-terminal-stop-docs-contract.test.d.ts.map +1 -0
  66. package/dist/hooks/__tests__/explicit-terminal-stop-docs-contract.test.js +43 -0
  67. package/dist/hooks/__tests__/explicit-terminal-stop-docs-contract.test.js.map +1 -0
  68. package/dist/hooks/__tests__/explicit-terminal-stop-model-docs-contract.test.d.ts +2 -0
  69. package/dist/hooks/__tests__/explicit-terminal-stop-model-docs-contract.test.d.ts.map +1 -0
  70. package/dist/hooks/__tests__/explicit-terminal-stop-model-docs-contract.test.js +38 -0
  71. package/dist/hooks/__tests__/explicit-terminal-stop-model-docs-contract.test.js.map +1 -0
  72. package/dist/hooks/__tests__/keyword-detector.test.js +108 -0
  73. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  74. package/dist/hooks/__tests__/prompt-guidance-test-helpers.d.ts.map +1 -1
  75. package/dist/hooks/__tests__/prompt-guidance-test-helpers.js +16 -1
  76. package/dist/hooks/__tests__/prompt-guidance-test-helpers.js.map +1 -1
  77. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  78. package/dist/hooks/keyword-detector.js +34 -8
  79. package/dist/hooks/keyword-detector.js.map +1 -1
  80. package/dist/mcp/__tests__/bootstrap.test.js +7 -25
  81. package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
  82. package/dist/mcp/__tests__/server-lifecycle.test.js +60 -0
  83. package/dist/mcp/__tests__/server-lifecycle.test.js.map +1 -1
  84. package/dist/mcp/__tests__/state-server.test.js +177 -0
  85. package/dist/mcp/__tests__/state-server.test.js.map +1 -1
  86. package/dist/mcp/bootstrap.d.ts.map +1 -1
  87. package/dist/mcp/bootstrap.js +36 -18
  88. package/dist/mcp/bootstrap.js.map +1 -1
  89. package/dist/mcp/state-server.d.ts +17 -0
  90. package/dist/mcp/state-server.d.ts.map +1 -1
  91. package/dist/mcp/state-server.js +55 -1
  92. package/dist/mcp/state-server.js.map +1 -1
  93. package/dist/notifications/__tests__/index.test.js +0 -3
  94. package/dist/notifications/__tests__/index.test.js.map +1 -1
  95. package/dist/notifications/__tests__/session-status.test.js +90 -0
  96. package/dist/notifications/__tests__/session-status.test.js.map +1 -1
  97. package/dist/notifications/session-status.d.ts +2 -0
  98. package/dist/notifications/session-status.d.ts.map +1 -1
  99. package/dist/notifications/session-status.js +19 -4
  100. package/dist/notifications/session-status.js.map +1 -1
  101. package/dist/question/__tests__/deep-interview.test.js +44 -0
  102. package/dist/question/__tests__/deep-interview.test.js.map +1 -1
  103. package/dist/question/__tests__/renderer.test.js +192 -12
  104. package/dist/question/__tests__/renderer.test.js.map +1 -1
  105. package/dist/question/__tests__/state.test.js +21 -1
  106. package/dist/question/__tests__/state.test.js.map +1 -1
  107. package/dist/question/deep-interview.d.ts +3 -0
  108. package/dist/question/deep-interview.d.ts.map +1 -1
  109. package/dist/question/deep-interview.js +18 -1
  110. package/dist/question/deep-interview.js.map +1 -1
  111. package/dist/question/renderer.d.ts +4 -2
  112. package/dist/question/renderer.d.ts.map +1 -1
  113. package/dist/question/renderer.js +87 -18
  114. package/dist/question/renderer.js.map +1 -1
  115. package/dist/runtime/__tests__/run-outcome.test.js +38 -0
  116. package/dist/runtime/__tests__/run-outcome.test.js.map +1 -1
  117. package/dist/runtime/__tests__/run-state.test.d.ts +2 -0
  118. package/dist/runtime/__tests__/run-state.test.d.ts.map +1 -0
  119. package/dist/runtime/__tests__/run-state.test.js +37 -0
  120. package/dist/runtime/__tests__/run-state.test.js.map +1 -0
  121. package/dist/runtime/run-loop.d.ts +5 -1
  122. package/dist/runtime/run-loop.d.ts.map +1 -1
  123. package/dist/runtime/run-loop.js +8 -3
  124. package/dist/runtime/run-loop.js.map +1 -1
  125. package/dist/runtime/run-outcome.d.ts +18 -0
  126. package/dist/runtime/run-outcome.d.ts.map +1 -1
  127. package/dist/runtime/run-outcome.js +156 -7
  128. package/dist/runtime/run-outcome.js.map +1 -1
  129. package/dist/runtime/run-state.d.ts +5 -1
  130. package/dist/runtime/run-state.d.ts.map +1 -1
  131. package/dist/runtime/run-state.js +13 -3
  132. package/dist/runtime/run-state.js.map +1 -1
  133. package/dist/runtime/terminal-lifecycle.d.ts +11 -0
  134. package/dist/runtime/terminal-lifecycle.d.ts.map +1 -0
  135. package/dist/runtime/terminal-lifecycle.js +52 -0
  136. package/dist/runtime/terminal-lifecycle.js.map +1 -0
  137. package/dist/scripts/__tests__/codex-native-hook.test.js +370 -56
  138. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  139. package/dist/scripts/__tests__/postinstall.test.d.ts +2 -0
  140. package/dist/scripts/__tests__/postinstall.test.d.ts.map +1 -0
  141. package/dist/scripts/__tests__/postinstall.test.js +178 -0
  142. package/dist/scripts/__tests__/postinstall.test.js.map +1 -0
  143. package/dist/scripts/codex-native-hook.d.ts +1 -0
  144. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  145. package/dist/scripts/codex-native-hook.js +115 -56
  146. package/dist/scripts/codex-native-hook.js.map +1 -1
  147. package/dist/scripts/postinstall.d.ts +22 -0
  148. package/dist/scripts/postinstall.d.ts.map +1 -0
  149. package/dist/scripts/postinstall.js +105 -0
  150. package/dist/scripts/postinstall.js.map +1 -0
  151. package/dist/state/__tests__/operations.test.js +60 -0
  152. package/dist/state/__tests__/operations.test.js.map +1 -1
  153. package/dist/state/operations.d.ts.map +1 -1
  154. package/dist/state/operations.js +18 -1
  155. package/dist/state/operations.js.map +1 -1
  156. package/dist/team/__tests__/role-router.test.js +6 -0
  157. package/dist/team/__tests__/role-router.test.js.map +1 -1
  158. package/dist/team/__tests__/runtime.test.js +108 -2
  159. package/dist/team/__tests__/runtime.test.js.map +1 -1
  160. package/dist/team/runtime.d.ts.map +1 -1
  161. package/dist/team/runtime.js +18 -4
  162. package/dist/team/runtime.js.map +1 -1
  163. package/dist/utils/__tests__/dep-versions.test.js +25 -8
  164. package/dist/utils/__tests__/dep-versions.test.js.map +1 -1
  165. package/dist/utils/__tests__/paths.test.js +45 -0
  166. package/dist/utils/__tests__/paths.test.js.map +1 -1
  167. package/dist/utils/paths.d.ts +2 -0
  168. package/dist/utils/paths.d.ts.map +1 -1
  169. package/dist/utils/paths.js +22 -7
  170. package/dist/utils/paths.js.map +1 -1
  171. package/dist/verification/__tests__/ci-rust-gates.test.js +1 -1
  172. package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
  173. package/package.json +3 -2
  174. package/prompts/architect.md +4 -0
  175. package/prompts/code-reviewer.md +3 -0
  176. package/skills/code-review/SKILL.md +94 -28
  177. package/skills/deep-interview/SKILL.md +91 -0
  178. package/src/scripts/__tests__/codex-native-hook.test.ts +468 -64
  179. package/src/scripts/__tests__/postinstall.test.ts +210 -0
  180. package/src/scripts/codex-native-hook.ts +136 -53
  181. package/src/scripts/postinstall-bootstrap.js +23 -0
  182. package/src/scripts/postinstall.ts +161 -0
  183. package/templates/AGENTS.md +1 -1
  184. package/templates/model-instructions/explore-lightweight-AGENTS.md +11 -0
  185. package/templates/model-instructions/sparkshell-lightweight-AGENTS.md +10 -0
@@ -0,0 +1,210 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { describe, it } from "node:test";
6
+ import { writeUserInstallStamp } from "../../cli/update.js";
7
+ import {
8
+ isGlobalInstallLifecycle,
9
+ runPostinstall,
10
+ } from "../postinstall.js";
11
+
12
+ describe("isGlobalInstallLifecycle", () => {
13
+ it("accepts npm_config_global=true", () => {
14
+ assert.equal(isGlobalInstallLifecycle({ npm_config_global: "true" }), true);
15
+ });
16
+
17
+ it("accepts npm_config_location=global", () => {
18
+ assert.equal(isGlobalInstallLifecycle({ npm_config_location: "global" }), true);
19
+ });
20
+
21
+ it("rejects local installs", () => {
22
+ assert.equal(isGlobalInstallLifecycle({ npm_config_global: "false" }), false);
23
+ });
24
+ });
25
+
26
+ describe("runPostinstall", () => {
27
+ it("runs interactive setup only for bumped global installs", async () => {
28
+ const root = await mkdtemp(join(tmpdir(), "omx-postinstall-"));
29
+ const stampPath = join(root, ".codex", ".omx", "install-state.json");
30
+ const logs: string[] = [];
31
+ let setupCalls = 0;
32
+
33
+ try {
34
+ const result = await runPostinstall({
35
+ env: { npm_config_global: "true" },
36
+ getCurrentVersion: async () => "0.14.1",
37
+ isInteractive: () => true,
38
+ log: (message) => logs.push(message),
39
+ readStamp: async () => ({
40
+ installed_version: "0.14.0",
41
+ setup_completed_version: "0.14.0",
42
+ updated_at: "2026-04-20T00:00:00.000Z",
43
+ }),
44
+ runSetup: async () => {
45
+ setupCalls += 1;
46
+ },
47
+ writeStamp: async (stamp) => writeUserInstallStamp(stamp, stampPath),
48
+ });
49
+
50
+ assert.equal(result.status, "setup-ran");
51
+ assert.equal(setupCalls, 1);
52
+ assert.match(logs.join("\n"), /Launching interactive setup/);
53
+
54
+ const stamp = JSON.parse(await readFile(stampPath, "utf-8")) as {
55
+ installed_version: string;
56
+ setup_completed_version: string;
57
+ };
58
+ assert.equal(stamp.installed_version, "0.14.1");
59
+ assert.equal(stamp.setup_completed_version, "0.14.1");
60
+ } finally {
61
+ await rm(root, { recursive: true, force: true });
62
+ }
63
+ });
64
+
65
+ it("records the installed version and prints a hint when no TTY is available", async () => {
66
+ const root = await mkdtemp(join(tmpdir(), "omx-postinstall-"));
67
+ const stampPath = join(root, ".codex", ".omx", "install-state.json");
68
+ const logs: string[] = [];
69
+ let setupCalls = 0;
70
+
71
+ try {
72
+ const result = await runPostinstall({
73
+ env: { npm_config_global: "true" },
74
+ getCurrentVersion: async () => "0.14.1",
75
+ isInteractive: () => false,
76
+ log: (message) => logs.push(message),
77
+ readStamp: async () => ({
78
+ installed_version: "0.14.0",
79
+ setup_completed_version: "0.14.0",
80
+ updated_at: "2026-04-20T00:00:00.000Z",
81
+ }),
82
+ runSetup: async () => {
83
+ setupCalls += 1;
84
+ },
85
+ writeStamp: async (stamp) => writeUserInstallStamp(stamp, stampPath),
86
+ });
87
+
88
+ assert.equal(result.status, "hinted");
89
+ assert.equal(setupCalls, 0);
90
+ assert.match(logs.join("\n"), /Run `omx setup` \(interactive\) or `omx update`/);
91
+
92
+ const stamp = JSON.parse(await readFile(stampPath, "utf-8")) as {
93
+ installed_version: string;
94
+ setup_completed_version: string;
95
+ };
96
+ assert.equal(stamp.installed_version, "0.14.1");
97
+ assert.equal(stamp.setup_completed_version, "0.14.0");
98
+ } finally {
99
+ await rm(root, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ it("skips local installs", async () => {
104
+ let setupCalls = 0;
105
+ const result = await runPostinstall({
106
+ env: { npm_config_global: "false" },
107
+ getCurrentVersion: async () => "0.14.1",
108
+ isInteractive: () => true,
109
+ runSetup: async () => {
110
+ setupCalls += 1;
111
+ },
112
+ });
113
+
114
+ assert.equal(result.status, "noop-local");
115
+ assert.equal(setupCalls, 0);
116
+ });
117
+
118
+ it("does not rerun setup when the installed version matches the saved stamp", async () => {
119
+ let setupCalls = 0;
120
+ const result = await runPostinstall({
121
+ env: { npm_config_global: "true" },
122
+ getCurrentVersion: async () => "0.14.1",
123
+ isInteractive: () => true,
124
+ readStamp: async () => ({
125
+ installed_version: "0.14.1",
126
+ setup_completed_version: "0.14.1",
127
+ updated_at: "2026-04-20T00:00:00.000Z",
128
+ }),
129
+ runSetup: async () => {
130
+ setupCalls += 1;
131
+ },
132
+ });
133
+
134
+ assert.equal(result.status, "noop-same-version");
135
+ assert.equal(setupCalls, 0);
136
+ });
137
+
138
+ it("warns and exits cleanly when setup fails", async () => {
139
+ const warnings: string[] = [];
140
+ const result = await runPostinstall({
141
+ env: { npm_config_global: "true" },
142
+ getCurrentVersion: async () => "0.14.1",
143
+ isInteractive: () => true,
144
+ readStamp: async () => ({
145
+ installed_version: "0.14.0",
146
+ setup_completed_version: "0.14.0",
147
+ updated_at: "2026-04-20T00:00:00.000Z",
148
+ }),
149
+ runSetup: async () => {
150
+ throw new Error("boom");
151
+ },
152
+ warn: (message) => warnings.push(message),
153
+ writeStamp: async () => {},
154
+ });
155
+
156
+ assert.equal(result.status, "setup-failed");
157
+ assert.match(warnings.join("\n"), /non-fatal error: boom/);
158
+ });
159
+
160
+ it("runs interactive setup from the npm install prefix instead of the package dir or INIT_CWD", async () => {
161
+ const installRoot = await mkdtemp(join(tmpdir(), "omx-postinstall-install-root-"));
162
+ const packageRoot = await mkdtemp(join(tmpdir(), "omx-postinstall-package-root-"));
163
+ const initCwd = await mkdtemp(join(tmpdir(), "omx-postinstall-init-cwd-"));
164
+ const originalCwd = process.cwd();
165
+ const scopeFile = join(installRoot, ".omx", "setup-scope.json");
166
+ const packageScopeFile = join(packageRoot, ".omx", "setup-scope.json");
167
+ const initCwdScopeFile = join(initCwd, ".omx", "setup-scope.json");
168
+
169
+ try {
170
+ process.chdir(packageRoot);
171
+
172
+ const result = await runPostinstall({
173
+ env: {
174
+ npm_config_global: "true",
175
+ npm_config_prefix: installRoot,
176
+ INIT_CWD: initCwd,
177
+ },
178
+ getCurrentVersion: async () => "0.14.1",
179
+ isInteractive: () => true,
180
+ readStamp: async () => ({
181
+ installed_version: "0.14.0",
182
+ setup_completed_version: "0.14.0",
183
+ updated_at: "2026-04-20T00:00:00.000Z",
184
+ }),
185
+ runSetup: async () => {
186
+ await mkdir(join(process.cwd(), ".omx"), { recursive: true });
187
+ await writeFile(
188
+ join(process.cwd(), ".omx", "setup-scope.json"),
189
+ JSON.stringify({ scope: "project" }),
190
+ );
191
+ },
192
+ writeStamp: async () => {},
193
+ });
194
+
195
+ assert.equal(result.status, "setup-ran");
196
+ assert.equal(process.cwd(), packageRoot);
197
+ assert.equal(
198
+ JSON.parse(await readFile(scopeFile, "utf-8")).scope,
199
+ "project",
200
+ );
201
+ await assert.rejects(() => readFile(packageScopeFile, "utf-8"));
202
+ await assert.rejects(() => readFile(initCwdScopeFile, "utf-8"));
203
+ } finally {
204
+ process.chdir(originalCwd);
205
+ await rm(installRoot, { recursive: true, force: true });
206
+ await rm(packageRoot, { recursive: true, force: true });
207
+ await rm(initCwd, { recursive: true, force: true });
208
+ }
209
+ });
210
+ });
@@ -2,6 +2,7 @@ import { execFileSync } from "child_process";
2
2
  import { existsSync, readFileSync } from "fs";
3
3
  import { mkdir, readFile, readdir, writeFile } from "fs/promises";
4
4
  import { join, resolve } from "path";
5
+ import { pathToFileURL } from "url";
5
6
  import { readModeState, readModeStateForSession, updateModeState } from "../modes/base.js";
6
7
  import {
7
8
  listActiveSkills,
@@ -9,7 +10,12 @@ import {
9
10
  } from "../state/skill-active.js";
10
11
  import { readSubagentSessionSummary } from "../subagents/tracker.js";
11
12
  import { resolveCanonicalTeamStateRoot } from "../team/state-root.js";
12
- import { readUsableSessionState, reconcileNativeSessionStart } from "../hooks/session.js";
13
+ import {
14
+ isSessionStateUsable,
15
+ readSessionState,
16
+ readUsableSessionState,
17
+ reconcileNativeSessionStart,
18
+ } from "../hooks/session.js";
13
19
  import {
14
20
  appendTeamEvent,
15
21
  readTeamLeaderAttention,
@@ -43,6 +49,7 @@ import {
43
49
  import type { HookEventEnvelope } from "../hooks/extensibility/types.js";
44
50
  import { dispatchHookEvent } from "../hooks/extensibility/dispatcher.js";
45
51
  import { reconcileHudForPromptSubmit } from "../hud/reconcile.js";
52
+ import { shellEscapeSingle } from "../hud/tmux.js";
46
53
  import { onSessionStart as buildWikiSessionStartContext } from "../wiki/lifecycle.js";
47
54
  import { readAutoresearchCompletionStatus, readAutoresearchModeState } from "../autoresearch/skill-validation.js";
48
55
  import { shouldContinueRun } from "../runtime/run-loop.js";
@@ -56,6 +63,7 @@ import {
56
63
  type TriageStateFile,
57
64
  } from "../hooks/triage-state.js";
58
65
  import { isPendingDeepInterviewQuestionEnforcement } from "../question/deep-interview.js";
66
+ import { resolveOmxCliEntryPath } from "../utils/paths.js";
59
67
 
60
68
  type CodexHookEventName =
61
69
  | "SessionStart"
@@ -203,15 +211,26 @@ function readPromptText(payload: CodexHookPayload): string {
203
211
  function sanitizePayloadForHookContext(
204
212
  payload: CodexHookPayload,
205
213
  hookEventName: CodexHookEventName,
214
+ canonicalSessionId = "",
206
215
  ): CodexHookPayload {
207
- if (hookEventName !== "UserPromptSubmit") return payload;
208
-
209
216
  const sanitized = { ...payload };
210
- delete sanitized.prompt;
211
- delete sanitized.input;
212
- delete sanitized.user_prompt;
213
- delete sanitized.userPrompt;
214
- delete sanitized.text;
217
+
218
+ if (hookEventName === "UserPromptSubmit") {
219
+ delete sanitized.prompt;
220
+ delete sanitized.input;
221
+ delete sanitized.user_prompt;
222
+ delete sanitized.userPrompt;
223
+ delete sanitized.text;
224
+ return sanitized;
225
+ }
226
+
227
+ if (hookEventName === "Stop") {
228
+ delete sanitized.stop_hook_active;
229
+ delete sanitized.stopHookActive;
230
+ delete sanitized.sessionId;
231
+ sanitized.session_id = canonicalSessionId.trim() || safeString(payload.session_id ?? payload.sessionId).trim();
232
+ }
233
+
215
234
  return sanitized;
216
235
  }
217
236
 
@@ -219,13 +238,14 @@ function buildBaseContext(
219
238
  cwd: string,
220
239
  payload: CodexHookPayload,
221
240
  hookEventName: CodexHookEventName,
241
+ canonicalSessionId = "",
222
242
  ): Record<string, unknown> {
223
243
  return {
224
244
  cwd,
225
245
  project_path: cwd,
226
246
  transcript_path: safeString(payload.transcript_path ?? payload.transcriptPath) || null,
227
247
  source: safeString(payload.source),
228
- payload: sanitizePayloadForHookContext(payload, hookEventName),
248
+ payload: sanitizePayloadForHookContext(payload, hookEventName, canonicalSessionId),
229
249
  };
230
250
  }
231
251
 
@@ -264,17 +284,28 @@ async function readActiveRalphState(
264
284
  stateDir: string,
265
285
  preferredSessionId?: string,
266
286
  ): Promise<Record<string, unknown> | null> {
267
- const sessionInfo = await readUsableSessionState(resolve(stateDir, "..", ".."));
268
- const currentOmxSessionId = safeString(sessionInfo?.session_id).trim();
287
+ const cwd = resolve(stateDir, "..", "..");
288
+ const [rawSessionInfo, usableSessionInfo] = await Promise.all([
289
+ readSessionState(cwd),
290
+ readUsableSessionState(cwd),
291
+ ]);
292
+ const currentOmxSessionId = safeString(usableSessionInfo?.session_id).trim();
293
+ const staleCurrentSessionId = rawSessionInfo && !isSessionStateUsable(rawSessionInfo, cwd)
294
+ ? safeString(rawSessionInfo.session_id).trim()
295
+ : "";
269
296
  const sessionCandidates = [...new Set([
270
297
  safeString(preferredSessionId).trim(),
271
298
  currentOmxSessionId,
272
299
  ].filter(Boolean))];
273
300
 
301
+ // Ralph Stop stays authoritative-scope-only once the Stop payload is session-bound.
302
+ // That is intentionally stricter than generic state MCP reads: do not scan sibling
303
+ // session scopes or fall back to root when a current/explicit session is in play.
274
304
  for (const sessionId of sessionCandidates) {
275
- const sessionScoped = await readJsonIfExists(
276
- join(stateDir, "sessions", sessionId, "ralph-state.json"),
277
- );
305
+ if (staleCurrentSessionId && sessionId === staleCurrentSessionId) {
306
+ continue;
307
+ }
308
+ const sessionScoped = await readStopSessionPinnedState("ralph-state.json", cwd, sessionId);
278
309
  if (sessionScoped?.active === true && shouldContinueRun(sessionScoped)) {
279
310
  return sessionScoped;
280
311
  }
@@ -287,17 +318,6 @@ async function readActiveRalphState(
287
318
  return direct;
288
319
  }
289
320
 
290
- const sessionsRoot = join(stateDir, "sessions");
291
- if (!existsSync(sessionsRoot)) return null;
292
- const entries = await readdir(sessionsRoot, { withFileTypes: true }).catch(() => []);
293
- for (const entry of entries) {
294
- if (!entry.isDirectory()) continue;
295
- const candidate = await readJsonIfExists(join(sessionsRoot, entry.name, "ralph-state.json"));
296
- if (candidate?.active === true && shouldContinueRun(candidate)) {
297
- return candidate;
298
- }
299
- }
300
-
301
321
  return null;
302
322
  }
303
323
 
@@ -399,32 +419,55 @@ function resolveSessionOwnerPid(payload: CodexHookPayload): number {
399
419
  return process.pid;
400
420
  }
401
421
 
402
- async function ensureOmxGitignoreEntry(cwd: string): Promise<{ changed: boolean; gitignorePath?: string }> {
403
- let repoRoot = "";
422
+ function tryReadGitValue(cwd: string, args: string[]): string | null {
404
423
  try {
405
- repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
424
+ const value = execFileSync("git", args, {
406
425
  cwd,
407
426
  encoding: "utf-8",
408
427
  stdio: ["ignore", "pipe", "ignore"],
409
428
  windowsHide: true,
410
429
  }).trim();
430
+ return value || null;
411
431
  } catch {
412
- return { changed: false };
432
+ return null;
413
433
  }
434
+ }
435
+
436
+ function isPathIgnoredByGit(cwd: string, path: string): boolean {
437
+ try {
438
+ execFileSync("git", ["check-ignore", "-q", path], {
439
+ cwd,
440
+ stdio: ["ignore", "ignore", "ignore"],
441
+ windowsHide: true,
442
+ });
443
+ return true;
444
+ } catch {
445
+ return false;
446
+ }
447
+ }
448
+
449
+ async function ensureOmxLocalIgnoreEntry(cwd: string): Promise<{ changed: boolean; excludePath?: string }> {
450
+ const repoRoot = tryReadGitValue(cwd, ["rev-parse", "--show-toplevel"]);
414
451
  if (!repoRoot) return { changed: false };
452
+ if (isPathIgnoredByGit(repoRoot, ".omx/")) {
453
+ return { changed: false };
454
+ }
415
455
 
416
- const gitignorePath = join(repoRoot, ".gitignore");
417
- const existing = existsSync(gitignorePath)
418
- ? await readFile(gitignorePath, "utf-8")
456
+ const excludePathValue = tryReadGitValue(repoRoot, ["rev-parse", "--git-path", "info/exclude"]);
457
+ if (!excludePathValue) return { changed: false };
458
+ const excludePath = resolve(repoRoot, excludePathValue);
459
+
460
+ const existing = existsSync(excludePath)
461
+ ? await readFile(excludePath, "utf-8")
419
462
  : "";
420
463
  const lines = existing.split(/\r?\n/).map((line) => line.trim());
421
464
  if (lines.includes(".omx/")) {
422
- return { changed: false, gitignorePath };
465
+ return { changed: false, excludePath };
423
466
  }
424
467
 
425
468
  const next = `${existing}${existing.endsWith("\n") || existing.length === 0 ? "" : "\n"}.omx/\n`;
426
- await writeFile(gitignorePath, next);
427
- return { changed: true, gitignorePath };
469
+ await writeFile(excludePath, next);
470
+ return { changed: true, excludePath };
428
471
  }
429
472
 
430
473
  async function buildSessionStartContext(
@@ -433,9 +476,9 @@ async function buildSessionStartContext(
433
476
  ): Promise<string | null> {
434
477
  const sections: string[] = [];
435
478
 
436
- const gitignoreResult = await ensureOmxGitignoreEntry(cwd);
437
- if (gitignoreResult.changed) {
438
- sections.push(`Added .omx/ to ${gitignoreResult.gitignorePath} to keep local OMX state out of source control.`);
479
+ const localIgnoreResult = await ensureOmxLocalIgnoreEntry(cwd);
480
+ if (localIgnoreResult.changed) {
481
+ sections.push(`Added .omx/ to ${localIgnoreResult.excludePath} to keep local OMX state out of source control without mutating tracked repo ignores.`);
439
482
  }
440
483
 
441
484
  const modeSummaries: string[] = [];
@@ -523,7 +566,17 @@ async function buildSessionStartContext(
523
566
  return sections.length > 0 ? sections.join("\n\n") : null;
524
567
  }
525
568
 
526
- function buildAdditionalContextMessage(prompt: string, skillState?: SkillActiveState | null): string | null {
569
+ function buildDeepInterviewQuestionBridgeInstruction(cwd: string): string {
570
+ const omxBin = resolveOmxCliEntryPath({ cwd }) || process.argv[1] || "omx";
571
+ const bridgeCommand = `${shellEscapeSingle(process.execPath)} ${shellEscapeSingle(omxBin)} question`;
572
+ return `Deep-interview must ask each interview round via \`omx question\`; do not fall back to \`request_user_input\` or plain-text questioning. After starting \`omx question\` in a background terminal, wait for that terminal to finish and read the JSON answer before continuing the interview. If bare \`omx question\` is unavailable in this reused session, use the current-session CLI bridge command: \`${bridgeCommand}\`. Stop remains blocked while a deep-interview question obligation is pending.`;
573
+ }
574
+
575
+ function buildAdditionalContextMessage(
576
+ prompt: string,
577
+ skillState?: SkillActiveState | null,
578
+ cwd: string = process.cwd(),
579
+ ): string | null {
527
580
  if (!prompt) return null;
528
581
  const promptPriorityMessage = buildPromptPriorityMessage(prompt);
529
582
  const matches = detectKeywords(prompt);
@@ -543,7 +596,7 @@ function buildAdditionalContextMessage(prompt: string, skillState?: SkillActiveS
543
596
  ? "Prompt-side `$ralph` activation seeds Ralph workflow state only; it does not invoke `omx ralph`. Use `omx ralph --prd ...` only when you explicitly want the PRD-gated CLI startup path."
544
597
  : null;
545
598
  const deepInterviewPromptActivationNote = skillState?.initialized_mode === "deep-interview"
546
- ? "Deep-interview must ask each interview round via `omx question`; do not fall back to `request_user_input` or plain-text questioning. Stop remains blocked while a deep-interview question obligation is pending."
599
+ ? buildDeepInterviewQuestionBridgeInstruction(cwd)
547
600
  : null;
548
601
  const combinedTransitionMessage = (() => {
549
602
  if (!skillState?.transition_message) return null;
@@ -997,7 +1050,11 @@ async function buildDeepInterviewQuestionStopOutput(
997
1050
  threadId: string,
998
1051
  ): Promise<{ output: Record<string, unknown>; obligationId: string } | null> {
999
1052
  const modeState = await readStopSessionPinnedState("deep-interview-state.json", cwd, sessionId);
1000
- if (!modeState || modeState.active !== true) return null;
1053
+ if (!modeState) return null;
1054
+
1055
+ const questionEnforcement = safeObject(modeState.question_enforcement);
1056
+ const hasPendingQuestionObligation = isPendingDeepInterviewQuestionEnforcement(questionEnforcement);
1057
+ if (modeState.active !== true && !hasPendingQuestionObligation) return null;
1001
1058
 
1002
1059
  const phase = formatPhase(modeState.current_phase, "planning");
1003
1060
  if (TERMINAL_MODE_PHASES.has(phase.toLowerCase()) || phase === "completing") {
@@ -1013,8 +1070,7 @@ async function buildDeepInterviewQuestionStopOutput(
1013
1070
  if (!blocker) return null;
1014
1071
  }
1015
1072
 
1016
- const questionEnforcement = safeObject(modeState.question_enforcement);
1017
- if (!isPendingDeepInterviewQuestionEnforcement(questionEnforcement)) {
1073
+ if (!hasPendingQuestionObligation) {
1018
1074
  return null;
1019
1075
  }
1020
1076
 
@@ -1148,6 +1204,7 @@ async function returnPersistentStopBlock(
1148
1204
  signatureValue: string,
1149
1205
  output: Record<string, unknown> | null,
1150
1206
  canonicalSessionId?: string,
1207
+ options: { allowRepeatDuringStopHook?: boolean } = { allowRepeatDuringStopHook: true },
1151
1208
  ): Promise<Record<string, unknown> | null> {
1152
1209
  return await maybeReturnRepeatableStopOutput(
1153
1210
  payload,
@@ -1155,7 +1212,7 @@ async function returnPersistentStopBlock(
1155
1212
  buildRepeatableStopSignature(payload, signatureKind, signatureValue, canonicalSessionId),
1156
1213
  output,
1157
1214
  canonicalSessionId,
1158
- { allowRepeatDuringStopHook: true },
1215
+ options,
1159
1216
  );
1160
1217
  }
1161
1218
 
@@ -1438,6 +1495,7 @@ async function buildStopHookOutput(
1438
1495
  safeString(ultraworkOutput.stopReason),
1439
1496
  ultraworkOutput,
1440
1497
  canonicalSessionId,
1498
+ { allowRepeatDuringStopHook: false },
1441
1499
  );
1442
1500
  }
1443
1501
 
@@ -1587,19 +1645,37 @@ export async function dispatchCodexNativeHook(
1587
1645
  const nativeSessionId = safeString(payload.session_id ?? payload.sessionId).trim();
1588
1646
  const threadId = safeString(payload.thread_id ?? payload.threadId).trim();
1589
1647
  const turnId = safeString(payload.turn_id ?? payload.turnId).trim();
1590
- let canonicalSessionId = safeString((await readUsableSessionState(cwd))?.session_id).trim();
1648
+ const currentSessionState = await readUsableSessionState(cwd);
1649
+ let canonicalSessionId = safeString(currentSessionState?.session_id).trim();
1650
+ let resolvedNativeSessionId = nativeSessionId;
1591
1651
 
1592
1652
  if (hookEventName === "SessionStart" && nativeSessionId) {
1593
1653
  const sessionState = await reconcileNativeSessionStart(cwd, nativeSessionId, {
1594
1654
  pid: options.sessionOwnerPid ?? resolveSessionOwnerPid(payload),
1595
1655
  });
1596
1656
  canonicalSessionId = safeString(sessionState.session_id).trim();
1657
+ resolvedNativeSessionId = safeString(sessionState.native_session_id).trim() || nativeSessionId;
1597
1658
  } else if (!canonicalSessionId) {
1598
- canonicalSessionId = safeString((await readUsableSessionState(cwd))?.session_id).trim();
1659
+ canonicalSessionId = safeString(currentSessionState?.session_id).trim();
1660
+ }
1661
+
1662
+ if (hookEventName === "Stop") {
1663
+ const stopCanonicalSessionId = await resolveInternalSessionIdForPayload(
1664
+ cwd,
1665
+ readPayloadSessionId(payload),
1666
+ );
1667
+ if (stopCanonicalSessionId) {
1668
+ canonicalSessionId = stopCanonicalSessionId;
1669
+ }
1670
+ if (canonicalSessionId && safeString(currentSessionState?.session_id).trim() === canonicalSessionId) {
1671
+ resolvedNativeSessionId =
1672
+ safeString(currentSessionState?.native_session_id).trim() || resolvedNativeSessionId;
1673
+ }
1599
1674
  }
1600
1675
 
1601
1676
  const eventSessionId = canonicalSessionId || nativeSessionId || undefined;
1602
1677
  const sessionIdForState = canonicalSessionId || nativeSessionId;
1678
+ let outputJson: Record<string, unknown> | null = null;
1603
1679
 
1604
1680
  if (hookEventName === "UserPromptSubmit") {
1605
1681
  const prompt = readPromptText(payload);
@@ -1684,10 +1760,10 @@ export async function dispatchCodexNativeHook(
1684
1760
  }
1685
1761
 
1686
1762
  if (omxEventName) {
1687
- const baseContext = buildBaseContext(cwd, payload, hookEventName!);
1688
- if (nativeSessionId) {
1689
- baseContext.native_session_id = nativeSessionId;
1690
- baseContext.codex_session_id = nativeSessionId;
1763
+ const baseContext = buildBaseContext(cwd, payload, hookEventName!, canonicalSessionId);
1764
+ if (resolvedNativeSessionId) {
1765
+ baseContext.native_session_id = resolvedNativeSessionId;
1766
+ baseContext.codex_session_id = resolvedNativeSessionId;
1691
1767
  }
1692
1768
  if (canonicalSessionId) {
1693
1769
  baseContext.omx_session_id = canonicalSessionId;
@@ -1705,11 +1781,10 @@ export async function dispatchCodexNativeHook(
1705
1781
  await dispatchHookEvent(event, { cwd });
1706
1782
  }
1707
1783
 
1708
- let outputJson: Record<string, unknown> | null = null;
1709
1784
  if (hookEventName === "SessionStart" || hookEventName === "UserPromptSubmit") {
1710
1785
  const additionalContext = hookEventName === "SessionStart"
1711
1786
  ? await buildSessionStartContext(cwd, canonicalSessionId || nativeSessionId)
1712
- : (buildAdditionalContextMessage(readPromptText(payload), skillState) ?? triageAdditionalContext);
1787
+ : (buildAdditionalContextMessage(readPromptText(payload), skillState, cwd) ?? triageAdditionalContext);
1713
1788
  if (additionalContext) {
1714
1789
  outputJson = {
1715
1790
  hookSpecificOutput: {
@@ -1742,6 +1817,14 @@ interface NativeHookCliReadResult {
1742
1817
  parseError: Error | null;
1743
1818
  }
1744
1819
 
1820
+ export function isCodexNativeHookMainModule(
1821
+ moduleUrl: string,
1822
+ argv1: string | undefined,
1823
+ ): boolean {
1824
+ if (!argv1) return false;
1825
+ return moduleUrl === pathToFileURL(argv1).href;
1826
+ }
1827
+
1745
1828
  async function readStdinJson(): Promise<NativeHookCliReadResult> {
1746
1829
  const chunks: Buffer[] = [];
1747
1830
  for await (const chunk of process.stdin) {
@@ -1786,7 +1869,7 @@ export async function runCodexNativeHookCli(): Promise<void> {
1786
1869
  }
1787
1870
  }
1788
1871
 
1789
- if (import.meta.url === `file://${process.argv[1]}`) {
1872
+ if (isCodexNativeHookMainModule(import.meta.url, process.argv[1])) {
1790
1873
  runCodexNativeHookCli().catch((error) => {
1791
1874
  process.stderr.write(
1792
1875
  `[omx] codex-native-hook failed: ${
@@ -0,0 +1,23 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+ const distScriptPath = join(__dirname, "..", "..", "dist", "scripts", "postinstall.js");
8
+
9
+ if (!existsSync(distScriptPath)) {
10
+ process.exit(0);
11
+ }
12
+
13
+ const moduleUrl = pathToFileURL(distScriptPath).href;
14
+ try {
15
+ const postinstallModule = await import(moduleUrl);
16
+ if (typeof postinstallModule.main === "function") {
17
+ await postinstallModule.main();
18
+ }
19
+ } catch (error) {
20
+ console.warn(
21
+ `[omx] Postinstall bootstrap skipped after a non-fatal error: ${error instanceof Error ? error.message : String(error)}`,
22
+ );
23
+ }