claude-code-kanban 4.28.0 → 4.30.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.
package/lib/parsers.js CHANGED
@@ -1,7 +1,8 @@
1
- const fs = require('fs');
1
+ const fs = require('node:fs');
2
2
  const { readFileSync, existsSync, readdirSync, statSync } = fs;
3
- const path = require('path');
4
- const { StringDecoder } = require('string_decoder');
3
+ const path = require('node:path');
4
+ const os = require('node:os');
5
+ const { StringDecoder } = require('node:string_decoder');
5
6
 
6
7
  function parseTask(raw) {
7
8
  const task = typeof raw === 'string' ? JSON.parse(raw) : raw;
@@ -12,7 +13,7 @@ function parseTask(raw) {
12
13
  status: task.status,
13
14
  blocks: task.blocks || [],
14
15
  blockedBy: task.blockedBy || [],
15
- isInternal: !!(task.metadata && task.metadata._internal),
16
+ isInternal: !!task.metadata?._internal,
16
17
  raw: task
17
18
  };
18
19
  }
@@ -152,12 +153,12 @@ function formatTaskUsage(usage) {
152
153
  const parts = [];
153
154
  if (usage.subagentTokens != null) {
154
155
  const t = usage.subagentTokens;
155
- const tok = t >= 1000 ? (t / 1000).toFixed(1).replace(/\.0$/, '') + 'k' : String(t);
156
- parts.push(tok + ' tok');
156
+ const tok = t >= 1000 ? `${(t / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(t);
157
+ parts.push(`${tok} tok`);
157
158
  }
158
159
  if (usage.toolUses != null) parts.push(usage.toolUses + (usage.toolUses === 1 ? ' tool' : ' tools'));
159
- if (usage.durationMs != null) parts.push(Math.round(usage.durationMs / 1000) + 's');
160
- return parts.length ? ' · ' + parts.join(' · ') : '';
160
+ if (usage.durationMs != null) parts.push(`${Math.round(usage.durationMs / 1000)}s`);
161
+ return parts.length ? ` · ${parts.join(' · ')}` : '';
161
162
  }
162
163
 
163
164
  // Raw per-agent cost for the agent modal's chips plus the formatted one-liner for
@@ -196,7 +197,7 @@ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
196
197
  const truncated = safeText.length > USER_TEXT_MAX;
197
198
  const msg = {
198
199
  type: 'user',
199
- text: truncated ? safeText.slice(0, USER_TEXT_MAX) + '...' : safeText,
200
+ text: truncated ? `${safeText.slice(0, USER_TEXT_MAX)}...` : safeText,
200
201
  fullText: truncated ? safeText : null,
201
202
  timestamp,
202
203
  ...(label && { systemLabel: label })
@@ -214,8 +215,8 @@ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
214
215
  }
215
216
  if (extras) {
216
217
  if (extras.uuid) msg.uuid = extras.uuid;
217
- if (extras.images && extras.images.length) msg.images = extras.images;
218
- if (extras.toolResultRefs && extras.toolResultRefs.length) msg.toolResultRefs = extras.toolResultRefs;
218
+ if (extras.images?.length) msg.images = extras.images;
219
+ if (extras.toolResultRefs?.length) msg.toolResultRefs = extras.toolResultRefs;
219
220
  if (extras.queued) msg.queued = true;
220
221
  }
221
222
  // One agent message reaches the log twice: the queued enqueue record and the
@@ -231,27 +232,37 @@ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
231
232
  messages.push(msg);
232
233
  }
233
234
 
234
- // Cache: jsonlPath -> { scannedUpTo, customTitle }
235
+ // Cache: jsonlPath -> { ino, mtimeMs, scannedUpTo, customTitle }
235
236
  // Only re-scan the new bytes appended since last scan
236
237
  const customTitleCache = new Map();
238
+ let sessionCacheDirty = false;
239
+
240
+ // An entry scanned up to `scannedUpTo` still describes the file when the file is the same one
241
+ // and has only grown. A same-size file with a new mtime was rewritten in place. The entries
242
+ // outlive the process (exportSessionCaches), so this is the only check between two runs.
243
+ function sameFileGrown(cached, stat) {
244
+ if (!cached || cached.ino !== stat.ino || stat.size < cached.scannedUpTo) return false;
245
+ return stat.size > cached.scannedUpTo || cached.mtimeMs === stat.mtimeMs;
246
+ }
237
247
  const CUSTOM_TITLE_SCAN_SIZE = 1048576; // 1MB max scan on first read
238
248
 
239
249
  // Returns the best title found, in priority order:
240
250
  // custom-title (user override) > ai-title > agent-name
241
251
  // Background-session JSONLs created by the "claude agents" feature only emit
242
252
  // ai-title/agent-name records; older sessions emit custom-title.
253
+ const TITLE_MARKER_TEXT = ['"custom-title"', '"ai-title"', '"agent-name"'];
254
+ const TITLE_MARKERS = TITLE_MARKER_TEXT.map((m) => Buffer.from(m));
255
+ const hasTitleMarker = (s) => TITLE_MARKER_TEXT.some((m) => s.includes(m));
256
+
243
257
  function extractCustomTitleFromText(text) {
244
- const hasCustom = text.includes('"custom-title"');
245
- const hasAi = text.includes('"ai-title"');
246
- const hasAgent = text.includes('"agent-name"');
247
- if (!hasCustom && !hasAi && !hasAgent) return null;
258
+ if (!hasTitleMarker(text)) return null;
248
259
  const lines = text.split('\n');
249
260
  let customTitle = null;
250
261
  let aiTitle = null;
251
262
  let agentName = null;
252
263
  for (let i = lines.length - 1; i >= 0; i--) {
253
264
  const line = lines[i];
254
- if (!line.includes('"custom-title"') && !line.includes('"ai-title"') && !line.includes('"agent-name"')) continue;
265
+ if (!hasTitleMarker(line)) continue;
255
266
  try {
256
267
  const data = JSON.parse(line);
257
268
  if (!customTitle && data.type === 'custom-title' && data.customTitle && !data.customTitle.startsWith('<')) {
@@ -262,17 +273,37 @@ function extractCustomTitleFromText(text) {
262
273
  agentName = data.agentName;
263
274
  }
264
275
  if (customTitle) break;
265
- } catch (e) {}
276
+ } catch {}
266
277
  }
267
278
  return customTitle || aiTitle || agentName || null;
268
279
  }
269
280
 
281
+ // Same answer as extractCustomTitleFromText(buf.toString('utf8')): that function only looks at
282
+ // lines holding a marker, and a line cut at \n decodes the same alone as inside the chunk.
283
+ function extractCustomTitleFromBuffer(buf) {
284
+ const starts = new Set();
285
+ for (const m of TITLE_MARKERS) {
286
+ for (let i = buf.indexOf(m); i !== -1; i = buf.indexOf(m, i + m.length)) {
287
+ starts.add(buf.lastIndexOf(0x0a, i) + 1);
288
+ }
289
+ }
290
+ if (!starts.size) return null;
291
+ const lines = [...starts]
292
+ .sort((a, b) => a - b)
293
+ .map((s) => {
294
+ const e = buf.indexOf(0x0a, s);
295
+ return buf.toString('utf8', s, e === -1 ? buf.length : e);
296
+ });
297
+ return extractCustomTitleFromText(lines.join('\n'));
298
+ }
299
+
270
300
  function readCustomTitle(jsonlPath, existingStat) {
271
301
  try {
272
302
  const stat = existingStat || statSync(jsonlPath);
273
- const cached = customTitleCache.get(jsonlPath);
303
+ const prev = customTitleCache.get(jsonlPath);
304
+ const cached = sameFileGrown(prev, stat) ? prev : null;
274
305
 
275
- if (cached && cached.scannedUpTo >= stat.size) return cached.customTitle;
306
+ if (cached && cached.scannedUpTo === stat.size) return cached.customTitle;
276
307
 
277
308
  let customTitle = cached?.customTitle || null;
278
309
  const fd = fs.openSync(jsonlPath, 'r');
@@ -282,23 +313,23 @@ function readCustomTitle(jsonlPath, existingStat) {
282
313
  if (len > 0) {
283
314
  const buf = Buffer.alloc(len);
284
315
  fs.readSync(fd, buf, 0, len, cached.scannedUpTo);
285
- customTitle = extractCustomTitleFromText(buf.toString('utf8')) || customTitle;
316
+ customTitle = extractCustomTitleFromBuffer(buf) || customTitle;
286
317
  }
287
318
  } else {
319
+ // The last chunk holding a title wins, so scan from the end and stop at the first hit.
288
320
  const CHUNK = CUSTOM_TITLE_SCAN_SIZE;
289
- for (let offset = 0; offset < stat.size; offset += CHUNK) {
290
- const len = Math.min(CHUNK, stat.size - offset);
291
- const buf = Buffer.alloc(len);
292
- fs.readSync(fd, buf, 0, len, offset);
293
- const found = extractCustomTitleFromText(buf.toString('utf8'));
294
- if (found) customTitle = found;
321
+ const buf = Buffer.allocUnsafe(Math.min(CHUNK, stat.size));
322
+ for (let offset = Math.floor((stat.size - 1) / CHUNK) * CHUNK; offset >= 0 && !customTitle; offset -= CHUNK) {
323
+ const n = fs.readSync(fd, buf, 0, Math.min(CHUNK, stat.size - offset), offset);
324
+ customTitle = extractCustomTitleFromBuffer(buf.subarray(0, n));
295
325
  }
296
326
  }
297
327
 
298
328
  fs.closeSync(fd);
299
- customTitleCache.set(jsonlPath, { scannedUpTo: stat.size, customTitle });
329
+ customTitleCache.set(jsonlPath, { ino: stat.ino, mtimeMs: stat.mtimeMs, scannedUpTo: stat.size, customTitle });
330
+ sessionCacheDirty = true;
300
331
  return customTitle;
301
- } catch (e) {
332
+ } catch {
302
333
  return null;
303
334
  }
304
335
  }
@@ -310,75 +341,18 @@ const SCRAPE_GITBRANCH_RE = /"gitBranch"\s*:\s*"((?:[^"\\]|\\.)*)"/;
310
341
  function scrapeScalarFromBlob(blob, re) {
311
342
  const m = blob.match(re);
312
343
  if (!m) return null;
313
- try { return JSON.parse(`"${m[1]}"`); } catch (e) { return null; }
344
+ try { return JSON.parse(`"${m[1]}"`); } catch { return null; }
314
345
  }
315
346
 
316
347
  const sessionInfoCache = new Map();
317
348
  const SESSION_INFO_CACHE_MAX = 2000;
318
349
 
319
- // Session-scoped /goal: a goal_status attachment carries the active condition
320
- // (latest-wins as the Stop hook re-evaluates). Only an unmet goal is "active" —
321
- // a met goal auto-clears in Claude Code, so treat met as removal, same as a
322
- // `/goal clear` command line. Returns the goal value to apply, or `undefined`
323
- // when the line is not a goal event (caller keeps its current value).
324
- function extractGoalFromLine(data) {
325
- if (data.type === 'attachment' && data.attachment?.type === 'goal_status') {
326
- return data.attachment.met ? null : { condition: data.attachment.condition || '' };
327
- }
328
- // `/goal clear` is a type:system local_command line carrying the command
329
- // markup in `content`; a user-typed command line carries it in message.content.
330
- const content = data.content ?? data.message?.content;
331
- if (typeof content === 'string' && content.includes(GOAL_COMMAND_TAG) && /<command-args>\s*clear/.test(content)) {
332
- return null;
333
- }
334
- return undefined;
335
- }
336
-
337
- const GOAL_COMMAND_TAG = '<command-name>/goal</command-name>';
338
- const GOAL_MARKERS = ['"goal_status"', GOAL_COMMAND_TAG].map((m) => Buffer.from(m));
339
- const SCAN_CHUNK = 65536;
340
- const scanBuf = Buffer.allocUnsafe(SCAN_CHUNK);
341
-
342
- // Streams [from, to) and calls onLine for the complete lines of every chunk
343
- // that contains a marker. Chunks without one are never decoded, which keeps a
344
- // full pass close to raw read speed (measured ~4x cheaper than decode+split).
345
- function scanMarkedLines(fd, from, to, markers, onLine) {
346
- let carry = Buffer.alloc(0);
347
- let offset = from;
348
- while (offset < to) {
349
- const n = fs.readSync(fd, scanBuf, 0, Math.min(SCAN_CHUNK, to - offset), offset);
350
- if (n === 0) break;
351
- offset += n;
352
- const view = carry.length ? Buffer.concat([carry, scanBuf.subarray(0, n)]) : scanBuf.subarray(0, n);
353
- const cut = view.lastIndexOf(0x0a) + 1;
354
- const body = view.subarray(0, cut);
355
- carry = Buffer.from(view.subarray(cut));
356
- if (markers.some((m) => body.includes(m))) for (const line of body.toString('utf8').split('\n')) onLine(line);
357
- }
358
- if (carry.length && markers.some((m) => carry.includes(m))) onLine(carry.toString('utf8'));
359
- }
360
-
361
- // Goal events are rare and can sit anywhere in a long JSONL, outside both the
362
- // head and tail windows. Returns the last goal update in [from, to), or
363
- // undefined when there is none.
364
- function scanGoalInRange(fd, from, to) {
365
- let latest;
366
- scanMarkedLines(fd, from, to, GOAL_MARKERS, (line) => {
367
- if (!line.includes('"goal_status"') && !line.includes(GOAL_COMMAND_TAG)) return;
368
- try {
369
- const update = extractGoalFromLine(JSON.parse(line));
370
- if (update !== undefined) latest = update;
371
- } catch (_) {}
372
- });
373
- return latest;
374
- }
375
-
376
350
  // gitBranch in the JSONL is pinned to the launch-time repo by Claude Code
377
351
  // and goes stale once cwd shifts (Bash `cd`, submodule). Callers needing the
378
352
  // live branch must resolve it from cwd separately. Cache is reset on inode
379
353
  // change or truncation (size < scannedUpTo).
380
354
  function readSessionInfoFromJsonl(jsonlPath) {
381
- const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null, goal: null };
355
+ const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null };
382
356
  let stat;
383
357
  let fd;
384
358
  try {
@@ -388,9 +362,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
388
362
  }
389
363
 
390
364
  const cached = sessionInfoCache.get(jsonlPath);
391
- const inodeMatch = cached && cached.ino === stat.ino;
392
- const sizeOK = cached && stat.size >= cached.scannedUpTo;
393
- const canIncrement = inodeMatch && sizeOK;
365
+ const canIncrement = sameFileGrown(cached, stat);
394
366
 
395
367
  if (canIncrement && stat.size === cached.scannedUpTo) {
396
368
  return {
@@ -400,7 +372,6 @@ function readSessionInfoFromJsonl(jsonlPath) {
400
372
  gitBranch: cached.gitBranch,
401
373
  logicalParentUuid: cached.logicalParentUuid || null,
402
374
  compactBoundaryUuid: cached.compactBoundaryUuid || null,
403
- goal: cached.goal || null,
404
375
  customTitle: readCustomTitle(jsonlPath, stat)
405
376
  };
406
377
  }
@@ -412,7 +383,6 @@ function readSessionInfoFromJsonl(jsonlPath) {
412
383
  result.gitBranch = cached.gitBranch;
413
384
  result.logicalParentUuid = cached.logicalParentUuid || null;
414
385
  result.compactBoundaryUuid = cached.compactBoundaryUuid || null;
415
- result.goal = cached.goal || null;
416
386
  }
417
387
 
418
388
  let lastCwdSeen = result.cwd;
@@ -432,10 +402,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
432
402
  // its presence in the parent JSONL to tell the two apart.
433
403
  result.compactBoundaryUuid = data.uuid || null;
434
404
  }
435
- // Forward → last wins.
436
- const goalUpdate = extractGoalFromLine(data);
437
- if (goalUpdate !== undefined) result.goal = goalUpdate;
438
- } catch (e) {}
405
+ } catch {}
439
406
  };
440
407
 
441
408
  const CHUNK_SIZE = 16384;
@@ -454,12 +421,6 @@ function readSessionInfoFromJsonl(jsonlPath) {
454
421
  if (deltaLen > 0) {
455
422
  const readLen = Math.min(deltaLen, DELTA_MAX);
456
423
  const readStart = stat.size - readLen;
457
- // The bytes an over-cap delta skips may hold a goal set, met or clear;
458
- // scanned first so the delta's own lines win by order.
459
- if (deltaLen > DELTA_MAX) {
460
- const skipped = scanGoalInRange(fd, cached.scannedUpTo, readStart);
461
- if (skipped !== undefined) result.goal = skipped;
462
- }
463
424
  const buf = Buffer.alloc(readLen);
464
425
  const n = fs.readSync(fd, buf, 0, readLen, readStart);
465
426
  if (n > 0) {
@@ -505,9 +466,6 @@ function readSessionInfoFromJsonl(jsonlPath) {
505
466
  const tn = fs.readSync(fd, tailBuf, 0, TAIL_SIZE, tailStart);
506
467
  const lines = tailBuf.toString('utf8', 0, tn).split('\n');
507
468
  let latestTailCwd = null;
508
- // undefined = no goal event seen in tail (keep head's); otherwise the
509
- // most recent tail goal/clear supersedes head (tail bytes are later).
510
- let tailGoal;
511
469
  for (let i = lines.length - 1; i >= 0; i--) {
512
470
  try {
513
471
  const data = JSON.parse(lines[i]);
@@ -515,22 +473,16 @@ function readSessionInfoFromJsonl(jsonlPath) {
515
473
  if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
516
474
  if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
517
475
  if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
518
- if (tailGoal === undefined) tailGoal = extractGoalFromLine(data);
519
- if (latestTailCwd && tailGoal !== undefined && result.slug && result.projectPath && result.gitBranch) break;
520
- } catch (e) {}
476
+ if (latestTailCwd && result.slug && result.projectPath && result.gitBranch) break;
477
+ } catch {}
521
478
  }
522
479
  if (latestTailCwd) lastCwdSeen = latestTailCwd;
523
- // A goal can be set, met or cleared between the head and tail windows.
524
- // The scan starts at the head's last partial line and runs to EOF, so a
525
- // tail event is simply seen again in order.
526
- const goal = tailGoal !== undefined ? tailGoal : scanGoalInRange(fd, offset - Buffer.byteLength(leftover), stat.size);
527
- if (goal !== undefined) result.goal = goal;
528
480
  }
529
481
  scannedUpTo = stat.size;
530
482
  }
531
- } catch (e) {
483
+ } catch {
532
484
  } finally {
533
- if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
485
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
534
486
  }
535
487
 
536
488
  result.cwd = lastCwdSeen;
@@ -538,25 +490,59 @@ function readSessionInfoFromJsonl(jsonlPath) {
538
490
  if (stat) {
539
491
  sessionInfoCache.set(jsonlPath, {
540
492
  ino: stat.ino,
541
- size: stat.size,
493
+ mtimeMs: stat.mtimeMs,
494
+ birthtimeMs: stat.birthtimeMs,
542
495
  scannedUpTo,
543
496
  slug: result.slug,
544
497
  projectPath: result.projectPath,
545
498
  gitBranch: result.gitBranch,
546
499
  cwd: result.cwd,
547
500
  logicalParentUuid: result.logicalParentUuid,
548
- compactBoundaryUuid: result.compactBoundaryUuid,
549
- goal: result.goal
501
+ compactBoundaryUuid: result.compactBoundaryUuid
550
502
  });
551
503
  if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
552
504
  const firstKey = sessionInfoCache.keys().next().value;
553
505
  sessionInfoCache.delete(firstKey);
554
506
  }
507
+ sessionCacheDirty = true;
555
508
  }
556
509
  result.customTitle = readCustomTitle(jsonlPath, stat);
557
510
  return result;
558
511
  }
559
512
 
513
+ // Counts the transcripts of one project folder that were read since start (or loaded from the
514
+ // session cache). Entries of deleted files stay until the next save, so a deletion does not
515
+ // lower the count.
516
+ function countTranscriptsBornBefore(dir, birthtimeMs) {
517
+ let n = 0;
518
+ for (const [p, e] of sessionInfoCache) if (e.birthtimeMs < birthtimeMs && path.dirname(p) === dir) n++;
519
+ return n;
520
+ }
521
+
522
+ const sessionCachesDirty = () => sessionCacheDirty;
523
+
524
+ // Snapshot of the per-transcript caches for lib/session-cache.js. Entries of deleted
525
+ // transcripts are left out, so the snapshot never holds more than the live set.
526
+ function exportSessionCaches() {
527
+ sessionCacheDirty = false;
528
+ const livePaths = new Set([...sessionInfoCache.keys(), ...customTitleCache.keys()].filter((p) => fs.existsSync(p)));
529
+ const live = (map) => [...map].filter(([p]) => livePaths.has(p));
530
+ return { info: live(sessionInfoCache), titles: live(customTitleCache) };
531
+ }
532
+
533
+ function importSessionCaches(snapshot) {
534
+ const valid = (e) => e && typeof e === 'object' && typeof e.ino === 'number' && typeof e.scannedUpTo === 'number' && typeof e.mtimeMs === 'number';
535
+ const load = (map, entries) => {
536
+ if (!Array.isArray(entries)) return;
537
+ for (const pair of entries) {
538
+ if (!Array.isArray(pair) || typeof pair[0] !== 'string' || !valid(pair[1])) continue;
539
+ map.set(pair[0], pair[1]);
540
+ }
541
+ };
542
+ load(sessionInfoCache, snapshot?.info?.slice(-SESSION_INFO_CACHE_MAX));
543
+ load(customTitleCache, snapshot?.titles?.slice(-SESSION_INFO_CACHE_MAX));
544
+ }
545
+
560
546
  // Background-task completion records arrive as a user message whose content is a
561
547
  // <task-notification> envelope. Rendering the raw text leaks the tag values as a
562
548
  // run-on line (task-id + tool-use-id + output-file path) plus the <usage> numbers
@@ -674,7 +660,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
674
660
  let fd;
675
661
  try {
676
662
  const stat = statSync(jsonlPath);
677
- fd = require('fs').openSync(jsonlPath, 'r');
663
+ fd = fs.openSync(jsonlPath, 'r');
678
664
  const messages = [];
679
665
  const toolResults = new Map();
680
666
  const toolResultExtras = new Map();
@@ -686,7 +672,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
686
672
  const start = Math.max(0, stat.size - readSize);
687
673
  const bufSize = readSize;
688
674
  const buf = Buffer.alloc(bufSize);
689
- require('fs').readSync(fd, buf, 0, bufSize, start);
675
+ fs.readSync(fd, buf, 0, bufSize, start);
690
676
 
691
677
  const text = buf.toString('utf8');
692
678
  const firstNewline = text.indexOf('\n');
@@ -706,7 +692,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
706
692
  const truncated = block.text.length > 500;
707
693
  messages.push({
708
694
  type: 'assistant',
709
- text: truncated ? block.text.slice(0, 500) + '...' : block.text,
695
+ text: truncated ? `${block.text.slice(0, 500)}...` : block.text,
710
696
  fullText: truncated ? block.text : null,
711
697
  timestamp: obj.timestamp,
712
698
  model: obj.message.model || null
@@ -718,33 +704,33 @@ function readRecentMessages(jsonlPath, limit = 10) {
718
704
  if (block.input) {
719
705
  inp = typeof block.input === 'string' ? (() => { try { return JSON.parse(block.input); } catch(_) { return {}; } })() : block.input;
720
706
  if (inp.file_path) { detail = inp.file_path.replace(/^.*[/\\]/, ''); fullDetail = inp.file_path; }
721
- else if (inp.command) { detail = inp.command.length > 80 ? inp.command.slice(0, 80) + '...' : inp.command; fullDetail = inp.command; }
707
+ else if (inp.command) { detail = inp.command.length > 80 ? `${inp.command.slice(0, 80)}...` : inp.command; fullDetail = inp.command; }
722
708
  else if (inp.pattern) { detail = inp.pattern; fullDetail = inp.pattern; }
723
709
  else if (inp.query) { detail = inp.query; fullDetail = inp.query; }
724
- else if (inp.url) { detail = inp.url.length > 80 ? inp.url.slice(0, 80) + '...' : inp.url; fullDetail = inp.url; }
725
- else if (inp.skill) { const s = inp.skill + (typeof inp.args === 'string' ? ' ' + inp.args : ''); detail = s.length > 80 ? s.slice(0, 80) + '...' : s; fullDetail = s; }
710
+ else if (inp.url) { detail = inp.url.length > 80 ? `${inp.url.slice(0, 80)}...` : inp.url; fullDetail = inp.url; }
711
+ else if (inp.skill) { const s = inp.skill + (typeof inp.args === 'string' ? ` ${inp.args}` : ''); detail = s.length > 80 ? `${s.slice(0, 80)}...` : s; fullDetail = s; }
726
712
  else if (inp.questions && Array.isArray(inp.questions)) {
727
- const parts = inp.questions.map(q => (q.header ? q.header + ': ' : '') + q.question);
713
+ const parts = inp.questions.map(q => (q.header ? `${q.header}: ` : '') + q.question);
728
714
  const s = parts.join(' | ');
729
- detail = s.length > 80 ? s.slice(0, 80) + '...' : s;
715
+ detail = s.length > 80 ? `${s.slice(0, 80)}...` : s;
730
716
  fullDetail = inp.questions.map(q => {
731
- let text = (q.header ? '[' + q.header + '] ' : '') + q.question;
732
- if (q.options) text += '\n\n' + q.options.map((o, j) => ' ' + (j + 1) + '. ' + o.label + (o.description ? ' — ' + o.description : '')).join('\n');
717
+ let text = (q.header ? `[${q.header}] ` : '') + q.question;
718
+ if (q.options) text += `\n\n${q.options.map((o, j) => ` ${j + 1}. ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n')}`;
733
719
  return text;
734
720
  }).join('\n\n');
735
721
  }
736
722
  else if (inp.to) {
737
723
  const proto = inp.message && typeof inp.message === 'object' ? inp.message : null;
738
724
  if (proto?.type === 'shutdown_request') {
739
- detail = '→ ' + inp.to + ': shutdown request' + (proto.reason ? ' (' + proto.reason + ')' : '');
725
+ detail = `→ ${inp.to}: shutdown request${proto.reason ? ` (${proto.reason})` : ''}`;
740
726
  } else if (proto?.type === 'shutdown_response') {
741
- detail = '→ ' + inp.to + ': ' + (proto.approve ? 'shutdown approved' : 'shutdown rejected');
727
+ detail = `→ ${inp.to}: ${proto.approve ? 'shutdown approved' : 'shutdown rejected'}`;
742
728
  } else if (proto?.type === 'plan_approval_response') {
743
- detail = '→ ' + inp.to + ': ' + (proto.approve ? 'plan approved' : 'plan rejected');
729
+ detail = `→ ${inp.to}: ${proto.approve ? 'plan approved' : 'plan rejected'}`;
744
730
  } else {
745
- detail = '→ ' + inp.to + (inp.summary ? ': ' + inp.summary : '');
731
+ detail = `→ ${inp.to}${inp.summary ? `: ${inp.summary}` : ''}`;
746
732
  }
747
- if (detail.length > 80) detail = detail.slice(0, 80) + '...';
733
+ if (detail.length > 80) detail = `${detail.slice(0, 80)}...`;
748
734
  fullDetail = typeof inp.message === 'string' ? inp.message : JSON.stringify(inp.message);
749
735
  }
750
736
  else if (inp.plan) {
@@ -765,7 +751,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
765
751
  if (inp.file_path) params.file_path = inp.file_path;
766
752
  if (inp.content) {
767
753
  if (inp.content.length > TOOL_RESULT_MAX) {
768
- params.content = inp.content.slice(0, TOOL_RESULT_MAX) + '\n... (truncated)';
754
+ params.content = `${inp.content.slice(0, TOOL_RESULT_MAX)}\n... (truncated)`;
769
755
  params.contentFull = inp.content;
770
756
  } else {
771
757
  params.content = inp.content;
@@ -807,7 +793,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
807
793
  } else if (block.name === 'TaskCreate') {
808
794
  if (inp.subject) params.subject = inp.subject;
809
795
  } else if (block.name === 'TaskUpdate') {
810
- if (inp.taskId) params.taskId = '#' + inp.taskId;
796
+ if (inp.taskId) params.taskId = `#${inp.taskId}`;
811
797
  if (inp.status) params.status = inp.status;
812
798
  } else if (block.name === 'NotebookEdit') {
813
799
  if (inp.command) params.command = inp.command;
@@ -833,8 +819,8 @@ function readRecentMessages(jsonlPath, limit = 10) {
833
819
  for (const [k, v] of Object.entries(inp)) {
834
820
  if (k === 'description' || v == null) continue;
835
821
  if (typeof v === 'string' && v.length > TOOL_RESULT_MAX) {
836
- params[k] = v.slice(0, TOOL_RESULT_MAX) + '\n... (truncated)';
837
- params[k + 'Full'] = v;
822
+ params[k] = `${v.slice(0, TOOL_RESULT_MAX)}\n... (truncated)`;
823
+ params[`${k}Full`] = v;
838
824
  } else {
839
825
  params[k] = v;
840
826
  }
@@ -896,7 +882,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
896
882
  if (tmMatch) {
897
883
  const attrs = tmMatch[1];
898
884
  const body = tmMatch[2].trim();
899
- const getAttr = (name) => (attrs.match(new RegExp(name + '="([^"]*)"')) || [])[1] || null;
885
+ const getAttr = (name) => (attrs.match(new RegExp(`${name}="([^"]*)"`)) || [])[1] || null;
900
886
  const tid = getAttr('teammate_id');
901
887
  const color = getAttr('color');
902
888
  const summary = getAttr('summary');
@@ -931,7 +917,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
931
917
  protocolType: protocol?.type || null,
932
918
  protocolLabel,
933
919
  protocolData: protocol || null,
934
- text: isProtocol ? null : (truncated ? body.slice(0, 500) + '...' : body),
920
+ text: isProtocol ? null : (truncated ? `${body.slice(0, 500)}...` : body),
935
921
  fullText: isProtocol ? null : (truncated ? body : null),
936
922
  timestamp: obj.timestamp
937
923
  });
@@ -1023,7 +1009,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
1023
1009
  pushUserMessage(messages, text, obj.timestamp, null, { queued: true, images: refs });
1024
1010
  }
1025
1011
  }
1026
- } catch (e) { /* partial line */ }
1012
+ } catch { /* partial line */ }
1027
1013
  }
