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/server.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
const express = require('express');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const fs = require('node:fs').promises;
|
|
7
|
-
const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, unlinkSync, mkdirSync, renameSync, openSync, readSync, closeSync } = require('node:fs');
|
|
7
|
+
const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, unlinkSync, mkdirSync, renameSync, openSync, readSync, closeSync, realpathSync } = require('node:fs');
|
|
8
8
|
const _readline = require('node:readline');
|
|
9
9
|
const chokidar = require('chokidar');
|
|
10
10
|
const os = require('node:os');
|
|
@@ -37,7 +37,11 @@ const {
|
|
|
37
37
|
} = require('./lib/parsers');
|
|
38
38
|
const { inlineHtmlAssets, MIME_BY_EXT } = require('./lib/inline-assets');
|
|
39
39
|
const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
|
|
40
|
-
const { getClaudeDir, getArgValue, storageNamespace } = require('./lib/claude-dir');
|
|
40
|
+
const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
|
|
41
|
+
const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
|
|
42
|
+
const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName } = require('./lib/dispatch');
|
|
43
|
+
const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
|
|
44
|
+
const { pickFolder } = require('./lib/folder-dialog');
|
|
41
45
|
|
|
42
46
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
43
47
|
const { runInstall, runUninstall } = require("./install");
|
|
@@ -77,7 +81,9 @@ const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
|
|
|
77
81
|
const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
|
|
78
82
|
const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
|
|
79
83
|
const PINS_FILE = path.join(CCK_DIR, 'pins.json');
|
|
84
|
+
const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
|
|
80
85
|
const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
|
|
86
|
+
const TERMINAL_TOKEN_FILE = path.join(CCK_DIR, 'terminal-token.json');
|
|
81
87
|
// Harness-owned scratchpad root; the per-session dir under it is created lazily.
|
|
82
88
|
const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
|
|
83
89
|
|
|
@@ -93,11 +99,11 @@ function readPins() {
|
|
|
93
99
|
return {};
|
|
94
100
|
}
|
|
95
101
|
|
|
96
|
-
function writeJsonAtomic(file, obj) {
|
|
102
|
+
function writeJsonAtomic(file, obj, mode) {
|
|
97
103
|
try {
|
|
98
104
|
mkdirSync(CCK_DIR, { recursive: true });
|
|
99
105
|
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
100
|
-
writeFileSync(tmp, JSON.stringify(obj, null, 2), 'utf8');
|
|
106
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2), { encoding: 'utf8', mode });
|
|
101
107
|
renameSync(tmp, file);
|
|
102
108
|
} catch (e) {
|
|
103
109
|
console.error(`Failed to write ${path.basename(file)}:`, e.message);
|
|
@@ -113,6 +119,14 @@ function writePins(pins) {
|
|
|
113
119
|
// a crashed one.
|
|
114
120
|
function writeServerInfo(port) {
|
|
115
121
|
writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
|
|
122
|
+
writeTerminalToken();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// For `dispatch start`: a local process as the same user can already run claude itself,
|
|
126
|
+
// so handing it the token adds little. A browser still cannot read the file.
|
|
127
|
+
function writeTerminalToken() {
|
|
128
|
+
if (terminal.unavailableReason()) return;
|
|
129
|
+
writeJsonAtomic(TERMINAL_TOKEN_FILE, { pid: process.pid, token: terminal.token }, 0o600);
|
|
116
130
|
}
|
|
117
131
|
|
|
118
132
|
// A beacon that outlives its server disarms UI approvals silently: approval-gate.sh
|
|
@@ -121,10 +135,22 @@ function writeServerInfo(port) {
|
|
|
121
135
|
// the way out. Only when the file is still ours: a newer server on the same config
|
|
122
136
|
// dir has already claimed it.
|
|
123
137
|
function removeServerInfo() {
|
|
138
|
+
for (const file of [SERVER_INFO_FILE, TERMINAL_TOKEN_FILE]) {
|
|
139
|
+
try {
|
|
140
|
+
const info = JSON.parse(readFileSync(file, 'utf8'));
|
|
141
|
+
if (info.pid === process.pid) unlinkSync(file);
|
|
142
|
+
} catch (_) { /* gone, unreadable, or not ours */ }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// A second server on the same config dir (a test hub, say) takes the beacon and removes it on
|
|
147
|
+
// exit, which leaves this live server undiscoverable. A newer live owner keeps it.
|
|
148
|
+
function reclaimServerInfo(port) {
|
|
124
149
|
try {
|
|
125
|
-
const
|
|
126
|
-
if (
|
|
127
|
-
} catch (_) { /*
|
|
150
|
+
const { pid } = JSON.parse(readFileSync(SERVER_INFO_FILE, 'utf8'));
|
|
151
|
+
if (pid === process.pid || isPidAlive(pid)) return;
|
|
152
|
+
} catch (_) { /* missing or unreadable beacon */ }
|
|
153
|
+
writeServerInfo(port);
|
|
128
154
|
}
|
|
129
155
|
|
|
130
156
|
process.on('exit', removeServerInfo);
|
|
@@ -422,9 +448,9 @@ let liveSessionsCache = null;
|
|
|
422
448
|
let lastLiveSessionsScan = 0;
|
|
423
449
|
const LIVE_SESSIONS_TTL = 5000;
|
|
424
450
|
|
|
425
|
-
function loadLiveSessions() {
|
|
451
|
+
function loadLiveSessions(fresh = false) {
|
|
426
452
|
const now = Date.now();
|
|
427
|
-
if (liveSessionsCache && now - lastLiveSessionsScan < LIVE_SESSIONS_TTL) return liveSessionsCache;
|
|
453
|
+
if (!fresh && liveSessionsCache && now - lastLiveSessionsScan < LIVE_SESSIONS_TTL) return liveSessionsCache;
|
|
428
454
|
const sessions = [];
|
|
429
455
|
if (existsSync(SESSIONS_DIR)) {
|
|
430
456
|
try {
|
|
@@ -432,7 +458,7 @@ function loadLiveSessions() {
|
|
|
432
458
|
try {
|
|
433
459
|
const s = JSON.parse(readFileSync(path.join(SESSIONS_DIR, file), 'utf8'));
|
|
434
460
|
if (s?.sessionId && s.kind === 'interactive') {
|
|
435
|
-
sessions.push({ sessionId: s.sessionId, cwd: s.cwd || null, startedAt: s.startedAt || 0, status: s.status || null });
|
|
461
|
+
sessions.push({ sessionId: s.sessionId, pid: s.pid || null, cwd: s.cwd || null, startedAt: s.startedAt || 0, status: s.status || null });
|
|
436
462
|
}
|
|
437
463
|
} catch (_) { /* skip invalid */ }
|
|
438
464
|
}
|
|
@@ -453,6 +479,19 @@ function isRegistryIdle(sessionId) {
|
|
|
453
479
|
return live?.status === 'idle';
|
|
454
480
|
}
|
|
455
481
|
|
|
482
|
+
// A registry file outlives a crashed claude, so the pid is probed rather than trusted.
|
|
483
|
+
function isSessionProcessAlive(sessionId, exceptPid = null) {
|
|
484
|
+
return loadLiveSessions().some((s) => {
|
|
485
|
+
if (s.sessionId !== sessionId || !s.pid || s.pid === exceptPid) return false;
|
|
486
|
+
return isPidAlive(s.pid);
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// EPERM means the process exists but belongs to someone else.
|
|
491
|
+
function isPidAlive(pid) {
|
|
492
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
493
|
+
}
|
|
494
|
+
|
|
456
495
|
function hasRecentLogActivity(sessionId, logAge) {
|
|
457
496
|
return logAge <= SESSION_STALE_MS && !isRegistryIdle(sessionId);
|
|
458
497
|
}
|
|
@@ -1165,6 +1204,7 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1165
1204
|
const pinnedIds = pinnedParam ? new Set(pinnedParam.split(',').filter(Boolean)) : new Set();
|
|
1166
1205
|
for (const id of includeIds) pinnedIds.add(id);
|
|
1167
1206
|
const activeFilter = req.query.filter === 'active';
|
|
1207
|
+
const terminalIds = activeFilter ? new Set(terminal.list().map((t) => t.id)) : new Set();
|
|
1168
1208
|
|
|
1169
1209
|
const metadata = loadSessionMetadata();
|
|
1170
1210
|
const sessionsMap = new Map();
|
|
@@ -1194,7 +1234,7 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1194
1234
|
|
|
1195
1235
|
// Cheap-probe: when filter=active, skip expensive enrichment for inactive non-pinned sessions.
|
|
1196
1236
|
// Mirrors the post-filter predicate using only signals already computed above.
|
|
1197
|
-
if (activeFilter && !pinnedIds.has(entry.name)) {
|
|
1237
|
+
if (activeFilter && !pinnedIds.has(entry.name) && !terminalIds.has(entry.name)) {
|
|
1198
1238
|
const cheaplyActive = logStat.hasMessages && (
|
|
1199
1239
|
hasVisibleLogActivity(entry.name, logAge)
|
|
1200
1240
|
|| agentStatus.hasActive
|
|
@@ -1294,7 +1334,7 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1294
1334
|
const metaAgentStatus = checkAgentStatus(metaAgentDir, stale, logMtime, metaIsTeam);
|
|
1295
1335
|
|
|
1296
1336
|
// Cheap-probe: no tasks here (metadata-only), so active = recent log OR live agent.
|
|
1297
|
-
if (activeFilter && !pinnedIds.has(sessionId)) {
|
|
1337
|
+
if (activeFilter && !pinnedIds.has(sessionId) && !terminalIds.has(sessionId)) {
|
|
1298
1338
|
const cheaplyActive = logStat.hasMessages && (
|
|
1299
1339
|
hasVisibleLogActivity(sessionId, logAge) || metaAgentStatus.hasActive || !!metaAgentStatus.waitingForUser
|
|
1300
1340
|
);
|
|
@@ -1495,7 +1535,7 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1495
1535
|
|| s.hasRecentActivity
|
|
1496
1536
|
);
|
|
1497
1537
|
for (const [id, s] of sessionsMap) {
|
|
1498
|
-
if (pinnedIds.has(id)) continue;
|
|
1538
|
+
if (pinnedIds.has(id) || terminalIds.has(id)) continue;
|
|
1499
1539
|
if (!isActive(s)) sessionsMap.delete(id);
|
|
1500
1540
|
}
|
|
1501
1541
|
}
|
|
@@ -1522,13 +1562,22 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1522
1562
|
sessions = [...top, ...missingPinned];
|
|
1523
1563
|
}
|
|
1524
1564
|
|
|
1525
|
-
res.json(sessions);
|
|
1565
|
+
res.json(withDispatchPlacement(sessions));
|
|
1526
1566
|
} catch (error) {
|
|
1527
1567
|
console.error('Error listing sessions:', error);
|
|
1528
1568
|
res.status(500).json({ error: 'Failed to list sessions' });
|
|
1529
1569
|
}
|
|
1530
1570
|
});
|
|
1531
1571
|
|
|
1572
|
+
// os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
|
|
1573
|
+
const TEMP_ROOT = (() => {
|
|
1574
|
+
try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
|
|
1575
|
+
})();
|
|
1576
|
+
function isTempPath(p) {
|
|
1577
|
+
const rel = path.relative(TEMP_ROOT, p);
|
|
1578
|
+
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1532
1581
|
// API: Get distinct project paths with last-modified timestamps
|
|
1533
1582
|
app.get('/api/projects', (_req, res) => {
|
|
1534
1583
|
res.setHeader('Cache-Control', 'no-store');
|
|
@@ -1542,7 +1591,11 @@ app.get('/api/projects', (_req, res) => {
|
|
|
1542
1591
|
}
|
|
1543
1592
|
}
|
|
1544
1593
|
const projects = Object.entries(projectMap)
|
|
1545
|
-
.map(([path, mtime]) => ({
|
|
1594
|
+
.map(([path, mtime]) => ({
|
|
1595
|
+
path,
|
|
1596
|
+
modifiedAt: mtime ? new Date(mtime).toISOString() : null,
|
|
1597
|
+
...(isTempPath(path) && { temp: true }),
|
|
1598
|
+
}))
|
|
1546
1599
|
.sort((a, b) => a.path.localeCompare(b.path));
|
|
1547
1600
|
res.json(projects);
|
|
1548
1601
|
});
|
|
@@ -3074,9 +3127,169 @@ app.get('/api/config', (_req, res) => {
|
|
|
3074
3127
|
costUrl: COST_URL,
|
|
3075
3128
|
memoryUrl: MEMORY_URL,
|
|
3076
3129
|
scratchAvailable: !!whichSync('scratch'),
|
|
3130
|
+
terminal: terminal.clientConfig(),
|
|
3131
|
+
});
|
|
3132
|
+
});
|
|
3133
|
+
|
|
3134
|
+
// #endregion
|
|
3135
|
+
|
|
3136
|
+
// #region TERMINAL
|
|
3137
|
+
// A new session may start only in a folder the user has already worked in, or one they
|
|
3138
|
+
// chose in the native dialog during this run. Anything else would let a page script pick
|
|
3139
|
+
// the directory claude runs in.
|
|
3140
|
+
const pickedFolders = new Set();
|
|
3141
|
+
function isAllowedFolder(dir) {
|
|
3142
|
+
const known = pickedFolders.has(dir) || Object.values(loadSessionMetadata()).some((m) => m.project === dir);
|
|
3143
|
+
try { return known && statSync(dir).isDirectory(); } catch { return false; }
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
const terminal = createTerminalService({
|
|
3147
|
+
config: readTerminalConfig({ getArgValue }),
|
|
3148
|
+
net,
|
|
3149
|
+
claudeDir: CLAUDE_DIR,
|
|
3150
|
+
isDefaultDir: isDefaultClaudeDir(CLAUDE_DIR),
|
|
3151
|
+
token: process.env.CCK_TERMINAL_TOKEN,
|
|
3152
|
+
which: whichSync,
|
|
3153
|
+
isLiveElsewhere: isSessionProcessAlive,
|
|
3154
|
+
// The project, not the last cwd: `claude --resume` finds a session under the
|
|
3155
|
+
// project dir it started in, and cwd drifts into subdirectories.
|
|
3156
|
+
resolveCwd: (id) => {
|
|
3157
|
+
const meta = loadSessionMetadata()[id];
|
|
3158
|
+
return meta ? meta.project || meta.cwd || null : null;
|
|
3159
|
+
},
|
|
3160
|
+
isAllowedFolder,
|
|
3161
|
+
liveSessions: () => loadLiveSessions(true),
|
|
3162
|
+
onChange: () => broadcast({ type: 'terminals-update', ids: terminal.list().map((t) => t.id) }),
|
|
3163
|
+
onExit: (id) => dispatches.sessionExited(id),
|
|
3164
|
+
});
|
|
3165
|
+
|
|
3166
|
+
let folderDialogOpen = false;
|
|
3167
|
+
app.post('/api/terminal/pick-folder', async (req, res) => {
|
|
3168
|
+
const reason = terminal.unavailableReason();
|
|
3169
|
+
if (reason) return res.status(403).json({ error: reason });
|
|
3170
|
+
if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
|
|
3171
|
+
if (folderDialogOpen) return res.status(409).json({ error: 'a folder dialog is already open' });
|
|
3172
|
+
folderDialogOpen = true;
|
|
3173
|
+
try {
|
|
3174
|
+
const dir = await pickFolder(whichSync, { start: req.body?.start });
|
|
3175
|
+
if (dir) pickedFolders.add(dir);
|
|
3176
|
+
res.json({ path: dir });
|
|
3177
|
+
} catch (e) {
|
|
3178
|
+
res.status(501).json({ error: e.message });
|
|
3179
|
+
} finally {
|
|
3180
|
+
folderDialogOpen = false;
|
|
3181
|
+
}
|
|
3182
|
+
});
|
|
3183
|
+
|
|
3184
|
+
// The hub's eviction check reads this to keep a pool with live PTYs alive.
|
|
3185
|
+
app.get('/api/terminals', (_req, res) => {
|
|
3186
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3187
|
+
res.json({ sessions: terminal.list() });
|
|
3188
|
+
});
|
|
3189
|
+
|
|
3190
|
+
app.delete('/api/terminals/:id', (req, res) => {
|
|
3191
|
+
const err = terminal.end(req.params.id, req.get('x-terminal-token'));
|
|
3192
|
+
if (err === 'auth') return res.status(401).json({ error: 'invalid terminal token' });
|
|
3193
|
+
if (err === 'not-found') return res.status(404).json({ error: 'no such terminal' });
|
|
3194
|
+
res.status(204).end();
|
|
3195
|
+
});
|
|
3196
|
+
|
|
3197
|
+
// Served from node_modules, never a CDN: any script on this page can use the token.
|
|
3198
|
+
const XTERM_FILES = {
|
|
3199
|
+
'xterm.js': '@xterm/xterm/lib/xterm.js',
|
|
3200
|
+
'xterm.css': '@xterm/xterm/css/xterm.css',
|
|
3201
|
+
'addon-fit.js': '@xterm/addon-fit/lib/addon-fit.js',
|
|
3202
|
+
'addon-webgl.js': '@xterm/addon-webgl/lib/addon-webgl.js',
|
|
3203
|
+
'addon-unicode11.js': '@xterm/addon-unicode11/lib/addon-unicode11.js',
|
|
3204
|
+
};
|
|
3205
|
+
app.get('/vendor/xterm/:file', (req, res) => {
|
|
3206
|
+
const rel = Object.hasOwn(XTERM_FILES, req.params.file) ? XTERM_FILES[req.params.file] : null;
|
|
3207
|
+
if (!rel) return res.status(404).end();
|
|
3208
|
+
let file;
|
|
3209
|
+
try { file = require.resolve(rel); } catch { return res.status(404).end(); }
|
|
3210
|
+
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
3211
|
+
res.sendFile(file);
|
|
3212
|
+
});
|
|
3213
|
+
// #endregion
|
|
3214
|
+
|
|
3215
|
+
// #region DISPATCH
|
|
3216
|
+
const dispatches = createDispatchRegistry({
|
|
3217
|
+
onSettle: (r) => {
|
|
3218
|
+
if (r.parent && r.report) enqueueSessionEvent(topicKey('dispatch', r.parent), formatDispatchLine(r));
|
|
3219
|
+
broadcast({ type: 'dispatch-update' });
|
|
3220
|
+
},
|
|
3221
|
+
});
|
|
3222
|
+
|
|
3223
|
+
const dispatchGroups = createGroupStore({
|
|
3224
|
+
load: () => {
|
|
3225
|
+
try { return JSON.parse(readFileSync(DISPATCH_GROUPS_FILE, 'utf8')); } catch { return null; }
|
|
3226
|
+
},
|
|
3227
|
+
save: (data) => writeJsonAtomic(DISPATCH_GROUPS_FILE, data),
|
|
3228
|
+
isAlive: (id) => terminal.isRunning(id) || isSessionProcessAlive(id),
|
|
3229
|
+
pinnedIds: () => new Set(Object.keys(readPins())),
|
|
3230
|
+
});
|
|
3231
|
+
|
|
3232
|
+
// The board places a session from these alone, so it never has to move it later.
|
|
3233
|
+
// `startedBy` lets it follow a starter the user put in a named group, which only the
|
|
3234
|
+
// browser knows; it goes once the dispatch settles.
|
|
3235
|
+
function withDispatchPlacement(sessions) {
|
|
3236
|
+
const groups = dispatchGroups.snapshot();
|
|
3237
|
+
const starters = new Map(
|
|
3238
|
+
dispatches.list().filter((r) => r.status === 'running' && r.parent && r.session).map((r) => [r.session, r.parent]),
|
|
3239
|
+
);
|
|
3240
|
+
if (!groups.size && !starters.size) return sessions;
|
|
3241
|
+
return sessions.map((s) => {
|
|
3242
|
+
const dispatchGroup = groups.get(s.id);
|
|
3243
|
+
const startedBy = starters.get(s.id);
|
|
3244
|
+
return dispatchGroup || startedBy ? { ...s, dispatchGroup, startedBy } : s;
|
|
3077
3245
|
});
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
// Starting a session needs the terminal token, as the browser does; the CLI reads it from
|
|
3249
|
+
// TERMINAL_TOKEN_FILE. The child gets only its dispatch capability through the preamble.
|
|
3250
|
+
app.post('/api/dispatch', (req, res) => {
|
|
3251
|
+
if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
|
|
3252
|
+
const { cwd, spec, name, model, worktree, parent, group, report, peer } = req.body || {};
|
|
3253
|
+
if (typeof spec !== 'string' || !spec.trim()) return res.status(400).json({ error: 'spec is required' });
|
|
3254
|
+
if (peer != null && !isPeerName(peer)) return res.status(400).json({ error: 'peer must be a peer name as ListAgents prints it' });
|
|
3255
|
+
if (parent != null && !(typeof parent === 'string' && isUUID(parent))) return res.status(400).json({ error: 'invalid parent' });
|
|
3256
|
+
if (group != null && !isGroupName(group)) {
|
|
3257
|
+
return res.status(400).json({ error: `group must be kebab-case, e.g. ${suggestGroupName(group) || 'my-group'}` });
|
|
3258
|
+
}
|
|
3259
|
+
const starterGroup = parent ? dispatchGroups.groupOf(parent) : null;
|
|
3260
|
+
const target = group || starterGroup;
|
|
3261
|
+
const r = dispatches.create({ parent, name, spec: spec.trim(), report: report === true, peer, group: target, worktree });
|
|
3262
|
+
const env = { CCK_DISPATCH_ID: r.id, ...(parent && { PARENT_SESSION_ID: parent }) };
|
|
3263
|
+
const started = terminal.startNew({ cwd, name, model, worktree, prompt: formatPreamble(r) }, env);
|
|
3264
|
+
if (started.error) {
|
|
3265
|
+
dispatches.discard(r.id);
|
|
3266
|
+
return res.status(started.status).json({ error: started.error });
|
|
3267
|
+
}
|
|
3268
|
+
dispatches.attach(r.id, { session: started.id, cwd: started.cwd });
|
|
3269
|
+
// A starter already in a group stays where it is: moving it would jump it under the user.
|
|
3270
|
+
if (target) dispatchGroups.join(target, [starterGroup ? null : parent, started.id]);
|
|
3271
|
+
broadcast({ type: 'dispatch-update' });
|
|
3272
|
+
res.status(201).json({ dispatch: r.id, session: started.id, cwd: started.cwd, group: target });
|
|
3078
3273
|
});
|
|
3079
3274
|
|
|
3275
|
+
app.post('/api/dispatch/:id/done', (req, res) => {
|
|
3276
|
+
const { cap, outcome, summary } = req.body || {};
|
|
3277
|
+
const err = dispatches.settle(req.params.id, cap, outcome, summary);
|
|
3278
|
+
if (err) return res.status(err.status).json({ error: err.error });
|
|
3279
|
+
res.status(204).end();
|
|
3280
|
+
});
|
|
3281
|
+
|
|
3282
|
+
app.get('/api/dispatch', async (req, res) => {
|
|
3283
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3284
|
+
const ids = typeof req.query.ids === 'string' && req.query.ids ? req.query.ids.split(',') : null;
|
|
3285
|
+
const parent = typeof req.query.parent === 'string' && req.query.parent ? req.query.parent : null;
|
|
3286
|
+
// res, not req: a GET's request stream can close before the response is sent.
|
|
3287
|
+
const out = await dispatches.wait({ ids, parent }, req.query.wait, (stop) => {
|
|
3288
|
+
res.on('close', stop);
|
|
3289
|
+
return () => res.off('close', stop);
|
|
3290
|
+
});
|
|
3291
|
+
if (!res.writableEnded) res.json(out);
|
|
3292
|
+
});
|
|
3080
3293
|
// #endregion
|
|
3081
3294
|
|
|
3082
3295
|
// #region TASK_ROUTES
|
|
@@ -3134,7 +3347,7 @@ app.get('/api/tasks/all', async (_req, res) => {
|
|
|
3134
3347
|
}
|
|
3135
3348
|
});
|
|
3136
3349
|
|
|
3137
|
-
const { enqueueSessionEvent, formatTaskMoved, handleSessionEvents } = require('./lib/session-events');
|
|
3350
|
+
const { enqueueSessionEvent, formatTaskMoved, handleSessionEvents, topicKey } = require('./lib/session-events');
|
|
3138
3351
|
app.get('/api/sessions/:sessionId/events', handleSessionEvents);
|
|
3139
3352
|
|
|
3140
3353
|
// API: Create a task
|
|
@@ -3891,9 +4104,16 @@ async function prewarmCaches() {
|
|
|
3891
4104
|
// The port is configurable and falls back to a random one when taken, so the postman
|
|
3892
4105
|
// monitor cannot assume it -- publish the live one where it can read it.
|
|
3893
4106
|
writeServerInfo(actualPort);
|
|
4107
|
+
setInterval(() => reclaimServerInfo(actualPort), 30000).unref();
|
|
3894
4108
|
migrateLegacyApprovalsConfig();
|
|
3895
4109
|
const warning = net.exposureWarning();
|
|
3896
4110
|
if (warning) console.log(warning);
|
|
4111
|
+
const terminalReason = terminal.unavailableReason();
|
|
4112
|
+
if (terminalReason && terminalReason !== 'disabled') console.log(`Terminal unavailable: ${terminalReason}`);
|
|
4113
|
+
// Under the hub the hub owns the token and hands it to the iframe itself.
|
|
4114
|
+
if (!terminalReason && !process.env.CLAUDE_HUB) {
|
|
4115
|
+
console.log(`Terminal enabled - open http://localhost:${actualPort}/#t=${terminal.token}`);
|
|
4116
|
+
}
|
|
3897
4117
|
|
|
3898
4118
|
if (process.argv.includes('--open')) {
|
|
3899
4119
|
import('open').then(open => open.default(`http://localhost:${actualPort}`));
|
|
@@ -3901,12 +4121,14 @@ async function prewarmCaches() {
|
|
|
3901
4121
|
setImmediate(prewarmCaches);
|
|
3902
4122
|
};
|
|
3903
4123
|
|
|
3904
|
-
const
|
|
4124
|
+
const listenOpts = { onUpgrade: terminal.handleUpgrade };
|
|
4125
|
+
const server = net.listenLoopback(app, PORT, onReady, listenOpts);
|
|
4126
|
+
process.on('exit', terminal.shutdown);
|
|
3905
4127
|
|
|
3906
4128
|
server.on('error', (err) => {
|
|
3907
4129
|
if (err.code === 'EADDRINUSE') {
|
|
3908
4130
|
console.log(`Port ${PORT} in use, trying random port...`);
|
|
3909
|
-
net.listenLoopback(app, 0, onReady);
|
|
4131
|
+
net.listenLoopback(app, 0, onReady, listenOpts);
|
|
3910
4132
|
} else {
|
|
3911
4133
|
throw err;
|
|
3912
4134
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Dispatch guide
|
|
2
|
+
|
|
3
|
+
A dispatch is one Claude Code session that cck starts for a task, in its embedded terminal. It is an ordinary session, not a child: it shows in the sidebar like any other, and the user can open its terminal at any time.
|
|
4
|
+
|
|
5
|
+
A dispatch is **fire-and-forget** by default: you hand the task off, and the user watches it in the sidebar. Add `--report` only when you need the outcome back: the user asked you to collect it, or your next step depends on it.
|
|
6
|
+
|
|
7
|
+
## Write the spec
|
|
8
|
+
|
|
9
|
+
The started session sees only the spec, not this conversation, so every spec is self-contained. Name:
|
|
10
|
+
|
|
11
|
+
- **Target:** the files, component, or environment in scope.
|
|
12
|
+
- **Change:** the concrete result to produce.
|
|
13
|
+
- **Constraints:** invariants and do-not-touch boundaries.
|
|
14
|
+
- **Ownership:** what it may edit. Two dispatches edit the same files only when each runs in its own `--worktree`.
|
|
15
|
+
- **Acceptance:** the test, output, or evidence that proves it is done.
|
|
16
|
+
|
|
17
|
+
Dispatch when the task can run on its own. Do the work yourself when it is small or needs context from this conversation that you cannot write down.
|
|
18
|
+
|
|
19
|
+
## Start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
claude-code-kanban dispatch start --cwd <dir> --spec-file <spec.md> --name <name> --group <group> --peer <your-peer> [--report] [--model haiku|sonnet|opus|fable] [--worktree [name]] --json
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- `--peer` is your own peer name: the first line of `ListAgents` ("This session is `<name>`"). Pass it whenever you have the `ListAgents` tool. cck then tells the started session to ask you with `SendMessage` instead of failing on a question. See [Peer](#peer).
|
|
26
|
+
- `--cwd` must be a project cck already knows (default: the current dir).
|
|
27
|
+
- `--spec-file` over `--spec` for anything longer than a line: no shell quoting.
|
|
28
|
+
- `--name` is what the user sees in the sidebar. Kebab-case, saying what the session does: `fix-login-redirect`, not `task-1`.
|
|
29
|
+
- `--group` names the effort, in kebab-case (`auth-refactor`), and shows the new session and this session together under one sidebar group. Pass it on your first dispatch; later dispatches join the same group without it. A group goes away when its sessions end, unless the user pins a member or keeps the group.
|
|
30
|
+
- The result holds the `dispatch` id and the `session` id.
|
|
31
|
+
|
|
32
|
+
## Fire-and-forget
|
|
33
|
+
|
|
34
|
+
Tell the user the session name, its group, and the dispatch id, then carry on with your own work or end your turn. The user follows the dispatch in the sidebar. With `--peer`, its questions still reach you as new turns.
|
|
35
|
+
|
|
36
|
+
## With `--report`
|
|
37
|
+
|
|
38
|
+
The started session settles with one report, `succeeded` or `failed`, or as `exited` when its terminal ends first. Start every independent dispatch first, then collect. Two channels, use either or both:
|
|
39
|
+
|
|
40
|
+
- **Inbox:** this skill armed it. Lines `cck:1 dispatch.<status> <id> ...` arrive on their own while you keep working.
|
|
41
|
+
- **Wait:** block until one settles.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
claude-code-kanban dispatch wait [<id>...] --timeout 15m --json
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
It returns `settled`, `running`, and `timeout`, as soon as any watched dispatch settles; call it again with the ids still running. A timeout is a checkpoint: the session may still be working. Look before you act:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
claude-code-kanban dispatch list --json
|
|
51
|
+
claude-code-kanban session peek <session-id> --limit 20
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
A dispatch still `running` is still working; retry only after a `failed` report or an `exited` one.
|
|
55
|
+
|
|
56
|
+
The summary is the started session's own claim. Verify it (run the tests, read the diff), then give the user each dispatch's outcome, the summary, and what you checked. Done when every `--report` dispatch has settled and each summary is verified.
|
|
57
|
+
|
|
58
|
+
## Peer
|
|
59
|
+
|
|
60
|
+
A dispatch is a Claude Code peer under its `--name`, so `SendMessage` reaches it and it reaches you. The peer channel carries the conversation. The report carries the record: only `dispatch done` settles a dispatch, ends `dispatch wait`, and shows in the sidebar.
|
|
61
|
+
|
|
62
|
+
- **Answer questions.** A question or a finding from the dispatch arrives as a new turn. Answer it yourself, or ask the user when the decision is theirs, then send the answer back.
|
|
63
|
+
- **Steer.** Send a short, self-contained message to the dispatch's name. It arrives between the receiver's steps, never inside a subagent or a running workflow.
|
|
64
|
+
- **Limits.** A session in another permission mode can hold a message until its user approves it, so anything the result depends on goes in the report. A dispatch that restarts ends as `exited` and cannot report, so its result comes back as a message.
|
|
65
|
+
|
|
66
|
+
## If you are the started session
|
|
67
|
+
|
|
68
|
+
Your prompt begins with `[cck dispatch <id>]` and holds your instructions: the peer to ask, and with a report the exact `dispatch done` command. Follow them. Without that line, the prompt is the task alone.
|
|
69
|
+
|
|
70
|
+
- Ask the peer with `SendMessage` when you need a decision or find something that changes the task, and keep working on what does not depend on the answer.
|
|
71
|
+
- The summary is three sentences: what changed, what you found, what remains. Use `--summary-file` if it needs quotes.
|
|
72
|
+
- After you report, a message from the peer is a new request: answer it.
|