@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.
- package/3p/pi-ask-user/index.ts +1 -1
- package/extensions/orchestrator/config.ts +3 -2
- package/extensions/orchestrator/custom-footer.ts +5 -2
- package/extensions/orchestrator/doctor.test.ts +3 -1
- package/extensions/orchestrator/doctor.ts +40 -2
- package/extensions/orchestrator/event-handlers.ts +47 -6
- package/extensions/orchestrator/flant-infra.test.ts +287 -0
- package/extensions/orchestrator/flant-infra.ts +316 -44
- package/extensions/orchestrator/integration.test.ts +205 -6
- package/extensions/orchestrator/model-registry.test.ts +76 -12
- package/extensions/orchestrator/model-registry.ts +48 -32
- package/extensions/orchestrator/orchestrator.ts +13 -2
- package/extensions/orchestrator/phases/review.test.ts +54 -0
- package/extensions/orchestrator/phases/review.ts +54 -12
- package/extensions/orchestrator/pp-menu.test.ts +74 -1
- package/extensions/orchestrator/pp-menu.ts +199 -41
- package/extensions/orchestrator/state.ts +41 -20
- package/extensions/orchestrator/test-helpers.ts +14 -2
- package/extensions/orchestrator/usage-tracker.test.ts +131 -3
- package/extensions/orchestrator/usage-tracker.ts +74 -11
- 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,
|
|
@@ -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 (
|
|
92
|
-
|
|
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
|
-
|
|
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 = (
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
976
|
-
const parts = [
|
|
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.
|
|
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
|
|
989
|
-
const
|
|
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 (
|
|
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
|
|
1016
|
-
|
|
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
|
-
|
|
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
|
|
2852
|
+
return `${taskNameFromState(t.dir, t.state)} — ${taskAge(t.state)}`;
|
|
2742
2853
|
}
|
|
2743
2854
|
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
288
|
-
return
|
|
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
|
-
|
|
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);
|