claude-code-kanban 5.3.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.
@@ -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
+ }
package/public/app.js CHANGED
@@ -1541,7 +1541,7 @@ function renderWaitingBody(tool, params) {
1541
1541
  const PLAN_REJECT_MSG = 'Plan rejected from the kanban board.';
1542
1542
 
1543
1543
  // Answerable only when the marker carries a request id (written by
1544
- // approval-gate.sh, which is polling for a decision) and the gate hasn't
1544
+ // the plugin's mod, which is polling for a decision) and the gate hasn't
1545
1545
  // lapsed past its wait window — older or lapsed markers are only answerable
1546
1546
  // in the terminal.
1547
1547
  function isWaitingAnswerable() {
@@ -1600,9 +1600,9 @@ function rejectWaitingPlan(inputId) {
1600
1600
  respondWaiting({ behavior: 'deny', message: feedback || PLAN_REJECT_MSG });
1601
1601
  }
1602
1602
 
1603
- // UI-driven approvals: POST the decision the blocking approval-gate.sh hook is
1604
- // polling for. First writer wins (D5) — a 409/410 means the ask was answered in
1605
- // the terminal or superseded, so just drop the card.
1603
+ // UI-driven approvals: POST the decision the plugin's mod is polling for.
1604
+ // First writer wins (D5) — a 409/410 means the ask was answered in the
1605
+ // terminal or superseded, so just drop the card.
1606
1606
  async function respondWaiting(payload) {
1607
1607
  if (!currentSessionId || !currentWaiting?.id) return;
1608
1608
  try {
@@ -8764,7 +8764,7 @@ function renderContextDetail(session, { tokens = true } = {}) {
8764
8764
  <div class="stat-item"><span class="stat-label">Current output</span><span class="stat-value">${formatTokens((usage.output_tokens || 0) / 1000)}</span></div>
8765
8765
  <div class="stat-divider"></div>
8766
8766
  <div class="stat-item"><span class="stat-label">Total input</span><span class="stat-value">${formatTokens(ctx.inputTokens / 1000)}</span></div>
8767
- <div class="stat-item"><span class="stat-label">Total output</span><span class="stat-value">${formatTokens(ctx.outputTokens / 1000)}</span></div>
8767
+ ${ctx.outputTokens ? `<div class="stat-item"><span class="stat-label">Total output</span><span class="stat-value">${formatTokens(ctx.outputTokens / 1000)}</span></div>` : ''}
8768
8768
  <div class="stat-divider"></div>`
8769
8769
  : '';
8770
8770
 
@@ -8784,9 +8784,9 @@ function renderContextDetail(session, { tokens = true } = {}) {
8784
8784
  <div class="detail-context-stats">
8785
8785
  ${tokenRows}
8786
8786
  <div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${getCostColor(cost.total_cost_usd)}">${formatCost(cost.total_cost_usd)}</span></div>
8787
- <div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>
8788
- <div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>
8789
- <div class="stat-item"><span class="stat-label">Lines</span><span class="stat-value"><span style="color:${CONTEXT_COLORS.green}">+${(cost.total_lines_added || 0).toLocaleString()}</span> / <span style="color:${CONTEXT_COLORS.red}">-${(cost.total_lines_removed || 0).toLocaleString()}</span></span></div>
8787
+ ${cost.total_duration_ms != null ? `<div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>` : ''}
8788
+ ${cost.total_api_duration_ms != null ? `<div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>` : ''}
8789
+ ${cost.total_lines_added != null ? `<div class="stat-item"><span class="stat-label">Lines</span><span class="stat-value"><span style="color:${CONTEXT_COLORS.green}">+${(cost.total_lines_added || 0).toLocaleString()}</span> / <span style="color:${CONTEXT_COLORS.red}">-${(cost.total_lines_removed || 0).toLocaleString()}</span></span></div>` : ''}
8790
8790
  </div>
8791
8791
  </div>`;
8792
8792
  }