@ipv9/tokentracker-cli 0.39.38 → 0.39.39

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.
Files changed (79) hide show
  1. package/README.md +41 -9
  2. package/dashboard/dist/assets/{Card-DD4s0V6T.js → Card-BbRYRVXf.js} +1 -1
  3. package/dashboard/dist/assets/DashboardPage-DYKHv0m3.js +60 -0
  4. package/dashboard/dist/assets/{FadeIn-CUSBK0jm.js → FadeIn-CjAacJ5t.js} +1 -1
  5. package/dashboard/dist/assets/{IpCheckPage-PHF4lPax.js → IpCheckPage-BGALK8Vy.js} +1 -1
  6. package/dashboard/dist/assets/{LimitsPage-dS9C9Vdt.js → LimitsPage-LkZGPYvg.js} +2 -2
  7. package/dashboard/dist/assets/{LocalOnlyNotice-DCmtS_j5.js → LocalOnlyNotice-Dxo2vFMQ.js} +1 -1
  8. package/dashboard/dist/assets/PopoverPopup-DP9fwdtl.js +1 -0
  9. package/dashboard/dist/assets/Select-DPE53NmM.js +1 -0
  10. package/dashboard/dist/assets/SelectItemText-D69v77is.js +12 -0
  11. package/dashboard/dist/assets/SettingsPage-Dxzqpi02.js +1 -0
  12. package/dashboard/dist/assets/SkillsPage-Bo7e39AB.js +1 -0
  13. package/dashboard/dist/assets/WidgetsPage-HHcIr_Eq.js +1 -0
  14. package/dashboard/dist/assets/WrappedPage-C-_jLXXU.js +1 -0
  15. package/dashboard/dist/assets/{arrow-up-right-CjaBQnwI.js → arrow-up-right-BJvJHUGj.js} +1 -1
  16. package/dashboard/dist/assets/{download-DLBqkSwp.js → download-CnzY_EgN.js} +1 -1
  17. package/dashboard/dist/assets/format-1w-q5n2f.js +1 -0
  18. package/dashboard/dist/assets/main-1kxAVM3m.css +1 -0
  19. package/dashboard/dist/assets/main-3gBEI6Jl.js +694 -0
  20. package/dashboard/dist/assets/mock-data-CJOMGcL9.js +1 -0
  21. package/dashboard/dist/assets/use-limits-display-prefs-D1nT9-mr.js +1 -0
  22. package/dashboard/dist/assets/use-native-settings-Dh6NzqjY.js +1 -0
  23. package/dashboard/dist/assets/use-usage-limits-BXR6lzGf.js +1 -0
  24. package/dashboard/dist/assets/useCurrency-Hhs5jbWw.js +1 -0
  25. package/dashboard/dist/index.html +15 -27
  26. package/dashboard/dist/llms.txt +0 -2
  27. package/dashboard/dist/sitemap.xml +0 -12
  28. package/package.json +7 -4
  29. package/src/cli.js +4 -13
  30. package/src/commands/doctor.js +1 -3
  31. package/src/commands/init.js +63 -53
  32. package/src/commands/serve.js +2 -50
  33. package/src/commands/status.js +1 -55
  34. package/src/commands/sync.js +134 -227
  35. package/src/lib/{browser-auth.js → browser-open.js} +3 -149
  36. package/src/lib/claude-categorizer.js +204 -32
  37. package/src/lib/codex-context-breakdown.js +83 -7
  38. package/src/lib/codex-rollout-parser.js +52 -10
  39. package/src/lib/diagnostics.js +1 -49
  40. package/src/lib/doctor.js +12 -87
  41. package/src/lib/file-identity.js +69 -0
  42. package/src/lib/init-flow.js +6 -3
  43. package/src/lib/local-api.js +6 -364
  44. package/src/lib/pricing/curated-overrides.json +1 -1
  45. package/src/lib/pricing/seed-snapshot.json +1 -1
  46. package/src/lib/rollout.js +392 -144
  47. package/src/lib/runtime-config.js +0 -32
  48. package/src/lib/sqlite-reader.js +83 -8
  49. package/dashboard/dist/assets/ActivityHeatmap-BrehqUIb.js +0 -42
  50. package/dashboard/dist/assets/DashboardPage-CyUShBRJ.js +0 -19
  51. package/dashboard/dist/assets/DevicePage-BA74Su1l.js +0 -1
  52. package/dashboard/dist/assets/DialogTitle-DoKsz5wi.js +0 -1
  53. package/dashboard/dist/assets/InsforgeUserHeaderControls-Bk_U2dHr.js +0 -1
  54. package/dashboard/dist/assets/LandingPage-Bbeu84Iu.js +0 -4356
  55. package/dashboard/dist/assets/LeaderboardAvatar-ByqeB6bM.js +0 -1
  56. package/dashboard/dist/assets/LeaderboardPage-BeWXF00S.js +0 -6
  57. package/dashboard/dist/assets/LeaderboardProfileModal-C4yvbZd_.js +0 -72
  58. package/dashboard/dist/assets/LeaderboardProfilePage-D3cbSh5o.js +0 -1
  59. package/dashboard/dist/assets/LoginPage-Ch3UQoWj.js +0 -1
  60. package/dashboard/dist/assets/PopoverPopup-DjG0gjlA.js +0 -1
  61. package/dashboard/dist/assets/Select-HzIjWJpE.js +0 -1
  62. package/dashboard/dist/assets/SelectItemText-f0tCwtvZ.js +0 -1
  63. package/dashboard/dist/assets/SettingsPage-DJOZAlZy.js +0 -1
  64. package/dashboard/dist/assets/SkillsPage-BQcyJIdx.js +0 -1
  65. package/dashboard/dist/assets/WidgetsPage-B_vxuV09.js +0 -1
  66. package/dashboard/dist/assets/WrappedPage-CZN9Y3Vz.js +0 -1
  67. package/dashboard/dist/assets/index-BeoRn2gJ.js +0 -2
  68. package/dashboard/dist/assets/info-BAIxlHIB.js +0 -1
  69. package/dashboard/dist/assets/main-DAcSUW2t.css +0 -1
  70. package/dashboard/dist/assets/main-pi07CfeC.js +0 -1004
  71. package/dashboard/dist/assets/use-limits-display-prefs-CMfASZsh.js +0 -1
  72. package/dashboard/dist/assets/use-native-settings-CfPik-Ao.js +0 -1
  73. package/dashboard/dist/assets/use-spring-wsmJQRqb.js +0 -1
  74. package/dashboard/dist/assets/use-usage-limits-Bt0Dp6kx.js +0 -1
  75. package/dashboard/dist/assets/useCurrency-CsaAMc3j.js +0 -1
  76. package/dashboard/dist/assets/useOpenInteractionType-CPzi5Z8M.js +0 -12
  77. package/dashboard/dist/share.html +0 -140
  78. package/src/commands/device-login.js +0 -161
  79. package/src/lib/upload-throttle.js +0 -148
