@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.
- package/.junie-plugin/plugin.json +8 -0
- package/assets/junie.svg +17 -0
- package/dist/install.mjs +61 -0
- package/dist/pragma-plugin.mjs +700 -0
- package/hooks/hooks.json +81 -0
- package/hooks/report.sh +715 -0
- package/package.json +42 -0
- package/pragma-plugin.json +14 -0
- package/scripts/install-local.ts +127 -0
- package/src/acp.ts +223 -0
- package/src/pragma-plugin.test.ts +82 -0
- package/src/pragma-plugin.ts +193 -0
- package/src/usage-limits.test.ts +95 -0
- package/src/usage-limits.ts +225 -0
package/hooks/report.sh
ADDED
|
@@ -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
|