claude-code-kanban 5.2.0 → 5.4.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.
Files changed (39) hide show
  1. package/README.md +2 -2
  2. package/cli.js +10 -5
  3. package/install.js +43 -123
  4. package/lib/approvals.js +16 -17
  5. package/lib/claude-dir.js +7 -1
  6. package/lib/git-branch.js +47 -0
  7. package/lib/net-guard.js +41 -14
  8. package/lib/parsers.js +96 -59
  9. package/lib/plugin-status.js +1 -1
  10. package/lib/proc-stats.js +82 -0
  11. package/lib/retention.js +12 -9
  12. package/lib/session-cache.js +1 -1
  13. package/lib/terminal.js +19 -5
  14. package/lib/worktrees.js +105 -0
  15. package/package.json +1 -2
  16. package/plugin/.claude-plugin/marketplace.json +1 -1
  17. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
  18. package/plugin/plugins/claude-code-kanban/hooks/activity.ts +266 -0
  19. package/plugin/plugins/claude-code-kanban/hooks/context.ts +77 -0
  20. package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -93
  21. package/plugin/plugins/claude-code-kanban/hooks/index.ts +8 -0
  22. package/plugin/plugins/claude-code-kanban/scripts/postman.js +4 -4
  23. package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +1 -1
  24. package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +0 -1
  25. package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +300 -0
  26. package/plugin/plugins/claude-code-kanban/tests/context.test.ts +72 -0
  27. package/plugin/plugins/claude-code-kanban/tsconfig.json +3 -0
  28. package/public/app.js +661 -380
  29. package/public/index.html +7 -3
  30. package/public/project-match.js +6 -4
  31. package/public/style.css +258 -79
  32. package/public/terminal-frame.js +341 -0
  33. package/public/terminal.html +27 -0
  34. package/public/vendor/claude-hub-sdk.js +13 -3
  35. package/server.js +163 -145
  36. package/skill-guides/dispatch.md +4 -4
  37. package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +0 -135
  38. package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +0 -208
  39. package/plugin/plugins/claude-code-kanban/scripts/context-status.sh +0 -18