1028
1014
 
1029
1015
  if (readSize >= stat.size) break;
@@ -1040,7 +1026,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
1040
1026
  const full = toolResults.get(msg.toolUseId);
1041
1027
  const truncated = full.length > TOOL_RESULT_MAX;
1042
1028
  if (truncated) {
1043
- msg.toolResult = full.slice(0, TOOL_RESULT_MAX) + '\n... (truncated)';
1029
+ msg.toolResult = `${full.slice(0, TOOL_RESULT_MAX)}\n... (truncated)`;
1044
1030
  msg.toolResultFull = full;
1045
1031
  } else {
1046
1032
  msg.toolResult = full;
@@ -1057,7 +1043,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
1057
1043
  }
1058
1044
  }
1059
1045
 
1060
- require('fs').closeSync(fd);
1046
+ fs.closeSync(fd);
1061
1047
  fd = null;
1062
1048
  messages.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
1063
1049
  for (let i = messages.length - 1; i > 0; i--) {
@@ -1072,8 +1058,8 @@ function readRecentMessages(jsonlPath, limit = 10) {
1072
1058
  }
1073
1059
  }
1074
1060
  return messages.slice(-limit);
1075
- } catch (e) {
1076
- if (fd) try { require('fs').closeSync(fd); } catch (_) {}
1061
+ } catch {
1062
+ if (fd) try { fs.closeSync(fd); } catch (_) {}
1077
1063
  return [];
1078
1064
  }
