claude-code-kanban 4.27.0 → 4.28.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,92 +4,116 @@
4
4
  [![license](https://img.shields.io/npm/l/claude-code-kanban)](LICENSE)
5
5
  [![npm downloads](https://img.shields.io/npm/dm/claude-code-kanban)](https://www.npmjs.com/package/claude-code-kanban)
6
6
 
7
- **[Live Demo & Docs](https://nikiforovall.blog/claude-code-kanban/)**
7
+ Start, watch and answer Claude Code sessions from one live board, with a terminal built in.
8
8
 
9
- > Watch Claude Code work, in real time.
9
+ **[Documentation](https://nikiforovall.blog/claude-code-kanban/)**
10
10
 
11
- ![Kanban board with session log](assets/shot-session-log.png)
11
+ <picture>
12
+ <source media="(prefers-color-scheme: dark)" srcset="website/public/shots/themes/ember-board-dark.webp">
13
+ <img alt="The board: session sidebar on the left, Pending and In Progress task columns, the agents log below, and the session log on the right" src="website/public/shots/themes/ember-board-light.webp">
14
+ </picture>
12
15
 
13
- ## Getting Started
16
+ ## Getting started
14
17
 
15
- ### 1. Install hooks (one-time setup)
18
+ You need Node.js 20 or later, the `claude` CLI, and `jq` for the hook scripts.
16
19
 
17
- Hooks enable subagent tracking, waiting-for-user detection, and session activity indicators. **Without hooks, you only see tasks — no agent log, no live indicators.**
20
+ ### 1. Install the integration (one time)
18
21
 
19
22
  ```bash
20
23
  npx claude-code-kanban --install
21
24
  ```
22
25
 
23
- Non-destructive — existing settings in `~/.claude/settings.json` are preserved. Uninstall anytime with `npx claude-code-kanban --uninstall`.
26
+ The installer adds a Claude Code plugin with hooks and skills, and a statusLine script for context use and cost. It asks before each change and keeps your other settings. Without the hooks, the board shows tasks only: no agent log, no live activity, no waiting prompts.
24
27
 
25
- Using another Claude config dir? Pass `--dir=<path>` (or set `CLAUDE_CONFIG_DIR`) to both `--install` and `--uninstall`. The plugin, hooks and statusLine land in that dir, and the hooks write their data under it when Claude Code runs with the same `CLAUDE_CONFIG_DIR`.
28
+ To remove it, run `npx claude-code-kanban --uninstall`. For another Claude config dir, pass the same `--dir=<path>` (or set `CLAUDE_CONFIG_DIR`) to `--install`, `--uninstall` and the server. See [Getting started](https://nikiforovall.blog/claude-code-kanban/getting-started/) for each install step.
26
29
 
27
- ### 2. Start the dashboard
30
+ ### 2. Start the board
28
31
 
29
32
  ```bash
30
33
  npx claude-code-kanban --open
31
34
  ```
32
35
 
36
+ The board runs at `http://localhost:3541`. To install the command globally, run `npm install -g claude-code-kanban`, then `claude-code-kanban --open`.
37
+
33
38
  ### 3. Use Claude Code as usual
34
39
 
35
- Tasks, agents, and messages appear on the board automatically — Claude Code writes task files and conversation logs to `~/.claude`, the dashboard watches them and streams updates to the browser via SSE. Moving a card is the one thing that flows the other way: the board notifies the owning session with the card subject and description, so the agent can act on it.
40
+ Run `claude` in any project. You do not configure anything per project. Claude Code writes task files and transcripts to the config dir, and the board watches them and sends each change to the browser.
36
41
 
37
- > **Empty board?** Claude Code ships the task tools off by default on some models — currently Opus 5, Fable 5 — so nothing writes task files and the board stays empty. Turn them on in `~/.claude/settings.json`:
42
+ > **Empty board?** Claude Code ships the task tools off by default on some models, so Claude writes no tasks. Turn them on in the `env` block of Claude Code `settings.json`, then restart Claude Code:
38
43
  >
39
44
  > ```json
40
45
  > { "env": { "CLAUDE_CODE_ENABLE_TODO_TOOLS": "true" } }
41
46
  > ```
42
47
  >
43
- > Then restart Claude Code. You can also add a task by hand from the board's Pending column.
48
+ > You can also add a task by hand with **Add task** in the Pending column.
44
49
 
45
50
  ## Features
46
51
 
47
- - **Real-time Kanban board** — Tasks move through Pending → In Progress → Completed as Claude works
48
- - **Session log** — The full conversation timeline: prompts, replies, tool calls and results (`Shift+L`)
49
- - **Agent log** — Live subagent tracking with prompts, duration, status, and idle detection
50
- - **Task detail panel** — Full description, notes, blockedBy/blocks dependencies, inline editing
51
- - **Follow & pin** — Follow the latest message live (`Shift+M`), pin the messages that matter
52
- - **Tool stats & impact** — Per-session tool usage breakdown and file impact
53
- - **Waiting-for-user indicators** — Amber highlight on sessions needing permission or input
54
- - **UI approvals** — Allow/deny permission asks and answer questions from the board; on by default, opt out per config dir — [docs](docs/ui-approvals.md)
55
- - **Agent teams** — Color-coded team members, owner filtering, member count badges
56
- - **17 color themes** — Dracula, Nord, Catppuccin, Gruvbox, Tokyo Night, and more — each in light and dark
57
- - **Storage manager** — Inspect disk usage and clean up stale sessions and tasks
58
- - **Session picker** — Jump to any session in the sidebar with `Shift+P`, filtering by name, project or branch
59
- - **Keyboard-first** — Press `?` for the full shortcut reference
52
+ ### Watch
60
53
 
61
- ![Session info](assets/shot-session-info.png)
54
+ - **Live board.** Tasks move through Pending, In Progress and Completed as Claude works. The task panel shows what a task waits on and what it blocks, and you can edit the title and description in place. [Sessions and the board](https://nikiforovall.blog/claude-code-kanban/guides/sessions-and-board/)
55
+ - **Session log.** Every prompt, reply and tool call in order (<kbd>Shift</kbd>+<kbd>L</kbd>). Follow the newest message with <kbd>Shift</kbd>+<kbd>M</kbd>, and pin the messages that matter. [Session log and details](https://nikiforovall.blog/claude-code-kanban/guides/session-details/)
56
+ - **Subagents.** The agents log lists each subagent with its model, status and run time. Open one to read its prompt and response. Team sessions get colored owner badges and an owner filter. [Subagents](https://nikiforovall.blog/claude-code-kanban/guides/subagents/)
57
+ - **Session info and tool stats.** Model, branch, context window use, cost, and which tools ran. The sidebar footer shows your 5-hour and 7-day rate limit use.
58
+ - **Zen mode.** <kbd>Shift</kbd>+<kbd>Z</kbd> shows only the current session in the sidebar, with its context use, scratchpad folder and linked documents.
62
59
 
63
- ![Subagent preview](assets/shot-subagent-preview.png)
60
+ <picture>
61
+ <source media="(prefers-color-scheme: dark)" srcset="website/public/shots/themes/ember-02-subagent-preview-dark.webp">
62
+ <img alt="The Agent modal open on the prompt of an Explore subagent, with id, status, tokens, tools and model chips, above the agents log" src="website/public/shots/themes/ember-02-subagent-preview-light.webp">
63
+ </picture>
64
64
 
65
- ![Theme picker](assets/shot-theme-picker.png)
65
+ ### Drive
66
66
 
67
+ - **Answer prompts from the board.** When Claude asks for permission, asks a question or waits for plan approval, the session gets an amber highlight and the ask shows with Allow and Deny buttons or an answer form. The terminal prompt stays open, and the first answer wins. [Answer prompts from the board](https://nikiforovall.blog/claude-code-kanban/guides/waiting-prompts/)
68
+ - **Embedded terminal.** Run a real Claude Code process for any session next to its board (<kbd>Ctrl</kbd>+<kbd>&#96;</kbd>). <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>R</kbd> resumes a past session and <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>S</kbd> swaps to the previous one. The terminal is off by default when the board runs alone. Start it with `--enable-terminal` and open the `#t=<token>` link the server prints. [Embedded terminal](https://nikiforovall.blog/claude-code-kanban/guides/embedded-terminal/)
69
+ - **New session.** <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>N</kbd> opens a dialog to pick a folder, a name, a model, an optional git worktree and a first prompt. Needs the terminal.
70
+ - **Dispatch.** Hand a written task to a new session with `claude-code-kanban dispatch start`, or ask Claude to do it with the `kanban-dispatch` skill. Add `--report` to get the outcome back. Needs the terminal. [Dispatch tasks to other sessions](https://nikiforovall.blog/claude-code-kanban/guides/dispatch/)
71
+ - **Steer with card moves.** Run `/claude-code-kanban:kanban-follow` in a session, then drag its cards. Claude starts, parks or stops the task. [Claude Code plugin skills](https://nikiforovall.blog/claude-code-kanban/guides/plugin-skills/)
67
72
 
68
- ## Context Window Monitoring
73
+ <picture>
74
+ <source media="(prefers-color-scheme: dark)" srcset="website/public/shots/themes/ember-11-waiting-prompt-dark.webp">
75
+ <img alt="An Awaiting permission: Bash dialog with the command and Allow and Deny buttons, over a session log that ends with the same waiting ask" src="website/public/shots/themes/ember-11-waiting-prompt-light.webp">
76
+ </picture>
69
77
 
70
- Per-session context usage bars, token/cost breakdowns, and model info in the sidebar and detail panel. The installer copies `context-status.sh` — wire it into your statusline in `~/.claude/settings.json`:
78
+ <picture>
79
+ <source media="(prefers-color-scheme: dark)" srcset="website/public/shots/themes/ember-terminal-dark.webp">
80
+ <img alt="Zen mode with the embedded terminal: one session card and its context use and cost in the sidebar, and Claude Code running in the terminal" src="website/public/shots/themes/ember-terminal-light.webp">
81
+ </picture>
71
82
 
72
- ```json
73
- {
74
- "statusLine": {
75
- "type": "command",
76
- "command": "~/.claude/hooks/context-status.sh | npx -y ccstatusline@latest",
77
- "padding": 0
78
- }
79
- }
80
- ```
83
+ ### Organize
84
+
85
+ - **Session groups.** Drag sessions and projects from different folders into one named group in the sidebar. [Session groups](https://nikiforovall.blog/claude-code-kanban/guides/session-groups/)
86
+ - **Filters, search and pins.** Filter by project and activity, search across sessions and tasks, pin a session or make it sticky. <kbd>Shift</kbd>+<kbd>P</kbd> opens the session picker.
87
+ - **Scratchpad and linked documents.** Keep a note per session, project or group (<kbd>N</kbd>), and link files to a session to preview them in one click.
88
+ - **Themes.** 17 color themes, each in light and dark. <kbd>T</kbd> switches the mode.
89
+ - **Keyboard first.** Arrow keys or <kbd>H</kbd> <kbd>J</kbd> <kbd>K</kbd> <kbd>L</kbd> move through tasks, and <kbd>Tab</kbd> moves between the sidebar and the board. Press <kbd>?</kbd> for the full list. [Keyboard shortcuts](https://nikiforovall.blog/claude-code-kanban/reference/keyboard-shortcuts/)
90
+
91
+ ## CLI
81
92
 
82
- The script pipes through, so your existing statusline keeps working.
93
+ With no subcommand, `claude-code-kanban` starts the server. Subcommands talk to a server that already runs:
94
+
95
+ - `session list|open|view|pin|pins|peek` to read and focus sessions.
96
+ - `preview-doc` and `link-doc` to show or link a file on the board.
97
+ - `dispatch start|done|wait|list` to start sessions with a task and collect their reports.
98
+
99
+ Run `claude-code-kanban --help` or see the [CLI reference](https://nikiforovall.blog/claude-code-kanban/reference/cli/).
83
100
 
84
101
  ## Configuration
85
102
 
86
103
  ```bash
87
- PORT=8080 npx claude-code-kanban # Custom port (falls back if busy)
88
- npx claude-code-kanban --open # Auto-open browser
89
- npx claude-code-kanban --dir=~/.claude-work # Custom Claude config dir (or CLAUDE_CONFIG_DIR)
104
+ PORT=8080 npx claude-code-kanban # Custom port. If it is busy, the server uses a random free port.
105
+ npx claude-code-kanban --dir=~/.claude-work # Another Claude config dir (or CLAUDE_CONFIG_DIR)
106
+ npx claude-code-kanban --enable-terminal # Turn on the embedded terminal
107
+ EDITOR="code -w" npx claude-code-kanban # Command for Open in editor (default: code)
90
108
  ```
91
109
 
92
- Global install: `npm install -g claude-code-kanban`, then `claude-code-kanban --open`.
110
+ - The server listens on `127.0.0.1` only and has no authentication. To reach it from another machine, use `--host` and `--allowed-hosts`, and do it only on a network you trust.
111
+ - UI approvals are on by default. Turn them off or tune them in `<config-dir>/.cck/config.json`.
112
+ - Terminal settings, such as the shell, font size and scrollback, go in the `CCK_TERMINAL` JSON variable.
113
+
114
+ See [Configuration](https://nikiforovall.blog/claude-code-kanban/reference/configuration/) for every setting, and [Troubleshooting](https://nikiforovall.blog/claude-code-kanban/troubleshooting/) for common problems.
115
+
116
+ Claude Code Kanban also runs as a tab in [Claude Code Hub](https://nikiforovall.blog/claude-code-kanban/guides/claude-code-hub/), where the terminal is on by default.
93
117
 
94
118
  ## License
95
119
 
package/cli.js CHANGED
@@ -1,4 +1,7 @@
1
+ const fs = require('fs');
1
2
  const path = require('path');
3
+ const { getClaudeDir, displayPath } = require('./lib/claude-dir');
4
+ const { isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
2
5
 
3
6
  // Help is auto-generated from this table — keep flags/usage in sync with `run` behavior.
4
7
  const COMMANDS = {
@@ -83,6 +86,70 @@ const COMMANDS = {
83
86
  },
84
87
  },
85
88
  },
89
+ dispatch: {
90
+ summary: 'Start a Claude Code session for a task in cck and collect its report',
91
+ verbs: {
92
+ start: {
93
+ summary: 'Start a session with a task; prints the dispatch id',
94
+ usage: 'claude-code-kanban dispatch start --cwd <dir> (--spec <text> | --spec-file <path>) [--name <n>] [--group <g>] [--report] [--peer <name>] [--model <m>] [--worktree [name]] [--json]',
95
+ flags: {
96
+ '--cwd <dir>': 'Folder to run in (a known project, default: current dir)',
97
+ '--spec <text>': 'The task, self-contained',
98
+ '--spec-file <path>': 'Read the task from a file',
99
+ '--name <n>': 'Session name',
100
+ '--group <g>': 'Show it with this session in a kebab-case group (default: this session\'s group)',
101
+ '--report': 'Ask it to report its outcome back to this session',
102
+ '--peer <name>': 'This session\'s peer name; it sends questions and findings there with SendMessage',
103
+ '--model <m>': 'fable, opus, sonnet or haiku',
104
+ '--worktree [name]': 'Run in a new git worktree',
105
+ '--json': 'Output JSON',
106
+ },
107
+ run: runDispatchStartCli,
108
+ },
109
+ done: {
110
+ summary: 'Report the outcome of a dispatch (run by the started session)',
111
+ usage: 'claude-code-kanban dispatch done <id> --cap <cap> --outcome succeeded|failed (--summary <text> | --summary-file <path>)',
112
+ flags: {
113
+ '<id>': 'Dispatch id from the preamble',
114
+ '--cap <cap>': 'Capability from the preamble',
115
+ '--outcome <o>': 'succeeded or failed',
116
+ '--summary <text>': 'What changed, what was found, what remains',
117
+ '--summary-file <path>': 'Read the summary from a file',
118
+ },
119
+ run: runDispatchDoneCli,
120
+ },
121
+ wait: {
122
+ summary: 'Wait until a dispatch settles; a timeout is a checkpoint, not a failure',
123
+ usage: 'claude-code-kanban dispatch wait [<id>...] [--timeout <dur>] [--json]',
124
+ flags: {
125
+ '<id>': 'Dispatches to wait on (default: all started by this session)',
126
+ '--timeout <dur>': 'How long to wait, e.g. 90s, 15m, 1h (default: 10m)',
127
+ '--json': 'Output JSON',
128
+ },
129
+ run: runDispatchWaitCli,
130
+ },
131
+ list: {
132
+ summary: 'List dispatches started by this session',
133
+ usage: 'claude-code-kanban dispatch list [--all] [--json]',
134
+ flags: {
135
+ '--all': 'Every dispatch on this board',
136
+ '--json': 'Output JSON',
137
+ },
138
+ run: runDispatchListCli,
139
+ },
140
+ },
141
+ },
142
+ skills: {
143
+ summary: 'Print a skill guide bundled with this version',
144
+ verbs: {
145
+ get: {
146
+ summary: 'Print the guide for a skill',
147
+ usage: 'claude-code-kanban skills get <name>',
148
+ flags: { '<name>': 'Skill name, e.g. dispatch' },
149
+ run: runSkillsGetCli,
150
+ },
151
+ },
152
+ },
86
153
  };
87
154
 
88
155
  function runCli(argv) {
@@ -197,8 +264,25 @@ function getArgValue(args, name) {
197
264
  return args[idx + 1] && !args[idx + 1].startsWith('--') ? args[idx + 1] : null;
198
265
  }
199
266
 
200
- function cliPort() { return process.env.PORT || 3541; }
201
- function unreachable() { return `Cannot reach cck server on port ${cliPort()}. Start it first with "claude-code-kanban".`; }
267
+ // The hub runs one cck per config dir, each on its own port, so 3541 can be another dir's board.
268
+ // The server's beacon in this config dir names the right one; PORT still wins when set.
269
+ function cliPort() {
270
+ if (process.env.PORT) return process.env.PORT;
271
+ const { port, pid } = readCckJson('server.json') || {};
272
+ return port && pid && isPidAlive(pid) ? port : 3541;
273
+ }
274
+
275
+ function readCckJson(name) {
276
+ try { return JSON.parse(fs.readFileSync(path.join(getClaudeDir(), '.cck', name), 'utf8')); } catch (_) { return null; }
277
+ }
278
+
279
+ // EPERM means the process exists but belongs to someone else.
280
+ function isPidAlive(pid) {
281
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
282
+ }
283
+ function unreachable() {
284
+ return `Cannot reach cck server for ${displayPath(getClaudeDir())} on port ${cliPort()}. Start it first with "claude-code-kanban".`;
285
+ }
202
286
 
203
287
  class CliUnreachable extends Error { constructor() { super(unreachable()); this.code = 'unreachable'; } }
204
288
 
@@ -213,15 +297,19 @@ async function cliFetch(urlPath, init) {
213
297
 
214
298
  // Every write verb posts JSON and reports failure the same way; `label` names the verb
215
299
  // in the error line. Returns false when the server refused, so callers just return 1.
216
- async function cliPostJson(urlPath, body, label) {
300
+ // Returns the parsed response body ({} when empty), or null after printing the failure.
301
+ async function cliPostJson(urlPath, body, label, headers = {}) {
217
302
  const res = await cliFetch(urlPath, {
218
303
  method: 'POST',
219
- headers: { 'Content-Type': 'application/json' },
304
+ headers: { 'Content-Type': 'application/json', ...headers },
220
305
  body: JSON.stringify(body)
221
306
  });
222
- if (res.ok) return true;
223
- console.error(`${label} failed (${res.status}): ${await res.text()}`);
224
- return false;
307
+ const text = await res.text();
308
+ let parsed = {};
309
+ try { parsed = text ? JSON.parse(text) : {}; } catch (_) { /* not JSON */ }
310
+ if (res.ok) return parsed;
311
+ console.error(`${label} failed (${res.status}): ${parsed.error || text}`);
312
+ return null;
225
313
  }
226
314
 
227
315
  function reportCliError(e) {
@@ -592,4 +680,144 @@ async function runSessionPeekCli(args) {
592
680
  } catch (e) { reportCliError(e); return 1; }
593
681
  }
594
682
 
683
+ function positionals(args, valueFlags) {
684
+ return args.filter((a, i) => !a.startsWith('--') && !valueFlags.includes(args[i - 1]));
685
+ }
686
+
687
+ // The server matches the folder against known project paths by string, so an 8.3 short name
688
+ // or a differently cased drive letter from the shell must become the long, canonical form.
689
+ function canonicalDir(dir) {
690
+ try { return fs.realpathSync.native(path.resolve(dir)); } catch (_) { return path.resolve(dir); }
691
+ }
692
+
693
+ function textArg(args, name) {
694
+ const file = getArgValue(args, `${name}-file`);
695
+ return file ? fs.readFileSync(path.resolve(file), 'utf8') : getArgValue(args, name);
696
+ }
697
+
698
+ function parseDuration(raw, fallbackSec) {
699
+ if (!raw) return fallbackSec;
700
+ const m = /^(\d+(?:\.\d+)?)(s|m|h)?$/.exec(raw);
701
+ if (!m) return null;
702
+ return Number(m[1]) * ({ s: 1, m: 60, h: 3600 }[m[2] || 's']);
703
+ }
704
+
705
+ function printDispatch(r) {
706
+ const head = `${r.id} ${r.status.padEnd(9)} session=${r.session}${r.name ? ` ${r.name}` : ''}`;
707
+ console.log(r.summary ? `${head}\n ${r.summary}` : head);
708
+ }
709
+
710
+ async function runDispatchStartCli(args) {
711
+ const token = readCckJson('terminal-token.json')?.token;
712
+ if (!token) {
713
+ console.error(`No terminal token for ${displayPath(getClaudeDir())}. The cck server must be running with the terminal enabled.`);
714
+ return 1;
715
+ }
716
+ let spec;
717
+ try { spec = textArg(args, 'spec'); } catch (e) { console.error(e.message); return 1; }
718
+ if (!spec) {
719
+ printLeafHelp('dispatch start', COMMANDS.dispatch.verbs.start);
720
+ return 1;
721
+ }
722
+ const hasGroup = args.some(a => a === '--group' || a.startsWith('--group='));
723
+ const group = hasGroup ? getArgValue(args, 'group') || '' : null;
724
+ if (hasGroup && !isGroupName(group)) {
725
+ const hint = suggestGroupName(group);
726
+ console.error(`Group names are kebab-case${hint ? `: try --group ${hint}` : ', e.g. auth-refactor'}`);
727
+ return 1;
728
+ }
729
+ const worktree = args.includes('--worktree') ? getArgValue(args, 'worktree') || true : false;
730
+ const body = {
731
+ cwd: canonicalDir(getArgValue(args, 'cwd') || '.'),
732
+ spec,
733
+ name: getArgValue(args, 'name'),
734
+ model: getArgValue(args, 'model'),
735
+ worktree,
736
+ group,
737
+ report: args.includes('--report'),
738
+ peer: getArgValue(args, 'peer') || null,
739
+ parent: process.env.CLAUDE_CODE_SESSION_ID || null,
740
+ };
741
+ try {
742
+ const out = await cliPostJson('/api/dispatch', body, 'Dispatch', { 'x-terminal-token': token });
743
+ if (!out) return 1;
744
+ if (args.includes('--json')) console.log(JSON.stringify(out, null, 2));
745
+ else console.log(`Started ${out.dispatch} (session ${out.session}) in ${out.cwd}${out.group ? ` [${out.group}]` : ''}`);
746
+ return 0;
747
+ } catch (e) { reportCliError(e); return 1; }
748
+ }
749
+
750
+ async function runDispatchDoneCli(args) {
751
+ const [id] = positionals(args, ['--cap', '--outcome', '--summary', '--summary-file']);
752
+ let summary;
753
+ try { summary = textArg(args, 'summary'); } catch (e) { console.error(e.message); return 1; }
754
+ const body = { cap: getArgValue(args, 'cap'), outcome: getArgValue(args, 'outcome'), summary };
755
+ if (!id || !body.cap || !body.outcome) {
756
+ printLeafHelp('dispatch done', COMMANDS.dispatch.verbs.done);
757
+ return 1;
758
+ }
759
+ try {
760
+ if (!await cliPostJson(`/api/dispatch/${encodeURIComponent(id)}/done`, body, 'Report')) return 1;
761
+ console.log(`Reported ${id}: ${body.outcome}`);
762
+ return 0;
763
+ } catch (e) { reportCliError(e); return 1; }
764
+ }
765
+
766
+ function dispatchQuery(ids, all = false) {
767
+ const q = new URLSearchParams();
768
+ if (ids.length) q.set('ids', ids.join(','));
769
+ else if (!all && process.env.CLAUDE_CODE_SESSION_ID) q.set('parent', process.env.CLAUDE_CODE_SESSION_ID);
770
+ return q;
771
+ }
772
+
773
+ async function runDispatchWaitCli(args) {
774
+ const timeoutRaw = getArgValue(args, 'timeout');
775
+ const timeoutSec = parseDuration(timeoutRaw, 600);
776
+ if (timeoutSec === null) {
777
+ console.error(`Invalid --timeout value: ${timeoutRaw}`);
778
+ return 1;
779
+ }
780
+ const q = dispatchQuery(positionals(args, ['--timeout']));
781
+ const deadline = Date.now() + timeoutSec * 1000;
782
+ try {
783
+ let out;
784
+ do {
785
+ q.set('wait', String(Math.max(1, Math.min(120, Math.ceil((deadline - Date.now()) / 1000)))));
786
+ const res = await cliFetch(`/api/dispatch?${q}`);
787
+ out = await res.json();
788
+ } while (out.timeout && Date.now() < deadline);
789
+ if (args.includes('--json')) console.log(JSON.stringify(out, null, 2));
790
+ else {
791
+ for (const r of out.settled) printDispatch(r);
792
+ if (out.running.length) console.log(`${out.timeout ? 'Timed out; still running' : 'Still running'}: ${out.running.map(r => r.id).join(' ')}`);
793
+ if (!out.settled.length && !out.running.length) console.log('No dispatches to wait on.');
794
+ }
795
+ return 0;
796
+ } catch (e) { reportCliError(e); return 1; }
797
+ }
798
+
799
+ async function runDispatchListCli(args) {
800
+ try {
801
+ const res = await cliFetch(`/api/dispatch?${dispatchQuery([], args.includes('--all'))}`);
802
+ const { settled, running } = await res.json();
803
+ const rows = [...running, ...settled].sort((a, b) => b.startedAt - a.startedAt);
804
+ if (args.includes('--json')) console.log(JSON.stringify(rows, null, 2));
805
+ else if (!rows.length) console.log('No dispatches.');
806
+ else rows.forEach(printDispatch);
807
+ return 0;
808
+ } catch (e) { reportCliError(e); return 1; }
809
+ }
810
+
811
+ async function runSkillsGetCli(args) {
812
+ const name = args.find(a => !a.startsWith('--'));
813
+ const file = name && /^[a-z][a-z-]*$/.test(name) ? path.join(__dirname, 'skill-guides', `${name}.md`) : null;
814
+ if (!file || !fs.existsSync(file)) {
815
+ const known = fs.readdirSync(path.join(__dirname, 'skill-guides')).map(f => f.replace(/\.md$/, ''));
816
+ console.error(`Unknown skill guide: ${name || '(none)'}. Known: ${known.join(', ')}`);
817
+ return 1;
818
+ }
819
+ process.stdout.write(fs.readFileSync(file, 'utf8'));
820
+ return 0;
821
+ }
822
+
595
823
  module.exports = { runCli };
package/lib/claude-dir.js CHANGED
@@ -50,4 +50,4 @@ function displayPath(p) {
50
50
  return rel.split(path.sep).join('/');
51
51
  }
52
52
 
53
- module.exports = { getArgValue, getClaudeDir, claudeCliEnv, displayPath, storageNamespace };
53
+ module.exports = { getArgValue, getClaudeDir, claudeCliEnv, isDefaultClaudeDir, displayPath, storageNamespace };
@@ -0,0 +1,93 @@
1
+ // Transient session groups: `dispatch start --group` (or the starter's own group) puts
2
+ // the started session and its starter together. A group lives while any member's claude
3
+ // runs, and for a grace period after; once none runs, only pinned members stay in it.
4
+ // The map is on disk so a pinned group outlives a server restart.
5
+
6
+ const GROUP_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
7
+ const MAX_GROUP = 64;
8
+ // Covers a claude that has started but not yet registered, a resume, and registry lag.
9
+ const GRACE_MS = 60 * 1000;
10
+
11
+ function isGroupName(s) {
12
+ return typeof s === 'string' && s.length <= MAX_GROUP && GROUP_RE.test(s);
13
+ }
14
+
15
+ function suggestGroupName(s) {
16
+ return String(s ?? '')
17
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9]+/g, '-')
20
+ .replace(/^-+|-+$/g, '')
21
+ .slice(0, MAX_GROUP)
22
+ .replace(/-+$/, '');
23
+ }
24
+
25
+ /**
26
+ * @param {object} o
27
+ * @param {() => object|null} o.load returns `{sessions: {[id]: group}}` or null
28
+ * @param {(data: object) => void} o.save
29
+ * @param {(id: string) => boolean} o.isAlive
30
+ * @param {() => Set<string>} o.pinnedIds
31
+ */
32
+ function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
33
+ const members = new Map();
34
+ const liveAt = new Map();
35
+ const saved = load()?.sessions;
36
+ if (saved && typeof saved === 'object') {
37
+ for (const [id, g] of Object.entries(saved)) if (isGroupName(g)) members.set(id, g);
38
+ }
39
+ for (const g of members.values()) liveAt.set(g, now());
40
+
41
+ const persist = () => save({ version: 1, sessions: Object.fromEntries(members) });
42
+
43
+ function join(group, ids) {
44
+ let changed = false;
45
+ for (const id of ids) {
46
+ if (!id || members.get(id) === group) continue;
47
+ members.set(id, group);
48
+ changed = true;
49
+ }
50
+ liveAt.set(group, now());
51
+ if (changed) persist();
52
+ }
53
+
54
+ function refresh() {
55
+ const t = now();
56
+ const byGroup = new Map();
57
+ for (const [id, g] of members) {
58
+ if (!byGroup.has(g)) byGroup.set(g, []);
59
+ byGroup.get(g).push(id);
60
+ }
61
+ let pinned = null;
62
+ let changed = false;
63
+ for (const [g, ids] of byGroup) {
64
+ if (ids.some(isAlive)) {
65
+ liveAt.set(g, t);
66
+ continue;
67
+ }
68
+ if (t - (liveAt.get(g) ?? 0) <= GRACE_MS) continue;
69
+ pinned ??= pinnedIds();
70
+ for (const id of ids) {
71
+ if (pinned.has(id)) continue;
72
+ members.delete(id);
73
+ changed = true;
74
+ }
75
+ if (!ids.some((id) => pinned.has(id))) liveAt.delete(g);
76
+ }
77
+ if (changed) persist();
78
+ }
79
+
80
+ function groupOf(id) {
81
+ refresh();
82
+ return members.get(id) || null;
83
+ }
84
+
85
+ function snapshot() {
86
+ refresh();
87
+ return members;
88
+ }
89
+
90
+ return { join, groupOf, snapshot };
91
+ }
92
+
93
+ module.exports = { createGroupStore, isGroupName, suggestGroupName, GRACE_MS };