@ilya-lesikov/pi-pi 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
  2. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
  3. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
  4. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
  5. package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
  6. package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
  7. package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
  8. package/3p/pi-subagents/src/agent-manager.ts +34 -1
  9. package/3p/pi-subagents/src/agent-runner.ts +66 -33
  10. package/3p/pi-subagents/src/index.ts +3 -38
  11. package/3p/pi-subagents/src/types.ts +4 -0
  12. package/extensions/orchestrator/agents/advisor.ts +35 -0
  13. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
  14. package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
  15. package/extensions/orchestrator/agents/constraints.test.ts +44 -0
  16. package/extensions/orchestrator/agents/constraints.ts +3 -0
  17. package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
  18. package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
  19. package/extensions/orchestrator/agents/planner.ts +9 -8
  20. package/extensions/orchestrator/agents/prompts.test.ts +120 -0
  21. package/extensions/orchestrator/agents/reviewer.ts +48 -0
  22. package/extensions/orchestrator/agents/task.ts +6 -20
  23. package/extensions/orchestrator/agents/tool-routing.ts +23 -1
  24. package/extensions/orchestrator/command-handlers.ts +1 -1
  25. package/extensions/orchestrator/config.ts +5 -2
  26. package/extensions/orchestrator/context.test.ts +54 -0
  27. package/extensions/orchestrator/context.ts +65 -2
  28. package/extensions/orchestrator/event-handlers.test.ts +97 -1
  29. package/extensions/orchestrator/event-handlers.ts +176 -43
  30. package/extensions/orchestrator/flant-infra.test.ts +25 -0
  31. package/extensions/orchestrator/flant-infra.ts +91 -0
  32. package/extensions/orchestrator/index.ts +1 -1
  33. package/extensions/orchestrator/integration.test.ts +107 -12
  34. package/extensions/orchestrator/messages.test.ts +30 -0
  35. package/extensions/orchestrator/messages.ts +6 -0
  36. package/extensions/orchestrator/model-registry.test.ts +48 -1
  37. package/extensions/orchestrator/model-registry.ts +43 -1
  38. package/extensions/orchestrator/orchestrator.test.ts +113 -0
  39. package/extensions/orchestrator/orchestrator.ts +151 -2
  40. package/extensions/orchestrator/phases/brainstorm.ts +9 -20
  41. package/extensions/orchestrator/phases/implementation.test.ts +11 -0
  42. package/extensions/orchestrator/phases/implementation.ts +4 -6
  43. package/extensions/orchestrator/phases/planning.test.ts +16 -0
  44. package/extensions/orchestrator/phases/planning.ts +11 -4
  45. package/extensions/orchestrator/phases/review-task.ts +1 -4
  46. package/extensions/orchestrator/phases/review.test.ts +8 -0
  47. package/extensions/orchestrator/phases/review.ts +4 -3
  48. package/extensions/orchestrator/plannotator.ts +9 -6
  49. package/extensions/orchestrator/pp-menu.ts +168 -54
  50. package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
  51. package/extensions/orchestrator/pp-state-tools.ts +249 -0
  52. package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
  53. package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
  54. package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
  55. package/extensions/orchestrator/test-helpers.ts +4 -1
  56. package/extensions/orchestrator/usage-tracker.ts +5 -1
  57. package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
  58. package/extensions/orchestrator/validate-artifacts.ts +2 -2
  59. package/package.json +1 -1
@@ -14,17 +14,20 @@ import {
14
14
  loadAllContextFiles,
15
15
  getPhaseArtifacts,
16
16
  getLatestSynthesizedPlan,
17
+ getArtifactManifest,
17
18
  loadBrainstormReviewOutputs,
18
19
  loadCodeReviewOutputs,
19
20
  loadPlanReviewOutputs,
20
21
  } from "./context.js";
21
- import { PRINCIPLES_BLOCK, TOOLS_BLOCK } from "./agents/tool-routing.js";
22
+ import { PRINCIPLES_BLOCK, TOOLS_BLOCK, DELEGATION_BLOCK } from "./agents/tool-routing.js";
22
23
  import { constraintsBlock, phaseConstraint } from "./agents/constraints.js";
23
24
  import { registerCbmTools } from "./cbm.js";
24
25
  import { registerExaTools } from "./exa.js";
25
26
  import { registerAstSearchTool } from "./ast-search.js";
26
27
  import { SUBAGENT_SESSION_KEY } from "./index.js";
27
28
  import { registerCommandHandlers } from "./command-handlers.js";
