claude-code-kanban 7.20.0 → 7.22.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,9 @@ const path = require('node:path');
3
3
  const { getClaudeDir, displayPath } = require('./lib/claude-dir');
4
4
  const { linkUrl } = require('./public/link-url');
5
5
  const { sessionProjectKey } = require('./public/project-match');
6
+ const { MODELS } = require('./lib/terminal');
7
+
8
+ const MODEL_HELP = [...MODELS].join(', ').replace(/, (\w+)$/, ' or $1');
6
9
  // Help is auto-generated from this table — keep flags/usage in sync with `run` behavior.
7
10
  const SESSION_FLAG = '--session <id>';
8
11
  const SESSION_FLAG_HELP = 'Session, full id or unique prefix (default: $PREVIEW_SESSION, else $CLAUDE_CODE_SESSION_ID)';
@@ -98,6 +101,68 @@ const COMMANDS = {
98
101
  },
99
102
  },
100
103
  },
104
+ addon: {
105
+ summary: 'Put web pages on the dock rail beside the embedded terminal, for this session or every session',
106
+ verbs: {
107
+ add: {
108
+ summary: 'Add a URL to the dock rail; the user opens it from the rail',
109
+ usage: 'claude-code-kanban addon add <url> | --manifest <dir> [--title <text>] [--icon <glyph>] [--param <k=v> ...] [--global [--run <cmd> [--cwd <dir>]] [--offline]] [--session <id>] [--json]',
110
+ flags: {
111
+ '<url>': 'An http(s) URL. {sessionId}, {cwd} and {project} are filled for each session, {<name>} from --param',
112
+ '--manifest <dir>': 'Add the add-on in <dir> from its addon.json (url, title, icon, run), as a global entry that runs in <dir>. Its id names the entry, so a repeat add updates it',
113
+ '--offline': 'With --global: write the board\'s addons.json directly, with no board running and no terminal token. A running board picks the change up',
114
+ '--title <text>': 'Rail and header title (default: the host)',
115
+ '--icon <glyph>': 'One or two characters on the rail button (default: the first letter of the title)',
116
+ '--param <k=v>': 'A value for {k} in the URL; repeat for each. `addon set` changes it for one session',
117
+ '--global': 'Show it on the rail of every session. Use only when the user asks for every session',
118
+ '--run <cmd>': 'With --global: the command that starts the page\'s server. The board runs it when the rail opens the page and its localhost port does not answer, with PORT set',
119
+ '--cwd <dir>': 'Absolute folder to run --run in (default: the board\'s folder)',
120
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
121
+ '--json': 'Output JSON (the add-on entry)',
122
+ },
123
+ notes: 'Quote the URL so the shell keeps the braces. Each value is URL-encoded when filled. A placeholder with no value is refused. A session add-on goes when the session expires; a global one stays until `addon rm`. The same URL added again prints the entry it already has; with --run it takes the new command. --run needs the board\'s terminal, like `dispatch start`, unless --offline. The server stops with the board; its output goes to .cck/addons/<id>.log.',
124
+ examples: [
125
+ 'claude-code-kanban addon add --manifest ~/.claude-hub/extensions/git-changes/examples/git-changes --offline',
126
+ 'claude-code-kanban addon add "http://localhost:4100/?cwd={cwd}" --title Changes',
127
+ 'claude-code-kanban addon add "http://localhost:4200/{sessionId}" --title Notes --global',
128
+ 'claude-code-kanban addon add "http://localhost:4117/?cwd={cwd}" --title Changes --global --run "node server.js" --cwd C:/tools/changes',
129
+ ],
130
+ run: runAddonAddCli,
131
+ },
132
+ list: {
133
+ summary: 'List the add-ons on a session\'s rail: the global ones, then the session\'s',
134
+ usage: 'claude-code-kanban addon list [--session <id>] [--json]',
135
+ flags: {
136
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
137
+ '--json': 'Output JSON ({global, session, params: {<id>: {k: v}}})',
138
+ },
139
+ run: runAddonListCli,
140
+ },
141
+ set: {
142
+ summary: 'Set a param of an add-on for one session; an empty value goes back to the add-on\'s own',
143
+ usage: 'claude-code-kanban addon set <id> <k=v> ... [--session <id>]',
144
+ flags: {
145
+ '<id>': 'The id `addon add` or `addon list` printed',
146
+ '<k=v>': 'The new value of {k}; k= with no value drops this session\'s value',
147
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
148
+ },
149
+ examples: ['claude-code-kanban addon set a3 repo=claude-code-hub'],
150
+ run: runAddonSetCli,
151
+ },
152
+ rm: {
153
+ summary: 'Take an add-on off the rail: a global one from every session',
154
+ usage: 'claude-code-kanban addon rm <id> | --key <key> [--offline] [--session <id>]',
155
+ flags: {
156
+ '<id>': 'The id `addon add` or `addon list` printed',
157
+ '--key <key>': 'The global add-on with this key, the id in its addon.json. No such add-on is not an error',
158
+ '--offline': 'Remove a global add-on from the board\'s addons.json directly, with no board running',
159
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
160
+ },
161
+ examples: ['claude-code-kanban addon rm a3', 'claude-code-kanban addon rm --key git-changes --offline'],
162
+ run: runAddonRmCli,
163
+ },
164
+ },
165
+ },
101
166
  session: {
102
167
  summary: 'List, search, open, inspect and group Claude Code sessions',
103
168
  verbs: {
@@ -333,23 +398,24 @@ const COMMANDS = {
333
398
  verbs: {
334
399
  start: {
335
400
  summary: 'Start claude in cck\'s terminal with a task; prints the session id',
336
- 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>...]',
401
+ usage: 'claude-code-kanban dispatch start --cwd <dir> (--spec <text> | --spec-file <path>) (--child | --group <g>) [--name <n>] [--model <m>] [--worktree [name]] [--task-list [id]] [--json] [-- <claude args>...]',
337
402
  flags: {
338
403
  '--cwd <dir>': 'Folder to run in (any existing folder, default: current dir)',
339
404
  '--spec <text>': 'The task, self-contained; sent as the first message',
340
405
  '--spec-file <path>': 'Read the task from a file',
341
406
  '--name <n>': 'Session name; also its peer name for SendMessage',
342
- '--group <g>': 'Add it to this group: a name, path or id. The groups it makes are transient (see `help group list`)',
343
- '--model <m>': 'fable, opus, sonnet or haiku',
407
+ '--child': 'Part of your work: its card shows under yours, wherever yours is. Needs a Claude Code session',
408
+ '--group <g>': 'Separate work: add it to this group, a name, path or id. The groups it makes are transient (see `help group list`)',
409
+ '--model <m>': MODEL_HELP,
344
410
  '--worktree [name]': 'Run in a new git worktree',
345
411
  '--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',
346
412
  '--json': 'Output JSON',
347
413
  '-- <claude args>': 'Passed to claude as they are, e.g. --permission-mode auto. No quotes, % or control characters; cck sets --session-id, --name, --model and --worktree',
348
414
  },
349
- notes: 'Needs the terminal token, so it runs on the machine of the cck server. cck sends no report: say in the spec how the session reports back. Run `claude-code-kanban skills get dispatch` for how to write the spec.',
415
+ notes: 'Pass one of --child or --group. Needs the terminal token, so it runs on the machine of the cck server. cck sends no report: say in the spec how the session reports back. Run `claude-code-kanban skills get dispatch` for how to write the spec.',
350
416
  examples: [
351
- 'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name fix-login-redirect --group auth-refactor --model sonnet -- --permission-mode auto',
352
- 'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name api-worker --task-list',
417
+ 'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name fix-login-redirect --child --model sonnet -- --permission-mode auto',
418
+ 'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name api-worker --group auth-refactor --task-list',
353
419
  ],
354
420
  run: runDispatchStartCli,
355
421
  },
@@ -376,6 +442,24 @@ const COMMANDS = {
376
442
  },
377
443
  },
378
444
  },
445
+ new: {
446
+ summary: 'Start claude in the board\'s terminal from any shell, and show it there',
447
+ usage: 'claude-code-kanban new [prompt...] [--cwd <dir>] [--name <n>] [--model <m>] [--json] [-- <claude args>...]',
448
+ flags: {
449
+ '[prompt...]': 'First message; the words are joined with spaces. None starts an empty session',
450
+ '--cwd <dir>': 'Folder to run in (default: current dir)',
451
+ '--name <n>': 'Session name',
452
+ '--model <m>': MODEL_HELP,
453
+ '--json': 'Output JSON: {session, cwd}',
454
+ '-- <claude args>': 'Passed to claude as they are, e.g. --permission-mode auto',
455
+ },
456
+ notes: 'For the user at a shell, not for agents: agents use `dispatch start`. A board tab with the terminal on must be open, else the session is ended; the tab shows it, and under the hub the hub switches to Kanban. Needs the terminal token, so it runs on the machine of the cck server.',
457
+ examples: [
458
+ 'claude-code-kanban new',
459
+ 'claude-code-kanban new fix the login redirect -- --permission-mode auto',
460
+ ],
461
+ run: runNewCli,
462
+ },
379
463
  terminal: {
380
464
  summary: 'Read the terminals running in cck',
381
465
  verbs: {
@@ -408,8 +492,16 @@ for (const [noun, cmd] of Object.entries(COMMANDS)) {
408
492
  cmd.name = noun;
409
493
  for (const [verb, v] of Object.entries(cmd.verbs || {})) v.name = `${noun} ${verb}`;
410
494
  }
495
+ // Words after `--` belong to claude, so cck's own --help and --version are looked for before it.
496
+ function ownArgs(argv) {
497
+ const sep = argv.indexOf('--');
498
+ return sep === -1 ? argv : argv.slice(0, sep);
499
+ }
500
+ const hasOwnFlag = (a, ...flags) => ownArgs(a).some((x) => flags.includes(x));
501
+ const hasHelp = (a) => hasOwnFlag(a, '--help', '-h');
502
+
411
503
  function runCli(argv) {
412
- if (argv.includes('--version') || argv.includes('-v')) {
504
+ if (hasOwnFlag(argv, '--version', '-v')) {
413
505
  console.log(require('./package.json').version);
414
506
  process.exit(0);
415
507
  }
@@ -435,10 +527,10 @@ function runCli(argv) {
435
527
  }
436
528
  if (cli.kind === 'noun') {
437
529
  printNounHelp(cli.noun);
438
- process.exit(argv.includes('--help') || argv.includes('-h') ? 0 : 1);
530
+ process.exit(hasHelp(argv) ? 0 : 1);
439
531
  }
440
532
  if (cli.kind === 'leaf') {
441
- if (cli.args.includes('--help') || cli.args.includes('-h')) {
533
+ if (hasHelp(cli.args)) {
442
534
  printLeafHelp(cli.entry);
443
535
  process.exit(0);
444
536
  }
@@ -452,7 +544,6 @@ function runCli(argv) {
452
544
 
453
545
  function resolveCliCommand(argv) {
454
546
  const noun = argv[2] && !argv[2].startsWith('-') ? argv[2] : null;
455
- const hasHelp = (a) => a.includes('--help') || a.includes('-h');
456
547
  if (!noun) return hasHelp(argv) ? { kind: 'help' } : { kind: 'server' };
457
548
  if (noun === 'help') return { kind: 'help', target: argv[3], verb: argv[4] };
458
549
  if (!Object.hasOwn(COMMANDS, noun)) return { kind: 'unknown-noun', noun };
@@ -506,8 +597,8 @@ function printTopHelp() {
506
597
 
507
598
  function printNounHelp(noun) {
508
599
  const entry = COMMANDS[noun];
509
- console.log(`${entry.summary}\n`);
510
600
  if (entry.verbs) {
601
+ console.log(`${entry.summary}\n`);
511
602
  console.log(`Usage: claude-code-kanban ${noun} <subcommand> [args] [--flags]\n`);
512
603
  console.log('Subcommands:');
513
604
  for (const [vName, v] of Object.entries(entry.verbs)) {
@@ -678,9 +769,11 @@ async function postDocLink(target, sessionId, { unlink = false, open = false } =
678
769
  } catch (e) { reportCliError(e); return 1; }
679
770
  }
680
771
 
772
+ const sessionArgOf = (args) => getArgValue(args, 'session') || process.env.PREVIEW_SESSION || process.env.CLAUDE_CODE_SESSION_ID;
773
+
681
774
  // Resolved to the full id because the browser keys linked docs by it, so a prefix won't match.
682
775
  async function resolveSessionArg(entry, args, why) {
683
- const sessionArg = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || process.env.CLAUDE_CODE_SESSION_ID;
776
+ const sessionArg = sessionArgOf(args);
684
777
  if (!sessionArg) {
685
778
  usageError(entry, `--session is required: ${why}`);
686
779
  return null;
@@ -763,7 +856,181 @@ async function runPaneListCli(args, entry) {
763
856
  const layout = await cliGetJson(panesPath(resolved.id), 'Pane list');
764
857
  if (args.includes('--json')) console.log(JSON.stringify(layout, null, 2));
765
858
  else if (!layout.panes.length) console.log(`No panes for session ${resolved.id.slice(0, 8)}.`);
766
- else printTable(['ID', 'KIND', 'TITLE', 'TARGET'], layout.panes.map((p) => [p.id, p.kind, p.title, p.target]));
859
+ else printTable(['ID', 'KIND', 'TITLE', 'TARGET'], layout.panes.map((p) => [p.id, p.dock ? `${p.kind} (dock)` : p.kind, p.title, p.target]));
860
+ return 0;
861
+ } catch (e) { reportCliError(e); return 1; }
862
+ }
863
+
864
+ const ADDONS_WHY = 'a session add-on is stored with its session.';
865
+
866
+ // `k=v` words; a repeated `--param` takes one each. Returns null after printing the error.
867
+ function addonParams(entry, words) {
868
+ const params = {};
869
+ for (const w of words) {
870
+ const i = w.indexOf('=');
871
+ if (i < 1) {
872
+ usageError(entry, `Expected k=v, got: ${w}`);
873
+ return null;
874
+ }
875
+ params[w.slice(0, i)] = w.slice(i + 1);
876
+ }
877
+ return params;
878
+ }
879
+
880
+ const argValues = (args, name) =>
881
+ args.flatMap((a, i) => {
882
+ if (a.startsWith(`--${name}=`)) return [a.slice(name.length + 3)];
883
+ return a === `--${name}` && args[i + 1] && !args[i + 1].startsWith('--') ? [args[i + 1]] : [];
884
+ });
885
+
886
+ // A global add-on needs no session; take one only when one is named or set.
887
+ async function optionalSession(entry, args) {
888
+ return sessionArgOf(args) ? resolveSessionArg(entry, args, ADDONS_WHY) : { id: null };
889
+ }
890
+
891
+ const sessionQuery = (resolved) => (resolved.id ? `?session=${encodeURIComponent(resolved.id)}` : '');
892
+
893
+ // The add-on's own manifest (addon.json); the store checks the fields it keeps.
894
+ function readAddonManifest(entry, dir) {
895
+ const file = path.join(dir, 'addon.json');
896
+ let m;
897
+ try {
898
+ m = JSON.parse(fs.readFileSync(file, 'utf8'));
899
+ } catch (e) {
900
+ usageError(entry, `Cannot read ${file}: ${e.code || e.message}`);
901
+ return null;
902
+ }
903
+ if (m?.kind !== 'addon' || typeof m.id !== 'string' || typeof m.url !== 'string') {
904
+ usageError(entry, `${file} is not an add-on manifest: it needs "kind": "addon", an id and a url`);
905
+ return null;
906
+ }
907
+ return m;
908
+ }
909
+
910
+ // With no board: the store writes addons.json itself, and a running board reads the change.
911
+ function offlineAddons() {
912
+ const { createAddonStore } = require('./lib/addons');
913
+ const { stampedJsonFile } = require('./lib/stamped-json-file');
914
+ const { writeJsonAtomic } = require('./lib/write-json-atomic');
915
+ return createAddonStore(stampedJsonFile(path.join(getClaudeDir(), '.cck', 'addons.json'), writeJsonAtomic));
916
+ }
917
+
918
+ async function runAddonAddCli(args, entry) {
919
+ const [urlArg] = positionals(args, ['--session', '--title', '--icon', '--param', '--run', '--cwd', '--manifest']);
920
+ const manifestDir = getArgValue(args, 'manifest');
921
+ if (!urlArg && !manifestDir) {
922
+ printLeafHelp(entry);
923
+ return 1;
924
+ }
925
+ if (urlArg && manifestDir) return usageError(entry, '--manifest takes the URL from addon.json');
926
+ let spec;
927
+ if (manifestDir) {
928
+ if (getArgValue(args, 'run') || getArgValue(args, 'cwd')) return usageError(entry, '--manifest takes run from addon.json and runs it in that folder');
929
+ const dir = path.resolve(manifestDir);
930
+ const m = readAddonManifest(entry, dir);
931
+ if (!m) return 1;
932
+ spec = { url: m.url, run: m.run, cwd: m.run && dir, title: m.title, icon: m.icon, key: m.id, isGlobal: true };
933
+ } else {
934
+ const cwd = getArgValue(args, 'cwd');
935
+ spec = { url: urlArg, run: getArgValue(args, 'run'), cwd: cwd && path.resolve(cwd), isGlobal: args.includes('--global') };
936
+ }
937
+ const { url, run, cwd, key, isGlobal } = spec;
938
+ const params = addonParams(entry, argValues(args, 'param'));
939
+ if (!params) return 1;
940
+ const offline = args.includes('--offline');
941
+ if (offline && !isGlobal) return usageError(entry, '--offline needs --global');
942
+ if (run && !isGlobal) return usageError(entry, '--run needs --global');
943
+ if (cwd && !run) return usageError(entry, '--cwd needs --run');
944
+ const needsToken = run && !offline;
945
+ const token = needsToken ? readTerminalToken() : null;
946
+ if (needsToken && !token) return 1;
947
+ const resolved = isGlobal ? { id: 'global' } : await resolveSessionArg(entry, args, ADDONS_WHY);
948
+ if (!resolved) return 1;
949
+ try {
950
+ const icon = getArgValue(args, 'icon') || spec.icon;
951
+ const title = getArgValue(args, 'title') || spec.title || '';
952
+ const fields = { ...(key && { key }), url, title, ...(icon && { icon }), params, ...(run && { run, ...(cwd && { cwd }) }) };
953
+ const out = offline
954
+ ? offlineAddons().add(resolved.id, fields)
955
+ : await cliPostJson('/api/addons', { scope: resolved.id, ...fields }, 'Add-on add', token ? { 'x-terminal-token': token } : undefined);
956
+ if (!out) return 1;
957
+ if (args.includes('--json')) console.log(JSON.stringify(out.addon, null, 2));
958
+ else console.log(`${out.addon.id} ${out.addon.title} (${isGlobal ? 'every session' : 'this session'})${out.added ? '' : ' (already there)'}`);
959
+ return 0;
960
+ } catch (e) { reportCliError(e); return 1; }
961
+ }
962
+
963
+ async function runAddonListCli(args, entry) {
964
+ const resolved = await optionalSession(entry, args);
965
+ if (!resolved) return 1;
966
+ try {
967
+ const list = await cliGetJson(`/api/addons${sessionQuery(resolved)}`, 'Add-on list');
968
+ if (args.includes('--json')) {
969
+ console.log(JSON.stringify(list, null, 2));
970
+ return 0;
971
+ }
972
+ const rows = [...list.global.map((a) => ['global', a]), ...list.session.map((a) => ['session', a])].map(([scope, a]) => {
973
+ const params = Object.entries({ ...a.params, ...list.params[a.id] }).map(([k, v]) => `${k}=${v}`).join(' ');
974
+ return [a.id, scope, a.title, a.url, params];
975
+ });
976
+ if (!rows.length) console.log('No add-ons on the rail.');
977
+ else printTable(['ID', 'SCOPE', 'TITLE', 'URL', 'PARAMS'], rows);
978
+ return 0;
979
+ } catch (e) { reportCliError(e); return 1; }
980
+ }
981
+
982
+ async function runAddonSetCli(args, entry) {
983
+ const [id, ...words] = positionals(args, ['--session']);
984
+ if (!id || !words.length) {
985
+ printLeafHelp(entry);
986
+ return 1;
987
+ }
988
+ const params = addonParams(entry, words);
989
+ if (!params) return 1;
990
+ const resolved = await resolveSessionArg(entry, args, 'a param value is set per session.');
991
+ if (!resolved) return 1;
992
+ try {
993
+ if (!await cliSendJson('PATCH', `/api/addons/${encodeURIComponent(id)}`, { session: resolved.id, params }, 'Add-on set')) return 1;
994
+ console.log(`Set ${Object.keys(params).join(', ')} of ${id} for session ${resolved.id.slice(0, 8)}`);
995
+ return 0;
996
+ } catch (e) { reportCliError(e); return 1; }
997
+ }
998
+
999
+ async function runAddonRmCli(args, entry) {
1000
+ const [idArg] = positionals(args, ['--session', '--key']);
1001
+ const key = getArgValue(args, 'key');
1002
+ if (!idArg && !key) {
1003
+ printLeafHelp(entry);
1004
+ return 1;
1005
+ }
1006
+ if (idArg && key) return usageError(entry, 'Pass an id or --key, not both');
1007
+ const offline = args.includes('--offline');
1008
+ const resolved = offline ? {} : await optionalSession(entry, args);
1009
+ if (!resolved) return 1;
1010
+ try {
1011
+ const store = offline ? offlineAddons() : null;
1012
+ const global = key && (store ? store.list(null) : await cliGetJson('/api/addons', 'Add-on list')).global;
1013
+ const id = idArg ?? global.find((a) => a.key === key)?.id;
1014
+ if (!id) {
1015
+ console.log(`No add-on has the key ${key}`);
1016
+ return 0;
1017
+ }
1018
+ if (store) {
1019
+ if (!store.remove(null, id)) {
1020
+ console.error(`No global add-on ${id}`);
1021
+ return 1;
1022
+ }
1023
+ console.log(`Removed add-on ${id} from every session`);
1024
+ return 0;
1025
+ }
1026
+ const out = await cliSendJson(
1027
+ 'DELETE',
1028
+ `/api/addons/${encodeURIComponent(id)}${sessionQuery(resolved)}`,
1029
+ undefined,
1030
+ 'Add-on rm',
1031
+ );
1032
+ if (!out) return 1;
1033
+ console.log(`Removed add-on ${id}${out.scope === 'global' ? ' from every session' : ''}`);
767
1034
  return 0;
768
1035
  } catch (e) { reportCliError(e); return 1; }
769
1036
  }
@@ -1426,10 +1693,28 @@ function readTerminalToken() {
1426
1693
  return token;
1427
1694
  }
1428
1695
 
1696
+ function splitClaudeArgs(argv) {
1697
+ const args = ownArgs(argv);
1698
+ return { args, claudeArgs: argv.slice(args.length + 1) };
1699
+ }
1700
+
1701
+ // A help key is `--name`, `--name <value>` or `--name [value]`; anything else is a positional
1702
+ // or the `--` separator. test/cli-flags.test.js checks every key in COMMANDS against this.
1703
+ const FLAG_KEY = /^(--[a-z][\w-]*)(?: (<[^>]+>|\[[^\]]+\]))?$/;
1704
+ function flagSpec(entry) {
1705
+ const names = [];
1706
+ const valueFlags = [];
1707
+ for (const key of Object.keys(entry.flags || {})) {
1708
+ const m = key.match(FLAG_KEY);
1709
+ if (!m) continue;
1710
+ names.push(m[1]);
1711
+ if (m[2]?.startsWith('<')) valueFlags.push(m[1]);
1712
+ }
1713
+ return { names, valueFlags };
1714
+ }
1715
+
1429
1716
  async function runDispatchStartCli(argv) {
1430
- const sep = argv.indexOf('--');
1431
- const args = sep === -1 ? argv : argv.slice(0, sep);
1432
- const claudeArgs = sep === -1 ? [] : argv.slice(sep + 1);
1717
+ const { args, claudeArgs } = splitClaudeArgs(argv);
1433
1718
  const token = readTerminalToken();
1434
1719
  if (!token) return 1;
1435
1720
  let spec;
@@ -1441,6 +1726,10 @@ async function runDispatchStartCli(argv) {
1441
1726
  const hasGroup = args.some(a => a === '--group' || a.startsWith('--group='));
1442
1727
  const group = hasGroup ? getArgValue(args, 'group') || '' : null;
1443
1728
  if (hasGroup && !group.trim()) return usageError(COMMANDS.dispatch.verbs.start, '--group needs a group name, path or id');
1729
+ const child = args.includes('--child');
1730
+ if (child === hasGroup) return usageError(COMMANDS.dispatch.verbs.start, 'Pass one of --child (part of your work) or --group <g> (separate work)');
1731
+ const parent = process.env.CLAUDE_CODE_SESSION_ID || null;
1732
+ if (child && !parent) return usageError(COMMANDS.dispatch.verbs.start, '--child runs only inside a Claude Code session; pass --group <g>');
1444
1733
  const worktree = args.includes('--worktree') ? getArgValue(args, 'worktree') || true : false;
1445
1734
  const hasTaskList = args.some(a => a === '--task-list' || a.startsWith('--task-list='));
1446
1735
  const ownList = process.env.CLAUDE_CODE_TASK_LIST_ID || process.env.CLAUDE_CODE_SESSION_ID;
@@ -1456,19 +1745,45 @@ async function runDispatchStartCli(argv) {
1456
1745
  worktree,
1457
1746
  taskList,
1458
1747
  group,
1748
+ child,
1459
1749
  claudeArgs,
1460
- parent: process.env.CLAUDE_CODE_SESSION_ID || null,
1750
+ parent,
1461
1751
  };
1462
1752
  try {
1463
1753
  const out = await cliPostJson('/api/dispatch', body, 'Dispatch', { 'x-terminal-token': token });
1464
1754
  if (!out) return 1;
1465
1755
  if (args.includes('--json')) console.log(JSON.stringify(out, null, 2));
1466
- else console.log(`Started session ${out.session} in ${out.cwd}${out.group ? ` [${out.group}${out.transient ? ', transient' : ''}]` : ''}`);
1756
+ else console.log(`Started session ${out.session} in ${out.cwd}${child ? ' [child]' : ''}${out.group ? ` [${out.group}${out.transient ? ', transient' : ''}]` : ''}`);
1467
1757
  if (out.groupError) console.error(`Not grouped: ${out.groupError}`);
1468
1758
  return 0;
1469
1759
  } catch (e) { reportCliError(e); return 1; }
1470
1760
  }
1471
1761
 
1762
+ async function runNewCli(argv) {
1763
+ const { args, claudeArgs } = splitClaudeArgs(argv);
1764
+ const { names, valueFlags } = flagSpec(COMMANDS.new);
1765
+ const unknown = args.find(a => a.startsWith('--') && !names.includes(a.split('=')[0]));
1766
+ if (unknown) return usageError(COMMANDS.new, `Unknown flag ${unknown}; put claude's own flags after --`);
1767
+ const words = positionals(args, valueFlags);
1768
+ const token = readTerminalToken();
1769
+ if (!token) return 1;
1770
+ const body = {
1771
+ cwd: canonicalDir(getArgValue(args, 'cwd') || '.'),
1772
+ prompt: words.join(' '),
1773
+ name: getArgValue(args, 'name'),
1774
+ model: getArgValue(args, 'model'),
1775
+ claudeArgs,
1776
+ show: true,
1777
+ };
1778
+ try {
1779
+ const out = await cliPostJson('/api/terminal/start', body, 'Start', { 'x-terminal-token': token });
1780
+ if (!out) return 1;
1781
+ if (args.includes('--json')) console.log(JSON.stringify(out, null, 2));
1782
+ else console.log(`Started session ${out.session} in ${out.cwd}; it shows on the board.`);
1783
+ return 0;
1784
+ } catch (e) { reportCliError(e); return 1; }
1785
+ }
1786
+
1472
1787
  async function fetchDispatches(all) {
1473
1788
  const q = new URLSearchParams();
1474
1789
  if (!all && process.env.CLAUDE_CODE_SESSION_ID) q.set('parent', process.env.CLAUDE_CODE_SESSION_ID);
@@ -1555,4 +1870,4 @@ async function runSkillsGetCli(args) {
1555
1870
  return 0;
1556
1871
  }
1557
1872
 
1558
- module.exports = { runCli, topHelp, COMMANDS, SERVER_FLAGS, AGENT_ID_RE };
1873
+ module.exports = { runCli, topHelp, COMMANDS, SERVER_FLAGS, AGENT_ID_RE, FLAG_KEY, flagSpec, splitClaudeArgs };
package/hub-app.json CHANGED
@@ -5,6 +5,11 @@
5
5
  "icon": "columns",
6
6
  "run": { "entry": "server.js", "defaultPort": 3541 },
7
7
  "publishes": ["session.changed"],
8
+ "actions": {
9
+ "handles": {
10
+ "session.open": { "params": { "session": "string" }, "mode": "message" }
11
+ }
12
+ },
8
13
  "settings": [
9
14
  {
10
15
  "key": "zenCentered",
@@ -38,6 +43,7 @@
38
43
  "projects": { "path": "/api/projects" },
39
44
  "terminal": { "liveWork": "/api/terminals" }
40
45
  },
46
+ "launcher": { "items": "/api/hub/launcher-items" },
41
47
  "loading": {
42
48
  "verbs": [
43
49
  "Shuffling cards…",
@@ -0,0 +1,177 @@
1
+ // Starts a global add-on's own server (`run` in its entry) the first time the board opens it and
2
+ // the URL's port does not answer. The process is cck's child, so it ends with the board; under
3
+ // the hub, `taskkill /f /t` on cck takes it too. A second board on the config dir finds the
4
+ // port answering and starts nothing.
5
+
6
+ const fs = require('node:fs');
7
+ const net = require('node:net');
8
+ const path = require('node:path');
9
+ const { spawn, spawnSync } = require('node:child_process');
10
+ const { sampleUrl } = require('./addons');
11
+ const { portOf } = require('./panes');
12
+
13
+ const MAX_STARTS = 3;
14
+ const STARTS_WINDOW_MS = 60_000;
15
+ const READY_MS = 20_000;
16
+ const POLL_MS = 300;
17
+
18
+ function urlPort(url) {
19
+ const u = new URL(url);
20
+ return { host: u.hostname.replace(/^\[|\]$/g, ''), port: portOf(u) };
21
+ }
22
+
23
+ const targetOf = (entry) => urlPort(sampleUrl(entry.url));
24
+
25
+ const exited = (child) => child.exitCode !== null || child.signalCode !== null;
26
+
27
+ function portAnswers({ host, port }, timeoutMs = 500) {
28
+ return new Promise((resolve) => {
29
+ const socket = net.connect({ host, port });
30
+ const done = (ok) => {
31
+ socket.destroy();
32
+ resolve(ok);
33
+ };
34
+ socket.setTimeout(timeoutMs, () => done(false));
35
+ socket.once('connect', () => done(true));
36
+ socket.once('error', () => done(false));
37
+ });
38
+ }
39
+
40
+ // Sync only on exit, where nothing async runs; else taskkill would hold the event loop for its run.
41
+ function killTree(child, sync = false) {
42
+ if (exited(child)) return;
43
+ if (process.platform !== 'win32') {
44
+ // The shell's own group: SIGTERM to the shell alone leaves the server it started running.
45
+ try {
46
+ process.kill(-child.pid, 'SIGTERM');
47
+ } catch {
48
+ child.kill('SIGTERM');
49
+ }
50
+ return;
51
+ }
52
+ const args = ['/f', '/t', '/pid', String(child.pid)];
53
+ const opts = { windowsHide: true, stdio: 'ignore' };
54
+ if (sync) spawnSync('taskkill', args, opts);
55
+ else spawn('taskkill', args, opts).once('error', () => {});
56
+ }
57
+
58
+ /**
59
+ * @param {object} o
60
+ * @param {string} o.logDir one `<id>.log` per add-on, rewritten on each start
61
+ * @param {(target: {host: string, port: number}) => Promise<boolean>} [o.probe]
62
+ */
63
+ function createAddonRunner({ logDir, probe = portAnswers, now = Date.now }) {
64
+ const byId = new Map();
65
+ const logPath = (id) => path.join(logDir, `${id}.log`);
66
+
67
+ async function waitReady(target, child) {
68
+ const until = now() + READY_MS;
69
+ while (now() < until) {
70
+ if (exited(child)) return false;
71
+ if (await probe(target)) return true;
72
+ await new Promise((r) => setTimeout(r, POLL_MS));
73
+ }
74
+ return false;
75
+ }
76
+
77
+ async function start(entry, target, state) {
78
+ const t = now();
79
+ state.starts = state.starts.filter((s) => t - s < STARTS_WINDOW_MS);
80
+ if (state.starts.length >= MAX_STARTS) {
81
+ return { state: 'failed', error: `Started ${MAX_STARTS} times in a minute; see the log`, log: logPath(entry.id) };
82
+ }
83
+ state.starts.push(t);
84
+ fs.mkdirSync(logDir, { recursive: true });
85
+ const fd = fs.openSync(logPath(entry.id), 'w');
86
+ let child;
87
+ try {
88
+ child = spawn(entry.run, {
89
+ shell: true,
90
+ detached: process.platform !== 'win32',
91
+ cwd: entry.cwd || undefined,
92
+ env: { ...process.env, PORT: String(target.port) },
93
+ stdio: ['ignore', fd, fd],
94
+ windowsHide: true,
95
+ });
96
+ } catch (e) {
97
+ return { state: 'failed', error: e.message, log: logPath(entry.id) };
98
+ } finally {
99
+ fs.closeSync(fd);
100
+ }
101
+ child.once('error', () => {});
102
+ state.child = child;
103
+ state.started = entry;
104
+ if (await waitReady(target, child)) return { state: 'up' };
105
+ const gone = exited(child);
106
+ killTree(child);
107
+ return { state: 'failed', error: gone ? `Exited with code ${child.exitCode}` : `No answer on port ${target.port} in ${READY_MS / 1000} s`, log: logPath(entry.id) };
108
+ }
109
+
110
+ // One start at a time per add-on: a second open while it starts waits for the same answer.
111
+ async function ensure(entry) {
112
+ if (!entry.run) return { state: 'none' };
113
+ const target = targetOf(entry);
114
+ if (!byId.has(entry.id)) byId.set(entry.id, { starts: [], child: null, pending: null });
115
+ const state = byId.get(entry.id);
116
+ if (state.pending) return state.pending;
117
+ state.pending = probe(target)
118
+ .then((up) => (up ? { state: 'up' } : start(entry, target, state)))
119
+ .finally(() => {
120
+ state.pending = null;
121
+ });
122
+ return state.pending;
123
+ }
124
+
125
+ function ownChild(id) {
126
+ const child = byId.get(id)?.child;
127
+ return child && !exited(child) ? child : null;
128
+ }
129
+
130
+ // 'running' (this board's child, or `external`: another process answers the port) or 'stopped'.
131
+ async function status(entry) {
132
+ if (ownChild(entry.id)) return 'running';
133
+ return (await probe(targetOf(entry))) ? 'external' : 'stopped';
134
+ }
135
+
136
+ // Kills this board's child and starts it again. A server this board did not start is left alone.
137
+ async function restart(entry) {
138
+ const child = ownChild(entry.id);
139
+ if (child) {
140
+ await new Promise((resolve) => {
141
+ child.once('exit', resolve);
142
+ killTree(child);
143
+ });
144
+ } else if ((await status(entry)) === 'external') {
145
+ return { state: 'external', error: 'Its server was not started by this board, so the board cannot restart it' };
146
+ }
147
+ const target = targetOf(entry);
148
+ const until = now() + READY_MS;
149
+ while (now() < until && (await probe(target))) await new Promise((r) => setTimeout(r, POLL_MS));
150
+ return ensure(entry);
151
+ }
152
+
153
+ function stop(id) {
154
+ const state = byId.get(id);
155
+ if (state?.child) killTree(state.child);
156
+ byId.delete(id);
157
+ fs.rmSync(logPath(id), { force: true });
158
+ }
159
+
160
+ // After another process changed addons.json: stops each server whose entry is gone or now starts
161
+ // another way, so a removed add-on lets go of its folder and the next open runs the new command.
162
+ function retain(entries) {
163
+ for (const [id, state] of byId) {
164
+ const e = entries.find((x) => x.id === id);
165
+ const was = state.started;
166
+ if (!e || (was && (e.run !== was.run || e.cwd !== was.cwd || e.url !== was.url))) stop(id);
167
+ }
168
+ }
169
+
170
+ function shutdown() {
171
+ for (const state of byId.values()) if (state.child) killTree(state.child, true);
172
+ }
173
+
174
+ return { ensure, status, restart, stop, retain, shutdown };
175
+ }
176
+
177
+ module.exports = { createAddonRunner, urlPort };