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
@@ -0,0 +1,324 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { readFileSync } from 'fs';
3
+ import { basename, dirname, resolve as resolvePath } from 'path';
4
+ import { readSessionState, isSessionStale } from '../../hooks/session.js';
5
+ import { runProcess } from './process-runner.js';
6
+ import { safeString } from './utils.js';
7
+
8
+ function sanitizeTmuxToken(value: string): string {
9
+ const cleaned = safeString(value)
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, '-')
12
+ .replace(/^-+|-+$/g, '');
13
+ return cleaned || 'unknown';
14
+ }
15
+
16
+ export function buildExpectedManagedTmuxSessionName(cwd: string, sessionId: string): string {
17
+ const parentPath = dirname(cwd);
18
+ const parentDir = basename(parentPath);
19
+ const dirName = basename(cwd);
20
+ const grandparentPath = dirname(parentPath);
21
+ const grandparentDir = basename(grandparentPath);
22
+ const repoDir = parentDir.endsWith('.omx-worktrees')
23
+ ? parentDir.slice(0, -'.omx-worktrees'.length)
24
+ : parentDir === 'worktrees' && grandparentDir === '.omx'
25
+ ? basename(dirname(grandparentPath))
26
+ : null;
27
+ const dirToken = repoDir
28
+ ? sanitizeTmuxToken(`${repoDir}-${dirName}`)
29
+ : sanitizeTmuxToken(dirName);
30
+ let branchToken = 'detached';
31
+ try {
32
+ const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
33
+ cwd,
34
+ encoding: 'utf-8',
35
+ stdio: ['ignore', 'pipe', 'ignore'],
36
+ timeout: 2000,
37
+ windowsHide: true,
38
+ }).trim();
39
+ if (branch) branchToken = sanitizeTmuxToken(branch);
40
+ } catch {
41
+ // best effort only
42
+ }
43
+ const sessionToken = sanitizeTmuxToken(sessionId.replace(/^omx-/, ''));
44
+ const name = `omx-${dirToken}-${branchToken}-${sessionToken}`;
45
+ return name.length > 120 ? name.slice(0, 120) : name;
46
+ }
47
+
48
+ export function resolveInvocationSessionId(payload: any): string {
49
+ return safeString(
50
+ payload?.session_id
51
+ || payload?.['session-id']
52
+ || process.env.OMX_SESSION_ID
53
+ || process.env.CODEX_SESSION_ID
54
+ || process.env.SESSION_ID
55
+ || '',
56
+ ).trim();
57
+ }
58
+
59
+ function readCurrentTmuxSessionName(): string {
60
+ if (!process.env.TMUX) return '';
61
+ try {
62
+ return execFileSync('tmux', ['display-message', '-p', '#S'], {
63
+ encoding: 'utf-8',
64
+ stdio: ['ignore', 'pipe', 'ignore'],
65
+ timeout: 2000,
66
+ }).trim();
67
+ } catch {
68
+ return '';
69
+ }
70
+ }
71
+
72
+ function readParentPid(pid: number): number | null {
73
+ if (!Number.isInteger(pid) || pid <= 1) return null;
74
+ try {
75
+ if (process.platform === 'linux') {
76
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8');
77
+ const commandEnd = stat.lastIndexOf(')');
78
+ if (commandEnd === -1) return null;
79
+ const remainder = stat.slice(commandEnd + 1).trim();
80
+ const fields = remainder.split(/\s+/);
81
+ if (fields.length === 0) return null;
82
+ const ppid = Number(fields[1]);
83
+ return Number.isFinite(ppid) && ppid > 0 ? ppid : null;
84
+ }
85
+ const raw = execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], {
86
+ encoding: 'utf-8',
87
+ timeout: 2000,
88
+ }).trim();
89
+ const ppid = Number(raw);
90
+ return Number.isFinite(ppid) && ppid > 0 ? ppid : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function processHasAncestorPid(targetPid: number, currentPid = process.pid): boolean {
97
+ if (!Number.isInteger(targetPid) || targetPid <= 1) return false;
98
+ let pid = Number.isInteger(currentPid) && currentPid > 1 ? currentPid : process.pid;
99
+ for (let depth = 0; depth < 64 && pid > 1; depth += 1) {
100
+ if (pid === targetPid) return true;
101
+ const parent = readParentPid(pid);
102
+ if (!parent || parent === pid) break;
103
+ pid = parent;
104
+ }
105
+ return false;
106
+ }
107
+
108
+ export async function resolveManagedSessionContext(cwd: string, payload: any, { allowTeamWorker = true } = {}): Promise<any> {
109
+ if (allowTeamWorker && safeString(process.env.OMX_TEAM_WORKER || '').trim() !== '') {
110
+ return {
111
+ managed: true,
112
+ reason: 'team_worker',
113
+ invocationSessionId: '',
114
+ sessionState: null,
115
+ expectedTmuxSessionName: '',
116
+ currentTmuxSessionName: '',
117
+ };
118
+ }
119
+
120
+ const invocationSessionId = resolveInvocationSessionId(payload);
121
+ if (!invocationSessionId) {
122
+ return {
123
+ managed: false,
124
+ reason: 'missing_session_id',
125
+ invocationSessionId: '',
126
+ sessionState: null,
127
+ expectedTmuxSessionName: '',
128
+ currentTmuxSessionName: '',
129
+ };
130
+ }
131
+
132
+ try {
133
+ const sessionState = await readSessionState(cwd);
134
+ if (!sessionState) {
135
+ return { managed: false, reason: 'missing_session_state', invocationSessionId, sessionState: null, expectedTmuxSessionName: '', currentTmuxSessionName: '' };
136
+ }
137
+ if (resolvePath(safeString(sessionState.cwd || cwd)) !== resolvePath(cwd)) {
138
+ return { managed: false, reason: 'cwd_mismatch', invocationSessionId, sessionState, expectedTmuxSessionName: '', currentTmuxSessionName: '' };
139
+ }
140
+ if (safeString(sessionState.session_id).trim() !== invocationSessionId) {
141
+ return { managed: false, reason: 'session_id_mismatch', invocationSessionId, sessionState, expectedTmuxSessionName: '', currentTmuxSessionName: '' };
142
+ }
143
+ if (isSessionStale(sessionState)) {
144
+ return { managed: false, reason: 'stale_session', invocationSessionId, sessionState, expectedTmuxSessionName: '', currentTmuxSessionName: '' };
145
+ }
146
+
147
+ const expectedTmuxSessionName = buildExpectedManagedTmuxSessionName(cwd, invocationSessionId);
148
+ const currentTmuxSessionName = readCurrentTmuxSessionName();
149
+ if (currentTmuxSessionName && currentTmuxSessionName === expectedTmuxSessionName) {
150
+ return {
151
+ managed: true,
152
+ reason: 'tmux_session_match',
153
+ invocationSessionId,
154
+ sessionState,
155
+ expectedTmuxSessionName,
156
+ currentTmuxSessionName,
157
+ };
158
+ }
159
+
160
+ if (processHasAncestorPid(sessionState.pid)) {
161
+ return {
162
+ managed: true,
163
+ reason: currentTmuxSessionName ? 'pid_ancestry_match_tmux_mismatch' : 'pid_ancestry_match',
164
+ invocationSessionId,
165
+ sessionState,
166
+ expectedTmuxSessionName,
167
+ currentTmuxSessionName: '',
168
+ };
169
+ }
170
+
171
+ return {
172
+ managed: false,
173
+ reason: currentTmuxSessionName ? 'tmux_session_mismatch' : 'pid_ancestry_mismatch',
174
+ invocationSessionId,
175
+ sessionState,
176
+ expectedTmuxSessionName,
177
+ currentTmuxSessionName,
178
+ };
179
+ } catch {
180
+ return {
181
+ managed: false,
182
+ reason: 'session_check_failed',
183
+ invocationSessionId,
184
+ sessionState: null,
185
+ expectedTmuxSessionName: '',
186
+ currentTmuxSessionName: '',
187
+ };
188
+ }
189
+ }
190
+
191
+ export async function isManagedOmxSession(cwd: string, payload: any, options: { allowTeamWorker?: boolean } = {}): Promise<boolean> {
192
+ const context = await resolveManagedSessionContext(cwd, payload, options);
193
+ return context.managed === true;
194
+ }
195
+
196
+ export async function verifyManagedPaneTarget(paneId: string, cwd: string, payload: any, { allowTeamWorker = true } = {}): Promise<any> {
197
+ const paneTarget = safeString(paneId).trim();
198
+ if (!paneTarget) {
199
+ return { ok: false, reason: 'missing_pane_target', paneTarget: '' };
200
+ }
201
+
202
+ const managedContext = await resolveManagedSessionContext(cwd, payload, { allowTeamWorker });
203
+ if (!managedContext.managed) {
204
+ return { ok: false, reason: managedContext.reason || 'unmanaged_session', paneTarget, managedContext };
205
+ }
206
+
207
+ if (managedContext.reason === 'team_worker') {
208
+ return { ok: true, reason: 'ok', paneTarget, managedContext };
209
+ }
210
+
211
+ const expectedSession = safeString(managedContext.expectedTmuxSessionName).trim();
212
+ if (!expectedSession) {
213
+ return { ok: false, reason: 'missing_expected_tmux_session', paneTarget, managedContext };
214
+ }
215
+
216
+ try {
217
+ const sessionResult = await runProcess('tmux', ['display-message', '-p', '-t', paneTarget, '#S'], 2000);
218
+ const paneSessionName = safeString(sessionResult.stdout).trim();
219
+ if (!paneSessionName) {
220
+ return { ok: false, reason: 'pane_session_missing', paneTarget, managedContext };
221
+ }
222
+ if (paneSessionName !== expectedSession) {
223
+ return { ok: false, reason: 'pane_not_managed_session', paneTarget, paneSessionName, managedContext };
224
+ }
225
+ return { ok: true, reason: 'ok', paneTarget, paneSessionName, managedContext };
226
+ } catch {
227
+ return { ok: false, reason: 'pane_session_lookup_failed', paneTarget, managedContext };
228
+ }
229
+ }
230
+
231
+
232
+ async function readManagedPaneCommandState(paneTarget: string): Promise<{ currentCommand: string; startCommand: string }> {
233
+ try {
234
+ const [currentResult, startResult] = await Promise.all([
235
+ runProcess('tmux', ['display-message', '-p', '-t', paneTarget, '#{pane_current_command}'], 2000),
236
+ runProcess('tmux', ['display-message', '-p', '-t', paneTarget, '#{pane_start_command}'], 2000),
237
+ ]);
238
+ return {
239
+ currentCommand: safeString(currentResult.stdout).trim().toLowerCase(),
240
+ startCommand: safeString(startResult.stdout).trim().toLowerCase(),
241
+ };
242
+ } catch {
243
+ return { currentCommand: '', startCommand: '' };
244
+ }
245
+ }
246
+
247
+ function paneLooksLikeManagedAgent({ currentCommand, startCommand }: { currentCommand: string; startCommand: string }): boolean {
248
+ if (/\bomx\b.*\bhud\b.*--watch/i.test(startCommand)) return false;
249
+ if (startCommand.includes('codex')) return true;
250
+ return currentCommand === 'codex' || currentCommand === 'node' || currentCommand === 'npx';
251
+ }
252
+ export async function resolveManagedCurrentPane(cwd: string, payload: any, { allowTeamWorker = false } = {}): Promise<string> {
253
+ const paneTarget = safeString(process.env.TMUX_PANE || '').trim();
254
+ if (!paneTarget) return '';
255
+ const verdict = await verifyManagedPaneTarget(paneTarget, cwd, payload, { allowTeamWorker });
256
+ if (!verdict.ok) return '';
257
+ const commandState = await readManagedPaneCommandState(paneTarget);
258
+ return paneLooksLikeManagedAgent(commandState) ? paneTarget : '';
259
+ }
260
+
261
+ export async function resolveManagedSessionPane(cwd: string, payload: any): Promise<string> {
262
+ const managedContext = await resolveManagedSessionContext(cwd, payload, { allowTeamWorker: false });
263
+ if (!managedContext.managed) return '';
264
+ const expectedSession = safeString(managedContext.expectedTmuxSessionName).trim();
265
+ if (!expectedSession) return '';
266
+
267
+ try {
268
+ const panesResult = await runProcess(
269
+ 'tmux',
270
+ ['list-panes', '-s', '-t', expectedSession, '-F', '#{pane_id}\t#{pane_current_command}\t#{pane_start_command}'],
271
+ 2000,
272
+ );
273
+ const panes = safeString(panesResult.stdout)
274
+ .trim()
275
+ .split('\n')
276
+ .filter(Boolean);
277
+ for (const line of panes) {
278
+ const [candidatePaneId, rawCurrentCommand = '', rawStartCommand = ''] = line.split('\t');
279
+ const startCommand = safeString(rawStartCommand).toLowerCase();
280
+ const currentCommand = safeString(rawCurrentCommand).trim().toLowerCase();
281
+ if (!candidatePaneId) continue;
282
+ if (/\bomx\b.*\bhud\b.*--watch/i.test(startCommand)) continue;
283
+ if (startCommand.includes('codex')) return candidatePaneId;
284
+ if (currentCommand === 'codex') return candidatePaneId;
285
+ }
286
+ } catch {
287
+ // best effort only
288
+ }
289
+
290
+ return '';
291
+ }
292
+
293
+ export async function resolveManagedPaneFromAnchor(anchorPane: string, cwd: string, payload: any, { allowTeamWorker = false } = {}): Promise<string> {
294
+ const paneTarget = safeString(anchorPane).trim();
295
+ if (!paneTarget) return '';
296
+ const verdict = await verifyManagedPaneTarget(paneTarget, cwd, payload, { allowTeamWorker });
297
+ if (!verdict.ok) return '';
298
+
299
+ try {
300
+ const sessionResult = await runProcess('tmux', ['display-message', '-p', '-t', paneTarget, '#S'], 2000);
301
+ const sessionName = safeString(sessionResult.stdout).trim();
302
+ if (!sessionName) return paneTarget;
303
+
304
+ const panesResult = await runProcess(
305
+ 'tmux',
306
+ ['list-panes', '-s', '-t', sessionName, '-F', '#{pane_id}\t#{pane_current_command}\t#{pane_start_command}'],
307
+ 2000,
308
+ );
309
+ const panes = safeString(panesResult.stdout).trim().split('\n').filter(Boolean);
310
+ for (const line of panes) {
311
+ const [candidatePaneId, rawCurrentCommand = '', rawStartCommand = ''] = line.split('\t');
312
+ const startCommand = safeString(rawStartCommand).toLowerCase();
313
+ const currentCommand = safeString(rawCurrentCommand).trim().toLowerCase();
314
+ if (!candidatePaneId) continue;
315
+ if (/\bomx\b.*\bhud\b.*--watch/i.test(startCommand)) continue;
316
+ if (startCommand.includes('codex')) return candidatePaneId;
317
+ if (currentCommand === 'codex') return candidatePaneId;
318
+ }
319
+ } catch {
320
+ // best effort only
321
+ }
322
+
323
+ return paneTarget;
324
+ }
@@ -0,0 +1,337 @@
1
+ import { existsSync } from 'fs';
2
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'fs/promises';
3
+ import { dirname, join } from 'path';
4
+ import { captureTmuxPaneFromEnv } from '../../state/mode-state-context.js';
5
+ import { resolveCodexPane } from '../tmux-hook-engine.js';
6
+ import { safeString } from './utils.js';
7
+
8
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
9
+ const RALPH_TERMINAL_PHASES = new Set(['complete', 'failed', 'cancelled']);
10
+ const RALPH_RESUME_LOCK_STALE_MS = 10_000;
11
+ const RALPH_RESUME_LOCK_TIMEOUT_MS = 5_000;
12
+ const RALPH_RESUME_LOCK_RETRY_MS = 25;
13
+
14
+ interface RalphSessionResumeHooks {
15
+ afterLockAcquired?: () => Promise<void> | void;
16
+ afterTargetWrite?: () => Promise<void> | void;
17
+ }
18
+
19
+ interface RalphSessionResumeParams {
20
+ stateDir: string;
21
+ payloadSessionId: string;
22
+ payloadThreadId?: string;
23
+ env?: NodeJS.ProcessEnv;
24
+ hooks?: RalphSessionResumeHooks;
25
+ }
26
+
27
+ export interface RalphSessionResumeResult {
28
+ currentOmxSessionId: string;
29
+ resumed: boolean;
30
+ updatedCurrentOwner: boolean;
31
+ reason: string;
32
+ sourcePath?: string;
33
+ targetPath?: string;
34
+ }
35
+
36
+ interface RalphStateCandidate {
37
+ sessionId: string;
38
+ path: string;
39
+ state: Record<string, unknown>;
40
+ }
41
+
42
+ function lockOwnerToken(): string {
43
+ return `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
44
+ }
45
+
46
+ async function sleep(ms: number): Promise<void> {
47
+ await new Promise((resolve) => setTimeout(resolve, ms));
48
+ }
49
+
50
+ async function maybeRecoverStaleLock(lockDir: string): Promise<boolean> {
51
+ try {
52
+ const info = await stat(lockDir);
53
+ if (Date.now() - info.mtimeMs <= RALPH_RESUME_LOCK_STALE_MS) {
54
+ return false;
55
+ }
56
+ await rm(lockDir, { recursive: true, force: true });
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ async function withRalphResumeLock<T>(
64
+ stateDir: string,
65
+ fn: () => Promise<T>,
66
+ ): Promise<T | null> {
67
+ const lockDir = join(stateDir, '.lock.ralph-session-resume');
68
+ const ownerPath = join(lockDir, 'owner');
69
+ const ownerToken = lockOwnerToken();
70
+ const deadline = Date.now() + RALPH_RESUME_LOCK_TIMEOUT_MS;
71
+ await mkdir(dirname(lockDir), { recursive: true }).catch(() => {});
72
+
73
+ while (true) {
74
+ try {
75
+ await mkdir(lockDir, { recursive: false });
76
+ try {
77
+ await writeFile(ownerPath, ownerToken, 'utf8');
78
+ } catch (error) {
79
+ await rm(lockDir, { recursive: true, force: true }).catch(() => {});
80
+ throw error;
81
+ }
82
+ break;
83
+ } catch (error) {
84
+ const err = error as NodeJS.ErrnoException;
85
+ if (err.code !== 'EEXIST') throw error;
86
+ if (await maybeRecoverStaleLock(lockDir)) continue;
87
+ if (Date.now() > deadline) return null;
88
+ await sleep(RALPH_RESUME_LOCK_RETRY_MS);
89
+ }
90
+ }
91
+
92
+ try {
93
+ return await fn();
94
+ } finally {
95
+ try {
96
+ const currentOwner = await readFile(ownerPath, 'utf8');
97
+ if (currentOwner.trim() === ownerToken) {
98
+ await rm(lockDir, { recursive: true, force: true });
99
+ }
100
+ } catch {
101
+ // Lock may already be gone after stale recovery or process interruption.
102
+ }
103
+ }
104
+ }
105
+
106
+ async function readJson(path: string): Promise<Record<string, unknown> | null> {
107
+ try {
108
+ return JSON.parse(await readFile(path, 'utf-8')) as Record<string, unknown>;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
115
+ await mkdir(dirname(path), { recursive: true }).catch(() => {});
116
+ const tempPath = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
117
+ await writeFile(tempPath, JSON.stringify(value, null, 2));
118
+ await rename(tempPath, path);
119
+ }
120
+
121
+ function isTerminalRalphPhase(value: unknown): boolean {
122
+ return RALPH_TERMINAL_PHASES.has(safeString(value).trim().toLowerCase());
123
+ }
124
+
125
+ function isActiveRalphCandidate(state: Record<string, unknown> | null): state is Record<string, unknown> {
126
+ if (!state || typeof state !== 'object') return false;
127
+ return state.active === true && !isTerminalRalphPhase(state.current_phase);
128
+ }
129
+
130
+ async function readCurrentOmxSessionId(stateDir: string): Promise<string> {
131
+ const session = await readJson(join(stateDir, 'session.json'));
132
+ const sessionId = safeString(session?.session_id).trim();
133
+ return SESSION_ID_PATTERN.test(sessionId) ? sessionId : '';
134
+ }
135
+
136
+ function resolveResumePane(env: NodeJS.ProcessEnv = process.env): string {
137
+ const injectedPane = captureTmuxPaneFromEnv(env);
138
+ if (env !== process.env && injectedPane) return injectedPane;
139
+ return resolveCodexPane() || injectedPane || '';
140
+ }
141
+
142
+ function bindCurrentPane(state: Record<string, unknown>, nowIso: string, env: NodeJS.ProcessEnv = process.env): Record<string, unknown> {
143
+ const paneId = resolveResumePane(env);
144
+ if (!paneId) return state;
145
+
146
+ return {
147
+ ...state,
148
+ tmux_pane_id: paneId,
149
+ tmux_pane_set_at: nowIso,
150
+ };
151
+ }
152
+
153
+ async function scanMatchingRalphCandidates(
154
+ stateDir: string,
155
+ currentOmxSessionId: string,
156
+ payloadSessionId: string,
157
+ payloadThreadId: string,
158
+ ): Promise<RalphStateCandidate[]> {
159
+ const sessionsRoot = join(stateDir, 'sessions');
160
+ if (!existsSync(sessionsRoot)) return [];
161
+
162
+ const entries = await readdir(sessionsRoot, { withFileTypes: true }).catch(() => []);
163
+ const matches: RalphStateCandidate[] = [];
164
+ for (const entry of entries) {
165
+ if (!entry.isDirectory() || !SESSION_ID_PATTERN.test(entry.name) || entry.name === currentOmxSessionId) continue;
166
+ const path = join(sessionsRoot, entry.name, 'ralph-state.json');
167
+ if (!existsSync(path)) continue;
168
+ const state = await readJson(path);
169
+ if (!isActiveRalphCandidate(state)) continue;
170
+ const ownerSessionId = safeString(state.owner_codex_session_id).trim();
171
+ const ownerThreadId = safeString(state.owner_codex_thread_id).trim();
172
+ if (ownerSessionId) {
173
+ if (!payloadSessionId || ownerSessionId !== payloadSessionId) continue;
174
+ } else if (!payloadThreadId || !ownerThreadId || ownerThreadId !== payloadThreadId) {
175
+ continue;
176
+ }
177
+ matches.push({
178
+ sessionId: entry.name,
179
+ path,
180
+ state,
181
+ });
182
+ }
183
+ return matches;
184
+ }
185
+
186
+ export async function reconcileRalphSessionResume({
187
+ stateDir,
188
+ payloadSessionId,
189
+ payloadThreadId = '',
190
+ env = process.env,
191
+ hooks,
192
+ }: RalphSessionResumeParams): Promise<RalphSessionResumeResult> {
193
+ const lockedResult = await withRalphResumeLock(stateDir, async () => {
194
+ await hooks?.afterLockAcquired?.();
195
+
196
+ const currentOmxSessionId = await readCurrentOmxSessionId(stateDir);
197
+ if (!currentOmxSessionId) {
198
+ return {
199
+ currentOmxSessionId: '',
200
+ resumed: false,
201
+ updatedCurrentOwner: false,
202
+ reason: 'current_omx_session_missing',
203
+ };
204
+ }
205
+
206
+ const currentSessionDir = join(stateDir, 'sessions', currentOmxSessionId);
207
+ const currentRalphPath = join(currentSessionDir, 'ralph-state.json');
208
+ const currentRalphExists = existsSync(currentRalphPath);
209
+ const currentRalphState = currentRalphExists
210
+ ? await readJson(currentRalphPath)
211
+ : null;
212
+ const nowIso = new Date().toISOString();
213
+
214
+ if (currentRalphState && currentRalphState.active === true) {
215
+ let changed = false;
216
+ const updated: Record<string, unknown> = { ...currentRalphState };
217
+ if (safeString(updated.owner_omx_session_id).trim() !== currentOmxSessionId) {
218
+ updated.owner_omx_session_id = currentOmxSessionId;
219
+ changed = true;
220
+ }
221
+ if (payloadSessionId && !safeString(updated.owner_codex_session_id).trim()) {
222
+ updated.owner_codex_session_id = payloadSessionId;
223
+ changed = true;
224
+ }
225
+ if (
226
+ typeof updated.owner_codex_thread_id === 'string'
227
+ && safeString(updated.owner_codex_session_id).trim()
228
+ ) {
229
+ delete updated.owner_codex_thread_id;
230
+ changed = true;
231
+ }
232
+ const currentPaneId = resolveResumePane(env);
233
+ const currentStatePaneId = safeString(updated.tmux_pane_id).trim();
234
+ if (currentPaneId && currentPaneId !== currentStatePaneId) {
235
+ Object.assign(updated, bindCurrentPane(updated, nowIso, env));
236
+ changed = true;
237
+ }
238
+ if (changed) {
239
+ await writeJsonAtomic(currentRalphPath, updated);
240
+ }
241
+ return {
242
+ currentOmxSessionId,
243
+ resumed: false,
244
+ updatedCurrentOwner: changed,
245
+ reason: 'current_ralph_active',
246
+ targetPath: currentRalphPath,
247
+ };
248
+ }
249
+
250
+ if (currentRalphExists) {
251
+ return {
252
+ currentOmxSessionId,
253
+ resumed: false,
254
+ updatedCurrentOwner: false,
255
+ reason: currentRalphState ? 'current_ralph_present' : 'current_ralph_unreadable',
256
+ targetPath: currentRalphPath,
257
+ };
258
+ }
259
+
260
+ const normalizedPayloadSessionId = safeString(payloadSessionId).trim();
261
+ const normalizedPayloadThreadId = safeString(payloadThreadId).trim();
262
+ if (!normalizedPayloadSessionId && !normalizedPayloadThreadId) {
263
+ return {
264
+ currentOmxSessionId,
265
+ resumed: false,
266
+ updatedCurrentOwner: false,
267
+ reason: 'payload_codex_identity_missing',
268
+ };
269
+ }
270
+
271
+ const candidates = await scanMatchingRalphCandidates(
272
+ stateDir,
273
+ currentOmxSessionId,
274
+ normalizedPayloadSessionId,
275
+ normalizedPayloadThreadId,
276
+ );
277
+ if (candidates.length !== 1) {
278
+ return {
279
+ currentOmxSessionId,
280
+ resumed: false,
281
+ updatedCurrentOwner: false,
282
+ reason: candidates.length === 0 ? 'no_matching_prior_ralph' : 'multiple_matching_prior_ralphs',
283
+ };
284
+ }
285
+
286
+ const source = candidates[0];
287
+ await mkdir(currentSessionDir, { recursive: true });
288
+
289
+ const nextState = bindCurrentPane({
290
+ ...source.state,
291
+ owner_omx_session_id: currentOmxSessionId,
292
+ ...(normalizedPayloadSessionId ? { owner_codex_session_id: normalizedPayloadSessionId } : {}),
293
+ }, nowIso, env);
294
+ if (safeString(nextState.owner_codex_session_id).trim()) {
295
+ delete nextState.owner_codex_thread_id;
296
+ }
297
+ delete nextState.completed_at;
298
+ delete nextState.stop_reason;
299
+
300
+ const previousState: Record<string, unknown> = {
301
+ ...source.state,
302
+ active: false,
303
+ current_phase: 'cancelled',
304
+ completed_at: nowIso,
305
+ stop_reason: 'ownership_transferred',
306
+ };
307
+
308
+ await writeJsonAtomic(currentRalphPath, nextState);
309
+ try {
310
+ await hooks?.afterTargetWrite?.();
311
+ await writeJsonAtomic(source.path, previousState);
312
+ } catch (error) {
313
+ await rm(currentRalphPath, { force: true }).catch(() => {});
314
+ throw error;
315
+ }
316
+
317
+ return {
318
+ currentOmxSessionId,
319
+ resumed: true,
320
+ updatedCurrentOwner: false,
321
+ reason: 'resumed_same_codex_session',
322
+ sourcePath: source.path,
323
+ targetPath: currentRalphPath,
324
+ };
325
+ });
326
+
327
+ if (lockedResult) {
328
+ return lockedResult;
329
+ }
330
+
331
+ return {
332
+ currentOmxSessionId: '',
333
+ resumed: false,
334
+ updatedCurrentOwner: false,
335
+ reason: 'resume_lock_timeout',
336
+ };
337
+ }
@@ -3,7 +3,6 @@
3
3
  */
4
4
 
5
5
  import { readFile, readdir } from 'fs/promises';
6
- import { existsSync } from 'fs';
7
6
  import { join } from 'path';
8
7
  import { asNumber, safeString } from './utils.js';
9
8
 
@@ -17,20 +16,13 @@ export function readJsonIfExists(path: string, fallback: any): Promise<any> {
17
16
  .catch(() => fallback);
18
17
  }
19
18
 
20
- export async function getScopedStateDirsForCurrentSession(baseStateDir: string, payloadSessionId?: any): Promise<string[]> {
21
- const explicitSessionId = safeString(payloadSessionId || '');
22
- if (SESSION_ID_PATTERN.test(explicitSessionId)) {
23
- const sessionDir = join(baseStateDir, 'sessions', explicitSessionId);
24
- return [sessionDir];
25
- }
26
-
19
+ export async function getScopedStateDirsForCurrentSession(baseStateDir: string): Promise<string[]> {
27
20
  const sessionPath = join(baseStateDir, 'session.json');
28
21
  try {
29
22
  const session = JSON.parse(await readFile(sessionPath, 'utf-8'));
30
23
  const sessionId = safeString(session && session.session_id ? session.session_id : '');
31
24
  if (SESSION_ID_PATTERN.test(sessionId)) {
32
- const sessionDir = join(baseStateDir, 'sessions', sessionId);
33
- if (existsSync(sessionDir)) return [sessionDir];
25
+ return [join(baseStateDir, 'sessions', sessionId)];
34
26
  }
35
27
  } catch {
36
28
  // No session file or malformed - fall back to global only