@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,5 +1,4 @@
1
- import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "fs";
2
- import { tmpdir } from "os";
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
3
2
  import { resolve, basename, join, relative, dirname, isAbsolute, sep } from "path";
4
3
  import { validateUserRequest, validateResearch, validateArtifact } from "./validate-artifacts.js";
5
4
  import { Type } from "@sinclair/typebox";
@@ -14,28 +13,33 @@ import {
14
13
  loadAllContextFiles,
15
14
  getPhaseArtifacts,
16
15
  getLatestSynthesizedPlan,
16
+ getArtifactManifest,
17
17
  loadBrainstormReviewOutputs,
18
18
  loadCodeReviewOutputs,
19
19
  loadPlanReviewOutputs,
20
20
  } from "./context.js";
21
- import { PRINCIPLES_BLOCK, TOOLS_BLOCK } from "./agents/tool-routing.js";
21
+ import { PRINCIPLES_BLOCK, TOOLS_BLOCK, DELEGATION_BLOCK } from "./agents/tool-routing.js";
22
22
  import { constraintsBlock, phaseConstraint } from "./agents/constraints.js";
23
23
  import { registerCbmTools } from "./cbm.js";
24
24
  import { registerExaTools } from "./exa.js";
25
25
  import { registerAstSearchTool } from "./ast-search.js";
26
26
  import { SUBAGENT_SESSION_KEY } from "./index.js";
27
- import { registerCommandHandlers } from "./command-handlers.js";
27
+ import { registerCommandHandlers, runAfterImplementForActive } from "./command-handlers.js";
28
+ import { registerStateFileTools } from "./pp-state-tools.js";
29
+ import { isAiCommentOnlyChange } from "./ai-comment-cleanup.js";
30
+ import { handleMainRateLimit, handleSubagentRateLimit, isRateLimitError, isSdkRetryableError } from "./rate-limit-fallback.js";
28
31
  import { setExtensionOnlyMode, unregisterAgentDefinitions } from "./agents/registry.js";
29
32
  import { resolveModel, getModelInfo, updateRegistryFromAvailableModels } from "./model-registry.js";
30
33
  import { spawnPlanners, spawnPlanReviewers } from "./phases/planning.js";
31
34
  import { spawnCodeReviewers } from "./phases/review.js";
32
35
  import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
33
36
  import { reviewPassUnanimousApprove } from "./phases/verdict.js";
34
- import { validateExitCriteria } from "./phases/machine.js";
37
+ import { nextPhase, validateExitCriteria } from "./phases/machine.js";
35
38
  import { openPlannotator, waitForPlannotatorResult, cancelPendingPlannotatorWait } from "./plannotator.js";
39
+ import { advanceBanner } from "./messages.js";
36
40
  import { Orchestrator, type ActiveTask } from "./orchestrator.js";
37
- import { createCustomFooter, setFooterContext, setFooterTracker } from "./custom-footer.js";
38
- import { createUsageTracker, dumpUsageSummary, loadUsageSummary, type UsageTracker } from "./usage-tracker.js";
41
+ import { createCustomFooter, setFooterContext, setFooterTracker, setFooterOrchestrator } from "./custom-footer.js";
42
+ import { createUsageTracker, dumpUsageSummary, loadUsageSummary, isSubscriptionRouted, type UsageTracker } from "./usage-tracker.js";
39
43
  import { askUser, isCancel } from "../../3p/pi-ask-user/index.js";
40
44
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
41
45
  import { findRootRepo, normalizeRepoPath, resolveRepoForFile, type RepoInfo } from "./repo-utils.js";
@@ -51,6 +55,34 @@ function isPathInside(basePath: string, targetPath: string): boolean {
51
55
  return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
52
56
  }
53
57
 
58
+ // Builds the spawn-time context block appended to a free agent's spawn prompt:
59
+ // inlines USER_REQUEST + RESEARCH (always present by spawn time) and appends a
60
+ // path-aware manifest of additional on-demand documents (artifacts + plan).
61
+ function buildSpawnContextBlock(taskDir: string): string {
62
+ const parts: string[] = [];
63
+
64
+ const userRequestPath = join(taskDir, "USER_REQUEST.md");
65
+ if (existsSync(userRequestPath)) {
66
+ parts.push("=== USER REQUEST ===\n" + readFileSync(userRequestPath, "utf-8").trimEnd());
67
+ }
68
+ const researchPath = join(taskDir, "RESEARCH.md");
69
+ if (existsSync(researchPath)) {
70
+ parts.push("=== RESEARCH ===\n" + readFileSync(researchPath, "utf-8").trimEnd());
71
+ }
72
+
73
+ const manifest = getArtifactManifest(taskDir);
74
+ if (manifest.length > 0) {
75
+ const lines = manifest.map((m) => `- ${m.path} — ${m.title}`);
76
+ parts.push(
77
+ "=== ADDITIONAL DOCUMENTS (read from disk if relevant) ===\n" +
78
+ lines.join("\n") +
79
+ "\nDo NOT re-read USER_REQUEST/RESEARCH from disk (already above).",
80
+ );
81
+ }
82
+
83
+ return parts.join("\n\n");
84
+ }
85
+
54
86
  export async function detectDefaultBranch(orchestrator: Orchestrator, repos: RepoInfo[], repoPath: string): Promise<string> {
55
87
  const normalizedPath = normalizeRepoPath(repoPath);
56
88
  const repo = repos.find((r) => r.path === normalizedPath);
@@ -89,15 +121,21 @@ export async function detectDefaultBranch(orchestrator: Orchestrator, repos: Rep
89
121
  }
90
122
 
91
123
  export async function selectOption(ctx: any, question: string, options: string[]): Promise<string | undefined> {
92
- const result = await askUser(ctx, {
93
- question,
94
- options,
95
- allowFreeform: false,
96
- allowComment: false,
97
- allowMultiple: false,
98
- });
99
- if (!result || isCancel(result) || result.kind !== "selection") return undefined;
100
- return result.selections[0];
124
+ const orchestrator = Orchestrator.current;
125
+ if (orchestrator) orchestrator.interactivePromptOpen = true;
126
+ try {
127
+ const result = await askUser(ctx, {
128
+ question,
129
+ options,
130
+ allowFreeform: false,
131
+ allowComment: false,
132
+ allowMultiple: false,
133
+ });
134
+ if (!result || isCancel(result) || result.kind !== "selection") return undefined;
135
+ return result.selections[0];
136
+ } finally {
137
+ if (orchestrator) orchestrator.interactivePromptOpen = false;
138
+ }
101
139
  }
102
140
 
103
141
  function resolveReviewers(
@@ -206,7 +244,7 @@ function tryCompleteReviewCycle(orchestrator: Orchestrator, spawnedReviewers?: n
206
244
  },
207
245
  "instruction",
208
246
  );
209
- orchestrator.safeSendUserMessage(reviewReadyMessage(phase, getEffectivePhaseMode(orchestrator.active.state)));
247
+ orchestrator.safeSendUserMessage(advanceBanner(reviewReadyMessage(phase, getEffectivePhaseMode(orchestrator.active.state))));
210
248
  }
211
249
 
