claude-code-kanban 6.1.0 → 6.1.2

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/cli.js CHANGED
@@ -747,6 +747,7 @@ async function runSessionViewCli(args) {
747
747
  return 1;
748
748
  }
749
749
  const asJson = args.includes('--json');
750
+ const rateLimits = asJson ? null : cliGetJson('/api/rate-limits', 'Rate limits').catch(() => ({}));
750
751
  const resolved = await resolveSessionByIdOrPrefix(idArg);
751
752
  if (!resolved) return 1;
752
753
  let list;
@@ -775,7 +776,6 @@ async function runSessionViewCli(args) {
775
776
  if (ctx) {
776
777
  const cw = ctx.context_window || {};
777
778
  const cost = ctx.cost || {};
778
- const rl = ctx.rate_limits || {};
779
779
  const modelName = ctx.model?.display_name || ctx.model?.id || '-';
780
780
  const modelExtras = [
781
781
  ctx.effort?.level,
@@ -790,9 +790,11 @@ async function runSessionViewCli(args) {
790
790
  ? ` · ${cost.total_api_duration_ms != null ? formatAge(cost.total_api_duration_ms) : '-'} api / ${formatAge(cost.total_duration_ms)} total · +${cost.total_lines_added || 0}/-${cost.total_lines_removed || 0}`
791
791
  : '';
792
792
  lines.push(` Cost: ${fmtCost(cost.total_cost_usd)}${timing}`);
793
- if (rl.five_hour || rl.seven_day) {
794
- lines.push(` Limits: 5h ${rl.five_hour?.used_percentage ?? '-'}% · 7d ${rl.seven_day?.used_percentage ?? '-'}%`);
795
- }
793
+ }
794
+ // Account-wide, so not read from this session's file, which can be days old.
795
+ const rl = await rateLimits;
796
+ if (rl.five_hour || rl.seven_day) {
797
+ lines.push(` Limits: 5h ${rl.five_hour?.used_percentage ?? '-'}% · 7d ${rl.seven_day?.used_percentage ?? '-'}%`);
796
798
  }
797
799
  console.log(lines.join('\n'));
798
800
  return 0;
package/lib/parsers.js CHANGED
@@ -373,20 +373,50 @@ const modeOf = (data) => {
373
373
  return (data.type === 'user' || data.type === 'permission-mode') && data.permissionMode || null;
374
374
  };
375
375
 
376
+ const isMainReply = (data) =>
377
+ data.type === 'assistant' && !data.isSidechain && data.message?.usage && data.message.model !== '<synthetic>';
378
+
376
379
  // The TTL of the last prompt-cache write, from the API's 5m/1h split of cache_creation.
377
380
  const cacheTtlOf = (data) => {
378
- const cc = data.type === 'assistant' && !data.isSidechain && data.message?.usage?.cache_creation;
381
+ const cc = isMainReply(data) && data.message.usage.cache_creation;
379
382
  if (!cc) return null;
380
383
  return cc.ephemeral_1h_input_tokens > 0 ? '1h' : cc.ephemeral_5m_input_tokens > 0 ? '5m' : null;
381
384
  };
382
385
 
386
+ // Copy of displayName in the plugin's hooks/context.ts; Node cannot import the mod.
387
+ function modelDisplayName(model) {
388
+ const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2})(?!\d))?/.exec(model || '');
389
+ if (!m) return model || null;
390
+ return `${m[1][0].toUpperCase()}${m[1].slice(1)} ${m[3] ? `${m[2]}.${m[3]}` : m[2]}`;
391
+ }
392
+
393
+ const isMainUser = (data) => data.type === 'user' && !data.isSidechain && data.timestamp;
394
+
395
+ // `at` is when the request went out: the reply's own lines land seconds to tens of seconds later,
396
+ // which would make the cache countdown run long.
397
+ const replyOf = (data, requestTs) => {
398
+ const u = data.message.usage;
399
+ return {
400
+ at: Date.parse(requestTs || data.timestamp) || null,
401
+ model: data.message.model || null,
402
+ usage: {
403
+ input_tokens: u.input_tokens || 0,
404
+ cache_read_input_tokens: u.cache_read_input_tokens || 0,
405
+ cache_creation_input_tokens: u.cache_creation_input_tokens || 0,
406
+ output_tokens: u.output_tokens || 0,
407
+ },
408
+ };
409
+ };
410
+ // After a compaction the old usage overstates what the next request writes.
411
+ const withoutUsage = (reply) => reply && { ...reply, usage: null };
412
+
383
413
  // gitBranch in the JSONL is pinned to the launch-time repo by Claude Code
384
414
  // and goes stale once cwd shifts (Bash `cd`, submodule). Callers needing the
385
415
  // live branch must resolve it from cwd separately. Cache is reset on inode
386
416
  // change or truncation (size < scannedUpTo).
387
417
 
388
418
  function readSessionInfoFromJsonl(jsonlPath) {
389
- const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null, permissionMode: null, cacheTtl: null };
419
+ const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null, permissionMode: null, cacheTtl: null, lastReply: null };
390
420
  let stat;
391
421
  let fd;
392
422
  try {
@@ -408,6 +438,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
408
438
  compactBoundaryUuid: cached.compactBoundaryUuid || null,
409
439
  permissionMode: cached.permissionMode || null,
410
440
  cacheTtl: cached.cacheTtl || null,
441
+ lastReply: cached.lastReply || null,
411
442
  customTitle: readCustomTitle(jsonlPath, stat)
412
443
  };
413
444
  }
@@ -421,9 +452,11 @@ function readSessionInfoFromJsonl(jsonlPath) {
421
452
  result.compactBoundaryUuid = cached.compactBoundaryUuid || null;
422
453
  result.permissionMode = cached.permissionMode || null;
423
454
  result.cacheTtl = cached.cacheTtl || null;
455
+ result.lastReply = cached.lastReply || null;
424
456
  }
425
457
 
426
458
  let lastCwdSeen = result.cwd;
