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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/README.md +27 -2
- package/extensions/index.ts +4 -4
- package/extensions/pr-review-focus.ts +265 -0
- package/extensions/pr-review-subagent.ts +54 -23
- package/extensions/review-table.ts +31 -88
- package/lib/pr-review-focus.ts +421 -0
- package/lib/pr-review-loop.ts +45 -0
- package/lib/pr-review-publish.ts +1 -71
- package/package.json +1 -1
- package/prompts/pr-review.md +4 -4
- package/tests/pr-review-extension-lifecycle.test.ts +42 -15
- 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 +18 -4
- package/tests/pr-review-publish.test.ts +9 -34
- package/tests/pr-review-tool-gate.test.ts +1 -4
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
|
|
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
|
|
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.
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
registerReviewTable(pi, loopCoordinator
|
|
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
|
|
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,
|
|
@@ -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(
|
|
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(
|
|
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
|
|
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) {
|