oh-my-codex 0.16.2 → 0.16.3

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 (149) hide show
  1. package/Cargo.lock +5 -5
  2. package/Cargo.toml +1 -1
  3. package/dist/cli/__tests__/doctor-warning-copy.test.js +36 -0
  4. package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
  5. package/dist/cli/__tests__/index.test.js +173 -3
  6. package/dist/cli/__tests__/index.test.js.map +1 -1
  7. package/dist/cli/__tests__/launch-fallback.test.js +58 -0
  8. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  9. package/dist/cli/__tests__/ralph.test.js +1 -0
  10. package/dist/cli/__tests__/ralph.test.js.map +1 -1
  11. package/dist/cli/__tests__/setup-hooks-shared-ownership.test.js +8 -0
  12. package/dist/cli/__tests__/setup-hooks-shared-ownership.test.js.map +1 -1
  13. package/dist/cli/__tests__/setup-install-mode.test.js +79 -2
  14. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  15. package/dist/cli/__tests__/team.test.js +161 -0
  16. package/dist/cli/__tests__/team.test.js.map +1 -1
  17. package/dist/cli/__tests__/uninstall.test.js +39 -1
  18. package/dist/cli/__tests__/uninstall.test.js.map +1 -1
  19. package/dist/cli/__tests__/update.test.js +109 -19
  20. package/dist/cli/__tests__/update.test.js.map +1 -1
  21. package/dist/cli/doctor.d.ts.map +1 -1
  22. package/dist/cli/doctor.js +17 -0
  23. package/dist/cli/doctor.js.map +1 -1
  24. package/dist/cli/index.d.ts.map +1 -1
  25. package/dist/cli/index.js +34 -4
  26. package/dist/cli/index.js.map +1 -1
  27. package/dist/cli/setup.d.ts.map +1 -1
  28. package/dist/cli/setup.js +68 -4
  29. package/dist/cli/setup.js.map +1 -1
  30. package/dist/cli/team.d.ts.map +1 -1
  31. package/dist/cli/team.js +54 -15
  32. package/dist/cli/team.js.map +1 -1
  33. package/dist/cli/uninstall.d.ts.map +1 -1
  34. package/dist/cli/uninstall.js +67 -5
  35. package/dist/cli/uninstall.js.map +1 -1
  36. package/dist/cli/update.d.ts +10 -2
  37. package/dist/cli/update.d.ts.map +1 -1
  38. package/dist/cli/update.js +99 -5
  39. package/dist/cli/update.js.map +1 -1
  40. package/dist/config/__tests__/codex-hooks.test.js +181 -4
  41. package/dist/config/__tests__/codex-hooks.test.js.map +1 -1
  42. package/dist/config/__tests__/generator-idempotent.test.js +59 -1
  43. package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
  44. package/dist/config/__tests__/generator-notify.test.js +39 -3
  45. package/dist/config/__tests__/generator-notify.test.js.map +1 -1
  46. package/dist/config/codex-hooks.d.ts +38 -4
  47. package/dist/config/codex-hooks.d.ts.map +1 -1
  48. package/dist/config/codex-hooks.js +181 -17
  49. package/dist/config/codex-hooks.js.map +1 -1
  50. package/dist/config/generator.d.ts +9 -0
  51. package/dist/config/generator.d.ts.map +1 -1
  52. package/dist/config/generator.js +176 -38
  53. package/dist/config/generator.js.map +1 -1
  54. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +29 -1
  55. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
  56. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js +10 -0
  57. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js.map +1 -1
  58. package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js +1 -0
  59. package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js.map +1 -1
  60. package/dist/hooks/__tests__/notify-hook-non-omx-guard.test.d.ts +2 -0
  61. package/dist/hooks/__tests__/notify-hook-non-omx-guard.test.d.ts.map +1 -0
  62. package/dist/hooks/__tests__/notify-hook-non-omx-guard.test.js +52 -0
  63. package/dist/hooks/__tests__/notify-hook-non-omx-guard.test.js.map +1 -0
  64. package/dist/hooks/__tests__/notify-hook-ralph-resume.test.js +148 -0
  65. package/dist/hooks/__tests__/notify-hook-ralph-resume.test.js.map +1 -1
  66. package/dist/hooks/__tests__/notify-hook-session-scope.test.js +3 -0
  67. package/dist/hooks/__tests__/notify-hook-session-scope.test.js.map +1 -1
  68. package/dist/hooks/__tests__/wiki-docs-contract.test.js +1 -2
  69. package/dist/hooks/__tests__/wiki-docs-contract.test.js.map +1 -1
  70. package/dist/planning/__tests__/artifacts.test.js +64 -0
  71. package/dist/planning/__tests__/artifacts.test.js.map +1 -1
  72. package/dist/planning/__tests__/ready-context-pack-role-refs.test.d.ts +2 -0
  73. package/dist/planning/__tests__/ready-context-pack-role-refs.test.d.ts.map +1 -0
  74. package/dist/planning/__tests__/ready-context-pack-role-refs.test.js +90 -0
  75. package/dist/planning/__tests__/ready-context-pack-role-refs.test.js.map +1 -0
  76. package/dist/planning/artifacts.d.ts +7 -2
  77. package/dist/planning/artifacts.d.ts.map +1 -1
  78. package/dist/planning/artifacts.js +62 -8
  79. package/dist/planning/artifacts.js.map +1 -1
  80. package/dist/planning/context-pack-status.d.ts +6 -0
  81. package/dist/planning/context-pack-status.d.ts.map +1 -1
  82. package/dist/planning/context-pack-status.js +25 -0
  83. package/dist/planning/context-pack-status.js.map +1 -1
  84. package/dist/ralph/persistence.d.ts +1 -1
  85. package/dist/ralph/persistence.d.ts.map +1 -1
  86. package/dist/ralph/persistence.js +8 -2
  87. package/dist/ralph/persistence.js.map +1 -1
  88. package/dist/scripts/__tests__/codex-native-hook.test.js +139 -11
  89. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  90. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  91. package/dist/scripts/codex-native-hook.js +9 -22
  92. package/dist/scripts/codex-native-hook.js.map +1 -1
  93. package/dist/scripts/notify-dispatcher.d.ts +7 -0
  94. package/dist/scripts/notify-dispatcher.d.ts.map +1 -0
  95. package/dist/scripts/notify-dispatcher.js +58 -0
  96. package/dist/scripts/notify-dispatcher.js.map +1 -0
  97. package/dist/scripts/notify-fallback-watcher.js +4 -0
  98. package/dist/scripts/notify-fallback-watcher.js.map +1 -1
  99. package/dist/scripts/notify-hook/ralph-session-resume.d.ts.map +1 -1
  100. package/dist/scripts/notify-hook/ralph-session-resume.js +96 -8
  101. package/dist/scripts/notify-hook/ralph-session-resume.js.map +1 -1
  102. package/dist/scripts/notify-hook/state-io.d.ts.map +1 -1
  103. package/dist/scripts/notify-hook/state-io.js +6 -2
  104. package/dist/scripts/notify-hook/state-io.js.map +1 -1
  105. package/dist/scripts/notify-hook/visual-verdict.js +3 -3
  106. package/dist/scripts/notify-hook/visual-verdict.js.map +1 -1
  107. package/dist/scripts/notify-hook.js +124 -0
  108. package/dist/scripts/notify-hook.js.map +1 -1
  109. package/dist/team/__tests__/approved-execution.test.js +45 -1
  110. package/dist/team/__tests__/approved-execution.test.js.map +1 -1
  111. package/dist/team/__tests__/runtime.test.js +173 -19
  112. package/dist/team/__tests__/runtime.test.js.map +1 -1
  113. package/dist/team/__tests__/worker-bootstrap.test.js +37 -0
  114. package/dist/team/__tests__/worker-bootstrap.test.js.map +1 -1
  115. package/dist/team/approved-execution.d.ts +1 -0
  116. package/dist/team/approved-execution.d.ts.map +1 -1
  117. package/dist/team/approved-execution.js +50 -0
  118. package/dist/team/approved-execution.js.map +1 -1
  119. package/dist/team/delivery-log.d.ts.map +1 -1
  120. package/dist/team/delivery-log.js +8 -1
  121. package/dist/team/delivery-log.js.map +1 -1
  122. package/dist/team/runtime.d.ts.map +1 -1
  123. package/dist/team/runtime.js +104 -18
  124. package/dist/team/runtime.js.map +1 -1
  125. package/dist/team/state/mailbox.d.ts +1 -0
  126. package/dist/team/state/mailbox.d.ts.map +1 -1
  127. package/dist/team/state/mailbox.js +10 -1
  128. package/dist/team/state/mailbox.js.map +1 -1
  129. package/dist/team/state-root.js +1 -1
  130. package/dist/team/state-root.js.map +1 -1
  131. package/dist/team/state.d.ts.map +1 -1
  132. package/dist/team/state.js +2 -2
  133. package/dist/team/state.js.map +1 -1
  134. package/dist/team/worker-bootstrap.d.ts +7 -2
  135. package/dist/team/worker-bootstrap.d.ts.map +1 -1
  136. package/dist/team/worker-bootstrap.js +17 -4
  137. package/dist/team/worker-bootstrap.js.map +1 -1
  138. package/package.json +1 -1
  139. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
  140. package/plugins/oh-my-codex/skills/omx-setup/SKILL.md +1 -1
  141. package/skills/omx-setup/SKILL.md +1 -1
  142. package/src/scripts/__tests__/codex-native-hook.test.ts +158 -11
  143. package/src/scripts/codex-native-hook.ts +8 -21
  144. package/src/scripts/notify-dispatcher.ts +74 -0
  145. package/src/scripts/notify-fallback-watcher.ts +6 -2
  146. package/src/scripts/notify-hook/ralph-session-resume.ts +117 -8
  147. package/src/scripts/notify-hook/state-io.ts +4 -2
  148. package/src/scripts/notify-hook/visual-verdict.ts +3 -3
  149. package/src/scripts/notify-hook.ts +116 -0
