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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/README.md +25 -0
- package/extensions/index.ts +2 -0
- package/extensions/pr-review-focus.ts +265 -0
- package/extensions/pr-review-subagent.ts +54 -21
- package/lib/pr-review-focus.ts +421 -0
- package/lib/pr-review-loop.ts +45 -0
- package/package.json +1 -1
- package/tests/pr-review-focus-view.test.ts +235 -0
- package/tests/pr-review-focus.test.ts +250 -0
- package/tests/pr-review-loop.test.ts +30 -0
- package/tests/pr-review-prompt.test.ts +14 -0
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.
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
485
|
-
if (!
|
|
486
|
-
|
|
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
|
|
493
|
+
onEvent?.(event);
|
|
489
494
|
} catch {
|
|
490
|
-
|
|
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
|
-
|
|
516
|
-
|
|
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
|
-
|
|
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(
|
|
674
|
-
|
|
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 (
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|