@ilya-lesikov/pi-pi 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/3p/pi-ask-user/index.ts +65 -49
  2. package/3p/pi-subagents/src/agent-manager.ts +8 -0
  3. package/3p/pi-subagents/src/agent-runner.ts +112 -19
  4. package/3p/pi-subagents/src/index.ts +3 -0
  5. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
  6. package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
  7. package/extensions/orchestrator/agents/constraints.ts +55 -0
  8. package/extensions/orchestrator/agents/explore.ts +13 -13
  9. package/extensions/orchestrator/agents/librarian.ts +12 -9
  10. package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
  11. package/extensions/orchestrator/agents/planner.ts +28 -23
  12. package/extensions/orchestrator/agents/registry.ts +2 -1
  13. package/extensions/orchestrator/agents/repo-context.ts +11 -0
  14. package/extensions/orchestrator/agents/task.ts +14 -11
  15. package/extensions/orchestrator/agents/tool-routing.ts +17 -32
  16. package/extensions/orchestrator/ast-search.ts +2 -1
  17. package/extensions/orchestrator/cbm.test.ts +35 -0
  18. package/extensions/orchestrator/cbm.ts +43 -13
  19. package/extensions/orchestrator/command-handlers.test.ts +390 -19
  20. package/extensions/orchestrator/command-handlers.ts +73 -31
  21. package/extensions/orchestrator/commands.test.ts +255 -2
  22. package/extensions/orchestrator/commands.ts +108 -10
  23. package/extensions/orchestrator/config.test.ts +289 -68
  24. package/extensions/orchestrator/config.ts +630 -121
  25. package/extensions/orchestrator/context.test.ts +177 -10
  26. package/extensions/orchestrator/context.ts +115 -14
  27. package/extensions/orchestrator/custom-footer.ts +2 -1
  28. package/extensions/orchestrator/doctor.test.ts +559 -0
  29. package/extensions/orchestrator/doctor.ts +664 -0
  30. package/extensions/orchestrator/event-handlers.test.ts +84 -22
  31. package/extensions/orchestrator/event-handlers.ts +1191 -360
  32. package/extensions/orchestrator/exa.test.ts +46 -0
  33. package/extensions/orchestrator/exa.ts +16 -10
  34. package/extensions/orchestrator/flant-infra.test.ts +224 -0
  35. package/extensions/orchestrator/flant-infra.ts +110 -41
  36. package/extensions/orchestrator/index.ts +13 -2
  37. package/extensions/orchestrator/integration.test.ts +2866 -118
  38. package/extensions/orchestrator/log.test.ts +219 -0
  39. package/extensions/orchestrator/log.ts +153 -0
  40. package/extensions/orchestrator/model-registry.test.ts +238 -0
  41. package/extensions/orchestrator/model-registry.ts +282 -0
  42. package/extensions/orchestrator/model-version.test.ts +27 -0
  43. package/extensions/orchestrator/model-version.ts +19 -0
  44. package/extensions/orchestrator/orchestrator.test.ts +206 -56
  45. package/extensions/orchestrator/orchestrator.ts +295 -148
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -35
  48. package/extensions/orchestrator/phases/implementation.ts +7 -11
  49. package/extensions/orchestrator/phases/machine.test.ts +27 -8
  50. package/extensions/orchestrator/phases/machine.ts +13 -0
  51. package/extensions/orchestrator/phases/planning.ts +57 -29
  52. package/extensions/orchestrator/phases/review-task.ts +3 -3
  53. package/extensions/orchestrator/phases/review.ts +38 -39
  54. package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
  55. package/extensions/orchestrator/phases/verdict.test.ts +139 -0
  56. package/extensions/orchestrator/phases/verdict.ts +82 -0
  57. package/extensions/orchestrator/plannotator.test.ts +85 -0
  58. package/extensions/orchestrator/plannotator.ts +24 -6
  59. package/extensions/orchestrator/pp-menu.test.ts +134 -0
  60. package/extensions/orchestrator/pp-menu.ts +2631 -392
  61. package/extensions/orchestrator/repo-utils.test.ts +151 -0
  62. package/extensions/orchestrator/repo-utils.ts +67 -0
  63. package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
  64. package/extensions/orchestrator/spawn-cleanup.ts +35 -0
  65. package/extensions/orchestrator/state.test.ts +76 -6
  66. package/extensions/orchestrator/state.ts +89 -26
  67. package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
  68. package/extensions/orchestrator/test-helpers.ts +217 -0
  69. package/extensions/orchestrator/tracer.test.ts +132 -0
  70. package/extensions/orchestrator/tracer.ts +127 -0
  71. package/extensions/orchestrator/transition-controller.test.ts +207 -0
  72. package/extensions/orchestrator/transition-controller.ts +259 -0
  73. package/extensions/orchestrator/usage-tracker.test.ts +435 -0
  74. package/extensions/orchestrator/usage-tracker.ts +23 -2
  75. package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
  76. package/package.json +2 -1
