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,266 @@
1
+ import type { EngineInterface, Register, ToolCallArgs } from 'claude-code'
2
+ import { cckRoot } from './context'
3
+
4
+ // Mods cannot delete files. The server reads any status but "waiting" as no ask.
5
+ export const CLEARED = JSON.stringify({ status: 'cleared' })
6
+
7
+ export const BOARD_ALLOWED = 'Approved from the board — running'
8
+
9
+ const POLL_MS = 500
10
+
11
+ type Kind = 'permission' | 'question' | 'plan'
12
+ type Ask = { id: string; kind: Kind; dir: string; waitMs?: number }
13
+ type Decision = {
14
+ behavior?: 'allow' | 'deny'
15
+ message?: string
16
+ updatedInput?: Record<string, unknown>
17
+ answers?: Record<string, unknown>
18
+ }
19
+
20
+ // tool.check carries the call's id on a real call only, so a call without one is found by its input.
21
+ const pending = new Set<{ id?: string; tool: string; args: Record<string, unknown>; asked: (ask: Ask) => void }>()
22
+ // The board's allow runs the tool again through $.tool.call, and its tool.check must not ask twice.
23
+ const allowOnce = new Set<string>()
24
+ const callKey = (tool: string, input: unknown) => `${tool}\0${JSON.stringify(input)}`
25
+
26
+ let cck: Promise<string> | undefined
27
+
28
+ // The engine does not follow $ into an imported function, so only the path rule is shared.
29
+ function cckDir($: EngineInterface) {
30
+ cck ??= Promise.all([$.env.get('CLAUDE_CONFIG_DIR'), $.env.get('HOME'), $.env.get('USERPROFILE')]).then(
31
+ ([configDir, home, userProfile]) => cckRoot(configDir, home, userProfile),
32
+ )
33
+ return cck
34
+ }
35
+
36
+ async function readText($: EngineInterface, path: string) {
37
+ try {
38
+ return await $.fs.read(path)
39
+ } catch {
40
+ return undefined
41
+ }
42
+ }
43
+
44
+ async function readJson<T>($: EngineInterface, path: string): Promise<T | undefined> {
45
+ try {
46
+ return JSON.parse((await readText($, path)) ?? '')
47
+ } catch {
48
+ return undefined
49
+ }
50
+ }
51
+
52
+ // The server folds an agent's lines last-key-wins, so a line leaves out a field it does not know.
53
+ async function appendLine($: EngineInterface, path: string, record: object, { onlyIfExists = false } = {}) {
54
+ const text = await readText($, path)
55
+ if (text === undefined && onlyIfExists) return
56
+ await $.fs.write(path, `${text ?? ''}${JSON.stringify(record)}\n`)
57
+ }
58
+
59
+ async function sessionDir($: EngineInterface) {
60
+ const [dir, id] = await Promise.all([cckDir($), $.session.id()])
61
+ return `${dir}/agent-activity/${id}`
62
+ }
63
+
64
+ const kindOf = (tool: string): Kind =>
65
+ tool === 'AskUserQuestion' ? 'question' : tool === 'ExitPlanMode' ? 'plan' : 'permission'
66
+
67
+ // Mirrors approvalsFrom and isKindGated in lib/approvals.js: the gate is on unless config.json
68
+ // says enabled: false, and a missing or broken file means the defaults.
69
+ async function boardWaitMs($: EngineInterface, kind: Kind) {
70
+ type Approvals = { enabled?: unknown; mode?: unknown; waitSeconds?: unknown }
71
+ const a = (await readJson<{ approvals?: Approvals }>($, `${await cckDir($)}/config.json`))?.approvals ?? {}
72
+ if (a.enabled === false) return undefined
73
+ if (kind === 'question' && a.mode === 'permission') return undefined
74
+ const seconds = Number.isInteger(a.waitSeconds) && (a.waitSeconds as number) >= 0 ? (a.waitSeconds as number) : 1800
75
+ return Math.min(seconds, 1800) * 1000
76
+ }
77
+
78
+ // The marker shows the ask on the board whether or not the board may answer it.
79
+ async function openAsk($: EngineInterface, tool: string, input: unknown, rule?: string): Promise<Ask> {
80
+ const kind = kindOf(tool)
81
+ const id = crypto.randomUUID()
82
+ const [dir, cwd, waitMs] = await Promise.all([sessionDir($), $.session.cwd(), boardWaitMs($, kind)])
83
+ await $.fs.write(
84
+ `${dir}/_waiting.json`,
85
+ JSON.stringify({
86
+ status: 'waiting',
87
+ kind,
88
+ id,
89
+ toolName: tool,
90
+ toolInput: JSON.stringify(input),
91
+ cwd,
92
+ rule,
93
+ timestamp: new Date().toISOString(),
94
+ }),
95
+ )
96
+ return { id, kind, dir, waitMs }
97
+ }
98
+
99
+ // A newer ask (a parallel call) may own the marker by now.
100
+ async function closeAsk($: EngineInterface, ask: Ask) {
101
+ const marker = `${ask.dir}/_waiting.json`
102
+ if ((await readJson<{ id?: string }>($, marker))?.id === ask.id) await $.fs.write(marker, CLEARED)
103
+ }
104
+
105
+ async function boardDecision($: EngineInterface, ask: Ask, signal: AbortSignal) {
106
+ const file = `${ask.dir}/_decision-${ask.id}.json`
107
+ for (let waited = 0; waited < (ask.waitMs ?? 0); waited += POLL_MS) {
108
+ try {
109
+ await $.clock.sleep(POLL_MS, { signal })
110
+ } catch {
111
+ return undefined
112
+ }
113
+ const d = await readJson<Decision>($, file)
114
+ if (d) return d
115
+ }
116
+ return undefined
117
+ }
118
+
119
+ // Only returning ends the terminal prompt, so an allow runs the tool again from here. A terminal
120
+ // "Yes" in the moment before the hook returns runs it twice (docs/ui-approvals.md).
121
+ async function answerFromBoard($: EngineInterface, tool: string, args: Record<string, unknown>, ask: Ask, d: Decision) {
122
+ if (ask.kind === 'question') {
123
+ return d.answers ? { result: { questions: args.questions, answers: d.answers } } : undefined
124
+ }
125
+ if (d.behavior !== 'allow') return { deny: d.message ?? 'Denied from the board' }
126
+ $.ui.status(BOARD_ALLOWED)
127
+ const input = { ...args, ...d.updatedInput }
128
+ const key = callKey(tool, input)
129
+ allowOnce.add(key)
130
+ try {
131
+ return await $.tool.call({ ...input, tool } as ToolCallArgs)
132
+ } finally {
133
+ allowOnce.delete(key)
134
+ $.ui.status(undefined)
135
+ }
136
+ }
137
+
138
+ async function mapTaskList($: EngineInterface, cwd: string) {
139
+ const taskListId = await $.env.get('CLAUDE_CODE_TASK_LIST_ID')
140
+ if (!taskListId) return
141
+ const path = `${await cckDir($)}/agent-activity/_task-maps/${taskListId}.json`
142
+ const maps = (await readJson<Record<string, unknown>>($, path)) ?? {}
143
+ maps[await $.session.id()] = { project: cwd, updatedAt: new Date().toISOString() }
144
+ await $.fs.write(path, JSON.stringify(maps))
145
+ }
146
+
147
+ async function markStopped($: EngineInterface) {
148
+ await $.fs.write(`${await sessionDir($)}/_stop.json`, '')
149
+ }
150
+
151
+ // The name map lets TeammateIdle, which names the teammate, find its agent id.
152
+ async function agentStarted($: EngineInterface, agentId: string, type: string, name?: string) {
153
+ const dir = await sessionDir($)
154
+ const ts = new Date().toISOString()
155
+ await Promise.all([
156
+ appendLine($, `${dir}/${agentId}.jsonl`, {
157
+ agentId,
158
+ type,
159
+ event: 'start',
160
+ status: 'active',
161
+ startedAt: ts,
162
+ updatedAt: ts,
163
+ }),
164
+ $.fs.write(`${dir}/_name-${name ?? type}.id`, agentId),
165
+ ])
166
+ }
167
+
168
+ // turn.complete also ends loops agent.spawn never started (internal agents): those have no file.
169
+ async function agentStopped($: EngineInterface, agentId: string, lastMessage: string) {
170
+ const ts = new Date().toISOString()
171
+ await appendLine(
172
+ $,
173
+ `${await sessionDir($)}/${agentId}.jsonl`,
174
+ {
175
+ agentId,
176
+ event: 'stop',
177
+ status: 'stopped',
178
+ ...(lastMessage && { lastMessage }),
179
+ stoppedAt: ts,
180
+ updatedAt: ts,
181
+ },
182
+ { onlyIfExists: true },
183
+ )
184
+ }
185
+
186
+ // TeammateIdle names the teammate but carries no agent_id outside the teammate's own loop.
187
+ async function teammateIdle($: EngineInterface, name: string, agentId?: string, agentType?: string) {
188
+ const dir = await sessionDir($)
189
+ const id = agentId || (await readText($, `${dir}/_name-${name}.id`))
190
+ if (!id) return
191
+ const type = agentId ? agentType : name
192
+ await appendLine(
193
+ $,
194
+ `${dir}/${id}.jsonl`,
195
+ {
196
+ agentId: id,
197
+ ...(type && { type }),
198
+ event: 'idle',
199
+ status: 'idle',
200
+ updatedAt: new Date().toISOString(),
201
+ },
202
+ { onlyIfExists: !type },
203
+ )
204
+ }
205
+
206
+ export const register: Register = on => {
207
+ on('tool.check', async ($, e, next) => {
208
+ if (allowOnce.delete(callKey(e.tool, e.input))) return { decision: 'allow' }
209
+ const result = await next(e)
210
+ if (result.decision !== 'ask') return result
211
+ const input = e.tool_use_id ? undefined : JSON.stringify(e.input)
212
+ const call = [...pending].find(p =>
213
+ e.tool_use_id ? p.id === e.tool_use_id : p.tool === e.tool && JSON.stringify(p.args) === input,
214
+ )
215
+ call?.asked(await openAsk($, e.tool, e.input, result.rule))
216
+ return result
217
+ })
218
+ on('tool.call', async ($, e, next) => {
219
+ const { tool, tool_use_id, agentId: _agentId, consent: _consent, ...args } = e as typeof e & { consent?: unknown }
220
+ let asked: (ask: Ask) => void = () => {}
221
+ const ask = new Promise<Ask>(resolve => {
222
+ asked = resolve
223
+ })
224
+ const call = { id: tool_use_id, tool, args, asked }
225
+ pending.add(call)
226
+ const run = next(e)
227
+ const settled = new AbortController()
228
+ const done = run.then(() => undefined).finally(() => settled.abort())
229
+ try {
230
+ const opened = await Promise.race([done, ask])
231
+ if (!opened) return await run
232
+ try {
233
+ const d = await Promise.race([
234
+ done,
235
+ boardDecision($, opened, AbortSignal.any([next.signal, settled.signal])),
236
+ ])
237
+ return (d && (await answerFromBoard($, tool, args, opened, d))) ?? (await run)
238
+ } finally {
239
+ await closeAsk($, opened)
240
+ }
241
+ } finally {
242
+ pending.delete(call)
243
+ }
244
+ })
245
+ on('session.start', async ($, e, next) => {
246
+ await mapTaskList($, e.cwd)
247
+ return next(e)
248
+ })
249
+ on('agent.spawn', async ($, e, next) => {
250
+ const result = await next(e)
251
+ if (result.agentId) await agentStarted($, result.agentId, e.subagentType, e.name)
252
+ return result
253
+ })
254
+ // An interrupted turn fires no Stop hook, so it does not mark the session unread either.
255
+ on('turn.complete', async ($, e, next) => {
256
+ if (e.agentId) await agentStopped($, e.agentId, e.answer)
257
+ else if (!e.isAborted) await markStopped($)
258
+ return next(e)
259
+ })
260
+ // Managed settings bypass user-tier mods on classic.* (cc-plugin-sec-default), so this one
261
+ // has no effect there; there is no native teammate-idle event to use instead.
262
+ on('classic.TeammateIdle', async ($, e, next) => {
263
+ await teammateIdle($, e.teammate_name, e.agent_id, e.agent_type)
264
+ return next(e)
265
+ })
266
+ }
@@ -0,0 +1,77 @@
1
+ import type { EngineInterface, ModelUsage, Register, SessionMeasureInput } from 'claude-code'
2
+
3
+ type Measure = Pick<SessionMeasureInput, 'context' | 'rateLimits' | 'cost'>
4
+
5
+ export function displayName(model: string) {
6
+ const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2})(?!\d))?/.exec(model)
7
+ if (!m?.[1]) return model
8
+ const family = m[1][0]!.toUpperCase() + m[1].slice(1)
9
+ return `${family} ${m[3] ? `${m[2]}.${m[3]}` : m[2]}`
10
+ }
11
+
12
+ const toEpochSeconds = (iso?: string) => (iso ? Math.floor(Date.parse(iso) / 1000) : undefined)
13
+
14
+ // The board reads the statusLine's JSON shape, so the file keeps its field names.
15
+ export function toStatus(model: string, m: Measure, usage: ModelUsage | undefined) {
16
+ const { context } = m
17
+ return {
18
+ model: { id: model, display_name: displayName(model) },
19
+ cost: { total_cost_usd: m.cost?.usd ?? 0 },
20
+ context_window: {
21
+ total_input_tokens: context.tokens ?? 0,
22
+ context_window_size: context.window,
23
+ current_usage: usage ?? null,
24
+ used_percentage: context.percent ?? null,
25
+ },
26
+ rate_limits: Object.fromEntries(
27
+ m.rateLimits.map(r => [r.kind, { used_percentage: r.percentUsed, resets_at: toEpochSeconds(r.resetsAt) }]),
28
+ ),
29
+ }
30
+ }
31
+
32
+ export const cckRoot = (configDir?: string, home?: string, userProfile?: string) =>
33
+ configDir ? `${configDir}/.cck` : `${home ?? userProfile}/.claude/.cck`
34
+
35
+ let cck: Promise<string> | undefined
36
+
37
+ function cckDir($: EngineInterface) {
38
+ cck ??= Promise.all([$.env.get('CLAUDE_CONFIG_DIR'), $.env.get('HOME'), $.env.get('USERPROFILE')]).then(
39
+ ([configDir, home, userProfile]) => cckRoot(configDir, home, userProfile),
40
+ )
41
+ return cck
42
+ }
43
+
44
+ let lastUsage: ModelUsage | undefined
45
+ let lastModel: string | undefined
46
+ const lastWritten = new Map<string, string>()
47
+
48
+ async function write($: EngineInterface, m: Measure) {
49
+ const [sessionId, model, dir] = await Promise.all([
50
+ $.session.id(),
51
+ lastModel ?? $.session.model(),
52
+ cckDir($),
53
+ ])
54
+ const text = JSON.stringify(toStatus(model, m, lastUsage))
55
+ const file = `${dir}/context-status/${sessionId}.json`
56
+ if (lastWritten.get(file) === text) return
57
+ await $.fs.write(file, text)
58
+ lastWritten.set(file, text)
59
+ }
60
+
61
+ export const register: Register = on => {
62
+ on('turn.step', async function* ($, e, next) {
63
+ const result = yield* next(e)
64
+ if (e.agentId === undefined && result.usage) {
65
+ const { model, ...usage } = result.usage
66
+ lastUsage = usage
67
+ lastModel = model
68
+ await write($, await $.session.usage())
69
+ }
70
+ return result
71
+ })
72
+
73
+ on('session.measure', async ($, e, next) => {
74
+ await write($, e)
75
+ return next(e)
76
+ })
77
+ }
@@ -1,95 +1,3 @@
1
1
  {
2
- "hooks": {
3
- "SessionStart": [
4
- {
5
- "matcher": "",
6
- "hooks": [
7
- {
8
- "type": "command",
9
- "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh\"",
10
- "shell": "bash",
11
- "timeout": 5
12
- }
13
- ]
14
- }
15
- ],
16
- "SubagentStart": [
17
- {
18
- "matcher": "",
19
- "hooks": [
20
- {
21
- "type": "command",
22
- "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh\"",
23
- "shell": "bash",
24
- "timeout": 5
25
- }
26
- ]
27
- }
28
- ],
29
- "SubagentStop": [
30
- {
31
- "matcher": "",
32
- "hooks": [
33
- {
34
- "type": "command",
35
- "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh\"",
36
- "shell": "bash",
37
- "timeout": 30
38
- }
39
- ]
40
- }
41
- ],
42
- "Stop": [
43
- {
44
- "matcher": "",
45
- "hooks": [
46
- {
47
- "type": "command",
48
- "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh\"",
49
- "shell": "bash",
50
- "timeout": 5
51
- }
52
- ]
53
- }
54
- ],
55
- "TeammateIdle": [
56
- {
57
- "matcher": "",
58
- "hooks": [
59
- {
60
- "type": "command",
61
- "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh\"",
62
- "shell": "bash",
63
- "timeout": 5
64
- }
65
- ]
66
- }
67
- ],
68
- "PermissionRequest": [
69
- {
70
- "matcher": "",
71
- "hooks": [
72
- {
73
- "type": "command",
74
- "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/approval-gate.sh\"",
75
- "shell": "bash",
76
- "timeout": 86400
77
- }
78
- ]
79
- }
80
- ],
81
- "PostToolUse": [
82
- {
83
- "matcher": "",
84
- "hooks": [
85
- {
86
- "type": "command",
87
- "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh\"",
88
- "shell": "bash",
89
- "timeout": 5
90
- }
91
- ]
92
- }
93
- ]
94
- }
2
+ "modules": ["./index.ts"]
95
3
  }
