diff --git a/CHANGELOG.md b/CHANGELOG.md index c86a1b3..8613bba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ The top entry is the current source version. Binary release metadata appears at `/api/v1/latest` only after a signed and notarized DMG has actually been published. -## Unreleased +## 0.4.0 — 2026-08-17 - The Token Widget overlay now meters Codex natively. It reads the active thread from Codex's local state database and its rollout files, so a frontmost Codex @@ -13,6 +13,12 @@ published. Claude installed, and the app is now named "Token Widget". - Deprecated the Codex CDP adapter (`scripts/install-token-meter-macos.sh`). It still works but will be removed in a future release; prefer the app. +- Hardened live rollout scanning when Codex prunes a JSONL file between + discovery and reading, so the native overlay keeps running instead of + crashing on a transient missing-file race. +- Standardized the packaged runtime and CI on Node.js 22.22, raised the source + minimum to Node.js 22.13, and added direct `node:sqlite` capability checks and + minimum-runtime regressions. ## 0.3.1 — 2026-08-15 diff --git a/package-lock.json b/package-lock.json index f8e7777..2cb619b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "token-meter", - "version": "0.3.1", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "token-meter", - "version": "0.3.1", + "version": "0.4.0", "license": "MIT", "os": [ "darwin" diff --git a/package.json b/package.json index 8858923..ec82058 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "token-meter", - "version": "0.3.1", + "version": "0.4.0", "description": "An open-source, session-aware live token meter for Codex Desktop and Claude Code in Claude Desktop.", "type": "module", "private": true, diff --git a/src/core/rollout-store.mjs b/src/core/rollout-store.mjs index f4022d2..fc50b8b 100644 --- a/src/core/rollout-store.mjs +++ b/src/core/rollout-store.mjs @@ -101,7 +101,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); @@ -111,6 +117,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, @@ -149,7 +164,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], @@ -292,7 +308,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 = ""; @@ -312,7 +329,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 = ""; diff --git a/test/rollout-store.test.mjs b/test/rollout-store.test.mjs index 42ebcba..29fa055 100644 --- a/test/rollout-store.test.mjs +++ b/test/rollout-store.test.mjs @@ -290,6 +290,43 @@ test("a deleted rollout is removed from the live index without stopping refresh" assert.deepEqual(await store.refresh({ activeThreadIds: [id] }), []); }); +test("refresh survives a rollout removed between discovery and read", async (context) => { + const directory = await mkdtemp(path.join(os.tmpdir(), "token-meter-rollout-race-")); + 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`, + ); + const metadata = `${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 writeFile(filePath, metadata); + + const store = new RolloutStore({ + sessionsDirectory: directory, + discoveryIntervalMs: 60_000, + }); + + await store.discover({ force: true }); + await rm(filePath); + assert.deepEqual(await store.refresh({ activeThreadIds: [threadId] }), []); + + await writeFile(filePath, metadata); + store.markDiscoveryDirty(); + await store.refresh({ activeThreadIds: [threadId] }); + await rm(filePath); + const cached = await store.refresh({ activeThreadIds: [threadId] }); + assert.equal(cached.length, 1); +}); + test("a filesystem notification makes a new child Agent discoverable immediately", async (context) => { const directory = await mkdtemp(path.join(os.tmpdir(), "token-meter-dirty-index-")); context.after(() => rm(directory, { recursive: true, force: true }));