claude-code-kanban 4.19.0 → 4.21.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 +10 -1
- package/lib/approvals.js +82 -0
- package/lib/session-events.js +99 -0
- package/package.json +2 -2
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +5 -2
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +2 -24
- package/plugin/plugins/claude-code-kanban/monitors.json +8 -0
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +17 -3
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +141 -0
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +67 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +20 -29
- package/plugin/plugins/claude-code-kanban/skills/kanban-follow/SKILL.md +39 -0
- package/public/app.js +582 -90
- package/public/index.html +2 -1
- package/public/style.css +359 -5
- package/server.js +157 -26
package/README.md
CHANGED
|
@@ -30,7 +30,15 @@ npx claude-code-kanban --open
|
|
|
30
30
|
|
|
31
31
|
### 3. Use Claude Code as usual
|
|
32
32
|
|
|
33
|
-
Tasks, agents, and messages appear on the board automatically — Claude Code writes task files and conversation logs to `~/.claude`, the dashboard watches them and streams updates to the browser via SSE.
|
|
33
|
+
Tasks, agents, and messages appear on the board automatically — Claude Code writes task files and conversation logs to `~/.claude`, the dashboard watches them and streams updates to the browser via SSE. Moving a card is the one thing that flows the other way: the board notifies the owning session with the card subject and description, so the agent can act on it.
|
|
34
|
+
|
|
35
|
+
> **Empty board?** Claude Code ships the task tools off by default on some models — currently Opus 5, Fable 5 — so nothing writes task files and the board stays empty. Turn them on in `~/.claude/settings.json`:
|
|
36
|
+
>
|
|
37
|
+
> ```json
|
|
38
|
+
> { "env": { "CLAUDE_CODE_ENABLE_TODO_TOOLS": "true" } }
|
|
39
|
+
> ```
|
|
40
|
+
>
|
|
41
|
+
> Then restart Claude Code. You can also add a task by hand from the board's Pending column.
|
|
34
42
|
|
|
35
43
|
## Features
|
|
36
44
|
|
|
@@ -41,6 +49,7 @@ Tasks, agents, and messages appear on the board automatically — Claude Code wr
|
|
|
41
49
|
- **Follow & pin** — Follow the latest message live (`Shift+M`), pin the messages that matter
|
|
42
50
|
- **Tool stats & impact** — Per-session tool usage breakdown and file impact
|
|
43
51
|
- **Waiting-for-user indicators** — Amber highlight on sessions needing permission or input
|
|
52
|
+
- **UI approvals (opt-in)** — Allow/deny permission asks and answer questions from the board — [docs](docs/ui-approvals.md)
|
|
44
53
|
- **Agent teams** — Color-coded team members, owner filtering, member count badges
|
|
45
54
|
- **17 color themes** — Dracula, Nord, Catppuccin, Gruvbox, Tokyo Night, and more — each in light and dark
|
|
46
55
|
- **Storage manager** — Inspect disk usage and clean up stale sessions and tasks
|
package/lib/approvals.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// UI-driven approvals (_plans/cck-ui-approvals/): approval-gate.sh writes the
|
|
2
|
+
// _waiting.json marker with a request id (D8) and polls for _decision-<id>.json.
|
|
3
|
+
// buildDecision validates a board response against the live marker and shapes
|
|
4
|
+
// the decision file; the route writes it. The hook deletes marker + decision on
|
|
5
|
+
// consumption; orphans from a terminal deny (D13) are left for the sweep.
|
|
6
|
+
|
|
7
|
+
// Ids are hook-minted (uuidgen or a time-pid-random compound) — anything outside
|
|
8
|
+
// this alphabet is either corruption or a path-traversal attempt (3.5).
|
|
9
|
+
function sanitizeRequestId(raw) {
|
|
10
|
+
return typeof raw === 'string' && /^[a-zA-Z0-9-]{1,64}$/.test(raw) ? raw : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// A decision whose shape doesn't match the ask's kind produces hook output
|
|
14
|
+
// Claude Code rejects, surfacing as an opaque stall — reject at the API
|
|
15
|
+
// boundary instead (D7). Returns { error, status } or { decision }.
|
|
16
|
+
function buildDecision(marker, body) {
|
|
17
|
+
if (!marker || marker.status !== 'waiting') {
|
|
18
|
+
return { error: 'No pending ask', status: 410 };
|
|
19
|
+
}
|
|
20
|
+
const id = sanitizeRequestId(body && body.id);
|
|
21
|
+
if (!id) return { error: 'Invalid or missing request id', status: 400 };
|
|
22
|
+
if (marker.id !== id) return { error: 'Ask superseded by a newer one', status: 409 };
|
|
23
|
+
|
|
24
|
+
if (marker.kind === 'question') {
|
|
25
|
+
if (!body.answers || typeof body.answers !== 'object') {
|
|
26
|
+
return { error: 'A question ask needs answers', status: 422 };
|
|
27
|
+
}
|
|
28
|
+
// Partial answers are allowed — Claude reads the answered keys and treats
|
|
29
|
+
// the rest as skipped. Only a completely empty object is a no-op ask.
|
|
30
|
+
// multiSelect answers arrive as arrays of labels — Claude Code validates
|
|
31
|
+
// that shape natively.
|
|
32
|
+
const usable = (v) =>
|
|
33
|
+
(typeof v === 'string' && v) ||
|
|
34
|
+
(Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === 'string' && x));
|
|
35
|
+
const given = Object.entries(body.answers).filter(([, v]) => usable(v));
|
|
36
|
+
if (!given.length) {
|
|
37
|
+
return { error: 'A question ask needs at least one answer', status: 422 };
|
|
38
|
+
}
|
|
39
|
+
return { decision: { answers: Object.fromEntries(given) } };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (body.behavior !== 'allow' && body.behavior !== 'deny') {
|
|
43
|
+
return { error: 'A permission ask needs behavior "allow" or "deny"', status: 422 };
|
|
44
|
+
}
|
|
45
|
+
const decision = { behavior: body.behavior };
|
|
46
|
+
if (typeof body.message === 'string' && body.message) decision.message = body.message;
|
|
47
|
+
if (body.updatedInput && typeof body.updatedInput === 'object') decision.updatedInput = body.updatedInput;
|
|
48
|
+
if (Array.isArray(body.updatedPermissions)) decision.updatedPermissions = body.updatedPermissions;
|
|
49
|
+
return { decision };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function decisionFileName(id) {
|
|
53
|
+
return `_decision-${id}.json`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Keep in sync with decisionFileName — the cleanup sweep matches by shape
|
|
57
|
+
function isDecisionFile(name) {
|
|
58
|
+
return name.startsWith('_decision-') && name.endsWith('.json');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// approval-gate.sh only polls for a decision for waitSeconds — after that the
|
|
62
|
+
// ask belongs to the terminal. Default and clamp mirror the gate's own parse
|
|
63
|
+
// (PERMISSION_TTL_MS hides the card at 30 min, so waiting longer than the UI
|
|
64
|
+
// can show the ask is strictly worse — D11); keep them in sync with
|
|
65
|
+
// approval-gate.sh.
|
|
66
|
+
const WAIT_SECONDS_DEFAULT = 30;
|
|
67
|
+
const WAIT_SECONDS_MAX = 1800;
|
|
68
|
+
// Small grace over the gate's own deadline so a race never 410s a live hook
|
|
69
|
+
const LAPSE_GRACE_MS = 5000;
|
|
70
|
+
|
|
71
|
+
function waitSecondsFrom(cfg) {
|
|
72
|
+
const raw = cfg && cfg.waitSeconds;
|
|
73
|
+
if (Number.isInteger(raw) && raw >= 0) return Math.min(raw, WAIT_SECONDS_MAX);
|
|
74
|
+
return WAIT_SECONDS_DEFAULT;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isLapsed(timestamp, waitMs, now = Date.now()) {
|
|
78
|
+
if (!timestamp) return true;
|
|
79
|
+
return now - new Date(timestamp).getTime() > waitMs + LAPSE_GRACE_MS;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { sanitizeRequestId, buildDecision, decisionFileName, isDecisionFile, waitSecondsFrom, isLapsed };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Session event doorbell: tells a live session that the board moved one of its tasks.
|
|
2
|
+
// Its own module so the behaviour is unit-testable without booting the server -- the
|
|
3
|
+
// bucket-map invariants here are the difference between a bounded queue and a map that
|
|
4
|
+
// grows one permanent entry per session id ever named in a request path.
|
|
5
|
+
|
|
6
|
+
// Tells a live session that the board moved one of its tasks. The postman monitor
|
|
7
|
+
// (plugin/plugins/claude-code-kanban/scripts/postman.js) drains this queue and prints
|
|
8
|
+
// each line, which Claude Code delivers into that session as a task notification.
|
|
9
|
+
//
|
|
10
|
+
// Deliberately in-memory and lossy. The task file is the durable command -- a dropped
|
|
11
|
+
// event only means the agent notices on its next turn instead of immediately -- so a
|
|
12
|
+
// disk queue would buy nothing. Reading consumes, so a restarted postman never replays
|
|
13
|
+
// a backlog and acts on the same move twice.
|
|
14
|
+
//
|
|
15
|
+
// A bucket exists only while it holds something: an undelivered line or a waiting
|
|
16
|
+
// poller. Without that, the map would grow one permanent entry per session id ever
|
|
17
|
+
// asked for -- and the id comes straight off the request path.
|
|
18
|
+
const sessionEventBuckets = new Map();
|
|
19
|
+
|
|
20
|
+
// The line reaches the model verbatim at hook trust level. The task id is caller-supplied
|
|
21
|
+
// and the subject and description are board-authored, so the length cap and the
|
|
22
|
+
// control-character scrub are what hold the one-line-per-event contract: a newline inside
|
|
23
|
+
// a description becomes a space rather than a second forged event.
|
|
24
|
+
//
|
|
25
|
+
// One cap for the whole line rather than one per field, because the description comes
|
|
26
|
+
// last: truncation eats its tail first and leaves the machine-readable head intact.
|
|
27
|
+
function sanitizeEventLine(line) {
|
|
28
|
+
return line.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 1500);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Everything after `description=` is the description verbatim to end of line, so no amount
|
|
32
|
+
// of board text can pose as a further field. That leaves the subject as the only value
|
|
33
|
+
// that needs delimiting.
|
|
34
|
+
function formatTaskMoved(taskId, prevStatus, task) {
|
|
35
|
+
const subject = String(task.subject || '').replace(/(["\\])/g, '\\$1');
|
|
36
|
+
const head = `cck:1 task.moved ${taskId} ${prevStatus || 'none'}>${task.status} subject="${subject}"`;
|
|
37
|
+
return task.description ? `${head} description=${task.description}` : head;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function enqueueSessionEvent(sessionId, line) {
|
|
41
|
+
const text = sanitizeEventLine(line);
|
|
42
|
+
if (!sessionId || !text) return;
|
|
43
|
+
let bucket = sessionEventBuckets.get(sessionId);
|
|
44
|
+
if (!bucket) {
|
|
45
|
+
bucket = { queue: [], waiters: new Set() };
|
|
46
|
+
sessionEventBuckets.set(sessionId, bucket);
|
|
47
|
+
}
|
|
48
|
+
bucket.queue.push(text);
|
|
49
|
+
// A session with no postman attached must not grow without bound.
|
|
50
|
+
if (bucket.queue.length > 50) bucket.queue.splice(0, bucket.queue.length - 50);
|
|
51
|
+
for (const wake of [...bucket.waiters]) wake();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Long-poll drained by the postman monitor. Routing stays in server.js; this is the handler.
|
|
55
|
+
function handleSessionEvents(req, res) {
|
|
56
|
+
const { sessionId } = req.params;
|
|
57
|
+
const bucket = sessionEventBuckets.get(sessionId);
|
|
58
|
+
const wait = Math.min(Math.max(Number(req.query.wait) || 0, 0), 120);
|
|
59
|
+
|
|
60
|
+
// A postman is armed by a skill invocation, so it can attach long after the board moved
|
|
61
|
+
// something. Those lines are read as instructions, and an hours-old instruction is worse
|
|
62
|
+
// than no instruction, so the grant starts the session's history rather than inheriting
|
|
63
|
+
// it: `first=1` drops the whole backlog. drain() (not a bare truncate) so an emptied
|
|
64
|
+
// bucket is still evicted from the map.
|
|
65
|
+
if (bucket && req.query.first === '1') drain(sessionId, bucket);
|
|
66
|
+
|
|
67
|
+
if (bucket && bucket.queue.length) return res.json({ events: drain(sessionId, bucket) });
|
|
68
|
+
if (!wait) return res.json({ events: [] });
|
|
69
|
+
|
|
70
|
+
const pending = bucket || { queue: [], waiters: new Set() };
|
|
71
|
+
sessionEventBuckets.set(sessionId, pending);
|
|
72
|
+
|
|
73
|
+
const send = () => {
|
|
74
|
+
// Set.delete is the whole idempotency story: whichever of enqueue, timeout, or
|
|
75
|
+
// client disconnect gets here first is the one that answers.
|
|
76
|
+
if (!pending.waiters.delete(send)) return;
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
req.removeListener('close', send);
|
|
79
|
+
const events = drain(sessionId, pending);
|
|
80
|
+
if (!res.writableEnded) res.json({ events });
|
|
81
|
+
};
|
|
82
|
+
const timer = setTimeout(send, wait * 1000);
|
|
83
|
+
pending.waiters.add(send);
|
|
84
|
+
req.on('close', send);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function drain(sessionId, bucket) {
|
|
88
|
+
const events = bucket.queue.splice(0);
|
|
89
|
+
if (!bucket.queue.length && !bucket.waiters.size) sessionEventBuckets.delete(sessionId);
|
|
90
|
+
return events;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
sessionEventBuckets,
|
|
95
|
+
sanitizeEventLine,
|
|
96
|
+
formatTaskMoved,
|
|
97
|
+
enqueueSessionEvent,
|
|
98
|
+
handleSessionEvents,
|
|
99
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.21.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
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"start": "node server.js",
|
|
11
11
|
"dev": "node --watch server.js",
|
|
12
12
|
"test": "node --test test/*.test.js",
|
|
13
|
-
"test:hooks": "bash tests/test-agent-spy.sh",
|
|
13
|
+
"test:hooks": "bash tests/test-agent-spy.sh && bash tests/test-approval-gate.sh",
|
|
14
14
|
"validate:schemas": "node test/validate-live-schemas.js",
|
|
15
15
|
"prepare": "husky"
|
|
16
16
|
},
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline,
|
|
3
|
+
"version": "2.10.0",
|
|
4
|
+
"description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, skills to drive the board from a session and to follow it",
|
|
5
|
+
"experimental": {
|
|
6
|
+
"monitors": "./monitors.json"
|
|
7
|
+
}
|
|
5
8
|
}
|
|
@@ -54,30 +54,8 @@
|
|
|
54
54
|
"hooks": [
|
|
55
55
|
{
|
|
56
56
|
"type": "command",
|
|
57
|
-
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/
|
|
58
|
-
"timeout":
|
|
59
|
-
}
|
|
60
|
-
]
|
|
61
|
-
}
|
|
62
|
-
],
|
|
63
|
-
"PreToolUse": [
|
|
64
|
-
{
|
|
65
|
-
"matcher": "AskUserQuestion",
|
|
66
|
-
"hooks": [
|
|
67
|
-
{
|
|
68
|
-
"type": "command",
|
|
69
|
-
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh",
|
|
70
|
-
"timeout": 5
|
|
71
|
-
}
|
|
72
|
-
]
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
"matcher": "ExitPlanMode",
|
|
76
|
-
"hooks": [
|
|
77
|
-
{
|
|
78
|
-
"type": "command",
|
|
79
|
-
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh",
|
|
80
|
-
"timeout": 5
|
|
57
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/approval-gate.sh",
|
|
58
|
+
"timeout": 86400
|
|
81
59
|
}
|
|
82
60
|
]
|
|
83
61
|
}
|
|
@@ -47,6 +47,15 @@ fi
|
|
|
47
47
|
# EnterPlanMode has no waiting semantics — skip
|
|
48
48
|
[ "$TOOL_NAME" = "EnterPlanMode" ] && exit 0
|
|
49
49
|
|
|
50
|
+
# Legacy-config shim: shipped hooks.json routes PermissionRequest to
|
|
51
|
+
# approval-gate.sh (which owns the id-bearing marker) and registers no
|
|
52
|
+
# PreToolUse, so the suppression and writer below only run for configs that
|
|
53
|
+
# still route those events here. Suppressing question/plan PermissionRequest
|
|
54
|
+
# keeps such a config from overwriting the gate's marker with an id-less one.
|
|
55
|
+
if [ "$EVENT" = "PermissionRequest" ] && { [ "$TOOL_NAME" = "AskUserQuestion" ] || [ "$TOOL_NAME" = "ExitPlanMode" ]; }; then
|
|
56
|
+
exit 0
|
|
57
|
+
fi
|
|
58
|
+
|
|
50
59
|
# Waiting-for-user events → write _waiting.json marker
|
|
51
60
|
if [ "$EVENT" = "PermissionRequest" ] || { [ "$EVENT" = "PreToolUse" ] && { [ "$TOOL_NAME" = "AskUserQuestion" ] || [ "$TOOL_NAME" = "ExitPlanMode" ]; }; }; then
|
|
52
61
|
DIR="$CCK_ACTIVITY/$SESSION_ID"
|
|
@@ -103,12 +112,17 @@ if [ "$EVENT" = "SubagentStart" ]; then
|
|
|
103
112
|
fi
|
|
104
113
|
|
|
105
114
|
elif [ "$EVENT" = "SubagentStop" ]; then
|
|
115
|
+
# Omit empty type: the server folds lines last-key-wins, so an empty type
|
|
116
|
+
# here would clobber the type recorded by the start line
|
|
106
117
|
echo "$INPUT" | jq -c \
|
|
107
118
|
--arg id "$AGENT_ID" --arg type "$AGENT_TYPE_RAW" --arg ts "$TS" \
|
|
108
|
-
'{agentId: $id,
|
|
109
|
-
lastMessage: (.last_assistant_message // ""), stoppedAt: $ts, updatedAt: $ts}
|
|
119
|
+
'{agentId: $id, event: "stop", status: "stopped",
|
|
120
|
+
lastMessage: (.last_assistant_message // ""), stoppedAt: $ts, updatedAt: $ts}
|
|
121
|
+
+ (if $type == "" then {} else {type: $type} end)' \
|
|
110
122
|
>> "$FILE"
|
|
111
123
|
|
|
112
124
|
elif [ "$EVENT" = "TeammateIdle" ]; then
|
|
113
|
-
|
|
125
|
+
TYPE_FIELD=""
|
|
126
|
+
[ -n "$AGENT_TYPE_RAW" ] && TYPE_FIELD="\"type\":\"$AGENT_TYPE_RAW\","
|
|
127
|
+
echo "{\"agentId\":\"$AGENT_ID\",${TYPE_FIELD}\"event\":\"idle\",\"status\":\"idle\",\"updatedAt\":\"$TS\"}" >> "$FILE"
|
|
114
128
|
fi
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Blocking approval gate: lets the cck board answer a permission ask or an
|
|
3
|
+
# AskUserQuestion. Always writes the _waiting.json marker first (badge behavior
|
|
4
|
+
# is unchanged when the feature is off), then — only when explicitly enabled and
|
|
5
|
+
# the board's server is alive — waits for a decision file written by the server.
|
|
6
|
+
#
|
|
7
|
+
# Contract (_plans/cck-ui-approvals/decisions.md):
|
|
8
|
+
# marker ~/.claude/.cck/agent-activity/<sid>/_waiting.json (D8: + id, cwd, permissionSuggestions)
|
|
9
|
+
# decision ~/.claude/.cck/agent-activity/<sid>/_decision-<id>.json (server writes it, Phase 3)
|
|
10
|
+
# config ~/.claude/.cck/approvals.json {enabled, mode, waitSeconds} (D2: fail-open when absent)
|
|
11
|
+
# liveness ~/.claude/.cck/server.json {port, pid} (D1: a dead board costs nothing)
|
|
12
|
+
#
|
|
13
|
+
# First writer wins (D5): a terminal answer deletes the marker via PostToolUse
|
|
14
|
+
# and this gate exits silently; a decision arriving after the tool already ran
|
|
15
|
+
# is discarded by Claude Code, so a losing write on either side is harmless.
|
|
16
|
+
|
|
17
|
+
INPUT=$(cat)
|
|
18
|
+
|
|
19
|
+
eval "$(echo "$INPUT" | jq -r '
|
|
20
|
+
@sh "SESSION_ID=\(.session_id // "")",
|
|
21
|
+
@sh "EVENT=\(.hook_event_name // "")",
|
|
22
|
+
@sh "TOOL_NAME=\(.tool_name // "")"
|
|
23
|
+
')"
|
|
24
|
+
|
|
25
|
+
[ -z "$SESSION_ID" ] && exit 0
|
|
26
|
+
|
|
27
|
+
# AskUserQuestion and ExitPlanMode gate on PermissionRequest, not PreToolUse:
|
|
28
|
+
# the TUI question and plan dialogs render ~10 s in while a PermissionRequest
|
|
29
|
+
# hook blocks (first writer wins, like permissions), but stay frozen for the
|
|
30
|
+
# whole wait during a PreToolUse hook — measured live (#42, #40). Suppress the
|
|
31
|
+
# PreToolUse double-fire in case a stale hooks.json still registers it.
|
|
32
|
+
if [ "$EVENT" = "PreToolUse" ]; then
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
KIND="permission"
|
|
37
|
+
[ "$TOOL_NAME" = "AskUserQuestion" ] && KIND="question"
|
|
38
|
+
[ "$TOOL_NAME" = "ExitPlanMode" ] && KIND="plan"
|
|
39
|
+
|
|
40
|
+
CCK_DIR="$HOME/.claude/.cck"
|
|
41
|
+
DIR="$CCK_DIR/agent-activity/$SESSION_ID"
|
|
42
|
+
MARKER="$DIR/_waiting.json"
|
|
43
|
+
mkdir -p "$DIR"
|
|
44
|
+
|
|
45
|
+
# uuidgen is missing on some Git Bash installs; uniqueness only has to hold
|
|
46
|
+
# across the asks of one session, so a timestamp compound is enough
|
|
47
|
+
REQ_ID=$(uuidgen 2>/dev/null) || REQ_ID="$(date +%s%N)-$$-$RANDOM"
|
|
48
|
+
|
|
49
|
+
TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
50
|
+
echo "$INPUT" | jq -c --arg kind "$KIND" --arg ts "$TS" --arg id "$REQ_ID" '{
|
|
51
|
+
status: "waiting",
|
|
52
|
+
kind: $kind,
|
|
53
|
+
id: $id,
|
|
54
|
+
toolName: (.tool_name // "unknown"),
|
|
55
|
+
toolInput: ((.tool_input | tostring) // ""),
|
|
56
|
+
cwd: (.cwd // ""),
|
|
57
|
+
permissionSuggestions: (.permission_suggestions // []),
|
|
58
|
+
timestamp: $ts
|
|
59
|
+
}' > "$MARKER"
|
|
60
|
+
|
|
61
|
+
# Every exit below leaves the marker in place for the badge; agent-spy.sh's
|
|
62
|
+
# PostToolUse (or the server's TTL) retires it, exactly as before this feature.
|
|
63
|
+
|
|
64
|
+
CONFIG="$CCK_DIR/approvals.json"
|
|
65
|
+
[ -f "$CONFIG" ] || exit 0
|
|
66
|
+
ENABLED=""
|
|
67
|
+
eval "$(jq -r '
|
|
68
|
+
@sh "ENABLED=\(.enabled // false)",
|
|
69
|
+
@sh "MODE=\(.mode // "permission")",
|
|
70
|
+
@sh "WAIT_SECONDS=\(.waitSeconds // 30)"
|
|
71
|
+
' < "$CONFIG" 2>/dev/null)"
|
|
72
|
+
[ "$ENABLED" = "true" ] || exit 0
|
|
73
|
+
|
|
74
|
+
if [ "$KIND" = "question" ] && [ "$MODE" != "permission+question" ]; then
|
|
75
|
+
exit 0
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
case "$WAIT_SECONDS" in *[!0-9]* | "") WAIT_SECONDS=30 ;; esac
|
|
79
|
+
# PERMISSION_TTL_MS hides the card at 30 min — waiting longer than the UI can
|
|
80
|
+
# show the ask is strictly worse than giving up (D11)
|
|
81
|
+
[ "$WAIT_SECONDS" -gt 1800 ] && WAIT_SECONDS=1800
|
|
82
|
+
|
|
83
|
+
SERVER_INFO="$CCK_DIR/server.json"
|
|
84
|
+
[ -f "$SERVER_INFO" ] || exit 0
|
|
85
|
+
SERVER_PORT=$(jq -r '.port // empty' < "$SERVER_INFO" 2>/dev/null)
|
|
86
|
+
[ -n "$SERVER_PORT" ] || exit 0
|
|
87
|
+
# A TCP connect beats a pid probe: it proves the board is actually serving, and
|
|
88
|
+
# it works in the stripped environment Claude Code spawns hooks into, where
|
|
89
|
+
# kill -0 cannot see native Windows pids and ps may be missing from PATH
|
|
90
|
+
(: < "/dev/tcp/127.0.0.1/$SERVER_PORT") 2>/dev/null || exit 0
|
|
91
|
+
|
|
92
|
+
DECISION="$DIR/_decision-$REQ_ID.json"
|
|
93
|
+
# EPOCHSECONDS (bash 5) keeps the poll loop free of `date` spawns
|
|
94
|
+
DEADLINE=$((EPOCHSECONDS + WAIT_SECONDS))
|
|
95
|
+
|
|
96
|
+
while :; do
|
|
97
|
+
if [ -f "$DECISION" ]; then
|
|
98
|
+
PAYLOAD=$(cat "$DECISION" 2>/dev/null)
|
|
99
|
+
rm -f "$DECISION" "$MARKER"
|
|
100
|
+
[ -n "$PAYLOAD" ] || exit 0
|
|
101
|
+
if [ "$KIND" = "plan" ] && [ "$(echo "$PAYLOAD" | jq -r '.behavior // "deny"' 2>/dev/null)" = "allow" ]; then
|
|
102
|
+
# A plan allow must echo tool_input as updatedInput — Claude Code
|
|
103
|
+
# >= 2.1.199 silently drops an ExitPlanMode allow without it and falls
|
|
104
|
+
# back to the built-in dialog (measured; plannotator does the same).
|
|
105
|
+
echo "$INPUT" | jq -c --argjson p "$PAYLOAD" \
|
|
106
|
+
'{hookSpecificOutput: {hookEventName: "PermissionRequest",
|
|
107
|
+
decision: ({behavior: "allow", updatedInput: (.tool_input // {})}
|
|
108
|
+
+ (if $p.updatedPermissions then {updatedPermissions: $p.updatedPermissions} else {} end))}}' 2>/dev/null
|
|
109
|
+
elif [ "$KIND" != "question" ]; then
|
|
110
|
+
# Permission asks and plan denies share this shaping — the server sends
|
|
111
|
+
# only behavior+message for a plan deny. PermissionRequest decisions must
|
|
112
|
+
# ride hookSpecificOutput — a top-level {decision} is the approve/block
|
|
113
|
+
# string channel and an object there throws
|
|
114
|
+
echo "$PAYLOAD" | jq -c '{hookSpecificOutput: {hookEventName: "PermissionRequest",
|
|
115
|
+
decision: ({behavior: (.behavior // "deny")}
|
|
116
|
+
+ (if .message then {message: .message} else {} end)
|
|
117
|
+
+ (if .updatedInput then {updatedInput: .updatedInput} else {} end)
|
|
118
|
+
+ (if .updatedPermissions then {updatedPermissions: .updatedPermissions} else {} end))}}' 2>/dev/null
|
|
119
|
+
else
|
|
120
|
+
# updatedInput replaces the whole input object, so echo every field and
|
|
121
|
+
# add the answers Claude never fills in itself (D6). Questions ride the
|
|
122
|
+
# PermissionRequest channel now (#42) — allow with the answers filled in.
|
|
123
|
+
ANSWERS=$(echo "$PAYLOAD" | jq -c '.answers // empty' 2>/dev/null)
|
|
124
|
+
[ -n "$ANSWERS" ] || exit 0
|
|
125
|
+
echo "$INPUT" | jq -c --argjson answers "$ANSWERS" \
|
|
126
|
+
'{hookSpecificOutput: {hookEventName: "PermissionRequest",
|
|
127
|
+
decision: {behavior: "allow", updatedInput: ((.tool_input // {}) + {answers: $answers})}}}' 2>/dev/null
|
|
128
|
+
fi
|
|
129
|
+
exit 0
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
# Marker gone = answered in the terminal (PostToolUse fires ~23 ms after — D5);
|
|
133
|
+
# id changed = displaced by a newer ask (D8). Either way this gate is over.
|
|
134
|
+
# Builtin read + substring match instead of jq: a spawn costs ~280 ms on
|
|
135
|
+
# Windows (O2), and the marker is single-line jq -c output with a known id.
|
|
136
|
+
IFS= read -r CUR_MARKER < "$MARKER" 2>/dev/null || exit 0
|
|
137
|
+
case "$CUR_MARKER" in *"\"id\":\"$REQ_ID\""*) ;; *) exit 0 ;; esac
|
|
138
|
+
|
|
139
|
+
[ "$EPOCHSECONDS" -ge "$DEADLINE" ] && exit 0
|
|
140
|
+
sleep 0.5
|
|
141
|
+
done
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Kanban -> session doorbell.
|
|
3
|
+
//
|
|
4
|
+
// Claude Code delivers every line a monitor prints to the owning session as a task
|
|
5
|
+
// notification, so this process is the only way board activity can reach a session that
|
|
6
|
+
// is sitting idle. It long-polls the kanban server for events addressed to this session
|
|
7
|
+
// and prints them one per line.
|
|
8
|
+
//
|
|
9
|
+
// Pairing is free: CLAUDE_CODE_SESSION_ID is inherited from the session that spawned us,
|
|
10
|
+
// so the id we poll with is the same id the hooks report. No cwd or pid guessing.
|
|
11
|
+
//
|
|
12
|
+
// The lines we print carry board text (the card subject and description), which the
|
|
13
|
+
// session is told to read as the user's own brief. That is only safe because we are armed
|
|
14
|
+
// by an explicit `kanban-follow` invocation: the user asked to follow the board before anything the
|
|
15
|
+
// board says can reach the model.
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
const SESSION_ID = process.env.CLAUDE_CODE_SESSION_ID;
|
|
22
|
+
const SERVER_INFO = path.join(os.homedir(), '.claude', '.cck', 'server.json');
|
|
23
|
+
// The server caps its own wait at 120s. Sitting at the ceiling halves every recurring
|
|
24
|
+
// cost -- handshake, route walk, timer, empty response -- and costs no event latency,
|
|
25
|
+
// because an enqueue wakes the poll immediately.
|
|
26
|
+
const WAIT_SEC = 120;
|
|
27
|
+
const RETRY_MS = 15000;
|
|
28
|
+
|
|
29
|
+
if (!SESSION_ID) process.exit(0);
|
|
30
|
+
|
|
31
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
32
|
+
|
|
33
|
+
// Re-read every cycle rather than caching: it is how we follow the board across a
|
|
34
|
+
// restart onto a different port. A file left behind by a crashed server names a port
|
|
35
|
+
// something else may now hold, so trust it only while its pid is alive.
|
|
36
|
+
function serverUrl() {
|
|
37
|
+
const { port, pid } = JSON.parse(fs.readFileSync(SERVER_INFO, 'utf8'));
|
|
38
|
+
if (pid) process.kill(pid, 0);
|
|
39
|
+
return `http://127.0.0.1:${port}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Once per process, not once per poll: the grant means "follow the board from here on", so
|
|
43
|
+
// the first attach throws away whatever queued up before it. A later reconnect must not
|
|
44
|
+
// discard again -- by then the queue holds events the user is owed.
|
|
45
|
+
let firstAttach = true;
|
|
46
|
+
|
|
47
|
+
async function poll(base) {
|
|
48
|
+
const first = firstAttach ? '&first=1' : '';
|
|
49
|
+
const url = `${base}/api/sessions/${encodeURIComponent(SESSION_ID)}/events?wait=${WAIT_SEC}${first}`;
|
|
50
|
+
const res = await fetch(url, { signal: AbortSignal.timeout((WAIT_SEC + 15) * 1000) });
|
|
51
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
52
|
+
firstAttach = false;
|
|
53
|
+
const { events } = await res.json();
|
|
54
|
+
return Array.isArray(events) ? events : [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
(async () => {
|
|
58
|
+
for (;;) {
|
|
59
|
+
try {
|
|
60
|
+
for (const line of await poll(serverUrl())) console.log(line);
|
|
61
|
+
} catch (_) {
|
|
62
|
+
// No board yet, or it went away. It may come back later in the session, so keep
|
|
63
|
+
// waiting quietly -- a missing server is the normal case, not an error.
|
|
64
|
+
await sleep(RETRY_MS);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
@@ -1,60 +1,53 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kanban
|
|
3
|
-
description: Drive the
|
|
3
|
+
description: Drive the kanban board — open, pin, preview, link, inspect.
|
|
4
4
|
argument-hint: '[open|pin|unpin|preview|link] [target]'
|
|
5
|
+
disable-model-invocation: true
|
|
5
6
|
---
|
|
6
7
|
|
|
7
8
|
# Kanban Skill
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
This session id is `${CLAUDE_SESSION_ID}`, substituted when the skill loads.
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
An argument names the section below that handles it; with no argument, open the current session. Prefer the bare `claude-code-kanban` binary, falling back to `npx claude-code-kanban` when it is off PATH or the user asks for npx.
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
To be driven *by* the board instead — card moves arriving as instructions — the user types `/claude-code-kanban:kanban-follow`.
|
|
14
15
|
|
|
15
|
-
##
|
|
16
|
+
## `open` — the current session
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
Pins the session and switches the board to the Active tab.
|
|
18
19
|
|
|
19
20
|
```bash
|
|
20
21
|
claude-code-kanban session open ${CLAUDE_SESSION_ID}
|
|
21
22
|
```
|
|
22
23
|
|
|
23
|
-
##
|
|
24
|
-
|
|
25
|
-
Pins the session so it stays visible regardless of filters. Three states: `pinned` (default), `sticky` (always at the top), or cleared with `--unpin`.
|
|
24
|
+
## `pin` — keep the session visible
|
|
26
25
|
|
|
27
26
|
```bash
|
|
28
27
|
claude-code-kanban session pin ${CLAUDE_SESSION_ID} # pin
|
|
29
|
-
claude-code-kanban session pin ${CLAUDE_SESSION_ID} --sticky #
|
|
28
|
+
claude-code-kanban session pin ${CLAUDE_SESSION_ID} --sticky # always at the top
|
|
30
29
|
claude-code-kanban session pin ${CLAUDE_SESSION_ID} --unpin # clear
|
|
30
|
+
claude-code-kanban session pins # list pinned; --sticky narrows
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
##
|
|
34
|
-
|
|
35
|
-
```bash
|
|
36
|
-
claude-code-kanban session pins # all pinned/sticky
|
|
37
|
-
claude-code-kanban session pins --sticky # sticky only
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
## Preview a file in kanban
|
|
33
|
+
## `preview` — open a file in the modal
|
|
41
34
|
|
|
42
|
-
|
|
35
|
+
Markdown or standalone HTML. HTML renders in a sandboxed iframe, so sibling assets like `./style.css` do not load. Relative paths are fine — the server resolves them.
|
|
43
36
|
|
|
44
37
|
```bash
|
|
45
|
-
claude-code-kanban preview-doc <
|
|
38
|
+
claude-code-kanban preview-doc <file.md|.html> --session ${CLAUDE_SESSION_ID}
|
|
46
39
|
```
|
|
47
40
|
|
|
48
|
-
##
|
|
41
|
+
## `link` — attach a doc without the modal
|
|
49
42
|
|
|
50
|
-
|
|
43
|
+
Adds the file to the session's linked docs in the sidebar. Any extension, and nothing pops up, so it is the safe choice while the user is working.
|
|
51
44
|
|
|
52
45
|
```bash
|
|
53
|
-
claude-code-kanban link-doc <path
|
|
54
|
-
claude-code-kanban link-doc <path
|
|
46
|
+
claude-code-kanban link-doc <path> --session ${CLAUDE_SESSION_ID} # link
|
|
47
|
+
claude-code-kanban link-doc <path> --session ${CLAUDE_SESSION_ID} --unlink # remove
|
|
55
48
|
```
|
|
56
49
|
|
|
57
|
-
##
|
|
50
|
+
## `list` / `view` / `peek` — read-only
|
|
58
51
|
|
|
59
52
|
```bash
|
|
60
53
|
claude-code-kanban session list --active # recent active sessions
|
|
@@ -64,12 +57,10 @@ claude-code-kanban session view ${CLAUDE_SESSION_ID} # full stats
|
|
|
64
57
|
claude-code-kanban session peek ${CLAUDE_SESSION_ID} --limit 20 # last 20 messages (server caps at 50)
|
|
65
58
|
```
|
|
66
59
|
|
|
67
|
-
`session list` shows 10 rows
|
|
68
|
-
|
|
69
|
-
Add `--json` to any list-style verb for machine-readable output.
|
|
60
|
+
`session list` shows 10 rows and always includes pinned sessions, sticky first (`--no-pins` disables both). `--json` works on any list-style verb.
|
|
70
61
|
|
|
71
62
|
## Troubleshooting
|
|
72
63
|
|
|
73
|
-
`claude-code-kanban help <command>` prints the authoritative flags
|
|
64
|
+
`claude-code-kanban help <command>` prints the authoritative flags — read it instead of guessing.
|
|
74
65
|
|
|
75
66
|
- **"Cannot reach cck server…"** → the error names the port it tried. Ask the user to start the server with `claude-code-kanban`. If they run it elsewhere, set `PORT=<n>` when invoking the CLI.
|