claude-code-kanban 4.26.0 → 4.27.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.
@@ -235,4 +235,4 @@ async function inlineHtmlAssets(html, absHtmlPath) {
235
235
  return { html: out, bytes: ctx.spent, skipped: ctx.skipped };
236
236
  }
237
237
 
238
- module.exports = { inlineHtmlAssets, MAX_ASSET_BYTES };
238
+ module.exports = { inlineHtmlAssets, MIME_BY_EXT, MAX_ASSET_BYTES };
package/lib/parsers.js CHANGED
@@ -185,6 +185,14 @@ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
185
185
  label = base + formatTaskUsage(notif.usage);
186
186
  safeText = notif.result || base;
187
187
  }
188
+ // A subagent, teammate or peer session handing work back through SendMessage is
189
+ // also model output, not user input. Callers on the `origin` path pass the parts
190
+ // ready-made; the text paths (type:'user', queued enqueue) parse the envelope.
191
+ const agentMsg = extras?.agent || (notif ? null : parseAgentMessage(safeText));
192
+ if (agentMsg) {
193
+ label = agentMsg.label;
194
+ safeText = agentMsg.body;
195
+ }
188
196
  const truncated = safeText.length > USER_TEXT_MAX;
189
197
  const msg = {
190
198
  type: 'user',
@@ -200,12 +208,26 @@ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
200
208
  if (notif.taskId) msg.taskId = notif.taskId;
201
209
  if (notif.status) msg.taskStatus = notif.status;
202
210
  }
211
+ if (agentMsg) {
212
+ msg.agentMessage = true;
213
+ if (agentMsg.from) msg.agentFrom = agentMsg.from;
214
+ }
203
215
  if (extras) {
204
216
  if (extras.uuid) msg.uuid = extras.uuid;
205
217
  if (extras.images && extras.images.length) msg.images = extras.images;
206
218
  if (extras.toolResultRefs && extras.toolResultRefs.length) msg.toolResultRefs = extras.toolResultRefs;
207
219
  if (extras.queued) msg.queued = true;
208
220
  }
221
+ // One agent message reaches the log twice: the queued enqueue record and the
222
+ // delivered one right after it. Keep the first and drop the twin here, so no
223
+ // renderer has to know about the double write.
224
+ if (agentMsg) {
225
+ const prev = messages[messages.length - 1];
226
+ if (prev?.agentMessage && (prev.agentFrom || null) === (msg.agentFrom || null) && prev.text === msg.text) {
227
+ if (!msg.queued) delete prev.queued;
228
+ return;
229
+ }
230
+ }
209
231
  messages.push(msg);
210
232
  }
211
233
 
@@ -587,6 +609,43 @@ function parseTaskNotification(text) {
587
609
  };
588
610
  }
589
611
 
612
+ // A message another agent sent into this session (a subagent hand-back, a teammate
613
+ // or a peer session using SendMessage) is model output, not user input. Turn the
614
+ // sender and the raw body into the chip label plus the body alone, so it never
615
+ // renders under a person icon.
616
+ function agentMessageParts(from, rawBody, handback) {
617
+ let body = (rawBody || '').trimEnd();
618
+ // A hand-back frames the report with the harness note that it carries no user
619
+ // authority, and indents every line of the report by two spaces so a forged
620
+ // frame at column zero is impossible. Strip the frame and that indent.
621
+ const framed = body.match(/^\[Subagent hand-back\][\s\S]*?report follows:\n([\s\S]*)$/);
622
+ if (framed) body = framed[1].replace(/^ {2}/gm, '').trim();
623
+ const handBack = !!framed || !!handback;
624
+ const kind = handBack ? 'Subagent hand-back' : 'Agent message';
625
+ // Harness agent ids are long hex; a named sender ("code-review") is shown whole.
626
+ const short = from && /^[0-9a-f]{12,}$/i.test(from) ? from.slice(0, 8) : from;
627
+ return { from: from || null, handBack, label: short ? `${kind} · ${short}` : kind, body };
628
+ }
629
+
630
+ // The text delivery paths carry the body inside an <agent-message from="..."> envelope,
631
+ // with the harness notes outside it. The envelope must open the message (after the
632
+ // harness preamble, if any): prose that merely quotes the tag is a user message.
633
+ // The body is unescaped agent text that can quote the envelope it describes, so the
634
+ // real closing tag is the last one.
635
+ function parseAgentMessage(text) {
636
+ if (typeof text !== 'string' || !text.includes('<agent-message')) return null;
637
+ const start = text.indexOf('<agent-message');
638
+ const preamble = text.slice(0, start).trim();
639
+ if (preamble && !/^Another Claude session sent a message[^\n]*:$/.test(preamble)) return null;
640
+ const gt = text.indexOf('>', start);
641
+ const end = text.lastIndexOf('</agent-message>');
642
+ if (gt === -1 || end < gt) return null;
643
+ const from = (text.slice(start, gt).match(/from="([^"]*)"/) || [])[1] || null;
644
+ let bodyStart = gt + 1;
645
+ while (text[bodyStart] === '\n') bodyStart++;
646
+ return agentMessageParts(from, text.slice(bodyStart, end));
647
+ }
648
+
590
649
  function getSystemMessageLabel(text) {
591
650
  const taskMatch = text.match(/<summary>([^<]+)<\/summary>/);
592
651
  if (taskMatch) return taskMatch[1].trim();
@@ -801,6 +860,15 @@ function readRecentMessages(jsonlPath, limit = 10) {
801
860
  messages.push(msg);
802
861
  }
803
862
  }
