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 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. It never directs Claude's work.
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
@@ -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.19.0",
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.3.3",
4
- "description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, and a skill to drive the board from a session"
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/agent-spy.sh",
58
- "timeout": 5
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
  }
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "name": "kanban-doorbell",
4
+ "description": "Notifies this session when its tasks are moved on the kanban board.",
5
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/postman.js\"",
6
+ "when": "on-skill-invoke:claude-code-kanban:kanban-follow"
7
+ }
8
+ ]
@@ -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, type: $type, event: "stop", status: "stopped",
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
- echo "{\"agentId\":\"$AGENT_ID\",\"type\":\"$AGENT_TYPE_RAW\",\"event\":\"idle\",\"status\":\"idle\",\"updatedAt\":\"$TS\"}" >> "$FILE"
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 claude-code-kanban dashboard from this session — focus the current session in the browser, pin/unpin it in the sidebar, preview a markdown or HTML file, link a document to the session, or inspect session stats and messages. Use when the user mentions kanban or cck.
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
- The current Claude session id is `${CLAUDE_SESSION_ID}` (substituted when this skill loads), so the user never needs to look it up.
10
+ This session id is `${CLAUDE_SESSION_ID}`, substituted when the skill loads.
10
11
 
11
- When the user passes arguments, map them to the matching command below (`open` → `session open`, `pin`/`unpin`/`pins` → `session pin`/`--unpin`/`session pins`, `preview` → `preview-doc`, `link` → `link-doc`, `list`/`view`/`peek` → the read-only verbs); with no arguments, open the current session.
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
- Prefer the bare `claude-code-kanban` binary; fall back to `npx claude-code-kanban` when it is not on PATH, or when the user asks for npx explicitly.
14
+ To be driven *by* the board instead — card moves arriving as instructions — the user types `/claude-code-kanban:kanban-follow`.
14
15
 
15
- ## Open the current session in kanban
16
+ ## `open` — the current session
16
17
 
17
- Primary use case. Pins the active session in the sidebar and switches to the Active tab.
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
- ## Pin the current session
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 # sticky at top
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
- ## List pinned sessions
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
- Opens a markdown or standalone HTML file in the preview modal (HTML renders live in a sandboxed iframe, so sibling assets like `./style.css` do not load). Relative paths are fine — the server resolves to absolute.
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 <path-to-file.md|.html> --session ${CLAUDE_SESSION_ID}
38
+ claude-code-kanban preview-doc <file.md|.html> --session ${CLAUDE_SESSION_ID}
46
39
  ```
47
40
 
48
- ## Link a document to the session (no modal)
41
+ ## `link` — attach a doc without the modal
49
42
 
50
- Same idea as `preview-doc`, but it only attaches the file to the session's linked docs in the sidebar — nothing pops up, so it is the safe choice while the user is working. Any extension is linkable.
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-to-file> --session ${CLAUDE_SESSION_ID} # link
54
- claude-code-kanban link-doc <path-to-file> --session ${CLAUDE_SESSION_ID} --unlink # remove
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
- ## Inspect sessions (read-only)
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 by default and always includes pinned sessions, sticky first — `--no-pins` disables both.
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 for any command — read it instead of guessing.
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.