claude-code-kanban 7.6.1 → 7.8.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/server.js CHANGED
@@ -16,7 +16,6 @@ const { isContained, realpathDeepest } = require('./lib/contain');
16
16
  const { resolveScratchSubdir, listScratchDir } = require('./lib/scratch-files');
17
17
  const { fileUrlToPath } = require('./lib/file-url');
18
18
  const { httpError: previewError } = require('./lib/http-error');
19
- const { oneLine } = require('./lib/one-line');
20
19
  const { pluginStatus } = require('./lib/plugin-status');
21
20
  const { getAutoCompact } = require('./lib/auto-compact');
22
21
 
@@ -49,10 +48,15 @@ const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRef
49
48
  const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
50
49
  const { readTerminalConfig } = require('./lib/terminal');
51
50
  const { createTerminalClient } = require('./lib/terminal-client');
51
+ const { kanbotOn, kanbotModel, createKanbot } = require('./lib/kanbot');
52
52
  const { createShowStore, mountShowRoutes, showBodyParser, SHOW_PATH } = require('./lib/show');
53
- const { readLiveSessions, isPidAlive, isSessionLive } = require('./lib/live-sessions');
53
+ const { readLiveSessions, isPidAlive } = require('./lib/live-sessions');
54
54
  const { createProcStats } = require('./lib/proc-stats');
55
- const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
55
+ const { createUserGroupStore } = require('./lib/user-groups');
56
+ const { createSessionPinStore } = require('./lib/session-pins');
57
+ const { stampedJsonFile } = require('./lib/stamped-json-file');
58
+ const { ownerLinks, moveRecipients } = require('./lib/owner-routing');
59
+ const { parseReviewSource, parseActionBody, formatActionMarkdown } = require('./lib/preview-action');
56
60
  const { createDispatchedStore, scanTranscripts, pruneSessionDirs, pruneContextStatus, pruneTaskMaps, retentionMs } = require('./lib/retention');
57
61
  const { freshRateLimits } = require('./lib/rate-limits');
58
62
  const { createWorktreeStore } = require('./lib/worktrees');
@@ -66,7 +70,7 @@ const { pickFolder } = require('./lib/folder-dialog');
66
70
  const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
67
71
  const { countTaskDir } = require('./lib/task-counts');
68
72
  const { readTaskDir } = require('./lib/task-dir');
69
- const { projectMatcher, normalizeProjectPath } = require('./public/project-match');
73
+ const { projectMatcher, normalizeProjectPath, sessionProjectKey } = require('./public/project-match');
70
74
  const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
71
75
 
72
76
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
@@ -110,7 +114,7 @@ const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
110
114
  const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
111
115
  const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
112
116
  const PINS_FILE = path.join(CCK_DIR, 'pins.json');
113
- const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
117
+ const USER_GROUPS_FILE = path.join(CCK_DIR, 'groups.json');
114
118
  const DISPATCHED_FILE = path.join(CCK_DIR, 'dispatched.json');
115
119
  const WORKTREES_FILE = path.join(CCK_DIR, 'worktrees.json');
116
120
  const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