@@ -1,18 +1,20 @@
1
1
  import { existsSync, copyFileSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from "fs";
2
2
  import { join, basename, relative } from "path";
3
3
  import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
- import { loadConfig, type PiPiConfig, type VariantConfig } from "./config.js";
4
+ import { loadConfig, resolvePreset, type NormalizedPiPiConfig } from "./config.js";
5
5
  import {
6
6
  createTask,
7
7
  loadTask,
8
8
  saveTask,
9
9
  lockTask,
10
+ getEffectivePhaseMode,
10
11
  type TaskType,
12
+ type TaskMode,
11
13
  type TaskState,
12
14
  type Phase,
13
15
  } from "./state.js";
14
16
  import { phasePipeline } from "./phases/machine.js";
15
- import { loadContextFiles, getPhaseArtifacts, getLatestSynthesizedPlan } from "./context.js";
17
+ import { getContextDirs, loadAllContextFiles, getPhaseArtifacts, getLatestSynthesizedPlan } from "./context.js";
16
18
  import { brainstormSystemPrompt } from "./phases/brainstorm.js";
17
19
  import { planningSystemPrompt, spawnPlanners } from "./phases/planning.js";
18
20
  import { implementationSystemPrompt } from "./phases/implementation.js";
@@ -22,6 +24,15 @@ import { registerAgentDefinitions, unregisterAgentDefinitions } from "./agents/r
22
24
  import { createExploreAgent } from "./agents/explore.js";
23
25
  import { createLibrarianAgent } from "./agents/librarian.js";
24
26
  import { createTaskAgent } from "./agents/task.js";
27
+ import { resolveModel, getModelInfo } from "./model-registry.js";
28
+ import { buildRepoContext } from "./agents/repo-context.js";
29
+ import { getLogger, addTaskDestination, removeTaskDestination, setLogLevel } from "./log.js";
30
+ import { handleSpawnResult } from "./spawn-cleanup.js";
31
+ import { TransitionController, type TransitionHost } from "./transition-controller.js";
32
+
33
+ function isEnabled(value: { enabled?: boolean } | undefined): boolean {
34
+ return value?.enabled !== false;
35
+ }
25
36
 
26
37
  const BUNDLED_TOOLS = new Set([
27
38
  "Agent", "get_subagent_result", "steer_subagent",
@@ -42,7 +53,7 @@ export interface ActiveTask {
42
53
 
43
54
  export class Orchestrator {
44
55
  active: ActiveTask | null = null;
45
- config!: PiPiConfig;
56
+ config!: NormalizedPiPiConfig;
46
57
  cwd = "";
47
58
  spawnedAgentIds = new Set<string>();
48
59
  agentDescriptions = new Map<string, string>();
@@ -59,22 +70,18 @@ export class Orchestrator {
59
70
  step?: string;
60
71
  }>();
61
72
  staleAgentTimer: ReturnType<typeof setInterval> | null = null;
62
- phaseCompactionPending = false;
63
- phaseCompactionResolve: (() => void) | null = null;
64
- taskDoneCompactionPending = false;
65
- taskDoneCompactionSummary = "";
66
- nudgeTimestamps: number[] = [];
67
- cooldownHits: number[] = [];
73
+ // Single consecutive-nudge guard (replaces the old multi-tier throttle). Reset
74
+ // to 0 on any productive turn; once it reaches the cap the nudges halt with one
75
+ // user notification.
76
+ consecutiveNudges = 0;
68
77
  nudgeHalted = false;
69
78
  pendingSubagentSpawns = 0;
70
79
  errorRetryCount = 0;
71
80
  commitReminderSent = false;
72
81
  phaseStartTime = 0;
73
- awaitPollTimer: ReturnType<typeof setInterval> | null = null;
74
82
  pendingRetryTimer: ReturnType<typeof setTimeout> | null = null;
75
83
  activeTaskToken = 0;
76
84
  userGatePending = false;
77
- reviewTransitionToken = -1;
78
85
  lastCtx: any = null;
79
86
  failedPlannerVariants: string[] = [];
80
87
  failedReviewerVariants: string[] = [];
@@ -82,9 +89,65 @@ export class Orchestrator {
82
89
  reviewerFailureDialogPending = false;
83
90
  plannotatorReject: ((reason: Error) => void) | null = null;
84
91
  plannotatorUnsub: (() => void) | null = null;
85
- transitionToNextPhase: (ctx: any) => Promise<{ ok: boolean; error?: string }> = async () => ({ ok: false, error: "not initialized" });
92
+ plannotatorTimer: ReturnType<typeof setTimeout> | null = null;
93
+ transitionToNextPhase: (ctx: any, plannerPreset?: string) => Promise<{ ok: boolean; error?: string }> = async () => ({ ok: false, error: "not initialized" });
94
+ // Assigned by registerEventHandlers. Wired into planner spawn onSettled as the
95
+ // safety net the deleted 5s poller used to provide: when a spawn settles having
96
+ // produced ZERO agents (zero enabled planners, or all spawns failed before any
97
+ // subagents:completed/failed event), nothing else would advance await_planners.
98
+ // For spawned>0 the lifecycle events drive completion, so onSettled passes the
99
+ // spawned count and this only force-checks the zero case.
100
+ checkPlannerCompletion: () => void = () => {};
101
+ readonly transitionController: TransitionController;
102
+
103
+ constructor(readonly pi: ExtensionAPI) {
104
+ // The controller calls pi (the main session) directly for sends, and uses the
105
+ // host only for live-ctx-dependent bits (compact/isIdle/currentStep).
106
+ this.transitionController = new TransitionController(this.makeTransitionHost(), this.pi);
107
+ }
108
+
109
+ // Live-session host the TransitionController uses for compaction/idle/step.
110
+ private makeTransitionHost(): TransitionHost {
111
+ return {
112
+ compact: (options) => {
113
+ const compact = this.lastCtx?.compact;
114
+ if (!compact) return false;
115
+ compact(options);
116
+ return true;
117
+ },
118
+ isIdle: () => {
119
+ const idle = this.lastCtx?.isIdle;
120
+ return typeof idle === "function" ? !!idle.call(this.lastCtx) : false;
121
+ },
122
+ currentStep: () => this.active?.state.step ?? null,
123
+ };
124
+ }
86
125
 
87
- constructor(readonly pi: ExtensionAPI) {}
126
+ safeSendUserMessage(text: string): void {
127
+ const log = getLogger();
128
+ const attempt = (retries: number) => {
129
+ try {
130
+ // Route through the controller's single send path. "instruction" maps to
131
+ // followUp: queues the message and triggers a turn once the current one
132
+ // settles (or runs immediately when idle), so it never throws "Agent is
133
+ // already processing" when called mid-tool (e.g. during a transition).
134
+ this.transitionController.send(text, "instruction");
135
+ log.debug({ s: "orchestrator", retries, text: text.slice(0, 200) }, "safeSend sent");
136
+ } catch (err: any) {
137
+ if (retries < 30) {
138
+ log.debug({ s: "orchestrator", retries, err: err?.message }, "safeSend retry");
139
+ setTimeout(() => attempt(retries + 1), 1000);
140
+ } else {
141
+ log.error({ s: "orchestrator", retries, err: err?.message ?? String(err), text: text.slice(0, 200) }, "safeSend failed after max retries");
142
+ this.lastCtx?.ui?.notify?.(
143
+ "pi-pi could not deliver a message to the agent; the task may be stalled. See logs.",
144
+ "error",
145
+ );
146
+ }
147
+ }
148
+ };
149
+ attempt(0);
150
+ }
88
151
 
89
152
  truncateResult(result: string): string {
90
153
  const trimmed = result.trim();
@@ -96,34 +159,53 @@ export class Orchestrator {
96
159
  }
97
160
 
98
161
  async switchModel(ctx: ExtensionContext, modelSpec: string, thinking: string): Promise<boolean> {
162
+ const log = getLogger();
99
163
  const registry = ctx.modelRegistry;
100
164
  const allModels = registry.getAvailable();
101
165
 
102
- const slashIdx = modelSpec.indexOf("/");
166
+ const requestedSpecs = [resolveModel(modelSpec), modelSpec].filter((value, index, arr) => arr.indexOf(value) === index);
167
+ log.debug({ s: "model", requestedSpecs, thinking, availableCount: allModels.length }, "switchModel");
103
168
  let resolved;
104
- if (slashIdx !== -1) {
105
- const provider = modelSpec.substring(0, slashIdx).trim().toLowerCase();
106
- const modelId = modelSpec.substring(slashIdx + 1).trim().toLowerCase();
107
- resolved = allModels.find(
108
- (m) => m.provider.toLowerCase() === provider && m.id.toLowerCase() === modelId,
109
- );
169
+ for (const spec of requestedSpecs) {
170
+ const slashIdx = spec.indexOf("/");
171
+ if (slashIdx !== -1) {
172
+ const provider = spec.substring(0, slashIdx).trim().toLowerCase();
173
+ const modelId = spec.substring(slashIdx + 1).trim().toLowerCase();
174
+ resolved = allModels.find(
175
+ (m) => m.provider.toLowerCase() === provider && m.id.toLowerCase() === modelId,
176
+ );
177
+ }
178
+ if (!resolved) {
179
+ const pattern = spec.toLowerCase();
180
+ const matches = allModels.filter(
181
+ (m) => m.id.toLowerCase() === pattern || m.id.toLowerCase().includes(pattern),
182
+ );
183
+ if (matches.length === 1) resolved = matches[0];
184
+ }
185
+ if (resolved) break;
110
186
  }
187
+
111
188
  if (!resolved) {
112
- const pattern = modelSpec.toLowerCase();
113
- const matches = allModels.filter(
114
- (m) => m.id.toLowerCase() === pattern || m.id.toLowerCase().includes(pattern),
115
- );
116
- if (matches.length === 1) resolved = matches[0];
189
+ log.warn({ s: "model", requestedSpecs }, "model not found");
190
+ return false;
117
191
  }
118
192
 
119
- if (!resolved) return false;
120
-
121
193
  const ok = await this.pi.setModel(resolved);
122
- if (!ok) return false;
194
+ if (!ok) {
195
+ log.warn({ s: "model", resolved: `${resolved.provider}/${resolved.id}` }, "setModel returned false");
196
+ return false;
197
+ }
123
198
 
124
- const VALID_THINKING = new Set(["off", "low", "medium", "high"]);
125
- const thinkingLevel = (VALID_THINKING.has(thinking) ? thinking : "high") as "off" | "low" | "medium" | "high";
199
+ const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh"]);
200
+ const thinkingLevel = (VALID_THINKING.has(thinking) ? thinking : "high") as
201
+ | "off"
202
+ | "minimal"
203
+ | "low"
204
+ | "medium"
205
+ | "high"
206
+ | "xhigh";
126
207
  this.pi.setThinkingLevel(thinkingLevel);
208
+ log.debug({ s: "model", model: `${resolved.provider}/${resolved.id}`, thinking: thinkingLevel }, "model switched");
127
209
  return true;
128
210
  }
129
211
 
@@ -137,11 +219,13 @@ export class Orchestrator {
137
219
  const phase = this.active.state.phase;
138
220
  const step = this.active.state.step;
139
221
  const reviewCycle = this.active.state.reviewCycle;
222
+ const effectiveMode = this.active.state.effectiveMode ?? this.active.state.mode;
223
+ const modeLabel = effectiveMode === "autonomous" ? " [autonomous]" : "";
140
224
 
141
- if (type === "debug" || type === "brainstorm") {
225
+ if (type === "debug" || type === "brainstorm" || type === "quick") {
142
226
  const elapsed = this.phaseStartTime > 0 ? this.formatElapsed(this.phaseStartTime) : "";
143
227
  const suffix = elapsed ? ` (${elapsed})` : "";
144
- ctx.ui.setStatus("pp-phase", `pp: ${type}${suffix}`);
228
+ ctx.ui.setStatus("pp-phase", `pp: ${type}${modeLabel}${suffix}`);
145
229
  return;
146
230
  }
147
231
 
@@ -162,7 +246,7 @@ export class Orchestrator {
162
246
  else if (step === "user_gate") detail = "review";
163
247
 
164
248
  if (reviewCycle) {
165
- const kind = reviewCycle.kind === "plannotator" ? "plannotator" : reviewCycle.kind === "auto-deep" ? "deep review" : "review";
249
+ const kind = reviewCycle.kind === "plannotator" ? "plannotator" : "review";
166
250
  detail = `${kind} #${reviewCycle.pass}`;
167
251
  }
168
252
 
@@ -174,7 +258,7 @@ export class Orchestrator {
174
258
  }
175
259
  }
176
260
 
177
- ctx.ui.setStatus("pp-phase", `pp: ${parts.join(" → ")}`);
261
+ ctx.ui.setStatus("pp-phase", `pp: ${parts.join(" → ")}${modeLabel}`);
178
262
  }
179
263
 
180
264
  private formatElapsed(startTime: number): string {
@@ -188,14 +272,22 @@ export class Orchestrator {
188
272
  return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
189
273
  }
190
274
 
191
- getPlanStartState(taskDir: string): { step: string; shouldSpawnPlanners: boolean } {
275
+ getPlanStartState(taskDir: string, plannerPresetName?: string): { step: string; shouldSpawnPlanners: boolean } {
192
276
  const plansDir = join(taskDir, "plans");
193
- const enabledPlannerCount = Object.values(this.config.planners).filter((v) => v.enabled).length;
277
+ const presetName = plannerPresetName ?? this.config.agents.subagents.presetGroups.planners.default;
278
+ const plannerVariants = resolvePreset(this.config, "planners", presetName);
279
+ const enabledPlannerVariants = Object.entries(plannerVariants)
280
+ .filter(([, v]) => isEnabled(v))
281
+ .map(([name]) => name);
194
282
  const plannerOutputs = existsSync(plansDir)
195
283
  ? readdirSync(plansDir).filter((f) => f.endsWith(".md") && !f.includes("synthesized") && !f.includes("review_"))
196
284
  : [];
285
+ const completedVariants = new Set(
286
+ plannerOutputs.map((f) => f.replace(/^\d+_/, "").replace(/\.md$/, "")),
287
+ );
288
+ const hasAllEnabledVariants = enabledPlannerVariants.every((name) => completedVariants.has(name));
197
289
 
198
- if (enabledPlannerCount === 0 || plannerOutputs.length >= enabledPlannerCount || getLatestSynthesizedPlan(taskDir)) {
290
+ if (enabledPlannerVariants.length === 0 || hasAllEnabledVariants || getLatestSynthesizedPlan(taskDir)) {
199
291
  return { step: "synthesize", shouldSpawnPlanners: false };
200
292
  }
201
293
 
@@ -205,23 +297,26 @@ export class Orchestrator {
205
297
  getPhasePrompt(_ctx: ExtensionContext): string {
206
298
  if (!this.active) return "";
207
299
 
300
+ const mode: TaskMode = getEffectivePhaseMode(this.active.state);
301
+
208
302
  if (this.active.state.reviewCycle?.step === "apply_feedback") {
209
303
  const pass = this.active.state.reviewCycle.pass;
210
- const manualReview = this.active.state.reviewCycle.kind === "manual";
211
- return reviewCycleSystemPrompt(this.active.dir, pass, manualReview, this.active.state.phase);
304
+ return reviewCycleSystemPrompt(this.active.dir, pass, this.active.state.phase);
212
305
  }
213
306
 
214
307
  switch (this.active.state.phase) {
215
308
  case "brainstorm":
216
- return brainstormSystemPrompt(this.active.type, this.active.description, this.active.dir);
309
+ return brainstormSystemPrompt(this.active.type, this.active.description, this.active.dir, this.cwd);
217
310
  case "debug":
218
- return brainstormSystemPrompt(this.active.type, this.active.description, this.active.dir);
311
+ return brainstormSystemPrompt(this.active.type, this.active.description, this.active.dir, this.cwd);
219
312
  case "plan":
220
- return planningSystemPrompt(this.active.dir);
313
+ return planningSystemPrompt(this.active.dir, mode);
221
314
  case "implement":
222
- return implementationSystemPrompt(this.active.dir);
315
+ return implementationSystemPrompt(this.active.dir, this.cwd);
223
316
  case "review":
224
- return reviewTaskSystemPrompt(this.active.dir);
317
+ return reviewTaskSystemPrompt(this.active.dir, this.cwd);
318
+ case "quick":
319
+ return "Work on the user's request directly. There are no phases, planning, or reviews.";
225
320
  default:
226
321
  return "";
227
322
  }
@@ -244,19 +339,31 @@ export class Orchestrator {
244
339
  description: string,
245
340
  fromTaskDir?: string,
246
341
  skipBrainstorm?: boolean,
342
+ mode?: TaskMode,
247
343
  ): Promise<void> {
344
+ const log = getLogger();
345
+ log.info({ s: "task", type, description, fromTaskDir: fromTaskDir ?? null, skipBrainstorm: skipBrainstorm ?? false, mode: mode ?? null }, "startTask");
346
+ const hadActive = !!this.active;
248
347
  if (this.active) {
249
348
  ctx.ui.notify(
250
- `Finishing previous task "${this.active.description}" (phase: ${this.active.state.phase})…`,
349
+ `Pausing previous task "${this.active.description}" (phase: ${this.active.state.phase})…`,
251
350
  "info",
252
351
  );
253
352
  this.abortAllSubagents();
254
- this.active.state.phase = "done";
255
353
  saveTask(this.active.dir, this.active.state);
256
354
  unregisterAgentDefinitions(this.pi);
257
355
  await this.cleanupActive();
258
356
  }
259
357
 
358
+ if (hadActive) {
359
+ // Route new-task compaction through the controller as a "done" target.
360
+ this.lastCtx = ctx;
361
+ await this.transitionController.requestTransition({
362
+ kind: "done",
363
+ summary: `Starting new ${type} task. Previous conversation discarded.`,
364
+ });
365
+ }
366
+
260
367
  try {
261
368
  this.config = loadConfig(this.cwd);
262
369
  } catch (err: any) {
@@ -264,16 +371,28 @@ export class Orchestrator {
264
371
  return;
265
372
  }
266
373
 
374
+ setLogLevel(this.config.general.logLevel);
267
375
  ensureGitignore(this.cwd);
268
376
 
269
- const dir = createTask(this.cwd, type, description);
377
+ const dir = createTask(this.cwd, type, description, mode);
270
378
  const state = loadTask(dir);
271
379
 
272
380
  if (fromTaskDir) {
273
381
  const srcUr = join(fromTaskDir, "USER_REQUEST.md");
274
382
  const srcRes = join(fromTaskDir, "RESEARCH.md");
275
383
  const srcArtifacts = join(fromTaskDir, "artifacts");
276
- if (existsSync(srcUr)) copyFileSync(srcUr, join(dir, "USER_REQUEST.md"));
384
+ if (existsSync(srcUr)) {
385
+ const originalUr = readFileSync(srcUr, "utf-8");
386
+ const implNote =
387
+ "# IMPLEMENTATION TASK\n\n" +
388
+ "This is now an **implement** task — the previous brainstorm/debug/review task is over.\n" +
389
+ "The user request, research, and artifacts below are carried over as context for implementation.\n" +
390
+ "Your job is to plan and implement actual code changes based on this research.\n" +
391
+ "Any prior instructions in the text below saying \"brainstorm only\", \"review only\",\n" +
392
+ "\"do not implement\", \"no code changes\", or similar DO NOT APPLY — they were for the previous task.\n\n" +
393
+ "---\n\n";
394
+ writeFileSync(join(dir, "USER_REQUEST.md"), implNote + originalUr, "utf-8");
395
+ }
277
396
  if (existsSync(srcRes)) copyFileSync(srcRes, join(dir, "RESEARCH.md"));
278
397
  if (existsSync(srcArtifacts)) {
279
398
  const destArtifacts = join(dir, "artifacts");
@@ -285,19 +404,21 @@ export class Orchestrator {
285
404
  state.from = relative(join(this.cwd, ".pp", "state"), fromTaskDir);
286
405
  if (skipBrainstorm && type === "implement") {
287
406
  state.phase = "plan";
288
- state.step = this.getPlanStartState(dir).step;
407
+ state.initialPhase = "plan";
408
+ state.activePlannerPreset = this.config.agents.subagents.presetGroups.planners.default;
409
+ state.step = this.getPlanStartState(dir, state.activePlannerPreset).step;
289
410
  }
290
411
  saveTask(dir, state);
291
412
  }
292
413
 
293
414
  let release: (() => Promise<void>) | null = null;
294
415
  try {
295
- release = await lockTask(dir, this.config.timeouts);
416
+ release = await lockTask(dir, this.config.performance.internals);
296
417
  } catch (err: any) {
297
418
  try {
298
419
  rmSync(dir, { recursive: true, force: true });
299
420
  } catch {
300
- console.error(`[pi-pi] Failed to clean up orphaned task dir: ${dir}`);
421
+ log.warn({ s: "task", dir }, "failed to clean up orphaned task dir");
301
422
  }
302
423
  ctx.ui.notify(`Failed to lock task: ${err.message}`, "error");
303
424
  return;
@@ -317,7 +438,10 @@ export class Orchestrator {
317
438
  description: state.description,
318
439
  };
319
440
 
320
- const modelConfig = this.config.mainModel[
441
+ addTaskDestination(dir);
442
+ log.info({ s: "task", dir, taskId: this.active.taskId, phase: state.phase, step: state.step }, "task activated");
443
+
444
+ const modelConfig = this.config.agents.orchestrators[
321
445
  type === "debug" ? "debug"
322
446
  : type === "brainstorm" ? "brainstorm"
323
447
  : type === "review" ? "review"
@@ -337,30 +461,52 @@ export class Orchestrator {
337
461
 
338
462
  this.phaseStartTime = Date.now();
339
463
  const isGenericDescription = ["implement", "debug", "brainstorm", "review"].includes(this.active.description);
464
+ const isGenericQuickDescription = this.active.description === "quick";
340
465
  const hasInheritedTaskContext = Boolean(fromTaskDir && type === "implement");
341
466
  const isWaitingForPlanners = this.active.state.phase === "plan" && this.active.state.step === "await_planners";
342
- if (isGenericDescription && !hasInheritedTaskContext) {
467
+ if ((isGenericDescription || isGenericQuickDescription) && !hasInheritedTaskContext) {
343
468
  ctx.ui.notify("Task created. Describe what you'd like to do.", "info");
344
469
  } else if (isWaitingForPlanners) {
345
470
  ctx.ui.notify("Entered plan phase. Waiting for planners to complete before synthesis.", "info");
346
471
  } else {
347
- this.pi.sendUserMessage(`[PI-PI] Entered ${this.active.state.phase} phase. Begin working.`, { deliverAs: "followUp" });
472
+ const desc = this.active.description;
473
+ const descSuffix = !isGenericDescription ? `\n\nTask: ${desc}` : "";
474
+ this.safeSendUserMessage(`[PI-PI] Entered ${this.active.state.phase} phase. Begin working.${descSuffix}`);
348
475
  }
349
476
 
350
477
  if (this.active.state.phase === "plan" && this.active.state.step === "await_planners") {
351
- this.pendingSubagentSpawns = Object.values(this.config.planners).filter((v) => v.enabled).length;
478
+ const requestedPlannerPresetName = this.active.state.activePlannerPreset ?? this.config.agents.subagents.presetGroups.planners.default;
479
+ const plannerPresetExists = Object.prototype.hasOwnProperty.call(this.config.agents.subagents.presetGroups.planners.presets ?? {}, requestedPlannerPresetName);
480
+ const plannerPresetName = plannerPresetExists
481
+ ? requestedPlannerPresetName
482
+ : (Object.keys(this.config.agents.subagents.presetGroups.planners.presets ?? {})[0] ?? requestedPlannerPresetName);
483
+ if (this.active.state.activePlannerPreset !== plannerPresetName) {
484
+ this.active.state.activePlannerPreset = plannerPresetName;
485
+ saveTask(this.active.dir, this.active.state);
486
+ }
487
+ if (!plannerPresetExists && plannerPresetName !== requestedPlannerPresetName) {
488
+ ctx.ui.notify(
489
+ `Planner preset "${requestedPlannerPresetName}" not found. Falling back to "${plannerPresetName}".`,
490
+ "warning",
491
+ );
492
+ }
493
+ const plannerVariants = resolvePreset(this.config, "planners", plannerPresetName);
494
+ this.pendingSubagentSpawns = Object.values(plannerVariants).filter((v) => isEnabled(v)).length;
352
495
  this.failedPlannerVariants = [];
353
- spawnPlanners(this.pi, this.cwd, this.active.dir, this.active.taskId, this.config).then((result) => {
354
- this.failedPlannerVariants = result.failedVariants;
355
- if (result.spawned === 0) this.pendingSubagentSpawns = 0;
356
- for (const id of result.agentIds ?? []) {
357
- this.spawnedAgentIds.delete(id);
358
- }
359
- this.pendingSubagentSpawns = 0;
360
- }).catch((err) => {
361
- this.pendingSubagentSpawns = 0;
362
- console.error(`[pi-pi] spawnPlanners failed: ${err.message}`);
363
- });
496
+ handleSpawnResult(
497
+ this,
498
+ spawnPlanners(
499
+ this.pi,
500
+ this.cwd,
501
+ this.active.dir,
502
+ this.active.taskId,
503
+ this.config,
504
+ this.transitionController.phaseSend,
505
+ plannerVariants,
506
+ this.active?.state.repos ?? [],
507
+ ),
508
+ { kind: "planner", logScope: "planner", logMessage: "spawnPlanners failed", onSettled: (result) => { if (!result?.spawned) this.checkPlannerCompletion(); } },
509
+ );
364
510
  }
365
511
  }
366
512
 
@@ -383,22 +529,14 @@ export class Orchestrator {
383
529
  this.pendingSubagentSpawns = 0;
384
530
  this.errorRetryCount = 0;
385
531
  this.commitReminderSent = false;
386
- this.nudgeTimestamps = [];
387
- this.cooldownHits = [];
532
+ this.consecutiveNudges = 0;
388
533
  this.nudgeHalted = false;
389
- this.phaseCompactionPending = false;
390
- this.phaseCompactionResolve = null;
391
534
  this.phaseStartTime = 0;
392
535
  this.userGatePending = false;
393
- this.reviewTransitionToken = -1;
394
536
  this.failedPlannerVariants = [];
395
537
  this.failedReviewerVariants = [];
396
538
  this.plannerFailureDialogPending = false;
397
539
  this.reviewerFailureDialogPending = false;
398
- if (this.awaitPollTimer) {
399
- clearInterval(this.awaitPollTimer);
400
- this.awaitPollTimer = null;
401
- }
402
540
  if (this.pendingRetryTimer) {
403
541
  clearTimeout(this.pendingRetryTimer);
404
542
  this.pendingRetryTimer = null;
@@ -411,101 +549,127 @@ export class Orchestrator {
411
549
 
412
550
  async cleanupActive(): Promise<void> {
413
551
  if (!this.active) return;
552
+ const dir = this.active.dir;
553
+ getLogger().info({ s: "task", dir }, "cleaning up active task");
554
+ removeTaskDestination();
414
555
  this.resetTaskScopedState();
415
556
  if (this.active.release) {
416
557
  try {
417
558
  await this.active.release();
418
559
  } catch (err: any) {
419
- console.error(`[pi-pi] Failed to release lock for ${this.active.dir}: ${err.message}`);
560
+ getLogger().error({ s: "task", dir, err: err.message }, "failed to release lock");
420
561
  }
421
562
  }
422
563
  this.active = null;
423
564
  }
424
565
 
425
566
  registerAgents(): void {
567
+ const log = getLogger();
426
568
  const explore = createExploreAgent(this.config);
427
569
  const librarian = createLibrarianAgent(this.config);
428
570
  const taskAgent = createTaskAgent(this.config, "{{subtask}}", { userRequest: "", synthesizedPlan: "" });
429
-
430
- const appendContext = (agentType: string, prompt: string): string => {
431
- const contextFiles = loadContextFiles(this.cwd, agentType as any, "system");
432
- if (contextFiles.length === 0) return prompt;
571
+ const phase = this.active?.state.phase;
572
+ const repos = this.active?.state.repos ?? [];
573
+ log.debug({ s: "agents", phase, repoCount: repos.length }, "registering agent definitions");
574
+ const contextDirs = getContextDirs(this.cwd, repos, this.config.general.loadExtraRepoConfigs);
575
+ const repoContext = buildRepoContext(repos);
576
+
577
+ const appendContext = (agentType: string, prompt: string, modelInfo: { vendor: string; family: string; tier: string }): string => {
578
+ const contextFiles = loadAllContextFiles(contextDirs, agentType as any, "system", phase, modelInfo);
579
+ if (contextFiles.length === 0 && !repoContext) return prompt;
580
+ const parts = [prompt];
581
+ if (repoContext) parts.push(repoContext.trimEnd());
582
+ if (contextFiles.length === 0) return parts.join("\n\n");
433
583
  const contextBlock = contextFiles.map((f) => f.content).join("\n\n");
434
- return prompt + "\n\n# Project Context\n\n" + contextBlock;
584
+ parts.push("# Project Context\n\n" + contextBlock);
585
+ return parts.join("\n\n");
435
586
  };
436
587
 
437
588
  registerAgentDefinitions(this.pi, [
438
- { type: "explore", variant: null, ...explore, prompt: appendContext("explore", explore.prompt) },
439
- { type: "librarian", variant: null, ...librarian, prompt: appendContext("librarian", librarian.prompt) },
440
- { type: "task", variant: null, ...taskAgent, prompt: appendContext("task", taskAgent.prompt) },
589
+ {
590
+ type: "explore",
591
+ variant: null,
592
+ ...explore,
593
+ prompt: appendContext("explore", explore.prompt, getModelInfo(resolveModel(this.config.agents.subagents.simple.explore.model))),
594
+ },
595
+ {
596
+ type: "librarian",
597
+ variant: null,
598
+ ...librarian,
599
+ prompt: appendContext("librarian", librarian.prompt, getModelInfo(resolveModel(this.config.agents.subagents.simple.librarian.model))),
600
+ },
601
+ {
602
+ type: "task",
603
+ variant: null,
604
+ ...taskAgent,
605
+ prompt: appendContext("task", taskAgent.prompt, getModelInfo(resolveModel(this.config.agents.subagents.simple.task.model))),
606
+ },
441
607
  ]);
442
608
  }
443
609
 
444
610
  injectContextAndArtifacts(taskDir: string, phase: Phase): void {
445
- const contextFiles = loadContextFiles(this.cwd, "main", "context");
611
+ const log = getLogger();
612
+ log.debug({ s: "context", taskDir, phase }, "injecting context and artifacts");
613
+ const modelSpec =
614
+ phase === "debug" && this.active?.type === "debug"
615
+ ? this.config.agents.orchestrators.debug.model
616
+ : phase === "brainstorm" && this.active?.type === "brainstorm"
617
+ ? this.config.agents.orchestrators.brainstorm.model
618
+ : phase === "review" && this.active?.type === "review"
619
+ ? this.config.agents.orchestrators.review.model
620
+ : phase === "plan"
621
+ ? this.config.agents.orchestrators.plan.model
622
+ : this.config.agents.orchestrators.implement.model;
623
+ const activeModelSpec = this.lastCtx?.model
624
+ ? `${this.lastCtx.model.provider}/${this.lastCtx.model.id}`
625
+ : modelSpec;
626
+ const repos = this.active?.state.repos ?? [];
627
+ const contextDirs = getContextDirs(this.cwd, repos, this.config.general.loadExtraRepoConfigs);
628
+ const contextFiles = loadAllContextFiles(
629
+ contextDirs,
630
+ "main",
631
+ "context",
632
+ phase,
633
+ getModelInfo(activeModelSpec),
634
+ );
446
635
  for (const cf of contextFiles) {
447
- this.pi.sendMessage(
636
+ this.transitionController.sendCustom(
448
637
  { customType: "pp-context", content: cf.content, display: false },
449
- { deliverAs: "steer" },
638
+ "context",
450
639
  );
451
640
  }
452
641
  const artifacts = getPhaseArtifacts(taskDir, phase);
453
642
  for (const artifact of artifacts) {
454
- this.pi.sendMessage(
643
+ this.transitionController.sendCustom(
455
644
  { customType: "pp-artifact", content: `=== ${artifact.name} ===\n${artifact.content}`, display: false },
456
- { deliverAs: "steer" },
645
+ "context",
457
646
  );
458
647
  }
459
648
  }
460
649
 
461
- waitForCompaction(): Promise<void> {
462
- if (!this.phaseCompactionPending && !this.taskDoneCompactionPending) return Promise.resolve();
463
- return new Promise((resolve) => {
464
- this.phaseCompactionResolve = resolve;
465
- });
466
- }
467
-
468
- compactAndTransition(ctx: ExtensionContext, taskDir: string, phase: Phase): void {
469
- this.phaseCompactionPending = true;
470
- ctx.compact({
471
- customInstructions: "Phase transition discard all prior conversation. Produce a one-line summary: 'Previous phase completed.'",
472
- onComplete: () => {
473
- this.phaseCompactionPending = false;
474
- if (this.phaseCompactionResolve) {
475
- this.phaseCompactionResolve();
476
- this.phaseCompactionResolve = null;
477
- }
478
- this.phaseStartTime = Date.now();
479
- if (this.active && (phase === "plan" || phase === "implement")) {
480
- const modelConfig = this.config.mainModel.implement;
481
- this.switchModel(ctx, modelConfig.model, modelConfig.thinking).catch(() => {});
482
- }
483
- this.injectContextAndArtifacts(taskDir, phase);
484
- if (this.active?.state.phase === "plan" && this.active.state.step === "await_planners") {
485
- ctx.ui.notify("Entered plan phase. Waiting for planners to complete before synthesis.", "info");
486
- } else {
487
- this.pi.sendUserMessage(`[PI-PI] Entered ${phase} phase. Begin working.`, { deliverAs: "followUp" });
488
- }
489
- },
490
- onError: (err) => {
491
- console.error(`[pi-pi] Phase compaction failed: ${err.message}`);
492
- this.phaseCompactionPending = false;
493
- if (this.phaseCompactionResolve) {
494
- this.phaseCompactionResolve();
495
- this.phaseCompactionResolve = null;
496
- }
650
+ compactAndTransition(ctx: ExtensionContext, taskDir: string, phase: Phase, onReady?: () => void, summary?: string): void {
651
+ getLogger().info({ s: "phase", taskDir, phase }, "compact and transition");
652
+ // Ensure the controller's host can reach this live ctx for compact/isIdle.
653
+ this.lastCtx = ctx;
654
+ // Notify-only case: entering plan and immediately awaiting planners. No
655
+ // "Begin working" instruction is sent — the agent waits for onSubagentsDone.
656
+ const notifyOnly = this.active?.state.phase === "plan" && this.active.state.step === "await_planners";
657
+ void this.transitionController.requestTransition({
658
+ kind: "phase",
659
+ summary: summary || "Phase transition — previous phase completed.",
660
+ onResume: async () => {
497
661
  this.phaseStartTime = Date.now();
498
662
  if (this.active && (phase === "plan" || phase === "implement")) {
499
- const modelConfig = this.config.mainModel.implement;
500
- this.switchModel(ctx, modelConfig.model, modelConfig.thinking).catch(() => {});
663
+ const modelConfig = phase === "plan" ? this.config.agents.orchestrators.plan : this.config.agents.orchestrators.implement;
664
+ await this.switchModel(ctx, modelConfig.model, modelConfig.thinking);
501
665
  }
502
666
  this.injectContextAndArtifacts(taskDir, phase);
503
- if (this.active?.state.phase === "plan" && this.active.state.step === "await_planners") {
667
+ onReady?.();
668
+ if (notifyOnly) {
504
669
  ctx.ui.notify("Entered plan phase. Waiting for planners to complete before synthesis.", "info");
505
- } else {
506
- this.pi.sendUserMessage(`[PI-PI] Entered ${phase} phase. Begin working.`, { deliverAs: "followUp" });
507
670
  }
508
671
  },
672
+ instruction: notifyOnly ? undefined : `[PI-PI] Entered ${phase} phase. Begin working.`,
509
673
  });
510
674
  }
511
675
 
@@ -521,23 +685,6 @@ export class Orchestrator {
521
685
  }
522
686
  }
523
687
 
524
- export function deepReviewConfig(config: PiPiConfig): PiPiConfig {
525
- const THINKING_UPGRADE: Record<string, string> = { low: "medium", medium: "high", high: "xhigh" };
526
- const upgrade = (reviewers: Record<string, VariantConfig>) => {
527
- const upgraded: Record<string, VariantConfig> = {};
528
- for (const [name, variant] of Object.entries(reviewers)) {
529
- upgraded[name] = { ...variant, thinking: THINKING_UPGRADE[variant.thinking] ?? "high" };
530
- }
531
- return upgraded;
532
- };
533
- return {
534
- ...config,
535
- codeReviewers: upgrade(config.codeReviewers),
536
- planReviewers: upgrade(config.planReviewers),
537
- brainstormReviewers: upgrade(config.brainstormReviewers),
538
- };
539
- }
540
-
541
688
  export function ensureGitignore(cwd: string): void {
542
689
  const ppDir = join(cwd, ".pp");
543
690
  if (!existsSync(ppDir)) {
@@ -545,7 +692,7 @@ export function ensureGitignore(cwd: string): void {
545
692
  }
546
693
 
547
694
  const gitignorePath = join(ppDir, ".gitignore");
548
- const requiredEntries = ["state/", "config.json"];
695
+ const requiredEntries = ["state/", "config.json", "logs/"];
549
696
 
550
697
  if (!existsSync(gitignorePath)) {
551
698
  writeFileSync(gitignorePath, requiredEntries.join("\n") + "\n", "utf-8");