claude-code-kanban 5.3.0 → 6.0.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 (36) hide show
  1. package/README.md +4 -4
  2. package/cli.js +30 -108
  3. package/install.js +43 -123
  4. package/lib/approvals.js +16 -17
  5. package/lib/dispatch-groups.js +9 -31
  6. package/lib/dispatch.js +19 -166
  7. package/lib/git-branch.js +62 -0
  8. package/lib/parsers.js +96 -59
  9. package/lib/plugin-status.js +1 -1
  10. package/lib/retention.js +5 -17
  11. package/lib/session-cache.js +1 -1
  12. package/lib/session-events.js +3 -2
  13. package/lib/terminal.js +30 -5
  14. package/package.json +1 -2
  15. package/plugin/.claude-plugin/marketplace.json +1 -1
  16. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
  17. package/plugin/plugins/claude-code-kanban/hooks/activity.ts +266 -0
  18. package/plugin/plugins/claude-code-kanban/hooks/context.ts +77 -0
  19. package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -93
  20. package/plugin/plugins/claude-code-kanban/hooks/index.ts +8 -0
  21. package/plugin/plugins/claude-code-kanban/monitors.json +0 -6
  22. package/plugin/plugins/claude-code-kanban/scripts/postman.js +6 -11
  23. package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +17 -11
  24. package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +50 -0
  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 +257 -74
  29. package/public/index.html +12 -2
  30. package/public/project-match.js +6 -1
  31. package/public/style.css +99 -0
  32. package/server.js +146 -147
  33. package/skill-guides/dispatch.md +16 -54
  34. package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +0 -135
  35. package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +0 -208
  36. package/plugin/plugins/claude-code-kanban/scripts/context-status.sh +0 -18
package/lib/retention.js CHANGED
@@ -3,7 +3,6 @@
3
3
  const fs = require('node:fs/promises');
4
4
  const path = require('node:path');
5
5
  const { readSettings } = require('./claude-settings');
6
- const { DISPATCH_OUTCOME } = require('./dispatch');
7
6
 
8
7
  const DAY_MS = 24 * 60 * 60 * 1000;
9
8
  const DEFAULT_CLEANUP_DAYS = 30;
@@ -11,7 +10,6 @@ const DEFAULT_CLEANUP_DAYS = 30;
11
10
  // read that gap as a deleted transcript.
12
11
  const GRACE_MS = 60 * 60 * 1000;
13
12
  const MAX_DISPATCHED = 500;
14
- const OUTCOMES = new Set(Object.keys(DISPATCH_OUTCOME));
15
13
 
16
14
  // Only the config dir's settings: retention is per config dir, not per project.
