oh-my-codex 0.5.1 → 0.6.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 (44) hide show
  1. package/README.md +16 -0
  2. package/dist/cli/team.d.ts.map +1 -1
  3. package/dist/cli/team.js +8 -2
  4. package/dist/cli/team.js.map +1 -1
  5. package/dist/cli/tmux-hook.d.ts.map +1 -1
  6. package/dist/cli/tmux-hook.js +6 -0
  7. package/dist/cli/tmux-hook.js.map +1 -1
  8. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js +42 -0
  9. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js.map +1 -1
  10. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +60 -0
  11. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  12. package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.d.ts +10 -0
  13. package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.d.ts.map +1 -0
  14. package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js +173 -0
  15. package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js.map +1 -0
  16. package/dist/mcp/__tests__/state-server-schema.test.js +6 -5
  17. package/dist/mcp/__tests__/state-server-schema.test.js.map +1 -1
  18. package/dist/mcp/__tests__/state-server-team-tools.test.js +230 -0
  19. package/dist/mcp/__tests__/state-server-team-tools.test.js.map +1 -1
  20. package/dist/mcp/state-server.d.ts.map +1 -1
  21. package/dist/mcp/state-server.js +112 -8
  22. package/dist/mcp/state-server.js.map +1 -1
  23. package/dist/team/__tests__/state.test.js +123 -0
  24. package/dist/team/__tests__/state.test.js.map +1 -1
  25. package/dist/team/__tests__/tmux-session.test.js +241 -18
  26. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  27. package/dist/team/runtime.d.ts +7 -0
  28. package/dist/team/runtime.d.ts.map +1 -1
  29. package/dist/team/runtime.js +33 -5
  30. package/dist/team/runtime.js.map +1 -1
  31. package/dist/team/state.d.ts +16 -0
  32. package/dist/team/state.d.ts.map +1 -1
  33. package/dist/team/state.js +110 -22
  34. package/dist/team/state.js.map +1 -1
  35. package/dist/team/tmux-session.d.ts +15 -2
  36. package/dist/team/tmux-session.d.ts.map +1 -1
  37. package/dist/team/tmux-session.js +242 -33
  38. package/dist/team/tmux-session.js.map +1 -1
  39. package/package.json +1 -1
  40. package/scripts/notify-hook/team-leader-nudge.js +79 -7
  41. package/scripts/notify-hook/team-worker.js +10 -8
  42. package/scripts/notify-hook/tmux-injection.js +27 -0
  43. package/scripts/tmux-hook-engine.js +11 -0
  44. package/skills/team/SKILL.md +13 -1
@@ -19,6 +19,14 @@ export function resolveLeaderNudgeIntervalMs() {
19
19
  return 120_000;
20
20
  }
21
21
 
