claude-code-kanban 7.7.0 → 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/README.md +1 -1
- package/cli.js +18 -47
- package/hub-app.json +2 -2
- package/install.js +11 -9
- package/lib/live-sessions.js +1 -1
- package/lib/terminal-client.js +4 -0
- package/lib/terminal-host.js +7 -1
- package/lib/terminal.js +61 -9
- package/lib/user-groups.js +86 -66
- package/package.json +2 -1
- package/public/app.js +486 -204
- package/public/group-tree.js +21 -0
- package/public/index.html +34 -13
- package/public/style.css +118 -6
- package/public/sw.js +1 -1
- package/public/vendor/claude-hub-sdk.js +1 -1
- package/server.js +72 -43
- package/skill-guides/dispatch.md +1 -1
- package/lib/dispatch-groups.js +0 -88
package/lib/user-groups.js
CHANGED
|
@@ -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 =
|
|
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)
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
217
|
-
//
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
241
|
-
return { group: null, left, ...ungroup({ ...member, release: true }) };
|
|
230
|
+
return { group: null, left, ...(left === null ? state() : ungroup(member)) };
|
|
242
231
|
}
|
|
243
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
384
|
+
module.exports = { createUserGroupStore };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "7.
|
|
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
|
},
|