@ilya-lesikov/pi-pi 0.6.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 (66) hide show
  1. package/3p/pi-ask-user/index.ts +1 -1
  2. package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
  3. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
  4. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
  5. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
  6. package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
  7. package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
  8. package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
  9. package/3p/pi-subagents/src/agent-manager.ts +34 -1
  10. package/3p/pi-subagents/src/agent-runner.ts +66 -33
  11. package/3p/pi-subagents/src/index.ts +3 -38
  12. package/3p/pi-subagents/src/types.ts +4 -0
  13. package/extensions/orchestrator/agents/advisor.ts +35 -0
  14. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
  15. package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
  16. package/extensions/orchestrator/agents/constraints.test.ts +44 -0
  17. package/extensions/orchestrator/agents/constraints.ts +3 -0
  18. package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
  19. package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
  20. package/extensions/orchestrator/agents/planner.ts +9 -8
  21. package/extensions/orchestrator/agents/prompts.test.ts +120 -0
  22. package/extensions/orchestrator/agents/reviewer.ts +48 -0
  23. package/extensions/orchestrator/agents/task.ts +6 -20
  24. package/extensions/orchestrator/agents/tool-routing.ts +23 -1
  25. package/extensions/orchestrator/command-handlers.ts +1 -1
  26. package/extensions/orchestrator/config.ts +8 -4
  27. package/extensions/orchestrator/context.test.ts +54 -0
  28. package/extensions/orchestrator/context.ts +65 -2
  29. package/extensions/orchestrator/custom-footer.ts +5 -2
  30. package/extensions/orchestrator/doctor.test.ts +3 -1
  31. package/extensions/orchestrator/doctor.ts +40 -2
  32. package/extensions/orchestrator/event-handlers.test.ts +97 -1
  33. package/extensions/orchestrator/event-handlers.ts +222 -48
  34. package/extensions/orchestrator/flant-infra.test.ts +312 -0
  35. package/extensions/orchestrator/flant-infra.ts +407 -44
  36. package/extensions/orchestrator/index.ts +1 -1
  37. package/extensions/orchestrator/integration.test.ts +312 -18
  38. package/extensions/orchestrator/messages.test.ts +30 -0
  39. package/extensions/orchestrator/messages.ts +6 -0
  40. package/extensions/orchestrator/model-registry.test.ts +124 -13
  41. package/extensions/orchestrator/model-registry.ts +91 -33
  42. package/extensions/orchestrator/orchestrator.test.ts +113 -0
  43. package/extensions/orchestrator/orchestrator.ts +163 -3
  44. package/extensions/orchestrator/phases/brainstorm.ts +9 -20
  45. package/extensions/orchestrator/phases/implementation.test.ts +11 -0
  46. package/extensions/orchestrator/phases/implementation.ts +4 -6
  47. package/extensions/orchestrator/phases/planning.test.ts +16 -0
  48. package/extensions/orchestrator/phases/planning.ts +11 -4
  49. package/extensions/orchestrator/phases/review-task.ts +1 -4
  50. package/extensions/orchestrator/phases/review.test.ts +62 -0
  51. package/extensions/orchestrator/phases/review.ts +58 -15
  52. package/extensions/orchestrator/plannotator.ts +9 -6
  53. package/extensions/orchestrator/pp-menu.test.ts +74 -1
  54. package/extensions/orchestrator/pp-menu.ts +366 -94
  55. package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
  56. package/extensions/orchestrator/pp-state-tools.ts +249 -0
  57. package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
  58. package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
  59. package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
  60. package/extensions/orchestrator/state.ts +41 -20
  61. package/extensions/orchestrator/test-helpers.ts +18 -3
  62. package/extensions/orchestrator/usage-tracker.test.ts +131 -3
  63. package/extensions/orchestrator/usage-tracker.ts +78 -11
  64. package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
  65. package/extensions/orchestrator/validate-artifacts.ts +2 -2
  66. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, writeFileSync } from "fs";
2
- import { join, relative } from "path";
2
+ import { join, relative, basename } from "path";
3
3
  import { isDeepStrictEqual } from "util";
4
- import { askUser, isCancel } from "../../3p/pi-ask-user/index.js";
4
+ import { askUser, isCancel, type CancelReason } from "../../3p/pi-ask-user/index.js";
5
5
  import { unregisterAgentDefinitions } from "./agents/registry.js";
