pi-pr-review 1.7.1 → 1.8.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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.7.1"
2
+ ".": "1.8.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.8.0](https://github.com/10ego/pi-pr-review/compare/v1.7.1...v1.8.0) (2026-07-14)
4
+
5
+
6
+ ### Features
7
+
8
+ * add live subagent focus viewer ([#17](https://github.com/10ego/pi-pr-review/issues/17)) ([44f1020](https://github.com/10ego/pi-pr-review/commit/44f102067e974ca2a723611c6882019bac6dbb9c))
9
+
3
10
  ## [1.7.1](https://github.com/10ego/pi-pr-review/compare/v1.7.0...v1.7.1) (2026-07-14)
4
11
 
5
12
 
package/README.md CHANGED
@@ -69,6 +69,31 @@ A review uses five focused passes by default:
69
69
 
70
70
  `--full` adds a convention and maintainability pass (`medium`). Large multi-file diffs are split by whole-file boundaries and reviewed in parallel. If the extension is unavailable, the prompt falls back to the current Pi session model.
71
71
 
72
+ ## Focus running reviewers
73
+
74
+ While a review is running in the interactive TUI, open the live read-only subagent view with:
75
+
76
+ ```text
77
+ /pr-review-focus
78
+ ```
79
+
80
+ `Ctrl+Alt+R` opens the same view without entering a command. The viewer keeps the parent review running and does not switch Pi sessions or attach an interactive terminal to a child process.
81
+
82
+ Viewer controls:
83
+
84
+ | Key | Action |
85
+ |---|---|
86
+ | `Tab` / `Right` | Focus the next pass. |
87
+ | `Shift+Tab` / `Left` | Focus the previous pass. |
88
+ | `Up` / `Down` | Scroll one line. |
89
+ | `PageUp` / `PageDown` | Scroll one page. |
90
+ | `Home` / `End` | Jump to the start or resume following live output. |
91
+ | `Esc` | Return to the main thread without cancelling the review. |
92
+
93
+ The view shows pass status, attempt/model, tool names and completion state, and bounded assistant output. It never stores the pass objective, input context, captured diff, raw child events, tool arguments, tool results, or stderr. Assistant text is sanitized and capped at 48 KiB per pass and 256 KiB across the active review; older text is evicted with an on-screen marker. State exists only in memory for the active session/cwd-bound `/pr-review` generation and is synchronously purged on completion, cancellation, replacement, or session/tree lifecycle changes.
94
+
95
+ The viewer intentionally cannot send prompts, steering, or follow-ups to reviewers. It is unavailable in print, JSON, and RPC modes and outside an active user-initiated `/pr-review` loop.
96
+
72
97
  ## Configure models
73
98
 
74
99
  `/pr-review-config` opens an interactive settings menu in the TUI. Use `/pr-review-config show` for a text summary or `key=value` arguments for direct changes.
@@ -1,11 +1,13 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { ReviewLoopCoordinator } from "../lib/pr-review-loop.ts";
3
3
  import registerPrReviewSubagents from "./pr-review-subagent.ts";
4
+ import registerReviewFocus from "./pr-review-focus.ts";
4
5
  import registerReviewTable from "./review-table.ts";
5
6
 
6
7
  /** Register the package behind one shared, session-scoped review-loop authority. */
7
8
  export default function registerPrReview(pi: ExtensionAPI) {
8
9
  const loopCoordinator = new ReviewLoopCoordinator(pi);
9
10
  registerPrReviewSubagents(pi, loopCoordinator);
11
+ registerReviewFocus(pi, loopCoordinator);
10
12
  registerReviewTable(pi, loopCoordinator);
11
13
  }
@@ -0,0 +1,265 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ Theme,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ type Component,
8
+ matchesKey,
9
+ truncateToWidth,
10
+ type TUI,
11
+ wrapTextWithAnsi,
12
+ } from "@earendil-works/pi-tui";
13
+ import type {
14
+ ReviewFocusPassSnapshot,
15
+ ReviewFocusSnapshot,
16
+ } from "../lib/pr-review-focus.ts";
17
+ import { ReviewLoopCoordinator } from "../lib/pr-review-loop.ts";
18
+
19
+ const SHORTCUT = "ctrl+alt+r" as const;
20
+
21
+ function statusIcon(status: ReviewFocusPassSnapshot["status"]): string {
22
+ switch (status) {
23
+ case "queued": return "○";
24
+ case "running": return "◉";
25
+ case "retrying": return "↻";
26
+ case "completed": return "✓";
27
+ case "failed": return "✗";
28
+ case "aborted": return "■";
29
+ }
30
+ }
31
+
32
+ function statusColor(status: ReviewFocusPassSnapshot["status"]): "muted" | "warning" | "success" | "error" {
33
+ switch (status) {
34
+ case "queued": return "muted";
35
+ case "running":
36
+ case "retrying": return "warning";
37
+ case "completed": return "success";
38
+ case "failed":
39
+ case "aborted": return "error";
40
+ }
41
+ }
42
+
43
+ export class ReviewFocusView implements Component {
44
+ private snapshot: ReviewFocusSnapshot;
45
+ private selectedIndex = 0;
46
+ private scrollTop = 0;
47
+ private followTail = true;
48
+ private unsubscribe?: () => void;
49
+ private closed = false;
50
+
51
+ constructor(
52
+ private readonly tui: Pick<TUI, "requestRender" | "terminal">,
53
+ private readonly theme: Theme,
54
+ initial: ReviewFocusSnapshot,
55
+ private readonly done: () => void,
56
+ ) {
57
+ this.snapshot = initial;
58
+ }
59
+
60
+ setUnsubscribe(unsubscribe: (() => void) | undefined): void {
61
+ this.unsubscribe = unsubscribe;
62
+ }
63
+
64
+ update(snapshot: ReviewFocusSnapshot | undefined): void {
65
+ if (!snapshot) {
66
+ this.close();
67
+ return;
68
+ }
69
+ const selectedKey = this.snapshot.passes[this.selectedIndex]?.key;
70
+ this.snapshot = snapshot;
71
+ if (selectedKey) {
72
+ const nextIndex = snapshot.passes.findIndex((pass) => pass.key === selectedKey);
73
+ this.selectedIndex = nextIndex >= 0 ? nextIndex : Math.min(this.selectedIndex, Math.max(0, snapshot.passes.length - 1));
74
+ } else {
75
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, snapshot.passes.length - 1));
76
+ }
77
+ this.tui.requestRender();
78
+ }
79
+
80
+ handleInput(data: string): void {
81
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
82
+ this.close();
83
+ return;
84
+ }
85
+ if (matchesKey(data, "tab") || matchesKey(data, "right")) {
86
+ this.selectRelative(1);
87
+ return;
88
+ }
89
+ if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
90
+ this.selectRelative(-1);
91
+ return;
92
+ }
93
+ if (matchesKey(data, "up")) {
94
+ this.followTail = false;
95
+ this.scrollTop = Math.max(0, this.scrollTop - 1);
96
+ this.tui.requestRender();
97
+ return;
98
+ }
99
+ if (matchesKey(data, "down")) {
100
+ this.followTail = false;
101
+ this.scrollTop++;
102
+ this.tui.requestRender();
103
+ return;
104
+ }
105
+ if (matchesKey(data, "pageUp")) {
106
+ this.followTail = false;
107
+ this.scrollTop = Math.max(0, this.scrollTop - this.pageSize());
108
+ this.tui.requestRender();
109
+ return;
110
+ }
111
+ if (matchesKey(data, "pageDown")) {
112
+ this.followTail = false;
113
+ this.scrollTop += this.pageSize();
114
+ this.tui.requestRender();
115
+ return;
116
+ }
117
+ if (matchesKey(data, "home")) {
118
+ this.followTail = false;
119
+ this.scrollTop = 0;
120
+ this.tui.requestRender();
121
+ return;
122
+ }
123
+ if (matchesKey(data, "end")) {
124
+ this.followTail = true;
125
+ this.tui.requestRender();
126
+ }
127
+ }
128
+
129
+ render(width: number): string[] {
130
+ const safeWidth = Math.max(20, width);
131
+ const height = Math.max(8, (this.tui.terminal.rows ?? 24) - 2);
132
+ const passes = this.snapshot.passes;
133
+ const pass = passes[this.selectedIndex];
134
+ const lines: string[] = [];
135
+ lines.push(truncateToWidth(this.theme.fg("accent", this.theme.bold("PR Review Focus")), safeWidth, "…", true));
136
+
137
+ if (!pass) {
138
+ lines.push(truncateToWidth(this.theme.fg("muted", "No reviewer passes have started yet."), safeWidth, "…", true));
139
+ lines.push("");
140
+ while (lines.length < height - 1) lines.push("");
141
+ lines.push(truncateToWidth(this.theme.fg("dim", "Esc return to main thread"), safeWidth, "…", true));
142
+ return lines;
143
+ }
144
+
145
+ const index = `${this.selectedIndex + 1}/${passes.length}`;
146
+ const state = `${statusIcon(pass.status)} ${pass.status}`;
147
+ lines.push(truncateToWidth(
148
+ `${this.theme.fg("accent", `[${index}] ${pass.label}`)} ${this.theme.fg("muted", `(${pass.tier})`)} ${this.theme.fg(statusColor(pass.status), state)}`,
149
+ safeWidth,
150
+ "…",
151
+ true,
152
+ ));
153
+ const attempt = pass.attempt > 0 ? `attempt ${pass.attempt}` : "waiting to start";
154
+ const model = pass.model ? ` · ${pass.model}` : "";
155
+ lines.push(truncateToWidth(this.theme.fg("dim", `${attempt}${model}`), safeWidth, "…", true));
156
+ lines.push(this.theme.fg("dim", "─".repeat(Math.max(1, safeWidth))));
157
+
158
+ const body: string[] = [];
159
+ if (pass.tools.length > 0) {
160
+ body.push(this.theme.fg("muted", "Tool activity"));
161
+ for (const tool of pass.tools) {
162
+ const icon = tool.status === "running" ? "…" : tool.status === "completed" ? "✓" : "✗";
163
+ const color = tool.status === "running" ? "warning" : tool.status === "completed" ? "success" : "error";
164
+ body.push(` ${this.theme.fg(color, icon)} ${this.theme.fg("toolOutput", tool.name)}`);
165
+ }
166
+ body.push("");
167
+ }
168
+ body.push(this.theme.fg("muted", "Assistant output"));
169
+ if (pass.evictedBytes > 0) {
170
+ body.push(this.theme.fg("warning", `… ${pass.evictedBytes} earlier UTF-8 bytes evicted by viewer limits …`));
171
+ }
172
+ const output = pass.assistantText || (pass.status === "queued" ? "(queued)" : "(waiting for assistant output…)");
173
+ for (const outputLine of wrapTextWithAnsi(output, Math.max(1, safeWidth - 2))) {
174
+ body.push(` ${this.theme.fg("toolOutput", outputLine)}`);
175
+ }
176
+ if (this.snapshot.droppedPasses > 0) {
177
+ body.push("");
178
+ body.push(this.theme.fg("warning", `${this.snapshot.droppedPasses} older pass record(s) evicted`));
179
+ }
180
+
181
+ const footerRows = 2;
182
+ const viewportRows = Math.max(1, height - lines.length - footerRows);
183
+ const maxScroll = Math.max(0, body.length - viewportRows);
184
+ if (this.followTail) this.scrollTop = maxScroll;
185
+ else this.scrollTop = Math.min(this.scrollTop, maxScroll);
186
+ const visible = body.slice(this.scrollTop, this.scrollTop + viewportRows);
187
+ for (const line of visible) lines.push(truncateToWidth(line, safeWidth, "…", true));
188
+ while (lines.length < height - footerRows) lines.push("");
189
+ const scroll = maxScroll > 0 ? ` · lines ${this.scrollTop + 1}-${Math.min(body.length, this.scrollTop + viewportRows)}/${body.length}` : "";
190
+ lines.push(truncateToWidth(this.theme.fg("dim", `Tab/←/→ pass · ↑/↓/PgUp/PgDn scroll${scroll}`), safeWidth, "…", true));
191
+ lines.push(truncateToWidth(this.theme.fg("dim", "Esc return to main thread · viewer is read-only"), safeWidth, "…", true));
192
+ return lines;
193
+ }
194
+
195
+ invalidate(): void {
196
+ this.tui.requestRender();
197
+ }
198
+
199
+ dispose(): void {
200
+ this.unsubscribe?.();
201
+ this.unsubscribe = undefined;
202
+ }
203
+
204
+ private pageSize(): number {
205
+ return Math.max(1, (this.tui.terminal.rows ?? 24) - 10);
206
+ }
207
+
208
+ private selectRelative(delta: number): void {
209
+ const count = this.snapshot.passes.length;
210
+ if (count === 0) return;
211
+ this.selectedIndex = (this.selectedIndex + delta + count) % count;
212
+ this.scrollTop = 0;
213
+ this.followTail = true;
214
+ this.tui.requestRender();
215
+ }
216
+
217
+ private close(): void {
218
+ if (this.closed) return;
219
+ this.closed = true;
220
+ this.dispose();
221
+ this.done();
222
+ }
223
+ }
224
+
225
+ export default function registerReviewFocus(
226
+ pi: ExtensionAPI,
227
+ loopCoordinator: ReviewLoopCoordinator,
228
+ ): void {
229
+ const open = async (ctx: ExtensionContext): Promise<void> => {
230
+ const mode = (ctx as ExtensionContext & { mode?: string }).mode;
231
+ if (!ctx.hasUI || (mode !== undefined && mode !== "tui") || typeof ctx.ui.custom !== "function") {
232
+ ctx.ui.notify("/pr-review-focus is available only in the interactive TUI", "warning");
233
+ return;
234
+ }
235
+ const initial = loopCoordinator.focusSnapshot(ctx);
236
+ if (!initial) {
237
+ ctx.ui.notify("No active user-initiated /pr-review loop to focus", "warning");
238
+ return;
239
+ }
240
+
241
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
242
+ const view = new ReviewFocusView(tui, theme, initial, done);
243
+ const unsubscribe = loopCoordinator.subscribeFocus(ctx, (snapshot) => view.update(snapshot));
244
+ view.setUnsubscribe(unsubscribe);
245
+ if (!unsubscribe) queueMicrotask(() => view.update(undefined));
246
+ return view;
247
+ });
248
+ };
249
+
250
+ pi.registerCommand("pr-review-focus", {
251
+ description: "Focus the live read-only output of running /pr-review subagents",
252
+ handler: async (args, ctx) => {
253
+ if ((args ?? "").trim()) {
254
+ ctx.ui.notify("Usage: /pr-review-focus", "warning");
255
+ return;
256
+ }
257
+ await open(ctx);
258
+ },
259
+ });
260
+
261
+ pi.registerShortcut(SHORTCUT, {
262
+ description: "Focus running PR review subagents",
263
+ handler: open,
264
+ });
265
+ }
@@ -54,7 +54,12 @@ import {
54
54
  combineAbortSignals,
55
55
  ReviewLoopCoordinator,
56
56
  reviewLoopDeniedResult,
57
+ type ReviewFocusPublisher,
57
58
  } from "../lib/pr-review-loop.ts";
