claude-code-kanban 4.20.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 +1 -0
- package/lib/approvals.js +82 -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 +355 -38
- package/public/index.html +1 -0
- package/public/style.css +249 -0
- package/server.js +58 -0
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/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,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
|
package/public/app.js
CHANGED
|
@@ -567,8 +567,6 @@ async function refreshProjectAgents() {
|
|
|
567
567
|
renderAgentFooter();
|
|
568
568
|
}
|
|
569
569
|
|
|
570
|
-
//#endregion
|
|
571
|
-
|
|
572
570
|
//#region MESSAGE_PANEL
|
|
573
571
|
function toggleMessagePanel() {
|
|
574
572
|
const panel = document.getElementById('message-panel');
|
|
@@ -1180,18 +1178,19 @@ function toggleToolGroup(id) {
|
|
|
1180
1178
|
if (el) el.classList.toggle('show');
|
|
1181
1179
|
}
|
|
1182
1180
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
if (tool === 'ExitPlanMode') return 'Plan awaiting approval';
|
|
1186
|
-
return 'Question pending';
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1181
|
+
// Legacy markers (written before the gate minted kind:"plan") carry no kind
|
|
1182
|
+
// for plans — the tool name is the durable signal there.
|
|
1189
1183
|
function getWaitingPill(kind, tool) {
|
|
1190
|
-
if (kind === '
|
|
1184
|
+
if (kind === 'plan' || tool === 'ExitPlanMode') return 'Plan awaiting approval';
|
|
1191
1185
|
if (kind === 'question') return 'Question pending';
|
|
1192
1186
|
return 'Awaiting permission';
|
|
1193
1187
|
}
|
|
1194
1188
|
|
|
1189
|
+
function getWaitingLabel(kind, tool) {
|
|
1190
|
+
const pill = getWaitingPill(kind, tool);
|
|
1191
|
+
return pill === 'Awaiting permission' ? `${pill}: ${tool}` : pill;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1195
1194
|
function deriveWaitingDetail(tool, params) {
|
|
1196
1195
|
if (!params) return '';
|
|
1197
1196
|
const trunc = (s) => (s.length > 80 ? `${s.slice(0, 80)}...` : s);
|
|
@@ -1235,17 +1234,40 @@ function renderWaitingBody(tool, params) {
|
|
|
1235
1234
|
return renderToolParamsHtml(params);
|
|
1236
1235
|
}
|
|
1237
1236
|
|
|
1237
|
+
const PLAN_REJECT_MSG = 'Plan rejected from the kanban board.';
|
|
1238
|
+
|
|
1239
|
+
// Answerable only when the marker carries a request id (written by
|
|
1240
|
+
// approval-gate.sh, which is polling for a decision) and the gate hasn't
|
|
1241
|
+
// lapsed past its wait window — older or lapsed markers are only answerable
|
|
1242
|
+
// in the terminal.
|
|
1243
|
+
function isWaitingAnswerable() {
|
|
1244
|
+
return !!(currentWaiting?.id && !currentWaiting.lapsed);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function parseWaitingInput() {
|
|
1248
|
+
try {
|
|
1249
|
+
return JSON.parse(currentWaiting?.toolInput || '');
|
|
1250
|
+
} catch (_) {
|
|
1251
|
+
return null; /* toolInput may be truncated/non-JSON */
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// One source for the decision-button pair on every surface — Approve/Reject
|
|
1256
|
+
// for plans, Allow/Deny for permissions; only the css class prefix differs.
|
|
1257
|
+
// Questions return '' (answered via the form in the detail modal).
|
|
1258
|
+
function waitingDecisionButtons(cls) {
|
|
1259
|
+
if (!isWaitingAnswerable() || currentWaiting.kind === 'question') return '';
|
|
1260
|
+
const plan = currentWaiting.kind === 'plan';
|
|
1261
|
+
// Code-authored call argument, not data — hence the constant-style name the
|
|
1262
|
+
// escaping check reads as safe.
|
|
1263
|
+
const DENY_ARG = plan ? "{behavior:'deny',message:PLAN_REJECT_MSG}" : "{behavior:'deny'}";
|
|
1264
|
+
return `<button class="${escapeHtml(cls)}-allow" onclick="event.stopPropagation();respondWaiting({behavior:'allow'})">${plan ? 'Approve' : 'Allow'}</button><button class="${escapeHtml(cls)}-deny" onclick="event.stopPropagation();respondWaiting(${DENY_ARG})">${plan ? 'Reject' : 'Deny'}</button>`;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1238
1267
|
function renderWaitingEntry() {
|
|
1239
1268
|
if (!isWaitingFresh()) return '';
|
|
1240
1269
|
const tool = currentWaiting.toolName || 'unknown';
|
|
1241
|
-
|
|
1242
|
-
if (currentWaiting.toolInput) {
|
|
1243
|
-
try {
|
|
1244
|
-
params = JSON.parse(currentWaiting.toolInput);
|
|
1245
|
-
} catch (_) {
|
|
1246
|
-
/* toolInput may be truncated/non-JSON */
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1270
|
+
const params = parseWaitingInput();
|
|
1249
1271
|
const pillText = getWaitingPill(currentWaiting.kind, tool);
|
|
1250
1272
|
const detail = deriveWaitingDetail(tool, params);
|
|
1251
1273
|
const detailHtml = detail ? ` <span style="color:var(--text-secondary)">${escapeHtml(detail)}</span>` : '';
|
|
@@ -1253,7 +1275,73 @@ function renderWaitingEntry() {
|
|
|
1253
1275
|
const bodyWrap = bodyHtml ? `<div class="msg-waiting-body">${bodyHtml}</div>` : '';
|
|
1254
1276
|
const pill = `<span class="msg-waiting-pill">${escapeHtml(pillText)}</span>`;
|
|
1255
1277
|
const discardBtn = `<button class="msg-waiting-discard" title="Discard permission prompt" onclick="event.stopPropagation();discardWaiting()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>`;
|
|
1256
|
-
|
|
1278
|
+
// Questions are answered in the detail modal; plans render there too.
|
|
1279
|
+
const buttons = waitingDecisionButtons('msg-waiting');
|
|
1280
|
+
const actions = buttons ? `<span class="msg-waiting-actions">${buttons}</span>` : '';
|
|
1281
|
+
// Lapsed = gate stopped polling — the ask is only answerable in the terminal
|
|
1282
|
+
const lapsed = !!currentWaiting.lapsed;
|
|
1283
|
+
return `<div class="msg-item msg-waiting" onclick="msgDetailFollowLatest=false;showWaitingDetail()">${getToolIcon(tool)}<div class="msg-body"><div class="msg-text">${pill} <span style="font-weight:600">${escapeHtml(tool)}</span>${detailHtml}</div>${bodyWrap}<div class="msg-waiting-footer"><div class="msg-time">${lapsed ? 'answer in the terminal' : 'waiting…'}</div>${actions}</div></div>${discardBtn}</div>`;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// Shared approval controls for a fresh plan ask — the waiting-detail footer and
|
|
1287
|
+
// the saved-plan modal offer the same decision, so they render the same row.
|
|
1288
|
+
// Distinct input ids keep the two surfaces from shadowing each other's feedback.
|
|
1289
|
+
function planApprovalControlsHtml(inputId) {
|
|
1290
|
+
return `<input id="${escapeHtml(inputId)}" class="waiting-option-input" type="text" placeholder="feedback for reject (optional)…"><button class="waiting-btn-allow" onclick="respondWaiting({behavior:'allow'})">Approve</button><button class="waiting-btn-deny" onclick="rejectWaitingPlan('${escAttrJs(inputId)}')">Reject</button>`;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
1294
|
+
function rejectWaitingPlan(inputId) {
|
|
1295
|
+
const feedback = document.getElementById(inputId)?.value?.trim();
|
|
1296
|
+
respondWaiting({ behavior: 'deny', message: feedback || PLAN_REJECT_MSG });
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// UI-driven approvals: POST the decision the blocking approval-gate.sh hook is
|
|
1300
|
+
// polling for. First writer wins (D5) — a 409/410 means the ask was answered in
|
|
1301
|
+
// the terminal or superseded, so just drop the card.
|
|
1302
|
+
async function respondWaiting(payload) {
|
|
1303
|
+
if (!currentSessionId || !currentWaiting?.id) return;
|
|
1304
|
+
try {
|
|
1305
|
+
const res = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/waiting/respond`, {
|
|
1306
|
+
method: 'POST',
|
|
1307
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1308
|
+
body: JSON.stringify({ id: currentWaiting.id, ...payload }),
|
|
1309
|
+
});
|
|
1310
|
+
if (res.ok || res.status === 409 || res.status === 410) {
|
|
1311
|
+
clearWaitingUi();
|
|
1312
|
+
} else {
|
|
1313
|
+
// Surface a respond failure — a silent console error reads as a dead button
|
|
1314
|
+
const text = await res.text();
|
|
1315
|
+
console.error('[respondWaiting]', res.status, text);
|
|
1316
|
+
let msg = text;
|
|
1317
|
+
try {
|
|
1318
|
+
msg = JSON.parse(text).error || text;
|
|
1319
|
+
} catch (_) {
|
|
1320
|
+
/* plain text */
|
|
1321
|
+
}
|
|
1322
|
+
showToast(msg, 'error');
|
|
1323
|
+
}
|
|
1324
|
+
} catch (e) {
|
|
1325
|
+
console.error('[respondWaiting]', e);
|
|
1326
|
+
showToast(String(e), 'error');
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Drop the answered/discarded ask from every surface it renders on
|
|
1331
|
+
function clearWaitingUi() {
|
|
1332
|
+
currentWaiting = null;
|
|
1333
|
+
waitingAnswerDraft = {};
|
|
1334
|
+
waitingCustomDraft = {};
|
|
1335
|
+
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
1336
|
+
// In follow mode stay in the modal and swap to the latest message —
|
|
1337
|
+
// closing it would silently drop the user out of following.
|
|
1338
|
+
if (msgDetailFollowLatest && currentMessages.length) showMsgDetail(currentMessages.length - 1);
|
|
1339
|
+
else closeMsgDetailModal();
|
|
1340
|
+
}
|
|
1341
|
+
// The saved-plan modal stays open after a decision — only its approval row goes
|
|
1342
|
+
document.getElementById('plan-approval-footer')?.remove();
|
|
1343
|
+
renderMessages(currentMessages);
|
|
1344
|
+
renderAgentFooter();
|
|
1257
1345
|
}
|
|
1258
1346
|
|
|
1259
1347
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
@@ -1263,12 +1351,7 @@ async function discardWaiting() {
|
|
|
1263
1351
|
const res = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/waiting/discard`, {
|
|
1264
1352
|
method: 'POST',
|
|
1265
1353
|
});
|
|
1266
|
-
if (res.ok)
|
|
1267
|
-
currentWaiting = null;
|
|
1268
|
-
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) closeMsgDetailModal();
|
|
1269
|
-
renderMessages(currentMessages);
|
|
1270
|
-
renderAgentFooter();
|
|
1271
|
-
}
|
|
1354
|
+
if (res.ok) clearWaitingUi();
|
|
1272
1355
|
} catch (e) {
|
|
1273
1356
|
console.error('[discardWaiting]', e);
|
|
1274
1357
|
}
|
|
@@ -1762,6 +1845,7 @@ function highlightSelectedMsg() {
|
|
|
1762
1845
|
|
|
1763
1846
|
function showMsgDetail(idx) {
|
|
1764
1847
|
currentMsgDetailIdx = idx;
|
|
1848
|
+
document.getElementById('msg-detail-waiting-footer').innerHTML = '';
|
|
1765
1849
|
msgHighlightDimmed = false;
|
|
1766
1850
|
const m = currentMessages[idx];
|
|
1767
1851
|
if (!m) return;
|
|
@@ -2630,7 +2714,7 @@ function renderAgentFooter() {
|
|
|
2630
2714
|
document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '▴' : '▾';
|
|
2631
2715
|
|
|
2632
2716
|
const permHtml = permFresh
|
|
2633
|
-
? `<
|
|
2717
|
+
? `<button type="button" class="permission-badge" onclick="showWaitingDetail()">${currentWaiting.kind === 'question' ? '❓ Question pending' : currentWaiting.kind === 'plan' ? '📋 Plan pending' : `⏳ Awaiting: ${escapeHtml(currentWaiting.toolName || 'unknown')}`}</button>`
|
|
2634
2718
|
: '';
|
|
2635
2719
|
|
|
2636
2720
|
content.innerHTML =
|
|
@@ -3368,6 +3452,59 @@ function renderTaskCard(task) {
|
|
|
3368
3452
|
//#endregion
|
|
3369
3453
|
|
|
3370
3454
|
//#region KANBAN
|
|
3455
|
+
// FLIP pass around renderKanban's innerHTML rebuild (#41): the rebuild lands cards at
|
|
3456
|
+
// their final spot instantly, so a status change reads as a full-board reload. Recording
|
|
3457
|
+
// where each card was and animating from there makes a move glide instead.
|
|
3458
|
+
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
3459
|
+
|
|
3460
|
+
function cardFlipKey(el) {
|
|
3461
|
+
return `${el.dataset.sessionId}|${el.dataset.taskId}`;
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
function captureCardRects() {
|
|
3465
|
+
const rects = new Map();
|
|
3466
|
+
for (const el of document.querySelectorAll('.column-tasks .task-card')) {
|
|
3467
|
+
rects.set(cardFlipKey(el), el.getBoundingClientRect());
|
|
3468
|
+
}
|
|
3469
|
+
return rects;
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
function playCardFlip(before) {
|
|
3473
|
+
// An empty board before the render means a view switch, not a move — nothing to glide.
|
|
3474
|
+
if (reducedMotion.matches || before.size === 0) return;
|
|
3475
|
+
for (const el of document.querySelectorAll('.column-tasks .task-card')) {
|
|
3476
|
+
const prev = before.get(cardFlipKey(el));
|
|
3477
|
+
if (!prev) {
|
|
3478
|
+
el.animate(
|
|
3479
|
+
[
|
|
3480
|
+
{ opacity: 0, transform: 'scale(0.97)' },
|
|
3481
|
+
{ opacity: 1, transform: 'none' },
|
|
3482
|
+
],
|
|
3483
|
+
{ duration: 150, easing: 'ease-out' },
|
|
3484
|
+
);
|
|
3485
|
+
continue;
|
|
3486
|
+
}
|
|
3487
|
+
const now = el.getBoundingClientRect();
|
|
3488
|
+
const dx = prev.left - now.left;
|
|
3489
|
+
const dy = prev.top - now.top;
|
|
3490
|
+
if (dx || dy) {
|
|
3491
|
+
el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], {
|
|
3492
|
+
duration: 220,
|
|
3493
|
+
easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)',
|
|
3494
|
+
});
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
// Rewrite a column only when its markup actually changed: SSE ticks fire on any task-file
|
|
3500
|
+
// write (updatedAt bumps, agent refreshes), and an unconditional innerHTML rebuild makes
|
|
3501
|
+
// the whole board blink for updates with nothing visible in them (#41).
|
|
3502
|
+
function setColumnHtml(el, html) {
|
|
3503
|
+
if (el._lastHtml === html) return;
|
|
3504
|
+
el._lastHtml = html;
|
|
3505
|
+
el.innerHTML = html;
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3371
3508
|
function renderKanban() {
|
|
3372
3509
|
let filtered = currentTasks.filter((t) => !isInternalTask(t));
|
|
3373
3510
|
if (ownerFilter) {
|
|
@@ -3384,27 +3521,41 @@ function renderKanban() {
|
|
|
3384
3521
|
const emptyIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>`;
|
|
3385
3522
|
const plusIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 5v14M5 12h14"/></svg>`;
|
|
3386
3523
|
|
|
3524
|
+
const writes = [];
|
|
3387
3525
|
// Adding is a live text input inside the column, so a background refresh would blow it
|
|
3388
3526
|
// away mid-typing -- leave the column alone until the input is gone.
|
|
3389
3527
|
if (!addingTask) {
|
|
3390
3528
|
const addTile = canAddTask()
|
|
3391
3529
|
? `<button type="button" class="column-add${pending.length ? '' : ' empty'}" onclick="startAddTask(this)">${plusIcon}<span>Add task</span></button>`
|
|
3392
3530
|
: '';
|
|
3393
|
-
|
|
3531
|
+
writes.push([
|
|
3532
|
+
pendingTasks,
|
|
3394
3533
|
pending.length > 0
|
|
3395
3534
|
? pending.map(renderTaskCard).join('') + addTile
|
|
3396
|
-
: addTile || `<div class="column-empty">${emptyIcon}<div>No pending tasks</div></div
|
|
3535
|
+
: addTile || `<div class="column-empty">${emptyIcon}<div>No pending tasks</div></div>`,
|
|
3536
|
+
]);
|
|
3397
3537
|
}
|
|
3398
3538
|
|
|
3399
|
-
|
|
3539
|
+
writes.push([
|
|
3540
|
+
inProgressTasks,
|
|
3400
3541
|
inProgress.length > 0
|
|
3401
3542
|
? inProgress.map(renderTaskCard).join('')
|
|
3402
|
-
: `<div class="column-empty">${emptyIcon}<div>No active tasks</div></div
|
|
3543
|
+
: `<div class="column-empty">${emptyIcon}<div>No active tasks</div></div>`,
|
|
3544
|
+
]);
|
|
3403
3545
|
|
|
3404
|
-
|
|
3546
|
+
writes.push([
|
|
3547
|
+
completedTasks,
|
|
3405
3548
|
completed.length > 0
|
|
3406
3549
|
? completed.map(renderTaskCard).join('')
|
|
3407
|
-
: `<div class="column-empty">${emptyIcon}<div>No completed tasks</div></div
|
|
3550
|
+
: `<div class="column-empty">${emptyIcon}<div>No completed tasks</div></div>`,
|
|
3551
|
+
]);
|
|
3552
|
+
|
|
3553
|
+
// Capture rects (a forced layout read per card) only when a column will
|
|
3554
|
+
// actually be rewritten — most SSE ticks change nothing and skip the FLIP.
|
|
3555
|
+
const willChange = writes.some(([el, html]) => el._lastHtml !== html);
|
|
3556
|
+
const flipRects = willChange ? captureCardRects() : null;
|
|
3557
|
+
for (const [el, html] of writes) setColumnHtml(el, html);
|
|
3558
|
+
if (flipRects) playCardFlip(flipRects);
|
|
3408
3559
|
|
|
3409
3560
|
if (selectedTaskId) {
|
|
3410
3561
|
const card =
|
|
@@ -3444,6 +3595,9 @@ function startAddTask(tile) {
|
|
|
3444
3595
|
input.type = 'text';
|
|
3445
3596
|
input.className = 'form-input column-add-input';
|
|
3446
3597
|
input.placeholder = 'Task subject, Enter to add';
|
|
3598
|
+
// The input mutates the column outside setColumnHtml — drop the cache so the
|
|
3599
|
+
// next render rebuilds instead of skipping on a stale match.
|
|
3600
|
+
pendingTasks._lastHtml = null;
|
|
3447
3601
|
tile.replaceWith(input);
|
|
3448
3602
|
input.focus();
|
|
3449
3603
|
|
|
@@ -5822,25 +5976,179 @@ function isWaitingFresh() {
|
|
|
5822
5976
|
return Date.now() - new Date(currentWaiting.timestamp).getTime() < WAITING_TTL_MS;
|
|
5823
5977
|
}
|
|
5824
5978
|
|
|
5979
|
+
// Per-question selections for the AskUserQuestion form, keyed by question text
|
|
5980
|
+
// (the shape the tool's `answers` input expects). Picks hold arrays of option
|
|
5981
|
+
// labels (length <= 1 unless multiSelect); free text lives separately so a
|
|
5982
|
+
// multi-select can combine both, the way the TUI's "Other" row does. Reset
|
|
5983
|
+
// when the ask changes.
|
|
5984
|
+
let waitingAnswerDraft = {};
|
|
5985
|
+
let waitingCustomDraft = {};
|
|
5986
|
+
let waitingDraftId = null;
|
|
5987
|
+
let waitingQuestions = [];
|
|
5988
|
+
|
|
5989
|
+
function waitingAnswerFor(q) {
|
|
5990
|
+
const picks = waitingAnswerDraft[q.question] || [];
|
|
5991
|
+
const custom = waitingCustomDraft[q.question] || '';
|
|
5992
|
+
if (q.multiSelect) {
|
|
5993
|
+
const all = custom ? picks.concat(custom) : picks;
|
|
5994
|
+
return all.length ? all : null;
|
|
5995
|
+
}
|
|
5996
|
+
return picks[0] || custom || null;
|
|
5997
|
+
}
|
|
5998
|
+
|
|
5999
|
+
// The action buttons live in the modal's fixed footer (#msg-detail-waiting-footer),
|
|
6000
|
+
// not the scrollable body — long content must not push them out of reach.
|
|
6001
|
+
function renderWaitingFooter(tool, params) {
|
|
6002
|
+
if (currentWaiting?.kind === 'plan') {
|
|
6003
|
+
return isWaitingAnswerable() ? planApprovalControlsHtml('plan-reject-feedback') : '';
|
|
6004
|
+
}
|
|
6005
|
+
if (currentWaiting?.kind !== 'question') return waitingDecisionButtons('waiting-btn');
|
|
6006
|
+
if (!isWaitingAnswerable()) return '';
|
|
6007
|
+
if (tool !== 'AskUserQuestion') return '';
|
|
6008
|
+
const questions = Array.isArray(params?.questions) ? params.questions : [];
|
|
6009
|
+
if (!questions.length) return '';
|
|
6010
|
+
const picked = questions.filter((q) => waitingAnswerFor(q)).length;
|
|
6011
|
+
const partial = picked > 0 && picked < questions.length ? ` (${picked}/${questions.length})` : '';
|
|
6012
|
+
return `<button id="waiting-answer-btn" class="waiting-btn-allow" ${picked > 0 ? '' : 'disabled'} onclick="submitWaitingAnswers()">Answer${partial}</button>`;
|
|
6013
|
+
}
|
|
6014
|
+
|
|
6015
|
+
function renderWaitingActions(tool, params) {
|
|
6016
|
+
if (!isWaitingAnswerable() || currentWaiting.kind !== 'question' || tool !== 'AskUserQuestion') return '';
|
|
6017
|
+
waitingQuestions = Array.isArray(params?.questions) ? params.questions : [];
|
|
6018
|
+
if (!waitingQuestions.length) return '';
|
|
6019
|
+
const qs = waitingQuestions
|
|
6020
|
+
.map((q, idx) => {
|
|
6021
|
+
const picks = waitingAnswerDraft[q.question] || [];
|
|
6022
|
+
const opts = (q.options || [])
|
|
6023
|
+
.map((o) => {
|
|
6024
|
+
const label = typeof o === 'string' ? o : o?.label || '';
|
|
6025
|
+
const desc = typeof o === 'object' ? o?.description || '' : '';
|
|
6026
|
+
const descHtml = desc ? `<span class="waiting-option-desc">${renderMarkdown(desc)}</span>` : '';
|
|
6027
|
+
// The option `preview` field is the TUI's side-by-side visualization
|
|
6028
|
+
// pane (mockups, code snippets, diagrams) — rendered as markdown
|
|
6029
|
+
const preview = typeof o === 'object' ? o?.preview || '' : '';
|
|
6030
|
+
const previewHtml = preview ? `<span class="waiting-option-preview">${renderMarkdown(preview)}</span>` : '';
|
|
6031
|
+
return `<button class="waiting-option${picks.includes(label) ? ' selected' : ''}" data-label="${escapeHtml(label)}" onclick="selectWaitingAnswer(${idx}, this.dataset.label)"><span class="waiting-option-label">${escapeHtml(label)}</span>${descHtml}${previewHtml}</button>`;
|
|
6032
|
+
})
|
|
6033
|
+
.join('');
|
|
6034
|
+
const header = q.header ? `<span class="waiting-question-header">${escapeHtml(q.header)}</span>` : '';
|
|
6035
|
+
const multi = q.multiSelect ? '<span class="waiting-question-multi">multi-select</span>' : '';
|
|
6036
|
+
const input = `<input type="text" class="waiting-option-input" placeholder="Or type your own answer" value="${escapeHtml(waitingCustomDraft[q.question] || '')}" oninput="setWaitingCustomAnswer(${idx}, this)">`;
|
|
6037
|
+
return `<div class="waiting-question">${header}${multi}<div class="waiting-question-text">${renderMarkdown(q.question || '')}</div><div class="waiting-options">${opts}</div>${input}</div>`;
|
|
6038
|
+
})
|
|
6039
|
+
.join('');
|
|
6040
|
+
return `<div class="waiting-actions">${qs}</div>`;
|
|
6041
|
+
}
|
|
6042
|
+
|
|
6043
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
6044
|
+
function selectWaitingAnswer(qi, label) {
|
|
6045
|
+
const q = waitingQuestions[qi];
|
|
6046
|
+
if (!q) return;
|
|
6047
|
+
// Clicking a picked option unpicks it, in both modes
|
|
6048
|
+
const picks = waitingAnswerDraft[q.question] || [];
|
|
6049
|
+
if (q.multiSelect) {
|
|
6050
|
+
waitingAnswerDraft[q.question] = picks.includes(label) ? picks.filter((l) => l !== label) : picks.concat(label);
|
|
6051
|
+
} else if (picks.includes(label)) {
|
|
6052
|
+
delete waitingAnswerDraft[q.question];
|
|
6053
|
+
} else {
|
|
6054
|
+
waitingAnswerDraft[q.question] = [label];
|
|
6055
|
+
delete waitingCustomDraft[q.question];
|
|
6056
|
+
}
|
|
6057
|
+
showWaitingDetail();
|
|
6058
|
+
}
|
|
6059
|
+
|
|
6060
|
+
// Free-text path — the TUI's "type your own answer". Updates the draft and the
|
|
6061
|
+
// Answer button in place: a full re-render here would drop the input's focus
|
|
6062
|
+
// on every keystroke.
|
|
6063
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML oninput
|
|
6064
|
+
function setWaitingCustomAnswer(qi, el) {
|
|
6065
|
+
const q = waitingQuestions[qi];
|
|
6066
|
+
if (!q) return;
|
|
6067
|
+
const v = el.value.trim();
|
|
6068
|
+
if (v) waitingCustomDraft[q.question] = v;
|
|
6069
|
+
else delete waitingCustomDraft[q.question];
|
|
6070
|
+
if (v && !q.multiSelect) {
|
|
6071
|
+
delete waitingAnswerDraft[q.question];
|
|
6072
|
+
for (const b of el.closest('.waiting-question')?.querySelectorAll('.waiting-option.selected') || []) {
|
|
6073
|
+
b.classList.remove('selected');
|
|
6074
|
+
}
|
|
6075
|
+
}
|
|
6076
|
+
const btn = document.getElementById('waiting-answer-btn');
|
|
6077
|
+
if (btn) {
|
|
6078
|
+
const picked = waitingQuestions.filter((qq) => waitingAnswerFor(qq)).length;
|
|
6079
|
+
btn.disabled = picked === 0;
|
|
6080
|
+
btn.textContent =
|
|
6081
|
+
picked > 0 && picked < waitingQuestions.length ? `Answer (${picked}/${waitingQuestions.length})` : 'Answer';
|
|
6082
|
+
}
|
|
6083
|
+
}
|
|
6084
|
+
|
|
6085
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
6086
|
+
function submitWaitingAnswers() {
|
|
6087
|
+
// Partial submits are fine — send only the questions actually answered.
|
|
6088
|
+
// multiSelect answers go as arrays of labels (the shape the TUI validates).
|
|
6089
|
+
const answers = {};
|
|
6090
|
+
for (const q of waitingQuestions) {
|
|
6091
|
+
const a = waitingAnswerFor(q);
|
|
6092
|
+
if (a) answers[q.question] = a;
|
|
6093
|
+
}
|
|
6094
|
+
if (!Object.keys(answers).length) return;
|
|
6095
|
+
respondWaiting({ answers });
|
|
6096
|
+
}
|
|
6097
|
+
|
|
5825
6098
|
function showWaitingDetail() {
|
|
5826
6099
|
if (!isWaitingFresh()) return;
|
|
5827
6100
|
currentMsgDetailIdx = MSG_DETAIL_WAITING_IDX;
|
|
5828
6101
|
msgHighlightDimmed = false;
|
|
5829
6102
|
highlightSelectedMsg();
|
|
6103
|
+
// Follow-mode poll ticks re-run this whole render — carry any typed reject
|
|
6104
|
+
// feedback across the rebuild, but never onto a different ask.
|
|
6105
|
+
const sameAsk = currentWaiting.id === waitingDraftId;
|
|
6106
|
+
const feedbackDraft = sameAsk ? document.getElementById('plan-reject-feedback')?.value || '' : '';
|
|
6107
|
+
if (!sameAsk) {
|
|
6108
|
+
waitingDraftId = currentWaiting.id || null;
|
|
6109
|
+
waitingAnswerDraft = {};
|
|
6110
|
+
waitingCustomDraft = {};
|
|
6111
|
+
}
|
|
5830
6112
|
const tool = currentWaiting.toolName || 'unknown';
|
|
5831
6113
|
const label = getWaitingLabel(currentWaiting.kind, tool);
|
|
5832
6114
|
const body = document.getElementById('msg-detail-body');
|
|
5833
6115
|
let inputHtml = '';
|
|
6116
|
+
const params = parseWaitingInput();
|
|
5834
6117
|
if (currentWaiting.toolInput) {
|
|
5835
|
-
|
|
5836
|
-
try {
|
|
5837
|
-
pretty = JSON.stringify(JSON.parse(currentWaiting.toolInput), null, 2);
|
|
5838
|
-
} catch (_) {
|
|
5839
|
-
/* keep raw */
|
|
5840
|
-
}
|
|
6118
|
+
const pretty = params ? JSON.stringify(params, null, 2) : currentWaiting.toolInput;
|
|
5841
6119
|
inputHtml = `<pre class="${TINTED_PRE_CLASS}">${escapeHtml(pretty)}</pre>`;
|
|
5842
6120
|
}
|
|
5843
|
-
|
|
6121
|
+
const actionsHtml = renderWaitingActions(tool, params);
|
|
6122
|
+
const footerHtml = renderWaitingFooter(tool, params);
|
|
6123
|
+
// With actions on screen the form/buttons are the content — tuck the raw
|
|
6124
|
+
// JSON behind a toggle, but keep what the user is approving visible: for a
|
|
6125
|
+
// permission ask surface the command (or description) as a one-line summary
|
|
6126
|
+
if ((actionsHtml || footerHtml) && inputHtml) {
|
|
6127
|
+
let summaryHtml = '';
|
|
6128
|
+
if (currentWaiting.kind === 'plan' && typeof params?.plan === 'string' && params.plan) {
|
|
6129
|
+
// A plan ask IS the plan — render it in full so it can be reviewed and
|
|
6130
|
+
// approved right here (follow mode included) instead of a JSON dump. The
|
|
6131
|
+
// raw tool input is dropped entirely to match the saved-plan modal.
|
|
6132
|
+
inputHtml = `<div class="detail-desc rendered-md">${renderMarkdown(params.plan)}</div>`;
|
|
6133
|
+
} else {
|
|
6134
|
+
if (currentWaiting.kind !== 'question' && params) {
|
|
6135
|
+
const gist =
|
|
6136
|
+
typeof params.command === 'string'
|
|
6137
|
+
? params.command
|
|
6138
|
+
: typeof params.description === 'string'
|
|
6139
|
+
? params.description
|
|
6140
|
+
: '';
|
|
6141
|
+
if (gist) summaryHtml = `<pre class="${TINTED_PRE_CLASS}">${escapeHtml(gist)}</pre>`;
|
|
6142
|
+
}
|
|
6143
|
+
inputHtml = `${summaryHtml}<details class="waiting-raw-input"><summary>Raw tool input</summary>${inputHtml}</details>`;
|
|
6144
|
+
}
|
|
6145
|
+
}
|
|
6146
|
+
body.innerHTML = inputHtml + actionsHtml;
|
|
6147
|
+
document.getElementById('msg-detail-waiting-footer').innerHTML = footerHtml;
|
|
6148
|
+
if (feedbackDraft) {
|
|
6149
|
+
const feedbackEl = document.getElementById('plan-reject-feedback');
|
|
6150
|
+
if (feedbackEl) feedbackEl.value = feedbackDraft;
|
|
6151
|
+
}
|
|
5844
6152
|
document.getElementById('msg-detail-title').textContent = label;
|
|
5845
6153
|
document.getElementById('msg-detail-agent-btn').style.display = 'none';
|
|
5846
6154
|
const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
|
|
@@ -7217,6 +7525,15 @@ function openPlanModal() {
|
|
|
7217
7525
|
if (!_pendingPlanContent) return;
|
|
7218
7526
|
const body = document.getElementById('plan-modal-body');
|
|
7219
7527
|
body.innerHTML = renderMarkdown(_pendingPlanContent);
|
|
7528
|
+
// A saved plan opened while its approval ask is still pending (sidebar
|
|
7529
|
+
// click, 'p') is the same review moment — offer Approve/Reject here too.
|
|
7530
|
+
document.getElementById('plan-approval-footer')?.remove();
|
|
7531
|
+
if (currentWaiting?.kind === 'plan' && isWaitingAnswerable() && _planSessionId === currentSessionId) {
|
|
7532
|
+
const controls = document.createElement('div');
|
|
7533
|
+
controls.id = 'plan-approval-footer';
|
|
7534
|
+
controls.innerHTML = planApprovalControlsHtml('plan-modal-reject-feedback');
|
|
7535
|
+
document.querySelector('#plan-modal .modal-footer').prepend(controls);
|
|
7536
|
+
}
|
|
7220
7537
|
document.getElementById('plan-modal').classList.add('visible');
|
|
7221
7538
|
const keyHandler = (e) => {
|
|
7222
7539
|
if (e.key === 'Escape') {
|
package/public/index.html
CHANGED
|
@@ -313,6 +313,7 @@
|
|
|
313
313
|
<div id="msg-detail-body" class="rendered-md modal-zoomable"></div>
|
|
314
314
|
<div id="msg-detail-meta" style="margin-top: 12px; font-size: 11px; color: var(--text-muted); flex-shrink: 0;"></div>
|
|
315
315
|
</div>
|
|
316
|
+
<div id="msg-detail-waiting-footer"></div>
|
|
316
317
|
</div>
|
|
317
318
|
</div>
|
|
318
319
|
|
package/public/style.css
CHANGED
|
@@ -3041,6 +3041,249 @@ body::before {
|
|
|
3041
3041
|
background: color-mix(in srgb, var(--warning, #f5a623) 16%, transparent);
|
|
3042
3042
|
}
|
|
3043
3043
|
}
|
|
3044
|
+
.msg-waiting-footer {
|
|
3045
|
+
display: flex;
|
|
3046
|
+
align-items: center;
|
|
3047
|
+
justify-content: space-between;
|
|
3048
|
+
gap: 8px;
|
|
3049
|
+
margin-top: 4px;
|
|
3050
|
+
}
|
|
3051
|
+
.msg-waiting-footer .msg-time {
|
|
3052
|
+
margin-top: 0;
|
|
3053
|
+
}
|
|
3054
|
+
.msg-waiting-actions {
|
|
3055
|
+
flex-shrink: 0;
|
|
3056
|
+
display: flex;
|
|
3057
|
+
gap: 6px;
|
|
3058
|
+
}
|
|
3059
|
+
.msg-waiting-actions button {
|
|
3060
|
+
border: 1px solid transparent;
|
|
3061
|
+
border-radius: 4px;
|
|
3062
|
+
padding: 2px 8px;
|
|
3063
|
+
font-family: var(--mono);
|
|
3064
|
+
font-size: 11px;
|
|
3065
|
+
font-weight: 600;
|
|
3066
|
+
cursor: pointer;
|
|
3067
|
+
background: none;
|
|
3068
|
+
}
|
|
3069
|
+
#msg-detail-waiting-footer {
|
|
3070
|
+
display: flex;
|
|
3071
|
+
gap: 8px;
|
|
3072
|
+
justify-content: flex-end;
|
|
3073
|
+
padding: 10px 20px;
|
|
3074
|
+
border-top: 1px solid var(--border);
|
|
3075
|
+
flex-shrink: 0;
|
|
3076
|
+
}
|
|
3077
|
+
#msg-detail-waiting-footer:empty {
|
|
3078
|
+
display: none;
|
|
3079
|
+
}
|
|
3080
|
+
#msg-detail-waiting-footer .waiting-option-input,
|
|
3081
|
+
#plan-approval-footer .waiting-option-input {
|
|
3082
|
+
flex: 1;
|
|
3083
|
+
margin: 0;
|
|
3084
|
+
}
|
|
3085
|
+
#plan-approval-footer {
|
|
3086
|
+
display: flex;
|
|
3087
|
+
gap: 8px;
|
|
3088
|
+
align-items: center;
|
|
3089
|
+
flex: 1;
|
|
3090
|
+
}
|
|
3091
|
+
.waiting-actions {
|
|
3092
|
+
display: flex;
|
|
3093
|
+
flex-direction: column;
|
|
3094
|
+
align-items: stretch;
|
|
3095
|
+
gap: 12px;
|
|
3096
|
+
margin-top: 14px;
|
|
3097
|
+
}
|
|
3098
|
+
.waiting-btn-allow,
|
|
3099
|
+
.waiting-btn-deny {
|
|
3100
|
+
border: 1px solid var(--border);
|
|
3101
|
+
border-radius: 4px;
|
|
3102
|
+
padding: 6px 16px;
|
|
3103
|
+
font-family: var(--mono);
|
|
3104
|
+
font-size: 12px;
|
|
3105
|
+
font-weight: 500;
|
|
3106
|
+
background: none;
|
|
3107
|
+
cursor: pointer;
|
|
3108
|
+
transition: all 0.15s ease;
|
|
3109
|
+
}
|
|
3110
|
+
/* One quiet color treatment for both button families — the card's compact
|
|
3111
|
+
buttons need the descendant selector to out-rank `.msg-waiting-actions button` */
|
|
3112
|
+
.waiting-btn-allow,
|
|
3113
|
+
.msg-waiting-actions .msg-waiting-allow {
|
|
3114
|
+
color: var(--success, #2ea043);
|
|
3115
|
+
border-color: color-mix(in srgb, var(--success, #2ea043) 35%, transparent);
|
|
3116
|
+
}
|
|
3117
|
+
.waiting-btn-allow:hover:not(:disabled),
|
|
3118
|
+
.msg-waiting-actions .msg-waiting-allow:hover {
|
|
3119
|
+
background: color-mix(in srgb, var(--success, #2ea043) 10%, transparent);
|
|
3120
|
+
}
|
|
3121
|
+
.waiting-btn-deny,
|
|
3122
|
+
.msg-waiting-actions .msg-waiting-deny {
|
|
3123
|
+
color: var(--danger, #f85149);
|
|
3124
|
+
border-color: color-mix(in srgb, var(--danger, #f85149) 35%, transparent);
|
|
3125
|
+
}
|
|
3126
|
+
.waiting-btn-deny:hover,
|
|
3127
|
+
.msg-waiting-actions .msg-waiting-deny:hover {
|
|
3128
|
+
background: color-mix(in srgb, var(--danger, #f85149) 10%, transparent);
|
|
3129
|
+
}
|
|
3130
|
+
.waiting-btn-allow:disabled {
|
|
3131
|
+
opacity: 0.4;
|
|
3132
|
+
cursor: default;
|
|
3133
|
+
}
|
|
3134
|
+
.waiting-raw-input {
|
|
3135
|
+
margin-bottom: 12px;
|
|
3136
|
+
}
|
|
3137
|
+
.waiting-raw-input summary {
|
|
3138
|
+
font-size: 11px;
|
|
3139
|
+
color: var(--text-secondary);
|
|
3140
|
+
cursor: pointer;
|
|
3141
|
+
user-select: none;
|
|
3142
|
+
}
|
|
3143
|
+
.waiting-question {
|
|
3144
|
+
border: 1px solid var(--border);
|
|
3145
|
+
border-radius: 6px;
|
|
3146
|
+
padding: 12px 14px;
|
|
3147
|
+
background: color-mix(in srgb, var(--bg-deep) 40%, transparent);
|
|
3148
|
+
}
|
|
3149
|
+
.waiting-question-header {
|
|
3150
|
+
display: inline-block;
|
|
3151
|
+
font-size: 10px;
|
|
3152
|
+
font-weight: 600;
|
|
3153
|
+
text-transform: uppercase;
|
|
3154
|
+
letter-spacing: 0.5px;
|
|
3155
|
+
color: var(--accent);
|
|
3156
|
+
margin-bottom: 4px;
|
|
3157
|
+
}
|
|
3158
|
+
.waiting-question-text {
|
|
3159
|
+
font-weight: 600;
|
|
3160
|
+
font-size: 13px;
|
|
3161
|
+
line-height: 1.5;
|
|
3162
|
+
margin-bottom: 10px;
|
|
3163
|
+
}
|
|
3164
|
+
/* Question text and option descriptions render as markdown (like the TUI),
|
|
3165
|
+
so fenced diagrams, tables, and mermaid keep their shape. */
|
|
3166
|
+
.waiting-question-text p,
|
|
3167
|
+
.waiting-option-desc p,
|
|
3168
|
+
.waiting-option-preview p {
|
|
3169
|
+
margin: 0 0 6px;
|
|
3170
|
+
}
|
|
3171
|
+
.waiting-question-text p:last-child,
|
|
3172
|
+
.waiting-option-desc p:last-child,
|
|
3173
|
+
.waiting-option-preview p:last-child {
|
|
3174
|
+
margin-bottom: 0;
|
|
3175
|
+
}
|
|
3176
|
+
.waiting-question-text pre,
|
|
3177
|
+
.waiting-option-desc pre {
|
|
3178
|
+
font-weight: 400;
|
|
3179
|
+
overflow-x: auto;
|
|
3180
|
+
padding: 8px 10px;
|
|
3181
|
+
border: 1px solid var(--border);
|
|
3182
|
+
border-radius: 4px;
|
|
3183
|
+
background: var(--bg-primary);
|
|
3184
|
+
}
|
|
3185
|
+
.waiting-question-text pre.mermaid,
|
|
3186
|
+
.waiting-option-desc pre.mermaid {
|
|
3187
|
+
background: none;
|
|
3188
|
+
border: none;
|
|
3189
|
+
text-align: center;
|
|
3190
|
+
}
|
|
3191
|
+
.waiting-question-text table,
|
|
3192
|
+
.waiting-option-desc table {
|
|
3193
|
+
border-collapse: collapse;
|
|
3194
|
+
font-weight: 400;
|
|
3195
|
+
}
|
|
3196
|
+
.waiting-question-text th,
|
|
3197
|
+
.waiting-question-text td,
|
|
3198
|
+
.waiting-option-desc th,
|
|
3199
|
+
.waiting-option-desc td {
|
|
3200
|
+
border: 1px solid var(--border);
|
|
3201
|
+
padding: 3px 8px;
|
|
3202
|
+
}
|
|
3203
|
+
.waiting-options {
|
|
3204
|
+
display: flex;
|
|
3205
|
+
flex-direction: column;
|
|
3206
|
+
gap: 6px;
|
|
3207
|
+
}
|
|
3208
|
+
.waiting-option {
|
|
3209
|
+
display: flex;
|
|
3210
|
+
flex-direction: column;
|
|
3211
|
+
gap: 2px;
|
|
3212
|
+
width: 100%;
|
|
3213
|
+
text-align: left;
|
|
3214
|
+
border: 1px solid var(--border);
|
|
3215
|
+
border-radius: 4px;
|
|
3216
|
+
padding: 8px 12px;
|
|
3217
|
+
font-family: var(--mono);
|
|
3218
|
+
font-size: 12px;
|
|
3219
|
+
background: none;
|
|
3220
|
+
color: var(--text-primary);
|
|
3221
|
+
cursor: pointer;
|
|
3222
|
+
transition:
|
|
3223
|
+
border-color 0.15s ease,
|
|
3224
|
+
background 0.15s ease;
|
|
3225
|
+
}
|
|
3226
|
+
.waiting-option:hover {
|
|
3227
|
+
border-color: var(--accent);
|
|
3228
|
+
}
|
|
3229
|
+
.waiting-option.selected {
|
|
3230
|
+
border-color: var(--accent);
|
|
3231
|
+
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
|
3232
|
+
}
|
|
3233
|
+
.waiting-option-label {
|
|
3234
|
+
font-weight: 600;
|
|
3235
|
+
}
|
|
3236
|
+
.waiting-option-desc {
|
|
3237
|
+
display: block;
|
|
3238
|
+
font-size: 11px;
|
|
3239
|
+
line-height: 1.45;
|
|
3240
|
+
color: var(--text-secondary);
|
|
3241
|
+
}
|
|
3242
|
+
.waiting-option-preview {
|
|
3243
|
+
display: block;
|
|
3244
|
+
margin-top: 6px;
|
|
3245
|
+
padding: 8px 10px;
|
|
3246
|
+
border: 1px solid var(--border);
|
|
3247
|
+
border-radius: 4px;
|
|
3248
|
+
background: var(--bg-primary);
|
|
3249
|
+
font-size: 11px;
|
|
3250
|
+
font-weight: 400;
|
|
3251
|
+
overflow-x: auto;
|
|
3252
|
+
}
|
|
3253
|
+
.waiting-option-preview pre {
|
|
3254
|
+
margin: 0;
|
|
3255
|
+
overflow-x: auto;
|
|
3256
|
+
background: none;
|
|
3257
|
+
}
|
|
3258
|
+
.waiting-question-multi {
|
|
3259
|
+
display: inline-block;
|
|
3260
|
+
margin-left: 6px;
|
|
3261
|
+
padding: 1px 6px;
|
|
3262
|
+
border: 1px solid var(--border);
|
|
3263
|
+
border-radius: 10px;
|
|
3264
|
+
font-size: 10px;
|
|
3265
|
+
color: var(--text-secondary);
|
|
3266
|
+
}
|
|
3267
|
+
.waiting-option-input {
|
|
3268
|
+
width: 100%;
|
|
3269
|
+
margin-top: 6px;
|
|
3270
|
+
border: 1px dashed var(--border);
|
|
3271
|
+
border-radius: 4px;
|
|
3272
|
+
padding: 8px 12px;
|
|
3273
|
+
font-family: var(--mono);
|
|
3274
|
+
font-size: 12px;
|
|
3275
|
+
background: none;
|
|
3276
|
+
color: var(--text-primary);
|
|
3277
|
+
transition: border-color 0.15s ease;
|
|
3278
|
+
}
|
|
3279
|
+
.waiting-option-input:hover,
|
|
3280
|
+
.waiting-option-input:focus {
|
|
3281
|
+
border-color: var(--accent);
|
|
3282
|
+
outline: none;
|
|
3283
|
+
}
|
|
3284
|
+
.waiting-option-input::placeholder {
|
|
3285
|
+
color: var(--text-secondary);
|
|
3286
|
+
}
|
|
3044
3287
|
.msg-item.msg-idle .msg-icon {
|
|
3045
3288
|
width: 12px;
|
|
3046
3289
|
height: 12px;
|
|
@@ -3610,11 +3853,17 @@ body::before {
|
|
|
3610
3853
|
background: var(--warning-dim);
|
|
3611
3854
|
border: 1px solid var(--warning);
|
|
3612
3855
|
border-radius: 8px;
|
|
3856
|
+
font-family: var(--mono);
|
|
3613
3857
|
font-size: 12px;
|
|
3614
3858
|
color: var(--warning);
|
|
3615
3859
|
white-space: nowrap;
|
|
3860
|
+
cursor: pointer;
|
|
3616
3861
|
animation: permission-pulse 1.5s ease-in-out infinite;
|
|
3617
3862
|
}
|
|
3863
|
+
.permission-badge:hover {
|
|
3864
|
+
background: color-mix(in srgb, var(--warning) 22%, transparent);
|
|
3865
|
+
animation-play-state: paused;
|
|
3866
|
+
}
|
|
3618
3867
|
@keyframes permission-pulse {
|
|
3619
3868
|
0%,
|
|
3620
3869
|
100% {
|
package/server.js
CHANGED
|
@@ -33,6 +33,7 @@ const {
|
|
|
33
33
|
buildLoopInfoFromState
|
|
34
34
|
} = require('./lib/parsers');
|
|
35
35
|
const { inlineHtmlAssets } = require('./lib/inline-assets');
|
|
36
|
+
const { buildDecision, decisionFileName, isDecisionFile, waitSecondsFrom, isLapsed } = require('./lib/approvals');
|
|
36
37
|
|
|
37
38
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
38
39
|
const { runInstall, runUninstall } = require("./install");
|
|
@@ -168,6 +169,25 @@ function persistAgent(dir, agent) {
|
|
|
168
169
|
fs.appendFile(file, JSON.stringify({ ...agent, event: 'server-update' }) + '\n', 'utf8').catch(() => {});
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
// Lapse math lives in lib/approvals (kept in sync with the gate's own parse);
|
|
173
|
+
// this only reads the config, cached so the polled session list doesn't re-read
|
|
174
|
+
// it per hit (marker stays visible for the badge until TTL).
|
|
175
|
+
const APPROVALS_CONFIG_FILE = path.join(CCK_DIR, 'approvals.json');
|
|
176
|
+
let approvalsWaitCache = { ts: 0, ms: 30 * 1000 };
|
|
177
|
+
function approvalsWaitMs() {
|
|
178
|
+
if (Date.now() - approvalsWaitCache.ts < 10 * 1000) return approvalsWaitCache.ms;
|
|
179
|
+
let cfg = null;
|
|
180
|
+
try {
|
|
181
|
+
cfg = JSON.parse(readFileSync(APPROVALS_CONFIG_FILE, 'utf8'));
|
|
182
|
+
} catch (e) { /* missing config — gate default */ }
|
|
183
|
+
approvalsWaitCache = { ts: Date.now(), ms: waitSecondsFrom(cfg) * 1000 };
|
|
184
|
+
return approvalsWaitCache.ms;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isWaitingLapsed(data) {
|
|
188
|
+
return isLapsed(data.timestamp, approvalsWaitMs());
|
|
189
|
+
}
|
|
190
|
+
|
|
171
191
|
function checkWaitingForUser(agentDir, logMtime) {
|
|
172
192
|
try {
|
|
173
193
|
const data = JSON.parse(readFileSync(path.join(agentDir, '_waiting.json'), 'utf8'));
|
|
@@ -177,6 +197,7 @@ function checkWaitingForUser(agentDir, logMtime) {
|
|
|
177
197
|
if (age >= PERMISSION_TTL_MS) return null;
|
|
178
198
|
// After grace period, check if session resumed activity (user already responded)
|
|
179
199
|
if (logMtime && age >= WAITING_RESOLVE_GRACE_MS && logMtime > waitTime + WAITING_RESOLVE_GRACE_MS) return null;
|
|
200
|
+
if (isWaitingLapsed(data)) return { ...data, lapsed: true };
|
|
180
201
|
return data;
|
|
181
202
|
}
|
|
182
203
|
} catch (e) { /* skip — missing or invalid */ }
|
|
@@ -2049,6 +2070,31 @@ app.post('/api/sessions/:sessionId/waiting/discard', (req, res) => {
|
|
|
2049
2070
|
}
|
|
2050
2071
|
});
|
|
2051
2072
|
|
|
2073
|
+
// UI-driven approvals: answers the ask approval-gate.sh is blocking on by
|
|
2074
|
+
// writing _decision-<id>.json next to the marker. The hook consumes and deletes
|
|
2075
|
+
// both files; a write for a hook that already gave up is accepted anyway (D13)
|
|
2076
|
+
// and left for cleanupAgentActivity's sweep.
|
|
2077
|
+
app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
|
|
2078
|
+
const sessionId = resolveSessionId(req.params.sessionId);
|
|
2079
|
+
const dir = path.join(AGENT_ACTIVITY_DIR, sessionId);
|
|
2080
|
+
let marker = null;
|
|
2081
|
+
try { marker = JSON.parse(readFileSync(path.join(dir, '_waiting.json'), 'utf8')); }
|
|
2082
|
+
catch (e) { /* missing or invalid → buildDecision reports 410 */ }
|
|
2083
|
+
// The gate stopped polling after waitSeconds — an orphaned decision file would
|
|
2084
|
+
// sit unconsumed while the card pretends the click worked.
|
|
2085
|
+
if (marker && isWaitingLapsed(marker)) {
|
|
2086
|
+
return res.status(410).json({ error: 'Ask lapsed — answer it in the terminal' });
|
|
2087
|
+
}
|
|
2088
|
+
const result = buildDecision(marker, req.body || {});
|
|
2089
|
+
if (result.error) return res.status(result.status).json({ error: result.error });
|
|
2090
|
+
try {
|
|
2091
|
+
writeJsonAtomic(path.join(dir, decisionFileName(marker.id)), result.decision);
|
|
2092
|
+
res.json({ ok: true });
|
|
2093
|
+
} catch (e) {
|
|
2094
|
+
res.status(500).json({ error: 'Failed to write decision' });
|
|
2095
|
+
}
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2052
2098
|
app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
|
|
2053
2099
|
const sessionId = resolveSessionId(req.params.sessionId);
|
|
2054
2100
|
const agentId = sanitizeAgentId(req.params.agentId);
|
|
@@ -3246,6 +3292,18 @@ async function cleanupAgentActivity() {
|
|
|
3246
3292
|
const age = now - stat.mtimeMs;
|
|
3247
3293
|
if ((contents.length === 0 && age > AGENT_STALE_MS) || age > CLEANUP_MAX_AGE_MS) {
|
|
3248
3294
|
await fs.rm(dirPath, { recursive: true, force: true });
|
|
3295
|
+
continue;
|
|
3296
|
+
}
|
|
3297
|
+
// Orphaned decision files: a terminal deny fires no hook event (D13), so a
|
|
3298
|
+
// board answer for an already-gone gate is never consumed. The gate waits at
|
|
3299
|
+
// most PERMISSION_TTL_MS, so anything older is unclaimable.
|
|
3300
|
+
for (const f of contents) {
|
|
3301
|
+
if (!isDecisionFile(f)) continue;
|
|
3302
|
+
const fp = path.join(dirPath, f);
|
|
3303
|
+
try {
|
|
3304
|
+
const fst = await fs.stat(fp);
|
|
3305
|
+
if (now - fst.mtimeMs > PERMISSION_TTL_MS) await fs.rm(fp, { force: true });
|
|
3306
|
+
} catch (e) { /* ignore per-file errors */ }
|
|
3249
3307
|
}
|
|
3250
3308
|
} catch (e) { /* ignore per-folder errors */ }
|
|
3251
3309
|
}
|