@ilya-lesikov/pi-pi 0.1.0 → 0.2.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,4 +1,4 @@
1
- import { existsSync, readdirSync } from "fs";
1
+ import { existsSync, readdirSync, writeFileSync } from "fs";
2
2
  import { join, relative } from "path";
3
3
  import { askUser } from "../../3p/pi-ask-user/index.js";
4
4
  import { unregisterAgentDefinitions } from "./agents/registry.js";
@@ -8,12 +8,13 @@ import {
8
8
  loadCodeReviewOutputs,
9
9
  loadPlanReviewOutputs,
10
10
  } from "./context.js";
11
- import { enterReviewCycle, finalizeReviewCycle } from "./event-handlers.js";
11
+ import { detectDefaultBranch, enterReviewCycle, finalizeReviewCycle } from "./event-handlers.js";
12
12
  import { Orchestrator, deepReviewConfig } from "./orchestrator.js";
13
13
  import { cancelPendingPlannotatorWait } from "./plannotator.js";
14
14
  import { spawnPlanners, spawnPlanReviewers } from "./phases/planning.js";
15
15
  import { spawnCodeReviewers } from "./phases/review.js";
16
16
  import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
17
+
17
18
  import {
18
19
  listTasks,
19
20
  loadTask,
@@ -72,6 +73,38 @@ function showStatus(orchestrator: Orchestrator, ctx: any): void {
72
73
  );
73
74
  }
74
75
 
