@pragma-sh/claude-code-plugin 0.1.0-alpha.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/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +8 -0
- package/assets/claude-code.svg +7 -0
- package/dist/pragma-plugin.mjs +476 -0
- package/hooks/hooks.json +105 -0
- package/hooks/report.sh +873 -0
- package/package.json +41 -0
- package/pragma-plugin.json +14 -0
- package/scripts/install.sh +4 -0
- package/src/pragma-plugin.test.ts +131 -0
- package/src/pragma-plugin.ts +237 -0
package/hooks/report.sh
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Pragma <-> Claude Code status bridge.
|
|
3
|
+
#
|
|
4
|
+
# Invoked by hooks/hooks.json on Claude Code lifecycle events (see the hook ->
|
|
5
|
+
# status table in AGENTS.md). Each event is translated into a `pragma-cli`
|
|
6
|
+
# status report for the current Pragma terminal tab. Outside a Pragma terminal
|
|
7
|
+
# PRAGMA_DAEMON_SOCKET is unset and there is no daemon to talk to, so every
|
|
8
|
+
# event is a silent no-op (exit 0).
|
|
9
|
+
#
|
|
10
|
+
# Abort handling (the hard part): when a user cancels a turn -- ESC mid-response,
|
|
11
|
+
# rejecting a command's permission prompt, or declining a question -- Claude Code
|
|
12
|
+
# fires NO hook at all. We verified this against every hook event in 2.1.186:
|
|
13
|
+
# Stop does not fire, SessionEnd does not fire, and the idle-prompt Notification
|
|
14
|
+
# (which DOES fire ~60s after a normal completion) never fires after a cancel.
|
|
15
|
+
# So a hook-only bridge can never observe the cancel and the tab stays stuck on
|
|
16
|
+
# `running`/`attention` until the next prompt or quit.
|
|
17
|
+
#
|
|
18
|
+
# The cancel *is*, however, written to the session transcript immediately: the
|
|
19
|
+
# turn ends with a trailing `user` message whose text is
|
|
20
|
+
# "[Request interrupted by user]" (or "... for tool use"). Since no hook reports
|
|
21
|
+
# it, we watch for it instead: `started` spawns a detached background watcher
|
|
22
|
+
# that polls the transcript and, the moment the active turn's tail shows the
|
|
23
|
+
# interrupt marker, reports `cleared` and exits. Normal completion (`Stop`),
|
|
24
|
+
# session start/end, and the next turn all tear the watcher down, so it only
|
|
25
|
+
# lives while a turn could still be cancelled.
|
|
26
|
+
|
|
27
|
+
set -u
|
|
28
|
+
|
|
29
|
+
# Outside Pragma there is no server to report to; every event is a silent no-op.
|
|
30
|
+
[ -n "${PRAGMA_SERVER_SOCKET:-}${PRAGMA_DAEMON_SOCKET:-}" ] || exit 0
|
|
31
|
+
|
|
32
|
+
agent="claude-code"
|
|
33
|
+
pragma_cli="${PRAGMA_CLI:-pragma-cli}"
|
|
34
|
+
tab="${PRAGMA_TAB_ID:-unknown}"
|
|
35
|
+
state_dir="${TMPDIR:-/tmp}"
|
|
36
|
+
# Per-tab files: the marker holds the active turn's token (presence = a turn is
|
|
37
|
+
# in flight); the pidfile holds the current watcher's pid so a new turn (or a
|
|
38
|
+
# normal end) can tear it down. Both are keyed on PRAGMA_TAB_ID.
|
|
39
|
+
marker="${state_dir}/pragma-cli-${agent}-${tab}.active"
|
|
40
|
+
pidfile="${state_dir}/pragma-cli-${agent}-${tab}.watcher"
|
|
41
|
+
session_file="${state_dir}/pragma-cli-${agent}-${tab}.session"
|
|
42
|
+
# Holds the session_id whose name was already reported, so each session (incl.
|
|
43
|
+
# a /resume switch to another session) names the tab exactly once.
|
|
44
|
+
named_file="${state_dir}/pragma-cli-${agent}-${tab}.sessionname"
|
|
45
|
+
children_dir="${state_dir}/pragma-cli-${agent}-${tab}.subagents"
|
|
46
|
+
# The session id that owns the in-flight marker. `/clear` fires SessionEnd (old
|
|
47
|
+
# session) + SessionStart (new session) while the user's next prompt may already
|
|
48
|
+
# have started a turn in the NEW session; hook processes race, and a late
|
|
49
|
+
# `cleared` landing after that `started` would wipe the marker and mute every
|
|
50
|
+
# marker-guarded report for the rest of the turn. This file lets `cleared` tell
|
|
51
|
+
# a stale clear from a legitimate one.
|
|
52
|
+
turn_session_file="${state_dir}/pragma-cli-${agent}-${tab}.turn-session"
|
|
53
|
+
|
|
54
|
+
# Poll cadence and absolute lifetime backstop (overridable for tests). The
|
|
55
|
+
# backstop guarantees a watcher can't outlive its session forever if the session
|
|
56
|
+
# is killed uncatchably (SIGKILL) and the marker is never removed.
|
|
57
|
+
interval="${PRAGMA_WATCH_INTERVAL:-1}"
|
|
58
|
+
max_lifetime="${PRAGMA_WATCH_MAX:-86400}"
|
|
59
|
+
# How long a PermissionRequest blocks waiting for a remote approve/deny from a
|
|
60
|
+
# Pragma toast before giving up and letting Claude Code show its own prompt.
|
|
61
|
+
approval_timeout="${PRAGMA_APPROVAL_TIMEOUT:-300}"
|
|
62
|
+
dismissed_answer="__PRAGMA_QUESTION_DISMISSED__"
|
|
63
|
+
|
|
64
|
+
# Reports a status to Pragma, swallowing every failure so a hook never disrupts
|
|
65
|
+
# a Claude Code session (e.g. when pragma-cli or the server is unavailable).
|
|
66
|
+
report() {
|
|
67
|
+
"$pragma_cli" agent report --agent "$agent" "$@" >/dev/null 2>&1 || true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Same as `report` but propagates the exit status. Use it for a report whose
|
|
71
|
+
# flags a older installed pragma-cli may not know (`--questions` landed after
|
|
72
|
+
# the hook did): clap exits nonzero on an unknown flag, and because `report`
|
|
73
|
+
# swallows that the attention silently never reaches any client while the
|
|
74
|
+
# chat message still arrives. Callers fall back on a nonzero status.
|
|
75
|
+
report_checked() {
|
|
76
|
+
"$pragma_cli" agent report --agent "$agent" "$@" >/dev/null 2>&1
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Reports a coarse rich message. Hook payloads are intentionally not parsed here
|
|
80
|
+
# beyond existing transcript handling; this keeps hooks fail-open and portable.
|
|
81
|
+
# AgentMessage.ts is milliseconds since Unix epoch (see @pragma/constants).
|
|
82
|
+
# `date +%s` is seconds — multiply so chat clients that stamp local input with
|
|
83
|
+
# Date.now() don't sort every agent bubble above the user's messages.
|
|
84
|
+
message_ts_ms() {
|
|
85
|
+
echo $(($(date +%s) * 1000))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
message() {
|
|
89
|
+
role="$1"
|
|
90
|
+
text="$2"
|
|
91
|
+
id="${agent}-${tab}-$(date +%s)-$$-$role"
|
|
92
|
+
ts="$(message_ts_ms)"
|
|
93
|
+
active="$(tracked_subagent_count)"
|
|
94
|
+
payload='{"id":"'"$id"'","role":"'"$role"'","text":"'"$text"'","subAgentsActive":'"$active"',"ts":'"$ts"'}'
|
|
95
|
+
"$pragma_cli" agent message --agent "$agent" --payload "$payload" >/dev/null 2>&1 || true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# Content-bearing messages (the user's prompt, the assistant's reply) need real
|
|
99
|
+
# JSON parsing/escaping that POSIX sh cannot do safely; python3 ships with
|
|
100
|
+
# macOS and every mainstream Linux distro. When it's missing these helpers do
|
|
101
|
+
# nothing and the bridge degrades to the coarse status-only messages above.
|
|
102
|
+
#
|
|
103
|
+
# Windows is the exception, and being on PATH is not proof of being usable there:
|
|
104
|
+
# it ships an App Execution Alias at
|
|
105
|
+
# ~/AppData/Local/Microsoft/WindowsApps/python3 that only prints "Python was not
|
|
106
|
+
# found" and exits nonzero. That satisfies `command -v`, so the emptiness checks
|
|
107
|
+
# below would wrongly take the has-python branch. Run it once and drop it unless
|
|
108
|
+
# it actually executes.
|
|
109
|
+
py3="$(command -v python3 2>/dev/null || true)"
|
|
110
|
+
if [ -n "$py3" ] && ! "$py3" -c '' >/dev/null 2>&1; then
|
|
111
|
+
py3=""
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
# Prints a top-level string field from the JSON document passed as $2.
|
|
115
|
+
json_field() {
|
|
116
|
+
[ -n "$py3" ] || return 0
|
|
117
|
+
printf '%s' "$2" | "$py3" -c '
|
|
118
|
+
import json, sys
|
|
119
|
+
try:
|
|
120
|
+
value = (json.load(sys.stdin) or {}).get(sys.argv[1])
|
|
121
|
+
except Exception:
|
|
122
|
+
value = None
|
|
123
|
+
if isinstance(value, str):
|
|
124
|
+
print(value)
|
|
125
|
+
' "$1" 2>/dev/null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# Prints the transcript path from a hook payload. Prefer real JSON parsing so
|
|
129
|
+
# Claude's whitespace and escaping are handled correctly; retain a portable
|
|
130
|
+
# fallback for hosts without python3.
|
|
131
|
+
transcript_path() {
|
|
132
|
+
input="$1"
|
|
133
|
+
value="$(json_field transcript_path "$input")"
|
|
134
|
+
if [ -n "$value" ]; then
|
|
135
|
+
printf '%s' "$value"
|
|
136
|
+
return 0
|
|
137
|
+
fi
|
|
138
|
+
printf '%s' "$input" | sed -n 's/.*"transcript_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# Prints a tab-title-sized session name derived from the prompt's first line.
|
|
142
|
+
# Silent no-op without python3 (the session simply stays unnamed).
|
|
143
|
+
session_name_from_prompt() {
|
|
144
|
+
[ -n "$py3" ] || return 0
|
|
145
|
+
printf '%s' "$1" | "$py3" -c '
|
|
146
|
+
import sys
|
|
147
|
+
lines = sys.stdin.read().strip().splitlines()
|
|
148
|
+
line = lines[0].strip() if lines else ""
|
|
149
|
+
print(line if len(line) <= 48 else line[:47].rstrip() + "\u2026")
|
|
150
|
+
' 2>/dev/null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# Claude Code exposes no conversation title to hooks, so the session is named
|
|
154
|
+
# after its first real user prompt; switching sessions renames on that
|
|
155
|
+
# session's first prompt. Pragma preserves manual tab renames regardless.
|
|
156
|
+
report_session_name() {
|
|
157
|
+
prompt_text="$1"
|
|
158
|
+
[ -n "$hook_session_id" ] || return 0
|
|
159
|
+
[ "$(cat "$named_file" 2>/dev/null)" = "$hook_session_id" ] && return 0
|
|
160
|
+
session_name="$(session_name_from_prompt "$prompt_text")"
|
|
161
|
+
[ -n "$session_name" ] || return 0
|
|
162
|
+
report session-name --name "$session_name"
|
|
163
|
+
printf '%s' "$hook_session_id" >"$named_file"
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
# Reports a rich message whose text is JSON-escaped (safe for arbitrary
|
|
167
|
+
# content). Silent no-op without python3 or when the text is empty.
|
|
168
|
+
content_message() {
|
|
169
|
+
role="$1"
|
|
170
|
+
text="$2"
|
|
171
|
+
[ -n "$py3" ] && [ -n "$text" ] || return 0
|
|
172
|
+
id="${agent}-${tab}-$(date +%s)-$$-$role"
|
|
173
|
+
ts="$(message_ts_ms)"
|
|
174
|
+
active="$(tracked_subagent_count)"
|
|
175
|
+
payload=$("$py3" -c '
|
|
176
|
+
import json, sys
|
|
177
|
+
print(json.dumps({
|
|
178
|
+
"id": sys.argv[1],
|
|
179
|
+
"role": sys.argv[2],
|
|
180
|
+
"text": sys.argv[3],
|
|
181
|
+
"subAgentsActive": int(sys.argv[5]),
|
|
182
|
+
"ts": int(sys.argv[4]),
|
|
183
|
+
}))' "$id" "$role" "$text" "$ts" "$active" 2>/dev/null)
|
|
184
|
+
[ -n "$payload" ] || return 0
|
|
185
|
+
"$pragma_cli" agent message --agent "$agent" --payload "$payload" >/dev/null 2>&1 || true
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Prints the newest assistant text in a Claude Code transcript JSONL — the
|
|
189
|
+
# reply the turn that just stopped produced. Empty output when unavailable.
|
|
190
|
+
last_assistant_text() {
|
|
191
|
+
tp="$1"
|
|
192
|
+
[ -n "$py3" ] && [ -n "$tp" ] && [ -f "$tp" ] || return 0
|
|
193
|
+
"$py3" -c '
|
|
194
|
+
import json, sys
|
|
195
|
+
last = ""
|
|
196
|
+
for line in open(sys.argv[1], encoding="utf-8", errors="replace"):
|
|
197
|
+
try:
|
|
198
|
+
obj = json.loads(line)
|
|
199
|
+
except Exception:
|
|
200
|
+
continue
|
|
201
|
+
if obj.get("type") != "assistant":
|
|
202
|
+
continue
|
|
203
|
+
content = (obj.get("message") or {}).get("content") or []
|
|
204
|
+
parts = [c.get("text", "") for c in content if isinstance(c, dict) and c.get("type") == "text"]
|
|
205
|
+
if any(parts):
|
|
206
|
+
last = "\n".join(p for p in parts if p)
|
|
207
|
+
print(last)
|
|
208
|
+
' "$tp" 2>/dev/null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# Succeeds when a Stop payload reports at least one subagent still running in
|
|
212
|
+
# `background_tasks`. Such a Stop only ends the parent's *inference turn* — the
|
|
213
|
+
# session keeps working and Claude auto-resumes when the subagent finishes — so
|
|
214
|
+
# reporting `stopped` would flip the tab to done while agents are still active.
|
|
215
|
+
has_running_subagents() {
|
|
216
|
+
[ -n "$py3" ] || return 1
|
|
217
|
+
printf '%s' "$1" | "$py3" -c '
|
|
218
|
+
import json, sys
|
|
219
|
+
try:
|
|
220
|
+
tasks = (json.load(sys.stdin) or {}).get("background_tasks") or []
|
|
221
|
+
except Exception:
|
|
222
|
+
tasks = []
|
|
223
|
+
running = any(
|
|
224
|
+
isinstance(t, dict) and t.get("type") == "subagent" and t.get("status") == "running"
|
|
225
|
+
for t in tasks
|
|
226
|
+
)
|
|
227
|
+
sys.exit(0 if running else 1)
|
|
228
|
+
' 2>/dev/null
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
# Claude's SubagentStart/SubagentStop hooks provide durable accounting when
|
|
232
|
+
# several children overlap. Stop.background_tasks is retained as a fallback.
|
|
233
|
+
child_marker() {
|
|
234
|
+
child_id="$1"
|
|
235
|
+
child_key=$(printf '%s' "$child_id" | cksum | tr -d '[:space:]')
|
|
236
|
+
printf '%s/%s' "$children_dir" "$child_key"
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
track_subagent() {
|
|
240
|
+
child_id="$1"
|
|
241
|
+
[ -n "$child_id" ] || return 0
|
|
242
|
+
mkdir -p "$children_dir"
|
|
243
|
+
: >"$(child_marker "$child_id")"
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
untrack_subagent() {
|
|
247
|
+
child_id="$1"
|
|
248
|
+
[ -n "$child_id" ] || return 0
|
|
249
|
+
rm -f "$(child_marker "$child_id")"
|
|
250
|
+
rmdir "$children_dir" 2>/dev/null || true
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
has_tracked_subagents() {
|
|
254
|
+
[ -d "$children_dir" ] || return 1
|
|
255
|
+
for child in "$children_dir"/*; do
|
|
256
|
+
[ -f "$child" ] && return 0
|
|
257
|
+
done
|
|
258
|
+
return 1
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
tracked_subagent_count() {
|
|
262
|
+
count=0
|
|
263
|
+
if [ -d "$children_dir" ]; then
|
|
264
|
+
for child in "$children_dir"/*; do
|
|
265
|
+
[ -f "$child" ] && count=$((count + 1))
|
|
266
|
+
done
|
|
267
|
+
fi
|
|
268
|
+
printf '%s' "$count"
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
clear_subagents() {
|
|
272
|
+
if [ -d "$children_dir" ]; then
|
|
273
|
+
rm -f "$children_dir"/*
|
|
274
|
+
rmdir "$children_dir" 2>/dev/null || true
|
|
275
|
+
fi
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
# Prints the question text from an AskUserQuestion PermissionRequest payload
|
|
279
|
+
# ($1) when it carries exactly one question. Empty for multi-question payloads
|
|
280
|
+
# (one free-text reply can't answer them all -- those fall back to a generic
|
|
281
|
+
# attention + Claude's native question UI) or without python3.
|
|
282
|
+
question_text() {
|
|
283
|
+
[ -n "$py3" ] || return 0
|
|
284
|
+
printf '%s' "$1" | "$py3" -c '
|
|
285
|
+
import json, sys
|
|
286
|
+
try:
|
|
287
|
+
questions = ((json.load(sys.stdin) or {}).get("tool_input") or {}).get("questions") or []
|
|
288
|
+
except Exception:
|
|
289
|
+
questions = []
|
|
290
|
+
if len(questions) == 1 and isinstance(questions[0], dict):
|
|
291
|
+
text = questions[0].get("question")
|
|
292
|
+
if isinstance(text, str) and text:
|
|
293
|
+
print(text)
|
|
294
|
+
' 2>/dev/null
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
# Prints the single question's answer choices as a QuestionOption JSON array
|
|
298
|
+
# (`[{"label": ..., "description": ...}]`) for `report attention --options`.
|
|
299
|
+
# Empty when there are no usable choices (the report degrades to free-text).
|
|
300
|
+
question_options() {
|
|
301
|
+
[ -n "$py3" ] || return 0
|
|
302
|
+
printf '%s' "$1" | "$py3" -c '
|
|
303
|
+
import json, sys
|
|
304
|
+
try:
|
|
305
|
+
questions = ((json.load(sys.stdin) or {}).get("tool_input") or {}).get("questions") or []
|
|
306
|
+
except Exception:
|
|
307
|
+
questions = []
|
|
308
|
+
options = []
|
|
309
|
+
if len(questions) == 1 and isinstance(questions[0], dict):
|
|
310
|
+
for option in questions[0].get("options") or []:
|
|
311
|
+
if isinstance(option, dict) and isinstance(option.get("label"), str) and option["label"]:
|
|
312
|
+
entry = {"label": option["label"]}
|
|
313
|
+
description = option.get("description")
|
|
314
|
+
if isinstance(description, str) and description:
|
|
315
|
+
entry["description"] = description
|
|
316
|
+
options.append(entry)
|
|
317
|
+
if options:
|
|
318
|
+
print(json.dumps(options))
|
|
319
|
+
' 2>/dev/null
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
# Prints every AskUserQuestion question as a Question JSON array
|
|
323
|
+
# (`[{"question": ..., "options": [...]}, ...]`) for `report attention
|
|
324
|
+
# --questions`. Empty when the payload carries fewer than two questions (the
|
|
325
|
+
# single-question path keeps the legacy `--question`/`--options` fields).
|
|
326
|
+
question_json() {
|
|
327
|
+
[ -n "$py3" ] || return 0
|
|
328
|
+
printf '%s' "$1" | "$py3" -c '
|
|
329
|
+
import json, sys
|
|
330
|
+
try:
|
|
331
|
+
questions = ((json.load(sys.stdin) or {}).get("tool_input") or {}).get("questions") or []
|
|
332
|
+
except Exception:
|
|
333
|
+
questions = []
|
|
334
|
+
entries = []
|
|
335
|
+
for question in questions:
|
|
336
|
+
if not isinstance(question, dict):
|
|
337
|
+
continue
|
|
338
|
+
text = question.get("question")
|
|
339
|
+
if not isinstance(text, str) or not text:
|
|
340
|
+
continue
|
|
341
|
+
entry = {"question": text, "options": []}
|
|
342
|
+
for option in question.get("options") or []:
|
|
343
|
+
if not isinstance(option, dict) or not isinstance(option.get("label"), str) or not option["label"]:
|
|
344
|
+
continue
|
|
345
|
+
item = {"label": option["label"]}
|
|
346
|
+
description = option.get("description")
|
|
347
|
+
if isinstance(description, str) and description:
|
|
348
|
+
item["description"] = description
|
|
349
|
+
entry["options"].append(item)
|
|
350
|
+
entries.append(entry)
|
|
351
|
+
if len(entries) > 1:
|
|
352
|
+
print(json.dumps(entries))
|
|
353
|
+
' 2>/dev/null
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
# Prints the PermissionRequest allow decision that feeds a remote reply ($2)
|
|
357
|
+
# back into AskUserQuestion: `updatedInput.answers` (keyed by question text) is
|
|
358
|
+
# how the permission component supplies collected answers, so Claude continues
|
|
359
|
+
# with the reply and never shows its terminal question UI.
|
|
360
|
+
question_allow_decision() {
|
|
361
|
+
[ -n "$py3" ] || return 0
|
|
362
|
+
printf '%s' "$1" | "$py3" -c '
|
|
363
|
+
import json, sys
|
|
364
|
+
try:
|
|
365
|
+
tool_input = (json.load(sys.stdin) or {}).get("tool_input") or {}
|
|
366
|
+
except Exception:
|
|
367
|
+
sys.exit(0)
|
|
368
|
+
questions = tool_input.get("questions") or []
|
|
369
|
+
if len(questions) != 1 or not isinstance(questions[0], dict):
|
|
370
|
+
sys.exit(0)
|
|
371
|
+
text = questions[0].get("question")
|
|
372
|
+
if not isinstance(text, str) or not text:
|
|
373
|
+
sys.exit(0)
|
|
374
|
+
updated = dict(tool_input)
|
|
375
|
+
updated["answers"] = {text: sys.argv[1]}
|
|
376
|
+
print(json.dumps({
|
|
377
|
+
"hookSpecificOutput": {
|
|
378
|
+
"hookEventName": "PermissionRequest",
|
|
379
|
+
"decision": {"behavior": "allow", "updatedInput": updated},
|
|
380
|
+
}
|
|
381
|
+
}))
|
|
382
|
+
' "$2" 2>/dev/null
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
# Prints the PermissionRequest allow decision for a multi-question
|
|
386
|
+
# AskUserQuestion. The mobile wizard submits all answers on one line joined by
|
|
387
|
+
# the ASCII unit separator (0x1f) -- not " | ", which an option label or
|
|
388
|
+
# free-text answer could legitimately contain and which would then corrupt
|
|
389
|
+
# the split below; split it back into answers keyed by question text so
|
|
390
|
+
# Claude continues without ever showing its terminal question UI.
|
|
391
|
+
question_multi_allow_decision() {
|
|
392
|
+
[ -n "$py3" ] || return 0
|
|
393
|
+
printf '%s' "$1" | "$py3" -c '
|
|
394
|
+
import json, sys
|
|
395
|
+
try:
|
|
396
|
+
tool_input = (json.load(sys.stdin) or {}).get("tool_input") or {}
|
|
397
|
+
except Exception:
|
|
398
|
+
sys.exit(0)
|
|
399
|
+
questions = tool_input.get("questions") or []
|
|
400
|
+
parts = [part.strip() for part in sys.argv[1].split("\x1f")]
|
|
401
|
+
if len(questions) < 2:
|
|
402
|
+
sys.exit(0)
|
|
403
|
+
updated = dict(tool_input)
|
|
404
|
+
answers = {}
|
|
405
|
+
for index, question in enumerate(questions):
|
|
406
|
+
if not isinstance(question, dict):
|
|
407
|
+
continue
|
|
408
|
+
text = question.get("question")
|
|
409
|
+
if isinstance(text, str) and text and index < len(parts):
|
|
410
|
+
answers[text] = parts[index]
|
|
411
|
+
updated["answers"] = answers
|
|
412
|
+
print(json.dumps({
|
|
413
|
+
"hookSpecificOutput": {
|
|
414
|
+
"hookEventName": "PermissionRequest",
|
|
415
|
+
"decision": {"behavior": "allow", "updatedInput": updated},
|
|
416
|
+
}
|
|
417
|
+
}))
|
|
418
|
+
' "$2" 2>/dev/null
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
# AskUserQuestion arrives through the same blocking PermissionRequest hook as
|
|
422
|
+
# command approvals. Report it as a `question` attention (text + choices +
|
|
423
|
+
# requestId) so clients render an answer UI instead of a command toast showing
|
|
424
|
+
# raw tool JSON; block on `await-answer` and feed a reply back through an
|
|
425
|
+
# allow decision with pre-filled answers. Dismiss emits a deny decision so the
|
|
426
|
+
# native question closes; timeout emits nothing so Claude falls back to it.
|
|
427
|
+
handle_question() {
|
|
428
|
+
input="$1"
|
|
429
|
+
qjson="$(question_json "$input")"
|
|
430
|
+
request_id="${agent}-${tab}-$(date +%s)-$$"
|
|
431
|
+
if [ -n "$qjson" ]; then
|
|
432
|
+
# Multi-question: mobile/desktop render a back/next wizard and submit one
|
|
433
|
+
# unit-separator-joined line. Report the questions array and feed the
|
|
434
|
+
# collected answers back through updatedInput.answers.
|
|
435
|
+
if ! report_checked attention --kind question --questions "$qjson" --request-id "$request_id"; then
|
|
436
|
+
# Installed pragma-cli is older than this hook and rejects `--questions`.
|
|
437
|
+
# Raise a generic attention and let Claude's own UI collect the answers
|
|
438
|
+
# instead of blocking on a reply no client can ever send.
|
|
439
|
+
report attention
|
|
440
|
+
message system "Claude Code is asking questions"
|
|
441
|
+
return 0
|
|
442
|
+
fi
|
|
443
|
+
message system "Claude Code is asking questions"
|
|
444
|
+
answer="$("$pragma_cli" agent await-answer \
|
|
445
|
+
--agent "$agent" --request-id "$request_id" --timeout "$approval_timeout" \
|
|
446
|
+
--dismiss-output "$dismissed_answer" 2>/dev/null)"
|
|
447
|
+
[ -n "$answer" ] || return 0
|
|
448
|
+
if [ "$answer" = "$dismissed_answer" ]; then
|
|
449
|
+
if [ -f "$marker" ]; then
|
|
450
|
+
report started
|
|
451
|
+
message system "Questions dismissed"
|
|
452
|
+
fi
|
|
453
|
+
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny"}}}'
|
|
454
|
+
return 0
|
|
455
|
+
fi
|
|
456
|
+
decision="$(question_multi_allow_decision "$input" "$answer")"
|
|
457
|
+
[ -n "$decision" ] || return 0
|
|
458
|
+
if [ -f "$marker" ]; then
|
|
459
|
+
report started
|
|
460
|
+
message system "Questions answered"
|
|
461
|
+
fi
|
|
462
|
+
printf '%s\n' "$decision"
|
|
463
|
+
return 0
|
|
464
|
+
fi
|
|
465
|
+
|
|
466
|
+
qtext="$(question_text "$input")"
|
|
467
|
+
if [ -z "$qtext" ]; then
|
|
468
|
+
# Unparseable payload: raise a generic attention and let Claude's own UI
|
|
469
|
+
# collect the answer.
|
|
470
|
+
report attention
|
|
471
|
+
message system "Claude Code is asking a question"
|
|
472
|
+
return 0
|
|
473
|
+
fi
|
|
474
|
+
qopts="$(question_options "$input")"
|
|
475
|
+
if [ -n "$qopts" ]; then
|
|
476
|
+
report attention --kind question --question "$qtext" --options "$qopts" --request-id "$request_id"
|
|
477
|
+
else
|
|
478
|
+
report attention --kind question --question "$qtext" --request-id "$request_id"
|
|
479
|
+
fi
|
|
480
|
+
message system "Claude Code is asking a question"
|
|
481
|
+
answer="$("$pragma_cli" agent await-answer \
|
|
482
|
+
--agent "$agent" --request-id "$request_id" --timeout "$approval_timeout" \
|
|
483
|
+
--dismiss-output "$dismissed_answer" 2>/dev/null)"
|
|
484
|
+
[ -n "$answer" ] || return 0
|
|
485
|
+
# The turn resumes the moment a reply (or dismissal) goes back to Claude, and
|
|
486
|
+
# no hook is guaranteed to fire next (a deny never runs the tool, so no
|
|
487
|
+
# PostToolUse). Re-assert `started` here so the tab drops back to "in
|
|
488
|
+
# progress" instead of staying stuck on the question attention. Guarded on the
|
|
489
|
+
# marker so a turn the abort watcher cleared meanwhile stays cleared.
|
|
490
|
+
if [ "$answer" = "$dismissed_answer" ]; then
|
|
491
|
+
if [ -f "$marker" ]; then
|
|
492
|
+
report started
|
|
493
|
+
message system "Question dismissed"
|
|
494
|
+
fi
|
|
495
|
+
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny"}}}'
|
|
496
|
+
return 0
|
|
497
|
+
fi
|
|
498
|
+
decision="$(question_allow_decision "$input" "$answer")"
|
|
499
|
+
[ -n "$decision" ] || return 0
|
|
500
|
+
if [ -f "$marker" ]; then
|
|
501
|
+
report started
|
|
502
|
+
message system "Question answered"
|
|
503
|
+
fi
|
|
504
|
+
printf '%s\n' "$decision"
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
# Extracts a human-readable command string from a PermissionRequest stdin JSON
|
|
508
|
+
# payload (passed as $1). Prefers `jq` for a faithful `tool_input.command`
|
|
509
|
+
# (Bash); falls back to the tool name when jq is absent or the tool has no
|
|
510
|
+
# command field, so the approval toast always shows *something* to approve.
|
|
511
|
+
extract_command() {
|
|
512
|
+
input="$1"
|
|
513
|
+
if [ -n "$py3" ]; then
|
|
514
|
+
summary=$(printf '%s' "$input" | "$py3" -c '
|
|
515
|
+
import json, sys
|
|
516
|
+
try:
|
|
517
|
+
payload = json.load(sys.stdin) or {}
|
|
518
|
+
except Exception:
|
|
519
|
+
sys.exit(0)
|
|
520
|
+
tool_input = payload.get("tool_input") or {}
|
|
521
|
+
if isinstance(tool_input, str):
|
|
522
|
+
try:
|
|
523
|
+
tool_input = json.loads(tool_input)
|
|
524
|
+
except Exception:
|
|
525
|
+
tool_input = {}
|
|
526
|
+
if not isinstance(tool_input, dict):
|
|
527
|
+
tool_input = {}
|
|
528
|
+
command = tool_input.get("command")
|
|
529
|
+
if isinstance(command, str) and command:
|
|
530
|
+
print(command)
|
|
531
|
+
sys.exit(0)
|
|
532
|
+
tool_name = payload.get("tool_name")
|
|
533
|
+
file_path = tool_input.get("file_path") or tool_input.get("filePath") or tool_input.get("path")
|
|
534
|
+
if tool_name in {"Read", "Write", "Edit"} and isinstance(file_path, str) and file_path:
|
|
535
|
+
print(f"{tool_name} {file_path}")
|
|
536
|
+
' 2>/dev/null)
|
|
537
|
+
if [ -n "$summary" ]; then
|
|
538
|
+
printf '%s' "$summary"
|
|
539
|
+
return 0
|
|
540
|
+
fi
|
|
541
|
+
fi
|
|
542
|
+
if command -v jq >/dev/null 2>&1; then
|
|
543
|
+
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
544
|
+
if [ -n "$cmd" ]; then
|
|
545
|
+
printf '%s' "$cmd"
|
|
546
|
+
return 0
|
|
547
|
+
fi
|
|
548
|
+
name=$(printf '%s' "$input" | jq -r '.tool_name // "tool"' 2>/dev/null)
|
|
549
|
+
path=$(printf '%s' "$input" | jq -r '
|
|
550
|
+
(.tool_input // {})
|
|
551
|
+
| if type == "string" then (try fromjson catch {}) else . end
|
|
552
|
+
| .file_path // .filePath // .path // empty
|
|
553
|
+
' 2>/dev/null)
|
|
554
|
+
case "$name" in
|
|
555
|
+
Read|Write|Edit)
|
|
556
|
+
if [ -n "$path" ]; then
|
|
557
|
+
printf '%s %s' "$name" "$path"
|
|
558
|
+
return 0
|
|
559
|
+
fi
|
|
560
|
+
;;
|
|
561
|
+
esac
|
|
562
|
+
args=$(printf '%s' "$input" | jq -rc '
|
|
563
|
+
(.tool_input // {})
|
|
564
|
+
| if type == "string" then (try fromjson catch {}) else . end
|
|
565
|
+
' 2>/dev/null)
|
|
566
|
+
printf '%s %s' "$name" "$args"
|
|
567
|
+
return 0
|
|
568
|
+
fi
|
|
569
|
+
printf '%s' "$input" | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
# Grep pattern matching a real interrupt user message in transcript JSONL.
|
|
573
|
+
# The cancel line carries the marker as an *unescaped* top-level JSON string
|
|
574
|
+
# (`"text":"[Request interrupted by user…`). The same phrase embedded in tool
|
|
575
|
+
# output (e.g. Claude reading this very script) is nested inside another JSON
|
|
576
|
+
# string, so its quotes arrive backslash-escaped and must never match.
|
|
577
|
+
interrupt_pattern='"text":"\[Request interrupted by user'
|
|
578
|
+
|
|
579
|
+
# Succeeds when the most recent turn in the transcript ended in a user
|
|
580
|
+
# interruption (cancel/abort). The marker is written as the final user message
|
|
581
|
+
# of the cancelled turn, so we only inspect the tail -- an interruption earlier
|
|
582
|
+
# in a turn that later continued must not count (later lines push it out).
|
|
583
|
+
turn_interrupted() {
|
|
584
|
+
tp="$1"
|
|
585
|
+
[ -n "$tp" ] && [ -f "$tp" ] || return 1
|
|
586
|
+
tail -n 5 "$tp" 2>/dev/null | grep -q "$interrupt_pattern"
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
# Like turn_interrupted, but scoped to transcript content written *after* `off`
|
|
590
|
+
# bytes -- i.e. only this turn's tail. This lets the watcher ignore an interrupt
|
|
591
|
+
# marker left by an earlier, already-cleared turn: that marker is the file's last
|
|
592
|
+
# line, so a plain tail would see it the instant a new turn starts -- before
|
|
593
|
+
# Claude has appended anything -- and false-clear a turn that is merely thinking.
|
|
594
|
+
interrupted_since() {
|
|
595
|
+
tp="$1"
|
|
596
|
+
off="${2:-0}"
|
|
597
|
+
[ -n "$tp" ] && [ -f "$tp" ] || return 1
|
|
598
|
+
[ -n "$off" ] || off=0
|
|
599
|
+
tail -c "+$((off + 1))" "$tp" 2>/dev/null | grep -q "$interrupt_pattern"
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
# Stops the current watcher (if any) and forgets its pid. Best-effort: the
|
|
603
|
+
# watcher also self-exits once the marker is gone, so a missed kill is harmless.
|
|
604
|
+
stop_watcher() {
|
|
605
|
+
if [ -f "$pidfile" ]; then
|
|
606
|
+
pid=$(cat "$pidfile" 2>/dev/null)
|
|
607
|
+
[ -n "$pid" ] && kill "$pid" 2>/dev/null
|
|
608
|
+
rm -f "$pidfile"
|
|
609
|
+
fi
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
# Background watcher loop. Polls the transcript for the cancel marker that no
|
|
613
|
+
# hook reports. `token` pins it to the turn that spawned it: if the marker is
|
|
614
|
+
# removed (normal end / session end) or rewritten (a new turn started), the
|
|
615
|
+
# watcher exits without touching state, so it can never clobber a later turn.
|
|
616
|
+
run_watcher() {
|
|
617
|
+
tp="$1"
|
|
618
|
+
token="$2"
|
|
619
|
+
offset="${3:-0}"
|
|
620
|
+
deadline=$(($(date +%s) + max_lifetime))
|
|
621
|
+
while :; do
|
|
622
|
+
# Turn ended or was superseded -> nothing to clear, exit quietly.
|
|
623
|
+
[ -f "$marker" ] || exit 0
|
|
624
|
+
[ "$(cat "$marker" 2>/dev/null)" = "$token" ] || exit 0
|
|
625
|
+
if interrupted_since "$tp" "$offset"; then
|
|
626
|
+
# Re-check the token right before acting so we never clear a turn that
|
|
627
|
+
# started in the gap between the poll and now.
|
|
628
|
+
[ "$(cat "$marker" 2>/dev/null)" = "$token" ] || exit 0
|
|
629
|
+
rm -f "$marker" "$turn_session_file"
|
|
630
|
+
report cleared
|
|
631
|
+
exit 0
|
|
632
|
+
fi
|
|
633
|
+
[ "$(date +%s)" -lt "$deadline" ] || exit 0
|
|
634
|
+
sleep "$interval"
|
|
635
|
+
done
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
# The watcher re-enters this same script as a detached child via `__watch`.
|
|
639
|
+
if [ "${1:-}" = "__watch" ]; then
|
|
640
|
+
run_watcher "$2" "$3" "${4:-}"
|
|
641
|
+
exit 0
|
|
642
|
+
fi
|
|
643
|
+
|
|
644
|
+
# Subagent hooks share the parent terminal environment, so without this guard a
|
|
645
|
+
# child Stop/PostToolUse can overwrite the parent turn's status. Current Claude
|
|
646
|
+
# adds `agent_id`; also recognize the explicit event and transcript fields for
|
|
647
|
+
# compatibility with payload variants. Consume stdin before any state mutation.
|
|
648
|
+
input="$(cat)"
|
|
649
|
+
hook_agent_id="$(json_field agent_id "$input")"
|
|
650
|
+
if [ -z "$hook_agent_id" ]; then
|
|
651
|
+
hook_agent_id=$(printf '%s' "$input" | sed -n 's/.*"agent_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
|
|
652
|
+
fi
|
|
653
|
+
hook_event_name="$(json_field hook_event_name "$input")"
|
|
654
|
+
agent_transcript_path="$(json_field agent_transcript_path "$input")"
|
|
655
|
+
if [ "${1:-}" = "subagent-start" ]; then
|
|
656
|
+
track_subagent "$hook_agent_id"
|
|
657
|
+
[ -f "$marker" ] || printf '%s' "$$-$(date +%s)" >"$marker"
|
|
658
|
+
message system "Claude Code started a subagent"
|
|
659
|
+
report started
|
|
660
|
+
exit 0
|
|
661
|
+
fi
|
|
662
|
+
if [ "${1:-}" = "subagent-stop" ]; then
|
|
663
|
+
untrack_subagent "$hook_agent_id"
|
|
664
|
+
exit 0
|
|
665
|
+
fi
|
|
666
|
+
if [ -n "$hook_agent_id" ] || [ "$hook_event_name" = "SubagentStop" ] || [ -n "$agent_transcript_path" ]; then
|
|
667
|
+
exit 0
|
|
668
|
+
fi
|
|
669
|
+
|
|
670
|
+
# Some Claude builds emit child completion as a plain Stop without any of the
|
|
671
|
+
# subagent fields above. The child still has its own session_id, so pin this tab
|
|
672
|
+
# to the parent session established by SessionStart/UserPromptSubmit and reject
|
|
673
|
+
# every event from a different session before it can alter parent status.
|
|
674
|
+
hook_session_id="$(json_field session_id "$input")"
|
|
675
|
+
case "$hook_event_name" in
|
|
676
|
+
SessionStart|UserPromptSubmit)
|
|
677
|
+
[ -z "$hook_session_id" ] || printf '%s' "$hook_session_id" >"$session_file"
|
|
678
|
+
;;
|
|
679
|
+
esac
|
|
680
|
+
if [ -n "$hook_session_id" ] && [ -f "$session_file" ]; then
|
|
681
|
+
parent_session_id="$(cat "$session_file" 2>/dev/null)"
|
|
682
|
+
[ -z "$parent_session_id" ] || [ "$hook_session_id" = "$parent_session_id" ] || exit 0
|
|
683
|
+
fi
|
|
684
|
+
|
|
685
|
+
case "${1:-}" in
|
|
686
|
+
started)
|
|
687
|
+
# A new turn is in flight. Tag the marker with a unique token, report
|
|
688
|
+
# running, then replace any prior watcher with a fresh one bound to this
|
|
689
|
+
# turn's transcript and token.
|
|
690
|
+
token="$$-$(date +%s)"
|
|
691
|
+
printf '%s' "$token" >"$marker"
|
|
692
|
+
printf '%s' "$hook_session_id" >"$turn_session_file"
|
|
693
|
+
report started
|
|
694
|
+
# UserPromptSubmit carries the user's prompt: surface it as the chat's user
|
|
695
|
+
# bubble; fall back to the coarse status line when it can't be extracted.
|
|
696
|
+
# Subagent completions auto-resume the parent through a synthetic
|
|
697
|
+
# UserPromptSubmit whose prompt is a `<task-notification>` block the user
|
|
698
|
+
# never typed -- report `started` for it but don't render a fake bubble.
|
|
699
|
+
prompt="$(json_field prompt "$input")"
|
|
700
|
+
case "$prompt" in
|
|
701
|
+
"<task-notification>"* | "[SYSTEM NOTIFICATION"*)
|
|
702
|
+
message system "Claude Code resumed after a subagent finished"
|
|
703
|
+
;;
|
|
704
|
+
"")
|
|
705
|
+
message assistant "Claude Code turn started"
|
|
706
|
+
;;
|
|
707
|
+
*)
|
|
708
|
+
content_message user "$prompt"
|
|
709
|
+
report_session_name "$prompt"
|
|
710
|
+
;;
|
|
711
|
+
esac
|
|
712
|
+
stop_watcher
|
|
713
|
+
tp="$(transcript_path "$input")"
|
|
714
|
+
if [ -n "$tp" ]; then
|
|
715
|
+
# Pin the watcher to where the transcript stands *now* so a prior turn's
|
|
716
|
+
# interrupt marker (already in the file) can't be mistaken for this turn's
|
|
717
|
+
# cancel while Claude is still thinking and has appended nothing yet.
|
|
718
|
+
offset=$(wc -c <"$tp" 2>/dev/null | tr -d '[:space:]')
|
|
719
|
+
[ -n "$offset" ] || offset=0
|
|
720
|
+
nohup sh "$0" __watch "$tp" "$token" "$offset" >/dev/null 2>&1 &
|
|
721
|
+
echo "$!" >"$pidfile"
|
|
722
|
+
fi
|
|
723
|
+
;;
|
|
724
|
+
stopped)
|
|
725
|
+
# `Stop` fires only on normal completion (never on a cancel). The
|
|
726
|
+
# transcript check is a belt-and-suspenders for any build where Stop might
|
|
727
|
+
# trail an interrupt.
|
|
728
|
+
tp="$(transcript_path "$input")"
|
|
729
|
+
if turn_interrupted "$tp"; then
|
|
730
|
+
stop_watcher
|
|
731
|
+
rm -f "$marker" "$turn_session_file"
|
|
732
|
+
report cleared
|
|
733
|
+
message system "Claude Code turn interrupted"
|
|
734
|
+
elif has_running_subagents "$input" || has_tracked_subagents; then
|
|
735
|
+
# The parent's inference turn ended, but background subagents are still
|
|
736
|
+
# working and Claude auto-resumes (a synthetic UserPromptSubmit) when
|
|
737
|
+
# they finish -- the session is NOT done. Stay on `started`, keep the
|
|
738
|
+
# marker and abort watcher alive so a cancel during the subagent phase
|
|
739
|
+
# is still detected, and surface the parent's interim reply.
|
|
740
|
+
[ -f "$marker" ] || printf '%s' "$$-$(date +%s)" >"$marker"
|
|
741
|
+
report started
|
|
742
|
+
reply="$(json_field last_assistant_message "$input")"
|
|
743
|
+
[ -n "$reply" ] || reply="$(last_assistant_text "$tp")"
|
|
744
|
+
if [ -n "$reply" ]; then
|
|
745
|
+
content_message assistant "$reply"
|
|
746
|
+
else
|
|
747
|
+
message assistant "Claude Code is waiting on subagents"
|
|
748
|
+
fi
|
|
749
|
+
else
|
|
750
|
+
# Tear down the watcher, clear the in-flight marker, and report the
|
|
751
|
+
# green "done" dot.
|
|
752
|
+
stop_watcher
|
|
753
|
+
rm -f "$marker" "$turn_session_file"
|
|
754
|
+
report stopped
|
|
755
|
+
# Stop carries the completed reply directly on current Claude builds.
|
|
756
|
+
# Prefer it over rereading the transcript, then retain transcript support
|
|
757
|
+
# for older builds that omit the field.
|
|
758
|
+
reply="$(json_field last_assistant_message "$input")"
|
|
759
|
+
[ -n "$reply" ] || reply="$(last_assistant_text "$tp")"
|
|
760
|
+
if [ -n "$reply" ]; then
|
|
761
|
+
content_message assistant "$reply"
|
|
762
|
+
else
|
|
763
|
+
message assistant "Claude Code turn completed"
|
|
764
|
+
fi
|
|
765
|
+
fi
|
|
766
|
+
;;
|
|
767
|
+
cleared)
|
|
768
|
+
# `/clear` fires SessionEnd + SessionStart while the user's next prompt may
|
|
769
|
+
# already have started a turn in the NEW session. The session pinning above
|
|
770
|
+
# discards the old session's late SessionEnd, but SessionStart re-pins to
|
|
771
|
+
# its own (new) session id first — so a SessionStart landing *after* that
|
|
772
|
+
# session's first `started` would wipe the live marker and mute every
|
|
773
|
+
# marker-guarded report for the rest of the turn. Skip the clear when the
|
|
774
|
+
# in-flight turn already belongs to this same session.
|
|
775
|
+
if [ "$hook_event_name" = "SessionStart" ] && [ -f "$marker" ] &&
|
|
776
|
+
[ -n "$hook_session_id" ] &&
|
|
777
|
+
[ "$(cat "$turn_session_file" 2>/dev/null)" = "$hook_session_id" ]; then
|
|
778
|
+
exit 0
|
|
779
|
+
fi
|
|
780
|
+
stop_watcher
|
|
781
|
+
rm -f "$marker" "$turn_session_file"
|
|
782
|
+
clear_subagents
|
|
783
|
+
if [ "$hook_event_name" = "SessionEnd" ]; then
|
|
784
|
+
rm -f "$session_file"
|
|
785
|
+
rm -f "$named_file"
|
|
786
|
+
fi
|
|
787
|
+
report cleared
|
|
788
|
+
;;
|
|
789
|
+
running)
|
|
790
|
+
# PostToolUse: a tool just finished mid-turn. If a turn is in flight, re-assert
|
|
791
|
+
# `running` so a lingering `attention` -- left by an approved permission prompt,
|
|
792
|
+
# which Claude Code reports via Notification but never *clears* with any hook --
|
|
793
|
+
# drops back to "in progress" at once instead of staying stuck until `Stop`. We
|
|
794
|
+
# deliberately leave the marker and the abort watcher untouched: this is the same
|
|
795
|
+
# turn `started` set up, so its cancel detection must keep running. Guarded on the
|
|
796
|
+
# marker so a stray PostToolUse outside a turn can't flash a phantom "running".
|
|
797
|
+
if [ -f "$marker" ]; then
|
|
798
|
+
report started
|
|
799
|
+
message tool "Claude Code tool finished"
|
|
800
|
+
fi
|
|
801
|
+
;;
|
|
802
|
+
permission)
|
|
803
|
+
# PermissionRequest: Claude is asking to run a tool and is BLOCKED on this
|
|
804
|
+
# hook's stdout. AskUserQuestion routes through this same hook, so branch it
|
|
805
|
+
# to the question flow (question attention + await-answer); everything else
|
|
806
|
+
# reports a `command` attention carrying the command text and a unique
|
|
807
|
+
# requestId, then blocks on `await-decision` for the verdict a Pragma
|
|
808
|
+
# approval toast publishes. Emit Claude's PermissionRequest decision JSON so
|
|
809
|
+
# an approve runs the tool and a deny rejects it -- all without the user
|
|
810
|
+
# touching the terminal. On timeout (no one answered) emit nothing so Claude
|
|
811
|
+
# falls back to its own native permission prompt. Guarded on the marker so a
|
|
812
|
+
# stray request outside a turn can't wedge the hook.
|
|
813
|
+
if [ -f "$marker" ]; then
|
|
814
|
+
if [ "$(json_field tool_name "$input")" = "AskUserQuestion" ]; then
|
|
815
|
+
handle_question "$input"
|
|
816
|
+
exit 0
|
|
817
|
+
fi
|
|
818
|
+
command_text="$(extract_command "$input")"
|
|
819
|
+
request_id="${agent}-${tab}-$(date +%s)-$$"
|
|
820
|
+
report attention --kind command --command "$command_text" --request-id "$request_id"
|
|
821
|
+
message system "Claude Code needs approval"
|
|
822
|
+
verdict="$("$pragma_cli" agent await-decision \
|
|
823
|
+
--agent "$agent" --request-id "$request_id" --timeout "$approval_timeout" 2>/dev/null)"
|
|
824
|
+
# Either verdict resumes the turn at once. An allowed tool will also fire
|
|
825
|
+
# PostToolUse when it finishes, but a denied one never runs — without this
|
|
826
|
+
# re-assert the tab would stay stuck on the command attention until Stop.
|
|
827
|
+
# Guarded on the marker so a turn the abort watcher cleared meanwhile
|
|
828
|
+
# stays cleared.
|
|
829
|
+
case "$verdict" in
|
|
830
|
+
allow)
|
|
831
|
+
[ -f "$marker" ] && report started
|
|
832
|
+
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
|
|
833
|
+
;;
|
|
834
|
+
deny)
|
|
835
|
+
[ -f "$marker" ] && report started
|
|
836
|
+
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny"}}}'
|
|
837
|
+
;;
|
|
838
|
+
*)
|
|
839
|
+
# Timed out / no decision: defer to Claude's own prompt.
|
|
840
|
+
:
|
|
841
|
+
;;
|
|
842
|
+
esac
|
|
843
|
+
fi
|
|
844
|
+
;;
|
|
845
|
+
attention)
|
|
846
|
+
# Only raise attention while a turn is actually in flight. The fast
|
|
847
|
+
# `PermissionRequest`/`Elicitation` hooks always fire mid-turn, so the marker
|
|
848
|
+
# is present then and the guard never suppresses a real prompt. It is
|
|
849
|
+
# defense-in-depth against any late/stray attention landing on an
|
|
850
|
+
# already-finished turn (which nothing would clear). It is also why we no
|
|
851
|
+
# longer wire the *debounced* `Notification permission_prompt` (~3-5s late):
|
|
852
|
+
# after an approval the marker is still present, so that stale notification
|
|
853
|
+
# would re-raise a phantom attention over a turn that is already running.
|
|
854
|
+
if [ -f "$marker" ]; then
|
|
855
|
+
report attention
|
|
856
|
+
message system "Claude Code needs attention"
|
|
857
|
+
fi
|
|
858
|
+
;;
|
|
859
|
+
idle)
|
|
860
|
+
# Idle-prompt notification. After a normal completion the marker is already
|
|
861
|
+
# gone, so this is a no-op and the green "done" dot is preserved. (It does
|
|
862
|
+
# not fire after a cancel -- the watcher handles those -- but if a future
|
|
863
|
+
# Claude Code build emits it, a lingering marker still clears the turn.)
|
|
864
|
+
if [ -f "$marker" ]; then
|
|
865
|
+
stop_watcher
|
|
866
|
+
rm -f "$marker" "$turn_session_file"
|
|
867
|
+
report cleared
|
|
868
|
+
message system "Claude Code turn cleared"
|
|
869
|
+
fi
|
|
870
|
+
;;
|
|
871
|
+
esac
|
|
872
|
+
|
|
873
|
+
exit 0
|