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 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
- await spawnCodex(buildPrompt(next));
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 running
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
  });