oh-my-codex 0.8.11 → 0.8.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 (162) hide show
  1. package/README.md +43 -35
  2. package/dist/agents/__tests__/definitions.test.js +1 -0
  3. package/dist/agents/__tests__/definitions.test.js.map +1 -1
  4. package/dist/agents/definitions.d.ts.map +1 -1
  5. package/dist/agents/definitions.js +11 -0
  6. package/dist/agents/definitions.js.map +1 -1
  7. package/dist/cli/__tests__/doctor-invalid-config.test.d.ts +2 -0
  8. package/dist/cli/__tests__/doctor-invalid-config.test.d.ts.map +1 -0
  9. package/dist/cli/__tests__/doctor-invalid-config.test.js +52 -0
  10. package/dist/cli/__tests__/doctor-invalid-config.test.js.map +1 -0
  11. package/dist/cli/__tests__/index.test.js +35 -3
  12. package/dist/cli/__tests__/index.test.js.map +1 -1
  13. package/dist/cli/__tests__/launch-fallback.test.d.ts +2 -0
  14. package/dist/cli/__tests__/launch-fallback.test.d.ts.map +1 -0
  15. package/dist/cli/__tests__/launch-fallback.test.js +60 -0
  16. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -0
  17. package/dist/cli/__tests__/resume.test.d.ts +2 -0
  18. package/dist/cli/__tests__/resume.test.d.ts.map +1 -0
  19. package/dist/cli/__tests__/resume.test.js +78 -0
  20. package/dist/cli/__tests__/resume.test.js.map +1 -0
  21. package/dist/cli/__tests__/session-search-help.test.d.ts +2 -0
  22. package/dist/cli/__tests__/session-search-help.test.d.ts.map +1 -0
  23. package/dist/cli/__tests__/session-search-help.test.js +36 -0
  24. package/dist/cli/__tests__/session-search-help.test.js.map +1 -0
  25. package/dist/cli/__tests__/session-search.test.d.ts +2 -0
  26. package/dist/cli/__tests__/session-search.test.d.ts.map +1 -0
  27. package/dist/cli/__tests__/session-search.test.js +77 -0
  28. package/dist/cli/__tests__/session-search.test.js.map +1 -0
  29. package/dist/cli/__tests__/setup-prompts-overwrite.test.js +2 -0
  30. package/dist/cli/__tests__/setup-prompts-overwrite.test.js.map +1 -1
  31. package/dist/cli/__tests__/team-decompose.test.js +41 -15
  32. package/dist/cli/__tests__/team-decompose.test.js.map +1 -1
  33. package/dist/cli/__tests__/team.test.js +208 -3
  34. package/dist/cli/__tests__/team.test.js.map +1 -1
  35. package/dist/cli/doctor.d.ts.map +1 -1
  36. package/dist/cli/doctor.js +26 -0
  37. package/dist/cli/doctor.js.map +1 -1
  38. package/dist/cli/index.d.ts +4 -3
  39. package/dist/cli/index.d.ts.map +1 -1
  40. package/dist/cli/index.js +73 -27
  41. package/dist/cli/index.js.map +1 -1
  42. package/dist/cli/session-search.d.ts +8 -0
  43. package/dist/cli/session-search.d.ts.map +1 -0
  44. package/dist/cli/session-search.js +133 -0
  45. package/dist/cli/session-search.js.map +1 -0
  46. package/dist/cli/team.d.ts +13 -12
  47. package/dist/cli/team.d.ts.map +1 -1
  48. package/dist/cli/team.js +123 -39
  49. package/dist/cli/team.js.map +1 -1
  50. package/dist/hooks/__tests__/agents-overlay.test.js +33 -1
  51. package/dist/hooks/__tests__/agents-overlay.test.js.map +1 -1
  52. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +219 -0
  53. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
  54. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js +1 -0
  55. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js.map +1 -1
  56. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js +64 -1
  57. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js.map +1 -1
  58. package/dist/hooks/__tests__/notify-hook-modules.test.js +7 -0
  59. package/dist/hooks/__tests__/notify-hook-modules.test.js.map +1 -1
  60. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js +2 -1
  61. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js.map +1 -1
  62. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +420 -5
  63. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  64. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js +95 -0
  65. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js.map +1 -1
  66. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js +3 -0
  67. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js.map +1 -1
  68. package/dist/hooks/__tests__/tmux-hook-engine.test.js +39 -1
  69. package/dist/hooks/__tests__/tmux-hook-engine.test.js.map +1 -1
  70. package/dist/hooks/agents-overlay.d.ts +6 -1
  71. package/dist/hooks/agents-overlay.d.ts.map +1 -1
  72. package/dist/hooks/agents-overlay.js +45 -4
  73. package/dist/hooks/agents-overlay.js.map +1 -1
  74. package/dist/mcp/team-server.js +1 -1
  75. package/dist/mcp/team-server.js.map +1 -1
  76. package/dist/session-history/__tests__/search.test.d.ts +2 -0
  77. package/dist/session-history/__tests__/search.test.d.ts.map +1 -0
  78. package/dist/session-history/__tests__/search.test.js +150 -0
  79. package/dist/session-history/__tests__/search.test.js.map +1 -0
  80. package/dist/session-history/search.d.ts +31 -0
  81. package/dist/session-history/search.d.ts.map +1 -0
  82. package/dist/session-history/search.js +326 -0
  83. package/dist/session-history/search.js.map +1 -0
  84. package/dist/team/__tests__/allocation-policy.test.d.ts +2 -0
  85. package/dist/team/__tests__/allocation-policy.test.d.ts.map +1 -0
  86. package/dist/team/__tests__/allocation-policy.test.js +39 -0
  87. package/dist/team/__tests__/allocation-policy.test.js.map +1 -0
  88. package/dist/team/__tests__/api-interop.test.js +140 -4
  89. package/dist/team/__tests__/api-interop.test.js.map +1 -1
  90. package/dist/team/__tests__/followup-planner.test.js +12 -0
  91. package/dist/team/__tests__/followup-planner.test.js.map +1 -1
  92. package/dist/team/__tests__/idle-nudge.test.js +6 -1
  93. package/dist/team/__tests__/idle-nudge.test.js.map +1 -1
  94. package/dist/team/__tests__/rebalance-policy.test.d.ts +2 -0
  95. package/dist/team/__tests__/rebalance-policy.test.d.ts.map +1 -0
  96. package/dist/team/__tests__/rebalance-policy.test.js +125 -0
  97. package/dist/team/__tests__/rebalance-policy.test.js.map +1 -0
  98. package/dist/team/__tests__/runtime.test.js +315 -12
  99. package/dist/team/__tests__/runtime.test.js.map +1 -1
  100. package/dist/team/__tests__/state.test.js +20 -1
  101. package/dist/team/__tests__/state.test.js.map +1 -1
  102. package/dist/team/__tests__/team-ops-contract.test.js +1 -0
  103. package/dist/team/__tests__/team-ops-contract.test.js.map +1 -1
  104. package/dist/team/__tests__/worker-bootstrap.test.js +20 -3
  105. package/dist/team/__tests__/worker-bootstrap.test.js.map +1 -1
  106. package/dist/team/allocation-policy.d.ts +23 -0
  107. package/dist/team/allocation-policy.d.ts.map +1 -0
  108. package/dist/team/allocation-policy.js +71 -0
  109. package/dist/team/allocation-policy.js.map +1 -0
  110. package/dist/team/api-interop.d.ts +1 -1
  111. package/dist/team/api-interop.d.ts.map +1 -1
  112. package/dist/team/api-interop.js +159 -0
  113. package/dist/team/api-interop.js.map +1 -1
  114. package/dist/team/idle-nudge.js +1 -1
  115. package/dist/team/idle-nudge.js.map +1 -1
  116. package/dist/team/rebalance-policy.d.ts +19 -0
  117. package/dist/team/rebalance-policy.d.ts.map +1 -0
  118. package/dist/team/rebalance-policy.js +48 -0
  119. package/dist/team/rebalance-policy.js.map +1 -0
  120. package/dist/team/runtime.d.ts.map +1 -1
  121. package/dist/team/runtime.js +132 -17
  122. package/dist/team/runtime.js.map +1 -1
  123. package/dist/team/state/types.d.ts +3 -0
  124. package/dist/team/state/types.d.ts.map +1 -1
  125. package/dist/team/state/types.js.map +1 -1
  126. package/dist/team/state.d.ts +8 -0
  127. package/dist/team/state.d.ts.map +1 -1
  128. package/dist/team/state.js +28 -12
  129. package/dist/team/state.js.map +1 -1
  130. package/dist/team/team-ops.d.ts +2 -1
  131. package/dist/team/team-ops.d.ts.map +1 -1
  132. package/dist/team/team-ops.js +1 -0
  133. package/dist/team/team-ops.js.map +1 -1
  134. package/dist/team/tmux-session.d.ts +5 -4
  135. package/dist/team/tmux-session.d.ts.map +1 -1
  136. package/dist/team/tmux-session.js +5 -67
  137. package/dist/team/tmux-session.js.map +1 -1
  138. package/dist/team/worker-bootstrap.d.ts +1 -0
  139. package/dist/team/worker-bootstrap.d.ts.map +1 -1
  140. package/dist/team/worker-bootstrap.js +9 -2
  141. package/dist/team/worker-bootstrap.js.map +1 -1
  142. package/package.json +2 -1
  143. package/prompts/team-executor.md +57 -0
  144. package/prompts/team-orchestrator.md +8 -0
  145. package/scripts/notify-fallback-watcher.js +295 -1
  146. package/scripts/notify-hook/auto-nudge.js +20 -4
  147. package/scripts/notify-hook/team-dispatch.js +11 -58
  148. package/scripts/notify-hook/team-leader-nudge.js +59 -12
  149. package/scripts/notify-hook/team-tmux-guard.js +28 -11
  150. package/scripts/notify-hook/team-worker.js +3 -1
  151. package/scripts/notify-hook/tmux-injection.js +12 -13
  152. package/scripts/tmux-hook-engine.js +56 -0
  153. package/skills/team/SKILL.md +14 -0
  154. package/templates/catalog-manifest.json +5 -0
  155. package/dist/rtk/__tests__/index.test.d.ts +0 -2
  156. package/dist/rtk/__tests__/index.test.d.ts.map +0 -1
  157. package/dist/rtk/__tests__/index.test.js +0 -104
  158. package/dist/rtk/__tests__/index.test.js.map +0 -1
  159. package/dist/rtk/index.d.ts +0 -130
  160. package/dist/rtk/index.d.ts.map +0 -1
  161. package/dist/rtk/index.js +0 -257
  162. package/dist/rtk/index.js.map +0 -1
