Skip to content
Merged
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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
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 @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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 = "";
Expand All @@ -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 = "";
Expand Down
37 changes: 37 additions & 0 deletions test/rollout-store.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down
Loading