claude-code-runrate 0.2.3 → 0.3.0

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.
@@ -87,7 +87,11 @@ function parseEvents(input) {
87
87
  const meta = { sessionId: /** @type {string|null} */(null), cwd: /** @type {string|null} */(null), gitBranch: /** @type {string|null} */(null), version: /** @type {string|null} */(null), startTs: /** @type {number|null} */(null), lastTs: /** @type {number|null} */(null) };
88
88
  /** @type {{ ts: number|null, kind: 'tool'|'cmd', tool: string, arg: string }[]} */
89
89
  const events = [];
90
- const tools = /** @type {Record<string, number>} */ ({});
90
+ // Null-prototype: keys are tool NAMES straight out of the transcript. On a
91
+ // plain object, `tools['constructor'] || 0` reads the inherited Object
92
+ // constructor (which then rendered as its native source in the feed header),
93
+ // and `tools['__proto__'] = n` performs a prototype write instead of counting.
94
+ const tools = /** @type {Record<string, number>} */ (Object.create(null));
91
95
  const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
92
96
  const files = new Set();
93
97
  const models = new Set();
@@ -111,7 +115,7 @@ function parseEvents(input) {
111
115
  if (meta.sessionId == null && o.sessionId) meta.sessionId = stripControl(String(o.sessionId));
112
116
  if (meta.cwd == null && o.cwd) meta.cwd = stripControl(String(o.cwd));
113
117
  if (meta.gitBranch == null && o.gitBranch) meta.gitBranch = stripControl(String(o.gitBranch));
114
- if (meta.version == null && o.version) meta.version = o.version;
118
+ if (meta.version == null && o.version) meta.version = stripControl(o.version);
115
119
  const ts = o.timestamp ? Date.parse(o.timestamp) : NaN;
116
120
  const tsOk = Number.isFinite(ts) ? ts : null;
117
121
  if (tsOk != null) {
@@ -137,7 +141,10 @@ function parseEvents(input) {
137
141
  if (o.type === 'assistant') {
138
142
  assistantTurns++;
139
143
  const msg = o.message || {};
140
- if (msg.model) { models.add(msg.model); lastModel = msg.model; }
144
+ // Sanitize here, not at the renderer: parseEvents is the documented choke
145
+ // point "so every renderer is covered", and lastModel/models are consumed
146
+ // by src/render/resume.js outside the sidecar's graph.
147
+ if (msg.model) { const m = stripControl(msg.model); models.add(m); lastModel = m; }
141
148
  const u = msg.usage;
142
149
  if (u) {
143
150
  const turn = { input: u.input_tokens || 0, output: u.output_tokens || 0, cacheRead: u.cache_read_input_tokens || 0, cacheCreate: u.cache_creation_input_tokens || 0 };
@@ -223,36 +230,45 @@ const MAX_READ = 4 * 1024 * 1024; // bound one tick's allocation; large backlogs
223
230
  * transcript is append-only; if it shrank (rotation/truncation) we restart at 0.
224
231
  * Reads at most `maxRead` bytes per call (bounded allocation), so a very large
225
232
  * transcript is consumed over several ticks rather than in one giant buffer.
226
- * Returns the new byte offset (advanced only past whole lines).
233
+ * Returns the new byte offset (advanced only past whole lines), and `restarted`
234
+ * — true when the file had shrunk and the tail went back to 0. Callers that
235
+ * ACCUMULATE across calls must reset their totals when they see it, or they
236
+ * re-add everything the restart is about to replay.
227
237
  * @param {string} file
228
238
  * @param {number} [fromOffset]
229
239
  * @param {number} [maxRead]
230
- * @returns {{ offset: number, lines: string[] }}
240
+ * @returns {{ offset: number, lines: string[], restarted: boolean }}
231
241
  */
232
242
  function readNewLines(file, fromOffset = 0, maxRead = MAX_READ) {
233
- let st; try { st = fs.statSync(file); } catch { return { offset: fromOffset, lines: [] }; }
243
+ let st; try { st = fs.statSync(file); } catch { return { offset: fromOffset, lines: [], restarted: false }; }
234
244
  let start = fromOffset;
235
- if (st.size < start) start = 0; // truncated/rotated → restart
245
+ const restarted = st.size < start; // truncated/rotated → restart
246
+ if (restarted) start = 0;
236
247
  let len = st.size - start;
237
- if (len <= 0) return { offset: st.size, lines: [] };
248
+ if (len <= 0) return { offset: st.size, lines: [], restarted };
238
249
  const capped = len > maxRead; // more data than one window holds
239
250
  if (capped) len = maxRead;
240
251
  const fd = fs.openSync(file, 'r');
241
252
  try {
242
253
  const buf = Buffer.alloc(len);
243
- fs.readSync(fd, buf, 0, len, start);
244
- const text = buf.toString('utf8');
245
- const lastNl = text.lastIndexOf('\n');
254
+ const read = fs.readSync(fd, buf, 0, len, start);
255
+ // Find the line break in the BYTES, and advance by bytes. Decoding first and
256
+ // re-encoding the kept prefix (what this used to do) is not a round trip:
257
+ // every invalid UTF-8 byte decodes to U+FFFD and re-encodes to THREE bytes,
258
+ // so the offset overshot the file — after which `st.size < start` reads as a
259
+ // truncation, the tail restarts at 0, and the whole transcript is re-ingested
260
+ // every tick, forever, with the stats inflating each time. One stray binary
261
+ // byte in a tool result was enough. Byte arithmetic has no such failure.
262
+ const lastNl = buf.lastIndexOf(0x0a, read - 1);
246
263
  if (lastNl < 0) {
247
264
  // No complete line in this window. If capped, the current line is longer
248
265
  // than the cap — skip past the window to guarantee forward progress (the
249
266
  // resulting partial line fails JSON.parse and is tolerated). Otherwise the
250
267
  // last line just isn't finished yet; wait for more.
251
- return capped ? { offset: start + len, lines: [] } : { offset: start, lines: [] };
268
+ return capped ? { offset: start + len, lines: [], restarted } : { offset: start, lines: [], restarted };
252
269
  }
253
- const whole = text.slice(0, lastNl);
254
- const consumed = start + Buffer.byteLength(whole, 'utf8') + 1; // +1 for the newline
255
- return { offset: consumed, lines: whole.split('\n').filter(Boolean) };
270
+ const text = buf.subarray(0, lastNl).toString('utf8');
271
+ return { offset: start + lastNl + 1, lines: text.split('\n').filter(Boolean), restarted };
256
272
  } finally {
257
273
  fs.closeSync(fd);
258
274
  }