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