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/README.md +5 -3
- package/cli.js +95 -29
- package/lib/panes.js +26 -5
- package/lib/parsers.js +15 -1
- package/lib/retention.js +25 -0
- package/lib/scratchpad-dir.js +19 -2
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +33 -18
- package/lib/task-dir.js +26 -0
- package/lib/terminal.js +1 -1
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -5
- package/plugin/plugins/claude-code-kanban/hooks/doorbell.ts +80 -0
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +2 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -3
- package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +74 -0
- package/public/app.js +1019 -1001
- package/public/index.html +13 -31
- package/public/style.css +190 -29
- package/server.js +171 -159
- package/plugin/plugins/claude-code-kanban/monitors.json +0 -8
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +0 -82
- package/plugin/plugins/claude-code-kanban/skills/follow/SKILL.md +0 -47
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
|
|
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,8 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, skills to drive the board from a session
|
|
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
|
+
})
|