@ilya-lesikov/pi-pi 0.7.0 → 0.9.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 (75) 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 +27 -11
  15. package/extensions/orchestrator/agents/constraints.test.ts +82 -0
  16. package/extensions/orchestrator/agents/constraints.ts +66 -2
  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 +39 -1
  24. package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
  25. package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
  26. package/extensions/orchestrator/command-handlers.test.ts +47 -0
  27. package/extensions/orchestrator/command-handlers.ts +44 -28
  28. package/extensions/orchestrator/config.test.ts +40 -1
  29. package/extensions/orchestrator/config.ts +18 -2
  30. package/extensions/orchestrator/context.test.ts +54 -0
  31. package/extensions/orchestrator/context.ts +81 -2
  32. package/extensions/orchestrator/custom-footer.test.ts +91 -0
  33. package/extensions/orchestrator/custom-footer.ts +20 -20
  34. package/extensions/orchestrator/event-handlers.test.ts +412 -2
  35. package/extensions/orchestrator/event-handlers.ts +556 -227
  36. package/extensions/orchestrator/flant-infra.test.ts +25 -0
  37. package/extensions/orchestrator/flant-infra.ts +91 -0
  38. package/extensions/orchestrator/index.ts +1 -1
  39. package/extensions/orchestrator/integration.test.ts +411 -20
  40. package/extensions/orchestrator/messages.test.ts +30 -0
  41. package/extensions/orchestrator/messages.ts +6 -0
  42. package/extensions/orchestrator/model-registry.test.ts +48 -1
  43. package/extensions/orchestrator/model-registry.ts +43 -1
  44. package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  45. package/extensions/orchestrator/orchestrator.test.ts +113 -0
  46. package/extensions/orchestrator/orchestrator.ts +197 -60
  47. package/extensions/orchestrator/phases/brainstorm.ts +20 -27
  48. package/extensions/orchestrator/phases/implementation.test.ts +11 -0
  49. package/extensions/orchestrator/phases/implementation.ts +4 -6
  50. package/extensions/orchestrator/phases/machine.test.ts +36 -0
  51. package/extensions/orchestrator/phases/machine.ts +11 -1
  52. package/extensions/orchestrator/phases/planning.test.ts +16 -0
  53. package/extensions/orchestrator/phases/planning.ts +11 -4
  54. package/extensions/orchestrator/phases/review-task.test.ts +20 -0
  55. package/extensions/orchestrator/phases/review-task.ts +47 -22
  56. package/extensions/orchestrator/phases/review.test.ts +34 -0
  57. package/extensions/orchestrator/phases/review.ts +44 -6
  58. package/extensions/orchestrator/plannotator.ts +9 -6
  59. package/extensions/orchestrator/pp-menu.test.ts +207 -1
  60. package/extensions/orchestrator/pp-menu.ts +514 -402
  61. package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
  62. package/extensions/orchestrator/pp-state-tools.ts +249 -0
  63. package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
  64. package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
  65. package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
  66. package/extensions/orchestrator/state.test.ts +9 -0
  67. package/extensions/orchestrator/state.ts +15 -0
  68. package/extensions/orchestrator/test-helpers.ts +4 -1
  69. package/extensions/orchestrator/transition-controller.test.ts +100 -0
  70. package/extensions/orchestrator/transition-controller.ts +61 -3
  71. package/extensions/orchestrator/usage-tracker.ts +5 -1
  72. package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
  73. package/extensions/orchestrator/validate-artifacts.ts +2 -2
  74. package/package.json +1 -1
  75. package/AGENTS.md +0 -28
