claude-code-kanban 5.3.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/parsers.js CHANGED
@@ -4,6 +4,21 @@ const path = require('node:path');
4
4
  const os = require('node:os');
5
5
  const { StringDecoder } = require('node:string_decoder');
6
6
 
7
+ // Full-transcript scans stream the file instead of reading it whole: a transcript can
8
+ // reach hundreds of megabytes, and the event loop it would block also carries the
9
+ // embedded terminal's keystrokes. Yields the same lines as content.split('\n').
10
+ async function* readLines(file) {
11
+ const decoder = new StringDecoder('utf8');
12
+ let rest = '';
13
+ for await (const chunk of fs.createReadStream(file, { highWaterMark: 1 << 20 })) {
14
+ const parts = decoder.write(chunk).split('\n');
15
+ parts[0] = rest + parts[0];
16
+ rest = parts.pop();
17
+ yield* parts;
18
+ }
19
+ yield rest + decoder.end();
20
+ }
21
+
7
22
  function parseTask(raw) {
8
23
  const task = typeof raw === 'string' ? JSON.parse(raw) : raw;
9
24
  return {
@@ -347,12 +362,24 @@ function scrapeScalarFromBlob(blob, re) {
347
362
  const sessionInfoCache = new Map();
348
363
  const SESSION_INFO_CACHE_MAX = 2000;
349
364
 
365
+ // A prompt line carries the mode it was sent in. After a Shift+Tab, Claude Code writes an
366
+ // auto_mode / auto_mode_exit attachment with the next tool result, and later rewrites a
367
+ // permission-mode line with the live mode, often tens of seconds after.
368
+ const modeOf = (data) => {
369
+ if (data.type === 'attachment') {
370
+ const t = data.attachment?.type;
371
+ return t === 'auto_mode' ? 'auto' : t === 'auto_mode_exit' ? 'default' : null;
372
+ }
373
+ return (data.type === 'user' || data.type === 'permission-mode') && data.permissionMode || null;
374
+ };
375
+
350
376
  // gitBranch in the JSONL is pinned to the launch-time repo by Claude Code
351
377
  // and goes stale once cwd shifts (Bash `cd`, submodule). Callers needing the
352
378
  // live branch must resolve it from cwd separately. Cache is reset on inode
353
379
  // change or truncation (size < scannedUpTo).
380
+
354
381
  function readSessionInfoFromJsonl(jsonlPath) {
355
- const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null };
382
+ const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null, permissionMode: null };
356
383
  let stat;
357
384
  let fd;
358
385
  try {
@@ -372,6 +399,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
372
399
  gitBranch: cached.gitBranch,
373
400
  logicalParentUuid: cached.logicalParentUuid || null,
374
401
  compactBoundaryUuid: cached.compactBoundaryUuid || null,
402
+ permissionMode: cached.permissionMode || null,
375
403
  customTitle: readCustomTitle(jsonlPath, stat)
376
404
  };
377
405
  }
@@ -383,6 +411,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
383
411
  result.gitBranch = cached.gitBranch;
384
412
  result.logicalParentUuid = cached.logicalParentUuid || null;
385
413
  result.compactBoundaryUuid = cached.compactBoundaryUuid || null;
414
+ result.permissionMode = cached.permissionMode || null;
386
415
  }
387
416
 
388
417
  let lastCwdSeen = result.cwd;
@@ -395,6 +424,8 @@ function readSessionInfoFromJsonl(jsonlPath) {
395
424
  lastCwdSeen = data.cwd;
396
425
  }
397
426
  if (data.gitBranch) result.gitBranch = data.gitBranch;
427
+ const mode = modeOf(data);
428
+ if (mode) result.permissionMode = mode;
398
429
  if (data.subtype === 'compact_boundary' && data.logicalParentUuid && !result.logicalParentUuid) {
399
430
  result.logicalParentUuid = data.logicalParentUuid;
400
431
  // The boundary record's own uuid: a fork copies it verbatim from the
@@ -466,6 +497,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
466
497
  const tn = fs.readSync(fd, tailBuf, 0, TAIL_SIZE, tailStart);
467
498
  const lines = tailBuf.toString('utf8', 0, tn).split('\n');
468
499
  let latestTailCwd = null;
500
+ let latestTailMode = null;
469
501
  for (let i = lines.length - 1; i >= 0; i--) {
470
502
  try {
471
503
  const data = JSON.parse(lines[i]);
@@ -473,10 +505,12 @@ function readSessionInfoFromJsonl(jsonlPath) {
473
505
  if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
474
506
  if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
475
507
  if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
476
- if (latestTailCwd && result.slug && result.projectPath && result.gitBranch) break;
508
+ if (!latestTailMode) latestTailMode = modeOf(data);
509
+ if (latestTailCwd && latestTailMode && result.slug && result.projectPath && result.gitBranch) break;
477
510
  } catch {}
478
511
  }
479
512
  if (latestTailCwd) lastCwdSeen = latestTailCwd;
513
+ if (latestTailMode) result.permissionMode = latestTailMode;
480
514
  }
481
515
  scannedUpTo = stat.size;
482
516
  }