17
15
  function retentionMs(claudeDir) {
@@ -28,10 +26,10 @@ function isExpired(id, at, { known, maxAgeMs, now }) {
28
26
  }
29
27
 
30
28
  /**
31
- * Sessions started through `dispatch start`, kept after the dispatch settles so the card
29
+ * Sessions started through `dispatch start`, kept after their terminal ends so the card
32
30
  * keeps its marker.
33
31
  * @param {object} o
34
- * @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent, status, at}}}` or null
32
+ * @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent, at}}}` or null
35
33
  * @param {(data: object) => void} o.save
36
34
  */
37
35
  function createDispatchedStore({ load, save, now = Date.now }) {
@@ -41,10 +39,7 @@ function createDispatchedStore({ load, save, now = Date.now }) {
41
39
  if (saved && typeof saved === 'object') {
42
40
  for (const [id, e] of Object.entries(saved)) {
43
41
  if (!e || !Number.isFinite(e.at)) continue;
44
- // The dispatch registry is in memory: a dispatch that was running when the server
45
- // stopped can never settle, so it reads as ended.
46
- const status = OUTCOMES.has(e.status) ? e.status : 'exited';
47
- entries.set(id, { parent: typeof e.parent === 'string' ? e.parent : null, status, at: e.at });
42
+ entries.set(id, { parent: typeof e.parent === 'string' ? e.parent : null, at: e.at });
48
43
  }
49
44
  }
50
45
 
@@ -52,18 +47,11 @@ function createDispatchedStore({ load, save, now = Date.now }) {
52
47
 
53
48
  function record(id, parent) {
54
49
  entries.delete(id);
55
- entries.set(id, { parent: parent || null, status: 'running', at: now() });
50
+ entries.set(id, { parent: parent || null, at: now() });
56
51
  if (entries.size > MAX_DISPATCHED) entries.delete(entries.keys().next().value);
57
52
  persist();
58
53
  }
59
54
 
60
- function settle(id, status) {
61
- const e = entries.get(id);
62
- if (!e || e.status !== 'running' || !OUTCOMES.has(status)) return;
63
- e.status = status;
64
- persist();
65
- }
66
-
67
55
  // Returns how many entries went; writes only when one did.
68
56
  function prune({ known, maxAgeMs }) {
69
57
  const t = now();
@@ -78,7 +66,7 @@ function createDispatchedStore({ load, save, now = Date.now }) {
78
66
  return removed;
79
67
  }
80
68
 
81
- return { record, settle, prune, get: (id) => entries.get(id) || null };
69
+ return { record, prune, get: (id) => entries.get(id) || null };
82
70
  }
83
71
 
84
72
  // Names only, no stat or parse: the sweep needs which transcripts exist, not what they hold.
@@ -4,7 +4,7 @@ const { exportParentVerdicts, importParentVerdicts, parentVerdictsDirty } = requ
4
4
  const { exportTaskCounts, importTaskCounts, taskCountsDirty } = require('./task-counts');
5
5
 
6
6
  // Bump when an entry's shape or meaning changes: a file with another version is ignored.
7
- const VERSION = 2;
7
+ const VERSION = 3;
8
8
  // A cache this large is not one cck wrote for a normal set of transcripts. Rebuild it cold.
9
9
  const MAX_BYTES = 8 * 1024 * 1024;
10
10
 
@@ -79,8 +79,9 @@ function clampWait(sec) {
79
79
  return Math.min(Math.max(Number(sec) || 0, 0), MAX_WAIT_SEC);
80
80
  }
81
81
 
82
- // Each topic rides its own bucket, so the dispatch postman never prints task moves
83
- // the user did not grant with the follow skill. No topic is the task-move bucket.
82
+ // Each topic rides its own bucket, so a postman on a topic never prints task moves the
83
+ // user did not grant with the follow skill. No topic is the task-move bucket. Plugins
84
+ // before 2.22.0 run a `--topic dispatch` postman, which must keep getting nothing.
84
85
  function topicKey(topic, sessionId) {
85
86
  return topic ? `${topic}:${sessionId}` : sessionId;
86
87
  }
package/lib/terminal.js CHANGED
@@ -26,6 +26,17 @@ const PICK_START_SLACK_MS = 2000;
26
26
  const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$/;
27
27
  const WORKTREE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
28
28
  const MODELS = new Set(['fable', 'opus', 'sonnet', 'haiku']);
29
+ // Extra claude args reach the command line inside plain quotes (quoteArg), so a character
30
+ // that ends or expands those quotes in any of the shells is refused.
31
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: refuses control characters on purpose
32
+ const EXTRA_ARG_RE = /^[^\x00-\x1f\x7f'"%]*$/;
33
+ const MAX_EXTRA_ARGS = 64;
34
+ const MAX_EXTRA_ARG = 4096;
35
+ // cck sets these itself, or they would start something other than a new session.
36
+ const OWNED_FLAGS = new Set([
37
+ '--session-id', '-n', '--name', '--model', '-w', '--worktree',
38
+ '-r', '--resume', '-c', '--continue', '--fork-session', '-p', '--print',
39
+ ]);
29
40
  const MAX_PROMPT = 32 * 1024;
30
41
  const PROMPT_QUIET_MS = 400;
31
42
  // Watermarks from https://xtermjs.org/docs/guides/flowcontrol/ — pause the PTY while a
@@ -118,8 +129,8 @@ function resolveShell(value, which, platform = process.platform, env = process.e
118
129
  }
119
130
 
120
131
  // claudeArgs is null for a plain shell. Its items are fixed flags, a validated UUID, or
121
- // values that passed NAME_RE / WORKTREE_RE, so plain quoting is enough to keep a space
122
- // from splitting an argument and nothing can break out of the quotes.
132
+ // values that passed NAME_RE / WORKTREE_RE / EXTRA_ARG_RE, so plain quoting is enough to
133
+ // keep a space from splitting an argument and nothing can break out of the quotes.
123
134
  function quoteArg(arg, family) {
124
135
  if (/^[A-Za-z0-9._-]+$/.test(arg)) return arg;
125
136
  return family === 'cmd' ? `"${arg}"` : `'${arg}'`;
@@ -142,7 +153,7 @@ function claudeArgsFor(mode, id, spec) {
142
153
  if (mode === 'fork') return ['--resume', id, '--fork-session'];
143
154
  if (mode === 'pick') return ['--resume'];
144
155
  if (mode === 'new') {
145
- const args = ['--session-id', id];
156
+ const args = ['--session-id', id, ...(spec.extraArgs || [])];
146
157
  if (spec.name) args.push('--name', spec.name);
147
158
  if (spec.model) args.push('--model', spec.model);
148
159
  // Last, because its value is optional and a following flag must not be taken for it.
@@ -153,6 +164,16 @@ function claudeArgsFor(mode, id, spec) {
153
164
  return ['--resume', id];
154
165
  }
155
166
 
167
+ function extraArgsError(args) {
168
+ if (!Array.isArray(args) || args.length > MAX_EXTRA_ARGS) return `claude args (at most ${MAX_EXTRA_ARGS})`;
169
+ for (const a of args) {
170
+ if (typeof a !== 'string' || a.length > MAX_EXTRA_ARG || !EXTRA_ARG_RE.test(a)) return 'claude arg: no quotes, % or control characters';
171
+ const flag = a.split('=')[0];
172
+ if (OWNED_FLAGS.has(flag)) return `claude arg: cck sets ${flag}`;
173
+ }
174
+ return null;
175
+ }
176
+
156
177
  // Returns the new-session options from a hello, or a string naming the bad field.
157
178
  function parseNewSpec(msg) {
158
179
  if (typeof msg.cwd !== 'string' || !msg.cwd) return 'folder';
@@ -165,7 +186,10 @@ function parseNewSpec(msg) {
165
186
  // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ESC so a prompt cannot carry terminal sequences
166
187
  const prompt = typeof msg.prompt === 'string' ? msg.prompt.replace(/\x1b/g, '').trim() : '';
167
188
  if (prompt.length > MAX_PROMPT) return 'prompt';
168
- return { cwd: msg.cwd, name: name || null, worktree, model, prompt: prompt || null };
189
+ const extraArgs = msg.extraArgs ?? [];
190
+ const bad = extraArgsError(extraArgs);
191
+ if (bad) return bad;
192
+ return { cwd: msg.cwd, name: name || null, worktree, model, prompt: prompt || null, extraArgs };
169
193
  }
170
194
 
171
195
  function samePath(a, b) {
@@ -552,8 +576,9 @@ function createTerminalService(o) {
552
576
  const cols = clampDim(msg.cols, 80);
553
577
  const rows = clampDim(msg.rows, 24);
554
578
 
579
+ // An ended PTY stays in the map until its process exits; a hello for its id starts a new one.
555
580
  const existing = sessions.get(id);
556
- if (existing) return attach(ws, existing, true);
581
+ if (existing && !existing.ended) return attach(ws, existing, true);
557
582
 
558
583
  if (mode === 'auto' && o.isLiveElsewhere(id)) {
559
584
  send(ws, { t: 'live' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "5.3.0",
3
+ "version": "6.0.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",
@@ -11,7 +11,6 @@
11
11
  "start": "node server.js",
12
12
  "dev": "node --watch server.js",
13
13
  "test": "node --test test/*.test.js",
14
- "test:hooks": "bash tests/test-agent-spy.sh && bash tests/test-approval-gate.sh",
15
14
  "validate:schemas": "node test/validate-live-schemas.js",
16
15
  "prepare": "husky"
17
16
  },
@@ -7,7 +7,7 @@
7
7
  {
8
8
  "name": "claude-code-kanban",
9
9
  "source": "./plugins/claude-code-kanban",
10
- "description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, and a skill to drive the board from a session"
10
+ "description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, and a skill to drive the board from a session"
11
11
  }
12
12
  ]
13
13
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.17.2",
4
- "description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, skills to drive the board from a session and to follow it",
3
+ "version": "2.22.0",
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
5
  "experimental": {
6
6
  "monitors": "./monitors.json"
7
7
  }
@@ -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
+ }
@@ -4,11 +4,5 @@
4
4
  "description": "Notifies this session when its tasks are moved on the kanban board or the user sends it review comments.",
5
5
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/postman.js\"",
6
6
  "when": "on-skill-invoke:claude-code-kanban:follow"
7
- },
8
- {
9
- "name": "kanban-dispatch-inbox",
10
- "description": "Notifies this session when a session it dispatched through cck reports or exits.",
11
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/postman.js\" --topic dispatch --keep-backlog",
12
- "when": "on-skill-invoke:claude-code-kanban:dispatch"
13
7
  }
14
8
  ]