@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
@@ -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
+ }
@@ -215,9 +215,12 @@ export function expectReviewAuto(menu: AskUserHarness, preset = "regular"): void
215
215
  menu.expect({ question: "Review preset", options: { include: [m.preset(preset), "Back"] }, choose: m.preset(preset) });
216
216
  }
217
217
 
218
- export function expectReviewOnMyOwn(menu: AskUserHarness): void {
218
+ export function expectReviewOnMyOwn(menu: AskUserHarness, editorGate?: "Done" | "Skip markers"): void {
219
219
  menu.expect({ question: m.anyTaskMenu, options: { include: ["Review"] }, choose: "Review" });
220
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
+ }
221
224
  }
222
225
 
223
226
  export function expectPpStartImplementAutonomous(menu: AskUserHarness): void {
@@ -12,7 +12,11 @@ import { SUB_MODEL_PREFIX, SUB_PROVIDER } from "./flant-infra.js";
12
12
  */
13
13
  export function isSubscriptionRouted(modelId?: string, provider?: string): boolean {
14
14
  if (provider === SUB_PROVIDER) return true;
15
- return typeof modelId === "string" && modelId.startsWith(SUB_MODEL_PREFIX);
15
+ if (typeof modelId !== "string") return false;
16
+ // Accept a bare `sub/<m>` id OR a full provider-prefixed spec
17
+ // `pp-flant-anthropic-sub/sub/<m>` passed as a single id (the form callers
18
+ // store/pass without a separate provider field).
19
+ return modelId.startsWith(SUB_MODEL_PREFIX) || modelId.startsWith(`${SUB_PROVIDER}/`);
16
20
  }
17
21
 
18
22
  export interface ModelUsage {
@@ -41,6 +41,26 @@ Ship minimal fix.
41
41
  expect(result.ok).toBe(true);
42
42
  });
43
43
 
44
+ it("accepts a Pattern constraints section", () => {
45
+ const content = `# Plan
46
+
47
+ ## Scope
48
+ Add a new annotation.
49
+
50
+ ## Checklist
51
+ - [ ] Add annotation — Done when: tests pass
52
+
53
+ ## Pattern constraints
54
+ - Mirror deletePolicies: one typed slice, kebab-case values.
55
+
56
+ ## Blockers
57
+ - none
58
+ `;
59
+
60
+ const result = validatePlan(content);
61
+ expect(result.ok).toBe(true);
62
+ });
63
+
44
64
  it("accepts multiline Done when continuation", () => {
45
65
  const content = `# Plan
46
66
 
@@ -5,7 +5,7 @@ const USER_REQUEST_ALLOWED_SECTIONS = ["Problem", "Constraints"] as const;
5
5
  const RESEARCH_REQUIRED_SECTIONS = ["Affected Code", "Architecture Context", "Constraints & Edge Cases"] as const;
6
6
  const RESEARCH_ALLOWED_SECTIONS = ["Affected Code", "Architecture Context", "Constraints & Edge Cases", "Open Questions"] as const;
7
7
  const PLAN_REQUIRED_SECTIONS = ["Scope", "Checklist"] as const;
8
- const PLAN_ALLOWED_SECTIONS = ["Scope", "Checklist", "Blockers"] as const;
8
+ const PLAN_ALLOWED_SECTIONS = ["Scope", "Checklist", "Pattern constraints", "Blockers"] as const;
9
9
  const PLACEHOLDER_PATTERNS = /^(?:[-*.…—]|tbd|todo|n\/a|na|none|\.{2,})$/i;
10
10
 
11
11
  function splitLines(content: string): string[] {
@@ -186,7 +186,7 @@ export function validatePlan(content: string): ValidationResult {
186
186
  const errors: string[] = [];
187
187
  const h1 = getH1(content);
188
188
  const firstHeading = getFirstHeading(content);
189
- const expectedSections = formatSectionList(PLAN_REQUIRED_SECTIONS, ["Blockers"]);
189
+ const expectedSections = formatSectionList(PLAN_REQUIRED_SECTIONS, ["Pattern constraints", "Blockers"]);
190
190
 
191
191
  if (!firstHeading) {
192
192
  errors.push("Missing required heading: # Plan. Expected first heading: # Plan");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilya-lesikov/pi-pi",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Pi-Pi Coding Agent: based on Pi Coding Agent, but twice as good",
5
5
  "keywords": [
6
6
  "pi-package",