claude-code-kanban 7.6.1 → 7.8.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/lib/terminal.js CHANGED
@@ -34,10 +34,11 @@ const WORKTREE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
34
34
  // Claude Code keeps a list in tasks/<id>/, so the id must stay one path segment.
35
35
  const TASK_LIST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
36
36
  const MODELS = new Set(['fable', 'opus', 'sonnet', 'haiku']);
37
+ const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
37
38
  // Extra claude args reach the command line inside plain quotes (quoteArg), so a character
38
39
  // that ends or expands those quotes in any of the shells is refused.
39
40
  // biome-ignore lint/suspicious/noControlCharactersInRegex: refuses control characters on purpose
40
- const EXTRA_ARG_RE = /^[^\x00-\x1f\x7f'"%]*$/;
41
+ const EXTRA_ARG_RE = /^[^\x00-\x1f\x7f"%]*$/;
41
42
  const MAX_EXTRA_ARGS = 64;
42
43
  const MAX_EXTRA_ARG = 4096;
43
44
  // cck sets these itself, or they would start something other than a new session.
@@ -47,6 +48,12 @@ const OWNED_FLAGS = new Set([
47
48
  ]);
48
49
  const MAX_PROMPT = 32 * 1024;
49
50
  const PROMPT_QUIET_MS = 400;
51
+ const SUBMIT_POLL_MS = 1000;
52
+ const SUBMIT_CHECKS = 5;
53
+ const SUBMIT_RETRIES = 2;
54
+ // The answer lines of Claude Code's folder-trust dialog, current and earlier wording. They tell the
55
+ // board the dialog is up; the looser onTrustScreen only holds the prompt back, where a miss is worse.
56
+ const TRUST_DIALOG = /Yes, I trust this folder|Do you trust the files in this folder\?/;
50
57
  // Watermarks from https://xtermjs.org/docs/guides/flowcontrol/ — pause the PTY while a
51
58
  // client lags, so a runaway process cannot grow the socket buffer without bound.
52
59
  const HIGH_WATER = 128 * 1024;
@@ -142,11 +149,11 @@ function resolveShell(value, which, platform = process.platform, env = process.e
142
149
  }
143
150
 
144
151
  // claudeArgs is null for a plain shell. Its items are fixed flags, a validated UUID, or
145
- // values that passed NAME_RE / WORKTREE_RE / EXTRA_ARG_RE, so plain quoting is enough to
146
- // keep a space from splitting an argument and nothing can break out of the quotes.
152
+ // values that passed NAME_RE / WORKTREE_RE / EXTRA_ARG_RE. Those hold no " or %, so cmd's
153
+ // double quotes hold them, and quotePrompt escapes the ' a path such as O'Brien can carry.
147
154
  function quoteArg(arg, family) {
148
155
  if (/^[A-Za-z0-9._-]+$/.test(arg)) return arg;
149
- return family === 'cmd' ? `"${arg}"` : `'${arg}'`;
156
+ return family === 'cmd' ? `"${arg}"` : quotePrompt(arg, family);
150
157
  }
151
158
 
152
159
  // Free text, unlike quoteArg's input. Inside single quotes pwsh only reads a quote mark (it
@@ -182,9 +189,12 @@ function claudeArgsFor(mode, id, spec) {
182
189
  if (mode === 'new') {
183
190
  // claude takes --session-id with --resume only together with --fork-session.
184
191
  const fork = spec.forkOf ? ['--resume', spec.forkOf, '--fork-session'] : [];
185
- const args = [...fork, '--session-id', id, ...(spec.extraArgs || [])];
192
+ // A plain resume keeps the transcript's id, so the PTY's own id is not passed on.
193
+ const own = spec.resume ? ['--resume', spec.resume] : ['--session-id', id];
194
+ const args = [...fork, ...own, ...(spec.extraArgs || [])];
186
195
  if (spec.name) args.push('--name', spec.name);
187
196
  if (spec.model) args.push('--model', spec.model);
197
+ if (spec.effort) args.push('--effort', spec.effort);
188
198
  // Last, because its value is optional and a following flag must not be taken for it.
189
199
  if (spec.worktree === true) args.push('-w');
190
200
  else if (spec.worktree) args.push('-w', spec.worktree);
@@ -196,7 +206,7 @@ function claudeArgsFor(mode, id, spec) {
196
206
  function extraArgsError(args) {
197
207
  if (!Array.isArray(args) || args.length > MAX_EXTRA_ARGS) return `claude args (at most ${MAX_EXTRA_ARGS})`;
198
208
  for (const a of args) {
199
- if (typeof a !== 'string' || a.length > MAX_EXTRA_ARG || !EXTRA_ARG_RE.test(a)) return 'claude arg: no quotes, % or control characters';
209
+ if (typeof a !== 'string' || a.length > MAX_EXTRA_ARG || !EXTRA_ARG_RE.test(a)) return 'claude arg: no double quotes, % or control characters';
200
210
  const flag = a.split('=')[0];
201
211
  if (OWNED_FLAGS.has(flag)) return `claude arg: cck sets ${flag}`;
202
212
  }
@@ -207,6 +217,8 @@ function extraArgsError(args) {
207
217
  function parseNewSpec(msg) {
208
218
  const forkOf = msg.forkOf ?? null;
209
219
  if (forkOf !== null && (typeof forkOf !== 'string' || !UUID_RE.test(forkOf))) return 'session to fork';
220
+ const resume = msg.resume ?? null;
221
+ if (resume !== null && (typeof resume !== 'string' || !UUID_RE.test(resume) || forkOf)) return 'session to resume';
210
222
  if (!forkOf && (typeof msg.cwd !== 'string' || !msg.cwd)) return 'folder';
211
223
  const name = typeof msg.name === 'string' ? msg.name.trim() : '';
212
224
  if (name && !NAME_RE.test(name)) return 'name';
@@ -214,6 +226,8 @@ function parseNewSpec(msg) {
214
226
  if (typeof worktree !== 'boolean' && !(typeof worktree === 'string' && WORKTREE_RE.test(worktree))) return 'worktree name';
215
227
  const model = msg.model || null;
216
228
  if (model && !MODELS.has(model)) return 'model';
229
+ const effort = msg.effort || null;
230
+ if (effort && !EFFORTS.has(effort)) return 'effort';
217
231
  const taskList = msg.taskList || null;
218
232
  if (taskList && !(typeof taskList === 'string' && TASK_LIST_RE.test(taskList))) return 'task list id';
219
233
  const edit = msg.edit === true;
@@ -227,7 +241,8 @@ function parseNewSpec(msg) {
227
241
  const bad = extraArgsError(extraArgs);
228
242
  if (bad) return bad;
229
243
  if (forkOf && worktree) return 'worktree: a fork runs in its parent session folder';
230
- return { cwd: forkOf ? null : msg.cwd, forkOf, name: name || null, worktree, model, taskList, prompt: prompt || null, extraArgs, edit };
244
+ const restore = msg.restore !== false;
245
+ return { cwd: forkOf ? null : msg.cwd, forkOf, resume, name: name || null, worktree, model, effort, taskList, prompt: prompt || null, extraArgs, edit, restore };
231
246
  }
232
247
 
233
248
  function samePath(a, b) {
@@ -258,7 +273,11 @@ function screenText(term) {
258
273
  // show tool posts to.
259
274
  function ptyEnv({ claudeDir, isDefaultDir, noFlicker, cckUrl, terminalId }) {
260
275
  const env = { ...process.env };
276
+ const inHub = !!env.CLAUDE_HUB;
261
277
  for (const k of STRIP_ENV) delete env[k];
278
+ // Claude Code reports TERM_PROGRAM as the metrics attribute terminal_type.
279
+ env.TERM_PROGRAM = inHub ? 'claude-code-hub' : 'claude-code-kanban';
280
+ delete env.TERM_PROGRAM_VERSION;
262
281
  if (isDefaultDir) delete env.CLAUDE_CONFIG_DIR;
263
282
  else env.CLAUDE_CONFIG_DIR = claudeDir;
264
283
  if (cckUrl) env.CCK_URL = cckUrl;
@@ -353,6 +372,7 @@ function createTerminalService(o) {
353
372
  const saveDelayMs = o.saveDelayMs ?? SAVE_DELAY_MS;
354
373
  const restoreGapMs = o.restoreGapMs ?? RESTORE_GAP_MS;
355
374
  const exitPollMs = o.exitPollMs ?? CLAUDE_EXIT_POLL_MS;
375
+ const submitPollMs = o.submitPollMs ?? SUBMIT_POLL_MS;
356
376
  const pidAlive = o.isPidAlive || isPidAlive;
357
377
  const exitTimer = setInterval(checkExits, exitPollMs);
358
378
  exitTimer.unref();
@@ -418,7 +438,7 @@ function createTerminalService(o) {
418
438
  if (savedTerminalIds[id]) terminalIds[id] = savedTerminalIds[id];
419
439
  }
420
440
  for (const s of sessions.values()) {
421
- if (s.ended || s.claudeExitedAt || !RESTORABLE_MODES.has(s.mode)) continue;
441
+ if (s.ended || s.claudeExitedAt || !RESTORABLE_MODES.has(s.mode) || !s.restore) continue;
422
442
  ids.add(s.id);
423
443
  if (s.taskList) taskLists[s.id] = s.taskList;
424
444
  terminalIds[s.id] = s.terminalId;
@@ -475,13 +495,24 @@ function createTerminalService(o) {
475
495
 
476
496
  // Waits for claude to turn on bracketed paste (its input box is live), then for the
477
497
  // screen to settle. A folder-trust question also takes input, and Enter there would
478
- // answer it, so the prompt waits for as long as that question is on screen.
498
+ // answer it, so the prompt waits for as long as that question is on screen. Once it is
499
+ // answered, claude turns paste off and redraws for seconds; text sent then is lost.
479
500
  function bracketed(text) {
480
501
  return `\x1b[200~${text.replace(/\r?\n/g, '\r')}\x1b[201~`;
481
502
  }
482
503
 
504
+ // The folder and session names show on claude's main screen, so a "trust" in them is not the dialog.
483
505
  function onTrustScreen(s) {
484
- return /\btrust\b/i.test(screenText(s.term));
506
+ let text = screenText(s.term);
507
+ for (const own of [path.basename(s.cwd), s.name]) if (own) text = text.split(own).join('');
508
+ return /\btrust\b/i.test(text);
509
+ }
510
+
511
+ // Checked only while the session's first prompt waits, so it ends for good once the prompt is sent.
512
+ function setAwaitingTrust(s, on) {
513
+ if (s.awaitingTrust === on) return;
514
+ s.awaitingTrust = on;
515
+ changed();
485
516
  }
486
517
 
487
518
  // `submit` is claude's prompt: wait for its input box, then press Enter. Without it the text
@@ -493,18 +524,47 @@ function createTerminalService(o) {
493
524
  let timer = null;
494
525
  const text = bracketed(raw);
495
526
  s.onOutput = (data) => {
496
- if (!armed && data.includes('\x1b[?2004h')) armed = true;
497
- if (!armed) return;
527
+ if (submit) {
528
+ const on = data.lastIndexOf('\x1b[?2004h');
529
+ const off = data.lastIndexOf('\x1b[?2004l');
530
+ if (on !== off) armed = on > off;
531
+ }
498
532
  clearTimeout(timer);
533
+ if (!armed) return;
499
534
  timer = setTimeout(() => {
500
- if (s.exited || (submit && onTrustScreen(s))) return;
535
+ if (s.exited) return;
536
+ if (submit && onTrustScreen(s)) return setAwaitingTrust(s, TRUST_DIALOG.test(screenText(s.term)));
537
+ setAwaitingTrust(s, false);
501
538
  s.onOutput = null;
502
539
  s.pty.write(text);
503
- if (submit) setTimeout(() => { if (!s.exited) s.pty.write('\r'); }, 100);
540
+ if (submit) setTimeout(() => submitPrompt(s), 100);
504
541
  }, PROMPT_QUIET_MS);
505
542
  };
506
543
  }
507
544
 
545
+ // claude sometimes drops the Enter after the paste and leaves the prompt typed but not sent.
546
+ // It bumps statusUpdatedAt in its registry entry when a turn starts, so while the entry stays
547
+ // idle and older than the Enter, Enter is pressed again. Only an idle entry gets it: a turn
548
+ // that started can show a permission dialog, and Enter there would pick its default.
549
+ function submitPrompt(s) {
550
+ if (s.exited) return;
551
+ let at = Date.now();
552
+ let checks = 0;
553
+ let retries = 0;
554
+ s.pty.write('\r');
555
+ s.submitTimer = setInterval(() => {
556
+ const entry = ownEntry(o.liveSessions(), s, s.startedAt);
557
+ const unsent = !s.exited && entry?.status === 'idle' && typeof entry.statusUpdatedAt === 'number' && entry.statusUpdatedAt < at;
558
+ if (!unsent || (++checks === SUBMIT_CHECKS && retries === SUBMIT_RETRIES)) return clearInterval(s.submitTimer);
559
+ if (checks < SUBMIT_CHECKS) return;
560
+ checks = 0;
561
+ retries++;
562
+ at = Date.now();
563
+ s.pty.write('\r');
564
+ }, submitPollMs);
565
+ s.submitTimer.unref();
566
+ }
567
+
508
568
  // The process a viewer types into: claude, found through its registry entry, or the shell.
509
569
  function inputPid(s, registry) {
510
570
  if (s.mode === 'shell') return s.pty.pid;
@@ -544,8 +604,10 @@ function createTerminalService(o) {
544
604
  if (moved) changed();
545
605
  }
546
606
 
607
+ // A resumed transcript keeps its own id, which is not the PTY's.
547
608
  function ownEntry(live, s, since) {
548
- return live.find((l) => l.pid && l.sessionId === s.id && l.startedAt >= since - PICK_START_SLACK_MS);
609
+ const sid = s.resumeOf || s.id;
610
+ return live.find((l) => l.pid && l.sessionId === sid && l.startedAt >= since - PICK_START_SLACK_MS);
549
611
  }
550
612
 
551
613
  // claude writes its registry entry a moment after it starts, so the pid is polled for.
@@ -594,8 +656,8 @@ function createTerminalService(o) {
594
656
  term.loadAddon(serializer);
595
657
  const s = {
596
658
  id, terminalId, mode, cwd, pty: proc, term, serializer, sockets: new Set(), exited: false, ended: false, paused: false, startedAt: Date.now(),
597
- name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null, edit: !!spec?.edit,
598
- forkOf: spec?.forkOf || null,
659
+ name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null, awaitingTrust: false, edit: !!spec?.edit,
660
+ forkOf: spec?.forkOf || null, resumeOf: spec?.resume || null, restore: spec?.restore !== false,
599
661
  };
600
662
  if (s.edit) queueText(s, claudeCommand(shell, claudeArgs, spec.prompt), false);
601
663
  else if (spec?.prompt) queueText(s, spec.prompt, true);
@@ -613,6 +675,7 @@ function createTerminalService(o) {
613
675
  s.exited = true;
614
676
  clearInterval(s.pickTimer);
615
677
  clearInterval(s.boostTimer);
678
+ clearInterval(s.submitTimer);
616
679
  if (sessions.get(s.id) === s) sessions.delete(s.id);
617
680
  o.onExit?.(s.id);
618
681
  if (!s.ended) changed();
@@ -744,6 +807,9 @@ function createTerminalService(o) {
744
807
  if (mode === 'new') {
745
808
  spec = parseNewSpec(msg);
746
809
  if (typeof spec === 'string') return { code: 4002, status: 400, error: `invalid ${spec}` };
810
+ if (spec.resume && o.isLiveElsewhere(spec.resume)) {
811
+ return { code: 4006, status: 409, error: 'the session to resume is running in another terminal' };
812
+ }
747
813
  if (spec.edit && spec.prompt && shellFamily(shell) === 'cmd') {
748
814
  return { code: 4002, status: 400, error: 'cmd cannot take a typed prompt; clear the prompt or set the terminal shell to pwsh or gitbash' };
749
815
  }
@@ -756,7 +822,7 @@ function createTerminalService(o) {
756
822
  cwd = spec?.forkOf ? forkCwd : msg.cwd;
757
823
  if (spec?.forkOf && cwd === null) return { code: 4004, status: 404, error: 'unknown session to fork' };
758
824
  if (!spec?.forkOf && (typeof cwd !== 'string' || !(await o.isAllowedFolder(cwd)))) {
759
- return { code: 4004, status: 403, error: 'folder is not a known project or a folder picked in this run' };
825
+ return { code: 4004, status: 404, error: 'folder does not exist' };
760
826
  }
761
827
  } else if (mode !== 'shell' && known === null) {
762
828
  return { code: 4004, status: 404, error: 'unknown session' };
@@ -803,7 +869,7 @@ function createTerminalService(o) {
803
869
  function list() {
804
870
  return [...sessions.values()].filter((s) => !s.ended).map((s) => ({
805
871
  id: s.id, terminalId: s.terminalId, mode: s.mode, cwd: s.cwd, pid: s.pty.pid, clients: s.sockets.size, startedAt: s.startedAt,
806
- name: s.name, worktree: s.worktree, edit: s.edit, forkOf: s.forkOf,
872
+ name: s.name, worktree: s.worktree, edit: s.edit, forkOf: s.forkOf, claudeExited: !!s.claudeExitedAt, awaitingTrust: s.awaitingTrust,
807
873
  }));
808
874
  }
809
875
 
@@ -878,4 +944,4 @@ function createTerminalService(o) {
878
944
  return { token, handleUpgrade, clientConfig, list, claudePids, isRunning, end, authorized, startNew, paste, restore, shutdown, unavailableReason };
879
945
  }
880
946
 
881
- module.exports = { createTerminalService, readTerminalConfig, localUnavailableReason, refuseUpgrade, clientConfigFor, ptyEnv, shellArgs, claudeCommand, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
947
+ module.exports = { MODELS,createTerminalService, readTerminalConfig, localUnavailableReason, refuseUpgrade, clientConfigFor, ptyEnv, shellArgs, claudeCommand, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
@@ -0,0 +1,384 @@
1
+ // User groups: named groups that hold projects and sessions, and may nest under another group.
2
+ // The CLI and plugins change them one operation at a time; the board writes its copy whole with
3
+ // `replace`. `groups` is one flat list; its order is the order of siblings. A member is in at
4
+ // most one group. A group that `dispatch start --group` had to make is transient: the board
5
+ // removes it once its Active view shows none of its sessions, unless the user keeps it (clears
6
+ // the flag).
7
+
8
+ const { randomUUID } = require('node:crypto');
9
+ const groupTree = require('../public/group-tree');
10
+ const { httpError } = require('./http-error');
11
+ const { oneLine } = require('./one-line');
12
+
13
+ const MAX_GROUPS = 1000;
14
+ const MAX_MEMBERS = 500;
15
+ const MAX_DEPTH = 8;
16
+ const MAX_NAME = 120;
17
+ const MAX_REF = 4096;
18
+ const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
19
+ const TYPES = new Set(['project', 'session']);
20
+
21
+ const fail = (status, message) => {
22
+ throw httpError(status, message);
23
+ };
24
+
25
+ const isRef = (s) => typeof s === 'string' && s.length > 0 && s.length <= MAX_REF;
26
+ const cleanName = (name) => (typeof name === 'string' && oneLine(name, MAX_NAME)) || null;
27
+ const memberKey = (m) => `${m.type}\0${m.ref}`;
28
+
29
+ function cleanMember(m) {
30
+ if (!m || !TYPES.has(m.type) || !isRef(m.ref)) return null;
31
+ const out = { type: m.type, ref: m.ref };
32
+ if (m.type === 'session' && isRef(m.under)) out.under = m.under;
33
+ else if (m.type === 'session' && m.loose === true) out.loose = true;
34
+ if (m.type === 'session' && isRef(m.parent) && m.parent !== m.ref) out.parent = m.parent;
35
+ return out;
36
+ }
37
+
38
+ const sameMember = (a, b) => a.type === b.type && a.ref === b.ref;
39
+
40
+ const groupHolding = (groups, member) => groups.find((g) => g.members.some((m) => sameMember(m, member)));
41
+ const groupsNamed = (groups, name) => groups.filter((g) => g.name.toLowerCase() === name.toLowerCase());
42
+
43
+ // Where the board shows a session (sgGroupForSession in public/app.js).
44
+ const sessionGroup = (groups, { ref, project }) =>
45
+ groupHolding(groups, { type: 'session', ref }) || (project && groupHolding(groups, { type: 'project', ref: project })) || null;
46
+
47
+ function wouldCycle(list, id, parent) {
48
+ let depth = 0;
49
+ for (let p = parent; p; p = list.find((g) => g.id === p)?.parent ?? null) {
50
+ if (p === id || ++depth >= MAX_DEPTH) return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ // `taken` holds member keys already placed; a member found again is dropped, so the first group
56
+ // that lists it keeps it.
57
+ function readGroups(list, taken = new Set()) {
58
+ const out = [];
59
+ const seen = new Set();
60
+ for (const g of Array.isArray(list) ? list : []) {
61
+ if (!g || !ID_RE.test(g.id) || seen.has(g.id) || out.length >= MAX_GROUPS) continue;
62
+ seen.add(g.id);
63
+ const members = [];
64
+ for (const m of Array.isArray(g.members) ? g.members : []) {
65
+ const clean = cleanMember(m);
66
+ if (!clean || members.length >= MAX_MEMBERS || taken.has(memberKey(clean))) continue;
67
+ taken.add(memberKey(clean));
68
+ members.push(clean);
69
+ }
70
+ const group = { id: g.id, name: cleanName(g.name) || 'Group', parent: typeof g.parent === 'string' ? g.parent : null, members };
71
+ if (g.transient === true) group.transient = true;
72
+ out.push(group);
73
+ }
74
+ for (const g of out) {
75
+ if (g.parent && (!seen.has(g.parent) || g.parent === g.id)) g.parent = null;
76
+ }
77
+ for (const g of out) if (wouldCycle(out, g.id, g.parent)) g.parent = null;
78
+ return out;
79
+ }
80
+
81
+ /**
82
+ * @param {object} o
83
+ * @param {() => object|null|undefined} o.load `undefined` means keep what is in memory: the file has
84
+ * not changed since the last load, or cannot be read
85
+ * @param {(data: object) => void} o.save throws when the groups must not or cannot be written
86
+ */
87
+ function createUserGroupStore({ load, save, now = () => new Date().toISOString(), newId = randomUUID }) {
88
+ let groups = [];
89
+ let rev = 0;
90
+ let migratedAt = null;
91
+ // The last state read or saved. The changes edit `groups` in place, so a refused save puts it back.
92
+ let lastGood = {};
93
+
94
+ function apply(data) {
95
+ rev = Number.isSafeInteger(data.rev) && data.rev >= 0 ? data.rev : 0;
96
+ migratedAt = typeof data.groupsMigratedAt === 'string' ? data.groupsMigratedAt : null;
97
+ groups = readGroups(data.groups);
98
+ }
99
+
100
+ // Another board on the same config dir writes the same file. Returns whether the rev moved.
101
+ function reload() {
102
+ const saved = load();
103
+ if (saved === undefined) return false;
104
+ const before = rev;
105
+ lastGood = saved && typeof saved === 'object' ? saved : {};
106
+ apply(lastGood);
107
+ return rev !== before;
108
+ }
109
+ reload();
110
+
111
+ const byId = (id) => groups.find((g) => g.id === id) || null;
112
+ const mustGet = (id) => byId(id) || fail(404, `no group ${id}`);
113
+ const groupOf = (member) => groupHolding(groups, member);
114
+ const byName = (name) => groupsNamed(groups, name);
115
+
116
+ const state = () => ({ version: 2, rev, groupsMigratedAt: migratedAt, groups });
117
+
118
+ function commit() {
119
+ rev++;
120
+ const data = structuredClone(state());
121
+ try {
122
+ save(data);
123
+ } catch (e) {
124
+ apply(lastGood);
125
+ throw e;
126
+ }
127
+ lastGood = data;
128
+ return state();
129
+ }
130
+
131
+ function checkParent(id, parent) {
132
+ if (parent == null) return null;
133
+ if (typeof parent !== 'string' || !byId(parent)) fail(400, `no parent group ${parent}`);
134
+ if (wouldCycle(groups, id, parent)) fail(400, 'parent would make a cycle or nest too deep');
135
+ return parent;
136
+ }
137
+
138
+ function moveBefore(list, item, before, same) {
139
+ const from = list.indexOf(item);
140
+ if (from >= 0) list.splice(from, 1);
141
+ const to = before == null ? -1 : list.findIndex((x) => same(x, before));
142
+ if (to >= 0) list.splice(to, 0, item);
143
+ else list.push(item);
144
+ }
145
+
146
+ function detach(keys) {
147
+ for (const g of groups) g.members = g.members.filter((m) => !keys.has(memberKey(m)));
148
+ }
149
+
150
+ function addGroup({ id, name, parent = null, before = null, members = [], transient = false }) {
151
+ if (groups.length >= MAX_GROUPS) fail(422, `at most ${MAX_GROUPS} groups`);
152
+ if (id != null && (!ID_RE.test(id) || byId(id))) fail(400, 'id must be unique, 1-64 of [A-Za-z0-9_-]');
153
+ const gid = id ?? newGroupId();
154
+ const clean = readGroups([{ id: gid, members }])[0].members;
155
+ const group = { id: gid, name: cleanName(name) || `Group ${groups.length + 1}`, parent: checkParent(gid, parent), members: clean };
156
+ if (transient) group.transient = true;
157
+ detach(new Set(clean.map(memberKey)));
158
+ groups.push(group);
159
+ moveBefore(groups, group, before, (g, id) => g.id === id);
160
+ return group;
161
+ }
162
+
163
+ function create(opts = {}) {
164
+ const group = addGroup(opts);
165
+ return { group: group.id, ...commit() };
166
+ }
167
+
168
+ const pathOf = (group) => {
169
+ const names = [];
170
+ for (let g = group; g; g = byId(g.parent)) names.unshift(g.name);
171
+ return names.join('/');
172
+ };
173
+
174
+ // A path names a top-level group, then a child at each step. `make` adds the missing steps, and
175
+ // `make.transient` marks the steps it adds.
176
+ function findPath(path, make, hint) {
177
+ const steps = path.split('/').map(cleanName);
178
+ if (steps.some((s) => !s)) fail(400, `group path "${path}" has an empty step`);
179
+ if (steps.length > MAX_DEPTH) fail(400, `a group path has at most ${MAX_DEPTH} steps`);
180
+ let parent = null;
181
+ let created = false;
182
+ for (const step of steps) {
183
+ const named = byName(step).filter((g) => g.parent === (parent?.id ?? null));
184
+ if (named.length > 1) fail(400, `${named.length} groups are named "${pathOf(named[0])}"; pass one id: ${named.map((g) => g.id).join(', ')}`);
185
+ if (named.length) parent = named[0];
186
+ else if (make) [parent, created] = [addGroup({ name: step, parent: parent?.id ?? null, transient: make.transient }), true];
187
+ else return fail(404, `no group "${parent ? `${pathOf(parent)}/` : ''}${step}"; ${hint}`);
188
+ }
189
+ return { group: parent, created };
190
+ }
191
+
192
+ // The CLI names a group the way the user sees it: by id, by name ignoring case, or by path.
193
+ function findGroup(key, make, hint = 'pass --create to make it, or see `group list`') {
194
+ if (!isRef(key) || !key.trim()) fail(400, 'group must be a group id, name or path');
195
+ const hit = byId(key);
196
+ if (hit) return { group: hit, created: false };
197
+ const name = cleanName(key);
198
+ const named = byName(name);
199
+ if (!named.length && key.includes('/')) return findPath(key, make, hint);
200
+ if (named.length > 1) fail(400, `${named.length} groups are named "${name}"; pass one id: ${named.map((g) => g.id).join(', ')}`);
201
+ if (named.length) return { group: named[0], created: false };
202
+ if (make) return { group: addGroup({ name, transient: make.transient }), created: true };
203
+ const known = groups.map((g) => g.name).join(', ');
204
+ return fail(404, `no group "${name}"${known ? ` (groups: ${known})` : ''}; ${hint}`);
205
+ }
206
+
207
+ // The group the board shows `peer` in: its own placement or its project's. A new group holds
208
+ // `peer` too.
209
+ function peerGroup(peer, make) {
210
+ const { ref, name } = peer;
211
+ const hit = sessionGroup(groups, peer);
212
+ if (hit) return { group: hit, created: false };
213
+ if (!make) return fail(404, `session ${ref.slice(0, 8)} is in no group; pass --create to make one`);
214
+ return { group: addGroup({ name, members: [{ type: 'session', ref }] }), created: true };
215
+ }
216
+
217
+ // A session placed with no `under` or `loose` shows in its project's block when the group holds
218
+ // its project, else on the group's own level, as the board's context menu places it.
219
+ // `peer` ({ref, project, name}) puts the session in the group of that session.
220
+ // `parent` names the session the board shows it under; null clears it.
221
+ function groupSession(ref, { group, peer, parent, create: make = false } = {}) {
222
+ // Checked before findGroup, which may add the group.
223
+ if (!isRef(ref)) fail(400, 'session id is required');
224
+ if (parent != null && (!isRef(parent) || parent === ref)) fail(400, 'parent must be a different session');
225
+ const member = { type: 'session', ref, parent };
226
+ const left = groupOf(member)?.name ?? null;
227
+ if (peer) {
228
+ if (!isRef(peer.ref) || peer.ref === ref) fail(400, 'the other session must be a different session');
229
+ } else if (group == null) {
230
+ return { group: null, left, ...(left === null ? state() : ungroup(member)) };
231
+ }
232
+ const { group: hit, created } = peer ? peerGroup(peer, make) : findGroup(group, make);
233
+ return { ...groupRef(hit), created, left, ...place(hit.id, member) };
234
+ }
235
+
236
+ // `dispatch start --group`: the started session joins the group, and the groups of the path
237
+ // that had to be made are transient.
238
+ function joinDispatch(key, ref) {
239
+ if (!isRef(ref)) fail(400, 'session id is required');
240
+ const { group } = findGroup(key, { transient: true });
241
+ const { rev: saved } = place(group.id, { type: 'session', ref });
242
+ return { ...groupRef(group), transient: !!group.transient, rev: saved };
243
+ }
244
+
245
+ // A renamed group is the user's: it stays after its sessions end.
246
+ function update(id, { name, parent, before } = {}) {
247
+ const group = mustGet(id);
248
+ const nextName = name === undefined ? group.name : cleanName(name) || fail(400, 'name must be a non-empty string');
249
+ const nextParent = parent === undefined ? group.parent : checkParent(id, parent);
250
+ if (before != null && !byId(before)) fail(400, `no group ${before}`);
251
+ if (name !== undefined) delete group.transient;
252
+ group.name = nextName;
253
+ group.parent = nextParent;
254
+ if (before !== undefined) moveBefore(groups, group, before, (g, id) => g.id === id);
255
+ return commit();
256
+ }
257
+
258
+ function remove(id) {
259
+ const group = mustGet(id);
260
+ for (const g of groups) if (g.parent === id) g.parent = group.parent;
261
+ groups = groups.filter((g) => g !== group);
262
+ return commit();
263
+ }
264
+
265
+ const describe = (g) => ({ id: g.id, name: g.name, path: pathOf(g) });
266
+ const groupRef = (g) => ({ group: g.id, name: g.name, path: pathOf(g) });
267
+
268
+ const userGroupByKey = (key) => findGroup(key, false, 'see `group list`').group;
269
+
270
+ // `transient` marks the groups of the path that had to be made.
271
+ function createPath(path, transient = false) {
272
+ const { group, created } = findGroup(path, { transient });
273
+ if (!created) fail(400, `group "${pathOf(group)}" exists (${group.id})`);
274
+ return { ...groupRef(group), rev: commit().rev };
275
+ }
276
+
277
+ function rename(key, name) {
278
+ const group = userGroupByKey(key);
279
+ const { rev: saved } = update(group.id, { name });
280
+ return { ...groupRef(group), rev: saved };
281
+ }
282
+
283
+ function removeByKey(key) {
284
+ const group = userGroupByKey(key);
285
+ return { removed: describe(group), ungrouped: group.members.length, rev: remove(group.id).rev };
286
+ }
287
+
288
+ // A parent that holds only empty groups goes with them.
289
+ function prune({ dryRun = false, isLive }) {
290
+ const doomed = cascade((g) => !g.members.some(isLive));
291
+ const removed = groups.filter((g) => doomed.has(g)).map(describe);
292
+ if (dryRun || !doomed.size) return { removed, dryRun, rev };
293
+ groups = groups.filter((g) => !doomed.has(g));
294
+ return { removed, dryRun, rev: commit().rev };
295
+ }
296
+
297
+ const cascade = (goes) => groupTree.cascade(groups, goes);
298
+
299
+ const subtree = (g) => [g, ...groups.filter((c) => c.parent === g.id).flatMap(subtree)];
300
+ const isSession = (m) => m.type === 'session';
301
+
302
+ function dropSessions(scope, drop) {
303
+ const emptied = new Set();
304
+ let members = 0;
305
+ for (const g of scope) {
306
+ const kept = g.members.filter((m) => !isSession(m) || !drop(m));
307
+ if (kept.length === g.members.length) continue;
308
+ members += g.members.length - kept.length;
309
+ g.members = kept;
310
+ if (!kept.length) emptied.add(g);
311
+ }
312
+ return { members, emptied };
313
+ }
314
+
315
+ // A group that was empty before the sweep stays: the user may have made it to fill later.
316
+ function expire(isDead) {
317
+ const { members, emptied } = dropSessions(groups, isDead);
318
+ if (!members) return { members: 0, groups: 0, rev };
319
+ const gone = cascade((g) => emptied.has(g));
320
+ groups = groups.filter((g) => !gone.has(g));
321
+ return { members, groups: gone.size, rev: commit().rev };
322
+ }
323
+
324
+ // `groups` counts every group after the clean, so the caller can show them without asking again.
325
+ function clean({ group, dryRun = false, isVisible }) {
326
+ const scope = group == null ? groups : subtree(mustGet(group));
327
+ const hidden = (m) => isSession(m) && !isVisible(m);
328
+ const countHidden = (g) => g.members.filter(hidden).length;
329
+ const dropped = dryRun ? scope.reduce((n, g) => n + countHidden(g), 0) : dropSessions(scope, hidden).members;
330
+ const counts = groups.map((g) => ({ ...describe(g), members: g.members.length, hidden: countHidden(g) }));
331
+ return { groups: counts, dropped, dryRun, rev: dropped && !dryRun ? commit().rev : rev };
332
+ }
333
+
334
+ // `supersede` names session members to drop with the move: the sessions of a project being
335
+ // moved, which only the caller can list. With no `parent`, a move inside the group keeps it.
336
+ function place(id, { type, ref, under, loose, parent, before = null, supersede = [] } = {}) {
337
+ const group = mustGet(id);
338
+ const kept = parent === undefined ? group.members.find((m) => sameMember(m, { type, ref }))?.parent : parent;
339
+ const member = cleanMember({ type, ref, under, loose, parent: kept }) || fail(400, 'type must be project or session, with a ref');
340
+ if (group.members.filter((m) => !sameMember(m, member)).length >= MAX_MEMBERS) fail(422, `at most ${MAX_MEMBERS} members`);
341
+ const keys = new Set([memberKey(member)]);
342
+ for (const sid of Array.isArray(supersede) ? supersede : []) if (isRef(sid)) keys.add(memberKey({ type: 'session', ref: sid }));
343
+ detach(keys);
344
+ moveBefore(group.members, member, before, sameMember);
345
+ return commit();
346
+ }
347
+
348
+ function ungroup({ type, ref } = {}) {
349
+ const member = cleanMember({ type, ref }) || fail(400, 'type must be project or session, with a ref');
350
+ detach(new Set([memberKey(member)]));
351
+ return commit();
352
+ }
353
+
354
+ // The board edits its own copy and writes it whole; `rev` must be the one it last read.
355
+ function replace({ rev: base, groups: list } = {}) {
356
+ if (base !== rev) fail(409, 'groups changed since rev; read them again');
357
+ groups = readGroups(list);
358
+ return commit();
359
+ }
360
+
361
+ // The board's groups from before the server kept them. Only the first import is taken; a later
362
+ // one changes nothing. A member the server already holds stays where it is.
363
+ function importLocal({ groups: list } = {}) {
364
+ if (migratedAt) return { imported: false, ...state() };
365
+ const taken = new Set(groups.flatMap((g) => g.members.map(memberKey)));
366
+ for (const g of readGroups(list, taken)) {
367
+ if (groups.length >= MAX_GROUPS) break;
368
+ groups.push({ ...g, id: byId(g.id) ? newGroupId() : g.id, parent: null });
369
+ }
370
+ migratedAt = now();
371
+ return { imported: true, ...commit() };
372
+ }
373
+
374
+ function newGroupId() {
375
+ let id;
376
+ do id = newId();
377
+ while (byId(id));
378
+ return id;
379
+ }
380
+
381
+ return { state, reload, create, update, remove, place, ungroup, replace, importLocal, groupSession, joinDispatch, createPath, rename, removeByKey, prune, expire, clean };
382
+ }
383
+
384
+ module.exports = { createUserGroupStore };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.6.1",
3
+ "version": "7.8.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -11,6 +11,7 @@
11
11
  "start": "node server.js",
12
12
  "dev": "node --watch server.js",
13
13
  "test": "node --test test/*.test.js",
14
+ "test:swarm": "npm --prefix extensions/swarm/plugin/plugins/swarm test",
14
15
  "validate:schemas": "node test/validate-live-schemas.js",
15
16
  "prepare": "husky"
16
17
  },
@@ -55,6 +56,7 @@
55
56
  "cli.js",
56
57
  "install.js",
57
58
  "plugin/**/*",
59
+ "kanbot/**/*",
58
60
  "lib/**/*",
59
61
  "public/**/*",
60
62
  "skill-guides/**/*"
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.5.0",
3
+ "version": "3.7.4",
4
4
  "description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, board moves and reviews delivered to the session, and skills to drive the board from a session"
5
5
  }