@ilya-lesikov/pi-pi 0.9.0 → 0.10.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 (106) hide show
  1. package/3p/pi-ask-user/index.ts +71 -124
  2. package/3p/pi-ask-user/single-select-layout.ts +3 -14
  3. package/3p/pi-subagents/package.json +13 -7
  4. package/3p/pi-subagents/src/agent-manager.ts +243 -79
  5. package/3p/pi-subagents/src/agent-runner.ts +501 -365
  6. package/3p/pi-subagents/src/agent-types.ts +46 -57
  7. package/3p/pi-subagents/src/cross-extension-rpc.ts +52 -46
  8. package/3p/pi-subagents/src/custom-agents.ts +30 -6
  9. package/3p/pi-subagents/src/default-agents.ts +25 -43
  10. package/3p/pi-subagents/src/enabled-models.ts +180 -0
  11. package/3p/pi-subagents/src/index.ts +599 -839
  12. package/3p/pi-subagents/src/model-resolver.ts +26 -7
  13. package/3p/pi-subagents/src/output-file.ts +21 -2
  14. package/3p/pi-subagents/src/prompts.ts +17 -3
  15. package/3p/pi-subagents/src/schedule-store.ts +153 -0
  16. package/3p/pi-subagents/src/schedule.ts +365 -0
  17. package/3p/pi-subagents/src/settings.ts +261 -0
  18. package/3p/pi-subagents/src/skill-loader.ts +77 -54
  19. package/3p/pi-subagents/src/status-note.ts +25 -0
  20. package/3p/pi-subagents/src/types.ts +97 -6
  21. package/3p/pi-subagents/src/ui/agent-widget.ts +94 -21
  22. package/3p/pi-subagents/src/ui/conversation-viewer.ts +147 -35
  23. package/3p/pi-subagents/src/ui/schedule-menu.ts +104 -0
  24. package/3p/pi-subagents/src/ui/viewer-keys.ts +39 -0
  25. package/3p/pi-subagents/src/usage.ts +60 -0
  26. package/3p/pi-subagents/src/worktree.ts +49 -20
  27. package/extensions/orchestrator/agents/advisor.ts +13 -8
  28. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +8 -3
  29. package/extensions/orchestrator/agents/code-reviewer.ts +8 -3
  30. package/extensions/orchestrator/agents/constraints.test.ts +26 -0
  31. package/extensions/orchestrator/agents/constraints.ts +12 -2
  32. package/extensions/orchestrator/agents/deep-debugger.ts +13 -8
  33. package/extensions/orchestrator/agents/explore.ts +12 -6
  34. package/extensions/orchestrator/agents/librarian.ts +13 -5
  35. package/extensions/orchestrator/agents/plan-reviewer.ts +8 -3
  36. package/extensions/orchestrator/agents/planner.ts +8 -3
  37. package/extensions/orchestrator/agents/pools.test.ts +56 -0
  38. package/extensions/orchestrator/agents/prompts.test.ts +52 -10
  39. package/extensions/orchestrator/agents/registry.test.ts +245 -0
  40. package/extensions/orchestrator/agents/registry.ts +145 -10
  41. package/extensions/orchestrator/agents/reviewer.ts +14 -8
  42. package/extensions/orchestrator/agents/task.ts +12 -6
  43. package/extensions/orchestrator/agents/tool-routing.ts +213 -85
  44. package/extensions/orchestrator/agents/wait-for-completion.test.ts +171 -0
  45. package/extensions/orchestrator/ast-search.test.ts +124 -0
  46. package/extensions/orchestrator/billing-spoof.test.ts +67 -0
  47. package/extensions/orchestrator/billing-spoof.ts +95 -0
  48. package/extensions/orchestrator/cbm.more.test.ts +358 -0
  49. package/extensions/orchestrator/command-handlers.ts +6 -0
  50. package/extensions/orchestrator/commands.test.ts +15 -2
  51. package/extensions/orchestrator/commands.ts +1 -1
  52. package/extensions/orchestrator/config.test.ts +89 -1
  53. package/extensions/orchestrator/config.ts +102 -19
  54. package/extensions/orchestrator/context.test.ts +46 -0
  55. package/extensions/orchestrator/context.ts +18 -5
  56. package/extensions/orchestrator/custom-footer.test.ts +24 -10
  57. package/extensions/orchestrator/custom-footer.ts +4 -2
  58. package/extensions/orchestrator/doctor.more.test.ts +315 -0
  59. package/extensions/orchestrator/doctor.ts +6 -2
  60. package/extensions/orchestrator/event-handlers.more.test.ts +561 -0
  61. package/extensions/orchestrator/event-handlers.test.ts +96 -9
  62. package/extensions/orchestrator/event-handlers.ts +344 -151
  63. package/extensions/orchestrator/exa.more.test.ts +118 -0
  64. package/extensions/orchestrator/flant-infra.more.test.ts +326 -0
  65. package/extensions/orchestrator/flant-infra.test.ts +127 -0
  66. package/extensions/orchestrator/flant-infra.ts +113 -39
  67. package/extensions/orchestrator/index.test.ts +76 -0
  68. package/extensions/orchestrator/index.ts +2 -0
  69. package/extensions/orchestrator/integration.test.ts +183 -65
  70. package/extensions/orchestrator/model-registry.test.ts +2 -1
  71. package/extensions/orchestrator/model-registry.ts +12 -2
  72. package/extensions/orchestrator/orchestrator.test.ts +67 -0
  73. package/extensions/orchestrator/orchestrator.ts +119 -27
  74. package/extensions/orchestrator/phases/brainstorm.test.ts +30 -1
  75. package/extensions/orchestrator/phases/brainstorm.ts +43 -6
  76. package/extensions/orchestrator/phases/implementation.ts +2 -0
  77. package/extensions/orchestrator/phases/machine.test.ts +17 -1
  78. package/extensions/orchestrator/phases/machine.ts +4 -1
  79. package/extensions/orchestrator/phases/planning.test.ts +47 -1
  80. package/extensions/orchestrator/phases/planning.ts +18 -3
  81. package/extensions/orchestrator/phases/review-task.ts +5 -0
  82. package/extensions/orchestrator/phases/review.test.ts +10 -0
  83. package/extensions/orchestrator/phases/review.ts +22 -7
  84. package/extensions/orchestrator/phases/spawn-blocking.test.ts +1 -0
  85. package/extensions/orchestrator/phases/verdict.ts +6 -5
  86. package/extensions/orchestrator/plannotator-open-failure.test.ts +67 -0
  87. package/extensions/orchestrator/plannotator.test.ts +38 -1
  88. package/extensions/orchestrator/plannotator.ts +50 -3
  89. package/extensions/orchestrator/pp-menu.leaves.test.ts +590 -0
  90. package/extensions/orchestrator/pp-menu.more.test.ts +524 -0
  91. package/extensions/orchestrator/pp-menu.test.ts +114 -7
  92. package/extensions/orchestrator/pp-menu.ts +579 -90
  93. package/extensions/orchestrator/pp-state-tools.test.ts +80 -0
  94. package/extensions/orchestrator/pp-state-tools.ts +65 -0
  95. package/extensions/orchestrator/rate-limit-fallback.more.test.ts +241 -0
  96. package/extensions/orchestrator/rate-limit-fallback.test.ts +20 -1
  97. package/extensions/orchestrator/rate-limit-fallback.ts +12 -0
  98. package/extensions/orchestrator/review-files.test.ts +26 -0
  99. package/extensions/orchestrator/review-files.ts +3 -0
  100. package/extensions/orchestrator/state.test.ts +73 -1
  101. package/extensions/orchestrator/state.ts +95 -23
  102. package/extensions/orchestrator/suppress-pierre-theme-spam.test.ts +56 -0
  103. package/extensions/orchestrator/suppress-pierre-theme-spam.ts +45 -0
  104. package/extensions/orchestrator/validate-artifacts.ts +1 -1
  105. package/package.json +6 -2
  106. package/scripts/test-3p.sh +52 -0
