claude-code-kanban 7.7.0 → 7.9.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.
@@ -1,24 +1,22 @@
1
1
  // User groups: named groups that hold projects and sessions, and may nest under another group.
2
2
  // The CLI and plugins change them one operation at a time; the board writes its copy whole with
3
3
  // `replace`. `groups` is one flat list; its order is the order of siblings. A member is in at
4
- // most one group.
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).
5
7
 
6
8
  const { randomUUID } = require('node:crypto');
9
+ const groupTree = require('../public/group-tree');
7
10
  const { httpError } = require('./http-error');
8
11
  const { oneLine } = require('./one-line');
9
12
 
10
- const MAX_GROUPS = 200;
13
+ const MAX_GROUPS = 1000;
11
14
  const MAX_MEMBERS = 500;
12
- const MAX_RELEASED = 500;
13
15
  const MAX_DEPTH = 8;
14
16
  const MAX_NAME = 120;
15
17
  const MAX_REF = 4096;
16
18
  const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
17
19
  const TYPES = new Set(['project', 'session']);
18
- // `group list` shows a dispatch group as `dispatch:<name>`; a key with it names the user group <name>,
19
- // which takes that dispatch group's sessions in.
20
- const DISPATCH_PREFIX = 'dispatch:';
21
- const dispatchName = (key) => (typeof key === 'string' && key.startsWith(DISPATCH_PREFIX) ? key.slice(DISPATCH_PREFIX.length) : null);
22
20
 
23
21
  const fail = (status, message) => {
24
22
  throw httpError(status, message);
@@ -42,16 +40,9 @@ const sameMember = (a, b) => a.type === b.type && a.ref === b.ref;
42
40
  const groupHolding = (groups, member) => groups.find((g) => g.members.some((m) => sameMember(m, member)));
43
41
  const groupsNamed = (groups, name) => groups.filter((g) => g.name.toLowerCase() === name.toLowerCase());
44
42
 
45
- // Where the board shows a session (sgGroupForSession in public/app.js): `held` is the user group
46
- // that holds it or its project. Else, unless released, `named` is a user group named like its
47
- // dispatch group, which takes it in, and with none `dispatch` is that dispatch group's name.
48
- function placeSession(groups, released, { ref, project, dispatchGroup }) {
49
- const held = groupHolding(groups, { type: 'session', ref }) || (project && groupHolding(groups, { type: 'project', ref: project }));
50
- if (held) return { held, named: null, dispatch: null };
51
- const dispatch = dispatchGroup && !released.includes(ref) ? dispatchGroup : null;
52
- const named = (dispatch && groupsNamed(groups, dispatch)[0]) || null;
53
- return { held: null, named, dispatch: named ? null : dispatch };
54
- }
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;
55
46
 
56
47
  function wouldCycle(list, id, parent) {
57
48
  let depth = 0;
@@ -76,7 +67,9 @@ function readGroups(list, taken = new Set()) {
76
67
  taken.add(memberKey(clean));
77
68
  members.push(clean);
78
69
  }
79
- out.push({ id: g.id, name: cleanName(g.name) || 'Group', parent: typeof g.parent === 'string' ? g.parent : null, members });
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);
80
73
  }
