From 8bd7b737b6567c154f04a52238668a8e86cd5955 Mon Sep 17 00:00:00 2001
From: Anuj7411
Date: Thu, 15 Oct 2026 19:16:48 +0530
Subject: [PATCH 1/9] feat(fs): read zstd-compressed session logs through one
helper
Codex now compresses older rollouts to rollout-*.jsonl.zst. Add
src/lib/zstd.ts (node:zlib only): it walks the frames first, because
Node decodes only the first frame of a multi-frame file and returns
partial text without an error for a truncated one. A file is decoded
only when every frame is complete, and each frame is checked against
its declared content size. Line 1 can be read by streaming up to the
first newline.
The FileSystem seam gains readBytes (InMemoryFs.writeBytes stores
compressed fixtures), and readLogText / readCompressedLogFirstLine are
the one way to read a session log, plain or compressed. On a Node.js
without zstd they throw ZstdUnavailableError naming the version needed.
Co-Authored-By: Claude Opus 5.5
---
src/lib/fs.ts | 61 +++++++++++-
src/lib/zstd.ts | 207 +++++++++++++++++++++++++++++++++++++++++
tests/lib/fs.test.ts | 73 ++++++++++++++-
tests/lib/zstd.test.ts | 98 +++++++++++++++++++
4 files changed, 432 insertions(+), 7 deletions(-)
create mode 100644 src/lib/zstd.ts
create mode 100644 tests/lib/zstd.test.ts
diff --git a/src/lib/fs.ts b/src/lib/fs.ts
index f39efb9..7df722a 100644
--- a/src/lib/fs.ts
+++ b/src/lib/fs.ts
@@ -9,6 +9,7 @@ import * as nodeFs from "node:fs";
import { promises as fs } from "node:fs";
import path from "node:path";
import { StringDecoder } from "node:string_decoder";
+import { decompressZstd, zstdCodec, zstdFirstLine, ZstdUnavailableError } from "./zstd.js";
export interface DirEntry {
readonly name: string;
@@ -29,6 +30,8 @@ export interface FileSystem {
* character cut at the limit is dropped, never decoded as U+FFFD.
*/
readHead(p: string, maxBytes: number): Promise;
+ /** The file's raw bytes (compressed logs are decoded by readLogText, not here). */
+ readBytes(p: string): Promise;
readDir(p: string): Promise;
stat(p: string): Promise;
}
@@ -54,6 +57,9 @@ export class RealFileSystem implements FileSystem {
// was read (a 467 MB log needed 1.6 GB of heap instead of 0.9 GB).
return (await fs.readFile(p)).toString("utf8");
}
+ async readBytes(p: string): Promise {
+ return fs.readFile(p);
+ }
async readHead(p: string, maxBytes: number): Promise {
const handle = await fs.open(p, "r");
try {
@@ -85,7 +91,8 @@ export class RealFileSystem implements FileSystem {
interface InMemoryFile {
readonly kind: "file";
- readonly content: string;
+ /** Text, or raw bytes (writeBytes: a compressed log). */
+ readonly content: string | Buffer;
readonly mtimeMs: number;
}
interface InMemoryDir {
@@ -114,6 +121,12 @@ export class InMemoryFs implements FileSystem {
this.nodes.set(norm, { kind: "file", content, mtimeMs });
}
+ /** A file of raw bytes, e.g. a `.jsonl.zst` log made with zlib.zstdCompressSync. */
+ writeBytes(p: string, content: Buffer, mtimeMs = 0): void {
+ this.writeFile(p, "", mtimeMs);
+ this.nodes.set(this.norm(p), { kind: "file", content: Buffer.from(content), mtimeMs });
+ }
+
mkdir(p: string): void {
const norm = this.norm(p);
const parts = norm.split("/");
@@ -128,16 +141,24 @@ export class InMemoryFs implements FileSystem {
async exists(p: string): Promise {
return this.nodes.has(this.norm(p));
}
- async readFile(p: string): Promise {
+ private file(p: string): InMemoryFile {
const n = this.nodes.get(this.norm(p));
if (!n || n.kind !== "file") {
throw Object.assign(new Error(`ENOENT: ${p}`), { code: "ENOENT" });
}
- return n.content;
+ return n;
+ }
+ async readFile(p: string): Promise {
+ const c = this.file(p).content;
+ return typeof c === "string" ? c : c.toString("utf8");
+ }
+ async readBytes(p: string): Promise {
+ const c = this.file(p).content;
+ return typeof c === "string" ? Buffer.from(c, "utf-8") : Buffer.from(c);
}
async readHead(p: string, maxBytes: number): Promise {
- const content = await this.readFile(p);
- return decodeUtf8Prefix(Buffer.from(content, "utf-8").subarray(0, Math.max(0, maxBytes)));
+ const content = await this.readBytes(p);
+ return decodeUtf8Prefix(content.subarray(0, Math.max(0, maxBytes)));
}
async readDir(p: string): Promise {
const norm = this.norm(p);
@@ -183,8 +204,38 @@ export class InMemoryFs implements FileSystem {
throw Object.assign(new Error(`ENOENT: ${p}`), { code: "ENOENT" });
}
if (n.kind === "file") {
+ // Text: its length in characters (what tests have always compared); bytes: their count.
return { mtimeMs: n.mtimeMs, size: n.content.length };
}
return { mtimeMs: 0, size: 0 };
}
}
+
+/**
+ * Session logs: the one way to read one, plain or compressed.
+ *
+ * Codex compresses older rollouts to `rollout-*.jsonl.zst`. Every reader of
+ * a session log goes through readLogText (and Codex discovery through
+ * readCompressedLogFirstLine), so a compressed log reads exactly like the
+ * plain log it was made from. A `.zst` log throws ZstdUnavailableError on a
+ * Node.js without zstd (discovery skips those logs with a note first) and
+ * CorruptZstdError when it is truncated or damaged; callers count both as
+ * unreadable, like any other read error.
+ */
+export function isCompressedLog(p: string): boolean {
+ return p.endsWith(".zst");
+}
+
+export async function readLogText(fs: FileSystem, p: string): Promise {
+ if (!isCompressedLog(p)) return fs.readFile(p);
+ const codec = zstdCodec();
+ if (!codec) throw new ZstdUnavailableError();
+ return decompressZstd(await fs.readBytes(p), codec).toString("utf8");
+}
+
+/** Line 1 of a compressed log (without its newline), decoding only up to it. */
+export async function readCompressedLogFirstLine(fs: FileSystem, p: string): Promise {
+ const codec = zstdCodec();
+ if (!codec) throw new ZstdUnavailableError();
+ return zstdFirstLine(await fs.readBytes(p), codec);
+}
diff --git a/src/lib/zstd.ts b/src/lib/zstd.ts
new file mode 100644
index 0000000..21afcda
--- /dev/null
+++ b/src/lib/zstd.ts
@@ -0,0 +1,207 @@
+/**
+ * Zstandard (`.zst`) decoding for the session logs Codex compresses
+ * (`rollout-*.jsonl.zst`). node:zlib only: no dependency, no network.
+ *
+ * Node.js ships zstd in node:zlib from 22.15 (and 23.8). On an older Node the
+ * codec is null and callers report the compressed logs as skipped, with the
+ * Node version they need (never silently).
+ *
+ * Node's decoder has two traps this module closes:
+ * - it decodes only the FIRST frame of a multi-frame input;
+ * - it returns partial output, with no error, for a truncated frame.
+ * So the frames are walked first (frame headers and block headers only, no
+ * decoding): a file is decoded only when every frame is complete, frame by
+ * frame, and each frame's output is checked against the content size its
+ * header declares. Anything else is CorruptZstdError (an unreadable log).
+ */
+import zlib from "node:zlib";
+import type { Transform } from "node:stream";
+
+export interface ZstdCodec {
+ /** Decode one complete zstd frame. */
+ decompressFrame(frame: Uint8Array): Buffer;
+ /** A streaming decoder for one frame (used to read only the first line). */
+ createDecompressStream(): Transform;
+}
+
+/** The Node.js release line that first ships zstd in node:zlib (said in notes). */
+export const ZSTD_MIN_NODE = "22.15";
+
+export class ZstdUnavailableError extends Error {
+ constructor() {
+ super(`reading compressed logs (.zst) needs Node.js ${ZSTD_MIN_NODE} or newer (you have ${process.version}).`);
+ this.name = "ZstdUnavailableError";
+ }
+}
+
+export class CorruptZstdError extends Error {
+ constructor(why: string) {
+ super(`not a complete zstd file: ${why}`);
+ this.name = "CorruptZstdError";
+ }
+}
+
+interface NodeZstd {
+ zstdDecompressSync?: (buf: Uint8Array) => Buffer;
+ createZstdDecompress?: () => Transform;
+}
+
+function nodeCodec(): ZstdCodec | null {
+ const z = zlib as unknown as NodeZstd;
+ const sync = z.zstdDecompressSync;
+ const stream = z.createZstdDecompress;
+ if (typeof sync !== "function" || typeof stream !== "function") return null;
+ return {
+ decompressFrame: (frame) => sync(frame),
+ createDecompressStream: () => stream(),
+ };
+}
+
+let detected: ZstdCodec | null | undefined;
+let override: ZstdCodec | null | undefined;
+
+/** This Node's zstd decoder, or null when it has none. */
+export function zstdCodec(): ZstdCodec | null {
+ if (override !== undefined) return override;
+ if (detected === undefined) detected = nodeCodec();
+ return detected;
+}
+
+/**
+ * Tests: pretend this Node has no zstd (null), or use another codec.
+ * undefined restores detection.
+ */
+export function setZstdCodecForTests(codec: ZstdCodec | null | undefined): void {
+ override = codec;
+}
+
+const ZSTD_MAGIC = 0xfd2fb528;
+const SKIPPABLE_MASK = 0xfffffff0;
+const SKIPPABLE_MAGIC = 0x184d2a50;
+
+interface Frame {
+ readonly start: number;
+ readonly end: number;
+ /** Decoded size the header declares, when it declares one. */
+ readonly contentSize: number | undefined;
+}
+
+/**
+ * The data frames of a zstd file, or CorruptZstdError when the bytes are not
+ * a sequence of complete frames (truncated, foreign, or empty). Skippable
+ * frames are allowed and ignored. Reads headers only (RFC 8878 section 3.1).
+ */
+export function zstdFrames(buf: Uint8Array): Frame[] {
+ const b = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
+ const frames: Frame[] = [];
+ let p = 0;
+ if (b.length === 0) throw new CorruptZstdError("empty file");
+ while (p < b.length) {
+ if (p + 4 > b.length) throw new CorruptZstdError("truncated frame header");
+ const magic = b.readUInt32LE(p);
+ if ((magic & SKIPPABLE_MASK) >>> 0 === SKIPPABLE_MAGIC) {
+ if (p + 8 > b.length) throw new CorruptZstdError("truncated skippable frame");
+ p += 8 + b.readUInt32LE(p + 4);
+ if (p > b.length) throw new CorruptZstdError("truncated skippable frame");
+ continue;
+ }
+ if (magic !== ZSTD_MAGIC) throw new CorruptZstdError("unknown frame");
+ const start = p;
+ p += 4;
+ if (p >= b.length) throw new CorruptZstdError("truncated frame header");
+ const fhd = b[p++]!;
+ const fcsFlag = fhd >> 6;
+ const singleSegment = (fhd >> 5) & 1;
+ const hasChecksum = (fhd >> 2) & 1;
+ const dictIdSize = [0, 1, 2, 4][fhd & 3]!;
+ const fcsSize = fcsFlag === 0 ? singleSegment : [1, 2, 4, 8][fcsFlag]!;
+ p += (singleSegment ? 0 : 1) + dictIdSize;
+ if (p + fcsSize > b.length) throw new CorruptZstdError("truncated frame header");
+ let contentSize: number | undefined;
+ if (fcsSize === 1) contentSize = b[p]!;
+ else if (fcsSize === 2) contentSize = b.readUInt16LE(p) + 256;
+ else if (fcsSize === 4) contentSize = b.readUInt32LE(p);
+ else if (fcsSize === 8) contentSize = Number(b.readBigUInt64LE(p));
+ p += fcsSize;
+ for (;;) {
+ if (p + 3 > b.length) throw new CorruptZstdError("truncated block");
+ const h = b[p]! | (b[p + 1]! << 8) | (b[p + 2]! << 16);
+ p += 3;
+ const type = (h >> 1) & 3;
+ if (type === 3) throw new CorruptZstdError("reserved block type");
+ p += type === 1 ? 1 : h >>> 3; // an RLE block stores one byte
+ if (p > b.length) throw new CorruptZstdError("truncated block");
+ if (h & 1) break; // last block
+ }
+ if (hasChecksum) p += 4;
+ if (p > b.length) throw new CorruptZstdError("truncated checksum");
+ frames.push({ start, end: p, contentSize });
+ }
+ if (frames.length === 0) throw new CorruptZstdError("no data frame");
+ return frames;
+}
+
+/** Decode a whole zstd file, every frame; CorruptZstdError unless it is complete. */
+export function decompressZstd(buf: Uint8Array, codec: ZstdCodec): Buffer {
+ const parts: Buffer[] = [];
+ for (const f of zstdFrames(buf)) {
+ let out: Buffer;
+ try {
+ out = codec.decompressFrame(buf.subarray(f.start, f.end));
+ } catch (e) {
+ throw new CorruptZstdError(e instanceof Error ? e.message : String(e));
+ }
+ if (f.contentSize !== undefined && out.length !== f.contentSize) {
+ throw new CorruptZstdError("decoded size does not match the frame header");
+ }
+ parts.push(out);
+ }
+ return parts.length === 1 ? parts[0]! : Buffer.concat(parts);
+}
+
+/** Decode one frame until its first newline; `newline` false when the frame has none. */
+function frameHead(codec: ZstdCodec, frame: Uint8Array): Promise<{ bytes: Buffer; newline: boolean }> {
+ return new Promise((resolve, reject) => {
+ const d = codec.createDecompressStream();
+ const chunks: Buffer[] = [];
+ let settled = false;
+ const done = (r: { bytes: Buffer; newline: boolean }): void => {
+ if (settled) return;
+ settled = true;
+ resolve(r);
+ };
+ d.on("data", (chunk: Buffer) => {
+ if (settled) return;
+ const nl = chunk.indexOf(0x0a);
+ if (nl < 0) {
+ chunks.push(chunk);
+ return;
+ }
+ chunks.push(chunk.subarray(0, nl));
+ done({ bytes: Buffer.concat(chunks), newline: true });
+ d.destroy(); // the rest of the frame is never decoded
+ });
+ d.on("end", () => done({ bytes: Buffer.concat(chunks), newline: false }));
+ d.on("error", (e) => {
+ if (settled) return;
+ settled = true;
+ reject(new CorruptZstdError(e.message));
+ });
+ d.end(frame);
+ });
+}
+
+/**
+ * Line 1 of a zstd file (without its newline), decoding only up to it. The
+ * file must still be complete (zstdFrames), so a truncated log is unreadable
+ * here exactly as it is for a full read.
+ */
+export async function zstdFirstLine(buf: Uint8Array, codec: ZstdCodec): Promise {
+ const parts: Buffer[] = [];
+ for (const f of zstdFrames(buf)) {
+ const h = await frameHead(codec, buf.subarray(f.start, f.end));
+ parts.push(h.bytes);
+ if (h.newline) break;
+ }
+ return Buffer.concat(parts).toString("utf8");
+}
diff --git a/tests/lib/fs.test.ts b/tests/lib/fs.test.ts
index ca1ad9d..66deb32 100644
--- a/tests/lib/fs.test.ts
+++ b/tests/lib/fs.test.ts
@@ -1,8 +1,17 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
-import { describe, expect, it } from "vitest";
-import { InMemoryFs, RealFileSystem, type FileSystem } from "../../src/lib/fs.js";
+import zlib from "node:zlib";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ InMemoryFs,
+ isCompressedLog,
+ readCompressedLogFirstLine,
+ readLogText,
+ RealFileSystem,
+ type FileSystem,
+} from "../../src/lib/fs.js";
+import { CorruptZstdError, setZstdCodecForTests, ZstdUnavailableError } from "../../src/lib/zstd.js";
// "é" is 2 bytes and "€" is 3 bytes in UTF-8: "aé€b" = 1 + 2 + 3 + 1 = 7 bytes.
const MIXED = "aé€b";
@@ -95,3 +104,63 @@ describe("InMemoryFs", () => {
expect(s.mtimeMs).toBe(42);
});
});
+
+describe("compressed session logs (readLogText)", () => {
+ const zstdCompress = (s: string): Buffer =>
+ (zlib as unknown as { zstdCompressSync: (b: Buffer) => Buffer }).zstdCompressSync(Buffer.from(s, "utf8"));
+ const TEXT = `{"type":"session_meta","payload":{"id":"a"}}\n{"n":"${MIXED}"}\n`;
+
+ afterEach(() => setZstdCodecForTests(undefined));
+
+ it("InMemoryFs stores raw bytes: readBytes returns them, stat counts them", async () => {
+ const fs = new InMemoryFs();
+ const bytes = zstdCompress(TEXT);
+ fs.writeBytes("/c/rollout-a.jsonl.zst", bytes, 7);
+ expect((await fs.readBytes("/c/rollout-a.jsonl.zst")).equals(bytes)).toBe(true);
+ expect(await fs.stat("/c/rollout-a.jsonl.zst")).toEqual({ mtimeMs: 7, size: bytes.length });
+ expect((await fs.readDir("/c")).map((e) => e.name)).toEqual(["rollout-a.jsonl.zst"]);
+ fs.writeFile("/c/plain.jsonl", TEXT);
+ expect((await fs.readBytes("/c/plain.jsonl")).toString("utf8")).toBe(TEXT);
+ });
+
+ it("reads a plain log as text and a .zst log decompressed, on both file systems", async () => {
+ const mem = new InMemoryFs();
+ mem.writeFile("/c/a.jsonl", TEXT);
+ mem.writeBytes("/c/a.jsonl.zst", zstdCompress(TEXT));
+ expect(isCompressedLog("/c/a.jsonl")).toBe(false);
+ expect(isCompressedLog("/c/a.jsonl.zst")).toBe(true);
+ expect(await readLogText(mem, "/c/a.jsonl")).toBe(TEXT);
+ expect(await readLogText(mem, "/c/a.jsonl.zst")).toBe(TEXT);
+ expect(await readCompressedLogFirstLine(mem, "/c/a.jsonl.zst")).toBe(TEXT.split("\n")[0]);
+
+ const dir = mkdtempSync(path.join(tmpdir(), "sipcode-zst-"));
+ try {
+ const p = path.join(dir, "rollout-a.jsonl.zst");
+ writeFileSync(p, zstdCompress(TEXT));
+ const real = new RealFileSystem();
+ expect(await readLogText(real, p)).toBe(TEXT);
+ expect(await readCompressedLogFirstLine(real, p)).toBe(TEXT.split("\n")[0]);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("a damaged .zst log throws (callers count it as unreadable)", async () => {
+ const mem = new InMemoryFs();
+ const full = zstdCompress(TEXT.repeat(50));
+ mem.writeBytes("/c/cut.jsonl.zst", full.subarray(0, full.length - 3));
+ mem.writeBytes("/c/junk.jsonl.zst", Buffer.from("not zstd"));
+ await expect(readLogText(mem, "/c/cut.jsonl.zst")).rejects.toThrow(CorruptZstdError);
+ await expect(readLogText(mem, "/c/junk.jsonl.zst")).rejects.toThrow(CorruptZstdError);
+ });
+
+ it("on a Node.js without zstd, a .zst log throws ZstdUnavailableError naming the version needed", async () => {
+ setZstdCodecForTests(null);
+ const mem = new InMemoryFs();
+ mem.writeBytes("/c/a.jsonl.zst", zstdCompress(TEXT));
+ mem.writeFile("/c/b.jsonl", TEXT);
+ await expect(readLogText(mem, "/c/a.jsonl.zst")).rejects.toThrow(ZstdUnavailableError);
+ await expect(readLogText(mem, "/c/a.jsonl.zst")).rejects.toThrow(/Node\.js 22\.15 or newer/);
+ expect(await readLogText(mem, "/c/b.jsonl")).toBe(TEXT);
+ });
+});
diff --git a/tests/lib/zstd.test.ts b/tests/lib/zstd.test.ts
new file mode 100644
index 0000000..b196de0
--- /dev/null
+++ b/tests/lib/zstd.test.ts
@@ -0,0 +1,98 @@
+/** zstd decoding for Codex's compressed logs: every frame, complete files only. */
+import { afterEach, describe, expect, it } from "vitest";
+import zlib from "node:zlib";
+import {
+ CorruptZstdError,
+ decompressZstd,
+ setZstdCodecForTests,
+ zstdCodec,
+ zstdFirstLine,
+ zstdFrames,
+ type ZstdCodec,
+} from "../../src/lib/zstd.js";
+
+const z = zlib as unknown as { zstdCompressSync: (b: Buffer) => Buffer };
+const zst = (s: string): Buffer => z.zstdCompressSync(Buffer.from(s, "utf8"));
+
+afterEach(() => setZstdCodecForTests(undefined));
+
+function codec(): ZstdCodec {
+ const c = zstdCodec();
+ if (!c) throw new Error("this Node has no zstd: the suite needs Node 22.15+");
+ return c;
+}
+
+describe("zstd", () => {
+ it("this Node has zstd (Node 22.15+ / 23.8+)", () => {
+ expect(zstdCodec()).not.toBeNull();
+ });
+
+ it("decodes a file back to its text", () => {
+ const text = Array.from({ length: 500 }, (_, i) => JSON.stringify({ i, pad: "x".repeat(100) })).join("\n") + "\n";
+ expect(decompressZstd(zst(text), codec()).toString("utf8")).toBe(text);
+ });
+
+ it("decodes every frame of a multi-frame file (Node alone decodes only the first)", () => {
+ const two = Buffer.concat([zst("a\n"), zst("b\n")]);
+ expect((zlib as unknown as { zstdDecompressSync: (b: Buffer) => Buffer }).zstdDecompressSync(two).toString()).toBe("a\n");
+ expect(zstdFrames(two)).toHaveLength(2);
+ expect(decompressZstd(two, codec()).toString()).toBe("a\nb\n");
+ });
+
+ it("ignores skippable frames", () => {
+ const skip = Buffer.alloc(8 + 3);
+ skip.writeUInt32LE(0x184d2a5e, 0);
+ skip.writeUInt32LE(3, 4);
+ expect(decompressZstd(Buffer.concat([skip, zst("x\n")]), codec()).toString()).toBe("x\n");
+ });
+
+ it("rejects a truncated file (Node alone returns partial text without an error)", () => {
+ const text = Array.from({ length: 2000 }, (_, i) => `{"i":${i},"pad":"${"y".repeat(200)}"}`).join("\n");
+ const full = zst(text);
+ for (const cut of [full.length - 1, full.length - 10, full.length >> 1, 5, 3]) {
+ expect(() => decompressZstd(full.subarray(0, cut), codec())).toThrow(CorruptZstdError);
+ }
+ });
+
+ it("rejects an empty file and bytes that are not zstd", () => {
+ expect(() => decompressZstd(Buffer.alloc(0), codec())).toThrow(CorruptZstdError);
+ expect(() => decompressZstd(Buffer.from('{"type":"session_meta"}\n'), codec())).toThrow(CorruptZstdError);
+ });
+
+ it("rejects a frame whose decoded size differs from its header", () => {
+ const short: ZstdCodec = { ...codec(), decompressFrame: (f) => codec().decompressFrame(f).subarray(1) };
+ expect(() => decompressZstd(zst("abc\n"), short)).toThrow(/decoded size/);
+ });
+
+ it("turns a decoder error into CorruptZstdError", () => {
+ const failing: ZstdCodec = {
+ ...codec(),
+ decompressFrame: () => {
+ throw new Error("Data corruption detected");
+ },
+ };
+ expect(() => decompressZstd(zst("abc\n"), failing)).toThrow(CorruptZstdError);
+ });
+
+ it("reads line 1 without decoding the rest", async () => {
+ const line1 = JSON.stringify({ type: "session_meta", payload: { id: "a", text: "é".repeat(30_000) } });
+ const rest = Array.from({ length: 3000 }, (_, i) => `{"n":${i}}`).join("\n");
+ expect(await zstdFirstLine(zst(`${line1}\n${rest}\n`), codec())).toBe(line1);
+ expect(await zstdFirstLine(zst("only line"), codec())).toBe("only line");
+ // Line 1 split across frames.
+ expect(await zstdFirstLine(Buffer.concat([zst("ab"), zst("c\nd\n")]), codec())).toBe("abc");
+ });
+
+ it("line 1 of a truncated or foreign file is an error, like a full read", async () => {
+ const full = zst(`first\n${"z".repeat(100_000)}\n`);
+ await expect(zstdFirstLine(full.subarray(0, full.length - 4), codec())).rejects.toThrow(CorruptZstdError);
+ await expect(zstdFirstLine(Buffer.from("plain text\n"), codec())).rejects.toThrow(CorruptZstdError);
+ });
+
+ it("can be switched off for tests (a Node without zstd)", () => {
+ setZstdCodecForTests(null);
+ expect(zstdCodec()).toBeNull();
+ setZstdCodecForTests(undefined);
+ expect(zstdCodec()).not.toBeNull();
+ });
+});
From 37c9ef482a9d5c42ecc95ce7f3a395da6732f6ee Mon Sep 17 00:00:00 2001
From: Anuj7411
Date: Thu, 15 Oct 2026 19:31:49 +0530
Subject: [PATCH 2/9] fix(codex): read compressed rollouts (.jsonl.zst)
everywhere a rollout is read
Current Codex compresses older rollouts to rollout-*.jsonl.zst, and
Sipcode skipped them, so a Codex user saw almost no history: forecast
had 0 days, limits restarted at 0%, why / stats / regress missed most
sessions.
Discovery now lists compressed rollouts (line 1 decoded by streaming up
to the first newline), and every session-log reader goes through
readLogText: the full parse, the request-key scan of files outside a
window, dropCopiedRequests (why / receipt), the limit-reading scan,
latest-session pick, drift. The usage cache stores a compressed log
once it decodes (it is complete by then), keyed by path, size, mtime.
One log per rollout name: when X.jsonl and X.jsonl.zst both exist, in
either folder, the plain one is read (Codex may still append to it);
between two of the same kind sessions/ wins, as before.
On a Node.js without zstd the compressed logs are still counted, with
the note: N compressed Codex log(s) (.jsonl.zst) skipped: reading them
needs Node.js 22.15 or newer (you have vX). A damaged or truncated .zst
is an unreadable file, never a crash.
Tests: a Codex home with plain, mixed, all-compressed and both-forms
layouts gives byte-identical stats / today / forecast / limits / why /
receipt / drift / regress output, JSON and terminal; per-reader tests
for discovery, parse, key scan, usage cache, damaged files and a Node
without zstd (injected, not by removing zlib).
Co-Authored-By: Claude Opus 5.5
---
src/commands/drift.ts | 4 +-
src/commands/receipt.ts | 4 +-
src/commands/why.ts | 4 +-
src/modules/agents/codex/discover.ts | 69 ++++--
src/modules/agents/latest.ts | 3 +-
src/modules/agents/loadSessions.ts | 23 +-
src/modules/agents/types.ts | 2 +-
src/modules/estimate/anchors.ts | 4 +-
src/modules/limits/load.ts | 3 +-
src/modules/regress/report.ts | 2 +-
.../codex-compressed.integration.test.ts | 202 ++++++++++++++++++
tests/integration/codex-fixtures.ts | 15 ++
.../discovery-notes-json.integration.test.ts | 31 ++-
.../integration/forecast.integration.test.ts | 10 +-
tests/integration/impact.integration.test.ts | 10 +-
tests/integration/regress.integration.test.ts | 1 +
tests/integration/stats.integration.test.ts | 10 +-
tests/integration/today.integration.test.ts | 10 +-
tests/integration/trend.integration.test.ts | 10 +-
tests/modules/agents/codex/adapter.test.ts | 27 ++-
tests/modules/agents/codex/discover.test.ts | 102 ++++++++-
.../agents/loadSessions-compressed.test.ts | 123 +++++++++++
tests/modules/agents/loadSessions.test.ts | 2 +
tests/modules/regress/format.test.ts | 4 +-
.../transcript/discover-counts.test.ts | 1 +
25 files changed, 588 insertions(+), 88 deletions(-)
create mode 100644 tests/integration/codex-compressed.integration.test.ts
create mode 100644 tests/modules/agents/loadSessions-compressed.test.ts
diff --git a/src/commands/drift.ts b/src/commands/drift.ts
index df532d0..3558a59 100644
--- a/src/commands/drift.ts
+++ b/src/commands/drift.ts
@@ -21,7 +21,7 @@
import { ASSERT_NO_NETWORK } from "../lib/privacy.js";
void ASSERT_NO_NETWORK;
import path from "node:path";
-import { RealFileSystem, type FileSystem } from "../lib/fs.js";
+import { readLogText, RealFileSystem, type FileSystem } from "../lib/fs.js";
import { RealClock, type Clock } from "../lib/clock.js";
import { RealProcessEnv, type ProcessEnv } from "../lib/process.js";
import { MESSAGES } from "../lib/messages.js";
@@ -242,7 +242,7 @@ export async function runDriftCommand(
}
let content: string;
try {
- content = await fs.readFile(s.filePath);
+ content = await readLogText(fs, s.filePath);
} catch {
metrics.set(s, null);
continue;
diff --git a/src/commands/receipt.ts b/src/commands/receipt.ts
index d8f77f5..ff392d8 100644
--- a/src/commands/receipt.ts
+++ b/src/commands/receipt.ts
@@ -17,7 +17,7 @@ void ASSERT_NO_NETWORK;
import path from "node:path";
import { promises as nodeFs } from "node:fs";
-import { RealFileSystem, type FileSystem } from "../lib/fs.js";
+import { readLogText, RealFileSystem, type FileSystem } from "../lib/fs.js";
import { RealClock, type Clock } from "../lib/clock.js";
import { RealProcessEnv, type ProcessEnv } from "../lib/process.js";
import {
@@ -196,7 +196,7 @@ export async function runReceipt(
if (agent.id === "claude-code") {
let contents: string;
try {
- contents = await fs.readFile(chosen.filePath);
+ contents = await readLogText(fs, chosen.filePath);
} catch {
stderr(MESSAGES.malformedTranscript(path.basename(chosen.filePath), 0));
return { exitCode: 1 };
diff --git a/src/commands/why.ts b/src/commands/why.ts
index 1ba13b5..9027719 100644
--- a/src/commands/why.ts
+++ b/src/commands/why.ts
@@ -5,7 +5,7 @@
import { ASSERT_NO_NETWORK } from "../lib/privacy.js";
void ASSERT_NO_NETWORK;
import path from "node:path";
-import { RealFileSystem, type FileSystem } from "../lib/fs.js";
+import { readLogText, RealFileSystem, type FileSystem } from "../lib/fs.js";
import { RealClock, type Clock } from "../lib/clock.js";
import { RealProcessEnv, type ProcessEnv } from "../lib/process.js";
import { MESSAGES } from "../lib/messages.js";
@@ -162,7 +162,7 @@ export async function runWhy(
if (agent.id === "claude-code") {
let contents: string;
try {
- contents = await fs.readFile(chosen.filePath);
+ contents = await readLogText(fs, chosen.filePath);
} catch {
stderr(
MESSAGES.malformedTranscript(
diff --git a/src/modules/agents/codex/discover.ts b/src/modules/agents/codex/discover.ts
index e4393a7..8f4f820 100644
--- a/src/modules/agents/codex/discover.ts
+++ b/src/modules/agents/codex/discover.ts
@@ -1,14 +1,23 @@
/**
- * Codex rollout discovery: $CODEX_HOME/{sessions,archived_sessions}/**\/rollout-*.jsonl.
+ * Codex rollout discovery: $CODEX_HOME/{sessions,archived_sessions}/**\/rollout-*.jsonl
+ * and rollout-*.jsonl.zst (Codex compresses older rollouts; mtimes are kept).
*
* Only line 1 (`session_meta`) is read here, for the session id and cwd; the
- * full file is read once, later, by loadSessions. `.jsonl.zst` (Codex's
- * optional compression of old rollouts) is listed as skipped, and folders or
- * files that cannot be read are counted: neither is ever silently dropped.
+ * full file is read once, later, by loadSessions (readLogText decodes a
+ * compressed one). On a Node.js without zstd, compressed rollouts are counted
+ * as skipped; folders or files that cannot be read (a damaged `.zst` too) are
+ * counted as unreadable: none is ever silently dropped.
* A missing sessions/ or archived_sessions/ folder is normal, not an error.
+ *
+ * One log per rollout name: when `X.jsonl` and `X.jsonl.zst` both exist (in
+ * either folder), the plain `X.jsonl` is read. It is the one Codex may still
+ * be appending to (a resumed session), and the compressed copy can be one
+ * whose plain file was not removed yet. Between two copies of the same kind,
+ * sessions/ wins over archived_sessions/.
*/
import path from "node:path";
-import type { FileSystem } from "../../../lib/fs.js";
+import { isCompressedLog, readCompressedLogFirstLine, type FileSystem } from "../../../lib/fs.js";
+import { zstdCodec } from "../../../lib/zstd.js";
import type { ProcessEnv } from "../../../lib/process.js";
import type { SessionMeta } from "../../transcript/discover.js";
import { cwdToProjectHash } from "../../transcript/discover.js";
@@ -27,6 +36,7 @@ export function resolveCodexHome(env: ProcessEnv): string {
export interface CodexDiscovery {
readonly sessions: SessionMeta[];
+ /** Compressed rollouts not read because this Node.js has no zstd. */
readonly skippedCompressed: number;
/** Rollout files that could not be read. */
readonly unreadable: number;
@@ -41,7 +51,6 @@ interface Found {
}
interface Counters {
- compressed: string[];
unreadable: number;
unreadableFolders: number;
}
@@ -57,22 +66,30 @@ async function walk(fs: FileSystem, dir: string, out: Found[], counters: Counter
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory) await walk(fs, p, out, counters);
- else if (e.isFile && e.name.startsWith("rollout-")) {
- if (e.name.endsWith(".jsonl.zst")) counters.compressed.push(p);
- else if (e.name.endsWith(".jsonl")) {
- try {
- const s = await fs.stat(p);
- out.push({ file: p, mtimeMs: s.mtimeMs, size: s.size });
- } catch {
- counters.unreadable++;
- }
+ else if (e.isFile && isRolloutName(e.name)) {
+ try {
+ const s = await fs.stat(p);
+ out.push({ file: p, mtimeMs: s.mtimeMs, size: s.size });
+ } catch {
+ counters.unreadable++;
}
}
}
}
+/** `rollout-*.jsonl` or `rollout-*.jsonl.zst`. */
+function isRolloutName(name: string): boolean {
+ return name.startsWith("rollout-") && (name.endsWith(".jsonl") || name.endsWith(".jsonl.zst"));
+}
+
+/** The plain log's name: `X.jsonl` for both `X.jsonl` and `X.jsonl.zst`. */
+function plainName(file: string): string {
+ return path.basename(file).replace(/\.zst$/, "");
+}
+
/** Line 1 of a rollout, reading the whole file only when line 1 outgrows the largest head window. */
async function readFirstLine(fs: FileSystem, file: string): Promise {
+ if (isCompressedLog(file)) return readCompressedLogFirstLine(fs, file);
for (let n = HEAD_START_BYTES; n <= HEAD_MAX_BYTES; n *= 2) {
const head = await fs.readHead(file, n);
const nl = head.indexOf("\n");
@@ -86,7 +103,7 @@ async function readFirstLine(fs: FileSystem, file: string): Promise {
return fullNl >= 0 ? full.slice(0, fullNl) : full;
}
-/** True as soon as one `rollout-*.jsonl` is found under `dir` (stops walking there). */
+/** True as soon as one `rollout-*.jsonl` or `.jsonl.zst` is found under `dir` (stops walking there). */
export async function hasCodexRollout(fs: FileSystem, dir: string): Promise {
let entries;
try {
@@ -95,7 +112,7 @@ export async function hasCodexRollout(fs: FileSystem, dir: string): Promise {
- const counters: Counters = { compressed: [], unreadable: 0, unreadableFolders: 0 };
+ const counters: Counters = { unreadable: 0, unreadableFolders: 0 };
const byName = new Map();
for (const sub of ["sessions", "archived_sessions"]) {
const dir = path.join(home, sub);
@@ -112,12 +129,20 @@ export async function listCodexSessions(fs: FileSystem, home: string): Promise b.mtimeMs - a.mtimeMs);
return {
sessions,
- skippedCompressed: counters.compressed.length,
+ skippedCompressed,
unreadable: counters.unreadable,
unreadableFolders: counters.unreadableFolders,
};
diff --git a/src/modules/agents/latest.ts b/src/modules/agents/latest.ts
index aeabbcc..3d34996 100644
--- a/src/modules/agents/latest.ts
+++ b/src/modules/agents/latest.ts
@@ -15,6 +15,7 @@
* - Inside one agent, a prefix matching several sessions picks the newest
* (unchanged). Matches in more than one agent are flagged `ambiguous`.
*/
+import { readLogText } from "../../lib/fs.js";
import { issue, type SipcodeIssue } from "../../lib/errors.js";
import { MESSAGES } from "../../lib/messages.js";
import { shortSessionId } from "../../lib/session-id.js";
@@ -92,7 +93,7 @@ export async function pickFrom(
for (const meta of metas) {
let content: string;
try {
- content = await deps.fs.readFile(meta.filePath);
+ content = await readLogText(deps.fs, meta.filePath);
} catch {
continue;
}
diff --git a/src/modules/agents/loadSessions.ts b/src/modules/agents/loadSessions.ts
index f0c16aa..67b8fc8 100644
--- a/src/modules/agents/loadSessions.ts
+++ b/src/modules/agents/loadSessions.ts
@@ -19,6 +19,8 @@
* zero usage. With a usage cache (today / forecast), unchanged files are
* served from it instead of being read.
*/
+import { isCompressedLog, readLogText } from "../../lib/fs.js";
+import { ZSTD_MIN_NODE } from "../../lib/zstd.js";
import { ok, type Result } from "../../lib/result.js";
import type { SipcodeIssue } from "../../lib/errors.js";
import { dedupeAcrossSessions, DedupeIndex } from "../transcript/dedupe.js";
@@ -72,7 +74,7 @@ export interface LoadCounts {
readonly unreadable: number;
/** Folders discovery could not list. */
readonly unreadableFolders: number;
- /** Compressed logs skipped on purpose (Codex `.jsonl.zst`). */
+ /** Compressed logs (Codex `.jsonl.zst`) skipped because this Node.js has no zstd. */
readonly skippedCompressed: number;
/** Files read for their request keys only (they can hold copies), not returned. */
readonly scannedOnly: number;
@@ -167,8 +169,8 @@ export function discoveryNotes(o: LoadCounts): string[] {
return notes;
}
-export function compressedSkippedMessage(n: number): string {
- return `${n} compressed Codex log(s) (.jsonl.zst) skipped: Sipcode cannot read compressed logs yet.`;
+export function compressedSkippedMessage(n: number, nodeVersion: string = process.version): string {
+ return `${n} compressed Codex log(s) (.jsonl.zst) skipped: reading them needs Node.js ${ZSTD_MIN_NODE} or newer (you have ${nodeVersion}).`;
}
type Read =
@@ -192,7 +194,7 @@ async function readParse(
): Promise {
let text: string;
try {
- text = await deps.fs.readFile(meta.filePath);
+ text = await readLogText(deps.fs, meta.filePath);
} catch {
return { ok: false, unreadable: true, issues: [] };
}
@@ -249,7 +251,7 @@ export async function loadSessions(
const meta = all[i]!;
let text: string;
try {
- text = await deps.fs.readFile(meta.filePath);
+ text = await readLogText(deps.fs, meta.filePath);
} catch {
unreadable++;
continue;
@@ -337,7 +339,10 @@ export async function loadSessions(
// next read).
const parseOne = async (i: number): Promise => {
const meta = all[i]!;
- const r = await readParse(agent, deps, meta, undefined, cache !== null);
+ // A compressed log is complete once it decodes (readLogText checks every frame), so its
+ // decoded size need not match the file size discovery saw.
+ const compressed = isCompressedLog(meta.filePath);
+ const r = await readParse(agent, deps, meta, undefined, cache !== null && !compressed);
if (cache) {
parsedNow[i] = 1;
cacheDirty = true;
@@ -349,7 +354,7 @@ export async function loadSessions(
}
added[i] = 1;
textChars.set(i, r.chars);
- if (cache && r.bytes === meta.size) {
+ if (cache && (compressed || r.bytes === meta.size)) {
if (!writer) {
writer = cache.writer();
await writer.add(cacheHeader());
@@ -455,7 +460,7 @@ const COPY_WINDOW_SLACK_MS = 60 * 60 * 1000;
* names (SessionMeta.lineage). A request is copied only from a thread into
* the threads forked or spawned from it, so two logs holding the same
* request are in one family. A family is open (null) when a link names a
- * thread with no readable log (deleted, compressed): the logs on its other
+ * thread with no readable log (deleted, damaged, or compressed on a Node.js without zstd): the logs on its other
* side cannot be told apart, so an open log is a candidate for every target.
* A log without lineage is open too.
*/
@@ -580,7 +585,7 @@ export async function dropCopiedRequests(i: {
const meta = i.all[k]!;
let content: string;
try {
- content = await deps.fs.readFile(meta.filePath);
+ content = await readLogText(deps.fs, meta.filePath);
} catch {
continue;
}
diff --git a/src/modules/agents/types.ts b/src/modules/agents/types.ts
index f13326d..da99f08 100644
--- a/src/modules/agents/types.ts
+++ b/src/modules/agents/types.ts
@@ -90,7 +90,7 @@ export interface SessionDiscovery {
readonly unreadable: number;
/** Folders that could not be listed (reported apart from files). Default 0. */
readonly unreadableFolders?: number;
- /** Compressed logs (Codex `.jsonl.zst`) skipped on purpose, reported as one count. Default 0. */
+ /** Compressed logs (Codex `.jsonl.zst`) skipped because this Node.js has no zstd, reported as one count. Default 0. */
readonly skippedCompressed?: number;
/** Other problems discovery wants surfaced. */
readonly issues: readonly SipcodeIssue[];
diff --git a/src/modules/estimate/anchors.ts b/src/modules/estimate/anchors.ts
index 8b5101d..362824b 100644
--- a/src/modules/estimate/anchors.ts
+++ b/src/modules/estimate/anchors.ts
@@ -17,7 +17,7 @@
* at 60 most-recent sessions for speed.
*/
import path from "node:path";
-import type { FileSystem } from "../../lib/fs.js";
+import { readLogText, type FileSystem } from "../../lib/fs.js";
import type { ProcessEnv } from "../../lib/process.js";
import {
listAllSessions,
@@ -180,7 +180,7 @@ export async function loadAnchors(
for (const sess of limited) {
let content: string;
try {
- content = await fs.readFile(sess.filePath);
+ content = await readLogText(fs, sess.filePath);
} catch {
continue;
}
diff --git a/src/modules/limits/load.ts b/src/modules/limits/load.ts
index 5d4dc5c..2866278 100644
--- a/src/modules/limits/load.ts
+++ b/src/modules/limits/load.ts
@@ -4,6 +4,7 @@
* and the usage cache are reused unchanged). Files that cannot be read are
* skipped here; the session loader's stderr note counts them.
*/
+import { readLogText } from "../../lib/fs.js";
import { scanCodexRateLimits, type LimitReading } from "../agents/codex/rateLimits.js";
import { discoverAgentSessions, type LoadedSession } from "../agents/loadSessions.js";
import { getAgentById } from "../agents/registry.js";
@@ -32,7 +33,7 @@ export async function loadCodexReadings(deps: AgentDeps): Promise
const sessions = found.value.sessions;
for (let i = 0; i < sessions.length; i += READ_AHEAD) {
const batch = sessions.slice(i, i + READ_AHEAD);
- const texts = await Promise.all(batch.map((m) => deps.fs.readFile(m.filePath).catch(() => undefined)));
+ const texts = await Promise.all(batch.map((m) => readLogText(deps.fs, m.filePath).catch(() => undefined)));
batch.forEach((meta, j) => {
const text = texts[j];
if (text === undefined) return;
diff --git a/src/modules/regress/report.ts b/src/modules/regress/report.ts
index ae224c0..a2f974e 100644
--- a/src/modules/regress/report.ts
+++ b/src/modules/regress/report.ts
@@ -21,7 +21,7 @@ export interface RegressAccess {
readonly unreadableFiles: number;
/** Log folders that could not be listed. */
readonly unreadableFolders: number;
- /** Compressed Codex logs (.jsonl.zst) skipped on purpose. */
+ /** Compressed Codex logs (.jsonl.zst) skipped: this Node.js has no zstd. */
readonly skippedCompressed: number;
}
diff --git a/tests/integration/codex-compressed.integration.test.ts b/tests/integration/codex-compressed.integration.test.ts
new file mode 100644
index 0000000..687dab8
--- /dev/null
+++ b/tests/integration/codex-compressed.integration.test.ts
@@ -0,0 +1,202 @@
+/**
+ * Codex compresses older rollouts to rollout-*.jsonl.zst (mtimes kept). A
+ * Codex home where some or all rollouts are compressed gives exactly the
+ * output of the same home with every rollout plain: stats, today, forecast,
+ * limits, why and regress, JSON and terminal, stdout and stderr, byte for
+ * byte. Between them these runs use every reader of a session log:
+ * discovery's line 1, the full parse, the request-key scan of files outside a
+ * window, the limit-reading scan, regress and why (auto-pick and --session),
+ * plus the usage cache (cold and warm).
+ */
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import zlib from "node:zlib";
+import { InMemoryFs } from "../../src/lib/fs.js";
+import { FakeClock } from "../../src/lib/clock.js";
+import { FakeProcessEnv } from "../../src/lib/process.js";
+import { runStats } from "../../src/commands/stats.js";
+import { runTodayCmd } from "../../src/commands/today.js";
+import { runForecastCmd } from "../../src/commands/forecast.js";
+import { runLimits } from "../../src/commands/limits.js";
+import { runWhy } from "../../src/commands/why.js";
+import { runRegress } from "../../src/commands/regress.js";
+import { runReceipt } from "../../src/commands/receipt.js";
+import { runDriftCommand } from "../../src/commands/drift.js";
+import type { UsageCaches } from "../../src/modules/agents/usageSessions.js";
+import { memCache, type MemCache } from "../modules/agents/memCache.js";
+import {
+ addCodexRollout,
+ addShrinkScenario,
+ addWeeklyLimitScenario,
+ CODEX_SESSIONS,
+ codexRollout,
+ LIMITS_SESSION_A,
+ LIMITS_SHRINK_SESSION,
+ solTurn,
+ withoutZstd,
+ zstdSkipMessage,
+} from "./codex-fixtures.js";
+import { addCodexDataset } from "./regress-fixtures.js";
+
+const NOW = new Date("2026-10-09T10:00:00.000Z");
+const compress = (s: string): Buffer =>
+ (zlib as unknown as { zstdCompressSync: (b: Buffer) => Buffer }).zstdCompressSync(Buffer.from(s, "utf8"));
+
+let savedTz: string | undefined;
+beforeAll(() => {
+ savedTz = process.env.TZ;
+ process.env.TZ = "UTC";
+});
+afterAll(() => {
+ if (savedTz === undefined) delete process.env.TZ;
+ else process.env.TZ = savedTz;
+});
+
+/** Records every file a scenario writes, so the same home can be laid out plain or compressed. */
+class Recorder extends InMemoryFs {
+ readonly files: Array<{ p: string; content: string; mtimeMs: number }> = [];
+ override writeFile(p: string, content: string, mtimeMs = 0): void {
+ this.files.push({ p, content, mtimeMs });
+ super.writeFile(p, content, mtimeMs);
+ }
+}
+
+function scenario(): Recorder {
+ const fs = new Recorder();
+ addWeeklyLimitScenario(fs); // limits, today, why
+ addShrinkScenario(fs); // limits history, files outside today's window (key scan)
+ addCodexDataset(fs, { dirty: true }); // regress finding (June)
+ addCodexRollout(fs, "cx-may", [solTurn("2026-05-19T10:00:00Z"), solTurn("2026-05-19T10:05:00Z")]);
+ // Session A resumed today in a second log: A's requests copied, plus one new
+ // request. A (outside today's window) keeps the copied requests whichever
+ // log is compressed: read by key scan for the period commands, by
+ // dropCopiedRequests for why / receipt.
+ const a = fs.files.find((f) => f.p.includes(LIMITS_SESSION_A))!;
+ const extra = codexRollout("resumed-a", "C:\\work\\sipcode", [solTurn("2026-10-09T09:30:00.000Z", 70_000, 2_000)])
+ .split("\n")
+ .slice(1)
+ .join("\n");
+ fs.writeFile(
+ `${CODEX_SESSIONS}/2026/10/09/rollout-2026-10-09T09-30-00-resumed-a.jsonl`,
+ `${a.content.trimEnd()}\n${extra}`,
+ Date.parse("2026-10-09T09:31:00.000Z"),
+ );
+ fs.writeFile(
+ `${CODEX_SESSIONS}/2026/10/09/rollout-2026-10-09T09-00-00-today.jsonl`,
+ codexRollout("today-1", "/home/u/my-app", [solTurn("2026-10-09T09:00:00.000Z", 40_000, 900)]),
+ Date.parse("2026-10-09T09:01:00.000Z"),
+ );
+ return fs;
+}
+
+type Layout = "plain" | "mixed" | "compressed" | "both";
+
+/** The scenario with rollouts plain, every other one compressed, all compressed, or each in both forms. */
+function home(layout: Layout): InMemoryFs {
+ const fs = new InMemoryFs();
+ scenario().files.forEach((f, i) => {
+ const rollout = /\/rollout-[^/]*\.jsonl$/.test(f.p);
+ const zst = rollout && (layout === "compressed" || layout === "both" || (layout === "mixed" && i % 2 === 0));
+ if (!zst || layout === "both") fs.writeFile(f.p, f.content, f.mtimeMs);
+ if (zst) fs.writeBytes(`${f.p}.zst`, compress(f.content), f.mtimeMs);
+ });
+ return fs;
+}
+
+const deps = (fs: InMemoryFs, extra: { usageCaches?: UsageCaches } = {}) => {
+ const out: string[] = [];
+ const d = {
+ fs,
+ env: new FakeProcessEnv({ homeDir: "/home/u", platform: "linux", vars: { NO_COLOR: "1" } }),
+ clock: new FakeClock(NOW),
+ stdout: (s: string) => out.push(s),
+ stderr: (s: string) => out.push(`stderr: ${s}`),
+ writeFile: async () => {},
+ ...extra,
+ };
+ return { d, out };
+};
+
+type Cmd = (fs: InMemoryFs, json: boolean, extra?: { usageCaches?: UsageCaches }) => Promise;
+
+const cmd =
+ (run: (json: boolean, d: ReturnType["d"]) => Promise<{ exitCode: number }>): Cmd =>
+ async (fs, json, extra) => {
+ const { d, out } = deps(fs, extra);
+ const r = await run(json, d);
+ return `exit ${r.exitCode}\n${out.join("\n")}`;
+ };
+
+const COMMANDS: Array<[string, Cmd]> = [
+ ["stats --since all", cmd((json, d) => runStats({ json, agent: "codex", since: "all", cwd: "/w" }, d))],
+ ["today", cmd((json, d) => runTodayCmd({ json, agent: "codex", cwd: "/w" }, d))],
+ ["forecast", cmd((json, d) => runForecastCmd({ json, agent: "codex", cwd: "/w" }, d))],
+ ["limits --since all", cmd((json, d) => runLimits({ json, since: "all", cwd: "/w" }, d))],
+ ["why (newest)", cmd((json, d) => runWhy({ json, agent: "codex", cwd: "/w" }, d))],
+ ["why --session", cmd((json, d) => runWhy({ json, agent: "codex", session: LIMITS_SHRINK_SESSION.slice(0, 13), cwd: "/w" }, d))],
+ ["regress", cmd((json, d) => runRegress({ json, agent: "codex", cwd: "/w", bugReport: true }, d))],
+ // Two logs of session A: the newest is reported with the requests the other one keeps dropped.
+ ["why --session (copied log)", cmd((json, d) => runWhy({ json, agent: "codex", session: LIMITS_SESSION_A, cwd: "/w" }, d))],
+ ["receipt --session (copied log)", cmd((json, d) => runReceipt({ json: true, agent: "codex", session: LIMITS_SESSION_A, cwd: "/w", noShare: true }, d))],
+ ["drift", cmd((json, d) => runDriftCommand({ json, agent: "codex", noCache: true, cwd: "/w" }, { ...d, now: NOW, stateDir: "/home/u/.sipcode/drift", configPaths: [] }))],
+];
+
+describe("Codex logs compressed (.jsonl.zst) read exactly like plain ones", { timeout: 30_000 }, () => {
+ for (const [name, run] of COMMANDS) {
+ it(`${name}: identical output for plain, mixed, all-compressed and both-forms homes`, async () => {
+ for (const json of [true, false]) {
+ const plain = await run(home("plain"), json);
+ expect(plain).not.toMatch(/stderr: .*(could not be read|compressed Codex log)/);
+ for (const layout of ["mixed", "compressed", "both"] as const) {
+ expect(await run(home(layout), json), `${layout}, json=${json}`).toBe(plain);
+ }
+ }
+ });
+ }
+
+ it("the reports are real ones (sessions, limit history, a regress finding)", async () => {
+ const statsOut = (await COMMANDS[0], true)).split("\n").slice(1);
+ const stats = JSON.parse(statsOut.filter((l) => !l.startsWith("stderr: ")).join("\n"));
+ // 10 rollouts.
+ expect(stats.sessionCount).toBe(10);
+ const limits = JSON.parse((await COMMANDS[3], true)).split("\n").slice(1).join("\n"));
+ expect(limits.windows[0].possibleCut).toEqual({ at: "2026-09-15", factor: 4 });
+ const regress = JSON.parse((await COMMANDS[6], true)).split("\n").slice(1).join("\n"));
+ expect(regress.findings.length).toBeGreaterThan(0);
+ expect(regress.access.skippedCompressed).toBe(0);
+ });
+
+ it("the usage cache serves compressed logs: cold and warm runs match, and a warm forecast decodes none", async () => {
+ const files = new Map();
+ const caches: UsageCaches = (id) => {
+ if (!files.has(id)) files.set(id, memCache());
+ return files.get(id)!;
+ };
+ const fs = home("compressed");
+ let bytesRead = 0;
+ const spy = Object.create(fs, {
+ readBytes: { value: async (p: string) => (bytesRead++, fs.readBytes(p)) },
+ }) as InMemoryFs;
+ const forecast = COMMANDS[2]![1];
+ const today = COMMANDS[1]![1];
+ const expected = await forecast(home("plain"), true);
+ expect(await forecast(spy, true, { usageCaches: caches })).toBe(expected);
+ expect(files.get("codex")!.text).toContain(".jsonl.zst");
+ const before = bytesRead;
+ expect(await forecast(spy, true, { usageCaches: caches })).toBe(expected);
+ // Discovery still reads line 1 of each log; no full read of a compressed log.
+ expect(bytesRead - before).toBe(countRollouts());
+ expect(await today(spy, true, { usageCaches: caches })).toBe(await today(home("plain"), true));
+ });
+
+ it("on a Node.js without zstd: compressed logs are skipped with a note naming the Node version needed", async () => {
+ const out = await withoutZstd(() => COMMANDS[0], false));
+ const n = scenario().files.filter((f, i) => /\/rollout-[^/]*\.jsonl$/.test(f.p) && i % 2 === 0).length;
+ expect(out).toContain(`[E009] ${zstdSkipMessage(n)}`);
+ const reg = await withoutZstd(() => COMMANDS[6], false));
+ expect(reg).toContain(`note: ${zstdSkipMessage(n)}`);
+ });
+});
+
+function countRollouts(): number {
+ return scenario().files.filter((f) => /\/rollout-[^/]*\.jsonl$/.test(f.p)).length;
+}
diff --git a/tests/integration/codex-fixtures.ts b/tests/integration/codex-fixtures.ts
index 5f9581a..1c8ee0b 100644
--- a/tests/integration/codex-fixtures.ts
+++ b/tests/integration/codex-fixtures.ts
@@ -1,8 +1,23 @@
/** Codex rollout builders shared by the period-command integration tests. */
import type { InMemoryFs } from "../../src/lib/fs.js";
+import { setZstdCodecForTests } from "../../src/lib/zstd.js";
export const CODEX_SESSIONS = "/home/u/.codex/sessions";
+/** Runs `fn` as on a Node.js without zstd: compressed rollouts are skipped with a note. */
+export async function withoutZstd(fn: () => Promise): Promise {
+ setZstdCodecForTests(null);
+ try {
+ return await fn();
+ } finally {
+ setZstdCodecForTests(undefined);
+ }
+}
+
+/** The skipped-compressed message on a Node.js without zstd (this process's version). */
+export const zstdSkipMessage = (n: number): string =>
+ `${n} compressed Codex log(s) (.jsonl.zst) skipped: reading them needs Node.js 22.15 or newer (you have ${process.version}).`;
+
const line = (o: unknown): string => JSON.stringify(o);
/** A usage-limit reading Codex logs after a request (event_msg token_count rate_limits). */
diff --git a/tests/integration/discovery-notes-json.integration.test.ts b/tests/integration/discovery-notes-json.integration.test.ts
index 6a3bc61..6feb3da 100644
--- a/tests/integration/discovery-notes-json.integration.test.ts
+++ b/tests/integration/discovery-notes-json.integration.test.ts
@@ -2,7 +2,7 @@
* Logs Sipcode skipped (compressed, unreadable) are reported in --json mode
* too: on stderr, as stats does, so the JSON on stdout stays unchanged.
*/
-import { describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { InMemoryFs } from "../../src/lib/fs.js";
import { FakeClock } from "../../src/lib/clock.js";
import { FakeProcessEnv } from "../../src/lib/process.js";
@@ -10,9 +10,11 @@ import { runTodayCmd } from "../../src/commands/today.js";
import { runForecastCmd } from "../../src/commands/forecast.js";
import { runTrend } from "../../src/commands/trend.js";
import { runImpactCommand } from "../../src/commands/impact.js";
-import { addCodexRollout, CODEX_SESSIONS, solTurn } from "./codex-fixtures.js";
+import { setZstdCodecForTests } from "../../src/lib/zstd.js";
+import { addCodexRollout, CODEX_SESSIONS, solTurn, zstdSkipMessage } from "./codex-fixtures.js";
-const NOTE = "note: 1 compressed Codex log(s) (.jsonl.zst) skipped: Sipcode cannot read compressed logs yet.";
+const NOTE = `note: ${zstdSkipMessage(1)}`;
+const UNREADABLE = "note: 1 transcript file(s) could not be read or parsed; totals exclude them.";
function logs(): InMemoryFs {
const fs = new InMemoryFs();
@@ -29,7 +31,9 @@ const cmds: Array<[string, Cmd]> = [
["impact", (fs, d) => runImpactCommand({ json: true, agent: "codex", cwd: "/nonexistent-sipcode-notes" }, { fs, ...d })],
];
-describe("--json: discovery notes go to stderr", () => {
+describe("--json: discovery notes go to stderr (a Node.js without zstd)", () => {
+ beforeEach(() => setZstdCodecForTests(null));
+ afterEach(() => setZstdCodecForTests(undefined));
for (const [name, run] of cmds) {
it(name, async () => {
const out: string[] = [];
@@ -46,3 +50,22 @@ describe("--json: discovery notes go to stderr", () => {
});
}
});
+
+describe("--json: a damaged compressed log is an unreadable file (Node.js with zstd)", () => {
+ for (const [name, run] of cmds) {
+ it(name, async () => {
+ const out: string[] = [];
+ const err: string[] = [];
+ const r = await run(logs(), {
+ env: new FakeProcessEnv({ homeDir: "/home/u", platform: "linux", vars: { NO_COLOR: "1" } }),
+ clock: new FakeClock(new Date("2026-05-20T12:00:00Z")),
+ stdout: (s: string) => out.push(s),
+ stderr: (s: string) => err.push(s),
+ });
+ expect(r.exitCode).toBe(0);
+ expect(err).toContain(UNREADABLE);
+ expect(err.join("\n")).not.toContain("compressed Codex log");
+ expect(() => JSON.parse(out.join("\n"))).not.toThrow();
+ });
+ }
+});
diff --git a/tests/integration/forecast.integration.test.ts b/tests/integration/forecast.integration.test.ts
index d4b7e9c..2603c36 100644
--- a/tests/integration/forecast.integration.test.ts
+++ b/tests/integration/forecast.integration.test.ts
@@ -4,7 +4,7 @@ import { runForecastCmd } from "../../src/commands/forecast.js";
import { InMemoryFs } from "../../src/lib/fs.js";
import { FakeClock } from "../../src/lib/clock.js";
import { FakeProcessEnv } from "../../src/lib/process.js";
-import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn } from "./codex-fixtures.js";
+import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn, withoutZstd, zstdSkipMessage } from "./codex-fixtures.js";
const NOW = new Date("2026-05-20T12:00:00.000Z");
@@ -100,12 +100,10 @@ describe("runForecastCmd: Claude Code and Codex sections", () => {
);
});
- it("reports skipped compressed Codex logs once, on stderr", async () => {
+ it("on a Node.js without zstd, reports skipped compressed Codex logs once, on stderr", async () => {
const fs = withCodex(new InMemoryFs());
fs.writeFile(`${CODEX_SESSIONS}/2026/05/09/rollout-a.jsonl.zst`, "z", 1);
- const r = await run({}, fs);
- expect(r.err).toBe(
- "note: 1 compressed Codex log(s) (.jsonl.zst) skipped: Sipcode cannot read compressed logs yet.",
- );
+ const r = await withoutZstd(() => run({}, fs));
+ expect(r.err).toBe(`note: ${zstdSkipMessage(1)}`);
});
});
diff --git a/tests/integration/impact.integration.test.ts b/tests/integration/impact.integration.test.ts
index 13ae2d8..c870432 100644
--- a/tests/integration/impact.integration.test.ts
+++ b/tests/integration/impact.integration.test.ts
@@ -4,7 +4,7 @@ import { runImpactCommand } from "../../src/commands/impact.js";
import { InMemoryFs } from "../../src/lib/fs.js";
import { FakeClock } from "../../src/lib/clock.js";
import { FakeProcessEnv } from "../../src/lib/process.js";
-import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn } from "./codex-fixtures.js";
+import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn, withoutZstd, zstdSkipMessage } from "./codex-fixtures.js";
const NOW = new Date("2026-05-20T12:00:00.000Z");
// A folder with no .sipcode/install-state.json: no install marker.
@@ -112,13 +112,11 @@ describe("runImpactCommand: Claude Code and Codex sections", () => {
);
});
- it("reports skipped compressed Codex logs once, on stderr", async () => {
+ it("on a Node.js without zstd, reports skipped compressed Codex logs once, on stderr", async () => {
const fs = withCodex(new InMemoryFs());
fs.writeFile(`${CODEX_SESSIONS}/2026/05/09/rollout-a.jsonl.zst`, "z", 1);
- const r = await run({}, fs);
- expect(r.err).toBe(
- "note: 1 compressed Codex log(s) (.jsonl.zst) skipped: Sipcode cannot read compressed logs yet.",
- );
+ const r = await withoutZstd(() => run({}, fs));
+ expect(r.err).toBe(`note: ${zstdSkipMessage(1)}`);
});
});
diff --git a/tests/integration/regress.integration.test.ts b/tests/integration/regress.integration.test.ts
index a400188..c93bb04 100644
--- a/tests/integration/regress.integration.test.ts
+++ b/tests/integration/regress.integration.test.ts
@@ -47,6 +47,7 @@ function blocked(base: InMemoryFs, fail: (p: string) => boolean): FileSystem {
exists: (p) => base.exists(p),
readFile: async (p) => (guard(p), base.readFile(p)),
readHead: async (p, n) => (guard(p), base.readHead(p, n)),
+ readBytes: async (p) => (guard(p), base.readBytes(p)),
readDir: async (p) => (guard(p), base.readDir(p)),
stat: (p) => base.stat(p),
};
diff --git a/tests/integration/stats.integration.test.ts b/tests/integration/stats.integration.test.ts
index fd7bc9d..22f676a 100644
--- a/tests/integration/stats.integration.test.ts
+++ b/tests/integration/stats.integration.test.ts
@@ -6,7 +6,7 @@ import { runStats } from "../../src/commands/stats.js";
import { InMemoryFs } from "../../src/lib/fs.js";
import { FakeClock } from "../../src/lib/clock.js";
import { FakeProcessEnv } from "../../src/lib/process.js";
-import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn } from "./codex-fixtures.js";
+import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn, withoutZstd, zstdSkipMessage } from "./codex-fixtures.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const fixtures = path.resolve(__dirname, "../fixtures/transcripts");
@@ -577,14 +577,12 @@ describe("runStats: Claude Code and Codex sections", { timeout: 20_000 }, () =>
expect(r.out).toMatch(/Both tools: .* \+ ~\$0\.00 \(\+ unpriced\)$/m);
});
- it("reports skipped compressed Codex logs as one line, apart from unreadable files", async () => {
+ it("on a Node.js without zstd, reports skipped compressed Codex logs as one line, apart from unreadable files", async () => {
const fs = codexOnly();
fs.writeFile(`${CODEX_SESSIONS}/2026/05/11/rollout-a.jsonl.zst`, "z", 1);
fs.writeFile(`${CODEX_SESSIONS}/2026/05/12/rollout-b.jsonl.zst`, "z", 1);
- const r = await run({ since: "30d" }, fs);
- expect(r.out).toContain(
- "[E009] 2 compressed Codex log(s) (.jsonl.zst) skipped: Sipcode cannot read compressed logs yet.",
- );
+ const r = await withoutZstd(() => run({ since: "30d" }, fs));
+ expect(r.out).toContain(`[E009] ${zstdSkipMessage(2)}`);
expect(r.out).not.toMatch(/couldn't read \d+ transcript file/);
expect(r.out.match(/\.jsonl\.zst/g)).toHaveLength(1);
});
diff --git a/tests/integration/today.integration.test.ts b/tests/integration/today.integration.test.ts
index e5928dd..6e6c475 100644
--- a/tests/integration/today.integration.test.ts
+++ b/tests/integration/today.integration.test.ts
@@ -4,7 +4,7 @@ import { runTodayCmd } from "../../src/commands/today.js";
import { InMemoryFs } from "../../src/lib/fs.js";
import { FakeClock } from "../../src/lib/clock.js";
import { FakeProcessEnv } from "../../src/lib/process.js";
-import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn } from "./codex-fixtures.js";
+import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn, withoutZstd, zstdSkipMessage } from "./codex-fixtures.js";
const NOW = new Date("2026-05-10T15:00:00.000Z");
@@ -105,13 +105,11 @@ describe("runTodayCmd: Claude Code and Codex sections", { timeout: 20_000 }, ()
expect(r.out).toMatch(/\(\+ unpriced\)$/m);
});
- it("reports skipped compressed Codex logs once, on stderr", async () => {
+ it("on a Node.js without zstd, reports skipped compressed Codex logs once, on stderr", async () => {
const fs = withCodex(new InMemoryFs());
fs.writeFile(`${CODEX_SESSIONS}/2026/05/09/rollout-a.jsonl.zst`, "z", 1);
fs.writeFile(`${CODEX_SESSIONS}/2026/05/09/rollout-b.jsonl.zst`, "z", 1);
- const r = await run({}, fs);
- expect(r.err).toBe(
- "note: 2 compressed Codex log(s) (.jsonl.zst) skipped: Sipcode cannot read compressed logs yet.",
- );
+ const r = await withoutZstd(() => run({}, fs));
+ expect(r.err).toBe(`note: ${zstdSkipMessage(2)}`);
});
});
diff --git a/tests/integration/trend.integration.test.ts b/tests/integration/trend.integration.test.ts
index 638e187..2cb254e 100644
--- a/tests/integration/trend.integration.test.ts
+++ b/tests/integration/trend.integration.test.ts
@@ -4,7 +4,7 @@ import { runTrend } from "../../src/commands/trend.js";
import { InMemoryFs } from "../../src/lib/fs.js";
import { FakeClock } from "../../src/lib/clock.js";
import { FakeProcessEnv } from "../../src/lib/process.js";
-import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn } from "./codex-fixtures.js";
+import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn, withoutZstd, zstdSkipMessage } from "./codex-fixtures.js";
const NOW = new Date("2026-05-20T12:00:00.000Z");
@@ -92,12 +92,10 @@ describe("runTrend: Claude Code and Codex sections", () => {
expect(ratio.out).not.toContain("without a known price");
});
- it("reports skipped compressed Codex logs once, on stderr", async () => {
+ it("on a Node.js without zstd, reports skipped compressed Codex logs once, on stderr", async () => {
const fs = withCodex(new InMemoryFs());
fs.writeFile(`${CODEX_SESSIONS}/2026/05/09/rollout-a.jsonl.zst`, "z", 1);
- const r = await run({}, fs);
- expect(r.err).toBe(
- "note: 1 compressed Codex log(s) (.jsonl.zst) skipped: Sipcode cannot read compressed logs yet.",
- );
+ const r = await withoutZstd(() => run({}, fs));
+ expect(r.err).toBe(`note: ${zstdSkipMessage(1)}`);
});
});
diff --git a/tests/modules/agents/codex/adapter.test.ts b/tests/modules/agents/codex/adapter.test.ts
index 428488a..bbb9b0f 100644
--- a/tests/modules/agents/codex/adapter.test.ts
+++ b/tests/modules/agents/codex/adapter.test.ts
@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
+import zlib from "node:zlib";
+import { setZstdCodecForTests } from "../../../../src/lib/zstd.js";
import { InMemoryFs, type FileSystem } from "../../../../src/lib/fs.js";
import { FakeClock } from "../../../../src/lib/clock.js";
import { FakeProcessEnv } from "../../../../src/lib/process.js";
@@ -49,12 +51,23 @@ describe("codex agent", () => {
fs.mkdir("/c/sessions/2026/10/01");
expect(await codexAgent.isInstalled(deps(fs), "/")).toBe(false);
});
- it("is false when sessions holds no rollout-*.jsonl", async () => {
+ it("is false when sessions holds no rollout-*.jsonl or rollout-*.jsonl.zst", async () => {
const fs = new InMemoryFs();
fs.writeFile("/c/sessions/2026/10/01/notes.txt", "x", 1);
- fs.writeFile("/c/sessions/2026/10/01/rollout-a.jsonl.zst", "z", 1);
+ fs.writeFile("/c/sessions/2026/10/01/rollout-a.json", "x", 1);
expect(await codexAgent.isInstalled(deps(fs), "/")).toBe(false);
});
+ it("is true when every rollout is compressed (Codex compresses older ones), with or without zstd", async () => {
+ const fs = new InMemoryFs();
+ fs.writeBytes("/c/sessions/2026/10/01/rollout-a.jsonl.zst", (zlib as unknown as { zstdCompressSync: (b: Buffer) => Buffer }).zstdCompressSync(Buffer.from("{}\n")), 1);
+ expect(await codexAgent.isInstalled(deps(fs), "/")).toBe(true);
+ setZstdCodecForTests(null);
+ try {
+ expect(await codexAgent.isInstalled(deps(fs), "/")).toBe(true);
+ } finally {
+ setZstdCodecForTests(undefined);
+ }
+ });
it("is true when a rollout exists anywhere under sessions", async () => {
const fs = new InMemoryFs();
fs.writeFile("/c/sessions/2026/10/01/rollout-x.jsonl", "", 1);
@@ -66,7 +79,7 @@ describe("codex agent", () => {
expect(await codexAgent.isInstalled(deps(fs), "/")).toBe(true);
const zstOnly = new InMemoryFs();
zstOnly.writeFile("/c/archived_sessions/rollout-x.jsonl.zst", "z", 1);
- expect(await codexAgent.isInstalled(deps(zstOnly), "/")).toBe(false);
+ expect(await codexAgent.isInstalled(deps(zstOnly), "/")).toBe(true);
});
});
@@ -138,7 +151,7 @@ describe("codex agent", () => {
expect(r.ok && r.value.agent).toBe("codex");
});
- it("discoverSessions reports skipped compressed rollouts and unreadable folders", async () => {
+ it("discoverSessions reports skipped compressed rollouts (a Node.js without zstd) and unreadable folders", async () => {
const mem = new InMemoryFs();
mem.writeFile("/c/sessions/2026/10/01/rollout-a.jsonl", rollout("a", "/p", record("r1", 100, 10)), 2);
mem.writeFile("/c/sessions/2026/10/01/rollout-z.jsonl.zst", "z", 1);
@@ -147,13 +160,17 @@ describe("codex agent", () => {
exists: (p) => mem.exists(p),
readFile: (p) => mem.readFile(p),
readHead: (p, n) => mem.readHead(p, n),
+ readBytes: (p) => mem.readBytes(p),
stat: (p) => mem.stat(p),
readDir: async (p) => {
if (p.replace(/\\/g, "/").endsWith("/10/02")) throw new Error("EACCES");
return mem.readDir(p);
},
};
- const r = await loadSessions({ agent: codexAgent, deps: deps(fs), cwd: "/", analyze: (x) => x.parsed });
+ setZstdCodecForTests(null);
+ const r = await loadSessions({ agent: codexAgent, deps: deps(fs), cwd: "/", analyze: (x) => x.parsed }).finally(() =>
+ setZstdCodecForTests(undefined),
+ );
if (!r.ok) throw new Error("load failed");
expect(r.value.sessions.map((s) => s.meta.sessionId)).toEqual(["a"]);
// A folder is not a file: counted apart. Compressed logs are one count, not one issue each.
diff --git a/tests/modules/agents/codex/discover.test.ts b/tests/modules/agents/codex/discover.test.ts
index 38d891f..2c80f4d 100644
--- a/tests/modules/agents/codex/discover.test.ts
+++ b/tests/modules/agents/codex/discover.test.ts
@@ -1,14 +1,18 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it } from "vitest";
+import zlib from "node:zlib";
import { InMemoryFs, type FileSystem } from "../../../../src/lib/fs.js";
import { FakeProcessEnv } from "../../../../src/lib/process.js";
-import { listCodexSessions, resolveCodexHome } from "../../../../src/modules/agents/codex/discover.js";
+import { hasCodexRollout, listCodexSessions, resolveCodexHome } from "../../../../src/modules/agents/codex/discover.js";
+import { setZstdCodecForTests } from "../../../../src/lib/zstd.js";
+
+const zst = (s: string): Buffer => (zlib as unknown as { zstdCompressSync: (b: Buffer) => Buffer }).zstdCompressSync(Buffer.from(s, "utf8"));
const meta = (id: string, cwd: string) => JSON.stringify({ timestamp: "2026-10-01T10:00:00Z", type: "session_meta", payload: { id, session_id: id, cwd, cli_version: "0.160.0" } });
const turn = JSON.stringify({ timestamp: "2026-10-01T10:00:01Z", type: "event_msg", payload: { type: "token_count", info: null } });
/** Wraps a FileSystem, counting full reads and optionally failing some paths. */
function spyFs(base: FileSystem, fail: (p: string, op: string) => boolean = () => false) {
- const calls = { readFile: 0, readHead: 0, headSizes: [] as number[] };
+ const calls = { readFile: 0, readHead: 0, readBytes: 0, headSizes: [] as number[] };
const guard = (p: string, op: string) => {
if (fail(p.replace(/\\/g, "/"), op)) throw Object.assign(new Error(`EACCES: ${p}`), { code: "EACCES" });
};
@@ -25,6 +29,11 @@ function spyFs(base: FileSystem, fail: (p: string, op: string) => boolean = () =
guard(p, "readHead");
return base.readHead(p, n);
},
+ readBytes: async (p) => {
+ calls.readBytes++;
+ guard(p, "readBytes");
+ return base.readBytes(p);
+ },
readDir: async (p) => {
guard(p, "readDir");
return base.readDir(p);
@@ -43,14 +52,15 @@ describe("Codex discovery", () => {
expect(resolveCodexHome(new FakeProcessEnv({ homeDir: "/h" }))).toMatch(/[\\/]h[\\/]\.codex$/);
});
- it("finds rollouts in sessions and archived_sessions, preferring sessions for the same file", async () => {
+ it("finds rollouts in sessions and archived_sessions, preferring sessions for the same file (a Node.js without zstd skips .zst)", async () => {
const fs = new InMemoryFs();
fs.writeFile("/c/sessions/2026/10/01/rollout-2026-10-01T10-00-00-aaa.jsonl", meta("aaa", "C:\\p"), 3);
fs.writeFile("/c/archived_sessions/rollout-2026-10-01T10-00-00-aaa.jsonl", meta("aaa", "C:\\old"), 1);
fs.writeFile("/c/archived_sessions/rollout-2026-09-01T10-00-00-bbb.jsonl", meta("bbb", "C:\\q"), 2);
fs.writeFile("/c/sessions/2026/10/01/rollout-2026-10-01T11-00-00-ccc.jsonl.zst", "binary", 4);
fs.writeFile("/c/sessions/2026/10/01/notes.txt", "x", 5);
- const r = await listCodexSessions(fs, "/c");
+ setZstdCodecForTests(null);
+ const r = await listCodexSessions(fs, "/c").finally(() => setZstdCodecForTests(undefined));
expect(r.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([["aaa", "C:\\p"], ["bbb", "C:\\q"]]);
expect(r.skippedCompressed).toBe(1);
expect(r.unreadable).toBe(0);
@@ -163,3 +173,85 @@ describe("Codex discovery", () => {
]);
});
});
+
+describe("Codex discovery: compressed rollouts (.jsonl.zst)", () => {
+ afterEach(() => setZstdCodecForTests(undefined));
+ const DAY = "/c/sessions/2026/10/01";
+
+ it("lists a compressed rollout with the id and cwd from its line 1, size and mtime from the file", async () => {
+ const mem = new InMemoryFs();
+ const bytes = zst([meta("zzz", "C:\\p"), turn, turn].join("\n") + "\n");
+ mem.writeBytes(`${DAY}/rollout-2026-10-01T10-00-00-zzz.jsonl.zst`, bytes, 9);
+ const { fs, calls } = spyFs(mem);
+ const r = await listCodexSessions(fs, "/c");
+ expect(r.sessions.map((s) => [s.sessionId, s.cwd, s.mtimeMs, s.size, s.cliVersion])).toEqual([
+ ["zzz", "C:\\p", 9, bytes.length, "0.160.0"],
+ ]);
+ expect(r.sessions[0]!.filePath).toMatch(/rollout-2026-10-01T10-00-00-zzz\.jsonl\.zst$/);
+ expect([r.skippedCompressed, r.unreadable]).toEqual([0, 0]);
+ expect(calls.readFile).toBe(0); // line 1 only, decoded by streaming
+ expect(calls.readBytes).toBe(1);
+ });
+
+ it("falls back to the file name without .jsonl.zst when line 1 is not session_meta", async () => {
+ const mem = new InMemoryFs();
+ mem.writeBytes(`${DAY}/rollout-2026-10-01T10-00-00-nometa.jsonl.zst`, zst(turn), 1);
+ const r = await listCodexSessions(mem, "/c");
+ expect(r.sessions.map((s) => s.sessionId)).toEqual(["rollout-2026-10-01T10-00-00-nometa"]);
+ });
+
+ it("reads one log per rollout name: the plain .jsonl over the .zst, in either folder", async () => {
+ const mem = new InMemoryFs();
+ // Same folder: plain wins.
+ mem.writeFile(`${DAY}/rollout-a.jsonl`, meta("a", "/plain"), 5);
+ mem.writeBytes(`${DAY}/rollout-a.jsonl.zst`, zst(meta("a", "/zst")), 4);
+ // Compressed in sessions/, plain in archived_sessions/: plain still wins.
+ mem.writeBytes(`${DAY}/rollout-b.jsonl.zst`, zst(meta("b", "/zst")), 6);
+ mem.writeFile("/c/archived_sessions/rollout-b.jsonl", meta("b", "/archived-plain"), 3);
+ // Both compressed: sessions/ wins, as for plain files.
+ mem.writeBytes(`${DAY}/rollout-c.jsonl.zst`, zst(meta("c", "/sessions")), 2);
+ mem.writeBytes("/c/archived_sessions/rollout-c.jsonl.zst", zst(meta("c", "/archived")), 1);
+ const r = await listCodexSessions(mem, "/c");
+ expect(r.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([
+ ["a", "/plain"],
+ ["b", "/archived-plain"],
+ ["c", "/sessions"],
+ ]);
+ expect([r.skippedCompressed, r.unreadable]).toEqual([0, 0]);
+ });
+
+ it("without zstd, counts only the compressed rollouts it cannot replace with a plain copy", async () => {
+ setZstdCodecForTests(null);
+ const mem = new InMemoryFs();
+ mem.writeFile(`${DAY}/rollout-a.jsonl`, meta("a", "/p"), 5);
+ mem.writeBytes(`${DAY}/rollout-a.jsonl.zst`, zst(meta("a", "/p")), 4);
+ mem.writeBytes(`${DAY}/rollout-b.jsonl.zst`, zst(meta("b", "/p")), 3);
+ mem.writeBytes("/c/archived_sessions/rollout-c.jsonl.zst", zst(meta("c", "/p")), 2);
+ const { fs, calls } = spyFs(mem);
+ const r = await listCodexSessions(fs, "/c");
+ expect(r.sessions.map((s) => s.sessionId)).toEqual(["a"]);
+ expect(r.skippedCompressed).toBe(2);
+ expect(r.unreadable).toBe(0);
+ expect(calls.readBytes).toBe(0); // never opened
+ });
+
+ it("counts a damaged or truncated .zst as unreadable, never a crash", async () => {
+ const mem = new InMemoryFs();
+ const good = zst([meta("ok", "/p"), turn].join("\n"));
+ const long = zst([meta("cut", "/p"), ...Array.from({ length: 2000 }, (_, i) => `{"n":${i},"pad":"${"q".repeat(80)}"}`)].join("\n"));
+ mem.writeBytes(`${DAY}/rollout-ok.jsonl.zst`, good, 3);
+ mem.writeBytes(`${DAY}/rollout-cut.jsonl.zst`, long.subarray(0, long.length - 5), 2);
+ mem.writeBytes(`${DAY}/rollout-junk.jsonl.zst`, Buffer.from(meta("junk", "/p")), 1);
+ mem.writeBytes(`${DAY}/rollout-empty.jsonl.zst`, Buffer.alloc(0), 1);
+ const r = await listCodexSessions(mem, "/c");
+ expect(r.sessions.map((s) => s.sessionId)).toEqual(["ok"]);
+ expect(r.unreadable).toBe(3);
+ expect(r.skippedCompressed).toBe(0);
+ });
+
+ it("hasCodexRollout finds a compressed-only folder", async () => {
+ const mem = new InMemoryFs();
+ mem.writeBytes(`${DAY}/rollout-a.jsonl.zst`, zst(meta("a", "/p")), 1);
+ expect(await hasCodexRollout(mem, "/c/sessions")).toBe(true);
+ });
+});
diff --git a/tests/modules/agents/loadSessions-compressed.test.ts b/tests/modules/agents/loadSessions-compressed.test.ts
new file mode 100644
index 0000000..bd3ae76
--- /dev/null
+++ b/tests/modules/agents/loadSessions-compressed.test.ts
@@ -0,0 +1,123 @@
+/** loadSessions reads Codex's compressed rollouts (.jsonl.zst) through every path: parse, key scan, cache. */
+import { afterEach, describe, expect, it } from "vitest";
+import zlib from "node:zlib";
+import { InMemoryFs } from "../../../src/lib/fs.js";
+import { FakeClock } from "../../../src/lib/clock.js";
+import { FakeProcessEnv } from "../../../src/lib/process.js";
+import { setZstdCodecForTests } from "../../../src/lib/zstd.js";
+import { codexAgent } from "../../../src/modules/agents/codex/adapter.js";
+import { loadSessions } from "../../../src/modules/agents/loadSessions.js";
+import { memCache } from "./memCache.js";
+
+const zst = (s: string): Buffer => (zlib as unknown as { zstdCompressSync: (b: Buffer) => Buffer }).zstdCompressSync(Buffer.from(s, "utf8"));
+const deps = (fs: InMemoryFs) => ({
+ fs,
+ env: new FakeProcessEnv({ homeDir: "/h", vars: { CODEX_HOME: "/c" } }),
+ clock: new FakeClock(new Date("2026-10-08")),
+});
+
+const line = (o: unknown) => JSON.stringify(o);
+const metaLine = (id: string, at: string) => line({ timestamp: at, type: "session_meta", payload: { id, session_id: id, cwd: "/p", cli_version: "0.160.0" } });
+const request = (rid: string, at: string, input: number) => [
+ line({ timestamp: at, type: "turn_context", payload: { turn_id: `t-${rid}`, model: "gpt-5.5" } }),
+ line({
+ timestamp: at,
+ type: "token_usage_record",
+ payload: {
+ turn_id: `t-${rid}`,
+ response_id: rid,
+ usage: { input_tokens: input, cached_input_tokens: 0, cache_write_input_tokens: 0, output_tokens: 10, reasoning_output_tokens: 0, total_tokens: input + 10 },
+ },
+ }),
+];
+const rollout = (id: string, at: string, ...reqs: string[][]) => [metaLine(id, at), ...reqs.flat()].join("\n") + "\n";
+
+const OLD_AT = "2026-10-01T10:00:00Z";
+const NEW_AT = "2026-10-07T10:00:00Z";
+/** The original (old, compressed) and a resumed copy in the window that repeats r1 and adds r2. */
+function resumedHome(compressOriginal: boolean): InMemoryFs {
+ const fs = new InMemoryFs();
+ const original = rollout("orig", OLD_AT, request("r1", OLD_AT, 1000));
+ const resumed = rollout("orig", OLD_AT, request("r1", OLD_AT, 1000), request("r2", NEW_AT, 500));
+ if (compressOriginal) fs.writeBytes("/c/sessions/2026/10/01/rollout-orig.jsonl.zst", zst(original), Date.parse(OLD_AT) + 60_000);
+ else fs.writeFile("/c/sessions/2026/10/01/rollout-orig.jsonl", original, Date.parse(OLD_AT) + 60_000);
+ fs.writeFile("/c/sessions/2026/10/07/rollout-resumed.jsonl", resumed, Date.parse(NEW_AT) + 60_000);
+ return fs;
+}
+
+afterEach(() => setZstdCodecForTests(undefined));
+
+const turns = (fs: InMemoryFs, windowSinceMs?: number) =>
+ loadSessions({
+ agent: codexAgent,
+ deps: deps(fs),
+ cwd: "/",
+ windowSinceMs,
+ analyze: (s) => s.parsed.assistantTurns.map((t) => `${t.requestKey}:${t.inputTokens}`),
+ });
+
+describe("loadSessions with compressed Codex rollouts", () => {
+ it("parses a compressed rollout exactly like the plain one", async () => {
+ const plain = await turns(resumedHome(false));
+ const compressed = await turns(resumedHome(true));
+ if (!plain.ok || !compressed.ok) throw new Error("load failed");
+ expect(compressed.value.sessions.map((s) => s.value)).toEqual(plain.value.sessions.map((s) => s.value));
+ expect(compressed.value.sessions.map((s) => s.value)).toEqual([["r2:500"], ["r1:1000"]]);
+ expect(compressed.value.unreadable).toBe(0);
+ });
+
+ it("key scan: a compressed original outside the window still keeps the requests it holds", async () => {
+ const since = Date.parse("2026-10-05T00:00:00Z");
+ const r = await turns(resumedHome(true), since);
+ if (!r.ok) throw new Error("load failed");
+ // Only the resumed log is returned; r1 belongs to the original (read by key scan only).
+ expect(r.value.sessions.map((s) => s.value)).toEqual([["r2:500"]]);
+ expect(r.value.scannedOnly).toBe(1);
+ expect(r.value.droppedDuplicateRequests).toBe(1);
+ expect(r.value.unreadable).toBe(0);
+ });
+
+ it("usage cache: a compressed rollout is cached and then served without decoding it again", async () => {
+ const cache = memCache();
+ const fs = new InMemoryFs();
+ fs.writeBytes("/c/sessions/2026/10/01/rollout-orig.jsonl.zst", zst(rollout("orig", OLD_AT, request("r1", OLD_AT, 1000))), 5);
+ fs.writeFile("/c/sessions/2026/10/07/rollout-new.jsonl", rollout("new", NEW_AT, request("r2", NEW_AT, 500)), 6);
+ let fullReads = 0;
+ const spy = Object.create(fs, {
+ readBytes: { value: async (p: string) => (p.endsWith(".zst") && fullReads++, fs.readBytes(p)) },
+ }) as InMemoryFs;
+ const run = () =>
+ loadSessions({ agent: codexAgent, deps: deps(spy), cwd: "/", cache, analyze: (s) => s.parsed.assistantTurns.length });
+ const cold = await run();
+ expect(cache.text).toContain("rollout-orig.jsonl.zst");
+ const coldReads = fullReads;
+ const warm = await run();
+ if (!cold.ok || !warm.ok) throw new Error("load failed");
+ expect(warm.value.sessions.map((s) => s.value)).toEqual(cold.value.sessions.map((s) => s.value));
+ // Warm: discovery decodes line 1 only (one readBytes per compressed log), no full read.
+ expect(fullReads - coldReads).toBe(1);
+ });
+
+ it("counts a truncated compressed rollout as unreadable, never a crash", async () => {
+ const fs = new InMemoryFs();
+ const text = rollout("cut", OLD_AT, ...Array.from({ length: 400 }, (_, i) => request(`r${i}`, OLD_AT, 100 + i)));
+ const full = zst(text);
+ // Line 1 decodes (a stream stops at the first newline), the whole file does not.
+ fs.writeBytes("/c/sessions/2026/10/01/rollout-cut.jsonl.zst", full.subarray(0, full.length - 2), 1);
+ fs.writeFile("/c/sessions/2026/10/02/rollout-ok.jsonl", rollout("ok", OLD_AT, request("x", OLD_AT, 5)), 2);
+ const r = await turns(fs);
+ if (!r.ok) throw new Error("load failed");
+ expect(r.value.sessions.map((s) => s.meta.sessionId)).toEqual(["ok"]);
+ expect(r.value.unreadable).toBe(1);
+ expect(r.value.issues).toEqual([]);
+ });
+
+ it("without zstd: the compressed original is skipped and counted, the plain log still loads", async () => {
+ setZstdCodecForTests(null);
+ const r = await turns(resumedHome(true));
+ if (!r.ok) throw new Error("load failed");
+ expect(r.value.sessions.map((s) => s.value)).toEqual([["r1:1000", "r2:500"]]);
+ expect(r.value.skippedCompressed).toBe(1);
+ expect(r.value.unreadable).toBe(0);
+ });
+});
diff --git a/tests/modules/agents/loadSessions.test.ts b/tests/modules/agents/loadSessions.test.ts
index c78bf6b..9fdd5f6 100644
--- a/tests/modules/agents/loadSessions.test.ts
+++ b/tests/modules/agents/loadSessions.test.ts
@@ -89,6 +89,7 @@ describe("loadSessions", () => {
const failingFs: FileSystem = {
exists: (p) => base.exists(p),
readHead: (p, n) => base.readHead(p, n),
+ readBytes: (p) => base.readBytes(p),
readDir: (p) => base.readDir(p),
stat: (p) => base.stat(p),
readFile: async (p) => {
@@ -168,6 +169,7 @@ describe("loadSessions", () => {
const failingFs: FileSystem = {
exists: (p) => base.exists(p),
readHead: (p, n) => base.readHead(p, n),
+ readBytes: (p) => base.readBytes(p),
readDir: (p) => base.readDir(p),
stat: (p) => base.stat(p),
readFile: async (p) => {
diff --git a/tests/modules/regress/format.test.ts b/tests/modules/regress/format.test.ts
index ace32e7..981987f 100644
--- a/tests/modules/regress/format.test.ts
+++ b/tests/modules/regress/format.test.ts
@@ -54,7 +54,9 @@ describe("formatRegressTerminal", () => {
it("names compressed logs it skipped", () => {
const out = formatRegressTerminal(report({ access: { ...NO_ACCESS_ISSUES, skippedCompressed: 2 } }), OPTS);
- expect(out).toContain("note: 2 compressed Codex log(s) (.jsonl.zst) skipped: Sipcode cannot read compressed logs yet.");
+ expect(out).toContain(
+ `note: 2 compressed Codex log(s) (.jsonl.zst) skipped: reading them needs Node.js 22.15 or newer (you have ${process.version}).`,
+ );
});
});
diff --git a/tests/modules/transcript/discover-counts.test.ts b/tests/modules/transcript/discover-counts.test.ts
index 4fe77a6..5b14f84 100644
--- a/tests/modules/transcript/discover-counts.test.ts
+++ b/tests/modules/transcript/discover-counts.test.ts
@@ -12,6 +12,7 @@ function failing(base: InMemoryFs, fail: (p: string, op: string) => boolean): Fi
exists: (p) => base.exists(p),
readFile: async (p) => (guard(p, "readFile"), base.readFile(p)),
readHead: async (p, n) => (guard(p, "readHead"), base.readHead(p, n)),
+ readBytes: async (p) => (guard(p, "readBytes"), base.readBytes(p)),
readDir: async (p) => (guard(p, "readDir"), base.readDir(p)),
stat: async (p) => (guard(p, "stat"), base.stat(p)),
};
From e04de7ce6c12c735e3f103c53913bb6609c9f64f Mon Sep 17 00:00:00 2001
From: Anuj7411
Date: Thu, 15 Oct 2026 19:36:13 +0530
Subject: [PATCH 3/9] fix(regress): --bug-report always answers on stdout;
skill allows a long first run
Investigated Codex reporting that `sipcode regress --bug-report` returned
no output. Sipcode itself always prints the result to stdout and exits
0 (or 2 when nothing could be read), with nothing on stderr. Two causes
were found from the Codex session's tool-call metadata:
- With no finding, --bug-report printed exactly the plain result and no
word about the bug report it was asked for. It now ends with: No bug
report: there is no finding to report. (every state: no regression,
not enough data, nothing read).
- Codex's script runner stopped the command after about 10 s with empty
output. A cold run over large Claude Code logs takes much longer (49 s
measured on 5.4 GB; 1 s warm), and a sandbox that cannot write
~/.sipcode never warms the cache. The regress skill now asks for a
timeout of at least 3 minutes and never to report empty output.
Co-Authored-By: Claude Opus 5.5
---
.../sipcode/skills/sipcode-regress/SKILL.md | 2 ++
src/lib/messages.ts | 3 ++
src/modules/regress/format-terminal.ts | 14 +++++++--
tests/integration/regress.integration.test.ts | 29 +++++++++++++++++++
tests/modules/regress/format.test.ts | 18 ++++++++++++
tests/plugin/codex-plugin.test.ts | 8 +++++
6 files changed, 71 insertions(+), 3 deletions(-)
diff --git a/plugins/sipcode/skills/sipcode-regress/SKILL.md b/plugins/sipcode/skills/sipcode-regress/SKILL.md
index 6f62bb7..1d5a43e 100644
--- a/plugins/sipcode/skills/sipcode-regress/SKILL.md
+++ b/plugins/sipcode/skills/sipcode-regress/SKILL.md
@@ -17,6 +17,8 @@ Answer "is it the tool or is it me?" from the user's own logs on this computer,
```
It reads `~/.claude/projects` and `~/.codex` and compares each version with the user's other versions. If the user asked only about Codex, run `sipcode regress --bug-report --agent codex`; only about Claude Code, `sipcode regress --bug-report --agent claude-code`. Use no other options.
+
+ On large logs it can take a minute or more. Give the command a timeout of at least 3 minutes. A run cut off by a timeout prints nothing, and empty output is never a result: if the command returns no output at all, run it again with a longer timeout before answering.
3. If the sandbox blocks a log folder (the output says "Not found or not readable", prints a WARNING that files could not be read, or the command exits with code 2), request permission to run the same command again with read access to those folders, for example by running it outside the sandbox. Do not report a result from a blocked run.
4. Never open, read, search or summarise the log files yourself, and never paste their contents. Work only from Sipcode's output.
diff --git a/src/lib/messages.ts b/src/lib/messages.ts
index 29f4192..40bbfcf 100644
--- a/src/lib/messages.ts
+++ b/src/lib/messages.ts
@@ -293,6 +293,9 @@ export const MESSAGES = {
regressNoFindings: "No regressions detected in your logs.",
+ /** regress --bug-report with no finding: say why no bug report follows (never a silent no-op). */
+ regressNoBugReport: "No bug report: there is no finding to report.",
+
/** What each tool needs before its versions can be compared (regress). */
regressNeeds: (agent: "claude-code" | "codex", minN: number) =>
agent === "claude-code"
diff --git a/src/modules/regress/format-terminal.ts b/src/modules/regress/format-terminal.ts
index bd2135b..796a3ba 100644
--- a/src/modules/regress/format-terminal.ts
+++ b/src/modules/regress/format-terminal.ts
@@ -45,6 +45,14 @@ export function formatRegressTerminal(
report: RegressReport,
opts: { readonly bugReport: boolean; readonly version: string },
): string {
+ const lines = body(report, opts);
+ // --bug-report with nothing to report still answers the flag: a run that only
+ // repeated the plain result read as "no output" to Codex.
+ if (opts.bugReport && report.findings.length === 0) lines.push("", MESSAGES.regressNoBugReport);
+ return lines.join("\n");
+}
+
+function body(report: RegressReport, opts: { readonly bugReport: boolean; readonly version: string }): string[] {
const lines = ["sipcode regress · local only, no network"];
if (report.claude) {
lines.push(claudeLine(report));
@@ -60,11 +68,11 @@ export function formatRegressTerminal(
lines.push("");
if (a.nothingRead) {
lines.push(MESSAGES.regressNothingToAnalyse);
- return lines.join("\n");
+ return lines;
}
if (report.findings.length === 0 && !report.comparable) {
lines.push(MESSAGES.regressNotEnoughData(report.minN, report.agents));
- return lines.join("\n");
+ return lines;
}
if (report.findings.length === 0) lines.push(MESSAGES.regressNoFindings);
for (const f of report.findings) lines.push(...findingLines(f));
@@ -74,5 +82,5 @@ export function formatRegressTerminal(
if (opts.bugReport && report.findings.length > 0) {
lines.push("", "---- paste into a GitHub issue (review it first) ----", "", formatBugReport(report, opts.version)!);
}
- return lines.join("\n");
+ return lines;
}
diff --git a/tests/integration/regress.integration.test.ts b/tests/integration/regress.integration.test.ts
index c93bb04..520ac7a 100644
--- a/tests/integration/regress.integration.test.ts
+++ b/tests/integration/regress.integration.test.ts
@@ -4,6 +4,7 @@ import { runRegress } from "../../src/commands/regress.js";
import { InMemoryFs, type FileSystem } from "../../src/lib/fs.js";
import { FakeClock } from "../../src/lib/clock.js";
import { FakeProcessEnv } from "../../src/lib/process.js";
+import { MESSAGES } from "../../src/lib/messages.js";
import {
addClaudeDataset,
addClaudeSession,
@@ -349,3 +350,31 @@ describe("sipcode regress: one tool compared, the other not (review fix)", () =>
expect(r.out).toContain("Nothing to analyse: no interactive Claude Code requests and no Codex requests could be read. This is NOT a clean result.");
});
});
+
+describe("sipcode regress --bug-report: the answer is always on stdout (Codex showed a run as 'no output')", () => {
+ const states: Array<[string, () => InMemoryFs, 0 | 2]> = [
+ ["no regression", () => dataset(false), 0],
+ ["not enough data", () => {
+ const fs = new InMemoryFs();
+ addCodexRollout(fs, "0199a000-0000-7000-8000-00000000000a", "0.140.0", 0, 2);
+ return fs;
+ }, 0],
+ ["nothing read", () => new InMemoryFs(), 2],
+ ];
+ for (const [name, make, code] of states) {
+ it(`${name}: exit ${code}, result and the no-bug-report line on stdout, nothing on stderr`, async () => {
+ const r = await run({ bugReport: true }, make());
+ expect(r.exitCode).toBe(code);
+ expect(r.err).toBe("");
+ expect(r.out.split("\n")[0]).toBe("sipcode regress · local only, no network");
+ expect(r.out.split("\n").at(-1)).toBe(MESSAGES.regressNoBugReport);
+ });
+ }
+
+ it("with a finding: the bug report follows on stdout, without the no-bug-report line", async () => {
+ const r = await run({ bugReport: true }, dataset(true));
+ expect(r.exitCode).toBe(0);
+ expect(r.out).toContain("---- paste into a GitHub issue (review it first) ----");
+ expect(r.out).not.toContain(MESSAGES.regressNoBugReport);
+ });
+});
diff --git a/tests/modules/regress/format.test.ts b/tests/modules/regress/format.test.ts
index 981987f..9b70aaa 100644
--- a/tests/modules/regress/format.test.ts
+++ b/tests/modules/regress/format.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { MESSAGES } from "../../../src/lib/messages.js";
import { formatBugReport } from "../../../src/modules/regress/bug-report.js";
import { formatRegressJson } from "../../../src/modules/regress/format-json.js";
import { formatRegressTerminal } from "../../../src/modules/regress/format-terminal.js";
@@ -58,6 +59,23 @@ describe("formatRegressTerminal", () => {
`note: 2 compressed Codex log(s) (.jsonl.zst) skipped: reading them needs Node.js 22.15 or newer (you have ${process.version}).`,
);
});
+
+ // Codex reported "no output" for `regress --bug-report` with no findings: the
+ // flag was silently ignored. Every state now says why there is no bug report.
+ it("--bug-report without findings says so on its own line, in every state", () => {
+ const BR = { ...OPTS, bugReport: true };
+ const clean = report({ claude: [file("2.1.60", healthy(30)), file("2.1.61", healthy(30), 1)] });
+ expect(clean.findings).toEqual([]);
+ const notEnough = report({ claude: [file("2.1.60", healthy(5))] });
+ const nothing = report({ claude: [], access: { ...NO_ACCESS_ISSUES, missingDirs: ["/h/.claude/projects"] } });
+ for (const r of [clean, notEnough, nothing]) {
+ const out = formatRegressTerminal(r, BR);
+ expect(out.split("\n").at(-1)).toBe(MESSAGES.regressNoBugReport);
+ expect(formatRegressTerminal(r, OPTS)).not.toContain(MESSAGES.regressNoBugReport);
+ }
+ expect(formatRegressTerminal(report(), BR)).not.toContain(MESSAGES.regressNoBugReport);
+ expect(formatRegressTerminal(report(), BR)).toContain("paste into a GitHub issue");
+ });
});
describe("formatRegressJson", () => {
diff --git a/tests/plugin/codex-plugin.test.ts b/tests/plugin/codex-plugin.test.ts
index 3fc7dce..d8f74c1 100644
--- a/tests/plugin/codex-plugin.test.ts
+++ b/tests/plugin/codex-plugin.test.ts
@@ -261,6 +261,14 @@ describe("Codex plugin: skills", () => {
expect(skillText("sipcode-regress")).toContain("Quote numbers exactly as printed");
});
+ it("regress: a long first run gets a long timeout, and empty output is never reported as a result", () => {
+ // Codex's script runner killed `regress --bug-report` after about 10 s with no output (a cold run over
+ // large Claude Code logs takes far longer).
+ const t = skillText("sipcode-regress");
+ expect(t).toContain("Give the command a timeout of at least 3 minutes.");
+ expect(t).toContain("empty output is never a result");
+ });
+
it("copy rules: no em or en dashes, no 'stops hallucinations', 'nerfed' only in a prohibition", () => {
const texts = [...SKILLS.map(skillText), JSON.stringify(manifest), JSON.stringify(overlay), JSON.stringify(marketplace)];
for (const t of texts) {
From c5c0270191e6dc745adbcbb9f5c5abf9d572b183 Mon Sep 17 00:00:00 2001
From: Anuj7411
Date: Thu, 15 Oct 2026 19:42:03 +0530
Subject: [PATCH 4/9] chore(verify-counts): read compressed Codex rollouts
(.jsonl.zst) independently
The independent counter skipped .jsonl.zst like Sipcode did, so it would
have agreed with a Sipcode that misses most Codex history. It now walks
the zstd frames itself and decodes each with node:zlib (Node decodes
only the first frame), checks each frame's declared size, and picks one
log per rollout name (the plain .jsonl when both exist). It prints how
many rollouts were compressed.
On a frozen copy of the maintainer's logs: 17 Codex logs (15
compressed), 89,451,779 tokens, $56.66, all checks match, exit 0.
Co-Authored-By: Claude Opus 5.5
---
scripts/verify-counts.mjs | 73 +++++++++++++++++++++++++++++++++++----
1 file changed, 67 insertions(+), 6 deletions(-)
diff --git a/scripts/verify-counts.mjs b/scripts/verify-counts.mjs
index 602f217..1b97966 100644
--- a/scripts/verify-counts.mjs
+++ b/scripts/verify-counts.mjs
@@ -49,6 +49,13 @@
// the percent its requests were charged rise by rise (by tokens), per 1M
// uncached input + output tokens; groups of 200,000+ such tokens; flagged
// at 2x the upper median of the others on the same plan, limit and window.
+// - Codex compresses older rollouts to rollout-*.jsonl.zst. They are decoded
+// here with node:zlib directly, frame by frame (Node's decoder stops after
+// the first frame), and each frame must be complete. One log per rollout
+// name: the plain .jsonl when both exist, else sessions/ before
+// archived_sessions/. Without zstd in this Node the script stops (it would
+// otherwise agree with a Sipcode that skips them).
+import { zstdDecompressSync } from "node:zlib";
import { readFileSync, readdirSync, statSync, existsSync, mkdtempSync, rmSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir, tmpdir } from "node:os";
@@ -390,7 +397,57 @@ function readCodexFile(text) {
};
}
-/** Rollout files by name; sessions/ is walked first and wins over archived_sessions/. */
+/**
+ * Decode a .zst rollout: split into frames by walking their headers (RFC 8878
+ * 3.1), decode each, check each decoded size against its header, concatenate.
+ * Throws on anything incomplete.
+ */
+function decodeZst(buf) {
+ if (typeof zstdDecompressSync !== "function") {
+ throw new Error(`compressed Codex logs need Node.js 22.15 or newer to verify (this is ${process.version})`);
+ }
+ const parts = [];
+ let p = 0;
+ while (p < buf.length) {
+ const magic = buf.readUInt32LE(p);
+ if (((magic & 0xfffffff0) >>> 0) === 0x184d2a50) {
+ p += 8 + buf.readUInt32LE(p + 4);
+ continue;
+ }
+ if (magic !== 0xfd2fb528) throw new Error("not a zstd frame");
+ const start = p;
+ const fhd = buf[p + 4];
+ const single = (fhd >> 5) & 1;
+ const fcsLen = [single, 2, 4, 8][fhd >> 6];
+ p += 5 + (single ? 0 : 1) + [0, 1, 2, 4][fhd & 3];
+ const fcs =
+ fcsLen === 1 ? buf[p] : fcsLen === 2 ? buf.readUInt16LE(p) + 256 : fcsLen === 4 ? buf.readUInt32LE(p) : fcsLen === 8 ? Number(buf.readBigUInt64LE(p)) : undefined;
+ p += fcsLen;
+ for (let last = 0; !last; ) {
+ if (p + 3 > buf.length) throw new Error("truncated zstd block");
+ const h = buf[p] | (buf[p + 1] << 8) | (buf[p + 2] << 16);
+ last = h & 1;
+ p += 3 + (((h >> 1) & 3) === 1 ? 1 : h >>> 3);
+ }
+ if ((fhd >> 2) & 1) p += 4;
+ if (p > buf.length) throw new Error("truncated zstd frame");
+ const out = zstdDecompressSync(buf.subarray(start, p));
+ if (fcs !== undefined && out.length !== fcs) throw new Error("zstd frame decoded to the wrong size");
+ parts.push(out);
+ }
+ return Buffer.concat(parts).toString("utf8");
+}
+
+/** A rollout's text, plain or compressed. */
+function readRollout(p) {
+ return p.endsWith(".zst") ? decodeZst(readFileSync(p)) : readFileSync(p, "utf8");
+}
+
+/**
+ * Rollout files by plain name (X.jsonl for X.jsonl and X.jsonl.zst): the
+ * plain file wins over a compressed one; otherwise sessions/ is walked first
+ * and wins over archived_sessions/.
+ */
function listRollouts() {
const byName = new Map();
for (const sub of ["sessions", "archived_sessions"]) {
@@ -404,8 +461,11 @@ function listRollouts() {
for (const e of entries) {
const p = join(dir, e.name);
if (e.isDirectory()) walk(p);
- else if (e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl") && !byName.has(e.name))
- byName.set(e.name, p);
+ else if (e.isFile() && e.name.startsWith("rollout-") && (e.name.endsWith(".jsonl") || e.name.endsWith(".jsonl.zst"))) {
+ const name = e.name.replace(/\.zst$/, "");
+ const kept = byName.get(name);
+ if (!kept || (kept.endsWith(".zst") && !p.endsWith(".zst"))) byName.set(name, p);
+ }
}
};
walk(join(CODEX_HOME, sub));
@@ -418,8 +478,8 @@ function readCodex() {
const out = [];
for (const [name, p] of byName) {
const st = statSync(p);
- const f = readCodexFile(readFileSync(p, "utf8"));
- out.push({ ...f, id: f.metaId ?? name.slice(0, -6), label: name.slice(8, 27), mtimeMs: st.mtimeMs });
+ const f = readCodexFile(readRollout(p));
+ out.push({ ...f, id: f.metaId ?? name.slice(0, -6), label: name.slice(8, 27), mtimeMs: st.mtimeMs, compressed: p.endsWith(".zst") });
}
return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
}
@@ -835,7 +895,7 @@ function limitReadings() {
const out = [];
let malformed = 0;
for (const p of listRollouts().values()) {
- for (const raw of readFileSync(p, "utf8").split(/\r?\n/)) {
+ for (const raw of readRollout(p).split(/\r?\n/)) {
if (!raw.includes('"rate_limits"')) continue;
let e;
try {
@@ -1363,6 +1423,7 @@ function main() {
`${s.unpricedTokens.toLocaleString("en-US")} unpriced tokens, ${s.synthetic} synthetic requests with usage`,
);
}
+ console.log(`codex: ${codex.filter((f) => f.compressed).length} of ${codex.length} rollout files compressed (.jsonl.zst)`);
// Codex ground truth: per file, the counted tokens equal Codex's own final cumulative total.
codex
From 45769322c0d6c7a8a80f5ac75345b0686e9cccf6 Mon Sep 17 00:00:00 2001
From: Anuj7411
Date: Thu, 15 Oct 2026 19:45:03 +0530
Subject: [PATCH 5/9] release: v1.7.5 (reads compressed Codex logs)
Version 1.7.5 in package.json, package-lock.json, server.json (both
fields), the plugin manifests, the site Hero and Footer, llms.txt.
CHANGELOG [1.7.5]. README and llms-full.txt: Node.js 22.15 or newer
reads the session logs Codex compresses. Test count 2,225 to 2,275
(npx vitest run, e2e included).
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 22 ++++++++++++++++++++--
README.md | 10 ++++++----
docs/site/public/llms-full.txt | 6 +++---
docs/site/public/llms.txt | 6 +++---
docs/site/src/components/Footer.astro | 4 ++--
docs/site/src/components/Hero.astro | 6 +++---
package-lock.json | 4 ++--
package.json | 2 +-
plugins/sipcode/.codex-plugin/plugin.json | 2 +-
plugins/sipcode/plugin.json | 2 +-
server.json | 4 ++--
11 files changed, 44 insertions(+), 24 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6caaac5..f1b6029 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,24 @@ This log starts at v1.6.5 (the reliability-pillar repositioning). Earlier histor
## [Unreleased]
-_Nothing landed since [1.7.4]._
+_Nothing landed since [1.7.5]._
+
+## [1.7.5] - 2026-10-15
+
+Codex now compresses its older session logs, and Sipcode reads them.
+
+### Fixed
+- **Compressed Codex logs are read.** Current Codex compresses older session logs to `rollout-*.jsonl.zst` and keeps only the newest ones as plain `.jsonl`. Sipcode skipped the compressed ones, so it saw almost none of a Codex user's history: `forecast` said it needed more days, `limits` started again at 0%, and `stats`, `why`, `today`, `trend`, `impact` and `regress` missed most sessions. Sipcode now reads them exactly like plain logs, on Node.js 22.15 or newer (zstd ships in Node's own zlib, so there is no new dependency and still no network). A session with both a plain and a compressed log is read once, from the plain log. A damaged or cut-off compressed log is counted as unreadable, never a crash.
+- **Older Node.js: a clear note.** On a Node.js without zstd the compressed logs are still counted, with the note "N compressed Codex log(s) (.jsonl.zst) skipped: reading them needs Node.js 22.15 or newer (you have vX)." JSON output is unchanged.
+- **`regress --bug-report` with nothing to report says so.** With no finding, the flag used to print only the plain result, which Codex read as "no output". It now ends with "No bug report: there is no finding to report." The answer was and is always on stdout.
+
+### Changed
+- **Codex plugin: `regress` gets time.** A first `regress` over large Claude Code logs can take a minute or more (49 seconds on 5.4 GB on the maintainer's machine; about 1 second once the usage cache is warm), and Codex stopped the command after about 10 seconds with empty output. The regress skill now gives the command a timeout of at least 3 minutes and never reports empty output as a result.
+- **README:** Node.js 22.15 or newer is needed to read compressed Codex logs.
+
+### Notes
+- `scripts/verify-counts.mjs` (the independent counter) reads `.jsonl.zst` too, decoding it with `node:zlib` itself, so it cannot agree with a Sipcode that skips them. On the maintainer's frozen logs it matches Sipcode on all 17 Codex logs (15 of them compressed).
+- Test count: 2,225 to 2,275 (`npx vitest run`, which includes the 20 e2e tests).
## [1.7.4] - 2026-10-14
@@ -405,7 +422,8 @@ This release rolls v1.6.9's B3 work (bumped but never published to npm) together
---
-[Unreleased]: https://github.com/Anuj7411/sipcode/compare/v1.7.4...HEAD
+[Unreleased]: https://github.com/Anuj7411/sipcode/compare/v1.7.5...HEAD
+[1.7.5]: https://github.com/Anuj7411/sipcode/compare/v1.7.4...v1.7.5
[1.7.4]: https://github.com/Anuj7411/sipcode/compare/v1.7.3...v1.7.4
[1.7.3]: https://github.com/Anuj7411/sipcode/compare/v1.7.2...v1.7.3
[1.7.2]: https://github.com/Anuj7411/sipcode/compare/v1.7.1...v1.7.2
diff --git a/README.md b/README.md
index be5280c..ea68bd9 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
-
+
@@ -164,7 +164,9 @@ Sipcode also reads OpenAI Codex CLI session logs (`~/.codex/sessions` and `~/.co
**Prices.** Codex models are priced from OpenAI's API price table ([developers.openai.com/api/docs/pricing](https://developers.openai.com/api/docs/pricing), and each Codex model's page under developers.openai.com/api/docs/models, as of 2026-10-09), including the long-context rate for prompts over 272,000 input tokens. A model with no known price is shown as unpriced: its tokens are still counted, a line under the cost says how many were left out of it, and it is never shown as $0.
-**Not supported for Codex yet:** writing rules to `AGENTS.md` (`rules`, and the rules step of `init`), registering the MCP server in Codex, the proxy and hooks (`proxy`, `hygiene`), `estimate` and `benchmark`. `rules --agent codex` and `estimate --agent codex` stop with error E009 instead of showing Claude Code data. Compressed Codex logs (`.jsonl.zst`) are skipped, and a note says how many.
+**Not supported for Codex yet:** writing rules to `AGENTS.md` (`rules`, and the rules step of `init`), registering the MCP server in Codex, the proxy and hooks (`proxy`, `hygiene`), `estimate` and `benchmark`. `rules --agent codex` and `estimate --agent codex` stop with error E009 instead of showing Claude Code data.
+
+**Compressed Codex logs.** Codex compresses older session logs to `.jsonl.zst`. Sipcode reads them exactly like plain logs on Node.js 22.15 or newer. On an older Node.js it skips them and a note says how many, and that reading them needs Node.js 22.15 or newer.
### Codex limits: `sipcode limits`
@@ -286,7 +288,7 @@ OpenAI's docs do not describe a button in the desktop app for adding a marketpla
## How to install (more detail)
-Sipcode works on Mac, Linux, and Windows. You need Node.js 20 or newer.
+Sipcode works on Mac, Linux, and Windows. You need Node.js 20 or newer, and Node.js 22.15 or newer to read the session logs Codex compresses (`.jsonl.zst`).
### Step 1. Install Node.js
@@ -487,7 +489,7 @@ Run any of them with `--help` for full options.
## Requirements
-- Node.js 20 or newer
+- Node.js 20 or newer. Node.js 22.15 or newer to read the session logs Codex compresses (`.jsonl.zst`); on an older Node.js they are skipped with a note.
- Claude Code installed (for the proxy hook). Sipcode also works as standalone CLI tools (benchmark, score, etc.) without Claude Code installed.
- Optional: OpenAI Codex CLI, for Codex spend analytics.
diff --git a/docs/site/public/llms-full.txt b/docs/site/public/llms-full.txt
index f415c3b..39f7da1 100644
--- a/docs/site/public/llms-full.txt
+++ b/docs/site/public/llms-full.txt
@@ -8,7 +8,7 @@ On a locked, public, synthetic 20-task benchmark corpus (scripted transcript pai
Sipcode also reads OpenAI Codex CLI session logs for spend analytics. `stats`, `today`, `forecast`, `trend`, `impact`, `why`, `receipt` and `drift` accept `--agent codex`; with both tools installed they show one section per tool. Codex models are priced from OpenAI's API price table as of 2026-10-08, and a model with no known price is shown as unpriced, never as $0. The proxy, hooks and rules are not supported for Codex yet. `sipcode limits` reads the usage-limit readings Codex writes into its logs: percent used per limit window, when it runs out at the current pace, which sessions used it, and how many tokens 1% bought over time, with a cautious possible-limit-cut flag.
-Sipcode is solo-maintained, MIT licensed, has 2,225 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop.
+Sipcode is solo-maintained, MIT licensed, has 2,275 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop.
## How Sipcode differs from neighboring tools
@@ -126,7 +126,7 @@ projected month-end $17,674 (range $14,139 to $21,208)
## How to install (more detail)
-Sipcode works on Mac, Linux, and Windows. Node.js 20 or newer is required.
+Sipcode works on Mac, Linux, and Windows. Node.js 20 or newer is required, and Node.js 22.15 or newer to read the session logs Codex compresses (`.jsonl.zst`).
### Step 1. Install Node.js
@@ -229,7 +229,7 @@ npm uninstall -g sipcode
## Requirements
-- Node.js 20 or newer
+- Node.js 20 or newer. Node.js 22.15 or newer to read the session logs Codex compresses (`.jsonl.zst`); on an older Node.js they are skipped with a note.
- Claude Code installed (for the proxy hook). Sipcode also works as standalone CLI tools (benchmark, score, etc.) without Claude Code installed.
## License
diff --git a/docs/site/public/llms.txt b/docs/site/public/llms.txt
index 52fb6a1..3d22693 100644
--- a/docs/site/public/llms.txt
+++ b/docs/site/public/llms.txt
@@ -8,7 +8,7 @@ On a locked, public, synthetic 20-task benchmark corpus (scripted transcript pai
Sipcode also reads OpenAI Codex CLI session logs for spend analytics. `stats`, `today`, `forecast`, `trend`, `impact`, `why`, `receipt` and `drift` accept `--agent codex`; with both tools installed they show one section per tool. Codex models are priced from OpenAI's API price table as of 2026-10-08, and a model with no known price is shown as unpriced, never as $0. The proxy, hooks and rules are not supported for Codex yet. `sipcode limits` reads the usage-limit readings Codex writes into its logs: percent used per limit window, when it runs out at the current pace, which sessions used it, and how many tokens 1% bought over time, with a cautious possible-limit-cut flag. `sipcode regress` checks the user's own Claude Code and Codex logs for regressions after an update (prompt-cache misses, 5-minute cache TTL, uncached context, hourly cache rebuilds, Codex limit draining faster per token) and pins each one to the version, comparing each version with the user's other versions. A Sipcode plugin for Codex (`codex plugin marketplace add Anuj7411/sipcode`) lets users ask about spend, limits and regressions in plain words; it runs the installed `sipcode` CLI.
-Sipcode is solo-maintained, MIT licensed, has 2,225 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop.
+Sipcode is solo-maintained, MIT licensed, has 2,275 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop.
## How Sipcode differs from neighboring tools
@@ -57,8 +57,8 @@ Sipcode ships an MCP server exposing 16 tools so Claude Code can introspect its
## Source code
-- [GitHub repository](https://github.com/Anuj7411/sipcode): MIT licensed source, 2,225 tests
-- [npm package](https://www.npmjs.com/package/sipcode): `sipcode` on npm, current version 1.7.4
+- [GitHub repository](https://github.com/Anuj7411/sipcode): MIT licensed source, 2,275 tests
+- [npm package](https://www.npmjs.com/package/sipcode): `sipcode` on npm, current version 1.7.5
- [Privacy test](https://github.com/Anuj7411/sipcode/blob/main/src): build-blocking check that no network modules are imported in `src/`
## License
diff --git a/docs/site/src/components/Footer.astro b/docs/site/src/components/Footer.astro
index f939024..49c28ee 100644
--- a/docs/site/src/components/Footer.astro
+++ b/docs/site/src/components/Footer.astro
@@ -25,11 +25,11 @@
- v1.7.4
+ v1.7.5
- 2,225 tests passing
+ 2,275 tests passing
@@ -19,7 +19,7 @@ const TEST_COUNT = 2225;
- v1.7.4 · MIT licensed
+ v1.7.5 · MIT licensed
{TEST_COUNT.toLocaleString()} tests passing
diff --git a/package-lock.json b/package-lock.json
index 46c7bf3..1977331 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sipcode",
- "version": "1.7.4",
+ "version": "1.7.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sipcode",
- "version": "1.7.4",
+ "version": "1.7.5",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
diff --git a/package.json b/package.json
index 35fa619..b46b1cf 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "sipcode",
- "version": "1.7.4",
+ "version": "1.7.5",
"mcpName": "io.github.Anuj7411/sipcode",
"description": "Sip your tokens, don't gulp them. Keep Claude Code's context clean: drift detection, re-read dedup, integrity scoring, AST-aware reads, and 16 MCP tools for Claude Desktop.",
"keywords": [
diff --git a/plugins/sipcode/.codex-plugin/plugin.json b/plugins/sipcode/.codex-plugin/plugin.json
index 9a17842..f6990aa 100644
--- a/plugins/sipcode/.codex-plugin/plugin.json
+++ b/plugins/sipcode/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "sipcode",
- "version": "1.7.4",
+ "version": "1.7.5",
"description": "Ask about your Codex usage in plain words: spend, usage limits, why a session was expensive, and whether Codex or Claude Code got worse after an update. Runs the Sipcode CLI on this computer, which reads your local Codex and Claude Code logs and makes no network calls.",
"author": {
"name": "Anuj Ojha",
diff --git a/plugins/sipcode/plugin.json b/plugins/sipcode/plugin.json
index 747bea8..d8fd9b3 100644
--- a/plugins/sipcode/plugin.json
+++ b/plugins/sipcode/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "sipcode",
- "version": "1.7.4",
+ "version": "1.7.5",
"description": "Ask about your Codex usage in plain words: spend, usage limits, why a session was expensive, and whether Codex or Claude Code got worse after an update. Runs the Sipcode CLI on this computer, which reads your local Codex and Claude Code logs and makes no network calls.",
"author": {
"name": "Anuj Ojha",
diff --git a/server.json b/server.json
index b400d79..895fa86 100644
--- a/server.json
+++ b/server.json
@@ -7,12 +7,12 @@
"url": "https://github.com/Anuj7411/sipcode",
"source": "github"
},
- "version": "1.7.4",
+ "version": "1.7.5",
"packages": [
{
"registryType": "npm",
"identifier": "sipcode",
- "version": "1.7.4",
+ "version": "1.7.5",
"runtimeHint": "npx",
"transport": {
"type": "stdio"
From ff418f037a5e513b899b0e0736057ffbfc07f05f Mon Sep 17 00:00:00 2001
From: Anuj7411
Date: Thu, 15 Oct 2026 19:56:02 +0530
Subject: [PATCH 6/9] fix(plugin): regress skill checks the Codex logs by
default
Inside Codex, the script runner stopped `sipcode regress --bug-report`
after about 10 s. A cold run over every Claude Code log takes about 49 s
(5.4 GB measured), and a sandbox that cannot write ~/.sipcode never warms
the usage cache, so the default run could never finish there.
The sipcode-regress skill now runs `sipcode regress --bug-report --agent
codex` (Codex logs only, about 1 s) when the user asks about Codex or
names no tool. The Claude Code check (`--agent claude-code`, or no
--agent for both tools) runs only when the user explicitly asks about
Claude Code; the skill then says the first run can take a minute or more
and keeps the 3-minute timeout. Every other rule is unchanged.
Co-Authored-By: Claude Opus 5.5
---
plugins/sipcode/skills/sipcode-regress/SKILL.md | 12 +++++++-----
tests/plugin/codex-plugin.test.ts | 16 ++++++++++++++++
2 files changed, 23 insertions(+), 5 deletions(-)
diff --git a/plugins/sipcode/skills/sipcode-regress/SKILL.md b/plugins/sipcode/skills/sipcode-regress/SKILL.md
index 1d5a43e..d98132f 100644
--- a/plugins/sipcode/skills/sipcode-regress/SKILL.md
+++ b/plugins/sipcode/skills/sipcode-regress/SKILL.md
@@ -10,21 +10,23 @@ Answer "is it the tool or is it me?" from the user's own logs on this computer,
## Run Sipcode
1. Run the command for the question below straight away. Do not check the Sipcode version first: every extra command is another request that re-sends this whole chat. If the shell says `sipcode` is not found or not recognized, tell the user: "Sipcode is not installed. Run `npm i -g sipcode` once in a terminal, then ask again." and stop. If Sipcode says the command or an option is unknown, tell the user: "Your Sipcode is older than this plugin. Run `npm i -g sipcode@latest`, then ask again." and stop. Do not try `npx` (the sandbox usually blocks network access) and do not install anything yourself.
-2. Run this once:
+2. If the user asks about Codex or does not name a tool, run the Codex check once:
```bash
- sipcode regress --bug-report
+ sipcode regress --bug-report --agent codex
```
- It reads `~/.claude/projects` and `~/.codex` and compares each version with the user's other versions. If the user asked only about Codex, run `sipcode regress --bug-report --agent codex`; only about Claude Code, `sipcode regress --bug-report --agent claude-code`. Use no other options.
+ It reads only the Codex logs in `~/.codex` (usually about a second) and compares each Codex version with the user's other versions.
- On large logs it can take a minute or more. Give the command a timeout of at least 3 minutes. A run cut off by a timeout prints nothing, and empty output is never a result: if the command returns no output at all, run it again with a longer timeout before answering.
+ Run the Claude Code check only when the user explicitly asks about Claude Code: `sipcode regress --bug-report --agent claude-code`, or `sipcode regress --bug-report` (no `--agent`) when they ask about both tools. It reads `~/.claude/projects` (and `~/.codex` for both). Before running it, tell the user the first run can take a minute or more on large Claude Code logs. Give the command a timeout of at least 3 minutes. Use no other options.
+
+ A run cut off by a timeout prints nothing, and empty output is never a result: if the command returns no output at all, run it again with a longer timeout before answering.
3. If the sandbox blocks a log folder (the output says "Not found or not readable", prints a WARNING that files could not be read, or the command exits with code 2), request permission to run the same command again with read access to those folders, for example by running it outside the sandbox. Do not report a result from a blocked run.
4. Never open, read, search or summarise the log files yourself, and never paste their contents. Work only from Sipcode's output.
## Report the result
-1. Start with the coverage lines, quoted exactly as printed: requests, sessions, date range and versions, and which known-regression windows the logs cover.
+1. Start with the coverage lines, quoted exactly as printed: requests, sessions, date range and versions, and (for Claude Code) which known-regression windows the logs cover.
- If the output says "Nothing to analyse", or the command exited with code 2, tell the user no logs could be read and that this is not a clean result. Never say "no regressions".
- If it prints a WARNING that files could not be read, say the result is partial and ask the user to allow read access, then run it again.
- If it says "Not enough data to compare versions yet", say that, and that it is not a clean result either.
diff --git a/tests/plugin/codex-plugin.test.ts b/tests/plugin/codex-plugin.test.ts
index d8f74c1..e264d49 100644
--- a/tests/plugin/codex-plugin.test.ts
+++ b/tests/plugin/codex-plugin.test.ts
@@ -269,6 +269,22 @@ describe("Codex plugin: skills", () => {
expect(t).toContain("empty output is never a result");
});
+ it("regress: defaults to the Codex logs; reads Claude Code logs only when the user asks about Claude Code", () => {
+ // A cold run over every Claude Code log took 49 s (5.4 GB) and Codex's runner stopped it at about 10 s;
+ // the Codex logs alone take about 1 s.
+ const t = skillText("sipcode-regress");
+ const fenced = [...t.matchAll(/```[a-z]*\r?\n([\s\S]*?)```/g)].map((m) => m[1]!.trim());
+ expect(fenced[0]).toBe("sipcode regress --bug-report --agent codex");
+ expect(t).toContain("If the user asks about Codex or does not name a tool, run the Codex check");
+ expect(t).toContain("Run the Claude Code check only when the user explicitly asks about Claude Code");
+ expect(t).toContain("`sipcode regress --bug-report --agent claude-code`");
+ // Both tools: no --agent.
+ expect(t).toContain("`sipcode regress --bug-report` (no `--agent`)");
+ expect(t).toContain("the first run can take a minute or more");
+ // The plain all-logs command is never the default.
+ expect(t).not.toMatch(/Run this once:\s*```bash\r?\n\s*sipcode regress --bug-report\r?\n/);
+ });
+
it("copy rules: no em or en dashes, no 'stops hallucinations', 'nerfed' only in a prohibition", () => {
const texts = [...SKILLS.map(skillText), JSON.stringify(manifest), JSON.stringify(overlay), JSON.stringify(marketplace)];
for (const t of texts) {
From 768634629d503359c37a213c32e067cb54055aca Mon Sep 17 00:00:00 2001
From: Anuj7411
Date: Thu, 15 Oct 2026 20:22:10 +0530
Subject: [PATCH 7/9] feat: warn at the top of the output about logs Sipcode
could not read
Skipped compressed Codex logs (Node.js without zstd), damaged or
unreadable log files and unlistable folders used to be small notes,
often the last line and on stderr; why, receipt and drift did not
mention them at all. A report could look complete while leaving most of
a user's history out.
In the terminal, stats, today, forecast, trend, impact, why, receipt,
drift, limits and regress now start stdout with one line per reason,
with the exact count and the tool's name, then a blank line:
warning: N Codex log file(s) could not be read (compressed .jsonl.zst
logs need Node.js 22.15 or newer; you have vX). Numbers below leave
them out.
warning: N Codex log file(s) could not be read (unreadable or
damaged). Numbers below leave them out.
warning: N Codex log folder(s) could not be read (the folder could
not be listed). Numbers below leave them out.
- Period commands: SectionOutput.unread collects the lines and
runSections prints every section's warnings above the banner and the
section headers (all sections now run before anything is printed).
- why / receipt / drift: UnreadOutput prints discovery's counts, plus
logs the pick or drift's baseline could not read or parse, before the
first line the command writes, so a "no sessions found" caused by
skipped logs carries the warning too.
- limits: also when every log was skipped and there are no readings.
- regress: the warnings lead the report and replace the compressed-log
note under the coverage lines; its sandbox WARNING now names only the
folders the run read (with --agent codex it said "allow read access
to and ...").
- --json: the notes stay on stderr (named by tool when regress or a
--session lookup covers both tools); JSON on stdout is unchanged and
stats keeps its warnings array.
When every log is read, every command's output is byte-identical to
before: a golden file recorded before this change covers stats, today,
forecast, trend, impact, why, receipt, drift, limits and regress,
terminal and JSON, both tools and each alone.
The four Codex plugin skills now say this warning first in the answer
(for --json, the stderr note).
Co-Authored-By: Claude Opus 5.5
---
.gitattributes | 3 +
.../sipcode/skills/sipcode-limits/SKILL.md | 2 +-
.../sipcode/skills/sipcode-regress/SKILL.md | 13 +-
plugins/sipcode/skills/sipcode-spend/SKILL.md | 2 +-
plugins/sipcode/skills/sipcode-why/SKILL.md | 2 +-
src/commands/drift.ts | 18 +-
src/commands/forecast.ts | 6 +-
src/commands/impact.ts | 6 +-
src/commands/limits.ts | 20 +-
src/commands/receipt.ts | 11 +-
src/commands/regress.ts | 17 +-
src/commands/stats.ts | 3 +
src/commands/today.ts | 6 +-
src/commands/trend.ts | 6 +-
src/commands/why.ts | 16 +-
src/lib/messages.ts | 5 +-
src/modules/agents/latest.ts | 69 +-
src/modules/agents/loadSessions.ts | 38 +-
src/modules/agents/multi.ts | 32 +-
src/modules/limits/load.ts | 18 +-
src/modules/regress/format-terminal.ts | 26 +-
.../__golden__/unread-warning-clean.txt | 7242 +++++++++++++++++
.../codex-compressed.integration.test.ts | 8 +-
tests/integration/codex-fixtures.ts | 4 +
.../integration/forecast.integration.test.ts | 8 +-
tests/integration/impact.integration.test.ts | 8 +-
tests/integration/stats.integration.test.ts | 10 +-
tests/integration/today.integration.test.ts | 8 +-
tests/integration/trend.integration.test.ts | 8 +-
.../unread-warning.integration.test.ts | 291 +
tests/modules/agents/multi.test.ts | 47 +
tests/modules/agents/unread-warnings.test.ts | 92 +
tests/modules/regress/format.test.ts | 23 +
tests/plugin/codex-plugin.test.ts | 6 +
34 files changed, 8004 insertions(+), 70 deletions(-)
create mode 100644 tests/integration/__golden__/unread-warning-clean.txt
create mode 100644 tests/integration/unread-warning.integration.test.ts
create mode 100644 tests/modules/agents/unread-warnings.test.ts
diff --git a/.gitattributes b/.gitattributes
index 8b4bc91..aacf10b 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,3 +2,6 @@
# this, core.autocrlf=true on Windows checks them out as CRLF, vitest rewrites
# them as LF on the next run, and git reports them modified.
*.snap text eol=lf
+
+# Golden output files compared byte for byte (vitest toMatchFileSnapshot writes LF).
+tests/integration/__golden__/* text eol=lf
diff --git a/plugins/sipcode/skills/sipcode-limits/SKILL.md b/plugins/sipcode/skills/sipcode-limits/SKILL.md
index 21b6385..ff3a1b9 100644
--- a/plugins/sipcode/skills/sipcode-limits/SKILL.md
+++ b/plugins/sipcode/skills/sipcode-limits/SKILL.md
@@ -20,7 +20,7 @@ Answer from the limit readings Codex writes into its own logs on this computer,
| how close they are, when it resets, what used it | `sipcode limits` |
| whether a limit shrank, or the history | `sipcode limits --since all` |
-Run it once, without `--json`. This answer is mostly times (resets, run-out estimates), and Sipcode prints them in the user's local time zone, which it reads from this computer; the JSON has UTC timestamps and unrounded values that you would have to convert. Lines Sipcode prints on stderr that start with `note:` are part of the answer.
+Run it once, without `--json`. This answer is mostly times (resets, run-out estimates), and Sipcode prints them in the user's local time zone, which it reads from this computer; the JSON has UTC timestamps and unrounded values that you would have to convert. Lines Sipcode prints on stderr that start with `note:` are part of the answer. If Sipcode prints a line starting "warning:" that log files could not be read, say it first in your answer, as printed: the numbers leave those logs out.
## Present numbers
diff --git a/plugins/sipcode/skills/sipcode-regress/SKILL.md b/plugins/sipcode/skills/sipcode-regress/SKILL.md
index d98132f..72ceba8 100644
--- a/plugins/sipcode/skills/sipcode-regress/SKILL.md
+++ b/plugins/sipcode/skills/sipcode-regress/SKILL.md
@@ -26,16 +26,17 @@ Answer "is it the tool or is it me?" from the user's own logs on this computer,
## Report the result
-1. Start with the coverage lines, quoted exactly as printed: requests, sessions, date range and versions, and (for Claude Code) which known-regression windows the logs cover.
+1. If Sipcode prints a line starting "warning:" that log files could not be read, say it first in your answer, quoted as printed: the result leaves those logs out, so it is partial.
+2. Then the coverage lines, quoted exactly as printed: requests, sessions, date range and versions, and (for Claude Code) which known-regression windows the logs cover.
- If the output says "Nothing to analyse", or the command exited with code 2, tell the user no logs could be read and that this is not a clean result. Never say "no regressions".
- If it prints a WARNING that files could not be read, say the result is partial and ask the user to allow read access, then run it again.
- If it says "Not enough data to compare versions yet", say that, and that it is not a clean result either.
- If it prints "Not enough data to compare Codex versions yet" or "Not enough data to compare Claude Code versions yet", that tool was read but not compared: say so, and do not include it in "no regressions".
-2. If there are findings, list each one with its versions, the evidence numbers and whether it matches a known, confirmed issue (R1 to R5). Quote numbers exactly as printed (they are already rounded for display); do not round or recompute them, and never invent one.
-3. A finding not in the known-issue catalog is a signal worth reporting, not proof of a bug. Say so plainly.
-4. A Codex finding ("Codex rate limit drains faster per token") could also be a limit change on the user's plan at the same time as the update, and Codex use on other devices or in the cloud shares the same limit: always say so, and call any limit change "possible".
-5. If there are no findings, say "No regressions detected in your logs" for the tools that were compared, and repeat the coverage, so the user knows what was checked. Do not speculate about causes.
-6. Offer the bug-report block (after "paste into a GitHub issue") for a GitHub issue. It holds token counts, versions, dates and short session ids only, never prompts or code. Tell the user to review it before posting.
+3. If there are findings, list each one with its versions, the evidence numbers and whether it matches a known, confirmed issue (R1 to R5). Quote numbers exactly as printed (they are already rounded for display); do not round or recompute them, and never invent one.
+4. A finding not in the known-issue catalog is a signal worth reporting, not proof of a bug. Say so plainly.
+5. A Codex finding ("Codex rate limit drains faster per token") could also be a limit change on the user's plan at the same time as the update, and Codex use on other devices or in the cloud shares the same limit: always say so, and call any limit change "possible".
+6. If there are no findings, say "No regressions detected in your logs" for the tools that were compared, and repeat the coverage, so the user knows what was checked. Do not speculate about causes.
+7. Offer the bug-report block (after "paste into a GitHub issue") for a GitHub issue. It holds token counts, versions, dates and short session ids only, never prompts or code. Tell the user to review it before posting.
## Rules
diff --git a/plugins/sipcode/skills/sipcode-spend/SKILL.md b/plugins/sipcode/skills/sipcode-spend/SKILL.md
index cd72928..e3496f8 100644
--- a/plugins/sipcode/skills/sipcode-spend/SKILL.md
+++ b/plugins/sipcode/skills/sipcode-spend/SKILL.md
@@ -21,7 +21,7 @@ Answer from the user's own Codex logs on this computer, through the published `s
| this month, month end, "what will this month cost" | `sipcode forecast --agent codex --json` |
| a period: last 7 days, since a date, all time | `sipcode stats --agent codex --since 7d --json` (use `7d`, `30d`, `90d`, `all` or a `yyyy-mm-dd` date) |
-Run it once. Lines Sipcode prints on stderr that start with `note:` are part of the answer.
+Run it once. Lines Sipcode prints on stderr that start with `note:` are part of the answer. If Sipcode prints a line starting "warning:" that log files could not be read, say it first in your answer, as printed: the numbers leave those logs out. With `--json` the same thing comes on stderr as a `note:` that log files or folders could not be read or parsed, or that compressed logs were skipped: say that first too.
## Present numbers
diff --git a/plugins/sipcode/skills/sipcode-why/SKILL.md b/plugins/sipcode/skills/sipcode-why/SKILL.md
index f7af53a..753566f 100644
--- a/plugins/sipcode/skills/sipcode-why/SKILL.md
+++ b/plugins/sipcode/skills/sipcode-why/SKILL.md
@@ -20,7 +20,7 @@ Answer from the user's own Codex logs on this computer, through the published `s
| the last (most recent) session | `sipcode why --agent codex --json` |
| a specific session | `sipcode why --agent codex --list` to find its id, then `sipcode why --agent codex --session --json` |
-The session you are in right now is also a Codex session: if the user means an earlier one, use `--list` and ask which one when it is not clear. Lines Sipcode prints on stderr that start with `note:` are part of the answer.
+The session you are in right now is also a Codex session: if the user means an earlier one, use `--list` and ask which one when it is not clear. Lines Sipcode prints on stderr that start with `note:` are part of the answer. If Sipcode prints a line starting "warning:" that log files could not be read, say it first in your answer, as printed: the numbers leave those logs out. With `--json` the same thing comes on stderr as a `note:` that log files or folders could not be read or parsed, or that compressed logs were skipped: say that first too.
## Present numbers
diff --git a/src/commands/drift.ts b/src/commands/drift.ts
index 3558a59..31c6728 100644
--- a/src/commands/drift.ts
+++ b/src/commands/drift.ts
@@ -38,6 +38,7 @@ import {
otherAgentHint,
pickFrom,
sessionPickError,
+ UnreadOutput,
} from "../modules/agents/latest.js";
import { computeSessionMetrics } from "../modules/drift/metrics.js";
import { buildDriftReport } from "../modules/drift/runDrift.js";
@@ -111,8 +112,12 @@ export async function runDriftCommand(
opts: DriftOptions = {},
deps: DriftDeps = {},
): Promise {
- const stdout = deps.stdout ?? ((s: string) => process.stdout.write(s + "\n"));
- const stderr = deps.stderr ?? ((s: string) => process.stderr.write(s + "\n"));
+ const rawStdout = deps.stdout ?? ((s: string) => process.stdout.write(s + "\n"));
+ const rawStderr = deps.stderr ?? ((s: string) => process.stderr.write(s + "\n"));
+ // Logs that could not be read are reported before the first line printed after discovery.
+ const unread = new UnreadOutput(opts.json ?? false, rawStdout, rawStderr);
+ const stdout = unread.stdout;
+ const stderr = unread.stderr;
const fs = deps.fs ?? new RealFileSystem();
const env = deps.env ?? new RealProcessEnv();
const clock = deps.clock ?? new RealClock();
@@ -160,11 +165,16 @@ export async function runDriftCommand(
// thread). When the cache already answers that, nothing is parsed.
const agentDeps = { fs, env, clock };
const lists = await listAgentSessions({ agents, deps: agentDeps, cwd, here: opts.here });
+ unread.watch(lists);
let picked: DriftPick | undefined = opts.session ? undefined : pickFromCache(lists, fresh);
/** The chosen session's own requests, when the pick parsed it. */
let chosenParsed: ParsedSession | undefined;
if (!picked) {
- const r = await pickFrom(lists, agentDeps, { sessionIdPrefix: opts.session, ownRequests: true });
+ const r = await pickFrom(lists, agentDeps, {
+ sessionIdPrefix: opts.session,
+ ownRequests: true,
+ onUnreadable: unread.unreadable,
+ });
if (r?.ambiguous || (!r && opts.session)) {
stderr(
sessionPickError({
@@ -244,10 +254,12 @@ export async function runDriftCommand(
try {
content = await readLogText(fs, s.filePath);
} catch {
+ unread.unreadable(agent);
metrics.set(s, null);
continue;
}
const p = agent.parseTranscript(content);
+ if (!p.ok) unread.unreadable(agent);
if (!p.ok || (p.value.isSubagent && s !== chosen)) {
metrics.set(s, null);
continue;
diff --git a/src/commands/forecast.ts b/src/commands/forecast.ts
index 3fec7a6..0a19613 100644
--- a/src/commands/forecast.ts
+++ b/src/commands/forecast.ts
@@ -7,7 +7,7 @@ void ASSERT_NO_NETWORK;
import { RealFileSystem, type FileSystem } from "../lib/fs.js";
import { RealClock, type Clock } from "../lib/clock.js";
import { RealProcessEnv, type ProcessEnv } from "../lib/process.js";
-import { discoveryNotes, loadSessions } from "../modules/agents/loadSessions.js";
+import { loadSessions } from "../modules/agents/loadSessions.js";
import { defaultUsageCaches, type UsageCaches } from "../modules/agents/usageSessions.js";
import {
agentLabel,
@@ -134,8 +134,8 @@ async function forecastForAgent(agent: Agent, ctx: ForecastContext): Promise e.message).join("\n"));
return o.result(1);
}
- // JSON too (on stderr, as stats does): the totals leave these logs out.
- for (const n of discoveryNotes(loaded.value)) stderr(n);
+ // Logs left out of the totals: a warning at the top of stdout (terminal), notes on stderr (--json).
+ o.unread(agent.displayName, loaded.value, opts.json ?? false);
const sessions: ForecastSession[] = [];
const unpricedBySession: { startedAt: string; unpriced: UnpricedUsage }[] = [];
diff --git a/src/commands/impact.ts b/src/commands/impact.ts
index 2c72ce8..6b90567 100644
--- a/src/commands/impact.ts
+++ b/src/commands/impact.ts
@@ -15,7 +15,7 @@ void ASSERT_NO_NETWORK;
import { RealFileSystem, type FileSystem } from "../lib/fs.js";
import { RealClock, type Clock } from "../lib/clock.js";
import { RealProcessEnv, type ProcessEnv } from "../lib/process.js";
-import { discoveryNotes, loadSessions } from "../modules/agents/loadSessions.js";
+import { loadSessions } from "../modules/agents/loadSessions.js";
import {
agentLabel,
resolveDisplayAgents,
@@ -171,8 +171,8 @@ async function impactForAgent(agent: Agent, ctx: ImpactContext): Promise {
+ if (opts.json) {
+ for (const n of discoveryNotes(counts)) stderr(n);
+ return;
+ }
+ const lines = unreadWarnings(getAgentById("codex").displayName, counts);
+ for (const l of lines) stdout(l);
+ if (lines.length > 0) stdout("");
+ };
const empty = (): LimitsExit => {
stdout(opts.json ? formatLimitsJson(null) : MESSAGES.limitsNoReadings);
return { exitCode: 0 };
@@ -79,7 +89,11 @@ export async function runLimits(opts: LimitsOptions = {}, deps: LimitsDeps = {})
const agentDeps = { fs, env, clock };
const scan = await loadCodexReadings(agentDeps);
if (scan.malformed > 0) stderr(MESSAGES.limitsMalformed(scan.malformed));
- if (scan.readings.length === 0) return empty();
+ if (scan.readings.length === 0) {
+ // "No readings" when every log was skipped must not pass for a clean answer.
+ unread(scan.unread);
+ return empty();
+ }
const cwd = opts.cwd ?? process.cwd();
const loaded = await loadSessions({
@@ -94,7 +108,7 @@ export async function runLimits(opts: LimitsOptions = {}, deps: LimitsDeps = {})
stderr(loaded.error.map((e) => e.message).join("\n"));
return { exitCode: 1 };
}
- for (const n of discoveryNotes(loaded.value)) stderr(n);
+ unread(loaded.value);
const requests: LimitRequest[] = [];
for (const s of loaded.value.sessions) for (const r of s.value) requests.push(r);
diff --git a/src/commands/receipt.ts b/src/commands/receipt.ts
index ff392d8..dbd2d7b 100644
--- a/src/commands/receipt.ts
+++ b/src/commands/receipt.ts
@@ -50,6 +50,7 @@ import {
pickFrom,
priceProvider,
sessionPickError,
+ UnreadOutput,
} from "../modules/agents/latest.js";
import { renderReceipt } from "../modules/receipt/render.js";
@@ -120,8 +121,12 @@ export async function runReceipt(
const fs = deps.fs ?? new RealFileSystem();
const clock = deps.clock ?? new RealClock();
const env = deps.env ?? new RealProcessEnv();
- const stdout = deps.stdout ?? ((s: string) => process.stdout.write(s + "\n"));
- const stderr = deps.stderr ?? ((s: string) => process.stderr.write(s + "\n"));
+ const rawStdout = deps.stdout ?? ((s: string) => process.stdout.write(s + "\n"));
+ const rawStderr = deps.stderr ?? ((s: string) => process.stderr.write(s + "\n"));
+ // Logs that could not be read are reported before the first line printed after discovery.
+ const unread = new UnreadOutput(opts.json ?? false, rawStdout, rawStderr);
+ const stdout = unread.stdout;
+ const stderr = unread.stderr;
const writeFile = deps.writeFile ?? realWriteFile;
const clipboard = deps.clipboard ?? new RealClipboard(env);
@@ -157,6 +162,7 @@ export async function runReceipt(
}
const agentDeps = { fs, env, clock };
const lists = await listAgentSessions({ agents, deps: agentDeps, cwd, here: opts.here });
+ unread.watch(lists);
if (lists.every((l) => l.scoped.length === 0)) {
stderr(
@@ -175,6 +181,7 @@ export async function runReceipt(
sessionIdPrefix: opts.session,
skipEmpty: false,
ownRequests: true,
+ onUnreadable: unread.unreadable,
});
const pickError = sessionPickError({
command: "receipt",
diff --git a/src/commands/regress.ts b/src/commands/regress.ts
index b1f7781..13e0579 100644
--- a/src/commands/regress.ts
+++ b/src/commands/regress.ts
@@ -15,6 +15,8 @@ import { RealClock, type Clock } from "../lib/clock.js";
import { RealProcessEnv, type ProcessEnv } from "../lib/process.js";
import { MESSAGES } from "../lib/messages.js";
import { parseAgentFlag } from "../modules/agents/cli.js";
+import { discoveryNotes, unreadWarnings } from "../modules/agents/loadSessions.js";
+import { getAgentById } from "../modules/agents/registry.js";
import { defaultUsageCaches, type UsageCaches } from "../modules/agents/usageSessions.js";
import { DEFAULT_MIN_N } from "../modules/regress/claude.js";
import { formatRegressJson } from "../modules/regress/format-json.js";
@@ -108,7 +110,20 @@ export async function runRegress(opts: RegressOptions = {}, deps: RegressDeps =
codex: codex ? { readings: codex.readings, requests: codex.requests } : null,
access: accessOf(claude, codex),
});
+ // Logs left out: warnings at the top of the terminal output; with --json,
+ // notes on stderr (the JSON's `access` holds the counts).
+ const loaded = [
+ ...(claude ? [{ name: getAgentById("claude-code").displayName, counts: claude.counts }] : []),
+ ...(codex ? [{ name: getAgentById("codex").displayName, counts: codex.counts }] : []),
+ ];
const fmt = { bugReport: opts.bugReport === true, version };
- stdout(opts.json ? formatRegressJson(report, fmt) : formatRegressTerminal(report, fmt));
+ if (opts.json) {
+ for (const l of loaded) {
+ for (const n of discoveryNotes(l.counts)) stderr(loaded.length > 1 ? `${l.name}: ${n}` : n);
+ }
+ stdout(formatRegressJson(report, fmt));
+ } else {
+ stdout(formatRegressTerminal(report, { ...fmt, unreadWarnings: loaded.flatMap((l) => unreadWarnings(l.name, l.counts)) }));
+ }
return { exitCode: report.access.nothingRead ? 2 : 0 };
}
diff --git a/src/commands/stats.ts b/src/commands/stats.ts
index 92efbd3..af8ede8 100644
--- a/src/commands/stats.ts
+++ b/src/commands/stats.ts
@@ -245,6 +245,9 @@ async function statsForAgent(agent: Agent, ctx: StatsContext): Promise e.message).join("\n"));
return o.result(1);
}
- // JSON too (on stderr, as stats does): the totals leave these logs out.
- for (const n of discoveryNotes(loaded.value)) stderr(n);
+ // Logs left out of the totals: a warning at the top of stdout (terminal), notes on stderr (--json).
+ o.unread(agent.displayName, loaded.value, opts.json ?? false);
const sessions: TodaySession[] = [];
// Unpriced tokens behind "spend so far": today's sessions only.
diff --git a/src/commands/trend.ts b/src/commands/trend.ts
index 5550401..87a8ed9 100644
--- a/src/commands/trend.ts
+++ b/src/commands/trend.ts
@@ -13,7 +13,7 @@ void ASSERT_NO_NETWORK;
import { RealFileSystem, type FileSystem } from "../lib/fs.js";
import { RealClock, type Clock } from "../lib/clock.js";
import { RealProcessEnv, type ProcessEnv } from "../lib/process.js";
-import { discoveryNotes, loadSessions } from "../modules/agents/loadSessions.js";
+import { loadSessions } from "../modules/agents/loadSessions.js";
import {
resolveDisplayAgents,
runSections,
@@ -181,8 +181,8 @@ async function trendForAgent(agent: Agent, ctx: TrendContext): Promise e.message).join("\n"));
return o.result(1);
}
- // JSON too (on stderr, as stats does): the totals leave these logs out.
- for (const n of discoveryNotes(loaded.value)) stderr(n);
+ // Logs left out of the totals: a warning at the top of stdout (terminal), notes on stderr (--json).
+ o.unread(agent.displayName, loaded.value, opts.json ?? false);
const sessions: TrendSession[] = [];
let unpriced = NO_UNPRICED;
diff --git a/src/commands/why.ts b/src/commands/why.ts
index 9027719..8a00bdf 100644
--- a/src/commands/why.ts
+++ b/src/commands/why.ts
@@ -35,6 +35,7 @@ import {
pickFrom,
priceProvider,
sessionPickError,
+ UnreadOutput,
} from "../modules/agents/latest.js";
export interface WhyOptions {
@@ -68,8 +69,12 @@ export async function runWhy(
const fs = deps.fs ?? new RealFileSystem();
const clock = deps.clock ?? new RealClock();
const env = deps.env ?? new RealProcessEnv();
- const stdout = deps.stdout ?? ((s: string) => process.stdout.write(s + "\n"));
- const stderr = deps.stderr ?? ((s: string) => process.stderr.write(s + "\n"));
+ const rawStdout = deps.stdout ?? ((s: string) => process.stdout.write(s + "\n"));
+ const rawStderr = deps.stderr ?? ((s: string) => process.stderr.write(s + "\n"));
+ // Logs that could not be read are reported before the first line printed after discovery.
+ const unread = new UnreadOutput(opts.json ?? false, rawStdout, rawStderr);
+ const stdout = unread.stdout;
+ const stderr = unread.stderr;
const cwd = opts.cwd ?? process.cwd();
// Which tools to look at: Claude Code and/or Codex (JSON: one tool).
@@ -102,6 +107,7 @@ export async function runWhy(
const agentDeps = { fs, env, clock };
const lists = await listAgentSessions({ agents, deps: agentDeps, cwd, here: opts.here });
+ unread.watch(lists);
const scopedCount = lists.reduce((n, l) => n + l.scoped.length, 0);
if (opts.list) {
@@ -141,7 +147,11 @@ export async function runWhy(
// report; drift guards the same way), falling back to the newest.
// A resumed session (or a Codex fork) repeats requests another file holds:
// the pick carries only this session's own, as the period commands count them.
- const picked = await pickFrom(lists, agentDeps, { sessionIdPrefix: opts.session, ownRequests: true });
+ const picked = await pickFrom(lists, agentDeps, {
+ sessionIdPrefix: opts.session,
+ ownRequests: true,
+ onUnreadable: unread.unreadable,
+ });
const pickError = sessionPickError({
command: "why",
picked,
diff --git a/src/lib/messages.ts b/src/lib/messages.ts
index 40bbfcf..b8a9fe1 100644
--- a/src/lib/messages.ts
+++ b/src/lib/messages.ts
@@ -285,8 +285,9 @@ export const MESSAGES = {
`next: npx sipcode regress --min-n 20`,
].join("\n"),
- regressUnreadable: (n: number, claudeDir: string, codexDir: string) =>
- `WARNING: ${n} log file(s) or folder(s) could not be read. Results cover only what was readable. If this runs inside a sandbox (for example Codex), allow read access to ${claudeDir} and ${codexDir} and run it again.`,
+ /** `dirs`: the log folders regress read (one with --agent). */
+ regressUnreadable: (n: number, dirs: readonly string[]) =>
+ `WARNING: ${n} log file(s) or folder(s) could not be read. Results cover only what was readable. If this runs inside a sandbox (for example Codex), allow read access to ${dirs.join(" and ")} and run it again.`,
regressNothingToAnalyse:
"Nothing to analyse: no interactive Claude Code requests and no Codex requests could be read. This is NOT a clean result.",
diff --git a/src/modules/agents/latest.ts b/src/modules/agents/latest.ts
index 3d34996..25b9000 100644
--- a/src/modules/agents/latest.ts
+++ b/src/modules/agents/latest.ts
@@ -21,7 +21,13 @@ import { MESSAGES } from "../../lib/messages.js";
import { shortSessionId } from "../../lib/session-id.js";
import { loadPricingForDate, type PriceProvider } from "../../lib/pricing/load.js";
import { analyzeTokens, isEmptySession } from "../transcript/analyzers/tokens.js";
-import { discoverAgentSessions, dropCopiedRequests } from "./loadSessions.js";
+import {
+ discoverAgentSessions,
+ discoveryNotes,
+ dropCopiedRequests,
+ unreadWarnings,
+ type UnreadCounts,
+} from "./loadSessions.js";
import type { ParsedSession, SessionMeta } from "./shared.js";
import type { Agent, AgentDeps } from "./types.js";
@@ -45,6 +51,8 @@ export interface AgentSessions {
readonly all: SessionMeta[];
/** `all` filtered by `--here` (equal to `all` without it). */
readonly scoped: SessionMeta[];
+ /** What discovery could not read (UnreadOutput reports it). */
+ readonly counts: UnreadCounts;
}
export async function listAgentSessions(i: {
@@ -61,6 +69,7 @@ export async function listAgentSessions(i: {
agent,
all,
scoped: i.here ? all.filter((m) => agent.matchesCwd(m, i.cwd)) : all,
+ counts: d.ok ? d.value : { unreadable: 0, unreadableFolders: 0, skippedCompressed: 0, issues: [] },
});
}
return out;
@@ -76,6 +85,8 @@ export interface PickOptions {
* file (resumed, nothing new logged yet), as it skips an empty one.
*/
readonly ownRequests?: boolean | undefined;
+ /** Called for each session log the pick could not read or parse (UnreadOutput.unreadable). */
+ readonly onUnreadable?: ((agent: Agent) => void) | undefined;
}
export async function pickFrom(
@@ -95,10 +106,14 @@ export async function pickFrom(
try {
content = await readLogText(deps.fs, meta.filePath);
} catch {
+ opts.onUnreadable?.(agent);
continue;
}
const p = agent.parseTranscript(content);
- if (!p.ok) continue;
+ if (!p.ok) {
+ opts.onUnreadable?.(agent);
+ continue;
+ }
let candidate: PickedSession = { agent, meta, parsed: p.value };
if (!prefix) {
// Helper threads (Codex subagents, auto-review) are never "your latest session".
@@ -164,6 +179,56 @@ export async function ownRequestsOnly(i: {
return r.sessions[0]!;
}
+/**
+ * Output for a single-session command (why / receipt / drift) that never
+ * hides logs it could not read: the first line the command prints, on either
+ * stream, is preceded by them. In the terminal, a warning per reason at the
+ * top of stdout (unreadWarnings); with --json, the usual notes on stderr
+ * (prefixed with the tool's name when several tools were looked at). Counts
+ * are discovery's plus the logs reported through `unreadable` so far. Prints
+ * nothing extra when everything was read.
+ */
+export class UnreadOutput {
+ private readonly extra = new Map();
+ private lists: ReadonlyArray = [];
+ private flushed = false;
+ constructor(
+ private readonly json: boolean,
+ private readonly rawOut: (s: string) => void,
+ private readonly rawErr: (s: string) => void,
+ ) {}
+ /** The tools looked at (from listAgentSessions); output before this is printed as is. */
+ watch(lists: ReadonlyArray): void {
+ this.lists = lists;
+ }
+ /** One more log of `agent` the command could not read or parse. */
+ readonly unreadable = (agent: Agent): void => {
+ this.extra.set(agent, (this.extra.get(agent) ?? 0) + 1);
+ };
+ readonly stdout = (s: string): void => {
+ this.flush();
+ this.rawOut(s);
+ };
+ readonly stderr = (s: string): void => {
+ this.flush();
+ this.rawErr(s);
+ };
+ private flush(): void {
+ if (this.flushed || this.lists.length === 0) return;
+ this.flushed = true;
+ const many = this.lists.length > 1;
+ const warnings: string[] = [];
+ for (const l of this.lists) {
+ const counts = { ...l.counts, unreadable: l.counts.unreadable + (this.extra.get(l.agent) ?? 0) };
+ if (this.json) for (const n of discoveryNotes(counts)) this.rawErr(many ? `${l.agent.displayName}: ${n}` : n);
+ else warnings.push(...unreadWarnings(l.agent.displayName, counts));
+ }
+ if (warnings.length === 0) return;
+ for (const w of warnings) this.rawOut(w);
+ this.rawOut("");
+ }
+}
+
/** With --here, the suggested command keeps it (without it, --agent picks the newest anywhere). */
export function otherAgentHint(o: Pick, here = false): string {
return `${o.agent.displayName} also has a recent session (${shortSessionId(o.meta.sessionId, o.agent.id)}): run with --agent ${o.agent.id}${here ? " --here" : ""}.`;
diff --git a/src/modules/agents/loadSessions.ts b/src/modules/agents/loadSessions.ts
index 67b8fc8..7b56d5d 100644
--- a/src/modules/agents/loadSessions.ts
+++ b/src/modules/agents/loadSessions.ts
@@ -152,11 +152,17 @@ export async function discoverAgentSessions(
});
}
+/** What a command could not read for one agent: loadSessions' counts, or discovery's. */
+export type UnreadCounts = Pick & {
+ readonly issues: readonly unknown[];
+};
+
/**
- * The stderr notes every period command prints about logs it could not use:
- * unreadable or unparseable files, unreadable folders, skipped compressed logs.
+ * The --json stderr notes about logs a command could not use: unreadable or
+ * unparseable files, unreadable folders, skipped compressed logs. (In the
+ * terminal, unreadWarnings says the same at the top of stdout.)
*/
-export function discoveryNotes(o: LoadCounts): string[] {
+export function discoveryNotes(o: UnreadCounts): string[] {
const notes: string[] = [];
const files = o.unreadable + o.issues.length;
if (files > 0) {
@@ -173,6 +179,32 @@ export function compressedSkippedMessage(n: number, nodeVersion: string = proces
return `${n} compressed Codex log(s) (.jsonl.zst) skipped: reading them needs Node.js ${ZSTD_MIN_NODE} or newer (you have ${nodeVersion}).`;
}
+const UNREAD_TAIL = "Numbers below leave them out.";
+
+/**
+ * The terminal warnings about logs a command could not read, one line per
+ * reason, printed at the top of stdout (runSections, UnreadOutput) so they are
+ * never missed. Empty when everything was read.
+ */
+export function unreadWarnings(agentName: string, o: UnreadCounts, nodeVersion: string = process.version): string[] {
+ const out: string[] = [];
+ if (o.skippedCompressed > 0) {
+ out.push(
+ `warning: ${o.skippedCompressed} ${agentName} log file(s) could not be read (compressed .jsonl.zst logs need Node.js ${ZSTD_MIN_NODE} or newer; you have ${nodeVersion}). ${UNREAD_TAIL}`,
+ );
+ }
+ const files = o.unreadable + o.issues.length;
+ if (files > 0) {
+ out.push(`warning: ${files} ${agentName} log file(s) could not be read (unreadable or damaged). ${UNREAD_TAIL}`);
+ }
+ if (o.unreadableFolders > 0) {
+ out.push(
+ `warning: ${o.unreadableFolders} ${agentName} log folder(s) could not be read (the folder could not be listed). ${UNREAD_TAIL}`,
+ );
+ }
+ return out;
+}
+
type Read =
| {
readonly ok: true;
diff --git a/src/modules/agents/multi.ts b/src/modules/agents/multi.ts
index 56cc5d1..71bd7b9 100644
--- a/src/modules/agents/multi.ts
+++ b/src/modules/agents/multi.ts
@@ -11,6 +11,7 @@ import type { ProcessEnv } from "../../lib/process.js";
import { hasClaudeTranscripts, resolveProjectsDir } from "../transcript/discover.js";
import { parseAgentFlag } from "./cli.js";
import { detectAgent, type AgentDetectResult } from "./detect.js";
+import { discoveryNotes, unreadWarnings, type UnreadCounts } from "./loadSessions.js";
import { getAgentById } from "./registry.js";
import type { Agent, AgentId } from "./types.js";
@@ -143,6 +144,8 @@ export interface SectionResult {
readonly emptyWindow?: boolean;
/** Totals for the combined line (stats / today / forecast). */
readonly totals?: CombinedPart;
+ /** Logs it could not read (unreadWarnings): printed at the very top of stdout. */
+ readonly warnings?: readonly string[];
}
/** Collects a section's output in order instead of printing it. */
@@ -154,6 +157,16 @@ export class SectionOutput {
readonly err = (text: string): void => {
this.writes.push({ stream: "stderr", text });
};
+ /** Lines runSections prints at the top of stdout, before every section. */
+ readonly warnings: string[] = [];
+ /**
+ * Logs this section could not read: in the terminal a warning at the top of
+ * stdout, with --json the usual notes on stderr (the JSON is unchanged).
+ */
+ unread(agentName: string, counts: UnreadCounts, json: boolean): void {
+ if (json) for (const n of discoveryNotes(counts)) this.err(n);
+ else this.warnings.push(...unreadWarnings(agentName, counts));
+ }
result(
exitCode: 0 | 1,
extra: { emptyWindow?: boolean; totals?: CombinedPart | undefined } = {},
@@ -161,6 +174,7 @@ export class SectionOutput {
const r: SectionResult = { exitCode, writes: this.writes };
return {
...r,
+ ...(this.warnings.length > 0 ? { warnings: this.warnings } : {}),
...(extra.emptyWindow ? { emptyWindow: true } : {}),
...(extra.totals ? { totals: extra.totals } : {}),
};
@@ -179,27 +193,41 @@ export interface RunSectionsInput {
readonly run: (agent: Agent, index: number) => Promise;
}
+/** The sections' warnings about logs they could not read, then a blank line; nothing when there are none. */
+function printWarnings(results: readonly SectionResult[], stdout: (s: string) => void): void {
+ const lines = results.flatMap((r) => r.warnings ?? []);
+ if (lines.length === 0) return;
+ for (const l of lines) stdout(l);
+ stdout("");
+}
+
/**
* One agent: exactly the command's single-agent output. Several: a header per
* agent, each section's output, then the combined line. The exit code is 1 only
* for a real error, never for a section that merely had nothing in the window.
+ * Warnings about logs a section could not read come first, above everything.
*/
export async function runSections(i: RunSectionsInput): Promise<0 | 1> {
if (i.agents.length === 1) {
const agent = i.agents[0]!;
+ const r = await i.run(agent, 0);
+ printWarnings([r], i.stdout);
if (i.banner && !i.detect.explicit) {
i.stdout(MESSAGES.agentDetectedAuto(agent.id));
if (i.detect.ambiguous && agent.id === i.detect.agent) i.stdout(MESSAGES.agentAmbiguous());
}
- const r = await i.run(agent, 0);
for (const w of r.writes) (w.stream === "stdout" ? i.stdout : i.stderr)(w.text);
return r.exitCode;
}
+ // Every section runs before anything is printed, so all warnings can lead.
+ const results: SectionResult[] = [];
+ for (const [index, agent] of i.agents.entries()) results.push(await i.run(agent, index));
+ printWarnings(results, i.stdout);
const totals: CombinedPart[] = [];
let exitCode: 0 | 1 = 0;
for (const [index, agent] of i.agents.entries()) {
i.stdout(sectionHeader(agent.displayName));
- const r = await i.run(agent, index);
+ const r = results[index]!;
for (const w of r.writes) {
if (w.stream === "stdout" || r.emptyWindow) i.stdout(w.text);
else i.stderr(w.text === "" ? "" : `${agent.displayName}: ${w.text}`);
diff --git a/src/modules/limits/load.ts b/src/modules/limits/load.ts
index 2866278..ea58654 100644
--- a/src/modules/limits/load.ts
+++ b/src/modules/limits/load.ts
@@ -2,11 +2,12 @@
* Codex limit inputs from disk: readings, scanned from every rollout Codex
* discovery lists, and requests, from the shared session loader (so dedupe
* and the usage cache are reused unchanged). Files that cannot be read are
- * skipped here; the session loader's stderr note counts them.
+ * skipped here and counted (`unread`); once sessions are loaded, the session
+ * loader's counts report them.
*/
import { readLogText } from "../../lib/fs.js";
import { scanCodexRateLimits, type LimitReading } from "../agents/codex/rateLimits.js";
-import { discoverAgentSessions, type LoadedSession } from "../agents/loadSessions.js";
+import { discoverAgentSessions, type LoadedSession, type UnreadCounts } from "../agents/loadSessions.js";
import { getAgentById } from "../agents/registry.js";
import type { AgentDeps } from "../agents/types.js";
import type { LimitRequest } from "./attribute.js";
@@ -18,6 +19,8 @@ export interface CodexReadings {
readonly readings: LimitReading[];
/** Lines or windows that could not be read (MESSAGES.limitsMalformed). */
readonly malformed: number;
+ /** Rollouts the scan could not read: discovery's counts plus files that failed to read. */
+ readonly unread: UnreadCounts;
}
/** Rollouts read at once. Small, so a few large logs never sit in memory together. */
@@ -27,7 +30,8 @@ export async function loadCodexReadings(deps: AgentDeps): Promise
const readings: LimitReading[] = [];
let malformed = 0;
const found = await discoverAgentSessions(getAgentById("codex"), deps);
- if (!found.ok) return { readings, malformed };
+ if (!found.ok) return { readings, malformed, unread: { unreadable: 0, unreadableFolders: 0, skippedCompressed: 0, issues: [] } };
+ let unreadable = found.value.unreadable;
// Up to READ_AHEAD files are read at once (`today` waits on this scan);
// they are scanned in discovery order, so the result never depends on timing.
const sessions = found.value.sessions;
@@ -36,13 +40,17 @@ export async function loadCodexReadings(deps: AgentDeps): Promise
const texts = await Promise.all(batch.map((m) => readLogText(deps.fs, m.filePath).catch(() => undefined)));
batch.forEach((meta, j) => {
const text = texts[j];
- if (text === undefined) return;
+ if (text === undefined) {
+ unreadable++;
+ return;
+ }
const scan = scanCodexRateLimits(text, { sessionId: meta.sessionId });
for (const r of scan.readings) readings.push(r);
malformed += scan.malformed;
});
}
- return { readings, malformed };
+ const { unreadableFolders, skippedCompressed, issues } = found.value;
+ return { readings, malformed, unread: { unreadable, unreadableFolders, skippedCompressed, issues } };
}
/** A loaded Codex session's requests with tokens and a time (loadSessions `analyze`). */
diff --git a/src/modules/regress/format-terminal.ts b/src/modules/regress/format-terminal.ts
index 796a3ba..be87a47 100644
--- a/src/modules/regress/format-terminal.ts
+++ b/src/modules/regress/format-terminal.ts
@@ -43,16 +43,30 @@ function findingLines(f: Finding): string[] {
export function formatRegressTerminal(
report: RegressReport,
- opts: { readonly bugReport: boolean; readonly version: string },
+ opts: {
+ readonly bugReport: boolean;
+ readonly version: string;
+ /**
+ * Per-tool warnings about logs that could not be read (unreadWarnings),
+ * printed first. They name the skipped compressed logs, so the note below
+ * the coverage lines is then left out.
+ */
+ readonly unreadWarnings?: readonly string[];
+ },
): string {
- const lines = body(report, opts);
+ const top = opts.unreadWarnings ?? [];
+ const lines = [...top, ...(top.length > 0 ? [""] : []), ...body(report, opts, top.length > 0)];
// --bug-report with nothing to report still answers the flag: a run that only
// repeated the plain result read as "no output" to Codex.
if (opts.bugReport && report.findings.length === 0) lines.push("", MESSAGES.regressNoBugReport);
return lines.join("\n");
}
-function body(report: RegressReport, opts: { readonly bugReport: boolean; readonly version: string }): string[] {
+function body(
+ report: RegressReport,
+ opts: { readonly bugReport: boolean; readonly version: string },
+ warnedAbove: boolean,
+): string[] {
const lines = ["sipcode regress · local only, no network"];
if (report.claude) {
lines.push(claudeLine(report));
@@ -63,8 +77,10 @@ function body(report: RegressReport, opts: { readonly bugReport: boolean; readon
const a = report.access;
for (const d of a.missingDirs) lines.push(`Not found or not readable: ${d}`);
const unreadable = a.unreadableFiles + a.unreadableFolders;
- if (unreadable > 0) lines.push(MESSAGES.regressUnreadable(unreadable, report.claudeDir, report.codexDir));
- if (a.skippedCompressed > 0) lines.push(`note: ${compressedSkippedMessage(a.skippedCompressed)}`);
+ // Only the folders this run read (the other one is "" with --agent).
+ const dirs = [report.claudeDir, report.codexDir].filter((d) => d !== "");
+ if (unreadable > 0) lines.push(MESSAGES.regressUnreadable(unreadable, dirs));
+ if (a.skippedCompressed > 0 && !warnedAbove) lines.push(`note: ${compressedSkippedMessage(a.skippedCompressed)}`);
lines.push("");
if (a.nothingRead) {
lines.push(MESSAGES.regressNothingToAnalyse);
diff --git a/tests/integration/__golden__/unread-warning-clean.txt b/tests/integration/__golden__/unread-warning-clean.txt
new file mode 100644
index 0000000..529f1a9
--- /dev/null
+++ b/tests/integration/__golden__/unread-warning-clean.txt
@@ -0,0 +1,7242 @@
+=== stats
+exit 0
+── Claude Code ──
+sipcode stats · all time · 45 sessions · claude-opus-4-8
+
+across 45 sessions you used 149,570,386 tokens.
+output ratio: 0.4% of all tokens. the other 99.6% was context sent with each request, 91% of it read from cache.
+
+estimated wasted on patterns sipcode catches:
+ · duplicate file reads 0 tokens ($0.0000)
+ · idle context 0 tokens ($0.0000)
+ · cache-creation overhead 13,593,600 tokens ($25.49)
+
+trend: token spend per day (223 days)
+ ▃▂▃▁▄▂▃▁▁▂▁▂▃▁▁▁▁▁▁▁▁▁██▂▁▁▁▁▁
+ min: 0 · max: 30.8M · median: 0
+
+top 5 most expensive sessions:
+ 1. 2026-08-19 · sess0045 · 3h 59m · 30,768,720 tok · $22.03
+ 2. 2026-08-18 · sess0044 · 3h 59m · 30,768,720 tok · $22.03
+ 3. 2026-04-22 · sess0023 · 1h 43m · 2,517,090 tok · $3.54
+ 4. 2026-04-21 · sess0022 · 1h 43m · 2,517,090 tok · $3.54
+ 5. 2026-04-20 · sess0021 · 1h 43m · 2,517,090 tok · $3.54
+
+projects (top 1 by spend):
+ · demo 100% · 45 sessions · $225.55
+
+est. total cost: $225.55
+prices from 2026-10-08 (1 days old)
+
+── Codex ──
+sipcode stats · all time · 8 sessions · gpt-5.4
+
+across 8 sessions you used 108,472,703 tokens.
+output ratio: 2.3% of all tokens. the other 97.7% was context sent with each request, 45% of it read from cache.
+
+estimated wasted on patterns sipcode catches:
+ · duplicate file reads 0 tokens ($0.0000)
+ · idle context 0 tokens ($0.0000)
+ · cache-creation overhead 0 tokens ($0.0000)
+
+trend: token spend per day (144 days)
+ ▁▁▄▁▄▄▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█▁▁▁▁▁▁▁▂
+ min: 0 · max: 46.0M · median: 0
+
+top 5 most expensive sessions:
+ 1. 2026-09-01 · 0199a000-0000-7000 · 887h 00m · 46,000,000 tok · $230.00
+ 2. 2026-06-15 · 0199a000-0000-7000 · 2h 30m · 18,750,000 tok · $41.63
+ 3. 2026-06-08 · 0199a000-0000-7000 · 2h 30m · 18,750,000 tok · $41.63
+ 4. 2026-06-01 · 0199a000-0000-7000 · 2h 30m · 18,750,000 tok · $41.63
+ 5. 2026-10-08 · 0199a0f7-11aa-7b02 · 12h 00m · 3,100,000 tok · $17.25
+
+projects (top 3 by spend):
+ · proj 91.1% · 4 sessions · $354.88
+ · my-app 4.4% · 1 sessions · $17.25
+ · sipcode 4.4% · 2 sessions · $17.25
+
+est. total cost: $389.38
+20,503 tokens on models without a known price (codex-auto-review): not included in the cost above.
+prices from 2026-10-09 (0 days old)
+
+Both tools: 258.0M tokens · ~$226 + ~$389 (+ unpriced)
+=== stats --agent codex
+exit 0
+sipcode stats · all time · 8 sessions · gpt-5.4
+
+across 8 sessions you used 108,472,703 tokens.
+output ratio: 2.3% of all tokens. the other 97.7% was context sent with each request, 45% of it read from cache.
+
+estimated wasted on patterns sipcode catches:
+ · duplicate file reads 0 tokens ($0.0000)
+ · idle context 0 tokens ($0.0000)
+ · cache-creation overhead 0 tokens ($0.0000)
+
+trend: token spend per day (144 days)
+ ▁▁▄▁▄▄▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█▁▁▁▁▁▁▁▂
+ min: 0 · max: 46.0M · median: 0
+
+top 5 most expensive sessions:
+ 1. 2026-09-01 · 0199a000-0000-7000 · 887h 00m · 46,000,000 tok · $230.00
+ 2. 2026-06-15 · 0199a000-0000-7000 · 2h 30m · 18,750,000 tok · $41.63
+ 3. 2026-06-08 · 0199a000-0000-7000 · 2h 30m · 18,750,000 tok · $41.63
+ 4. 2026-06-01 · 0199a000-0000-7000 · 2h 30m · 18,750,000 tok · $41.63
+ 5. 2026-10-08 · 0199a0f7-11aa-7b02 · 12h 00m · 3,100,000 tok · $17.25
+
+projects (top 3 by spend):
+ · proj 91.1% · 4 sessions · $354.88
+ · my-app 4.4% · 1 sessions · $17.25
+ · sipcode 4.4% · 2 sessions · $17.25
+
+est. total cost: $389.38
+20,503 tokens on models without a known price (codex-auto-review): not included in the cost above.
+prices from 2026-10-09 (0 days old)
+=== stats --agent claude-code
+exit 0
+sipcode stats · all time · 45 sessions · claude-opus-4-8
+
+across 45 sessions you used 149,570,386 tokens.
+output ratio: 0.4% of all tokens. the other 99.6% was context sent with each request, 91% of it read from cache.
+
+estimated wasted on patterns sipcode catches:
+ · duplicate file reads 0 tokens ($0.0000)
+ · idle context 0 tokens ($0.0000)
+ · cache-creation overhead 13,593,600 tokens ($25.49)
+
+trend: token spend per day (223 days)
+ ▃▂▃▁▄▂▃▁▁▂▁▂▃▁▁▁▁▁▁▁▁▁██▂▁▁▁▁▁
+ min: 0 · max: 30.8M · median: 0
+
+top 5 most expensive sessions:
+ 1. 2026-08-19 · sess0045 · 3h 59m · 30,768,720 tok · $22.03
+ 2. 2026-08-18 · sess0044 · 3h 59m · 30,768,720 tok · $22.03
+ 3. 2026-04-22 · sess0023 · 1h 43m · 2,517,090 tok · $3.54
+ 4. 2026-04-21 · sess0022 · 1h 43m · 2,517,090 tok · $3.54
+ 5. 2026-04-20 · sess0021 · 1h 43m · 2,517,090 tok · $3.54
+
+projects (top 1 by spend):
+ · demo 100% · 45 sessions · $225.55
+
+est. total cost: $225.55
+prices from 2026-10-08 (1 days old)
+=== stats --json
+exit 0
+{
+ "schemaVersion": "sipcode-stats/1",
+ "window": {
+ "sinceIso": "2026-03-01T00:00:00.000Z",
+ "untilIso": "2026-10-10T00:00:00.000Z",
+ "days": 223,
+ "raw": "all"
+ },
+ "agent": "claude-code",
+ "primaryModel": "claude-opus-4-8",
+ "sessionCount": 45,
+ "totals": {
+ "inputTokens": 164911,
+ "outputTokens": 634800,
+ "cacheReadTokens": 135177075,
+ "cacheCreationTokens": 13593600,
+ "totalTokens": 149570386,
+ "outputRatioPct": 0.4,
+ "estCostUSD": 225.5493
+ },
+ "savings": {
+ "duplicateReadsTokens": 0,
+ "idleContextTokens": 0,
+ "cacheCreationTokens": 13593600,
+ "duplicateReadsUSD": 0,
+ "idleContextUSD": 0,
+ "cacheCreationUSD": 25.488,
+ "totalRecoverableUSD": 25.488
+ },
+ "trendDaily": [
+ {
+ "date": "2026-03-01",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-02",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-03",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-06",
+ "tokens": 1910125,
+ "sessions": 1,
+ "estCostUSD": 3.1883
+ },
+ {
+ "date": "2026-03-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-10",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-11",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-12",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-13",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-15",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-16",
+ "tokens": 1910125,
+ "sessions": 1,
+ "estCostUSD": 3.1883
+ },
+ {
+ "date": "2026-03-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-18",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-19",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-20",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-21",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-22",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-23",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-25",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-26",
+ "tokens": 1910125,
+ "sessions": 1,
+ "estCostUSD": 3.1883
+ },
+ {
+ "date": "2026-03-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-31",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-04-01",
+ "tokens": 5034180,
+ "sessions": 2,
+ "estCostUSD": 5.8217
+ },
+ {
+ "date": "2026-04-02",
+ "tokens": 5034180,
+ "sessions": 2,
+ "estCostUSD": 5.8217
+ },
+ {
+ "date": "2026-04-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-05",
+ "tokens": 1910125,
+ "sessions": 1,
+ "estCostUSD": 3.1883
+ },
+ {
+ "date": "2026-04-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-10",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-04-11",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-04-12",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-04-13",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-15",
+ "tokens": 1910125,
+ "sessions": 1,
+ "estCostUSD": 3.1883
+ },
+ {
+ "date": "2026-04-16",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-18",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-19",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-20",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-04-21",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-04-22",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-04-23",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-25",
+ "tokens": 1910125,
+ "sessions": 1,
+ "estCostUSD": 3.1883
+ },
+ {
+ "date": "2026-04-26",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-01",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-10",
+ "tokens": 1824300,
+ "sessions": 1,
+ "estCostUSD": 14.9325
+ },
+ {
+ "date": "2026-05-11",
+ "tokens": 1824300,
+ "sessions": 1,
+ "estCostUSD": 14.9325
+ },
+ {
+ "date": "2026-05-12",
+ "tokens": 1824300,
+ "sessions": 1,
+ "estCostUSD": 14.9325
+ },
+ {
+ "date": "2026-05-13",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-15",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-16",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-18",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-19",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-20",
+ "tokens": 838642,
+ "sessions": 1,
+ "estCostUSD": 4.0242
+ },
+ {
+ "date": "2026-05-21",
+ "tokens": 838642,
+ "sessions": 1,
+ "estCostUSD": 4.0242
+ },
+ {
+ "date": "2026-05-22",
+ "tokens": 838642,
+ "sessions": 1,
+ "estCostUSD": 4.0242
+ },
+ {
+ "date": "2026-05-23",
+ "tokens": 838642,
+ "sessions": 1,
+ "estCostUSD": 4.0242
+ },
+ {
+ "date": "2026-05-24",
+ "tokens": 838642,
+ "sessions": 1,
+ "estCostUSD": 4.0242
+ },
+ {
+ "date": "2026-05-25",
+ "tokens": 838642,
+ "sessions": 1,
+ "estCostUSD": 4.0242
+ },
+ {
+ "date": "2026-05-26",
+ "tokens": 838642,
+ "sessions": 1,
+ "estCostUSD": 4.0242
+ },
+ {
+ "date": "2026-05-27",
+ "tokens": 838642,
+ "sessions": 1,
+ "estCostUSD": 4.0242
+ },
+ {
+ "date": "2026-05-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-30",
+ "tokens": 5034180,
+ "sessions": 2,
+ "estCostUSD": 4.5664
+ },
+ {
+ "date": "2026-05-31",
+ "tokens": 5034180,
+ "sessions": 2,
+ "estCostUSD": 4.5664
+ },
+ {
+ "date": "2026-06-01",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-10",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-11",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-12",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-13",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-15",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-16",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-18",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-19",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-20",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-21",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-22",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-23",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-25",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-26",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-01",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-10",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-11",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-12",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-13",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-15",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-16",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-18",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-19",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-20",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-21",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-22",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-23",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-25",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-26",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-31",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-01",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-10",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-11",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-12",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-13",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-15",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-16",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-18",
+ "tokens": 30768720,
+ "sessions": 1,
+ "estCostUSD": 22.0317
+ },
+ {
+ "date": "2026-08-19",
+ "tokens": 30768720,
+ "sessions": 1,
+ "estCostUSD": 22.0317
+ },
+ {
+ "date": "2026-08-20",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-21",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-22",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-23",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-25",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-26",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-28",
+ "tokens": 1990000,
+ "sessions": 1,
+ "estCostUSD": 3.987
+ },
+ {
+ "date": "2026-08-29",
+ "tokens": 1990000,
+ "sessions": 1,
+ "estCostUSD": 3.987
+ },
+ {
+ "date": "2026-08-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-31",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-01",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-10",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-11",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-12",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-13",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-15",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-16",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-18",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-19",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-20",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-21",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-22",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-23",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-25",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-26",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-01",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ }
+ ],
+ "topExpensive": [
+ {
+ "sessionId": "sess0045-0000-4000-8000-000000000000",
+ "sessionIdShort": "sess0045",
+ "startedAt": "2026-08-19T09:00:59.000Z",
+ "tokens": 30768720,
+ "estCostUSD": 22.0317,
+ "durationHuman": "3h 59m",
+ "project": "demo",
+ "label": ""
+ },
+ {
+ "sessionId": "sess0044-0000-4000-8000-000000000000",
+ "sessionIdShort": "sess0044",
+ "startedAt": "2026-08-18T09:00:59.000Z",
+ "tokens": 30768720,
+ "estCostUSD": 22.0317,
+ "durationHuman": "3h 59m",
+ "project": "demo",
+ "label": ""
+ },
+ {
+ "sessionId": "sess0023-0000-4000-8000-000000000000",
+ "sessionIdShort": "sess0023",
+ "startedAt": "2026-04-22T09:00:59.000Z",
+ "tokens": 2517090,
+ "estCostUSD": 3.5385,
+ "durationHuman": "1h 43m",
+ "project": "demo",
+ "label": ""
+ },
+ {
+ "sessionId": "sess0022-0000-4000-8000-000000000000",
+ "sessionIdShort": "sess0022",
+ "startedAt": "2026-04-21T09:00:59.000Z",
+ "tokens": 2517090,
+ "estCostUSD": 3.5385,
+ "durationHuman": "1h 43m",
+ "project": "demo",
+ "label": ""
+ },
+ {
+ "sessionId": "sess0021-0000-4000-8000-000000000000",
+ "sessionIdShort": "sess0021",
+ "startedAt": "2026-04-20T09:00:59.000Z",
+ "tokens": 2517090,
+ "estCostUSD": 3.5385,
+ "durationHuman": "1h 43m",
+ "project": "demo",
+ "label": ""
+ }
+ ],
+ "byProject": [
+ {
+ "name": "demo",
+ "projectHash": "C--demo",
+ "sessionCount": 45,
+ "tokens": 149570386,
+ "estCostUSD": 225.5493,
+ "pctOfSpend": 100
+ }
+ ],
+ "groupBy": "none",
+ "metaPricing": {
+ "asOf": "2026-10-08",
+ "ageDays": 1
+ },
+ "warnings": []
+}
+stderr: note: Codex logs found too. JSON covers Claude Code; run with --agent codex for Codex.
+=== stats --agent codex --json
+exit 0
+{
+ "schemaVersion": "sipcode-stats/1",
+ "window": {
+ "sinceIso": "2026-05-19T00:00:00.000Z",
+ "untilIso": "2026-10-10T00:00:00.000Z",
+ "days": 144,
+ "raw": "all"
+ },
+ "agent": "codex",
+ "primaryModel": "gpt-5.4",
+ "sessionCount": 8,
+ "totals": {
+ "inputTokens": 58772000,
+ "outputTokens": 2450703,
+ "cacheReadTokens": 47250000,
+ "cacheCreationTokens": 0,
+ "totalTokens": 108472703,
+ "outputRatioPct": 2.3,
+ "estCostUSD": 389.381
+ },
+ "savings": {
+ "duplicateReadsTokens": 0,
+ "idleContextTokens": 0,
+ "cacheCreationTokens": 0,
+ "duplicateReadsUSD": 0,
+ "idleContextUSD": 0,
+ "cacheCreationUSD": 0,
+ "totalRecoverableUSD": 0
+ },
+ "trendDaily": [
+ {
+ "date": "2026-05-19",
+ "tokens": 2200,
+ "sessions": 1,
+ "estCostUSD": 0.006
+ },
+ {
+ "date": "2026-05-20",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-21",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-22",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-23",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-25",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-26",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-31",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-01",
+ "tokens": 18750000,
+ "sessions": 1,
+ "estCostUSD": 41.625
+ },
+ {
+ "date": "2026-06-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-08",
+ "tokens": 18750000,
+ "sessions": 1,
+ "estCostUSD": 41.625
+ },
+ {
+ "date": "2026-06-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-10",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-11",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-12",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-13",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-15",
+ "tokens": 18750000,
+ "sessions": 1,
+ "estCostUSD": 41.625
+ },
+ {
+ "date": "2026-06-16",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-18",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-19",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-20",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-21",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-22",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-23",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-25",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-26",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-01",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-10",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-11",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-12",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-13",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-15",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-16",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-18",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-19",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-20",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-21",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-22",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-23",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-25",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-26",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-31",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-01",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-10",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-11",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-12",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-13",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-15",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-16",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-18",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-19",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-20",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-21",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-22",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-23",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-25",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-26",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-31",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-01",
+ "tokens": 46000000,
+ "sessions": 1,
+ "estCostUSD": 230
+ },
+ {
+ "date": "2026-09-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-10",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-11",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-12",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-13",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-15",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-16",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-18",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-19",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-20",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-21",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-22",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-23",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-25",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-26",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-01",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-07",
+ "tokens": 3100000,
+ "sessions": 1,
+ "estCostUSD": 17.25
+ },
+ {
+ "date": "2026-10-08",
+ "tokens": 3120503,
+ "sessions": 2,
+ "estCostUSD": 17.25
+ },
+ {
+ "date": "2026-10-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ }
+ ],
+ "topExpensive": [
+ {
+ "sessionId": "0199a000-0000-7000-8000-000000000001",
+ "sessionIdShort": "0199a000-0000-7000",
+ "startedAt": "2026-09-01T01:00:00.000Z",
+ "tokens": 46000000,
+ "estCostUSD": 230,
+ "durationHuman": "887h 00m",
+ "project": "proj",
+ "label": ""
+ },
+ {
+ "sessionId": "0199a000-0000-7000-8000-00000000000c",
+ "sessionIdShort": "0199a000-0000-7000",
+ "startedAt": "2026-06-15T01:00:00.000Z",
+ "tokens": 18750000,
+ "estCostUSD": 41.625,
+ "durationHuman": "2h 30m",
+ "project": "proj",
+ "label": ""
+ },
+ {
+ "sessionId": "0199a000-0000-7000-8000-00000000000b",
+ "sessionIdShort": "0199a000-0000-7000",
+ "startedAt": "2026-06-08T01:00:00.000Z",
+ "tokens": 18750000,
+ "estCostUSD": 41.625,
+ "durationHuman": "2h 30m",
+ "project": "proj",
+ "label": ""
+ },
+ {
+ "sessionId": "0199a000-0000-7000-8000-00000000000a",
+ "sessionIdShort": "0199a000-0000-7000",
+ "startedAt": "2026-06-01T01:00:00.000Z",
+ "tokens": 18750000,
+ "estCostUSD": 41.625,
+ "durationHuman": "2h 30m",
+ "project": "proj",
+ "label": ""
+ },
+ {
+ "sessionId": "0199a0f7-11aa-7b02-8000-00000000000b",
+ "sessionIdShort": "0199a0f7-11aa-7b02",
+ "startedAt": "2026-10-08T20:00:00.000Z",
+ "tokens": 3100000,
+ "estCostUSD": 17.25,
+ "durationHuman": "12h 00m",
+ "project": "my-app",
+ "label": ""
+ }
+ ],
+ "byProject": [
+ {
+ "name": "proj",
+ "projectHash": "-home-u-proj",
+ "sessionCount": 4,
+ "tokens": 102250000,
+ "estCostUSD": 354.875,
+ "pctOfSpend": 91.1
+ },
+ {
+ "name": "my-app",
+ "projectHash": "-home-u-my-app",
+ "sessionCount": 1,
+ "tokens": 3100000,
+ "estCostUSD": 17.25,
+ "pctOfSpend": 4.4
+ },
+ {
+ "name": "sipcode",
+ "projectHash": "C--work-sipcode",
+ "sessionCount": 2,
+ "tokens": 3120503,
+ "estCostUSD": 17.25,
+ "pctOfSpend": 4.4
+ },
+ {
+ "name": "p",
+ "projectHash": "C--p",
+ "sessionCount": 1,
+ "tokens": 2200,
+ "estCostUSD": 0.006,
+ "pctOfSpend": 0
+ }
+ ],
+ "groupBy": "none",
+ "metaPricing": {
+ "asOf": "2026-10-09",
+ "ageDays": 0
+ },
+ "warnings": []
+}
+stderr: 20,503 tokens on models without a known price (codex-auto-review): not included in the cost above.
+=== stats --agent claude-code --json
+exit 0
+{
+ "schemaVersion": "sipcode-stats/1",
+ "window": {
+ "sinceIso": "2026-03-01T00:00:00.000Z",
+ "untilIso": "2026-10-10T00:00:00.000Z",
+ "days": 223,
+ "raw": "all"
+ },
+ "agent": "claude-code",
+ "primaryModel": "claude-opus-4-8",
+ "sessionCount": 45,
+ "totals": {
+ "inputTokens": 164911,
+ "outputTokens": 634800,
+ "cacheReadTokens": 135177075,
+ "cacheCreationTokens": 13593600,
+ "totalTokens": 149570386,
+ "outputRatioPct": 0.4,
+ "estCostUSD": 225.5493
+ },
+ "savings": {
+ "duplicateReadsTokens": 0,
+ "idleContextTokens": 0,
+ "cacheCreationTokens": 13593600,
+ "duplicateReadsUSD": 0,
+ "idleContextUSD": 0,
+ "cacheCreationUSD": 25.488,
+ "totalRecoverableUSD": 25.488
+ },
+ "trendDaily": [
+ {
+ "date": "2026-03-01",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-02",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-03",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-06",
+ "tokens": 1910125,
+ "sessions": 1,
+ "estCostUSD": 3.1883
+ },
+ {
+ "date": "2026-03-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-10",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-11",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-12",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-13",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-15",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-16",
+ "tokens": 1910125,
+ "sessions": 1,
+ "estCostUSD": 3.1883
+ },
+ {
+ "date": "2026-03-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-18",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-19",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-20",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-21",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-22",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-23",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-03-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-25",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-26",
+ "tokens": 1910125,
+ "sessions": 1,
+ "estCostUSD": 3.1883
+ },
+ {
+ "date": "2026-03-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-03-31",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-04-01",
+ "tokens": 5034180,
+ "sessions": 2,
+ "estCostUSD": 5.8217
+ },
+ {
+ "date": "2026-04-02",
+ "tokens": 5034180,
+ "sessions": 2,
+ "estCostUSD": 5.8217
+ },
+ {
+ "date": "2026-04-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-05",
+ "tokens": 1910125,
+ "sessions": 1,
+ "estCostUSD": 3.1883
+ },
+ {
+ "date": "2026-04-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-10",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-04-11",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-04-12",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-04-13",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-15",
+ "tokens": 1910125,
+ "sessions": 1,
+ "estCostUSD": 3.1883
+ },
+ {
+ "date": "2026-04-16",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-18",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-19",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-20",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-04-21",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-04-22",
+ "tokens": 2517090,
+ "sessions": 1,
+ "estCostUSD": 3.5385
+ },
+ {
+ "date": "2026-04-23",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-25",
+ "tokens": 1910125,
+ "sessions": 1,
+ "estCostUSD": 3.1883
+ },
+ {
+ "date": "2026-04-26",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-04-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-01",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-10",
+ "tokens": 1824300,
+ "sessions": 1,
+ "estCostUSD": 14.9325
+ },
+ {
+ "date": "2026-05-11",
+ "tokens": 1824300,
+ "sessions": 1,
+ "estCostUSD": 14.9325
+ },
+ {
+ "date": "2026-05-12",
+ "tokens": 1824300,
+ "sessions": 1,
+ "estCostUSD": 14.9325
+ },
+ {
+ "date": "2026-05-13",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-15",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-16",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-18",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-19",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-20",
+ "tokens": 838642,
+ "sessions": 1,
+ "estCostUSD": 4.0242
+ },
+ {
+ "date": "2026-05-21",
+ "tokens": 838642,
+ "sessions": 1,
+ "estCostUSD": 4.0242
+ },
+ {
+ "date": "2026-05-22",
+ "tokens": 838642,
+ "sessions": 1,
+ "estCostUSD": 4.0242
+ },
+ {
+ "date": "2026-05-23",
+ "tokens": 838642,
+ "sessions": 1,
+ "estCostUSD": 4.0242
+ },
+ {
+ "date": "2026-05-24",
+ "tokens": 838642,
+ "sessions": 1,
+ "estCostUSD": 4.0242
+ },
+ {
+ "date": "2026-05-25",
+ "tokens": 838642,
+ "sessions": 1,
+ "estCostUSD": 4.0242
+ },
+ {
+ "date": "2026-05-26",
+ "tokens": 838642,
+ "sessions": 1,
+ "estCostUSD": 4.0242
+ },
+ {
+ "date": "2026-05-27",
+ "tokens": 838642,
+ "sessions": 1,
+ "estCostUSD": 4.0242
+ },
+ {
+ "date": "2026-05-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-05-30",
+ "tokens": 5034180,
+ "sessions": 2,
+ "estCostUSD": 4.5664
+ },
+ {
+ "date": "2026-05-31",
+ "tokens": 5034180,
+ "sessions": 2,
+ "estCostUSD": 4.5664
+ },
+ {
+ "date": "2026-06-01",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-10",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-11",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-12",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-13",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-15",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-16",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-18",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-19",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-20",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-21",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-22",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-23",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-25",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-26",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-06-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-01",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-10",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-11",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-12",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-13",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-15",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-16",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-18",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-19",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-20",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-21",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-22",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-23",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-25",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-26",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-07-31",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-01",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-10",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-11",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-12",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-13",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-15",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-16",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-18",
+ "tokens": 30768720,
+ "sessions": 1,
+ "estCostUSD": 22.0317
+ },
+ {
+ "date": "2026-08-19",
+ "tokens": 30768720,
+ "sessions": 1,
+ "estCostUSD": 22.0317
+ },
+ {
+ "date": "2026-08-20",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-21",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-22",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-23",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-25",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-26",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-28",
+ "tokens": 1990000,
+ "sessions": 1,
+ "estCostUSD": 3.987
+ },
+ {
+ "date": "2026-08-29",
+ "tokens": 1990000,
+ "sessions": 1,
+ "estCostUSD": 3.987
+ },
+ {
+ "date": "2026-08-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-08-31",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-01",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-10",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-11",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-12",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-13",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-14",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-15",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-16",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-17",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-18",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-19",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-20",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-21",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-22",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-23",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-24",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-25",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-26",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-27",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-28",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-29",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-09-30",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-01",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-02",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-03",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-04",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-05",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-06",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-07",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-08",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ },
+ {
+ "date": "2026-10-09",
+ "tokens": 0,
+ "sessions": 0,
+ "estCostUSD": 0
+ }
+ ],
+ "topExpensive": [
+ {
+ "sessionId": "sess0045-0000-4000-8000-000000000000",
+ "sessionIdShort": "sess0045",
+ "startedAt": "2026-08-19T09:00:59.000Z",
+ "tokens": 30768720,
+ "estCostUSD": 22.0317,
+ "durationHuman": "3h 59m",
+ "project": "demo",
+ "label": ""
+ },
+ {
+ "sessionId": "sess0044-0000-4000-8000-000000000000",
+ "sessionIdShort": "sess0044",
+ "startedAt": "2026-08-18T09:00:59.000Z",
+ "tokens": 30768720,
+ "estCostUSD": 22.0317,
+ "durationHuman": "3h 59m",
+ "project": "demo",
+ "label": ""
+ },
+ {
+ "sessionId": "sess0023-0000-4000-8000-000000000000",
+ "sessionIdShort": "sess0023",
+ "startedAt": "2026-04-22T09:00:59.000Z",
+ "tokens": 2517090,
+ "estCostUSD": 3.5385,
+ "durationHuman": "1h 43m",
+ "project": "demo",
+ "label": ""
+ },
+ {
+ "sessionId": "sess0022-0000-4000-8000-000000000000",
+ "sessionIdShort": "sess0022",
+ "startedAt": "2026-04-21T09:00:59.000Z",
+ "tokens": 2517090,
+ "estCostUSD": 3.5385,
+ "durationHuman": "1h 43m",
+ "project": "demo",
+ "label": ""
+ },
+ {
+ "sessionId": "sess0021-0000-4000-8000-000000000000",
+ "sessionIdShort": "sess0021",
+ "startedAt": "2026-04-20T09:00:59.000Z",
+ "tokens": 2517090,
+ "estCostUSD": 3.5385,
+ "durationHuman": "1h 43m",
+ "project": "demo",
+ "label": ""
+ }
+ ],
+ "byProject": [
+ {
+ "name": "demo",
+ "projectHash": "C--demo",
+ "sessionCount": 45,
+ "tokens": 149570386,
+ "estCostUSD": 225.5493,
+ "pctOfSpend": 100
+ }
+ ],
+ "groupBy": "none",
+ "metaPricing": {
+ "asOf": "2026-10-08",
+ "ageDays": 1
+ },
+ "warnings": []
+}
+=== today
+exit 0
+── Claude Code ──
+sipcode today · today
+
+(no sessions today — baseline 30d still shown above headline)
+
+No sessions today yet. Your 30-day median is $0.00/day — go build something.
+
+── Codex ──
+sipcode today · today
+
+(no sessions today — baseline 30d still shown above headline)
+
+No sessions today yet. Your 30-day median is $17.25/day — go build something.
+Codex weekly limit: 62% used, resets Wed 08:30. At this pace you run out Sat ~16:00.
+
+=== today --agent codex
+exit 0
+sipcode today · today
+
+(no sessions today — baseline 30d still shown above headline)
+
+No sessions today yet. Your 30-day median is $17.25/day — go build something.
+Codex weekly limit: 62% used, resets Wed 08:30. At this pace you run out Sat ~16:00.
+=== today --agent claude-code
+exit 0
+sipcode today · today
+
+(no sessions today — baseline 30d still shown above headline)
+
+No sessions today yet. Your 30-day median is $0.00/day — go build something.
+=== today --json
+exit 0
+{
+ "schemaVersion": "sipcode-today/1",
+ "status": "no-sessions-today",
+ "today": null,
+ "baseline": {
+ "windowDays": 30,
+ "isPartial": false,
+ "medianSpendPerDayUSD": 0,
+ "medianTokensPerDay": 0,
+ "medianOutputRatioPct": 0
+ },
+ "comparison": null,
+ "headline": "No sessions today yet. Your 30-day median is $0.00/day — go build something."
+}
+stderr: note: Codex logs found too. JSON covers Claude Code; run with --agent codex for Codex.
+=== today --agent codex --json
+exit 0
+{
+ "schemaVersion": "sipcode-today/1",
+ "status": "no-sessions-today",
+ "today": null,
+ "baseline": {
+ "windowDays": 30,
+ "isPartial": false,
+ "medianSpendPerDayUSD": 17.25,
+ "medianTokensPerDay": 3110251.5,
+ "medianOutputRatioPct": 3.0326797179956353
+ },
+ "comparison": null,
+ "headline": "No sessions today yet. Your 30-day median is $17.25/day — go build something."
+}
+=== today --agent claude-code --json
+exit 0
+{
+ "schemaVersion": "sipcode-today/1",
+ "status": "no-sessions-today",
+ "today": null,
+ "baseline": {
+ "windowDays": 30,
+ "isPartial": false,
+ "medianSpendPerDayUSD": 0,
+ "medianTokensPerDay": 0,
+ "medianOutputRatioPct": 0
+ },
+ "comparison": null,
+ "headline": "No sessions today yet. Your 30-day median is $0.00/day — go build something."
+}
+=== forecast
+exit 0
+── Claude Code ──
+sipcode forecast · projection to end of this month
+
+ No sessions in your last 14 days — can't forecast a trajectory.
+
+── Codex ──
+sipcode forecast · projection to end of October 2026 (22 days remaining)
+
+ current pace (last 14 days)
+ avg daily spend $2.46 across 3 sessions
+ median daily spend $0.00
+
+ projected month-end
+ spend $88.71 (range: $71 – $106)
+ 20,503 tokens on models without a known price (codex-auto-review): not included in the cost above.
+ vs last month 61.4% less than previous month ($230.00)
+
+ At your current pace ($2.46/day across 14 days of recent sessions), you're on track to spend about $89 by month-end. 61.4% less than last month ($230.00). Confidence range: $71–$106.
+
+=== forecast --agent codex
+exit 0
+sipcode forecast · projection to end of October 2026 (22 days remaining)
+
+ current pace (last 14 days)
+ avg daily spend $2.46 across 3 sessions
+ median daily spend $0.00
+
+ projected month-end
+ spend $88.71 (range: $71 – $106)
+ 20,503 tokens on models without a known price (codex-auto-review): not included in the cost above.
+ vs last month 61.4% less than previous month ($230.00)
+
+ At your current pace ($2.46/day across 14 days of recent sessions), you're on track to spend about $89 by month-end. 61.4% less than last month ($230.00). Confidence range: $71–$106.
+=== forecast --agent claude-code
+exit 0
+sipcode forecast · projection to end of this month
+
+ No sessions in your last 14 days — can't forecast a trajectory.
+=== forecast --json
+exit 0
+{
+ "schemaVersion": "sipcode-forecast/1",
+ "status": "no-recent-activity",
+ "trajectoryInput": null,
+ "monthEnd": null,
+ "comparison": null,
+ "headline": "No sessions in your last 14 days — can't forecast a trajectory."
+}
+stderr: note: Codex logs found too. JSON covers Claude Code; run with --agent codex for Codex.
+=== forecast --agent codex --json
+exit 0
+{
+ "schemaVersion": "sipcode-forecast/1",
+ "status": "ok",
+ "trajectoryInput": {
+ "windowDays": 14,
+ "isPartial": false,
+ "sessionsSampled": 3,
+ "avgDailySpendUSD": 2.4642857142857144,
+ "medianDailySpendUSD": 0
+ },
+ "monthEnd": {
+ "monthLabel": "October 2026",
+ "daysRemaining": 22,
+ "projectedSpendUSD": 88.71428571428572,
+ "confidenceLowUSD": 70.97142857142858,
+ "confidenceHighUSD": 106.45714285714287,
+ "spendSoFarUSD": 34.5
+ },
+ "comparison": {
+ "lastMonthSpendUSD": 230,
+ "vsLastMonthPct": -61.42857142857142
+ },
+ "headline": "At your current pace ($2.46/day across 14 days of recent sessions), you're on track to spend about $89 by month-end. 61.4% less than last month ($230.00). Confidence range: $71–$106."
+}
+stderr: 20,503 tokens on models without a known price (codex-auto-review): not included in the cost above.
+=== forecast --agent claude-code --json
+exit 0
+{
+ "schemaVersion": "sipcode-forecast/1",
+ "status": "no-recent-activity",
+ "trajectoryInput": null,
+ "monthEnd": null,
+ "comparison": null,
+ "headline": "No sessions in your last 14 days — can't forecast a trajectory."
+}
+=== trend
+exit 0
+── Claude Code ──
+sipcode trend · output ratio (output tokens / total tokens, higher = leaner context)
+
+ insufficient data — need at least 5 days with sessions to compute a trend.
+
+ 2026-09-09 → 2026-10-09
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+ min 0.0% · median 0.0% · max 0.0%
+
+ total sessions across window: 0
+
+── Codex ──
+sipcode trend · output ratio (output tokens / total tokens, higher = leaner context)
+
+ insufficient data — need at least 5 days with sessions to compute a trend.
+
+ 2026-09-09 → 2026-10-09
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██▁
+ min 0.0% · median 0.0% · max 3.2%
+
+ total sessions across window: 3
+
+=== trend --agent codex
+exit 0
+sipcode trend · output ratio (output tokens / total tokens, higher = leaner context)
+
+ insufficient data — need at least 5 days with sessions to compute a trend.
+
+ 2026-09-09 → 2026-10-09
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██▁
+ min 0.0% · median 0.0% · max 3.2%
+
+ total sessions across window: 3
+=== trend --agent claude-code
+exit 0
+sipcode trend · output ratio (output tokens / total tokens, higher = leaner context)
+
+ insufficient data — need at least 5 days with sessions to compute a trend.
+
+ 2026-09-09 → 2026-10-09
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+ min 0.0% · median 0.0% · max 0.0%
+
+ total sessions across window: 0
+=== trend --json
+exit 0
+{
+ "schemaVersion": "sipcode-trend/1",
+ "metric": "output-ratio",
+ "window": {
+ "since": "2026-09-09",
+ "until": "2026-10-09"
+ },
+ "verdict": "insufficient-data",
+ "median": 0,
+ "slopePerDay": 0,
+ "days": [
+ {
+ "date": "2026-09-09",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-10",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-11",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-12",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-13",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-14",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-15",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-16",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-17",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-18",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-19",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-20",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-21",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-22",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-23",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-24",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-25",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-26",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-27",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-28",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-29",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-30",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-01",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-02",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-03",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-04",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-05",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-06",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-07",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-08",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-09",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ }
+ ]
+}
+stderr: note: Codex logs found too. JSON covers Claude Code; run with --agent codex for Codex.
+=== trend --agent codex --json
+exit 0
+{
+ "schemaVersion": "sipcode-trend/1",
+ "metric": "output-ratio",
+ "window": {
+ "since": "2026-09-09",
+ "until": "2026-10-09"
+ },
+ "verdict": "insufficient-data",
+ "median": 0.0322326860600317,
+ "slopePerDay": 0,
+ "days": [
+ {
+ "date": "2026-09-09",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-10",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-11",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-12",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-13",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-14",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-15",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-16",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-17",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-18",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-19",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-20",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-21",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-22",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-23",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-24",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-25",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-26",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-27",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-28",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-29",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-30",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-01",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-02",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-03",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-04",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-05",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-06",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-07",
+ "sessions": 1,
+ "value": 0.03225806451612903,
+ "numerator": 100000,
+ "denominator": 3100000
+ },
+ {
+ "date": "2026-10-08",
+ "sessions": 2,
+ "value": 0.032207307603934365,
+ "numerator": 100503,
+ "denominator": 3120503
+ },
+ {
+ "date": "2026-10-09",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ }
+ ]
+}
+stderr: 20,503 tokens on models without a known price (codex-auto-review): not included in the cost above.
+=== trend --agent claude-code --json
+exit 0
+{
+ "schemaVersion": "sipcode-trend/1",
+ "metric": "output-ratio",
+ "window": {
+ "since": "2026-09-09",
+ "until": "2026-10-09"
+ },
+ "verdict": "insufficient-data",
+ "median": 0,
+ "slopePerDay": 0,
+ "days": [
+ {
+ "date": "2026-09-09",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-10",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-11",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-12",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-13",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-14",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-15",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-16",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-17",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-18",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-19",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-20",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-21",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-22",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-23",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-24",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-25",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-26",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-27",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-28",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-29",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-09-30",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-01",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-02",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-03",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-04",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-05",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-06",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-07",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-08",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ },
+ {
+ "date": "2026-10-09",
+ "sessions": 0,
+ "value": 0,
+ "numerator": 0,
+ "denominator": 0
+ }
+ ]
+}
+=== impact
+exit 0
+── Claude Code ──
+sipcode impact · before vs after the optimizer kicked in
+──────────────────────────────────────────────────────────────────────────
+all-time totals (across every Claude Code session on disk):
+──────────────────────────────────────────────────────────────────────────
+ sessions: 45
+ total tokens: 149.57M
+ total spend: $225.55
+ output ratio: 0.4%
+──────────────────────────────────────────────────────────────────────────
+(no install marker = no before/after split possible)
+
+found 45 sessions across all time (149.57M tokens, $225.55 total) — but no install marker, so no before/after split is possible. Run `sipcode rules --install` in your project, or pass since=YYYY-MM-DD, to enable a real comparison.
+
+
+ • If you've been using Sipcode without `rules --install`, pass --since YYYY-MM-DD to set the pivot manually.
+ • Or run `sipcode rules --install` to start measuring the impact going forward.
+
+
+── Codex ──
+sipcode impact · before vs after the optimizer kicked in
+──────────────────────────────────────────────────────────────────────────
+all-time totals (across every Codex session on disk):
+──────────────────────────────────────────────────────────────────────────
+ sessions: 8
+ total tokens: 108.47M
+ total spend: $389.38
+ 20,503 tokens on models without a known price (codex-auto-review): not included in the cost above.
+ output ratio: 2.3%
+──────────────────────────────────────────────────────────────────────────
+(no install marker = no before/after split possible)
+
+found 8 sessions across all time (108.47M tokens, $389.38 total), but no install marker, so no before/after split is possible. Setting Sipcode up (rules, MCP, proxy) is not supported for Codex yet; pass --since YYYY-MM-DD to compare before and after a date.
+
+
+ • Setting Sipcode up (rules, MCP, proxy) is not supported for Codex yet, so there is no install date to compare from.
+ • Pass --since YYYY-MM-DD to compare before and after a date of your choice.
+
+
+=== impact --agent codex
+exit 0
+sipcode impact · before vs after the optimizer kicked in
+──────────────────────────────────────────────────────────────────────────
+all-time totals (across every Codex session on disk):
+──────────────────────────────────────────────────────────────────────────
+ sessions: 8
+ total tokens: 108.47M
+ total spend: $389.38
+ 20,503 tokens on models without a known price (codex-auto-review): not included in the cost above.
+ output ratio: 2.3%
+──────────────────────────────────────────────────────────────────────────
+(no install marker = no before/after split possible)
+
+found 8 sessions across all time (108.47M tokens, $389.38 total), but no install marker, so no before/after split is possible. Setting Sipcode up (rules, MCP, proxy) is not supported for Codex yet; pass --since YYYY-MM-DD to compare before and after a date.
+
+
+ • Setting Sipcode up (rules, MCP, proxy) is not supported for Codex yet, so there is no install date to compare from.
+ • Pass --since YYYY-MM-DD to compare before and after a date of your choice.
+
+=== impact --agent claude-code
+exit 0
+sipcode impact · before vs after the optimizer kicked in
+──────────────────────────────────────────────────────────────────────────
+all-time totals (across every Claude Code session on disk):
+──────────────────────────────────────────────────────────────────────────
+ sessions: 45
+ total tokens: 149.57M
+ total spend: $225.55
+ output ratio: 0.4%
+──────────────────────────────────────────────────────────────────────────
+(no install marker = no before/after split possible)
+
+found 45 sessions across all time (149.57M tokens, $225.55 total) — but no install marker, so no before/after split is possible. Run `sipcode rules --install` in your project, or pass since=YYYY-MM-DD, to enable a real comparison.
+
+
+ • If you've been using Sipcode without `rules --install`, pass --since YYYY-MM-DD to set the pivot manually.
+ • Or run `sipcode rules --install` to start measuring the impact going forward.
+
+=== impact --json
+exit 0
+{
+ "schemaVersion": "sipcode-impact/1",
+ "status": "no-install-marker",
+ "installedAtIso": null,
+ "markerSource": "none",
+ "before": {
+ "sinceIso": "2026-03-01T09:00:59.000Z",
+ "untilIso": "2026-10-09T10:00:00.000Z",
+ "days": 222,
+ "sessionCount": 0,
+ "totalTokens": 0,
+ "estCostUSD": 0,
+ "avgCostPerSessionUSD": 0,
+ "avgTokensPerSession": 0,
+ "outputRatioPct": 0,
+ "recoverableTokens": 0
+ },
+ "after": {
+ "sinceIso": "2026-03-01T09:00:59.000Z",
+ "untilIso": "2026-10-09T10:00:00.000Z",
+ "days": 222,
+ "sessionCount": 0,
+ "totalTokens": 0,
+ "estCostUSD": 0,
+ "avgCostPerSessionUSD": 0,
+ "avgTokensPerSession": 0,
+ "outputRatioPct": 0,
+ "recoverableTokens": 0
+ },
+ "delta": null,
+ "warningReason": "no-install-marker",
+ "allTime": {
+ "sinceIso": "2026-03-01T09:00:59.000Z",
+ "untilIso": "2026-10-09T10:00:00.000Z",
+ "days": 222,
+ "sessionCount": 45,
+ "totalTokens": 149570386,
+ "estCostUSD": 225.55,
+ "avgCostPerSessionUSD": 5.01,
+ "avgTokensPerSession": 3323786,
+ "outputRatioPct": 0.4,
+ "recoverableTokens": 13593600
+ },
+ "headline": "found 45 sessions across all time (149.57M tokens, $225.55 total) — but no install marker, so no before/after split is possible. Run `sipcode rules --install` in your project, or pass since=YYYY-MM-DD, to enable a real comparison.",
+ "notes": [
+ "If you've been using Sipcode without `rules --install`, pass --since YYYY-MM-DD to set the pivot manually.",
+ "Or run `sipcode rules --install` to start measuring the impact going forward."
+ ]
+}
+stderr: note: Codex logs found too. JSON covers Claude Code; run with --agent codex for Codex.
+=== impact --agent codex --json
+exit 0
+{
+ "schemaVersion": "sipcode-impact/1",
+ "status": "no-install-marker",
+ "installedAtIso": null,
+ "markerSource": "none",
+ "before": {
+ "sinceIso": "2026-05-19T10:00:00Z",
+ "untilIso": "2026-10-09T10:00:00.000Z",
+ "days": 143,
+ "sessionCount": 0,
+ "totalTokens": 0,
+ "estCostUSD": 0,
+ "avgCostPerSessionUSD": 0,
+ "avgTokensPerSession": 0,
+ "outputRatioPct": 0,
+ "recoverableTokens": 0
+ },
+ "after": {
+ "sinceIso": "2026-05-19T10:00:00Z",
+ "untilIso": "2026-10-09T10:00:00.000Z",
+ "days": 143,
+ "sessionCount": 0,
+ "totalTokens": 0,
+ "estCostUSD": 0,
+ "avgCostPerSessionUSD": 0,
+ "avgTokensPerSession": 0,
+ "outputRatioPct": 0,
+ "recoverableTokens": 0
+ },
+ "delta": null,
+ "warningReason": "no-install-marker",
+ "allTime": {
+ "sinceIso": "2026-05-19T10:00:00Z",
+ "untilIso": "2026-10-09T10:00:00.000Z",
+ "days": 143,
+ "sessionCount": 8,
+ "totalTokens": 108472703,
+ "estCostUSD": 389.38,
+ "avgCostPerSessionUSD": 48.67,
+ "avgTokensPerSession": 13559088,
+ "outputRatioPct": 2.3,
+ "recoverableTokens": 0
+ },
+ "headline": "found 8 sessions across all time (108.47M tokens, $389.38 total), but no install marker, so no before/after split is possible. Setting Sipcode up (rules, MCP, proxy) is not supported for Codex yet; pass --since YYYY-MM-DD to compare before and after a date.",
+ "notes": [
+ "Setting Sipcode up (rules, MCP, proxy) is not supported for Codex yet, so there is no install date to compare from.",
+ "Pass --since YYYY-MM-DD to compare before and after a date of your choice."
+ ]
+}
+stderr: 20,503 tokens on models without a known price (codex-auto-review): not included in the cost above.
+=== impact --agent claude-code --json
+exit 0
+{
+ "schemaVersion": "sipcode-impact/1",
+ "status": "no-install-marker",
+ "installedAtIso": null,
+ "markerSource": "none",
+ "before": {
+ "sinceIso": "2026-03-01T09:00:59.000Z",
+ "untilIso": "2026-10-09T10:00:00.000Z",
+ "days": 222,
+ "sessionCount": 0,
+ "totalTokens": 0,
+ "estCostUSD": 0,
+ "avgCostPerSessionUSD": 0,
+ "avgTokensPerSession": 0,
+ "outputRatioPct": 0,
+ "recoverableTokens": 0
+ },
+ "after": {
+ "sinceIso": "2026-03-01T09:00:59.000Z",
+ "untilIso": "2026-10-09T10:00:00.000Z",
+ "days": 222,
+ "sessionCount": 0,
+ "totalTokens": 0,
+ "estCostUSD": 0,
+ "avgCostPerSessionUSD": 0,
+ "avgTokensPerSession": 0,
+ "outputRatioPct": 0,
+ "recoverableTokens": 0
+ },
+ "delta": null,
+ "warningReason": "no-install-marker",
+ "allTime": {
+ "sinceIso": "2026-03-01T09:00:59.000Z",
+ "untilIso": "2026-10-09T10:00:00.000Z",
+ "days": 222,
+ "sessionCount": 45,
+ "totalTokens": 149570386,
+ "estCostUSD": 225.55,
+ "avgCostPerSessionUSD": 5.01,
+ "avgTokensPerSession": 3323786,
+ "outputRatioPct": 0.4,
+ "recoverableTokens": 13593600
+ },
+ "headline": "found 45 sessions across all time (149.57M tokens, $225.55 total) — but no install marker, so no before/after split is possible. Run `sipcode rules --install` in your project, or pass since=YYYY-MM-DD, to enable a real comparison.",
+ "notes": [
+ "If you've been using Sipcode without `rules --install`, pass --since YYYY-MM-DD to set the pivot manually.",
+ "Or run `sipcode rules --install` to start measuring the impact going forward."
+ ]
+}
+=== why
+exit 0
+── Codex ──
+(showing latest session 0199a0f7-11aa-7b02 from -home-u-my-app; pass --here to scope to this directory)
+
+sipcode why · session 0199a0f7-11aa-7b02 · 12h 0m · gpt-5.4
+project: -home-u-my-app
+
+you used 3,100,000 tokens. 100,000 were output (text, code and reasoning). the other 3,000,000 were context sent with each request.
+output ratio: 3.2% of the tokens not read from cache
+
+sipcode estimates ~0 tokens were RECOVERABLE this session (potential, not yet realized). (Sipcode cannot capture this: setup is not supported for Codex yet.)
+ · smart manifest (S001): 0 tokens
+ · read-once cache (S030): 0 tokens
+ · diff-output (S021): 0 tokens
+
+top leaks (this session):
+ 1. input tokens (system + prompts) — 3,000,000 tokens (rehydrated each turn)
+
+est. cost: $17.2500 · 0 tool calls · 0 distinct files read
+(pass --verbose for the full breakdown)
+
+prices from 2026-10-09 (0 days old)
+
+run `npx sipcode stats --agent codex` to track your Codex spend; setting Sipcode up (rules, MCP, proxy) is not supported for Codex yet.
+
+Claude Code also has a recent session (sess0043): run with --agent claude-code.
+=== why --agent codex
+exit 0
+(showing latest session 0199a0f7-11aa-7b02 from -home-u-my-app; pass --here to scope to this directory)
+
+sipcode why · session 0199a0f7-11aa-7b02 · 12h 0m · gpt-5.4
+project: -home-u-my-app
+
+you used 3,100,000 tokens. 100,000 were output (text, code and reasoning). the other 3,000,000 were context sent with each request.
+output ratio: 3.2% of the tokens not read from cache
+
+sipcode estimates ~0 tokens were RECOVERABLE this session (potential, not yet realized). (Sipcode cannot capture this: setup is not supported for Codex yet.)
+ · smart manifest (S001): 0 tokens
+ · read-once cache (S030): 0 tokens
+ · diff-output (S021): 0 tokens
+
+top leaks (this session):
+ 1. input tokens (system + prompts) — 3,000,000 tokens (rehydrated each turn)
+
+est. cost: $17.2500 · 0 tool calls · 0 distinct files read
+(pass --verbose for the full breakdown)
+
+prices from 2026-10-09 (0 days old)
+
+run `npx sipcode stats --agent codex` to track your Codex spend; setting Sipcode up (rules, MCP, proxy) is not supported for Codex yet.
+=== why --agent claude-code
+exit 0
+(showing latest session sess0043 from C--demo; pass --here to scope to this directory)
+
+sipcode why · session sess0043 · 24m 1s · claude-fable-5-1
+project: C--demo
+
+you used 1,990,000 tokens. 10,000 were output (text, code and reasoning). the other 1,980,000 were context sent with each request, 90% of it read from cache.
+output ratio: 5% of the tokens not read from cache
+
+sipcode estimates ~24,640 tokens were RECOVERABLE this session (potential, not yet realized). (Run `sipcode rules --install` to start capturing this for real, then `sipcode impact` to measure.)
+ · smart manifest (S001): 24,640 tokens
+ · read-once cache (S030): 0 tokens
+ · diff-output (S021): 0 tokens
+
+top leaks (this session):
+ 1. cache-creation overhead — 112,000 tokens (context written into cache)
+ 2. input tokens (system + prompts) — 80,000 tokens (rehydrated each turn)
+
+est. cost: $3.9870 · 0 tool calls · 0 distinct files read
+(pass --verbose for the full breakdown)
+
+prices from 2026-06-11 (120 days old)
+
+run `npx sipcode init` to start saving on your next session.
+=== why --json
+exit 0
+{
+ "schemaVersion": "sipcode-why/1",
+ "header": {
+ "sessionIdShort": "sess0043",
+ "model": "claude-fable-5-1",
+ "durationHuman": "24m 1s",
+ "projectHash": "C--demo"
+ },
+ "punchline": {
+ "totalTokens": 1990000,
+ "outputTokens": 10000,
+ "nonOutputTokens": 1980000,
+ "outputRatioPct": 5,
+ "oneLiner": "you used 1,990,000 tokens. 10,000 were output (text, code and reasoning). the other 1,980,000 were context sent with each request, 90% of it read from cache."
+ },
+ "estimatedSavings": {
+ "totalTokens": 24640,
+ "totalUSD": 0.04936667336683417,
+ "breakdown": {
+ "s001_manifest": 24640,
+ "s030_readOnce": 0,
+ "s021_diffOutput": 0
+ }
+ },
+ "topLeaks": [
+ {
+ "rank": 1,
+ "title": "cache-creation overhead",
+ "tokens": 112000,
+ "detail": "context written into cache"
+ },
+ {
+ "rank": 2,
+ "title": "input tokens (system + prompts)",
+ "tokens": 80000,
+ "detail": "rehydrated each turn"
+ }
+ ],
+ "totals": {
+ "inputTokens": 80000,
+ "outputTokens": 10000,
+ "cacheReadTokens": 1788000,
+ "cacheCreationTokens": 112000,
+ "estCostUSD": 3.9869999999999997,
+ "toolCallCount": 0,
+ "distinctFilesRead": 0
+ },
+ "topExpensive": [],
+ "duplicates": [],
+ "idle": [],
+ "warnings": [],
+ "metaPricing": {
+ "asOf": "2026-06-11",
+ "ageDays": 120
+ },
+ "nextStep": "run `npx sipcode init` to start saving on your next session."
+}
+stderr: note: Codex logs found too. JSON covers Claude Code; run with --agent codex for Codex.
+=== why --agent codex --json
+exit 0
+{
+ "schemaVersion": "sipcode-why/1",
+ "header": {
+ "sessionIdShort": "0199a0f7-11aa-7b02",
+ "model": "gpt-5.4",
+ "durationHuman": "12h 0m",
+ "projectHash": "-home-u-my-app"
+ },
+ "punchline": {
+ "totalTokens": 3100000,
+ "outputTokens": 100000,
+ "nonOutputTokens": 3000000,
+ "outputRatioPct": 3.2,
+ "oneLiner": "you used 3,100,000 tokens. 100,000 were output (text, code and reasoning). the other 3,000,000 were context sent with each request."
+ },
+ "estimatedSavings": {
+ "totalTokens": 0,
+ "totalUSD": 0,
+ "breakdown": {
+ "s001_manifest": 0,
+ "s030_readOnce": 0,
+ "s021_diffOutput": 0
+ }
+ },
+ "topLeaks": [
+ {
+ "rank": 1,
+ "title": "input tokens (system + prompts)",
+ "tokens": 3000000,
+ "detail": "rehydrated each turn"
+ }
+ ],
+ "totals": {
+ "inputTokens": 3000000,
+ "outputTokens": 100000,
+ "cacheReadTokens": 0,
+ "cacheCreationTokens": 0,
+ "estCostUSD": 17.25,
+ "toolCallCount": 0,
+ "distinctFilesRead": 0
+ },
+ "topExpensive": [],
+ "duplicates": [],
+ "idle": [],
+ "warnings": [],
+ "metaPricing": {
+ "asOf": "2026-10-09",
+ "ageDays": 0
+ },
+ "nextStep": "run `npx sipcode stats --agent codex` to track your Codex spend; setting Sipcode up (rules, MCP, proxy) is not supported for Codex yet."
+}
+=== why --agent claude-code --json
+exit 0
+{
+ "schemaVersion": "sipcode-why/1",
+ "header": {
+ "sessionIdShort": "sess0043",
+ "model": "claude-fable-5-1",
+ "durationHuman": "24m 1s",
+ "projectHash": "C--demo"
+ },
+ "punchline": {
+ "totalTokens": 1990000,
+ "outputTokens": 10000,
+ "nonOutputTokens": 1980000,
+ "outputRatioPct": 5,
+ "oneLiner": "you used 1,990,000 tokens. 10,000 were output (text, code and reasoning). the other 1,980,000 were context sent with each request, 90% of it read from cache."
+ },
+ "estimatedSavings": {
+ "totalTokens": 24640,
+ "totalUSD": 0.04936667336683417,
+ "breakdown": {
+ "s001_manifest": 24640,
+ "s030_readOnce": 0,
+ "s021_diffOutput": 0
+ }
+ },
+ "topLeaks": [
+ {
+ "rank": 1,
+ "title": "cache-creation overhead",
+ "tokens": 112000,
+ "detail": "context written into cache"
+ },
+ {
+ "rank": 2,
+ "title": "input tokens (system + prompts)",
+ "tokens": 80000,
+ "detail": "rehydrated each turn"
+ }
+ ],
+ "totals": {
+ "inputTokens": 80000,
+ "outputTokens": 10000,
+ "cacheReadTokens": 1788000,
+ "cacheCreationTokens": 112000,
+ "estCostUSD": 3.9869999999999997,
+ "toolCallCount": 0,
+ "distinctFilesRead": 0
+ },
+ "topExpensive": [],
+ "duplicates": [],
+ "idle": [],
+ "warnings": [],
+ "metaPricing": {
+ "asOf": "2026-06-11",
+ "ageDays": 120
+ },
+ "nextStep": "run `npx sipcode init` to start saving on your next session."
+}
+=== receipt
+exit 0
+── Codex ──
+sipcode · session 0199a0f7-11aa-7b02 · 12h 0m · Oct 08, 2026
+
+ 3,100,000 tokens wasted ($17.2500)
+
+ what we caught
+ 1. input tokens (system + prompts) 3,000,000
+
+ prices as of 2026-10-09
+
+wrote C:/nonexistent-sipcode-unread/.sipcode/receipts/0199a0f7-11aa-7b02/receipt.html
+→ file://C:/nonexistent-sipcode-unread/.sipcode/receipts/0199a0f7-11aa-7b02/receipt.html
+
+Claude Code also has a recent session (sess0043): run with --agent claude-code.
+=== receipt --agent codex
+exit 0
+sipcode · session 0199a0f7-11aa-7b02 · 12h 0m · Oct 08, 2026
+
+ 3,100,000 tokens wasted ($17.2500)
+
+ what we caught
+ 1. input tokens (system + prompts) 3,000,000
+
+ prices as of 2026-10-09
+
+wrote C:/nonexistent-sipcode-unread/.sipcode/receipts/0199a0f7-11aa-7b02/receipt.html
+→ file://C:/nonexistent-sipcode-unread/.sipcode/receipts/0199a0f7-11aa-7b02/receipt.html
+=== receipt --agent claude-code
+exit 0
+sipcode · session sess · 24m 1s · Aug 29, 2026
+
+ 24,640 tokens sipped ($0.0494)
+
+ what we caught
+ 1. cache-creation overhead 112,000
+ 2. input tokens (system + prompts) 80,000
+
+ prices as of 2026-06-11
+
+wrote C:/nonexistent-sipcode-unread/.sipcode/receipts/sess/receipt.html
+→ file://C:/nonexistent-sipcode-unread/.sipcode/receipts/sess/receipt.html
+=== receipt --json
+exit 0
+{
+ "schemaVersion": "sipcode-receipt/1",
+ "variant": "post-install",
+ "htmlPath": "C:/nonexistent-sipcode-unread/.sipcode/receipts/sess/receipt.html",
+ "pngPath": null,
+ "hero": {
+ "tokens": 24640,
+ "tokensDisplay": "24,640",
+ "sublabel": "sipped",
+ "dollarDisplay": "$0.0494",
+ "pricingAsOf": "2026-06-11"
+ },
+ "leaks": [
+ {
+ "rank": 1,
+ "title": "cache-creation overhead",
+ "tokensDisplay": "112,000"
+ },
+ {
+ "rank": 2,
+ "title": "input tokens (system + prompts)",
+ "tokensDisplay": "80,000"
+ }
+ ],
+ "sessionIdShort": "sess",
+ "durationDisplay": "24m 1s",
+ "dateDisplay": "Aug 29, 2026"
+}
+stderr: note: Codex logs found too. JSON covers Claude Code; run with --agent codex for Codex.
+=== receipt --agent codex --json
+exit 0
+{
+ "schemaVersion": "sipcode-receipt/1",
+ "variant": "pre-install",
+ "htmlPath": "C:/nonexistent-sipcode-unread/.sipcode/receipts/0199a0f7-11aa-7b02/receipt.html",
+ "pngPath": null,
+ "hero": {
+ "tokens": 3100000,
+ "tokensDisplay": "3,100,000",
+ "sublabel": "wasted",
+ "dollarDisplay": "$17.2500",
+ "pricingAsOf": "2026-10-09"
+ },
+ "leaks": [
+ {
+ "rank": 1,
+ "title": "input tokens (system + prompts)",
+ "tokensDisplay": "3,000,000"
+ }
+ ],
+ "sessionIdShort": "0199a0f7-11aa-7b02",
+ "durationDisplay": "12h 0m",
+ "dateDisplay": "Oct 08, 2026"
+}
+=== receipt --agent claude-code --json
+exit 0
+{
+ "schemaVersion": "sipcode-receipt/1",
+ "variant": "post-install",
+ "htmlPath": "C:/nonexistent-sipcode-unread/.sipcode/receipts/sess/receipt.html",
+ "pngPath": null,
+ "hero": {
+ "tokens": 24640,
+ "tokensDisplay": "24,640",
+ "sublabel": "sipped",
+ "dollarDisplay": "$0.0494",
+ "pricingAsOf": "2026-06-11"
+ },
+ "leaks": [
+ {
+ "rank": 1,
+ "title": "cache-creation overhead",
+ "tokensDisplay": "112,000"
+ },
+ {
+ "rank": 2,
+ "title": "input tokens (system + prompts)",
+ "tokensDisplay": "80,000"
+ }
+ ],
+ "sessionIdShort": "sess",
+ "durationDisplay": "24m 1s",
+ "dateDisplay": "Aug 29, 2026"
+}
+=== drift
+exit 0
+── Codex ──
+⚠ Context drift detected in your latest Codex session
+
+What this means: your newest session is behaving differently from your
+recent norm, in ways that waste tokens and can make Codex less reliable.
+(This is "context rot": answer quality drops as context gets bloated or stale.)
+
+Signals that regressed (2):
+
+ ▲ Tokens per turn — up 1140%
+ your norm: 125,000 → this session: 1,550,000
+ Each step is sending far more context than your norm. Bloated context costs more tokens and can bury the detail Codex needs, which is the heart of context rot.
+ → Fix: Start a fresh chat for your next task to reset the context, and run `sipcode why` to see which turns and files are heaviest.
+
+ ▼ Cache reuse — down 44 points
+ your norm: 44% → this session: 0%
+ Much less of your context is being reused from cache (cached tokens cost far less). Usually from settings/MCP servers changing mid-session, or idle gaps long enough for the cache to expire.
+ → Fix: Avoid changing MCP servers or config mid-task, and work in steady bursts so the cache stays warm.
+
+How this was measured: your latest session vs the median of your last 6 sessions.
+Conservative by design — it stays silent unless something really moved.
+Run `sipcode why` for a per-session forensic breakdown.
+
+Claude Code also has a recent session (sess0043): run with --agent claude-code.
+=== drift --agent codex
+exit 0
+⚠ Context drift detected in your latest Codex session
+
+What this means: your newest session is behaving differently from your
+recent norm, in ways that waste tokens and can make Codex less reliable.
+(This is "context rot": answer quality drops as context gets bloated or stale.)
+
+Signals that regressed (2):
+
+ ▲ Tokens per turn — up 1140%
+ your norm: 125,000 → this session: 1,550,000
+ Each step is sending far more context than your norm. Bloated context costs more tokens and can bury the detail Codex needs, which is the heart of context rot.
+ → Fix: Start a fresh chat for your next task to reset the context, and run `sipcode why` to see which turns and files are heaviest.
+
+ ▼ Cache reuse — down 44 points
+ your norm: 44% → this session: 0%
+ Much less of your context is being reused from cache (cached tokens cost far less). Usually from settings/MCP servers changing mid-session, or idle gaps long enough for the cache to expire.
+ → Fix: Avoid changing MCP servers or config mid-task, and work in steady bursts so the cache stays warm.
+
+How this was measured: your latest session vs the median of your last 6 sessions.
+Conservative by design — it stays silent unless something really moved.
+Run `sipcode why` for a per-session forensic breakdown.
+=== drift --agent claude-code
+exit 0
+✓ Sipcode drift: no drift — context health stable vs your recent baseline.
+=== drift --json
+exit 0
+{
+ "schemaVersion": "sipcode-drift/2",
+ "hasRegression": false,
+ "summary": "no drift — context health stable vs your recent baseline.",
+ "causes": [],
+ "latest": {
+ "sessionId": "sess0043-0000-4000-8000-000000000000",
+ "endedAtMs": 1787995500000,
+ "totalTokens": 1990000,
+ "assistantTurns": 25,
+ "tokensPerTurn": 79600,
+ "cacheHitRate": 0.9030303030303031,
+ "duplicateReadTokens": 0,
+ "outputRatio": 0.04950495049504951,
+ "projectHash": "C--demo"
+ },
+ "baseline": {
+ "count": 6,
+ "medianTokensPerTurn": 83903,
+ "medianCacheHitRate": 0.9492672917939076,
+ "medianDuplicateReadTokens": 0
+ },
+ "note": "Drift compares your latest session against the median of recent ones. Conservative by design — it stays silent unless something really moved. Run `sipcode why` for a per-session forensic breakdown.",
+ "projectHash": "C--demo",
+ "baselineScope": "project"
+}
+stderr: note: Codex logs found too. JSON covers Claude Code; run with --agent codex for Codex.
+=== drift --agent codex --json
+exit 0
+{
+ "schemaVersion": "sipcode-drift/2",
+ "hasRegression": true,
+ "summary": "drift detected — 2 signals regressed vs your baseline (using your global baseline; not enough per-project history yet). These cost tokens and degrade answer quality (context rot).",
+ "causes": [
+ {
+ "metric": "Tokens per turn",
+ "direction": "up",
+ "changeDisplay": "up 1140%",
+ "baselineDisplay": "125,000",
+ "latestDisplay": "1,550,000",
+ "meaning": "Each step is sending far more context than your norm. Bloated context costs more tokens and can bury the detail Codex needs, which is the heart of context rot.",
+ "fix": "Start a fresh chat for your next task to reset the context, and run `sipcode why` to see which turns and files are heaviest."
+ },
+ {
+ "metric": "Cache reuse",
+ "direction": "down",
+ "changeDisplay": "down 44 points",
+ "baselineDisplay": "44%",
+ "latestDisplay": "0%",
+ "meaning": "Much less of your context is being reused from cache (cached tokens cost far less). Usually from settings/MCP servers changing mid-session, or idle gaps long enough for the cache to expire.",
+ "fix": "Avoid changing MCP servers or config mid-task, and work in steady bursts so the cache stays warm."
+ }
+ ],
+ "latest": {
+ "sessionId": "0199a0f7-11aa-7b02-8000-00000000000b",
+ "endedAtMs": 1791489660000,
+ "totalTokens": 3100000,
+ "assistantTurns": 2,
+ "tokensPerTurn": 1550000,
+ "cacheHitRate": 0,
+ "duplicateReadTokens": 0,
+ "outputRatio": 0.03225806451612903,
+ "projectHash": "-home-u-my-app"
+ },
+ "baseline": {
+ "count": 6,
+ "medianTokensPerTurn": 125000,
+ "medianCacheHitRate": 0.4375,
+ "medianDuplicateReadTokens": 0
+ },
+ "note": "Drift compares your latest session against the median of recent ones. Conservative by design — it stays silent unless something really moved. Run `sipcode why` for a per-session forensic breakdown.",
+ "projectHash": "-home-u-my-app",
+ "baselineScope": "global"
+}
+=== drift --agent claude-code --json
+exit 0
+{
+ "schemaVersion": "sipcode-drift/2",
+ "hasRegression": false,
+ "summary": "no drift — context health stable vs your recent baseline.",
+ "causes": [],
+ "latest": {
+ "sessionId": "sess0043-0000-4000-8000-000000000000",
+ "endedAtMs": 1787995500000,
+ "totalTokens": 1990000,
+ "assistantTurns": 25,
+ "tokensPerTurn": 79600,
+ "cacheHitRate": 0.9030303030303031,
+ "duplicateReadTokens": 0,
+ "outputRatio": 0.04950495049504951,
+ "projectHash": "C--demo"
+ },
+ "baseline": {
+ "count": 6,
+ "medianTokensPerTurn": 83903,
+ "medianCacheHitRate": 0.9492672917939076,
+ "medianDuplicateReadTokens": 0
+ },
+ "note": "Drift compares your latest session against the median of recent ones. Conservative by design — it stays silent unless something really moved. Run `sipcode why` for a per-session forensic breakdown.",
+ "projectHash": "C--demo",
+ "baselineScope": "project"
+}
+=== limits
+exit 0
+sipcode limits · Codex · plan: plus · as of 2h ago (your last Codex request)
+
+Weekly limit 62% used resets Wed 14 Oct 08:30 at this pace: runs out Sat 10 Oct ~16:00
+
+What used your weekly limit (since Wed 7 Oct)
+ 0199a0f7-11aa-7b02 my-app 27% 3.1M tokens
+ 0199a1b2-c3d4-7a11 sipcode 22% 3.1M tokens
+ 0199a000-0000-7000 proj 8% 1.0M tokens
+ codex-auto-review (1 request) 5%
+
+Tokens per 1% of your weekly limit (gpt-5.4)
+ Sep 1: 400K Sep 8: 400K Sep 15: 100K
+ Possible limit cut around Sep 15: 4.0x fewer tokens per 1%.
+ Limits cover your whole account. Codex use on other devices or in the cloud
+ is not in these logs and can look like a cut.
+=== limits --agent codex
+exit 0
+sipcode limits · Codex · plan: plus · as of 2h ago (your last Codex request)
+
+Weekly limit 62% used resets Wed 14 Oct 08:30 at this pace: runs out Sat 10 Oct ~16:00
+
+What used your weekly limit (since Wed 7 Oct)
+ 0199a0f7-11aa-7b02 my-app 27% 3.1M tokens
+ 0199a1b2-c3d4-7a11 sipcode 22% 3.1M tokens
+ 0199a000-0000-7000 proj 8% 1.0M tokens
+ codex-auto-review (1 request) 5%
+
+Tokens per 1% of your weekly limit (gpt-5.4)
+ Sep 1: 400K Sep 8: 400K Sep 15: 100K
+ Possible limit cut around Sep 15: 4.0x fewer tokens per 1%.
+ Limits cover your whole account. Codex use on other devices or in the cloud
+ is not in these logs and can look like a cut.
+=== limits --json
+exit 0
+{
+ "agent": "codex",
+ "planType": "plus",
+ "asOf": "2026-10-09T08:00:00.000Z",
+ "windows": [
+ {
+ "limitId": "codex",
+ "windowMinutes": 10080,
+ "label": "Weekly",
+ "usedPercent": 62,
+ "resetsAt": "2026-10-14T08:30:00.000Z",
+ "projection": {
+ "kind": "runs-out",
+ "at": "2026-10-10T16:20:19.354Z"
+ },
+ "usedBy": [
+ {
+ "sessionId": "0199a0f7-11aa-7b02-8000-00000000000b",
+ "project": "my-app",
+ "percent": 27,
+ "tokens": 3100000
+ },
+ {
+ "sessionId": "0199a1b2-c3d4-7a11-8000-00000000000a",
+ "project": "sipcode",
+ "percent": 22.15686274509804,
+ "tokens": 3100000
+ },
+ {
+ "sessionId": "0199a000-0000-7000-8000-000000000001",
+ "project": "proj",
+ "percent": 7.8431372549019605,
+ "tokens": 1000000
+ },
+ {
+ "sessionId": "codex-auto-review",
+ "project": null,
+ "percent": 5,
+ "tokens": 20503
+ }
+ ],
+ "unattributedPercent": 0,
+ "tokensPerPercent": [
+ {
+ "model": "gpt-5.5",
+ "periodStart": "2026-06-15T00:00:00.000Z",
+ "tokensPerPercent": 250000
+ },
+ {
+ "model": "gpt-5.4",
+ "periodStart": "2026-09-01T00:00:00.000Z",
+ "tokensPerPercent": 400000
+ },
+ {
+ "model": "gpt-5.4",
+ "periodStart": "2026-09-08T00:00:00.000Z",
+ "tokensPerPercent": 400000
+ },
+ {
+ "model": "gpt-5.4",
+ "periodStart": "2026-09-15T00:00:00.000Z",
+ "tokensPerPercent": 100000
+ }
+ ],
+ "possibleCut": {
+ "at": "2026-09-15",
+ "factor": 4
+ },
+ "periodCount": 7
+ }
+ ]
+}
+=== limits --agent codex --json
+exit 0
+{
+ "agent": "codex",
+ "planType": "plus",
+ "asOf": "2026-10-09T08:00:00.000Z",
+ "windows": [
+ {
+ "limitId": "codex",
+ "windowMinutes": 10080,
+ "label": "Weekly",
+ "usedPercent": 62,
+ "resetsAt": "2026-10-14T08:30:00.000Z",
+ "projection": {
+ "kind": "runs-out",
+ "at": "2026-10-10T16:20:19.354Z"
+ },
+ "usedBy": [
+ {
+ "sessionId": "0199a0f7-11aa-7b02-8000-00000000000b",
+ "project": "my-app",
+ "percent": 27,
+ "tokens": 3100000
+ },
+ {
+ "sessionId": "0199a1b2-c3d4-7a11-8000-00000000000a",
+ "project": "sipcode",
+ "percent": 22.15686274509804,
+ "tokens": 3100000
+ },
+ {
+ "sessionId": "0199a000-0000-7000-8000-000000000001",
+ "project": "proj",
+ "percent": 7.8431372549019605,
+ "tokens": 1000000
+ },
+ {
+ "sessionId": "codex-auto-review",
+ "project": null,
+ "percent": 5,
+ "tokens": 20503
+ }
+ ],
+ "unattributedPercent": 0,
+ "tokensPerPercent": [
+ {
+ "model": "gpt-5.5",
+ "periodStart": "2026-06-15T00:00:00.000Z",
+ "tokensPerPercent": 250000
+ },
+ {
+ "model": "gpt-5.4",
+ "periodStart": "2026-09-01T00:00:00.000Z",
+ "tokensPerPercent": 400000
+ },
+ {
+ "model": "gpt-5.4",
+ "periodStart": "2026-09-08T00:00:00.000Z",
+ "tokensPerPercent": 400000
+ },
+ {
+ "model": "gpt-5.4",
+ "periodStart": "2026-09-15T00:00:00.000Z",
+ "tokensPerPercent": 100000
+ }
+ ],
+ "possibleCut": {
+ "at": "2026-09-15",
+ "factor": 4
+ },
+ "periodCount": 7
+ }
+ ]
+}
+=== regress
+exit 0
+sipcode regress · local only, no network
+Claude Code: 1,527 interactive requests in 43 sessions (60 subagent and Agent SDK requests left out of the comparison), 2026-03-01 to 2026-08-29, versions 2.1.60 to 2.1.262
+Known-regression windows your logs cover: R1, R2, R3, R4, R5
+Codex: 461 requests in 8 sessions, 2026-05-19 to 2026-10-09, versions 0.140.0 to 0.160.0, 4 version/model groups with enough limit data
+
+● Prompt cache missing on back-to-back turns [warm-cache-miss] Claude Code 2.1.76 to 2.1.90 = known R2, R3
+ v2.1.76: 100.0% of 72 warm turns were cache misses (your other versions: 2.2% of 1386) [matches known R2]
+ v2.1.90: 25.0% of 96 warm turns were cache misses (your other versions: 5.7% of 1362) [matches known R3]
+ e.g. session sess0029 2026-05-12T09:02:00.000Z ctx=56,072 cache_read=14,569 cache_write=41,500
+ e.g. session sess0029 2026-05-12T09:03:00.000Z ctx=57,572 cache_read=14,569 cache_write=43,000
+ e.g. session sess0029 2026-05-12T09:04:00.000Z ctx=59,072 cache_read=14,569 cache_write=44,500
+● Cache stays cold for several turns after an idle break [post-idle-cold] Claude Code 2.1.90 = known R3
+ v2.1.90: 100.0% of 8 idle returns stayed cold (other versions: 0.0%) [matches known R3]
+ e.g. session sess0037 2026-05-27T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003
+ e.g. session sess0036 2026-05-26T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003
+ e.g. session sess0035 2026-05-25T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003
+● Main-conversation cache written with the 5-minute TTL instead of 1-hour [ttl-5m] Claude Code 2.1.100 to 2.1.105 = known R4
+ v2.1.100: 100.0% of cache writes were 5-minute over 60 turns (other versions: 4.1%) [matches known R4]
+ v2.1.105: 100.0% of cache writes were 5-minute over 60 turns (other versions: 4.1%) [matches known R4]
+ note: If you use an API key, cloud provider or usage credits, 5-minute TTL is expected, not a bug.
+● Large uncached input on tool-result turns (context not being cached) [uncached-input] Claude Code 2.1.258 = known R1
+ claude-fable-5-1 v2.1.258: median uncached input 3,200 tokens/turn over 50 tool-result turns (same model, other versions: 5) [matches known R1]
+ e.g. session sess0043 2026-08-29T09:01:00.000Z uncached_input=3,200 ctx=43,200
+ e.g. session sess0043 2026-08-29T09:02:00.000Z uncached_input=3,200 ctx=46,200
+ e.g. session sess0043 2026-08-29T09:03:00.000Z uncached_input=3,200 ctx=49,200
+● Whole prompt cache rebuilt about every hour [hourly-rebuild] Claude Code 2.1.240 = known R5
+ v2.1.240: 4 of 4 gaps between full rebuilds were 50 to 70 minutes [matches known R5]
+● Codex rate limit drains faster per token [codex-limit-burn] Codex 0.142.0 (not in the known-issue catalog)
+ v0.142.0 gpt-5.5 (plus, weekly window): 25.00% of the limit per 1M uncached+output tokens vs 2.19% on your other versions (11.4x)
+ note: Limits are shared with Codex on your other devices and in the cloud. A limit change on your plan at the same time as the update would look the same: run `sipcode limits` to see whether a limit may have shrunk.
+
+A finding that is not in the known-issue catalog is a signal worth reporting, not proof of a bug.
+
+---- paste into a GitHub issue (review it first) ----
+
+### Prompt cache missing on back-to-back turns
+
+**Detected by** sipcode regress unknown from local logs only (no data left the machine).
+**Affected Claude Code versions (from my logs):** 2.1.76 to 2.1.90
+**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29
+
+**Evidence**
+- v2.1.76: 100.0% of 72 warm turns were cache misses (your other versions: 2.2% of 1386) [matches known R2]
+- v2.1.90: 25.0% of 96 warm turns were cache misses (your other versions: 5.7% of 1362) [matches known R3]
+
+**Sample requests (token counts only)**
+- session sess0029 2026-05-12T09:02:00.000Z ctx=56,072 cache_read=14,569 cache_write=41,500
+- session sess0029 2026-05-12T09:03:00.000Z ctx=57,572 cache_read=14,569 cache_write=43,000
+- session sess0029 2026-05-12T09:04:00.000Z ctx=59,072 cache_read=14,569 cache_write=44,500
+
+**Matches known issue:** R2 --resume full prompt-cache miss (every message under --print --resume) (anthropics/claude-code#34629, CHANGELOG 2.1.90, fixed in 2.1.90); R3 thinking history cleared on every turn after an idle >1h, causing repeated cache misses (Anthropic April 23 postmortem, fixed in 2.1.101)
+
+### Cache stays cold for several turns after an idle break
+
+**Detected by** sipcode regress unknown from local logs only (no data left the machine).
+**Affected Claude Code versions (from my logs):** 2.1.90
+**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29
+
+**Evidence**
+- v2.1.90: 100.0% of 8 idle returns stayed cold (other versions: 0.0%) [matches known R3]
+
+**Sample requests (token counts only)**
+- session sess0037 2026-05-27T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003
+- session sess0036 2026-05-26T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003
+- session sess0035 2026-05-25T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003
+
+**Matches known issue:** R3 thinking history cleared on every turn after an idle >1h, causing repeated cache misses (Anthropic April 23 postmortem, fixed in 2.1.101)
+
+### Main-conversation cache written with the 5-minute TTL instead of 1-hour
+
+**Detected by** sipcode regress unknown from local logs only (no data left the machine).
+**Affected Claude Code versions (from my logs):** 2.1.100 to 2.1.105
+**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29
+
+**Evidence**
+- v2.1.100: 100.0% of cache writes were 5-minute over 60 turns (other versions: 4.1%) [matches known R4]
+- v2.1.105: 100.0% of cache writes were 5-minute over 60 turns (other versions: 4.1%) [matches known R4]
+
+**Matches known issue:** R4 1-hour prompt cache TTL silently downgraded to 5 minutes (anthropics/claude-code#46829, CHANGELOG 2.1.108 + 2.1.129, fixed in 2.1.129)
+
+_Note: If you use an API key, cloud provider or usage credits, 5-minute TTL is expected, not a bug._
+
+### Large uncached input on tool-result turns (context not being cached)
+
+**Detected by** sipcode regress unknown from local logs only (no data left the machine).
+**Affected Claude Code versions (from my logs):** 2.1.258
+**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29
+
+**Evidence**
+- claude-fable-5-1 v2.1.258: median uncached input 3,200 tokens/turn over 50 tool-result turns (same model, other versions: 5) [matches known R1]
+
+**Sample requests (token counts only)**
+- session sess0043 2026-08-29T09:01:00.000Z uncached_input=3,200 ctx=43,200
+- session sess0043 2026-08-29T09:02:00.000Z uncached_input=3,200 ctx=46,200
+- session sess0043 2026-08-29T09:03:00.000Z uncached_input=3,200 ctx=49,200
+
+**Matches known issue:** R1 Fable 5.1 context after tool results re-sent as uncached input every tool-call turn (CHANGELOG 2.1.260, fixed in 2.1.260)
+
+### Whole prompt cache rebuilt about every hour
+
+**Detected by** sipcode regress unknown from local logs only (no data left the machine).
+**Affected Claude Code versions (from my logs):** 2.1.240
+**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29
+
+**Evidence**
+- v2.1.240: 4 of 4 gaps between full rebuilds were 50 to 70 minutes [matches known R5]
+
+**Matches known issue:** R5 OAuth token refresh re-rendered tool definitions, rebuilding the whole cache about hourly (CHANGELOG 2.1.248, fixed in 2.1.248)
+
+### Codex rate limit drains faster per token
+
+**Detected by** sipcode regress unknown from local logs only (no data left the machine).
+**Affected Codex versions (from my logs):** 0.142.0
+**My data:** 461 Codex requests, 8 sessions, 2026-05-19 to 2026-10-09
+
+**Evidence**
+- v0.142.0 gpt-5.5 (plus, weekly window): 25.00% of the limit per 1M uncached+output tokens vs 2.19% on your other versions (11.4x)
+
+_Note: Limits are shared with Codex on your other devices and in the cloud. A limit change on your plan at the same time as the update would look the same: run `sipcode limits` to see whether a limit may have shrunk._
+
+=== regress --agent codex
+exit 0
+sipcode regress · local only, no network
+Codex: 461 requests in 8 sessions, 2026-05-19 to 2026-10-09, versions 0.140.0 to 0.160.0, 4 version/model groups with enough limit data
+
+● Codex rate limit drains faster per token [codex-limit-burn] Codex 0.142.0 (not in the known-issue catalog)
+ v0.142.0 gpt-5.5 (plus, weekly window): 25.00% of the limit per 1M uncached+output tokens vs 2.19% on your other versions (11.4x)
+ note: Limits are shared with Codex on your other devices and in the cloud. A limit change on your plan at the same time as the update would look the same: run `sipcode limits` to see whether a limit may have shrunk.
+
+A finding that is not in the known-issue catalog is a signal worth reporting, not proof of a bug.
+
+---- paste into a GitHub issue (review it first) ----
+
+### Codex rate limit drains faster per token
+
+**Detected by** sipcode regress unknown from local logs only (no data left the machine).
+**Affected Codex versions (from my logs):** 0.142.0
+**My data:** 461 Codex requests, 8 sessions, 2026-05-19 to 2026-10-09
+
+**Evidence**
+- v0.142.0 gpt-5.5 (plus, weekly window): 25.00% of the limit per 1M uncached+output tokens vs 2.19% on your other versions (11.4x)
+
+_Note: Limits are shared with Codex on your other devices and in the cloud. A limit change on your plan at the same time as the update would look the same: run `sipcode limits` to see whether a limit may have shrunk._
+
+=== regress --agent claude-code
+exit 0
+sipcode regress · local only, no network
+Claude Code: 1,527 interactive requests in 43 sessions (60 subagent and Agent SDK requests left out of the comparison), 2026-03-01 to 2026-08-29, versions 2.1.60 to 2.1.262
+Known-regression windows your logs cover: R1, R2, R3, R4, R5
+
+● Prompt cache missing on back-to-back turns [warm-cache-miss] Claude Code 2.1.76 to 2.1.90 = known R2, R3
+ v2.1.76: 100.0% of 72 warm turns were cache misses (your other versions: 2.2% of 1386) [matches known R2]
+ v2.1.90: 25.0% of 96 warm turns were cache misses (your other versions: 5.7% of 1362) [matches known R3]
+ e.g. session sess0029 2026-05-12T09:02:00.000Z ctx=56,072 cache_read=14,569 cache_write=41,500
+ e.g. session sess0029 2026-05-12T09:03:00.000Z ctx=57,572 cache_read=14,569 cache_write=43,000
+ e.g. session sess0029 2026-05-12T09:04:00.000Z ctx=59,072 cache_read=14,569 cache_write=44,500
+● Cache stays cold for several turns after an idle break [post-idle-cold] Claude Code 2.1.90 = known R3
+ v2.1.90: 100.0% of 8 idle returns stayed cold (other versions: 0.0%) [matches known R3]
+ e.g. session sess0037 2026-05-27T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003
+ e.g. session sess0036 2026-05-26T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003
+ e.g. session sess0035 2026-05-25T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003
+● Main-conversation cache written with the 5-minute TTL instead of 1-hour [ttl-5m] Claude Code 2.1.100 to 2.1.105 = known R4
+ v2.1.100: 100.0% of cache writes were 5-minute over 60 turns (other versions: 4.1%) [matches known R4]
+ v2.1.105: 100.0% of cache writes were 5-minute over 60 turns (other versions: 4.1%) [matches known R4]
+ note: If you use an API key, cloud provider or usage credits, 5-minute TTL is expected, not a bug.
+● Large uncached input on tool-result turns (context not being cached) [uncached-input] Claude Code 2.1.258 = known R1
+ claude-fable-5-1 v2.1.258: median uncached input 3,200 tokens/turn over 50 tool-result turns (same model, other versions: 5) [matches known R1]
+ e.g. session sess0043 2026-08-29T09:01:00.000Z uncached_input=3,200 ctx=43,200
+ e.g. session sess0043 2026-08-29T09:02:00.000Z uncached_input=3,200 ctx=46,200
+ e.g. session sess0043 2026-08-29T09:03:00.000Z uncached_input=3,200 ctx=49,200
+● Whole prompt cache rebuilt about every hour [hourly-rebuild] Claude Code 2.1.240 = known R5
+ v2.1.240: 4 of 4 gaps between full rebuilds were 50 to 70 minutes [matches known R5]
+
+---- paste into a GitHub issue (review it first) ----
+
+### Prompt cache missing on back-to-back turns
+
+**Detected by** sipcode regress unknown from local logs only (no data left the machine).
+**Affected Claude Code versions (from my logs):** 2.1.76 to 2.1.90
+**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29
+
+**Evidence**
+- v2.1.76: 100.0% of 72 warm turns were cache misses (your other versions: 2.2% of 1386) [matches known R2]
+- v2.1.90: 25.0% of 96 warm turns were cache misses (your other versions: 5.7% of 1362) [matches known R3]
+
+**Sample requests (token counts only)**
+- session sess0029 2026-05-12T09:02:00.000Z ctx=56,072 cache_read=14,569 cache_write=41,500
+- session sess0029 2026-05-12T09:03:00.000Z ctx=57,572 cache_read=14,569 cache_write=43,000
+- session sess0029 2026-05-12T09:04:00.000Z ctx=59,072 cache_read=14,569 cache_write=44,500
+
+**Matches known issue:** R2 --resume full prompt-cache miss (every message under --print --resume) (anthropics/claude-code#34629, CHANGELOG 2.1.90, fixed in 2.1.90); R3 thinking history cleared on every turn after an idle >1h, causing repeated cache misses (Anthropic April 23 postmortem, fixed in 2.1.101)
+
+### Cache stays cold for several turns after an idle break
+
+**Detected by** sipcode regress unknown from local logs only (no data left the machine).
+**Affected Claude Code versions (from my logs):** 2.1.90
+**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29
+
+**Evidence**
+- v2.1.90: 100.0% of 8 idle returns stayed cold (other versions: 0.0%) [matches known R3]
+
+**Sample requests (token counts only)**
+- session sess0037 2026-05-27T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003
+- session sess0036 2026-05-26T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003
+- session sess0035 2026-05-25T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003
+
+**Matches known issue:** R3 thinking history cleared on every turn after an idle >1h, causing repeated cache misses (Anthropic April 23 postmortem, fixed in 2.1.101)
+
+### Main-conversation cache written with the 5-minute TTL instead of 1-hour
+
+**Detected by** sipcode regress unknown from local logs only (no data left the machine).
+**Affected Claude Code versions (from my logs):** 2.1.100 to 2.1.105
+**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29
+
+**Evidence**
+- v2.1.100: 100.0% of cache writes were 5-minute over 60 turns (other versions: 4.1%) [matches known R4]
+- v2.1.105: 100.0% of cache writes were 5-minute over 60 turns (other versions: 4.1%) [matches known R4]
+
+**Matches known issue:** R4 1-hour prompt cache TTL silently downgraded to 5 minutes (anthropics/claude-code#46829, CHANGELOG 2.1.108 + 2.1.129, fixed in 2.1.129)
+
+_Note: If you use an API key, cloud provider or usage credits, 5-minute TTL is expected, not a bug._
+
+### Large uncached input on tool-result turns (context not being cached)
+
+**Detected by** sipcode regress unknown from local logs only (no data left the machine).
+**Affected Claude Code versions (from my logs):** 2.1.258
+**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29
+
+**Evidence**
+- claude-fable-5-1 v2.1.258: median uncached input 3,200 tokens/turn over 50 tool-result turns (same model, other versions: 5) [matches known R1]
+
+**Sample requests (token counts only)**
+- session sess0043 2026-08-29T09:01:00.000Z uncached_input=3,200 ctx=43,200
+- session sess0043 2026-08-29T09:02:00.000Z uncached_input=3,200 ctx=46,200
+- session sess0043 2026-08-29T09:03:00.000Z uncached_input=3,200 ctx=49,200
+
+**Matches known issue:** R1 Fable 5.1 context after tool results re-sent as uncached input every tool-call turn (CHANGELOG 2.1.260, fixed in 2.1.260)
+
+### Whole prompt cache rebuilt about every hour
+
+**Detected by** sipcode regress unknown from local logs only (no data left the machine).
+**Affected Claude Code versions (from my logs):** 2.1.240
+**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29
+
+**Evidence**
+- v2.1.240: 4 of 4 gaps between full rebuilds were 50 to 70 minutes [matches known R5]
+
+**Matches known issue:** R5 OAuth token refresh re-rendered tool definitions, rebuilding the whole cache about hourly (CHANGELOG 2.1.248, fixed in 2.1.248)
+
+=== regress --json
+exit 0
+{
+ "schemaVersion": "sipcode-regress/1",
+ "agents": [
+ "claude-code",
+ "codex"
+ ],
+ "minN": 20,
+ "coverage": {
+ "claudeCode": {
+ "requests": 1527,
+ "excludedAutomated": 60,
+ "sessions": 43,
+ "versions": 12,
+ "firstVersion": "2.1.60",
+ "lastVersion": "2.1.262",
+ "from": "2026-03-01",
+ "to": "2026-08-29",
+ "knownWindowsCovered": [
+ "R1",
+ "R2",
+ "R3",
+ "R4",
+ "R5"
+ ]
+ },
+ "codex": {
+ "requests": 461,
+ "sessions": 8,
+ "versions": 4,
+ "firstVersion": "0.140.0",
+ "lastVersion": "0.160.0",
+ "from": "2026-05-19",
+ "to": "2026-10-09",
+ "readings": 459,
+ "groups": 4
+ }
+ },
+ "access": {
+ "missingDirs": [],
+ "unreadableFiles": 0,
+ "unreadableFolders": 0,
+ "skippedCompressed": 0,
+ "nothingRead": false
+ },
+ "comparable": true,
+ "comparableByAgent": {
+ "claudeCode": true,
+ "codex": true
+ },
+ "findings": [
+ {
+ "id": "warm-cache-miss",
+ "agent": "claude-code",
+ "title": "Prompt cache missing on back-to-back turns",
+ "from": "2.1.76",
+ "to": "2.1.90",
+ "known": [
+ {
+ "id": "R2",
+ "what": "--resume full prompt-cache miss (every message under --print --resume)",
+ "ref": "anthropics/claude-code#34629, CHANGELOG 2.1.90",
+ "fixed": "2.1.90"
+ },
+ {
+ "id": "R3",
+ "what": "thinking history cleared on every turn after an idle >1h, causing repeated cache misses",
+ "ref": "Anthropic April 23 postmortem",
+ "fixed": "2.1.101"
+ }
+ ],
+ "evidence": [
+ "v2.1.76: 100.0% of 72 warm turns were cache misses (your other versions: 2.2% of 1386) [matches known R2]",
+ "v2.1.90: 25.0% of 96 warm turns were cache misses (your other versions: 5.7% of 1362) [matches known R3]"
+ ],
+ "examples": [
+ "session sess0029 2026-05-12T09:02:00.000Z ctx=56,072 cache_read=14,569 cache_write=41,500",
+ "session sess0029 2026-05-12T09:03:00.000Z ctx=57,572 cache_read=14,569 cache_write=43,000",
+ "session sess0029 2026-05-12T09:04:00.000Z ctx=59,072 cache_read=14,569 cache_write=44,500"
+ ],
+ "note": null
+ },
+ {
+ "id": "post-idle-cold",
+ "agent": "claude-code",
+ "title": "Cache stays cold for several turns after an idle break",
+ "from": "2.1.90",
+ "to": "2.1.90",
+ "known": [
+ {
+ "id": "R3",
+ "what": "thinking history cleared on every turn after an idle >1h, causing repeated cache misses",
+ "ref": "Anthropic April 23 postmortem",
+ "fixed": "2.1.101"
+ }
+ ],
+ "evidence": [
+ "v2.1.90: 100.0% of 8 idle returns stayed cold (other versions: 0.0%) [matches known R3]"
+ ],
+ "examples": [
+ "session sess0037 2026-05-27T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003",
+ "session sess0036 2026-05-26T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003",
+ "session sess0035 2026-05-25T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003"
+ ],
+ "note": null
+ },
+ {
+ "id": "ttl-5m",
+ "agent": "claude-code",
+ "title": "Main-conversation cache written with the 5-minute TTL instead of 1-hour",
+ "from": "2.1.100",
+ "to": "2.1.105",
+ "known": [
+ {
+ "id": "R4",
+ "what": "1-hour prompt cache TTL silently downgraded to 5 minutes",
+ "ref": "anthropics/claude-code#46829, CHANGELOG 2.1.108 + 2.1.129",
+ "fixed": "2.1.129"
+ }
+ ],
+ "evidence": [
+ "v2.1.100: 100.0% of cache writes were 5-minute over 60 turns (other versions: 4.1%) [matches known R4]",
+ "v2.1.105: 100.0% of cache writes were 5-minute over 60 turns (other versions: 4.1%) [matches known R4]"
+ ],
+ "examples": [],
+ "note": "If you use an API key, cloud provider or usage credits, 5-minute TTL is expected, not a bug."
+ },
+ {
+ "id": "uncached-input",
+ "agent": "claude-code",
+ "title": "Large uncached input on tool-result turns (context not being cached)",
+ "from": "2.1.258",
+ "to": "2.1.258",
+ "known": [
+ {
+ "id": "R1",
+ "what": "Fable 5.1 context after tool results re-sent as uncached input every tool-call turn",
+ "ref": "CHANGELOG 2.1.260",
+ "fixed": "2.1.260"
+ }
+ ],
+ "evidence": [
+ "claude-fable-5-1 v2.1.258: median uncached input 3,200 tokens/turn over 50 tool-result turns (same model, other versions: 5) [matches known R1]"
+ ],
+ "examples": [
+ "session sess0043 2026-08-29T09:01:00.000Z uncached_input=3,200 ctx=43,200",
+ "session sess0043 2026-08-29T09:02:00.000Z uncached_input=3,200 ctx=46,200",
+ "session sess0043 2026-08-29T09:03:00.000Z uncached_input=3,200 ctx=49,200"
+ ],
+ "note": null
+ },
+ {
+ "id": "hourly-rebuild",
+ "agent": "claude-code",
+ "title": "Whole prompt cache rebuilt about every hour",
+ "from": "2.1.240",
+ "to": "2.1.240",
+ "known": [
+ {
+ "id": "R5",
+ "what": "OAuth token refresh re-rendered tool definitions, rebuilding the whole cache about hourly",
+ "ref": "CHANGELOG 2.1.248",
+ "fixed": "2.1.248"
+ }
+ ],
+ "evidence": [
+ "v2.1.240: 4 of 4 gaps between full rebuilds were 50 to 70 minutes [matches known R5]"
+ ],
+ "examples": [],
+ "note": null
+ },
+ {
+ "id": "codex-limit-burn",
+ "agent": "codex",
+ "title": "Codex rate limit drains faster per token",
+ "from": "0.142.0",
+ "to": "0.142.0",
+ "known": [],
+ "evidence": [
+ "v0.142.0 gpt-5.5 (plus, weekly window): 25.00% of the limit per 1M uncached+output tokens vs 2.19% on your other versions (11.4x)"
+ ],
+ "examples": [],
+ "note": "Limits are shared with Codex on your other devices and in the cloud. A limit change on your plan at the same time as the update would look the same: run `sipcode limits` to see whether a limit may have shrunk."
+ }
+ ],
+ "checks": {
+ "claudeCode": {
+ "warmCacheMiss": [
+ {
+ "version": "2.1.60",
+ "n": 108,
+ "rate": 0,
+ "baseline": 0.07555555555555556,
+ "baselineN": 1350
+ },
+ {
+ "version": "2.1.76",
+ "n": 72,
+ "rate": 1,
+ "baseline": 0.021645021645021644,
+ "baselineN": 1386
+ },
+ {
+ "version": "2.1.90",
+ "n": 96,
+ "rate": 0.25,
+ "baseline": 0.05726872246696035,
+ "baselineN": 1362
+ },
+ {
+ "version": "2.1.95",
+ "n": 108,
+ "rate": 0,
+ "baseline": 0.07555555555555556,
+ "baselineN": 1350
+ },
+ {
+ "version": "2.1.100",
+ "n": 58,
+ "rate": 0,
+ "baseline": 0.07285714285714286,
+ "baselineN": 1400
+ },
+ {
+ "version": "2.1.105",
+ "n": 58,
+ "rate": 0,
+ "baseline": 0.07285714285714286,
+ "baselineN": 1400
+ },
+ {
+ "version": "2.1.140",
+ "n": 108,
+ "rate": 0,
+ "baseline": 0.07555555555555556,
+ "baselineN": 1350
+ },
+ {
+ "version": "2.1.200",
+ "n": 108,
+ "rate": 0,
+ "baseline": 0.07555555555555556,
+ "baselineN": 1350
+ },
+ {
+ "version": "2.1.240",
+ "n": 478,
+ "rate": 0.012552301255230125,
+ "baseline": 0.09795918367346938,
+ "baselineN": 980
+ },
+ {
+ "version": "2.1.250",
+ "n": 108,
+ "rate": 0,
+ "baseline": 0.07555555555555556,
+ "baselineN": 1350
+ },
+ {
+ "version": "2.1.258",
+ "n": 48,
+ "rate": 0,
+ "baseline": 0.07234042553191489,
+ "baselineN": 1410
+ },
+ {
+ "version": "2.1.262",
+ "n": 108,
+ "rate": 0,
+ "baseline": 0.07555555555555556,
+ "baselineN": 1350
+ }
+ ],
+ "postIdleCold": [
+ {
+ "version": "2.1.90",
+ "n": 8,
+ "rate": 1,
+ "baseline": 0,
+ "baselineN": 18
+ }
+ ],
+ "ttl5m": [
+ {
+ "version": "2.1.60",
+ "n": 115,
+ "share5m": 0,
+ "baseline": 0.08498583569405099
+ },
+ {
+ "version": "2.1.76",
+ "n": 75,
+ "share5m": 0,
+ "baseline": 0.08264462809917356
+ },
+ {
+ "version": "2.1.90",
+ "n": 112,
+ "share5m": 0,
+ "baseline": 0.08480565371024736
+ },
+ {
+ "version": "2.1.95",
+ "n": 115,
+ "share5m": 0,
+ "baseline": 0.08498583569405099
+ },
+ {
+ "version": "2.1.100",
+ "n": 60,
+ "share5m": 1,
+ "baseline": 0.0408997955010225
+ },
+ {
+ "version": "2.1.105",
+ "n": 60,
+ "share5m": 1,
+ "baseline": 0.0408997955010225
+ },
+ {
+ "version": "2.1.140",
+ "n": 115,
+ "share5m": 0,
+ "baseline": 0.08498583569405099
+ },
+ {
+ "version": "2.1.200",
+ "n": 115,
+ "share5m": 0,
+ "baseline": 0.08498583569405099
+ },
+ {
+ "version": "2.1.240",
+ "n": 480,
+ "share5m": 0,
+ "baseline": 0.11461318051575932
+ },
+ {
+ "version": "2.1.250",
+ "n": 115,
+ "share5m": 0,
+ "baseline": 0.08498583569405099
+ },
+ {
+ "version": "2.1.258",
+ "n": 50,
+ "share5m": 0,
+ "baseline": 0.08124576844955991
+ },
+ {
+ "version": "2.1.262",
+ "n": 115,
+ "share5m": 0,
+ "baseline": 0.08498583569405099
+ }
+ ],
+ "uncachedInput": [
+ {
+ "model": "claude-fable-5-1",
+ "version": "2.1.258",
+ "n": 50,
+ "median": 3200,
+ "baseline": 5
+ },
+ {
+ "model": "claude-opus-4-8",
+ "version": "2.1.240",
+ "n": 480,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-opus-4-6",
+ "version": "2.1.100",
+ "n": 60,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-opus-4-6",
+ "version": "2.1.105",
+ "n": 60,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-opus-4-6",
+ "version": "2.1.90",
+ "n": 112,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-opus-4-6",
+ "version": "2.1.76",
+ "n": 75,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-fable-5-1",
+ "version": "2.1.262",
+ "n": 25,
+ "median": 5,
+ "baseline": 5
+ },
+ {
+ "model": "claude-opus-4-8",
+ "version": "2.1.262",
+ "n": 90,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-fable-5-1",
+ "version": "2.1.250",
+ "n": 25,
+ "median": 5,
+ "baseline": 5
+ },
+ {
+ "model": "claude-opus-4-8",
+ "version": "2.1.250",
+ "n": 90,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-fable-5-1",
+ "version": "2.1.200",
+ "n": 25,
+ "median": 5,
+ "baseline": 5
+ },
+ {
+ "model": "claude-opus-4-8",
+ "version": "2.1.200",
+ "n": 90,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-fable-5-1",
+ "version": "2.1.140",
+ "n": 25,
+ "median": 5,
+ "baseline": 5
+ },
+ {
+ "model": "claude-opus-4-8",
+ "version": "2.1.140",
+ "n": 90,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-fable-5-1",
+ "version": "2.1.95",
+ "n": 25,
+ "median": 5,
+ "baseline": 5
+ },
+ {
+ "model": "claude-opus-4-8",
+ "version": "2.1.95",
+ "n": 90,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-fable-5-1",
+ "version": "2.1.60",
+ "n": 25,
+ "median": 5,
+ "baseline": 5
+ },
+ {
+ "model": "claude-opus-4-8",
+ "version": "2.1.60",
+ "n": 90,
+ "median": 3,
+ "baseline": 3
+ }
+ ],
+ "hourlyRebuild": [
+ {
+ "version": "2.1.76",
+ "n": 30,
+ "hourly": 0
+ },
+ {
+ "version": "2.1.90",
+ "n": 16,
+ "hourly": 0
+ },
+ {
+ "version": "2.1.240",
+ "n": 4,
+ "hourly": 4
+ }
+ ]
+ },
+ "codex": [
+ {
+ "version": "0.140.0",
+ "model": "gpt-5.5",
+ "plan": "plus",
+ "limitId": "codex",
+ "windowMinutes": 10080,
+ "requests": 150,
+ "percent": 6.000000000000004,
+ "workTokens": 3000000,
+ "percentPerMillion": 2.0000000000000013
+ },
+ {
+ "version": "0.141.0",
+ "model": "gpt-5.5",
+ "plan": "plus",
+ "limitId": "codex",
+ "windowMinutes": 10080,
+ "requests": 137,
+ "percent": 6.0000000000000115,
+ "workTokens": 2740000,
+ "percentPerMillion": 2.189781021897814
+ },
+ {
+ "version": "0.142.0",
+ "model": "gpt-5.5",
+ "plan": "plus",
+ "limitId": "codex",
+ "windowMinutes": 10080,
+ "requests": 150,
+ "percent": 75,
+ "workTokens": 3000000,
+ "percentPerMillion": 25
+ },
+ {
+ "version": "0.160.0",
+ "model": "gpt-5.4",
+ "plan": "plus",
+ "limitId": "codex",
+ "windowMinutes": 10080,
+ "requests": 8,
+ "percent": 207,
+ "workTokens": 52200000,
+ "percentPerMillion": 3.96551724137931
+ }
+ ]
+ },
+ "bugReport": "### Prompt cache missing on back-to-back turns\n\n**Detected by** sipcode regress unknown from local logs only (no data left the machine).\n**Affected Claude Code versions (from my logs):** 2.1.76 to 2.1.90\n**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29\n\n**Evidence**\n- v2.1.76: 100.0% of 72 warm turns were cache misses (your other versions: 2.2% of 1386) [matches known R2]\n- v2.1.90: 25.0% of 96 warm turns were cache misses (your other versions: 5.7% of 1362) [matches known R3]\n\n**Sample requests (token counts only)**\n- session sess0029 2026-05-12T09:02:00.000Z ctx=56,072 cache_read=14,569 cache_write=41,500\n- session sess0029 2026-05-12T09:03:00.000Z ctx=57,572 cache_read=14,569 cache_write=43,000\n- session sess0029 2026-05-12T09:04:00.000Z ctx=59,072 cache_read=14,569 cache_write=44,500\n\n**Matches known issue:** R2 --resume full prompt-cache miss (every message under --print --resume) (anthropics/claude-code#34629, CHANGELOG 2.1.90, fixed in 2.1.90); R3 thinking history cleared on every turn after an idle >1h, causing repeated cache misses (Anthropic April 23 postmortem, fixed in 2.1.101)\n\n### Cache stays cold for several turns after an idle break\n\n**Detected by** sipcode regress unknown from local logs only (no data left the machine).\n**Affected Claude Code versions (from my logs):** 2.1.90\n**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29\n\n**Evidence**\n- v2.1.90: 100.0% of 8 idle returns stayed cold (other versions: 0.0%) [matches known R3]\n\n**Sample requests (token counts only)**\n- session sess0037 2026-05-27T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003\n- session sess0036 2026-05-26T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003\n- session sess0035 2026-05-25T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003\n\n**Matches known issue:** R3 thinking history cleared on every turn after an idle >1h, causing repeated cache misses (Anthropic April 23 postmortem, fixed in 2.1.101)\n\n### Main-conversation cache written with the 5-minute TTL instead of 1-hour\n\n**Detected by** sipcode regress unknown from local logs only (no data left the machine).\n**Affected Claude Code versions (from my logs):** 2.1.100 to 2.1.105\n**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29\n\n**Evidence**\n- v2.1.100: 100.0% of cache writes were 5-minute over 60 turns (other versions: 4.1%) [matches known R4]\n- v2.1.105: 100.0% of cache writes were 5-minute over 60 turns (other versions: 4.1%) [matches known R4]\n\n**Matches known issue:** R4 1-hour prompt cache TTL silently downgraded to 5 minutes (anthropics/claude-code#46829, CHANGELOG 2.1.108 + 2.1.129, fixed in 2.1.129)\n\n_Note: If you use an API key, cloud provider or usage credits, 5-minute TTL is expected, not a bug._\n\n### Large uncached input on tool-result turns (context not being cached)\n\n**Detected by** sipcode regress unknown from local logs only (no data left the machine).\n**Affected Claude Code versions (from my logs):** 2.1.258\n**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29\n\n**Evidence**\n- claude-fable-5-1 v2.1.258: median uncached input 3,200 tokens/turn over 50 tool-result turns (same model, other versions: 5) [matches known R1]\n\n**Sample requests (token counts only)**\n- session sess0043 2026-08-29T09:01:00.000Z uncached_input=3,200 ctx=43,200\n- session sess0043 2026-08-29T09:02:00.000Z uncached_input=3,200 ctx=46,200\n- session sess0043 2026-08-29T09:03:00.000Z uncached_input=3,200 ctx=49,200\n\n**Matches known issue:** R1 Fable 5.1 context after tool results re-sent as uncached input every tool-call turn (CHANGELOG 2.1.260, fixed in 2.1.260)\n\n### Whole prompt cache rebuilt about every hour\n\n**Detected by** sipcode regress unknown from local logs only (no data left the machine).\n**Affected Claude Code versions (from my logs):** 2.1.240\n**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29\n\n**Evidence**\n- v2.1.240: 4 of 4 gaps between full rebuilds were 50 to 70 minutes [matches known R5]\n\n**Matches known issue:** R5 OAuth token refresh re-rendered tool definitions, rebuilding the whole cache about hourly (CHANGELOG 2.1.248, fixed in 2.1.248)\n\n### Codex rate limit drains faster per token\n\n**Detected by** sipcode regress unknown from local logs only (no data left the machine).\n**Affected Codex versions (from my logs):** 0.142.0\n**My data:** 461 Codex requests, 8 sessions, 2026-05-19 to 2026-10-09\n\n**Evidence**\n- v0.142.0 gpt-5.5 (plus, weekly window): 25.00% of the limit per 1M uncached+output tokens vs 2.19% on your other versions (11.4x)\n\n_Note: Limits are shared with Codex on your other devices and in the cloud. A limit change on your plan at the same time as the update would look the same: run `sipcode limits` to see whether a limit may have shrunk._\n"
+}
+=== regress --agent codex --json
+exit 0
+{
+ "schemaVersion": "sipcode-regress/1",
+ "agents": [
+ "codex"
+ ],
+ "minN": 20,
+ "coverage": {
+ "claudeCode": null,
+ "codex": {
+ "requests": 461,
+ "sessions": 8,
+ "versions": 4,
+ "firstVersion": "0.140.0",
+ "lastVersion": "0.160.0",
+ "from": "2026-05-19",
+ "to": "2026-10-09",
+ "readings": 459,
+ "groups": 4
+ }
+ },
+ "access": {
+ "missingDirs": [],
+ "unreadableFiles": 0,
+ "unreadableFolders": 0,
+ "skippedCompressed": 0,
+ "nothingRead": false
+ },
+ "comparable": true,
+ "comparableByAgent": {
+ "claudeCode": null,
+ "codex": true
+ },
+ "findings": [
+ {
+ "id": "codex-limit-burn",
+ "agent": "codex",
+ "title": "Codex rate limit drains faster per token",
+ "from": "0.142.0",
+ "to": "0.142.0",
+ "known": [],
+ "evidence": [
+ "v0.142.0 gpt-5.5 (plus, weekly window): 25.00% of the limit per 1M uncached+output tokens vs 2.19% on your other versions (11.4x)"
+ ],
+ "examples": [],
+ "note": "Limits are shared with Codex on your other devices and in the cloud. A limit change on your plan at the same time as the update would look the same: run `sipcode limits` to see whether a limit may have shrunk."
+ }
+ ],
+ "checks": {
+ "claudeCode": null,
+ "codex": [
+ {
+ "version": "0.140.0",
+ "model": "gpt-5.5",
+ "plan": "plus",
+ "limitId": "codex",
+ "windowMinutes": 10080,
+ "requests": 150,
+ "percent": 6.000000000000004,
+ "workTokens": 3000000,
+ "percentPerMillion": 2.0000000000000013
+ },
+ {
+ "version": "0.141.0",
+ "model": "gpt-5.5",
+ "plan": "plus",
+ "limitId": "codex",
+ "windowMinutes": 10080,
+ "requests": 137,
+ "percent": 6.0000000000000115,
+ "workTokens": 2740000,
+ "percentPerMillion": 2.189781021897814
+ },
+ {
+ "version": "0.142.0",
+ "model": "gpt-5.5",
+ "plan": "plus",
+ "limitId": "codex",
+ "windowMinutes": 10080,
+ "requests": 150,
+ "percent": 75,
+ "workTokens": 3000000,
+ "percentPerMillion": 25
+ },
+ {
+ "version": "0.160.0",
+ "model": "gpt-5.4",
+ "plan": "plus",
+ "limitId": "codex",
+ "windowMinutes": 10080,
+ "requests": 8,
+ "percent": 207,
+ "workTokens": 52200000,
+ "percentPerMillion": 3.96551724137931
+ }
+ ]
+ },
+ "bugReport": "### Codex rate limit drains faster per token\n\n**Detected by** sipcode regress unknown from local logs only (no data left the machine).\n**Affected Codex versions (from my logs):** 0.142.0\n**My data:** 461 Codex requests, 8 sessions, 2026-05-19 to 2026-10-09\n\n**Evidence**\n- v0.142.0 gpt-5.5 (plus, weekly window): 25.00% of the limit per 1M uncached+output tokens vs 2.19% on your other versions (11.4x)\n\n_Note: Limits are shared with Codex on your other devices and in the cloud. A limit change on your plan at the same time as the update would look the same: run `sipcode limits` to see whether a limit may have shrunk._\n"
+}
+=== regress --agent claude-code --json
+exit 0
+{
+ "schemaVersion": "sipcode-regress/1",
+ "agents": [
+ "claude-code"
+ ],
+ "minN": 20,
+ "coverage": {
+ "claudeCode": {
+ "requests": 1527,
+ "excludedAutomated": 60,
+ "sessions": 43,
+ "versions": 12,
+ "firstVersion": "2.1.60",
+ "lastVersion": "2.1.262",
+ "from": "2026-03-01",
+ "to": "2026-08-29",
+ "knownWindowsCovered": [
+ "R1",
+ "R2",
+ "R3",
+ "R4",
+ "R5"
+ ]
+ },
+ "codex": null
+ },
+ "access": {
+ "missingDirs": [],
+ "unreadableFiles": 0,
+ "unreadableFolders": 0,
+ "skippedCompressed": 0,
+ "nothingRead": false
+ },
+ "comparable": true,
+ "comparableByAgent": {
+ "claudeCode": true,
+ "codex": null
+ },
+ "findings": [
+ {
+ "id": "warm-cache-miss",
+ "agent": "claude-code",
+ "title": "Prompt cache missing on back-to-back turns",
+ "from": "2.1.76",
+ "to": "2.1.90",
+ "known": [
+ {
+ "id": "R2",
+ "what": "--resume full prompt-cache miss (every message under --print --resume)",
+ "ref": "anthropics/claude-code#34629, CHANGELOG 2.1.90",
+ "fixed": "2.1.90"
+ },
+ {
+ "id": "R3",
+ "what": "thinking history cleared on every turn after an idle >1h, causing repeated cache misses",
+ "ref": "Anthropic April 23 postmortem",
+ "fixed": "2.1.101"
+ }
+ ],
+ "evidence": [
+ "v2.1.76: 100.0% of 72 warm turns were cache misses (your other versions: 2.2% of 1386) [matches known R2]",
+ "v2.1.90: 25.0% of 96 warm turns were cache misses (your other versions: 5.7% of 1362) [matches known R3]"
+ ],
+ "examples": [
+ "session sess0029 2026-05-12T09:02:00.000Z ctx=56,072 cache_read=14,569 cache_write=41,500",
+ "session sess0029 2026-05-12T09:03:00.000Z ctx=57,572 cache_read=14,569 cache_write=43,000",
+ "session sess0029 2026-05-12T09:04:00.000Z ctx=59,072 cache_read=14,569 cache_write=44,500"
+ ],
+ "note": null
+ },
+ {
+ "id": "post-idle-cold",
+ "agent": "claude-code",
+ "title": "Cache stays cold for several turns after an idle break",
+ "from": "2.1.90",
+ "to": "2.1.90",
+ "known": [
+ {
+ "id": "R3",
+ "what": "thinking history cleared on every turn after an idle >1h, causing repeated cache misses",
+ "ref": "Anthropic April 23 postmortem",
+ "fixed": "2.1.101"
+ }
+ ],
+ "evidence": [
+ "v2.1.90: 100.0% of 8 idle returns stayed cold (other versions: 0.0%) [matches known R3]"
+ ],
+ "examples": [
+ "session sess0037 2026-05-27T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003",
+ "session sess0036 2026-05-26T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003",
+ "session sess0035 2026-05-25T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003"
+ ],
+ "note": null
+ },
+ {
+ "id": "ttl-5m",
+ "agent": "claude-code",
+ "title": "Main-conversation cache written with the 5-minute TTL instead of 1-hour",
+ "from": "2.1.100",
+ "to": "2.1.105",
+ "known": [
+ {
+ "id": "R4",
+ "what": "1-hour prompt cache TTL silently downgraded to 5 minutes",
+ "ref": "anthropics/claude-code#46829, CHANGELOG 2.1.108 + 2.1.129",
+ "fixed": "2.1.129"
+ }
+ ],
+ "evidence": [
+ "v2.1.100: 100.0% of cache writes were 5-minute over 60 turns (other versions: 4.1%) [matches known R4]",
+ "v2.1.105: 100.0% of cache writes were 5-minute over 60 turns (other versions: 4.1%) [matches known R4]"
+ ],
+ "examples": [],
+ "note": "If you use an API key, cloud provider or usage credits, 5-minute TTL is expected, not a bug."
+ },
+ {
+ "id": "uncached-input",
+ "agent": "claude-code",
+ "title": "Large uncached input on tool-result turns (context not being cached)",
+ "from": "2.1.258",
+ "to": "2.1.258",
+ "known": [
+ {
+ "id": "R1",
+ "what": "Fable 5.1 context after tool results re-sent as uncached input every tool-call turn",
+ "ref": "CHANGELOG 2.1.260",
+ "fixed": "2.1.260"
+ }
+ ],
+ "evidence": [
+ "claude-fable-5-1 v2.1.258: median uncached input 3,200 tokens/turn over 50 tool-result turns (same model, other versions: 5) [matches known R1]"
+ ],
+ "examples": [
+ "session sess0043 2026-08-29T09:01:00.000Z uncached_input=3,200 ctx=43,200",
+ "session sess0043 2026-08-29T09:02:00.000Z uncached_input=3,200 ctx=46,200",
+ "session sess0043 2026-08-29T09:03:00.000Z uncached_input=3,200 ctx=49,200"
+ ],
+ "note": null
+ },
+ {
+ "id": "hourly-rebuild",
+ "agent": "claude-code",
+ "title": "Whole prompt cache rebuilt about every hour",
+ "from": "2.1.240",
+ "to": "2.1.240",
+ "known": [
+ {
+ "id": "R5",
+ "what": "OAuth token refresh re-rendered tool definitions, rebuilding the whole cache about hourly",
+ "ref": "CHANGELOG 2.1.248",
+ "fixed": "2.1.248"
+ }
+ ],
+ "evidence": [
+ "v2.1.240: 4 of 4 gaps between full rebuilds were 50 to 70 minutes [matches known R5]"
+ ],
+ "examples": [],
+ "note": null
+ }
+ ],
+ "checks": {
+ "claudeCode": {
+ "warmCacheMiss": [
+ {
+ "version": "2.1.60",
+ "n": 108,
+ "rate": 0,
+ "baseline": 0.07555555555555556,
+ "baselineN": 1350
+ },
+ {
+ "version": "2.1.76",
+ "n": 72,
+ "rate": 1,
+ "baseline": 0.021645021645021644,
+ "baselineN": 1386
+ },
+ {
+ "version": "2.1.90",
+ "n": 96,
+ "rate": 0.25,
+ "baseline": 0.05726872246696035,
+ "baselineN": 1362
+ },
+ {
+ "version": "2.1.95",
+ "n": 108,
+ "rate": 0,
+ "baseline": 0.07555555555555556,
+ "baselineN": 1350
+ },
+ {
+ "version": "2.1.100",
+ "n": 58,
+ "rate": 0,
+ "baseline": 0.07285714285714286,
+ "baselineN": 1400
+ },
+ {
+ "version": "2.1.105",
+ "n": 58,
+ "rate": 0,
+ "baseline": 0.07285714285714286,
+ "baselineN": 1400
+ },
+ {
+ "version": "2.1.140",
+ "n": 108,
+ "rate": 0,
+ "baseline": 0.07555555555555556,
+ "baselineN": 1350
+ },
+ {
+ "version": "2.1.200",
+ "n": 108,
+ "rate": 0,
+ "baseline": 0.07555555555555556,
+ "baselineN": 1350
+ },
+ {
+ "version": "2.1.240",
+ "n": 478,
+ "rate": 0.012552301255230125,
+ "baseline": 0.09795918367346938,
+ "baselineN": 980
+ },
+ {
+ "version": "2.1.250",
+ "n": 108,
+ "rate": 0,
+ "baseline": 0.07555555555555556,
+ "baselineN": 1350
+ },
+ {
+ "version": "2.1.258",
+ "n": 48,
+ "rate": 0,
+ "baseline": 0.07234042553191489,
+ "baselineN": 1410
+ },
+ {
+ "version": "2.1.262",
+ "n": 108,
+ "rate": 0,
+ "baseline": 0.07555555555555556,
+ "baselineN": 1350
+ }
+ ],
+ "postIdleCold": [
+ {
+ "version": "2.1.90",
+ "n": 8,
+ "rate": 1,
+ "baseline": 0,
+ "baselineN": 18
+ }
+ ],
+ "ttl5m": [
+ {
+ "version": "2.1.60",
+ "n": 115,
+ "share5m": 0,
+ "baseline": 0.08498583569405099
+ },
+ {
+ "version": "2.1.76",
+ "n": 75,
+ "share5m": 0,
+ "baseline": 0.08264462809917356
+ },
+ {
+ "version": "2.1.90",
+ "n": 112,
+ "share5m": 0,
+ "baseline": 0.08480565371024736
+ },
+ {
+ "version": "2.1.95",
+ "n": 115,
+ "share5m": 0,
+ "baseline": 0.08498583569405099
+ },
+ {
+ "version": "2.1.100",
+ "n": 60,
+ "share5m": 1,
+ "baseline": 0.0408997955010225
+ },
+ {
+ "version": "2.1.105",
+ "n": 60,
+ "share5m": 1,
+ "baseline": 0.0408997955010225
+ },
+ {
+ "version": "2.1.140",
+ "n": 115,
+ "share5m": 0,
+ "baseline": 0.08498583569405099
+ },
+ {
+ "version": "2.1.200",
+ "n": 115,
+ "share5m": 0,
+ "baseline": 0.08498583569405099
+ },
+ {
+ "version": "2.1.240",
+ "n": 480,
+ "share5m": 0,
+ "baseline": 0.11461318051575932
+ },
+ {
+ "version": "2.1.250",
+ "n": 115,
+ "share5m": 0,
+ "baseline": 0.08498583569405099
+ },
+ {
+ "version": "2.1.258",
+ "n": 50,
+ "share5m": 0,
+ "baseline": 0.08124576844955991
+ },
+ {
+ "version": "2.1.262",
+ "n": 115,
+ "share5m": 0,
+ "baseline": 0.08498583569405099
+ }
+ ],
+ "uncachedInput": [
+ {
+ "model": "claude-fable-5-1",
+ "version": "2.1.258",
+ "n": 50,
+ "median": 3200,
+ "baseline": 5
+ },
+ {
+ "model": "claude-opus-4-8",
+ "version": "2.1.240",
+ "n": 480,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-opus-4-6",
+ "version": "2.1.100",
+ "n": 60,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-opus-4-6",
+ "version": "2.1.105",
+ "n": 60,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-opus-4-6",
+ "version": "2.1.90",
+ "n": 112,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-opus-4-6",
+ "version": "2.1.76",
+ "n": 75,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-fable-5-1",
+ "version": "2.1.262",
+ "n": 25,
+ "median": 5,
+ "baseline": 5
+ },
+ {
+ "model": "claude-opus-4-8",
+ "version": "2.1.262",
+ "n": 90,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-fable-5-1",
+ "version": "2.1.250",
+ "n": 25,
+ "median": 5,
+ "baseline": 5
+ },
+ {
+ "model": "claude-opus-4-8",
+ "version": "2.1.250",
+ "n": 90,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-fable-5-1",
+ "version": "2.1.200",
+ "n": 25,
+ "median": 5,
+ "baseline": 5
+ },
+ {
+ "model": "claude-opus-4-8",
+ "version": "2.1.200",
+ "n": 90,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-fable-5-1",
+ "version": "2.1.140",
+ "n": 25,
+ "median": 5,
+ "baseline": 5
+ },
+ {
+ "model": "claude-opus-4-8",
+ "version": "2.1.140",
+ "n": 90,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-fable-5-1",
+ "version": "2.1.95",
+ "n": 25,
+ "median": 5,
+ "baseline": 5
+ },
+ {
+ "model": "claude-opus-4-8",
+ "version": "2.1.95",
+ "n": 90,
+ "median": 3,
+ "baseline": 3
+ },
+ {
+ "model": "claude-fable-5-1",
+ "version": "2.1.60",
+ "n": 25,
+ "median": 5,
+ "baseline": 5
+ },
+ {
+ "model": "claude-opus-4-8",
+ "version": "2.1.60",
+ "n": 90,
+ "median": 3,
+ "baseline": 3
+ }
+ ],
+ "hourlyRebuild": [
+ {
+ "version": "2.1.76",
+ "n": 30,
+ "hourly": 0
+ },
+ {
+ "version": "2.1.90",
+ "n": 16,
+ "hourly": 0
+ },
+ {
+ "version": "2.1.240",
+ "n": 4,
+ "hourly": 4
+ }
+ ]
+ },
+ "codex": null
+ },
+ "bugReport": "### Prompt cache missing on back-to-back turns\n\n**Detected by** sipcode regress unknown from local logs only (no data left the machine).\n**Affected Claude Code versions (from my logs):** 2.1.76 to 2.1.90\n**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29\n\n**Evidence**\n- v2.1.76: 100.0% of 72 warm turns were cache misses (your other versions: 2.2% of 1386) [matches known R2]\n- v2.1.90: 25.0% of 96 warm turns were cache misses (your other versions: 5.7% of 1362) [matches known R3]\n\n**Sample requests (token counts only)**\n- session sess0029 2026-05-12T09:02:00.000Z ctx=56,072 cache_read=14,569 cache_write=41,500\n- session sess0029 2026-05-12T09:03:00.000Z ctx=57,572 cache_read=14,569 cache_write=43,000\n- session sess0029 2026-05-12T09:04:00.000Z ctx=59,072 cache_read=14,569 cache_write=44,500\n\n**Matches known issue:** R2 --resume full prompt-cache miss (every message under --print --resume) (anthropics/claude-code#34629, CHANGELOG 2.1.90, fixed in 2.1.90); R3 thinking history cleared on every turn after an idle >1h, causing repeated cache misses (Anthropic April 23 postmortem, fixed in 2.1.101)\n\n### Cache stays cold for several turns after an idle break\n\n**Detected by** sipcode regress unknown from local logs only (no data left the machine).\n**Affected Claude Code versions (from my logs):** 2.1.90\n**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29\n\n**Evidence**\n- v2.1.90: 100.0% of 8 idle returns stayed cold (other versions: 0.0%) [matches known R3]\n\n**Sample requests (token counts only)**\n- session sess0037 2026-05-27T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003\n- session sess0036 2026-05-26T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003\n- session sess0035 2026-05-25T10:20:00.000Z 2 turns later cache_read=0 of ctx 76,003\n\n**Matches known issue:** R3 thinking history cleared on every turn after an idle >1h, causing repeated cache misses (Anthropic April 23 postmortem, fixed in 2.1.101)\n\n### Main-conversation cache written with the 5-minute TTL instead of 1-hour\n\n**Detected by** sipcode regress unknown from local logs only (no data left the machine).\n**Affected Claude Code versions (from my logs):** 2.1.100 to 2.1.105\n**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29\n\n**Evidence**\n- v2.1.100: 100.0% of cache writes were 5-minute over 60 turns (other versions: 4.1%) [matches known R4]\n- v2.1.105: 100.0% of cache writes were 5-minute over 60 turns (other versions: 4.1%) [matches known R4]\n\n**Matches known issue:** R4 1-hour prompt cache TTL silently downgraded to 5 minutes (anthropics/claude-code#46829, CHANGELOG 2.1.108 + 2.1.129, fixed in 2.1.129)\n\n_Note: If you use an API key, cloud provider or usage credits, 5-minute TTL is expected, not a bug._\n\n### Large uncached input on tool-result turns (context not being cached)\n\n**Detected by** sipcode regress unknown from local logs only (no data left the machine).\n**Affected Claude Code versions (from my logs):** 2.1.258\n**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29\n\n**Evidence**\n- claude-fable-5-1 v2.1.258: median uncached input 3,200 tokens/turn over 50 tool-result turns (same model, other versions: 5) [matches known R1]\n\n**Sample requests (token counts only)**\n- session sess0043 2026-08-29T09:01:00.000Z uncached_input=3,200 ctx=43,200\n- session sess0043 2026-08-29T09:02:00.000Z uncached_input=3,200 ctx=46,200\n- session sess0043 2026-08-29T09:03:00.000Z uncached_input=3,200 ctx=49,200\n\n**Matches known issue:** R1 Fable 5.1 context after tool results re-sent as uncached input every tool-call turn (CHANGELOG 2.1.260, fixed in 2.1.260)\n\n### Whole prompt cache rebuilt about every hour\n\n**Detected by** sipcode regress unknown from local logs only (no data left the machine).\n**Affected Claude Code versions (from my logs):** 2.1.240\n**My data:** 1,527 requests, 43 sessions, 2026-03-01 to 2026-08-29\n\n**Evidence**\n- v2.1.240: 4 of 4 gaps between full rebuilds were 50 to 70 minutes [matches known R5]\n\n**Matches known issue:** R5 OAuth token refresh re-rendered tool definitions, rebuilding the whole cache about hourly (CHANGELOG 2.1.248, fixed in 2.1.248)\n"
+}
diff --git a/tests/integration/codex-compressed.integration.test.ts b/tests/integration/codex-compressed.integration.test.ts
index 687dab8..5306aa0 100644
--- a/tests/integration/codex-compressed.integration.test.ts
+++ b/tests/integration/codex-compressed.integration.test.ts
@@ -34,6 +34,7 @@ import {
solTurn,
withoutZstd,
zstdSkipMessage,
+ zstdSkipWarning,
} from "./codex-fixtures.js";
import { addCodexDataset } from "./regress-fixtures.js";
@@ -188,12 +189,15 @@ describe("Codex logs compressed (.jsonl.zst) read exactly like plain ones", { ti
expect(await today(spy, true, { usageCaches: caches })).toBe(await today(home("plain"), true));
});
- it("on a Node.js without zstd: compressed logs are skipped with a note naming the Node version needed", async () => {
+ it("on a Node.js without zstd: compressed logs are skipped with a warning naming the Node version needed", async () => {
const out = await withoutZstd(() => COMMANDS[0], false));
const n = scenario().files.filter((f, i) => /\/rollout-[^/]*\.jsonl$/.test(f.p) && i % 2 === 0).length;
+ expect(out.split("\n").slice(0, 2)).toEqual(["exit 0", zstdSkipWarning(n)]);
expect(out).toContain(`[E009] ${zstdSkipMessage(n)}`);
const reg = await withoutZstd(() => COMMANDS[6], false));
- expect(reg).toContain(`note: ${zstdSkipMessage(n)}`);
+ expect(reg.split("\n").slice(0, 2)).toEqual(["exit 0", zstdSkipWarning(n)]);
+ // JSON: the note stays on stderr.
+ expect(await withoutZstd(() => COMMANDS[6], true))).toContain(`stderr: note: ${zstdSkipMessage(n)}`);
});
});
diff --git a/tests/integration/codex-fixtures.ts b/tests/integration/codex-fixtures.ts
index 1c8ee0b..1d0a6a4 100644
--- a/tests/integration/codex-fixtures.ts
+++ b/tests/integration/codex-fixtures.ts
@@ -18,6 +18,10 @@ export async function withoutZstd(fn: () => Promise): Promise {
export const zstdSkipMessage = (n: number): string =>
`${n} compressed Codex log(s) (.jsonl.zst) skipped: reading them needs Node.js 22.15 or newer (you have ${process.version}).`;
+/** The terminal warning at the top of stdout for the same skipped logs. */
+export const zstdSkipWarning = (n: number): string =>
+ `warning: ${n} Codex log file(s) could not be read (compressed .jsonl.zst logs need Node.js 22.15 or newer; you have ${process.version}). Numbers below leave them out.`;
+
const line = (o: unknown): string => JSON.stringify(o);
/** A usage-limit reading Codex logs after a request (event_msg token_count rate_limits). */
diff --git a/tests/integration/forecast.integration.test.ts b/tests/integration/forecast.integration.test.ts
index 2603c36..abb2b0b 100644
--- a/tests/integration/forecast.integration.test.ts
+++ b/tests/integration/forecast.integration.test.ts
@@ -4,7 +4,7 @@ import { runForecastCmd } from "../../src/commands/forecast.js";
import { InMemoryFs } from "../../src/lib/fs.js";
import { FakeClock } from "../../src/lib/clock.js";
import { FakeProcessEnv } from "../../src/lib/process.js";
-import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn, withoutZstd, zstdSkipMessage } from "./codex-fixtures.js";
+import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn, withoutZstd, zstdSkipMessage, zstdSkipWarning } from "./codex-fixtures.js";
const NOW = new Date("2026-05-20T12:00:00.000Z");
@@ -100,10 +100,12 @@ describe("runForecastCmd: Claude Code and Codex sections", () => {
);
});
- it("on a Node.js without zstd, reports skipped compressed Codex logs once, on stderr", async () => {
+ it("on a Node.js without zstd, warns about skipped compressed Codex logs once, at the top of stdout", async () => {
const fs = withCodex(new InMemoryFs());
fs.writeFile(`${CODEX_SESSIONS}/2026/05/09/rollout-a.jsonl.zst`, "z", 1);
const r = await withoutZstd(() => run({}, fs));
- expect(r.err).toBe(`note: ${zstdSkipMessage(1)}`);
+ expect(r.out.split("\n").slice(0, 2)).toEqual([zstdSkipWarning(1), ""]);
+ expect(r.out.match(/\.jsonl\.zst/g)).toHaveLength(1);
+ expect(r.err).toBe("");
});
});
diff --git a/tests/integration/impact.integration.test.ts b/tests/integration/impact.integration.test.ts
index c870432..329d43d 100644
--- a/tests/integration/impact.integration.test.ts
+++ b/tests/integration/impact.integration.test.ts
@@ -4,7 +4,7 @@ import { runImpactCommand } from "../../src/commands/impact.js";
import { InMemoryFs } from "../../src/lib/fs.js";
import { FakeClock } from "../../src/lib/clock.js";
import { FakeProcessEnv } from "../../src/lib/process.js";
-import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn, withoutZstd, zstdSkipMessage } from "./codex-fixtures.js";
+import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn, withoutZstd, zstdSkipMessage, zstdSkipWarning } from "./codex-fixtures.js";
const NOW = new Date("2026-05-20T12:00:00.000Z");
// A folder with no .sipcode/install-state.json: no install marker.
@@ -112,11 +112,13 @@ describe("runImpactCommand: Claude Code and Codex sections", () => {
);
});
- it("on a Node.js without zstd, reports skipped compressed Codex logs once, on stderr", async () => {
+ it("on a Node.js without zstd, warns about skipped compressed Codex logs once, at the top of stdout", async () => {
const fs = withCodex(new InMemoryFs());
fs.writeFile(`${CODEX_SESSIONS}/2026/05/09/rollout-a.jsonl.zst`, "z", 1);
const r = await withoutZstd(() => run({}, fs));
- expect(r.err).toBe(`note: ${zstdSkipMessage(1)}`);
+ expect(r.out.split("\n").slice(0, 2)).toEqual([zstdSkipWarning(1), ""]);
+ expect(r.out.match(/\.jsonl\.zst/g)).toHaveLength(1);
+ expect(r.err).toBe("");
});
});
diff --git a/tests/integration/stats.integration.test.ts b/tests/integration/stats.integration.test.ts
index 22f676a..4218928 100644
--- a/tests/integration/stats.integration.test.ts
+++ b/tests/integration/stats.integration.test.ts
@@ -6,7 +6,7 @@ import { runStats } from "../../src/commands/stats.js";
import { InMemoryFs } from "../../src/lib/fs.js";
import { FakeClock } from "../../src/lib/clock.js";
import { FakeProcessEnv } from "../../src/lib/process.js";
-import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn, withoutZstd, zstdSkipMessage } from "./codex-fixtures.js";
+import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn, withoutZstd, zstdSkipMessage, zstdSkipWarning } from "./codex-fixtures.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const fixtures = path.resolve(__dirname, "../fixtures/transcripts");
@@ -577,14 +577,16 @@ describe("runStats: Claude Code and Codex sections", { timeout: 20_000 }, () =>
expect(r.out).toMatch(/Both tools: .* \+ ~\$0\.00 \(\+ unpriced\)$/m);
});
- it("on a Node.js without zstd, reports skipped compressed Codex logs as one line, apart from unreadable files", async () => {
+ it("on a Node.js without zstd, reports skipped compressed Codex logs as one reason, apart from unreadable files", async () => {
const fs = codexOnly();
fs.writeFile(`${CODEX_SESSIONS}/2026/05/11/rollout-a.jsonl.zst`, "z", 1);
fs.writeFile(`${CODEX_SESSIONS}/2026/05/12/rollout-b.jsonl.zst`, "z", 1);
const r = await withoutZstd(() => run({ since: "30d" }, fs));
+ // The warning leads stdout; the report's own warnings list keeps its line.
+ expect(r.out.split("\n").slice(0, 2)).toEqual([zstdSkipWarning(2), ""]);
expect(r.out).toContain(`[E009] ${zstdSkipMessage(2)}`);
- expect(r.out).not.toMatch(/couldn't read \d+ transcript file/);
- expect(r.out.match(/\.jsonl\.zst/g)).toHaveLength(1);
+ expect(r.out).not.toMatch(/couldn't read \d+ transcript file|log file\(s\) could not be read \(unreadable/);
+ expect(r.out.match(/\.jsonl\.zst/g)).toHaveLength(2);
});
it("--since all counts days from the earliest session, not from 1970", async () => {
diff --git a/tests/integration/today.integration.test.ts b/tests/integration/today.integration.test.ts
index 6e6c475..926f4ed 100644
--- a/tests/integration/today.integration.test.ts
+++ b/tests/integration/today.integration.test.ts
@@ -4,7 +4,7 @@ import { runTodayCmd } from "../../src/commands/today.js";
import { InMemoryFs } from "../../src/lib/fs.js";
import { FakeClock } from "../../src/lib/clock.js";
import { FakeProcessEnv } from "../../src/lib/process.js";
-import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn, withoutZstd, zstdSkipMessage } from "./codex-fixtures.js";
+import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn, withoutZstd, zstdSkipMessage, zstdSkipWarning } from "./codex-fixtures.js";
const NOW = new Date("2026-05-10T15:00:00.000Z");
@@ -105,11 +105,13 @@ describe("runTodayCmd: Claude Code and Codex sections", { timeout: 20_000 }, ()
expect(r.out).toMatch(/\(\+ unpriced\)$/m);
});
- it("on a Node.js without zstd, reports skipped compressed Codex logs once, on stderr", async () => {
+ it("on a Node.js without zstd, warns about skipped compressed Codex logs once, at the top of stdout", async () => {
const fs = withCodex(new InMemoryFs());
fs.writeFile(`${CODEX_SESSIONS}/2026/05/09/rollout-a.jsonl.zst`, "z", 1);
fs.writeFile(`${CODEX_SESSIONS}/2026/05/09/rollout-b.jsonl.zst`, "z", 1);
const r = await withoutZstd(() => run({}, fs));
- expect(r.err).toBe(`note: ${zstdSkipMessage(2)}`);
+ expect(r.out.split("\n").slice(0, 2)).toEqual([zstdSkipWarning(2), ""]);
+ expect(r.out.match(/\.jsonl\.zst/g)).toHaveLength(1);
+ expect(r.err).toBe("");
});
});
diff --git a/tests/integration/trend.integration.test.ts b/tests/integration/trend.integration.test.ts
index 2cb254e..3bfd6f1 100644
--- a/tests/integration/trend.integration.test.ts
+++ b/tests/integration/trend.integration.test.ts
@@ -4,7 +4,7 @@ import { runTrend } from "../../src/commands/trend.js";
import { InMemoryFs } from "../../src/lib/fs.js";
import { FakeClock } from "../../src/lib/clock.js";
import { FakeProcessEnv } from "../../src/lib/process.js";
-import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn, withoutZstd, zstdSkipMessage } from "./codex-fixtures.js";
+import { addCodexRollout, autoReviewTurn, CODEX_SESSIONS, solTurn, withoutZstd, zstdSkipMessage, zstdSkipWarning } from "./codex-fixtures.js";
const NOW = new Date("2026-05-20T12:00:00.000Z");
@@ -92,10 +92,12 @@ describe("runTrend: Claude Code and Codex sections", () => {
expect(ratio.out).not.toContain("without a known price");
});
- it("on a Node.js without zstd, reports skipped compressed Codex logs once, on stderr", async () => {
+ it("on a Node.js without zstd, warns about skipped compressed Codex logs once, at the top of stdout", async () => {
const fs = withCodex(new InMemoryFs());
fs.writeFile(`${CODEX_SESSIONS}/2026/05/09/rollout-a.jsonl.zst`, "z", 1);
const r = await withoutZstd(() => run({}, fs));
- expect(r.err).toBe(`note: ${zstdSkipMessage(1)}`);
+ expect(r.out.split("\n").slice(0, 2)).toEqual([zstdSkipWarning(1), ""]);
+ expect(r.out.match(/\.jsonl\.zst/g)).toHaveLength(1);
+ expect(r.err).toBe("");
});
});
diff --git a/tests/integration/unread-warning.integration.test.ts b/tests/integration/unread-warning.integration.test.ts
new file mode 100644
index 0000000..d27f614
--- /dev/null
+++ b/tests/integration/unread-warning.integration.test.ts
@@ -0,0 +1,291 @@
+/**
+ * Logs Sipcode could not read (compressed logs on a Node.js without zstd,
+ * damaged or unreadable files) are impossible to miss: in the terminal, every
+ * period and single-session command prints a `warning:` line at the top of
+ * stdout; with --json, the notes stay on stderr and the JSON is unchanged.
+ * When nothing is skipped, every output is byte-identical to before (golden
+ * file recorded before the warning existed).
+ */
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { InMemoryFs } from "../../src/lib/fs.js";
+import { FakeClock } from "../../src/lib/clock.js";
+import { FakeProcessEnv } from "../../src/lib/process.js";
+import { FakeClipboard } from "../../src/lib/clipboard.js";
+import { runStats } from "../../src/commands/stats.js";
+import { runTodayCmd } from "../../src/commands/today.js";
+import { runForecastCmd } from "../../src/commands/forecast.js";
+import { runTrend } from "../../src/commands/trend.js";
+import { runImpactCommand } from "../../src/commands/impact.js";
+import { runWhy } from "../../src/commands/why.js";
+import { runReceipt } from "../../src/commands/receipt.js";
+import { runDriftCommand } from "../../src/commands/drift.js";
+import { runLimits } from "../../src/commands/limits.js";
+import { runRegress } from "../../src/commands/regress.js";
+import type { StoreIO } from "../../src/modules/drift/store.js";
+import type { UsageCaches } from "../../src/modules/agents/usageSessions.js";
+import { memCache } from "../modules/agents/memCache.js";
+import {
+ addCodexRollout,
+ addShrinkScenario,
+ addWeeklyLimitScenario,
+ CODEX_SESSIONS,
+ solTurn,
+ withoutZstd,
+} from "./codex-fixtures.js";
+import { addClaudeDataset, addCodexDataset } from "./regress-fixtures.js";
+
+const NOW = new Date("2026-10-09T10:00:00.000Z");
+
+let savedTz: string | undefined;
+beforeAll(() => {
+ savedTz = process.env.TZ;
+ process.env.TZ = "UTC";
+});
+afterAll(() => {
+ if (savedTz === undefined) delete process.env.TZ;
+ else process.env.TZ = savedTz;
+});
+
+/** Claude Code and Codex logs: limits, regress data for both tools, today's sessions. */
+function home(fs: F = new InMemoryFs() as F): F {
+ addClaudeDataset(fs, { dirty: true });
+ addCodexDataset(fs, { dirty: true });
+ addWeeklyLimitScenario(fs);
+ addShrinkScenario(fs);
+ addCodexRollout(fs, "cx-may", [solTurn("2026-05-19T10:00:00Z"), solTurn("2026-05-19T10:05:00Z")]);
+ return fs;
+}
+
+/** Two extra compressed Codex logs: skipped without zstd, damaged (unreadable) with it. */
+function withBadZst(fs: F): F {
+ fs.writeFile(`${CODEX_SESSIONS}/2026/05/09/rollout-a.jsonl.zst`, "z", Date.parse("2026-05-09T10:00:00Z"));
+ fs.writeFile(`${CODEX_SESSIONS}/2026/05/09/rollout-b.jsonl.zst`, "z", Date.parse("2026-05-09T11:00:00Z"));
+ return fs;
+}
+
+function memStoreIO(): StoreIO {
+ const files = new Map();
+ return {
+ async read(p) {
+ return files.get(p) ?? null;
+ },
+ async write(p, c) {
+ files.set(p, c);
+ },
+ async append(p, c) {
+ files.set(p, (files.get(p) ?? "") + c);
+ },
+ };
+}
+
+interface Out {
+ readonly exitCode: number;
+ readonly out: string[];
+ readonly err: string[];
+}
+
+type Run = (fs: InMemoryFs, json: boolean, agent: string | undefined) => Promise;
+
+function io(fs: InMemoryFs) {
+ const out: string[] = [];
+ const err: string[] = [];
+ const caches = new Map>();
+ const usageCaches: UsageCaches = (id) => {
+ if (!caches.has(id)) caches.set(id, memCache());
+ return caches.get(id)!;
+ };
+ return {
+ out,
+ err,
+ d: {
+ fs,
+ env: new FakeProcessEnv({ homeDir: "/home/u", platform: "linux", vars: { NO_COLOR: "1" } }),
+ clock: new FakeClock(NOW),
+ stdout: (s: string) => out.push(s),
+ stderr: (s: string) => err.push(s),
+ usageCaches,
+ },
+ };
+}
+
+const run =
+ (f: (json: boolean, agent: string | undefined, d: ReturnType["d"]) => Promise<{ exitCode: number }>): Run =>
+ async (fs, json, agent) => {
+ const c = io(fs);
+ const r = await f(json, agent, c.d);
+ return { exitCode: r.exitCode, out: c.out, err: c.err };
+ };
+
+const cwd = "/nonexistent-sipcode-unread";
+
+const COMMANDS: Array<[string, Run]> = [
+ ["stats", run((json, agent, d) => runStats({ json, agent, since: "all", cwd }, { ...d, writeFile: async () => {} }))],
+ ["today", run((json, agent, d) => runTodayCmd({ json, agent, cwd }, d))],
+ ["forecast", run((json, agent, d) => runForecastCmd({ json, agent, cwd }, d))],
+ ["trend", run((json, agent, d) => runTrend({ json, agent, cwd }, d))],
+ ["impact", run((json, agent, d) => runImpactCommand({ json, agent, cwd }, d))],
+ ["why", run((json, agent, d) => runWhy({ json, agent, cwd }, d))],
+ [
+ "receipt",
+ run((json, agent, d) =>
+ runReceipt({ json, agent, cwd, htmlOnly: true, noShare: true }, { ...d, clipboard: new FakeClipboard(), writeFile: async () => {} }),
+ ),
+ ],
+ [
+ "drift",
+ run((json, agent, d) =>
+ runDriftCommand(
+ { json, agent, cwd, noCache: true },
+ { ...d, now: NOW, homeDir: "/home/u", stateDir: "/state", storeIO: memStoreIO(), configPaths: [], configReader: async () => null },
+ ),
+ ),
+ ],
+ ["limits", run((json, agent, d) => runLimits({ json, agent, since: "all", cwd }, d))],
+ ["regress", run((json, agent, d) => runRegress({ json, agent, cwd, bugReport: true }, d))],
+];
+
+/** Every command, terminal and JSON, both tools and each one alone (limits: Codex only). */
+const CASES: Array<{ name: string; run: Run; json: boolean; agent: string | undefined }> = COMMANDS.flatMap(([name, r]) =>
+ [false, true].flatMap((json) =>
+ (name === "limits" ? [undefined, "codex"] : [undefined, "codex", "claude-code"]).map((agent) => ({
+ name: `${name}${agent ? ` --agent ${agent}` : ""}${json ? " --json" : ""}`,
+ run: r,
+ json,
+ agent,
+ })),
+ ),
+);
+
+/** stdout as lines (a command may print several lines in one write). */
+const lines = (o: Out): string[] => o.out.join("\n").split("\n");
+
+const show = (o: Out): string => [`exit ${o.exitCode}`, ...o.out, ...o.err.map((l) => `stderr: ${l}`)].join("\n");
+
+describe("nothing skipped: output byte-identical to before", { timeout: 60_000 }, () => {
+ it("every command, terminal and JSON", async () => {
+ const all: string[] = [];
+ for (const c of CASES) all.push(`=== ${c.name}`, show(await c.run(home(), c.json, c.agent)));
+ await expect(all.join("\n") + "\n").toMatchFileSnapshot("./__golden__/unread-warning-clean.txt");
+ });
+});
+
+const SKIPPED = (n: number): string =>
+ `warning: ${n} Codex log file(s) could not be read (compressed .jsonl.zst logs need Node.js 22.15 or newer; you have ${process.version}). Numbers below leave them out.`;
+const DAMAGED = (n: number): string =>
+ `warning: ${n} Codex log file(s) could not be read (unreadable or damaged). Numbers below leave them out.`;
+const SKIPPED_NOTE = (n: number): string =>
+ `note: ${n} compressed Codex log(s) (.jsonl.zst) skipped: reading them needs Node.js 22.15 or newer (you have ${process.version}).`;
+const DAMAGED_NOTE = (n: number): string => `note: ${n} transcript file(s) could not be read or parsed; totals exclude them.`;
+
+/** Does this case read the Codex logs? (JSON shows one tool: Claude Code when both are installed.) */
+function readsCodex(c: (typeof CASES)[number]): boolean {
+ const name = c.name.split(" ")[0]!;
+ if (c.agent === "codex" || name === "limits") return true;
+ if (c.agent === "claude-code") return false;
+ return !c.json || name === "regress";
+}
+
+for (const [label, wrap, warning, note] of [
+ ["a Node.js without zstd: compressed logs skipped", withoutZstd, SKIPPED, SKIPPED_NOTE],
+ ["damaged compressed logs (Node.js with zstd)", (f: () => Promise) => f(), DAMAGED, DAMAGED_NOTE],
+] as const) {
+ describe(`unread Codex logs, ${label}`, { timeout: 60_000 }, () => {
+ for (const c of CASES) {
+ it(c.name, async () => {
+ const clean = await c.run(home(), c.json, c.agent);
+ const r = await wrap(() => c.run(withBadZst(home()), c.json, c.agent));
+ const cmd = c.name.split(" ")[0]!;
+ expect(r.exitCode).toBe(clean.exitCode);
+ if (!readsCodex(c)) {
+ // The Codex logs are not read: nothing changes.
+ expect(r).toEqual(clean);
+ return;
+ }
+ if (!c.json) {
+ // Terminal: the warning is the first line of stdout, then a blank line, then the usual output.
+ const got = lines(r);
+ expect(got[0]).toBe(warning(2));
+ expect(got[1]).toBe("");
+ expect(got.filter((l) => l.startsWith("warning:"))).toEqual([warning(2)]);
+ if (cmd === "stats") {
+ // Its own `warnings (N):` list under the Codex section stays as before.
+ const noList = (ls: string[]) => ls.filter((l) => l !== "" && !/^warnings \(\d+\):$|^ · \[E\d+\] /.test(l));
+ expect(noList(got.slice(2))).toEqual(noList(lines(clean)));
+ } else if (cmd === "regress") {
+ // Its WARNING line under the coverage (sandbox advice) stays as before.
+ expect(got.slice(2).filter((l) => !l.startsWith("WARNING: "))).toEqual(lines(clean));
+ } else {
+ expect(got.slice(2)).toEqual(lines(clean));
+ }
+ // Not repeated as a small note on stderr.
+ expect(r.err.join("\n")).not.toMatch(/compressed Codex log|could not be read or parsed/);
+ expect(r.err).toEqual(clean.err);
+ return;
+ }
+ // --json: stdout is unchanged JSON (stats keeps its warnings array); notes go to stderr.
+ expect(r.out.join("\n")).not.toContain("warning:");
+ if (cmd === "stats") {
+ const j = JSON.parse(r.out.join("\n"));
+ expect(j.warnings.length).toBe(JSON.parse(clean.out.join("\n")).warnings.length + 1);
+ expect(r.err).toEqual(clean.err);
+ return;
+ }
+ if (cmd === "regress") {
+ // Same JSON; only its `access` counts name the skipped logs.
+ const { access, ...rest } = JSON.parse(r.out.join("\n"));
+ const { access: cleanAccess, ...cleanRest } = JSON.parse(clean.out.join("\n"));
+ expect(rest).toEqual(cleanRest);
+ expect(Object.keys(access)).toEqual(Object.keys(cleanAccess));
+ } else {
+ expect(r.out).toEqual(clean.out);
+ }
+ const both = cmd === "regress" && c.agent === undefined;
+ expect(r.err).toContain(both ? `Codex: ${note(2)}` : note(2));
+ });
+ }
+ });
+}
+
+describe("unread logs: the cases where the warning matters most", () => {
+ it("limits: every Codex log skipped prints the warning before 'no readings'", async () => {
+ const fs = new InMemoryFs();
+ fs.writeFile(`${CODEX_SESSIONS}/2026/05/09/rollout-a.jsonl.zst`, "z", 1);
+ const r = await withoutZstd(() => COMMANDS.find(([n]) => n === "limits"));
+ expect(lines(r)).toEqual([SKIPPED(1), "", "no Codex limit readings found yet (Codex records them after each request)."]);
+ });
+
+ it("why --agent codex: every Codex log skipped prints the warning on stdout before the error", async () => {
+ const fs = new InMemoryFs();
+ fs.writeFile(`${CODEX_SESSIONS}/2026/05/09/rollout-a.jsonl.zst`, "z", 1);
+ const r = await withoutZstd(() => COMMANDS.find(([n]) => n === "why"));
+ expect(r.exitCode).toBe(1);
+ expect(lines(r)).toEqual([SKIPPED(1), ""]);
+ expect(r.err.length).toBeGreaterThan(0);
+ });
+
+ it("stats --agent codex: every Codex log skipped is not a brand-new user without a warning", async () => {
+ const fs = new InMemoryFs();
+ fs.writeFile(`${CODEX_SESSIONS}/2026/05/09/rollout-a.jsonl.zst`, "z", 1);
+ const r = await withoutZstd(() => COMMANDS.find(([n]) => n === "stats"));
+ expect(lines(r)[0]).toBe(SKIPPED(1));
+ });
+
+ it("skipped and unreadable in one run: one line each, exact counts", async () => {
+ // A plain rollout whose line 1 reads but whose full read fails (a file locked or cut off mid-read).
+ const locked = `${CODEX_SESSIONS}/2026/05/10/rollout-2026-05-10T10-00-00-c.jsonl`;
+ class LockedFs extends InMemoryFs {
+ override async readFile(p: string): Promise {
+ if (p.replace(/\\/g, "/") === locked) throw Object.assign(new Error(`EBUSY: ${p}`), { code: "EBUSY" });
+ return super.readFile(p);
+ }
+ }
+ const locking = withBadZst(home(new LockedFs()));
+ addCodexRollout(locking, "c", [solTurn("2026-05-10T10:00:00Z")]);
+ const r = await withoutZstd(() => COMMANDS.find(([n]) => n === "today"));
+ expect(lines(r).slice(0, 3)).toEqual([
+ SKIPPED(2),
+ "warning: 1 Codex log file(s) could not be read (unreadable or damaged). Numbers below leave them out.",
+ "",
+ ]);
+ });
+});
diff --git a/tests/modules/agents/multi.test.ts b/tests/modules/agents/multi.test.ts
index e62d3d9..19b4acb 100644
--- a/tests/modules/agents/multi.test.ts
+++ b/tests/modules/agents/multi.test.ts
@@ -11,6 +11,7 @@ import {
resolveDisplayAgents,
runSections,
sectionHeader,
+ SectionOutput,
type SectionResult,
} from "../../../src/modules/agents/multi.js";
import type { Agent } from "../../../src/modules/agents/types.js";
@@ -274,6 +275,52 @@ describe("runSections", () => {
expect(r.out).toEqual(["── Claude Code ──", "", "── Codex ──", "codex report", ""]);
});
+ it("warnings about unread logs lead stdout, above the banner and every section", async () => {
+ const one = await sections(
+ [{ exitCode: 0, writes: [w("stdout", "a")], warnings: ["warning: 1 Claude Code log file(s) could not be read (x)."] }],
+ [claudeCodeAgent],
+ );
+ expect(one.out).toEqual([
+ "warning: 1 Claude Code log file(s) could not be read (x).",
+ "",
+ "detected agent: claude-code (auto). pass --agent to override.",
+ "a",
+ ]);
+ const two = await sections([
+ { exitCode: 0, writes: [w("stdout", "claude report")] },
+ { exitCode: 0, writes: [w("stdout", "codex report")], warnings: ["warning: 2 Codex log file(s) (y).", "warning: 1 Codex log folder(s) (z)."] },
+ ]);
+ expect(two.out).toEqual([
+ "warning: 2 Codex log file(s) (y).",
+ "warning: 1 Codex log folder(s) (z).",
+ "",
+ "── Claude Code ──",
+ "claude report",
+ "",
+ "── Codex ──",
+ "codex report",
+ "",
+ ]);
+ });
+
+ it("SectionOutput.unread: a warning in the terminal, the usual notes on stderr with --json", () => {
+ const counts = { unreadable: 1, unreadableFolders: 0, skippedCompressed: 0, issues: [] };
+ const term = new SectionOutput();
+ term.unread("Codex", counts, false);
+ expect(term.result(0).warnings).toEqual([
+ "warning: 1 Codex log file(s) could not be read (unreadable or damaged). Numbers below leave them out.",
+ ]);
+ expect(term.writes).toEqual([]);
+ const json = new SectionOutput();
+ json.unread("Codex", counts, true);
+ expect(json.result(0).warnings).toBeUndefined();
+ expect(json.writes).toEqual([w("stderr", "note: 1 transcript file(s) could not be read or parsed; totals exclude them.")]);
+ // Nothing unread: nothing at all (the result has no warnings key).
+ const clean = new SectionOutput();
+ clean.unread("Codex", { ...counts, unreadable: 0 }, false);
+ expect(clean.result(0)).toEqual({ exitCode: 0, writes: [] });
+ });
+
it("ends with the combined line when two sections have totals", async () => {
const r = await sections([
{ exitCode: 0, writes: [], totals: { tokens: 1_000, usd: 2 } },
diff --git a/tests/modules/agents/unread-warnings.test.ts b/tests/modules/agents/unread-warnings.test.ts
new file mode 100644
index 0000000..e524f2a
--- /dev/null
+++ b/tests/modules/agents/unread-warnings.test.ts
@@ -0,0 +1,92 @@
+/**
+ * The warning about logs Sipcode could not read: one line per reason with the
+ * exact count and the tool's name, and, for single-session commands, printed
+ * before the first line the command writes (UnreadOutput).
+ */
+import { describe, expect, it } from "vitest";
+import { issue } from "../../../src/lib/errors.js";
+import { claudeCodeAgent } from "../../../src/modules/agents/claude-code/adapter.js";
+import { discoveryNotes, unreadWarnings } from "../../../src/modules/agents/loadSessions.js";
+import { UnreadOutput, type AgentSessions } from "../../../src/modules/agents/latest.js";
+import type { Agent } from "../../../src/modules/agents/types.js";
+
+const NONE = { unreadable: 0, unreadableFolders: 0, skippedCompressed: 0, issues: [] };
+const codex = { ...claudeCodeAgent, id: "codex", displayName: "Codex" } as Agent;
+
+describe("unreadWarnings", () => {
+ it("nothing unread: no lines", () => {
+ expect(unreadWarnings("Codex", NONE)).toEqual([]);
+ });
+
+ it("one line per reason, exact counts, the Node.js version for compressed logs", () => {
+ const lines = unreadWarnings(
+ "Claude Code",
+ { unreadable: 2, unreadableFolders: 1, skippedCompressed: 3, issues: [issue("E003", "x")] },
+ "v22.11.0",
+ );
+ expect(lines).toEqual([
+ "warning: 3 Claude Code log file(s) could not be read (compressed .jsonl.zst logs need Node.js 22.15 or newer; you have v22.11.0). Numbers below leave them out.",
+ "warning: 3 Claude Code log file(s) could not be read (unreadable or damaged). Numbers below leave them out.",
+ "warning: 1 Claude Code log folder(s) could not be read (the folder could not be listed). Numbers below leave them out.",
+ ]);
+ });
+
+ it("copy rules: no em or en dashes", () => {
+ const all = unreadWarnings("Codex", { unreadable: 1, unreadableFolders: 1, skippedCompressed: 1, issues: [] }).join("\n");
+ expect(all).not.toMatch(/[–—]/);
+ });
+});
+
+describe("UnreadOutput (why / receipt / drift)", () => {
+ const list = (agent: Agent, counts = NONE): AgentSessions => ({ agent, all: [], scoped: [], counts });
+
+ function make(json: boolean) {
+ const out: string[] = [];
+ const err: string[] = [];
+ const u = new UnreadOutput(json, (s) => out.push(s), (s) => err.push(s));
+ return { u, out, err };
+ }
+
+ it("output before discovery passes through untouched", () => {
+ const { u, out, err } = make(false);
+ u.stderr("bad flag");
+ u.stdout("x");
+ expect(err).toEqual(["bad flag"]);
+ expect(out).toEqual(["x"]);
+ });
+
+ it("nothing unread: output unchanged", () => {
+ const { u, out, err } = make(false);
+ u.watch([list(claudeCodeAgent), list(codex)]);
+ u.stdout("report");
+ expect(out).toEqual(["report"]);
+ expect(err).toEqual([]);
+ });
+
+ it("terminal: the warnings, then a blank line, come before the first line on either stream, once", () => {
+ const { u, out, err } = make(false);
+ u.watch([list(claudeCodeAgent), list(codex, { ...NONE, unreadable: 1 })]);
+ u.unreadable(codex);
+ u.stderr("no sessions found");
+ u.stdout("later");
+ expect(out).toEqual([
+ "warning: 2 Codex log file(s) could not be read (unreadable or damaged). Numbers below leave them out.",
+ "",
+ "later",
+ ]);
+ expect(err).toEqual(["no sessions found"]);
+ });
+
+ it("--json: the usual notes on stderr, named by tool when several were looked at; stdout untouched", () => {
+ const one = make(true);
+ one.u.watch([list(codex, { ...NONE, unreadable: 1 })]);
+ one.u.stdout("{}");
+ expect(one.out).toEqual(["{}"]);
+ expect(one.err).toEqual(["note: 1 transcript file(s) could not be read or parsed; totals exclude them."]);
+
+ const two = make(true);
+ two.u.watch([list(claudeCodeAgent), list(codex, { ...NONE, unreadable: 1 })]);
+ two.u.stdout("{}");
+ expect(two.err).toEqual([`Codex: ${discoveryNotes({ ...NONE, unreadable: 1 })[0]}`]);
+ });
+});
diff --git a/tests/modules/regress/format.test.ts b/tests/modules/regress/format.test.ts
index 9b70aaa..81af756 100644
--- a/tests/modules/regress/format.test.ts
+++ b/tests/modules/regress/format.test.ts
@@ -60,6 +60,29 @@ describe("formatRegressTerminal", () => {
);
});
+ it("unread-log warnings come first; the compressed note below is then not repeated", () => {
+ const top = ["warning: 2 Codex log file(s) could not be read (x). Numbers below leave them out."];
+ const r = report({ access: { ...NO_ACCESS_ISSUES, skippedCompressed: 2 } });
+ const out = formatRegressTerminal(r, { ...OPTS, unreadWarnings: top }).split("\n");
+ expect(out.slice(0, 3)).toEqual([top[0], "", "sipcode regress · local only, no network"]);
+ expect(out.join("\n")).not.toContain("compressed Codex log(s)");
+ // No warnings: exactly the output without the option.
+ expect(formatRegressTerminal(r, { ...OPTS, unreadWarnings: [] })).toBe(formatRegressTerminal(r, OPTS));
+ });
+
+ it("the unreadable WARNING names only the folders that were read (no empty Claude Code folder for --agent codex)", () => {
+ const access = { ...NO_ACCESS_ISSUES, unreadableFiles: 2 };
+ const codexOnly = formatRegressTerminal(
+ report({ claude: null, claudeDir: "", codex: { readings: [], requests: [] }, access }),
+ OPTS,
+ );
+ expect(codexOnly).toContain("allow read access to /h/.codex and run it again.");
+ expect(codexOnly).not.toContain("to and");
+ expect(formatRegressTerminal(report({ access }), OPTS)).toContain(
+ "allow read access to /h/.claude/projects and /h/.codex and run it again.",
+ );
+ });
+
// Codex reported "no output" for `regress --bug-report` with no findings: the
// flag was silently ignored. Every state now says why there is no bug report.
it("--bug-report without findings says so on its own line, in every state", () => {
diff --git a/tests/plugin/codex-plugin.test.ts b/tests/plugin/codex-plugin.test.ts
index e264d49..b530dcb 100644
--- a/tests/plugin/codex-plugin.test.ts
+++ b/tests/plugin/codex-plugin.test.ts
@@ -285,6 +285,12 @@ describe("Codex plugin: skills", () => {
expect(t).not.toMatch(/Run this once:\s*```bash\r?\n\s*sipcode regress --bug-report\r?\n/);
});
+ it("every skill: Sipcode's unread-logs warning is said first", () => {
+ for (const s of SKILLS) {
+ expect(skillText(s), s).toContain('If Sipcode prints a line starting "warning:" that log files could not be read, say it first in your answer');
+ }
+ });
+
it("copy rules: no em or en dashes, no 'stops hallucinations', 'nerfed' only in a prohibition", () => {
const texts = [...SKILLS.map(skillText), JSON.stringify(manifest), JSON.stringify(overlay), JSON.stringify(marketplace)];
for (const t of texts) {
From 874fad8ac5e1a4161c10084f79cad2602cb42836 Mon Sep 17 00:00:00 2001
From: Anuj7411
Date: Thu, 15 Oct 2026 20:22:22 +0530
Subject: [PATCH 8/9] docs: v1.7.5 changelog for the unread-logs warning and
the Codex regress default
CHANGELOG [1.7.5]: the warning at the top of the output for logs Sipcode
could not read, the regress skill's Codex default, and the regress
sandbox WARNING that named an empty folder with --agent. README and
llms-full.txt: an older Node.js gets a warning at the top of each
report. Test count 2,275 to 2,409 (npx vitest run, e2e included) in the
README badge, site Hero and Footer, llms.txt and llms-full.txt.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 8 +++++---
README.md | 6 +++---
docs/site/public/llms-full.txt | 4 ++--
docs/site/public/llms.txt | 4 ++--
docs/site/src/components/Footer.astro | 2 +-
docs/site/src/components/Hero.astro | 2 +-
6 files changed, 14 insertions(+), 12 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1b6029..685b62c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,16 +16,18 @@ Codex now compresses its older session logs, and Sipcode reads them.
### Fixed
- **Compressed Codex logs are read.** Current Codex compresses older session logs to `rollout-*.jsonl.zst` and keeps only the newest ones as plain `.jsonl`. Sipcode skipped the compressed ones, so it saw almost none of a Codex user's history: `forecast` said it needed more days, `limits` started again at 0%, and `stats`, `why`, `today`, `trend`, `impact` and `regress` missed most sessions. Sipcode now reads them exactly like plain logs, on Node.js 22.15 or newer (zstd ships in Node's own zlib, so there is no new dependency and still no network). A session with both a plain and a compressed log is read once, from the plain log. A damaged or cut-off compressed log is counted as unreadable, never a crash.
-- **Older Node.js: a clear note.** On a Node.js without zstd the compressed logs are still counted, with the note "N compressed Codex log(s) (.jsonl.zst) skipped: reading them needs Node.js 22.15 or newer (you have vX)." JSON output is unchanged.
+- **Older Node.js: a clear warning.** On a Node.js without zstd the compressed logs are still counted and reported (see "Logs Sipcode could not read" below). JSON output is unchanged; with `--json` the note "N compressed Codex log(s) (.jsonl.zst) skipped: reading them needs Node.js 22.15 or newer (you have vX)." goes to stderr.
+- **`regress` sandbox warning with `--agent`.** The "could not be read" WARNING told the user to allow read access to both log folders, and with `--agent codex` it named an empty one ("allow read access to and ..."). It now names only the folders the run read.
- **`regress --bug-report` with nothing to report says so.** With no finding, the flag used to print only the plain result, which Codex read as "no output". It now ends with "No bug report: there is no finding to report." The answer was and is always on stdout.
### Changed
-- **Codex plugin: `regress` gets time.** A first `regress` over large Claude Code logs can take a minute or more (49 seconds on 5.4 GB on the maintainer's machine; about 1 second once the usage cache is warm), and Codex stopped the command after about 10 seconds with empty output. The regress skill now gives the command a timeout of at least 3 minutes and never reports empty output as a result.
+- **Logs Sipcode could not read are impossible to miss.** When some log files cannot be read (compressed Codex logs on a Node.js older than 22.15, a damaged or unreadable file, a folder that cannot be listed), `stats`, `today`, `forecast`, `trend`, `impact`, `why`, `receipt`, `drift`, `limits` and `regress` now start their terminal output with one line per reason, for example "warning: 3 Codex log file(s) could not be read (compressed .jsonl.zst logs need Node.js 22.15 or newer; you have v22.11.0). Numbers below leave them out." It used to be a small note, often the last line and on stderr, and `why`, `receipt` and `drift` did not mention these logs at all. With `--json` the notes stay on stderr and the JSON is unchanged (`stats` keeps its `warnings` array). When every log is read, the output is byte for byte what it was. The four Codex plugin skills say this warning first in their answer.
+- **Codex plugin: `regress` checks the Codex logs by default.** A first `regress` over large Claude Code logs can take a minute or more (49 seconds on 5.4 GB on the maintainer's machine; about 1 second once the usage cache is warm), Codex stopped the command after about 10 seconds with empty output, and a sandbox that cannot write `~/.sipcode` never warms the cache. When the user asks about Codex or names no tool, the regress skill now runs `sipcode regress --bug-report --agent codex` (Codex logs only, about 1 second). It reads the Claude Code logs only when the user asks about Claude Code, says first that the run can take a minute or more, and gives the command a timeout of at least 3 minutes. Empty output is never reported as a result.
- **README:** Node.js 22.15 or newer is needed to read compressed Codex logs.
### Notes
- `scripts/verify-counts.mjs` (the independent counter) reads `.jsonl.zst` too, decoding it with `node:zlib` itself, so it cannot agree with a Sipcode that skips them. On the maintainer's frozen logs it matches Sipcode on all 17 Codex logs (15 of them compressed).
-- Test count: 2,225 to 2,275 (`npx vitest run`, which includes the 20 e2e tests).
+- Test count: 2,225 to 2,409 (`npx vitest run`, which includes the 20 e2e tests).
## [1.7.4] - 2026-10-14
diff --git a/README.md b/README.md
index ea68bd9..6346e31 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
-
+
@@ -166,7 +166,7 @@ Sipcode also reads OpenAI Codex CLI session logs (`~/.codex/sessions` and `~/.co
**Not supported for Codex yet:** writing rules to `AGENTS.md` (`rules`, and the rules step of `init`), registering the MCP server in Codex, the proxy and hooks (`proxy`, `hygiene`), `estimate` and `benchmark`. `rules --agent codex` and `estimate --agent codex` stop with error E009 instead of showing Claude Code data.
-**Compressed Codex logs.** Codex compresses older session logs to `.jsonl.zst`. Sipcode reads them exactly like plain logs on Node.js 22.15 or newer. On an older Node.js it skips them and a note says how many, and that reading them needs Node.js 22.15 or newer.
+**Compressed Codex logs.** Codex compresses older session logs to `.jsonl.zst`. Sipcode reads them exactly like plain logs on Node.js 22.15 or newer. On an older Node.js it skips them, and every report starts with a warning that says how many it could not read and that reading them needs Node.js 22.15 or newer. A damaged or unreadable log gets the same kind of warning. With `--json` the warning is a note on stderr and the JSON is unchanged.
### Codex limits: `sipcode limits`
@@ -489,7 +489,7 @@ Run any of them with `--help` for full options.
## Requirements
-- Node.js 20 or newer. Node.js 22.15 or newer to read the session logs Codex compresses (`.jsonl.zst`); on an older Node.js they are skipped with a note.
+- Node.js 20 or newer. Node.js 22.15 or newer to read the session logs Codex compresses (`.jsonl.zst`); on an older Node.js they are skipped, with a warning at the top of each report.
- Claude Code installed (for the proxy hook). Sipcode also works as standalone CLI tools (benchmark, score, etc.) without Claude Code installed.
- Optional: OpenAI Codex CLI, for Codex spend analytics.
diff --git a/docs/site/public/llms-full.txt b/docs/site/public/llms-full.txt
index 39f7da1..027e2dd 100644
--- a/docs/site/public/llms-full.txt
+++ b/docs/site/public/llms-full.txt
@@ -8,7 +8,7 @@ On a locked, public, synthetic 20-task benchmark corpus (scripted transcript pai
Sipcode also reads OpenAI Codex CLI session logs for spend analytics. `stats`, `today`, `forecast`, `trend`, `impact`, `why`, `receipt` and `drift` accept `--agent codex`; with both tools installed they show one section per tool. Codex models are priced from OpenAI's API price table as of 2026-10-08, and a model with no known price is shown as unpriced, never as $0. The proxy, hooks and rules are not supported for Codex yet. `sipcode limits` reads the usage-limit readings Codex writes into its logs: percent used per limit window, when it runs out at the current pace, which sessions used it, and how many tokens 1% bought over time, with a cautious possible-limit-cut flag.
-Sipcode is solo-maintained, MIT licensed, has 2,275 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop.
+Sipcode is solo-maintained, MIT licensed, has 2,409 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop.
## How Sipcode differs from neighboring tools
@@ -229,7 +229,7 @@ npm uninstall -g sipcode
## Requirements
-- Node.js 20 or newer. Node.js 22.15 or newer to read the session logs Codex compresses (`.jsonl.zst`); on an older Node.js they are skipped with a note.
+- Node.js 20 or newer. Node.js 22.15 or newer to read the session logs Codex compresses (`.jsonl.zst`); on an older Node.js they are skipped, with a warning at the top of each report.
- Claude Code installed (for the proxy hook). Sipcode also works as standalone CLI tools (benchmark, score, etc.) without Claude Code installed.
## License
diff --git a/docs/site/public/llms.txt b/docs/site/public/llms.txt
index 3d22693..7e9adeb 100644
--- a/docs/site/public/llms.txt
+++ b/docs/site/public/llms.txt
@@ -8,7 +8,7 @@ On a locked, public, synthetic 20-task benchmark corpus (scripted transcript pai
Sipcode also reads OpenAI Codex CLI session logs for spend analytics. `stats`, `today`, `forecast`, `trend`, `impact`, `why`, `receipt` and `drift` accept `--agent codex`; with both tools installed they show one section per tool. Codex models are priced from OpenAI's API price table as of 2026-10-08, and a model with no known price is shown as unpriced, never as $0. The proxy, hooks and rules are not supported for Codex yet. `sipcode limits` reads the usage-limit readings Codex writes into its logs: percent used per limit window, when it runs out at the current pace, which sessions used it, and how many tokens 1% bought over time, with a cautious possible-limit-cut flag. `sipcode regress` checks the user's own Claude Code and Codex logs for regressions after an update (prompt-cache misses, 5-minute cache TTL, uncached context, hourly cache rebuilds, Codex limit draining faster per token) and pins each one to the version, comparing each version with the user's other versions. A Sipcode plugin for Codex (`codex plugin marketplace add Anuj7411/sipcode`) lets users ask about spend, limits and regressions in plain words; it runs the installed `sipcode` CLI.
-Sipcode is solo-maintained, MIT licensed, has 2,275 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop.
+Sipcode is solo-maintained, MIT licensed, has 2,409 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop.
## How Sipcode differs from neighboring tools
@@ -57,7 +57,7 @@ Sipcode ships an MCP server exposing 16 tools so Claude Code can introspect its
## Source code
-- [GitHub repository](https://github.com/Anuj7411/sipcode): MIT licensed source, 2,275 tests
+- [GitHub repository](https://github.com/Anuj7411/sipcode): MIT licensed source, 2,409 tests
- [npm package](https://www.npmjs.com/package/sipcode): `sipcode` on npm, current version 1.7.5
- [Privacy test](https://github.com/Anuj7411/sipcode/blob/main/src): build-blocking check that no network modules are imported in `src/`
diff --git a/docs/site/src/components/Footer.astro b/docs/site/src/components/Footer.astro
index 49c28ee..c40ffd8 100644
--- a/docs/site/src/components/Footer.astro
+++ b/docs/site/src/components/Footer.astro
@@ -29,7 +29,7 @@
- 2,275 tests passing
+ 2,409 tests passing
From a38a3937362607f34b2f16a351519f7e9752c926 Mon Sep 17 00:00:00 2001
From: Anuj7411
Date: Thu, 15 Oct 2026 20:31:13 +0530
Subject: [PATCH 9/9] test(golden): replace the machine's drive and root before
comparing
The receipt rows print the path they wrote: C:/ on this machine, D:/ on
the CI Windows runner, / on Linux. The golden output now holds .
Co-Authored-By: Claude Opus 5.5
---
.../__golden__/unread-warning-clean.txt | 18 +++++++++---------
.../unread-warning.integration.test.ts | 9 ++++++++-
2 files changed, 17 insertions(+), 10 deletions(-)
diff --git a/tests/integration/__golden__/unread-warning-clean.txt b/tests/integration/__golden__/unread-warning-clean.txt
index 529f1a9..ff64978 100644
--- a/tests/integration/__golden__/unread-warning-clean.txt
+++ b/tests/integration/__golden__/unread-warning-clean.txt
@@ -5399,8 +5399,8 @@ sipcode · session 0199a0f7-11aa-7b02 · 12h 0m · Oct 08, 2026
prices as of 2026-10-09
-wrote C:/nonexistent-sipcode-unread/.sipcode/receipts/0199a0f7-11aa-7b02/receipt.html
-→ file://C:/nonexistent-sipcode-unread/.sipcode/receipts/0199a0f7-11aa-7b02/receipt.html
+wrote /.sipcode/receipts/0199a0f7-11aa-7b02/receipt.html
+→ file:///.sipcode/receipts/0199a0f7-11aa-7b02/receipt.html
Claude Code also has a recent session (sess0043): run with --agent claude-code.
=== receipt --agent codex
@@ -5414,8 +5414,8 @@ sipcode · session 0199a0f7-11aa-7b02 · 12h 0m · Oct 08, 2026
prices as of 2026-10-09
-wrote C:/nonexistent-sipcode-unread/.sipcode/receipts/0199a0f7-11aa-7b02/receipt.html
-→ file://C:/nonexistent-sipcode-unread/.sipcode/receipts/0199a0f7-11aa-7b02/receipt.html
+wrote /.sipcode/receipts/0199a0f7-11aa-7b02/receipt.html
+→ file:///.sipcode/receipts/0199a0f7-11aa-7b02/receipt.html
=== receipt --agent claude-code
exit 0
sipcode · session sess · 24m 1s · Aug 29, 2026
@@ -5428,14 +5428,14 @@ sipcode · session sess · 24m 1s · Aug 29, 2026
prices as of 2026-06-11
-wrote C:/nonexistent-sipcode-unread/.sipcode/receipts/sess/receipt.html
-→ file://C:/nonexistent-sipcode-unread/.sipcode/receipts/sess/receipt.html
+wrote /.sipcode/receipts/sess/receipt.html
+→ file:///.sipcode/receipts/sess/receipt.html
=== receipt --json
exit 0
{
"schemaVersion": "sipcode-receipt/1",
"variant": "post-install",
- "htmlPath": "C:/nonexistent-sipcode-unread/.sipcode/receipts/sess/receipt.html",
+ "htmlPath": "/.sipcode/receipts/sess/receipt.html",
"pngPath": null,
"hero": {
"tokens": 24640,
@@ -5466,7 +5466,7 @@ exit 0
{
"schemaVersion": "sipcode-receipt/1",
"variant": "pre-install",
- "htmlPath": "C:/nonexistent-sipcode-unread/.sipcode/receipts/0199a0f7-11aa-7b02/receipt.html",
+ "htmlPath": "/.sipcode/receipts/0199a0f7-11aa-7b02/receipt.html",
"pngPath": null,
"hero": {
"tokens": 3100000,
@@ -5491,7 +5491,7 @@ exit 0
{
"schemaVersion": "sipcode-receipt/1",
"variant": "post-install",
- "htmlPath": "C:/nonexistent-sipcode-unread/.sipcode/receipts/sess/receipt.html",
+ "htmlPath": "/.sipcode/receipts/sess/receipt.html",
"pngPath": null,
"hero": {
"tokens": 24640,
diff --git a/tests/integration/unread-warning.integration.test.ts b/tests/integration/unread-warning.integration.test.ts
index d27f614..fc318aa 100644
--- a/tests/integration/unread-warning.integration.test.ts
+++ b/tests/integration/unread-warning.integration.test.ts
@@ -159,7 +159,14 @@ const CASES: Array<{ name: string; run: Run; json: boolean; agent: string | unde
/** stdout as lines (a command may print several lines in one write). */
const lines = (o: Out): string[] => o.out.join("\n").split("\n");
-const show = (o: Out): string => [`exit ${o.exitCode}`, ...o.out, ...o.err.map((l) => `stderr: ${l}`)].join("\n");
+// The fake cwd resolves to "C:/…" or "D:/…" on Windows and "/…" on Linux (receipt prints the
+// path it wrote), so it is replaced with one placeholder before the golden comparison.
+const machineFree = (s: string): string =>
+ s
+ .replace(/file:\/\/\/?(?:[A-Za-z]:)?\/nonexistent-sipcode-unread/g, "file://")
+ .replace(/(?:[A-Za-z]:)?\/nonexistent-sipcode-unread/g, "");
+const show = (o: Out): string =>
+ machineFree([`exit ${o.exitCode}`, ...o.out, ...o.err.map((l) => `stderr: ${l}`)].join("\n"));
describe("nothing skipped: output byte-identical to before", { timeout: 60_000 }, () => {
it("every command, terminal and JSON", async () => {