@ilya-lesikov/pi-pi 0.1.0 → 0.3.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.
@@ -24,9 +24,13 @@ import { spawnCodeReviewers } from "./phases/review.js";
24
24
  import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
25
25
  import { openPlannotator, waitForPlannotatorResult, cancelPendingPlannotatorWait } from "./plannotator.js";
26
26
  import { Orchestrator, deepReviewConfig, type ActiveTask } from "./orchestrator.js";
27
+ import { createCustomFooter, setFooterContext, setFooterTracker } from "./custom-footer.js";
28
+ import { createUsageTracker, dumpUsageSummary, loadUsageSummary, type UsageTracker } from "./usage-tracker.js";
27
29
  import { askUser } from "../../3p/pi-ask-user/index.js";
28
30
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
31
 
32
+ const USAGE_TRACKER_KEY = Symbol.for("pi-pi:usage-tracker");
33
+
30
34
  export async function detectDefaultBranch(pi: ExtensionAPI, cwd: string, config?: { diffBaseBranch?: string }): Promise<string> {
31
35
  if (config?.diffBaseBranch) return config.diffBaseBranch;
32
36
  try {
@@ -46,17 +50,6 @@ export async function detectDefaultBranch(pi: ExtensionAPI, cwd: string, config?
46
50
  return "main";
47
51
  }
48
52
 
49
- export async function detectRepoCwd(pi: ExtensionAPI, modifiedFiles: Set<string>, fallbackCwd: string): Promise<string> {
50
- if (modifiedFiles.size === 0) return fallbackCwd;
51
- const firstFile = [...modifiedFiles][0];
52
- const dir = resolve(firstFile, "..");
53
- try {
54
- const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: dir, timeout: 5000 });
55
- if (result.code === 0 && result.stdout.trim()) return result.stdout.trim();
56
- } catch {}
57
- return fallbackCwd;
58
- }
59
-
60
53
  export async function selectOption(ctx: any, question: string, options: string[]): Promise<string | undefined> {
61
54
  const result = await askUser(ctx, {
62
55
  question,
@@ -69,12 +62,6 @@ export async function selectOption(ctx: any, question: string, options: string[]
69
62
  return result.selections[0];
70
63
  }
71
64
 
72
- function setStep(orchestrator: Orchestrator, step: string): void {
73
- if (!orchestrator.active) return;
74
- orchestrator.active.state.step = step;
75
- saveTask(orchestrator.active.dir, orchestrator.active.state);
76
- }
77
-
78
65
  function tryCompleteReviewCycle(orchestrator: Orchestrator): void {
79
66
  if (
80
67
  !orchestrator.active?.state.reviewCycle ||
@@ -163,7 +150,7 @@ export async function enterReviewCycle(orchestrator: Orchestrator, ctx: any, kin
163
150
  orchestrator.active.state.step = "synthesize";
164
151
  saveTask(orchestrator.active.dir, orchestrator.active.state);
165
152
  const feedback = result.feedback ? `\n\nFeedback:\n${result.feedback}` : "";
166
- return `Plannotator requested changes.${feedback}\n\nUser wants to continue. Run /pp when ready to advance.`;
153
+ return `Plannotator requested changes.${feedback}\n\nAddress the user's feedback. If the feedback contains questions, answer them. If it requests changes, make the changes. Then call pp_phase_complete when done.`;
167
154
  }
168
155
 
169
156
  orchestrator.active.state.reviewCycle = null;
@@ -214,13 +201,13 @@ export async function enterReviewCycle(orchestrator: Orchestrator, ctx: any, kin
214
201
  return `Started review cycle pass ${pass} (${kind}). Awaiting reviewers.`;
215
202
  }
216
203
 
217
- export function stopTask(orchestrator: Orchestrator): string {
204
+ export async function stopTask(orchestrator: Orchestrator): Promise<string> {
218
205
  if (!orchestrator.active) return "No active task.";
219
206
  orchestrator.abortAllSubagents();
220
207
  orchestrator.active.state.phase = "done";
221
208
  saveTask(orchestrator.active.dir, orchestrator.active.state);
222
209
  const desc = orchestrator.active.description;
223
- orchestrator.cleanupActive();
210
+ await orchestrator.cleanupActive();
224
211
  const taskStore = (globalThis as any)[Symbol.for("pi-tasks:store")];
225
212
  taskStore?.clearAll?.();
226
213
  return `Task "${desc}" stopped.`;
@@ -238,128 +225,6 @@ export function finalizeReviewCycle(task: ActiveTask): void {
238
225
  saveTask(task.dir, task.state);
239
226
  }
240
227
 
241
- export async function runUserGateDialog(orchestrator: Orchestrator, ctx: any, summary: string): Promise<string> {
242
- if (!orchestrator.active) return "No active task.";
243
- if (orchestrator.userGatePending) return "A user-gate dialog is already in progress.";
244
- orchestrator.userGatePending = true;
245
-
246
- try {
247
- return await runUserGateDialogInner(orchestrator, ctx, summary);
248
- } finally {
249
- orchestrator.userGatePending = false;
250
- }
251
- }
252
-
253
- async function runUserGateDialogInner(orchestrator: Orchestrator, ctx: any, summary: string): Promise<string> {
254
- if (!orchestrator.active) return "No active task.";
255
- const pi = orchestrator.pi;
256
- const phase = orchestrator.active.state.phase;
257
- const task = orchestrator.active;
258
-
259
- if (orchestrator.spawnedAgentIds.size > 0 || orchestrator.pendingSubagentSpawns > 0) {
260
- const count = orchestrator.spawnedAgentIds.size + orchestrator.pendingSubagentSpawns;
261
- return `${count} subagent(s) still running or spawning.`;
262
- }
263
-
264
- const byKind = task.state.reviewPassByKind ?? {};
265
- const autoCount = byKind["auto"] ?? 0;
266
- const deepCount = byKind["auto-deep"] ?? 0;
267
- const autoLabel = autoCount > 0 ? `Review (pass ${autoCount + 1})` : "Review";
268
- const deepLabel = deepCount > 0 ? `Deep review (pass ${deepCount + 1})` : "Deep review";
269
-
270
- const continueMessage = "User wants to continue. Run /pp when ready to advance.";
271
-
272
- if (phase === "brainstorm" && task.type === "implement") {
273
- const choice = await selectOption(ctx, summary, ["Approve brainstorm", autoLabel, deepLabel, "Continue brainstorming", "Stop task"]);
274
- finalizeReviewCycle(task);
275
- if (choice === "Approve brainstorm") {
276
- const result = await orchestrator.transitionToNextPhase(ctx);
277
- return result.ok ? "Brainstorm approved. Transitioned to plan." : `Transition blocked: ${result.error}`;
278
- }
279
- if (choice === autoLabel) return enterReviewCycle(orchestrator, ctx, "auto");
280
- if (choice === deepLabel) return enterReviewCycle(orchestrator, ctx, "auto-deep");
281
- if (choice === "Stop task") return stopTask(orchestrator);
282
- setStep(orchestrator, "llm_work");
283
- return continueMessage;
284
- }
285
-
286
- if (phase === "brainstorm" && task.type === "brainstorm") {
287
- const choice = await selectOption(ctx, summary, ["Approve brainstorm", autoLabel, deepLabel, "Continue brainstorming", "Finish brainstorming", "Stop task"]);
288
- finalizeReviewCycle(task);
289
- if (choice === "Approve brainstorm") {
290
- const result = await orchestrator.transitionToNextPhase(ctx);
291
- return result.ok ? "Brainstorm approved. Transitioned to plan." : `Transition blocked: ${result.error}`;
292
- }
293
- if (choice === autoLabel) return enterReviewCycle(orchestrator, ctx, "auto");
294
- if (choice === deepLabel) return enterReviewCycle(orchestrator, ctx, "auto-deep");
295
- if (choice === "Finish brainstorming" || choice === "Stop task") return stopTask(orchestrator);
296
- setStep(orchestrator, "llm_work");
297
- return continueMessage;
298
- }
299
-
300
- if (phase === "debug") {
301
- const choice = await selectOption(ctx, summary, ["Approve debug", autoLabel, deepLabel, "Continue debugging", "Finish debugging", "Stop task"]);
302
- finalizeReviewCycle(task);
303
- if (choice === "Approve debug") {
304
- const result = await orchestrator.transitionToNextPhase(ctx);
305
- return result.ok ? "Debug approved. Transitioned to plan." : `Transition blocked: ${result.error}`;
306
- }
307
- if (choice === autoLabel) return enterReviewCycle(orchestrator, ctx, "auto");
308
- if (choice === deepLabel) return enterReviewCycle(orchestrator, ctx, "auto-deep");
309
- if (choice === "Finish debugging" || choice === "Stop task") return stopTask(orchestrator);
310
- setStep(orchestrator, "llm_work");
311
- return continueMessage;
312
- }
313
-
314
- if (phase === "plan") {
315
- const choice = await selectOption(ctx, summary, [
316
- "Approve plan",
317
- autoLabel,
318
- deepLabel,
319
- "Review in Plannotator",
320
- "Review on my own",
321
- "Continue planning",
322
- "Stop task",
323
- ]);
324
- finalizeReviewCycle(task);
325
- if (choice === "Approve plan") {
326
- const result = await orchestrator.transitionToNextPhase(ctx);
327
- return result.ok ? "Plan approved. Transitioned to implement." : `Transition blocked: ${result.error}`;
328
- }
329
- if (choice === autoLabel) return enterReviewCycle(orchestrator, ctx, "auto");
330
- if (choice === deepLabel) return enterReviewCycle(orchestrator, ctx, "auto-deep");
331
- if (choice === "Review in Plannotator") return enterReviewCycle(orchestrator, ctx, "plannotator");
332
- if (choice === "Stop task") return stopTask(orchestrator);
333
- setStep(orchestrator, "synthesize");
334
- return continueMessage;
335
- }
336
-
337
- if (phase === "implement") {
338
- const choice = await selectOption(ctx, summary, [
339
- "Approve implementation",
340
- autoLabel,
341
- deepLabel,
342
- "Review in Plannotator",
343
- "Review on my own",
344
- "Continue implementation",
345
- "Stop task",
346
- ]);
347
- finalizeReviewCycle(task);
348
- if (choice === "Approve implementation") {
349
- const result = await orchestrator.transitionToNextPhase(ctx);
350
- return result.ok ? "Implementation approved. Task completed." : `Transition blocked: ${result.error}`;
351
- }
352
- if (choice === autoLabel) return enterReviewCycle(orchestrator, ctx, "auto");
353
- if (choice === deepLabel) return enterReviewCycle(orchestrator, ctx, "auto-deep");
354
- if (choice === "Review in Plannotator") return enterReviewCycle(orchestrator, ctx, "plannotator");
355
- if (choice === "Stop task") return stopTask(orchestrator);
356
- setStep(orchestrator, "llm_work");
357
- return continueMessage;
358
- }
359
-
360
- return "No dialog available for this phase.";
361
- }
362
-
363
228
  function registerOrchestratorTools(orchestrator: Orchestrator): void {
364
229
  registerPhaseCompleteTool(orchestrator);
365
230
  registerCommitTool(orchestrator);
@@ -411,9 +276,11 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
411
276
  let hasNeedsChanges = false;
412
277
  for (const review of params.reviews) {
413
278
  ctx.ui?.setWorkingMessage?.(`Waiting for Plannotator review: ${review.range}…`);
279
+ const rangeBase = review.range.includes("..") ? review.range.split("..")[0] : review.range;
414
280
  const result = await openCodeReviewDirect(pi, {
415
281
  cwd: review.cwd,
416
- diffType: `range:${review.range}`,
282
+ diffType: "branch",
283
+ defaultBranch: rangeBase,
417
284
  });
418
285
  if ("error" in result) {
419
286
  results.push(`${review.cwd} (${review.range}): ${result.error}`);
@@ -434,15 +301,23 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
434
301
  saveTask(orchestrator.active.dir, orchestrator.active.state);
435
302
  }
436
303
  return {
437
- content: [{ type: "text" as const, text: `Plannotator review complete.\n\n${summary}\n\nFix the issues noted above, then call pp_phase_complete when done.` }],
304
+ content: [{ type: "text" as const, text: `Plannotator review complete.\n\n${summary}\n\nAddress the user's feedback. If the feedback contains questions, answer them. If it requests changes, make the changes. Then call pp_phase_complete when done.` }],
438
305
  details: {},
439
306
  };
440
307
  }
441
308
 
442
309
  ctx.ui?.setWorkingMessage?.("Waiting for user input…");
443
310
  try {
444
- const gateResult = await runUserGateDialog(orchestrator, ctx, `Plannotator review complete.\n\n${summary}`);
445
- return { content: [{ type: "text" as const, text: gateResult }], details: {} };
311
+ const { showActiveTaskMenu } = await import("./pp-menu.js");
312
+ const text = await showActiveTaskMenu(orchestrator, ctx, `Plannotator review complete.\n\n${summary}`, "tool");
313
+ if (orchestrator.phaseCompactionPending || orchestrator.taskDoneCompactionPending) {
314
+ await orchestrator.waitForCompaction();
315
+ return { content: [{ type: "text" as const, text: "Phase transition complete." }], details: {} };
316
+ }
317
+ if (!text) {
318
+ return { content: [{ type: "text" as const, text: "User dismissed the menu. Wait for the user's next message. When you resume work, update USER_REQUEST.md and RESEARCH.md with any new findings before calling pp_phase_complete." }], details: {} };
319
+ }
320
+ return { content: [{ type: "text" as const, text }], details: {} };
446
321
  } finally {
447
322
  ctx.ui?.setWorkingMessage?.();
448
323
  }
@@ -476,11 +351,22 @@ function registerCommitTool(orchestrator: Orchestrator): void {
476
351
  if (!orchestrator.config.autoCommit) {
477
352
  return { content: [{ type: "text" as const, text: "autoCommit is disabled in config." }], details: {} };
478
353
  }
479
- if (orchestrator.active.modifiedFiles.size === 0) {
354
+ const files: string[] = [];
355
+ try {
356
+ const gitResult = await pi.exec("git", ["diff", "--name-only"], { cwd: orchestrator.cwd, timeout: 5000 });
357
+ if (gitResult.code === 0 && gitResult.stdout.trim()) {
358
+ files.push(...gitResult.stdout.trim().split("\n").filter(Boolean));
359
+ }
360
+ const stagedResult = await pi.exec("git", ["diff", "--name-only", "--cached"], { cwd: orchestrator.cwd, timeout: 5000 });
361
+ if (stagedResult.code === 0 && stagedResult.stdout.trim()) {
362
+ for (const f of stagedResult.stdout.trim().split("\n").filter(Boolean)) {
363
+ if (!files.includes(f)) files.push(f);
364
+ }
365
+ }
366
+ } catch {}
367
+ if (files.length === 0) {
480
368
  return { content: [{ type: "text" as const, text: "No modified files to commit." }], details: {} };
481
369
  }
482
-
483
- const files = [...orchestrator.active.modifiedFiles];
484
370
  const result = autoCommit(files, params.message, orchestrator.cwd);
485
371
  if (result.ok) {
486
372
  orchestrator.active.modifiedFiles.clear();
@@ -523,10 +409,11 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
523
409
  const { showActiveTaskMenu } = await import("./pp-menu.js");
524
410
  const text = await showActiveTaskMenu(orchestrator, ctx, params.summary, "tool");
525
411
  if (orchestrator.phaseCompactionPending || orchestrator.taskDoneCompactionPending) {
526
- return { content: [{ type: "text" as const, text: "Phase transition in progress." }], details: {} };
412
+ await orchestrator.waitForCompaction();
413
+ return { content: [{ type: "text" as const, text: "Phase transition complete." }], details: {} };
527
414
  }
528
415
  if (!text) {
529
- return { content: [{ type: "text" as const, text: "No action selected." }], details: {} };
416
+ return { content: [{ type: "text" as const, text: "User dismissed the menu. Wait for the user's next message. When you resume work, update USER_REQUEST.md and RESEARCH.md with any new findings before calling pp_phase_complete." }], details: {} };
530
417
  }
531
418
  return { content: [{ type: "text" as const, text }], details: {} };
532
419
  } finally {
@@ -539,6 +426,10 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
539
426
  export function registerEventHandlers(orchestrator: Orchestrator): void {
540
427
  const pi = orchestrator.pi;
541
428
 
429
+ function getUsageTracker(): UsageTracker | undefined {
430
+ return (globalThis as any)[USAGE_TRACKER_KEY] as UsageTracker | undefined;
431
+ }
432
+
542
433
  function trackSubagentEvent(data: any, event: "created" | "started" | "first_tool" | "first_turn" | "completed" | "failed"): void {
543
434
  if (!orchestrator.active || !data?.id) return;
544
435
  const now = Date.now();
@@ -709,7 +600,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
709
600
  }
710
601
 
711
602
  if (choice === "Stop task") {
712
- pi.sendUserMessage(`[PI-PI] ${stopTask(orchestrator)}`, { deliverAs: "followUp" });
603
+ pi.sendUserMessage(`[PI-PI] ${await stopTask(orchestrator)}`, { deliverAs: "followUp" });
713
604
  return;
714
605
  }
715
606
 
@@ -821,7 +712,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
821
712
  }
822
713
 
823
714
  if (choice === "Stop task") {
824
- pi.sendUserMessage(`[PI-PI] ${stopTask(orchestrator)}`, { deliverAs: "followUp" });
715
+ pi.sendUserMessage(`[PI-PI] ${await stopTask(orchestrator)}`, { deliverAs: "followUp" });
825
716
  return;
826
717
  }
827
718
 
@@ -848,6 +739,18 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
848
739
  }
849
740
 
850
741
  pi.events.on("subagents:completed", (data: any) => {
742
+ const usageTracker = getUsageTracker();
743
+ if (usageTracker && data?.tokens) {
744
+ usageTracker.recordSubagentCompletion(data.tokens, undefined, {
745
+ description: data.description || data.type || data.id || "unknown",
746
+ agentType: data.type || "unknown",
747
+ modelId: data.modelId || "unknown",
748
+ durationMs: data.durationMs,
749
+ toolUses: data.toolUses,
750
+ });
751
+ (orchestrator.lastCtx?.ui as any)?.requestRender?.();
752
+ }
753
+
851
754
  if (!orchestrator.active || !data?.id) return;
852
755
  trackSubagentEvent(data, "completed");
853
756
  orchestrator.spawnedAgentIds.delete(data.id);
@@ -916,10 +819,36 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
916
819
  await orchestrator.cleanupActive();
917
820
  });
918
821
 
822
+ pi.on("session_shutdown", async (_event, ctx) => {
823
+ if ((globalThis as any)[SUBAGENT_SESSION_KEY]) return;
824
+ const tracker = getUsageTracker();
825
+ if (!tracker) return;
826
+ const sessionId = ctx.sessionManager?.getSessionId?.() || `session-${Date.now()}`;
827
+ try {
828
+ dumpUsageSummary(tracker, sessionId);
829
+ } catch (err: any) {
830
+ console.error(`[pi-pi] Failed to dump usage summary: ${err.message}`);
831
+ }
832
+ delete (globalThis as any)[USAGE_TRACKER_KEY];
833
+ });
834
+
919
835
  pi.on("session_start", async (_event, ctx) => {
920
836
  orchestrator.lastCtx = ctx;
921
837
  orchestrator.cwd = ctx.cwd;
922
838
 
839
+ if (!(globalThis as any)[SUBAGENT_SESSION_KEY]) {
840
+ const tracker = createUsageTracker();
841
+ const sessionId = ctx.sessionManager?.getSessionId?.() || "";
842
+ if (sessionId) {
843
+ const previous = loadUsageSummary(sessionId);
844
+ if (previous) tracker.loadFromSummary(previous);
845
+ }
846
+ (globalThis as any)[USAGE_TRACKER_KEY] = tracker;
847
+ setFooterContext(ctx);
848
+ setFooterTracker(tracker);
849
+ ctx.ui.setFooter(createCustomFooter);
850
+ }
851
+
923
852
  const subagentsMgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
924
853
  subagentsMgr?.refreshWidget?.(ctx.ui);
925
854
  const taskStore = (globalThis as any)[Symbol.for("pi-tasks:store")];
@@ -1196,6 +1125,20 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1196
1125
  });
1197
1126
 
1198
1127
  pi.on("turn_end", async (event, ctx) => {
1128
+ const msg = event.message as any;
1129
+ const usageTracker = getUsageTracker();
1130
+ if (usageTracker && msg?.usage) {
1131
+ const input = typeof msg.usage.input === "number" ? msg.usage.input : 0;
1132
+ const output = typeof msg.usage.output === "number" ? msg.usage.output : 0;
1133
+ const cacheRead = typeof msg.usage.cacheRead === "number" ? msg.usage.cacheRead : 0;
1134
+ const cacheWrite = typeof msg.usage.cacheWrite === "number" ? msg.usage.cacheWrite : 0;
1135
+ const cost = typeof msg.usage.cost?.total === "number" ? msg.usage.cost.total : 0;
1136
+ const modelId = (typeof msg.model === "string" && msg.model) || ctx.model?.id || "unknown-model";
1137
+ const provider = (typeof msg.provider === "string" && msg.provider) || ctx.model?.provider || "unknown";
1138
+ usageTracker.recordTurn(modelId, provider, input, output, cacheRead, cacheWrite, cost);
1139
+ (ctx.ui as any)?.requestRender?.();
1140
+ }
1141
+
1199
1142
  if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
1200
1143
  orchestrator.updateStatus(ctx);
1201
1144
 
@@ -1218,7 +1161,6 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1218
1161
 
1219
1162
  const phase = orchestrator.active.state.phase;
1220
1163
 
1221
- const msg = event.message as any;
1222
1164
  if (msg?.stopReason === "aborted") return;
1223
1165
  if (msg?.stopReason === "error") {
1224
1166
  const errorMsg = msg.errorMessage || "unknown error";
@@ -1323,6 +1265,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1323
1265
  }
1324
1266
 
1325
1267
  if (orchestrator.active.type === "brainstorm" && phase === "brainstorm") return;
1268
+ if (orchestrator.active.type === "review" && phase === "review") return;
1326
1269
 
1327
1270
  const contentParts = Array.isArray(msg?.content) ? msg.content : [];
1328
1271
  const hasText = contentParts.some((c: any) => c.type === "text" && c.text?.trim());
@@ -1331,7 +1274,9 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1331
1274
  const turnWasEmpty = !hasText && !hasToolCalls && !hasToolResults;
1332
1275
 
1333
1276
  if (!turnWasEmpty) {
1334
- if (hasText && !hasToolCalls) {
1277
+ const lastPart = contentParts.length > 0 ? contentParts[contentParts.length - 1] : null;
1278
+ const endsWithText = lastPart?.type === "text" && lastPart?.text?.trim();
1279
+ if (hasText && (!hasToolCalls || endsWithText)) {
1335
1280
  const step = orchestrator.active.state.step;
1336
1281
  if (step !== "await_planners" && step !== "await_reviewers") {
1337
1282
  pi.sendMessage(
@@ -11,6 +11,8 @@ export interface OpenRouterModelData {
11
11
  pricing: {
12
12
  prompt: number;
13
13
  completion: number;
14
+ cacheRead: number;
15
+ cacheWrite: number;
14
16
  };
15
17
  modality: string;
16
18
  }
@@ -241,6 +243,8 @@ export async function fetchOpenRouterMetadata(modelIds: string[]): Promise<Recor
241
243
  pricing: {
242
244
  prompt: toNumber(pricing.prompt, 0),
243
245
  completion: toNumber(pricing.completion, 0),
246
+ cacheRead: toNumber(pricing.input_cache_read, 0),
247
+ cacheWrite: toNumber(pricing.input_cache_write, 0),
244
248
  },
245
249
  modality: typeof architecture.modality === "string" ? architecture.modality : "text",
246
250
  };
@@ -267,8 +271,8 @@ function buildProviderModelConfig(
267
271
  cost: {
268
272
  input: toNumber(modelMeta?.pricing.prompt, 0) * 1_000_000,
269
273
  output: toNumber(modelMeta?.pricing.completion, 0) * 1_000_000,
270
- cacheRead: 0,
271
- cacheWrite: 0,
274
+ cacheRead: toNumber(modelMeta?.pricing.cacheRead, 0) * 1_000_000,
275
+ cacheWrite: toNumber(modelMeta?.pricing.cacheWrite, 0) * 1_000_000,
272
276
  },
273
277
  contextWindow: toNumber(modelMeta?.context_length, 200000),
274
278
  maxTokens: toNumber(modelMeta?.max_completion_tokens, 32000),
@@ -358,6 +362,7 @@ export function generateFlantConfig(models: string[]): Partial<PiPiConfig> {
358
362
  implement: { model: modelSpec(implementModel), thinking: "high" },
359
363
  debug: { model: modelSpec(debugModel), thinking: "high" },
360
364
  brainstorm: { model: modelSpec(brainstormModel), thinking: "high" },
365
+ review: { model: modelSpec(implementModel), thinking: "high" },
361
366
  },
362
367
  planners: {
363
368
  opus: makeVariant(latestOpus, fallback),
@@ -4,7 +4,7 @@ import { join } from "path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { Orchestrator } from "./orchestrator.js";
6
6
  import { registerCommandHandlers } from "./command-handlers.js";
7
- import { registerEventHandlers, runUserGateDialog } from "./event-handlers.js";
7
+ import { registerEventHandlers } from "./event-handlers.js";
8
8
  import { createTask, loadTask, saveTask } from "./state.js";
9
9
 
10
10
  vi.mock("./cbm.js", () => ({ registerCbmTools: vi.fn() }));
@@ -25,6 +25,7 @@ vi.mock("./config.js", async (importOriginal) => {
25
25
  implement: { model: "test/model", thinking: "high" },
26
26
  debug: { model: "test/model", thinking: "high" },
27
27
  brainstorm: { model: "test/model", thinking: "high" },
28
+ review: { model: "test/model", thinking: "high" },
28
29
  },
29
30
  planners: { test: { enabled: true, model: "test/planner", thinking: "low" } },
30
31
  planReviewers: {},
@@ -142,6 +143,7 @@ function makeConfig() {
142
143
  implement: { model: "test/model", thinking: "high" },
143
144
  debug: { model: "test/model", thinking: "high" },
144
145
  brainstorm: { model: "test/model", thinking: "high" },
146
+ review: { model: "test/model", thinking: "high" },
145
147
  },
146
148
  planners: {
147
149
  test: { enabled: true, model: "test/planner", thinking: "low" },
@@ -98,6 +98,7 @@ describe("deepReviewConfig", () => {
98
98
  implement: { model: "a/impl", thinking: "high" },
99
99
  debug: { model: "a/debug", thinking: "high" },
100
100
  brainstorm: { model: "a/brain", thinking: "high" },
101
+ review: { model: "a/review", thinking: "high" },
101
102
  },
102
103
  planners: {},
103
104
  planReviewers: {
@@ -16,7 +16,8 @@ import { loadContextFiles, getPhaseArtifacts, getLatestSynthesizedPlan } from ".
16
16
  import { brainstormSystemPrompt } from "./phases/brainstorm.js";
17
17
  import { planningSystemPrompt, spawnPlanners } from "./phases/planning.js";
18
18
  import { implementationSystemPrompt } from "./phases/implementation.js";
19
- import { reviewSystemPrompt } from "./phases/review.js";
19
+ import { reviewSystemPrompt as reviewCycleSystemPrompt } from "./phases/review.js";
20
+ import { reviewSystemPrompt as reviewTaskSystemPrompt } from "./phases/review-task.js";
20
21
  import { registerAgentDefinitions, unregisterAgentDefinitions } from "./agents/registry.js";
21
22
  import { createExploreAgent } from "./agents/explore.js";
22
23
  import { createLibrarianAgent } from "./agents/librarian.js";
@@ -207,7 +208,7 @@ export class Orchestrator {
207
208
  if (this.active.state.reviewCycle?.step === "apply_feedback") {
208
209
  const pass = this.active.state.reviewCycle.pass;
209
210
  const manualReview = this.active.state.reviewCycle.kind === "manual";
210
- return reviewSystemPrompt(this.active.dir, pass, manualReview, this.active.state.phase);
211
+ return reviewCycleSystemPrompt(this.active.dir, pass, manualReview, this.active.state.phase);
211
212
  }
212
213
 
213
214
  switch (this.active.state.phase) {
@@ -219,6 +220,8 @@ export class Orchestrator {
219
220
  return planningSystemPrompt(this.active.dir);
220
221
  case "implement":
221
222
  return implementationSystemPrompt(this.active.dir);
223
+ case "review":
224
+ return reviewTaskSystemPrompt(this.active.dir);
222
225
  default:
223
226
  return "";
224
227
  }
@@ -314,7 +317,12 @@ export class Orchestrator {
314
317
  description: state.description,
315
318
  };
316
319
 
317
- const modelConfig = this.config.mainModel[type === "debug" ? "debug" : type === "brainstorm" ? "brainstorm" : "implement"];
320
+ const modelConfig = this.config.mainModel[
321
+ type === "debug" ? "debug"
322
+ : type === "brainstorm" ? "brainstorm"
323
+ : type === "review" ? "review"
324
+ : "implement"
325
+ ];
318
326
  const modelOk = await this.switchModel(ctx, modelConfig.model, modelConfig.thinking);
319
327
  if (!modelOk) {
320
328
  ctx.ui.notify(`Model "${modelConfig.model}" not found — using current model`, "warning");
@@ -328,7 +336,7 @@ export class Orchestrator {
328
336
  this.injectContextAndArtifacts(this.active.dir, this.active.state.phase);
329
337
 
330
338
  this.phaseStartTime = Date.now();
331
- const isGenericDescription = ["implement", "debug", "brainstorm"].includes(this.active.description);
339
+ const isGenericDescription = ["implement", "debug", "brainstorm", "review"].includes(this.active.description);
332
340
  const hasInheritedTaskContext = Boolean(fromTaskDir && type === "implement");
333
341
  const isWaitingForPlanners = this.active.state.phase === "plan" && this.active.state.step === "await_planners";
334
342
  if (isGenericDescription && !hasInheritedTaskContext) {
@@ -450,6 +458,13 @@ export class Orchestrator {
450
458
  }
451
459
  }
452
460
 
461
+ waitForCompaction(): Promise<void> {
462
+ if (!this.phaseCompactionPending && !this.taskDoneCompactionPending) return Promise.resolve();
463
+ return new Promise((resolve) => {
464
+ this.phaseCompactionResolve = resolve;
465
+ });
466
+ }
467
+
453
468
  compactAndTransition(ctx: ExtensionContext, taskDir: string, phase: Phase): void {
454
469
  this.phaseCompactionPending = true;
455
470
  ctx.compact({
@@ -461,6 +476,10 @@ export class Orchestrator {
461
476
  this.phaseCompactionResolve = null;
462
477
  }
463
478
  this.phaseStartTime = Date.now();
479
+ if (this.active && (phase === "plan" || phase === "implement")) {
480
+ const modelConfig = this.config.mainModel.implement;
481
+ this.switchModel(ctx, modelConfig.model, modelConfig.thinking).catch(() => {});
482
+ }
464
483
  this.injectContextAndArtifacts(taskDir, phase);
465
484
  if (this.active?.state.phase === "plan" && this.active.state.step === "await_planners") {
466
485
  ctx.ui.notify("Entered plan phase. Waiting for planners to complete before synthesis.", "info");
@@ -476,6 +495,10 @@ export class Orchestrator {
476
495
  this.phaseCompactionResolve = null;
477
496
  }
478
497
  this.phaseStartTime = Date.now();
498
+ if (this.active && (phase === "plan" || phase === "implement")) {
499
+ const modelConfig = this.config.mainModel.implement;
500
+ this.switchModel(ctx, modelConfig.model, modelConfig.thinking).catch(() => {});
501
+ }
479
502
  this.injectContextAndArtifacts(taskDir, phase);
480
503
  if (this.active?.state.phase === "plan" && this.active.state.step === "await_planners") {
481
504
  ctx.ui.notify("Entered plan phase. Waiting for planners to complete before synthesis.", "info");
@@ -67,6 +67,11 @@ const TRANSITIONS: Record<TaskType, Record<string, string[]>> = {
67
67
  plan: ["implement"],
68
68
  implement: ["done"],
69
69
  },
70
+ review: {
71
+ review: ["plan"],
72
+ plan: ["implement"],
73
+ implement: ["done"],
74
+ },
70
75
  };
71
76
 
72
77
  export function canTransition(taskType: TaskType, from: Phase, to: Phase): boolean {
@@ -116,6 +121,37 @@ export function validateExitCriteria(
116
121
  return { ok: true };
117
122
  }
118
123
 
124
+ case "review": {
125
+ const ur = join(taskDir, "USER_REQUEST.md");
126
+ const res = join(taskDir, "RESEARCH.md");
127
+ if (isMissingOrEmpty(ur)) {
128
+ return { ok: false, reason: "USER_REQUEST.md does not exist or is empty" };
129
+ }
130
+ if (isMissingOrEmpty(res)) {
131
+ return { ok: false, reason: "RESEARCH.md does not exist or is empty" };
132
+ }
133
+ const urContent = readFileSync(ur, "utf-8");
134
+ const resContent = readFileSync(res, "utf-8");
135
+
136
+ const userRequestValidation = validateUserRequest(urContent);
137
+ if (!userRequestValidation.ok) {
138
+ return {
139
+ ok: false,
140
+ reason: formatValidationErrors("USER_REQUEST.md", userRequestValidation.errors, USER_REQUEST_TEMPLATE),
141
+ };
142
+ }
143
+
144
+ const researchValidation = validateResearch(resContent);
145
+ if (!researchValidation.ok) {
146
+ return {
147
+ ok: false,
148
+ reason: formatValidationErrors("RESEARCH.md", researchValidation.errors, RESEARCH_TEMPLATE),
149
+ };
150
+ }
151
+
152
+ return { ok: true };
153
+ }
154
+
119
155
  case "plan": {
120
156
  const plan = getLatestSynthesizedPlan(taskDir);
121
157
  if (!plan) {
@@ -205,5 +241,7 @@ export function phasePipeline(taskType: TaskType): Phase[] {
205
241
  return ["debug", "plan", "implement", "done"];
206
242
  case "brainstorm":
207
243
  return ["brainstorm", "plan", "implement", "done"];
244
+ case "review":
245
+ return ["review", "plan", "implement", "done"];
208
246
  }
209
247
  }
@@ -0,0 +1,32 @@
1
+ export function reviewSystemPrompt(taskDir: string): string {
2
+ return [
3
+ "[PI-PI — REVIEW PHASE]",
4
+ "",
5
+ "You are reviewing code changes. USER_REQUEST.md describes what to review.",
6
+ "Read it first to understand the scope.",
7
+ "",
8
+ "Use available tools to analyze the changes:",
9
+ "- git diff, git log, git show for examining commits and diffs",
10
+ "- read, lsp, grep, find for understanding the code",
11
+ "- gh pr view for GitHub PR context if a PR URL is mentioned",
12
+ "",
13
+ "Write your findings:",
14
+ `- ${taskDir}/USER_REQUEST.md — update with a clear problem statement of issues found`,
15
+ `- ${taskDir}/RESEARCH.md — detailed technical analysis`,
16
+ "",
17
+ "USER_REQUEST.md format:",
18
+ "- # User Request",
19
+ "- ## Problem",
20
+ "- ## Constraints",
21
+ "",
22
+ "RESEARCH.md format:",
23
+ "- ## Affected Code",
24
+ "- ## Architecture Context",
25
+ "- ## Constraints & Edge Cases",
26
+ "- ## Open Questions (optional)",
27
+ "",
28
+ "Focus on: correctness, edge cases, style consistency, missing tests, potential bugs.",
29
+ "",
30
+ "When complete, call pp_phase_complete with a brief summary of findings.",
31
+ ].join("\n");
32
+ }