claude-code-kanban 7.16.0 → 7.18.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 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
- <a href="https://youtu.be/QbvDBFyfC7s">
12
- <picture>
13
- <source media="(prefers-color-scheme: dark)" srcset="website/public/video/cck-dark.webp">
14
- <img alt="Claude Code Kanban tour video (74 seconds). Opens on YouTube." src="website/public/video/cck-light.webp">
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
- Watch the tour on YouTube: [light](https://youtu.be/QbvDBFyfC7s), [dark](https://youtu.be/bXZ4_QCmD7k).
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. Install the command
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
- ### 3. Start the board
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-kanban --open
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 board runs at `http://localhost:3541`.
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
- ### 4. Use Claude Code as usual
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
- 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.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.16.0",
3
+ "version": "7.18.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -39,6 +39,7 @@
39
39
  "@xterm/addon-fit": "0.11.0",
40
40
  "@xterm/addon-serialize": "0.14.0",
41
41
  "@xterm/addon-unicode11": "0.9.0",
42
+ "@xterm/addon-web-links": "0.12.0",
42
43
  "@xterm/addon-webgl": "0.19.0",
43
44
  "@xterm/headless": "6.0.0",
44
45
  "@xterm/xterm": "6.0.0",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.7.11",
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($: EngineInterface, e: AgentSpawnInput, agentId: string, model: string) {
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
- const result = await next(e)
255
- if (result.agentId) await agentStarted($, e, result.agentId, result.model)
256
- return result
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) await agentStopped($, e.agentId, e.answer)
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 post($: EngineInterface, input: ShowInput) {
66
- const t = await terminal($)
67
- if (!t) return { deny: 'The overlay needs a Claude Code session started in the cck terminal.' }
68
- const token = await terminalToken($, t.url)
69
- if (!token) return { deny: `No terminal token for the cck server at ${t.url}. It must run with the terminal enabled.` }
70
- const { title, key, kind, file, base } = input
71
- const body = JSON.stringify({ sessionId: await $.session.id(), title, key, kind, file, base })
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(`${t.url}/api/terminals/${encodeURIComponent(t.terminalId)}/show`, {
66
+ const res = await $.http.fetch(`${url}${path}`, {
75
67
  method: 'POST',
76
- headers: { 'content-type': 'application/json', 'x-terminal-token': token },
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 ${t.url}: ${err instanceof Error ? err.message : String(err)}` }
73
+ return { deny: `Cannot reach cck at ${url}: ${err instanceof Error ? err.message : String(err)}` }
81
74
  }
82
- if (!res.ok) return { deny: errorText(res.status, res.text) }
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
- if (await terminal($)) {
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) => post($, e as unknown as ShowInput))
120
-
121
- on('prompt.attachment', { type: 'skill_listing' }, async ($, e, next) => {
122
- if (await terminal($)) return next(e)
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 = { decision?: ToolCheckDecision; rule?: string; env?: Record<string, string> }
10
+ type EngineOptions = {
11
+ decision?: ToolCheckDecision
12
+ rule?: string
13
+ env?: Record<string, string>
14
+ agents?: AgentInfo[]
15
+ }
11
16
 
12
- // A test hook cannot raise tool.check, so the test raises it (askFor), and the engine's tool.call
13
- // holds the first call until the test answers the terminal prompt.
14
- function engine(on: On, files: Record<string, string>, { decision = 'allow', rule, env = {} }: EngineOptions = {}) {
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()