kitty-hive 0.6.12 → 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/codex-channel.ts +463 -3
- package/dist/admin-http.js +266 -0
- package/dist/admin-http.js.map +1 -1
- package/dist/codex-channel-runtime.d.ts +126 -0
- package/dist/codex-channel-runtime.js +203 -0
- package/dist/codex-channel-runtime.js.map +1 -0
- package/dist/codex-supervisor.d.ts +84 -0
- package/dist/codex-supervisor.js +379 -0
- package/dist/codex-supervisor.js.map +1 -0
- package/dist/db.d.ts +26 -0
- package/dist/db.js +63 -2
- package/dist/db.js.map +1 -1
- package/dist/index.js +199 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp/agent-tools.js +62 -1
- package/dist/mcp/agent-tools.js.map +1 -1
- package/dist/mcp/dm-tools.js +6 -5
- package/dist/mcp/dm-tools.js.map +1 -1
- package/dist/mcp/task-tools.js +30 -10
- package/dist/mcp/task-tools.js.map +1 -1
- package/dist/models.d.ts +2 -0
- package/dist/models.js.map +1 -1
- package/dist/server.js +14 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/start.d.ts +1 -0
- package/dist/tools/start.js +55 -12
- package/dist/tools/start.js.map +1 -1
- package/package.json +1 -1
package/codex-channel.ts
CHANGED
|
@@ -31,8 +31,10 @@
|
|
|
31
31
|
* CODEX_EXTRA_ARGS extra space-separated args before the prompt
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
-
import { spawn } from 'node:child_process';
|
|
34
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
35
35
|
import { execSync } from 'node:child_process';
|
|
36
|
+
import { createServer } from 'node:net';
|
|
37
|
+
import { TurnTracker, type RpcTransport, type TurnOutcome } from './src/codex-channel-runtime.js';
|
|
36
38
|
|
|
37
39
|
// --- Config (env) ---
|
|
38
40
|
|
|
@@ -44,6 +46,12 @@ const HIVE_AGENT_ROLES = process.env.HIVE_AGENT_ROLES || '';
|
|
|
44
46
|
const CODEX_CMD = process.env.CODEX_CMD || 'codex';
|
|
45
47
|
const CODEX_PROFILE = process.env.CODEX_PROFILE || '';
|
|
46
48
|
const CODEX_EXTRA_ARGS = (process.env.CODEX_EXTRA_ARGS || '').trim();
|
|
49
|
+
// CODEX_CHANNEL_MODE: 'auto' (default) tries appserver first, falls back to
|
|
50
|
+
// exec if codex < 0.124 or app-server fails to start. 'appserver' forces
|
|
51
|
+
// appserver mode (fails hard if unavailable). 'exec' forces the legacy
|
|
52
|
+
// spawn-per-event mode (works on any codex version, no shared context).
|
|
53
|
+
const CODEX_CHANNEL_MODE = (process.env.CODEX_CHANNEL_MODE || 'auto') as 'auto' | 'appserver' | 'exec';
|
|
54
|
+
const CODEX_APPSERVER_CWD = process.env.CODEX_APPSERVER_CWD || process.cwd();
|
|
47
55
|
|
|
48
56
|
// Allow CLI flags to override env
|
|
49
57
|
for (let i = 2; i < process.argv.length; i++) {
|
|
@@ -207,6 +215,13 @@ interface ParsedEvent {
|
|
|
207
215
|
raw?: string;
|
|
208
216
|
}
|
|
209
217
|
|
|
218
|
+
// NO daemon-side retry. Earlier versions retried failed `sendTurn`s up to 3×;
|
|
219
|
+
// that turned out to be the proximate cause of the 2026-05-26 duplicate-turn
|
|
220
|
+
// incident — see codex-channel-runtime.ts header for the post-mortem. The
|
|
221
|
+
// TurnTracker now blocks re-issue of `turn/start` for an already-attempted
|
|
222
|
+
// event_id; loss recovery (hive SSE replay, pending_pushes drain on daemon
|
|
223
|
+
// respawn) lives at a higher layer.
|
|
224
|
+
|
|
210
225
|
let codexBusy = false;
|
|
211
226
|
const eventQueue: ParsedEvent[] = [];
|
|
212
227
|
|
|
@@ -254,6 +269,329 @@ function buildPrompt(ev: ParsedEvent): string {
|
|
|
254
269
|
].join('\n');
|
|
255
270
|
}
|
|
256
271
|
|
|
272
|
+
// ===== Appserver mode (codex ≥ 0.124) =====
|
|
273
|
+
// Long-lived codex via `codex app-server --listen ws://127.0.0.1:<port>`.
|
|
274
|
+
// One JSON-RPC WebSocket connection, one persistent thread, one turn per
|
|
275
|
+
// hive event injected via turn/start. Thread context survives across events
|
|
276
|
+
// — no codex startup overhead, no fresh-context loss.
|
|
277
|
+
|
|
278
|
+
let pushMode: 'appserver' | 'exec' | null = null;
|
|
279
|
+
let appserverProc: ChildProcess | null = null;
|
|
280
|
+
let appserverWs: any /* WebSocket */ = null;
|
|
281
|
+
let appserverWsUrl: string | null = null; // ws://127.0.0.1:<port> — for outside callers via supervisor
|
|
282
|
+
let threadId: string | null = null;
|
|
283
|
+
let appserverDeathHandled = false; // guard so multiple death signals only exit once
|
|
284
|
+
let turnTracker: TurnTracker | null = null;
|
|
285
|
+
|
|
286
|
+
/** Called when EITHER codex app-server child process dies post-ready OR the WS
|
|
287
|
+
* closes / errors. Both fire together when app-server crashes. Exits the
|
|
288
|
+
* daemon with non-zero so the supervisor's child.on('exit') triggers an
|
|
289
|
+
* exponential-backoff respawn with a fresh codex app-server. In-flight events
|
|
290
|
+
* in the local queue ARE lost — known limitation, documented in v0.7.0 notes.
|
|
291
|
+
* Idempotent via the appserverDeathHandled guard. */
|
|
292
|
+
function onAppserverDeath(reason: string): void {
|
|
293
|
+
if (appserverDeathHandled) return;
|
|
294
|
+
appserverDeathHandled = true;
|
|
295
|
+
console.error(`[codex-channel] appserver died: ${reason}`);
|
|
296
|
+
console.error('[codex-channel] exiting daemon — supervisor will respawn with fresh codex app-server');
|
|
297
|
+
try { appserverWs?.close(); } catch { /* ignore */ }
|
|
298
|
+
try { appserverProc?.kill('SIGTERM'); } catch { /* ignore */ }
|
|
299
|
+
// exit code 2 distinguishes "app-server crash" from "clean shutdown via SIGTERM"
|
|
300
|
+
process.exit(2);
|
|
301
|
+
}
|
|
302
|
+
let nextRpcId = 100;
|
|
303
|
+
const pendingResponses = new Map<number, { resolve: (r: any) => void; reject: (e: Error) => void }>();
|
|
304
|
+
|
|
305
|
+
function pickFreePort(): Promise<number> {
|
|
306
|
+
return new Promise((resolve, reject) => {
|
|
307
|
+
const srv = createServer();
|
|
308
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
309
|
+
const addr = srv.address();
|
|
310
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
311
|
+
srv.close(() => port ? resolve(port) : reject(new Error('failed to pick free port')));
|
|
312
|
+
});
|
|
313
|
+
srv.on('error', reject);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function setupAppserver(): Promise<void> {
|
|
318
|
+
if (typeof (globalThis as any).WebSocket !== 'function') {
|
|
319
|
+
throw new Error('global WebSocket not available (need Node 22+); set CODEX_CHANNEL_MODE=exec');
|
|
320
|
+
}
|
|
321
|
+
const port = await pickFreePort();
|
|
322
|
+
console.error(`[codex-channel] starting appserver: ${CODEX_CMD} app-server --listen ws://127.0.0.1:${port}`);
|
|
323
|
+
|
|
324
|
+
// detached: true puts appserverProc in its own process group, so SIGTERM
|
|
325
|
+
// to -pid kills the entire subtree (npm/npx wrapper + grandchild codex
|
|
326
|
+
// binary). Without this, killing only the wrapper leaves the actual codex
|
|
327
|
+
// process orphaned and still LISTENing on the ws port — see Bug 1 follow-up
|
|
328
|
+
// (2026-05-20): `agent remove` killed the daemon but `lsof -i :<port>` still
|
|
329
|
+
// showed the vendor codex binary holding the port.
|
|
330
|
+
appserverProc = spawn(CODEX_CMD, ['app-server', '--listen', `ws://127.0.0.1:${port}`], {
|
|
331
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
332
|
+
detached: true,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Wait for listen-ready line on stderr, or fail after 10s
|
|
336
|
+
await new Promise<void>((resolve, reject) => {
|
|
337
|
+
let ready = false;
|
|
338
|
+
const timer = setTimeout(() => {
|
|
339
|
+
if (!ready) { ready = true; reject(new Error('codex app-server did not become ready within 10s')); }
|
|
340
|
+
}, 10000);
|
|
341
|
+
appserverProc!.stderr!.on('data', (chunk) => {
|
|
342
|
+
const s = String(chunk);
|
|
343
|
+
process.stderr.write(`[codex-app] ${s}`);
|
|
344
|
+
if (!ready && /Listening|listen|started|ready/i.test(s)) {
|
|
345
|
+
ready = true; clearTimeout(timer); resolve();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
appserverProc!.stdout!.on('data', (chunk) => process.stderr.write(`[codex-app] ${chunk}`));
|
|
349
|
+
appserverProc!.on('exit', (code, signal) => {
|
|
350
|
+
if (!ready) {
|
|
351
|
+
ready = true; clearTimeout(timer);
|
|
352
|
+
reject(new Error(`codex app-server exited (code=${code}, signal=${signal}) before becoming ready`));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// post-ready exit: codex app-server crashed mid-flight. Daemon's WS will
|
|
356
|
+
// also close immediately; further turn/start calls would silently fail.
|
|
357
|
+
// Best recovery is for daemon to die so supervisor respawns it cleanly
|
|
358
|
+
// with a fresh codex app-server. In-flight events in the local queue
|
|
359
|
+
// are lost — acceptable for v1 (rare path); pending_pushes in hive
|
|
360
|
+
// will hold any events that arrived AFTER daemon exit and re-deliver
|
|
361
|
+
// when supervisor's new daemon binds.
|
|
362
|
+
onAppserverDeath(`codex app-server exited (code=${code}, signal=${signal})`);
|
|
363
|
+
});
|
|
364
|
+
appserverProc!.on('error', (err) => {
|
|
365
|
+
if (!ready) { ready = true; clearTimeout(timer); reject(err); return; }
|
|
366
|
+
onAppserverDeath(`codex app-server process error: ${err?.message || err}`);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Even after the "listening" log line, give the WS endpoint a beat to accept.
|
|
371
|
+
await new Promise(r => setTimeout(r, 200));
|
|
372
|
+
|
|
373
|
+
// Open WS
|
|
374
|
+
appserverWs = new (globalThis as any).WebSocket(`ws://127.0.0.1:${port}`);
|
|
375
|
+
await new Promise<void>((resolve, reject) => {
|
|
376
|
+
const onOpen = () => { cleanup(); resolve(); };
|
|
377
|
+
const onErr = (e: any) => { cleanup(); reject(new Error(`WS open failed: ${e?.message || e?.type || 'unknown'}`)); };
|
|
378
|
+
const cleanup = () => {
|
|
379
|
+
appserverWs.removeEventListener('open', onOpen);
|
|
380
|
+
appserverWs.removeEventListener('error', onErr);
|
|
381
|
+
};
|
|
382
|
+
appserverWs.addEventListener('open', onOpen);
|
|
383
|
+
appserverWs.addEventListener('error', onErr);
|
|
384
|
+
setTimeout(() => { if (appserverWs.readyState !== 1) { cleanup(); reject(new Error('WS open timeout')); } }, 5000);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
appserverWs.addEventListener('message', (ev: any) => {
|
|
388
|
+
let msg: any;
|
|
389
|
+
try { msg = JSON.parse(String(ev.data)); } catch (err) {
|
|
390
|
+
console.error('[codex-channel] WS parse error:', err);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
// RPC response
|
|
394
|
+
if (msg.id != null && (msg.result !== undefined || msg.error !== undefined)) {
|
|
395
|
+
const pending = pendingResponses.get(msg.id);
|
|
396
|
+
if (pending) {
|
|
397
|
+
pendingResponses.delete(msg.id);
|
|
398
|
+
if (msg.error) pending.reject(new Error(`codex rpc error: ${JSON.stringify(msg.error)}`));
|
|
399
|
+
else pending.resolve(msg.result);
|
|
400
|
+
}
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
// Notification — let the tracker route turn-related ones by turn id.
|
|
404
|
+
// Unmatched notifications (item delta, agentMessage stream, etc.) are
|
|
405
|
+
// ignored at this layer.
|
|
406
|
+
if (msg.method && turnTracker?.handleNotification(msg.method, msg.params)) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
appserverWs.addEventListener('close', () => {
|
|
412
|
+
onAppserverDeath('appserver WS closed by remote (codex app-server likely exited)');
|
|
413
|
+
});
|
|
414
|
+
appserverWs.addEventListener('error', (e: any) => {
|
|
415
|
+
onAppserverDeath(`appserver WS error: ${e?.message || e?.type || 'unknown'}`);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// 1. initialize
|
|
419
|
+
await rpcCall('initialize', {
|
|
420
|
+
clientInfo: { name: 'kitty-hive-codex-channel', version: '0.7.0' },
|
|
421
|
+
});
|
|
422
|
+
// initialized notification — fire and forget
|
|
423
|
+
appserverWs.send(JSON.stringify({ jsonrpc: '2.0', method: 'initialized', params: {} }));
|
|
424
|
+
|
|
425
|
+
// 2. thread/resume (if we have a persisted thread_id from a prior daemon
|
|
426
|
+
// lifetime) or thread/start (first-time spawn). Supervisor injects
|
|
427
|
+
// HIVE_AGENT_THREAD_ID after the first ready announcement, persisted in
|
|
428
|
+
// agents.thread_id; codex app-server loads the corresponding jsonl from
|
|
429
|
+
// ~/.codex/sessions/ so conversation history survives daemon kill /
|
|
430
|
+
// serve restart / machine reboot.
|
|
431
|
+
const persistedThreadId = (process.env.HIVE_AGENT_THREAD_ID || '').trim();
|
|
432
|
+
let resumed = false;
|
|
433
|
+
if (persistedThreadId) {
|
|
434
|
+
try {
|
|
435
|
+
const resumeResp = await rpcCall('thread/resume', { threadId: persistedThreadId });
|
|
436
|
+
const resumedId = resumeResp?.thread?.id;
|
|
437
|
+
if (!resumedId) throw new Error(`thread/resume returned no thread.id: ${JSON.stringify(resumeResp)}`);
|
|
438
|
+
threadId = resumedId;
|
|
439
|
+
resumed = true;
|
|
440
|
+
console.error(`[codex-channel] appserver thread resumed: ${threadId} (${resumeResp?.thread?.turns?.length ?? 0} turns)`);
|
|
441
|
+
} catch (err) {
|
|
442
|
+
// Common cause: jsonl missing (stale thread_id from a wiped ~/.codex)
|
|
443
|
+
// or codex schema upgrade. Fall back to a fresh thread; the new
|
|
444
|
+
// thread_id will overwrite the stale one in agents.thread_id via
|
|
445
|
+
// markDaemonReady → setAgentThreadId, so this self-heals.
|
|
446
|
+
console.error(`[codex-channel] thread/resume failed for ${persistedThreadId}, falling back to thread/start: ${err}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (!resumed) {
|
|
450
|
+
const threadResp = await rpcCall('thread/start', { cwd: CODEX_APPSERVER_CWD });
|
|
451
|
+
threadId = threadResp?.thread?.id;
|
|
452
|
+
if (!threadId) throw new Error(`thread/start did not return thread.id: ${JSON.stringify(threadResp)}`);
|
|
453
|
+
console.error(`[codex-channel] appserver thread started: ${threadId}`);
|
|
454
|
+
}
|
|
455
|
+
appserverWsUrl = `ws://127.0.0.1:${port}`;
|
|
456
|
+
|
|
457
|
+
// Bring up the TurnTracker now that we have a thread. RpcTransport is a
|
|
458
|
+
// thin adapter over the local rpcCall — the tracker stays decoupled from
|
|
459
|
+
// our WS plumbing so tests can swap in a stub transport.
|
|
460
|
+
turnTracker = new TurnTracker(
|
|
461
|
+
{ call: (method, params, timeoutMs) => rpcCall(method, params, timeoutMs) },
|
|
462
|
+
threadId!,
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
// 2a. Announce ready signal back to hive supervisor so kitty-kitty (or any
|
|
466
|
+
// other launcher) can query the (ws_url, thread_id) pair via
|
|
467
|
+
// hive_codex_pane_ws / GET /admin/codex-daemons. Best-effort: failure here
|
|
468
|
+
// just means the daemon's pane info isn't immediately discoverable; daemon
|
|
469
|
+
// still processes pushes normally. Also persists thread_id for next spawn.
|
|
470
|
+
await announceReady().catch(err => {
|
|
471
|
+
console.error('[codex-channel] failed to announce ready to supervisor:', err);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// 3. intro turn. On thread/start (first-time spawn), inject the full agent
|
|
475
|
+
// brief. On thread/resume, the brief is already in thread history — but the
|
|
476
|
+
// daemon process is brand new, so its MCP client session lost the
|
|
477
|
+
// hive_start binding that the thread previously made. Inject a short
|
|
478
|
+
// re-bind notice so codex calls hive_start again before the next event.
|
|
479
|
+
if (resumed) {
|
|
480
|
+
const rebind = [
|
|
481
|
+
`[kitty-hive] daemon restarted — your MCP session is fresh.`,
|
|
482
|
+
`Call hive_start({ id: "${agentId}" }) again to re-bind your hive identity`,
|
|
483
|
+
`before handling the next event. Then wait silently for the next push.`,
|
|
484
|
+
].join('\n');
|
|
485
|
+
const outcome = await sendTurn(rebind, { eventId: `daemon-rebind:${threadId}:${Date.now()}` });
|
|
486
|
+
if (outcome.kind !== 'completed') {
|
|
487
|
+
console.error(`[codex-channel] rebind intro turn outcome: ${outcome.kind} — continuing anyway`);
|
|
488
|
+
}
|
|
489
|
+
} else {
|
|
490
|
+
const intro = [
|
|
491
|
+
`You are kitty-hive agent "${agentName}" (id: ${agentId}).`,
|
|
492
|
+
``,
|
|
493
|
+
`You are running inside a persistent codex thread driven by the kitty-hive`,
|
|
494
|
+
`codex-channel daemon. The daemon will inject one short message per hive`,
|
|
495
|
+
`event into this thread; you handle the event and wait for the next.`,
|
|
496
|
+
``,
|
|
497
|
+
`FIRST ACTION: call hive_start({ id: "${agentId}" }) to bind your MCP`,
|
|
498
|
+
`session to your hive identity. This makes every hive_* tool call run`,
|
|
499
|
+
`as you (not as a new agent). Do this BEFORE handling the first event.`,
|
|
500
|
+
`NOTE: if you ever see a "[kitty-hive] daemon restarted" notice, call`,
|
|
501
|
+
`hive_start again — the daemon process restarted and the MCP binding`,
|
|
502
|
+
`was lost (but this thread's history was preserved).`,
|
|
503
|
+
``,
|
|
504
|
+
`For each event:`,
|
|
505
|
+
`- Push notifications are id-only by design — call the fetch tool the`,
|
|
506
|
+
` daemon points to (hive_dm_read / hive_check / hive_team_events /`,
|
|
507
|
+
` hive_team_info) BEFORE acting on the event content.`,
|
|
508
|
+
`- Handle per type (DM, task-propose, step-start, awaiting_approval,`,
|
|
509
|
+
` team-message, ...) using the matching hive_* tools.`,
|
|
510
|
+
`- When done, just stop. The next event will arrive as a new turn.`,
|
|
511
|
+
``,
|
|
512
|
+
`Acknowledge readiness briefly, then wait for the first event.`,
|
|
513
|
+
].join('\n');
|
|
514
|
+
const outcome = await sendTurn(intro, { eventId: `daemon-intro:${threadId}:${Date.now()}` });
|
|
515
|
+
if (outcome.kind !== 'completed') {
|
|
516
|
+
console.error(`[codex-channel] startup intro turn outcome: ${outcome.kind} — continuing anyway`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Tell the hive supervisor (the parent process that spawned us) that we have
|
|
522
|
+
* a live ws + thread, so it can answer hive_codex_pane_ws / show in admin
|
|
523
|
+
* snapshots. HIVE_URL is the supervisor's MCP base url (set explicitly by
|
|
524
|
+
* codex-supervisor when it spawns us); derive admin URL from it. */
|
|
525
|
+
async function announceReady(): Promise<void> {
|
|
526
|
+
if (!agentId || !appserverWsUrl || !threadId) return;
|
|
527
|
+
const adminUrl = ENV_URL.replace(/\/mcp\/?$/, '') + '/admin/codex-daemon-ready';
|
|
528
|
+
const res = await fetch(adminUrl, {
|
|
529
|
+
method: 'POST',
|
|
530
|
+
headers: { 'Content-Type': 'application/json' },
|
|
531
|
+
body: JSON.stringify({
|
|
532
|
+
agent_id: agentId,
|
|
533
|
+
ws_url: appserverWsUrl,
|
|
534
|
+
thread_id: threadId,
|
|
535
|
+
}),
|
|
536
|
+
});
|
|
537
|
+
if (!res.ok) {
|
|
538
|
+
throw new Error(`POST ${adminUrl} → ${res.status} ${await res.text().catch(() => '')}`);
|
|
539
|
+
}
|
|
540
|
+
console.error(`[codex-channel] announced ready: ws=${appserverWsUrl} thread=${threadId.slice(0, 8)}...`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function rpcCall(method: string, params: any, timeoutMs = 30000): Promise<any> {
|
|
544
|
+
if (!appserverWs || appserverWs.readyState !== 1) throw new Error('appserver WS not open');
|
|
545
|
+
const id = nextRpcId++;
|
|
546
|
+
const payload = JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
|
547
|
+
return new Promise((resolve, reject) => {
|
|
548
|
+
pendingResponses.set(id, { resolve, reject });
|
|
549
|
+
appserverWs.send(payload);
|
|
550
|
+
setTimeout(() => {
|
|
551
|
+
if (pendingResponses.has(id)) {
|
|
552
|
+
pendingResponses.delete(id);
|
|
553
|
+
reject(new Error(`codex appserver rpc '${method}' timed out after ${timeoutMs}ms`));
|
|
554
|
+
}
|
|
555
|
+
}, timeoutMs);
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Inject one hive event as a turn into the codex thread. Returns the
|
|
560
|
+
* terminal outcome — caller branches on `outcome.kind` rather than catching.
|
|
561
|
+
* See codex-channel-runtime.ts for the design rationale (2026-05-26 incident
|
|
562
|
+
* notes). The eventId is used for cross-attempt idempotency: once an event
|
|
563
|
+
* has been handed to the tracker, the tracker refuses to re-issue
|
|
564
|
+
* `turn/start` for the same id, no matter what kind of failure intervened.
|
|
565
|
+
* This is the core defense against duplicate-turn injection. */
|
|
566
|
+
async function sendTurn(text: string, opts: { eventId?: string } = {}): Promise<TurnOutcome> {
|
|
567
|
+
if (!turnTracker) throw new Error('turnTracker not initialized — did setupAppserver run?');
|
|
568
|
+
return turnTracker.sendTurn(text, opts);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Short prompt for appserver mode — context is persistent, so each event is
|
|
572
|
+
* just "here's what arrived + how to fetch full content". */
|
|
573
|
+
function buildEventTurnText(ev: ParsedEvent): string {
|
|
574
|
+
const senderLabel = ev.from || ev.from_agent_id || 'unknown';
|
|
575
|
+
const summary = ev.title || ev.preview || ev.raw || `(no summary)`;
|
|
576
|
+
let fetchHint: string;
|
|
577
|
+
if (ev.type === 'message' && ev.message_id != null) {
|
|
578
|
+
fetchHint = `hive_dm_read({ message_id: ${ev.message_id} })`;
|
|
579
|
+
} else if (ev.task_id) {
|
|
580
|
+
fetchHint = `hive_check({ task_id: "${ev.task_id}" })`;
|
|
581
|
+
} else if (ev.team_id) {
|
|
582
|
+
fetchHint = ev.type === 'team-rules-update'
|
|
583
|
+
? `hive_team_info({ team_id: "${ev.team_id}" }) # rules updated; refresh`
|
|
584
|
+
: `hive_team_events({ team_id: "${ev.team_id}" })`;
|
|
585
|
+
} else {
|
|
586
|
+
fetchHint = `hive_inbox()`;
|
|
587
|
+
}
|
|
588
|
+
return [
|
|
589
|
+
`[hive event] type=${ev.type} from=${senderLabel}`,
|
|
590
|
+
`summary: ${summary}`,
|
|
591
|
+
`fetch: ${fetchHint}`,
|
|
592
|
+
].join('\n');
|
|
593
|
+
}
|
|
594
|
+
|
|
257
595
|
function spawnCodex(prompt: string): Promise<void> {
|
|
258
596
|
return new Promise((resolve) => {
|
|
259
597
|
const args: string[] = ['exec'];
|
|
@@ -283,17 +621,80 @@ function spawnCodex(prompt: string): Promise<void> {
|
|
|
283
621
|
});
|
|
284
622
|
}
|
|
285
623
|
|
|
624
|
+
/** Build a stable dedup key from a parsed event. Used as TurnTracker's
|
|
625
|
+
* eventId so even a retry/replay of the same logical hive event never
|
|
626
|
+
* produces a second `turn/start` on codex's side. Falls back to a content
|
|
627
|
+
* hash when nothing else is available. */
|
|
628
|
+
function eventDedupKey(ev: ParsedEvent): string {
|
|
629
|
+
if (ev.event_id) return `evid:${ev.event_id}`;
|
|
630
|
+
if (ev.message_id != null) return `dm:${ev.message_id}`;
|
|
631
|
+
if (ev.task_id) return `task:${ev.task_id}:${ev.type || 'unknown'}`;
|
|
632
|
+
if (ev.team_id) return `team:${ev.team_id}:${ev.type || 'unknown'}`;
|
|
633
|
+
// Last resort — should be rare; hash content + sender so unrelated events
|
|
634
|
+
// don't collide.
|
|
635
|
+
return `raw:${ev.from_agent_id || 'unknown'}:${ev.type || 'unknown'}:${(ev.raw || ev.preview || '').slice(0, 80)}`;
|
|
636
|
+
}
|
|
637
|
+
|
|
286
638
|
async function processNextEvent() {
|
|
287
639
|
if (codexBusy) return;
|
|
288
640
|
const next = eventQueue.shift();
|
|
289
641
|
if (!next) return;
|
|
290
642
|
codexBusy = true;
|
|
291
643
|
try {
|
|
292
|
-
|
|
644
|
+
if (pushMode === 'appserver') {
|
|
645
|
+
const text = buildEventTurnText(next);
|
|
646
|
+
const eventId = eventDedupKey(next);
|
|
647
|
+
console.error(`[codex-channel] inject turn (${text.length} chars) eventId=${eventId} into thread ${threadId?.slice(-8)}`);
|
|
648
|
+
const outcome = await sendTurn(text, { eventId });
|
|
649
|
+
// TurnOutcome is a closed union — each kind reflects a known terminal
|
|
650
|
+
// state observed at codex's side. We DON'T retry locally on any of
|
|
651
|
+
// these: re-issuing turn/start was the exact cause of the 2026-05-26
|
|
652
|
+
// duplicate-turn incident. Loss recovery (if any is warranted) lives
|
|
653
|
+
// a layer up: hive's pending_pushes / SSE replay across daemon
|
|
654
|
+
// respawn delivers missed events again.
|
|
655
|
+
switch (outcome.kind) {
|
|
656
|
+
case 'completed':
|
|
657
|
+
// Normal path. Nothing more to do.
|
|
658
|
+
break;
|
|
659
|
+
case 'failed':
|
|
660
|
+
console.error(`[codex-channel] turn failed on codex (turnId=${outcome.turnId} willRetry=${outcome.willRetry}): ${JSON.stringify(outcome.error)}`);
|
|
661
|
+
break;
|
|
662
|
+
case 'interrupted':
|
|
663
|
+
console.error(`[codex-channel] turn interrupted on codex (turnId=${outcome.turnId}) — event consumed`);
|
|
664
|
+
break;
|
|
665
|
+
case 'timeout':
|
|
666
|
+
console.error(`[codex-channel] turn did not complete within ${outcome.afterMs}ms (turnId=${outcome.turnId}); leaving turn in-flight on codex, NOT retrying to avoid duplicate inject`);
|
|
667
|
+
break;
|
|
668
|
+
case 'rpc_send_error':
|
|
669
|
+
console.error(`[codex-channel] turn/start RPC error (event eventId=${eventId}): ${outcome.error.message}`);
|
|
670
|
+
if (!appserverWs || appserverWs.readyState !== 1) {
|
|
671
|
+
console.error('[codex-channel] appserver WS appears dead; switching subsequent events to exec mode (daemon will exit shortly anyway)');
|
|
672
|
+
pushMode = 'exec';
|
|
673
|
+
}
|
|
674
|
+
break;
|
|
675
|
+
case 'skipped_duplicate':
|
|
676
|
+
// Should only fire if the same eventId is enqueued twice in this
|
|
677
|
+
// daemon lifetime — e.g. SSE drain replay after a brief disconnect.
|
|
678
|
+
// Expected behavior; log at info to make it visible without alarm.
|
|
679
|
+
console.error(`[codex-channel] skipped already-injected event (eventId=${outcome.eventId})`);
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
} else {
|
|
683
|
+
await spawnCodex(buildPrompt(next));
|
|
684
|
+
}
|
|
685
|
+
} catch (err) {
|
|
686
|
+
// Anything that throws now is genuinely unexpected (sendTurn no longer
|
|
687
|
+
// throws — it returns TurnOutcome). Log loudly and move on; do not
|
|
688
|
+
// re-queue, to preserve the no-duplicate guarantee.
|
|
689
|
+
console.error('[codex-channel] unexpected processing error (event dropped to preserve no-duplicate guarantee):', err);
|
|
690
|
+
console.error(`[codex-channel] dropped event: ${JSON.stringify({
|
|
691
|
+
type: next.type, event_id: next.event_id, task_id: next.task_id,
|
|
692
|
+
message_id: next.message_id, from: next.from || next.from_agent_id,
|
|
693
|
+
})}`);
|
|
293
694
|
} finally {
|
|
294
695
|
codexBusy = false;
|
|
295
696
|
}
|
|
296
|
-
// Drain any events that piled up while codex was
|
|
697
|
+
// Drain any events that piled up while codex was busy
|
|
297
698
|
if (eventQueue.length > 0) setImmediate(processNextEvent);
|
|
298
699
|
}
|
|
299
700
|
|
|
@@ -371,8 +772,63 @@ async function listenSSE() {
|
|
|
371
772
|
|
|
372
773
|
// --- Boot ---
|
|
373
774
|
|
|
775
|
+
async function setupPushMode() {
|
|
776
|
+
if (CODEX_CHANNEL_MODE === 'exec') {
|
|
777
|
+
pushMode = 'exec';
|
|
778
|
+
console.error(`[codex-channel] mode: exec (per-event codex spawn; forced via CODEX_CHANNEL_MODE=exec)`);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
// 'auto' or 'appserver': try appserver
|
|
782
|
+
try {
|
|
783
|
+
await setupAppserver();
|
|
784
|
+
pushMode = 'appserver';
|
|
785
|
+
console.error(`[codex-channel] mode: appserver (long-lived codex thread; context persists across events)`);
|
|
786
|
+
} catch (err) {
|
|
787
|
+
if (CODEX_CHANNEL_MODE === 'appserver') {
|
|
788
|
+
console.error(`[codex-channel] appserver mode forced but setup failed: ${err}`);
|
|
789
|
+
cleanupAppserver();
|
|
790
|
+
process.exit(1);
|
|
791
|
+
}
|
|
792
|
+
console.error(`[codex-channel] appserver setup failed, falling back to exec mode: ${err}`);
|
|
793
|
+
cleanupAppserver();
|
|
794
|
+
pushMode = 'exec';
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function cleanupAppserver() {
|
|
799
|
+
if (appserverWs) {
|
|
800
|
+
try { appserverWs.close(); } catch { /* ignore */ }
|
|
801
|
+
appserverWs = null;
|
|
802
|
+
}
|
|
803
|
+
if (appserverProc?.pid) {
|
|
804
|
+
// Kill the WHOLE process group (negative pid). Because we spawned with
|
|
805
|
+
// `detached: true`, appserverProc.pid is also the group leader id; the
|
|
806
|
+
// grandchild codex binary the wrapper exec'd is in the same group and
|
|
807
|
+
// will receive SIGTERM too. Without this, the wrapper dies but the
|
|
808
|
+
// grandchild codex binary stays alive holding the ws port.
|
|
809
|
+
try { process.kill(-appserverProc.pid, 'SIGTERM'); } catch { /* ignore */ }
|
|
810
|
+
// Fallback: also SIGTERM the wrapper itself in case kill -group missed.
|
|
811
|
+
try { appserverProc.kill('SIGTERM'); } catch { /* ignore */ }
|
|
812
|
+
appserverProc = null;
|
|
813
|
+
}
|
|
814
|
+
threadId = null;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function shutdown(signal: NodeJS.Signals) {
|
|
818
|
+
// Mark intentional shutdown BEFORE killing children — otherwise
|
|
819
|
+
// appserverProc.kill() triggers our death handler thinking app-server
|
|
820
|
+
// crashed, racing process.exit(0) with process.exit(2).
|
|
821
|
+
appserverDeathHandled = true;
|
|
822
|
+
console.error(`[codex-channel] received ${signal}, shutting down...`);
|
|
823
|
+
cleanupAppserver();
|
|
824
|
+
process.exit(0);
|
|
825
|
+
}
|
|
826
|
+
process.on('SIGTERM', shutdown);
|
|
827
|
+
process.on('SIGINT', shutdown);
|
|
828
|
+
|
|
374
829
|
async function main() {
|
|
375
830
|
preflight();
|
|
831
|
+
// 1. register on hive
|
|
376
832
|
while (true) {
|
|
377
833
|
try {
|
|
378
834
|
await initHiveSession();
|
|
@@ -385,10 +841,14 @@ async function main() {
|
|
|
385
841
|
await new Promise((r) => setTimeout(r, 3000));
|
|
386
842
|
}
|
|
387
843
|
}
|
|
844
|
+
// 2. set up codex push mode (appserver preferred, exec fallback)
|
|
845
|
+
await setupPushMode();
|
|
846
|
+
// 3. subscribe to hive SSE
|
|
388
847
|
await listenSSE();
|
|
389
848
|
}
|
|
390
849
|
|
|
391
850
|
main().catch((err) => {
|
|
392
851
|
console.error('[codex-channel] fatal:', err);
|
|
852
|
+
cleanupAppserver();
|
|
393
853
|
process.exit(1);
|
|
394
854
|
});
|