6
6
  import {
7
7
  type AfterEditCommandConfig,
@@ -27,7 +27,8 @@ import {
27
27
  } from "./context.js";
28
28
  import { detectDefaultBranch, enterReviewCycle, finalizeReviewCycle } from "./event-handlers.js";
29
29
  import { Orchestrator } from "./orchestrator.js";
30
- import { cancelPendingPlannotatorWait } from "./plannotator.js";
30
+ import { cancelPendingPlannotatorWait, openPlannotator, waitForPlannotatorResult } from "./plannotator.js";
31
+ import { advanceBanner } from "./messages.js";
31
32
  import { spawnPlanners, spawnPlanReviewers } from "./phases/planning.js";
32
33
  import { spawnCodeReviewers } from "./phases/review.js";
33
34
  import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
@@ -44,6 +45,8 @@ import {
44
45
  saveTask,
45
46
  taskAge,
46
47
  taskName,
48
+ taskNameFromState,
49
+ taskShortId,
47
50
  type AutonomousConfig,
48
51
  type TaskMode,
49
52
  type TaskInfo,
@@ -53,6 +56,8 @@ import {
53
56
  clearFlantGeneratedConfig,
54
57
  getFlantGeneratedConfig,
55
58
  loadFlantSettings,
59
+ readClaudeOAuthToken,
60
+ readGatewayApiKey,
56
61
  saveFlantSettings,
57
62
  unregisterFlantProviders,
58
63
  updateFlantInfra,
@@ -67,6 +72,13 @@ type MenuMode = "command" | "tool";
67
72
 
68
73
  const BACK = "back" as const;
69
74
 
75
+ // Sentinel returned by showActiveTaskMenu when the user dismissed the top-level
76
+ // menu with a deliberate ESC (cancelReason "user"), as opposed to selecting the
77
+ // "Back" option or a programmatic dismissal. Callers (pp_phase_complete) use it
78
+ // to stop the turn cleanly instead of returning reminder text that starts a new
79
+ // LLM turn. It is intentionally distinct from the empty-string "Back" return.
80
+ export const USER_CANCELLED = "\u0000user-cancelled" as const;
81
+
70
82
  type OptionInput = string | { title: string; description?: string };
71
83
 
72
84
  type TimeoutKey =
@@ -81,6 +93,18 @@ function isEnabled(value: { enabled?: boolean } | undefined): boolean {
81
93
  }
82
94
 
83
95
  async function selectOption(ctx: any, question: string, options: OptionInput[]): Promise<string | undefined> {
96
+ return (await selectOptionCancelable(ctx, question, options)).choice;
97
+ }
98
+
99
+ // Like selectOption but also surfaces the cancel reason so callers can tell a
100
+ // deliberate user ESC (reason "user") apart from a normal non-selection. Used by
101
+ // showActiveTaskMenu so pp_phase_complete can stop the turn cleanly on ESC
102
+ // (mirroring ask_user) while keeping the "Back" navigation reminder.
103
+ async function selectOptionCancelable(
104
+ ctx: any,
105
+ question: string,
106
+ options: OptionInput[],
107
+ ): Promise<{ choice?: string; cancelReason?: CancelReason }> {
84
108
  const result = await askUser(ctx, {
85
109
  question,
86
110
  options,
@@ -88,8 +112,9 @@ async function selectOption(ctx: any, question: string, options: OptionInput[]):
88
112
  allowComment: false,
89
113
  allowMultiple: false,
90
114
  });
91
- if (!result || isCancel(result) || result.kind !== "selection") return undefined;
92
- return result.selections[0];
115
+ if (result && isCancel(result)) return { cancelReason: result.reason };
116
+ if (!result || result.kind !== "selection") return {};
117
+ return { choice: result.selections[0] };
93
118
  }
94
119
 
95
120
  function opt(title: string, description: string): OptionInput {
@@ -201,6 +226,44 @@ async function pickCommitForRepo(orchestrator: Orchestrator, ctx: any, repo: Rep
201
226
  return pickedHash || null;
202
227
  }
203
228
 
229
+ async function repoHasReviewableChanges(
230
+ orchestrator: Orchestrator,
231
+ repo: RepoInfo,
232
+ base: string,
233
+ ): Promise<{ changed: boolean; error?: string }> {
234
+ const run = async (args: string[]): Promise<{ out: string; failed: boolean }> => {
235
+ try {
236
+ const res = await orchestrator.pi.exec("git", args, { cwd: repo.path, timeout: 5000 });
237
+ if (res.code !== 0) return { out: "", failed: true };
238
+ return { out: res.stdout, failed: false };
239
+ } catch {
240
+ return { out: "", failed: true };
241
+ }
242
+ };
243
+
244
+ const committed = await run(["diff", "--name-only", `${base}...HEAD`]);
245
+ const unstaged = await run(["diff", "--name-only"]);
246
+ const staged = await run(["diff", "--cached", "--name-only"]);
247
+ const status = await run(["status", "--porcelain"]);
248
+
249
+ const hasCommitted = committed.out.trim().length > 0;
250
+ const hasUnstaged = unstaged.out.trim().length > 0;
251
+ const hasStaged = staged.out.trim().length > 0;
252
+ const hasUntracked = status.out.split("\n").some((line) => line.startsWith("??"));
253
+ const changed = hasCommitted || hasUnstaged || hasStaged || hasUntracked;
254
+
255
+ if (changed) return { changed: true };
256
+
257
+ // No change detected — but a failed probe (e.g. a missing/invalid base ref)
258
+ // could be masking real changes, so surface it rather than silently hiding
259
+ // the repo.
260
+ if (committed.failed || unstaged.failed || staged.failed || status.failed) {
261
+ return { changed: false, error: "git status/diff failed" };
262
+ }
263
+
264
+ return { changed: false };
265
+ }
266
+
204
267
  interface RepoPrContext {
205
268
  repoPath: string;
206
269
  prUrl: string | null;
@@ -228,6 +291,11 @@ function showStatus(orchestrator: Orchestrator, ctx: any): void {
228
291
  }
229
292
 
230
293
  async function abortCurrentWork(orchestrator: Orchestrator, ctx: any): Promise<void> {
294
+ // Clear pi-pi's own delayed post-error retry (timer + ESC interrupt). Unlike
295
+ // pause/finish, abortCurrentWork does NOT go through cleanupActive/
296
+ // resetTaskScopedState, so without this a scheduled retry would survive the
297
+ // abort and re-nudge seconds later.
298
+ orchestrator.cancelPendingRetry();
231
299
  orchestrator.abortAllSubagents();
232
300
  orchestrator.transitionController.abortMainAgent(ctx.abort?.bind(ctx));
233
301
  await ctx.waitForIdle?.();
@@ -771,6 +839,9 @@ function collectRoleAssignments(config: Partial<PiPiConfig> | null): string[] {
771
839
  add("agents.subagents.simple.explore", config.agents?.subagents?.simple?.explore?.model);
772
840
  add("agents.subagents.simple.librarian", config.agents?.subagents?.simple?.librarian?.model);
773
841
  add("agents.subagents.simple.task", config.agents?.subagents?.simple?.task?.model);
842
+ add("agents.subagents.simple.advisor", config.agents?.subagents?.simple?.advisor?.model);
843
+ add("agents.subagents.simple.deep-debugger", config.agents?.subagents?.simple?.["deep-debugger"]?.model);
844
+ add("agents.subagents.simple.reviewer", config.agents?.subagents?.simple?.reviewer?.model);
774
845
  return out;
775
846
  }
776
847
 
@@ -783,6 +854,21 @@ function flantStatusText(settings: FlantSettings): string {
783
854
  `Last updated: ${settings.lastUpdated ?? "never"}`,
784
855
  `Providers: pp-flant-anthropic (${providers.anthropic} models), pp-flant-openai (${providers.openai} models)`,
785
856
  ];
857
+ if (settings.subscription) {
858
+ const hasOAuth = !!readClaudeOAuthToken();
859
+ const hasGatewayKey = !!readGatewayApiKey();
860
+ const subActive = hasOAuth && hasGatewayKey;
861
+ lines.push(
862
+ `Personal subscription: on (${subActive ? `active — pp-flant-anthropic-sub, ${providers.anthropic} models` : "inactive"})`,
863
+ );
864
+ lines.push(`Rate-limit switch-back check: every ${settings.switchBackIntervalMinutes} min`);
865
+ if (!subActive) {
866
+ if (!hasOAuth) lines.push(" - missing Claude OAuth token (run pi /login for Anthropic)");
867
+ if (!hasGatewayKey) lines.push(" - missing gateway key (set LLM_API_KEY or FLANT_API_KEY)");
868
+ }
869
+ } else {
870
+ lines.push("Personal subscription: off");
871
+ }
786
872
  if (assignments.length === 0) {
787
873
  lines.push("Role assignments: none");
788
874
  } else {
@@ -811,13 +897,22 @@ async function showFlantInfraMenu(orchestrator: Orchestrator, ctx: any): Promise
811
897
  while (true) {
812
898
  const settings = loadFlantSettings();
813
899
  const enableLabel = `Enable: ${settings.enabled ? "ON" : "OFF"}`;
814
- const options: OptionInput[] = [enableLabel];
900
+ const options: OptionInput[] = [
901
+ { title: enableLabel, description: "Turn the Flant AI model providers on or off" },
902
+ ];
903
+ const subscriptionLabel = `Personal Claude subscription: ${settings.subscription ? "ON" : "OFF"}`;
815
904
  if (settings.enabled) {
816
905
  options.push(
817
- `Auto-update on startup: ${settings.autoUpdate ? "ON" : "OFF"}`,
818
- `Cache period: ${settings.cacheTTLDays} ${settings.cacheTTLDays === 1 ? "day" : "days"}`,
819
- "Update now",
820
- "Current status",
906
+ { title: subscriptionLabel, description: "Route Claude roles through your personal Claude subscription instead of the gateway" },
907
+ { title: `Auto-update on startup: ${settings.autoUpdate ? "ON" : "OFF"}`, description: "Refresh the available model list automatically each time pi starts" },
908
+ { title: `Cache period: ${settings.cacheTTLDays} ${settings.cacheTTLDays === 1 ? "day" : "days"}`, description: "How long the fetched model list is reused before it is refreshed" },
909
+ );
910
+ if (settings.subscription) {
911
+ options.push({ title: `Rate-limit switch-back check: every ${settings.switchBackIntervalMinutes} min`, description: "How often to retry your subscription after it was rate-limited and traffic fell back to the gateway" });
912
+ }
913
+ options.push(
914
+ { title: "Update now", description: "Fetch the latest model list from Flant right away" },
915
+ { title: "Current status", description: "Show the current Flant configuration, providers, and model counts" },
821
916
  );
822
917
  }
823
918
  options.push({ title: "Back", description: "Return to the previous menu" });
@@ -852,6 +947,50 @@ async function showFlantInfraMenu(orchestrator: Orchestrator, ctx: any): Promise
852
947
  continue;
853
948
  }
854
949
 
950
+ if (choice === subscriptionLabel) {
951
+ const turningOn = !settings.subscription;
952
+ if (turningOn) {
953
+ if (!readClaudeOAuthToken()) {
954
+ ctx.ui.notify("No Claude OAuth token found. Log in to your personal Claude subscription in pi first (/login → Anthropic), then retry.", "warning");
955
+ continue;
956
+ }
957
+ if (!readGatewayApiKey()) {
958
+ ctx.ui.notify("Set LLM_API_KEY (or FLANT_API_KEY) for the gateway first.", "warning");
959
+ continue;
960
+ }
961
+ }
962
+ const next = { ...settings, subscription: turningOn };
963
+ saveFlantSettings(next);
964
+ const result = await updateFlantInfra(orchestrator.pi);
965
+ if (!result.ok) {
966
+ ctx.ui.notify(`Personal subscription ${turningOn ? "enable" : "disable"} failed: ${result.error ?? "unknown error"}`, "error");
967
+ } else {
968
+ ctx.ui.notify(
969
+ turningOn
970
+ ? "Personal Claude subscription ON — Claude roles now route through sub/claude-* (billed to your subscription); non-Claude roles stay on llm-api.flant.ru."
971
+ : "Personal Claude subscription OFF — Claude roles reverted to pp-flant-anthropic.",
972
+ "info",
973
+ );
974
+ }
975
+ continue;
976
+ }
977
+
978
+ if (choice.startsWith("Rate-limit switch-back check:")) {
979
+ const selected = await selectOption(ctx, "Switch-back check interval", [
980
+ { title: "15 min", description: "Probe the subscription limit every 15 minutes" },
981
+ { title: "30 min", description: "Default — probe every 30 minutes" },
982
+ { title: "60 min", description: "Probe hourly" },
983
+ { title: "120 min", description: "Probe every two hours" },
984
+ { title: "Back", description: "Return to the previous menu" },
985
+ ]);
986
+ if (!selected || selected === "Back") continue;
987
+ const mins = Number(selected.split(" ")[0]);
988
+ if (!Number.isFinite(mins) || mins <= 0) continue;
989
+ saveFlantSettings({ ...settings, switchBackIntervalMinutes: mins });
990
+ ctx.ui.notify(`Switch-back check interval set to ${mins} min.`, "info");
991
+ continue;
992
+ }
993
+
855
994
  if (choice.startsWith("Cache period:")) {
856
995
  const selected = await selectOption(ctx, "Cache period", [
857
996
  { title: "1 day", description: "Refresh model metadata daily" },
@@ -899,17 +1038,18 @@ function formatElapsedDuration(ms: number): string {
899
1038
  return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
900
1039
  }
901
1040
 
902
- function showUsage(ctx: any): void {
1041
+ export function showUsage(ctx: any): void {
903
1042
  const tracker = (globalThis as any)[Symbol.for("pi-pi:usage-tracker")] as
904
1043
  | {
905
1044
  getTotalInputTokens(): number; getTotalOutputTokens(): number;
906
1045
  getTotalCacheReadTokens(): number; getTotalCacheWriteTokens(): number;
1046
+ getTotalProcessedInputTokens(): number;
907
1047
  getTotalCost(): number; getCacheHitRate(): number;
908
1048
  getMainInputTokens(): number; getMainOutputTokens(): number;
909
1049
  getMainCacheReadTokens(): number; getMainCacheWriteTokens(): number;
910
1050
  getMainCost(): number;
911
- getPerModelUsage(): Record<string, { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; cacheSupported: boolean; turns: number }>;
912
- getSubagentList(): Array<{ description: string; agentType: string; modelId: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; cacheSupported: boolean; cost: number; durationMs: number; toolUses: number }>;
1051
+ getPerModelUsage(): Record<string, { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; cacheSupported: boolean; turns: number; subscription: boolean }>;
1052
+ getSubagentList(): Array<{ description: string; agentType: string; modelId: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; cacheSupported: boolean; cost: number; durationMs: number; toolUses: number; subscription: boolean }>;
913
1053
  }
914
1054
  | undefined;
915
1055
 
@@ -918,30 +1058,52 @@ function showUsage(ctx: any): void {
918
1058
  return;
919
1059
  }
920
1060
 
921
- const totalInput = tracker.getTotalInputTokens();
1061
+ // Token-weighted cache hit rate: cache reads over all processed input
1062
+ // (uncached + cache read + cache write). Same formula everywhere so the
1063
+ // total, per-model, and per-agent percentages are directly comparable.
1064
+ const hitRate = (uncached: number, cacheRead: number, cacheWrite: number): number => {
1065
+ const processed = uncached + cacheRead + cacheWrite;
1066
+ return processed > 0 ? cacheRead / processed : 0;
1067
+ };
1068
+
1069
+ // Compact one-line input part: total processed input with an inline
1070
+ // uncached / cache read / cache write breakdown, e.g. "↑1.3k (u84 r1.0k w200)".
1071
+ const inputPart = (uncached: number, cacheRead: number, cacheWrite: number, cacheSupported: boolean): string => {
1072
+ const processed = uncached + cacheRead + cacheWrite;
1073
+ const head = `↑${formatTokenCount(processed)}`;
1074
+ if (!cacheSupported) return head;
1075
+ return `${head} (u${formatTokenCount(uncached)} r${formatTokenCount(cacheRead)} w${formatTokenCount(cacheWrite)})`;
1076
+ };
1077
+
1078
+ const totalUncachedInput = tracker.getTotalInputTokens();
922
1079
  const totalOutput = tracker.getTotalOutputTokens();
923
1080
  const totalCacheRead = tracker.getTotalCacheReadTokens();
1081
+ const totalCacheWrite = tracker.getTotalCacheWriteTokens();
1082
+ const totalProcessedInput = tracker.getTotalProcessedInputTokens();
924
1083
  const totalCost = tracker.getTotalCost();
925
- const totalCacheRate = (totalCacheRead + totalInput) > 0
926
- ? totalCacheRead / (totalCacheRead + totalInput)
927
- : 0;
1084
+ const totalCacheRate = tracker.getCacheHitRate();
928
1085
 
929
1086
  const mainInput = tracker.getMainInputTokens();
930
1087
  const mainOutput = tracker.getMainOutputTokens();
931
1088
  const mainCacheRead = tracker.getMainCacheReadTokens();
1089
+ const mainCacheWrite = tracker.getMainCacheWriteTokens();
932
1090
  const mainCost = tracker.getMainCost();
933
1091
  const models = tracker.getPerModelUsage();
934
1092
  const subagents = tracker.getSubagentList();
935
1093
 
936
- const byModel = new Map<string, { input: number; output: number; cacheRead: number; cacheWrite: number; cacheSupported: boolean; cost: number }>();
1094
+ const byModel = new Map<string, { input: number; output: number; cacheRead: number; cacheWrite: number; cacheSupported: boolean; cost: number; subscription: boolean }>();
937
1095
  const mainModelEntries = Object.entries(models);
938
- const mainTotalTokens = mainModelEntries.reduce((s, [, u]) => s + u.inputTokens + u.outputTokens, 0);
1096
+ // Subscription (flat-rate) models contribute no dollars, so exclude their
1097
+ // tokens from the proportional-share denominator or paid rows would be
1098
+ // inflated and the sub/ rows would receive a spurious share.
1099
+ const mainTotalTokens = mainModelEntries.reduce((s, [, u]) => s + (u.subscription ? 0 : u.inputTokens + u.outputTokens), 0);
939
1100
  for (const [modelId, usage] of mainModelEntries) {
940
1101
  const modelTokens = usage.inputTokens + usage.outputTokens;
941
- const modelCostShare = mainTotalTokens > 0 ? mainCost * (modelTokens / mainTotalTokens) : 0;
1102
+ const modelCostShare = usage.subscription || mainTotalTokens <= 0 ? 0 : mainCost * (modelTokens / mainTotalTokens);
942
1103
  byModel.set(modelId, {
943
1104
  input: usage.inputTokens, output: usage.outputTokens,
944
1105
  cacheRead: usage.cacheReadTokens, cacheWrite: usage.cacheWriteTokens, cacheSupported: usage.cacheSupported, cost: modelCostShare,
1106
+ subscription: usage.subscription,
945
1107
  });
946
1108
  }
947
1109
  for (const sa of subagents) {
@@ -954,28 +1116,37 @@ function showUsage(ctx: any): void {
954
1116
  existing.cacheWrite += sa.cacheWriteTokens;
955
1117
  if (sa.cacheSupported) existing.cacheSupported = true;
956
1118
  existing.cost += sa.cost;
1119
+ if (sa.subscription) existing.subscription = true;
957
1120
  } else {
958
1121
  byModel.set(key, {
959
1122
  input: sa.inputTokens, output: sa.outputTokens,
960
1123
  cacheRead: sa.cacheReadTokens, cacheWrite: sa.cacheWriteTokens, cacheSupported: sa.cacheSupported, cost: sa.cost,
1124
+ subscription: sa.subscription,
961
1125
  });
962
1126
  }
963
1127
  }
964
1128
 
1129
+ // Total input the model processed = uncached + cache read + cache write.
1130
+ // Shown as an explicit breakdown so the (often tiny) uncached sliver no
1131
+ // longer masquerades as the whole "Input" figure.
965
1132
  const lines: string[] = ["Session usage (total):"];
966
- lines.push(` Input: ${formatTokenCount(totalInput)} tokens`);
1133
+ lines.push(` Input: ${formatTokenCount(totalProcessedInput)} tokens`);
1134
+ lines.push(` • uncached: ${formatTokenCount(totalUncachedInput)}`);
1135
+ lines.push(` • cache read: ${formatTokenCount(totalCacheRead)}`);
1136
+ lines.push(` • cache write: ${formatTokenCount(totalCacheWrite)}`);
967
1137
  lines.push(` Output: ${formatTokenCount(totalOutput)} tokens`);
968
1138
  if (totalCacheRead > 0) lines.push(` Cache: ⚡${Math.round(totalCacheRate * 100)}% hit rate`);
969
- if (totalCost > 0) lines.push(` Cost: $${totalCost.toFixed(2)}`);
1139
+ lines.push(` Cost: $${totalCost.toFixed(2)}`);
970
1140
 
971
1141
  if (byModel.size > 0) {
972
1142
  lines.push("");
973
1143
  lines.push("By model:");
974
1144
  for (const [modelId, m] of byModel) {
975
- const cr = (m.cacheRead + m.input) > 0 ? Math.round(m.cacheRead / (m.cacheRead + m.input) * 100) : 0;
976
- const parts = [`↑${formatTokenCount(m.input)}`, `↓${formatTokenCount(m.output)}`];
1145
+ const cr = Math.round(hitRate(m.input, m.cacheRead, m.cacheWrite) * 100);
1146
+ const parts = [inputPart(m.input, m.cacheRead, m.cacheWrite, m.cacheSupported), `↓${formatTokenCount(m.output)}`];
977
1147
  if (m.cacheSupported) parts.push(`⚡${cr}%`);
978
- if (m.cost > 0) parts.push(`$${m.cost.toFixed(2)}`);
1148
+ if (m.subscription) parts.push("subscription");
1149
+ else if (m.cost > 0) parts.push(`$${m.cost.toFixed(2)}`);
979
1150
  lines.push(` ${modelId}: ${parts.join(" ")}`);
980
1151
  }
981
1152
  }
@@ -985,13 +1156,15 @@ function showUsage(ctx: any): void {
985
1156
  const agentModelNames = Object.keys(models);
986
1157
  if (agentModelNames.length > 0) {
987
1158
  const mainCacheSupported = mainModelEntries.some(([, u]) => u.cacheSupported);
988
- const mainParts = [`↑${formatTokenCount(mainInput)}`, `↓${formatTokenCount(mainOutput)}`];
989
- const mainCR = (mainCacheRead + mainInput) > 0 ? Math.round(mainCacheRead / (mainCacheRead + mainInput) * 100) : 0;
1159
+ const mainAllSubscription = mainModelEntries.every(([, u]) => u.subscription);
1160
+ const mainParts = [inputPart(mainInput, mainCacheRead, mainCacheWrite, mainCacheSupported), `↓${formatTokenCount(mainOutput)}`];
1161
+ const mainCR = Math.round(hitRate(mainInput, mainCacheRead, mainCacheWrite) * 100);
990
1162
  if (mainCacheSupported) mainParts.push(`⚡${mainCR}%`);
991
- if (mainCost > 0) mainParts.push(`$${mainCost.toFixed(2)}`);
1163
+ if (mainAllSubscription) mainParts.push("subscription");
1164
+ else if (mainCost > 0) mainParts.push(`$${mainCost.toFixed(2)}`);
992
1165
  lines.push(` Main (${agentModelNames.join(", ")}): ${mainParts.join(" ")}`);
993
1166
  }
994
- const byAgentType = new Map<string, { input: number; output: number; cacheRead: number; cacheSupported: boolean; cost: number; durationMs: number; toolUses: number; count: number }>();
1167
+ const byAgentType = new Map<string, { input: number; output: number; cacheRead: number; cacheWrite: number; cacheSupported: boolean; cost: number; durationMs: number; toolUses: number; count: number; subscriptionRuns: number }>();
995
1168
  for (const sa of subagents) {
996
1169
  const key = sa.agentType || sa.description;
997
1170
  const existing = byAgentType.get(key);
@@ -999,24 +1172,29 @@ function showUsage(ctx: any): void {
999
1172
  existing.input += sa.inputTokens;
1000
1173
  existing.output += sa.outputTokens;
1001
1174
  existing.cacheRead += sa.cacheReadTokens;
1175
+ existing.cacheWrite += sa.cacheWriteTokens;
1002
1176
  if (sa.cacheSupported) existing.cacheSupported = true;
1003
1177
  existing.cost += sa.cost;
1004
1178
  existing.durationMs += sa.durationMs;
1005
1179
  existing.toolUses += sa.toolUses;
1006
1180
  existing.count += 1;
1181
+ if (sa.subscription) existing.subscriptionRuns += 1;
1007
1182
  } else {
1008
1183
  byAgentType.set(key, {
1009
- input: sa.inputTokens, output: sa.outputTokens, cacheRead: sa.cacheReadTokens,
1184
+ input: sa.inputTokens, output: sa.outputTokens, cacheRead: sa.cacheReadTokens, cacheWrite: sa.cacheWriteTokens,
1010
1185
  cacheSupported: sa.cacheSupported, cost: sa.cost, durationMs: sa.durationMs, toolUses: sa.toolUses, count: 1,
1186
+ subscriptionRuns: sa.subscription ? 1 : 0,
1011
1187
  });
1012
1188
  }
1013
1189
  }
1014
1190
  for (const [agentType, agg] of byAgentType) {
1015
- const saCR = (agg.cacheRead + agg.input) > 0
1016
- ? Math.round(agg.cacheRead / (agg.cacheRead + agg.input) * 100) : 0;
1017
- const parts = [`↑${formatTokenCount(agg.input)}`, `↓${formatTokenCount(agg.output)}`];
1191
+ const saCR = Math.round(hitRate(agg.input, agg.cacheRead, agg.cacheWrite) * 100);
1192
+ const parts = [inputPart(agg.input, agg.cacheRead, agg.cacheWrite, agg.cacheSupported), `↓${formatTokenCount(agg.output)}`];
1018
1193
  if (agg.cacheSupported) parts.push(`⚡${saCR}%`);
1019
- if (agg.cost > 0) parts.push(`$${agg.cost.toFixed(2)}`);
1194
+ // All runs subscription-routed → flat-rate label; otherwise show the
1195
+ // paid-only summed cost (subscription runs already contribute $0).
1196
+ if (agg.subscriptionRuns === agg.count) parts.push("subscription");
1197
+ else if (agg.cost > 0) parts.push(`$${agg.cost.toFixed(2)}`);
1020
1198
  if (agg.durationMs > 0) parts.push(formatElapsedDuration(agg.durationMs));
1021
1199
  if (agg.toolUses > 0) parts.push(`${agg.toolUses} tools`);
1022
1200
  const countSuffix = agg.count > 1 ? ` (×${agg.count})` : "";
@@ -1085,12 +1263,16 @@ const ORCHESTRATOR_ROLES: Array<{ role: MainModelRole; label: string; descriptio
1085
1263
  { role: "plan", label: "Planner", description: "agents.orchestrators.plan" },
1086
1264
  { role: "debug", label: "Debugger", description: "agents.orchestrators.debug" },
1087
1265
  { role: "review", label: "Reviewer", description: "agents.orchestrators.review" },
1266
+ { role: "quick", label: "Quick", description: "agents.orchestrators.quick" },
1088
1267
  ];
1089
1268
 
1090
1269
  const SUBAGENT_ROLES: Array<{ role: AgentRole; label: string; description: string }> = [
1091
1270
  { role: "explore", label: "Explore", description: "agents.subagents.simple.explore" },
1092
1271
  { role: "librarian", label: "Librarian", description: "agents.subagents.simple.librarian" },
1093
1272
  { role: "task", label: "Task", description: "agents.subagents.simple.task" },
1273
+ { role: "advisor", label: "Advisor", description: "agents.subagents.simple.advisor" },
1274
+ { role: "deep-debugger", label: "Deep debugger", description: "agents.subagents.simple.deep-debugger" },
1275
+ { role: "reviewer", label: "Reviewer", description: "agents.subagents.simple.reviewer" },
1094
1276
  ];
1095
1277
 
1096
1278
  const PRESET_GROUP_ITEMS: Array<{ group: PresetGroup; label: string }> = [
@@ -1816,7 +1998,7 @@ async function showPresetVariantEditor(
1816
1998
  continue;
1817
1999
  }
1818
2000
  if (choice.startsWith("Enabled:")) {
1819
- await showBooleanSetting(orchestrator, ctx, "Enabled", [...variantPath, "enabled"]);
2001
+ await showBooleanSetting(orchestrator, ctx, "Enabled", [...variantPath, "enabled"], "Make this agent available for use", "Disable this agent so it is not used");
1820
2002
  continue;
1821
2003
  }
1822
2004
  const confirm = await selectOption(ctx, "Confirm delete?", [
@@ -1957,8 +2139,8 @@ async function showPresetEnabledSetting(
1957
2139
  const yesTitle = withTags("Yes", formatSourceTags(true, info));
1958
2140
  const noTitle = withTags("No", formatSourceTags(false, info));
1959
2141
  const choice = await selectOption(ctx, "Enabled", [
1960
- yesTitle,
1961
- noTitle,
2142
+ { title: yesTitle, description: "Make this preset available for selection" },
2143
+ { title: noTitle, description: "Hide this preset so it can no longer be selected" },
1962
2144
  opt("Back", "Return to the previous menu"),
1963
2145
  ]);
1964
2146
  if (!choice || choice === "Back") return;
@@ -2139,7 +2321,7 @@ async function showAfterEditCommands(orchestrator: Orchestrator, ctx: any): Prom
2139
2321
  continue;
2140
2322
  }
2141
2323
  if (commandChoice.startsWith("Enabled:")) {
2142
- await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"]);
2324
+ await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"], "Run this command when its triggers fire", "Keep this command configured but stop running it");
2143
2325
  continue;
2144
2326
  }
2145
2327
  if (commandChoice === "Triggers") {
@@ -2256,7 +2438,7 @@ async function showAfterImplementCommands(orchestrator: Orchestrator, ctx: any):
2256
2438
  continue;
2257
2439
  }
2258
2440
  if (commandChoice.startsWith("Enabled:")) {
2259
- await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"]);
2441
+ await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"], "Run this command when its triggers fire", "Keep this command configured but stop running it");
2260
2442
  continue;
2261
2443
  }
2262
2444
  const confirm = await selectOption(ctx, "Confirm delete?", [
@@ -2379,14 +2561,16 @@ async function showBooleanSetting(
2379
2561
  ctx: any,
2380
2562
  title: string,
2381
2563
  keyPath: string[],
2564
+ yesDescription = "Turn this setting on",
2565
+ noDescription = "Turn this setting off",
2382
2566
  ): Promise<void> {
2383
2567
  while (true) {
2384
2568
  const info = getConfigSourceInfo(orchestrator, keyPath);
2385
2569
  const yesTitle = withTags("Yes", formatSourceTags(true, info));
2386
2570
  const noTitle = withTags("No", formatSourceTags(false, info));
2387
2571
  const choice = await selectOption(ctx, title, [
2388
- yesTitle,
2389
- noTitle,
2572
+ { title: yesTitle, description: yesDescription },
2573
+ { title: noTitle, description: noDescription },
2390
2574
  ...buildResetOptions(orchestrator, keyPath),
2391
2575
  opt("Back", "Return to the previous menu"),
2392
2576
  ]);
@@ -2408,14 +2592,16 @@ async function showInvertedBooleanSetting(
2408
2592
  ctx: any,
2409
2593
  title: string,
2410
2594
  keyPath: string[],
2595
+ yesDescription = "Turn this setting on",
2596
+ noDescription = "Turn this setting off",
2411
2597
  ): Promise<void> {
2412
2598
  while (true) {
2413
2599
  const info = getConfigSourceInfo(orchestrator, keyPath);
2414
2600
  const yesTitle = withTags("Yes", formatSourceTags(false, info));
2415
2601
  const noTitle = withTags("No", formatSourceTags(true, info));
2416
2602
  const choice = await selectOption(ctx, title, [
2417
- yesTitle,
2418
- noTitle,
2603
+ { title: yesTitle, description: yesDescription },
2604
+ { title: noTitle, description: noDescription },
2419
2605
  ...buildResetOptions(orchestrator, keyPath),
2420
2606
  opt("Back", "Return to the previous menu"),
2421
2607
  ]);
@@ -2433,15 +2619,15 @@ async function showInvertedBooleanSetting(
2433
2619
  }
2434
2620
 
2435
2621
  async function showLogLevelSetting(orchestrator: Orchestrator, ctx: any): Promise<void> {
2436
- const levels: Array<{ value: PiPiConfig["general"]["logLevel"]; label: string }> = [
2437
- { value: "debug", label: "Debug" },
2438
- { value: "info", label: "Info" },
2439
- { value: "warn", label: "Warning" },
2440
- { value: "error", label: "Error" },
2622
+ const levels: Array<{ value: PiPiConfig["general"]["logLevel"]; label: string; description: string }> = [
2623
+ { value: "debug", label: "Debug", description: "Log everything, including detailed diagnostics for troubleshooting" },
2624
+ { value: "info", label: "Info", description: "Log normal activity plus warnings and errors" },
2625
+ { value: "warn", label: "Warning", description: "Log only warnings and errors" },
2626
+ { value: "error", label: "Error", description: "Log only errors" },
2441
2627
  ];
2442
2628
  while (true) {
2443
2629
  const info = getConfigSourceInfo(orchestrator, ["general", "logLevel"]);
2444
- const options: OptionInput[] = levels.map((entry) => withTags(entry.label, formatSourceTags(entry.value, info)));
2630
+ const options: OptionInput[] = levels.map((entry) => ({ title: withTags(entry.label, formatSourceTags(entry.value, info)), description: entry.description }));
2445
2631
  options.push(...buildResetOptions(orchestrator, ["general", "logLevel"]));
2446
2632
  options.push(opt("Back", "Return to the previous menu"));
2447
2633
  const choice = await selectOption(ctx, "Log level", options);
@@ -2471,11 +2657,11 @@ async function showGeneralSettings(orchestrator: Orchestrator, ctx: any): Promis
2471
2657
  ]);
2472
2658
  if (!choice || choice === "Back") return BACK;
2473
2659
  if (choice.startsWith("Commit automatically:")) {
2474
- await showBooleanSetting(orchestrator, ctx, "Commit automatically", ["general", "autoCommit"]);
2660
+ await showBooleanSetting(orchestrator, ctx, "Commit automatically", ["general", "autoCommit"], "Commit changes automatically as work progresses", "Leave committing to you");
2475
2661
  continue;
2476
2662
  }
2477
2663
  if (choice.startsWith("Ignore configs from other repos:")) {
2478
- await showInvertedBooleanSetting(orchestrator, ctx, "Ignore configs from other repos", ["general", "loadExtraRepoConfigs"]);
2664
+ await showInvertedBooleanSetting(orchestrator, ctx, "Ignore configs from other repos", ["general", "loadExtraRepoConfigs"], "Use only the root repo config and ignore configs from other registered repos", "Also load configs from the other registered repos");
2479
2665
  continue;
2480
2666
  }
2481
2667
  if (choice.startsWith("Log level:")) {
@@ -2483,7 +2669,7 @@ async function showGeneralSettings(orchestrator: Orchestrator, ctx: any): Promis
2483
2669
  continue;
2484
2670
  }
2485
2671
  if (choice.startsWith("Tracing:")) {
2486
- await showBooleanSetting(orchestrator, ctx, "Tracing", ["general", "tracing"]);
2672
+ await showBooleanSetting(orchestrator, ctx, "Tracing", ["general", "tracing"], "Capture full session traces to .pp/logs/traces/", "Do not record session traces");
2487
2673
  continue;
2488
2674
  }
2489
2675
  await showFlantInfraMenu(orchestrator, ctx);
@@ -2737,14 +2923,59 @@ async function pickModeForTaskStart(
2737
2923
  }
2738
2924
  }
2739
2925
 
2926
+ // Title: scannable human-readable intent + age. Callers must guarantee
2927
+ // uniqueness across the menu (see buildResumeOptions) so the option->task
2928
+ // mapping stays stable.
2740
2929
  function resumeOptionTitle(t: TaskInfo): string {
2741
- return taskName(t.dir);
2930
+ return `${taskNameFromState(t.dir, t.state)} — ${taskAge(t.state)}`;
2742
2931
  }
2743
2932
 
2744
- function resumeOptionDescription(t: TaskInfo): string {
2745
- const age = taskAge(t.state);
2746
- const phase = t.state.phase === t.type ? t.type : `${t.type}/${t.state.phase}`;
2747
- return `${phase}, ${age}`;
2933
+ // Description: rich per-entry detail sourced entirely from the already-loaded
2934
+ // TaskState (no extra disk reads). Empty/irrelevant fields are omitted.
2935
+ function resumeOptionDescription(t: TaskInfo, cwd: string): string {
2936
+ const s = t.state;
2937
+ const parts: string[] = [];
2938
+
2939
+ const phaseStep = s.step ? `${s.phase}/${s.step}` : s.phase;
2940
+ parts.push(`${t.type} · ${phaseStep}`);
2941
+
2942
+ const mode = s.effectiveMode ?? s.mode;
2943
+ if (mode) parts.push(mode);
2944
+
2945
+ if (s.reviewCycle && s.reviewCycle.pass > 0) parts.push(`review pass ${s.reviewCycle.pass}`);
2946
+
2947
+ const fileCount = s.modifiedFiles?.length ?? 0;
2948
+ if (fileCount > 0) parts.push(`${fileCount} file${fileCount === 1 ? "" : "s"}`);
2949
+
2950
+ // Only surface the repo when it is informative: a multi-repo task, or a root
2951
+ // repo that differs from the current project directory.
2952
+ const rootRepo = s.repos?.find((r) => r.isRoot) ?? s.repos?.[0];
2953
+ if (rootRepo && ((s.repos?.length ?? 0) > 1 || basename(rootRepo.path) !== basename(cwd))) {
2954
+ parts.push(`repo: ${basename(rootRepo.path)}`);
2955
+ }
2956
+
2957
+ parts.push(`id ${taskShortId(t.dir)}`);
2958
+ return parts.join(" · ");
2959
+ }
2960
+
2961
+ // Build menu options with a stable option->task index. Titles are made unique
2962
+ // (short id appended on collision) so selection maps back deterministically even
2963
+ // if two tasks share an intent, and the age component can't break the mapping
2964
+ // after the picker returns.
2965
+ function buildResumeOptions(tasks: TaskInfo[], cwd: string): { options: OptionInput[]; byTitle: Map<string, TaskInfo> } {
2966
+ const seen = new Map<string, number>();
2967
+ const byTitle = new Map<string, TaskInfo>();
2968
+ const options: OptionInput[] = [];
2969
+ for (const t of tasks) {
2970
+ let title = resumeOptionTitle(t);
2971
+ const count = seen.get(title) ?? 0;
2972
+ seen.set(title, count + 1);
2973
+ if (count > 0 || byTitle.has(title)) title = `${title} [${taskShortId(t.dir)}]`;
2974
+ while (byTitle.has(title)) title = `${title} ·`;
2975
+ byTitle.set(title, t);
2976
+ options.push({ title, description: resumeOptionDescription(t, cwd) });
2977
+ }
2978
+ return { options, byTitle };
2748
2979
  }
2749
2980
 
2750
2981
  async function showResumeMenu(
@@ -2760,16 +2991,13 @@ async function showResumeMenu(
2760
2991
  return BACK;
2761
2992
  }
2762
2993
 
2763
- const options: OptionInput[] = tasks.map((t) => ({
2764
- title: resumeOptionTitle(t),
2765
- description: resumeOptionDescription(t),
2766
- }));
2994
+ const { options, byTitle } = buildResumeOptions(tasks, orchestrator.cwd);
2767
2995
  options.push({ title: "Back", description: "Return to the previous menu" });
2768
2996
 
2769
2997
  const choice = await selectOption(ctx, "Resume", options);
2770
2998
  if (!choice || choice === "Back") return BACK;
2771
2999
 
2772
- const task = tasks.find((t) => resumeOptionTitle(t) === choice);
3000
+ const task = byTitle.get(choice);
2773
3001
  if (!task) continue;
2774
3002
  const result = await resumeTask(orchestrator, ctx, task);
2775
3003
  if (result.ok) return "started";
@@ -2906,30 +3134,24 @@ async function openCodeReviewInPlannotator(
2906
3134
  if (payload.diffType) requestPayload.diffType = payload.diffType;
2907
3135
  if (payload.defaultBranch) requestPayload.defaultBranch = payload.defaultBranch;
2908
3136
 
2909
- return await new Promise((resolve) => {
2910
- let handled = false;
2911
- const timer = setTimeout(() => {
2912
- if (!handled) resolve({ status: "error", error: "Plannotator is not available." });
2913
- }, 30000);
2914
- orchestrator.pi.events.emit("plannotator:request", {
2915
- requestId: crypto.randomUUID(),
2916
- action: "code-review",
2917
- payload: requestPayload,
2918
- respond: (response: any) => {
2919
- handled = true;
2920
- clearTimeout(timer);
2921
- if (response?.status !== "handled") {
2922
- resolve({ status: "error", error: response?.error || "Plannotator is not available." });
2923
- return;
2924
- }
2925
- const approved = !!response?.result?.approved;
2926
- const feedback = typeof response?.result?.feedback === "string" && response.result.feedback.trim().length > 0
2927
- ? response.result.feedback
2928
- : undefined;
2929
- resolve({ status: approved ? "approved" : "needs_changes", feedback });
2930
- },
2931
- });
2932
- });
3137
+ const { opened, reviewId } = await openPlannotator(orchestrator.pi, "code-review", requestPayload);
3138
+ if (!opened) {
3139
+ return { status: "error", error: "Plannotator is not available." };
3140
+ }
3141
+
3142
+ let result: { approved: boolean; feedback?: string; error?: string };
3143
+ try {
3144
+ result = await waitForPlannotatorResult(orchestrator, reviewId, null);
3145
+ } catch (err) {
3146
+ return { status: "error", error: err instanceof Error ? err.message : "Plannotator review failed." };
3147
+ }
3148
+ if (result.error) {
3149
+ return { status: "error", error: result.error };
3150
+ }
3151
+ const feedback = typeof result.feedback === "string" && result.feedback.trim().length > 0
3152
+ ? result.feedback
3153
+ : undefined;
3154
+ return { status: result.approved ? "approved" : "needs_changes", feedback };
2933
3155
  }
2934
3156
 
2935
3157
  async function startReviewTask(
@@ -3251,13 +3473,16 @@ async function showQuickTaskMenu(
3251
3473
  if (summary !== "/pp") headerLines.push(`\n\n${summary}`);
3252
3474
  const menuTitle = headerLines.join("");
3253
3475
 
3254
- const choice = await selectOption(ctx, menuTitle, [
3476
+ const { choice, cancelReason } = await selectOptionCancelable(ctx, menuTitle, [
3255
3477
  opt("Complete", "Mark task as done and clean up"),
3256
3478
  opt("Pause", "Suspend task to resume later"),
3257
3479
  opt("Info", "Subagents, usage, and task status"),
3258
3480
  opt("Settings", "Flant AI and other configuration"),
3259
3481
  opt("Back", "Return to the prompt and keep working"),
3260
3482
  ]);
3483
+ // A deliberate ESC in tool mode must stop the turn cleanly (mirror the
3484
+ // guided/autonomous branches); in command mode ESC just closes the menu.
3485
+ if (cancelReason === "user" && mode === "tool") return USER_CANCELLED;
3261
3486
  if (!choice || choice === "Back") return "";
3262
3487
  if (choice === "Info") {
3263
3488
  await showInfoMenu(orchestrator, ctx);
@@ -3282,7 +3507,7 @@ export async function showActiveTaskMenu(
3282
3507
  summary: string,
3283
3508
  mode: MenuMode = "command",
3284
3509
  ): Promise<string> {
3285
- const continueMessage = "User wants to continue. Run /pp when ready to advance.";
3510
+ const continueMessage = advanceBanner("[PI-PI] User wants to continue. Run /pp when ready to advance.");
3286
3511
 
3287
3512
  while (true) {
3288
3513
  if (!orchestrator.active) return "No active task.";
@@ -3306,13 +3531,17 @@ export async function showActiveTaskMenu(
3306
3531
  const opt = (title: string, description: string): OptionInput => ({ title, description });
3307
3532
 
3308
3533
  if (effectiveMode === "autonomous") {
3309
- const autoChoice = await selectOption(ctx, `/pp\n\nTask: ${task.type}\nPhase: ${phase}${summary !== "/pp" ? `\n\n${summary}` : ""}`, [
3534
+ const { choice: autoChoice, cancelReason } = await selectOptionCancelable(ctx, `/pp\n\nTask: ${task.type}\nPhase: ${phase}${summary !== "/pp" ? `\n\n${summary}` : ""}`, [
3310
3535
  opt("Complete task", "Mark task as done and clean up"),
3311
3536
  opt("Pause task", "Suspend task to resume later"),
3312
3537
  opt("Info", "Subagents, usage, and task status"),
3313
3538
  opt("Settings", "Flant AI and other configuration"),
3314
3539
  opt("Back", "Return to the prompt and keep working"),
3315
3540
  ]);
3541
+ // A deliberate ESC only needs distinct handling in tool mode (so
3542
+ // pp_phase_complete can stop the turn); in command mode ESC just closes
3543
+ // the menu like "Back".
3544
+ if (cancelReason === "user" && mode === "tool") return USER_CANCELLED;
3316
3545
  if (!autoChoice || autoChoice === "Back") return "";
3317
3546
  if (autoChoice === "Info") {
3318
3547
  await showInfoMenu(orchestrator, ctx);
@@ -3342,7 +3571,8 @@ export async function showActiveTaskMenu(
3342
3571
  const headerLines = [`/pp\n\nTask: ${task.type}\nPhase: ${phase}`];
3343
3572
  if (summary !== "/pp") headerLines.push(`\n\n${summary}`);
3344
3573
  const menuTitle = headerLines.join("");
3345
- const choice = await selectOption(ctx, menuTitle, options);
3574
+ const { choice, cancelReason } = await selectOptionCancelable(ctx, menuTitle, options);
3575
+ if (cancelReason === "user" && mode === "tool") return USER_CANCELLED;
3346
3576
  if (!choice || choice === "Back") {
3347
3577
  return "";
3348
3578
  }
@@ -3417,7 +3647,9 @@ export async function showActiveTaskMenu(
3417
3647
  if (hasPlannotator) {
3418
3648
  reviewOptions.push(opt("Review in Plannotator", phase === "plan" ? "Open plan review in browser" : "Open code diff review in browser"));
3419
3649
  }
3420
- reviewOptions.push(opt("Review on my own", "Review manually, then continue"));
3650
+ reviewOptions.push(opt("Review on my own", phase === "implement"
3651
+ ? "Review in your editor: mark spots with AI_REVIEW: comments, then have the agent address them"
3652
+ : "Review manually, then continue"));
3421
3653
  reviewOptions.push(opt("Back", "Return to the previous menu"));
3422
3654
 
3423
3655
  const reviewChoice = await selectOption(ctx, "Review", reviewOptions);
@@ -3433,8 +3665,23 @@ export async function showActiveTaskMenu(
3433
3665
  if (handled.continueLoop) continue;
3434
3666
  return handled.text ?? text;
3435
3667
  }
3436
- const repos = getRegisteredRepos(orchestrator);
3668
+ const allRepos = getRegisteredRepos(orchestrator);
3437
3669
  const summaries: string[] = [];
3670
+ const repos: RepoInfo[] = [];
3671
+ for (const repo of allRepos) {
3672
+ const base = await detectDefaultBranch(orchestrator, allRepos, repo.path);
3673
+ const { changed, error } = await repoHasReviewableChanges(orchestrator, repo, base);
3674
+ if (error) {
3675
+ summaries.push(`${formatRepoLabel(repo)}: ERROR — ${error}`);
3676
+ repos.push(repo);
3677
+ } else if (changed) {
3678
+ repos.push(repo);
3679
+ }
3680
+ }
3681
+ if (repos.length === 0) {
3682
+ ctx.ui.notify("No registered repositories have changes to review.", "info");
3683
+ continue;
3684
+ }
3438
3685
  let stopReviewing = false;
3439
3686
 
3440
3687
  for (const repo of repos) {
@@ -3503,10 +3750,35 @@ export async function showActiveTaskMenu(
3503
3750
  if (reviewChoice === "Review on my own") {
3504
3751
  if (phase === "plan") {
3505
3752
  setStep(orchestrator, "synthesize");
3506
- } else {
3753
+ return continueMessage;
3754
+ }
3755
+ if (phase !== "implement") {
3507
3756
  setStep(orchestrator, "llm_work");
3757
+ return continueMessage;
3508
3758
  }
3509
- return continueMessage;
3759
+
3760
+ const gate = await selectOption(ctx, "Editor review", [
3761
+ opt("Done", "I've added AI_REVIEW: markers and saved my files"),
3762
+ opt("Skip markers", "Continue without the marker workflow"),
3763
+ opt("Back", "Return to the review menu"),
3764
+ ]);
3765
+ if (!gate || gate === "Back") continue;
3766
+ setStep(orchestrator, "llm_work");
3767
+ if (gate === "Skip markers") {
3768
+ return continueMessage;
3769
+ }
3770
+ return advanceBanner(
3771
+ "[PI-PI] The user reviewed the changes in their editor and left inline `AI_REVIEW:` markers " +
3772
+ "(inside each file's native comment syntax, e.g. `// AI_REVIEW: ...`, `# AI_REVIEW: ...`, " +
3773
+ "`<!-- AI_REVIEW: ... -->`).\n\n" +
3774
+ "For each registered repo, enumerate the CHANGED files only — union of `git diff --name-only <base>...HEAD`, " +
3775
+ "`git diff --name-only`, `git diff --cached --name-only`, and untracked-non-ignored files from `git status --porcelain` " +
3776
+ "(base = each repo's configured base branch) — and search WITHIN those files for `AI_REVIEW:`. Do NOT grep the whole " +
3777
+ "worktree (avoid vendored/generated/node_modules and historical markers).\n\n" +
3778
+ "For each marker: address the request, then remove that marker in the SAME edit. After a pass, re-scan the changed " +
3779
+ "files and repeat until no `AI_REVIEW:` markers remain. Then verify your work and report what you changed per marker. " +
3780
+ "When complete, call pp_phase_complete.",
3781
+ );
3510
3782
  }
3511
3783
 
3512
3784
  const reviewPreset = await pickPreset(ctx, orchestrator, getReviewPresetGroup(phase), "Review preset");