@ilya-lesikov/pi-pi 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/3p/pi-ask-user/index.ts +1 -1
  2. package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
  3. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
  4. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
  5. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
  6. package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
  7. package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
  8. package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
  9. package/3p/pi-subagents/src/agent-manager.ts +34 -1
  10. package/3p/pi-subagents/src/agent-runner.ts +66 -33
  11. package/3p/pi-subagents/src/index.ts +3 -38
  12. package/3p/pi-subagents/src/types.ts +4 -0
  13. package/extensions/orchestrator/agents/advisor.ts +35 -0
  14. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
  15. package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
  16. package/extensions/orchestrator/agents/constraints.test.ts +44 -0
  17. package/extensions/orchestrator/agents/constraints.ts +3 -0
  18. package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
  19. package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
  20. package/extensions/orchestrator/agents/planner.ts +9 -8
  21. package/extensions/orchestrator/agents/prompts.test.ts +120 -0
  22. package/extensions/orchestrator/agents/reviewer.ts +48 -0
  23. package/extensions/orchestrator/agents/task.ts +6 -20
  24. package/extensions/orchestrator/agents/tool-routing.ts +23 -1
  25. package/extensions/orchestrator/command-handlers.ts +1 -1
  26. package/extensions/orchestrator/config.ts +8 -4
  27. package/extensions/orchestrator/context.test.ts +54 -0
  28. package/extensions/orchestrator/context.ts +65 -2
  29. package/extensions/orchestrator/custom-footer.ts +5 -2
  30. package/extensions/orchestrator/doctor.test.ts +3 -1
  31. package/extensions/orchestrator/doctor.ts +40 -2
  32. package/extensions/orchestrator/event-handlers.test.ts +97 -1
  33. package/extensions/orchestrator/event-handlers.ts +222 -48
  34. package/extensions/orchestrator/flant-infra.test.ts +312 -0
  35. package/extensions/orchestrator/flant-infra.ts +407 -44
  36. package/extensions/orchestrator/index.ts +1 -1
  37. package/extensions/orchestrator/integration.test.ts +312 -18
  38. package/extensions/orchestrator/messages.test.ts +30 -0
  39. package/extensions/orchestrator/messages.ts +6 -0
  40. package/extensions/orchestrator/model-registry.test.ts +124 -13
  41. package/extensions/orchestrator/model-registry.ts +91 -33
  42. package/extensions/orchestrator/orchestrator.test.ts +113 -0
  43. package/extensions/orchestrator/orchestrator.ts +163 -3
  44. package/extensions/orchestrator/phases/brainstorm.ts +9 -20
  45. package/extensions/orchestrator/phases/implementation.test.ts +11 -0
  46. package/extensions/orchestrator/phases/implementation.ts +4 -6
  47. package/extensions/orchestrator/phases/planning.test.ts +16 -0
  48. package/extensions/orchestrator/phases/planning.ts +11 -4
  49. package/extensions/orchestrator/phases/review-task.ts +1 -4
  50. package/extensions/orchestrator/phases/review.test.ts +62 -0
  51. package/extensions/orchestrator/phases/review.ts +58 -15
  52. package/extensions/orchestrator/plannotator.ts +9 -6
  53. package/extensions/orchestrator/pp-menu.test.ts +74 -1
  54. package/extensions/orchestrator/pp-menu.ts +366 -94
  55. package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
  56. package/extensions/orchestrator/pp-state-tools.ts +249 -0
  57. package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
  58. package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
  59. package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
  60. package/extensions/orchestrator/state.ts +41 -20
  61. package/extensions/orchestrator/test-helpers.ts +18 -3
  62. package/extensions/orchestrator/usage-tracker.test.ts +131 -3
  63. package/extensions/orchestrator/usage-tracker.ts +78 -11
  64. package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
  65. package/extensions/orchestrator/validate-artifacts.ts +2 -2
  66. package/package.json +1 -1
@@ -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,14 +238,21 @@ function tryCompleteReviewCycle(orchestrator: Orchestrator, spawnedReviewers?: n
206
238
  },
207
239
  "instruction",
208
240
  );
209
- orchestrator.safeSendUserMessage(reviewReadyMessage(phase));
241
+ orchestrator.safeSendUserMessage(advanceBanner(reviewReadyMessage(phase, getEffectivePhaseMode(orchestrator.active.state))));
210
242
  }
