claude-code-kanban 5.4.0 → 6.1.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 +23 -13
- package/cli.js +20 -103
- package/lib/dispatch-groups.js +9 -31
- package/lib/dispatch.js +19 -166
- package/lib/git-branch.js +16 -1
- package/lib/live-sessions.js +39 -0
- package/lib/parsers.js +18 -3
- package/lib/retention.js +5 -17
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +3 -2
- package/lib/terminal-client.js +221 -0
- package/lib/terminal-host.js +95 -0
- package/lib/terminal.js +101 -42
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +5 -2
- package/plugin/plugins/claude-code-kanban/monitors.json +0 -6
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +2 -7
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +17 -11
- package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +50 -0
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +14 -1
- package/public/app.js +384 -93
- package/public/index.html +14 -3
- package/public/project-match.js +6 -1
- package/public/style.css +110 -0
- package/server.js +102 -119
- package/skill-guides/dispatch.md +16 -54
package/lib/terminal.js
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// (net-guard upgradeVerdict), refusal when the server is exposed, and a per-launch
|
|
8
8
|
// token sent in the first message. The token is the only one that stops a
|
|
9
9
|
// non-browser local process.
|
|
10
|
+
//
|
|
11
|
+
// The service runs in its own process (lib/terminal-host.js): cck checks the first two
|
|
12
|
+
// gates and hands the upgraded socket over, so no keystroke or output byte waits on
|
|
13
|
+
// cck's main thread.
|
|
10
14
|
|
|
11
15
|
const crypto = require('node:crypto');
|
|
12
16
|
const fs = require('node:fs');
|
|
@@ -26,6 +30,17 @@ const PICK_START_SLACK_MS = 2000;
|
|
|
26
30
|
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$/;
|
|
27
31
|
const WORKTREE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
28
32
|
const MODELS = new Set(['fable', 'opus', 'sonnet', 'haiku']);
|
|
33
|
+
// Extra claude args reach the command line inside plain quotes (quoteArg), so a character
|
|
34
|
+
// that ends or expands those quotes in any of the shells is refused.
|
|
35
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: refuses control characters on purpose
|
|
36
|
+
const EXTRA_ARG_RE = /^[^\x00-\x1f\x7f'"%]*$/;
|
|
37
|
+
const MAX_EXTRA_ARGS = 64;
|
|
38
|
+
const MAX_EXTRA_ARG = 4096;
|
|
39
|
+
// cck sets these itself, or they would start something other than a new session.
|
|
40
|
+
const OWNED_FLAGS = new Set([
|
|
41
|
+
'--session-id', '-n', '--name', '--model', '-w', '--worktree',
|
|
42
|
+
'-r', '--resume', '-c', '--continue', '--fork-session', '-p', '--print',
|
|
43
|
+
]);
|
|
29
44
|
const MAX_PROMPT = 32 * 1024;
|
|
30
45
|
const PROMPT_QUIET_MS = 400;
|
|
31
46
|
// Watermarks from https://xtermjs.org/docs/guides/flowcontrol/ — pause the PTY while a
|
|
@@ -118,8 +133,8 @@ function resolveShell(value, which, platform = process.platform, env = process.e
|
|
|
118
133
|
}
|
|
119
134
|
|
|
120
135
|
// claudeArgs is null for a plain shell. Its items are fixed flags, a validated UUID, or
|
|
121
|
-
// values that passed NAME_RE / WORKTREE_RE, so plain quoting is enough to
|
|
122
|
-
// from splitting an argument and nothing can break out of the quotes.
|
|
136
|
+
// values that passed NAME_RE / WORKTREE_RE / EXTRA_ARG_RE, so plain quoting is enough to
|
|
137
|
+
// keep a space from splitting an argument and nothing can break out of the quotes.
|
|
123
138
|
function quoteArg(arg, family) {
|
|
124
139
|
if (/^[A-Za-z0-9._-]+$/.test(arg)) return arg;
|
|
125
140
|
return family === 'cmd' ? `"${arg}"` : `'${arg}'`;
|
|
@@ -142,7 +157,7 @@ function claudeArgsFor(mode, id, spec) {
|
|
|
142
157
|
if (mode === 'fork') return ['--resume', id, '--fork-session'];
|
|
143
158
|
if (mode === 'pick') return ['--resume'];
|
|
144
159
|
if (mode === 'new') {
|
|
145
|
-
const args = ['--session-id', id];
|
|
160
|
+
const args = ['--session-id', id, ...(spec.extraArgs || [])];
|
|
146
161
|
if (spec.name) args.push('--name', spec.name);
|
|
147
162
|
if (spec.model) args.push('--model', spec.model);
|
|
148
163
|
// Last, because its value is optional and a following flag must not be taken for it.
|
|
@@ -153,6 +168,16 @@ function claudeArgsFor(mode, id, spec) {
|
|
|
153
168
|
return ['--resume', id];
|
|
154
169
|
}
|
|
155
170
|
|
|
171
|
+
function extraArgsError(args) {
|
|
172
|
+
if (!Array.isArray(args) || args.length > MAX_EXTRA_ARGS) return `claude args (at most ${MAX_EXTRA_ARGS})`;
|
|
173
|
+
for (const a of args) {
|
|
174
|
+
if (typeof a !== 'string' || a.length > MAX_EXTRA_ARG || !EXTRA_ARG_RE.test(a)) return 'claude arg: no quotes, % or control characters';
|
|
175
|
+
const flag = a.split('=')[0];
|
|
176
|
+
if (OWNED_FLAGS.has(flag)) return `claude arg: cck sets ${flag}`;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
156
181
|
// Returns the new-session options from a hello, or a string naming the bad field.
|
|
157
182
|
function parseNewSpec(msg) {
|
|
158
183
|
if (typeof msg.cwd !== 'string' || !msg.cwd) return 'folder';
|
|
@@ -165,7 +190,10 @@ function parseNewSpec(msg) {
|
|
|
165
190
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips ESC so a prompt cannot carry terminal sequences
|
|
166
191
|
const prompt = typeof msg.prompt === 'string' ? msg.prompt.replace(/\x1b/g, '').trim() : '';
|
|
167
192
|
if (prompt.length > MAX_PROMPT) return 'prompt';
|
|
168
|
-
|
|
193
|
+
const extraArgs = msg.extraArgs ?? [];
|
|
194
|
+
const bad = extraArgsError(extraArgs);
|
|
195
|
+
if (bad) return bad;
|
|
196
|
+
return { cwd: msg.cwd, name: name || null, worktree, model, prompt: prompt || null, extraArgs };
|
|
169
197
|
}
|
|
170
198
|
|
|
171
199
|
function samePath(a, b) {
|
|
@@ -225,15 +253,43 @@ function refuse(socket, status, reason) {
|
|
|
225
253
|
socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n${reason}\n`);
|
|
226
254
|
}
|
|
227
255
|
|
|
256
|
+
function localUnavailableReason(config, net) {
|
|
257
|
+
if (!config.enabled) return 'disabled';
|
|
258
|
+
if (net.EXPOSED) return 'refused while listening on a non-loopback address';
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function refuseUpgrade(req, socket, reasonFor) {
|
|
263
|
+
let pathname;
|
|
264
|
+
try { pathname = new URL(req.url, 'http://x').pathname; } catch { pathname = ''; }
|
|
265
|
+
if (pathname !== WS_PATH) {
|
|
266
|
+
refuse(socket, '404 Not Found', 'no such endpoint');
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
const reason = reasonFor();
|
|
270
|
+
if (reason) refuse(socket, '403 Forbidden', reason);
|
|
271
|
+
return !!reason;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function clientConfigFor(config, available) {
|
|
275
|
+
return {
|
|
276
|
+
available,
|
|
277
|
+
fontFamily: config.fontFamily,
|
|
278
|
+
fontSize: config.fontSize,
|
|
279
|
+
scrollback: config.scrollback,
|
|
280
|
+
maxSessions: config.maxSessions,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
228
284
|
/**
|
|
229
285
|
* @param {object} o
|
|
230
286
|
* @param {ReturnType<typeof readTerminalConfig>} o.config
|
|
231
287
|
* @param {{EXPOSED: boolean, upgradeVerdict: (req: any) => string|null}} o.net
|
|
232
288
|
* @param {string} o.claudeDir
|
|
233
289
|
* @param {boolean} o.isDefaultDir
|
|
234
|
-
* @param {(id: string) => string|null} o.resolveCwd null = unknown session
|
|
290
|
+
* @param {(id: string) => string|null|Promise<string|null>} o.resolveCwd null = unknown session
|
|
235
291
|
* @param {(id: string, exceptPid?: number) => boolean} o.isLiveElsewhere
|
|
236
|
-
* @param {(dir: string) => boolean} o.isAllowedFolder where a new session may start
|
|
292
|
+
* @param {(dir: string) => boolean|Promise<boolean>} o.isAllowedFolder where a new session may start
|
|
237
293
|
* @param {() => {pid: number|null, sessionId: string, cwd: string|null, startedAt: number}[]} o.liveSessions
|
|
238
294
|
* claude's live-session registry, read fresh
|
|
239
295
|
* @param {(cmd: string) => string|null} o.which
|
|
@@ -286,10 +342,7 @@ function createTerminalService(o) {
|
|
|
286
342
|
}
|
|
287
343
|
|
|
288
344
|
function unavailableReason() {
|
|
289
|
-
|
|
290
|
-
if (net.EXPOSED) return 'refused while listening on a non-loopback address';
|
|
291
|
-
if (!load()) return loadError;
|
|
292
|
-
return null;
|
|
345
|
+
return localUnavailableReason(config, net) || (load() ? null : loadError);
|
|
293
346
|
}
|
|
294
347
|
|
|
295
348
|
function changed() {
|
|
@@ -319,11 +372,11 @@ function createTerminalService(o) {
|
|
|
319
372
|
function restore() {
|
|
320
373
|
if (!restoreQueue.length || unavailableReason()) return;
|
|
321
374
|
console.log(`Restoring ${restoreQueue.length} terminal(s)`);
|
|
322
|
-
const next = () => {
|
|
375
|
+
const next = async () => {
|
|
323
376
|
while (restoreQueue.length && !shuttingDown) {
|
|
324
377
|
const id = restoreQueue.shift();
|
|
325
378
|
if (sessions.has(id) || o.isLiveElsewhere(id)) continue;
|
|
326
|
-
const r = start(id, 'resume', {}, HEADLESS_COLS, HEADLESS_ROWS);
|
|
379
|
+
const r = await start(id, 'resume', {}, HEADLESS_COLS, HEADLESS_ROWS);
|
|
327
380
|
if (!r.error) break;
|
|
328
381
|
console.log(`Could not restore terminal ${id}: ${r.error}`);
|
|
329
382
|
}
|
|
@@ -474,7 +527,8 @@ function createTerminalService(o) {
|
|
|
474
527
|
// Claude's registry entry keeps its pid and swaps its sessionId to the picked one; the entry
|
|
475
528
|
// is found by folder and start time, and the pick shows as an id with a transcript behind it.
|
|
476
529
|
function watchPick(s) {
|
|
477
|
-
s.pickTimer = setInterval(() => {
|
|
530
|
+
s.pickTimer = setInterval(async () => {
|
|
531
|
+
if (s.pickChecking) return;
|
|
478
532
|
const live = o.liveSessions();
|
|
479
533
|
const entry = s.claudePid && live.find((l) => l.pid === s.claudePid);
|
|
480
534
|
if (!entry) {
|
|
@@ -485,7 +539,11 @@ function createTerminalService(o) {
|
|
|
485
539
|
return;
|
|
486
540
|
}
|
|
487
541
|
const picked = entry.sessionId;
|
|
488
|
-
if (picked === s.id
|
|
542
|
+
if (picked === s.id) return;
|
|
543
|
+
s.pickChecking = true;
|
|
544
|
+
const known = await o.resolveCwd(picked);
|
|
545
|
+
s.pickChecking = false;
|
|
546
|
+
if (known === null || s.exited || s.mode !== 'pick') return;
|
|
489
547
|
clearInterval(s.pickTimer);
|
|
490
548
|
// Two claude processes on one session write the same transcript. When cck already runs it,
|
|
491
549
|
// the pick is ended and its viewers move to that terminal; the sockets leave first so no
|
|
@@ -538,7 +596,7 @@ function createTerminalService(o) {
|
|
|
538
596
|
});
|
|
539
597
|
}
|
|
540
598
|
|
|
541
|
-
function onHello(ws, msg) {
|
|
599
|
+
async function onHello(ws, msg) {
|
|
542
600
|
if (!msg || msg.t !== 'hello' || !tokenMatches(token, msg.token)) {
|
|
543
601
|
send(ws, { t: 'error', msg: 'The terminal token is out of date. Reload the hub window.' });
|
|
544
602
|
return ws.close(4001);
|
|
@@ -560,36 +618,43 @@ function createTerminalService(o) {
|
|
|
560
618
|
send(ws, { t: 'live' });
|
|
561
619
|
return ws.close(1000);
|
|
562
620
|
}
|
|
563
|
-
const r = start(id, mode, msg, cols, rows);
|
|
621
|
+
const r = await start(id, mode, msg, cols, rows);
|
|
622
|
+
if (r.running) return attach(ws, r.running, true);
|
|
564
623
|
if (r.error) {
|
|
565
624
|
send(ws, { t: 'error', msg: r.error });
|
|
566
625
|
return ws.close(r.code);
|
|
567
626
|
}
|
|
627
|
+
// The viewer left while the PTY started; it runs headless until the next attach.
|
|
628
|
+
if (ws.readyState !== 1) return;
|
|
568
629
|
attach(ws, r.session, false);
|
|
569
630
|
}
|
|
570
631
|
|
|
571
632
|
// The admission rules shared by the socket hello and startNew. A refusal carries both a
|
|
572
633
|
// WebSocket close code and an HTTP status, so each caller maps it its own way.
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
634
|
+
// resolveCwd and isAllowedFolder may answer later, so the limits are checked again after them.
|
|
635
|
+
async function start(id, mode, msg, cols, rows, extraEnv) {
|
|
636
|
+
const full = { code: 4003, status: 429, error: `${config.maxSessions} terminals are open; end one first` };
|
|
637
|
+
if (sessions.size >= config.maxSessions) return full;
|
|
638
|
+
let spec = null;
|
|
639
|
+
if (mode === 'new') {
|
|
640
|
+
spec = parseNewSpec(msg);
|
|
641
|
+
if (typeof spec === 'string') return { code: 4002, status: 400, error: `invalid ${spec}` };
|
|
576
642
|
}
|
|
577
|
-
const known = o.resolveCwd(id);
|
|
643
|
+
const known = await o.resolveCwd(id);
|
|
578
644
|
let cwd = known;
|
|
579
|
-
let spec = null;
|
|
580
645
|
if (mode === 'new' || mode === 'pick') {
|
|
581
|
-
if (mode === 'new') {
|
|
582
|
-
spec = parseNewSpec(msg);
|
|
583
|
-
if (typeof spec === 'string') return { code: 4002, status: 400, error: `invalid ${spec}` };
|
|
584
|
-
}
|
|
585
646
|
if (known !== null) return { code: 4006, status: 409, error: 'session id already in use' };
|
|
586
|
-
if (typeof msg.cwd !== 'string' || !o.isAllowedFolder(msg.cwd)) {
|
|
647
|
+
if (typeof msg.cwd !== 'string' || !(await o.isAllowedFolder(msg.cwd))) {
|
|
587
648
|
return { code: 4004, status: 403, error: 'folder is not a known project or a folder picked in this run' };
|
|
588
649
|
}
|
|
589
650
|
cwd = msg.cwd;
|
|
590
651
|
} else if (mode !== 'shell' && known === null) {
|
|
591
652
|
return { code: 4004, status: 404, error: 'unknown session' };
|
|
592
653
|
}
|
|
654
|
+
if (shuttingDown) return { code: 1001, status: 503, error: 'the terminal is shutting down' };
|
|
655
|
+
const running = sessions.get(id);
|
|
656
|
+
if (running && !running.ended) return { running, code: 4006, status: 409, error: 'session id already in use' };
|
|
657
|
+
if (sessions.size >= config.maxSessions) return full;
|
|
593
658
|
try {
|
|
594
659
|
return { session: spawnSession(id, mode === 'auto' ? 'resume' : mode, cwd, cols, rows, spec, extraEnv) };
|
|
595
660
|
} catch (e) {
|
|
@@ -598,31 +663,24 @@ function createTerminalService(o) {
|
|
|
598
663
|
}
|
|
599
664
|
|
|
600
665
|
function handleUpgrade(req, socket, head) {
|
|
601
|
-
|
|
602
|
-
try { pathname = new URL(req.url, 'http://x').pathname; } catch { pathname = ''; }
|
|
603
|
-
if (pathname !== WS_PATH) return refuse(socket, '404 Not Found', 'no such endpoint');
|
|
604
|
-
const reason = unavailableReason() || net.upgradeVerdict(req);
|
|
605
|
-
if (reason) return refuse(socket, '403 Forbidden', reason);
|
|
666
|
+
if (refuseUpgrade(req, socket, () => unavailableReason() || net.upgradeVerdict(req))) return;
|
|
606
667
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
607
668
|
const timer = setTimeout(() => ws.close(4001), HELLO_TIMEOUT_MS);
|
|
608
669
|
ws.once('message', (raw, isBinary) => {
|
|
609
670
|
clearTimeout(timer);
|
|
610
671
|
let msg = null;
|
|
611
672
|
if (!isBinary) { try { msg = JSON.parse(raw.toString()); } catch { /* treated as bad hello */ } }
|
|
612
|
-
onHello(ws, msg)
|
|
673
|
+
onHello(ws, msg).catch((e) => {
|
|
674
|
+
send(ws, { t: 'error', msg: `failed to start the terminal: ${e.message}` });
|
|
675
|
+
ws.close(1011);
|
|
676
|
+
});
|
|
613
677
|
});
|
|
614
678
|
ws.on('error', () => {});
|
|
615
679
|
});
|
|
616
680
|
}
|
|
617
681
|
|
|
618
682
|
function clientConfig() {
|
|
619
|
-
return
|
|
620
|
-
available: !unavailableReason(),
|
|
621
|
-
fontFamily: config.fontFamily,
|
|
622
|
-
fontSize: config.fontSize,
|
|
623
|
-
scrollback: config.scrollback,
|
|
624
|
-
maxSessions: config.maxSessions,
|
|
625
|
-
};
|
|
683
|
+
return clientConfigFor(config, !unavailableReason());
|
|
626
684
|
}
|
|
627
685
|
|
|
628
686
|
// kill() returns before the process is gone, so an ended session leaves list() at once.
|
|
@@ -669,10 +727,11 @@ function createTerminalService(o) {
|
|
|
669
727
|
|
|
670
728
|
// A 'new' session for a caller with no socket. The PTY runs headless until a browser
|
|
671
729
|
// attaches, and a later attach resizes it.
|
|
672
|
-
function startNew(msg, extraEnv) {
|
|
730
|
+
async function startNew(msg, extraEnv) {
|
|
673
731
|
const reason = unavailableReason();
|
|
674
732
|
if (reason) return { status: 403, error: reason };
|
|
675
|
-
|
|
733
|
+
if (msg.id != null && !(typeof msg.id === 'string' && UUID_RE.test(msg.id))) return { status: 400, error: 'invalid session id' };
|
|
734
|
+
const r = await start(msg.id || crypto.randomUUID(), 'new', msg, HEADLESS_COLS, HEADLESS_ROWS, extraEnv);
|
|
676
735
|
return r.error ? r : { id: r.session.id, cwd: r.session.cwd };
|
|
677
736
|
}
|
|
678
737
|
|
|
@@ -700,4 +759,4 @@ function createTerminalService(o) {
|
|
|
700
759
|
return { token, handleUpgrade, clientConfig, list, claudePids, isRunning, end, authorized, startNew, paste, restore, shutdown, unavailableReason };
|
|
701
760
|
}
|
|
702
761
|
|
|
703
|
-
module.exports = { createTerminalService, readTerminalConfig, ptyEnv, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|
|
762
|
+
module.exports = { createTerminalService, readTerminalConfig, localUnavailableReason, refuseUpgrade, clientConfigFor, ptyEnv, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.23.0",
|
|
4
4
|
"description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, skills to drive the board from a session and to follow it",
|
|
5
5
|
"experimental": {
|
|
6
6
|
"monitors": "./monitors.json"
|
|
@@ -12,9 +12,10 @@ export function displayName(model: string) {
|
|
|
12
12
|
const toEpochSeconds = (iso?: string) => (iso ? Math.floor(Date.parse(iso) / 1000) : undefined)
|
|
13
13
|
|
|
14
14
|
// The board reads the statusLine's JSON shape, so the file keeps its field names.
|
|
15
|
-
export function toStatus(model: string, m: Measure, usage: ModelUsage | undefined) {
|
|
15
|
+
export function toStatus(model: string, m: Measure, usage: ModelUsage | undefined, requestAt?: number) {
|
|
16
16
|
const { context } = m
|
|
17
17
|
return {
|
|
18
|
+
...(requestAt && { cache: { last_request_at: requestAt } }),
|
|
18
19
|
model: { id: model, display_name: displayName(model) },
|
|
19
20
|
cost: { total_cost_usd: m.cost?.usd ?? 0 },
|
|
20
21
|
context_window: {
|
|
@@ -43,6 +44,7 @@ function cckDir($: EngineInterface) {
|
|
|
43
44
|
|
|
44
45
|
let lastUsage: ModelUsage | undefined
|
|
45
46
|
let lastModel: string | undefined
|
|
47
|
+
let lastRequestAt: number | undefined
|
|
46
48
|
const lastWritten = new Map<string, string>()
|
|
47
49
|
|
|
48
50
|
async function write($: EngineInterface, m: Measure) {
|
|
@@ -51,7 +53,7 @@ async function write($: EngineInterface, m: Measure) {
|
|
|
51
53
|
lastModel ?? $.session.model(),
|
|
52
54
|
cckDir($),
|
|
53
55
|
])
|
|
54
|
-
const text = JSON.stringify(toStatus(model, m, lastUsage))
|
|
56
|
+
const text = JSON.stringify(toStatus(model, m, lastUsage, lastRequestAt))
|
|
55
57
|
const file = `${dir}/context-status/${sessionId}.json`
|
|
56
58
|
if (lastWritten.get(file) === text) return
|
|
57
59
|
await $.fs.write(file, text)
|
|
@@ -65,6 +67,7 @@ export const register: Register = on => {
|
|
|
65
67
|
const { model, ...usage } = result.usage
|
|
66
68
|
lastUsage = usage
|
|
67
69
|
lastModel = model
|
|
70
|
+
lastRequestAt = Date.now()
|
|
68
71
|
await write($, await $.session.usage())
|
|
69
72
|
}
|
|
70
73
|
return result
|
|
@@ -4,11 +4,5 @@
|
|
|
4
4
|
"description": "Notifies this session when its tasks are moved on the kanban board or the user sends it review comments.",
|
|
5
5
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/postman.js\"",
|
|
6
6
|
"when": "on-skill-invoke:claude-code-kanban:follow"
|
|
7
|
-
},
|
|
8
|
-
{
|
|
9
|
-
"name": "kanban-dispatch-inbox",
|
|
10
|
-
"description": "Notifies this session when a session it dispatched through cck reports or exits.",
|
|
11
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/postman.js\" --topic dispatch --keep-backlog",
|
|
12
|
-
"when": "on-skill-invoke:claude-code-kanban:dispatch"
|
|
13
7
|
}
|
|
14
8
|
]
|
|
@@ -29,10 +29,6 @@ const RETRY_MS = 15000;
|
|
|
29
29
|
// Windows sometimes fails a loopback connect with ETIMEDOUT, or resets it once open, while the
|
|
30
30
|
// board is up, so those errors get a few short waits before the normal one.
|
|
31
31
|
const CONNECT_RETRY_MS = [250, 500, 1000, 2000];
|
|
32
|
-
// `--topic dispatch` is the dispatch inbox: reports from sessions this one started.
|
|
33
|
-
const TOPIC = process.argv.includes('--topic') ? process.argv[process.argv.indexOf('--topic') + 1] : null;
|
|
34
|
-
// A dispatch report is a result, not an instruction, so a late attach still wants it.
|
|
35
|
-
const KEEP_BACKLOG = process.argv.includes('--keep-backlog');
|
|
36
32
|
|
|
37
33
|
if (!SESSION_ID) process.exit(0);
|
|
38
34
|
|
|
@@ -53,12 +49,11 @@ function serverUrl() {
|
|
|
53
49
|
// Once per process, not once per poll: the grant means "follow the board from here on", so
|
|
54
50
|
// the first attach throws away whatever queued up before it. A later reconnect must not
|
|
55
51
|
// discard again -- by then the queue holds events the user is owed.
|
|
56
|
-
let firstAttach =
|
|
52
|
+
let firstAttach = true;
|
|
57
53
|
|
|
58
54
|
async function poll(base) {
|
|
59
55
|
const first = firstAttach ? '&first=1' : '';
|
|
60
|
-
const
|
|
61
|
-
const url = `${base}/api/sessions/${encodeURIComponent(SESSION_ID)}/events?wait=${WAIT_SEC}${first}${topic}`;
|
|
56
|
+
const url = `${base}/api/sessions/${encodeURIComponent(SESSION_ID)}/events?wait=${WAIT_SEC}${first}`;
|
|
62
57
|
const res = await fetch(url, { signal: AbortSignal.timeout((WAIT_SEC + 15) * 1000) });
|
|
63
58
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
64
59
|
firstAttach = false;
|
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: dispatch
|
|
3
|
-
description: Dispatch
|
|
4
|
-
argument-hint: '<task> [--
|
|
3
|
+
description: Dispatch tasks to new Claude Code sessions in the kanban board's terminal. Use when the user asks to dispatch or delegate a task to another session.
|
|
4
|
+
argument-hint: '<task> [--handoff] [--group <name>] [--model haiku|sonnet|opus|fable] [--worktree [name]] [-- <claude args>]'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Kanban dispatch
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Invoking this skill also arms this session's dispatch inbox: when a session you dispatched with `--report` reports or exits, a line arrives here:
|
|
12
|
-
|
|
13
|
-
```
|
|
14
|
-
[kanban board] Dispatch <dispatch-id> (session <uuid>) <reported success|reported failure|ended without a report>. Summary: <text>
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
Load the guide before running any dispatch command:
|
|
9
|
+
The guide ships with the `claude-code-kanban` binary, so it always matches the commands that binary accepts. Load it before running any dispatch command:
|
|
18
10
|
|
|
19
11
|
```bash
|
|
20
12
|
claude-code-kanban skills get dispatch
|
|
21
13
|
```
|
|
22
14
|
|
|
23
15
|
Fall back to `npx claude-code-kanban` when the bare binary is not on PATH. If `skills get` is unknown, the installed cck is too old: tell the user to update it, and do not guess commands.
|
|
16
|
+
|
|
17
|
+
## Reply
|
|
18
|
+
|
|
19
|
+
End every spec with the reply line, because you usually need the result to continue:
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
When you are done, or cannot finish, send the result to <your peer name> with the SendMessage tool, then stop.
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`--handoff` is a skill argument that drops the reply line: the session owns the task, and the user follows it on the board. Keep it out of the `dispatch start` command.
|
|
26
|
+
|
|
27
|
+
## Orchestration patterns
|
|
28
|
+
|
|
29
|
+
Use request-reply, handoff or orchestrator-workers directly. When another shape fits better (a separate reviewer, steps that feed each other, a sub-orchestrator, a decision for the user), propose it in one line, the pattern and why, and dispatch only after the user agrees, unless the user named it. Details: [references/orchestration-patterns.md](references/orchestration-patterns.md).
|
package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Orchestration patterns
|
|
2
|
+
|
|
3
|
+
Each pattern is only `dispatch start` plus `SendMessage`; the specs decide who talks to whom. A **worker** is a session you dispatch.
|
|
4
|
+
|
|
5
|
+
## Select
|
|
6
|
+
|
|
7
|
+
Use one of these directly:
|
|
8
|
+
|
|
9
|
+
| Pattern | When |
|
|
10
|
+
|---|---|
|
|
11
|
+
| Request-reply | The default |
|
|
12
|
+
| Handoff | The user passed `--handoff` or asked for no reply |
|
|
13
|
+
| Orchestrator-workers | The task splits into parts that do not depend on each other |
|
|
14
|
+
|
|
15
|
+
Propose these first, unless the user named one. Each adds steps, rounds or sessions the user did not ask for.
|
|
16
|
+
|
|
17
|
+
| Pattern | Fits when |
|
|
18
|
+
|---|---|
|
|
19
|
+
| Prompt chaining | A step needs the previous step's output |
|
|
20
|
+
| Evaluator-optimizer | There is a clear acceptance bar and a second look improves the result |
|
|
21
|
+
| Hierarchical | A part is itself large enough to split |
|
|
22
|
+
| Human-in-the-loop | The spec leaves a choice the user should make |
|
|
23
|
+
|
|
24
|
+
## Request-reply
|
|
25
|
+
|
|
26
|
+
One worker, one reply. The reply can go to any peer. When another session owns the work and should act on the result, name it in the reply line, and ask for a one-line note to you as well, so you know the worker finished.
|
|
27
|
+
|
|
28
|
+
## Handoff
|
|
29
|
+
|
|
30
|
+
One worker, no reply line. The worker owns the task, and you save the turn a reply costs.
|
|
31
|
+
|
|
32
|
+
## Orchestrator-workers
|
|
33
|
+
|
|
34
|
+
One worker per independent part, all started before you wait on any. They run at the same time, and each holds only its own part in context. Give each a unique `--name` and one shared `--group`, then combine the replies.
|
|
35
|
+
|
|
36
|
+
## Prompt chaining
|
|
37
|
+
|
|
38
|
+
Workers in sequence; each spec carries the previous reply. You check each output before you start the next step, and stop the chain when a check fails.
|
|
39
|
+
|
|
40
|
+
## Evaluator-optimizer
|
|
41
|
+
|
|
42
|
+
A generator and an evaluator that message each other until the evaluator accepts. A separate evaluator judges the work with fresh eyes. Give each the other's name, and cap the rounds in both specs: `stop after 3 rounds and send what you have`.
|
|
43
|
+
|
|
44
|
+
## Hierarchical
|
|
45
|
+
|
|
46
|
+
A worker that dispatches its own workers and replies once they all have. Your context then holds one reply per part instead of one per leaf. Its spec says to wait for its workers and to give them its own peer name.
|
|
47
|
+
|
|
48
|
+
## Human-in-the-loop
|
|
49
|
+
|
|
50
|
+
Add to the worker's spec: `When you need a decision, ask <your peer name> with the SendMessage tool and keep working on what does not depend on the answer.` Ask the user when the decision is theirs, and reply with `SendMessage`. You can steer a running worker the same way at any time.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { On, SessionMeasureInput } from 'claude-code'
|
|
2
2
|
import { expect, test } from 'claude-code/testing'
|
|
3
|
-
import { displayName } from '../hooks/context'
|
|
3
|
+
import { displayName, toStatus } from '../hooks/context'
|
|
4
4
|
|
|
5
5
|
const MEASURE: SessionMeasureInput = {
|
|
6
6
|
context: { tokens: 129_174, window: 1_000_000, percent: 13 },
|
|
@@ -45,6 +45,19 @@ test('session.measure writes the statusLine shape the board reads', async ($, on
|
|
|
45
45
|
expect(status.rate_limits.seven_day.used_percentage).toBe(7.5)
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
+
test('session.measure writes no cache entry before the first request', async ($, on) => {
|
|
49
|
+
const writes = engine(on, { CLAUDE_CONFIG_DIR: 'C:/cfg' }, 'sid-4')
|
|
50
|
+
|
|
51
|
+
await $.session.measure(MEASURE)
|
|
52
|
+
|
|
53
|
+
expect(JSON.parse(writes[0]?.text ?? '{}').cache).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('toStatus adds the last request time', async () => {
|
|
57
|
+
const status = toStatus('claude-opus-5-5', MEASURE, undefined, 1_790_000_000_000)
|
|
58
|
+
expect(status.cache).toEqual({ last_request_at: 1_790_000_000_000 })
|
|
59
|
+
})
|
|
60
|
+
|
|
48
61
|
test('skips a write when the status has not changed', async ($, on) => {
|
|
49
62
|
const writes = engine(on, { CLAUDE_CONFIG_DIR: 'C:/cfg' }, 'sid-3')
|
|
50
63
|
|