claude-code-kanban 7.3.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 +21 -22
- 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 +1180 -1099
- package/public/index.html +23 -38
- package/public/style.css +199 -37
- 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
|
@@ -23,7 +23,7 @@ const priority = require('./priority');
|
|
|
23
23
|
|
|
24
24
|
const WS_PATH = '/api/terminal/ws';
|
|
25
25
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
26
|
-
const MODES = new Set(['auto', 'resume', '
|
|
26
|
+
const MODES = new Set(['auto', 'resume', 'shell', 'new', 'pick']);
|
|
27
27
|
const PICK_POLL_MS = 1000;
|
|
28
28
|
// How long before the PTY a registry entry may claim to have started (clock skew between the two).
|
|
29
29
|
const PICK_START_SLACK_MS = 2000;
|
|
@@ -59,7 +59,6 @@ const RESTORE_GAP_MS = 2000;
|
|
|
59
59
|
// The hub stops cck with `taskkill /f /t`, which can deliver the PTY exits to cck a moment
|
|
60
60
|
// before cck itself dies. The delay keeps that teardown out of the saved list.
|
|
61
61
|
const SAVE_DELAY_MS = 1000;
|
|
62
|
-
// A fork runs under its parent's id until claude names the new one, so it has no id to resume.
|
|
63
62
|
const RESTORABLE_MODES = new Set(['resume', 'new']);
|
|
64
63
|
// Quitting claude drops the PTY to its shell, so the PTY's exit cannot tell cck that claude is gone.
|
|
65
64
|
const CLAUDE_EXIT_POLL_MS = 5000;
|
|
@@ -177,10 +176,11 @@ function shellArgs(shell, claudeArgs) {
|
|
|
177
176
|
|
|
178
177
|
function claudeArgsFor(mode, id, spec) {
|
|
179
178
|
if (mode === 'shell') return null;
|
|
180
|
-
if (mode === 'fork') return ['--resume', id, '--fork-session'];
|
|
181
179
|
if (mode === 'pick') return ['--resume'];
|
|
182
180
|
if (mode === 'new') {
|
|
183
|
-
|
|
181
|
+
// claude takes --session-id with --resume only together with --fork-session.
|
|
182
|
+
const fork = spec.forkOf ? ['--resume', spec.forkOf, '--fork-session'] : [];
|
|
183
|
+
const args = [...fork, '--session-id', id, ...(spec.extraArgs || [])];
|
|
184
184
|
if (spec.name) args.push('--name', spec.name);
|
|
185
185
|
if (spec.model) args.push('--model', spec.model);
|
|
186
186
|
// Last, because its value is optional and a following flag must not be taken for it.
|
|
@@ -203,7 +203,9 @@ function extraArgsError(args) {
|
|
|
203
203
|
|
|
204
204
|
// Returns the new-session options from a hello, or a string naming the bad field.
|
|
205
205
|
function parseNewSpec(msg) {
|
|
206
|
-
|
|
206
|
+
const forkOf = msg.forkOf ?? null;
|
|
207
|
+
if (forkOf !== null && (typeof forkOf !== 'string' || !UUID_RE.test(forkOf))) return 'session to fork';
|
|
208
|
+
if (!forkOf && (typeof msg.cwd !== 'string' || !msg.cwd)) return 'folder';
|
|
207
209
|
const name = typeof msg.name === 'string' ? msg.name.trim() : '';
|
|
208
210
|
if (name && !NAME_RE.test(name)) return 'name';
|
|
209
211
|
const worktree = msg.worktree ?? false;
|
|
@@ -222,7 +224,8 @@ function parseNewSpec(msg) {
|
|
|
222
224
|
const extraArgs = msg.extraArgs ?? [];
|
|
223
225
|
const bad = extraArgsError(extraArgs);
|
|
224
226
|
if (bad) return bad;
|
|
225
|
-
|
|
227
|
+
if (forkOf && worktree) return 'worktree: a fork runs in its parent session folder';
|
|
228
|
+
return { cwd: forkOf ? null : msg.cwd, forkOf, name: name || null, worktree, model, taskList, prompt: prompt || null, extraArgs, edit };
|
|
226
229
|
}
|
|
227
230
|
|
|
228
231
|
function samePath(a, b) {
|
|
@@ -248,7 +251,7 @@ function screenText(term) {
|
|
|
248
251
|
|
|
249
252
|
// Claude Code keeps .claude.json beside ~/.claude when CLAUDE_CONFIG_DIR is unset but
|
|
250
253
|
// inside the dir when set, so the default dir must stay unset, not be spelled out.
|
|
251
|
-
// 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
|
|
252
255
|
// one server.json, and the last one started owns it.
|
|
253
256
|
function ptyEnv({ claudeDir, isDefaultDir, noFlicker, cckUrl }) {
|
|
254
257
|
const env = { ...process.env };
|
|
@@ -489,13 +492,7 @@ function createTerminalService(o) {
|
|
|
489
492
|
if (s.claudeExitedAt) return null;
|
|
490
493
|
if (s.claudePid || s.mode === 'pick') return s.claudePid;
|
|
491
494
|
const live = registry || o.liveSessions();
|
|
492
|
-
|
|
493
|
-
if (own || s.mode !== 'fork') {
|
|
494
|
-
s.claudePid = own?.pid;
|
|
495
|
-
return s.claudePid;
|
|
496
|
-
}
|
|
497
|
-
const claimed = new Set([...sessions.values()].map((x) => x.claudePid).filter(Boolean));
|
|
498
|
-
s.claudePid = findPickProcess(live, s, claimed);
|
|
495
|
+
s.claudePid = ownEntry(live, s, s.startedAt)?.pid;
|
|
499
496
|
return s.claudePid;
|
|
500
497
|
}
|
|
501
498
|
|
|
@@ -578,6 +575,7 @@ function createTerminalService(o) {
|
|
|
578
575
|
const s = {
|
|
579
576
|
id, mode, cwd, pty: proc, term, serializer, sockets: new Set(), exited: false, ended: false, paused: false, startedAt: Date.now(),
|
|
580
577
|
name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null, edit: !!spec?.edit,
|
|
578
|
+
forkOf: spec?.forkOf || null,
|
|
581
579
|
};
|
|
582
580
|
if (s.edit) queueText(s, claudeCommand(shell, claudeArgs, spec.prompt), false);
|
|
583
581
|
else if (spec?.prompt) queueText(s, spec.prompt, true);
|
|
@@ -730,14 +728,16 @@ function createTerminalService(o) {
|
|
|
730
728
|
return { code: 4002, status: 400, error: 'cmd cannot take a typed prompt; clear the prompt or set the terminal shell to pwsh or gitbash' };
|
|
731
729
|
}
|
|
732
730
|
}
|
|
733
|
-
|
|
731
|
+
// `claude --resume` finds a fork's parent only from the parent's own project folder.
|
|
732
|
+
const [known, forkCwd] = await Promise.all([o.resolveCwd(id), spec?.forkOf ? o.resolveCwd(spec.forkOf) : null]);
|
|
734
733
|
let cwd = known;
|
|
735
734
|
if (mode === 'new' || mode === 'pick') {
|
|
736
735
|
if (known !== null) return { code: 4006, status: 409, error: 'session id already in use' };
|
|
737
|
-
|
|
736
|
+
cwd = spec?.forkOf ? forkCwd : msg.cwd;
|
|
737
|
+
if (spec?.forkOf && cwd === null) return { code: 4004, status: 404, error: 'unknown session to fork' };
|
|
738
|
+
if (!spec?.forkOf && (typeof cwd !== 'string' || !(await o.isAllowedFolder(cwd)))) {
|
|
738
739
|
return { code: 4004, status: 403, error: 'folder is not a known project or a folder picked in this run' };
|
|
739
740
|
}
|
|
740
|
-
cwd = msg.cwd;
|
|
741
741
|
} else if (mode !== 'shell' && known === null) {
|
|
742
742
|
return { code: 4004, status: 404, error: 'unknown session' };
|
|
743
743
|
}
|
|
@@ -783,7 +783,7 @@ function createTerminalService(o) {
|
|
|
783
783
|
function list() {
|
|
784
784
|
return [...sessions.values()].filter((s) => !s.ended).map((s) => ({
|
|
785
785
|
id: s.id, mode: s.mode, cwd: s.cwd, pid: s.pty.pid, clients: s.sockets.size, startedAt: s.startedAt,
|
|
786
|
-
name: s.name, worktree: s.worktree, edit: s.edit,
|
|
786
|
+
name: s.name, worktree: s.worktree, edit: s.edit, forkOf: s.forkOf,
|
|
787
787
|
}));
|
|
788
788
|
}
|
|
789
789
|
|
|
@@ -827,12 +827,11 @@ function createTerminalService(o) {
|
|
|
827
827
|
}
|
|
828
828
|
|
|
829
829
|
// Pastes into claude's input box and stops there: Enter would also submit whatever the
|
|
830
|
-
// user had half-typed, and over a permission dialog it would answer it. A
|
|
831
|
-
//
|
|
832
|
-
// pending queued prompt owns the input box until it is sent.
|
|
830
|
+
// user had half-typed, and over a permission dialog it would answer it. A pending queued
|
|
831
|
+
// prompt owns the input box until it is sent.
|
|
833
832
|
function paste(id, text) {
|
|
834
833
|
const s = sessions.get(id);
|
|
835
|
-
if (!s || s.ended || s.exited || s.claudeExitedAt || s.onOutput || s.mode === 'shell'
|
|
834
|
+
if (!s || s.ended || s.exited || s.claudeExitedAt || s.onOutput || s.mode === 'shell') return false;
|
|
836
835
|
if (onTrustScreen(s)) return false;
|
|
837
836
|
// Until the user runs the typed command, the input box is the shell's command line.
|
|
838
837
|
if (s.edit && !s.claudePid) return false;
|
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
|
+
})
|