@@ -2,12 +2,13 @@ import { existsSync } from 'fs';
2
2
  import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'fs/promises';
3
3
  import { dirname, join, resolve } from 'path';
4
4
  import { captureTmuxPaneFromEnv } from '../../state/mode-state-context.js';
5
- import { readUsableSessionState } from '../../hooks/session.js';
5
+ import { isSessionStateUsable } from '../../hooks/session.js';
6
6
  import { resolveCodexPane } from '../tmux-hook-engine.js';
7
7
  import { safeString } from './utils.js';
8
8
 
9
9
  const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
10
- const RALPH_TERMINAL_PHASES = new Set(['blocked_on_user', 'complete', 'failed', 'cancelled']);
10
+ const RALPH_TERMINAL_PHASES = new Set(['blocked_on_user', 'complete', 'failed', 'cancelled', 'interrupted']);
11
+ const DEFAULT_RALPH_ACTIVE_STATE_STALE_MS = 24 * 60 * 60 * 1000;
11
12
  const RALPH_RESUME_LOCK_STALE_MS = 10_000;
12
13
  const RALPH_RESUME_LOCK_TIMEOUT_MS = 5_000;
13
14
  const RALPH_RESUME_LOCK_RETRY_MS = 25;
@@ -40,6 +41,14 @@ interface RalphStateCandidate {
40
41
  state: Record<string, unknown>;
41
42
  }
