@ilya-lesikov/pi-pi 0.4.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 +73 -31
  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 +1191 -360
  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 +295 -148
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -35
  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 -29
  52. package/extensions/orchestrator/phases/review-task.ts +3 -3
  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 +2631 -392
  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
- pi.sendUserMessage("[PI-PI] Review cycle is ready for apply_feedback. Read reviewer outputs and proceed.", { deliverAs: "followUp" });
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,43 +298,78 @@ 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();
207
- orchestrator.active.state.phase = "done";
355
+ orchestrator.active.state.reviewCycle = null;
208
356
  saveTask(orchestrator.active.dir, orchestrator.active.state);
209
357
  const desc = orchestrator.active.description;
358
+ const type = orchestrator.active.type;
210
359
  await orchestrator.cleanupActive();
211
360
  const taskStore = (globalThis as any)[Symbol.for("pi-tasks:store")];
212
361
  taskStore?.clearAll?.();
213
- return `Task "${desc}" stopped.`;
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
+
372
+ return `Task "${desc}" stopped. Use /pp → Resume to continue.`;
214
373
  }
215
374
 
216
375
  export function finalizeReviewCycle(task: ActiveTask): void {
@@ -218,19 +377,184 @@ export function finalizeReviewCycle(task: ActiveTask): void {
218
377
  const kind = task.state.reviewCycle.kind;
219
378
  task.state.reviewPass = task.state.reviewCycle.pass;
220
379
  task.reviewPass = task.state.reviewPass;
221
- if (!task.state.reviewPassByKind) task.state.reviewPassByKind = {};
222
- task.state.reviewPassByKind[kind] = (task.state.reviewPassByKind[kind] ?? 0) + 1;
380
+ incrementReviewPass(task, kind);
223
381
  task.state.reviewCycle = null;
224
382
  task.state.step = "user_gate";
225
383
  saveTask(task.dir, task.state);
226
384
  }
227
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
+
228
412
  function registerOrchestratorTools(orchestrator: Orchestrator): void {
413
+ registerRepoTool(orchestrator);
229
414
  registerPhaseCompleteTool(orchestrator);
230
415
  registerCommitTool(orchestrator);
231
416
  registerSpecifyReviewsTool(orchestrator);
232
417
  }
233
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
+
234
558
  function openCodeReviewDirect(
235
559
  pi: ExtensionAPI,
236
560
  payload: Record<string, unknown>,
@@ -260,7 +584,8 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
260
584
  description:
261
585
  "Specify which repositories and commit ranges to open in Plannotator for code review. " +
262
586
  "Called when the user requests a Plannotator code review. " +
263
- "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).",
264
589
  parameters: Type.Object({
265
590
  reviews: Type.Array(Type.Object({
266
591
  cwd: Type.String({ description: "Absolute path to the git repository" }),
@@ -268,6 +593,9 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
268
593
  })),
269
594
  }),
270
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
+ }
271
599
  if (!params.reviews || params.reviews.length === 0) {
272
600
  return { content: [{ type: "text" as const, text: "No reviews specified." }], isError: true as const, details: {} };
273
601
  }
@@ -276,12 +604,89 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
276
604
  let hasNeedsChanges = false;
277
605
  for (const review of params.reviews) {
278
606
  ctx.ui?.setWorkingMessage?.(`Waiting for Plannotator review: ${review.range}…`);
279
- 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
+
280
667
  const result = await openCodeReviewDirect(pi, {
281
- cwd: review.cwd,
668
+ cwd: reviewCwd,
282
669
  diffType: "branch",
283
- defaultBranch: rangeBase,
670
+ defaultBranch: compareBase,
284
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
+
285
690
  if ("error" in result) {
286
691
  results.push(`${review.cwd} (${review.range}): ${result.error}`);
287
692
  } else {
@@ -310,9 +715,12 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
310
715
  try {
311
716
  const { showActiveTaskMenu } = await import("./pp-menu.js");
312
717
  const text = await showActiveTaskMenu(orchestrator, ctx, `Plannotator review complete.\n\n${summary}`, "tool");
313
- if (orchestrator.phaseCompactionPending || orchestrator.taskDoneCompactionPending) {
314
- await orchestrator.waitForCompaction();
315
- return { content: [{ type: "text" as const, text: "Phase transition complete." }], details: {} };
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()) {
722
+ ctx.abort?.();
723
+ return { content: [{ type: "text" as const, text: "" }], details: {} };
316
724
  }
317
725
  if (!text) {
318
726
  return { content: [{ type: "text" as const, text: "User dismissed the menu. Wait for the user's next message. When you resume work, update USER_REQUEST.md and RESEARCH.md with any new findings before calling pp_phase_complete." }], details: {} };
@@ -327,7 +735,7 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
327
735
 
328
736
  function loadPhaseReviewOutputs(taskDir: string, phase: string, pass: number): { name: string; content: string }[] {
329
737
  if (phase === "brainstorm") return loadBrainstormReviewOutputs(taskDir, pass);
330
- if (phase === "plan") return loadPlanReviewOutputs(taskDir);
738
+ if (phase === "plan") return loadPlanReviewOutputs(taskDir, pass);
331
739
  return loadCodeReviewOutputs(taskDir, pass);
332
740
  }
333
741
 
@@ -340,37 +748,65 @@ function registerCommitTool(orchestrator: Orchestrator): void {
340
748
  description:
341
749
  "Commit modified files with a descriptive message. Call after completing a logical " +
342
750
  "unit of work (e.g. implementing one plan item, fixing a bug, adding a test). " +
343
- "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.",
344
754
  parameters: Type.Object({
345
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." })),
346
757
  }),
347
758
  async execute(_toolCallId, params: any) {
348
759
  if (!orchestrator.active) {
349
760
  return { content: [{ type: "text" as const, text: "No active task." }], isError: true as const, details: {} };
350
761
  }
351
- if (!orchestrator.config.autoCommit) {
762
+ if (!orchestrator.config.general.autoCommit) {
352
763
  return { content: [{ type: "text" as const, text: "autoCommit is disabled in config." }], details: {} };
353
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
+
354
779
  const files: string[] = [];
355
780
  try {
356
- const gitResult = await pi.exec("git", ["diff", "--name-only"], { cwd: orchestrator.cwd, timeout: 5000 });
357
- if (gitResult.code === 0 && gitResult.stdout.trim()) {
358
- files.push(...gitResult.stdout.trim().split("\n").filter(Boolean));
359
- }
360
- const stagedResult = await pi.exec("git", ["diff", "--name-only", "--cached"], { cwd: orchestrator.cwd, timeout: 5000 });
361
- if (stagedResult.code === 0 && stagedResult.stdout.trim()) {
362
- for (const f of stagedResult.stdout.trim().split("\n").filter(Boolean)) {
363
- 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
+ }
364
792
  }
365
793
  }
366
794
  } catch {}
367
795
  if (files.length === 0) {
368
796
  return { content: [{ type: "text" as const, text: "No modified files to commit." }], details: {} };
369
797
  }
370
- const result = autoCommit(files, params.message, orchestrator.cwd);
798
+ const result = autoCommit(files, params.message, commitRepoPath);
371
799
  if (result.ok) {
372
- orchestrator.active.modifiedFiles.clear();
373
- 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];
374
810
  saveTask(orchestrator.active.dir, orchestrator.active.state);
375
811
  orchestrator.commitReminderSent = false;
376
812
  return { content: [{ type: "text" as const, text: `Committed ${files.length} file(s): ${result.commitHash ?? "ok"}` }], details: {} };
@@ -404,13 +840,87 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
404
840
  const count = orchestrator.spawnedAgentIds.size + orchestrator.pendingSubagentSpawns;
405
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: {} };
406
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
+ }
407
909
  ctx.ui.setWorkingMessage?.("Waiting for user approval…");
408
910
  try {
409
911
  const { showActiveTaskMenu } = await import("./pp-menu.js");
410
912
  const text = await showActiveTaskMenu(orchestrator, ctx, params.summary, "tool");
411
- if (orchestrator.phaseCompactionPending || orchestrator.taskDoneCompactionPending) {
412
- await orchestrator.waitForCompaction();
413
- return { content: [{ type: "text" as const, text: "Phase transition complete." }], details: {} };
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()) {
922
+ ctx.abort?.();
923
+ return { content: [{ type: "text" as const, text: "" }], details: {} };
414
924
  }
415
925
  if (!text) {
416
926
  return { content: [{ type: "text" as const, text: "User dismissed the menu. Wait for the user's next message. When you resume work, update USER_REQUEST.md and RESEARCH.md with any new findings before calling pp_phase_complete." }], details: {} };
@@ -423,9 +933,89 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
423
933
  });
424
934
  }
425
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
+
426
991
  export function registerEventHandlers(orchestrator: Orchestrator): void {
427
992
  const pi = orchestrator.pi;
428
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
+
429
1019
  function getUsageTracker(): UsageTracker | undefined {
430
1020
  return (globalThis as any)[USAGE_TRACKER_KEY] as UsageTracker | undefined;
431
1021
  }
@@ -445,6 +1035,25 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
445
1035
  if (event === "first_turn" && lifecycle.firstTurnAt == null) lifecycle.firstTurnAt = now;
446
1036
  orchestrator.agentLifecycle.set(data.id, lifecycle);
447
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
+
448
1057
  const ageMs = lifecycle.createdAt == null ? 0 : now - lifecycle.createdAt;
449
1058
  const startedDeltaMs = lifecycle.startedAt == null || lifecycle.createdAt == null ? undefined : lifecycle.startedAt - lifecycle.createdAt;
450
1059
  const firstToolDeltaMs = lifecycle.firstToolAt == null || lifecycle.createdAt == null ? undefined : lifecycle.firstToolAt - lifecycle.createdAt;
@@ -452,8 +1061,8 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
452
1061
  const pending = orchestrator.pendingSubagentSpawns;
453
1062
  const running = orchestrator.spawnedAgentIds.size;
454
1063
  const desc = data.description || lifecycle.description || data.type || lifecycle.type || data.id;
455
- appendTaskLog(orchestrator.active.dir, "subagent-lifecycle.jsonl", {
456
- timestamp: new Date(now).toISOString(),
1064
+ getLogger().debug({
1065
+ s: "subagent",
457
1066
  id: data.id,
458
1067
  event,
459
1068
  description: desc,
@@ -468,7 +1077,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
468
1077
  createdToFirstTurnMs: firstTurnDeltaMs ?? null,
469
1078
  toolName: data.toolName ?? null,
470
1079
  turnCount: data.turnCount ?? null,
471
- });
1080
+ }, "subagent lifecycle event");
472
1081
  }
473
1082
 
474
1083
  function startStaleAgentWatchdog(): void {
@@ -481,7 +1090,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
481
1090
  }
482
1091
  const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
483
1092
  const now = Date.now();
484
- const staleMs = orchestrator.config.timeouts.agentStale;
1093
+ const staleMs = orchestrator.config.performance.internals.subagentStale;
485
1094
  for (const [id, spawnTime] of orchestrator.agentSpawnTimes) {
486
1095
  const record = mgr?.getRecord?.(id);
487
1096
  if (record?.status === "running" || record?.status === "queued") continue;
@@ -493,13 +1102,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
493
1102
  orchestrator.agentSpawnTimes.delete(id);
494
1103
  orchestrator.agentDescriptions.delete(id);
495
1104
  orchestrator.agentLifecycle.delete(id);
496
- pi.sendMessage(
1105
+ orchestrator.transitionController.sendCustom(
497
1106
  {
498
1107
  customType: "pp-agent-stale",
499
1108
  content: `Aborted stale agent "${desc}" — no completion after ${Math.round(staleMs / 1000)}s.`,
500
1109
  display: true,
501
1110
  },
502
- { deliverAs: "steer" },
1111
+ "context",
503
1112
  );
504
1113
  }
505
1114
  }
@@ -539,13 +1148,23 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
539
1148
  trackSubagentEvent(data, "first_turn");
540
1149
  });
541
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
+
542
1160
  function checkPlannerCompletion(): void {
543
1161
  if (
544
1162
  !orchestrator.active ||
545
1163
  orchestrator.active.state.phase !== "plan" ||
546
1164
  orchestrator.active.state.step !== "await_planners" ||
547
1165
  orchestrator.spawnedAgentIds.size > 0 ||
548
- orchestrator.pendingSubagentSpawns > 0
1166
+ orchestrator.pendingSubagentSpawns > 0 ||
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,35 +1258,42 @@ 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
- });
597
- pi.sendUserMessage(`[PI-PI] Retrying failed planners: ${variantsText}.`, { deliverAs: "followUp" });
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
+ );
1290
+ orchestrator.safeSendUserMessage(`[PI-PI] Retrying failed planners: ${variantsText}.`);
598
1291
  return;
599
1292
  }
600
1293
  }
601
1294
 
602
1295
  if (choice === "Stop task") {
603
- pi.sendUserMessage(`[PI-PI] ${await stopTask(orchestrator)}`, { deliverAs: "followUp" });
1296
+ orchestrator.safeSendUserMessage(`[PI-PI] ${await stopTask(orchestrator)}`);
604
1297
  return;
605
1298
  }
606
1299
 
@@ -616,23 +1309,29 @@ 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
- pi.sendUserMessage("[PI-PI] All planners completed. Read their outputs and synthesize the plan.", { deliverAs: "followUp" });
1334
+ orchestrator.safeSendUserMessage("[PI-PI] All planners completed. Read their outputs and synthesize the plan.");
636
1335
  }
637
1336
 
638
1337
  function checkReviewCycleCompletion(): void {
@@ -640,12 +1339,96 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
640
1339
  !orchestrator.active?.state.reviewCycle ||
641
1340
  orchestrator.active.state.reviewCycle.step !== "await_reviewers" ||
642
1341
  orchestrator.spawnedAgentIds.size > 0 ||
643
- orchestrator.pendingSubagentSpawns > 0
1342
+ orchestrator.pendingSubagentSpawns > 0 ||
1343
+ orchestrator.transitionController.isTransitioning()
644
1344
  ) return;
645
1345
 
646
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
+
647
1429
  if (
648
1430
  failedReviewerVariants.length > 0 &&
1431
+ effectiveMode !== "autonomous" &&
649
1432
  !orchestrator.reviewerFailureDialogPending &&
650
1433
  orchestrator.lastCtx
651
1434
  ) {
@@ -663,13 +1446,9 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
663
1446
  const cycle = orchestrator.active?.state.reviewCycle;
664
1447
  if (cycle) {
665
1448
  const pass = cycle.pass;
666
- const reviewConfig = cycle.kind === "auto-deep" ? deepReviewConfig(orchestrator.config) : orchestrator.config;
667
1449
  const phase = orchestrator.active!.state.phase;
668
- const sourceReviewers = phase === "brainstorm"
669
- ? reviewConfig.brainstormReviewers
670
- : phase === "plan"
671
- ? reviewConfig.planReviewers
672
- : reviewConfig.codeReviewers;
1450
+ const presetName = normalizeStoredReviewPresetName(orchestrator, phase);
1451
+ const sourceReviewers = resolveReviewers(orchestrator, phase, presetName);
673
1452
  const failedSet = new Set(failedReviewerVariants);
674
1453
  const scopedReviewers: typeof sourceReviewers = {};
675
1454
  for (const [name, cfg] of Object.entries(sourceReviewers)) {
@@ -677,42 +1456,62 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
677
1456
  }
678
1457
  const retryCount = Object.keys(scopedReviewers).length;
679
1458
  if (retryCount > 0) {
680
- const retryConfig = phase === "brainstorm"
681
- ? { ...reviewConfig, brainstormReviewers: scopedReviewers }
682
- : phase === "plan"
683
- ? { ...reviewConfig, planReviewers: scopedReviewers }
684
- : { ...reviewConfig, codeReviewers: scopedReviewers };
685
1459
  const spawnFn = phase === "brainstorm"
686
- ? () => 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
+ )
687
1471
  : phase === "plan"
688
- ? () => spawnPlanReviewers(pi, orchestrator.cwd, orchestrator.active!.dir, orchestrator.active!.taskId, retryConfig)
689
- : () => 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
+ );
690
1495
  orchestrator.failedReviewerVariants = [];
691
1496
  orchestrator.pendingSubagentSpawns = retryCount;
692
1497
  cycle.step = "await_reviewers";
693
1498
  orchestrator.active!.state.step = "await_reviewers";
694
1499
  saveTask(orchestrator.active!.dir, orchestrator.active!.state);
695
- spawnFn().then((result) => {
696
- orchestrator.failedReviewerVariants = result.failedVariants;
697
- if (result.spawned === 0) orchestrator.pendingSubagentSpawns = 0;
698
- for (const id of result.agentIds ?? []) {
699
- orchestrator.spawnedAgentIds.delete(id);
700
- }
701
- orchestrator.pendingSubagentSpawns = 0;
702
- checkReviewCycleCompletion();
703
- }).catch((err) => {
704
- orchestrator.pendingSubagentSpawns = 0;
705
- console.error(`[pi-pi] retry spawn reviewers failed (${phase}): ${err.message}`);
706
- checkReviewCycleCompletion();
1500
+ handleSpawnResult(orchestrator, spawnFn(), {
1501
+ kind: "reviewer",
1502
+ logScope: "review",
1503
+ logMessage: "retry spawn reviewers failed",
1504
+ logExtra: { phase },
1505
+ onSettled: checkReviewCycleCompletion,
707
1506
  });
708
- pi.sendUserMessage(`[PI-PI] Retrying failed reviewers: ${variantsText}.`, { deliverAs: "followUp" });
1507
+ orchestrator.safeSendUserMessage(`[PI-PI] Retrying failed reviewers: ${variantsText}.`);
709
1508
  return;
710
1509
  }
711
1510
  }
712
1511
  }
713
1512
 
714
1513
  if (choice === "Stop task") {
715
- pi.sendUserMessage(`[PI-PI] ${await stopTask(orchestrator)}`, { deliverAs: "followUp" });
1514
+ orchestrator.safeSendUserMessage(`[PI-PI] ${await stopTask(orchestrator)}`);
716
1515
  return;
717
1516
  }
718
1517
 
@@ -722,7 +1521,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
722
1521
  orchestrator.active.state.reviewCycle = null;
723
1522
  orchestrator.active.state.step = "user_gate";
724
1523
  saveTask(orchestrator.active.dir, orchestrator.active.state);
725
- pi.sendUserMessage("[PI-PI] Review cycle skipped. Use /pp to choose the next action.", { deliverAs: "followUp" });
1524
+ orchestrator.safeSendUserMessage("[PI-PI] Review cycle skipped. Use /pp to choose the next action.");
726
1525
  return;
727
1526
  }
