@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
@@ -40,13 +40,18 @@ vi.mock("./cbm.js", () => ({ registerCbmTools: vi.fn() }));
40
40
  vi.mock("./exa.js", () => ({ registerExaTools: vi.fn() }));
41
41
  vi.mock("./ast-search.js", () => ({ registerAstSearchTool: vi.fn() }));
42
42
  vi.mock("./doctor.js", () => ({ runDoctor: vi.fn(async () => undefined) }));
43
- vi.mock("./agents/registry.js", () => ({
44
- registerAgentDefinitions: vi.fn(),
45
- unregisterAgentDefinitions: vi.fn(),
46
- setExtensionOnlyMode: vi.fn(),
47
- spawnViaRpc: vi.fn(async (_pi: any, _type: string) => ({ id: `mock-${Math.random().toString(36).slice(2)}` })),
48
- waitForCompletion: vi.fn(async () => undefined),
49
- }));
43
+ vi.mock("./agents/registry.js", async (importOriginal) => {
44
+ const actual = await importOriginal<typeof import("./agents/registry.js")>();
45
+ return {
46
+ ...actual,
47
+ registerAgentDefinitions: vi.fn(),
48
+ unregisterAgentDefinitions: vi.fn(),
49
+ setExtensionOnlyMode: vi.fn(),
50
+ getAgentConfigSnapshot: vi.fn(() => undefined),
51
+ spawnViaRpc: vi.fn(async (_pi: any, _type: string) => ({ id: `mock-${Math.random().toString(36).slice(2)}` })),
52
+ waitForCompletion: vi.fn(async () => undefined),
53
+ };
54
+ });
50
55
 
