@oh-my-pi/omp-stats 18.1.22 → 18.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/aggregator.ts CHANGED
@@ -3,6 +3,8 @@ import * as path from "node:path";
3
3
  import { getStatsDbPath, workerHostEntry } from "@oh-my-pi/pi-utils";
4
4
  import { withFileLock } from "@oh-my-pi/pi-utils/file-lock";
5
5
  import {
6
+ applySessionParseResult,
7
+ completeSessionSync,
6
8
  getRecentErrors as dbGetRecentErrors,
7
9
  getRecentRequests as dbGetRecentRequests,
8
10
  getBehaviorByModel,
@@ -26,15 +28,17 @@ import {
26
28
  getToolStatsByModel,
27
29
  getToolTimeSeries,
28
30
  initDb,
29
- insertMessageStats,
30
- insertToolCalls,
31
- insertUserMessageStats,
32
31
  markSessionBackfillsComplete,
33
- setFileOffset,
34
- updateToolResults,
35
- updateUserMessageLinks,
32
+ prepareSessionSync,
36
33
  } from "./db";
37
- import { getSessionEntry, listAllSessionFiles, type ParseSessionResult, parseSessionFile } from "./parser";
34
+ import {
35
+ getSessionEntry,
36
+ listAllSessionFiles,
37
+ matchesSessionFile,
38
+ type ParseSessionResult,
39
+ parseSessionFile,
40
+ type SessionParserState,
41
+ } from "./parser";
38
42
  import type { SyncWorkerRequest, SyncWorkerResponse } from "./sync-worker";
39
43
  // Coding-agent binary/bundle workers route through the CLI entrypoint with a
40
44
  // hidden argv mode, so the compiled binary and npm bundle only need one
@@ -69,20 +73,6 @@ export async function withStatsSyncLock<T>(dbPath: string, fn: () => Promise<T>)
69
73
  });
70
74
  }
71
75
 
72
- /**
73
- * Apply a freshly parsed result to the database. Runs entirely on the
74
- * main thread so the single SQLite handle owns every write.
75
- */
76
- function applyParseResult(sessionFile: string, lastModified: number, result: ParseSessionResult): number {
77
- if (result.stats.length > 0) insertMessageStats(result.stats);
78
- if (result.userStats.length > 0) insertUserMessageStats(result.userStats);
79
- if (result.userLinks.length > 0) updateUserMessageLinks(result.userLinks);
80
- if (result.toolCalls.length > 0) insertToolCalls(result.toolCalls);
81
- if (result.toolResults.length > 0) updateToolResults(result.toolResults);
82
- setFileOffset(sessionFile, result.newOffset, lastModified);
83
- return result.stats.length + result.userStats.length;
84
- }
85
-
86
76
  /**
87
77
  * Progress event emitted after each session file is fully processed.
88
78
  * `current` is the number of files completed (skipped + parsed),
@@ -239,20 +229,34 @@ export async function smokeTestSyncWorker({ timeoutMs = 5_000 }: { timeoutMs?: n
239
229
  * bar walks at a steady rate).
240
230
  */
241
231
  export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed: number; files: number }> {
242
- return withStatsSyncLock(getStatsDbPath(), () => syncAllSessionsLocked(opts));
232
+ return withStatsSyncLock(getStatsDbPath(), async () => {
233
+ let processed = 0;
234
+ let files = 0;
235
+ while (true) {
236
+ const result = await syncAllSessionsLocked(opts);
237
+ processed += result.processed;
238
+ files += result.files;
239
+ if (!result.reconcile) return { processed, files };
240
+ }
241
+ });
243
242
  }
244
243
 