212
250
  function reviewReadyMessage(phase: string, mode: TaskMode): string {
@@ -286,7 +324,9 @@ export async function enterReviewCycle(
286
324
  orchestrator.active.state.reviewCycle = null;
287
325
  orchestrator.active.state.step = "llm_work";
288
326
  saveTask(orchestrator.active.dir, orchestrator.active.state);
289
- return "User wants a Plannotator code review. Call pp_specify_reviews with the list of repositories and commit ranges to review. Include all repos where you made changes.";
327
+ // Implement/review-phase Plannotator is driven entirely by the menu's per-repo
328
+ // interleaved loop (pp-menu runPlannotatorCursor); it never enters this path.
329
+ return "Plannotator code review runs per-repo from the /pp Review menu. Open /pp → Review → Review in Plannotator.";
290
330
  }
291
331
 
292
332
  const phase = orchestrator.active.state.phase;
@@ -379,6 +419,14 @@ export async function stopTask(orchestrator: Orchestrator): Promise<string> {
379
419
  return `Task "${desc}" stopped. Use /pp → Resume to continue.`;
380
420
  }
381
421
 
422
+ // A review is LIVE when its cycle exists and reviewers are still being awaited.
423
+ // Guards the menu/autonomous callers against re-spawning reviewers on top of a
424
+ // running cycle (the "6 agents" double-spawn). NOT "a cycle object exists": a
425
+ // completed cycle is finalized (and nulled) legitimately before the next pass.
426
+ export function isReviewCycleLive(task: ActiveTask): boolean {
427
+ return !!task.state.reviewCycle && task.state.reviewCycle.step === "await_reviewers";
428
+ }
429
+
382
430
  export function finalizeReviewCycle(task: ActiveTask): void {
383
431
  if (!task.state.reviewCycle) return;
384
432
  const kind = task.state.reviewCycle.kind;
@@ -416,11 +464,174 @@ export function finalizeReviewCycleAutonomous(task: ActiveTask): void {
416
464
  saveTask(task.dir, task.state);
417
465
  }
418
466
 
467
+ export function registerOrchestratorToolsForTest(orchestrator: Orchestrator): void {
468
+ registerOrchestratorTools(orchestrator);
469
+ }
470
+
419
471
  function registerOrchestratorTools(orchestrator: Orchestrator): void {
420
472
  registerRepoTool(orchestrator);
473
+ registerCheckoutPrHeadTool(orchestrator);
421
474
  registerPhaseCompleteTool(orchestrator);
422
475
  registerCommitTool(orchestrator);
423
- registerSpecifyReviewsTool(orchestrator);
476
+ registerStateFileTools(orchestrator);
477
+ }
478
+
479
+ // Extension-side PR-head checkout for the review phase. Performed here (never via the
480
+ // agent prompt) so REVIEW_READONLY_CONSTRAINT stays truthful — the agent never runs
481
+ // `git checkout` itself. Lands the repo on its PR head under strict safety rules (no stash,
482
+ // no --force, no branch switch, no worktree, no detached checkout): when the tree is clean
483
+ // and on the PR head's branch but merely behind, it fast-forwards the branch to the head
484
+ // (`git merge --ff-only`, which never detaches, never rewrites, and refuses a non-ff move).
485
+ // A dirty tree, a different branch, or a non-fast-forwardable divergence all HALT with a
486
+ // message for the user. Checking HEAD's SHA first makes this re-entrant and detached-safe.
487
+ export async function checkoutPrHead(
488
+ orchestrator: Orchestrator,
489
+ repoPath: string,
490
+ headRefName: string,
491
+ headRefOid: string,
492
+ ): Promise<{ ok: boolean; message: string }> {
493
+ const pi = orchestrator.pi;
494
+ const name = (headRefName ?? "").trim();
495
+ const oid = (headRefOid ?? "").trim();
496
+ if (!name && !oid) {
497
+ return { ok: true, message: `No PR head provided for ${repoPath}; skipping (non-PR review scope).` };
498
+ }
499
+
500
+ const run = (args: string[]) => pi.exec("git", args, { cwd: repoPath, timeout: 15000 });
501
+ const target = `${name || oid}${name && oid ? ` @ ${oid}` : ""}`;
502
+
503
+ let status;
504
+ try {
505
+ status = await run(["status", "--porcelain"]);
506
+ } catch (err: any) {
507
+ return { ok: false, message: `Cannot inspect ${repoPath}: ${err?.message ?? "git status failed"}` };
508
+ }
509
+ if (status.code !== 0) {
510
+ return { ok: false, message: `Cannot inspect ${repoPath}: ${status.stderr?.trim() || status.stdout?.trim() || "git status failed"}` };
511
+ }
512
+ if (status.stdout.trim()) {
513
+ return {
514
+ ok: false,
515
+ message:
516
+ `HALT: ${repoPath} has uncommitted changes. Commit or stash them, then bring the repo to its PR head (${target}) and ask me to continue.\n\n${status.stdout.trim()}`,
517
+ };
518
+ }
519
+
520
+ if (!oid) {
521
+ return { ok: true, message: `${repoPath}: no PR head commit provided; left as-is (clean tree).` };
522
+ }
523
+
524
+ let head;
525
+ try {
526
+ head = await run(["rev-parse", "HEAD"]);
527
+ } catch (err: any) {
528
+ return { ok: false, message: `Cannot read HEAD of ${repoPath}: ${err?.message ?? "git rev-parse failed"}` };
529
+ }
530
+ if (head.stdout.trim() === oid) {
531
+ return { ok: true, message: `${repoPath} is on PR head ${oid}.` };
532
+ }
533
+
534
+ let branch;
535
+ try {
536
+ branch = await run(["rev-parse", "--abbrev-ref", "HEAD"]);
537
+ } catch (err: any) {
538
+ return { ok: false, message: `Cannot read current branch of ${repoPath}: ${err?.message ?? "git rev-parse failed"}` };
539
+ }
540
+ const currentBranch = branch.stdout.trim();
541
+ if (currentBranch === "HEAD") {
542
+ return {
543
+ ok: false,
544
+ message:
545
+ `HALT: ${repoPath} has a detached HEAD, not at the PR head commit ${oid}. ` +
546
+ `Check out the PR head branch${name ? ` "${name}"` : ""} at ${oid}, then ask me to continue. I will not move HEAD for you.`,
547
+ };
548
+ }
549
+ if (name && currentBranch !== name) {
550
+ return {
551
+ ok: false,
552
+ message:
553
+ `HALT: ${repoPath} is on "${currentBranch}", not the PR head branch "${name}" (target commit ${oid}). ` +
554
+ "Check out that branch at its PR head, then ask me to continue. I will not switch branches for you.",
555
+ };
556
+ }
557
+
558
+ // Clean tree, on the PR head's branch, but behind: advance the branch to the head with a
559
+ // fast-forward-only merge. This moves the branch ref without detaching, forcing, or stashing,
560
+ // and fails safely if the branch has diverged from the PR head.
561
+ let ff;
562
+ try {
563
+ ff = await run(["merge", "--ff-only", oid]);
564
+ } catch (err: any) {
565
+ return { ok: false, message: `Fast-forward of ${repoPath} to PR head ${oid} failed: ${err?.message ?? "git merge failed"}` };
566
+ }
567
+ if (ff.code !== 0) {
568
+ return {
569
+ ok: false,
570
+ message:
571
+ `HALT: ${repoPath} (branch "${currentBranch}") cannot fast-forward to PR head ${oid} — it has diverged. ` +
572
+ `Reconcile it with the PR head, then ask me to continue. I will not force-move or rebase it for you.` +
573
+ `${ff.stderr?.trim() ? `\n\n${ff.stderr.trim()}` : ""}`,
574
+ };
575
+ }
576
+
577
+ // `git merge --ff-only <ancestor>` returns exit 0 ("Already up to date") WITHOUT moving HEAD
578
+ // when the branch is already ahead of the PR head, so a zero exit is not proof we landed on it.
579
+ // Re-read HEAD and HALT if the branch carries commits beyond the PR head.
580
+ let after;
581
+ try {
582
+ after = await run(["rev-parse", "HEAD"]);
583
+ } catch (err: any) {
584
+ return { ok: false, message: `Cannot confirm HEAD of ${repoPath} after fast-forward: ${err?.message ?? "git rev-parse failed"}` };
585
+ }
586
+ if (after.stdout.trim() !== oid) {
587
+ return {
588
+ ok: false,
589
+ message:
590
+ `HALT: ${repoPath} (branch "${currentBranch}") is ahead of the PR head ${oid} — it has commits the PR head does not. ` +
591
+ "Push them to the PR (or check out the exact PR head), then ask me to continue. I will not move HEAD backwards for you.",
592
+ };
593
+ }
594
+ return { ok: true, message: `${repoPath} fast-forwarded to PR head ${oid}.` };
595
+ }
596
+
597
+ function registerCheckoutPrHeadTool(orchestrator: Orchestrator): void {
598
+ const pi = orchestrator.pi;
599
+
600
+ pi.registerTool({
601
+ name: "pp_checkout_pr_head",
602
+ label: "pi-pi",
603
+ description:
604
+ "Land a registered repository on its PR head commit before reviewing it. " +
605
+ "Call this ONLY for a PR-scoped review, once per repo, AFTER you have resolved the PR " +
606
+ "(e.g. via `gh pr view --json headRefName,headRefOid`). Do NOT call it for a branch, " +
607
+ "commit-range, or uncommitted-changes review. The extension does this safely (no worktree, " +
608
+ "no stash, no --force, no branch switch, no detached checkout): a clean repo already on the " +
609
+ "PR head succeeds; a clean repo on the PR head's branch but behind is fast-forwarded to the " +
610
+ "head; a dirty tree, a different branch, or a diverged branch HALTS with a message for you to " +
611
+ "relay to the user. You never run `git checkout` yourself.",
612
+ parameters: Type.Object({
613
+ repoPath: Type.String({ description: "Absolute path to the registered repository" }),
614
+ headRefName: Type.String({ description: "PR head branch name (headRefName)" }),
615
+ headRefOid: Type.String({ description: "PR head commit SHA (headRefOid)" }),
616
+ }),
617
+ async execute(_toolCallId, params: any) {
618
+ if (!orchestrator.active) {
619
+ return { content: [{ type: "text" as const, text: "No active task." }], isError: true as const, details: {} };
620
+ }
621
+ const pathInput = typeof params.repoPath === "string" ? params.repoPath.trim() : "";
622
+ if (!pathInput) {
623
+ return { content: [{ type: "text" as const, text: "Missing repoPath." }], isError: true as const, details: {} };
624
+ }
625
+ const normalized = normalizeRepoPath(pathInput);
626
+ const repos = orchestrator.active.state.repos ?? [];
627
+ const registered = repos.find((repo) => repo.path === normalized);
628
+ if (!registered) {
629
+ return { content: [{ type: "text" as const, text: `Repository is not registered: ${params.repoPath}` }], isError: true as const, details: {} };
630
+ }
631
+ const result = await checkoutPrHead(orchestrator, registered.path, params.headRefName ?? "", params.headRefOid ?? "");
632
+ return { content: [{ type: "text" as const, text: result.message }], isError: result.ok ? undefined : (true as const), details: {} };
633
+ },
634
+ });
424
635
  }
425
636
 
426
637
  function registerRepoTool(orchestrator: Orchestrator): void {
@@ -562,190 +773,6 @@ function registerRepoTool(orchestrator: Orchestrator): void {
562
773
  });
563
774
  }
564
775
 
565
- function openCodeReviewDirect(
566
- pi: ExtensionAPI,
567
- payload: Record<string, unknown>,
568
- ): Promise<{ approved: boolean; feedback?: string } | { error: string }> {
569
- return new Promise((resolve) => {
570
- pi.events.emit("plannotator:request", {
571
- requestId: crypto.randomUUID(),
572
- action: "code-review",
573
- payload,
574
- respond: (response: any) => {
575
- if (response.status === "handled") {
576
- resolve({ approved: !!response.result?.approved, feedback: response.result?.feedback });
577
- } else {
578
- resolve({ error: response.error || "Plannotator not available" });
579
- }
580
- },
581
- });
582
- });
583
- }
584
-
585
- function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
586
- const pi = orchestrator.pi;
587
-
588
- pi.registerTool({
589
- name: "pp_specify_reviews",
590
- label: "pi-pi",
591
- description:
592
- "Specify which repositories and commit ranges to open in Plannotator for code review. " +
593
- "Called when the user requests a Plannotator code review. " +
594
- "Plannotator will open sequentially for each entry. Results are returned after all reviews complete. " +
595
- "Range supports both 'base..HEAD' and 'base..target' (non-HEAD target is reviewed via temporary worktree checkout).",
596
- parameters: Type.Object({
597
- reviews: Type.Array(Type.Object({
598
- cwd: Type.String({ description: "Absolute path to the git repository" }),
599
- range: Type.String({ description: "Git commit range, e.g. 'origin/main..HEAD', 'abc123..def456', 'HEAD~3..HEAD'" }),
600
- })),
601
- }),
602
- async execute(_toolCallId, params: any, _signal, _onUpdate, ctx) {
603
- if (orchestrator.active && getEffectiveMode(orchestrator.active.state) === "autonomous") {
604
- return { content: [{ type: "text" as const, text: "Plannotator review is not available in autonomous mode. Continue with automated reviews via pp_phase_complete." }], isError: true as const, details: {} };
605
- }
606
- if (!params.reviews || params.reviews.length === 0) {
607
- return { content: [{ type: "text" as const, text: "No reviews specified." }], isError: true as const, details: {} };
608
- }
609
-
610
- const results: string[] = [];
611
- let hasNeedsChanges = false;
612
- for (const review of params.reviews) {
613
- ctx.ui?.setWorkingMessage?.(`Waiting for Plannotator review: ${review.range}…`);
614
- const range = String(review.range ?? "").trim();
615
- let compareBase = range;
616
- let compareTarget = "HEAD";
617
- const rangeSeparatorIdx = range.indexOf("..");
618
- if (rangeSeparatorIdx >= 0) {
619
- compareBase = range.slice(0, rangeSeparatorIdx).trim();
620
- compareTarget = range.slice(rangeSeparatorIdx + 2).trim() || "HEAD";
621
- }
622
-
623
- if (!compareBase) {
624
- results.push(`${review.cwd} (${review.range}): Invalid range (missing base).`);
625
- continue;
626
- }
627
-
628
- let reviewCwd = review.cwd;
629
- let tempWorktreePath: string | null = null;
630
- let tempWorktreeParent: string | null = null;
631
- let setupError: string | null = null;
632
-
633
- if (compareTarget !== "HEAD") {
634
- tempWorktreeParent = mkdtempSync(join(tmpdir(), "pi-pi-review-worktree-"));
635
- tempWorktreePath = join(tempWorktreeParent, "checkout");
636
- try {
637
- const addResult = await pi.exec("git", ["worktree", "add", "--detach", tempWorktreePath, compareTarget], {
638
- cwd: review.cwd,
639
- timeout: 20000,
640
- });
641
- if (addResult.code !== 0) {
642
- setupError = addResult.stderr?.trim() || addResult.stdout?.trim() || "Failed to prepare temporary worktree";
643
- } else {
644
- reviewCwd = tempWorktreePath;
645
- }
646
- } catch (error: any) {
647
- setupError = error?.message ?? String(error);
648
- }
649
- }
650
-
651
- if (setupError) {
652
- results.push(`${review.cwd} (${review.range}): ${setupError}`);
653
- if (tempWorktreePath) {
654
- try {
655
- await pi.exec("git", ["worktree", "remove", "--force", tempWorktreePath], {
656
- cwd: review.cwd,
657
- timeout: 20000,
658
- });
659
- } catch {}
660
- }
661
- if (tempWorktreePath) {
662
- try {
663
- rmSync(tempWorktreePath, { recursive: true, force: true });
664
- } catch {}
665
- }
666
- if (tempWorktreeParent) {
667
- try {
668
- rmSync(tempWorktreeParent, { recursive: true, force: true });
669
- } catch {}
670
- }
671
- continue;
672
- }
673
-
674
- const result = await openCodeReviewDirect(pi, {
675
- cwd: reviewCwd,
676
- diffType: "branch",
677
- defaultBranch: compareBase,
678
- });
679
-
680
- if (tempWorktreePath) {
681
- try {
682
- await pi.exec("git", ["worktree", "remove", "--force", tempWorktreePath], {
683
- cwd: review.cwd,
684
- timeout: 20000,
685
- });
686
- } catch {}
687
- try {
688
- rmSync(tempWorktreePath, { recursive: true, force: true });
689
- } catch {}
690
- if (tempWorktreeParent) {
691
- try {
692
- rmSync(tempWorktreeParent, { recursive: true, force: true });
693
- } catch {}
694
- }
695
- }
696
-
697
- if ("error" in result) {
698
- results.push(`${review.cwd} (${review.range}): ${result.error}`);
699
- } else {
700
- const status = result.approved ? "APPROVED" : "NEEDS_CHANGES";
701
- if (!result.approved) hasNeedsChanges = true;
702
- const feedback = result.feedback ? `\nFeedback: ${result.feedback}` : "";
703
- results.push(`${review.cwd} (${review.range}): ${status}${feedback}`);
704
- }
705
- }
706
- ctx.ui?.setWorkingMessage?.();
707
-
708
- const summary = results.join("\n\n");
709
-
710
- if (hasNeedsChanges) {
711
- if (orchestrator.active) {
712
- orchestrator.active.state.step = "llm_work";
713
- saveTask(orchestrator.active.dir, orchestrator.active.state);
714
- }
715
- return {
716
- content: [{ type: "text" as const, text: `Plannotator review complete.\n\n${summary}\n\nAddress the user's feedback. If the feedback contains questions, answer them. If it requests changes, make the changes. Then call pp_phase_complete when done.` }],
717
- details: {},
718
- };
719
- }
720
-
721
- ctx.ui?.setWorkingMessage?.("Waiting for user input…");
722
- try {
723
- const { showActiveTaskMenu, USER_CANCELLED } = await import("./pp-menu.js");
724
- const text = await showActiveTaskMenu(orchestrator, ctx, `Plannotator review complete.\n\n${summary}`, "tool");
725
- // Deliberate user ESC: stop the turn cleanly (mirror ask_user), no
726
- // reminder text that would start a new LLM turn.
727
- if (text === USER_CANCELLED) {
728
- ctx.abort?.();
729
- return { content: [{ type: "text" as const, text: "" }], details: {} };
730
- }
731
- // A transition may have started while the menu was open. The controller
732
- // is the source of truth; abort the agent's pending turn so it doesn't
733
- // race the transition. (Interactive-UX abort — stays local, not routed.)
734
- if (!orchestrator.transitionController.isRunning()) {
735
- ctx.abort?.();
736
- return { content: [{ type: "text" as const, text: "" }], details: {} };
737
- }
738
- if (!text) {
739
- return { content: [{ type: "text" as const, text: "User dismissed the menu. Wait for the user's next message. When you resume work, update USER_REQUEST.md and RESEARCH.md with any new findings before calling pp_phase_complete." }], details: {} };
740
- }
741
- return { content: [{ type: "text" as const, text }], details: {} };
742
- } finally {
743
- ctx.ui?.setWorkingMessage?.();
744
- }
745
- },
746
- });
747
- }
748
-
749
776
  function loadPhaseReviewOutputs(taskDir: string, phase: string, pass: number): { name: string; content: string }[] {
750
777
  if (phase === "brainstorm") return loadBrainstormReviewOutputs(taskDir, pass);
751
778
  if (phase === "plan") return loadPlanReviewOutputs(taskDir, pass);
@@ -915,6 +942,33 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
915
942
  }
