claude-code-kanban 7.4.0 → 7.6.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 +5 -3
- package/cli.js +95 -29
- package/hub-app.json +5 -0
- package/lib/panes.js +41 -6
- package/lib/parsers.js +42 -3
- package/lib/retention.js +54 -6
- package/lib/scratchpad-dir.js +19 -2
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +174 -47
- package/lib/show.js +385 -0
- package/lib/task-dir.js +26 -0
- package/lib/terminal-client.js +14 -1
- package/lib/terminal-host.js +2 -1
- package/lib/terminal.js +28 -8
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -5
- package/plugin/plugins/claude-code-kanban/hooks/doorbell.ts +136 -0
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +4 -0
- package/plugin/plugins/claude-code-kanban/hooks/show.ts +120 -0
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +2 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +3 -3
- package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +45 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/NOTICE.md +27 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/chart.md +58 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/architecture.md +18 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/er.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/flow.md +13 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/layers.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/sequence.md +19 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/state.md +12 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/swimlane.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/timeline.md +9 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/tree.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram.md +87 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/explain.md +98 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/mockup.md +30 -0
- package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +133 -0
- package/plugin/plugins/claude-code-kanban/tests/show.test.ts +137 -0
- package/public/app.js +2151 -1108
- package/public/index.html +19 -36
- package/public/style.css +1047 -125
- package/public/terminal-frame.js +0 -2
- package/public/tree-highlight.js +137 -15
- package/server.js +237 -178
- package/lib/dispatch.js +0 -32
- package/plugin/plugins/claude-code-kanban/monitors.json +0 -8
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +0 -82
- package/plugin/plugins/claude-code-kanban/skills/follow/SKILL.md +0 -47
package/server.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
const express = require('express');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const fs = require('node:fs').promises;
|
|
7
|
-
const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, unlinkSync, mkdirSync, renameSync, openSync, readSync, closeSync, realpathSync } = require('node:fs');
|
|
7
|
+
const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, unlinkSync, rmSync, mkdirSync, renameSync, openSync, readSync, closeSync, realpathSync } = require('node:fs');
|
|
8
8
|
const _readline = require('node:readline');
|
|
9
9
|
const chokidar = require('chokidar');
|
|
10
10
|
const os = require('node:os');
|
|
@@ -12,7 +12,7 @@ const crypto = require('node:crypto');
|
|
|
12
12
|
const { spawn } = require('node:child_process');
|
|
13
13
|
const { assertOpenTarget, openInEditor, whichSync, exeBehindShim } = require('./lib/open-editor');
|
|
14
14
|
const { createNetGuard } = require('./lib/net-guard');
|
|
15
|
-
const { isContained } = require('./lib/contain');
|
|
15
|
+
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');
|
|
@@ -28,6 +28,7 @@ const {
|
|
|
28
28
|
modelDisplayName,
|
|
29
29
|
buildSessionDigest,
|
|
30
30
|
readCompactSummaries,
|
|
31
|
+
fillCompactSummaries,
|
|
31
32
|
readArtifactLinks,
|
|
32
33
|
readScratchpadCreations,
|
|
33
34
|
extractPromptFromTranscript,
|
|
@@ -48,14 +49,15 @@ const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRef
|
|
|
48
49
|
const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
|
|
49
50
|
const { readTerminalConfig } = require('./lib/terminal');
|
|
50
51
|
const { createTerminalClient } = require('./lib/terminal-client');
|
|
52
|
+
const { createShowStore, mountShowRoutes, showBodyParser, SHOW_PATH } = require('./lib/show');
|
|
51
53
|
const { readLiveSessions, isPidAlive, isSessionLive } = require('./lib/live-sessions');
|
|
52
54
|
const { createProcStats } = require('./lib/proc-stats');
|
|
53
|
-
const { createDispatchRegistry } = require('./lib/dispatch');
|
|
54
55
|
const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
|
|
55
|
-
const { createDispatchedStore, scanTranscripts, pruneSessionDirs, pruneContextStatus, retentionMs } = require('./lib/retention');
|
|
56
|
+
const { createDispatchedStore, scanTranscripts, pruneSessionDirs, pruneContextStatus, pruneTaskMaps, retentionMs } = require('./lib/retention');
|
|
56
57
|
const { freshRateLimits } = require('./lib/rate-limits');
|
|
57
58
|
const { createWorktreeStore } = require('./lib/worktrees');
|
|
58
|
-
const { createScratchpadDirResolver } = require('./lib/scratchpad-dir');
|
|
59
|
+
const { createScratchpadDirResolver, scratchpadRoot } = require('./lib/scratchpad-dir');
|
|
60
|
+
const { readSettings } = require('./lib/claude-settings');
|
|
59
61
|
const { readGitBranch, sessionGitBranch } = require('./lib/git-branch');
|
|
60
62
|
const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
|
|
61
63
|
const { createPaneStore, isOwnOrigin } = require('./lib/panes');
|
|
@@ -63,6 +65,7 @@ const { probeFraming, parseOrigin } = require('./lib/frame-policy');
|
|
|
63
65
|
const { pickFolder } = require('./lib/folder-dialog');
|
|
64
66
|
const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
|
|
65
67
|
const { countTaskDir } = require('./lib/task-counts');
|
|
68
|
+
const { readTaskDir } = require('./lib/task-dir');
|
|
66
69
|
const { projectMatcher, normalizeProjectPath } = require('./public/project-match');
|
|
67
70
|
const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
|
|
68
71
|
|
|
@@ -115,13 +118,25 @@ const PANES_FILE = path.join(CCK_DIR, 'panes.json');
|
|
|
115
118
|
const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
|
|
116
119
|
const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
|
|
117
120
|
const SESSION_CACHE_FILE = path.join(CCK_DIR, 'session-cache.json');
|
|
118
|
-
|
|
121
|
+
// Every board on a config dir rewrites the whole file, so a test board points this elsewhere.
|
|
122
|
+
const TERMINALS_FILE = process.env.CCK_TERMINALS_FILE ? path.resolve(process.env.CCK_TERMINALS_FILE) : path.join(CCK_DIR, 'terminals.json');
|
|
119
123
|
// os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
|
|
120
124
|
const TEMP_ROOT = (() => {
|
|
121
125
|
try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
|
|
122
126
|
})();
|
|
123
127
|
// Harness-owned scratchpad root; the per-session dir under it is created lazily.
|
|
124
|
-
|
|
128
|
+
// Used only for a transcript that does not record its scratchpad dir. Settings come first:
|
|
129
|
+
// cck often runs under the hub or a tray, not in the shell where `claude` ran.
|
|
130
|
+
const SCRATCHPAD_ROOT = (() => {
|
|
131
|
+
const root = scratchpadRoot({
|
|
132
|
+
platform: process.platform,
|
|
133
|
+
overrides: [readSettings(path.join(CLAUDE_DIR, 'settings.json'))?.env?.CLAUDE_CODE_TMPDIR, process.env.CLAUDE_CODE_TMPDIR],
|
|
134
|
+
tmpdir: os.tmpdir(),
|
|
135
|
+
uid: process.getuid?.(),
|
|
136
|
+
});
|
|
137
|
+
// Claude Code resolves the root with realpath too: /private/tmp/claude-<uid> on macOS.
|
|
138
|
+
return realpathDeepest(root);
|
|
139
|
+
})();
|
|
125
140
|
|
|
126
141
|
// #endregion
|
|
127
142
|
|
|
@@ -136,25 +151,66 @@ function readPins() {
|
|
|
136
151
|
}
|
|
137
152
|
|
|
138
153
|
function writeJsonAtomic(file, obj, mode) {
|
|
154
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
155
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
139
156
|
try {
|
|
140
|
-
mkdirSync(path.dirname(file), { recursive: true });
|
|
141
|
-
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
142
157
|
writeFileSync(tmp, JSON.stringify(obj, null, 2), { encoding: 'utf8', mode });
|
|
143
158
|
renameSync(tmp, file);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
rmSync(tmp, { force: true });
|
|
161
|
+
throw e;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Claude Code reads task files while the board writes them, so it must never see half a file.
|
|
166
|
+
// On Windows a rename over a file another handle has open fails with EPERM or EBUSY until it closes.
|
|
167
|
+
let taskWriteSeq = 0;
|
|
168
|
+
async function writeTaskFile(file, task) {
|
|
169
|
+
const tmp = `${file}.${process.pid}.${++taskWriteSeq}.tmp`;
|
|
170
|
+
try {
|
|
171
|
+
await fs.writeFile(tmp, JSON.stringify(task, null, 2));
|
|
172
|
+
for (let attempt = 1; ; attempt++) {
|
|
173
|
+
try {
|
|
174
|
+
await fs.rename(tmp, file);
|
|
175
|
+
break;
|
|
176
|
+
} catch (e) {
|
|
177
|
+
if (attempt >= 10 || (e.code !== 'EPERM' && e.code !== 'EBUSY')) throw e;
|
|
178
|
+
await new Promise((r) => setTimeout(r, attempt * 10));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} catch (e) {
|
|
182
|
+
await fs.rm(tmp, { force: true });
|
|
183
|
+
throw e;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// For state that is also held in memory: a failed save is logged and the server goes on.
|
|
188
|
+
function writeJsonAtomicOrLog(file, obj, mode) {
|
|
189
|
+
try {
|
|
190
|
+
writeJsonAtomic(file, obj, mode);
|
|
144
191
|
} catch (e) {
|
|
145
192
|
console.error(`Failed to write ${path.basename(file)}:`, e.message);
|
|
146
193
|
}
|
|
147
194
|
}
|
|
148
195
|
|
|
196
|
+
function readJsonOrNull(file) {
|
|
197
|
+
try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const jsonFile = (file) => ({
|
|
201
|
+
load: () => readJsonOrNull(file),
|
|
202
|
+
save: (data) => writeJsonAtomicOrLog(file, data),
|
|
203
|
+
});
|
|
204
|
+
|
|
149
205
|
function writePins(pins) {
|
|
150
206
|
writeJsonAtomic(PINS_FILE, pins);
|
|
151
207
|
}
|
|
152
208
|
|
|
153
|
-
// Port discovery for out-of-process helpers (the
|
|
209
|
+
// Port discovery for out-of-process helpers (the plugin's doorbell mod, the CLI).
|
|
154
210
|
// The pid rides along so a reader can tell a live server from a file left behind by
|
|
155
211
|
// a crashed one.
|
|
156
212
|
function writeServerInfo(port) {
|
|
157
|
-
|
|
213
|
+
writeJsonAtomicOrLog(SERVER_INFO_FILE, { port, pid: process.pid });
|
|
158
214
|
}
|
|
159
215
|
|
|
160
216
|
// For `dispatch start`: a local process as the same user can already run claude itself,
|
|
@@ -172,14 +228,14 @@ function writeTerminalToken(port) {
|
|
|
172
228
|
if (pid !== null && !isPidAlive(pid)) try { unlinkSync(file); } catch (_) { /* already gone */ }
|
|
173
229
|
}
|
|
174
230
|
terminalTokenFile = path.join(TERMINAL_TOKENS_DIR, `${port}.json`);
|
|
175
|
-
|
|
231
|
+
writeJsonAtomicOrLog(terminalTokenFile,{ pid: process.pid, token: terminal.token }, 0o600);
|
|
176
232
|
}
|
|
177
233
|
|
|
178
234
|
function ownerPid(file) {
|
|
179
235
|
try { return JSON.parse(readFileSync(file, 'utf8')).pid ?? null; } catch (_) { return null; }
|
|
180
236
|
}
|
|
181
237
|
|
|
182
|
-
// A beacon that outlives its server sends the
|
|
238
|
+
// A beacon that outlives its server sends the doorbell mod and the CLI to a closed
|
|
183
239
|
// port. The hub spawns every sub-app on
|
|
184
240
|
// an ephemeral port, so a stale beacon never comes back on its own — drop ours on
|
|
185
241
|
// the way out. Only when the file is still ours: a newer server on the same config
|
|
@@ -255,9 +311,14 @@ function persistAgent(dir, agent) {
|
|
|
255
311
|
const CCK_CONFIG_FILE = path.join(CCK_DIR, 'config.json');
|
|
256
312
|
const LEGACY_APPROVALS_FILE = path.join(CCK_DIR, 'approvals.json');
|
|
257
313
|
const cckConfigCache = new Map();
|
|
314
|
+
const readCckConfig = () => JSON.parse(readFileSync(CCK_CONFIG_FILE, 'utf8'));
|
|
258
315
|
function approvalsConfig() {
|
|
259
316
|
return cachedByMtime(cckConfigCache, 'approvals', CCK_CONFIG_FILE,
|
|
260
|
-
() => approvalsFrom(
|
|
317
|
+
() => approvalsFrom(readCckConfig()), approvalsFrom(null));
|
|
318
|
+
}
|
|
319
|
+
function boardEventsEnabled() {
|
|
320
|
+
return cachedByMtime(cckConfigCache, 'boardEvents', CCK_CONFIG_FILE,
|
|
321
|
+
() => boardEventsOn(readCckConfig()), boardEventsOn(null));
|
|
261
322
|
}
|
|
262
323
|
|
|
263
324
|
// approvals.json predates config.json (and was opt-in). Fold it into
|
|
@@ -268,7 +329,7 @@ function migrateLegacyApprovalsConfig() {
|
|
|
268
329
|
const legacy = JSON.parse(readFileSync(LEGACY_APPROVALS_FILE, 'utf8'));
|
|
269
330
|
let cfg = {};
|
|
270
331
|
try { cfg = JSON.parse(readFileSync(CCK_CONFIG_FILE, 'utf8')) || {}; } catch { /* absent */ }
|
|
271
|
-
if (!cfg.approvals)
|
|
332
|
+
if (!cfg.approvals) writeJsonAtomicOrLog(CCK_CONFIG_FILE, { ...cfg, approvals: legacy });
|
|
272
333
|
unlinkSync(LEGACY_APPROVALS_FILE);
|
|
273
334
|
cckConfigCache.clear();
|
|
274
335
|
} catch (e) {
|
|
@@ -352,12 +413,7 @@ function getGitBranch(cwd) {
|
|
|
352
413
|
return branch;
|
|
353
414
|
}
|
|
354
415
|
|
|
355
|
-
const worktrees = createWorktreeStore(
|
|
356
|
-
load: () => {
|
|
357
|
-
try { return JSON.parse(readFileSync(WORKTREES_FILE, 'utf8')); } catch { return null; }
|
|
358
|
-
},
|
|
359
|
-
save: (data) => writeJsonAtomic(WORKTREES_FILE, data),
|
|
360
|
-
});
|
|
416
|
+
const worktrees = createWorktreeStore(jsonFile(WORKTREES_FILE));
|
|
361
417
|
|
|
362
418
|
function getSessionLogStat(meta) {
|
|
363
419
|
if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
|
|
@@ -457,6 +513,11 @@ function resolveSessionId(sessionId) {
|
|
|
457
513
|
return (teamConfig?.leadSessionId) ? teamConfig.leadSessionId : sessionId;
|
|
458
514
|
}
|
|
459
515
|
|
|
516
|
+
function sessionMetaFor(sessionId) {
|
|
517
|
+
const metadata = loadSessionMetadata();
|
|
518
|
+
return metadata[sessionId] || metadata[resolveSessionId(sessionId)];
|
|
519
|
+
}
|
|
520
|
+
|
|
460
521
|
// Recent Claude Code releases auto-create a single-member "self-team" for every
|
|
461
522
|
// session: a teams/session-<id>/config.json whose only member is the "team-lead"
|
|
462
523
|
// (the session itself). These are not real multi-agent teams — surfacing them
|
|
@@ -600,8 +661,9 @@ app.param('name', (_req, res, next, val) => {
|
|
|
600
661
|
next();
|
|
601
662
|
});
|
|
602
663
|
|
|
603
|
-
|
|
604
|
-
|
|
664
|
+
app.use(SHOW_PATH, showBodyParser());
|
|
665
|
+
// Parse JSON bodies. A review of 50 comments at the field caps is about 450 kB.
|
|
666
|
+
app.use(express.json({ limit: '1mb' }));
|
|
605
667
|
// #endregion
|
|
606
668
|
|
|
607
669
|
// #region STATIC
|
|
@@ -840,6 +902,7 @@ function refreshSessionMetadataPath(jsonlPath) {
|
|
|
840
902
|
if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
|
|
841
903
|
existing.permissionMode = info.permissionMode;
|
|
842
904
|
existing.cacheTtl = info.cacheTtl;
|
|
905
|
+
if (info.scratchpadDir) existing.scratchpadDir = info.scratchpadDir;
|
|
843
906
|
existing.lastReply = info.lastReply;
|
|
844
907
|
return true;
|
|
845
908
|
}
|
|
@@ -932,7 +995,8 @@ function loadSessionMetadata() {
|
|
|
932
995
|
compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null,
|
|
933
996
|
permissionMode: sessionInfo.permissionMode,
|
|
934
997
|
cacheTtl: sessionInfo.cacheTtl || null,
|
|
935
|
-
lastReply: sessionInfo.lastReply || null
|
|
998
|
+
lastReply: sessionInfo.lastReply || null,
|
|
999
|
+
scratchpadDir: sessionInfo.scratchpadDir || null
|
|
936
1000
|
};
|
|
937
1001
|
sessionIds.push(sessionId);
|
|
938
1002
|
}
|
|
@@ -1136,6 +1200,8 @@ function getSessionDisplayName(_sessionId, meta) {
|
|
|
1136
1200
|
const getScratchpadDir = createScratchpadDirResolver({
|
|
1137
1201
|
root: SCRATCHPAD_ROOT,
|
|
1138
1202
|
resolveWorktree: worktrees.resolve,
|
|
1203
|
+
// Transcripts record the 8.3 short form on Windows; path checks and the watcher need the long one.
|
|
1204
|
+
toLong: realpathDeepest,
|
|
1139
1205
|
});
|
|
1140
1206
|
|
|
1141
1207
|
function buildSessionObject(id, meta, overrides = {}) {
|
|
@@ -1707,25 +1773,9 @@ function addOwnerSessions(tasks, listIdOf) {
|
|
|
1707
1773
|
|
|
1708
1774
|
app.get('/api/sessions/:sessionId', async (req, res) => {
|
|
1709
1775
|
try {
|
|
1710
|
-
const sessionPath = taskDirFor(req.params.sessionId);
|
|
1711
|
-
|
|
1712
1776
|
// A session that never used the board has no task dir. That is the common case and
|
|
1713
1777
|
// an answer, not a failure; a 404 only reached the browser console.
|
|
1714
|
-
|
|
1715
|
-
return res.json([]);
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
|
|
1719
|
-
const tasks = [];
|
|
1720
|
-
|
|
1721
|
-
for (const file of taskFiles) {
|
|
1722
|
-
try {
|
|
1723
|
-
const task = JSON.parse(readFileSync(path.join(sessionPath, file), 'utf8'));
|
|
1724
|
-
tasks.push(task);
|
|
1725
|
-
} catch (e) {
|
|
1726
|
-
console.error(`Error parsing ${file}:`, e);
|
|
1727
|
-
}
|
|
1728
|
-
}
|
|
1778
|
+
const tasks = (await readTaskDir(taskDirFor(req.params.sessionId))).map(({ task }) => task);
|
|
1729
1779
|
|
|
1730
1780
|
// Sort by ID (numeric)
|
|
1731
1781
|
tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
|
|
@@ -1739,41 +1789,24 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
|
|
|
1739
1789
|
});
|
|
1740
1790
|
|
|
1741
1791
|
// API: Get combined tasks for a project (all sessions + shared task lists)
|
|
1742
|
-
app.get('/api/projects/:encodedPath/tasks', (req, res) => {
|
|
1792
|
+
app.get('/api/projects/:encodedPath/tasks', async (req, res) => {
|
|
1743
1793
|
try {
|
|
1744
1794
|
const projectPath = Buffer.from(req.params.encodedPath, 'base64').toString('utf8');
|
|
1745
1795
|
const metadata = loadSessionMetadata();
|
|
1746
|
-
const
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
const taskDirs = new Set();
|
|
1753
|
-
for (const sid of projectSessionIds) {
|
|
1754
|
-
const listName = sessionToList[sid];
|
|
1755
|
-
if (listName) {
|
|
1756
|
-
const dir = path.join(TASKS_DIR, listName);
|
|
1757
|
-
if (existsSync(dir)) taskDirs.add(dir);
|
|
1758
|
-
} else {
|
|
1759
|
-
const dir = path.join(TASKS_DIR, sid);
|
|
1760
|
-
if (existsSync(dir)) taskDirs.add(dir);
|
|
1761
|
-
}
|
|
1762
|
-
}
|
|
1796
|
+
const taskDirs = new Set(
|
|
1797
|
+
Object.entries(metadata)
|
|
1798
|
+
.filter(([, m]) => m.project === projectPath)
|
|
1799
|
+
.map(([id]) => taskDirFor(id)),
|
|
1800
|
+
);
|
|
1763
1801
|
|
|
1764
1802
|
const tasks = [];
|
|
1765
|
-
const seenKeys = new Set();
|
|
1766
1803
|
for (const dir of taskDirs) {
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
task._taskDir = path.basename(dir);
|
|
1774
|
-
tasks.push(task);
|
|
1775
|
-
}
|
|
1776
|
-
} catch (_) {}
|
|
1804
|
+
const seenIds = new Set();
|
|
1805
|
+
for (const { task } of await readTaskDir(dir)) {
|
|
1806
|
+
if (seenIds.has(task.id)) continue;
|
|
1807
|
+
seenIds.add(task.id);
|
|
1808
|
+
task._taskDir = path.basename(dir);
|
|
1809
|
+
tasks.push(task);
|
|
1777
1810
|
}
|
|
1778
1811
|
}
|
|
1779
1812
|
tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
|
|
@@ -1788,8 +1821,7 @@ app.get('/api/projects/:encodedPath/tasks', (req, res) => {
|
|
|
1788
1821
|
// API: Get session plan
|
|
1789
1822
|
app.get('/api/sessions/:sessionId/plan', async (req, res) => {
|
|
1790
1823
|
try {
|
|
1791
|
-
const
|
|
1792
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
1824
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
1793
1825
|
// Most sessions have no saved plan, and the info modal asks for one every time it
|
|
1794
1826
|
// opens, so "no plan" is a normal answer rather than a 404 in the console.
|
|
1795
1827
|
const slug = meta?.slug;
|
|
@@ -1808,8 +1840,7 @@ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
|
|
|
1808
1840
|
|
|
1809
1841
|
app.get('/api/sessions/:sessionId/loop', (req, res) => {
|
|
1810
1842
|
try {
|
|
1811
|
-
const
|
|
1812
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
1843
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
1813
1844
|
if (!meta?.jsonlPath) return res.json({ wakeups: [], crons: [] });
|
|
1814
1845
|
const state = refreshLoopInfoState(meta.jsonlPath);
|
|
1815
1846
|
const filtered = filterActiveLoopInfo(buildLoopInfoFromState(state));
|
|
@@ -1851,8 +1882,7 @@ function getArtifactLinks(jsonlPath) {
|
|
|
1851
1882
|
|
|
1852
1883
|
app.get('/api/sessions/:sessionId/artifacts', async (req, res) => {
|
|
1853
1884
|
try {
|
|
1854
|
-
const
|
|
1855
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
1885
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
1856
1886
|
if (!meta?.jsonlPath) return res.json({ artifacts: [] });
|
|
1857
1887
|
res.json({ artifacts: await getArtifactLinks(meta.jsonlPath) });
|
|
1858
1888
|
} catch (error) {
|
|
@@ -1956,8 +1986,7 @@ function getCreatedPads(meta) {
|
|
|
1956
1986
|
|
|
1957
1987
|
app.get('/api/sessions/:sessionId/pads', async (req, res) => {
|
|
1958
1988
|
try {
|
|
1959
|
-
const
|
|
1960
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
1989
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
1961
1990
|
if (!meta?.jsonlPath) return res.json({ pads: [] });
|
|
1962
1991
|
res.json({ pads: await getCreatedPads(meta) });
|
|
1963
1992
|
} catch (error) {
|
|
@@ -2290,8 +2319,7 @@ app.post('/api/sessions/:sessionId/workflows/:wfId/open', (req, res) => {
|
|
|
2290
2319
|
// API: Open session plan in VS Code
|
|
2291
2320
|
app.post('/api/sessions/:sessionId/plan/open', (req, res) => {
|
|
2292
2321
|
try {
|
|
2293
|
-
const
|
|
2294
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
2322
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
2295
2323
|
const slug = meta?.slug;
|
|
2296
2324
|
if (!slug) return res.status(404).json({ error: 'No plan found' });
|
|
2297
2325
|
|
|
@@ -3006,8 +3034,7 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages/stream', (req, res) =
|
|
|
3006
3034
|
async function sendSessionMessages(req, res) {
|
|
3007
3035
|
const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50);
|
|
3008
3036
|
const before = req.query.before || null;
|
|
3009
|
-
const
|
|
3010
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
3037
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
3011
3038
|
const jsonlPath = meta?.jsonlPath;
|
|
3012
3039
|
if (!jsonlPath) return res.json({ messages: [], hasMore: false, sessionId: req.params.sessionId });
|
|
3013
3040
|
let messages, hasMore;
|
|
@@ -3020,7 +3047,10 @@ async function sendSessionMessages(req, res) {
|
|
|
3020
3047
|
hasMore = messages.length > limit;
|
|
3021
3048
|
if (hasMore) messages = messages.slice(-limit);
|
|
3022
3049
|
}
|
|
3023
|
-
const
|
|
3050
|
+
const compactedMsgs = messages.filter(m => m.systemLabel === 'Compacted');
|
|
3051
|
+
const compactPromise = compactedMsgs.some(m => !m.compactSummary)
|
|
3052
|
+
? cachedByMtime(compactSummaryCache, jsonlPath, jsonlPath, () => readCompactSummaries(jsonlPath), [])
|
|
3053
|
+
: null;
|
|
3024
3054
|
const agentMessages = messages.filter(m => m.tool === 'Agent' && m.toolUseId);
|
|
3025
3055
|
if (agentMessages.length) {
|
|
3026
3056
|
const progressMap = await getProgressMap(jsonlPath);
|
|
@@ -3074,16 +3104,7 @@ async function sendSessionMessages(req, res) {
|
|
|
3074
3104
|
}
|
|
3075
3105
|
}
|
|
3076
3106
|
}
|
|
3077
|
-
|
|
3078
|
-
// Match compaction messages to summaries by chronological order
|
|
3079
|
-
const compactedMsgs = messages
|
|
3080
|
-
.filter(m => m.systemLabel === 'Compacted')
|
|
3081
|
-
.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
|
|
3082
|
-
for (let i = 0; i < compactedMsgs.length; i++) {
|
|
3083
|
-
if (i < compactSummaries.length) {
|
|
3084
|
-
compactedMsgs[i].compactSummary = compactSummaries[i].summary;
|
|
3085
|
-
}
|
|
3086
|
-
}
|
|
3107
|
+
if (compactPromise) fillCompactSummaries(compactedMsgs, await compactPromise);
|
|
3087
3108
|
// The client keeps needing toolUseId when it builds a follow-up URL from it:
|
|
3088
3109
|
// lazy-fetching a truncated tool result, or fetching each tool-result image.
|
|
3089
3110
|
const clientNeedsToolUseId = (msg) => msg.toolResultTruncated || msg.toolResultImageCount;
|
|
@@ -3097,8 +3118,7 @@ async function sendSessionMessages(req, res) {
|
|
|
3097
3118
|
app.get('/api/sessions/:sessionId/messages', asyncRoute(sendSessionMessages));
|
|
3098
3119
|
|
|
3099
3120
|
app.get('/api/sessions/:sessionId/tool-result/:toolUseId', asyncRoute(async (req, res) => {
|
|
3100
|
-
const
|
|
3101
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
3121
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
3102
3122
|
const jsonlPath = meta?.jsonlPath;
|
|
3103
3123
|
if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
|
|
3104
3124
|
const content = await readFullToolResult(jsonlPath, req.params.toolUseId);
|
|
@@ -3203,8 +3223,7 @@ async function buildToolStats(jsonlPath) {
|
|
|
3203
3223
|
}
|
|
3204
3224
|
|
|
3205
3225
|
app.get('/api/sessions/:sessionId/tool-stats', async (req, res) => {
|
|
3206
|
-
const
|
|
3207
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
3226
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
3208
3227
|
const jsonlPath = meta?.jsonlPath;
|
|
3209
3228
|
if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
|
|
3210
3229
|
try {
|
|
@@ -3217,8 +3236,7 @@ app.get('/api/sessions/:sessionId/tool-stats', async (req, res) => {
|
|
|
3217
3236
|
});
|
|
3218
3237
|
|
|
3219
3238
|
async function sendTranscriptImage(req, res, read) {
|
|
3220
|
-
const
|
|
3221
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
3239
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
3222
3240
|
const jsonlPath = meta?.jsonlPath;
|
|
3223
3241
|
if (!jsonlPath) return res.status(404).end();
|
|
3224
3242
|
const img = await read(jsonlPath);
|
|
@@ -3299,6 +3317,16 @@ function resolveSessionFolder(id) {
|
|
|
3299
3317
|
return meta ? meta.project || meta.cwd || null : null;
|
|
3300
3318
|
}
|
|
3301
3319
|
|
|
3320
|
+
// The host saves its terminals; cck adds the show map (terminal id → session id) beside them.
|
|
3321
|
+
const terminalsFile = jsonFile(TERMINALS_FILE);
|
|
3322
|
+
let savedTerminals = terminalsFile.load() || {};
|
|
3323
|
+
|
|
3324
|
+
function saveTerminals() {
|
|
3325
|
+
const kept = new Set(Object.values(savedTerminals.terminalIds || {}));
|
|
3326
|
+
const showSessions = show.prune((id) => kept.has(id) || terminal.hasTerminal(id));
|
|
3327
|
+
terminalsFile.save({ ...savedTerminals, showSessions });
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3302
3330
|
const terminal = createTerminalClient({
|
|
3303
3331
|
config: readTerminalConfig({ getArgValue }),
|
|
3304
3332
|
net,
|
|
@@ -3306,17 +3334,18 @@ const terminal = createTerminalClient({
|
|
|
3306
3334
|
isDefaultDir: isDefaultClaudeDir(CLAUDE_DIR),
|
|
3307
3335
|
sessionsDir: SESSIONS_DIR,
|
|
3308
3336
|
token: process.env.CCK_TERMINAL_TOKEN,
|
|
3309
|
-
load:
|
|
3310
|
-
|
|
3337
|
+
load: terminalsFile.load,
|
|
3338
|
+
save: (data) => {
|
|
3339
|
+
savedTerminals = data;
|
|
3340
|
+
saveTerminals();
|
|
3311
3341
|
},
|
|
3312
|
-
save: (data) => writeJsonAtomic(TERMINALS_FILE, data),
|
|
3313
3342
|
// The project, not the last cwd: `claude --resume` finds a session under the
|
|
3314
3343
|
// project dir it started in, and cwd drifts into subdirectories.
|
|
3315
3344
|
resolveCwd: resolveSessionFolder,
|
|
3316
3345
|
isAllowedFolder,
|
|
3317
3346
|
onChange: () => broadcast({ type: 'terminals-update', ids: terminal.ids() }),
|
|
3318
3347
|
onExit: (id) => {
|
|
3319
|
-
if (
|
|
3348
|
+
if (dispatched.get(id)) broadcast({ type: 'dispatch-update' });
|
|
3320
3349
|
},
|
|
3321
3350
|
});
|
|
3322
3351
|
|
|
@@ -3377,6 +3406,25 @@ app.delete('/api/terminals/:id', terminalRoute(async (req, res) => {
|
|
|
3377
3406
|
res.status(204).end();
|
|
3378
3407
|
}));
|
|
3379
3408
|
|
|
3409
|
+
const show = createShowStore({
|
|
3410
|
+
load: () => savedTerminals.showSessions,
|
|
3411
|
+
onChange: saveTerminals,
|
|
3412
|
+
onPosted: (e) => broadcast({ type: 'show:posted', ...e }),
|
|
3413
|
+
startedSession: terminal.sessionOfTerminal,
|
|
3414
|
+
resolveDir: (id) => {
|
|
3415
|
+
const meta = sessionMetaFor(id);
|
|
3416
|
+
return meta ? getScratchpadDir(id, meta) : null;
|
|
3417
|
+
},
|
|
3418
|
+
});
|
|
3419
|
+
mountShowRoutes(app, {
|
|
3420
|
+
store: show,
|
|
3421
|
+
authorized: (token) => terminal.authorized(token),
|
|
3422
|
+
hasTerminal: (id) => terminal.hasTerminal(id),
|
|
3423
|
+
readPreviewFile,
|
|
3424
|
+
broadcast,
|
|
3425
|
+
onRemoved: (sessionId, paths) => panes.removeShow(sessionId, paths),
|
|
3426
|
+
});
|
|
3427
|
+
|
|
3380
3428
|
// Served from node_modules, never a CDN: any script on this page can use the token.
|
|
3381
3429
|
const XTERM_FILES = {
|
|
3382
3430
|
'xterm.js': '@xterm/xterm/lib/xterm.js',
|
|
@@ -3396,30 +3444,15 @@ app.get('/vendor/xterm/:file', (req, res) => {
|
|
|
3396
3444
|
// #endregion
|
|
3397
3445
|
|
|
3398
3446
|
// #region DISPATCH
|
|
3399
|
-
const dispatched = createDispatchedStore(
|
|
3400
|
-
load: () => {
|
|
3401
|
-
try { return JSON.parse(readFileSync(DISPATCHED_FILE, 'utf8')); } catch { return null; }
|
|
3402
|
-
},
|
|
3403
|
-
save: (data) => writeJsonAtomic(DISPATCHED_FILE, data),
|
|
3404
|
-
});
|
|
3405
|
-
|
|
3406
|
-
const dispatches = createDispatchRegistry();
|
|
3447
|
+
const dispatched = createDispatchedStore(jsonFile(DISPATCHED_FILE));
|
|
3407
3448
|
|
|
3408
3449
|
const dispatchGroups = createGroupStore({
|
|
3409
|
-
|
|
3410
|
-
try { return JSON.parse(readFileSync(DISPATCH_GROUPS_FILE, 'utf8')); } catch { return null; }
|
|
3411
|
-
},
|
|
3412
|
-
save: (data) => writeJsonAtomic(DISPATCH_GROUPS_FILE, data),
|
|
3450
|
+
...jsonFile(DISPATCH_GROUPS_FILE),
|
|
3413
3451
|
isAlive: (id) => terminal.isRunning(id) || isSessionLive(loadLiveSessions(), id),
|
|
3414
3452
|
pinnedIds: () => new Set(Object.keys(readPins())),
|
|
3415
3453
|
});
|
|
3416
3454
|
|
|
3417
|
-
const linkedDocs = createLinkedDocStore(
|
|
3418
|
-
load: () => {
|
|
3419
|
-
try { return JSON.parse(readFileSync(LINKED_DOCS_FILE, 'utf8')); } catch { return null; }
|
|
3420
|
-
},
|
|
3421
|
-
save: (data) => writeJsonAtomic(LINKED_DOCS_FILE, data),
|
|
3422
|
-
});
|
|
3455
|
+
const linkedDocs = createLinkedDocStore(jsonFile(LINKED_DOCS_FILE));
|
|
3423
3456
|
|
|
3424
3457
|
// The board places a session from these alone, so it never has to move it later.
|
|
3425
3458
|
function withDispatchPlacement(sessions) {
|
|
@@ -3448,8 +3481,7 @@ app.post('/api/dispatch', terminalRoute(async (req, res) => {
|
|
|
3448
3481
|
}
|
|
3449
3482
|
const started = await terminal.startNew({ cwd, name, model, worktree, taskList, prompt: spec.trim(), extraArgs: claudeArgs });
|
|
3450
3483
|
if (started.error) return res.status(started.status).json({ error: started.error });
|
|
3451
|
-
|
|
3452
|
-
dispatched.record(started.id, parent);
|
|
3484
|
+
dispatched.record(started.id, { parent, cwd: started.cwd, name, group, worktree });
|
|
3453
3485
|
// The starter stays where it is: moving it would jump it under the user.
|
|
3454
3486
|
if (group) dispatchGroups.join(group, [started.id]);
|
|
3455
3487
|
broadcast({ type: 'dispatch-update' });
|
|
@@ -3459,7 +3491,7 @@ app.post('/api/dispatch', terminalRoute(async (req, res) => {
|
|
|
3459
3491
|
app.get('/api/dispatch', (req, res) => {
|
|
3460
3492
|
res.setHeader('Cache-Control', 'no-store');
|
|
3461
3493
|
const parent = typeof req.query.parent === 'string' && req.query.parent ? req.query.parent : null;
|
|
3462
|
-
res.json({ running:
|
|
3494
|
+
res.json({ running: dispatched.running(terminal.isRunning, { parent }) });
|
|
3463
3495
|
});
|
|
3464
3496
|
// #endregion
|
|
3465
3497
|
|
|
@@ -3473,14 +3505,13 @@ app.get('/api/tasks/all', async (_req, res) => {
|
|
|
3473
3505
|
|
|
3474
3506
|
const metadata = loadSessionMetadata();
|
|
3475
3507
|
const { listToSessions } = loadAllTaskMaps();
|
|
3476
|
-
const sessionDirs =
|
|
3508
|
+
const sessionDirs = (await fs.readdir(TASKS_DIR, { withFileTypes: true }))
|
|
3477
3509
|
.filter(d => d.isDirectory());
|
|
3510
|
+
const dirTasks = await Promise.all(sessionDirs.map((d) => readTaskDir(path.join(TASKS_DIR, d.name))));
|
|
3478
3511
|
|
|
3479
3512
|
const allTasks = [];
|
|
3480
3513
|
|
|
3481
|
-
|
|
3482
|
-
const sessionPath = path.join(TASKS_DIR, sessionDir.name);
|
|
3483
|
-
const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
|
|
3514
|
+
sessionDirs.forEach((sessionDir, i) => {
|
|
3484
3515
|
const meta = metadata[sessionDir.name] || {};
|
|
3485
3516
|
|
|
3486
3517
|
// For custom task list directories (non-UUID dirs), resolve project from the
|
|
@@ -3496,20 +3527,15 @@ app.get('/api/tasks/all', async (_req, res) => {
|
|
|
3496
3527
|
}
|
|
3497
3528
|
}
|
|
3498
3529
|
|
|
3499
|
-
for (const
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
project
|
|
3507
|
-
});
|
|
3508
|
-
} catch {
|
|
3509
|
-
// Skip invalid files
|
|
3510
|
-
}
|
|
3530
|
+
for (const { task } of dirTasks[i]) {
|
|
3531
|
+
allTasks.push({
|
|
3532
|
+
...task,
|
|
3533
|
+
sessionId: sessionDir.name,
|
|
3534
|
+
sessionName: getSessionDisplayName(sessionDir.name, meta),
|
|
3535
|
+
project
|
|
3536
|
+
});
|
|
3511
3537
|
}
|
|
3512
|
-
}
|
|
3538
|
+
});
|
|
3513
3539
|
|
|
3514
3540
|
res.json(allTasks);
|
|
3515
3541
|
} catch (error) {
|
|
@@ -3519,11 +3545,13 @@ app.get('/api/tasks/all', async (_req, res) => {
|
|
|
3519
3545
|
});
|
|
3520
3546
|
|
|
3521
3547
|
const {
|
|
3548
|
+
boardEventsOn,
|
|
3522
3549
|
enqueueSessionEvent,
|
|
3523
3550
|
formatReviewSubmitted,
|
|
3524
3551
|
formatTaskMoved,
|
|
3525
3552
|
handleSessionEvents,
|
|
3526
|
-
|
|
3553
|
+
hasDoorbell,
|
|
3554
|
+
configureSessionEvents,
|
|
3527
3555
|
} = require('./lib/session-events');
|
|
3528
3556
|
app.get('/api/sessions/:sessionId/events', handleSessionEvents);
|
|
3529
3557
|
|
|
@@ -3557,7 +3585,7 @@ app.post('/api/tasks/:sessionId', async (req, res) => {
|
|
|
3557
3585
|
|
|
3558
3586
|
// No doorbell here, unlike a move: the user typed this task, so telling their session
|
|
3559
3587
|
// about it would only repeat what they just said. Dragging it to In Progress rings.
|
|
3560
|
-
await
|
|
3588
|
+
await writeTaskFile(path.join(sessionDir, `${id}.json`), task);
|
|
3561
3589
|
res.json({ success: true, task });
|
|
3562
3590
|
} catch (error) {
|
|
3563
3591
|
console.error('Error creating task:', error);
|
|
@@ -3585,14 +3613,14 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
3585
3613
|
if (description !== undefined) task.description = description;
|
|
3586
3614
|
if (req.body.status !== undefined) task.status = req.body.status;
|
|
3587
3615
|
|
|
3588
|
-
await
|
|
3616
|
+
await writeTaskFile(taskPath, task);
|
|
3589
3617
|
|
|
3590
3618
|
// Ring the session only for a move. The direction has to ride in the line because
|
|
3591
3619
|
// the write above destroyed the old status -- nothing downstream can recover it, and
|
|
3592
3620
|
// which way a task moved is what decides whether to start work or stop it.
|
|
3593
|
-
if (task.status !== prevStatus) {
|
|
3621
|
+
if (task.status !== prevStatus && boardEventsEnabled()) {
|
|
3594
3622
|
// The route param is a task *directory*, which for a shared list or team board is
|
|
3595
|
-
// not a session id -- and the
|
|
3623
|
+
// not a session id -- and the doorbell polls with its own session id, so an unresolved
|
|
3596
3624
|
// name would queue the line where nobody drains it.
|
|
3597
3625
|
const line = formatTaskMoved(taskId, prevStatus, task);
|
|
3598
3626
|
for (const sid of resolveSessionsForTaskDir(sessionId)) enqueueSessionEvent(sid, line);
|
|
@@ -3608,21 +3636,7 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
3608
3636
|
// A deleted task can't block anything, so drop the dangling reference instead of
|
|
3609
3637
|
// refusing the delete -- a stale blockedBy id would pin the other task as BLOCKED forever.
|
|
3610
3638
|
async function deleteTasks(dir, shouldDelete) {
|
|
3611
|
-
|
|
3612
|
-
try {
|
|
3613
|
-
files = (await fs.readdir(dir)).filter((f) => f.endsWith('.json'));
|
|
3614
|
-
} catch (e) {
|
|
3615
|
-
if (e.code === 'ENOENT') return [];
|
|
3616
|
-
throw e;
|
|
3617
|
-
}
|
|
3618
|
-
const tasks = [];
|
|
3619
|
-
for (const file of files) {
|
|
3620
|
-
try {
|
|
3621
|
-
tasks.push({ file, task: JSON.parse(await fs.readFile(path.join(dir, file), 'utf8')) });
|
|
3622
|
-
} catch (e) {
|
|
3623
|
-
console.error(`Error parsing ${file}:`, e);
|
|
3624
|
-
}
|
|
3625
|
-
}
|
|
3639
|
+
const tasks = await readTaskDir(dir);
|
|
3626
3640
|
const doomed = tasks.filter((t) => shouldDelete(t));
|
|
3627
3641
|
const doomedIds = new Set(doomed.map(({ task }) => task.id));
|
|
3628
3642
|
const strip = (ids) => ids?.filter((id) => !doomedIds.has(id));
|
|
@@ -3635,7 +3649,7 @@ async function deleteTasks(dir, shouldDelete) {
|
|
|
3635
3649
|
task.blockedBy = strip(task.blockedBy);
|
|
3636
3650
|
task.blocks = strip(task.blocks);
|
|
3637
3651
|
if (JSON.stringify(task) === before) return null;
|
|
3638
|
-
return
|
|
3652
|
+
return writeTaskFile(path.join(dir, file), task);
|
|
3639
3653
|
}),
|
|
3640
3654
|
);
|
|
3641
3655
|
// Delete the task file
|
|
@@ -3831,6 +3845,7 @@ function resolvePreviewPath(rawPath, base) {
|
|
|
3831
3845
|
app.post('/api/preview', async (req, res) => {
|
|
3832
3846
|
try {
|
|
3833
3847
|
const { path: filePath, sessionId, base } = req.body || {};
|
|
3848
|
+
if (sessionId && !isSafeId(sessionId)) return res.status(400).json({ error: 'Invalid session ID' });
|
|
3834
3849
|
const abs = resolvePreviewPath(filePath, base);
|
|
3835
3850
|
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
3836
3851
|
// Validate here so the CLI still gets 400/404, but broadcast the path only —
|
|
@@ -3852,7 +3867,8 @@ app.post('/api/preview', async (req, res) => {
|
|
|
3852
3867
|
app.post('/api/document/link', async (req, res) => {
|
|
3853
3868
|
try {
|
|
3854
3869
|
const { path: filePath, sessionId, unlink, open } = req.body || {};
|
|
3855
|
-
if (
|
|
3870
|
+
if (!sessionId) return res.status(400).json({ error: 'sessionId is required' });
|
|
3871
|
+
if (!isSafeId(sessionId)) return res.status(400).json({ error: 'Invalid session ID' });
|
|
3856
3872
|
const url = linkUrl(filePath);
|
|
3857
3873
|
const abs = url || resolvePreviewPath(filePath);
|
|
3858
3874
|
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
@@ -4026,10 +4042,10 @@ const panes = createPaneStore({
|
|
|
4026
4042
|
const stamp = panesFileStamp();
|
|
4027
4043
|
if (stamp === panesStamp) return undefined;
|
|
4028
4044
|
panesStamp = stamp;
|
|
4029
|
-
|
|
4045
|
+
return readJsonOrNull(PANES_FILE);
|
|
4030
4046
|
},
|
|
4031
4047
|
save: (data) => {
|
|
4032
|
-
|
|
4048
|
+
writeJsonAtomicOrLog(PANES_FILE, data);
|
|
4033
4049
|
panesStamp = panesFileStamp();
|
|
4034
4050
|
},
|
|
4035
4051
|
onChange: (sessionId, layout) => {
|
|
@@ -4103,9 +4119,11 @@ app.get('/api/panes/:sessionId', (req, res) => {
|
|
|
4103
4119
|
|
|
4104
4120
|
app.post('/api/panes/:sessionId', async (req, res) => {
|
|
4105
4121
|
try {
|
|
4106
|
-
const { target, base, title } = req.body || {};
|
|
4122
|
+
const { target, base, title, kind, show } = req.body || {};
|
|
4107
4123
|
if (typeof target !== 'string' || !target) throw previewError(400, 'target is required', 'bad_target');
|
|
4108
|
-
|
|
4124
|
+
// A message target is a session log message id, which the board resolves when it shows the pane.
|
|
4125
|
+
const resolved = kind === 'message' ? { kind, target } : await resolvePaneTarget(target, base);
|
|
4126
|
+
res.json(panes.add(req.params.sessionId, { ...resolved, title, show: show === true }));
|
|
4109
4127
|
} catch (error) {
|
|
4110
4128
|
paneRouteError(res, error, 'POST /api/panes');
|
|
4111
4129
|
}
|
|
@@ -4140,6 +4158,19 @@ app.get('/api/panes/:sessionId/:paneId/framing', async (req, res) => {
|
|
|
4140
4158
|
}
|
|
4141
4159
|
});
|
|
4142
4160
|
|
|
4161
|
+
app.patch('/api/panes/:sessionId/:paneId', (req, res) => {
|
|
4162
|
+
try {
|
|
4163
|
+
const { sessionId, paneId } = req.params;
|
|
4164
|
+
const title = req.body?.title;
|
|
4165
|
+
if (typeof title !== 'string') throw previewError(400, 'title must be a string', 'bad_title');
|
|
4166
|
+
const layout = panes.rename(sessionId, paneId, title);
|
|
4167
|
+
if (!layout) throw previewError(404, `No pane ${paneId} in session ${sessionId}`, 'no_pane');
|
|
4168
|
+
res.json({ layout });
|
|
4169
|
+
} catch (error) {
|
|
4170
|
+
paneRouteError(res, error, 'PATCH /api/panes/:paneId');
|
|
4171
|
+
}
|
|
4172
|
+
});
|
|
4173
|
+
|
|
4143
4174
|
app.delete('/api/panes/:sessionId/:paneId', (req, res) => {
|
|
4144
4175
|
const { sessionId, paneId } = req.params;
|
|
4145
4176
|
const layout = panes.remove(sessionId, paneId);
|
|
@@ -4155,6 +4186,23 @@ app.delete('/api/panes/:sessionId/:paneId', (req, res) => {
|
|
|
4155
4186
|
// carries one line, and the file is what each route points at.
|
|
4156
4187
|
const REVIEW_DIR = path.join(CCK_DIR, 'reviews');
|
|
4157
4188
|
const REVIEW_KIND_RE = /^[a-z]{1,20}$/;
|
|
4189
|
+
|
|
4190
|
+
// `<ts>.pending` holds the doorbell line of a review the mod has not acked yet.
|
|
4191
|
+
configureSessionEvents({
|
|
4192
|
+
onDelivered: (entry) => fs.unlink(entry.marker).catch(() => {}),
|
|
4193
|
+
restore: async (sessionId) => {
|
|
4194
|
+
if (!isUUID(sessionId)) return [];
|
|
4195
|
+
const dir = path.join(REVIEW_DIR, sessionId);
|
|
4196
|
+
const names = (await fs.readdir(dir).catch(() => [])).filter((n) => n.endsWith('.pending')).sort();
|
|
4197
|
+
const lines = [];
|
|
4198
|
+
for (const name of names) {
|
|
4199
|
+
const marker = path.join(dir, name);
|
|
4200
|
+
const text = await fs.readFile(marker, 'utf8').catch(() => '');
|
|
4201
|
+
if (text) lines.push({ text, marker });
|
|
4202
|
+
}
|
|
4203
|
+
return lines;
|
|
4204
|
+
},
|
|
4205
|
+
});
|
|
4158
4206
|
const REVIEW_MAX_COMMENTS = 50;
|
|
4159
4207
|
const REVIEW_MAX_CHARS = 4000;
|
|
4160
4208
|
|
|
@@ -4223,8 +4271,11 @@ app.post('/api/sessions/:sessionId/review', async (req, res) => {
|
|
|
4223
4271
|
await fs.writeFile(file, markdown);
|
|
4224
4272
|
|
|
4225
4273
|
let delivered = null;
|
|
4226
|
-
if (
|
|
4227
|
-
|
|
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 });
|
|
4228
4279
|
delivered = 'doorbell';
|
|
4229
4280
|
} else if (
|
|
4230
4281
|
terminal.authorized(req.get('x-terminal-token')) &&
|
|
@@ -4284,6 +4335,13 @@ app.use('/api', (_req, res) => {
|
|
|
4284
4335
|
res.status(404).json({ error: 'Not found' });
|
|
4285
4336
|
});
|
|
4286
4337
|
|
|
4338
|
+
app.use((err, req, res, next) => {
|
|
4339
|
+
if (res.headersSent || !req.path.startsWith('/api/')) return next(err);
|
|
4340
|
+
const status = err.status || 500;
|
|
4341
|
+
if (status >= 500) console.error(`Error in ${req.method} ${req.path}:`, err);
|
|
4342
|
+
res.status(status).json({ error: status >= 500 ? 'Internal error' : err.message });
|
|
4343
|
+
});
|
|
4344
|
+
|
|
4287
4345
|
// Watch for file changes (chokidar handles non-existent paths)
|
|
4288
4346
|
const watcher = chokidar.watch(TASKS_DIR, {
|
|
4289
4347
|
persistent: true,
|
|
@@ -4498,8 +4556,10 @@ async function cleanupAgentActivity() {
|
|
|
4498
4556
|
const now = Date.now();
|
|
4499
4557
|
for (const entry of entries) {
|
|
4500
4558
|
if (!entry.isDirectory()) continue;
|
|
4559
|
+
const dirPath = path.join(AGENT_ACTIVITY_DIR, entry.name);
|
|
4560
|
+
// Map files are rewritten in place, so the dir mtime does not track their use.
|
|
4561
|
+
if (dirPath === TASK_MAPS_DIR) continue;
|
|
4501
4562
|
try {
|
|
4502
|
-
const dirPath = path.join(AGENT_ACTIVITY_DIR, entry.name);
|
|
4503
4563
|
const contents = await fs.readdir(dirPath);
|
|
4504
4564
|
const stat = await fs.stat(dirPath);
|
|
4505
4565
|
const age = now - stat.mtimeMs;
|
|
@@ -4533,8 +4593,9 @@ async function runRetention() {
|
|
|
4533
4593
|
const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
|
|
4534
4594
|
const contexts = await pruneContextStatus(CONTEXT_STATUS_DIR, opts);
|
|
4535
4595
|
const wts = worktrees.prune(scan?.dirs);
|
|
4536
|
-
|
|
4537
|
-
|
|
4596
|
+
const taskMaps = await pruneTaskMaps(TASK_MAPS_DIR, opts);
|
|
4597
|
+
if (markers || layouts || reviews || contexts || wts || taskMaps) {
|
|
4598
|
+
console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${reviews} reviews, ${contexts} context status files, ${wts} worktrees, ${taskMaps} task-list mappings`);
|
|
4538
4599
|
}
|
|
4539
4600
|
} catch (e) {
|
|
4540
4601
|
console.warn('[retention] failed:', e.message);
|
|
@@ -4561,7 +4622,7 @@ const PREWARM_SLICE_MS = 10;
|
|
|
4561
4622
|
// first list is sent, or after the fallback when no client asks.
|
|
4562
4623
|
const PREWARM_FALLBACK_MS = 5000;
|
|
4563
4624
|
const SESSION_CACHE_SAVE_MS = 30000;
|
|
4564
|
-
const persistSessionCache = () => saveSessionCache((data) =>
|
|
4625
|
+
const persistSessionCache = () => saveSessionCache((data) => writeJsonAtomicOrLog(SESSION_CACHE_FILE, data));
|
|
4565
4626
|
let prewarmStarted = false;
|
|
4566
4627
|
function startPrewarm() {
|
|
4567
4628
|
if (prewarmStarted) return;
|
|
@@ -4595,8 +4656,8 @@ async function prewarmCaches() {
|
|
|
4595
4656
|
const onReady = (actualPort) => {
|
|
4596
4657
|
boardPort = Number(actualPort);
|
|
4597
4658
|
terminal.setServerUrl(`http://127.0.0.1:${actualPort}`);
|
|
4598
|
-
// The port is configurable and falls back to a random one when taken, so the
|
|
4599
|
-
//
|
|
4659
|
+
// The port is configurable and falls back to a random one when taken, so the doorbell
|
|
4660
|
+
// mod cannot assume it -- publish the live one where it can read it.
|
|
4600
4661
|
writeServerInfo(actualPort);
|
|
4601
4662
|
writeTerminalToken(actualPort);
|
|
4602
4663
|
console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
|
|
@@ -4637,7 +4698,5 @@ async function prewarmCaches() {
|
|
|
4637
4698
|
throw err;
|
|
4638
4699
|
}
|
|
4639
4700
|
});
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
// #endregion
|
|
4643
4701
|
}
|
|
4702
|
+
// #endregion
|