claude-code-kanban 4.28.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/install.js CHANGED
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const fs = require('fs');
4
- const path = require('path');
5
- const os = require('os');
6
- const readline = require('readline');
7
- const { execSync } = require('child_process');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const readline = require('node:readline');
6
+ const { execSync } = require('node:child_process');
8
7
  const { getClaudeDir, claudeCliEnv, displayPath } = require('./lib/claude-dir');
9
8
 
10
9
  const CLAUDE_DIR = getClaudeDir();
@@ -205,7 +204,7 @@ async function runInstall({ pluginOnly = false } = {}) {
205
204
  console.log(`\n StatusLine: ${dim('not configured')}`);
206
205
  if (await prompt(` Set up context tracking statusline? [Y/n] `)) {
207
206
  settings.statusLine = { type: 'command', command: CTX_COMMAND };
208
- fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
207
+ fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`);
209
208
  console.log(` ${green('✓')} StatusLine configured`);
210
209
  } else {
211
210
  console.log(` ${dim('Skipped')}`);
@@ -216,7 +215,7 @@ async function runInstall({ pluginOnly = false } = {}) {
216
215
  if (await prompt(` Prepend context spy to existing statusline? [Y/n] `)) {
217
216
  settings.statusLine.type = 'command';
218
217
  settings.statusLine.command = `${CTX_COMMAND} | ${existing}`;
219
- fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
218
+ fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`);
220
219
  console.log(` ${green('✓')} StatusLine updated`);
221
220
  } else {
222
221
  console.log(` ${dim('Skipped')}`);
@@ -294,7 +293,7 @@ async function runUninstall() {
294
293
  }
295
294
 
296
295
  if (changed) {
297
- fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
296
+ fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`);
298
297
  }
299
298
  } catch {
300
299
  console.log(` Settings: ${red('✗')} Could not parse settings.json`);
package/lib/approvals.js CHANGED
@@ -17,7 +17,7 @@ function buildDecision(marker, body) {
17
17
  if (!marker || marker.status !== 'waiting') {
18
18
  return { error: 'No pending ask', status: 410 };
19
19
  }
20
- const id = sanitizeRequestId(body && body.id);
20
+ const id = sanitizeRequestId(body?.id);
21
21
  if (!id) return { error: 'Invalid or missing request id', status: 400 };
22
22
  if (marker.id !== id) return { error: 'Ask superseded by a newer one', status: 409 };
23
23
 
@@ -68,7 +68,7 @@ const WAIT_SECONDS_MAX = 1800;
68
68
  // Small grace over the gate's own deadline so a race never 410s a live hook
69
69
  const LAPSE_GRACE_MS = 5000;
70
70
  function waitSecondsFrom(cfg) {
71
- const raw = cfg && cfg.waitSeconds;
71
+ const raw = cfg?.waitSeconds;
72
72
  if (Number.isInteger(raw) && raw >= 0) return Math.min(raw, WAIT_SECONDS_MAX);
73
73
  return WAIT_SECONDS_DEFAULT;
74
74
  }
package/lib/contain.js CHANGED
@@ -11,8 +11,8 @@
11
11
  // CANONICAL COPY: scripts/security-lib/contain.js in the claude-code-hub repo.
12
12
  // Run scripts/sync-security-lib.sh after editing.
13
13
 
14
- const path = require('path');
15
- const fs = require('fs');
14
+ const path = require('node:path');
15
+ const fs = require('node:fs');
16
16
 
17
17
  const IS_WIN = process.platform === 'win32';
18
18
 
@@ -1,5 +1,6 @@
1
1
  // Transient session groups: `dispatch start --group` (or the starter's own group) puts
2
- // the started session and its starter together. A group lives while any member's claude
2
+ // the started session in a group. The starter is not moved: it is only remembered, so its
3
+ // later dispatches default to the same group. A group lives while any member's claude
3
4
  // runs, and for a grace period after; once none runs, only pinned members stay in it.
4
5
  // The map is on disk so a pinned group outlives a server restart.
5
6
 
@@ -24,28 +25,39 @@ function suggestGroupName(s) {
24
25
 
25
26
  /**
26
27
  * @param {object} o
27
- * @param {() => object|null} o.load returns `{sessions: {[id]: group}}` or null
28
+ * @param {() => object|null} o.load returns `{sessions: {[id]: group}, starters?: {[id]: starterId}}` or null
28
29
  * @param {(data: object) => void} o.save
29
30
  * @param {(id: string) => boolean} o.isAlive
30
31
  * @param {() => Set<string>} o.pinnedIds
31
32
  */
32
33
  function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
33
34
  const members = new Map();
35
+ const starterOf = new Map();
34
36
  const liveAt = new Map();
35
- const saved = load()?.sessions;
36
- if (saved && typeof saved === 'object') {
37
- for (const [id, g] of Object.entries(saved)) if (isGroupName(g)) members.set(id, g);
37
+ const saved = load();
38
+ if (saved?.sessions && typeof saved.sessions === 'object') {
39
+ for (const [id, g] of Object.entries(saved.sessions)) if (isGroupName(g)) members.set(id, g);
40
+ }
41
+ if (saved?.starters && typeof saved.starters === 'object') {
42
+ for (const [id, s] of Object.entries(saved.starters)) if (members.has(id) && typeof s === 'string') starterOf.set(id, s);
38
43
  }
39
44
  for (const g of members.values()) liveAt.set(g, now());
40
45
 
41
- const persist = () => save({ version: 1, sessions: Object.fromEntries(members) });
46
+ const persist = () =>
47
+ save({ version: 1, sessions: Object.fromEntries(members), starters: Object.fromEntries(starterOf) });
42
48
 
43
- function join(group, ids) {
49
+ function join(group, ids, starter) {
44
50
  let changed = false;
45
51
  for (const id of ids) {
46
- if (!id || members.get(id) === group) continue;
47
- members.set(id, group);
48
- changed = true;
52
+ if (!id) continue;
53
+ if (members.get(id) !== group) {
54
+ members.set(id, group);
55
+ changed = true;
56
+ }
57
+ if (starter && starterOf.get(id) !== starter) {
58
+ starterOf.set(id, starter);
59
+ changed = true;
60
+ }
49
61
  }
50
62
  liveAt.set(group, now());
51
63
  if (changed) persist();
@@ -70,6 +82,7 @@ function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
70
82
  for (const id of ids) {
71
83
  if (pinned.has(id)) continue;
72
84
  members.delete(id);
85
+ starterOf.delete(id);
73
86
  changed = true;
74
87
  }
75
88
  if (!ids.some((id) => pinned.has(id))) liveAt.delete(g);
@@ -77,9 +90,13 @@ function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
77
90
  if (changed) persist();
78
91
  }
79
92
 
93
+ // A starter is not a member; it defaults to the group of a session it started.
80
94
  function groupOf(id) {
81
95
  refresh();
82
- return members.get(id) || null;
96
+ if (members.has(id)) return members.get(id);
97
+ let group = null;
98
+ for (const [child, starter] of starterOf) if (starter === id) group = members.get(child);
99
+ return group;
83
100
  }
84
101
 
85
102
  function snapshot() {
package/lib/dispatch.js CHANGED
@@ -112,6 +112,7 @@ function createDispatchRegistry({ onSettle, now = Date.now } = {}) {
112
112
  if (!tokenMatches(r.cap, cap)) return { status: 403, error: 'wrong capability' };
113
113
  if (r.status !== 'running') return { status: 409, error: `already ${r.status}` };
114
114
  if (!OUTCOMES.has(outcome)) return { status: 400, error: 'outcome must be succeeded or failed' };
115
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
115
116
  const text = typeof summary === 'string' ? summary.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, MAX_SUMMARY) : '';
116
117
  finish(r, outcome, text || null);
117
118
  return null;
package/lib/file-url.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // spelling `file:\\\C:\dir\a.md`. Both are normalized to a plain OS path so the
7
7
  // user does not have to hand-edit what their clipboard produced.
8
8
 
9
- const { fileURLToPath } = require('url');
9
+ const { fileURLToPath } = require('node:url');
10
10
 
11
11
  // Matches any number of leading slashes before a drive letter, so it re-spells both
12
12
  // the well-formed `file:///C:/dir` and `file://C:/dir`, where the parser reads C: as
@@ -5,8 +5,8 @@
5
5
  //
6
6
  // Remote refs (https:, //cdn, data:) are left untouched: they already resolve.
7
7
 
8
- const path = require('path');
9
- const fs = require('fs').promises;
8
+ const path = require('node:path');
9
+ const fs = require('node:fs').promises;
10
10
 
11
11
  // Individually large assets are the ones that blow up as base64 (+33%), and the total
12
12
  // guards the modal: the whole document is handed to the client in one JSON response.
@@ -0,0 +1,56 @@
1
+ // Server copy of each session's linked docs. The browser keeps its own list in localStorage
2
+ // and merges this one in when it connects, so a `link-doc` sent while no tab is open is not lost.
3
+ // Newest first, capped like the browser list.
4
+
5
+ const MAX_PER_SESSION = 20;
6
+
7
+ // Same rule as `canonicalPath` in public/app.js: one Windows file is reached as `C:\a` and `c:/a`.
8
+ function canonicalPath(p) {
9
+ const slashed = String(p).replace(/\\/g, '/');
10
+ return /^[A-Za-z]:/.test(slashed) ? slashed.toLowerCase() : slashed;
11
+ }
12
+
13
+ /**
14
+ * @param {object} o
15
+ * @param {() => object|null} o.load returns `{sessions: {[id]: string[]}}` or null
16
+ * @param {(data: object) => void} o.save
17
+ */
18
+ function createLinkedDocStore({ load, save }) {
19
+ const bySession = new Map();
20
+ const saved = load()?.sessions;
21
+ if (saved && typeof saved === 'object') {
22
+ for (const [id, paths] of Object.entries(saved)) {
23
+ const clean = Array.isArray(paths) ? paths.filter((p) => typeof p === 'string' && p) : [];
24
+ if (clean.length) bySession.set(id, clean.slice(0, MAX_PER_SESSION));
25
+ }
26
+ }
27
+
28
+ const persist = () => save({ version: 1, sessions: Object.fromEntries(bySession) });
29
+
30
+ function link(sessionId, filePath) {
31
+ const key = canonicalPath(filePath);
32
+ const rest = (bySession.get(sessionId) || []).filter((p) => canonicalPath(p) !== key);
33
+ bySession.set(sessionId, [filePath, ...rest].slice(0, MAX_PER_SESSION));
34
+ persist();
35
+ }
36
+
37
+ // With no path, forgets every doc of the session. Returns whether anything was removed.
38
+ function unlink(sessionId, filePath) {
39
+ const paths = bySession.get(sessionId);
40
+ if (!paths) return false;
41
+ const key = filePath == null ? null : canonicalPath(filePath);
42
+ const rest = key === null ? [] : paths.filter((p) => canonicalPath(p) !== key);
43
+ if (rest.length === paths.length) return false;
44
+ if (rest.length) bySession.set(sessionId, rest);
45
+ else bySession.delete(sessionId);
46
+ persist();
47
+ return true;
48
+ }
49
+
50
+ const get = (sessionId) => [...(bySession.get(sessionId) || [])];
51
+ const all = () => Object.fromEntries([...bySession].map(([id, paths]) => [id, [...paths]]));
52
+
53
+ return { link, unlink, get, all };
54
+ }
55
+
56
+ module.exports = { createLinkedDocStore, canonicalPath, MAX_PER_SESSION };
package/lib/net-guard.js CHANGED
@@ -245,7 +245,7 @@ function createNetGuard(config = {}) {
245
245
  * @param {{maxHeaderSize?: number, onUpgrade?: (req: any, socket: any, head: Buffer) => void}} [opts]
246
246
  */
247
247
  function listenLoopback(app, port, onReady, opts = {}) {
248
- const http = require('http');
248
+ const http = require('node:http');
249
249
  const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
250
250
  const makeServer = (handler) => {
251
251
  const s = http.createServer(serverOpts, handler);
@@ -21,9 +21,9 @@
21
21
  // npm packages and cannot import from each other. Run scripts/sync-security-lib.sh
22
22
  // after editing.
23
23
 
24
- const { spawn } = require('child_process');
25
- const path = require('path');
26
- const fs = require('fs');
24
+ const { spawn } = require('node:child_process');
25
+ const path = require('node:path');
26
+ const fs = require('node:fs');
27
27
 
28
28
  const IS_WIN = process.platform === 'win32';
29
29
 
@@ -37,6 +37,7 @@ const VSCODE_FAMILY = new Set([
37
37
  // escape it. Inside "..." cmd treats & | < > ^ ( ) as literal, so only these
38
38
  // matter: '"' ends the quoted run, '%' triggers variable expansion whose value
39
39
  // could itself contain a quote. Neither is legal in an NTFS path anyway.
40
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: NUL is one of the rejected characters
40
41
  const WIN_CMD_FORBIDDEN = /["%\r\n\u0000]/;
41
42
 
42
43
  function fail(message, status) {
@@ -168,6 +169,7 @@ function resolveEditor() {
168
169
  */
169
170
  function assertOpenTarget(value, label = 'path') {
170
171
  if (typeof value !== 'string' || !value.trim()) throw fail(`${label} must be a non-empty string`, 400);
172
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: NUL is one of the rejected characters
171
173
  if (/[\r\n\u0000]/.test(value)) throw fail(`${label} contains invalid characters`, 400);
172
174
  const resolved = path.resolve(value);
173
175
  if (!fs.existsSync(resolved)) throw fail(`${label} does not exist`, 404);
@@ -0,0 +1,58 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { countTranscriptsBornBefore } = require('./parsers');
4
+
5
+ // Verdicts of lookupParentSession in server.js. A verdict depends on the child's head and on
6
+ // the transcripts of its folder born before it, because only those can be its parent. So a new
7
+ // transcript cannot change it, but a replaced child or parent, a compact anchor seen later, or
8
+ // an older transcript moved into the folder can. Each entry records these and is checked on use.
9
+ const verdicts = new Map();
10
+ let dirty = false;
11
+
12
+ function getParentVerdict(sessionId, meta) {
13
+ const v = verdicts.get(sessionId);
14
+ if (!v || !meta?.jsonlPath || v.childPath !== meta.jsonlPath) return null;
15
+ if (v.logicalParentUuid !== (meta.logicalParentUuid || null)) return null;
16
+ try {
17
+ const self = fs.statSync(v.childPath);
18
+ if (self.ino !== v.childIno) return null;
19
+ if (v.parentIno !== null && fs.statSync(v.result.parentJsonlPath).ino !== v.parentIno) return null;
20
+ if (countTranscriptsBornBefore(path.dirname(v.childPath), self.birthtimeMs) !== v.olderCount) return null;
21
+ } catch (_) {
22
+ return null;
23
+ }
24
+ return v.result;
25
+ }
26
+
27
+ // self: the child's stat from the lookup. parentIno: null when no parent was found.
28
+ function setParentVerdict(sessionId, meta, self, parentIno, result) {
29
+ verdicts.set(sessionId, {
30
+ childPath: meta.jsonlPath,
31
+ childIno: self.ino,
32
+ logicalParentUuid: meta.logicalParentUuid || null,
33
+ parentIno,
34
+ olderCount: countTranscriptsBornBefore(path.dirname(meta.jsonlPath), self.birthtimeMs),
35
+ result,
36
+ });
37
+ dirty = true;
38
+ }
39
+
40
+ const parentVerdictsDirty = () => dirty;
41
+
42
+ function exportParentVerdicts() {
43
+ dirty = false;
44
+ return [...verdicts].filter(([, v]) => fs.existsSync(v.childPath));
45
+ }
46
+
47
+ function importParentVerdicts(entries) {
48
+ if (!Array.isArray(entries)) return;
49
+ const num = (x) => typeof x === 'number';
50
+ for (const pair of entries) {
51
+ const v = Array.isArray(pair) ? pair[1] : null;
52
+ if (typeof pair?.[0] !== 'string' || !v || typeof v.childPath !== 'string' || !num(v.childIno) || !num(v.olderCount)) continue;
53
+ if (!(v.parentIno === null || num(v.parentIno)) || typeof v.result?.relation !== 'string') continue;
54
+ verdicts.set(pair[0], v);
55
+ }
56
+ }
57
+
58
+ module.exports = { getParentVerdict, setParentVerdict, parentVerdictsDirty, exportParentVerdicts, importParentVerdicts };