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.
Files changed (47) hide show
  1. package/dist/cli/__tests__/team.test.js +17 -0
  2. package/dist/cli/__tests__/team.test.js.map +1 -1
  3. package/dist/cli/team.d.ts.map +1 -1
  4. package/dist/cli/team.js +3 -2
  5. package/dist/cli/team.js.map +1 -1
  6. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +44 -0
  7. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
  8. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.d.ts +2 -0
  9. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.d.ts.map +1 -0
  10. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js +135 -0
  11. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js.map +1 -0
  12. package/dist/mcp/__tests__/state-server-schema.test.js +2 -1
  13. package/dist/mcp/__tests__/state-server-schema.test.js.map +1 -1
  14. package/dist/team/__tests__/mcp-comm.test.js +117 -7
  15. package/dist/team/__tests__/mcp-comm.test.js.map +1 -1
  16. package/dist/team/__tests__/runtime.test.js +58 -1
  17. package/dist/team/__tests__/runtime.test.js.map +1 -1
  18. package/dist/team/__tests__/state.test.js +76 -1
  19. package/dist/team/__tests__/state.test.js.map +1 -1
  20. package/dist/team/contracts.d.ts +1 -1
  21. package/dist/team/contracts.d.ts.map +1 -1
  22. package/dist/team/contracts.js +2 -0
  23. package/dist/team/contracts.js.map +1 -1
  24. package/dist/team/mcp-comm.d.ts +28 -4
  25. package/dist/team/mcp-comm.d.ts.map +1 -1
  26. package/dist/team/mcp-comm.js +172 -6
  27. package/dist/team/mcp-comm.js.map +1 -1
  28. package/dist/team/runtime.d.ts.map +1 -1
  29. package/dist/team/runtime.js +374 -41
  30. package/dist/team/runtime.js.map +1 -1
  31. package/dist/team/scaling.d.ts.map +1 -1
  32. package/dist/team/scaling.js +90 -13
  33. package/dist/team/scaling.js.map +1 -1
  34. package/dist/team/state.d.ts +54 -1
  35. package/dist/team/state.d.ts.map +1 -1
  36. package/dist/team/state.js +300 -5
  37. package/dist/team/state.js.map +1 -1
  38. package/dist/team/team-ops.d.ts +8 -1
  39. package/dist/team/team-ops.d.ts.map +1 -1
  40. package/dist/team/team-ops.js +7 -0
  41. package/dist/team/team-ops.js.map +1 -1
  42. package/package.json +1 -1
  43. package/scripts/notify-fallback-watcher.js +4 -0
  44. package/scripts/notify-hook/team-dispatch.js +274 -0
  45. package/scripts/notify-hook.js +11 -0
  46. package/skills/team/SKILL.md +3 -2
  47. 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
+ }
@@ -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 {
@@ -173,8 +173,9 @@ Semantics:
173
173
 
174
174
  - `.omx/state/team/<team>/...` files
175
175
  - Team mailbox files:
176
- - `.omx/state/team/<team>/mailbox/leader-fixed.json`
177
- - `.omx/state/team/<team>/mailbox/worker-<n>.json`
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
 
@@ -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