@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.
@@ -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