claude-code-kanban 4.29.0 → 4.31.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 -4
- package/cli.js +406 -75
- package/install.js +9 -9
- package/lib/approvals.js +2 -2
- package/lib/contain.js +2 -2
- package/lib/dispatch-groups.js +28 -11
- package/lib/dispatch.js +1 -0
- package/lib/file-url.js +1 -1
- package/lib/inline-assets.js +2 -2
- package/lib/linked-docs.js +58 -0
- package/lib/net-guard.js +1 -1
- package/lib/open-editor.js +5 -3
- package/lib/parent-cache.js +58 -0
- package/lib/parsers.js +175 -168
- package/lib/plugin-status.js +38 -0
- package/lib/session-cache.js +34 -0
- package/lib/session-events.js +2 -1
- package/lib/task-counts.js +90 -0
- package/lib/terminal.js +9 -4
- package/package.json +2 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +3 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +16 -46
- package/public/app.js +817 -376
- package/public/index.html +51 -25
- package/public/link-url.js +15 -0
- package/public/project-match.js +27 -0
- package/public/style.css +385 -103
- package/public/sw.js +1 -1
- package/public/themes.css +49 -0
- package/server.js +185 -86
- package/skill-guides/dispatch.md +5 -4
package/server.js
CHANGED
|
@@ -15,6 +15,7 @@ const { createNetGuard } = require('./lib/net-guard');
|
|
|
15
15
|
const { isContained } = require('./lib/contain');
|
|
16
16
|
const { resolveScratchSubdir, listScratchDir } = require('./lib/scratch-files');
|
|
17
17
|
const { fileUrlToPath } = require('./lib/file-url');
|
|
18
|
+
const { pluginStatus } = require('./lib/plugin-status');
|
|
18
19
|
|
|
19
20
|
const {
|
|
20
21
|
readRecentMessages: _readRecentMessagesUncached,
|
|
@@ -41,7 +42,12 @@ const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = requ
|
|
|
41
42
|
const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
|
|
42
43
|
const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName } = require('./lib/dispatch');
|
|
43
44
|
const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
|
|
45
|
+
const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
|
|
44
46
|
const { pickFolder } = require('./lib/folder-dialog');
|
|
47
|
+
const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
|
|
48
|
+
const { countTaskDir } = require('./lib/task-counts');
|
|
49
|
+
const { projectMatcher } = require('./public/project-match');
|
|
50
|
+
const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
|
|
45
51
|
|
|
46
52
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
47
53
|
const { runInstall, runUninstall } = require("./install");
|
|
@@ -49,10 +55,12 @@ if (process.argv.includes("--install") || process.argv.includes("--uninstall"))
|
|
|
49
55
|
(process.argv.includes("--install") ? runInstall({ pluginOnly }) : runUninstall())
|
|
50
56
|
.then(() => process.exit(0))
|
|
51
57
|
.catch(e => { console.error(e.message); process.exit(1); });
|
|
52
|
-
|
|
58
|
+
} else if (!require("./cli").runCli(process.argv)) {
|
|
59
|
+
startServer();
|
|
53
60
|
}
|
|
54
|
-
if (require("./cli").runCli(process.argv)) return;
|
|
55
61
|
|
|
62
|
+
// Not indented: the formatter is off for this file, and a reindent would rewrite every line.
|
|
63
|
+
function startServer() {
|
|
56
64
|
|
|
57
65
|
const app = express();
|
|
58
66
|
const PORT = process.env.PORT || 3541;
|
|
@@ -82,10 +90,16 @@ const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
|
|
|
82
90
|
const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
|
|
83
91
|
const PINS_FILE = path.join(CCK_DIR, 'pins.json');
|
|
84
92
|
const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
|
|
93
|
+
const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
|
|
85
94
|
const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
|
|
86
|
-
const
|
|
95
|
+
const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
|
|
96
|
+
const SESSION_CACHE_FILE = path.join(CCK_DIR, 'session-cache.json');
|
|
97
|
+
// os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
|
|
98
|
+
const TEMP_ROOT = (() => {
|
|
99
|
+
try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
|
|
100
|
+
})();
|
|
87
101
|
// Harness-owned scratchpad root; the per-session dir under it is created lazily.
|
|
88
|
-
const SCRATCHPAD_ROOT = path.join(
|
|
102
|
+
const SCRATCHPAD_ROOT = path.join(TEMP_ROOT, 'claude');
|
|
89
103
|
|
|
90
104
|
// #endregion
|
|
91
105
|
|
|
@@ -101,7 +115,7 @@ function readPins() {
|
|
|
101
115
|
|
|
102
116
|
function writeJsonAtomic(file, obj, mode) {
|
|
103
117
|
try {
|
|
104
|
-
mkdirSync(
|
|
118
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
105
119
|
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
106
120
|
writeFileSync(tmp, JSON.stringify(obj, null, 2), { encoding: 'utf8', mode });
|
|
107
121
|
renameSync(tmp, file);
|
|
@@ -119,37 +133,46 @@ function writePins(pins) {
|
|
|
119
133
|
// a crashed one.
|
|
120
134
|
function writeServerInfo(port) {
|
|
121
135
|
writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
|
|
122
|
-
writeTerminalToken();
|
|
123
136
|
}
|
|
124
137
|
|
|
125
138
|
// For `dispatch start`: a local process as the same user can already run claude itself,
|
|
126
139
|
// so handing it the token adds little. A browser still cannot read the file.
|
|
127
|
-
|
|
140
|
+
// One file per port: two boards on one config dir each keep a token, so the CLI can
|
|
141
|
+
// reach the board CCK_URL names even when the other one owns server.json.
|
|
142
|
+
// The sweep also drops terminal-token.json, the single file that older versions wrote.
|
|
143
|
+
let terminalTokenFile = null;
|
|
144
|
+
function writeTerminalToken(port) {
|
|
128
145
|
if (terminal.unavailableReason()) return;
|
|
129
|
-
|
|
146
|
+
let stale = [path.join(CCK_DIR, 'terminal-token.json')];
|
|
147
|
+
try { stale = stale.concat(readdirSync(TERMINAL_TOKENS_DIR).map(name => path.join(TERMINAL_TOKENS_DIR, name))); } catch (_) { /* no dir yet */ }
|
|
148
|
+
for (const file of stale) {
|
|
149
|
+
const pid = ownerPid(file);
|
|
150
|
+
if (pid !== null && !isPidAlive(pid)) try { unlinkSync(file); } catch (_) { /* already gone */ }
|
|
151
|
+
}
|
|
152
|
+
terminalTokenFile = path.join(TERMINAL_TOKENS_DIR, `${port}.json`);
|
|
153
|
+
writeJsonAtomic(terminalTokenFile, { pid: process.pid, token: terminal.token }, 0o600);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function ownerPid(file) {
|
|
157
|
+
try { return JSON.parse(readFileSync(file, 'utf8')).pid ?? null; } catch (_) { return null; }
|
|
130
158
|
}
|
|
131
159
|
|
|
132
160
|
// A beacon that outlives its server disarms UI approvals silently: approval-gate.sh
|
|
133
161
|
// probes the port, finds it closed and never waits. The hub spawns every sub-app on
|
|
134
162
|
// an ephemeral port, so a stale beacon never comes back on its own — drop ours on
|
|
135
163
|
// the way out. Only when the file is still ours: a newer server on the same config
|
|
136
|
-
// dir has already claimed it.
|
|
164
|
+
// dir has already claimed it. The terminal token file goes the same way.
|
|
137
165
|
function removeServerInfo() {
|
|
138
|
-
for (const file of [SERVER_INFO_FILE,
|
|
139
|
-
try {
|
|
140
|
-
const info = JSON.parse(readFileSync(file, 'utf8'));
|
|
141
|
-
if (info.pid === process.pid) unlinkSync(file);
|
|
142
|
-
} catch (_) { /* gone, unreadable, or not ours */ }
|
|
166
|
+
for (const file of [SERVER_INFO_FILE, terminalTokenFile]) {
|
|
167
|
+
if (file && ownerPid(file) === process.pid) try { unlinkSync(file); } catch (_) { /* already gone */ }
|
|
143
168
|
}
|
|
144
169
|
}
|
|
145
170
|
|
|
146
171
|
// A second server on the same config dir (a test hub, say) takes the beacon and removes it on
|
|
147
172
|
// exit, which leaves this live server undiscoverable. A newer live owner keeps it.
|
|
148
173
|
function reclaimServerInfo(port) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (pid === process.pid || isPidAlive(pid)) return;
|
|
152
|
-
} catch (_) { /* missing or unreadable beacon */ }
|
|
174
|
+
const pid = ownerPid(SERVER_INFO_FILE);
|
|
175
|
+
if (pid !== null && isPidAlive(pid)) return;
|
|
153
176
|
writeServerInfo(port);
|
|
154
177
|
}
|
|
155
178
|
|
|
@@ -718,29 +741,13 @@ function getTaskCounts(sessionPath) {
|
|
|
718
741
|
const cached = taskCountsCache.get(sessionPath);
|
|
719
742
|
if (cached) return cached;
|
|
720
743
|
|
|
721
|
-
const
|
|
722
|
-
let completed = 0, inProgress = 0, pending = 0, newestTaskMtime = null;
|
|
744
|
+
const { completed, inProgress, pending, newestTaskMtime } = countTaskDir(sessionPath);
|
|
723
745
|
// Directory mtime bumps when task files are added/removed, so it stays fresh even for an
|
|
724
746
|
// emptied dir (task list closed) that has no files left to date. Task-file mtime alone would
|
|
725
747
|
// report 0 for such a dir and lose "latest wins" to a stale prior-boot dir.
|
|
726
748
|
let dirMtime = 0;
|
|
727
749
|
try { dirMtime = statSync(sessionPath).mtimeMs; } catch (_) {}
|
|
728
750
|
|
|
729
|
-
for (const file of taskFiles) {
|
|
730
|
-
try {
|
|
731
|
-
const taskPath = path.join(sessionPath, file);
|
|
732
|
-
const task = JSON.parse(readFileSync(taskPath, 'utf8'));
|
|
733
|
-
if (task.metadata?._internal) continue;
|
|
734
|
-
if (task.status === 'completed') completed++;
|
|
735
|
-
else if (task.status === 'in_progress') inProgress++;
|
|
736
|
-
else pending++;
|
|
737
|
-
const taskStat = statSync(taskPath);
|
|
738
|
-
if (!newestTaskMtime || taskStat.mtime > newestTaskMtime) {
|
|
739
|
-
newestTaskMtime = taskStat.mtime;
|
|
740
|
-
}
|
|
741
|
-
} catch { /* skip invalid */ }
|
|
742
|
-
}
|
|
743
|
-
|
|
744
751
|
const taskCount = completed + inProgress + pending;
|
|
745
752
|
const result = { taskCount, completed, inProgress, pending, newestTaskMtime, dirMtime };
|
|
746
753
|
taskCountsCache.set(sessionPath, result);
|
|
@@ -823,8 +830,6 @@ function refreshSessionMetadataPath(jsonlPath) {
|
|
|
823
830
|
if (info.cwd) existing.cwd = info.cwd;
|
|
824
831
|
if (info.gitBranch) existing.gitBranch = info.gitBranch;
|
|
825
832
|
if (info.customTitle) existing.customTitle = info.customTitle;
|
|
826
|
-
// Direct assign (not guarded) so a /goal clear propagates as null.
|
|
827
|
-
existing.goal = info.goal || null;
|
|
828
833
|
if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
|
|
829
834
|
if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
|
|
830
835
|
return true;
|
|
@@ -911,7 +916,6 @@ function loadSessionMetadata() {
|
|
|
911
916
|
cwd: sessionInfo.cwd || null,
|
|
912
917
|
gitBranch: sessionInfo.gitBranch || null,
|
|
913
918
|
customTitle: sessionInfo.customTitle || null,
|
|
914
|
-
goal: sessionInfo.goal || null,
|
|
915
919
|
jsonlPath: jsonlPath,
|
|
916
920
|
logicalParentUuid: sessionInfo.logicalParentUuid || null,
|
|
917
921
|
compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null
|
|
@@ -1155,7 +1159,6 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
1155
1159
|
gitBranch: resolveSessionGitBranch(meta),
|
|
1156
1160
|
worktree: resolveWorktree(meta.project),
|
|
1157
1161
|
customTitle: meta.customTitle || null,
|
|
1158
|
-
goal: meta.goal || null,
|
|
1159
1162
|
taskCount: 0,
|
|
1160
1163
|
completed: 0,
|
|
1161
1164
|
inProgress: 0,
|
|
@@ -1177,7 +1180,6 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
1177
1180
|
contextStatus: getContextStatus(id, meta),
|
|
1178
1181
|
...getPlanInfo(meta.slug),
|
|
1179
1182
|
...getWorkflowInfoSummary(id),
|
|
1180
|
-
loopInfo: getLoopInfoSummary(meta),
|
|
1181
1183
|
...overrides,
|
|
1182
1184
|
// Remove internal-only field
|
|
1183
1185
|
_logStat: undefined,
|
|
@@ -1551,46 +1553,83 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1551
1553
|
// The client sends the session it currently has open, which it cannot render at all
|
|
1552
1554
|
// if the row is missing — that one is not a preference.
|
|
1553
1555
|
if (projectFilter) {
|
|
1554
|
-
|
|
1556
|
+
const matches = projectMatcher(String(projectFilter));
|
|
1557
|
+
sessions = sessions.filter(s => matches(s.project) || includeIds.has(s.id));
|
|
1558
|
+
}
|
|
1559
|
+
// The sidebar's 24h filter: projects with any transcript written in the window, so an older
|
|
1560
|
+
// session of such a project stays in.
|
|
1561
|
+
const recentHours = Number(req.query.recentHours);
|
|
1562
|
+
if (recentHours > 0) {
|
|
1563
|
+
const cutoff = Date.now() - recentHours * 3600 * 1000;
|
|
1564
|
+
const activity = projectActivity();
|
|
1565
|
+
sessions = sessions.filter(s => (activity.get(s.project) || 0) > cutoff || includeIds.has(s.id));
|
|
1555
1566
|
}
|
|
1556
1567
|
|
|
1568
|
+
const paged = limit !== null && limit > 0;
|
|
1569
|
+
// The sidebar loads the next page on scroll while this is true. A header, so the body stays a plain array.
|
|
1570
|
+
res.setHeader('X-Has-More', String(paged && sessions.length > limit));
|
|
1557
1571
|
// Apply limit if specified, but always include pinned sessions
|
|
1558
|
-
if (
|
|
1572
|
+
if (paged) {
|
|
1559
1573
|
const top = sessions.slice(0, limit);
|
|
1560
1574
|
const topIds = new Set(top.map(s => s.id));
|
|
1561
1575
|
const missingPinned = sessions.filter(s => pinnedIds.has(s.id) && !topIds.has(s.id));
|
|
1562
1576
|
sessions = [...top, ...missingPinned];
|
|
1563
1577
|
}
|
|
1564
1578
|
|
|
1579
|
+
// Loop info can mean a full read of the transcript, so only the rows sent pay for it.
|
|
1580
|
+
for (const s of sessions) s.loopInfo = getLoopInfoSummary(s);
|
|
1581
|
+
|
|
1565
1582
|
res.json(withDispatchPlacement(sessions));
|
|
1583
|
+
startPrewarm();
|
|
1566
1584
|
} catch (error) {
|
|
1567
1585
|
console.error('Error listing sessions:', error);
|
|
1568
1586
|
res.status(500).json({ error: 'Failed to list sessions' });
|
|
1569
1587
|
}
|
|
1570
1588
|
});
|
|
1571
1589
|
|
|
1572
|
-
//
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1590
|
+
// Ids of sessions whose id or name contains q, from any transcript, newest first. The client
|
|
1591
|
+
// gets the rows through `/api/sessions?include=`. A linear scan is enough: ~4 ms for 10k ids,
|
|
1592
|
+
// plus one stat per hit for the sort.
|
|
1593
|
+
const SESSION_SEARCH_MIN = 3;
|
|
1594
|
+
const SESSION_SEARCH_MAX = 20;
|
|
1595
|
+
app.get('/api/sessions/search', (req, res) => {
|
|
1596
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1597
|
+
const text = String(req.query.q || '').trim().toLowerCase();
|
|
1598
|
+
if (text.length < SESSION_SEARCH_MIN) return res.json([]);
|
|
1599
|
+
const idQ = text.replace(/-/g, '');
|
|
1600
|
+
const searchIds = idQ.length >= SESSION_SEARCH_MIN && /^[0-9a-f]+$/.test(idQ);
|
|
1601
|
+
const hits = [];
|
|
1602
|
+
for (const [id, meta] of Object.entries(loadSessionMetadata())) {
|
|
1603
|
+
const name = getSessionDisplayName(id, meta);
|
|
1604
|
+
if ((searchIds && id.replace(/-/g, '').includes(idQ)) || name?.toLowerCase().includes(text)) {
|
|
1605
|
+
hits.push({ id, mtime: getSessionLogStat(meta).mtime || 0 });
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
hits.sort((a, b) => b.mtime - a.mtime);
|
|
1609
|
+
res.json(hits.slice(0, SESSION_SEARCH_MAX).map((h) => h.id));
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1576
1612
|
function isTempPath(p) {
|
|
1577
1613
|
const rel = path.relative(TEMP_ROOT, p);
|
|
1578
1614
|
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
1579
1615
|
}
|
|
1580
1616
|
|
|
1581
|
-
//
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
const
|
|
1585
|
-
const projectMap = {};
|
|
1586
|
-
for (const meta of Object.values(metadata)) {
|
|
1617
|
+
// Project path → newest transcript mtime among its sessions (undefined when none has one).
|
|
1618
|
+
function projectActivity() {
|
|
1619
|
+
const activity = new Map();
|
|
1620
|
+
for (const meta of Object.values(loadSessionMetadata())) {
|
|
1587
1621
|
if (!meta.project) continue;
|
|
1588
1622
|
const mtime = getSessionLogStat(meta).mtime;
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
}
|
|
1623
|
+
const prev = activity.get(meta.project);
|
|
1624
|
+
if (!prev || (mtime && mtime > prev)) activity.set(meta.project, mtime);
|
|
1592
1625
|
}
|
|
1593
|
-
|
|
1626
|
+
return activity;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// API: Get distinct project paths with last-modified timestamps
|
|
1630
|
+
app.get('/api/projects', (_req, res) => {
|
|
1631
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1632
|
+
const projects = [...projectActivity()]
|
|
1594
1633
|
.map(([path, mtime]) => ({
|
|
1595
1634
|
path,
|
|
1596
1635
|
modifiedAt: mtime ? new Date(mtime).toISOString() : null,
|
|
@@ -1804,8 +1843,9 @@ async function readCreatedPads(meta) {
|
|
|
1804
1843
|
// The project, not `meta.cwd`: cwd is wherever the session last stood, which drifts
|
|
1805
1844
|
// into subdirectories — a session that made a pad and then worked inside it reports
|
|
1806
1845
|
// a cwd below the pad, and a scan from there finds nothing above it.
|
|
1807
|
-
|
|
1808
|
-
const
|
|
1846
|
+
// The session's own scratchpad dir too: a pad made there sits outside the project.
|
|
1847
|
+
const roots = unresolved.length ? [meta.project || meta.cwd, getScratchpadDir(sessionId, meta)] : [];
|
|
1848
|
+
const scanned = (await Promise.all(roots.filter(Boolean).map((r) => findPads(r)))).flat();
|
|
1809
1849
|
const candidates = [...new Set([...reported, ...scanned.map((dir) => path.join(dir, SCRATCHPAD_MANIFEST))])];
|
|
1810
1850
|
const rows = await Promise.all(
|
|
1811
1851
|
candidates.map(async (file) => {
|
|
@@ -2398,7 +2438,8 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
2398
2438
|
try {
|
|
2399
2439
|
const progressMap = getProgressMap(meta.jsonlPath);
|
|
2400
2440
|
for (const entry of Object.values(progressMap)) {
|
|
2401
|
-
|
|
2441
|
+
byAgentId[entry.agentId] ||= {};
|
|
2442
|
+
const e = byAgentId[entry.agentId];
|
|
2402
2443
|
for (const f of ['prompt', 'name', 'description', 'usage']) {
|
|
2403
2444
|
if (entry[f] && !e[f]) e[f] = entry[f];
|
|
2404
2445
|
}
|
|
@@ -2674,7 +2715,6 @@ function resolveSubagentJsonl(meta, sessionId, agentId) {
|
|
|
2674
2715
|
// Fork: copies the parent's early messages verbatim (same UUIDs). Anchor = first UUID.
|
|
2675
2716
|
// Compact: writes a compact_boundary record with logicalParentUuid in the preamble.
|
|
2676
2717
|
// Birthtime (not mtime) identifies the parent — mtime changes on resume, birthtime is immutable.
|
|
2677
|
-
const parentSessionCache = new Map();
|
|
2678
2718
|
const FORK_ANCHOR_SCAN_LINES = 10;
|
|
2679
2719
|
function findForkAnchorUuid(jsonlPath) {
|
|
2680
2720
|
let text;
|
|
@@ -2688,8 +2728,8 @@ function findForkAnchorUuid(jsonlPath) {
|
|
|
2688
2728
|
return firstUuid;
|
|
2689
2729
|
}
|
|
2690
2730
|
// Fallback when the metadata cache lacks the boundary pair (older entries, cold cache).
|
|
2691
|
-
// Reached only from lookupParentSession, whose result is
|
|
2692
|
-
//
|
|
2731
|
+
// Reached only from lookupParentSession, whose result is cached (lib/parent-cache.js) —
|
|
2732
|
+
// one read per session until its verdict goes stale, never per request.
|
|
2693
2733
|
// Bounded read (~1 MB) mirrors readSessionInfoFromJsonl's HEAD_MAX — compact_boundary
|
|
2694
2734
|
// always sits in the preamble before the first user/assistant record.
|
|
2695
2735
|
const COMPACT_ANCHOR_READ_MAX = 1048576;
|
|
@@ -2729,8 +2769,9 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
|
|
|
2729
2769
|
if (!f.endsWith('.jsonl')) continue;
|
|
2730
2770
|
const fp = path.join(projectDir, f);
|
|
2731
2771
|
if (fp === excludeJsonlPath) continue;
|
|
2732
|
-
let
|
|
2733
|
-
try {
|
|
2772
|
+
let st;
|
|
2773
|
+
try { st = statSync(fp); } catch { continue; }
|
|
2774
|
+
const birthtime = st.birthtimeMs;
|
|
2734
2775
|
if (maxBirthtimeMs != null && birthtime >= maxBirthtimeMs) continue;
|
|
2735
2776
|
if (birthtime >= bestBirthtime) continue;
|
|
2736
2777
|
let text;
|
|
@@ -2741,7 +2782,7 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
|
|
|
2741
2782
|
try {
|
|
2742
2783
|
const d = JSON.parse(l);
|
|
2743
2784
|
if (d.uuid === targetUuid && d.sessionId) {
|
|
2744
|
-
best = { parentSessionId: d.sessionId, parentJsonlPath: fp, isFork: false };
|
|
2785
|
+
best = { parentSessionId: d.sessionId, parentJsonlPath: fp, parentIno: st.ino, isFork: false };
|
|
2745
2786
|
bestBirthtime = birthtime;
|
|
2746
2787
|
bestText = text;
|
|
2747
2788
|
break;
|
|
@@ -2754,8 +2795,10 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
|
|
|
2754
2795
|
return best;
|
|
2755
2796
|
}
|
|
2756
2797
|
function lookupParentSession(sessionId) {
|
|
2757
|
-
if (parentSessionCache.has(sessionId)) return parentSessionCache.get(sessionId);
|
|
2758
2798
|
const meta = loadSessionMetadata()[sessionId];
|
|
2799
|
+
const known = getParentVerdict(sessionId, meta);
|
|
2800
|
+
if (known) return known;
|
|
2801
|
+
let self = null, parentIno = null;
|
|
2759
2802
|
// relation is the single verdict: 'none' (no parent found), 'compact' (this session
|
|
2760
2803
|
// continues a lineage the parent could not hold) or 'fork' (an intentional branch off
|
|
2761
2804
|
// a parent that keeps its own life). isCompact/isFork are derived views of it.
|
|
@@ -2770,23 +2813,24 @@ function lookupParentSession(sessionId) {
|
|
|
2770
2813
|
// A fork copies the parent's early records verbatim, so its first uuid is the anchor.
|
|
2771
2814
|
const anchorUuid = compactAnchor ?? findForkAnchorUuid(meta.jsonlPath);
|
|
2772
2815
|
if (anchorUuid) {
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath, selfBirthtime, boundary?.boundaryUuid);
|
|
2816
|
+
try { self = statSync(meta.jsonlPath); } catch { /* ignore */ }
|
|
2817
|
+
if (self) {
|
|
2818
|
+
const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath, self.birthtimeMs, boundary?.boundaryUuid);
|
|
2777
2819
|
if (hit) {
|
|
2778
2820
|
// No compact boundary at all → fork by construction; with one, the copied
|
|
2779
2821
|
// boundary uuid (hit.isFork) is what separates a fork from a continuation.
|
|
2780
2822
|
result.parentSessionId = hit.parentSessionId;
|
|
2781
2823
|
result.parentJsonlPath = hit.parentJsonlPath;
|
|
2782
2824
|
result.relation = (!compactAnchor || hit.isFork) ? 'fork' : 'compact';
|
|
2825
|
+
parentIno = hit.parentIno;
|
|
2783
2826
|
}
|
|
2784
2827
|
}
|
|
2785
2828
|
}
|
|
2786
2829
|
}
|
|
2787
2830
|
result.isCompact = result.relation === 'compact';
|
|
2788
2831
|
result.isFork = result.relation === 'fork';
|
|
2789
|
-
|
|
2832
|
+
// No anchor yet means the head is not written; that "no parent" is not a verdict.
|
|
2833
|
+
if (self) setParentVerdict(sessionId, meta, self, parentIno, result);
|
|
2790
2834
|
return result;
|
|
2791
2835
|
}
|
|
2792
2836
|
// #endregion
|
|
@@ -3118,7 +3162,7 @@ app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
|
|
|
3118
3162
|
// #region META_ROUTES
|
|
3119
3163
|
app.get('/api/version', (_req, res) => {
|
|
3120
3164
|
const pkg = require('./package.json');
|
|
3121
|
-
res.json({ version: pkg.version });
|
|
3165
|
+
res.json({ version: pkg.version, plugin: pluginStatus(CLAUDE_DIR) });
|
|
3122
3166
|
});
|
|
3123
3167
|
|
|
3124
3168
|
app.get('/api/config', (_req, res) => {
|
|
@@ -3143,8 +3187,10 @@ function isAllowedFolder(dir) {
|
|
|
3143
3187
|
try { return known && statSync(dir).isDirectory(); } catch { return false; }
|
|
3144
3188
|
}
|
|
3145
3189
|
|
|
3190
|
+
let listenPort = null;
|
|
3146
3191
|
const terminal = createTerminalService({
|
|
3147
3192
|
config: readTerminalConfig({ getArgValue }),
|
|
3193
|
+
serverUrl: () => (listenPort ? `http://127.0.0.1:${listenPort}` : null),
|
|
3148
3194
|
net,
|
|
3149
3195
|
claudeDir: CLAUDE_DIR,
|
|
3150
3196
|
isDefaultDir: isDefaultClaudeDir(CLAUDE_DIR),
|
|
@@ -3229,6 +3275,13 @@ const dispatchGroups = createGroupStore({
|
|
|
3229
3275
|
pinnedIds: () => new Set(Object.keys(readPins())),
|
|
3230
3276
|
});
|
|
3231
3277
|
|
|
3278
|
+
const linkedDocs = createLinkedDocStore({
|
|
3279
|
+
load: () => {
|
|
3280
|
+
try { return JSON.parse(readFileSync(LINKED_DOCS_FILE, 'utf8')); } catch { return null; }
|
|
3281
|
+
},
|
|
3282
|
+
save: (data) => writeJsonAtomic(LINKED_DOCS_FILE, data),
|
|
3283
|
+
});
|
|
3284
|
+
|
|
3232
3285
|
// The board places a session from these alone, so it never has to move it later.
|
|
3233
3286
|
// `startedBy` lets it follow a starter the user put in a named group, which only the
|
|
3234
3287
|
// browser knows; it goes once the dispatch settles.
|
|
@@ -3256,8 +3309,7 @@ app.post('/api/dispatch', (req, res) => {
|
|
|
3256
3309
|
if (group != null && !isGroupName(group)) {
|
|
3257
3310
|
return res.status(400).json({ error: `group must be kebab-case, e.g. ${suggestGroupName(group) || 'my-group'}` });
|
|
3258
3311
|
}
|
|
3259
|
-
const
|
|
3260
|
-
const target = group || starterGroup;
|
|
3312
|
+
const target = group || (parent && dispatchGroups.groupOf(parent)) || null;
|
|
3261
3313
|
const r = dispatches.create({ parent, name, spec: spec.trim(), report: report === true, peer, group: target, worktree });
|
|
3262
3314
|
const env = { CCK_DISPATCH_ID: r.id, ...(parent && { PARENT_SESSION_ID: parent }) };
|
|
3263
3315
|
const started = terminal.startNew({ cwd, name, model, worktree, prompt: formatPreamble(r) }, env);
|
|
@@ -3266,8 +3318,8 @@ app.post('/api/dispatch', (req, res) => {
|
|
|
3266
3318
|
return res.status(started.status).json({ error: started.error });
|
|
3267
3319
|
}
|
|
3268
3320
|
dispatches.attach(r.id, { session: started.id, cwd: started.cwd });
|
|
3269
|
-
//
|
|
3270
|
-
if (target) dispatchGroups.join(target, [
|
|
3321
|
+
// The starter stays where it is: moving it would jump it under the user.
|
|
3322
|
+
if (target) dispatchGroups.join(target, [started.id], parent);
|
|
3271
3323
|
broadcast({ type: 'dispatch-update' });
|
|
3272
3324
|
res.status(201).json({ dispatch: r.id, session: started.id, cwd: started.cwd, group: target });
|
|
3273
3325
|
});
|
|
@@ -3646,21 +3698,40 @@ app.post('/api/preview', async (req, res) => {
|
|
|
3646
3698
|
// API: Link a file to a session's sidebar docs without opening the preview modal.
|
|
3647
3699
|
// Not extension-restricted, matching /api/file/resolve — an unpreviewable link just
|
|
3648
3700
|
// opens in the editor. Unlinking skips the stat so a deleted file can still be removed.
|
|
3701
|
+
// An http(s) URL is linked as it is. `open` comes from `preview-doc <url>`: a tab cannot
|
|
3702
|
+
// open a URL without a click, so the tab on screen offers an Open button.
|
|
3649
3703
|
app.post('/api/document/link', async (req, res) => {
|
|
3650
3704
|
try {
|
|
3651
|
-
const { path: filePath, sessionId, unlink } = req.body || {};
|
|
3705
|
+
const { path: filePath, sessionId, unlink, open } = req.body || {};
|
|
3652
3706
|
if (typeof sessionId !== 'string' || !sessionId) return res.status(400).json({ error: 'sessionId is required' });
|
|
3653
|
-
const
|
|
3707
|
+
const url = linkUrl(filePath);
|
|
3708
|
+
const abs = url || resolvePreviewPath(filePath);
|
|
3654
3709
|
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
3655
|
-
if (!unlink) await statFileTarget(abs);
|
|
3656
|
-
|
|
3657
|
-
|
|
3710
|
+
if (!unlink && !url) await statFileTarget(abs);
|
|
3711
|
+
if (unlink) linkedDocs.unlink(sessionId, abs);
|
|
3712
|
+
else linkedDocs.link(sessionId, abs);
|
|
3713
|
+
broadcast({ type: 'document:link', path: abs, sessionId, unlink: !!unlink, open: !!(url && open && !unlink) });
|
|
3714
|
+
res.json({ success: true, path: abs, tabs: clients.size });
|
|
3658
3715
|
} catch (error) {
|
|
3659
3716
|
console.error('Error in /api/document/link:', error);
|
|
3660
3717
|
res.status(error.status || 500).json({ error: error.message || 'Link failed' });
|
|
3661
3718
|
}
|
|
3662
3719
|
});
|
|
3663
3720
|
|
|
3721
|
+
app.get('/api/document/links', (req, res) => {
|
|
3722
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3723
|
+
const sessionId = req.query.session ? String(req.query.session) : null;
|
|
3724
|
+
res.json(sessionId ? { [sessionId]: linkedDocs.get(sessionId) } : linkedDocs.all());
|
|
3725
|
+
});
|
|
3726
|
+
|
|
3727
|
+
// The browser's own unlink: it already updated its list, so this only drops the server
|
|
3728
|
+
// copy that would bring the doc back on the next merge. No path clears the session.
|
|
3729
|
+
app.delete('/api/document/links/:sessionId', (req, res) => {
|
|
3730
|
+
const raw = String(req.query.path || '');
|
|
3731
|
+
const filePath = linkUrl(raw) || resolvePreviewPath(raw);
|
|
3732
|
+
res.json({ removed: linkedDocs.unlink(req.params.sessionId, filePath) });
|
|
3733
|
+
});
|
|
3734
|
+
|
|
3664
3735
|
app.get('/api/session/resolve', (req, res) => {
|
|
3665
3736
|
try {
|
|
3666
3737
|
const idArg = (req.query.id || '').toString();
|
|
@@ -4080,30 +4151,51 @@ setInterval(cleanupContextStatus, 30 * 60 * 1000);
|
|
|
4080
4151
|
// enrichment for inactive sessions, so we no longer drive a full self-request
|
|
4081
4152
|
// here — that was 690× wasted work for an active-filter first hit.
|
|
4082
4153
|
// Yields to the event loop periodically so any inbound request isn't starved.
|
|
4154
|
+
// A yield every N sessions does not bound the wait: one large transcript is a long sync read.
|
|
4155
|
+
const PREWARM_SLICE_MS = 10;
|
|
4156
|
+
// Started on the ready line, the full read of every transcript competes for disk and CPU with
|
|
4157
|
+
// the hub's other apps while they render, and slows their first render. It starts after the
|
|
4158
|
+
// first list is sent, or after the fallback when no client asks.
|
|
4159
|
+
const PREWARM_FALLBACK_MS = 5000;
|
|
4160
|
+
const SESSION_CACHE_SAVE_MS = 30000;
|
|
4161
|
+
const persistSessionCache = () => saveSessionCache((data) => writeJsonAtomic(SESSION_CACHE_FILE, data));
|
|
4162
|
+
let prewarmStarted = false;
|
|
4163
|
+
function startPrewarm() {
|
|
4164
|
+
if (prewarmStarted) return;
|
|
4165
|
+
prewarmStarted = true;
|
|
4166
|
+
setImmediate(prewarmCaches);
|
|
4167
|
+
}
|
|
4168
|
+
|
|
4083
4169
|
async function prewarmCaches() {
|
|
4084
4170
|
const t0 = Date.now();
|
|
4085
4171
|
try {
|
|
4086
4172
|
const metadata = loadSessionMetadata();
|
|
4087
4173
|
|
|
4088
|
-
let
|
|
4174
|
+
let sliceStart = Date.now();
|
|
4089
4175
|
for (const meta of Object.values(metadata)) {
|
|
4090
4176
|
if (meta?.jsonlPath) {
|
|
4091
4177
|
try { refreshLoopInfoState(meta.jsonlPath); } catch {}
|
|
4092
4178
|
}
|
|
4093
|
-
if (
|
|
4179
|
+
if (Date.now() - sliceStart >= PREWARM_SLICE_MS) {
|
|
4180
|
+
await new Promise(r => setImmediate(r));
|
|
4181
|
+
sliceStart = Date.now();
|
|
4182
|
+
}
|
|
4094
4183
|
}
|
|
4095
4184
|
|
|
4096
4185
|
console.log(`[prewarm] done in ${Date.now() - t0}ms (${Object.keys(metadata).length} sessions)`);
|
|
4186
|
+
persistSessionCache();
|
|
4097
4187
|
} catch (e) {
|
|
4098
4188
|
console.warn('[prewarm] failed:', e.message);
|
|
4099
4189
|
}
|
|
4100
4190
|
}
|
|
4101
4191
|
|
|
4102
4192
|
const onReady = (actualPort) => {
|
|
4103
|
-
|
|
4193
|
+
listenPort = actualPort;
|
|
4104
4194
|
// The port is configurable and falls back to a random one when taken, so the postman
|
|
4105
4195
|
// monitor cannot assume it -- publish the live one where it can read it.
|
|
4106
4196
|
writeServerInfo(actualPort);
|
|
4197
|
+
writeTerminalToken(actualPort);
|
|
4198
|
+
console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
|
|
4107
4199
|
setInterval(() => reclaimServerInfo(actualPort), 30000).unref();
|
|
4108
4200
|
migrateLegacyApprovalsConfig();
|
|
4109
4201
|
const warning = net.exposureWarning();
|
|
@@ -4118,9 +4210,15 @@ async function prewarmCaches() {
|
|
|
4118
4210
|
if (process.argv.includes('--open')) {
|
|
4119
4211
|
import('open').then(open => open.default(`http://localhost:${actualPort}`));
|
|
4120
4212
|
}
|
|
4121
|
-
|
|
4213
|
+
setTimeout(startPrewarm, PREWARM_FALLBACK_MS).unref();
|
|
4122
4214
|
};
|
|
4123
4215
|
|
|
4216
|
+
loadSessionCache(SESSION_CACHE_FILE);
|
|
4217
|
+
// Under the hub a child can end by TerminateProcess on Windows, where no exit handler runs,
|
|
4218
|
+
// so the cache is also saved on a timer.
|
|
4219
|
+
setInterval(persistSessionCache, SESSION_CACHE_SAVE_MS).unref();
|
|
4220
|
+
process.on('exit', persistSessionCache);
|
|
4221
|
+
|
|
4124
4222
|
const listenOpts = { onUpgrade: terminal.handleUpgrade };
|
|
4125
4223
|
const server = net.listenLoopback(app, PORT, onReady, listenOpts);
|
|
4126
4224
|
process.on('exit', terminal.shutdown);
|
|
@@ -4135,4 +4233,5 @@ async function prewarmCaches() {
|
|
|
4135
4233
|
});
|
|
4136
4234
|
|
|
4137
4235
|
|
|
4138
|
-
// #endregion
|
|
4236
|
+
// #endregion
|
|
4237
|
+
}
|
package/skill-guides/dispatch.md
CHANGED
|
@@ -19,14 +19,15 @@ Dispatch when the task can run on its own. Do the work yourself when it is small
|
|
|
19
19
|
## Start
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
claude-code-kanban dispatch start --cwd <dir> --spec-file <spec.md> --name <name> --group <group> --peer <your-peer> [--report]
|
|
22
|
+
claude-code-kanban dispatch start --cwd <dir> --spec-file <spec.md> --name <name> --group <group> --peer <your-peer> [--report] --json
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
`claude-code-kanban help dispatch start` lists every flag (model, worktree, and the rest). `project list` shows the folders `--cwd` accepts. How to choose the values:
|
|
26
|
+
|
|
25
27
|
- `--peer` is your own peer name: the first line of `ListAgents` ("This session is `<name>`"). Pass it whenever you have the `ListAgents` tool. cck then tells the started session to ask you with `SendMessage` instead of failing on a question. See [Peer](#peer).
|
|
26
|
-
- `--cwd` must be a project cck already knows (default: the current dir).
|
|
27
28
|
- `--spec-file` over `--spec` for anything longer than a line: no shell quoting.
|
|
28
29
|
- `--name` is what the user sees in the sidebar. Kebab-case, saying what the session does: `fix-login-redirect`, not `task-1`.
|
|
29
|
-
- `--group` names the effort, in kebab-case (`auth-refactor`), and shows the new session
|
|
30
|
+
- `--group` names the effort, in kebab-case (`auth-refactor`), and shows the new session under that sidebar group. This session stays where it is. Pass it on your first dispatch; later dispatches join the same group without it. A group goes away when its sessions end, unless the user pins a member or keeps the group.
|
|
30
31
|
- The result holds the `dispatch` id and the `session` id.
|
|
31
32
|
|
|
32
33
|
## Fire-and-forget
|
|
@@ -44,7 +45,7 @@ The started session settles with one report, `succeeded` or `failed`, or as `exi
|
|
|
44
45
|
claude-code-kanban dispatch wait [<id>...] --timeout 15m --json
|
|
45
46
|
```
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
Call it again with the ids still running (`help dispatch wait` has the output fields). A timeout is a checkpoint: the session may still be working. Look before you act:
|
|
48
49
|
|
|
49
50
|
```bash
|
|
50
51
|
claude-code-kanban dispatch list --json
|