From 4310b1fe2670cc52e671921490901c1a6d5af8fc Mon Sep 17 00:00:00 2001 From: MEKSI8 Date: Wed, 16 Sep 2026 18:26:34 +0300 Subject: [PATCH] fix(hive): backfill the context gauge for Codex agents from their rollout log Codex agent cards can remain without a context (`ctx %`) reading while a worker is active. The root cause is a provider-coverage gap, not one shared telemetry failure: Claude can send an exact reading through a status hook and the renderer independently backfills from Claude transcripts every 15 seconds (`useHive.ts:565-590`), while fleet `tokens`/`lastTool` come from a separate OpenTelemetry input. Codex supplies none of those, but its rollout logs carry `token_count` events with the same accounting. `HiveManager` now exposes the isolated worker `CODEX_HOME` at `/agents//.codex`. The main-process poll reads that location rather than the registry's project `cwd`, selects the newest nested rollout, and feeds the latest valid `last_token_usage.input_tokens` / `model_context_window` reading through `HookServer.reportContext()`. The renderer needs no change. The 8-second poll does not walk all rollout history on every tick: the selected path, mtime, size and reading are cached, ordinary ticks stat only the selected file, and discovery repeats after 60 seconds or as soon as the selected file disappears. Codex hook delivery was traced through the shared shim architecture but was not empirically proven here; this fix does not depend on that path. Fleet cost/activity telemetry stays out of scope. Tests: 15 new cases in test/codex-context.test.cjs, including the registry/HiveManager seam and the tail-boundary case. The repository's full `node --test test/*.test.cjs` exits 1 on this platform both before and after this change with the same 19 pre-existing failures. --- src/main/codexContext.ts | 205 +++++++++++++++++++++++++++++ src/main/hive.ts | 5 + src/main/hooks.ts | 28 ++-- src/main/index.ts | 20 +++ test/codex-context.test.cjs | 256 ++++++++++++++++++++++++++++++++++++ 5 files changed, 502 insertions(+), 12 deletions(-) create mode 100644 src/main/codexContext.ts create mode 100644 test/codex-context.test.cjs diff --git a/src/main/codexContext.ts b/src/main/codexContext.ts new file mode 100644 index 000000000..efeb4e2bf --- /dev/null +++ b/src/main/codexContext.ts @@ -0,0 +1,205 @@ +/** + * Codex context-gauge backfill. + * + * Context coverage has provider-specific inputs. Claude can report exact + * readings through status hooks, while the renderer independently backfills + * missing Claude readings from transcripts every 15 seconds. Fleet + * tokens/lastTool use separate OpenTelemetry inputs. Codex supplies none of + * those Claude-shaped context inputs, but its rollout logs contain a compatible + * `token_count` reading. Hook delivery was traced through the shared shim + * architecture, not empirically verified here; rollout polling is independent + * of that path. + * + * HiveManager exposes each worker's isolated CODEX_HOME at + * `/agents//.codex`. This module tails its latest rollout and feeds + * the reading through `HookServer.reportContext()`. + * + * Pure Node - no `electron` import - so it runs standalone in `node --test`. + */ +import { readdirSync, statSync, existsSync, openSync, fstatSync, readSync, closeSync, type Dirent } from 'node:fs'; +import { join } from 'node:path'; + +export interface CodexContextReading { + tokens: number; + limit: number; +} + +const MAX_WALK_DEPTH = 6; +const DISCOVERY_TTL_MS = 60_000; + +interface RolloutFile { + path: string; + mtimeMs: number; + size: number; +} + +interface ContextCacheEntry { + discoveredAt: number; + selected: RolloutFile | null; + reading: CodexContextReading | null; +} + +const contextCache = new Map(); + +function collectRolloutFiles(dir: string, depth = 0): RolloutFile[] { + if (depth > MAX_WALK_DEPTH) return []; + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + const found: RolloutFile[] = []; + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + found.push(...collectRolloutFiles(full, depth + 1)); + } else if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) { + try { + const stat = statSync(full); + found.push({ path: full, mtimeMs: stat.mtimeMs, size: stat.size }); + } catch { + // The file can disappear between directory enumeration and stat. + } + } + } + return found; +} + +function findLatestRollout(codexHome: string): RolloutFile | null { + const sessionsDir = join(codexHome, 'sessions'); + if (!existsSync(sessionsDir)) return null; + const files = collectRolloutFiles(sessionsDir); + if (!files.length) return null; + files.sort((a, b) => b.mtimeMs - a.mtimeMs); + return files[0]; +} + +export function findLatestCodexRollout(codexHome: string): string | null { + return findLatestRollout(codexHome)?.path ?? null; +} + +const DEFAULT_TAIL_BYTES = 256 * 1024; + +interface RolloutTokenCountPayload { + type: 'token_count'; + info?: { + last_token_usage?: { input_tokens?: unknown }; + model_context_window?: unknown; + }; +} + +function isTokenCountPayload(value: unknown): value is RolloutTokenCountPayload { + return !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'token_count'; +} + +export function readLatestTokenCount( + filePath: string, + tailBytes: number = DEFAULT_TAIL_BYTES +): CodexContextReading | null { + let fd: number; + try { + fd = openSync(filePath, 'r'); + } catch { + return null; + } + try { + const size = fstatSync(fd).size; + const start = Math.max(0, size - tailBytes); + const length = size - start; + if (length <= 0) return null; + const buffer = Buffer.alloc(length); + readSync(fd, buffer, 0, length, start); + const lines = buffer.toString('utf8').split('\n'); + let startsAtLineBoundary = start === 0; + if (start > 0) { + const previousByte = Buffer.alloc(1); + readSync(fd, previousByte, 0, 1, start - 1); + startsAtLineBoundary = previousByte[0] === 0x0a; + } + const usableLines = startsAtLineBoundary ? lines : lines.slice(1); + for (let i = usableLines.length - 1; i >= 0; i--) { + const line = usableLines[i].trim(); + if (!line) continue; + let event: unknown; + try { + event = JSON.parse(line); + } catch { + continue; + } + const payload = (event as { payload?: unknown } | null)?.payload; + if (!isTokenCountPayload(payload)) continue; + const tokens = payload.info?.last_token_usage?.input_tokens; + const limit = payload.info?.model_context_window; + if (typeof tokens === 'number' && Number.isFinite(tokens) + && typeof limit === 'number' && Number.isFinite(limit) && limit > 0) { + return { tokens, limit }; + } + } + return null; + } finally { + closeSync(fd); + } +} + +export function clearCodexContextCache(): void { + contextCache.clear(); +} + +export function readCodexContext( + codexHome: string, + nowMs: number = Date.now() +): CodexContextReading | null { + try { + const cached = contextCache.get(codexHome); + if (cached && nowMs - cached.discoveredAt < DISCOVERY_TTL_MS) { + if (!cached.selected) return cached.reading; + try { + const stat = statSync(cached.selected.path); + if (stat.mtimeMs === cached.selected.mtimeMs && stat.size === cached.selected.size) { + return cached.reading; + } + const reading = readLatestTokenCount(cached.selected.path); + contextCache.set(codexHome, { + ...cached, + selected: { path: cached.selected.path, mtimeMs: stat.mtimeMs, size: stat.size }, + reading + }); + return reading; + } catch { + contextCache.delete(codexHome); + } + } + + const selected = findLatestRollout(codexHome); + const reading = selected ? readLatestTokenCount(selected.path) : null; + contextCache.set(codexHome, { discoveredAt: nowMs, selected, reading }); + return reading; + } catch { + return null; + } +} + +interface CodexRegistrySource { + registry(): { + agents: Record; + }; + codexHome(agentId: string): string | null; +} + +export interface CodexAgentContextReading { + agentId: string; + reading: CodexContextReading; +} + +export function readCodexRegistryContexts(source: CodexRegistrySource): CodexAgentContextReading[] { + const readings: CodexAgentContextReading[] = []; + for (const [agentId, agent] of Object.entries(source.registry().agents)) { + if (agent.archived || agent.provider !== 'codex') continue; + const codexHome = source.codexHome(agentId); + if (!codexHome) continue; + const reading = readCodexContext(codexHome); + if (reading) readings.push({ agentId, reading }); + } + return readings; +} diff --git a/src/main/hive.ts b/src/main/hive.ts index 4537c2bf6..4ed878f97 100644 --- a/src/main/hive.ts +++ b/src/main/hive.ts @@ -417,6 +417,11 @@ export class HiveManager { private agentDir(id: string): string { return join(this.root()!, 'agents', id); } + /** The isolated CODEX_HOME provisioned for a hive worker. */ + codexHome(id: string): string | null { + const root = this.root(); + return root ? join(root, 'agents', id, '.codex') : null; + } /** IPC endpoint the cth-hook shim talks to (Phase 1 autonomy). * On POSIX this is a Unix-domain socket file under the hive root. On Windows, * Node's `net` IPC uses named pipes (a flat `\\.\pipe\` namespace, not the diff --git a/src/main/hooks.ts b/src/main/hooks.ts index a4cf07439..45e7c48d6 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -148,6 +148,21 @@ export class HookServer { return this.contextById.get(agentId); } + /** Route a non-status context reading through the shared retention and IPC + * path. Codex rollout polling uses this entry point; the renderer's Claude + * transcript fallback remains independent. */ + reportContext(agentId: string, tokens: number, limit: number): void { + if (!agentId || !Number.isFinite(tokens) || !Number.isFinite(limit) || limit <= 0) return; + this.setContext(agentId, tokens, limit); + } + + private setContext(agentId: string, tokens: number, limit: number): void { + // Retain for main-side reads (voice get_agent_detail / list_agents) … + this.contextById.set(agentId, { tokens, limit, ts: Date.now() }); + // … and forward live to the renderer's agent-card context gauge. + this.getWebContents()?.send('hive:contextUpdate', { agentId, tokens, limit }); + } + private handle(p: HookPayload): unknown { const agentId = p.agent_id ?? undefined; const event = p.hook_event_name ?? 'Unknown'; @@ -170,18 +185,7 @@ export class HookServer { const cw = p.context_window; if (agentId && cw && typeof cw.total_input_tokens === 'number' && typeof cw.context_window_size === 'number' && cw.context_window_size > 0) { - // Retain for main-side reads (voice get_agent_detail / list_agents) … - this.contextById.set(agentId, { - tokens: cw.total_input_tokens, - limit: cw.context_window_size, - ts: Date.now() - }); - // … and forward live to the renderer's agent-card context gauge. - this.getWebContents()?.send('hive:contextUpdate', { - agentId, - tokens: cw.total_input_tokens, - limit: cw.context_window_size - }); + this.setContext(agentId, cw.total_input_tokens, cw.context_window_size); } return {}; } diff --git a/src/main/index.ts b/src/main/index.ts index 40634ac6f..bd7f0916a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -55,6 +55,7 @@ import { registerRealtimeActionIpc } from './realtimeActions'; import { initCompletionWatcher } from './realtimeCompletionWatcher'; import type { TaskCard, InboxMessage } from './realtimeCompletionWatcher'; import { TelemetryCollector } from './telemetry'; +import { readCodexRegistryContexts } from './codexContext'; import { CostLedgerTotals } from './costLifetime'; import { analytics, isRendererMessageSurface } from './analytics'; import type { SpawnFailReason } from './analytics'; @@ -272,6 +273,7 @@ const breaker = new CircuitBreaker(() => { // heartbeat mission is disabled (it ships off). let fleetTimer: ReturnType | null = null; let breakerBeatTimer: ReturnType | null = null; +let codexContextTimer: ReturnType | null = null; // Feed the breaker's api_error-storm trip from Oscar's OTel api_error spans — // Jim's one breaker input with no on-branch source (telemetry.onApiError seam). telemetry.onApiError((agentId) => breaker.recordError(agentId)); @@ -1310,6 +1312,21 @@ function writeFleetSnapshot(): void { } } +/** Backfill Codex context from its provider-specific rollout data. Claude can + * use status hooks or the renderer's 15-second transcript fallback, while + * fleet telemetry is a separate input. Codex hook delivery was traced through + * the shared shim architecture but was not empirically verified for this fix. */ +function pollCodexContext(): void { + if (!hive.enabled()) return; + try { + for (const { agentId, reading } of readCodexRegistryContexts(hive)) { + hookServer.reportContext(agentId, reading.tokens, reading.limit); + } + } catch (e) { + console.error('[fleet] codex context poll failed:', e); + } +} + /** Arm the heartbeat with an adaptive, self-rescheduling cadence (recursive * setTimeout instead of a fixed setInterval). Each beat runs the cost/breaker * pass, re-engages a quiet floor, stamps lastFiredAt, then re-arms: ~base on a @@ -5180,6 +5197,9 @@ function armAlwaysOnBeats(): void { if (fleetTimer) clearInterval(fleetTimer); writeFleetSnapshot(); fleetTimer = setInterval(writeFleetSnapshot, 8_000); + if (codexContextTimer) clearInterval(codexContextTimer); + pollCodexContext(); + codexContextTimer = setInterval(pollCodexContext, 8_000); if (breakerBeatTimer) clearInterval(breakerBeatTimer); breakerBeatTimer = setInterval(() => { try { runBreakerBeat(300_000); } catch (e) { console.error('[breaker beat]', e); } }, 30_000); if (workerWakeTimer) clearInterval(workerWakeTimer); diff --git a/test/codex-context.test.cjs b/test/codex-context.test.cjs new file mode 100644 index 000000000..8fcf8aef8 --- /dev/null +++ b/test/codex-context.test.cjs @@ -0,0 +1,256 @@ +'use strict'; +/** + * Codex rollout-log context-gauge backfill (see src/main/codexContext.ts). + * + * Claude context updates can arrive through status hooks or the renderer's + * transcript backfill. Codex rollout logs are a third provider-specific input + * that can supply the same gauge without changing telemetry accounting. + * + * Line shapes below are trimmed/sanitized from a real `rollout-*.jsonl` + * produced by a live Codex worker (`token_count` event, `info.last_token_usage` + * / `info.model_context_window`), not invented. + */ + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const loadTs = require('./load-ts.cjs'); + +const { + clearCodexContextCache, + findLatestCodexRollout, + readLatestTokenCount, + readCodexContext, + readCodexRegistryContexts +} = + loadTs('src/main/codexContext.ts'); +const { HiveManager } = loadTs('src/main/hive.ts'); + +function tokenCountLine(inputTokens, contextWindow, ts) { + return JSON.stringify({ + timestamp: ts, + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { input_tokens: inputTokens, output_tokens: 10, total_tokens: inputTokens + 10 }, + last_token_usage: { input_tokens: inputTokens, output_tokens: 10, total_tokens: inputTokens + 10 }, + model_context_window: contextWindow + }, + rate_limits: { limit_id: 'codex' } + } + }); +} + +/** A realistic non-token_count line (a tool call), so tests exercise a mixed + * rollout rather than a synthetic file of nothing but token_count events. */ +function functionCallLine(name) { + return JSON.stringify({ + timestamp: '2026-09-15T09:16:30.919Z', + type: 'response_item', + payload: { type: 'function_call', name, arguments: '{}', call_id: 'call_x' } + }); +} + +const tempDirs = new Set(); + +function tempRepo() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-context-test-')); + tempDirs.add(dir); + return dir; +} + +test.afterEach(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + assert.equal(fs.existsSync(dir), false, `removed temporary directory ${dir}`); + } + tempDirs.clear(); + clearCodexContextCache?.(); +}); + +test('findLatestCodexRollout returns null when the agent has no Codex session data yet', () => { + const cwd = tempRepo(); + assert.equal(findLatestCodexRollout(cwd), null); +}); + +test('findLatestCodexRollout finds a rollout nested under sessions/YYYY/MM/DD', () => { + const codexHome = tempRepo(); + const dir = path.join(codexHome, 'sessions', '2026', '09', '15'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'rollout-2026-09-15T08-00-00-abc.jsonl'); + fs.writeFileSync(file, tokenCountLine(1000, 258400, '2026-09-15T08:00:00.000Z') + '\n', 'utf8'); + assert.equal(findLatestCodexRollout(codexHome), file); +}); + +test('findLatestCodexRollout picks the most recently modified file across multiple sessions', () => { + const codexHome = tempRepo(); + const day1 = path.join(codexHome, 'sessions', '2026', '09', '14'); + const day2 = path.join(codexHome, 'sessions', '2026', '09', '15'); + fs.mkdirSync(day1, { recursive: true }); + fs.mkdirSync(day2, { recursive: true }); + const older = path.join(day1, 'rollout-2026-09-14T08-00-00-old.jsonl'); + const newer = path.join(day2, 'rollout-2026-09-15T08-00-00-new.jsonl'); + fs.writeFileSync(older, tokenCountLine(500, 258400, '2026-09-14T08:00:00.000Z') + '\n', 'utf8'); + // Force a distinct, later mtime regardless of how fast the two writes ran. + const past = new Date(Date.now() - 60_000); + fs.utimesSync(older, past, past); + fs.writeFileSync(newer, tokenCountLine(9000, 258400, '2026-09-15T08:00:00.000Z') + '\n', 'utf8'); + assert.equal(findLatestCodexRollout(codexHome), newer); +}); + +test('readLatestTokenCount reads the real event shape (payload.info.last_token_usage / model_context_window)', () => { + const cwd = tempRepo(); + const file = path.join(cwd, 'rollout.jsonl'); + fs.writeFileSync(file, tokenCountLine(17724, 258400, '2026-09-15T08:09:58.124Z') + '\n', 'utf8'); + assert.deepEqual(readLatestTokenCount(file), { tokens: 17724, limit: 258400 }); +}); + +test('readLatestTokenCount returns the LAST token_count in a mixed, multi-turn file', () => { + const cwd = tempRepo(); + const file = path.join(cwd, 'rollout.jsonl'); + const lines = [ + tokenCountLine(17724, 258400, '2026-09-15T08:09:58.124Z'), + functionCallLine('exec'), + functionCallLine('apply_patch'), + tokenCountLine(45000, 258400, '2026-09-15T09:00:00.000Z'), + functionCallLine('wait'), + tokenCountLine(90603, 258400, '2026-09-15T14:09:33.383Z') + ]; + fs.writeFileSync(file, lines.join('\n') + '\n', 'utf8'); + assert.deepEqual(readLatestTokenCount(file), { tokens: 90603, limit: 258400 }); +}); + +test('readLatestTokenCount finds the last reading even when the tail window must be re-scanned', () => { + // Force a tail window that lands mid-file (smaller than the whole fixture, + // but comfortably larger than the final line) so the "drop the possibly + // partial first line of the window" behavior is actually exercised, not + // just theoretical. + const cwd = tempRepo(); + const file = path.join(cwd, 'rollout.jsonl'); + const padding = 'x'.repeat(2000); + const lines = [ + tokenCountLine(1000, 258400, '2026-09-15T08:00:00.000Z'), + functionCallLine(padding), + tokenCountLine(2000, 258400, '2026-09-15T08:05:00.000Z') + ]; + const content = lines.join('\n') + '\n'; + fs.writeFileSync(file, content, 'utf8'); + const lastLineBytes = Buffer.byteLength(lines[lines.length - 1], 'utf8'); + const tailBytes = lastLineBytes + 20; // fits the final line, cuts into the one before it + assert.ok(tailBytes < Buffer.byteLength(content, 'utf8'), 'the window must actually land mid-file'); + assert.deepEqual(readLatestTokenCount(file, tailBytes), { tokens: 2000, limit: 258400 }); +}); + +test('readLatestTokenCount keeps a complete first line at a tail-window boundary', () => { + const cwd = tempRepo(); + const file = path.join(cwd, 'rollout.jsonl'); + const latest = tokenCountLine(7777, 258400, '2026-09-15T08:05:00.000Z'); + const content = functionCallLine('x'.repeat(2000)) + '\n' + latest + '\n'; + fs.writeFileSync(file, content, 'utf8'); + const tailBytes = Buffer.byteLength(latest + '\n', 'utf8'); + assert.equal(content.length - tailBytes, Buffer.byteLength(functionCallLine('x'.repeat(2000)) + '\n')); + assert.deepEqual(readLatestTokenCount(file, tailBytes), { tokens: 7777, limit: 258400 }); +}); + +test('readLatestTokenCount ignores a trailing partial line from a session still being written', () => { + const cwd = tempRepo(); + const file = path.join(cwd, 'rollout.jsonl'); + const complete = tokenCountLine(5000, 258400, '2026-09-15T08:00:00.000Z'); + // No trailing newline, and the JSON itself is cut off mid-write. + const partial = tokenCountLine(9999, 258400, '2026-09-15T08:10:00.000Z').slice(0, 40); + fs.writeFileSync(file, complete + '\n' + partial, 'utf8'); + assert.deepEqual(readLatestTokenCount(file), { tokens: 5000, limit: 258400 }); +}); + +test('readLatestTokenCount returns null for a rollout with no token_count event yet', () => { + const cwd = tempRepo(); + const file = path.join(cwd, 'rollout.jsonl'); + fs.writeFileSync(file, functionCallLine('exec') + '\n', 'utf8'); + assert.equal(readLatestTokenCount(file), null); +}); + +test('readLatestTokenCount returns null for a missing file', () => { + assert.equal(readLatestTokenCount(path.join(tempRepo(), 'does-not-exist.jsonl')), null); +}); + +test('readLatestTokenCount rejects a non-positive or non-numeric context window', () => { + const cwd = tempRepo(); + const file = path.join(cwd, 'rollout.jsonl'); + fs.writeFileSync(file, tokenCountLine(1000, 0, '2026-09-15T08:00:00.000Z') + '\n', 'utf8'); + assert.equal(readLatestTokenCount(file), null); +}); + +test('readCodexContext locates and reads in one call', () => { + const codexHome = tempRepo(); + const dir = path.join(codexHome, 'sessions', '2026', '09', '15'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'rollout-2026-09-15T08-00-00-abc.jsonl'), + tokenCountLine(30100, 258400, '2026-09-15T08:00:00.000Z') + '\n', + 'utf8' + ); + assert.deepEqual(readCodexContext(codexHome), { tokens: 30100, limit: 258400 }); +}); + +test('readCodexContext caches discovery and refreshes the selected rollout when it changes', () => { + const codexHome = tempRepo(); + const dir = path.join(codexHome, 'sessions', '2026', '09', '15'); + fs.mkdirSync(dir, { recursive: true }); + const selected = path.join(dir, 'rollout-selected.jsonl'); + fs.writeFileSync(selected, tokenCountLine(1000, 258400, '2026-09-15T08:00:00.000Z') + '\n', 'utf8'); + + assert.deepEqual(readCodexContext(codexHome, 1000), { tokens: 1000, limit: 258400 }); + fs.appendFileSync(selected, tokenCountLine(2000, 258400, '2026-09-15T08:01:00.000Z') + '\n', 'utf8'); + assert.deepEqual(readCodexContext(codexHome, 2000), { tokens: 2000, limit: 258400 }); + + const newer = path.join(dir, 'rollout-newer.jsonl'); + fs.writeFileSync(newer, tokenCountLine(3000, 258400, '2026-09-15T08:02:00.000Z') + '\n', 'utf8'); + const future = new Date(Date.now() + 60_000); + fs.utimesSync(newer, future, future); + assert.deepEqual(readCodexContext(codexHome, 3000), { tokens: 2000, limit: 258400 }); + assert.deepEqual(readCodexContext(codexHome, 62_000), { tokens: 3000, limit: 258400 }); +}); + +test('registry polling reads the HiveManager CODEX_HOME instead of the project cwd', () => { + const harnessHome = tempRepo(); + const hiveRoot = path.join(harnessHome, 'hive'); + const projectCwd = path.join(harnessHome, 'project-worktree'); + fs.mkdirSync(projectCwd, { recursive: true }); + fs.mkdirSync(hiveRoot, { recursive: true }); + fs.writeFileSync(path.join(hiveRoot, 'registry.json'), JSON.stringify({ + godId: null, + agents: { + 'codex-worker': { + id: 'codex-worker', + name: 'Codex Worker', + provider: 'codex', + cwd: projectCwd, + status: 'working', + lastSeen: Date.now() + } + } + }), 'utf8'); + + const hive = new HiveManager(() => harnessHome); + const codexHome = hive.codexHome('codex-worker'); + assert.notEqual(codexHome, null); + assert.notEqual(codexHome, projectCwd); + const sessionDir = path.join(codexHome, 'sessions', '2026', '09', '15'); + fs.mkdirSync(sessionDir, { recursive: true }); + fs.writeFileSync( + path.join(sessionDir, 'rollout-real-home.jsonl'), + tokenCountLine(4242, 258400, '2026-09-15T08:00:00.000Z') + '\n', + 'utf8' + ); + + assert.deepEqual(readCodexRegistryContexts(hive), [ + { agentId: 'codex-worker', reading: { tokens: 4242, limit: 258400 } } + ]); +}); + +test('readCodexContext returns null for an agent that was never a Codex worker', () => { + assert.equal(readCodexContext(tempRepo()), null); +});