oh-my-codex 0.11.12 → 0.11.13

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 (248) hide show
  1. package/Cargo.lock +5 -5
  2. package/Cargo.toml +1 -1
  3. package/README.md +23 -0
  4. package/README.vi.md +144 -185
  5. package/crates/omx-runtime-core/src/engine.rs +122 -4
  6. package/crates/omx-runtime-core/src/lib.rs +17 -0
  7. package/dist/cli/__tests__/autoresearch.test.js +11 -0
  8. package/dist/cli/__tests__/autoresearch.test.js.map +1 -1
  9. package/dist/cli/__tests__/cleanup.test.js +117 -4
  10. package/dist/cli/__tests__/cleanup.test.js.map +1 -1
  11. package/dist/cli/__tests__/error-handling-warnings.test.js +13 -0
  12. package/dist/cli/__tests__/error-handling-warnings.test.js.map +1 -1
  13. package/dist/cli/__tests__/exec.test.js +6 -0
  14. package/dist/cli/__tests__/exec.test.js.map +1 -1
  15. package/dist/cli/__tests__/index.test.js +94 -1
  16. package/dist/cli/__tests__/index.test.js.map +1 -1
  17. package/dist/cli/__tests__/launch-fallback.test.js +3 -0
  18. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  19. package/dist/cli/__tests__/package-bin-contract.test.js +10 -0
  20. package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
  21. package/dist/cli/__tests__/packaged-script-resolution.test.js +4 -3
  22. package/dist/cli/__tests__/packaged-script-resolution.test.js.map +1 -1
  23. package/dist/cli/__tests__/resume.test.js +6 -0
  24. package/dist/cli/__tests__/resume.test.js.map +1 -1
  25. package/dist/cli/__tests__/setup-refresh.test.js +29 -12
  26. package/dist/cli/__tests__/setup-refresh.test.js.map +1 -1
  27. package/dist/cli/__tests__/star-prompt.test.js +16 -0
  28. package/dist/cli/__tests__/star-prompt.test.js.map +1 -1
  29. package/dist/cli/__tests__/uninstall.test.js +112 -1
  30. package/dist/cli/__tests__/uninstall.test.js.map +1 -1
  31. package/dist/cli/__tests__/windows-popup-loop-contract.test.d.ts +2 -0
  32. package/dist/cli/__tests__/windows-popup-loop-contract.test.d.ts.map +1 -0
  33. package/dist/cli/__tests__/windows-popup-loop-contract.test.js +30 -0
  34. package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -0
  35. package/dist/cli/cleanup.d.ts +2 -0
  36. package/dist/cli/cleanup.d.ts.map +1 -1
  37. package/dist/cli/cleanup.js +26 -1
  38. package/dist/cli/cleanup.js.map +1 -1
  39. package/dist/cli/index.d.ts +7 -0
  40. package/dist/cli/index.d.ts.map +1 -1
  41. package/dist/cli/index.js +161 -50
  42. package/dist/cli/index.js.map +1 -1
  43. package/dist/cli/setup.d.ts.map +1 -1
  44. package/dist/cli/setup.js +15 -14
  45. package/dist/cli/setup.js.map +1 -1
  46. package/dist/cli/star-prompt.d.ts.map +1 -1
  47. package/dist/cli/star-prompt.js +1 -0
  48. package/dist/cli/star-prompt.js.map +1 -1
  49. package/dist/cli/team.d.ts.map +1 -1
  50. package/dist/cli/team.js +5 -1
  51. package/dist/cli/team.js.map +1 -1
  52. package/dist/cli/uninstall.d.ts.map +1 -1
  53. package/dist/cli/uninstall.js +26 -0
  54. package/dist/cli/uninstall.js.map +1 -1
  55. package/dist/cli/update.d.ts.map +1 -1
  56. package/dist/cli/update.js +1 -0
  57. package/dist/cli/update.js.map +1 -1
  58. package/dist/config/__tests__/generator-idempotent.test.js +4 -4
  59. package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
  60. package/dist/config/__tests__/mcp-registry.test.js +13 -16
  61. package/dist/config/__tests__/mcp-registry.test.js.map +1 -1
  62. package/dist/config/mcp-registry.d.ts +1 -0
  63. package/dist/config/mcp-registry.d.ts.map +1 -1
  64. package/dist/config/mcp-registry.js +4 -4
  65. package/dist/config/mcp-registry.js.map +1 -1
  66. package/dist/config/models.d.ts +1 -0
  67. package/dist/config/models.d.ts.map +1 -1
  68. package/dist/config/models.js +39 -1
  69. package/dist/config/models.js.map +1 -1
  70. package/dist/hooks/__tests__/keyword-detector.test.js +12 -1
  71. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  72. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +499 -17
  73. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
  74. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js +140 -14
  75. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js.map +1 -1
  76. package/dist/hooks/__tests__/notify-hook-modules.test.js +5 -0
  77. package/dist/hooks/__tests__/notify-hook-modules.test.js.map +1 -1
  78. package/dist/hooks/__tests__/notify-hook-ralph-resume.test.d.ts +2 -0
  79. package/dist/hooks/__tests__/notify-hook-ralph-resume.test.d.ts.map +1 -0
  80. package/dist/hooks/__tests__/notify-hook-ralph-resume.test.js +597 -0
  81. package/dist/hooks/__tests__/notify-hook-ralph-resume.test.js.map +1 -0
  82. package/dist/hooks/__tests__/notify-hook-regression-205.test.js +15 -1
  83. package/dist/hooks/__tests__/notify-hook-regression-205.test.js.map +1 -1
  84. package/dist/hooks/__tests__/notify-hook-session-scope.test.js +73 -53
  85. package/dist/hooks/__tests__/notify-hook-session-scope.test.js.map +1 -1
  86. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js +193 -2
  87. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js.map +1 -1
  88. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +183 -0
  89. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  90. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js +255 -97
  91. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js.map +1 -1
  92. package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js +0 -0
  93. package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js.map +1 -1
  94. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js +46 -0
  95. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js.map +1 -1
  96. package/dist/hooks/keyword-detector.d.ts +1 -0
  97. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  98. package/dist/hooks/keyword-detector.js +48 -0
  99. package/dist/hooks/keyword-detector.js.map +1 -1
  100. package/dist/hooks/session.d.ts.map +1 -1
  101. package/dist/hooks/session.js +1 -0
  102. package/dist/hooks/session.js.map +1 -1
  103. package/dist/hud/__tests__/state.test.js +70 -1
  104. package/dist/hud/__tests__/state.test.js.map +1 -1
  105. package/dist/hud/state.d.ts.map +1 -1
  106. package/dist/hud/state.js +10 -37
  107. package/dist/hud/state.js.map +1 -1
  108. package/dist/mcp/state-server.d.ts.map +1 -1
  109. package/dist/mcp/state-server.js +5 -0
  110. package/dist/mcp/state-server.js.map +1 -1
  111. package/dist/modes/__tests__/base-session-scope.test.js +46 -0
  112. package/dist/modes/__tests__/base-session-scope.test.js.map +1 -1
  113. package/dist/modes/base.d.ts.map +1 -1
  114. package/dist/modes/base.js +4 -0
  115. package/dist/modes/base.js.map +1 -1
  116. package/dist/notifications/__tests__/custom-alias-enablement.test.d.ts +2 -0
  117. package/dist/notifications/__tests__/custom-alias-enablement.test.d.ts.map +1 -0
  118. package/dist/notifications/__tests__/custom-alias-enablement.test.js +84 -0
  119. package/dist/notifications/__tests__/custom-alias-enablement.test.js.map +1 -0
  120. package/dist/notifications/__tests__/idle-cooldown.test.js +55 -0
  121. package/dist/notifications/__tests__/idle-cooldown.test.js.map +1 -1
  122. package/dist/notifications/idle-cooldown.d.ts +8 -6
  123. package/dist/notifications/idle-cooldown.d.ts.map +1 -1
  124. package/dist/notifications/idle-cooldown.js +53 -22
  125. package/dist/notifications/idle-cooldown.js.map +1 -1
  126. package/dist/notifications/notifier.js +1 -1
  127. package/dist/notifications/notifier.js.map +1 -1
  128. package/dist/notifications/reply-listener.d.ts.map +1 -1
  129. package/dist/notifications/reply-listener.js +1 -0
  130. package/dist/notifications/reply-listener.js.map +1 -1
  131. package/dist/openclaw/config.js +2 -2
  132. package/dist/openclaw/config.js.map +1 -1
  133. package/dist/runtime/bridge.d.ts +1 -0
  134. package/dist/runtime/bridge.d.ts.map +1 -1
  135. package/dist/runtime/bridge.js +2 -6
  136. package/dist/runtime/bridge.js.map +1 -1
  137. package/dist/scripts/notify-fallback-watcher.js +97 -59
  138. package/dist/scripts/notify-fallback-watcher.js.map +1 -1
  139. package/dist/scripts/notify-hook/auto-nudge.d.ts +2 -1
  140. package/dist/scripts/notify-hook/auto-nudge.d.ts.map +1 -1
  141. package/dist/scripts/notify-hook/auto-nudge.js +72 -238
  142. package/dist/scripts/notify-hook/auto-nudge.js.map +1 -1
  143. package/dist/scripts/notify-hook/managed-tmux.d.ts +19 -0
  144. package/dist/scripts/notify-hook/managed-tmux.d.ts.map +1 -0
  145. package/dist/scripts/notify-hook/managed-tmux.js +320 -0
  146. package/dist/scripts/notify-hook/managed-tmux.js.map +1 -0
  147. package/dist/scripts/notify-hook/ralph-session-resume.d.ts +22 -0
  148. package/dist/scripts/notify-hook/ralph-session-resume.d.ts.map +1 -0
  149. package/dist/scripts/notify-hook/ralph-session-resume.js +277 -0
  150. package/dist/scripts/notify-hook/ralph-session-resume.js.map +1 -0
  151. package/dist/scripts/notify-hook/state-io.d.ts +1 -1
  152. package/dist/scripts/notify-hook/state-io.d.ts.map +1 -1
  153. package/dist/scripts/notify-hook/state-io.js +2 -10
  154. package/dist/scripts/notify-hook/state-io.js.map +1 -1
  155. package/dist/scripts/notify-hook/team-dispatch.d.ts.map +1 -1
  156. package/dist/scripts/notify-hook/team-dispatch.js +60 -59
  157. package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
  158. package/dist/scripts/notify-hook/team-leader-nudge.d.ts +2 -1
  159. package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
  160. package/dist/scripts/notify-hook/team-leader-nudge.js +13 -5
  161. package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
  162. package/dist/scripts/notify-hook/team-tmux-guard.d.ts.map +1 -1
  163. package/dist/scripts/notify-hook/team-tmux-guard.js +1 -19
  164. package/dist/scripts/notify-hook/team-tmux-guard.js.map +1 -1
  165. package/dist/scripts/notify-hook/team-worker.js +4 -4
  166. package/dist/scripts/notify-hook/team-worker.js.map +1 -1
  167. package/dist/scripts/notify-hook/tmux-injection.d.ts +1 -1
  168. package/dist/scripts/notify-hook/tmux-injection.d.ts.map +1 -1
  169. package/dist/scripts/notify-hook/tmux-injection.js +102 -35
  170. package/dist/scripts/notify-hook/tmux-injection.js.map +1 -1
  171. package/dist/scripts/notify-hook.js +144 -20
  172. package/dist/scripts/notify-hook.js.map +1 -1
  173. package/dist/scripts/tmux-hook-engine.d.ts +1 -0
  174. package/dist/scripts/tmux-hook-engine.d.ts.map +1 -1
  175. package/dist/scripts/tmux-hook-engine.js +3 -0
  176. package/dist/scripts/tmux-hook-engine.js.map +1 -1
  177. package/dist/team/__tests__/api-interop.test.js +96 -4
  178. package/dist/team/__tests__/api-interop.test.js.map +1 -1
  179. package/dist/team/__tests__/leader-activity.test.js +107 -2
  180. package/dist/team/__tests__/leader-activity.test.js.map +1 -1
  181. package/dist/team/__tests__/runtime-cli.test.js +32 -0
  182. package/dist/team/__tests__/runtime-cli.test.js.map +1 -1
  183. package/dist/team/__tests__/runtime.test.js +148 -0
  184. package/dist/team/__tests__/runtime.test.js.map +1 -1
  185. package/dist/team/__tests__/shutdown-fallback.test.js +13 -0
  186. package/dist/team/__tests__/shutdown-fallback.test.js.map +1 -1
  187. package/dist/team/__tests__/state-root.test.js +11 -1
  188. package/dist/team/__tests__/state-root.test.js.map +1 -1
  189. package/dist/team/__tests__/state.test.js +16 -5
  190. package/dist/team/__tests__/state.test.js.map +1 -1
  191. package/dist/team/__tests__/tmux-session.test.js +460 -2
  192. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  193. package/dist/team/api-interop.d.ts.map +1 -1
  194. package/dist/team/api-interop.js +34 -7
  195. package/dist/team/api-interop.js.map +1 -1
  196. package/dist/team/commit-hygiene.d.ts +60 -0
  197. package/dist/team/commit-hygiene.d.ts.map +1 -0
  198. package/dist/team/commit-hygiene.js +232 -0
  199. package/dist/team/commit-hygiene.js.map +1 -0
  200. package/dist/team/leader-activity.d.ts.map +1 -1
  201. package/dist/team/leader-activity.js +17 -35
  202. package/dist/team/leader-activity.js.map +1 -1
  203. package/dist/team/runtime-cli.d.ts +9 -1
  204. package/dist/team/runtime-cli.d.ts.map +1 -1
  205. package/dist/team/runtime-cli.js +15 -6
  206. package/dist/team/runtime-cli.js.map +1 -1
  207. package/dist/team/runtime.d.ts +7 -2
  208. package/dist/team/runtime.d.ts.map +1 -1
  209. package/dist/team/runtime.js +391 -63
  210. package/dist/team/runtime.js.map +1 -1
  211. package/dist/team/state/dispatch.js +1 -1
  212. package/dist/team/state/dispatch.js.map +1 -1
  213. package/dist/team/state/mailbox.d.ts +1 -0
  214. package/dist/team/state/mailbox.d.ts.map +1 -1
  215. package/dist/team/state/mailbox.js +54 -8
  216. package/dist/team/state/mailbox.js.map +1 -1
  217. package/dist/team/state-root.d.ts +1 -1
  218. package/dist/team/state-root.d.ts.map +1 -1
  219. package/dist/team/state-root.js +8 -3
  220. package/dist/team/state-root.js.map +1 -1
  221. package/dist/team/state.d.ts.map +1 -1
  222. package/dist/team/state.js +66 -3
  223. package/dist/team/state.js.map +1 -1
  224. package/dist/team/tmux-session.d.ts.map +1 -1
  225. package/dist/team/tmux-session.js +69 -27
  226. package/dist/team/tmux-session.js.map +1 -1
  227. package/dist/utils/__tests__/platform-command.test.js +101 -2
  228. package/dist/utils/__tests__/platform-command.test.js.map +1 -1
  229. package/dist/utils/git-layout.d.ts +8 -0
  230. package/dist/utils/git-layout.d.ts.map +1 -0
  231. package/dist/utils/git-layout.js +58 -0
  232. package/dist/utils/git-layout.js.map +1 -0
  233. package/dist/utils/platform-command.d.ts.map +1 -1
  234. package/dist/utils/platform-command.js +32 -1
  235. package/dist/utils/platform-command.js.map +1 -1
  236. package/package.json +6 -6
  237. package/src/scripts/notify-fallback-watcher.ts +96 -58
  238. package/src/scripts/notify-hook/auto-nudge.ts +75 -230
  239. package/src/scripts/notify-hook/managed-tmux.ts +324 -0
  240. package/src/scripts/notify-hook/ralph-session-resume.ts +337 -0
  241. package/src/scripts/notify-hook/state-io.ts +2 -10
  242. package/src/scripts/notify-hook/team-dispatch.ts +70 -54
  243. package/src/scripts/notify-hook/team-leader-nudge.ts +19 -5
  244. package/src/scripts/notify-hook/team-tmux-guard.ts +0 -20
  245. package/src/scripts/notify-hook/team-worker.ts +4 -4
  246. package/src/scripts/notify-hook/tmux-injection.ts +103 -33
  247. package/src/scripts/notify-hook.ts +150 -21
  248. package/src/scripts/tmux-hook-engine.ts +4 -0
