claude-code-kanban 7.17.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.17.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",
@@ -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()
@@ -1,26 +1,20 @@
1
1
  import type { On, ToolCallArgs } from 'claude-code'
2
- import { expect, test } from 'claude-code/testing'
3
- import { boardPort, dropSkillLine, shownText, TOOL } from '../hooks/show'
2
+ import { expect, mock, test } from 'claude-code/testing'
3
+ import { boardPort, CLAIM_OFF_TERMINAL, paneText, shownText, TOOL } from '../hooks/show'
4
4
 
5
5
  const CCK_URL = 'http://127.0.0.1:4100'
6
6
  const TERMINAL_ID = 'term-1'
7
7
  const TOKEN_FILE = 'C:/cfg/.cck/terminal-tokens/4100.json'
8
- const LISTING = [
9
- 'The following skills are available for use with the Skill tool:',
10
- '',
11
- '- claude-code-kanban:dispatch',
12
- '- claude-code-kanban:show',
13
- '- claude-code-kanban:kanban: Kanban board CLI',
14
- '- meta:show-me: Help the user understand the current topic visually',
15
- ].join('\n')
8
+ const SERVER_FILE = 'C:/cfg/.cck/server.json'
16
9
 
17
10
  type Reply = { status: number; body: unknown } | Error
18
11
  type Request = { url: string; method?: string; headers?: Record<string, string>; body?: unknown }
19
12
 