@@ -1,12 +1,13 @@
1
1
  import { readFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
2
2
  import { join } from "path";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { resolvePreset, type PiPiConfig, type VariantConfig } from "../config.js";
4
+ import { resolvePreset, reviewPresetGroupForPhase, type PiPiConfig, type VariantConfig } from "../config.js";
5
5
  import { registerAgentDefinitions, spawnViaRpc, waitForCompletion } from "../agents/registry.js";
6
6
  import { createCodeReviewerAgent } from "../agents/code-reviewer.js";
7
7
  import { getContextDirs, getLatestSynthesizedPlan, getArtifactManifest } from "../context.js";
8
8
  import type { RepoInfo } from "../repo-utils.js";
9
9
  import type { PhaseSend } from "../transition-controller.js";
10
+ import { isReviewFileForRound } from "../review-files.js";
10
11
 
11
12
  function isEnabled(value: { enabled?: boolean } | undefined): boolean {
12
13
  return value?.enabled !== false;
@@ -14,19 +15,23 @@ function isEnabled(value: { enabled?: boolean } | undefined): boolean {
14
15
 
15
16
  export function reviewSystemPrompt(taskDir: string, pass: number, phase?: string, mode?: "guided" | "autonomous"): string {
16
17
  // Each phase writes/loads its review outputs in a distinct directory:
17
- // brainstorm -> brainstorm-reviews, plan -> plan-reviews, everything else
18
+ // brainstorm/debug -> brainstorm-reviews, plan -> plan-reviews, everything else
18
19
  // (implement/review) -> code-reviews. The apply_feedback prompt must point the
19
20
  // agent at the SAME directory the reviewers wrote to (see planning.ts /
20
21
  // context.ts), otherwise it synthesizes against the wrong (empty) directory.
21
- const reviewsDirName = phase === "brainstorm" ? "brainstorm-reviews" : phase === "plan" ? "plan-reviews" : "code-reviews";
22
+ const reviewsDirName = reviewPresetGroupForPhase(phase ?? "") === "brainstormReviewers"
23
+ ? "brainstorm-reviews"
24
+ : phase === "plan"
25
+ ? "plan-reviews"
26
+ : "code-reviews";
22
27
  const reviewsDir = join(taskDir, reviewsDirName);
23
28
  const plansDir = join(taskDir, "plans");
24
29
 
25
- if (phase === "brainstorm") {
30
+ if (reviewPresetGroupForPhase(phase ?? "") === "brainstormReviewers") {
26
31
  return [
27
- `[PI-PI — BRAINSTORM REVIEW CYCLE (pass ${pass})]`,
32
+ `[PI-PI — ${phase === "debug" ? "DEBUG" : "BRAINSTORM"} REVIEW CYCLE (pass ${pass})]`,
28
33
  "",
29
- "Brainstorm reviewer outputs are ready.",
34
+ `${phase === "debug" ? "Debug" : "Brainstorm"} reviewer outputs are ready.`,
30
35
  `Read them from ${reviewsDir}/.`,
31
36
  "",
32
37
  "# FORBIDDEN:",
@@ -42,6 +47,11 @@ export function reviewSystemPrompt(taskDir: string, pass: number, phase?: string
42
47
  "4. If reviewers found no actionable gaps (e.g. task already done, minor suggestions only): do NOT modify the files",
43
48
  "5. Ignore suggestions that don't affect downstream planning quality",
44
49
  "",
50
+ "# Interaction (adapted streamlined flow for this synthesis step):",
51
+ "- Work autonomously through the reviewer outputs; do NOT interrupt with mid-flow questions — a genuine blocker is the only reason to stop and ask.",
52
+ `- For a hard or contentious call (reviewers disagree, or a high-stakes change to USER_REQUEST/RESEARCH), consult an advisor whose model family differs from yours before committing — you run on ${phase === "debug" ? "GPT, so default to a Claude-family advisor" : "Claude, so default to a GPT-family advisor"}; escalate for the hardest calls.`,
53
+ "- Before finalizing any concrete, costly-to-reverse or opinion-heavy edit (exact wording, structure, naming, defaults), surface the ACTUAL proposed change rather than silently burying it.",
54
+ "",
45
55
  "USER_REQUEST.md MUST keep exactly: # User Request, ## Problem, ## Constraints",
46
56
  "RESEARCH.md MUST keep exactly: ## Affected Code, ## Architecture Context, ## Constraints & Edge Cases, ## Open Questions (optional)",
47
57
  "Any other sections will fail validation.",
@@ -71,6 +81,11 @@ export function reviewSystemPrompt(taskDir: string, pass: number, phase?: string
71
81
  `2. Synthesize into ${reviewsDir}/<unix-epoch-seconds>_final_pass-${pass}.md (prefix with the current Unix epoch seconds, e.g. \`date +%s\`, so the file orders chronologically)`,
72
82
  "3. Present the synthesis to the user",
73
83
  "",
84
+ "# Interaction (adapted streamlined flow for this synthesis step):",
85
+ "- Work autonomously through the reviewer outputs; do NOT interrupt with mid-flow questions — a genuine blocker is the only reason to stop and ask.",
86
+ "- For a hard or contentious call (reviewers disagree, or a high-stakes accept/reject), consult an advisor whose model family differs from yours before committing — you run on Claude, so default to a GPT-family advisor; escalate for the hardest calls.",
87
+ "- Surface committed specifics rather than burying them: present the synthesis (and any contentious accept/reject decisions) to the user clearly before finalizing.",
88
+ "",
74
89
  "In the synthesized final-review file you MUST include a machine-readable `ANCHORS:` block for the accepted findings — one line per finding in EXACTLY this format (a later publish step consumes these lines):",
75
90
  "ANCHORS:",
76
91
  "<relative/path/from/repo/root>:<line> — <one-line finding>",
@@ -237,7 +252,7 @@ export async function spawnCodeReviewers(
237
252
  await Promise.allSettled(results);
238
253
 
239
254
  const reviewFiles = existsSync(reviewsDir)
240
- ? readdirSync(reviewsDir).filter((f) => f.includes(`round-${round}`) && !f.includes("final"))
255
+ ? readdirSync(reviewsDir).filter((f) => isReviewFileForRound(f, round))
241
256
  : [];
242
257
 
243
258
  if (reviewFiles.length > 0) {
@@ -9,6 +9,7 @@ let waitStarted = false;
9
9
  vi.mock("../agents/registry.js", () => ({
10
10
  registerAgentDefinitions: vi.fn(),
11
11
  unregisterAgentDefinitions: vi.fn(),
12
+ getAgentConfigSnapshot: vi.fn(() => undefined),
12
13
  spawnViaRpc: vi.fn(async () => ({ id: `agent-${Math.random().toString(36).slice(2)}` })),
13
14
  waitForCompletion: vi.fn(
14
15
  () =>
@@ -1,6 +1,8 @@
1
1
  import { readFileSync, existsSync, readdirSync } from "fs";
2
2
  import { join } from "path";
3
3
  import type { Phase } from "../state.js";
4
+ import { isReviewFileForRound } from "../review-files.js";
5
+ import { reviewPresetGroupForPhase } from "../config.js";
4
6
 
5
7
  export type ReviewVerdict = "approve" | "changes" | "unknown";
6
8
 
@@ -50,8 +52,9 @@ export function hasActionableFindings(reviewContent: string): boolean {
50
52
  }
51
53
 
52
54
  function reviewsDirForPhase(taskDir: string, phase: Phase): string {
53
- if (phase === "brainstorm") return join(taskDir, "brainstorm-reviews");
54
- if (phase === "plan") return join(taskDir, "plan-reviews");
55
+ const group = reviewPresetGroupForPhase(phase);
56
+ if (group === "brainstormReviewers") return join(taskDir, "brainstorm-reviews");
57
+ if (group === "planReviewers") return join(taskDir, "plan-reviews");
55
58
  return join(taskDir, "code-reviews");
56
59
  }
57
60
 
@@ -64,9 +67,7 @@ export function reviewPassUnanimousApprove(
64
67
  if (expectedReviewerCount <= 0) return false;
65
68
  const dir = reviewsDirForPhase(taskDir, phase);
66
69
  if (!existsSync(dir)) return false;
67
- const files = readdirSync(dir).filter(
68
- (f) => f.includes(`round-${round}`) && !f.includes("final") && f.endsWith(".md"),
69
- );
70
+ const files = readdirSync(dir).filter((f) => isReviewFileForRound(f, round));
70
71
  if (files.length < expectedReviewerCount) return false;
71
72
  for (const f of files) {
72
73
  let content: string;
@@ -0,0 +1,67 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+
6
+ const openOutcome = { value: "not-handled" as "opened" | "timeout" | "not-handled" };
7
+ vi.mock("./plannotator.js", () => ({
8
+ cancelPendingPlannotatorWait: () => {},
9
+ waitForPlannotatorResult: vi.fn(async () => ({ approved: true })),
10
+ openPlannotator: vi.fn(async () => ({
11
+ opened: false,
12
+ reviewId: null,
13
+ outcome: openOutcome.value,
14
+ })),
15
+ }));
16
+
17
+ import { enterReviewCycle } from "./event-handlers.js";
18
+ import { getDefaultConfig } from "./config.js";
19
+
20
+ const tempDirs: string[] = [];
21
+ function makeTaskDir(): string {
22
+ const dir = mkdtempSync(join(tmpdir(), "pi-pi-plan-open-"));
23
+ tempDirs.push(dir);
24
+ return dir;
25
+ }
26
+
27
+ function makeOrchestrator(): any {
28
+ const dir = makeTaskDir();
29
+ mkdirSync(join(dir, "plans"), { recursive: true });
30
+ writeFileSync(join(dir, "plans", "1_synthesized.md"), "# Plan\n\n## Scope\nx\n\n## Checklist\n- [ ] a — Done when: b\n", "utf-8");
31
+ return {
32
+ active: {
33
+ dir,
34
+ type: "implement",
35
+ state: { phase: "plan", step: "llm_work", reviewCycle: null, reviewPass: 0 },
36
+ },
37
+ pi: {},
38
+ config: getDefaultConfig(),
39
+ cwd: dir,
40
+ };
41
+ }
42
+
43
+ afterEach(() => {
44
+ for (const d of tempDirs.splice(0)) rmSync(d, { recursive: true, force: true });
45
+ });
46
+
47
+ describe("enterReviewCycle plan-phase Plannotator open failure", () => {
48
+ const ctx = { ui: { setWorkingMessage: () => {}, notify: () => {} } };
49
+
50
+ it("timeout failure ends with the 'Choose another option.' sentinel so the menu loops back", async () => {
51
+ openOutcome.value = "timeout";
52
+ const orchestrator = makeOrchestrator();
53
+ const msg = await enterReviewCycle(orchestrator, ctx, "plannotator");
54
+ expect(msg).toContain("did not respond within 30s");
55
+ // handleReviewResult (pp-menu.ts) matches the exact "Choose another option."
56
+ // sentinel to keep the inline Review menu open for retry/another method.
57
+ expect(msg).toContain("Choose another option.");
58
+ });
59
+
60
+ it("not-handled failure ends with the same sentinel and a distinct diagnosis", async () => {
61
+ openOutcome.value = "not-handled";
62
+ const orchestrator = makeOrchestrator();
63
+ const msg = await enterReviewCycle(orchestrator, ctx, "plannotator");
64
+ expect(msg).toContain("no handler responded");
65
+ expect(msg).toContain("Choose another option.");
66
+ });
67
+ });
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  cancelPendingPlannotatorWait,
4
4
  openPlannotator,
5
+ openAnnotateReview,
5
6
  waitForPlannotatorResult,
6
7
  } from "./plannotator.js";
7
8
  import type { Orchestrator } from "./orchestrator.js";
@@ -79,7 +80,43 @@ describe("openPlannotator", () => {
79
80
  });
80
81
  const pi = { events } as any;
81
82
  const result = await openPlannotator(pi, "open", {});
82
- expect(result).toEqual({ opened: true, reviewId: "rev-9" });
83
+ expect(result).toEqual({ opened: true, reviewId: "rev-9", outcome: "opened" });
83
84
  expect(clearSpy).toHaveBeenCalled();
84
85
  });
86
+
87
+ it("reports the not-handled outcome when no handler claims the request", async () => {
88
+ const events = makeEvents();
89
+ events.on("plannotator:request", (req: any) => {
90
+ req.respond({ status: "not-handled" });
91
+ });
92
+ const result = await openPlannotator({ events } as any, "open", {});
93
+ expect(result).toEqual({ opened: false, reviewId: null, outcome: "not-handled" });
94
+ });
95
+
96
+ it("reports the timeout outcome when nothing responds within 30s", async () => {
97
+ vi.useFakeTimers();
98
+ const events = makeEvents();
99
+ const p = openPlannotator({ events } as any, "open", {});
100
+ await vi.advanceTimersByTimeAsync(30000 + 1);
101
+ await expect(p).resolves.toEqual({ opened: false, reviewId: null, outcome: "timeout" });
102
+ });
103
+ });
104
+
105
+ describe("openAnnotateReview", () => {
106
+ it("resolves with the synchronous annotate result", async () => {
107
+ const events = makeEvents();
108
+ events.on("plannotator:request", (req: any) => {
109
+ req.respond({ status: "handled", result: { feedback: "tighten scope" } });
110
+ });
111
+ const result = await openAnnotateReview({ events } as any, { folderPath: "/task" });
112
+ expect(result).toEqual({ opened: true, result: { feedback: "tighten scope" } });
113
+ });
114
+
115
+ it("does not hang forever when no handler responds (bounded timeout)", async () => {
116
+ vi.useFakeTimers();
117
+ const events = makeEvents();
118
+ const p = openAnnotateReview({ events } as any, { folderPath: "/task" }, 1000);
119
+ await vi.advanceTimersByTimeAsync(1001);
120
+ await expect(p).resolves.toEqual({ opened: false, result: null });
121
+ });
85
122
  });
@@ -1,16 +1,18 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { Orchestrator } from "./orchestrator.js";
3
3
 
4
+ export type PlannotatorOpenOutcome = "opened" | "timeout" | "not-handled";
5
+
4
6
  export function openPlannotator(
5
7
  pi: ExtensionAPI,
6
8
  action: string,
7
9
  payload: Record<string, unknown>,
8
- ): Promise<{ opened: boolean; reviewId: string | null }> {
10
+ ): Promise<{ opened: boolean; reviewId: string | null; outcome: PlannotatorOpenOutcome }> {
9
11
  const requestId = crypto.randomUUID();
10
12
  return new Promise((resolve) => {
11
13
  let handled = false;
12
14
  const timer = setTimeout(() => {
13
- if (!handled) resolve({ opened: false, reviewId: null });
15
+ if (!handled) resolve({ opened: false, reviewId: null, outcome: "timeout" });
14
16
  }, 30000);
15
17
  pi.events.emit("plannotator:request", {
16
18
  requestId,
@@ -20,14 +22,59 @@ export function openPlannotator(
20
22
  handled = true;
21
23
  clearTimeout(timer);
22
24
  const reviewId = response?.result?.reviewId ?? null;
23
- resolve({ opened: response.status === "handled", reviewId });
25
+ const opened = response.status === "handled";
26
+ resolve({ opened, reviewId, outcome: opened ? "opened" : "not-handled" });
24
27
  },
25
28
  });
26
29
  });
27
30
  }
28
31
 
32
+ export interface AnnotateReviewResult {
33
+ feedback: string;
34
+ exit?: boolean;
35
+ approved?: boolean;
36
+ }
37
+
29
38
  const PLANNOTATOR_RESULT_TIMEOUT_MS = 30 * 60 * 1000;
30
39
 
40
+ // The `annotate` action is a SYNCHRONOUS request/response: its handler awaits the
41
+ // full browser review and only calls respond() with the PlannotatorAnnotationResult
42
+ // once the reviewer approves / submits feedback / closes. That can take minutes, so
43
+ // unlike openPlannotator this helper does NOT wrap respond in a short (30s) ack
44
+ // timer — the result arrives via the (late) respond callback, not a
45
+ // plannotator:review-result event, so waitForPlannotatorResult must NOT be used
46
+ // here. A long review-length timeout still bounds the pathological case where
47
+ // pi-plannotator is not installed/enabled (no listener ever calls respond), so the
48
+ // /pp menu can't hang forever — it resolves as not-opened and the caller recovers.
49
+ export function openAnnotateReview(
50
+ pi: ExtensionAPI,
51
+ payload: Record<string, unknown>,
52
+ timeoutMs: number = PLANNOTATOR_RESULT_TIMEOUT_MS,
53
+ ): Promise<{ opened: boolean; result: AnnotateReviewResult | null }> {
54
+ const requestId = crypto.randomUUID();
55
+ return new Promise((resolve) => {
56
+ let settled = false;
57
+ const timer = setTimeout(() => {
58
+ if (!settled) {
59
+ settled = true;
60
+ resolve({ opened: false, result: null });
61
+ }
62
+ }, timeoutMs);
63
+ pi.events.emit("plannotator:request", {
64
+ requestId,
65
+ action: "annotate",
66
+ payload,
67
+ respond: (response: any) => {
68
+ if (settled) return;
69
+ settled = true;
70
+ clearTimeout(timer);
71
+ const opened = response?.status === "handled";
72
+ resolve({ opened, result: opened ? (response?.result ?? null) : null });
73
+ },
74
+ });
75
+ });
76
+ }
77
+
31
78
  export function cancelPendingPlannotatorWait(orchestrator: Orchestrator): void {
32
79
  if (orchestrator.plannotatorTimer) {
33
80
  clearTimeout(orchestrator.plannotatorTimer);