@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.
- package/3p/pi-ask-user/index.ts +1 -1
- package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
- package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
- package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
- package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
- package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
- package/3p/pi-subagents/src/agent-manager.ts +34 -1
- package/3p/pi-subagents/src/agent-runner.ts +66 -33
- package/3p/pi-subagents/src/index.ts +3 -38
- package/3p/pi-subagents/src/types.ts +4 -0
- package/extensions/orchestrator/agents/advisor.ts +35 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/constraints.test.ts +44 -0
- package/extensions/orchestrator/agents/constraints.ts +3 -0
- package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
- package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/planner.ts +9 -8
- package/extensions/orchestrator/agents/prompts.test.ts +120 -0
- package/extensions/orchestrator/agents/reviewer.ts +48 -0
- package/extensions/orchestrator/agents/task.ts +6 -20
- package/extensions/orchestrator/agents/tool-routing.ts +23 -1
- package/extensions/orchestrator/command-handlers.ts +1 -1
- package/extensions/orchestrator/config.ts +8 -4
- package/extensions/orchestrator/context.test.ts +54 -0
- package/extensions/orchestrator/context.ts +65 -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.test.ts +97 -1
- package/extensions/orchestrator/event-handlers.ts +222 -48
- package/extensions/orchestrator/flant-infra.test.ts +312 -0
- package/extensions/orchestrator/flant-infra.ts +407 -44
- package/extensions/orchestrator/index.ts +1 -1
- package/extensions/orchestrator/integration.test.ts +312 -18
- package/extensions/orchestrator/messages.test.ts +30 -0
- package/extensions/orchestrator/messages.ts +6 -0
- package/extensions/orchestrator/model-registry.test.ts +124 -13
- package/extensions/orchestrator/model-registry.ts +91 -33
- package/extensions/orchestrator/orchestrator.test.ts +113 -0
- package/extensions/orchestrator/orchestrator.ts +163 -3
- package/extensions/orchestrator/phases/brainstorm.ts +9 -20
- package/extensions/orchestrator/phases/implementation.test.ts +11 -0
- package/extensions/orchestrator/phases/implementation.ts +4 -6
- package/extensions/orchestrator/phases/planning.test.ts +16 -0
- package/extensions/orchestrator/phases/planning.ts +11 -4
- package/extensions/orchestrator/phases/review-task.ts +1 -4
- package/extensions/orchestrator/phases/review.test.ts +62 -0
- package/extensions/orchestrator/phases/review.ts +58 -15
- package/extensions/orchestrator/plannotator.ts +9 -6
- package/extensions/orchestrator/pp-menu.test.ts +74 -1
- package/extensions/orchestrator/pp-menu.ts +366 -94
- package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
- package/extensions/orchestrator/pp-state-tools.ts +249 -0
- package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
- package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
- package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
- package/extensions/orchestrator/state.ts +41 -20
- package/extensions/orchestrator/test-helpers.ts +18 -3
- package/extensions/orchestrator/usage-tracker.test.ts +131 -3
- package/extensions/orchestrator/usage-tracker.ts +78 -11
- package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
- package/extensions/orchestrator/validate-artifacts.ts +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { Orchestrator } from "./orchestrator.js";
|
|
2
|
+
import { getLogger } from "./log.js";
|
|
3
|
+
import { isSubscriptionRouted } from "./usage-tracker.js";
|
|
4
|
+
import { setSubscriptionFallbackActive, toNonSubSpec } from "./model-registry.js";
|
|
5
|
+
import { loadFlantSettings, probeSubscriptionCleared } from "./flant-infra.js";
|
|
6
|
+
import { askUser, isCancel } from "../../3p/pi-ask-user/index.js";
|
|
7
|
+
|
|
8
|
+
// Recognise a rate-limit / 429 error from a turn's or subagent's error message.
|
|
9
|
+
export function isRateLimitError(message?: string): boolean {
|
|
10
|
+
if (typeof message !== "string") return false;
|
|
11
|
+
return /\b429\b|rate.?limit|too many requests|exceed your account/i.test(message);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Mirror of the SDK's AgentSession._isRetryableError classifier
|
|
15
|
+
// (agent-session.js: overloaded/rate-limit/5xx/network/stream-ended/timeout/...).
|
|
16
|
+
// When true, the SDK auto-retries the SAME turn itself (abortable backoff bound
|
|
17
|
+
// to ESC, visible countdown). pi-pi must NOT ALSO schedule its own post-error
|
|
18
|
+
// retry for these: a second, independent retry races the SDK's continue() and
|
|
19
|
+
// reproduces "Agent is already processing", and re-nudges a futile sub-429. This
|
|
20
|
+
// list is kept in sync with the SDK regex; if they drift, pi-pi at worst falls
|
|
21
|
+
// back to its own idle-gated retry (still safe, just not unified).
|
|
22
|
+
export function isSdkRetryableError(message?: string): boolean {
|
|
23
|
+
if (typeof message !== "string" || !message) return false;
|
|
24
|
+
return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test(
|
|
25
|
+
message,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SWITCH_DIALOG_CONTEXT =
|
|
30
|
+
"Switching between the personal subscription and regular flant Claude changes the provider/endpoint, " +
|
|
31
|
+
"so the prompt cache is LOST and the full conversation context is re-sent on the next call. " +
|
|
32
|
+
"Regular (non-subscription) flant Claude is billed PER TOKEN (paid).";
|
|
33
|
+
|
|
34
|
+
// Handle a subscription-routed 429 on the MAIN turn. Stops futile retries on the
|
|
35
|
+
// still-limited sub model, then (once, session-sticky) asks the user whether to
|
|
36
|
+
// fall back to non-sub Claude. On confirm: activates the session-scoped override,
|
|
37
|
+
// switches the active model, arms the switch-back probe, and nudges to continue.
|
|
38
|
+
export async function handleMainRateLimit(
|
|
39
|
+
orchestrator: Orchestrator,
|
|
40
|
+
ctx: any,
|
|
41
|
+
modelId: string | undefined,
|
|
42
|
+
provider: string | undefined,
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
const log = getLogger();
|
|
45
|
+
if (!isSubscriptionRouted(modelId, provider)) return;
|
|
46
|
+
|
|
47
|
+
// Stop the futile same-model retry (SDK backoff + pi-pi's own timer): retrying
|
|
48
|
+
// the sub model against an account-level limit cannot succeed.
|
|
49
|
+
ctx?.abort?.();
|
|
50
|
+
orchestrator.cancelPendingRetry();
|
|
51
|
+
|
|
52
|
+
if (orchestrator.subFallbackActive) return; // sticky — already on non-sub
|
|
53
|
+
await offerFallback(orchestrator, ctx, modelId ?? orchestrator.subFallbackModelId ?? "", "main");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Handle a subscription-routed 429 reported via subagents:failed. Uses ONE
|
|
57
|
+
// global dialogue (never per-subagent) — the same offerFallback path as the
|
|
58
|
+
// main turn, guarded so only one dialogue is open at a time.
|
|
59
|
+
export async function handleSubagentRateLimit(
|
|
60
|
+
orchestrator: Orchestrator,
|
|
61
|
+
ctx: any,
|
|
62
|
+
modelId: string | undefined,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
if (!isSubscriptionRouted(modelId)) return;
|
|
65
|
+
if (orchestrator.subFallbackActive) return; // sticky
|
|
66
|
+
await offerFallback(orchestrator, ctx, modelId ?? "", "subagent");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function offerFallback(
|
|
70
|
+
orchestrator: Orchestrator,
|
|
71
|
+
ctx: any,
|
|
72
|
+
subModelId: string,
|
|
73
|
+
origin: "main" | "subagent",
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const log = getLogger();
|
|
76
|
+
if (orchestrator.subFallbackDialogPending) return;
|
|
77
|
+
if (!ctx?.hasUI) {
|
|
78
|
+
// No UI to ask — leave sub routing in place; the error is surfaced elsewhere.
|
|
79
|
+
log.debug({ s: "ratelimit" }, "no UI available to offer subscription fallback");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
orchestrator.subFallbackDialogPending = true;
|
|
83
|
+
const taskToken = orchestrator.activeTaskToken;
|
|
84
|
+
try {
|
|
85
|
+
const result = await askUser(ctx, {
|
|
86
|
+
question: "Personal Claude subscription is rate-limited. Switch to regular (paid) flant Claude?",
|
|
87
|
+
context: SWITCH_DIALOG_CONTEXT,
|
|
88
|
+
options: [
|
|
89
|
+
{ title: "Switch to non-sub Claude", description: "Continue on regular flant Claude (paid per token) until you switch back." },
|
|
90
|
+
{ title: "Stay on subscription", description: "Do not switch. Work stays paused until the limit clears." },
|
|
91
|
+
],
|
|
92
|
+
allowFreeform: false,
|
|
93
|
+
allowComment: false,
|
|
94
|
+
allowMultiple: false,
|
|
95
|
+
});
|
|
96
|
+
if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active) return;
|
|
97
|
+
const chose = result && !isCancel(result) && result.kind === "selection" ? result.selections[0] : undefined;
|
|
98
|
+
if (chose !== "Switch to non-sub Claude") {
|
|
99
|
+
ctx.ui?.notify?.("Staying on subscription. Auto-continuation paused until you resume or the limit clears.", "info");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await activateFallback(orchestrator, ctx, subModelId, origin);
|
|
103
|
+
} finally {
|
|
104
|
+
orchestrator.subFallbackDialogPending = false;
|
|
105
|
+
orchestrator.subFallbackPendingDecision = false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function activateFallback(
|
|
110
|
+
orchestrator: Orchestrator,
|
|
111
|
+
ctx: any,
|
|
112
|
+
subModelId: string,
|
|
113
|
+
origin: "main" | "subagent",
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
const log = getLogger();
|
|
116
|
+
orchestrator.subFallbackActive = true;
|
|
117
|
+
orchestrator.subFallbackModelId = subModelId || orchestrator.subFallbackModelId;
|
|
118
|
+
// Activate the session-scoped override so EVERY future model resolution
|
|
119
|
+
// (phase switches, new subagents, planner/reviewer specs) rewrites sub→non-sub.
|
|
120
|
+
// This is what actually re-routes future subagent spawns, regardless of origin.
|
|
121
|
+
setSubscriptionFallbackActive(true);
|
|
122
|
+
|
|
123
|
+
// Switch the CURRENT main model to the non-sub equivalent ONLY when the 429 was
|
|
124
|
+
// on the main turn. For a SUBAGENT 429 the failing model is the subagent's, not
|
|
125
|
+
// the main session's — switching the main model here would change the active
|
|
126
|
+
// orchestrator model the user never touched (e.g. debug's GPT -> Claude). The
|
|
127
|
+
// session override above already re-routes the retried/next subagent.
|
|
128
|
+
if (origin === "main" && subModelId) {
|
|
129
|
+
const nonSub = toNonSubSpec(subModelId);
|
|
130
|
+
const ok = await orchestrator.switchModel(ctx, nonSub, currentThinking(orchestrator));
|
|
131
|
+
if (!ok) log.warn({ s: "ratelimit", nonSub }, "failed to switch main model to non-sub");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
ctx.ui?.notify?.("Switched to regular flant Claude (paid per token). Will periodically check if the subscription limit has cleared.", "info");
|
|
135
|
+
|
|
136
|
+
armSwitchBackProbe(orchestrator);
|
|
137
|
+
|
|
138
|
+
// Nudge to continue — retries were cancelled, so the turn is stopped. Idle-gated
|
|
139
|
+
// (same guard as the post-error nudge) so it never races the SDK into an
|
|
140
|
+
// "Agent is already processing" error.
|
|
141
|
+
const phase = orchestrator.active?.state.phase ?? "current";
|
|
142
|
+
orchestrator.sendUserMessageWhenIdle(
|
|
143
|
+
`[PI-PI] Switched to regular (non-subscription) flant Claude after a rate limit. Continue working on the current phase (${phase}).`,
|
|
144
|
+
orchestrator.activeTaskToken,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function currentThinking(orchestrator: Orchestrator): string {
|
|
149
|
+
const phase = orchestrator.active?.state.phase;
|
|
150
|
+
const orchestrators = orchestrator.config?.agents?.orchestrators as Record<string, { thinking?: string }> | undefined;
|
|
151
|
+
const key = phase === "debug" || phase === "brainstorm" || phase === "review" || phase === "quick" ? phase : "implement";
|
|
152
|
+
return orchestrators?.[key]?.thinking ?? "high";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Arm the fixed-interval switch-back probe. On each interval an out-of-band probe
|
|
156
|
+
// checks whether the sub limit cleared; a 429/failure silently re-arms, a success
|
|
157
|
+
// opens the switch-back dialogue. Only one timer runs at a time.
|
|
158
|
+
export function armSwitchBackProbe(orchestrator: Orchestrator): void {
|
|
159
|
+
if (orchestrator.subSwitchBackTimer) clearTimeout(orchestrator.subSwitchBackTimer);
|
|
160
|
+
const minutes = Math.max(1, loadFlantSettings().switchBackIntervalMinutes || 30);
|
|
161
|
+
const taskToken = orchestrator.activeTaskToken;
|
|
162
|
+
orchestrator.subSwitchBackTimer = setTimeout(() => {
|
|
163
|
+
orchestrator.subSwitchBackTimer = null;
|
|
164
|
+
void runSwitchBackProbe(orchestrator, taskToken);
|
|
165
|
+
}, minutes * 60 * 1000);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function runSwitchBackProbe(orchestrator: Orchestrator, taskToken: number): Promise<void> {
|
|
169
|
+
const log = getLogger();
|
|
170
|
+
if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active || !orchestrator.subFallbackActive) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const modelId = orchestrator.subFallbackModelId;
|
|
174
|
+
if (!modelId) {
|
|
175
|
+
armSwitchBackProbe(orchestrator);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const outcome = await probeSubscriptionCleared(modelId);
|
|
179
|
+
if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active || !orchestrator.subFallbackActive) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (outcome !== "ok") {
|
|
183
|
+
// Still limited (or transient error) — stay on non-sub, silently re-arm.
|
|
184
|
+
log.debug({ s: "ratelimit", outcome }, "switch-back probe: not cleared, re-arming");
|
|
185
|
+
armSwitchBackProbe(orchestrator);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
await offerSwitchBack(orchestrator, modelId);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function offerSwitchBack(orchestrator: Orchestrator, subModelId: string): Promise<void> {
|
|
192
|
+
const log = getLogger();
|
|
193
|
+
const ctx = orchestrator.lastCtx;
|
|
194
|
+
if (orchestrator.subFallbackDialogPending || !ctx?.hasUI) {
|
|
195
|
+
armSwitchBackProbe(orchestrator);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
orchestrator.subFallbackDialogPending = true;
|
|
199
|
+
const taskToken = orchestrator.activeTaskToken;
|
|
200
|
+
try {
|
|
201
|
+
const result = await askUser(ctx, {
|
|
202
|
+
question: "Your Claude subscription limit appears to have cleared. Switch back to the subscription?",
|
|
203
|
+
context: SWITCH_DIALOG_CONTEXT,
|
|
204
|
+
options: [
|
|
205
|
+
{ title: "Switch back to subscription", description: "Resume on the personal Claude subscription (flat-rate)." },
|
|
206
|
+
{ title: "Stay on non-sub Claude", description: "Keep using regular flant Claude; check again later." },
|
|
207
|
+
],
|
|
208
|
+
allowFreeform: false,
|
|
209
|
+
allowComment: false,
|
|
210
|
+
allowMultiple: false,
|
|
211
|
+
});
|
|
212
|
+
if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active) return;
|
|
213
|
+
const chose = result && !isCancel(result) && result.kind === "selection" ? result.selections[0] : undefined;
|
|
214
|
+
if (chose !== "Switch back to subscription") {
|
|
215
|
+
// Stay on non-sub; re-arm so we check again after the interval.
|
|
216
|
+
armSwitchBackProbe(orchestrator);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
await switchBackToSub(orchestrator, ctx, subModelId);
|
|
220
|
+
} finally {
|
|
221
|
+
orchestrator.subFallbackDialogPending = false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function switchBackToSub(orchestrator: Orchestrator, ctx: any, subModelId: string): Promise<void> {
|
|
226
|
+
const log = getLogger();
|
|
227
|
+
// Clear the override FIRST so switchModel resolves the sub spec unrewritten.
|
|
228
|
+
orchestrator.subFallbackActive = false;
|
|
229
|
+
setSubscriptionFallbackActive(false);
|
|
230
|
+
if (orchestrator.subSwitchBackTimer) {
|
|
231
|
+
clearTimeout(orchestrator.subSwitchBackTimer);
|
|
232
|
+
orchestrator.subSwitchBackTimer = null;
|
|
233
|
+
}
|
|
234
|
+
const ok = await orchestrator.switchModel(ctx, subModelId, currentThinking(orchestrator));
|
|
235
|
+
if (!ok) log.warn({ s: "ratelimit", subModelId }, "failed to switch back to sub model");
|
|
236
|
+
orchestrator.subFallbackModelId = null;
|
|
237
|
+
ctx.ui?.notify?.("Switched back to the personal Claude subscription.", "info");
|
|
238
|
+
const phase = orchestrator.active?.state.phase ?? "current";
|
|
239
|
+
orchestrator.sendUserMessageWhenIdle(
|
|
240
|
+
`[PI-PI] Switched back to the personal Claude subscription. Continue working on the current phase (${phase}).`,
|
|
241
|
+
orchestrator.activeTaskToken,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
@@ -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);
|
|
@@ -203,9 +215,12 @@ export function expectReviewAuto(menu: AskUserHarness, preset = "regular"): void
|
|
|
203
215
|
menu.expect({ question: "Review preset", options: { include: [m.preset(preset), "Back"] }, choose: m.preset(preset) });
|
|
204
216
|
}
|
|
205
217
|
|
|
206
|
-
export function expectReviewOnMyOwn(menu: AskUserHarness): void {
|
|
218
|
+
export function expectReviewOnMyOwn(menu: AskUserHarness, editorGate?: "Done" | "Skip markers"): void {
|
|
207
219
|
menu.expect({ question: m.anyTaskMenu, options: { include: ["Review"] }, choose: "Review" });
|
|
208
220
|
menu.expect({ question: "Review", options: { include: ["Review on my own"] }, choose: "Review on my own" });
|
|
221
|
+
if (editorGate) {
|
|
222
|
+
menu.expect({ question: "Editor review", options: { include: [editorGate] }, choose: editorGate });
|
|
223
|
+
}
|
|
209
224
|
}
|
|
210
225
|
|
|
211
226
|
export function expectPpStartImplementAutonomous(menu: AskUserHarness): void {
|
|
@@ -96,6 +96,7 @@ describe("usage-tracker", () => {
|
|
|
96
96
|
cacheWriteTokens: 0,
|
|
97
97
|
cacheSupported: false,
|
|
98
98
|
turns: 1,
|
|
99
|
+
subscription: false,
|
|
99
100
|
},
|
|
100
101
|
"anthropic/claude-opus-4-6": {
|
|
101
102
|
inputTokens: 7,
|
|
@@ -104,6 +105,7 @@ describe("usage-tracker", () => {
|
|
|
104
105
|
cacheWriteTokens: 0,
|
|
105
106
|
cacheSupported: false,
|
|
106
107
|
turns: 1,
|
|
108
|
+
subscription: false,
|
|
107
109
|
},
|
|
108
110
|
});
|
|
109
111
|
});
|
|
@@ -186,6 +188,7 @@ describe("usage-tracker", () => {
|
|
|
186
188
|
cost: 0.2,
|
|
187
189
|
durationMs: 900,
|
|
188
190
|
toolUses: 3,
|
|
191
|
+
subscription: false,
|
|
189
192
|
},
|
|
190
193
|
]);
|
|
191
194
|
});
|
|
@@ -226,12 +229,21 @@ describe("usage-tracker", () => {
|
|
|
226
229
|
expect(tracker.getTotalCost()).toBe(2);
|
|
227
230
|
});
|
|
228
231
|
|
|
229
|
-
it("getCacheHitRate
|
|
232
|
+
it("getCacheHitRate is cacheRead over processed input (uncached + read + write)", () => {
|
|
230
233
|
const tracker = createUsageTracker();
|
|
231
234
|
|
|
232
|
-
|
|
235
|
+
// uncached 30, cacheRead 10, cacheWrite 10 → 10 / (30+10+10) = 0.2
|
|
236
|
+
tracker.recordTurn("openai/gpt-5", "openai", 30, 0, 10, 10, 0, true);
|
|
233
237
|
|
|
234
|
-
expect(tracker.getCacheHitRate()).toBeCloseTo(10 /
|
|
238
|
+
expect(tracker.getCacheHitRate()).toBeCloseTo(10 / 50);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("getCacheHitRate includes subagent cache tokens", () => {
|
|
242
|
+
const tracker = createUsageTracker();
|
|
243
|
+
tracker.recordTurn("openai/gpt-5", "openai", 10, 0, 0, 0, 0, true);
|
|
244
|
+
tracker.recordSubagentCompletion({ input: 0, output: 0, cacheRead: 30, cacheWrite: 10 } as any);
|
|
245
|
+
// cacheRead 30 / processed (10 + 30 + 10) = 0.6
|
|
246
|
+
expect(tracker.getCacheHitRate()).toBeCloseTo(30 / 50);
|
|
235
247
|
});
|
|
236
248
|
|
|
237
249
|
it("getCacheHitRate returns zero when denominator is zero", () => {
|
|
@@ -239,6 +251,22 @@ describe("usage-tracker", () => {
|
|
|
239
251
|
expect(tracker.getCacheHitRate()).toBe(0);
|
|
240
252
|
});
|
|
241
253
|
|
|
254
|
+
it("getTotalProcessedInputTokens sums uncached + cache read + cache write across main and subagents", () => {
|
|
255
|
+
const tracker = createUsageTracker();
|
|
256
|
+
tracker.recordTurn("openai/gpt-5", "openai", 10, 0, 20, 5, 0, true);
|
|
257
|
+
tracker.recordSubagentCompletion({ input: 3, output: 0, cacheRead: 7, cacheWrite: 2 } as any);
|
|
258
|
+
// main: 10+20+5=35, subagent: 3+7+2=12 → 47
|
|
259
|
+
expect(tracker.getTotalProcessedInputTokens()).toBe(47);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("getTotalCacheReadTokens and getTotalCacheWriteTokens include subagents", () => {
|
|
263
|
+
const tracker = createUsageTracker();
|
|
264
|
+
tracker.recordTurn("openai/gpt-5", "openai", 0, 0, 20, 5, 0, true);
|
|
265
|
+
tracker.recordSubagentCompletion({ input: 0, output: 0, cacheRead: 7, cacheWrite: 2 } as any);
|
|
266
|
+
expect(tracker.getTotalCacheReadTokens()).toBe(27);
|
|
267
|
+
expect(tracker.getTotalCacheWriteTokens()).toBe(7);
|
|
268
|
+
});
|
|
269
|
+
|
|
242
270
|
it("isCacheSupported true when any model or subagent has cache", () => {
|
|
243
271
|
const tracker = createUsageTracker();
|
|
244
272
|
expect(tracker.isCacheSupported()).toBe(false);
|
|
@@ -382,6 +410,106 @@ describe("usage-tracker", () => {
|
|
|
382
410
|
expect(roundTrip.subagents).toEqual((mid as any).subagents);
|
|
383
411
|
});
|
|
384
412
|
|
|
413
|
+
it("subscription main turns count tokens but contribute zero cost", () => {
|
|
414
|
+
const tracker = createUsageTracker();
|
|
415
|
+
|
|
416
|
+
// Detected via the sub/ model id prefix...
|
|
417
|
+
tracker.recordTurn("sub/claude-opus-4-6", "anthropic", 100, 50, 10, 5, 1.23, true);
|
|
418
|
+
// ...or via the subscription provider with a bare id (keyed under sub/).
|
|
419
|
+
tracker.recordTurn("claude-opus-4-6", "pp-flant-anthropic-sub", 20, 10, 0, 0, 0.5, true);
|
|
420
|
+
|
|
421
|
+
expect(tracker.getMainInputTokens()).toBe(120);
|
|
422
|
+
expect(tracker.getMainOutputTokens()).toBe(60);
|
|
423
|
+
expect(tracker.getMainCacheReadTokens()).toBe(10);
|
|
424
|
+
expect(tracker.getMainCost()).toBe(0);
|
|
425
|
+
expect(tracker.getTotalCost()).toBe(0);
|
|
426
|
+
expect(tracker.getPerModelUsage()["sub/claude-opus-4-6"]?.subscription).toBe(true);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("provider-detected subscription turns key under sub/ so paid rows stay separate", () => {
|
|
430
|
+
const tracker = createUsageTracker();
|
|
431
|
+
|
|
432
|
+
// Same underlying model id: one paid, one subscription (provider-only).
|
|
433
|
+
tracker.recordTurn("claude-opus-4-6", "pp-flant-anthropic", 10, 5, 0, 0, 0.4, false);
|
|
434
|
+
tracker.recordTurn("claude-opus-4-6", "pp-flant-anthropic-sub", 20, 10, 0, 0, 2.0, false);
|
|
435
|
+
|
|
436
|
+
const usage = tracker.getPerModelUsage();
|
|
437
|
+
expect(usage["claude-opus-4-6"]).toMatchObject({ inputTokens: 10, outputTokens: 5, subscription: false });
|
|
438
|
+
expect(usage["sub/claude-opus-4-6"]).toMatchObject({ inputTokens: 20, outputTokens: 10, subscription: true });
|
|
439
|
+
expect(tracker.getMainCost()).toBeCloseTo(0.4);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("subscription subagents count tokens but contribute zero cost", () => {
|
|
443
|
+
const tracker = createUsageTracker();
|
|
444
|
+
|
|
445
|
+
tracker.recordSubagentCompletion({ input: 30, output: 15 } as any, 0.9, {
|
|
446
|
+
description: "Explore", agentType: "explore", modelId: "sub/claude-haiku-4-5",
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
expect(tracker.getSubagentTotals()).toEqual({ inputTokens: 30, outputTokens: 15, cost: 0 });
|
|
450
|
+
expect(tracker.getSubagentList()[0]?.subscription).toBe(true);
|
|
451
|
+
expect(tracker.getSubagentList()[0]?.cost).toBe(0);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("mixed paid and subscription session totals only the paid portion", () => {
|
|
455
|
+
const tracker = createUsageTracker();
|
|
456
|
+
|
|
457
|
+
tracker.recordTurn("openai/gpt-5", "openai", 10, 5, 0, 0, 0.4, false);
|
|
458
|
+
tracker.recordTurn("sub/claude-opus-4-6", "pp-flant-anthropic-sub", 10, 5, 0, 0, 2.0, false);
|
|
459
|
+
tracker.recordSubagentCompletion({ input: 5, output: 2 } as any, 0.1, { modelId: "openai/gpt-5" });
|
|
460
|
+
tracker.recordSubagentCompletion({ input: 5, output: 2 } as any, 3.0, { modelId: "sub/claude-haiku-4-5" });
|
|
461
|
+
|
|
462
|
+
expect(tracker.getMainCost()).toBeCloseTo(0.4);
|
|
463
|
+
expect(tracker.getSubagentTotals().cost).toBeCloseTo(0.1);
|
|
464
|
+
expect(tracker.getTotalCost()).toBeCloseTo(0.5);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("subscription marker survives JSON round-trip and stays paid-only", () => {
|
|
468
|
+
const source = createUsageTracker();
|
|
469
|
+
source.recordTurn("sub/claude-opus-4-6", "pp-flant-anthropic-sub", 10, 5, 0, 0, 2.0, true);
|
|
470
|
+
source.recordSubagentCompletion({ input: 5, output: 2 } as any, 3.0, { modelId: "sub/claude-haiku-4-5" });
|
|
471
|
+
|
|
472
|
+
const mid = source.toSummary() as Record<string, unknown>;
|
|
473
|
+
const restored = createUsageTracker();
|
|
474
|
+
restored.loadFromSummary(mid);
|
|
475
|
+
|
|
476
|
+
const roundTrip = restored.toSummary() as any;
|
|
477
|
+
expect(restored.getTotalCost()).toBe(0);
|
|
478
|
+
expect(restored.getPerModelUsage()["sub/claude-opus-4-6"]?.subscription).toBe(true);
|
|
479
|
+
expect(restored.getSubagentList()[0]?.subscription).toBe(true);
|
|
480
|
+
expect(roundTrip.totals).toEqual((mid as any).totals);
|
|
481
|
+
expect(roundTrip.models).toEqual((mid as any).models);
|
|
482
|
+
expect(roundTrip.subagents).toEqual((mid as any).subagents);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("legacy summaries without subscription field default to non-subscription", () => {
|
|
486
|
+
const tracker = createUsageTracker();
|
|
487
|
+
tracker.loadFromSummary({
|
|
488
|
+
totals: { inputTokens: 10, outputTokens: 5, cost: 1.5, turns: 1 },
|
|
489
|
+
models: { "openai/gpt-5": { inputTokens: 10, outputTokens: 5, turns: 1 } },
|
|
490
|
+
subagents: [{ description: "a", agentType: "x", modelId: "m", inputTokens: 3, outputTokens: 1, cost: 0.2 }],
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
expect(tracker.getPerModelUsage()["openai/gpt-5"]?.subscription).toBe(false);
|
|
494
|
+
expect(tracker.getSubagentList()[0]?.subscription).toBe(false);
|
|
495
|
+
expect(tracker.getMainCost()).toBe(1.5);
|
|
496
|
+
expect(tracker.getSubagentTotals().cost).toBeCloseTo(0.2);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("loadFromSummary recovers subscription from sub/ prefix when flag is absent", () => {
|
|
500
|
+
const tracker = createUsageTracker();
|
|
501
|
+
tracker.loadFromSummary({
|
|
502
|
+
totals: { inputTokens: 10, outputTokens: 5, cost: 0, turns: 1 },
|
|
503
|
+
models: { "sub/claude-opus-4-6": { inputTokens: 10, outputTokens: 5, turns: 1 } },
|
|
504
|
+
subagents: [{ description: "a", agentType: "x", modelId: "sub/claude-haiku-4-5", inputTokens: 3, outputTokens: 1, cost: 2.5 }],
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
expect(tracker.getPerModelUsage()["sub/claude-opus-4-6"]?.subscription).toBe(true);
|
|
508
|
+
expect(tracker.getSubagentList()[0]?.subscription).toBe(true);
|
|
509
|
+
expect(tracker.getSubagentList()[0]?.cost).toBe(0);
|
|
510
|
+
expect(tracker.getSubagentTotals().cost).toBe(0);
|
|
511
|
+
});
|
|
512
|
+
|
|
385
513
|
it("reset clears all state", () => {
|
|
386
514
|
const tracker = createUsageTracker();
|
|
387
515
|
tracker.recordTurn("openai/gpt-5", "openai", 1, 2, 3, 4, 0.2, true);
|