claude-code-kanban 5.1.1 → 5.2.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-settings.js +21 -0
- package/lib/dispatch.js +11 -4
- package/lib/retention.js +137 -0
- package/lib/session-events.js +30 -7
- package/lib/terminal.js +23 -3
- 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 +14 -7
- 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 +2 -2
- 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 -1
- package/public/app.js +699 -138
- 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 +20 -14
- package/public/style.css +277 -44
- package/server.js +152 -15
- package/skill-guides/dispatch.md +1 -1
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,
|
|
@@ -40,8 +41,9 @@ const { inlineHtmlAssets, MIME_BY_EXT } = require('./lib/inline-assets');
|
|
|
40
41
|
const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
|
|
41
42
|
const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
|
|
42
43
|
const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
|
|
43
|
-
const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName } = require('./lib/dispatch');
|
|
44
|
+
const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName, DISPATCH_OUTCOME } = require('./lib/dispatch');
|
|
44
45
|
const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
|
|
46
|
+
const { createDispatchedStore, listTranscriptIds, pruneSessionDirs, retentionMs } = require('./lib/retention');
|
|
45
47
|
const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
|
|
46
48
|
const { pickFolder } = require('./lib/folder-dialog');
|
|
47
49
|
const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
|
|
@@ -90,6 +92,7 @@ const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
|
|
|
90
92
|
const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
|
|
91
93
|
const PINS_FILE = path.join(CCK_DIR, 'pins.json');
|
|
92
94
|
const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
|
|
95
|
+
const DISPATCHED_FILE = path.join(CCK_DIR, 'dispatched.json');
|
|
93
96
|
const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
|
|
94
97
|
const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
|
|
95
98
|
const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
|
|
@@ -200,6 +203,7 @@ const WAITING_RESOLVE_GRACE_MS = 15 * 1000;
|
|
|
200
203
|
const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
|
|
201
204
|
const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
|
|
202
205
|
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
|
206
|
+
const RETENTION_FIRST_RUN_MS = 5 * 60 * 1000;
|
|
203
207
|
// #endregion
|
|
204
208
|
|
|
205
209
|
// #region AGENT_ACTIVITY
|
|
@@ -286,6 +290,11 @@ function getContextStatus(sessionId, meta) {
|
|
|
286
290
|
return contextStatusCache.get(sessionId) || (meta?.teamLeaderId ? contextStatusCache.get(meta.teamLeaderId) : null) || null;
|
|
287
291
|
}
|
|
288
292
|
|
|
293
|
+
function getContextFields(sessionId, meta) {
|
|
294
|
+
const contextStatus = getContextStatus(sessionId, meta);
|
|
295
|
+
return { contextStatus, autoCompact: contextStatus ? getAutoCompact(CLAUDE_DIR, meta?.project) : null };
|
|
296
|
+
}
|
|
297
|
+
|
|
289
298
|
function isAgentFresh(agent) {
|
|
290
299
|
if (isGhostAgent(agent)) return false;
|
|
291
300
|
const ts = agent.updatedAt || agent.startedAt;
|
|
@@ -1191,7 +1200,7 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
1191
1200
|
tasksDir: null,
|
|
1192
1201
|
projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
|
|
1193
1202
|
scratchpadDir: getScratchpadDir(id, meta),
|
|
1194
|
-
|
|
1203
|
+
...getContextFields(id, meta),
|
|
1195
1204
|
...getPlanInfo(meta.slug),
|
|
1196
1205
|
...getWorkflowInfoSummary(id),
|
|
1197
1206
|
...overrides,
|
|
@@ -1520,10 +1529,7 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1520
1529
|
// Backfill contextStatus for already-built sessions that are pinned
|
|
1521
1530
|
for (const pid of pinnedIds) {
|
|
1522
1531
|
const s = sessionsMap.get(pid);
|
|
1523
|
-
if (s && !s.contextStatus)
|
|
1524
|
-
const meta = metadata[pid];
|
|
1525
|
-
s.contextStatus = getContextStatus(pid, meta);
|
|
1526
|
-
}
|
|
1532
|
+
if (s && !s.contextStatus) Object.assign(s, getContextFields(pid, metadata[pid]));
|
|
1527
1533
|
}
|
|
1528
1534
|
|
|
1529
1535
|
// Ensure pinned sessions are in the map even if they weren't discovered
|
|
@@ -1750,7 +1756,7 @@ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
|
|
|
1750
1756
|
if (!existsSync(planPath)) return res.json({ content: null });
|
|
1751
1757
|
|
|
1752
1758
|
const content = await fs.readFile(planPath, 'utf8');
|
|
1753
|
-
res.json({ content, slug });
|
|
1759
|
+
res.json({ content, slug, path: planPath });
|
|
1754
1760
|
} catch (error) {
|
|
1755
1761
|
console.error('Error reading plan:', error);
|
|
1756
1762
|
res.status(500).json({ error: 'Failed to read plan' });
|
|
@@ -3318,9 +3324,17 @@ app.get('/vendor/xterm/:file', (req, res) => {
|
|
|
3318
3324
|
// #endregion
|
|
3319
3325
|
|
|
3320
3326
|
// #region DISPATCH
|
|
3327
|
+
const dispatched = createDispatchedStore({
|
|
3328
|
+
load: () => {
|
|
3329
|
+
try { return JSON.parse(readFileSync(DISPATCHED_FILE, 'utf8')); } catch { return null; }
|
|
3330
|
+
},
|
|
3331
|
+
save: (data) => writeJsonAtomic(DISPATCHED_FILE, data),
|
|
3332
|
+
});
|
|
3333
|
+
|
|
3321
3334
|
const dispatches = createDispatchRegistry({
|
|
3322
3335
|
onSettle: (r) => {
|
|
3323
3336
|
if (r.parent && r.report) enqueueSessionEvent(topicKey('dispatch', r.parent), formatDispatchLine(r));
|
|
3337
|
+
if (r.session) dispatched.settle(r.session, r.status);
|
|
3324
3338
|
broadcast({ type: 'dispatch-update' });
|
|
3325
3339
|
},
|
|
3326
3340
|
});
|
|
@@ -3343,17 +3357,19 @@ const linkedDocs = createLinkedDocStore({
|
|
|
3343
3357
|
|
|
3344
3358
|
// The board places a session from these alone, so it never has to move it later.
|
|
3345
3359
|
// `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.
|
|
3360
|
+
// browser knows; it goes once the dispatch settles. `dispatched` stays for the card's marker.
|
|
3347
3361
|
function withDispatchPlacement(sessions) {
|
|
3348
3362
|
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
3363
|
return sessions.map((s) => {
|
|
3354
3364
|
const dispatchGroup = groups.get(s.id);
|
|
3355
|
-
const
|
|
3356
|
-
|
|
3365
|
+
const marker = dispatched.get(s.id);
|
|
3366
|
+
if (!dispatchGroup && !marker) return s;
|
|
3367
|
+
return {
|
|
3368
|
+
...s,
|
|
3369
|
+
dispatchGroup,
|
|
3370
|
+
startedBy: marker?.status === 'running' ? marker.parent || undefined : undefined,
|
|
3371
|
+
dispatched: marker ? { parent: marker.parent, outcome: DISPATCH_OUTCOME[marker.status] } : undefined,
|
|
3372
|
+
};
|
|
3357
3373
|
});
|
|
3358
3374
|
}
|
|
3359
3375
|
|
|
@@ -3377,6 +3393,7 @@ app.post('/api/dispatch', (req, res) => {
|
|
|
3377
3393
|
return res.status(started.status).json({ error: started.error });
|
|
3378
3394
|
}
|
|
3379
3395
|
dispatches.attach(r.id, { session: started.id, cwd: started.cwd });
|
|
3396
|
+
dispatched.record(started.id, parent);
|
|
3380
3397
|
// The starter stays where it is: moving it would jump it under the user.
|
|
3381
3398
|
if (target) dispatchGroups.join(target, [started.id], parent);
|
|
3382
3399
|
broadcast({ type: 'dispatch-update' });
|
|
@@ -3458,7 +3475,14 @@ app.get('/api/tasks/all', async (_req, res) => {
|
|
|
3458
3475
|
}
|
|
3459
3476
|
});
|
|
3460
3477
|
|
|
3461
|
-
const {
|
|
3478
|
+
const {
|
|
3479
|
+
enqueueSessionEvent,
|
|
3480
|
+
formatReviewSubmitted,
|
|
3481
|
+
formatTaskMoved,
|
|
3482
|
+
handleSessionEvents,
|
|
3483
|
+
hasSessionListener,
|
|
3484
|
+
topicKey,
|
|
3485
|
+
} = require('./lib/session-events');
|
|
3462
3486
|
app.get('/api/sessions/:sessionId/events', handleSessionEvents);
|
|
3463
3487
|
|
|
3464
3488
|
// API: Create a task
|
|
@@ -3912,6 +3936,102 @@ app.get('/api/file/resolve', async (req, res) => {
|
|
|
3912
3936
|
|
|
3913
3937
|
// #endregion
|
|
3914
3938
|
|
|
3939
|
+
// #region REVIEW
|
|
3940
|
+
// API: Send review comments on something the board shows (a file preview today) to a
|
|
3941
|
+
// session. The source is described, not interpreted, so a new kind of preview needs a
|
|
3942
|
+
// client change only. The batch is always written to a file: the doorbell is lossy and
|
|
3943
|
+
// carries one line, and the file is what each route points at.
|
|
3944
|
+
const REVIEW_DIR = path.join(CCK_DIR, 'reviews');
|
|
3945
|
+
const REVIEW_KIND_RE = /^[a-z]{1,20}$/;
|
|
3946
|
+
const REVIEW_MAX_COMMENTS = 50;
|
|
3947
|
+
const REVIEW_MAX_CHARS = 4000;
|
|
3948
|
+
|
|
3949
|
+
function parseReviewBody(body) {
|
|
3950
|
+
const { source, comments } = body || {};
|
|
3951
|
+
if (!source || !REVIEW_KIND_RE.test(source.kind)) throw previewError(400, 'source.kind is required');
|
|
3952
|
+
// The label is pasted into the terminal; an ESC could end bracketed paste and submit text.
|
|
3953
|
+
const label = String(source.label || '')
|
|
3954
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
|
|
3955
|
+
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
|
3956
|
+
.trim()
|
|
3957
|
+
.slice(0, 200);
|
|
3958
|
+
if (!label) throw previewError(400, 'source.label is required');
|
|
3959
|
+
if (!Array.isArray(comments) || !comments.length || comments.length > REVIEW_MAX_COMMENTS) {
|
|
3960
|
+
throw previewError(400, `comments must hold 1 to ${REVIEW_MAX_COMMENTS} items`);
|
|
3961
|
+
}
|
|
3962
|
+
const field = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().replace(/\s+/g, ' ').slice(0, max) : null);
|
|
3963
|
+
// Element and selector are printed inside a markdown code span.
|
|
3964
|
+
const code = (v, max) => field(v, max)?.replace(/`/g, "'") || null;
|
|
3965
|
+
const items = comments.map((c) => ({
|
|
3966
|
+
quote: String(c?.quote || '').trim().slice(0, REVIEW_MAX_CHARS),
|
|
3967
|
+
comment: String(c?.comment || '').trim().slice(0, REVIEW_MAX_CHARS),
|
|
3968
|
+
heading: field(c?.heading, 200),
|
|
3969
|
+
line: Number.isInteger(c?.line) && c.line > 0 ? c.line : null,
|
|
3970
|
+
element: code(c?.element, 300),
|
|
3971
|
+
selector: code(c?.selector, 500),
|
|
3972
|
+
}));
|
|
3973
|
+
if (items.some((c) => !c.comment)) throw previewError(400, 'every comment needs text');
|
|
3974
|
+
return { kind: source.kind, label, path: source.kind === 'file' ? source.path : null, items };
|
|
3975
|
+
}
|
|
3976
|
+
|
|
3977
|
+
function formatReviewMarkdown(src, items) {
|
|
3978
|
+
const parts = [
|
|
3979
|
+
`# Review of ${src.path || src.label}`,
|
|
3980
|
+
'',
|
|
3981
|
+
"Each comment is the user's instruction about the quoted text. A quote missing from the source means the source changed after the review: say so.",
|
|
3982
|
+
];
|
|
3983
|
+
items.forEach((c, i) => {
|
|
3984
|
+
const where = [c.line && `line ${c.line}`, c.heading && `under "${c.heading}"`].filter(Boolean).join(', ');
|
|
3985
|
+
parts.push('', `## ${i + 1}${where ? ` (${where})` : ''}`, '');
|
|
3986
|
+
if (c.element) parts.push(`Element: \`${c.element}\`${c.selector ? ` at \`${c.selector}\`` : ''}`, '');
|
|
3987
|
+
if (c.quote) parts.push(...c.quote.split(/\r?\n/).map((l) => `> ${l}`), '');
|
|
3988
|
+
parts.push(c.comment);
|
|
3989
|
+
});
|
|
3990
|
+
return `${parts.join('\n')}\n`;
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
function isWaitingOnUser(sessionId) {
|
|
3994
|
+
const meta = loadSessionMetadata()[sessionId] || {};
|
|
3995
|
+
return !!checkWaitingForUser(path.join(AGENT_ACTIVITY_DIR, sessionId), getSessionLogStat(meta).mtime);
|
|
3996
|
+
}
|
|
3997
|
+
|
|
3998
|
+
app.post('/api/sessions/:sessionId/review', async (req, res) => {
|
|
3999
|
+
try {
|
|
4000
|
+
const sessionId = req.params.sessionId;
|
|
4001
|
+
if (!isUUID(sessionId)) return res.status(400).json({ error: 'invalid session id' });
|
|
4002
|
+
const src = parseReviewBody(req.body);
|
|
4003
|
+
if (src.kind === 'file') {
|
|
4004
|
+
src.path = resolvePreviewPath(src.path);
|
|
4005
|
+
if (!src.path) return res.status(400).json({ error: 'source.path is required for a file' });
|
|
4006
|
+
await statFileTarget(src.path);
|
|
4007
|
+
}
|
|
4008
|
+
|
|
4009
|
+
const markdown = formatReviewMarkdown(src, src.items);
|
|
4010
|
+
const dir = path.join(REVIEW_DIR, sessionId);
|
|
4011
|
+
await fs.mkdir(dir, { recursive: true });
|
|
4012
|
+
const file = path.join(dir, `${Date.now()}.md`);
|
|
4013
|
+
await fs.writeFile(file, markdown);
|
|
4014
|
+
|
|
4015
|
+
let delivered = null;
|
|
4016
|
+
if (hasSessionListener(sessionId)) {
|
|
4017
|
+
enqueueSessionEvent(sessionId, formatReviewSubmitted(src.items.length, src.label, file));
|
|
4018
|
+
delivered = 'doorbell';
|
|
4019
|
+
} else if (
|
|
4020
|
+
terminal.authorized(req.get('x-terminal-token')) &&
|
|
4021
|
+
terminal.isRunning(sessionId) &&
|
|
4022
|
+
!isWaitingOnUser(sessionId)
|
|
4023
|
+
) {
|
|
4024
|
+
if (terminal.paste(sessionId, `Address my review comments on ${src.label}: ${file}`)) delivered = 'terminal';
|
|
4025
|
+
}
|
|
4026
|
+
res.json({ delivered, file, markdown });
|
|
4027
|
+
} catch (error) {
|
|
4028
|
+
if (!error.status) console.error('Error in POST /api/sessions/:id/review:', error);
|
|
4029
|
+
res.status(error.status || 500).json({ error: error.message || 'Review failed' });
|
|
4030
|
+
}
|
|
4031
|
+
});
|
|
4032
|
+
|
|
4033
|
+
// #endregion
|
|
4034
|
+
|
|
3915
4035
|
// #region SSE
|
|
3916
4036
|
// SSE endpoint for live updates
|
|
3917
4037
|
app.get('/api/events', (req, res) => {
|
|
@@ -4201,10 +4321,27 @@ async function cleanupAgentActivity() {
|
|
|
4201
4321
|
} catch { /* agent-activity dir may not exist */ }
|
|
4202
4322
|
}
|
|
4203
4323
|
|
|
4324
|
+
// Dispatch markers and reviews: see docs/retention.md.
|
|
4325
|
+
async function runRetention() {
|
|
4326
|
+
try {
|
|
4327
|
+
const opts = { known: await listTranscriptIds(PROJECTS_DIR), maxAgeMs: retentionMs(CLAUDE_DIR) };
|
|
4328
|
+
const markers = dispatched.prune(opts);
|
|
4329
|
+
const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
|
|
4330
|
+
if (markers || reviews) console.log(`[retention] removed ${markers} dispatch markers, ${reviews} reviews`);
|
|
4331
|
+
} catch (e) {
|
|
4332
|
+
console.warn('[retention] failed:', e.message);
|
|
4333
|
+
}
|
|
4334
|
+
}
|
|
4335
|
+
|
|
4204
4336
|
cleanupAgentActivity();
|
|
4205
4337
|
cleanupContextStatus();
|
|
4206
4338
|
setInterval(cleanupAgentActivity, CLEANUP_INTERVAL_MS);
|
|
4207
4339
|
setInterval(cleanupContextStatus, 30 * 60 * 1000);
|
|
4340
|
+
// Later than the startup scan and prewarm, so the first sweep never competes with them.
|
|
4341
|
+
setTimeout(() => {
|
|
4342
|
+
runRetention();
|
|
4343
|
+
setInterval(runRetention, CLEANUP_INTERVAL_MS);
|
|
4344
|
+
}, RETENTION_FIRST_RUN_MS);
|
|
4208
4345
|
|
|
4209
4346
|
// Warm the metadata + loop-info caches in the background so the first user
|
|
4210
4347
|
// request lands warm. The cheap-probe in /api/sessions skips per-session
|
package/skill-guides/dispatch.md
CHANGED
|
@@ -38,7 +38,7 @@ Tell the user the session name, its group, and the dispatch id, then carry on wi
|
|
|
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
|