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.
- package/README.md +16 -0
- package/dist/cli/team.d.ts.map +1 -1
- package/dist/cli/team.js +8 -2
- package/dist/cli/team.js.map +1 -1
- package/dist/cli/tmux-hook.d.ts.map +1 -1
- package/dist/cli/tmux-hook.js +6 -0
- package/dist/cli/tmux-hook.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js +42 -0
- package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +60 -0
- package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.d.ts +10 -0
- package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.d.ts.map +1 -0
- package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js +173 -0
- package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js.map +1 -0
- package/dist/mcp/__tests__/state-server-schema.test.js +6 -5
- package/dist/mcp/__tests__/state-server-schema.test.js.map +1 -1
- package/dist/mcp/__tests__/state-server-team-tools.test.js +230 -0
- package/dist/mcp/__tests__/state-server-team-tools.test.js.map +1 -1
- package/dist/mcp/state-server.d.ts.map +1 -1
- package/dist/mcp/state-server.js +112 -8
- package/dist/mcp/state-server.js.map +1 -1
- package/dist/team/__tests__/state.test.js +123 -0
- package/dist/team/__tests__/state.test.js.map +1 -1
- package/dist/team/__tests__/tmux-session.test.js +241 -18
- package/dist/team/__tests__/tmux-session.test.js.map +1 -1
- package/dist/team/runtime.d.ts +7 -0
- package/dist/team/runtime.d.ts.map +1 -1
- package/dist/team/runtime.js +33 -5
- package/dist/team/runtime.js.map +1 -1
- package/dist/team/state.d.ts +16 -0
- package/dist/team/state.d.ts.map +1 -1
- package/dist/team/state.js +110 -22
- package/dist/team/state.js.map +1 -1
- package/dist/team/tmux-session.d.ts +15 -2
- package/dist/team/tmux-session.d.ts.map +1 -1
- package/dist/team/tmux-session.js +242 -33
- package/dist/team/tmux-session.js.map +1 -1
- package/package.json +1 -1
- package/scripts/notify-hook/team-leader-nudge.js +79 -7
- package/scripts/notify-hook/team-worker.js +10 -8
- package/scripts/notify-hook/tmux-injection.js +27 -0
- package/scripts/tmux-hook-engine.js +11 -0
- 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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
180
|
+
const messages = normalizeMailboxMessages(mailbox);
|
|
132
181
|
const newest = messages.length > 0 ? messages[messages.length - 1] : null;
|
|
133
|
-
const newestId = newest
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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',
|
|
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:
|
|
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:
|
|
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:
|
package/skills/team/SKILL.md
CHANGED
|
@@ -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
|
|
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`
|