pi-pr-review 1.7.0 → 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.0"
2
+ ".": "1.8.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
10
+ ## [1.7.1](https://github.com/10ego/pi-pr-review/compare/v1.7.0...v1.7.1) (2026-07-14)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **publish:** handle direct review requests in extension ([#15](https://github.com/10ego/pi-pr-review/issues/15)) ([e27ffeb](https://github.com/10ego/pi-pr-review/commit/e27ffebb181687a7e38f5ce8f0045b5db76975f4))
16
+
3
17
  ## [1.7.0](https://github.com/10ego/pi-pr-review/compare/v1.6.6...v1.7.0) (2026-07-13)
4
18
 
5
19
 
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.
@@ -147,7 +172,7 @@ The extension—not the model—owns publishing. It creates one formal review wi
147
172
 
148
173
  Closed or merged PRs use a body-only review. Open PRs attach eligible P0–P3 findings as inline comments and keep nits or off-diff findings in the review body.
149
174
 
150
- After a review completes, you can directly ask the agent to “post the inline review.” That interactive/RPC input creates one host-side, session- and repository-bound authorization for the extension-owned `pr_review_publish` tool. The authorization is consumed by one matching call; an unnumbered request is restricted to the latest cached review, while “publish the review for PR #123 binds the named PR. The model cannot self-authorize, supply replacement review text, replay the request, or use this tool to rerun review agents. An explicit authorized request permits stale publication.
175
+ After a review completes, you can directly ask the agent to “post the inline review or “post it as an inline review.” The extension handles that request directly before an agent turn, selecting the latest cached review for an unnumbered request or the named PR in “publish the review for PR #123.” Only fresh interactive/RPC input can trigger this path; extension-generated, queued, or steering input cannot. The extension publishes only validated cached content, never replacement model-authored text, and never starts or reruns review agents. A direct request permits stale publication.
151
176
 
152
177
  Stale publication is also enabled by default through `allowStalePublish: true`; disable it with `/pr-review-config allow_stale_publish=false`. Automatic posting and `/pr-review-publish` without an override use the setting captured when the review starts. The explicit `--allow-stale` flag remains available when the captured setting disabled stale publication:
153
178
 
@@ -214,7 +239,7 @@ The verdict is `request_changes` only when a validated P0 or P1 finding exists.
214
239
  - Reviewer subprocesses start with extension discovery disabled, so they cannot recursively invoke this package's agents or verification tool.
215
240
  - Reviewers receive the captured diff and are instructed not to modify files.
216
241
  - The orchestrator does not check out, commit, or push PR code.
217
- - GitHub writes require `--comment`, an effective `autoPostReviews: true` setting, or a fresh direct user request that grants one host-side call to the cache-only `pr_review_publish` tool. `allowStalePublish` controls whether an authorized/configured write may be stale; it does not independently authorize a write.
242
+ - GitHub writes require `--comment`, an effective `autoPostReviews: true` setting, the model-free `/pr-review-publish` command, or a narrowly matched direct interactive/RPC publish request handled by the extension before an agent turn. `allowStalePublish` controls whether an invocation/config-authorized write may be stale; it does not independently authorize a write.
218
243
  - Publication authority is captured before review or optional verification begins, so PR code cannot enable it mid-run.
219
244
  - Multiple model calls run per PR. Use a cheaper `light` model and reserve stronger models for `heavy` passes to control cost.
220
245
  - Same-head review markers prevent duplicate publication by the same GitHub identity.
@@ -1,13 +1,13 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { ReviewLoopCoordinator } from "../lib/pr-review-loop.ts";
3
- import { CachedPublishAuthorizationGate } from "../lib/pr-review-publish.ts";
4
3
  import registerPrReviewSubagents from "./pr-review-subagent.ts";
4
+ import registerReviewFocus from "./pr-review-focus.ts";
5
5
  import registerReviewTable from "./review-table.ts";
6
6
 
7
7
  /** Register the package behind one shared, session-scoped review-loop authority. */