29
+ import { registerStateFileTools } from "./pp-state-tools.js";
30
+ import { handleMainRateLimit, handleSubagentRateLimit, isRateLimitError, isSdkRetryableError } from "./rate-limit-fallback.js";
28
31
  import { setExtensionOnlyMode, unregisterAgentDefinitions } from "./agents/registry.js";
29
32
  import { resolveModel, getModelInfo, updateRegistryFromAvailableModels } from "./model-registry.js";
30
33
  import { spawnPlanners, spawnPlanReviewers } from "./phases/planning.js";
@@ -33,9 +36,10 @@ import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
33
36
  import { reviewPassUnanimousApprove } from "./phases/verdict.js";
34
37
  import { validateExitCriteria } from "./phases/machine.js";
35
38
  import { openPlannotator, waitForPlannotatorResult, cancelPendingPlannotatorWait } from "./plannotator.js";
39
+ import { advanceBanner } from "./messages.js";
36
40
  import { Orchestrator, type ActiveTask } from "./orchestrator.js";
37
41
  import { createCustomFooter, setFooterContext, setFooterTracker } from "./custom-footer.js";
38
- import { createUsageTracker, dumpUsageSummary, loadUsageSummary, type UsageTracker } from "./usage-tracker.js";
42
+ import { createUsageTracker, dumpUsageSummary, loadUsageSummary, isSubscriptionRouted, type UsageTracker } from "./usage-tracker.js";
39
43
  import { askUser, isCancel } from "../../3p/pi-ask-user/index.js";
40
44
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
41
45
  import { findRootRepo, normalizeRepoPath, resolveRepoForFile, type RepoInfo } from "./repo-utils.js";
@@ -51,6 +55,34 @@ function isPathInside(basePath: string, targetPath: string): boolean {
51
55
  return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
52
56
  }
53
57
 
58
+ // Builds the spawn-time context block appended to a free agent's spawn prompt:
59
+ // inlines USER_REQUEST + RESEARCH (always present by spawn time) and appends a
60
+ // path-aware manifest of additional on-demand documents (artifacts + plan).
61
+ function buildSpawnContextBlock(taskDir: string): string {
62
+ const parts: string[] = [];
63
+
64
+ const userRequestPath = join(taskDir, "USER_REQUEST.md");
65
+ if (existsSync(userRequestPath)) {
66
+ parts.push("=== USER REQUEST ===\n" + readFileSync(userRequestPath, "utf-8").trimEnd());
67
+ }
68
+ const researchPath = join(taskDir, "RESEARCH.md");
69
+ if (existsSync(researchPath)) {
70
+ parts.push("=== RESEARCH ===\n" + readFileSync(researchPath, "utf-8").trimEnd());
71
+ }
72
+
73
+ const manifest = getArtifactManifest(taskDir);
74
+ if (manifest.length > 0) {
75
+ const lines = manifest.map((m) => `- ${m.path} — ${m.title}`);
76
+ parts.push(
77
+ "=== ADDITIONAL DOCUMENTS (read from disk if relevant) ===\n" +
78
+ lines.join("\n") +
79
+ "\nDo NOT re-read USER_REQUEST/RESEARCH from disk (already above).",
80
+ );
81
+ }
82
+
83
+ return parts.join("\n\n");
84
+ }
85
+
54
86
  export async function detectDefaultBranch(orchestrator: Orchestrator, repos: RepoInfo[], repoPath: string): Promise<string> {
55
87
  const normalizedPath = normalizeRepoPath(repoPath);
56
88
  const repo = repos.find((r) => r.path === normalizedPath);
@@ -206,7 +238,7 @@ function tryCompleteReviewCycle(orchestrator: Orchestrator, spawnedReviewers?: n
206
238
  },
207
239
  "instruction",
208
240
  );
209
- orchestrator.safeSendUserMessage(reviewReadyMessage(phase, getEffectivePhaseMode(orchestrator.active.state)));
241
+ orchestrator.safeSendUserMessage(advanceBanner(reviewReadyMessage(phase, getEffectivePhaseMode(orchestrator.active.state))));
210
242
  }
211
243
 
212
244
  function reviewReadyMessage(phase: string, mode: TaskMode): string {
@@ -421,6 +453,7 @@ function registerOrchestratorTools(orchestrator: Orchestrator): void {
421
453
  registerPhaseCompleteTool(orchestrator);
422
454
  registerCommitTool(orchestrator);
423
455
  registerSpecifyReviewsTool(orchestrator);
456
+ registerStateFileTools(orchestrator);
424
457
  }
425
458
 
426
459
  function registerRepoTool(orchestrator: Orchestrator): void {
@@ -562,24 +595,24 @@ function registerRepoTool(orchestrator: Orchestrator): void {
562
595
  });
563
596
  }
