@ilya-lesikov/pi-pi 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/3p/pi-ask-user/index.ts +65 -49
  2. package/3p/pi-subagents/src/agent-manager.ts +8 -0
  3. package/3p/pi-subagents/src/agent-runner.ts +112 -19
  4. package/3p/pi-subagents/src/index.ts +3 -0
  5. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
  6. package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
  7. package/extensions/orchestrator/agents/constraints.ts +55 -0
  8. package/extensions/orchestrator/agents/explore.ts +13 -13
  9. package/extensions/orchestrator/agents/librarian.ts +12 -9
  10. package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
  11. package/extensions/orchestrator/agents/planner.ts +28 -23
  12. package/extensions/orchestrator/agents/registry.ts +2 -1
  13. package/extensions/orchestrator/agents/repo-context.ts +11 -0
  14. package/extensions/orchestrator/agents/task.ts +14 -11
  15. package/extensions/orchestrator/agents/tool-routing.ts +17 -32
  16. package/extensions/orchestrator/ast-search.ts +2 -1
  17. package/extensions/orchestrator/cbm.test.ts +35 -0
  18. package/extensions/orchestrator/cbm.ts +43 -13
  19. package/extensions/orchestrator/command-handlers.test.ts +390 -19
  20. package/extensions/orchestrator/command-handlers.ts +68 -28
  21. package/extensions/orchestrator/commands.test.ts +255 -2
  22. package/extensions/orchestrator/commands.ts +108 -10
  23. package/extensions/orchestrator/config.test.ts +289 -68
  24. package/extensions/orchestrator/config.ts +630 -121
  25. package/extensions/orchestrator/context.test.ts +177 -10
  26. package/extensions/orchestrator/context.ts +115 -14
  27. package/extensions/orchestrator/custom-footer.ts +2 -1
  28. package/extensions/orchestrator/doctor.test.ts +559 -0
  29. package/extensions/orchestrator/doctor.ts +664 -0
  30. package/extensions/orchestrator/event-handlers.test.ts +84 -22
  31. package/extensions/orchestrator/event-handlers.ts +1177 -341
  32. package/extensions/orchestrator/exa.test.ts +46 -0
  33. package/extensions/orchestrator/exa.ts +16 -10
  34. package/extensions/orchestrator/flant-infra.test.ts +224 -0
  35. package/extensions/orchestrator/flant-infra.ts +110 -41
  36. package/extensions/orchestrator/index.ts +13 -2
  37. package/extensions/orchestrator/integration.test.ts +2866 -118
  38. package/extensions/orchestrator/log.test.ts +219 -0
  39. package/extensions/orchestrator/log.ts +153 -0
  40. package/extensions/orchestrator/model-registry.test.ts +238 -0
  41. package/extensions/orchestrator/model-registry.ts +282 -0
  42. package/extensions/orchestrator/model-version.test.ts +27 -0
  43. package/extensions/orchestrator/model-version.ts +19 -0
  44. package/extensions/orchestrator/orchestrator.test.ts +206 -56
  45. package/extensions/orchestrator/orchestrator.ts +287 -140
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -36
  48. package/extensions/orchestrator/phases/implementation.ts +7 -11
  49. package/extensions/orchestrator/phases/machine.test.ts +27 -8
  50. package/extensions/orchestrator/phases/machine.ts +13 -0
  51. package/extensions/orchestrator/phases/planning.ts +57 -31
  52. package/extensions/orchestrator/phases/review-task.ts +3 -7
  53. package/extensions/orchestrator/phases/review.ts +38 -39
  54. package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
  55. package/extensions/orchestrator/phases/verdict.test.ts +139 -0
  56. package/extensions/orchestrator/phases/verdict.ts +82 -0
  57. package/extensions/orchestrator/plannotator.test.ts +85 -0
  58. package/extensions/orchestrator/plannotator.ts +24 -6
  59. package/extensions/orchestrator/pp-menu.test.ts +134 -0
  60. package/extensions/orchestrator/pp-menu.ts +2557 -347
  61. package/extensions/orchestrator/repo-utils.test.ts +151 -0
  62. package/extensions/orchestrator/repo-utils.ts +67 -0
  63. package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
  64. package/extensions/orchestrator/spawn-cleanup.ts +35 -0
  65. package/extensions/orchestrator/state.test.ts +76 -6
  66. package/extensions/orchestrator/state.ts +89 -26
  67. package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
  68. package/extensions/orchestrator/test-helpers.ts +217 -0
  69. package/extensions/orchestrator/tracer.test.ts +132 -0
  70. package/extensions/orchestrator/tracer.ts +127 -0
  71. package/extensions/orchestrator/transition-controller.test.ts +207 -0
  72. package/extensions/orchestrator/transition-controller.ts +259 -0
  73. package/extensions/orchestrator/usage-tracker.test.ts +435 -0
  74. package/extensions/orchestrator/usage-tracker.ts +23 -2
  75. package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
  76. package/package.json +2 -1
