@ilya-lesikov/pi-pi 0.5.0 → 0.6.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 (76) hide show
  1. package/3p/pi-ask-user/index.ts +65 -49
  2. package/3p/pi-subagents/src/agent-manager.ts +8 -0
  3. package/3p/pi-subagents/src/agent-runner.ts +112 -19
  4. package/3p/pi-subagents/src/index.ts +3 -0
  5. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
  6. package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
  7. package/extensions/orchestrator/agents/constraints.ts +55 -0
  8. package/extensions/orchestrator/agents/explore.ts +13 -13
  9. package/extensions/orchestrator/agents/librarian.ts +12 -9
  10. package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
  11. package/extensions/orchestrator/agents/planner.ts +28 -23
  12. package/extensions/orchestrator/agents/registry.ts +2 -1
  13. package/extensions/orchestrator/agents/repo-context.ts +11 -0
  14. package/extensions/orchestrator/agents/task.ts +14 -11
  15. package/extensions/orchestrator/agents/tool-routing.ts +17 -32
  16. package/extensions/orchestrator/ast-search.ts +2 -1
  17. package/extensions/orchestrator/cbm.test.ts +35 -0
  18. package/extensions/orchestrator/cbm.ts +43 -13
  19. package/extensions/orchestrator/command-handlers.test.ts +390 -19
  20. package/extensions/orchestrator/command-handlers.ts +68 -28
  21. package/extensions/orchestrator/commands.test.ts +255 -2
  22. package/extensions/orchestrator/commands.ts +108 -10
  23. package/extensions/orchestrator/config.test.ts +289 -68
  24. package/extensions/orchestrator/config.ts +630 -121
  25. package/extensions/orchestrator/context.test.ts +177 -10
  26. package/extensions/orchestrator/context.ts +115 -14
  27. package/extensions/orchestrator/custom-footer.ts +2 -1
  28. package/extensions/orchestrator/doctor.test.ts +559 -0
  29. package/extensions/orchestrator/doctor.ts +664 -0
  30. package/extensions/orchestrator/event-handlers.test.ts +84 -22
  31. package/extensions/orchestrator/event-handlers.ts +1177 -341
  32. package/extensions/orchestrator/exa.test.ts +46 -0
  33. package/extensions/orchestrator/exa.ts +16 -10
  34. package/extensions/orchestrator/flant-infra.test.ts +224 -0
  35. package/extensions/orchestrator/flant-infra.ts +110 -41
  36. package/extensions/orchestrator/index.ts +13 -2
  37. package/extensions/orchestrator/integration.test.ts +2866 -118
  38. package/extensions/orchestrator/log.test.ts +219 -0
  39. package/extensions/orchestrator/log.ts +153 -0
  40. package/extensions/orchestrator/model-registry.test.ts +238 -0
  41. package/extensions/orchestrator/model-registry.ts +282 -0
  42. package/extensions/orchestrator/model-version.test.ts +27 -0
  43. package/extensions/orchestrator/model-version.ts +19 -0
  44. package/extensions/orchestrator/orchestrator.test.ts +206 -56
  45. package/extensions/orchestrator/orchestrator.ts +287 -140
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -36
  48. package/extensions/orchestrator/phases/implementation.ts +7 -11
  49. package/extensions/orchestrator/phases/machine.test.ts +27 -8
  50. package/extensions/orchestrator/phases/machine.ts +13 -0
  51. package/extensions/orchestrator/phases/planning.ts +57 -31
  52. package/extensions/orchestrator/phases/review-task.ts +3 -7
  53. package/extensions/orchestrator/phases/review.ts +38 -39
  54. package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
  55. package/extensions/orchestrator/phases/verdict.test.ts +139 -0
  56. package/extensions/orchestrator/phases/verdict.ts +82 -0
  57. package/extensions/orchestrator/plannotator.test.ts +85 -0
  58. package/extensions/orchestrator/plannotator.ts +24 -6
  59. package/extensions/orchestrator/pp-menu.test.ts +134 -0
  60. package/extensions/orchestrator/pp-menu.ts +2557 -347
  61. package/extensions/orchestrator/repo-utils.test.ts +151 -0
  62. package/extensions/orchestrator/repo-utils.ts +67 -0
  63. package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
  64. package/extensions/orchestrator/spawn-cleanup.ts +35 -0
  65. package/extensions/orchestrator/state.test.ts +76 -6
  66. package/extensions/orchestrator/state.ts +89 -26
  67. package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
  68. package/extensions/orchestrator/test-helpers.ts +217 -0
  69. package/extensions/orchestrator/tracer.test.ts +132 -0
  70. package/extensions/orchestrator/tracer.ts +127 -0
  71. package/extensions/orchestrator/transition-controller.test.ts +207 -0
  72. package/extensions/orchestrator/transition-controller.ts +259 -0
  73. package/extensions/orchestrator/usage-tracker.test.ts +435 -0
  74. package/extensions/orchestrator/usage-tracker.ts +23 -2
  75. package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
  76. package/package.json +2 -1
@@ -1,53 +1,91 @@
1
- import { existsSync, readdirSync, readFileSync } from "fs";
2
- import { resolve, basename, join } from "path";
1
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "fs";
2
+ import { tmpdir } from "os";
3
+ import { resolve, basename, join, relative, dirname, isAbsolute, sep } from "path";
3
4
  import { validateUserRequest, validateResearch, validateArtifact } from "./validate-artifacts.js";
4
5
  import { Type } from "@sinclair/typebox";
5
- import { loadConfig } from "./config.js";
6
- import { runAfterEdit, autoCommit } from "./commands.js";
7
- import { taskName, getActiveTask, saveTask, appendTaskLog } from "./state.js";
6
+ import { loadConfig, resolvePreset } from "./config.js";
7
+ import { runAfterEdit, autoCommit, loadRepoAfterEditCommands } from "./commands.js";
8
+ import { taskName, getActiveTask, getEffectiveMode, getEffectivePhaseMode, saveTask, type Phase, type TaskMode } from "./state.js";
9
+ import { getLogger, initSessionLogger, addTaskDestination, setLogLevel, flushLogs } from "./log.js";
10
+ import { initTracer, finalizeTracer, getTracer } from "./tracer.js";
11
+ import { handleSpawnResult } from "./spawn-cleanup.js";
8
12
  import {
9
- loadContextFiles,
13
+ getContextDirs,
14
+ loadAllContextFiles,
10
15
  getPhaseArtifacts,
11
16
  getLatestSynthesizedPlan,
12
17
  loadBrainstormReviewOutputs,
13
18
  loadCodeReviewOutputs,
14
19
  loadPlanReviewOutputs,
15
20
  } from "./context.js";
16
- import { WORKING_PRINCIPLES, COMMUNICATION } from "./agents/tool-routing.js";
21
+ import { PRINCIPLES_BLOCK, TOOLS_BLOCK } from "./agents/tool-routing.js";
22
+ import { constraintsBlock, phaseConstraint } from "./agents/constraints.js";
17
23
  import { registerCbmTools } from "./cbm.js";
18
24
  import { registerExaTools } from "./exa.js";
19
25
  import { registerAstSearchTool } from "./ast-search.js";
20
26
  import { SUBAGENT_SESSION_KEY } from "./index.js";
27
+ import { registerCommandHandlers } from "./command-handlers.js";
21
28
  import { setExtensionOnlyMode, unregisterAgentDefinitions } from "./agents/registry.js";
29
+ import { resolveModel, getModelInfo, updateRegistryFromAvailableModels } from "./model-registry.js";
22
30
  import { spawnPlanners, spawnPlanReviewers } from "./phases/planning.js";
23
31
  import { spawnCodeReviewers } from "./phases/review.js";
24
32
  import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
33
+ import { reviewPassUnanimousApprove } from "./phases/verdict.js";
34
+ import { validateExitCriteria } from "./phases/machine.js";
25
35
  import { openPlannotator, waitForPlannotatorResult, cancelPendingPlannotatorWait } from "./plannotator.js";
26
- import { Orchestrator, deepReviewConfig, type ActiveTask } from "./orchestrator.js";
36
+ import { Orchestrator, type ActiveTask } from "./orchestrator.js";
27
37
  import { createCustomFooter, setFooterContext, setFooterTracker } from "./custom-footer.js";
28
38
  import { createUsageTracker, dumpUsageSummary, loadUsageSummary, type UsageTracker } from "./usage-tracker.js";
29
- import { askUser } from "../../3p/pi-ask-user/index.js";
39
+ import { askUser, isCancel } from "../../3p/pi-ask-user/index.js";
30
40
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
41
+ import { findRootRepo, normalizeRepoPath, resolveRepoForFile, type RepoInfo } from "./repo-utils.js";
31
42
 
32
43
  const USAGE_TRACKER_KEY = Symbol.for("pi-pi:usage-tracker");
33
44
 
34
- export async function detectDefaultBranch(pi: ExtensionAPI, cwd: string, config?: { diffBaseBranch?: string }): Promise<string> {
35
- if (config?.diffBaseBranch) return config.diffBaseBranch;
45
+ function isEnabled(value: { enabled?: boolean } | undefined): boolean {
46
+ return value?.enabled !== false;
47
+ }
48
+
49
+ function isPathInside(basePath: string, targetPath: string): boolean {
50
+ const rel = relative(basePath, targetPath);
51
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
52
+ }
53
+
54
+ export async function detectDefaultBranch(orchestrator: Orchestrator, repos: RepoInfo[], repoPath: string): Promise<string> {
55
+ const normalizedPath = normalizeRepoPath(repoPath);
56
+ const repo = repos.find((r) => r.path === normalizedPath);
57
+ if (repo?.baseBranch) return repo.baseBranch;
58
+
36
59
  try {
37
- const result = await pi.exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd, timeout: 5000 });
38
- if (result.code === 0 && result.stdout.trim()) {
39
- return "origin/" + result.stdout.trim().replace("refs/remotes/origin/", "");
60
+ const headRef = await orchestrator.pi.exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], {
61
+ cwd: normalizedPath,
62
+ timeout: 5000,
63
+ });
64
+ if (headRef.code === 0) {
65
+ const value = headRef.stdout.trim();
66
+ if (value.startsWith("refs/remotes/")) {
67
+ return value.slice("refs/remotes/".length);
68
+ }
40
69
  }
41
70
  } catch {}
71
+
42
72
  try {
43
- const result = await pi.exec("git", ["show-ref", "--verify", "refs/remotes/origin/main"], { cwd, timeout: 5000 });
44
- if (result.code === 0) return "origin/main";
73
+ const mainRef = await orchestrator.pi.exec("git", ["show-ref", "--verify", "--quiet", "refs/remotes/origin/main"], {
74
+ cwd: normalizedPath,
75
+ timeout: 5000,
76
+ });
77
+ if (mainRef.code === 0) return "origin/main";
45
78
  } catch {}
79
+
46
80
  try {
47
- const result = await pi.exec("git", ["show-ref", "--verify", "refs/remotes/origin/master"], { cwd, timeout: 5000 });
48
- if (result.code === 0) return "origin/master";
81
+ const masterRef = await orchestrator.pi.exec("git", ["show-ref", "--verify", "--quiet", "refs/remotes/origin/master"], {
82
+ cwd: normalizedPath,
83
+ timeout: 5000,
84
+ });
85
+ if (masterRef.code === 0) return "origin/master";
49
86
  } catch {}
50
- return "main";
87
+
88
+ return "origin/main";
51
89
  }
52
90
 
53
91
  export async function selectOption(ctx: any, question: string, options: string[]): Promise<string | undefined> {
@@ -58,11 +96,77 @@ export async function selectOption(ctx: any, question: string, options: string[]
58
96
  allowComment: false,
59
97
  allowMultiple: false,
60
98
  });
61
- if (!result || result.kind !== "selection") return undefined;
99
+ if (!result || isCancel(result) || result.kind !== "selection") return undefined;
62
100
  return result.selections[0];
63
101
  }
64
102
 