564
597
 
565
- function openCodeReviewDirect(
566
- pi: ExtensionAPI,
598
+ async function openCodeReviewDirect(
599
+ orchestrator: Orchestrator,
567
600
  payload: Record<string, unknown>,
568
601
  ): Promise<{ approved: boolean; feedback?: string } | { error: string }> {
569
- return new Promise((resolve) => {
570
- pi.events.emit("plannotator:request", {
571
- requestId: crypto.randomUUID(),
572
- action: "code-review",
573
- payload,
574
- respond: (response: any) => {
575
- if (response.status === "handled") {
576
- resolve({ approved: !!response.result?.approved, feedback: response.result?.feedback });
577
- } else {
578
- resolve({ error: response.error || "Plannotator not available" });
579
- }
580
- },
581
- });
582
- });
602
+ const { opened, reviewId } = await openPlannotator(orchestrator.pi, "code-review", payload);
603
+ if (!opened) {
604
+ return { error: "Plannotator not available" };
605
+ }
606
+ let result: { approved: boolean; feedback?: string; error?: string };
607
+ try {
608
+ result = await waitForPlannotatorResult(orchestrator, reviewId, null);
609
+ } catch (err) {
610
+ return { error: err instanceof Error ? err.message : "Plannotator review failed" };
611
+ }
612
+ if (result.error) {
613
+ return { error: result.error };
614
+ }
615
+ return { approved: result.approved, feedback: result.feedback };
583
616
  }
584
617
 
585
618
  function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
@@ -671,7 +704,7 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
671
704
  continue;
672
705
  }
673
706
 
