oh-my-codex 0.8.0 → 0.8.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 (169) hide show
  1. package/README.de.md +7 -2
  2. package/README.es.md +7 -2
  3. package/README.fr.md +7 -2
  4. package/README.it.md +7 -2
  5. package/README.ja.md +7 -2
  6. package/README.ko.md +7 -2
  7. package/README.md +61 -11
  8. package/README.pt.md +7 -2
  9. package/README.ru.md +7 -2
  10. package/README.tr.md +7 -2
  11. package/README.vi.md +7 -2
  12. package/README.zh-TW.md +366 -0
  13. package/README.zh.md +7 -2
  14. package/dist/cli/__tests__/index.test.js +70 -4
  15. package/dist/cli/__tests__/index.test.js.map +1 -1
  16. package/dist/cli/__tests__/setup-skills-overwrite.test.js +100 -1
  17. package/dist/cli/__tests__/setup-skills-overwrite.test.js.map +1 -1
  18. package/dist/cli/__tests__/team.test.js +219 -1
  19. package/dist/cli/__tests__/team.test.js.map +1 -1
  20. package/dist/cli/catalog-contract.d.ts.map +1 -1
  21. package/dist/cli/catalog-contract.js +8 -2
  22. package/dist/cli/catalog-contract.js.map +1 -1
  23. package/dist/cli/index.d.ts +7 -1
  24. package/dist/cli/index.d.ts.map +1 -1
  25. package/dist/cli/index.js +58 -12
  26. package/dist/cli/index.js.map +1 -1
  27. package/dist/cli/setup.d.ts.map +1 -1
  28. package/dist/cli/setup.js +50 -17
  29. package/dist/cli/setup.js.map +1 -1
  30. package/dist/cli/team.d.ts.map +1 -1
  31. package/dist/cli/team.js +257 -0
  32. package/dist/cli/team.js.map +1 -1
  33. package/dist/config/__tests__/models.test.js +11 -11
  34. package/dist/config/__tests__/models.test.js.map +1 -1
  35. package/dist/config/models.d.ts +4 -3
  36. package/dist/config/models.d.ts.map +1 -1
  37. package/dist/config/models.js +6 -5
  38. package/dist/config/models.js.map +1 -1
  39. package/dist/hooks/__tests__/keyword-detector.test.js +46 -3
  40. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  41. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js +23 -7
  42. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js.map +1 -1
  43. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js +176 -1
  44. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js.map +1 -1
  45. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +61 -1
  46. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  47. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js +17 -7
  48. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js.map +1 -1
  49. package/dist/hooks/__tests__/openclaw-setup-contract.test.js +26 -16
  50. package/dist/hooks/__tests__/openclaw-setup-contract.test.js.map +1 -1
  51. package/dist/hooks/keyword-detector.d.ts +2 -1
  52. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  53. package/dist/hooks/keyword-detector.js +41 -4
  54. package/dist/hooks/keyword-detector.js.map +1 -1
  55. package/dist/hooks/keyword-registry.d.ts.map +1 -1
  56. package/dist/hooks/keyword-registry.js +5 -0
  57. package/dist/hooks/keyword-registry.js.map +1 -1
  58. package/dist/mcp/__tests__/path-traversal.test.js +9 -227
  59. package/dist/mcp/__tests__/path-traversal.test.js.map +1 -1
  60. package/dist/mcp/__tests__/state-server-schema.test.js +16 -20
  61. package/dist/mcp/__tests__/state-server-schema.test.js.map +1 -1
  62. package/dist/mcp/__tests__/state-server-team-tools.test.js +30 -487
  63. package/dist/mcp/__tests__/state-server-team-tools.test.js.map +1 -1
  64. package/dist/mcp/state-server.d.ts +179 -0
  65. package/dist/mcp/state-server.d.ts.map +1 -1
  66. package/dist/mcp/state-server.js +217 -1111
  67. package/dist/mcp/state-server.js.map +1 -1
  68. package/dist/mcp/team-server.d.ts.map +1 -1
  69. package/dist/mcp/team-server.js +28 -7
  70. package/dist/mcp/team-server.js.map +1 -1
  71. package/dist/notifications/__tests__/dispatch-cooldown.test.d.ts +5 -0
  72. package/dist/notifications/__tests__/dispatch-cooldown.test.d.ts.map +1 -0
  73. package/dist/notifications/__tests__/dispatch-cooldown.test.js +100 -0
  74. package/dist/notifications/__tests__/dispatch-cooldown.test.js.map +1 -0
  75. package/dist/notifications/__tests__/temp-mode.test.d.ts +2 -0
  76. package/dist/notifications/__tests__/temp-mode.test.d.ts.map +1 -0
  77. package/dist/notifications/__tests__/temp-mode.test.js +172 -0
  78. package/dist/notifications/__tests__/temp-mode.test.js.map +1 -0
  79. package/dist/notifications/config.d.ts.map +1 -1
  80. package/dist/notifications/config.js +59 -6
  81. package/dist/notifications/config.js.map +1 -1
  82. package/dist/notifications/dispatch-cooldown.d.ts +36 -0
  83. package/dist/notifications/dispatch-cooldown.d.ts.map +1 -0
  84. package/dist/notifications/dispatch-cooldown.js +109 -0
  85. package/dist/notifications/dispatch-cooldown.js.map +1 -0
  86. package/dist/notifications/index.d.ts +5 -0
  87. package/dist/notifications/index.d.ts.map +1 -1
  88. package/dist/notifications/index.js +39 -8
  89. package/dist/notifications/index.js.map +1 -1
  90. package/dist/notifications/temp-contract.d.ts +22 -0
  91. package/dist/notifications/temp-contract.d.ts.map +1 -0
  92. package/dist/notifications/temp-contract.js +147 -0
  93. package/dist/notifications/temp-contract.js.map +1 -0
  94. package/dist/notifications/types.d.ts +18 -0
  95. package/dist/notifications/types.d.ts.map +1 -1
  96. package/dist/openclaw/__tests__/config.test.js +81 -0
  97. package/dist/openclaw/__tests__/config.test.js.map +1 -1
  98. package/dist/openclaw/__tests__/dispatcher.test.js +50 -7
  99. package/dist/openclaw/__tests__/dispatcher.test.js.map +1 -1
  100. package/dist/openclaw/config.d.ts +4 -0
  101. package/dist/openclaw/config.d.ts.map +1 -1
  102. package/dist/openclaw/config.js +110 -16
  103. package/dist/openclaw/config.js.map +1 -1
  104. package/dist/openclaw/dispatcher.d.ts +10 -4
  105. package/dist/openclaw/dispatcher.d.ts.map +1 -1
  106. package/dist/openclaw/dispatcher.js +40 -10
  107. package/dist/openclaw/dispatcher.js.map +1 -1
  108. package/dist/openclaw/types.d.ts +5 -1
  109. package/dist/openclaw/types.d.ts.map +1 -1
  110. package/dist/team/__tests__/api-interop.test.d.ts +2 -0
  111. package/dist/team/__tests__/api-interop.test.d.ts.map +1 -0
  112. package/dist/team/__tests__/api-interop.test.js +1052 -0
  113. package/dist/team/__tests__/api-interop.test.js.map +1 -0
  114. package/dist/team/__tests__/mcp-comm.test.js +30 -0
  115. package/dist/team/__tests__/mcp-comm.test.js.map +1 -1
  116. package/dist/team/__tests__/runtime-cli.test.js +6 -0
  117. package/dist/team/__tests__/runtime-cli.test.js.map +1 -1
  118. package/dist/team/__tests__/runtime.test.js +52 -22
  119. package/dist/team/__tests__/runtime.test.js.map +1 -1
  120. package/dist/team/__tests__/tmux-claude-workers-demo.test.d.ts +2 -0
  121. package/dist/team/__tests__/tmux-claude-workers-demo.test.d.ts.map +1 -0
  122. package/dist/team/__tests__/tmux-claude-workers-demo.test.js +190 -0
  123. package/dist/team/__tests__/tmux-claude-workers-demo.test.js.map +1 -0
  124. package/dist/team/__tests__/tmux-session.test.js +45 -2
  125. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  126. package/dist/team/__tests__/worker-bootstrap.test.js +20 -12
  127. package/dist/team/__tests__/worker-bootstrap.test.js.map +1 -1
  128. package/dist/team/api-interop.d.ts +19 -0
  129. package/dist/team/api-interop.d.ts.map +1 -0
  130. package/dist/team/api-interop.js +578 -0
  131. package/dist/team/api-interop.js.map +1 -0
  132. package/dist/team/mcp-comm.d.ts.map +1 -1
  133. package/dist/team/mcp-comm.js +26 -0
  134. package/dist/team/mcp-comm.js.map +1 -1
  135. package/dist/team/runtime-cli.d.ts +3 -0
  136. package/dist/team/runtime-cli.d.ts.map +1 -1
  137. package/dist/team/runtime-cli.js +24 -2
  138. package/dist/team/runtime-cli.js.map +1 -1
  139. package/dist/team/runtime.d.ts.map +1 -1
  140. package/dist/team/runtime.js +67 -11
  141. package/dist/team/runtime.js.map +1 -1
  142. package/dist/team/scaling.js.map +1 -1
  143. package/dist/team/state/types.d.ts +1 -1
  144. package/dist/team/state/types.d.ts.map +1 -1
  145. package/dist/team/state.d.ts +1 -1
  146. package/dist/team/state.d.ts.map +1 -1
  147. package/dist/team/tmux-session.d.ts +1 -1
  148. package/dist/team/tmux-session.d.ts.map +1 -1
  149. package/dist/team/tmux-session.js +17 -5
  150. package/dist/team/tmux-session.js.map +1 -1
  151. package/dist/team/worker-bootstrap.d.ts.map +1 -1
  152. package/dist/team/worker-bootstrap.js +48 -19
  153. package/dist/team/worker-bootstrap.js.map +1 -1
  154. package/package.json +1 -1
  155. package/scripts/demo-claude-workers.sh +241 -0
  156. package/scripts/demo-team-e2e.sh +179 -0
  157. package/scripts/notify-hook/team-dispatch.js +186 -12
  158. package/scripts/notify-hook/team-leader-nudge.js +42 -2
  159. package/scripts/notify-hook/team-worker.js +63 -4
  160. package/skills/configure-notifications/SKILL.md +193 -185
  161. package/skills/omx-setup/SKILL.md +1 -1
  162. package/skills/team/SKILL.md +47 -5
  163. package/skills/worker/SKILL.md +40 -10
  164. package/templates/AGENTS.md +7 -3
  165. package/templates/catalog-manifest.json +26 -3
  166. package/skills/configure-discord/SKILL.md +0 -256
  167. package/skills/configure-openclaw/SKILL.md +0 -264
  168. package/skills/configure-slack/SKILL.md +0 -226
  169. package/skills/configure-telegram/SKILL.md +0 -232
