@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,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, findLatestFamilyMatch } 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,63 @@ 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 allSpecs = allModels.map((m) => `${m.provider}/${m.id}`);
180
+ const familyMatch = findLatestFamilyMatch(spec, allSpecs);
181
+ if (familyMatch) {
182
+ const fmLower = familyMatch.toLowerCase();
183
+ resolved = allModels.find(
184
+ (m) => `${m.provider.toLowerCase()}/${m.id.toLowerCase()}` === fmLower,
185
+ );
186
+ }
187
+ }
188
+ if (!resolved) {
189
+ const pattern = spec.toLowerCase();
190
+ const matches = allModels.filter(
191
+ (m) => m.id.toLowerCase() === pattern || m.id.toLowerCase().includes(pattern),
192
+ );
193
+ if (matches.length === 1) resolved = matches[0];
194
+ }
195
+ if (resolved) break;
131
196
  }
197
+
132
198
  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];
199
+ log.warn({ s: "model", requestedSpecs }, "model not found");
200
+ return false;
138
201
  }
139
202
 
140
- if (!resolved) return false;
141
-
142
203
  const ok = await this.pi.setModel(resolved);
143
- if (!ok) return false;
204
+ if (!ok) {
205
+ log.warn({ s: "model", resolved: `${resolved.provider}/${resolved.id}` }, "setModel returned false");
206
+ return false;
207
+ }
144
208
 
145
- const VALID_THINKING = new Set(["off", "low", "medium", "high"]);
146
- const thinkingLevel = (VALID_THINKING.has(thinking) ? thinking : "high") as "off" | "low" | "medium" | "high";
209
+ const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh"]);
210
+ const thinkingLevel = (VALID_THINKING.has(thinking) ? thinking : "high") as
211
+ | "off"
212
+ | "minimal"
213
+ | "low"
214
+ | "medium"
215
+ | "high"
216
+ | "xhigh";
147
217
  this.pi.setThinkingLevel(thinkingLevel);
218
+ log.debug({ s: "model", model: `${resolved.provider}/${resolved.id}`, thinking: thinkingLevel }, "model switched");
148
219
  return true;
149
220
  }
150
221
 
