claude-code-kanban 7.17.0 → 7.19.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/README.md +65 -40
- package/hub-app.json +23 -0
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/activity.ts +45 -6
- package/plugin/plugins/claude-code-kanban/hooks/show.ts +93 -53
- package/plugin/plugins/claude-code-kanban/skills/visualize/SKILL.md +1 -0
- package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +79 -13
- package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +1 -0
- package/plugin/plugins/claude-code-kanban/tests/show.test.ts +63 -28
- package/public/app.js +194 -82
- package/public/index.html +3 -3
- package/public/style.css +106 -1
- package/public/vendor/claude-hub-sdk.js +20 -3
package/README.md
CHANGED
|
@@ -8,58 +8,45 @@ Start, watch and answer Claude Code sessions from one live board, with a termina
|
|
|
8
8
|
|
|
9
9
|
**[Documentation](https://nikiforovall.blog/claude-code-kanban/)**
|
|
10
10
|
|
|
11
|
-
<
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
</picture>
|
|
16
|
-
</a>
|
|
11
|
+
<picture>
|
|
12
|
+
<source media="(prefers-color-scheme: dark)" srcset="website/public/shots/themes/ember-01-board-session-log-dark.webp">
|
|
13
|
+
<img alt="The board with the session list, task columns, the agents log and the session log of tool calls." src="website/public/shots/themes/ember-01-board-session-log-light.webp">
|
|
14
|
+
</picture>
|
|
17
15
|
|
|
18
|
-
|
|
16
|
+
<table>
|
|
17
|
+
<tr>
|
|
18
|
+
<td width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="website/public/shots/themes/ember-terminal-dark.webp"><img alt="The embedded terminal with a show card checklist next to it." src="website/public/shots/themes/ember-terminal-light.webp"></picture></td>
|
|
19
|
+
<td width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="website/public/shots/themes/ember-13b-split-view-dark.webp"><img alt="The board split with an HTML preview pane." src="website/public/shots/themes/ember-13b-split-view-light.webp"></picture></td>
|
|
20
|
+
<td width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="website/public/shots/themes/ember-14b-show-chart-dark.webp"><img alt="A show card with a spend chart over the terminal." src="website/public/shots/themes/ember-14b-show-chart-light.webp"></picture></td>
|
|
21
|
+
</tr>
|
|
22
|
+
<tr>
|
|
23
|
+
<td align="center">Embedded terminal</td>
|
|
24
|
+
<td align="center">Panes and split view</td>
|
|
25
|
+
<td align="center">Show cards</td>
|
|
26
|
+
</tr>
|
|
27
|
+
</table>
|
|
28
|
+
|
|
29
|
+
Watch the 74-second tour on YouTube: [light](https://youtu.be/QbvDBFyfC7s), [dark](https://youtu.be/bXZ4_QCmD7k).
|
|
19
30
|
|
|
20
31
|
## Getting started
|
|
21
32
|
|
|
22
33
|
You need Node.js 20 or later and the `claude` CLI.
|
|
23
34
|
|
|
24
|
-
### 1.
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
npm install -g claude-code-kanban
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
To update, run the same command again.
|
|
31
|
-
|
|
32
|
-
To try it without an install, run `npx claude-code-kanban --open`. npx keeps a copy of each version in its cache and can run an old copy, so use the global install to keep the board and to update it.
|
|
33
|
-
|
|
34
|
-
### 2. Install the integration (one time)
|
|
35
|
-
|
|
36
|
-
```bash
|
|
37
|
-
claude-code-kanban --install
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
The installer adds a Claude Code plugin with hooks, skills and a mod for context use and cost (Claude Code 2.1.287 or later). It asks before it installs and keeps your other settings. Without the hooks, the board shows tasks only: no agent log, no live activity, no waiting prompts.
|
|
41
|
-
|
|
42
|
-
To remove it, run `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.
|
|
35
|
+
### 1. Run it in Claude Code Hub
|
|
43
36
|
|
|
44
|
-
|
|
37
|
+
[Claude Code Hub](https://nikiforovall.blog/claude-code-hub/) runs the board as a tab next to Cost, Marketplace and Memory, in one window with keyboard switching. The embedded terminal is on by default, and the board opens a session's cost, plugins and memory in the other tabs.
|
|
45
38
|
|
|
46
39
|
```bash
|
|
47
|
-
claude-code-
|
|
40
|
+
npm install -g claude-code-hub
|
|
41
|
+
claude-code-hub --install # one time: installs this board's plugin
|
|
42
|
+
claude-code-hub --open
|
|
48
43
|
```
|
|
49
44
|
|
|
50
|
-
The
|
|
45
|
+
The hub runs at `http://localhost:3540`. See [Claude Code Hub](https://nikiforovall.blog/claude-code-kanban/guides/claude-code-hub/) for how the board works there. To run the board without the hub, see [Appendix: run the board standalone](#appendix-run-the-board-standalone).
|
|
51
46
|
|
|
52
|
-
###
|
|
47
|
+
### 2. Use Claude Code as usual
|
|
53
48
|
|
|
54
|
-
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.
|
|
55
|
-
|
|
56
|
-
> **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:
|
|
57
|
-
>
|
|
58
|
-
> ```json
|
|
59
|
-
> { "env": { "CLAUDE_CODE_ENABLE_TODO_TOOLS": "true" } }
|
|
60
|
-
> ```
|
|
61
|
-
>
|
|
62
|
-
> You can also add a task by hand with **Add task** in the Pending column.
|
|
49
|
+
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. If the board stays empty, see [Appendix: empty board](#appendix-empty-board).
|
|
63
50
|
|
|
64
51
|
## Features
|
|
65
52
|
|
|
@@ -133,7 +120,45 @@ EDITOR="code -w" claude-code-kanban # Command for Open in editor (default
|
|
|
133
120
|
|
|
134
121
|
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.
|
|
135
122
|
|
|
136
|
-
|
|
123
|
+
## Appendix: run the board standalone
|
|
124
|
+
|
|
125
|
+
### 1. Install the command
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
npm install -g claude-code-kanban
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
To update, run the same command again.
|
|
132
|
+
|
|
133
|
+
To try it without an install, run `npx claude-code-kanban --open`. npx keeps a copy of each version in its cache and can run an old copy, so use the global install to keep the board and to update it.
|
|
134
|
+
|
|
135
|
+
### 2. Install the integration (one time)
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
claude-code-kanban --install
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The installer adds a Claude Code plugin with hooks, skills and a mod for context use and cost (Claude Code 2.1.287 or later). It asks before it installs and keeps your other settings. Without the hooks, the board shows tasks only: no agent log, no live activity, no waiting prompts.
|
|
142
|
+
|
|
143
|
+
To remove it, run `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.
|
|
144
|
+
|
|
145
|
+
### 3. Start the board
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
claude-code-kanban --open
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The board runs at `http://localhost:3541`. Then use Claude Code as usual.
|
|
152
|
+
|
|
153
|
+
## Appendix: empty board
|
|
154
|
+
|
|
155
|
+
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:
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{ "env": { "CLAUDE_CODE_ENABLE_TODO_TOOLS": "true" } }
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
You can also add a task by hand with **Add task** in the Pending column.
|
|
137
162
|
|
|
138
163
|
## License
|
|
139
164
|
|
package/hub-app.json
CHANGED
|
@@ -5,6 +5,29 @@
|
|
|
5
5
|
"icon": "columns",
|
|
6
6
|
"run": { "entry": "server.js", "defaultPort": 3541 },
|
|
7
7
|
"publishes": ["session.changed"],
|
|
8
|
+
"settings": [
|
|
9
|
+
{
|
|
10
|
+
"key": "zenCentered",
|
|
11
|
+
"type": "boolean",
|
|
12
|
+
"label": "Center zen mode",
|
|
13
|
+
"help": "In zen mode, centers the session card and its panel in the sidebar's height.",
|
|
14
|
+
"default": false
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"key": "zenPattern",
|
|
18
|
+
"type": "choice",
|
|
19
|
+
"label": "Zen mode pattern",
|
|
20
|
+
"help": "In zen mode, fills the sidebar's free space with a pattern tinted by the session's state.",
|
|
21
|
+
"options": [
|
|
22
|
+
{ "value": "none", "label": "None" },
|
|
23
|
+
{ "value": "dots", "label": "Dots" },
|
|
24
|
+
{ "value": "diagonal", "label": "Diagonal" },
|
|
25
|
+
{ "value": "rings", "label": "Rings" },
|
|
26
|
+
{ "value": "grid", "label": "Grid" }
|
|
27
|
+
],
|
|
28
|
+
"default": "dots"
|
|
29
|
+
}
|
|
30
|
+
],
|
|
8
31
|
"keys": { "keeps": ["ctrl+alt+1", "ctrl+alt+2", "ctrl+alt+3", "ctrl+alt+4", "ctrl+alt+5", "ctrl+alt+6", "ctrl+alt+7", "ctrl+alt+8", "ctrl+alt+9", "ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+shift+d", "ctrl+shift+k", "ctrl+shift+l", "ctrl+shift+m", "ctrl+shift+p", "ctrl+shift+z"] },
|
|
9
32
|
"plugin": {
|
|
10
33
|
"marketplace": "plugin",
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.14",
|
|
4
4
|
"description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, board moves and reviews delivered to the session, and skills to drive the board from a session"
|
|
5
5
|
}
|
|
@@ -150,14 +150,19 @@ async function markStopped($: EngineInterface) {
|
|
|
150
150
|
|
|
151
151
|
// The name map lets TeammateIdle, which names the teammate, find its agent id. The prompt is on
|
|
152
152
|
// the start line because a resume runs a new prompt, and the server's own lookup finds the first.
|
|
153
|
-
async function agentStarted(
|
|
153
|
+
async function agentStarted(
|
|
154
|
+
$: EngineInterface,
|
|
155
|
+
e: Partial<Pick<AgentSpawnInput, 'subagentType' | 'prompt' | 'description' | 'name'>>,
|
|
156
|
+
agentId: string,
|
|
157
|
+
model?: string,
|
|
158
|
+
) {
|
|
154
159
|
const dir = await sessionDir($)
|
|
155
160
|
const ts = new Date().toISOString()
|
|
156
161
|
await Promise.all([
|
|
157
162
|
appendLine($, `${dir}/${agentId}.jsonl`, {
|
|
158
163
|
agentId,
|
|
159
164
|
type: e.subagentType,
|
|
160
|
-
model,
|
|
165
|
+
...(model && { model }),
|
|
161
166
|
event: 'start',
|
|
162
167
|
status: 'active',
|
|
163
168
|
...(e.prompt && { prompt: e.prompt }),
|
|
@@ -169,6 +174,27 @@ async function agentStarted($: EngineInterface, e: AgentSpawnInput, agentId: str
|
|
|
169
174
|
])
|
|
170
175
|
}
|
|
171
176
|
|
|
177
|
+
// A forked skill fires no agent.spawn, but $.agent.list() names it; internal loops (compaction,
|
|
178
|
+
// memory) and workflow agents it does not. A spawned agent's turn.start runs inside its spawn's
|
|
179
|
+
// next(), and a second start line would reset the server's fold, so turn.start leaves the agents
|
|
180
|
+
// of a spawn in flight to the spawn; turn.complete catches a fork that started in that window.
|
|
181
|
+
const tracked = new Map<string, Promise<void>>()
|
|
182
|
+
let spawning = 0
|
|
183
|
+
|
|
184
|
+
function trackAgent($: EngineInterface, agentId: string, prompt?: string) {
|
|
185
|
+
const found = tracked.get(agentId) ?? startUnspawned($, agentId, prompt).catch(() => {})
|
|
186
|
+
tracked.set(agentId, found)
|
|
187
|
+
return found
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function startUnspawned($: EngineInterface, agentId: string, prompt?: string) {
|
|
191
|
+
if (await $.fs.exists(`${await sessionDir($)}/${agentId}.jsonl`)) return
|
|
192
|
+
const agent = (await $.agent.list()).find(a => a.id === agentId)
|
|
193
|
+
if (!agent) return
|
|
194
|
+
const { type: subagentType, description, name } = agent
|
|
195
|
+
await agentStarted($, { subagentType, description, name, prompt }, agentId)
|
|
196
|
+
}
|
|
197
|
+
|
|
172
198
|
// turn.complete also ends loops agent.spawn never started (internal agents): those have no file.
|
|
173
199
|
async function agentStopped($: EngineInterface, agentId: string, lastMessage: string) {
|
|
174
200
|
const ts = new Date().toISOString()
|
|
@@ -251,13 +277,26 @@ export const register: Register = on => {
|
|
|
251
277
|
return next(e)
|
|
252
278
|
})
|
|
253
279
|
on('agent.spawn', async ($, e, next) => {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
280
|
+
spawning++
|
|
281
|
+
try {
|
|
282
|
+
const result = await next(e)
|
|
283
|
+
if (result.agentId) await agentStarted($, e, result.agentId, result.model)
|
|
284
|
+
return result
|
|
285
|
+
} finally {
|
|
286
|
+
spawning--
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
on('turn.start', async ($, e, next) => {
|
|
290
|
+
if (e.agentId && !spawning) await trackAgent($, e.agentId, e.text || undefined)
|
|
291
|
+
return next(e)
|
|
257
292
|
})
|
|
258
293
|
// An interrupted turn fires no Stop hook, so it does not mark the session unread either.
|
|
259
294
|
on('turn.complete', async ($, e, next) => {
|
|
260
|
-
if (e.agentId)
|
|
295
|
+
if (e.agentId) {
|
|
296
|
+
await trackAgent($, e.agentId)
|
|
297
|
+
tracked.delete(e.agentId)
|
|
298
|
+
await agentStopped($, e.agentId, e.answer)
|
|
299
|
+
}
|
|
261
300
|
else if (!e.isAborted) await markStopped($)
|
|
262
301
|
return next(e)
|
|
263
302
|
})
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { EngineInterface, Register } from 'claude-code'
|
|
2
2
|
import { cckRoot } from './context'
|
|
3
|
+
import { boardUrl } from './doorbell'
|
|
3
4
|
|
|
4
5
|
export const TOOL = 'mcp__claude-code-kanban__show'
|
|
5
|
-
export const SKILL_LINE = /^\s*-\s*claude-code-kanban:show(?=:|\s*$)/
|
|
6
6
|
|
|
7
7
|
type ShowInput = { title: string; key?: string; kind?: 'markdown' | 'html'; file?: string; base?: string }
|
|
8
8
|
type Shown = { title: string; key?: string | null; index: number; count: number; path?: string | null }
|
|
@@ -16,13 +16,6 @@ export function shownText({ title, key, index, count, path }: Shown, claim = fal
|
|
|
16
16
|
return claim ? `Card ${at} is ready. Write it to ${path}; the card refreshes on each save.` : `Shown ${at}`
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function dropSkillLine(listing: string) {
|
|
20
|
-
return listing
|
|
21
|
-
.split('\n')
|
|
22
|
-
.filter(l => !SKILL_LINE.test(l))
|
|
23
|
-
.join('\n')
|
|
24
|
-
}
|
|
25
|
-
|
|
26
19
|
let cck: Promise<string> | undefined
|
|
27
20
|
|
|
28
21
|
function cckDir($: EngineInterface) {
|
|
@@ -62,64 +55,111 @@ function errorText(status: number, text: string) {
|
|
|
62
55
|
return `cck answered HTTP ${status}`
|
|
63
56
|
}
|
|
64
57
|
|
|
65
|
-
async function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
let res: Awaited<ReturnType<EngineInterface['http']['fetch']>>
|
|
58
|
+
async function send(
|
|
59
|
+
$: EngineInterface,
|
|
60
|
+
url: string,
|
|
61
|
+
path: string,
|
|
62
|
+
headers: Record<string, string>,
|
|
63
|
+
body: unknown,
|
|
64
|
+
): Promise<{ text: string } | { deny: string }> {
|
|
73
65
|
try {
|
|
74
|
-
res = await $.http.fetch(`${
|
|
66
|
+
const res = await $.http.fetch(`${url}${path}`, {
|
|
75
67
|
method: 'POST',
|
|
76
|
-
headers: { 'content-type': 'application/json',
|
|
77
|
-
body,
|
|
68
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
69
|
+
body: JSON.stringify(body),
|
|
78
70
|
})
|
|
71
|
+
return res.ok ? { text: res.text } : { deny: errorText(res.status, res.text) }
|
|
79
72
|
} catch (err) {
|
|
80
|
-
return { deny: `Cannot reach cck at ${
|
|
73
|
+
return { deny: `Cannot reach cck at ${url}: ${err instanceof Error ? err.message : String(err)}` }
|
|
81
74
|
}
|
|
82
|
-
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function post($: EngineInterface, t: { url: string; terminalId: string }, input: ShowInput) {
|
|
78
|
+
const token = await terminalToken($, t.url)
|
|
79
|
+
if (!token) return { deny: `No terminal token for the cck server at ${t.url}. It must run with the terminal enabled.` }
|
|
80
|
+
const { title, key, kind, file, base } = input
|
|
81
|
+
const body = { sessionId: await $.session.id(), title, key, kind, file, base }
|
|
82
|
+
const res = await send($, t.url, `/api/terminals/${encodeURIComponent(t.terminalId)}/show`, { 'x-terminal-token': token }, body)
|
|
83
|
+
if ('deny' in res) return res
|
|
83
84
|
return { result: shownText(JSON.parse(res.text) as Shown, file == null) }
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
// Read on every post, as the doorbell does, so a board that restarted on another port is found.
|
|
88
|
+
async function findBoard($: EngineInterface) {
|
|
89
|
+
try {
|
|
90
|
+
return boardUrl(undefined, await $.fs.read(`${await cckDir($)}/server.json`))
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const CLAIM_OFF_TERMINAL =
|
|
97
|
+
'No overlay here: write the card to a markdown or HTML file in your scratchpad directory, then call show again with file.'
|
|
98
|
+
|
|
99
|
+
export function paneText(title: string, added: boolean) {
|
|
100
|
+
return added
|
|
101
|
+
? `Added "${title}" as a tab of this session on the cck board, in the background. Tell the user it waits there; the tab refreshes on each save.`
|
|
102
|
+
: `The board already has a tab for this file; it refreshes on each save.`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function postPane($: EngineInterface, { title, file }: ShowInput) {
|
|
106
|
+
if (file == null) return { result: CLAIM_OFF_TERMINAL }
|
|
107
|
+
const [url, sessionId] = await Promise.all([findBoard($), $.session.id()])
|
|
108
|
+
if (!url) return { deny: 'The cck board is not running. Ask the user to start it, or give them the file path.' }
|
|
109
|
+
const res = await send($, url, `/api/panes/${encodeURIComponent(sessionId)}`, {}, { target: file, title, show: true })
|
|
110
|
+
if ('deny' in res) return res
|
|
111
|
+
const { added } = JSON.parse(res.text) as { added: boolean }
|
|
112
|
+
return { result: paneText(title, added) }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const WHEN =
|
|
116
|
+
'Post one when the user asks you to show, draw or visualize something, or when your answer is a comparison of 4 or more rows, a flow, or numbers over time. Pick the form that makes the point best: a table, chart, diagram, mockup, small widget or a mix.'
|
|
117
|
+
|
|
118
|
+
const FILE = { type: 'string', description: 'Absolute path of an existing markdown, HTML, image or text file' }
|
|
119
|
+
|
|
120
|
+
const PANE_TOOL = {
|
|
121
|
+
description: `Show a card as a tab of this session on the cck board. ${WHEN} Write the card to a markdown or HTML file in your scratchpad directory, then pass it as file; post again after a new file, and edit the same file to update its tab, which refreshes on each save. For an HTML card, load the claude-code-kanban:show skill first for the HTML contract. The tab opens in the background: in your reply, say what the card shows and that it waits on the board.`,
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: { title: { type: 'string', description: 'Tab title' }, file: FILE },
|
|
125
|
+
required: ['title', 'file'],
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const OVERLAY_TOOL = {
|
|
130
|
+
description: `Show a card in the cck overlay above this terminal. ${WHEN} Give each card a key that names what it shows (for example latency-chart), and post again with that key to update the card in place instead of adding a new one. Without file, the tool answers with a path: write the card there with Write, and change it with Edit; the card refreshes on each save. Pass file to show a file that already exists. To explain code with a call stack or a tree, load the claude-code-kanban:show skill first for the notation. The user sees the rendered card and knows you do not; in your reply, say what the card shows, and take the render as given.`,
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: 'object',
|
|
133
|
+
properties: {
|
|
134
|
+
title: { type: 'string', description: 'Card title' },
|
|
135
|
+
kind: {
|
|
136
|
+
type: 'string',
|
|
137
|
+
enum: ['markdown', 'html'],
|
|
138
|
+
description:
|
|
139
|
+
'markdown (default): mermaid, diff and code fences render. html: a body fragment; load the claude-code-kanban:show skill first for the HTML contract',
|
|
140
|
+
},
|
|
141
|
+
file: FILE,
|
|
142
|
+
key: { type: 'string', description: 'Card name: the same key replaces the card in place and keeps its file' },
|
|
143
|
+
base: {
|
|
144
|
+
type: 'string',
|
|
145
|
+
description:
|
|
146
|
+
"Absolute path of the folder that the card's path:line links resolve against, usually the repo root. Always pass it with a call stack. Without it, links resolve against the session cwd, which is wrong for a worktree or another repo",
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
required: ['title'],
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
86
153
|
// A -p run is detached from the terminal, so only an interactive session gets the tool.
|
|
87
154
|
export const register: Register = on => {
|
|
88
155
|
on('session.start', { isInteractive: true }, async ($, e, next) => {
|
|
89
|
-
|
|
90
|
-
await $.tool.register({
|
|
91
|
-
name: 'show',
|
|
92
|
-
description:
|
|
93
|
-
'Show a card in the cck overlay above this terminal. Post one when the user asks you to show, draw or visualize something, or when your answer is a comparison of 4 or more rows, a flow, or numbers over time. Pick the form that makes the point best: a table, chart, diagram, mockup, small widget or a mix. Give each card a key that names what it shows (for example latency-chart), and post again with that key to update the card in place instead of adding a new one. Without file, the tool answers with a path: write the card there with Write, and change it with Edit; the card refreshes on each save. Pass file to show a file that already exists. To explain code with a call stack or a tree, load the claude-code-kanban:show skill first for the notation. The user sees the rendered card and knows you do not; in your reply, say what the card shows, and take the render as given.',
|
|
94
|
-
inputSchema: {
|
|
95
|
-
type: 'object',
|
|
96
|
-
properties: {
|
|
97
|
-
title: { type: 'string', description: 'Card title' },
|
|
98
|
-
kind: {
|
|
99
|
-
type: 'string',
|
|
100
|
-
enum: ['markdown', 'html'],
|
|
101
|
-
description:
|
|
102
|
-
'markdown (default): mermaid, diff and code fences render. html: a body fragment; load the claude-code-kanban:show skill first for the HTML contract',
|
|
103
|
-
},
|
|
104
|
-
file: { type: 'string', description: 'Absolute path of an existing markdown, HTML, image or text file' },
|
|
105
|
-
key: { type: 'string', description: 'Card name: the same key replaces the card in place and keeps its file' },
|
|
106
|
-
base: {
|
|
107
|
-
type: 'string',
|
|
108
|
-
description:
|
|
109
|
-
"Absolute path of the folder that the card's path:line links resolve against, usually the repo root. Always pass it with a call stack. Without it, links resolve against the session cwd, which is wrong for a worktree or another repo",
|
|
110
|
-
},
|
|
111
|
-
},
|
|
112
|
-
required: ['title'],
|
|
113
|
-
},
|
|
114
|
-
})
|
|
115
|
-
}
|
|
156
|
+
await $.tool.register({ name: 'show', ...((await terminal($)) ? OVERLAY_TOOL : PANE_TOOL) })
|
|
116
157
|
return next(e)
|
|
117
158
|
})
|
|
118
159
|
|
|
119
|
-
on('tool.call', { tool: TOOL }, async ($, e) =>
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return next({ ...e, text: dropSkillLine(e.text) })
|
|
160
|
+
on('tool.call', { tool: TOOL }, async ($, e) => {
|
|
161
|
+
const input = e as unknown as ShowInput
|
|
162
|
+
const t = await terminal($)
|
|
163
|
+
return t ? post($, t, input) : postPane($, input)
|
|
124
164
|
})
|
|
125
165
|
}
|
|
@@ -46,6 +46,7 @@ Every exhibit, ready or new, keeps these:
|
|
|
46
46
|
- Colors come from the tokens on every draw, in canvas too, so a theme change needs no extra code.
|
|
47
47
|
- Nothing runs while the reader does nothing, except a running model that is on screen.
|
|
48
48
|
- Numbers from a model are marked as model numbers.
|
|
49
|
+
- When the reader's choice should come back to the session, such as quiz answers or a pick, wrap the inputs in `<form data-cck-submit="<name>">`, or put `data-cck-action="<name>"` on one button. It works in a board pane: the session gets the fields as its next prompt. The board sends only on a real click. Give `type="button"` to buttons that must not send, and call `preventDefault` on the submit so the page also works outside cck.
|
|
49
50
|
- The script goes at the end of `<body>`; its CSS goes in the second `<style>`.
|
|
50
51
|
|
|
51
52
|
## Blocks
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { On, ToolCallArgs, ToolCheckDecision } from 'claude-code'
|
|
1
|
+
import type { AgentInfo, On, ToolCallArgs, ToolCheckDecision } from 'claude-code'
|
|
2
2
|
import { type Engine, expect, type MockClock, mock, test } from 'claude-code/testing'
|
|
3
3
|
import { BOARD_ALLOWED, CLEARED } from '../hooks/activity'
|
|
4
4
|
|
|
@@ -7,11 +7,20 @@ const SESSION = `${ROOT}/sid-1`
|
|
|
7
7
|
const MARKER = `${SESSION}/_waiting.json`
|
|
8
8
|
const CONFIG = 'C:/cfg/.cck/config.json'
|
|
9
9
|
|
|
10
|
-
type EngineOptions = {
|
|
10
|
+
type EngineOptions = {
|
|
11
|
+
decision?: ToolCheckDecision
|
|
12
|
+
rule?: string
|
|
13
|
+
env?: Record<string, string>
|
|
14
|
+
agents?: AgentInfo[]
|
|
15
|
+
}
|
|
11
16
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
function engine(
|
|
17
|
+
// The engine raises tool.check inside the call, and its ask waits in classic.PermissionRequest
|
|
18
|
+
// until the test answers the terminal prompt.
|
|
19
|
+
function engine(
|
|
20
|
+
on: On,
|
|
21
|
+
files: Record<string, string>,
|
|
22
|
+
{ decision = 'allow', rule, env = {}, agents = [] }: EngineOptions = {},
|
|
23
|
+
) {
|
|
15
24
|
const writes: { path: string; text: string }[] = []
|
|
16
25
|
const runs: unknown[] = []
|
|
17
26
|
const status: (string | undefined)[] = []
|
|
@@ -38,29 +47,28 @@ function engine(on: On, files: Record<string, string>, { decision = 'allow', rul
|
|
|
38
47
|
return { value: undefined }
|
|
39
48
|
})
|
|
40
49
|
on('tool.check', () => ({ decision, rule }))
|
|
41
|
-
let held = decision === 'ask'
|
|
42
50
|
on('tool.call', async (_$, e) => {
|
|
43
51
|
const { tool, tool_use_id, agentId, ...input } = e
|
|
44
|
-
if (held) {
|
|
45
|
-
held = false
|
|
46
|
-
await terminal
|
|
47
|
-
}
|
|
48
52
|
runs.push(input)
|
|
49
53
|
return { result: { stdout: 'ok' } }
|
|
50
54
|
})
|
|
51
55
|
on('session.start', (_$, e) => ({ cwd: e.cwd }))
|
|
52
56
|
on('agent.spawn', () => ({ model: 'haiku', agentId: 'a1' }))
|
|
57
|
+
on('agent.list', () => ({ value: agents }))
|
|
58
|
+
on('fs.exists', (_$, e) => ({ value: norm(e.path) in files }))
|
|
59
|
+
on('turn.start', (_$, e) => ({ turnId: e.turnId }))
|
|
53
60
|
on('turn.complete', (_$, e) => ({ text: e.answer }))
|
|
61
|
+
on('classic.PermissionRequest', async () => {
|
|
62
|
+
await terminal
|
|
63
|
+
return { decision: { behavior: 'allow' as const } }
|
|
64
|
+
})
|
|
54
65
|
on('classic.*', () => ({}))
|
|
55
66
|
return { writes, runs, status, answer }
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
async function askFor($: Engine, clock: MockClock, args: { tool: string } & Record<string, unknown>) {
|
|
59
|
-
const { tool, ...input } = args
|
|
60
70
|
const call = $.tool.call(args as ToolCallArgs)
|
|
61
71
|
await clock.settle()
|
|
62
|
-
await $.tool.check({ tool, input })
|
|
63
|
-
await clock.settle()
|
|
64
72
|
return { call }
|
|
65
73
|
}
|
|
66
74
|
|
|
@@ -305,6 +313,64 @@ test('a finished loop that was never spawned writes nothing', async ($, on) => {
|
|
|
305
313
|
expect(writes).toEqual([])
|
|
306
314
|
})
|
|
307
315
|
|
|
316
|
+
const forked: AgentInfo = {
|
|
317
|
+
id: 'f1',
|
|
318
|
+
type: 'general-purpose',
|
|
319
|
+
description: '/code-review high',
|
|
320
|
+
status: 'running',
|
|
321
|
+
name: 'code-review',
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
test('a forked skill is recorded at its turn start and stopped at its end', async ($, on) => {
|
|
325
|
+
const files: Record<string, string> = {}
|
|
326
|
+
engine(on, files, { agents: [forked] })
|
|
327
|
+
|
|
328
|
+
await $.turn.start({ text: 'review the MR', turnId: 't', agentId: 'f1' })
|
|
329
|
+
await $.turn.complete(turn({ agentId: 'f1', answer: 'review done' }))
|
|
330
|
+
|
|
331
|
+
const all = lines(files[`${SESSION}/f1.jsonl`])
|
|
332
|
+
expect(all.map(l => l.event)).toEqual(['start', 'stop'])
|
|
333
|
+
expect(all[0]).toMatchObject({
|
|
334
|
+
agentId: 'f1',
|
|
335
|
+
type: 'general-purpose',
|
|
336
|
+
description: '/code-review high',
|
|
337
|
+
prompt: 'review the MR',
|
|
338
|
+
})
|
|
339
|
+
expect('model' in all[0]).toBe(false)
|
|
340
|
+
expect(all[1]).toMatchObject({ status: 'stopped', lastMessage: 'review done' })
|
|
341
|
+
expect(files[`${SESSION}/_name-code-review.id`]).toBe('f1')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('a forked skill whose start no hook heard is recorded at its end', async ($, on) => {
|
|
345
|
+
const files: Record<string, string> = {}
|
|
346
|
+
engine(on, files, { agents: [{ ...forked, id: 'f2' }] })
|
|
347
|
+
|
|
348
|
+
await $.turn.complete(turn({ agentId: 'f2' }))
|
|
349
|
+
|
|
350
|
+
expect(lines(files[`${SESSION}/f2.jsonl`]).map(l => l.event)).toEqual(['start', 'stop'])
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
test('a spawned subagent turn start adds no second start line', async ($, on) => {
|
|
354
|
+
const files: Record<string, string> = {}
|
|
355
|
+
engine(on, files, { agents: [{ ...forked, id: 'a1' }] })
|
|
356
|
+
|
|
357
|
+
await $.agent.spawn(spawn('general-purpose'))
|
|
358
|
+
await $.turn.start({ text: 'p', turnId: 't', agentId: 'a1' })
|
|
359
|
+
|
|
360
|
+
expect(lines(files[`${SESSION}/a1.jsonl`]).map(l => l.event)).toEqual(['start'])
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
test('a turn start inside its spawn leaves the start line to the spawn', async ($, on) => {
|
|
364
|
+
const files: Record<string, string> = {}
|
|
365
|
+
engine(on, files, { agents: [{ ...forked, id: 'a1' }] })
|
|
366
|
+
|
|
367
|
+
await Promise.all([$.agent.spawn(spawn('general-purpose')), $.turn.start({ text: 'p', turnId: 't', agentId: 'a1' })])
|
|
368
|
+
|
|
369
|
+
const all = lines(files[`${SESSION}/a1.jsonl`])
|
|
370
|
+
expect(all.map(l => l.event)).toEqual(['start'])
|
|
371
|
+
expect(all[0]).toMatchObject({ model: 'haiku' })
|
|
372
|
+
})
|
|
373
|
+
|
|
308
374
|
test('a named spawn maps the name, and TeammateIdle finds the agent by it', async ($, on) => {
|
|
309
375
|
const files: Record<string, string> = {}
|
|
310
376
|
engine(on, files)
|
|
@@ -23,6 +23,7 @@ function engine(on: On, replies: (() => Reply)[], submit?: () => Promise<unknown
|
|
|
23
23
|
on('env.get', (_$, e) => ({ value: { CLAUDE_CONFIG_DIR: 'C:/cfg' }[e.name] }))
|
|
24
24
|
on('session.id', () => ({ value: sessionId }))
|
|
25
25
|
on('session.start', (_$, e) => ({ cwd: e.cwd }))
|
|
26
|
+
on('tool.register', (_$, e) => ({ value: { tool: `mcp__claude-code-kanban__${e.name}` } }))
|
|
26
27
|
on('http.fetch', async (_$, e) => {
|
|
27
28
|
urls.push(e.url)
|
|
28
29
|
const reply = replies.shift()
|