22
+ export function resolveLeaderAllIdleNudgeCooldownMs() {
23
+ const raw = safeString(process.env.OMX_TEAM_LEADER_ALL_IDLE_COOLDOWN_MS || '');
24
+ const parsed = asNumber(raw);
25
+ // Default: 30 seconds.
26
+ if (parsed !== null && parsed >= 5_000 && parsed <= 10 * 60_000) return parsed;
27
+ return 30_000;
28
+ }
29
+
22
30
  export function resolveLeaderStalenessThresholdMs() {
23
31
  const raw = safeString(process.env.OMX_TEAM_LEADER_STALE_MS || '');
24
32
  const parsed = asNumber(raw);
@@ -52,6 +60,36 @@ export async function isLeaderStale(stateDir, thresholdMs, nowMs) {
52
60
  return (nowMs - lastMs) >= thresholdMs;
53
61
  }
54
62
 
63
+ async function readWorkerStatusState(stateDir, teamName, workerName) {
64
+ if (!workerName) return 'unknown';
65
+ const path = join(stateDir, 'team', teamName, 'workers', workerName, 'status.json');
66
+ try {
67
+ if (!existsSync(path)) return 'unknown';
68
+ const parsed = JSON.parse(await readFile(path, 'utf-8'));
69
+ return safeString(parsed && parsed.state ? parsed.state : 'unknown') || 'unknown';
70
+ } catch {
71
+ return 'unknown';
72
+ }
73
+ }
74
+
75
+ function normalizeMailboxMessages(rawMailbox) {
76
+ if (Array.isArray(rawMailbox)) return rawMailbox;
77
+ if (rawMailbox && typeof rawMailbox === 'object' && Array.isArray(rawMailbox.messages)) {
78
+ return rawMailbox.messages;
79
+ }
80
+ return [];
81
+ }
82
+
83
+ function normalizeMessageIdentity(msg) {
84
+ if (!msg || typeof msg !== 'object') return '';
85
+ const explicitId = safeString(msg.message_id || '').trim();
86
+ if (explicitId) return explicitId;
87
+ const createdAt = safeString(msg.created_at || msg.timestamp || '').trim();
88
+ const from = safeString(msg.from_worker || msg.from || '').trim();
89
+ const body = safeString(msg.body || '').trim();
90
+ return [createdAt, from, body].filter(Boolean).join('|');
91
+ }
92
+
55
93
  export async function emitTeamNudgeEvent(cwd, teamName, reason, nowIso) {
56
94
  const eventsDir = join(cwd, '.omx', 'state', 'team', teamName, 'events');
57
95
  const eventsPath = join(eventsDir, 'events.ndjson');
@@ -73,6 +111,7 @@ export async function emitTeamNudgeEvent(cwd, teamName, reason, nowIso) {
73
111
 
74
112
  export async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir, preComputedLeaderStale }) {
75
113
  const intervalMs = resolveLeaderNudgeIntervalMs();
114
+ const idleCooldownMs = resolveLeaderAllIdleNudgeCooldownMs();
76
115
  const nowMs = Date.now();
77
116
  const nowIso = new Date().toISOString();
78
117
  const omxDir = join(cwd, '.omx');
@@ -85,6 +124,9 @@ export async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir, preComputed
85
124
  if (!nudgeState.last_nudged_by_team || typeof nudgeState.last_nudged_by_team !== 'object') {
86
125
  nudgeState.last_nudged_by_team = {};
87
126
  }
127
+ if (!nudgeState.last_idle_nudged_by_team || typeof nudgeState.last_idle_nudged_by_team !== 'object') {
128
+ nudgeState.last_idle_nudged_by_team = {};
129
+ }
88
130
 
89
131
  const activeTeamNames = new Set();
90
132
  try {
@@ -105,21 +147,28 @@ export async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir, preComputed
105
147
  const leaderStale = typeof preComputedLeaderStale === 'boolean' ? preComputedLeaderStale : false;
106
148
 
107
149
  for (const teamName of activeTeamNames) {
108
- let tmuxTarget = '';
150
+ let tmuxSession = '';
151
+ let leaderPaneId = '';
152
+ let workers = [];
109
153
  try {
110
154
  const manifestPath = join(omxDir, 'state', 'team', teamName, 'manifest.v2.json');
111
155
  const configPath = join(omxDir, 'state', 'team', teamName, 'config.json');
112
156
  const srcPath = existsSync(manifestPath) ? manifestPath : configPath;
113
157
  if (existsSync(srcPath)) {
114
158
  const raw = JSON.parse(await readFile(srcPath, 'utf-8'));
115
- tmuxTarget = safeString(raw && raw.tmux_session ? raw.tmux_session : '').trim();
159
+ tmuxSession = safeString(raw && raw.tmux_session ? raw.tmux_session : '').trim();
160
+ leaderPaneId = safeString(raw && raw.leader_pane_id ? raw.leader_pane_id : '').trim();
161
+ if (Array.isArray(raw && raw.workers)) workers = raw.workers;
116
162
  }
117
163
  } catch {
118
164
  // ignore
119
165
  }
166
+ const tmuxTarget = leaderPaneId || tmuxSession;
120
167
  if (!tmuxTarget) continue;
121
168
 
122
- const paneStatus = await checkWorkerPanesAlive(tmuxTarget);
169
+ const paneStatus = tmuxSession
170
+ ? await checkWorkerPanesAlive(tmuxSession)
171
+ : { alive: false, paneCount: 0 };
123
172
 
124
173
  let mailbox = null;
125
174
  try {
@@ -128,9 +177,17 @@ export async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir, preComputed
128
177
  } catch {
129
178
  mailbox = null;
130
179
  }
131
- const messages = mailbox && Array.isArray(mailbox.messages) ? mailbox.messages : [];
180
+ const messages = normalizeMailboxMessages(mailbox);
132
181
  const newest = messages.length > 0 ? messages[messages.length - 1] : null;
133
- const newestId = newest && typeof newest.message_id === 'string' ? newest.message_id : '';
182
+ const newestId = normalizeMessageIdentity(newest);
183
+
184
+ const workerNames = Array.isArray(workers)
185
+ ? workers.map((w) => safeString(w && w.name ? w.name : '')).filter(Boolean)
186
+ : [];
187
+ const workerStates = workerNames.length > 0
188
+ ? await Promise.all(workerNames.map((workerName) => readWorkerStatusState(stateDir, teamName, workerName)))
189
+ : [];
190
+ const allWorkersIdle = workerStates.length > 0 && workerStates.every((state) => state === 'idle' || state === 'done');
134
191
 
135
192
  const prev = nudgeState.last_nudged_by_team[teamName] && typeof nudgeState.last_nudged_by_team[teamName] === 'object'
136
193
  ? nudgeState.last_nudged_by_team[teamName]
@@ -142,14 +199,26 @@ export async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir, preComputed
142
199
  const hasNewMessage = newestId && newestId !== prevMsgId;
143
200
  const dueByTime = !Number.isFinite(prevAtMs) || (nowMs - prevAtMs >= intervalMs);
144
201
 
202
+ const prevIdle = nudgeState.last_idle_nudged_by_team[teamName] && typeof nudgeState.last_idle_nudged_by_team[teamName] === 'object'
203
+ ? nudgeState.last_idle_nudged_by_team[teamName]
204
+ : {};
205
+ const prevIdleAtIso = safeString(prevIdle.at || '');
206
+ const prevIdleAtMs = prevIdleAtIso ? Date.parse(prevIdleAtIso) : NaN;
207
+ const dueByIdleCooldown = !Number.isFinite(prevIdleAtMs) || (nowMs - prevIdleAtMs >= idleCooldownMs);
208
+ const shouldSendAllIdleNudge = allWorkersIdle && dueByIdleCooldown;
209
+
145
210
  // stalePanesNudge must respect the same dueByTime rate limit (issue #116)
146
211
  const stalePanesNudge = paneStatus.alive && leaderStale;
147
212
 
148
- if (!hasNewMessage && !dueByTime) continue;
213
+ if (!shouldSendAllIdleNudge && !hasNewMessage && !dueByTime) continue;
149
214
 
150
215
  let nudgeReason = '';
151
216
  let text = '';
152
- if (stalePanesNudge && hasNewMessage) {
217
+ if (shouldSendAllIdleNudge) {
218
+ nudgeReason = 'all_workers_idle';
219
+ const N = workerNames.length;
220
+ text = `[OMX] All ${N} worker${N === 1 ? '' : 's'} idle. Ready for next instructions.`;
221
+ } else if (stalePanesNudge && hasNewMessage) {
153
222
  nudgeReason = 'stale_leader_with_messages';
154
223
  text = `Team ${teamName}: leader stale, ${paneStatus.paneCount} pane(s) active, ${messages.length} msg(s) pending. Run: omx team status ${teamName}`;
155
224
  } else if (stalePanesNudge) {
@@ -172,6 +241,9 @@ export async function maybeNudgeTeamLeader({ cwd, stateDir, logsDir, preComputed
172
241
  await new Promise(r => setTimeout(r, 100));
173
242
  await runProcess('tmux', ['send-keys', '-t', tmuxTarget, 'C-m'], 3000);
174
243
  nudgeState.last_nudged_by_team[teamName] = { at: nowIso, last_message_id: newestId || prevMsgId || '' };
244
+ if (shouldSendAllIdleNudge) {
245
+ nudgeState.last_idle_nudged_by_team[teamName] = { at: nowIso, worker_count: workerNames.length };
246
+ }
175
247
 
176
248
  await emitTeamNudgeEvent(cwd, teamName, nudgeReason, nowIso);
177
249
 
@@ -104,7 +104,8 @@ export async function readTeamWorkersForIdleCheck(stateDir, teamName) {
104
104
  const workers = parsed.workers;
105
105
  if (!Array.isArray(workers) || workers.length === 0) return null;
106
106
  const tmuxSession = safeString(parsed.tmux_session || '').trim();
107
- return { workers, tmuxSession };
107
+ const leaderPaneId = safeString(parsed.leader_pane_id || '').trim();
108
+ return { workers, tmuxSession, leaderPaneId };
108
109
  } catch {
109
110
  return null;
110
111
  }
@@ -141,8 +142,9 @@ export async function maybeNotifyLeaderAllWorkersIdle({ cwd, stateDir, logsDir,
141
142
  // Read team config to get worker list and leader tmux target
142
143
  const teamInfo = await readTeamWorkersForIdleCheck(stateDir, teamName);
143
144
  if (!teamInfo) return;
144
- const { workers, tmuxSession } = teamInfo;
145
- if (!tmuxSession) return;
145
+ const { workers, tmuxSession, leaderPaneId } = teamInfo;
146
+ const tmuxTarget = leaderPaneId || tmuxSession;
147
+ if (!tmuxTarget) return;
146
148
 
147
149
  // Check cooldown to prevent notification spam
148
150
  const idleStatePath = join(stateDir, 'team', teamName, 'all-workers-idle.json');
@@ -162,11 +164,11 @@ export async function maybeNotifyLeaderAllWorkersIdle({ cwd, stateDir, logsDir,
162
164
  const message = `[OMX] All ${N} worker${N === 1 ? '' : 's'} idle. Ready for next instructions. ${DEFAULT_MARKER}`;
163
165
 
164
166
  try {
165
- await runProcess('tmux', ['send-keys', '-t', tmuxSession, '-l', message], 3000);
167
+ await runProcess('tmux', ['send-keys', '-t', tmuxTarget, '-l', message], 3000);
166
168
  await new Promise(r => setTimeout(r, 100));
167
- await runProcess('tmux', ['send-keys', '-t', tmuxSession, 'C-m'], 3000);
169
+ await runProcess('tmux', ['send-keys', '-t', tmuxTarget, 'C-m'], 3000);
168
170
  await new Promise(r => setTimeout(r, 100));
169
- await runProcess('tmux', ['send-keys', '-t', tmuxSession, 'C-m'], 3000);
171
+ await runProcess('tmux', ['send-keys', '-t', tmuxTarget, 'C-m'], 3000);
170
172
 
171
173
  const nextIdleState = {
172
174
  ...idleState,
@@ -195,7 +197,7 @@ export async function maybeNotifyLeaderAllWorkersIdle({ cwd, stateDir, logsDir,
195
197
  timestamp: nowIso,
196
198
  type: 'all_workers_idle_notification',
197
199
  team: teamName,
198
- tmux_target: tmuxSession,
200
+ tmux_target: tmuxTarget,
199
201
  worker: workerName,
200
202
  worker_count: N,
201
203
  });
@@ -204,7 +206,7 @@ export async function maybeNotifyLeaderAllWorkersIdle({ cwd, stateDir, logsDir,
204
206
  timestamp: nowIso,
205
207
  type: 'all_workers_idle_notification',
206
208
  team: teamName,
207
- tmux_target: tmuxSession,
209
+ tmux_target: tmuxTarget,
208
210
  worker: workerName,
209
211
  error: err instanceof Error ? err.message : safeString(err),
210
212
  }).catch(() => {});
@@ -21,6 +21,7 @@ import {
21
21
  pickActiveMode,
22
22
  evaluateInjectionGuards,
23
23
  buildSendKeysArgv,
24
+ buildPaneInModeArgv,
24
25
  } from '../tmux-hook-engine.js';
25
26
 
26
27
  export async function resolveSessionToPane(sessionName) {
@@ -329,6 +330,32 @@ export async function handleTmuxInjection({
329
330
  }
330
331
  };
331
332
 
333
+ // Scroll-safety guard: skip injection when the user is actively scrolling
334
+ // (pane is in copy-mode / tmux's scrollback view). Sending keys to a pane
335
+ // in copy-mode would exit scrollback and disrupt the user's review session.
336
+ // We do NOT record the dedupe key here so the injection can be retried on
337
+ // the next agent-turn event once the pane is no longer in scroll mode.
338
+ if (config.skip_if_scrolling) {
339
+ try {
340
+ const modeResult = await runProcess('tmux', buildPaneInModeArgv(paneTarget), 1000);
341
+ const paneInMode = safeString(modeResult.stdout).trim();
342
+ if (paneInMode === '1') {
343
+ state.last_reason = 'scroll_active';
344
+ state.last_event_at = nowIso;
345
+ await writeFile(hookStatePath, JSON.stringify(state, null, 2)).catch(() => {});
346
+ await logTmuxHookEvent(logsDir, {
347
+ ...baseLog,
348
+ event: 'injection_skipped',
349
+ reason: 'scroll_active',
350
+ pane_target: paneTarget,
351
+ });
352
+ return;
353
+ }
354
+ } catch {
355
+ // Non-fatal: if querying copy-mode state fails, proceed with injection.
356
+ }
357
+ }
358
+
332
359
  if (config.dry_run) {
333
360
  updateStateForAttempt(false, 'dry_run');
334
361
  await writeFile(hookStatePath, JSON.stringify(state, null, 2)).catch(() => {});
@@ -74,6 +74,8 @@ export function normalizeTmuxHookConfig(raw) {
74
74
  marker,
75
75
  dry_run: raw.dry_run === true,
76
76
  log_level: logLevel,
77
+ // Skip injection when the target pane is in copy-mode / scrollback (default: true).
78
+ skip_if_scrolling: raw.skip_if_scrolling === false ? false : true,
77
79
  };
78
80
  }
79
81
 
@@ -143,6 +145,15 @@ export function evaluateInjectionGuards({
143
145
  return { allow: true, reason: 'ok', dedupeKey };
144
146
  }
145
147
 
148
+ /**
149
+ * Returns the tmux argv to query whether a pane is currently in copy-mode
150
+ * (scrollback). The command prints "1" if the pane is in any mode, "0"
151
+ * otherwise.
152
+ */
153
+ export function buildPaneInModeArgv(paneTarget) {
154
+ return ['display-message', '-p', '-t', paneTarget, '#{pane_in_mode}'];
155
+ }
156
+
146
157
  export function buildSendKeysArgv({ paneTarget, prompt, dryRun }) {
147
158
  if (dryRun) return null;
148
159
  // Use a 2-step send for reliability:
@@ -183,7 +183,19 @@ Useful runtime env vars:
183
183
  - `OMX_TEAM_AUTO_TRUST=0`
184
184
  - Disable auto-advance for trust prompt (default behavior auto-advances)
185
185
  - `OMX_TEAM_WORKER_LAUNCH_ARGS`
186
- - Extra args passed to worker `codex` launch
186
+ - Extra args passed to worker launch command
187
+ - `OMX_TEAM_WORKER_CLI`
188
+ - Worker CLI selector: `auto|codex|claude` (default: `auto`)
189
+ - `auto` chooses `claude` when worker `--model` contains `claude`, otherwise `codex`
190
+ - In `claude` mode, workers launch as `claude --dangerously-skip-permissions` and ignore explicit model/config/effort launch overrides (uses default `settings.json`)
191
+ - `OMX_TEAM_WORKER_CLI_MAP`
192
+ - Per-worker CLI selector (comma-separated `auto|codex|claude`)
193
+ - Length must be `1` (broadcast) or exactly the team worker count
194
+ - Example: `OMX_TEAM_WORKER_CLI_MAP=codex,codex,claude,claude`
195
+ - When present, overrides `OMX_TEAM_WORKER_CLI`
196
+ - `OMX_TEAM_AUTO_INTERRUPT_RETRY`
197
+ - Trigger submit fallback (default: enabled)
198
+ - `0` disables adaptive queue->resend escalation
187
199
  - `OMX_TEAM_LEADER_NUDGE_MS`
188
200
  - Leader nudge interval in ms (default 120000)
189
201
  - `OMX_TEAM_STRICT_SUBMIT=1`