oh-my-codex 0.3.9 → 0.4.0

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 (184) hide show
  1. package/README.md +25 -0
  2. package/dist/cli/__tests__/doctor-team.test.js +58 -0
  3. package/dist/cli/__tests__/doctor-team.test.js.map +1 -1
  4. package/dist/cli/__tests__/index.test.js +9 -3
  5. package/dist/cli/__tests__/index.test.js.map +1 -1
  6. package/dist/cli/__tests__/lifecycle-notifications.test.d.ts +2 -0
  7. package/dist/cli/__tests__/lifecycle-notifications.test.d.ts.map +1 -0
  8. package/dist/cli/__tests__/lifecycle-notifications.test.js +48 -0
  9. package/dist/cli/__tests__/lifecycle-notifications.test.js.map +1 -0
  10. package/dist/cli/doctor.js +28 -0
  11. package/dist/cli/doctor.js.map +1 -1
  12. package/dist/cli/hooks.d.ts +4 -0
  13. package/dist/cli/hooks.d.ts.map +1 -0
  14. package/dist/cli/hooks.js +201 -0
  15. package/dist/cli/hooks.js.map +1 -0
  16. package/dist/cli/index.d.ts +1 -1
  17. package/dist/cli/index.d.ts.map +1 -1
  18. package/dist/cli/index.js +181 -2
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/config/__tests__/models.test.d.ts +2 -0
  21. package/dist/config/__tests__/models.test.d.ts.map +1 -0
  22. package/dist/config/__tests__/models.test.js +69 -0
  23. package/dist/config/__tests__/models.test.js.map +1 -0
  24. package/dist/config/models.d.ts +24 -0
  25. package/dist/config/models.d.ts.map +1 -0
  26. package/dist/config/models.js +53 -0
  27. package/dist/config/models.js.map +1 -0
  28. package/dist/hooks/__tests__/notify-hook-linked-sync.test.js +6 -0
  29. package/dist/hooks/__tests__/notify-hook-linked-sync.test.js.map +1 -1
  30. package/dist/hooks/__tests__/notify-hook-session-scope.test.js +6 -0
  31. package/dist/hooks/__tests__/notify-hook-session-scope.test.js.map +1 -1
  32. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +224 -36
  33. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  34. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js +4 -0
  35. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js.map +1 -1
  36. package/dist/hooks/__tests__/tmux-hook-engine.test.js +1 -1
  37. package/dist/hooks/__tests__/tmux-hook-engine.test.js.map +1 -1
  38. package/dist/hooks/extensibility/__tests__/example-hook-plugins.test.d.ts +2 -0
  39. package/dist/hooks/extensibility/__tests__/example-hook-plugins.test.d.ts.map +1 -0
  40. package/dist/hooks/extensibility/__tests__/example-hook-plugins.test.js +153 -0
  41. package/dist/hooks/extensibility/__tests__/example-hook-plugins.test.js.map +1 -0
  42. package/dist/hooks/extensibility/dispatcher.d.ts +4 -0
  43. package/dist/hooks/extensibility/dispatcher.d.ts.map +1 -0
  44. package/dist/hooks/extensibility/dispatcher.js +223 -0
  45. package/dist/hooks/extensibility/dispatcher.js.map +1 -0
  46. package/dist/hooks/extensibility/events.d.ts +18 -0
  47. package/dist/hooks/extensibility/events.d.ts.map +1 -0
  48. package/dist/hooks/extensibility/events.js +53 -0
  49. package/dist/hooks/extensibility/events.js.map +1 -0
  50. package/dist/hooks/extensibility/index.d.ts +6 -0
  51. package/dist/hooks/extensibility/index.d.ts.map +1 -0
  52. package/dist/hooks/extensibility/index.js +6 -0
  53. package/dist/hooks/extensibility/index.js.map +1 -0
  54. package/dist/hooks/extensibility/loader.d.ts +14 -0
  55. package/dist/hooks/extensibility/loader.d.ts.map +1 -0
  56. package/dist/hooks/extensibility/loader.js +102 -0
  57. package/dist/hooks/extensibility/loader.js.map +1 -0
  58. package/dist/hooks/extensibility/logging.d.ts +4 -0
  59. package/dist/hooks/extensibility/logging.d.ts.map +1 -0
  60. package/dist/hooks/extensibility/logging.js +16 -0
  61. package/dist/hooks/extensibility/logging.js.map +1 -0
  62. package/dist/hooks/extensibility/plugin-runner.d.ts +2 -0
  63. package/dist/hooks/extensibility/plugin-runner.d.ts.map +1 -0
  64. package/dist/hooks/extensibility/plugin-runner.js +69 -0
  65. package/dist/hooks/extensibility/plugin-runner.js.map +1 -0
  66. package/dist/hooks/extensibility/runtime.d.ts +3 -0
  67. package/dist/hooks/extensibility/runtime.d.ts.map +1 -0
  68. package/dist/hooks/extensibility/runtime.js +29 -0
  69. package/dist/hooks/extensibility/runtime.js.map +1 -0
  70. package/dist/hooks/extensibility/sdk.d.ts +11 -0
  71. package/dist/hooks/extensibility/sdk.d.ts.map +1 -0
  72. package/dist/hooks/extensibility/sdk.js +240 -0
  73. package/dist/hooks/extensibility/sdk.js.map +1 -0
  74. package/dist/hooks/extensibility/types.d.ts +122 -0
  75. package/dist/hooks/extensibility/types.d.ts.map +1 -0
  76. package/dist/hooks/extensibility/types.js +2 -0
  77. package/dist/hooks/extensibility/types.js.map +1 -0
  78. package/dist/mcp/__tests__/state-paths.test.js +21 -1
  79. package/dist/mcp/__tests__/state-paths.test.js.map +1 -1
  80. package/dist/mcp/__tests__/state-server-team-tools.test.js +53 -1
  81. package/dist/mcp/__tests__/state-server-team-tools.test.js.map +1 -1
  82. package/dist/mcp/state-paths.d.ts +1 -0
  83. package/dist/mcp/state-paths.d.ts.map +1 -1
  84. package/dist/mcp/state-paths.js +34 -1
  85. package/dist/mcp/state-paths.js.map +1 -1
  86. package/dist/mcp/state-server.d.ts.map +1 -1
  87. package/dist/mcp/state-server.js +46 -11
  88. package/dist/mcp/state-server.js.map +1 -1
  89. package/dist/notifications/__tests__/config.test.d.ts +2 -0
  90. package/dist/notifications/__tests__/config.test.d.ts.map +1 -0
  91. package/dist/notifications/__tests__/config.test.js +186 -0
  92. package/dist/notifications/__tests__/config.test.js.map +1 -0
  93. package/dist/notifications/__tests__/dispatcher.test.d.ts +2 -0
  94. package/dist/notifications/__tests__/dispatcher.test.d.ts.map +1 -0
  95. package/dist/notifications/__tests__/dispatcher.test.js +202 -0
  96. package/dist/notifications/__tests__/dispatcher.test.js.map +1 -0
  97. package/dist/notifications/__tests__/formatter.test.d.ts +2 -0
  98. package/dist/notifications/__tests__/formatter.test.d.ts.map +1 -0
  99. package/dist/notifications/__tests__/formatter.test.js +103 -0
  100. package/dist/notifications/__tests__/formatter.test.js.map +1 -0
  101. package/dist/notifications/__tests__/notifier.test.d.ts +2 -0
  102. package/dist/notifications/__tests__/notifier.test.d.ts.map +1 -0
  103. package/dist/notifications/__tests__/notifier.test.js +104 -0
  104. package/dist/notifications/__tests__/notifier.test.js.map +1 -0
  105. package/dist/notifications/__tests__/profiles.test.d.ts +2 -0
  106. package/dist/notifications/__tests__/profiles.test.d.ts.map +1 -0
  107. package/dist/notifications/__tests__/profiles.test.js +404 -0
  108. package/dist/notifications/__tests__/profiles.test.js.map +1 -0
  109. package/dist/notifications/__tests__/reply-listener.test.d.ts +2 -0
  110. package/dist/notifications/__tests__/reply-listener.test.d.ts.map +1 -0
  111. package/dist/notifications/__tests__/reply-listener.test.js +58 -0
  112. package/dist/notifications/__tests__/reply-listener.test.js.map +1 -0
  113. package/dist/notifications/__tests__/session-registry.test.d.ts +2 -0
  114. package/dist/notifications/__tests__/session-registry.test.d.ts.map +1 -0
  115. package/dist/notifications/__tests__/session-registry.test.js +147 -0
  116. package/dist/notifications/__tests__/session-registry.test.js.map +1 -0
  117. package/dist/notifications/__tests__/tmux-detector.test.d.ts +2 -0
  118. package/dist/notifications/__tests__/tmux-detector.test.d.ts.map +1 -0
  119. package/dist/notifications/__tests__/tmux-detector.test.js +77 -0
  120. package/dist/notifications/__tests__/tmux-detector.test.js.map +1 -0
  121. package/dist/notifications/__tests__/tmux.test.d.ts +2 -0
  122. package/dist/notifications/__tests__/tmux.test.d.ts.map +1 -0
  123. package/dist/notifications/__tests__/tmux.test.js +90 -0
  124. package/dist/notifications/__tests__/tmux.test.js.map +1 -0
  125. package/dist/notifications/config.d.ts +44 -0
  126. package/dist/notifications/config.d.ts.map +1 -0
  127. package/dist/notifications/config.js +407 -0
  128. package/dist/notifications/config.js.map +1 -0
  129. package/dist/notifications/dispatcher.d.ts +15 -0
  130. package/dist/notifications/dispatcher.d.ts.map +1 -0
  131. package/dist/notifications/dispatcher.js +410 -0
  132. package/dist/notifications/dispatcher.js.map +1 -0
  133. package/dist/notifications/formatter.d.ts +14 -0
  134. package/dist/notifications/formatter.d.ts.map +1 -0
  135. package/dist/notifications/formatter.js +134 -0
  136. package/dist/notifications/formatter.js.map +1 -0
  137. package/dist/notifications/index.d.ts +32 -0
  138. package/dist/notifications/index.d.ts.map +1 -0
  139. package/dist/notifications/index.js +93 -0
  140. package/dist/notifications/index.js.map +1 -0
  141. package/dist/notifications/reply-listener.d.ts +47 -0
  142. package/dist/notifications/reply-listener.d.ts.map +1 -0
  143. package/dist/notifications/reply-listener.js +656 -0
  144. package/dist/notifications/reply-listener.js.map +1 -0
  145. package/dist/notifications/session-registry.d.ts +26 -0
  146. package/dist/notifications/session-registry.d.ts.map +1 -0
  147. package/dist/notifications/session-registry.js +275 -0
  148. package/dist/notifications/session-registry.js.map +1 -0
  149. package/dist/notifications/tmux-detector.d.ts +17 -0
  150. package/dist/notifications/tmux-detector.d.ts.map +1 -0
  151. package/dist/notifications/tmux-detector.js +82 -0
  152. package/dist/notifications/tmux-detector.js.map +1 -0
  153. package/dist/notifications/tmux.d.ts +28 -0
  154. package/dist/notifications/tmux.d.ts.map +1 -0
  155. package/dist/notifications/tmux.js +210 -0
  156. package/dist/notifications/tmux.js.map +1 -0
  157. package/dist/notifications/types.d.ts +181 -0
  158. package/dist/notifications/types.d.ts.map +1 -0
  159. package/dist/notifications/types.js +9 -0
  160. package/dist/notifications/types.js.map +1 -0
  161. package/dist/team/__tests__/runtime.test.js +54 -2
  162. package/dist/team/__tests__/runtime.test.js.map +1 -1
  163. package/dist/team/__tests__/state.test.js +30 -0
  164. package/dist/team/__tests__/state.test.js.map +1 -1
  165. package/dist/team/__tests__/worker-bootstrap.test.js +2 -0
  166. package/dist/team/__tests__/worker-bootstrap.test.js.map +1 -1
  167. package/dist/team/runtime.d.ts +2 -2
  168. package/dist/team/runtime.d.ts.map +1 -1
  169. package/dist/team/runtime.js +19 -12
  170. package/dist/team/runtime.js.map +1 -1
  171. package/dist/team/state.d.ts +1 -1
  172. package/dist/team/state.d.ts.map +1 -1
  173. package/dist/team/state.js +5 -0
  174. package/dist/team/state.js.map +1 -1
  175. package/dist/team/tmux-session.d.ts.map +1 -1
  176. package/dist/team/tmux-session.js +59 -15
  177. package/dist/team/tmux-session.js.map +1 -1
  178. package/dist/team/worker-bootstrap.d.ts.map +1 -1
  179. package/dist/team/worker-bootstrap.js +4 -0
  180. package/dist/team/worker-bootstrap.js.map +1 -1
  181. package/package.json +1 -1
  182. package/scripts/hook-derived-watcher.js +335 -0
  183. package/scripts/notify-hook.js +168 -7
  184. package/scripts/tmux-hook-engine.js +3 -2
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync } from 'fs';
4
+ import { appendFile, mkdir, readFile, readdir, stat, writeFile } from 'fs/promises';
5
+ import { dirname, join, resolve } from 'path';
6
+ import { homedir } from 'os';
7
+
8
+ function argValue(name, fallback = '') {
9
+ const idx = process.argv.indexOf(name);
10
+ if (idx < 0 || idx + 1 >= process.argv.length) return fallback;
11
+ return process.argv[idx + 1];
12
+ }
13
+
14
+ function asNumber(value, fallback) {
15
+ const parsed = Number(value);
16
+ return Number.isFinite(parsed) ? parsed : fallback;
17
+ }
18
+
19
+ const cwd = resolve(argValue('--cwd', process.cwd()));
20
+ const runOnce = process.argv.includes('--once');
21
+ const pollMs = Math.max(250, asNumber(argValue('--poll-ms', process.env.OMX_HOOK_DERIVED_POLL_MS || '800'), 800));
22
+ const maxFileAgeMs = Math.max(10_000, asNumber(argValue('--file-age-ms', process.env.OMX_HOOK_DERIVED_FILE_AGE_MS || '90000'), 90000));
23
+
24
+ const omxDir = join(cwd, '.omx');
25
+ const logsDir = join(omxDir, 'logs');
26
+ const stateDir = join(omxDir, 'state');
27
+ const watcherStatePath = join(stateDir, 'hook-derived-watcher-state.json');
28
+ const logPath = join(logsDir, `hook-derived-watcher-${new Date().toISOString().split('T')[0]}.jsonl`);
29
+
30
+ const fileState = new Map();
31
+ let stopping = false;
32
+ let flushedOnShutdown = false;
33
+
34
+ function safeString(value) {
35
+ return typeof value === 'string' ? value : '';
36
+ }
37
+
38
+ function derivedLog(entry) {
39
+ return appendFile(logPath, `${JSON.stringify({ timestamp: new Date().toISOString(), ...entry })}\n`).catch(() => {});
40
+ }
41
+
42
+ function parseJsonLine(line) {
43
+ try {
44
+ return JSON.parse(line);
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function sessionDirs() {
51
+ const now = new Date();
52
+ const today = join(
53
+ homedir(),
54
+ '.codex',
55
+ 'sessions',
56
+ String(now.getUTCFullYear()),
57
+ String(now.getUTCMonth() + 1).padStart(2, '0'),
58
+ String(now.getUTCDate()).padStart(2, '0')
59
+ );
60
+ const yesterdayDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
61
+ const yesterday = join(
62
+ homedir(),
63
+ '.codex',
64
+ 'sessions',
65
+ String(yesterdayDate.getUTCFullYear()),
66
+ String(yesterdayDate.getUTCMonth() + 1).padStart(2, '0'),
67
+ String(yesterdayDate.getUTCDate()).padStart(2, '0')
68
+ );
69
+ return Array.from(new Set([today, yesterday]));
70
+ }
71
+
72
+ async function readFirstLine(path) {
73
+ const content = await readFile(path, 'utf-8');
74
+ const idx = content.indexOf('\n');
75
+ return idx >= 0 ? content.slice(0, idx) : content;
76
+ }
77
+
78
+ function shouldTrackSessionMeta(line) {
79
+ const parsed = parseJsonLine(line);
80
+ if (!parsed || parsed.type !== 'session_meta' || !parsed.payload) return null;
81
+ const payload = parsed.payload;
82
+ if (safeString(payload.cwd) !== cwd) return null;
83
+ const threadId = safeString(payload.id);
84
+ if (!threadId) return null;
85
+ return {
86
+ threadId,
87
+ sessionId: threadId,
88
+ };
89
+ }
90
+
91
+ async function discoverRolloutFiles() {
92
+ const now = Date.now();
93
+ const discovered = [];
94
+ for (const dir of sessionDirs()) {
95
+ if (!existsSync(dir)) continue;
96
+ const names = await readdir(dir).catch(() => []);
97
+ for (const name of names) {
98
+ if (!name.startsWith('rollout-') || !name.endsWith('.jsonl')) continue;
99
+ const path = join(dir, name);
100
+ const st = await stat(path).catch(() => null);
101
+ if (!st) continue;
102
+ if (now - st.mtimeMs > maxFileAgeMs) continue;
103
+ discovered.push(path);
104
+ }
105
+ }
106
+ discovered.sort();
107
+ return discovered;
108
+ }
109
+
110
+ function inferDerivedEvent(parsed, meta) {
111
+ if (!parsed || parsed.type !== 'event_msg' || !parsed.payload) return null;
112
+
113
+ const payload = parsed.payload;
114
+ const payloadType = safeString(payload.type).toLowerCase();
115
+ const timestamp = safeString(parsed.timestamp) || new Date().toISOString();
116
+ const turnId = safeString(payload.turn_id || parsed.turn_id || parsed.id);
117
+
118
+ const base = {
119
+ schema_version: '1',
120
+ timestamp,
121
+ source: 'derived',
122
+ context: {
123
+ parser_reason: '',
124
+ payload_type: payloadType || 'unknown',
125
+ },
126
+ session_id: meta.sessionId,
127
+ thread_id: meta.threadId,
128
+ turn_id: turnId || undefined,
129
+ };
130
+
131
+ if (['tool_call_start', 'tool_use_start', 'tool_start', 'tool_invocation_start'].includes(payloadType)) {
132
+ return {
133
+ ...base,
134
+ event: 'pre-tool-use',
135
+ confidence: 0.8,
136
+ parser_reason: `payload_type:${payloadType}`,
137
+ context: {
138
+ ...base.context,
139
+ parser_reason: `payload_type:${payloadType}`,
140
+ tool_name: safeString(payload.tool_name || payload.tool || payload.name),
141
+ },
142
+ };
143
+ }
144
+
145
+ if (['tool_call_end', 'tool_use_end', 'tool_end', 'tool_invocation_end'].includes(payloadType)) {
146
+ return {
147
+ ...base,
148
+ event: 'post-tool-use',
149
+ confidence: 0.8,
150
+ parser_reason: `payload_type:${payloadType}`,
151
+ context: {
152
+ ...base.context,
153
+ parser_reason: `payload_type:${payloadType}`,
154
+ tool_name: safeString(payload.tool_name || payload.tool || payload.name),
155
+ tool_ok: payload.ok === true,
156
+ },
157
+ };
158
+ }
159
+
160
+ if (payloadType === 'assistant_message') {
161
+ const message = safeString(payload.text || payload.message || payload.content);
162
+ const looksLikeQuestion = /\?|\b(can you|could you|please provide|need input|what should)/i.test(message);
163
+ if (looksLikeQuestion) {
164
+ return {
165
+ ...base,
166
+ event: 'needs-input',
167
+ confidence: 0.55,
168
+ parser_reason: 'assistant_message_heuristic_question',
169
+ context: {
170
+ ...base.context,
171
+ parser_reason: 'assistant_message_heuristic_question',
172
+ preview: message.slice(0, 200),
173
+ },
174
+ };
175
+ }
176
+ }
177
+
178
+ return null;
179
+ }
180
+
181
+ async function dispatchDerivedEvent(event) {
182
+ try {
183
+ const { dispatchHookEvent } = await import('../dist/hooks/extensibility/dispatcher.js');
184
+ await dispatchHookEvent(event, {
185
+ cwd,
186
+ allowTeamWorkerSideEffects: false,
187
+ });
188
+ await derivedLog({
189
+ type: 'derived_event_dispatch',
190
+ event: event.event,
191
+ source: event.source,
192
+ confidence: event.confidence,
193
+ thread_id: event.thread_id,
194
+ turn_id: event.turn_id,
195
+ parser_reason: event.parser_reason,
196
+ ok: true,
197
+ });
198
+ } catch (err) {
199
+ await derivedLog({
200
+ type: 'derived_event_dispatch',
201
+ event: event.event,
202
+ source: event.source,
203
+ thread_id: event.thread_id,
204
+ turn_id: event.turn_id,
205
+ parser_reason: event.parser_reason,
206
+ ok: false,
207
+ error: err instanceof Error ? err.message : 'dispatch_failed',
208
+ });
209
+ }
210
+ }
211
+
212
+ async function ensureTrackedFiles() {
213
+ const files = await discoverRolloutFiles();
214
+ for (const path of files) {
215
+ if (fileState.has(path)) continue;
216
+ const firstLine = await readFirstLine(path).catch(() => '');
217
+ const meta = shouldTrackSessionMeta(firstLine);
218
+ if (!meta) continue;
219
+ const size = (await stat(path).catch(() => ({ size: 0 }))).size || 0;
220
+ const offset = runOnce ? 0 : size;
221
+ fileState.set(path, {
222
+ ...meta,
223
+ offset,
224
+ partial: '',
225
+ dispatched: 0,
226
+ });
227
+ }
228
+ }
229
+
230
+ async function processLine(meta, line) {
231
+ const parsed = parseJsonLine(line);
232
+ const derived = inferDerivedEvent(parsed, meta);
233
+ if (!derived) return;
234
+ await dispatchDerivedEvent(derived);
235
+ meta.dispatched += 1;
236
+ }
237
+
238
+ async function pollFiles() {
239
+ for (const [path, meta] of fileState.entries()) {
240
+ const currentSize = (await stat(path).catch(() => ({ size: 0 }))).size || 0;
241
+ if (currentSize <= meta.offset) continue;
242
+
243
+ const content = await readFile(path, 'utf-8').catch(() => '');
244
+ if (!content) continue;
245
+
246
+ const delta = content.slice(meta.offset);
247
+ meta.offset = currentSize;
248
+ const merged = meta.partial + delta;
249
+ const lines = merged.split('\n');
250
+ meta.partial = lines.pop() || '';
251
+
252
+ for (const line of lines) {
253
+ if (!line.trim()) continue;
254
+ await processLine(meta, line);
255
+ }
256
+ }
257
+ }
258
+
259
+ async function writeState() {
260
+ await mkdir(stateDir, { recursive: true }).catch(() => {});
261
+ const tracked = Array.from(fileState.values()).reduce((sum, item) => sum + item.dispatched, 0);
262
+ const state = {
263
+ pid: process.pid,
264
+ started_at: new Date().toISOString(),
265
+ cwd,
266
+ poll_ms: pollMs,
267
+ max_file_age_ms: maxFileAgeMs,
268
+ tracked_files: fileState.size,
269
+ dispatched_events: tracked,
270
+ };
271
+ await writeFile(watcherStatePath, JSON.stringify(state, null, 2)).catch(() => {});
272
+ }
273
+
274
+ async function flushOnce(reason) {
275
+ if (flushedOnShutdown) return;
276
+ flushedOnShutdown = true;
277
+ await ensureTrackedFiles();
278
+ await pollFiles();
279
+ await writeState();
280
+ await derivedLog({ type: 'watcher_flush', reason });
281
+ }
282
+
283
+ async function tick() {
284
+ if (stopping) return;
285
+ await ensureTrackedFiles();
286
+ await pollFiles();
287
+ await writeState();
288
+ setTimeout(tick, pollMs);
289
+ }
290
+
291
+ function shutdown(signal) {
292
+ stopping = true;
293
+ flushOnce(`signal:${signal}`)
294
+ .finally(() => derivedLog({ type: 'watcher_stop', signal }))
295
+ .finally(() => process.exit(0));
296
+ }
297
+
298
+ async function main() {
299
+ if (process.env.OMX_HOOK_DERIVED_SIGNALS !== '1') {
300
+ process.exit(0);
301
+ }
302
+
303
+ await mkdir(dirname(logPath), { recursive: true }).catch(() => {});
304
+ await mkdir(stateDir, { recursive: true }).catch(() => {});
305
+
306
+ await derivedLog({
307
+ type: 'watcher_start',
308
+ cwd,
309
+ poll_ms: pollMs,
310
+ max_file_age_ms: maxFileAgeMs,
311
+ once: runOnce,
312
+ });
313
+
314
+ process.on('SIGINT', () => shutdown('SIGINT'));
315
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
316
+ process.on('SIGHUP', () => shutdown('SIGHUP'));
317
+
318
+ if (runOnce) {
319
+ await flushOnce('once');
320
+ await derivedLog({ type: 'watcher_once_complete' });
321
+ process.exit(0);
322
+ }
323
+
324
+ await tick();
325
+ }
326
+
327
+ main().catch(async (err) => {
328
+ await mkdir(dirname(logPath), { recursive: true }).catch(() => {});
329
+ await derivedLog({
330
+ type: 'watcher_error',
331
+ reason: 'fatal',
332
+ error: err instanceof Error ? err.message : 'unknown_error',
333
+ });
334
+ process.exit(1);
335
+ });
@@ -437,7 +437,63 @@ function resolveLeaderNudgeIntervalMs() {
437
437
  return 120_000;
438
438
  }
439
439
 
440
- async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir }) {
440
+ function resolveLeaderStalenessThresholdMs() {
441
+ const raw = safeString(process.env.OMX_TEAM_LEADER_STALE_MS || '');
442
+ const parsed = asNumber(raw);
443
+ // Default: 3 minutes. Guard against unreasonable values.
444
+ if (parsed !== null && parsed >= 10_000 && parsed <= 30 * 60_000) return parsed;
445
+ return 180_000;
446
+ }
447
+
448
+ async function checkWorkerPanesAlive(tmuxTarget) {
449
+ // Check if the team tmux session has worker panes running.
450
+ // tmuxTarget is either "omx-team-foo" or "session:window".
451
+ const sessionName = tmuxTarget.split(':')[0];
452
+ try {
453
+ const result = await runProcess('tmux', ['list-panes', '-t', sessionName, '-F', '#{pane_id} #{pane_pid}'], 2000);
454
+ const lines = (result.stdout || '')
455
+ .split('\n')
456
+ .map(l => l.trim())
457
+ .filter(Boolean);
458
+ return { alive: lines.length > 0, paneCount: lines.length };
459
+ } catch {
460
+ return { alive: false, paneCount: 0 };
461
+ }
462
+ }
463
+
464
+ async function isLeaderStale(stateDir, thresholdMs, nowMs) {
465
+ // Check HUD state (updated by the notify hook on each leader turn) for staleness.
466
+ const hudStatePath = join(stateDir, 'hud-state.json');
467
+ const hudState = await readJsonIfExists(hudStatePath, null);
468
+ if (!hudState || typeof hudState !== 'object') return true;
469
+ const lastTurnAt = safeString(hudState.last_turn_at || '');
470
+ if (!lastTurnAt) return true;
471
+ const lastMs = Date.parse(lastTurnAt);
472
+ if (!Number.isFinite(lastMs)) return true;
473
+ return (nowMs - lastMs) >= thresholdMs;
474
+ }
475
+
476
+ async function emitTeamNudgeEvent(cwd, teamName, reason, nowIso) {
477
+ // Write a team_leader_nudge event to the team's events.ndjson log.
478
+ const eventsDir = join(cwd, '.omx', 'state', 'team', teamName, 'events');
479
+ const eventsPath = join(eventsDir, 'events.ndjson');
480
+ try {
481
+ await mkdir(eventsDir, { recursive: true });
482
+ const event = {
483
+ event_id: `nudge-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
484
+ team: teamName,
485
+ type: 'team_leader_nudge',
486
+ worker: 'leader-fixed',
487
+ reason,
488
+ created_at: nowIso,
489
+ };
490
+ await appendFile(eventsPath, JSON.stringify(event) + '\n');
491
+ } catch {
492
+ // Best effort
493
+ }
494
+ }
495
+
496
+ async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir, preComputedLeaderStale }) {
441
497
  const intervalMs = resolveLeaderNudgeIntervalMs();
442
498
  const nowMs = Date.now();
443
499
  const nowIso = new Date().toISOString();
@@ -467,6 +523,9 @@ async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir }) {
467
523
  // Non-critical
468
524
  }
469
525
 
526
+ // Use pre-computed staleness (captured before HUD state was updated this turn)
527
+ const leaderStale = typeof preComputedLeaderStale === 'boolean' ? preComputedLeaderStale : false;
528
+
470
529
  for (const teamName of activeTeamNames) {
471
530
  // Resolve tmux target (session:window) from manifest/config. Best effort.
472
531
  let tmuxTarget = '';
@@ -483,6 +542,9 @@ async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir }) {
483
542
  }
484
543
  if (!tmuxTarget) continue;
485
544
 
545
+ // Check if worker panes are still alive in tmux
546
+ const paneStatus = await checkWorkerPanesAlive(tmuxTarget);
547
+
486
548
  let mailbox = null;
487
549
  try {
488
550
  const mailboxPath = join(omxDir, 'state', 'team', teamName, 'mailbox', 'leader-fixed.json');
@@ -503,17 +565,51 @@ async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir }) {
503
565
 
504
566
  const hasNewMessage = newestId && newestId !== prevMsgId;
505
567
  const dueByTime = !Number.isFinite(prevAtMs) || (nowMs - prevAtMs >= intervalMs);
506
- if (!hasNewMessage && !dueByTime) continue;
507
568
 
569
+ // New condition: worker panes alive + leader stale = always nudge
570
+ const stalePanesNudge = paneStatus.alive && leaderStale;
571
+
572
+ if (!hasNewMessage && !dueByTime && !stalePanesNudge) continue;
573
+
574
+ // Build contextual nudge message
508
575
  const msgCount = messages.length;
509
- const text = hasNewMessage
510
- ? `Team ${teamName}: ${msgCount} msg(s) for leader. Run: omx team status ${teamName}`
511
- : `Team ${teamName} active. Run: omx team status ${teamName}`;
576
+ let nudgeReason = '';
577
+ let text = '';
578
+ if (stalePanesNudge && hasNewMessage) {
579
+ nudgeReason = 'stale_leader_with_messages';
580
+ text = `Team ${teamName}: leader stale, ${paneStatus.paneCount} pane(s) active, ${msgCount} msg(s) pending. Run: omx team status ${teamName}`;
581
+ } else if (stalePanesNudge) {
582
+ nudgeReason = 'stale_leader_panes_alive';
583
+ text = `Team ${teamName}: leader stale, ${paneStatus.paneCount} worker pane(s) still active. Run: omx team status ${teamName}`;
584
+ } else if (hasNewMessage) {
585
+ nudgeReason = 'new_mailbox_message';
586
+ text = `Team ${teamName}: ${msgCount} msg(s) for leader. Run: omx team status ${teamName}`;
587
+ } else {
588
+ nudgeReason = 'periodic_check';
589
+ text = `Team ${teamName} active. Run: omx team status ${teamName}`;
590
+ }
512
591
  const capped = text.length > 180 ? `${text.slice(0, 177)}...` : text;
513
592
 
514
593
  try {
515
- await runProcess('tmux', ['display-message', '-t', tmuxTarget, '--', capped], 1200);
594
+ await runProcess('tmux', ['send-keys', '-t', tmuxTarget, capped, 'C-m', 'C-m'], 1200);
516
595
  nudgeState.last_nudged_by_team[teamName] = { at: nowIso, last_message_id: newestId || prevMsgId || '' };
596
+
597
+ // Emit team event for the nudge
598
+ await emitTeamNudgeEvent(cwd, teamName, nudgeReason, nowIso);
599
+
600
+ // Log the nudge
601
+ try {
602
+ await logTmuxHookEvent(logsDir, {
603
+ timestamp: nowIso,
604
+ type: 'team_leader_nudge',
605
+ team: teamName,
606
+ tmux_target: tmuxTarget,
607
+ reason: nudgeReason,
608
+ pane_count: paneStatus.paneCount,
609
+ leader_stale: leaderStale,
610
+ message_count: msgCount,
611
+ });
612
+ } catch { /* ignore */ }
517
613
  } catch (err) {
518
614
  // Best effort. Log only in debug mode to avoid noise.
519
615
  try {
@@ -522,6 +618,7 @@ async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir }) {
522
618
  type: 'team_leader_nudge',
523
619
  team: teamName,
524
620
  tmux_target: tmuxTarget,
621
+ reason: nudgeReason,
525
622
  error: safeString(err && err.message ? err.message : err),
526
623
  });
527
624
  } catch { /* ignore */ }
@@ -812,6 +909,22 @@ function parseTeamWorkerEnv(rawValue) {
812
909
  return { teamName: match[1], workerName: match[2] };
813
910
  }
814
911
 
912
+ async function dispatchNativeHookEvent(cwd, eventName, payload, context = {}) {
913
+ try {
914
+ const { buildNativeHookEvent } = await import('../dist/hooks/extensibility/events.js');
915
+ const { dispatchHookEvent } = await import('../dist/hooks/extensibility/dispatcher.js');
916
+ const event = buildNativeHookEvent(eventName, context, {
917
+ session_id: safeString(payload.session_id || payload['session-id'] || ''),
918
+ thread_id: safeString(payload['thread-id'] || payload.thread_id || ''),
919
+ turn_id: safeString(payload['turn-id'] || payload.turn_id || ''),
920
+ mode: safeString(payload.mode || ''),
921
+ });
922
+ await dispatchHookEvent(event, { cwd });
923
+ } catch {
924
+ // Non-fatal: extensibility modules may not be built yet
925
+ }
926
+ }
927
+
815
928
  async function main() {
816
929
  const rawPayload = process.argv[process.argv.length - 1];
817
930
  if (!rawPayload || rawPayload.startsWith('-')) {
@@ -968,6 +1081,17 @@ async function main() {
968
1081
  }
969
1082
  }
970
1083
 
1084
+ // 3.5. Pre-compute leader staleness BEFORE updating HUD state (used by nudge in step 6)
1085
+ let preComputedLeaderStale = false;
1086
+ if (!isTeamWorker) {
1087
+ try {
1088
+ const stalenessMs = resolveLeaderStalenessThresholdMs();
1089
+ preComputedLeaderStale = await isLeaderStale(stateDir, stalenessMs, Date.now());
1090
+ } catch {
1091
+ // Non-critical
1092
+ }
1093
+ }
1094
+
971
1095
  // 4. Write HUD state summary for `omx hud` (lead session only)
972
1096
  if (!isTeamWorker) {
973
1097
  const hudStatePath = join(stateDir, 'hud-state.json');
@@ -1026,11 +1150,48 @@ async function main() {
1026
1150
  // 6. Team leader nudge (lead session only): remind the leader to check teammate/mailbox state.
1027
1151
  if (!isTeamWorker) {
1028
1152
  try {
1029
- await maybeNudgeTeamLeader({ cwd, stateDir, logsDir });
1153
+ await maybeNudgeTeamLeader({ cwd, stateDir, logsDir, preComputedLeaderStale });
1030
1154
  } catch {
1031
1155
  // Non-critical
1032
1156
  }
1033
1157
  }
1158
+
1159
+ // 7. Dispatch native turn-complete hook event (best effort, post-dedupe)
1160
+ await dispatchNativeHookEvent(cwd, 'turn-complete', payload, {
1161
+ source: safeString(payload.source || 'native'),
1162
+ type: safeString(payload.type || 'agent-turn-complete'),
1163
+ input_messages: normalizeInputMessages(payload),
1164
+ output_preview: safeString(payload['last-assistant-message'] || payload.last_assistant_message || '').slice(0, 400),
1165
+ });
1166
+
1167
+ // 8. Dispatch session-idle lifecycle notification (lead session only, best effort)
1168
+ if (!isTeamWorker) {
1169
+ try {
1170
+ const { notifyLifecycle } = await import('../dist/notifications/index.js');
1171
+ const sessionJsonPath = join(stateDir, 'session.json');
1172
+ let notifySessionId = '';
1173
+ try {
1174
+ const sessionData = JSON.parse(await readFile(sessionJsonPath, 'utf-8'));
1175
+ notifySessionId = safeString(sessionData && sessionData.session_id ? sessionData.session_id : '');
1176
+ } catch { /* no session file */ }
1177
+
1178
+ if (notifySessionId) {
1179
+ await notifyLifecycle('session-idle', {
1180
+ sessionId: notifySessionId,
1181
+ projectPath: cwd,
1182
+ });
1183
+ await dispatchNativeHookEvent(cwd, 'session-idle', {
1184
+ ...payload,
1185
+ session_id: notifySessionId,
1186
+ }, {
1187
+ project_path: cwd,
1188
+ reason: 'post_turn_idle_notification',
1189
+ });
1190
+ }
1191
+ } catch {
1192
+ // Non-fatal: notification module may not be built or config may not exist
1193
+ }
1194
+ }
1034
1195
  }
1035
1196
 
1036
1197
  async function readdir(dir) {
@@ -149,10 +149,11 @@ export function buildSendKeysArgv({ paneTarget, prompt, dryRun }) {
149
149
  // 1) literal prompt bytes, 2) explicit carriage return.
150
150
  return {
151
151
  typeArgv: ['send-keys', '-t', paneTarget, '-l', prompt],
152
- // Some panes/shells swallow one of these key names; send both.
152
+ // Codex CLI uses raw input mode where 'Enter' key name is unreliable;
153
+ // send 'C-m' (carriage return) twice for reliable prompt submission.
153
154
  submitArgv: [
154
155
  ['send-keys', '-t', paneTarget, 'C-m'],
155
- ['send-keys', '-t', paneTarget, 'Enter'],
156
+ ['send-keys', '-t', paneTarget, 'C-m'],
156
157
  ],
157
158
  };
158
159
  }