728
1527
 
@@ -735,6 +1534,11 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
735
1534
  return;
736
1535
  }
737
1536
 
1537
+ if (orchestrator.active.state.reviewerFailureAutoRetried) {
1538
+ orchestrator.active.state.reviewerFailureAutoRetried = false;
1539
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
1540
+ }
1541
+ markAllAgentsConsumed();
738
1542
  tryCompleteReviewCycle(orchestrator);
739
1543
  }
740
1544
 
@@ -763,13 +1567,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
763
1567
  const tokens = data.tokens?.total ? `${data.tokens.total} tok` : "";
764
1568
  const stats = [duration, tokens].filter(Boolean).join(", ");
765
1569
 
766
- pi.sendMessage(
1570
+ orchestrator.transitionController.sendCustom(
767
1571
  {
768
1572
  customType: "pp-subagent-result",
769
1573
  content: `${desc} completed${stats ? ` (${stats})` : ""}. Use get_subagent_result to read the output.`,
770
1574
  display: false,
771
1575
  },
772
- { deliverAs: "steer" },
1576
+ "context",
773
1577
  );
774
1578
 
775
1579
  checkPlannerCompletion();
@@ -796,7 +1600,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
796
1600
  orchestrator.abortAllSubagents();
797
1601
  }
798
1602
 