@@ -1,4 +1,4 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  vi.mock("./log.js", () => ({
4
4
  getLogger: () => ({
@@ -11,7 +11,10 @@ import {
11
11
  getAllAliases,
12
12
  getModelFamilies,
13
13
  getModelInfo,
14
+ isSubscriptionFallbackActive,
14
15
  resolveModel,
16
+ setSubscriptionFallbackActive,
17
+ toNonSubSpec,
15
18
  updateRegistryFromAvailableModels,
16
19
  } from "./model-registry.js";
17
20
 
@@ -299,4 +302,48 @@ describe("model-registry", () => {
299
302
  expect(findLatestFamilyMatch("anthropic/claude-opus-latest", [])).toBeNull();
300
303
  });
301
304
  });
305
+
306
+ describe("subscription fallback rewrite", () => {
307
+ beforeEach(() => setSubscriptionFallbackActive(false));
308
+ afterEach(() => setSubscriptionFallbackActive(false));
309
+
310
+ it("toNonSubSpec rewrites provider-prefixed sub specs", () => {
311
+ expect(toNonSubSpec("pp-flant-anthropic-sub/sub/claude-opus-4-8")).toBe("pp-flant-anthropic/claude-opus-4-8");
312
+ });
313
+
314
+ it("toNonSubSpec rewrites bare sub/ ids", () => {
315
+ expect(toNonSubSpec("sub/claude-haiku-4-5")).toBe("pp-flant-anthropic/claude-haiku-4-5");
316
+ });
317
+
318
+ it("toNonSubSpec leaves non-subscription specs unchanged", () => {
319
+ expect(toNonSubSpec("pp-flant-anthropic/claude-opus-4-8")).toBe("pp-flant-anthropic/claude-opus-4-8");
320
+ expect(toNonSubSpec("openai/gpt-5.4")).toBe("openai/gpt-5.4");
321
+ });
322
+
323
+ it("resolveModel leaves sub specs unchanged while fallback inactive", () => {
324
+ expect(isSubscriptionFallbackActive()).toBe(false);
325
+ expect(resolveModel("pp-flant-anthropic-sub/sub/claude-opus-4-8")).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
326
+ });
327
+
328
+ it("resolveModel rewrites sub specs to non-sub while fallback active", () => {
329
+ setSubscriptionFallbackActive(true);
330
+ expect(isSubscriptionFallbackActive()).toBe(true);
331
+ expect(resolveModel("pp-flant-anthropic-sub/sub/claude-opus-4-8")).toBe("pp-flant-anthropic/claude-opus-4-8");
332
+ expect(resolveModel("sub/claude-haiku-4-5")).toBe("pp-flant-anthropic/claude-haiku-4-5");
333
+ });
334
+
335
+ it("resolveModel does not touch non-sub specs while fallback active", () => {
336
+ setSubscriptionFallbackActive(true);
337
+ expect(resolveModel("pp-flant-openai/gpt-5-4")).toBe("pp-flant-openai/gpt-5-4");
338
+ });
339
+
340
+ it("resolveModel rewrites a subscription alias after registry update while fallback active", () => {
341
+ updateRegistryFromAvailableModels([
342
+ "pp-flant-anthropic-sub/sub/claude-opus-4-7",
343
+ "pp-flant-anthropic-sub/sub/claude-opus-4-8",
344
+ ]);
345
+ setSubscriptionFallbackActive(true);
346
+ expect(resolveModel("pp-flant-anthropic-sub/claude-opus-latest")).toBe("pp-flant-anthropic/claude-opus-4-8");
347
+ });
348
+ });
302
349
  });
@@ -207,12 +207,54 @@ function findFamily(modelId: string): ModelFamilyDefinition | null {
207
207
  }
208
208
 
209
209
  import { getLogger } from "./log.js";
210
+ import { SUB_MODEL_PREFIX, SUB_PROVIDER } from "./flant-infra.js";
211
+
212
+ // Session-scoped subscription fallback. When active (set after the user accepts
213
+ // a sub-429 fallback), every model spec resolved through resolveModel that is
214
+ // subscription-routed (`pp-flant-anthropic-sub/sub/<m>` or a bare `sub/<m>`) is
215
+ // rewritten to the regular per-token form `pp-flant-anthropic/<m>`. This is the
216
+ // single lever every resolution site funnels through (main phase switchModel,
217
+ // subagent tool_call input.model, planner/reviewer specs, registerAgents), so a
218
+ // one-shot switch cannot be undone by a later phase/subagent re-resolving a
219
+ // `sub/` spec. Cleared when the user switches back or the task ends.
220
+ let subscriptionFallbackActive = false;
221
+
222
+ export function setSubscriptionFallbackActive(active: boolean): void {
223
+ subscriptionFallbackActive = active;
224
+ }
225
+
226
+ export function isSubscriptionFallbackActive(): boolean {
227
+ return subscriptionFallbackActive;
228
+ }
229
+
230
+ // Rewrite a subscription-routed spec to its regular per-token equivalent.
231
+ // Handles both `pp-flant-anthropic-sub/sub/<m>` and a bare `sub/<m>` id.
232
+ // Non-subscription specs pass through unchanged.
233
+ export function toNonSubSpec(spec: string): string {
234
+ if (spec.startsWith(`${SUB_PROVIDER}/${SUB_MODEL_PREFIX}`)) {
235
+ return `pp-flant-anthropic/${spec.slice(`${SUB_PROVIDER}/${SUB_MODEL_PREFIX}`.length)}`;
236
+ }
237
+ if (spec.startsWith(`${SUB_PROVIDER}/`)) {
238
+ return `pp-flant-anthropic/${spec.slice(`${SUB_PROVIDER}/`.length)}`;
239
+ }
240
+ if (spec.startsWith(SUB_MODEL_PREFIX)) {
241
+ return `pp-flant-anthropic/${spec.slice(SUB_MODEL_PREFIX.length)}`;
242
+ }
243
+ return spec;
244
+ }
210
245
 
211
246
  export function resolveModel(aliasOrId: string): string {
212
- const resolved = aliasMap[aliasOrId] ?? aliasOrId;
247
+ let resolved = aliasMap[aliasOrId] ?? aliasOrId;
213
248
  if (resolved !== aliasOrId) {
214
249
  getLogger().debug({ s: "model", alias: aliasOrId, resolved }, "resolved model alias");
215
250
  }
251
+ if (subscriptionFallbackActive) {
252
+ const rewritten = toNonSubSpec(resolved);
253
+ if (rewritten !== resolved) {
254
+ getLogger().debug({ s: "model", from: resolved, to: rewritten }, "subscription fallback rewrite");
255
+ resolved = rewritten;
256
+ }
257
+ }
216
258
  return resolved;
217
259
  }
218
260
 
@@ -0,0 +1 @@
1
+ {"version":"4.1.4","results":[[":pp-menu.test.ts",{"duration":5.372961000000032,"failed":false}],[":integration.test.ts",{"duration":943.253143,"failed":false}],[":phases/review.test.ts",{"duration":2.5133239999999546,"failed":false}],[":pr-comments.test.ts",{"duration":4.420278999999994,"failed":false}],[":transition-controller.test.ts",{"duration":5.222491000000005,"failed":false}],[":event-handlers.test.ts",{"duration":10.928670000000011,"failed":false}],[":ai-comment-cleanup.test.ts",{"duration":3.3210659999999947,"failed":false}],[":flant-infra.test.ts",{"duration":40.368476,"failed":false}],[":command-handlers.test.ts",{"duration":13.727003000000025,"failed":false}],[":usage-tracker.test.ts",{"duration":7.265218000000004,"failed":false}],[":doctor.test.ts",{"duration":10.992058,"failed":false}],[":context.test.ts",{"duration":8.419984999999997,"failed":false}],[":orchestrator.test.ts",{"duration":12.065533000000016,"failed":false}],[":config.test.ts",{"duration":8.647639000000026,"failed":false}],[":model-registry.test.ts",{"duration":9.934792000000002,"failed":false}],[":state.test.ts",{"duration":13.755476000000002,"failed":false}],[":commands.test.ts",{"duration":270.744763,"failed":false}],[":phases/machine.test.ts",{"duration":8.854923999999983,"failed":false}],[":pp-state-tools.test.ts",{"duration":7.878408999999991,"failed":false}],[":log.test.ts",{"duration":884.438896,"failed":false}],[":repo-utils.test.ts",{"duration":3.7061610000000016,"failed":false}],[":phases/verdict.test.ts",{"duration":4.656940000000006,"failed":false}],[":rate-limit-fallback-flow.test.ts",{"duration":6.88740700000001,"failed":false}],[":agents/prompts.test.ts",{"duration":4.860277999999994,"failed":false}],[":tracer.test.ts",{"duration":3.9392250000000004,"failed":false}],[":rate-limit-fallback.test.ts",{"duration":3.124099000000001,"failed":false}],[":validate-artifacts.test.ts",{"duration":3.718504000000003,"failed":false}],[":plannotator.test.ts",{"duration":6.168539999999993,"failed":false}],[":agents/constraints.test.ts",{"duration":2.458521999999995,"failed":false}],[":custom-footer.test.ts",{"duration":3.8825789999999927,"failed":false}],[":phases/spawn-blocking.test.ts",{"duration":54.621959000000004,"failed":false}],[":exa.test.ts",{"duration":3.8611999999999966,"failed":false}],[":spawn-cleanup.test.ts",{"duration":2.2092779999999976,"failed":false}],[":subagent-session-marker.test.ts",{"duration":1.6192010000000039,"failed":false}],[":model-version.test.ts",{"duration":5.690410999999997,"failed":false}],[":messages.test.ts",{"duration":1.798244000000004,"failed":false}],[":cbm.test.ts",{"duration":2.306488999999999,"failed":false}],[":phases/brainstorm.test.ts",{"duration":1.6732620000000225,"failed":false}],[":phases/planning.test.ts",{"duration":1.6508400000000165,"failed":false}],[":phases/implementation.test.ts",{"duration":2.0228309999999965,"failed":false}]]}
@@ -123,6 +123,119 @@ describe("Orchestrator.safeSendUserMessage", () => {
123
123
  });
124
124
  });
