oh-my-codex 0.7.0 → 0.7.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.
- package/dist/cli/__tests__/team.test.js +17 -0
- package/dist/cli/__tests__/team.test.js.map +1 -1
- package/dist/cli/team.d.ts.map +1 -1
- package/dist/cli/team.js +3 -2
- package/dist/cli/team.js.map +1 -1
- package/dist/hooks/__tests__/notify-fallback-watcher.test.js +44 -0
- package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-dispatch.test.d.ts +2 -0
- package/dist/hooks/__tests__/notify-hook-team-dispatch.test.d.ts.map +1 -0
- package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js +135 -0
- package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js.map +1 -0
- package/dist/mcp/__tests__/state-server-schema.test.js +2 -1
- package/dist/mcp/__tests__/state-server-schema.test.js.map +1 -1
- package/dist/team/__tests__/mcp-comm.test.js +117 -7
- package/dist/team/__tests__/mcp-comm.test.js.map +1 -1
- package/dist/team/__tests__/runtime.test.js +58 -1
- package/dist/team/__tests__/runtime.test.js.map +1 -1
- package/dist/team/__tests__/state.test.js +76 -1
- package/dist/team/__tests__/state.test.js.map +1 -1
- package/dist/team/contracts.d.ts +1 -1
- package/dist/team/contracts.d.ts.map +1 -1
- package/dist/team/contracts.js +2 -0
- package/dist/team/contracts.js.map +1 -1
- package/dist/team/mcp-comm.d.ts +28 -4
- package/dist/team/mcp-comm.d.ts.map +1 -1
- package/dist/team/mcp-comm.js +172 -6
- package/dist/team/mcp-comm.js.map +1 -1
- package/dist/team/runtime.d.ts.map +1 -1
- package/dist/team/runtime.js +374 -41
- package/dist/team/runtime.js.map +1 -1
- package/dist/team/scaling.d.ts.map +1 -1
- package/dist/team/scaling.js +90 -13
- package/dist/team/scaling.js.map +1 -1
- package/dist/team/state.d.ts +54 -1
- package/dist/team/state.d.ts.map +1 -1
- package/dist/team/state.js +300 -5
- package/dist/team/state.js.map +1 -1
- package/dist/team/team-ops.d.ts +8 -1
- package/dist/team/team-ops.d.ts.map +1 -1
- package/dist/team/team-ops.js +7 -0
- package/dist/team/team-ops.js.map +1 -1
- package/package.json +1 -1
- package/scripts/notify-fallback-watcher.js +4 -0
- package/scripts/notify-hook/team-dispatch.js +274 -0
- package/scripts/notify-hook.js +11 -0
- package/skills/team/SKILL.md +3 -2
- package/skills/worker/SKILL.md +4 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'fs/promises';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { dirname, join, resolve } from 'path';
|
|
4
|
+
import { safeString } from './utils.js';
|
|
5
|
+
import { runProcess } from './process-runner.js';
|
|
6
|
+
import { resolvePaneTarget } from './tmux-injection.js';
|
|
7
|
+
import { buildPaneInModeArgv, buildSendKeysArgv } from '../tmux-hook-engine.js';
|
|
8
|
+
|
|
9
|
+
function readJson(path, fallback) {
|
|
10
|
+
return readFile(path, 'utf8')
|
|
11
|
+
.then((raw) => JSON.parse(raw))
|
|
12
|
+
.catch(() => fallback);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function writeJsonAtomic(path, value) {
|
|
16
|
+
await mkdir(dirname(path), { recursive: true });
|
|
17
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
18
|
+
await writeFile(tmp, JSON.stringify(value, null, 2));
|
|
19
|
+
await rename(tmp, path);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Keep stale-timeout semantics aligned with src/team/state.ts LOCK_STALE_MS.
|
|
23
|
+
const DISPATCH_LOCK_STALE_MS = 5 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
async function withDispatchLock(teamDirPath, fn) {
|
|
26
|
+
const lockDir = join(teamDirPath, 'dispatch', '.lock');
|
|
27
|
+
const ownerPath = join(lockDir, 'owner');
|
|
28
|
+
const ownerToken = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
|
|
29
|
+
const deadline = Date.now() + 5_000;
|
|
30
|
+
await mkdir(dirname(lockDir), { recursive: true });
|
|
31
|
+
|
|
32
|
+
while (true) {
|
|
33
|
+
try {
|
|
34
|
+
await mkdir(lockDir, { recursive: false });
|
|
35
|
+
try {
|
|
36
|
+
await writeFile(ownerPath, ownerToken, 'utf8');
|
|
37
|
+
} catch (error) {
|
|
38
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
break;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
44
|
+
try {
|
|
45
|
+
const info = await stat(lockDir);
|
|
46
|
+
if (Date.now() - info.mtimeMs > DISPATCH_LOCK_STALE_MS) {
|
|
47
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// best effort
|
|
52
|
+
}
|
|
53
|
+
if (Date.now() > deadline) throw new Error(`Timed out acquiring dispatch lock for ${teamDirPath}`);
|
|
54
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
return await fn();
|
|
60
|
+
} finally {
|
|
61
|
+
try {
|
|
62
|
+
const currentOwner = await readFile(ownerPath, 'utf8');
|
|
63
|
+
if (currentOwner.trim() === ownerToken) {
|
|
64
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// best effort
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function withMailboxLock(teamDirPath, workerName, fn) {
|
|
73
|
+
const lockDir = join(teamDirPath, 'mailbox', `.lock-${workerName}`);
|
|
74
|
+
const ownerPath = join(lockDir, 'owner');
|
|
75
|
+
const ownerToken = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
|
|
76
|
+
const deadline = Date.now() + 5_000;
|
|
77
|
+
await mkdir(dirname(lockDir), { recursive: true });
|
|
78
|
+
|
|
79
|
+
while (true) {
|
|
80
|
+
try {
|
|
81
|
+
await mkdir(lockDir, { recursive: false });
|
|
82
|
+
try {
|
|
83
|
+
await writeFile(ownerPath, ownerToken, 'utf8');
|
|
84
|
+
} catch (error) {
|
|
85
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
91
|
+
try {
|
|
92
|
+
const info = await stat(lockDir);
|
|
93
|
+
if (Date.now() - info.mtimeMs > DISPATCH_LOCK_STALE_MS) {
|
|
94
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// best effort
|
|
99
|
+
}
|
|
100
|
+
if (Date.now() > deadline) throw new Error(`Timed out acquiring mailbox lock for ${teamDirPath}/${workerName}`);
|
|
101
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
return await fn();
|
|
107
|
+
} finally {
|
|
108
|
+
try {
|
|
109
|
+
const currentOwner = await readFile(ownerPath, 'utf8');
|
|
110
|
+
if (currentOwner.trim() === ownerToken) {
|
|
111
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// best effort
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function defaultInjectTarget(request, config) {
|
|
120
|
+
if (request.pane_id) return { type: 'pane', value: request.pane_id };
|
|
121
|
+
if (typeof request.worker_index === 'number' && Array.isArray(config?.workers)) {
|
|
122
|
+
const worker = config.workers.find((candidate) => Number(candidate?.index) === request.worker_index);
|
|
123
|
+
if (worker?.pane_id) return { type: 'pane', value: worker.pane_id };
|
|
124
|
+
}
|
|
125
|
+
if (typeof request.worker_index === 'number' && config.tmux_session) {
|
|
126
|
+
return { type: 'pane', value: `${config.tmux_session}:${request.worker_index}` };
|
|
127
|
+
}
|
|
128
|
+
if (config.tmux_session) return { type: 'session', value: config.tmux_session };
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function injectDispatchRequest(request, config, cwd) {
|
|
133
|
+
const target = defaultInjectTarget(request, config);
|
|
134
|
+
if (!target) {
|
|
135
|
+
return { ok: false, reason: 'missing_tmux_target' };
|
|
136
|
+
}
|
|
137
|
+
const resolution = await resolvePaneTarget(target, '', cwd, '');
|
|
138
|
+
if (!resolution.paneTarget) {
|
|
139
|
+
return { ok: false, reason: `target_resolution_failed:${resolution.reason}` };
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const inMode = await runProcess('tmux', buildPaneInModeArgv(resolution.paneTarget), 1000);
|
|
143
|
+
if (safeString(inMode.stdout).trim() === '1') {
|
|
144
|
+
return { ok: false, reason: 'scroll_active' };
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// best effort
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const argv = buildSendKeysArgv({
|
|
151
|
+
paneTarget: resolution.paneTarget,
|
|
152
|
+
prompt: request.trigger_message,
|
|
153
|
+
dryRun: false,
|
|
154
|
+
});
|
|
155
|
+
await runProcess('tmux', argv.typeArgv, 3000);
|
|
156
|
+
for (const submit of argv.submitArgv) {
|
|
157
|
+
await runProcess('tmux', submit, 3000);
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, reason: 'tmux_send_keys_sent', pane: resolution.paneTarget };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function shouldSkipRequest(request) {
|
|
163
|
+
if (request.status !== 'pending') return true;
|
|
164
|
+
return request.transport_preference !== 'hook_preferred_with_fallback';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function updateMailboxNotified(stateDir, teamName, workerName, messageId) {
|
|
168
|
+
const teamDirPath = join(stateDir, 'team', teamName);
|
|
169
|
+
const mailboxPath = join(teamDirPath, 'mailbox', `${workerName}.json`);
|
|
170
|
+
return await withMailboxLock(teamDirPath, workerName, async () => {
|
|
171
|
+
const mailbox = await readJson(mailboxPath, { worker: workerName, messages: [] });
|
|
172
|
+
if (!mailbox || !Array.isArray(mailbox.messages)) return false;
|
|
173
|
+
const msg = mailbox.messages.find((candidate) => candidate?.message_id === messageId);
|
|
174
|
+
if (!msg) return false;
|
|
175
|
+
if (!msg.notified_at) msg.notified_at = new Date().toISOString();
|
|
176
|
+
await writeJsonAtomic(mailboxPath, mailbox);
|
|
177
|
+
return true;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function appendDispatchLog(logsDir, event) {
|
|
182
|
+
const path = join(logsDir, `team-dispatch-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
183
|
+
await appendFile(path, `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`).catch(() => {});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function drainPendingTeamDispatch({
|
|
187
|
+
cwd,
|
|
188
|
+
stateDir = join(cwd, '.omx', 'state'),
|
|
189
|
+
logsDir = join(cwd, '.omx', 'logs'),
|
|
190
|
+
maxPerTick = 5,
|
|
191
|
+
injector = injectDispatchRequest,
|
|
192
|
+
} = {}) {
|
|
193
|
+
if (safeString(process.env.OMX_TEAM_WORKER)) {
|
|
194
|
+
return { processed: 0, skipped: 0, failed: 0, reason: 'worker_context' };
|
|
195
|
+
}
|
|
196
|
+
const teamRoot = join(stateDir, 'team');
|
|
197
|
+
if (!existsSync(teamRoot)) return { processed: 0, skipped: 0, failed: 0 };
|
|
198
|
+
|
|
199
|
+
const teams = await readdir(teamRoot).catch(() => []);
|
|
200
|
+
|
|
201
|
+
let processed = 0;
|
|
202
|
+
let skipped = 0;
|
|
203
|
+
let failed = 0;
|
|
204
|
+
|
|
205
|
+
for (const teamName of teams) {
|
|
206
|
+
if (processed >= maxPerTick) break;
|
|
207
|
+
const teamDirPath = join(teamRoot, teamName);
|
|
208
|
+
const manifestPath = join(teamDirPath, 'manifest.v2.json');
|
|
209
|
+
const configPath = join(teamDirPath, 'config.json');
|
|
210
|
+
const requestsPath = join(teamDirPath, 'dispatch', 'requests.json');
|
|
211
|
+
if (!existsSync(requestsPath)) continue;
|
|
212
|
+
|
|
213
|
+
const config = await readJson(existsSync(manifestPath) ? manifestPath : configPath, {});
|
|
214
|
+
await withDispatchLock(teamDirPath, async () => {
|
|
215
|
+
const requests = await readJson(requestsPath, []);
|
|
216
|
+
if (!Array.isArray(requests)) return;
|
|
217
|
+
|
|
218
|
+
let mutated = false;
|
|
219
|
+
for (const request of requests) {
|
|
220
|
+
if (processed >= maxPerTick) break;
|
|
221
|
+
if (!request || typeof request !== 'object') continue;
|
|
222
|
+
if (shouldSkipRequest(request)) {
|
|
223
|
+
skipped += 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const result = await injector(request, config, resolve(cwd));
|
|
228
|
+
const nowIso = new Date().toISOString();
|
|
229
|
+
request.attempt_count = Number.isFinite(request.attempt_count) ? Math.max(0, request.attempt_count + 1) : 1;
|
|
230
|
+
request.updated_at = nowIso;
|
|
231
|
+
|
|
232
|
+
if (result.ok) {
|
|
233
|
+
request.status = 'notified';
|
|
234
|
+
request.notified_at = nowIso;
|
|
235
|
+
request.last_reason = result.reason;
|
|
236
|
+
if (request.kind === 'mailbox' && request.message_id) {
|
|
237
|
+
await updateMailboxNotified(stateDir, teamName, request.to_worker, request.message_id).catch(() => {});
|
|
238
|
+
}
|
|
239
|
+
processed += 1;
|
|
240
|
+
mutated = true;
|
|
241
|
+
await appendDispatchLog(logsDir, {
|
|
242
|
+
type: 'dispatch_notified',
|
|
243
|
+
team: teamName,
|
|
244
|
+
request_id: request.request_id,
|
|
245
|
+
worker: request.to_worker,
|
|
246
|
+
message_id: request.message_id || null,
|
|
247
|
+
reason: result.reason,
|
|
248
|
+
});
|
|
249
|
+
} else {
|
|
250
|
+
request.status = 'failed';
|
|
251
|
+
request.failed_at = nowIso;
|
|
252
|
+
request.last_reason = result.reason;
|
|
253
|
+
processed += 1;
|
|
254
|
+
failed += 1;
|
|
255
|
+
mutated = true;
|
|
256
|
+
await appendDispatchLog(logsDir, {
|
|
257
|
+
type: 'dispatch_failed',
|
|
258
|
+
team: teamName,
|
|
259
|
+
request_id: request.request_id,
|
|
260
|
+
worker: request.to_worker,
|
|
261
|
+
message_id: request.message_id || null,
|
|
262
|
+
reason: result.reason,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (mutated) {
|
|
268
|
+
await writeJsonAtomic(requestsPath, requests);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return { processed, skipped, failed };
|
|
274
|
+
}
|
package/scripts/notify-hook.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* auto-nudge.js – stall-pattern detection and auto-nudge
|
|
15
15
|
* linked-sync.js – linked ralph/team terminal sync
|
|
16
16
|
* tmux-injection.js – tmux prompt injection
|
|
17
|
+
* team-dispatch.js – durable team dispatch queue consumer
|
|
17
18
|
* team-leader-nudge.js – leader mailbox nudge
|
|
18
19
|
* team-worker.js – worker heartbeat and idle notification
|
|
19
20
|
*/
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
readdir,
|
|
37
38
|
} from './notify-hook/state-io.js';
|
|
38
39
|
import { isLeaderStale, resolveLeaderStalenessThresholdMs, maybeNudgeTeamLeader } from './notify-hook/team-leader-nudge.js';
|
|
40
|
+
import { drainPendingTeamDispatch } from './notify-hook/team-dispatch.js';
|
|
39
41
|
import { syncLinkedRalphOnTeamTerminal } from './notify-hook/linked-sync.js';
|
|
40
42
|
import { handleTmuxInjection } from './notify-hook/tmux-injection.js';
|
|
41
43
|
import { maybeAutoNudge, resolveNudgePaneTarget } from './notify-hook/auto-nudge.js';
|
|
@@ -296,6 +298,15 @@ async function main() {
|
|
|
296
298
|
}
|
|
297
299
|
}
|
|
298
300
|
|
|
301
|
+
// 5.5. Opportunistic team dispatch drain (leader session only).
|
|
302
|
+
if (!isTeamWorker) {
|
|
303
|
+
try {
|
|
304
|
+
await drainPendingTeamDispatch({ cwd, stateDir, logsDir, maxPerTick: 5 });
|
|
305
|
+
} catch {
|
|
306
|
+
// Non-critical
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
299
310
|
// 6. Team leader nudge (lead session only): remind the leader to check teammate/mailbox state.
|
|
300
311
|
if (!isTeamWorker) {
|
|
301
312
|
try {
|
package/skills/team/SKILL.md
CHANGED
|
@@ -173,8 +173,9 @@ Semantics:
|
|
|
173
173
|
|
|
174
174
|
- `.omx/state/team/<team>/...` files
|
|
175
175
|
- Team mailbox files:
|
|
176
|
-
|
|
177
|
-
|
|
176
|
+
- `.omx/state/team/<team>/mailbox/leader-fixed.json`
|
|
177
|
+
- `.omx/state/team/<team>/mailbox/worker-<n>.json`
|
|
178
|
+
- `.omx/state/team/<team>/dispatch/requests.json` (durable dispatch queue; hook-preferred, fallback-aware)
|
|
178
179
|
|
|
179
180
|
### Key Files
|
|
180
181
|
|
package/skills/worker/SKILL.md
CHANGED
|
@@ -62,6 +62,10 @@ Check your mailbox for messages:
|
|
|
62
62
|
|
|
63
63
|
When notified, read messages and follow any instructions. Use short ACK replies when appropriate.
|
|
64
64
|
|
|
65
|
+
Note: leader dispatch is state-first. The durable queue lives at:
|
|
66
|
+
`<team_state_root>/team/<teamName>/dispatch/requests.json`
|
|
67
|
+
Hooks/watchers may nudge you after mailbox/inbox state is already written.
|
|
68
|
+
|
|
65
69
|
Use MCP tools:
|
|
66
70
|
- `team_mailbox_list` to read
|
|
67
71
|
- `team_mailbox_mark_delivered` to acknowledge delivery
|