@ilya-lesikov/pi-pi 0.9.0 → 0.10.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 (106) hide show
  1. package/3p/pi-ask-user/index.ts +71 -124
  2. package/3p/pi-ask-user/single-select-layout.ts +3 -14
  3. package/3p/pi-subagents/package.json +13 -7
  4. package/3p/pi-subagents/src/agent-manager.ts +243 -79
  5. package/3p/pi-subagents/src/agent-runner.ts +501 -365
  6. package/3p/pi-subagents/src/agent-types.ts +46 -57
  7. package/3p/pi-subagents/src/cross-extension-rpc.ts +52 -46
  8. package/3p/pi-subagents/src/custom-agents.ts +30 -6
  9. package/3p/pi-subagents/src/default-agents.ts +25 -43
  10. package/3p/pi-subagents/src/enabled-models.ts +180 -0
  11. package/3p/pi-subagents/src/index.ts +599 -839
  12. package/3p/pi-subagents/src/model-resolver.ts +26 -7
  13. package/3p/pi-subagents/src/output-file.ts +21 -2
  14. package/3p/pi-subagents/src/prompts.ts +17 -3
  15. package/3p/pi-subagents/src/schedule-store.ts +153 -0
  16. package/3p/pi-subagents/src/schedule.ts +365 -0
  17. package/3p/pi-subagents/src/settings.ts +261 -0
  18. package/3p/pi-subagents/src/skill-loader.ts +77 -54
  19. package/3p/pi-subagents/src/status-note.ts +25 -0
  20. package/3p/pi-subagents/src/types.ts +97 -6
  21. package/3p/pi-subagents/src/ui/agent-widget.ts +94 -21
  22. package/3p/pi-subagents/src/ui/conversation-viewer.ts +147 -35
  23. package/3p/pi-subagents/src/ui/schedule-menu.ts +104 -0
  24. package/3p/pi-subagents/src/ui/viewer-keys.ts +39 -0
  25. package/3p/pi-subagents/src/usage.ts +60 -0
  26. package/3p/pi-subagents/src/worktree.ts +49 -20
  27. package/extensions/orchestrator/agents/advisor.ts +13 -8
  28. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +8 -3
  29. package/extensions/orchestrator/agents/code-reviewer.ts +8 -3
  30. package/extensions/orchestrator/agents/constraints.test.ts +26 -0
  31. package/extensions/orchestrator/agents/constraints.ts +12 -2
  32. package/extensions/orchestrator/agents/deep-debugger.ts +13 -8
  33. package/extensions/orchestrator/agents/explore.ts +12 -6
  34. package/extensions/orchestrator/agents/librarian.ts +13 -5
  35. package/extensions/orchestrator/agents/plan-reviewer.ts +8 -3
  36. package/extensions/orchestrator/agents/planner.ts +8 -3
  37. package/extensions/orchestrator/agents/pools.test.ts +56 -0
  38. package/extensions/orchestrator/agents/prompts.test.ts +52 -10
  39. package/extensions/orchestrator/agents/registry.test.ts +245 -0
  40. package/extensions/orchestrator/agents/registry.ts +145 -10
  41. package/extensions/orchestrator/agents/reviewer.ts +14 -8
  42. package/extensions/orchestrator/agents/task.ts +12 -6
  43. package/extensions/orchestrator/agents/tool-routing.ts +213 -85
  44. package/extensions/orchestrator/agents/wait-for-completion.test.ts +171 -0
  45. package/extensions/orchestrator/ast-search.test.ts +124 -0
  46. package/extensions/orchestrator/billing-spoof.test.ts +67 -0
  47. package/extensions/orchestrator/billing-spoof.ts +95 -0
  48. package/extensions/orchestrator/cbm.more.test.ts +358 -0
  49. package/extensions/orchestrator/command-handlers.ts +6 -0
  50. package/extensions/orchestrator/commands.test.ts +15 -2
  51. package/extensions/orchestrator/commands.ts +1 -1
  52. package/extensions/orchestrator/config.test.ts +89 -1
  53. package/extensions/orchestrator/config.ts +102 -19
  54. package/extensions/orchestrator/context.test.ts +46 -0
  55. package/extensions/orchestrator/context.ts +18 -5
  56. package/extensions/orchestrator/custom-footer.test.ts +24 -10
  57. package/extensions/orchestrator/custom-footer.ts +4 -2
  58. package/extensions/orchestrator/doctor.more.test.ts +315 -0
  59. package/extensions/orchestrator/doctor.ts +6 -2
  60. package/extensions/orchestrator/event-handlers.more.test.ts +561 -0
  61. package/extensions/orchestrator/event-handlers.test.ts +96 -9
  62. package/extensions/orchestrator/event-handlers.ts +344 -151
  63. package/extensions/orchestrator/exa.more.test.ts +118 -0
  64. package/extensions/orchestrator/flant-infra.more.test.ts +326 -0
  65. package/extensions/orchestrator/flant-infra.test.ts +127 -0
  66. package/extensions/orchestrator/flant-infra.ts +113 -39
  67. package/extensions/orchestrator/index.test.ts +76 -0
  68. package/extensions/orchestrator/index.ts +2 -0
  69. package/extensions/orchestrator/integration.test.ts +183 -65
  70. package/extensions/orchestrator/model-registry.test.ts +2 -1
  71. package/extensions/orchestrator/model-registry.ts +12 -2
  72. package/extensions/orchestrator/orchestrator.test.ts +67 -0
  73. package/extensions/orchestrator/orchestrator.ts +119 -27
  74. package/extensions/orchestrator/phases/brainstorm.test.ts +30 -1
  75. package/extensions/orchestrator/phases/brainstorm.ts +43 -6
  76. package/extensions/orchestrator/phases/implementation.ts +2 -0
  77. package/extensions/orchestrator/phases/machine.test.ts +17 -1
  78. package/extensions/orchestrator/phases/machine.ts +4 -1
  79. package/extensions/orchestrator/phases/planning.test.ts +47 -1
  80. package/extensions/orchestrator/phases/planning.ts +18 -3
  81. package/extensions/orchestrator/phases/review-task.ts +5 -0
  82. package/extensions/orchestrator/phases/review.test.ts +10 -0
  83. package/extensions/orchestrator/phases/review.ts +22 -7
  84. package/extensions/orchestrator/phases/spawn-blocking.test.ts +1 -0
  85. package/extensions/orchestrator/phases/verdict.ts +6 -5
  86. package/extensions/orchestrator/plannotator-open-failure.test.ts +67 -0
  87. package/extensions/orchestrator/plannotator.test.ts +38 -1
  88. package/extensions/orchestrator/plannotator.ts +50 -3
  89. package/extensions/orchestrator/pp-menu.leaves.test.ts +590 -0
  90. package/extensions/orchestrator/pp-menu.more.test.ts +524 -0
  91. package/extensions/orchestrator/pp-menu.test.ts +114 -7
  92. package/extensions/orchestrator/pp-menu.ts +579 -90
  93. package/extensions/orchestrator/pp-state-tools.test.ts +80 -0
  94. package/extensions/orchestrator/pp-state-tools.ts +65 -0
  95. package/extensions/orchestrator/rate-limit-fallback.more.test.ts +241 -0
  96. package/extensions/orchestrator/rate-limit-fallback.test.ts +20 -1
  97. package/extensions/orchestrator/rate-limit-fallback.ts +12 -0
  98. package/extensions/orchestrator/review-files.test.ts +26 -0
  99. package/extensions/orchestrator/review-files.ts +3 -0
  100. package/extensions/orchestrator/state.test.ts +73 -1
  101. package/extensions/orchestrator/state.ts +95 -23
  102. package/extensions/orchestrator/suppress-pierre-theme-spam.test.ts +56 -0
  103. package/extensions/orchestrator/suppress-pierre-theme-spam.ts +45 -0
  104. package/extensions/orchestrator/validate-artifacts.ts +1 -1
  105. package/package.json +6 -2
  106. package/scripts/test-3p.sh +52 -0
