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 +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 +97 -38
- package/public/style.css +105 -0
- package/public/vendor/claude-hub-sdk.js +3 -1
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()
|
|
@@ -1,26 +1,20 @@
|
|
|
1
1
|
import type { On, ToolCallArgs } from 'claude-code'
|
|
2
|
-
import { expect, test } from 'claude-code/testing'
|
|
3
|
-
import { boardPort,
|
|
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
|
|
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 (
|
|
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
|
|
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(
|
|
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('
|
|
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('
|
|
74
|
-
const board = engine(on,
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
3401
|
-
const
|
|
3402
|
-
const
|
|
3403
|
-
|
|
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
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
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
|
|
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
|
-
|
|
3435
|
-
|
|
3436
|
-
(a
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
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
|
|
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">${
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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,
|