65
- function tryCompleteReviewCycle(orchestrator: Orchestrator): void {
103
+ function resolveReviewers(
104
+ orchestrator: Orchestrator,
105
+ phase: string,
106
+ presetName?: string,
107
+ ): Record<string, any> {
108
+ const group = phase === "brainstorm"
109
+ ? "brainstormReviewers"
110
+ : phase === "plan"
111
+ ? "planReviewers"
112
+ : "codeReviewers";
113
+ return resolvePreset(orchestrator.config, group, presetName);
114
+ }
115
+
116
+ function getDefaultReviewPresetName(orchestrator: Orchestrator, phase: string): string {
117
+ if (phase === "brainstorm") return orchestrator.config.agents.subagents.presetGroups.brainstormReviewers.default;
118
+ if (phase === "plan") return orchestrator.config.agents.subagents.presetGroups.planReviewers.default;
119
+ return orchestrator.config.agents.subagents.presetGroups.codeReviewers.default;
120
+ }
121
+
122
+ function normalizeStoredPlannerPresetName(orchestrator: Orchestrator): string {
123
+ const requestedName = orchestrator.active?.state.activePlannerPreset ?? orchestrator.config.agents.subagents.presetGroups.planners.default;
124
+ const plannerPresets = orchestrator.config.agents.subagents.presetGroups.planners.presets ?? {};
125
+ const exists = Object.prototype.hasOwnProperty.call(plannerPresets, requestedName);
126
+ const resolvedName = exists ? requestedName : (Object.keys(plannerPresets)[0] ?? requestedName);
127
+
128
+ if (orchestrator.active && orchestrator.active.state.activePlannerPreset !== resolvedName) {
129
+ orchestrator.active.state.activePlannerPreset = resolvedName;
130
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
131
+ }
132
+
133
+ if (!exists && resolvedName !== requestedName) {
134
+ orchestrator.lastCtx?.ui?.notify(
135
+ `Planner preset "${requestedName}" not found. Falling back to "${resolvedName}".`,
136
+ "warning",
137
+ );
138
+ }
139
+
140
+ return resolvedName;
141
+ }
142
+
143
+ function normalizeStoredReviewPresetName(orchestrator: Orchestrator, phase: string): string {
144
+ const group = phase === "brainstorm"
145
+ ? "brainstormReviewers"
146
+ : phase === "plan"
147
+ ? "planReviewers"
148
+ : "codeReviewers";
149
+ const requestedName = orchestrator.active?.state.activeReviewPreset ?? getDefaultReviewPresetName(orchestrator, phase);
150
+ const reviewPresets = orchestrator.config.agents.subagents.presetGroups[group].presets ?? {};
151
+ const exists = Object.prototype.hasOwnProperty.call(reviewPresets, requestedName);
152
+ const resolvedName = exists ? requestedName : (Object.keys(reviewPresets)[0] ?? requestedName);
153
+
154
+ if (orchestrator.active && orchestrator.active.state.activeReviewPreset !== resolvedName) {
155
+ orchestrator.active.state.activeReviewPreset = resolvedName;
156
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
157
+ }
158
+
159
+ if (!exists && resolvedName !== requestedName) {
160
+ orchestrator.lastCtx?.ui?.notify(
161
+ `Review preset "${requestedName}" not found. Falling back to "${resolvedName}".`,
162
+ "warning",
163
+ );
164
+ }
165
+
166
+ return resolvedName;
167
+ }
168
+
169
+ function tryCompleteReviewCycle(orchestrator: Orchestrator, spawnedReviewers?: number): void {
66
170
  if (
67
171
  !orchestrator.active?.state.reviewCycle ||
68
172
  orchestrator.active.state.reviewCycle.step !== "await_reviewers" ||
@@ -70,41 +174,60 @@ function tryCompleteReviewCycle(orchestrator: Orchestrator): void {
70
174
  orchestrator.pendingSubagentSpawns > 0
71
175
  ) return;
72
176
 
73
- if (orchestrator.reviewTransitionToken === orchestrator.activeTaskToken) return;
74
-
177
+ // Idempotent by state: the first call mutates reviewCycle.step away from
178
+ // "await_reviewers" (or nulls reviewCycle), so the guard above no-ops any
179
+ // subsequent caller. No separate dedup token needed.
75
180
  const cycle = orchestrator.active.state.reviewCycle;
76
181
  const phase = orchestrator.active.state.phase;
77
182
  const outputs = loadPhaseReviewOutputs(orchestrator.active.dir, phase, cycle.pass);
78
183
  const pi = orchestrator.pi;
79
184
 
80
- orchestrator.reviewTransitionToken = orchestrator.activeTaskToken;
185
+ if (spawnedReviewers === 0 && outputs.length === 0) {
186
+ orchestrator.active.state.reviewCycle = null;
187
+ orchestrator.active.state.step = "llm_work";
188
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
189
+ orchestrator.safeSendUserMessage("[PI-PI] No reviewer outputs were produced — nothing to review. Continue working.");
190
+ return;
191
+ }
192
+
81
193
  cycle.step = "apply_feedback";
82
194
  orchestrator.active.state.step = "apply_feedback";
83
195
  saveTask(orchestrator.active.dir, orchestrator.active.state);
84
196
 
85
197
  const rendered = outputs.length
86
198
  ? outputs.map((o) => `=== ${o.name} ===\n${o.content}`).join("\n\n")
87
- : "No reviewer outputs found. Review the implementation yourself and decide whether to approve or request changes.";
199
+ : "All reviewers failed to produce output. Review the work yourself and decide whether to approve or request changes.";
88
200
 
89
- pi.sendMessage(
201
+ orchestrator.transitionController.sendCustom(
90
202
  {
91
203
  customType: "pp-review-ready",
92
204
  content: `[PI-PI] Reviewer outputs are ready.\n\n${rendered}`,
93
205
  display: false,
94
206
  },
95
- { deliverAs: "followUp" },
207
+ "instruction",
96
208
  );
97
- orchestrator.safeSendUserMessage("[PI-PI] Review cycle is ready for apply_feedback. Read reviewer outputs and proceed.");
209
+ orchestrator.safeSendUserMessage(reviewReadyMessage(phase));
210
+ }
211
+
212
+ function reviewReadyMessage(phase: string): string {
213
+ if (phase === "brainstorm") {
214
+ return "[PI-PI] Review cycle is ready for apply_feedback. The reviewers assessed your artifacts (USER_REQUEST.md, RESEARCH.md, and artifacts/), not a code diff. Read their outputs and update those artifacts as needed.";
215
+ }
216
+ return "[PI-PI] Review cycle is ready for apply_feedback. Read reviewer outputs and proceed.";
98
217
  }
99
218
 
100
- export async function enterReviewCycle(orchestrator: Orchestrator, ctx: any, kind: "auto" | "auto-deep" | "plannotator") {
219
+ export async function enterReviewCycle(
220
+ orchestrator: Orchestrator,
221
+ ctx: any,
222
+ kind: "plannotator" | string,
223
+ ): Promise<string> {
101
224
  if (!orchestrator.active) return "No active task.";
102
225
  const pi = orchestrator.pi;
103
226
  const pass = orchestrator.active.state.reviewPass + 1;
104
- orchestrator.active.state.reviewCycle = { kind, step: "spawn_reviewers", pass };
105
- saveTask(orchestrator.active.dir, orchestrator.active.state);
106
227
 
107
228
  if (kind === "plannotator") {
229
+ orchestrator.active.state.reviewCycle = { kind: "plannotator", step: "spawn_reviewers", pass };
230
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
108
231
  const phase = orchestrator.active.state.phase;
109
232
  if (phase === "brainstorm") {
110
233
  orchestrator.active.state.reviewCycle = null;
@@ -160,13 +283,14 @@ export async function enterReviewCycle(orchestrator: Orchestrator, ctx: any, kin
160
283
  }
161
284
 
162
285
  const phase = orchestrator.active.state.phase;
163
- const config = kind === "auto-deep" ? deepReviewConfig(orchestrator.config) : orchestrator.config;
164
- const reviewers = phase === "brainstorm"
165
- ? config.brainstormReviewers
166
- : phase === "plan"
167
- ? config.planReviewers
168
- : config.codeReviewers;
169
- const enabledCount = Object.values(reviewers).filter((v) => v.enabled).length;
286
+ const presetName = kind || getDefaultReviewPresetName(orchestrator, phase);
287
+ orchestrator.active.state.reviewCycle = { kind: "auto", step: "spawn_reviewers", pass };
288
+ orchestrator.active.state.reviewerFailureAutoRetried = false;
289
+ orchestrator.active.state.activeReviewPreset = presetName;
290
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
291
+
292
+ const reviewers = resolveReviewers(orchestrator, phase, presetName);
293
+ const enabledCount = Object.values(reviewers).filter((v) => isEnabled(v)).length;
170
294
  if (enabledCount === 0) {
171
295
  orchestrator.active.state.reviewCycle = null;
172
296
  saveTask(orchestrator.active.dir, orchestrator.active.state);
@@ -174,41 +298,77 @@ export async function enterReviewCycle(orchestrator: Orchestrator, ctx: any, kin
174
298
  return `No ${label} reviewers enabled. Choose another option.`;
175
299
  }
176
300
 
177
- orchestrator.reviewTransitionToken = -1;
178
301
  orchestrator.pendingSubagentSpawns = enabledCount;
179
- const spawnFn = phase === "brainstorm"
180
- ? () => spawnBrainstormReviewers(pi, orchestrator.cwd, orchestrator.active!.dir, orchestrator.active!.taskId, config, pass)
181
- : phase === "plan"
182
- ? () => spawnPlanReviewers(pi, orchestrator.cwd, orchestrator.active!.dir, orchestrator.active!.taskId, config)
183
- : () => spawnCodeReviewers(pi, orchestrator.cwd, orchestrator.active!.dir, orchestrator.active!.taskId, config, pass);
184
- spawnFn().then((result) => {
185
- orchestrator.failedReviewerVariants = result.failedVariants;
186
- if (result.spawned === 0) orchestrator.pendingSubagentSpawns = 0;
187
- for (const id of result.agentIds ?? []) {
188
- orchestrator.spawnedAgentIds.delete(id);
189
- }
190
- orchestrator.pendingSubagentSpawns = 0;
191
- tryCompleteReviewCycle(orchestrator);
192
- }).catch((err) => {
193
- orchestrator.pendingSubagentSpawns = 0;
194
- tryCompleteReviewCycle(orchestrator);
195
- console.error(`[pi-pi] spawn reviewers failed (${phase}): ${err.message}`);
302
+ const spawnFn = phase === "brainstorm"
303
+ ? () => spawnBrainstormReviewers(
304
+ pi,
305
+ orchestrator.cwd,
306
+ orchestrator.active!.dir,
307
+ orchestrator.active!.taskId,
308
+ orchestrator.config,
309
+ pass,
310
+ orchestrator.transitionController.phaseSend,
311
+ reviewers,
312
+ orchestrator.active?.state.repos ?? [],
313
+ )
314
+ : phase === "plan"
315
+ ? () => spawnPlanReviewers(
316
+ pi,
317
+ orchestrator.cwd,
318
+ orchestrator.active!.dir,
319
+ orchestrator.active!.taskId,
320
+ orchestrator.config,
321
+ pass,
322
+ orchestrator.transitionController.phaseSend,
323
+ reviewers,
324
+ orchestrator.active?.state.repos ?? [],
325
+ )
326
+ : () => spawnCodeReviewers(
327
+ pi,
328
+ orchestrator.cwd,
329
+ orchestrator.active!.dir,
330
+ orchestrator.active!.taskId,
331
+ orchestrator.config,
332
+ pass,
333
+ phase,
334
+ orchestrator.transitionController.phaseSend,
335
+ reviewers,
336
+ orchestrator.active?.state.repos ?? [],
337
+ );
338
+ handleSpawnResult(orchestrator, spawnFn(), {
339
+ kind: "reviewer",
340
+ logScope: "review",
341
+ logMessage: "spawn reviewers failed",
342
+ logExtra: { phase },
343
+ onSettled: (result) => tryCompleteReviewCycle(orchestrator, result?.spawned),
196
344
  });
197
345
 
198
346
  orchestrator.active.state.reviewCycle.step = "await_reviewers";
199
347
  orchestrator.active.state.step = "await_reviewers";
200
348
  saveTask(orchestrator.active.dir, orchestrator.active.state);
201
- return `Started review cycle pass ${pass} (${kind}). Awaiting reviewers.`;
349
+ return `Started review cycle pass ${pass} (auto, preset: ${presetName}). Awaiting reviewers.`;
202
350
  }
203
351
 
204
352
  export async function stopTask(orchestrator: Orchestrator): Promise<string> {
205
353
  if (!orchestrator.active) return "No active task.";
206
354
  orchestrator.abortAllSubagents();
355
+ orchestrator.active.state.reviewCycle = null;
207
356
  saveTask(orchestrator.active.dir, orchestrator.active.state);
208
357
  const desc = orchestrator.active.description;
358
+ const type = orchestrator.active.type;
209
359
  await orchestrator.cleanupActive();
210
360
  const taskStore = (globalThis as any)[Symbol.for("pi-tasks:store")];
211
361
  taskStore?.clearAll?.();
362
+
363
+ // Route the stop/pause compaction through the controller as a "done" target.
364
+ // The controller supplies the summary to session_before_compact and its
365
+ // awaitable resolves at every terminus (session_compact, no-op skip,
366
+ // already-idle), so this await never hangs and never resolves early.
367
+ await orchestrator.transitionController.requestTransition({
368
+ kind: "done",
369
+ summary: `Task "${desc}" (${type}) stopped/paused.`,
370
+ });
371
+
212
372
  return `Task "${desc}" stopped. Use /pp → Resume to continue.`;
213
373
  }
214
374
 
@@ -217,19 +377,184 @@ export function finalizeReviewCycle(task: ActiveTask): void {
217
377
  const kind = task.state.reviewCycle.kind;
218
378
  task.state.reviewPass = task.state.reviewCycle.pass;
219
379
  task.reviewPass = task.state.reviewPass;
220
- if (!task.state.reviewPassByKind) task.state.reviewPassByKind = {};
221
- task.state.reviewPassByKind[kind] = (task.state.reviewPassByKind[kind] ?? 0) + 1;
380
+ incrementReviewPass(task, kind);
222
381
  task.state.reviewCycle = null;
223
382
  task.state.step = "user_gate";
224
383
  saveTask(task.dir, task.state);
225
384
  }
226
385
 
386
+ function incrementReviewPass(task: ActiveTask, kind: string): void {
387
+ if (!task.state.reviewPassByKind) task.state.reviewPassByKind = {};
388
+ const phase = task.state.phase;
389
+ if (!task.state.reviewPassByKind[phase]) task.state.reviewPassByKind[phase] = {};
390
+ task.state.reviewPassByKind[phase][kind] = (task.state.reviewPassByKind[phase][kind] ?? 0) + 1;
391
+ }
392
+
393
+ function completedReviewPasses(task: ActiveTask, kind: string): number {
394
+ return task.state.reviewPassByKind?.[task.state.phase]?.[kind] ?? 0;
395
+ }
396
+
397
+ export function finalizeReviewCycleAutonomous(task: ActiveTask): void {
398
+ if (!task.state.reviewCycle) return;
399
+ const kind = task.state.reviewCycle.kind;
400
+ task.state.reviewPass = task.state.reviewCycle.pass;
401
+ task.reviewPass = task.state.reviewPass;
402
+ incrementReviewPass(task, kind);
403
+ task.state.reviewCycle = null;
404
+ if (task.state.phase === "plan") {
405
+ task.state.step = "synthesize";
406
+ } else {
407
+ task.state.step = "llm_work";
408
+ }
409
+ saveTask(task.dir, task.state);
410
+ }
411
+
227
412
  function registerOrchestratorTools(orchestrator: Orchestrator): void {
413
+ registerRepoTool(orchestrator);
228
414
  registerPhaseCompleteTool(orchestrator);
229
415
  registerCommitTool(orchestrator);
230
416
  registerSpecifyReviewsTool(orchestrator);
231
417
  }
232
418
 
419
+ function registerRepoTool(orchestrator: Orchestrator): void {
420
+ const pi = orchestrator.pi;
421
+
422
+ pi.registerTool({
423
+ name: "pp_register_repo",
424
+ label: "pi-pi",
425
+ description:
426
+ "Register a git repository you're working in. Call this for every repo " +
427
+ "including the root directory at the start of each task. Pass the base " +
428
+ "branch — the branch this work will be merged into (e.g. origin/main, origin/develop).",
429
+ parameters: Type.Object({
430
+ path: Type.String({ description: "Absolute path to the git repository (or any path inside it)" }),
431
+ baseBranch: Type.Optional(Type.String({ description: "Base branch for this repo (e.g. origin/main)" })),
432
+ }),
433
+ async execute(_toolCallId, params: any) {
434
+ if (!orchestrator.active) {
435
+ return { content: [{ type: "text" as const, text: "No active task." }], isError: true as const, details: {} };
436
+ }
437
+ getLogger().debug({ s: "tool", tool: "pp_register_repo", path: params.path, baseBranch: params.baseBranch }, "register repo called");
438
+
439
+ const pathInput = typeof params.path === "string" ? params.path.trim() : "";
440
+ if (!pathInput) {
441
+ return { content: [{ type: "text" as const, text: "Missing path." }], isError: true as const, details: {} };
442
+ }
443
+
444
+ const normalizedInputPath = normalizeRepoPath(pathInput);
445
+ let gitCwd = normalizedInputPath;
446
+ try {
447
+ gitCwd = statSync(normalizedInputPath).isDirectory() ? normalizedInputPath : dirname(normalizedInputPath);
448
+ } catch {
449
+ gitCwd = dirname(normalizedInputPath);
450
+ }
451
+
452
+ let gitRoot = "";
453
+ try {
454
+ const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: gitCwd, timeout: 5000 });
455
+ if (result.code !== 0) {
456
+ return { content: [{ type: "text" as const, text: "Not a git repository." }], isError: true as const, details: {} };
457
+ }
458
+ gitRoot = result.stdout.trim();
459
+ } catch {
460
+ return { content: [{ type: "text" as const, text: "Not a git repository." }], isError: true as const, details: {} };
461
+ }
462
+
463
+ if (!gitRoot) {
464
+ return { content: [{ type: "text" as const, text: "Not a git repository." }], isError: true as const, details: {} };
465
+ }
466
+
467
+ const normalizedRepo = normalizeRepoPath(gitRoot);
468
+ const normalizedRoot = normalizeRepoPath(orchestrator.cwd);
469
+ const hadRepos = Array.isArray(orchestrator.active.state.repos);
470
+ const repos = orchestrator.active.state.repos ?? [{ path: normalizedRoot, isRoot: true }];
471
+ const baseBranch = typeof params.baseBranch === "string" && params.baseBranch.trim().length > 0
472
+ ? params.baseBranch.trim()
473
+ : undefined;
474
+ const isRoot = normalizedRepo === normalizedRoot;
475
+
476
+ let added = false;
477
+ let changed = !hadRepos;
478
+
479
+ if (isRoot) {
480
+ const existingRootIdx = repos.findIndex((repo) => repo.isRoot);
481
+ const existingByPathIdx = repos.findIndex((repo) => repo.path === normalizedRepo);
482
+
483
+ if (existingRootIdx >= 0) {
484
+ const existingRoot = repos[existingRootIdx];
485
+ if (existingRoot.path !== normalizedRepo) {
486
+ existingRoot.path = normalizedRepo;
487
+ changed = true;
488
+ }
489
+ if (!existingRoot.isRoot) {
490
+ existingRoot.isRoot = true;
491
+ changed = true;
492
+ }
493
+ if (baseBranch && existingRoot.baseBranch !== baseBranch) {
494
+ existingRoot.baseBranch = baseBranch;
495
+ changed = true;
496
+ }
497
+ if (existingByPathIdx >= 0 && existingByPathIdx !== existingRootIdx) {
498
+ repos.splice(existingByPathIdx, 1);
499
+ changed = true;
500
+ }
501
+ } else if (existingByPathIdx >= 0) {
502
+ const existing = repos[existingByPathIdx];
503
+ if (!existing.isRoot) {
504
+ existing.isRoot = true;
505
+ changed = true;
506
+ }
507
+ if (baseBranch && existing.baseBranch !== baseBranch) {
508
+ existing.baseBranch = baseBranch;
509
+ changed = true;
510
+ }
511
+ } else {
512
+ repos.push({ path: normalizedRepo, isRoot: true, ...(baseBranch ? { baseBranch } : {}) });
513
+ added = true;
514
+ changed = true;
515
+ }
516
+ } else {
517
+ const existingByPathIdx = repos.findIndex((repo) => repo.path === normalizedRepo);
518
+ if (existingByPathIdx >= 0) {
519
+ const existing = repos[existingByPathIdx];
520
+ if (baseBranch && existing.baseBranch !== baseBranch) {
521
+ existing.baseBranch = baseBranch;
522
+ changed = true;
523
+ }
524
+ } else {
525
+ repos.push({ path: normalizedRepo, isRoot: false, ...(baseBranch ? { baseBranch } : {}) });
526
+ added = true;
527
+ changed = true;
528
+ }
529
+ }
530
+
531
+ orchestrator.active.state.repos = repos;
532
+ if (changed) {
533
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
534
+ }
535
+
536
+ if (added) {
537
+ unregisterAgentDefinitions(orchestrator.pi);
538
+ orchestrator.registerAgents();
539
+ }
540
+
541
+ const registered = repos.find((repo) => repo.path === normalizedRepo) ?? {
542
+ path: normalizedRepo,
543
+ isRoot,
544
+ ...(baseBranch ? { baseBranch } : {}),
545
+ };
546
+ const rootLabel = registered.isRoot ? " (root)" : "";
547
+ const baseLabel = registered.baseBranch ? `, base: ${registered.baseBranch}` : "";
548
+ const action = added ? "Registered" : changed ? "Updated" : "Already registered";
549
+
550
+ return {
551
+ content: [{ type: "text" as const, text: `${action} repository: ${registered.path}${rootLabel}${baseLabel}` }],
552
+ details: {},
553
+ };
554
+ },
555
+ });
556
+ }
557
+
233
558
  function openCodeReviewDirect(
234
559
  pi: ExtensionAPI,
235
560
  payload: Record<string, unknown>,
@@ -259,7 +584,8 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
259
584
  description:
260
585
  "Specify which repositories and commit ranges to open in Plannotator for code review. " +
261
586
  "Called when the user requests a Plannotator code review. " +
262
- "Plannotator will open sequentially for each entry. Results are returned after all reviews complete.",
587
+ "Plannotator will open sequentially for each entry. Results are returned after all reviews complete. " +
588
+ "Range supports both 'base..HEAD' and 'base..target' (non-HEAD target is reviewed via temporary worktree checkout).",
263
589
  parameters: Type.Object({
264
590
  reviews: Type.Array(Type.Object({
265
591
  cwd: Type.String({ description: "Absolute path to the git repository" }),
@@ -267,6 +593,9 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
267
593
  })),
268
594
  }),
269
595
  async execute(_toolCallId, params: any, _signal, _onUpdate, ctx) {
596
+ if (orchestrator.active && getEffectiveMode(orchestrator.active.state) === "autonomous") {
597
+ 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: {} };
598
+ }
270
599
  if (!params.reviews || params.reviews.length === 0) {
271
600
  return { content: [{ type: "text" as const, text: "No reviews specified." }], isError: true as const, details: {} };
272
601
  }
@@ -275,12 +604,89 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
275
604
  let hasNeedsChanges = false;
276
605
  for (const review of params.reviews) {
277
606
  ctx.ui?.setWorkingMessage?.(`Waiting for Plannotator review: ${review.range}…`);
278
- const rangeBase = review.range.includes("..") ? review.range.split("..")[0] : review.range;
607
+ const range = String(review.range ?? "").trim();
608
+ let compareBase = range;
609
+ let compareTarget = "HEAD";
610
+ const rangeSeparatorIdx = range.indexOf("..");
611
+ if (rangeSeparatorIdx >= 0) {
612
+ compareBase = range.slice(0, rangeSeparatorIdx).trim();
613
+ compareTarget = range.slice(rangeSeparatorIdx + 2).trim() || "HEAD";
614
+ }
615
+
616
+ if (!compareBase) {
617
+ results.push(`${review.cwd} (${review.range}): Invalid range (missing base).`);
618
+ continue;
619
+ }
620
+
621
+ let reviewCwd = review.cwd;
622
+ let tempWorktreePath: string | null = null;
623
+ let tempWorktreeParent: string | null = null;
624
+ let setupError: string | null = null;
625
+
626
+ if (compareTarget !== "HEAD") {
627
+ tempWorktreeParent = mkdtempSync(join(tmpdir(), "pi-pi-review-worktree-"));
628
+ tempWorktreePath = join(tempWorktreeParent, "checkout");
629
+ try {
630
+ const addResult = await pi.exec("git", ["worktree", "add", "--detach", tempWorktreePath, compareTarget], {
631
+ cwd: review.cwd,
632
+ timeout: 20000,
633
+ });
634
+ if (addResult.code !== 0) {
635
+ setupError = addResult.stderr?.trim() || addResult.stdout?.trim() || "Failed to prepare temporary worktree";
636
+ } else {
637
+ reviewCwd = tempWorktreePath;
638
+ }
639
+ } catch (error: any) {
640
+ setupError = error?.message ?? String(error);
641
+ }
642
+ }
643
+
644
+ if (setupError) {
645
+ results.push(`${review.cwd} (${review.range}): ${setupError}`);
646
+ if (tempWorktreePath) {
647
+ try {
648
+ await pi.exec("git", ["worktree", "remove", "--force", tempWorktreePath], {
649
+ cwd: review.cwd,
650
+ timeout: 20000,
651
+ });
652
+ } catch {}
653
+ }
654
+ if (tempWorktreePath) {
655
+ try {
656
+ rmSync(tempWorktreePath, { recursive: true, force: true });
657
+ } catch {}
658
+ }
659
+ if (tempWorktreeParent) {
660
+ try {
661
+ rmSync(tempWorktreeParent, { recursive: true, force: true });
662
+ } catch {}
663
+ }
664
+ continue;
665
+ }
666
+
279
667
  const result = await openCodeReviewDirect(pi, {
280
- cwd: review.cwd,
668
+ cwd: reviewCwd,
281
669
  diffType: "branch",
282
- defaultBranch: rangeBase,
670
+ defaultBranch: compareBase,
283
671
  });
672
+
673
+ if (tempWorktreePath) {
674
+ try {
675
+ await pi.exec("git", ["worktree", "remove", "--force", tempWorktreePath], {
676
+ cwd: review.cwd,
677
+ timeout: 20000,
678
+ });
679
+ } catch {}
680
+ try {
681
+ rmSync(tempWorktreePath, { recursive: true, force: true });
682
+ } catch {}
683
+ if (tempWorktreeParent) {
684
+ try {
685
+ rmSync(tempWorktreeParent, { recursive: true, force: true });
686
+ } catch {}
687
+ }
688
+ }
689
+
284
690
  if ("error" in result) {
285
691
  results.push(`${review.cwd} (${review.range}): ${result.error}`);
286
692
  } else {
@@ -309,7 +715,10 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
309
715
  try {
310
716
  const { showActiveTaskMenu } = await import("./pp-menu.js");
311
717
  const text = await showActiveTaskMenu(orchestrator, ctx, `Plannotator review complete.\n\n${summary}`, "tool");
312
- if (orchestrator.phaseCompactionPending || orchestrator.taskDoneCompactionPending) {
718
+ // A transition may have started while the menu was open. The controller
719
+ // is the source of truth; abort the agent's pending turn so it doesn't
720
+ // race the transition. (Interactive-UX abort — stays local, not routed.)
721
+ if (!orchestrator.transitionController.isRunning()) {
313
722
  ctx.abort?.();
314
723
  return { content: [{ type: "text" as const, text: "" }], details: {} };
315
724
  }
@@ -326,7 +735,7 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
326
735
 
327
736
  function loadPhaseReviewOutputs(taskDir: string, phase: string, pass: number): { name: string; content: string }[] {
328
737
  if (phase === "brainstorm") return loadBrainstormReviewOutputs(taskDir, pass);
329
- if (phase === "plan") return loadPlanReviewOutputs(taskDir);
738
+ if (phase === "plan") return loadPlanReviewOutputs(taskDir, pass);
330
739
  return loadCodeReviewOutputs(taskDir, pass);
331
740
  }
332
741
 
@@ -339,37 +748,65 @@ function registerCommitTool(orchestrator: Orchestrator): void {
339
748
  description:
340
749
  "Commit modified files with a descriptive message. Call after completing a logical " +
341
750
  "unit of work (e.g. implementing one plan item, fixing a bug, adding a test). " +
342
- "The message should describe WHAT changed and WHY, not list files.",
751
+ "The message should describe WHAT changed and WHY, not list files. " +
752
+ "Prefix the message with a conventional-commit type (fix:, feat:, or chore:) " +
753
+ "unless the user asked for a different commit style.",
343
754
  parameters: Type.Object({
344
755
  message: Type.String({ description: "Commit message describing the change (max 72 chars for first line)" }),
756
+ repo: Type.Optional(Type.String({ description: "Absolute path to the repo to commit in. Defaults to root." })),
345
757
  }),
346
758
  async execute(_toolCallId, params: any) {
347
759
  if (!orchestrator.active) {
348
760
  return { content: [{ type: "text" as const, text: "No active task." }], isError: true as const, details: {} };
349
761
  }
350
- if (!orchestrator.config.autoCommit) {
762
+ if (!orchestrator.config.general.autoCommit) {
351
763
  return { content: [{ type: "text" as const, text: "autoCommit is disabled in config." }], details: {} };
352
764
  }
765
+
766
+ const repos = orchestrator.active.state.repos ?? [];
767
+ const rootRepo = findRootRepo(repos);
768
+ const defaultRepoPath = rootRepo?.path ?? orchestrator.cwd;
769
+ let commitRepoPath = defaultRepoPath;
770
+ if (typeof params.repo === "string" && params.repo.trim().length > 0) {
771
+ const normalized = normalizeRepoPath(params.repo);
772
+ const registered = repos.find((repo) => repo.path === normalized);
773
+ if (!registered) {
774
+ return { content: [{ type: "text" as const, text: `Repository is not registered: ${params.repo}` }], isError: true as const, details: {} };
775
+ }
776
+ commitRepoPath = registered.path;
777
+ }
778
+
353
779
  const files: string[] = [];
354
780
  try {
355
- const gitResult = await pi.exec("git", ["diff", "--name-only"], { cwd: orchestrator.cwd, timeout: 5000 });
356
- if (gitResult.code === 0 && gitResult.stdout.trim()) {
357
- files.push(...gitResult.stdout.trim().split("\n").filter(Boolean));
358
- }
359
- const stagedResult = await pi.exec("git", ["diff", "--name-only", "--cached"], { cwd: orchestrator.cwd, timeout: 5000 });
360
- if (stagedResult.code === 0 && stagedResult.stdout.trim()) {
361
- for (const f of stagedResult.stdout.trim().split("\n").filter(Boolean)) {
362
- if (!files.includes(f)) files.push(f);
781
+ const statusResult = await pi.exec("git", ["status", "--porcelain"], { cwd: commitRepoPath, timeout: 5000 });
782
+ if (statusResult.code === 0 && statusResult.stdout.trim()) {
783
+ for (const rawLine of statusResult.stdout.split("\n")) {
784
+ const line = rawLine.trimEnd();
785
+ if (!line) continue;
786
+ const pathPart = line.slice(3);
787
+ if (!pathPart) continue;
788
+ const finalPath = pathPart.includes(" -> ") ? pathPart.split(" -> ").at(-1)! : pathPart;
789
+ if (!files.includes(finalPath)) {
790
+ files.push(finalPath);
791
+ }
363
792
  }
364
793
  }
365
794
  } catch {}
366
795
  if (files.length === 0) {
367
796
  return { content: [{ type: "text" as const, text: "No modified files to commit." }], details: {} };
368
797
  }
369
- const result = autoCommit(files, params.message, orchestrator.cwd);
798
+ const result = autoCommit(files, params.message, commitRepoPath);
370
799
  if (result.ok) {
371
- orchestrator.active.modifiedFiles.clear();
372
- orchestrator.active.state.modifiedFiles = [];
800
+ const remaining = [...orchestrator.active.modifiedFiles].filter((file) => {
801
+ const absoluteFile = resolve(orchestrator.cwd, file);
802
+ const repo = resolveRepoForFile(repos, absoluteFile);
803
+ return repo?.path !== commitRepoPath;
804
+ });
805
+ orchestrator.active.modifiedFiles = new Set(remaining);
806
+ orchestrator.active.state.modifiedFiles = [...orchestrator.active.modifiedFiles];
807
+ const committed = new Set(orchestrator.active.state.committedFiles ?? []);
808
+ for (const file of files) committed.add(file);
809
+ orchestrator.active.state.committedFiles = [...committed];
373
810
  saveTask(orchestrator.active.dir, orchestrator.active.state);
374
811
  orchestrator.commitReminderSent = false;
375
812
  return { content: [{ type: "text" as const, text: `Committed ${files.length} file(s): ${result.commitHash ?? "ok"}` }], details: {} };
@@ -403,11 +840,85 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
403
840
  const count = orchestrator.spawnedAgentIds.size + orchestrator.pendingSubagentSpawns;
404
841
  return { content: [{ type: "text" as const, text: `${count} subagent(s) still running. Wait for them to complete before calling pp_phase_complete.` }], isError: true as const, details: {} };
405
842
  }
843
+ const effectiveMode = getEffectiveMode(orchestrator.active.state);
844
+ if (effectiveMode === "autonomous") {
845
+ const phase = orchestrator.active.state.phase;
846
+ let justFinalizedReviewCycle = false;
847
+ if (orchestrator.active.state.reviewCycle?.step === "apply_feedback") {
848
+ const completedRound = orchestrator.active.state.reviewCycle.pass;
849
+ const completedPreset = normalizeStoredReviewPresetName(orchestrator, phase);
850
+ const enabledReviewerCount = Object.values(resolveReviewers(orchestrator, phase, completedPreset)).filter((v) => isEnabled(v)).length;
851
+ finalizeReviewCycleAutonomous(orchestrator.active);
852
+ justFinalizedReviewCycle = true;
853
+ if (reviewPassUnanimousApprove(orchestrator.active.dir, phase, completedRound, enabledReviewerCount)) {
854
+ orchestrator.active.state.reviewApprovedClean = true;
855
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
856
+ }
857
+ }
858
+ const phaseConfig = orchestrator.active.state.autonomousConfig?.phases?.[phase];
859
+ const reviewPreset = phaseConfig?.reviewPreset;
860
+ const maxReviewPasses = phaseConfig?.maxReviewPasses ?? 0;
861
+ const completedAutoPasses = orchestrator.active.state.reviewPassByKind?.[phase]?.auto ?? 0;
862
+
863
+ if (
864
+ justFinalizedReviewCycle &&
865
+ !orchestrator.active.state.reviewApprovedClean &&
866
+ reviewPreset &&
867
+ maxReviewPasses > 0 &&
868
+ completedAutoPasses < maxReviewPasses
869
+ ) {
870
+ return {
871
+ content: [{ type: "text" as const, text: `The review pass found changes to make. Apply the reviewers' required changes, then call pp_phase_complete again to re-review (pass ${completedAutoPasses + 1}/${maxReviewPasses >= 999 ? "∞" : maxReviewPasses}). Do NOT wait for the user and do NOT advance the phase.` }],
872
+ details: {},
873
+ };
874
+ }
875
+
876
+ if (
877
+ !justFinalizedReviewCycle &&
878
+ !orchestrator.active.state.reviewApprovedClean &&
879
+ reviewPreset &&
880
+ maxReviewPasses > 0 &&
881
+ completedAutoPasses < maxReviewPasses
882
+ ) {
883
+ const exitCheck = validateExitCriteria(orchestrator.active.dir, orchestrator.active.type, phase);
884
+ if (!exitCheck.ok) {
885
+ return {
886
+ content: [{ type: "text" as const, text: `Cannot start review yet: ${exitCheck.reason}\n\nFix this and call pp_phase_complete again. Do NOT wait for the user.` }],
887
+ details: {},
888
+ };
889
+ }
890
+ const reviewText = await enterReviewCycle(orchestrator, ctx, reviewPreset);
891
+ if (orchestrator.active?.state.step === "await_reviewers") {
892
+ return {
893
+ content: [{ type: "text" as const, text: `Reviews are running (${reviewPreset}, pass ${completedAutoPasses + 1}/${maxReviewPasses >= 999 ? "∞" : maxReviewPasses}). You will be notified automatically when the reviewer outputs are ready — then read them and proceed. Do NOT wait for the user and do NOT stop.` }],
894
+ details: {},
895
+ };
896
+ }
897
+ if (!reviewText.includes("No") || !reviewText.includes("reviewers enabled")) {
898
+ return { content: [{ type: "text" as const, text: reviewText }], details: {} };
899
+ }
900
+ }
901
+
902
+ const plannerPreset = orchestrator.active.state.autonomousConfig?.phases?.plan?.plannerPreset;
903
+ const result = await orchestrator.transitionToNextPhase(ctx, plannerPreset);
904
+ if (!result.ok) {
905
+ return { content: [{ type: "text" as const, text: `Transition blocked: ${result.error}` }], details: {} };
906
+ }
907
+ return { content: [{ type: "text" as const, text: "" }], details: {} };
908
+ }
406
909
  ctx.ui.setWorkingMessage?.("Waiting for user approval…");
407
910
  try {
408
911
  const { showActiveTaskMenu } = await import("./pp-menu.js");
409
912
  const text = await showActiveTaskMenu(orchestrator, ctx, params.summary, "tool");
410
- if (orchestrator.phaseCompactionPending || orchestrator.taskDoneCompactionPending) {
913
+ // A transition or await may have started while the menu was open. The
914
+ // controller is the source of truth; abort the pending turn so it can't
915
+ // race. (Interactive-UX abort — stays local, not routed.)
916
+ const curStep = orchestrator.active?.state.step;
917
+ if (curStep === "await_planners" || curStep === "await_reviewers") {
918
+ ctx.abort?.();
919
+ return { content: [{ type: "text" as const, text: `Waiting for ${curStep === "await_planners" ? "planners" : "reviewers"} to complete. Do NOT proceed until notified.` }], details: {} };
920
+ }
921
+ if (!orchestrator.transitionController.isRunning()) {
411
922
  ctx.abort?.();
412
923
  return { content: [{ type: "text" as const, text: "" }], details: {} };
413
924
  }
@@ -422,9 +933,89 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
422
933
  });
423
934
  }
424
935
 
936
+ function registerMainTraceHooks(orchestrator: Orchestrator): void {
937
+ const pi = orchestrator.pi;
938
+ // These hooks are registered only on the root orchestrator session
939
+ // (registerEventHandlers is never called from the subagent branch in index.ts),
940
+ // so they only ever receive root-session events — no SUBAGENT_SESSION_KEY gate needed.
941
+
942
+ pi.on("before_agent_start", async (event) => {
943
+ getTracer()?.traceMain("before_agent_start", {
944
+ prompt: event.prompt,
945
+ images: event.images,
946
+ systemPrompt: event.systemPrompt,
947
+ });
948
+ });
949
+ pi.on("agent_start", async () => {
950
+ getTracer()?.traceMain("agent_start", {});
951
+ });
952
+ pi.on("agent_end", async (event) => {
953
+ getTracer()?.traceMain("agent_end", { messages: event.messages });
954
+ });
955
+ pi.on("turn_start", async (event) => {
956
+ const tracer = getTracer();
957
+ if (tracer) tracer.turnIndex = event.turnIndex;
958
+ tracer?.traceMain("turn_start", { turnIndex: event.turnIndex, timestamp: event.timestamp });
959
+ });
960
+ pi.on("turn_end", async (event) => {
961
+ const tracer = getTracer();
962
+ if (tracer) tracer.turnIndex = event.turnIndex;
963
+ tracer?.traceMain("turn_end", { turnIndex: event.turnIndex, message: event.message, toolResults: event.toolResults });
964
+ });
965
+ pi.on("message_start", async (event) => {
966
+ const tracer = getTracer();
967
+ tracer?.traceMain("message_start", { turnIndex: tracer.turnIndex, message: event.message });
968
+ });
969
+ pi.on("message_update", async (event) => {
970
+ const tracer = getTracer();
971
+ tracer?.traceMain("message_update", { turnIndex: tracer.turnIndex, assistantMessageEvent: event.assistantMessageEvent });
972
+ });
973
+ pi.on("message_end", async (event) => {
974
+ const tracer = getTracer();
975
+ tracer?.traceMain("message_end", { turnIndex: tracer.turnIndex, message: event.message });
976
+ });
977
+ pi.on("tool_execution_start", async (event) => {
978
+ const tracer = getTracer();
979
+ tracer?.traceMain("tool_execution_start", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, args: event.args });
980
+ });
981
+ pi.on("tool_execution_update", async (event) => {
982
+ const tracer = getTracer();
983
+ tracer?.traceMain("tool_execution_update", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, args: event.args, partialResult: event.partialResult });
984
+ });
985
+ pi.on("tool_execution_end", async (event) => {
986
+ const tracer = getTracer();
987
+ tracer?.traceMain("tool_execution_end", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, result: event.result, isError: event.isError });
988
+ });
989
+ }
990
+
425
991
  export function registerEventHandlers(orchestrator: Orchestrator): void {
426
992
  const pi = orchestrator.pi;
427
993
 
994
+ registerMainTraceHooks(orchestrator);
995
+
996
+ // TransitionController drivers. These are the reliable idle/completion signals:
997
+ // - agent_end: the main loop went idle. If a transition is pending, this is
998
+ // when the controller fires compaction.
999
+ // - session_compact: compaction finished. The controller resumes (injects
1000
+ // context/artifacts + sends the next instruction).
1001
+ // The SDK supports multiple handlers per event, so these coexist with the
1002
+ // trace-only hooks registered above. registerEventHandlers is never called on
1003
+ // the subagent branch, so these only ever see root-session events.
1004
+ pi.on("agent_end", async (_event, ctx) => {
1005
+ if (ctx) orchestrator.lastCtx = ctx;
1006
+ orchestrator.transitionController.onAgentEnd();
1007
+ });
1008
+ pi.on("session_compact", async (_event, ctx) => {
1009
+ if (ctx) orchestrator.lastCtx = ctx;
1010
+ orchestrator.transitionController.onSessionCompact();
1011
+ });
1012
+
1013
+ // Expose the event-driven planner-completion check so initial plan-entry
1014
+ // spawns (in command-handlers / orchestrator) can wire it as their onSettled
1015
+ // safety net (the deleted poller's former role). checkPlannerCompletion is a
1016
+ // hoisted function declaration below.
1017
+ orchestrator.checkPlannerCompletion = () => checkPlannerCompletion();
1018
+
428
1019
  function getUsageTracker(): UsageTracker | undefined {
429
1020
  return (globalThis as any)[USAGE_TRACKER_KEY] as UsageTracker | undefined;
430
1021
  }
@@ -444,6 +1035,25 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
444
1035
  if (event === "first_turn" && lifecycle.firstTurnAt == null) lifecycle.firstTurnAt = now;
445
1036
  orchestrator.agentLifecycle.set(data.id, lifecycle);
446
1037
 
1038
+ getTracer()?.traceMain("subagent_lifecycle", {
1039
+ event,
1040
+ subagentId: data.id,
1041
+ type: data.type ?? lifecycle.type,
1042
+ description: data.description ?? lifecycle.description,
1043
+ parentToolCallId: data.toolCallId,
1044
+ phase: lifecycle.phase,
1045
+ step: lifecycle.step,
1046
+ toolName: data.toolName,
1047
+ turnCount: data.turnCount,
1048
+ tokens: data.tokens,
1049
+ durationMs: data.durationMs,
1050
+ toolUses: data.toolUses,
1051
+ modelId: data.modelId,
1052
+ status: data.status,
1053
+ error: data.error,
1054
+ result: data.result,
1055
+ });
1056
+
447
1057
  const ageMs = lifecycle.createdAt == null ? 0 : now - lifecycle.createdAt;
448
1058
  const startedDeltaMs = lifecycle.startedAt == null || lifecycle.createdAt == null ? undefined : lifecycle.startedAt - lifecycle.createdAt;
449
1059
  const firstToolDeltaMs = lifecycle.firstToolAt == null || lifecycle.createdAt == null ? undefined : lifecycle.firstToolAt - lifecycle.createdAt;
@@ -451,8 +1061,8 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
451
1061
  const pending = orchestrator.pendingSubagentSpawns;
452
1062
  const running = orchestrator.spawnedAgentIds.size;
453
1063
  const desc = data.description || lifecycle.description || data.type || lifecycle.type || data.id;
454
- appendTaskLog(orchestrator.active.dir, "subagent-lifecycle.jsonl", {
455
- timestamp: new Date(now).toISOString(),
1064
+ getLogger().debug({
1065
+ s: "subagent",
456
1066
  id: data.id,
457
1067
  event,
458
1068
  description: desc,
@@ -467,7 +1077,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
467
1077
  createdToFirstTurnMs: firstTurnDeltaMs ?? null,
468
1078
  toolName: data.toolName ?? null,
469
1079
  turnCount: data.turnCount ?? null,
470
- });
1080
+ }, "subagent lifecycle event");
471
1081
  }
472
1082
 
473
1083
  function startStaleAgentWatchdog(): void {
@@ -480,7 +1090,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
480
1090
  }
481
1091
  const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
482
1092
  const now = Date.now();
483
- const staleMs = orchestrator.config.timeouts.agentStale;
1093
+ const staleMs = orchestrator.config.performance.internals.subagentStale;
484
1094
  for (const [id, spawnTime] of orchestrator.agentSpawnTimes) {
485
1095
  const record = mgr?.getRecord?.(id);
486
1096
  if (record?.status === "running" || record?.status === "queued") continue;
@@ -492,13 +1102,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
492
1102
  orchestrator.agentSpawnTimes.delete(id);
493
1103
  orchestrator.agentDescriptions.delete(id);
494
1104
  orchestrator.agentLifecycle.delete(id);
495
- pi.sendMessage(
1105
+ orchestrator.transitionController.sendCustom(
496
1106
  {
497
1107
  customType: "pp-agent-stale",
498
1108
  content: `Aborted stale agent "${desc}" — no completion after ${Math.round(staleMs / 1000)}s.`,
499
1109
  display: true,
500
1110
  },
501
- { deliverAs: "steer" },
1111
+ "context",
502
1112
  );
503
1113
  }
504
1114
  }
@@ -538,6 +1148,15 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
538
1148
  trackSubagentEvent(data, "first_turn");
539
1149
  });
540
1150
 
1151
+ function markAllAgentsConsumed(): void {
1152
+ const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
1153
+ if (!mgr?.getRecord) return;
1154
+ for (const id of orchestrator.agentDescriptions.keys()) {
1155
+ const record = mgr.getRecord(id);
1156
+ if (record) record.resultConsumed = true;
1157
+ }
1158
+ }
1159
+
541
1160
  function checkPlannerCompletion(): void {
542
1161
  if (
543
1162
  !orchestrator.active ||
@@ -545,7 +1164,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
545
1164
  orchestrator.active.state.step !== "await_planners" ||
546
1165
  orchestrator.spawnedAgentIds.size > 0 ||
547
1166
  orchestrator.pendingSubagentSpawns > 0 ||
548
- orchestrator.phaseCompactionPending
1167
+ orchestrator.transitionController.isTransitioning()
549
1168
  ) return;
550
1169
 
551
1170
  const plansDir = join(orchestrator.active.dir, "plans");
@@ -553,8 +1172,75 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
553
1172
  readdirSync(plansDir).some((f) => f.endsWith(".md") && !f.includes("synthesized") && !f.includes("review_"));
554
1173
 
555
1174
  const failedPlannerVariants = [...orchestrator.failedPlannerVariants];
1175
+ const effectiveMode = getEffectiveMode(orchestrator.active.state);
1176
+ if (effectiveMode === "autonomous" && failedPlannerVariants.length > 0) {
1177
+ const alreadyRetried = orchestrator.active.state.plannerFailureAutoRetried === true;
1178
+ if (!alreadyRetried) {
1179
+ const failedSet = new Set(failedPlannerVariants);
1180
+ const presetName = normalizeStoredPlannerPresetName(orchestrator);
1181
+ const planners = resolvePreset(orchestrator.config, "planners", presetName);
1182
+ const scopedPlanners: typeof planners = {};
1183
+ for (const [name, cfg] of Object.entries(planners)) {
1184
+ if (failedSet.has(name)) scopedPlanners[name] = cfg;
1185
+ }
1186
+ const retryCount = Object.keys(scopedPlanners).length;
1187
+ if (retryCount > 0) {
1188
+ orchestrator.active.state.plannerFailureAutoRetried = true;
1189
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
1190
+ orchestrator.failedPlannerVariants = [];
1191
+ orchestrator.pendingSubagentSpawns = retryCount;
1192
+ handleSpawnResult(
1193
+ orchestrator,
1194
+ spawnPlanners(
1195
+ pi,
1196
+ orchestrator.cwd,
1197
+ orchestrator.active!.dir,
1198
+ orchestrator.active!.taskId,
1199
+ orchestrator.config,
1200
+ orchestrator.transitionController.phaseSend,
1201
+ scopedPlanners,
1202
+ orchestrator.active?.state.repos ?? [],
1203
+ ),
1204
+ {
1205
+ kind: "planner",
1206
+ logScope: "planner",
1207
+ logMessage: "retry spawnPlanners failed",
1208
+ onSettled: checkPlannerCompletion,
1209
+ },
1210
+ );
1211
+ orchestrator.safeSendUserMessage(`[PI-PI] Retrying failed planners once: ${failedPlannerVariants.join(", ")}.`);
1212
+ return;
1213
+ }
1214
+ }
1215
+
1216
+ orchestrator.failedPlannerVariants = [];
1217
+ orchestrator.active.state.plannerFailureAutoRetried = false;
1218
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
1219
+ if (!hasPlanFiles) {
1220
+ // Custom (sendMessage) followUp does NOT start a turn when the agent is
1221
+ // idle (SDK agent-session.js) — emit the payload as display context and
1222
+ // start the synthesizer turn via safeSendUserMessage (sendUserMessage
1223
+ // always triggers a turn).
1224
+ orchestrator.transitionController.sendCustom(
1225
+ {
1226
+ customType: "pp-planners-error",
1227
+ content: "All planner subagents failed. Continue without planner outputs and synthesize the plan yourself.",
1228
+ display: true,
1229
+ },
1230
+ "context",
1231
+ );
1232
+ orchestrator.safeSendUserMessage("[PI-PI] All planners failed. Synthesize the plan yourself based on USER_REQUEST.md and RESEARCH.md.");
1233
+ } else {
1234
+ orchestrator.safeSendUserMessage("[PI-PI] Some planners failed. Continue with available planner outputs.");
1235
+ }
1236
+ orchestrator.active.state.step = "synthesize";
1237
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
1238
+ return;
1239
+ }
1240
+
556
1241
  if (
557
1242
  failedPlannerVariants.length > 0 &&
1243
+ effectiveMode !== "autonomous" &&
558
1244
  !orchestrator.plannerFailureDialogPending &&
559
1245
  orchestrator.lastCtx
560
1246
  ) {
@@ -572,28 +1258,35 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
572
1258
 
573
1259
  if (choice === "Retry failed planners") {
574
1260
  const failedSet = new Set(failedPlannerVariants);
575
- const planners: typeof orchestrator.config.planners = {};
576
- for (const [name, cfg] of Object.entries(orchestrator.config.planners)) {
577
- if (failedSet.has(name)) planners[name] = cfg;
1261
+ const presetName = normalizeStoredPlannerPresetName(orchestrator);
1262
+ const planners = resolvePreset(orchestrator.config, "planners", presetName);
1263
+ const scopedPlanners: typeof planners = {};
1264
+ for (const [name, cfg] of Object.entries(planners)) {
1265
+ if (failedSet.has(name)) scopedPlanners[name] = cfg;
578
1266
  }
579
- const retryCount = Object.keys(planners).length;
1267
+ const retryCount = Object.keys(scopedPlanners).length;
580
1268
  if (retryCount > 0) {
581
- const retryConfig = { ...orchestrator.config, planners };
582
1269
  orchestrator.failedPlannerVariants = [];
583
1270
  orchestrator.pendingSubagentSpawns = retryCount;
584
- spawnPlanners(pi, orchestrator.cwd, orchestrator.active!.dir, orchestrator.active!.taskId, retryConfig).then((result) => {
585
- orchestrator.failedPlannerVariants = result.failedVariants;
586
- if (result.spawned === 0) orchestrator.pendingSubagentSpawns = 0;
587
- for (const id of result.agentIds ?? []) {
588
- orchestrator.spawnedAgentIds.delete(id);
589
- }
590
- orchestrator.pendingSubagentSpawns = 0;
591
- checkPlannerCompletion();
592
- }).catch((err) => {
593
- orchestrator.pendingSubagentSpawns = 0;
594
- console.error(`[pi-pi] retry spawnPlanners failed: ${err.message}`);
595
- checkPlannerCompletion();
596
- });
1271
+ handleSpawnResult(
1272
+ orchestrator,
1273
+ spawnPlanners(
1274
+ pi,
1275
+ orchestrator.cwd,
1276
+ orchestrator.active!.dir,
1277
+ orchestrator.active!.taskId,
1278
+ orchestrator.config,
1279
+ orchestrator.transitionController.phaseSend,
1280
+ scopedPlanners,
1281
+ orchestrator.active?.state.repos ?? [],
1282
+ ),
1283
+ {
1284
+ kind: "planner",
1285
+ logScope: "planner",
1286
+ logMessage: "retry spawnPlanners failed",
1287
+ onSettled: checkPlannerCompletion,
1288
+ },
1289
+ );
597
1290
  orchestrator.safeSendUserMessage(`[PI-PI] Retrying failed planners: ${variantsText}.`);
598
1291
  return;
599
1292
  }
@@ -616,20 +1309,26 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
616
1309
  }
617
1310
 
618
1311
  if (!hasPlanFiles) {
619
- pi.sendMessage(
1312
+ // Display payload as context; start the synthesizer turn via safeSendUserMessage
1313
+ // (a custom followUp message does not start an idle turn).
1314
+ orchestrator.transitionController.sendCustom(
620
1315
  {
621
1316
  customType: "pp-planners-error",
622
1317
  content: "All planner subagents finished but no plan files were produced. You must create the plan yourself based on USER_REQUEST.md and RESEARCH.md.",
623
1318
  display: true,
624
1319
  },
625
- { deliverAs: "followUp" },
1320
+ "context",
626
1321
  );
627
1322
  orchestrator.active.state.step = "synthesize";
628
1323
  saveTask(orchestrator.active.dir, orchestrator.active.state);
1324
+ orchestrator.safeSendUserMessage("[PI-PI] No plan files were produced. Create the plan yourself based on USER_REQUEST.md and RESEARCH.md.");
629
1325
  return;
630
1326
  }
631
1327
 
632
1328
  orchestrator.failedPlannerVariants = [];
1329
+ orchestrator.active.state.plannerFailureAutoRetried = false;
1330
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
1331
+ markAllAgentsConsumed();
633
1332
  orchestrator.active.state.step = "synthesize";
634
1333
  saveTask(orchestrator.active.dir, orchestrator.active.state);
635
1334
  orchestrator.safeSendUserMessage("[PI-PI] All planners completed. Read their outputs and synthesize the plan.");
@@ -641,12 +1340,95 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
641
1340
  orchestrator.active.state.reviewCycle.step !== "await_reviewers" ||
642
1341
  orchestrator.spawnedAgentIds.size > 0 ||
643
1342
  orchestrator.pendingSubagentSpawns > 0 ||
644
- orchestrator.phaseCompactionPending
1343
+ orchestrator.transitionController.isTransitioning()
645
1344
  ) return;
646
1345
 
647
1346
  const failedReviewerVariants = [...orchestrator.failedReviewerVariants];
1347
+ const effectiveMode = getEffectiveMode(orchestrator.active.state);
1348
+ if (effectiveMode === "autonomous" && failedReviewerVariants.length > 0) {
1349
+ const cycle = orchestrator.active.state.reviewCycle;
1350
+ const phase = orchestrator.active.state.phase;
1351
+ const outputs = loadPhaseReviewOutputs(orchestrator.active.dir, phase, cycle.pass);
1352
+ const alreadyRetried = orchestrator.active.state.reviewerFailureAutoRetried === true;
1353
+ if (!alreadyRetried) {
1354
+ const presetName = normalizeStoredReviewPresetName(orchestrator, phase);
1355
+ const sourceReviewers = resolveReviewers(orchestrator, phase, presetName);
1356
+ const failedSet = new Set(failedReviewerVariants);
1357
+ const scopedReviewers: typeof sourceReviewers = {};
1358
+ for (const [name, cfg] of Object.entries(sourceReviewers)) {
1359
+ if (failedSet.has(name)) scopedReviewers[name] = cfg;
1360
+ }
1361
+ const retryCount = Object.keys(scopedReviewers).length;
1362
+ if (retryCount > 0) {
1363
+ const spawnFn = phase === "brainstorm"
1364
+ ? () => spawnBrainstormReviewers(
1365
+ pi,
1366
+ orchestrator.cwd,
1367
+ orchestrator.active!.dir,
1368
+ orchestrator.active!.taskId,
1369
+ orchestrator.config,
1370
+ cycle.pass,
1371
+ orchestrator.transitionController.phaseSend,
1372
+ scopedReviewers,
1373
+ orchestrator.active?.state.repos ?? [],
1374
+ )
1375
+ : phase === "plan"
1376
+ ? () => spawnPlanReviewers(
1377
+ pi,
1378
+ orchestrator.cwd,
1379
+ orchestrator.active!.dir,
1380
+ orchestrator.active!.taskId,
1381
+ orchestrator.config,
1382
+ cycle.pass,
1383
+ orchestrator.transitionController.phaseSend,
1384
+ scopedReviewers,
1385
+ orchestrator.active?.state.repos ?? [],
1386
+ )
1387
+ : () => spawnCodeReviewers(
1388
+ pi,
1389
+ orchestrator.cwd,
1390
+ orchestrator.active!.dir,
1391
+ orchestrator.active!.taskId,
1392
+ orchestrator.config,
1393
+ cycle.pass,
1394
+ phase,
1395
+ orchestrator.transitionController.phaseSend,
1396
+ scopedReviewers,
1397
+ orchestrator.active?.state.repos ?? [],
1398
+ );
1399
+ orchestrator.active.state.reviewerFailureAutoRetried = true;
1400
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
1401
+ orchestrator.failedReviewerVariants = [];
1402
+ orchestrator.pendingSubagentSpawns = retryCount;
1403
+ cycle.step = "await_reviewers";
1404
+ orchestrator.active.state.step = "await_reviewers";
1405
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
1406
+ handleSpawnResult(orchestrator, spawnFn(), {
1407
+ kind: "reviewer",
1408
+ logScope: "review",
1409
+ logMessage: "retry spawn reviewers failed",
1410
+ logExtra: { phase },
1411
+ onSettled: checkReviewCycleCompletion,
1412
+ });
1413
+ orchestrator.safeSendUserMessage(`[PI-PI] Retrying failed reviewers once: ${failedReviewerVariants.join(", ")}.`);
1414
+ return;
1415
+ }
1416
+ }
1417
+
1418
+ orchestrator.failedReviewerVariants = [];
1419
+ orchestrator.active.state.reviewerFailureAutoRetried = false;
1420
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
1421
+ if (outputs.length === 0) {
1422
+ finalizeReviewCycleAutonomous(orchestrator.active);
1423
+ orchestrator.safeSendUserMessage("[PI-PI] All reviewers failed twice. Continue without reviewer outputs and proceed with best judgment.");
1424
+ return;
1425
+ }
1426
+ orchestrator.safeSendUserMessage("[PI-PI] Some reviewers failed. Continue with available reviewer outputs.");
1427
+ }
1428
+
648
1429
  if (
649
1430
  failedReviewerVariants.length > 0 &&
1431
+ effectiveMode !== "autonomous" &&
650
1432
  !orchestrator.reviewerFailureDialogPending &&
651
1433
  orchestrator.lastCtx
652
1434
  ) {
@@ -664,13 +1446,9 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
664
1446
  const cycle = orchestrator.active?.state.reviewCycle;
665
1447
  if (cycle) {
666
1448
  const pass = cycle.pass;
667
- const reviewConfig = cycle.kind === "auto-deep" ? deepReviewConfig(orchestrator.config) : orchestrator.config;
668
1449
  const phase = orchestrator.active!.state.phase;
669
- const sourceReviewers = phase === "brainstorm"
670
- ? reviewConfig.brainstormReviewers
671
- : phase === "plan"
672
- ? reviewConfig.planReviewers
673
- : reviewConfig.codeReviewers;
1450
+ const presetName = normalizeStoredReviewPresetName(orchestrator, phase);
1451
+ const sourceReviewers = resolveReviewers(orchestrator, phase, presetName);
674
1452
  const failedSet = new Set(failedReviewerVariants);
675
1453
  const scopedReviewers: typeof sourceReviewers = {};
676
1454
  for (const [name, cfg] of Object.entries(sourceReviewers)) {
@@ -678,33 +1456,53 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
678
1456
  }
679
1457
  const retryCount = Object.keys(scopedReviewers).length;
680
1458
  if (retryCount > 0) {
681
- const retryConfig = phase === "brainstorm"
682
- ? { ...reviewConfig, brainstormReviewers: scopedReviewers }
683
- : phase === "plan"
684
- ? { ...reviewConfig, planReviewers: scopedReviewers }
685
- : { ...reviewConfig, codeReviewers: scopedReviewers };
686
1459
  const spawnFn = phase === "brainstorm"
687
- ? () => spawnBrainstormReviewers(pi, orchestrator.cwd, orchestrator.active!.dir, orchestrator.active!.taskId, retryConfig, pass)
1460
+ ? () => spawnBrainstormReviewers(
1461
+ pi,
1462
+ orchestrator.cwd,
1463
+ orchestrator.active!.dir,
1464
+ orchestrator.active!.taskId,
1465
+ orchestrator.config,
1466
+ pass,
1467
+ orchestrator.transitionController.phaseSend,
1468
+ scopedReviewers,
1469
+ orchestrator.active?.state.repos ?? [],
1470
+ )
688
1471
  : phase === "plan"
689
- ? () => spawnPlanReviewers(pi, orchestrator.cwd, orchestrator.active!.dir, orchestrator.active!.taskId, retryConfig)
690
- : () => spawnCodeReviewers(pi, orchestrator.cwd, orchestrator.active!.dir, orchestrator.active!.taskId, retryConfig, pass);
1472
+ ? () => spawnPlanReviewers(
1473
+ pi,
1474
+ orchestrator.cwd,
1475
+ orchestrator.active!.dir,
1476
+ orchestrator.active!.taskId,
1477
+ orchestrator.config,
1478
+ pass,
1479
+ orchestrator.transitionController.phaseSend,
1480
+ scopedReviewers,
1481
+ orchestrator.active?.state.repos ?? [],
1482
+ )
1483
+ : () => spawnCodeReviewers(
1484
+ pi,
1485
+ orchestrator.cwd,
1486
+ orchestrator.active!.dir,
1487
+ orchestrator.active!.taskId,
1488
+ orchestrator.config,
1489
+ pass,
1490
+ phase,
1491
+ orchestrator.transitionController.phaseSend,
1492
+ scopedReviewers,
1493
+ orchestrator.active?.state.repos ?? [],
1494
+ );
691
1495
  orchestrator.failedReviewerVariants = [];
692
1496
  orchestrator.pendingSubagentSpawns = retryCount;
693
1497
  cycle.step = "await_reviewers";
694
1498
  orchestrator.active!.state.step = "await_reviewers";
695
1499
  saveTask(orchestrator.active!.dir, orchestrator.active!.state);
696
- spawnFn().then((result) => {
697
- orchestrator.failedReviewerVariants = result.failedVariants;
698
- if (result.spawned === 0) orchestrator.pendingSubagentSpawns = 0;
699
- for (const id of result.agentIds ?? []) {
700
- orchestrator.spawnedAgentIds.delete(id);
701
- }
702
- orchestrator.pendingSubagentSpawns = 0;
703
- checkReviewCycleCompletion();
704
- }).catch((err) => {
705
- orchestrator.pendingSubagentSpawns = 0;
706
- console.error(`[pi-pi] retry spawn reviewers failed (${phase}): ${err.message}`);
707
- checkReviewCycleCompletion();
1500
+ handleSpawnResult(orchestrator, spawnFn(), {
1501
+ kind: "reviewer",
1502
+ logScope: "review",
1503
+ logMessage: "retry spawn reviewers failed",
1504
+ logExtra: { phase },
1505
+ onSettled: checkReviewCycleCompletion,
708
1506
  });
709
1507
  orchestrator.safeSendUserMessage(`[PI-PI] Retrying failed reviewers: ${variantsText}.`);
710
1508
  return;
@@ -736,6 +1534,11 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
736
1534
  return;
737
1535
  }
738
1536
 
1537
+ if (orchestrator.active.state.reviewerFailureAutoRetried) {
1538
+ orchestrator.active.state.reviewerFailureAutoRetried = false;
1539
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
1540
+ }
1541
+ markAllAgentsConsumed();
739
1542
  tryCompleteReviewCycle(orchestrator);
740
1543
  }
741
1544
 
@@ -764,13 +1567,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
764
1567
  const tokens = data.tokens?.total ? `${data.tokens.total} tok` : "";
765
1568
  const stats = [duration, tokens].filter(Boolean).join(", ");
766
1569
 
767
- pi.sendMessage(
1570
+ orchestrator.transitionController.sendCustom(
768
1571
  {
769
1572
  customType: "pp-subagent-result",
770
1573
  content: `${desc} completed${stats ? ` (${stats})` : ""}. Use get_subagent_result to read the output.`,
771
1574
  display: false,
772
1575
  },
773
- { deliverAs: "steer" },
1576
+ "context",
774
1577
  );
775
1578
 
776
1579
  checkPlannerCompletion();
@@ -797,7 +1600,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
797
1600
  orchestrator.abortAllSubagents();
798
1601
  }
799
1602
 
800
- pi.sendMessage(
1603
+ orchestrator.transitionController.sendCustom(
801
1604
  {
802
1605
  customType: "pp-subagent-error",
803
1606
  content: isApiError
@@ -805,7 +1608,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
805
1608
  : `**${desc}** failed: ${data.error || "unknown error"}. Do NOT retry — continue with available information.`,
806
1609
  display: true,
807
1610
  },
808
- { deliverAs: "steer" },
1611
+ "context",
809
1612
  );
810
1613
 
811
1614
  checkPlannerCompletion();
@@ -813,6 +1616,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
813
1616
  });
814
1617
 
815
1618
  pi.on("session_before_switch" as any, async () => {
1619
+ finalizeTracer();
816
1620
  if (!orchestrator.active) return;
817
1621
  cancelPendingPlannotatorWait(orchestrator);
818
1622
  orchestrator.abortAllSubagents();
@@ -828,14 +1632,47 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
828
1632
  try {
829
1633
  dumpUsageSummary(tracker, sessionId);
830
1634
  } catch (err: any) {
831
- console.error(`[pi-pi] Failed to dump usage summary: ${err.message}`);
1635
+ getLogger().error({ s: "usage", err: err.message }, "failed to dump usage summary");
832
1636
  }
1637
+ flushLogs();
1638
+ finalizeTracer();
833
1639
  delete (globalThis as any)[USAGE_TRACKER_KEY];
834
1640
  });
835
1641
 
836
1642
  pi.on("session_start", async (_event, ctx) => {
837
1643
  orchestrator.lastCtx = ctx;
838
1644
  orchestrator.cwd = ctx.cwd;
1645
+ (globalThis as any)[Symbol.for("pi-pi:orchestrator-cwd")] = ctx.cwd;
1646
+
1647
+ const ppDir = join(ctx.cwd, ".pp");
1648
+ const { ensureGitignore } = await import("./orchestrator.js");
1649
+ ensureGitignore(ctx.cwd);
1650
+
1651
+ let earlyLogLevel: "debug" | "info" | "warn" | "error" = "info";
1652
+ try {
1653
+ const { readRawConfig, GLOBAL_CONFIG_PATH } = await import("./config.js");
1654
+ const { isValidLogLevel: checkLevel } = await import("./log.js");
1655
+ const globalRaw = readRawConfig(GLOBAL_CONFIG_PATH);
1656
+ const projectRaw = readRawConfig(join(ppDir, "config.json"));
1657
+ const rawLevel = projectRaw?.general?.logLevel ?? globalRaw?.general?.logLevel;
1658
+ if (checkLevel(rawLevel)) earlyLogLevel = rawLevel;
1659
+ } catch {}
1660
+
1661
+ initSessionLogger(ppDir, earlyLogLevel);
1662
+ const log = getLogger();
1663
+ log.info({ s: "session", cwd: ctx.cwd, logLevel: earlyLogLevel }, "session started");
1664
+
1665
+ const available = (ctx as any).modelRegistry?.getAvailable?.();
1666
+ if (Array.isArray(available)) {
1667
+ const modelIds = available
1668
+ .map((m: any) => {
1669
+ const provider = typeof m?.provider === "string" ? m.provider.trim() : "";
1670
+ const id = typeof m?.id === "string" ? m.id.trim() : "";
1671
+ return provider && id ? `${provider}/${id}` : "";
1672
+ })
1673
+ .filter((id: string) => id.length > 0);
1674
+ updateRegistryFromAvailableModels(modelIds);
1675
+ }
839
1676
 
840
1677
  if (!(globalThis as any)[SUBAGENT_SESSION_KEY]) {
841
1678
  const tracker = createUsageTracker();
@@ -865,7 +1702,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
865
1702
  `Duplicate tools detected: ${duplicates.join(", ")}. ` +
866
1703
  `Remove the conflicting packages: pi remove npm:@tintinweb/pi-subagents npm:@tintinweb/pi-tasks npm:pi-ask-user`;
867
1704
  ctx.ui.notify(msg, "error");
868
- console.error(`[pi-pi] FATAL: ${msg}`);
1705
+ getLogger().error({ s: "init", duplicates }, "conflicting extensions detected");
869
1706
  return;
870
1707
  }
871
1708
 
@@ -874,16 +1711,28 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
874
1711
  setPI(pi);
875
1712
  await initFlantOnStartup(pi);
876
1713
  } catch (err: any) {
877
- console.error(`[pi-pi] Flant infra init failed: ${err.message}`);
1714
+ getLogger().error({ s: "flant", err: err.message }, "flant infra init failed");
878
1715
  }
879
1716
 
880
1717
  try {
881
1718
  orchestrator.config = loadConfig(orchestrator.cwd);
882
1719
  } catch (err: any) {
883
- console.error(`[pi-pi] Failed to load config on session start: ${err.message}`);
1720
+ getLogger().error({ s: "config", err: err.message }, "failed to load config on session start");
884
1721
  return;
885
1722
  }
886
1723
 
1724
+ setLogLevel(orchestrator.config.general.logLevel);
1725
+ log.info({ s: "config", logLevel: orchestrator.config.general.logLevel }, "config loaded");
1726
+
1727
+ if (orchestrator.config.general.tracing) {
1728
+ const sessionId = ctx.sessionManager?.getSessionId?.() || `session-${Date.now()}`;
1729
+ initTracer(ppDir, sessionId);
1730
+ log.info({ s: "tracing", sessionId }, "session tracing enabled");
1731
+ } else {
1732
+ finalizeTracer();
1733
+ }
1734
+
1735
+ registerCommandHandlers(orchestrator);
887
1736
  registerCbmTools(pi, orchestrator.cwd);
888
1737
  registerExaTools(pi);
889
1738
  registerAstSearchTool(pi, orchestrator.cwd);
@@ -891,7 +1740,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
891
1740
  setExtensionOnlyMode(pi);
892
1741
  orchestrator.registerAgents();
893
1742
 
894
- const found = getActiveTask(orchestrator.cwd, orchestrator.config.timeouts.lockStale);
1743
+ const found = getActiveTask(orchestrator.cwd, orchestrator.config.performance.internals.taskLockStale);
895
1744
  if (found) {
896
1745
  ctx.ui.notify(
897
1746
  `Paused task: "${taskName(found.dir)}" (${found.type}, phase: ${found.state.phase}). Run /pp and choose Resume to continue.`,
@@ -913,34 +1762,77 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
913
1762
 
914
1763
  pi.on("before_agent_start", async (event, ctx) => {
915
1764
  orchestrator.lastCtx = ctx;
916
- if (orchestrator.taskDoneCompactionPending || orchestrator.phaseCompactionPending) {
917
- ctx.abort();
1765
+ // The controller owns the transition/await abort gate: it decides whether the
1766
+ // agent loop may start (not running during a pending/compacting/resuming
1767
+ // transition, subsuming the old compaction-pending checks, or while awaiting
1768
+ // subagents) AND issues the abort itself.
1769
+ if (orchestrator.transitionController.gateAgentStart(() => ctx.abort())) {
918
1770
  return;
919
1771
  }
920
1772
  if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
921
1773
 
922
- const step = orchestrator.active.state.step;
923
- if (step === "await_planners" || step === "await_reviewers") {
924
- ctx.abort();
925
- return;
1774
+ // Clear the nudge guard ONLY on a genuine user re-engagement. Controller-
1775
+ // injected prompts (nudges, "Begin working" handoffs, planner-error prompts)
1776
+ // are all "[PI-PI]"-prefixed and themselves restart the loop via followUp;
1777
+ // resetting on those would make consecutiveNudges oscillate 0->1->0 and the
1778
+ // halt could never fire. A nudge-induced restart must NOT clear the guard.
1779
+ const isControllerInjected = (event.prompt ?? "").startsWith("[PI-PI]");
1780
+ if (!isControllerInjected) {
1781
+ orchestrator.nudgeHalted = false;
1782
+ orchestrator.consecutiveNudges = 0;
926
1783
  }
927
-
928
- orchestrator.nudgeHalted = false;
929
1784
  orchestrator.updateStatus(ctx);
930
1785
 
931
1786
  const phasePrompt = orchestrator.getPhasePrompt(ctx);
932
- const systemContextFiles = loadContextFiles(orchestrator.cwd, "main", "system");
1787
+ const phase = orchestrator.active?.state.phase;
1788
+ const modelSpec = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
1789
+ const modelInfo = getModelInfo(modelSpec);
1790
+ const repos = orchestrator.active?.state.repos ?? [];
1791
+ const contextDirs = getContextDirs(orchestrator.cwd, repos, orchestrator.config.general.loadExtraRepoConfigs);
1792
+ const systemContextFiles = loadAllContextFiles(contextDirs, "main", "system", phase, modelInfo);
933
1793
  const systemSnippets = systemContextFiles.map((f) => f.content).join("\n\n");
934
-
935
- const fullAddition = [WORKING_PRINCIPLES, COMMUNICATION, systemSnippets, phasePrompt].filter(Boolean).join("\n\n");
936
- if (!fullAddition) return;
1794
+ const effectiveMode: TaskMode = getEffectivePhaseMode(orchestrator.active.state);
1795
+ const now = new Date();
1796
+ const monthYear = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
1797
+
1798
+ const projectContext = systemSnippets
1799
+ ? ["<project_context>", systemSnippets, "</project_context>"].join("\n")
1800
+ : "";
1801
+ const checklistLine =
1802
+ phase === "implement" ? "Keep the plan checklist current: mark each item done (- [ ] → - [x]) as you complete it." : "";
1803
+ const taskBlock = [
1804
+ "<task>",
1805
+ phasePrompt,
1806
+ checklistLine,
1807
+ `Current month: ${monthYear}. Working directory: ${orchestrator.cwd}.`,
1808
+ "</task>",
1809
+ ]
1810
+ .filter(Boolean)
1811
+ .join("\n");
1812
+
1813
+ const fullPrompt = [
1814
+ constraintsBlock(phase as Phase, effectiveMode),
1815
+ PRINCIPLES_BLOCK,
1816
+ TOOLS_BLOCK,
1817
+ projectContext,
1818
+ taskBlock,
1819
+ ]
1820
+ .filter(Boolean)
1821
+ .join("\n\n");
937
1822
 
938
1823
  return {
939
- systemPrompt: event.systemPrompt + "\n\n" + fullAddition,
1824
+ systemPrompt: fullPrompt,
940
1825
  };
941
1826
  });
942
1827
 
943
1828
  pi.on("tool_call", async (event, _ctx) => {
1829
+ getLogger().debug({ s: "hook", hook: "tool_call", tool: event.toolName }, "tool call");
1830
+ if (event.toolName === "ask_user" && orchestrator.active) {
1831
+ if (getEffectivePhaseMode(orchestrator.active.state) === "autonomous") {
1832
+ return { block: true, reason: "Autonomous mode — make your best judgment based on available context." };
1833
+ }
1834
+ }
1835
+
944
1836
  if (event.toolName === "Agent" && orchestrator.active) {
945
1837
  const input = event.input as Record<string, unknown>;
946
1838
  const requestedType = ((input.subagent_type as string) || "").toLowerCase();
@@ -952,16 +1844,16 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
952
1844
 
953
1845
  if (isExplore) {
954
1846
  input.subagent_type = "explore";
955
- input.model = orchestrator.config.agents.explore.model;
956
- input.thinking = orchestrator.config.agents.explore.thinking;
1847
+ input.model = resolveModel(orchestrator.config.agents.subagents.simple.explore.model);
1848
+ input.thinking = orchestrator.config.agents.subagents.simple.explore.thinking;
957
1849
  } else if (isLibrarian) {
958
1850
  input.subagent_type = "librarian";
959
- input.model = orchestrator.config.agents.librarian.model;
960
- input.thinking = orchestrator.config.agents.librarian.thinking;
1851
+ input.model = resolveModel(orchestrator.config.agents.subagents.simple.librarian.model);
1852
+ input.thinking = orchestrator.config.agents.subagents.simple.librarian.thinking;
961
1853
  } else {
962
1854
  input.subagent_type = "task";
963
- input.model = orchestrator.config.agents.task.model;
964
- input.thinking = orchestrator.config.agents.task.thinking;
1855
+ input.model = resolveModel(orchestrator.config.agents.subagents.simple.task.model);
1856
+ input.thinking = orchestrator.config.agents.subagents.simple.task.thinking;
965
1857
  }
966
1858
  }
967
1859
 
@@ -972,25 +1864,38 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
972
1864
  const ppStateDir = resolve(orchestrator.cwd, ".pp", "state");
973
1865
  const ppDir = resolve(orchestrator.cwd, ".pp");
974
1866
 
975
- if (resolvedPath.startsWith(ppStateDir + "/") || resolvedPath === ppStateDir) {
1867
+ if (isPathInside(ppStateDir, resolvedPath)) {
976
1868
  if (!resolvedPath.endsWith(".md")) {
977
1869
  return { block: true, reason: "Cannot write non-.md files in .pp/state/" };
978
1870
  }
979
1871
  }
980
1872
 
981
1873
  const fileName = basename(resolvedPath);
982
- if (fileName === "state.json" && (resolvedPath.startsWith(ppDir + "/") || resolvedPath === ppDir)) {
1874
+ if (fileName === "state.json" && isPathInside(ppDir, resolvedPath)) {
983
1875
  return { block: true, reason: "state.json is managed by the extension" };
984
1876
  }
985
1877
 
986
- if (fileName === "config.json" && (resolvedPath.startsWith(ppDir + "/") || resolvedPath === ppDir)) {
1878
+ if (fileName === "config.json" && isPathInside(ppDir, resolvedPath)) {
987
1879
  return { block: true, reason: "config.json is managed by the user, not the LLM" };
988
1880
  }
989
1881
  }
990
1882
  return;
991
1883
  });
992
1884
 
993
- pi.on("tool_result", async (event, _ctx) => {
1885
+ pi.on("tool_result", async (event, ctx) => {
1886
+ // ESC in an ask_user dialogue must stop the LLM's turn (treat ESC as
1887
+ // "stop, I want to type"). Only a deliberate user cancel aborts — timeout
1888
+ // and programmatic signal-aborts carry a non-"user" reason and must not.
1889
+ // The benign tool result has already been produced, so abort() yields a
1890
+ // clean stop rather than an error in the agent loop.
1891
+ if (event.toolName === "ask_user") {
1892
+ const cancelReason = (event.details as { cancelReason?: string } | undefined)?.cancelReason;
1893
+ if (cancelReason === "user") {
1894
+ getLogger().debug({ s: "hook", hook: "tool_result", tool: "ask_user" }, "user cancelled ask_user — aborting turn");
1895
+ ctx.abort?.();
1896
+ }
1897
+ }
1898
+
994
1899
  if (!orchestrator.active) return;
995
1900
 
996
1901
  if ((event.toolName === "edit" || event.toolName === "write") && !event.isError) {
@@ -1025,7 +1930,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1025
1930
  }
1026
1931
  }
1027
1932
  const artifactsDir = join(taskDir, "artifacts");
1028
- if (resolvedWrite.startsWith(artifactsDir + "/") && resolvedWrite.endsWith(".md")) {
1933
+ if (isPathInside(artifactsDir, resolvedWrite) && resolvedWrite.endsWith(".md")) {
1029
1934
  const content = readFileSync(resolvedWrite, "utf-8");
1030
1935
  const result = validateArtifact(content);
1031
1936
  if (!result.ok) {
@@ -1038,24 +1943,39 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1038
1943
  }
1039
1944
  }
1040
1945
 
1041
- if (filePath.includes(".pp/")) return;
1946
+ const ppDir = resolve(orchestrator.cwd, ".pp");
1947
+ if (isPathInside(ppDir, resolvedWrite)) return;
1042
1948
 
1043
1949
  if (orchestrator.active.state.phase !== "implement") return;
1044
1950
 
1045
- orchestrator.active.modifiedFiles.add(filePath);
1951
+ orchestrator.active.modifiedFiles.add(resolvedWrite);
1046
1952
  orchestrator.active.state.modifiedFiles = [...orchestrator.active.modifiedFiles];
1047
- if (!orchestrator.active.state.repoCwd) {
1048
- const dir = resolve(filePath, "..");
1049
- try {
1050
- const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: dir, timeout: 5000 });
1051
- if (result.code === 0 && result.stdout.trim()) {
1052
- orchestrator.active.state.repoCwd = result.stdout.trim();
1053
- }
1054
- } catch {}
1055
- }
1953
+ orchestrator.active.state.reviewApprovedClean = false;
1056
1954
  try { saveTask(orchestrator.active.dir, orchestrator.active.state); } catch {}
1057
1955
 
1058
- const afterEditResults = runAfterEdit(filePath, orchestrator.config, orchestrator.cwd);
1956
+ const repos = orchestrator.active.state.repos ?? [];
1957
+ const repo = resolveRepoForFile(repos, resolvedWrite);
1958
+ getLogger().debug({ s: "afterEdit", file: resolvedWrite, repo: repo?.path ?? null, isRoot: repo?.isRoot ?? null }, "resolving afterEdit commands");
1959
+ const afterEditResults: Array<{ ok: boolean; command: string; output: string }> = [];
1960
+ if (repo) {
1961
+ if (repo.isRoot) {
1962
+ const fileInRepo = relative(orchestrator.cwd, resolvedWrite);
1963
+ afterEditResults.push(
1964
+ ...runAfterEdit(
1965
+ fileInRepo,
1966
+ orchestrator.config.commands.afterEdit,
1967
+ orchestrator.config.performance.commands.afterEdit,
1968
+ orchestrator.cwd,
1969
+ ),
1970
+ );
1971
+ } else if (orchestrator.config.general.loadExtraRepoConfigs) {
1972
+ const repoCommands = loadRepoAfterEditCommands(repo.path);
1973
+ if (repoCommands && Object.keys(repoCommands).length > 0) {
1974
+ const fileInRepo = relative(repo.path, resolvedWrite);
1975
+ afterEditResults.push(...runAfterEdit(fileInRepo, repoCommands, orchestrator.config.performance.commands.afterEdit, repo.path));
1976
+ }
1977
+ }
1978
+ }
1059
1979
  const failures = afterEditResults.filter((r) => !r.ok);
1060
1980
 
1061
1981
  if (failures.length > 0) {
@@ -1084,10 +2004,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1084
2004
  });
1085
2005
 
1086
2006
  pi.on("session_before_compact", async (event, _ctx) => {
1087
- if (orchestrator.taskDoneCompactionPending) {
1088
- const summary = orchestrator.taskDoneCompactionSummary;
1089
- orchestrator.taskDoneCompactionPending = false;
1090
- orchestrator.taskDoneCompactionSummary = "";
2007
+ const transitioning = orchestrator.transitionController.isTransitioning();
2008
+ getLogger().debug({ s: "hook", hook: "session_before_compact", transitioning, state: orchestrator.transitionController.getState() }, "before compact");
2009
+ // Controller-initiated compaction (phase transition or task done/stop/new-task):
2010
+ // supply the transition summary. The controller is the single source of truth,
2011
+ // so this works whether or not `active` is still set (done cleanup nulls it).
2012
+ if (transitioning) {
2013
+ const summary = orchestrator.transitionController.currentSummary() || "Phase transition in progress.";
1091
2014
  return {
1092
2015
  compaction: {
1093
2016
  summary,
@@ -1097,20 +2020,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1097
2020
  };
1098
2021
  }
1099
2022
 
2023
+ // Natural (user-triggered) compaction: re-inject phase artifacts so context
2024
+ // survives.
1100
2025
  if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
1101
2026
 
1102
- if (orchestrator.phaseCompactionPending) {
1103
- const summary = orchestrator.phaseCompactionSummary || "Phase transition in progress.";
1104
- orchestrator.phaseCompactionSummary = "";
1105
- return {
1106
- compaction: {
1107
- summary,
1108
- firstKeptEntryId: event.preparation.firstKeptEntryId,
1109
- tokensBefore: event.preparation.tokensBefore,
1110
- },
1111
- };
1112
- }
1113
-
1114
2027
  const artifacts = getPhaseArtifacts(orchestrator.active.dir, orchestrator.active.state.phase);
1115
2028
  if (artifacts.length === 0) return;
1116
2029
 
@@ -1118,13 +2031,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1118
2031
  .map((a) => `=== ${a.name} ===\n${a.content}`)
1119
2032
  .join("\n\n");
1120
2033
 
1121
- pi.sendMessage(
2034
+ orchestrator.transitionController.sendCustom(
1122
2035
  {
1123
2036
  customType: "pp-artifact-reinject",
1124
2037
  content: `[PI-PI ARTIFACTS — re-injected after compaction]\n\n${artifactText}`,
1125
2038
  display: false,
1126
2039
  },
1127
- { deliverAs: "steer" },
2040
+ "context",
1128
2041
  );
1129
2042
 
1130
2043
  return;
@@ -1136,33 +2049,36 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1136
2049
  if (usageTracker && msg?.usage) {
1137
2050
  const input = typeof msg.usage.input === "number" ? msg.usage.input : 0;
1138
2051
  const output = typeof msg.usage.output === "number" ? msg.usage.output : 0;
2052
+ const cacheSupported = typeof msg.usage.cacheRead === "number" || typeof msg.usage.cacheWrite === "number";
1139
2053
  const cacheRead = typeof msg.usage.cacheRead === "number" ? msg.usage.cacheRead : 0;
1140
2054
  const cacheWrite = typeof msg.usage.cacheWrite === "number" ? msg.usage.cacheWrite : 0;
1141
2055
  const cost = typeof msg.usage.cost?.total === "number" ? msg.usage.cost.total : 0;
1142
2056
  const modelId = (typeof msg.model === "string" && msg.model) || ctx.model?.id || "unknown-model";
1143
2057
  const provider = (typeof msg.provider === "string" && msg.provider) || ctx.model?.provider || "unknown";
1144
- usageTracker.recordTurn(modelId, provider, input, output, cacheRead, cacheWrite, cost);
2058
+ usageTracker.recordTurn(modelId, provider, input, output, cacheRead, cacheWrite, cost, cacheSupported);
1145
2059
  (ctx.ui as any)?.requestRender?.();
1146
2060
  }
1147
2061
 
1148
2062
  if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
1149
- if (orchestrator.phaseCompactionPending || orchestrator.taskDoneCompactionPending) return;
2063
+ // A transition is in flight (controller owns the loop) — skip orthogonal
2064
+ // turn_end work (commit reminder / nudges) until it resumes.
2065
+ if (orchestrator.transitionController.isTransitioning()) return;
1150
2066
  orchestrator.updateStatus(ctx);
1151
2067
 
1152
2068
  if (
1153
2069
  orchestrator.active.state.phase === "implement" &&
1154
- orchestrator.config.autoCommit &&
2070
+ orchestrator.config.general.autoCommit &&
1155
2071
  orchestrator.active.modifiedFiles.size > 0 &&
1156
2072
  !orchestrator.commitReminderSent
1157
2073
  ) {
1158
2074
  orchestrator.commitReminderSent = true;
1159
- pi.sendMessage(
2075
+ orchestrator.transitionController.sendCustom(
1160
2076
  {
1161
2077
  customType: "pp-commit-reminder",
1162
- content: `You have ${orchestrator.active.modifiedFiles.size} uncommitted file(s). If you've completed a logical unit of work, call pp_commit with a descriptive message.`,
2078
+ content: `You have ${orchestrator.active.modifiedFiles.size} uncommitted file(s). If you've completed a logical unit of work, call pp_commit with a descriptive message. Prefix it with a conventional-commit type (fix:, feat:, or chore:) unless the user asked for a different commit style.`,
1163
2079
  display: false,
1164
2080
  },
1165
- { deliverAs: "steer" },
2081
+ "context",
1166
2082
  );
1167
2083
  }
1168
2084
 
@@ -1171,20 +2087,29 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1171
2087
  if (msg?.stopReason === "aborted") return;
1172
2088
  if (msg?.stopReason === "error") {
1173
2089
  const errorMsg = msg.errorMessage || "unknown error";
1174
- console.error(`[pi-pi] Turn ended with error: ${errorMsg}`);
1175
- console.error(`[pi-pi] model=${msg.model} provider=${msg.provider} api=${msg.api}`);
1176
- console.error(`[pi-pi] usage: input=${msg.usage?.input} output=${msg.usage?.output} cache_read=${msg.usage?.cacheRead}`);
1177
- console.error(`[pi-pi] content blocks: ${msg.content?.length ?? 0}`);
1178
- for (const c of msg.content || []) {
1179
- if (c.type === "toolCall") console.error(`[pi-pi] toolCall: ${c.name}`);
1180
- if (c.type === "text" && c.text) console.error(`[pi-pi] text: ${c.text.slice(0, 100)}`);
1181
- if (c.type === "thinking") console.error(`[pi-pi] thinking: ${c.thinking?.slice(0, 100) || "(redacted)"}`);
1182
- }
2090
+ const contentSummary = (msg.content || []).map((c: any) => {
2091
+ if (c.type === "toolCall") return `toolCall:${c.name}`;
2092
+ if (c.type === "text" && c.text) return `text:${c.text.slice(0, 100)}`;
2093
+ if (c.type === "thinking") return `thinking:${c.thinking?.slice(0, 100) || "(redacted)"}`;
2094
+ return c.type;
2095
+ });
2096
+ getLogger().error({
2097
+ s: "turn",
2098
+ err: errorMsg,
2099
+ model: msg.model,
2100
+ provider: msg.provider,
2101
+ api: msg.api,
2102
+ input: msg.usage?.input,
2103
+ output: msg.usage?.output,
2104
+ cacheRead: msg.usage?.cacheRead,
2105
+ contentBlocks: msg.content?.length ?? 0,
2106
+ contentSummary,
2107
+ }, "turn ended with error");
1183
2108
  orchestrator.errorRetryCount = (orchestrator.errorRetryCount ?? 0) + 1;
1184
- const retryDelays = [2000, 6000, 24000];
1185
- if (orchestrator.errorRetryCount <= retryDelays.length) {
1186
- const delay = retryDelays[orchestrator.errorRetryCount - 1];
1187
- ctx.ui.notify(`API error (attempt ${orchestrator.errorRetryCount}/${retryDelays.length}): ${errorMsg}. Retrying in ${delay / 1000}s...`, "warning");
2109
+ const maxRetries = 5;
2110
+ if (orchestrator.errorRetryCount <= maxRetries) {
2111
+ const delay = 2000 * Math.pow(2, orchestrator.errorRetryCount - 1);
2112
+ ctx.ui.notify(`API error (attempt ${orchestrator.errorRetryCount}/${maxRetries}): ${errorMsg}. Retrying in ${delay / 1000}s...`, "warning");
1188
2113
  const taskToken = orchestrator.activeTaskToken;
1189
2114
  if (orchestrator.pendingRetryTimer) clearTimeout(orchestrator.pendingRetryTimer);
1190
2115
  orchestrator.pendingRetryTimer = setTimeout(() => {
@@ -1193,7 +2118,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1193
2118
  orchestrator.safeSendUserMessage(`[PI-PI] Previous request failed due to an API error. Continue working on the current phase (${phase}).`);
1194
2119
  }, delay);
1195
2120
  } else {
1196
- ctx.ui.notify(`API error persisted after 3 retries: ${errorMsg}. Stopping auto-retry.`, "error");
2121
+ ctx.ui.notify(`API error persisted after ${maxRetries} retries: ${errorMsg}. Stopping auto-retry.`, "error");
1197
2122
  orchestrator.errorRetryCount = 0;
1198
2123
  }
1199
2124
  return;
@@ -1204,76 +2129,14 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1204
2129
  return;
1205
2130
  }
1206
2131
 
2132
+ // While awaiting subagents, completion is driven entirely by the event-driven
2133
+ // owners (checkPlannerCompletion / checkReviewCycleCompletion / tryCompleteReviewCycle,
2134
+ // wired to subagents:completed/failed and the blocking spawn promise's onSettled).
2135
+ // The old 5s setInterval poller was a redundant fallback and has been removed;
2136
+ // turn_end simply returns here (no nudge, no poll) while in an await_* step.
1207
2137
  if (orchestrator.active.state.step === "await_planners" || orchestrator.active.state.step === "await_reviewers") {
1208
- if (!orchestrator.awaitPollTimer) {
1209
- orchestrator.awaitPollTimer = setInterval(() => {
1210
- if (!orchestrator.active) {
1211
- clearInterval(orchestrator.awaitPollTimer!);
1212
- orchestrator.awaitPollTimer = null;
1213
- return;
1214
- }
1215
- if (orchestrator.phaseCompactionPending) return;
1216
- const taskDir = orchestrator.active.dir;
1217
- if (orchestrator.active.state.step === "await_planners") {
1218
- const plansDir = join(taskDir, "plans");
1219
- const plannerCount = Object.values(orchestrator.config.planners).filter((v) => v.enabled).length;
1220
- if (existsSync(plansDir)) {
1221
- const planFiles = readdirSync(plansDir).filter((f) => f.endsWith(".md") && !f.includes("synthesized") && !f.includes("review_"));
1222
- if (planFiles.length >= plannerCount) {
1223
- clearInterval(orchestrator.awaitPollTimer!);
1224
- orchestrator.awaitPollTimer = null;
1225
- orchestrator.spawnedAgentIds.clear();
1226
- orchestrator.pendingSubagentSpawns = 0;
1227
- orchestrator.active.state.step = "synthesize";
1228
- saveTask(orchestrator.active.dir, orchestrator.active.state);
1229
- orchestrator.safeSendUserMessage("[PI-PI] All planners completed. Read their outputs and synthesize the plan.");
1230
- }
1231
- }
1232
- } else if (orchestrator.active.state.step === "await_reviewers" && orchestrator.active.state.reviewCycle) {
1233
- const cycle = orchestrator.active.state.reviewCycle;
1234
- const reviewConfig = cycle.kind === "auto-deep" ? deepReviewConfig(orchestrator.config) : orchestrator.config;
1235
- const phase = orchestrator.active.state.phase;
1236
- const reviewers = phase === "brainstorm"
1237
- ? reviewConfig.brainstormReviewers
1238
- : phase === "plan"
1239
- ? reviewConfig.planReviewers
1240
- : reviewConfig.codeReviewers;
1241
- const reviewerCount = Object.values(reviewers).filter((v) => v.enabled).length;
1242
- const outputs = loadPhaseReviewOutputs(taskDir, phase, cycle.pass);
1243
- if (outputs.length >= reviewerCount) {
1244
- clearInterval(orchestrator.awaitPollTimer!);
1245
- orchestrator.awaitPollTimer = null;
1246
- orchestrator.spawnedAgentIds.clear();
1247
- orchestrator.pendingSubagentSpawns = 0;
1248
-
1249
- if (orchestrator.reviewTransitionToken === orchestrator.activeTaskToken) return;
1250
- orchestrator.reviewTransitionToken = orchestrator.activeTaskToken;
1251
-
1252
- cycle.step = "apply_feedback";
1253
- orchestrator.active.state.step = "apply_feedback";
1254
- saveTask(orchestrator.active.dir, orchestrator.active.state);
1255
- const rendered = outputs.map((o) => `=== ${o.name} ===\n${o.content}`).join("\n\n");
1256
- pi.sendMessage(
1257
- { customType: "pp-review-ready", content: `[PI-PI] Reviewer outputs are ready.\n\n${rendered}`, display: false },
1258
- { deliverAs: "followUp" },
1259
- );
1260
- orchestrator.safeSendUserMessage("[PI-PI] Review cycle is ready for apply_feedback. Read reviewer outputs and proceed.");
1261
- }
1262
- } else {
1263
- clearInterval(orchestrator.awaitPollTimer!);
1264
- orchestrator.awaitPollTimer = null;
1265
- }
1266
- }, 5000);
1267
- }
1268
2138
  return;
1269
2139
  }
1270
- if (orchestrator.awaitPollTimer) {
1271
- clearInterval(orchestrator.awaitPollTimer);
1272
- orchestrator.awaitPollTimer = null;
1273
- }
1274
-
1275
- if (orchestrator.active.type === "brainstorm" && phase === "brainstorm") return;
1276
- if (orchestrator.active.type === "review" && phase === "review") return;
1277
2140
 
1278
2141
  const contentParts = Array.isArray(msg?.content) ? msg.content : [];
1279
2142
  const hasText = contentParts.some((c: any) => c.type === "text" && c.text?.trim());
@@ -1281,78 +2144,51 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1281
2144
  const hasToolResults = event.toolResults && event.toolResults.length > 0;
1282
2145
  const turnWasEmpty = !hasText && !hasToolCalls && !hasToolResults;
1283
2146
 
1284
- if (!turnWasEmpty) {
1285
- const lastPart = contentParts.length > 0 ? contentParts[contentParts.length - 1] : null;
1286
- const endsWithText = lastPart?.type === "text" && lastPart?.text?.trim();
1287
- if (hasText && (!hasToolCalls || endsWithText)) {
1288
- const step = orchestrator.active.state.step;
1289
- if (step !== "await_planners" && step !== "await_reviewers" && !orchestrator.textStopReminderSent) {
1290
- orchestrator.textStopReminderSent = true;
1291
- orchestrator.safeSendUserMessage(`[PI-PI] You stopped without calling pp_phase_complete. If you are done with the ${phase} phase, call pp_phase_complete now. If not, continue working.`);
1292
- }
1293
- } else {
1294
- orchestrator.textStopReminderSent = false;
1295
- }
1296
- return;
1297
- }
1298
- orchestrator.textStopReminderSent = false;
1299
- if (orchestrator.nudgeHalted) return;
1300
- if (orchestrator.spawnedAgentIds.size > 0 || orchestrator.pendingSubagentSpawns > 0) return;
1301
-
1302
- const step = orchestrator.active.state.step;
1303
- if (step === "await_planners" || step === "await_reviewers") return;
1304
-
1305
- const now = Date.now();
2147
+ const isAutonomous = getEffectivePhaseMode(orchestrator.active.state) === "autonomous";
1306
2148
 
1307
- orchestrator.nudgeTimestamps.push(now);
1308
- orchestrator.nudgeTimestamps = orchestrator.nudgeTimestamps.filter((t) => now - t < 60000);
2149
+ // Nudges fire ONLY for plan/implement. brainstorm/debug/review (and quick) are
2150
+ // interactive by nature stopping there is normal, not a stall. They are also
2151
+ // suppressed whenever the controller is not running (a transition handoff or
2152
+ // await is in progress — that pause is plumbing, not a genuine model stop).
2153
+ const nudgesEnabled = (phase === "plan" || phase === "implement") && orchestrator.transitionController.isRunning();
1309
2154
 
1310
- const sendNudge = () => {
1311
- orchestrator.safeSendUserMessage(`[PI-PI] Your previous response was interrupted. Continue working on the current phase (${phase}). Pick up where you left off.`);
1312
- };
2155
+ // A genuine model stop is a turn that produced no forward progress: either a
2156
+ // text-only reply (no tool call, or one that trails into text) or a fully
2157
+ // empty turn. A turn that ended on a tool call IS progress and resets the guard.
2158
+ const lastPart = contentParts.length > 0 ? contentParts[contentParts.length - 1] : null;
2159
+ const endsWithText = lastPart?.type === "text" && !!lastPart?.text?.trim();
2160
+ const isGenuineStop = turnWasEmpty || (hasText && (!hasToolCalls || endsWithText));
1313
2161
 
1314
- if (orchestrator.nudgeTimestamps.length <= 3) {
1315
- sendNudge();
2162
+ if (!isGenuineStop) {
2163
+ // Forward progress — clear the consecutive-nudge guard.
2164
+ orchestrator.consecutiveNudges = 0;
1316
2165
  return;
1317
2166
  }
1318
2167
 
1319
- if (orchestrator.nudgeTimestamps.length >= 5) {
1320
- orchestrator.cooldownHits.push(now);
1321
- orchestrator.cooldownHits = orchestrator.cooldownHits.filter((t) => now - t < 20 * 60 * 1000);
1322
-
1323
- if (orchestrator.cooldownHits.length >= 5) {
1324
- orchestrator.nudgeHalted = true;
1325
- orchestrator.nudgeTimestamps = [];
1326
- orchestrator.cooldownHits = [];
1327
- pi.sendMessage(
1328
- {
1329
- customType: "pp-continuation-halted",
1330
- content: "Agent has been repeatedly interrupted. Auto-continuation paused. Send any message to resume nudging.",
1331
- display: true,
1332
- },
1333
- { deliverAs: "steer" },
1334
- );
1335
- return;
1336
- }
2168
+ if (!nudgesEnabled || orchestrator.nudgeHalted) return;
1337
2169
 
1338
- pi.sendMessage(
2170
+ // Single consecutive-nudge guard: nudge up to MAX_CONSECUTIVE_NUDGES times in
2171
+ // a row, then halt with one notification until the user re-engages (a fresh
2172
+ // before_agent_start resets the counter).
2173
+ const MAX_CONSECUTIVE_NUDGES = 6;
2174
+ if (orchestrator.consecutiveNudges >= MAX_CONSECUTIVE_NUDGES) {
2175
+ orchestrator.nudgeHalted = true;
2176
+ orchestrator.transitionController.sendCustom(
1339
2177
  {
1340
- customType: "pp-continuation-cooldown",
1341
- content: "Agent interrupted repeatedly. Waiting 60 seconds before retrying.",
2178
+ customType: "pp-continuation-halted",
2179
+ content: "Agent has been repeatedly interrupted without making progress. Auto-continuation paused. Send any message to resume.",
1342
2180
  display: true,
1343
2181
  },
1344
- { deliverAs: "steer" },
2182
+ "context",
1345
2183
  );
1346
- orchestrator.nudgeTimestamps = [];
1347
- const cooldownToken = orchestrator.activeTaskToken;
1348
- setTimeout(() => {
1349
- if (orchestrator.activeTaskToken !== cooldownToken || !orchestrator.active) return;
1350
- sendNudge();
1351
- }, 60000);
1352
2184
  return;
1353
2185
  }
1354
2186
 
1355
- sendNudge();
2187
+ orchestrator.consecutiveNudges++;
2188
+ const nudge = isAutonomous
2189
+ ? `[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.`
2190
+ : `[PI-PI] Continue the ${phase} phase where you left off.`;
2191
+ orchestrator.safeSendUserMessage(nudge);
1356
2192
  });
1357
2193
 
1358
2194