@ilya-lesikov/pi-pi 0.7.0 → 0.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.
Files changed (59) hide show
  1. package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
  2. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
  3. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
  4. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
  5. package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
  6. package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
  7. package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
  8. package/3p/pi-subagents/src/agent-manager.ts +34 -1
  9. package/3p/pi-subagents/src/agent-runner.ts +66 -33
  10. package/3p/pi-subagents/src/index.ts +3 -38
  11. package/3p/pi-subagents/src/types.ts +4 -0
  12. package/extensions/orchestrator/agents/advisor.ts +35 -0
  13. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
  14. package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
  15. package/extensions/orchestrator/agents/constraints.test.ts +44 -0
  16. package/extensions/orchestrator/agents/constraints.ts +3 -0
  17. package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
  18. package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
  19. package/extensions/orchestrator/agents/planner.ts +9 -8
  20. package/extensions/orchestrator/agents/prompts.test.ts +120 -0
  21. package/extensions/orchestrator/agents/reviewer.ts +48 -0
  22. package/extensions/orchestrator/agents/task.ts +6 -20
  23. package/extensions/orchestrator/agents/tool-routing.ts +23 -1
  24. package/extensions/orchestrator/command-handlers.ts +1 -1
  25. package/extensions/orchestrator/config.ts +5 -2
  26. package/extensions/orchestrator/context.test.ts +54 -0
  27. package/extensions/orchestrator/context.ts +65 -2
  28. package/extensions/orchestrator/event-handlers.test.ts +97 -1
  29. package/extensions/orchestrator/event-handlers.ts +176 -43
  30. package/extensions/orchestrator/flant-infra.test.ts +25 -0
  31. package/extensions/orchestrator/flant-infra.ts +91 -0
  32. package/extensions/orchestrator/index.ts +1 -1
  33. package/extensions/orchestrator/integration.test.ts +107 -12
  34. package/extensions/orchestrator/messages.test.ts +30 -0
  35. package/extensions/orchestrator/messages.ts +6 -0
  36. package/extensions/orchestrator/model-registry.test.ts +48 -1
  37. package/extensions/orchestrator/model-registry.ts +43 -1
  38. package/extensions/orchestrator/orchestrator.test.ts +113 -0
  39. package/extensions/orchestrator/orchestrator.ts +151 -2
  40. package/extensions/orchestrator/phases/brainstorm.ts +9 -20
  41. package/extensions/orchestrator/phases/implementation.test.ts +11 -0
  42. package/extensions/orchestrator/phases/implementation.ts +4 -6
  43. package/extensions/orchestrator/phases/planning.test.ts +16 -0
  44. package/extensions/orchestrator/phases/planning.ts +11 -4
  45. package/extensions/orchestrator/phases/review-task.ts +1 -4
  46. package/extensions/orchestrator/phases/review.test.ts +8 -0
  47. package/extensions/orchestrator/phases/review.ts +4 -3
  48. package/extensions/orchestrator/plannotator.ts +9 -6
  49. package/extensions/orchestrator/pp-menu.ts +168 -54
  50. package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
  51. package/extensions/orchestrator/pp-state-tools.ts +249 -0
  52. package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
  53. package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
  54. package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
  55. package/extensions/orchestrator/test-helpers.ts +4 -1
  56. package/extensions/orchestrator/usage-tracker.ts +5 -1
  57. package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
  58. package/extensions/orchestrator/validate-artifacts.ts +2 -2
  59. package/package.json +1 -1
@@ -161,14 +161,15 @@ Supported actions and payloads:
161
161
  - `annotate-last`: `{ markdown? }`
162
162
  - `archive`: `{ customPlanPath? }`
163
163
 
164
- Plan review is asynchronous:
164
+ Plan review and code review are asynchronous:
165
165
 