59
+ import {
60
+ normalizeReviewFocusJsonEvent,
61
+ ReviewJsonLineDecoder,
62
+ } from "../lib/pr-review-focus.ts";
58
63
  import {
59
64
  appendToolPolicyArgs,
60
65
  buildReviewBaseArgs,
@@ -450,12 +455,12 @@ function runReviewSubprocess(
450
455
  input: string,
451
456
  signal: AbortSignal | undefined,
452
457
  onText: (text: string) => void,
458
+ onEvent?: (event: unknown) => void,
453
459
  ): Promise<RunResult> {
454
460
  return new Promise<RunResult>((resolve) => {
455
461
  const messages: Message[] = [];
456
462
  const result: RunResult = { text: "", exitCode: 0, stderr: "", toolElapsedMs: 0 };
457
463
  const startedAt = monotonicNow();
458
- let buffer = "";
459
464
  let aborted = false;
460
465
  let closed = false;
461
466
  let killTimer: ReturnType<typeof setTimeout> | undefined;
@@ -481,13 +486,13 @@ function runReviewSubprocess(
481
486
  signal?.removeEventListener("abort", kill);
482
487
  };
483
488
 
484
- const processLine = (line: string) => {
485
- if (!line.trim()) return;
486
- let event: { type?: string; message?: Message };
489
+ const processEvent = (raw: unknown) => {
490
+ if (!raw || typeof raw !== "object") return;
491
+ const event = raw as { type?: string; message?: Message };
487
492
  try {
488
- event = JSON.parse(line);
493
+ onEvent?.(event);
489
494
  } catch {
490
- return;
495
+ // Focus observers are non-authoritative and must never change review results.
491
496
  }
492
497
  const now = monotonicNow();
493
498
  result.firstEventMs ??= now - startedAt;
@@ -512,12 +517,8 @@ function runReviewSubprocess(
512
517
  }
513
518
  };
514
519
 
515
- proc.stdout.on("data", (data) => {
516
- buffer += data.toString();
517
- const lines = buffer.split("\n");
518
- buffer = lines.pop() ?? "";
519
- for (const line of lines) processLine(line);
520
- });
520
+ const decoder = new ReviewJsonLineDecoder(processEvent);
521
+ proc.stdout.on("data", (data) => decoder.push(data.toString()));
521
522
  proc.stderr.on("data", (data) => {
522
523
  result.stderr += data.toString();
523
524
  });
@@ -529,7 +530,7 @@ function runReviewSubprocess(
529
530
  proc.on("close", (code) => {
530
531
  closed = true;
531
532
  cleanupAbort();
532
- if (buffer.trim()) processLine(buffer);
533
+ decoder.end();
533
534
  if (activeTools > 0) {
534
535
  result.toolElapsedMs += monotonicNow() - activeToolsStartedAt;
535
536
  activeTools = 0;
@@ -563,6 +564,7 @@ interface SubagentPassRequest {
563
564
  toolPolicy?: ToolPolicy;
564
565
  majorOnly?: boolean;
565
566
  minorHygiene?: boolean;
567
+ focusPublisher?: ReviewFocusPublisher;
566
568
  }
567
569
 
568
570
  interface ModelAttemptReport {
@@ -642,6 +644,7 @@ async function runSubagentAttempt(
642
644
  ctx: Pick<ExtensionContext, "cwd">,
643
645
  pass: SubagentPassRequest,
644
646
  attempt: ModelAttempt,
647
+ attemptOrdinal: number,
645
648
  toolPolicy: ToolPolicy,
646
649
  signal: AbortSignal | undefined,
647
650
  onText?: (text: string) => void,
@@ -669,10 +672,21 @@ async function runSubagentAttempt(
669
672
  if (beforeSpawn && !beforeSpawn()) {
670
673
  throw new Error("The active /pr-review loop ended before the reviewer could start.");
671
674
  }
675
+ pass.focusPublisher?.publish({ type: "attempt_started", attempt: attemptOrdinal, model: attempt.spec });
672
676
  const invocation = getPiInvocation(args);
673
- const result = await runReviewSubprocess(invocation.command, invocation.args, ctx.cwd, task, signal, (text) => {
674
- onText?.(text);
675
- });
677
+ const result = await runReviewSubprocess(
678
+ invocation.command,
679
+ invocation.args,
680
+ ctx.cwd,
681
+ task,
682
+ signal,
683
+ (text) => onText?.(text),
684
+ (event) => {
685
+ for (const normalized of normalizeReviewFocusJsonEvent(event)) {
686
+ pass.focusPublisher?.publish(normalized);
687
+ }
688
+ },
689
+ );
676
690
  return { result, notice: noticeForAttempt(pass.tier, attempt), elapsedMs: monotonicNow() - startedAt };
677
691
  } catch (e) {
678
692
  return {
@@ -707,12 +721,14 @@ async function runSubagentPass(
707
721
  let lastResult: RunResult | undefined;
708
722
  let lastNotice = noticeForAttempt(tier, attempts[0]!);
709
723
 
710
- for (const attempt of attempts) {
724
+ for (let attemptIndex = 0; attemptIndex < attempts.length; attemptIndex++) {
725
+ const attempt = attempts[attemptIndex]!;
711
726
  const { result, notice, elapsedMs } = await runSubagentAttempt(
712
727
  config,
713
728
  ctx,
714
729
  pass,
715
730
  attempt,
731
+ attemptIndex + 1,
716
732
  toolPolicy,
717
733
  signal,
718
734
  onText,
@@ -739,6 +755,7 @@ async function runSubagentPass(
739
755
  });
740
756
 
741
757
  if (!failed) {
758
+ pass.focusPublisher?.publish({ type: "completed" });
742
759
  return {
743
760
  id: pass.id ?? tier,
744
761
  tier,
@@ -760,9 +777,11 @@ async function runSubagentPass(
760
777
  }
761
778
 
762
779
  if (!retryable || signal?.aborted) break;
780
+ pass.focusPublisher?.publish({ type: "retrying" });
763
781
  }
764
782
 
765
783
  const final = lastResult ?? { text: "", exitCode: 1, stderr: "", errorMessage: "No model attempts were available.", toolElapsedMs: 0 };
784
+ pass.focusPublisher?.publish({ type: signal?.aborted || final.stopReason === "aborted" ? "aborted" : "failed" });
766
785
  return {
767
786
  id: pass.id ?? tier,
768
787
  tier,
@@ -1066,7 +1085,7 @@ export default function registerPrReviewSubagents(
1066
1085
  ],
1067
1086
  parameters: ReviewSubagentParams,
1068
1087
 
1069
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
1088
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
1070
1089
  const lease = loopCoordinator.acquire(ctx);
1071
1090
  if (!lease) return reviewLoopDeniedResult("review_subagent");
1072
1091
  const executionSignal = combineAbortSignals(signal, lease.signal);
@@ -1082,6 +1101,11 @@ export default function registerPrReviewSubagents(
1082
1101
  };
1083
1102
  }
1084
1103
  if (!loopCoordinator.isLeaseActive(lease, ctx)) return reviewLoopDeniedResult("review_subagent");
1104
+ const focusPublisher = loopCoordinator.createFocusPublisher(lease, ctx, {
1105
+ key: `${toolCallId}:single`,
1106
+ label: `${tier} review`,
1107
+ tier,
1108
+ });
1085
1109
  const config = loadConfig(ctx);
1086
1110
  const result = await runSubagentPass(
1087
1111
  config,
@@ -1093,6 +1117,7 @@ export default function registerPrReviewSubagents(
1093
1117
  toolPolicy: normalizeToolPolicy(params.tool_policy),
1094
1118
  majorOnly: params.major_only === true,
1095
1119
  minorHygiene: params.minor_hygiene === true,
1120
+ focusPublisher,
1096
1121
  },
1097
1122
  executionSignal,
1098
1123
  (text) => onUpdate?.({ content: [{ type: "text", text }] }),
@@ -1155,7 +1180,7 @@ export default function registerPrReviewSubagents(
1155
1180
  ],
1156
1181
  parameters: ReviewSubagentsParams,
1157
1182
 
1158
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
1183
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
1159
1184
  const lease = loopCoordinator.acquire(ctx);
1160
1185
  if (!lease) return reviewLoopDeniedResult("review_subagents");
1161
1186
  const executionSignal = combineAbortSignals(signal, lease.signal);
@@ -1214,7 +1239,7 @@ export default function registerPrReviewSubagents(
1214
1239
  : loadedContext.context;
1215
1240
  const majorOnly = params.major_only === true;
1216
1241
  const minorHygiene = params.minor_hygiene === true;
1217
- const passes: SubagentPassRequest[] = rawPasses.flatMap((pass, index) => {
1242
+ const passesWithoutFocus: SubagentPassRequest[] = rawPasses.flatMap((pass, index) => {
1218
1243
  const tier = pass.tier as Tier;
1219
1244
  const baseId = typeof pass.id === "string" && pass.id.trim() ? pass.id.trim() : `${index + 1}-${tier}`;
1220
1245
  const baseContext = combineContexts(sharedContext, loadedPassContexts[index]!.context);
@@ -1238,8 +1263,16 @@ export default function registerPrReviewSubagents(
1238
1263
  ? requestedShards.map((shard, shardIndex) => makePass(shard, shardIndex))
1239
1264
  : [makePass(undefined, 0)];
1240
1265
  });
1241
- const maxParallel = normalizeMaxParallel(params.max_parallel, passes.length);
1242
1266
  if (!loopCoordinator.isLeaseActive(lease, ctx)) return reviewLoopDeniedResult("review_subagents");
1267
+ const passes = passesWithoutFocus.map((pass, index) => ({
1268
+ ...pass,
1269
+ focusPublisher: loopCoordinator.createFocusPublisher(lease, ctx, {
1270
+ key: `${toolCallId}:${index}`,
1271
+ label: pass.id ?? `${pass.tier} review`,
1272
+ tier: pass.tier,
1273
+ }),
1274
+ }));
1275
+ const maxParallel = normalizeMaxParallel(params.max_parallel, passes.length);
1243
1276
  const config = loadConfig(ctx);
1244
1277
  const batchStartedAt = monotonicNow();
1245
1278