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 +68 -44
- package/cli.js +235 -7
- package/lib/claude-dir.js +1 -1
- package/lib/dispatch-groups.js +93 -0
- package/lib/dispatch.js +171 -0
- package/lib/folder-dialog.js +91 -0
- package/lib/net-guard.js +20 -3
- package/lib/session-events.js +22 -2
- package/lib/terminal.js +555 -0
- package/package.json +14 -3
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/monitors.json +6 -0
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +34 -2
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +7 -2
- package/plugin/plugins/claude-code-kanban/skills/kanban-dispatch/SKILL.md +23 -0
- package/public/app.js +1761 -138
- package/public/index.html +150 -14
- package/public/style.css +772 -53
- package/public/sw.js +2 -1
- package/public/themes.css +84 -80
- package/server.js +240 -18
- package/skill-guides/dispatch.md +72 -0
package/README.md
CHANGED
|
@@ -4,92 +4,116 @@
|
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](https://www.npmjs.com/package/claude-code-kanban)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Start, watch and answer Claude Code sessions from one live board, with a terminal built in.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**[Documentation](https://nikiforovall.blog/claude-code-kanban/)**
|
|
10
10
|
|
|
11
|
-
|
|
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
|
|
16
|
+
## Getting started
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
You need Node.js 20 or later, the `claude` CLI, and `jq` for the hook scripts.
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
### 1. Install the integration (one time)
|
|
18
21
|
|
|
19
22
|
```bash
|
|
20
23
|
npx claude-code-kanban --install
|
|
21
24
|
```
|
|
22
25
|
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
>
|
|
48
|
+
> You can also add a task by hand with **Add task** in the Pending column.
|
|
44
49
|
|
|
45
50
|
## Features
|
|
46
51
|
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>`</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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
88
|
-
npx claude-code-kanban --
|
|
89
|
-
npx claude-code-kanban --
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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 };
|