claude-code-kanban 4.24.0 → 4.26.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 +4 -2
- package/cli.js +1 -1
- package/install.js +30 -10
- package/lib/approvals.js +34 -3
- package/lib/claude-dir.js +53 -0
- package/lib/open-editor.js +4 -1
- package/lib/parsers.js +118 -4
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +2 -2
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +58 -23
- package/plugin/plugins/claude-code-kanban/scripts/context-status.sh +2 -2
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +2 -1
- package/public/app.js +428 -126
- package/public/index.html +21 -3
- package/public/style.css +216 -17
- package/server.js +141 -46
package/server.js
CHANGED
|
@@ -21,6 +21,7 @@ const {
|
|
|
21
21
|
readSessionInfoFromJsonl,
|
|
22
22
|
buildSessionDigest,
|
|
23
23
|
readCompactSummaries,
|
|
24
|
+
readArtifactLinks,
|
|
24
25
|
extractPromptFromTranscript,
|
|
25
26
|
extractModelFromTranscript,
|
|
26
27
|
extractStructuredResultFromTranscript,
|
|
@@ -33,7 +34,8 @@ const {
|
|
|
33
34
|
buildLoopInfoFromState
|
|
34
35
|
} = require('./lib/parsers');
|
|
35
36
|
const { inlineHtmlAssets } = require('./lib/inline-assets');
|
|
36
|
-
const { buildDecision, decisionFileName, isDecisionFile,
|
|
37
|
+
const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
|
|
38
|
+
const { getClaudeDir, getArgValue, storageNamespace } = require('./lib/claude-dir');
|
|
37
39
|
|
|
38
40
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
39
41
|
const { runInstall, runUninstall } = require("./install");
|
|
@@ -56,30 +58,8 @@ app.use(net.hostGuard);
|
|
|
56
58
|
app.use(net.frameGuard);
|
|
57
59
|
app.use(net.originGuard);
|
|
58
60
|
|
|
59
|
-
// Parse --dir flag for custom Claude directory
|
|
60
|
-
function getClaudeDir() {
|
|
61
|
-
const dirIndex = process.argv.findIndex(arg => arg.startsWith('--dir'));
|
|
62
|
-
if (dirIndex !== -1) {
|
|
63
|
-
const arg = process.argv[dirIndex];
|
|
64
|
-
if (arg.includes('=')) {
|
|
65
|
-
const dir = arg.split('=')[1];
|
|
66
|
-
return dir.startsWith('~') ? dir.replace('~', os.homedir()) : dir;
|
|
67
|
-
} else if (process.argv[dirIndex + 1]) {
|
|
68
|
-
const dir = process.argv[dirIndex + 1];
|
|
69
|
-
return dir.startsWith('~') ? dir.replace('~', os.homedir()) : dir;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_DIR || path.join(os.homedir(), '.claude');
|
|
73
|
-
}
|
|
74
|
-
|
|
75
61
|
function getArgUrl(argName, envName) {
|
|
76
|
-
|
|
77
|
-
if (idx !== -1) {
|
|
78
|
-
const arg = process.argv[idx];
|
|
79
|
-
if (arg.includes('=')) return arg.split('=').slice(1).join('=');
|
|
80
|
-
if (process.argv[idx + 1]) return process.argv[idx + 1];
|
|
81
|
-
}
|
|
82
|
-
return process.env[envName] || null;
|
|
62
|
+
return getArgValue(argName) || process.env[envName] || null;
|
|
83
63
|
}
|
|
84
64
|
|
|
85
65
|
const MARKETPLACE_URL = getArgUrl('marketplace-url', 'MARKETPLACE_URL');
|
|
@@ -126,12 +106,33 @@ function writePins(pins) {
|
|
|
126
106
|
writeJsonAtomic(PINS_FILE, pins);
|
|
127
107
|
}
|
|
128
108
|
|
|
129
|
-
// Port discovery for out-of-process helpers (the postman monitor).
|
|
130
|
-
// so a reader can tell a live server from a file left behind by
|
|
109
|
+
// Port discovery for out-of-process helpers (the postman monitor, approval-gate.sh).
|
|
110
|
+
// The pid rides along so a reader can tell a live server from a file left behind by
|
|
111
|
+
// a crashed one.
|
|
131
112
|
function writeServerInfo(port) {
|
|
132
113
|
writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
|
|
133
114
|
}
|
|
134
115
|
|
|
116
|
+
// A beacon that outlives its server disarms UI approvals silently: approval-gate.sh
|
|
117
|
+
// probes the port, finds it closed and never waits. The hub spawns every sub-app on
|
|
118
|
+
// an ephemeral port, so a stale beacon never comes back on its own — drop ours on
|
|
119
|
+
// the way out. Only when the file is still ours: a newer server on the same config
|
|
120
|
+
// dir has already claimed it.
|
|
121
|
+
function removeServerInfo() {
|
|
122
|
+
try {
|
|
123
|
+
const info = JSON.parse(readFileSync(SERVER_INFO_FILE, 'utf8'));
|
|
124
|
+
if (info.pid === process.pid) unlinkSync(SERVER_INFO_FILE);
|
|
125
|
+
} catch (_) { /* gone, unreadable, or not ours */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
process.on('exit', removeServerInfo);
|
|
129
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
130
|
+
process.on(sig, () => {
|
|
131
|
+
removeServerInfo();
|
|
132
|
+
process.exit(0);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
135
136
|
// #endregion
|
|
136
137
|
|
|
137
138
|
// #region TIMINGS
|
|
@@ -176,23 +177,30 @@ function persistAgent(dir, agent) {
|
|
|
176
177
|
fs.appendFile(file, `${JSON.stringify({ ...agent, event: 'server-update' })}\n`, 'utf8').catch(() => {});
|
|
177
178
|
}
|
|
178
179
|
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
function
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
try {
|
|
188
|
-
cfg = JSON.parse(readFileSync(APPROVALS_CONFIG_FILE, 'utf8'));
|
|
189
|
-
} catch { /* missing config — gate default */ }
|
|
190
|
-
approvalsWaitCache = { ts: Date.now(), ms: waitSecondsFrom(cfg) * 1000 };
|
|
191
|
-
return approvalsWaitCache.ms;
|
|
180
|
+
// <CCK_DIR>/config.json — cck's own settings, per Claude config dir.
|
|
181
|
+
// Normalization lives in lib/approvals (kept in sync with the gate's own parse).
|
|
182
|
+
const CCK_CONFIG_FILE = path.join(CCK_DIR, 'config.json');
|
|
183
|
+
const LEGACY_APPROVALS_FILE = path.join(CCK_DIR, 'approvals.json');
|
|
184
|
+
const cckConfigCache = new Map();
|
|
185
|
+
function approvalsConfig() {
|
|
186
|
+
return cachedByMtime(cckConfigCache, 'approvals', CCK_CONFIG_FILE,
|
|
187
|
+
() => approvalsFrom(JSON.parse(readFileSync(CCK_CONFIG_FILE, 'utf8'))), approvalsFrom(null));
|
|
192
188
|
}
|
|
193
189
|
|
|
194
|
-
|
|
195
|
-
|
|
190
|
+
// approvals.json predates config.json (and was opt-in). Fold it into
|
|
191
|
+
// config.json once so an existing opt-in keeps its tuning, then drop it.
|
|
192
|
+
function migrateLegacyApprovalsConfig() {
|
|
193
|
+
if (!existsSync(LEGACY_APPROVALS_FILE)) return;
|
|
194
|
+
try {
|
|
195
|
+
const legacy = JSON.parse(readFileSync(LEGACY_APPROVALS_FILE, 'utf8'));
|
|
196
|
+
let cfg = {};
|
|
197
|
+
try { cfg = JSON.parse(readFileSync(CCK_CONFIG_FILE, 'utf8')) || {}; } catch { /* absent */ }
|
|
198
|
+
if (!cfg.approvals) writeJsonAtomic(CCK_CONFIG_FILE, { ...cfg, approvals: legacy });
|
|
199
|
+
unlinkSync(LEGACY_APPROVALS_FILE);
|
|
200
|
+
cckConfigCache.clear();
|
|
201
|
+
} catch (e) {
|
|
202
|
+
console.error(`[cck] could not migrate ${LEGACY_APPROVALS_FILE}: ${e.message}`);
|
|
203
|
+
}
|
|
196
204
|
}
|
|
197
205
|
|
|
198
206
|
function checkWaitingForUser(agentDir, logMtime) {
|
|
@@ -204,7 +212,9 @@ function checkWaitingForUser(agentDir, logMtime) {
|
|
|
204
212
|
if (age >= PERMISSION_TTL_MS) return null;
|
|
205
213
|
// After grace period, check if session resumed activity (user already responded)
|
|
206
214
|
if (logMtime && age >= WAITING_RESOLVE_GRACE_MS && logMtime > waitTime + WAITING_RESOLVE_GRACE_MS) return null;
|
|
207
|
-
|
|
215
|
+
// Opted out reads the same as lapsed to the board: the ask is only
|
|
216
|
+
// answerable in the terminal, so no buttons.
|
|
217
|
+
if (boardRefusal(data, approvalsConfig())) return { ...data, lapsed: true };
|
|
208
218
|
return data;
|
|
209
219
|
}
|
|
210
220
|
} catch { /* skip — missing or invalid */ }
|
|
@@ -267,6 +277,34 @@ function getGitBranch(cwd) {
|
|
|
267
277
|
return branch;
|
|
268
278
|
}
|
|
269
279
|
|
|
280
|
+
// A linked worktree's `.git` is a file holding `gitdir: <main>/.git/worktrees/<name>`, so the
|
|
281
|
+
// main checkout is readable without spawning git. Path shape alone would not do: only some
|
|
282
|
+
// worktrees live under `<repo>/.claude/worktrees/`, the rest sit beside the repo.
|
|
283
|
+
// Cached without a TTL, misses included: a directory cannot turn from an ordinary checkout into
|
|
284
|
+
// a linked worktree without being recreated, and a recreated path is a new cache key.
|
|
285
|
+
const worktreeCache = new Map();
|
|
286
|
+
const WORKTREE_CACHE_MAX = 500;
|
|
287
|
+
const GITDIR_WORKTREE_RE = /^gitdir:\s*(.*)[/\\]\.git[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
|
|
288
|
+
|
|
289
|
+
function resolveWorktree(dir) {
|
|
290
|
+
if (!dir) return null;
|
|
291
|
+
if (worktreeCache.has(dir)) return worktreeCache.get(dir);
|
|
292
|
+
|
|
293
|
+
let worktree = null;
|
|
294
|
+
try {
|
|
295
|
+
// An ordinary checkout's `.git` is a directory, so the read throws EISDIR — that is the
|
|
296
|
+
// answer, and it costs one syscall instead of a stat followed by a read.
|
|
297
|
+
const m = GITDIR_WORKTREE_RE.exec(readFileSync(path.join(dir, '.git'), 'utf8').trim());
|
|
298
|
+
if (m) worktree = { repo: m[1], name: m[2] };
|
|
299
|
+
} catch (_) {}
|
|
300
|
+
|
|
301
|
+
worktreeCache.set(dir, worktree);
|
|
302
|
+
if (worktreeCache.size > WORKTREE_CACHE_MAX) {
|
|
303
|
+
worktreeCache.delete(worktreeCache.keys().next().value);
|
|
304
|
+
}
|
|
305
|
+
return worktree;
|
|
306
|
+
}
|
|
307
|
+
|
|
270
308
|
// Only spawn git when cwd has diverged from the launch project — that's the
|
|
271
309
|
// only case the JSONL value is wrong. Saves N spawns on a typical list build.
|
|
272
310
|
function resolveSessionGitBranch(meta) {
|
|
@@ -516,6 +554,18 @@ app.get('/hub-config', (_req, res) => {
|
|
|
516
554
|
res.json({ enabled: !!process.env.CLAUDE_HUB, url: process.env.HUB_URL || null });
|
|
517
555
|
});
|
|
518
556
|
|
|
557
|
+
// app.js reads localStorage synchronously at startup, before any fetch could deliver the
|
|
558
|
+
// namespace, so it is injected into the page instead of served from /hub-config.
|
|
559
|
+
const STORAGE_NS = storageNamespace(CLAUDE_DIR);
|
|
560
|
+
const NS_DECL = 'window.__STORAGE_NS__ = null;';
|
|
561
|
+
const RAW_INDEX = readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8');
|
|
562
|
+
if (!RAW_INDEX.includes(NS_DECL)) throw new Error(`public/index.html is missing "${NS_DECL}"`);
|
|
563
|
+
const INDEX_HTML = RAW_INDEX.replace(NS_DECL, `window.__STORAGE_NS__ = ${JSON.stringify(STORAGE_NS)};`);
|
|
564
|
+
app.get(['/', '/index.html'], (_req, res) => {
|
|
565
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
566
|
+
res.type('html').send(INDEX_HTML);
|
|
567
|
+
});
|
|
568
|
+
|
|
519
569
|
// Serve static files
|
|
520
570
|
app.get('/sw.js', (_req, res) => {
|
|
521
571
|
res.setHeader('Cache-Control', 'no-cache');
|
|
@@ -1024,12 +1074,30 @@ function getSessionDisplayName(_sessionId, meta) {
|
|
|
1024
1074
|
return null;
|
|
1025
1075
|
}
|
|
1026
1076
|
|
|
1077
|
+
// The harness spells a project path into a temp/log dir name by replacing every
|
|
1078
|
+
// non-alphanumeric character with a dash, drive colon and separators included.
|
|
1079
|
+
function encodeProjectDirName(p) {
|
|
1080
|
+
return p.replace(/[^a-zA-Z0-9]/g, '-');
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1027
1083
|
// Derived by convention, not looked up: the harness creates the dir lazily, so a
|
|
1028
1084
|
// stat here would report "missing" for every session that has not written a temp
|
|
1029
1085
|
// file yet — and it would put IO on the session-list hot path. Pure string join.
|
|
1030
1086
|
function getScratchpadDir(id, meta) {
|
|
1031
1087
|
if (!meta.jsonlPath) return null;
|
|
1032
|
-
|
|
1088
|
+
const byProject = path.join(SCRATCHPAD_ROOT, path.basename(path.dirname(meta.jsonlPath)), id, 'scratchpad');
|
|
1089
|
+
|
|
1090
|
+
// A session launched with `claude -w` starts in the main checkout and enters the
|
|
1091
|
+
// worktree afterwards, so the harness keys its scratchpad on the repo while the
|
|
1092
|
+
// transcript ends up filed under the worktree. Neither the project nor any `cwd`
|
|
1093
|
+
// in the log records that, so the two candidates are told apart by which one the
|
|
1094
|
+
// harness actually created. Probed only for worktree sessions, and only until one
|
|
1095
|
+
// of them exists — before that there is nothing to disambiguate and `byProject`,
|
|
1096
|
+
// right for a session started inside the worktree, stands.
|
|
1097
|
+
const wt = resolveWorktree(meta.project);
|
|
1098
|
+
if (!wt || existsSync(byProject)) return byProject;
|
|
1099
|
+
const byRepo = path.join(SCRATCHPAD_ROOT, encodeProjectDirName(wt.repo), id, 'scratchpad');
|
|
1100
|
+
return existsSync(byRepo) ? byRepo : byProject;
|
|
1033
1101
|
}
|
|
1034
1102
|
|
|
1035
1103
|
function buildSessionObject(id, meta, overrides = {}) {
|
|
@@ -1044,6 +1112,7 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
1044
1112
|
cwd: meta.cwd || null,
|
|
1045
1113
|
description: meta.description || null,
|
|
1046
1114
|
gitBranch: resolveSessionGitBranch(meta),
|
|
1115
|
+
worktree: resolveWorktree(meta.project),
|
|
1047
1116
|
customTitle: meta.customTitle || null,
|
|
1048
1117
|
goal: meta.goal || null,
|
|
1049
1118
|
taskCount: 0,
|
|
@@ -1583,6 +1652,32 @@ app.get('/api/sessions/:sessionId/loop', (req, res) => {
|
|
|
1583
1652
|
}
|
|
1584
1653
|
});
|
|
1585
1654
|
|
|
1655
|
+
// Cold path — a full transcript scan, so the result is held until the file grows.
|
|
1656
|
+
// The zen panel asks again on every re-render, which then costs one stat.
|
|
1657
|
+
const artifactsByPath = new Map();
|
|
1658
|
+
|
|
1659
|
+
function getArtifactLinks(jsonlPath) {
|
|
1660
|
+
let stat;
|
|
1661
|
+
try { stat = statSync(jsonlPath); } catch (_) { return []; }
|
|
1662
|
+
const hit = artifactsByPath.get(jsonlPath);
|
|
1663
|
+
if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.artifacts;
|
|
1664
|
+
const artifacts = readArtifactLinks(jsonlPath);
|
|
1665
|
+
artifactsByPath.set(jsonlPath, { mtimeMs: stat.mtimeMs, size: stat.size, artifacts });
|
|
1666
|
+
return artifacts;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
app.get('/api/sessions/:sessionId/artifacts', (req, res) => {
|
|
1670
|
+
try {
|
|
1671
|
+
const metadata = loadSessionMetadata();
|
|
1672
|
+
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
1673
|
+
if (!meta?.jsonlPath) return res.json({ artifacts: [] });
|
|
1674
|
+
res.json({ artifacts: getArtifactLinks(meta.jsonlPath) });
|
|
1675
|
+
} catch (error) {
|
|
1676
|
+
console.error('Error reading artifacts:', error);
|
|
1677
|
+
res.status(500).json({ error: 'Failed to read artifacts' });
|
|
1678
|
+
}
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1586
1681
|
// API: List workflow scripts for a session. Parses each script's meta for the
|
|
1587
1682
|
// canonical name + description (cold path — only when the workflow modal opens).
|
|
1588
1683
|
app.get('/api/sessions/:sessionId/workflows', async (req, res) => {
|
|
@@ -2170,9 +2265,8 @@ app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
|
|
|
2170
2265
|
catch { /* missing or invalid → buildDecision reports 410 */ }
|
|
2171
2266
|
// The gate stopped polling after waitSeconds — an orphaned decision file would
|
|
2172
2267
|
// sit unconsumed while the card pretends the click worked.
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
}
|
|
2268
|
+
const refusal = marker && boardRefusal(marker, approvalsConfig());
|
|
2269
|
+
if (refusal) return res.status(refusal.status).json({ error: refusal.error });
|
|
2176
2270
|
const result = buildDecision(marker, req.body || {});
|
|
2177
2271
|
if (result.error) return res.status(result.status).json({ error: result.error });
|
|
2178
2272
|
try {
|
|
@@ -3463,6 +3557,7 @@ async function prewarmCaches() {
|
|
|
3463
3557
|
// The port is configurable and falls back to a random one when taken, so the postman
|
|
3464
3558
|
// monitor cannot assume it -- publish the live one where it can read it.
|
|
3465
3559
|
writeServerInfo(actualPort);
|
|
3560
|
+
migrateLegacyApprovalsConfig();
|
|
3466
3561
|
const warning = net.exposureWarning();
|
|
3467
3562
|
if (warning) console.log(warning);
|
|
3468
3563
|
|