8
8
  export default function registerPrReview(pi: ExtensionAPI) {
9
9
  const loopCoordinator = new ReviewLoopCoordinator(pi);
10
- const publishAuthorization = new CachedPublishAuthorizationGate();
11
- registerPrReviewSubagents(pi, loopCoordinator, () => publishAuthorization.clear());
12
- registerReviewTable(pi, loopCoordinator, publishAuthorization);
10
+ registerPrReviewSubagents(pi, loopCoordinator);
11
+ registerReviewFocus(pi, loopCoordinator);
12
+ registerReviewTable(pi, loopCoordinator);
13
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,
@@ -987,7 +1006,6 @@ const ReviewSubagentsParams = Type.Object({
987
1006
  export default function registerPrReviewSubagents(
988
1007
  pi: ExtensionAPI,
989
1008
  loopCoordinator = new ReviewLoopCoordinator(pi),
990
- revokePublishAuthorization: () => void = () => {},
991
1009
  ) {
992
1010
  // Resolve security-sensitive executables only from the PATH trusted when this
993
1011
  // extension starts, never from a later mutable process environment.
@@ -1067,7 +1085,7 @@ export default function registerPrReviewSubagents(
1067
1085
  ],
1068
1086
  parameters: ReviewSubagentParams,
1069
1087
 
1070
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
1088
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
1071
1089
  const lease = loopCoordinator.acquire(ctx);
1072
1090
  if (!lease) return reviewLoopDeniedResult("review_subagent");
1073
1091
  const executionSignal = combineAbortSignals(signal, lease.signal);
@@ -1083,6 +1101,11 @@ export default function registerPrReviewSubagents(
1083
1101
  };
1084
1102
  }
1085
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
+ });
1086
1109
  const config = loadConfig(ctx);
1087
1110
  const result = await runSubagentPass(
1088
1111
  config,
@@ -1094,6 +1117,7 @@ export default function registerPrReviewSubagents(
1094
1117
  toolPolicy: normalizeToolPolicy(params.tool_policy),
1095
1118
  majorOnly: params.major_only === true,
1096
1119
  minorHygiene: params.minor_hygiene === true,
1120
+ focusPublisher,
1097
1121
  },
1098
1122
  executionSignal,
1099
1123
  (text) => onUpdate?.({ content: [{ type: "text", text }] }),
@@ -1156,7 +1180,7 @@ export default function registerPrReviewSubagents(
1156
1180
  ],
1157
1181
  parameters: ReviewSubagentsParams,
1158
1182
 
1159
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
1183
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
1160
1184
  const lease = loopCoordinator.acquire(ctx);
1161
1185
  if (!lease) return reviewLoopDeniedResult("review_subagents");
1162
1186
  const executionSignal = combineAbortSignals(signal, lease.signal);
@@ -1215,7 +1239,7 @@ export default function registerPrReviewSubagents(
1215
1239
  : loadedContext.context;
1216
1240
  const majorOnly = params.major_only === true;
1217
1241
  const minorHygiene = params.minor_hygiene === true;
1218
- const passes: SubagentPassRequest[] = rawPasses.flatMap((pass, index) => {
1242
+ const passesWithoutFocus: SubagentPassRequest[] = rawPasses.flatMap((pass, index) => {
1219
1243
  const tier = pass.tier as Tier;
1220
1244
  const baseId = typeof pass.id === "string" && pass.id.trim() ? pass.id.trim() : `${index + 1}-${tier}`;
1221
1245
  const baseContext = combineContexts(sharedContext, loadedPassContexts[index]!.context);
@@ -1239,8 +1263,16 @@ export default function registerPrReviewSubagents(
1239
1263
  ? requestedShards.map((shard, shardIndex) => makePass(shard, shardIndex))
1240
1264
  : [makePass(undefined, 0)];
1241
1265
  });
1242
- const maxParallel = normalizeMaxParallel(params.max_parallel, passes.length);
1243
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);
1244
1276
  const config = loadConfig(ctx);
1245
1277
  const batchStartedAt = monotonicNow();
1246
1278
 
@@ -1329,7 +1361,6 @@ export default function registerPrReviewSubagents(
1329
1361
  handler: async (args, ctx) => {
1330
1362
  // Extension commands execute before input events, so revoke explicitly.
1331
1363
  loopCoordinator.clear();
1332
- revokePublishAuthorization();
1333
1364
  const raw = (args ?? "").trim();
1334
1365
  const parsed = parseConfigArgs(raw);
1335
1366
  if (parsed.errors.length) {