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/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', 'fork', 'shell', 'new', 'pick']);
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
- const args = ['--session-id', id, ...(spec.extraArgs || [])];
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
- if (typeof msg.cwd !== 'string' || !msg.cwd) return 'folder';
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
- return { cwd: msg.cwd, name: name || null, worktree, model, taskList, prompt: prompt || null, extraArgs, edit };
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 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
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
- const own = ownEntry(live, s, s.startedAt);
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
- const known = await o.resolveCwd(id);
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
- if (typeof msg.cwd !== 'string' || !(await o.isAllowedFolder(msg.cwd))) {
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 fork runs under
831
- // a new session id, so its terminal id does not name the session the text is for. A
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' || s.mode === 'fork') return false;
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,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.3.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
+ })