@@ -23,6 +23,10 @@ async function writeJsonAtomic(path, value) {
23
23
  const DISPATCH_LOCK_STALE_MS = 5 * 60 * 1000;
24
24
  const DEFAULT_ISSUE_DISPATCH_COOLDOWN_MS = 15 * 60 * 1000;
25
25
  const ISSUE_DISPATCH_COOLDOWN_ENV = 'OMX_TEAM_DISPATCH_ISSUE_COOLDOWN_MS';
26
+ const DEFAULT_DISPATCH_TRIGGER_COOLDOWN_MS = 30 * 1000;
27
+ const DISPATCH_TRIGGER_COOLDOWN_ENV = 'OMX_TEAM_DISPATCH_TRIGGER_COOLDOWN_MS';
28
+ const LEADER_PANE_MISSING_DEFERRED_REASON = 'leader_pane_missing_deferred';
29
+ const LEADER_NOTIFICATION_DEFERRED_TYPE = 'leader_notification_deferred';
26
30
 
27
31
  function resolveIssueDispatchCooldownMs(env = process.env) {
28
32
  const raw = safeString(env[ISSUE_DISPATCH_COOLDOWN_ENV]).trim();
@@ -32,6 +36,14 @@ function resolveIssueDispatchCooldownMs(env = process.env) {
32
36
  return parsed;
33
37
  }
34
38
 
39
+ function resolveDispatchTriggerCooldownMs(env = process.env) {
40
+ const raw = safeString(env[DISPATCH_TRIGGER_COOLDOWN_ENV]).trim();
41
+ if (raw === '') return DEFAULT_DISPATCH_TRIGGER_COOLDOWN_MS;
42
+ const parsed = Number.parseInt(raw, 10);
43
+ if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_DISPATCH_TRIGGER_COOLDOWN_MS;
44
+ return parsed;
45
+ }
46
+
35
47
  function extractIssueKey(triggerMessage) {
36
48
  const match = safeString(triggerMessage).match(/\b([A-Z][A-Z0-9]+-\d+)\b/i);
37
49
  return match?.[1]?.toUpperCase() || null;
@@ -41,6 +53,10 @@ function issueCooldownStatePath(teamDirPath) {
41
53
  return join(teamDirPath, 'dispatch', 'issue-cooldown.json');
42
54
  }
43
55
 
56
+ function triggerCooldownStatePath(teamDirPath) {
57
+ return join(teamDirPath, 'dispatch', 'trigger-cooldown.json');
58
+ }
59
+
44
60
  async function readIssueCooldownState(teamDirPath) {
45
61
  const fallback = { by_issue: {} };
46
62
  const parsed = await readJson(issueCooldownStatePath(teamDirPath), fallback);
@@ -50,6 +66,32 @@ async function readIssueCooldownState(teamDirPath) {
50
66
  return parsed;
51
67
  }
52
68
 
69
+ async function readTriggerCooldownState(teamDirPath) {
70
+ const fallback = { by_trigger: {} };
71
+ const parsed = await readJson(triggerCooldownStatePath(teamDirPath), fallback);
72
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.by_trigger !== 'object' || parsed.by_trigger === null) {
73
+ return fallback;
74
+ }
75
+ return parsed;
76
+ }
77
+
78
+ function normalizeTriggerKey(value) {
79
+ return safeString(value).replace(/\s+/g, ' ').trim();
80
+ }
81
+
82
+ function parseTriggerCooldownEntry(entry) {
83
+ if (typeof entry === 'number') {
84
+ return { at: entry, lastRequestId: '' };
85
+ }
86
+ if (!entry || typeof entry !== 'object') {
87
+ return { at: NaN, lastRequestId: '' };
88
+ }
89
+ return {
90
+ at: Number(entry.at),
91
+ lastRequestId: safeString(entry.last_request_id).trim(),
92
+ };
93
+ }
94
+
53
95
  async function withDispatchLock(teamDirPath, fn) {
54
96
  const lockDir = join(teamDirPath, 'dispatch', '.lock');
55
97
  const ownerPath = join(lockDir, 'owner');
@@ -145,18 +187,15 @@ async function withMailboxLock(teamDirPath, workerName, fn) {
145
187
  }
146
188
 
147
189
  function defaultInjectTarget(request, config) {
190
+ if (request.to_worker === 'leader-fixed') {
191
+ if (config.leader_pane_id) return { type: 'pane', value: config.leader_pane_id };
192
+ return null;
193
+ }
148
194
  if (request.pane_id) return { type: 'pane', value: request.pane_id };
149
195
  if (typeof request.worker_index === 'number' && Array.isArray(config?.workers)) {
150
196
  const worker = config.workers.find((candidate) => Number(candidate?.index) === request.worker_index);
151
197
  if (worker?.pane_id) return { type: 'pane', value: worker.pane_id };
152
198
  }
153
- // Leader-fixed fallback: use config.leader_pane_id when request has no
154
- // pane_id or worker_index (leader is not a worker). Without this, leader
155
- // dispatch falls through to the session target which hits the active pane
156
- // (likely a worker). Fixes #433.
157
- if (request.to_worker === 'leader-fixed' && config.leader_pane_id) {
158
- return { type: 'pane', value: config.leader_pane_id };
159
- }
160
199
  if (typeof request.worker_index === 'number' && config.tmux_session) {
161
200
  return { type: 'pane', value: `${config.tmux_session}.${request.worker_index}` };
162
201
  }
@@ -164,6 +203,30 @@ function defaultInjectTarget(request, config) {
164
203
  return null;
165
204
  }
166
205
 
206
+ async function appendLeaderNotificationDeferredEvent({
207
+ stateDir,
208
+ teamName,
209
+ request,
210
+ reason,
211
+ nowIso,
212
+ }) {
213
+ const eventsDir = join(stateDir, 'team', teamName, 'events');
214
+ const eventsPath = join(eventsDir, 'events.ndjson');
215
+ const event = {
216
+ event_id: `leader-deferred-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
217
+ team: teamName,
218
+ type: LEADER_NOTIFICATION_DEFERRED_TYPE,
219
+ worker: request.to_worker,
220
+ to_worker: request.to_worker,
221
+ reason,
222
+ created_at: nowIso,
223
+ request_id: request.request_id,
224
+ ...(request.message_id ? { message_id: request.message_id } : {}),
225
+ };
226
+ await mkdir(eventsDir, { recursive: true }).catch(() => {});
227
+ await appendFile(eventsPath, JSON.stringify(event) + '\n').catch(() => {});
228
+ }
229
+
167
230
  function resolveWorkerCliForRequest(request, config) {
168
231
  const workers = Array.isArray(config?.workers) ? config.workers : [];
169
232
  const idx = Number.isFinite(request?.worker_index) ? Number(request.worker_index) : null;
@@ -184,6 +247,19 @@ function capturedPaneContainsTrigger(captured, trigger) {
184
247
  return normalizeCaptureText(captured).includes(normalizeCaptureText(trigger));
185
248
  }
186
249
 
250
+ function capturedPaneContainsTriggerNearTail(captured, trigger, nonEmptyTailLines = 24) {
251
+ if (!captured || !trigger) return false;
252
+ const normalizedTrigger = normalizeCaptureText(trigger);
253
+ if (!normalizedTrigger) return false;
254
+ const lines = safeString(captured)
255
+ .split('\n')
256
+ .map((line) => line.replace(/\r/g, '').trim())
257
+ .filter((line) => line.length > 0);
258
+ if (lines.length === 0) return false;
259
+ const tail = lines.slice(-Math.max(1, nonEmptyTailLines)).join(' ');
260
+ return normalizeCaptureText(tail).includes(normalizedTrigger);
261
+ }
262
+
187
263
  // Ported from src/team/tmux-session.ts:949-963 — detects active CLI task indicators.
188
264
  function paneHasActiveTask(captured) {
189
265
  const lines = safeString(captured)
@@ -200,6 +276,40 @@ function paneHasActiveTask(captured) {
200
276
  return false;
201
277
  }
202
278
 
279
+ function paneIsBootstrapping(captured) {
280
+ const lines = safeString(captured)
281
+ .split('\n')
282
+ .map((line) => line.replace(/\r/g, '').trim())
283
+ .filter((line) => line.length > 0);
284
+ return lines.some((line) =>
285
+ /\b(loading|initializing|starting up)\b/i.test(line)
286
+ || /\bmodel:\s*loading\b/i.test(line)
287
+ || /\bconnecting\s+to\b/i.test(line),
288
+ );
289
+ }
290
+
291
+ function paneLooksReady(captured) {
292
+ const content = safeString(captured).trimEnd();
293
+ if (content === '') return false;
294
+
295
+ const lines = content
296
+ .split('\n')
297
+ .map((line) => line.replace(/\r/g, ''))
298
+ .map((line) => line.trimEnd())
299
+ .filter((line) => line.trim() !== '');
300
+
301
+ if (paneIsBootstrapping(content)) return false;
302
+
303
+ const lastLine = lines.length > 0 ? lines[lines.length - 1] : '';
304
+ if (/^\s*[›>❯]\s*/u.test(lastLine)) return true;
305
+
306
+ const hasCodexPromptLine = lines.some((line) => /^\s*›\s*/u.test(line));
307
+ const hasClaudePromptLine = lines.some((line) => /^\s*❯\s*/u.test(line));
308
+ if (hasCodexPromptLine || hasClaudePromptLine) return true;
309
+
310
+ return false;
311
+ }
312
+
203
313
  const INJECT_VERIFY_DELAY_MS = 250;
204
314
  const INJECT_VERIFY_ROUNDS = 3;
205
315
 
@@ -266,16 +376,29 @@ async function injectDispatchRequest(request, config, cwd) {
266
376
  for (let round = 0; round < INJECT_VERIFY_ROUNDS; round++) {
267
377
  await new Promise((r) => setTimeout(r, INJECT_VERIFY_DELAY_MS));
268
378
  try {
269
- // Primary: trigger text no longer in narrow input area
379
+ // Primary: trigger text no longer in narrow input area.
380
+ // Secondary guard: also inspect the recent non-empty tail of wide capture.
381
+ // This avoids false confirmations when Codex leaves the unsent draft just
382
+ // above a large blank area (narrow capture misses it) while still avoiding
383
+ // full-scrollback false positives.
270
384
  const narrowCap = await runProcess('tmux', verifyNarrowArgv, 2000);
271
- if (!capturedPaneContainsTrigger(narrowCap.stdout, request.trigger_message)) {
272
- return { ok: true, reason: 'tmux_send_keys_confirmed', pane: resolution.paneTarget };
273
- }
274
- // Secondary: worker is actively processing (mirrors sync path tmux-session.ts:1292-1294)
275
385
  const wideCap = await runProcess('tmux', verifyWideArgv, 2000);
386
+ // Worker is actively processing (mirrors sync path tmux-session.ts:1292-1294)
276
387
  if (paneHasActiveTask(wideCap.stdout)) {
277
388
  return { ok: true, reason: 'tmux_send_keys_confirmed_active_task', pane: resolution.paneTarget };
278
389
  }
390
+ // Do not declare success while a *worker* pane is still bootstrapping / not
391
+ // input-ready. Otherwise a pre-ready send can be marked "confirmed" and later
392
+ // appear as a stuck unsent draft once the UI finishes loading.
393
+ // Keep leader-fixed behavior unchanged to avoid regressing leader notification flow.
394
+ if (request.to_worker !== 'leader-fixed' && !paneLooksReady(wideCap.stdout)) {
395
+ continue;
396
+ }
397
+ const triggerInNarrow = capturedPaneContainsTrigger(narrowCap.stdout, request.trigger_message);
398
+ const triggerNearTail = capturedPaneContainsTriggerNearTail(wideCap.stdout, request.trigger_message);
399
+ if (!triggerInNarrow && !triggerNearTail) {
400
+ return { ok: true, reason: 'tmux_send_keys_confirmed', pane: resolution.paneTarget };
401
+ }
279
402
  } catch {
280
403
  // capture failed; fall through to retry C-m
281
404
  }
@@ -332,6 +455,7 @@ export async function drainPendingTeamDispatch({
332
455
  let skipped = 0;
333
456
  let failed = 0;
334
457
  const issueCooldownMs = resolveIssueDispatchCooldownMs();
458
+ const triggerCooldownMs = resolveDispatchTriggerCooldownMs();
335
459
 
336
460
  for (const teamName of teams) {
337
461
  if (processed >= maxPerTick) break;
@@ -346,7 +470,9 @@ export async function drainPendingTeamDispatch({
346
470
  const requests = await readJson(requestsPath, []);
347
471
  if (!Array.isArray(requests)) return;
348
472
  const issueCooldownState = await readIssueCooldownState(teamDirPath);
473
+ const triggerCooldownState = await readTriggerCooldownState(teamDirPath);
349
474
  const issueCooldownByIssue = issueCooldownState.by_issue || {};
475
+ const triggerCooldownByKey = triggerCooldownState.by_trigger || {};
350
476
  const nowMs = Date.now();
351
477
 
352
478
  let mutated = false;
@@ -358,6 +484,34 @@ export async function drainPendingTeamDispatch({
358
484
  continue;
359
485
  }
360
486
 
487
+ if (request.to_worker === 'leader-fixed' && !safeString(config?.leader_pane_id).trim()) {
488
+ const nowIso = new Date().toISOString();
489
+ request.updated_at = nowIso;
490
+ request.last_reason = LEADER_PANE_MISSING_DEFERRED_REASON;
491
+ request.status = 'pending';
492
+ skipped += 1;
493
+ mutated = true;
494
+ await appendDispatchLog(logsDir, {
495
+ type: 'dispatch_deferred',
496
+ team: teamName,
497
+ request_id: request.request_id,
498
+ worker: request.to_worker,
499
+ to_worker: request.to_worker,
500
+ message_id: request.message_id || null,
501
+ reason: LEADER_PANE_MISSING_DEFERRED_REASON,
502
+ status: 'pending',
503
+ tmux_injection_attempted: false,
504
+ });
505
+ await appendLeaderNotificationDeferredEvent({
506
+ stateDir,
507
+ teamName,
508
+ request,
509
+ reason: LEADER_PANE_MISSING_DEFERRED_REASON,
510
+ nowIso,
511
+ });
512
+ continue;
513
+ }
514
+
361
515
  const issueKey = extractIssueKey(request.trigger_message);
362
516
  if (issueCooldownMs > 0 && issueKey) {
363
517
  const lastInjectedMs = Number(issueCooldownByIssue[issueKey]);
@@ -367,11 +521,29 @@ export async function drainPendingTeamDispatch({
367
521
  }
368
522
  }
369
523
 
524
+ const triggerKey = normalizeTriggerKey(request.trigger_message);
525
+ if (triggerCooldownMs > 0 && triggerKey) {
526
+ const parsed = parseTriggerCooldownEntry(triggerCooldownByKey[triggerKey]);
527
+ const withinCooldown = Number.isFinite(parsed.at) && parsed.at > 0 && nowMs - parsed.at < triggerCooldownMs;
528
+ const sameRequestRetry = parsed.lastRequestId !== '' && parsed.lastRequestId === safeString(request.request_id).trim();
529
+ if (withinCooldown && !sameRequestRetry) {
530
+ skipped += 1;
531
+ continue;
532
+ }
533
+ }
534
+
370
535
  const result = await injector(request, config, resolve(cwd));
371
536
  if (issueKey && issueCooldownMs > 0) {
372
537
  issueCooldownByIssue[issueKey] = Date.now();
373
538
  mutated = true;
374
539
  }
540
+ if (triggerKey && triggerCooldownMs > 0) {
541
+ triggerCooldownByKey[triggerKey] = {
542
+ at: Date.now(),
543
+ last_request_id: safeString(request.request_id).trim(),
544
+ };
545
+ mutated = true;
546
+ }
375
547
  const nowIso = new Date().toISOString();
376
548
  request.attempt_count = Number.isFinite(request.attempt_count) ? Math.max(0, request.attempt_count + 1) : 1;
377
549
  request.updated_at = nowIso;
@@ -449,6 +621,8 @@ export async function drainPendingTeamDispatch({
449
621
  if (mutated) {
450
622
  issueCooldownState.by_issue = issueCooldownByIssue;
451
623
  await writeJsonAtomic(issueCooldownStatePath(teamDirPath), issueCooldownState);
624
+ triggerCooldownState.by_trigger = triggerCooldownByKey;
625
+ await writeJsonAtomic(triggerCooldownStatePath(teamDirPath), triggerCooldownState);
452
626
  await writeJsonAtomic(requestsPath, requests);
453
627
  }
454
628
  });
@@ -10,6 +10,8 @@ import { readJsonIfExists, getScopedStateDirsForCurrentSession } from './state-i
10
10
  import { runProcess } from './process-runner.js';
11
11
  import { logTmuxHookEvent } from './log.js';
12
12
  import { DEFAULT_MARKER } from '../tmux-hook-engine.js';
13
+ const LEADER_PANE_MISSING_NO_INJECTION_REASON = 'leader_pane_missing_no_injection';
14
+ const LEADER_NOTIFICATION_DEFERRED_TYPE = 'leader_notification_deferred';
13
15
 
14
16
  export function resolveLeaderNudgeIntervalMs() {
15
17
  const raw = safeString(process.env.OMX_TEAM_LEADER_NUDGE_MS || '');
@@ -109,6 +111,26 @@ export async function emitTeamNudgeEvent(cwd, teamName, reason, nowIso) {
109
111
  }
110
112
  }
111
113
 
114
+ async function emitLeaderNudgeDeferredEvent(cwd, teamName, reason, nowIso) {
115
+ const eventsDir = join(cwd, '.omx', 'state', 'team', teamName, 'events');
116
+ const eventsPath = join(eventsDir, 'events.ndjson');
117
+ try {
118
+ await mkdir(eventsDir, { recursive: true });
119
+ const event = {
120
+ event_id: `leader-deferred-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
121
+ team: teamName,
122
+ type: LEADER_NOTIFICATION_DEFERRED_TYPE,
123
+ worker: 'leader-fixed',
124
+ to_worker: 'leader-fixed',
125
+ reason,
126
+ created_at: nowIso,
127
+ };
128
+ await appendFile(eventsPath, JSON.stringify(event) + '\n');
129
+ } catch {
130
+ // Best effort
131
+ }
132
+ }
133
+
112
134
  export async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir, preComputedLeaderStale }) {
113
135
  const intervalMs = resolveLeaderNudgeIntervalMs();
114
136
  const idleCooldownMs = resolveLeaderAllIdleNudgeCooldownMs();
@@ -163,8 +185,8 @@ export async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir, preComputed
163
185
  } catch {
164
186
  // ignore
165
187
  }
166
- const tmuxTarget = leaderPaneId || tmuxSession;
167
- if (!tmuxTarget) continue;
188
+ if (!tmuxSession && !leaderPaneId) continue;
189
+ const tmuxTarget = leaderPaneId;
168
190
 
169
191
  const paneStatus = tmuxSession
170
192
  ? await checkWorkerPanesAlive(tmuxSession)
@@ -234,6 +256,24 @@ export async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir, preComputed
234
256
  const capped = text.length > 180 ? `${text.slice(0, 177)}...` : text;
235
257
  const markedText = `${capped} ${DEFAULT_MARKER}`;
236
258
 
259
+ if (!tmuxTarget) {
260
+ await emitLeaderNudgeDeferredEvent(cwd, teamName, LEADER_PANE_MISSING_NO_INJECTION_REASON, nowIso);
261
+ try {
262
+ await logTmuxHookEvent(logsDir, {
263
+ timestamp: nowIso,
264
+ type: LEADER_NOTIFICATION_DEFERRED_TYPE,
265
+ team: teamName,
266
+ worker: 'leader-fixed',
267
+ to_worker: 'leader-fixed',
268
+ reason: LEADER_PANE_MISSING_NO_INJECTION_REASON,
269
+ leader_pane_id: null,
270
+ tmux_session: tmuxSession || null,
271
+ tmux_injection_attempted: false,
272
+ });
273
+ } catch { /* ignore */ }
274
+ continue;
275
+ }
276
+
237
277
  try {
238
278
  await runProcess('tmux', ['send-keys', '-t', tmuxTarget, '-l', markedText], 3000);
239
279
  await new Promise(r => setTimeout(r, 100));
@@ -194,6 +194,43 @@ export async function readTeamWorkersForIdleCheck(stateDir, teamName) {
194
194
  }
195
195
  }
196
196
 
197
+ async function emitLeaderPaneMissingDeferred({
198
+ stateDir,
199
+ logsDir,
200
+ teamName,
201
+ workerName,
202
+ tmuxSession,
203
+ leaderPaneId,
204
+ reason = 'leader_pane_missing_no_injection',
205
+ }) {
206
+ const nowIso = new Date().toISOString();
207
+ await logTmuxHookEvent(logsDir, {
208
+ timestamp: nowIso,
209
+ type: 'leader_notification_deferred',
210
+ team: teamName,
211
+ worker: workerName,
212
+ to_worker: 'leader-fixed',
213
+ reason,
214
+ leader_pane_id: leaderPaneId || null,
215
+ tmux_session: tmuxSession || null,
216
+ tmux_injection_attempted: false,
217
+ }).catch(() => {});
218
+
219
+ const eventsDir = join(stateDir, 'team', teamName, 'events');
220
+ const eventsPath = join(eventsDir, 'events.ndjson');
221
+ await mkdir(eventsDir, { recursive: true }).catch(() => {});
222
+ const event = {
223
+ event_id: `leader-deferred-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
224
+ team: teamName,
225
+ type: 'leader_notification_deferred',
226
+ worker: workerName,
227
+ to_worker: 'leader-fixed',
228
+ reason,
229
+ created_at: nowIso,
230
+ };
231
+ await appendFile(eventsPath, JSON.stringify(event) + '\n').catch(() => {});
232
+ }
233
+
197
234
  export async function updateWorkerHeartbeat(stateDir, teamName, workerName) {
198
235
  const heartbeatPath = join(stateDir, 'team', teamName, 'workers', workerName, 'heartbeat.json');
199
236
  let turnCount = 0;
@@ -228,8 +265,6 @@ export async function maybeNotifyLeaderAllWorkersIdle({ cwd, stateDir, logsDir,
228
265
  const teamInfo = await readTeamWorkersForIdleCheck(stateDir, teamName);
229
266
  if (!teamInfo) return;
230
267
  const { workers, tmuxSession, leaderPaneId } = teamInfo;
231
- const tmuxTarget = leaderPaneId || tmuxSession;
232
- if (!tmuxTarget) return;
233
268
 
234
269
  // Check cooldown to prevent notification spam
235
270
  const idleStatePath = join(stateDir, 'team', teamName, 'all-workers-idle.json');
@@ -252,8 +287,21 @@ export async function maybeNotifyLeaderAllWorkersIdle({ cwd, stateDir, logsDir,
252
287
  );
253
288
  if (!allIdle) return;
254
289
 
290
+ if (!leaderPaneId) {
291
+ await emitLeaderPaneMissingDeferred({
292
+ stateDir,
293
+ logsDir,
294
+ teamName,
295
+ workerName,
296
+ tmuxSession,
297
+ leaderPaneId,
298
+ });
299
+ return;
300
+ }
301
+
255
302
  const N = workers.length;
256
303
  const message = `[OMX] All ${N} worker${N === 1 ? '' : 's'} idle. Ready for next instructions. ${DEFAULT_MARKER}`;
304
+ const tmuxTarget = leaderPaneId;
257
305
 
258
306
  try {
259
307
  await runProcess('tmux', ['send-keys', '-t', tmuxTarget, '-l', message], 3000);
@@ -382,8 +430,19 @@ export async function maybeNotifyLeaderWorkerIdle({ cwd, stateDir, logsDir, pars
382
430
  const teamInfo = await readTeamWorkersForIdleCheck(stateDir, teamName);
383
431
  if (!teamInfo) return;
384
432
  const { tmuxSession, leaderPaneId } = teamInfo;
385
- const tmuxTarget = leaderPaneId || tmuxSession;
386
- if (!tmuxTarget) return;
433
+
434
+ if (!leaderPaneId) {
435
+ await emitLeaderPaneMissingDeferred({
436
+ stateDir,
437
+ logsDir,
438
+ teamName,
439
+ workerName,
440
+ tmuxSession,
441
+ leaderPaneId,
442
+ });
443
+ return;
444
+ }
445
+ const tmuxTarget = leaderPaneId;
387
446
 
388
447
  // Build notification message with context
389
448
  const parts = [`[OMX] ${workerName} idle`];