916
943
  }
917
944
 
945
+ // Terminal handoff (#1): an autonomous IMPLEMENT phase whose next phase is
946
+ // "done" should NOT auto-complete. Run afterImplement (transitionToNextPhase
947
+ // is skipped here, so run it explicitly — once), then open the GUIDED
948
+ // implement menu and wait, instead of tearing the task down.
949
+ if (phase === "implement" && nextPhase(orchestrator.active.type, phase) === "done") {
950
+ if (!orchestrator.active.state.afterImplementRan) {
951
+ const afterError = runAfterImplementForActive(orchestrator);
952
+ if (afterError) {
953
+ return { content: [{ type: "text" as const, text: `${afterError}\n\nDo NOT wait for the user — fix these and call pp_phase_complete again.` }], details: {} };
954
+ }
955
+ orchestrator.active.state.afterImplementRan = true;
956
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
957
+ }
958
+ ctx.ui.setWorkingMessage?.("Waiting for user approval…");
959
+ try {
960
+ const { showActiveTaskMenu, USER_CANCELLED } = await import("./pp-menu.js");
961
+ const text = await showActiveTaskMenu(orchestrator, ctx, params.summary, "tool", true);
962
+ if (text === USER_CANCELLED) {
963
+ ctx.abort?.();
964
+ return { content: [{ type: "text" as const, text: "" }], details: {} };
965
+ }
966
+ return { content: [{ type: "text" as const, text: text ?? "" }], details: {} };
967
+ } finally {
968
+ ctx.ui.setWorkingMessage?.();
969
+ }
970
+ }
971
+
918
972
  const plannerPreset = orchestrator.active.state.autonomousConfig?.phases?.plan?.plannerPreset;
