claude-code-kanban 4.20.0 → 4.22.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
@@ -49,6 +49,7 @@ Tasks, agents, and messages appear on the board automatically — Claude Code wr
49
49
  - **Follow & pin** — Follow the latest message live (`Shift+M`), pin the messages that matter
50
50
  - **Tool stats & impact** — Per-session tool usage breakdown and file impact
51
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)
52
53
  - **Agent teams** — Color-coded team members, owner filtering, member count badges
53
54
  - **17 color themes** — Dracula, Nord, Catppuccin, Gruvbox, Tokyo Night, and more — each in light and dark
54
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,36 @@
1
+ 'use strict';
2
+
3
+ // Some tools hand out a `file://` URL where a path is expected — Explorer's
4
+ // "copy as path" on a network share, a PDF viewer's location bar, a chat client
5
+ // that linkifies attachments. Windows apps also emit the non-standard backslash
6
+ // spelling `file:\\\C:\dir\a.md`. Both are normalized to a plain OS path so the
7
+ // user does not have to hand-edit what their clipboard produced.
8
+
9
+ const { fileURLToPath } = require('url');
10
+
11
+ // Matches any number of leading slashes before a drive letter, so it re-spells both
12
+ // the well-formed `file:///C:/dir` and `file://C:/dir`, where the parser reads C: as
13
+ // a host and rejects the URL outright. Anything else — POSIX paths, UNC shares — is
14
+ // left for the parser.
15
+ const DRIVE_URL = /^file:\/*([a-zA-Z]:.*)$/;
16
+
17
+ /**
18
+ * Convert a `file://` URL to an OS path. Any other string is returned unchanged,
19
+ * as is a URL too malformed to parse — the caller reports "not found" on the
20
+ * literal text, which is more useful than a parser error.
21
+ * @param {string} value
22
+ */
23
+ function fileUrlToPath(value) {
24
+ if (typeof value !== 'string' || !/^file:/i.test(value)) return value;
25
+ // Backslashes are not legal in a URL, so this cannot corrupt a well-formed one.
26
+ let url = value.replace(/\\/g, '/');
27
+ const drive = DRIVE_URL.exec(url);
28
+ if (drive) url = `file:///${drive[1]}`;
29
+ try {
30
+ return fileURLToPath(url);
31
+ } catch {
32
+ return value;
33
+ }
34
+ }
35
+
36
+ module.exports = { fileUrlToPath };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.20.0",
3
+ "version": "4.22.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,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.7.1",
3
+ "version": "2.10.0",
4
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
5
  "experimental": {
6
6
  "monitors": "./monitors.json"
@@ -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
  }
@@ -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