pi-pr-review 1.7.1 → 1.9.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.9.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.0](https://github.com/10ego/pi-pr-review/compare/v1.8.0...v1.9.0) (2026-07-14)
4
+
5
+
6
+ ### Features
7
+
8
+ * **review:** add constrained one-shot self-review ([#18](https://github.com/10ego/pi-pr-review/issues/18)) ([ab267c4](https://github.com/10ego/pi-pr-review/commit/ab267c40415731b2fbbea91c5b345153fa7b6132))
9
+
10
+ ## [1.8.0](https://github.com/10ego/pi-pr-review/compare/v1.7.1...v1.8.0) (2026-07-14)
11
+
12
+
13
+ ### Features
14
+
15
+ * 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))
16
+
3
17
  ## [1.7.1](https://github.com/10ego/pi-pr-review/compare/v1.7.0...v1.7.1) (2026-07-14)
4
18
 
5
19
 
package/README.md CHANGED
@@ -14,7 +14,7 @@ The default review prioritizes P0–P2 defects and allows up to three minor find
14
14
 
15
15
  ## Requirements
16
16
 
17
- - Pi `0.77.0` or newer.
17
+ - Pi `0.80.5` or newer (the first release with the terminal `agent_settled` lifecycle event).
18
18
  - [`gh`](https://cli.github.com/) installed and authenticated with `gh auth login`.
19
19
  - Pi running inside the repository that owns the PR.
20
20
 
@@ -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.
@@ -133,6 +158,20 @@ Example:
133
158
 
134
159
  Tier subprocesses retry configured fallbacks only for retryable quota, rate-limit, or capacity failures. If a tier is unset, it uses the nearest configured tier and then Pi's default model.
135
160
 
161
+ ## One-shot self-review for top-level tasks
162
+
163
+ For an eligible long-running coding task started by direct interactive or RPC input, Pi exposes `self_review_subagent` near the end of the task. The tool takes no arguments. Its empty schema is closed with `additionalProperties: false`; the extension—not the caller—fixes the objective, heavy tier, P0–P2-only severity policy, no-minor-hygiene policy, and no-tools isolation.
164
+
165
+ The permit is bound to one top-level task generation, the Pi session instance, cwd, and canonical Git worktree. Dispatch is additionally bound to the tool-call ID from an assistant `message_end` containing exactly one tool call, `self_review_subagent`; mixed, multiple, or direct unbound dispatches are denied without consuming the reusable task permit. A bound permit is consumed atomically before delta capture, so concurrent or replayed calls are rejected and the tool is hidden immediately. It is never available during `/pr-review`. Low-level `agent_end` events do not revoke it because Pi may still retry, compact, or run queued continuations; unused authority is cleared only at terminal `agent_settled` (or earlier cancellation/session/input boundaries).
166
+
167
+ The child uses Pi RPC mode with a bounded ten-minute total runtime. Before startup, the host creates a mode-`0700` temporary `PI_CODING_AGENT_DIR`, copies and normalizes the trusted user settings there with retry and compaction disabled, and exposes only validated regular `auth.json`/`models.json` files through controlled symlinks. The same private directory—not the mutable worktree—is the child process cwd, and inherited runtime preload flags (`NODE_OPTIONS` and `BUN_OPTIONS`) are removed. RPC control acknowledgements can therefore persist only to temporary settings. The host attempts synchronous recursive removal after every supervised outcome; unsafe source configuration or cleanup failure fails the call closed. Environment-based authentication otherwise remains inherited, and no credentials are placed in arguments or prompts. The host waits for acknowledgements that automatic compaction and automatic retry are disabled before it submits the sole prompt, and it also aborts on timeout, bounded stdout/stderr overflow, any retry/compaction lifecycle event, or a second `agent_start`. There is no fallback, sharding, extension discovery, tools, publication, or verification behavior.
168
+
169
+ Self-review is deliberately fail-closed. At extension startup, the host resolves one canonical executable Git from that startup `PATH`; the same absolute executable is bound through clean-baseline capture, permit validation, and delta capture, so later `PATH` changes cannot select repository-controlled Git. The worktree must be clean when the top-level task starts and HEAD must remain unchanged. Baseline capture receives its abort signal immediately, so new input, cancellation, or session/tree navigation can stop even the initial Git inspection. At execution, the host builds a complete bounded diff of Git-visible tracked, staged, and non-ignored untracked changes relative to that starting HEAD. It rejects an empty delta, any changed or dirty submodule, more than 200 status records, more than 4 MiB of diff, a changed session/cwd/worktree/HEAD, or a status change during capture.
170
+
171
+ The child must return strict JSON containing only a `findings` array. Every finding must pass an exact host-owned schema: P0/P1/P2 severity and matching title/blocking state, concrete impact/trigger/evidence strings, normalized repo-relative changed-line coordinates, side, task/diff relationship flags, and bounded confidence. The host derives changed-line anchors from the captured unified diff and requires each claimed range to lie completely within one changed-line run in one hunk on the exact claimed path and side; binary and no-hunk paths have no valid anchors. Markdown, malformed JSON, extra or missing fields, P3/nits, inconsistent metadata, out-of-delta anchors, and unsafe paths are rejected rather than shown as review results. No findings is `{"findings":[]}`.
172
+
173
+ This is a practical Git-derived boundary, not a filesystem snapshot or sandbox. Ignored files are not included, and an external process that rewrites ordinary file contents without changing status shape could race capture. Requiring a clean start, rejecting submodule deltas, checking HEAD and status before/after capture, incrementally bounding all output, and failing closed avoids silently presenting a partial oversized delta. The RPC leader starts in a detached POSIX process group; on failure, abort, timeout, or retained descendants, the host sends group TERM then KILL, destroys inherited pipes, and stops waiting after a bounded drain deadline. A descendant that deliberately creates a different process group/session can escape those signals, although it cannot force the host to retain the supervised pipes indefinitely. Use a separate sandbox or snapshot system when stronger filesystem, process, or network isolation is required.
174
+
136
175
  ## Publish to GitHub
137
176
 
138
177
  Publishing is off by default.
@@ -212,6 +251,8 @@ The verdict is `request_changes` only when a validated P0 or P1 finding exists.
212
251
  - Every review tool also checks an in-memory, session- and cwd-bound loop lease before reading review context, running verification, or spawning a reviewer. Hiding the tools is not the only enforcement boundary.
213
252
  - Unrelated input, terminal completion, cancellation, session navigation, or tree navigation revokes the lease and aborts in-flight review work. Tools are suspended while a non-open PR waits for confirmation.
214
253
  - Reviewer subprocesses start with extension discovery disabled, so they cannot recursively invoke this package's agents or verification tool.
254
+ - `self_review_subagent` has separate one-shot authority for an eligible direct top-level task; it cannot authorize `review_subagent`, `review_subagents`, or `pr_review_verify`, and those permissive schemas remain exclusively behind `ReviewLoopCoordinator`.
255
+ - Self-review execute-time checks are authoritative: visibility alone never grants a permit, and consumption hides the tool before any asynchronous delta or child work.
215
256
  - Reviewers receive the captured diff and are instructed not to modify files.
216
257
  - The orchestrator does not check out, commit, or push PR code.
217
258
  - 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.
@@ -1,11 +1,15 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { ReviewLoopCoordinator } from "../lib/pr-review-loop.ts";
3
+ import { SelfReviewPermitCoordinator } from "../lib/pr-self-review.ts";
3
4
  import registerPrReviewSubagents from "./pr-review-subagent.ts";
5
+ import registerReviewFocus from "./pr-review-focus.ts";
4
6
  import registerReviewTable from "./review-table.ts";
5
7
 
6
8
  /** Register the package behind one shared, session-scoped review-loop authority. */
7
9
  export default function registerPrReview(pi: ExtensionAPI) {
8
10
  const loopCoordinator = new ReviewLoopCoordinator(pi);
9
- registerPrReviewSubagents(pi, loopCoordinator);
10
- registerReviewTable(pi, loopCoordinator);
11
+ const selfReviewCoordinator = new SelfReviewPermitCoordinator(pi, () => !!loopCoordinator.peek());
12
+ registerPrReviewSubagents(pi, loopCoordinator, selfReviewCoordinator);
13
+ registerReviewFocus(pi, loopCoordinator);
14
+ registerReviewTable(pi, loopCoordinator, selfReviewCoordinator);
11
15
  }
@@ -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
+ }