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.
- package/README.md +68 -44
- package/cli.js +235 -7
- package/lib/claude-dir.js +1 -1
- package/lib/dispatch-groups.js +93 -0
- package/lib/dispatch.js +171 -0
- package/lib/folder-dialog.js +91 -0
- package/lib/inline-assets.js +1 -1
- package/lib/net-guard.js +20 -3
- package/lib/parsers.js +113 -0
- package/lib/scratch-files.js +47 -0
- package/lib/session-events.js +22 -2
- package/lib/terminal.js +555 -0
- package/package.json +14 -3
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/monitors.json +6 -0
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +34 -2
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +7 -2
- package/plugin/plugins/claude-code-kanban/skills/kanban-dispatch/SKILL.md +23 -0
- package/public/app.js +2250 -214
- package/public/index.html +155 -14
- package/public/style.css +1048 -132
- package/public/sw.js +2 -1
- package/public/themes.css +84 -80
- package/server.js +647 -91
- package/skill-guides/dispatch.md +72 -0
package/lib/dispatch.js
ADDED
|
@@ -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 };
|
package/lib/inline-assets.js
CHANGED
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
|
-
*
|
|
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) =>
|
|
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 };
|
package/lib/session-events.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
};
|