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 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.20.0",
3
+ "version": "4.21.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -10,7 +10,7 @@
10
10
  "start": "node server.js",
11
11
  "dev": "node --watch server.js",
12
12
  "test": "node --test test/*.test.js",
13
- "test:hooks": "bash tests/test-agent-spy.sh",
13
+ "test:hooks": "bash tests/test-agent-spy.sh && bash tests/test-approval-gate.sh",
14
14
  "validate:schemas": "node test/validate-live-schemas.js",
15
15
  "prepare": "husky"
16
16
  },
@@ -1,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
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
- function getWaitingLabel(kind, tool) {
1184
- if (kind !== 'question') return `Awaiting permission: ${tool}`;
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 === 'question' && tool === 'ExitPlanMode') return 'Plan awaiting approval';
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
- let params = null;
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
- 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-time">waiting…</div></div>${discardBtn}</div>`;
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 ? '&#x25B4;' : '&#x25BE;';
2631
2715
 
2632
2716
  const permHtml = permFresh
2633
- ? `<div class="permission-badge">${currentWaiting.kind === 'question' ? '❓ Question pending' : `⏳ Awaiting: ${escapeHtml(currentWaiting.toolName || 'unknown')}`}</div>`
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
- pendingTasks.innerHTML =
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
- inProgressTasks.innerHTML =
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
- completedTasks.innerHTML =
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
- let pretty = currentWaiting.toolInput;
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
- body.innerHTML = inputHtml;
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
  }