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/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 inspect Claude Code sessions',
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 [--active] [--days <n>] [--project <name>] [--limit <n|all>] [--no-pins] [--json]',
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
- '--active': 'Only sessions with recent activity (sidebar-style filter)',
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: 10). Use "all" for no cap.',
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 --active',
117
- 'claude-code-kanban session list --days 0.5 --limit all --project my-repo',
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 of connected browser tabs',
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 (a known project, default: current dir)',
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 printTopHelp() {
363
- console.log('Usage: claude-code-kanban <command> [args] [--flags]\n');
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
- console.log(` ${name.padEnd(20)}${cmd.summary}${verbs}`);
475
+ lines.push(` ${name.padEnd(20)}${cmd.summary}${verbs}`);
368
476
  }
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):');
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) 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.');
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
- // 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;
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 (!isSessionActive(s)) return 'idle';
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
- async function fetchSessionsList(limit, pinnedIds = [], project = null) {
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 pinnedQ = pinnedIds.length ? `&pinned=${pinnedIds.join(',')}` : '';
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
- return cliGetJson(`/api/sessions?limit=${q}${pinnedQ}${projectQ}`, 'Session list');
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
- function fetchSessionsByIds(ids, label) {
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
- 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 {}; }
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('--active');
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
- const parsed = parseLimit(args, { fallback: 10, allowAll: true });
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
- list = await fetchSessionsList(hasClientFilter ? null : limit, pinnedIds, projectFilter);
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 => pinOf(s.id) || (s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff));
867
+ list = list.filter(s => s.pin || (s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff));
868
+ total = list.length;
750
869
  }
751
- const pinRank = id => pinOf(id) === 'sticky' ? 0 : pinOf(id) === 'pinned' ? 1 : 2;
870
+ const pinRank = s => s.pin === 'sticky' ? 0 : s.pin === 'pinned' ? 1 : 2;
752
871
  list.sort((a, b) => {
753
- const r = pinRank(a.id) - pinRank(b.id);
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 => pinOf(s.id) && !topIds.has(s.id));
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: pinOf(s.id) })), null, 2));
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: pinOf(s.id) || '',
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.customTitle || s.name || s.slug || '',
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', { id: resolved.id, state }, 'Pin')) return 1;
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.customTitle || s.name || s.slug || '';
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 cliGetJson(urlPath, label) {
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.json();
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.customTitle || s.name || s.slug || '',
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 };