agent-relay-runner 0.62.1 → 0.62.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.62.1",
3
+ "version": "0.62.3",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "directory": "runner"
21
21
  },
22
22
  "dependencies": {
23
- "agent-relay-sdk": "0.2.39"
23
+ "agent-relay-sdk": "0.2.40"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.62.1",
4
+ "version": "0.62.3",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -12,6 +12,16 @@
12
12
  }
13
13
  ],
14
14
  "PreToolUse": [
15
+ {
16
+ "matcher": "Bash",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/read-only-bash-guard.sh\"",
21
+ "timeout": 5
22
+ }
23
+ ]
24
+ },
15
25
  {
16
26
  "matcher": "AskUserQuestion",
17
27
  "hooks": [
@@ -0,0 +1,689 @@
1
+ #!/usr/bin/env bash
2
+ # BEST-EFFORT GUARDRAIL / defense-in-depth only. This hook denies common
3
+ # mutating Bash patterns for read-only Claude sessions, but it is not a sandbox.
4
+ # Real boundaries are Relay token scope, unavailable write tools, and future
5
+ # OS-level enforcement.
6
+ set -euo pipefail
7
+ source "${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/hooks/relay-status.sh"
8
+ relay_install_hook_guard read-only-bash-guard
9
+
10
+ payload="$(cat)"
11
+
12
+ emit_deny() {
13
+ local reason="${1:-Read-only Claude sessions may only run inspection commands.}"
14
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' \
15
+ "$(relay_json_escape "$reason")"
16
+ }
17
+
18
+ case "${AGENT_RELAY_APPROVAL:-}" in
19
+ open|guarded)
20
+ exit 0
21
+ ;;
22
+ read-only)
23
+ ;;
24
+ *)
25
+ # Unknown approval values fail closed so typos such as "readonly" or
26
+ # casing drift do not silently disable the read-only boundary.
27
+ ;;
28
+ esac
29
+
30
+ if ! command -v jq >/dev/null 2>&1; then
31
+ emit_deny "Read-only Bash guard could not inspect the command because jq is unavailable."
32
+ exit 0
33
+ fi
34
+
35
+ tool_name="$(printf '%s' "$payload" | jq -r '.tool_name // empty' 2>/dev/null || true)"
36
+ if [ "${tool_name,,}" != "bash" ]; then
37
+ exit 0
38
+ fi
39
+
40
+ command_text="$(printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
41
+ if [ -z "$command_text" ]; then
42
+ emit_deny "Read-only Bash guard denied an empty Bash command."
43
+ exit 0
44
+ fi
45
+
46
+ shopt -s extglob
47
+
48
+ DENY_REASON=""
49
+ SEGMENTS=()
50
+ WORDS=()
51
+
52
+ deny_segment() {
53
+ DENY_REASON="$1"
54
+ return 1
55
+ }
56
+
57
+ trim_shell_text() {
58
+ local value="$1"
59
+ value="${value##+([[:space:]])}"
60
+ value="${value%%+([[:space:]])}"
61
+ printf '%s' "$value"
62
+ }
63
+
64
+ hard_deny_reason() {
65
+ local text="$1"
66
+ case "$text" in
67
+ *'$('*|*'`'*)
68
+ printf '%s' "Read-only Bash guard denied command substitution."
69
+ return 0
70
+ ;;
71
+ *'<('*|*'>('*)
72
+ printf '%s' "Read-only Bash guard denied process substitution."
73
+ return 0
74
+ ;;
75
+ esac
76
+
77
+ local i len ch next quote="" escaped=0
78
+ len=${#text}
79
+ for ((i = 0; i < len; i++)); do
80
+ ch="${text:i:1}"
81
+ if (( escaped )); then
82
+ escaped=0
83
+ continue
84
+ fi
85
+ if [ -n "$quote" ]; then
86
+ if [ "$quote" = '"' ] && [ "$ch" = "\\" ]; then
87
+ escaped=1
88
+ continue
89
+ fi
90
+ if [ "$ch" = "$quote" ]; then
91
+ quote=""
92
+ fi
93
+ continue
94
+ fi
95
+
96
+ case "$ch" in
97
+ "'"|'"')
98
+ quote="$ch"
99
+ ;;
100
+ "\\")
101
+ escaped=1
102
+ ;;
103
+ ">")
104
+ printf '%s' "Read-only Bash guard denied shell output redirection."
105
+ return 0
106
+ ;;
107
+ "<")
108
+ next="${text:i+1:1}"
109
+ if [ "$next" = ">" ]; then
110
+ printf '%s' "Read-only Bash guard denied shell output redirection."
111
+ return 0
112
+ fi
113
+ ;;
114
+ esac
115
+ done
116
+
117
+ return 1
118
+ }
119
+
120
+ split_segments() {
121
+ local text="$1"
122
+ local current="" i len ch next quote="" escaped=0
123
+ SEGMENTS=()
124
+ len=${#text}
125
+ for ((i = 0; i < len; i++)); do
126
+ ch="${text:i:1}"
127
+ if (( escaped )); then
128
+ current+="$ch"
129
+ escaped=0
130
+ continue
131
+ fi
132
+ if [ -n "$quote" ]; then
133
+ current+="$ch"
134
+ if [ "$quote" = '"' ] && [ "$ch" = "\\" ]; then
135
+ escaped=1
136
+ continue
137
+ fi
138
+ if [ "$ch" = "$quote" ]; then
139
+ quote=""
140
+ fi
141
+ continue
142
+ fi
143
+
144
+ case "$ch" in
145
+ "'"|'"')
146
+ quote="$ch"
147
+ current+="$ch"
148
+ ;;
149
+ "\\")
150
+ escaped=1
151
+ current+="$ch"
152
+ ;;
153
+ $'\n'|";")
154
+ SEGMENTS+=("$current")
155
+ current=""
156
+ ;;
157
+ "&")
158
+ next="${text:i+1:1}"
159
+ if [ "$next" = "&" ]; then
160
+ ((i += 1))
161
+ fi
162
+ SEGMENTS+=("$current")
163
+ current=""
164
+ ;;
165
+ "|")
166
+ next="${text:i+1:1}"
167
+ if [ "$next" = "|" ]; then
168
+ ((i += 1))
169
+ fi
170
+ SEGMENTS+=("$current")
171
+ current=""
172
+ ;;
173
+ *)
174
+ current+="$ch"
175
+ ;;
176
+ esac
177
+ done
178
+
179
+ if [ -n "$quote" ] || (( escaped )); then
180
+ return 1
181
+ fi
182
+ SEGMENTS+=("$current")
183
+ return 0
184
+ }
185
+
186
+ shell_words() {
187
+ local text="$1"
188
+ local word="" i len ch quote="" escaped=0 in_word=0
189
+ WORDS=()
190
+ len=${#text}
191
+ for ((i = 0; i < len; i++)); do
192
+ ch="${text:i:1}"
193
+ if (( escaped )); then
194
+ word+="$ch"
195
+ in_word=1
196
+ escaped=0
197
+ continue
198
+ fi
199
+ if [ -n "$quote" ]; then
200
+ if [ "$quote" = '"' ] && [ "$ch" = "\\" ]; then
201
+ escaped=1
202
+ continue
203
+ fi
204
+ if [ "$ch" = "$quote" ]; then
205
+ quote=""
206
+ else
207
+ word+="$ch"
208
+ fi
209
+ in_word=1
210
+ continue
211
+ fi
212
+
213
+ case "$ch" in
214
+ [[:space:]])
215
+ if (( in_word )); then
216
+ WORDS+=("$word")
217
+ word=""
218
+ in_word=0
219
+ fi
220
+ ;;
221
+ "'"|'"')
222
+ quote="$ch"
223
+ in_word=1
224
+ ;;
225
+ "\\")
226
+ escaped=1
227
+ in_word=1
228
+ ;;
229
+ *)
230
+ word+="$ch"
231
+ in_word=1
232
+ ;;
233
+ esac
234
+ done
235
+
236
+ if [ -n "$quote" ] || (( escaped )); then
237
+ return 1
238
+ fi
239
+ if (( in_word )); then
240
+ WORDS+=("$word")
241
+ fi
242
+ return 0
243
+ }
244
+
245
+ validate_git_branch() {
246
+ local i="$1" arg
247
+ while (( i < ${#WORDS[@]} )); do
248
+ arg="${WORDS[i]}"
249
+ case "$arg" in
250
+ --list|--all|--remotes|--show-current|-a|-r|-v|-vv)
251
+ ;;
252
+ *)
253
+ deny_segment "Read-only Bash guard denied a git branch mutation or unsupported branch option."
254
+ return 1
255
+ ;;
256
+ esac
257
+ ((i += 1))
258
+ done
259
+ return 0
260
+ }
261
+
262
+ validate_git_read_args() {
263
+ local i="$1" arg
264
+ while (( i < ${#WORDS[@]} )); do
265
+ arg="${WORDS[i]}"
266
+ if [ "$arg" = "--" ]; then
267
+ return 0
268
+ fi
269
+ case "$arg" in
270
+ --output|--output=*)
271
+ deny_segment "Read-only Bash guard denied git output-to-file options."
272
+ return 1
273
+ ;;
274
+ esac
275
+ ((i += 1))
276
+ done
277
+ return 0
278
+ }
279
+
280
+ validate_git() {
281
+ local i="$1" arg sub
282
+ while (( i < ${#WORDS[@]} )); do
283
+ arg="${WORDS[i]}"
284
+ case "$arg" in
285
+ -C)
286
+ ((i += 1))
287
+ if (( i >= ${#WORDS[@]} )); then
288
+ deny_segment "Read-only Bash guard denied git -C without a path."
289
+ return 1
290
+ fi
291
+ ((i += 1))
292
+ ;;
293
+ --no-pager)
294
+ ((i += 1))
295
+ ;;
296
+ -c|--config-env|--config-env=*)
297
+ deny_segment "Read-only Bash guard denied git configuration or alias injection."
298
+ return 1
299
+ ;;
300
+ --)
301
+ ((i += 1))
302
+ break
303
+ ;;
304
+ -*)
305
+ deny_segment "Read-only Bash guard denied an unsupported git global option."
306
+ return 1
307
+ ;;
308
+ *)
309
+ break
310
+ ;;
311
+ esac
312
+ done
313
+
314
+ if (( i >= ${#WORDS[@]} )); then
315
+ deny_segment "Read-only Bash guard denied git without an explicit read-only subcommand."
316
+ return 1
317
+ fi
318
+
319
+ sub="${WORDS[i],,}"
320
+ ((i += 1))
321
+ case "$sub" in
322
+ diff|log|status|show|blame|grep|rev-parse|describe)
323
+ validate_git_read_args "$i"
324
+ return $?
325
+ ;;
326
+ branch)
327
+ validate_git_branch "$i"
328
+ return $?
329
+ ;;
330
+ *)
331
+ deny_segment "Read-only Bash guard denied a git subcommand that is not in the read-only allow-list."
332
+ return 1
333
+ ;;
334
+ esac
335
+ }
336
+
337
+ find_arg_takes_operand() {
338
+ local arg="$1"
339
+ case "$arg" in
340
+ -name|-iname|-path|-ipath|-wholename|-iwholename|-regex|-iregex|-type|-xtype|-user|-group|-newer|-samefile|-size|-perm|-mtime|-mmin|-atime|-amin|-ctime|-cmin|-maxdepth|-mindepth|-printf|-printf0)
341
+ return 0
342
+ ;;
343
+ -newer??)
344
+ return 0
345
+ ;;
346
+ *)
347
+ return 1
348
+ ;;
349
+ esac
350
+ }
351
+
352
+ validate_find() {
353
+ local i="$1" arg skip_next=0
354
+ while (( i < ${#WORDS[@]} )); do
355
+ arg="${WORDS[i]}"
356
+ if (( skip_next )); then
357
+ skip_next=0
358
+ ((i += 1))
359
+ continue
360
+ fi
361
+ case "$arg" in
362
+ -exec|-execdir|-ok|-okdir|-delete|-fls|-fprint|-fprintf|-fprint0)
363
+ deny_segment "Read-only Bash guard denied find mutating action predicates."
364
+ return 1
365
+ ;;
366
+ esac
367
+ if find_arg_takes_operand "$arg"; then
368
+ skip_next=1
369
+ fi
370
+ ((i += 1))
371
+ done
372
+ return 0
373
+ }
374
+
375
+ validate_fd() {
376
+ local i="$1" arg
377
+ while (( i < ${#WORDS[@]} )); do
378
+ arg="${WORDS[i]}"
379
+ if [ "$arg" = "--" ]; then
380
+ return 0
381
+ fi
382
+ case "$arg" in
383
+ -x|-X|--exec|--exec-batch)
384
+ deny_segment "Read-only Bash guard denied fd exec action options."
385
+ return 1
386
+ ;;
387
+ esac
388
+ ((i += 1))
389
+ done
390
+ return 0
391
+ }
392
+
393
+ validate_rg() {
394
+ local i="$1" arg
395
+ while (( i < ${#WORDS[@]} )); do
396
+ arg="${WORDS[i]}"
397
+ if [ "$arg" = "--" ]; then
398
+ return 0
399
+ fi
400
+ case "$arg" in
401
+ --pre|--pre=*)
402
+ deny_segment "Read-only Bash guard denied rg preprocessor execution options."
403
+ return 1
404
+ ;;
405
+ esac
406
+ ((i += 1))
407
+ done
408
+ return 0
409
+ }
410
+
411
+ validate_sort() {
412
+ local i="$1" arg
413
+ while (( i < ${#WORDS[@]} )); do
414
+ arg="${WORDS[i]}"
415
+ if [ "$arg" = "--" ]; then
416
+ return 0
417
+ fi
418
+ case "$arg" in
419
+ -o|-o?*|--output|--output=*)
420
+ deny_segment "Read-only Bash guard denied sort output-to-file options."
421
+ return 1
422
+ ;;
423
+ esac
424
+ ((i += 1))
425
+ done
426
+ return 0
427
+ }
428
+
429
+ uniq_arg_takes_operand() {
430
+ local arg="$1"
431
+ case "$arg" in
432
+ -f|-s|-w|--skip-fields|--skip-chars|--check-chars)
433
+ return 0
434
+ ;;
435
+ *)
436
+ return 1
437
+ ;;
438
+ esac
439
+ }
440
+
441
+ validate_uniq() {
442
+ local i="$1" arg operands=0 after_options=0 skip_next=0
443
+ while (( i < ${#WORDS[@]} )); do
444
+ arg="${WORDS[i]}"
445
+ if (( skip_next )); then
446
+ skip_next=0
447
+ ((i += 1))
448
+ continue
449
+ fi
450
+ if (( ! after_options )) && [ "$arg" = "--" ]; then
451
+ after_options=1
452
+ ((i += 1))
453
+ continue
454
+ fi
455
+ if (( ! after_options )) && [ "$arg" != "-" ] && [[ "$arg" == -* ]]; then
456
+ if uniq_arg_takes_operand "$arg"; then
457
+ skip_next=1
458
+ fi
459
+ ((i += 1))
460
+ continue
461
+ fi
462
+
463
+ ((operands += 1))
464
+ if (( operands > 1 )); then
465
+ deny_segment "Read-only Bash guard denied uniq output-file operands."
466
+ return 1
467
+ fi
468
+ ((i += 1))
469
+ done
470
+ return 0
471
+ }
472
+
473
+ validate_yq() {
474
+ local i="$1" arg
475
+ while (( i < ${#WORDS[@]} )); do
476
+ arg="${WORDS[i]}"
477
+ if [ "$arg" = "--" ]; then
478
+ return 0
479
+ fi
480
+ case "$arg" in
481
+ -i|-i?*|--inplace|--inplace=*)
482
+ deny_segment "Read-only Bash guard denied yq in-place edit options."
483
+ return 1
484
+ ;;
485
+ esac
486
+ ((i += 1))
487
+ done
488
+ return 0
489
+ }
490
+
491
+ validate_bun() {
492
+ local i="$1" sub next
493
+ if (( i >= ${#WORDS[@]} )); then
494
+ deny_segment "Read-only Bash guard denied bun without an explicit test/typecheck command."
495
+ return 1
496
+ fi
497
+ sub="${WORDS[i],,}"
498
+ case "$sub" in
499
+ test)
500
+ return 0
501
+ ;;
502
+ run)
503
+ next="${WORDS[i+1]:-}"
504
+ if [ "$next" = "typecheck" ]; then
505
+ return 0
506
+ fi
507
+ deny_segment "Read-only Bash guard only allows bun run typecheck in read-only sessions."
508
+ return 1
509
+ ;;
510
+ *)
511
+ deny_segment "Read-only Bash guard denied a bun command outside the test/typecheck allow-list."
512
+ return 1
513
+ ;;
514
+ esac
515
+ }
516
+
517
+ validate_agent_relay() {
518
+ local i="$1" sub next
519
+ if (( i >= ${#WORDS[@]} )); then
520
+ deny_segment "Read-only Bash guard denied agent-relay without an allowed read/message subcommand."
521
+ return 1
522
+ fi
523
+ sub="${WORDS[i]}"
524
+ next="${WORDS[i+1]:-}"
525
+ case "$sub" in
526
+ /reply|/react|/message|/status|/read-message|/tags|/whoami|/guide)
527
+ return 0
528
+ ;;
529
+ get-message|status|whoami|guide|tags)
530
+ return 0
531
+ ;;
532
+ workspace)
533
+ if [ "$next" = "status" ]; then
534
+ return 0
535
+ fi
536
+ deny_segment "Read-only Bash guard denied a mutating agent-relay workspace command."
537
+ return 1
538
+ ;;
539
+ *)
540
+ deny_segment "Read-only Bash guard denied an agent-relay command outside the read/message allow-list."
541
+ return 1
542
+ ;;
543
+ esac
544
+ }
545
+
546
+ validate_leading_assignment() {
547
+ local assignment="$1" name value
548
+ name="${assignment%%=*}"
549
+ value="${assignment#*=}"
550
+
551
+ case "$name" in
552
+ LANG|LANGUAGE|LC_*)
553
+ if [[ "$value" =~ ^[A-Za-z0-9_.:@+-]+$ ]]; then
554
+ return 0
555
+ fi
556
+ ;;
557
+ TZ)
558
+ if [[ "$value" =~ ^[A-Za-z0-9_+:-]+(/[A-Za-z0-9_+:-]+)*$ ]]; then
559
+ return 0
560
+ fi
561
+ ;;
562
+ PAGER|GIT_PAGER)
563
+ if [ "$value" = "cat" ]; then
564
+ return 0
565
+ fi
566
+ ;;
567
+ esac
568
+
569
+ deny_segment "Read-only Bash guard denied unsafe leading environment assignment."
570
+ return 1
571
+ }
572
+
573
+ validate_segment() {
574
+ local raw="$1" segment i=0 saw_assignment=0 command_name base
575
+ segment="$(trim_shell_text "$raw")"
576
+ if [ -z "$segment" ]; then
577
+ return 0
578
+ fi
579
+ if ! shell_words "$segment"; then
580
+ deny_segment "Read-only Bash guard denied shell syntax it could not parse."
581
+ return 1
582
+ fi
583
+ if (( ${#WORDS[@]} == 0 )); then
584
+ return 0
585
+ fi
586
+
587
+ while (( i < ${#WORDS[@]} )) && [[ "${WORDS[i]}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; do
588
+ if ! validate_leading_assignment "${WORDS[i]}"; then
589
+ return 1
590
+ fi
591
+ saw_assignment=1
592
+ ((i += 1))
593
+ done
594
+
595
+ if (( i >= ${#WORDS[@]} )); then
596
+ deny_segment "Read-only Bash guard denied an assignment-only shell segment."
597
+ return 1
598
+ fi
599
+
600
+ command_name="${WORDS[i]}"
601
+ if [[ "$command_name" == \$* ]]; then
602
+ deny_segment "Read-only Bash guard denied an indirect command name."
603
+ return 1
604
+ fi
605
+
606
+ base="${command_name##*/}"
607
+ base="${base,,}"
608
+ if [ -z "$base" ]; then
609
+ deny_segment "Read-only Bash guard denied an empty command name."
610
+ return 1
611
+ fi
612
+
613
+ case "$base" in
614
+ bash|sh|zsh|dash|env|python*|node|ruby|perl|php|awk|eval|exec|source|.)
615
+ deny_segment "Read-only Bash guard denied a nested shell, interpreter, or shell re-entry command."
616
+ return 1
617
+ ;;
618
+ esac
619
+
620
+ if (( saw_assignment )) && [[ "$command_name" == *'$'* ]]; then
621
+ deny_segment "Read-only Bash guard denied assignment-based command indirection."
622
+ return 1
623
+ fi
624
+
625
+ case "$base" in
626
+ git)
627
+ validate_git "$((i + 1))"
628
+ return $?
629
+ ;;
630
+ find)
631
+ validate_find "$((i + 1))"
632
+ return $?
633
+ ;;
634
+ fd)
635
+ validate_fd "$((i + 1))"
636
+ return $?
637
+ ;;
638
+ rg)
639
+ validate_rg "$((i + 1))"
640
+ return $?
641
+ ;;
642
+ sort)
643
+ validate_sort "$((i + 1))"
644
+ return $?
645
+ ;;
646
+ uniq)
647
+ validate_uniq "$((i + 1))"
648
+ return $?
649
+ ;;
650
+ yq)
651
+ validate_yq "$((i + 1))"
652
+ return $?
653
+ ;;
654
+ grep|cat|ls|head|tail|wc|pwd|jq|nl|cut)
655
+ return 0
656
+ ;;
657
+ bun)
658
+ validate_bun "$((i + 1))"
659
+ return $?
660
+ ;;
661
+ agent-relay)
662
+ validate_agent_relay "$((i + 1))"
663
+ return $?
664
+ ;;
665
+ *)
666
+ deny_segment "Read-only Bash guard denied a command outside the read-only allow-list."
667
+ return 1
668
+ ;;
669
+ esac
670
+ }
671
+
672
+ if reason="$(hard_deny_reason "$command_text")"; then
673
+ emit_deny "$reason"
674
+ exit 0
675
+ fi
676
+
677
+ if ! split_segments "$command_text"; then
678
+ emit_deny "Read-only Bash guard denied shell syntax it could not parse."
679
+ exit 0
680
+ fi
681
+
682
+ for segment in "${SEGMENTS[@]}"; do
683
+ if ! validate_segment "$segment"; then
684
+ emit_deny "${DENY_REASON:-Read-only Claude sessions may only run explicit inspection commands.}"
685
+ exit 0
686
+ fi
687
+ done
688
+
689
+ exit 0
@@ -126,6 +126,50 @@ export function extractLastAssistantTurn(jsonl: string): string {
126
126
  return collected.join("\n\n").trim();
127
127
  }
128
128
 
129
+ /**
130
+ * Like extractLastAssistantTurn but skips the first `afterEntry` valid JSON entries.
131
+ * Used when a partial transcript flush already emitted entries 1..afterEntry so the
132
+ * Stop-hook full-mode capture doesn't re-emit the already-published text.
133
+ */
134
+ export function extractLastAssistantTurnAfterEntry(jsonl: string, afterEntry: number): string {
135
+ const lines = jsonl.split("\n");
136
+ const collected: string[] = [];
137
+ let entryIndex = 0;
138
+ for (const line of lines) {
139
+ const trimmed = line.trim();
140
+ if (!trimmed) continue;
141
+ let entry: TranscriptEntry;
142
+ try {
143
+ entry = JSON.parse(trimmed) as TranscriptEntry;
144
+ } catch {
145
+ continue;
146
+ }
147
+ entryIndex++;
148
+ if (isSidechainEntry(entry)) continue;
149
+ if (isRealUserPrompt(entry)) {
150
+ collected.length = 0;
151
+ continue;
152
+ }
153
+ if (entryIndex <= afterEntry) continue;
154
+ const text = assistantText(entry);
155
+ if (text) collected.push(text);
156
+ }
157
+ return collected.join("\n\n").trim();
158
+ }
159
+
160
+ /**
161
+ * Count valid JSON entries in a JSONL string. Used to record the high-water-mark
162
+ * after a pre-flush so the Stop hook knows where to resume in full-capture mode.
163
+ */
164
+ export function countTranscriptEntries(jsonl: string): number {
165
+ let count = 0;
166
+ for (const line of jsonl.split("\n")) {
167
+ if (!line.trim()) continue;
168
+ try { JSON.parse(line); count++; } catch {}
169
+ }
170
+ return count;
171
+ }
172
+
129
173
  /**
130
174
  * Returns only the text from the LAST assistant entry in the current turn.
131
175
  * Unlike extractLastAssistantTurn which collects all intermediate text between
@@ -450,25 +450,21 @@ export function sessionStatusLineSettingsArgs(...argLists: string[][]): string[]
450
450
  }
451
451
 
452
452
  export function claudeLaunchArgs(defaultArgs: string[], providerArgs: string[], approvalMode?: string): string[] {
453
+ const stripToolArgs = approvalMode === "read-only";
453
454
  return [
454
- ...stripClaudePermissionArgs(defaultArgs),
455
- ...stripClaudePermissionArgs(providerArgs),
455
+ ...stripClaudePermissionArgs(defaultArgs, stripToolArgs),
456
+ ...stripClaudePermissionArgs(providerArgs, stripToolArgs),
456
457
  ...claudePermissionModeArgs(approvalMode),
457
458
  ];
458
459
  }
459
460
 
460
461
  export function claudePermissionModeArgs(approvalMode?: string): string[] {
461
462
  if (approvalMode === "open") return ["--dangerously-skip-permissions"];
462
- if (approvalMode === "read-only") return [
463
- "--permission-mode",
464
- "dontAsk",
465
- "--allowedTools",
466
- "Read,Grep,Glob,LS,Bash(agent-relay *)",
467
- ];
463
+ if (approvalMode === "read-only") return ["--permission-mode", "dontAsk", "--tools", "Read,Grep,Glob,LS,Bash"];
468
464
  return ["--permission-mode", "default"];
469
465
  }
470
466
 
471
- function stripClaudePermissionArgs(args: string[]): string[] {
467
+ function stripClaudePermissionArgs(args: string[], stripToolArgs = false): string[] {
472
468
  const result: string[] = [];
473
469
  for (let i = 0; i < args.length; i++) {
474
470
  const arg = args[i];
@@ -484,6 +480,10 @@ function stripClaudePermissionArgs(args: string[]): string[] {
484
480
  i++;
485
481
  continue;
486
482
  }
483
+ if (stripToolArgs && /^--(?:tools|allowedTools|allowed-tools|disallowedTools|disallowed-tools)(?:=|$)/.test(arg)) {
484
+ if (!arg.includes("=")) i++;
485
+ continue;
486
+ }
487
487
  result.push(arg);
488
488
  }
489
489
  return result;
@@ -36,7 +36,9 @@ interface ControlServerOptions {
36
36
  // Phase 1 live-session lane: a provider Stop hook hands over its transcript
37
37
  // path so the runner can capture the assistant turn and surface it in the
38
38
  // dashboard chat without the agent re-emitting it via /reply.
39
- onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown }): Promise<void>;
39
+ // isPreFlush: true signals a mid-turn call from a PreToolUse hook — flush any
40
+ // un-mirrored narrative before the interactive form appears in chat (#435).
41
+ onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; isPreFlush?: boolean }): Promise<void>;
40
42
  // A provider UserPromptSubmit hook hands over the prompt the human typed
41
43
  // directly into the session (web terminal / TUI) so the runner can mirror it
42
44
  // into the dashboard chat and start tailing the turn transcript for reasoning.
@@ -221,6 +223,14 @@ async function handlePermissionRequest(
221
223
  ): Promise<Response> {
222
224
  const body = await req.json().catch(() => null);
223
225
  if (!isRecord(body)) return Response.json({});
226
+ // #435: flush any un-mirrored narrative from the transcript before the form
227
+ // appears in chat — the transcript already has the assistant text block at
228
+ // PreToolUse time, but the Stop hook (which normally captures it) won't fire
229
+ // until after the user answers, so the form would otherwise arrive first.
230
+ const transcriptPath = typeof body.transcript_path === "string" ? body.transcript_path : "";
231
+ if (transcriptPath && options.onSessionTurn) {
232
+ await options.onSessionTurn({ transcriptPath, isPreFlush: true }).catch(() => {});
233
+ }
224
234
  const approvalId = crypto.randomUUID();
225
235
  const view = claudePermissionApprovalView(approvalId, body);
226
236
  options.onStatus({
package/src/runner.ts CHANGED
@@ -13,7 +13,7 @@ import { ClaimTracker } from "./claim-tracker";
13
13
  import { startControlServer, type ControlServer } from "./control-server";
14
14
  import { ReplyObligationCache, obligationRequiresExplicitReply } from "./reply-obligation-cache";
15
15
  import { Outbox, type OutboxRecord } from "./outbox";
16
- import { extractLastAssistantTurn, extractFinalAssistantMessage, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
16
+ import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, countTranscriptEntries, extractFinalAssistantMessage, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
17
17
  import { computeContextRatio } from "./session-insights";
18
18
  import { agentProfileProjectionReport } from "./profile-projection";
19
19
  import { profileUsesHostProviderGlobals } from "./profile-home";
@@ -254,6 +254,10 @@ export class AgentRunner {
254
254
  // its final response. Set when a provider-turn starts, cleared when it ends.
255
255
  private currentTurnId?: string;
256
256
  private currentTurnStartedAt?: number;
257
+ // #435: high-water-mark for the pre-turn narrative flush. Counts how many
258
+ // valid JSONL entries were in the transcript at last pre-flush time so the
259
+ // Stop-hook capture (full mode) can skip what was already emitted.
260
+ private narrativeFlushEntryCount = 0;
257
261
  // True while a turn that was already in flight is being compacted. Claude's PostCompact
258
262
  // hook posts a single `idle`, but a mid-turn compaction RESUMES the turn afterward — so
259
263
  // treating that idle as a turn end (flip idle, kill the reasoning tail) makes chat go dark
@@ -1283,8 +1287,33 @@ export class AgentRunner {
1283
1287
  // relay message existing, so turns started from the web terminal (which create
1284
1288
  // no relay message) are mirrored too. A reply obligation, when present, is still
1285
1289
  // used as replyTo so the Stop hook stops nagging the agent to /reply.
1286
- private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown }): Promise<void> {
1290
+ //
1291
+ // isPreFlush: true (#435) — mid-turn call from a PreToolUse hook (AskUserQuestion /
1292
+ // ExitPlanMode / permission). Emits any un-mirrored narrative from the transcript
1293
+ // tail immediately, before the interactive form appears in chat, then updates the
1294
+ // entry-count cursor so the eventual Stop-hook call (full mode) skips it.
1295
+ private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; isPreFlush?: boolean }): Promise<void> {
1287
1296
  if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1297
+
1298
+ if (input.isPreFlush) {
1299
+ if (!input.transcriptPath) return;
1300
+ let jsonl: string | undefined;
1301
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { return; }
1302
+ if (!jsonl) return;
1303
+ const body = extractLastAssistantTurnAfterEntry(jsonl, this.narrativeFlushEntryCount);
1304
+ this.narrativeFlushEntryCount = countTranscriptEntries(jsonl);
1305
+ if (!body) return;
1306
+ const turnId = this.currentTurnId;
1307
+ this.sessionLog(`pre-flush narrative for turn ${turnId ?? "?"} (${body.length} chars)`);
1308
+ await this.publishSessionEvent({
1309
+ from: this.agentId,
1310
+ to: "user",
1311
+ body,
1312
+ session: { type: "response", origin: "provider", ...(turnId ? { turnId } : {}) },
1313
+ });
1314
+ return;
1315
+ }
1316
+
1288
1317
  const turnId = this.currentTurnId;
1289
1318
  this.stopReasoningTail();
1290
1319
  // Optional correlation for threading + obligation clearing — never a capture gate.
@@ -1300,6 +1329,12 @@ export class AgentRunner {
1300
1329
  replyToMessageId = obligation?.messageId;
1301
1330
  }
1302
1331
 
1332
+ // Grab and reset the pre-flush cursor before extraction: the cursor may be
1333
+ // non-zero when a PreToolUse hook flushed narrative earlier this turn (#435).
1334
+ // In "full" mode, skip those already-emitted entries so they don't duplicate.
1335
+ const flushCount = this.narrativeFlushEntryCount;
1336
+ this.narrativeFlushEntryCount = 0;
1337
+
1303
1338
  // The Stop hook can fire before the final assistant entry is flushed to disk.
1304
1339
  // Claude writes thinking and text as separate entries (both with end_turn), so
1305
1340
  // the transcript can "look complete" while the text entry is still pending.
@@ -1314,7 +1349,11 @@ export class AgentRunner {
1314
1349
  try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { break; }
1315
1350
  }
1316
1351
  if (!transcriptLooksComplete(jsonl)) continue;
1317
- const extract = this.options.providerConfig.chatCaptureMode === "full" ? extractLastAssistantTurn : extractFinalAssistantMessage;
1352
+ // Full mode: if a pre-flush already emitted entries 1..flushCount, only
1353
+ // collect text from entries after that mark to avoid double-emit (#435).
1354
+ const extract = this.options.providerConfig.chatCaptureMode === "full"
1355
+ ? (j: string) => (flushCount > 0 ? extractLastAssistantTurnAfterEntry(j, flushCount) : extractLastAssistantTurn(j))
1356
+ : extractFinalAssistantMessage;
1318
1357
  body = extract(jsonl);
1319
1358
  if (body) break;
1320
1359
  }
@@ -2466,6 +2505,7 @@ function runtimeProviderCapabilities(options: RunnerOptions, contextState?: Cont
2466
2505
  const model = options.model ?? probeModel?.model;
2467
2506
  const effort = options.effort ?? probeModel?.effort;
2468
2507
  const modelSource = options.model ? "runtime" as const : probeModel?.model ? "provider" as const : "runtime" as const;
2508
+ const shellMode = runtimeShellMode(options);
2469
2509
  return {
2470
2510
  lifecycle: {
2471
2511
  managed: true,
@@ -2487,7 +2527,8 @@ function runtimeProviderCapabilities(options: RunnerOptions, contextState?: Cont
2487
2527
  approvalMode: options.approvalMode,
2488
2528
  fileRead: true,
2489
2529
  fileWrite: options.approvalMode !== "read-only",
2490
- shell: options.approvalMode !== "read-only",
2530
+ shell: shellMode !== "none",
2531
+ shellMode,
2491
2532
  source: "runtime",
2492
2533
  confidence: "reported",
2493
2534
  lastUpdatedAt: options.startedAt,
@@ -2511,6 +2552,13 @@ function runtimeProviderCapabilities(options: RunnerOptions, contextState?: Cont
2511
2552
  };
2512
2553
  }
2513
2554
 
2555
+ function runtimeShellMode(options: RunnerOptions): NonNullable<NonNullable<ProviderCapabilities["session"]>["shellMode"]> {
2556
+ if (options.approvalMode === "read-only") {
2557
+ return options.provider === "claude" ? "read-only-guarded" : "none";
2558
+ }
2559
+ return options.approvalMode === "open" ? "unrestricted" : "guarded";
2560
+ }
2561
+
2514
2562
  function runtimeProviderTerminalCapabilities(options: RunnerOptions): Pick<ProviderCapabilities, "terminal"> {
2515
2563
  if (options.provider === "claude" && options.headless) {
2516
2564
  return {