799
- pi.sendMessage(
1603
+ orchestrator.transitionController.sendCustom(
800
1604
  {
801
1605
  customType: "pp-subagent-error",
802
1606
  content: isApiError
@@ -804,7 +1608,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
804
1608
  : `**${desc}** failed: ${data.error || "unknown error"}. Do NOT retry — continue with available information.`,
805
1609
  display: true,
806
1610
  },
807
- { deliverAs: "steer" },
1611
+ "context",
808
1612
  );
809
1613
 
810
1614
  checkPlannerCompletion();
@@ -812,6 +1616,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
812
1616
  });
813
1617
 
814
1618
  pi.on("session_before_switch" as any, async () => {
1619
+ finalizeTracer();
815
1620
  if (!orchestrator.active) return;
816
1621
  cancelPendingPlannotatorWait(orchestrator);
817
1622
  orchestrator.abortAllSubagents();
@@ -827,14 +1632,47 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
827
1632
  try {
828
1633
  dumpUsageSummary(tracker, sessionId);
829
1634
  } catch (err: any) {
830
- console.error(`[pi-pi] Failed to dump usage summary: ${err.message}`);
1635
+ getLogger().error({ s: "usage", err: err.message }, "failed to dump usage summary");
831
1636
  }
1637
+ flushLogs();
1638
+ finalizeTracer();
832
1639
  delete (globalThis as any)[USAGE_TRACKER_KEY];
833
1640
  });