863
+ } else if (obj.type === 'user' && obj.origin?.kind === 'peer') {
864
+ // Claude Code >= 2.1 files the delivered copy as isMeta (dropped by the
865
+ // branch below) and repeats the envelope's fields on `origin`. That
866
+ // record is always written; the queued enqueue is not, so read it from
867
+ // here and let pushUserMessage drop whichever twin arrives second.
868
+ pushUserMessage(messages, '', obj.timestamp, null, {
869
+ uuid: obj.uuid,
870
+ agent: agentMessageParts(obj.origin.from, obj.origin.body, obj.origin.handback)
871
+ });
804
872
  } else if (obj.type === 'user' && obj.message?.role === 'user' && !obj.isMeta) {
805
873
  if (obj.isCompactSummary) {
806
874
  // Surface the compaction summary as a single expandable "Compacted"
@@ -1359,6 +1427,49 @@ function readArtifactLinks(jsonlPath) {
1359
1427
  return [...byUrl.values()].sort((x, y) => (x.firstSeen < y.firstSeen ? -1 : x.firstSeen > y.firstSeen ? 1 : 0));
1360
1428
  }
1361
1429
 
1430
+ const SCRATCH_NEW_RE = /\bscratch new\b/;
1431
+ // `scratch new` reports where it put the pad; the two spaces are the CLI's own column
1432
+ // padding, which is what makes the prefilter below narrow enough to be worth having.
1433
+ const SCRATCH_MANIFEST_RE = /^\s*manifest\s*:\s*(.+?)\s*$/m;
1434
+ // The name must sit immediately after `new`, and the bare-word branch must refuse a
1435
+ // leading `-`, so that a flag or a flag's value cannot be read as the name.
1436
+ const SCRATCH_NAME_RE = /\bscratch\s+new\s+(?:"([^"]+)"|'([^']+)'|(?!-)(\S+))/;
1437
+
1438
+ // The `scratch new` calls in a transcript, `{ ts, path, name }` per call. `path` is null
1439
+ // whenever the command piped the CLI's output away — the common case, not an edge one:
1440
+ // the manifest is the 4th of ~13 printed lines, so any `| tail -N` cuts it off. `name`
1441
+ // stands in because it is what the manifest stores as its own `name`; the path is not
1442
+ // recovered from the command, which would mean tracking the `cd` and a relative `--dir`.
1443
+ function readScratchpadCreations(jsonlPath) {
1444
+ const calls = new Map();
1445
+ try {
1446
+ const content = readFileSync(jsonlPath, 'utf8');
1447
+ for (const line of content.split('\n')) {
1448
+ // Substring reject before JSON.parse: transcripts reach tens of megabytes and
1449
+ // only a handful of lines can ever match.
1450
+ const isCall = line.includes('scratch new');
1451
+ if (!isCall && !line.includes('manifest :')) continue;
1452
+ let obj;
1453
+ try { obj = JSON.parse(line); } catch (_) { continue; }
1454
+ const blocks = obj?.message?.content;
1455
+ if (!Array.isArray(blocks)) continue;
1456
+ const ts = Date.parse(obj.timestamp);
1457
+ for (const b of blocks) {
1458
+ if (b?.type === 'tool_use' && b.name === 'Bash' && SCRATCH_NEW_RE.test(b.input?.command || '')) {
1459
+ if (b.id && Number.isFinite(ts)) {
1460
+ const named = SCRATCH_NAME_RE.exec(b.input.command);
1461
+ calls.set(b.id, { ts, path: null, name: named?.slice(1).find(Boolean) ?? null });
1462
+ }
1463
+ } else if (b?.type === 'tool_result' && calls.has(b.tool_use_id)) {
1464
+ const m = SCRATCH_MANIFEST_RE.exec(toolResultText(b.content));
1465
+ if (m) calls.get(b.tool_use_id).path = m[1];
1466
+ }
1467
+ }
1468
+ }
1469
+ } catch (_) {}
1470
+ return [...calls.values()];
1471
+ }
1472
+
1362
1473
  function readCompactSummaries(jsonlPath) {
1363
1474
  const results = [];
1364
1475
  // Inline format: newer Claude Code stores the summary directly in the parent
@@ -1684,6 +1795,7 @@ module.exports = {
1684
1795
  parseSessionsIndex,
1685
1796
  parseJsonlLine,
1686
1797
  parseTaskNotification,
1798
+ parseAgentMessage,
1687
1799
  getSystemMessageLabel,
1688
1800
  readSessionInfoFromJsonl,
1689
1801
  readRecentMessages,
@@ -1698,6 +1810,7 @@ module.exports = {
1698
1810
  buildSessionDigest,
1699
1811
  readCompactSummaries,
1700
1812
  readArtifactLinks,
1813
+ readScratchpadCreations,
1701
1814
  findTerminatedTeammates,
1702
1815
  extractPromptFromTranscript,
1703
1816
  extractModelFromTranscript,
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs').promises;
4
+ const path = require('node:path');
5
+ const { isContained } = require('./contain');
6
+
7
+ // Resolves a folder inside a session's scratchpad dir from the path the client sends
8
+ // back — the absolute path a listing handed out, or one relative to the root. Returns
9
+ // null when it escapes the root (`..`, another dir, a symlink out), so the endpoint
10
+ // never lists anything the root did not hand out.
11
+ function resolveScratchSubdir(root, sub) {
12
+ if (!sub) return root;
13
+ if (typeof sub !== 'string') return null;
14
+ const full = path.resolve(root, sub);
15
+ return isContained(full, root) ? full : null;
16
+ }
17
+
18
+ // One level of a scratchpad dir, newest first whatever the kind. Not recursive on
19
+ // purpose: sessions drop clones and build output in here, and the client asks for a
20
+ // folder's children only when the user opens it.
21
+ async function listScratchDir(dir) {
22
+ let entries;
23
+ try {
24
+ entries = await fs.readdir(dir, { withFileTypes: true });
25
+ } catch (_) {
26
+ return [];
27
+ }
28
+ const rows = await Promise.all(
29
+ entries
30
+ .filter((entry) => entry.isFile() || entry.isDirectory())
31
+ .map(async (entry) => {
32
+ const full = path.join(dir, entry.name);
33
+ try {
34
+ const { mtimeMs } = await fs.stat(full);
35
+ return { name: entry.name, path: full, kind: entry.isDirectory() ? 'dir' : 'file', mtimeMs };
36
+ } catch (_) {
37
+ return null;
38
+ }
39
+ }),
40
+ );
41
+ return rows
42
+ .filter(Boolean)
43
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
44
+ .map(({ mtimeMs, ...row }) => ({ ...row, modifiedAt: new Date(mtimeMs).toISOString() }));
45
+ }
46
+
47
+ module.exports = { resolveScratchSubdir, listScratchDir };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.26.0",
3
+ "version": "4.27.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
  "bin": {