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.
- package/lib/inline-assets.js +1 -1
- package/lib/parsers.js +113 -0
- package/lib/scratch-files.js +47 -0
- package/package.json +1 -1
- package/public/app.js +496 -83
- package/public/index.html +5 -0
- package/public/style.css +214 -17
- package/server.js +407 -73
package/lib/inline-assets.js
CHANGED
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 };
|