claude-code-kanban 4.29.0 → 4.30.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 +3 -2
- package/cli.js +383 -70
- package/install.js +7 -8
- 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 +56 -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/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/scripts/postman.js +3 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +16 -46
- package/public/app.js +563 -313
- package/public/index.html +46 -23
- package/public/project-match.js +27 -0
- package/public/style.css +306 -94
- package/public/sw.js +1 -1
- package/server.js +167 -74
- package/skill-guides/dispatch.md +5 -4
package/server.js
CHANGED
|
@@ -41,7 +41,12 @@ const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = requ
|
|
|
41
41
|
const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
|
|
42
42
|
const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName } = require('./lib/dispatch');
|
|
43
43
|
const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
|
|
44
|
+
const { createLinkedDocStore } = require('./lib/linked-docs');
|
|
44
45
|
const { pickFolder } = require('./lib/folder-dialog');
|
|
46
|
+
const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
|
|
47
|
+
const { countTaskDir } = require('./lib/task-counts');
|
|
48
|
+
const { projectMatcher } = require('./public/project-match');
|
|
49
|
+
const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
|
|
45
50
|
|
|
46
51
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
47
52
|
const { runInstall, runUninstall } = require("./install");
|
|
@@ -49,10 +54,12 @@ if (process.argv.includes("--install") || process.argv.includes("--uninstall"))
|
|
|
49
54
|
(process.argv.includes("--install") ? runInstall({ pluginOnly }) : runUninstall())
|
|
50
55
|
.then(() => process.exit(0))
|
|
51
56
|
.catch(e => { console.error(e.message); process.exit(1); });
|
|
52
|
-
|
|
57
|
+
} else if (!require("./cli").runCli(process.argv)) {
|
|
58
|
+
startServer();
|
|
53
59
|
}
|
|
54
|
-
if (require("./cli").runCli(process.argv)) return;
|
|
55
60
|
|
|
61
|
+
// Not indented: the formatter is off for this file, and a reindent would rewrite every line.
|
|
62
|
+
function startServer() {
|
|
56
63
|
|
|
57
64
|
const app = express();
|
|
58
65
|
const PORT = process.env.PORT || 3541;
|
|
@@ -82,8 +89,10 @@ const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
|
|
|
82
89
|
const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
|
|
83
90
|
const PINS_FILE = path.join(CCK_DIR, 'pins.json');
|
|
84
91
|
const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
|
|
92
|
+
const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
|
|
85
93
|
const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
|
|
86
|
-
const
|
|
94
|
+
const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
|
|
95
|
+
const SESSION_CACHE_FILE = path.join(CCK_DIR, 'session-cache.json');
|
|
87
96
|
// Harness-owned scratchpad root; the per-session dir under it is created lazily.
|
|
88
97
|
const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
|
|
89
98
|
|
|
@@ -101,7 +110,7 @@ function readPins() {
|
|
|
101
110
|
|
|
102
111
|
function writeJsonAtomic(file, obj, mode) {
|
|
103
112
|
try {
|
|
104
|
-
mkdirSync(
|
|
113
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
105
114
|
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
106
115
|
writeFileSync(tmp, JSON.stringify(obj, null, 2), { encoding: 'utf8', mode });
|
|
107
116
|
renameSync(tmp, file);
|
|
@@ -119,37 +128,46 @@ function writePins(pins) {
|
|
|
119
128
|
// a crashed one.
|
|
120
129
|
function writeServerInfo(port) {
|
|
121
130
|
writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
|
|
122
|
-
writeTerminalToken();
|
|
123
131
|
}
|
|
124
132
|
|
|
125
133
|
// For `dispatch start`: a local process as the same user can already run claude itself,
|
|
126
134
|
// so handing it the token adds little. A browser still cannot read the file.
|
|
127
|
-
|
|
135
|
+
// One file per port: two boards on one config dir each keep a token, so the CLI can
|
|
136
|
+
// reach the board CCK_URL names even when the other one owns server.json.
|
|
137
|
+
// The sweep also drops terminal-token.json, the single file that older versions wrote.
|
|
138
|
+
let terminalTokenFile = null;
|
|
139
|
+
function writeTerminalToken(port) {
|
|
128
140
|
if (terminal.unavailableReason()) return;
|
|
129
|
-
|
|
141
|
+
let stale = [path.join(CCK_DIR, 'terminal-token.json')];
|
|
142
|
+
try { stale = stale.concat(readdirSync(TERMINAL_TOKENS_DIR).map(name => path.join(TERMINAL_TOKENS_DIR, name))); } catch (_) { /* no dir yet */ }
|
|
143
|
+
for (const file of stale) {
|
|
144
|
+
const pid = ownerPid(file);
|
|
145
|
+
if (pid !== null && !isPidAlive(pid)) try { unlinkSync(file); } catch (_) { /* already gone */ }
|
|
146
|
+
}
|
|
147
|
+
terminalTokenFile = path.join(TERMINAL_TOKENS_DIR, `${port}.json`);
|
|
148
|
+
writeJsonAtomic(terminalTokenFile, { pid: process.pid, token: terminal.token }, 0o600);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function ownerPid(file) {
|
|
152
|
+
try { return JSON.parse(readFileSync(file, 'utf8')).pid ?? null; } catch (_) { return null; }
|
|
130
153
|
}
|
|
131
154
|
|
|
132
155
|
// A beacon that outlives its server disarms UI approvals silently: approval-gate.sh
|
|
133
156
|
// probes the port, finds it closed and never waits. The hub spawns every sub-app on
|
|
134
157
|
// an ephemeral port, so a stale beacon never comes back on its own — drop ours on
|
|
135
158
|
// the way out. Only when the file is still ours: a newer server on the same config
|
|
136
|
-
// dir has already claimed it.
|
|
159
|
+
// dir has already claimed it. The terminal token file goes the same way.
|
|
137
160
|
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 */ }
|
|
161
|
+
for (const file of [SERVER_INFO_FILE, terminalTokenFile]) {
|
|
162
|
+
if (file && ownerPid(file) === process.pid) try { unlinkSync(file); } catch (_) { /* already gone */ }
|
|
143
163
|
}
|
|
144
164
|
}
|
|
145
165
|
|
|
146
166
|
// A second server on the same config dir (a test hub, say) takes the beacon and removes it on
|
|
147
167
|
// exit, which leaves this live server undiscoverable. A newer live owner keeps it.
|
|
148
168
|
function reclaimServerInfo(port) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (pid === process.pid || isPidAlive(pid)) return;
|
|
152
|
-
} catch (_) { /* missing or unreadable beacon */ }
|
|
169
|
+
const pid = ownerPid(SERVER_INFO_FILE);
|
|
170
|
+
if (pid !== null && isPidAlive(pid)) return;
|
|
153
171
|
writeServerInfo(port);
|
|
154
172
|
}
|
|
155
173
|
|
|
@@ -718,29 +736,13 @@ function getTaskCounts(sessionPath) {
|
|
|
718
736
|
const cached = taskCountsCache.get(sessionPath);
|
|
719
737
|
if (cached) return cached;
|
|
720
738
|
|
|
721
|
-
const
|
|
722
|
-
let completed = 0, inProgress = 0, pending = 0, newestTaskMtime = null;
|
|
739
|
+
const { completed, inProgress, pending, newestTaskMtime } = countTaskDir(sessionPath);
|
|
723
740
|
// Directory mtime bumps when task files are added/removed, so it stays fresh even for an
|
|
724
741
|
// emptied dir (task list closed) that has no files left to date. Task-file mtime alone would
|
|
725
742
|
// report 0 for such a dir and lose "latest wins" to a stale prior-boot dir.
|
|
726
743
|
let dirMtime = 0;
|
|
727
744
|
try { dirMtime = statSync(sessionPath).mtimeMs; } catch (_) {}
|
|
728
745
|
|
|
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
746
|
const taskCount = completed + inProgress + pending;
|
|
745
747
|
const result = { taskCount, completed, inProgress, pending, newestTaskMtime, dirMtime };
|
|
746
748
|
taskCountsCache.set(sessionPath, result);
|
|
@@ -823,8 +825,6 @@ function refreshSessionMetadataPath(jsonlPath) {
|
|
|
823
825
|
if (info.cwd) existing.cwd = info.cwd;
|
|
824
826
|
if (info.gitBranch) existing.gitBranch = info.gitBranch;
|
|
825
827
|
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
828
|
if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
|
|
829
829
|
if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
|
|
830
830
|
return true;
|
|
@@ -911,7 +911,6 @@ function loadSessionMetadata() {
|
|
|
911
911
|
cwd: sessionInfo.cwd || null,
|
|
912
912
|
gitBranch: sessionInfo.gitBranch || null,
|
|
913
913
|
customTitle: sessionInfo.customTitle || null,
|
|
914
|
-
goal: sessionInfo.goal || null,
|
|
915
914
|
jsonlPath: jsonlPath,
|
|
916
915
|
logicalParentUuid: sessionInfo.logicalParentUuid || null,
|
|
917
916
|
compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null
|
|
@@ -1155,7 +1154,6 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
1155
1154
|
gitBranch: resolveSessionGitBranch(meta),
|
|
1156
1155
|
worktree: resolveWorktree(meta.project),
|
|
1157
1156
|
customTitle: meta.customTitle || null,
|
|
1158
|
-
goal: meta.goal || null,
|
|
1159
1157
|
taskCount: 0,
|
|
1160
1158
|
completed: 0,
|
|
1161
1159
|
inProgress: 0,
|
|
@@ -1177,7 +1175,6 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
1177
1175
|
contextStatus: getContextStatus(id, meta),
|
|
1178
1176
|
...getPlanInfo(meta.slug),
|
|
1179
1177
|
...getWorkflowInfoSummary(id),
|
|
1180
|
-
loopInfo: getLoopInfoSummary(meta),
|
|
1181
1178
|
...overrides,
|
|
1182
1179
|
// Remove internal-only field
|
|
1183
1180
|
_logStat: undefined,
|
|
@@ -1551,24 +1548,62 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1551
1548
|
// The client sends the session it currently has open, which it cannot render at all
|
|
1552
1549
|
// if the row is missing — that one is not a preference.
|
|
1553
1550
|
if (projectFilter) {
|
|
1554
|
-
|
|
1551
|
+
const matches = projectMatcher(String(projectFilter));
|
|
1552
|
+
sessions = sessions.filter(s => matches(s.project) || includeIds.has(s.id));
|
|
1553
|
+
}
|
|
1554
|
+
// The sidebar's 24h filter: projects with any transcript written in the window, so an older
|
|
1555
|
+
// session of such a project stays in.
|
|
1556
|
+
const recentHours = Number(req.query.recentHours);
|
|
1557
|
+
if (recentHours > 0) {
|
|
1558
|
+
const cutoff = Date.now() - recentHours * 3600 * 1000;
|
|
1559
|
+
const activity = projectActivity();
|
|
1560
|
+
sessions = sessions.filter(s => (activity.get(s.project) || 0) > cutoff || includeIds.has(s.id));
|
|
1555
1561
|
}
|
|
1556
1562
|
|
|
1563
|
+
const paged = limit !== null && limit > 0;
|
|
1564
|
+
// The sidebar loads the next page on scroll while this is true. A header, so the body stays a plain array.
|
|
1565
|
+
res.setHeader('X-Has-More', String(paged && sessions.length > limit));
|
|
1557
1566
|
// Apply limit if specified, but always include pinned sessions
|
|
1558
|
-
if (
|
|
1567
|
+
if (paged) {
|
|
1559
1568
|
const top = sessions.slice(0, limit);
|
|
1560
1569
|
const topIds = new Set(top.map(s => s.id));
|
|
1561
1570
|
const missingPinned = sessions.filter(s => pinnedIds.has(s.id) && !topIds.has(s.id));
|
|
1562
1571
|
sessions = [...top, ...missingPinned];
|
|
1563
1572
|
}
|
|
1564
1573
|
|
|
1574
|
+
// Loop info can mean a full read of the transcript, so only the rows sent pay for it.
|
|
1575
|
+
for (const s of sessions) s.loopInfo = getLoopInfoSummary(s);
|
|
1576
|
+
|
|
1565
1577
|
res.json(withDispatchPlacement(sessions));
|
|
1578
|
+
startPrewarm();
|
|
1566
1579
|
} catch (error) {
|
|
1567
1580
|
console.error('Error listing sessions:', error);
|
|
1568
1581
|
res.status(500).json({ error: 'Failed to list sessions' });
|
|
1569
1582
|
}
|
|
1570
1583
|
});
|
|
1571
1584
|
|
|
1585
|
+
// Ids of sessions whose id or name contains q, from any transcript, newest first. The client
|
|
1586
|
+
// gets the rows through `/api/sessions?include=`. A linear scan is enough: ~4 ms for 10k ids,
|
|
1587
|
+
// plus one stat per hit for the sort.
|
|
1588
|
+
const SESSION_SEARCH_MIN = 3;
|
|
1589
|
+
const SESSION_SEARCH_MAX = 20;
|
|
1590
|
+
app.get('/api/sessions/search', (req, res) => {
|
|
1591
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1592
|
+
const text = String(req.query.q || '').trim().toLowerCase();
|
|
1593
|
+
if (text.length < SESSION_SEARCH_MIN) return res.json([]);
|
|
1594
|
+
const idQ = text.replace(/-/g, '');
|
|
1595
|
+
const searchIds = idQ.length >= SESSION_SEARCH_MIN && /^[0-9a-f]+$/.test(idQ);
|
|
1596
|
+
const hits = [];
|
|
1597
|
+
for (const [id, meta] of Object.entries(loadSessionMetadata())) {
|
|
1598
|
+
const name = getSessionDisplayName(id, meta);
|
|
1599
|
+
if ((searchIds && id.replace(/-/g, '').includes(idQ)) || name?.toLowerCase().includes(text)) {
|
|
1600
|
+
hits.push({ id, mtime: getSessionLogStat(meta).mtime || 0 });
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
hits.sort((a, b) => b.mtime - a.mtime);
|
|
1604
|
+
res.json(hits.slice(0, SESSION_SEARCH_MAX).map((h) => h.id));
|
|
1605
|
+
});
|
|
1606
|
+
|
|
1572
1607
|
// os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
|
|
1573
1608
|
const TEMP_ROOT = (() => {
|
|
1574
1609
|
try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
|
|
@@ -1578,19 +1613,22 @@ function isTempPath(p) {
|
|
|
1578
1613
|
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
1579
1614
|
}
|
|
1580
1615
|
|
|
1581
|
-
//
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
const
|
|
1585
|
-
const projectMap = {};
|
|
1586
|
-
for (const meta of Object.values(metadata)) {
|
|
1616
|
+
// Project path → newest transcript mtime among its sessions (undefined when none has one).
|
|
1617
|
+
function projectActivity() {
|
|
1618
|
+
const activity = new Map();
|
|
1619
|
+
for (const meta of Object.values(loadSessionMetadata())) {
|
|
1587
1620
|
if (!meta.project) continue;
|
|
1588
1621
|
const mtime = getSessionLogStat(meta).mtime;
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
}
|
|
1622
|
+
const prev = activity.get(meta.project);
|
|
1623
|
+
if (!prev || (mtime && mtime > prev)) activity.set(meta.project, mtime);
|
|
1592
1624
|
}
|
|
1593
|
-
|
|
1625
|
+
return activity;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// API: Get distinct project paths with last-modified timestamps
|
|
1629
|
+
app.get('/api/projects', (_req, res) => {
|
|
1630
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1631
|
+
const projects = [...projectActivity()]
|
|
1594
1632
|
.map(([path, mtime]) => ({
|
|
1595
1633
|
path,
|
|
1596
1634
|
modifiedAt: mtime ? new Date(mtime).toISOString() : null,
|
|
@@ -2398,7 +2436,8 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
2398
2436
|
try {
|
|
2399
2437
|
const progressMap = getProgressMap(meta.jsonlPath);
|
|
2400
2438
|
for (const entry of Object.values(progressMap)) {
|
|
2401
|
-
|
|
2439
|
+
byAgentId[entry.agentId] ||= {};
|
|
2440
|
+
const e = byAgentId[entry.agentId];
|
|
2402
2441
|
for (const f of ['prompt', 'name', 'description', 'usage']) {
|
|
2403
2442
|
if (entry[f] && !e[f]) e[f] = entry[f];
|
|
2404
2443
|
}
|
|
@@ -2674,7 +2713,6 @@ function resolveSubagentJsonl(meta, sessionId, agentId) {
|
|
|
2674
2713
|
// Fork: copies the parent's early messages verbatim (same UUIDs). Anchor = first UUID.
|
|
2675
2714
|
// Compact: writes a compact_boundary record with logicalParentUuid in the preamble.
|
|
2676
2715
|
// Birthtime (not mtime) identifies the parent — mtime changes on resume, birthtime is immutable.
|
|
2677
|
-
const parentSessionCache = new Map();
|
|
2678
2716
|
const FORK_ANCHOR_SCAN_LINES = 10;
|
|
2679
2717
|
function findForkAnchorUuid(jsonlPath) {
|
|
2680
2718
|
let text;
|
|
@@ -2688,8 +2726,8 @@ function findForkAnchorUuid(jsonlPath) {
|
|
|
2688
2726
|
return firstUuid;
|
|
2689
2727
|
}
|
|
2690
2728
|
// Fallback when the metadata cache lacks the boundary pair (older entries, cold cache).
|
|
2691
|
-
// Reached only from lookupParentSession, whose result is
|
|
2692
|
-
//
|
|
2729
|
+
// Reached only from lookupParentSession, whose result is cached (lib/parent-cache.js) —
|
|
2730
|
+
// one read per session until its verdict goes stale, never per request.
|
|
2693
2731
|
// Bounded read (~1 MB) mirrors readSessionInfoFromJsonl's HEAD_MAX — compact_boundary
|
|
2694
2732
|
// always sits in the preamble before the first user/assistant record.
|
|
2695
2733
|
const COMPACT_ANCHOR_READ_MAX = 1048576;
|
|
@@ -2729,8 +2767,9 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
|
|
|
2729
2767
|
if (!f.endsWith('.jsonl')) continue;
|
|
2730
2768
|
const fp = path.join(projectDir, f);
|
|
2731
2769
|
if (fp === excludeJsonlPath) continue;
|
|
2732
|
-
let
|
|
2733
|
-
try {
|
|
2770
|
+
let st;
|
|
2771
|
+
try { st = statSync(fp); } catch { continue; }
|
|
2772
|
+
const birthtime = st.birthtimeMs;
|
|
2734
2773
|
if (maxBirthtimeMs != null && birthtime >= maxBirthtimeMs) continue;
|
|
2735
2774
|
if (birthtime >= bestBirthtime) continue;
|
|
2736
2775
|
let text;
|
|
@@ -2741,7 +2780,7 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
|
|
|
2741
2780
|
try {
|
|
2742
2781
|
const d = JSON.parse(l);
|
|
2743
2782
|
if (d.uuid === targetUuid && d.sessionId) {
|
|
2744
|
-
best = { parentSessionId: d.sessionId, parentJsonlPath: fp, isFork: false };
|
|
2783
|
+
best = { parentSessionId: d.sessionId, parentJsonlPath: fp, parentIno: st.ino, isFork: false };
|
|
2745
2784
|
bestBirthtime = birthtime;
|
|
2746
2785
|
bestText = text;
|
|
2747
2786
|
break;
|
|
@@ -2754,8 +2793,10 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
|
|
|
2754
2793
|
return best;
|
|
2755
2794
|
}
|
|
2756
2795
|
function lookupParentSession(sessionId) {
|
|
2757
|
-
if (parentSessionCache.has(sessionId)) return parentSessionCache.get(sessionId);
|
|
2758
2796
|
const meta = loadSessionMetadata()[sessionId];
|
|
2797
|
+
const known = getParentVerdict(sessionId, meta);
|
|
2798
|
+
if (known) return known;
|
|
2799
|
+
let self = null, parentIno = null;
|
|
2759
2800
|
// relation is the single verdict: 'none' (no parent found), 'compact' (this session
|
|
2760
2801
|
// continues a lineage the parent could not hold) or 'fork' (an intentional branch off
|
|
2761
2802
|
// a parent that keeps its own life). isCompact/isFork are derived views of it.
|
|
@@ -2770,23 +2811,24 @@ function lookupParentSession(sessionId) {
|
|
|
2770
2811
|
// A fork copies the parent's early records verbatim, so its first uuid is the anchor.
|
|
2771
2812
|
const anchorUuid = compactAnchor ?? findForkAnchorUuid(meta.jsonlPath);
|
|
2772
2813
|
if (anchorUuid) {
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath, selfBirthtime, boundary?.boundaryUuid);
|
|
2814
|
+
try { self = statSync(meta.jsonlPath); } catch { /* ignore */ }
|
|
2815
|
+
if (self) {
|
|
2816
|
+
const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath, self.birthtimeMs, boundary?.boundaryUuid);
|
|
2777
2817
|
if (hit) {
|
|
2778
2818
|
// No compact boundary at all → fork by construction; with one, the copied
|
|
2779
2819
|
// boundary uuid (hit.isFork) is what separates a fork from a continuation.
|
|
2780
2820
|
result.parentSessionId = hit.parentSessionId;
|
|
2781
2821
|
result.parentJsonlPath = hit.parentJsonlPath;
|
|
2782
2822
|
result.relation = (!compactAnchor || hit.isFork) ? 'fork' : 'compact';
|
|
2823
|
+
parentIno = hit.parentIno;
|
|
2783
2824
|
}
|
|
2784
2825
|
}
|
|
2785
2826
|
}
|
|
2786
2827
|
}
|
|
2787
2828
|
result.isCompact = result.relation === 'compact';
|
|
2788
2829
|
result.isFork = result.relation === 'fork';
|
|
2789
|
-
|
|
2830
|
+
// No anchor yet means the head is not written; that "no parent" is not a verdict.
|
|
2831
|
+
if (self) setParentVerdict(sessionId, meta, self, parentIno, result);
|
|
2790
2832
|
return result;
|
|
2791
2833
|
}
|
|
2792
2834
|
// #endregion
|
|
@@ -3143,8 +3185,10 @@ function isAllowedFolder(dir) {
|
|
|
3143
3185
|
try { return known && statSync(dir).isDirectory(); } catch { return false; }
|
|
3144
3186
|
}
|
|
3145
3187
|
|
|
3188
|
+
let listenPort = null;
|
|
3146
3189
|
const terminal = createTerminalService({
|
|
3147
3190
|
config: readTerminalConfig({ getArgValue }),
|
|
3191
|
+
serverUrl: () => (listenPort ? `http://127.0.0.1:${listenPort}` : null),
|
|
3148
3192
|
net,
|
|
3149
3193
|
claudeDir: CLAUDE_DIR,
|
|
3150
3194
|
isDefaultDir: isDefaultClaudeDir(CLAUDE_DIR),
|
|
@@ -3229,6 +3273,13 @@ const dispatchGroups = createGroupStore({
|
|
|
3229
3273
|
pinnedIds: () => new Set(Object.keys(readPins())),
|
|
3230
3274
|
});
|
|
3231
3275
|
|
|
3276
|
+
const linkedDocs = createLinkedDocStore({
|
|
3277
|
+
load: () => {
|
|
3278
|
+
try { return JSON.parse(readFileSync(LINKED_DOCS_FILE, 'utf8')); } catch { return null; }
|
|
3279
|
+
},
|
|
3280
|
+
save: (data) => writeJsonAtomic(LINKED_DOCS_FILE, data),
|
|
3281
|
+
});
|
|
3282
|
+
|
|
3232
3283
|
// The board places a session from these alone, so it never has to move it later.
|
|
3233
3284
|
// `startedBy` lets it follow a starter the user put in a named group, which only the
|
|
3234
3285
|
// browser knows; it goes once the dispatch settles.
|
|
@@ -3256,8 +3307,7 @@ app.post('/api/dispatch', (req, res) => {
|
|
|
3256
3307
|
if (group != null && !isGroupName(group)) {
|
|
3257
3308
|
return res.status(400).json({ error: `group must be kebab-case, e.g. ${suggestGroupName(group) || 'my-group'}` });
|
|
3258
3309
|
}
|
|
3259
|
-
const
|
|
3260
|
-
const target = group || starterGroup;
|
|
3310
|
+
const target = group || (parent && dispatchGroups.groupOf(parent)) || null;
|
|
3261
3311
|
const r = dispatches.create({ parent, name, spec: spec.trim(), report: report === true, peer, group: target, worktree });
|
|
3262
3312
|
const env = { CCK_DISPATCH_ID: r.id, ...(parent && { PARENT_SESSION_ID: parent }) };
|
|
3263
3313
|
const started = terminal.startNew({ cwd, name, model, worktree, prompt: formatPreamble(r) }, env);
|
|
@@ -3266,8 +3316,8 @@ app.post('/api/dispatch', (req, res) => {
|
|
|
3266
3316
|
return res.status(started.status).json({ error: started.error });
|
|
3267
3317
|
}
|
|
3268
3318
|
dispatches.attach(r.id, { session: started.id, cwd: started.cwd });
|
|
3269
|
-
//
|
|
3270
|
-
if (target) dispatchGroups.join(target, [
|
|
3319
|
+
// The starter stays where it is: moving it would jump it under the user.
|
|
3320
|
+
if (target) dispatchGroups.join(target, [started.id], parent);
|
|
3271
3321
|
broadcast({ type: 'dispatch-update' });
|
|
3272
3322
|
res.status(201).json({ dispatch: r.id, session: started.id, cwd: started.cwd, group: target });
|
|
3273
3323
|
});
|
|
@@ -3653,14 +3703,29 @@ app.post('/api/document/link', async (req, res) => {
|
|
|
3653
3703
|
const abs = resolvePreviewPath(filePath);
|
|
3654
3704
|
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
3655
3705
|
if (!unlink) await statFileTarget(abs);
|
|
3706
|
+
if (unlink) linkedDocs.unlink(sessionId, abs);
|
|
3707
|
+
else linkedDocs.link(sessionId, abs);
|
|
3656
3708
|
broadcast({ type: 'document:link', path: abs, sessionId, unlink: !!unlink });
|
|
3657
|
-
res.json({ success: true });
|
|
3709
|
+
res.json({ success: true, path: abs, tabs: clients.size });
|
|
3658
3710
|
} catch (error) {
|
|
3659
3711
|
console.error('Error in /api/document/link:', error);
|
|
3660
3712
|
res.status(error.status || 500).json({ error: error.message || 'Link failed' });
|
|
3661
3713
|
}
|
|
3662
3714
|
});
|
|
3663
3715
|
|
|
3716
|
+
app.get('/api/document/links', (req, res) => {
|
|
3717
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3718
|
+
const sessionId = req.query.session ? String(req.query.session) : null;
|
|
3719
|
+
res.json(sessionId ? { [sessionId]: linkedDocs.get(sessionId) } : linkedDocs.all());
|
|
3720
|
+
});
|
|
3721
|
+
|
|
3722
|
+
// The browser's own unlink: it already updated its list, so this only drops the server
|
|
3723
|
+
// copy that would bring the doc back on the next merge. No path clears the session.
|
|
3724
|
+
app.delete('/api/document/links/:sessionId', (req, res) => {
|
|
3725
|
+
const filePath = req.query.path ? resolvePreviewPath(String(req.query.path)) : null;
|
|
3726
|
+
res.json({ removed: linkedDocs.unlink(req.params.sessionId, filePath) });
|
|
3727
|
+
});
|
|
3728
|
+
|
|
3664
3729
|
app.get('/api/session/resolve', (req, res) => {
|
|
3665
3730
|
try {
|
|
3666
3731
|
const idArg = (req.query.id || '').toString();
|
|
@@ -4080,30 +4145,51 @@ setInterval(cleanupContextStatus, 30 * 60 * 1000);
|
|
|
4080
4145
|
// enrichment for inactive sessions, so we no longer drive a full self-request
|
|
4081
4146
|
// here — that was 690× wasted work for an active-filter first hit.
|
|
4082
4147
|
// Yields to the event loop periodically so any inbound request isn't starved.
|
|
4148
|
+
// A yield every N sessions does not bound the wait: one large transcript is a long sync read.
|
|
4149
|
+
const PREWARM_SLICE_MS = 10;
|
|
4150
|
+
// Started on the ready line, the full read of every transcript competes for disk and CPU with
|
|
4151
|
+
// the hub's other apps while they render, and slows their first render. It starts after the
|
|
4152
|
+
// first list is sent, or after the fallback when no client asks.
|
|
4153
|
+
const PREWARM_FALLBACK_MS = 5000;
|
|
4154
|
+
const SESSION_CACHE_SAVE_MS = 30000;
|
|
4155
|
+
const persistSessionCache = () => saveSessionCache((data) => writeJsonAtomic(SESSION_CACHE_FILE, data));
|
|
4156
|
+
let prewarmStarted = false;
|
|
4157
|
+
function startPrewarm() {
|
|
4158
|
+
if (prewarmStarted) return;
|
|
4159
|
+
prewarmStarted = true;
|
|
4160
|
+
setImmediate(prewarmCaches);
|
|
4161
|
+
}
|
|
4162
|
+
|
|
4083
4163
|
async function prewarmCaches() {
|
|
4084
4164
|
const t0 = Date.now();
|
|
4085
4165
|
try {
|
|
4086
4166
|
const metadata = loadSessionMetadata();
|
|
4087
4167
|
|
|
4088
|
-
let
|
|
4168
|
+
let sliceStart = Date.now();
|
|
4089
4169
|
for (const meta of Object.values(metadata)) {
|
|
4090
4170
|
if (meta?.jsonlPath) {
|
|
4091
4171
|
try { refreshLoopInfoState(meta.jsonlPath); } catch {}
|
|
4092
4172
|
}
|
|
4093
|
-
if (
|
|
4173
|
+
if (Date.now() - sliceStart >= PREWARM_SLICE_MS) {
|
|
4174
|
+
await new Promise(r => setImmediate(r));
|
|
4175
|
+
sliceStart = Date.now();
|
|
4176
|
+
}
|
|
4094
4177
|
}
|
|
4095
4178
|
|
|
4096
4179
|
console.log(`[prewarm] done in ${Date.now() - t0}ms (${Object.keys(metadata).length} sessions)`);
|
|
4180
|
+
persistSessionCache();
|
|
4097
4181
|
} catch (e) {
|
|
4098
4182
|
console.warn('[prewarm] failed:', e.message);
|
|
4099
4183
|
}
|
|
4100
4184
|
}
|
|
4101
4185
|
|
|
4102
4186
|
const onReady = (actualPort) => {
|
|
4103
|
-
|
|
4187
|
+
listenPort = actualPort;
|
|
4104
4188
|
// The port is configurable and falls back to a random one when taken, so the postman
|
|
4105
4189
|
// monitor cannot assume it -- publish the live one where it can read it.
|
|
4106
4190
|
writeServerInfo(actualPort);
|
|
4191
|
+
writeTerminalToken(actualPort);
|
|
4192
|
+
console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
|
|
4107
4193
|
setInterval(() => reclaimServerInfo(actualPort), 30000).unref();
|
|
4108
4194
|
migrateLegacyApprovalsConfig();
|
|
4109
4195
|
const warning = net.exposureWarning();
|
|
@@ -4118,9 +4204,15 @@ async function prewarmCaches() {
|
|
|
4118
4204
|
if (process.argv.includes('--open')) {
|
|
4119
4205
|
import('open').then(open => open.default(`http://localhost:${actualPort}`));
|
|
4120
4206
|
}
|
|
4121
|
-
|
|
4207
|
+
setTimeout(startPrewarm, PREWARM_FALLBACK_MS).unref();
|
|
4122
4208
|
};
|
|
4123
4209
|
|
|
4210
|
+
loadSessionCache(SESSION_CACHE_FILE);
|
|
4211
|
+
// Under the hub a child can end by TerminateProcess on Windows, where no exit handler runs,
|
|
4212
|
+
// so the cache is also saved on a timer.
|
|
4213
|
+
setInterval(persistSessionCache, SESSION_CACHE_SAVE_MS).unref();
|
|
4214
|
+
process.on('exit', persistSessionCache);
|
|
4215
|
+
|
|
4124
4216
|
const listenOpts = { onUpgrade: terminal.handleUpgrade };
|
|
4125
4217
|
const server = net.listenLoopback(app, PORT, onReady, listenOpts);
|
|
4126
4218
|
process.on('exit', terminal.shutdown);
|
|
@@ -4135,4 +4227,5 @@ async function prewarmCaches() {
|
|
|
4135
4227
|
});
|
|
4136
4228
|
|
|
4137
4229
|
|
|
4138
|
-
// #endregion
|
|
4230
|
+
// #endregion
|
|
4231
|
+
}
|
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
|