211
243
 
212
- function reviewReadyMessage(phase: string): string {
244
+ function reviewReadyMessage(phase: string, mode: TaskMode): string {
213
245
  if (phase === "brainstorm") {
214
246
  return "[PI-PI] Review cycle is ready for apply_feedback. The reviewers assessed your artifacts (USER_REQUEST.md, RESEARCH.md, and artifacts/), not a code diff. Read their outputs and update those artifacts as needed.";
215
247
  }
216
- return "[PI-PI] Review cycle is ready for apply_feedback. Read reviewer outputs and proceed.";
248
+ // Only autonomous plan/implement auto-advance: those must re-call
249
+ // pp_phase_complete to finalize the pass and transition. Guided phases
250
+ // (including debug and the interactive review phase) stay user-driven, so
251
+ // they get neutral wording with no re-call/auto-advance directive.
252
+ if (mode === "autonomous") {
253
+ return "[PI-PI] Review cycle is ready for apply_feedback. Read the reviewer outputs, apply any required changes, then call pp_phase_complete again to finalize this review pass and advance the phase. Do NOT stop or wait for the user — the phase is NOT complete until you re-call pp_phase_complete.";
254
+ }
255
+ return "[PI-PI] Review cycle is ready for apply_feedback. Read the reviewer outputs and apply any required changes.";
217
256
  }
218
257
 
219
258
  export async function enterReviewCycle(
@@ -414,6 +453,7 @@ function registerOrchestratorTools(orchestrator: Orchestrator): void {
414
453
  registerPhaseCompleteTool(orchestrator);
415
454
  registerCommitTool(orchestrator);
416
455
  registerSpecifyReviewsTool(orchestrator);
456
+ registerStateFileTools(orchestrator);
417
457
  }
418
458
 
419
459
  function registerRepoTool(orchestrator: Orchestrator): void {
@@ -555,24 +595,24 @@ function registerRepoTool(orchestrator: Orchestrator): void {
555
595
  });
556
596
  }
557
597
 
558
- function openCodeReviewDirect(
559
- pi: ExtensionAPI,
598
+ async function openCodeReviewDirect(
599
+ orchestrator: Orchestrator,
560
600
  payload: Record<string, unknown>,
561
601
  ): Promise<{ approved: boolean; feedback?: string } | { error: string }> {
562
- return new Promise((resolve) => {
563
- pi.events.emit("plannotator:request", {
564
- requestId: crypto.randomUUID(),
565
- action: "code-review",
566
- payload,
567
- respond: (response: any) => {
568
- if (response.status === "handled") {
569
- resolve({ approved: !!response.result?.approved, feedback: response.result?.feedback });
570
- } else {
571
- resolve({ error: response.error || "Plannotator not available" });
572
- }
573
- },
574
- });
575
- });
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 };
576
616
  }
577
617
 
578
618
  function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
@@ -664,7 +704,7 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
664
704
  continue;
665
705
  }
666
706
 
