diff --git a/CHANGELOG.md b/CHANGELOG.md
index e63ab2e..6632e75 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,55 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.9.2] - 2026-07-15
+
+### Fixed
+
+- **Deep Rescan can no longer shrink sealed history.** The windowed rebuild
+ used to overwrite sealed relay days unconditionally; when the raw JSONL
+ behind a day had aged out (or a file read truncated mid-stream), the rebuilt
+ day was smaller and the loss was permanent — this shrank a sealed
+ 2026-06-12 on 2026-07-12, and by 2026-07-15 four more days (2.4B tokens)
+ would have gone the same way. Sealed days are now only replaced when the
+ rebuild carries at least as much data, per provider (`shouldKeepSealedDay`);
+ `/api/rescan?force=true` remains the explicit override for parser-fix
+ rescans where shrinking is the point.
+- Per-file fail-soft read errors in the codex and claude-code parsers (and
+ the shared `readJsonlFile`/`readJsonlFileFromOffset` helpers) now surface
+ as provider warnings tagged `partial` instead of silently truncating a
+ scan. Partial faults deliberately do NOT abort the trailing gap-fill (one
+ permanently unreadable file must not block sealing forever); a forced deep
+ rescan refuses to shrink a sealed day for a provider that hit a truncated
+ read during that rebuild.
+- The sealed-day guard also protects frozen cost: a rebuild that re-derives
+ identical tokens but lower cost (kosha unreadable/offline during the
+ rescan, a model since pruned from the registry) keeps the sealed day.
+- Provider warnings no longer accumulate without bound in the warm daemon:
+ each refresh tick and each deep rescan replaces the previous cycle's
+ provider warnings instead of appending duplicates forever.
+- Summary cache schema bumped to 3 so cached summaries holding the old
+ even-split model rows rebuild instead of being served post-upgrade.
+- The trailing gap-fill no longer seals days BEFORE the gap from the partial
+ slice a fresh-mtime multi-day file happens to carry — interior holes stay
+ open for an explicit rescan instead of freezing a partial day forever.
+- Model summaries now use the exact per-project (provider, model) cross-cut
+ instead of even-splitting a day-level bucket across providers — one model
+ used by codex and codex-desktop in the same day no longer shows two
+ identical phantom rows with fractional half-tokens.
+- The web dashboard's "Today" is now actually today: the last daily entry is
+ only presented as today when its date matches the local calendar date,
+ instead of relabeling the most recent active day.
+- `ensure-summary` no longer bakes an arbitrarily stale cached summary into
+ the static web build: it prefers the live daemon, accepts the cache only if
+ it was scanned today, and rescans otherwise.
+
+### Added
+
+- **Today, hour by hour** panel on the web dashboard: today's cost per local
+ hour as a 24-bar chart with a custom hover tooltip (cost, tokens, records,
+ busiest model per hour), current-hour marker, and an honest whole-series
+ switch to token bars when the day has no priced usage.
+
## [1.9.1] - 2026-07-10
### Added
diff --git a/README.md b/README.md
index ba8cceb..841d37b 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@
Token Usage Tracker for AI Coding Agents
+
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 84c71e6..40e7127 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@sriinnu/tokmeter-cli",
- "version": "1.9.1",
+ "version": "1.9.2",
"private": true,
"description": "Token usage tracking CLI and automation helpers",
"type": "module",
diff --git a/packages/core/package.json b/packages/core/package.json
index a1ada1f..65a174e 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@sriinnu/tokmeter-core",
- "version": "1.9.1",
+ "version": "1.9.2",
"private": true,
"description": "Token usage tracking core — session parsers, aggregation, and pricing",
"type": "module",
diff --git a/packages/core/src/aggregate-consumers.ts b/packages/core/src/aggregate-consumers.ts
index f3a6c04..8ff2bb5 100644
--- a/packages/core/src/aggregate-consumers.ts
+++ b/packages/core/src/aggregate-consumers.ts
@@ -389,11 +389,31 @@ export function computeModelCostsFromState(
return modelTotalsToSummaries(totals.values(), grandTotalCost);
}
- // Unfiltered fast path: day-level model buckets. Multi-provider single-model
- // days even-split (best we can do at this granularity).
+ // Unfiltered path. Exact (provider, model) pairing lives in the per-project
+ // cross-cut buckets — use them whenever the day carries them. Only
+ // pre-enrichment day files (modelBuckets backfilled empty by
+ // forwardMigrateAggregate) fall back to the day-level even-split
+ // approximation, which fabricates identical half-token rows when one model
+ // ran under two providers in a day (codex + codex-desktop sharing
+ // gpt-5.6-sol showed 190,725,623.5 tokens EACH).
for (const day of iterateAllDays(aggregates, todayAccumulator)) {
if (!dayInWindow(day, opts, todayKey)) continue;
grandTotalCost += day.cost;
+ // The cross-cut must be COMPLETE for the day, not merely present: a
+ // partially corrupt/hand-edited file with one bucket-less project would
+ // otherwise silently drop that project's models from the rows. Every
+ // record folds into exactly one (project, model) bucket, so a complete
+ // cross-cut's record count equals the day's.
+ let bucketRecords = 0;
+ for (const project of Object.values(day.projects)) {
+ for (const mb of Object.values(project.modelBuckets)) bucketRecords += mb.recordCount;
+ }
+ if (bucketRecords >= day.recordCount && day.recordCount > 0) {
+ for (const project of Object.values(day.projects)) {
+ for (const mb of Object.values(project.modelBuckets)) foldCrossCut(totals, mb);
+ }
+ continue;
+ }
for (const model of Object.values(day.models)) {
const n = model.providers.length;
for (const provider of model.providers) {
diff --git a/packages/core/src/aggregate-migration-parity.test.ts b/packages/core/src/aggregate-migration-parity.test.ts
index 30dec98..1d041fe 100644
--- a/packages/core/src/aggregate-migration-parity.test.ts
+++ b/packages/core/src/aggregate-migration-parity.test.ts
@@ -492,3 +492,44 @@ describe("Phase 3 parity — provider filter on aggregate getters", () => {
expect(fresh).toEqual(legacy);
});
});
+
+describe("getModelCosts — one model under two providers stays exactly attributed", () => {
+ // codex + codex-desktop both ran gpt-5.6-sol on the same day with very
+ // different volumes. The old day-level even-split fabricated two identical
+ // half-token rows (…623.5 each); the per-project cross-cut is exact.
+ const now = Date.now();
+ const records = [
+ r({
+ timestamp: now,
+ provider: "codex" as TokenRecord["provider"],
+ model: "gpt-5.6-sol",
+ project: "alpha",
+ inputTokens: 1000,
+ outputTokens: 500,
+ cost: 2,
+ }),
+ r({
+ timestamp: now + 1,
+ provider: "codex-desktop" as TokenRecord["provider"],
+ model: "gpt-5.6-sol",
+ project: "alpha",
+ inputTokens: 9000,
+ outputTokens: 100,
+ cost: 0,
+ }),
+ ];
+ const { aggregates, todayAcc } = projectToState(records, now);
+
+ test("matches legacy exact pairing — no even-split, no fractional tokens", () => {
+ const legacy = aggregateByModel(records);
+ const fresh = computeModelCostsFromState(aggregates, todayAcc);
+ expect(fresh).toEqual(legacy);
+ const codexRow = fresh.find((m) => m.provider === "codex");
+ const desktopRow = fresh.find((m) => m.provider === "codex-desktop");
+ expect(codexRow?.inputTokens).toBe(1000);
+ expect(desktopRow?.inputTokens).toBe(9000);
+ for (const row of fresh) {
+ expect(Number.isInteger(row.totalTokens)).toBe(true);
+ }
+ });
+});
diff --git a/packages/core/src/aggregates.ts b/packages/core/src/aggregates.ts
index 73ac389..083667c 100644
Binary files a/packages/core/src/aggregates.ts and b/packages/core/src/aggregates.ts differ
diff --git a/packages/core/src/config-service.ts b/packages/core/src/config-service.ts
index 63d9a84..c829058 100644
--- a/packages/core/src/config-service.ts
+++ b/packages/core/src/config-service.ts
@@ -40,6 +40,8 @@ export type DefaultRange = "all" | "today" | "week" | "month" | "year";
export type DefaultSort = "cost" | "tokens" | "activeDays";
export type MenubarColorSource = "off" | "context" | "block" | "budget";
const MENUBAR_COLOR_SOURCES: readonly MenubarColorSource[] = ["off", "context", "block", "budget"];
+export type WeekChartStyle = "line" | "bars" | "area";
+const WEEK_CHART_STYLES: readonly WeekChartStyle[] = ["line", "bars", "area"];
export interface UserConfig {
version: 1;
@@ -55,6 +57,9 @@ export interface UserConfig {
* - "off": no coloring.
*/
menubarColorSource: MenubarColorSource;
+ /** Rendering style for the popover's LAST 7 DAYS chart (Swift mirrors
+ * this in HubConfigStore.WeekChartStyle — keep raw values identical). */
+ weekChartStyle: WeekChartStyle;
};
daemon: {
/** Advisory: seconds between full rescans inside the daemon. */
@@ -107,7 +112,7 @@ export interface UserConfig {
*/
export const DEFAULT_CONFIG: UserConfig = {
version: 1,
- bar: { refreshSeconds: 30, menubarColorSource: "context" },
+ bar: { refreshSeconds: 30, menubarColorSource: "context", weekChartStyle: "line" },
daemon: { scanIntervalSeconds: 60, antigravityLivePolling: false },
cli: { defaultRange: "all", defaultSort: "cost" },
alerts: { dailyCostThreshold: null },
@@ -188,6 +193,9 @@ function normalizeConfig(raw: Partial): UserConfig {
)
? (raw.bar?.menubarColorSource as MenubarColorSource)
: d.bar.menubarColorSource,
+ weekChartStyle: WEEK_CHART_STYLES.includes(raw.bar?.weekChartStyle as WeekChartStyle)
+ ? (raw.bar?.weekChartStyle as WeekChartStyle)
+ : d.bar.weekChartStyle,
},
daemon: {
scanIntervalSeconds: clampPositiveInt(
diff --git a/packages/core/src/parsers/claude-code.ts b/packages/core/src/parsers/claude-code.ts
index d25cc02..4256418 100644
--- a/packages/core/src/parsers/claude-code.ts
+++ b/packages/core/src/parsers/claude-code.ts
@@ -116,11 +116,16 @@ export class ClaudeCodeParser implements SessionParser {
const cwd = decodeClaudeSlugDir(file);
const isSubagent = file.includes("/subagents/");
- // Append mode: only parse new bytes from where we left off
+ // Append mode: only parse new bytes from where we left off. Read faults
+ // fail soft but surface via onWarning so a rebuild knows it's partial.
+ const readFault = (error: unknown) =>
+ opts?.onWarning?.(
+ `failed read of ${file}: ${error instanceof Error ? error.message : error}`
+ );
const lines =
cacheResult.appendOffset > 0
- ? await readJsonlFileFromOffset(file, cacheResult.appendOffset)
- : await readJsonlFile(file);
+ ? await readJsonlFileFromOffset(file, cacheResult.appendOffset, readFault)
+ : await readJsonlFile(file, readFault);
const newRecords: TokenRecord[] = [];
// Dedup: using both usage values and a stable per-message discriminator so
diff --git a/packages/core/src/parsers/codex.ts b/packages/core/src/parsers/codex.ts
index 28a74a8..0d68cbd 100644
--- a/packages/core/src/parsers/codex.ts
+++ b/packages/core/src/parsers/codex.ts
@@ -449,7 +449,11 @@ function foldCodexEvent(
* read. Either way the caller gets just this file's records — to fold and drop
* — so peak memory is bounded to a single file, not the whole corpus.
*/
-export async function parseCodexFile(file: string, sizeBytes: number): Promise {
+export async function parseCodexFile(
+ file: string,
+ sizeBytes: number,
+ onWarning?: (message: string) => void
+): Promise {
const out: TokenRecord[] = [];
const state = defaultState();
if (sizeBytes >= CODEX_LARGE_FILE_BYTES) {
@@ -469,19 +473,24 @@ export async function parseCodexFile(file: string, sizeBytes: number): Promise(file);
+ const events = await readJsonlFile(file, (error) => {
+ onWarning?.(`failed read of ${file}: ${error instanceof Error ? error.message : error}`);
+ });
for (const evt of events) foldCodexEvent(evt, state, file, out);
}
return out;
@@ -588,12 +597,12 @@ export class CodexParser implements SessionParser {
// Big ones: strictly one at a time.
for (const f of large) {
- await onFile(await parseCodexFile(f.file, f.size));
+ await onFile(await parseCodexFile(f.file, f.size, opts?.onWarning));
}
// Small ones: a bounded concurrent batch. onFile's fold is synchronous, so
// interleaved awaits here can't corrupt the caller's accumulators.
await mapWithConcurrency(small, CODEX_SCAN_CONCURRENCY, async (f) => {
- await onFile(await parseCodexFile(f.file, f.size));
+ await onFile(await parseCodexFile(f.file, f.size, opts?.onWarning));
});
}
}
diff --git a/packages/core/src/parsers/utils.ts b/packages/core/src/parsers/utils.ts
index 67f5be7..0affca0 100644
--- a/packages/core/src/parsers/utils.ts
+++ b/packages/core/src/parsers/utils.ts
@@ -341,8 +341,14 @@ export function clearRecordCache(): void {
} catch {}
}
-/** Read only the tail of a file from a byte offset (for append-only parsing). */
-export async function readJsonlFileFromOffset(path: string, offsetBytes: number): Promise {
+/** Read only the tail of a file from a byte offset (for append-only parsing).
+ * A read failure returns [] (fail-soft) — pass `onError` to distinguish that
+ * from an empty tail when the caller seals history. */
+export async function readJsonlFileFromOffset(
+ path: string,
+ offsetBytes: number,
+ onError?: (error: unknown) => void
+): Promise {
const { open } = await import("node:fs/promises");
let fd: Awaited> | null = null;
try {
@@ -379,7 +385,8 @@ export async function readJsonlFileFromOffset(path: string, offsetBytes: numb
} catch {}
}
return results;
- } catch {
+ } catch (error) {
+ onError?.(error);
return [];
} finally {
// Guarantee the fd is released even if read() threw mid-buffer. Without
@@ -576,8 +583,13 @@ export async function readJsonFile(path: string): Promise {
}
}
-/** Read JSONL file and parse each line. */
-export async function readJsonlFile(path: string): Promise {
+/** Read JSONL file and parse each line. Malformed lines are skipped; a read
+ * failure returns [] (fail-soft) — pass `onError` to distinguish that from a
+ * genuinely empty file when the caller seals history. */
+export async function readJsonlFile(
+ path: string,
+ onError?: (error: unknown) => void
+): Promise {
try {
const raw = await readFile(path, "utf-8");
const lines = raw.split("\n").filter((l: string) => l.trim());
@@ -590,7 +602,8 @@ export async function readJsonlFile(path: string): Promise {
}
}
return results;
- } catch {
+ } catch (error) {
+ onError?.(error);
return [];
}
}
diff --git a/packages/core/src/relay-loader.test.ts b/packages/core/src/relay-loader.test.ts
index b35395d..57de751 100644
--- a/packages/core/src/relay-loader.test.ts
+++ b/packages/core/src/relay-loader.test.ts
@@ -129,3 +129,170 @@ describe("refreshFromRelay — bounded trailing-gap fill (no full rescan on a no
expect(state.aggregates.has(yKey)).toBe(true);
});
});
+
+// ─── Deep Rescan sealed-day guard (the 2026-07-12 history-shrink regression) ──
+//
+// rebuildRecentWindow re-derives the window from raw and used to overwrite
+// sealed days unconditionally — a partial raw corpus (cleaned-up JSONL, a
+// truncated read) permanently shrank sealed history. The guard: a sealed day
+// is only replaced when the rebuild carries at least as much data, per
+// provider. These tests drive the REAL codex parser against a fixture corpus
+// in a temp home (codex keeps no record cache, so nothing leaks outside it).
+
+import { mkdirSync, writeFileSync } from "node:fs";
+import { type DailyAggregate, shouldKeepSealedDay } from "./aggregates.js";
+import { rebuildRecentWindow } from "./relay-loader.js";
+import type { ScanWarning } from "./types.js";
+
+// Local-time ISO (no Z) so day keys are TZ-stable under both bun and vitest.
+const codexEvents = (day: string, tokens: number[]): string =>
+ [
+ JSON.stringify({
+ timestamp: `${day}T12:00:00`,
+ type: "session_meta",
+ payload: { cwd: "/tmp/demo" },
+ }),
+ JSON.stringify({
+ timestamp: `${day}T12:00:01`,
+ type: "turn_context",
+ payload: { model: "gpt-5" },
+ }),
+ ...tokens.map((n, i) =>
+ JSON.stringify({
+ timestamp: `${day}T12:00:0${2 + i}`,
+ type: "event_msg",
+ payload: {
+ type: "token_count",
+ info: { last_token_usage: { input_tokens: n, output_tokens: 0 } },
+ },
+ })
+ ),
+ ].join("\n");
+
+function writeCodexFixture(homeDir: string, day: string, tokens: number[]): void {
+ const [y, m, d] = day.split("-");
+ const dir = join(homeDir, ".codex/sessions", y, m, d);
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(join(dir, `rollout-${day}-fixture.jsonl`), codexEvents(day, tokens));
+}
+
+const codexRecord = (ts: number, inputTokens: number): TokenRecord =>
+ ({
+ timestamp: ts,
+ provider: "codex",
+ model: "gpt-5",
+ project: "demo",
+ inputTokens,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ reasoningTokens: 0,
+ cost: 0,
+ }) as TokenRecord;
+
+describe("rebuildRecentWindow — sealed days never shrink without force", () => {
+ let home: string;
+ const DAY_KEY = "2026-06-13";
+ const dayTs = Date.parse(`${DAY_KEY}T12:00:00`); // local noon
+ beforeEach(() => {
+ home = mkdtempSync(join(tmpdir(), "tokmeter-rescan-"));
+ });
+ afterEach(() => {
+ rmSync(home, { recursive: true, force: true });
+ });
+
+ function sealDay(tokenCounts: number[]): void {
+ const [day] = aggregateRecordsByDay(tokenCounts.map((n, i) => codexRecord(dayTs + i, n)));
+ writeDayFile(home, day);
+ }
+
+ test("partial raw (fewer tokens than sealed) → sealed day kept, warning pushed", async () => {
+ sealDay([100, 200]); // sealed: 300 tokens
+ writeCodexFixture(home, DAY_KEY, [100]); // raw now holds only 100
+ const warnings: ScanWarning[] = [];
+ const state = await rebuildRecentWindow(ctxFor(home), REF, warnings, 7);
+ expect(state.aggregates.get(DAY_KEY)?.totalTokens).toBe(300);
+ expect(warnings.some((w) => w.scope === "history" && w.message.includes(DAY_KEY))).toBe(true);
+ });
+
+ test("force=true replaces the sealed day even when the rebuild is smaller", async () => {
+ sealDay([100, 200]);
+ writeCodexFixture(home, DAY_KEY, [100]);
+ const warnings: ScanWarning[] = [];
+ const state = await rebuildRecentWindow(ctxFor(home), REF, warnings, 7, true);
+ expect(state.aggregates.get(DAY_KEY)?.totalTokens).toBe(100);
+ });
+
+ test("richer raw (more tokens than sealed) → replaced without force", async () => {
+ sealDay([100]); // sealed: 100 tokens
+ writeCodexFixture(home, DAY_KEY, [100, 200]); // raw grew (e.g. parser fix found more)
+ const warnings: ScanWarning[] = [];
+ const state = await rebuildRecentWindow(ctxFor(home), REF, warnings, 7);
+ expect(state.aggregates.get(DAY_KEY)?.totalTokens).toBe(300);
+ });
+
+ test("raw fully deleted → sealed day untouched (relay's core promise)", async () => {
+ sealDay([100, 200]);
+ const warnings: ScanWarning[] = [];
+ const state = await rebuildRecentWindow(ctxFor(home), REF, warnings, 7);
+ expect(state.aggregates.get(DAY_KEY)?.totalTokens).toBe(300);
+ });
+});
+
+describe("shouldKeepSealedDay — per-provider no-shrink contract", () => {
+ const dayFrom = (records: TokenRecord[]): DailyAggregate => aggregateRecordsByDay(records)[0];
+ const ts = Date.parse("2026-06-13T12:00:00");
+
+ test("day grows overall but one provider bucket shrinks → keep sealed", () => {
+ const sealed = dayFrom([codexRecord(ts, 100), r(ts + 1, 0)]); // codex 100 + claude 120
+ const rebuilt = dayFrom([codexRecord(ts, 50), r(ts + 1, 0), r(ts + 2, 0)]); // codex 50, claude 240
+ expect(rebuilt.totalTokens).toBeGreaterThan(sealed.totalTokens);
+ expect(shouldKeepSealedDay(sealed, rebuilt, { force: false })).toBe(true);
+ });
+
+ test("identical rebuild → replace (costByHour backfill must work)", () => {
+ const sealed = dayFrom([codexRecord(ts, 100)]);
+ const rebuilt = dayFrom([codexRecord(ts, 100)]);
+ expect(shouldKeepSealedDay(sealed, rebuilt, { force: false })).toBe(false);
+ });
+
+ test("same tokens but lower cost (kosha offline during rescan) → keep sealed", () => {
+ const sealed = dayFrom([{ ...codexRecord(ts, 100), cost: 2.5 } as TokenRecord]);
+ const rebuilt = dayFrom([codexRecord(ts, 100)]); // cost 0 — pricing unavailable
+ expect(rebuilt.totalTokens).toBe(sealed.totalTokens);
+ expect(shouldKeepSealedDay(sealed, rebuilt, { force: false })).toBe(true);
+ expect(shouldKeepSealedDay(sealed, rebuilt, { force: true })).toBe(false);
+ });
+
+ test("provider missing entirely from rebuild → keep sealed", () => {
+ const sealed = dayFrom([codexRecord(ts, 100), r(ts + 1, 0)]);
+ const rebuilt = dayFrom([r(ts + 1, 0)]); // codex vanished
+ expect(shouldKeepSealedDay(sealed, rebuilt, { force: false })).toBe(true);
+ });
+});
+
+describe("refreshFromRelay — gap fill never seals days before the gap", () => {
+ let home: string;
+ beforeEach(() => {
+ home = mkdtempSync(join(tmpdir(), "tokmeter-gapfix-"));
+ });
+ afterEach(() => {
+ rmSync(home, { recursive: true, force: true });
+ });
+
+ test("pre-gap records admitted by the mtime watermark are not sealed as partial days", async () => {
+ // Relay is current through 2026-06-10; the gap starts 06-11. A fresh-mtime
+ // multi-day file also carries 06-09 records (a still-active old session).
+ // 06-09 is an interior hole — sealing it from this one file's slice would
+ // freeze a partial day forever.
+ const seeded = seedDay(home, Date.parse("2026-06-10T12:00:00"), 1.0);
+ expect(seeded).toBe("2026-06-10");
+ writeCodexFixture(home, "2026-06-09", [500]);
+ writeCodexFixture(home, "2026-06-14", [700]);
+ const warnings: ScanWarning[] = [];
+ const state = await refreshFromRelay(ctxFor(home), REF, warnings, false);
+ expect(state.aggregates.has("2026-06-09")).toBe(false); // interior hole stays open
+ expect(state.aggregates.get("2026-06-14")?.totalTokens).toBe(700); // gap day sealed
+ expect(state.aggregates.get("2026-06-10")?.cost).toBeCloseTo(1.0, 10); // untouched
+ });
+});
diff --git a/packages/core/src/relay-loader.ts b/packages/core/src/relay-loader.ts
index facbbf7..7d08815 100644
--- a/packages/core/src/relay-loader.ts
+++ b/packages/core/src/relay-loader.ts
@@ -16,7 +16,7 @@ import {
migrateMonolithSnapshotIfNeeded,
writeDayFile,
} from "./aggregates-store.js";
-import { type DailyAggregate, aggregateRecordsByDay } from "./aggregates.js";
+import { type DailyAggregate, aggregateRecordsByDay, shouldKeepSealedDay } from "./aggregates.js";
import { isBeforeToday, localDateKey, startOfLocalDay, yesterdayDateKey } from "./date-utils.js";
import { getParsers } from "./parsers/index.js";
import { enrichCosts, toErrorMessage } from "./pricing-enrichment.js";
@@ -73,15 +73,24 @@ export async function refreshFromRelay(
const raw = await scanRawRecords(ctx, undefined, "history", warnings, floorMs);
const todayKey = localDateKey(referenceTimestamp);
- // A partial gap scan (provider crash mid-fill) writes nothing — a healthy
- // re-run will fill it correctly. Better to retry than freeze a degraded day.
- const gapDegraded = warnings.slice(warnBefore).some((w) => w.scope === "provider");
+ // A crashed provider mid-fill writes nothing — a healthy re-run will fill
+ // it correctly. Better to retry than freeze a degraded day. Per-file
+ // `partial` faults deliberately do NOT abort: a permanently unreadable file
+ // would otherwise block the gap fill on every cold start until the raw
+ // JSONL behind the gap ages out — losing whole days to protect one file's
+ // tail.
+ const gapDegraded = warnings.slice(warnBefore).some((w) => w.scope === "provider" && !w.partial);
if (gapDegraded) return { aggregates, historySource: "extended" };
const onDiskSet = new Set(onDisk);
for (const day of aggregateRecordsByDay(raw)) {
// Never seal today; never overwrite an already-sealed (immutable) day.
- if (day.date >= todayKey || onDiskSet.has(day.date)) continue;
+ // Never seal a day BEFORE the gap either: the mtime watermark admits
+ // multi-day session files whose early records predate gapStart, but those
+ // records are only the slice living in still-fresh files — sealing an
+ // interior-hole day from them freezes a partial day permanently. Interior
+ // holes are recovered by an explicit rescan, not the gap fill.
+ if (day.date >= todayKey || day.date < gapStart || onDiskSet.has(day.date)) continue;
try {
writeDayFile(ctx.homeDir, day);
aggregates.set(day.date, day);
@@ -121,7 +130,6 @@ async function foldRawIntoDays(
floorMs?: number
): Promise