1079
1065
  }
@@ -1172,7 +1158,7 @@ function readCachedImage(sessionId, n, claudeDir) {
1172
1158
  const idx = Number(n);
1173
1159
  if (!sessionId || !/^[A-Za-z0-9._-]+$/.test(sessionId)) return null;
1174
1160
  if (!Number.isInteger(idx) || idx < 0) return null;
1175
- const baseDir = claudeDir || path.join(require('os').homedir(), '.claude');
1161
+ const baseDir = claudeDir || path.join(os.homedir(), '.claude');
1176
1162
  const dir = path.join(baseDir, 'image-cache', sessionId);
1177
1163
  try {
1178
1164
  const match = readdirSync(dir).find((f) => {
@@ -1266,8 +1252,8 @@ function buildSessionDigest(jsonlPath) {
1266
1252
  if (!map[key]) {
1267
1253
  let prompt = null;
1268
1254
  const promptMatch = promptRe.exec(line);
1269
- if (promptMatch && promptMatch[1]) {
1270
- try { prompt = JSON.parse('"' + promptMatch[1] + '"'); } catch (_) { prompt = promptMatch[1]; }
1255
+ if (promptMatch?.[1]) {
1256
+ try { prompt = JSON.parse(`"${promptMatch[1]}"`); } catch (_) { prompt = promptMatch[1]; }
1271
1257
  }
1272
1258
  map[key] = { agentId: agentMatch[1], prompt };
1273
1259
  }
@@ -1578,14 +1564,13 @@ function extractModelFromTranscript(jsonlPath) {
1578
1564
  const bytesRead = readSync(fd, buf, 0, CHUNK, null);
1579
1565
  if (bytesRead === 0) break;
1580
1566
  accumulated += buf.toString('utf8', 0, bytesRead);
1581
- let nlIdx;
1582
- while ((nlIdx = accumulated.indexOf('\n')) !== -1) {
1567
+ for (let nlIdx = accumulated.indexOf('\n'); nlIdx !== -1; nlIdx = accumulated.indexOf('\n')) {
1583
1568
  const line = accumulated.slice(0, nlIdx);
1584
1569
  accumulated = accumulated.slice(nlIdx + 1);
1585
1570
  if (!line.trim()) continue;
1586
1571
  try {
1587
1572
  const obj = JSON.parse(line);
1588
- const model = obj.model || (obj.message && obj.message.model);
1573
+ const model = obj.model || obj.message?.model;
1589
1574
  if (model) return model;
1590
1575
  } catch (_) {}
1591
1576
  }
@@ -1631,8 +1616,8 @@ function formatStructuredResult(input) {
1631
1616
  const parts = [];
1632
1617
  for (const [key, value] of Object.entries(input)) {
1633
1618
  if (value == null || value === '') continue;
1634
- parts.push('### ' + key);
1635
- parts.push(typeof value === 'string' ? value : '```json\n' + JSON.stringify(value, null, 2) + '\n```');
1619
+ parts.push(`### ${key}`);
1620
+ parts.push(typeof value === 'string' ? value : `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``);
1636
1621
  }
1637
1622
  return parts.length ? parts.join('\n\n') : null;
1638
1623
  }
@@ -1684,12 +1669,21 @@ const EMPTY_LOOP_STATE = () => ({
1684
1669
  taskIdByToolUseId: new Map(), deletedTaskIds: new Set()
1685
1670
  });
1686
1671
 
1672
+ const LOOP_TOOL_TEXT = ['"ScheduleWakeup"', '"CronCreate"', '"CronDelete"'];
1673
+ const LOOP_TOOL_MARKERS = LOOP_TOOL_TEXT.map((m) => Buffer.from(m));
1674
+
1675
+ // Only a CronCreate result is ever read back from taskIdByToolUseId, and it is written after
1676
+ // its tool_use, so other tool results (most lines of a transcript) are not parsed.
1677
+ // `hay` is a line or a Buffer; both have includes.
1678
+ const hasPendingCronId = (hay, state) =>
1679
+ state.crons.some((c) => c.id && !state.taskIdByToolUseId.has(c.id) && hay.includes(c.id));
1680
+
1681
+ const wakeupFireMs = (w) => Date.parse(w.timestamp) + (w.delaySeconds ?? 0) * 1000;
1682
+
1687
1683
  function processLoopLine(line, state) {
1688
1684
  if (!line) return;
1689
- const hasToolResult = line.includes('"tool_use_id"');
1690
- const hasLoopTool = line.includes('"ScheduleWakeup"')
1691
- || line.includes('"CronCreate"')
1692
- || line.includes('"CronDelete"');
1685
+ const hasToolResult = line.includes('"tool_use_id"') && hasPendingCronId(line, state);
1686
+ const hasLoopTool = LOOP_TOOL_TEXT.some((m) => line.includes(m));
1693
1687
  if (!hasToolResult && !hasLoopTool) return;
1694
1688
  let obj;
1695
1689
  try { obj = JSON.parse(line); } catch (_) { return; }
@@ -1703,6 +1697,11 @@ function processLoopLine(line, state) {
1703
1697
  } else if (b.type === 'tool_use') {
1704
1698
  const inp = b.input || {};
1705
1699
  if (b.name === 'ScheduleWakeup') {
1700
+ // The harness keeps one pending wakeup per session: a newer call replaces it and
1701
+ // stop cancels it. Wakeups that already fired stay as history.
1702
+ const at = Date.parse(obj.timestamp);
1703
+ state.wakeups = state.wakeups.filter((w) => wakeupFireMs(w) <= at);
1704
+ if (inp.stop) continue;
1706
1705
  state.wakeups.push({
1707
1706
  id: b.id || null,
1708
1707
  timestamp: obj.timestamp || null,
@@ -1727,6 +1726,12 @@ function processLoopLine(line, state) {
1727
1726
  }
1728
1727
  }
1729
1728
 
1729
+ // True when processLoopLine could act on some line in bytes: a loop tool call, or the result of a
1730
+ // CronCreate whose task id is still unknown. Most appended bytes hold neither and are never decoded.
1731
+ function hasLoopMarker(bytes, state) {
1732
+ return LOOP_TOOL_MARKERS.some((m) => bytes.includes(m)) || hasPendingCronId(bytes, state);
1733
+ }
1734
+
1730
1735
  // Reads bytes appended to jsonlPath since prev.scannedOffset and merges into
1731
1736
  // state. Pass `null` (or undefined) for prev to do a one-time full scan.
1732
1737
  function updateLoopInfo(jsonlPath, prev) {
@@ -1751,19 +1756,17 @@ function updateLoopInfo(jsonlPath, prev) {
1751
1756
  try {
1752
1757
  fd = fs.openSync(jsonlPath, 'r');
1753
1758
  const len = stat.size - state.scannedOffset;
1754
- const buf = Buffer.alloc(len);
1759
+ const buf = Buffer.allocUnsafe(len);
1755
1760
  const n = fs.readSync(fd, buf, 0, len, state.scannedOffset);
1756
- const text = buf.toString('utf8', 0, n);
1757
- const lastNl = text.lastIndexOf('\n');
1758
- if (lastNl < 0) {
1759
- // No complete line yet — leave scannedOffset alone, update mtime only.
1760
- state.mtimeMs = stat.mtimeMs;
1761
- return state;
1762
- }
1763
- const complete = text.slice(0, lastNl);
1764
- for (const line of complete.split('\n')) processLoopLine(line, state);
1765
- state.scannedOffset += Buffer.byteLength(complete, 'utf8') + 1;
1766
1761
  state.mtimeMs = stat.mtimeMs;
1762
+ // No complete line yet — leave scannedOffset alone.
1763
+ const lastNl = buf.subarray(0, n).lastIndexOf(0x0a);
1764
+ if (lastNl < 0) return state;
1765
+ const complete = buf.subarray(0, lastNl);
1766
+ if (hasLoopMarker(complete, state)) {
1767
+ for (const line of complete.toString('utf8').split('\n')) processLoopLine(line, state);
1768
+ }
1769
+ state.scannedOffset += lastNl + 1;
1767
1770
  state.size = stat.size;
1768
1771
  } catch (_) {
1769
1772
  // leave state as-is
@@ -1798,6 +1801,10 @@ module.exports = {
1798
1801
  parseAgentMessage,
1799
1802
  getSystemMessageLabel,
1800
1803
  readSessionInfoFromJsonl,
1804
+ exportSessionCaches,
1805
+ importSessionCaches,
1806
+ sessionCachesDirty,
1807
+ countTranscriptsBornBefore,
1801
1808
  readRecentMessages,
1802
1809
  readMessagesPage,
1803
1810
  readFullToolResult,