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 +1 -0
- package/lib/approvals.js +82 -0
- package/lib/file-url.js +36 -0
- package/package.json +2 -2
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +2 -24
- 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/public/app.js +1335 -165
- package/public/index.html +1 -0
- package/public/style.css +648 -39
- package/server.js +256 -81
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
|
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 };
|
package/lib/file-url.js
ADDED
|
@@ -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.
|
|
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.
|
|
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/
|
|
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
|