@@ -24,8 +24,13 @@ afterEach(() => {
24
24
  interface RegisteredTool {
25
25
  name: string;
26
26
  execute: (id: string, params: any) => Promise<any>;
27
+ renderShell?: string;
28
+ renderCall?: (args: any, theme: any, context: any) => any;
29
+ renderResult?: (result: any, options: any, theme: any, context: any) => any;
27
30
  }
28
31
 
32
+ const theme = { fg: (_c: string, t: string) => t } as any;
33
+
29
34
  function setup() {
30
35
  const cwd = makeTempCwd();
31
36
  const taskDir = join(cwd, ".pp", "state", "brainstorm", "abc_brainstorm");
@@ -190,3 +195,78 @@ describe("pp_edit_state_file", () => {
190
195
  expect(readFileSync(join(taskDir, "RESEARCH.md"), "utf-8")).toContain("## Affected Code");
191
196
  });
192
197
  });
198
+
199
+ describe("state-file tool rendering (hidden on success, visible on failure/hang)", () => {
200
+ for (const name of ["pp_write_state_file", "pp_edit_state_file"]) {
201
+ it(`${name} owns its shell and renders nothing on a settled (non-partial) call`, () => {
202
+ const { tools } = setup();
203
+ const tool = tools.get(name)!;
204
+ expect(tool.renderShell).toBe("self");
205
+ const callComp = tool.renderCall!({}, theme, { isPartial: false });
206
+ expect(callComp.render(80)).toEqual([]);
207
+ });
208
+
209
+ it(`${name} renders a visible line for an in-flight (partial) call — hang visibility`, () => {
210
+ const { tools } = setup();
211
+ const tool = tools.get(name)!;
212
+ const callComp = tool.renderCall!({}, theme, { isPartial: true });
213
+ expect(callComp.render(80).length).toBe(1);
214
+ });
215
+
216
+ it(`${name} renders nothing on a successful result`, () => {
217
+ const { tools } = setup();
218
+ const tool = tools.get(name)!;
219
+ const comp = tool.renderResult!({ content: [{ type: "text", text: "Created X" }] }, { isPartial: false }, theme, { isError: false });
220
+ expect(comp.render(80)).toEqual([]);
221
+ });
222
+
223
+ it(`${name} renders a visible line while partial (hang)`, () => {
224
+ const { tools } = setup();
225
+ const tool = tools.get(name)!;
226
+ const comp = tool.renderResult!({ content: [] }, { isPartial: true }, theme, { isError: false });
227
+ expect(comp.render(80).length).toBe(1);
228
+ });
229
+
230
+ it(`${name} renders a visible line on error`, () => {
231
+ const { tools } = setup();
232
+ const tool = tools.get(name)!;
233
+ const comp = tool.renderResult!({ content: [{ type: "text", text: "boom" }], isError: true }, { isPartial: false }, theme, { isError: true });
234
+ const rows = comp.render(80);
235
+ expect(rows.length).toBe(1);
236
+ expect(rows[0]).toContain("boom");
237
+ });
238
+ }
239
+ });
240
+
241
+ describe("state-file writes invalidate a clean review (review pass 3)", () => {
242
+ function setupWithState() {
243
+ const cwd = makeTempCwd();
244
+ const taskDir = join(cwd, ".pp", "state", "brainstorm", "abc_brainstorm");
245
+ mkdirSync(join(taskDir, "artifacts"), { recursive: true });
246
+ const tools = new Map<string, RegisteredTool>();
247
+ const state: any = { reviewApprovedClean: true };
248
+ const orchestrator: any = {
249
+ cwd,
250
+ active: { dir: taskDir, state },
251
+ pi: { registerTool: (t: RegisteredTool) => tools.set(t.name, t) },
252
+ };
253
+ registerStateFileTools(orchestrator);
254
+ return { taskDir, tools, state };
255
+ }
256
+
257
+ it("pp_write_state_file clears reviewApprovedClean", async () => {
258
+ const { tools, state } = setupWithState();
259
+ const res = await tools.get("pp_write_state_file")!.execute("1", { path: "RESEARCH.md", content: VALID_RESEARCH });
260
+ expect(res.isError).toBeFalsy();
261
+ expect(state.reviewApprovedClean).toBe(false);
262
+ });
263
+
264
+ it("pp_edit_state_file clears reviewApprovedClean", async () => {
265
+ const { taskDir, tools, state } = setupWithState();
266
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
267
+ state.reviewApprovedClean = true;
268
+ const res = await tools.get("pp_edit_state_file")!.execute("1", { path: "RESEARCH.md", oldText: "does a thing", newText: "does another thing" });
269
+ expect(res.isError).toBeFalsy();
270
+ expect(state.reviewApprovedClean).toBe(false);
271
+ });
272
+ });
@@ -1,7 +1,9 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
2
  import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
3
3
  import { Type } from "@sinclair/typebox";
4
+ import { Container, Text, type Component } from "@earendil-works/pi-tui";
4
5
  import type { Orchestrator } from "./orchestrator.js";
6
+ import { saveTask } from "./state.js";
5
7
  import { getLogger } from "./log.js";
6
8
  import {
7
9
  validateArtifact,
@@ -147,12 +149,67 @@ function validationError(label: string, errors: string[], hint: string): ToolRes
147
149
  );
148
150
  }