@@ -498,7 +532,8 @@ function readSessionInfoFromJsonl(jsonlPath) {
498
532
  gitBranch: result.gitBranch,
499
533
  cwd: result.cwd,
500
534
  logicalParentUuid: result.logicalParentUuid,
501
- compactBoundaryUuid: result.compactBoundaryUuid
535
+ compactBoundaryUuid: result.compactBoundaryUuid,
536
+ permissionMode: result.permissionMode
502
537
  });
503
538
  if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
504
539
  const firstKey = sessionInfoCache.keys().next().value;
@@ -1064,12 +1099,10 @@ function readRecentMessages(jsonlPath, limit = 10) {
1064
1099
  }
1065
1100
  }
1066
1101
 
1067
- function readFullToolResult(jsonlPath, toolUseId) {
1102
+ async function readFullToolResult(jsonlPath, toolUseId) {
1068
1103
  if (!toolUseId || !jsonlPath || !existsSync(jsonlPath)) return null;
1069
1104
  try {
1070
- const content = readFileSync(jsonlPath, 'utf8');
1071
- const lines = content.split('\n');
1072
- for (const line of lines) {
1105
+ for await (const line of readLines(jsonlPath)) {
1073
1106
  if (!line || line.indexOf(toolUseId) === -1) continue;
1074
1107
  try {
1075
1108
  const obj = JSON.parse(line);
@@ -1092,14 +1125,12 @@ function readFullToolResult(jsonlPath, toolUseId) {
1092
1125
  return null;
1093
1126
  }
1094
1127
 
1095
- function readUserImage(jsonlPath, msgUuid, blockIndex) {
1128
+ async function readUserImage(jsonlPath, msgUuid, blockIndex) {
1096
1129
  if (!msgUuid || !jsonlPath) return null;
1097
1130
  const idx = Number(blockIndex);
1098
1131
  if (!Number.isInteger(idx) || idx < 0) return null;
1099
1132
  try {
1100
- const content = readFileSync(jsonlPath, 'utf8');
1101
- const lines = content.split('\n');
1102
- for (const line of lines) {
1133
+ for await (const line of readLines(jsonlPath)) {
1103
1134
  if (!line || line.indexOf(msgUuid) === -1) continue;
1104
1135
  try {
1105
1136
  const obj = JSON.parse(line);
@@ -1119,14 +1150,12 @@ function readUserImage(jsonlPath, msgUuid, blockIndex) {
1119
1150
 
1120
1151
  // Read the Nth base64 image embedded in a tool_result block (e.g. the output of
1121
1152
  // a Read on an image file). Scans for the tool_result matching toolUseId.
1122
- function readToolResultImage(jsonlPath, toolUseId, n) {
1153
+ async function readToolResultImage(jsonlPath, toolUseId, n) {
1123
1154
  if (!toolUseId || !jsonlPath) return null;
1124
1155
  const idx = Number(n);
1125
1156
  if (!Number.isInteger(idx) || idx < 0) return null;
1126
1157
  try {
1127
- const content = readFileSync(jsonlPath, 'utf8');
1128
- const lines = content.split('\n');
1129
- for (const line of lines) {
1158
+ for await (const line of readLines(jsonlPath)) {
1130
1159
  if (!line || line.indexOf(toolUseId) === -1) continue;
1131
1160
  let obj;
1132
1161
  try { obj = JSON.parse(line); } catch (_) { continue; }
@@ -1196,14 +1225,13 @@ function readMessagesPage(jsonlPath, limit = 10, beforeTimestamp = null) {
1196
1225
  };
1197
1226
  }
1198
1227
 
1199
- function buildSessionDigest(jsonlPath) {
1228
+ async function buildSessionDigest(jsonlPath) {
1200
1229
  const map = {};
1201
1230
  const terminated = new Map();
1202
1231
  const rejectedToolUseIds = new Set();
1203
1232
  const promptByToolUseId = {};
1204
1233
  const killedAgentIds = new Set();
1205
1234
  try {
1206
- const content = readFileSync(jsonlPath, 'utf8');
1207
1235
  const re = /"type":"agent_progress"[^}]*"agentId":"([^"]+)"/;
1208
1236
  const parentRe = /"parentToolUseID":"([^"]+)"/;
1209
1237
  const promptRe = /"prompt":"((?:[^"\\]|\\.)*)"/;
@@ -1215,7 +1243,7 @@ function buildSessionDigest(jsonlPath) {
1215
1243
  const nameByToolUseId = {};
1216
1244
  const descByToolUseId = {};
1217
1245
  const usageByToolUseId = {};
1218
- for (const line of content.split('\n')) {
1246
+ for await (const line of readLines(jsonlPath)) {
1219
1247
  // Terminated-teammate detection: check first since cheap substring guards
1220
1248
  if (line.includes('teammate-message') &&
1221
1249
  (line.includes('teammate_terminated') || line.includes('shutdown_response'))) {
@@ -1346,8 +1374,8 @@ function buildSessionDigest(jsonlPath) {
1346
1374
  return { progressMap: map, terminated, rejectedAgentIds, rejectedPrompts, killedAgentIds };
1347
1375
  }
1348
1376
 
1349
- function buildAgentProgressMap(jsonlPath) {
1350
- return buildSessionDigest(jsonlPath).progressMap;
1377
+ async function buildAgentProgressMap(jsonlPath) {
1378
+ return (await buildSessionDigest(jsonlPath)).progressMap;
1351
1379
  }
1352
1380
 
1353
1381
  const ARTIFACT_URL_RE = /https:\/\/claude\.ai\/\S*artifact\/[A-Za-z0-9_-]+/;
@@ -1365,7 +1393,7 @@ function toolResultText(content) {
1365
1393
  // only when its tool_use_id belongs to a publish call — an artifact URL quoted
1366
1394
  // back by a grep or a file read would otherwise register another session's page as
1367
1395
  // this session's own, and a `read` of someone else's artifact is not a publish.
1368
- function readArtifactLinks(jsonlPath) {
1396
+ async function readArtifactLinks(jsonlPath) {
1369
1397
  const byUrl = new Map();
1370
1398
  const publishedFileByToolUseId = new Map();
1371
1399
  const upsert = (url, ts) => {
@@ -1378,8 +1406,7 @@ function readArtifactLinks(jsonlPath) {
1378
1406
  return a;
1379
1407
  };
1380
1408
  try {
1381
- const content = readFileSync(jsonlPath, 'utf8');
1382
- for (const line of content.split('\n')) {
1409
+ for await (const line of readLines(jsonlPath)) {
1383
1410
  if (!line.includes('"frame-link"') && !line.includes('"name":"Artifact"') && !line.includes('claude.ai'))
1384
1411
  continue;
1385
1412
  let obj;
@@ -1426,11 +1453,10 @@ const SCRATCH_NAME_RE = /\bscratch\s+new\s+(?:"([^"]+)"|'([^']+)'|(?!-)(\S+))/;
1426
1453
  // the manifest is the 4th of ~13 printed lines, so any `| tail -N` cuts it off. `name`
1427
1454
  // stands in because it is what the manifest stores as its own `name`; the path is not
1428
1455
  // recovered from the command, which would mean tracking the `cd` and a relative `--dir`.
1429
- function readScratchpadCreations(jsonlPath) {
1456
+ async function readScratchpadCreations(jsonlPath) {
1430
1457
  const calls = new Map();
1431
1458
  try {
1432
- const content = readFileSync(jsonlPath, 'utf8');
1433
- for (const line of content.split('\n')) {
1459
+ for await (const line of readLines(jsonlPath)) {
1434
1460
  // Substring reject before JSON.parse: transcripts reach tens of megabytes and
1435
1461
  // only a handful of lines can ever match.
1436
1462
  const isCall = line.includes('scratch new');
@@ -1456,13 +1482,12 @@ function readScratchpadCreations(jsonlPath) {
1456
1482
  return [...calls.values()];
1457
1483
  }
1458
1484
 
1459
- function readCompactSummaries(jsonlPath) {
1485
+ async function readCompactSummaries(jsonlPath) {
1460
1486
  const results = [];
1461
1487
  // Inline format: newer Claude Code stores the summary directly in the parent
1462
1488
  // session JSONL as a user message with isCompactSummary: true (no subagent file).
1463
1489
  try {
1464
- const content = readFileSync(jsonlPath, 'utf8');
1465
- for (const line of content.split('\n')) {
1490
+ for await (const line of readLines(jsonlPath)) {
1466
1491
  if (!line.trim() || !line.includes('isCompactSummary')) continue;
1467
1492
  try {
1468
1493
  const obj = JSON.parse(line);
@@ -1515,8 +1540,8 @@ function readCompactSummaries(jsonlPath) {
1515
1540
  return results.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
1516
1541
  }
1517
1542
 
1518
- function findTerminatedTeammates(jsonlPath) {
1519
- return buildSessionDigest(jsonlPath).terminated;
1543
+ async function findTerminatedTeammates(jsonlPath) {
1544
+ return (await buildSessionDigest(jsonlPath)).terminated;
1520
1545
  }
1521
1546
 
1522
1547
  function extractPromptFromTranscript(jsonlPath) {
@@ -1585,28 +1610,28 @@ function extractModelFromTranscript(jsonlPath) {
1585
1610
  // output tokens, and first/last timestamps (for duration). COLD PATH ONLY —
1586
1611
  // used by the workflow run view on demand, never from the session-scan hot
1587
1612
  // path (a full read per agent would blow the /api/sessions budget).
1588
- function extractTranscriptStats(jsonlPath) {
1589
- let content;
1590
- try { content = readFileSync(jsonlPath, 'utf8'); } catch (_) { return null; }
1613
+ async function extractTranscriptStats(jsonlPath) {
1591
1614
  let model = null;
1592
1615
  let outputTokens = 0;
1593
1616
  let firstTs = null;
1594
1617
  let lastTs = null;
1595
- for (const line of content.split('\n')) {
1596
- if (!line.trim()) continue;
1597
- let obj;
1598
- try { obj = JSON.parse(line); } catch (_) { continue; }
1599
- if (obj.timestamp) {
1600
- if (!firstTs) firstTs = obj.timestamp;
1601
- lastTs = obj.timestamp;
1602
- }
1603
- const msg = obj.message;
1604
- if (msg && typeof msg === 'object') {
1605
- if (!model && msg.model) model = msg.model;
1606
- const out = msg.usage?.output_tokens;
1607
- if (typeof out === 'number') outputTokens += out;
1618
+ try {
1619
+ for await (const line of readLines(jsonlPath)) {
1620
+ if (!line.trim()) continue;
1621
+ let obj;
1622
+ try { obj = JSON.parse(line); } catch (_) { continue; }
1623
+ if (obj.timestamp) {
1624
+ if (!firstTs) firstTs = obj.timestamp;
1625
+ lastTs = obj.timestamp;
1626
+ }
1627
+ const msg = obj.message;
1628
+ if (msg && typeof msg === 'object') {
1629
+ if (!model && msg.model) model = msg.model;
1630
+ const out = msg.usage?.output_tokens;
1631
+ if (typeof out === 'number') outputTokens += out;
1632
+ }
1608
1633
  }
1609
- }
1634
+ } catch (_) { return null; }
1610
1635
  return { model, outputTokens, firstTs, lastTs };
1611
1636
  }
1612
1637
 
@@ -1625,7 +1650,9 @@ function formatStructuredResult(input) {
1625
1650
  // Some subagents end their run on a tool call and never emit a text response, so
1626
1651
  // their lastMessage is empty: workflow subagents given a schema call StructuredOutput,
1627
1652
  // background subagents report through SubagentHandback. Tail-read the transcript and
1628
- // take the LAST such tool_use as the agent's result. Returns null when absent.
1653
+ // take the LAST such tool_use as the agent's result. Without one, take the last
1654
+ // assistant text: the plugin's mod records no lastMessage, because Claude Code's
1655
+ // turn.complete carries an empty answer for a subagent. Returns null when neither exists.
1629
1656
  const RESULT_TOOLS = {
1630
1657
  StructuredOutput: formatStructuredResult,
1631
1658
  SubagentHandback: (input) => (typeof input.message === 'string' && input.message) || null,
@@ -1649,16 +1676,25 @@ function extractAgentResultFromTranscript(jsonlPath) {
1649
1676
  const text = buf.toString('utf8');
1650
1677
  // Drop the leading partial line unless we read from the very start.
1651
1678
  const clean = start > 0 ? text.slice(text.indexOf('\n') + 1) : text;
1652
- if (!RESULT_TOOL_TEXT.some((t) => clean.includes(t))) return null;
1653
1679
  const lines = clean.split('\n');
1654
- for (let i = lines.length - 1; i >= 0; i--) {
1655
- if (!RESULT_TOOL_TEXT.some((t) => lines[i].includes(t))) continue;
1656
- let obj;
1657
- try { obj = JSON.parse(lines[i]); } catch (_) { continue; }
1658
- const content = obj?.message?.content;
1659
- const result = Array.isArray(content) && content.map(resultFromBlock).findLast(Boolean);
1660
- if (result) return result;
1661
- }
1680
+ const lastAssistant = (needle, pick) => {
1681
+ for (let i = lines.length - 1; i >= 0; i--) {
1682
+ if (!needle(lines[i])) continue;
1683
+ let obj;
1684
+ try { obj = JSON.parse(lines[i]); } catch (_) { continue; }
1685
+ const content = obj?.message?.content;
1686
+ const found = obj?.type === 'assistant' && Array.isArray(content) && pick(content);
1687
+ if (found) return found;
1688
+ }
1689
+ return null;
1690
+ };
1691
+ return lastAssistant(
1692
+ (line) => RESULT_TOOL_TEXT.some((t) => line.includes(t)),
1693
+ (content) => content.map(resultFromBlock).findLast(Boolean),
1694
+ ) ?? lastAssistant(
1695
+ (line) => line.includes('"type":"assistant"'),
1696
+ (content) => toolResultText(content).trim(),
1697
+ );
1662
1698
  } catch (_) {
1663
1699
  } finally {
1664
1700
  closeSync(fd);
@@ -1831,5 +1867,6 @@ module.exports = {
1831
1867
  extractPromptFromTranscript,
1832
1868
  extractModelFromTranscript,
1833
1869
  extractAgentResultFromTranscript,
1834
- extractTranscriptStats
1870
+ extractTranscriptStats,
1871
+ readLines
1835
1872
  };
@@ -31,7 +31,7 @@ function pluginStatus(claudeDir, bundledManifest = BUNDLED_MANIFEST) {
31
31
 
32
32
  // --dir rather than CLAUDE_CONFIG_DIR=…, so the same line runs in bash, PowerShell and cmd.
33
33
  function installCommand(claudeDir) {
34
- const base = 'claude-code-kanban --install --plugin-only';
34
+ const base = 'claude-code-kanban --install --yes';
35
35
  return isDefaultClaudeDir(claudeDir) ? base : `${base} --dir "${claudeDir}"`;
36
36
  }
37
37
 
@@ -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 = 2;
7
+ const VERSION = 3;
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/lib/terminal.js CHANGED
@@ -552,8 +552,9 @@ function createTerminalService(o) {
552
552
  const cols = clampDim(msg.cols, 80);
553
553
  const rows = clampDim(msg.rows, 24);
554
554
 
555
+ // An ended PTY stays in the map until its process exits; a hello for its id starts a new one.
555
556
  const existing = sessions.get(id);
556
- if (existing) return attach(ws, existing, true);
557
+ if (existing && !existing.ended) return attach(ws, existing, true);
557
558
 
558
559
  if (mode === 'auto' && o.isLiveElsewhere(id)) {
559
560
  send(ws, { t: 'live' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -11,7 +11,6 @@
11
11
  "start": "node server.js",
12
12
  "dev": "node --watch server.js",
13
13
  "test": "node --test test/*.test.js",
14
- "test:hooks": "bash tests/test-agent-spy.sh && bash tests/test-approval-gate.sh",
15
14
  "validate:schemas": "node test/validate-live-schemas.js",
16
15
  "prepare": "husky"
17
16
  },
@@ -7,7 +7,7 @@
7
7
  {
8
8
  "name": "claude-code-kanban",
9
9
  "source": "./plugins/claude-code-kanban",
10
- "description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, and a skill to drive the board from a session"
10
+ "description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, and a skill to drive the board from a session"
11
11
  }
12
12
  ]
13
13
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.17.2",
4
- "description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, skills to drive the board from a session and to follow it",
3
+ "version": "2.21.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"
7
7
  }