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/README.md +2 -1
- package/cli.js +391 -70
- package/hub-app.json +3 -3
- package/install.js +11 -9
- 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/live-sessions.js +1 -1
- 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-client.js +4 -0
- package/lib/terminal-host.js +7 -1
- package/lib/terminal.js +86 -20
- package/lib/user-groups.js +384 -0
- package/package.json +3 -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 +1647 -464
- package/public/group-tree.js +21 -0
- package/public/index.html +53 -28
- package/public/style.css +820 -106
- package/public/sw.js +1 -1
- package/public/terminal-frame.js +12 -0
- package/public/vendor/claude-hub-sdk.js +1 -1
- package/server.js +333 -148
- package/skill-guides/dispatch.md +1 -1
- package/lib/dispatch-groups.js +0 -88
package/cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const fs = require('node:fs');
|
|
2
2
|
const path = require('node:path');
|
|
3
3
|
const { getClaudeDir, displayPath } = require('./lib/claude-dir');
|
|
4
|
-
const { isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
|
|
5
4
|
const { linkUrl } = require('./public/link-url');
|
|
5
|
+
const { sessionProjectKey } = require('./public/project-match');
|
|
6
6
|
// Help is auto-generated from this table — keep flags/usage in sync with `run` behavior.
|
|
7
7
|
const SESSION_FLAG = '--session <id>';
|
|
8
8
|
const SESSION_FLAG_HELP = 'Session, full id or unique prefix (default: $PREVIEW_SESSION, else $CLAUDE_CODE_SESSION_ID)';
|
|
@@ -99,22 +99,23 @@ const COMMANDS = {
|
|
|
99
99
|
},
|
|
100
100
|
},
|
|
101
101
|
session: {
|
|
102
|
-
summary: 'List, search, open and
|
|
102
|
+
summary: 'List, search, open, inspect and group Claude Code sessions',
|
|
103
103
|
verbs: {
|
|
104
104
|
list: {
|
|
105
|
-
summary: 'List sessions (pinned/sticky always included)',
|
|
106
|
-
usage: 'claude-code-kanban session list [--
|
|
105
|
+
summary: 'List the active sessions, as the sidebar\'s Active view shows them (pinned/sticky always included)',
|
|
106
|
+
usage: 'claude-code-kanban session list [--all] [--days <n>] [--project <name>] [--limit <n|all>] [--no-pins] [--json]',
|
|
107
107
|
flags: {
|
|
108
|
-
'--
|
|
108
|
+
'--all': 'Every session, active or not',
|
|
109
109
|
'--days <n>': 'Only sessions modified within the last N days (fractional ok, e.g. 0.5)',
|
|
110
110
|
'--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:
|
|
111
|
+
'--limit <n|all>': 'Max rows to display (default: every active row; 20 with --all). Pinned sessions are kept past it.',
|
|
112
112
|
'--no-pins': 'Disable always-include and sticky-first ordering for pinned sessions',
|
|
113
113
|
'--json': 'Output JSON instead of a table',
|
|
114
114
|
},
|
|
115
|
+
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
116
|
examples: [
|
|
116
|
-
'claude-code-kanban session list
|
|
117
|
-
'claude-code-kanban session list --days 0.5 --limit
|
|
117
|
+
'claude-code-kanban session list',
|
|
118
|
+
'claude-code-kanban session list --all --days 0.5 --limit 20 --project my-repo',
|
|
118
119
|
],
|
|
119
120
|
run: runSessionListCli,
|
|
120
121
|
},
|
|
@@ -173,11 +174,11 @@ const COMMANDS = {
|
|
|
173
174
|
run: runSessionAgentsCli,
|
|
174
175
|
},
|
|
175
176
|
pin: {
|
|
176
|
-
summary: 'Pin (or unpin) a session in the sidebar
|
|
177
|
+
summary: 'Pin (or unpin) a session in the sidebar; every board on this config dir shows it',
|
|
177
178
|
usage: 'claude-code-kanban session pin <id> [--sticky] [--unpin]',
|
|
178
179
|
flags: {
|
|
179
180
|
'<id>': 'Full session id, or a unique prefix',
|
|
180
|
-
'--sticky': 'Set sticky state (always shown
|
|
181
|
+
'--sticky': 'Set sticky state (always shown in the list)',
|
|
181
182
|
'--unpin': 'Clear pin/sticky state',
|
|
182
183
|
},
|
|
183
184
|
examples: [
|
|
@@ -186,6 +187,99 @@ const COMMANDS = {
|
|
|
186
187
|
],
|
|
187
188
|
run: runSessionPinCli,
|
|
188
189
|
},
|
|
190
|
+
group: {
|
|
191
|
+
summary: 'Move a session into a group in the sidebar',
|
|
192
|
+
usage: 'claude-code-kanban session group <id> (<group> | --with <session> | --parent <session>) [--no-parent] [--create] [--json]',
|
|
193
|
+
flags: {
|
|
194
|
+
'<id>': 'Full session id, or a unique prefix',
|
|
195
|
+
'<group>': 'Group name (any case), path from the top (parent/child) or id, from `group list`',
|
|
196
|
+
'--with <session>': 'Join the group this other session shows in (id or unique prefix)',
|
|
197
|
+
'--parent <session>': 'Show the session under this one, indented, when both are in the same list; alone, it also joins its group',
|
|
198
|
+
'--no-parent': 'Show the session on its own again',
|
|
199
|
+
'--create': 'Make the group, and the missing parents of a path, when none matches; with --with, it holds both sessions',
|
|
200
|
+
'--json': 'Output JSON ({group, name, path, created, left, rev})',
|
|
201
|
+
},
|
|
202
|
+
notes: 'The session leaves the group it was in. In a group that holds its project, it shows in that project\'s block.',
|
|
203
|
+
examples: [
|
|
204
|
+
'claude-code-kanban session group $CLAUDE_SESSION_ID "Auth refactor"',
|
|
205
|
+
'claude-code-kanban session group 3fa9c1 auth-refactor/swarm-1 --create',
|
|
206
|
+
'claude-code-kanban session group 3fa9c1 --with $CLAUDE_SESSION_ID --create',
|
|
207
|
+
'claude-code-kanban session group 3fa9c1 --parent $CLAUDE_SESSION_ID',
|
|
208
|
+
],
|
|
209
|
+
run: runSessionGroupCli,
|
|
210
|
+
},
|
|
211
|
+
ungroup: {
|
|
212
|
+
summary: 'Take a session out of its group',
|
|
213
|
+
usage: 'claude-code-kanban session ungroup <id> [--json]',
|
|
214
|
+
flags: {
|
|
215
|
+
'<id>': 'Full session id, or a unique prefix',
|
|
216
|
+
'--json': 'Output JSON ({left, rev})',
|
|
217
|
+
},
|
|
218
|
+
notes: 'A session whose project is in a group still shows there; move the project on the board.',
|
|
219
|
+
examples: ['claude-code-kanban session ungroup $CLAUDE_SESSION_ID'],
|
|
220
|
+
run: runSessionUngroupCli,
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
group: {
|
|
225
|
+
summary: 'Read and change the session groups in the sidebar',
|
|
226
|
+
verbs: {
|
|
227
|
+
list: {
|
|
228
|
+
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)',
|
|
229
|
+
usage: 'claude-code-kanban group list [--all] [--json]',
|
|
230
|
+
flags: {
|
|
231
|
+
'--all': 'Every member, active or not',
|
|
232
|
+
'--json': 'Output JSON ({rev, groups: [{id, name, parent, transient?, visible, total, members}]}); a session member has title, branch, status, pinned and age, or missing: true; a project member has sessions (its shown sessions)',
|
|
233
|
+
},
|
|
234
|
+
notes: 'A project member shows when it has a session the Active view shows. visible/total counts the members shown. A group marked (transient) goes when the board\'s Active view shows none of its sessions; Keep or a rename on the board, or `group rename`, keeps it. `dispatch start --group` and `group create --transient` make transient groups. Pass a group\'s name, path (parent/child) or id to `session group`.',
|
|
235
|
+
examples: ['claude-code-kanban group list', 'claude-code-kanban group list --all --json'],
|
|
236
|
+
run: runGroupListCli,
|
|
237
|
+
},
|
|
238
|
+
create: {
|
|
239
|
+
summary: 'Make a group, and the missing parents of a path',
|
|
240
|
+
usage: 'claude-code-kanban group create <name|path> [--transient] [--json]',
|
|
241
|
+
flags: {
|
|
242
|
+
'<name|path>': 'Name of a new top-level group, or a path from the top (parent/child)',
|
|
243
|
+
'--transient': 'The groups it makes go when the Active view shows none of their sessions',
|
|
244
|
+
'--json': 'Output JSON ({group, name, path, rev})',
|
|
245
|
+
},
|
|
246
|
+
notes: 'Fails when the group exists.',
|
|
247
|
+
examples: ['claude-code-kanban group create "Auth refactor"', 'claude-code-kanban group create auth-refactor/swarm-1'],
|
|
248
|
+
run: runGroupCreateCli,
|
|
249
|
+
},
|
|
250
|
+
rename: {
|
|
251
|
+
summary: 'Rename a group; it keeps its place, parent and members',
|
|
252
|
+
usage: 'claude-code-kanban group rename <group> <new name> [--json]',
|
|
253
|
+
flags: {
|
|
254
|
+
'<group>': 'Group name (any case), path from the top (parent/child) or id, from `group list`',
|
|
255
|
+
'<new name>': 'The new name',
|
|
256
|
+
'--json': 'Output JSON ({group, name, path, rev})',
|
|
257
|
+
},
|
|
258
|
+
examples: ['claude-code-kanban group rename auth-refactor "Auth v2"'],
|
|
259
|
+
run: runGroupRenameCli,
|
|
260
|
+
},
|
|
261
|
+
rm: {
|
|
262
|
+
summary: 'Remove a group, as Delete on the board does',
|
|
263
|
+
usage: 'claude-code-kanban group rm <group> [--json]',
|
|
264
|
+
flags: {
|
|
265
|
+
'<group>': 'Group name (any case), path from the top (parent/child) or id, from `group list`',
|
|
266
|
+
'--json': 'Output JSON ({removed: {id, name, path}, ungrouped, rev})',
|
|
267
|
+
},
|
|
268
|
+
notes: 'Its members leave the group and show as with no group: in their project\'s group or the top level. Its child groups move up one level.',
|
|
269
|
+
examples: ['claude-code-kanban group rm presales-cloud'],
|
|
270
|
+
run: runGroupRmCli,
|
|
271
|
+
},
|
|
272
|
+
prune: {
|
|
273
|
+
summary: 'Remove every empty group',
|
|
274
|
+
usage: 'claude-code-kanban group prune [--dry-run] [--json]',
|
|
275
|
+
flags: {
|
|
276
|
+
'--dry-run': 'List the groups it would remove, and remove none',
|
|
277
|
+
'--json': 'Output JSON ({removed: [{id, name, path}], dryRun, rev})',
|
|
278
|
+
},
|
|
279
|
+
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.',
|
|
280
|
+
examples: ['claude-code-kanban group prune --dry-run', 'claude-code-kanban group prune'],
|
|
281
|
+
run: runGroupPruneCli,
|
|
282
|
+
},
|
|
189
283
|
},
|
|
190
284
|
},
|
|
191
285
|
task: {
|
|
@@ -230,11 +324,11 @@ const COMMANDS = {
|
|
|
230
324
|
summary: 'Start claude in cck\'s terminal with a task; prints the session id',
|
|
231
325
|
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
326
|
flags: {
|
|
233
|
-
'--cwd <dir>': 'Folder to run in (
|
|
327
|
+
'--cwd <dir>': 'Folder to run in (any existing folder, default: current dir)',
|
|
234
328
|
'--spec <text>': 'The task, self-contained; sent as the first message',
|
|
235
329
|
'--spec-file <path>': 'Read the task from a file',
|
|
236
330
|
'--name <n>': 'Session name; also its peer name for SendMessage',
|
|
237
|
-
'--group <g>': '
|
|
331
|
+
'--group <g>': 'Add it to this group: a name, path or id. The groups it makes are transient (see `help group list`)',
|
|
238
332
|
'--model <m>': 'fable, opus, sonnet or haiku',
|
|
239
333
|
'--worktree [name]': 'Run in a new git worktree',
|
|
240
334
|
'--task-list [id]': 'Use this task list instead of its own (default: this session\'s list), so its cards show on one board with yours. Off unless given',
|
|
@@ -271,6 +365,20 @@ const COMMANDS = {
|
|
|
271
365
|
},
|
|
272
366
|
},
|
|
273
367
|
},
|
|
368
|
+
terminal: {
|
|
369
|
+
summary: 'Read the terminals running in cck',
|
|
370
|
+
verbs: {
|
|
371
|
+
list: {
|
|
372
|
+
summary: 'List every terminal in cck: dispatched, opened by hand, and Kanbot',
|
|
373
|
+
usage: 'claude-code-kanban terminal list [--json]',
|
|
374
|
+
flags: {
|
|
375
|
+
'--json': 'Output JSON',
|
|
376
|
+
},
|
|
377
|
+
notes: 'Read-only. `dispatch end` ends a terminal this session dispatched; the user ends the others from the board.',
|
|
378
|
+
run: runTerminalListCli,
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
},
|
|
274
382
|
skills: {
|
|
275
383
|
summary: 'Print a skill guide bundled with this version',
|
|
276
384
|
verbs: {
|
|
@@ -359,26 +467,30 @@ const SERVER_FLAGS = [
|
|
|
359
467
|
['--yes', '', 'With --install: no prompt'],
|
|
360
468
|
];
|
|
361
469
|
|
|
362
|
-
function
|
|
363
|
-
|
|
364
|
-
console.log('Commands:');
|
|
470
|
+
function topHelp() {
|
|
471
|
+
const lines = ['Usage: claude-code-kanban <command> [args] [--flags]\n', 'Commands:'];
|
|
365
472
|
for (const [name, cmd] of Object.entries(COMMANDS)) {
|
|
366
473
|
const verbs = cmd.verbs ? ` (${Object.keys(cmd.verbs).join(', ')})` : '';
|
|
367
|
-
|
|
474
|
+
lines.push(` ${name.padEnd(20)}${cmd.summary}${verbs}`);
|
|
368
475
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
476
|
+
lines.push(` ${'help'.padEnd(20)}Show help for a command (claude-code-kanban help <command>)`);
|
|
477
|
+
lines.push('\nFlags:');
|
|
478
|
+
lines.push(' --help, -h Show help (top-level, noun-level, or leaf-level)');
|
|
479
|
+
lines.push(' --version, -v Print version and exit');
|
|
480
|
+
lines.push('\nServer (no command; a flag wins over its env var):');
|
|
374
481
|
const flagPad = Math.max(...SERVER_FLAGS.map(([flag]) => flag.length)) + 2;
|
|
375
482
|
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
|
-
|
|
483
|
+
for (const [flag, env, desc] of SERVER_FLAGS) lines.push(` ${flag.padEnd(flagPad)}${env.padEnd(envPad)}${desc}`);
|
|
484
|
+
lines.push('\nCommand env:');
|
|
485
|
+
lines.push(' CCK_URL Server base URL, e.g. http://127.0.0.1:4795 (wins over PORT)');
|
|
486
|
+
lines.push(' PORT Server port (default: the one this config dir\'s server reports, else 3541)');
|
|
487
|
+
lines.push(' CLAUDE_CONFIG_DIR Claude config dir whose board to use');
|
|
488
|
+
lines.push('\nRun `claude-code-kanban help <command>` for its subcommands, and `help <command> <subcommand>` for flags and examples.');
|
|
489
|
+
return lines.join('\n');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function printTopHelp() {
|
|
493
|
+
console.log(topHelp());
|
|
382
494
|
}
|
|
383
495
|
|
|
384
496
|
function printNounHelp(noun) {
|
|
@@ -653,13 +765,13 @@ async function printLinkedDocs(sessionId, asJson) {
|
|
|
653
765
|
} catch (e) { reportCliError(e); return 1; }
|
|
654
766
|
}
|
|
655
767
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
|
|
768
|
+
function sessionTitle(s) {
|
|
769
|
+
return s.customTitle || s.name || s.slug || '';
|
|
659
770
|
}
|
|
660
771
|
|
|
772
|
+
// The board's badge rule (docs/session-states.md). `active` is a wider rule, for which rows show.
|
|
661
773
|
function sessionStatus(s) {
|
|
662
|
-
if (!
|
|
774
|
+
if (!(s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser)) return 'idle';
|
|
663
775
|
if (s.hasWaitingForUser) return 'wait';
|
|
664
776
|
if (s.inProgress > 0) return 'busy';
|
|
665
777
|
return 'active';
|
|
@@ -674,24 +786,29 @@ function parseLimit(args, { fallback, allowAll = false }) {
|
|
|
674
786
|
return { ok: true, limit: n };
|
|
675
787
|
}
|
|
676
788
|
|
|
677
|
-
|
|
789
|
+
// `lite` leaves out the fields that can read a whole transcript (loopInfo).
|
|
790
|
+
function sessionsListPath({ limit, noPins = false, include = [], project = null, activeOnly = false, lite = false }) {
|
|
678
791
|
const q = limit === null ? 'all' : String(limit);
|
|
679
|
-
const
|
|
792
|
+
const pinsQ = noPins ? '&pins=off' : '';
|
|
793
|
+
const includeQ = include.length ? `&include=${include.map(encodeURIComponent).join(',')}` : '';
|
|
680
794
|
const projectQ = project ? `&project=${encodeURIComponent(project)}` : '';
|
|
681
|
-
|
|
795
|
+
const filterQ = activeOnly ? '&filter=active' : '';
|
|
796
|
+
const liteQ = lite ? '&lite=1' : '';
|
|
797
|
+
return `/api/sessions?limit=${q}${pinsQ}${includeQ}${projectQ}${filterQ}${liteQ}`;
|
|
682
798
|
}
|
|
683
799
|
|
|
684
|
-
|
|
685
|
-
return cliGetJson(`/api/sessions?limit=1&include=${ids.map(encodeURIComponent).join(',')}`, label);
|
|
686
|
-
}
|
|
800
|
+
const fetchSessionsList = (opts, label = 'Session list') => cliGetJson(sessionsListPath(opts), label);
|
|
687
801
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
802
|
+
const uniqueById = (list) => [...new Map(list.map((s) => [s.id, s])).values()];
|
|
803
|
+
|
|
804
|
+
// The server refuses a request line above about 16 KB (431), about 430 ids, so the ids go in batches.
|
|
805
|
+
const IDS_PER_REQUEST = 100;
|
|
806
|
+
|
|
807
|
+
async function fetchSessionsByIds(ids, label, { lite = false } = {}) {
|
|
808
|
+
const batches = [];
|
|
809
|
+
for (let i = 0; i < ids.length; i += IDS_PER_REQUEST) batches.push(ids.slice(i, i + IDS_PER_REQUEST));
|
|
810
|
+
const lists = await Promise.all(batches.map((include) => fetchSessionsList({ limit: 1, include, lite }, label)));
|
|
811
|
+
return uniqueById(lists.flat());
|
|
695
812
|
}
|
|
696
813
|
|
|
697
814
|
async function resolveSessionByIdOrPrefix(idArg) {
|
|
@@ -720,7 +837,7 @@ async function resolveSessionByIdOrPrefix(idArg) {
|
|
|
720
837
|
}
|
|
721
838
|
|
|
722
839
|
async function runSessionListCli(args) {
|
|
723
|
-
const activeOnly = args.includes('--
|
|
840
|
+
const activeOnly = !args.includes('--all');
|
|
724
841
|
const noPins = args.includes('--no-pins');
|
|
725
842
|
const projectFilter = getArgValue(args, 'project');
|
|
726
843
|
const daysArg = getArgValue(args, 'days');
|
|
@@ -728,40 +845,43 @@ async function runSessionListCli(args) {
|
|
|
728
845
|
if (daysArg !== null && (Number.isNaN(days) || days <= 0)) {
|
|
729
846
|
return usageError(COMMANDS.session.verbs.list, `Invalid --days value: ${daysArg}`);
|
|
730
847
|
}
|
|
731
|
-
|
|
848
|
+
// The Active view is short, so it has no cap. The server reads a whole transcript for some
|
|
849
|
+
// fields of each row it sends, so --all keeps one.
|
|
850
|
+
const parsed = parseLimit(args, { fallback: activeOnly ? null : 20, allowAll: true });
|
|
732
851
|
if (!parsed.ok) return usageError(COMMANDS.session.verbs.list, parsed.error);
|
|
733
852
|
const limit = parsed.limit;
|
|
734
853
|
const asJson = args.includes('--json');
|
|
735
|
-
const pinsMap = noPins ? {} : await fetchPinsMap();
|
|
736
|
-
const pinnedIds = Object.keys(pinsMap);
|
|
737
|
-
const hasClientFilter = activeOnly || days !== null;
|
|
738
854
|
let list;
|
|
855
|
+
let total;
|
|
739
856
|
try {
|
|
740
|
-
|
|
857
|
+
const res = await cliGet(sessionsListPath({ limit: days !== null ? null : limit, noPins, project: projectFilter, activeOnly, lite: !asJson }), 'Session list');
|
|
858
|
+
list = await res.json();
|
|
859
|
+
total = Number(res.headers.get('x-total-count')) || list.length;
|
|
741
860
|
} catch (e) {
|
|
742
861
|
reportCliError(e);
|
|
743
862
|
return 1;
|
|
744
863
|
}
|
|
745
|
-
const pinOf = id => pinsMap[id] || null;
|
|
746
|
-
if (activeOnly) list = list.filter(s => pinOf(s.id) || isSessionActive(s));
|
|
747
864
|
if (days !== null) {
|
|
748
865
|
const cutoff = Date.now() - days * 86_400_000;
|
|
749
|
-
list = list.filter(s =>
|
|
866
|
+
list = list.filter(s => s.pin || (s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff));
|
|
867
|
+
total = list.length;
|
|
750
868
|
}
|
|
751
|
-
const pinRank =
|
|
869
|
+
const pinRank = s => s.pin === 'sticky' ? 0 : s.pin === 'pinned' ? 1 : 2;
|
|
752
870
|
list.sort((a, b) => {
|
|
753
|
-
const r = pinRank(a
|
|
871
|
+
const r = pinRank(a) - pinRank(b);
|
|
754
872
|
if (r !== 0) return r;
|
|
755
873
|
return new Date(b.modifiedAt || 0) - new Date(a.modifiedAt || 0);
|
|
756
874
|
});
|
|
757
875
|
if (limit !== null && list.length > limit) {
|
|
758
876
|
const top = list.slice(0, limit);
|
|
759
877
|
const topIds = new Set(top.map(s => s.id));
|
|
760
|
-
const extraPinned = list.filter(s =>
|
|
878
|
+
const extraPinned = list.filter(s => s.pin && !topIds.has(s.id));
|
|
761
879
|
list = [...top, ...extraPinned];
|
|
762
880
|
}
|
|
881
|
+
const cut = list.length < total ? `${list.length} of ${total} shown; --limit all shows every row` : null;
|
|
763
882
|
if (asJson) {
|
|
764
|
-
console.log(JSON.stringify(list.map(s => ({ ...s, pinState:
|
|
883
|
+
console.log(JSON.stringify(list.map(({ pin, ...s }) => ({ ...s, pinState: pin || null })), null, 2));
|
|
884
|
+
if (cut) console.error(cut);
|
|
765
885
|
return 0;
|
|
766
886
|
}
|
|
767
887
|
if (!list.length) {
|
|
@@ -770,12 +890,12 @@ async function runSessionListCli(args) {
|
|
|
770
890
|
}
|
|
771
891
|
const rows = list.map(s => ({
|
|
772
892
|
id: s.id.slice(0, 8),
|
|
773
|
-
pin:
|
|
893
|
+
pin: s.pin || '',
|
|
774
894
|
status: sessionStatus(s),
|
|
775
895
|
age: s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-',
|
|
776
896
|
tasks: `${s.completed}/${s.taskCount}`,
|
|
777
897
|
project: path.basename(s.project || ''),
|
|
778
|
-
title: s
|
|
898
|
+
title: sessionTitle(s),
|
|
779
899
|
}));
|
|
780
900
|
const w = {
|
|
781
901
|
id: 8,
|
|
@@ -789,6 +909,7 @@ async function runSessionListCli(args) {
|
|
|
789
909
|
for (const r of rows) {
|
|
790
910
|
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
911
|
}
|
|
912
|
+
if (cut) console.log(cut);
|
|
792
913
|
return 0;
|
|
793
914
|
}
|
|
794
915
|
|
|
@@ -829,13 +950,186 @@ async function runSessionPinCli(args) {
|
|
|
829
950
|
const resolved = await resolveSessionByIdOrPrefix(idArg);
|
|
830
951
|
if (!resolved) return 1;
|
|
831
952
|
try {
|
|
832
|
-
if (!await cliPostJson('/api/session/pin', {
|
|
953
|
+
if (!await cliPostJson('/api/session/pin', { ids: [resolved.id], state }, 'Pin')) return 1;
|
|
833
954
|
const label = state === 'none' ? 'unpinned' : state;
|
|
834
955
|
console.log(`Session ${label}: ${resolved.id}${resolved.customTitle ? ` (${resolved.customTitle})` : ''}`);
|
|
835
956
|
return 0;
|
|
836
957
|
} catch (e) { reportCliError(e); return 1; }
|
|
837
958
|
}
|
|
838
959
|
|
|
960
|
+
const postSessionGroup = (id, body, label) => cliPostJson(`/api/sessions/${encodeURIComponent(id)}/group`, body, label);
|
|
961
|
+
|
|
962
|
+
async function runSessionGroupCli(args, entry) {
|
|
963
|
+
const [idArg, groupArg] = positionals(args, ['--with', '--parent']);
|
|
964
|
+
const parentArg = getArgValue(args, 'parent');
|
|
965
|
+
const withFlag = getArgValue(args, 'with');
|
|
966
|
+
const noParent = args.includes('--no-parent');
|
|
967
|
+
if (!idArg || (!groupArg && !withFlag && !parentArg)) {
|
|
968
|
+
printLeafHelp(entry);
|
|
969
|
+
return 1;
|
|
970
|
+
}
|
|
971
|
+
if (groupArg && withFlag) return usageError(entry, 'Give one of <group> or --with.');
|
|
972
|
+
if (parentArg && noParent) return usageError(entry, 'Give one of --parent or --no-parent.');
|
|
973
|
+
const [resolved, withPeer, parent] = await Promise.all([idArg, withFlag, parentArg].map((a) => a && resolveSessionByIdOrPrefix(a)));
|
|
974
|
+
if (!resolved || (withFlag && !withPeer) || (parentArg && !parent)) return 1;
|
|
975
|
+
// With no group, --parent also names the group to join.
|
|
976
|
+
const peer = withPeer || (!groupArg && parent);
|
|
977
|
+
const create = args.includes('--create');
|
|
978
|
+
const body = peer ? { with: peer.id, create } : { group: groupArg, create };
|
|
979
|
+
if (parent) body.parent = parent.id;
|
|
980
|
+
else if (noParent) body.parent = null;
|
|
981
|
+
try {
|
|
982
|
+
const out = await postSessionGroup(resolved.id, body, 'Group');
|
|
983
|
+
if (!out) return 1;
|
|
984
|
+
const { group, name, path, created, left, rev } = out;
|
|
985
|
+
if (args.includes('--json')) console.log(JSON.stringify({ group, name, path, created, left, rev }, null, 2));
|
|
986
|
+
else console.log(`Session ${resolved.id.slice(0, 8)} moved to ${created ? 'new ' : ''}group "${path}" (${group})`);
|
|
987
|
+
return 0;
|
|
988
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
async function runSessionUngroupCli(args, entry) {
|
|
992
|
+
const [idArg] = positionals(args, []);
|
|
993
|
+
if (!idArg) {
|
|
994
|
+
printLeafHelp(entry);
|
|
995
|
+
return 1;
|
|
996
|
+
}
|
|
997
|
+
const resolved = await resolveSessionByIdOrPrefix(idArg);
|
|
998
|
+
if (!resolved) return 1;
|
|
999
|
+
try {
|
|
1000
|
+
const out = await postSessionGroup(resolved.id, { group: null }, 'Ungroup');
|
|
1001
|
+
if (!out) return 1;
|
|
1002
|
+
const { left, rev } = out;
|
|
1003
|
+
const short = resolved.id.slice(0, 8);
|
|
1004
|
+
if (args.includes('--json')) console.log(JSON.stringify({ left, rev }, null, 2));
|
|
1005
|
+
else console.log(left ? `Session ${short} left group "${left}"` : `Session ${short} was in no group`);
|
|
1006
|
+
return 0;
|
|
1007
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
async function groupVerb(args, urlPath, body, label, line) {
|
|
1011
|
+
try {
|
|
1012
|
+
const out = await cliPostJson(urlPath, body, label);
|
|
1013
|
+
if (!out) return 1;
|
|
1014
|
+
console.log(args.includes('--json') ? JSON.stringify(out, null, 2) : line(out));
|
|
1015
|
+
return 0;
|
|
1016
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async function runGroupCreateCli(args, entry) {
|
|
1020
|
+
const [pathArg] = positionals(args, []);
|
|
1021
|
+
if (!pathArg) {
|
|
1022
|
+
printLeafHelp(entry);
|
|
1023
|
+
return 1;
|
|
1024
|
+
}
|
|
1025
|
+
const transient = args.includes('--transient');
|
|
1026
|
+
return groupVerb(args, '/api/groups/path', { path: pathArg, transient }, 'Create', (r) => `Created group "${r.path}" (${r.group})${transient ? ', transient' : ''}`);
|
|
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
|
+
async function runGroupListCli(args) {
|
|
1093
|
+
const activeOnly = !args.includes('--all');
|
|
1094
|
+
let rev;
|
|
1095
|
+
let groups;
|
|
1096
|
+
try {
|
|
1097
|
+
({ rev, groups } = await cliGetJson('/api/groups', 'Group list'));
|
|
1098
|
+
const sessionRefs = [...new Set(groups.flatMap((g) => g.members.filter((m) => m.type === 'session').map((m) => m.ref)))];
|
|
1099
|
+
// An inactive session held by id is still listed, so only the active list needs the id lookups.
|
|
1100
|
+
const lists = await Promise.all([
|
|
1101
|
+
fetchSessionsList({ limit: null, activeOnly, lite: true }, 'Group list'),
|
|
1102
|
+
activeOnly ? fetchSessionsByIds(sessionRefs, 'Group list', { lite: true }) : [],
|
|
1103
|
+
]);
|
|
1104
|
+
const sessions = uniqueById(lists.flat());
|
|
1105
|
+
groups = describeGroupMembers(groups, sessions, activeOnly);
|
|
1106
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
1107
|
+
if (args.includes('--json')) {
|
|
1108
|
+
console.log(JSON.stringify({ rev, groups }, null, 2));
|
|
1109
|
+
return 0;
|
|
1110
|
+
}
|
|
1111
|
+
if (!groups.length) {
|
|
1112
|
+
console.log('No groups.');
|
|
1113
|
+
return 0;
|
|
1114
|
+
}
|
|
1115
|
+
const rows = [];
|
|
1116
|
+
const add = (parent, depth) => {
|
|
1117
|
+
const pad = ' '.repeat(depth);
|
|
1118
|
+
for (const g of groups.filter((x) => x.parent === parent)) {
|
|
1119
|
+
rows.push([g.id, '', '', '', `${pad}${g.name}${g.transient ? ' (transient)' : ''} (${g.visible}/${g.total})`]);
|
|
1120
|
+
for (const m of g.members) {
|
|
1121
|
+
if (m.type === 'project') rows.push(['project', '', '', '', `${pad} ${m.ref} (${m.sessions} sessions)`]);
|
|
1122
|
+
else if (m.missing) rows.push([m.ref.slice(0, 8), 'gone', '-', '', `${pad} (no such session)`]);
|
|
1123
|
+
else rows.push([m.ref.slice(0, 8), m.status, m.age, m.pinned || '', `${pad} ${m.title}`]);
|
|
1124
|
+
}
|
|
1125
|
+
add(g.id, depth + 1);
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
add(null, 0);
|
|
1129
|
+
printTable(['ID', 'STATUS', 'AGE', 'PIN', 'NAME'], rows);
|
|
1130
|
+
return 0;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
839
1133
|
async function runSessionViewCli(args) {
|
|
840
1134
|
const idArg = args.find(a => !a.startsWith('--'));
|
|
841
1135
|
if (!idArg) {
|
|
@@ -860,7 +1154,7 @@ async function runSessionViewCli(args) {
|
|
|
860
1154
|
return 0;
|
|
861
1155
|
}
|
|
862
1156
|
const status = sessionStatus(s);
|
|
863
|
-
const title = s
|
|
1157
|
+
const title = sessionTitle(s);
|
|
864
1158
|
const age = s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-';
|
|
865
1159
|
const fmtTok = (n) => typeof n === 'number' ? (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)) : '-';
|
|
866
1160
|
const fmtCost = (n) => typeof n === 'number' ? `$${n.toFixed(2)}` : '-';
|
|
@@ -907,12 +1201,14 @@ function printTable(header, rows) {
|
|
|
907
1201
|
|
|
908
1202
|
const ageOf = (iso) => (iso ? formatAge(Date.now() - new Date(iso).getTime()) : '-');
|
|
909
1203
|
|
|
910
|
-
async function
|
|
1204
|
+
async function cliGet(urlPath, label) {
|
|
911
1205
|
const res = await cliFetch(urlPath);
|
|
912
1206
|
if (!res.ok) throw new Error(`${label} failed (${res.status}): ${await res.text()}`);
|
|
913
|
-
return res
|
|
1207
|
+
return res;
|
|
914
1208
|
}
|
|
915
1209
|
|
|
1210
|
+
const cliGetJson = async (urlPath, label) => (await cliGet(urlPath, label)).json();
|
|
1211
|
+
|
|
916
1212
|
async function runSessionSearchCli(args) {
|
|
917
1213
|
const entry = COMMANDS.session.verbs.search;
|
|
918
1214
|
const text = positionals(args, ['--limit']).join(' ').trim();
|
|
@@ -941,7 +1237,7 @@ async function runSessionSearchCli(args) {
|
|
|
941
1237
|
byId.has(s.id) ? sessionStatus(s) : '-',
|
|
942
1238
|
ageOf(s.modifiedAt),
|
|
943
1239
|
path.basename(s.project || ''),
|
|
944
|
-
s
|
|
1240
|
+
sessionTitle(s),
|
|
945
1241
|
]));
|
|
946
1242
|
return 0;
|
|
947
1243
|
} catch (e) { reportCliError(e); return 1; }
|
|
@@ -1107,10 +1403,7 @@ async function runDispatchStartCli(argv) {
|
|
|
1107
1403
|
}
|
|
1108
1404
|
const hasGroup = args.some(a => a === '--group' || a.startsWith('--group='));
|
|
1109
1405
|
const group = hasGroup ? getArgValue(args, 'group') || '' : null;
|
|
1110
|
-
if (hasGroup && !
|
|
1111
|
-
const hint = suggestGroupName(group);
|
|
1112
|
-
return usageError(COMMANDS.dispatch.verbs.start, `Group names are kebab-case${hint ? `: try --group ${hint}` : ', e.g. auth-refactor'}`);
|
|
1113
|
-
}
|
|
1406
|
+
if (hasGroup && !group.trim()) return usageError(COMMANDS.dispatch.verbs.start, '--group needs a group name, path or id');
|
|
1114
1407
|
const worktree = args.includes('--worktree') ? getArgValue(args, 'worktree') || true : false;
|
|
1115
1408
|
const hasTaskList = args.some(a => a === '--task-list' || a.startsWith('--task-list='));
|
|
1116
1409
|
const ownList = process.env.CLAUDE_CODE_TASK_LIST_ID || process.env.CLAUDE_CODE_SESSION_ID;
|
|
@@ -1133,7 +1426,8 @@ async function runDispatchStartCli(argv) {
|
|
|
1133
1426
|
const out = await cliPostJson('/api/dispatch', body, 'Dispatch', { 'x-terminal-token': token });
|
|
1134
1427
|
if (!out) return 1;
|
|
1135
1428
|
if (args.includes('--json')) console.log(JSON.stringify(out, null, 2));
|
|
1136
|
-
else console.log(`Started session ${out.session} in ${out.cwd}${out.group ? ` [${out.group}]` : ''}`);
|
|
1429
|
+
else console.log(`Started session ${out.session} in ${out.cwd}${out.group ? ` [${out.group}${out.transient ? ', transient' : ''}]` : ''}`);
|
|
1430
|
+
if (out.groupError) console.error(`Not grouped: ${out.groupError}`);
|
|
1137
1431
|
return 0;
|
|
1138
1432
|
} catch (e) { reportCliError(e); return 1; }
|
|
1139
1433
|
}
|
|
@@ -1186,6 +1480,33 @@ async function runDispatchEndCli(args) {
|
|
|
1186
1480
|
} catch (e) { reportCliError(e); return 1; }
|
|
1187
1481
|
}
|
|
1188
1482
|
|
|
1483
|
+
function terminalKind(t, self) {
|
|
1484
|
+
if (t.kanbot) return 'kanbot';
|
|
1485
|
+
if (t.dispatched) return self && t.dispatched.parent === self ? 'dispatch (yours)' : 'dispatch';
|
|
1486
|
+
return t.mode === 'shell' ? 'shell' : 'claude';
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
async function runTerminalListCli(args) {
|
|
1490
|
+
let rows;
|
|
1491
|
+
try {
|
|
1492
|
+
rows = (await cliGetJson('/api/terminals', 'Terminal list')).sessions.sort((a, b) => b.startedAt - a.startedAt);
|
|
1493
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
1494
|
+
if (args.includes('--json')) {
|
|
1495
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
1496
|
+
return 0;
|
|
1497
|
+
}
|
|
1498
|
+
if (!rows.length) {
|
|
1499
|
+
console.log('No terminals.');
|
|
1500
|
+
return 0;
|
|
1501
|
+
}
|
|
1502
|
+
const self = process.env.CLAUDE_CODE_SESSION_ID;
|
|
1503
|
+
printTable(['SESSION', 'AGE', 'KIND', 'NAME', 'CWD'], rows.map(t => [t.id, ageOf(t.startedAt), terminalKind(t, self), t.name || '-', t.cwd || '-']));
|
|
1504
|
+
if (rows.some(t => self && t.dispatched?.parent === self)) {
|
|
1505
|
+
console.log('\nEnd one of yours with `claude-code-kanban dispatch end <session>`. The user ends the others from the board.');
|
|
1506
|
+
}
|
|
1507
|
+
return 0;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1189
1510
|
async function runSkillsGetCli(args) {
|
|
1190
1511
|
const name = args.find(a => !a.startsWith('--'));
|
|
1191
1512
|
const file = name && /^[a-z][a-z-]*$/.test(name) ? path.join(__dirname, 'skill-guides', `${name}.md`) : null;
|
|
@@ -1197,4 +1518,4 @@ async function runSkillsGetCli(args) {
|
|
|
1197
1518
|
return 0;
|
|
1198
1519
|
}
|
|
1199
1520
|
|
|
1200
|
-
module.exports = { runCli, COMMANDS, SERVER_FLAGS };
|
|
1521
|
+
module.exports = { runCli, topHelp, COMMANDS, SERVER_FLAGS };
|