834
1641
 
835
1642
  pi.on("session_start", async (_event, ctx) => {
836
1643
  orchestrator.lastCtx = ctx;
837
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
+ }
838
1676
 
839
1677
  if (!(globalThis as any)[SUBAGENT_SESSION_KEY]) {
840
1678
  const tracker = createUsageTracker();
@@ -864,7 +1702,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
864
1702
  `Duplicate tools detected: ${duplicates.join(", ")}. ` +
865
1703
  `Remove the conflicting packages: pi remove npm:@tintinweb/pi-subagents npm:@tintinweb/pi-tasks npm:pi-ask-user`;
866
1704
  ctx.ui.notify(msg, "error");
867
- console.error(`[pi-pi] FATAL: ${msg}`);
1705
+ getLogger().error({ s: "init", duplicates }, "conflicting extensions detected");
868
1706
  return;
869
1707
  }
870
1708
 
@@ -873,16 +1711,28 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
873
1711
  setPI(pi);
874
1712
  await initFlantOnStartup(pi);
875
1713
  } catch (err: any) {
876
- console.error(`[pi-pi] Flant infra init failed: ${err.message}`);
1714
+ getLogger().error({ s: "flant", err: err.message }, "flant infra init failed");
877
1715
  }
878
1716
 
879
1717
  try {
880
1718
  orchestrator.config = loadConfig(orchestrator.cwd);
881
1719
  } catch (err: any) {
882
- 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");
883
1721
  return;
884
1722
  }