459
+ let lastUserTs = canIncrement ? cached.lastUserTs || null : null;
427
460
  const applyLine = (line) => {
428
461
  try {
429
462
  const data = JSON.parse(line);
@@ -437,6 +470,9 @@ function readSessionInfoFromJsonl(jsonlPath) {
437
470
  if (mode) result.permissionMode = mode;
438
471
  const ttl = cacheTtlOf(data);
439
472
  if (ttl) result.cacheTtl = ttl;
473
+ if (isMainUser(data)) lastUserTs = data.timestamp;
474
+ else if (isMainReply(data)) result.lastReply = replyOf(data, lastUserTs);
475
+ if (data.subtype === 'compact_boundary') result.lastReply = withoutUsage(result.lastReply);
440
476
  if (data.subtype === 'compact_boundary' && data.logicalParentUuid && !result.logicalParentUuid) {
441
477
  result.logicalParentUuid = data.logicalParentUuid;
442
478
  // The boundary record's own uuid: a fork copies it verbatim from the
@@ -450,6 +486,8 @@ function readSessionInfoFromJsonl(jsonlPath) {
450
486
  const CHUNK_SIZE = 16384;
451
487
  const TAIL_SIZE = 16384;
452
488
  const HEAD_MAX = 1048576;
489
+ const BACK_CHUNK = 65536;
490
+ const BACK_MAX = 2097152;
453
491
  let scannedUpTo = canIncrement ? cached.scannedUpTo : 0;
454
492
 
455
493
  try {
@@ -502,29 +540,66 @@ function readSessionInfoFromJsonl(jsonlPath) {
502
540
 
503
541
  // Tail scan catches late cwd switches past HEAD_MAX. projectPath stays
504
542
  // anchored to the earliest cwd — it is never overwritten here.
543
+ // The first window is TAIL_SIZE. A tail of one long line (11% of transcripts over 1 MB)
544
+ // holds no reply, so it reads further back in BACK_CHUNK steps until it finds one.
505
545
  if (stat.size > offset) {
506
- const tailStart = Math.max(offset, stat.size - TAIL_SIZE);
507
- const tailBuf = Buffer.alloc(TAIL_SIZE);
508
- const tn = fs.readSync(fd, tailBuf, 0, TAIL_SIZE, tailStart);
509
- const lines = tailBuf.toString('utf8', 0, tn).split('\n');
510
546
  let latestTailCwd = null;
511
547
  let latestTailMode = null;
512
548
  let latestTailTtl = null;
513
- for (let i = lines.length - 1; i >= 0; i--) {
514
- try {
515
- const data = JSON.parse(lines[i]);
516
- if (!latestTailTtl) latestTailTtl = cacheTtlOf(data);
517
- if (!result.slug && data.slug) result.slug = data.slug;
518
- if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
519
- if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
520
- if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
521
- if (!latestTailMode) latestTailMode = modeOf(data);
522
- if (latestTailCwd && latestTailMode && latestTailTtl && result.slug && result.projectPath && result.gitBranch) break;
523
- } catch {}
549
+ let tailReply = null;
550
+ let tailRequestTs = null;
551
+ let compactedAfterReply = false;
552
+ let latestTailUserTs = null;
553
+ // Filled from the end; [start, end) holds read bytes not parsed yet.
554
+ const window = Buffer.allocUnsafe(Math.min(BACK_MAX, stat.size - offset));
555
+ let start = window.length;
556
+ let end = window.length;
557
+ let pos = stat.size;
558
+ while (!(tailReply && tailRequestTs) && start > 0) {
559
+ const len = Math.min(pos === stat.size ? TAIL_SIZE : BACK_CHUNK, start);
560
+ start -= len;
561
+ pos -= len;
562
+ if (fs.readSync(fd, window, start, len, pos) < len) break;
563
+ let from = start;
564
+ if (pos > offset) {
565
+ // Bytes before the first newline may be the end of a line that starts further back.
566
+ const nl = window.subarray(start, start + len).indexOf(10);
567
+ if (nl < 0) continue;
568
+ from = start + nl + 1;
569
+ }
570
+ const lines = window.toString('utf8', from, end).split('\n');
571
+ end = from - 1;
572
+ for (let i = lines.length - 1; i >= 0; i--) {
573
+ try {
574
+ const data = JSON.parse(lines[i]);
575
+ if (!latestTailTtl) latestTailTtl = cacheTtlOf(data);
576
+ if (!result.slug && data.slug) result.slug = data.slug;
577
+ if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
578
+ if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
579
+ if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
580
+ if (!latestTailMode) latestTailMode = modeOf(data);
581
+ if (!latestTailUserTs && isMainUser(data)) latestTailUserTs = data.timestamp;
582
+ if (!tailReply) {
583
+ if (data.subtype === 'compact_boundary') compactedAfterReply = true;
584
+ else if (isMainReply(data)) tailReply = data;
585
+ } else if (!tailRequestTs && isMainUser(data)) tailRequestTs = data.timestamp;
586
+ if (latestTailCwd && latestTailMode && latestTailTtl && tailRequestTs && result.slug && result.projectPath && result.gitBranch) break;
587
+ } catch {}
588
+ }
524
589
  }
590
+ const reachedHead = pos <= offset;
525
591
  if (latestTailCwd) lastCwdSeen = latestTailCwd;
526
592
  if (latestTailMode) result.permissionMode = latestTailMode;
527
593
  if (latestTailTtl) result.cacheTtl = latestTailTtl;
594
+ if (latestTailUserTs || !reachedHead) lastUserTs = latestTailUserTs;
595
+ if (tailReply) {
596
+ const reply = replyOf(tailReply, tailRequestTs);
597
+ result.lastReply = compactedAfterReply ? withoutUsage(reply) : reply;
598
+ } else if (!reachedHead) {
599
+ // No reply in the last BACK_MAX bytes: the head's reply is far older than any TTL.
600
+ result.lastReply = null;
601
+ result.cacheTtl = null;
602
+ }
528
603
  }
529
604
  scannedUpTo = stat.size;
530
605
  }
@@ -548,7 +623,9 @@ function readSessionInfoFromJsonl(jsonlPath) {
548
623
  logicalParentUuid: result.logicalParentUuid,
549
624
  compactBoundaryUuid: result.compactBoundaryUuid,
550
625
  permissionMode: result.permissionMode,
551
- cacheTtl: result.cacheTtl
626
+ cacheTtl: result.cacheTtl,
627
+ lastReply: result.lastReply,
628
+ lastUserTs
552
629
  });
553
630
  if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
554
631
  const firstKey = sessionInfoCache.keys().next().value;
@@ -1592,35 +1669,78 @@ function extractPromptFromTranscript(jsonlPath) {
1592
1669
  return null;
1593
1670
  }
1594
1671
 
1672
+ // The last maxBytes of a file as whole lines: a cut first line is dropped.
1673
+ function readTailLines(jsonlPath, maxBytes) {
1674
+ const size = statSync(jsonlPath).size;
1675
+ const readSize = Math.min(maxBytes, size);
1676
+ const start = size - readSize;
1677
+ const buf = Buffer.alloc(readSize);
1678
+ const fd = fs.openSync(jsonlPath, 'r');
1679
+ try {
1680
+ fs.readSync(fd, buf, 0, readSize, start);
1681
+ } finally {
1682
+ fs.closeSync(fd);
1683
+ }
1684
+ const text = buf.toString('utf8');
1685
+ return (start > 0 ? text.slice(text.indexOf('\n') + 1) : text).split('\n');
1686
+ }
1687
+
1688
+ function lineModel(line) {
1689
+ if (!line.trim()) return null;
1690
+ try {
1691
+ const obj = JSON.parse(line);
1692
+ return obj.model || obj.message?.model || null;
1693
+ } catch (_) {
1694
+ return null;
1695
+ }
1696
+ }
1697
+
1595
1698
  function extractModelFromTranscript(jsonlPath) {
1596
1699
  const { openSync, readSync, closeSync } = fs;
1597
1700
  const MAX_READ = 65536;
1598
1701
  const CHUNK = 4096;
1599
1702
  const fd = openSync(jsonlPath, 'r');
1703
+ let size;
1600
1704
  try {
1705
+ size = fs.fstatSync(fd).size;
1601
1706
  let accumulated = '';
1707
+ let total = 0;
1602
1708
  const buf = Buffer.alloc(CHUNK);
1603
- while (accumulated.length < MAX_READ) {
1709
+ while (total < MAX_READ) {
1604
1710
  const bytesRead = readSync(fd, buf, 0, CHUNK, null);
1605
1711
  if (bytesRead === 0) break;
1712
+ total += bytesRead;
1606
1713
  accumulated += buf.toString('utf8', 0, bytesRead);
1607
1714
  for (let nlIdx = accumulated.indexOf('\n'); nlIdx !== -1; nlIdx = accumulated.indexOf('\n')) {
1608
- const line = accumulated.slice(0, nlIdx);
1715
+ const model = lineModel(accumulated.slice(0, nlIdx));
1609
1716
  accumulated = accumulated.slice(nlIdx + 1);
1610
- if (!line.trim()) continue;
1611
- try {
1612
- const obj = JSON.parse(line);
1613
- const model = obj.model || obj.message?.model;
1614
- if (model) return model;
1615
- } catch (_) {}
1717
+ if (model) return model;
1616
1718
  }
1617
1719
  }
1618
1720
  } finally {
1619
1721
  closeSync(fd);
1620
1722
  }
1723
+ // A long prompt or tool result can push the first assistant line past the
1724
+ // head window, so look at the newest lines too.
1725
+ if (size <= MAX_READ) return null;
1726
+ const lines = readTailLines(jsonlPath, MAX_READ);
1727
+ for (let i = lines.length - 1; i >= 0; i--) {
1728
+ const model = lineModel(lines[i]);
1729
+ if (model) return model;
1730
+ }
1621
1731
  return null;
1622
1732
  }
1623
1733
 
1734
+ // The meta file Claude Code writes next to a subagent transcript: agentType,
1735
+ // description, and the model the agent was asked for ("opus", "haiku", "inherit").
1736
+ function readSubagentMeta(jsonlPath) {
1737
+ try {
1738
+ return JSON.parse(readFileSync(jsonlPath.replace(/\.jsonl$/, '.meta.json'), 'utf8'));
1739
+ } catch (_) {
1740
+ return null;
1741
+ }
1742
+ }
1743
+
1624
1744
  // Full-file scan of a subagent transcript for roster stats: model, summed
1625
1745
  // output tokens, and first/last timestamps (for duration). COLD PATH ONLY —
1626
1746
  // used by the workflow run view on demand, never from the session-scan hot
@@ -1680,18 +1800,8 @@ function resultFromBlock(b) {
1680
1800
  }
1681
1801
 
1682
1802
  function extractAgentResultFromTranscript(jsonlPath) {
1683
- const { openSync, readSync, closeSync } = fs;
1684
- const stat = statSync(jsonlPath);
1685
- const readSize = Math.min(1048576, stat.size);
1686
- const start = stat.size - readSize;
1687
- const fd = openSync(jsonlPath, 'r');
1803
+ const lines = readTailLines(jsonlPath, 1048576);
1688
1804
  try {
1689
- const buf = Buffer.alloc(readSize);
1690
- readSync(fd, buf, 0, readSize, start);
1691
- const text = buf.toString('utf8');
1692
- // Drop the leading partial line unless we read from the very start.
1693
- const clean = start > 0 ? text.slice(text.indexOf('\n') + 1) : text;
1694
- const lines = clean.split('\n');
1695
1805
  const lastAssistant = (needle, pick) => {
1696
1806
  for (let i = lines.length - 1; i >= 0; i--) {
1697
1807
  if (!needle(lines[i])) continue;
@@ -1710,10 +1820,7 @@ function extractAgentResultFromTranscript(jsonlPath) {
1710
1820
  (line) => line.includes('"type":"assistant"'),
1711
1821
  (content) => toolResultText(content).trim(),
1712
1822
  );
1713
- } catch (_) {
1714
- } finally {
1715
- closeSync(fd);
1716
- }
1823
+ } catch (_) {}
1717
1824
  return null;
1718
1825
  }
1719
1826
 
@@ -1851,6 +1958,7 @@ function buildLoopInfoFromState(state) {
1851
1958
  }
1852
1959
 
1853
1960
  module.exports = {
1961
+ modelDisplayName,
1854
1962
  parseTask,
1855
1963
  parseAgent,
1856
1964
  parseWaiting,
@@ -1881,6 +1989,7 @@ module.exports = {
1881
1989
  findTerminatedTeammates,
1882
1990
  extractPromptFromTranscript,
1883
1991
  extractModelFromTranscript,
1992
+ readSubagentMeta,
1884
1993
  extractAgentResultFromTranscript,
1885
1994
  extractTranscriptStats,
1886
1995
  readLines
@@ -0,0 +1,21 @@
1
+ // Rate limits belong to the account, so each window comes from the freshest context-status
2
+ // file that has it. A window whose `resets_at` (epoch seconds) has passed is dropped: its
3
+ // percent is from before the reset. A window with no `resets_at` is kept.
4
+
5
+ const WINDOWS = ['five_hour', 'seven_day'];
6
+
7
+ function freshRateLimits(entries, nowSec) {
8
+ const best = {};
9
+ for (const e of entries) {
10
+ for (const w of WINDOWS) {
11
+ const limit = e?.rate_limits?.[w];
12
+ if (limit?.used_percentage == null) continue;
13
+ if (limit.resets_at != null && limit.resets_at <= nowSec) continue;
14
+ const at = e._updatedAt || 0;
15
+ if (!best[w] || at > best[w].at) best[w] = { at, limit };
16
+ }
17
+ }
18
+ return Object.fromEntries(Object.entries(best).map(([w, b]) => [w, b.limit]));
19
+ }
20
+
21
+ module.exports = { freshRateLimits };
package/lib/retention.js CHANGED
@@ -10,6 +10,7 @@ const DEFAULT_CLEANUP_DAYS = 30;
10
10
  // read that gap as a deleted transcript.
11
11
  const GRACE_MS = 60 * 60 * 1000;
12
12
  const MAX_DISPATCHED = 500;
13
+ const MAX_CONTEXT_STATUS = 2000;
13
14
 
14
15
  // Only the config dir's settings: retention is per config dir, not per project.
15
16
  function retentionMs(claudeDir) {
@@ -94,6 +95,22 @@ async function scanTranscripts(projectsDir) {
94
95
  return ids.size ? { ids, dirs } : null;
95
96
  }
96
97
 
98
+ async function expireFiles(dir, names, idOf, opts) {
99
+ let removed = 0;
100
+ const kept = [];
101
+ for (const f of names) {
102
+ const file = path.join(dir, f);
103
+ try {
104
+ const { mtimeMs } = await fs.stat(file);
105
+ if (isExpired(idOf(f), mtimeMs, opts)) {
106
+ await fs.rm(file, { force: true });
107
+ removed++;
108
+ } else kept.push({ file, mtimeMs });
109
+ } catch {}
110
+ }
111
+ return { removed, kept };
112
+ }
113
+
97
114
  // `<dir>/<session id>/<ts>.md`, one folder per session.
98
115
  async function pruneSessionDirs(dir, { known, maxAgeMs, now = Date.now() }) {
99
116
  let removed = 0;
@@ -108,21 +125,46 @@ async function pruneSessionDirs(dir, { known, maxAgeMs, now = Date.now() }) {
108
125
  const sessionDir = path.join(dir, d.name);
109
126
  try {
110
127
  const files = await fs.readdir(sessionDir);
111
- let kept = 0;
112
- for (const f of files) {
113
- const file = path.join(sessionDir, f);
114
- const { mtimeMs } = await fs.stat(file);
115
- if (isExpired(d.name, mtimeMs, { known, maxAgeMs, now })) {
116
- await fs.rm(file, { force: true });
117
- removed++;
118
- } else kept++;
119
- }
128
+ const res = await expireFiles(sessionDir, files, () => d.name, { known, maxAgeMs, now });
129
+ removed += res.removed;
120
130
  // An old mtime means no file was added since the read, so a review written right
121
131
  // now cannot lose its folder.
122
- if (!kept && now - (await fs.stat(sessionDir)).mtimeMs >= GRACE_MS) await fs.rmdir(sessionDir);
132
+ if (!res.kept.length && now - (await fs.stat(sessionDir)).mtimeMs >= GRACE_MS) await fs.rmdir(sessionDir);
123
133
  } catch {}
124
134
  }
125
135
  return removed;
126
136
  }
127
137
 
128
- module.exports = { createDispatchedStore, scanTranscripts, pruneSessionDirs, retentionMs, GRACE_MS, MAX_DISPATCHED, DAY_MS };
138
+ // `<dir>/<session id>.json`, written by the plugin mod. The cap guards users who keep
139
+ // transcripts forever; it drops the oldest by mtime.
140
+ async function pruneContextStatus(dir, { known, maxAgeMs, now = Date.now(), max = MAX_CONTEXT_STATUS }) {
141
+ let names;
142
+ try {
143
+ names = await fs.readdir(dir);
144
+ } catch {
145
+ return 0;
146
+ }
147
+ const jsonNames = names.filter((f) => f.endsWith('.json'));
148
+ let { removed, kept } = await expireFiles(dir, jsonNames, (f) => f.slice(0, -'.json'.length), { known, maxAgeMs, now });
149
+ if (kept.length > max) {
150
+ kept.sort((a, b) => b.mtimeMs - a.mtimeMs);
151
+ for (const { file } of kept.slice(max)) {
152
+ try {
153
+ await fs.rm(file, { force: true });
154
+ removed++;
155
+ } catch {}
156
+ }
157
+ }
158
+ return removed;
159
+ }
160
+
161
+ module.exports = {
162
+ createDispatchedStore,
163
+ scanTranscripts,
164
+ pruneSessionDirs,
165
+ pruneContextStatus,
166
+ retentionMs,
167
+ GRACE_MS,
168
+ MAX_DISPATCHED,
169
+ DAY_MS,
170
+ };
@@ -4,7 +4,7 @@ const { exportParentVerdicts, importParentVerdicts, parentVerdictsDirty } = requ
4
4
  const { exportTaskCounts, importTaskCounts, taskCountsDirty } = require('./task-counts');
5
5
 
6
6
  // Bump when an entry's shape or meaning changes: a file with another version is ignored.
7
- const VERSION = 4;
7
+ const VERSION = 5;
8
8
  // A cache this large is not one cck wrote for a normal set of transcripts. Rebuild it cold.
9
9
  const MAX_BYTES = 8 * 1024 * 1024;
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "6.1.0",
3
+ "version": "6.1.2",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.23.0",
3
+ "version": "2.24.0",
4
4
  "description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, skills to drive the board from a session and to follow it",
5
5
  "experimental": {
6
6
  "monitors": "./monitors.json"
@@ -149,13 +149,14 @@ async function markStopped($: EngineInterface) {
149
149
  }
150
150
 
151
151
  // The name map lets TeammateIdle, which names the teammate, find its agent id.
152
- async function agentStarted($: EngineInterface, agentId: string, type: string, name?: string) {
152
+ async function agentStarted($: EngineInterface, agentId: string, type: string, model: string, name?: string) {
153
153
  const dir = await sessionDir($)
154
154
  const ts = new Date().toISOString()
155
155
  await Promise.all([
156
156
  appendLine($, `${dir}/${agentId}.jsonl`, {
157
157
  agentId,
158
158
  type,
159
+ model,
159
160
  event: 'start',
160
161
  status: 'active',
161
162
  startedAt: ts,
@@ -248,7 +249,7 @@ export const register: Register = on => {
248
249
  })
249
250
  on('agent.spawn', async ($, e, next) => {
250
251
  const result = await next(e)
251
- if (result.agentId) await agentStarted($, result.agentId, e.subagentType, e.name)
252
+ if (result.agentId) await agentStarted($, result.agentId, e.subagentType, result.model, e.name)
252
253
  return result
253
254
  })
254
255
  // An interrupted turn fires no Stop hook, so it does not mark the session unread either.
@@ -42,22 +42,17 @@ function cckDir($: EngineInterface) {
42
42
  return cck
43
43
  }
44
44
 
45
- let lastUsage: ModelUsage | undefined
46
- let lastModel: string | undefined
47
- let lastRequestAt: number | undefined
48
- const lastWritten = new Map<string, string>()
45
+ // Keyed by session id: `/clear` keeps the process and changes the id.
46
+ const sessions = new Map<string, { req?: { usage: ModelUsage; model: string; at: number }; written?: string }>()
49
47
 
50
- async function write($: EngineInterface, m: Measure) {
51
- const [sessionId, model, dir] = await Promise.all([
52
- $.session.id(),
53
- lastModel ?? $.session.model(),
54
- cckDir($),
55
- ])
56
- const text = JSON.stringify(toStatus(model, m, lastUsage, lastRequestAt))
57
- const file = `${dir}/context-status/${sessionId}.json`
58
- if (lastWritten.get(file) === text) return
59
- await $.fs.write(file, text)
60
- lastWritten.set(file, text)
48
+ async function write($: EngineInterface, m: Measure, id?: string) {
49
+ const [sessionId, dir] = await Promise.all([id ?? $.session.id(), cckDir($)])
50
+ const s = sessions.get(sessionId) ?? {}
51
+ const model = s.req?.model ?? (await $.session.model())
52
+ const text = JSON.stringify(toStatus(model, m, s.req?.usage, s.req?.at))
53
+ if (s.written === text) return
54
+ await $.fs.write(`${dir}/context-status/${sessionId}.json`, text)
55
+ sessions.set(sessionId, { ...s, written: text })
61
56
  }
62
57
 
63
58
  export const register: Register = on => {
@@ -65,10 +60,9 @@ export const register: Register = on => {
65
60
  const result = yield* next(e)
66
61
  if (e.agentId === undefined && result.usage) {
67
62
  const { model, ...usage } = result.usage
68
- lastUsage = usage
69
- lastModel = model
70
- lastRequestAt = Date.now()
71
- await write($, await $.session.usage())
63
+ const sessionId = await $.session.id()
64
+ sessions.set(sessionId, { ...sessions.get(sessionId), req: { usage, model, at: Date.now() } })
65
+ await write($, await $.session.usage(), sessionId)
72
66
  }
73
67
  return result
74
68
  })
@@ -77,4 +71,9 @@ export const register: Register = on => {
77
71
  await write($, e)
78
72
  return next(e)
79
73
  })
74
+
75
+ on('session.end', async ($, e, next) => {
76
+ sessions.delete(e.sessionId)
77
+ return next(e)
78
+ })
80
79
  }
@@ -28,6 +28,8 @@ claude-code-kanban help <command> <subcommand> # flags and examples
28
28
  | `view` / `peek` | `session view <id>`, `session peek <id>` |
29
29
  | tasks, projects | `task list`, `project list` |
30
30
 
31
+ `preview` and `link` take markdown (`.md`) and HTML (`.html`). HTML renders as authored in a sandboxed iframe, and cck inlines its local stylesheets, scripts and images, `url()` refs in `<style>` blocks included. To show the user a page, a prototype or a collage of screenshots, write the HTML and preview that file: do not render it to an image first or serve it over HTTP.
32
+
31
33
  To be driven *by* the board instead — card moves arriving as instructions — the user types `/claude-code-kanban:follow`.
32
34
 
33
35
  ## Troubleshooting
@@ -253,7 +253,7 @@ test('a subagent spawn then finish records start and stop', async ($, on) => {
253
253
  await $.turn.complete(turn({ agentId: 'a1', answer: 'Task done with "quotes"' }))
254
254
 
255
255
  const [start, stop] = lines(files[`${SESSION}/a1.jsonl`])
256
- expect(start).toMatchObject({ agentId: 'a1', type: 'general-purpose', event: 'start', status: 'active' })
256
+ expect(start).toMatchObject({ agentId: 'a1', type: 'general-purpose', model: 'haiku', event: 'start', status: 'active' })
257
257
  expect(stop).toMatchObject({ agentId: 'a1', event: 'stop', status: 'stopped', lastMessage: 'Task done with "quotes"' })
258
258
  expect('type' in stop).toBe(false)
259
259
  expect(files[`${SESSION}/_name-general-purpose.id`]).toBe('a1')
@@ -12,14 +12,14 @@ const MEASURE: SessionMeasureInput = {
12
12
  changed: ['context', 'rateLimits', 'cost'],
13
13
  }
14
14
 
15
- function engine(on: On, env: Record<string, string>, sessionId: string) {
15
+ function engine(on: On, env: Record<string, string>, sessionId: string | (() => string)) {
16
16
  const writes: { path: string; text: string }[] = []
17
17
  on('fs.write', (_$, e) => {
18
18
  writes.push({ path: e.path.replaceAll('\\', '/'), text: e.text })
19
19
  return { value: undefined }
20
20
  })
21
21
  on('env.get', (_$, e) => ({ value: env[e.name] }))
22
- on('session.id', () => ({ value: sessionId }))
22
+ on('session.id', () => ({ value: typeof sessionId === 'function' ? sessionId() : sessionId }))
23
23
  on('session.model', () => ({ value: 'claude-opus-5-5' }))
24
24
  on('session.measure', (_$, e) => ({ changed: [...e.changed] }))
25
25
  return writes
@@ -53,6 +53,37 @@ test('session.measure writes no cache entry before the first request', async ($,
53
53
  expect(JSON.parse(writes[0]?.text ?? '{}').cache).toBeUndefined()
54
54
  })
55
55
 
56
+ const USAGE = { input_tokens: 2, output_tokens: 100, cache_read_input_tokens: 5000, cache_creation_input_tokens: 300 }
57
+
58
+ test('after /clear the new session id does not get the old request', async ($, on) => {
59
+ let sid = 'sid-old'
60
+ const writes = engine(on, { CLAUDE_CONFIG_DIR: 'C:/cfg' }, () => sid)
61
+ on('session.usage', () => ({ value: MEASURE }))
62
+ on('session.end', (_$, e) => ({ sessionId: e.sessionId }))
63
+ on('turn.step', async function* (_$, e) {
64
+ return { turnId: e.turnId, index: e.index, answer: 'ok', toolUses: [], stopReason: 'end_turn', usage: { ...USAGE, model: 'claude-fable-5-1' } }
65
+ })
66
+
67
+ const step = $.turn.step({ turnId: 't1', index: 0, model: 'claude-fable-5-1', messageCount: 1 })
68
+ for await (const _ of step);
69
+ await step.result
70
+ expect(writes.length).toBe(1)
71
+ const old = JSON.parse(writes.at(-1)?.text ?? '{}')
72
+ expect(old.cache?.last_request_at).toBeGreaterThan(0)
73
+ expect(old.context_window.current_usage).toEqual(USAGE)
74
+ expect(old.model.id).toBe('claude-fable-5-1')
75
+
76
+ await $.session.end({ reason: 'clear', sessionId: 'sid-old', resume: {} as never })
77
+ sid = 'sid-new'
78
+ await $.session.measure(MEASURE)
79
+
80
+ expect(writes.at(-1)?.path).toBe('C:/cfg/.cck/context-status/sid-new.json')
81
+ const fresh = JSON.parse(writes.at(-1)?.text ?? '{}')
82
+ expect(fresh.cache).toBeUndefined()
83
+ expect(fresh.context_window.current_usage).toBeNull()
84
+ expect(fresh.model.id).toBe('claude-opus-5-5')
85
+ })
86
+
56
87
  test('toStatus adds the last request time', async () => {
57
88
  const status = toStatus('claude-opus-5-5', MEASURE, undefined, 1_790_000_000_000)
58
89
  expect(status.cache).toEqual({ last_request_at: 1_790_000_000_000 })
package/public/app.js CHANGED
@@ -889,7 +889,12 @@ async function viewAgentLog(agentId) {
889
889
  if (!messagePanelOpen) toggleMessagePanel();
890
890
  const header = document.querySelector('.message-panel-header h3');
891
891
  if (header) {
892
- header.innerHTML = `<span class="agent-log-title"><button class="agent-log-back" onclick="exitAgentLogMode()" title="Back to session log">&larr;</button> ${escapeHtml(agent.type || 'unknown')} <code class="agent-log-id">(${escapeHtml(shortId)})</code></span>`;
892
+ const fullModel = agentModel(agent);
893
+ const model = shortModelName(fullModel);
894
+ const modelHtml = model
895
+ ? `<span class="agent-log-model" title="${escapeHtml(fullModel)}">${escapeHtml(model)}</span> <span class="agent-ident-sep">·</span> `
896
+ : '';
897
+ header.innerHTML = `<span class="agent-log-title"><button class="agent-log-back" onclick="exitAgentLogMode()" title="Back to session log">&larr;</button> ${modelHtml}${escapeHtml(agent.type || 'unknown')} <code class="agent-log-id">(${escapeHtml(shortId)})</code></span>`;
893
898
  }
894
899
  fetchAgentMessages();
895
900
  if (agentLogSSE) {
@@ -3138,15 +3143,12 @@ function renderAgentFooter() {
3138
3143
  : `active · ${formatDuration(elapsed)}`;
3139
3144
  const descText = a.description || '';
3140
3145
  const promptTrimmed = stripAnsi(stripTeammateWrapper((a.prompt || '').trim())).replace(/[\r\n]+/g, ' ');
3141
- const displayText = descText || promptTrimmed;
3142
- const displayTrunc = displayText.length > 60 ? `${displayText.substring(0, 60)}…` : displayText;
3143
- const msgHtml = displayTrunc
3144
- ? `<div class="agent-message" title="${escapeHtml(displayText)}">${escapeHtml(displayTrunc)}</div>`
3145
- : '';
3146
3146
  const rawType = a.type || 'unknown';
3147
3147
  const colonIdx = rawType.indexOf(':');
3148
3148
  const typeNs = colonIdx > 0 ? rawType.substring(0, colonIdx + 1) : '';
3149
3149
  const typeName = colonIdx > 0 ? rawType.substring(colonIdx + 1) : rawType;
3150
+ const displayText = descText || promptTrimmed || typeName;
3151
+ const taskHtml = `<div class="agent-task" title="${escapeHtml(displayText)}">${escapeHtml(displayText)}</div>`;
3150
3152
  const agentNameVal = a.agentName || null;
3151
3153
  const nameColor = agentNameVal ? getOwnerColor(agentNameVal) : null;
3152
3154
  const nameBadgeHtml = nameColor
@@ -3155,15 +3157,16 @@ function renderAgentFooter() {
3155
3157
  const agentColor = resolveNamedColor(a.color);
3156
3158
  const colorStyle = agentColor ? ` style="border-left:3px solid ${agentColor.color}"` : '';
3157
3159
  const selectedClass = a.agentId === currentAgentModalId ? ' selected' : '';
3158
- const cardModel = shortModelName(a.model);
3159
- const modelChipHtml = cardModel
3160
- ? `<span class="agent-card-model" title="${escapeHtml(a.model)}">${escapeHtml(cardModel)}</span>`
3160
+ const fullModel = agentModel(a);
3161
+ const cardModel = shortModelName(fullModel);
3162
+ const modelHtml = cardModel
3163
+ ? `<span class="agent-card-model" title="${escapeHtml(fullModel)}">${escapeHtml(cardModel)}</span><span class="agent-ident-sep">·</span>`
3161
3164
  : '';
3162
3165
  return `<div class="agent-card${selectedClass}" data-agent-id="${escapeHtml(a.agentId)}"${colorStyle} onclick="showAgentModal('${escAttrJs(a.agentId)}')">
3163
3166
  ${agentLogButton(a.agentId, 'agent-card-log-btn')}
3164
- <div class="agent-type-row">${typeNs ? `<span class="agent-type-ns">${escapeHtml(typeNs)}</span>` : ''}<span class="agent-type-name">${escapeHtml(typeName)}</span>${nameBadgeHtml}</div>
3165
- <div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span>${modelChipHtml}</div>
3166
- ${msgHtml}
3167
+ ${taskHtml}
3168
+ <div class="agent-ident-row">${modelHtml}<span class="agent-type-name" title="${escapeHtml(rawType)}">${typeNs ? `<span class="agent-type-ns">${escapeHtml(typeNs)}</span>` : ''}${escapeHtml(typeName)}</span>${nameBadgeHtml}</div>
3169
+ <div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span></div>
3167
3170
  </div>`;
3168
3171
  })
3169
3172
  .join('');
@@ -3266,7 +3269,7 @@ function showAgentModal(agentId) {
3266
3269
  const modalNameLabel = agent.agentName ? ` · ${escapeHtml(agent.agentName)}` : '';
3267
3270
  title.innerHTML = `${statusDot} ${escapeHtml(agent.type || 'unknown')}${modalNameLabel}`;
3268
3271
 
3269
- const shortModel = shortModelName(agent.model);
3272
+ const shortModel = shortModelName(agentModel(agent));
3270
3273
  const shortId = agent.agentId ? agent.agentId.slice(0, 8) : '';
3271
3274
  const chip = (label, value, opts = {}) => {
3272
3275
  const cls = opts.cls ? ` ${opts.cls}` : '';
@@ -8948,10 +8951,10 @@ const INPUT_PRICE_PER_MTOK = [
8948
8951
  function cacheRewrite(usage, modelId, writeRate) {
8949
8952
  const price = INPUT_PRICE_PER_MTOK.find(([prefix]) => modelId?.startsWith(prefix))?.[1];
8950
8953
  const tokens =
8951
- (usage.input_tokens || 0) +
8952
- (usage.cache_read_input_tokens || 0) +
8953
- (usage.cache_creation_input_tokens || 0) +
8954
- (usage.output_tokens || 0);
8954
+ (usage?.input_tokens || 0) +
8955
+ (usage?.cache_read_input_tokens || 0) +
8956
+ (usage?.cache_creation_input_tokens || 0) +
8957
+ (usage?.output_tokens || 0);
8955
8958
  if (!price || !tokens) return null;
8956
8959
  return { tokens, usd: (tokens / 1e6) * price * writeRate };
8957
8960
  }
@@ -8982,47 +8985,48 @@ function tickCacheTimers() {
8982
8985
  }
8983
8986
  }
8984
8987
 
8985
- function renderCacheTimer(lastRequestAt, ttl, usage, modelId) {
8988
+ function renderCacheTimer({ at, ttl, usage, model }) {
8986
8989
  const cache = CACHE_TTL[ttl];
8987
- if (!lastRequestAt || !cache) return '';
8988
- const expiresAt = lastRequestAt + cache.ms;
8989
- const rewrite = cacheRewrite(usage, modelId, cache.write);
8990
+ if (!at || !cache) return '';
8991
+ const expiresAt = at + cache.ms;
8992
+ const rewrite = cacheRewrite(usage, model, cache.write);
8990
8993
  const expiredText = rewrite ? `expired · ~${formatCost(rewrite.usd)}` : 'expired';
8991
8994
  const { text, color } = formatCacheLeft(expiresAt, expiredText);
8992
8995
  cacheTimerInterval ??= setInterval(tickCacheTimers, 1000);
8993
- const at = new Date(expiresAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
8996
+ const clock = new Date(expiresAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
8994
8997
  const cost = rewrite
8995
8998
  ? ` It writes ${formatTokens(rewrite.tokens / 1000)} tokens again, about ${formatCost(rewrite.usd)} at list price.`
8996
8999
  : '';
8997
- const title = `Prompt cache, ${ttl} TTL. Expires at ${at}; the next request after that writes it again.${cost}`;
8998
- return `<div class="stat-item" title="${escapeHtml(title)}"><span class="stat-label">Cache</span><span class="stat-value cache-timer" data-expires="${expiresAt}" data-expired-text="${escapeHtml(expiredText)}" style="color:${color}">${text}</span></div>`;
9000
+ const title = `Prompt cache, ${ttl} TTL. Expires at ${clock}; the next request after that writes it again.${cost}`;
9001
+ return `<div class="stat-item" title="${escapeHtml(title)}"><span class="stat-label">Cache</span><span class="stat-value cache-timer" data-expires="${escapeHtml(expiresAt)}" data-expired-text="${escapeHtml(expiredText)}" style="color:${color}">${text}</span></div>`;
8999
9002
  }
9000
9003
 
9004
+ // The Cache row and the current-usage rows come from the transcript (session.cache); the rest needs the plugin's file.
9001
9005
  function renderContextDetail(session, { tokens = true } = {}) {
9002
9006
  const ctx = getCtx(session);
9003
- if (!ctx) return '';
9004
- const raw = session.contextStatus;
9005
- const totalK = ctx.size / 1000;
9006
- const color = getContextColor(ctx.usedTokens, ctx.modelName);
9007
-
9008
- const cw = raw.context_window || {};
9009
- const usage = cw.current_usage || {};
9010
- const cost = raw.cost || {};
9011
- const tokenRows = tokens
9012
- ? `<div class="stat-item"><span class="stat-label">Cache read</span><span class="stat-value">${formatTokens((usage.cache_read_input_tokens || 0) / 1000)}</span></div>
9013
- <div class="stat-item"><span class="stat-label">Cache write</span><span class="stat-value">${formatTokens((usage.cache_creation_input_tokens || 0) / 1000)}</span></div>
9014
- <div class="stat-item"><span class="stat-label">Current input</span><span class="stat-value">${formatTokens((usage.input_tokens || 0) / 1000)}</span></div>
9015
- <div class="stat-item"><span class="stat-label">Current output</span><span class="stat-value">${formatTokens((usage.output_tokens || 0) / 1000)}</span></div>
9016
- <div class="stat-divider"></div>
9017
- <div class="stat-item"><span class="stat-label">Total input</span><span class="stat-value">${formatTokens(ctx.inputTokens / 1000)}</span></div>
9007
+ const reply = session.cache;
9008
+ if (!ctx && !reply) return '';
9009
+ const usage = reply?.usage;
9010
+ const cost = session.contextStatus?.cost || {};
9011
+ const color = ctx && getContextColor(ctx.usedTokens, ctx.modelName);
9012
+ const title = ctx?.modelName || reply?.modelName || 'Context Window';
9013
+
9014
+ const usageRows =
9015
+ tokens && usage
9016
+ ? `<div class="stat-item"><span class="stat-label">Cache read</span><span class="stat-value">${formatTokens(usage.cache_read_input_tokens / 1000)}</span></div>
9017
+ <div class="stat-item"><span class="stat-label">Cache write</span><span class="stat-value">${formatTokens(usage.cache_creation_input_tokens / 1000)}</span></div>
9018
+ <div class="stat-item"><span class="stat-label">Current input</span><span class="stat-value">${formatTokens(usage.input_tokens / 1000)}</span></div>
9019
+ <div class="stat-item"><span class="stat-label">Current output</span><span class="stat-value">${formatTokens(usage.output_tokens / 1000)}</span></div>
9020
+ <div class="stat-divider"></div>`
9021
+ : '';
9022
+ const totalRows =
9023
+ tokens && ctx
9024
+ ? `<div class="stat-item"><span class="stat-label">Total input</span><span class="stat-value">${formatTokens(ctx.inputTokens / 1000)}</span></div>
9018
9025
  ${ctx.outputTokens ? `<div class="stat-item"><span class="stat-label">Total output</span><span class="stat-value">${formatTokens(ctx.outputTokens / 1000)}</span></div>` : ''}
9019
9026
  <div class="stat-divider"></div>`
9020
- : '';
9021
-
9022
- return `
9023
- <div class="detail-context">
9024
- <div class="detail-context-title">${ctx.modelName ? escapeHtml(ctx.modelName) : 'Context Window'}</div>
9025
- <div class="detail-context-bar">
9027
+ : '';
9028
+ const bar = ctx
9029
+ ? `<div class="detail-context-bar">
9026
9030
  <div class="context-bar-track">
9027
9031
  <div class="context-bar-fill" style="width:${ctx.pct}%;background:${color}"></div>
9028
9032
  ${renderMarkers(ctx.markers)}
@@ -9030,12 +9034,19 @@ function renderContextDetail(session, { tokens = true } = {}) {
9030
9034
  </div>
9031
9035
  <div class="detail-context-summary">
9032
9036
  <span style="color:${color}">${Math.round(ctx.pct)}% used</span>
9033
- <span${ctx.compacts ? ` title="Auto-compact window · model window ${escapeHtml(formatTokens(ctx.modelSize / 1000))}"` : ''}>${formatTokens(ctx.usedTokens / 1000)} / ${formatTokens(totalK)}</span>
9034
- </div>
9037
+ <span${ctx.compacts ? ` title="Auto-compact window · model window ${escapeHtml(formatTokens(ctx.modelSize / 1000))}"` : ''}>${formatTokens(ctx.usedTokens / 1000)} / ${formatTokens(ctx.size / 1000)}</span>
9038
+ </div>`
9039
+ : '';
9040
+
9041
+ return `
9042
+ <div class="detail-context">
9043
+ <div class="detail-context-title">${escapeHtml(title)}</div>
9044
+ ${bar}
9035
9045
  <div class="detail-context-stats">
9036
- ${tokenRows}
9037
- <div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${getCostColor(cost.total_cost_usd)}">${formatCost(cost.total_cost_usd)}</span></div>
9038
- ${renderCacheTimer(raw.cache?.last_request_at, session.cacheTtl, usage, raw.model?.id)}
9046
+ ${usageRows}
9047
+ ${totalRows}
9048
+ ${ctx ? `<div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${getCostColor(cost.total_cost_usd)}">${formatCost(cost.total_cost_usd)}</span></div>` : ''}
9049
+ ${reply ? renderCacheTimer(reply) : ''}
9039
9050
  ${cost.total_duration_ms != null ? `<div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>` : ''}
9040
9051
  ${cost.total_api_duration_ms != null ? `<div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>` : ''}
9041
9052
  ${cost.total_lines_added != null ? `<div class="stat-item"><span class="stat-label">Lines</span><span class="stat-value"><span style="color:${CONTEXT_COLORS.green}">+${(cost.total_lines_added || 0).toLocaleString()}</span> / <span style="color:${CONTEXT_COLORS.red}">-${(cost.total_lines_removed || 0).toLocaleString()}</span></span></div>` : ''}
@@ -10530,9 +10541,10 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
10530
10541
  });
10531
10542
  html += `</div>`;
10532
10543
 
10533
- if (session.contextStatus) {
10544
+ const contextHtml = renderContextDetail(session);
10545
+ if (contextHtml) {
10534
10546
  html += `<hr style="border: none; border-top: 1px solid var(--border); margin: 12px 0;">`;
10535
- html += renderContextDetail(session);
10547
+ html += contextHtml;
10536
10548
  }
10537
10549
 
10538
10550
  if (planContent) {
@@ -11266,6 +11278,10 @@ function shortModelName(model) {
11266
11278
  return model ? model.replace(/^claude-/, '').replace(/-\d{8}$/, '') : '';
11267
11279
  }
11268
11280
 
11281
+ function agentModel(agent) {
11282
+ return agent.model || agent.modelAlias || '';
11283
+ }
11284
+
11269
11285
  function renderWorkflowRun(run) {
11270
11286
  const body = document.getElementById('workflow-modal-body');
11271
11287
  const allDone = run.startedCount > 0 && run.doneCount >= run.startedCount;
@@ -13209,7 +13225,7 @@ msgContentEl.addEventListener('wheel', function (e) {
13209
13225
  }
13210
13226
  });
13211
13227
 
13212
- const footerState = { version: null, plugin: null, limitsKey: null, timer: null };
13228
+ const footerState = { version: null, plugin: null, limitsKey: null, timer: null, resetTimer: null };
13213
13229
  function formatResetIn(epochSec) {
13214
13230
  if (!epochSec) return null;
13215
13231
  const ms = epochSec * 1000 - Date.now();
@@ -13298,14 +13314,10 @@ function refreshRateLimits() {
13298
13314
  if (footerState.timer) return;
13299
13315
  footerState.timer = setTimeout(() => {
13300
13316
  footerState.timer = null;
13301
- fetch('/api/context-status')
13317
+ fetch('/api/rate-limits')
13302
13318
  .then((r) => r.json())
13303
- .then((all) => {
13304
- let freshest = null;
13305
- for (const e of Object.values(all || {})) {
13306
- if (e?.rate_limits && (!freshest || (e._updatedAt || 0) > (freshest._updatedAt || 0))) freshest = e;
13307
- }
13308
- const rl = freshest?.rate_limits || null;
13319
+ .then((rl) => {
13320
+ scheduleRateLimitReset(rl);
13309
13321
  const fh = rl?.five_hour?.used_percentage ?? null;
13310
13322
  const sd = rl?.seven_day?.used_percentage ?? null;
13311
13323
  const key = `${fh}|${sd}`;
@@ -13316,6 +13328,18 @@ function refreshRateLimits() {
13316
13328
  .catch(() => {});
13317
13329
  }, 1500);
13318
13330
  }
13331
+
13332
+ // No context-update comes when a window resets on an idle board, so refetch at the earliest reset.
13333
+ function scheduleRateLimitReset(rl) {
13334
+ clearTimeout(footerState.resetTimer);
13335
+ footerState.resetTimer = null;
13336
+ const resets = Object.values(rl || {})
13337
+ .map((b) => b?.resets_at)
13338
+ .filter((s) => s > 0);
13339
+ if (!resets.length) return;
13340
+ const ms = Math.min(...resets) * 1000 - Date.now() + 1000;
13341
+ footerState.resetTimer = setTimeout(refreshRateLimits, Math.min(Math.max(ms, 0), 2 ** 31 - 1));
13342
+ }
13319
13343
  fetch('/api/version')
13320
13344
  .then((r) => r.json())
13321
13345
  .then((d) => {
package/public/style.css CHANGED
@@ -4407,13 +4407,19 @@ body.booting .boot-splash {
4407
4407
  }
4408
4408
  .agent-log-title {
4409
4409
  display: flex;
4410
- align-items: center;
4410
+ align-items: baseline;
4411
4411
  gap: 6px;
4412
4412
  }
4413
4413
  .agent-log-id {
4414
4414
  font-size: 11px;
4415
4415
  color: var(--text-tertiary);
4416
4416
  }
4417
+ .agent-log-model {
4418
+ font-family: var(--mono);
4419
+ font-size: 12px;
4420
+ font-weight: 600;
4421
+ color: var(--text-primary);
4422
+ }
4417
4423
  .msg-agent-log-btn {
4418
4424
  flex-shrink: 0;
4419
4425
  margin-left: 0;
@@ -4636,7 +4642,8 @@ body.booting .boot-splash {
4636
4642
  border: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
4637
4643
  border-radius: 8px;
4638
4644
  white-space: nowrap;
4639
- min-width: 0;
4645
+ flex-shrink: 0;
4646
+ min-width: 180px;
4640
4647
  overflow: hidden;
4641
4648
  transition:
4642
4649
  opacity 0.3s,
@@ -4675,21 +4682,33 @@ body.booting .boot-splash {
4675
4682
  .agent-card.fading {
4676
4683
  opacity: 0.4;
4677
4684
  }
4678
- .agent-type-row {
4685
+ .agent-task {
4686
+ max-width: 240px;
4687
+ padding-right: 14px;
4688
+ font-size: 12.5px;
4689
+ font-weight: 600;
4690
+ color: var(--text-primary);
4691
+ overflow: hidden;
4692
+ text-overflow: ellipsis;
4693
+ }
4694
+ .agent-ident-row {
4679
4695
  display: flex;
4680
4696
  align-items: center;
4681
- gap: 4px;
4697
+ gap: 5px;
4682
4698
  min-width: 0;
4699
+ max-width: 240px;
4700
+ font-size: 11px;
4701
+ color: var(--text-secondary);
4702
+ }
4703
+ .agent-ident-sep {
4704
+ color: var(--text-muted);
4705
+ flex-shrink: 0;
4683
4706
  }
4684
4707
  .agent-type-ns {
4685
- font-size: 11px;
4686
4708
  color: var(--text-muted);
4687
- font-weight: 400;
4688
4709
  }
4689
4710
  .agent-type-name {
4690
- font-size: 13px;
4691
- font-weight: 600;
4692
- color: var(--text-primary);
4711
+ min-width: 0;
4693
4712
  overflow: hidden;
4694
4713
  text-overflow: ellipsis;
4695
4714
  }
@@ -4697,8 +4716,8 @@ body.booting .boot-splash {
4697
4716
  font-size: 9px;
4698
4717
  padding: 2px 6px;
4699
4718
  border-radius: 3px;
4700
- margin-left: 6px;
4701
- vertical-align: middle;
4719
+ margin-left: 2px;
4720
+ flex-shrink: 0;
4702
4721
  }
4703
4722
  .agent-status-row {
4704
4723
  display: flex;
@@ -4728,30 +4747,13 @@ body.booting .boot-splash {
4728
4747
  color: var(--text-primary);
4729
4748
  }
4730
4749
  .agent-status {
4731
- font-size: 11px;
4732
- color: var(--text-tertiary);
4750
+ font-size: 10px;
4751
+ color: var(--text-muted);
4733
4752
  }
4734
4753
  .agent-card-model {
4735
- margin-left: auto;
4736
- padding: 1px 6px;
4737
- font-family: var(--font-mono, monospace);
4738
- font-size: 9.5px;
4739
- line-height: 1.4;
4740
- color: var(--text-tertiary);
4741
- border: 1px solid var(--border);
4742
- border-radius: 999px;
4743
- white-space: nowrap;
4744
- overflow: hidden;
4745
- text-overflow: ellipsis;
4746
- max-width: 45%;
4747
- }
4748
- .agent-message {
4749
- font-size: 11px;
4750
- color: var(--text-muted);
4751
- max-width: 220px;
4752
- overflow: hidden;
4753
- text-overflow: ellipsis;
4754
- white-space: nowrap;
4754
+ flex-shrink: 0;
4755
+ font-weight: 600;
4756
+ color: var(--text-primary);
4755
4757
  }
4756
4758
 
4757
4759
  .agent-dismiss-btn {
package/server.js CHANGED
@@ -22,12 +22,14 @@ const {
22
22
  readRecentMessages: _readRecentMessagesUncached,
23
23
  readMessagesPage: _readMessagesPageUncached,
24
24
  readSessionInfoFromJsonl,
25
+ modelDisplayName,
25
26
  buildSessionDigest,
26
27
  readCompactSummaries,
27
28
  readArtifactLinks,
28
29
  readScratchpadCreations,
29
30
  extractPromptFromTranscript,
30
31
  extractModelFromTranscript,
32
+ readSubagentMeta,
31
33
  extractAgentResultFromTranscript,
32
34
  extractTranscriptStats,
33
35
  readFullToolResult,
@@ -47,7 +49,8 @@ const { readLiveSessions, isPidAlive, isSessionLive } = require('./lib/live-sess
47
49
  const { createProcStats } = require('./lib/proc-stats');
48
50
  const { createDispatchRegistry } = require('./lib/dispatch');
49
51
  const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
50
- const { createDispatchedStore, scanTranscripts, pruneSessionDirs, retentionMs } = require('./lib/retention');
52
+ const { createDispatchedStore, scanTranscripts, pruneSessionDirs, pruneContextStatus, retentionMs } = require('./lib/retention');
53
+ const { freshRateLimits } = require('./lib/rate-limits');
51
54
  const { createWorktreeStore } = require('./lib/worktrees');
52
55
  const { readGitBranch, sessionGitBranch } = require('./lib/git-branch');
53
56
  const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
@@ -208,7 +211,6 @@ const SESSION_STALE_MS = 5 * 60 * 1000;
208
211
  // never the "active" status (which stays accurate via hasRecentLog).
209
212
  const SESSION_GRACE_MS = 2 * 60 * 1000;
210
213
  const WAITING_RESOLVE_GRACE_MS = 15 * 1000;
211
- const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
212
214
  const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
213
215
  const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
214
216
  const RETENTION_FIRST_RUN_MS = 5 * 60 * 1000;
@@ -302,11 +304,12 @@ function getContextStatus(sessionId, meta) {
302
304
  }
303
305
 
304
306
  function getContextFields(sessionId, meta) {
305
- const contextStatus = getContextStatus(sessionId, meta);
307
+ // A team dir has no transcript of its own; its lead's transcript has the requests.
308
+ const m = meta?.teamLeaderId ? sessionMetadataCache[meta.teamLeaderId] : meta;
309
+ const reply = m?.lastReply;
306
310
  return {
307
- contextStatus,
308
- autoCompact: contextStatus ? getAutoCompact(CLAUDE_DIR, meta?.project) : null,
309
- cacheTtl: meta?.cacheTtl || null,
311
+ contextStatus: getContextStatus(sessionId, meta),
312
+ cache: reply ? { ttl: m.cacheTtl || null, ...reply, modelName: modelDisplayName(reply.model) } : null,
310
313
  };
311
314
  }
312
315
 
@@ -828,6 +831,7 @@ function refreshSessionMetadataPath(jsonlPath) {
828
831
  if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
829
832
  existing.permissionMode = info.permissionMode;
830
833
  existing.cacheTtl = info.cacheTtl;
834
+ existing.lastReply = info.lastReply;
831
835
  return true;
832
836
  }
833
837
 
@@ -916,7 +920,8 @@ function loadSessionMetadata() {
916
920
  logicalParentUuid: sessionInfo.logicalParentUuid || null,
917
921
  compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null,
918
922
  permissionMode: sessionInfo.permissionMode,
919
- cacheTtl: sessionInfo.cacheTtl || null
923
+ cacheTtl: sessionInfo.cacheTtl || null,
924
+ lastReply: sessionInfo.lastReply || null
920
925
  };
921
926
  sessionIds.push(sessionId);
922
927
  }
@@ -1573,7 +1578,14 @@ app.get('/api/sessions', async (req, res) => {
1573
1578
  }
1574
1579
 
1575
1580
  // Loop info can mean a full read of the transcript, so only the rows sent pay for it.
1576
- for (const s of sessions) s.loopInfo = getLoopInfoSummary(s);
1581
+ // Same for autoCompact: up to 3 settings stats per call.
1582
+ const autoCompactByProject = new Map();
1583
+ for (const s of sessions) {
1584
+ s.loopInfo = getLoopInfoSummary(s);
1585
+ if (!s.contextStatus) continue;
1586
+ if (!autoCompactByProject.has(s.project)) autoCompactByProject.set(s.project, getAutoCompact(CLAUDE_DIR, s.project));
1587
+ s.autoCompact = autoCompactByProject.get(s.project);
1588
+ }
1577
1589
 
1578
1590
  res.json(withDispatchPlacement(sessions));
1579
1591
  startPrewarm();
@@ -2147,10 +2159,7 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
2147
2159
  for (const f of files) {
2148
2160
  const agentId = f.slice('agent-'.length, -'.jsonl'.length);
2149
2161
  const stats = (await extractTranscriptStats(path.join(runDir, f))) || {};
2150
- let type = null;
2151
- try {
2152
- type = JSON.parse(readFileSync(path.join(runDir, `agent-${agentId}.meta.json`), 'utf8')).agentType || null;
2153
- } catch (_) {}
2162
+ const type = readSubagentMeta(path.join(runDir, f))?.agentType || null;
2154
2163
  const entry = journal.get(agentId);
2155
2164
  const durationMs = stats.firstTs && stats.lastTs ? new Date(stats.lastTs) - new Date(stats.firstTs) : null;
2156
2165
  if (stats.lastTs && (!stoppedAt || stats.lastTs > stoppedAt)) stoppedAt = stats.lastTs;
@@ -2382,6 +2391,8 @@ app.get('/api/teams/:name', (req, res) => {
2382
2391
  // The resultUnavailable latch is persisted on the agent record. Bump this when
2383
2392
  // extractAgentResultFromTranscript finds more, so agents latched earlier rescan once.
2384
2393
  const RESULT_SCAN = 3;
2394
+ // Same for the modelUnavailable latch and extractModelFromTranscript.
2395
+ const MODEL_SCAN = 2;
2385
2396
 
2386
2397
  app.get('/api/sessions/:sessionId/agents', async (req, res) => {
2387
2398
  const sessionId = resolveSessionId(req.params.sessionId);
@@ -2523,19 +2534,34 @@ app.get('/api/sessions/:sessionId/agents', async (req, res) => {
2523
2534
  }
2524
2535
  }
2525
2536
 
2526
- // Retry stopped agents even if modelUnavailable was set — it may have been marked
2527
- // unavailable while the agent was still active and its JSONL wasn't ready yet.
2528
- const agentsNeedingModel = agents.filter(a => !a.model && (!a.modelUnavailable || a.status === 'stopped'));
2537
+ // The plugin's start record carries the model; this covers sessions without it.
2538
+ const agentsNeedingModel = agents.filter(a => !a.model && a.modelUnavailable !== MODEL_SCAN);
2529
2539
  if (agentsNeedingModel.length && meta.jsonlPath) {
2530
2540
  for (const agent of agentsNeedingModel) {
2541
+ const jsonl = subagentJsonlForExtraction(meta, agent.agentId);
2531
2542
  let model = null;
2532
- try { model = extractModelFromTranscript(subagentJsonlForExtraction(meta, agent.agentId)); } catch (_) {}
2543
+ try { model = extractModelFromTranscript(jsonl); } catch (_) {}
2533
2544
  if (model) {
2534
2545
  agent.model = model;
2535
2546
  delete agent.modelUnavailable;
2547
+ delete agent.modelAlias;
2536
2548
  dirty.add(agent);
2537
- } else if (agent.status === 'stopped' && !agent.modelUnavailable) {
2538
- agent.modelUnavailable = true;
2549
+ continue;
2550
+ }
2551
+ // Until the transcript has an assistant line, show the requested alias.
2552
+ // agent.model stays empty so the exact id is still looked up.
2553
+ if (!agent.modelAlias) {
2554
+ let alias = readSubagentMeta(jsonl)?.model || null;
2555
+ if (alias === 'inherit') {
2556
+ try { alias = extractModelFromTranscript(meta.jsonlPath); } catch (_) { alias = null; }
2557
+ }
2558
+ if (alias) {
2559
+ agent.modelAlias = alias;
2560
+ dirty.add(agent);
2561
+ }
2562
+ }
2563
+ if (agent.status === 'stopped') {
2564
+ agent.modelUnavailable = MODEL_SCAN;
2539
2565
  dirty.add(agent);
2540
2566
  }
2541
2567
  }
@@ -4063,6 +4089,10 @@ app.get('/api/context-status', (_req, res) => {
4063
4089
  res.json(Object.fromEntries(contextStatusCache));
4064
4090
  });
4065
4091
 
4092
+ app.get('/api/rate-limits', (_req, res) => {
4093
+ res.json(freshRateLimits(contextStatusCache.values(), Date.now() / 1000));
4094
+ });
4095
+
4066
4096
  app.use('/api', (_req, res) => {
4067
4097
  res.status(404).json({ error: 'Not found' });
4068
4098
  });
@@ -4246,47 +4276,35 @@ agentActivityWatcher.on('all', (event, filePath) => {
4246
4276
  const contextStatusWatcher = chokidar.watch(CONTEXT_STATUS_DIR, {
4247
4277
  persistent: true,
4248
4278
  ignoreInitial: false,
4279
+ alwaysStat: true,
4249
4280
  depth: 0
4250
4281
  });
4251
4282
 
4252
- contextStatusWatcher.on('all', (event, filePath) => {
4283
+ // The initial scan adds one event per kept file (up to MAX_CONTEXT_STATUS), so it broadcasts once at the end.
4284
+ let contextStatusReady = false;
4285
+ contextStatusWatcher.on('ready', () => {
4286
+ contextStatusReady = true;
4287
+ broadcast({ type: 'context-update', sessionId: null });
4288
+ });
4289
+
4290
+ contextStatusWatcher.on('all', (event, filePath, stats) => {
4253
4291
  if (!filePath.endsWith('.json')) return;
4254
4292
  const sessionId = path.basename(filePath, '.json');
4255
4293
  if (event === 'add' || event === 'change') {
4256
4294
  try {
4257
4295
  const data = JSON.parse(readFileSync(filePath, 'utf8'));
4258
- try { data._updatedAt = statSync(filePath).mtimeMs; } catch (_) { data._updatedAt = Date.now(); }
4296
+ data._updatedAt = stats?.mtimeMs ?? Date.now();
4259
4297
  contextStatusCache.set(sessionId, data);
4260
- evictStaleCache(contextStatusCache);
4261
4298
  } catch { /* ignore malformed */ }
4262
- broadcast({ type: 'context-update', sessionId });
4263
4299
  } else if (event === 'unlink') {
4264
4300
  contextStatusCache.delete(sessionId);
4265
- broadcast({ type: 'context-update', sessionId });
4266
- }
4301
+ } else return;
4302
+ if (contextStatusReady) broadcast({ type: 'context-update', sessionId });
4267
4303
  });
4268
4304
 
4269
4305
  // #endregion
4270
4306
 
4271
4307
  // #region CLEANUP
4272
- async function cleanupContextStatus() {
4273
- try {
4274
- const entries = await fs.readdir(CONTEXT_STATUS_DIR);
4275
- const now = Date.now();
4276
- for (const f of entries) {
4277
- if (!f.endsWith('.json')) continue;
4278
- try {
4279
- const fp = path.join(CONTEXT_STATUS_DIR, f);
4280
- const st = statSync(fp);
4281
- if (now - st.mtimeMs > CTX_CLEANUP_MAX_AGE_MS) {
4282
- await fs.unlink(fp);
4283
- contextStatusCache.delete(path.basename(f, '.json'));
4284
- }
4285
- } catch { /* ignore */ }
4286
- }
4287
- } catch { /* dir may not exist */ }
4288
- }
4289
-
4290
4308
  async function cleanupAgentActivity() {
4291
4309
  try {
4292
4310
  const entries = await fs.readdir(AGENT_ACTIVITY_DIR, { withFileTypes: true });
@@ -4317,16 +4335,17 @@ async function cleanupAgentActivity() {
4317
4335
  } catch { /* agent-activity dir may not exist */ }
4318
4336
  }
4319
4337
 
4320
- // Dispatch markers, reviews and worktrees: see docs/retention.md.
4338
+ // Dispatch markers, reviews, context status and worktrees: see docs/retention.md.
4321
4339
  async function runRetention() {
4322
4340
  try {
4323
4341
  const scan = await scanTranscripts(PROJECTS_DIR);
4324
4342
  const opts = { known: scan?.ids, maxAgeMs: retentionMs(CLAUDE_DIR) };
4325
4343
  const markers = dispatched.prune(opts);
4326
4344
  const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
4345
+ const contexts = await pruneContextStatus(CONTEXT_STATUS_DIR, opts);
4327
4346
  const wts = worktrees.prune(scan?.dirs);
4328
- if (markers || reviews || wts) {
4329
- console.log(`[retention] removed ${markers} dispatch markers, ${reviews} reviews, ${wts} worktrees`);
4347
+ if (markers || reviews || contexts || wts) {
4348
+ console.log(`[retention] removed ${markers} dispatch markers, ${reviews} reviews, ${contexts} context status files, ${wts} worktrees`);
4330
4349
  }
4331
4350
  } catch (e) {
4332
4351
  console.warn('[retention] failed:', e.message);
@@ -4334,9 +4353,7 @@ async function runRetention() {
4334
4353
  }
4335
4354
 
4336
4355
  cleanupAgentActivity();
4337
- cleanupContextStatus();
4338
4356
  setInterval(cleanupAgentActivity, CLEANUP_INTERVAL_MS);
4339
- setInterval(cleanupContextStatus, 30 * 60 * 1000);
4340
4357
  // Later than the startup scan and prewarm, so the first sweep never competes with them.
4341
4358
  setTimeout(() => {
4342
4359
  runRetention();