@@ -0,0 +1,300 @@
1
+ import type { On, ToolCallArgs, ToolCheckDecision } from 'claude-code'
2
+ import { type Engine, expect, type MockClock, mock, test } from 'claude-code/testing'
3
+ import { BOARD_ALLOWED, CLEARED } from '../hooks/activity'
4
+
5
+ const ROOT = 'C:/cfg/.cck/agent-activity'
6
+ const SESSION = `${ROOT}/sid-1`
7
+ const MARKER = `${SESSION}/_waiting.json`
8
+ const CONFIG = 'C:/cfg/.cck/config.json'
9
+
10
+ type EngineOptions = { decision?: ToolCheckDecision; rule?: string; env?: Record<string, string> }
11
+
12
+ // A test hook cannot raise tool.check, so the test raises it (askFor), and the engine's tool.call
13
+ // holds the first call until the test answers the terminal prompt.
14
+ function engine(on: On, files: Record<string, string>, { decision = 'allow', rule, env = {} }: EngineOptions = {}) {
15
+ const writes: { path: string; text: string }[] = []
16
+ const runs: unknown[] = []
17
+ const status: (string | undefined)[] = []
18
+ let answer = () => {}
19
+ const terminal = new Promise<void>(resolve => {
20
+ answer = resolve
21
+ })
22
+ const norm = (p: string) => p.replaceAll('\\', '/')
23
+ on('fs.read', (_$, e) => {
24
+ const text = files[norm(e.path)]
25
+ if (text === undefined) throw new Error(`ENOENT: ${e.path}`)
26
+ return { value: text }
27
+ })
28
+ on('fs.write', (_$, e) => {
29
+ writes.push({ path: norm(e.path), text: e.text })
30
+ files[norm(e.path)] = e.text
31
+ return { value: undefined }
32
+ })
33
+ on('env.get', (_$, e) => ({ value: { CLAUDE_CONFIG_DIR: 'C:/cfg', ...env }[e.name] }))
34
+ on('session.id', () => ({ value: 'sid-1' }))
35
+ on('session.cwd', () => ({ value: 'C:/proj' }))
36
+ on('ui.status', (_$, e) => {
37
+ status.push(e.text)
38
+ return { value: undefined }
39
+ })
40
+ on('tool.check', () => ({ decision, rule }))
41
+ let held = decision === 'ask'
42
+ on('tool.call', async (_$, e) => {
43
+ const { tool, tool_use_id, agentId, ...input } = e
44
+ if (held) {
45
+ held = false
46
+ await terminal
47
+ }
48
+ runs.push(input)
49
+ return { result: { stdout: 'ok' } }
50
+ })
51
+ on('session.start', (_$, e) => ({ cwd: e.cwd }))
52
+ on('agent.spawn', () => ({ model: 'haiku', agentId: 'a1' }))
53
+ on('turn.complete', (_$, e) => ({ text: e.answer }))
54
+ on('classic.*', () => ({}))
55
+ return { writes, runs, status, answer }
56
+ }
57
+
58
+ async function askFor($: Engine, clock: MockClock, args: { tool: string } & Record<string, unknown>) {
59
+ const { tool, ...input } = args
60
+ const call = $.tool.call(args as ToolCallArgs)
61
+ await clock.settle()
62
+ await $.tool.check({ tool, input })
63
+ await clock.settle()
64
+ return { call }
65
+ }
66
+
67
+ const marker = (files: Record<string, string>) => JSON.parse(files[MARKER]!)
68
+ const decide = (files: Record<string, string>, decision: object) => {
69
+ files[`${SESSION}/_decision-${marker(files).id}.json`] = JSON.stringify(decision)
70
+ }
71
+
72
+ const lines = (text = '') => text.trim().split('\n').map(l => JSON.parse(l))
73
+
74
+ const turn = (fields: { agentId?: string; answer?: string; isAborted?: boolean }) => ({
75
+ answer: '',
76
+ durationMs: 1,
77
+ isAborted: false,
78
+ turnId: 't',
79
+ reason: 'answer' as const,
80
+ ...fields,
81
+ })
82
+
83
+ const spawn = (subagentType: string, name?: string) => ({
84
+ tool_use_id: 'tu',
85
+ prompt: 'p',
86
+ description: 'd',
87
+ subagentType,
88
+ provider: { plugin: 'engine', tier: 'core' as const },
89
+ parentModel: 'opus',
90
+ background: false,
91
+ fork: false,
92
+ name,
93
+ })
94
+
95
+ test('an allowed tool call writes nothing', async ($, on) => {
96
+ const { writes } = engine(on, {})
97
+
98
+ const result = await $.tool.call({ tool: 'Bash', command: 'ls' })
99
+
100
+ expect(result.result).toEqual({ stdout: 'ok' })
101
+ expect(writes).toEqual([])
102
+ })
103
+
104
+ test('an ask writes the marker, and a terminal answer clears it', async ($, on) => {
105
+ const files: Record<string, string> = {}
106
+ const clock = mock.clock(on)
107
+ const { runs, answer } = engine(on, files, { decision: 'ask', rule: 'Bash(ls)' })
108
+
109
+ const { call } = await askFor($, clock, { tool: 'Bash', command: 'ls' })
110
+ expect(marker(files)).toMatchObject({
111
+ status: 'waiting',
112
+ kind: 'permission',
113
+ toolName: 'Bash',
114
+ toolInput: '{"command":"ls"}',
115
+ cwd: 'C:/proj',
116
+ rule: 'Bash(ls)',
117
+ })
118
+ await clock.advance(1500)
119
+ answer()
120
+
121
+ expect((await call).result).toEqual({ stdout: 'ok' })
122
+ expect(files[MARKER]).toBe(CLEARED)
123
+ expect(runs).toEqual([{ command: 'ls' }])
124
+ })
125
+
126
+ test('a board allow runs the tool with the board input and shows why', async ($, on) => {
127
+ const files: Record<string, string> = {}
128
+ const clock = mock.clock(on)
129
+ const { runs, status } = engine(on, files, { decision: 'ask' })
130
+
131
+ const { call } = await askFor($, clock, { tool: 'Bash', command: 'ls' })
132
+ decide(files, { behavior: 'allow', updatedInput: { command: 'ls -a' } })
133
+ await clock.advance(500)
134
+
135
+ expect((await call).result).toEqual({ stdout: 'ok' })
136
+ expect(runs).toEqual([{ command: 'ls -a' }])
137
+ expect(status).toEqual([BOARD_ALLOWED, undefined])
138
+ expect(files[MARKER]).toBe(CLEARED)
139
+ })
140
+
141
+ test('a board deny refuses the call with its message', async ($, on) => {
142
+ const files: Record<string, string> = {}
143
+ const clock = mock.clock(on)
144
+ const { runs } = engine(on, files, { decision: 'ask' })
145
+
146
+ const { call } = await askFor($, clock, { tool: 'Bash', command: 'rm -rf x' })
147
+ decide(files, { behavior: 'deny', message: 'not that' })
148
+ await clock.advance(500)
149
+
150
+ expect((await call).deny).toBe('not that')
151
+ expect(runs).toEqual([])
152
+ expect(files[MARKER]).toBe(CLEARED)
153
+ })
154
+
155
+ test('board answers to a question come back as the tool result', async ($, on) => {
156
+ const files: Record<string, string> = {}
157
+ const clock = mock.clock(on)
158
+ engine(on, files, { decision: 'ask' })
159
+ const questions = [{ question: 'Fruit?', header: 'Fruit', options: [], multiSelect: false }]
160
+
161
+ const { call } = await askFor($, clock, { tool: 'AskUserQuestion', questions })
162
+ expect(marker(files).kind).toBe('question')
163
+ decide(files, { answers: { 'Fruit?': 'Pear' } })
164
+ await clock.advance(500)
165
+
166
+ expect((await call).result).toEqual({ questions, answers: { 'Fruit?': 'Pear' } })
167
+ })
168
+
169
+ test('with approvals off the board only shows the ask', async ($, on) => {
170
+ const files: Record<string, string> = { [CONFIG]: '{"approvals":{"enabled":false}}' }
171
+ const clock = mock.clock(on)
172
+ const { runs, answer } = engine(on, files, { decision: 'ask' })
173
+
174
+ const { call } = await askFor($, clock, { tool: 'Bash', command: 'ls' })
175
+ decide(files, { behavior: 'deny' })
176
+ await clock.advance(2000)
177
+ answer()
178
+
179
+ expect((await call).result).toEqual({ stdout: 'ok' })
180
+ expect(runs).toEqual([{ command: 'ls' }])
181
+ })
182
+
183
+ test('the board stops waiting after waitSeconds', async ($, on) => {
184
+ const files: Record<string, string> = { [CONFIG]: '{"approvals":{"waitSeconds":1}}' }
185
+ const clock = mock.clock(on)
186
+ const { runs, answer } = engine(on, files, { decision: 'ask' })
187
+
188
+ const { call } = await askFor($, clock, { tool: 'Bash', command: 'ls' })
189
+ await clock.advance(1500)
190
+ decide(files, { behavior: 'deny' })
191
+ await clock.advance(1000)
192
+ answer()
193
+
194
+ expect((await call).result).toEqual({ stdout: 'ok' })
195
+ expect(runs).toEqual([{ command: 'ls' }])
196
+ })
197
+
198
+ test('a marker that a newer ask owns stays', async ($, on) => {
199
+ const files: Record<string, string> = {}
200
+ const clock = mock.clock(on)
201
+ const { answer } = engine(on, files, { decision: 'ask' })
202
+
203
+ const { call } = await askFor($, clock, { tool: 'Bash', command: 'ls' })
204
+ files[MARKER] = '{"status":"waiting","id":"newer"}'
205
+ answer()
206
+ await call
207
+
208
+ expect(files[MARKER]).toBe('{"status":"waiting","id":"newer"}')
209
+ })
210
+
211
+ test('a finished main turn marks the session unread', async ($, on) => {
212
+ const files: Record<string, string> = {}
213
+ engine(on, files)
214
+
215
+ await $.turn.complete(turn({}))
216
+
217
+ expect(files[`${SESSION}/_stop.json`]).toBe('')
218
+ })
219
+
220
+ test('an interrupted main turn does not mark the session unread', async ($, on) => {
221
+ const { writes } = engine(on, {})
222
+
223
+ await $.turn.complete(turn({ isAborted: true }))
224
+
225
+ expect(writes).toEqual([])
226
+ })
227
+
228
+ test('session start maps the session to a custom task list', async ($, on) => {
229
+ const map = `${ROOT}/_task-maps/list-1.json`
230
+ const files: Record<string, string> = { [map]: '{"old":{"project":"C:/x"}}' }
231
+ engine(on, files, { env: { CLAUDE_CODE_TASK_LIST_ID: 'list-1' } })
232
+
233
+ await $.session.start({ cwd: 'C:/proj', surface: null, isInteractive: false })
234
+
235
+ const maps = JSON.parse(files[map]!)
236
+ expect(maps.old).toEqual({ project: 'C:/x' })
237
+ expect(maps['sid-1'].project).toBe('C:/proj')
238
+ })
239
+
240
+ test('session start writes nothing without a custom task list', async ($, on) => {
241
+ const { writes } = engine(on, {})
242
+
243
+ await $.session.start({ cwd: 'C:/proj', surface: null, isInteractive: false })
244
+
245
+ expect(writes).toEqual([])
246
+ })
247
+
248
+ test('a subagent spawn then finish records start and stop', async ($, on) => {
249
+ const files: Record<string, string> = {}
250
+ engine(on, files)
251
+
252
+ await $.agent.spawn(spawn('general-purpose'))
253
+ await $.turn.complete(turn({ agentId: 'a1', answer: 'Task done with "quotes"' }))
254
+
255
+ const [start, stop] = lines(files[`${SESSION}/a1.jsonl`])
256
+ expect(start).toMatchObject({ agentId: 'a1', type: 'general-purpose', event: 'start', status: 'active' })
257
+ expect(stop).toMatchObject({ agentId: 'a1', event: 'stop', status: 'stopped', lastMessage: 'Task done with "quotes"' })
258
+ expect('type' in stop).toBe(false)
259
+ expect(files[`${SESSION}/_name-general-purpose.id`]).toBe('a1')
260
+ expect(files[`${SESSION}/_stop.json`]).toBeUndefined()
261
+ })
262
+
263
+ test('a subagent stop with an empty answer leaves lastMessage out', async ($, on) => {
264
+ const files: Record<string, string> = {}
265
+ engine(on, files)
266
+
267
+ await $.agent.spawn(spawn('general-purpose'))
268
+ await $.turn.complete(turn({ agentId: 'a1' }))
269
+
270
+ const [, stop] = lines(files[`${SESSION}/a1.jsonl`])
271
+ expect(stop).toMatchObject({ agentId: 'a1', event: 'stop' })
272
+ expect('lastMessage' in stop).toBe(false)
273
+ })
274
+
275
+ test('a finished loop that was never spawned writes nothing', async ($, on) => {
276
+ const { writes } = engine(on, {})
277
+
278
+ await $.turn.complete(turn({ agentId: 'internal' }))
279
+
280
+ expect(writes).toEqual([])
281
+ })
282
+
283
+ test('a named spawn maps the name, and TeammateIdle finds the agent by it', async ($, on) => {
284
+ const files: Record<string, string> = {}
285
+ engine(on, files)
286
+
287
+ await $.agent.spawn(spawn('general-purpose', 'researcher'))
288
+ await $.classic.TeammateIdle({ teammate_name: 'researcher', team_name: 'team' })
289
+
290
+ const idle = lines(files[`${SESSION}/a1.jsonl`])[1]
291
+ expect(idle).toMatchObject({ agentId: 'a1', type: 'researcher', event: 'idle', status: 'idle' })
292
+ })
293
+
294
+ test('TeammateIdle for an unknown teammate writes nothing', async ($, on) => {
295
+ const { writes } = engine(on, {})
296
+
297
+ await $.classic.TeammateIdle({ teammate_name: 'ghost', team_name: 'team' })
298
+
299
+ expect(writes).toEqual([])
300
+ })
@@ -0,0 +1,72 @@
1
+ import type { On, SessionMeasureInput } from 'claude-code'
2
+ import { expect, test } from 'claude-code/testing'
3
+ import { displayName } from '../hooks/context'
4
+
5
+ const MEASURE: SessionMeasureInput = {
6
+ context: { tokens: 129_174, window: 1_000_000, percent: 13 },
7
+ rateLimits: [
8
+ { kind: 'five_hour', percentUsed: 1, resetsAt: '2026-10-02T13:10:00Z' },
9
+ { kind: 'seven_day', percentUsed: 7.5, resetsAt: '2026-10-09T00:00:00Z' },
10
+ ],
11
+ cost: { usd: 2.71 },
12
+ changed: ['context', 'rateLimits', 'cost'],
13
+ }
14
+
15
+ function engine(on: On, env: Record<string, string>, sessionId: string) {
16
+ const writes: { path: string; text: string }[] = []
17
+ on('fs.write', (_$, e) => {
18
+ writes.push({ path: e.path.replaceAll('\\', '/'), text: e.text })
19
+ return { value: undefined }
20
+ })
21
+ on('env.get', (_$, e) => ({ value: env[e.name] }))
22
+ on('session.id', () => ({ value: sessionId }))
23
+ on('session.model', () => ({ value: 'claude-opus-5-5' }))
24
+ on('session.measure', (_$, e) => ({ changed: [...e.changed] }))
25
+ return writes
26
+ }
27
+
28
+ test('session.measure writes the statusLine shape the board reads', async ($, on) => {
29
+ const writes = engine(on, { CLAUDE_CONFIG_DIR: 'C:/cfg' }, 'sid-1')
30
+
31
+ await $.session.measure(MEASURE)
32
+
33
+ expect(writes.length).toBe(1)
34
+ expect(writes[0]?.path).toBe('C:/cfg/.cck/context-status/sid-1.json')
35
+ const status = JSON.parse(writes[0]?.text ?? '{}')
36
+ expect(status.model).toEqual({ id: 'claude-opus-5-5', display_name: 'Opus 5.5' })
37
+ expect(status.cost.total_cost_usd).toBe(2.71)
38
+ expect(status.context_window).toEqual({
39
+ total_input_tokens: 129_174,
40
+ context_window_size: 1_000_000,
41
+ current_usage: null,
42
+ used_percentage: 13,
43
+ })
44
+ expect(status.rate_limits.five_hour).toEqual({ used_percentage: 1, resets_at: 1790946600 })
45
+ expect(status.rate_limits.seven_day.used_percentage).toBe(7.5)
46
+ })
47
+
48
+ test('skips a write when the status has not changed', async ($, on) => {
49
+ const writes = engine(on, { CLAUDE_CONFIG_DIR: 'C:/cfg' }, 'sid-3')
50
+
51
+ await $.session.measure(MEASURE)
52
+ await $.session.measure(MEASURE)
53
+ await $.session.measure({ ...MEASURE, cost: { usd: 3 } })
54
+
55
+ expect(writes.length).toBe(2)
56
+ })
57
+
58
+ test('display name from model id', async () => {
59
+ expect(displayName('claude-opus-5-5')).toBe('Opus 5.5')
60
+ expect(displayName('claude-haiku-4-5-20251001')).toBe('Haiku 4.5')
61
+ expect(displayName('claude-fable-5-1[1m]')).toBe('Fable 5.1')
62
+ expect(displayName('claude-opus-4-20250514')).toBe('Opus 4')
63
+ expect(displayName('gpt-x')).toBe('gpt-x')
64
+ })
65
+
66
+ test('falls back to ~/.claude when CLAUDE_CONFIG_DIR is unset', async ($, on) => {
67
+ const writes = engine(on, { USERPROFILE: 'C:/Users/me' }, 'sid-2')
68
+
69
+ await $.session.measure(MEASURE)
70
+
71
+ expect(writes.map(w => w.path)).toEqual(['C:/Users/me/.claude/.cck/context-status/sid-2.json'])
72
+ })
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "./.claude-plugin/types/tsconfig.json"
3
+ }