149
151
 
152
+ // Minimal-on-success rendering for the state-file tools. With renderShell:"self"
153
+ // the tool owns its inner framing: renderCall shows a line only while the call is
154
+ // in flight (isPartial) so a HANG is visible, and renders nothing once settled;
155
+ // renderResult renders nothing on success and a visible line on failure/hang.
156
+ // Net on success: no tool title, no args echo, no diff, no result text.
157
+ // NOTE: the host ToolExecutionComponent still prepends one unconditional
158
+ // Spacer(1) and treats any returned renderer as "content" (so its full
159
+ // hideComponent collapse path is not taken), meaning a successful write leaves at
160
+ // most a single blank line rather than truly zero rows. That is the intended
161
+ // fallback: it eliminates the full-file/diff "state-file spam" while keeping a
162
+ // hang (isPartial) or failure (isError) visible with an explicit line. Fully
163
+ // removing the residual blank line would require an upstream host change (the
164
+ // Spacer is host-owned).
165
+ function emptyComponent(): Component {
166
+ return new Container();
167
+ }
168
+
169
+ function lineComponent(text: string): Component {
170
+ const c = new Container();
171
+ c.addChild(new Text(text, 0, 0));
172
+ return c;
173
+ }
174
+
175
+ function renderStateCall(_args: unknown, theme: any, context: any): Component {
176
+ // The host re-runs BOTH renderCall and renderResult every cycle and stacks
177
+ // their output, so renderCall must stay empty once a result exists — otherwise
178
+ // a "Saving state…" line would persist onto a successful (silent) write. While
179
+ // the call is still in flight (isPartial, no final result yet) we DO show the
180
+ // line, so a genuine HANG before any result is visible. context.isPartial is
181
+ // true during execution and set false when the final result arrives.
182
+ if (context?.isPartial) return lineComponent(theme.fg("warning", "◆ [PI-PI] Saving state…"));
183
+ return emptyComponent();
184
+ }
185
+
186
+ function renderStateResult(result: ToolResult, options: { isPartial?: boolean }, theme: any, context: any): Component {
187
+ if (options?.isPartial) return lineComponent(theme.fg("warning", "◆ [PI-PI] Saving state…"));
188
+ if (result?.isError || context?.isError) {
189
+ const text = result?.content?.[0]?.type === "text" ? result.content[0].text.split("\n")[0] : "State write failed";
190
+ return lineComponent(theme.fg("error", text));
191
+ }
192
+ return emptyComponent();
193
+ }
194
+
150
195
  // Dedicated compact-output tools for editing .pp state files. Unlike the generic
