@ilya-lesikov/pi-pi 0.6.0 → 0.7.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,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,
@@ -44,6 +44,8 @@ import {
44
44
  saveTask,
45
45
  taskAge,
46
46
  taskName,
47
+ taskNameFromState,
48
+ taskShortId,
47
49
  type AutonomousConfig,
48
50
  type TaskMode,
49
51
  type TaskInfo,
@@ -53,6 +55,8 @@ import {
53
55
  clearFlantGeneratedConfig,
54
56
  getFlantGeneratedConfig,
55
57
  loadFlantSettings,
58
+ readClaudeOAuthToken,
59
+ readGatewayApiKey,
56
60
  saveFlantSettings,
57
61
  unregisterFlantProviders,
58
62
  updateFlantInfra,
@@ -67,6 +71,13 @@ type MenuMode = "command" | "tool";
67
71
 
68
72
  const BACK = "back" as const;
69
73
 
74
+ // Sentinel returned by showActiveTaskMenu when the user dismissed the top-level
75
+ // menu with a deliberate ESC (cancelReason "user"), as opposed to selecting the
76
+ // "Back" option or a programmatic dismissal. Callers (pp_phase_complete) use it
77
+ // to stop the turn cleanly instead of returning reminder text that starts a new
78
+ // LLM turn. It is intentionally distinct from the empty-string "Back" return.
79
+ export const USER_CANCELLED = "\u0000user-cancelled" as const;
80
+
70
81
  type OptionInput = string | { title: string; description?: string };
71
82
 
72
83
  type TimeoutKey =
@@ -81,6 +92,18 @@ function isEnabled(value: { enabled?: boolean } | undefined): boolean {
81
92
  }
82
93
 
83
94
  async function selectOption(ctx: any, question: string, options: OptionInput[]): Promise<string | undefined> {
95
+ return (await selectOptionCancelable(ctx, question, options)).choice;
96
+ }
97
+
98
+ // Like selectOption but also surfaces the cancel reason so callers can tell a
99
+ // deliberate user ESC (reason "user") apart from a normal non-selection. Used by
100
+ // showActiveTaskMenu so pp_phase_complete can stop the turn cleanly on ESC
101
+ // (mirroring ask_user) while keeping the "Back" navigation reminder.
102
+ async function selectOptionCancelable(
103
+ ctx: any,
104
+ question: string,
105
+ options: OptionInput[],
106
+ ): Promise<{ choice?: string; cancelReason?: CancelReason }> {
84
107
  const result = await askUser(ctx, {
85
108
  question,
86
109
  options,
@@ -88,8 +111,9 @@ async function selectOption(ctx: any, question: string, options: OptionInput[]):
88
111
  allowComment: false,
89
112
  allowMultiple: false,
90
113
  });
91
- if (!result || isCancel(result) || result.kind !== "selection") return undefined;
92
- return result.selections[0];
114
+ if (result && isCancel(result)) return { cancelReason: result.reason };
115
+ if (!result || result.kind !== "selection") return {};
116
+ return { choice: result.selections[0] };
93
117
  }
94
118
 
95
119
  function opt(title: string, description: string): OptionInput {
@@ -783,6 +807,20 @@ function flantStatusText(settings: FlantSettings): string {
783
807
  `Last updated: ${settings.lastUpdated ?? "never"}`,
784
808
  `Providers: pp-flant-anthropic (${providers.anthropic} models), pp-flant-openai (${providers.openai} models)`,
785
809
  ];
810
+ if (settings.subscription) {
811
+ const hasOAuth = !!readClaudeOAuthToken();
812
+ const hasGatewayKey = !!readGatewayApiKey();
813
+ const subActive = hasOAuth && hasGatewayKey;
814
+ lines.push(
815
+ `Personal subscription: on (${subActive ? `active — pp-flant-anthropic-sub, ${providers.anthropic} models` : "inactive"})`,
816
+ );
817
+ if (!subActive) {
818
+ if (!hasOAuth) lines.push(" - missing Claude OAuth token (run pi /login for Anthropic)");
819
+ if (!hasGatewayKey) lines.push(" - missing gateway key (set LLM_API_KEY or FLANT_API_KEY)");
820
+ }
821
+ } else {
822
+ lines.push("Personal subscription: off");
823
+ }
786
824
  if (assignments.length === 0) {
787
825
  lines.push("Role assignments: none");
788
826
  } else {
@@ -812,10 +850,12 @@ async function showFlantInfraMenu(orchestrator: Orchestrator, ctx: any): Promise
812
850
  const settings = loadFlantSettings();
813
851
  const enableLabel = `Enable: ${settings.enabled ? "ON" : "OFF"}`;
814
852
  const options: OptionInput[] = [enableLabel];
853
+ const subscriptionLabel = `Personal Claude subscription: ${settings.subscription ? "ON" : "OFF"}`;
815
854
  if (settings.enabled) {
816
855
  options.push(
817
856
  `Auto-update on startup: ${settings.autoUpdate ? "ON" : "OFF"}`,
818
857
  `Cache period: ${settings.cacheTTLDays} ${settings.cacheTTLDays === 1 ? "day" : "days"}`,
858
+ subscriptionLabel,
819
859
  "Update now",
820
860
  "Current status",
821
861
  );
@@ -852,6 +892,34 @@ async function showFlantInfraMenu(orchestrator: Orchestrator, ctx: any): Promise
852
892
  continue;
853
893
  }
854
894
 
895
+ if (choice === subscriptionLabel) {
896
+ const turningOn = !settings.subscription;
897
+ if (turningOn) {
898
+ if (!readClaudeOAuthToken()) {
899
+ ctx.ui.notify("No Claude OAuth token found. Log in to your personal Claude subscription in pi first (/login → Anthropic), then retry.", "warning");
900
+ continue;
901
+ }
902
+ if (!readGatewayApiKey()) {
903
+ ctx.ui.notify("Set LLM_API_KEY (or FLANT_API_KEY) for the gateway first.", "warning");
904
+ continue;
905
+ }
906
+ }
907
+ const next = { ...settings, subscription: turningOn };
908
+ saveFlantSettings(next);
909
+ const result = await updateFlantInfra(orchestrator.pi);
910
+ if (!result.ok) {
911
+ ctx.ui.notify(`Personal subscription ${turningOn ? "enable" : "disable"} failed: ${result.error ?? "unknown error"}`, "error");
912
+ } else {
913
+ ctx.ui.notify(
914
+ turningOn
915
+ ? "Personal Claude subscription ON — Claude roles now route through sub/claude-* (billed to your subscription); non-Claude roles stay on llm-api.flant.ru."
916
+ : "Personal Claude subscription OFF — Claude roles reverted to pp-flant-anthropic.",
917
+ "info",
918
+ );
919
+ }
920
+ continue;
921
+ }
922
+
855
923
  if (choice.startsWith("Cache period:")) {
856
924
  const selected = await selectOption(ctx, "Cache period", [
857
925
  { title: "1 day", description: "Refresh model metadata daily" },
@@ -899,17 +967,18 @@ function formatElapsedDuration(ms: number): string {
899
967
  return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
900
968
  }
901
969
 
902
- function showUsage(ctx: any): void {
970
+ export function showUsage(ctx: any): void {
903
971
  const tracker = (globalThis as any)[Symbol.for("pi-pi:usage-tracker")] as
904
972
  | {
905
973
  getTotalInputTokens(): number; getTotalOutputTokens(): number;
906
974
  getTotalCacheReadTokens(): number; getTotalCacheWriteTokens(): number;
975
+ getTotalProcessedInputTokens(): number;
907
976
  getTotalCost(): number; getCacheHitRate(): number;
908
977
  getMainInputTokens(): number; getMainOutputTokens(): number;
909
978
  getMainCacheReadTokens(): number; getMainCacheWriteTokens(): number;
910
979
  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 }>;
980
+ getPerModelUsage(): Record<string, { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; cacheSupported: boolean; turns: number; subscription: boolean }>;
981
+ 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
982
  }
914
983
  | undefined;
915
984
 
@@ -918,30 +987,52 @@ function showUsage(ctx: any): void {
918
987
  return;
919
988
  }
920
989
 
921
- const totalInput = tracker.getTotalInputTokens();
990
+ // Token-weighted cache hit rate: cache reads over all processed input
991
+ // (uncached + cache read + cache write). Same formula everywhere so the
992
+ // total, per-model, and per-agent percentages are directly comparable.
993
+ const hitRate = (uncached: number, cacheRead: number, cacheWrite: number): number => {
994
+ const processed = uncached + cacheRead + cacheWrite;
995
+ return processed > 0 ? cacheRead / processed : 0;
996
+ };
997
+
998
+ // Compact one-line input part: total processed input with an inline
999
+ // uncached / cache read / cache write breakdown, e.g. "↑1.3k (u84 r1.0k w200)".
1000
+ const inputPart = (uncached: number, cacheRead: number, cacheWrite: number, cacheSupported: boolean): string => {
1001
+ const processed = uncached + cacheRead + cacheWrite;
1002
+ const head = `↑${formatTokenCount(processed)}`;
1003
+ if (!cacheSupported) return head;
1004
+ return `${head} (u${formatTokenCount(uncached)} r${formatTokenCount(cacheRead)} w${formatTokenCount(cacheWrite)})`;
1005
+ };
1006
+
1007
+ const totalUncachedInput = tracker.getTotalInputTokens();
922
1008
  const totalOutput = tracker.getTotalOutputTokens();
923
1009
  const totalCacheRead = tracker.getTotalCacheReadTokens();
1010
+ const totalCacheWrite = tracker.getTotalCacheWriteTokens();
1011
+ const totalProcessedInput = tracker.getTotalProcessedInputTokens();
924
1012
  const totalCost = tracker.getTotalCost();
925
- const totalCacheRate = (totalCacheRead + totalInput) > 0
926
- ? totalCacheRead / (totalCacheRead + totalInput)
927
- : 0;
1013
+ const totalCacheRate = tracker.getCacheHitRate();
928
1014
 
929
1015
  const mainInput = tracker.getMainInputTokens();
930
1016
  const mainOutput = tracker.getMainOutputTokens();
931
1017
  const mainCacheRead = tracker.getMainCacheReadTokens();
1018
+ const mainCacheWrite = tracker.getMainCacheWriteTokens();
932
1019
  const mainCost = tracker.getMainCost();
933
1020
  const models = tracker.getPerModelUsage();
934
1021
  const subagents = tracker.getSubagentList();
935
1022
 
936
- const byModel = new Map<string, { input: number; output: number; cacheRead: number; cacheWrite: number; cacheSupported: boolean; cost: number }>();
1023
+ const byModel = new Map<string, { input: number; output: number; cacheRead: number; cacheWrite: number; cacheSupported: boolean; cost: number; subscription: boolean }>();
937
1024
  const mainModelEntries = Object.entries(models);
938
- const mainTotalTokens = mainModelEntries.reduce((s, [, u]) => s + u.inputTokens + u.outputTokens, 0);
1025
+ // Subscription (flat-rate) models contribute no dollars, so exclude their
1026
+ // tokens from the proportional-share denominator or paid rows would be
1027
+ // inflated and the sub/ rows would receive a spurious share.
1028
+ const mainTotalTokens = mainModelEntries.reduce((s, [, u]) => s + (u.subscription ? 0 : u.inputTokens + u.outputTokens), 0);
939
1029
  for (const [modelId, usage] of mainModelEntries) {
940
1030
  const modelTokens = usage.inputTokens + usage.outputTokens;
941
- const modelCostShare = mainTotalTokens > 0 ? mainCost * (modelTokens / mainTotalTokens) : 0;
1031
+ const modelCostShare = usage.subscription || mainTotalTokens <= 0 ? 0 : mainCost * (modelTokens / mainTotalTokens);
942
1032
  byModel.set(modelId, {
943
1033
  input: usage.inputTokens, output: usage.outputTokens,
944
1034
  cacheRead: usage.cacheReadTokens, cacheWrite: usage.cacheWriteTokens, cacheSupported: usage.cacheSupported, cost: modelCostShare,
1035
+ subscription: usage.subscription,
945
1036
  });
946
1037
  }
947
1038
  for (const sa of subagents) {
@@ -954,28 +1045,37 @@ function showUsage(ctx: any): void {
954
1045
  existing.cacheWrite += sa.cacheWriteTokens;
955
1046
  if (sa.cacheSupported) existing.cacheSupported = true;
956
1047
  existing.cost += sa.cost;
1048
+ if (sa.subscription) existing.subscription = true;
957
1049
  } else {
958
1050
  byModel.set(key, {
959
1051
  input: sa.inputTokens, output: sa.outputTokens,
960
1052
  cacheRead: sa.cacheReadTokens, cacheWrite: sa.cacheWriteTokens, cacheSupported: sa.cacheSupported, cost: sa.cost,
1053
+ subscription: sa.subscription,
961
1054
  });
962
1055
  }
963
1056
  }
964
1057
 
1058
+ // Total input the model processed = uncached + cache read + cache write.
1059
+ // Shown as an explicit breakdown so the (often tiny) uncached sliver no
1060
+ // longer masquerades as the whole "Input" figure.
965
1061
  const lines: string[] = ["Session usage (total):"];
966
- lines.push(` Input: ${formatTokenCount(totalInput)} tokens`);
1062
+ lines.push(` Input: ${formatTokenCount(totalProcessedInput)} tokens`);
1063
+ lines.push(` • uncached: ${formatTokenCount(totalUncachedInput)}`);
1064
+ lines.push(` • cache read: ${formatTokenCount(totalCacheRead)}`);
1065
+ lines.push(` • cache write: ${formatTokenCount(totalCacheWrite)}`);
967
1066
  lines.push(` Output: ${formatTokenCount(totalOutput)} tokens`);
968
1067
  if (totalCacheRead > 0) lines.push(` Cache: ⚡${Math.round(totalCacheRate * 100)}% hit rate`);
969
- if (totalCost > 0) lines.push(` Cost: $${totalCost.toFixed(2)}`);
1068
+ lines.push(` Cost: $${totalCost.toFixed(2)}`);
970
1069
 
971
1070
  if (byModel.size > 0) {
972
1071
  lines.push("");
973
1072
  lines.push("By model:");
974
1073
  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)}`];
1074
+ const cr = Math.round(hitRate(m.input, m.cacheRead, m.cacheWrite) * 100);
1075
+ const parts = [inputPart(m.input, m.cacheRead, m.cacheWrite, m.cacheSupported), `↓${formatTokenCount(m.output)}`];
977
1076
  if (m.cacheSupported) parts.push(`⚡${cr}%`);
978
- if (m.cost > 0) parts.push(`$${m.cost.toFixed(2)}`);
1077
+ if (m.subscription) parts.push("subscription");
1078
+ else if (m.cost > 0) parts.push(`$${m.cost.toFixed(2)}`);
979
1079
  lines.push(` ${modelId}: ${parts.join(" ")}`);
980
1080
  }
981
1081
  }
@@ -985,13 +1085,15 @@ function showUsage(ctx: any): void {
985
1085
  const agentModelNames = Object.keys(models);
986
1086
  if (agentModelNames.length > 0) {
987
1087
  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;
1088
+ const mainAllSubscription = mainModelEntries.every(([, u]) => u.subscription);
1089
+ const mainParts = [inputPart(mainInput, mainCacheRead, mainCacheWrite, mainCacheSupported), `↓${formatTokenCount(mainOutput)}`];
1090
+ const mainCR = Math.round(hitRate(mainInput, mainCacheRead, mainCacheWrite) * 100);
990
1091
  if (mainCacheSupported) mainParts.push(`⚡${mainCR}%`);
991
- if (mainCost > 0) mainParts.push(`$${mainCost.toFixed(2)}`);
1092
+ if (mainAllSubscription) mainParts.push("subscription");
1093
+ else if (mainCost > 0) mainParts.push(`$${mainCost.toFixed(2)}`);
992
1094
  lines.push(` Main (${agentModelNames.join(", ")}): ${mainParts.join(" ")}`);
993
1095
  }
994
- const byAgentType = new Map<string, { input: number; output: number; cacheRead: number; cacheSupported: boolean; cost: number; durationMs: number; toolUses: number; count: number }>();
1096
+ 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
1097
  for (const sa of subagents) {
996
1098
  const key = sa.agentType || sa.description;
997
1099
  const existing = byAgentType.get(key);
@@ -999,24 +1101,29 @@ function showUsage(ctx: any): void {
999
1101
  existing.input += sa.inputTokens;
1000
1102
  existing.output += sa.outputTokens;
1001
1103
  existing.cacheRead += sa.cacheReadTokens;
1104
+ existing.cacheWrite += sa.cacheWriteTokens;
1002
1105
  if (sa.cacheSupported) existing.cacheSupported = true;
1003
1106
  existing.cost += sa.cost;
1004
1107
  existing.durationMs += sa.durationMs;
1005
1108
  existing.toolUses += sa.toolUses;
1006
1109
  existing.count += 1;
1110
+ if (sa.subscription) existing.subscriptionRuns += 1;
1007
1111
  } else {
1008
1112
  byAgentType.set(key, {
1009
- input: sa.inputTokens, output: sa.outputTokens, cacheRead: sa.cacheReadTokens,
1113
+ input: sa.inputTokens, output: sa.outputTokens, cacheRead: sa.cacheReadTokens, cacheWrite: sa.cacheWriteTokens,
1010
1114
  cacheSupported: sa.cacheSupported, cost: sa.cost, durationMs: sa.durationMs, toolUses: sa.toolUses, count: 1,
1115
+ subscriptionRuns: sa.subscription ? 1 : 0,
1011
1116
  });
1012
1117
  }
1013
1118
  }
1014
1119
  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)}`];
1120
+ const saCR = Math.round(hitRate(agg.input, agg.cacheRead, agg.cacheWrite) * 100);
1121
+ const parts = [inputPart(agg.input, agg.cacheRead, agg.cacheWrite, agg.cacheSupported), `↓${formatTokenCount(agg.output)}`];
1018
1122
  if (agg.cacheSupported) parts.push(`⚡${saCR}%`);
1019
- if (agg.cost > 0) parts.push(`$${agg.cost.toFixed(2)}`);
1123
+ // All runs subscription-routed → flat-rate label; otherwise show the
1124
+ // paid-only summed cost (subscription runs already contribute $0).
1125
+ if (agg.subscriptionRuns === agg.count) parts.push("subscription");
1126
+ else if (agg.cost > 0) parts.push(`$${agg.cost.toFixed(2)}`);
1020
1127
  if (agg.durationMs > 0) parts.push(formatElapsedDuration(agg.durationMs));
1021
1128
  if (agg.toolUses > 0) parts.push(`${agg.toolUses} tools`);
1022
1129
  const countSuffix = agg.count > 1 ? ` (×${agg.count})` : "";
@@ -1085,6 +1192,7 @@ const ORCHESTRATOR_ROLES: Array<{ role: MainModelRole; label: string; descriptio
1085
1192
  { role: "plan", label: "Planner", description: "agents.orchestrators.plan" },
1086
1193
  { role: "debug", label: "Debugger", description: "agents.orchestrators.debug" },
1087
1194
  { role: "review", label: "Reviewer", description: "agents.orchestrators.review" },
1195
+ { role: "quick", label: "Quick", description: "agents.orchestrators.quick" },
1088
1196
  ];
1089
1197
 
1090
1198
  const SUBAGENT_ROLES: Array<{ role: AgentRole; label: string; description: string }> = [
@@ -2737,14 +2845,59 @@ async function pickModeForTaskStart(
2737
2845
  }
2738
2846
  }
2739
2847
 
2848
+ // Title: scannable human-readable intent + age. Callers must guarantee
2849
+ // uniqueness across the menu (see buildResumeOptions) so the option->task
2850
+ // mapping stays stable.
2740
2851
  function resumeOptionTitle(t: TaskInfo): string {
2741
- return taskName(t.dir);
2852
+ return `${taskNameFromState(t.dir, t.state)} — ${taskAge(t.state)}`;
2742
2853
  }
2743
2854
 
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}`;
2855
+ // Description: rich per-entry detail sourced entirely from the already-loaded
2856
+ // TaskState (no extra disk reads). Empty/irrelevant fields are omitted.
2857
+ function resumeOptionDescription(t: TaskInfo, cwd: string): string {
2858
+ const s = t.state;
2859
+ const parts: string[] = [];
2860
+
2861
+ const phaseStep = s.step ? `${s.phase}/${s.step}` : s.phase;
2862
+ parts.push(`${t.type} · ${phaseStep}`);
2863
+
2864
+ const mode = s.effectiveMode ?? s.mode;
2865
+ if (mode) parts.push(mode);
2866
+
2867
+ if (s.reviewCycle && s.reviewCycle.pass > 0) parts.push(`review pass ${s.reviewCycle.pass}`);
2868
+
2869
+ const fileCount = s.modifiedFiles?.length ?? 0;
2870
+ if (fileCount > 0) parts.push(`${fileCount} file${fileCount === 1 ? "" : "s"}`);
2871
+
2872
+ // Only surface the repo when it is informative: a multi-repo task, or a root
2873
+ // repo that differs from the current project directory.
2874
+ const rootRepo = s.repos?.find((r) => r.isRoot) ?? s.repos?.[0];
2875
+ if (rootRepo && ((s.repos?.length ?? 0) > 1 || basename(rootRepo.path) !== basename(cwd))) {
2876
+ parts.push(`repo: ${basename(rootRepo.path)}`);
2877
+ }
2878
+
2879
+ parts.push(`id ${taskShortId(t.dir)}`);
2880
+ return parts.join(" · ");
2881
+ }
2882
+
2883
+ // Build menu options with a stable option->task index. Titles are made unique
2884
+ // (short id appended on collision) so selection maps back deterministically even
2885
+ // if two tasks share an intent, and the age component can't break the mapping
2886
+ // after the picker returns.
2887
+ function buildResumeOptions(tasks: TaskInfo[], cwd: string): { options: OptionInput[]; byTitle: Map<string, TaskInfo> } {
2888
+ const seen = new Map<string, number>();
2889
+ const byTitle = new Map<string, TaskInfo>();
2890
+ const options: OptionInput[] = [];
2891
+ for (const t of tasks) {
2892
+ let title = resumeOptionTitle(t);
2893
+ const count = seen.get(title) ?? 0;
2894
+ seen.set(title, count + 1);
2895
+ if (count > 0 || byTitle.has(title)) title = `${title} [${taskShortId(t.dir)}]`;
2896
+ while (byTitle.has(title)) title = `${title} ·`;
2897
+ byTitle.set(title, t);
2898
+ options.push({ title, description: resumeOptionDescription(t, cwd) });
2899
+ }
2900
+ return { options, byTitle };
2748
2901
  }
2749
2902
 
2750
2903
  async function showResumeMenu(
@@ -2760,16 +2913,13 @@ async function showResumeMenu(
2760
2913
  return BACK;
2761
2914
  }
2762
2915
 
2763
- const options: OptionInput[] = tasks.map((t) => ({
2764
- title: resumeOptionTitle(t),
2765
- description: resumeOptionDescription(t),
2766
- }));
2916
+ const { options, byTitle } = buildResumeOptions(tasks, orchestrator.cwd);
2767
2917
  options.push({ title: "Back", description: "Return to the previous menu" });
2768
2918
 
2769
2919
  const choice = await selectOption(ctx, "Resume", options);
2770
2920
  if (!choice || choice === "Back") return BACK;
2771
2921
 
2772
- const task = tasks.find((t) => resumeOptionTitle(t) === choice);
2922
+ const task = byTitle.get(choice);
2773
2923
  if (!task) continue;
2774
2924
  const result = await resumeTask(orchestrator, ctx, task);
2775
2925
  if (result.ok) return "started";
@@ -3251,13 +3401,16 @@ async function showQuickTaskMenu(
3251
3401
  if (summary !== "/pp") headerLines.push(`\n\n${summary}`);
3252
3402
  const menuTitle = headerLines.join("");
3253
3403
 
3254
- const choice = await selectOption(ctx, menuTitle, [
3404
+ const { choice, cancelReason } = await selectOptionCancelable(ctx, menuTitle, [
3255
3405
  opt("Complete", "Mark task as done and clean up"),
3256
3406
  opt("Pause", "Suspend task to resume later"),
3257
3407
  opt("Info", "Subagents, usage, and task status"),
3258
3408
  opt("Settings", "Flant AI and other configuration"),
3259
3409
  opt("Back", "Return to the prompt and keep working"),
3260
3410
  ]);
3411
+ // A deliberate ESC in tool mode must stop the turn cleanly (mirror the
3412
+ // guided/autonomous branches); in command mode ESC just closes the menu.
3413
+ if (cancelReason === "user" && mode === "tool") return USER_CANCELLED;
3261
3414
  if (!choice || choice === "Back") return "";
3262
3415
  if (choice === "Info") {
3263
3416
  await showInfoMenu(orchestrator, ctx);
@@ -3306,13 +3459,17 @@ export async function showActiveTaskMenu(
3306
3459
  const opt = (title: string, description: string): OptionInput => ({ title, description });
3307
3460
 
3308
3461
  if (effectiveMode === "autonomous") {
3309
- const autoChoice = await selectOption(ctx, `/pp\n\nTask: ${task.type}\nPhase: ${phase}${summary !== "/pp" ? `\n\n${summary}` : ""}`, [
3462
+ const { choice: autoChoice, cancelReason } = await selectOptionCancelable(ctx, `/pp\n\nTask: ${task.type}\nPhase: ${phase}${summary !== "/pp" ? `\n\n${summary}` : ""}`, [
3310
3463
  opt("Complete task", "Mark task as done and clean up"),
3311
3464
  opt("Pause task", "Suspend task to resume later"),
3312
3465
  opt("Info", "Subagents, usage, and task status"),
3313
3466
  opt("Settings", "Flant AI and other configuration"),
3314
3467
  opt("Back", "Return to the prompt and keep working"),
3315
3468
  ]);
3469
+ // A deliberate ESC only needs distinct handling in tool mode (so
3470
+ // pp_phase_complete can stop the turn); in command mode ESC just closes
3471
+ // the menu like "Back".
3472
+ if (cancelReason === "user" && mode === "tool") return USER_CANCELLED;
3316
3473
  if (!autoChoice || autoChoice === "Back") return "";
3317
3474
  if (autoChoice === "Info") {
3318
3475
  await showInfoMenu(orchestrator, ctx);
@@ -3342,7 +3499,8 @@ export async function showActiveTaskMenu(
3342
3499
  const headerLines = [`/pp\n\nTask: ${task.type}\nPhase: ${phase}`];
3343
3500
  if (summary !== "/pp") headerLines.push(`\n\n${summary}`);
3344
3501
  const menuTitle = headerLines.join("");
3345
- const choice = await selectOption(ctx, menuTitle, options);
3502
+ const { choice, cancelReason } = await selectOptionCancelable(ctx, menuTitle, options);
3503
+ if (cancelReason === "user" && mode === "tool") return USER_CANCELLED;
3346
3504
  if (!choice || choice === "Back") {
3347
3505
  return "";
3348
3506
  }
@@ -262,30 +262,51 @@ export function validateFromPath(cwd: string, fromPath: string): { ok: true; dir
262
262
 
263
263
  export function taskName(taskDir: string): string {
264
264
  try {
265
- const state = loadTask(taskDir);
266
- let desc = state.description ?? "";
267
-
268
- if (["implement", "debug", "brainstorm", "review", "quick"].includes(desc)) {
269
- const urPath = join(taskDir, "USER_REQUEST.md");
270
- if (existsSync(urPath)) {
271
- const content = readFileSync(urPath, "utf-8");
272
- const lines = content.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
273
- const firstContent = lines.find((l) => !l.startsWith("#"));
274
- if (firstContent) desc = firstContent;
275
- }
276
- }
277
-
278
- if (desc) {
279
- desc = desc.replace(/\s+/g, " ").trim();
280
- if (desc.length > 60) desc = desc.slice(0, 57) + "...";
281
- return desc;
282
- }
265
+ return taskNameFromState(taskDir, loadTask(taskDir));
283
266
  } catch {
284
267
  getLogger().warn({ s: "state", taskDir }, "failed to read task name");
268
+ return dirSlugName(taskDir);
269
+ }
270
+ }
271
+
272
+ // Same as taskName but reuses an already-loaded TaskState (avoids re-reading
273
+ // state.json per resume-menu entry). The only per-entry disk read is the
274
+ // USER_REQUEST.md fallback, which is unavoidable for generic descriptions.
275
+ export function taskNameFromState(taskDir: string, state: TaskState): string {
276
+ let desc = state.description ?? "";
277
+
278
+ if (["implement", "debug", "brainstorm", "review", "quick"].includes(desc)) {
279
+ const urPath = join(taskDir, "USER_REQUEST.md");
280
+ if (existsSync(urPath)) {
281
+ const content = readFileSync(urPath, "utf-8");
282
+ const lines = content.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
283
+ const firstContent = lines.find((l) => !l.startsWith("#"));
284
+ if (firstContent) desc = firstContent;
285
+ }
285
286
  }
287
+
288
+ if (desc) {
289
+ desc = desc.replace(/\s+/g, " ").trim();
290
+ if (desc.length > 60) desc = desc.slice(0, 57) + "...";
291
+ return desc;
292
+ }
293
+ return dirSlugName(taskDir);
294
+ }
295
+
296
+ function dirSlugName(taskDir: string): string {
297
+ const dir = basename(taskDir);
298
+ const idx = dir.indexOf("_");
299
+ const slug = idx >= 0 ? dir.slice(idx + 1) : dir;
300
+ return slug.replace(/-/g, " ");
301
+ }
302
+
303
+ // The task's short id is the leading segment of its dir name (`${id}_${slug}`),
304
+ // where id is a 12-char uuid slice from createTask. Used to disambiguate
305
+ // otherwise-identical resume-menu titles.
306
+ export function taskShortId(taskDir: string): string {
286
307
  const dir = basename(taskDir);
287
- const match = dir.match(/^\d+_(.+)$/);
288
- return match ? match[1].replace(/-/g, " ") : dir;
308
+ const idPart = dir.split("_", 1)[0] ?? dir;
309
+ return idPart.slice(0, 6);
289
310
  }
290
311
 
291
312
  export function taskAge(state: TaskState): string {
@@ -9,7 +9,10 @@ export interface MenuExpectation {
9
9
  include?: TextMatch[];
10
10
  exclude?: TextMatch[];
11
11
  };
12
- choose: TextMatch | ((options: string[]) => string);
12
+ // When set, the harness returns a cancel (as askUser/isCancel would on ESC)
13
+ // instead of choosing an option. `choose` is ignored in that case.
14
+ cancel?: "user" | "timeout" | "signal";
15
+ choose?: TextMatch | ((options: string[]) => string);
13
16
  }
14
17
 
15
18
  export interface MenuTranscriptEntry {
@@ -21,7 +24,7 @@ export interface MenuTranscriptEntry {
21
24
  export interface AskUserHarness {
22
25
  transcript: MenuTranscriptEntry[];
23
26
  expect(step: MenuExpectation): AskUserHarness;
24
- handle(opts: any): Promise<{ kind: "selection"; selections: [string] }>;
27
+ handle(opts: any): Promise<{ kind: "selection"; selections: [string] } | { __cancel: true; reason: "user" | "timeout" | "signal" }>;
25
28
  assertDone(): void;
26
29
  }
27
30
 
@@ -123,6 +126,15 @@ export function createAskUserHarness(): AskUserHarness {
123
126
  }
124
127
  }
125
128
 
129
+ if (expected.cancel) {
130
+ transcript.push({ question, options, chosen: `<cancel:${expected.cancel}>` });
131
+ return { __cancel: true, reason: expected.cancel };
132
+ }
133
+
134
+ if (expected.choose === undefined) {
135
+ throw new Error(`Menu expectation is missing a chooser\n${renderMenu(question, options)}`);
136
+ }
137
+
126
138
  let chosen: string;
127
139
  if (typeof expected.choose === "function") {
128
140
  const maybeChoice = (expected.choose as (options: string[]) => unknown)(titles);