42
43
 
44
+ interface RalphStateFreshness {
45
+ stale: boolean;
46
+ ageMs: number;
47
+ checkedAtMs: number;
48
+ staleThresholdMs: number;
49
+ timestampSource: string;
50
+ }
51
+
43
52
  function lockOwnerToken(): string {
44
53
  return `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
45
54
  }
@@ -128,6 +137,80 @@ function isActiveRalphCandidate(state: Record<string, unknown> | null): state is
128
137
  return state.active === true && !isTerminalRalphPhase(state.current_phase);
129
138
  }
130
139
 
140
+ function parsePositiveInteger(value: unknown): number | null {
141
+ const parsed = Number.parseInt(safeString(value).trim(), 10);
142
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
143
+ }
144
+
145
+ function resolveActiveStateStaleThresholdMs(env: NodeJS.ProcessEnv = process.env): number {
146
+ return parsePositiveInteger(env.OMX_RALPH_ACTIVE_STATE_STALE_MS)
147
+ ?? parsePositiveInteger(env.OMX_RALPH_RESUME_STALE_MS)
148
+ ?? DEFAULT_RALPH_ACTIVE_STATE_STALE_MS;
149
+ }
150
+
151
+ function parseTimestampMs(value: unknown): number | null {
152
+ const raw = safeString(value).trim();
153
+ if (!raw) return null;
154
+ const parsed = Date.parse(raw);
155
+ return Number.isFinite(parsed) ? parsed : null;
156
+ }
157
+
158
+ function stateActivityTimestampMs(state: Record<string, unknown>): { ms: number; source: string } | null {
159
+ let newest: { ms: number; source: string } | null = null;
160
+ for (const key of ['updated_at', 'last_turn_at', 'tmux_pane_set_at']) {
161
+ const ms = parseTimestampMs(state[key]);
162
+ if (ms !== null && (!newest || ms > newest.ms)) {
163
+ newest = { ms, source: key };
164
+ }
165
+ }
166
+ return newest;
167
+ }
168
+
169
+ async function readRalphStateFreshness(
170
+ path: string,
171
+ state: Record<string, unknown>,
172
+ env: NodeJS.ProcessEnv = process.env,
173
+ ): Promise<RalphStateFreshness> {
174
+ const checkedAtMs = Date.now();
175
+ const threshold = resolveActiveStateStaleThresholdMs(env);
176
+ let timestamp = stateActivityTimestampMs(state);
177
+ if (!timestamp) {
178
+ try {
179
+ const info = await stat(path);
180
+ timestamp = { ms: info.mtimeMs, source: 'mtime' };
181
+ } catch {
182
+ timestamp = { ms: checkedAtMs, source: 'missing_mtime' };
183
+ }
184
+ }
185
+ const ageMs = Math.max(0, checkedAtMs - timestamp.ms);
186
+ return {
187
+ stale: ageMs > threshold,
188
+ ageMs,
189
+ checkedAtMs,
190
+ staleThresholdMs: threshold,
191
+ timestampSource: timestamp.source,
192
+ };
193
+ }
194
+
195
+ async function markRalphStateAbandoned(
196
+ path: string,
197
+ state: Record<string, unknown>,
198
+ freshness: RalphStateFreshness,
199
+ ): Promise<void> {
200
+ const nowIso = new Date(freshness.checkedAtMs).toISOString();
201
+ await writeJsonAtomic(path, {
202
+ ...state,
203
+ active: false,
204
+ current_phase: 'cancelled',
205
+ completed_at: nowIso,
206
+ abandoned_at: nowIso,
207
+ stop_reason: 'stale_active_state',
208
+ stale_resume_age_ms: freshness.ageMs,
209
+ stale_resume_threshold_ms: freshness.staleThresholdMs,
210
+ stale_resume_timestamp_source: freshness.timestampSource,
211
+ });
212
+ }
213
+
131
214
  function readSessionIdFromEnvironment(env: NodeJS.ProcessEnv = process.env): string {
132
215
  const candidates = [env.OMX_SESSION_ID, env.CODEX_SESSION_ID, env.SESSION_ID];
133
216
  for (const candidate of candidates) {
@@ -144,7 +227,10 @@ async function readCurrentOmxSessionId(stateDir: string, env: NodeJS.ProcessEnv
144
227
  if (existsSync(envScopedDir)) return envSessionId;
145
228
  }
146
229
 
147
- const session = await readUsableSessionState(resolve(stateDir, '..', '..'));
230
+ const cwd = resolve(stateDir, '..', '..');
231
+ const session = await readJson(join(stateDir, 'session.json'));
232
+ if (!session || typeof session !== 'object') return '';
233
+ if (!isSessionStateUsable(session as any, cwd)) return '';
148
234
  const sessionId = safeString(session?.session_id).trim();
149
235
  return SESSION_ID_PATTERN.test(sessionId) ? sessionId : '';
150
236
  }
@@ -171,12 +257,14 @@ async function scanMatchingRalphCandidates(
171
257
  currentOmxSessionId: string,
172
258
  payloadSessionId: string,
173
259
  payloadThreadId: string,
174
- ): Promise<RalphStateCandidate[]> {
260
+ env: NodeJS.ProcessEnv = process.env,
261
+ ): Promise<{ candidates: RalphStateCandidate[]; abandonedCount: number }> {
175
262
  const sessionsRoot = join(stateDir, 'sessions');
176
- if (!existsSync(sessionsRoot)) return [];
263
+ if (!existsSync(sessionsRoot)) return { candidates: [], abandonedCount: 0 };
177
264
 
178
265
  const entries = await readdir(sessionsRoot, { withFileTypes: true }).catch(() => []);
179
266
  const matches: RalphStateCandidate[] = [];
267
+ let abandonedCount = 0;
180
268
  for (const entry of entries) {
181
269
  if (!entry.isDirectory() || !SESSION_ID_PATTERN.test(entry.name) || entry.name === currentOmxSessionId) continue;
182
270
  const path = join(sessionsRoot, entry.name, 'ralph-state.json');
@@ -190,13 +278,19 @@ async function scanMatchingRalphCandidates(
190
278
  } else if (!payloadThreadId || !ownerThreadId || ownerThreadId !== payloadThreadId) {
191
279
  continue;
192
280
  }
281
+ const freshness = await readRalphStateFreshness(path, state, env);
282
+ if (freshness.stale) {
283
+ await markRalphStateAbandoned(path, state, freshness);
284
+ abandonedCount += 1;
285
+ continue;
286
+ }
193
287
  matches.push({
194
288
  sessionId: entry.name,
195
289
  path,
196
290
  state,
197
291
  });
198
292
  }
199
- return matches;
293
+ return { candidates: matches, abandonedCount };
200
294
  }
201
295
 
202
296
  export async function reconcileRalphSessionResume({
@@ -228,6 +322,18 @@ export async function reconcileRalphSessionResume({
228
322
  const nowIso = new Date().toISOString();
229
323
 
230
324
  if (currentRalphState && currentRalphState.active === true) {
325
+ const freshness = await readRalphStateFreshness(currentRalphPath, currentRalphState, env);
326
+ if (freshness.stale) {
327
+ await markRalphStateAbandoned(currentRalphPath, currentRalphState, freshness);
328
+ return {
329
+ currentOmxSessionId,
330
+ resumed: false,
331
+ updatedCurrentOwner: false,
332
+ reason: 'current_ralph_abandoned_stale',
333
+ targetPath: currentRalphPath,
334
+ };
335
+ }
336
+
231
337
  let changed = false;
232
338
  const updated: Record<string, unknown> = { ...currentRalphState };
233
339
  const normalizedPayloadThreadId = safeString(payloadThreadId).trim();
@@ -293,18 +399,21 @@ export async function reconcileRalphSessionResume({
293
399
  };
294
400
  }
295
401
 
296
- const candidates = await scanMatchingRalphCandidates(
402
+ const { candidates, abandonedCount } = await scanMatchingRalphCandidates(
297
403
  stateDir,
298
404
  currentOmxSessionId,
299
405
  normalizedPayloadSessionId,
300
406
  normalizedPayloadThreadId,
407
+ env,
301
408
  );
302
409
  if (candidates.length !== 1) {
303
410
  return {
304
411
  currentOmxSessionId,
305
412
  resumed: false,
306
413
  updatedCurrentOwner: false,
307
- reason: candidates.length === 0 ? 'no_matching_prior_ralph' : 'multiple_matching_prior_ralphs',
414
+ reason: candidates.length === 0
415
+ ? (abandonedCount > 0 ? 'matching_prior_ralph_abandoned_stale' : 'no_matching_prior_ralph')
416
+ : 'multiple_matching_prior_ralphs',
308
417
  };
309
418
  }
310
419
 
@@ -5,7 +5,7 @@
5
5
  import { mkdir, readFile, readdir, writeFile } from 'fs/promises';
6
6
  import { dirname, join, resolve } from 'path';
7
7
  import { existsSync } from 'fs';
8
- import { readUsableSessionState } from '../../hooks/session.js';
8
+ import { isSessionStateUsable } from '../../hooks/session.js';
9
9
  import { asNumber, safeString } from './utils.js';
10
10
 
11
11
  const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
@@ -43,7 +43,9 @@ export async function readCurrentSessionId(baseStateDir: string): Promise<string
43
43
  }
44
44
 
45
45
  const cwd = resolve(baseStateDir, '..', '..');
46
- const session = await readUsableSessionState(cwd);
46
+ const session = await readJsonIfExists(join(baseStateDir, 'session.json'), null);
47
+ if (!session || typeof session !== 'object') return undefined;
48
+ if (!isSessionStateUsable(session, cwd)) return undefined;
47
49
  const sessionId = safeString(session?.session_id);
48
50
  return SESSION_ID_PATTERN.test(sessionId) ? sessionId : undefined;
49
51
  }
@@ -38,7 +38,7 @@ function extractJsonCandidates(rawMessage: any): string[] {
38
38
  return candidates;
39
39
  }
40
40
 
41
- async function maybePersistRuntimeVisualFeedback({ cwd, output, sessionId }: any): Promise<void> {
41
+ async function maybePersistRuntimeVisualFeedback({ cwd, output, sessionId, stateDir }: any): Promise<void> {
42
42
  if (!cwd || !output) return;
43
43
 
44
44
  const candidates = extractJsonCandidates(output);
@@ -51,7 +51,7 @@ async function maybePersistRuntimeVisualFeedback({ cwd, output, sessionId }: any
51
51
  try {
52
52
  const parsed = JSON.parse(candidate);
53
53
  const feedback = buildVisualLoopFeedback(parsed);
54
- await recordRalphVisualFeedback(cwd, feedback, sessionId || undefined);
54
+ await recordRalphVisualFeedback(cwd, feedback, sessionId || undefined, stateDir || undefined);
55
55
  return;
56
56
  } catch {
57
57
  // Try next candidate
@@ -93,7 +93,7 @@ export async function maybePersistVisualVerdict({ cwd, payload, stateDir, logsDi
93
93
  // Runtime visual feedback (JSON/fenced JSON) for ralph-progress persistence.
94
94
  // Non-fatal and observable via warn-level structured logging.
95
95
  try {
96
- await maybePersistRuntimeVisualFeedback({ cwd, output, sessionId });
96
+ await maybePersistRuntimeVisualFeedback({ cwd, output, sessionId, stateDir });
97
97
  } catch (err: any) {
98
98
  await logNotifyHookEvent(logsDir, {
99
99
  timestamp: new Date().toISOString(),
@@ -21,6 +21,7 @@
21
21
  import { writeFile, appendFile, mkdir, readFile } from 'fs/promises';
22
22
  import { existsSync } from 'fs';
23
23
  import { dirname, join, resolve } from 'path';
24
+ import { isSessionStateUsable } from '../hooks/session.js';
24
25
 
25
26
  import { safeString, asNumber } from './notify-hook/utils.js';
26
27
  import {
@@ -65,6 +66,7 @@ import {
65
66
  maybeNotifyLeaderWorkerIdle,
66
67
  } from './notify-hook/team-worker.js';
67
68
  import { DEFAULT_MARKER } from './tmux-hook-engine.js';
69
+ import { sameFilePath } from '../utils/paths.js';
68
70
 
69
71
  const RALPH_ACTIVE_PROGRESS_PHASES = new Set([
70
72
  'start',
@@ -82,6 +84,117 @@ const RALPH_ACTIVE_PROGRESS_PHASES = new Set([
82
84
 
83
85
  const IDLE_NOTIFICATION_SUMMARY_MAX_LENGTH = 240;
84
86
 
87
+ async function readJsonFileIfObject(path: string): Promise<Record<string, unknown> | null> {
88
+ try {
89
+ const raw = await readFile(path, 'utf-8');
90
+ const parsed = JSON.parse(raw);
91
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
92
+ ? parsed as Record<string, unknown>
93
+ : null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ function hasOmxRuntimeStateMarker(value: Record<string, unknown> | null): boolean {
100
+ if (!value) return false;
101
+ return typeof value.active === 'boolean'
102
+ || typeof value.team_name === 'string'
103
+ || typeof value.current_phase === 'string'
104
+ || typeof value.lifecycle_outcome === 'string'
105
+ || typeof value.run_outcome === 'string';
106
+ }
107
+
108
+ async function hasManagedTeamStateTree(cwd: string): Promise<boolean> {
109
+ const teamStateRoot = join(cwd, '.omx', 'state', 'team');
110
+ if (!existsSync(teamStateRoot)) return false;
111
+ let entries: string[] = [];
112
+ try {
113
+ entries = await readdir(teamStateRoot);
114
+ } catch {
115
+ return false;
116
+ }
117
+ for (const entry of entries) {
118
+ if (entry.startsWith('.')) continue;
119
+ const teamDir = join(teamStateRoot, entry);
120
+ if (existsSync(join(teamDir, 'manifest.v2.json')) || existsSync(join(teamDir, 'config.json'))) {
121
+ return true;
122
+ }
123
+ }
124
+ return false;
125
+ }
126
+
127
+ async function isOmxManagedCwd(cwd: string): Promise<boolean> {
128
+ const trustedInternalCwd = safeString(process.env.OMX_NOTIFY_HOOK_TRUSTED_MANAGED_CWD || '').trim();
129
+ if (trustedInternalCwd && sameFilePath(trustedInternalCwd, cwd)) return true;
130
+ if (existsSync(join(cwd, '.omx', 'setup-scope.json'))) return true;
131
+ if (existsSync(join(cwd, '.omx', 'managed'))) return true;
132
+ const sessionStatePath = join(cwd, '.omx', 'state', 'session.json');
133
+ if (existsSync(sessionStatePath)) {
134
+ try {
135
+ const sessionState = JSON.parse(await readFile(sessionStatePath, 'utf-8'));
136
+ if (isSessionStateUsable(sessionState, cwd)) return true;
137
+ } catch {
138
+ // Continue checking other managed markers.
139
+ }
140
+ }
141
+ const teamState = await readJsonFileIfObject(join(cwd, '.omx', 'state', 'team-state.json'));
142
+ if (hasOmxRuntimeStateMarker(teamState)) return true;
143
+ const hudState = await readJsonFileIfObject(join(cwd, '.omx', 'state', 'hud-state.json'));
144
+ if (hudState && (typeof hudState.last_turn_at === 'string' || typeof hudState.turn_count === 'number')) return true;
145
+ if (await hasManagedTeamStateTree(cwd)) return true;
146
+ const teamWorkerEnv = safeString(process.env.OMX_TEAM_INTERNAL_WORKER || process.env.OMX_TEAM_WORKER || '').trim();
147
+ if (teamWorkerEnv) {
148
+ const [teamName = '', workerName = ''] = teamWorkerEnv.split('/');
149
+ if (teamName && workerName) {
150
+ const candidateStateRoots = [
151
+ safeString(process.env.OMX_TEAM_STATE_ROOT || '').trim(),
152
+ safeString(process.env.OMX_TEAM_LEADER_CWD || '').trim()
153
+ ? join(resolve(cwd, safeString(process.env.OMX_TEAM_LEADER_CWD || '').trim()), '.omx', 'state')
154
+ : '',
155
+ join(cwd, '.omx', 'state'),
156
+ ].filter((value, index, values) => value && values.indexOf(value) === index);
157
+ for (const candidateStateRoot of candidateStateRoots) {
158
+ const identityPath = join(candidateStateRoot, 'team', teamName, 'workers', workerName, 'identity.json');
159
+ if (!existsSync(identityPath)) continue;
160
+ try {
161
+ const raw = await readFile(identityPath, 'utf-8');
162
+ const identity = JSON.parse(raw);
163
+ const worktreePath = safeString(identity?.worktree_path || '').trim();
164
+ const stateRoot = safeString(identity?.team_state_root || '').trim();
165
+ if (
166
+ (!worktreePath || sameFilePath(worktreePath, cwd))
167
+ && (!stateRoot || sameFilePath(stateRoot, candidateStateRoot))
168
+ ) {
169
+ return true;
170
+ }
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+ // A worker notify hook with an explicit runtime root hint is OMX-scoped
176
+ // even when the hint fails validation. Let the main worker path log the
177
+ // unresolved-root warning and fail closed without inventing local state.
178
+ if (
179
+ safeString(process.env.OMX_TEAM_STATE_ROOT || '').trim()
180
+ || safeString(process.env.OMX_TEAM_LEADER_CWD || '').trim()
181
+ ) {
182
+ return true;
183
+ }
184
+ }
185
+ }
186
+ const hooksPath = join(cwd, '.codex', 'hooks.json');
187
+ if (existsSync(hooksPath)) {
188
+ try {
189
+ const raw = await readFile(hooksPath, 'utf-8');
190
+ return /(?:^|[\\/])codex-native-hook\.js(?:["'\s]|$)/.test(raw);
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+ return false;
196
+ }
197
+
85
198
  function summarizeIdleNotificationMessage(message: unknown): string {
86
199
  const source = safeString(message)
87
200
  .split('\n')
@@ -172,6 +285,9 @@ async function main() {
172
285
  }
173
286
 
174
287
  const cwd = payload.cwd || payload['cwd'] || process.cwd();
288
+ if (!(await isOmxManagedCwd(cwd))) {
289
+ process.exit(0);
290
+ }
175
291
  const payloadSessionId = safeString(payload.session_id || payload['session-id'] || '');
176
292
  const payloadThreadId = safeString(payload['thread-id'] || payload.thread_id || '');
177
293
  const inputMessages = normalizeInputMessages(payload);