151
196
  // write/edit tools (which render the full file content / a unified diff into the
152
197
  // UI on every update — the "state-file spam"), these return only a one-line
153
198
  // summary and no details.diff, so the TUI shows nothing large. Structure is
154
199
  // validated inline (rejecting bad writes up front) instead of via an appended
155
200
  // <validation-error> round-trip on the generic tools.
201
+ // These tools only ever write the reviewed artifact set (USER_REQUEST.md,
202
+ // RESEARCH.md, artifacts/*.md, plans/*_synthesized.md), so a successful write
203
+ // invalidates any prior clean review for the phase — otherwise "Auto review,
204
+ // then continue" would skip re-review over content changed through the preferred
205
+ // state-file tools (the generic edit/write hook does not see these writes).
206
+ function invalidateCleanReview(orchestrator: Orchestrator): void {
207
+ const active = orchestrator.active;
208
+ if (!active?.state?.reviewApprovedClean) return;
209
+ active.state.reviewApprovedClean = false;
210
+ try { saveTask(active.dir, active.state); } catch { /* best-effort */ }
211
+ }
212
+
156
213
  export function registerStateFileTools(orchestrator: Orchestrator): void {
157
214
  const pi = orchestrator.pi;
158
215
  const log = getLogger();
@@ -160,6 +217,9 @@ export function registerStateFileTools(orchestrator: Orchestrator): void {
160
217
  pi.registerTool({
161
218
  name: "pp_write_state_file",
162
219
  label: "pi-pi",
220
+ renderShell: "self",
221
+ renderCall: renderStateCall,
222
+ renderResult: renderStateResult as any,
163
223
  description:
164
224
  "Create or overwrite a pi-pi state file (USER_REQUEST.md, RESEARCH.md, artifacts/*.md, " +
165
225
  "or plans/*_synthesized.md) under the active task directory. PREFER this over the generic " +
@@ -188,6 +248,7 @@ export function registerStateFileTools(orchestrator: Orchestrator): void {
188
248
  return err(`Failed to write ${resolved.label}: ${e?.message ?? String(e)}`);
189
249
  }
190
250
  const { added, removed } = lineDelta(before, content);
251
+ invalidateCleanReview(orchestrator);
191
252
  log.debug({ s: "tool", tool: "pp_write_state_file", path: resolved.label, added, removed }, "state file written");
192
253
  const verb = existed ? "Updated" : "Created";
193
254
  return ok(`${verb} ${resolved.label} (+${added}/-${removed} lines)`);
@@ -197,6 +258,9 @@ export function registerStateFileTools(orchestrator: Orchestrator): void {
197
258
  pi.registerTool({
198
259
  name: "pp_edit_state_file",
199
260
  label: "pi-pi",
261
+ renderShell: "self",
262
+ renderCall: renderStateCall,
263
+ renderResult: renderStateResult as any,
200
264
  description:
201
265
  "Edit a pi-pi state file in place by replacing an exact text span. PREFER this over the " +
202
266
  "generic edit tool for .pp state files (USER_REQUEST.md, RESEARCH.md, artifacts/*.md, " +
@@ -238,6 +302,7 @@ export function registerStateFileTools(orchestrator: Orchestrator): void {
238
302
  return err(`Failed to write ${resolved.label}: ${e?.message ?? String(e)}`);
239
303
  }
240
304
  const { added, removed } = lineDelta(before, after);
305
+ invalidateCleanReview(orchestrator);
241
306
  log.debug(
242
307
  { s: "tool", tool: "pp_edit_state_file", path: resolved.label, occurrences, replaceAll, added, removed },
243
308
  "state file edited",
@@ -0,0 +1,241 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ isSubscriptionRouted: vi.fn(),
5
+ setSubscriptionFallbackActive: vi.fn(),
6
+ toNonSubSpec: vi.fn((id: string) => `nonsub:${id}`),
7
+ loadFlantSettings: vi.fn(() => ({ switchBackIntervalMinutes: 30 })),
8
+ probeSubscriptionCleared: vi.fn(),
9
+ askUser: vi.fn(),
10
+ isCancel: vi.fn(() => false),
11
+ }));
12
+
13
+ vi.mock("./usage-tracker.js", () => ({
14
+ isSubscriptionRouted: mocks.isSubscriptionRouted,
15
+ }));
16
+ vi.mock("./model-registry.js", () => ({
17
+ setSubscriptionFallbackActive: mocks.setSubscriptionFallbackActive,
18
+ toNonSubSpec: mocks.toNonSubSpec,
19
+ }));
20
+ vi.mock("./flant-infra.js", () => ({
21
+ loadFlantSettings: mocks.loadFlantSettings,
22
+ probeSubscriptionCleared: mocks.probeSubscriptionCleared,
23
+ }));
24
+ vi.mock("../../3p/pi-ask-user/index.js", () => ({
25
+ askUser: mocks.askUser,
26
+ isCancel: mocks.isCancel,
27
+ }));
28
+ vi.mock("./log.js", () => ({
29
+ getLogger: () => ({ debug: vi.fn(), warn: vi.fn(), info: vi.fn() }),
30
+ }));
31
+
32
+ import {
33
+ isRateLimitError,
34
+ isSdkRetryableError,
35
+ handleMainRateLimit,
36
+ handleSubagentRateLimit,
37
+ armSwitchBackProbe,
38
+ } from "./rate-limit-fallback.js";
39
+
40
+ function makeOrchestrator(overrides: Record<string, unknown> = {}): any {
41
+ return {
42
+ subFallbackActive: false,
43
+ subFallbackModelId: null,
44
+ subFallbackDialogPending: false,
45
+ subFallbackPendingDecision: false,
46
+ activeTaskToken: 1,
47
+ active: { state: { phase: "implement" } },
48
+ subSwitchBackTimer: null,
49
+ lastCtx: null,
50
+ config: { agents: { orchestrators: { implement: { thinking: "high" } } } },
51
+ cancelPendingRetry: vi.fn(),
52
+ switchModel: vi.fn(async () => true),
53
+ sendUserMessageWhenIdle: vi.fn(),
54
+ ...overrides,
55
+ };
56
+ }
57
+
58
+ function makeCtx(overrides: Record<string, unknown> = {}): any {
59
+ return {
60
+ hasUI: true,
61
+ abort: vi.fn(),
62
+ ui: { notify: vi.fn() },
63
+ ...overrides,
64
+ };
65
+ }
66
+
67
+ beforeEach(() => {
68
+ mocks.isSubscriptionRouted.mockReturnValue(true);
69
+ mocks.isCancel.mockReturnValue(false);
70
+ mocks.loadFlantSettings.mockReturnValue({ switchBackIntervalMinutes: 30 } as any);
71
+ });
72
+
73
+ afterEach(() => {
74
+ vi.clearAllMocks();
75
+ vi.useRealTimers();
76
+ });
77
+
78
+ describe("isRateLimitError extra shapes", () => {
79
+ it("matches dotted/spaced rate-limit variants", () => {
80
+ expect(isRateLimitError("rate-limit hit")).toBe(true);
81
+ expect(isRateLimitError("RATELIMIT")).toBe(true);
82
+ expect(isRateLimitError("HTTP 429 returned")).toBe(true);
83
+ });
84
+ it("does not match numbers that merely contain 429", () => {
85
+ expect(isRateLimitError("error code 4290")).toBe(false);
86
+ expect(isRateLimitError("connection reset")).toBe(false);
87
+ expect(isRateLimitError(42 as any)).toBe(false);
88
+ });
89
+ });
90
+
91
+ describe("isSdkRetryableError extra shapes", () => {
92
+ it("matches a broad set of transient network/server errors", () => {
93
+ for (const m of [
94
+ "provider returned error",
95
+ "502 bad gateway",
96
+ "504",
97
+ "service unavailable",
98
+ "internal error",
99
+ "connection refused",
100
+ "websocket closed",
101
+ "other side closed",
102
+ "upstream connect error",
103
+ "reset before headers",
104
+ "socket hang up",
105
+ "http2 request did not get a response",
106
+ "terminated",
107
+ "retry delay exceeded",
108
+ ]) {
109
+ expect(isSdkRetryableError(m)).toBe(true);
110
+ }
111
+ });
112
+ it("rejects plain application errors and non-strings", () => {
113
+ expect(isSdkRetryableError("permission denied")).toBe(false);
114
+ expect(isSdkRetryableError(null as any)).toBe(false);
115
+ expect(isSdkRetryableError(123 as any)).toBe(false);
116
+ });
117
+ });
118
+
119
+ describe("handleMainRateLimit", () => {
120
+ it("returns early without aborting when not subscription-routed", async () => {
121
+ mocks.isSubscriptionRouted.mockReturnValue(false);
122
+ const orch = makeOrchestrator();
123
+ const ctx = makeCtx();
124
+ await handleMainRateLimit(orch, ctx, "claude", "provider");
125
+ expect(ctx.abort).not.toHaveBeenCalled();
126
+ expect(orch.cancelPendingRetry).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it("aborts and cancels retries, then stops when already on non-sub", async () => {
130
+ const orch = makeOrchestrator({ subFallbackActive: true });
131
+ const ctx = makeCtx();
132
+ await handleMainRateLimit(orch, ctx, "sub/claude", "sub");
133
+ expect(ctx.abort).toHaveBeenCalled();
134
+ expect(orch.cancelPendingRetry).toHaveBeenCalled();
135
+ expect(mocks.askUser).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it("skips the dialog when no UI is available", async () => {
139
+ const orch = makeOrchestrator();
140
+ const ctx = makeCtx({ hasUI: false });
141
+ await handleMainRateLimit(orch, ctx, "sub/claude", "sub");
142
+ expect(mocks.askUser).not.toHaveBeenCalled();
143
+ expect(orch.switchModel).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it("activates fallback and switches the main model on confirm", async () => {
147
+ mocks.askUser.mockResolvedValue({ kind: "selection", selections: ["Switch to non-sub Claude"] });
148
+ vi.useFakeTimers();
149
+ const orch = makeOrchestrator();
150
+ const ctx = makeCtx();
151
+ await handleMainRateLimit(orch, ctx, "sub/claude-opus", "sub");
152
+ expect(orch.subFallbackActive).toBe(true);
153
+ expect(mocks.setSubscriptionFallbackActive).toHaveBeenCalledWith(true);
154
+ expect(mocks.toNonSubSpec).toHaveBeenCalledWith("sub/claude-opus");
155
+ expect(orch.switchModel).toHaveBeenCalled();
156
+ expect(orch.sendUserMessageWhenIdle).toHaveBeenCalled();
157
+ expect(orch.subSwitchBackTimer).not.toBeNull();
158
+ });
159
+
160
+ it("stays on subscription and notifies when the user declines", async () => {
161
+ mocks.askUser.mockResolvedValue({ kind: "selection", selections: ["Stay on subscription"] });
162
+ const orch = makeOrchestrator();
163
+ const ctx = makeCtx();
164
+ await handleMainRateLimit(orch, ctx, "sub/claude", "sub");
165
+ expect(orch.subFallbackActive).toBe(false);
166
+ expect(ctx.ui.notify).toHaveBeenCalled();
167
+ expect(orch.switchModel).not.toHaveBeenCalled();
168
+ });
169
+
170
+ it("does not open a second dialog while one is pending", async () => {
171
+ const orch = makeOrchestrator({ subFallbackDialogPending: true });
172
+ const ctx = makeCtx();
173
+ await handleMainRateLimit(orch, ctx, "sub/claude", "sub");
174
+ expect(mocks.askUser).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it("aborts activation when the task token changes mid-dialog", async () => {
178
+ const orch = makeOrchestrator();
179
+ mocks.askUser.mockImplementation(async () => {
180
+ orch.activeTaskToken = 999;
181
+ return { kind: "selection", selections: ["Switch to non-sub Claude"] };
182
+ });
183
+ const ctx = makeCtx();
184
+ await handleMainRateLimit(orch, ctx, "sub/claude", "sub");
185
+ expect(orch.subFallbackActive).toBe(false);
186
+ expect(orch.switchModel).not.toHaveBeenCalled();
187
+ });
188
+ });
189
+
190
+ describe("handleSubagentRateLimit", () => {
191
+ it("returns early when not subscription-routed", async () => {
192
+ mocks.isSubscriptionRouted.mockReturnValue(false);
193
+ const orch = makeOrchestrator();
194
+ await handleSubagentRateLimit(orch, makeCtx(), "claude");
195
+ expect(mocks.askUser).not.toHaveBeenCalled();
196
+ });
197
+
198
+ it("returns early when already on non-sub", async () => {
199
+ const orch = makeOrchestrator({ subFallbackActive: true });
200
+ await handleSubagentRateLimit(orch, makeCtx(), "sub/claude");
201
+ expect(mocks.askUser).not.toHaveBeenCalled();
202
+ });
203
+
204
+ it("activates fallback WITHOUT switching the main model for a subagent 429", async () => {
205
+ mocks.askUser.mockResolvedValue({ kind: "selection", selections: ["Switch to non-sub Claude"] });
206
+ vi.useFakeTimers();
207
+ const orch = makeOrchestrator();
208
+ await handleSubagentRateLimit(orch, makeCtx(), "sub/claude-haiku");
209
+ expect(mocks.setSubscriptionFallbackActive).toHaveBeenCalledWith(true);
210
+ expect(orch.switchModel).not.toHaveBeenCalled();
211
+ expect(orch.subFallbackActive).toBe(true);
212
+ });
213
+ });
214
+
215
+ describe("armSwitchBackProbe", () => {
216
+ it("schedules a timer using the configured interval and clears any prior timer", () => {
217
+ vi.useFakeTimers();
218
+ const prior = setTimeout(() => {}, 100000) as any;
219
+ const orch = makeOrchestrator({ subSwitchBackTimer: prior });
220
+ armSwitchBackProbe(orch);
221
+ expect(orch.subSwitchBackTimer).not.toBeNull();
222
+ expect(orch.subSwitchBackTimer).not.toBe(prior);
223
+ });
224
+
225
+ it("floors the interval to at least one minute", () => {
226
+ vi.useFakeTimers();
227
+ mocks.loadFlantSettings.mockReturnValue({ switchBackIntervalMinutes: 0 } as any);
228
+ const orch = makeOrchestrator();
229
+ armSwitchBackProbe(orch);
230
+ expect(orch.subSwitchBackTimer).not.toBeNull();
231
+ });
232
+
233
+ it("runs the probe when the timer fires and stays on non-sub while still limited", async () => {
234
+ vi.useFakeTimers();
235
+ mocks.probeSubscriptionCleared.mockResolvedValue("rate_limited");
236
+ const orch = makeOrchestrator({ subFallbackActive: true, subFallbackModelId: "sub/claude" });
237
+ armSwitchBackProbe(orch);
238
+ await vi.runOnlyPendingTimersAsync();
239
+ expect(mocks.probeSubscriptionCleared).toHaveBeenCalledWith("sub/claude");
240
+ });
241
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { isRateLimitError, isSdkRetryableError } from "./rate-limit-fallback.js";
2
+ import { isRateLimitError, isExtraUsageError, isSdkRetryableError } from "./rate-limit-fallback.js";
3
3
  import { isSubscriptionRouted } from "./usage-tracker.js";
4
4
  import { SUB_MODEL_PREFIX, SUB_PROVIDER, subProbeModelId } from "./flant-infra.js";
5
5
 
@@ -18,6 +18,25 @@ describe("isRateLimitError", () => {
18
18
  expect(isRateLimitError("")).toBe(false);
19
19
  expect(isRateLimitError(undefined)).toBe(false);
20
20
  });
21
+
22
+ it("does not match the 400 extra-usage message (kept separate from 429)", () => {
23
+ expect(isRateLimitError("Third-party apps now draw from extra usage, not plan limits")).toBe(false);
24
+ });
25
+ });
26
+
27
+ describe("isExtraUsageError", () => {
28
+ it("matches the subscription 400 extra-usage phrasing", () => {
29
+ expect(isExtraUsageError("400 Third-party apps now draw from extra usage, not plan limits")).toBe(true);
30
+ expect(isExtraUsageError("you are out of extra usage")).toBe(true);
31
+ expect(isExtraUsageError("requests now draw from your extra usage balance")).toBe(true);
32
+ });
33
+
34
+ it("does not match 429 or unrelated errors (no over-match)", () => {
35
+ expect(isExtraUsageError("Error 429: too many requests")).toBe(false);
36
+ expect(isExtraUsageError("500 internal server error")).toBe(false);
37
+ expect(isExtraUsageError("")).toBe(false);
38
+ expect(isExtraUsageError(undefined)).toBe(false);
39
+ });
21
40
  });
22
41
 
23
42
  describe("isSdkRetryableError", () => {
@@ -11,6 +11,18 @@ export function isRateLimitError(message?: string): boolean {
11
11
  return /\b429\b|rate.?limit|too many requests|exceed your account/i.test(message);
12
12
  }
13
13
 
14
+ // Recognise the subscription-routing-specific 400 "extra usage" error, e.g.
15
+ // "Third-party apps now draw from extra usage, not plan limits". This is a
16
+ // DISTINCT failure class from 429 (not retryable, not a plan rate-limit) but is
17
+ // funneled into the SAME sub→non-sub fallback. Kept separate from
18
+ // isRateLimitError so the 429 regex stays clean. Anchored on the specific
19
+ // phrasing to avoid over-matching other 400s; the CALLER additionally requires
20
+ // isSubscriptionRouted.
21
+ export function isExtraUsageError(message?: string): boolean {
22
+ if (typeof message !== "string" || !message) return false;
23
+ return /extra usage|draw from[\s\S]{0,40}plan limits/i.test(message);
24
+ }
25
+
14
26
  // Mirror of the SDK's AgentSession._isRetryableError classifier
15
27
  // (agent-session.js: overloaded/rate-limit/5xx/network/stream-ended/timeout/...).
16
28
  // When true, the SDK auto-retries the SAME turn itself (abortable backoff bound
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isReviewFileForRound } from "./review-files.js";
3
+
4
+ describe("isReviewFileForRound", () => {
5
+ it("matches the exact round suffix", () => {
6
+ expect(isReviewFileForRound("001_alpha_round-1.md", 1)).toBe(true);
7
+ expect(isReviewFileForRound("42_gemini_round-3.md", 3)).toBe(true);
8
+ });
9
+
10
+ it("does not let round-1 match round-10 through round-19", () => {
11
+ expect(isReviewFileForRound("001_alpha_round-10.md", 1)).toBe(false);
12
+ expect(isReviewFileForRound("001_alpha_round-11.md", 1)).toBe(false);
13
+ expect(isReviewFileForRound("001_alpha_round-19.md", 1)).toBe(false);
14
+ expect(isReviewFileForRound("001_alpha_round-10.md", 10)).toBe(true);
15
+ });
16
+
17
+ it("excludes synthesized final-pass files", () => {
18
+ expect(isReviewFileForRound("001_final_pass-1.md", 1)).toBe(false);
19
+ expect(isReviewFileForRound("001_final_pass-10.md", 1)).toBe(false);
20
+ });
21
+
22
+ it("requires the .md extension", () => {
23
+ expect(isReviewFileForRound("001_alpha_round-1.txt", 1)).toBe(false);
24
+ expect(isReviewFileForRound("001_alpha_round-1", 1)).toBe(false);
25
+ });
26
+ });
@@ -0,0 +1,3 @@
1
+ export function isReviewFileForRound(filename: string, pass: number): boolean {
2
+ return new RegExp(`_round-${pass}\\.md$`).test(filename);
3
+ }
@@ -1,12 +1,13 @@
1
1
  import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
2
2
  import { tmpdir } from "os";
3
- import { join, resolve } from "path";
3
+ import { join, resolve, relative } from "path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import lockfile from "proper-lockfile";
6
6
  import {
7
7
  createTask,
8
8
  formatModeIndicator,
9
9
  getActiveTask,
10
+ getActiveTaskStatus,
10
11
  getEffectiveMode,
11
12
  getFirstPhase,
12
13
  listTasks,
@@ -152,6 +153,23 @@ describe("listTasks", () => {
152
153
  expect(brainstormOnly[0].type).toBe("brainstorm");
153
154
  });
154
155
 
156
+ it("includes done tasks when includeDone is set", () => {
157
+ const cwd = makeCwd();
158
+ const active = createTask(cwd, "implement", "Active feature");
159
+ const doneTask = createTask(cwd, "implement", "Finished feature");
160
+ const doneState = loadTask(doneTask);
161
+ doneState.phase = "done";
162
+ saveTask(doneTask, doneState);
163
+
164
+ const activeOnly = listTasks(cwd, { type: "implement" }).map((t) => t.dir);
165
+ expect(activeOnly).toContain(active);
166
+ expect(activeOnly).not.toContain(doneTask);
167
+
168
+ const withDone = listTasks(cwd, { type: "implement", includeDone: true }).map((t) => t.dir);
169
+ expect(withDone).toContain(active);
170
+ expect(withDone).toContain(doneTask);
171
+ });
172
+
155
173
  it("skips corrupt task entries", () => {
156
174
  const cwd = makeCwd();
157
175
  createTask(cwd, "implement", "Healthy task");
@@ -219,6 +237,17 @@ describe("validateFromPath", () => {
219
237
  reason: "No state.json found at . — not a valid task directory",
220
238
  });
221
239
  });
240
+
241
+ it("accepts the stateDir-relative form the From fork derives from an absolute dir", () => {
242
+ // startTask receives an absolute fromTaskDir and feeds validateFromPath the
243
+ // stateDir-relative form (the same shape stored in state.from). This mirrors
244
+ // that conversion and confirms the guard accepts it and returns the absolute dir.
245
+ const cwd = makeCwd();
246
+ const absoluteTaskDir = createTask(cwd, "brainstorm", "Source");
247
+ const rel = relative(join(cwd, ".pp", "state"), absoluteTaskDir);
248
+
249
+ expect(validateFromPath(cwd, rel)).toEqual({ ok: true, dir: absoluteTaskDir });
250
+ });
222
251
  });
223
252
 
224
253
  describe("taskName", () => {
@@ -235,6 +264,21 @@ describe("taskName", () => {
235
264
 
236
265
  expect(taskName(taskDir)).toBe("my fallback name");
237
266
  });
267
+
268
+ it("falls back to RESEARCH.md when description is generic and no USER_REQUEST.md", () => {
269
+ const cwd = makeCwd();
270
+ const taskDir = createTask(cwd, "review", "review");
271
+ writeFileSync(join(taskDir, "RESEARCH.md"), "## Affected Code\nReview the payment refactor\n", "utf-8");
272
+ expect(taskName(taskDir)).toBe("Review the payment refactor");
273
+ });
274
+
275
+ it("prefers USER_REQUEST.md over RESEARCH.md for a generic description", () => {
276
+ const cwd = makeCwd();
277
+ const taskDir = createTask(cwd, "brainstorm", "brainstorm");
278
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), "# User Request\nAdd dark mode\n", "utf-8");
279
+ writeFileSync(join(taskDir, "RESEARCH.md"), "## Affected Code\nsomething else\n", "utf-8");
280
+ expect(taskName(taskDir)).toBe("Add dark mode");
281
+ });
238
282
  });
239
283
 
240
284
  describe("taskAge", () => {
@@ -371,6 +415,34 @@ describe("getActiveTask", () => {
371
415
  });
372
416
  });
373
417
 
418
+ describe("getActiveTaskStatus", () => {
419
+ it("reports none when no tasks exist", () => {
420
+ const cwd = makeCwd();
421
+ expect(getActiveTaskStatus(cwd).kind).toBe("none");
422
+ });
423
+
424
+ it("reports single for exactly one unlocked task", () => {
425
+ const cwd = makeCwd();
426
+ const taskDir = createTask(cwd, "implement", "Only one");
427
+ vi.spyOn(lockfile, "checkSync").mockReturnValue(false);
428
+
429
+ const status = getActiveTaskStatus(cwd);
430
+ expect(status.kind).toBe("single");
431
+ if (status.kind === "single") expect(status.task.dir).toBe(taskDir);
432
+ });
433
+
434
+ it("reports ambiguous with all tasks when multiple are unlocked", () => {
435
+ const cwd = makeCwd();
436
+ createTask(cwd, "implement", "First");
437
+ createTask(cwd, "debug", "Second");
438
+ vi.spyOn(lockfile, "checkSync").mockReturnValue(false);
439
+
440
+ const status = getActiveTaskStatus(cwd);
441
+ expect(status.kind).toBe("ambiguous");
442
+ if (status.kind === "ambiguous") expect(status.tasks).toHaveLength(2);
443
+ });
444
+ });
445
+
374
446
  describe("getFirstPhase", () => {
375
447
  it("returns expected first phase for each task type", () => {
376
448
  expect(getFirstPhase("implement")).toBe("brainstorm");