claude-code-kanban 5.1.1 → 5.3.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 +3 -2
- package/lib/auto-compact.js +42 -0
- package/lib/claude-dir.js +7 -1
- package/lib/claude-settings.js +21 -0
- package/lib/dispatch.js +11 -4
- package/lib/net-guard.js +41 -14
- package/lib/proc-stats.js +82 -0
- package/lib/retention.js +140 -0
- package/lib/session-events.js +30 -7
- package/lib/terminal.js +39 -6
- package/lib/worktrees.js +105 -0
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +15 -8
- package/plugin/plugins/claude-code-kanban/monitors.json +3 -3
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +2 -2
- package/plugin/plugins/claude-code-kanban/skills/{kanban-dispatch → dispatch}/SKILL.md +3 -3
- package/plugin/plugins/claude-code-kanban/skills/{kanban-follow → follow}/SKILL.md +17 -9
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -2
- package/public/app.js +1332 -490
- package/public/fonts/OFL-IBM-Plex.txt +93 -0
- package/public/fonts/OFL-Playfair-Display.txt +93 -0
- package/public/fonts/fonts.css +244 -0
- package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
- package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
- package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
- package/public/index.html +27 -17
- package/public/project-match.js +6 -4
- package/public/style.css +528 -116
- package/public/terminal-frame.js +341 -0
- package/public/terminal.html +27 -0
- package/public/vendor/claude-hub-sdk.js +13 -3
- package/server.js +204 -60
- package/skill-guides/dispatch.md +5 -5
package/server.js
CHANGED
|
@@ -16,6 +16,7 @@ const { isContained } = require('./lib/contain');
|
|
|
16
16
|
const { resolveScratchSubdir, listScratchDir } = require('./lib/scratch-files');
|
|
17
17
|
const { fileUrlToPath } = require('./lib/file-url');
|
|
18
18
|
const { pluginStatus } = require('./lib/plugin-status');
|
|
19
|
+
const { getAutoCompact } = require('./lib/auto-compact');
|
|
19
20
|
|
|
20
21
|
const {
|
|
21
22
|
readRecentMessages: _readRecentMessagesUncached,
|
|
@@ -38,15 +39,18 @@ const {
|
|
|
38
39
|
} = require('./lib/parsers');
|
|
39
40
|
const { inlineHtmlAssets, MIME_BY_EXT } = require('./lib/inline-assets');
|
|
40
41
|
const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
|
|
41
|
-
const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
|
|
42
|
+
const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir, encodeProjectDirName } = require('./lib/claude-dir');
|
|
42
43
|
const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
|
|
43
|
-
const {
|
|
44
|
+
const { createProcStats } = require('./lib/proc-stats');
|
|
45
|
+
const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName, DISPATCH_OUTCOME } = require('./lib/dispatch');
|
|
44
46
|
const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
|
|
47
|
+
const { createDispatchedStore, scanTranscripts, pruneSessionDirs, retentionMs } = require('./lib/retention');
|
|
48
|
+
const { createWorktreeStore } = require('./lib/worktrees');
|
|
45
49
|
const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
|
|
46
50
|
const { pickFolder } = require('./lib/folder-dialog');
|
|
47
51
|
const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
|
|
48
52
|
const { countTaskDir } = require('./lib/task-counts');
|
|
49
|
-
const { projectMatcher } = require('./public/project-match');
|
|
53
|
+
const { projectMatcher, normalizeProjectPath } = require('./public/project-match');
|
|
50
54
|
const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
|
|
51
55
|
|
|
52
56
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
@@ -67,7 +71,8 @@ const PORT = parseInt(getArgValue('port') || process.env.PORT || '3541', 10);
|
|
|
67
71
|
|
|
68
72
|
// Mounted before express.json() so a rejected request never buffers a body, and
|
|
69
73
|
// before the /api catch-all so no route escapes the check.
|
|
70
|
-
|
|
74
|
+
// The board frames the terminal from its other loopback name, so Chrome runs it in its own process.
|
|
75
|
+
const net = createNetGuard({ appName: 'Claude Task Kanban', selfFramedPaths: ['/terminal.html'] });
|
|
71
76
|
app.use(net.hostGuard);
|
|
72
77
|
app.use(net.frameGuard);
|
|
73
78
|
app.use(net.originGuard);
|
|
@@ -90,6 +95,8 @@ const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
|
|
|
90
95
|
const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
|
|
91
96
|
const PINS_FILE = path.join(CCK_DIR, 'pins.json');
|
|
92
97
|
const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
|
|
98
|
+
const DISPATCHED_FILE = path.join(CCK_DIR, 'dispatched.json');
|
|
99
|
+
const WORKTREES_FILE = path.join(CCK_DIR, 'worktrees.json');
|
|
93
100
|
const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
|
|
94
101
|
const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
|
|
95
102
|
const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
|
|
@@ -200,6 +207,7 @@ const WAITING_RESOLVE_GRACE_MS = 15 * 1000;
|
|
|
200
207
|
const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
|
|
201
208
|
const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
|
|
202
209
|
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
|
210
|
+
const RETENTION_FIRST_RUN_MS = 5 * 60 * 1000;
|
|
203
211
|
// #endregion
|
|
204
212
|
|
|
205
213
|
// #region AGENT_ACTIVITY
|
|
@@ -286,6 +294,11 @@ function getContextStatus(sessionId, meta) {
|
|
|
286
294
|
return contextStatusCache.get(sessionId) || (meta?.teamLeaderId ? contextStatusCache.get(meta.teamLeaderId) : null) || null;
|
|
287
295
|
}
|
|
288
296
|
|
|
297
|
+
function getContextFields(sessionId, meta) {
|
|
298
|
+
const contextStatus = getContextStatus(sessionId, meta);
|
|
299
|
+
return { contextStatus, autoCompact: contextStatus ? getAutoCompact(CLAUDE_DIR, meta?.project) : null };
|
|
300
|
+
}
|
|
301
|
+
|
|
289
302
|
function isAgentFresh(agent) {
|
|
290
303
|
if (isGhostAgent(agent)) return false;
|
|
291
304
|
const ts = agent.updatedAt || agent.startedAt;
|
|
@@ -329,33 +342,12 @@ function getGitBranch(cwd) {
|
|
|
329
342
|
return branch;
|
|
330
343
|
}
|
|
331
344
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const WORKTREE_CACHE_MAX = 500;
|
|
339
|
-
const GITDIR_WORKTREE_RE = /^gitdir:\s*(.*)[/\\]\.git[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
|
|
340
|
-
|
|
341
|
-
function resolveWorktree(dir) {
|
|
342
|
-
if (!dir) return null;
|
|
343
|
-
if (worktreeCache.has(dir)) return worktreeCache.get(dir);
|
|
344
|
-
|
|
345
|
-
let worktree = null;
|
|
346
|
-
try {
|
|
347
|
-
// An ordinary checkout's `.git` is a directory, so the read throws EISDIR — that is the
|
|
348
|
-
// answer, and it costs one syscall instead of a stat followed by a read.
|
|
349
|
-
const m = GITDIR_WORKTREE_RE.exec(readFileSync(path.join(dir, '.git'), 'utf8').trim());
|
|
350
|
-
if (m) worktree = { repo: m[1], name: m[2] };
|
|
351
|
-
} catch (_) {}
|
|
352
|
-
|
|
353
|
-
worktreeCache.set(dir, worktree);
|
|
354
|
-
if (worktreeCache.size > WORKTREE_CACHE_MAX) {
|
|
355
|
-
worktreeCache.delete(worktreeCache.keys().next().value);
|
|
356
|
-
}
|
|
357
|
-
return worktree;
|
|
358
|
-
}
|
|
345
|
+
const worktrees = createWorktreeStore({
|
|
346
|
+
load: () => {
|
|
347
|
+
try { return JSON.parse(readFileSync(WORKTREES_FILE, 'utf8')); } catch { return null; }
|
|
348
|
+
},
|
|
349
|
+
save: (data) => writeJsonAtomic(WORKTREES_FILE, data),
|
|
350
|
+
});
|
|
359
351
|
|
|
360
352
|
// Only spawn git when cwd has diverged from the launch project — that's the
|
|
361
353
|
// only case the JSONL value is wrong. Saves N spawns on a typical list build.
|
|
@@ -1131,12 +1123,6 @@ function getSessionDisplayName(_sessionId, meta) {
|
|
|
1131
1123
|
return null;
|
|
1132
1124
|
}
|
|
1133
1125
|
|
|
1134
|
-
// The harness spells a project path into a temp/log dir name by replacing every
|
|
1135
|
-
// non-alphanumeric character with a dash, drive colon and separators included.
|
|
1136
|
-
function encodeProjectDirName(p) {
|
|
1137
|
-
return p.replace(/[^a-zA-Z0-9]/g, '-');
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
1126
|
// Derived by convention, not looked up: the harness creates the dir lazily, so a
|
|
1141
1127
|
// stat here would report "missing" for every session that has not written a temp
|
|
1142
1128
|
// file yet — and it would put IO on the session-list hot path. Pure string join.
|
|
@@ -1151,7 +1137,7 @@ function getScratchpadDir(id, meta) {
|
|
|
1151
1137
|
// harness actually created. Probed only for worktree sessions, and only until one
|
|
1152
1138
|
// of them exists — before that there is nothing to disambiguate and `byProject`,
|
|
1153
1139
|
// right for a session started inside the worktree, stands.
|
|
1154
|
-
const wt =
|
|
1140
|
+
const wt = worktrees.resolve(meta.project);
|
|
1155
1141
|
if (!wt || existsSync(byProject)) return byProject;
|
|
1156
1142
|
const byRepo = path.join(SCRATCHPAD_ROOT, encodeProjectDirName(wt.repo), id, 'scratchpad');
|
|
1157
1143
|
return existsSync(byRepo) ? byRepo : byProject;
|
|
@@ -1170,7 +1156,7 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
1170
1156
|
cwd: meta.cwd || null,
|
|
1171
1157
|
description: meta.description || null,
|
|
1172
1158
|
gitBranch: resolveSessionGitBranch(meta),
|
|
1173
|
-
worktree:
|
|
1159
|
+
worktree: worktrees.resolve(meta.project),
|
|
1174
1160
|
customTitle: meta.customTitle || null,
|
|
1175
1161
|
taskCount: 0,
|
|
1176
1162
|
completed: 0,
|
|
@@ -1191,7 +1177,7 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
1191
1177
|
tasksDir: null,
|
|
1192
1178
|
projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
|
|
1193
1179
|
scratchpadDir: getScratchpadDir(id, meta),
|
|
1194
|
-
|
|
1180
|
+
...getContextFields(id, meta),
|
|
1195
1181
|
...getPlanInfo(meta.slug),
|
|
1196
1182
|
...getWorkflowInfoSummary(id),
|
|
1197
1183
|
...overrides,
|
|
@@ -1520,10 +1506,7 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1520
1506
|
// Backfill contextStatus for already-built sessions that are pinned
|
|
1521
1507
|
for (const pid of pinnedIds) {
|
|
1522
1508
|
const s = sessionsMap.get(pid);
|
|
1523
|
-
if (s && !s.contextStatus)
|
|
1524
|
-
const meta = metadata[pid];
|
|
1525
|
-
s.contextStatus = getContextStatus(pid, meta);
|
|
1526
|
-
}
|
|
1509
|
+
if (s && !s.contextStatus) Object.assign(s, getContextFields(pid, metadata[pid]));
|
|
1527
1510
|
}
|
|
1528
1511
|
|
|
1529
1512
|
// Ensure pinned sessions are in the map even if they weren't discovered
|
|
@@ -1572,7 +1555,7 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1572
1555
|
// if the row is missing — that one is not a preference.
|
|
1573
1556
|
if (projectFilter) {
|
|
1574
1557
|
const matches = projectMatcher(String(projectFilter));
|
|
1575
|
-
sessions = sessions.filter(s => matches(s.project) || includeIds.has(s.id));
|
|
1558
|
+
sessions = sessions.filter(s => matches(s.project, s.worktree?.repo) || includeIds.has(s.id));
|
|
1576
1559
|
}
|
|
1577
1560
|
// The sidebar's 24h filter: projects with any transcript written in the window, so an older
|
|
1578
1561
|
// session of such a project stays in.
|
|
@@ -1644,14 +1627,30 @@ function projectActivity() {
|
|
|
1644
1627
|
return activity;
|
|
1645
1628
|
}
|
|
1646
1629
|
|
|
1647
|
-
// API: Get distinct project paths with last-modified timestamps
|
|
1630
|
+
// API: Get distinct project paths with last-modified timestamps. A linked worktree is folded
|
|
1631
|
+
// into its repo, which lists it under `worktrees`; the repo row exists even when only its
|
|
1632
|
+
// worktrees have transcripts.
|
|
1648
1633
|
app.get('/api/projects', (_req, res) => {
|
|
1649
1634
|
res.setHeader('Cache-Control', 'no-store');
|
|
1650
|
-
const
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1635
|
+
const byRepo = new Map();
|
|
1636
|
+
for (const [project, mtime] of projectActivity()) {
|
|
1637
|
+
const repo = worktrees.resolve(project)?.repo || project;
|
|
1638
|
+
const key = normalizeProjectPath(repo);
|
|
1639
|
+
let row = byRepo.get(key);
|
|
1640
|
+
if (!row) {
|
|
1641
|
+
row = { path: repo, mtime: 0, worktrees: [] };
|
|
1642
|
+
byRepo.set(key, row);
|
|
1643
|
+
}
|
|
1644
|
+
if (repo === project) row.path = project;
|
|
1645
|
+
else row.worktrees.push(project);
|
|
1646
|
+
row.mtime = Math.max(row.mtime, mtime || 0);
|
|
1647
|
+
}
|
|
1648
|
+
const projects = [...byRepo.values()]
|
|
1649
|
+
.map((r) => ({
|
|
1650
|
+
path: r.path,
|
|
1651
|
+
modifiedAt: r.mtime ? new Date(r.mtime).toISOString() : null,
|
|
1652
|
+
...(isTempPath(r.path) && { temp: true }),
|
|
1653
|
+
...(r.worktrees.length && { worktrees: r.worktrees.sort() }),
|
|
1655
1654
|
}))
|
|
1656
1655
|
.sort((a, b) => a.path.localeCompare(b.path));
|
|
1657
1656
|
res.json(projects);
|
|
@@ -1750,7 +1749,7 @@ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
|
|
|
1750
1749
|
if (!existsSync(planPath)) return res.json({ content: null });
|
|
1751
1750
|
|
|
1752
1751
|
const content = await fs.readFile(planPath, 'utf8');
|
|
1753
|
-
res.json({ content, slug });
|
|
1752
|
+
res.json({ content, slug, path: planPath });
|
|
1754
1753
|
} catch (error) {
|
|
1755
1754
|
console.error('Error reading plan:', error);
|
|
1756
1755
|
res.status(500).json({ error: 'Failed to read plan' });
|
|
@@ -3292,6 +3291,16 @@ app.get('/api/terminals', (_req, res) => {
|
|
|
3292
3291
|
res.json({ sessions: terminal.list() });
|
|
3293
3292
|
});
|
|
3294
3293
|
|
|
3294
|
+
const terminalProcStats = createProcStats();
|
|
3295
|
+
app.get('/api/terminals/stats', async (_req, res) => {
|
|
3296
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3297
|
+
const pids = terminal.claudePids();
|
|
3298
|
+
const byPid = await terminalProcStats([process.pid, ...Object.values(pids)]);
|
|
3299
|
+
const terminals = {};
|
|
3300
|
+
for (const [id, pid] of Object.entries(pids)) if (byPid[pid]) terminals[id] = byPid[pid];
|
|
3301
|
+
res.json({ cck: byPid[process.pid] || null, terminals });
|
|
3302
|
+
});
|
|
3303
|
+
|
|
3295
3304
|
app.delete('/api/terminals/:id', (req, res) => {
|
|
3296
3305
|
const err = terminal.end(req.params.id, req.get('x-terminal-token'));
|
|
3297
3306
|
if (err === 'auth') return res.status(401).json({ error: 'invalid terminal token' });
|
|
@@ -3318,9 +3327,17 @@ app.get('/vendor/xterm/:file', (req, res) => {
|
|
|
3318
3327
|
// #endregion
|
|
3319
3328
|
|
|
3320
3329
|
// #region DISPATCH
|
|
3330
|
+
const dispatched = createDispatchedStore({
|
|
3331
|
+
load: () => {
|
|
3332
|
+
try { return JSON.parse(readFileSync(DISPATCHED_FILE, 'utf8')); } catch { return null; }
|
|
3333
|
+
},
|
|
3334
|
+
save: (data) => writeJsonAtomic(DISPATCHED_FILE, data),
|
|
3335
|
+
});
|
|
3336
|
+
|
|
3321
3337
|
const dispatches = createDispatchRegistry({
|
|
3322
3338
|
onSettle: (r) => {
|
|
3323
3339
|
if (r.parent && r.report) enqueueSessionEvent(topicKey('dispatch', r.parent), formatDispatchLine(r));
|
|
3340
|
+
if (r.session) dispatched.settle(r.session, r.status);
|
|
3324
3341
|
broadcast({ type: 'dispatch-update' });
|
|
3325
3342
|
},
|
|
3326
3343
|
});
|
|
@@ -3343,17 +3360,19 @@ const linkedDocs = createLinkedDocStore({
|
|
|
3343
3360
|
|
|
3344
3361
|
// The board places a session from these alone, so it never has to move it later.
|
|
3345
3362
|
// `startedBy` lets it follow a starter the user put in a named group, which only the
|
|
3346
|
-
// browser knows; it goes once the dispatch settles.
|
|
3363
|
+
// browser knows; it goes once the dispatch settles. `dispatched` stays for the card's marker.
|
|
3347
3364
|
function withDispatchPlacement(sessions) {
|
|
3348
3365
|
const groups = dispatchGroups.snapshot();
|
|
3349
|
-
const starters = new Map(
|
|
3350
|
-
dispatches.list().filter((r) => r.status === 'running' && r.parent && r.session).map((r) => [r.session, r.parent]),
|
|
3351
|
-
);
|
|
3352
|
-
if (!groups.size && !starters.size) return sessions;
|
|
3353
3366
|
return sessions.map((s) => {
|
|
3354
3367
|
const dispatchGroup = groups.get(s.id);
|
|
3355
|
-
const
|
|
3356
|
-
|
|
3368
|
+
const marker = dispatched.get(s.id);
|
|
3369
|
+
if (!dispatchGroup && !marker) return s;
|
|
3370
|
+
return {
|
|
3371
|
+
...s,
|
|
3372
|
+
dispatchGroup,
|
|
3373
|
+
startedBy: marker?.status === 'running' ? marker.parent || undefined : undefined,
|
|
3374
|
+
dispatched: marker ? { parent: marker.parent, outcome: DISPATCH_OUTCOME[marker.status] } : undefined,
|
|
3375
|
+
};
|
|
3357
3376
|
});
|
|
3358
3377
|
}
|
|
3359
3378
|
|
|
@@ -3377,6 +3396,7 @@ app.post('/api/dispatch', (req, res) => {
|
|
|
3377
3396
|
return res.status(started.status).json({ error: started.error });
|
|
3378
3397
|
}
|
|
3379
3398
|
dispatches.attach(r.id, { session: started.id, cwd: started.cwd });
|
|
3399
|
+
dispatched.record(started.id, parent);
|
|
3380
3400
|
// The starter stays where it is: moving it would jump it under the user.
|
|
3381
3401
|
if (target) dispatchGroups.join(target, [started.id], parent);
|
|
3382
3402
|
broadcast({ type: 'dispatch-update' });
|
|
@@ -3458,7 +3478,14 @@ app.get('/api/tasks/all', async (_req, res) => {
|
|
|
3458
3478
|
}
|
|
3459
3479
|
});
|
|
3460
3480
|
|
|
3461
|
-
const {
|
|
3481
|
+
const {
|
|
3482
|
+
enqueueSessionEvent,
|
|
3483
|
+
formatReviewSubmitted,
|
|
3484
|
+
formatTaskMoved,
|
|
3485
|
+
handleSessionEvents,
|
|
3486
|
+
hasSessionListener,
|
|
3487
|
+
topicKey,
|
|
3488
|
+
} = require('./lib/session-events');
|
|
3462
3489
|
app.get('/api/sessions/:sessionId/events', handleSessionEvents);
|
|
3463
3490
|
|
|
3464
3491
|
// API: Create a task
|
|
@@ -3912,6 +3939,102 @@ app.get('/api/file/resolve', async (req, res) => {
|
|
|
3912
3939
|
|
|
3913
3940
|
// #endregion
|
|
3914
3941
|
|
|
3942
|
+
// #region REVIEW
|
|
3943
|
+
// API: Send review comments on something the board shows (a file preview today) to a
|
|
3944
|
+
// session. The source is described, not interpreted, so a new kind of preview needs a
|
|
3945
|
+
// client change only. The batch is always written to a file: the doorbell is lossy and
|
|
3946
|
+
// carries one line, and the file is what each route points at.
|
|
3947
|
+
const REVIEW_DIR = path.join(CCK_DIR, 'reviews');
|
|
3948
|
+
const REVIEW_KIND_RE = /^[a-z]{1,20}$/;
|
|
3949
|
+
const REVIEW_MAX_COMMENTS = 50;
|
|
3950
|
+
const REVIEW_MAX_CHARS = 4000;
|
|
3951
|
+
|
|
3952
|
+
function parseReviewBody(body) {
|
|
3953
|
+
const { source, comments } = body || {};
|
|
3954
|
+
if (!source || !REVIEW_KIND_RE.test(source.kind)) throw previewError(400, 'source.kind is required');
|
|
3955
|
+
// The label is pasted into the terminal; an ESC could end bracketed paste and submit text.
|
|
3956
|
+
const label = String(source.label || '')
|
|
3957
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
|
|
3958
|
+
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
|
3959
|
+
.trim()
|
|
3960
|
+
.slice(0, 200);
|
|
3961
|
+
if (!label) throw previewError(400, 'source.label is required');
|
|
3962
|
+
if (!Array.isArray(comments) || !comments.length || comments.length > REVIEW_MAX_COMMENTS) {
|
|
3963
|
+
throw previewError(400, `comments must hold 1 to ${REVIEW_MAX_COMMENTS} items`);
|
|
3964
|
+
}
|
|
3965
|
+
const field = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().replace(/\s+/g, ' ').slice(0, max) : null);
|
|
3966
|
+
// Element and selector are printed inside a markdown code span.
|
|
3967
|
+
const code = (v, max) => field(v, max)?.replace(/`/g, "'") || null;
|
|
3968
|
+
const items = comments.map((c) => ({
|
|
3969
|
+
quote: String(c?.quote || '').trim().slice(0, REVIEW_MAX_CHARS),
|
|
3970
|
+
comment: String(c?.comment || '').trim().slice(0, REVIEW_MAX_CHARS),
|
|
3971
|
+
heading: field(c?.heading, 200),
|
|
3972
|
+
line: Number.isInteger(c?.line) && c.line > 0 ? c.line : null,
|
|
3973
|
+
element: code(c?.element, 300),
|
|
3974
|
+
selector: code(c?.selector, 500),
|
|
3975
|
+
}));
|
|
3976
|
+
if (items.some((c) => !c.comment)) throw previewError(400, 'every comment needs text');
|
|
3977
|
+
return { kind: source.kind, label, path: source.kind === 'file' ? source.path : null, items };
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3980
|
+
function formatReviewMarkdown(src, items) {
|
|
3981
|
+
const parts = [
|
|
3982
|
+
`# Review of ${src.path || src.label}`,
|
|
3983
|
+
'',
|
|
3984
|
+
`Each comment is the user's instruction about the quoted text.${src.path ? ' A quote missing from the source means the source changed after the review: say so.' : ''}`,
|
|
3985
|
+
];
|
|
3986
|
+
items.forEach((c, i) => {
|
|
3987
|
+
const where = [c.line && `line ${c.line}`, c.heading && `under "${c.heading}"`].filter(Boolean).join(', ');
|
|
3988
|
+
parts.push('', `## ${i + 1}${where ? ` (${where})` : ''}`, '');
|
|
3989
|
+
if (c.element) parts.push(`Element: \`${c.element}\`${c.selector ? ` at \`${c.selector}\`` : ''}`, '');
|
|
3990
|
+
if (c.quote) parts.push(...c.quote.split(/\r?\n/).map((l) => `> ${l}`), '');
|
|
3991
|
+
parts.push(c.comment);
|
|
3992
|
+
});
|
|
3993
|
+
return `${parts.join('\n')}\n`;
|
|
3994
|
+
}
|
|
3995
|
+
|
|
3996
|
+
function isWaitingOnUser(sessionId) {
|
|
3997
|
+
const meta = loadSessionMetadata()[sessionId] || {};
|
|
3998
|
+
return !!checkWaitingForUser(path.join(AGENT_ACTIVITY_DIR, sessionId), getSessionLogStat(meta).mtime);
|
|
3999
|
+
}
|
|
4000
|
+
|
|
4001
|
+
app.post('/api/sessions/:sessionId/review', async (req, res) => {
|
|
4002
|
+
try {
|
|
4003
|
+
const sessionId = req.params.sessionId;
|
|
4004
|
+
if (!isUUID(sessionId)) return res.status(400).json({ error: 'invalid session id' });
|
|
4005
|
+
const src = parseReviewBody(req.body);
|
|
4006
|
+
if (src.kind === 'file') {
|
|
4007
|
+
src.path = resolvePreviewPath(src.path);
|
|
4008
|
+
if (!src.path) return res.status(400).json({ error: 'source.path is required for a file' });
|
|
4009
|
+
await statFileTarget(src.path);
|
|
4010
|
+
}
|
|
4011
|
+
|
|
4012
|
+
const markdown = formatReviewMarkdown(src, src.items);
|
|
4013
|
+
const dir = path.join(REVIEW_DIR, sessionId);
|
|
4014
|
+
await fs.mkdir(dir, { recursive: true });
|
|
4015
|
+
const file = path.join(dir, `${Date.now()}.md`);
|
|
4016
|
+
await fs.writeFile(file, markdown);
|
|
4017
|
+
|
|
4018
|
+
let delivered = null;
|
|
4019
|
+
if (hasSessionListener(sessionId)) {
|
|
4020
|
+
enqueueSessionEvent(sessionId, formatReviewSubmitted(src.items.length, src.label, file));
|
|
4021
|
+
delivered = 'doorbell';
|
|
4022
|
+
} else if (
|
|
4023
|
+
terminal.authorized(req.get('x-terminal-token')) &&
|
|
4024
|
+
terminal.isRunning(sessionId) &&
|
|
4025
|
+
!isWaitingOnUser(sessionId)
|
|
4026
|
+
) {
|
|
4027
|
+
if (terminal.paste(sessionId, `Address my review comments on ${src.label}: ${file}`)) delivered = 'terminal';
|
|
4028
|
+
}
|
|
4029
|
+
res.json({ delivered, file, markdown });
|
|
4030
|
+
} catch (error) {
|
|
4031
|
+
if (!error.status) console.error('Error in POST /api/sessions/:id/review:', error);
|
|
4032
|
+
res.status(error.status || 500).json({ error: error.message || 'Review failed' });
|
|
4033
|
+
}
|
|
4034
|
+
});
|
|
4035
|
+
|
|
4036
|
+
// #endregion
|
|
4037
|
+
|
|
3915
4038
|
// #region SSE
|
|
3916
4039
|
// SSE endpoint for live updates
|
|
3917
4040
|
app.get('/api/events', (req, res) => {
|
|
@@ -4201,10 +4324,31 @@ async function cleanupAgentActivity() {
|
|
|
4201
4324
|
} catch { /* agent-activity dir may not exist */ }
|
|
4202
4325
|
}
|
|
4203
4326
|
|
|
4327
|
+
// Dispatch markers, reviews and worktrees: see docs/retention.md.
|
|
4328
|
+
async function runRetention() {
|
|
4329
|
+
try {
|
|
4330
|
+
const scan = await scanTranscripts(PROJECTS_DIR);
|
|
4331
|
+
const opts = { known: scan?.ids, maxAgeMs: retentionMs(CLAUDE_DIR) };
|
|
4332
|
+
const markers = dispatched.prune(opts);
|
|
4333
|
+
const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
|
|
4334
|
+
const wts = worktrees.prune(scan?.dirs);
|
|
4335
|
+
if (markers || reviews || wts) {
|
|
4336
|
+
console.log(`[retention] removed ${markers} dispatch markers, ${reviews} reviews, ${wts} worktrees`);
|
|
4337
|
+
}
|
|
4338
|
+
} catch (e) {
|
|
4339
|
+
console.warn('[retention] failed:', e.message);
|
|
4340
|
+
}
|
|
4341
|
+
}
|
|
4342
|
+
|
|
4204
4343
|
cleanupAgentActivity();
|
|
4205
4344
|
cleanupContextStatus();
|
|
4206
4345
|
setInterval(cleanupAgentActivity, CLEANUP_INTERVAL_MS);
|
|
4207
4346
|
setInterval(cleanupContextStatus, 30 * 60 * 1000);
|
|
4347
|
+
// Later than the startup scan and prewarm, so the first sweep never competes with them.
|
|
4348
|
+
setTimeout(() => {
|
|
4349
|
+
runRetention();
|
|
4350
|
+
setInterval(runRetention, CLEANUP_INTERVAL_MS);
|
|
4351
|
+
}, RETENTION_FIRST_RUN_MS);
|
|
4208
4352
|
|
|
4209
4353
|
// Warm the metadata + loop-info caches in the background so the first user
|
|
4210
4354
|
// request lands warm. The cheap-probe in /api/sessions skips per-session
|
package/skill-guides/dispatch.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
-
A dispatch
|
|
5
|
+
A dispatch **reports back** by default: start it with `--report` and `--peer`, then collect and verify the outcome (With `--report`, below). When the user's request says `--no-report`, it is **fire-and-forget**: start it with neither flag (Fire-and-forget, below). `--no-report` lives only in the user's request; `dispatch start` has no such flag.
|
|
6
6
|
|
|
7
7
|
## Write the spec
|
|
8
8
|
|
|
@@ -19,12 +19,12 @@ Dispatch when the task can run on its own. Do the work yourself when it is small
|
|
|
19
19
|
## Start
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
claude-code-kanban dispatch start --cwd <dir> --spec-file <spec.md> --name <name> --group <group> --peer <your-peer>
|
|
22
|
+
claude-code-kanban dispatch start --cwd <dir> --spec-file <spec.md> --name <name> --group <group> --peer <your-peer> --report --json
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
`claude-code-kanban help dispatch start` lists every flag (model, worktree, and the rest). `project list` shows the folders `--cwd` accepts. How to choose the values:
|
|
26
26
|
|
|
27
|
-
- `--peer` is your own peer name: the first line of `ListAgents` ("This session is `<name>`"). Pass it
|
|
27
|
+
- `--peer` is your own peer name: the first line of `ListAgents` ("This session is `<name>`"). Pass it when 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).
|
|
28
28
|
- `--spec-file` over `--spec` for anything longer than a line: no shell quoting.
|
|
29
29
|
- `--name` is what the user sees in the sidebar. Kebab-case, saying what the session does: `fix-login-redirect`, not `task-1`.
|
|
30
30
|
- `--group` names the effort, in kebab-case (`auth-refactor`), and shows the new session under that sidebar group. This session stays where it is. 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.
|
|
@@ -32,13 +32,13 @@ claude-code-kanban dispatch start --cwd <dir> --spec-file <spec.md> --name <name
|
|
|
32
32
|
|
|
33
33
|
## Fire-and-forget
|
|
34
34
|
|
|
35
|
-
Tell the user the session name, its group, and the dispatch id
|
|
35
|
+
The started session gets the task alone and does not know your session exists. Tell the user the session name, its group, and the dispatch id. Your part ends with that message: the user follows the dispatch in the sidebar, and asks you when they want it checked.
|
|
36
36
|
|
|
37
37
|
## With `--report`
|
|
38
38
|
|
|
39
39
|
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:
|
|
40
40
|
|
|
41
|
-
- **Inbox:** this skill armed it. Lines `
|
|
41
|
+
- **Inbox:** this skill armed it. Lines `[kanban board] Dispatch <id> (session <uuid>) ...` arrive on their own while you keep working.
|
|
42
42
|
- **Wait:** block until one settles.
|
|
43
43
|
|
|
44
44
|
```bash
|