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/README.md +3 -2
- package/cli.js +206 -168
- package/lib/frame-policy.js +79 -0
- package/lib/http-error.js +6 -0
- package/lib/linked-docs.js +5 -7
- package/lib/one-line.js +6 -0
- package/lib/panes.js +174 -0
- package/lib/parsers.js +150 -41
- package/lib/rate-limits.js +21 -0
- package/lib/retention.js +67 -19
- package/lib/scratchpad-dir.js +41 -0
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +3 -4
- package/lib/terminal.js +46 -6
- package/lib/worktrees.js +11 -2
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/activity.ts +3 -2
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +18 -19
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +31 -21
- package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +1 -1
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +33 -2
- package/public/app.js +1023 -170
- package/public/index.html +37 -0
- package/public/link-url.js +6 -3
- package/public/style.css +487 -41
- package/public/tree-highlight.js +74 -0
- package/server.js +194 -84
- package/skill-guides/dispatch.md +1 -1
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
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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 (
|
|
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
|
|
1715
|
+
const model = lineModel(accumulated.slice(0, nlIdx));
|
|
1609
1716
|
accumulated = accumulated.slice(nlIdx + 1);
|
|
1610
|
-
if (
|
|
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
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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 };
|