166
- - callers send `plannotator:request` with action `plan-review`
167
- - Plannotator opens the browser review and immediately responds with `{ status: "handled", result: { status: "pending", reviewId } }`
168
- - when the human approves or rejects in the browser, Plannotator emits `plannotator:review-result` with `{ reviewId, approved, feedback, savedPath?, agentSwitch?, permissionMode? }`
166
+ - callers send `plannotator:request` with action `plan-review` or `code-review`
167
+ - Plannotator immediately responds with `{ status: "handled", result: { status: "pending", reviewId } }` — for `code-review` this ack is emitted BEFORE the (potentially slow) browser session and PR/diff/worktree preparation start, so callers can detect an absent extension within the short availability window without waiting for the review itself
168
+ - when the human approves or rejects in the browser (or closes the window), Plannotator emits `plannotator:review-result` with `{ reviewId, approved, feedback, savedPath?, agentSwitch?, permissionMode? }`
169
+ - if `code-review` startup/preparation fails after the pending ack, Plannotator emits `plannotator:review-result` with `{ reviewId, approved: false, error }` so the failure is distinguishable from a legitimate user rejection
169
170
  - callers can query `review-status` with the same `reviewId` to recover from startup races or session restarts
170
171
 
171
- The other shared actions remain request/response flows. Payloads are intentionally minimal and only include fields the shared implementation actually uses.
172
+ The remaining shared actions (`annotate`, `annotate-last`, `archive`, `review-status`) are synchronous request/response flows. Payloads are intentionally minimal and only include fields the shared implementation actually uses.
172
173
 
173
174
  ### Markdown annotation
174
175
 
@@ -59,6 +59,19 @@ export interface PlanReviewBrowserSession extends BrowserDecisionSession<PlanRev
59
59
  onDecision: (listener: (result: PlanReviewDecision) => void | Promise<void>) => () => void;
60
60
  }
61
61
 
62
+ export interface CodeReviewDecision {
63
+ approved: boolean;
64
+ feedback?: string;
65
+ annotations?: unknown[];
66
+ agentSwitch?: string;
67
+ exit?: boolean;
68
+ }
69
+
70
+ export interface CodeReviewBrowserSession extends BrowserDecisionSession<CodeReviewDecision> {
71
+ reviewId: string;
72
+ onDecision: (listener: (result: CodeReviewDecision) => void | Promise<void>) => () => void;
73
+ }
74
+
62
75
  const __dirname = dirname(fileURLToPath(import.meta.url));
63
76
  let planHtmlContent = "";
64
77
  let reviewHtmlContent = "";
@@ -230,15 +243,7 @@ export async function openCodeReview(
230
243
  export async function startCodeReviewBrowserSession(
231
244
  ctx: ExtensionContext,
232
245
  options: { cwd?: string; defaultBranch?: string; diffType?: DiffType; prUrl?: string; vcsType?: VcsSelection; useLocal?: boolean } = {},
233
- ): Promise<
234
- BrowserDecisionSession<{
235
- approved: boolean;
236
- feedback?: string;
237
- annotations?: unknown[];
238
- agentSwitch?: string;
239
- exit?: boolean;
240
- }>
241
- > {
246
+ ): Promise<CodeReviewBrowserSession> {
242
247
  if (!ctx.hasUI || !reviewHtmlContent) {
243
248
  throw new Error("Plannotator code review browser is unavailable in this session.");
244
249
  }
@@ -477,9 +482,19 @@ export async function startCodeReviewBrowserSession(
477
482
  shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,
478
483
  pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined,
479
484
  onCleanup: worktreeCleanup,
485
+ repoCwd: agentCwd ?? options.cwd ?? ctx.cwd,
480
486
  });
481
487
 
482
- return startBrowserDecisionSession(server, ctx, server.waitForDecision);
488
+ const session = startBrowserDecisionSession(server, ctx, server.waitForDecision);
489
+ server.onDecision(() => {
490
+ setTimeout(() => session.stop(), 1500);
491
+ });
492
+
493
+ return {
494
+ ...session,
495
+ reviewId: server.reviewId,
496
+ onDecision: server.onDecision,
497
+ };
483
498
  }
484
499
 