@@ -141,15 +145,6 @@ const SCRATCHPAD_ROOT = (() => {
141
145
  // #endregion
142
146
 
143
147
  // #region SERVER_STATE
144
- // Server-side pin mirror (UI authoritative, server stores latest pushed state for CLI queries).
145
- function readPins() {
146
- try {
147
- const obj = JSON.parse(readFileSync(PINS_FILE, 'utf8'));
148
- if (obj && typeof obj === 'object' && !Array.isArray(obj)) return obj;
149
- } catch (_) {}
150
- return {};
151
- }
152
-
153
148
  function writeJsonAtomic(file, obj, mode) {
154
149
  mkdirSync(path.dirname(file), { recursive: true });
155
150
  const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
@@ -202,9 +197,7 @@ const jsonFile = (file) => ({
202
197
  save: (data) => writeJsonAtomicOrLog(file, data),
203
198
  });
204
199
 
205
- function writePins(pins) {
206
- writeJsonAtomic(PINS_FILE, pins);
207
- }
200
+ const sessionPins = createSessionPinStore(stampedJsonFile(PINS_FILE, writeJsonAtomic));
208
201
 
209
202
  // Port discovery for out-of-process helpers (the plugin's doorbell mod, the CLI).
210
203
  // The pid rides along so a reader can tell a live server from a file left behind by
@@ -320,6 +313,16 @@ function boardEventsEnabled() {
320
313
  return cachedByMtime(cckConfigCache, 'boardEvents', CCK_CONFIG_FILE,
321
314
  () => boardEventsOn(readCckConfig()), boardEventsOn(null));
322
315
  }
316
+ function kanbotEnabled() {
317
+ return cachedByMtime(cckConfigCache, 'kanbot', CCK_CONFIG_FILE,
318
+ () => kanbotOn(readCckConfig()), kanbotOn(null));
319
+ }
320
+ function kanbotModelSetting() {
321
+ return cachedByMtime(cckConfigCache, 'kanbotModel', CCK_CONFIG_FILE,
322
+ () => kanbotModel(readCckConfig()), kanbotModel(null));
323
+ }
324
+ // Kanbot's transcripts all land in one project folder, which the board never lists (docs/kanbot.md).
325
+ const kanbot = createKanbot({ cckDir: CCK_DIR, projectsDir: PROJECTS_DIR });
323
326
 
324
327
  // approvals.json predates config.json (and was opt-in). Fold it into
325
328
  // config.json once so an existing opt-in keeps its tuning, then drop it.
@@ -934,7 +937,7 @@ function loadSessionMetadata() {
934
937
  }
935
938
 
936
939
  const projectDirs = readdirSync(PROJECTS_DIR, { withFileTypes: true })
937
- .filter(d => d.isDirectory());
940
+ .filter(d => d.isDirectory() && !kanbot.isOwnProjectDirName(d.name));
938
941
 
939
942
  for (const projectDir of projectDirs) {
940
943
  const projectPath = path.join(PROJECTS_DIR, projectDir.name);
@@ -1263,12 +1266,13 @@ app.get('/api/sessions', async (req, res) => {
1263
1266
  const limitParam = req.query.limit || '20';
1264
1267
  const limit = limitParam === 'all' ? null : parseInt(limitParam, 10);
1265
1268
 
1266
- const pinnedParam = req.query.pinned;
1269
+ // `pinned=` from a board page loaded before the server kept pins is ignored.
1267
1270
  const includeIds = req.query.include ? new Set(req.query.include.split(',').filter(Boolean)) : new Set();
1268
- const pinnedIds = pinnedParam ? new Set(pinnedParam.split(',').filter(Boolean)) : new Set();
1271
+ const pins = req.query.pins === 'off' ? {} : sessionPins.state().pins;
1272
+ const pinnedIds = new Set(Object.keys(pins));
1269
1273
  for (const id of includeIds) pinnedIds.add(id);
1270
1274
  const activeFilter = req.query.filter === 'active';
1271
- const terminalIds = activeFilter ? new Set(terminal.ids()) : new Set();
1275
+ const terminalIds = new Set(terminal.ids());
1272
1276
 
1273
1277
  const metadata = loadSessionMetadata();
1274
1278
  const sessionsMap = new Map();
@@ -1524,6 +1528,9 @@ app.get('/api/sessions', async (req, res) => {
1524
1528
  } catch (_) {}
1525
1529
  }
1526
1530
 
1531
+ // Task dirs and agent-activity name Kanbot's sessions too.
1532
+ for (const sid of sessionsMap.keys()) if (kanbot.isOwnSession(sid)) sessionsMap.delete(sid);
1533
+
1527
1534
  // Correlate plan sessions with their implementation sessions (same slug)
1528
1535
  const slugGroups = new Map();
1529
1536
  for (const [_sid, session] of sessionsMap) {
@@ -1589,20 +1596,16 @@ app.get('/api/sessions', async (req, res) => {
1589
1596
  }));
1590
1597
  }
1591
1598
 
1592
- // Server-side activity filter (mirrors the client predicate in public/app.js).
1593
- // Pinned IDs bypass — they should always be in the response.
1594
- if (activeFilter) {
1595
- const isActive = (s) =>
1596
- s.hasMessages && (
1597
- (!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0))
1598
- || s.hasActiveAgents
1599
- || s.hasWaitingForUser
1600
- || s.hasRecentActivity
1601
- );
1602
- for (const [id, s] of sessionsMap) {
1603
- if (pinnedIds.has(id) || terminalIds.has(id)) continue;
1604
- if (!isActive(s)) sessionsMap.delete(id);
1605
- }
1599
+ // The sidebar's Active rule (getFilteredSessions in public/app.js); the CLI reads `active`
1600
+ // instead of keeping its own copy. Pinned IDs bypass the filter.
1601
+ for (const [id, s] of sessionsMap) {
1602
+ s.active = terminalIds.has(id) || !!(s.hasMessages && (
1603
+ (!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0))
1604
+ || s.hasActiveAgents
1605
+ || s.hasWaitingForUser
1606
+ || s.hasRecentActivity
1607
+ ));
1608
+ if (activeFilter && !s.active && !pinnedIds.has(id)) sessionsMap.delete(id);
1606
1609
  }
1607
1610
 
1608
1611
  // Convert map to array and sort by most recently modified
@@ -1611,7 +1614,7 @@ app.get('/api/sessions', async (req, res) => {
1611
1614
 
1612
1615
  // Apply project filter before limit so the limit is per-project
1613
1616
  const projectFilter = req.query.project;
1614
- // `include` is narrower than `pinned`: pinned rows survive the limit but still obey
1617
+ // `include` is narrower than a pin: pinned rows survive the limit but still obey
1615
1618
  // the project filter, because pinning is a preference and the filter is an intent.
1616
1619
  // The client sends the session it currently has open, which it cannot render at all
1617
1620
  // if the row is missing — that one is not a preference.
@@ -1631,6 +1634,7 @@ app.get('/api/sessions', async (req, res) => {
1631
1634
  const paged = limit !== null && limit > 0;
1632
1635
  // The sidebar loads the next page on scroll while this is true. A header, so the body stays a plain array.
1633
1636
  res.setHeader('X-Has-More', String(paged && sessions.length > limit));
1637
+ res.setHeader('X-Total-Count', String(sessions.length));
1634
1638
  // Apply limit if specified, but always include pinned sessions
1635
1639
  if (paged) {
1636
1640
  const top = sessions.slice(0, limit);
@@ -1639,17 +1643,21 @@ app.get('/api/sessions', async (req, res) => {
1639
1643
  sessions = [...top, ...missingPinned];
1640
1644
  }
1641
1645
 
1642
- // Loop info can mean a full read of the transcript, so only the rows sent pay for it.
1646
+ // Loop info can mean a full read of the transcript, so only the rows sent pay for it, and
1647
+ // `lite=1` (the CLI's group list, which only counts and names sessions) skips it.
1643
1648
  // Same for autoCompact: up to 3 settings stats per call.
1649
+ const lite = req.query.lite === '1';
1644
1650
  const autoCompactByProject = new Map();
1645
1651
  for (const s of sessions) {
1652
+ s.pin = pins[s.id] || null;
1653
+ if (lite) continue;
1646
1654
  s.loopInfo = getLoopInfoSummary(s);
1647
1655
  if (!s.contextStatus) continue;
1648
1656
  if (!autoCompactByProject.has(s.project)) autoCompactByProject.set(s.project, getAutoCompact(CLAUDE_DIR, s.project));
1649
1657
  s.autoCompact = autoCompactByProject.get(s.project);
1650
1658
  }
1651
1659
 
1652
- res.json(withDispatchPlacement(sessions));
1660
+ res.json(withDispatchMarker(sessions));
1653
1661
  startPrewarm();
1654
1662
  } catch (error) {
1655
1663
  console.error('Error listing sessions:', error);
@@ -1687,7 +1695,7 @@ app.get('/api/sessions/known', (_req, res) => {
1687
1695
  known.set(id, { id, project: meta.project || null, name: getSessionDisplayName(id, meta) });
1688
1696
  }
1689
1697
  const add = (id, project = null) => {
1690
- if (!known.has(id)) known.set(id, { id, project, name: null });
1698
+ if (!known.has(id) && !kanbot.isOwnSession(id)) known.set(id, { id, project, name: null });
1691
1699
  };
1692
1700
  for (const dir of [TASKS_DIR, AGENT_ACTIVITY_DIR]) {
1693
1701
  if (!existsSync(dir)) continue;
@@ -1749,24 +1757,19 @@ app.get('/api/projects', (_req, res) => {
1749
1757
  res.json(projects);
1750
1758
  });
1751
1759
 
1760
+ const ownerRoutingInputs = () => ({
1761
+ dispatched,
1762
+ listToSessions: loadAllTaskMaps().listToSessions,
1763
+ metadata: loadSessionMetadata,
1764
+ });
1765
+
1752
1766
  // API: Get tasks for a session
1753
- // A card's owner links to the session of that name that the list's session dispatched.
1754
- // A name two of its dispatched sessions share gets no link.
1755
1767
  function addOwnerSessions(tasks, listIdOf) {
1756
1768
  const lists = new Set(tasks.filter((t) => t.owner).map(listIdOf));
1757
1769
  if (!lists.size) return;
1758
- const childIds = [...dispatched.entries()].filter(([, e]) => lists.has(e.parent));
1759
- if (!childIds.length) return;
1760
- const metadata = loadSessionMetadata();
1761
- const children = new Map();
1762
- for (const [id, { parent }] of childIds) {
1763
- const name = metadata[id]?.agentName;
1764
- if (!name) continue;
1765
- const key = `${parent}\n${name}`;
1766
- children.set(key, children.has(key) ? null : id);
1767
- }
1770
+ const linkOf = ownerLinks(lists, ownerRoutingInputs());
1768
1771
  for (const t of tasks) {
1769
- const id = t.owner && children.get(`${listIdOf(t)}\n${t.owner}`);
1772
+ const id = t.owner && linkOf(listIdOf(t), t.owner);
1770
1773
  if (id) t.ownerSessionId = id;
1771
1774
  }
1772
1775
  }
@@ -3276,19 +3279,17 @@ app.get('/api/config', (_req, res) => {
3276
3279
  memoryUrl: MEMORY_URL,
3277
3280
  scratchAvailable: !!whichSync('scratch'),
3278
3281
  terminal: terminal.clientConfig(),
3282
+ kanbot: kanbotEnabled(),
3279
3283
  });
3280
3284
  });
3281
3285
 
3282
3286
  // #endregion
3283
3287
 
3284
3288
  // #region TERMINAL
3285
- // A new session may start only in a folder the user has already worked in, or one they
3286
- // chose in the native dialog during this run. Anything else would let a page script pick
3287
- // the directory claude runs in.
3288
- const pickedFolders = new Set();
3289
+ // Any existing folder is allowed: the terminal token already opens a shell PTY, so a folder
3290
+ // list would block valid use without protecting anything.
3289
3291
  function isAllowedFolder(dir) {
3290
- const known = pickedFolders.has(dir) || Object.values(loadSessionMetadata()).some((m) => m.project === dir);
3291
- try { return known && statSync(dir).isDirectory(); } catch { return false; }
3292
+ try { return statSync(dir).isDirectory(); } catch { return false; }
3292
3293
  }
3293
3294
 
3294
3295
  // Restore runs before the first full metadata scan, so until that scan has run a session's
@@ -3300,7 +3301,7 @@ function resolveSessionFolder(id) {
3300
3301
  }
3301
3302
  try {
3302
3303
  for (const dir of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
3303
- if (!dir.isDirectory()) continue;
3304
+ if (!dir.isDirectory() || kanbot.isOwnProjectDirName(dir.name)) continue;
3304
3305
  const jsonlPath = path.join(PROJECTS_DIR, dir.name, `${id}.jsonl`);
3305
3306
  if (!existsSync(jsonlPath)) continue;
3306
3307
  let indexProject = null;
@@ -3343,7 +3344,7 @@ const terminal = createTerminalClient({
3343
3344
  // project dir it started in, and cwd drifts into subdirectories.
3344
3345
  resolveCwd: resolveSessionFolder,
3345
3346
  isAllowedFolder,
3346
- onChange: () => broadcast({ type: 'terminals-update', ids: terminal.ids() }),
3347
+ onChange: () => broadcast({ type: 'terminals-update', ids: terminal.ids(), awaitingTrust: terminal.awaitingTrust() }),
3347
3348
  onExit: (id) => {
3348
3349
  if (dispatched.get(id)) broadcast({ type: 'dispatch-update' });
3349
3350
  },
@@ -3365,7 +3366,6 @@ app.post('/api/terminal/pick-folder', async (req, res) => {
3365
3366
  folderDialogOpen = true;
3366
3367
  try {
3367
3368
  const dir = await pickFolder(whichSync, { start: req.body?.start });
3368
- if (dir) pickedFolders.add(dir);
3369
3369
  res.json({ path: dir });
3370
3370
  } catch (e) {
3371
3371
  res.status(501).json({ error: e.message });
@@ -3377,8 +3377,8 @@ app.post('/api/terminal/pick-folder', async (req, res) => {
3377
3377
  // The New session dialog's "Stay here": the PTY starts headless and the board keeps its view.
3378
3378
  app.post('/api/terminal/start', terminalRoute(async (req, res) => {
3379
3379
  if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3380
- const { id, cwd, name, model, worktree, prompt } = req.body || {};
3381
- const started = await terminal.startNew({ id, cwd, name, model, worktree, prompt });
3380
+ const { id, cwd, name, model, effort, worktree, prompt } = req.body || {};
3381
+ const started = await terminal.startNew({ id, cwd, name, model, effort, worktree, prompt });
3382
3382
  if (started.error) return res.status(started.status).json({ error: started.error });
3383
3383
  res.status(201).json({ session: started.id, cwd: started.cwd });
3384
3384
  }));
@@ -3386,7 +3386,14 @@ app.post('/api/terminal/start', terminalRoute(async (req, res) => {
3386
3386
  // The hub's eviction check reads this to keep a pool with live PTYs alive.
3387
3387
  app.get('/api/terminals', terminalRoute(async (_req, res) => {
3388
3388
  res.setHeader('Cache-Control', 'no-store');
3389
- res.json({ sessions: await terminal.sessions() });
3389
+ const sessions = await terminal.sessions();
3390
+ res.json({
3391
+ sessions: sessions.map((t) => {
3392
+ if (t.id === kanbot.ptyId) return { ...t, kanbot: true };
3393
+ const marker = dispatched.get(t.id);
3394
+ return marker ? { ...t, dispatched: { parent: marker.parent || null } } : t;
3395
+ }),
3396
+ });
3390
3397
  }));
3391
3398
 
3392
3399
  const terminalProcStats = createProcStats();
@@ -3404,6 +3411,7 @@ app.delete('/api/terminals/:id', terminalRoute(async (req, res) => {
3404
3411
  if (err === 'auth') return res.status(401).json({ error: 'invalid terminal token' });
3405
3412
  if (err === 'not-found') return res.status(404).json({ error: 'no such terminal' });
3406
3413
  res.status(204).end();
3414
+ broadcast({ type: 'terminal:ended', id: req.params.id });
3407
3415
  }));
3408
3416
 
3409
3417
  const show = createShowStore({
@@ -3446,26 +3454,12 @@ app.get('/vendor/xterm/:file', (req, res) => {
3446
3454
  // #region DISPATCH
3447
3455
  const dispatched = createDispatchedStore(jsonFile(DISPATCHED_FILE));
3448
3456
 
3449
- const dispatchGroups = createGroupStore({
3450
- ...jsonFile(DISPATCH_GROUPS_FILE),
3451
- isAlive: (id) => terminal.isRunning(id) || isSessionLive(loadLiveSessions(), id),
3452
- pinnedIds: () => new Set(Object.keys(readPins())),
3453
- });
3454
-
3455
3457
  const linkedDocs = createLinkedDocStore(jsonFile(LINKED_DOCS_FILE));
3456
3458
 
3457
- // The board places a session from these alone, so it never has to move it later.
3458
- function withDispatchPlacement(sessions) {
3459
- const groups = dispatchGroups.snapshot();
3459
+ function withDispatchMarker(sessions) {
3460
3460
  return sessions.map((s) => {
3461
- const dispatchGroup = groups.get(s.id);
3462
3461
  const marker = dispatched.get(s.id);
3463
- if (!dispatchGroup && !marker) return s;
3464
- return {
3465
- ...s,
3466
- dispatchGroup,
3467
- dispatched: marker ? { parent: marker.parent } : undefined,
3468
- };
3462
+ return marker ? { ...s, dispatched: { parent: marker.parent } } : s;
3469
3463
  });
3470
3464
  }
3471
3465
 
@@ -3476,16 +3470,31 @@ app.post('/api/dispatch', terminalRoute(async (req, res) => {
3476
3470
  const { cwd, spec, name, model, worktree, taskList, parent, group, claudeArgs } = req.body || {};
3477
3471
  if (typeof spec !== 'string' || !spec.trim()) return res.status(400).json({ error: 'spec is required' });
3478
3472
  if (parent != null && !(typeof parent === 'string' && isUUID(parent))) return res.status(400).json({ error: 'invalid parent' });
3479
- if (group != null && !isGroupName(group)) {
3480
- return res.status(400).json({ error: `group must be kebab-case, e.g. ${suggestGroupName(group) || 'my-group'}` });
3481
- }
3473
+ if (group != null && !(typeof group === 'string' && group.trim())) return res.status(400).json({ error: 'group must be a group name, path or id' });
3482
3474
  const started = await terminal.startNew({ cwd, name, model, worktree, taskList, prompt: spec.trim(), extraArgs: claudeArgs });
3483
3475
  if (started.error) return res.status(started.status).json({ error: started.error });
3484
3476
  dispatched.record(started.id, { parent, cwd: started.cwd, name, group, worktree });
3485
- // The starter stays where it is: moving it would jump it under the user.
3486
- if (group) dispatchGroups.join(group, [started.id]);
3477
+ // Joined before dispatch-update, so the board places the session from its first frame. The
3478
+ // starter stays where it is: moving it would jump it under the user.
3479
+ let joined = null;
3480
+ let groupError;
3481
+ if (group) {
3482
+ try {
3483
+ userGroups.reload();
3484
+ joined = userGroups.joinDispatch(group, started.id);
3485
+ broadcast({ type: 'group:changed', rev: joined.rev });
3486
+ } catch (e) {
3487
+ groupError = e.message;
3488
+ }
3489
+ }
3487
3490
  broadcast({ type: 'dispatch-update' });
3488
- res.status(201).json({ session: started.id, cwd: started.cwd, group: group || null });
3491
+ res.status(201).json({
3492
+ session: started.id,
3493
+ cwd: started.cwd,
3494
+ group: joined?.path ?? null,
3495
+ ...(joined ? { groupId: joined.group, transient: joined.transient } : {}),
3496
+ ...(groupError ? { groupError } : {}),
3497
+ });
3489
3498
  }));
3490
3499
 
3491
3500
  app.get('/api/dispatch', (req, res) => {
@@ -3495,6 +3504,45 @@ app.get('/api/dispatch', (req, res) => {
3495
3504
  });
3496
3505
  // #endregion
3497
3506
 
3507
+ // #region KANBOT
3508
+ function kanbotRoute(fn) {
3509
+ return terminalRoute(async (req, res) => {
3510
+ if (!kanbotEnabled()) return res.status(404).json({ error: 'kanbot is turned off in .cck/config.json' });
3511
+ return fn(req, res);
3512
+ });
3513
+ }
3514
+
3515
+ const kanbotState = () => ({ ptyId: kanbot.ptyId, sessionId: kanbot.sessionId });
3516
+
3517
+ // After /exit the PTY lives on as a plain shell, so it is ended and the chat resumed in a new one.
3518
+ async function ensureKanbot() {
3519
+ if (kanbot.ptyId && terminal.isRunning(kanbot.ptyId)) {
3520
+ const row = (await terminal.sessions()).find((t) => t.id === kanbot.ptyId);
3521
+ if (row && !row.claudeExited) return null;
3522
+ if (row) await terminal.end(kanbot.ptyId, terminal.token);
3523
+ }
3524
+ const started = await terminal.startNew(kanbot.startSpec({
3525
+ model: kanbotModelSetting(),
3526
+ boardUrl: `http://127.0.0.1:${boardPort}`,
3527
+ }));
3528
+ if (started.error) return started;
3529
+ kanbot.ptyId = started.id;
3530
+ kanbot.own(started.id);
3531
+ return null;
3532
+ }
3533
+
3534
+ // Two opens at once would start two claude processes on the same transcript.
3535
+ let kanbotStarting = null;
3536
+
3537
+ app.post('/api/kanbot/start', kanbotRoute(async (req, res) => {
3538
+ if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3539
+ kanbotStarting ||= ensureKanbot().finally(() => { kanbotStarting = null; });
3540
+ const failed = await kanbotStarting;
3541
+ if (failed) return res.status(failed.status).json({ error: failed.error });
3542
+ res.json(kanbotState());
3543
+ }));
3544
+ // #endregion
3545
+
3498
3546
  // #region TASK_ROUTES
3499
3547
  // API: Get all tasks across all sessions
3500
3548
  app.get('/api/tasks/all', async (_req, res) => {
@@ -3548,6 +3596,7 @@ const {
3548
3596
  boardEventsOn,
3549
3597
  enqueueSessionEvent,
3550
3598
  formatReviewSubmitted,
3599
+ formatActionSubmitted,
3551
3600
  formatTaskMoved,
3552
3601
  handleSessionEvents,
3553
3602
  hasDoorbell,
@@ -3577,7 +3626,6 @@ app.post('/api/tasks/:sessionId', async (req, res) => {
3577
3626
  id,
3578
3627
  subject,
3579
3628
  description: (req.body.description || '').trim(),
3580
- activeForm: subject,
3581
3629
  status: 'pending',
3582
3630
  blocks: [],
3583
3631
  blockedBy: [],
@@ -3623,7 +3671,9 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
3623
3671
  // not a session id -- and the doorbell polls with its own session id, so an unresolved
3624
3672
  // name would queue the line where nobody drains it.
3625
3673
  const line = formatTaskMoved(taskId, prevStatus, task);
3626
- for (const sid of resolveSessionsForTaskDir(sessionId)) enqueueSessionEvent(sid, line);
3674
+ const recipients =
3675
+ moveRecipients(sessionId, task.owner, ownerRoutingInputs()) ?? resolveSessionsForTaskDir(sessionId);
3676
+ for (const sid of recipients) enqueueSessionEvent(sid, line);
3627
3677
  }
3628
3678
 
3629
3679
  res.json({ success: true, task });
@@ -3907,6 +3957,9 @@ app.get('/api/session/resolve', (req, res) => {
3907
3957
  const m = metadata[idArg];
3908
3958
  return res.json({ id: idArg, customTitle: m?.customTitle || null });
3909
3959
  }
3960
+ // A session cck just dispatched has no transcript yet, and its starter groups it at once.
3961
+ const started = dispatched.get(idArg);
3962
+ if (started) return res.json({ id: idArg, customTitle: started.name || null });
3910
3963
  const matches = ids.filter(id => id.startsWith(idArg));
3911
3964
  if (matches.length === 0) return res.status(404).json({ matches: [] });
3912
3965
  if (matches.length > 1) {
@@ -3934,36 +3987,136 @@ app.post('/api/session/open', async (req, res) => {
3934
3987
  }
3935
3988
  });
3936
3989
 
3937
- app.post('/api/session/pin', async (req, res) => {
3938
- try {
3939
- const { id, state } = req.body || {};
3940
- if (!id || typeof id !== 'string') return res.status(400).json({ error: 'id is required' });
3941
- if (!['none', 'pinned', 'sticky'].includes(state)) {
3942
- return res.status(400).json({ error: 'state must be none|pinned|sticky' });
3943
- }
3944
- const pins = readPins();
3945
- if (state === 'none') delete pins[id];
3946
- else pins[id] = state;
3947
- writePins(pins);
3948
- broadcast({ type: 'session:pin', id, state });
3949
- res.json({ success: true, id, state });
3950
- } catch (error) {
3951
- console.error('Error in /api/session/pin:', error);
3952
- res.status(500).json({ error: error.message || 'Failed' });
3990
+ // #region SESSION_PINS
3991
+ function broadcastPins(changed) {
3992
+ for (const { id, state } of changed) broadcast({ type: 'session:pin', id, state });
3993
+ }
3994
+
3995
+ // Another board on this config dir wrote the file. The reload in each route covers a write that
3996
+ // lands before the watcher fires.
3997
+ chokidar.watch(PINS_FILE, { ignoreInitial: true }).on('all', (event) => {
3998
+ if (event === 'add' || event === 'change') broadcastPins(sessionPins.reload());
3999
+ });
4000
+
4001
+ function pinsReply() {
4002
+ const { pins } = sessionPins.state();
4003
+ return { pins, items:Object.entries(pins).map(([id, state]) => ({ id, state })) };
4004
+ }
4005
+
4006
+ function pinRoute(fn) {
4007
+ return (req, res) => {
4008
+ res.setHeader('Cache-Control', 'no-store');
4009
+ broadcastPins(sessionPins.reload());
4010
+ res.json(fn(req.body || {}));
4011
+ };
4012
+ }
4013
+
4014
+ app.post('/api/session/pin', pinRoute(({ ids, state }) => {
4015
+ broadcastPins(sessionPins.set(ids, state));
4016
+ return { success: true, ids, state };
4017
+ }));
4018
+
4019
+ app.post('/api/session/pins/import', pinRoute((b) => {
4020
+ broadcastPins(sessionPins.importLocal(b));
4021
+ return pinsReply();
4022
+ }));
4023
+
4024
+ app.get('/api/session/pins', pinRoute(() => pinsReply()));
4025
+ // #endregion
4026
+
4027
+ // #region USER_GROUPS
4028
+ const userGroups = createUserGroupStore(stampedJsonFile(USER_GROUPS_FILE, writeJsonAtomic));
4029
+ // The watcher tells this board's pages about another board's write; the reload in each route
4030
+ // covers a write that lands before the watcher fires.
4031
+ chokidar.watch(USER_GROUPS_FILE, { ignoreInitial: true }).on('all', (event) => {
4032
+ if ((event === 'add' || event === 'change') && userGroups.reload()) {
4033
+ broadcast({ type: 'group:changed', rev: userGroups.state().rev });
3953
4034
  }
3954
4035
  });
3955
4036
 
3956
- app.get('/api/session/pins', (_req, res) => {
4037
+ // A 409 is a stale rev and carries the current state, so the board needs no second read.
4038
+ function groupRoute(fn) {
4039
+ return (req, res, next) => {
4040
+ userGroups.reload();
4041
+ const before = userGroups.state().rev;
4042
+ let out;
4043
+ try {
4044
+ out = fn(req.body || {}, req.params);
4045
+ } catch (e) {
4046
+ if (e.status === 409) return res.status(409).json({ error: e.message, ...userGroups.state() });
4047
+ return next(e);
4048
+ }
4049
+ res.json(out);
4050
+ if (out.rev !== before) broadcast({ type: 'group:changed', rev: out.rev });
4051
+ };
4052
+ }
4053
+
4054
+ app.get('/api/groups', (_req, res) => {
3957
4055
  res.setHeader('Cache-Control', 'no-store');
3958
- try {
3959
- const pins = readPins();
3960
- const items = Object.entries(pins).map(([id, state]) => ({ id, state }));
3961
- res.json({ pins, items });
3962
- } catch (error) {
3963
- console.error('Error in GET /api/session/pins:', error);
3964
- res.status(500).json({ error: error.message || 'Failed' });
3965
- }
4056
+ userGroups.reload();
4057
+ res.json(userGroups.state());
3966
4058
  });
4059
+ app.post('/api/groups', groupRoute((b) => userGroups.create(b)));
4060
+ app.put('/api/groups', groupRoute((b) => userGroups.replace(b)));
4061
+ app.post('/api/groups/import', groupRoute((b) => userGroups.importLocal(b)));
4062
+ app.post('/api/groups/ungroup', groupRoute((b) => userGroups.ungroup(b)));
4063
+ app.patch('/api/groups/:id', groupRoute((b, p) => userGroups.update(p.id, b)));
4064
+ app.delete('/api/groups/:id', groupRoute((_b, p) => userGroups.remove(p.id)));
4065
+ app.put('/api/groups/:id/members', groupRoute((b, p) => userGroups.place(p.id, b)));
4066
+ app.post('/api/groups/path', groupRoute((b) => userGroups.createPath(b.path, b.transient === true)));
4067
+ app.post('/api/groups/rename', groupRoute((b) => userGroups.rename(b.group, b.name)));
4068
+ app.post('/api/groups/remove', groupRoute((b) => userGroups.removeByKey(b.group)));
4069
+ app.post('/api/groups/prune', groupRoute((b) => {
4070
+ const known = loadSessionMetadata();
4071
+ return userGroups.prune({ dryRun: b.dryRun === true, isLive: liveGroupMember((id) => !!known[id]) });
4072
+ }));
4073
+ // `keep` is the ids of `/api/sessions?filter=active&limit=all`: the Active rule lives in that route.
4074
+ // `hide` is the ids dismissed on the board: hidden there even while their terminal runs, unless pinned.
4075
+ app.post('/api/groups/clean', groupRoute((b) => {
4076
+ if (!Array.isArray(b.keep)) throw previewError(400, 'keep must be an array of session ids');
4077
+ const keep = new Set(b.keep);
4078
+ const hide = new Set(Array.isArray(b.hide) ? b.hide : []);
4079
+ const pins = sessionPins.state().pins;
4080
+ const live = liveGroupMember((id) => keep.has(id));
4081
+ const isVisible = (m) => (Object.hasOwn(pins, m.ref) || !hide.has(m.ref)) && live(m);
4082
+ return userGroups.clean({ group: b.group ?? null, dryRun: b.dryRun === true, isVisible });
4083
+ }));
4084
+
4085
+ // A session that runs in the terminal or is still starting has no transcript yet.
4086
+ function liveGroupMember(isKnown) {
4087
+ const live = new Set([...terminal.ids(), ...Object.keys(sessionPins.state().pins)]);
4088
+ return (m) => m.type !== 'session' || isKnown(m.ref) || live.has(m.ref);
4089
+ }
4090
+
4091
+ // Members dead in the previous sweep too, which is the grace period: a member has no timestamp.
4092
+ let groupMembersDead = new Set();
4093
+ function expireGroupMembers(known) {
4094
+ if (!known) return { members: 0, groups: 0 };
4095
+ const isLive = liveGroupMember((id) => known.has(id));
4096
+ const dead = new Set();
4097
+ userGroups.reload();
4098
+ const out = userGroups.expire((m) => {
4099
+ if (isLive(m)) return false;
4100
+ dead.add(m.ref);
4101
+ return groupMembersDead.has(m.ref);
4102
+ });
4103
+ groupMembersDead = dead;
4104
+ if (out.members) broadcast({ type: 'group:changed', rev: out.rev });
4105
+ return out;
4106
+ }
4107
+ // `with` names another session; its project is keyed as the board keys it, by the worktree's repo.
4108
+ function groupPeer(id) {
4109
+ const meta = loadSessionMetadata()[id];
4110
+ if (!meta) throw previewError(404, `no session ${id}`);
4111
+ return {
4112
+ ref: id,
4113
+ project: sessionProjectKey({ project: meta.project, worktree: worktrees.resolve(meta.project) }),
4114
+ name: getSessionDisplayName(id, meta) || `Session ${id.slice(0, 8)}`,
4115
+ };
4116
+ }
4117
+ app.post('/api/sessions/:id/group', groupRoute((b, p) =>
4118
+ userGroups.groupSession(p.id, { ...b, peer: typeof b.with === 'string' ? groupPeer(b.with) : undefined })));
4119
+ // #endregion
3967
4120
 
3968
4121
  app.get('/api/preview', async (req, res) => {
3969
4122
  const abs = resolvePreviewPath(req.query.path, req.query.base);
@@ -4185,7 +4338,6 @@ app.delete('/api/panes/:sessionId/:paneId', (req, res) => {
4185
4338
  // client change only. The batch is always written to a file: the doorbell is lossy and
4186
4339
  // carries one line, and the file is what each route points at.
4187
4340
  const REVIEW_DIR = path.join(CCK_DIR, 'reviews');
4188
- const REVIEW_KIND_RE = /^[a-z]{1,20}$/;
4189
4341
 
4190
4342
  // `<ts>.pending` holds the doorbell line of a review the mod has not acked yet.
4191
4343
  configureSessionEvents({
@@ -4208,10 +4360,7 @@ const REVIEW_MAX_CHARS = 4000;
4208
4360
 
4209
4361
  function parseReviewBody(body) {
4210
4362
  const { source, comments } = body || {};
4211
- if (!source || !REVIEW_KIND_RE.test(source.kind)) throw previewError(400, 'source.kind is required');
4212
- // The label is pasted into the terminal; an ESC could end bracketed paste and submit text.
4213
- const label = oneLine(source.label, 200);
4214
- if (!label) throw previewError(400, 'source.label is required');
4363
+ const src = parseReviewSource(source);
4215
4364
  if (!Array.isArray(comments) || !comments.length || comments.length > REVIEW_MAX_COMMENTS) {
4216
4365
  throw previewError(400, `comments must hold 1 to ${REVIEW_MAX_COMMENTS} items`);
4217
4366
  }
@@ -4227,8 +4376,14 @@ function parseReviewBody(body) {
4227
4376
  selector: code(c?.selector, 500),
4228
4377
  }));
4229
4378
  if (items.some((c) => !c.comment)) throw previewError(400, 'every comment needs text');
4230
- const locate = oneLine(source.locate, 600) || null;
4231
- return { kind: source.kind, label, path: source.kind === 'file' ? source.path : null, locate, items };
4379
+ return { ...src, items };
4380
+ }
4381
+
4382
+ async function resolveReviewSource(src) {
4383
+ if (src.kind !== 'file') return;
4384
+ src.path = resolvePreviewPath(src.path);
4385
+ if (!src.path) throw previewError(400, 'source.path is required for a file');
4386
+ await statFileTarget(src.path);
4232
4387
  }
4233
4388
 
4234
4389
  function formatReviewMarkdown(src, items) {
@@ -4258,32 +4413,13 @@ app.post('/api/sessions/:sessionId/review', async (req, res) => {
4258
4413
  const sessionId = req.params.sessionId;
4259
4414
  if (!isUUID(sessionId)) return res.status(400).json({ error: 'invalid session id' });
4260
4415
  const src = parseReviewBody(req.body);
4261
- if (src.kind === 'file') {
4262
- src.path = resolvePreviewPath(src.path);
4263
- if (!src.path) return res.status(400).json({ error: 'source.path is required for a file' });
4264
- await statFileTarget(src.path);
4265
- }
4266
-
4416
+ await resolveReviewSource(src);
4267
4417
  const markdown = formatReviewMarkdown(src, src.items);
4268
- const dir = path.join(REVIEW_DIR, sessionId);
4269
- await fs.mkdir(dir, { recursive: true });
4270
- const file = path.join(dir, `${Date.now()}.md`);
4271
- await fs.writeFile(file, markdown);
4272
-
4273
- let delivered = null;
4274
- if (boardEventsEnabled() && hasDoorbell(sessionId)) {
4275
- const line = formatReviewSubmitted(src.items.length, src.label, file);
4276
- const marker = file.replace(/\.md$/, '.pending');
4277
- await fs.writeFile(marker, line);
4278
- enqueueSessionEvent(sessionId, line, { marker });
4279
- delivered = 'doorbell';
4280
- } else if (
4281
- terminal.authorized(req.get('x-terminal-token')) &&
4282
- terminal.isRunning(sessionId) &&
4283
- !isWaitingOnUser(sessionId)
4284
- ) {
4285
- if (await terminal.paste(sessionId, `Address my review comments on ${src.label}: ${file}`)) delivered = 'terminal';
4286
- }
4418
+ const file = await writeReviewFile(sessionId, markdown);
4419
+ const delivered = await deliverReviewFile(req, sessionId, file, {
4420
+ line: formatReviewSubmitted(src.items.length, src.label, file),
4421
+ paste: `Address my review comments on ${src.label}: ${file}`,
4422
+ });
4287
4423
  res.json({ delivered, file, markdown });
4288
4424
  } catch (error) {
4289
4425
  if (!error.status) console.error('Error in POST /api/sessions/:id/review:', error);
@@ -4291,6 +4427,53 @@ app.post('/api/sessions/:sessionId/review', async (req, res) => {
4291
4427
  }
4292
4428
  });
4293
4429
 
4430
+ // A button or form in an HTML preview, sent by the board after the user's click. The file goes in
4431
+ // REVIEW_DIR, so a review's restore and retention cover it.
4432
+ app.post('/api/sessions/:sessionId/action', async (req, res) => {
4433
+ try {
4434
+ const sessionId = req.params.sessionId;
4435
+ if (!isUUID(sessionId)) return res.status(400).json({ error: 'invalid session id' });
4436
+ const act = parseActionBody(req.body);
4437
+ await resolveReviewSource(act.src);
4438
+ const markdown = formatActionMarkdown(act);
4439
+ const file = await writeReviewFile(sessionId, markdown);
4440
+ const delivered = await deliverReviewFile(req, sessionId, file, {
4441
+ line: formatActionSubmitted(act.action, act.src.label, file),
4442
+ paste: `Act on my answer "${act.action}" on ${act.src.label}: ${file}`,
4443
+ });
4444
+ res.json({ delivered, file, markdown });
4445
+ } catch (error) {
4446
+ if (!error.status) console.error('Error in POST /api/sessions/:id/action:', error);
4447
+ res.status(error.status || 500).json({ error: error.message || 'Action failed' });
4448
+ }
4449
+ });
4450
+
4451
+ async function writeReviewFile(sessionId, markdown) {
4452
+ const dir = path.join(REVIEW_DIR, sessionId);
4453
+ await fs.mkdir(dir, { recursive: true });
4454
+ const file = path.join(dir, `${Date.now()}.md`);
4455
+ await fs.writeFile(file, markdown);
4456
+ return file;
4457
+ }
4458
+
4459
+ async function deliverReviewFile(req, sessionId, file, { line, paste }) {
4460
+ if (boardEventsEnabled() && hasDoorbell(sessionId)) {
4461
+ const marker = file.replace(/\.md$/, '.pending');
4462
+ await fs.writeFile(marker, line);
4463
+ enqueueSessionEvent(sessionId, line, { marker });
4464
+ return 'doorbell';
4465
+ }
4466
+ if (
4467
+ terminal.authorized(req.get('x-terminal-token')) &&
4468
+ terminal.isRunning(sessionId) &&
4469
+ !isWaitingOnUser(sessionId) &&
4470
+ (await terminal.paste(sessionId, paste))
4471
+ ) {
4472
+ return 'terminal';
4473
+ }
4474
+ return null;
4475
+ }
4476
+
4294
4477
  // #endregion
4295
4478
 
4296
4479
  // #region SSE
@@ -4339,7 +4522,7 @@ app.use((err, req, res, next) => {
4339
4522
  if (res.headersSent || !req.path.startsWith('/api/')) return next(err);
4340
4523
  const status = err.status || 500;
4341
4524
  if (status >= 500) console.error(`Error in ${req.method} ${req.path}:`, err);
4342
- res.status(status).json({ error: status >= 500 ? 'Internal error' : err.message });
4525
+ res.status(status).json({ error: status >= 500 && !err.expose ? 'Internal error' : err.message });
4343
4526
  });
4344
4527
 
4345
4528
  // Watch for file changes (chokidar handles non-existent paths)
@@ -4428,6 +4611,7 @@ const projectsWatcher = chokidar.watch(PROJECTS_DIR, {
4428
4611
 
4429
4612
  projectsWatcher.on('all', (event, filePath) => {
4430
4613
  if (event !== 'add' && event !== 'change' && event !== 'unlink') return;
4614
+ if (kanbot.isOwnPath(filePath)) return kanbot.onTranscript(filePath, event);
4431
4615
  if (filePath.endsWith('.jsonl')) {
4432
4616
  if (event === 'unlink') {
4433
4617
  loopInfoStateByPath.delete(filePath);
@@ -4594,8 +4778,9 @@ async function runRetention() {
4594
4778
  const contexts = await pruneContextStatus(CONTEXT_STATUS_DIR, opts);
4595
4779
  const wts = worktrees.prune(scan?.dirs);
4596
4780
  const taskMaps = await pruneTaskMaps(TASK_MAPS_DIR, opts);
4597
- if (markers || layouts || reviews || contexts || wts || taskMaps) {
4598
- console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${reviews} reviews, ${contexts} context status files, ${wts} worktrees, ${taskMaps} task-list mappings`);
4781
+ const grouped = expireGroupMembers(scan?.ids);
4782
+ if (markers || layouts || reviews || contexts || wts || taskMaps || grouped.members) {
4783
+ console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${reviews} reviews, ${contexts} context status files, ${wts} worktrees, ${taskMaps} task-list mappings, ${grouped.members} group members, ${grouped.groups} groups`);
4599
4784
  }
4600
4785
  } catch (e) {
4601
4786
  console.warn('[retention] failed:', e.message);