919
973
  const result = await orchestrator.transitionToNextPhase(ctx, plannerPreset);
920
974
  if (!result.ok) {
@@ -989,31 +1043,49 @@ function registerMainTraceHooks(orchestrator: Orchestrator): void {
989
1043
  tracer?.traceMain("turn_end", { turnIndex: event.turnIndex, message: event.message, toolResults: event.toolResults });
990
1044
  });
991
1045
  pi.on("message_start", async (event) => {
1046
+ markMainTurnActivity(orchestrator);
992
1047
  const tracer = getTracer();
993
1048
  tracer?.traceMain("message_start", { turnIndex: tracer.turnIndex, message: event.message });
994
1049
  });
995
1050
  pi.on("message_update", async (event) => {
1051
+ markMainTurnActivity(orchestrator);
996
1052
  const tracer = getTracer();
997
1053
  tracer?.traceMain("message_update", { turnIndex: tracer.turnIndex, assistantMessageEvent: event.assistantMessageEvent });
998
1054
  });
999
1055
  pi.on("message_end", async (event) => {
1056
+ markMainTurnActivity(orchestrator);
1000
1057
  const tracer = getTracer();
1001
1058
  tracer?.traceMain("message_end", { turnIndex: tracer.turnIndex, message: event.message });
1002
1059
  });
