claude-code-kanban 7.4.0 → 7.5.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/lib/terminal.js CHANGED
@@ -251,7 +251,7 @@ function screenText(term) {
251
251
 
252
252
  // Claude Code keeps .claude.json beside ~/.claude when CLAUDE_CONFIG_DIR is unset but
253
253
  // inside the dir when set, so the default dir must stay unset, not be spelled out.
254
- // CCK_URL pins the CLI and the postman to this board: two boards on one config dir share
254
+ // CCK_URL pins the CLI and the doorbell mod to this board: two boards on one config dir share
255
255
  // one server.json, and the last one started owns it.
256
256
  function ptyEnv({ claudeDir, isDefaultDir, noFlicker, cckUrl }) {
257
257
  const env = { ...process.env };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.4.0",
3
+ "version": "7.5.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,8 +1,5 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.25.2",
4
- "description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, skills to drive the board from a session and to follow it",
5
- "experimental": {
6
- "monitors": "./monitors.json"
7
- }
3
+ "version": "3.0.1",
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"
8
5
  }
@@ -0,0 +1,80 @@
1
+ import type { EngineInterface, Register } from 'claude-code'
2
+ import { cckRoot } from './context'
3
+
4
+ // $.http.fetch gives up on a request after 30 s, so the poll asks the board to answer sooner.
5
+ export const WAIT_SEC = 25
6
+ export const RETRY_MS = 10_000
7
+
8
+ export function boardUrl(cckUrl: string | undefined, serverJson: string | undefined) {
9
+ if (cckUrl) return cckUrl.replace(/\/+$/, '')
10
+ try {
11
+ const { port } = JSON.parse(serverJson ?? '')
12
+ return Number.isInteger(port) ? `http://127.0.0.1:${port}` : undefined
13
+ } catch {
14
+ return undefined
15
+ }
16
+ }
17
+
18
+ let cck: Promise<string> | undefined
19
+
20
+ // The engine does not follow $ into an imported function, so only the path rule is shared.
21
+ function cckDir($: EngineInterface) {
22
+ cck ??= Promise.all([$.env.get('CLAUDE_CONFIG_DIR'), $.env.get('HOME'), $.env.get('USERPROFILE')]).then(
23
+ ([configDir, home, userProfile]) => cckRoot(configDir, home, userProfile),
24
+ )
25
+ return cck
26
+ }
27
+
28
+ // Read on every poll, so the mod follows a board that restarted on another port. CCK_URL comes
29
+ // from the board whose terminal started this session, which owns the session's queue.
30
+ async function findBoard($: EngineInterface) {
31
+ const cckUrl = await $.env.get('CCK_URL')
32
+ if (cckUrl) return boardUrl(cckUrl, undefined)
33
+ try {
34
+ return boardUrl(undefined, await $.fs.read(`${await cckDir($)}/server.json`))
35
+ } catch {
36
+ return undefined
37
+ }
38
+ }
39
+
40
+ async function poll($: EngineInterface, url: string, sessionId: string, first: boolean) {
41
+ const query = `wait=${WAIT_SEC}${first ? '&first=1' : ''}`
42
+ const r = await $.http.fetch(`${url}/api/sessions/${encodeURIComponent(sessionId)}/events?${query}`)
43
+ if (!r.ok) throw new Error(`HTTP ${r.status}`)
44
+ const { events } = JSON.parse(r.text) as { events?: unknown }
45
+ return Array.isArray(events) ? events.filter((e): e is string => typeof e === 'string') : []
46
+ }
47
+
48
+ // The session id changes on /clear, so it is read for every poll. The first poll for an id drops
49
+ // what the board queued before this session listened: a move from hours ago is not an instruction.
50
+ async function listen($: EngineInterface) {
51
+ let attached: string | undefined
52
+ for (;;) {
53
+ const [url, sessionId] = await Promise.all([findBoard($), $.session.id()])
54
+ let events: string[] | undefined
55
+ try {
56
+ if (url) events = await poll($, url, sessionId, attached !== sessionId)
57
+ } catch {}
58
+ if (!events) {
59
+ await $.clock.sleep(RETRY_MS)
60
+ continue
61
+ }
62
+ attached = sessionId
63
+ // A submit waits until the session is idle, and one prompt keeps a burst of moves in one turn.
64
+ if (events.length) await $.prompt.submit({ text: events.join('\n') }).catch(() => {})
65
+ }
66
+ }
67
+
68
+ let listening = false
69
+
70
+ // Only a session with a person at the prompt listens: a -p run ends on its own and must not wait on an open poll.
71
+ export const register: Register = on => {
72
+ on('session.start', { isInteractive: true }, async ($, e, next) => {
73
+ const result = await next(e)
74
+ if (!listening) {
75
+ listening = true
76
+ void listen($)
77
+ }
78
+ return result
79
+ })
80
+ }
@@ -1,8 +1,10 @@
1
1
  import type { Register } from 'claude-code'
2
2
  import { register as activity } from './activity'
3
3
  import { register as context } from './context'
4
+ import { register as doorbell } from './doorbell'
4
5
 
5
6
  export const register: Register = (on, options) => {
6
7
  context(on, options)
7
8
  activity(on, options)
9
+ doorbell(on, options)
8
10
  }
@@ -19,7 +19,7 @@ This session id is `${CLAUDE_SESSION_ID}`. `--session` defaults to it through `$
19
19
  | `session` | `list` · `search <text>` · `open <id>` focuses it · `view <id>` stats and transcript path · `plan <id>` · `agents <id>` · `pin <id>` |
20
20
  | `task` | `list` for a session, a project or the whole board |
21
21
  | `project` | `list` |
22
- | `dispatch` | `start`, `list` — run `/claude-code-kanban:dispatch` instead |
22
+ | `dispatch` | `start`, `list`, `end` — run `/claude-code-kanban:dispatch` instead |
23
23
 
24
24
  ## Run a command
25
25
 
@@ -38,8 +38,6 @@ To show the user a page, a prototype or a collage of screenshots, write it as HT
38
38
 
39
39
  Read its transcript. `session view <id>` and `session search <text> --json` print the path.
40
40
 
41
- To have card moves arrive in this session as instructions, the user types `/claude-code-kanban:follow`.
42
-
43
41
  ## Troubleshooting
44
42
 
45
43
  - **"Cannot reach cck server…"** → the error names the config dir and the port it tried. Ask the user to start the server with `claude-code-kanban`. If it runs elsewhere, set `PORT=<n>` or `CCK_URL=<url>` when invoking the CLI.
@@ -0,0 +1,74 @@
1
+ import type { On } from 'claude-code'
2
+ import { expect, mock, test } from 'claude-code/testing'
3
+ import { boardUrl, RETRY_MS } from '../hooks/doorbell'
4
+
5
+ test('the board URL comes from CCK_URL, else from server.json', async () => {
6
+ expect(boardUrl('http://127.0.0.1:4000/', '{"port":3541}')).toBe('http://127.0.0.1:4000')
7
+ expect(boardUrl(undefined, '{"port":3541}')).toBe('http://127.0.0.1:3541')
8
+ expect(boardUrl(undefined, '{"port":"3541"}')).toBe(undefined)
9
+ expect(boardUrl(undefined, 'not json')).toBe(undefined)
10
+ expect(boardUrl(undefined, undefined)).toBe(undefined)
11
+ })
12
+
13
+ function engine(on: On, replies: (() => { status: number; events?: string[] })[]) {
14
+ const urls: string[] = []
15
+ const prompts: string[] = []
16
+ let sessionId = 'sid-1'
17
+ on('fs.read', (_$, e) => {
18
+ if (e.path.replaceAll('\\', '/') === 'C:/cfg/.cck/server.json') return { value: '{"port":3541}' }
19
+ throw new Error(`ENOENT: ${e.path}`)
20
+ })
21
+ on('env.get', (_$, e) => ({ value: { CLAUDE_CONFIG_DIR: 'C:/cfg' }[e.name] }))
22
+ on('session.id', () => ({ value: sessionId }))
23
+ on('session.start', (_$, e) => ({ cwd: e.cwd }))
24
+ on('http.fetch', async (_$, e) => {
25
+ urls.push(e.url)
26
+ const reply = replies.shift()
27
+ if (!reply) return new Promise(() => {})
28
+ const { status, events = [] } = reply()
29
+ return { value: { status, ok: status < 300, headers: {}, text: JSON.stringify({ events }) } }
30
+ })
31
+ on('prompt.submit', (_$, e) => {
32
+ prompts.push(e.text)
33
+ return { text: e.text }
34
+ })
35
+ return { urls, prompts, clear: () => (sessionId = 'sid-2') }
36
+ }
37
+
38
+ test('a -p run does not poll the board', async ($, on) => {
39
+ const clock = mock.clock(on)
40
+ const board = engine(on, [])
41
+ await $.session.start({ cwd: 'C:/proj', surface: null, isInteractive: false })
42
+ await clock.settle()
43
+ expect(board.urls).toEqual([])
44
+ })
45
+
46
+ test('polls the board, submits a burst as one prompt, and drops the backlog for a new session id', async ($, on) => {
47
+ const clock = mock.clock(on)
48
+ const replies = [
49
+ () => ({ status: 200, events: ['line one', 'line two'] }),
50
+ () => ({ status: 503 }),
51
+ () => {
52
+ board.clear()
53
+ return { status: 200 }
54
+ },
55
+ () => ({ status: 200, events: ['after clear'] }),
56
+ ]
57
+ const board = engine(on, replies)
58
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
59
+ await clock.settle()
60
+
61
+ expect(board.prompts).toEqual(['line one\nline two'])
62
+ expect(board.urls).toEqual([
63
+ 'http://127.0.0.1:3541/api/sessions/sid-1/events?wait=25&first=1',
64
+ 'http://127.0.0.1:3541/api/sessions/sid-1/events?wait=25',
65
+ ])
66
+
67
+ await clock.advance(RETRY_MS)
68
+ expect(board.urls.slice(2)).toEqual([
69
+ 'http://127.0.0.1:3541/api/sessions/sid-1/events?wait=25',
70
+ 'http://127.0.0.1:3541/api/sessions/sid-2/events?wait=25&first=1',
71
+ 'http://127.0.0.1:3541/api/sessions/sid-2/events?wait=25',
72
+ ])
73
+ expect(board.prompts).toEqual(['line one\nline two', 'after clear'])
74
+ })