245
- async function syncAllSessionsLocked(opts?: SyncOptions): Promise<{ processed: number; files: number }> {
244
+ async function syncAllSessionsLocked(
245
+ opts?: SyncOptions,
246
+ ): Promise<{ processed: number; files: number; reconcile: boolean }> {
246
247
  await initDb();
248
+ const replay = prepareSessionSync();
247
249
 
248
250
  const files = await listAllSessionFiles();
249
251
  let totalProcessed = 0;
250
252
  let filesProcessed = 0;
251
253
  let completed = 0;
252
254
  let cursor = 0;
255
+ let reconcile = false;
253
256
  const finish = () => {
257
+ completeSessionSync(reconcile);
254
258
  markSessionBackfillsComplete();
255
- return { processed: totalProcessed, files: filesProcessed };
259
+ return { processed: totalProcessed, files: filesProcessed, reconcile };
256
260
  };
257
261
  if (files.length === 0) return finish();
258
262
 
@@ -268,7 +272,12 @@ async function syncAllSessionsLocked(opts?: SyncOptions): Promise<{ processed: n
268
272
 
269
273
  const processFile = async (
270
274
  sessionFile: string,
271
- parse: (sessionFile: string, fromOffset: number) => Promise<ParseSessionResult>,
275
+ parse: (
276
+ sessionFile: string,
277
+ fromOffset: number,
278
+ state?: SessionParserState,
279
+ replay?: boolean,
280
+ ) => Promise<ParseSessionResult>,
272
281
  ): Promise<void> => {
273
282
  let fileStats: fs.Stats;
274
283
  try {
@@ -279,14 +288,24 @@ async function syncAllSessionsLocked(opts?: SyncOptions): Promise<{ processed: n
279
288
  }
280
289
  const lastModified = fileStats.mtimeMs;
281
290
  const stored = getFileOffset(sessionFile);
282
- if (stored && stored.lastModified >= lastModified) {
291
+ if (
292
+ !replay &&
293
+ stored?.parserState &&
294
+ stored.lastModified === lastModified &&
295
+ stored.parserState.size === fileStats.size &&
296
+ matchesSessionFile(stored.parserState, fileStats)
297
+ ) {
283
298
  report(sessionFile);
284
299
  return;
285
300
  }
286
301
 
287
- const fromOffset = stored?.offset ?? 0;
288
- const result = await parse(sessionFile, fromOffset);
289
- const inserted = applyParseResult(sessionFile, lastModified, result);
302
+ const unknownIdentity = stored !== null && !stored.parserState;
303
+ const fromOffset = unknownIdentity ? 0 : (stored?.offset ?? 0);
304
+ const result = await parse(sessionFile, fromOffset, stored?.parserState, replay);
305
+ if (unknownIdentity && result.parserState) result.reset = true;
306
+ const applied = applySessionParseResult(sessionFile, result, replay || !stored?.parserState);
307
+ const inserted = applied.processed;
308
+ if (applied.reconcile) reconcile = true;
290
309
  if (inserted > 0) {
291
310
  totalProcessed += inserted;
292
311
  filesProcessed++;
@@ -312,7 +331,9 @@ async function syncAllSessionsLocked(opts?: SyncOptions): Promise<{ processed: n
312
331
  const idx = cursor++;
313
332
  if (idx >= files.length) return;
314
333
  const sessionFile = files[idx];
315
- await processFile(sessionFile, (file, fromOffset) => dispatch(handle, { sessionFile: file, fromOffset }));
334
+ await processFile(sessionFile, (file, fromOffset, parserState, replay) =>
335
+ dispatch(handle, { sessionFile: file, fromOffset, parserState, replay }),
336
+ );
316
337
  }
317
338
  }
318
339
 
@@ -1509,10 +1509,35 @@
1509
1509
  border: 1px solid var(--border);
1510
1510
  border-radius: var(--radius-md);
1511
1511
  color: var(--text);
1512
+ font-family: inherit;
1512
1513
  font-size: 12px;
1513
1514
  padding: 6px 10px;
1514
1515
  }
1515
1516
 
1517
+ /* Prevent iOS from zooming the viewport when a search input receives focus
1518
+ (mirrors the .stats-select coarse-pointer treatment). */
1519
+ @media (pointer: coarse) {
1520
+ .stats-trace-input {
1521
+ font-size: 16px;
1522
+ }
1523
+ }
1524
+
1525
+ /* Native clear button is a dark glyph in WebKit; keep it visible in the dark
1526
+ theme by inverting it (no-op in light). */
1527
+ .stats-trace-input::-webkit-search-cancel-button {
1528
+ filter: invert(1);
1529
+ }
1530
+
1531
+ @media (prefers-color-scheme: light) {
1532
+ :root:not([data-theme]) .stats-trace-input::-webkit-search-cancel-button {
1533
+ filter: none;
1534
+ }
1535
+ }
1536
+
1537
+ [data-theme="light"] .stats-trace-input::-webkit-search-cancel-button {
1538
+ filter: none;
1539
+ }
1540
+
1516
1541
  .stats-trace-input:focus-visible {
1517
1542
  outline: 2px solid var(--link);
1518
1543
  outline-offset: 1px;
@@ -1532,6 +1557,11 @@
1532
1557
  user-select: none;
1533
1558
  }
1534
1559
 
1560
+ /* Brand-tint the native checkbox instead of leaving the browser default. */
1561
+ .stats-trace-check input[type="checkbox"] {
1562
+ accent-color: var(--accent);
1563
+ }
1564
+
1535
1565
  .stats-trace-icon-btn {
1536
1566
  display: inline-flex;
1537
1567
  align-items: center;
package/src/db.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  } from "@oh-my-pi/pi-catalog/models";
10
10
  import type { ModelCost } from "@oh-my-pi/pi-catalog/types";
11
11
  import { getConfigRootDir, getStatsDbPath } from "@oh-my-pi/pi-utils";
12
- import { classifyAgentType } from "./parser";
12
+ import { classifyAgentType, type ParseSessionResult, type SessionParserState } from "./parser";
13
13
  import type {
14
14
  AgentType,
15
15
  AgentTypeStats,
@@ -144,7 +144,10 @@ const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
144
144
  const PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY = "premium_requests_priority_v1";
145
145
  const AGENT_TYPE_BACKFILL_KEY = "agent_type_v1";
146
146
  const FORK_DEDUPE_KEY = "fork_dedupe_v1";
147
- const TOOL_CALLS_BACKFILL_KEY = "tool_calls_v1";
147
+ // v2: tool-name sanitization at ingest (see `sanitizeToolName` in parser.ts)
148
+ // collapses provider-side garbage names; a full re-parse replaces the polluted
149
+ // rows already stored in existing databases.
150
+ const TOOL_CALLS_BACKFILL_KEY = "tool_calls_v2";
148
151
  // Older ingests dropped `Usage.orchestration` (never a stored column) when
149
152
  // pricing, so subscription models billed on orchestration tokens — multi-agent
150
153
  // Grok most notably — were priced from conversation buckets alone and could not
@@ -222,7 +225,8 @@ export async function initDb(): Promise<Database> {
222
225
  CREATE TABLE IF NOT EXISTS file_offsets (
223
226
  session_file TEXT PRIMARY KEY,
224
227
  offset INTEGER NOT NULL,
225
- last_modified INTEGER NOT NULL
228
+ last_modified INTEGER NOT NULL,
229
+ parser_state TEXT
226
230
  );
227
231
 
228
232
  CREATE TABLE IF NOT EXISTS user_messages (
@@ -274,6 +278,10 @@ export async function initDb(): Promise<Database> {
274
278
  );
275
279
  `);
276
280
 
281
+ const offsetColumns = db.prepare("PRAGMA table_info(file_offsets)").all() as { name: string }[];
282
+ if (!offsetColumns.some(column => column.name === "parser_state")) {
283
+ db.run("ALTER TABLE file_offsets ADD COLUMN parser_state TEXT");
284
+ }
277
285
  const messageColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
278
286
  if (!messageColumns.some(column => column.name === "premium_requests")) {
279
287
  db.run("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
@@ -590,26 +598,104 @@ function backfillNoCacheInputCosts(database: Database): void {
590
598
  /**
591
599
  * Get the stored offset for a session file.
592
600
  */
593
- export function getFileOffset(sessionFile: string): { offset: number; lastModified: number } | null {
601
+ export function getFileOffset(
602
+ sessionFile: string,
603
+ ): { offset: number; lastModified: number; parserState?: SessionParserState } | null {
594
604
  if (!db) return null;
595
605
 
596
- const stmt = db.prepare("SELECT offset, last_modified FROM file_offsets WHERE session_file = ?");
597
- const row = stmt.get(sessionFile) as { offset: number; last_modified: number } | undefined;
598
-
599
- return row ? { offset: row.offset, lastModified: row.last_modified } : null;
606
+ const stmt = db.prepare("SELECT offset, last_modified, parser_state FROM file_offsets WHERE session_file = ?");
607
+ const row = stmt.get(sessionFile) as
608
+ | { offset: number; last_modified: number; parser_state: string | null }
609
+ | undefined;
610
+ if (!row) return null;
611
+ let parserState: SessionParserState | undefined;
612
+ if (row.parser_state) {
613
+ try {
614
+ const state = JSON.parse(row.parser_state) as SessionParserState;
615
+ if (state?.version === 1 && state.offset === row.offset) parserState = state;
616
+ } catch {
617
+ /* A missing cursor is reconstructed from the transcript. */
618
+ }
619
+ }
620
+ return { offset: row.offset, lastModified: row.last_modified, parserState };
600
621
  }
601
622
 
602
623
  /**
603
624
  * Update the stored offset for a session file.
604
625
  */
605
- export function setFileOffset(sessionFile: string, offset: number, lastModified: number): void {
626
+ export function setFileOffset(
627
+ sessionFile: string,
628
+ offset: number,
629
+ lastModified: number,
630
+ parserState?: SessionParserState,
631
+ ): void {
606
632
  if (!db) return;
607
633
 
608
634
  const stmt = db.prepare(`
609
- INSERT OR REPLACE INTO file_offsets (session_file, offset, last_modified)
610
- VALUES (?, ?, ?)
635
+ INSERT OR REPLACE INTO file_offsets (session_file, offset, last_modified, parser_state)
636
+ VALUES (?, ?, ?, ?)
611
637
  `);
612
- stmt.run(sessionFile, offset, lastModified);
638
+ stmt.run(sessionFile, offset, lastModified, parserState ? JSON.stringify(parserState) : null);
639
+ }
640
+
641
+ export function applySessionParseResult(
642
+ sessionFile: string,
643
+ result: ParseSessionResult,
644
+ rebuild = false,
645
+ ): { processed: number; reconcile: boolean } {
646
+ const parserState = result.parserState;
647
+ if (!db || !parserState) return { processed: 0, reconcile: false };
648
+ const database = db;
649
+ return database.transaction(() => {
650
+ let reconcile = result.reset ?? false;
651
+ if (result.reset || rebuild) {
652
+ const retainedMessages = new Set(result.stats.map(row => JSON.stringify([row.entryId, row.timestamp])));
653
+ const retainedUsers = new Set(result.userStats.map(row => JSON.stringify([row.entryId, row.timestamp])));
654
+ const retainedTools = new Set(
655
+ result.toolCalls.map(row => JSON.stringify([row.entryId, row.timestamp, row.toolCallId])),
656
+ );
657
+ const messages = database
658
+ .prepare("SELECT entry_id, timestamp FROM messages WHERE session_file = ?")
659
+ .all(sessionFile) as {
660
+ entry_id: string;
661
+ timestamp: number;
662
+ }[];
663
+ const users = database
664
+ .prepare("SELECT entry_id, timestamp FROM user_messages WHERE session_file = ?")
665
+ .all(sessionFile) as { entry_id: string; timestamp: number }[];
666
+ const tools = database
667
+ .prepare("SELECT entry_id, timestamp, tool_call_id FROM tool_calls WHERE session_file = ?")
668
+ .all(sessionFile) as { entry_id: string; timestamp: number; tool_call_id: string }[];
669
+ // A removed owner may have surviving fork copies skipped earlier in this pass.
670
+ reconcile ||=
671
+ messages.some(row => !retainedMessages.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
672
+ users.some(row => !retainedUsers.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
673
+ tools.some(row => !retainedTools.has(JSON.stringify([row.entry_id, row.timestamp, row.tool_call_id])));
674
+ database.prepare("DELETE FROM messages WHERE session_file = ?").run(sessionFile);
675
+ database.prepare("DELETE FROM user_messages WHERE session_file = ?").run(sessionFile);
676
+ database.prepare("DELETE FROM tool_calls WHERE session_file = ?").run(sessionFile);
677
+ }
678
+ if (reconcile) {
679
+ database
680
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('session_reconciliation', 'pending')")
681
+ .run();
682
+ }
683
+ if (result.stats.length > 0) insertMessageStats(result.stats);
684
+ if (result.userStats.length > 0) insertUserMessageStats(result.userStats);
685
+ if (result.userLinks.length > 0) updateUserMessageLinks(result.userLinks);
686
+ if (result.toolCalls.length > 0) insertToolCalls(result.toolCalls);
687
+ if (result.toolResults.length > 0) updateToolResults(result.toolResults);
688
+ setFileOffset(sessionFile, result.newOffset, parserState.mtimeMs, parserState);
689
+ return { processed: result.stats.length + result.userStats.length, reconcile };
690
+ })();
691
+ }
692
+
693
+ export function prepareSessionSync(): boolean {
694
+ return Boolean(db?.prepare("SELECT 1 FROM meta WHERE key = 'session_reconciliation'").get());
695
+ }
696
+
697
+ export function completeSessionSync(reconcile: boolean): void {
698
+ if (!reconcile) db?.prepare("DELETE FROM meta WHERE key = 'session_reconciliation'").run();
613
699
  }
614
700
 
615
701
  /**
package/src/parser.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type * as nodeFs from "node:fs";
1
2
  import * as fs from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
  import {
@@ -29,6 +30,9 @@ import { computeUserMessageMetrics } from "./user-metrics";
29
30
  /** Basename of an advisor agent's transcript inside a session artifacts dir. */
30
31
  const ADVISOR_TRANSCRIPT_BASENAME = "__advisor.jsonl";
31
32
 
33
+ /** Characters a persisted tool name may consist of without sanitization. */
34
+ const TOOL_NAME_PATTERN = /^[\w.:-]+$/;
35
+
32
36
  /**
33
37
  * Classify which agent produced a transcript from its path within the sessions
34
38
  * directory. Layout: `<sessionsDir>/<project>/<file>.jsonl` is the `main`
@@ -354,27 +358,51 @@ function extractToolCalls(
354
358
  );
355
359
  if (blocks.length === 0) return [];
356
360
 
357
- return blocks.map(block => {
361
+ const calls: ToolCallStats[] = [];
362
+ for (const block of blocks) {
363
+ // Names reduced to nothing by sanitization carry no tool identity:
364
+ // skip them rather than attributing usage to garbage (see
365
+ // sanitizeToolName). callsInTurn still counts the raw block total.
366
+ const toolName = sanitizeToolName(block.name);
367
+ if (toolName === null) continue;
358
368
  let argsChars = 0;
359
369
  try {
360
370
  argsChars = JSON.stringify(block.arguments ?? {}).length;
361
371
  } catch {
362
372
  // Non-serializable arguments (shouldn't happen in persisted JSONL); size unknown.
363
373
  }
364
- return {
374
+ calls.push({
365
375
  sessionFile,
366
376
  entryId: entry.id,
367
377
  toolCallId: block.id,
368
378
  folder,
369
- toolName: block.name,
379
+ toolName,
370
380
  model: msg.model,
371
381
  provider: msg.provider,
372
382
  timestamp: coerceEntryTimestamp(msg.timestamp, entry),
373
383
  agentType,
374
384
  callsInTurn: blocks.length,
375
385
  argsChars,
376
- };
377
- });
386
+ });
387
+ }
388
+ return calls;
389
+ }
390
+
391
+ /**
392
+ * Tool names as persisted can be polluted by provider-side parse garbage — a
393
+ * gateway may hand the model's whole invocation text back as the function
394
+ * name (e.g. `bash command="ls -la …"` with a stray in-band closer), which
395
+ * then shows up verbatim in every `GROUP BY tool_name` aggregate and the
396
+ * dashboard tool filter. Reduce such names to their leading identifier token;
397
+ * names that yield no identifier at all carry no tool identity and are
398
+ * returned as `null` so the row is skipped.
399
+ */
400
+ function sanitizeToolName(name: string): string | null {
401
+ const trimmed = name.trim();
402
+ if (trimmed.length === 0) return null;
403
+ if (TOOL_NAME_PATTERN.test(trimmed)) return trimmed;
404
+ const candidate = trimmed.split(/[^\w.:-]/)[0] ?? "";
405
+ return candidate.length > 0 ? candidate : null;
378
406
  }
379
407
 
380
408
  /**
@@ -454,22 +482,18 @@ function scanLastServiceTier(bytes: Uint8Array): ServiceTierByFamily | undefined
454
482
  });
455
483
  return currentServiceTier;
456
484
  }
457
- /**
458
- * Parse a session file and extract all assistant message stats.
459
- * Uses incremental reading with offset tracking.
460
- *
461
- * Service-tier carry-over: `currentServiceTier` is a session-scoped piece of
462
- * state derived from `service_tier_change` entries that affects whether
463
- * subsequent OpenAI assistant replies count as premium requests. Incremental
464
- * syncs that resume past the most-recent tier change would otherwise lose
465
- * that state and silently record `premiumRequests = 0` for priority traffic
466
- * (the coding-agent stopped folding the tier into `usage.premiumRequests`
467
- * after 13f59162e — the parser is now the sole source of truth). When
468
- * `fromOffset > 0` we therefore scan the bytes preceding `fromOffset`
469
- * for the latest service-tier value before parsing the unprocessed tail.
470
- * The scan only keeps the current tier and does not materialize prefix
471
- * entries, preserving offset-based memory behavior for large sessions.
472
- */
485
+ export interface SessionParserState {
486
+ version: 1;
487
+ offset: number;
488
+ dev: number;
489
+ ino: number;
490
+ birthtimeMs: number;
491
+ size: number;
492
+ mtimeMs: number;
493
+ checkpoint: string;
494
+ serviceTier: ServiceTierByFamily | null;
495
+ }
496
+
473
497
  export interface ParseSessionResult {
474
498
  stats: MessageStatsInput[];
475
499
  userStats: UserMessageStats[];
@@ -477,11 +501,80 @@ export interface ParseSessionResult {
477
501
  toolCalls: ToolCallStats[];
478
502
  toolResults: ToolResultLink[];
479
503
  newOffset: number;
504
+ parserState?: SessionParserState;
505
+ reset?: boolean;
506
+ }
507
+
508
+ const CHECKPOINT_BYTES = 256;
509
+
510
+ async function readCheckpoint(handle: fs.FileHandle, end: number): Promise<Uint8Array> {
511
+ // Positional reads keep the descriptor at zero for Bun.file(fd)'s subsequent tail read.
512
+ const start = Math.max(0, end - CHECKPOINT_BYTES);
513
+ const bytes = new Uint8Array(end - start);
514
+ let read = 0;
515
+ while (read < bytes.length) {
516
+ const result = await handle.read(bytes, read, bytes.length - read, start + read);
517
+ if (result.bytesRead === 0) break;
518
+ read += result.bytesRead;
519
+ }
520
+ return bytes.subarray(0, read);
521
+ }
522
+
523
+ export function matchesSessionFile(state: SessionParserState, info: nodeFs.Stats): boolean {
524
+ return state.dev === info.dev && state.ino === info.ino && state.birthtimeMs === info.birthtimeMs;
480
525
  }
481
- export async function parseSessionFile(sessionPath: string, fromOffset = 0): Promise<ParseSessionResult> {
526
+
527
+ /** Offset-only callers reconstruct service-tier state once; persisted cursors read only the appended tail. */
528
+ export async function parseSessionFile(
529
+ sessionPath: string,
530
+ fromOffset = 0,
531
+ state?: SessionParserState,
532
+ replay = false,
533
+ ): Promise<ParseSessionResult> {
482
534
  let bytes: Uint8Array;
535
+ let start = fromOffset;
536
+ let reset = false;
537
+ let currentServiceTier: ServiceTierByFamily | undefined;
538
+ let info: nodeFs.Stats;
539
+ let checkpoint: string;
540
+ let read: number;
541
+ let entries: SessionEntry[];
483
542
  try {
484
- bytes = await Bun.file(sessionPath).bytes();
543
+ const handle = await fs.open(sessionPath, "r");
544
+ try {
545
+ info = await handle.stat();
546
+ const file = Bun.file(handle.fd);
547
+ let resume = state?.version === 1 && state.offset === fromOffset;
548
+ if (resume && state) {
549
+ reset =
550
+ !matchesSessionFile(state, info) ||
551
+ info.size < state.size ||
552
+ (info.size === state.size && info.mtimeMs !== state.mtimeMs);
553
+ if (!reset) {
554
+ const previous = await readCheckpoint(handle, fromOffset);
555
+ reset = Bun.hash(previous).toString(16) !== state.checkpoint;
556
+ }
557
+ resume = !reset;
558
+ }
559
+ if (fromOffset > info.size) reset = true;
560
+ if (replay) resume = false;
561
+ start = reset || replay ? 0 : Math.max(0, fromOffset);
562
+ const readStart = resume ? start : 0;
563
+ bytes = await file.slice(readStart, info.size).bytes();
564
+ currentServiceTier = resume
565
+ ? (state?.serviceTier ?? undefined)
566
+ : scanLastServiceTier(bytes.subarray(0, start));
567
+ ({ entries, read } = parseSessionEntriesLenient(bytes.subarray(start - readStart)));
568
+ const newOffset = start + read;
569
+ const checkpointStart = Math.max(0, newOffset - CHECKPOINT_BYTES);
570
+ const previous =
571
+ checkpointStart >= readStart
572
+ ? bytes.subarray(checkpointStart - readStart, newOffset - readStart)
573
+ : await readCheckpoint(handle, newOffset);
574
+ checkpoint = Bun.hash(previous).toString(16);
575
+ } finally {
576
+ await handle.close();
577
+ }
485
578
  } catch (err) {
486
579
  if (isEnoent(err))
487
580
  return { stats: [], userStats: [], userLinks: [], toolCalls: [], toolResults: [], newOffset: fromOffset };
@@ -496,13 +589,6 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
496
589
  const toolCalls: ToolCallStats[] = [];
497
590
  const toolResults: ToolResultLink[] = [];
498
591
  const userByEntryId = new Map<string, UserMessageStats>();
499
- const start = Math.max(0, Math.min(fromOffset, bytes.length));
500
- const unprocessed = bytes.subarray(start);
501
- const { entries, read } = parseSessionEntriesLenient(unprocessed);
502
- let currentServiceTier: ServiceTierByFamily | undefined;
503
- if (start > 0) {
504
- currentServiceTier = scanLastServiceTier(bytes.subarray(0, start));
505
- }
506
592
  for (const entry of entries) {
507
593
  if (isServiceTierChange(entry)) {
508
594
  currentServiceTier = coerceServiceTierByFamily(entry.serviceTier);
@@ -552,7 +638,27 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
552
638
  }
553
639
  }
554
640
 
555
- return { stats, userStats, userLinks, toolCalls, toolResults, newOffset: start + read };
641
+ const newOffset = start + read;
642
+ return {
643
+ stats,
644
+ userStats,
645
+ userLinks,
646
+ toolCalls,
647
+ toolResults,
648
+ newOffset,
649
+ reset,
650
+ parserState: {
651
+ version: 1,
652
+ offset: newOffset,
653
+ dev: info.dev,
654
+ ino: info.ino,
655
+ birthtimeMs: info.birthtimeMs,
656
+ size: info.size,
657
+ mtimeMs: info.mtimeMs,
658
+ checkpoint,
659
+ serviceTier: currentServiceTier ?? null,
660
+ },
661
+ };
556
662
  }
557
663
 
558
664
  /**
@@ -26,7 +26,7 @@ export const STATS_DASHBOARD_SECURITY_VERSION = "3";
26
26
  /** IPv4 loopback address shared by the dashboard server and reuse probe. */
27
27
  export const STATS_DASHBOARD_HOSTNAME = "127.0.0.1";
28
28
 
29
- type StatsDashboardProbe = "reusable" | "occupied" | "unreachable";
29
+ type StatsDashboardProbe = "reusable" | "replaceable" | "occupied" | "unreachable";
30
30
 
31
31
  async function probeStatsDashboard(port: number, hostname: string): Promise<StatsDashboardProbe> {
32
32
  const probeHostname = hostname === "0.0.0.0" ? STATS_DASHBOARD_HOSTNAME : hostname === "::" ? "::1" : hostname;
@@ -35,13 +35,22 @@ async function probeStatsDashboard(port: number, hostname: string): Promise<Stat
35
35
  const response = await fetch(`http://${urlHostname}:${port}/api/stats/models`, {
36
36
  signal: AbortSignal.timeout(STATS_PROBE_TIMEOUT_MS),
37
37
  });
38
+ const dashboardVersionHeader = response.headers.get(STATS_DASHBOARD_HEADER);
39
+ const dashboardVersion = dashboardVersionHeader === null ? Number.NaN : Number(dashboardVersionHeader);
40
+ // Never replace a newer dashboard: an older CLI must not downgrade it.
41
+ const replaceable =
42
+ response.status === 200 &&
43
+ Number.isSafeInteger(dashboardVersion) &&
44
+ dashboardVersion > 0 &&
45
+ dashboardVersion <= Number(STATS_DASHBOARD_SECURITY_VERSION);
38
46
  const reusable =
39
47
  response.status === 200 &&
40
- response.headers.get(STATS_DASHBOARD_HEADER) === STATS_DASHBOARD_SECURITY_VERSION &&
48
+ dashboardVersionHeader === STATS_DASHBOARD_SECURITY_VERSION &&
41
49
  response.headers.get(STATS_DASHBOARD_HOSTNAME_HEADER) === hostname &&
42
50
  !response.headers.has("Access-Control-Allow-Origin");
43
51
  await response.body?.cancel();
44
- return reusable ? "reusable" : "occupied";
52
+ if (reusable) return "reusable";
53
+ return replaceable ? "replaceable" : "occupied";
45
54
  } catch {
46
55
  return "unreachable";
47
56
  }
@@ -218,7 +227,7 @@ async function terminatePortHolder(holder: PortHolder): Promise<void> {
218
227
  await Bun.sleep(PROCESS_EXIT_POLL_MS);
219
228
  }
220
229
 
221
- async function reclaimStatsPort(port: number): Promise<"retry"> {
230
+ async function reclaimStatsPort(port: number, hasDashboardIdentity = false): Promise<"retry"> {
222
231
  const holder = await findPortHolder(port);
223
232
  if (!holder) {
224
233
  throw new Error(`Port ${port} is in use, but the listening process could not be identified.`);
@@ -238,7 +247,7 @@ async function reclaimStatsPort(port: number): Promise<"retry"> {
238
247
  /\/packages\/stats\/src\/index\.ts(?:["'\s]|$)/.test(normalizedCommand) ||
239
248
  (normalizedImage === "omp" && /(?:^|\s)stats(?:\s|$)/.test(normalizedCommand)) ||
240
249
  /(?:^|\/)omp(?:\.exe)?["'\s]+stats(?:["'\s]|$)/.test(normalizedCommand);
241
- if (!STATS_RUNTIME_IMAGES[normalizedImage] || !hasStatsIdentity) {
250
+ if (!STATS_RUNTIME_IMAGES[normalizedImage] || (!hasStatsIdentity && !hasDashboardIdentity)) {
242
251
  throw new Error(
243
252
  `Port ${port} is in use by ${holder.image} (PID ${holder.pid}), which is not identifiable as an omp stats dashboard; refusing to stop it.`,
244
253
  );
@@ -257,12 +266,14 @@ export async function prepareStatsPort(port: number, hostname = STATS_DASHBOARD_
257
266
  if (port === 0) return "retry";
258
267
  const probe = await probeStatsDashboard(port, hostname);
259
268
  if (probe === "reusable") return "reuse";
269
+ if (probe === "replaceable") return reclaimStatsPort(port, true);
260
270
  if (probe === "occupied") return reclaimStatsPort(port);
261
271
  return "retry";
262
272
  }
263
273
 
264
274
  /** Reuse or reclaim a listener found after the server bind reports EADDRINUSE. */
265
275
  export async function recoverStatsPort(port: number, hostname = STATS_DASHBOARD_HOSTNAME): Promise<"retry" | "reuse"> {
266
- if ((await probeStatsDashboard(port, hostname)) === "reusable") return "reuse";
267
- return reclaimStatsPort(port);
276
+ const probe = await probeStatsDashboard(port, hostname);
277
+ if (probe === "reusable") return "reuse";
278
+ return reclaimStatsPort(port, probe === "replaceable");
268
279
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Stateless parse worker for `syncAllSessions`. The main thread owns the
3
- * SQLite handle; workers receive `{ sessionFile, fromOffset }`, run
3
+ * SQLite handle; workers receive a session path, offset, and parser state, run
4
4
  * `parseSessionFile` (which is pure I/O + CPU, no DB), and post the
5
5
  * structured-clone-safe result back. One in-flight request per worker so
6
6
  * the main thread can fan jobs out 1:1 with the pool size.
@@ -11,9 +11,11 @@
11
11
  * for issue #1011 / PR #1027, where the worker silently failed to load).
12
12
  */
13
13
 
14
- import { type ParseSessionResult, parseSessionFile } from "./parser";
14
+ import { type ParseSessionResult, parseSessionFile, type SessionParserState } from "./parser";
15
15
 
16
- export type SyncWorkerRequest = { kind?: "parse"; sessionFile: string; fromOffset: number } | { kind: "ping" };
16
+ export type SyncWorkerRequest =
17
+ | { kind?: "parse"; sessionFile: string; fromOffset: number; parserState?: SessionParserState; replay?: boolean }
18
+ | { kind: "ping" };
17
19
 
18
20
  export type SyncWorkerResponse =
19
21
  | { ok: true; kind?: "parse"; result: ParseSessionResult }
@@ -31,7 +33,12 @@ self.onmessage = async event => {
31
33
  self.postMessage({ ok: true, kind: "pong" } satisfies SyncWorkerResponse);
32
34
  return;
33
35
  }
34
- const result = await parseSessionFile(request.sessionFile, request.fromOffset);
36
+ const result = await parseSessionFile(
37
+ request.sessionFile,
38
+ request.fromOffset,
39
+ request.parserState,
40
+ request.replay,
41
+ );
35
42
  self.postMessage({ ok: true, result } satisfies SyncWorkerResponse);
36
43
  } catch (err) {
37
44
  const error = err instanceof Error ? (err.stack ?? err.message) : String(err);