76
+ async function abortCurrentWork(orchestrator: Orchestrator, ctx: any): Promise<void> {
77
+ orchestrator.abortAllSubagents();
78
+ ctx.abort?.();
79
+ await ctx.waitForIdle?.();
80
+ const taskStore = (globalThis as any)[Symbol.for("pi-tasks:store")];
81
+ taskStore?.clearAll?.();
82
+ taskStore?.refreshWidget?.(ctx.ui);
83
+ }
84
+
85
+ async function pauseTask(orchestrator: Orchestrator, ctx: any): Promise<string> {
86
+ if (!orchestrator.active) return "No active task.";
87
+
88
+ cancelPendingPlannotatorWait(orchestrator);
89
+ orchestrator.abortAllSubagents();
90
+ ctx.abort?.();
91
+ await ctx.waitForIdle?.();
92
+
93
+ const name = orchestrator.active.description;
94
+
95
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
96
+ unregisterAgentDefinitions(orchestrator.pi);
97
+ await orchestrator.cleanupActive();
98
+
99
+ const taskStore = (globalThis as any)[Symbol.for("pi-tasks:store")];
100
+ taskStore?.clearAll?.();
101
+ taskStore?.refreshWidget?.(ctx.ui);
102
+
103
+ orchestrator.updateStatus(ctx);
104
+ ctx.ui.notify(`Task "${name}" paused. Use /pp → Resume to continue.`, "info");
105
+ return `Task "${name}" paused.`;
106
+ }
107
+
75
108
  async function finishTask(orchestrator: Orchestrator, ctx: any): Promise<string> {
76
109
  if (!orchestrator.active) return "No active task.";
77
110
 
@@ -162,7 +195,12 @@ export async function resumeTask(
162
195
  description: task.state.description,
163
196
  };
164
197
 
165
- const modelConfig = orchestrator.config.mainModel[task.type === "debug" ? "debug" : task.type === "brainstorm" ? "brainstorm" : "implement"];
198
+ const modelConfig = orchestrator.config.mainModel[
199
+ task.type === "debug" ? "debug"
200
+ : task.type === "brainstorm" ? "brainstorm"
201
+ : task.type === "review" ? "review"
202
+ : "implement"
203
+ ];
166
204
  const modelOk = await orchestrator.switchModel(ctx, modelConfig.model, modelConfig.thinking);
167
205
  if (!modelOk) {
168
206
  ctx.ui.notify(`Model "${modelConfig.model}" not found — using current model`, "warning");
@@ -401,6 +439,7 @@ function collectRoleAssignments(config: Partial<PiPiConfig> | null): string[] {
401
439
  add("mainModel.implement", config.mainModel?.implement?.model);
402
440
  add("mainModel.debug", config.mainModel?.debug?.model);
403
441
  add("mainModel.brainstorm", config.mainModel?.brainstorm?.model);
442
+ add("mainModel.review", config.mainModel?.review?.model);
404
443
 
405
444
  for (const [name, variant] of Object.entries(config.planners ?? {})) {
406
445
  if (variant.enabled) add(`planners.${name}`, variant.model);
@@ -500,7 +539,14 @@ async function showFlantInfraMenu(orchestrator: Orchestrator, ctx: any): Promise
500
539
  }
501
540
 
502
541
  if (choice.startsWith("Cache period:")) {
503
- const selected = await selectOption(ctx, "Cache period", ["1 day", "3 days", "7 days", "14 days", "30 days", "Back"]);
542
+ const selected = await selectOption(ctx, "Cache period", [
543
+ { title: "1 day", description: "Refresh model metadata daily" },
544
+ { title: "3 days", description: "Refresh model metadata every three days" },
545
+ { title: "7 days", description: "Default — refresh weekly" },
546
+ { title: "14 days", description: "Refresh model metadata every two weeks" },
547
+ { title: "30 days", description: "Refresh model metadata monthly" },
548
+ { title: "Back", description: "Return to the previous menu" },
549
+ ]);
504
550
  if (!selected || selected === "Back") continue;
505
551
  const days = Number(selected.split(" ")[0]);
506
552
  if (!Number.isFinite(days) || days <= 0) continue;
@@ -520,13 +566,158 @@ async function showFlantInfraMenu(orchestrator: Orchestrator, ctx: any): Promise
520
566
  }
521
567
  }
522
568
 
523
- async function showSettingsMenu(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
569
+ function formatTokenCount(count: number): string {
570
+ if (count < 1000) return String(count);
571
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
572
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
573
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
574
+ return `${Math.round(count / 1000000)}M`;
575
+ }
576
+
577
+ function formatDuration(ms: number): string {
578
+ const sec = Math.floor(ms / 1000);
579
+ if (sec < 60) return `${sec}s`;
580
+ const min = Math.floor(sec / 60);
581
+ const remSec = sec % 60;
582
+ if (min < 60) return remSec > 0 ? `${min}m ${remSec}s` : `${min}m`;
583
+ const hr = Math.floor(min / 60);
584
+ const remMin = min % 60;
585
+ return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
586
+ }
587
+
588
+ function showUsage(ctx: any): void {
589
+ const tracker = (globalThis as any)[Symbol.for("pi-pi:usage-tracker")] as
590
+ | {
591
+ getTotalInputTokens(): number; getTotalOutputTokens(): number;
592
+ getTotalCacheReadTokens(): number; getTotalCacheWriteTokens(): number;
593
+ getTotalCost(): number; getCacheHitRate(): number;
594
+ getPerModelUsage(): Record<string, { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; turns: number }>;
595
+ getSubagentList(): Array<{ description: string; modelId: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; cost: number; durationMs: number; toolUses: number }>;
596
+ }
597
+ | undefined;
598
+
599
+ if (!tracker) {
600
+ ctx.ui.notify("No usage data available.", "info");
601
+ return;
602
+ }
603
+
604
+ const mainInput = tracker.getTotalInputTokens();
605
+ const mainOutput = tracker.getTotalOutputTokens();
606
+ const mainCacheRead = tracker.getTotalCacheReadTokens();
607
+ const mainCacheWrite = tracker.getTotalCacheWriteTokens();
608
+ const mainCost = tracker.getTotalCost();
609
+ const models = tracker.getPerModelUsage();
610
+ const subagents = tracker.getSubagentList();
611
+
612
+ let saInput = 0, saOutput = 0, saCacheRead = 0, saCacheWrite = 0, saCost = 0;
613
+ for (const sa of subagents) {
614
+ saInput += sa.inputTokens;
615
+ saOutput += sa.outputTokens;
616
+ saCacheRead += sa.cacheReadTokens;
617
+ saCacheWrite += sa.cacheWriteTokens;
618
+ saCost += sa.cost;
619
+ }
620
+
621
+ const totalInput = mainInput + saInput;
622
+ const totalOutput = mainOutput + saOutput;
623
+ const totalCacheRead = mainCacheRead + saCacheRead;
624
+ const totalCost = mainCost + saCost;
625
+ const totalCacheRate = (totalCacheRead + totalInput) > 0
626
+ ? totalCacheRead / (totalCacheRead + totalInput)
627
+ : 0;
628
+
629
+ const byModel = new Map<string, { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }>();
630
+ for (const [modelId, usage] of Object.entries(models)) {
631
+ byModel.set(modelId, {
632
+ input: usage.inputTokens, output: usage.outputTokens,
633
+ cacheRead: usage.cacheReadTokens, cacheWrite: usage.cacheWriteTokens, cost: 0,
634
+ });
635
+ }
636
+ if (mainCost > 0 && Object.keys(models).length === 1) {
637
+ const entry = byModel.values().next().value;
638
+ if (entry) entry.cost = mainCost;
639
+ }
640
+ for (const sa of subagents) {
641
+ const key = sa.modelId !== "unknown" ? sa.modelId : sa.description;
642
+ const existing = byModel.get(key);
643
+ if (existing) {
644
+ existing.input += sa.inputTokens;
645
+ existing.output += sa.outputTokens;
646
+ existing.cacheRead += sa.cacheReadTokens;
647
+ existing.cacheWrite += sa.cacheWriteTokens;
648
+ existing.cost += sa.cost;
649
+ } else {
650
+ byModel.set(key, {
651
+ input: sa.inputTokens, output: sa.outputTokens,
652
+ cacheRead: sa.cacheReadTokens, cacheWrite: sa.cacheWriteTokens, cost: sa.cost,
653
+ });
654
+ }
655
+ }
656
+
657
+ const lines: string[] = ["Session usage (total):"];
658
+ lines.push(` Input: ${formatTokenCount(totalInput)} tokens`);
659
+ lines.push(` Output: ${formatTokenCount(totalOutput)} tokens`);
660
+ if (totalCacheRead > 0) lines.push(` Cache: ⚡${Math.round(totalCacheRate * 100)}% hit rate`);
661
+ if (totalCost > 0) lines.push(` Cost: $${totalCost.toFixed(3)}`);
662
+
663
+ if (byModel.size > 0) {
664
+ lines.push("");
665
+ lines.push("By model:");
666
+ for (const [modelId, m] of byModel) {
667
+ const cr = (m.cacheRead + m.input) > 0 ? Math.round(m.cacheRead / (m.cacheRead + m.input) * 100) : 0;
668
+ const parts = [`↑${formatTokenCount(m.input)}`, `↓${formatTokenCount(m.output)}`];
669
+ if (cr > 0) parts.push(`⚡${cr}%`);
670
+ if (m.cost > 0) parts.push(`$${m.cost.toFixed(3)}`);
671
+ lines.push(` ${modelId}: ${parts.join(" ")}`);
672
+ }
673
+ }
674
+
675
+ lines.push("");
676
+ lines.push("By agent:");
677
+ const mainModelEntries = Object.entries(models);
678
+ if (mainModelEntries.length > 0) {
679
+ const mainParts = [`↑${formatTokenCount(mainInput)}`, `↓${formatTokenCount(mainOutput)}`];
680
+ const mainCR = (mainCacheRead + mainInput) > 0 ? Math.round(mainCacheRead / (mainCacheRead + mainInput) * 100) : 0;
681
+ if (mainCR > 0) mainParts.push(`⚡${mainCR}%`);
682
+ if (mainCost > 0) mainParts.push(`$${mainCost.toFixed(3)}`);
683
+ const mainModel = mainModelEntries.map(([id]) => id).join(", ");
684
+ lines.push(` Main (${mainModel}): ${mainParts.join(" ")}`);
685
+ }
686
+ for (const sa of subagents) {
687
+ const saCR = (sa.cacheReadTokens + sa.inputTokens) > 0
688
+ ? Math.round(sa.cacheReadTokens / (sa.cacheReadTokens + sa.inputTokens) * 100) : 0;
689
+ const parts = [`↑${formatTokenCount(sa.inputTokens)}`, `↓${formatTokenCount(sa.outputTokens)}`];
690
+ if (saCR > 0) parts.push(`⚡${saCR}%`);
691
+ if (sa.cost > 0) parts.push(`$${sa.cost.toFixed(3)}`);
692
+ if (sa.durationMs > 0) parts.push(formatDuration(sa.durationMs));
693
+ if (sa.toolUses > 0) parts.push(`${sa.toolUses} tools`);
694
+ lines.push(` ${sa.description}: ${parts.join(" ")}`);
695
+ }
696
+
697
+ ctx.ui.notify(lines.join("\n"), "info");
698
+ }
699
+
700
+ async function showSettingsMenu(orchestrator: Orchestrator, ctx: any, showFlant = true): Promise<typeof BACK> {
524
701
  while (true) {
525
- const choice = await selectOption(ctx, "Settings", [
526
- { title: "Flant AI Infrastructure", description: "Configure corporate AI model provider" },
527
- { title: "Back", description: "Return to the previous menu" },
528
- ]);
702
+ const options: OptionInput[] = [
703
+ { title: "Usage", description: "Show session token usage and cost breakdown" },
704
+ { title: "LSP", description: "Language server status and controls" },
705
+ ];
706
+ if (showFlant) {
707
+ options.push({ title: "Flant AI Infrastructure", description: "Configure corporate AI model provider" });
708
+ }
709
+ options.push({ title: "Back", description: "Return to the previous menu" });
710
+
711
+ const choice = await selectOption(ctx, "Settings", options);
529
712
  if (!choice || choice === "Back") return BACK;
713
+ if (choice === "Usage") {
714
+ showUsage(ctx);
715
+ continue;
716
+ }
717
+ if (choice === "LSP") {
718
+ await showLspMenu(ctx);
719
+ continue;
720
+ }
530
721
  await showFlantInfraMenu(orchestrator, ctx);
531
722
  }
532
723
  }
@@ -643,6 +834,148 @@ async function showImplementMenu(orchestrator: Orchestrator, ctx: any): Promise<
643
834
  }
644
835
  }
645
836
 
837
+ function buildPrContext(parsed: any): { prUrl: string | null; prContext: string | null } {
838
+ const title = typeof parsed?.title === "string" ? parsed.title.trim() : "";
839
+ const body = typeof parsed?.body === "string" ? parsed.body.trim() : "";
840
+ const commentsRaw = Array.isArray(parsed?.comments)
841
+ ? parsed.comments
842
+ : Array.isArray(parsed?.comments?.nodes)
843
+ ? parsed.comments.nodes
844
+ : [];
845
+ const comments: string[] = commentsRaw
846
+ .map((comment: any) => {
847
+ const text = typeof comment?.body === "string"
848
+ ? comment.body.trim()
849
+ : typeof comment?.bodyText === "string"
850
+ ? comment.bodyText.trim()
851
+ : "";
852
+ return text;
853
+ })
854
+ .filter((text: string): text is string => text.length > 0);
855
+
856
+ const parts: string[] = [];
857
+ if (title) parts.push(`Title: ${title}`);
858
+ if (body) parts.push(`Body:\n${body}`);
859
+ if (comments.length > 0) {
860
+ parts.push(`Comments:\n${comments.map((comment, index) => `${index + 1}. ${comment}`).join("\n\n")}`);
861
+ }
862
+
863
+ const prUrl = typeof parsed?.url === "string" && parsed.url.trim().length > 0 ? parsed.url.trim() : null;
864
+ return { prUrl, prContext: parts.length > 0 ? parts.join("\n\n") : null };
865
+ }
866
+
867
+ async function detectCurrentPrContext(orchestrator: Orchestrator): Promise<{ prUrl: string | null; prContext: string | null }> {
868
+ try {
869
+ const prResult = await orchestrator.pi.exec("gh", ["pr", "view", "--json", "url,title,body,comments"], {
870
+ cwd: orchestrator.cwd,
871
+ timeout: 10000,
872
+ });
873
+ if (prResult.code !== 0) return { prUrl: null, prContext: null };
874
+ const parsed = JSON.parse(prResult.stdout);
875
+ return buildPrContext(parsed);
876
+ } catch {
877
+ return { prUrl: null, prContext: null };
878
+ }
879
+ }
880
+
881
+ async function openReviewTaskInPlannotator(orchestrator: Orchestrator): Promise<string> {
882
+ if (!orchestrator.active) return "No active task.";
883
+ const payload: Record<string, unknown> = { cwd: orchestrator.cwd };
884
+
885
+ return await new Promise((resolve) => {
886
+ let handled = false;
887
+ orchestrator.pi.events.emit("plannotator:request", {
888
+ requestId: crypto.randomUUID(),
889
+ action: "code-review",
890
+ payload,
891
+ respond: (response: any) => {
892
+ handled = true;
893
+ if (response?.status !== "handled") {
894
+ resolve(`Plannotator is not available${response?.error ? `: ${response.error}` : "."}`);
895
+ return;
896
+ }
897
+ const approved = !!response?.result?.approved;
898
+ const feedback = typeof response?.result?.feedback === "string" && response.result.feedback.trim().length > 0
899
+ ? `\n\nFeedback:\n${response.result.feedback}`
900
+ : "";
901
+ resolve(approved ? "Plannotator approved the review." : `Plannotator requested changes.${feedback}`);
902
+ },
903
+ });
904
+ setTimeout(() => {
905
+ if (!handled) resolve("Plannotator is not available.");
906
+ }, 30000);
907
+ });
908
+ }
909
+
910
+ async function startReviewTask(
911
+ orchestrator: Orchestrator,
912
+ ctx: any,
913
+ userRequestContent: string,
914
+ researchContent: string | null,
915
+ description: string,
916
+ ): Promise<"started" | typeof BACK> {
917
+ await orchestrator.startTask(ctx, "review", description);
918
+ if (!orchestrator.active || orchestrator.active.type !== "review") return BACK;
919
+ writeFileSync(join(orchestrator.active.dir, "USER_REQUEST.md"), userRequestContent, "utf-8");
920
+ if (researchContent) {
921
+ writeFileSync(join(orchestrator.active.dir, "RESEARCH.md"), researchContent, "utf-8");
922
+ }
923
+ return "started";
924
+ }
925
+
926
+ async function showReviewMenu(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK | "started"> {
927
+ while (true) {
928
+ const choice = await selectOption(ctx, "Review", [
929
+ { title: "Current branch", description: "Review changes on current branch vs base" },
930
+ { title: "Uncommitted changes", description: "Review working directory changes" },
931
+ { title: "Describe", description: "Describe what to review and let the agent figure it out" },
932
+ { title: "Resume", description: "Resume a previously unfinished review" },
933
+ { title: "Back", description: "Return to the previous menu" },
934
+ ]);
935
+ if (!choice || choice === "Back") return BACK;
936
+
937
+ if (choice === "Resume") {
938
+ const result = await showResumeMenu(orchestrator, ctx, "review", "No paused review tasks found.");
939
+ if (result === "started") return result;
940
+ continue;
941
+ }
942
+
943
+ if (choice === "Current branch") {
944
+ const base = await detectDefaultBranch(orchestrator.pi, orchestrator.cwd, orchestrator.config);
945
+ const pr = await detectCurrentPrContext(orchestrator);
946
+
947
+ const urLines = [`# User Request\nReview current branch changes (${base}..HEAD)`];
948
+ if (pr.prUrl) urLines.push(`PR: ${pr.prUrl}`);
949
+ urLines.push("", "## Problem", "Review and identify issues in the code changes.", "", "## Constraints", "Focus on correctness, edge cases, style, missing tests, potential bugs.");
950
+ const urContent = urLines.join("\n") + "\n";
951
+
952
+ let resContent: string | null = null;
953
+ if (pr.prContext) {
954
+ resContent = ["## PR Context", pr.prContext, "", "## Affected Code", "(to be filled during review)", "", "## Architecture Context", "(to be filled during review)"].join("\n") + "\n";
955
+ }
956
+
957
+ const description = await promptDescription(ctx, "Describe the review (optional)", "review");
958
+ if (!description) continue;
959
+ return startReviewTask(orchestrator, ctx, urContent, resContent, description);
960
+ }
961
+
962
+ if (choice === "Uncommitted changes") {
963
+ const urContent = "# User Request\nReview uncommitted changes\n\n## Problem\nReview and identify issues in uncommitted working directory changes.\n\n## Constraints\nFocus on correctness, edge cases, style, missing tests, potential bugs.\n";
964
+ const description = await promptDescription(ctx, "Describe the review (optional)", "review");
965
+ if (!description) continue;
966
+ return startReviewTask(orchestrator, ctx, urContent, null, description);
967
+ }
968
+
969
+ const input = await ctx.ui.input("Describe what to review");
970
+ if (input === undefined || input === null) continue;
971
+ const trimmed = String(input).trim();
972
+ if (!trimmed) continue;
973
+
974
+ const urContent = `# User Request\n${trimmed}\n\n## Problem\n${trimmed}\n\n## Constraints\nFocus on correctness, edge cases, style, missing tests, potential bugs.\n`;
975
+ return startReviewTask(orchestrator, ctx, urContent, null, trimmed);
976
+ }
977
+ }
978
+
646
979
  async function showTaskTypeMenu(
647
980
  orchestrator: Orchestrator,
648
981
  ctx: any,
@@ -675,13 +1008,13 @@ async function showNoActiveMenu(orchestrator: Orchestrator, ctx: any): Promise<s
675
1008
  { title: "Debug", description: "Diagnose an issue. Then (optionally) fix it" },
676
1009
  { title: "Brainstorm", description: "Explore and brainstorm. Then (optionally) plan and implement" },
677
1010
  { title: "Implement", description: "Brainstorm, plan and implement" },
1011
+ { title: "Review", description: "Review code changes, diffs, or pull requests" },
678
1012
  { title: "Resume", description: "Resume a previously unfinished task" },
679
1013
  { title: "Subagents", description: "Manage running agents" },
680
- { title: "LSP", description: "Language server status and controls" },
681
- { title: "Settings", description: "Configure pi-pi extension" },
682
- { title: "Close", description: "Close this menu" },
1014
+ { title: "Settings", description: "LSP, Flant AI, and other configuration" },
1015
+ { title: "Back", description: "Close this menu" },
683
1016
  ]);
684
- if (!choice || choice === "Close") return undefined;
1017
+ if (!choice || choice === "Back") return undefined;
685
1018
 
686
1019
  if (choice === "Debug") {
687
1020
  const result = await showTaskTypeMenu(orchestrator, ctx, "debug", "Describe the task");
@@ -701,6 +1034,12 @@ async function showNoActiveMenu(orchestrator: Orchestrator, ctx: any): Promise<s
701
1034
  continue;
702
1035
  }
703
1036
 
1037
+ if (choice === "Review") {
1038
+ const result = await showReviewMenu(orchestrator, ctx);
1039
+ if (result === "started") return undefined;
1040
+ continue;
1041
+ }
1042
+
704
1043
  if (choice === "Resume") {
705
1044
  const result = await showResumeMenu(orchestrator, ctx, undefined, "No paused tasks found.");
706
1045
  if (result === "started") return undefined;
@@ -712,12 +1051,7 @@ async function showNoActiveMenu(orchestrator: Orchestrator, ctx: any): Promise<s
712
1051
  continue;
713
1052
  }
714
1053
 
715
- if (choice === "LSP") {
716
- await showLspMenu(ctx);
717
- continue;
718
- }
719
-
720
- await showSettingsMenu(orchestrator, ctx);
1054
+ await showSettingsMenu(orchestrator, ctx, true);
721
1055
  }
