claude-code-kanban 4.26.0 → 4.28.0-rc.1

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.
@@ -0,0 +1,171 @@
1
+ // Dispatch: one Claude Code session started by another through cck, and the report it
2
+ // settles with. In memory on purpose: the PTYs die with the server, so nothing a
3
+ // restart drops could still settle.
4
+ //
5
+ // The child holds a per-dispatch capability, never the terminal token, so it can
6
+ // report its own outcome and nothing else. A stale or duplicate child is refused
7
+ // because a record settles once.
8
+
9
+ const crypto = require('node:crypto');
10
+ const { tokenMatches } = require('./terminal');
11
+ const { clampWait } = require('./session-events');
12
+
13
+ const OUTCOMES = new Set(['succeeded', 'failed']);
14
+ const MAX_SUMMARY = 4000;
15
+ // Settled records outlive their session long enough for a parent to collect them.
16
+ const KEEP_SETTLED_MS = 24 * 60 * 60 * 1000;
17
+ const ID_RE = /^d_[0-9a-f]{12}$/;
18
+ // The peer name lands verbatim in the started session's prompt.
19
+ const PEER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
20
+
21
+ const isPeerName = (v) => typeof v === 'string' && PEER_RE.test(v);
22
+
23
+ // With neither --report nor --peer the started session gets the task alone, like one the user typed.
24
+ function formatPreamble(record, cli = 'claude-code-kanban') {
25
+ if (!record.report && !record.peer) return record.spec;
26
+ const lines = [`[cck dispatch ${record.id}] Another Claude Code session started you through claude-code-kanban to do the task below.`];
27
+ if (record.peer) {
28
+ lines.push(
29
+ `That session is the peer "${record.peer}". Ask it, not the user: when you need a decision, or find something that changes the task, send a short message with the SendMessage tool to "${record.peer}".`,
30
+ 'Keep working on what does not depend on the answer. The reply arrives as a new message.',
31
+ );
32
+ }
33
+ if (record.report) {
34
+ lines.push(
35
+ 'When the task is done, or you cannot finish it, report exactly once with this command, then stop:',
36
+ `${cli} dispatch done ${record.id} --cap ${record.cap} --outcome succeeded --summary "<what changed, what you found, what remains>"`,
37
+ record.peer
38
+ ? 'Use --outcome failed when the task cannot be finished. If the command refuses because the dispatch already ended, send the summary to the peer instead.'
39
+ : 'Use --outcome failed when the task is not done. Do not ask the user questions the other session must answer; report failed with the question instead.',
40
+ );
41
+ }
42
+ lines.push('', 'Task:', record.spec);
43
+ return lines.join('\n');
44
+ }
45
+
46
+ // The line reaches the parent through the postman at hook trust level; the summary is
47
+ // last so no summary text can pose as a further field.
48
+ function formatDispatchLine(r) {
49
+ const head = `cck:1 dispatch.${r.status} ${r.id} session=${r.session}`;
50
+ return r.summary ? `${head} summary=${r.summary}` : head;
51
+ }
52
+
53
+ function publicView(r) {
54
+ const { cap: _cap, ...rest } = r;
55
+ return rest;
56
+ }
57
+
58
+ function createDispatchRegistry({ onSettle, now = Date.now } = {}) {
59
+ const records = new Map();
60
+ const waiters = new Set();
61
+
62
+ function prune() {
63
+ const cutoff = now() - KEEP_SETTLED_MS;
64
+ for (const [id, r] of records) if (r.settledAt && r.settledAt < cutoff) records.delete(id);
65
+ }
66
+
67
+ function create({ parent, spec, name, report, peer, group, worktree }) {
68
+ prune();
69
+ const r = {
70
+ id: `d_${crypto.randomBytes(6).toString('hex')}`,
71
+ cap: crypto.randomBytes(16).toString('hex'),
72
+ parent: parent || null,
73
+ session: null,
74
+ cwd: null,
75
+ name: name || null,
76
+ report: !!report,
77
+ peer: peer || null,
78
+ group: group || null,
79
+ worktree: worktree || null,
80
+ spec,
81
+ status: 'running',
82
+ summary: null,
83
+ startedAt: now(),
84
+ settledAt: null,
85
+ };
86
+ records.set(r.id, r);
87
+ return r;
88
+ }
89
+
90
+ // The preamble carries the id, so the record exists before its session does.
91
+ function attach(id, { session, cwd }) {
92
+ const r = records.get(id);
93
+ if (r) Object.assign(r, { session, cwd });
94
+ }
95
+
96
+ function discard(id) {
97
+ records.delete(id);
98
+ }
99
+
100
+ function finish(r, status, summary) {
101
+ r.status = status;
102
+ r.summary = summary;
103
+ r.settledAt = now();
104
+ onSettle?.(r);
105
+ for (const wake of [...waiters]) wake();
106
+ }
107
+
108
+ // Returns null when settled, or {status, error} naming the refusal.
109
+ function settle(id, cap, outcome, summary) {
110
+ const r = typeof id === 'string' && ID_RE.test(id) ? records.get(id) : null;
111
+ if (!r) return { status: 404, error: 'no such dispatch' };
112
+ if (!tokenMatches(r.cap, cap)) return { status: 403, error: 'wrong capability' };
113
+ if (r.status !== 'running') return { status: 409, error: `already ${r.status}` };
114
+ if (!OUTCOMES.has(outcome)) return { status: 400, error: 'outcome must be succeeded or failed' };
115
+ const text = typeof summary === 'string' ? summary.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, MAX_SUMMARY) : '';
116
+ finish(r, outcome, text || null);
117
+ return null;
118
+ }
119
+
120
+ // The PTY ended with no report: the parent must not wait on it forever.
121
+ function sessionExited(sessionId) {
122
+ for (const r of records.values()) if (r.session === sessionId && r.status === 'running') finish(r, 'exited', null);
123
+ }
124
+
125
+ function select({ ids, parent } = {}) {
126
+ prune();
127
+ let out = [...records.values()];
128
+ if (ids?.length) out = out.filter((r) => ids.includes(r.id));
129
+ else if (parent) out = out.filter((r) => r.parent === parent);
130
+ return out;
131
+ }
132
+
133
+ function list(filter) {
134
+ return select(filter).map(publicView);
135
+ }
136
+
137
+ // Resolves once any selected record is settled, or after `waitSec`. Stateless for the
138
+ // caller: it passes the ids still running on the next call. `onClose(stop)` returns an
139
+ // unsubscribe, called once the wait ends.
140
+ function wait(filter, waitSec, onClose) {
141
+ const sec = clampWait(waitSec);
142
+ const snapshot = () => {
143
+ const rows = select(filter);
144
+ return {
145
+ settled: rows.filter((r) => r.status !== 'running').map(publicView),
146
+ running: rows.filter((r) => r.status === 'running').map(publicView),
147
+ };
148
+ };
149
+ const first = snapshot();
150
+ if (first.settled.length || !first.running.length || !sec) return Promise.resolve({ ...first, timeout: false });
151
+ return new Promise((resolve) => {
152
+ let unsubscribe;
153
+ const done = (timeout) => {
154
+ if (!waiters.delete(wake)) return;
155
+ clearTimeout(timer);
156
+ unsubscribe?.();
157
+ resolve({ ...snapshot(), timeout });
158
+ };
159
+ const wake = () => {
160
+ if (select(filter).some((r) => r.status !== 'running')) done(false);
161
+ };
162
+ const timer = setTimeout(() => done(true), sec * 1000);
163
+ waiters.add(wake);
164
+ unsubscribe = onClose?.(() => done(true));
165
+ });
166
+ }
167
+
168
+ return { create, attach, discard, settle, sessionExited, list, wait };
169
+ }
170
+
171
+ module.exports = { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName };
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ // Opens the OS folder picker on the machine running cck. The page cannot do this: a
4
+ // browser's directory input hands over file contents, never the folder's path.
5
+
6
+ const { spawn } = require('node:child_process');
7
+ const { statSync } = require('node:fs');
8
+ const path = require('node:path');
9
+
10
+ const PROMPT = 'Choose a folder for the new session';
11
+
12
+ // The owner form keeps the dialog above the browser; without it the dialog opens behind.
13
+ // The start folder arrives in an env var, never spliced into the script.
14
+ const WIN_SCRIPT = [
15
+ 'Add-Type -AssemblyName System.Windows.Forms',
16
+ '[Console]::OutputEncoding = [Text.Encoding]::UTF8',
17
+ '$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true }',
18
+ '$d = New-Object System.Windows.Forms.FolderBrowserDialog',
19
+ `$d.Description = "${PROMPT}"`,
20
+ '$d.ShowNewFolderButton = $true',
21
+ 'if ($env:CCK_PICK_START) { $d.SelectedPath = $env:CCK_PICK_START }',
22
+ 'if ($d.ShowDialog($owner) -eq "OK") { [Console]::Out.Write($d.SelectedPath) }',
23
+ ].join('; ');
24
+
25
+ // argv, not string interpolation, so a quote in the path cannot end the AppleScript literal.
26
+ const MAC_SCRIPT = [
27
+ 'on run argv',
28
+ 'if (count of argv) > 0 then',
29
+ `return POSIX path of (choose folder with prompt "${PROMPT}" default location (POSIX file (item 1 of argv)))`,
30
+ 'end if',
31
+ `return POSIX path of (choose folder with prompt "${PROMPT}")`,
32
+ 'end run',
33
+ ];
34
+
35
+ function candidates(platform, start) {
36
+ // No `.exe`: whichSync appends PATHEXT itself, so `powershell.exe` is never found.
37
+ // pwsh first: on .NET Core the same FolderBrowserDialog is the Explorer-style picker, while
38
+ // Windows PowerShell (.NET Framework) shows the old tree view.
39
+ if (platform === 'win32') {
40
+ const args = ['-NoProfile', '-STA', '-Command', WIN_SCRIPT];
41
+ return [['pwsh', args], ['powershell', args]];
42
+ }
43
+ if (platform === 'darwin') return [['osascript', [...MAC_SCRIPT.flatMap((l) => ['-e', l]), ...(start ? [start] : [])]]];
44
+ return [
45
+ ['zenity', ['--file-selection', '--directory', `--title=${PROMPT}`, ...(start ? [`--filename=${start}${path.sep}`] : [])]],
46
+ ['kdialog', ['--getexistingdirectory', start || '.', '--title', PROMPT]],
47
+ ];
48
+ }
49
+
50
+ // The page sends any text the user typed; only an existing absolute folder is worth opening at.
51
+ function startFolder(dir) {
52
+ if (typeof dir !== 'string' || !path.isAbsolute(dir)) return null;
53
+ try {
54
+ return statSync(dir).isDirectory() ? dir : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * @param {(cmd: string) => string|null} which
62
+ * @param {{ start?: string }} [opts] folder the dialog opens at, ignored unless it exists
63
+ * @returns {Promise<string|null>} the chosen folder, or null when the user cancels
64
+ * @throws when this machine has no folder dialog
65
+ */
66
+ function pickFolder(which, opts = {}, platform = process.platform, env = process.env) {
67
+ if (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
68
+ return Promise.reject(new Error('no display for a folder dialog'));
69
+ }
70
+ const start = startFolder(opts.start);
71
+ const found = candidates(platform, start).find(([cmd]) => which(cmd));
72
+ if (!found) return Promise.reject(new Error('no folder dialog on this machine (install zenity or kdialog)'));
73
+ const [cmd, args] = found;
74
+ return new Promise((resolve, reject) => {
75
+ const child = spawn(which(cmd), args, {
76
+ stdio: ['ignore', 'pipe', 'ignore'],
77
+ windowsHide: true,
78
+ env: start ? { ...env, CCK_PICK_START: start } : env,
79
+ });
80
+ let out = '';
81
+ child.stdout.on('data', (d) => { out += d; });
82
+ child.on('error', reject);
83
+ // Every picker exits non-zero on cancel and prints nothing.
84
+ child.on('close', () => {
85
+ const dir = out.trim();
86
+ resolve(dir ? dir.replace(/(.)[/\\]$/, '$1') : null);
87
+ });
88
+ });
89
+ }
90
+
91
+ module.exports = { pickFolder, candidates, startFolder };
@@ -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/net-guard.js CHANGED
@@ -181,6 +181,16 @@ function createNetGuard(config = {}) {
181
181
  return next();
182
182
  }
183
183
 
184
+ // For a WebSocket upgrade, which originGuard must not judge: the handshake is a
185
+ // GET, and WebSocket has no CORS, so any page can open one. Browsers always send
186
+ // Origin on the handshake, so a missing or `null` one is refused, not trusted.
187
+ // Returns null when allowed, or the reason it was refused.
188
+ function upgradeVerdict(req) {
189
+ if (!hostAllowed(parseHostHeader(req.headers.host))) return 'unrecognized Host header';
190
+ if (originVerdict(req.headers.origin) !== true) return 'cross-origin or missing Origin';
191
+ return null;
192
+ }
193
+
184
194
  // Constant for the life of the process, and frameGuard runs ahead of
185
195
  // express.static — so this is built once rather than per asset fetch.
186
196
  //
@@ -229,12 +239,19 @@ function createNetGuard(config = {}) {
229
239
  * @param {any} app express app (or any http request handler)
230
240
  * @param {number} port 0 selects a free port
231
241
  * @param {(port: number) => void} [onReady]
232
- * @param {{maxHeaderSize?: number}} [opts]
242
+ * `onUpgrade` is attached to both servers; a caller that attached it to the
243
+ * returned one alone would drop every WebSocket that arrives over the other family.
244
+ *
245
+ * @param {{maxHeaderSize?: number, onUpgrade?: (req: any, socket: any, head: Buffer) => void}} [opts]
233
246
  */
234
247
  function listenLoopback(app, port, onReady, opts = {}) {
235
248
  const http = require('http');
236
249
  const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
237
- const makeServer = (handler) => http.createServer(serverOpts, handler);
250
+ const makeServer = (handler) => {
251
+ const s = http.createServer(serverOpts, handler);
252
+ if (opts.onUpgrade) s.on('upgrade', opts.onUpgrade);
253
+ return s;
254
+ };
238
255
  const server = makeServer(app);
239
256
  let secondary = null;
240
257
 
@@ -261,7 +278,7 @@ function createNetGuard(config = {}) {
261
278
  return `WARNING: listening on ${BIND_HOST} - reachable from your network, with no authentication.`;
262
279
  }
263
280
 
264
- return { BIND_HOST, ALLOWED_HOSTS, hostGuard, originGuard, frameGuard, listenLoopback, exposureWarning };
281
+ return { BIND_HOST, ALLOWED_HOSTS, EXPOSED, hostGuard, originGuard, upgradeVerdict, frameGuard, listenLoopback, exposureWarning };
265
282
  }
266
283
 
267
284
  module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress };
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 };
@@ -51,11 +51,28 @@ function enqueueSessionEvent(sessionId, line) {
51
51
  for (const wake of [...bucket.waiters]) wake();
52
52
  }
53
53
 
54
+ const MAX_WAIT_SEC = 120;
55
+ const TOPIC_RE = /^[a-z]+$/;
56
+
57
+ function clampWait(sec) {
58
+ return Math.min(Math.max(Number(sec) || 0, 0), MAX_WAIT_SEC);
59
+ }
60
+
61
+ // Each topic rides its own bucket, so the kanban-dispatch postman never prints task moves
62
+ // the user did not grant with kanban-follow. No topic is the task-move bucket.
63
+ function topicKey(topic, sessionId) {
64
+ return topic ? `${topic}:${sessionId}` : sessionId;
65
+ }
66
+
54
67
  // Long-poll drained by the postman monitor. Routing stays in server.js; this is the handler.
55
68
  function handleSessionEvents(req, res) {
56
- const { sessionId } = req.params;
69
+ const topic = req.query.topic;
70
+ if (topic !== undefined && !(typeof topic === 'string' && TOPIC_RE.test(topic))) {
71
+ return res.status(400).json({ error: 'invalid topic' });
72
+ }
73
+ const sessionId = topicKey(topic, req.params.sessionId);
57
74
  const bucket = sessionEventBuckets.get(sessionId);
58
- const wait = Math.min(Math.max(Number(req.query.wait) || 0, 0), 120);
75
+ const wait = clampWait(req.query.wait);
59
76
 
60
77
  // A postman is armed by a skill invocation, so it can attach long after the board moved
61
78
  // something. Those lines are read as instructions, and an hours-old instruction is worse
@@ -96,4 +113,7 @@ module.exports = {
96
113
  formatTaskMoved,
97
114
  enqueueSessionEvent,
98
115
  handleSessionEvents,
116
+ topicKey,
117
+ clampWait,
118
+ MAX_WAIT_SEC,
99
119
  };