674
- const result = await openCodeReviewDirect(pi, {
707
+ const result = await openCodeReviewDirect(orchestrator, {
675
708
  cwd: reviewCwd,
676
709
  diffType: "branch",
677
710
  defaultBranch: compareBase,
@@ -1168,6 +1201,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1168
1201
  if (data.description) {
1169
1202
  orchestrator.agentDescriptions.set(data.id, data.description);
1170
1203
  }
1204
+
1171
1205
  trackSubagentEvent(data, "created");
1172
1206
  startStaleAgentWatchdog();
1173
1207
  const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
@@ -1214,7 +1248,14 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1214
1248
 
1215
1249
  const failedPlannerVariants = [...orchestrator.failedPlannerVariants];
1216
1250
  const effectiveMode = getEffectiveMode(orchestrator.active.state);
1217
- if (effectiveMode === "autonomous" && failedPlannerVariants.length > 0) {
1251
+ // Do NOT auto-retry failed variants while a subscription-429 fallback decision
1252
+ // is in flight: re-spawning now would use the still-sub-routed model and
1253
+ // re-hit the limit. Once the user decides, the fallback nudge re-drives this.
1254
+ if (
1255
+ effectiveMode === "autonomous" &&
1256
+ failedPlannerVariants.length > 0 &&
1257
+ !orchestrator.subFallbackPendingDecision
1258
+ ) {
1218
1259
  const alreadyRetried = orchestrator.active.state.plannerFailureAutoRetried === true;
1219
1260
  if (!alreadyRetried) {
1220
1261
  const failedSet = new Set(failedPlannerVariants);
@@ -1386,7 +1427,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1386
1427
 
1387
1428
  const failedReviewerVariants = [...orchestrator.failedReviewerVariants];
1388
1429
  const effectiveMode = getEffectiveMode(orchestrator.active.state);
1389
- if (effectiveMode === "autonomous" && failedReviewerVariants.length > 0) {
1430
+ // See the planner path: suppress auto-retry while a sub-429 fallback decision
1431
+ // is pending so we don't re-spawn on the still-sub-routed model.
1432
+ if (
1433
+ effectiveMode === "autonomous" &&
1434
+ failedReviewerVariants.length > 0 &&
1435
+ !orchestrator.subFallbackPendingDecision
1436
+ ) {
1390
1437
  const cycle = orchestrator.active.state.reviewCycle;
1391
1438
  const phase = orchestrator.active.state.phase;
1392
1439
  const outputs = loadPhaseReviewOutputs(orchestrator.active.dir, phase, cycle.pass);
@@ -1641,6 +1688,23 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1641
1688
  orchestrator.abortAllSubagents();
1642
1689
  }
1643
1690
 
1691
+ // Subscription rate-limit (429) on a sub-routed subagent: offer the ONE
1692
+ // global switch-to-non-sub dialogue (never per-subagent). subagents:failed
1693
+ // carries the subagent's resolved model id.
1694
+ const failedModelId = typeof data.modelId === "string" ? data.modelId : undefined;
1695
+ const subRateLimited =
1696
+ isRateLimitError(data.error) &&
1697
+ isSubscriptionRouted(failedModelId) &&
1698
+ !orchestrator.subFallbackActive;
1699
+ if (subRateLimited) {
1700
+ // Set the decision-pending flag SYNCHRONOUSLY (before the completion checks
1701
+ // below) so the autonomous planner/reviewer auto-retry does NOT re-spawn
1702
+ // the failed variant on the still-sub-routed model while the fallback
1703
+ // dialogue is in flight.
1704
+ orchestrator.subFallbackPendingDecision = true;
1705
+ void handleSubagentRateLimit(orchestrator, orchestrator.lastCtx, failedModelId);
1706
+ }
1707
+
1644
1708
  orchestrator.transitionController.sendCustom(
1645
1709
  {
1646
1710
  customType: "pp-subagent-error",
@@ -1821,6 +1885,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1821
1885
  if (!isControllerInjected) {
1822
1886
  orchestrator.nudgeHalted = false;
1823
1887
  orchestrator.consecutiveNudges = 0;
1888
+ // Genuine user re-engagement also clears the API-error retry halt, so a
1889
+ // fresh request can auto-retry again from a clean counter.
1890
+ orchestrator.errorNudgeHalted = false;
1891
+ orchestrator.errorRetryCount = 0;
1824
1892
  }
1825
1893
  orchestrator.updateStatus(ctx);
1826
1894
 
@@ -1855,6 +1923,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1855
1923
  constraintsBlock(phase as Phase, effectiveMode),
1856
1924
  PRINCIPLES_BLOCK,
1857
1925
  TOOLS_BLOCK,
1926
+ DELEGATION_BLOCK,
1858
1927
  projectContext,
1859
1928
  taskBlock,
1860
1929
  ]
@@ -1877,24 +1946,29 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1877
1946
  if (event.toolName === "Agent" && orchestrator.active) {
1878
1947
  const input = event.input as Record<string, unknown>;
1879
1948
  const requestedType = ((input.subagent_type as string) || "").toLowerCase();
1949
+ const validTypes = ["explore", "librarian", "task", "advisor", "deep-debugger", "reviewer"];
1880
1950
  if (!requestedType) {
1881
- return { block: true, reason: "subagent_type is required. Use Explore for codebase research, Librarian for external docs, or Task for implementation subtasks." };
1951
+ return { block: true, reason: "subagent_type is required. Valid types: explore (codebase research), librarian (external docs), task (implementation subtask), advisor (design/'why is this broken' judgment), deep-debugger (hard persistent failures), reviewer (code review — only when the user explicitly asks)." };
1882
1952
  }
1883
- const isExplore = requestedType === "explore";
1884
- const isLibrarian = requestedType === "librarian";
1885
-
1886
- if (isExplore) {
1887
- input.subagent_type = "explore";
1888
- input.model = resolveModel(orchestrator.config.agents.subagents.simple.explore.model);
1889
- input.thinking = orchestrator.config.agents.subagents.simple.explore.thinking;
1890
- } else if (isLibrarian) {
1891
- input.subagent_type = "librarian";
1892
- input.model = resolveModel(orchestrator.config.agents.subagents.simple.librarian.model);
1893
- input.thinking = orchestrator.config.agents.subagents.simple.librarian.thinking;
1894
- } else {
1895
- input.subagent_type = "task";
1896
- input.model = resolveModel(orchestrator.config.agents.subagents.simple.task.model);
1897
- input.thinking = orchestrator.config.agents.subagents.simple.task.thinking;
1953
+ if (!validTypes.includes(requestedType)) {
1954
+ return { block: true, reason: `Unknown subagent_type "${requestedType}". Valid types: ${validTypes.join(", ")}.` };
1955
+ }
1956
+
1957
+ const simple = orchestrator.config.agents.subagents.simple;
1958
+ const role = requestedType as keyof typeof simple;
1959
+ input.subagent_type = requestedType;
1960
+ input.model = resolveModel(simple[role].model);
1961
+ input.thinking = simple[role].thinking;
1962
+
1963
+ // Spawn-time context injection: append USER_REQUEST + RESEARCH content and a
1964
+ // path-aware manifest of on-demand documents to the LLM-supplied spawn prompt.
1965
+ // explore/librarian intentionally get nothing (fast, scoped retrieval).
1966
+ if (["task", "advisor", "deep-debugger", "reviewer"].includes(requestedType)) {
1967
+ const contextBlock = buildSpawnContextBlock(orchestrator.active.dir);
1968
+ if (contextBlock) {
1969
+ const existingPrompt = typeof input.prompt === "string" ? input.prompt : "";
1970
+ input.prompt = existingPrompt ? `${existingPrompt}\n\n${contextBlock}` : contextBlock;
1971
+ }
1898
1972
  }
1899
1973
  }
1900
1974
 
@@ -2146,6 +2220,39 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
2146
2220
  contentBlocks: msg.content?.length ?? 0,
2147
2221
  contentSummary,
2148
2222
  }, "turn ended with error");
2223
+ // Subscription rate-limit (429) on a sub-routed main turn: retrying the
2224
+ // same sub model is futile against an account-level limit. Offer a
2225
+ // user-gated switch to non-sub Claude instead of the generic backoff.
2226
+ const activeModelId = (typeof msg.model === "string" && msg.model) || ctx.model?.id;
2227
+ const activeProvider = (typeof msg.provider === "string" && msg.provider) || ctx.model?.provider;
2228
+ if (
2229
+ isRateLimitError(errorMsg) &&
2230
+ isSubscriptionRouted(activeModelId, activeProvider) &&
2231
+ !orchestrator.subFallbackActive
2232
+ ) {
2233
+ void handleMainRateLimit(orchestrator, ctx, activeModelId, activeProvider);
2234
+ return;
2235
+ }
2236
+ // The SDK already auto-retries this class of error itself (abortable
2237
+ // backoff bound to ESC, continuing the SAME turn). Running pi-pi's OWN
2238
+ // independent retry on top would double-fire: its followUp races the SDK's
2239
+ // continue() into "Agent is already processing", and it re-nudges a still-
2240
+ // failing model. So for SDK-retryable errors we defer entirely to the SDK
2241
+ // and do nothing here. pi-pi's own idle-gated retry remains only as the
2242
+ // fallback for errors the SDK does NOT retry.
2243
+ if (isSdkRetryableError(errorMsg)) {
2244
+ getLogger().debug({ s: "turn", err: errorMsg }, "deferring to SDK auto-retry; pi-pi retry skipped");
2245
+ return;
2246
+ }
2247
+ // Halt guard: once the consecutive-error cap is exceeded we stop auto-
2248
+ // retrying until the user re-engages. errorRetryCount is NO LONGER reset on
2249
+ // benign intervening turns (see below) — otherwise a retried turn that ends
2250
+ // as a harmless text-only reply would reset the counter and the cap could
2251
+ // never accumulate, letting transient errors nudge unbounded.
2252
+ if (orchestrator.errorNudgeHalted) {
2253
+ getLogger().debug({ s: "turn", err: errorMsg }, "error auto-retry halted; awaiting user re-engagement");
2254
+ return;
2255
+ }
2149
2256
  orchestrator.errorRetryCount = (orchestrator.errorRetryCount ?? 0) + 1;
2150
2257
  const maxRetries = 5;
2151
2258
  if (orchestrator.errorRetryCount <= maxRetries) {
@@ -2153,18 +2260,44 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
2153
2260
  ctx.ui.notify(`API error (attempt ${orchestrator.errorRetryCount}/${maxRetries}): ${errorMsg}. Retrying in ${delay / 1000}s...`, "warning");
2154
2261
  const taskToken = orchestrator.activeTaskToken;
2155
2262
  if (orchestrator.pendingRetryTimer) clearTimeout(orchestrator.pendingRetryTimer);
2263
+ // Arm a direct ESC interrupt for this retry window — no SDK/interactive
2264
+ // binding covers pi-pi's own timer (the turn already ended in error).
2265
+ orchestrator.armRetryEscInterrupt(ctx);
2156
2266
  orchestrator.pendingRetryTimer = setTimeout(() => {
2157
2267
  orchestrator.pendingRetryTimer = null;
2158
- if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active) return;
2159
- orchestrator.safeSendUserMessage(`[PI-PI] Previous request failed due to an API error. Continue working on the current phase (${phase}).`);
2268
+ if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active) {
2269
+ orchestrator.disarmRetryEscInterrupt();
2270
+ return;
2271
+ }
2272
+ // Defer until the main session is idle: sending a followUp while the SDK
2273
+ // still has an active run throws an async, runtime-swallowed "Agent is
2274
+ // already processing" error. sendUserMessageWhenIdle polls (reusing
2275
+ // pendingRetryTimer so ESC/abort still cancels) and disarms the ESC hook
2276
+ // once delivered.
2277
+ orchestrator.sendUserMessageWhenIdle(
2278
+ `[PI-PI] Previous request failed due to an API error. Continue working on the current phase (${phase}).`,
2279
+ taskToken,
2280
+ );
2160
2281
  }, delay);
2161
2282
  } else {
2162
- ctx.ui.notify(`API error persisted after ${maxRetries} retries: ${errorMsg}. Stopping auto-retry.`, "error");
2163
- orchestrator.errorRetryCount = 0;
2283
+ ctx.ui.notify(`API error persisted after ${maxRetries} retries: ${errorMsg}. Stopping auto-retry — send any message to resume.`, "error");
2284
+ // Halt (do NOT reset the counter) so no further error turn re-arms a retry
2285
+ // until the user re-engages. cancelPendingRetry would reset the count and
2286
+ // re-open the floodgate, so only clear the live timer/ESC hook here.
2287
+ orchestrator.errorNudgeHalted = true;
2288
+ if (orchestrator.pendingRetryTimer) {
2289
+ clearTimeout(orchestrator.pendingRetryTimer);
2290
+ orchestrator.pendingRetryTimer = null;
2291
+ }
2292
+ orchestrator.disarmRetryEscInterrupt();
2164
2293
  }
2165
2294
  return;
2166
2295
  }
2167
- orchestrator.errorRetryCount = 0;
2296
+ // NOTE: errorRetryCount is intentionally NOT reset here. Resetting on every
2297
+ // non-error turn let a benign nudge-induced turn zero the counter, defeating
2298
+ // the maxRetries cap and allowing unbounded error nudges. The counter is
2299
+ // reset only on genuine user re-engagement (before_agent_start) and on task
2300
+ // reset / cancelPendingRetry.
2168
2301
 
2169
2302
  if ((globalThis as any)[SUBAGENT_SESSION_KEY]) {
2170
2303
  return;
@@ -427,6 +427,7 @@ describe("flant-infra", () => {
427
427
  enabled: false,
428
428
  autoUpdate: true,
429
429
  cacheTTLDays: 7,
430
+ switchBackIntervalMinutes: 30,
430
431
  subscription: false,
431
432
  lastUpdated: null,
432
433
  cachedFlantModels: null,
@@ -465,6 +466,7 @@ describe("flant-infra", () => {
465
466
  enabled: true,
466
467
  autoUpdate: false,
467
468
  cacheTTLDays: 3,
469
+ switchBackIntervalMinutes: 30,
468
470
  subscription: false,
469
471
  lastUpdated: "2026-01-02T03:04:05.000Z",
470
472
  cachedFlantModels: ["gpt-5-4", "claude-opus-4-6"],
@@ -488,6 +490,7 @@ describe("flant-infra", () => {
488
490
  enabled: true,
489
491
  autoUpdate: true,
490
492
  cacheTTLDays: 14,
493
+ switchBackIntervalMinutes: 30,
491
494
  subscription: true,
492
495
  lastUpdated: "2026-02-01T00:00:00.000Z",
493
496
  cachedFlantModels: ["claude-opus-4-6", "gpt-5-4"],
@@ -508,4 +511,26 @@ describe("flant-infra", () => {
508
511
  expect(loaded).toEqual(settings);
509
512
  expect(existsSync(join(dir, "extensions", "pp", "cache", "flant-models.json"))).toBe(true);
510
513
  });
514
+
515
+ it("normalizes switchBackIntervalMinutes: default when missing, parsed, floored to >=1", async () => {
516
+ const dir = makeTempDir();
517
+ const mod = await loadFlantInfraModule(dir);
518
+ const settingsDir = join(dir, "extensions", "pp", "cache");
519
+ const settingsPath = join(settingsDir, "flant-models.json");
520
+ mkdirSync(settingsDir, { recursive: true });
521
+
522
+ // Missing -> default 30.
523
+ writeFileSync(settingsPath, JSON.stringify({ enabled: true }), "utf-8");
524
+ expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(30);
525
+
526
+ // String numeric -> parsed and rounded.
527
+ writeFileSync(settingsPath, JSON.stringify({ enabled: true, switchBackIntervalMinutes: "45" }), "utf-8");
528
+ expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(45);
529
+
530
+ // Invalid / <1 -> floored to 1.
531
+ writeFileSync(settingsPath, JSON.stringify({ enabled: true, switchBackIntervalMinutes: 0 }), "utf-8");
532
+ expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(1);
533
+ writeFileSync(settingsPath, JSON.stringify({ enabled: true, switchBackIntervalMinutes: "nonsense" }), "utf-8");
534
+ expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(30);
535
+ });
511
536
  });
@@ -35,6 +35,13 @@ export interface FlantSettings {
35
35
  * Claude OAuth token (billed against their personal Claude subscription).
36
36
  */
37
37
  subscription: boolean;
38
+ /**
39
+ * Minutes between out-of-band "is the subscription limit cleared yet?" probes
40
+ * while the sub→non-sub rate-limit fallback is active. On each interval a
41
+ * cheap probe hits the sub model; on success the user is asked to switch back.
42
+ * Default 30.
43
+ */
44
+ switchBackIntervalMinutes: number;
38
45
  }
39
46
 
40
47
  const GEMINI_MAP: Record<string, string> = {
@@ -64,6 +71,7 @@ const DEFAULT_SETTINGS: FlantSettings = {
64
71
  cachedFlantModels: null,
65
72
  cachedOpenRouterData: null,
66
73
  subscription: false,
74
+ switchBackIntervalMinutes: 30,
67
75
  };
68
76
 
69
77
  /** Provider name for the personal-subscription Claude routing. */
@@ -245,10 +253,15 @@ function normalizeSettings(raw: unknown): FlantSettings {
245
253
  if (!raw || typeof raw !== "object") return { ...DEFAULT_SETTINGS };
246
254
  const value = raw as Record<string, unknown>;
247
255
  const cacheTTLDays = Math.max(1, Math.round(toNumber(value.cacheTTLDays, DEFAULT_SETTINGS.cacheTTLDays)));
256
+ const switchBackIntervalMinutes = Math.max(
257
+ 1,
258
+ Math.round(toNumber(value.switchBackIntervalMinutes, DEFAULT_SETTINGS.switchBackIntervalMinutes)),
259
+ );
248
260
  return {
249
261
  enabled: !!value.enabled,
250
262
  autoUpdate: value.autoUpdate === undefined ? true : !!value.autoUpdate,
251
263
  cacheTTLDays,
264
+ switchBackIntervalMinutes,
252
265
  subscription: !!value.subscription,
253
266
  lastUpdated: typeof value.lastUpdated === "string" ? value.lastUpdated : null,
254
267
  cachedFlantModels: Array.isArray(value.cachedFlantModels)
@@ -347,6 +360,81 @@ function mapFlantToOpenRouterId(modelId: string): string | null {
347
360
  return null;
348
361
  }
349
362
 
363
+ /**
364
+ * Out-of-band probe: is the personal Claude subscription limit cleared yet?
365
+ * Sends a tiny, fully throwaway request (NOT part of the session, so it cannot
366
+ * pollute conversation/context/cache) to the gateway's Anthropic endpoint using
367
+ * the personal Claude OAuth token. Returns:
368
+ * "ok" — 200: capacity is back (offer switch-back)
369
+ * "rate_limited" — 429: still limited (stay on non-sub, retry later)
370
+ * "error" — credentials missing / network / other status (treat as not-back)
371
+ *
372
+ * The OAuth token is refreshed first so a failure is a genuine 429 and not an
373
+ * expired-token false negative. `max_tokens: 1` + an explicit "just respond hi"
374
+ * instruction keep output at ~1 token.
375
+ */
376
+ // Derive the gateway probe model id (`sub/<bare-claude-id>`) from any stored
377
+ // form: `pp-flant-anthropic-sub/sub/<m>`, `sub/<m>`, or a bare `<m>`. Exported
378
+ // for testing the derivation without a network call.
379
+ export function subProbeModelId(modelId: string): string {
380
+ let bare = modelId;
381
+ if (bare.startsWith(`${SUB_PROVIDER}/`)) bare = bare.slice(`${SUB_PROVIDER}/`.length);
382
+ if (bare.startsWith(SUB_MODEL_PREFIX)) bare = bare.slice(SUB_MODEL_PREFIX.length);
383
+ return `${SUB_MODEL_PREFIX}${bare}`;
384
+ }
385
+
386
+ export async function probeSubscriptionCleared(
387
+ modelId: string,
388
+ ): Promise<"ok" | "rate_limited" | "error"> {
389
+ const log = getLogger();
390
+ await refreshClaudeOAuthToken();
391
+ const oauthToken = readClaudeOAuthToken();
392
+ const gatewayKey = readGatewayApiKey();
393
+ if (!oauthToken || !gatewayKey) {
394
+ log.debug({ s: "flant", hasOAuth: !!oauthToken, hasGatewayKey: !!gatewayKey }, "probe skipped: missing credentials");
395
+ return "error";
396
+ }
397
+ // The gateway expects the bare claude-* id under the `sub/` prefix.
398
+ const probeModel = subProbeModelId(modelId);
399
+ try {
400
+ const res = await fetch("https://llm-api.flant.ru/v1/messages", {
401
+ method: "POST",
402
+ headers: {
403
+ "content-type": "application/json",
404
+ "anthropic-version": "2023-06-01",
405
+ // Claude Code OAuth identity headers — the gateway requires these for
406
+ // subscription (sub/) routing; without them it can reject the probe and
407
+ // we would never detect that the limit cleared. Mirrors doctor.ts.
408
+ "anthropic-beta": "claude-code-20250219,oauth-2025-04-20",
409
+ "user-agent": "claude-cli/1.0.0",
410
+ "x-app": "cli",
411
+ Authorization: `Bearer ${oauthToken}`,
412
+ "x-litellm-api-key": `Bearer ${gatewayKey}`,
413
+ },
414
+ body: JSON.stringify({
415
+ model: probeModel,
416
+ max_tokens: 1,
417
+ temperature: 0,
418
+ messages: [{ role: "user", content: "just respond hi" }],
419
+ }),
420
+ signal: AbortSignal.timeout(30000),
421
+ });
422
+ if (res.status === 429) {
423
+ log.debug({ s: "flant", model: probeModel }, "probe: still rate limited");
424
+ return "rate_limited";
425
+ }
426
+ if (res.ok) {
427
+ log.debug({ s: "flant", model: probeModel }, "probe: capacity back");
428
+ return "ok";
429
+ }
430
+ log.debug({ s: "flant", model: probeModel, status: res.status }, "probe: unexpected status");
431
+ return "error";
432
+ } catch (err: any) {
433
+ log.debug({ s: "flant", err: err?.message }, "probe failed");
434
+ return "error";
435
+ }
436
+ }
437
+
350
438
  export async function discoverFlantModels(apiKey: string): Promise<string[]> {
351
439
  const res = await fetch("https://llm-api.flant.ru/v1/models", {
352
440
  headers: { Authorization: `Bearer ${apiKey}` },
@@ -655,6 +743,9 @@ export function generateFlantConfig(models: string[], subscriptionActive = false
655
743
  explore: { model: modelSpec(fastModel, sub), thinking: "low" },
656
744
  librarian: { model: modelSpec(fastModel, sub), thinking: "medium" },
657
745
  task: { model: modelSpec(taskModel, sub), thinking: "medium" },
746
+ advisor: { model: modelSpec(latestGpt ?? fallback, sub), thinking: "high" },
747
+ "deep-debugger": { model: modelSpec(latestGpt ?? fallback, sub), thinking: "high" },
748
+ reviewer: { model: modelSpec(latestGpt ?? fallback, sub), thinking: "high" },
658
749
  },
659
750
  presetGroups: {
660
751
  planners: buildPresetGroup({
@@ -65,7 +65,7 @@ function registerSubagentTools(pi: ExtensionAPI): void {
65
65
  ...event.content,
66
66
  {
67
67
  type: "text" as const,
68
- text: `\n\n<validation-error>\nPlan structure is invalid:\n${result.errors.map((e) => `- ${e}`).join("\n")}\n\nFix immediately. Required structure:\n# Plan\n## Scope\n<2-4 lines>\n## Checklist\n- [ ] <outcome> — Done when: <observable condition>\n## Blockers (optional)\n<issues>\n\nRewrite the file now.\n</validation-error>`,
68
+ text: `\n\n<validation-error>\nPlan structure is invalid:\n${result.errors.map((e) => `- ${e}`).join("\n")}\n\nFix immediately. Required structure:\n# Plan\n## Scope\n<2-4 lines>\n## Checklist\n- [ ] <outcome> — Done when: <observable condition>\n## Pattern constraints (optional; include when adding a type/function/user-facing value)\n<closest existing analog + conventions to mirror>\n## Blockers (optional)\n<issues>\n\nRewrite the file now.\n</validation-error>`,
69
69
  },
70
70
  ],
71
71
  };