@@ -6,6 +6,14 @@ import { spawnSync } from 'child_process';
6
6
  import { dirname, join, resolve } from 'path';
7
7
  import { homedir } from 'os';
8
8
  import { drainPendingTeamDispatch } from './notify-hook/team-dispatch.js';
9
+ import { resolveNudgePaneTarget } from './notify-hook/auto-nudge.js';
10
+ import { checkPaneReadyForTeamSendKeys } from './notify-hook/team-tmux-guard.js';
11
+ import {
12
+ isLeaderStale,
13
+ maybeNudgeTeamLeader,
14
+ resolveLeaderStalenessThresholdMs,
15
+ } from './notify-hook/team-leader-nudge.js';
16
+ import { DEFAULT_MARKER } from './tmux-hook-engine.js';
9
17
 
10
18
  function argValue(name, fallback = '') {
11
19
  const idx = process.argv.indexOf(name);
@@ -35,7 +43,10 @@ function isPidAlive(pid) {
35
43
  const cwd = resolve(argValue('--cwd', process.cwd()));
36
44
  const notifyScript = resolve(argValue('--notify-script', join(cwd, 'scripts', 'notify-hook.js')));
37
45
  const runOnce = process.argv.includes('--once');
38
- const pollMs = Math.max(50, asNumber(argValue('--poll-ms', '700'), 700));
46
+ // Keep fallback control-plane ticks comfortably below the default dispatch
47
+ // ack budget so leaderless team dispatch + stale-alert recovery do not feel
48
+ // laggy between native notify-hook turns.
49
+ const pollMs = Math.max(50, asNumber(argValue('--poll-ms', '250'), 250));
39
50
  const parentPid = Math.trunc(asNumber(argValue('--parent-pid', String(process.ppid || 0)), process.ppid || 0));
40
51
  const startedAt = Date.now();
41
52
  const fileWindowMs = runOnce ? 15000 : 30000;
@@ -56,6 +67,9 @@ const stateDir = join(omxDir, 'state');
56
67
  const statePath = join(stateDir, 'notify-fallback-state.json');
57
68
  const pidFilePath = resolve(argValue('--pid-file', join(stateDir, 'notify-fallback.pid')));
58
69
  const logPath = join(logsDir, `notify-fallback-${new Date().toISOString().split('T')[0]}.jsonl`);
70
+ const RALPH_CONTINUE_TEXT = 'Ralph loop active continue';
71
+ const RALPH_CONTINUE_CADENCE_MS = 60_000;
72
+ const RALPH_TERMINAL_PHASES = new Set(['complete', 'failed', 'cancelled']);
59
73
 
60
74
  const fileState = new Map();
61
75
  const seenTurnKeys = new Set();
@@ -69,11 +83,185 @@ let lastDispatchDrain = {
69
83
  last_result: null,
70
84
  last_error: null,
71
85
  };
86
+ let leaderNudgeRuns = 0;
87
+ let lastLeaderNudge = {
88
+ enabled: true,
89
+ leader_only: safeString(process.env.OMX_TEAM_WORKER || '').trim() === '',
90
+ stale_threshold_ms: null,
91
+ precomputed_leader_stale: null,
92
+ last_tick_at: null,
93
+ last_error: null,
94
+ };
95
+ let lastRalphContinueSteer = {
96
+ enabled: true,
97
+ cadence_ms: RALPH_CONTINUE_CADENCE_MS,
98
+ message: RALPH_CONTINUE_TEXT,
99
+ active: false,
100
+ last_state_check_at: null,
101
+ last_sent_at: '',
102
+ last_reason: 'init',
103
+ state_path: '',
104
+ pane_id: '',
105
+ pane_current_command: '',
106
+ current_phase: '',
107
+ };
108
+ let lastParentGuard = {
109
+ reason: '',
110
+ state_path: '',
111
+ current_phase: '',
112
+ };
72
113
 
73
114
  function eventLog(event) {
74
115
  return appendFile(logPath, `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`).catch(() => {});
75
116
  }
76
117
 
118
+ function normalizeRalphContinueSteerState(raw) {
119
+ if (!raw || typeof raw !== 'object') return { ...lastRalphContinueSteer };
120
+ return {
121
+ enabled: raw.enabled !== false,
122
+ cadence_ms: Number.isFinite(raw.cadence_ms) && raw.cadence_ms > 0 ? raw.cadence_ms : RALPH_CONTINUE_CADENCE_MS,
123
+ message: safeString(raw.message) || RALPH_CONTINUE_TEXT,
124
+ active: raw.active === true,
125
+ last_state_check_at: safeString(raw.last_state_check_at) || null,
126
+ last_sent_at: safeString(raw.last_sent_at),
127
+ last_reason: safeString(raw.last_reason) || 'init',
128
+ state_path: safeString(raw.state_path),
129
+ pane_id: safeString(raw.pane_id),
130
+ pane_current_command: safeString(raw.pane_current_command),
131
+ current_phase: safeString(raw.current_phase),
132
+ };
133
+ }
134
+
135
+ function hasRalphTerminalState(raw) {
136
+ if (!raw || typeof raw !== 'object') return true;
137
+ if (raw.active !== true) return true;
138
+ const phase = safeString(raw.current_phase).trim().toLowerCase();
139
+ if (phase && RALPH_TERMINAL_PHASES.has(phase)) return true;
140
+ if (safeString(raw.completed_at).trim()) return true;
141
+ return false;
142
+ }
143
+
144
+ async function loadPersistedWatcherState() {
145
+ const persisted = await readFile(statePath, 'utf-8')
146
+ .then((content) => JSON.parse(content))
147
+ .catch(() => null);
148
+ lastRalphContinueSteer = normalizeRalphContinueSteerState(persisted?.ralph_continue_steer);
149
+ }
150
+
151
+ async function resolveActiveRalphState() {
152
+ const candidateDirs = [];
153
+ const sessionPath = join(stateDir, 'session.json');
154
+ try {
155
+ const session = JSON.parse(await readFile(sessionPath, 'utf-8'));
156
+ const sessionId = safeString(session?.session_id).trim();
157
+ if (sessionId) {
158
+ candidateDirs.push(join(stateDir, 'sessions', sessionId));
159
+ }
160
+ } catch {
161
+ // No active session file; fall back to root state only.
162
+ }
163
+ if (!candidateDirs.includes(stateDir)) candidateDirs.push(stateDir);
164
+
165
+ for (const dir of candidateDirs) {
166
+ const path = join(dir, 'ralph-state.json');
167
+ if (!existsSync(path)) continue;
168
+ const parsed = await readFile(path, 'utf-8')
169
+ .then((content) => JSON.parse(content))
170
+ .catch(() => null);
171
+ if (!parsed || typeof parsed !== 'object') continue;
172
+ if (hasRalphTerminalState(parsed)) {
173
+ return {
174
+ active: false,
175
+ reason: 'terminal',
176
+ path,
177
+ state: parsed,
178
+ };
179
+ }
180
+ return {
181
+ active: true,
182
+ reason: 'active',
183
+ path,
184
+ state: parsed,
185
+ };
186
+ }
187
+
188
+ return {
189
+ active: false,
190
+ reason: 'cleared',
191
+ path: '',
192
+ state: null,
193
+ };
194
+ }
195
+
196
+ async function emitRalphContinueSteer(paneId, message) {
197
+ const markedText = `${message} ${DEFAULT_MARKER}`;
198
+ await new Promise((resolve) => {
199
+ const typed = spawnSync('tmux', ['send-keys', '-t', paneId, '-l', markedText], { encoding: 'utf-8' });
200
+ if (typed.status !== 0) throw new Error((typed.stderr || typed.stdout || '').trim() || 'tmux send-keys failed');
201
+ setTimeout(resolve, 100);
202
+ });
203
+ await new Promise((resolve) => {
204
+ const submitA = spawnSync('tmux', ['send-keys', '-t', paneId, 'C-m'], { encoding: 'utf-8' });
205
+ if (submitA.status !== 0) throw new Error((submitA.stderr || submitA.stdout || '').trim() || 'tmux send-keys C-m failed');
206
+ setTimeout(resolve, 100);
207
+ });
208
+ const submitB = spawnSync('tmux', ['send-keys', '-t', paneId, 'C-m'], { encoding: 'utf-8' });
209
+ if (submitB.status !== 0) {
210
+ throw new Error((submitB.stderr || submitB.stdout || '').trim() || 'tmux send-keys C-m failed');
211
+ }
212
+ }
213
+
214
+ async function runRalphContinueSteerTick() {
215
+ const now = Date.now();
216
+ const nowIso = new Date(now).toISOString();
217
+ const activeRalph = await resolveActiveRalphState();
218
+ lastRalphContinueSteer = {
219
+ ...lastRalphContinueSteer,
220
+ active: activeRalph.active,
221
+ current_phase: safeString(activeRalph.state?.current_phase),
222
+ last_state_check_at: nowIso,
223
+ last_reason: activeRalph.reason,
224
+ state_path: activeRalph.path,
225
+ pane_current_command: '',
226
+ };
227
+
228
+ if (!activeRalph.active) return;
229
+
230
+ const lastSentMs = Date.parse(lastRalphContinueSteer.last_sent_at);
231
+ if (Number.isFinite(lastSentMs) && now - lastSentMs < RALPH_CONTINUE_CADENCE_MS) {
232
+ lastRalphContinueSteer.last_reason = 'cooldown';
233
+ return;
234
+ }
235
+
236
+ const paneId = safeString(activeRalph.state?.tmux_pane_id).trim() || await resolveNudgePaneTarget(stateDir);
237
+ if (!paneId) {
238
+ lastRalphContinueSteer.last_reason = 'pane_missing';
239
+ lastRalphContinueSteer.pane_id = '';
240
+ return;
241
+ }
242
+
243
+ const paneGuard = await checkPaneReadyForTeamSendKeys(paneId);
244
+ lastRalphContinueSteer.pane_id = paneId;
245
+ lastRalphContinueSteer.pane_current_command = paneGuard.paneCurrentCommand || '';
246
+ if (!paneGuard.ok) {
247
+ lastRalphContinueSteer.last_reason = paneGuard.reason || 'pane_guard_blocked';
248
+ return;
249
+ }
250
+
251
+ await emitRalphContinueSteer(paneId, RALPH_CONTINUE_TEXT);
252
+ lastRalphContinueSteer.last_sent_at = nowIso;
253
+ lastRalphContinueSteer.last_reason = 'sent';
254
+ await eventLog({
255
+ type: 'ralph_continue_steer',
256
+ reason: 'sent',
257
+ pane_id: paneId,
258
+ state_path: activeRalph.path,
259
+ current_phase: safeString(activeRalph.state?.current_phase) || null,
260
+ cadence_ms: RALPH_CONTINUE_CADENCE_MS,
261
+ message: RALPH_CONTINUE_TEXT,
262
+ });
263
+ }
264
+
77
265
  async function readPidFilePid(path) {
78
266
  const raw = await readFile(path, 'utf-8');
79
267
  const trimmed = raw.trim();
@@ -151,6 +339,17 @@ async function writeState(extra = {}) {
151
339
  run_count: dispatchDrainRuns,
152
340
  ...lastDispatchDrain,
153
341
  },
342
+ leader_nudge: {
343
+ enabled: true,
344
+ run_count: leaderNudgeRuns,
345
+ ...lastLeaderNudge,
346
+ },
347
+ ralph_continue_steer: {
348
+ ...lastRalphContinueSteer,
349
+ enabled: true,
350
+ cadence_ms: RALPH_CONTINUE_CADENCE_MS,
351
+ message: RALPH_CONTINUE_TEXT,
352
+ },
154
353
  ...extra,
155
354
  };
156
355
  await writeFile(statePath, JSON.stringify(state, null, 2)).catch(() => {});
@@ -177,6 +376,30 @@ async function requestShutdown(reason, signal = null) {
177
376
  async function enforceLifecycleGuards() {
178
377
  if (runOnce) return false;
179
378
  if (parentIsGone()) {
379
+ const activeRalph = await resolveActiveRalphState();
380
+ if (activeRalph.active) {
381
+ const currentPhase = safeString(activeRalph.state?.current_phase);
382
+ const nextParentGuard = {
383
+ reason: 'parent_gone_deferred_for_active_ralph',
384
+ state_path: activeRalph.path,
385
+ current_phase: currentPhase,
386
+ };
387
+ if (
388
+ lastParentGuard.reason !== nextParentGuard.reason
389
+ || lastParentGuard.state_path !== nextParentGuard.state_path
390
+ || lastParentGuard.current_phase !== nextParentGuard.current_phase
391
+ ) {
392
+ await eventLog({
393
+ type: 'watcher_parent_guard',
394
+ reason: nextParentGuard.reason,
395
+ state_path: nextParentGuard.state_path,
396
+ current_phase: currentPhase || null,
397
+ });
398
+ lastParentGuard = nextParentGuard;
399
+ }
400
+ return false;
401
+ }
402
+ lastParentGuard = { reason: '', state_path: '', current_phase: '' };
180
403
  await requestShutdown('parent_gone');
181
404
  return true;
182
405
  }
@@ -340,6 +563,72 @@ async function pollFiles() {
340
563
  }
341
564
  }
342
565
 
566
+ async function runLeaderNudgeTick() {
567
+ const startedIso = new Date().toISOString();
568
+ const leaderOnly = safeString(process.env.OMX_TEAM_WORKER || '').trim() === '';
569
+ const staleThresholdMs = resolveLeaderStalenessThresholdMs();
570
+
571
+ if (!leaderOnly) {
572
+ leaderNudgeRuns += 1;
573
+ lastLeaderNudge = {
574
+ enabled: true,
575
+ leader_only: false,
576
+ stale_threshold_ms: staleThresholdMs,
577
+ precomputed_leader_stale: null,
578
+ last_tick_at: startedIso,
579
+ last_error: 'worker_context',
580
+ };
581
+ await eventLog({
582
+ type: 'leader_nudge_tick',
583
+ leader_only: false,
584
+ run_count: leaderNudgeRuns,
585
+ reason: 'worker_context',
586
+ stale_threshold_ms: staleThresholdMs,
587
+ });
588
+ return;
589
+ }
590
+
591
+ try {
592
+ const preComputedLeaderStale = await isLeaderStale(stateDir, staleThresholdMs, Date.now());
593
+ await maybeNudgeTeamLeader({ cwd, stateDir, logsDir, preComputedLeaderStale });
594
+ leaderNudgeRuns += 1;
595
+ lastLeaderNudge = {
596
+ enabled: true,
597
+ leader_only: true,
598
+ stale_threshold_ms: staleThresholdMs,
599
+ precomputed_leader_stale: preComputedLeaderStale,
600
+ last_tick_at: startedIso,
601
+ last_error: null,
602
+ };
603
+ await eventLog({
604
+ type: 'leader_nudge_tick',
605
+ leader_only: true,
606
+ run_count: leaderNudgeRuns,
607
+ stale_threshold_ms: staleThresholdMs,
608
+ precomputed_leader_stale: preComputedLeaderStale,
609
+ reason: 'leader_nudge_checked',
610
+ });
611
+ } catch (err) {
612
+ leaderNudgeRuns += 1;
613
+ lastLeaderNudge = {
614
+ enabled: true,
615
+ leader_only: true,
616
+ stale_threshold_ms: staleThresholdMs,
617
+ precomputed_leader_stale: null,
618
+ last_tick_at: startedIso,
619
+ last_error: err instanceof Error ? err.message : safeString(err),
620
+ };
621
+ await eventLog({
622
+ type: 'leader_nudge_tick',
623
+ leader_only: true,
624
+ run_count: leaderNudgeRuns,
625
+ stale_threshold_ms: staleThresholdMs,
626
+ reason: 'leader_nudge_failed',
627
+ error: lastLeaderNudge.last_error,
628
+ });
629
+ }
630
+ }
631
+
343
632
  async function runDispatchDrainTick() {
344
633
  const startedIso = new Date().toISOString();
345
634
  try {
@@ -383,6 +672,8 @@ async function tick() {
383
672
  await ensureTrackedFiles();
384
673
  await pollFiles();
385
674
  await runDispatchDrainTick();
675
+ await runLeaderNudgeTick();
676
+ await runRalphContinueSteerTick();
386
677
  await writeState();
387
678
  if (await enforceLifecycleGuards()) return;
388
679
  setTimeout(() => {
@@ -403,6 +694,7 @@ async function main() {
403
694
  }
404
695
 
405
696
  await registerPidFile();
697
+ await loadPersistedWatcherState();
406
698
  await eventLog({
407
699
  type: 'watcher_start',
408
700
  cwd,
@@ -423,6 +715,8 @@ async function main() {
423
715
  await ensureTrackedFiles();
424
716
  await pollFiles();
425
717
  await runDispatchDrainTick();
718
+ await runLeaderNudgeTick();
719
+ await runRalphContinueSteerTick();
426
720
  await writeState();
427
721
  await eventLog({ type: 'watcher_once_complete', seen_turns: seenTurnKeys.size });
428
722
  process.exit(0);
@@ -10,7 +10,8 @@ import { asNumber, safeString } from './utils.js';
10
10
  import { readJsonIfExists, getScopedStateDirsForCurrentSession, readdir } from './state-io.js';
11
11
  import { runProcess } from './process-runner.js';
12
12
  import { logTmuxHookEvent } from './log.js';
13
- import { DEFAULT_MARKER } from '../tmux-hook-engine.js';
13
+ import { checkPaneReadyForTeamSendKeys } from './team-tmux-guard.js';
14
+ import { buildCapturePaneArgv, DEFAULT_MARKER } from '../tmux-hook-engine.js';
14
15
 
15
16
  export const SKILL_ACTIVE_STATE_FILE = 'skill-active-state.json';
16
17
  export const DEEP_INTERVIEW_BLOCKED_APPROVAL_INPUTS = ['yes', 'y', 'proceed', 'continue', 'ok', 'sure', 'go ahead', 'next i should'];
@@ -178,6 +179,8 @@ export const DEFAULT_STALL_PATTERNS = [
178
179
  'would you like',
179
180
  'shall i',
180
181
  'next i can',
182
+ 'continue with',
183
+ 'continue on',
181
184
  'do you want me to',
182
185
  'let me know if',
183
186
  'do you want',
@@ -186,6 +189,9 @@ export const DEFAULT_STALL_PATTERNS = [
186
189
  'just let me know',
187
190
  'i can also',
188
191
  'i could also',
192
+ 'pick up with',
193
+ 'next step',
194
+ 'next steps',
189
195
  'ready to proceed',
190
196
  'should i',
191
197
  'whenever you',
@@ -249,9 +255,7 @@ export function detectStallPattern(text, patterns) {
249
255
 
250
256
  export async function capturePane(paneId, lines = 10) {
251
257
  try {
252
- const result = await runProcess('tmux', [
253
- 'capture-pane', '-t', paneId, '-p', '-l', String(lines),
254
- ], 3000);
258
+ const result = await runProcess('tmux', buildCapturePaneArgv(paneId, lines), 3000);
255
259
  return result.stdout || '';
256
260
  } catch {
257
261
  return '';
@@ -336,6 +340,18 @@ export async function maybeAutoNudge({ cwd, stateDir, logsDir, payload }) {
336
340
  if (skillState?.phase === 'completing' && !detected) return;
337
341
  if (!detected || !paneId) return;
338
342
 
343
+ const paneGuard = await checkPaneReadyForTeamSendKeys(paneId);
344
+ if (!paneGuard.ok) {
345
+ await logTmuxHookEvent(logsDir, {
346
+ timestamp: new Date().toISOString(),
347
+ type: 'auto_nudge_skipped',
348
+ pane_id: paneId,
349
+ reason: paneGuard.reason,
350
+ source,
351
+ }).catch(() => {});
352
+ return;
353
+ }
354
+
339
355
  const deepInterviewLockActive = isDeepInterviewAutoApprovalLocked(skillState) && !releaseReason;
340
356
  if (deepInterviewLockActive && isBlockedAutoApprovalInput(config.response, skillState.input_lock?.blocked_inputs)) {
341
357
  const blockedMessage = skillState.input_lock?.message || DEEP_INTERVIEW_INPUT_LOCK_MESSAGE;
@@ -4,7 +4,14 @@ import { dirname, join, resolve } from 'path';
4
4
  import { safeString } from './utils.js';
5
5
  import { runProcess } from './process-runner.js';
6
6
  import { resolvePaneTarget } from './tmux-injection.js';
7
- import { buildCapturePaneArgv, buildPaneInModeArgv, buildSendKeysArgv } from '../tmux-hook-engine.js';
7
+ import {
8
+ buildCapturePaneArgv,
9
+ buildPaneInModeArgv,
10
+ buildSendKeysArgv,
11
+ normalizeTmuxCapture,
12
+ paneHasActiveTask,
13
+ paneLooksReady,
14
+ } from '../tmux-hook-engine.js';
8
15
 
9
16
  function readJson(path, fallback) {
10
17
  return readFile(path, 'utf8')
@@ -260,18 +267,14 @@ function resolveWorkerCliForRequest(request, config) {
260
267
  return 'codex';
261
268
  }
262
269
 
263
- function normalizeCaptureText(value) {
264
- return safeString(value).replace(/\r/g, '').replace(/\s+/g, ' ').trim();
265
- }
266
-
267
270
  function capturedPaneContainsTrigger(captured, trigger) {
268
271
  if (!captured || !trigger) return false;
269
- return normalizeCaptureText(captured).includes(normalizeCaptureText(trigger));
272
+ return normalizeTmuxCapture(captured).includes(normalizeTmuxCapture(trigger));
270
273
  }
271
274
 
272
275
  function capturedPaneContainsTriggerNearTail(captured, trigger, nonEmptyTailLines = 24) {
273
276
  if (!captured || !trigger) return false;
274
- const normalizedTrigger = normalizeCaptureText(trigger);
277
+ const normalizedTrigger = normalizeTmuxCapture(trigger);
275
278
  if (!normalizedTrigger) return false;
276
279
  const lines = safeString(captured)
277
280
  .split('\n')
@@ -279,57 +282,7 @@ function capturedPaneContainsTriggerNearTail(captured, trigger, nonEmptyTailLine
279
282
  .filter((line) => line.length > 0);
280
283
  if (lines.length === 0) return false;
281
284
  const tail = lines.slice(-Math.max(1, nonEmptyTailLines)).join(' ');
282
- return normalizeCaptureText(tail).includes(normalizedTrigger);
283
- }
284
-
285
- // Ported from src/team/tmux-session.ts:949-963 — detects active CLI task indicators.
286
- function paneHasActiveTask(captured) {
287
- const lines = safeString(captured)
288
- .split('\n')
289
- .map((line) => line.replace(/\r/g, '').trim())
290
- .filter((line) => line.length > 0);
291
- const tail = lines.slice(-40);
292
- if (tail.some((line) => /\b\d+\s+background terminal running\b/i.test(line))) return true;
293
- if (tail.some((line) => /esc to interrupt/i.test(line))) return true;
294
- if (tail.some((line) => /\bbackground terminal running\b/i.test(line))) return true;
295
- if (tail.some((line) => /^•\s.+\(.+•\s*esc to interrupt\)$/i.test(line))) return true;
296
- // Claude active generation lines
297
- if (tail.some((line) => /^[·✻]\s+[A-Za-z][A-Za-z0-9''-]*(?:\s+[A-Za-z][A-Za-z0-9''-]*){0,3}(?:…|\.{3})$/u.test(line))) return true;
298
- return false;
299
- }
300
-
301
- function paneIsBootstrapping(captured) {
302
- const lines = safeString(captured)
303
- .split('\n')
304
- .map((line) => line.replace(/\r/g, '').trim())
305
- .filter((line) => line.length > 0);
306
- return lines.some((line) =>
307
- /\b(loading|initializing|starting up)\b/i.test(line)
308
- || /\bmodel:\s*loading\b/i.test(line)
309
- || /\bconnecting\s+to\b/i.test(line),
310
- );
311
- }
312
-
313
- function paneLooksReady(captured) {
314
- const content = safeString(captured).trimEnd();
315
- if (content === '') return false;
316
-
317
- const lines = content
318
- .split('\n')
319
- .map((line) => line.replace(/\r/g, ''))
320
- .map((line) => line.trimEnd())
321
- .filter((line) => line.trim() !== '');
322
-
323
- if (paneIsBootstrapping(content)) return false;
324
-
325
- const lastLine = lines.length > 0 ? lines[lines.length - 1] : '';
326
- if (/^\s*[›>❯]\s*/u.test(lastLine)) return true;
327
-
328
- const hasCodexPromptLine = lines.some((line) => /^\s*›\s*/u.test(line));
329
- const hasClaudePromptLine = lines.some((line) => /^\s*❯\s*/u.test(line));
330
- if (hasCodexPromptLine || hasClaudePromptLine) return true;
331
-
332
- return false;
285
+ return normalizeTmuxCapture(tail).includes(normalizedTrigger);
333
286
  }
334
287
 
335
288
  const INJECT_VERIFY_DELAY_MS = 250;
@@ -53,6 +53,40 @@ export function resolveLeaderProgressStallThresholdMs() {
53
53
  return 120_000;
54
54
  }
55
55
 
56
+ function buildStatusCheckReminder(teamName) {
57
+ return `Next: check messages; keep orchestrating; if done, gracefully shut down: omx team shutdown ${teamName}.`;
58
+ }
59
+
60
+ function buildMailboxCheckReminder(teamName) {
61
+ return `Next: read messages; keep orchestrating; if done, gracefully shut down: omx team shutdown ${teamName}.`;
62
+ }
63
+
64
+ function buildWorkerStartEvidenceReminder(teamName, workerName) {
65
+ return `Next: check ${workerName} msg/output, confirm task in omx team status ${teamName}, then reassign/nudge.`;
66
+ }
67
+
68
+ function buildLeaderActionGuidance(teamName, {
69
+ allWorkersIdle = false,
70
+ workerPanesAlive = false,
71
+ taskCounts = {},
72
+ } = {}) {
73
+ const pending = Number.isFinite(taskCounts.pending) ? taskCounts.pending : 0;
74
+ const blocked = Number.isFinite(taskCounts.blocked) ? taskCounts.blocked : 0;
75
+ const inProgress = Number.isFinite(taskCounts.in_progress) ? taskCounts.in_progress : 0;
76
+ const tasksComplete = pending === 0 && blocked === 0 && inProgress === 0;
77
+ const pendingFollowUpTasks = allWorkersIdle && pending > 0 && blocked === 0 && inProgress === 0;
78
+
79
+ if (pendingFollowUpTasks) {
80
+ return workerPanesAlive
81
+ ? 'Next: assign the next follow-up task to this idle team.'
82
+ : 'Next: launch a new team for the next task set.';
83
+ }
84
+ if (allWorkersIdle && tasksComplete) {
85
+ return `Next: omx team shutdown ${teamName}.`;
86
+ }
87
+ return buildStatusCheckReminder(teamName);
88
+ }
89
+
56
90
  export async function checkWorkerPanesAlive(tmuxTarget, workerPaneIds = []) {
57
91
  const sessionName = tmuxTarget.split(':')[0];
58
92
  try {
@@ -492,6 +526,11 @@ export async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir, preComputed
492
526
  : [];
493
527
  const allWorkersIdle = workerStates.length > 0 && workerStates.every((state) => state === 'idle' || state === 'done');
494
528
  const progressSnapshot = await readTeamProgressSnapshot(stateDir, teamName, workerNames);
529
+ const leaderActionGuidance = buildLeaderActionGuidance(teamName, {
530
+ allWorkersIdle,
531
+ workerPanesAlive: paneStatus.alive,
532
+ taskCounts: progressSnapshot.taskCounts,
533
+ });
495
534
  const prevProgress = nudgeState.progress_by_team[teamName] && typeof nudgeState.progress_by_team[teamName] === 'object'
496
535
  ? nudgeState.progress_by_team[teamName]
497
536
  : {};
@@ -543,7 +582,10 @@ export async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir, preComputed
543
582
  // Stale-leader follow-up is the only periodic visible nudge path.
544
583
  // This keeps the leader pane quieter when the leader is not actually stale.
545
584
  const stalePanesNudge = paneStatus.alive && leaderStale;
546
- const stalledTeamNudge = teamProgressStalled && leaderStale && (dueByTime || prevReason !== 'leader_stale_with_stalled_team');
585
+ const stalledTeamReason = leaderStale ? 'leader_stale_with_stalled_team' : 'stalled_team_progress';
586
+ const previousStalledTeamNudge =
587
+ prevReason === 'leader_stale_with_stalled_team' || prevReason === 'stalled_team_progress';
588
+ const stalledTeamNudge = teamProgressStalled && (dueByTime || !previousStalledTeamNudge);
547
589
  const staleFollowupDue = stalePanesNudge && dueByTime;
548
590
 
549
591
  if (!shouldSendAllIdleNudge && !hasNewMessage && !stalledTeamNudge && !staleFollowupDue) continue;
@@ -553,32 +595,37 @@ export async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir, preComputed
553
595
  if (shouldSendAllIdleNudge) {
554
596
  nudgeReason = 'all_workers_idle';
555
597
  const N = workerNames.length;
556
- text = `[OMX] All ${N} worker${N === 1 ? '' : 's'} idle. Ready for next instructions.`;
598
+ text = `[OMX] All ${N} worker${N === 1 ? '' : 's'} idle. ${leaderActionGuidance}`;
557
599
  } else if (ackWithoutStartEvidence) {
558
600
  nudgeReason = ACK_WITHOUT_START_EVIDENCE_REASON;
559
601
  text =
560
602
  `Team ${teamName}: ${ackWithoutStartEvidence.worker} said "${ackWithoutStartEvidence.body}" `
561
- + `but has no work-start evidence yet (status: ${ackWithoutStartEvidence.statusState}, no owned in_progress task). `
562
- + `Run: omx team status ${teamName}`;
603
+ + `but has no start evidence (status: ${ackWithoutStartEvidence.statusState}). `
604
+ + buildWorkerStartEvidenceReminder(teamName, ackWithoutStartEvidence.worker);
563
605
  } else if (stalledTeamNudge) {
564
- nudgeReason = 'leader_stale_with_stalled_team';
606
+ nudgeReason = stalledTeamReason;
565
607
  const { pending, in_progress, blocked } = progressSnapshot.taskCounts;
566
608
  const missingSignals = progressSnapshot.missingSignalWorkers > 0
567
- ? `; ${progressSnapshot.missingSignalWorkers} worker signal${progressSnapshot.missingSignalWorkers === 1 ? '' : 's'} missing`
609
+ ? `; ${progressSnapshot.missingSignalWorkers} signal${progressSnapshot.missingSignalWorkers === 1 ? '' : 's'} missing`
568
610
  : '';
611
+ const stallPrefix = leaderStale ? 'leader stale, ' : 'worker panes stalled, ';
569
612
  text =
570
- `Team ${teamName}: leader stale, no team progress for ${formatDurationMs(stalledForMs)} `
571
- + `(pending:${pending} in_progress:${in_progress} blocked:${blocked}${missingSignals}). `
572
- + `Run: omx team status ${teamName}`;
613
+ `Team ${teamName}: ${stallPrefix}no progress ${formatDurationMs(stalledForMs)}. `
614
+ + `${leaderActionGuidance} `
615
+ + `(p:${pending} ip:${in_progress} b:${blocked}${missingSignals})`;
573
616
  } else if (stalePanesNudge && hasNewMessage) {
574
617
  nudgeReason = 'stale_leader_with_messages';
575
- text = `Team ${teamName}: leader stale, ${paneStatus.paneCount} pane(s) active, ${messages.length} msg(s) pending. Run: omx team status ${teamName}`;
618
+ text =
619
+ `Team ${teamName}: leader stale, ${paneStatus.paneCount} pane(s) active, ${messages.length} msg(s) pending. `
620
+ + buildMailboxCheckReminder(teamName);
576
621
  } else if (staleFollowupDue) {
577
622
  nudgeReason = 'stale_leader_panes_alive';
578
- text = `Team ${teamName}: leader stale, ${paneStatus.paneCount} worker pane(s) still active. Run: omx team status ${teamName}`;
623
+ text =
624
+ `Team ${teamName}: leader stale, ${paneStatus.paneCount} worker pane(s) still active. `
625
+ + leaderActionGuidance;
579
626
  } else if (hasNewMessage) {
580
627
  nudgeReason = 'new_mailbox_message';
581
- text = `Team ${teamName}: ${messages.length} msg(s) for leader. Run: omx team status ${teamName}`;
628
+ text = `Team ${teamName}: ${messages.length} msg(s) for leader. ${buildMailboxCheckReminder(teamName)}`;
582
629
  } else {
583
630
  continue;
584
631
  }