oh-my-codex 0.12.0 → 0.12.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 (89) hide show
  1. package/Cargo.lock +5 -5
  2. package/Cargo.toml +1 -1
  3. package/README.md +7 -0
  4. package/dist/cli/__tests__/cleanup.test.js +15 -0
  5. package/dist/cli/__tests__/cleanup.test.js.map +1 -1
  6. package/dist/cli/__tests__/index.test.js +147 -2
  7. package/dist/cli/__tests__/index.test.js.map +1 -1
  8. package/dist/cli/__tests__/launch-fallback.test.js +95 -1
  9. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  10. package/dist/cli/cleanup.d.ts.map +1 -1
  11. package/dist/cli/cleanup.js +6 -3
  12. package/dist/cli/cleanup.js.map +1 -1
  13. package/dist/cli/index.d.ts +18 -1
  14. package/dist/cli/index.d.ts.map +1 -1
  15. package/dist/cli/index.js +178 -23
  16. package/dist/cli/index.js.map +1 -1
  17. package/dist/hooks/__tests__/agents-overlay.test.js +18 -0
  18. package/dist/hooks/__tests__/agents-overlay.test.js.map +1 -1
  19. package/dist/hooks/__tests__/keyword-detector.test.js +156 -1
  20. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  21. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +56 -2
  22. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
  23. package/dist/hooks/agents-overlay.d.ts.map +1 -1
  24. package/dist/hooks/agents-overlay.js +25 -3
  25. package/dist/hooks/agents-overlay.js.map +1 -1
  26. package/dist/hooks/keyword-detector.d.ts +5 -1
  27. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  28. package/dist/hooks/keyword-detector.js +104 -6
  29. package/dist/hooks/keyword-detector.js.map +1 -1
  30. package/dist/hud/__tests__/render.test.js +6 -0
  31. package/dist/hud/__tests__/render.test.js.map +1 -1
  32. package/dist/hud/__tests__/state.test.js +55 -1
  33. package/dist/hud/__tests__/state.test.js.map +1 -1
  34. package/dist/hud/render.d.ts.map +1 -1
  35. package/dist/hud/render.js +8 -3
  36. package/dist/hud/render.js.map +1 -1
  37. package/dist/hud/state.d.ts.map +1 -1
  38. package/dist/hud/state.js +71 -9
  39. package/dist/hud/state.js.map +1 -1
  40. package/dist/hud/types.d.ts +2 -2
  41. package/dist/hud/types.d.ts.map +1 -1
  42. package/dist/mcp/__tests__/state-server.test.js +82 -0
  43. package/dist/mcp/__tests__/state-server.test.js.map +1 -1
  44. package/dist/mcp/state-server.d.ts +4 -4
  45. package/dist/mcp/state-server.d.ts.map +1 -1
  46. package/dist/mcp/state-server.js +35 -0
  47. package/dist/mcp/state-server.js.map +1 -1
  48. package/dist/scripts/__tests__/codex-native-hook.test.js +336 -6
  49. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  50. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  51. package/dist/scripts/codex-native-hook.js +221 -23
  52. package/dist/scripts/codex-native-hook.js.map +1 -1
  53. package/dist/scripts/notify-fallback-watcher.js +60 -3
  54. package/dist/scripts/notify-fallback-watcher.js.map +1 -1
  55. package/dist/state/__tests__/skill-active.test.d.ts +2 -0
  56. package/dist/state/__tests__/skill-active.test.d.ts.map +1 -0
  57. package/dist/state/__tests__/skill-active.test.js +84 -0
  58. package/dist/state/__tests__/skill-active.test.js.map +1 -0
  59. package/dist/state/skill-active.d.ts +55 -0
  60. package/dist/state/skill-active.d.ts.map +1 -0
  61. package/dist/state/skill-active.js +179 -0
  62. package/dist/state/skill-active.js.map +1 -0
  63. package/dist/team/__tests__/runtime-cli.test.js +85 -0
  64. package/dist/team/__tests__/runtime-cli.test.js.map +1 -1
  65. package/dist/team/__tests__/runtime.test.js +241 -7
  66. package/dist/team/__tests__/runtime.test.js.map +1 -1
  67. package/dist/team/__tests__/state.test.js +7 -0
  68. package/dist/team/__tests__/state.test.js.map +1 -1
  69. package/dist/team/__tests__/tmux-session.test.js +143 -0
  70. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  71. package/dist/team/runtime-cli.d.ts +15 -0
  72. package/dist/team/runtime-cli.d.ts.map +1 -1
  73. package/dist/team/runtime-cli.js +37 -20
  74. package/dist/team/runtime-cli.js.map +1 -1
  75. package/dist/team/runtime.d.ts +1 -0
  76. package/dist/team/runtime.d.ts.map +1 -1
  77. package/dist/team/runtime.js +16 -8
  78. package/dist/team/runtime.js.map +1 -1
  79. package/dist/team/state/mailbox.d.ts.map +1 -1
  80. package/dist/team/state/mailbox.js +6 -0
  81. package/dist/team/state/mailbox.js.map +1 -1
  82. package/dist/team/tmux-session.d.ts.map +1 -1
  83. package/dist/team/tmux-session.js +32 -4
  84. package/dist/team/tmux-session.js.map +1 -1
  85. package/package.json +1 -1
  86. package/prompts/information-architect.md +15 -102
  87. package/src/scripts/__tests__/codex-native-hook.test.ts +421 -6
  88. package/src/scripts/codex-native-hook.ts +287 -25
  89. package/src/scripts/notify-fallback-watcher.ts +62 -5
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from "child_process";
2
2
  import { existsSync, readFileSync } from "fs";