485
500
  export async function openMarkdownAnnotation(
@@ -0,0 +1,172 @@
1
+ import { describe, expect, test, mock, beforeEach } from "bun:test";
2
+
3
+ type DecisionListener = (result: { approved: boolean; feedback?: string; agentSwitch?: string }) => void;
4
+
5
+ let sessionBehavior: {
6
+ throwOnStart?: Error;
7
+ decision?: { approved: boolean; feedback?: string };
8
+ resolveWaitImmediately?: boolean;
9
+ } = {};
10
+ let startCalls = 0;
11
+ let decisionListeners: DecisionListener[] = [];
12
+
13
+ mock.module("./plannotator-browser.js", () => ({
14
+ getLastAssistantMessageText: () => "",
15
+ getStartupErrorMessage: (err: unknown) => (err instanceof Error ? err.message : "Unknown error"),
16
+ hasPlanBrowserHtml: () => true,
17
+ hasReviewBrowserHtml: () => true,
18
+ shouldUseLocalPrCheckout: (options: { useLocal?: boolean }) => options.useLocal !== false,
19
+ openArchiveBrowserAction: async () => ({ opened: false }),
20
+ openCodeReview: async () => ({ approved: false }),
21
+ openPlanReviewBrowser: async () => ({ approved: false }),
22
+ openLastMessageAnnotation: async () => ({ feedback: "" }),
23
+ openMarkdownAnnotation: async () => ({ feedback: "" }),
24
+ startCodeReviewBrowserSession: async () => {
25
+ startCalls += 1;
26
+ if (sessionBehavior.throwOnStart) throw sessionBehavior.throwOnStart;
27
+ return {
28
+ url: "http://localhost:0",
29
+ reviewId: "server-review-id",
30
+ onDecision: (listener: DecisionListener) => {
31
+ decisionListeners.push(listener);
32
+ return () => {};
33
+ },
34
+ waitForDecision: () =>
35
+ new Promise((resolve) => {
36
+ if (sessionBehavior.resolveWaitImmediately && sessionBehavior.decision) {
37
+ resolve(sessionBehavior.decision as any);
38
+ }
39
+ }),
40
+ stop: () => {},
41
+ };
42
+ },
43
+ startLastMessageAnnotationSession: async () => ({}),
44
+ startMarkdownAnnotationSession: async () => ({}),
45
+ startPlanReviewBrowserSession: async () => ({}),
46
+ }));
47
+
48
+ const { registerPlannotatorEventListeners, PLANNOTATOR_REQUEST_CHANNEL, PLANNOTATOR_REVIEW_RESULT_CHANNEL } =
49
+ await import("./plannotator-events.js");
50
+
51
+ interface FakePi {
52
+ on: (event: string, handler: (event: any, ctx: any) => void | Promise<void>) => void;
53
+ events: {
54
+ on: (channel: string, handler: (data: any) => void) => () => void;
55
+ emit: (channel: string, data: any) => void;
56
+ _handlers: Record<string, Array<(data: any) => void>>;
57
+ _sessionStart?: (event: any, ctx: any) => void | Promise<void>;
58
+ };
59
+ }
60
+
61
+ function makeFakePi(): FakePi {
62
+ const handlers: Record<string, Array<(data: any) => void>> = {};
63
+ const pi: FakePi = {
64
+ on: (event, handler) => {
65
+ if (event === "session_start") pi.events._sessionStart = handler;
66
+ },
67
+ events: {
68
+ _handlers: handlers,
69
+ on: (channel, handler) => {
70
+ (handlers[channel] ??= []).push(handler);
71
+ return () => {
72
+ handlers[channel] = (handlers[channel] ?? []).filter((h) => h !== handler);
73
+ };
74
+ },
75
+ emit: (channel, data) => {
76
+ for (const h of handlers[channel] ?? []) h(data);
77
+ },
78
+ },
79
+ };
80
+ return pi;
81
+ }
82
+
83
+ async function emitCodeReviewRequest(pi: FakePi): Promise<{
84
+ response: any;
85
+ results: any[];
86
+ }> {
87
+ const results: any[] = [];
88
+ pi.events.on(PLANNOTATOR_REVIEW_RESULT_CHANNEL, (data) => results.push(data));
89
+
90
+ let response: any;
91
+ pi.events.emit(PLANNOTATOR_REQUEST_CHANNEL, {
92
+ requestId: "req-1",
93
+ action: "code-review",
94
+ payload: { cwd: "/tmp/repo" },
95
+ respond: (r: any) => {
96
+ response = r;
97
+ },
98
+ });
99
+ // Let the synchronous respond + microtasks settle.
100
+ await new Promise((r) => setTimeout(r, 10));
101
+ return { response, results };
102
+ }
103
+
104
+ beforeEach(() => {
105
+ sessionBehavior = {};
106
+ startCalls = 0;
107
+ decisionListeners = [];
108
+ });
109
+
110
+ describe("code-review two-phase handler", () => {
111
+ test("acks pending with a reviewId synchronously, before any heavy prep resolves", async () => {
112
+ const pi = makeFakePi();
113
+ registerPlannotatorEventListeners(pi as any);
114
+ await pi.events._sessionStart?.({}, { cwd: "/tmp/repo", hasUI: true });
115
+
116
+ let respondedSynchronously = false;
117
+ let ackReviewId: string | undefined;
118
+ pi.events.emit(PLANNOTATOR_REQUEST_CHANNEL, {
119
+ requestId: "req-1",
120
+ action: "code-review",
121
+ payload: { cwd: "/tmp/repo" },
122
+ respond: (r: any) => {
123
+ respondedSynchronously = true;
124
+ ackReviewId = r?.result?.reviewId;
125
+ expect(r.status).toBe("handled");
126
+ expect(r.result.status).toBe("pending");
127
+ },
128
+ });
129
+
130
+ // The ack must fire in the same tick as the request handler's first
131
+ // awaited boundary — before startCodeReviewBrowserSession is invoked.
132
+ expect(respondedSynchronously).toBe(true);
133
+ expect(typeof ackReviewId).toBe("string");
134
+ expect(ackReviewId!.length).toBeGreaterThan(0);
135
+ });
136
+
137
+ test("emits a review-result on the pending reviewId when the user decides", async () => {
138
+ const pi = makeFakePi();
139
+ registerPlannotatorEventListeners(pi as any);
140
+ await pi.events._sessionStart?.({}, { cwd: "/tmp/repo", hasUI: true });
141
+
142
+ const { response, results } = await emitCodeReviewRequest(pi);
143
+ const reviewId = response.result.reviewId;
144
+
145
+ expect(startCalls).toBe(1);
146
+ expect(decisionListeners.length).toBe(1);
147
+
148
+ decisionListeners[0]({ approved: true, feedback: "looks good" });
149
+
150
+ expect(results.length).toBe(1);
151
+ expect(results[0].reviewId).toBe(reviewId);
152
+ expect(results[0].approved).toBe(true);
153
+ expect(results[0].feedback).toBe("looks good");
154
+ expect(results[0].error).toBeUndefined();
155
+ });
156
+
157
+ test("emits a review-result with an error when startup/prep fails after the pending ack", async () => {
158
+ sessionBehavior = { throwOnStart: new Error("worktree prep failed") };
159
+ const pi = makeFakePi();
160
+ registerPlannotatorEventListeners(pi as any);
161
+ await pi.events._sessionStart?.({}, { cwd: "/tmp/repo", hasUI: true });
162
+
163
+ const { response, results } = await emitCodeReviewRequest(pi);
164
+ const reviewId = response.result.reviewId;
165
+
166
+ expect(response.result.status).toBe("pending");
167
+ expect(results.length).toBe(1);
168
+ expect(results[0].reviewId).toBe(reviewId);
169
+ expect(results[0].approved).toBe(false);
170
+ expect(results[0].error).toBe("worktree prep failed");
171
+ });
172
+ });
@@ -8,7 +8,6 @@ import {
8
8
  getLastAssistantMessageText,
9
9
  getStartupErrorMessage,
10
10
  openArchiveBrowserAction,
11
- openCodeReview,
12
11
  openLastMessageAnnotation,
13
12
  openMarkdownAnnotation,
14
13
  startCodeReviewBrowserSession,
@@ -74,6 +73,12 @@ export interface PlannotatorReviewResultEvent {
74
73
  savedPath?: string;
75
74
  agentSwitch?: string;
76
75
  permissionMode?: string;
76
+ /**
77
+ * Set when the review could not be produced (e.g. code-review startup/prep
78
+ * failed after the pending ack). Distinguishes a failure from a legitimate
79
+ * user rejection (`approved: false` with feedback).
80
+ */
81
+ error?: string;
77
82
  }
78
83
 
79
84
  export interface PlannotatorReviewStatusPayload {
@@ -101,6 +106,11 @@ export interface PlannotatorCodeReviewResult {
101
106
  agentSwitch?: string;
102
107
  }
103
108
 
109
+ export interface PlannotatorCodeReviewStartResult {
110
+ status: "pending";
111
+ reviewId: string;
112
+ }
113
+
104
114
  export interface PlannotatorAnnotatePayload {
105
115
  filePath: string;
106
116
  markdown?: string;
@@ -129,7 +139,7 @@ export interface PlannotatorArchiveResult {
129
139
  export type PlannotatorRequestMap = {
130
140
  "plan-review": PlannotatorRequestBase<"plan-review", PlannotatorPlanReviewPayload, PlannotatorPlanReviewStartResult>;
131
141
  "review-status": PlannotatorRequestBase<"review-status", PlannotatorReviewStatusPayload, PlannotatorReviewStatusResult>;
132
- "code-review": PlannotatorRequestBase<"code-review", PlannotatorCodeReviewPayload, PlannotatorCodeReviewResult>;
142
+ "code-review": PlannotatorRequestBase<"code-review", PlannotatorCodeReviewPayload, PlannotatorCodeReviewStartResult>;
133
143
  annotate: PlannotatorRequestBase<"annotate", PlannotatorAnnotatePayload, PlannotatorAnnotationResult>;
134
144
  "annotate-last": PlannotatorRequestBase<"annotate-last", PlannotatorAnnotatePayload, PlannotatorAnnotationResult>;
135
145
  archive: PlannotatorRequestBase<"archive", PlannotatorArchivePayload, PlannotatorArchiveResult>;
@@ -138,7 +148,7 @@ export type PlannotatorRequest = PlannotatorRequestMap[PlannotatorAction];
138
148
  export type PlannotatorResponseMap = {
139
149
  "plan-review": PlannotatorResponse<PlannotatorPlanReviewStartResult>;
140
150
  "review-status": PlannotatorResponse<PlannotatorReviewStatusResult>;
141
- "code-review": PlannotatorResponse<PlannotatorCodeReviewResult>;
151
+ "code-review": PlannotatorResponse<PlannotatorCodeReviewStartResult>;
142
152
  annotate: PlannotatorResponse<PlannotatorAnnotationResult>;
143
153
  "annotate-last": PlannotatorResponse<PlannotatorAnnotationResult>;
144
154
  archive: PlannotatorResponse<PlannotatorArchiveResult>;
@@ -263,15 +273,43 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void {
263
273
  return;
264
274
  }
265
275
  case "code-review": {
266
- const result = await openCodeReview(ctx, {
267
- cwd: request.payload?.cwd,
268
- defaultBranch: request.payload?.defaultBranch,
269
- diffType: request.payload?.diffType,
270
- vcsType: request.payload?.vcsType,
271
- useLocal: request.payload?.useLocal,
272
- prUrl: request.payload?.prUrl,
273
- });
274
- request.respond({ status: "handled", result });
276
+ const reviewId = crypto.randomUUID();
277
+ setStoredReviewStatus(reviewId, { status: "pending" });
278
+ request.respond({ status: "handled", result: { status: "pending", reviewId } });
279
+
280
+ const payload = request.payload;
281
+ void (async () => {
282
+ try {
283
+ const session = await startCodeReviewBrowserSession(ctx, {
284
+ cwd: payload?.cwd,
285
+ defaultBranch: payload?.defaultBranch,
286
+ diffType: payload?.diffType,
287
+ vcsType: payload?.vcsType,
288
+ useLocal: payload?.useLocal,
289
+ prUrl: payload?.prUrl,
290
+ });
291
+ session.onDecision((result) => {
292
+ const reviewResult = {
293
+ reviewId,
294
+ approved: result.approved,
295
+ feedback: result.feedback,
296
+ agentSwitch: result.agentSwitch,
297
+ } satisfies PlannotatorReviewResultEvent;
298
+ setStoredReviewStatus(reviewId, { status: "completed", ...reviewResult });
299
+ pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult);
300
+ });
301
+ await session.waitForDecision();
302
+ } catch (err) {
303
+ const message = getStartupErrorMessage(err);
304
+ const reviewResult = {
305
+ reviewId,
306
+ approved: false,
307
+ error: message,
308
+ } satisfies PlannotatorReviewResultEvent;
309
+ setStoredReviewStatus(reviewId, { status: "completed", ...reviewResult });
310
+ pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult);
311
+ }
312
+ })();
275
313
  return;
276
314
  }
277
315
  case "annotate": {
@@ -0,0 +1,45 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { execSync } from "node:child_process";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { getRepoInfo } from "./project";
7
+
8
+ const cleanup: string[] = [];
9
+
10
+ function makeRepo(branch: string): string {
11
+ const dir = mkdtempSync(join(tmpdir(), "plannotator-project-test-"));
12
+ cleanup.push(dir);
13
+ const run = (cmd: string) => execSync(cmd, { cwd: dir, stdio: ["pipe", "pipe", "pipe"] });
14
+ run(`git init -q -b ${branch}`);
15
+ run("git config user.email test@test.local");
16
+ run("git config user.name test");
17
+ run("git remote add origin https://github.com/acme/widget.git");
18
+ run("git commit -q --allow-empty -m init");
19
+ return dir;
20
+ }
21
+
22
+ afterAll(() => {
23
+ for (const dir of cleanup) {
24
+ try {
25
+ rmSync(dir, { recursive: true, force: true });
26
+ } catch {}
27
+ }
28
+ });
29
+
30
+ describe("getRepoInfo(cwd)", () => {
31
+ test("reports the repo at the given cwd, not process.cwd()", () => {
32
+ const repo = makeRepo("feature-x");
33
+ const info = getRepoInfo(repo);
34
+ expect(info).not.toBeNull();
35
+ expect(info!.display).toBe("acme/widget");
36
+ expect(info!.branch).toBe("feature-x");
37
+ });
38
+
39
+ test("two different cwds resolve to their own repos", () => {
40
+ const a = makeRepo("branch-a");
41
+ const b = makeRepo("branch-b");
42
+ expect(getRepoInfo(a)!.branch).toBe("branch-a");
43
+ expect(getRepoInfo(b)!.branch).toBe("branch-b");
44
+ });
45
+ });
@@ -9,11 +9,12 @@ import { sanitizeTag } from "../generated/project.js";
9
9
  import { parseRemoteUrl, getDirName } from "../generated/repo.js";
10
10
 
11
11
  /** Run a git command and return stdout (empty string on error). */
12
- function git(cmd: string): string {
12
+ function git(cmd: string, cwd?: string): string {
13
13
  try {
14
14
  return execSync(`git ${cmd}`, {
15
15
  encoding: "utf-8",
16
16
  stdio: ["pipe", "pipe", "pipe"],
17
+ cwd,
17
18
  }).trim();
18
19
  } catch {
19
20
  return "";
@@ -39,23 +40,23 @@ export function detectProjectName(): string {
39
40
  }
40
41
  }
41
42
 
42
- export function getRepoInfo(): { display: string; branch?: string } | null {
43
- const branch = git("rev-parse --abbrev-ref HEAD");
43
+ export function getRepoInfo(cwd?: string): { display: string; branch?: string } | null {
44
+ const branch = git("rev-parse --abbrev-ref HEAD", cwd);
44
45
  const safeBranch = branch && branch !== "HEAD" ? branch : undefined;
45
46
 
46
- const originUrl = git("remote get-url origin");
47
+ const originUrl = git("remote get-url origin", cwd);
47
48
  const orgRepo = parseRemoteUrl(originUrl);
48
49
  if (orgRepo) {
49
50
  return { display: orgRepo, branch: safeBranch };
50
51
  }
51
52
 
52
- const topLevel = git("rev-parse --show-toplevel");
53
+ const topLevel = git("rev-parse --show-toplevel", cwd);
53
54
  const repoName = getDirName(topLevel);
54
55
  if (repoName) {
55
56
  return { display: repoName, branch: safeBranch };
56
57
  }
57
58
 
58
- const cwdName = getDirName(process.cwd());
59
+ const cwdName = getDirName(cwd ?? process.cwd());
59
60
  if (cwdName) {
60
61
  return { display: cwdName };
61
62
  }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { readFileSync, existsSync } from "node:fs";
3
4
  import { createServer } from "node:http";
4
5
  import os from "node:os";
@@ -161,18 +162,22 @@ function detectWSL(): boolean {
161
162
  return false;
162
163
  }
163
164
 
165
+ export interface ReviewDecision {
166
+ approved: boolean;
167
+ feedback: string;
168
+ annotations: unknown[];
169
+ agentSwitch?: string;
170
+ exit?: boolean;
171
+ }
172
+
164
173
  export interface ReviewServerResult {
174
+ reviewId: string;
165
175
  port: number;
166
176
  portSource: "env" | "remote-default" | "random";
167
177
  url: string;
168
178
  isRemote: boolean;
169
- waitForDecision: () => Promise<{
170
- approved: boolean;
171
- feedback: string;
172
- annotations: unknown[];
173
- agentSwitch?: string;
174
- exit?: boolean;
175
- }>;
179
+ waitForDecision: () => Promise<ReviewDecision>;
180
+ onDecision: (listener: (result: ReviewDecision) => void | Promise<void>) => () => void;
176
181
  stop: () => void;
177
182
  }
178
183
 
@@ -206,6 +211,8 @@ export async function startReviewServer(options: {
206
211
  onCleanup?: () => void | Promise<void>;
207
212
  /** Called when server starts with the URL, remote status, and port */
208
213
  onReady?: (url: string, isRemote: boolean, port: number) => void;
214
+ /** Working directory of the reviewed repo, used for repo-info (title/header) detection. */
215
+ repoCwd?: string;
209
216
  }): Promise<ReviewServerResult> {
210
217
  const gitUser = detectGitUser();
211
218
  let draftKey = contentHash(options.rawPatch);
@@ -266,7 +273,7 @@ export async function startReviewServer(options: {
266
273
  }
267
274
  : workspace
268
275
  ? { display: basename(workspace.root), branch: "Workspace" }
269
- : getRepoInfo();
276
+ : getRepoInfo(options.repoCwd);
270
277
  const editorAnnotations = createEditorAnnotationHandler();
271
278
  const externalAnnotations = createExternalAnnotationHandler("review");
272
279
 
@@ -538,22 +545,24 @@ export async function startReviewServer(options: {
538
545
  (options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined;
539
546
  const pasteApiUrl =
540
547
  (options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined;
541
- let resolveDecision!: (result: {
542
- approved: boolean;
543
- feedback: string;
544
- annotations: unknown[];
545
- agentSwitch?: string;
546
- exit?: boolean;
547
- }) => void;
548
- const decisionPromise = new Promise<{
549
- approved: boolean;
550
- feedback: string;
551
- annotations: unknown[];
552
- agentSwitch?: string;
553
- exit?: boolean;
554
- }>((r) => {
548
+ const reviewId = randomUUID();
549
+ let resolveDecision!: (result: ReviewDecision) => void;
550
+ const decisionListeners = new Set<(result: ReviewDecision) => void | Promise<void>>();
551
+ let decisionSettled = false;
552
+ const decisionPromise = new Promise<ReviewDecision>((r) => {
555
553
  resolveDecision = r;
556
554
  });
555
+ const publishDecision = (result: ReviewDecision): boolean => {
556
+ if (decisionSettled) return false;
557
+ decisionSettled = true;
558
+ resolveDecision(result);
559
+ for (const listener of decisionListeners) {
560
+ Promise.resolve(listener(result)).catch((error) => {
561
+ console.error("[Code Review] Decision listener failed:", error);
562
+ });
563
+ }
564
+ return true;
565
+ };
557
566
 
558
567
  const aiRuntime = await createPiAIRuntime({ getCwd: resolveAgentCwd });
559
568
 
@@ -1175,13 +1184,13 @@ export async function startReviewServer(options: {
1175
1184
  return;
1176
1185
  } else if (url.pathname === "/api/exit" && req.method === "POST") {
1177
1186
  deleteDraft(draftKey);
1178
- resolveDecision({ approved: false, feedback: '', annotations: [], exit: true });
1179
- json(res, { ok: true });
1187
+ const duplicate = !publishDecision({ approved: false, feedback: '', annotations: [], exit: true });
1188
+ json(res, { ok: true, ...(duplicate && { duplicate: true }) });
1180
1189
  } else if (url.pathname === "/api/feedback" && req.method === "POST") {
1181
1190
  try {
1182
1191
  const body = await parseBody(req);
1183
1192
  deleteDraft(draftKey);
1184
- resolveDecision({
1193
+ publishDecision({
1185
1194
  approved: (body.approved as boolean) ?? false,
1186
1195
  feedback: (body.feedback as string) || "",
1187
1196
  annotations: (body.annotations as unknown[]) || [],
@@ -1207,11 +1216,18 @@ export async function startReviewServer(options: {
1207
1216
  }
1208
1217
 
1209
1218
  return {
1219
+ reviewId,
1210
1220
  port,
1211
1221
  portSource,
1212
1222
  url: serverUrl,
1213
1223
  isRemote,
1214
1224
  waitForDecision: () => decisionPromise,
1225
+ onDecision: (listener) => {
1226
+ decisionListeners.add(listener);
1227
+ return () => {
1228
+ decisionListeners.delete(listener);
1229
+ };
1230
+ },
1215
1231
  stop: () => {
1216
1232
  process.removeListener("exit", exitHandler);
1217
1233
  agentJobs.killAll();
@@ -123,6 +123,10 @@ export class AgentManager {
123
123
  record.status = "running";
124
124
  record.startedAt = Date.now();
125
125
  if (options.toolCallId && !record.toolCallId) record.toolCallId = options.toolCallId;
126
+ // Reset first-progress flags: a re-started (e.g. dequeued) record must be able
127
+ // to emit first_tool/first_turn again for its fresh run.
128
+ record.firstToolEmitted = false;
129
+ record.firstTurnEmitted = false;
126
130
  if (options.isBackground) this.runningBackground++;
127
131
  this.onStart?.(record);
128
132
 
@@ -153,9 +157,38 @@ export class AgentManager {
153
157
  signal: record.abortController!.signal,
154
158
  onToolActivity: (activity) => {
155
159
  if (activity.type === "end") record.toolUses++;
160
+ // Emit first_tool once per run from this single choke point, so ALL spawn
161
+ // paths (RPC panels, Agent-tool background, foreground) report it — not
162
+ // just the Agent tool's background branch. Panels spawn via RPC and were
163
+ // previously silent, making it impossible to tell if a reviewer actually
164
+ // investigated (ran tools) or wrote from context alone.
165
+ if (activity.type === "start" && !record.firstToolEmitted) {
166
+ record.firstToolEmitted = true;
167
+ try {
168
+ pi.events.emit("subagents:first_tool", {
169
+ id,
170
+ type,
171
+ description: options.description,
172
+ toolName: activity.toolName,
173
+ });
174
+ } catch { /* ignore */ }
175
+ }
156
176
  options.onToolActivity?.(activity);
157
177
  },
158
- onTurnEnd: options.onTurnEnd,
178
+ onTurnEnd: (turnCount) => {
179
+ if (!record.firstTurnEmitted) {
180
+ record.firstTurnEmitted = true;
181
+ try {
182
+ pi.events.emit("subagents:first_turn", {
183
+ id,
184
+ type,
185
+ description: options.description,
186
+ turnCount,
187
+ });
188
+ } catch { /* ignore */ }
189
+ }
190
+ options.onTurnEnd?.(turnCount);
191
+ },
159
192
  onTextDelta: options.onTextDelta,
160
193
  validateCompletion: options.validateCompletion,
161
194
  maxValidationRetries: options.maxValidationRetries,