claude-code-kanban 4.27.0 → 4.28.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/README.md +72 -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/net-guard.js +20 -3
- 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 +1761 -138
- package/public/index.html +150 -14
- package/public/style.css +772 -53
- package/public/sw.js +2 -1
- package/public/themes.css +84 -80
- package/server.js +240 -18
- 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/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/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
|
};
|