20
13
  function engine(on: On, env: Record<string, string>, reply?: Reply) {
21
- const registered: string[] = []
14
+ const registered: { name: string; required: unknown }[] = []
22
15
  const requests: Request[] = []
23
- const files: Record<string, string> = { [TOKEN_FILE]: '{"token":"tok-abc"}' }
16
+ const files: Record<string, string> = { [TOKEN_FILE]: '{"token":"tok-abc"}', [SERVER_FILE]: '{"pid":1,"port":4100}' }
17
+ mock.clock(on)
24
18
  on('env.get', (_$, e) => ({ value: { CLAUDE_CONFIG_DIR: 'C:/cfg', ...env }[e.name] }))
25
19
  on('fs.read', (_$, e) => {
26
20
  const text = files[e.path.replaceAll('\\', '/')]
@@ -30,12 +24,11 @@ function engine(on: On, env: Record<string, string>, reply?: Reply) {
30
24
  on('session.id', () => ({ value: 'sid-1' }))
31
25
  on('session.start', (_$, e) => ({ cwd: e.cwd }))
32
26
  on('tool.register', (_$, e) => {
33
- registered.push(e.name)
27
+ registered.push({ name: e.name, required: (e.inputSchema as { required?: unknown }).required })
34
28
  return { value: { tool: `mcp__claude-code-kanban__${e.name}` } }
35
29
  })
36
- on('prompt.attachment', (_$, e) => ({ text: e.text }))
37
30
  on('http.fetch', (_$, e) => {
38
- if (!e.url.includes('/show')) return new Promise(() => {})
31
+ if (!/\/show$|\/api\/panes\//.test(e.url)) return new Promise(() => {})
39
32
  const { method, headers, body } = (e.init ?? {}) as Omit<Request, 'url'>
40
33
  requests.push({ url: e.url, method, headers, body: typeof body === 'string' ? JSON.parse(body) : body })
41
34
  if (!reply) throw new Error('no reply')
@@ -46,10 +39,9 @@ function engine(on: On, env: Record<string, string>, reply?: Reply) {
46
39
  }
47
40
 
48
41
  const GATE = { CCK_URL, CCK_TERMINAL_ID: TERMINAL_ID }
49
- const listing = { type: 'skill_listing', text: LISTING, origin: { kind: 'engine' } } as const
50
42
  const call = (input: Record<string, unknown>) => ({ tool: TOOL, ...input }) as ToolCallArgs
51
43
 
52
- test('helpers: the port of CCK_URL, the answer line, the listing filter', async () => {
44
+ test('helpers: the port of CCK_URL, the answer lines', async () => {
53
45
  expect(boardPort('http://127.0.0.1:4100')).toBe('4100')
54
46
  expect(boardPort('http://localhost:3541/')).toBe('3541')
55
47
  expect(boardPort('http://localhost')).toBe(undefined)
@@ -58,24 +50,67 @@ test('helpers: the port of CCK_URL, the answer line, the listing filter', async
58
50
  expect(shownText({ title: 'Plan', key: 'plan', index: 1, count: 1, path: 'C:/s/p1.md' }, true)).toBe(
59
51
  'Card "Plan" (plan, 1/1) is ready. Write it to C:/s/p1.md; the card refreshes on each save.',
60
52
  )
61
- expect(dropSkillLine(LISTING)).toBe(LISTING.replace('- claude-code-kanban:show\n', ''))
62
- expect(dropSkillLine('- claude-code-kanban:show: Show a diagram\n- x')).toBe('- x')
53
+ expect(paneText('Plan', false)).toBe('The board already has a tab for this file; it refreshes on each save.')
63
54
  })
64
55
 
65
- test('gate off: no tool, and the skill line is dropped', async ($, on) => {
56
+ test('in the terminal: the overlay tool, file optional', async ($, on) => {
57
+ const board = engine(on, GATE)
58
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
59
+ expect(board.registered).toEqual([{ name: 'show', required: ['title'] }])
60
+ })
61
+
62
+ test('outside the terminal: the pane tool, file required', async ($, on) => {
66
63
  const board = engine(on, { CCK_URL })
67
64
  await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
68
- expect(board.registered).toEqual([])
69
- const { text } = await $.prompt.attachment(listing)
70
- expect(text).toBe(LISTING.replace('- claude-code-kanban:show\n', ''))
65
+ expect(board.registered).toEqual([{ name: 'show', required: ['title', 'file'] }])
71
66
  })
72
67
 
73
- test('gate on: the tool is registered and the skill line stays', async ($, on) => {
74
- const board = engine(on, GATE)
68
+ test('outside the terminal: a file post adds a show pane on the board of server.json', async ($, on) => {
69
+ const board = engine(on, {}, { status: 200, body: { pane: { id: 'p1' }, added: true } })
70
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
71
+ const res = await $.tool.call(call({ title: 'Plan', file: 'C:/s/plan.html' }))
72
+ expect(res.result).toBe(paneText('Plan', true))
73
+ expect(board.requests).toEqual([
74
+ {
75
+ url: 'http://127.0.0.1:4100/api/panes/sid-1',
76
+ method: 'POST',
77
+ headers: { 'content-type': 'application/json' },
78
+ body: { target: 'C:/s/plan.html', title: 'Plan', show: true },
79
+ },
80
+ ])
81
+ })
82
+
83
+ test('outside the terminal: the same file again answers that the tab exists', async ($, on) => {
84
+ engine(on, {}, { status: 200, body: { pane: { id: 'p1' }, added: false } })
75
85
  await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
76
- expect(board.registered).toEqual(['show'])
77
- const { text } = await $.prompt.attachment(listing)
78
- expect(text).toBe(LISTING)
86
+ const res = await $.tool.call(call({ title: 'Plan', file: 'C:/s/plan.html' }))
87
+ expect(res.result).toBe(paneText('Plan', false))
88
+ })
89
+
90
+ test('outside the terminal: a claim sends nothing and asks for a file', async ($, on) => {
91
+ const board = engine(on, {})
92
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
93
+ const res = await $.tool.call(call({ title: 'Plan' }))
94
+ expect(res.result).toBe(CLAIM_OFF_TERMINAL)
95
+ expect(board.requests).toEqual([])
96
+ })
97
+
98
+ test('outside the terminal: no board, no request, an error that says so', async ($, on) => {
99
+ // CCK_URL keeps the doorbell on its open poll: without a board it sleeps, and the unload rejects that sleep.
100
+ const board = engine(on, { CCK_URL })
101
+ delete board.files[SERVER_FILE]
102
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
103
+ const res = await $.tool.call(call({ title: 'Plan', file: 'C:/s/plan.md' }))
104
+ expect(res.deny).toContain('not running')
105
+ expect(board.requests).toEqual([])
106
+ })
107
+
108
+ test('outside the terminal: a board refusal reaches the model with its text', async ($, on) => {
109
+ const error = 'Not a previewable text, markdown, HTML or image file'
110
+ engine(on, {}, { status: 400, body: { error } })
111
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
112
+ const res = await $.tool.call(call({ title: 'Zip', file: 'C:/s/a.zip' }))
113
+ expect(res.deny).toBe(error)
79
114
  })
80
115
 
81
116
  test('a -p run gets no tool', async ($, on) => {
package/public/app.js CHANGED
@@ -571,6 +571,9 @@ function sessionBarClass(s) {
571
571
  function isActiveSession(s) {
572
572
  return !isWaitingSession(s) && (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents);
573
573
  }
574
+ function sessionState(s) {
575
+ return isWaitingSession(s) ? 'waiting' : isActiveSession(s) ? 'working' : 'idle';
576
+ }
574
577
  // hasRecentLog carries the server's registry-idle correction: an open-but-idle
575
578
  // terminal keeps touching its JSONL, so mtime alone is not proof of activity.
576
579
  function isSessionLive(s) {
@@ -815,6 +818,7 @@ async function fetchTasks(sessionId) {
815
818
  //#region TIMINGS
816
819
  const WAITING_TTL_MS = 30 * 60 * 1000;
817
820
  const AGENT_LOG_MAX = 8;
821
+ const ZEN_AGENTS_MAX = 5;
818
822
  const LIVE_INDICATOR_MS = 10 * 1000;
819
823
  const JUST_NOW_MS = 60 * 1000;
820
824
  //#endregion
@@ -3397,17 +3401,63 @@ function formatDuration(ms) {
3397
3401
  //#endregion
3398
3402
 
3399
3403
  //#region AGENTS
3400
- function renderAgentFooter() {
3401
- const footer = document.getElementById('agent-footer');
3402
- const content = document.getElementById('agent-footer-content');
3403
- const label = document.getElementById('agent-footer-label');
3404
+ function agentTypeParts(a) {
3405
+ const raw = a.type || 'unknown';
3406
+ const colonIdx = raw.indexOf(':');
3407
+ return colonIdx > 0
3408
+ ? { raw, ns: raw.substring(0, colonIdx + 1), name: raw.substring(colonIdx + 1) }
3409
+ : { raw, ns: '', name: raw };
3410
+ }
3411
+
3412
+ function agentTaskText(a) {
3413
+ if (a.description) return a.description;
3414
+ const prompt = stripAnsi(stripTeammateWrapper((a.prompt || '').trim())).replace(/[\r\n]+/g, ' ');
3415
+ return prompt || agentTypeParts(a).name;
3416
+ }
3417
+
3418
+ function agentElapsed(a, now) {
3419
+ return a.status === 'stopped' && a.stoppedAt
3420
+ ? new Date(a.stoppedAt).getTime() - new Date(a.startedAt || a.stoppedAt).getTime()
3421
+ : now - new Date(a.startedAt || a.updatedAt).getTime();
3422
+ }
3423
+
3424
+ function agentIdentHtml(a) {
3425
+ const { raw, ns, name } = agentTypeParts(a);
3426
+ const fullModel = agentModel(a);
3427
+ const model = shortModelName(fullModel);
3428
+ const modelHtml = model
3429
+ ? `<span class="agent-card-model" title="${escapeHtml(fullModel)}">${escapeHtml(model)}</span><span class="agent-ident-sep">·</span>`
3430
+ : '';
3431
+ return `${modelHtml}<span class="agent-type-name" title="${escapeHtml(raw)}">${ns ? `<span class="agent-type-ns">${escapeHtml(ns)}</span>` : ''}${escapeHtml(name)}</span>`;
3432
+ }
3433
+
3434
+ // The count names every running agent; the rows stop at ZEN_AGENTS_MAX, and the Agents Log
3435
+ // below the board lists the rest.
3436
+ function zenAgentsHtml(agents) {
3437
+ const active = agents.filter((a) => a.status === 'active');
3438
+ if (!active.length) return '';
3404
3439
  const now = Date.now();
3440
+ const rows = active.slice(0, ZEN_AGENTS_MAX).map((a) => {
3441
+ const task = truncateAtWord(agentTaskText(a), 200);
3442
+ return `<button type="button" class="zen-agent" title="${escapeHtml(task)}" onclick="showAgentModal('${escAttrJs(a.agentId)}')">
3443
+ <span class="agent-dot active"></span>
3444
+ <span class="zen-agent-text"><span class="zen-agent-task">${escapeHtml(task)}</span><span class="agent-ident-row">${agentIdentHtml(a)}</span></span>
3445
+ <span class="zen-agent-time">${formatDuration(agentElapsed(a, now))}</span>
3446
+ </button>`;
3447
+ });
3448
+ return `<div class="panel-section-header"><span>Agents</span><span class="panel-section-count">${active.length}</span></div>${rows.join('')}`;
3449
+ }
3405
3450
 
3406
- const agents = currentAgents;
3407
- // Filter shutdown ghosts: for same-type agents, keep if they overlapped (parallel)
3408
- // or started >30s after previous stopped (legitimate re-spawn). Filter the rest.
3451
+ function renderZenAgents(agents) {
3452
+ const el = zenMode && sessionsList.querySelector('.zen-agents');
3453
+ if (el) el.innerHTML = zenAgentsHtml(agents);
3454
+ }
3455
+
3456
+ // Filter shutdown ghosts: for same-type agents, keep if they overlapped (parallel)
3457
+ // or started >30s after previous stopped (legitimate re-spawn). Filter the rest.
3458
+ function visibleAgents() {
3409
3459
  const byType = {};
3410
- for (const a of agents) {
3460
+ for (const a of currentAgents) {
3411
3461
  const groupKey = a.agentName || a.type;
3412
3462
  if (!byType[groupKey]) byType[groupKey] = [];
3413
3463
  byType[groupKey].push(a);
@@ -3431,13 +3481,21 @@ function renderAgentFooter() {
3431
3481
  }
3432
3482
  // Sort: active/idle first, then by updatedAt desc
3433
3483
  const statusOrder = { active: 0, idle: 1, stopped: 2 };
3434
- const visible = filtered
3435
- .sort(
3436
- (a, b) =>
3437
- (statusOrder[a.status] ?? 2) - (statusOrder[b.status] ?? 2) ||
3438
- new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0),
3439
- )
3440
- .slice(0, AGENT_LOG_MAX);
3484
+ return filtered.sort(
3485
+ (a, b) =>
3486
+ (statusOrder[a.status] ?? 2) - (statusOrder[b.status] ?? 2) ||
3487
+ new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0),
3488
+ );
3489
+ }
3490
+
3491
+ function renderAgentFooter() {
3492
+ const footer = document.getElementById('agent-footer');
3493
+ const content = document.getElementById('agent-footer-content');
3494
+ const label = document.getElementById('agent-footer-label');
3495
+ const now = Date.now();
3496
+ const agents = visibleAgents();
3497
+ renderZenAgents(agents);
3498
+ const visible = agents.slice(0, AGENT_LOG_MAX);
3441
3499
 
3442
3500
  const permFresh = isWaitingFresh();
3443
3501
 
@@ -3461,23 +3519,14 @@ function renderAgentFooter() {
3461
3519
  permHtml +
3462
3520
  visible
3463
3521
  .map((a) => {
3464
- const elapsed =
3465
- a.status === 'stopped' && a.stoppedAt
3466
- ? new Date(a.stoppedAt).getTime() - new Date(a.startedAt || a.stoppedAt).getTime()
3467
- : now - new Date(a.startedAt || a.updatedAt).getTime();
3522
+ const elapsed = agentElapsed(a, now);
3468
3523
  const statusText =
3469
3524
  a.status === 'stopped'
3470
3525
  ? `stopped · ${formatDuration(elapsed)}`
3471
3526
  : a.status === 'idle'
3472
3527
  ? `idle · ${formatDuration(elapsed)}`
3473
3528
  : `active · ${formatDuration(elapsed)}`;
3474
- const descText = a.description || '';
3475
- const promptTrimmed = stripAnsi(stripTeammateWrapper((a.prompt || '').trim())).replace(/[\r\n]+/g, ' ');
3476
- const rawType = a.type || 'unknown';
3477
- const colonIdx = rawType.indexOf(':');
3478
- const typeNs = colonIdx > 0 ? rawType.substring(0, colonIdx + 1) : '';
3479
- const typeName = colonIdx > 0 ? rawType.substring(colonIdx + 1) : rawType;
3480
- const displayText = descText || promptTrimmed || typeName;
3529
+ const displayText = agentTaskText(a);
3481
3530
  const taskHtml = `<div class="agent-task" title="${escapeHtml(displayText)}">${escapeHtml(displayText)}</div>`;
3482
3531
  const agentNameVal = a.agentName || null;
3483
3532
  const nameColor = agentNameVal ? getOwnerColor(agentNameVal) : null;
@@ -3487,15 +3536,10 @@ function renderAgentFooter() {
3487
3536
  const agentColor = resolveNamedColor(a.color);
3488
3537
  const colorStyle = agentColor ? ` style="border-left:3px solid ${agentColor.color}"` : '';
3489
3538
  const selectedClass = a.agentId === currentAgentModalId ? ' selected' : '';
3490
- const fullModel = agentModel(a);
3491
- const cardModel = shortModelName(fullModel);
3492
- const modelHtml = cardModel
3493
- ? `<span class="agent-card-model" title="${escapeHtml(fullModel)}">${escapeHtml(cardModel)}</span><span class="agent-ident-sep">·</span>`
3494
- : '';
3495
3539
  return `<div class="agent-card${selectedClass}" data-agent-id="${escapeHtml(a.agentId)}"${colorStyle} onclick="showAgentModal('${escAttrJs(a.agentId)}')">
3496
3540
  ${agentLogButton(a.agentId, 'agent-card-log-btn')}
3497
3541
  ${taskHtml}
3498
- <div class="agent-ident-row">${modelHtml}<span class="agent-type-name" title="${escapeHtml(rawType)}">${typeNs ? `<span class="agent-type-ns">${escapeHtml(typeNs)}</span>` : ''}${escapeHtml(typeName)}</span>${nameBadgeHtml}</div>
3542
+ <div class="agent-ident-row">${agentIdentHtml(a)}${nameBadgeHtml}</div>
3499
3543
  <div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span></div>
3500
3544
  </div>`;
3501
3545
  })
@@ -3889,6 +3933,8 @@ function renderSessions() {
3889
3933
  // and still offers everything, or there would be no way to switch sessions without leaving zen.
3890
3934
  // Group zen renders only its subtree's buckets, so it takes every session unfiltered.
3891
3935
  const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
3936
+ sessionsList.classList.toggle('zen', !!zenSession);
3937
+ sessionsList.dataset.zenState = zenSession ? sessionState(zenSession) : '';
3892
3938
  let filteredSessions;
3893
3939
  if (zenGroup) filteredSessions = applyStableSessionOrder(sessions);
3894
3940
  else if (zenMode) filteredSessions = zenSession ? [zenSession] : [];
@@ -4064,7 +4110,8 @@ function renderSessions() {
4064
4110
  ${renderArtifactsHtml(zenSession.id)}
4065
4111
  ${renderZenBookmarksHtml(zenSession.id)}
4066
4112
  </div>
4067
- ${zenSession.hasWorkflow ? renderWorkflowLiveHtml(zenSession.id) : ''}`;
4113
+ ${zenSession.hasWorkflow ? renderWorkflowLiveHtml(zenSession.id) : ''}
4114
+ <div class="zen-agents">${zenAgentsHtml(visibleAgents())}</div>`;
4068
4115
  bindLinkedDocsHandlers(sessionsList.querySelector('.linked-docs-section'), zenSession.id);
4069
4116
  artifactsSection.load(zenSession.id);
4070
4117
  scratchFilesSection.load(zenSession.id);
@@ -11569,6 +11616,12 @@ function toggleZenMode() {
11569
11616
  if (!leavingSessionZen) fetchSessions(false);
11570
11617
  }
11571
11618
 
11619
+ function applyHubSettings(s) {
11620
+ sessionsList.classList.toggle('zen-centered', s.zenCentered === true);
11621
+ if (s.zenPattern) sessionsList.dataset.zenPattern = s.zenPattern;
11622
+ else delete sessionsList.dataset.zenPattern;
11623
+ }
11624
+
11572
11625
  function renderZenState() {
11573
11626
  const btn = document.getElementById('zen-mode-btn');
11574
11627
  if (!btn) return;
@@ -16067,17 +16120,18 @@ async function terminalFetch(url, method, body) {
16067
16120
  return res.status === 401 && (await refreshTerminalToken()) ? send() : res;
16068
16121
  }
16069
16122
 
16070
- const TERMINAL_STATE_LABEL = { busy: 'working', wait: 'waiting', idle: 'idle' };
16123
+ const TERMINAL_DOT_STATE = { working: 'busy', waiting: 'wait', idle: 'idle' };
16071
16124
 
16072
16125
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
16073
16126
  async function endAllTerminals() {
16074
16127
  const items = [...document.querySelectorAll('#terminal-manager-body [data-end]')].map((b) => {
16075
16128
  const id = b.dataset.end;
16076
16129
  const session = sessions.find((s) => s.id === id);
16077
- const state = !session ? 'idle' : isWaitingSession(session) ? 'wait' : isActiveSession(session) ? 'busy' : 'idle';
16130
+ const label = session ? sessionState(session) : 'idle';
16131
+ const state = TERMINAL_DOT_STATE[label];
16078
16132
  const started = Number(b.dataset.started);
16079
16133
  const meta = [
16080
- TERMINAL_STATE_LABEL[state],
16134
+ label,
16081
16135
  started && `up ${formatDuration(Date.now() - started)}`,
16082
16136
  formatTerminalProc(terminalProcStats[id]),
16083
16137
  ]
@@ -16775,9 +16829,13 @@ function withHeadFirst(content, head) {
16775
16829
  return content.slice(0, cut) + inject + content.slice(cut);
16776
16830
  }
16777
16831
 
16832
+ // The board's scrollbar rules do not reach into a frame, which would show the OS default.
16833
+ // Grey, not a board token, because the page paints its own background.
16834
+ const FRAME_SCROLLBAR_CSS = ':where(html){scrollbar-width:thin;scrollbar-color:rgba(128,128,128,.45) transparent}';
16835
+
16778
16836
  // A whole document gets the tokens and base styles first in its head, so its own styles win.
16779
16837
  function showSrcdoc(content, restoreY) {
16780
- const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}</style>`;
16838
+ const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}${FRAME_SCROLLBAR_CSS}</style>`;
16781
16839
  const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${modalZoom});</script>${REVIEW_BRIDGE_TAG}`;
16782
16840
  if (!/^\s*(<!doctype|<html)/i.test(content)) {
16783
16841
  return `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${content}${bridge}</body></html>`;
@@ -16788,7 +16846,7 @@ function showSrcdoc(content, restoreY) {
16788
16846
  // An HTML file preview gets only the tokens, first in its head: its own :root rules win, and
16789
16847
  // a page that declares its defaults in :where(:root) follows the board theme.
16790
16848
  function themedSrcdoc(content) {
16791
- const head = `<style id="cck-tokens">:root{${showTokenCss()}}</style>`;
16849
+ const head = `<style id="cck-tokens">:root{${showTokenCss()}}${FRAME_SCROLLBAR_CSS}</style>`;
16792
16850
  return withHeadFirst(content, head) + THEME_BRIDGE_TAG + REVIEW_BRIDGE_TAG;
16793
16851
  }
16794
16852
 
@@ -18105,6 +18163,7 @@ hub.onActive((active) => {
18105
18163
  pushTerminalClaims();
18106
18164
  });
18107
18165
  hub.onStatus(pushTerminalClaims);
18166
+ hub.onSettings(applyHubSettings);
18108
18167
 
18109
18168
  // session.changed goes out once the switch settles, so arrowing through the list sends one event.
18110
18169
  let publishTimer = 0;
package/public/style.css CHANGED
@@ -834,6 +834,111 @@ body::before {
834
834
  padding: 0 14px 12px;
835
835
  }
836
836
 
837
+ .sessions-list.zen {
838
+ --zen-dot: var(--border);
839
+ display: flex;
840
+ flex-direction: column;
841
+ }
842
+
843
+ .sessions-list.zen[data-zen-state='working'] {
844
+ --zen-dot: color-mix(in srgb, var(--success) 45%, var(--border));
845
+ }
846
+
847
+ .sessions-list.zen[data-zen-state='waiting'] {
848
+ --zen-dot: color-mix(in srgb, var(--warning) 55%, var(--border));
849
+ }
850
+
851
+ /* The spacers fill the free space. Centered, they split it 1:2, so a growing panel moves the card up by a third of the growth, not a half. With no free space they are 0 and the list scrolls from the top. */
852
+ .sessions-list.zen::after,
853
+ .sessions-list.zen.zen-centered::before {
854
+ content: '';
855
+ flex: 1 0 0;
856
+ }
857
+
858
+ /* Dots unless the hub's zenPattern setting names another pattern. Line patterns use a lighter color, because a line has more ink than a dot. */
859
+ .sessions-list.zen {
860
+ --zen-line: color-mix(in srgb, var(--zen-dot) 50%, transparent);
861
+ --zen-fill: radial-gradient(circle, var(--zen-dot) 1px, transparent 1.5px) 0 0 / 20px 20px;
862
+ --zen-glow-y: 0;
863
+ }
864
+
865
+ .sessions-list.zen[data-zen-pattern='diagonal'] {
866
+ --zen-fill: repeating-linear-gradient(-45deg, var(--zen-line) 0 1px, transparent 1px 14px);
867
+ }
868
+
869
+ .sessions-list.zen[data-zen-pattern='rings'] {
870
+ --zen-fill: radial-gradient(circle, transparent 2.5px, var(--zen-line) 3px 3.5px, transparent 4px) 0 0 / 28px 28px;
871
+ }
872
+
873
+ .sessions-list.zen[data-zen-pattern='grid'] {
874
+ --zen-fill:
875
+ linear-gradient(var(--zen-line) 1px, transparent 1px) 0 0 / 24px 24px,
876
+ linear-gradient(90deg, var(--zen-line) 1px, transparent 1px) 0 0 / 24px 24px;
877
+ }
878
+
879
+ /* The pattern glows out from the card. The glow has a fixed height, so it looks the same in a short space and a tall one. */
880
+ .sessions-list.zen:not([data-zen-pattern='none'])::after,
881
+ .sessions-list.zen.zen-centered:not([data-zen-pattern='none'])::before {
882
+ background: var(--zen-fill);
883
+ mask-image: radial-gradient(110% 260px at 50% var(--zen-glow-y), rgb(0 0 0 / 0.9), transparent);
884
+ }
885
+
886
+ .sessions-list.zen.zen-centered::before {
887
+ --zen-glow-y: 100%;
888
+ }
889
+
890
+ .sessions-list.zen.zen-centered::after {
891
+ flex-grow: 2;
892
+ }
893
+
894
+ .sessions-list.zen > * {
895
+ flex-shrink: 0;
896
+ }
897
+
898
+ /* After the ::after spacer, so the agents sit at the bottom of the free space. */
899
+ .zen-agents {
900
+ order: 1;
901
+ display: flex;
902
+ flex-direction: column;
903
+ gap: 2px;
904
+ margin-top: 6px;
905
+ }
906
+ .zen-agents:empty {
907
+ display: none;
908
+ }
909
+ .zen-agent {
910
+ display: grid;
911
+ grid-template-columns: 5px 1fr auto;
912
+ align-items: center;
913
+ gap: 8px;
914
+ padding: 5px 8px;
915
+ background: none;
916
+ border: none;
917
+ border-radius: 6px;
918
+ font: inherit;
919
+ font-size: 12px;
920
+ text-align: left;
921
+ cursor: pointer;
922
+ }
923
+ .zen-agent:hover {
924
+ background: var(--bg-hover);
925
+ }
926
+ .zen-agent-text {
927
+ min-width: 0;
928
+ }
929
+ .zen-agent-task {
930
+ display: block;
931
+ overflow: hidden;
932
+ text-overflow: ellipsis;
933
+ white-space: nowrap;
934
+ color: var(--text-primary);
935
+ }
936
+ .zen-agent-time {
937
+ font-size: 10.5px;
938
+ font-variant-numeric: tabular-nums;
939
+ color: var(--text-tertiary);
940
+ }
941
+
837
942
  .session-item {
838
943
  position: relative;
839
944
  display: block;
@@ -1,4 +1,4 @@
1
- // claude-hub-sdk 1.6.0 (sha256 71a58022516d). Copied by npm run sdk:sync in claude-code-hub. Do not edit.
1
+ // claude-hub-sdk 1.7.0 (sha256 904e53d87ec4). Copied by npm run sdk:sync in claude-code-hub. Do not edit.
2
2
  // Claude Code Hub SDK: the one rule that names a key press, shared by the hub page, client.js and
3
3
  // stub.js. The hub serves it to its page as /sdk/keys.js; mount() and sdk:sync put it in front of
4
4
  // client.js and stub.js, so an app still loads one file.
@@ -60,6 +60,8 @@ if (typeof module === 'object' && module.exports) module.exports = ClaudeHubKeys
60
60
  status: 'standalone',
61
61
  themes: [],
62
62
  inHub: false,
63
+ settings: null,
64
+ onSettings: () => off,
63
65
  onStatus: () => off,
64
66
  onActive: () => off,
65
67
  onThemes: () => off,