667
- const result = await openCodeReviewDirect(pi, {
707
+ const result = await openCodeReviewDirect(orchestrator, {
668
708
  cwd: reviewCwd,
669
709
  diffType: "branch",
670
710
  defaultBranch: compareBase,
@@ -713,8 +753,14 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
713
753
 
714
754
  ctx.ui?.setWorkingMessage?.("Waiting for user input…");
715
755
  try {
716
- const { showActiveTaskMenu } = await import("./pp-menu.js");
756
+ const { showActiveTaskMenu, USER_CANCELLED } = await import("./pp-menu.js");
717
757
  const text = await showActiveTaskMenu(orchestrator, ctx, `Plannotator review complete.\n\n${summary}`, "tool");
758
+ // Deliberate user ESC: stop the turn cleanly (mirror ask_user), no
759
+ // reminder text that would start a new LLM turn.
760
+ if (text === USER_CANCELLED) {
761
+ ctx.abort?.();
762
+ return { content: [{ type: "text" as const, text: "" }], details: {} };
763
+ }
718
764
  // A transition may have started while the menu was open. The controller
719
765
  // is the source of truth; abort the agent's pending turn so it doesn't
720
766
  // race the transition. (Interactive-UX abort — stays local, not routed.)
@@ -840,7 +886,10 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
840
886
  const count = orchestrator.spawnedAgentIds.size + orchestrator.pendingSubagentSpawns;
841
887
  return { content: [{ type: "text" as const, text: `${count} subagent(s) still running. Wait for them to complete before calling pp_phase_complete.` }], isError: true as const, details: {} };
842
888
  }
843
- const effectiveMode = getEffectiveMode(orchestrator.active.state);
889
+ // Gate on the effective PHASE mode, not the task mode: brainstorm/debug/
890
+ // review are always user-driven even for an autonomous task, so they must
891
+ // fall through to the guided menu path below rather than auto-advancing.
892
+ const effectiveMode = getEffectivePhaseMode(orchestrator.active.state);
844
893
  if (effectiveMode === "autonomous") {
845
894
  const phase = orchestrator.active.state.phase;
846
895
  let justFinalizedReviewCycle = false;
@@ -908,8 +957,16 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
908
957
  }
909
958
  ctx.ui.setWorkingMessage?.("Waiting for user approval…");
910
959
  try {
911
- const { showActiveTaskMenu } = await import("./pp-menu.js");
960
+ const { showActiveTaskMenu, USER_CANCELLED } = await import("./pp-menu.js");
912
961
  const text = await showActiveTaskMenu(orchestrator, ctx, params.summary, "tool");
962
+ // A deliberate user ESC on the menu means "stop cleanly, let me type" —
963
+ // mirror ask_user: abort the turn and return nothing so no new LLM turn
964
+ // starts. This takes precedence over the reminder path below, in ALL
965
+ // interactive cases (including while a transition is mid-flight).
966
+ if (text === USER_CANCELLED) {
967
+ ctx.abort?.();
968
+ return { content: [{ type: "text" as const, text: "" }], details: {} };
969
+ }
913
970
  // A transition or await may have started while the menu was open. The
914
971
  // controller is the source of truth; abort the pending turn so it can't
915
972
  // race. (Interactive-UX abort — stays local, not routed.)
@@ -923,6 +980,8 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
923
980
  return { content: [{ type: "text" as const, text: "" }], details: {} };
924
981
  }
925
982
  if (!text) {
983
+ // Non-ESC dismissal (explicit "Back") while a transition is running:
984
+ // keep the intentional artifact-update reminder (commit 10e7021).
926
985
  return { content: [{ type: "text" as const, text: "User dismissed the menu. Wait for the user's next message. When you resume work, update USER_REQUEST.md and RESEARCH.md with any new findings before calling pp_phase_complete." }], details: {} };
927
986
  }
928
987
  return { content: [{ type: "text" as const, text }], details: {} };
@@ -993,6 +1052,21 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
993
1052
 
994
1053
  registerMainTraceHooks(orchestrator);
995
1054
 
1055
+ // Personal-subscription Claude routing registers the sub provider with a
1056
+ // literal OAuth token (a static snapshot). That token expires within a few
1057
+ // hours, so a long-lived session would keep sending a stale token and the
1058
+ // gateway would return 401. Refresh (and re-register on change) before each
1059
+ // turn's LLM call. Cheap no-op when subscription routing is inactive or the
1060
+ // token is unchanged. registerEventHandlers runs only on the root session.
1061
+ pi.on("turn_start", async () => {
1062
+ try {
1063
+ const { refreshSubProvider } = await import("./flant-infra.js");
1064
+ await refreshSubProvider(pi);
1065
+ } catch (err: any) {
1066
+ getLogger().debug({ s: "flant", err: err?.message }, "sub provider refresh failed");
1067
+ }
1068
+ });
1069
+
996
1070
  // TransitionController drivers. These are the reliable idle/completion signals:
997
1071
  // - agent_end: the main loop went idle. If a transition is pending, this is
998
1072
  // when the controller fires compaction.
@@ -1127,6 +1201,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1127
1201
  if (data.description) {
1128
1202
  orchestrator.agentDescriptions.set(data.id, data.description);
1129
1203
  }
1204
+
1130
1205
  trackSubagentEvent(data, "created");
1131
1206
  startStaleAgentWatchdog();
1132
1207
  const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
@@ -1173,7 +1248,14 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1173
1248
 
1174
1249
  const failedPlannerVariants = [...orchestrator.failedPlannerVariants];
1175
1250
  const effectiveMode = getEffectiveMode(orchestrator.active.state);
1176
- 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
+ ) {
1177
1259
  const alreadyRetried = orchestrator.active.state.plannerFailureAutoRetried === true;
1178
1260
  if (!alreadyRetried) {
1179
1261
  const failedSet = new Set(failedPlannerVariants);
@@ -1345,7 +1427,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1345
1427
 
1346
1428
  const failedReviewerVariants = [...orchestrator.failedReviewerVariants];
1347
1429
  const effectiveMode = getEffectiveMode(orchestrator.active.state);
1348
- 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
+ ) {
1349
1437
  const cycle = orchestrator.active.state.reviewCycle;
1350
1438
  const phase = orchestrator.active.state.phase;
1351
1439
  const outputs = loadPhaseReviewOutputs(orchestrator.active.dir, phase, cycle.pass);
@@ -1600,6 +1688,23 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1600
1688
  orchestrator.abortAllSubagents();
1601
1689
  }
1602
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
+
1603
1708
  orchestrator.transitionController.sendCustom(
1604
1709
  {
1605
1710
  customType: "pp-subagent-error",
@@ -1780,6 +1885,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1780
1885
  if (!isControllerInjected) {
1781
1886
  orchestrator.nudgeHalted = false;
1782
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;
1783
1892
  }
1784
1893
  orchestrator.updateStatus(ctx);
1785
1894
 
@@ -1814,6 +1923,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1814
1923
  constraintsBlock(phase as Phase, effectiveMode),
1815
1924
  PRINCIPLES_BLOCK,
1816
1925
  TOOLS_BLOCK,
1926
+ DELEGATION_BLOCK,
1817
1927
  projectContext,
1818
1928
  taskBlock,
1819
1929
  ]
@@ -1836,24 +1946,29 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1836
1946
  if (event.toolName === "Agent" && orchestrator.active) {
1837
1947
  const input = event.input as Record<string, unknown>;
1838
1948
  const requestedType = ((input.subagent_type as string) || "").toLowerCase();
1949
+ const validTypes = ["explore", "librarian", "task", "advisor", "deep-debugger", "reviewer"];
1839
1950
  if (!requestedType) {
1840
- 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)." };
1841
1952
  }
1842
- const isExplore = requestedType === "explore";
1843
- const isLibrarian = requestedType === "librarian";
1844
-
1845
- if (isExplore) {
1846
- input.subagent_type = "explore";
1847
- input.model = resolveModel(orchestrator.config.agents.subagents.simple.explore.model);
1848
- input.thinking = orchestrator.config.agents.subagents.simple.explore.thinking;
1849
- } else if (isLibrarian) {
1850
- input.subagent_type = "librarian";
1851
- input.model = resolveModel(orchestrator.config.agents.subagents.simple.librarian.model);
1852
- input.thinking = orchestrator.config.agents.subagents.simple.librarian.thinking;
1853
- } else {
1854
- input.subagent_type = "task";
1855
- input.model = resolveModel(orchestrator.config.agents.subagents.simple.task.model);
1856
- 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
+ }
1857
1972
  }
1858
1973
  }
1859
1974
 
@@ -2105,6 +2220,39 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
2105
2220
  contentBlocks: msg.content?.length ?? 0,
2106
2221
  contentSummary,
2107
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
+ }
2108
2256
  orchestrator.errorRetryCount = (orchestrator.errorRetryCount ?? 0) + 1;
2109
2257
  const maxRetries = 5;
2110
2258
  if (orchestrator.errorRetryCount <= maxRetries) {
@@ -2112,18 +2260,44 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
2112
2260
  ctx.ui.notify(`API error (attempt ${orchestrator.errorRetryCount}/${maxRetries}): ${errorMsg}. Retrying in ${delay / 1000}s...`, "warning");
2113
2261
  const taskToken = orchestrator.activeTaskToken;
2114
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);
2115
2266
  orchestrator.pendingRetryTimer = setTimeout(() => {
2116
2267
  orchestrator.pendingRetryTimer = null;
2117
- if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active) return;
2118
- 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
+ );
2119
2281
  }, delay);
2120
2282
  } else {
2121
- ctx.ui.notify(`API error persisted after ${maxRetries} retries: ${errorMsg}. Stopping auto-retry.`, "error");
2122
- 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();
2123
2293
  }
2124
2294
  return;
2125
2295
  }
2126
- 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.
2127
2301
 
2128
2302
  if ((globalThis as any)[SUBAGENT_SESSION_KEY]) {
2129
2303
  return;