Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ if (options.command === "snapshot") {
cdpPort: options.cdpPort || 9334,
signal: controller.signal,
});
if (controller.signal.aborted) process.exitCode = 130;
} else if (options.command === "remove") {
const modulePath = new URL("./codex/injector.mjs", import.meta.url);
const { removeCodexMeter } = await import(modulePath);
Expand Down
6 changes: 5 additions & 1 deletion src/codex/cdp-client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ function withTimeout(promise, timeoutMs, message) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
timer.unref?.();
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
Expand Down Expand Up @@ -55,6 +54,11 @@ export class CdpClient {
}

async call(method, params = {}, { timeoutMs = 5_000 } = {}) {
if (this.socket.readyState !== 1) {
throw new Error(
`CDP socket is not open (readyState ${this.socket.readyState})`,
);
}
const id = this.nextId++;
const response = new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
Expand Down
117 changes: 75 additions & 42 deletions src/codex/injector.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -323,56 +323,89 @@ export async function runCodexInjector({

try {
await verifyMacListenerOwner(cdpPort, { appPath });
let cdpVerified = true;
let lastCdpErrorLoggedMs = 0;
while (!stopping) {
const nowMs = Date.now();
if (nowMs - lastDiscoveryMs >= targetDiscoveryIntervalMs) {
const targets = await listTargets(cdpPort);
for (const target of targets) {
if (attached.has(target.id)) continue;
const connection = await attachCodexTarget(target, payload).catch(() => null);
if (connection) attached.set(target.id, connection);
try {
if (!cdpVerified) {
await verifyMacListenerOwner(cdpPort, { appPath });
cdpVerified = true;
console.error(
`[token-meter] CDP listener on port ${cdpPort} is available again; resuming.`,
);
}
lastDiscoveryMs = nowMs;
}

const probes = [];
for (const connection of attached.values()) {
try {
connection.probe = await connection.client.evaluate(
buildSessionProbeExpression(),
const nowMs = Date.now();
if (nowMs - lastDiscoveryMs >= targetDiscoveryIntervalMs) {
const targets = await listTargets(cdpPort);
for (const target of targets) {
if (attached.has(target.id)) continue;
const connection = await attachCodexTarget(target, payload).catch(() => null);
if (connection) attached.set(target.id, connection);
}
lastDiscoveryMs = nowMs;
}
} catch (error) {
cdpVerified = false;
for (const connection of attached.values()) connection.failed = true;
const nowMs = Date.now();
if (nowMs - lastCdpErrorLoggedMs >= 60_000) {
lastCdpErrorLoggedMs = nowMs;
console.error(
`[token-meter] CDP temporarily unavailable (${error?.message ?? error}); retrying.`,
);
probes.push(connection.probe);
} catch {
connection.failed = true;
}
}
for (const [id, connection] of attached) {
if (connection.failed || !connection.probe?.eligible) {
connection.client.close();
attached.delete(id);

if (cdpVerified && !stopping) {
const probes = [];
for (const connection of attached.values()) {
try {
connection.probe = await connection.client.evaluate(
buildSessionProbeExpression(),
);
probes.push(connection.probe);
} catch {
connection.failed = true;
}
}
for (const [id, connection] of attached) {
if (connection.failed || !connection.probe?.eligible) {
connection.client.close();
attached.delete(id);
}
}
}

const activeThreadIds = probes.map((probe) => probe.threadId).filter(Boolean);
store.historyFileLimit = warmedHistoryFileLimit;
const files = await store.refresh({ activeThreadIds });
for (const connection of attached.values()) {
const snapshot = engine.snapshot(files, {
threadId: connection.probe.threadId,
nowMs,
});
snapshot.binding = {
source: connection.probe.bindingSource,
exact: Boolean(connection.probe.threadId),
};
await connection.client.evaluate(updateExpression(snapshot)).catch(() => {
connection.failed = true;
});
const activeThreadIds = probes.map((probe) => probe.threadId).filter(Boolean);
store.historyFileLimit = warmedHistoryFileLimit;
try {
const files = await store.refresh({ activeThreadIds });
for (const connection of attached.values()) {
const snapshot = engine.snapshot(files, {
threadId: connection.probe.threadId,
nowMs: Date.now(),
});
snapshot.binding = {
source: connection.probe.bindingSource,
exact: Boolean(connection.probe.threadId),
};
await connection.client.evaluate(updateExpression(snapshot)).catch(() => {
connection.failed = true;
});
}
} catch (error) {
const nowMs = Date.now();
if (nowMs - lastCdpErrorLoggedMs >= 60_000) {
lastCdpErrorLoggedMs = nowMs;
console.error(
`[token-meter] poll error (${error?.message ?? error}); continuing.`,
);
}
}
warmedHistoryFileLimit = Math.min(
historyFileLimit,
warmedHistoryFileLimit + historyFilesPerPoll,
);
}
warmedHistoryFileLimit = Math.min(
historyFileLimit,
warmedHistoryFileLimit + historyFilesPerPoll,
);

await waitForNextPoll(pollIntervalMs, signal);
}
Expand Down
16 changes: 16 additions & 0 deletions src/codex/session-probe.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,26 @@ export function buildSessionProbeExpression() {
const routeId = normalizeThreadId(
routeMatch == null ? null : decodeURIComponent(routeMatch[1])
);
const optimisticMatch = String(
activeRow?.getAttribute('data-app-action-sidebar-thread-id') ?? ''
)
.replace(/^local:/, '')
.match(/^client-new-thread:([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})$/i);
const contentConversationId = normalizeThreadId(
document.querySelector('[data-response-annotation-conversation]')
?.getAttribute('data-response-annotation-conversation') ??
document.querySelector('[data-above-composer-conversation-id]')
?.getAttribute('data-above-composer-conversation-id') ??
null
);
let threadId = null;
let bindingSource = null;
if (activeId != null) {
threadId = activeId;
bindingSource = 'active-sidebar-row';
} else if (optimisticMatch != null && contentConversationId != null) {
threadId = contentConversationId;
bindingSource = 'active-composer-conversation';
} else if (routeId != null) {
threadId = routeId;
bindingSource = 'thread-route';
Expand All @@ -54,6 +69,7 @@ export function buildSessionProbeExpression() {
'textarea, [contenteditable="true"], [data-app-action-composer]'
)),
activeThread: Boolean(activeRow),
conversationId: Boolean(contentConversationId),
};
const markerCount = Object.values(markers).filter(Boolean).length;
const eligible =
Expand Down
26 changes: 22 additions & 4 deletions src/core/rollout-store.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,13 @@ export function parseRolloutLine(line) {
}

async function defaultReadRange(filePath, { length, position }) {
const handle = await open(filePath, "r");
let handle;
try {
handle = await open(filePath, "r");
} catch (error) {
if (error?.code === "ENOENT") return Buffer.alloc(0);
throw error;
}
try {
const buffer = Buffer.allocUnsafe(length);
const { bytesRead } = await handle.read(buffer, 0, length, position);
Expand All @@ -86,6 +92,15 @@ async function defaultReadRange(filePath, { length, position }) {
}
}

async function statIfExists(targetPath) {
try {
return await stat(targetPath);
} catch (error) {
if (error?.code === "ENOENT") return null;
throw error;
}
}

function createFileState(filePath, discoveredId, modifiedMs) {
return {
path: filePath,
Expand Down Expand Up @@ -121,7 +136,8 @@ async function walk(directory, result) {
}
const match = entry.name.match(ROLLOUT_FILE);
if (!entry.isFile() || match == null) return;
const fileStat = await stat(fullPath);
const fileStat = await statIfExists(fullPath);
if (fileStat == null) return;
result.push({
path: fullPath,
discoveredId: match[1],
Expand Down Expand Up @@ -264,7 +280,8 @@ export class RolloutStore {

async #readMetadata(file) {
if (file.meta != null) return;
const fileStat = await stat(file.path);
const fileStat = await statIfExists(file.path);
if (fileStat == null) return;
const decoder = new StringDecoder("utf8");
let position = 0;
let source = "";
Expand All @@ -284,7 +301,8 @@ export class RolloutStore {
}

async #readAppended(file) {
const fileStat = await stat(file.path);
const fileStat = await statIfExists(file.path);
if (fileStat == null) return;
if (fileStat.size < file.offset) {
file.offset = 0;
file.remainder = "";
Expand Down
65 changes: 64 additions & 1 deletion test/cdp-client.test.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,73 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isLoopbackWebSocketUrl } from "../src/codex/cdp-client.mjs";
import {
CdpClient,
isLoopbackWebSocketUrl,
} from "../src/codex/cdp-client.mjs";

class FakeSocket extends EventTarget {
constructor() {
super();
this.readyState = 1; // WebSocket.OPEN
this.sent = [];
}

send(data) {
this.sent.push(String(data));
}

close() {
this.readyState = 3; // WebSocket.CLOSED
this.dispatchEvent(new Event("close"));
}

receive(raw) {
this.dispatchEvent(new MessageEvent("message", { data: raw }));
}
}

test("CDP client accepts loopback targets only", () => {
assert.equal(isLoopbackWebSocketUrl("ws://127.0.0.1:9334/devtools/page/1"), true);
assert.equal(isLoopbackWebSocketUrl("ws://localhost:9334/devtools/page/1"), true);
assert.equal(isLoopbackWebSocketUrl("ws://192.168.1.4:9334/devtools/page/1"), false);
assert.equal(isLoopbackWebSocketUrl("wss://example.com/devtools/page/1"), false);
});

test("CDP client rejects a pending call when the socket closes", async () => {
const socket = new FakeSocket();
const client = new CdpClient(socket);
const call = client.call("Runtime.evaluate", { expression: "1" });
socket.close();
await assert.rejects(call, /CDP target closed/);
});

test("CDP client refuses calls on a closed socket", async () => {
const socket = new FakeSocket();
const client = new CdpClient(socket);
socket.close();
await assert.rejects(
client.call("Runtime.evaluate", { expression: "1" }),
/not open/,
);
});

test("CDP client resolves calls from matching responses", async () => {
const socket = new FakeSocket();
const client = new CdpClient(socket);
const call = client.call("Runtime.evaluate", { expression: "1" });
const sent = JSON.parse(socket.sent[0]);
assert.equal(sent.method, "Runtime.evaluate");
socket.receive(
JSON.stringify({ id: sent.id, result: { result: { value: 42 } } }),
);
assert.equal((await call).result.value, 42);
});

test("CDP client times out hung calls instead of hanging forever", async () => {
const socket = new FakeSocket();
const client = new CdpClient(socket);
await assert.rejects(
client.call("Runtime.evaluate", { expression: "1" }, { timeoutMs: 50 }),
/timed out/,
);
});
54 changes: 54 additions & 0 deletions test/rollout-store.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,57 @@ test("a filesystem notification makes a new child Agent discoverable immediately
[rootId, childId],
);
});

test("refresh survives a rollout file removed between discovery and read", async (context) => {
const directory = await mkdtemp(path.join(os.tmpdir(), "token-meter-rollout-"));
context.after(() => rm(directory, { recursive: true, force: true }));
const threadId = "019fc0bf-d10c-7472-bb0e-fd6f0df8ab3e";
const filePath = path.join(
directory,
`rollout-2026-08-01T21-33-44-${threadId}.jsonl`,
);
await writeFile(
filePath,
`${JSON.stringify({
timestamp: "2026-08-01T21:33:44.000Z",
type: "session_meta",
payload: {
id: threadId,
session_id: threadId,
source: "vscode",
thread_source: "user",
},
})}\n`,
);

const store = new RolloutStore({
sessionsDirectory: directory,
discoveryIntervalMs: 60_000,
});

// Discover first, then remove the file before any metadata/appended read.
await store.discover({ force: true });
await rm(filePath);
const files = await store.refresh({ activeThreadIds: [threadId] });
assert.deepEqual(files, []);

// Also exercise the appended-read path: cached state, then file disappears.
await writeFile(
filePath,
`${JSON.stringify({
timestamp: "2026-08-01T21:33:44.000Z",
type: "session_meta",
payload: {
id: threadId,
session_id: threadId,
source: "vscode",
thread_source: "user",
},
})}\n`,
);
await store.markDiscoveryDirty();
await store.refresh({ activeThreadIds: [threadId] });
await rm(filePath);
const cached = await store.refresh({ activeThreadIds: [threadId] });
assert.equal(cached.length, 1);
});
Loading