claude-code-kanban 7.6.1 → 7.7.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 +2 -1
- package/cli.js +412 -62
- package/hub-app.json +1 -1
- package/kanbot/.claude-plugin/plugin.json +5 -0
- package/kanbot/agents/kanbot.md +34 -0
- package/lib/http-error.js +2 -1
- package/lib/kanbot.js +86 -0
- package/lib/owner-routing.js +53 -0
- package/lib/preview-action.js +55 -0
- package/lib/session-events.js +5 -0
- package/lib/session-pins.js +99 -0
- package/lib/stamped-json-file.js +49 -0
- package/lib/terminal.js +27 -13
- package/lib/user-groups.js +364 -0
- package/package.json +2 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +4 -2
- package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +5 -1
- package/public/app.js +1196 -295
- package/public/index.html +19 -15
- package/public/style.css +727 -125
- package/public/terminal-frame.js +12 -0
- package/server.js +273 -117
package/cli.js
CHANGED
|
@@ -3,6 +3,8 @@ const path = require('node:path');
|
|
|
3
3
|
const { getClaudeDir, displayPath } = require('./lib/claude-dir');
|
|
4
4
|
const { isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
|
|
5
5
|
const { linkUrl } = require('./public/link-url');
|
|
6
|
+
const { sessionProjectKey } = require('./public/project-match');
|
|
7
|
+
const { DISPATCH_PREFIX, placeSession } = require('./lib/user-groups');
|
|
6
8
|
// Help is auto-generated from this table — keep flags/usage in sync with `run` behavior.
|
|
7
9
|
const SESSION_FLAG = '--session <id>';
|
|
8
10
|
const SESSION_FLAG_HELP = 'Session, full id or unique prefix (default: $PREVIEW_SESSION, else $CLAUDE_CODE_SESSION_ID)';
|
|
@@ -99,22 +101,23 @@ const COMMANDS = {
|
|
|
99
101
|
},
|
|
100
102
|
},
|
|
101
103
|
session: {
|
|
102
|
-
summary: 'List, search, open and
|
|
104
|
+
summary: 'List, search, open, inspect and group Claude Code sessions',
|
|
103
105
|
verbs: {
|
|
104
106
|
list: {
|
|
105
|
-
summary: 'List sessions (pinned/sticky always included)',
|
|
106
|
-
usage: 'claude-code-kanban session list [--
|
|
107
|
+
summary: 'List the active sessions, as the sidebar\'s Active view shows them (pinned/sticky always included)',
|
|
108
|
+
usage: 'claude-code-kanban session list [--all] [--days <n>] [--project <name>] [--limit <n|all>] [--no-pins] [--json]',
|
|
107
109
|
flags: {
|
|
108
|
-
'--
|
|
110
|
+
'--all': 'Every session, active or not',
|
|
109
111
|
'--days <n>': 'Only sessions modified within the last N days (fractional ok, e.g. 0.5)',
|
|
110
112
|
'--project <name>': 'Filter by project: an absolute path selects one project, other text matches a part of the path',
|
|
111
|
-
'--limit <n|all>': 'Max rows to display (default:
|
|
113
|
+
'--limit <n|all>': 'Max rows to display (default: every active row; 20 with --all). Pinned sessions are kept past it.',
|
|
112
114
|
'--no-pins': 'Disable always-include and sticky-first ordering for pinned sessions',
|
|
113
115
|
'--json': 'Output JSON instead of a table',
|
|
114
116
|
},
|
|
117
|
+
notes: 'Active: running in the terminal, or with messages and open tasks, live agents, a wait on the user or recent activity. STATUS is idle, active, busy (a task in progress) or wait. When --limit cuts the list, a last line says how many rows it shows of how many (on stderr with --json).',
|
|
115
118
|
examples: [
|
|
116
|
-
'claude-code-kanban session list
|
|
117
|
-
'claude-code-kanban session list --days 0.5 --limit
|
|
119
|
+
'claude-code-kanban session list',
|
|
120
|
+
'claude-code-kanban session list --all --days 0.5 --limit 20 --project my-repo',
|
|
118
121
|
],
|
|
119
122
|
run: runSessionListCli,
|
|
120
123
|
},
|
|
@@ -173,7 +176,7 @@ const COMMANDS = {
|
|
|
173
176
|
run: runSessionAgentsCli,
|
|
174
177
|
},
|
|
175
178
|
pin: {
|
|
176
|
-
summary: 'Pin (or unpin) a session in the sidebar
|
|
179
|
+
summary: 'Pin (or unpin) a session in the sidebar; every board on this config dir shows it',
|
|
177
180
|
usage: 'claude-code-kanban session pin <id> [--sticky] [--unpin]',
|
|
178
181
|
flags: {
|
|
179
182
|
'<id>': 'Full session id, or a unique prefix',
|
|
@@ -186,6 +189,98 @@ const COMMANDS = {
|
|
|
186
189
|
],
|
|
187
190
|
run: runSessionPinCli,
|
|
188
191
|
},
|
|
192
|
+
group: {
|
|
193
|
+
summary: 'Move a session into a group in the sidebar',
|
|
194
|
+
usage: 'claude-code-kanban session group <id> (<group> | --with <session> | --parent <session>) [--no-parent] [--create] [--json]',
|
|
195
|
+
flags: {
|
|
196
|
+
'<id>': 'Full session id, or a unique prefix',
|
|
197
|
+
'<group>': 'Group name (any case), path from the top (parent/child) or id, from `group list`',
|
|
198
|
+
'--with <session>': 'Join the group this other session shows in (id or unique prefix)',
|
|
199
|
+
'--parent <session>': 'Show the session under this one, indented, when both are in the same list; alone, it also joins its group',
|
|
200
|
+
'--no-parent': 'Show the session on its own again',
|
|
201
|
+
'--create': 'Make the group, and the missing parents of a path, when none matches; with --with, it holds both sessions',
|
|
202
|
+
'--json': 'Output JSON ({group, name, path, created, left, rev})',
|
|
203
|
+
},
|
|
204
|
+
notes: 'The session leaves the group it was in. In a group that holds its project, it shows in that project\'s block.',
|
|
205
|
+
examples: [
|
|
206
|
+
'claude-code-kanban session group $CLAUDE_SESSION_ID "Auth refactor"',
|
|
207
|
+
'claude-code-kanban session group 3fa9c1 auth-refactor/swarm-1 --create',
|
|
208
|
+
'claude-code-kanban session group 3fa9c1 --with $CLAUDE_SESSION_ID --create',
|
|
209
|
+
'claude-code-kanban session group 3fa9c1 --parent $CLAUDE_SESSION_ID',
|
|
210
|
+
],
|
|
211
|
+
run: runSessionGroupCli,
|
|
212
|
+
},
|
|
213
|
+
ungroup: {
|
|
214
|
+
summary: 'Take a session out of its group',
|
|
215
|
+
usage: 'claude-code-kanban session ungroup <id> [--json]',
|
|
216
|
+
flags: {
|
|
217
|
+
'<id>': 'Full session id, or a unique prefix',
|
|
218
|
+
'--json': 'Output JSON ({left, rev})',
|
|
219
|
+
},
|
|
220
|
+
notes: 'Also takes it out of its dispatch group. A session whose project is in a group still shows there; move the project on the board.',
|
|
221
|
+
examples: ['claude-code-kanban session ungroup $CLAUDE_SESSION_ID'],
|
|
222
|
+
run: runSessionUngroupCli,
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
group: {
|
|
227
|
+
summary: 'Read and change the session groups in the sidebar',
|
|
228
|
+
verbs: {
|
|
229
|
+
list: {
|
|
230
|
+
summary: 'List the groups in sidebar order, nested groups under their parent, with the members the sidebar\'s Active view shows (active, pinned or sticky)',
|
|
231
|
+
usage: 'claude-code-kanban group list [--all] [--json]',
|
|
232
|
+
flags: {
|
|
233
|
+
'--all': 'Every member, active or not',
|
|
234
|
+
'--json': 'Output JSON ({rev, groups: [{id, name, parent, visible, total, members}]}); a dispatch group has dispatch: true and id dispatch:<name>; a session member has title, branch, status, pinned and age, or missing: true; a project member has sessions (its shown sessions)',
|
|
235
|
+
},
|
|
236
|
+
notes: 'A project member shows when it has a session the Active view shows. visible/total counts the members shown. A session started with `dispatch start --group` shows in that dispatch group, marked (dispatch) and listed after the user groups, unless a user group holds it or its project; a user group with the same name takes it in. Dispatch groups go when their sessions end. Pass a user group\'s name, path (parent/child) or id to `session group`; dispatch:<name> there makes the user group <name>, which keeps the sessions after they end.',
|
|
237
|
+
examples: ['claude-code-kanban group list', 'claude-code-kanban group list --all --json'],
|
|
238
|
+
run: runGroupListCli,
|
|
239
|
+
},
|
|
240
|
+
create: {
|
|
241
|
+
summary: 'Make a group, and the missing parents of a path',
|
|
242
|
+
usage: 'claude-code-kanban group create <name|path> [--json]',
|
|
243
|
+
flags: {
|
|
244
|
+
'<name|path>': 'Name of a new top-level group, or a path from the top (parent/child); dispatch:<name> makes a user group that takes in that dispatch group',
|
|
245
|
+
'--json': 'Output JSON ({group, name, path, rev})',
|
|
246
|
+
},
|
|
247
|
+
notes: 'Fails when the group exists.',
|
|
248
|
+
examples: ['claude-code-kanban group create "Auth refactor"', 'claude-code-kanban group create auth-refactor/swarm-1'],
|
|
249
|
+
run: runGroupCreateCli,
|
|
250
|
+
},
|
|
251
|
+
rename: {
|
|
252
|
+
summary: 'Rename a group; it keeps its place, parent and members',
|
|
253
|
+
usage: 'claude-code-kanban group rename <group> <new name> [--json]',
|
|
254
|
+
flags: {
|
|
255
|
+
'<group>': 'Group name (any case), path from the top (parent/child) or id, from `group list`',
|
|
256
|
+
'<new name>': 'The new name',
|
|
257
|
+
'--json': 'Output JSON ({group, name, path, rev})',
|
|
258
|
+
},
|
|
259
|
+
examples: ['claude-code-kanban group rename auth-refactor "Auth v2"'],
|
|
260
|
+
run: runGroupRenameCli,
|
|
261
|
+
},
|
|
262
|
+
rm: {
|
|
263
|
+
summary: 'Remove a group, as Delete on the board does',
|
|
264
|
+
usage: 'claude-code-kanban group rm <group> [--json]',
|
|
265
|
+
flags: {
|
|
266
|
+
'<group>': 'Group name (any case), path from the top (parent/child) or id, from `group list`',
|
|
267
|
+
'--json': 'Output JSON ({removed: {id, name, path}, ungrouped, rev})',
|
|
268
|
+
},
|
|
269
|
+
notes: 'Its members leave the group and show as with no group: in their project\'s group, their dispatch group or the top level. Its child groups move up one level. A dispatch group cannot be removed: it goes when its sessions end.',
|
|
270
|
+
examples: ['claude-code-kanban group rm presales-cloud'],
|
|
271
|
+
run: runGroupRmCli,
|
|
272
|
+
},
|
|
273
|
+
prune: {
|
|
274
|
+
summary: 'Remove every empty group',
|
|
275
|
+
usage: 'claude-code-kanban group prune [--dry-run] [--json]',
|
|
276
|
+
flags: {
|
|
277
|
+
'--dry-run': 'List the groups it would remove, and remove none',
|
|
278
|
+
'--json': 'Output JSON ({removed: [{id, name, path}], dryRun, rev})',
|
|
279
|
+
},
|
|
280
|
+
notes: 'Empty: no session whose transcript still exists, no project, and no child group that is not empty. It checks every member, not only the ones the Active view shows.',
|
|
281
|
+
examples: ['claude-code-kanban group prune --dry-run', 'claude-code-kanban group prune'],
|
|
282
|
+
run: runGroupPruneCli,
|
|
283
|
+
},
|
|
189
284
|
},
|
|
190
285
|
},
|
|
191
286
|
task: {
|
|
@@ -230,7 +325,7 @@ const COMMANDS = {
|
|
|
230
325
|
summary: 'Start claude in cck\'s terminal with a task; prints the session id',
|
|
231
326
|
usage: 'claude-code-kanban dispatch start --cwd <dir> (--spec <text> | --spec-file <path>) [--name <n>] [--group <g>] [--model <m>] [--worktree [name]] [--task-list [id]] [--json] [-- <claude args>...]',
|
|
232
327
|
flags: {
|
|
233
|
-
'--cwd <dir>': 'Folder to run in (
|
|
328
|
+
'--cwd <dir>': 'Folder to run in (any existing folder, default: current dir)',
|
|
234
329
|
'--spec <text>': 'The task, self-contained; sent as the first message',
|
|
235
330
|
'--spec-file <path>': 'Read the task from a file',
|
|
236
331
|
'--name <n>': 'Session name; also its peer name for SendMessage',
|
|
@@ -271,6 +366,20 @@ const COMMANDS = {
|
|
|
271
366
|
},
|
|
272
367
|
},
|
|
273
368
|
},
|
|
369
|
+
terminal: {
|
|
370
|
+
summary: 'Read the terminals running in cck',
|
|
371
|
+
verbs: {
|
|
372
|
+
list: {
|
|
373
|
+
summary: 'List every terminal in cck: dispatched, opened by hand, and Kanbot',
|
|
374
|
+
usage: 'claude-code-kanban terminal list [--json]',
|
|
375
|
+
flags: {
|
|
376
|
+
'--json': 'Output JSON',
|
|
377
|
+
},
|
|
378
|
+
notes: 'Read-only. `dispatch end` ends a terminal this session dispatched; the user ends the others from the board.',
|
|
379
|
+
run: runTerminalListCli,
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
},
|
|
274
383
|
skills: {
|
|
275
384
|
summary: 'Print a skill guide bundled with this version',
|
|
276
385
|
verbs: {
|
|
@@ -359,26 +468,30 @@ const SERVER_FLAGS = [
|
|
|
359
468
|
['--yes', '', 'With --install: no prompt'],
|
|
360
469
|
];
|
|
361
470
|
|
|
362
|
-
function
|
|
363
|
-
|
|
364
|
-
console.log('Commands:');
|
|
471
|
+
function topHelp() {
|
|
472
|
+
const lines = ['Usage: claude-code-kanban <command> [args] [--flags]\n', 'Commands:'];
|
|
365
473
|
for (const [name, cmd] of Object.entries(COMMANDS)) {
|
|
366
474
|
const verbs = cmd.verbs ? ` (${Object.keys(cmd.verbs).join(', ')})` : '';
|
|
367
|
-
|
|
475
|
+
lines.push(` ${name.padEnd(20)}${cmd.summary}${verbs}`);
|
|
368
476
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
477
|
+
lines.push(` ${'help'.padEnd(20)}Show help for a command (claude-code-kanban help <command>)`);
|
|
478
|
+
lines.push('\nFlags:');
|
|
479
|
+
lines.push(' --help, -h Show help (top-level, noun-level, or leaf-level)');
|
|
480
|
+
lines.push(' --version, -v Print version and exit');
|
|
481
|
+
lines.push('\nServer (no command; a flag wins over its env var):');
|
|
374
482
|
const flagPad = Math.max(...SERVER_FLAGS.map(([flag]) => flag.length)) + 2;
|
|
375
483
|
const envPad = Math.max(...SERVER_FLAGS.map(([, env]) => env.length)) + 2;
|
|
376
|
-
for (const [flag, env, desc] of SERVER_FLAGS)
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
484
|
+
for (const [flag, env, desc] of SERVER_FLAGS) lines.push(` ${flag.padEnd(flagPad)}${env.padEnd(envPad)}${desc}`);
|
|
485
|
+
lines.push('\nCommand env:');
|
|
486
|
+
lines.push(' CCK_URL Server base URL, e.g. http://127.0.0.1:4795 (wins over PORT)');
|
|
487
|
+
lines.push(' PORT Server port (default: the one this config dir\'s server reports, else 3541)');
|
|
488
|
+
lines.push(' CLAUDE_CONFIG_DIR Claude config dir whose board to use');
|
|
489
|
+
lines.push('\nRun `claude-code-kanban help <command>` for its subcommands, and `help <command> <subcommand>` for flags and examples.');
|
|
490
|
+
return lines.join('\n');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function printTopHelp() {
|
|
494
|
+
console.log(topHelp());
|
|
382
495
|
}
|
|
383
496
|
|
|
384
497
|
function printNounHelp(noun) {
|
|
@@ -653,13 +766,13 @@ async function printLinkedDocs(sessionId, asJson) {
|
|
|
653
766
|
} catch (e) { reportCliError(e); return 1; }
|
|
654
767
|
}
|
|
655
768
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
|
|
769
|
+
function sessionTitle(s) {
|
|
770
|
+
return s.customTitle || s.name || s.slug || '';
|
|
659
771
|
}
|
|
660
772
|
|
|
773
|
+
// The board's badge rule (docs/session-states.md). `active` is a wider rule, for which rows show.
|
|
661
774
|
function sessionStatus(s) {
|
|
662
|
-
if (!
|
|
775
|
+
if (!(s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser)) return 'idle';
|
|
663
776
|
if (s.hasWaitingForUser) return 'wait';
|
|
664
777
|
if (s.inProgress > 0) return 'busy';
|
|
665
778
|
return 'active';
|
|
@@ -674,24 +787,29 @@ function parseLimit(args, { fallback, allowAll = false }) {
|
|
|
674
787
|
return { ok: true, limit: n };
|
|
675
788
|
}
|
|
676
789
|
|
|
677
|
-
|
|
790
|
+
// `lite` leaves out the fields that can read a whole transcript (loopInfo).
|
|
791
|
+
function sessionsListPath({ limit, noPins = false, include = [], project = null, activeOnly = false, lite = false }) {
|
|
678
792
|
const q = limit === null ? 'all' : String(limit);
|
|
679
|
-
const
|
|
793
|
+
const pinsQ = noPins ? '&pins=off' : '';
|
|
794
|
+
const includeQ = include.length ? `&include=${include.map(encodeURIComponent).join(',')}` : '';
|
|
680
795
|
const projectQ = project ? `&project=${encodeURIComponent(project)}` : '';
|
|
681
|
-
|
|
796
|
+
const filterQ = activeOnly ? '&filter=active' : '';
|
|
797
|
+
const liteQ = lite ? '&lite=1' : '';
|
|
798
|
+
return `/api/sessions?limit=${q}${pinsQ}${includeQ}${projectQ}${filterQ}${liteQ}`;
|
|
682
799
|
}
|
|
683
800
|
|
|
684
|
-
|
|
685
|
-
return cliGetJson(`/api/sessions?limit=1&include=${ids.map(encodeURIComponent).join(',')}`, label);
|
|
686
|
-
}
|
|
801
|
+
const fetchSessionsList = (opts, label = 'Session list') => cliGetJson(sessionsListPath(opts), label);
|
|
687
802
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
803
|
+
const uniqueById = (list) => [...new Map(list.map((s) => [s.id, s])).values()];
|
|
804
|
+
|
|
805
|
+
// The server refuses a request line above about 16 KB (431), about 430 ids, so the ids go in batches.
|
|
806
|
+
const IDS_PER_REQUEST = 100;
|
|
807
|
+
|
|
808
|
+
async function fetchSessionsByIds(ids, label, { lite = false } = {}) {
|
|
809
|
+
const batches = [];
|
|
810
|
+
for (let i = 0; i < ids.length; i += IDS_PER_REQUEST) batches.push(ids.slice(i, i + IDS_PER_REQUEST));
|
|
811
|
+
const lists = await Promise.all(batches.map((include) => fetchSessionsList({ limit: 1, include, lite }, label)));
|
|
812
|
+
return uniqueById(lists.flat());
|
|
695
813
|
}
|
|
696
814
|
|
|
697
815
|
async function resolveSessionByIdOrPrefix(idArg) {
|
|
@@ -720,7 +838,7 @@ async function resolveSessionByIdOrPrefix(idArg) {
|
|
|
720
838
|
}
|
|
721
839
|
|
|
722
840
|
async function runSessionListCli(args) {
|
|
723
|
-
const activeOnly = args.includes('--
|
|
841
|
+
const activeOnly = !args.includes('--all');
|
|
724
842
|
const noPins = args.includes('--no-pins');
|
|
725
843
|
const projectFilter = getArgValue(args, 'project');
|
|
726
844
|
const daysArg = getArgValue(args, 'days');
|
|
@@ -728,40 +846,43 @@ async function runSessionListCli(args) {
|
|
|
728
846
|
if (daysArg !== null && (Number.isNaN(days) || days <= 0)) {
|
|
729
847
|
return usageError(COMMANDS.session.verbs.list, `Invalid --days value: ${daysArg}`);
|
|
730
848
|
}
|
|
731
|
-
|
|
849
|
+
// The Active view is short, so it has no cap. The server reads a whole transcript for some
|
|
850
|
+
// fields of each row it sends, so --all keeps one.
|
|
851
|
+
const parsed = parseLimit(args, { fallback: activeOnly ? null : 20, allowAll: true });
|
|
732
852
|
if (!parsed.ok) return usageError(COMMANDS.session.verbs.list, parsed.error);
|
|
733
853
|
const limit = parsed.limit;
|
|
734
854
|
const asJson = args.includes('--json');
|
|
735
|
-
const pinsMap = noPins ? {} : await fetchPinsMap();
|
|
736
|
-
const pinnedIds = Object.keys(pinsMap);
|
|
737
|
-
const hasClientFilter = activeOnly || days !== null;
|
|
738
855
|
let list;
|
|
856
|
+
let total;
|
|
739
857
|
try {
|
|
740
|
-
|
|
858
|
+
const res = await cliGet(sessionsListPath({ limit: days !== null ? null : limit, noPins, project: projectFilter, activeOnly, lite: !asJson }), 'Session list');
|
|
859
|
+
list = await res.json();
|
|
860
|
+
total = Number(res.headers.get('x-total-count')) || list.length;
|
|
741
861
|
} catch (e) {
|
|
742
862
|
reportCliError(e);
|
|
743
863
|
return 1;
|
|
744
864
|
}
|
|
745
|
-
const pinOf = id => pinsMap[id] || null;
|
|
746
|
-
if (activeOnly) list = list.filter(s => pinOf(s.id) || isSessionActive(s));
|
|
747
865
|
if (days !== null) {
|
|
748
866
|
const cutoff = Date.now() - days * 86_400_000;
|
|
749
|
-
list = list.filter(s =>
|
|
867
|
+
list = list.filter(s => s.pin || (s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff));
|
|
868
|
+
total = list.length;
|
|
750
869
|
}
|
|
751
|
-
const pinRank =
|
|
870
|
+
const pinRank = s => s.pin === 'sticky' ? 0 : s.pin === 'pinned' ? 1 : 2;
|
|
752
871
|
list.sort((a, b) => {
|
|
753
|
-
const r = pinRank(a
|
|
872
|
+
const r = pinRank(a) - pinRank(b);
|
|
754
873
|
if (r !== 0) return r;
|
|
755
874
|
return new Date(b.modifiedAt || 0) - new Date(a.modifiedAt || 0);
|
|
756
875
|
});
|
|
757
876
|
if (limit !== null && list.length > limit) {
|
|
758
877
|
const top = list.slice(0, limit);
|
|
759
878
|
const topIds = new Set(top.map(s => s.id));
|
|
760
|
-
const extraPinned = list.filter(s =>
|
|
879
|
+
const extraPinned = list.filter(s => s.pin && !topIds.has(s.id));
|
|
761
880
|
list = [...top, ...extraPinned];
|
|
762
881
|
}
|
|
882
|
+
const cut = list.length < total ? `${list.length} of ${total} shown; --limit all shows every row` : null;
|
|
763
883
|
if (asJson) {
|
|
764
|
-
console.log(JSON.stringify(list.map(s => ({ ...s, pinState:
|
|
884
|
+
console.log(JSON.stringify(list.map(({ pin, ...s }) => ({ ...s, pinState: pin || null })), null, 2));
|
|
885
|
+
if (cut) console.error(cut);
|
|
765
886
|
return 0;
|
|
766
887
|
}
|
|
767
888
|
if (!list.length) {
|
|
@@ -770,12 +891,12 @@ async function runSessionListCli(args) {
|
|
|
770
891
|
}
|
|
771
892
|
const rows = list.map(s => ({
|
|
772
893
|
id: s.id.slice(0, 8),
|
|
773
|
-
pin:
|
|
894
|
+
pin: s.pin || '',
|
|
774
895
|
status: sessionStatus(s),
|
|
775
896
|
age: s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-',
|
|
776
897
|
tasks: `${s.completed}/${s.taskCount}`,
|
|
777
898
|
project: path.basename(s.project || ''),
|
|
778
|
-
title: s
|
|
899
|
+
title: sessionTitle(s),
|
|
779
900
|
}));
|
|
780
901
|
const w = {
|
|
781
902
|
id: 8,
|
|
@@ -789,6 +910,7 @@ async function runSessionListCli(args) {
|
|
|
789
910
|
for (const r of rows) {
|
|
790
911
|
console.log(`${r.id.padEnd(w.id)} ${r.pin.padEnd(w.pin)} ${r.status.padEnd(w.status)} ${r.age.padEnd(w.age)} ${r.tasks.padEnd(w.tasks)} ${r.project.padEnd(w.project)} ${r.title}`);
|
|
791
912
|
}
|
|
913
|
+
if (cut) console.log(cut);
|
|
792
914
|
return 0;
|
|
793
915
|
}
|
|
794
916
|
|
|
@@ -829,13 +951,202 @@ async function runSessionPinCli(args) {
|
|
|
829
951
|
const resolved = await resolveSessionByIdOrPrefix(idArg);
|
|
830
952
|
if (!resolved) return 1;
|
|
831
953
|
try {
|
|
832
|
-
if (!await cliPostJson('/api/session/pin', {
|
|
954
|
+
if (!await cliPostJson('/api/session/pin', { ids: [resolved.id], state }, 'Pin')) return 1;
|
|
833
955
|
const label = state === 'none' ? 'unpinned' : state;
|
|
834
956
|
console.log(`Session ${label}: ${resolved.id}${resolved.customTitle ? ` (${resolved.customTitle})` : ''}`);
|
|
835
957
|
return 0;
|
|
836
958
|
} catch (e) { reportCliError(e); return 1; }
|
|
837
959
|
}
|
|
838
960
|
|
|
961
|
+
const postSessionGroup = (id, body, label) => cliPostJson(`/api/sessions/${encodeURIComponent(id)}/group`, body, label);
|
|
962
|
+
|
|
963
|
+
async function runSessionGroupCli(args, entry) {
|
|
964
|
+
const [idArg, groupArg] = positionals(args, ['--with', '--parent']);
|
|
965
|
+
const parentArg = getArgValue(args, 'parent');
|
|
966
|
+
const withFlag = getArgValue(args, 'with');
|
|
967
|
+
const noParent = args.includes('--no-parent');
|
|
968
|
+
if (!idArg || (!groupArg && !withFlag && !parentArg)) {
|
|
969
|
+
printLeafHelp(entry);
|
|
970
|
+
return 1;
|
|
971
|
+
}
|
|
972
|
+
if (groupArg && withFlag) return usageError(entry, 'Give one of <group> or --with.');
|
|
973
|
+
if (parentArg && noParent) return usageError(entry, 'Give one of --parent or --no-parent.');
|
|
974
|
+
const [resolved, withPeer, parent] = await Promise.all([idArg, withFlag, parentArg].map((a) => a && resolveSessionByIdOrPrefix(a)));
|
|
975
|
+
if (!resolved || (withFlag && !withPeer) || (parentArg && !parent)) return 1;
|
|
976
|
+
// With no group, --parent also names the group to join.
|
|
977
|
+
const peer = withPeer || (!groupArg && parent);
|
|
978
|
+
const create = args.includes('--create');
|
|
979
|
+
const body = peer ? { with: peer.id, create } : { group: groupArg, create };
|
|
980
|
+
if (parent) body.parent = parent.id;
|
|
981
|
+
else if (noParent) body.parent = null;
|
|
982
|
+
try {
|
|
983
|
+
const out = await postSessionGroup(resolved.id, body, 'Group');
|
|
984
|
+
if (!out) return 1;
|
|
985
|
+
const { group, name, path, created, left, rev } = out;
|
|
986
|
+
if (args.includes('--json')) console.log(JSON.stringify({ group, name, path, created, left, rev }, null, 2));
|
|
987
|
+
else console.log(`Session ${resolved.id.slice(0, 8)} moved to ${created ? 'new ' : ''}group "${path}" (${group})`);
|
|
988
|
+
return 0;
|
|
989
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
async function runSessionUngroupCli(args, entry) {
|
|
993
|
+
const [idArg] = positionals(args, []);
|
|
994
|
+
if (!idArg) {
|
|
995
|
+
printLeafHelp(entry);
|
|
996
|
+
return 1;
|
|
997
|
+
}
|
|
998
|
+
const resolved = await resolveSessionByIdOrPrefix(idArg);
|
|
999
|
+
if (!resolved) return 1;
|
|
1000
|
+
try {
|
|
1001
|
+
const out = await postSessionGroup(resolved.id, { group: null }, 'Ungroup');
|
|
1002
|
+
if (!out) return 1;
|
|
1003
|
+
const { left, rev } = out;
|
|
1004
|
+
const short = resolved.id.slice(0, 8);
|
|
1005
|
+
if (args.includes('--json')) console.log(JSON.stringify({ left, rev }, null, 2));
|
|
1006
|
+
else console.log(left ? `Session ${short} left group "${left}"` : `Session ${short} was in no group`);
|
|
1007
|
+
return 0;
|
|
1008
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async function groupVerb(args, urlPath, body, label, line) {
|
|
1012
|
+
try {
|
|
1013
|
+
const out = await cliPostJson(urlPath, body, label);
|
|
1014
|
+
if (!out) return 1;
|
|
1015
|
+
console.log(args.includes('--json') ? JSON.stringify(out, null, 2) : line(out));
|
|
1016
|
+
return 0;
|
|
1017
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async function runGroupCreateCli(args, entry) {
|
|
1021
|
+
const [pathArg] = positionals(args, []);
|
|
1022
|
+
if (!pathArg) {
|
|
1023
|
+
printLeafHelp(entry);
|
|
1024
|
+
return 1;
|
|
1025
|
+
}
|
|
1026
|
+
return groupVerb(args, '/api/groups/path', { path: pathArg }, 'Create', (r) => `Created group "${r.path}" (${r.group})`);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
async function runGroupRenameCli(args, entry) {
|
|
1030
|
+
const [groupArg, nameArg] = positionals(args, []);
|
|
1031
|
+
if (!groupArg || !nameArg) {
|
|
1032
|
+
printLeafHelp(entry);
|
|
1033
|
+
return 1;
|
|
1034
|
+
}
|
|
1035
|
+
return groupVerb(args, '/api/groups/rename', { group: groupArg, name: nameArg }, 'Rename', (r) => `Renamed group to "${r.path}" (${r.group})`);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
async function runGroupRmCli(args, entry) {
|
|
1039
|
+
const [groupArg] = positionals(args, []);
|
|
1040
|
+
if (!groupArg) {
|
|
1041
|
+
printLeafHelp(entry);
|
|
1042
|
+
return 1;
|
|
1043
|
+
}
|
|
1044
|
+
return groupVerb(args, '/api/groups/remove', { group: groupArg }, 'Remove', (r) =>
|
|
1045
|
+
`Removed group "${r.removed.path}" (${r.removed.id})${r.ungrouped ? `; ${r.ungrouped} member${r.ungrouped === 1 ? '' : 's'} left it` : ''}`);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function runGroupPruneCli(args) {
|
|
1049
|
+
const dryRun = args.includes('--dry-run');
|
|
1050
|
+
return groupVerb(args, '/api/groups/prune', { dryRun }, 'Prune', (r) => {
|
|
1051
|
+
if (!r.removed.length) return 'No empty groups.';
|
|
1052
|
+
return r.removed.map((g) => `${dryRun ? 'Would remove' : 'Removed'} "${g.path}" (${g.id})`).join('\n');
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// A session member carries the session's metadata; a project member carries the count of its
|
|
1057
|
+
// sessions that are shown. With activeOnly, a member is shown when the sidebar's Active view
|
|
1058
|
+
// shows it: an active or pinned session, a project with one. A project counts the sessions the
|
|
1059
|
+
// sidebar puts under it: keyed as the board keys them, without those a group holds by id.
|
|
1060
|
+
function describeGroupMembers(groups, sessions, activeOnly) {
|
|
1061
|
+
const byId = new Map(sessions.map((s) => [s.id, s]));
|
|
1062
|
+
const shown = (s) => !activeOnly || s.active || !!s.pin;
|
|
1063
|
+
const heldById = new Set(groups.flatMap((g) => g.members.filter((m) => m.type === 'session').map((m) => m.ref)));
|
|
1064
|
+
const shownByProject = new Map();
|
|
1065
|
+
for (const s of sessions) {
|
|
1066
|
+
const key = sessionProjectKey(s);
|
|
1067
|
+
if (!shown(s) || !key || heldById.has(s.id)) continue;
|
|
1068
|
+
shownByProject.set(key, (shownByProject.get(key) || 0) + 1);
|
|
1069
|
+
}
|
|
1070
|
+
const describe = (m) => {
|
|
1071
|
+
if (m.type === 'project') {
|
|
1072
|
+
const count = shownByProject.get(m.ref) || 0;
|
|
1073
|
+
return [!activeOnly || count > 0, { ...m, sessions: count }];
|
|
1074
|
+
}
|
|
1075
|
+
const s = byId.get(m.ref);
|
|
1076
|
+
if (!s) return [!activeOnly, { ...m, missing: true }];
|
|
1077
|
+
return [shown(s), {
|
|
1078
|
+
...m,
|
|
1079
|
+
title: sessionTitle(s),
|
|
1080
|
+
branch: s.gitBranch || null,
|
|
1081
|
+
status: sessionStatus(s),
|
|
1082
|
+
pinned: s.pin || null,
|
|
1083
|
+
age: ageOf(s.modifiedAt),
|
|
1084
|
+
}];
|
|
1085
|
+
};
|
|
1086
|
+
return groups.map((g) => {
|
|
1087
|
+
const members = g.members.map(describe).filter(([visible]) => visible).map(([, m]) => m);
|
|
1088
|
+
return { ...g, visible: members.length, total: g.members.length, members };
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Dispatch groups are listed after the user groups, as in the sidebar.
|
|
1093
|
+
function placeDispatched(groups, sessions, released = []) {
|
|
1094
|
+
const dispatchGroups = new Map();
|
|
1095
|
+
for (const s of sessions) {
|
|
1096
|
+
if (!s.dispatchGroup) continue;
|
|
1097
|
+
const { named, dispatch } = placeSession(groups, released, { ref: s.id, project: sessionProjectKey(s), dispatchGroup: s.dispatchGroup });
|
|
1098
|
+
if (dispatch && !dispatchGroups.has(dispatch)) {
|
|
1099
|
+
dispatchGroups.set(dispatch, { id: `${DISPATCH_PREFIX}${dispatch}`, name: dispatch, parent: null, dispatch: true, members: [] });
|
|
1100
|
+
}
|
|
1101
|
+
(named || dispatchGroups.get(dispatch))?.members.push({ type: 'session', ref: s.id });
|
|
1102
|
+
}
|
|
1103
|
+
return [...groups, ...dispatchGroups.values()];
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
async function runGroupListCli(args) {
|
|
1107
|
+
const activeOnly = !args.includes('--all');
|
|
1108
|
+
let rev;
|
|
1109
|
+
let groups;
|
|
1110
|
+
try {
|
|
1111
|
+
let released;
|
|
1112
|
+
let dispatch;
|
|
1113
|
+
({ rev, groups, released, dispatch } = await cliGetJson('/api/groups?dispatch=1', 'Group list'));
|
|
1114
|
+
const memberRefs = groups.flatMap((g) => g.members.filter((m) => m.type === 'session').map((m) => m.ref));
|
|
1115
|
+
const sessionRefs = [...new Set([...memberRefs, ...Object.keys(dispatch || {})])];
|
|
1116
|
+
// An inactive session held by id is still listed, so only the active list needs the id lookups.
|
|
1117
|
+
const lists = await Promise.all([
|
|
1118
|
+
fetchSessionsList({ limit: null, activeOnly, lite: true }, 'Group list'),
|
|
1119
|
+
activeOnly ? fetchSessionsByIds(sessionRefs, 'Group list', { lite: true }) : [],
|
|
1120
|
+
]);
|
|
1121
|
+
const sessions = uniqueById(lists.flat());
|
|
1122
|
+
groups = describeGroupMembers(placeDispatched(groups, sessions, released), sessions, activeOnly);
|
|
1123
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
1124
|
+
if (args.includes('--json')) {
|
|
1125
|
+
console.log(JSON.stringify({ rev, groups }, null, 2));
|
|
1126
|
+
return 0;
|
|
1127
|
+
}
|
|
1128
|
+
if (!groups.length) {
|
|
1129
|
+
console.log('No groups.');
|
|
1130
|
+
return 0;
|
|
1131
|
+
}
|
|
1132
|
+
const rows = [];
|
|
1133
|
+
const add = (parent, depth) => {
|
|
1134
|
+
const pad = ' '.repeat(depth);
|
|
1135
|
+
for (const g of groups.filter((x) => x.parent === parent)) {
|
|
1136
|
+
rows.push([g.dispatch ? '-' : g.id, '', '', '', `${pad}${g.name}${g.dispatch ? ' (dispatch)' : ''} (${g.visible}/${g.total})`]);
|
|
1137
|
+
for (const m of g.members) {
|
|
1138
|
+
if (m.type === 'project') rows.push(['project', '', '', '', `${pad} ${m.ref} (${m.sessions} sessions)`]);
|
|
1139
|
+
else if (m.missing) rows.push([m.ref.slice(0, 8), 'gone', '-', '', `${pad} (no such session)`]);
|
|
1140
|
+
else rows.push([m.ref.slice(0, 8), m.status, m.age, m.pinned || '', `${pad} ${m.title}`]);
|
|
1141
|
+
}
|
|
1142
|
+
add(g.id, depth + 1);
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
add(null, 0);
|
|
1146
|
+
printTable(['ID', 'STATUS', 'AGE', 'PIN', 'NAME'], rows);
|
|
1147
|
+
return 0;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
839
1150
|
async function runSessionViewCli(args) {
|
|
840
1151
|
const idArg = args.find(a => !a.startsWith('--'));
|
|
841
1152
|
if (!idArg) {
|
|
@@ -860,7 +1171,7 @@ async function runSessionViewCli(args) {
|
|
|
860
1171
|
return 0;
|
|
861
1172
|
}
|
|
862
1173
|
const status = sessionStatus(s);
|
|
863
|
-
const title = s
|
|
1174
|
+
const title = sessionTitle(s);
|
|
864
1175
|
const age = s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-';
|
|
865
1176
|
const fmtTok = (n) => typeof n === 'number' ? (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)) : '-';
|
|
866
1177
|
const fmtCost = (n) => typeof n === 'number' ? `$${n.toFixed(2)}` : '-';
|
|
@@ -907,12 +1218,14 @@ function printTable(header, rows) {
|
|
|
907
1218
|
|
|
908
1219
|
const ageOf = (iso) => (iso ? formatAge(Date.now() - new Date(iso).getTime()) : '-');
|
|
909
1220
|
|
|
910
|
-
async function
|
|
1221
|
+
async function cliGet(urlPath, label) {
|
|
911
1222
|
const res = await cliFetch(urlPath);
|
|
912
1223
|
if (!res.ok) throw new Error(`${label} failed (${res.status}): ${await res.text()}`);
|
|
913
|
-
return res
|
|
1224
|
+
return res;
|
|
914
1225
|
}
|
|
915
1226
|
|
|
1227
|
+
const cliGetJson = async (urlPath, label) => (await cliGet(urlPath, label)).json();
|
|
1228
|
+
|
|
916
1229
|
async function runSessionSearchCli(args) {
|
|
917
1230
|
const entry = COMMANDS.session.verbs.search;
|
|
918
1231
|
const text = positionals(args, ['--limit']).join(' ').trim();
|
|
@@ -941,7 +1254,7 @@ async function runSessionSearchCli(args) {
|
|
|
941
1254
|
byId.has(s.id) ? sessionStatus(s) : '-',
|
|
942
1255
|
ageOf(s.modifiedAt),
|
|
943
1256
|
path.basename(s.project || ''),
|
|
944
|
-
s
|
|
1257
|
+
sessionTitle(s),
|
|
945
1258
|
]));
|
|
946
1259
|
return 0;
|
|
947
1260
|
} catch (e) { reportCliError(e); return 1; }
|
|
@@ -1134,6 +1447,7 @@ async function runDispatchStartCli(argv) {
|
|
|
1134
1447
|
if (!out) return 1;
|
|
1135
1448
|
if (args.includes('--json')) console.log(JSON.stringify(out, null, 2));
|
|
1136
1449
|
else console.log(`Started session ${out.session} in ${out.cwd}${out.group ? ` [${out.group}]` : ''}`);
|
|
1450
|
+
if (out.group) await noteJoinedGroup(out.session, out.group);
|
|
1137
1451
|
return 0;
|
|
1138
1452
|
} catch (e) { reportCliError(e); return 1; }
|
|
1139
1453
|
}
|
|
@@ -1144,6 +1458,15 @@ async function fetchDispatches(all) {
|
|
|
1144
1458
|
return (await cliGetJson(`/api/dispatch?${q}`, 'Dispatch list')).running.sort((a, b) => b.startedAt - a.startedAt);
|
|
1145
1459
|
}
|
|
1146
1460
|
|
|
1461
|
+
// A user group named like the dispatch group takes the session in; say so, or the board looks wrong.
|
|
1462
|
+
async function noteJoinedGroup(session, dispatchGroup) {
|
|
1463
|
+
try {
|
|
1464
|
+
const { groups, released } = await cliGetJson('/api/groups', 'Group list');
|
|
1465
|
+
const { named } = placeSession(groups, released, { ref: session, dispatchGroup });
|
|
1466
|
+
if (named) console.error(`Joins user group "${named.name}" (${named.id}), which has the same name.`);
|
|
1467
|
+
} catch (_) { /* the session started; the note is optional */ }
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1147
1470
|
const dispatchLine = (r) => `${r.session}${r.name ? ` ${r.name}` : ''}${r.group ? ` [${r.group}]` : ''}`;
|
|
1148
1471
|
|
|
1149
1472
|
async function runDispatchListCli(args) {
|
|
@@ -1186,6 +1509,33 @@ async function runDispatchEndCli(args) {
|
|
|
1186
1509
|
} catch (e) { reportCliError(e); return 1; }
|
|
1187
1510
|
}
|
|
1188
1511
|
|
|
1512
|
+
function terminalKind(t, self) {
|
|
1513
|
+
if (t.kanbot) return 'kanbot';
|
|
1514
|
+
if (t.dispatched) return self && t.dispatched.parent === self ? 'dispatch (yours)' : 'dispatch';
|
|
1515
|
+
return t.mode === 'shell' ? 'shell' : 'claude';
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
async function runTerminalListCli(args) {
|
|
1519
|
+
let rows;
|
|
1520
|
+
try {
|
|
1521
|
+
rows = (await cliGetJson('/api/terminals', 'Terminal list')).sessions.sort((a, b) => b.startedAt - a.startedAt);
|
|
1522
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
1523
|
+
if (args.includes('--json')) {
|
|
1524
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
1525
|
+
return 0;
|
|
1526
|
+
}
|
|
1527
|
+
if (!rows.length) {
|
|
1528
|
+
console.log('No terminals.');
|
|
1529
|
+
return 0;
|
|
1530
|
+
}
|
|
1531
|
+
const self = process.env.CLAUDE_CODE_SESSION_ID;
|
|
1532
|
+
printTable(['SESSION', 'AGE', 'KIND', 'NAME', 'CWD'], rows.map(t => [t.id, ageOf(t.startedAt), terminalKind(t, self), t.name || '-', t.cwd || '-']));
|
|
1533
|
+
if (rows.some(t => self && t.dispatched?.parent === self)) {
|
|
1534
|
+
console.log('\nEnd one of yours with `claude-code-kanban dispatch end <session>`. The user ends the others from the board.');
|
|
1535
|
+
}
|
|
1536
|
+
return 0;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1189
1539
|
async function runSkillsGetCli(args) {
|
|
1190
1540
|
const name = args.find(a => !a.startsWith('--'));
|
|
1191
1541
|
const file = name && /^[a-z][a-z-]*$/.test(name) ? path.join(__dirname, 'skill-guides', `${name}.md`) : null;
|
|
@@ -1197,4 +1547,4 @@ async function runSkillsGetCli(args) {
|
|
|
1197
1547
|
return 0;
|
|
1198
1548
|
}
|
|
1199
1549
|
|
|
1200
|
-
module.exports = { runCli, COMMANDS, SERVER_FLAGS };
|
|
1550
|
+
module.exports = { runCli, topHelp, COMMANDS, SERVER_FLAGS };
|