51
56
  vi.mock("./config.js", async (importOriginal) => {
52
57
  const original = await importOriginal<typeof import("./config.js")>();
@@ -58,6 +63,7 @@ vi.mock("./config.js", async (importOriginal) => {
58
63
  logLevel: "info",
59
64
  },
60
65
  agents: {
66
+ maxConcurrentSubagents: 7,
61
67
  orchestrators: {
62
68
  implement: { model: "test/model", thinking: "high" },
63
69
  plan: { model: "test/model", thinking: "high" },
@@ -71,9 +77,14 @@ vi.mock("./config.js", async (importOriginal) => {
71
77
  explore: { model: "test/explore", thinking: "low" },
72
78
  librarian: { model: "test/librarian", thinking: "medium" },
73
79
  task: { model: "test/task", thinking: "medium" },
74
- advisor: { model: "test/advisor", thinking: "high" },
75
- "deep-debugger": { model: "test/deep-debugger", thinking: "high" },
76
- reviewer: { model: "test/reviewer", thinking: "high" },
80
+ },
81
+ pools: {
82
+ advisors: [
83
+ { enabled: true, model: "test/advisor-fable", thinking: "high" },
84
+ { enabled: true, model: "test/advisor-gpt", thinking: "high" },
85
+ ],
86
+ reviewers: [{ enabled: true, model: "test/reviewer", thinking: "high" }],
87
+ deepDebuggers: [{ enabled: true, model: "test/deep-debugger", thinking: "high" }],
77
88
  },
78
89
  presetGroups: {
79
90
  planners: {
@@ -215,6 +226,7 @@ function makeConfig() {
215
226
  logLevel: "info",
216
227
  },
217
228
  agents: {
229
+ maxConcurrentSubagents: 7,
218
230
  orchestrators: {
219
231
  implement: { model: "test/model", thinking: "high" },
220
232
  plan: { model: "test/model", thinking: "high" },
@@ -228,9 +240,14 @@ function makeConfig() {
228
240
  explore: { model: "test/explore", thinking: "low" },
229
241
  librarian: { model: "test/librarian", thinking: "medium" },
230
242
  task: { model: "test/task", thinking: "medium" },
231
- advisor: { model: "test/advisor", thinking: "high" },
232
- "deep-debugger": { model: "test/deep-debugger", thinking: "high" },
233
- reviewer: { model: "test/reviewer", thinking: "high" },
243
+ },
244
+ pools: {
245
+ advisors: [
246
+ { enabled: true, model: "test/advisor-fable", thinking: "high" },
247
+ { enabled: true, model: "test/advisor-gpt", thinking: "high" },
248
+ ],
249
+ reviewers: [{ enabled: true, model: "test/reviewer", thinking: "high" }],
250
+ deepDebuggers: [{ enabled: true, model: "test/deep-debugger", thinking: "high" }],
234
251
  },
235
252
  presetGroups: {
236
253
  planners: {
@@ -1317,6 +1334,49 @@ describe("edge cases and regressions", () => {
1317
1334
  expect(finalState.reviewCycle).toBeNull();
1318
1335
  });
1319
1336
 
1337
+ it("manual auto-review (item 5) loops in a guided phase then stops in-phase on clean approve", async () => {
1338
+ const cwd = makeTempDir();
1339
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1340
+ const ctx = makeCtx();
1341
+
1342
+ // Guided implement task, parked in the implement phase.
1343
+ await orchestrator.startTask(ctx as any, "implement", "Manual auto-review test", undefined, undefined, "guided");
1344
+ const taskDir = orchestrator.active!.dir;
1345
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1346
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1347
+ const plansDir = join(taskDir, "plans");
1348
+ mkdirSync(plansDir, { recursive: true });
1349
+ writeFileSync(
1350
+ join(plansDir, `${Math.floor(Date.now() / 1000)}_synthesized.md`),
1351
+ makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
1352
+ "utf-8",
1353
+ );
1354
+ orchestrator.active!.state.phase = "implement";
1355
+ orchestrator.active!.state.step = "llm_work";
1356
+ // Arm the item-5 flag (advanceOnComplete=false): stop in-phase on approve.
1357
+ orchestrator.active!.state.manualAutoReview = { phase: "implement", preset: "regular", maxPasses: 3, advanceOnComplete: false };
1358
+ saveTask(taskDir, orchestrator.active!.state);
1359
+
1360
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1361
+ // First call: enters the review cycle (spawns reviewers).
1362
+ const first = await ppPhaseComplete.execute("m-1", { summary: "implemented" }, undefined, undefined, ctx);
1363
+ expect(first.content[0].text).toContain("pass 1/3");
1364
+ expect(orchestrator.active!.state.reviewCycle).not.toBeNull();
1365
+
1366
+ // Reviewer approves.
1367
+ const reviewsDir = join(taskDir, "code-reviews");
1368
+ mkdirSync(reviewsDir, { recursive: true });
1369
+ writeFileSync(join(reviewsDir, `${Math.floor(Date.now() / 1000)}_test_round-1.md`), "VERDICT: APPROVE", "utf-8");
1370
+ emitSubagentCreated(pi, "reviewer-1", "Code reviewer (test)");
1371
+ emitSubagentCompleted(pi, "reviewer-1", "Code reviewer (test)");
1372
+
1373
+ // Second call: finalizes the clean pass; item 5 stops in-phase and clears the flag.
1374
+ const second = await ppPhaseComplete.execute("m-2", { summary: "fixes applied" }, undefined, undefined, ctx);
1375
+ expect(second.content[0].text).toContain("Staying in the implement phase");
1376
+ expect(orchestrator.active!.state.phase).toBe("implement");
1377
+ expect(orchestrator.active!.state.manualAutoReview).toBeUndefined();
1378
+ });
1379
+
1320
1380
  it("continue brainstorming sets step back to llm_work", async () => {
1321
1381
  const cwd = makeTempDir();
1322
1382
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -1678,7 +1738,7 @@ describe("task modes and quick task", () => {
1678
1738
  const ctx = makeCtx();
1679
1739
 
1680
1740
  await orchestrator.startTask(ctx as any, "quick", "quick");
1681
- menu.expect({ question: m.taskMenu("quick", "quick"), options: { include: ["Complete", "Pause"], exclude: ["Next", "Review"] }, choose: "Back" });
1741
+ menu.expect({ question: m.taskMenu("quick", "quick"), options: { include: ["Complete", "Pause"], exclude: ["Next", "Review"] }, choose: "Back to prompt" });
1682
1742
  const pp = getCommand(pi, "pp");
1683
1743
  await pp(undefined, ctx);
1684
1744
  });
@@ -1796,7 +1856,7 @@ describe("task modes and quick task", () => {
1796
1856
  expect(orchestrator.transitionController.isRunning()).toBe(true);
1797
1857
 
1798
1858
  // Explicit "Back" navigation (not an ESC): the reminder must be preserved.
1799
- menu.expect({ question: m.anyTaskMenu, options: { include: ["Back"] }, choose: "Back" });
1859
+ menu.expect({ question: m.anyTaskMenu, options: { include: ["Back to prompt"] }, choose: "Back to prompt" });
1800
1860
 
1801
1861
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1802
1862
  const result = await ppPhaseComplete.execute("back-1", { summary: "done" }, undefined, undefined, ctx);
@@ -2130,7 +2190,7 @@ describe("task modes and quick task", () => {
2130
2190
  .expect({ question: "Implement", options: { include: ["Resume", "Back"] }, choose: "Resume" })
2131
2191
  .expect({ question: "Implement", options: { include: ["Back"] }, choose: "Back" })
2132
2192
  .expect({ question: "Task", options: { include: ["Back"] }, choose: "Back" })
2133
- .expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
2193
+ .expect({ question: "/pp", options: { include: ["Back to prompt"] }, choose: "Back to prompt" });
2134
2194
  const pp = getCommand(pi, "pp");
2135
2195
  await pp(undefined, ctx);
2136
2196
 
@@ -2194,7 +2254,8 @@ describe("task modes and quick task", () => {
2194
2254
  const beforeStart = pi._handlers.get("before_agent_start")!;
2195
2255
  const result = await beforeStart({ systemPrompt: "HARNESS_BASE_PROMPT" }, ctx);
2196
2256
  const prompt = result?.systemPrompt ?? "";
2197
- expect(prompt.startsWith("<constraints>")).toBe(true);
2257
+ expect(prompt.startsWith("<identity>")).toBe(true);
2258
+ expect(prompt).toContain("<constraints>");
2198
2259
  expect(prompt).not.toContain("HARNESS_BASE_PROMPT");
2199
2260
  expect(prompt).toContain("The moment its work is complete, call pp_phase_complete");
2200
2261
  // No interactive '/pp menu' advance guidance in autonomous mode.
@@ -2213,7 +2274,8 @@ describe("task modes and quick task", () => {
2213
2274
 
2214
2275
  const beforeStart = pi._handlers.get("before_agent_start")!;
2215
2276
  const prompt = (await beforeStart({ systemPrompt: "HARNESS_BASE_PROMPT" }, ctx))?.systemPrompt ?? "";
2216
- expect(prompt.startsWith("<constraints>")).toBe(true);
2277
+ expect(prompt.startsWith("<identity>")).toBe(true);
2278
+ expect(prompt).toContain("<constraints>");
2217
2279
  expect(prompt).toContain("ACTIVE PHASE: plan (READ-ONLY)");
2218
2280
  expect(prompt).toContain("<principles>");
2219
2281
  expect(prompt).toContain("<tools>");
@@ -2351,6 +2413,52 @@ describe("task modes and quick task", () => {
2351
2413
  expect(state.reviewerFailureAutoRetried).toBe(false);
2352
2414
  });
2353
2415
 
2416
+ it("captures the initiating prompt into a generic-description task name (#7)", async () => {
2417
+ const cwd = makeTempDir();
2418
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2419
+ const ctx = makeCtx({ cwd });
2420
+
2421
+ await orchestrator.startTask({ ...ctx, cwd } as any, "review", "review", undefined, undefined, "guided");
2422
+ const beforeStart = pi._handlers.get("before_agent_start")!;
2423
+ await beforeStart({ systemPrompt: "base", prompt: " Review the payment refactor PR " }, ctx);
2424
+ expect(orchestrator.active!.state.description).toBe("Review the payment refactor PR");
2425
+ expect(orchestrator.active!.description).toBe("Review the payment refactor PR");
2426
+ });
2427
+
2428
+ it("does not overwrite a non-generic description or capture a [PI-PI] injection", async () => {
2429
+ const cwd = makeTempDir();
2430
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2431
+ const ctx = makeCtx({ cwd });
2432
+
2433
+ await orchestrator.startTask({ ...ctx, cwd } as any, "review", "review", undefined, undefined, "guided");
2434
+ const beforeStart = pi._handlers.get("before_agent_start")!;
2435
+ await beforeStart({ systemPrompt: "base", prompt: "[PI-PI] Continue working on the current phase" }, ctx);
2436
+ expect(orchestrator.active!.state.description).toBe("review");
2437
+ });
2438
+
2439
+ it("collapses a multi-line initiating prompt into one line (#8), bounded by the 700-char cap", async () => {
2440
+ const cwd = makeTempDir();
2441
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2442
+ const ctx = makeCtx({ cwd });
2443
+
2444
+ await orchestrator.startTask({ ...ctx, cwd } as any, "review", "review", undefined, undefined, "guided");
2445
+ const beforeStart = pi._handlers.get("before_agent_start")!;
2446
+ await beforeStart({ systemPrompt: "base", prompt: "Review the auth refactor\nand the token store\nfor race conditions" }, ctx);
2447
+ expect(orchestrator.active!.state.description).toBe("Review the auth refactor and the token store for race conditions");
2448
+ });
2449
+
2450
+ it("caps the collapsed multi-line description at 700 chars (#8)", async () => {
2451
+ const cwd = makeTempDir();
2452
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2453
+ const ctx = makeCtx({ cwd });
2454
+
2455
+ await orchestrator.startTask({ ...ctx, cwd } as any, "review", "review", undefined, undefined, "guided");
2456
+ const beforeStart = pi._handlers.get("before_agent_start")!;
2457
+ const longPrompt = "line one\n" + "x".repeat(2000);
2458
+ await beforeStart({ systemPrompt: "base", prompt: longPrompt }, ctx);
2459
+ expect(orchestrator.active!.state.description.length).toBe(700);
2460
+ });
2461
+
2354
2462
  it("blocks ask_user in autonomous mode after first phase", async () => {
2355
2463
  const cwd = makeTempDir();
2356
2464
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -3462,10 +3570,10 @@ describe("tool blocking", () => {
3462
3570
  });
3463
3571
 
3464
3572
  describe("error retry", () => {
3465
- // Non-SDK-retryable error messages exercise pi-pi's OWN retry path. SDK-
3466
- // retryable errors (rate limit, overloaded, 5xx, stream-ended, timeout, etc.)
3467
- // are now deferred to the SDK's own auto-retry (see the defer test below), so
3468
- // these use a message the SDK does NOT retry.
3573
+ // Both SDK-retryable and non-SDK-retryable errors now exercise pi-pi's OWN
3574
+ // idle-gated retry. By the time turn_end fires, the SDK's short in-prompt
3575
+ // retry budget is already exhausted, so pi-pi's longer retry (8×, up to ~4min)
3576
+ // takes over instead of the session silently dying after the SDK's ~14s budget.
3469
3577
  it("turn_end with a non-SDK-retryable error uses pi-pi's idle-gated retry", async () => {
3470
3578
  vi.useFakeTimers();
3471
3579
  const cwd = makeTempDir();
@@ -3478,28 +3586,33 @@ describe("error retry", () => {
3478
3586
  await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
3479
3587
 
3480
3588
  expect(orchestrator.errorRetryCount).toBe(1);
3481
- expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Retrying in 2s"), "warning");
3589
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Retrying in 2s"), "info");
3482
3590
 
3483
3591
  await vi.advanceTimersByTimeAsync(2000);
3484
- expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Previous request failed"), { deliverAs: "followUp" });
3592
+ expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Transient API error"), { deliverAs: "followUp" });
3485
3593
  vi.useRealTimers();
3486
3594
  });
3487
3595
 
3488
- it("turn_end defers SDK-retryable errors to the SDK (no pi-pi retry)", async () => {
3596
+ it("turn_end retries SDK-retryable errors via pi-pi's idle-gated retry", async () => {
3597
+ vi.useFakeTimers();
3489
3598
  const cwd = makeTempDir();
3490
3599
  const { pi, orchestrator } = await setupOrchestrator(cwd);
3491
3600
  const ctx = makeCtx();
3492
3601
 
3493
- await orchestrator.startTask(ctx as any, "implement", "sdk defer test");
3602
+ await orchestrator.startTask(ctx as any, "implement", "sdk retryable test");
3494
3603
  const turnEnd = pi._handlers.get("turn_end")!;
3495
3604
 
3496
- // "stream ended before message_stop" is SDK-retryable pi-pi must NOT run
3497
- // its own retry (which would race the SDK's continue()).
3605
+ // "stream ended before message_stop" is SDK-retryable, but by turn_end the
3606
+ // SDK's own short retry budget is exhausted, so pi-pi's longer retry takes
3607
+ // over rather than letting the autonomous session die.
3498
3608
  await turnEnd({ message: { stopReason: "error", errorMessage: "Anthropic stream ended before message_stop", content: [] } }, ctx);
3499
3609
 
3500
- expect(orchestrator.errorRetryCount).toBe(0);
3501
- expect(orchestrator.pendingRetryTimer).toBeNull();
3502
- expect(ctx.ui.notify).not.toHaveBeenCalledWith(expect.stringContaining("Retrying in"), "warning");
3610
+ expect(orchestrator.errorRetryCount).toBe(1);
3611
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Retrying in 2s"), "info");
3612
+
3613
+ await vi.advanceTimersByTimeAsync(2000);
3614
+ expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Transient API error"), { deliverAs: "followUp" });
3615
+ vi.useRealTimers();
3503
3616
  });
3504
3617
 
3505
3618
  it("turn_end stops retrying after max retries (non-SDK-retryable)", async () => {
@@ -3511,7 +3624,8 @@ describe("error retry", () => {
3511
3624
  await orchestrator.startTask(ctx as any, "implement", "retry max test");
3512
3625
  const turnEnd = pi._handlers.get("turn_end")!;
3513
3626
 
3514
- for (let i = 0; i < 6; i++) {
3627
+ // 8 retries then a 9th error turn that trips the halt.
3628
+ for (let i = 0; i < 9; i++) {
3515
3629
  await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
3516
3630
  }
3517
3631
 
@@ -4038,7 +4152,7 @@ describe("session lifecycle", () => {
4038
4152
  expect(toolNames).toContain("pp_register_repo");
4039
4153
  });
4040
4154
 
4041
- it("session_start detects paused tasks and notifies", async () => {
4155
+ it("session_start does not notify about paused tasks (#1: all hints removed)", async () => {
4042
4156
  const cwd = makeTempDir();
4043
4157
  createTask(cwd, "implement", "Paused task");
4044
4158
  const pi = makePi();
@@ -4050,7 +4164,7 @@ describe("session lifecycle", () => {
4050
4164
  const sessionStart = pi._handlers.get("session_start")!;
4051
4165
  await sessionStart({}, ctx);
4052
4166
 
4053
- expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Paused task"), "info");
4167
+ expect(ctx.ui.notify).not.toHaveBeenCalledWith(expect.stringContaining("Paused task"), "info");
4054
4168
  });
4055
4169
 
4056
4170
  it("session_start loads config", async () => {
@@ -4082,29 +4196,31 @@ describe("menu contracts", () => {
4082
4196
 
4083
4197
  menu
4084
4198
  .expect({ question: "/pp", options: { include: ["Settings"] }, choose: "Settings" })
4085
- .expect({ question: "Settings", options: { exact: ["General", "Agents", "Commands", "Performance", "LSP", "Back"] }, choose: "Back" })
4086
- .expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
4199
+ .expect({ question: "Settings", options: { exact: ["General", "Agents", "Commands", "Performance", "LSP", "Flant", "Info", "Back"] }, choose: "Back" })
4200
+ .expect({ question: "/pp", options: { include: ["Back to prompt"] }, choose: "Back to prompt" });
4087
4201
 
4088
4202
  const pp = getCommand(pi, "pp");
4089
4203
  await pp(undefined, ctx);
4090
4204
  });
4091
4205
 
4092
- it("info menu shows Doctor and hides LSP", async () => {
4206
+ it("info menu (under Settings) shows Doctor and hides LSP", async () => {
4093
4207
  const cwd = makeTempDir();
4094
4208
  const { pi } = await setupOrchestrator(cwd);
4095
4209
  const ctx = makeCtx();
4096
4210
 
4097
4211
  menu
4098
- .expect({ question: "/pp", options: { include: ["Info"] }, choose: "Info" })
4212
+ .expect({ question: "/pp", options: { include: ["Settings"] }, choose: "Settings" })
4213
+ .expect({ question: "Settings", options: { include: ["Info"] }, choose: "Info" })
4099
4214
  .expect({
4100
4215
  question: "Info",
4101
4216
  options: {
4102
- include: ["Subagents", "Usage", "Doctor", "Back"],
4103
- exclude: ["LSP"],
4217
+ include: ["Usage", "Doctor", "Back"],
4218
+ exclude: ["LSP", "Subagents"],
4104
4219
  },
4105
4220
  choose: "Back",
4106
4221
  })
4107
- .expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
4222
+ .expect({ question: "Settings", options: { include: ["Back"] }, choose: "Back" })
4223
+ .expect({ question: "/pp", options: { include: ["Back to prompt"] }, choose: "Back to prompt" });
4108
4224
 
4109
4225
  const pp = getCommand(pi, "pp");
4110
4226
  await pp(undefined, ctx);
@@ -4117,10 +4233,12 @@ describe("menu contracts", () => {
4117
4233
  const runDoctorSpy = vi.mocked(doctorModule.runDoctor);
4118
4234
 
4119
4235
  menu
4120
- .expect({ question: "/pp", options: { include: ["Info"] }, choose: "Info" })
4236
+ .expect({ question: "/pp", options: { include: ["Settings"] }, choose: "Settings" })
4237
+ .expect({ question: "Settings", options: { include: ["Info"] }, choose: "Info" })
4121
4238
  .expect({ question: "Info", options: { include: ["Doctor", "Back"] }, choose: "Doctor" })
4122
4239
  .expect({ question: "Info", options: { include: ["Back"] }, choose: "Back" })
4123
- .expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
4240
+ .expect({ question: "Settings", options: { include: ["Back"] }, choose: "Back" })
4241
+ .expect({ question: "/pp", options: { include: ["Back to prompt"] }, choose: "Back to prompt" });
4124
4242
 
4125
4243
  const pp = getCommand(pi, "pp");
4126
4244
  await pp(undefined, ctx);
@@ -4142,7 +4260,7 @@ describe("menu contracts", () => {
4142
4260
  .expect({ question: "LSP", options: { exact: ["Restart all servers", "Back"] }, choose: "Restart all servers" })
4143
4261
  .expect({ question: "LSP", options: { include: ["Back"] }, choose: "Back" })
4144
4262
  .expect({ question: "Settings", options: { include: ["Back"] }, choose: "Back" })
4145
- .expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
4263
+ .expect({ question: "/pp", options: { include: ["Back to prompt"] }, choose: "Back to prompt" });
4146
4264
 
4147
4265
  const pp = getCommand(pi, "pp");
4148
4266
  await pp(undefined, ctx);
@@ -4162,7 +4280,7 @@ describe("menu contracts", () => {
4162
4280
  .expect({ question: "LSP", options: { exact: ["Restart all servers", "Back"] }, choose: "Restart all servers" })
4163
4281
  .expect({ question: "LSP", options: { include: ["Back"] }, choose: "Back" })
4164
4282
  .expect({ question: "Settings", options: { include: ["Back"] }, choose: "Back" })
4165
- .expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
4283
+ .expect({ question: "/pp", options: { include: ["Back to prompt"] }, choose: "Back to prompt" });
4166
4284
 
4167
4285
  const pp = getCommand(pi, "pp");
4168
4286
  await pp(undefined, ctx);
@@ -4178,9 +4296,9 @@ describe("menu contracts", () => {
4178
4296
  menu
4179
4297
  .expect({ question: "/pp", options: { include: ["Settings"] }, choose: "Settings" })
4180
4298
  .expect({ question: "Settings", options: { include: ["Agents", "Back"] }, choose: "Agents" })
4181
- .expect({ question: "Agents", options: { exact: ["Orchestrators", "Subagents", "Back"] }, choose: "Back" })
4299
+ .expect({ question: "Agents", options: { exact: ["Orchestrators", "Subagents", "Max concurrent subagents: 7", "Back"] }, choose: "Back" })
4182
4300
  .expect({ question: "Settings", options: { include: ["Back"] }, choose: "Back" })
4183
- .expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
4301
+ .expect({ question: "/pp", options: { include: ["Back to prompt"] }, choose: "Back to prompt" });
4184
4302
 
4185
4303
  const pp = getCommand(pi, "pp");
4186
4304
  await pp(undefined, ctx);
@@ -4196,9 +4314,9 @@ describe("menu contracts", () => {
4196
4314
  menu.expect({
4197
4315
  question: m.taskMenu("implement", "brainstorm"),
4198
4316
  options: {
4199
- exact: ["Next", "Review", "Info", "Settings", "Back"],
4317
+ exact: ["Next", "Review", "Subagents", "Settings", "Back to prompt"],
4200
4318
  },
4201
- choose: "Back",
4319
+ choose: "Back to prompt",
4202
4320
  });
4203
4321
 
4204
4322
  const pp = getCommand(pi, "pp");
@@ -4218,9 +4336,9 @@ describe("menu contracts", () => {
4218
4336
  menu.expect({
4219
4337
  question: m.taskMenu("implement", "plan"),
4220
4338
  options: {
4221
- exact: ["Next", "Info", "Settings", "Back"],
4339
+ exact: ["Next", "Subagents", "Settings", "Back to prompt"],
4222
4340
  },
4223
- choose: "Back",
4341
+ choose: "Back to prompt",
4224
4342
  });
4225
4343
 
4226
4344
  const pp = getCommand(pi, "pp");
@@ -4239,9 +4357,9 @@ describe("menu contracts", () => {
4239
4357
  menu.expect({
4240
4358
  question: m.taskMenu("implement", "brainstorm"),
4241
4359
  options: {
4242
- exact: ["Next", "Review", "Info", "Settings", "Back"],
4360
+ exact: ["Next", "Review", "Subagents", "Settings", "Back to prompt"],
4243
4361
  },
4244
- choose: "Back",
4362
+ choose: "Back to prompt",
4245
4363
  });
4246
4364
 
4247
4365
  const pp = getCommand(pi, "pp");
@@ -4258,9 +4376,9 @@ describe("menu contracts", () => {
4258
4376
  menu.expect({
4259
4377
  question: m.taskMenu("debug", "debug"),
4260
4378
  options: {
4261
- exact: ["Next", "Review", "Info", "Settings", "Back"],
4379
+ exact: ["Next", "Review", "Subagents", "Settings", "Back to prompt"],
4262
4380
  },
4263
- choose: "Back",
4381
+ choose: "Back to prompt",
4264
4382
  });
4265
4383
 
4266
4384
  const pp = getCommand(pi, "pp");
@@ -4277,9 +4395,9 @@ describe("menu contracts", () => {
4277
4395
  menu.expect({
4278
4396
  question: m.taskMenu("review", "review"),
4279
4397
  options: {
4280
- exact: ["Next", "Review", "Info", "Settings", "Back"],
4398
+ exact: ["Next", "Review", "Subagents", "Settings", "Back to prompt"],
4281
4399
  },
4282
- choose: "Back",
4400
+ choose: "Back to prompt",
4283
4401
  });
4284
4402
 
4285
4403
  const pp = getCommand(pi, "pp");
@@ -4378,9 +4496,9 @@ describe("menu contracts", () => {
4378
4496
  menu.expect({
4379
4497
  question: m.taskMenu("quick", "quick"),
4380
4498
  options: {
4381
- exact: ["Complete", "Pause", "Info", "Settings", "Back"],
4499
+ exact: ["Complete", "Pause", "Subagents", "Settings", "Back to prompt"],
4382
4500
  },
4383
- choose: "Back",
4501
+ choose: "Back to prompt",
4384
4502
  });
4385
4503
 
4386
4504
  const pp = getCommand(pi, "pp");
@@ -4395,8 +4513,8 @@ describe("menu contracts", () => {
4395
4513
  await orchestrator.startTask(ctx as any, "implement", "contract next");
4396
4514
  menu
4397
4515
  .expect({ question: m.taskMenu("implement", "brainstorm"), options: { include: ["Next"] }, choose: "Next" })
4398
- .expect({ question: "Next", options: { exact: ["Continue to plan & implement", "Complete", "Pause", "Back"] }, choose: "Back" })
4399
- .expect({ question: m.taskMenu("implement", "brainstorm"), options: { include: ["Back"] }, choose: "Back" });
4516
+ .expect({ question: "Next", options: { exact: ["Continue to plan & implement", "Auto review, then continue to plan & implement", "Complete", "Pause", "Back"] }, choose: "Back" })
4517
+ .expect({ question: m.taskMenu("implement", "brainstorm"), options: { include: ["Back to prompt"] }, choose: "Back to prompt" });
4400
4518
 
4401
4519
  const pp = getCommand(pi, "pp");
4402
4520
  await pp(undefined, ctx);
@@ -4408,7 +4526,7 @@ describe("menu contracts", () => {
4408
4526
  menu
4409
4527
  .expect({ question: m.taskMenu("implement", "implement"), options: { include: ["Next"] }, choose: "Next" })
4410
4528
  .expect({ question: "Next", options: { exact: ["Complete", "Pause", "Back"] }, choose: "Back" })
4411
- .expect({ question: m.taskMenu("implement", "implement"), options: { include: ["Back"] }, choose: "Back" });
4529
+ .expect({ question: m.taskMenu("implement", "implement"), options: { include: ["Back to prompt"] }, choose: "Back to prompt" });
4412
4530
 
4413
4531
  await pp(undefined, ctx);
4414
4532
  });
@@ -4422,8 +4540,8 @@ describe("menu contracts", () => {
4422
4540
 
4423
4541
  menu
4424
4542
  .expect({ question: m.taskMenu("implement", "brainstorm"), options: { include: ["Review"] }, choose: "Review" })
4425
- .expect({ question: "Review", options: { exact: [m.autoReview, "Review on my own", "Back"] }, choose: "Back" })
4426
- .expect({ question: m.taskMenu("implement", "brainstorm"), options: { include: ["Back"] }, choose: "Back" });
4543
+ .expect({ question: "Review", options: { exact: [m.autoReview, "Auto review until approved", "Review in Plannotator", "Review on my own", "Back"] }, choose: "Back" })
4544
+ .expect({ question: m.taskMenu("implement", "brainstorm"), options: { include: ["Back to prompt"] }, choose: "Back to prompt" });
4427
4545
 
4428
4546
  const pp = getCommand(pi, "pp");
4429
4547
  await pp(undefined, ctx);
@@ -4441,7 +4559,7 @@ describe("menu contracts", () => {
4441
4559
  .expect({ question: "Mode", options: { exact: ["Guided", "Autonomous", "Back"] }, choose: "Back" })
4442
4560
  .expect({ question: "Implement", options: { include: ["Back"] }, choose: "Back" })
4443
4561
  .expect({ question: "Task", options: { include: ["Back"] }, choose: "Back" })
4444
- .expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
4562
+ .expect({ question: "/pp", options: { include: ["Back to prompt"] }, choose: "Back to prompt" });
4445
4563
 
4446
4564
  const pp = getCommand(pi, "pp");
4447
4565
  await pp(undefined, ctx);
@@ -248,9 +248,10 @@ describe("model-registry", () => {
248
248
 
249
249
  it("getModelFamilies returns all family definitions", () => {
250
250
  const families = getModelFamilies();
251
- expect(families).toHaveLength(10);
251
+ expect(families).toHaveLength(11);
252
252
  expect(families.map((f) => f.family).sort()).toEqual([
253
253
  "deepseek",
254
+ "fable",
254
255
  "gemini-flash",
255
256
  "gemini-pro",
256
257
  "gpt",
@@ -1,7 +1,7 @@
1
1
  import { compareModelVersion } from "./model-version.js";
2
2
 
3
3
  export type Vendor = "anthropic" | "openai" | "google" | "deepseek" | "xai" | "qwen" | "unknown";
4
- export type Family = "opus" | "sonnet" | "haiku" | "gpt" | "gpt-mini" | "gemini-pro" | "gemini-flash" | "deepseek" | "grok" | "qwen" | "unknown";
4
+ export type Family = "opus" | "fable" | "sonnet" | "haiku" | "gpt" | "gpt-mini" | "gemini-pro" | "gemini-flash" | "deepseek" | "grok" | "qwen" | "unknown";
5
5
  export type Tier = "stupid" | "regular" | "smart" | "xsmart" | "unknown";
6
6
 
7
7
  export interface ModelInfo {
@@ -13,7 +13,7 @@ export interface ModelInfo {
13
13
 
14
14
  type ProviderPrefix = "anthropic" | "openai" | "google" | "deepseek" | "x-ai" | "qwen" | "pp-flant-anthropic" | "pp-flant-anthropic-sub" | "pp-flant-openai";
15
15
  type KnownVendor = "anthropic" | "openai" | "google" | "deepseek" | "xai" | "qwen";
16
- type KnownFamily = "opus" | "sonnet" | "haiku" | "gpt" | "gpt-mini" | "gemini-pro" | "gemini-flash" | "deepseek" | "grok" | "qwen";
16
+ type KnownFamily = "opus" | "fable" | "sonnet" | "haiku" | "gpt" | "gpt-mini" | "gemini-pro" | "gemini-flash" | "deepseek" | "grok" | "qwen";
17
17
  type KnownTier = "stupid" | "regular" | "smart" | "xsmart";
18
18
 
19
19
  export interface ModelFamilyDefinition {
@@ -47,6 +47,16 @@ export const MODEL_FAMILIES: ModelFamilyDefinition[] = [
47
47
  providers: ["anthropic", "pp-flant-anthropic", "pp-flant-anthropic-sub"],
48
48
  nativeLatestProviders: ["anthropic"],
49
49
  },
50
+ {
51
+ vendor: "anthropic",
52
+ family: "fable",
53
+ tier: "xsmart",
54
+ displayName: "Claude Fable",
55
+ patterns: [/^(anthropic|pp-flant-anthropic)\/claude-fable-[a-z0-9.-]+$/, /^pp-flant-anthropic-sub\/sub\/claude-fable-[a-z0-9.-]+$/],
56
+ aliasTemplate: "claude-fable-latest",
57
+ providers: ["anthropic", "pp-flant-anthropic", "pp-flant-anthropic-sub"],
58
+ nativeLatestProviders: ["anthropic"],
59
+ },
50
60
  {
51
61
  vendor: "anthropic",
52
62
  family: "sonnet",
@@ -123,6 +123,46 @@ describe("Orchestrator.safeSendUserMessage", () => {
123
123
  });
124
124
  });
125
125
 
126
+ describe("Orchestrator.deliverReviewReady (item 9: no editor leak on /pp close)", () => {
127
+ function setup() {
128
+ const sendUserMessage = vi.fn();
129
+ const orchestrator = new Orchestrator(makePi({ sendUserMessage }));
130
+ orchestrator.active = makeActiveTask(null);
131
+ // isIdle true so sendUserMessageWhenIdle delivers synchronously.
132
+ orchestrator.lastCtx = { isIdle: () => true };
133
+ return { orchestrator, sendUserMessage };
134
+ }
135
+
136
+ it("does NOT queue the banner while a menu/ask dialogue is live (nothing to leak on ESC)", () => {
137
+ const { orchestrator, sendUserMessage } = setup();
138
+ orchestrator.interactivePromptOpen = true;
139
+ orchestrator.deliverReviewReady("[PI-PI] Review cycle is ready for apply_feedback.");
140
+ expect(sendUserMessage).not.toHaveBeenCalled();
141
+ expect(orchestrator.pendingReviewReady).toContain("ready for apply_feedback");
142
+ });
143
+
144
+ it("delivers exactly once as a fresh turn when the dialogue closes, then never re-fires", () => {
145
+ const { orchestrator, sendUserMessage } = setup();
146
+ orchestrator.interactivePromptOpen = true;
147
+ orchestrator.deliverReviewReady("[PI-PI] Review cycle is ready for apply_feedback.");
148
+ orchestrator.interactivePromptOpen = false;
149
+ orchestrator.flushPendingReviewReady();
150
+ expect(sendUserMessage).toHaveBeenCalledTimes(1);
151
+ expect(orchestrator.pendingReviewReady).toBeNull();
152
+ // A second flush (e.g. a later idle) must not re-send.
153
+ orchestrator.flushPendingReviewReady();
154
+ expect(sendUserMessage).toHaveBeenCalledTimes(1);
155
+ });
156
+
157
+ it("delivers immediately when no dialogue is open", () => {
158
+ const { orchestrator, sendUserMessage } = setup();
159
+ orchestrator.interactivePromptOpen = false;
160
+ orchestrator.deliverReviewReady("[PI-PI] Review cycle is ready for apply_feedback.");
161
+ expect(sendUserMessage).toHaveBeenCalledTimes(1);
162
+ expect(orchestrator.pendingReviewReady).toBeNull();
163
+ });
164
+ });
165
+
126
166
  describe("Orchestrator.cancelPendingRetry", () => {
127
167
  it("clears the pending timer, disarms the ESC interrupt, and resets the counter", () => {
128
168
  vi.useFakeTimers();
@@ -404,6 +444,33 @@ describe("Orchestrator.checkForConflictingExtensions", () => {
404
444
  });
405
445
  });
406
446
 
447
+ describe("Orchestrator.applySubagentConcurrency", () => {
448
+ const MANAGER_KEY = Symbol.for("pi-subagents:manager");
449
+
450
+ afterEach(() => {
451
+ delete (globalThis as any)[MANAGER_KEY];
452
+ });
453
+
454
+ it("applies the configured limit to the manager handle", () => {
455
+ const setMaxConcurrent = vi.fn();
456
+ (globalThis as any)[MANAGER_KEY] = { setMaxConcurrent };
457
+ const orchestrator = new Orchestrator(makePi());
458
+ orchestrator.config = getDefaultConfig() as any;
459
+ orchestrator.config.agents.maxConcurrentSubagents = 7;
460
+
461
+ orchestrator.applySubagentConcurrency();
462
+
463
+ expect(setMaxConcurrent).toHaveBeenCalledWith(7);
464
+ });
465
+
466
+ it("no-ops when the manager handle is absent", () => {
467
+ const orchestrator = new Orchestrator(makePi());
468
+ orchestrator.config = getDefaultConfig() as any;
469
+
470
+ expect(() => orchestrator.applySubagentConcurrency()).not.toThrow();
471
+ });
472
+ });
473
+
407
474
  describe("Orchestrator.abortAllSubagents", () => {
408
475
  it("emits stop events for all spawned subagents and clears the set", () => {
409
476
  const emit = vi.fn();