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.
Files changed (42) hide show
  1. package/README.md +3 -2
  2. package/lib/auto-compact.js +42 -0
  3. package/lib/claude-settings.js +21 -0
  4. package/lib/dispatch.js +11 -4
  5. package/lib/retention.js +137 -0
  6. package/lib/session-events.js +30 -7
  7. package/lib/terminal.js +23 -3
  8. package/package.json +1 -1
  9. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
  10. package/plugin/plugins/claude-code-kanban/hooks/hooks.json +14 -7
  11. package/plugin/plugins/claude-code-kanban/monitors.json +3 -3
  12. package/plugin/plugins/claude-code-kanban/scripts/postman.js +2 -2
  13. package/plugin/plugins/claude-code-kanban/skills/{kanban-dispatch → dispatch}/SKILL.md +2 -2
  14. package/plugin/plugins/claude-code-kanban/skills/{kanban-follow → follow}/SKILL.md +17 -9
  15. package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -1
  16. package/public/app.js +699 -138
  17. package/public/fonts/OFL-IBM-Plex.txt +93 -0
  18. package/public/fonts/OFL-Playfair-Display.txt +93 -0
  19. package/public/fonts/fonts.css +244 -0
  20. package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  21. package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
  22. package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  23. package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  24. package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
  25. package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  26. package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  27. package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  28. package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  29. package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  30. package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
  31. package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  32. package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
  33. package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
  34. package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
  35. package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
  36. package/public/fonts/playfair-display-latin-400.woff2 +0 -0
  37. package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
  38. package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
  39. package/public/index.html +20 -14
  40. package/public/style.css +277 -44
  41. package/server.js +152 -15
  42. 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
- contextStatus: getContextStatus(id, meta),
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 startedBy = starters.get(s.id);
3356
- return dispatchGroup || startedBy ? { ...s, dispatchGroup, startedBy } : s;
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 { enqueueSessionEvent, formatTaskMoved, handleSessionEvents, topicKey } = require('./lib/session-events');
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
@@ -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 `cck:1 dispatch.<status> <id> ...` arrive on their own while you keep working.
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