@@ -0,0 +1,8 @@
1
+ import type { Register } from 'claude-code'
2
+ import { register as activity } from './activity'
3
+ import { register as context } from './context'
4
+
5
+ export const register: Register = (on, options) => {
6
+ context(on, options)
7
+ activity(on, options)
8
+ }
@@ -26,8 +26,8 @@ const SERVER_INFO = path.join(CLAUDE_DIR, '.cck', 'server.json');
26
26
  // because an enqueue wakes the poll immediately.
27
27
  const WAIT_SEC = 120;
28
28
  const RETRY_MS = 15000;
29
- // Windows sometimes fails a loopback connect with ETIMEDOUT while the board is up, so that
30
- // error gets a few short waits before the normal one.
29
+ // Windows sometimes fails a loopback connect with ETIMEDOUT, or resets it once open, while the
30
+ // board is up, so those errors get a few short waits before the normal one.
31
31
  const CONNECT_RETRY_MS = [250, 500, 1000, 2000];
32
32
  // `--topic dispatch` is the dispatch inbox: reports from sessions this one started.
33
33
  const TOPIC = process.argv.includes('--topic') ? process.argv[process.argv.indexOf('--topic') + 1] : null;
@@ -75,8 +75,8 @@ async function poll(base) {
75
75
  } catch (e) {
76
76
  // No board yet, or it went away. It may come back later in the session, so keep
77
77
  // waiting quietly -- a missing server is the normal case, not an error.
78
- const timedOut = e.cause?.code === 'ETIMEDOUT' && e.cause?.syscall === 'connect';
79
- if (timedOut && connectRetries < CONNECT_RETRY_MS.length) {
78
+ const blip = (e.cause?.code === 'ETIMEDOUT' && e.cause?.syscall === 'connect') || e.cause?.code === 'ECONNRESET';
79
+ if (blip && connectRetries < CONNECT_RETRY_MS.length) {
80
80
  await sleep(CONNECT_RETRY_MS[connectRetries++]);
81
81
  } else {
82
82
  connectRetries = 0;