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/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, waitSecondsFrom, isLapsed } = require('./lib/approvals');
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
- const idx = process.argv.findIndex(arg => arg.startsWith(`--${argName}`));
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). The pid rides along
130
- // so a reader can tell a live server from a file left behind by a crashed one.
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
- // Lapse math lives in lib/approvals (kept in sync with the gate's own parse);
180
- // this only reads the config, cached so the polled session list doesn't re-read
181
- // it per hit (marker stays visible for the badge until TTL).
182
- const APPROVALS_CONFIG_FILE = path.join(CCK_DIR, 'approvals.json');
183
- let approvalsWaitCache = { ts: 0, ms: 30 * 1000 };
184
- function approvalsWaitMs() {
185
- if (Date.now() - approvalsWaitCache.ts < 10 * 1000) return approvalsWaitCache.ms;
186
- let cfg = null;
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
- function isWaitingLapsed(data) {
195
- return isLapsed(data.timestamp, approvalsWaitMs());
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
- if (isWaitingLapsed(data)) return { ...data, lapsed: true };
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
- return path.join(SCRATCHPAD_ROOT, path.basename(path.dirname(meta.jsonlPath)), id, 'scratchpad');
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
- if (marker && isWaitingLapsed(marker)) {
2174
- return res.status(410).json({ error: 'Ask lapsed — answer it in the terminal' });
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