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/README.md +2 -1
- package/cli.js +391 -70
- package/hub-app.json +3 -3
- package/install.js +11 -9
- 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/live-sessions.js +1 -1
- 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-client.js +4 -0
- package/lib/terminal-host.js +7 -1
- package/lib/terminal.js +86 -20
- package/lib/user-groups.js +384 -0
- package/package.json +3 -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 +1647 -464
- package/public/group-tree.js +21 -0
- package/public/index.html +53 -28
- package/public/style.css +820 -106
- package/public/sw.js +1 -1
- package/public/terminal-frame.js +12 -0
- package/public/vendor/claude-hub-sdk.js +1 -1
- package/server.js +333 -148
- package/skill-guides/dispatch.md +1 -1
- package/lib/dispatch-groups.js +0 -88
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
|
|
53
|
+
const { readLiveSessions, isPidAlive } = require('./lib/live-sessions');
|
|
54
54
|
const { createProcStats } = require('./lib/proc-stats');
|
|
55
|
-
const {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
|
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(
|
|
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
|
|
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 &&
|
|
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
|
-
//
|
|
3286
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 && !
|
|
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
|
-
//
|
|
3486
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3959
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4231
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
4598
|
-
|
|
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);
|