81
74
  for (const g of out) {
82
75
  if (g.parent && (!seen.has(g.parent) || g.parent === g.id)) g.parent = null;
@@ -85,8 +78,6 @@ function readGroups(list, taken = new Set()) {
85
78
  return out;
86
79
  }
87
80
 
88
- const readReleased = (list) => [...new Set((Array.isArray(list) ? list : []).filter(isRef))].slice(-MAX_RELEASED);
89
-
90
81
  /**
91
82
  * @param {object} o
92
83
  * @param {() => object|null|undefined} o.load `undefined` means keep what is in memory: the file has
@@ -95,7 +86,6 @@ const readReleased = (list) => [...new Set((Array.isArray(list) ? list : []).fil
95
86
  */
96
87
  function createUserGroupStore({ load, save, now = () => new Date().toISOString(), newId = randomUUID }) {
97
88
  let groups = [];
98
- let released = [];
99
89
  let rev = 0;
100
90
  let migratedAt = null;
101
91
  // The last state read or saved. The changes edit `groups` in place, so a refused save puts it back.
@@ -105,7 +95,6 @@ function createUserGroupStore({ load, save, now = () => new Date().toISOString()
105
95
  rev = Number.isSafeInteger(data.rev) && data.rev >= 0 ? data.rev : 0;
106
96
  migratedAt = typeof data.groupsMigratedAt === 'string' ? data.groupsMigratedAt : null;
107
97
  groups = readGroups(data.groups);
108
- released = readReleased(data.released);
109
98
  }
110
99
 
111
100
  // Another board on the same config dir writes the same file. Returns whether the rev moved.
@@ -124,7 +113,7 @@ function createUserGroupStore({ load, save, now = () => new Date().toISOString()
124
113
  const groupOf = (member) => groupHolding(groups, member);
125
114
  const byName = (name) => groupsNamed(groups, name);
126
115
 
127
- const state = () => ({ version: 2, rev, groupsMigratedAt: migratedAt, groups, released });
116
+ const state = () => ({ version: 2, rev, groupsMigratedAt: migratedAt, groups });
128
117
 
129
118
  function commit() {
130
119
  rev++;
@@ -158,12 +147,13 @@ function createUserGroupStore({ load, save, now = () => new Date().toISOString()
158
147
  for (const g of groups) g.members = g.members.filter((m) => !keys.has(memberKey(m)));
159
148
  }
160
149
 
161
- function addGroup({ id, name, parent = null, before = null, members = [] }) {
150
+ function addGroup({ id, name, parent = null, before = null, members = [], transient = false }) {
162
151
  if (groups.length >= MAX_GROUPS) fail(422, `at most ${MAX_GROUPS} groups`);
163
152
  if (id != null && (!ID_RE.test(id) || byId(id))) fail(400, 'id must be unique, 1-64 of [A-Za-z0-9_-]');
164
153
  const gid = id ?? newGroupId();
165
154
  const clean = readGroups([{ id: gid, members }])[0].members;
166
155
  const group = { id: gid, name: cleanName(name) || `Group ${groups.length + 1}`, parent: checkParent(gid, parent), members: clean };
156
+ if (transient) group.transient = true;
167
157
  detach(new Set(clean.map(memberKey)));
168
158
  groups.push(group);
169
159
  moveBefore(groups, group, before, (g, id) => g.id === id);
@@ -181,7 +171,8 @@ function createUserGroupStore({ load, save, now = () => new Date().toISOString()
181
171
  return names.join('/');
182
172
  };
183
173
 
184
- // A path names a top-level group, then a child at each step. `make` adds the missing steps.
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.
185
176
  function findPath(path, make, hint) {
186
177
  const steps = path.split('/').map(cleanName);
187
178
  if (steps.some((s) => !s)) fail(400, `group path "${path}" has an empty step`);
@@ -192,7 +183,7 @@ function createUserGroupStore({ load, save, now = () => new Date().toISOString()
192
183
  const named = byName(step).filter((g) => g.parent === (parent?.id ?? null));
193
184
  if (named.length > 1) fail(400, `${named.length} groups are named "${pathOf(named[0])}"; pass one id: ${named.map((g) => g.id).join(', ')}`);
194
185
  if (named.length) parent = named[0];
195
- else if (make) [parent, created] = [addGroup({ name: step, parent: parent?.id ?? null }), true];
186
+ else if (make) [parent, created] = [addGroup({ name: step, parent: parent?.id ?? null, transient: make.transient }), true];
196
187
  else return fail(404, `no group "${parent ? `${pathOf(parent)}/` : ''}${step}"; ${hint}`);
197
188
  }
198
189
  return { group: parent, created };
@@ -208,25 +199,24 @@ function createUserGroupStore({ load, save, now = () => new Date().toISOString()
208
199
  if (!named.length && key.includes('/')) return findPath(key, make, hint);
209
200
  if (named.length > 1) fail(400, `${named.length} groups are named "${name}"; pass one id: ${named.map((g) => g.id).join(', ')}`);
210
201
  if (named.length) return { group: named[0], created: false };
211
- if (make) return { group: addGroup({ name }), created: true };
202
+ if (make) return { group: addGroup({ name, transient: make.transient }), created: true };
212
203
  const known = groups.map((g) => g.name).join(', ');
213
204
  return fail(404, `no group "${name}"${known ? ` (groups: ${known})` : ''}; ${hint}`);
214
205
  }
215
206
 
216
- // The group the board shows `peer` in: its own placement, its project's, or a user group named
217
- // like its dispatch group. A new group holds `peer` too.
207
+ // The group the board shows `peer` in: its own placement or its project's. A new group holds
208
+ // `peer` too.
218
209
  function peerGroup(peer, make) {
219
210
  const { ref, name } = peer;
220
- const { held, named, dispatch } = placeSession(groups, released, peer);
221
- const hit = held || named;
211
+ const hit = sessionGroup(groups, peer);
222
212
  if (hit) return { group: hit, created: false };
223
213
  if (!make) return fail(404, `session ${ref.slice(0, 8)} is in no group; pass --create to make one`);
224
- return { group: addGroup({ name: dispatch || name, members: [{ type: 'session', ref }] }), created: true };
214
+ return { group: addGroup({ name, members: [{ type: 'session', ref }] }), created: true };
225
215
  }
226
216
 
227
217
  // A session placed with no `under` or `loose` shows in its project's block when the group holds
228
218
  // its project, else on the group's own level, as the board's context menu places it.
229
- // `peer` ({ref, project, dispatchGroup, name}) puts the session in the group of that session.
219
+ // `peer` ({ref, project, name}) puts the session in the group of that session.
230
220
  // `parent` names the session the board shows it under; null clears it.
231
221
  function groupSession(ref, { group, peer, parent, create: make = false } = {}) {
232
222
  // Checked before findGroup, which may add the group.
@@ -237,19 +227,28 @@ function createUserGroupStore({ load, save, now = () => new Date().toISOString()
237
227
  if (peer) {
238
228
  if (!isRef(peer.ref) || peer.ref === ref) fail(400, 'the other session must be a different session');
239
229
  } else if (group == null) {
240
- if (left === null && released.includes(ref)) return { group: null, left, ...state() };
241
- return { group: null, left, ...ungroup({ ...member, release: true }) };
230
+ return { group: null, left, ...(left === null ? state() : ungroup(member)) };
242
231
  }
243
- const dispatched = dispatchName(group);
244
- const { group: hit, created } = peer ? peerGroup(peer, make) : findGroup(dispatched ?? group, make || dispatched !== null);
232
+ const { group: hit, created } = peer ? peerGroup(peer, make) : findGroup(group, make);
245
233
  return { ...groupRef(hit), created, left, ...place(hit.id, member) };
246
234
  }
247
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.
248
246
  function update(id, { name, parent, before } = {}) {
249
247
  const group = mustGet(id);
250
248
  const nextName = name === undefined ? group.name : cleanName(name) || fail(400, 'name must be a non-empty string');
251
249
  const nextParent = parent === undefined ? group.parent : checkParent(id, parent);
252
250
  if (before != null && !byId(before)) fail(400, `no group ${before}`);
251
+ if (name !== undefined) delete group.transient;
253
252
  group.name = nextName;
254
253
  group.parent = nextParent;
255
254
  if (before !== undefined) moveBefore(groups, group, before, (g, id) => g.id === id);
@@ -266,13 +265,11 @@ function createUserGroupStore({ load, save, now = () => new Date().toISOString()
266
265
  const describe = (g) => ({ id: g.id, name: g.name, path: pathOf(g) });
267
266
  const groupRef = (g) => ({ group: g.id, name: g.name, path: pathOf(g) });
268
267
 
269
- function userGroupByKey(key) {
270
- if (dispatchName(key) !== null) fail(400, 'a dispatch group goes when its sessions end; `session ungroup` takes a session out');
271
- return findGroup(key, false, 'see `group list`').group;
272
- }
268
+ const userGroupByKey = (key) => findGroup(key, false, 'see `group list`').group;
273
269
 
274
- function createPath(path) {
275
- const { group, created } = findGroup(dispatchName(path) ?? path, true);
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 });
276
273
  if (!created) fail(400, `group "${pathOf(group)}" exists (${group.id})`);
277
274
  return { ...groupRef(group), rev: commit().rev };
278
275
  }
@@ -290,14 +287,50 @@ function createUserGroupStore({ load, save, now = () => new Date().toISOString()
290
287
 
291
288
  // A parent that holds only empty groups goes with them.
292
289
  function prune({ dryRun = false, isLive }) {
293
- const empty = (g) => !g.members.some(isLive) && groups.every((c) => c.parent !== g.id || empty(c));
294
- const doomed = groups.filter(empty);
295
- const removed = doomed.map(describe);
296
- if (dryRun || !doomed.length) return { removed, dryRun, rev };
297
- groups = groups.filter((g) => !doomed.includes(g));
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));
298
294
  return { removed, dryRun, rev: commit().rev };
299
295
  }
300
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
+
301
334
  // `supersede` names session members to drop with the move: the sessions of a project being
302
335
  // moved, which only the caller can list. With no `parent`, a move inside the group keeps it.
303
336
  function place(id, { type, ref, under, loose, parent, before = null, supersede = [] } = {}) {
@@ -312,41 +345,28 @@ function createUserGroupStore({ load, save, now = () => new Date().toISOString()
312
345
  return commit();
313
346
  }
314
347
 
315
- function ungroup({ type, ref, release = false } = {}) {
348
+ function ungroup({ type, ref } = {}) {
316
349
  const member = cleanMember({ type, ref }) || fail(400, 'type must be project or session, with a ref');
317
350
  detach(new Set([memberKey(member)]));
318
- if (release && type === 'session') addReleased([ref]);
319
- return commit();
320
- }
321
-
322
- function addReleased(ids) {
323
- released = readReleased([...released.filter((x) => !ids.includes(x)), ...ids]);
324
- }
325
-
326
- function release(ids) {
327
- if (!Array.isArray(ids)) fail(400, 'ids must be an array');
328
- addReleased(ids);
329
351
  return commit();
330
352
  }
331
353
 
332
354
  // The board edits its own copy and writes it whole; `rev` must be the one it last read.
333
- function replace({ rev: base, groups: list, released: rel } = {}) {
355
+ function replace({ rev: base, groups: list } = {}) {
334
356
  if (base !== rev) fail(409, 'groups changed since rev; read them again');
335
357
  groups = readGroups(list);
336
- released = readReleased(rel);
337
358
  return commit();
338
359
  }
339
360
 
340
361
  // The board's groups from before the server kept them. Only the first import is taken; a later
341
362
  // one changes nothing. A member the server already holds stays where it is.
342
- function importLocal({ groups: list, released: rel } = {}) {
363
+ function importLocal({ groups: list } = {}) {
343
364
  if (migratedAt) return { imported: false, ...state() };
344
365
  const taken = new Set(groups.flatMap((g) => g.members.map(memberKey)));
345
366
  for (const g of readGroups(list, taken)) {
346
367
  if (groups.length >= MAX_GROUPS) break;
347
368
  groups.push({ ...g, id: byId(g.id) ? newGroupId() : g.id, parent: null });
348
369
  }
349
- addReleased(readReleased(rel));
350
370
  migratedAt = now();
351
371
  return { imported: true, ...commit() };
352
372
  }
@@ -358,7 +378,7 @@ function createUserGroupStore({ load, save, now = () => new Date().toISOString()
358
378
  return id;
359
379
  }
360
380
 
361
- return { state, reload, create, update, remove, place, ungroup, release, replace, importLocal, groupSession, createPath, rename, removeByKey, prune };
381
+ return { state, reload, create, update, remove, place, ungroup, replace, importLocal, groupSession, joinDispatch, createPath, rename, removeByKey, prune, expire, clean };
362
382
  }
363
383
 
364
- module.exports = { createUserGroupStore, placeSession, DISPATCH_PREFIX };
384
+ module.exports = { createUserGroupStore };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.7.0",
3
+ "version": "7.9.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
  },