@@ -1,19 +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
- appendTaskLog,
10
+ getEffectivePhaseMode,
11
11
  type TaskType,
12
+ type TaskMode,
12
13
  type TaskState,
13
14
  type Phase,
14
15
  } from "./state.js";
15
16
  import { phasePipeline } from "./phases/machine.js";
16
- import { loadContextFiles, getPhaseArtifacts, getLatestSynthesizedPlan } from "./context.js";
17
+ import { getContextDirs, loadAllContextFiles, getPhaseArtifacts, getLatestSynthesizedPlan } from "./context.js";
17
18
  import { brainstormSystemPrompt } from "./phases/brainstorm.js";
18
19
  import { planningSystemPrompt, spawnPlanners } from "./phases/planning.js";
19
20
  import { implementationSystemPrompt } from "./phases/implementation.js";
@@ -23,6 +24,15 @@ import { registerAgentDefinitions, unregisterAgentDefinitions } from "./agents/r
23
24
  import { createExploreAgent } from "./agents/explore.js";
24
25
  import { createLibrarianAgent } from "./agents/librarian.js";
25
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
+ }
26
36
 
27
37
  const BUNDLED_TOOLS = new Set([
28
38
  "Agent", "get_subagent_result", "steer_subagent",
@@ -43,7 +53,7 @@ export interface ActiveTask {
43
53
 
44
54
  export class Orchestrator {
45
55
  active: ActiveTask | null = null;
46
- config!: PiPiConfig;
56
+ config!: NormalizedPiPiConfig;
47
57
  cwd = "";
48
58
  spawnedAgentIds = new Set<string>();
49
59
  agentDescriptions = new Map<string, string>();
@@ -60,23 +70,18 @@ export class Orchestrator {
60
70
  step?: string;
61
71
  }>();
62
72
  staleAgentTimer: ReturnType<typeof setInterval> | null = null;
63
- phaseCompactionPending = false;
64
- phaseCompactionSummary = "";
65
- taskDoneCompactionPending = false;
66
- taskDoneCompactionSummary = "";
67
- nudgeTimestamps: number[] = [];
68
- 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;
69
77
  nudgeHalted = false;
70
78
  pendingSubagentSpawns = 0;
71
79
  errorRetryCount = 0;
72
80
  commitReminderSent = false;
73
- textStopReminderSent = false;
74
81
  phaseStartTime = 0;
75
- awaitPollTimer: ReturnType<typeof setInterval> | null = null;
76
82
  pendingRetryTimer: ReturnType<typeof setTimeout> | null = null;
77
83
  activeTaskToken = 0;
78
84
  userGatePending = false;
79
- reviewTransitionToken = -1;
80
85
  lastCtx: any = null;
81
86
  failedPlannerVariants: string[] = [];
82
87
  failedReviewerVariants: string[] = [];
@@ -84,23 +89,60 @@ export class Orchestrator {
84
89
  reviewerFailureDialogPending = false;
85
90
  plannotatorReject: ((reason: Error) => void) | null = null;
86
91
  plannotatorUnsub: (() => void) | null = null;
87
- 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
+ }
88
108
 
89
- constructor(readonly pi: ExtensionAPI) {}
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
+ }
90
125
 
91
126
  safeSendUserMessage(text: string): void {
92
- const log = (event: string, extra?: Record<string, unknown>) => {
93
- if (this.active) appendTaskLog(this.active.dir, "debug.jsonl", { timestamp: new Date().toISOString(), event, text: text.slice(0, 200), ...extra });
94
- };
127
+ const log = getLogger();
95
128
  const attempt = (retries: number) => {
96
129
  try {
97
- this.pi.sendUserMessage(text);
98
- log("safeSend_sent", { retries });
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");
99
136
  } catch (err: any) {
100
137
  if (retries < 30) {
138
+ log.debug({ s: "orchestrator", retries, err: err?.message }, "safeSend retry");
101
139
  setTimeout(() => attempt(retries + 1), 1000);
102
140
  } else {
103
- log("safeSend_failed", { error: err?.message ?? String(err), retries });
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
+ );
104
146
  }
105
147
  }
106
148
  };
@@ -117,34 +159,53 @@ export class Orchestrator {
117
159
  }
118
160
 
119
161
  async switchModel(ctx: ExtensionContext, modelSpec: string, thinking: string): Promise<boolean> {
162
+ const log = getLogger();
120
163
  const registry = ctx.modelRegistry;
121
164
  const allModels = registry.getAvailable();
122
165
 
123
- 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");
124
168
  let resolved;
125
- if (slashIdx !== -1) {
126
- const provider = modelSpec.substring(0, slashIdx).trim().toLowerCase();
127
- const modelId = modelSpec.substring(slashIdx + 1).trim().toLowerCase();
128
- resolved = allModels.find(
129
- (m) => m.provider.toLowerCase() === provider && m.id.toLowerCase() === modelId,
130
- );
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;
131
186
  }
187
+
132
188
  if (!resolved) {
133
- const pattern = modelSpec.toLowerCase();
134
- const matches = allModels.filter(
135
- (m) => m.id.toLowerCase() === pattern || m.id.toLowerCase().includes(pattern),
136
- );
137
- if (matches.length === 1) resolved = matches[0];
189
+ log.warn({ s: "model", requestedSpecs }, "model not found");
190
+ return false;
138
191
  }
139
192
 
140
- if (!resolved) return false;
141
-
142
193
  const ok = await this.pi.setModel(resolved);
143
- if (!ok) return false;
194
+ if (!ok) {
195
+ log.warn({ s: "model", resolved: `${resolved.provider}/${resolved.id}` }, "setModel returned false");
196
+ return false;
197
+ }
144
198
 
145
- const VALID_THINKING = new Set(["off", "low", "medium", "high"]);
146
- 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";
147
207
  this.pi.setThinkingLevel(thinkingLevel);
208
+ log.debug({ s: "model", model: `${resolved.provider}/${resolved.id}`, thinking: thinkingLevel }, "model switched");
148
209
  return true;
149
210
  }
150
211
 
@@ -158,11 +219,13 @@ export class Orchestrator {
158
219
  const phase = this.active.state.phase;
159
220
  const step = this.active.state.step;
160
221
  const reviewCycle = this.active.state.reviewCycle;
222
+ const effectiveMode = this.active.state.effectiveMode ?? this.active.state.mode;
223
+ const modeLabel = effectiveMode === "autonomous" ? " [autonomous]" : "";
161
224
 
162
- if (type === "debug" || type === "brainstorm") {
225
+ if (type === "debug" || type === "brainstorm" || type === "quick") {
163
226
  const elapsed = this.phaseStartTime > 0 ? this.formatElapsed(this.phaseStartTime) : "";
164
227
  const suffix = elapsed ? ` (${elapsed})` : "";
165
- ctx.ui.setStatus("pp-phase", `pp: ${type}${suffix}`);
228
+ ctx.ui.setStatus("pp-phase", `pp: ${type}${modeLabel}${suffix}`);
166
229
  return;
167
230
  }
168
231
 
@@ -183,7 +246,7 @@ export class Orchestrator {
183
246
  else if (step === "user_gate") detail = "review";
184
247
 
185
248
  if (reviewCycle) {
186
- const kind = reviewCycle.kind === "plannotator" ? "plannotator" : reviewCycle.kind === "auto-deep" ? "deep review" : "review";
249
+ const kind = reviewCycle.kind === "plannotator" ? "plannotator" : "review";
187
250
  detail = `${kind} #${reviewCycle.pass}`;
188
251
  }
189
252
 
@@ -195,7 +258,7 @@ export class Orchestrator {
195
258
  }
196
259
  }
197
260
 
198
- ctx.ui.setStatus("pp-phase", `pp: ${parts.join(" → ")}`);
261
+ ctx.ui.setStatus("pp-phase", `pp: ${parts.join(" → ")}${modeLabel}`);
199
262
  }
200
263
 
201
264
  private formatElapsed(startTime: number): string {
@@ -209,14 +272,22 @@ export class Orchestrator {
209
272
  return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
210
273
  }
211
274
 
212
- getPlanStartState(taskDir: string): { step: string; shouldSpawnPlanners: boolean } {
275
+ getPlanStartState(taskDir: string, plannerPresetName?: string): { step: string; shouldSpawnPlanners: boolean } {
213
276
  const plansDir = join(taskDir, "plans");
214
- 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);
215
282
  const plannerOutputs = existsSync(plansDir)
216
283
  ? readdirSync(plansDir).filter((f) => f.endsWith(".md") && !f.includes("synthesized") && !f.includes("review_"))
217
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));
218
289
 
219
- if (enabledPlannerCount === 0 || plannerOutputs.length >= enabledPlannerCount || getLatestSynthesizedPlan(taskDir)) {
290
+ if (enabledPlannerVariants.length === 0 || hasAllEnabledVariants || getLatestSynthesizedPlan(taskDir)) {
220
291
  return { step: "synthesize", shouldSpawnPlanners: false };
221
292
  }
222
293
 
@@ -226,23 +297,26 @@ export class Orchestrator {
226
297
  getPhasePrompt(_ctx: ExtensionContext): string {
227
298
  if (!this.active) return "";
228
299
 
300
+ const mode: TaskMode = getEffectivePhaseMode(this.active.state);
301
+
229
302
  if (this.active.state.reviewCycle?.step === "apply_feedback") {
230
303
  const pass = this.active.state.reviewCycle.pass;
231
- const manualReview = this.active.state.reviewCycle.kind === "manual";
232
- return reviewCycleSystemPrompt(this.active.dir, pass, manualReview, this.active.state.phase);
304
+ return reviewCycleSystemPrompt(this.active.dir, pass, this.active.state.phase);
233
305
  }
234
306
 
235
307
  switch (this.active.state.phase) {
236
308
  case "brainstorm":
237
- 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);
238
310
  case "debug":
239
- 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);
240
312
  case "plan":
241
- return planningSystemPrompt(this.active.dir);
313
+ return planningSystemPrompt(this.active.dir, mode);
242
314
  case "implement":
243
- return implementationSystemPrompt(this.active.dir);
315
+ return implementationSystemPrompt(this.active.dir, this.cwd);
244
316
  case "review":
245
- 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.";
246
320
  default:
247
321
  return "";
248
322
  }
@@ -265,7 +339,11 @@ export class Orchestrator {
265
339
  description: string,
266
340
  fromTaskDir?: string,
267
341
  skipBrainstorm?: boolean,
342
+ mode?: TaskMode,
268
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;
269
347
  if (this.active) {
270
348
  ctx.ui.notify(
271
349
  `Pausing previous task "${this.active.description}" (phase: ${this.active.state.phase})…`,
@@ -277,6 +355,15 @@ export class Orchestrator {
277
355
  await this.cleanupActive();
278
356
  }
279
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
+
280
367
  try {
281
368
  this.config = loadConfig(this.cwd);
282
369
  } catch (err: any) {
@@ -284,16 +371,28 @@ export class Orchestrator {
284
371
  return;
285
372
  }
286
373
 
374
+ setLogLevel(this.config.general.logLevel);
287
375
  ensureGitignore(this.cwd);
288
376
 
289
- const dir = createTask(this.cwd, type, description);
377
+ const dir = createTask(this.cwd, type, description, mode);
290
378
  const state = loadTask(dir);
291
379
 
292
380
  if (fromTaskDir) {
293
381
  const srcUr = join(fromTaskDir, "USER_REQUEST.md");
294
382
  const srcRes = join(fromTaskDir, "RESEARCH.md");
295
383
  const srcArtifacts = join(fromTaskDir, "artifacts");
296
- 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
+ }
297
396
  if (existsSync(srcRes)) copyFileSync(srcRes, join(dir, "RESEARCH.md"));
298
397
  if (existsSync(srcArtifacts)) {
299
398
  const destArtifacts = join(dir, "artifacts");
@@ -305,19 +404,21 @@ export class Orchestrator {
305
404
  state.from = relative(join(this.cwd, ".pp", "state"), fromTaskDir);
306
405
  if (skipBrainstorm && type === "implement") {
307
406
  state.phase = "plan";
308
- 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;
309
410
  }
310
411
  saveTask(dir, state);
311
412
  }
312
413
 
313
414
  let release: (() => Promise<void>) | null = null;
314
415
  try {
315
- release = await lockTask(dir, this.config.timeouts);
416
+ release = await lockTask(dir, this.config.performance.internals);
316
417
  } catch (err: any) {
317
418
  try {
318
419
  rmSync(dir, { recursive: true, force: true });
319
420
  } catch {
320
- 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");
321
422
  }
322
423
  ctx.ui.notify(`Failed to lock task: ${err.message}`, "error");
323
424
  return;
@@ -337,7 +438,10 @@ export class Orchestrator {
337
438
  description: state.description,
338
439
  };
339
440
 
340
- 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[
341
445
  type === "debug" ? "debug"
342
446
  : type === "brainstorm" ? "brainstorm"
343
447
  : type === "review" ? "review"
@@ -357,30 +461,52 @@ export class Orchestrator {
357
461
 
358
462
  this.phaseStartTime = Date.now();
359
463
  const isGenericDescription = ["implement", "debug", "brainstorm", "review"].includes(this.active.description);
464
+ const isGenericQuickDescription = this.active.description === "quick";
360
465
  const hasInheritedTaskContext = Boolean(fromTaskDir && type === "implement");
361
466
  const isWaitingForPlanners = this.active.state.phase === "plan" && this.active.state.step === "await_planners";
362
- if (isGenericDescription && !hasInheritedTaskContext) {
467
+ if ((isGenericDescription || isGenericQuickDescription) && !hasInheritedTaskContext) {
363
468
  ctx.ui.notify("Task created. Describe what you'd like to do.", "info");
364
469
  } else if (isWaitingForPlanners) {
365
470
  ctx.ui.notify("Entered plan phase. Waiting for planners to complete before synthesis.", "info");
366
471
  } else {
367
- this.safeSendUserMessage(`[PI-PI] Entered ${this.active.state.phase} phase. Begin working.`);
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}`);
368
475
  }
369
476
 
370
477
  if (this.active.state.phase === "plan" && this.active.state.step === "await_planners") {
371
- 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;
372
495
  this.failedPlannerVariants = [];
373
- spawnPlanners(this.pi, this.cwd, this.active.dir, this.active.taskId, this.config).then((result) => {
374
- this.failedPlannerVariants = result.failedVariants;
375
- if (result.spawned === 0) this.pendingSubagentSpawns = 0;
376
- for (const id of result.agentIds ?? []) {
377
- this.spawnedAgentIds.delete(id);
378
- }
379
- this.pendingSubagentSpawns = 0;
380
- }).catch((err) => {
381
- this.pendingSubagentSpawns = 0;
382
- console.error(`[pi-pi] spawnPlanners failed: ${err.message}`);
383
- });
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
+ );
384
510
  }
385
511
  }
386
512
 
@@ -403,23 +529,14 @@ export class Orchestrator {
403
529
  this.pendingSubagentSpawns = 0;
404
530
  this.errorRetryCount = 0;
405
531
  this.commitReminderSent = false;
406
- this.textStopReminderSent = false;
407
- this.nudgeTimestamps = [];
408
- this.cooldownHits = [];
532
+ this.consecutiveNudges = 0;
409
533
  this.nudgeHalted = false;
410
- this.phaseCompactionPending = false;
411
- this.phaseCompactionSummary = "";
412
534
  this.phaseStartTime = 0;
413
535
  this.userGatePending = false;
414
- this.reviewTransitionToken = -1;
415
536
  this.failedPlannerVariants = [];
416
537
  this.failedReviewerVariants = [];
417
538
  this.plannerFailureDialogPending = false;
418
539
  this.reviewerFailureDialogPending = false;
419
- if (this.awaitPollTimer) {
420
- clearInterval(this.awaitPollTimer);
421
- this.awaitPollTimer = null;
422
- }
423
540
  if (this.pendingRetryTimer) {
424
541
  clearTimeout(this.pendingRetryTimer);
425
542
  this.pendingRetryTimer = null;
@@ -432,80 +549,127 @@ export class Orchestrator {
432
549
 
433
550
  async cleanupActive(): Promise<void> {
434
551
  if (!this.active) return;
552
+ const dir = this.active.dir;
553
+ getLogger().info({ s: "task", dir }, "cleaning up active task");
554
+ removeTaskDestination();
435
555
  this.resetTaskScopedState();
436
556
  if (this.active.release) {
437
557
  try {
438
558
  await this.active.release();
439
559
  } catch (err: any) {
440
- 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");
441
561
  }
442
562
  }
443
563
  this.active = null;
444
564
  }
445
565
 
446
566
  registerAgents(): void {
567
+ const log = getLogger();
447
568
  const explore = createExploreAgent(this.config);
448
569
  const librarian = createLibrarianAgent(this.config);
449
570
  const taskAgent = createTaskAgent(this.config, "{{subtask}}", { userRequest: "", synthesizedPlan: "" });
450
-
451
- const appendContext = (agentType: string, prompt: string): string => {
452
- const contextFiles = loadContextFiles(this.cwd, agentType as any, "system");
453
- 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");
454
583
  const contextBlock = contextFiles.map((f) => f.content).join("\n\n");
455
- return prompt + "\n\n# Project Context\n\n" + contextBlock;
584
+ parts.push("# Project Context\n\n" + contextBlock);
585
+ return parts.join("\n\n");
456
586
  };
457
587
 
458
588
  registerAgentDefinitions(this.pi, [
459
- { type: "explore", variant: null, ...explore, prompt: appendContext("explore", explore.prompt) },
460
- { type: "librarian", variant: null, ...librarian, prompt: appendContext("librarian", librarian.prompt) },
461
- { 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
+ },
462
607
  ]);
463
608
  }
464
609
 
465
610
  injectContextAndArtifacts(taskDir: string, phase: Phase): void {
466
- 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
+ );
467
635
  for (const cf of contextFiles) {
468
- this.pi.sendMessage(
636
+ this.transitionController.sendCustom(
469
637
  { customType: "pp-context", content: cf.content, display: false },
470
- { deliverAs: "steer" },
638
+ "context",
471
639
  );
472
640
  }
473
641
  const artifacts = getPhaseArtifacts(taskDir, phase);
474
642
  for (const artifact of artifacts) {
475
- this.pi.sendMessage(
643
+ this.transitionController.sendCustom(
476
644
  { customType: "pp-artifact", content: `=== ${artifact.name} ===\n${artifact.content}`, display: false },
477
- { deliverAs: "steer" },
645
+ "context",
478
646
  );
479
647
  }
480
648
  }
481
649
 
482
- compactAndTransition(ctx: ExtensionContext, taskDir: string, phase: Phase, onReady?: () => void): void {
483
- this.phaseCompactionPending = true;
484
- const finalize = () => {
485
- this.phaseStartTime = Date.now();
486
- if (this.active && (phase === "plan" || phase === "implement")) {
487
- const modelConfig = this.config.mainModel.implement;
488
- this.switchModel(ctx, modelConfig.model, modelConfig.thinking).catch(() => {});
489
- }
490
- this.injectContextAndArtifacts(taskDir, phase);
491
- onReady?.();
492
- if (this.active?.state.phase === "plan" && this.active.state.step === "await_planners") {
493
- ctx.ui.notify("Entered plan phase. Waiting for planners to complete before synthesis.", "info");
494
- } else {
495
- this.safeSendUserMessage(`[PI-PI] Entered ${phase} phase. Begin working.`);
496
- }
497
- };
498
- ctx.compact({
499
- customInstructions: "Phase transition — discard all prior conversation. Produce a one-line summary: 'Previous phase completed.'",
500
- onComplete: () => {
501
- this.phaseCompactionPending = false;
502
- finalize();
503
- },
504
- onError: (err) => {
505
- console.error(`[pi-pi] Phase compaction failed: ${err.message}`);
506
- this.phaseCompactionPending = false;
507
- finalize();
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 () => {
661
+ this.phaseStartTime = Date.now();
662
+ if (this.active && (phase === "plan" || phase === "implement")) {
663
+ const modelConfig = phase === "plan" ? this.config.agents.orchestrators.plan : this.config.agents.orchestrators.implement;
664
+ await this.switchModel(ctx, modelConfig.model, modelConfig.thinking);
665
+ }
666
+ this.injectContextAndArtifacts(taskDir, phase);
667
+ onReady?.();
668
+ if (notifyOnly) {
669
+ ctx.ui.notify("Entered plan phase. Waiting for planners to complete before synthesis.", "info");
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");