claude-code-kanban 6.1.1 → 7.0.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/panes.js ADDED
@@ -0,0 +1,174 @@
1
+ // Per-session pane layout: the tabs next to Board. The file is the only copy, shared by every
2
+ // board process on the config dir, the CLI and the agent. Which tab is on screen is each
3
+ // browser tab's own state, not stored here. Expiry: docs/retention.md.
4
+
5
+ const path = require('node:path');
6
+ const { httpError } = require('./http-error');
7
+ const { canonicalPath } = require('./linked-docs');
8
+ const { isLoopbackAddress } = require('./net-guard');
9
+ const { oneLine } = require('./one-line');
10
+ const { pruneMap } = require('./retention');
11
+
12
+ const MAX_PANES = 50;
13
+ const MAX_TITLE = 100;
14
+ const KINDS = new Set(['url', 'html', 'markdown', 'text', 'image']);
15
+
16
+ const portOf = (u) => Number(u.port || (u.protocol === 'https:' ? 443 : 80));
17
+
18
+ // A frame with allow-scripts plus allow-same-origin on the board's origin or the hub's can reach
19
+ // into its parent and drop its own sandbox. Every name either one is served under counts.
20
+ function isOwnOrigin(url, { boardPort, boardHosts = [], hubUrl }) {
21
+ let u;
22
+ try {
23
+ u = new URL(url);
24
+ } catch {
25
+ return false;
26
+ }
27
+ const port = portOf(u);
28
+ const loopback = isLoopbackAddress(u.hostname);
29
+ if (u.protocol === 'http:' && port === boardPort && (loopback || boardHosts.includes(u.hostname))) return true;
30
+ let hub;
31
+ try {
32
+ hub = new URL(hubUrl);
33
+ } catch {
34
+ return false;
35
+ }
36
+ const hubHost = u.hostname === hub.hostname || (loopback && isLoopbackAddress(hub.hostname));
37
+ return u.protocol === hub.protocol && port === portOf(hub) && hubHost;
38
+ }
39
+
40
+ const emptyLayout = () => ({ rev: 0, nextId: 1, panes: [], updatedAt: null });
41
+
42
+ const targetKey = (kind, target) => (kind === 'url' ? `url:${target}` : `file:${canonicalPath(target)}`);
43
+
44
+ function defaultTitle(kind, target) {
45
+ if (kind !== 'url') return path.basename(target);
46
+ try {
47
+ return new URL(target).host;
48
+ } catch {
49
+ return target;
50
+ }
51
+ }
52
+
53
+ const idNumber = (id) => Number(id.slice(1)) || 0;
54
+
55
+ function cleanLayout(raw) {
56
+ if (!raw || !Array.isArray(raw.panes) || !Number.isFinite(raw.updatedAt)) return null;
57
+ const ids = new Set();
58
+ const panes = raw.panes.filter((p) => {
59
+ const ok = p && typeof p.id === 'string' && !ids.has(p.id) && KINDS.has(p.kind) && typeof p.target === 'string' && p.target;
60
+ if (ok) ids.add(p.id);
61
+ return ok;
62
+ });
63
+ const nextId = Math.max(Number.isInteger(raw.nextId) ? raw.nextId : 1, ...panes.map((p) => idNumber(p.id) + 1));
64
+ const rev = Number.isInteger(raw.rev) && raw.rev > 0 ? raw.rev : 1;
65
+ return { rev, nextId, panes: panes.slice(0, MAX_PANES), updatedAt: raw.updatedAt };
66
+ }
67
+
68
+ const publicLayout = (l) => ({ rev: l.rev, panes: l.panes.map((p) => ({ ...p })), updatedAt: l.updatedAt });
69
+
70
+ /**
71
+ * Every write re-reads the file first, so two boards on one config dir add to each other's
72
+ * layouts instead of overwriting them. The read-to-rename window is left open: both writers
73
+ * are one user's own board tabs and CLI calls.
74
+ *
75
+ * @param {object} o
76
+ * @param {() => object|null|undefined} o.load returns `{version: 2, sessions: {[id]: {rev, nextId, panes, updatedAt}}}`,
77
+ * null when there is no file, or undefined when the file has not changed since the last load or save
78
+ * @param {(data: object) => void} o.save
79
+ * @param {(sessionId: string, layout: object) => void} [o.onChange] called for each layout whose rev
80
+ * moved, from this store's writes and from `reload()` alike
81
+ */
82
+ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
83
+ let bySession = new Map();
84
+
85
+ function reload() {
86
+ const data = load();
87
+ if (data === undefined) return;
88
+ const next = new Map();
89
+ const saved = data?.sessions;
90
+ if (saved && typeof saved === 'object') {
91
+ for (const [id, raw] of Object.entries(saved)) {
92
+ const layout = cleanLayout(raw);
93
+ if (layout) next.set(id, layout);
94
+ }
95
+ }
96
+ const before = bySession;
97
+ bySession = next;
98
+ for (const [id, layout] of next) {
99
+ if (before.get(id)?.rev !== layout.rev) onChange(id, publicLayout(layout));
100
+ }
101
+ }
102
+ reload();
103
+
104
+ const persist = () => save({ version: 2, sessions: Object.fromEntries(bySession) });
105
+
106
+ function write(sessionId, layout) {
107
+ layout.rev += 1;
108
+ layout.updatedAt = now();
109
+ bySession.set(sessionId, layout);
110
+ persist();
111
+ onChange(sessionId, publicLayout(layout));
112
+ return publicLayout(layout);
113
+ }
114
+
115
+ function get(sessionId) {
116
+ const layout = bySession.get(sessionId);
117
+ return publicLayout(layout || emptyLayout());
118
+ }
119
+
120
+ // The same target added again returns its pane where it is, so a repeated `pane add` is harmless.
121
+ function add(sessionId, { kind, target, title }) {
122
+ if (!KINDS.has(kind)) throw httpError(400, `Unknown pane kind: ${kind}`, 'bad_kind');
123
+ reload();
124
+ const layout = bySession.get(sessionId) || emptyLayout();
125
+ const key = targetKey(kind, target);
126
+ const existing = layout.panes.find((p) => targetKey(p.kind, p.target) === key);
127
+ if (existing) return { pane: { ...existing }, added: false, layout: publicLayout(layout) };
128
+ if (layout.panes.length >= MAX_PANES) throw httpError(409, `A session holds at most ${MAX_PANES} panes`, 'pane_limit');
129
+ const pane = {
130
+ id: `p${layout.nextId++}`,
131
+ kind,
132
+ target,
133
+ title: oneLine(title, MAX_TITLE) || oneLine(defaultTitle(kind, target), MAX_TITLE),
134
+ addedAt: now(),
135
+ };
136
+ layout.panes.push(pane);
137
+ return { pane: { ...pane }, added: true, layout: write(sessionId, layout) };
138
+ }
139
+
140
+ // Returns the new layout, or null when the session has no such pane. An emptied layout stays
141
+ // until retention drops it, so its ids and rev keep counting up.
142
+ function remove(sessionId, paneId) {
143
+ reload();
144
+ const layout = bySession.get(sessionId);
145
+ const i = layout ? layout.panes.findIndex((p) => p.id === paneId) : -1;
146
+ if (i < 0) return null;
147
+ layout.panes.splice(i, 1);
148
+ return write(sessionId, layout);
149
+ }
150
+
151
+ // `order` is every pane id of the session, in the new tab order.
152
+ function reorder(sessionId, order) {
153
+ reload();
154
+ const layout = bySession.get(sessionId);
155
+ const ids = layout ? layout.panes.map((p) => p.id) : [];
156
+ const valid = Array.isArray(order) && order.length === ids.length && new Set(order).size === ids.length;
157
+ if (!valid || !order.every((id) => ids.includes(id))) throw httpError(400, 'order must list every pane id once', 'bad_order');
158
+ if (!layout || order.every((id, i) => id === ids[i])) return get(sessionId);
159
+ layout.panes = order.map((id) => layout.panes.find((p) => p.id === id));
160
+ return write(sessionId, layout);
161
+ }
162
+
163
+ // Returns how many layouts went; writes only when one did.
164
+ function prune({ known, maxAgeMs }) {
165
+ reload();
166
+ const removed = pruneMap(bySession, (l) => l.updatedAt, { known, maxAgeMs, now: now() });
167
+ if (removed) persist();
168
+ return removed;
169
+ }
170
+
171
+ return { get, add, remove, reorder, prune, reload };
172
+ }
173
+
174
+ module.exports = { createPaneStore, isOwnOrigin, portOf, MAX_PANES, MAX_TITLE };
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) {
@@ -25,6 +26,18 @@ function isExpired(id, at, { known, maxAgeMs, now }) {
25
26
  return (known && !known.has(id)) || age > maxAgeMs;
26
27
  }
27
28
 
29
+ // Deletes the expired entries of a session-keyed map and returns how many went.
30
+ function pruneMap(map, atOf, { known, maxAgeMs, now }) {
31
+ let removed = 0;
32
+ for (const [id, entry] of map) {
33
+ if (isExpired(id, atOf(entry), { known, maxAgeMs, now })) {
34
+ map.delete(id);
35
+ removed++;
36
+ }
37
+ }
38
+ return removed;
39
+ }
40
+
28
41
  /**
29
42
  * Sessions started through `dispatch start`, kept after their terminal ends so the card
30
43
  * keeps its marker.
@@ -54,14 +67,7 @@ function createDispatchedStore({ load, save, now = Date.now }) {
54
67
 
55
68
  // Returns how many entries went; writes only when one did.
56
69
  function prune({ known, maxAgeMs }) {
57
- const t = now();
58
- let removed = 0;
59
- for (const [id, e] of entries) {
60
- if (isExpired(id, e.at, { known, maxAgeMs, now: t })) {
61
- entries.delete(id);
62
- removed++;
63
- }
64
- }
70
+ const removed = pruneMap(entries, (e) => e.at, { known, maxAgeMs, now: now() });
65
71
  if (removed) persist();
66
72
  return removed;
67
73
  }
@@ -94,6 +100,22 @@ async function scanTranscripts(projectsDir) {
94
100
  return ids.size ? { ids, dirs } : null;
95
101
  }
96
102
 
103
+ async function expireFiles(dir, names, idOf, opts) {
104
+ let removed = 0;
105
+ const kept = [];
106
+ for (const f of names) {
107
+ const file = path.join(dir, f);
108
+ try {
109
+ const { mtimeMs } = await fs.stat(file);
110
+ if (isExpired(idOf(f), mtimeMs, opts)) {
111
+ await fs.rm(file, { force: true });
112
+ removed++;
113
+ } else kept.push({ file, mtimeMs });
114
+ } catch {}
115
+ }
116
+ return { removed, kept };
117
+ }
118
+
97
119
  // `<dir>/<session id>/<ts>.md`, one folder per session.
98
120
  async function pruneSessionDirs(dir, { known, maxAgeMs, now = Date.now() }) {
99
121
  let removed = 0;
@@ -108,21 +130,47 @@ async function pruneSessionDirs(dir, { known, maxAgeMs, now = Date.now() }) {
108
130
  const sessionDir = path.join(dir, d.name);
109
131
  try {
110
132
  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
- }
133
+ const res = await expireFiles(sessionDir, files, () => d.name, { known, maxAgeMs, now });
134
+ removed += res.removed;
120
135
  // An old mtime means no file was added since the read, so a review written right
121
136
  // now cannot lose its folder.
122
- if (!kept && now - (await fs.stat(sessionDir)).mtimeMs >= GRACE_MS) await fs.rmdir(sessionDir);
137
+ if (!res.kept.length && now - (await fs.stat(sessionDir)).mtimeMs >= GRACE_MS) await fs.rmdir(sessionDir);
123
138
  } catch {}
124
139
  }
125
140
  return removed;
126
141
  }
127
142
 
128
- module.exports = { createDispatchedStore, scanTranscripts, pruneSessionDirs, retentionMs, GRACE_MS, MAX_DISPATCHED, DAY_MS };
143
+ // `<dir>/<session id>.json`, written by the plugin mod. The cap guards users who keep
144
+ // transcripts forever; it drops the oldest by mtime.
145
+ async function pruneContextStatus(dir, { known, maxAgeMs, now = Date.now(), max = MAX_CONTEXT_STATUS }) {
146
+ let names;
147
+ try {
148
+ names = await fs.readdir(dir);
149
+ } catch {
150
+ return 0;
151
+ }
152
+ const jsonNames = names.filter((f) => f.endsWith('.json'));
153
+ let { removed, kept } = await expireFiles(dir, jsonNames, (f) => f.slice(0, -'.json'.length), { known, maxAgeMs, now });
154
+ if (kept.length > max) {
155
+ kept.sort((a, b) => b.mtimeMs - a.mtimeMs);
156
+ for (const { file } of kept.slice(max)) {
157
+ try {
158
+ await fs.rm(file, { force: true });
159
+ removed++;
160
+ } catch {}
161
+ }
162
+ }
163
+ return removed;
164
+ }
165
+
166
+ module.exports = {
167
+ pruneMap,
168
+ createDispatchedStore,
169
+ scanTranscripts,
170
+ pruneSessionDirs,
171
+ pruneContextStatus,
172
+ retentionMs,
173
+ GRACE_MS,
174
+ MAX_DISPATCHED,
175
+ DAY_MS,
176
+ };
@@ -0,0 +1,41 @@
1
+ // Where the harness puts a session's scratchpad. The rule and its probes: docs/session-scanning.md.
2
+
3
+ const path = require('node:path');
4
+ const { existsSync, opendirSync } = require('node:fs');
5
+ const { encodeProjectDirName } = require('./claude-dir');
6
+
7
+ function isEmptyDir(dir) {
8
+ let handle;
9
+ try {
10
+ handle = opendirSync(dir);
11
+ return handle.readSync() === null;
12
+ } catch {
13
+ return true;
14
+ } finally {
15
+ handle?.closeSync();
16
+ }
17
+ }
18
+
19
+ function createScratchpadDirResolver({ root, resolveWorktree, exists = existsSync, isEmpty = isEmptyDir }) {
20
+ // A by-project dir that holds an entry always wins, so that answer is kept and never probed again.
21
+ const settled = new Map();
22
+
23
+ return function getScratchpadDir(id, meta) {
24
+ if (!meta.jsonlPath) return null;
25
+ const byProject = path.join(root, path.basename(path.dirname(meta.jsonlPath)), id, 'scratchpad');
26
+ if (settled.get(id) === byProject) return byProject;
27
+
28
+ // Claude Code keys the scratchpad on the process's original cwd, while entering a worktree
29
+ // moves the transcript under the worktree.
30
+ const wt = resolveWorktree(meta.project);
31
+ const launch = wt ? wt.repo : meta.project;
32
+ if (!launch) return byProject;
33
+ const byLaunch = path.join(root, encodeProjectDirName(launch), id, 'scratchpad');
34
+ if (byLaunch === byProject || !exists(byLaunch)) return byProject;
35
+ if (isEmpty(byProject)) return byLaunch;
36
+ settled.set(id, byProject);
37
+ return byProject;
38
+ };
39
+ }
40
+
41
+ module.exports = { createScratchpadDirResolver };