@pragma-sh/junie-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,715 @@
1
+ #!/usr/bin/env sh
2
+ # Pragma <-> JetBrains Junie CLI status bridge.
3
+ #
4
+ # Invoked by hooks/hooks.json on Junie lifecycle events (see the hook -> status
5
+ # table in AGENTS.md). Each event becomes a `pragma-cli` status report for the
6
+ # current Pragma terminal tab. Outside a Pragma terminal PRAGMA_SERVER_SOCKET /
7
+ # PRAGMA_DAEMON_SOCKET are unset and there is no server to talk to, so every
8
+ # event is a silent no-op -- except PermissionRequest, which answers `ask`
9
+ # instead, because Junie reads silence from a successful hook as approval.
10
+ #
11
+ # Junie's stdin envelope is snake_case (`hook_event_name`, `session_id`,
12
+ # `tool_name`, `tool_input`, `last_assistant_message`, ...), like Claude Code's.
13
+ # Two differences shape everything below:
14
+ #
15
+ # * Only `SessionStart` and `UserPromptSubmit` carry `session_id`; `PreToolUse`,
16
+ # `PermissionRequest`, `Stop`, `StopFailure` and `SessionEnd` do not. The
17
+ # session id is therefore captured on the two events that have it and cached
18
+ # per tab, and every later event reuses the cached one.
19
+ # * There is no `transcript_path` at all. Junie writes its session log to
20
+ # `<junie home>/sessions/<session_id>/events.jsonl`, which this script
21
+ # resolves from the cached session id (SessionStart's `cwd` is Junie's own
22
+ # home directory, so it is preferred when it actually holds `sessions/`).
23
+ #
24
+ # Abort handling (the hard part): an interrupted turn (Esc) fires no hook at all
25
+ # -- Junie runs `Stop` only for genuine completions. The cancel *is* recorded in
26
+ # `events.jsonl` as a `ResultBlockUpdatedEvent` with `"cancelled":true` (and a
27
+ # failed turn as `AgentTaskFailedEvent`), so `started` spawns one detached
28
+ # per-tab watcher that polls that file past this turn's starting byte offset.
29
+ # The same watcher streams Junie's assistant output mid-turn (see sync_messages)
30
+ # and raises its question attentions (see sync_questions): asking a question
31
+ # fires no hook either, it is only an `AskAsyncRequestUpdatedEvent` in that log.
32
+
33
+ set -u
34
+
35
+ # Outside Pragma there is no server to report to; every event is a silent no-op
36
+ # EXCEPT `permission`: Junie reads a hook that exits 0 with no decision as an
37
+ # approval, so silence would rubber-stamp the sensitive action without Pragma or
38
+ # its native prompt. Return `ask` (Junie's "show your own prompt" verdict) and
39
+ # only let the other events fall through silently.
40
+ if [ -z "${PRAGMA_SERVER_SOCKET:-}${PRAGMA_DAEMON_SOCKET:-}" ]; then
41
+ if [ "${1:-}" = "permission" ]; then
42
+ printf '%s\n' '{"decision":"ask","reason":"Not running inside Pragma"}'
43
+ fi
44
+ exit 0
45
+ fi
46
+
47
+ agent="junie"
48
+ pragma_cli="${PRAGMA_CLI:-pragma-cli}"
49
+ tab="${PRAGMA_TAB_ID:-unknown}"
50
+ state_dir="${TMPDIR:-/tmp}"
51
+ # Per-tab files: the marker holds the active turn's token (presence = a turn is
52
+ # in flight); the pidfile holds the current watcher's pid so a new turn (or a
53
+ # normal end) can tear it down.
54
+ marker="${state_dir}/pragma-cli-${agent}-${tab}.active"
55
+ pidfile="${state_dir}/pragma-cli-${agent}-${tab}.watcher"
56
+ # The parent session id, cached because most Junie hooks omit it.
57
+ session_file="${state_dir}/pragma-cli-${agent}-${tab}.session"
58
+ # Junie's own home directory, as reported by SessionStart's `cwd`.
59
+ home_file="${state_dir}/pragma-cli-${agent}-${tab}.home"
60
+ # Last session name reported for this tab, so a rename is sent once per change.
61
+ named_file="${state_dir}/pragma-cli-${agent}-${tab}.sessionname"
62
+ # The session id that owns the in-flight marker, so a late `cleared` from a
63
+ # superseded session cannot wipe a turn that already started in a new one.
64
+ turn_session_file="${state_dir}/pragma-cli-${agent}-${tab}.turn-session"
65
+ # Characters of assistant output already streamed for the active turn.
66
+ sent_file="${state_dir}/pragma-cli-${agent}-${tab}.sent"
67
+ # Event-log byte offset the active turn started at, so `stopped` can run the
68
+ # final message sync over the same window the watcher used.
69
+ offset_file="${state_dir}/pragma-cli-${agent}-${tab}.offset"
70
+ # Request id of the question this tab is currently blocked on, so the attention
71
+ # is raised once and cleared when Junie's own prompt resolves.
72
+ question_file="${state_dir}/pragma-cli-${agent}-${tab}.question"
73
+ # Scratch files the question scan writes its parsed fields into.
74
+ question_scratch="${state_dir}/pragma-cli-${agent}-${tab}.question-scan"
75
+
76
+ # Poll cadence and absolute lifetime backstop (overridable for tests). The
77
+ # backstop guarantees a watcher can't outlive its session forever if the session
78
+ # is killed uncatchably (SIGKILL) and the marker is never removed.
79
+ interval="${PRAGMA_WATCH_INTERVAL:-1}"
80
+ max_lifetime="${PRAGMA_WATCH_MAX:-86400}"
81
+ # How long a PermissionRequest blocks waiting for a remote approve/deny from a
82
+ # Pragma toast before deferring to Junie's own prompt. Junie's hook `timeout`
83
+ # for this event is set slightly higher in hooks.json so this wins the race.
84
+ approval_timeout="${PRAGMA_APPROVAL_TIMEOUT:-300}"
85
+
86
+ # Reports a status to Pragma, swallowing every failure so a hook never disrupts
87
+ # a Junie session (e.g. when pragma-cli or the server is unavailable).
88
+ report() {
89
+ "$pragma_cli" agent report --agent "$agent" "$@" >/dev/null 2>&1 || true
90
+ }
91
+
92
+ # AgentMessage.ts is milliseconds since Unix epoch (see @pragma/constants).
93
+ # `date +%s` is seconds -- multiply so chat clients that stamp local input with
94
+ # Date.now() don't sort every agent bubble above the user's messages.
95
+ message_ts_ms() {
96
+ echo $(($(date +%s) * 1000))
97
+ }
98
+
99
+ # Reports a coarse status-only message with no user/agent content in it, so it
100
+ # needs no JSON escaping and works without python3.
101
+ message() {
102
+ role="$1"
103
+ text="$2"
104
+ id="${agent}-${tab}-$(date +%s)-$$-$role"
105
+ ts="$(message_ts_ms)"
106
+ payload='{"id":"'"$id"'","role":"'"$role"'","text":"'"$text"'","subAgentsActive":0,"ts":'"$ts"'}'
107
+ "$pragma_cli" agent message --agent "$agent" --payload "$payload" >/dev/null 2>&1 || true
108
+ }
109
+
110
+ # Content-bearing messages (the user's prompt, the assistant's reply) need real
111
+ # JSON parsing/escaping that POSIX sh cannot do safely; python3 ships with macOS
112
+ # and every mainstream Linux distro, and the sibling hook bridges
113
+ # (claude-code-plugin, codex-plugin, grok-plugin) already depend on it the same
114
+ # way. When it's missing these helpers do nothing and the bridge degrades to the
115
+ # coarse status-only messages above.
116
+ #
117
+ # Windows is the exception, and being on PATH is not proof of being usable there:
118
+ # it ships an App Execution Alias at
119
+ # ~/AppData/Local/Microsoft/WindowsApps/python3 that only prints "Python was not
120
+ # found" and exits nonzero. That satisfies `command -v`, so the emptiness checks
121
+ # below would wrongly take the has-python branch. Run it once and drop it unless
122
+ # it actually executes.
123
+ py3="$(command -v python3 2>/dev/null || true)"
124
+ if [ -n "$py3" ] && ! "$py3" -c '' >/dev/null 2>&1; then
125
+ py3=""
126
+ fi
127
+
128
+ # Prints a top-level string field from the JSON document passed as $2.
129
+ json_field() {
130
+ [ -n "$py3" ] || return 0
131
+ printf '%s' "$2" | "$py3" -c '
132
+ import json, sys
133
+ try:
134
+ value = (json.load(sys.stdin) or {}).get(sys.argv[1])
135
+ except Exception:
136
+ value = None
137
+ if isinstance(value, str):
138
+ print(value)
139
+ ' "$1" 2>/dev/null
140
+ }
141
+
142
+ # Prints a tab-title-sized name from arbitrary text (first nonblank line).
143
+ # Silent no-op without python3 (the session simply stays unnamed).
144
+ session_name_from_text() {
145
+ [ -n "$py3" ] || return 0
146
+ printf '%s' "$1" | "$py3" -c '
147
+ import sys
148
+ line = ""
149
+ for candidate in sys.stdin.read().splitlines():
150
+ if candidate.strip():
151
+ line = candidate.strip()
152
+ break
153
+ print(line if len(line) <= 48 else line[:47].rstrip() + "…")
154
+ ' 2>/dev/null
155
+ }
156
+
157
+ # Prints the directory Junie keeps this tab's session log in. Junie chdirs to
158
+ # its own home before running hooks, so SessionStart's `cwd` is the most
159
+ # reliable base; JUNIE_HOME and ~/.junie cover the rest.
160
+ session_dir() {
161
+ sid="$(cat "$session_file" 2>/dev/null)"
162
+ [ -n "$sid" ] || return 0
163
+ for base in "$(cat "$home_file" 2>/dev/null)" "${JUNIE_HOME:-}" "$HOME/.junie"; do
164
+ [ -n "$base" ] || continue
165
+ if [ -d "$base/sessions/$sid" ]; then
166
+ printf '%s/sessions/%s' "$base" "$sid"
167
+ return 0
168
+ fi
169
+ done
170
+ printf '%s/sessions/%s' "${JUNIE_HOME:-$HOME/.junie}" "$sid"
171
+ }
172
+
173
+ # Prints Junie's own generated task name for the session, which it writes to the
174
+ # event log as `AgentTaskNameUpdatedEvent`. Empty until Junie has named the task.
175
+ session_title() {
176
+ events="$1"
177
+ [ -n "$py3" ] && [ -n "$events" ] && [ -f "$events" ] || return 0
178
+ "$py3" -c '
179
+ import json, sys
180
+ name = ""
181
+ try:
182
+ with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
183
+ for raw in handle:
184
+ try:
185
+ record = json.loads(raw)
186
+ except Exception:
187
+ continue
188
+ event = ((record.get("event") or {}).get("agentEvent") or {})
189
+ if event.get("kind") == "AgentTaskNameUpdatedEvent":
190
+ candidate = event.get("name")
191
+ if isinstance(candidate, str) and candidate.strip():
192
+ name = candidate.strip()
193
+ except Exception:
194
+ sys.exit(0)
195
+ print(name)
196
+ ' "$events" 2>/dev/null
197
+ }
198
+
199
+ # Names the hosting tab after Junie's generated task name, falling back to the
200
+ # first prompt until Junie has produced one. Reported only when it changes, so a
201
+ # rename lands once per title. Pragma preserves manual tab renames.
202
+ report_session_name() {
203
+ events="$1"
204
+ fallback="$2"
205
+ session_name="$(session_title "$events")"
206
+ [ -n "$session_name" ] || session_name="$(session_name_from_text "$fallback")"
207
+ [ -n "$session_name" ] || return 0
208
+ [ "$(cat "$named_file" 2>/dev/null)" = "$session_name" ] && return 0
209
+ report session-name --name "$session_name"
210
+ printf '%s' "$session_name" >"$named_file"
211
+ }
212
+
213
+ # Reports a rich message whose text is JSON-escaped (safe for arbitrary
214
+ # content). Silent no-op without python3 or when the text is empty.
215
+ content_message() {
216
+ role="$1"
217
+ text="$2"
218
+ id="${3:-${agent}-${tab}-$(date +%s)-$$-$role}"
219
+ [ -n "$py3" ] && [ -n "$text" ] || return 0
220
+ ts="$(message_ts_ms)"
221
+ payload=$("$py3" -c '
222
+ import json, sys
223
+ print(json.dumps({
224
+ "id": sys.argv[1],
225
+ "role": sys.argv[2],
226
+ "text": sys.argv[3],
227
+ "subAgentsActive": 0,
228
+ "ts": int(sys.argv[4]),
229
+ }))' "$id" "$role" "$text" "$ts" 2>/dev/null)
230
+ [ -n "$payload" ] || return 0
231
+ "$pragma_cli" agent message --agent "$agent" --payload "$payload" >/dev/null 2>&1 || true
232
+ }
233
+
234
+ # Prints a human-readable summary of a tool call from a PreToolUse or
235
+ # PermissionRequest payload.
236
+ tool_summary() {
237
+ [ -n "$py3" ] || return 0
238
+ printf '%s' "$1" | "$py3" -c '
239
+ import json, sys
240
+ try:
241
+ payload = json.load(sys.stdin) or {}
242
+ except Exception:
243
+ sys.exit(0)
244
+ name = payload.get("tool_name") or "tool"
245
+ tool_input = payload.get("tool_input")
246
+ if isinstance(tool_input, str):
247
+ try:
248
+ tool_input = json.loads(tool_input)
249
+ except Exception:
250
+ tool_input = {}
251
+ if not isinstance(tool_input, dict):
252
+ tool_input = {}
253
+ command = tool_input.get("command")
254
+ if isinstance(command, str) and command:
255
+ print(command)
256
+ sys.exit(0)
257
+ path = tool_input.get("file_path") or tool_input.get("path") or tool_input.get("filePath")
258
+ print(f"{name} {path}" if isinstance(path, str) and path else name)
259
+ ' 2>/dev/null
260
+ }
261
+
262
+
263
+ # Succeeds when the event log recorded a cancelled or failed turn after `off`
264
+ # bytes -- i.e. this turn was interrupted or died. Scoped to this turn's tail so
265
+ # an earlier turn's record can never clear a new turn.
266
+ #
267
+ # Junie 26.8.10 records an interruption in one of two shapes, depending on how
268
+ # far the turn got: a cancel before any result block exists (Escape right after
269
+ # submit) writes only a top-level `CancelAgentEvent`, while a cancel after
270
+ # output started writes `ResultBlockUpdatedEvent` with `"cancelled":true` (and
271
+ # a failed turn writes `AgentTaskFailedEvent`). The watcher must match all
272
+ # three, or an early abort looks like the turn is still running.
273
+ aborted_since() {
274
+ events="$1"
275
+ off="${2:-0}"
276
+ [ -n "$events" ] && [ -f "$events" ] || return 1
277
+ [ -n "$off" ] || off=0
278
+ tail -c "+$((off + 1))" "$events" 2>/dev/null |
279
+ grep -q '"cancelled":true\|"kind":"AgentTaskFailedEvent"\|"kind":"CancelAgentEvent"'
280
+ }
281
+
282
+ # Streams the assistant text produced so far in this turn. Junie records its
283
+ # reply as a series of `*BlockUpdatedEvent` records keyed by `stepId`, each one
284
+ # a *replacement* for the previous state of that block -- so the text is rebuilt
285
+ # by keeping the newest record per stepId, in first-seen order, rather than
286
+ # concatenating every record.
287
+ assistant_text_since() {
288
+ events="$1"
289
+ off="${2:-0}"
290
+ [ -n "$py3" ] && [ -n "$events" ] && [ -f "$events" ] || return 0
291
+ "$py3" -c '
292
+ import json, sys
293
+
294
+ KINDS = {
295
+ "MarkdownBlockUpdatedEvent": ("text", "markdown", "content"),
296
+ "MessageBlockUpdatedEvent": ("text", "message", "content"),
297
+ "TerminalBlockUpdatedEvent": ("details",),
298
+ "ResultBlockUpdatedEvent": ("result",),
299
+ }
300
+
301
+ path, offset = sys.argv[1], int(sys.argv[2])
302
+ order = []
303
+ blocks = {}
304
+ try:
305
+ with open(path, "rb") as handle:
306
+ handle.seek(offset)
307
+ for raw in handle:
308
+ try:
309
+ record = json.loads(raw.decode("utf-8", errors="replace"))
310
+ except Exception:
311
+ continue
312
+ event = ((record.get("event") or {}).get("agentEvent") or {})
313
+ fields = KINDS.get(event.get("kind"))
314
+ if fields is None:
315
+ continue
316
+ text = next(
317
+ (
318
+ event[field]
319
+ for field in fields
320
+ if isinstance(event.get(field), str) and event[field].strip()
321
+ ),
322
+ None,
323
+ )
324
+ if text is None:
325
+ continue
326
+ step_id = event.get("stepId")
327
+ key = step_id if isinstance(step_id, str) and step_id else len(order)
328
+ if key not in blocks:
329
+ order.append(key)
330
+ blocks[key] = text.strip()
331
+ except Exception:
332
+ sys.exit(0)
333
+ sys.stdout.write("\n\n".join(blocks[key] for key in order))
334
+ ' "$events" "$off" 2>/dev/null
335
+ }
336
+
337
+ # Emits the turn's assistant text under the stable id `junie-<token>-assistant`
338
+ # whenever it has grown since the last sync.
339
+ sync_messages() {
340
+ events="$1"
341
+ off="$2"
342
+ token="$3"
343
+ [ -n "$py3" ] || return 0
344
+ text="$(assistant_text_since "$events" "$off")"
345
+ [ -n "$text" ] || return 0
346
+ sent="$(cat "$sent_file" 2>/dev/null)"
347
+ [ -n "$sent" ] || sent=0
348
+ length=${#text}
349
+ [ "$length" -gt "$sent" ] || return 0
350
+ content_message assistant "$text" "${agent}-${token}-assistant"
351
+ printf '%s' "$length" >"$sent_file"
352
+ }
353
+
354
+ # Scans this turn's event log for the question(s) Junie is currently blocked on
355
+ # and writes the parsed data to the scratch files. Junie's `ask_user` and
356
+ # `ask_user_choice` tools do NOT fire `PreToolUse` -- they are recorded as
357
+ # `AskAsyncRequestUpdatedEvent` instead -- so the event log is the only place a
358
+ # hook bridge can see a question at all. Each record replaces the previous state
359
+ # of its `stepId`, so the newest record per step decides whether it is pending.
360
+ #
361
+ # When several questions are pending at once (one `ask_user_choice` call with
362
+ # multiple questions), they are reported as ONE multi-question attention so a
363
+ # remote client can answer them together: `.id` holds the first question's
364
+ # identifier (the attention's request id), `.sig` the full pending set (the
365
+ # report dedupe key), `.q` the first question's text, and `.multi` the
366
+ # `questions` JSON array for `report attention --questions`.
367
+ scan_question() {
368
+ events="$1"
369
+ off="${2:-0}"
370
+ rm -f "${question_scratch}.id" "${question_scratch}.sig" "${question_scratch}.q" "${question_scratch}.o" "${question_scratch}.multi"
371
+ [ -n "$py3" ] && [ -n "$events" ] && [ -f "$events" ] || return 0
372
+ "$py3" -c '
373
+ import json, sys
374
+
375
+ path, offset, out = sys.argv[1], int(sys.argv[2]), sys.argv[3]
376
+ order, blocks = [], {}
377
+ try:
378
+ with open(path, "rb") as handle:
379
+ handle.seek(offset)
380
+ for raw in handle:
381
+ try:
382
+ record = json.loads(raw.decode("utf-8", errors="replace"))
383
+ except Exception:
384
+ continue
385
+ event = ((record.get("event") or {}).get("agentEvent") or {})
386
+ if event.get("kind") != "AskAsyncRequestUpdatedEvent":
387
+ continue
388
+ step_id = event.get("stepId")
389
+ key = step_id if isinstance(step_id, str) and step_id else len(order)
390
+ if key not in blocks:
391
+ order.append(key)
392
+ blocks[key] = event
393
+ except Exception:
394
+ sys.exit(0)
395
+ pending = [blocks[key] for key in order if blocks[key].get("status") == "IN_PROGRESS"]
396
+ if not pending:
397
+ sys.exit(0)
398
+ entries, ids = [], []
399
+ for event in pending:
400
+ request = event.get("request") or {}
401
+ question = request.get("question") or event.get("title")
402
+ if not isinstance(question, str) or not question.strip():
403
+ continue
404
+ entry = {"question": question.strip()}
405
+ options = []
406
+ for option in request.get("options") or []:
407
+ if not isinstance(option, dict):
408
+ continue
409
+ label = option.get("title") or option.get("id")
410
+ if not isinstance(label, str) or not label.strip():
411
+ continue
412
+ choice = {"label": label.strip()}
413
+ description = option.get("description")
414
+ if isinstance(description, str) and description.strip():
415
+ choice["description"] = description.strip()
416
+ options.append(choice)
417
+ if options:
418
+ entry["options"] = options
419
+ identifier = request.get("id") or event.get("stepId") or question
420
+ entries.append(entry)
421
+ ids.append(str(identifier))
422
+ if not entries:
423
+ sys.exit(0)
424
+ first = entries[0]
425
+ with open(out + ".id", "w", encoding="utf-8") as handle:
426
+ handle.write(ids[0])
427
+ with open(out + ".sig", "w", encoding="utf-8") as handle:
428
+ handle.write(",".join(ids))
429
+ with open(out + ".q", "w", encoding="utf-8") as handle:
430
+ handle.write(first["question"])
431
+ if len(entries) > 1:
432
+ with open(out + ".multi", "w", encoding="utf-8") as handle:
433
+ handle.write(json.dumps(entries))
434
+ elif "options" in first:
435
+ with open(out + ".o", "w", encoding="utf-8") as handle:
436
+ handle.write(json.dumps(first["options"]))
437
+ ' "$events" "$off" "$question_scratch" 2>/dev/null
438
+ }
439
+
440
+ # Raises a `question` attention the first time Junie blocks on one (or on a
441
+ # batch of them), and drops the tab back to `started` once that question is
442
+ # resolved. The reply itself is typed into the TUI by the plugin's watcher:
443
+ # Junie exposes no hook that can answer.
444
+ sync_questions() {
445
+ events="$1"
446
+ off="$2"
447
+ scan_question "$events" "$off"
448
+ pending_id="$(cat "${question_scratch}.id" 2>/dev/null)"
449
+ reported_id="$(cat "$question_file" 2>/dev/null)"
450
+ if [ -n "$pending_id" ]; then
451
+ # Dedupe on the whole pending set, so a batch that gains or loses a
452
+ # question re-reports instead of being hidden by an unchanged first id.
453
+ pending_sig="$(cat "${question_scratch}.sig" 2>/dev/null)"
454
+ [ -n "$pending_sig" ] || pending_sig="$pending_id"
455
+ [ "$pending_sig" = "$reported_id" ] && return 0
456
+ qtext="$(cat "${question_scratch}.q" 2>/dev/null)"
457
+ [ -n "$qtext" ] || return 0
458
+ qopts="$(cat "${question_scratch}.o" 2>/dev/null)"
459
+ multi="$(cat "${question_scratch}.multi" 2>/dev/null)"
460
+ request_id="${agent}-${tab}-${pending_id}"
461
+ if [ -n "$multi" ]; then
462
+ # Older pragma-cli/server builds reject the `questions` wire field (the
463
+ # report is dropped, which would hide the question entirely); fall back
464
+ # to the first pending question as a plain single-question attention.
465
+ if ! "$pragma_cli" agent report --agent "$agent" attention --kind question \
466
+ --questions "$multi" --request-id "$request_id" >/dev/null 2>&1; then
467
+ report attention --kind question --question "$qtext" \
468
+ --options "${qopts:-[]}" --request-id "$request_id"
469
+ fi
470
+ elif [ -n "$qopts" ]; then
471
+ report attention --kind question --question "$qtext" --options "$qopts" \
472
+ --request-id "$request_id"
473
+ else
474
+ report attention --kind question --question "$qtext" --request-id "$request_id"
475
+ fi
476
+ content_message system "$qtext"
477
+ printf '%s' "$pending_sig" >"$question_file"
478
+ return 0
479
+ fi
480
+ if [ -n "$reported_id" ]; then
481
+ rm -f "$question_file"
482
+ [ -f "$marker" ] && report started
483
+ fi
484
+ }
485
+
486
+ # Stops the current watcher (if any) and forgets its pid. Best-effort: the
487
+ # watcher also self-exits once the marker is gone, so a missed kill is harmless.
488
+ stop_watcher() {
489
+ if [ -f "$pidfile" ]; then
490
+ pid=$(cat "$pidfile" 2>/dev/null)
491
+ [ -n "$pid" ] && kill "$pid" 2>/dev/null
492
+ rm -f "$pidfile"
493
+ fi
494
+ }
495
+
496
+ # Background watcher loop. Streams assistant output and polls for the cancel
497
+ # that no hook reports. `token` pins it to the turn that spawned it: if the
498
+ # marker is removed (normal end / session end) or rewritten (a new turn
499
+ # started), the watcher exits without touching state, so it can never clobber a
500
+ # later turn.
501
+ run_watcher() {
502
+ events="$1"
503
+ token="$2"
504
+ offset="${3:-0}"
505
+ deadline=$(($(date +%s) + max_lifetime))
506
+ while :; do
507
+ # Turn ended or was superseded -> nothing to clear, exit quietly.
508
+ [ -f "$marker" ] || exit 0
509
+ [ "$(cat "$marker" 2>/dev/null)" = "$token" ] || exit 0
510
+ sync_messages "$events" "$offset" "$token"
511
+ sync_questions "$events" "$offset"
512
+ if aborted_since "$events" "$offset"; then
513
+ # Re-check the token right before acting so we never clear a turn that
514
+ # started in the gap between the poll and now.
515
+ [ "$(cat "$marker" 2>/dev/null)" = "$token" ] || exit 0
516
+ rm -f "$marker" "$turn_session_file" "$sent_file" "$offset_file" "$question_file" "$question_scratch".*
517
+ report cleared
518
+ exit 0
519
+ fi
520
+ [ "$(date +%s)" -lt "$deadline" ] || exit 0
521
+ sleep "$interval"
522
+ done
523
+ }
524
+
525
+ # The watcher re-enters this same script as a detached child via `__watch`.
526
+ if [ "${1:-}" = "__watch" ]; then
527
+ run_watcher "$2" "$3" "${4:-0}"
528
+ exit 0
529
+ fi
530
+
531
+ input="$(cat)"
532
+ hook_event_name="$(json_field hook_event_name "$input")"
533
+ hook_session_id="$(json_field session_id "$input")"
534
+
535
+ # Only SessionStart and UserPromptSubmit carry `session_id` (and SessionStart
536
+ # alone carries Junie's home directory as `cwd`); cache both so the events that
537
+ # omit them can still resolve this tab's session log.
538
+ case "$hook_event_name" in
539
+ SessionStart | UserPromptSubmit)
540
+ if [ -n "$hook_session_id" ]; then
541
+ printf '%s' "$hook_session_id" >"$session_file"
542
+ fi
543
+ ;;
544
+ esac
545
+ if [ "$hook_event_name" = "SessionStart" ]; then
546
+ junie_home="$(json_field cwd "$input")"
547
+ if [ -n "$junie_home" ] && [ -d "$junie_home/sessions" ]; then
548
+ printf '%s' "$junie_home" >"$home_file"
549
+ fi
550
+ fi
551
+ [ -n "$hook_session_id" ] || hook_session_id="$(cat "$session_file" 2>/dev/null)"
552
+
553
+ case "${1:-}" in
554
+ started)
555
+ # A new turn is in flight. Tag the marker with a unique token, report
556
+ # running, then replace any prior watcher with a fresh one bound to this
557
+ # turn's event log and byte offset.
558
+ token="$$-$(date +%s)"
559
+ printf '%s' "$token" >"$marker"
560
+ printf '%s' "$hook_session_id" >"$turn_session_file"
561
+ rm -f "$sent_file" "$offset_file" "$question_file" "$question_scratch".*
562
+ report started
563
+ prompt="$(json_field prompt "$input")"
564
+ if [ -n "$prompt" ]; then
565
+ content_message user "$prompt"
566
+ else
567
+ message assistant "Junie turn started"
568
+ fi
569
+ events="$(session_dir)/events.jsonl"
570
+ report_session_name "$events" "$prompt"
571
+ stop_watcher
572
+ # Pin the watcher to where the event log stands *now* so a prior turn's
573
+ # records can't be mistaken for this turn's output or cancel.
574
+ offset=0
575
+ if [ -f "$events" ]; then
576
+ offset=$(wc -c <"$events" 2>/dev/null | tr -d '[:space:]')
577
+ [ -n "$offset" ] || offset=0
578
+ fi
579
+ printf '%s' "$offset" >"$offset_file"
580
+ nohup sh "$0" __watch "$events" "$token" "$offset" >/dev/null 2>&1 &
581
+ echo "$!" >"$pidfile"
582
+ ;;
583
+ stopped)
584
+ # Junie fires Stop only when a turn really completed (a cancel fires
585
+ # nothing), so a bare Stop with no in-flight marker must not create a
586
+ # phantom done dot.
587
+ if [ ! -f "$marker" ]; then
588
+ exit 0
589
+ fi
590
+ token="$(cat "$marker" 2>/dev/null)"
591
+ stop_watcher
592
+ events="$(session_dir)/events.jsonl"
593
+ offset="$(cat "$offset_file" 2>/dev/null)"
594
+ [ -n "$offset" ] || offset=0
595
+ # Final reply before `stopped`, so the reply event always precedes the done
596
+ # report for chat consumers. `last_assistant_message` is Junie's own summary
597
+ # of the finished turn and is preferred; the streamed event-log text is the
598
+ # fallback for builds that omit it.
599
+ reply="$(json_field last_assistant_message "$input")"
600
+ [ -n "$reply" ] || reply="$(assistant_text_since "$events" "$offset")"
601
+ [ -z "$reply" ] || content_message assistant "$reply" "${agent}-${token}-assistant"
602
+ rm -f "$marker" "$turn_session_file" "$sent_file" "$offset_file" "$question_file" "$question_scratch".*
603
+ report stopped
604
+ report_session_name "$events" ""
605
+ ;;
606
+ failed)
607
+ # StopFailure: the turn ended on an LLM/API error, so it never completed.
608
+ # Clear rather than report done.
609
+ if [ -f "$marker" ]; then
610
+ stop_watcher
611
+ rm -f "$marker" "$turn_session_file" "$sent_file" "$offset_file" "$question_file" "$question_scratch".*
612
+ report cleared
613
+ detail="$(json_field error "$input")"
614
+ [ -n "$detail" ] || detail="unknown error"
615
+ message system "Junie turn failed ($detail)"
616
+ fi
617
+ ;;
618
+ cleared)
619
+ # SessionStart fires for a brand new session (startup, `/clear`, resume,
620
+ # compaction) while the previous session's SessionEnd and the new session's
621
+ # first prompt can race. Skip the clear when the in-flight turn already
622
+ # belongs to this same session.
623
+ if [ "$hook_event_name" = "SessionStart" ] && [ -f "$marker" ] &&
624
+ [ -n "$hook_session_id" ] &&
625
+ [ "$(cat "$turn_session_file" 2>/dev/null)" = "$hook_session_id" ]; then
626
+ exit 0
627
+ fi
628
+ stop_watcher
629
+ rm -f "$marker" "$turn_session_file" "$sent_file" "$offset_file" "$question_file" "$question_scratch".*
630
+ if [ "$hook_event_name" = "SessionEnd" ]; then
631
+ rm -f "$session_file" "$named_file" "$home_file"
632
+ fi
633
+ report cleared
634
+ ;;
635
+ pre-tool)
636
+ # PreToolUse fires before *every* tool call and is Junie's only mid-turn
637
+ # signal (it has no PostToolUse hook), so it doubles as the "still running"
638
+ # heartbeat that drops a resolved `attention` back to "in progress".
639
+ if [ ! -f "$marker" ]; then
640
+ exit 0
641
+ fi
642
+ tool_name="$(json_field tool_name "$input")"
643
+ case "$tool_name" in
644
+ ask_user | ask_user_choice)
645
+ # Junie's question tools are dispatched through its own async-request
646
+ # channel, not the tool pipeline: verified on 26.8.3, asking a question
647
+ # fires no `PreToolUse` at all and only writes an
648
+ # `AskAsyncRequestUpdatedEvent` to the event log, which the per-turn
649
+ # watcher picks up (see sync_questions). This arm exists so that a build
650
+ # which *does* route them here cannot re-assert `started` over a live
651
+ # question attention.
652
+ :
653
+ ;;
654
+ submit)
655
+ # The `submit` tool is Junie's turn terminator and is immediately
656
+ # followed by Stop; reporting anything for it only races that Stop.
657
+ :
658
+ ;;
659
+ *)
660
+ report started
661
+ summary="$(tool_summary "$input")"
662
+ [ -n "$summary" ] || summary="Junie tool call"
663
+ message tool "$summary"
664
+ ;;
665
+ esac
666
+ ;;
667
+ permission)
668
+ # PermissionRequest: Junie is asking to run a sensitive action and is BLOCKED
669
+ # on this hook's stdout. Report a `command` attention carrying the command
670
+ # text and a unique requestId, then block on `await-decision` for the verdict
671
+ # a Pragma approval toast publishes.
672
+ #
673
+ # The timeout branch matters here in a way it does not for Claude Code: for
674
+ # Junie, a hook that exits 0 without a decision *auto-approves* the action.
675
+ # Emitting nothing would therefore silently bypass the user's own approval,
676
+ # so an unanswered request returns `ask`, which is Junie's "show your native
677
+ # prompt" verdict.
678
+ if [ -f "$marker" ]; then
679
+ command_text="$(tool_summary "$input")"
680
+ [ -n "$command_text" ] || command_text="$(json_field tool_name "$input")"
681
+ request_id="${agent}-${tab}-$(date +%s)-$$"
682
+ report attention --kind command --command "$command_text" --request-id "$request_id"
683
+ message system "Junie needs approval"
684
+ verdict="$("$pragma_cli" agent await-decision \
685
+ --agent "$agent" --request-id "$request_id" --timeout "$approval_timeout" 2>/dev/null)"
686
+ # Either verdict resumes the turn at once. An allowed action fires a
687
+ # PreToolUse when it runs, but a denied one never does -- without this
688
+ # re-assert the tab would stay stuck on the command attention until Stop.
689
+ # Guarded on the marker so a turn the abort watcher cleared meanwhile
690
+ # stays cleared.
691
+ case "$verdict" in
692
+ allow)
693
+ [ -f "$marker" ] && report started
694
+ printf '%s\n' '{"decision":"allow","reason":"Approved in Pragma"}'
695
+ ;;
696
+ deny)
697
+ [ -f "$marker" ] && report started
698
+ printf '%s\n' '{"decision":"deny","reason":"Denied in Pragma"}'
699
+ ;;
700
+ *)
701
+ # Timed out / no decision: hand the request back to Junie's own prompt
702
+ # rather than letting silence auto-approve it.
703
+ printf '%s\n' '{"decision":"ask","reason":"No Pragma decision"}'
704
+ ;;
705
+ esac
706
+ else
707
+ # No in-flight turn is tracked (e.g. Junie was started outside Pragma, or
708
+ # the marker was lost), yet silence still means approve. Defer to Junie's
709
+ # own prompt.
710
+ printf '%s\n' '{"decision":"ask","reason":"No Pragma turn is tracked"}'
711
+ fi
712
+ ;;
713
+ esac
714
+
715
+ exit 0