claude-code-kanban 7.6.0 → 7.7.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.
@@ -176,6 +176,18 @@
176
176
  if (e.target !== term.textarea) forwardClaimed(e);
177
177
  });
178
178
  term.textarea.addEventListener('focus', () => up('focused'));
179
+ // xterm reports focus only when its textarea gains or loses it, not when an app turns focus reporting
180
+ // (DECSET 1004) on. After a reload xterm is focused before the replayed output sets the mode, so the
181
+ // app never gets a focus-in and draws its cursor as unfocused.
182
+ term.parser.registerCsiHandler({ prefix: '?', final: 'h' }, (params) => {
183
+ if (!params.includes(1004) || term.modes.sendFocusMode) return false;
184
+ win.queueMicrotask(() => {
185
+ if (term.modes.sendFocusMode && doc.hasFocus() && doc.activeElement === term.textarea) {
186
+ send({ t: 'in', d: '\x1b[I' });
187
+ }
188
+ });
189
+ return false;
190
+ });
179
191
  // xterm leaves OSC 10/11 color queries unanswered, so apps that pick a palette from the
180
192
  // background (Claude Code's Auto theme) assume dark even on a light theme.
181
193
  for (const [code, key] of [
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,16 @@ 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
53
  const { readLiveSessions, isPidAlive, isSessionLive } = require('./lib/live-sessions');
54
54
  const { createProcStats } = require('./lib/proc-stats');
55
55
  const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
56
+ const { createUserGroupStore } = require('./lib/user-groups');
57
+ const { createSessionPinStore } = require('./lib/session-pins');
58
+ const { stampedJsonFile } = require('./lib/stamped-json-file');
59
+ const { ownerLinks, moveRecipients } = require('./lib/owner-routing');
60
+ const { parseReviewSource, parseActionBody, formatActionMarkdown } = require('./lib/preview-action');
56
61
  const { createDispatchedStore, scanTranscripts, pruneSessionDirs, pruneContextStatus, pruneTaskMaps, retentionMs } = require('./lib/retention');
57
62
  const { freshRateLimits } = require('./lib/rate-limits');
58
63
  const { createWorktreeStore } = require('./lib/worktrees');
@@ -66,7 +71,7 @@ const { pickFolder } = require('./lib/folder-dialog');
66
71
  const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
67
72
  const { countTaskDir } = require('./lib/task-counts');
68
73
  const { readTaskDir } = require('./lib/task-dir');
69
- const { projectMatcher, normalizeProjectPath } = require('./public/project-match');
74
+ const { projectMatcher, normalizeProjectPath, sessionProjectKey } = require('./public/project-match');
70
75
  const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
71
76
 
72
77
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
@@ -111,6 +116,7 @@ const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
111
116
  const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
112
117
  const PINS_FILE = path.join(CCK_DIR, 'pins.json');
113
118
  const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
119
+ const USER_GROUPS_FILE = path.join(CCK_DIR, 'groups.json');
114
120
  const DISPATCHED_FILE = path.join(CCK_DIR, 'dispatched.json');
115
121
  const WORKTREES_FILE = path.join(CCK_DIR, 'worktrees.json');
116
122
  const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
@@ -141,15 +147,6 @@ const SCRATCHPAD_ROOT = (() => {
141
147
  // #endregion
142
148
 
143
149
  // #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
150
  function writeJsonAtomic(file, obj, mode) {
154
151
  mkdirSync(path.dirname(file), { recursive: true });
155
152
  const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
@@ -202,9 +199,7 @@ const jsonFile = (file) => ({
202
199
  save: (data) => writeJsonAtomicOrLog(file, data),
203
200
  });
204
201
 
205
- function writePins(pins) {
206
- writeJsonAtomic(PINS_FILE, pins);
207
- }
202
+ const sessionPins = createSessionPinStore(stampedJsonFile(PINS_FILE, writeJsonAtomic));
208
203
 
209
204
  // Port discovery for out-of-process helpers (the plugin's doorbell mod, the CLI).
210
205
  // The pid rides along so a reader can tell a live server from a file left behind by
@@ -320,6 +315,16 @@ function boardEventsEnabled() {
320
315
  return cachedByMtime(cckConfigCache, 'boardEvents', CCK_CONFIG_FILE,
321
316
  () => boardEventsOn(readCckConfig()), boardEventsOn(null));
322
317
  }
318
+ function kanbotEnabled() {
319
+ return cachedByMtime(cckConfigCache, 'kanbot', CCK_CONFIG_FILE,
320
+ () => kanbotOn(readCckConfig()), kanbotOn(null));
321
+ }
322
+ function kanbotModelSetting() {
323
+ return cachedByMtime(cckConfigCache, 'kanbotModel', CCK_CONFIG_FILE,
324
+ () => kanbotModel(readCckConfig()), kanbotModel(null));
325
+ }
326
+ // Kanbot's transcripts all land in one project folder, which the board never lists (docs/kanbot.md).
327
+ const kanbot = createKanbot({ cckDir: CCK_DIR, projectsDir: PROJECTS_DIR });
323
328
 
324
329
  // approvals.json predates config.json (and was opt-in). Fold it into
325
330
  // config.json once so an existing opt-in keeps its tuning, then drop it.
@@ -934,7 +939,7 @@ function loadSessionMetadata() {
934
939
  }
935
940
 
936
941
  const projectDirs = readdirSync(PROJECTS_DIR, { withFileTypes: true })
937
- .filter(d => d.isDirectory());
942
+ .filter(d => d.isDirectory() && !kanbot.isOwnProjectDirName(d.name));
938
943
 
939
944
  for (const projectDir of projectDirs) {
940
945
  const projectPath = path.join(PROJECTS_DIR, projectDir.name);
@@ -1263,12 +1268,13 @@ app.get('/api/sessions', async (req, res) => {
1263
1268
  const limitParam = req.query.limit || '20';
1264
1269
  const limit = limitParam === 'all' ? null : parseInt(limitParam, 10);
1265
1270
 
1266
- const pinnedParam = req.query.pinned;
1271
+ // `pinned=` from a board page loaded before the server kept pins is ignored.
1267
1272
  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();
1273
+ const pins = req.query.pins === 'off' ? {} : sessionPins.state().pins;
1274
+ const pinnedIds = new Set(Object.keys(pins));
1269
1275
  for (const id of includeIds) pinnedIds.add(id);
1270
1276
  const activeFilter = req.query.filter === 'active';
1271
- const terminalIds = activeFilter ? new Set(terminal.ids()) : new Set();
1277
+ const terminalIds = new Set(terminal.ids());
1272
1278
 
1273
1279
  const metadata = loadSessionMetadata();
1274
1280
  const sessionsMap = new Map();
@@ -1524,6 +1530,9 @@ app.get('/api/sessions', async (req, res) => {
1524
1530
  } catch (_) {}
1525
1531
  }
1526
1532
 
1533
+ // Task dirs and agent-activity name Kanbot's sessions too.
1534
+ for (const sid of sessionsMap.keys()) if (kanbot.isOwnSession(sid)) sessionsMap.delete(sid);
1535
+
1527
1536
  // Correlate plan sessions with their implementation sessions (same slug)
1528
1537
  const slugGroups = new Map();
1529
1538
  for (const [_sid, session] of sessionsMap) {
@@ -1589,20 +1598,16 @@ app.get('/api/sessions', async (req, res) => {
1589
1598
  }));
1590
1599
  }
1591
1600
 
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
- }
1601
+ // The sidebar's Active rule (getFilteredSessions in public/app.js); the CLI reads `active`
1602
+ // instead of keeping its own copy. Pinned IDs bypass the filter.
1603
+ for (const [id, s] of sessionsMap) {
1604
+ s.active = terminalIds.has(id) || !!(s.hasMessages && (
1605
+ (!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0))
1606
+ || s.hasActiveAgents
1607
+ || s.hasWaitingForUser
1608
+ || s.hasRecentActivity
1609
+ ));
1610
+ if (activeFilter && !s.active && !pinnedIds.has(id)) sessionsMap.delete(id);
1606
1611
  }
1607
1612
 
1608
1613
  // Convert map to array and sort by most recently modified
@@ -1611,7 +1616,7 @@ app.get('/api/sessions', async (req, res) => {
1611
1616
 
1612
1617
  // Apply project filter before limit so the limit is per-project
1613
1618
  const projectFilter = req.query.project;
1614
- // `include` is narrower than `pinned`: pinned rows survive the limit but still obey
1619
+ // `include` is narrower than a pin: pinned rows survive the limit but still obey
1615
1620
  // the project filter, because pinning is a preference and the filter is an intent.
1616
1621
  // The client sends the session it currently has open, which it cannot render at all
1617
1622
  // if the row is missing — that one is not a preference.
@@ -1631,6 +1636,7 @@ app.get('/api/sessions', async (req, res) => {
1631
1636
  const paged = limit !== null && limit > 0;
1632
1637
  // The sidebar loads the next page on scroll while this is true. A header, so the body stays a plain array.
1633
1638
  res.setHeader('X-Has-More', String(paged && sessions.length > limit));
1639
+ res.setHeader('X-Total-Count', String(sessions.length));
1634
1640
  // Apply limit if specified, but always include pinned sessions
1635
1641
  if (paged) {
1636
1642
  const top = sessions.slice(0, limit);
@@ -1639,10 +1645,14 @@ app.get('/api/sessions', async (req, res) => {
1639
1645
  sessions = [...top, ...missingPinned];
1640
1646
  }
1641
1647
 
1642
- // Loop info can mean a full read of the transcript, so only the rows sent pay for it.
1648
+ // Loop info can mean a full read of the transcript, so only the rows sent pay for it, and
1649
+ // `lite=1` (the CLI's group list, which only counts and names sessions) skips it.
1643
1650
  // Same for autoCompact: up to 3 settings stats per call.
1651
+ const lite = req.query.lite === '1';
1644
1652
  const autoCompactByProject = new Map();
1645
1653
  for (const s of sessions) {
1654
+ s.pin = pins[s.id] || null;
1655
+ if (lite) continue;
1646
1656
  s.loopInfo = getLoopInfoSummary(s);
1647
1657
  if (!s.contextStatus) continue;
1648
1658
  if (!autoCompactByProject.has(s.project)) autoCompactByProject.set(s.project, getAutoCompact(CLAUDE_DIR, s.project));
@@ -1687,7 +1697,7 @@ app.get('/api/sessions/known', (_req, res) => {
1687
1697
  known.set(id, { id, project: meta.project || null, name: getSessionDisplayName(id, meta) });
1688
1698
  }
1689
1699
  const add = (id, project = null) => {
1690
- if (!known.has(id)) known.set(id, { id, project, name: null });
1700
+ if (!known.has(id) && !kanbot.isOwnSession(id)) known.set(id, { id, project, name: null });
1691
1701
  };
1692
1702
  for (const dir of [TASKS_DIR, AGENT_ACTIVITY_DIR]) {
1693
1703
  if (!existsSync(dir)) continue;
@@ -1749,24 +1759,19 @@ app.get('/api/projects', (_req, res) => {
1749
1759
  res.json(projects);
1750
1760
  });
1751
1761
 
1762
+ const ownerRoutingInputs = () => ({
1763
+ dispatched,
1764
+ listToSessions: loadAllTaskMaps().listToSessions,
1765
+ metadata: loadSessionMetadata,
1766
+ });
1767
+
1752
1768
  // 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
1769
  function addOwnerSessions(tasks, listIdOf) {
1756
1770
  const lists = new Set(tasks.filter((t) => t.owner).map(listIdOf));
1757
1771
  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
- }
1772
+ const linkOf = ownerLinks(lists, ownerRoutingInputs());
1768
1773
  for (const t of tasks) {
1769
- const id = t.owner && children.get(`${listIdOf(t)}\n${t.owner}`);
1774
+ const id = t.owner && linkOf(listIdOf(t), t.owner);
1770
1775
  if (id) t.ownerSessionId = id;
1771
1776
  }
1772
1777
  }
@@ -3276,19 +3281,17 @@ app.get('/api/config', (_req, res) => {
3276
3281
  memoryUrl: MEMORY_URL,
3277
3282
  scratchAvailable: !!whichSync('scratch'),
3278
3283
  terminal: terminal.clientConfig(),
3284
+ kanbot: kanbotEnabled(),
3279
3285
  });
3280
3286
  });
3281
3287
 
3282
3288
  // #endregion
3283
3289
 
3284
3290
  // #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();
3291
+ // Any existing folder is allowed: the terminal token already opens a shell PTY, so a folder
3292
+ // list would block valid use without protecting anything.
3289
3293
  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; }
3294
+ try { return statSync(dir).isDirectory(); } catch { return false; }
3292
3295
  }
3293
3296
 
3294
3297
  // Restore runs before the first full metadata scan, so until that scan has run a session's
@@ -3300,7 +3303,7 @@ function resolveSessionFolder(id) {
3300
3303
  }
3301
3304
  try {
3302
3305
  for (const dir of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
3303
- if (!dir.isDirectory()) continue;
3306
+ if (!dir.isDirectory() || kanbot.isOwnProjectDirName(dir.name)) continue;
3304
3307
  const jsonlPath = path.join(PROJECTS_DIR, dir.name, `${id}.jsonl`);
3305
3308
  if (!existsSync(jsonlPath)) continue;
3306
3309
  let indexProject = null;
@@ -3365,7 +3368,6 @@ app.post('/api/terminal/pick-folder', async (req, res) => {
3365
3368
  folderDialogOpen = true;
3366
3369
  try {
3367
3370
  const dir = await pickFolder(whichSync, { start: req.body?.start });
3368
- if (dir) pickedFolders.add(dir);
3369
3371
  res.json({ path: dir });
3370
3372
  } catch (e) {
3371
3373
  res.status(501).json({ error: e.message });
@@ -3386,7 +3388,14 @@ app.post('/api/terminal/start', terminalRoute(async (req, res) => {
3386
3388
  // The hub's eviction check reads this to keep a pool with live PTYs alive.
3387
3389
  app.get('/api/terminals', terminalRoute(async (_req, res) => {
3388
3390
  res.setHeader('Cache-Control', 'no-store');
3389
- res.json({ sessions: await terminal.sessions() });
3391
+ const sessions = await terminal.sessions();
3392
+ res.json({
3393
+ sessions: sessions.map((t) => {
3394
+ if (t.id === kanbot.ptyId) return { ...t, kanbot: true };
3395
+ const marker = dispatched.get(t.id);
3396
+ return marker ? { ...t, dispatched: { parent: marker.parent || null } } : t;
3397
+ }),
3398
+ });
3390
3399
  }));
3391
3400
 
3392
3401
  const terminalProcStats = createProcStats();
@@ -3404,6 +3413,7 @@ app.delete('/api/terminals/:id', terminalRoute(async (req, res) => {
3404
3413
  if (err === 'auth') return res.status(401).json({ error: 'invalid terminal token' });
3405
3414
  if (err === 'not-found') return res.status(404).json({ error: 'no such terminal' });
3406
3415
  res.status(204).end();
3416
+ broadcast({ type: 'terminal:ended', id: req.params.id });
3407
3417
  }));
3408
3418
 
3409
3419
  const show = createShowStore({
@@ -3449,7 +3459,7 @@ const dispatched = createDispatchedStore(jsonFile(DISPATCHED_FILE));
3449
3459
  const dispatchGroups = createGroupStore({
3450
3460
  ...jsonFile(DISPATCH_GROUPS_FILE),
3451
3461
  isAlive: (id) => terminal.isRunning(id) || isSessionLive(loadLiveSessions(), id),
3452
- pinnedIds: () => new Set(Object.keys(readPins())),
3462
+ pinnedIds: () => new Set(Object.keys(sessionPins.state().pins)),
3453
3463
  });
3454
3464
 
3455
3465
  const linkedDocs = createLinkedDocStore(jsonFile(LINKED_DOCS_FILE));
@@ -3495,6 +3505,45 @@ app.get('/api/dispatch', (req, res) => {
3495
3505
  });
3496
3506
  // #endregion
3497
3507
 
3508
+ // #region KANBOT
3509
+ function kanbotRoute(fn) {
3510
+ return terminalRoute(async (req, res) => {
3511
+ if (!kanbotEnabled()) return res.status(404).json({ error: 'kanbot is turned off in .cck/config.json' });
3512
+ return fn(req, res);
3513
+ });
3514
+ }
3515
+
3516
+ const kanbotState = () => ({ ptyId: kanbot.ptyId, sessionId: kanbot.sessionId });
3517
+
3518
+ // After /exit the PTY lives on as a plain shell, so it is ended and the chat resumed in a new one.
3519
+ async function ensureKanbot() {
3520
+ if (kanbot.ptyId && terminal.isRunning(kanbot.ptyId)) {
3521
+ const row = (await terminal.sessions()).find((t) => t.id === kanbot.ptyId);
3522
+ if (row && !row.claudeExited) return null;
3523
+ if (row) await terminal.end(kanbot.ptyId, terminal.token);
3524
+ }
3525
+ const started = await terminal.startNew(kanbot.startSpec({
3526
+ model: kanbotModelSetting(),
3527
+ boardUrl: `http://127.0.0.1:${boardPort}`,
3528
+ }));
3529
+ if (started.error) return started;
3530
+ kanbot.ptyId = started.id;
3531
+ kanbot.own(started.id);
3532
+ return null;
3533
+ }
3534
+
3535
+ // Two opens at once would start two claude processes on the same transcript.
3536
+ let kanbotStarting = null;
3537
+
3538
+ app.post('/api/kanbot/start', kanbotRoute(async (req, res) => {
3539
+ if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3540
+ kanbotStarting ||= ensureKanbot().finally(() => { kanbotStarting = null; });
3541
+ const failed = await kanbotStarting;
3542
+ if (failed) return res.status(failed.status).json({ error: failed.error });
3543
+ res.json(kanbotState());
3544
+ }));
3545
+ // #endregion
3546
+
3498
3547
  // #region TASK_ROUTES
3499
3548
  // API: Get all tasks across all sessions
3500
3549
  app.get('/api/tasks/all', async (_req, res) => {
@@ -3548,6 +3597,7 @@ const {
3548
3597
  boardEventsOn,
3549
3598
  enqueueSessionEvent,
3550
3599
  formatReviewSubmitted,
3600
+ formatActionSubmitted,
3551
3601
  formatTaskMoved,
3552
3602
  handleSessionEvents,
3553
3603
  hasDoorbell,
@@ -3623,7 +3673,9 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
3623
3673
  // not a session id -- and the doorbell polls with its own session id, so an unresolved
3624
3674
  // name would queue the line where nobody drains it.
3625
3675
  const line = formatTaskMoved(taskId, prevStatus, task);
3626
- for (const sid of resolveSessionsForTaskDir(sessionId)) enqueueSessionEvent(sid, line);
3676
+ const recipients =
3677
+ moveRecipients(sessionId, task.owner, ownerRoutingInputs()) ?? resolveSessionsForTaskDir(sessionId);
3678
+ for (const sid of recipients) enqueueSessionEvent(sid, line);
3627
3679
  }
3628
3680
 
3629
3681
  res.json({ success: true, task });
@@ -3934,36 +3986,109 @@ app.post('/api/session/open', async (req, res) => {
3934
3986
  }
3935
3987
  });
3936
3988
 
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' });
3989
+ // #region SESSION_PINS
3990
+ function broadcastPins(changed) {
3991
+ for (const { id, state } of changed) broadcast({ type: 'session:pin', id, state });
3992
+ }
3993
+
3994
+ // Another board on this config dir wrote the file. The reload in each route covers a write that
3995
+ // lands before the watcher fires.
3996
+ chokidar.watch(PINS_FILE, { ignoreInitial: true }).on('all', (event) => {
3997
+ if (event === 'add' || event === 'change') broadcastPins(sessionPins.reload());
3998
+ });
3999
+
4000
+ function pinsReply() {
4001
+ const { pins } = sessionPins.state();
4002
+ return { pins, items:Object.entries(pins).map(([id, state]) => ({ id, state })) };
4003
+ }
4004
+
4005
+ function pinRoute(fn) {
4006
+ return (req, res) => {
4007
+ res.setHeader('Cache-Control', 'no-store');
4008
+ broadcastPins(sessionPins.reload());
4009
+ res.json(fn(req.body || {}));
4010
+ };
4011
+ }
4012
+
4013
+ app.post('/api/session/pin', pinRoute(({ ids, state }) => {
4014
+ broadcastPins(sessionPins.set(ids, state));
4015
+ return { success: true, ids, state };
4016
+ }));
4017
+
4018
+ app.post('/api/session/pins/import', pinRoute((b) => {
4019
+ broadcastPins(sessionPins.importLocal(b));
4020
+ return pinsReply();
4021
+ }));
4022
+
4023
+ app.get('/api/session/pins', pinRoute(() => pinsReply()));
4024
+ // #endregion
4025
+
4026
+ // #region USER_GROUPS
4027
+ const userGroups = createUserGroupStore(stampedJsonFile(USER_GROUPS_FILE, writeJsonAtomic));
4028
+ // The watcher tells this board's pages about another board's write; the reload in each route
4029
+ // covers a write that lands before the watcher fires.
4030
+ chokidar.watch(USER_GROUPS_FILE, { ignoreInitial: true }).on('all', (event) => {
4031
+ if ((event === 'add' || event === 'change') && userGroups.reload()) {
4032
+ broadcast({ type: 'group:changed', rev: userGroups.state().rev });
3953
4033
  }
3954
4034
  });
3955
4035
 
3956
- app.get('/api/session/pins', (_req, res) => {
4036
+ // A 409 is a stale rev and carries the current state, so the board needs no second read.
4037
+ function groupRoute(fn) {
4038
+ return (req, res, next) => {
4039
+ userGroups.reload();
4040
+ const before = userGroups.state().rev;
4041
+ let out;
4042
+ try {
4043
+ out = fn(req.body || {}, req.params);
4044
+ } catch (e) {
4045
+ if (e.status === 409) return res.status(409).json({ error: e.message, ...userGroups.state() });
4046
+ return next(e);
4047
+ }
4048
+ res.json(out);
4049
+ if (out.rev !== before) broadcast({ type: 'group:changed', rev: out.rev });
4050
+ };
4051
+ }
4052
+
4053
+ app.get('/api/groups', (req, res) => {
3957
4054
  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
- }
4055
+ userGroups.reload();
4056
+ const state = userGroups.state();
4057
+ res.json(req.query.dispatch === '1' ? { ...state, dispatch: Object.fromEntries(dispatchGroups.snapshot()) } : 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.post('/api/groups/release', groupRoute((b) => userGroups.release(b.ids)));
4064
+ app.patch('/api/groups/:id', groupRoute((b, p) => userGroups.update(p.id, b)));
4065
+ app.delete('/api/groups/:id', groupRoute((_b, p) => userGroups.remove(p.id)));
4066
+ app.put('/api/groups/:id/members', groupRoute((b, p) => userGroups.place(p.id, b)));
4067
+ app.post('/api/groups/path', groupRoute((b) => userGroups.createPath(b.path)));
4068
+ app.post('/api/groups/rename', groupRoute((b) => userGroups.rename(b.group, b.name)));
4069
+ app.post('/api/groups/remove', groupRoute((b) => userGroups.removeByKey(b.group)));
4070
+ app.post('/api/groups/prune', groupRoute((b) => userGroups.prune({ dryRun: b.dryRun === true, isLive: liveGroupMember() })));
4071
+
4072
+ // A session that runs in the terminal or is still starting has no transcript yet.
4073
+ function liveGroupMember() {
4074
+ const known = loadSessionMetadata();
4075
+ const running = new Set([...terminal.ids(), ...dispatchGroups.snapshot().keys()]);
4076
+ return (m) => m.type !== 'session' || !!known[m.ref] || running.has(m.ref);
4077
+ }
4078
+ // `with` names another session; its project is keyed as the board keys it, by the worktree's repo.
4079
+ function groupPeer(id) {
4080
+ const meta = loadSessionMetadata()[id];
4081
+ if (!meta) throw previewError(404, `no session ${id}`);
4082
+ return {
4083
+ ref: id,
4084
+ project: sessionProjectKey({ project: meta.project, worktree: worktrees.resolve(meta.project) }),
4085
+ dispatchGroup: dispatchGroups.snapshot().get(id) || null,
4086
+ name: getSessionDisplayName(id, meta) || `Session ${id.slice(0, 8)}`,
4087
+ };
4088
+ }
4089
+ app.post('/api/sessions/:id/group', groupRoute((b, p) =>
4090
+ userGroups.groupSession(p.id, { ...b, peer: typeof b.with === 'string' ? groupPeer(b.with) : undefined })));
4091
+ // #endregion
3967
4092
 
3968
4093
  app.get('/api/preview', async (req, res) => {
3969
4094
  const abs = resolvePreviewPath(req.query.path, req.query.base);
@@ -4185,7 +4310,6 @@ app.delete('/api/panes/:sessionId/:paneId', (req, res) => {
4185
4310
  // client change only. The batch is always written to a file: the doorbell is lossy and
4186
4311
  // carries one line, and the file is what each route points at.
4187
4312
  const REVIEW_DIR = path.join(CCK_DIR, 'reviews');
4188
- const REVIEW_KIND_RE = /^[a-z]{1,20}$/;
4189
4313
 
4190
4314
  // `<ts>.pending` holds the doorbell line of a review the mod has not acked yet.
4191
4315
  configureSessionEvents({
@@ -4208,10 +4332,7 @@ const REVIEW_MAX_CHARS = 4000;
4208
4332
 
4209
4333
  function parseReviewBody(body) {
4210
4334
  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');
4335
+ const src = parseReviewSource(source);
4215
4336
  if (!Array.isArray(comments) || !comments.length || comments.length > REVIEW_MAX_COMMENTS) {
4216
4337
  throw previewError(400, `comments must hold 1 to ${REVIEW_MAX_COMMENTS} items`);
4217
4338
  }
@@ -4227,8 +4348,14 @@ function parseReviewBody(body) {
4227
4348
  selector: code(c?.selector, 500),
4228
4349
  }));
4229
4350
  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 };
4351
+ return { ...src, items };
4352
+ }
4353
+
4354
+ async function resolveReviewSource(src) {
4355
+ if (src.kind !== 'file') return;
4356
+ src.path = resolvePreviewPath(src.path);
4357
+ if (!src.path) throw previewError(400, 'source.path is required for a file');
4358
+ await statFileTarget(src.path);
4232
4359
  }
4233
4360
 
4234
4361
  function formatReviewMarkdown(src, items) {
@@ -4258,32 +4385,13 @@ app.post('/api/sessions/:sessionId/review', async (req, res) => {
4258
4385
  const sessionId = req.params.sessionId;
4259
4386
  if (!isUUID(sessionId)) return res.status(400).json({ error: 'invalid session id' });
4260
4387
  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
-
4388
+ await resolveReviewSource(src);
4267
4389
  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
- }
4390
+ const file = await writeReviewFile(sessionId, markdown);
4391
+ const delivered = await deliverReviewFile(req, sessionId, file, {
4392
+ line: formatReviewSubmitted(src.items.length, src.label, file),
4393
+ paste: `Address my review comments on ${src.label}: ${file}`,
4394
+ });
4287
4395
  res.json({ delivered, file, markdown });
4288
4396
  } catch (error) {
4289
4397
  if (!error.status) console.error('Error in POST /api/sessions/:id/review:', error);
@@ -4291,6 +4399,53 @@ app.post('/api/sessions/:sessionId/review', async (req, res) => {
4291
4399
  }
4292
4400
  });
4293
4401
 
4402
+ // A button or form in an HTML preview, sent by the board after the user's click. The file goes in
4403
+ // REVIEW_DIR, so a review's restore and retention cover it.
4404
+ app.post('/api/sessions/:sessionId/action', async (req, res) => {
4405
+ try {
4406
+ const sessionId = req.params.sessionId;
4407
+ if (!isUUID(sessionId)) return res.status(400).json({ error: 'invalid session id' });
4408
+ const act = parseActionBody(req.body);
4409
+ await resolveReviewSource(act.src);
4410
+ const markdown = formatActionMarkdown(act);
4411
+ const file = await writeReviewFile(sessionId, markdown);
4412
+ const delivered = await deliverReviewFile(req, sessionId, file, {
4413
+ line: formatActionSubmitted(act.action, act.src.label, file),
4414
+ paste: `Act on my answer "${act.action}" on ${act.src.label}: ${file}`,
4415
+ });
4416
+ res.json({ delivered, file, markdown });
4417
+ } catch (error) {
4418
+ if (!error.status) console.error('Error in POST /api/sessions/:id/action:', error);
4419
+ res.status(error.status || 500).json({ error: error.message || 'Action failed' });
4420
+ }
4421
+ });
4422
+
4423
+ async function writeReviewFile(sessionId, markdown) {
4424
+ const dir = path.join(REVIEW_DIR, sessionId);
4425
+ await fs.mkdir(dir, { recursive: true });
4426
+ const file = path.join(dir, `${Date.now()}.md`);
4427
+ await fs.writeFile(file, markdown);
4428
+ return file;
4429
+ }
4430
+
4431
+ async function deliverReviewFile(req, sessionId, file, { line, paste }) {
4432
+ if (boardEventsEnabled() && hasDoorbell(sessionId)) {
4433
+ const marker = file.replace(/\.md$/, '.pending');
4434
+ await fs.writeFile(marker, line);
4435
+ enqueueSessionEvent(sessionId, line, { marker });
4436
+ return 'doorbell';
4437
+ }
4438
+ if (
4439
+ terminal.authorized(req.get('x-terminal-token')) &&
4440
+ terminal.isRunning(sessionId) &&
4441
+ !isWaitingOnUser(sessionId) &&
4442
+ (await terminal.paste(sessionId, paste))
4443
+ ) {
4444
+ return 'terminal';
4445
+ }
4446
+ return null;
4447
+ }
4448
+
4294
4449
  // #endregion
4295
4450
 
4296
4451
  // #region SSE
@@ -4339,7 +4494,7 @@ app.use((err, req, res, next) => {
4339
4494
  if (res.headersSent || !req.path.startsWith('/api/')) return next(err);
4340
4495
  const status = err.status || 500;
4341
4496
  if (status >= 500) console.error(`Error in ${req.method} ${req.path}:`, err);
4342
- res.status(status).json({ error: status >= 500 ? 'Internal error' : err.message });
4497
+ res.status(status).json({ error: status >= 500 && !err.expose ? 'Internal error' : err.message });
4343
4498
  });
4344
4499
 
4345
4500
  // Watch for file changes (chokidar handles non-existent paths)
@@ -4428,6 +4583,7 @@ const projectsWatcher = chokidar.watch(PROJECTS_DIR, {
4428
4583
 
4429
4584
  projectsWatcher.on('all', (event, filePath) => {
4430
4585
  if (event !== 'add' && event !== 'change' && event !== 'unlink') return;
4586
+ if (kanbot.isOwnPath(filePath)) return kanbot.onTranscript(filePath, event);
4431
4587
  if (filePath.endsWith('.jsonl')) {
4432
4588
  if (event === 'unlink') {
4433
4589
  loopInfoStateByPath.delete(filePath);