125
125
 
126
+ describe("Orchestrator.cancelPendingRetry", () => {
127
+ it("clears the pending timer, disarms the ESC interrupt, and resets the counter", () => {
128
+ vi.useFakeTimers();
129
+ const send = vi.fn();
130
+ const orchestrator = new Orchestrator(makePi({ sendUserMessage: send }));
131
+ const unsub = vi.fn();
132
+ orchestrator.errorRetryCount = 3;
133
+ orchestrator.pendingRetryEscUnsub = unsub;
134
+ orchestrator.pendingRetryTimer = setTimeout(() => send("fired"), 1000) as any;
135
+
136
+ orchestrator.cancelPendingRetry();
137
+
138
+ expect(orchestrator.pendingRetryTimer).toBeNull();
139
+ expect(orchestrator.pendingRetryEscUnsub).toBeNull();
140
+ expect(orchestrator.errorRetryCount).toBe(0);
141
+ expect(unsub).toHaveBeenCalledTimes(1);
142
+ vi.advanceTimersByTime(2000);
143
+ expect(send).not.toHaveBeenCalled();
144
+ vi.useRealTimers();
145
+ });
146
+ });
147
+
148
+ describe("Orchestrator.armRetryEscInterrupt", () => {
149
+ function makeCtxWithTerminal() {
150
+ let handler: ((data: string) => any) | null = null;
151
+ const notify = vi.fn();
152
+ const ctx = {
153
+ ui: {
154
+ notify,
155
+ onTerminalInput: (h: (data: string) => any) => {
156
+ handler = h;
157
+ return () => { handler = null; };
158
+ },
159
+ },
160
+ };
161
+ return { ctx, notify, feed: (data: string) => handler?.(data) };
162
+ }
163
+
164
+ it("cancels the pending retry on a bare ESC only", () => {
165
+ vi.useFakeTimers();
166
+ const orchestrator = new Orchestrator(makePi());
167
+ const { ctx, notify, feed } = makeCtxWithTerminal();
168
+ orchestrator.pendingRetryTimer = setTimeout(() => {}, 10000) as any;
169
+ orchestrator.armRetryEscInterrupt(ctx as any);
170
+
171
+ // Arrow key (ESC-prefixed sequence) must NOT cancel and must NOT be consumed.
172
+ const arrow = feed("\x1b[A");
173
+ expect(arrow).toBeUndefined();
174
+ expect(orchestrator.pendingRetryTimer).not.toBeNull();
175
+
176
+ // Bare ESC cancels and consumes.
177
+ const esc = feed("\x1b");
178
+ expect(esc).toEqual({ consume: true });
179
+ expect(orchestrator.pendingRetryTimer).toBeNull();
180
+ expect(notify).toHaveBeenCalled();
181
+ vi.useRealTimers();
182
+ });
183
+
184
+ it("is a no-op when no retry timer is armed", () => {
185
+ const orchestrator = new Orchestrator(makePi());
186
+ const { ctx, feed } = makeCtxWithTerminal();
187
+ orchestrator.armRetryEscInterrupt(ctx as any);
188
+ expect(feed("\x1b")).toBeUndefined();
189
+ });
190
+ });
191
+
192
+ describe("Orchestrator.sendUserMessageWhenIdle", () => {
193
+ it("sends immediately when idle", () => {
194
+ const send = vi.fn();
195
+ const orchestrator = new Orchestrator(makePi({ sendUserMessage: send }));
196
+ orchestrator.active = makeActiveTask(null);
197
+ orchestrator.activeTaskToken = 7;
198
+ orchestrator.lastCtx = { isIdle: () => true };
199
+
200
+ orchestrator.sendUserMessageWhenIdle("[PI-PI] go", 7);
201
+ expect(send).toHaveBeenCalledWith("[PI-PI] go", { deliverAs: "followUp" });
202
+ });
203
+
204
+ it("defers while busy, then sends once idle", async () => {
205
+ vi.useFakeTimers();
206
+ const send = vi.fn();
207
+ const orchestrator = new Orchestrator(makePi({ sendUserMessage: send }));
208
+ orchestrator.active = makeActiveTask(null);
209
+ orchestrator.activeTaskToken = 1;
210
+ let idle = false;
211
+ orchestrator.lastCtx = { isIdle: () => idle };
212
+
213
+ orchestrator.sendUserMessageWhenIdle("[PI-PI] go", 1);
214
+ expect(send).not.toHaveBeenCalled();
215
+ await vi.advanceTimersByTimeAsync(3000);
216
+ expect(send).not.toHaveBeenCalled();
217
+ idle = true;
218
+ await vi.advanceTimersByTimeAsync(1000);
219
+ expect(send).toHaveBeenCalledWith("[PI-PI] go", { deliverAs: "followUp" });
220
+ vi.useRealTimers();
221
+ });
222
+
223
+ it("drops (does not send) when the task token changes", async () => {
224
+ vi.useFakeTimers();
225
+ const send = vi.fn();
226
+ const orchestrator = new Orchestrator(makePi({ sendUserMessage: send }));
227
+ orchestrator.active = makeActiveTask(null);
228
+ orchestrator.activeTaskToken = 1;
229
+ orchestrator.lastCtx = { isIdle: () => false };
230
+
231
+ orchestrator.sendUserMessageWhenIdle("[PI-PI] go", 1);
232
+ orchestrator.activeTaskToken = 2; // task switched
233
+ await vi.advanceTimersByTimeAsync(2000);
234
+ expect(send).not.toHaveBeenCalled();
235
+ vi.useRealTimers();
236
+ });
237
+ });
238
+
126
239
  describe("Orchestrator.switchModel thinking level", () => {
127
240
  function makeCtx() {
128
241
  return {
@@ -13,7 +13,6 @@ import {
13
13
  type TaskState,
14
14
  type Phase,
15
15
  } from "./state.js";
16
- import { phasePipeline } from "./phases/machine.js";
17
16
  import { getContextDirs, loadAllContextFiles, getPhaseArtifacts, getLatestSynthesizedPlan } from "./context.js";
18
17
  import { brainstormSystemPrompt } from "./phases/brainstorm.js";
19
18
  import { planningSystemPrompt, spawnPlanners } from "./phases/planning.js";
@@ -24,10 +23,14 @@ import { registerAgentDefinitions, unregisterAgentDefinitions } from "./agents/r
24
23
  import { createExploreAgent } from "./agents/explore.js";
25
24
  import { createLibrarianAgent } from "./agents/librarian.js";
26
25
  import { createTaskAgent } from "./agents/task.js";
27
- import { resolveModel, getModelInfo, findLatestFamilyMatch } from "./model-registry.js";
26
+ import { createAdvisorAgent } from "./agents/advisor.js";
27
+ import { createDeepDebuggerAgent } from "./agents/deep-debugger.js";
28
+ import { createReviewerAgent } from "./agents/reviewer.js";
29
+ import { resolveModel, getModelInfo, findLatestFamilyMatch, setSubscriptionFallbackActive } from "./model-registry.js";
28
30
  import { buildRepoContext } from "./agents/repo-context.js";
29
31
  import { getLogger, addTaskDestination, removeTaskDestination, setLogLevel } from "./log.js";
30
32
  import { handleSpawnResult } from "./spawn-cleanup.js";
33
+ import { getTracer } from "./tracer.js";
31
34
  import { TransitionController, type TransitionHost } from "./transition-controller.js";
32
35
 
33
36
  function isEnabled(value: { enabled?: boolean } | undefined): boolean {
@@ -70,6 +73,16 @@ export class Orchestrator {
70
73
  step?: string;
71
74
  }>();
72
75
  staleAgentTimer: ReturnType<typeof setInterval> | null = null;
76
+ // Main-turn stall watchdog (BUG-2). A main turn that starts but never emits a
77
+ // terminal turn_end/error can wedge the session ("Working…" forever). Unlike
78
+ // staleAgentTimer (subagents only), this watches the MAIN session: any main-
79
+ // session stream/tool/turn activity refreshes mainTurnLastActivity; when a turn
80
+ // is in flight with no activity beyond config.performance.internals.mainTurnStale,
81
+ // the watchdog recovers via the idle-gated single-send path.
82
+ mainTurnTimer: ReturnType<typeof setInterval> | null = null;
83
+ mainTurnLastActivity = 0;
84
+ mainTurnInFlight = false;
85
+ mainTurnRecovering = false;
73
86
  // Single consecutive-nudge guard (replaces the old multi-tier throttle). Reset
74
87
  // to 0 on any productive turn; once it reaches the cap the nudges halt with one
75
88
  // user notification.
@@ -77,10 +90,49 @@ export class Orchestrator {
77
90
  nudgeHalted = false;
78
91
  pendingSubagentSpawns = 0;
79
92
  errorRetryCount = 0;
93
+ // Halts the API-error auto-retry once errorRetryCount exceeds its cap, mirroring
94
+ // nudgeHalted. Without this, a benign intervening turn (e.g. the retried turn
95
+ // ends as a text-only "I'll wait") reset errorRetryCount to 0, so the 5-retry
96
+ // cap never accumulated and the "Previous request failed" nudge could fire
97
+ // unbounded (hundreds of times) against transient errors. Cleared only on
98
+ // genuine (non-[PI-PI]) user re-engagement, like nudgeHalted.
99
+ errorNudgeHalted = false;
80
100
  commitReminderSent = false;
81
101
  phaseStartTime = 0;
82
102
  pendingRetryTimer: ReturnType<typeof setTimeout> | null = null;
103
+ // Unsubscribe for the direct ESC interrupt armed while pendingRetryTimer is
104
+ // live. pi-pi's own post-error retry is NOT covered by any SDK/interactive ESC
105
+ // binding (the turn already ended in error, the session is not streaming), so
106
+ // without this ESC would not cancel it.
107
+ pendingRetryEscUnsub: (() => void) | null = null;
83
108
  activeTaskToken = 0;
109
+ // Side-channel for stale-nudge re-validation. A continuation nudge is delivered
110
+ // as a followUp whose prompt STRING carries no phase/task token, and the SDK
111
+ // queue only surfaces the string in before_agent_start. So at nudge-generation
112
+ // time we record {phase, taskToken} keyed by the exact nudge string; at delivery
113
+ // we re-check both against the live phase/token and drop the nudge on mismatch
114
+ // (a nudge generated for an old phase/task must not drive a turn in the new one).
115
+ // Last-wins per distinct string; a stale entry only ever fails-closed to drop.
116
+ pendingNudges = new Map<string, { phase: Phase; taskToken: number }>();
117
+ // Subscription rate-limit fallback (Issue 5). subFallbackActive mirrors the
118
+ // model-registry override flag; subFallbackDialogPending guards against
119
+ // opening more than one switch dialogue at a time (across main + subagents);
120
+ // subFallbackModelId records the sub model that hit the limit (used by the
121
+ // switch-back probe); subSwitchBackTimer is the fixed-interval probe timer.
122
+ subFallbackActive = false;
123
+ subFallbackDialogPending = false;
124
+ // True while a user-facing dialogue (ask_user / the /pp menu / any interactive
125
+ // selectOption) is open. The main-turn watchdog skips while set so a turn
126
+ // legitimately parked on a human is not aborted. Set on dialogue open, cleared
127
+ // in finally on every exit (resolve, ESC/cancel, error).
128
+ interactivePromptOpen = false;
129
+ // Set SYNCHRONOUSLY the moment a sub-429 is detected (before any async dialog),
130
+ // and cleared once the decision resolves. The autonomous planner/reviewer
131
+ // auto-retry consults this to avoid re-spawning a failed variant on the still-
132
+ // sub-routed model while the fallback decision is in flight.
133
+ subFallbackPendingDecision = false;
134
+ subFallbackModelId: string | null = null;
135
+ subSwitchBackTimer: ReturnType<typeof setTimeout> | null = null;
84
136
  userGatePending = false;
85
137
  lastCtx: any = null;
86
138
  failedPlannerVariants: string[] = [];
@@ -100,10 +152,16 @@ export class Orchestrator {
100
152
  checkPlannerCompletion: () => void = () => {};
101
153
  readonly transitionController: TransitionController;
102
154
 
155
+ // The single live instance, so module-level dialogue wrappers (selectOption in
156
+ // event-handlers/pp-menu) can toggle interactivePromptOpen without threading a
157
+ // reference through ~70 call sites.
158
+ static current: Orchestrator | null = null;
159
+
103
160
  constructor(readonly pi: ExtensionAPI) {
104
161
  // The controller calls pi (the main session) directly for sends, and uses the
105
162
  // host only for live-ctx-dependent bits (compact/isIdle/currentStep).
106
163
  this.transitionController = new TransitionController(this.makeTransitionHost(), this.pi);
164
+ Orchestrator.current = this;
107
165
  }
108
166
 
109
167
  // Live-session host the TransitionController uses for compaction/idle/step.
@@ -123,6 +181,87 @@ export class Orchestrator {
123
181
  };
124
182
  }
125
183
 
184
+ // Arm a direct ESC interrupt for the post-error retry window. Idempotent: a
185
+ // single onTerminalInput handler stays registered until the retry is delivered,
186
+ // cancelled, or the task is reset. While pendingRetryTimer is live, ESC cancels
187
+ // the pending retry (no other binding covers this window).
188
+ armRetryEscInterrupt(ctx: any): void {
189
+ if (this.pendingRetryEscUnsub) return;
190
+ const onTerminalInput = ctx?.ui?.onTerminalInput;
191
+ if (typeof onTerminalInput !== "function") return;
192
+ const unsub = onTerminalInput.call(ctx.ui, (data: string) => {
193
+ if (!this.pendingRetryTimer) return undefined;
194
+ // Match a STANDALONE ESC only. Arrow/function/mouse sequences also start
195
+ // with 0x1b (e.g. "\x1b[A"), so `includes` would misfire on navigation
196
+ // keys and swallow them; a bare ESC is exactly the one-byte string.
197
+ if (data === "\x1b") {
198
+ this.cancelPendingRetry();
199
+ ctx?.ui?.notify?.("Retry cancelled.", "info");
200
+ return { consume: true };
201
+ }
202
+ return undefined;
203
+ });
204
+ this.pendingRetryEscUnsub = typeof unsub === "function" ? unsub : null;
205
+ }
206
+
207
+ disarmRetryEscInterrupt(): void {
208
+ if (this.pendingRetryEscUnsub) {
209
+ try {
210
+ this.pendingRetryEscUnsub();
211
+ } catch {
212
+ // ignore unsubscribe failures
213
+ }
214
+ this.pendingRetryEscUnsub = null;
215
+ }
216
+ }
217
+
218
+ // Cancel a pending post-error retry (timer + ESC interrupt) and reset the retry
219
+ // counter. Used by the ESC interrupt handler and by abort paths.
220
+ cancelPendingRetry(): void {
221
+ if (this.pendingRetryTimer) {
222
+ clearTimeout(this.pendingRetryTimer);
223
+ this.pendingRetryTimer = null;
224
+ }
225
+ this.disarmRetryEscInterrupt();
226
+ this.errorRetryCount = 0;
227
+ this.errorNudgeHalted = false;
228
+ }
229
+
230
+ // Deliver a queued message only once the main session is idle. Firing a
231
+ // followUp while the SDK still has an active run triggers an async, runtime-
232
+ // swallowed "Agent is already processing" rejection (surfaces as
233
+ // Extension "<runtime>" error), so we PRE-CHECK idle and DEFER (bounded poll)
234
+ // rather than dropping the nudge. Guarded by activeTaskToken; the poll reuses
235
+ // pendingRetryTimer so ESC/abort cancels it.
236
+ sendUserMessageWhenIdle(text: string, taskToken: number, attempt = 0): void {
237
+ const log = getLogger();
238
+ if (this.activeTaskToken !== taskToken || !this.active) {
239
+ this.disarmRetryEscInterrupt();
240
+ return;
241
+ }
242
+ const idleFn = this.lastCtx?.isIdle;
243
+ const idle = typeof idleFn === "function" ? !!idleFn.call(this.lastCtx) : true;
244
+ if (idle) {
245
+ this.disarmRetryEscInterrupt();
246
+ this.safeSendUserMessage(text);
247
+ return;
248
+ }
249
+ const MAX_ATTEMPTS = 120; // ~2min at 1s poll
250
+ if (attempt >= MAX_ATTEMPTS) {
251
+ log.warn({ s: "orchestrator", attempt }, "sendUserMessageWhenIdle gave up waiting for idle");
252
+ this.disarmRetryEscInterrupt();
253
+ this.lastCtx?.ui?.notify?.(
254
+ "pi-pi stopped waiting for the agent to go idle; auto-continuation was dropped. Send any message to resume.",
255
+ "warning",
256
+ );
257
+ return;
258
+ }
259
+ this.pendingRetryTimer = setTimeout(() => {
260
+ this.pendingRetryTimer = null;
261
+ this.sendUserMessageWhenIdle(text, taskToken, attempt + 1);
262
+ }, 1000);
263
+ }
264
+
126
265
  safeSendUserMessage(text: string): void {
127
266
  const log = getLogger();
128
267
  const attempt = (retries: number) => {
@@ -219,67 +358,17 @@ export class Orchestrator {
219
358
  return true;
220
359
  }
221
360
 
361
+ // The footer's phase/mode display (line 1) reads orchestrator state directly; this only
362
+ // sets a hidden "pp-phase" status whose value changes per transition so the host repaints
363
+ // the footer. Nothing renders this string (footer line 3 was removed), so it stays terse.
222
364
  updateStatus(ctx: ExtensionContext): void {
223
365
  if (!this.active || this.active.state.phase === "done") {
224
366
  ctx.ui.setStatus("pp-phase", undefined);
225
367
  return;
226
368
  }
227
-
228
- const type = this.active.type;
229
- const phase = this.active.state.phase;
230
- const step = this.active.state.step;
231
- const reviewCycle = this.active.state.reviewCycle;
232
- const effectiveMode = this.active.state.effectiveMode ?? this.active.state.mode;
233
- const modeLabel = effectiveMode === "autonomous" ? " [autonomous]" : "";
234
-
235
- if (type === "debug" || type === "brainstorm" || type === "quick") {
236
- const elapsed = this.phaseStartTime > 0 ? this.formatElapsed(this.phaseStartTime) : "";
237
- const suffix = elapsed ? ` (${elapsed})` : "";
238
- ctx.ui.setStatus("pp-phase", `pp: ${type}${modeLabel}${suffix}`);
239
- return;
240
- }
241
-
242
- const pipeline = phasePipeline(type).filter((p) => p !== "done");
243
- const currentIdx = pipeline.indexOf(phase as (typeof pipeline)[number]);
244
-
245
- const parts: string[] = [];
246
- for (let i = 0; i < pipeline.length; i++) {
247
- const p = pipeline[i];
248
- if (i < currentIdx) {
249
- parts.push(`✔ ${p}`);
250
- } else if (p === phase) {
251
- let detail = "";
252
- if (step === "await_planners") detail = "planners";
253
- else if (step === "await_reviewers") detail = "reviewers";
254
- else if (step === "synthesize") detail = "synthesize";
255
- else if (step === "apply_feedback") detail = "feedback";
256
- else if (step === "user_gate") detail = "review";
257
-
258
- if (reviewCycle) {
259
- const kind = reviewCycle.kind === "plannotator" ? "plannotator" : "review";
260
- detail = `${kind} #${reviewCycle.pass}`;
261
- }
262
-
263
- const elapsed = this.phaseStartTime > 0 ? this.formatElapsed(this.phaseStartTime) : "";
264
- const sub = [detail, elapsed].filter(Boolean).join(", ");
265
- parts.push(sub ? `${p} (${sub})` : p);
266
- } else {
267
- parts.push(p);
268
- }
269
- }
270
-
271
- ctx.ui.setStatus("pp-phase", `pp: ${parts.join(" → ")}${modeLabel}`);
272
- }
273
-
274
- private formatElapsed(startTime: number): string {
275
- const sec = Math.floor((Date.now() - startTime) / 1000);
276
- if (sec < 60) return `${sec}s`;
277
- const min = Math.floor(sec / 60);
278
- const remSec = sec % 60;
279
- if (min < 60) return remSec > 0 ? `${min}m ${remSec}s` : `${min}m`;
280
- const hr = Math.floor(min / 60);
281
- const remMin = min % 60;
282
- return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
369
+ const s = this.active.state;
370
+ const cycle = s.reviewCycle ? `:${s.reviewCycle.kind}#${s.reviewCycle.pass}` : "";
371
+ ctx.ui.setStatus("pp-phase", `${this.active.type}:${s.phase}:${s.step}:${getEffectivePhaseMode(s)}${cycle}`);
283
372
  }
284
373
 
285
374
  getPlanStartState(taskDir: string, plannerPresetName?: string): { step: string; shouldSpawnPlanners: boolean } {
@@ -370,7 +459,8 @@ export class Orchestrator {
370
459
  this.lastCtx = ctx;
371
460
  await this.transitionController.requestTransition({
372
461
  kind: "done",
373
- summary: `Starting new ${type} task. Previous conversation discarded.`,
462
+ discard: true,
463
+ summary: `A new, unrelated ${type} task is starting. The previous task is finished — DISCARD its entire conversation. Do NOT carry forward, reference, or act on any prior task's messages, phase, plan, or aborted turns; treat the new task as a clean slate.`,
374
464
  });
375
465
  }
376
466
 
@@ -539,9 +629,11 @@ export class Orchestrator {
539
629
  this.agentLifecycle.clear();
540
630
  this.pendingSubagentSpawns = 0;
541
631
  this.errorRetryCount = 0;
632
+ this.errorNudgeHalted = false;
542
633
  this.commitReminderSent = false;
543
634
  this.consecutiveNudges = 0;
544
635
  this.nudgeHalted = false;
636
+ this.pendingNudges.clear();
545
637
  this.phaseStartTime = 0;
546
638
  this.userGatePending = false;
547
639
  this.failedPlannerVariants = [];
@@ -552,10 +644,34 @@ export class Orchestrator {
552
644
  clearTimeout(this.pendingRetryTimer);
553
645
  this.pendingRetryTimer = null;
554
646
  }
647
+ this.disarmRetryEscInterrupt();
555
648
  if (this.staleAgentTimer) {
556
649
  clearInterval(this.staleAgentTimer);
557
650
  this.staleAgentTimer = null;
558
651
  }
652
+ if (this.mainTurnTimer) {
653
+ clearInterval(this.mainTurnTimer);
654
+ this.mainTurnTimer = null;
655
+ }
656
+ this.mainTurnInFlight = false;
657
+ this.mainTurnRecovering = false;
658
+ this.clearSubscriptionFallback();
659
+ }
660
+
661
+ // Reset the subscription rate-limit fallback: cancel the switch-back probe
662
+ // timer, clear the model-registry override, and reset guards. Called on task
663
+ // reset/cleanup so the sticky override never leaks across tasks.
664
+ clearSubscriptionFallback(): void {
665
+ if (this.subSwitchBackTimer) {
666
+ clearTimeout(this.subSwitchBackTimer);
667
+ this.subSwitchBackTimer = null;
668
+ }
669
+ this.subFallbackActive = false;
670
+ this.subFallbackDialogPending = false;
671
+ this.interactivePromptOpen = false;
672
+ this.subFallbackPendingDecision = false;
673
+ this.subFallbackModelId = null;
674
+ setSubscriptionFallbackActive(false);
559
675
  }
560
676
 
561
677
  async cleanupActive(): Promise<void> {
@@ -578,7 +694,10 @@ export class Orchestrator {
578
694
  const log = getLogger();
579
695
  const explore = createExploreAgent(this.config);
580
696
  const librarian = createLibrarianAgent(this.config);
581
- const taskAgent = createTaskAgent(this.config, "{{subtask}}", { userRequest: "", synthesizedPlan: "" });
697
+ const taskAgent = createTaskAgent(this.config);
698
+ const advisor = createAdvisorAgent(this.config);
699
+ const deepDebugger = createDeepDebuggerAgent(this.config);
700
+ const reviewer = createReviewerAgent(this.config);
582
701
  const phase = this.active?.state.phase;
583
702
  const repos = this.active?.state.repos ?? [];
584
703
  log.debug({ s: "agents", phase, repoCount: repos.length }, "registering agent definitions");
@@ -615,6 +734,24 @@ export class Orchestrator {
615
734
  ...taskAgent,
616
735
  prompt: appendContext("task", taskAgent.prompt, getModelInfo(resolveModel(this.config.agents.subagents.simple.task.model))),
617
736
  },
737
+ {
738
+ type: "advisor",
739
+ variant: null,
740
+ ...advisor,
741
+ prompt: appendContext("advisor", advisor.prompt, getModelInfo(resolveModel(this.config.agents.subagents.simple.advisor.model))),
742
+ },
743
+ {
744
+ type: "deep-debugger",
745
+ variant: null,
746
+ ...deepDebugger,
747
+ prompt: appendContext("deep-debugger", deepDebugger.prompt, getModelInfo(resolveModel(this.config.agents.subagents.simple["deep-debugger"].model))),
748
+ },
749
+ {
750
+ type: "reviewer",
751
+ variant: null,
752
+ ...reviewer,
753
+ prompt: appendContext("reviewer", reviewer.prompt, getModelInfo(resolveModel(this.config.agents.subagents.simple.reviewer.model))),
754
+ },
618
755
  ]);
619
756
  }
620
757