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.
- package/README.md +2 -2
- package/cli.js +10 -5
- package/install.js +43 -123
- package/lib/approvals.js +16 -17
- package/lib/git-branch.js +47 -0
- package/lib/parsers.js +96 -59
- package/lib/plugin-status.js +1 -1
- package/lib/session-cache.js +1 -1
- package/lib/terminal.js +2 -1
- package/package.json +1 -2
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
- package/plugin/plugins/claude-code-kanban/hooks/activity.ts +266 -0
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +77 -0
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -93
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +8 -0
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +4 -4
- package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +300 -0
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +72 -0
- package/plugin/plugins/claude-code-kanban/tsconfig.json +3 -0
- package/public/app.js +8 -8
- package/server.js +106 -95
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +0 -135
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +0 -208
- 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
|
+
})
|
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
|
-
//
|
|
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
|
|
1604
|
-
//
|
|
1605
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
8788
|
-
|
|
8789
|
-
|
|
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
|
}
|