@@ -158,11 +229,13 @@ export class Orchestrator {
158
229
  const phase = this.active.state.phase;
159
230
  const step = this.active.state.step;
160
231
  const reviewCycle = this.active.state.reviewCycle;
232
+ const effectiveMode = this.active.state.effectiveMode ?? this.active.state.mode;
233
+ const modeLabel = effectiveMode === "autonomous" ? " [autonomous]" : "";
161
234
 
162
- if (type === "debug" || type === "brainstorm") {
235
+ if (type === "debug" || type === "brainstorm" || type === "quick") {
163
236
  const elapsed = this.phaseStartTime > 0 ? this.formatElapsed(this.phaseStartTime) : "";
164
237
  const suffix = elapsed ? ` (${elapsed})` : "";
165
- ctx.ui.setStatus("pp-phase", `pp: ${type}${suffix}`);
238
+ ctx.ui.setStatus("pp-phase", `pp: ${type}${modeLabel}${suffix}`);
166
239
  return;
167
240
  }
168
241
 
@@ -183,7 +256,7 @@ export class Orchestrator {
183
256
  else if (step === "user_gate") detail = "review";
184
257
 
185
258
  if (reviewCycle) {
186
- const kind = reviewCycle.kind === "plannotator" ? "plannotator" : reviewCycle.kind === "auto-deep" ? "deep review" : "review";
259
+ const kind = reviewCycle.kind === "plannotator" ? "plannotator" : "review";
187
260
  detail = `${kind} #${reviewCycle.pass}`;
188
261
  }
189
262
 
@@ -195,7 +268,7 @@ export class Orchestrator {
195
268
  }
196
269
  }
197
270
 
198
- ctx.ui.setStatus("pp-phase", `pp: ${parts.join(" → ")}`);
271
+ ctx.ui.setStatus("pp-phase", `pp: ${parts.join(" → ")}${modeLabel}`);
199
272
  }
200
273
 
201
274
  private formatElapsed(startTime: number): string {
@@ -209,14 +282,22 @@ export class Orchestrator {
209
282
  return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
210
283
  }
211
284
 
212
- getPlanStartState(taskDir: string): { step: string; shouldSpawnPlanners: boolean } {
285
+ getPlanStartState(taskDir: string, plannerPresetName?: string): { step: string; shouldSpawnPlanners: boolean } {
213
286
  const plansDir = join(taskDir, "plans");
214
- const enabledPlannerCount = Object.values(this.config.planners).filter((v) => v.enabled).length;
287
+ const presetName = plannerPresetName ?? this.config.agents.subagents.presetGroups.planners.default;
288
+ const plannerVariants = resolvePreset(this.config, "planners", presetName);
289
+ const enabledPlannerVariants = Object.entries(plannerVariants)
290
+ .filter(([, v]) => isEnabled(v))
291
+ .map(([name]) => name);
215
292
  const plannerOutputs = existsSync(plansDir)
216
293
  ? readdirSync(plansDir).filter((f) => f.endsWith(".md") && !f.includes("synthesized") && !f.includes("review_"))
217
294
  : [];
295
+ const completedVariants = new Set(
296
+ plannerOutputs.map((f) => f.replace(/^\d+_/, "").replace(/\.md$/, "")),
297
+ );
298
+ const hasAllEnabledVariants = enabledPlannerVariants.every((name) => completedVariants.has(name));
218
299
 
219
- if (enabledPlannerCount === 0 || plannerOutputs.length >= enabledPlannerCount || getLatestSynthesizedPlan(taskDir)) {
300
+ if (enabledPlannerVariants.length === 0 || hasAllEnabledVariants || getLatestSynthesizedPlan(taskDir)) {
220
301
  return { step: "synthesize", shouldSpawnPlanners: false };
221
302
  }
222
303
 
@@ -226,23 +307,26 @@ export class Orchestrator {
226
307
  getPhasePrompt(_ctx: ExtensionContext): string {
227
308
  if (!this.active) return "";
228
309
 
310
+ const mode: TaskMode = getEffectivePhaseMode(this.active.state);
311
+
229
312
  if (this.active.state.reviewCycle?.step === "apply_feedback") {
230
313
  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);
314
+ return reviewCycleSystemPrompt(this.active.dir, pass, this.active.state.phase, mode);
233
315
  }
234
316
 
235
317
  switch (this.active.state.phase) {
236
318
  case "brainstorm":
237
- return brainstormSystemPrompt(this.active.type, this.active.description, this.active.dir);
319
+ return brainstormSystemPrompt(this.active.type, this.active.description, this.active.dir, this.cwd);
238
320
  case "debug":
239
- return brainstormSystemPrompt(this.active.type, this.active.description, this.active.dir);
321
+ return brainstormSystemPrompt(this.active.type, this.active.description, this.active.dir, this.cwd);
240
322
  case "plan":
241
- return planningSystemPrompt(this.active.dir);
323
+ return planningSystemPrompt(this.active.dir, mode);
242
324
  case "implement":
243
- return implementationSystemPrompt(this.active.dir);
325
+ return implementationSystemPrompt(this.active.dir, this.cwd);
244
326
  case "review":
245
- return reviewTaskSystemPrompt(this.active.dir);
327
+ return reviewTaskSystemPrompt(this.active.dir, this.cwd);
328
+ case "quick":
329
+ return "Work on the user's request directly. There are no phases, planning, or reviews.";
246
330
  default:
247
331
  return "";
248
332
  }
@@ -265,7 +349,11 @@ export class Orchestrator {
265
349
  description: string,
266
350
  fromTaskDir?: string,
267
351
  skipBrainstorm?: boolean,
352
+ mode?: TaskMode,
268
353
  ): Promise<void> {
354
+ const log = getLogger();
355
+ log.info({ s: "task", type, description, fromTaskDir: fromTaskDir ?? null, skipBrainstorm: skipBrainstorm ?? false, mode: mode ?? null }, "startTask");
356
+ const hadActive = !!this.active;
269
357
  if (this.active) {
270
358
  ctx.ui.notify(
271
359
  `Pausing previous task "${this.active.description}" (phase: ${this.active.state.phase})…`,
@@ -277,6 +365,15 @@ export class Orchestrator {
277
365
  await this.cleanupActive();
278
366
  }
279
367
 
368
+ if (hadActive) {
369
+ // Route new-task compaction through the controller as a "done" target.
370
+ this.lastCtx = ctx;
371
+ await this.transitionController.requestTransition({
372
+ kind: "done",
373
+ summary: `Starting new ${type} task. Previous conversation discarded.`,
374
+ });
375
+ }
376
+
280
377
  try {
281
378
  this.config = loadConfig(this.cwd);
282
379
  } catch (err: any) {
@@ -284,16 +381,28 @@ export class Orchestrator {
284
381
  return;
285
382
  }
286
383
 
384
+ setLogLevel(this.config.general.logLevel);
287
385
  ensureGitignore(this.cwd);
288
386
 
289
- const dir = createTask(this.cwd, type, description);
387
+ const dir = createTask(this.cwd, type, description, mode);
290
388
  const state = loadTask(dir);
291
389
 
292
390
  if (fromTaskDir) {
293
391
  const srcUr = join(fromTaskDir, "USER_REQUEST.md");
294
392
  const srcRes = join(fromTaskDir, "RESEARCH.md");
295
393
  const srcArtifacts = join(fromTaskDir, "artifacts");
296
- if (existsSync(srcUr)) copyFileSync(srcUr, join(dir, "USER_REQUEST.md"));
394
+ if (existsSync(srcUr)) {
395
+ const originalUr = readFileSync(srcUr, "utf-8");
396
+ const implNote =
397
+ "# IMPLEMENTATION TASK\n\n" +
398
+ "This is now an **implement** task — the previous brainstorm/debug/review task is over.\n" +
399
+ "The user request, research, and artifacts below are carried over as context for implementation.\n" +
400
+ "Your job is to plan and implement actual code changes based on this research.\n" +
401
+ "Any prior instructions in the text below saying \"brainstorm only\", \"review only\",\n" +
402
+ "\"do not implement\", \"no code changes\", or similar DO NOT APPLY — they were for the previous task.\n\n" +
403
+ "---\n\n";
404
+ writeFileSync(join(dir, "USER_REQUEST.md"), implNote + originalUr, "utf-8");
405
+ }
297
406
  if (existsSync(srcRes)) copyFileSync(srcRes, join(dir, "RESEARCH.md"));
298
407
  if (existsSync(srcArtifacts)) {
299
408
  const destArtifacts = join(dir, "artifacts");
@@ -305,19 +414,21 @@ export class Orchestrator {
305
414
  state.from = relative(join(this.cwd, ".pp", "state"), fromTaskDir);
306
415
  if (skipBrainstorm && type === "implement") {
307
416
  state.phase = "plan";
308
- state.step = this.getPlanStartState(dir).step;
417
+ state.initialPhase = "plan";
418
+ state.activePlannerPreset = this.config.agents.subagents.presetGroups.planners.default;
419
+ state.step = this.getPlanStartState(dir, state.activePlannerPreset).step;
309
420
  }
310
421
  saveTask(dir, state);
311
422
  }
312
423
 
313
424
  let release: (() => Promise<void>) | null = null;
314
425
  try {
315
- release = await lockTask(dir, this.config.timeouts);
426
+ release = await lockTask(dir, this.config.performance.internals);
316
427
  } catch (err: any) {
317
428
  try {
318
429
  rmSync(dir, { recursive: true, force: true });
319
430
  } catch {
320
- console.error(`[pi-pi] Failed to clean up orphaned task dir: ${dir}`);
431
+ log.warn({ s: "task", dir }, "failed to clean up orphaned task dir");
321
432
  }
322
433
  ctx.ui.notify(`Failed to lock task: ${err.message}`, "error");
323
434
  return;
@@ -337,10 +448,14 @@ export class Orchestrator {
337
448
  description: state.description,
338
449
  };
339
450
 
340
- const modelConfig = this.config.mainModel[
451
+ addTaskDestination(dir);
452
+ log.info({ s: "task", dir, taskId: this.active.taskId, phase: state.phase, step: state.step }, "task activated");
453
+
454
+ const modelConfig = this.config.agents.orchestrators[
341
455
  type === "debug" ? "debug"
342
456
  : type === "brainstorm" ? "brainstorm"
343
457
  : type === "review" ? "review"
458
+ : type === "quick" ? "quick"
344
459
  : "implement"
345
460
  ];
346
461
  const modelOk = await this.switchModel(ctx, modelConfig.model, modelConfig.thinking);
@@ -357,30 +472,52 @@ export class Orchestrator {
357
472
 
358
473
  this.phaseStartTime = Date.now();
359
474
  const isGenericDescription = ["implement", "debug", "brainstorm", "review"].includes(this.active.description);
475
+ const isGenericQuickDescription = this.active.description === "quick";
360
476
  const hasInheritedTaskContext = Boolean(fromTaskDir && type === "implement");
361
477
  const isWaitingForPlanners = this.active.state.phase === "plan" && this.active.state.step === "await_planners";
362
- if (isGenericDescription && !hasInheritedTaskContext) {
478
+ if ((isGenericDescription || isGenericQuickDescription) && !hasInheritedTaskContext) {
363
479
  ctx.ui.notify("Task created. Describe what you'd like to do.", "info");
364
480
  } else if (isWaitingForPlanners) {
365
481
  ctx.ui.notify("Entered plan phase. Waiting for planners to complete before synthesis.", "info");
366
482
  } else {
367
- this.safeSendUserMessage(`[PI-PI] Entered ${this.active.state.phase} phase. Begin working.`);
483
+ const desc = this.active.description;
484
+ const descSuffix = !isGenericDescription ? `\n\nTask: ${desc}` : "";
485
+ this.safeSendUserMessage(`[PI-PI] Entered ${this.active.state.phase} phase. Begin working.${descSuffix}`);
368
486
  }
369
487
 
370
488
  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;
489
+ const requestedPlannerPresetName = this.active.state.activePlannerPreset ?? this.config.agents.subagents.presetGroups.planners.default;
490
+ const plannerPresetExists = Object.prototype.hasOwnProperty.call(this.config.agents.subagents.presetGroups.planners.presets ?? {}, requestedPlannerPresetName);
491
+ const plannerPresetName = plannerPresetExists
492
+ ? requestedPlannerPresetName
493
+ : (Object.keys(this.config.agents.subagents.presetGroups.planners.presets ?? {})[0] ?? requestedPlannerPresetName);
494
+ if (this.active.state.activePlannerPreset !== plannerPresetName) {
495
+ this.active.state.activePlannerPreset = plannerPresetName;
496
+ saveTask(this.active.dir, this.active.state);
497
+ }
498
+ if (!plannerPresetExists && plannerPresetName !== requestedPlannerPresetName) {
499
+ ctx.ui.notify(
500
+ `Planner preset "${requestedPlannerPresetName}" not found. Falling back to "${plannerPresetName}".`,
501
+ "warning",
502
+ );
503
+ }
504
+ const plannerVariants = resolvePreset(this.config, "planners", plannerPresetName);
505
+ this.pendingSubagentSpawns = Object.values(plannerVariants).filter((v) => isEnabled(v)).length;
372
506
  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
- });
507
+ handleSpawnResult(
508
+ this,
509
+ spawnPlanners(
510
+ this.pi,
511
+ this.cwd,
512
+ this.active.dir,
513
+ this.active.taskId,
514
+ this.config,
515
+ this.transitionController.phaseSend,
516
+ plannerVariants,
517
+ this.active?.state.repos ?? [],
518
+ ),
519
+ { kind: "planner", logScope: "planner", logMessage: "spawnPlanners failed", onSettled: (result) => { if (!result?.spawned) this.checkPlannerCompletion(); } },
520
+ );
384
521
  }
385
522
  }
386
523
 
@@ -403,23 +540,14 @@ export class Orchestrator {
403
540
  this.pendingSubagentSpawns = 0;
404
541
  this.errorRetryCount = 0;
405
542
  this.commitReminderSent = false;
406
- this.textStopReminderSent = false;
407
- this.nudgeTimestamps = [];
408
- this.cooldownHits = [];
543
+ this.consecutiveNudges = 0;
409
544
  this.nudgeHalted = false;
410
- this.phaseCompactionPending = false;
411
- this.phaseCompactionSummary = "";
412
545
  this.phaseStartTime = 0;
413
546
  this.userGatePending = false;
414
- this.reviewTransitionToken = -1;
415
547
  this.failedPlannerVariants = [];
416
548
  this.failedReviewerVariants = [];
417
549
  this.plannerFailureDialogPending = false;
418
550
  this.reviewerFailureDialogPending = false;
419
- if (this.awaitPollTimer) {
420
- clearInterval(this.awaitPollTimer);
421
- this.awaitPollTimer = null;
422
- }
423
551
  if (this.pendingRetryTimer) {
424
552
  clearTimeout(this.pendingRetryTimer);
425
553
  this.pendingRetryTimer = null;
@@ -432,80 +560,127 @@ export class Orchestrator {
432
560
 
433
561
  async cleanupActive(): Promise<void> {
434
562
  if (!this.active) return;
563
+ const dir = this.active.dir;
564
+ getLogger().info({ s: "task", dir }, "cleaning up active task");
565
+ removeTaskDestination();
435
566
  this.resetTaskScopedState();
436
567
  if (this.active.release) {
437
568
  try {
438
569
  await this.active.release();
439
570
  } catch (err: any) {
440
- console.error(`[pi-pi] Failed to release lock for ${this.active.dir}: ${err.message}`);
571
+ getLogger().error({ s: "task", dir, err: err.message }, "failed to release lock");
441
572
  }
442
573
  }
443
574
  this.active = null;
444
575
  }
445
576
 
446
577
  registerAgents(): void {
578
+ const log = getLogger();
447
579
  const explore = createExploreAgent(this.config);
448
580
  const librarian = createLibrarianAgent(this.config);
449
581
  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;
582
+ const phase = this.active?.state.phase;
583
+ const repos = this.active?.state.repos ?? [];
584
+ log.debug({ s: "agents", phase, repoCount: repos.length }, "registering agent definitions");
585
+ const contextDirs = getContextDirs(this.cwd, repos, this.config.general.loadExtraRepoConfigs);
586
+ const repoContext = buildRepoContext(repos);
587
+
588
+ const appendContext = (agentType: string, prompt: string, modelInfo: { vendor: string; family: string; tier: string }): string => {
589
+ const contextFiles = loadAllContextFiles(contextDirs, agentType as any, "system", phase, modelInfo);
590
+ if (contextFiles.length === 0 && !repoContext) return prompt;
591
+ const parts = [prompt];
592
+ if (repoContext) parts.push(repoContext.trimEnd());
593
+ if (contextFiles.length === 0) return parts.join("\n\n");
454
594
  const contextBlock = contextFiles.map((f) => f.content).join("\n\n");
455
- return prompt + "\n\n# Project Context\n\n" + contextBlock;
595
+ parts.push("# Project Context\n\n" + contextBlock);
596
+ return parts.join("\n\n");
456
597
  };
457
598
 
458
599
  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) },
600
+ {
601
+ type: "explore",
602
+ variant: null,
603
+ ...explore,
604
+ prompt: appendContext("explore", explore.prompt, getModelInfo(resolveModel(this.config.agents.subagents.simple.explore.model))),
605
+ },
606
+ {
607
+ type: "librarian",
608
+ variant: null,
609
+ ...librarian,
610
+ prompt: appendContext("librarian", librarian.prompt, getModelInfo(resolveModel(this.config.agents.subagents.simple.librarian.model))),
611
+ },
612
+ {
613
+ type: "task",
614
+ variant: null,
615
+ ...taskAgent,
616
+ prompt: appendContext("task", taskAgent.prompt, getModelInfo(resolveModel(this.config.agents.subagents.simple.task.model))),
617
+ },
462
618
  ]);
463
619
  }
464
620
 
465
621
  injectContextAndArtifacts(taskDir: string, phase: Phase): void {
466
- const contextFiles = loadContextFiles(this.cwd, "main", "context");
622
+ const log = getLogger();
623
+ log.debug({ s: "context", taskDir, phase }, "injecting context and artifacts");
624
+ const modelSpec =
625
+ phase === "debug" && this.active?.type === "debug"
626
+ ? this.config.agents.orchestrators.debug.model
627
+ : phase === "brainstorm" && this.active?.type === "brainstorm"
628
+ ? this.config.agents.orchestrators.brainstorm.model
629
+ : phase === "review" && this.active?.type === "review"
630
+ ? this.config.agents.orchestrators.review.model
631
+ : phase === "plan"
632
+ ? this.config.agents.orchestrators.plan.model
633
+ : this.config.agents.orchestrators.implement.model;
634
+ const activeModelSpec = this.lastCtx?.model
635
+ ? `${this.lastCtx.model.provider}/${this.lastCtx.model.id}`
636
+ : modelSpec;
637
+ const repos = this.active?.state.repos ?? [];
638
+ const contextDirs = getContextDirs(this.cwd, repos, this.config.general.loadExtraRepoConfigs);
639
+ const contextFiles = loadAllContextFiles(
640
+ contextDirs,
641
+ "main",
642
+ "context",
643
+ phase,
644
+ getModelInfo(activeModelSpec),
645
+ );
467
646
  for (const cf of contextFiles) {
468
- this.pi.sendMessage(
647
+ this.transitionController.sendCustom(
469
648
  { customType: "pp-context", content: cf.content, display: false },
470
- { deliverAs: "steer" },
649
+ "context",
471
650
  );
472
651
  }
473
652
  const artifacts = getPhaseArtifacts(taskDir, phase);
474
653
  for (const artifact of artifacts) {
475
- this.pi.sendMessage(
654
+ this.transitionController.sendCustom(
476
655
  { customType: "pp-artifact", content: `=== ${artifact.name} ===\n${artifact.content}`, display: false },
477
- { deliverAs: "steer" },
656
+ "context",
478
657
  );
479
658
  }
480
659
  }
481
660
 
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();
661
+ compactAndTransition(ctx: ExtensionContext, taskDir: string, phase: Phase, onReady?: () => void, summary?: string): void {
662
+ getLogger().info({ s: "phase", taskDir, phase }, "compact and transition");
663
+ // Ensure the controller's host can reach this live ctx for compact/isIdle.
664
+ this.lastCtx = ctx;
665
+ // Notify-only case: entering plan and immediately awaiting planners. No
666
+ // "Begin working" instruction is sent — the agent waits for onSubagentsDone.
667
+ const notifyOnly = this.active?.state.phase === "plan" && this.active.state.step === "await_planners";
668
+ void this.transitionController.requestTransition({
669
+ kind: "phase",
670
+ summary: summary || "Phase transition — previous phase completed.",
671
+ onResume: async () => {
672
+ this.phaseStartTime = Date.now();
673
+ if (this.active && (phase === "plan" || phase === "implement")) {
674
+ const modelConfig = phase === "plan" ? this.config.agents.orchestrators.plan : this.config.agents.orchestrators.implement;
675
+ await this.switchModel(ctx, modelConfig.model, modelConfig.thinking);
676
+ }
677
+ this.injectContextAndArtifacts(taskDir, phase);
678
+ onReady?.();
679
+ if (notifyOnly) {
680
+ ctx.ui.notify("Entered plan phase. Waiting for planners to complete before synthesis.", "info");
681
+ }
508
682
  },
683
+ instruction: notifyOnly ? undefined : `[PI-PI] Entered ${phase} phase. Begin working.`,
509
684
  });
510
685
  }
511
686
 
@@ -521,23 +696,6 @@ export class Orchestrator {
521
696
  }
522
697
  }
523
698
 
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
699
  export function ensureGitignore(cwd: string): void {
542
700
  const ppDir = join(cwd, ".pp");
543
701
  if (!existsSync(ppDir)) {
@@ -545,7 +703,7 @@ export function ensureGitignore(cwd: string): void {
545
703
  }
546
704
 
547
705
  const gitignorePath = join(ppDir, ".gitignore");
548
- const requiredEntries = ["state/", "config.json"];
706
+ const requiredEntries = ["state/", "config.json", "logs/"];
549
707
 
550
708
  if (!existsSync(gitignorePath)) {
551
709
  writeFileSync(gitignorePath, requiredEntries.join("\n") + "\n", "utf-8");