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.
- package/README.md +4 -4
- package/cli.js +30 -108
- package/install.js +43 -123
- package/lib/approvals.js +16 -17
- package/lib/dispatch-groups.js +9 -31
- package/lib/dispatch.js +19 -166
- package/lib/git-branch.js +62 -0
- package/lib/parsers.js +96 -59
- package/lib/plugin-status.js +1 -1
- package/lib/retention.js +5 -17
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +3 -2
- package/lib/terminal.js +30 -5
- 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/monitors.json +0 -6
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +6 -11
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +17 -11
- package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +50 -0
- 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 +257 -74
- package/public/index.html +12 -2
- package/public/project-match.js +6 -1
- package/public/style.css +99 -0
- package/server.js +146 -147
- package/skill-guides/dispatch.md +16 -54
- 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
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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.
|
package/lib/session-cache.js
CHANGED
|
@@ -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 =
|
|
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
|
|
package/lib/session-events.js
CHANGED
|
@@ -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
|
|
83
|
-
//
|
|
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
|
|
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
|
-
|
|
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": "
|
|
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
|
|
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.
|
|
4
|
-
"description": "claude-code-kanban dashboard integration: agent activity tracking, context
|
|
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
|
-
"
|
|
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
|
}
|
|
@@ -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
|
]
|