claude-code-kanban 7.4.0 → 7.5.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/lib/panes.js +26 -5
- package/lib/parsers.js +15 -1
- package/lib/retention.js +25 -0
- package/lib/scratchpad-dir.js +19 -2
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +33 -18
- package/lib/task-dir.js +26 -0
- package/lib/terminal.js +1 -1
- 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 +80 -0
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +2 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -3
- package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +74 -0
- package/public/app.js +1019 -1001
- package/public/index.html +13 -31
- package/public/style.css +190 -29
- package/server.js +171 -159
- 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');
|
|
@@ -52,10 +52,11 @@ const { readLiveSessions, isPidAlive, isSessionLive } = require('./lib/live-sess
|
|
|
52
52
|
const { createProcStats } = require('./lib/proc-stats');
|
|
53
53
|
const { createDispatchRegistry } = require('./lib/dispatch');
|
|
54
54
|
const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
|
|
55
|
-
const { createDispatchedStore, scanTranscripts, pruneSessionDirs, pruneContextStatus, retentionMs } = require('./lib/retention');
|
|
55
|
+
const { createDispatchedStore, scanTranscripts, pruneSessionDirs, pruneContextStatus, pruneTaskMaps, retentionMs } = require('./lib/retention');
|
|
56
56
|
const { freshRateLimits } = require('./lib/rate-limits');
|
|
57
57
|
const { createWorktreeStore } = require('./lib/worktrees');
|
|
58
|
-
const { createScratchpadDirResolver } = require('./lib/scratchpad-dir');
|
|
58
|
+
const { createScratchpadDirResolver, scratchpadRoot } = require('./lib/scratchpad-dir');
|
|
59
|
+
const { readSettings } = require('./lib/claude-settings');
|
|
59
60
|
const { readGitBranch, sessionGitBranch } = require('./lib/git-branch');
|
|
60
61
|
const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
|
|
61
62
|
const { createPaneStore, isOwnOrigin } = require('./lib/panes');
|
|
@@ -63,6 +64,7 @@ const { probeFraming, parseOrigin } = require('./lib/frame-policy');
|
|
|
63
64
|
const { pickFolder } = require('./lib/folder-dialog');
|
|
64
65
|
const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
|
|
65
66
|
const { countTaskDir } = require('./lib/task-counts');
|
|
67
|
+
const { readTaskDir } = require('./lib/task-dir');
|
|
66
68
|
const { projectMatcher, normalizeProjectPath } = require('./public/project-match');
|
|
67
69
|
const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
|
|
68
70
|
|
|
@@ -115,13 +117,25 @@ const PANES_FILE = path.join(CCK_DIR, 'panes.json');
|
|
|
115
117
|
const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
|
|
116
118
|
const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
|
|
117
119
|
const SESSION_CACHE_FILE = path.join(CCK_DIR, 'session-cache.json');
|
|
118
|
-
|
|
120
|
+
// Every board on a config dir rewrites the whole file, so a test board points this elsewhere.
|
|
121
|
+
const TERMINALS_FILE = process.env.CCK_TERMINALS_FILE ? path.resolve(process.env.CCK_TERMINALS_FILE) : path.join(CCK_DIR, 'terminals.json');
|
|
119
122
|
// os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
|
|
120
123
|
const TEMP_ROOT = (() => {
|
|
121
124
|
try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
|
|
122
125
|
})();
|
|
123
126
|
// Harness-owned scratchpad root; the per-session dir under it is created lazily.
|
|
124
|
-
|
|
127
|
+
// Used only for a transcript that does not record its scratchpad dir. Settings come first:
|
|
128
|
+
// cck often runs under the hub or a tray, not in the shell where `claude` ran.
|
|
129
|
+
const SCRATCHPAD_ROOT = (() => {
|
|
130
|
+
const root = scratchpadRoot({
|
|
131
|
+
platform: process.platform,
|
|
132
|
+
overrides: [readSettings(path.join(CLAUDE_DIR, 'settings.json'))?.env?.CLAUDE_CODE_TMPDIR, process.env.CLAUDE_CODE_TMPDIR],
|
|
133
|
+
tmpdir: os.tmpdir(),
|
|
134
|
+
uid: process.getuid?.(),
|
|
135
|
+
});
|
|
136
|
+
// Claude Code resolves the root with realpath too: /private/tmp/claude-<uid> on macOS.
|
|
137
|
+
return realpathDeepest(root);
|
|
138
|
+
})();
|
|
125
139
|
|
|
126
140
|
// #endregion
|
|
127
141
|
|
|
@@ -136,25 +150,66 @@ function readPins() {
|
|
|
136
150
|
}
|
|
137
151
|
|
|
138
152
|
function writeJsonAtomic(file, obj, mode) {
|
|
153
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
154
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
139
155
|
try {
|
|
140
|
-
mkdirSync(path.dirname(file), { recursive: true });
|
|
141
|
-
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
142
156
|
writeFileSync(tmp, JSON.stringify(obj, null, 2), { encoding: 'utf8', mode });
|
|
143
157
|
renameSync(tmp, file);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
rmSync(tmp, { force: true });
|
|
160
|
+
throw e;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Claude Code reads task files while the board writes them, so it must never see half a file.
|
|
165
|
+
// On Windows a rename over a file another handle has open fails with EPERM or EBUSY until it closes.
|
|
166
|
+
let taskWriteSeq = 0;
|
|
167
|
+
async function writeTaskFile(file, task) {
|
|
168
|
+
const tmp = `${file}.${process.pid}.${++taskWriteSeq}.tmp`;
|
|
169
|
+
try {
|
|
170
|
+
await fs.writeFile(tmp, JSON.stringify(task, null, 2));
|
|
171
|
+
for (let attempt = 1; ; attempt++) {
|
|
172
|
+
try {
|
|
173
|
+
await fs.rename(tmp, file);
|
|
174
|
+
break;
|
|
175
|
+
} catch (e) {
|
|
176
|
+
if (attempt >= 10 || (e.code !== 'EPERM' && e.code !== 'EBUSY')) throw e;
|
|
177
|
+
await new Promise((r) => setTimeout(r, attempt * 10));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} catch (e) {
|
|
181
|
+
await fs.rm(tmp, { force: true });
|
|
182
|
+
throw e;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// For state that is also held in memory: a failed save is logged and the server goes on.
|
|
187
|
+
function writeJsonAtomicOrLog(file, obj, mode) {
|
|
188
|
+
try {
|
|
189
|
+
writeJsonAtomic(file, obj, mode);
|
|
144
190
|
} catch (e) {
|
|
145
191
|
console.error(`Failed to write ${path.basename(file)}:`, e.message);
|
|
146
192
|
}
|
|
147
193
|
}
|
|
148
194
|
|
|
195
|
+
function readJsonOrNull(file) {
|
|
196
|
+
try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const jsonFile = (file) => ({
|
|
200
|
+
load: () => readJsonOrNull(file),
|
|
201
|
+
save: (data) => writeJsonAtomicOrLog(file, data),
|
|
202
|
+
});
|
|
203
|
+
|
|
149
204
|
function writePins(pins) {
|
|
150
205
|
writeJsonAtomic(PINS_FILE, pins);
|
|
151
206
|
}
|
|
152
207
|
|
|
153
|
-
// Port discovery for out-of-process helpers (the
|
|
208
|
+
// Port discovery for out-of-process helpers (the plugin's doorbell mod, the CLI).
|
|
154
209
|
// The pid rides along so a reader can tell a live server from a file left behind by
|
|
155
210
|
// a crashed one.
|
|
156
211
|
function writeServerInfo(port) {
|
|
157
|
-
|
|
212
|
+
writeJsonAtomicOrLog(SERVER_INFO_FILE, { port, pid: process.pid });
|
|
158
213
|
}
|
|
159
214
|
|
|
160
215
|
// For `dispatch start`: a local process as the same user can already run claude itself,
|
|
@@ -172,14 +227,14 @@ function writeTerminalToken(port) {
|
|
|
172
227
|
if (pid !== null && !isPidAlive(pid)) try { unlinkSync(file); } catch (_) { /* already gone */ }
|
|
173
228
|
}
|
|
174
229
|
terminalTokenFile = path.join(TERMINAL_TOKENS_DIR, `${port}.json`);
|
|
175
|
-
|
|
230
|
+
writeJsonAtomicOrLog(terminalTokenFile,{ pid: process.pid, token: terminal.token }, 0o600);
|
|
176
231
|
}
|
|
177
232
|
|
|
178
233
|
function ownerPid(file) {
|
|
179
234
|
try { return JSON.parse(readFileSync(file, 'utf8')).pid ?? null; } catch (_) { return null; }
|
|
180
235
|
}
|
|
181
236
|
|
|
182
|
-
// A beacon that outlives its server sends the
|
|
237
|
+
// A beacon that outlives its server sends the doorbell mod and the CLI to a closed
|
|
183
238
|
// port. The hub spawns every sub-app on
|
|
184
239
|
// an ephemeral port, so a stale beacon never comes back on its own — drop ours on
|
|
185
240
|
// the way out. Only when the file is still ours: a newer server on the same config
|
|
@@ -255,9 +310,14 @@ function persistAgent(dir, agent) {
|
|
|
255
310
|
const CCK_CONFIG_FILE = path.join(CCK_DIR, 'config.json');
|
|
256
311
|
const LEGACY_APPROVALS_FILE = path.join(CCK_DIR, 'approvals.json');
|
|
257
312
|
const cckConfigCache = new Map();
|
|
313
|
+
const readCckConfig = () => JSON.parse(readFileSync(CCK_CONFIG_FILE, 'utf8'));
|
|
258
314
|
function approvalsConfig() {
|
|
259
315
|
return cachedByMtime(cckConfigCache, 'approvals', CCK_CONFIG_FILE,
|
|
260
|
-
() => approvalsFrom(
|
|
316
|
+
() => approvalsFrom(readCckConfig()), approvalsFrom(null));
|
|
317
|
+
}
|
|
318
|
+
function boardEventsEnabled() {
|
|
319
|
+
return cachedByMtime(cckConfigCache, 'boardEvents', CCK_CONFIG_FILE,
|
|
320
|
+
() => boardEventsOn(readCckConfig()), boardEventsOn(null));
|
|
261
321
|
}
|
|
262
322
|
|
|
263
323
|
// approvals.json predates config.json (and was opt-in). Fold it into
|
|
@@ -268,7 +328,7 @@ function migrateLegacyApprovalsConfig() {
|
|
|
268
328
|
const legacy = JSON.parse(readFileSync(LEGACY_APPROVALS_FILE, 'utf8'));
|
|
269
329
|
let cfg = {};
|
|
270
330
|
try { cfg = JSON.parse(readFileSync(CCK_CONFIG_FILE, 'utf8')) || {}; } catch { /* absent */ }
|
|
271
|
-
if (!cfg.approvals)
|
|
331
|
+
if (!cfg.approvals) writeJsonAtomicOrLog(CCK_CONFIG_FILE, { ...cfg, approvals: legacy });
|
|
272
332
|
unlinkSync(LEGACY_APPROVALS_FILE);
|
|
273
333
|
cckConfigCache.clear();
|
|
274
334
|
} catch (e) {
|
|
@@ -352,12 +412,7 @@ function getGitBranch(cwd) {
|
|
|
352
412
|
return branch;
|
|
353
413
|
}
|
|
354
414
|
|
|
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
|
-
});
|
|
415
|
+
const worktrees = createWorktreeStore(jsonFile(WORKTREES_FILE));
|
|
361
416
|
|
|
362
417
|
function getSessionLogStat(meta) {
|
|
363
418
|
if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
|
|
@@ -457,6 +512,11 @@ function resolveSessionId(sessionId) {
|
|
|
457
512
|
return (teamConfig?.leadSessionId) ? teamConfig.leadSessionId : sessionId;
|
|
458
513
|
}
|
|
459
514
|
|
|
515
|
+
function sessionMetaFor(sessionId) {
|
|
516
|
+
const metadata = loadSessionMetadata();
|
|
517
|
+
return metadata[sessionId] || metadata[resolveSessionId(sessionId)];
|
|
518
|
+
}
|
|
519
|
+
|
|
460
520
|
// Recent Claude Code releases auto-create a single-member "self-team" for every
|
|
461
521
|
// session: a teams/session-<id>/config.json whose only member is the "team-lead"
|
|
462
522
|
// (the session itself). These are not real multi-agent teams — surfacing them
|
|
@@ -600,8 +660,8 @@ app.param('name', (_req, res, next, val) => {
|
|
|
600
660
|
next();
|
|
601
661
|
});
|
|
602
662
|
|
|
603
|
-
// Parse JSON bodies
|
|
604
|
-
app.use(express.json());
|
|
663
|
+
// Parse JSON bodies. A review of 50 comments at the field caps is about 450 kB.
|
|
664
|
+
app.use(express.json({ limit: '1mb' }));
|
|
605
665
|
// #endregion
|
|
606
666
|
|
|
607
667
|
// #region STATIC
|
|
@@ -840,6 +900,7 @@ function refreshSessionMetadataPath(jsonlPath) {
|
|
|
840
900
|
if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
|
|
841
901
|
existing.permissionMode = info.permissionMode;
|
|
842
902
|
existing.cacheTtl = info.cacheTtl;
|
|
903
|
+
if (info.scratchpadDir) existing.scratchpadDir = info.scratchpadDir;
|
|
843
904
|
existing.lastReply = info.lastReply;
|
|
844
905
|
return true;
|
|
845
906
|
}
|
|
@@ -932,7 +993,8 @@ function loadSessionMetadata() {
|
|
|
932
993
|
compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null,
|
|
933
994
|
permissionMode: sessionInfo.permissionMode,
|
|
934
995
|
cacheTtl: sessionInfo.cacheTtl || null,
|
|
935
|
-
lastReply: sessionInfo.lastReply || null
|
|
996
|
+
lastReply: sessionInfo.lastReply || null,
|
|
997
|
+
scratchpadDir: sessionInfo.scratchpadDir || null
|
|
936
998
|
};
|
|
937
999
|
sessionIds.push(sessionId);
|
|
938
1000
|
}
|
|
@@ -1136,6 +1198,8 @@ function getSessionDisplayName(_sessionId, meta) {
|
|
|
1136
1198
|
const getScratchpadDir = createScratchpadDirResolver({
|
|
1137
1199
|
root: SCRATCHPAD_ROOT,
|
|
1138
1200
|
resolveWorktree: worktrees.resolve,
|
|
1201
|
+
// Transcripts record the 8.3 short form on Windows; path checks and the watcher need the long one.
|
|
1202
|
+
toLong: realpathDeepest,
|
|
1139
1203
|
});
|
|
1140
1204
|
|
|
1141
1205
|
function buildSessionObject(id, meta, overrides = {}) {
|
|
@@ -1707,25 +1771,9 @@ function addOwnerSessions(tasks, listIdOf) {
|
|
|
1707
1771
|
|
|
1708
1772
|
app.get('/api/sessions/:sessionId', async (req, res) => {
|
|
1709
1773
|
try {
|
|
1710
|
-
const sessionPath = taskDirFor(req.params.sessionId);
|
|
1711
|
-
|
|
1712
1774
|
// A session that never used the board has no task dir. That is the common case and
|
|
1713
1775
|
// 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
|
-
}
|
|
1776
|
+
const tasks = (await readTaskDir(taskDirFor(req.params.sessionId))).map(({ task }) => task);
|
|
1729
1777
|
|
|
1730
1778
|
// Sort by ID (numeric)
|
|
1731
1779
|
tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
|
|
@@ -1739,41 +1787,24 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
|
|
|
1739
1787
|
});
|
|
1740
1788
|
|
|
1741
1789
|
// API: Get combined tasks for a project (all sessions + shared task lists)
|
|
1742
|
-
app.get('/api/projects/:encodedPath/tasks', (req, res) => {
|
|
1790
|
+
app.get('/api/projects/:encodedPath/tasks', async (req, res) => {
|
|
1743
1791
|
try {
|
|
1744
1792
|
const projectPath = Buffer.from(req.params.encodedPath, 'base64').toString('utf8');
|
|
1745
1793
|
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
|
-
}
|
|
1794
|
+
const taskDirs = new Set(
|
|
1795
|
+
Object.entries(metadata)
|
|
1796
|
+
.filter(([, m]) => m.project === projectPath)
|
|
1797
|
+
.map(([id]) => taskDirFor(id)),
|
|
1798
|
+
);
|
|
1763
1799
|
|
|
1764
1800
|
const tasks = [];
|
|
1765
|
-
const seenKeys = new Set();
|
|
1766
1801
|
for (const dir of taskDirs) {
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
task._taskDir = path.basename(dir);
|
|
1774
|
-
tasks.push(task);
|
|
1775
|
-
}
|
|
1776
|
-
} catch (_) {}
|
|
1802
|
+
const seenIds = new Set();
|
|
1803
|
+
for (const { task } of await readTaskDir(dir)) {
|
|
1804
|
+
if (seenIds.has(task.id)) continue;
|
|
1805
|
+
seenIds.add(task.id);
|
|
1806
|
+
task._taskDir = path.basename(dir);
|
|
1807
|
+
tasks.push(task);
|
|
1777
1808
|
}
|
|
1778
1809
|
}
|
|
1779
1810
|
tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
|
|
@@ -1788,8 +1819,7 @@ app.get('/api/projects/:encodedPath/tasks', (req, res) => {
|
|
|
1788
1819
|
// API: Get session plan
|
|
1789
1820
|
app.get('/api/sessions/:sessionId/plan', async (req, res) => {
|
|
1790
1821
|
try {
|
|
1791
|
-
const
|
|
1792
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
1822
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
1793
1823
|
// Most sessions have no saved plan, and the info modal asks for one every time it
|
|
1794
1824
|
// opens, so "no plan" is a normal answer rather than a 404 in the console.
|
|
1795
1825
|
const slug = meta?.slug;
|
|
@@ -1808,8 +1838,7 @@ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
|
|
|
1808
1838
|
|
|
1809
1839
|
app.get('/api/sessions/:sessionId/loop', (req, res) => {
|
|
1810
1840
|
try {
|
|
1811
|
-
const
|
|
1812
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
1841
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
1813
1842
|
if (!meta?.jsonlPath) return res.json({ wakeups: [], crons: [] });
|
|
1814
1843
|
const state = refreshLoopInfoState(meta.jsonlPath);
|
|
1815
1844
|
const filtered = filterActiveLoopInfo(buildLoopInfoFromState(state));
|
|
@@ -1851,8 +1880,7 @@ function getArtifactLinks(jsonlPath) {
|
|
|
1851
1880
|
|
|
1852
1881
|
app.get('/api/sessions/:sessionId/artifacts', async (req, res) => {
|
|
1853
1882
|
try {
|
|
1854
|
-
const
|
|
1855
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
1883
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
1856
1884
|
if (!meta?.jsonlPath) return res.json({ artifacts: [] });
|
|
1857
1885
|
res.json({ artifacts: await getArtifactLinks(meta.jsonlPath) });
|
|
1858
1886
|
} catch (error) {
|
|
@@ -1956,8 +1984,7 @@ function getCreatedPads(meta) {
|
|
|
1956
1984
|
|
|
1957
1985
|
app.get('/api/sessions/:sessionId/pads', async (req, res) => {
|
|
1958
1986
|
try {
|
|
1959
|
-
const
|
|
1960
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
1987
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
1961
1988
|
if (!meta?.jsonlPath) return res.json({ pads: [] });
|
|
1962
1989
|
res.json({ pads: await getCreatedPads(meta) });
|
|
1963
1990
|
} catch (error) {
|
|
@@ -2290,8 +2317,7 @@ app.post('/api/sessions/:sessionId/workflows/:wfId/open', (req, res) => {
|
|
|
2290
2317
|
// API: Open session plan in VS Code
|
|
2291
2318
|
app.post('/api/sessions/:sessionId/plan/open', (req, res) => {
|
|
2292
2319
|
try {
|
|
2293
|
-
const
|
|
2294
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
2320
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
2295
2321
|
const slug = meta?.slug;
|
|
2296
2322
|
if (!slug) return res.status(404).json({ error: 'No plan found' });
|
|
2297
2323
|
|
|
@@ -3006,8 +3032,7 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages/stream', (req, res) =
|
|
|
3006
3032
|
async function sendSessionMessages(req, res) {
|
|
3007
3033
|
const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50);
|
|
3008
3034
|
const before = req.query.before || null;
|
|
3009
|
-
const
|
|
3010
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
3035
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
3011
3036
|
const jsonlPath = meta?.jsonlPath;
|
|
3012
3037
|
if (!jsonlPath) return res.json({ messages: [], hasMore: false, sessionId: req.params.sessionId });
|
|
3013
3038
|
let messages, hasMore;
|
|
@@ -3097,8 +3122,7 @@ async function sendSessionMessages(req, res) {
|
|
|
3097
3122
|
app.get('/api/sessions/:sessionId/messages', asyncRoute(sendSessionMessages));
|
|
3098
3123
|
|
|
3099
3124
|
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)];
|
|
3125
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
3102
3126
|
const jsonlPath = meta?.jsonlPath;
|
|
3103
3127
|
if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
|
|
3104
3128
|
const content = await readFullToolResult(jsonlPath, req.params.toolUseId);
|
|
@@ -3203,8 +3227,7 @@ async function buildToolStats(jsonlPath) {
|
|
|
3203
3227
|
}
|
|
3204
3228
|
|
|
3205
3229
|
app.get('/api/sessions/:sessionId/tool-stats', async (req, res) => {
|
|
3206
|
-
const
|
|
3207
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
3230
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
3208
3231
|
const jsonlPath = meta?.jsonlPath;
|
|
3209
3232
|
if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
|
|
3210
3233
|
try {
|
|
@@ -3217,8 +3240,7 @@ app.get('/api/sessions/:sessionId/tool-stats', async (req, res) => {
|
|
|
3217
3240
|
});
|
|
3218
3241
|
|
|
3219
3242
|
async function sendTranscriptImage(req, res, read) {
|
|
3220
|
-
const
|
|
3221
|
-
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
3243
|
+
const meta = sessionMetaFor(req.params.sessionId);
|
|
3222
3244
|
const jsonlPath = meta?.jsonlPath;
|
|
3223
3245
|
if (!jsonlPath) return res.status(404).end();
|
|
3224
3246
|
const img = await read(jsonlPath);
|
|
@@ -3306,10 +3328,7 @@ const terminal = createTerminalClient({
|
|
|
3306
3328
|
isDefaultDir: isDefaultClaudeDir(CLAUDE_DIR),
|
|
3307
3329
|
sessionsDir: SESSIONS_DIR,
|
|
3308
3330
|
token: process.env.CCK_TERMINAL_TOKEN,
|
|
3309
|
-
|
|
3310
|
-
try { return JSON.parse(readFileSync(TERMINALS_FILE, 'utf8')); } catch { return null; }
|
|
3311
|
-
},
|
|
3312
|
-
save: (data) => writeJsonAtomic(TERMINALS_FILE, data),
|
|
3331
|
+
...jsonFile(TERMINALS_FILE),
|
|
3313
3332
|
// The project, not the last cwd: `claude --resume` finds a session under the
|
|
3314
3333
|
// project dir it started in, and cwd drifts into subdirectories.
|
|
3315
3334
|
resolveCwd: resolveSessionFolder,
|
|
@@ -3396,30 +3415,17 @@ app.get('/vendor/xterm/:file', (req, res) => {
|
|
|
3396
3415
|
// #endregion
|
|
3397
3416
|
|
|
3398
3417
|
// #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
|
-
});
|
|
3418
|
+
const dispatched = createDispatchedStore(jsonFile(DISPATCHED_FILE));
|
|
3405
3419
|
|
|
3406
3420
|
const dispatches = createDispatchRegistry();
|
|
3407
3421
|
|
|
3408
3422
|
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),
|
|
3423
|
+
...jsonFile(DISPATCH_GROUPS_FILE),
|
|
3413
3424
|
isAlive: (id) => terminal.isRunning(id) || isSessionLive(loadLiveSessions(), id),
|
|
3414
3425
|
pinnedIds: () => new Set(Object.keys(readPins())),
|
|
3415
3426
|
});
|
|
3416
3427
|
|
|
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
|
-
});
|
|
3428
|
+
const linkedDocs = createLinkedDocStore(jsonFile(LINKED_DOCS_FILE));
|
|
3423
3429
|
|
|
3424
3430
|
// The board places a session from these alone, so it never has to move it later.
|
|
3425
3431
|
function withDispatchPlacement(sessions) {
|
|
@@ -3473,14 +3479,13 @@ app.get('/api/tasks/all', async (_req, res) => {
|
|
|
3473
3479
|
|
|
3474
3480
|
const metadata = loadSessionMetadata();
|
|
3475
3481
|
const { listToSessions } = loadAllTaskMaps();
|
|
3476
|
-
const sessionDirs =
|
|
3482
|
+
const sessionDirs = (await fs.readdir(TASKS_DIR, { withFileTypes: true }))
|
|
3477
3483
|
.filter(d => d.isDirectory());
|
|
3484
|
+
const dirTasks = await Promise.all(sessionDirs.map((d) => readTaskDir(path.join(TASKS_DIR, d.name))));
|
|
3478
3485
|
|
|
3479
3486
|
const allTasks = [];
|
|
3480
3487
|
|
|
3481
|
-
|
|
3482
|
-
const sessionPath = path.join(TASKS_DIR, sessionDir.name);
|
|
3483
|
-
const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
|
|
3488
|
+
sessionDirs.forEach((sessionDir, i) => {
|
|
3484
3489
|
const meta = metadata[sessionDir.name] || {};
|
|
3485
3490
|
|
|
3486
3491
|
// For custom task list directories (non-UUID dirs), resolve project from the
|
|
@@ -3496,20 +3501,15 @@ app.get('/api/tasks/all', async (_req, res) => {
|
|
|
3496
3501
|
}
|
|
3497
3502
|
}
|
|
3498
3503
|
|
|
3499
|
-
for (const
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
project
|
|
3507
|
-
});
|
|
3508
|
-
} catch {
|
|
3509
|
-
// Skip invalid files
|
|
3510
|
-
}
|
|
3504
|
+
for (const { task } of dirTasks[i]) {
|
|
3505
|
+
allTasks.push({
|
|
3506
|
+
...task,
|
|
3507
|
+
sessionId: sessionDir.name,
|
|
3508
|
+
sessionName: getSessionDisplayName(sessionDir.name, meta),
|
|
3509
|
+
project
|
|
3510
|
+
});
|
|
3511
3511
|
}
|
|
3512
|
-
}
|
|
3512
|
+
});
|
|
3513
3513
|
|
|
3514
3514
|
res.json(allTasks);
|
|
3515
3515
|
} catch (error) {
|
|
@@ -3519,6 +3519,7 @@ app.get('/api/tasks/all', async (_req, res) => {
|
|
|
3519
3519
|
});
|
|
3520
3520
|
|
|
3521
3521
|
const {
|
|
3522
|
+
boardEventsOn,
|
|
3522
3523
|
enqueueSessionEvent,
|
|
3523
3524
|
formatReviewSubmitted,
|
|
3524
3525
|
formatTaskMoved,
|
|
@@ -3557,7 +3558,7 @@ app.post('/api/tasks/:sessionId', async (req, res) => {
|
|
|
3557
3558
|
|
|
3558
3559
|
// No doorbell here, unlike a move: the user typed this task, so telling their session
|
|
3559
3560
|
// about it would only repeat what they just said. Dragging it to In Progress rings.
|
|
3560
|
-
await
|
|
3561
|
+
await writeTaskFile(path.join(sessionDir, `${id}.json`), task);
|
|
3561
3562
|
res.json({ success: true, task });
|
|
3562
3563
|
} catch (error) {
|
|
3563
3564
|
console.error('Error creating task:', error);
|
|
@@ -3585,14 +3586,14 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
3585
3586
|
if (description !== undefined) task.description = description;
|
|
3586
3587
|
if (req.body.status !== undefined) task.status = req.body.status;
|
|
3587
3588
|
|
|
3588
|
-
await
|
|
3589
|
+
await writeTaskFile(taskPath, task);
|
|
3589
3590
|
|
|
3590
3591
|
// Ring the session only for a move. The direction has to ride in the line because
|
|
3591
3592
|
// the write above destroyed the old status -- nothing downstream can recover it, and
|
|
3592
3593
|
// which way a task moved is what decides whether to start work or stop it.
|
|
3593
|
-
if (task.status !== prevStatus) {
|
|
3594
|
+
if (task.status !== prevStatus && boardEventsEnabled()) {
|
|
3594
3595
|
// The route param is a task *directory*, which for a shared list or team board is
|
|
3595
|
-
// not a session id -- and the
|
|
3596
|
+
// not a session id -- and the doorbell polls with its own session id, so an unresolved
|
|
3596
3597
|
// name would queue the line where nobody drains it.
|
|
3597
3598
|
const line = formatTaskMoved(taskId, prevStatus, task);
|
|
3598
3599
|
for (const sid of resolveSessionsForTaskDir(sessionId)) enqueueSessionEvent(sid, line);
|
|
@@ -3608,21 +3609,7 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
3608
3609
|
// A deleted task can't block anything, so drop the dangling reference instead of
|
|
3609
3610
|
// refusing the delete -- a stale blockedBy id would pin the other task as BLOCKED forever.
|
|
3610
3611
|
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
|
-
}
|
|
3612
|
+
const tasks = await readTaskDir(dir);
|
|
3626
3613
|
const doomed = tasks.filter((t) => shouldDelete(t));
|
|
3627
3614
|
const doomedIds = new Set(doomed.map(({ task }) => task.id));
|
|
3628
3615
|
const strip = (ids) => ids?.filter((id) => !doomedIds.has(id));
|
|
@@ -3635,7 +3622,7 @@ async function deleteTasks(dir, shouldDelete) {
|
|
|
3635
3622
|
task.blockedBy = strip(task.blockedBy);
|
|
3636
3623
|
task.blocks = strip(task.blocks);
|
|
3637
3624
|
if (JSON.stringify(task) === before) return null;
|
|
3638
|
-
return
|
|
3625
|
+
return writeTaskFile(path.join(dir, file), task);
|
|
3639
3626
|
}),
|
|
3640
3627
|
);
|
|
3641
3628
|
// Delete the task file
|
|
@@ -3831,6 +3818,7 @@ function resolvePreviewPath(rawPath, base) {
|
|
|
3831
3818
|
app.post('/api/preview', async (req, res) => {
|
|
3832
3819
|
try {
|
|
3833
3820
|
const { path: filePath, sessionId, base } = req.body || {};
|
|
3821
|
+
if (sessionId && !isSafeId(sessionId)) return res.status(400).json({ error: 'Invalid session ID' });
|
|
3834
3822
|
const abs = resolvePreviewPath(filePath, base);
|
|
3835
3823
|
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
3836
3824
|
// Validate here so the CLI still gets 400/404, but broadcast the path only —
|
|
@@ -3852,7 +3840,8 @@ app.post('/api/preview', async (req, res) => {
|
|
|
3852
3840
|
app.post('/api/document/link', async (req, res) => {
|
|
3853
3841
|
try {
|
|
3854
3842
|
const { path: filePath, sessionId, unlink, open } = req.body || {};
|
|
3855
|
-
if (
|
|
3843
|
+
if (!sessionId) return res.status(400).json({ error: 'sessionId is required' });
|
|
3844
|
+
if (!isSafeId(sessionId)) return res.status(400).json({ error: 'Invalid session ID' });
|
|
3856
3845
|
const url = linkUrl(filePath);
|
|
3857
3846
|
const abs = url || resolvePreviewPath(filePath);
|
|
3858
3847
|
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
@@ -4026,10 +4015,10 @@ const panes = createPaneStore({
|
|
|
4026
4015
|
const stamp = panesFileStamp();
|
|
4027
4016
|
if (stamp === panesStamp) return undefined;
|
|
4028
4017
|
panesStamp = stamp;
|
|
4029
|
-
|
|
4018
|
+
return readJsonOrNull(PANES_FILE);
|
|
4030
4019
|
},
|
|
4031
4020
|
save: (data) => {
|
|
4032
|
-
|
|
4021
|
+
writeJsonAtomicOrLog(PANES_FILE, data);
|
|
4033
4022
|
panesStamp = panesFileStamp();
|
|
4034
4023
|
},
|
|
4035
4024
|
onChange: (sessionId, layout) => {
|
|
@@ -4103,9 +4092,11 @@ app.get('/api/panes/:sessionId', (req, res) => {
|
|
|
4103
4092
|
|
|
4104
4093
|
app.post('/api/panes/:sessionId', async (req, res) => {
|
|
4105
4094
|
try {
|
|
4106
|
-
const { target, base, title } = req.body || {};
|
|
4095
|
+
const { target, base, title, kind } = req.body || {};
|
|
4107
4096
|
if (typeof target !== 'string' || !target) throw previewError(400, 'target is required', 'bad_target');
|
|
4108
|
-
|
|
4097
|
+
// A message target is a session log message id, which the board resolves when it shows the pane.
|
|
4098
|
+
const resolved = kind === 'message' ? { kind, target } : await resolvePaneTarget(target, base);
|
|
4099
|
+
res.json(panes.add(req.params.sessionId, { ...resolved, title }));
|
|
4109
4100
|
} catch (error) {
|
|
4110
4101
|
paneRouteError(res, error, 'POST /api/panes');
|
|
4111
4102
|
}
|
|
@@ -4140,6 +4131,19 @@ app.get('/api/panes/:sessionId/:paneId/framing', async (req, res) => {
|
|
|
4140
4131
|
}
|
|
4141
4132
|
});
|
|
4142
4133
|
|
|
4134
|
+
app.patch('/api/panes/:sessionId/:paneId', (req, res) => {
|
|
4135
|
+
try {
|
|
4136
|
+
const { sessionId, paneId } = req.params;
|
|
4137
|
+
const title = req.body?.title;
|
|
4138
|
+
if (typeof title !== 'string') throw previewError(400, 'title must be a string', 'bad_title');
|
|
4139
|
+
const layout = panes.rename(sessionId, paneId, title);
|
|
4140
|
+
if (!layout) throw previewError(404, `No pane ${paneId} in session ${sessionId}`, 'no_pane');
|
|
4141
|
+
res.json({ layout });
|
|
4142
|
+
} catch (error) {
|
|
4143
|
+
paneRouteError(res, error, 'PATCH /api/panes/:paneId');
|
|
4144
|
+
}
|
|
4145
|
+
});
|
|
4146
|
+
|
|
4143
4147
|
app.delete('/api/panes/:sessionId/:paneId', (req, res) => {
|
|
4144
4148
|
const { sessionId, paneId } = req.params;
|
|
4145
4149
|
const layout = panes.remove(sessionId, paneId);
|
|
@@ -4223,7 +4227,7 @@ app.post('/api/sessions/:sessionId/review', async (req, res) => {
|
|
|
4223
4227
|
await fs.writeFile(file, markdown);
|
|
4224
4228
|
|
|
4225
4229
|
let delivered = null;
|
|
4226
|
-
if (hasSessionListener(sessionId)) {
|
|
4230
|
+
if (boardEventsEnabled() && hasSessionListener(sessionId)) {
|
|
4227
4231
|
enqueueSessionEvent(sessionId, formatReviewSubmitted(src.items.length, src.label, file));
|
|
4228
4232
|
delivered = 'doorbell';
|
|
4229
4233
|
} else if (
|
|
@@ -4284,6 +4288,13 @@ app.use('/api', (_req, res) => {
|
|
|
4284
4288
|
res.status(404).json({ error: 'Not found' });
|
|
4285
4289
|
});
|
|
4286
4290
|
|
|
4291
|
+
app.use((err, req, res, next) => {
|
|
4292
|
+
if (res.headersSent || !req.path.startsWith('/api/')) return next(err);
|
|
4293
|
+
const status = err.status || 500;
|
|
4294
|
+
if (status >= 500) console.error(`Error in ${req.method} ${req.path}:`, err);
|
|
4295
|
+
res.status(status).json({ error: status >= 500 ? 'Internal error' : err.message });
|
|
4296
|
+
});
|
|
4297
|
+
|
|
4287
4298
|
// Watch for file changes (chokidar handles non-existent paths)
|
|
4288
4299
|
const watcher = chokidar.watch(TASKS_DIR, {
|
|
4289
4300
|
persistent: true,
|
|
@@ -4498,8 +4509,10 @@ async function cleanupAgentActivity() {
|
|
|
4498
4509
|
const now = Date.now();
|
|
4499
4510
|
for (const entry of entries) {
|
|
4500
4511
|
if (!entry.isDirectory()) continue;
|
|
4512
|
+
const dirPath = path.join(AGENT_ACTIVITY_DIR, entry.name);
|
|
4513
|
+
// Map files are rewritten in place, so the dir mtime does not track their use.
|
|
4514
|
+
if (dirPath === TASK_MAPS_DIR) continue;
|
|
4501
4515
|
try {
|
|
4502
|
-
const dirPath = path.join(AGENT_ACTIVITY_DIR, entry.name);
|
|
4503
4516
|
const contents = await fs.readdir(dirPath);
|
|
4504
4517
|
const stat = await fs.stat(dirPath);
|
|
4505
4518
|
const age = now - stat.mtimeMs;
|
|
@@ -4533,8 +4546,9 @@ async function runRetention() {
|
|
|
4533
4546
|
const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
|
|
4534
4547
|
const contexts = await pruneContextStatus(CONTEXT_STATUS_DIR, opts);
|
|
4535
4548
|
const wts = worktrees.prune(scan?.dirs);
|
|
4536
|
-
|
|
4537
|
-
|
|
4549
|
+
const taskMaps = await pruneTaskMaps(TASK_MAPS_DIR, opts);
|
|
4550
|
+
if (markers || layouts || reviews || contexts || wts || taskMaps) {
|
|
4551
|
+
console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${reviews} reviews, ${contexts} context status files, ${wts} worktrees, ${taskMaps} task-list mappings`);
|
|
4538
4552
|
}
|
|
4539
4553
|
} catch (e) {
|
|
4540
4554
|
console.warn('[retention] failed:', e.message);
|
|
@@ -4561,7 +4575,7 @@ const PREWARM_SLICE_MS = 10;
|
|
|
4561
4575
|
// first list is sent, or after the fallback when no client asks.
|
|
4562
4576
|
const PREWARM_FALLBACK_MS = 5000;
|
|
4563
4577
|
const SESSION_CACHE_SAVE_MS = 30000;
|
|
4564
|
-
const persistSessionCache = () => saveSessionCache((data) =>
|
|
4578
|
+
const persistSessionCache = () => saveSessionCache((data) => writeJsonAtomicOrLog(SESSION_CACHE_FILE, data));
|
|
4565
4579
|
let prewarmStarted = false;
|
|
4566
4580
|
function startPrewarm() {
|
|
4567
4581
|
if (prewarmStarted) return;
|
|
@@ -4595,8 +4609,8 @@ async function prewarmCaches() {
|
|
|
4595
4609
|
const onReady = (actualPort) => {
|
|
4596
4610
|
boardPort = Number(actualPort);
|
|
4597
4611
|
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
|
-
//
|
|
4612
|
+
// The port is configurable and falls back to a random one when taken, so the doorbell
|
|
4613
|
+
// mod cannot assume it -- publish the live one where it can read it.
|
|
4600
4614
|
writeServerInfo(actualPort);
|
|
4601
4615
|
writeTerminalToken(actualPort);
|
|
4602
4616
|
console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
|
|
@@ -4637,7 +4651,5 @@ async function prewarmCaches() {
|
|
|
4637
4651
|
throw err;
|
|
4638
4652
|
}
|
|
4639
4653
|
});
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
// #endregion
|
|
4643
4654
|
}
|
|
4655
|
+
// #endregion
|