claude-code-kanban 6.1.1 → 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 +6 -4
- package/lib/parsers.js +150 -41
- package/lib/rate-limits.js +21 -0
- package/lib/retention.js +53 -11
- package/lib/session-cache.js +1 -1
- 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 +2 -0
- 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 +83 -59
- package/public/style.css +35 -33
- package/server.js +64 -47
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
|
-
|
|
794
|
-
|
|
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
|
|
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) {
|
|
@@ -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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
+
};
|
package/lib/session-cache.js
CHANGED
|
@@ -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 =
|
|
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": "2.
|
|
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
|
-
|
|
46
|
-
|
|
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,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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">←</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
|
|
3159
|
-
const
|
|
3160
|
-
|
|
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
|
-
|
|
3165
|
-
<div class="agent-
|
|
3166
|
-
${
|
|
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
|
|
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
|
|
8952
|
-
(usage
|
|
8953
|
-
(usage
|
|
8954
|
-
(usage
|
|
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(
|
|
8988
|
+
function renderCacheTimer({ at, ttl, usage, model }) {
|
|
8986
8989
|
const cache = CACHE_TTL[ttl];
|
|
8987
|
-
if (!
|
|
8988
|
-
const expiresAt =
|
|
8989
|
-
const rewrite = cacheRewrite(usage,
|
|
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
|
|
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 ${
|
|
9000
|
+
const title = `Prompt cache, ${ttl} TTL. Expires at ${clock}; the next request after that writes it again.${cost}`;
|
|
8998
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
|
-
|
|
9004
|
-
|
|
9005
|
-
const
|
|
9006
|
-
const
|
|
9007
|
-
|
|
9008
|
-
const
|
|
9009
|
-
|
|
9010
|
-
const
|
|
9011
|
-
|
|
9012
|
-
|
|
9013
|
-
<div class="stat-item"><span class="stat-label">Cache write</span><span class="stat-value">${formatTokens(
|
|
9014
|
-
<div class="stat-item"><span class="stat-label">Current input</span><span class="stat-value">${formatTokens(
|
|
9015
|
-
<div class="stat-item"><span class="stat-label">Current output</span><span class="stat-value">${formatTokens(
|
|
9016
|
-
<div class="stat-divider"></div
|
|
9017
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
${
|
|
9037
|
-
|
|
9038
|
-
${
|
|
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
|
-
|
|
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 +=
|
|
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/
|
|
13317
|
+
fetch('/api/rate-limits')
|
|
13302
13318
|
.then((r) => r.json())
|
|
13303
|
-
.then((
|
|
13304
|
-
|
|
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:
|
|
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
|
-
|
|
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-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
4701
|
-
|
|
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:
|
|
4732
|
-
color: var(--text-
|
|
4750
|
+
font-size: 10px;
|
|
4751
|
+
color: var(--text-muted);
|
|
4733
4752
|
}
|
|
4734
4753
|
.agent-card-model {
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
2527
|
-
|
|
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(
|
|
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
|
-
|
|
2538
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|