@@ -5,7 +5,11 @@ const readline = require("node:readline");
5
5
 
6
6
  const crypto = require("node:crypto");
7
7
  const { ensureDir } = require("./fs");
8
- const { readSqliteJsonRows } = require("./sqlite-reader");
8
+ const {
9
+ readSqliteJsonRows,
10
+ readSqliteJsonRowsWithStatus,
11
+ SQLITE_READ_MISSING,
12
+ } = require("./sqlite-reader");
9
13
 
10
14
  const DEFAULT_SOURCE = "codex";
11
15
  const DEFAULT_MODEL = "unknown";
@@ -49,28 +53,10 @@ async function listClaudeProjectFiles(projectsDir) {
49
53
  return out;
50
54
  }
51
55
 
52
- // Fingerprint the first bytes of a file so rotation is detected even when the
53
- // OS reuses the inode after unlink+recreate (Linux logrotate). Returns "" on
54
- // any error so callers treat "no fingerprint" as "not changed". Also returns
55
- // "" when the file is shorter than the fingerprint window: for a sub-256B
56
- // file that is still being appended to, the window would extend into the
57
- // not-yet-written append zone, so plain growth (not rotation) would shift
58
- // the hash and produce a false "rotated" signal.
59
- function readFileHeadSignature(filePath) {
60
- try {
61
- const fd = fssync.openSync(filePath, "r");
62
- try {
63
- const buf = Buffer.alloc(256);
64
- const bytes = fssync.readSync(fd, buf, 0, 256, 0);
65
- if (bytes < 256) return "";
66
- return crypto.createHash("sha1").update(buf.subarray(0, bytes)).digest("hex");
67
- } finally {
68
- fssync.closeSync(fd);
69
- }
70
- } catch {
71
- return "";
72
- }
73
- }
56
+ // Moved to ./file-identity so the context-breakdown caches can share it
57
+ // rather than grow a second copy. Imported under the same name, so all 19
58
+ // call sites below are unaffected.
59
+ const { readFileHeadSignature } = require("./file-identity");
74
60
 
