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.
- package/README.md +2 -1
- package/cli.js +412 -62
- package/hub-app.json +1 -1
- package/kanbot/.claude-plugin/plugin.json +5 -0
- package/kanbot/agents/kanbot.md +34 -0
- package/lib/http-error.js +2 -1
- package/lib/kanbot.js +86 -0
- package/lib/owner-routing.js +53 -0
- package/lib/preview-action.js +55 -0
- package/lib/session-events.js +5 -0
- package/lib/session-pins.js +99 -0
- package/lib/stamped-json-file.js +49 -0
- package/lib/terminal.js +27 -13
- package/lib/user-groups.js +364 -0
- package/package.json +2 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +4 -2
- package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +5 -1
- package/public/app.js +1211 -294
- package/public/index.html +19 -15
- package/public/style.css +727 -125
- package/public/terminal-frame.js +12 -0
- package/server.js +273 -117
package/public/terminal-frame.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
1593
|
-
//
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
s.
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
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
|
|
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
|
|
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 &&
|
|
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
|
-
//
|
|
3286
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4231
|
-
|
|
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
|
-
|
|
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
|
|
4269
|
-
await
|
|
4270
|
-
|
|
4271
|
-
|
|
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);
|