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/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 inspect Claude Code sessions',
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 [--active] [--days <n>] [--project <name>] [--limit <n|all>] [--no-pins] [--json]',
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
- '--active': 'Only sessions with recent activity (sidebar-style filter)',
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: 10). Use "all" for no cap.',
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 --active',
117
- 'claude-code-kanban session list --days 0.5 --limit all --project my-repo',
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 of connected browser tabs',
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, top of list)',
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 (a known project, default: current dir)',
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>': 'Show it with this session in a kebab-case group (default: this session\'s group)',
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 printTopHelp() {
363
- console.log('Usage: claude-code-kanban <command> [args] [--flags]\n');
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
- console.log(` ${name.padEnd(20)}${cmd.summary}${verbs}`);
474
+ lines.push(` ${name.padEnd(20)}${cmd.summary}${verbs}`);
368
475
  }
369
- console.log(` ${'help'.padEnd(20)}Show help for a command (claude-code-kanban help <command>)`);
370
- console.log('\nFlags:');
371
- console.log(' --help, -h Show help (top-level, noun-level, or leaf-level)');
372
- console.log(' --version, -v Print version and exit');
373
- console.log('\nServer (no command; a flag wins over its env var):');
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) console.log(` ${flag.padEnd(flagPad)}${env.padEnd(envPad)}${desc}`);
377
- console.log('\nCommand env:');
378
- console.log(' CCK_URL Server base URL, e.g. http://127.0.0.1:4795 (wins over PORT)');
379
- console.log(' PORT Server port (default: the one this config dir\'s server reports, else 3541)');
380
- console.log(' CLAUDE_CONFIG_DIR Claude config dir whose board to use');
381
- console.log('\nRun `claude-code-kanban help <command>` for its subcommands, and `help <command> <subcommand>` for flags and examples.');
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
- // Mirror of `isSessionActive` in public/app.js — keep in sync (different runtimes, no shared module).
657
- function isSessionActive(s) {
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 (!isSessionActive(s)) return 'idle';
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
- async function fetchSessionsList(limit, pinnedIds = [], project = null) {
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 pinnedQ = pinnedIds.length ? `&pinned=${pinnedIds.join(',')}` : '';
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
- return cliGetJson(`/api/sessions?limit=${q}${pinnedQ}${projectQ}`, 'Session list');
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
- function fetchSessionsByIds(ids, label) {
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
- async function fetchPinsMap() {
689
- try {
690
- const res = await cliFetch('/api/session/pins');
691
- if (!res.ok) return {};
692
- const { pins = {} } = await res.json();
693
- return pins;
694
- } catch { return {}; }
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('--active');
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
- const parsed = parseLimit(args, { fallback: 10, allowAll: true });
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
- list = await fetchSessionsList(hasClientFilter ? null : limit, pinnedIds, projectFilter);
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 => pinOf(s.id) || (s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff));
866
+ list = list.filter(s => s.pin || (s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff));
867
+ total = list.length;
750
868
  }
751
- const pinRank = id => pinOf(id) === 'sticky' ? 0 : pinOf(id) === 'pinned' ? 1 : 2;
869
+ const pinRank = s => s.pin === 'sticky' ? 0 : s.pin === 'pinned' ? 1 : 2;
752
870
  list.sort((a, b) => {
753
- const r = pinRank(a.id) - pinRank(b.id);
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 => pinOf(s.id) && !topIds.has(s.id));
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: pinOf(s.id) })), null, 2));
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: pinOf(s.id) || '',
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.customTitle || s.name || s.slug || '',
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', { id: resolved.id, state }, 'Pin')) return 1;
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.customTitle || s.name || s.slug || '';
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 cliGetJson(urlPath, label) {
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.json();
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.customTitle || s.name || s.slug || '',
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 && !isGroupName(group)) {
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 };