75
61
  async function listGeminiSessionFiles(tmpDir) {
76
62
  const out = [];
@@ -151,6 +137,7 @@ async function parseRolloutIncremental({
151
137
  const startOffset = sameFile ? prev.offset || 0 : 0;
152
138
  const lastTotal = sameFile ? prev.lastTotal || null : null;
153
139
  const lastModel = sameFile ? prev.lastModel || null : null;
140
+ const forkState = sameFile ? prev.codexFork || null : null;
154
141
 
155
142
  const projectContext = projectEnabled
156
143
  ? await resolveProjectContextForFile({
@@ -169,6 +156,7 @@ async function parseRolloutIncremental({
169
156
  startOffset,
170
157
  lastTotal,
171
158
  lastModel,
159
+ forkState,
172
160
  hourlyState,
173
161
  touchedBuckets,
174
162
  source: fileSource,
@@ -186,6 +174,7 @@ async function parseRolloutIncremental({
186
174
  offset: result.endOffset,
187
175
  lastTotal: result.lastTotal,
188
176
  lastModel: result.lastModel,
177
+ codexFork: result.codexFork || null,
189
178
  head: headSig,
190
179
  updatedAt: new Date().toISOString(),
191
180
  };
@@ -796,6 +785,7 @@ async function parseRolloutFile({
796
785
  startOffset,
797
786
  lastTotal,
798
787
  lastModel,
788
+ forkState,
799
789
  hourlyState,
800
790
  touchedBuckets,
801
791
  source,
@@ -810,7 +800,8 @@ async function parseRolloutFile({
810
800
  const st = await fs.stat(filePath);
811
801
  const endOffset = st.size;
812
802
  if (startOffset >= endOffset) {
813
- return { endOffset, lastTotal, lastModel, eventsAggregated: 0 };
803
+ const carriedFork = forkState === "child" || forkState === "replay" ? forkState : null;
804
+ return { endOffset, lastTotal, lastModel, eventsAggregated: 0, codexFork: carriedFork };
814
805
  }
815
806
 
816
807
  const stream = fssync.createReadStream(filePath, { encoding: "utf8", start: startOffset });
@@ -823,14 +814,37 @@ async function parseRolloutFile({
823
814
  let currentProjectKey = projectKey || null;
824
815
  let eventsAggregated = 0;
825
816
 
817
+ // Codex subagent-fork accounting (issue #75). A rollout spawned via
818
+ // session_meta.source.subagent.thread_spawn replays the parent thread's
819
+ // token_count history before a deterministic
820
+ // "inter_agent_communication_metadata" boundary, then the genuine child
821
+ // turns. codexFork tracks that lifecycle across incremental reads:
822
+ // null -> not a fork (or not yet known); accounting on
823
+ // "replay" -> fork detected, boundary not seen; accounting off
824
+ // "child" -> boundary passed; accounting on
825
+ // The cumulative total_token_usage counter is continuous across the
826
+ // boundary, so we still advance the delta baseline through the replay
827
+ // (without accumulating) and let the first child delta be measured from the
828
+ // last replayed cumulative.
829
+ let codexFork = forkState === "child" || forkState === "replay" ? forkState : null;
830
+ let accounting = codexFork !== "replay";
831
+
826
832
  for await (const line of rl) {
827
833
  if (!line) continue;
834
+ // Format contract with Codex (issue #75): fork detection and the replay
835
+ // boundary are matched by these exact record-type substrings. If a future
836
+ // Codex format renames/reshapes them, this prefilter drops the line and the
837
+ // fork silently reverts to counting replayed history — update these literals
838
+ // (and the tests in test/codex-fork-history.test.js) as a conscious change.
828
839
  const maybeTokenCount = line.includes('"token_count"');
840
+ const maybeBoundary =
841
+ !maybeTokenCount && line.includes('"inter_agent_communication_metadata"');
829
842
  const maybeTurnContext =
830
843
  !maybeTokenCount &&
844
+ !maybeBoundary &&
831
845
  (line.includes('"turn_context"') || line.includes('"session_meta"')) &&
832
- (line.includes('"model"') || line.includes('"cwd"'));
833
- if (!maybeTokenCount && !maybeTurnContext) continue;
846
+ (line.includes('"model"') || line.includes('"cwd"') || line.includes('"thread_spawn"'));
847
+ if (!maybeTokenCount && !maybeTurnContext && !maybeBoundary) continue;
834
848
 
835
849
  let obj;
836
850
  try {
@@ -839,11 +853,27 @@ async function parseRolloutFile({
839
853
  continue;
840
854
  }
841
855
 
856
+ // Boundary between replayed parent history and genuine child turns.
857
+ if (obj?.type === "inter_agent_communication_metadata") {
858
+ accounting = true;
859
+ if (codexFork === "replay") codexFork = "child";
860
+ continue;
861
+ }
862
+
842
863
  if (
843
864
  (obj?.type === "turn_context" || obj?.type === "session_meta") &&
844
865
  obj?.payload &&
845
866
  typeof obj.payload === "object"
846
867
  ) {
868
+ if (
869
+ obj.type === "session_meta" &&
870
+ obj.payload.source?.subagent?.thread_spawn &&
871
+ codexFork !== "child"
872
+ ) {
873
+ // Fork detected before any boundary in this file: enter replay mode.
874
+ codexFork = "replay";
875
+ accounting = false;
876
+ }
847
877
  if (typeof obj.payload.model === "string") {
848
878
  model = obj.payload.model;
849
879
  }
@@ -878,6 +908,17 @@ async function parseRolloutFile({
878
908
  const totalUsage = info.total_token_usage;
879
909
 
880
910
  const delta = pickDelta(lastUsage, totalUsage, totals);
911
+ if (!accounting) {
912
+ // Replay mode (subagent fork before the boundary): advance the delta
913
+ // baseline so the first child delta is measured from the last replayed
914
+ // cumulative, but do not attribute the replayed parent history to this
915
+ // child. Advance unconditionally here — pickDelta can return null for a
916
+ // zero/duplicate delta, and we still want the baseline to track the
917
+ // replay. This branch is scoped to forks so the shared non-fork path
918
+ // below keeps its original guard order untouched.
919
+ if (totalUsage && typeof totalUsage === "object") totals = totalUsage;
920
+ continue;
921
+ }
881
922
  if (!delta) continue;
882
923
  delta.conversation_count = 1;
883
924
 
@@ -905,7 +946,30 @@ async function parseRolloutFile({
905
946
  eventsAggregated += 1;
906
947
  }
907
948
 
908
- return { endOffset, lastTotal: totals, lastModel: model, eventsAggregated };
949
+ // Fail closed for a partially-written fork: the boundary never arrived, so
950
+ // the replay is not yet safely separable from the child. Do not advance the
951
+ // cursor or the persisted baseline — the completed file is re-read next pass.
952
+ // This degrades safely (0 tokens, never over-count) but is silent, so surface
953
+ // it under debug: a file that stays here across many passes is a boundary
954
+ // that was malformed/renamed, not merely still being written.
955
+ if (codexFork === "replay") {
956
+ if (process.env.TOKENTRACKER_DEBUG) {
957
+ process.stderr.write(
958
+ `[codex-fork] ${filePath}: subagent fork has no ` +
959
+ `inter_agent_communication_metadata boundary yet; deferring ` +
960
+ `(0 tokens ingested, cursor held at ${startOffset})\n`,
961
+ );
962
+ }
963
+ return {
964
+ endOffset: startOffset,
965
+ lastTotal,
966
+ lastModel: model,
967
+ eventsAggregated: 0,
968
+ codexFork: "replay",
969
+ };
970
+ }
971
+
972
+ return { endOffset, lastTotal: totals, lastModel: model, eventsAggregated, codexFork };
909
973
  }
910
974
 
911
975
  async function parseClaudeFile({
@@ -3388,7 +3452,7 @@ function resolveKimiDefaultModel(env = process.env) {
3388
3452
  // token. Source is merged with Kiro IDE (source='kiro') and canonicalized
3389
3453
  // model names are used so CLI and IDE rows collapse when they refer to the
3390
3454
  // same underlying Bedrock model. Cursor state is per-request-id so mutable
3391
- // requests can be reprocessed (subtract-old/add-new on fingerprint change).
3455
+ // requests can be reprocessed (a grown request adds only its growth, #65).
3392
3456
  // ─────────────────────────────────────────────────────────────────────────────
3393
3457
 
3394
3458
  const KIRO_CLI_CHARS_PER_TOKEN = 4;
@@ -3526,7 +3590,7 @@ async function readKiroCliMessageChars(jsonlPath, turnMessageIds) {
3526
3590
  // sibling. Returns [{ request_id, model_id, request_start_timestamp_ms,
3527
3591
  // input_tokens, output_tokens }]. We use the same request_id dedup slot as
3528
3592
  // the SQLite path so mutations (turn rewritten on next flush) go through
3529
- // the subtract-old/add-new path in parseKiroCliIncremental.
3593
+ // the positive-only per-request delta in parseKiroCliIncremental.
3530
3594
  async function readKiroCliSessionTurns(jsonPath) {
3531
3595
  if (!jsonPath || !fssync.existsSync(jsonPath)) return [];
3532
3596
  let parsed;
@@ -3700,15 +3764,27 @@ function readKiroCliRequests(dbPath, env = process.env, sqliteOptions = {}) {
3700
3764
  "json_extract(value, '$.user_turn_metadata.requests') AS requests_json " +
3701
3765
  "FROM conversations_v2 " +
3702
3766
  "WHERE json_extract(value, '$.user_turn_metadata.requests') IS NOT NULL";
3703
- const rows = readSqliteJsonRows(dbPath, sql, {
3767
+ // #66 built the {ok, reason} taxonomy for exactly this caller. There is
3768
+ // deliberately no GATE — a degraded read only shrinks the result, which
3769
+ // clamps to a zero delta — but "no gate needed" is not "no signal needed".
3770
+ // Without this an operator whose database has been corrupt or locked for
3771
+ // weeks sees a clean sync and a healthy status line, because
3772
+ // warnSqliteUnavailable fires only for a missing sqlite BACKEND.
3773
+ const readResult = readSqliteJsonRowsWithStatus(dbPath, sql, {
3704
3774
  label: "Kiro CLI",
3705
3775
  env,
3706
3776
  maxBuffer: 128 * 1024 * 1024,
3707
3777
  timeout: 120_000,
3708
3778
  ...sqliteOptions,
3709
3779
  });
3780
+ if (!readResult.ok && readResult.reason !== SQLITE_READ_MISSING) {
3781
+ const stderr = (sqliteOptions && sqliteOptions.stderr) || process.stderr;
3782
+ stderr.write(
3783
+ `[kiro-cli] SQLite read degraded (${readResult.reason}); Kiro CLI totals may be stale this sync\n`,
3784
+ );
3785
+ }
3710
3786
  const flat = [];
3711
- for (const row of rows) {
3787
+ for (const row of readResult.rows) {
3712
3788
  let requests;
3713
3789
  try {
3714
3790
  requests = JSON.parse(row.requests_json || "[]");
@@ -3731,7 +3807,63 @@ function readKiroCliRequests(dbPath, env = process.env, sqliteOptions = {}) {
3731
3807
  });
3732
3808
  }
3733
3809
  }
3734
- return flat;
3810
+ return { ok: readResult.ok, reason: readResult.reason, rows: flat };
3811
+ }
3812
+
3813
+ // ── #65: per-request, never-pruned, positive-only watermark ────────────────
3814
+ //
3815
+ // The bug: `readKiroCliRequests` reads the whole database with NO age filter,
3816
+ // while `cursors.kiroCli.requests` was pruned at 90 days AND capped at 20,000
3817
+ // entries. The memory window was strictly smaller than the read window, so an
3818
+ // evicted-but-still-returned request was re-added on every sync, forever.
3819
+ //
3820
+ // The fix is the smallest one that actually closes it: keep that same
3821
+ // per-request record, stop pruning it, and only ever ADD the growth.
3822
+ //
3823
+ // INTENT — TWO RULES, AND EVERY DEFECT THIS FILE HAS SEEN CAME FROM BREAKING
3824
+ // ONE OF THEM. Do not "optimise" either away.
3825
+ //
3826
+ // 1. NEVER PRUNE. The reader has no age or count filter, so anything
3827
+ // dropped from this map is re-added in full on the next sync. That IS
3828
+ // issue #65. Dropping only the age cap and keeping the count cap merely
3829
+ // converts an age-triggered recount into a volume-triggered one.
3830
+ //
3831
+ // 2. THE DELTA IS CLAMPED AT ZERO — this parser never subtracts. Attribution
3832
+ // is only ever needed to justify a DECREASE, so never decreasing removes
3833
+ // the need for it entirely:
3834
+ // • a shrinking read (deleted session, corrupt file, unreadable or
3835
+ // vanished database, partial parse) produces a zero delta, so no
3836
+ // health gate is needed to protect against it;
3837
+ // • `source="kiro"` hourly buckets are shared with the Kiro IDE parser,
3838
+ // and since we only add we never need to know the IDE's share, so no
3839
+ // change to the shared `cursors.hourly` schema is needed either.
3840
+ //
3841
+ // Keying by request identity rather than by bucket also removes two whole
3842
+ // failure modes that a bucket-keyed version had: a request replaced by a
3843
+ // different request of equal value in the same bucket is still counted, and a
3844
+ // request that changes bucket or loses its model id contributes a zero delta
3845
+ // instead of opening a phantom bucket.
3846
+ //
3847
+ // Cost, stated plainly: a genuine downward correction never propagates, and a
3848
+ // request that moves to another bucket leaves its earlier contribution behind
3849
+ // in the old one. State size grows with Kiro's own history rather than being
3850
+ // bounded — that is the deliberate trade for correctness, and the number
3851
+ // belongs in the release notes rather than in a prune policy.
3852
+ const KIRO_WATERMARK_VERSION = 2;
3853
+
3854
+ // A stored value is only usable as a floor when it is a finite, non-negative
3855
+ // number. Anything else is corruption from a user-writable cursor file, not a
3856
+ // zero: reading it as zero would make `delta = want - 0 = want` and re-add the
3857
+ // whole contribution. Both call sites must use this — an earlier version had
3858
+ // one loop fall back to 0 and its sibling fall back to `want`, which laundered
3859
+ // corruption into a legitimate-looking zero.
3860
+ function kiroWatermarkFloor(cell, field, fallback) {
3861
+ if (!cell || typeof cell !== "object") return fallback;
3862
+ const value = cell[field];
3863
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
3864
+ return fallback;
3865
+ }
3866
+ return value;
3735
3867
  }
3736
3868
 
3737
3869
  async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onProgress, env, sqliteOptions } = {}) {
@@ -3765,12 +3897,16 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
3765
3897
  // SQLite carries a persisted request_id UUID; session files synthesize
3766
3898
  // `${sessionId}:${loop_id.rand}`. When kiro-cli migrates a live session
3767
3899
  // into SQLite the same turn lands under a new request_id — the cross-
3768
- // source retraction pass below (D-1 + TASK-007) matches session_id
3900
+ // same turn lands under a new request_id; the watermark makes that a
3901
+ // no-op because the bucket and its value are unchanged (see #65).
3902
+ // (historical note: a cross-source retraction pass used to live below)
3903
+ // was: source retraction pass matched session_id ↔
3769
3904
  // SQLite conversation_id OR continuation_id to subtract the orphan
3770
3905
  // session-file cursor entry before the new SQLite row is processed.
3771
- const flatDb = fssync.existsSync(dbPath)
3906
+ const dbRead = fssync.existsSync(dbPath)
3772
3907
  ? readKiroCliRequests(dbPath, resolvedEnv, sqliteOptions)
3773
- : [];
3908
+ : { ok: true, reason: null, rows: [] };
3909
+ const flatDb = dbRead.rows;
3774
3910
  const sessionFilesList = resolveKiroCliSessionFiles(resolvedEnv);
3775
3911
  let flatSessions = [];
3776
3912
  for (const jsonPath of sessionFilesList) {
@@ -3779,7 +3915,7 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
3779
3915
  }
3780
3916
  // Per-request state replaces the old seenIds set. Each entry captures
3781
3917
  // what we contributed for that request_id last time, so a later mutation
3782
- // (same request_id, different fingerprint) can subtract-old/add-new
3918
+ // (same request_id, different fingerprint) contributes only its growth
3783
3919
  // instead of being skipped forever.
3784
3920
  const requestState =
3785
3921
  kiroCliState.requests && typeof kiroCliState.requests === "object"
@@ -3824,53 +3960,17 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
3824
3960
  migratedMsgIds.add(row.message_id);
3825
3961
  }
3826
3962
  if (migratedConvIds.size > 0) {
3827
- // Pre-collect to retract so mutation during iteration is safe.
3828
- // Retraction stays session-level: for every cursor entry whose
3829
- // session_id has any row in SQLite, subtract its prior contribution.
3830
- // This is provably correct because turns still live in the session
3831
- // file get re-added in this same run via the (turn-granular) filter
3832
- // below, producing a net delta of zero for un-migrated turns.
3833
- const toRetract = [];
3834
- for (const [reqId, prev] of Object.entries(requestState)) {
3835
- if (!prev || typeof prev !== "object") continue;
3836
- // Bug-2: prefer the stored session_id tag (new schema); fall back
3837
- // to colon-split for legacy cursors pre-dating this change.
3838
- let sid = null;
3839
- if (typeof prev.session_id === "string" && prev.session_id) {
3840
- sid = prev.session_id;
3841
- } else {
3842
- const colon = reqId.indexOf(":");
3843
- if (colon > 0) sid = reqId.slice(0, colon);
3844
- }
3845
- if (!sid || !migratedConvIds.has(sid)) continue;
3846
- toRetract.push([reqId, prev, sid]);
3847
- }
3848
- for (const [reqId, prev, sid] of toRetract) {
3849
- if (prev.input_tokens || prev.output_tokens) {
3850
- const prevBucket = getHourlyBucket(
3851
- hourlyState,
3852
- "kiro",
3853
- prev.model,
3854
- prev.bucketStart,
3855
- );
3856
- addTotals(prevBucket.totals, {
3857
- input_tokens: -prev.input_tokens,
3858
- cached_input_tokens: 0,
3859
- cache_creation_input_tokens: 0,
3860
- output_tokens: -prev.output_tokens,
3861
- reasoning_output_tokens: 0,
3862
- total_tokens: -(prev.input_tokens + prev.output_tokens),
3863
- conversation_count: -1,
3864
- });
3865
- touchedBuckets.add(bucketKey("kiro", prev.model, prev.bucketStart));
3866
- }
3867
- delete requestState[reqId];
3868
- if (debugEnabled) {
3869
- process.stderr.write(
3870
- `[kiro-cli] retracted migrated session entry (conv ${sid})\n`,
3871
- );
3872
- }
3873
- }
3963
+ // #65: the cursor-retraction pass that used to live here is gone. It
3964
+ // walked the age- and count-capped `requestState` to subtract a migrated
3965
+ // conversation's earlier contribution the very state whose eviction
3966
+ // causes #65. Under a positive-only watermark it is unnecessary: the same
3967
+ // turn arriving from SQLite instead of a session file lands in the same
3968
+ // (model, bucketStart) with the same value, so `desired` is unchanged and
3969
+ // the delta is zero.
3970
+ //
3971
+ // The turn-granular filter below DOES have to stay: it removes a turn that
3972
+ // is present in BOTH readers within a SINGLE sync, which would otherwise
3973
+ // inflate `desired` itself.
3874
3974
  // Turn-granular filter: drop a session-file turn only when at least
3875
3975
  // one of its assistant/tool_result message_ids is present in SQLite
3876
3976
  // (i.e. this specific turn has been flushed). Newer turns in the
@@ -3910,11 +4010,16 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
3910
4010
  // Clamp + cap BEFORE flushing so the early-return path applies the
3911
4011
  // same guarantees as the main path (fixes a skip that flushed
3912
4012
  // negative conversation_counts and left the cap unapplied).
3913
- const cappedEarly = clampAndCapKiroCliState({
3914
- requestState,
3915
- hourlyState,
3916
- touchedBuckets,
3917
- });
4013
+ // Clamp only. The cap is deliberately NOT applied here either: pruning a
4014
+ // request from this map means the reader re-adds it in full next sync,
4015
+ // which is issue #65 (INTENT rule 1).
4016
+ for (const key of touchedBuckets) {
4017
+ const b = hourlyState.buckets && hourlyState.buckets[key];
4018
+ if (b && b.totals && b.totals.conversation_count < 0) {
4019
+ b.totals.conversation_count = 0;
4020
+ }
4021
+ }
4022
+ const cappedEarly = requestState;
3918
4023
  const bucketsQueued = await enqueueTouchedBuckets({
3919
4024
  queuePath,
3920
4025
  hourlyState,
@@ -3923,18 +4028,39 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
3923
4028
  const updatedAt = new Date().toISOString();
3924
4029
  hourlyState.updatedAt = updatedAt;
3925
4030
  cursors.hourly = hourlyState;
3926
- cursors.kiroCli = { ...kiroCliState, requests: cappedEarly, updatedAt };
4031
+ // Stamp the era marker only when doing so cannot be mistaken later for
4032
+ // "already adopted". For a cursor that has prior state but no marker — a
4033
+ // pre-#65 install — stamping here would make the NEXT sync skip adoption
4034
+ // and re-add the entire corpus on top of the existing inflation. Leave
4035
+ // such a cursor pre-era so adoption still fires when data appears.
4036
+ const priorEra = kiroCliState.watermarkVersion === KIRO_WATERMARK_VERSION;
4037
+ const priorState = Object.keys(kiroCliState).length > 0;
4038
+ cursors.kiroCli = {
4039
+ ...kiroCliState,
4040
+ requests: cappedEarly,
4041
+ ...(!priorState || priorEra ? { watermarkVersion: KIRO_WATERMARK_VERSION } : {}),
4042
+ updatedAt,
4043
+ };
3927
4044
  return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued };
3928
4045
  }
3929
4046
  const cb = typeof onProgress === "function" ? onProgress : null;
3930
4047
  let recordsProcessed = 0;
3931
- let eventsAggregated = 0;
4048
+
4049
+ // What the CURRENT reader output says each request contributed. Deltas are
4050
+ // taken per request against the watermark after the loop.
4051
+ const desiredRequests = {};
4052
+ let seenRequests = 0;
3932
4053
 
3933
4054
  for (let i = 0; i < flat.length; i++) {
3934
4055
  const r = flat[i];
3935
4056
  recordsProcessed++;
3936
4057
 
3937
- const requestId = r.request_id || r.message_id;
4058
+ // Identity must be STABLE ACROSS TIERS. When kiro-cli flushes a live
4059
+ // session into SQLite the same turn reappears under a brand-new
4060
+ // request_id, so keying on request_id first would count it twice — the
4061
+ // cross-source double count that TASK-007's retraction pass used to undo.
4062
+ // `message_id` survives that migration, so prefer it.
4063
+ const requestId = r.message_id || r.request_id;
3938
4064
  if (!requestId) continue;
3939
4065
 
3940
4066
  const promptChars = toNonNegativeInt(r.user_prompt_length);
@@ -3953,54 +4079,46 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
3953
4079
 
3954
4080
  // Fingerprint captures every field whose change should cause a re-bucket.
3955
4081
  const fingerprint = `${promptChars}:${responseChars}:${model}:${tsMs}`;
3956
- const prev = requestState[requestId];
3957
- if (prev && prev.fingerprint === fingerprint) continue; // unchanged
3958
-
3959
- // Subtract the prior contribution (if any) from its prior bucket so the
3960
- // bucket's absolute totals reflect the CURRENT truth, not the historical
3961
- // truth. enqueueTouchedBuckets will emit the net delta at flush time.
3962
- if (prev && (prev.input_tokens || prev.output_tokens)) {
3963
- const prevBucket = getHourlyBucket(hourlyState, "kiro", prev.model, prev.bucketStart);
3964
- addTotals(prevBucket.totals, {
3965
- input_tokens: -prev.input_tokens,
3966
- cached_input_tokens: 0,
3967
- cache_creation_input_tokens: 0,
3968
- output_tokens: -prev.output_tokens,
3969
- reasoning_output_tokens: 0,
3970
- total_tokens: -(prev.input_tokens + prev.output_tokens),
3971
- conversation_count: -1,
3972
- });
3973
- touchedBuckets.add(bucketKey("kiro", prev.model, prev.bucketStart));
3974
- }
3975
-
3976
- // Add the new contribution.
3977
- if (approxInput > 0 || approxOutput > 0) {
3978
- const bucket = getHourlyBucket(hourlyState, "kiro", model, bucketStart);
3979
- addTotals(bucket.totals, {
3980
- input_tokens: approxInput,
3981
- cached_input_tokens: 0,
3982
- cache_creation_input_tokens: 0,
3983
- output_tokens: approxOutput,
3984
- reasoning_output_tokens: 0,
3985
- total_tokens: approxInput + approxOutput,
3986
- conversation_count: 1,
3987
- });
3988
- touchedBuckets.add(bucketKey("kiro", model, bucketStart));
3989
- eventsAggregated++;
3990
- }
3991
-
3992
- // Always record the cursor entry (even for zero-token requests) so we
3993
- // don't re-count later if Kiro rewrites this request with real data.
3994
- // Bug-2: tag session-origin entries with session_id so the retraction
3995
- // pass can identify them regardless of request_id format (the
3996
- // no-loop_id fallback produces a bare UUID with no colon, which would
3997
- // otherwise be indistinguishable from SQLite's UUID keys).
3998
- requestState[requestId] = {
4082
+ // `requests` is an ARRAY per turn while `message_id` plausibly identifies
4083
+ // the TURN, so two distinct requests can share one identity. Last-write-
4084
+ // wins would silently halve them, and an undercount here is unrecoverable.
4085
+ // Accumulate instead, keeping each underlying request's own share so a
4086
+ // repeated row replaces rather than adds. Cross-tier duplicates of the
4087
+ // same turn are already removed by the turn-granular filter above, so
4088
+ // whatever still shares an identity here is genuinely distinct work.
4089
+ //
4090
+ // This is safe whether or not kiro-cli actually emits shared message_ids:
4091
+ // with one request per turn it reduces to the single-entry case exactly.
4092
+ const partKey = r.request_id || r.message_id;
4093
+ if (!desiredRequests[requestId]) seenRequests += 1;
4094
+ // The pre-watermark cursor keyed `request_id || message_id`; this one keys
4095
+ // `message_id || request_id`. Carry the other key so adoption can probe
4096
+ // both without it every legacy entry misses and adoption sees an empty
4097
+ // cursor.
4098
+ const altKey = r.request_id && r.message_id
4099
+ ? (requestId === r.message_id ? r.request_id : r.message_id)
4100
+ : null;
4101
+ const entry = desiredRequests[requestId] || {
4102
+ altKey,
4103
+ parts: new Map(),
4104
+ input_tokens: 0,
4105
+ output_tokens: 0,
4106
+ };
4107
+ entry.parts.set(partKey, { input: approxInput, output: approxOutput });
4108
+ let sumInput = 0;
4109
+ let sumOutput = 0;
4110
+ for (const part of entry.parts.values()) {
4111
+ sumInput += part.input;
4112
+ sumOutput += part.output;
4113
+ }
4114
+ desiredRequests[requestId] = {
4115
+ ...entry,
4116
+ altKey: entry.altKey || altKey,
3999
4117
  fingerprint,
4000
4118
  bucketStart,
4001
4119
  model,
4002
- input_tokens: approxInput,
4003
- output_tokens: approxOutput,
4120
+ input_tokens: sumInput,
4121
+ output_tokens: sumOutput,
4004
4122
  ...(r.session_id ? { session_id: r.session_id } : {}),
4005
4123
  };
4006
4124
 
@@ -4009,23 +4127,152 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
4009
4127
  index: i + 1,
4010
4128
  total: flat.length,
4011
4129
  recordsProcessed,
4012
- eventsAggregated,
4130
+ // Deltas are applied after this loop; report requests seen so far.
4131
+ // Counter, not Object.keys — this reader has no age limit, so
4132
+ // recomputing it every 50 records is quadratic in total history.
4133
+ eventsAggregated: seenRequests,
4013
4134
  bucketsQueued: touchedBuckets.size,
4014
4135
  });
4015
4136
  }
4016
4137
  }
4017
4138
 
4018
- const cappedState = clampAndCapKiroCliState({
4019
- requestState,
4020
- hourlyState,
4021
- touchedBuckets,
4022
- });
4139
+ // ── Apply max(0, current - watermark), per request ───────────────────────
4140
+ // `requestState` IS the watermark: the highest contribution this parser has
4141
+ // ever applied for each request id. It is never pruned (INTENT rule 1).
4142
+ const watermarkEra = kiroCliState.watermarkVersion === KIRO_WATERMARK_VERSION;
4143
+ const hasPriorState = Object.keys(kiroCliState).length > 0;
4144
+
4145
+ // A cursor written by a NEWER version must be left alone rather than
4146
+ // silently rewritten down to this schema.
4147
+ if (
4148
+ typeof kiroCliState.watermarkVersion === "number" &&
4149
+ kiroCliState.watermarkVersion > KIRO_WATERMARK_VERSION
4150
+ ) {
4151
+ return { recordsProcessed, eventsAggregated: 0, bucketsQueued: 0 };
4152
+ }
4153
+
4154
+ // Adoption: a pre-watermark cursor's tokens are already in the hourly
4155
+ // buckets — inflated, because that is #65. Seed each request's watermark
4156
+ // from what we see now and apply no delta, so the existing total is frozen
4157
+ // rather than added to a second time. This freezes prior inflation; it does
4158
+ // not repair it, because repair would need the CLI's share of a shared kiro
4159
+ // bucket and the hourly state does not record that. Say so in the release
4160
+ // notes. Requests arriving AFTER adoption are absent from the watermark and
4161
+ // are therefore counted in full.
4162
+ // Adoption is decided PER REQUEST, never as one flag for the whole sync. A
4163
+ // single `adopting` latch froze every request it saw — including ones that
4164
+ // had never reached a bucket — and permanently lost all work created in the
4165
+ // upgrade window.
4166
+ //
4167
+ // For a pre-watermark cursor the question "was this request already added?"
4168
+ // has three answers, and the old cursor's own prune policy resolves the
4169
+ // ambiguous one:
4170
+ // • a prior record exists (under EITHER key scheme — the old cursor keyed
4171
+ // `request_id || message_id`, this one keys `message_id || request_id`,
4172
+ // so both must be probed or every entry misses) -> already counted, and
4173
+ // its recorded value is the floor, so later growth still lands;
4174
+ // • no record, and the bucket is OLDER than the old age cap -> it was
4175
+ // pruned, i.e. counted but forgotten -> freeze it;
4176
+ // • no record, and the bucket is NEWER than the cap -> the old cursor
4177
+ // would still be holding it if it had ever been counted, so it is
4178
+ // genuinely new -> count it in full.
4179
+ const migrating = hasPriorState && !watermarkEra;
4180
+ const legacyPruneCutoffMs = Date.now() - KIRO_CLI_CURSOR_MAX_AGE_MS;
4181
+ const priorFor = (want, requestId) => {
4182
+ const direct = requestState[requestId];
4183
+ if (direct) return direct;
4184
+ // Probe the other key scheme so a pre-watermark cursor is not misread as
4185
+ // empty.
4186
+ const alt = want.altKey ? requestState[want.altKey] : null;
4187
+ return alt || null;
4188
+ };
4189
+ const presumedCounted = (want) => {
4190
+ if (!migrating) return false;
4191
+ const ts = Date.parse(want.bucketStart);
4192
+ return Number.isFinite(ts) && ts < legacyPruneCutoffMs;
4193
+ };
4194
+
4195
+ let eventsAggregated = 0;
4196
+ let adoptionComplete = true;
4197
+ for (const [requestId, want] of Object.entries(desiredRequests)) {
4198
+ const prior = priorFor(want, requestId);
4199
+ const frozen = !prior && presumedCounted(want);
4200
+ if (!prior && migrating && !frozen && !dbRead.ok) {
4201
+ // A degraded read during the migration sync cannot tell "new" from
4202
+ // "the DB just did not report it", so adoption is not finished.
4203
+ adoptionComplete = false;
4204
+ }
4205
+
4206
+ const floorInput = frozen
4207
+ ? want.input_tokens
4208
+ : prior
4209
+ ? kiroWatermarkFloor(prior, "input_tokens", want.input_tokens)
4210
+ : 0;
4211
+ const floorOutput = frozen
4212
+ ? want.output_tokens
4213
+ : prior
4214
+ ? kiroWatermarkFloor(prior, "output_tokens", want.output_tokens)
4215
+ : 0;
4216
+
4217
+ const dInput = Math.max(0, want.input_tokens - floorInput);
4218
+ const dOutput = Math.max(0, want.output_tokens - floorOutput);
4219
+
4220
+ // `counted` is RECORDED, not inferred. Inferring it from `!prior` meant a
4221
+ // request first seen with zero tokens never contributed its conversation
4222
+ // when it later grew — Kiro rewriting a request with real data is normal.
4223
+ const alreadyCounted = prior ? prior.counted === true : frozen;
4224
+ const dConv = !alreadyCounted && (dInput > 0 || dOutput > 0) ? 1 : 0;
4225
+
4226
+ requestState[requestId] = {
4227
+ fingerprint: want.fingerprint,
4228
+ bucketStart: want.bucketStart,
4229
+ model: want.model,
4230
+ input_tokens: Math.max(floorInput, want.input_tokens),
4231
+ output_tokens: Math.max(floorOutput, want.output_tokens),
4232
+ counted: alreadyCounted || dConv === 1,
4233
+ ...(want.session_id ? { session_id: want.session_id } : {}),
4234
+ };
4235
+
4236
+ if (!dInput && !dOutput && !dConv) continue;
4237
+
4238
+ const bucket = getHourlyBucket(hourlyState, "kiro", want.model, want.bucketStart);
4239
+ addTotals(bucket.totals, {
4240
+ input_tokens: dInput,
4241
+ cached_input_tokens: 0,
4242
+ cache_creation_input_tokens: 0,
4243
+ output_tokens: dOutput,
4244
+ reasoning_output_tokens: 0,
4245
+ total_tokens: dInput + dOutput,
4246
+ conversation_count: dConv,
4247
+ });
4248
+ touchedBuckets.add(bucketKey("kiro", want.model, want.bucketStart));
4249
+ eventsAggregated += 1;
4250
+ }
4251
+
4252
+ // Clamp only; the cap is deliberately NOT applied (INTENT rule 1).
4253
+ for (const key of touchedBuckets) {
4254
+ const bucket = hourlyState.buckets && hourlyState.buckets[key];
4255
+ if (bucket && bucket.totals && bucket.totals.conversation_count < 0) {
4256
+ bucket.totals.conversation_count = 0;
4257
+ }
4258
+ }
4023
4259
 
4024
4260
  const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
4025
4261
  const updatedAt = new Date().toISOString();
4026
4262
  hourlyState.updatedAt = updatedAt;
4027
4263
  cursors.hourly = hourlyState;
4028
- cursors.kiroCli = { ...kiroCliState, requests: cappedState, updatedAt };
4264
+ cursors.kiroCli = {
4265
+ ...kiroCliState,
4266
+ requests: requestState,
4267
+ // Stamp only when this sync could actually complete the migration. The
4268
+ // early-return path applies the same caution; applying it there and not
4269
+ // here let one degraded read end migration prematurely and re-count the
4270
+ // whole corpus on the next healthy sync.
4271
+ ...(!migrating || adoptionComplete
4272
+ ? { watermarkVersion: KIRO_WATERMARK_VERSION }
4273
+ : {}),
4274
+ updatedAt,
4275
+ };
4029
4276
 
4030
4277
  return { recordsProcessed, eventsAggregated, bucketsQueued };
4031
4278
  }
@@ -4042,7 +4289,8 @@ function clampAndCapKiroCliState({ requestState, hourlyState, touchedBuckets })
4042
4289
  // TASK-010: clamp conversation_count to >= 0 on Kiro-touched buckets
4043
4290
  // only. The shared enqueueTouchedBuckets is left untouched so
4044
4291
  // legitimate negatives from the 10 other parsers are not masked. Kiro
4045
- // negatives come from the subtract-old pass on mutation or retraction.
4292
+ // negatives can only come from the other kiro writers now; this parser
4293
+ // never emits one (#65).
4046
4294
  for (const key of touchedBuckets) {
4047
4295
  const bucket = hourlyState.buckets && hourlyState.buckets[key];
4048
4296
  if (bucket && bucket.totals && bucket.totals.conversation_count < 0) {