1003
1060
  pi.on("tool_execution_start", async (event) => {
1061
+ markMainTurnActivity(orchestrator);
1004
1062
  const tracer = getTracer();
1005
1063
  tracer?.traceMain("tool_execution_start", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, args: event.args });
1006
1064
  });
1007
1065
  pi.on("tool_execution_update", async (event) => {
1066
+ markMainTurnActivity(orchestrator);
1008
1067
  const tracer = getTracer();
1009
1068
  tracer?.traceMain("tool_execution_update", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, args: event.args, partialResult: event.partialResult });
1010
1069
  });
1011
1070
  pi.on("tool_execution_end", async (event) => {
1071
+ markMainTurnActivity(orchestrator);
1012
1072
  const tracer = getTracer();
1013
1073
  tracer?.traceMain("tool_execution_end", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, result: event.result, isError: event.isError });
1014
1074
  });
1015
1075
  }
1016
1076
 
1077
+ // Main-turn watchdog activity helpers (BUG-2). Kept module-level so they can be
1078
+ // called from the several existing main-session handlers without registering a
1079
+ // second handler per event.
1080
+ function markMainTurnActivity(orchestrator: Orchestrator): void {
1081
+ orchestrator.mainTurnLastActivity = Date.now();
1082
+ }
1083
+
1084
+ function endMainTurn(orchestrator: Orchestrator): void {
1085
+ orchestrator.mainTurnInFlight = false;
1086
+ orchestrator.mainTurnRecovering = false;
1087
+ }
1088
+
1017
1089
  export function registerEventHandlers(orchestrator: Orchestrator): void {
1018
1090
  const pi = orchestrator.pi;
1019
1091
 
@@ -1044,6 +1116,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1044
1116
  // the subagent branch, so these only ever see root-session events.
1045
1117
  pi.on("agent_end", async (_event, ctx) => {
1046
1118
  if (ctx) orchestrator.lastCtx = ctx;
1119
+ endMainTurn(orchestrator);
1047
1120
  orchestrator.transitionController.onAgentEnd();
1048
1121
  });
1049
1122
  pi.on("session_compact", async (_event, ctx) => {
@@ -1051,6 +1124,65 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1051
1124
  orchestrator.transitionController.onSessionCompact();
1052
1125
  });
1053
1126
 
1127
+ // BUG-2: main-turn stall watchdog. A main turn can start (turn_start / manual
1128
+ // "continue" / SDK auto-retry) and then wedge with no terminal turn_end, so the
1129
+ // session sits "Working…" forever with nothing to recover it (the SDK-retryable
1130
+ // deferral below arms nothing, and startStaleAgentWatchdog only watches
1131
+ // subagents). Activity is marked from the existing main-session handlers
1132
+ // (turn_start below, tool_call, tool_result, turn_end) so we do NOT register a
1133
+ // second handler per event (the runtime allows it, but keeping one owner per
1134
+ // event is simpler and test-mock-friendly). When a turn is in flight with NO
1135
+ // activity beyond the configured threshold, abort + recover via the idle-gated
1136
+ // single-send path so recovery can't itself race into "Agent is already
1137
+ // processing".
1138
+ const startMainTurnWatchdog = () => {
1139
+ if (orchestrator.mainTurnTimer) return;
1140
+ orchestrator.mainTurnTimer = setInterval(() => {
1141
+ if (!orchestrator.active) {
1142
+ clearInterval(orchestrator.mainTurnTimer!);
1143
+ orchestrator.mainTurnTimer = null;
1144
+ return;
1145
+ }
1146
+ if (!orchestrator.mainTurnInFlight || orchestrator.mainTurnRecovering) return;
1147
+ // Only a genuinely stuck turn is a target: an idle session (turn already
1148
+ // settled), an in-progress transition, or an await_* step is not a stall.
1149
+ if (orchestrator.transitionController.isTransitioning()) return;
1150
+ const step = orchestrator.active.state.step;
1151
+ if (step === "await_planners" || step === "await_reviewers") return;
1152
+ // A turn parked on an open user-facing dialogue (ask_user / the /pp menu /
1153
+ // any interactive selectOption / the rate-limit fallback switch) is
1154
+ // user-gated, not wedged — do not abort it out from under the user.
1155
+ if (orchestrator.interactivePromptOpen || orchestrator.subFallbackDialogPending) return;
1156
+ const staleMs = orchestrator.config.performance.internals.mainTurnStale;
1157
+ if (Date.now() - orchestrator.mainTurnLastActivity <= staleMs) return;
1158
+
1159
+ orchestrator.mainTurnRecovering = true;
1160
+ const taskToken = orchestrator.activeTaskToken;
1161
+ const phase = orchestrator.active.state.phase;
1162
+ getLogger().warn({ s: "watchdog", phase, staleMs }, "main turn wedged — aborting and recovering");
1163
+ orchestrator.lastCtx?.ui?.notify?.(
1164
+ `Main turn stalled with no activity for ${Math.round(staleMs / 1000)}s — recovering.`,
1165
+ "warning",
1166
+ );
1167
+ try {
1168
+ orchestrator.transitionController.abortMainAgent(orchestrator.lastCtx?.abort?.bind(orchestrator.lastCtx));
1169
+ } catch {}
1170
+ orchestrator.mainTurnInFlight = false;
1171
+ orchestrator.sendUserMessageWhenIdle(
1172
+ `[PI-PI] The previous turn stalled without completing. Continue working on the current phase (${phase}).`,
1173
+ taskToken,
1174
+ );
1175
+ }, 30000);
1176
+ };
1177
+
1178
+ pi.on("turn_start", async (_event, ctx) => {
1179
+ if (ctx) orchestrator.lastCtx = ctx;
1180
+ orchestrator.mainTurnInFlight = true;
1181
+ orchestrator.mainTurnRecovering = false;
1182
+ markMainTurnActivity(orchestrator);
1183
+ startMainTurnWatchdog();
1184
+ });
1185
+
1054
1186
  // Expose the event-driven planner-completion check so initial plan-entry
1055
1187
  // spawns (in command-handlers / orchestrator) can wire it as their onSettled
1056
1188
  // safety net (the deleted poller's former role). checkPlannerCompletion is a
@@ -1168,7 +1300,12 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1168
1300
  if (data.description) {
1169
1301
  orchestrator.agentDescriptions.set(data.id, data.description);
1170
1302
  }
1303
+
1171
1304
  trackSubagentEvent(data, "created");
1305
+ // Nudge acceptance/suppression is driven from the root `Agent` tool-call observation
1306
+ // (main-initiated by construction), NOT here — this event also fires for lineage-less
1307
+ // nested spawns, which must not count as the main agent following a nudge.
1308
+
1172
1309
  startStaleAgentWatchdog();
1173
1310
  const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
1174
1311
  mgr?.refreshWidget?.(orchestrator.lastCtx?.ui);
@@ -1214,7 +1351,14 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1214
1351
 
1215
1352
  const failedPlannerVariants = [...orchestrator.failedPlannerVariants];
1216
1353
  const effectiveMode = getEffectiveMode(orchestrator.active.state);
1217
- if (effectiveMode === "autonomous" && failedPlannerVariants.length > 0) {
1354
+ // Do NOT auto-retry failed variants while a subscription-429 fallback decision
1355
+ // is in flight: re-spawning now would use the still-sub-routed model and
1356
+ // re-hit the limit. Once the user decides, the fallback nudge re-drives this.
1357
+ if (
1358
+ effectiveMode === "autonomous" &&
1359
+ failedPlannerVariants.length > 0 &&
1360
+ !orchestrator.subFallbackPendingDecision
1361
+ ) {
1218
1362
  const alreadyRetried = orchestrator.active.state.plannerFailureAutoRetried === true;
1219
1363
  if (!alreadyRetried) {
1220
1364
  const failedSet = new Set(failedPlannerVariants);
@@ -1386,7 +1530,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1386
1530
 
1387
1531
  const failedReviewerVariants = [...orchestrator.failedReviewerVariants];
1388
1532
  const effectiveMode = getEffectiveMode(orchestrator.active.state);
1389
- if (effectiveMode === "autonomous" && failedReviewerVariants.length > 0) {
1533
+ // See the planner path: suppress auto-retry while a sub-429 fallback decision
1534
+ // is pending so we don't re-spawn on the still-sub-routed model.
1535
+ if (
1536
+ effectiveMode === "autonomous" &&
1537
+ failedReviewerVariants.length > 0 &&
1538
+ !orchestrator.subFallbackPendingDecision
1539
+ ) {
1390
1540
  const cycle = orchestrator.active.state.reviewCycle;
1391
1541
  const phase = orchestrator.active.state.phase;
1392
1542
  const outputs = loadPhaseReviewOutputs(orchestrator.active.dir, phase, cycle.pass);
@@ -1641,6 +1791,23 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1641
1791
  orchestrator.abortAllSubagents();
1642
1792
  }
1643
1793
 
1794
+ // Subscription rate-limit (429) on a sub-routed subagent: offer the ONE
1795
+ // global switch-to-non-sub dialogue (never per-subagent). subagents:failed
1796
+ // carries the subagent's resolved model id.
1797
+ const failedModelId = typeof data.modelId === "string" ? data.modelId : undefined;
1798
+ const subRateLimited =
1799
+ isRateLimitError(data.error) &&
1800
+ isSubscriptionRouted(failedModelId) &&
1801
+ !orchestrator.subFallbackActive;
1802
+ if (subRateLimited) {
1803
+ // Set the decision-pending flag SYNCHRONOUSLY (before the completion checks
1804
+ // below) so the autonomous planner/reviewer auto-retry does NOT re-spawn
1805
+ // the failed variant on the still-sub-routed model while the fallback
1806
+ // dialogue is in flight.
1807
+ orchestrator.subFallbackPendingDecision = true;
1808
+ void handleSubagentRateLimit(orchestrator, orchestrator.lastCtx, failedModelId);
1809
+ }
1810
+
1644
1811
  orchestrator.transitionController.sendCustom(
1645
1812
  {
1646
1813
  customType: "pp-subagent-error",
@@ -1725,6 +1892,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1725
1892
  (globalThis as any)[USAGE_TRACKER_KEY] = tracker;
1726
1893
  setFooterContext(ctx);
1727
1894
  setFooterTracker(tracker);
1895
+ setFooterOrchestrator(orchestrator);
1728
1896
  ctx.ui.setFooter(createCustomFooter);
1729
1897
  }
1730
1898
 
@@ -1810,6 +1978,30 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1810
1978
  if (orchestrator.transitionController.gateAgentStart(() => ctx.abort())) {
1811
1979
  return;
1812
1980
  }
1981
+
1982
+ // Stale continuation-nudge guard. A `[PI-PI] Continue the <phase> phase…`
1983
+ // nudge is a followUp queued at turn_end; by the time it is delivered the
1984
+ // phase may have advanced (plan→implement) or the task may have changed
1985
+ // (old plan-task → new plan-task). Re-validate against the phase/token
1986
+ // captured at generation time and abort the turn on any mismatch — a stale
1987
+ // nudge is not genuine user re-engagement, so this runs BEFORE the no-active
1988
+ // early return and does NOT clear the nudge/error guards. Checked only for
1989
+ // the tracked "Continue the <phase> phase" shape; other [PI-PI] handoffs are
1990
+ // untouched.
1991
+ const nudgeMeta = orchestrator.pendingNudges.get(event.prompt ?? "");
1992
+ if (nudgeMeta !== undefined) {
1993
+ orchestrator.pendingNudges.delete(event.prompt ?? "");
1994
+ const livePhase = orchestrator.active?.state.phase;
1995
+ if (!orchestrator.active || nudgeMeta.phase !== livePhase || nudgeMeta.taskToken !== orchestrator.activeTaskToken) {
1996
+ getLogger().debug(
1997
+ { s: "hook", hook: "before_agent_start", nudgePhase: nudgeMeta.phase, livePhase, nudgeToken: nudgeMeta.taskToken, liveToken: orchestrator.activeTaskToken },
1998
+ "dropping stale continuation nudge",
1999
+ );
2000
+ ctx.abort();
2001
+ return;
2002
+ }
2003
+ }
2004
+
1813
2005
  if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
1814
2006
 
1815
2007
  // Clear the nudge guard ONLY on a genuine user re-engagement. Controller-
@@ -1821,6 +2013,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1821
2013
  if (!isControllerInjected) {
1822
2014
  orchestrator.nudgeHalted = false;
1823
2015
  orchestrator.consecutiveNudges = 0;
2016
+ // Genuine user re-engagement also clears the API-error retry halt, so a
2017
+ // fresh request can auto-retry again from a clean counter.
2018
+ orchestrator.errorNudgeHalted = false;
2019
+ orchestrator.errorRetryCount = 0;
1824
2020
  }
1825
2021
  orchestrator.updateStatus(ctx);
1826
2022
 
@@ -1839,6 +2035,11 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1839
2035
  const projectContext = systemSnippets
1840
2036
  ? ["<project_context>", systemSnippets, "</project_context>"].join("\n")
1841
2037
  : "";
2038
+ const agentsMdPath = join(orchestrator.cwd, "AGENTS.md");
2039
+ const agentsMd =
2040
+ orchestrator.config.general.injectAgentsMd && existsSync(agentsMdPath)
2041
+ ? [`<agents_md source="${agentsMdPath}">`, readFileSync(agentsMdPath, "utf-8"), "</agents_md>"].join("\n")
2042
+ : "";
1842
2043
  const checklistLine =
1843
2044
  phase === "implement" ? "Keep the plan checklist current: mark each item done (- [ ] → - [x]) as you complete it." : "";
1844
2045
  const taskBlock = [
@@ -1855,7 +2056,9 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1855
2056
  constraintsBlock(phase as Phase, effectiveMode),
1856
2057
  PRINCIPLES_BLOCK,
1857
2058
  TOOLS_BLOCK,
2059
+ DELEGATION_BLOCK,
1858
2060
  projectContext,
2061
+ agentsMd,
1859
2062
  taskBlock,
1860
2063
  ]
1861
2064
  .filter(Boolean)
@@ -1867,6 +2070,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1867
2070
  });
1868
2071
 
1869
2072
  pi.on("tool_call", async (event, _ctx) => {
2073
+ markMainTurnActivity(orchestrator);
1870
2074
  getLogger().debug({ s: "hook", hook: "tool_call", tool: event.toolName }, "tool call");
1871
2075
  if (event.toolName === "ask_user" && orchestrator.active) {
1872
2076
  if (getEffectivePhaseMode(orchestrator.active.state) === "autonomous") {
@@ -1877,24 +2081,29 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1877
2081
  if (event.toolName === "Agent" && orchestrator.active) {
1878
2082
  const input = event.input as Record<string, unknown>;
1879
2083
  const requestedType = ((input.subagent_type as string) || "").toLowerCase();
2084
+ const validTypes = ["explore", "librarian", "task", "advisor", "deep-debugger", "reviewer"];
1880
2085
  if (!requestedType) {
1881
- return { block: true, reason: "subagent_type is required. Use Explore for codebase research, Librarian for external docs, or Task for implementation subtasks." };
2086
+ return { block: true, reason: "subagent_type is required. Valid types: explore (codebase research), librarian (external docs), task (implementation subtask), advisor (design/'why is this broken' judgment), deep-debugger (hard persistent failures), reviewer (code review — only when the user explicitly asks)." };
1882
2087
  }
1883
- const isExplore = requestedType === "explore";
1884
- const isLibrarian = requestedType === "librarian";
1885
-
1886
- if (isExplore) {
1887
- input.subagent_type = "explore";
1888
- input.model = resolveModel(orchestrator.config.agents.subagents.simple.explore.model);
1889
- input.thinking = orchestrator.config.agents.subagents.simple.explore.thinking;
1890
- } else if (isLibrarian) {
1891
- input.subagent_type = "librarian";
1892
- input.model = resolveModel(orchestrator.config.agents.subagents.simple.librarian.model);
1893
- input.thinking = orchestrator.config.agents.subagents.simple.librarian.thinking;
1894
- } else {
1895
- input.subagent_type = "task";
1896
- input.model = resolveModel(orchestrator.config.agents.subagents.simple.task.model);
1897
- input.thinking = orchestrator.config.agents.subagents.simple.task.thinking;
2088
+ if (!validTypes.includes(requestedType)) {
2089
+ return { block: true, reason: `Unknown subagent_type "${requestedType}". Valid types: ${validTypes.join(", ")}.` };
2090
+ }
2091
+
2092
+ const simple = orchestrator.config.agents.subagents.simple;
2093
+ const role = requestedType as keyof typeof simple;
2094
+ input.subagent_type = requestedType;
2095
+ input.model = resolveModel(simple[role].model);
2096
+ input.thinking = simple[role].thinking;
2097
+
2098
+ // Spawn-time context injection: append USER_REQUEST + RESEARCH content and a
2099
+ // path-aware manifest of on-demand documents to the LLM-supplied spawn prompt.
2100
+ // explore/librarian intentionally get nothing (fast, scoped retrieval).
2101
+ if (["task", "advisor", "deep-debugger", "reviewer"].includes(requestedType)) {
2102
+ const contextBlock = buildSpawnContextBlock(orchestrator.active.dir);
2103
+ if (contextBlock) {
2104
+ const existingPrompt = typeof input.prompt === "string" ? input.prompt : "";
2105
+ input.prompt = existingPrompt ? `${existingPrompt}\n\n${contextBlock}` : contextBlock;
2106
+ }
1898
2107
  }
1899
2108
  }
1900
2109
 
@@ -1919,11 +2128,49 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1919
2128
  if (fileName === "config.json" && isPathInside(ppDir, resolvedPath)) {
1920
2129
  return { block: true, reason: "config.json is managed by the user, not the LLM" };
1921
2130
  }
2131
+
2132
+ // Review-phase read-only exception is scoped to AI_COMMENT markers: the main
2133
+ // agent may only insert/remove `AI_COMMENT:` markers in source files. Enforce
2134
+ // it at the gate (the constraint is otherwise prompt-level only): a source
2135
+ // write/edit is allowed only when it changes nothing but AI_COMMENT markers.
2136
+ // Publishing findings as file comments goes through this path.
2137
+ if (
2138
+ orchestrator.active?.state.phase === "review" &&
2139
+ !isPathInside(ppDir, resolvedPath)
2140
+ ) {
2141
+ const denial = {
2142
+ block: true as const,
2143
+ reason: "Review phase is read-only except for inserting/removing AI_COMMENT: markers. This edit changes non-AI_COMMENT content — record the finding instead of applying a fix.",
2144
+ };
2145
+ if (event.toolName === "edit") {
2146
+ const edits = Array.isArray((event.input as any)?.edits) ? (event.input as any).edits : [];
2147
+ const legacy = event.input as any;
2148
+ const allEdits = [
2149
+ ...edits,
2150
+ ...(typeof legacy?.oldText === "string" && typeof legacy?.newText === "string" ? [{ oldText: legacy.oldText, newText: legacy.newText }] : []),
2151
+ ];
2152
+ for (const e of allEdits) {
2153
+ if (typeof e?.oldText !== "string" || typeof e?.newText !== "string") continue;
2154
+ if (!isAiCommentOnlyChange(e.oldText, e.newText)) return denial;
2155
+ }
2156
+ } else {
2157
+ // Anchoring inserts markers into EXISTING code; creating a new source
2158
+ // file is never a valid AI_COMMENT-only operation.
2159
+ if (!existsSync(resolvedPath)) return denial;
2160
+ const newContent = typeof (event.input as any)?.content === "string" ? (event.input as any).content : "";
2161
+ let before = "";
2162
+ try {
2163
+ before = readFileSync(resolvedPath, "utf-8");
2164
+ } catch {}
2165
+ if (!isAiCommentOnlyChange(before, newContent)) return denial;
2166
+ }
2167
+ }
1922
2168
  }
1923
2169
  return;
1924
2170
  });
1925
2171
 
1926
2172
  pi.on("tool_result", async (event, ctx) => {
2173
+ markMainTurnActivity(orchestrator);
1927
2174
  // ESC in an ask_user dialogue must stop the LLM's turn (treat ESC as
1928
2175
  // "stop, I want to type"). Only a deliberate user cancel aborts — timeout
1929
2176
  // and programmatic signal-aborts carry a non-"user" reason and must not.
@@ -1992,6 +2239,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1992
2239
  orchestrator.active.modifiedFiles.add(resolvedWrite);
1993
2240
  orchestrator.active.state.modifiedFiles = [...orchestrator.active.modifiedFiles];
1994
2241
  orchestrator.active.state.reviewApprovedClean = false;
2242
+ // New implement edits invalidate a prior afterImplement run (e.g. fixes made
2243
+ // via the guided menu after the autonomous terminal handoff), so the hooks
2244
+ // re-run and validate the final code on the next completion.
2245
+ orchestrator.active.state.afterImplementRan = false;
1995
2246
  try { saveTask(orchestrator.active.dir, orchestrator.active.state); } catch {}
1996
2247
 
1997
2248
  const repos = orchestrator.active.state.repos ?? [];
@@ -2052,10 +2303,21 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
2052
2303
  // so this works whether or not `active` is still set (done cleanup nulls it).
2053
2304
  if (transitioning) {
2054
2305
  const summary = orchestrator.transitionController.currentSummary() || "Phase transition in progress.";
2306
+ // Task-boundary discard: keep NO verbatim transcript beyond the summary so
2307
+ // the finished/replaced task cannot leak into the next task. Point
2308
+ // firstKeptEntryId at the newest branch entry (nothing before it is kept
2309
+ // verbatim). Phase transitions keep the default recent window so
2310
+ // legitimate context carries forward.
2311
+ const branchEntries = (event as any).branchEntries as Array<{ id: string }> | undefined;
2312
+ const lastEntryId = branchEntries && branchEntries.length > 0 ? branchEntries[branchEntries.length - 1].id : undefined;
2313
+ const firstKeptEntryId =
2314
+ orchestrator.transitionController.isDiscardTransition() && lastEntryId
2315
+ ? lastEntryId
2316
+ : event.preparation.firstKeptEntryId;
2055
2317
  return {
2056
2318
  compaction: {
2057
2319
  summary,
2058
- firstKeptEntryId: event.preparation.firstKeptEntryId,
2320
+ firstKeptEntryId,
2059
2321
  tokensBefore: event.preparation.tokensBefore,
2060
2322
  },
2061
2323
  };
@@ -2085,6 +2347,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
2085
2347
  });
2086
2348
 
2087
2349
  pi.on("turn_end", async (event, ctx) => {
2350
+ // The turn produced a terminal event — it is no longer wedged. If the SDK
2351
+ // auto-retries a retryable error it will emit a fresh turn_start, which
2352
+ // re-arms the watchdog.
2353
+ endMainTurn(orchestrator);
2088
2354
  const msg = event.message as any;
2089
2355
  const usageTracker = getUsageTracker();
2090
2356
  if (usageTracker && msg?.usage) {
@@ -2146,6 +2412,39 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
2146
2412
  contentBlocks: msg.content?.length ?? 0,
2147
2413
  contentSummary,
2148
2414
  }, "turn ended with error");
2415
+ // Subscription rate-limit (429) on a sub-routed main turn: retrying the
2416
+ // same sub model is futile against an account-level limit. Offer a
2417
+ // user-gated switch to non-sub Claude instead of the generic backoff.
2418
+ const activeModelId = (typeof msg.model === "string" && msg.model) || ctx.model?.id;
2419
+ const activeProvider = (typeof msg.provider === "string" && msg.provider) || ctx.model?.provider;
2420
+ if (
2421
+ isRateLimitError(errorMsg) &&
2422
+ isSubscriptionRouted(activeModelId, activeProvider) &&
2423
+ !orchestrator.subFallbackActive
2424
+ ) {
2425
+ void handleMainRateLimit(orchestrator, ctx, activeModelId, activeProvider);
2426
+ return;
2427
+ }
2428
+ // The SDK already auto-retries this class of error itself (abortable
2429
+ // backoff bound to ESC, continuing the SAME turn). Running pi-pi's OWN
2430
+ // independent retry on top would double-fire: its followUp races the SDK's
2431
+ // continue() into "Agent is already processing", and it re-nudges a still-
2432
+ // failing model. So for SDK-retryable errors we defer entirely to the SDK
2433
+ // and do nothing here. pi-pi's own idle-gated retry remains only as the
2434
+ // fallback for errors the SDK does NOT retry.
2435
+ if (isSdkRetryableError(errorMsg)) {
2436
+ getLogger().debug({ s: "turn", err: errorMsg }, "deferring to SDK auto-retry; pi-pi retry skipped");
2437
+ return;
2438
+ }
2439
+ // Halt guard: once the consecutive-error cap is exceeded we stop auto-
2440
+ // retrying until the user re-engages. errorRetryCount is NO LONGER reset on
2441
+ // benign intervening turns (see below) — otherwise a retried turn that ends
2442
+ // as a harmless text-only reply would reset the counter and the cap could
2443
+ // never accumulate, letting transient errors nudge unbounded.
2444
+ if (orchestrator.errorNudgeHalted) {
2445
+ getLogger().debug({ s: "turn", err: errorMsg }, "error auto-retry halted; awaiting user re-engagement");
2446
+ return;
2447
+ }
2149
2448
  orchestrator.errorRetryCount = (orchestrator.errorRetryCount ?? 0) + 1;
2150
2449
  const maxRetries = 5;
2151
2450
  if (orchestrator.errorRetryCount <= maxRetries) {
@@ -2153,18 +2452,44 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
2153
2452
  ctx.ui.notify(`API error (attempt ${orchestrator.errorRetryCount}/${maxRetries}): ${errorMsg}. Retrying in ${delay / 1000}s...`, "warning");
2154
2453
  const taskToken = orchestrator.activeTaskToken;
2155
2454
  if (orchestrator.pendingRetryTimer) clearTimeout(orchestrator.pendingRetryTimer);
2455
+ // Arm a direct ESC interrupt for this retry window — no SDK/interactive
2456
+ // binding covers pi-pi's own timer (the turn already ended in error).
2457
+ orchestrator.armRetryEscInterrupt(ctx);
2156
2458
  orchestrator.pendingRetryTimer = setTimeout(() => {
2157
2459
  orchestrator.pendingRetryTimer = null;
2158
- if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active) return;
2159
- orchestrator.safeSendUserMessage(`[PI-PI] Previous request failed due to an API error. Continue working on the current phase (${phase}).`);
2460
+ if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active) {
2461
+ orchestrator.disarmRetryEscInterrupt();
2462
+ return;
2463
+ }
2464
+ // Defer until the main session is idle: sending a followUp while the SDK
2465
+ // still has an active run throws an async, runtime-swallowed "Agent is
2466
+ // already processing" error. sendUserMessageWhenIdle polls (reusing
2467
+ // pendingRetryTimer so ESC/abort still cancels) and disarms the ESC hook
2468
+ // once delivered.
2469
+ orchestrator.sendUserMessageWhenIdle(
2470
+ `[PI-PI] Previous request failed due to an API error. Continue working on the current phase (${phase}).`,
2471
+ taskToken,
2472
+ );
2160
2473
  }, delay);
2161
2474
  } else {
2162
- ctx.ui.notify(`API error persisted after ${maxRetries} retries: ${errorMsg}. Stopping auto-retry.`, "error");
2163
- orchestrator.errorRetryCount = 0;
2475
+ ctx.ui.notify(`API error persisted after ${maxRetries} retries: ${errorMsg}. Stopping auto-retry — send any message to resume.`, "error");
2476
+ // Halt (do NOT reset the counter) so no further error turn re-arms a retry
2477
+ // until the user re-engages. cancelPendingRetry would reset the count and
2478
+ // re-open the floodgate, so only clear the live timer/ESC hook here.
2479
+ orchestrator.errorNudgeHalted = true;
2480
+ if (orchestrator.pendingRetryTimer) {
2481
+ clearTimeout(orchestrator.pendingRetryTimer);
2482
+ orchestrator.pendingRetryTimer = null;
2483
+ }
2484
+ orchestrator.disarmRetryEscInterrupt();
2164
2485
  }
2165
2486
  return;
2166
2487
  }
2167
- orchestrator.errorRetryCount = 0;
2488
+ // NOTE: errorRetryCount is intentionally NOT reset here. Resetting on every
2489
+ // non-error turn let a benign nudge-induced turn zero the counter, defeating
2490
+ // the maxRetries cap and allowing unbounded error nudges. The counter is
2491
+ // reset only on genuine user re-engagement (before_agent_start) and on task
2492
+ // reset / cancelPendingRetry.
2168
2493
 
2169
2494
  if ((globalThis as any)[SUBAGENT_SESSION_KEY]) {
2170
2495
  return;
@@ -2229,6 +2554,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
2229
2554
  const nudge = isAutonomous
2230
2555
  ? `[PI-PI] Continue the ${phase} phase. ${phaseConstraint(phase as Phase)} If the phase's objectives are met, call pp_phase_complete now; otherwise call the next tool. Do NOT apologize or reply with text only — respond with a tool call.`
2231
2556
  : `[PI-PI] Continue the ${phase} phase where you left off.`;
2557
+ // Record the nudge's phase/task at generation time so a delivery that lands
2558
+ // after the phase advanced or the task changed can be dropped (the queued
2559
+ // followUp string itself carries no token).
2560
+ orchestrator.pendingNudges.set(nudge, { phase: phase as Phase, taskToken: orchestrator.activeTaskToken });
2232
2561
  orchestrator.safeSendUserMessage(nudge);
2233
2562
  });
2234
2563