@@ -7,6 +7,7 @@ import { join } from 'node:path';
7
7
  import { spawn, spawnSync } from 'node:child_process';
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { initTeamState, enqueueDispatchRequest, readDispatchRequest } from '../../team/state.js';
10
+ import { buildWindowsMsysBackgroundHelperBootstrapScript } from '../../cli/index.js';
10
11
  import { writeSessionStart } from '../session.js';
11
12
  async function appendLine(path, line) {
12
13
  const prev = await readFile(path, 'utf-8');
@@ -111,7 +112,7 @@ if [[ "$cmd" == "display-message" ]]; then
111
112
  exit 0
112
113
  fi
113
114
  if [[ "$fmt" == "#S" ]]; then
114
- echo "session-test"
115
+ echo "\${OMX_TEST_TMUX_SESSION_NAME:-session-test}"
115
116
  exit 0
116
117
  fi
117
118
  exit 0
@@ -129,6 +130,20 @@ if [[ "$cmd" == "send-keys" ]]; then
129
130
  exit 0
130
131
  fi
131
132
  if [[ "$cmd" == "list-panes" ]]; then
133
+ target=""
134
+ while [[ "$#" -gt 0 ]]; do
135
+ case "$1" in
136
+ -t)
137
+ shift
138
+ target="$1"
139
+ ;;
140
+ esac
141
+ shift || true
142
+ done
143
+ if [[ -n "$target" ]]; then
144
+ printf "%%42\tcodex\tcodex\n"
145
+ exit 0
146
+ fi
132
147
  echo "%42 1"