722
1056
  }
723
1057
 
@@ -767,32 +1101,36 @@ export async function showActiveTaskMenu(
767
1101
 
768
1102
  const waiting = step === "await_planners" || step === "await_reviewers";
769
1103
  const { autoLabel, deepLabel } = getReviewLabels(orchestrator);
770
- const hasPlannotator = phase === "plan" || phase === "implement";
771
- const canFinishPhase = (phase === "brainstorm" && task.type === "brainstorm") || phase === "debug";
1104
+ const isReviewPhase = phase === "review";
1105
+ const hasPlannotator = phase === "plan" || phase === "implement" || isReviewPhase;
772
1106
 
773
1107
  const opt = (title: string, description: string): OptionInput => ({ title, description });
774
1108
 
775
1109
  const options: OptionInput[] = [];
1110
+ options.push(opt("Finish", "Complete, pause, or continue to next phase"));
776
1111
  if (!waiting) {
777
- options.push(opt("Approve & continue", "Advance to the next phase"));
778
1112
  options.push(opt(autoLabel, "Run automated review with configured reviewers"));
779
1113
  options.push(opt(deepLabel, "Run automated review with higher thinking level"));
780
1114
  if (hasPlannotator) {
781
- options.push(opt("Review in Plannotator", "Open visual review in browser"));
782
- options.push(opt("Review on my own", "Review manually, then continue"));
783
- }
784
- options.push(opt("Back to prompt", "Return to the prompt and keep working"));
785
- if (canFinishPhase) {
786
- options.push(opt("Finish", "Complete this phase and end the task"));
1115
+ if (isReviewPhase) {
1116
+ options.push(opt("Review in Plannotator", "Open visual diff review in browser"));
1117
+ } else {
1118
+ options.push(opt("Review in Plannotator", "Open visual review in browser"));
1119
+ }
1120
+ if (!isReviewPhase) {
1121
+ options.push(opt("Review on my own", "Review manually, then continue"));
1122
+ }
787
1123
  }
788
1124
  }
789
- options.push(opt("Abort", "Stop the task without completing"));
790
- options.push(opt("Status", "Show current task phase, step, and timing"));
791
1125
  options.push(opt("Subagents", "Manage running agents"));
792
- options.push(opt("LSP", "Language server status and controls"));
1126
+ options.push(opt("Status", "Show current task phase, step, and timing"));
1127
+ options.push(opt("Settings", "LSP and other configuration"));
1128
+ options.push(opt("Back", "Return to the prompt and keep working"));
793
1129
 
794
1130
  const choice = await selectOption(ctx, summary, options);
795
- if (!choice) return mode === "tool" ? "No action selected." : "";
1131
+ if (!choice || choice === "Back") {
1132
+ return "";
1133
+ }
796
1134
 
797
1135
  if (choice === "Status") {
798
1136
  showStatus(orchestrator, ctx);
@@ -802,18 +1140,37 @@ export async function showActiveTaskMenu(
802
1140
  await showSubagentsMenu(ctx);
803
1141
  continue;
804
1142
  }
805
- if (choice === "LSP") {
806
- await showLspMenu(ctx);
1143
+ if (choice === "Settings") {
1144
+ await showSettingsMenu(orchestrator, ctx, false);
807
1145
  continue;
808
1146
  }
809
- if (choice === "Abort" || choice === "Finish") {
810
- const text = await finishTask(orchestrator, ctx);
811
- return mode === "tool" ? text : "";
812
- }
813
1147
 
814
- finalizeReviewCycle(task);
1148
+ if (mode === "command") {
1149
+ await abortCurrentWork(orchestrator, ctx);
1150
+ }
815
1151
 
816
- if (choice === "Approve & continue") {
1152
+ if (choice === "Finish") {
1153
+ const canContinue = phase !== "implement" && !waiting;
1154
+ const continueLabel = phase === "plan" ? "Continue to implement" : "Continue to plan & implement";
1155
+ const finishOptions: OptionInput[] = [];
1156
+ if (canContinue) {
1157
+ finishOptions.push(opt(continueLabel, "Approve and advance to the next phase"));
1158
+ }
1159
+ finishOptions.push(opt("Complete", "Mark task as done and clean up"));
1160
+ finishOptions.push(opt("Pause", "Suspend task to resume later"));
1161
+ finishOptions.push(opt("Back", "Return to the previous menu"));
1162
+
1163
+ const finishChoice = await selectOption(ctx, "Finish", finishOptions);
1164
+ if (!finishChoice || finishChoice === "Back") continue;
1165
+ if (finishChoice === "Pause") {
1166
+ const text = await pauseTask(orchestrator, ctx);
1167
+ return mode === "tool" ? text : "";
1168
+ }
1169
+ if (finishChoice === "Complete") {
1170
+ const text = await finishTask(orchestrator, ctx);
1171
+ return mode === "tool" ? text : "";
1172
+ }
1173
+ finalizeReviewCycle(task);
817
1174
  const result = await orchestrator.transitionToNextPhase(ctx);
818
1175
  if (!result.ok) return `Transition blocked: ${result.error}`;
819
1176
  if (orchestrator.phaseCompactionPending || orchestrator.taskDoneCompactionPending) return "";
@@ -822,6 +1179,14 @@ export async function showActiveTaskMenu(
822
1179
  return "";
823
1180
  }
824
1181
 
1182
+ if (choice === "Review in Plannotator" && isReviewPhase) {
1183
+ const text = await openReviewTaskInPlannotator(orchestrator);
1184
+ ctx.ui.notify(text, "info");
1185
+ continue;
1186
+ }
1187
+
1188
+ finalizeReviewCycle(task);
1189
+
825
1190
  if (choice === autoLabel || choice === deepLabel || choice === "Review in Plannotator") {
826
1191
  const kind = choice === autoLabel ? "auto" as const : choice === deepLabel ? "auto-deep" as const : "plannotator" as const;
827
1192
  if (kind !== "plannotator" && !hasEnabledReviewers(orchestrator, kind)) {
@@ -845,15 +1210,6 @@ export async function showActiveTaskMenu(
845
1210
  }
846
1211
  return continueMessage;
847
1212
  }
848
-
849
- if (choice === "Back to prompt") {
850
- if (phase === "plan") {
851
- setStep(orchestrator, "synthesize");
852
- } else {
853
- setStep(orchestrator, "llm_work");
854
- }
855
- return continueMessage;
856
- }
857
1213
  }
858
1214
  }
859
1215
 
@@ -13,12 +13,13 @@ function getLockfileFs(): typeof import("fs") | undefined {
13
13
  }
14
14
  import type { TimeoutConfig } from "./config.js";
15
15
 
16
- export type TaskType = "implement" | "debug" | "brainstorm";
16
+ export type TaskType = "implement" | "debug" | "brainstorm" | "review";
17
17
 
18
18
  export type ImplementPhase = "brainstorm" | "plan" | "implement" | "done";
19
19
  export type DebugPhase = "debug" | "plan" | "implement" | "done";
20
20
  export type BrainstormPhase = "brainstorm" | "plan" | "implement" | "done";
21
- export type Phase = ImplementPhase | DebugPhase | BrainstormPhase;
21
+ export type ReviewPhase = "review" | "plan" | "implement" | "done";
22
+ export type Phase = ImplementPhase | DebugPhase | BrainstormPhase | ReviewPhase;
22
23
 
23
24
  export interface TaskState {
24
25
  phase: Phase;
@@ -31,6 +32,7 @@ export interface TaskState {
31
32
  from: string | null;
32
33
  description: string;
33
34
  startedAt: string;
35
+
34
36
  }
35
37
 
36
38
  export interface TaskInfo {
@@ -60,7 +62,7 @@ export function createTask(cwd: string, type: TaskType, description: string): st
60
62
  mkdirSync(taskDir, { recursive: true });
61
63
 
62
64
  const state: TaskState = {
63
- phase: type === "implement" ? "brainstorm" : type === "debug" ? "debug" : "brainstorm",
65
+ phase: type === "implement" ? "brainstorm" : type === "debug" ? "debug" : type === "review" ? "review" : "brainstorm",
64
66
  step: "llm_work",
65
67
  reviewCycle: null,
66
68
  reviewPass: 0,
@@ -77,10 +79,7 @@ export function loadTask(taskDir: string): TaskState {
77
79
  const sp = taskStatePath(taskDir);
78
80
  const raw = readFileSync(sp, "utf-8");
79
81
  try {
80
- const state = JSON.parse(raw) as TaskState & { phase?: string };
81
- if (state.phase === "planning") {
82
- state.phase = "plan";
83
- }
82
+ const state = JSON.parse(raw) as TaskState;
84
83
  return state;
85
84
  } catch (err: any) {
86
85
  throw new Error(`Failed to parse ${sp}: ${err.message}`);
@@ -95,7 +94,7 @@ export function listTasks(cwd: string, type?: TaskType): TaskInfo[] {
95
94
  const base = stateDir(cwd);
96
95
  if (!existsSync(base)) return [];
97
96
 
98
- const types: TaskType[] = type ? [type] : ["implement", "debug", "brainstorm"];
97
+ const types: TaskType[] = type ? [type] : ["implement", "debug", "brainstorm", "review"];
99
98
  const results: TaskInfo[] = [];
100
99
 
101
100
  for (const t of types) {
@@ -196,7 +195,7 @@ export function taskName(taskDir: string): string {
196
195
  const state = loadTask(taskDir);
197
196
  let desc = state.description ?? "";
198
197
 
199
- if (["implement", "debug", "brainstorm"].includes(desc)) {
198
+ if (["implement", "debug", "brainstorm", "review"].includes(desc)) {
200
199
  const urPath = join(taskDir, "USER_REQUEST.md");
201
200
  if (existsSync(urPath)) {
202
201
  const content = readFileSync(urPath, "utf-8");