885
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);
886
1736
  registerCbmTools(pi, orchestrator.cwd);
887
1737
  registerExaTools(pi);
888
1738
  registerAstSearchTool(pi, orchestrator.cwd);
@@ -890,7 +1740,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
890
1740
  setExtensionOnlyMode(pi);
891
1741
  orchestrator.registerAgents();
892
1742
 
893
- const found = getActiveTask(orchestrator.cwd, orchestrator.config.timeouts.lockStale);
1743
+ const found = getActiveTask(orchestrator.cwd, orchestrator.config.performance.internals.taskLockStale);
894
1744
  if (found) {
895
1745
  ctx.ui.notify(
896
1746
  `Paused task: "${taskName(found.dir)}" (${found.type}, phase: ${found.state.phase}). Run /pp and choose Resume to continue.`,
@@ -912,34 +1762,77 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
912
1762
 
913
1763
  pi.on("before_agent_start", async (event, ctx) => {
914
1764
  orchestrator.lastCtx = ctx;
915
- if (orchestrator.taskDoneCompactionPending || orchestrator.phaseCompactionPending) {
916
- 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())) {
917
1770
  return;
918
1771
  }
919
1772
  if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
920
1773
 
921
- const step = orchestrator.active.state.step;
922
- if (step === "await_planners" || step === "await_reviewers") {
923
- ctx.abort();
924
- 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;
925
1783
  }
926
-
927
- orchestrator.nudgeHalted = false;
928
1784
  orchestrator.updateStatus(ctx);
929
1785
 
930
1786
  const phasePrompt = orchestrator.getPhasePrompt(ctx);
931
- 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);
932
1793
  const systemSnippets = systemContextFiles.map((f) => f.content).join("\n\n");
933
-
934
- const fullAddition = [WORKING_PRINCIPLES, COMMUNICATION, systemSnippets, phasePrompt].filter(Boolean).join("\n\n");
935
- 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");
936
1822
 
937
1823
  return {
938
- systemPrompt: event.systemPrompt + "\n\n" + fullAddition,
1824
+ systemPrompt: fullPrompt,
939
1825
  };
940
1826
  });
941
1827
 
942
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
+
943
1836
  if (event.toolName === "Agent" && orchestrator.active) {
944
1837
  const input = event.input as Record<string, unknown>;
945
1838
  const requestedType = ((input.subagent_type as string) || "").toLowerCase();
@@ -951,16 +1844,16 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
951
1844
 
952
1845
  if (isExplore) {
953
1846
  input.subagent_type = "explore";
954
- input.model = orchestrator.config.agents.explore.model;
955
- 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;
956
1849
  } else if (isLibrarian) {
957
1850
  input.subagent_type = "librarian";
958
- input.model = orchestrator.config.agents.librarian.model;
959
- 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;
960
1853
  } else {
961
1854
  input.subagent_type = "task";
962
- input.model = orchestrator.config.agents.task.model;
963
- 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;
964
1857
  }
965
1858
  }
966
1859
 
@@ -971,25 +1864,38 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
971
1864
  const ppStateDir = resolve(orchestrator.cwd, ".pp", "state");
972
1865
  const ppDir = resolve(orchestrator.cwd, ".pp");
973
1866
 
974
- if (resolvedPath.startsWith(ppStateDir + "/") || resolvedPath === ppStateDir) {
1867
+ if (isPathInside(ppStateDir, resolvedPath)) {
975
1868
  if (!resolvedPath.endsWith(".md")) {
976
1869
  return { block: true, reason: "Cannot write non-.md files in .pp/state/" };
977
1870
  }
978
1871
  }
979
1872
 
980
1873
  const fileName = basename(resolvedPath);
981
- if (fileName === "state.json" && (resolvedPath.startsWith(ppDir + "/") || resolvedPath === ppDir)) {
1874
+ if (fileName === "state.json" && isPathInside(ppDir, resolvedPath)) {
982
1875
  return { block: true, reason: "state.json is managed by the extension" };
983
1876
  }
984
1877
 
985
- if (fileName === "config.json" && (resolvedPath.startsWith(ppDir + "/") || resolvedPath === ppDir)) {
1878
+ if (fileName === "config.json" && isPathInside(ppDir, resolvedPath)) {
986
1879
  return { block: true, reason: "config.json is managed by the user, not the LLM" };
987
1880
  }
988
1881
  }
989
1882
  return;
990
1883
  });
991
1884
 
992
- 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
+
993
1899
  if (!orchestrator.active) return;
994
1900
 
995
1901
  if ((event.toolName === "edit" || event.toolName === "write") && !event.isError) {
@@ -1024,7 +1930,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1024
1930
  }
1025
1931
  }
1026
1932
  const artifactsDir = join(taskDir, "artifacts");