133
148
  exit 0
134
149
  fi
@@ -372,6 +387,71 @@ describe('notify-fallback watcher', () => {
372
387
  await rm(wd, { recursive: true, force: true });
373
388
  }
374
389
  });
390
+ it('suppresses idle no-op lifecycle and control-plane logs during authority-only one-shot ticks', async () => {
391
+ const wd = await mkdtemp(join(tmpdir(), 'omx-fallback-authority-noop-'));
392
+ try {
393
+ await mkdir(join(wd, '.omx', 'logs'), { recursive: true });
394
+ await mkdir(join(wd, '.omx', 'state'), { recursive: true });
395
+ const watcherScript = new URL('../../../dist/scripts/notify-fallback-watcher.js', import.meta.url).pathname;
396
+ const notifyHook = new URL('../../../dist/scripts/notify-hook.js', import.meta.url).pathname;
397
+ const result = spawnSync(process.execPath, [watcherScript, '--once', '--authority-only', '--cwd', wd, '--notify-script', notifyHook, '--poll-ms', '50'], { encoding: 'utf-8', env: buildCleanNotifyEnv() });
398
+ assert.equal(result.status, 0, result.stderr || result.stdout);
399
+ const watcherStatePath = join(wd, '.omx', 'state', 'notify-fallback-state.json');
400
+ const watcherState = JSON.parse(await readFile(watcherStatePath, 'utf-8'));
401
+ assert.equal(watcherState.authority_only, true);
402
+ assert.equal(watcherState.dispatch_drain?.run_count, 1);
403
+ assert.equal(watcherState.dispatch_drain?.last_result?.processed ?? 0, 0);
404
+ assert.equal(watcherState.leader_nudge?.run_count, 1);
405
+ assert.equal(watcherState.leader_nudge?.precomputed_leader_stale, false);
406
+ assert.equal(watcherState.fallback_auto_nudge?.last_reason, 'hud_state_missing');
407
+ const logPath = join(wd, '.omx', 'logs', `notify-fallback-${new Date().toISOString().split('T')[0]}.jsonl`);
408
+ const logContent = await readFile(logPath, 'utf-8').catch(() => '');
409
+ assert.equal(logContent.trim(), '');
410
+ }
411
+ finally {
412
+ await rm(wd, { recursive: true, force: true });
413
+ }
414
+ });
415
+ it('suppresses authority-only control-plane ticks when only skill-active-state carries the deep-interview input lock', async () => {
416
+ const wd = await mkdtemp(join(tmpdir(), 'omx-fallback-authority-skill-lock-'));
417
+ try {
418
+ await mkdir(join(wd, '.omx', 'logs'), { recursive: true });
419
+ await mkdir(join(wd, '.omx', 'state'), { recursive: true });
420
+ await writeFile(join(wd, '.omx', 'state', 'skill-active-state.json'), JSON.stringify({
421
+ version: 1,
422
+ active: true,
423
+ skill: 'deep-interview',
424
+ keyword: 'deep interview',
425
+ phase: 'planning',
426
+ activated_at: '2026-02-25T00:00:00.000Z',
427
+ updated_at: '2026-02-25T00:00:00.000Z',
428
+ source: 'keyword-detector',
429
+ input_lock: {
430
+ active: true,
431
+ scope: 'deep-interview-auto-approval',
432
+ acquired_at: '2026-02-25T00:00:00.000Z',
433
+ blocked_inputs: ['yes', 'y', 'proceed', 'continue', 'ok', 'sure', 'go ahead', 'next i should'],
434
+ message: 'Deep interview is active; auto-approval shortcuts are blocked until the interview finishes.',
435
+ },
436
+ }, null, 2));
437
+ const watcherScript = new URL('../../../dist/scripts/notify-fallback-watcher.js', import.meta.url).pathname;
438
+ const notifyHook = new URL('../../../dist/scripts/notify-hook.js', import.meta.url).pathname;
439
+ const result = spawnSync(process.execPath, [watcherScript, '--once', '--authority-only', '--cwd', wd, '--notify-script', notifyHook, '--poll-ms', '50'], { encoding: 'utf-8', env: buildCleanNotifyEnv() });
440
+ assert.equal(result.status, 0, result.stderr || result.stdout);
441
+ const watcherStatePath = join(wd, '.omx', 'state', 'notify-fallback-state.json');
442
+ const watcherState = JSON.parse(await readFile(watcherStatePath, 'utf-8'));
443
+ assert.equal(watcherState.authority_only, true);
444
+ assert.equal(watcherState.dispatch_drain?.run_count, 1);
445
+ assert.equal(watcherState.leader_nudge?.run_count, 0);
446
+ assert.equal(watcherState.fallback_auto_nudge?.last_reason, 'init');
447
+ const logPath = join(wd, '.omx', 'logs', `notify-fallback-${new Date().toISOString().split('T')[0]}.jsonl`);
448
+ const logContent = await readFile(logPath, 'utf-8').catch(() => '');
449
+ assert.equal(logContent.trim(), '');
450
+ }
451
+ finally {
452
+ await rm(wd, { recursive: true, force: true });
453
+ }
454
+ });
375
455
  it('disables fallback watcher nudges when deep-interview state is active', async () => {
376
456
  const wd = await mkdtemp(join(tmpdir(), 'omx-fallback-deep-interview-suppressed-'));
377
457
  const fakeBinDir = join(wd, 'fake-bin');
@@ -423,6 +503,69 @@ describe('notify-fallback watcher', () => {
423
503
  await rm(wd, { recursive: true, force: true });
424
504
  }
425
505
  });
506
+ it('disables fallback watcher nudges when only skill-active-state carries the deep-interview input lock', async () => {
507
+ const wd = await mkdtemp(join(tmpdir(), 'omx-fallback-deep-interview-skill-lock-'));
508
+ const fakeBinDir = join(wd, 'fake-bin');
509
+ const tmuxLogPath = join(wd, 'tmux.log');
510
+ try {
511
+ await mkdir(join(wd, '.omx', 'logs'), { recursive: true });
512
+ await mkdir(join(wd, '.omx', 'state', 'team', 'dispatch-team'), { recursive: true });
513
+ await mkdir(fakeBinDir, { recursive: true });
514
+ await writeFile(join(fakeBinDir, 'tmux'), buildFakeTmux(tmuxLogPath));
515
+ await chmod(join(fakeBinDir, 'tmux'), 0o755);
516
+ await writeFile(join(wd, '.omx', 'state', 'skill-active-state.json'), JSON.stringify({
517
+ version: 1,
518
+ active: true,
519
+ skill: 'deep-interview',
520
+ keyword: 'deep interview',
521
+ phase: 'planning',
522
+ activated_at: '2026-02-25T00:00:00.000Z',
523
+ updated_at: '2026-02-25T00:00:00.000Z',
524
+ source: 'keyword-detector',
525
+ input_lock: {
526
+ active: true,
527
+ scope: 'deep-interview-auto-approval',
528
+ acquired_at: '2026-02-25T00:00:00.000Z',
529
+ blocked_inputs: ['yes', 'y', 'proceed', 'continue', 'ok', 'sure', 'go ahead', 'next i should'],
530
+ message: 'Deep interview is active; auto-approval shortcuts are blocked until the interview finishes.',
531
+ },
532
+ }, null, 2));
533
+ await writeFile(join(wd, '.omx', 'state', 'ralph-state.json'), JSON.stringify({
534
+ active: true,
535
+ current_phase: 'executing',
536
+ tmux_pane_id: '%42',
537
+ }, null, 2));
538
+ await writeFile(join(wd, '.omx', 'state', 'team-state.json'), JSON.stringify({
539
+ active: true,
540
+ team_name: 'dispatch-team',
541
+ current_phase: 'team-exec',
542
+ }, null, 2));
543
+ await writeFile(join(wd, '.omx', 'state', 'hud-state.json'), JSON.stringify({
544
+ last_turn_at: new Date(Date.now() - 300_000).toISOString(),
545
+ turn_count: 3,
546
+ last_agent_output: 'Would you like me to continue?',
547
+ }, null, 2));
548
+ await writeFile(join(wd, '.omx', 'state', 'team', 'dispatch-team', 'config.json'), JSON.stringify({
549
+ name: 'dispatch-team',
550
+ tmux_session: 'omx-team-dispatch-team',
551
+ leader_pane_id: '%42',
552
+ }, null, 2));
553
+ const watcherScript = new URL('../../../dist/scripts/notify-fallback-watcher.js', import.meta.url).pathname;
554
+ const notifyHook = new URL('../../../dist/scripts/notify-hook.js', import.meta.url).pathname;
555
+ const result = spawnSync(process.execPath, [watcherScript, '--once', '--cwd', wd, '--notify-script', notifyHook], {
556
+ encoding: 'utf-8',
557
+ env: buildCleanNotifyEnv({ PATH: `${fakeBinDir}:${process.env.PATH || ''}` }),
558
+ });
559
+ assert.equal(result.status, 0, result.stderr || result.stdout);
560
+ const tmuxLog = await readFile(tmuxLogPath, 'utf8').catch(() => '');
561
+ assert.doesNotMatch(tmuxLog, /Ralph loop active continue/);
562
+ assert.doesNotMatch(tmuxLog, /Team dispatch-team:/);
563
+ assert.doesNotMatch(tmuxLog, /yes, proceed \[OMX_TMUX_INJECT\]/);
564
+ }
565
+ finally {
566
+ await rm(wd, { recursive: true, force: true });
567
+ }
568
+ });
426
569
  it('runs leader nudge checks from the fallback watcher so stale alerts do not wait for a leader turn', async () => {
427
570
  const wd = await mkdtemp(join(tmpdir(), 'omx-fallback-leader-nudge-'));
428
571
  const fakeBinDir = join(wd, 'fake-bin');
@@ -532,9 +675,181 @@ describe('notify-fallback watcher', () => {
532
675
  const logPath = join(wd, '.omx', 'logs', `notify-fallback-${new Date().toISOString().split('T')[0]}.jsonl`);
533
676
  const logEntries = (await readFile(logPath, 'utf-8')).trim().split('\n').filter(Boolean).map((line) => JSON.parse(line));
534
677
  const nudgeEvent = logEntries.find((entry) => entry.type === 'leader_nudge_tick');
535
- assert.ok(nudgeEvent, 'expected leader_nudge_tick log event');
536
- assert.equal(nudgeEvent.precomputed_leader_stale, false);
537
- assert.equal(nudgeEvent.reason, 'leader_nudge_skipped_not_stale');
678
+ assert.equal(nudgeEvent, undefined);
679
+ }
680
+ finally {
681
+ await rm(wd, { recursive: true, force: true });
682
+ }
683
+ });
684
+ it('runs stalled-worker leader nudges from the fallback watcher even when the leader is not stale', async () => {
685
+ const wd = await mkdtemp(join(tmpdir(), 'omx-fallback-worker-stall-nudge-'));
686
+ const fakeBinDir = join(wd, 'fake-bin');
687
+ const tmuxLogPath = join(wd, 'tmux.log');
688
+ try {
689
+ await mkdir(join(wd, '.omx', 'logs'), { recursive: true });
690
+ await mkdir(join(wd, '.omx', 'state', 'team', 'dispatch-team', 'workers', 'worker-1'), { recursive: true });
691
+ await mkdir(join(wd, '.omx', 'state', 'team', 'dispatch-team', 'tasks'), { recursive: true });
692
+ await mkdir(fakeBinDir, { recursive: true });
693
+ const tmuxScript = `#!/usr/bin/env bash
694
+ set -eu
695
+ echo "$@" >> "${tmuxLogPath}"
696
+ cmd="$1"
697
+ shift || true
698
+ if [[ "$cmd" == "display-message" ]]; then
699
+ target=""
700
+ fmt=""
701
+ while [[ "$#" -gt 0 ]]; do
702
+ case "$1" in
703
+ -t)
704
+ shift
705
+ target="$1"
706
+ ;;
707
+ *)
708
+ fmt="$1"
709
+ ;;
710
+ esac
711
+ shift || true
712
+ done
713
+ if [[ "$fmt" == "#{pane_in_mode}" ]]; then
714
+ echo "0"
715
+ exit 0
716
+ fi
717
+ if [[ "$fmt" == "#{pane_id}" ]]; then
718
+ echo "\${target:-%42}"
719
+ exit 0
720
+ fi
721
+ if [[ "$fmt" == "#{pane_current_path}" ]]; then
722
+ dirname "${tmuxLogPath}"
723
+ exit 0
724
+ fi
725
+ if [[ "$fmt" == "#{pane_current_command}" ]]; then
726
+ echo "codex"
727
+ exit 0
728
+ fi
729
+ if [[ "$fmt" == "#S" ]]; then
730
+ echo "omx-team-dispatch-team"
731
+ exit 0
732
+ fi
733
+ exit 0
734
+ fi
735
+ if [[ "$cmd" == "send-keys" ]]; then
736
+ exit 0
737
+ fi
738
+ if [[ "$cmd" == "list-panes" ]]; then
739
+ target=""
740
+ while [[ "$#" -gt 0 ]]; do
741
+ case "$1" in
742
+ -t)
743
+ shift
744
+ target="$1"
745
+ ;;
746
+ esac
747
+ shift || true
748
+ done
749
+ if [[ -n "$target" ]]; then
750
+ printf "%%42 12345\n%%10 12346\n%%11 12347\n"
751
+ exit 0
752
+ fi
753
+ echo "%42 1"
754
+ exit 0
755
+ fi
756
+ exit 0
757
+ `;
758
+ await writeFile(join(fakeBinDir, 'tmux'), tmuxScript);
759
+ await chmod(join(fakeBinDir, 'tmux'), 0o755);
760
+ const now = Date.now();
761
+ await writeFile(join(wd, '.omx', 'state', 'team-state.json'), JSON.stringify({
762
+ active: true,
763
+ team_name: 'dispatch-team',
764
+ current_phase: 'team-exec',
765
+ }, null, 2));
766
+ await writeFile(join(wd, '.omx', 'state', 'hud-state.json'), JSON.stringify({
767
+ last_turn_at: new Date().toISOString(),
768
+ turn_count: 3,
769
+ }, null, 2));
770
+ await writeFile(join(wd, '.omx', 'state', 'team', 'dispatch-team', 'config.json'), JSON.stringify({
771
+ name: 'dispatch-team',
772
+ tmux_session: 'omx-team-dispatch-team',
773
+ leader_pane_id: '%42',
774
+ workers: [
775
+ { name: 'worker-1', index: 1, pane_id: '%10' },
776
+ { name: 'worker-2', index: 2, pane_id: '%11' },
777
+ ],
778
+ }, null, 2));
779
+ await writeFile(join(wd, '.omx', 'state', 'team', 'dispatch-team', 'tasks', 'task-1.json'), JSON.stringify({
780
+ id: '1',
781
+ subject: 'Pending work',
782
+ description: 'Needs attention',
783
+ status: 'pending',
784
+ created_at: new Date().toISOString(),
785
+ }, null, 2));
786
+ await writeFile(join(wd, '.omx', 'state', 'team', 'dispatch-team', 'workers', 'worker-1', 'status.json'), JSON.stringify({
787
+ state: 'working',
788
+ current_task_id: '1',
789
+ updated_at: new Date(now - 180_000).toISOString(),
790
+ }, null, 2));
791
+ await writeFile(join(wd, '.omx', 'state', 'team', 'dispatch-team', 'workers', 'worker-1', 'heartbeat.json'), JSON.stringify({
792
+ alive: true,
793
+ pid: 101,
794
+ turn_count: 2,
795
+ last_turn_at: new Date(now - 180_000).toISOString(),
796
+ }, null, 2));
797
+ await writeFile(join(wd, '.omx', 'state', 'team-leader-nudge.json'), JSON.stringify({
798
+ last_nudged_by_team: {
799
+ 'dispatch-team': {
800
+ at: new Date(now - 5_000).toISOString(),
801
+ last_message_id: '',
802
+ reason: 'new_mailbox_message',
803
+ },
804
+ },
805
+ progress_by_team: {
806
+ 'dispatch-team': {
807
+ signature: JSON.stringify({
808
+ tasks: [{ id: '1', owner: '', status: 'pending' }],
809
+ workers: [
810
+ {
811
+ worker: 'worker-1',
812
+ state: 'working',
813
+ current_task_id: '1',
814
+ status_missing: false,
815
+ turn_count: 2,
816
+ heartbeat_missing: false,
817
+ },
818
+ {
819
+ worker: 'worker-2',
820
+ state: 'unknown',
821
+ current_task_id: '',
822
+ status_missing: true,
823
+ turn_count: null,
824
+ heartbeat_missing: true,
825
+ },
826
+ ],
827
+ }),
828
+ last_progress_at: new Date(now - 180_000).toISOString(),
829
+ },
830
+ },
831
+ }, null, 2));
832
+ const watcherScript = new URL('../../../dist/scripts/notify-fallback-watcher.js', import.meta.url).pathname;
833
+ const notifyHook = new URL('../../../dist/scripts/notify-hook.js', import.meta.url).pathname;
834
+ const result = spawnSync(process.execPath, [watcherScript, '--once', '--cwd', wd, '--notify-script', notifyHook], {
835
+ encoding: 'utf-8',
836
+ env: buildCleanNotifyEnv({
837
+ PATH: `${fakeBinDir}:${process.env.PATH || ''}`,
838
+ OMX_TEAM_PROGRESS_STALL_MS: '60000',
839
+ OMX_TEAM_LEADER_NUDGE_MS: '30000',
840
+ OMX_TEAM_LEADER_STALE_MS: '60000',
841
+ }),
842
+ });
843
+ assert.equal(result.status, 0, result.stderr || result.stdout);
844
+ const tmuxLog = await readFile(tmuxLogPath, 'utf8');
845
+ assert.match(tmuxLog, /send-keys -t %42 -l Team dispatch-team: worker panes stalled, no progress 3m/);
846
+ assert.doesNotMatch(tmuxLog, /leader stale/);
847
+ const watcherStatePath = join(wd, '.omx', 'state', 'notify-fallback-state.json');
848
+ const watcherState = JSON.parse(await readFile(watcherStatePath, 'utf-8'));
849
+ assert.equal(watcherState.leader_nudge?.enabled, true);
850
+ assert.equal(watcherState.leader_nudge?.leader_only, true);
851
+ assert.equal(watcherState.leader_nudge?.run_count, 1);
852
+ assert.equal(watcherState.leader_nudge?.precomputed_leader_stale, false);
538
853
  }
539
854
  finally {
540
855
  await rm(wd, { recursive: true, force: true });
@@ -569,6 +884,7 @@ describe('notify-fallback watcher', () => {
569
884
  PATH: `${fakeBinDir}:${process.env.PATH || ''}`,
570
885
  CODEX_HOME: codexHome,
571
886
  OMX_SESSION_ID: 'sess-managed-fallback',
887
+ OMX_TEST_TMUX_SESSION_NAME: 'omx-fallback-auto-nudge-stalled-managed',
572
888
  TMUX: '1',
573
889
  TMUX_PANE: '%42',
574
890
  OMX_NOTIFY_FALLBACK_AUTO_NUDGE_STALL_MS: '5000',
@@ -587,6 +903,98 @@ describe('notify-fallback watcher', () => {
587
903
  await rm(wd, { recursive: true, force: true });
588
904
  }
589
905
  });
906
+ it('respects `.omx/tmux-hook.json` enabled:false for fallback auto-nudge', async () => {
907
+ const wd = await mkdtemp(join(tmpdir(), 'omx-fallback-auto-nudge-disabled-'));
908
+ const fakeBinDir = join(wd, 'fake-bin');
909
+ const tmuxLogPath = join(wd, 'tmux.log');
910
+ const codexHome = join(wd, 'codex-home');
911
+ try {
912
+ await mkdir(join(wd, '.omx', 'logs'), { recursive: true });
913
+ await mkdir(join(wd, '.omx', 'state'), { recursive: true });
914
+ await mkdir(fakeBinDir, { recursive: true });
915
+ await mkdir(codexHome, { recursive: true });
916
+ await writeFile(join(fakeBinDir, 'tmux'), buildFakeTmux(tmuxLogPath));
917
+ await chmod(join(fakeBinDir, 'tmux'), 0o755);
918
+ await writeFile(join(codexHome, '.omx-config.json'), JSON.stringify({
919
+ autoNudge: { enabled: true, delaySec: 0, ttlMs: 30_000 },
920
+ }, null, 2));
921
+ await writeFile(join(wd, '.omx', 'tmux-hook.json'), JSON.stringify({
922
+ enabled: false,
923
+ target: { type: 'pane', value: '%42' },
924
+ }, null, 2));
925
+ await writeSessionStart(wd, 'sess-managed-fallback');
926
+ await writeFile(join(wd, '.omx', 'state', 'hud-state.json'), JSON.stringify({
927
+ last_turn_at: new Date(Date.now() - 6_000).toISOString(),
928
+ turn_count: 7,
929
+ last_agent_output: 'If you want, I can keep going from here.',
930
+ }, null, 2));
931
+ const watcherScript = new URL('../../../dist/scripts/notify-fallback-watcher.js', import.meta.url).pathname;
932
+ const notifyHook = new URL('../../../dist/scripts/notify-hook.js', import.meta.url).pathname;
933
+ const result = spawnSync(process.execPath, [watcherScript, '--once', '--cwd', wd, '--notify-script', notifyHook, '--poll-ms', '50'], {
934
+ encoding: 'utf-8',
935
+ env: buildCleanNotifyEnv({
936
+ PATH: `${fakeBinDir}:${process.env.PATH || ''}`,
937
+ CODEX_HOME: codexHome,
938
+ OMX_SESSION_ID: 'sess-managed-fallback',
939
+ TMUX: '1',
940
+ TMUX_PANE: '%42',
941
+ OMX_NOTIFY_FALLBACK_AUTO_NUDGE_STALL_MS: '5000',
942
+ }),
943
+ });
944
+ assert.equal(result.status, 0, result.stderr || result.stdout);
945
+ const tmuxLog = await readFile(tmuxLogPath, 'utf8').catch(() => '');
946
+ assert.doesNotMatch(tmuxLog, /send-keys -t %42 -l yes, proceed \[OMX_TMUX_INJECT\]/);
947
+ }
948
+ finally {
949
+ await rm(wd, { recursive: true, force: true });
950
+ }
951
+ });
952
+ it('suppresses fallback unmanaged-session auto-nudge skip logs while idle', async () => {
953
+ const wd = await mkdtemp(join(tmpdir(), 'omx-fallback-auto-nudge-unmanaged-'));
954
+ const fakeBinDir = join(wd, 'fake-bin');
955
+ const tmuxLogPath = join(wd, 'tmux.log');
956
+ const codexHome = join(wd, 'codex-home');
957
+ try {
958
+ await mkdir(join(wd, '.omx', 'logs'), { recursive: true });
959
+ await mkdir(join(wd, '.omx', 'state'), { recursive: true });
960
+ await mkdir(fakeBinDir, { recursive: true });
961
+ await mkdir(codexHome, { recursive: true });
962
+ await writeFile(join(fakeBinDir, 'tmux'), buildFakeTmux(tmuxLogPath));
963
+ await chmod(join(fakeBinDir, 'tmux'), 0o755);
964
+ await writeFile(join(codexHome, '.omx-config.json'), JSON.stringify({
965
+ autoNudge: { enabled: true, delaySec: 0, ttlMs: 30_000 },
966
+ }, null, 2));
967
+ await writeFile(join(wd, '.omx', 'state', 'hud-state.json'), JSON.stringify({
968
+ last_turn_at: new Date(Date.now() - 6_000).toISOString(),
969
+ turn_count: 9,
970
+ last_agent_output: 'If you want, I can keep going from here.',
971
+ }, null, 2));
972
+ const watcherScript = new URL('../../../dist/scripts/notify-fallback-watcher.js', import.meta.url).pathname;
973
+ const notifyHook = new URL('../../../dist/scripts/notify-hook.js', import.meta.url).pathname;
974
+ const result = spawnSync(process.execPath, [watcherScript, '--once', '--cwd', wd, '--notify-script', notifyHook, '--poll-ms', '50'], {
975
+ encoding: 'utf-8',
976
+ env: buildCleanNotifyEnv({
977
+ PATH: `${fakeBinDir}:${process.env.PATH || ''}`,
978
+ CODEX_HOME: codexHome,
979
+ TMUX: '1',
980
+ TMUX_PANE: '%42',
981
+ OMX_NOTIFY_FALLBACK_AUTO_NUDGE_STALL_MS: '5000',
982
+ }),
983
+ });
984
+ assert.equal(result.status, 0, result.stderr || result.stdout);
985
+ const tmuxLog = await readFile(tmuxLogPath, 'utf8').catch(() => '');
986
+ assert.doesNotMatch(tmuxLog, /send-keys -t %42 -l yes, proceed \[OMX_TMUX_INJECT\]/);
987
+ const watcherStatePath = join(wd, '.omx', 'state', 'notify-fallback-state.json');
988
+ const watcherState = JSON.parse(await readFile(watcherStatePath, 'utf-8'));
989
+ assert.equal(watcherState.fallback_auto_nudge?.last_reason, 'eligible_but_not_sent');
990
+ const tmuxHookLogPath = join(wd, '.omx', 'logs', `tmux-hook-${new Date().toISOString().split('T')[0]}.jsonl`);
991
+ const tmuxHookLog = await readFile(tmuxHookLogPath, 'utf-8').catch(() => '');
992
+ assert.equal(tmuxHookLog.trim(), '');
993
+ }
994
+ finally {
995
+ await rm(wd, { recursive: true, force: true });
996
+ }
997
+ });
590
998
  it('does not auto-nudge stalled-like output when the latest turn is still fresh', async () => {
591
999
  const wd = await mkdtemp(join(tmpdir(), 'omx-fallback-auto-nudge-fresh-'));
592
1000
  const fakeBinDir = join(wd, 'fake-bin');
@@ -737,7 +1145,9 @@ describe('notify-fallback watcher', () => {
737
1145
  });
738
1146
  it('runs bounded non-turn team dispatch drain tick in leader context', async () => {
739
1147
  const wd = await mkdtemp(join(tmpdir(), 'omx-fallback-dispatch-'));
1148
+ const previousRuntimeBridge = process.env.OMX_RUNTIME_BRIDGE;
740
1149
  try {
1150
+ process.env.OMX_RUNTIME_BRIDGE = '0';
741
1151
  await initTeamState('dispatch-team', 'task', 'executor', 1, wd);
742
1152
  const queued = await enqueueDispatchRequest('dispatch-team', {
743
1153
  kind: 'inbox',
@@ -754,12 +1164,18 @@ describe('notify-fallback watcher', () => {
754
1164
  assert.notEqual(request?.status, 'pending');
755
1165
  }
756
1166
  finally {
1167
+ if (typeof previousRuntimeBridge === 'string')
1168
+ process.env.OMX_RUNTIME_BRIDGE = previousRuntimeBridge;
1169
+ else
1170
+ delete process.env.OMX_RUNTIME_BRIDGE;
757
1171
  await rm(wd, { recursive: true, force: true });
758
1172
  }
759
1173
  });
760
1174
  it('skips dispatch drain in worker context (leader-only guard)', async () => {
761
1175
  const wd = await mkdtemp(join(tmpdir(), 'omx-fallback-dispatch-worker-'));
1176
+ const previousRuntimeBridge = process.env.OMX_RUNTIME_BRIDGE;
762
1177
  try {
1178
+ process.env.OMX_RUNTIME_BRIDGE = '0';
763
1179
  await initTeamState('dispatch-team', 'task', 'executor', 1, wd);
764
1180
  const queued = await enqueueDispatchRequest('dispatch-team', {
765
1181
  kind: 'inbox',
@@ -781,11 +1197,13 @@ describe('notify-fallback watcher', () => {
781
1197
  const logPath = join(wd, '.omx', 'logs', `notify-fallback-${new Date().toISOString().split('T')[0]}.jsonl`);
782
1198
  const logEntries = (await readFile(logPath, 'utf-8')).trim().split('\n').filter(Boolean).map((line) => JSON.parse(line));
783
1199
  const drainEvent = logEntries.find((entry) => entry.type === 'dispatch_drain_tick');
784
- assert.ok(drainEvent, 'expected dispatch_drain_tick log event');
785
- assert.equal(drainEvent.leader_only, false);
786
- assert.equal(drainEvent.reason, 'worker_context');
1200
+ assert.equal(drainEvent, undefined);
787
1201
  }
788
1202
  finally {
1203
+ if (typeof previousRuntimeBridge === 'string')
1204
+ process.env.OMX_RUNTIME_BRIDGE = previousRuntimeBridge;
1205
+ else
1206
+ delete process.env.OMX_RUNTIME_BRIDGE;
789
1207
  await rm(wd, { recursive: true, force: true });
790
1208
  }
791
1209
  });
@@ -794,18 +1212,20 @@ describe('notify-fallback watcher', () => {
794
1212
  const fakeBinDir = join(wd, 'fake-bin');
795
1213
  const tmuxLogPath = join(wd, 'tmux.log');
796
1214
  const captureFile = join(wd, 'capture.txt');
1215
+ const previousRuntimeBridge = process.env.OMX_RUNTIME_BRIDGE;
797
1216
  try {
1217
+ process.env.OMX_RUNTIME_BRIDGE = '0';
798
1218
  await mkdir(fakeBinDir, { recursive: true });
799
1219
  await writeFile(join(fakeBinDir, 'tmux'), buildFakeTmux(tmuxLogPath));
800
1220
  await chmod(join(fakeBinDir, 'tmux'), 0o755);
801
- await writeFile(captureFile, 'dispatch ping');
1221
+ await writeFile(captureFile, '... ping ...');
802
1222
  await initTeamState('dispatch-team', 'task', 'executor', 1, wd);
803
1223
  const queued = await enqueueDispatchRequest('dispatch-team', {
804
1224
  kind: 'inbox',
805
1225
  to_worker: 'worker-1',
806
1226
  worker_index: 1,
807
1227
  pane_id: '%42',
808
- trigger_message: 'dispatch ping',
1228
+ trigger_message: 'ping',
809
1229
  }, wd);
810
1230
  const watcherScript = new URL('../../../dist/scripts/notify-fallback-watcher.js', import.meta.url).pathname;
811
1231
  const notifyHook = new URL('../../../dist/scripts/notify-hook.js', import.meta.url).pathname;
@@ -819,14 +1239,21 @@ describe('notify-fallback watcher', () => {
819
1239
  const second = spawnSync(process.execPath, [watcherScript, '--once', '--cwd', wd, '--notify-script', notifyHook, '--poll-ms', '50', '--dispatch-max-per-tick', '1'], { encoding: 'utf-8', env });
820
1240
  assert.equal(second.status, 0, second.stderr || second.stdout);
821
1241
  const tmuxLog = await readFile(tmuxLogPath, 'utf8');
822
- const typeMatches = tmuxLog.match(/send-keys -t %42 -l dispatch ping/g) || [];
823
- assert.equal(typeMatches.length, 1, 'watcher retries should be submit-only when draft remains visible');
1242
+ const typeMatches = tmuxLog.match(/send-keys -t %42 -l ping/g) || [];
1243
+ assert.equal(typeMatches.length, 1, 'fresh attempt should type once; retries with draft should be submit-only');
1244
+ const cmMatches = tmuxLog.match(/send-keys -t %42 C-m/g) || [];
1245
+ assert.ok(cmMatches.length > 0, 'submit should use C-m');
824
1246
  assert.ok(!/send-keys[^\n]*-l[^\n]*C-m/.test(tmuxLog), 'must keep -l payload and C-m submits isolated');
825
1247
  const request = await readDispatchRequest('dispatch-team', queued.request.request_id, wd);
826
1248
  assert.equal(request?.status, 'pending');
1249
+ assert.equal(request?.attempt_count, 2);
827
1250
  assert.equal(request?.last_reason, 'tmux_send_keys_unconfirmed');
828
1251
  }
829
1252
  finally {
1253
+ if (typeof previousRuntimeBridge === 'string')
1254
+ process.env.OMX_RUNTIME_BRIDGE = previousRuntimeBridge;
1255
+ else
1256
+ delete process.env.OMX_RUNTIME_BRIDGE;
830
1257
  await rm(wd, { recursive: true, force: true });
831
1258
  }
832
1259
  });
@@ -989,6 +1416,7 @@ describe('notify-fallback watcher', () => {
989
1416
  }, null, 2));
990
1417
  await writeFile(statePath, JSON.stringify({
991
1418
  ralph_continue_steer: {
1419
+ pane_id: '%7',
992
1420
  last_sent_at: new Date(Date.now() - 61_000).toISOString(),
993
1421
  },
994
1422
  }, null, 2));
@@ -1002,6 +1430,7 @@ describe('notify-fallback watcher', () => {
1002
1430
  assert.equal(missingRun.status, 0, missingRun.stderr || missingRun.stdout);
1003
1431
  let watcherState = JSON.parse(await readFile(statePath, 'utf-8'));
1004
1432
  assert.equal(watcherState.ralph_continue_steer?.last_reason, 'progress_missing');
1433
+ assert.equal(watcherState.ralph_continue_steer?.pane_id, '%42');
1005
1434
  await writeFile(join(stateDir, 'hud-state.json'), JSON.stringify({
1006
1435
  last_progress_at: 'not-a-date',
1007
1436
  }, null, 2));
@@ -1009,6 +1438,7 @@ describe('notify-fallback watcher', () => {
1009
1438
  assert.equal(invalidRun.status, 0, invalidRun.stderr || invalidRun.stdout);
1010
1439
  watcherState = JSON.parse(await readFile(statePath, 'utf-8'));
1011
1440
  assert.equal(watcherState.ralph_continue_steer?.last_reason, 'progress_invalid');
1441
+ assert.equal(watcherState.ralph_continue_steer?.pane_id, '%42');
1012
1442
  const tmuxLog = await readFile(tmuxLogPath, 'utf8').catch(() => '');
1013
1443
  const sends = tmuxLog.match(/send-keys -t %42 -l Ralph loop active continue \[OMX_TMUX_INJECT\]/g) || [];
1014
1444
  assert.equal(sends.length, 0, 'missing or invalid progress should fail closed without sending steer');
@@ -1260,7 +1690,9 @@ describe('notify-fallback watcher', () => {
1260
1690
  const wd = await mkdtemp(join(tmpdir(), 'omx-fallback-control-plane-split-'));
1261
1691
  const fakeBinDir = join(wd, 'fake-bin');
1262
1692
  const tmuxLogPath = join(wd, 'tmux.log');
1693
+ const previousRuntimeBridge = process.env.OMX_RUNTIME_BRIDGE;
1263
1694
  try {
1695
+ process.env.OMX_RUNTIME_BRIDGE = '0';
1264
1696
  await mkdir(join(wd, '.omx', 'state'), { recursive: true });
1265
1697
  await mkdir(fakeBinDir, { recursive: true });
1266
1698
  await writeFile(join(fakeBinDir, 'tmux'), buildFakeTmux(tmuxLogPath, {
@@ -1313,6 +1745,10 @@ describe('notify-fallback watcher', () => {
1313
1745
  assert.ok(ralphFailureEvent, 'expected Ralph failure to be logged without aborting team control-plane pumping');
1314
1746
  }
1315
1747
  finally {
1748
+ if (typeof previousRuntimeBridge === 'string')
1749
+ process.env.OMX_RUNTIME_BRIDGE = previousRuntimeBridge;
1750
+ else
1751
+ delete process.env.OMX_RUNTIME_BRIDGE;
1316
1752
  await rm(wd, { recursive: true, force: true });
1317
1753
  }
1318
1754
  });
@@ -1322,7 +1758,9 @@ describe('notify-fallback watcher', () => {
1322
1758
  const tmuxLogPath = join(wd, 'tmux.log');
1323
1759
  const captureSeqFile = join(wd, 'capture-seq.txt');
1324
1760
  const captureCounterFile = join(wd, 'capture-seq.idx');
1761
+ const previousRuntimeBridge = process.env.OMX_RUNTIME_BRIDGE;
1325
1762
  try {
1763
+ process.env.OMX_RUNTIME_BRIDGE = '0';
1326
1764
  await mkdir(fakeBinDir, { recursive: true });
1327
1765
  await writeFile(join(fakeBinDir, 'tmux'), buildFakeTmux(tmuxLogPath));
1328
1766
  await chmod(join(fakeBinDir, 'tmux'), 0o755);
@@ -1331,11 +1769,11 @@ describe('notify-fallback watcher', () => {
1331
1769
  // (no trigger) so the request is retyped on every retry.
1332
1770
  await writeFile(captureSeqFile, [
1333
1771
  // Run 1 (attempt 0): 1 shared preflight + 3 verify rounds × 2 captures = 7
1334
- 'ready', 'dispatch ping', 'dispatch ping', 'dispatch ping', 'dispatch ping', 'dispatch ping', 'dispatch ping',
1772
+ 'ready', 'ping', 'ping', 'ping', 'ping', 'ping', 'ping',
1335
1773
  // Run 2 (attempt 1): 1 shared preflight + 1 pre-capture + 3 verify rounds × 2 captures = 8
1336
- 'ready', 'ready', 'dispatch ping', 'dispatch ping', 'dispatch ping', 'dispatch ping', 'dispatch ping', 'dispatch ping',
1774
+ 'ready', 'ready', 'ping', 'ping', 'ping', 'ping', 'ping', 'ping',
1337
1775
  // Run 3 (attempt 2): 1 shared preflight + 1 pre-capture + 3 verify rounds × 2 captures = 8
1338
- 'ready', 'ready', 'dispatch ping', 'dispatch ping', 'dispatch ping', 'dispatch ping', 'dispatch ping', 'dispatch ping',
1776
+ 'ready', 'ready', 'ping', 'ping', 'ping', 'ping', 'ping', 'ping',
1339
1777
  ].join('\n'));
1340
1778
  await initTeamState('dispatch-team', 'task', 'executor', 1, wd);
1341
1779
  const queued = await enqueueDispatchRequest('dispatch-team', {
@@ -1343,7 +1781,7 @@ describe('notify-fallback watcher', () => {
1343
1781
  to_worker: 'worker-1',
1344
1782
  worker_index: 1,
1345
1783
  pane_id: '%42',
1346
- trigger_message: 'dispatch ping',
1784
+ trigger_message: 'ping',
1347
1785
  }, wd);
1348
1786
  const watcherScript = new URL('../../../dist/scripts/notify-fallback-watcher.js', import.meta.url).pathname;
1349
1787
  const notifyHook = new URL('../../../dist/scripts/notify-hook.js', import.meta.url).pathname;
@@ -1358,13 +1796,17 @@ describe('notify-fallback watcher', () => {
1358
1796
  assert.equal(run.status, 0, run.stderr || run.stdout);
1359
1797
  }
1360
1798
  const tmuxLog = await readFile(tmuxLogPath, 'utf8');
1361
- const typeMatches = tmuxLog.match(/send-keys -t %42 -l dispatch ping/g) || [];
1362
- assert.equal(typeMatches.length, 3, 'initial + retype on every retry when trigger absent from narrow area');
1799
+ const typeMatches = tmuxLog.match(/send-keys -t %42 -l ping/g) || [];
1800
+ assert.equal(typeMatches.length, 3, 'should retype on every retry when trigger not in narrow capture (fresh + 2 retries)');
1363
1801
  const request = await readDispatchRequest('dispatch-team', queued.request.request_id, wd);
1364
1802
  assert.equal(request?.status, 'failed');
1365
1803
  assert.equal(request?.last_reason, 'unconfirmed_after_max_retries');
1366
1804
  }
1367
1805
  finally {
1806
+ if (typeof previousRuntimeBridge === 'string')
1807
+ process.env.OMX_RUNTIME_BRIDGE = previousRuntimeBridge;
1808
+ else
1809
+ delete process.env.OMX_RUNTIME_BRIDGE;
1368
1810
  await rm(wd, { recursive: true, force: true });
1369
1811
  }
1370
1812
  });
@@ -1860,5 +2302,45 @@ describe('notify-fallback watcher', () => {
1860
2302
  await rm(tempHome, { recursive: true, force: true });
1861
2303
  }
1862
2304
  });
2305
+ it('keeps the detached helper alive after the hidden bootstrap exits', async () => {
2306
+ const wd = await mkdtemp(join(tmpdir(), 'omx-fallback-bootstrap-survival-'));
2307
+ const readyPath = join(wd, 'helper-ready.json');
2308
+ const helperScriptPath = join(wd, 'helper-survival.js');
2309
+ try {
2310
+ await writeFile(helperScriptPath, `
2311
+ const fs = require('node:fs');
2312
+ const readyPath = process.argv[2];
2313
+ fs.writeFileSync(readyPath, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }));
2314
+ setInterval(() => {}, 1000);
2315
+ `);
2316
+ const bootstrap = spawnSync(process.execPath, [
2317
+ '-e',
2318
+ buildWindowsMsysBackgroundHelperBootstrapScript([helperScriptPath, readyPath], wd),
2319
+ ], {
2320
+ cwd: wd,
2321
+ encoding: 'utf-8',
2322
+ stdio: ['ignore', 'pipe', 'pipe'],
2323
+ windowsHide: true,
2324
+ });
2325
+ assert.equal(bootstrap.status, 0, bootstrap.stderr || bootstrap.stdout);
2326
+ const helperPid = Number.parseInt((bootstrap.stdout || '').trim(), 10);
2327
+ assert.ok(Number.isFinite(helperPid) && helperPid > 0, 'expected detached helper pid from bootstrap');
2328
+ await waitFor(async () => {
2329
+ try {
2330
+ const ready = JSON.parse(await readFile(readyPath, 'utf-8'));
2331
+ return ready.pid === helperPid;
2332
+ }
2333
+ catch {
2334
+ return false;
2335
+ }
2336
+ }, 4000, 50);
2337
+ assert.ok(isPidAlive(helperPid), 'expected detached helper to survive after bootstrap exit');
2338
+ process.kill(helperPid, 'SIGTERM');
2339
+ await waitFor(async () => !isPidAlive(helperPid), 4000, 50);
2340
+ }
2341
+ finally {
2342
+ await rm(wd, { recursive: true, force: true });
2343
+ }
2344
+ });
1863
2345
  });
1864
2346
  //# sourceMappingURL=notify-fallback-watcher.test.js.map