3
3
  import { mkdir, readFile, readdir, writeFile } from "fs/promises";
4
- import { join } from "path";
4
+ import { join, resolve } from "path";
5
5
  import { readModeState } from "../modes/base.js";
6
6
  import { getReadScopedStateDirs } from "../mcp/state-paths.js";
7
7
  import { readSubagentSessionSummary } from "../subagents/tracker.js";
@@ -15,8 +15,10 @@ import {
15
15
  } from "../hooks/keyword-detector.js";
16
16
  import {
17
17
  detectStallPattern,
18
+ isDeepInterviewInputLockActive,
18
19
  isDeepInterviewStateActive,
19
20
  loadAutoNudgeConfig,
21
+ normalizeAutoNudgeSignatureText,
20
22
  } from "./notify-hook/auto-nudge.js";
21
23
  import {
22
24
  buildNativePostToolUseOutput,
@@ -53,6 +55,8 @@ export interface NativeHookDispatchResult {
53
55
  const TERMINAL_RALPH_PHASES = new Set(["complete", "failed", "cancelled"]);
54
56
  const TERMINAL_MODE_PHASES = new Set(["complete", "failed", "cancelled"]);
55
57
  const SKILL_STOP_BLOCKERS = new Set(["ralplan", "deep-interview"]);
58
+ const TEAM_TERMINAL_TASK_STATUSES = new Set(["completed", "failed"]);
59
+ const NATIVE_STOP_STATE_FILE = "native-stop-state.json";
56
60
 
57
61
  function safeString(value: unknown): string {
58
62
  return typeof value === "string" ? value : "";
@@ -420,14 +424,118 @@ async function buildSessionStartContext(
420
424
  return sections.join("\n\n");
421
425
  }
422
426
 
423
- function buildAdditionalContextMessage(prompt: string): string | null {
427
+ function buildAdditionalContextMessage(prompt: string, skillState?: SkillActiveState | null): string | null {
424
428
  if (!prompt) return null;
425
429
  const match = detectPrimaryKeyword(prompt);
426
430
  if (!match) return null;
427
431
 
432
+ if (skillState?.initialized_mode && skillState.initialized_state_path) {
433
+ return [
434
+ `OMX native UserPromptSubmit detected workflow keyword "${match.keyword}" -> ${match.skill}.`,
435
+ `skill: ${skillState.initialized_mode} activated and initial state initialized at ${skillState.initialized_state_path}; write subsequent updates via omx_state MCP.`,
436
+ "Follow AGENTS.md routing and preserve ralplan/ralph execution gates.",
437
+ ].join(" ");
438
+ }
439
+
428
440
  return `OMX native UserPromptSubmit detected workflow keyword "${match.keyword}" -> ${match.skill}. Follow AGENTS.md routing and preserve ralplan/ralph execution gates.`;
429
441
  }
430
442
 
443
+ function parseTeamWorkerEnv(rawValue: string): { teamName: string; workerName: string } | null {
444
+ const match = /^([a-z0-9][a-z0-9-]{0,29})\/(worker-\d+)$/.exec(rawValue.trim());
445
+ if (!match) return null;
446
+ return {
447
+ teamName: match[1] || "",
448
+ workerName: match[2] || "",
449
+ };
450
+ }
451
+
452
+ async function readTeamStateRootFromJson(path: string): Promise<string | null> {
453
+ const parsed = await readJsonIfExists(path);
454
+ const value = safeString(parsed?.team_state_root).trim();
455
+ return value || null;
456
+ }
457
+
458
+ async function resolveTeamStateDirForWorkerContext(
459
+ cwd: string,
460
+ workerContext: { teamName: string; workerName: string },
461
+ ): Promise<string> {
462
+ const explicitStateRoot = safeString(process.env.OMX_TEAM_STATE_ROOT).trim();
463
+ if (explicitStateRoot) {
464
+ return resolve(cwd, explicitStateRoot);
465
+ }
466
+
467
+ const leaderCwd = safeString(process.env.OMX_TEAM_LEADER_CWD).trim();
468
+ const candidateStateDirs = [
469
+ ...(leaderCwd ? [join(resolve(leaderCwd), ".omx", "state")] : []),
470
+ join(cwd, ".omx", "state"),
471
+ ];
472
+
473
+ for (const candidateStateDir of candidateStateDirs) {
474
+ const teamRoot = join(candidateStateDir, "team", workerContext.teamName);
475
+ if (!existsSync(teamRoot)) continue;
476
+
477
+ const identityRoot = await readTeamStateRootFromJson(
478
+ join(teamRoot, "workers", workerContext.workerName, "identity.json"),
479
+ );
480
+ if (identityRoot) return resolve(cwd, identityRoot);
481
+
482
+ const manifestRoot = await readTeamStateRootFromJson(join(teamRoot, "manifest.v2.json"));
483
+ if (manifestRoot) return resolve(cwd, manifestRoot);
484
+
485
+ const configRoot = await readTeamStateRootFromJson(join(teamRoot, "config.json"));
486
+ if (configRoot) return resolve(cwd, configRoot);
487
+
488
+ return candidateStateDir;
489
+ }
490
+
491
+ return join(cwd, ".omx", "state");
492
+ }
493
+
494
+ async function buildTeamWorkerStopOutput(
495
+ cwd: string,
496
+ ): Promise<Record<string, unknown> | null> {
497
+ const workerContext = parseTeamWorkerEnv(safeString(process.env.OMX_TEAM_WORKER));
498
+ if (!workerContext) return null;
499
+
500
+ const stateDir = await resolveTeamStateDirForWorkerContext(cwd, workerContext);
501
+ const workerRoot = join(stateDir, "team", workerContext.teamName, "workers", workerContext.workerName);
502
+ const [identity, status] = await Promise.all([
503
+ readJsonIfExists(join(workerRoot, "identity.json")),
504
+ readJsonIfExists(join(workerRoot, "status.json")),
505
+ ]);
506
+
507
+ const candidateTaskIds = new Set<string>();
508
+ const currentTaskId = safeString(status?.current_task_id).trim();
509
+ if (currentTaskId) candidateTaskIds.add(currentTaskId);
510
+ const assignedTasks = Array.isArray(identity?.assigned_tasks) ? identity?.assigned_tasks : [];
511
+ for (const taskId of assignedTasks) {
512
+ const normalized = safeString(taskId).trim();
513
+ if (normalized) candidateTaskIds.add(normalized);
514
+ }
515
+
516
+ for (const taskId of candidateTaskIds) {
517
+ const task = await readJsonIfExists(
518
+ join(stateDir, "team", workerContext.teamName, "tasks", `task-${taskId}.json`),
519
+ );
520
+ const statusValue = safeString(task?.status).trim().toLowerCase();
521
+ if (!statusValue || TEAM_TERMINAL_TASK_STATUSES.has(statusValue)) continue;
522
+ return {
523
+ decision: "block",
524
+ reason:
525
+ `OMX team worker ${workerContext.workerName} is still assigned non-terminal task ${taskId} (${statusValue}); continue the current assigned task or report a concrete blocker before stopping.`,
526
+ stopReason: `team_worker_${workerContext.workerName}_${taskId}_${statusValue}`,
527
+ systemMessage:
528
+ `OMX team worker ${workerContext.workerName} is still assigned task ${taskId} (${statusValue}).`,
529
+ };
530
+ }
531
+
532
+ return null;
533
+ }
534
+
535
+ function hasTeamWorkerContext(): boolean {
536
+ return parseTeamWorkerEnv(safeString(process.env.OMX_TEAM_WORKER)) !== null;
537
+ }
538
+
431
539
  function isStopExempt(payload: CodexHookPayload): boolean {
432
540
  const candidates = [
433
541
  payload.stop_reason,
@@ -469,15 +577,145 @@ async function buildTeamStopOutput(cwd: string): Promise<Record<string, unknown>
469
577
  const coarsePhase = teamState.current_phase;
470
578
  const canonicalPhase = teamName ? (await readTeamPhase(teamName, cwd))?.current_phase ?? coarsePhase : coarsePhase;
471
579
  if (!isNonTerminalPhase(canonicalPhase)) return null;
472
- const phase = formatPhase(canonicalPhase);
580
+ return buildTeamStopOutputForPhase(teamName, formatPhase(canonicalPhase));
581
+ }
582
+
583
+ function buildTeamStopReason(teamName: string, phase: string): string {
584
+ const teamContext = teamName ? ` (${teamName})` : "";
585
+ return `OMX team pipeline is still active${teamContext} at phase ${phase}; continue coordinating until the team reaches a terminal phase. If system-generated worker auto-checkpoint commits exist, rewrite them into Lore-format final commits before merge/finalization.`;
586
+ }
587
+
588
+ function buildTeamStopOutputForPhase(teamName: string, phase: string): Record<string, unknown> {
473
589
  return {
474
590
  decision: "block",
475
- reason: `OMX team pipeline is still active${teamName ? ` (${teamName})` : ""} at phase ${phase}; continue coordinating until the team reaches a terminal phase.`,
591
+ reason: buildTeamStopReason(teamName, phase),
476
592
  stopReason: `team_${phase}`,
477
593
  systemMessage: `OMX team pipeline is still active at phase ${phase}.`,
478
594
  };
479
595
  }
480
596
 
597
+ function readPayloadSessionId(payload: CodexHookPayload): string {
598
+ return safeString(payload.session_id ?? payload.sessionId).trim();
599
+ }
600
+
601
+ function readPayloadThreadId(payload: CodexHookPayload): string {
602
+ return safeString(payload.thread_id ?? payload.threadId).trim();
603
+ }
604
+
605
+ function readPayloadTurnId(payload: CodexHookPayload): string {
606
+ return safeString(payload.turn_id ?? payload.turnId).trim();
607
+ }
608
+
609
+ async function isDeepInterviewSuppressedForStop(
610
+ cwd: string,
611
+ stateDir: string,
612
+ sessionId: string,
613
+ ): Promise<boolean> {
614
+ if (await isDeepInterviewStateActive(stateDir)) return true;
615
+ if (await isDeepInterviewInputLockActive(stateDir)) return true;
616
+
617
+ const scopedModeState = sessionId
618
+ ? await readScopedJsonState("deep-interview-state.json", cwd, sessionId)
619
+ : null;
620
+ if (scopedModeState?.active === true) return true;
621
+
622
+ const scopedSkillState = sessionId
623
+ ? await readScopedJsonState("skill-active-state.json", cwd, sessionId)
624
+ : null;
625
+ if (!scopedSkillState || scopedSkillState.active !== true) return false;
626
+ return safeString(scopedSkillState.skill).trim() === "deep-interview";
627
+ }
628
+
629
+ function buildRepeatableStopSignature(
630
+ payload: CodexHookPayload,
631
+ kind: string,
632
+ detail = "",
633
+ ): string {
634
+ const sessionId = readPayloadSessionId(payload) || "no-session";
635
+ const threadId = readPayloadThreadId(payload) || "no-thread";
636
+ const turnId = readPayloadTurnId(payload);
637
+ const normalizedDetail = normalizeAutoNudgeSignatureText(detail) || safeString(detail).trim().toLowerCase();
638
+ const transcriptPath = safeString(payload.transcript_path ?? payload.transcriptPath).trim() || "no-transcript";
639
+ const lastAssistantMessage = normalizeAutoNudgeSignatureText(
640
+ payload.last_assistant_message ?? payload.lastAssistantMessage,
641
+ ) || "no-message";
642
+ if (turnId) {
643
+ return [
644
+ kind,
645
+ sessionId,
646
+ threadId,
647
+ turnId,
648
+ transcriptPath,
649
+ lastAssistantMessage,
650
+ normalizedDetail || "no-detail",
651
+ ].join("|");
652
+ }
653
+ return [
654
+ kind,
655
+ sessionId,
656
+ threadId,
657
+ transcriptPath,
658
+ lastAssistantMessage,
659
+ normalizedDetail || "no-detail",
660
+ ].join("|");
661
+ }
662
+
663
+ async function readNativeStopState(stateDir: string): Promise<Record<string, unknown>> {
664
+ return await readJsonIfExists(join(stateDir, NATIVE_STOP_STATE_FILE)) ?? {};
665
+ }
666
+
667
+ function readNativeStopSessionKey(payload: CodexHookPayload): string {
668
+ return readPayloadSessionId(payload) || readPayloadThreadId(payload) || "global";
669
+ }
670
+
671
+ function readPreviousNativeStopSignature(
672
+ state: Record<string, unknown>,
673
+ sessionKey: string,
674
+ ): string {
675
+ const sessions = safeObject(state.sessions);
676
+ const sessionState = safeObject(sessions[sessionKey]);
677
+ return safeString(sessionState.last_signature).trim();
678
+ }
679
+
680
+ async function persistNativeStopSignature(
681
+ stateDir: string,
682
+ payload: CodexHookPayload,
683
+ signature: string,
684
+ ): Promise<void> {
685
+ if (!signature) return;
686
+ const state = await readNativeStopState(stateDir);
687
+ const sessions = safeObject(state.sessions);
688
+ const sessionKey = readNativeStopSessionKey(payload);
689
+ sessions[sessionKey] = {
690
+ ...safeObject(sessions[sessionKey]),
691
+ last_signature: signature,
692
+ updated_at: new Date().toISOString(),
693
+ };
694
+ await writeFile(join(stateDir, NATIVE_STOP_STATE_FILE), JSON.stringify({
695
+ ...state,
696
+ sessions,
697
+ }, null, 2));
698
+ }
699
+
700
+ async function maybeReturnRepeatableStopOutput(
701
+ payload: CodexHookPayload,
702
+ stateDir: string,
703
+ signature: string,
704
+ output: Record<string, unknown> | null,
705
+ ): Promise<Record<string, unknown> | null> {
706
+ if (!output) return null;
707
+ const stopHookActive = payload.stop_hook_active === true || payload.stopHookActive === true;
708
+ if (stopHookActive) {
709
+ const state = await readNativeStopState(stateDir);
710
+ const previousSignature = readPreviousNativeStopSignature(state, readNativeStopSessionKey(payload));
711
+ if (!signature || previousSignature === signature) {
712
+ return null;
713
+ }
714
+ }
715
+ await persistNativeStopSignature(stateDir, payload, signature);
716
+ return output;
717
+ }
718
+
481
719
  async function findCanonicalActiveTeamForSession(
482
720
  cwd: string,
483
721
  sessionId: string,
@@ -513,9 +751,16 @@ async function findCanonicalActiveTeamForSession(
513
751
  async function buildSkillStopOutput(
514
752
  cwd: string,
515
753
  sessionId: string,
754
+ threadId: string,
516
755
  ): Promise<Record<string, unknown> | null> {
517
756
  const state = await readScopedJsonState("skill-active-state.json", cwd, sessionId);
518
757
  if (!state || state.active !== true) return null;
758
+ const stateSessionId = safeString(state.session_id).trim();
759
+ const stateThreadId = safeString(state.thread_id).trim();
760
+ if (sessionId && stateSessionId && stateSessionId !== sessionId) return null;
761
+ if (sessionId && !stateSessionId && threadId && stateThreadId && stateThreadId !== threadId) {
762
+ return null;
763
+ }
519
764
  const skill = safeString(state.skill).trim();
520
765
  const phase = formatPhase(state.phase, "planning");
521
766
  if (!SKILL_STOP_BLOCKERS.has(skill) || phase === "completing") return null;
@@ -542,10 +787,14 @@ async function buildStopHookOutput(
542
787
  return null;
543
788
  }
544
789
 
545
- const sessionId = safeString(payload.session_id ?? payload.sessionId).trim();
790
+ const sessionId = readPayloadSessionId(payload);
791
+ const threadId = readPayloadThreadId(payload);
546
792
  const ralphState = await readActiveRalphState(stateDir);
547
793
  const stopHookActive = payload.stop_hook_active === true || payload.stopHookActive === true;
548
794
  if (!ralphState) {
795
+ const teamWorkerOutput = await buildTeamWorkerStopOutput(cwd);
796
+ if (!stopHookActive && hasTeamWorkerContext()) return teamWorkerOutput;
797
+
549
798
  const autopilotOutput = await buildModeBasedStopOutput("autopilot", cwd);
550
799
  if (!stopHookActive && autopilotOutput) return autopilotOutput;
551
800
 
@@ -556,42 +805,55 @@ async function buildStopHookOutput(
556
805
  if (!stopHookActive && ultraqaOutput) return ultraqaOutput;
557
806
 
558
807
  const teamOutput = await buildTeamStopOutput(cwd);
559
- if (!stopHookActive && teamOutput) return teamOutput;
808
+ if (teamOutput) {
809
+ const teamSignature = buildRepeatableStopSignature(payload, "team-stop", safeString(teamOutput.stopReason));
810
+ return await maybeReturnRepeatableStopOutput(payload, stateDir, teamSignature, teamOutput);
811
+ }
560
812
 
561
813
  if (sessionId) {
562
814
  const canonicalTeam = await findCanonicalActiveTeamForSession(cwd, sessionId);
563
- if (!stopHookActive && canonicalTeam) {
564
- return {
565
- decision: "block",
566
- reason: `OMX team pipeline is still active (${canonicalTeam.teamName}) at phase ${canonicalTeam.phase}; continue coordinating until the team reaches a terminal phase.`,
567
- stopReason: `team_${canonicalTeam.phase}`,
568
- systemMessage: `OMX team pipeline is still active at phase ${canonicalTeam.phase}.`,
569
- };
815
+ if (canonicalTeam) {
816
+ const canonicalTeamOutput = buildTeamStopOutputForPhase(
817
+ canonicalTeam.teamName,
818
+ canonicalTeam.phase,
819
+ );
820
+ const canonicalTeamSignature = buildRepeatableStopSignature(payload, "team-stop", `${canonicalTeam.teamName}|${canonicalTeam.phase}`);
821
+ const repeatedCanonicalTeamOutput = await maybeReturnRepeatableStopOutput(
822
+ payload,
823
+ stateDir,
824
+ canonicalTeamSignature,
825
+ canonicalTeamOutput,
826
+ );
827
+ if (repeatedCanonicalTeamOutput) return repeatedCanonicalTeamOutput;
570
828
  }
571
829
 
572
- const skillOutput = await buildSkillStopOutput(cwd, sessionId);
830
+ const skillOutput = await buildSkillStopOutput(cwd, sessionId, threadId);
573
831
  if (!stopHookActive && skillOutput) return skillOutput;
574
832
  }
575
833
 
576
- const deepInterviewActive = await isDeepInterviewStateActive(stateDir);
834
+ const deepInterviewActive = await isDeepInterviewSuppressedForStop(cwd, stateDir, sessionId);
577
835
  const lastAssistantMessage = safeString(
578
836
  payload.last_assistant_message ?? payload.lastAssistantMessage,
579
837
  );
580
838
  const autoNudgeConfig = await loadAutoNudgeConfig();
581
839
 
582
840
  if (
583
- !stopHookActive
584
- && !deepInterviewActive
841
+ !deepInterviewActive
585
842
  && autoNudgeConfig.enabled
586
843
  && detectStallPattern(lastAssistantMessage, autoNudgeConfig.patterns)
587
844
  ) {
588
- return {
589
- decision: "block",
590
- reason: autoNudgeConfig.response,
591
- stopReason: "auto_nudge",
592
- systemMessage:
593
- "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
594
- };
845
+ return await maybeReturnRepeatableStopOutput(
846
+ payload,
847
+ stateDir,
848
+ buildRepeatableStopSignature(payload, "auto-nudge", lastAssistantMessage),
849
+ {
850
+ decision: "block",
851
+ reason: autoNudgeConfig.response,
852
+ stopReason: "auto_nudge",
853
+ systemMessage:
854
+ "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
855
+ },
856
+ );
595
857
  }
596
858
 
597
859
  return null;
@@ -667,7 +929,7 @@ export async function dispatchCodexNativeHook(
667
929
  if (hookEventName === "SessionStart" || hookEventName === "UserPromptSubmit") {
668
930
  const additionalContext = hookEventName === "SessionStart"
669
931
  ? await buildSessionStartContext(cwd, sessionId)
670
- : buildAdditionalContextMessage(readPromptText(payload));
932
+ : buildAdditionalContextMessage(readPromptText(payload), skillState);
671
933
  if (additionalContext) {
672
934
  outputJson = {
673
935
  hookSpecificOutput: {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { existsSync } from 'fs';
4
- import { appendFile, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from 'fs/promises';
4
+ import { appendFile, mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'fs/promises';
5
5
  import { spawnSync } from 'child_process';
6
6
  import { dirname, join, resolve } from 'path';
7
7
  import { homedir } from 'os';
@@ -110,6 +110,13 @@ const stateDir = join(omxDir, 'state');
110
110
  const statePath = join(stateDir, 'notify-fallback-state.json');
111
111
  const pidFilePath = resolve(argValue('--pid-file', join(stateDir, 'notify-fallback.pid')));
112
112
  const logPath = join(logsDir, `notify-fallback-${new Date().toISOString().split('T')[0]}.jsonl`);
113
+ const logRotatePath = `${logPath}.1`;
114
+ const logLockPath = `${logPath}.lock`;
115
+ const defaultMaxLogBytes = 10 * 1024 * 1024;
116
+ const maxLogBytes = Math.max(
117
+ 0,
118
+ asNumber(argValue('--log-max-bytes', process.env.OMX_NOTIFY_FALLBACK_LOG_MAX_BYTES || String(defaultMaxLogBytes)), defaultMaxLogBytes),
119
+ );
113
120
  const ralphSteerTimestampPath = join(stateDir, 'ralph-last-steer-at');
114
121
  const ralphSteerLockPath = join(stateDir, 'ralph-continue-steer.lock');
115
122
  const watcherOwnerToken = `${process.pid}-${startedAt}-${Math.random().toString(36).slice(2, 10)}`;
@@ -117,6 +124,7 @@ const RALPH_CONTINUE_TEXT = 'Ralph loop active continue';
117
124
  const RALPH_CONTINUE_CADENCE_MS = 60_000;
118
125
  const RALPH_STEER_LOCK_STALE_MS = 30_000;
119
126
  const RALPH_TERMINAL_PHASES = new Set(['complete', 'failed', 'cancelled']);
127
+ const QUIET_ONCE_EVENT_TYPES = new Set(['watcher_start', 'watcher_once_complete']);
120
128
 
121
129
  interface WatcherFileMeta {
122
130
  threadId: string;
@@ -311,8 +319,57 @@ let adaptivePollState: AdaptivePollState = {
311
319
  last_activity_at: null,
312
320
  last_activity_reason: 'init',
313
321
  };
314
- function eventLog(event: Record<string, unknown>): Promise<void> {
315
- return appendFile(logPath, `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`).catch(() => {});
322
+
323
+ function shouldSuppressEventLog(event: Record<string, unknown>): boolean {
324
+ const eventType = safeString(event.type).trim();
325
+ return runOnce && QUIET_ONCE_EVENT_TYPES.has(eventType);
326
+ }
327
+
328
+ async function acquireLogLock(timeoutMs = 1000): Promise<boolean> {
329
+ const deadline = Date.now() + timeoutMs;
330
+ while (Date.now() < deadline) {
331
+ try {
332
+ await mkdir(logLockPath, { recursive: false });
333
+ return true;
334
+ } catch (error) {
335
+ if ((error as NodeJS.ErrnoException | null)?.code !== 'EEXIST') return false;
336
+ const lockStat = await stat(logLockPath).catch(() => null);
337
+ if (lockStat && Date.now() - lockStat.mtimeMs > 5000) {
338
+ await rm(logLockPath, { recursive: true, force: true }).catch(() => {});
339
+ continue;
340
+ }
341
+ await sleep(10);
342
+ }
343
+ }
344
+ return false;
345
+ }
346
+
347
+ async function releaseLogLock(): Promise<void> {
348
+ await rm(logLockPath, { recursive: true, force: true }).catch(() => {});
349
+ }
350
+
351
+ async function rotateLogIfNeeded(nextEntryBytes: number): Promise<void> {
352
+ if (maxLogBytes <= 0) return;
353
+ const currentStat = await stat(logPath).catch(() => null);
354
+ if (!currentStat || currentStat.size + nextEntryBytes <= maxLogBytes) return;
355
+ await unlink(logRotatePath).catch(() => {});
356
+ await rename(logPath, logRotatePath).catch(() => {});
357
+ }
358
+
359
+ async function eventLog(event: Record<string, unknown>): Promise<void> {
360
+ if (shouldSuppressEventLog(event)) return;
361
+ const line = `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`;
362
+ await mkdir(dirname(logPath), { recursive: true }).catch(() => {});
363
+ const locked = await acquireLogLock();
364
+ if (!locked) return;
365
+ try {
366
+ await rotateLogIfNeeded(Buffer.byteLength(line));
367
+ await appendFile(logPath, line);
368
+ } catch {
369
+ // best effort only
370
+ } finally {
371
+ await releaseLogLock();
372
+ }
316
373
  }
317
374
 
318
375
  function shouldLogLeaderNudgeTick(reason: string): boolean {
@@ -1669,8 +1726,8 @@ async function main(): Promise<void> {
1669
1726
  notify_script: notifyScript,
1670
1727
  authority_only: authorityOnly,
1671
1728
  poll_ms: pollMs,
1672
- effective_poll_ms: adaptivePollState.current_ms,
1673
- idle_max_poll_ms: idleMaxPollMs,
1729
+ effective_poll_ms: adaptivePollState.current_ms,
1730
+ idle_max_poll_ms: idleMaxPollMs,
1674
1731
  once: runOnce,
1675
1732
  parent_pid: parentPid,
1676
1733
  pid_file: runOnce ? null : pidFilePath,