1027
- if (resolvedWrite.startsWith(artifactsDir + "/") && resolvedWrite.endsWith(".md")) {
1933
+ if (isPathInside(artifactsDir, resolvedWrite) && resolvedWrite.endsWith(".md")) {
1028
1934
  const content = readFileSync(resolvedWrite, "utf-8");
1029
1935
  const result = validateArtifact(content);
1030
1936
  if (!result.ok) {
@@ -1037,24 +1943,39 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1037
1943
  }
1038
1944
  }
1039
1945
 
1040
- if (filePath.includes(".pp/")) return;
1946
+ const ppDir = resolve(orchestrator.cwd, ".pp");
1947
+ if (isPathInside(ppDir, resolvedWrite)) return;
1041
1948
 
1042
1949
  if (orchestrator.active.state.phase !== "implement") return;
1043
1950
 
1044
- orchestrator.active.modifiedFiles.add(filePath);
1951
+ orchestrator.active.modifiedFiles.add(resolvedWrite);
1045
1952
  orchestrator.active.state.modifiedFiles = [...orchestrator.active.modifiedFiles];
1046
- if (!orchestrator.active.state.repoCwd) {
1047
- const dir = resolve(filePath, "..");
1048
- try {
1049
- const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: dir, timeout: 5000 });
1050
- if (result.code === 0 && result.stdout.trim()) {
1051
- orchestrator.active.state.repoCwd = result.stdout.trim();
1052
- }
1053
- } catch {}
1054
- }
1953
+ orchestrator.active.state.reviewApprovedClean = false;
1055
1954
  try { saveTask(orchestrator.active.dir, orchestrator.active.state); } catch {}
1056
1955
 
1057
- 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
+ }
1058
1979
  const failures = afterEditResults.filter((r) => !r.ok);
1059
1980
 
1060
1981
  if (failures.length > 0) {
@@ -1083,10 +2004,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1083
2004
  });
1084
2005
 
1085
2006
  pi.on("session_before_compact", async (event, _ctx) => {
1086
- if (orchestrator.taskDoneCompactionPending) {
1087
- const summary = orchestrator.taskDoneCompactionSummary;
1088
- orchestrator.taskDoneCompactionPending = false;
1089
- 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.";
1090
2014
  return {
1091
2015
  compaction: {
1092
2016
  summary,
@@ -1096,18 +2020,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1096
2020
  };
1097
2021
  }
1098
2022
 
2023
+ // Natural (user-triggered) compaction: re-inject phase artifacts so context
2024
+ // survives.
1099
2025
  if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
1100
2026
 
1101
- if (orchestrator.phaseCompactionPending) {
1102
- return {
1103
- compaction: {
1104
- summary: `Previous phase (${orchestrator.active.state.phase}) completed. Transitioning to next phase.`,
1105
- firstKeptEntryId: event.preparation.firstKeptEntryId,
1106
- tokensBefore: event.preparation.tokensBefore,
1107
- },
1108
- };
1109
- }
1110
-
1111
2027
  const artifacts = getPhaseArtifacts(orchestrator.active.dir, orchestrator.active.state.phase);
1112
2028
  if (artifacts.length === 0) return;
1113
2029
 
@@ -1115,13 +2031,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1115
2031
  .map((a) => `=== ${a.name} ===\n${a.content}`)
1116
2032
  .join("\n\n");
1117
2033
 
1118
- pi.sendMessage(
2034
+ orchestrator.transitionController.sendCustom(
1119
2035
  {
1120
2036
  customType: "pp-artifact-reinject",
1121
2037
  content: `[PI-PI ARTIFACTS — re-injected after compaction]\n\n${artifactText}`,
1122
2038
  display: false,
1123
2039
  },
1124
- { deliverAs: "steer" },
2040
+ "context",
1125
2041
  );
1126
2042
 
1127
2043
  return;
@@ -1133,32 +2049,36 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1133
2049
  if (usageTracker && msg?.usage) {
1134
2050
  const input = typeof msg.usage.input === "number" ? msg.usage.input : 0;
1135
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";
1136
2053
  const cacheRead = typeof msg.usage.cacheRead === "number" ? msg.usage.cacheRead : 0;
1137
2054
  const cacheWrite = typeof msg.usage.cacheWrite === "number" ? msg.usage.cacheWrite : 0;
1138
2055
  const cost = typeof msg.usage.cost?.total === "number" ? msg.usage.cost.total : 0;
1139
2056
  const modelId = (typeof msg.model === "string" && msg.model) || ctx.model?.id || "unknown-model";
1140
2057
  const provider = (typeof msg.provider === "string" && msg.provider) || ctx.model?.provider || "unknown";
1141
- usageTracker.recordTurn(modelId, provider, input, output, cacheRead, cacheWrite, cost);
2058
+ usageTracker.recordTurn(modelId, provider, input, output, cacheRead, cacheWrite, cost, cacheSupported);
1142
2059
  (ctx.ui as any)?.requestRender?.();
1143
2060
  }
1144
2061
 
1145
2062
  if (!orchestrator.active || orchestrator.active.state.phase === "done") 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;
1146
2066
  orchestrator.updateStatus(ctx);
1147
2067
 
1148
2068
  if (
1149
2069
  orchestrator.active.state.phase === "implement" &&
1150
- orchestrator.config.autoCommit &&
2070
+ orchestrator.config.general.autoCommit &&
1151
2071
  orchestrator.active.modifiedFiles.size > 0 &&
1152
2072
  !orchestrator.commitReminderSent
1153
2073
  ) {
1154
2074
  orchestrator.commitReminderSent = true;
1155
- pi.sendMessage(
2075
+ orchestrator.transitionController.sendCustom(
1156
2076
  {
1157
2077
  customType: "pp-commit-reminder",
1158
- 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.`,
1159
2079
  display: false,
1160
2080
  },
1161
- { deliverAs: "steer" },
2081
+ "context",
1162
2082
  );
1163
2083
  }
1164
2084
 
@@ -1167,29 +2087,38 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1167
2087
  if (msg?.stopReason === "aborted") return;
1168
2088
  if (msg?.stopReason === "error") {
1169
2089
  const errorMsg = msg.errorMessage || "unknown error";
1170
- console.error(`[pi-pi] Turn ended with error: ${errorMsg}`);
1171
- console.error(`[pi-pi] model=${msg.model} provider=${msg.provider} api=${msg.api}`);
1172
- console.error(`[pi-pi] usage: input=${msg.usage?.input} output=${msg.usage?.output} cache_read=${msg.usage?.cacheRead}`);
1173
- console.error(`[pi-pi] content blocks: ${msg.content?.length ?? 0}`);
1174
- for (const c of msg.content || []) {
1175
- if (c.type === "toolCall") console.error(`[pi-pi] toolCall: ${c.name}`);
1176
- if (c.type === "text" && c.text) console.error(`[pi-pi] text: ${c.text.slice(0, 100)}`);
1177
- if (c.type === "thinking") console.error(`[pi-pi] thinking: ${c.thinking?.slice(0, 100) || "(redacted)"}`);
1178
- }
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");
1179
2108
  orchestrator.errorRetryCount = (orchestrator.errorRetryCount ?? 0) + 1;
1180
- const retryDelays = [2000, 6000, 24000];
1181
- if (orchestrator.errorRetryCount <= retryDelays.length) {
1182
- const delay = retryDelays[orchestrator.errorRetryCount - 1];
1183
- 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");
1184
2113
  const taskToken = orchestrator.activeTaskToken;
1185
2114
  if (orchestrator.pendingRetryTimer) clearTimeout(orchestrator.pendingRetryTimer);
1186
2115
  orchestrator.pendingRetryTimer = setTimeout(() => {
1187
2116
  orchestrator.pendingRetryTimer = null;
1188
2117
  if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active) return;
1189
- pi.sendUserMessage(`[PI-PI] Previous request failed due to an API error. Continue working on the current phase (${phase}).`, { deliverAs: "followUp" });
2118
+ orchestrator.safeSendUserMessage(`[PI-PI] Previous request failed due to an API error. Continue working on the current phase (${phase}).`);
1190
2119
  }, delay);
1191
2120
  } else {
1192
- 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");
1193
2122
  orchestrator.errorRetryCount = 0;
1194
2123
  }
1195
2124
  return;
@@ -1200,75 +2129,14 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1200
2129
  return;
1201
2130
  }
1202
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.
1203
2137
  if (orchestrator.active.state.step === "await_planners" || orchestrator.active.state.step === "await_reviewers") {
1204
- if (!orchestrator.awaitPollTimer) {
1205
- orchestrator.awaitPollTimer = setInterval(() => {
1206
- if (!orchestrator.active) {
1207
- clearInterval(orchestrator.awaitPollTimer!);
1208
- orchestrator.awaitPollTimer = null;
1209
- return;
1210
- }
1211
- const taskDir = orchestrator.active.dir;
1212
- if (orchestrator.active.state.step === "await_planners") {
1213
- const plansDir = join(taskDir, "plans");
1214
- const plannerCount = Object.values(orchestrator.config.planners).filter((v) => v.enabled).length;
1215
- if (existsSync(plansDir)) {
1216
- const planFiles = readdirSync(plansDir).filter((f) => f.endsWith(".md") && !f.includes("synthesized") && !f.includes("review_"));
1217
- if (planFiles.length >= plannerCount) {
1218
- clearInterval(orchestrator.awaitPollTimer!);
1219
- orchestrator.awaitPollTimer = null;
1220
- orchestrator.spawnedAgentIds.clear();
1221
- orchestrator.pendingSubagentSpawns = 0;
1222
- orchestrator.active.state.step = "synthesize";
1223
- saveTask(orchestrator.active.dir, orchestrator.active.state);
1224
- pi.sendUserMessage("[PI-PI] All planners completed. Read their outputs and synthesize the plan.", { deliverAs: "followUp" });
1225
- }
1226
- }
1227
- } else if (orchestrator.active.state.step === "await_reviewers" && orchestrator.active.state.reviewCycle) {
1228
- const cycle = orchestrator.active.state.reviewCycle;
1229
- const reviewConfig = cycle.kind === "auto-deep" ? deepReviewConfig(orchestrator.config) : orchestrator.config;
1230
- const phase = orchestrator.active.state.phase;
1231
- const reviewers = phase === "brainstorm"
1232
- ? reviewConfig.brainstormReviewers
1233
- : phase === "plan"
1234
- ? reviewConfig.planReviewers
1235
- : reviewConfig.codeReviewers;
1236
- const reviewerCount = Object.values(reviewers).filter((v) => v.enabled).length;
1237
- const outputs = loadPhaseReviewOutputs(taskDir, phase, cycle.pass);
1238
- if (outputs.length >= reviewerCount) {
1239
- clearInterval(orchestrator.awaitPollTimer!);
1240
- orchestrator.awaitPollTimer = null;
1241
- orchestrator.spawnedAgentIds.clear();
1242
- orchestrator.pendingSubagentSpawns = 0;
1243
-
1244
- if (orchestrator.reviewTransitionToken === orchestrator.activeTaskToken) return;
1245
- orchestrator.reviewTransitionToken = orchestrator.activeTaskToken;
1246
-
1247
- cycle.step = "apply_feedback";
1248
- orchestrator.active.state.step = "apply_feedback";
1249
- saveTask(orchestrator.active.dir, orchestrator.active.state);
1250
- const rendered = outputs.map((o) => `=== ${o.name} ===\n${o.content}`).join("\n\n");
1251
- pi.sendMessage(
1252
- { customType: "pp-review-ready", content: `[PI-PI] Reviewer outputs are ready.\n\n${rendered}`, display: false },
1253
- { deliverAs: "followUp" },
1254
- );
1255
- pi.sendUserMessage("[PI-PI] Review cycle is ready for apply_feedback. Read reviewer outputs and proceed.", { deliverAs: "followUp" });
1256
- }
1257
- } else {
1258
- clearInterval(orchestrator.awaitPollTimer!);
1259
- orchestrator.awaitPollTimer = null;
1260
- }
1261
- }, 5000);
1262
- }
1263
2138
  return;
1264
2139
  }
1265
- if (orchestrator.awaitPollTimer) {
1266
- clearInterval(orchestrator.awaitPollTimer);
1267
- orchestrator.awaitPollTimer = null;
1268
- }
1269
-
1270
- if (orchestrator.active.type === "brainstorm" && phase === "brainstorm") return;
1271
- if (orchestrator.active.type === "review" && phase === "review") return;
1272
2140
 
1273
2141
  const contentParts = Array.isArray(msg?.content) ? msg.content : [];
1274
2142
  const hasText = contentParts.some((c: any) => c.type === "text" && c.text?.trim());
@@ -1276,88 +2144,51 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1276
2144
  const hasToolResults = event.toolResults && event.toolResults.length > 0;
1277
2145
  const turnWasEmpty = !hasText && !hasToolCalls && !hasToolResults;
1278
2146
 
1279
- if (!turnWasEmpty) {
1280
- const lastPart = contentParts.length > 0 ? contentParts[contentParts.length - 1] : null;
1281
- const endsWithText = lastPart?.type === "text" && lastPart?.text?.trim();
1282
- if (hasText && (!hasToolCalls || endsWithText)) {
1283
- const step = orchestrator.active.state.step;
1284
- if (step !== "await_planners" && step !== "await_reviewers") {
1285
- pi.sendMessage(
1286
- {
1287
- customType: "pp-phase-complete-reminder",
1288
- content: `[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.`,
1289
- display: false,
1290
- },
1291
- { deliverAs: "followUp" },
1292
- );
1293
- }
1294
- }
1295
- return;
1296
- }
1297
- if (orchestrator.nudgeHalted) return;
1298
- if (orchestrator.spawnedAgentIds.size > 0 || orchestrator.pendingSubagentSpawns > 0) return;
2147
+ const isAutonomous = getEffectivePhaseMode(orchestrator.active.state) === "autonomous";
1299
2148
 
1300
- const step = orchestrator.active.state.step;
1301
- if (step === "await_planners" || step === "await_reviewers") return;
1302
-
1303
- const now = Date.now();
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();
1304
2154
 
1305
- orchestrator.nudgeTimestamps.push(now);
1306
- orchestrator.nudgeTimestamps = orchestrator.nudgeTimestamps.filter((t) => now - t < 60000);
1307
-
1308
- const sendNudge = () => {
1309
- pi.sendMessage(
1310
- {
1311
- customType: "pp-continuation",
1312
- content: `[PI-PI] Your previous response was interrupted. Continue working on the current phase (${phase}). Pick up where you left off.`,
1313
- display: false,
1314
- },
1315
- { deliverAs: "followUp" },
1316
- );
1317
- };
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));
1318
2161
 
1319
- if (orchestrator.nudgeTimestamps.length <= 3) {
1320
- sendNudge();
2162
+ if (!isGenuineStop) {
2163
+ // Forward progress — clear the consecutive-nudge guard.
2164
+ orchestrator.consecutiveNudges = 0;
1321
2165
  return;
1322
2166
  }
1323
2167
 
1324
- if (orchestrator.nudgeTimestamps.length >= 5) {
1325
- orchestrator.cooldownHits.push(now);
1326
- orchestrator.cooldownHits = orchestrator.cooldownHits.filter((t) => now - t < 20 * 60 * 1000);
1327
-
1328
- if (orchestrator.cooldownHits.length >= 5) {
1329
- orchestrator.nudgeHalted = true;
1330
- orchestrator.nudgeTimestamps = [];
1331
- orchestrator.cooldownHits = [];
1332
- pi.sendMessage(
1333
- {
1334
- customType: "pp-continuation-halted",
1335
- content: "Agent has been repeatedly interrupted. Auto-continuation paused. Send any message to resume nudging.",
1336
- display: true,
1337
- },
1338
- { deliverAs: "steer" },
1339
- );
1340
- return;
1341
- }
2168
+ if (!nudgesEnabled || orchestrator.nudgeHalted) return;
1342
2169
 
1343
- 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(
1344
2177
  {
1345
- customType: "pp-continuation-cooldown",
1346
- 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.",
1347
2180
  display: true,
1348
2181
  },
1349
- { deliverAs: "steer" },
2182
+ "context",
1350
2183
  );
1351
- orchestrator.nudgeTimestamps = [];
1352
- const cooldownToken = orchestrator.activeTaskToken;
1353
- setTimeout(() => {
1354
- if (orchestrator.activeTaskToken !== cooldownToken || !orchestrator.active) return;
1355
- sendNudge();
1356
- }, 60000);
1357
2184
  return;
1358
2185
  }
1359
2186
 
1360
- 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);
1361
2192
  });
1362
2193
 
1363
2194