@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,27 +1,51 @@
1
1
  import { existsSync, readdirSync, writeFileSync } from "fs";
2
2
  import { join, relative } from "path";
3
- import { askUser } from "../../3p/pi-ask-user/index.js";
3
+ import { isDeepStrictEqual } from "util";
4
+ import { askUser, isCancel } from "../../3p/pi-ask-user/index.js";
4
5
  import { unregisterAgentDefinitions } from "./agents/registry.js";
5
- import { loadConfig, type PiPiConfig } from "./config.js";
6
+ import {
7
+ type AfterEditCommandConfig,
8
+ type AfterImplementCommandConfig,
9
+ GLOBAL_CONFIG_PATH,
10
+ getDefaultConfig,
11
+ loadConfig,
12
+ mergeConfigLayers,
13
+ parseDuration,
14
+ readRawConfig,
15
+ removeConfigValue,
16
+ resolvePreset,
17
+ writeConfigValue,
18
+ type PiPiConfig,
19
+ type PresetGroup,
20
+ type OrchestratorRole,
21
+ type VariantConfig,
22
+ } from "./config.js";
6
23
  import {
7
24
  loadBrainstormReviewOutputs,
8
25
  loadCodeReviewOutputs,
9
26
  loadPlanReviewOutputs,
10
27
  } from "./context.js";
11
28
  import { detectDefaultBranch, enterReviewCycle, finalizeReviewCycle } from "./event-handlers.js";
12
- import { Orchestrator, deepReviewConfig } from "./orchestrator.js";
29
+ import { Orchestrator } from "./orchestrator.js";
13
30
  import { cancelPendingPlannotatorWait } from "./plannotator.js";
14
31
  import { spawnPlanners, spawnPlanReviewers } from "./phases/planning.js";
15
32
  import { spawnCodeReviewers } from "./phases/review.js";
16
33
  import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
34
+ import { nextPhase } from "./phases/machine.js";
35
+ import { getAllAliases, getModelFamilies, getModelInfo, resolveModel, updateRegistryFromAvailableModels } from "./model-registry.js";
36
+ import { compareModelVersion } from "./model-version.js";
17
37
 
18
38
  import {
19
39
  listTasks,
40
+ getEffectiveMode,
41
+ getEffectivePhaseMode,
20
42
  loadTask,
21
43
  lockTask,
22
44
  saveTask,
23
45
  taskAge,
24
46
  taskName,
47
+ type AutonomousConfig,
48
+ type TaskMode,
25
49
  type TaskInfo,
26
50
  type TaskType,
27
51
  } from "./state.js";
@@ -34,6 +58,10 @@ import {
34
58
  updateFlantInfra,
35
59
  type FlantSettings,
36
60
  } from "./flant-infra.js";
61
+ import { runDoctor } from "./doctor.js";
62
+ import { normalizeRepoPath, type RepoInfo } from "./repo-utils.js";
63
+ import { getLogger, addTaskDestination, setLogLevel } from "./log.js";
64
+ import { handleSpawnResult } from "./spawn-cleanup.js";
37
65
 
38
66
  type MenuMode = "command" | "tool";
39
67
 
@@ -41,6 +69,17 @@ const BACK = "back" as const;
41
69
 
42
70
  type OptionInput = string | { title: string; description?: string };
43
71
 
72
+ type TimeoutKey =
73
+ | "performance.commands.afterEdit"
74
+ | "performance.commands.afterImplement"
75
+ | "performance.internals.subagentStale"
76
+ | "performance.internals.taskLockStale"
77
+ | "performance.internals.taskLockRefresh";
78
+
79
+ function isEnabled(value: { enabled?: boolean } | undefined): boolean {
80
+ return value?.enabled !== false;
81
+ }
82
+
44
83
  async function selectOption(ctx: any, question: string, options: OptionInput[]): Promise<string | undefined> {
45
84
  const result = await askUser(ctx, {
46
85
  question,
@@ -49,10 +88,125 @@ async function selectOption(ctx: any, question: string, options: OptionInput[]):
49
88
  allowComment: false,
50
89
  allowMultiple: false,
51
90
  });
52
- if (!result || result.kind !== "selection") return undefined;
91
+ if (!result || isCancel(result) || result.kind !== "selection") return undefined;
53
92
  return result.selections[0];
54
93
  }
55
94
 
95
+ function opt(title: string, description: string): OptionInput {
96
+ return { title, description };
97
+ }
98
+
99
+ function getRegisteredRepos(orchestrator: Orchestrator): RepoInfo[] {
100
+ const repos = orchestrator.active?.state.repos ?? [];
101
+ if (repos.length > 0) return repos;
102
+ return [{ path: normalizeRepoPath(orchestrator.cwd), isRoot: true }];
103
+ }
104
+
105
+ function validateRepos(repos: RepoInfo[]): RepoInfo[] {
106
+ return repos.filter((repo) => {
107
+ try {
108
+ if (!existsSync(repo.path)) return false;
109
+ return existsSync(join(repo.path, ".git"));
110
+ } catch {
111
+ return false;
112
+ }
113
+ });
114
+ }
115
+
116
+ function formatRepoLabel(repo: RepoInfo): string {
117
+ return `${repo.path}${repo.isRoot ? " (root)" : ""}`;
118
+ }
119
+
120
+ function formatRepoList(repos: RepoInfo[]): string {
121
+ return repos
122
+ .map((repo) => {
123
+ const base = repo.baseBranch ? `, base: ${repo.baseBranch}` : "";
124
+ return `- ${formatRepoLabel(repo)}${base}`;
125
+ })
126
+ .join("\n");
127
+ }
128
+
129
+ function appendSection(content: string, heading: string, body: string): string {
130
+ const normalized = content.trimEnd();
131
+ if (normalized.includes(`${heading}\n`)) return normalized + "\n";
132
+ return `${normalized}\n\n${heading}\n${body}\n`;
133
+ }
134
+
135
+ function appendToSection(content: string, heading: string, body: string): string {
136
+ const normalized = content.trimEnd();
137
+ if (normalized.includes(body)) return normalized + "\n";
138
+
139
+ const lines = normalized.split("\n");
140
+ const sectionIndex = lines.findIndex((line) => line.trim() === heading);
141
+ if (sectionIndex === -1) {
142
+ return appendSection(normalized, heading, body);
143
+ }
144
+
145
+ let insertIndex = lines.length;
146
+ for (let i = sectionIndex + 1; i < lines.length; i += 1) {
147
+ if (lines[i]?.startsWith("## ")) {
148
+ insertIndex = i;
149
+ break;
150
+ }
151
+ }
152
+
153
+ const insertLines = body.split("\n");
154
+ if (insertIndex > sectionIndex + 1 && lines[insertIndex - 1]?.trim() !== "") {
155
+ insertLines.unshift("");
156
+ }
157
+ lines.splice(insertIndex, 0, ...insertLines);
158
+ return lines.join("\n") + "\n";
159
+ }
160
+
161
+ function appendRepoContext(content: string, repos: RepoInfo[]): string {
162
+ if (repos.length === 0) return content;
163
+ const lines = repos.map((repo) => `- ${formatRepoLabel(repo)}${repo.baseBranch ? ` (base: ${repo.baseBranch})` : ""}`);
164
+ return appendToSection(content, "## Constraints", `Registered repositories:\n${lines.join("\n")}`);
165
+ }
166
+
167
+ function appendResearchOpenQuestions(content: string, text: string): string {
168
+ const normalized = content.trimEnd();
169
+ if (normalized.includes("## Open Questions\n")) {
170
+ return `${normalized}\n${text}\n`;
171
+ }
172
+ return `${normalized}\n\n## Open Questions\n${text}\n`;
173
+ }
174
+
175
+ async function pickCommitForRepo(orchestrator: Orchestrator, ctx: any, repo: RepoInfo): Promise<string | null> {
176
+ let commits: Array<{ hash: string; message: string; age: string }> = [];
177
+ try {
178
+ const logResult = await orchestrator.pi.exec(
179
+ "git", ["log", "--oneline", "--format=%h\t%s\t%cr", "-30"],
180
+ { cwd: repo.path, timeout: 5000 },
181
+ );
182
+ if (logResult.code === 0 && logResult.stdout.trim()) {
183
+ commits = logResult.stdout.trim().split("\n").map((line) => {
184
+ const [hash, message, age] = line.split("\t");
185
+ return { hash: hash || "", message: message || "", age: age || "" };
186
+ }).filter((c) => c.hash);
187
+ }
188
+ } catch {}
189
+ if (commits.length === 0) {
190
+ ctx.ui.notify(`No commits found in ${repo.path}.`, "info");
191
+ return null;
192
+ }
193
+ const commitOptions: OptionInput[] = commits.map((c) => ({
194
+ title: `${c.hash} ${c.message}`,
195
+ description: c.age,
196
+ }));
197
+ commitOptions.push({ title: "Back", description: "Return to the previous menu" });
198
+ const picked = await selectOption(ctx, `Review changes since (${repo.path}):`, commitOptions);
199
+ if (!picked || picked === "Back") return null;
200
+ const pickedHash = picked.split(" ")[0];
201
+ return pickedHash || null;
202
+ }
203
+
204
+ interface RepoPrContext {
205
+ repoPath: string;
206
+ prUrl: string | null;
207
+ prContext: string | null;
208
+ }
209
+
56
210
  function setStep(orchestrator: Orchestrator, step: string): void {
57
211
  if (!orchestrator.active) return;
58
212
  orchestrator.active.state.step = step;
@@ -75,7 +229,7 @@ function showStatus(orchestrator: Orchestrator, ctx: any): void {
75
229
 
76
230
  async function abortCurrentWork(orchestrator: Orchestrator, ctx: any): Promise<void> {
77
231
  orchestrator.abortAllSubagents();
78
- ctx.abort?.();
232
+ orchestrator.transitionController.abortMainAgent(ctx.abort?.bind(ctx));
79
233
  await ctx.waitForIdle?.();
80
234
  const taskStore = (globalThis as any)[Symbol.for("pi-tasks:store")];
81
235
  taskStore?.clearAll?.();
@@ -87,11 +241,13 @@ async function pauseTask(orchestrator: Orchestrator, ctx: any): Promise<string>
87
241
 
88
242
  cancelPendingPlannotatorWait(orchestrator);
89
243
  orchestrator.abortAllSubagents();
90
- ctx.abort?.();
244
+ orchestrator.transitionController.abortMainAgent(ctx.abort?.bind(ctx));
91
245
  await ctx.waitForIdle?.();
92
246
 
93
247
  const name = orchestrator.active.description;
248
+ const type = orchestrator.active.type;
94
249
 
250
+ orchestrator.active.state.reviewCycle = null;
95
251
  saveTask(orchestrator.active.dir, orchestrator.active.state);
96
252
  unregisterAgentDefinitions(orchestrator.pi);
97
253
  await orchestrator.cleanupActive();
@@ -100,7 +256,15 @@ async function pauseTask(orchestrator: Orchestrator, ctx: any): Promise<string>
100
256
  taskStore?.clearAll?.();
101
257
  taskStore?.refreshWidget?.(ctx.ui);
102
258
 
259
+ orchestrator.lastCtx = ctx;
103
260
  orchestrator.updateStatus(ctx);
261
+ // Route through the controller as a "done" target (same coordinator as every
262
+ // other compaction). The agent was aborted above, so this compacts via the
263
+ // already-idle path; the awaitable resolves at every terminus.
264
+ await orchestrator.transitionController.requestTransition({
265
+ kind: "done",
266
+ summary: `Task "${name}" (${type}) paused.`,
267
+ });
104
268
  ctx.ui.notify(`Task "${name}" paused. Use /pp → Resume to continue.`, "info");
105
269
  return `Task "${name}" paused.`;
106
270
  }
@@ -110,17 +274,17 @@ async function finishTask(orchestrator: Orchestrator, ctx: any): Promise<string>
110
274
 
111
275
  cancelPendingPlannotatorWait(orchestrator);
112
276
  orchestrator.abortAllSubagents();
113
- ctx.abort?.();
277
+ orchestrator.transitionController.abortMainAgent(ctx.abort?.bind(ctx));
114
278
  await ctx.waitForIdle?.();
115
279
 
116
280
  const name = orchestrator.active.description;
117
281
  const type = orchestrator.active.type;
118
282
  const dir = orchestrator.active.dir;
119
283
 
120
- orchestrator.taskDoneCompactionPending = true;
121
- orchestrator.taskDoneCompactionSummary = `Task "${name}" (${type}) completed.`;
284
+ orchestrator.lastCtx = ctx;
122
285
 
123
286
  orchestrator.active.state.phase = "done";
287
+ orchestrator.active.state.reviewCycle = null;
124
288
  saveTask(orchestrator.active.dir, orchestrator.active.state);
125
289
  unregisterAgentDefinitions(orchestrator.pi);
126
290
  await orchestrator.cleanupActive();
@@ -130,7 +294,12 @@ async function finishTask(orchestrator: Orchestrator, ctx: any): Promise<string>
130
294
  taskStore?.refreshWidget?.(ctx.ui);
131
295
 
132
296
  orchestrator.updateStatus(ctx);
133
- ctx.compact?.();
297
+ // Route through the controller as a "done" target (fire-and-forget: the task
298
+ // is over, nothing awaits this compaction).
299
+ void orchestrator.transitionController.requestTransition({
300
+ kind: "done",
301
+ summary: `Task "${name}" (${type}) completed.`,
302
+ });
134
303
 
135
304
  const urExists = existsSync(join(dir, "USER_REQUEST.md"));
136
305
  const resExists = existsSync(join(dir, "RESEARCH.md"));
@@ -150,10 +319,59 @@ async function finishTask(orchestrator: Orchestrator, ctx: any): Promise<string>
150
319
 
151
320
  function loadPhaseReviewOutputs(taskDir: string, phase: string, pass: number): { name: string; content: string }[] {
152
321
  if (phase === "brainstorm") return loadBrainstormReviewOutputs(taskDir, pass);
153
- if (phase === "plan") return loadPlanReviewOutputs(taskDir);
322
+ if (phase === "plan") return loadPlanReviewOutputs(taskDir, pass);
154
323
  return loadCodeReviewOutputs(taskDir, pass);
155
324
  }
156
325
 
326
+ function getDefaultReviewPresetName(config: PiPiConfig, phase: string): string {
327
+ if (phase === "brainstorm") return config.agents.subagents.presetGroups.brainstormReviewers.default;
328
+ if (phase === "plan") return config.agents.subagents.presetGroups.planReviewers.default;
329
+ return config.agents.subagents.presetGroups.codeReviewers.default;
330
+ }
331
+
332
+ function isPresetEnabled(preset: { enabled?: boolean } | undefined): boolean {
333
+ return preset?.enabled !== false;
334
+ }
335
+
336
+ function getReviewPresetGroup(phase: string): PresetGroup {
337
+ if (phase === "brainstorm") return "brainstormReviewers";
338
+ if (phase === "plan") return "planReviewers";
339
+ return "codeReviewers";
340
+ }
341
+
342
+ export async function pickPreset(
343
+ ctx: any,
344
+ orchestrator: Orchestrator,
345
+ group: PresetGroup,
346
+ title: string,
347
+ ): Promise<string | null> {
348
+ const presets = orchestrator.config.agents.subagents.presetGroups[group].presets ?? {};
349
+ const defaultPresetName = orchestrator.config.agents.subagents.presetGroups[group].default;
350
+
351
+ const options: OptionInput[] = [];
352
+ const byTitle = new Map<string, string>();
353
+
354
+ for (const [presetName, preset] of Object.entries(presets)) {
355
+ if (!isPresetEnabled(preset)) continue;
356
+ const enabledModels = Object.values(preset.agents)
357
+ .filter((variant) => isEnabled(variant))
358
+ .map((variant) => {
359
+ const info = getModelInfo(variant.model);
360
+ return `${info.vendor}/${info.family} (${variant.thinking})`;
361
+ });
362
+ const description = enabledModels.length > 0 ? enabledModels.join(", ") : "No enabled models";
363
+ const optionTitle = presetName === defaultPresetName ? `${presetName} [default]` : presetName;
364
+ byTitle.set(optionTitle, presetName);
365
+ options.push({ title: optionTitle, description });
366
+ }
367
+
368
+ options.push({ title: "Back", description: "Return to the previous menu" });
369
+
370
+ const choice = await selectOption(ctx, title, options);
371
+ if (!choice || choice === "Back") return null;
372
+ return byTitle.get(choice) ?? null;
373
+ }
374
+
157
375
  export async function resumeTask(
158
376
  orchestrator: Orchestrator,
159
377
  ctx: any,
@@ -171,9 +389,9 @@ export async function resumeTask(
171
389
 
172
390
  let release: (() => Promise<void>) | null = null;
173
391
  try {
174
- release = await lockTask(task.dir, orchestrator.config.timeouts);
392
+ release = await lockTask(task.dir, orchestrator.config.performance.internals);
175
393
  } catch {
176
- const staleSeconds = Math.round(orchestrator.config.timeouts.lockStale / 1000);
394
+ const staleSeconds = Math.round(orchestrator.config.performance.internals.taskLockStale / 1000);
177
395
  const message =
178
396
  `Cannot resume: task is locked by another pi session (or a session that crashed less than ${staleSeconds}s ago). ` +
179
397
  `Wait ${staleSeconds}s for the lock to expire, or kill the other session first.`;
@@ -184,6 +402,39 @@ export async function resumeTask(
184
402
  orchestrator.resetTaskScopedState();
185
403
  orchestrator.activeTaskToken++;
186
404
 
405
+ const normalizedRoot = normalizeRepoPath(orchestrator.cwd);
406
+ if (!task.state.repos || task.state.repos.length === 0) {
407
+ task.state.repos = [{ path: normalizedRoot, isRoot: true }];
408
+ saveTask(task.dir, task.state);
409
+ }
410
+
411
+ if (!task.state.repos.some((repo) => repo.isRoot)) {
412
+ const rootByPath = task.state.repos.find((repo) => repo.path === normalizedRoot);
413
+ if (rootByPath) {
414
+ rootByPath.isRoot = true;
415
+ } else if (task.state.repos.length > 0) {
416
+ task.state.repos[0]!.isRoot = true;
417
+ } else {
418
+ task.state.repos = [{ path: normalizedRoot, isRoot: true }];
419
+ }
420
+ saveTask(task.dir, task.state);
421
+ }
422
+
423
+ const validRepos = validateRepos(task.state.repos ?? []);
424
+ if ((task.state.repos?.length ?? 0) !== validRepos.length) {
425
+ const pruned = (task.state.repos?.length ?? 0) - validRepos.length;
426
+ task.state.repos = validRepos;
427
+ saveTask(task.dir, task.state);
428
+ ctx.ui.notify(`Pruned ${pruned} stale repo(s) that no longer exist.`, "warning");
429
+ }
430
+
431
+ if (!task.state.repos || task.state.repos.length === 0) {
432
+ task.state.repos = [{ path: normalizedRoot, isRoot: true }];
433
+ saveTask(task.dir, task.state);
434
+ }
435
+
436
+ const needsRepoRegistrationPrompt = task.state.repos.some((repo) => !repo.baseBranch);
437
+
187
438
  orchestrator.active = {
188
439
  dir: task.dir,
189
440
  type: task.type,
@@ -195,7 +446,11 @@ export async function resumeTask(
195
446
  description: task.state.description,
196
447
  };
197
448
 
198
- const modelConfig = orchestrator.config.mainModel[
449
+ addTaskDestination(task.dir);
450
+ setLogLevel(orchestrator.config.general.logLevel);
451
+ getLogger().info({ s: "task", dir: task.dir, type: task.type, phase: task.state.phase }, "task resumed");
452
+
453
+ const modelConfig = orchestrator.config.agents.orchestrators[
199
454
  task.type === "debug" ? "debug"
200
455
  : task.type === "brainstorm" ? "brainstorm"
201
456
  : task.type === "review" ? "review"
@@ -215,32 +470,52 @@ export async function resumeTask(
215
470
 
216
471
  if (orchestrator.active.state.phase === "plan" && orchestrator.active.state.step === "await_planners") {
217
472
  const plansDir = join(orchestrator.active.dir, "plans");
218
- const enabledVariants = Object.entries(orchestrator.config.planners).filter(([, v]) => v.enabled);
473
+ const requestedPlannerPresetName = orchestrator.active.state.activePlannerPreset ?? orchestrator.config.agents.subagents.presetGroups.planners.default;
474
+ const plannerPresetExists = Object.prototype.hasOwnProperty.call(orchestrator.config.agents.subagents.presetGroups.planners.presets ?? {}, requestedPlannerPresetName);
475
+ const plannerPresetName = plannerPresetExists
476
+ ? requestedPlannerPresetName
477
+ : (Object.keys(orchestrator.config.agents.subagents.presetGroups.planners.presets ?? {})[0] ?? requestedPlannerPresetName);
478
+ if (orchestrator.active.state.activePlannerPreset !== plannerPresetName) {
479
+ orchestrator.active.state.activePlannerPreset = plannerPresetName;
480
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
481
+ }
482
+ if (!plannerPresetExists && plannerPresetName !== requestedPlannerPresetName) {
483
+ ctx.ui.notify(
484
+ `Planner preset "${requestedPlannerPresetName}" not found. Falling back to "${plannerPresetName}".`,
485
+ "warning",
486
+ );
487
+ }
488
+ const plannerVariants = resolvePreset(orchestrator.config, "planners", plannerPresetName);
489
+ const enabledVariants = Object.entries(plannerVariants).filter(([, v]) => isEnabled(v));
219
490
  const planFiles = existsSync(plansDir)
220
491
  ? readdirSync(plansDir).filter((f) => f.endsWith(".md") && !f.includes("synthesized") && !f.includes("review_"))
221
492
  : [];
222
- if (planFiles.length >= enabledVariants.length) {
493
+ const completedVariants = new Set(planFiles.map((f) => f.replace(/^\d+_/, "").replace(/\.md$/, "")));
494
+ const hasAllEnabledVariants = enabledVariants.every(([name]) => completedVariants.has(name));
495
+ if (hasAllEnabledVariants) {
223
496
  orchestrator.active.state.step = "synthesize";
224
497
  saveTask(orchestrator.active.dir, orchestrator.active.state);
225
498
  } else {
226
- const completedVariants = new Set(planFiles.map((f) => f.replace(/^\d+_/, "").replace(/\.md$/, "")));
227
499
  const missingVariants = enabledVariants.filter(([name]) => !completedVariants.has(name));
228
500
  if (missingVariants.length > 0) {
229
- const missingConfig: Record<string, any> = {};
501
+ const missingConfig: typeof plannerVariants = {};
230
502
  for (const [name, cfg] of missingVariants) missingConfig[name] = cfg;
231
- const partialConfig = { ...orchestrator.config, planners: missingConfig };
232
503
  orchestrator.pendingSubagentSpawns = missingVariants.length;
233
504
  orchestrator.failedPlannerVariants = [];
234
- spawnPlanners(pi, orchestrator.cwd, orchestrator.active.dir, orchestrator.active.taskId, partialConfig).then((result) => {
235
- orchestrator.failedPlannerVariants = result.failedVariants;
236
- if (result.spawned === 0) orchestrator.pendingSubagentSpawns = 0;
237
- for (const id of result.agentIds ?? []) {
238
- orchestrator.spawnedAgentIds.delete(id);
239
- }
240
- orchestrator.pendingSubagentSpawns = 0;
241
- }).catch((err: any) => {
242
- orchestrator.pendingSubagentSpawns = 0;
243
- console.error(`[pi-pi] spawnPlanners failed: ${err.message}`);
505
+ const plannerSpawn = spawnPlanners(
506
+ pi,
507
+ orchestrator.cwd,
508
+ orchestrator.active.dir,
509
+ orchestrator.active.taskId,
510
+ orchestrator.config,
511
+ orchestrator.transitionController.phaseSend,
512
+ missingConfig,
513
+ orchestrator.active?.state.repos ?? [],
514
+ );
515
+ handleSpawnResult(orchestrator, plannerSpawn, {
516
+ kind: "planner",
517
+ logScope: "planner",
518
+ logMessage: "spawnPlanners failed",
244
519
  });
245
520
  } else {
246
521
  orchestrator.active.state.step = "synthesize";
@@ -251,35 +526,56 @@ export async function resumeTask(
251
526
 
252
527
  if (orchestrator.active.state.reviewCycle) {
253
528
  const cycle = orchestrator.active.state.reviewCycle;
254
- const reviewConfig = cycle.kind === "auto-deep" ? deepReviewConfig(orchestrator.config) : orchestrator.config;
255
529
  const phase = orchestrator.active.state.phase;
530
+ const requestedReviewPresetName = orchestrator.active.state.activeReviewPreset
531
+ ?? getDefaultReviewPresetName(orchestrator.config, phase);
532
+ const group = getReviewPresetGroup(phase);
533
+ const reviewPresetExists = Object.prototype.hasOwnProperty.call(orchestrator.config.agents.subagents.presetGroups[group].presets ?? {}, requestedReviewPresetName);
534
+ const presetName = reviewPresetExists
535
+ ? requestedReviewPresetName
536
+ : (Object.keys(orchestrator.config.agents.subagents.presetGroups[group].presets ?? {})[0] ?? requestedReviewPresetName);
537
+ if (orchestrator.active.state.activeReviewPreset !== presetName) {
538
+ orchestrator.active.state.activeReviewPreset = presetName;
539
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
540
+ }
541
+ if (!reviewPresetExists && presetName !== requestedReviewPresetName) {
542
+ ctx.ui.notify(
543
+ `Review preset "${requestedReviewPresetName}" not found. Falling back to "${presetName}".`,
544
+ "warning",
545
+ );
546
+ }
256
547
  const reviewers = phase === "brainstorm"
257
- ? reviewConfig.brainstormReviewers
548
+ ? resolvePreset(orchestrator.config, "brainstormReviewers", presetName)
258
549
  : phase === "plan"
259
- ? reviewConfig.planReviewers
260
- : reviewConfig.codeReviewers;
261
- const reviewerCount = Object.values(reviewers).filter((v) => v.enabled).length;
550
+ ? resolvePreset(orchestrator.config, "planReviewers", presetName)
551
+ : resolvePreset(orchestrator.config, "codeReviewers", presetName);
552
+ const reviewerCount = Object.values(reviewers).filter((v) => isEnabled(v)).length;
262
553
 
263
- if ((cycle.kind === "auto" || cycle.kind === "auto-deep") && (cycle.step === "spawn_reviewers" || cycle.step === "await_reviewers")) {
554
+ if (cycle.kind === "auto" && (cycle.step === "spawn_reviewers" || cycle.step === "await_reviewers")) {
264
555
  const outputs = loadPhaseReviewOutputs(orchestrator.active.dir, phase, cycle.pass);
265
- if (outputs.length >= reviewerCount) {
556
+ if (reviewerCount === 0) {
557
+ orchestrator.active.state.reviewCycle = null;
558
+ orchestrator.active.state.step = "llm_work";
559
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
560
+ orchestrator.safeSendUserMessage("[PI-PI] No reviewers configured — nothing to review. Continue working.");
561
+ } else if (outputs.length >= reviewerCount) {
266
562
  cycle.step = "apply_feedback";
267
563
  orchestrator.active.state.step = "apply_feedback";
268
564
  saveTask(orchestrator.active.dir, orchestrator.active.state);
269
565
  const rendered = outputs.map((o) => `=== ${o.name} ===\n${o.content}`).join("\n\n");
270
- pi.sendMessage(
566
+ orchestrator.transitionController.sendCustom(
271
567
  {
272
568
  customType: "pp-review-ready",
273
569
  content: `[PI-PI] Reviewer outputs are ready.\n\n${rendered}`,
274
570
  display: false,
275
571
  },
276
- { deliverAs: "steer" },
572
+ "context",
277
573
  );
278
574
  } else {
279
575
  const completedVariants = new Set(
280
576
  outputs.map((o) => o.name.replace(/^\d+_/, "").replace(/_round-\d+\.md$/, "").replace(/\.md$/, "")),
281
577
  );
282
- const enabledVariants = Object.entries(reviewers).filter(([, v]) => v.enabled);
578
+ const enabledVariants = Object.entries(reviewers).filter(([, v]) => isEnabled(v));
283
579
  const missingVariants = enabledVariants.filter(([name]) => !completedVariants.has(name));
284
580
 
285
581
  if (missingVariants.length === 0) {
@@ -287,39 +583,69 @@ export async function resumeTask(
287
583
  orchestrator.active.state.step = "apply_feedback";
288
584
  saveTask(orchestrator.active.dir, orchestrator.active.state);
289
585
  const rendered = outputs.map((o) => `=== ${o.name} ===\n${o.content}`).join("\n\n");
290
- pi.sendMessage(
586
+ orchestrator.transitionController.sendCustom(
291
587
  {
292
588
  customType: "pp-review-ready",
293
589
  content: `[PI-PI] Reviewer outputs are ready.\n\n${rendered}`,
294
590
  display: false,
295
591
  },
296
- { deliverAs: "steer" },
592
+ "context",
297
593
  );
298
594
  } else {
299
- const missingReviewerConfig: Record<string, any> = {};
595
+ const missingReviewerConfig: typeof reviewers = {};
300
596
  for (const [name, cfg] of missingVariants) missingReviewerConfig[name] = cfg;
301
- const partialConfig = phase === "brainstorm"
302
- ? { ...reviewConfig, brainstormReviewers: missingReviewerConfig }
303
- : phase === "plan"
304
- ? { ...reviewConfig, planReviewers: missingReviewerConfig }
305
- : { ...reviewConfig, codeReviewers: missingReviewerConfig };
597
+ orchestrator.active.state.activeReviewPreset = presetName;
598
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
306
599
  orchestrator.pendingSubagentSpawns = missingVariants.length;
307
600
  const spawnFn = phase === "brainstorm"
308
- ? () => spawnBrainstormReviewers(pi, orchestrator.cwd, orchestrator.active!.dir, orchestrator.active!.taskId, partialConfig, cycle.pass)
601
+ ? () => spawnBrainstormReviewers(
602
+ pi,
603
+ orchestrator.cwd,
604
+ orchestrator.active!.dir,
605
+ orchestrator.active!.taskId,
606
+ orchestrator.config,
607
+ cycle.pass,
608
+ orchestrator.transitionController.phaseSend,
609
+ missingReviewerConfig,
610
+ orchestrator.active?.state.repos ?? [],
611
+ )
309
612
  : phase === "plan"
310
- ? () => spawnPlanReviewers(pi, orchestrator.cwd, orchestrator.active!.dir, orchestrator.active!.taskId, partialConfig)
311
- : () => spawnCodeReviewers(pi, orchestrator.cwd, orchestrator.active!.dir, orchestrator.active!.taskId, partialConfig, cycle.pass);
613
+ ? () => spawnPlanReviewers(
614
+ pi,
615
+ orchestrator.cwd,
616
+ orchestrator.active!.dir,
617
+ orchestrator.active!.taskId,
618
+ orchestrator.config,
619
+ cycle.pass,
620
+ orchestrator.transitionController.phaseSend,
621
+ missingReviewerConfig,
622
+ orchestrator.active?.state.repos ?? [],
623
+ )
624
+ : () => spawnCodeReviewers(
625
+ pi,
626
+ orchestrator.cwd,
627
+ orchestrator.active!.dir,
628
+ orchestrator.active!.taskId,
629
+ orchestrator.config,
630
+ cycle.pass,
631
+ phase,
632
+ orchestrator.transitionController.phaseSend,
633
+ missingReviewerConfig,
634
+ orchestrator.active?.state.repos ?? [],
635
+ );
312
636
  orchestrator.failedReviewerVariants = [];
313
- spawnFn().then((result) => {
314
- orchestrator.failedReviewerVariants = result.failedVariants;
315
- if (result.spawned === 0) orchestrator.pendingSubagentSpawns = 0;
316
- for (const id of result.agentIds ?? []) {
317
- orchestrator.spawnedAgentIds.delete(id);
318
- }
319
- orchestrator.pendingSubagentSpawns = 0;
320
- }).catch((err: any) => {
321
- orchestrator.pendingSubagentSpawns = 0;
322
- console.error(`[pi-pi] spawn reviewers failed: ${err.message}`);
637
+ handleSpawnResult(orchestrator, spawnFn(), {
638
+ kind: "reviewer",
639
+ logScope: "review",
640
+ logMessage: "spawn reviewers failed",
641
+ onSettled: (result) => {
642
+ if (result?.spawned === 0 && orchestrator.active?.state.reviewCycle?.step === "await_reviewers") {
643
+ orchestrator.active.state.reviewCycle = null;
644
+ orchestrator.active.state.step = "llm_work";
645
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
646
+ orchestrator.safeSendUserMessage("[PI-PI] No reviewer outputs were produced — nothing to review. Continue working.");
647
+ }
648
+ },
323
649
  });
324
650
  cycle.step = "await_reviewers";
325
651
  orchestrator.active.state.step = "await_reviewers";
@@ -329,13 +655,13 @@ export async function resumeTask(
329
655
  } else if (cycle.step === "apply_feedback") {
330
656
  const outputs = loadPhaseReviewOutputs(orchestrator.active.dir, phase, cycle.pass);
331
657
  const rendered = outputs.map((o) => `=== ${o.name} ===\n${o.content}`).join("\n\n");
332
- pi.sendMessage(
658
+ orchestrator.transitionController.sendCustom(
333
659
  {
334
660
  customType: "pp-review-ready",
335
661
  content: `[PI-PI] Review cycle is in apply_feedback step.\n\n${rendered}`,
336
662
  display: false,
337
663
  },
338
- { deliverAs: "steer" },
664
+ "context",
339
665
  );
340
666
  }
341
667
  }
@@ -349,6 +675,12 @@ export async function resumeTask(
349
675
  orchestrator.safeSendUserMessage(`[PI-PI] Resumed ${orchestrator.active.state.phase} phase. Continue working.`);
350
676
  }
351
677
 
678
+ if (needsRepoRegistrationPrompt) {
679
+ orchestrator.safeSendUserMessage(
680
+ "[PI-PI] Register your repos using pp_register_repo (including the root) before continuing.",
681
+ );
682
+ }
683
+
352
684
  return { ok: true };
353
685
  }
354
686
 
@@ -356,10 +688,11 @@ function listCompletedFromTasks(cwd: string): TaskInfo[] {
356
688
  const paused = new Set<string>([
357
689
  ...listTasks(cwd, "brainstorm").map((t) => t.dir),
358
690
  ...listTasks(cwd, "debug").map((t) => t.dir),
691
+ ...listTasks(cwd, "review").map((t) => t.dir),
359
692
  ]);
360
693
  const results: TaskInfo[] = [];
361
694
 
362
- for (const type of ["brainstorm", "debug"] as TaskType[]) {
695
+ for (const type of ["brainstorm", "debug", "review"] as TaskType[]) {
363
696
  const typeDir = join(cwd, ".pp", "state", type);
364
697
  if (!existsSync(typeDir)) continue;
365
698
  for (const entry of readdirSync(typeDir, { withFileTypes: true })) {
@@ -395,34 +728,6 @@ async function showSubagentsMenu(ctx: any): Promise<void> {
395
728
  await api.showMenu(ctx);
396
729
  }
397
730
 
398
- async function showLspMenu(ctx: any): Promise<typeof BACK> {
399
- while (true) {
400
- const choice = await selectOption(ctx, "LSP", [
401
- { title: "Status", description: "Show detected language servers and their state" },
402
- { title: "Restart", description: "Stop all servers. They reinitialize on next use" },
403
- { title: "Back", description: "Return to the previous menu" },
404
- ]);
405
- if (!choice || choice === "Back") return BACK;
406
-
407
- const api = (globalThis as any)[Symbol.for("pi-lsp:api")] as {
408
- status?: (menuCtx: any) => Promise<void>;
409
- restart?: (menuCtx: any) => Promise<void>;
410
- } | undefined;
411
-
412
- if (!api?.status || !api?.restart) {
413
- ctx.ui.notify("LSP API is not available.", "warning");
414
- continue;
415
- }
416
-
417
- if (choice === "Status") {
418
- await api.status(ctx);
419
- continue;
420
- }
421
-
422
- await api.restart(ctx);
423
- }
424
- }
425
-
426
731
  function countFlantProviders(settings: FlantSettings): { anthropic: number; openai: number } {
427
732
  const models = settings.cachedFlantModels ?? [];
428
733
  const anthropic = models.filter((m) => m.startsWith("claude-")).length;
@@ -436,27 +741,36 @@ function collectRoleAssignments(config: Partial<PiPiConfig> | null): string[] {
436
741
  if (typeof value === "string" && value.length > 0) out.push(`${key} = ${value}`);
437
742
  };
438
743
 
439
- add("mainModel.implement", config.mainModel?.implement?.model);
440
- add("mainModel.debug", config.mainModel?.debug?.model);
441
- add("mainModel.brainstorm", config.mainModel?.brainstorm?.model);
442
- add("mainModel.review", config.mainModel?.review?.model);
744
+ add("agents.orchestrators.implement", config.agents?.orchestrators?.implement?.model);
745
+ add("agents.orchestrators.plan", config.agents?.orchestrators?.plan?.model);
746
+ add("agents.orchestrators.debug", config.agents?.orchestrators?.debug?.model);
747
+ add("agents.orchestrators.brainstorm", config.agents?.orchestrators?.brainstorm?.model);
748
+ add("agents.orchestrators.review", config.agents?.orchestrators?.review?.model);
749
+
750
+ const addPresetAssignments = (group: "planners" | "planReviewers" | "codeReviewers" | "brainstormReviewers") => {
751
+ const presets = config.agents?.subagents?.presetGroups?.[group]?.presets;
752
+ if (!presets || typeof presets !== "object") return;
753
+ for (const [presetName, preset] of Object.entries(presets)) {
754
+ if (!preset || typeof preset !== "object") continue;
755
+ const agents = (preset as any).agents;
756
+ if (!agents || typeof agents !== "object") continue;
757
+ if ((preset as any).enabled === false) continue;
758
+ for (const [name, variant] of Object.entries(agents as Record<string, any>)) {
759
+ if (variant?.enabled !== false) {
760
+ add(`agents.subagents.presetGroups.${group}.presets.${presetName}.agents.${name}`, variant.model);
761
+ }
762
+ }
763
+ }
764
+ };
443
765
 
444
- for (const [name, variant] of Object.entries(config.planners ?? {})) {
445
- if (variant.enabled) add(`planners.${name}`, variant.model);
446
- }
447
- for (const [name, variant] of Object.entries(config.planReviewers ?? {})) {
448
- if (variant.enabled) add(`planReviewers.${name}`, variant.model);
449
- }
450
- for (const [name, variant] of Object.entries(config.codeReviewers ?? {})) {
451
- if (variant.enabled) add(`codeReviewers.${name}`, variant.model);
452
- }
453
- for (const [name, variant] of Object.entries(config.brainstormReviewers ?? {})) {
454
- if (variant.enabled) add(`brainstormReviewers.${name}`, variant.model);
455
- }
766
+ addPresetAssignments("planners");
767
+ addPresetAssignments("planReviewers");
768
+ addPresetAssignments("codeReviewers");
769
+ addPresetAssignments("brainstormReviewers");
456
770
 
457
- add("agents.explore", config.agents?.explore?.model);
458
- add("agents.librarian", config.agents?.librarian?.model);
459
- add("agents.task", config.agents?.task?.model);
771
+ add("agents.subagents.simple.explore", config.agents?.subagents?.simple?.explore?.model);
772
+ add("agents.subagents.simple.librarian", config.agents?.subagents?.simple?.librarian?.model);
773
+ add("agents.subagents.simple.task", config.agents?.subagents?.simple?.task?.model);
460
774
  return out;
461
775
  }
462
776
 
@@ -574,7 +888,7 @@ function formatTokenCount(count: number): string {
574
888
  return `${Math.round(count / 1000000)}M`;
575
889
  }
576
890
 
577
- function formatDuration(ms: number): string {
891
+ function formatElapsedDuration(ms: number): string {
578
892
  const sec = Math.floor(ms / 1000);
579
893
  if (sec < 60) return `${sec}s`;
580
894
  const min = Math.floor(sec / 60);
@@ -594,8 +908,8 @@ function showUsage(ctx: any): void {
594
908
  getMainInputTokens(): number; getMainOutputTokens(): number;
595
909
  getMainCacheReadTokens(): number; getMainCacheWriteTokens(): number;
596
910
  getMainCost(): number;
597
- getPerModelUsage(): Record<string, { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; turns: number }>;
598
- getSubagentList(): Array<{ description: string; agentType: string; modelId: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; cost: number; durationMs: number; toolUses: number }>;
911
+ getPerModelUsage(): Record<string, { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; cacheSupported: boolean; turns: number }>;
912
+ getSubagentList(): Array<{ description: string; agentType: string; modelId: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; cacheSupported: boolean; cost: number; durationMs: number; toolUses: number }>;
599
913
  }
600
914
  | undefined;
601
915
 
@@ -619,7 +933,7 @@ function showUsage(ctx: any): void {
619
933
  const models = tracker.getPerModelUsage();
620
934
  const subagents = tracker.getSubagentList();
621
935
 
622
- const byModel = new Map<string, { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }>();
936
+ const byModel = new Map<string, { input: number; output: number; cacheRead: number; cacheWrite: number; cacheSupported: boolean; cost: number }>();
623
937
  const mainModelEntries = Object.entries(models);
624
938
  const mainTotalTokens = mainModelEntries.reduce((s, [, u]) => s + u.inputTokens + u.outputTokens, 0);
625
939
  for (const [modelId, usage] of mainModelEntries) {
@@ -627,7 +941,7 @@ function showUsage(ctx: any): void {
627
941
  const modelCostShare = mainTotalTokens > 0 ? mainCost * (modelTokens / mainTotalTokens) : 0;
628
942
  byModel.set(modelId, {
629
943
  input: usage.inputTokens, output: usage.outputTokens,
630
- cacheRead: usage.cacheReadTokens, cacheWrite: usage.cacheWriteTokens, cost: modelCostShare,
944
+ cacheRead: usage.cacheReadTokens, cacheWrite: usage.cacheWriteTokens, cacheSupported: usage.cacheSupported, cost: modelCostShare,
631
945
  });
632
946
  }
633
947
  for (const sa of subagents) {
@@ -638,11 +952,12 @@ function showUsage(ctx: any): void {
638
952
  existing.output += sa.outputTokens;
639
953
  existing.cacheRead += sa.cacheReadTokens;
640
954
  existing.cacheWrite += sa.cacheWriteTokens;
955
+ if (sa.cacheSupported) existing.cacheSupported = true;
641
956
  existing.cost += sa.cost;
642
957
  } else {
643
958
  byModel.set(key, {
644
959
  input: sa.inputTokens, output: sa.outputTokens,
645
- cacheRead: sa.cacheReadTokens, cacheWrite: sa.cacheWriteTokens, cost: sa.cost,
960
+ cacheRead: sa.cacheReadTokens, cacheWrite: sa.cacheWriteTokens, cacheSupported: sa.cacheSupported, cost: sa.cost,
646
961
  });
647
962
  }
648
963
  }
@@ -659,7 +974,7 @@ function showUsage(ctx: any): void {
659
974
  for (const [modelId, m] of byModel) {
660
975
  const cr = (m.cacheRead + m.input) > 0 ? Math.round(m.cacheRead / (m.cacheRead + m.input) * 100) : 0;
661
976
  const parts = [`↑${formatTokenCount(m.input)}`, `↓${formatTokenCount(m.output)}`];
662
- if (cr > 0) parts.push(`⚡${cr}%`);
977
+ if (m.cacheSupported) parts.push(`⚡${cr}%`);
663
978
  if (m.cost > 0) parts.push(`$${m.cost.toFixed(2)}`);
664
979
  lines.push(` ${modelId}: ${parts.join(" ")}`);
665
980
  }
@@ -669,13 +984,14 @@ function showUsage(ctx: any): void {
669
984
  lines.push("By agent:");
670
985
  const agentModelNames = Object.keys(models);
671
986
  if (agentModelNames.length > 0) {
987
+ const mainCacheSupported = mainModelEntries.some(([, u]) => u.cacheSupported);
672
988
  const mainParts = [`↑${formatTokenCount(mainInput)}`, `↓${formatTokenCount(mainOutput)}`];
673
989
  const mainCR = (mainCacheRead + mainInput) > 0 ? Math.round(mainCacheRead / (mainCacheRead + mainInput) * 100) : 0;
674
- if (mainCR > 0) mainParts.push(`⚡${mainCR}%`);
990
+ if (mainCacheSupported) mainParts.push(`⚡${mainCR}%`);
675
991
  if (mainCost > 0) mainParts.push(`$${mainCost.toFixed(2)}`);
676
992
  lines.push(` Main (${agentModelNames.join(", ")}): ${mainParts.join(" ")}`);
677
993
  }
678
- const byAgentType = new Map<string, { input: number; output: number; cacheRead: number; cost: number; durationMs: number; toolUses: number; count: number }>();
994
+ const byAgentType = new Map<string, { input: number; output: number; cacheRead: number; cacheSupported: boolean; cost: number; durationMs: number; toolUses: number; count: number }>();
679
995
  for (const sa of subagents) {
680
996
  const key = sa.agentType || sa.description;
681
997
  const existing = byAgentType.get(key);
@@ -683,6 +999,7 @@ function showUsage(ctx: any): void {
683
999
  existing.input += sa.inputTokens;
684
1000
  existing.output += sa.outputTokens;
685
1001
  existing.cacheRead += sa.cacheReadTokens;
1002
+ if (sa.cacheSupported) existing.cacheSupported = true;
686
1003
  existing.cost += sa.cost;
687
1004
  existing.durationMs += sa.durationMs;
688
1005
  existing.toolUses += sa.toolUses;
@@ -690,7 +1007,7 @@ function showUsage(ctx: any): void {
690
1007
  } else {
691
1008
  byAgentType.set(key, {
692
1009
  input: sa.inputTokens, output: sa.outputTokens, cacheRead: sa.cacheReadTokens,
693
- cost: sa.cost, durationMs: sa.durationMs, toolUses: sa.toolUses, count: 1,
1010
+ cacheSupported: sa.cacheSupported, cost: sa.cost, durationMs: sa.durationMs, toolUses: sa.toolUses, count: 1,
694
1011
  });
695
1012
  }
696
1013
  }
@@ -698,9 +1015,9 @@ function showUsage(ctx: any): void {
698
1015
  const saCR = (agg.cacheRead + agg.input) > 0
699
1016
  ? Math.round(agg.cacheRead / (agg.cacheRead + agg.input) * 100) : 0;
700
1017
  const parts = [`↑${formatTokenCount(agg.input)}`, `↓${formatTokenCount(agg.output)}`];
701
- if (saCR > 0) parts.push(`⚡${saCR}%`);
1018
+ if (agg.cacheSupported) parts.push(`⚡${saCR}%`);
702
1019
  if (agg.cost > 0) parts.push(`$${agg.cost.toFixed(2)}`);
703
- if (agg.durationMs > 0) parts.push(formatDuration(agg.durationMs));
1020
+ if (agg.durationMs > 0) parts.push(formatElapsedDuration(agg.durationMs));
704
1021
  if (agg.toolUses > 0) parts.push(`${agg.toolUses} tools`);
705
1022
  const countSuffix = agg.count > 1 ? ` (×${agg.count})` : "";
706
1023
  lines.push(` ${agentType}${countSuffix}: ${parts.join(" ")}`);
@@ -713,10 +1030,11 @@ async function showInfoMenu(orchestrator: Orchestrator, ctx: any): Promise<typeo
713
1030
  while (true) {
714
1031
  const options: OptionInput[] = [];
715
1032
  options.push({ title: "Subagents", description: "Manage running agents" });
716
- options.push({ title: "LSP", description: "Language server status and controls" });
717
1033
  options.push({ title: "Usage", description: "Show session token usage and cost breakdown" });
1034
+ options.push({ title: "Doctor", description: "Run diagnostic checks" });
718
1035
  if (orchestrator.active) {
719
1036
  options.push({ title: "Task status", description: "Show current task phase, step, and timing" });
1037
+ options.push({ title: "Repos", description: "Registered repositories and base branches" });
720
1038
  }
721
1039
  options.push({ title: "Back", description: "Return to the previous menu" });
722
1040
 
@@ -726,146 +1044,1812 @@ async function showInfoMenu(orchestrator: Orchestrator, ctx: any): Promise<typeo
726
1044
  await showSubagentsMenu(ctx);
727
1045
  continue;
728
1046
  }
729
- if (choice === "LSP") {
730
- await showLspMenu(ctx);
731
- continue;
732
- }
733
1047
  if (choice === "Usage") {
734
1048
  showUsage(ctx);
735
1049
  continue;
736
1050
  }
1051
+ if (choice === "Doctor") {
1052
+ await runDoctor(orchestrator, ctx);
1053
+ continue;
1054
+ }
737
1055
  if (choice === "Task status") {
738
1056
  showStatus(orchestrator, ctx);
739
1057
  continue;
740
1058
  }
1059
+ if (choice === "Repos") {
1060
+ await showReposSettings(orchestrator, ctx);
1061
+ continue;
1062
+ }
741
1063
  }
742
1064
  }
743
1065
 
744
- async function showSettingsMenu(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
745
- while (true) {
746
- const options: OptionInput[] = [];
747
- options.push({ title: "Flant AI Infrastructure", description: "Configure corporate AI model provider" });
748
- options.push({ title: "Back", description: "Return to the previous menu" });
1066
+ type Scope = "global" | "project";
1067
+ type MainModelRole = keyof PiPiConfig["agents"]["orchestrators"];
1068
+ type AgentRole = keyof PiPiConfig["agents"]["subagents"]["simple"];
1069
+ type TimeoutGroup = "commands" | "internals";
1070
+ type CommandListKey = keyof PiPiConfig["commands"];
1071
+ type TimeoutEntry = { key: TimeoutKey; path: string[]; value: number };
1072
+
1073
+ export interface ConfigSourceInfo {
1074
+ activeValue: any;
1075
+ defaultValue: any;
1076
+ flantValue: any | undefined;
1077
+ globalValue: any | undefined;
1078
+ projectValue: any | undefined;
1079
+ source: "default" | "flant" | "global" | "project";
1080
+ }
749
1081
 
750
- const choice = await selectOption(ctx, "Settings", options);
751
- if (!choice || choice === "Back") return BACK;
752
- await showFlantInfraMenu(orchestrator, ctx);
1082
+ const ORCHESTRATOR_ROLES: Array<{ role: MainModelRole; label: string; description: string }> = [
1083
+ { role: "brainstorm", label: "Brainstormer", description: "agents.orchestrators.brainstorm" },
1084
+ { role: "implement", label: "Implementer", description: "agents.orchestrators.implement" },
1085
+ { role: "plan", label: "Planner", description: "agents.orchestrators.plan" },
1086
+ { role: "debug", label: "Debugger", description: "agents.orchestrators.debug" },
1087
+ { role: "review", label: "Reviewer", description: "agents.orchestrators.review" },
1088
+ ];
1089
+
1090
+ const SUBAGENT_ROLES: Array<{ role: AgentRole; label: string; description: string }> = [
1091
+ { role: "explore", label: "Explore", description: "agents.subagents.simple.explore" },
1092
+ { role: "librarian", label: "Librarian", description: "agents.subagents.simple.librarian" },
1093
+ { role: "task", label: "Task", description: "agents.subagents.simple.task" },
1094
+ ];
1095
+
1096
+ const PRESET_GROUP_ITEMS: Array<{ group: PresetGroup; label: string }> = [
1097
+ { group: "brainstormReviewers", label: "Brainstorm reviewers" },
1098
+ { group: "planners", label: "Planners" },
1099
+ { group: "planReviewers", label: "Plan reviewers" },
1100
+ { group: "codeReviewers", label: "Code reviewers" },
1101
+ ];
1102
+
1103
+ const TIMEOUT_LABELS: Record<TimeoutKey, string> = {
1104
+ "performance.commands.afterEdit": "Command after file edit",
1105
+ "performance.commands.afterImplement": "Command after implementation",
1106
+ "performance.internals.subagentStale": "Subagent stale",
1107
+ "performance.internals.taskLockStale": "Lock stale",
1108
+ "performance.internals.taskLockRefresh": "Lock update",
1109
+ };
1110
+
1111
+ const VALID_NAME_RE = /^[A-Za-z0-9-]+$/;
1112
+
1113
+ function getProjectConfigPath(cwd: string): string {
1114
+ return join(cwd, ".pp", "config.json");
1115
+ }
1116
+
1117
+ function getScopeConfigPath(orchestrator: Orchestrator, scope: Scope): string {
1118
+ return scope === "global" ? GLOBAL_CONFIG_PATH : getProjectConfigPath(orchestrator.cwd);
1119
+ }
1120
+
1121
+ function hasNestedKey(obj: unknown, keyPath: string[]): boolean {
1122
+ let cursor: any = obj;
1123
+ for (const key of keyPath) {
1124
+ if (!cursor || typeof cursor !== "object") return false;
1125
+ if (!Object.prototype.hasOwnProperty.call(cursor, key)) return false;
1126
+ cursor = cursor[key];
753
1127
  }
1128
+ return true;
754
1129
  }
755
1130
 
756
- async function promptDescription(ctx: any, prompt: string, fallback: string): Promise<string | undefined> {
757
- const value = await ctx.ui.input(prompt);
758
- if (value === undefined || value === null) return undefined;
759
- const trimmed = String(value).trim();
760
- return trimmed || fallback;
1131
+ function getNestedValue(obj: unknown, keyPath: string[]): any {
1132
+ let cursor: any = obj;
1133
+ for (const key of keyPath) {
1134
+ if (!cursor || typeof cursor !== "object") return undefined;
1135
+ if (!Object.prototype.hasOwnProperty.call(cursor, key)) return undefined;
1136
+ cursor = cursor[key];
1137
+ }
1138
+ return cursor;
761
1139
  }
762
1140
 
763
- function resumeOptionTitle(t: TaskInfo): string {
764
- return taskName(t.dir);
1141
+ function setNestedValue(obj: Record<string, any>, keyPath: string[], value: any): void {
1142
+ if (keyPath.length === 0) return;
1143
+ let cursor: Record<string, any> = obj;
1144
+ for (let i = 0; i < keyPath.length - 1; i += 1) {
1145
+ const key = keyPath[i]!;
1146
+ const current = cursor[key];
1147
+ if (!current || typeof current !== "object" || Array.isArray(current)) cursor[key] = {};
1148
+ cursor = cursor[key] as Record<string, any>;
1149
+ }
1150
+ cursor[keyPath[keyPath.length - 1]!] = value;
765
1151
  }
766
1152
 
767
- function resumeOptionDescription(t: TaskInfo): string {
768
- const age = taskAge(t.state);
769
- const phase = t.state.phase === t.type ? t.type : `${t.type}/${t.state.phase}`;
770
- return `${phase}, ${age}`;
1153
+ function deleteNestedValue(obj: Record<string, any>, keyPath: string[]): void {
1154
+ if (keyPath.length === 0) return;
1155
+ let cursor: Record<string, any> = obj;
1156
+ for (let i = 0; i < keyPath.length - 1; i += 1) {
1157
+ const key = keyPath[i]!;
1158
+ const current = cursor[key];
1159
+ if (!current || typeof current !== "object" || Array.isArray(current)) return;
1160
+ cursor = current;
1161
+ }
1162
+ delete cursor[keyPath[keyPath.length - 1]!];
771
1163
  }
772
1164
 
773
- async function showResumeMenu(
774
- orchestrator: Orchestrator,
775
- ctx: any,
776
- type: TaskType | undefined,
777
- emptyMessage: string,
778
- ): Promise<typeof BACK | "started"> {
779
- while (true) {
780
- const tasks = listTasks(orchestrator.cwd, type);
781
- if (tasks.length === 0) {
782
- ctx.ui.notify(emptyMessage, "info");
783
- return BACK;
784
- }
1165
+ function formatInlineValue(value: any): string {
1166
+ if (typeof value === "string") return JSON.stringify(value);
1167
+ if (typeof value === "number" || typeof value === "boolean" || value === null) return String(value);
1168
+ if (value === undefined) return "undefined";
1169
+ try {
1170
+ return JSON.stringify(value);
1171
+ } catch {
1172
+ return String(value);
1173
+ }
1174
+ }
785
1175
 
786
- const options: OptionInput[] = tasks.map((t) => ({
787
- title: resumeOptionTitle(t),
788
- description: resumeOptionDescription(t),
789
- }));
790
- options.push({ title: "Back", description: "Return to the previous menu" });
1176
+ function tagsString(tags: string[]): string {
1177
+ return tags.length > 0 ? `(${tags.join(", ")})` : "";
1178
+ }
791
1179
 
792
- const choice = await selectOption(ctx, "Resume", options);
793
- if (!choice || choice === "Back") return BACK;
1180
+ function withTags(label: string, tags: string): string {
1181
+ return tags ? `${label} ${tags}` : label;
1182
+ }
794
1183
 
795
- const task = tasks.find((t) => resumeOptionTitle(t) === choice);
796
- if (!task) continue;
797
- const result = await resumeTask(orchestrator, ctx, task);
798
- if (result.ok) return "started";
799
- }
1184
+ function getRawScopeValue(orchestrator: Orchestrator, scope: Scope, keyPath: string[]): any {
1185
+ const raw = readRawConfig(getScopeConfigPath(orchestrator, scope));
1186
+ return getNestedValue(raw, keyPath);
800
1187
  }
801
1188
 
802
- function fromOptionTitle(t: TaskInfo): string {
803
- return taskName(t.dir);
1189
+ function getRawScopeConfigs(orchestrator: Orchestrator): { globalConfig: Record<string, any>; projectConfig: Record<string, any> } {
1190
+ return {
1191
+ globalConfig: readRawConfig(GLOBAL_CONFIG_PATH),
1192
+ projectConfig: readRawConfig(getProjectConfigPath(orchestrator.cwd)),
1193
+ };
804
1194
  }
805
1195
 
806
- function fromOptionDescription(t: TaskInfo, cwd: string): string {
807
- const age = taskAge(t.state);
808
- const rel = relative(join(cwd, ".pp", "state"), t.dir);
809
- return `${t.type}, ${age} — ${rel}`;
1196
+ function isEmptyOverride(value: unknown): boolean {
1197
+ return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value as object).length === 0;
810
1198
  }
811
1199
 
812
- async function showFromMenu(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK | "started"> {
813
- while (true) {
814
- const tasks = listCompletedFromTasks(orchestrator.cwd);
815
- if (tasks.length === 0) {
816
- ctx.ui.notify("No completed brainstorm/debug tasks with artifacts found.", "info");
817
- return BACK;
818
- }
1200
+ function hasLayerOverride(orchestrator: Orchestrator, scope: Scope, keyPath: string[]): boolean {
1201
+ const raw = readRawConfig(getScopeConfigPath(orchestrator, scope));
1202
+ if (!hasNestedKey(raw, keyPath)) return false;
1203
+ return !isEmptyOverride(getNestedValue(raw, keyPath));
1204
+ }
819
1205
 
820
- const options: OptionInput[] = tasks.map((t) => ({
821
- title: fromOptionTitle(t),
822
- description: fromOptionDescription(t, orchestrator.cwd),
823
- }));
824
- options.push({ title: "Back", description: "Return to the previous menu" });
1206
+ function getLayerOverrideValue(orchestrator: Orchestrator, scope: Scope, keyPath: string[]): any {
1207
+ const raw = readRawConfig(getScopeConfigPath(orchestrator, scope));
1208
+ return getNestedValue(raw, keyPath);
1209
+ }
825
1210
 
826
- const choice = await selectOption(ctx, "From", options);
827
- if (!choice || choice === "Back") return BACK;
1211
+ function getOwnedScopes(orchestrator: Orchestrator, keyPath: string[]): Scope[] {
1212
+ const out: Scope[] = [];
1213
+ if (hasLayerOverride(orchestrator, "global", keyPath)) out.push("global");
1214
+ if (hasLayerOverride(orchestrator, "project", keyPath)) out.push("project");
1215
+ return out;
1216
+ }
828
1217
 
829
- const selected = tasks.find((t) => fromOptionTitle(t) === choice);
830
- if (!selected) continue;
1218
+ export function getConfigSourceInfo(orchestrator: Orchestrator, keyPath: string[]): ConfigSourceInfo {
1219
+ const { globalConfig, projectConfig } = getRawScopeConfigs(orchestrator);
1220
+ const flantConfig = getFlantGeneratedConfig() as Record<string, any> | null;
1221
+ const activeValue = getNestedValue(orchestrator.config as Record<string, any>, keyPath);
1222
+ const defaultValue = getNestedValue(getDefaultConfig() as Record<string, any>, keyPath);
1223
+ const flantValue = flantConfig ? getNestedValue(flantConfig, keyPath) : undefined;
1224
+ const globalValue = hasNestedKey(globalConfig, keyPath) ? getNestedValue(globalConfig, keyPath) : undefined;
1225
+ const projectValue = hasNestedKey(projectConfig, keyPath) ? getNestedValue(projectConfig, keyPath) : undefined;
1226
+ const source = hasNestedKey(projectConfig, keyPath)
1227
+ ? "project"
1228
+ : hasNestedKey(globalConfig, keyPath)
1229
+ ? "global"
1230
+ : flantConfig && hasNestedKey(flantConfig, keyPath)
1231
+ ? "flant"
1232
+ : "default";
1233
+ return {
1234
+ activeValue,
1235
+ defaultValue,
1236
+ flantValue,
1237
+ globalValue,
1238
+ projectValue,
1239
+ source,
1240
+ };
1241
+ }
831
1242
 
832
- const description = await promptDescription(ctx, "Describe the task", "implement");
833
- if (!description) continue;
1243
+ export function formatSourceTags(currentValue: any, info: ConfigSourceInfo): string {
1244
+ const tags: string[] = [];
1245
+ if (isDeepStrictEqual(currentValue, info.activeValue)) tags.push("active");
1246
+ if (isDeepStrictEqual(currentValue, info.defaultValue)) tags.push("default");
1247
+ if (info.flantValue !== undefined && isDeepStrictEqual(currentValue, info.flantValue)) tags.push("flant");
1248
+ if (info.globalValue !== undefined && isDeepStrictEqual(currentValue, info.globalValue)) tags.push("global");
1249
+ if (info.projectValue !== undefined && isDeepStrictEqual(currentValue, info.projectValue)) tags.push("project");
1250
+ return tagsString(tags);
1251
+ }
834
1252
 
835
- await orchestrator.startTask(ctx, "implement", description, selected.dir, true);
836
- return "started";
1253
+ export function buildResetOptions(orchestrator: Orchestrator, keyPath: string[]): OptionInput[] {
1254
+ const options: OptionInput[] = [];
1255
+ const globalValue = getLayerOverrideValue(orchestrator, "global", keyPath);
1256
+ if (hasLayerOverride(orchestrator, "global", keyPath) && !isEmptyOverride(globalValue)) {
1257
+ options.push(opt("Reset global setting", formatInlineValue(globalValue)));
1258
+ }
1259
+ const projectValue = getLayerOverrideValue(orchestrator, "project", keyPath);
1260
+ if (hasLayerOverride(orchestrator, "project", keyPath) && !isEmptyOverride(projectValue)) {
1261
+ options.push(opt("Reset project setting", formatInlineValue(projectValue)));
837
1262
  }
1263
+ return options;
838
1264
  }
839
1265
 
840
- async function showImplementMenu(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK | "started"> {
841
- while (true) {
842
- const choice = await selectOption(ctx, "Implement", [
843
- { title: "New", description: "Start a new implementation from scratch" },
844
- { title: "From", description: "Continue from a completed brainstorm or debug task" },
845
- { title: "Resume", description: "Resume a paused implementation" },
846
- { title: "Back", description: "Return to the previous menu" },
847
- ]);
848
- if (!choice || choice === "Back") return BACK;
1266
+ export function formatDuration(ms: number): string {
1267
+ if (ms < 1000) return `${Math.round(ms)}ms`;
1268
+ if (ms < 60000) return `${Math.round(ms / 1000)}s`;
1269
+ if (ms < 3600000) return `${Math.round(ms / 60000)}m`;
1270
+ return `${Math.round(ms / 3600000)}h`;
1271
+ }
849
1272
 
850
- if (choice === "New") {
851
- const description = await promptDescription(ctx, "Describe the task", "implement");
852
- if (!description) continue;
853
- await orchestrator.startTask(ctx, "implement", description);
854
- return "started";
855
- }
1273
+ export function slugify(text: string, maxLen = 40): string {
1274
+ const collapsed = text.replace(/\s+/g, " ").trim();
1275
+ const safe = collapsed.replace(/[^A-Za-z0-9 _./:-]/g, "").trim();
1276
+ const base = safe || collapsed || "(empty)";
1277
+ if (base.length <= maxLen) return base;
1278
+ return `${base.slice(0, Math.max(1, maxLen - 1)).trimEnd()}…`;
1279
+ }
856
1280
 
857
- if (choice === "From") {
858
- const result = await showFromMenu(orchestrator, ctx);
859
- if (result === "started") return result;
860
- continue;
1281
+ function tryApplyConfigChange(orchestrator: Orchestrator, scope: Scope, keyPath: string[], value: any): { ok: boolean; error?: string } {
1282
+ try {
1283
+ const nextGlobal = structuredClone(readRawConfig(GLOBAL_CONFIG_PATH));
1284
+ const nextProject = structuredClone(readRawConfig(getProjectConfigPath(orchestrator.cwd)));
1285
+ const target = scope === "global" ? nextGlobal : nextProject;
1286
+ setNestedValue(target, keyPath, value);
1287
+ mergeConfigLayers(nextGlobal, nextProject);
1288
+ writeConfigValue(getScopeConfigPath(orchestrator, scope), keyPath, value);
1289
+ orchestrator.config = loadConfig(orchestrator.cwd);
1290
+ return { ok: true };
1291
+ } catch (err: any) {
1292
+ return { ok: false, error: err?.message ?? String(err) };
1293
+ }
1294
+ }
1295
+
1296
+ function tryClearConfigOverride(orchestrator: Orchestrator, scope: Scope, keyPath: string[]): { ok: boolean; error?: string } {
1297
+ try {
1298
+ const nextGlobal = structuredClone(readRawConfig(GLOBAL_CONFIG_PATH));
1299
+ const nextProject = structuredClone(readRawConfig(getProjectConfigPath(orchestrator.cwd)));
1300
+ const target = scope === "global" ? nextGlobal : nextProject;
1301
+ deleteNestedValue(target, keyPath);
1302
+ mergeConfigLayers(nextGlobal, nextProject);
1303
+ removeConfigValue(getScopeConfigPath(orchestrator, scope), keyPath);
1304
+ orchestrator.config = loadConfig(orchestrator.cwd);
1305
+ return { ok: true };
1306
+ } catch (err: any) {
1307
+ return { ok: false, error: err?.message ?? String(err) };
1308
+ }
1309
+ }
1310
+
1311
+ function parseCommaSeparated(input: string): string[] {
1312
+ return input
1313
+ .split(",")
1314
+ .map((item) => item.trim())
1315
+ .filter(Boolean);
1316
+ }
1317
+
1318
+ function makeUniqueTitle(base: string, used: Set<string>): string {
1319
+ if (!used.has(base)) {
1320
+ used.add(base);
1321
+ return base;
1322
+ }
1323
+ let index = 2;
1324
+ while (true) {
1325
+ const candidate = `${base} (${index})`;
1326
+ if (!used.has(candidate)) {
1327
+ used.add(candidate);
1328
+ return candidate;
861
1329
  }
1330
+ index += 1;
1331
+ }
1332
+ }
862
1333
 
863
- const result = await showResumeMenu(orchestrator, ctx, "implement", "No paused implement tasks found.");
864
- if (result === "started") return result;
1334
+ function normalizeProviderLabel(provider: string): string {
1335
+ if (provider === "anthropic") return "Anthropic";
1336
+ if (provider === "openai") return "OpenAI";
1337
+ if (provider === "google") return "Google";
1338
+ if (provider === "deepseek") return "DeepSeek";
1339
+ if (provider === "x-ai") return "xAI";
1340
+ if (provider === "qwen") return "Qwen";
1341
+ if (provider === "pp-flant-anthropic") return "Flant Anthropic";
1342
+ if (provider === "pp-flant-openai") return "Flant OpenAI";
1343
+ return provider;
1344
+ }
1345
+
1346
+ function providerOrder(provider: string): number {
1347
+ if (provider === "pp-flant-anthropic") return 0;
1348
+ if (provider === "pp-flant-openai") return 1;
1349
+ if (provider === "anthropic") return 2;
1350
+ if (provider === "openai") return 3;
1351
+ if (provider === "google") return 4;
1352
+ if (provider === "deepseek") return 5;
1353
+ if (provider === "x-ai") return 6;
1354
+ if (provider === "qwen") return 7;
1355
+ return 99;
1356
+ }
1357
+
1358
+ function listAvailableModels(ctx: any): Array<{ provider: string; id: string; spec: string }> {
1359
+ const available = ctx?.modelRegistry?.getAvailable?.();
1360
+ if (!Array.isArray(available)) return [];
1361
+
1362
+ const seen = new Set<string>();
1363
+ const models: Array<{ provider: string; id: string; spec: string }> = [];
1364
+ for (const model of available) {
1365
+ const provider = typeof model?.provider === "string" ? model.provider.trim() : "";
1366
+ const id = typeof model?.id === "string" ? model.id.trim() : "";
1367
+ if (!provider || !id) continue;
1368
+ const spec = `${provider}/${id}`;
1369
+ const key = spec.toLowerCase();
1370
+ if (seen.has(key)) continue;
1371
+ seen.add(key);
1372
+ models.push({ provider, id, spec });
865
1373
  }
1374
+
1375
+ models.sort((a, b) => {
1376
+ const byProviderOrder = providerOrder(a.provider) - providerOrder(b.provider);
1377
+ if (byProviderOrder !== 0) return byProviderOrder;
1378
+ const byProviderName = a.provider.localeCompare(b.provider);
1379
+ if (byProviderName !== 0) return byProviderName;
1380
+ return compareModelVersion(b.id, a.id);
1381
+ });
1382
+
1383
+ return models;
866
1384
  }
867
1385
 
868
- function buildPrContext(parsed: any): { prUrl: string | null; prContext: string | null } {
1386
+ async function pickScope(ctx: any, orchestrator: Orchestrator): Promise<Scope | null> {
1387
+ const choice = await selectOption(ctx, "Scope", [
1388
+ opt("Set globally", GLOBAL_CONFIG_PATH),
1389
+ opt("Set for project", getProjectConfigPath(orchestrator.cwd)),
1390
+ opt("Back", "Return to the previous menu"),
1391
+ ]);
1392
+ if (!choice || choice === "Back") return null;
1393
+ if (choice === "Set globally") return "global";
1394
+ if (choice === "Set for project") return "project";
1395
+ return null;
1396
+ }
1397
+
1398
+ async function pickScopeFromOwned(ctx: any, orchestrator: Orchestrator, keyPath: string[]): Promise<Scope | null> {
1399
+ const scopes = getOwnedScopes(orchestrator, keyPath);
1400
+ if (scopes.length === 0) return null;
1401
+ if (scopes.length === 1) return scopes[0]!;
1402
+ const options: OptionInput[] = [
1403
+ opt("Global override", GLOBAL_CONFIG_PATH),
1404
+ opt("Project override", getProjectConfigPath(orchestrator.cwd)),
1405
+ opt("Back", "Return to the previous menu"),
1406
+ ];
1407
+ const choice = await selectOption(ctx, "Choose override scope", options);
1408
+ if (!choice || choice === "Back") return null;
1409
+ return choice === "Global override" ? "global" : "project";
1410
+ }
1411
+
1412
+ async function pickModel(ctx: any, currentModel?: string): Promise<string | null> {
1413
+ const aliasMap = getAllAliases();
1414
+ const families = getModelFamilies();
1415
+ const availableModels = listAvailableModels(ctx);
1416
+ const availableSpecs = new Set(availableModels.map((m) => m.spec));
1417
+ const currentResolved = currentModel ? resolveModel(currentModel) : null;
1418
+ const currentAvailable = currentResolved ? availableSpecs.has(currentResolved) : false;
1419
+ const visibleAliases = new Set(
1420
+ Object.entries(aliasMap)
1421
+ .filter(([, resolved]) => availableSpecs.has(resolved))
1422
+ .map(([alias]) => alias),
1423
+ );
1424
+ const options: OptionInput[] = [];
1425
+ const selectionToModel = new Map<string, string>();
1426
+ const usedTitles = new Set<string>();
1427
+
1428
+ if (currentModel && !visibleAliases.has(currentModel)) {
1429
+ const tags = ["active"];
1430
+ if (!currentAvailable) tags.push("unavailable");
1431
+ const title = makeUniqueTitle(withTags(currentModel, tagsString(tags)), usedTitles);
1432
+ options.push(opt(title, "Current model"));
1433
+ selectionToModel.set(title, currentModel);
1434
+ }
1435
+
1436
+ const aliasEntries: Array<{ provider: string; displayName: string; alias: string }> = [];
1437
+ for (const family of families) {
1438
+ for (const alias of family.aliases) {
1439
+ if (!visibleAliases.has(alias)) continue;
1440
+ aliasEntries.push({
1441
+ provider: alias.split("/")[0] ?? "",
1442
+ displayName: family.displayName,
1443
+ alias,
1444
+ });
1445
+ }
1446
+ }
1447
+
1448
+ aliasEntries.sort((a, b) => {
1449
+ const byProviderOrder = providerOrder(a.provider) - providerOrder(b.provider);
1450
+ if (byProviderOrder !== 0) return byProviderOrder;
1451
+ const byProviderName = a.provider.localeCompare(b.provider);
1452
+ if (byProviderName !== 0) return byProviderName;
1453
+ return a.displayName.localeCompare(b.displayName);
1454
+ });
1455
+
1456
+ for (const entry of aliasEntries) {
1457
+ const providerLabel = normalizeProviderLabel(entry.provider);
1458
+ const tags = entry.alias === currentModel ? tagsString(["active"]) : "";
1459
+ const title = makeUniqueTitle(withTags(`${providerLabel} — ${entry.displayName} (latest)`, tags), usedTitles);
1460
+ options.push(opt(title, entry.alias));
1461
+ selectionToModel.set(title, entry.alias);
1462
+ }
1463
+
1464
+ if (availableModels.length > 0) {
1465
+ for (const model of availableModels) {
1466
+ if (currentModel && model.spec === currentModel) continue;
1467
+ const providerLabel = normalizeProviderLabel(model.provider);
1468
+ const title = makeUniqueTitle(`${providerLabel} — ${model.id}`, usedTitles);
1469
+ options.push(opt(title, model.spec));
1470
+ selectionToModel.set(title, model.spec);
1471
+ }
1472
+ }
1473
+
1474
+ options.push(opt("Back", "Return to the previous menu"));
1475
+
1476
+ while (true) {
1477
+ const choice = await selectOption(ctx, "Model", options);
1478
+ if (!choice || choice === "Back") return null;
1479
+ const selected = selectionToModel.get(choice);
1480
+ if (selected) return selected;
1481
+ }
1482
+ }
1483
+
1484
+ async function pickThinking(
1485
+ ctx: any,
1486
+ allowXhigh: boolean,
1487
+ orchestrator?: Orchestrator,
1488
+ keyPath?: string[],
1489
+ ): Promise<string | null> {
1490
+ const options: OptionInput[] = [];
1491
+ const byTitle = new Map<string, string>();
1492
+ const usedTitles = new Set<string>();
1493
+ const values = allowXhigh ? ["xhigh", "high", "medium", "low", "off"] : ["high", "medium", "low", "off"];
1494
+ const info = orchestrator && keyPath ? getConfigSourceInfo(orchestrator, keyPath) : null;
1495
+ for (const value of values) {
1496
+ const label = thinkingLabel(value);
1497
+ const title = makeUniqueTitle(withTags(label, info ? formatSourceTags(value, info) : ""), usedTitles);
1498
+ options.push(title);
1499
+ byTitle.set(title, value);
1500
+ }
1501
+ options.push(opt("Back", "Return to the previous menu"));
1502
+ const choice = await selectOption(ctx, "Thinking level", options);
1503
+ if (!choice || choice === "Back") return null;
1504
+ return byTitle.get(choice) ?? null;
1505
+ }
1506
+
1507
+ function refreshSubagentDefinitions(orchestrator: Orchestrator, keyPath: string[]): void {
1508
+ if (keyPath[0] !== "agents") return;
1509
+ unregisterAgentDefinitions(orchestrator.pi);
1510
+ orchestrator.registerAgents();
1511
+ }
1512
+
1513
+ function applyConfigChange(orchestrator: Orchestrator, scope: Scope, keyPath: string[], value: any): void {
1514
+ const result = tryApplyConfigChange(orchestrator, scope, keyPath, value);
1515
+ if (!result.ok) {
1516
+ orchestrator.lastCtx?.ui?.notify(`Config update rejected: ${result.error}`, "error");
1517
+ return;
1518
+ }
1519
+ const available = (orchestrator.lastCtx as any)?.modelRegistry?.getAvailable?.();
1520
+ if (Array.isArray(available)) {
1521
+ const modelIds = available
1522
+ .map((m: any) => {
1523
+ const provider = typeof m?.provider === "string" ? m.provider.trim() : "";
1524
+ const id = typeof m?.id === "string" ? m.id.trim() : "";
1525
+ return provider && id ? `${provider}/${id}` : "";
1526
+ })
1527
+ .filter((id: string) => id.length > 0);
1528
+ updateRegistryFromAvailableModels(modelIds);
1529
+ }
1530
+ refreshSubagentDefinitions(orchestrator, keyPath);
1531
+ }
1532
+
1533
+ function clearConfigOverride(orchestrator: Orchestrator, scope: Scope, keyPath: string[]): void {
1534
+ const result = tryClearConfigOverride(orchestrator, scope, keyPath);
1535
+ if (!result.ok) {
1536
+ orchestrator.lastCtx?.ui?.notify(`Config update rejected: ${result.error}`, "error");
1537
+ return;
1538
+ }
1539
+ refreshSubagentDefinitions(orchestrator, keyPath);
1540
+ }
1541
+
1542
+ function thinkingLabel(value: string): string {
1543
+ if (value === "off") return "Off";
1544
+ if (value === "low") return "Low";
1545
+ if (value === "medium") return "Medium";
1546
+ if (value === "high") return "High";
1547
+ if (value === "xhigh") return "Extra High";
1548
+ return value;
1549
+ }
1550
+
1551
+ function logLevelLabel(value: string): string {
1552
+ if (value === "debug") return "Debug";
1553
+ if (value === "info") return "Info";
1554
+ if (value === "warn") return "Warning";
1555
+ if (value === "error") return "Error";
1556
+ return value;
1557
+ }
1558
+
1559
+ function applyScopeChoice(orchestrator: Orchestrator, keyPath: string[], value: any, scope: Scope | null): void {
1560
+ if (!scope) return;
1561
+ try {
1562
+ const globalConfig = structuredClone(readRawConfig(GLOBAL_CONFIG_PATH));
1563
+ const projectConfig = structuredClone(readRawConfig(getProjectConfigPath(orchestrator.cwd)));
1564
+ const mergedWithoutScope = scope === "global"
1565
+ ? (() => {
1566
+ deleteNestedValue(globalConfig, keyPath);
1567
+ return mergeConfigLayers(globalConfig, null);
1568
+ })()
1569
+ : (() => {
1570
+ deleteNestedValue(projectConfig, keyPath);
1571
+ return mergeConfigLayers(globalConfig, projectConfig);
1572
+ })();
1573
+ const defaultForScope = getNestedValue(mergedWithoutScope, keyPath);
1574
+ if (isDeepStrictEqual(value, defaultForScope)) {
1575
+ if (scope === "global") {
1576
+ clearConfigOverride(orchestrator, "global", keyPath);
1577
+ return;
1578
+ }
1579
+ if (!hasLayerOverride(orchestrator, "global", keyPath)) {
1580
+ clearConfigOverride(orchestrator, "project", keyPath);
1581
+ return;
1582
+ }
1583
+ }
1584
+ } catch {}
1585
+ applyConfigChange(orchestrator, scope, keyPath, value);
1586
+ }
1587
+
1588
+ function enabledPresetSummary(variants: Record<string, VariantConfig>): string {
1589
+ const enabled = Object.entries(variants)
1590
+ .filter(([, variant]) => isEnabled(variant))
1591
+ .map(([name, variant]) => {
1592
+ const info = getModelInfo(variant.model);
1593
+ return `${name}: ${info.vendor}/${info.family} (${variant.thinking})`;
1594
+ });
1595
+ return enabled.length > 0 ? enabled.join(", ") : "No enabled models";
1596
+ }
1597
+
1598
+ async function promptRequiredInput(ctx: any, label: string): Promise<string | null> {
1599
+ const value = await ctx.ui.input(label);
1600
+ if (value === undefined || value === null) return null;
1601
+ const trimmed = String(value).trim();
1602
+ if (!trimmed) return null;
1603
+ return trimmed;
1604
+ }
1605
+
1606
+ async function promptSafeName(ctx: any, label: string): Promise<string | null> {
1607
+ const value = await promptRequiredInput(ctx, label);
1608
+ if (!value) return null;
1609
+ if (!VALID_NAME_RE.test(value)) {
1610
+ ctx.ui.notify("Use only letters, numbers, and '-'.", "warning");
1611
+ return null;
1612
+ }
1613
+ return value;
1614
+ }
1615
+
1616
+ async function maybeHandleResetChoice(orchestrator: Orchestrator, ctx: any, choice: string, keyPath: string[]): Promise<boolean> {
1617
+ if (choice === "Reset global setting") {
1618
+ const confirm = await selectOption(ctx, "Confirm reset?", [
1619
+ opt("Yes, reset", `Reset global override ${formatInlineValue(getLayerOverrideValue(orchestrator, "global", keyPath))}`),
1620
+ opt("Back", "Cancel"),
1621
+ ]);
1622
+ if (confirm !== "Yes, reset") return true;
1623
+ clearConfigOverride(orchestrator, "global", keyPath);
1624
+ return true;
1625
+ }
1626
+ if (choice === "Reset project setting") {
1627
+ const confirm = await selectOption(ctx, "Confirm reset?", [
1628
+ opt("Yes, reset", `Reset project override ${formatInlineValue(getLayerOverrideValue(orchestrator, "project", keyPath))}`),
1629
+ opt("Back", "Cancel"),
1630
+ ]);
1631
+ if (confirm !== "Yes, reset") return true;
1632
+ clearConfigOverride(orchestrator, "project", keyPath);
1633
+ return true;
1634
+ }
1635
+ return false;
1636
+ }
1637
+
1638
+ async function showOrchestratorEditor(
1639
+ orchestrator: Orchestrator,
1640
+ ctx: any,
1641
+ role: MainModelRole,
1642
+ label: string,
1643
+ ): Promise<typeof BACK> {
1644
+ while (true) {
1645
+ const current = orchestrator.config.agents.orchestrators[role];
1646
+ const basePath = ["agents", "orchestrators", role];
1647
+ const choice = await selectOption(ctx, label, [
1648
+ opt(`Model: ${current.model}`, "Select model"),
1649
+ opt(`Thinking: ${thinkingLabel(current.thinking)}`, "Select thinking level"),
1650
+ ...buildResetOptions(orchestrator, basePath),
1651
+ opt("Back", "Return to the previous menu"),
1652
+ ]);
1653
+ if (!choice || choice === "Back") return BACK;
1654
+ if (choice.startsWith("Model:")) {
1655
+ const model = await pickModel(ctx, current.model);
1656
+ if (!model) continue;
1657
+ applyScopeChoice(orchestrator, [...basePath, "model"], model, await pickScope(ctx, orchestrator));
1658
+ continue;
1659
+ }
1660
+ if (choice.startsWith("Thinking:")) {
1661
+ const thinking = await pickThinking(ctx, false, orchestrator, [...basePath, "thinking"]);
1662
+ if (!thinking) continue;
1663
+ applyScopeChoice(orchestrator, [...basePath, "thinking"], thinking, await pickScope(ctx, orchestrator));
1664
+ continue;
1665
+ }
1666
+ await maybeHandleResetChoice(orchestrator, ctx, choice, basePath);
1667
+ }
1668
+ }
1669
+
1670
+ async function showOrchestratorsSettings(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
1671
+ while (true) {
1672
+ const options: OptionInput[] = ORCHESTRATOR_ROLES.map(({ role, label, description }) => {
1673
+ const current = orchestrator.config.agents.orchestrators[role];
1674
+ return opt(label, `${current.model} / ${thinkingLabel(current.thinking)} — ${description}`);
1675
+ });
1676
+ options.push(opt("Back", "Return to the previous menu"));
1677
+ const choice = await selectOption(ctx, "Orchestrators", options);
1678
+ if (!choice || choice === "Back") return BACK;
1679
+ const picked = ORCHESTRATOR_ROLES.find((item) => item.label === choice);
1680
+ if (!picked) continue;
1681
+ await showOrchestratorEditor(orchestrator, ctx, picked.role, picked.label);
1682
+ }
1683
+ }
1684
+
1685
+ async function showSimpleSubagentEditor(
1686
+ orchestrator: Orchestrator,
1687
+ ctx: any,
1688
+ role: AgentRole,
1689
+ label: string,
1690
+ ): Promise<typeof BACK> {
1691
+ while (true) {
1692
+ const current = orchestrator.config.agents.subagents.simple[role];
1693
+ const basePath = ["agents", "subagents", "simple", role];
1694
+ const choice = await selectOption(ctx, label, [
1695
+ opt(`Model: ${current.model}`, "Select model"),
1696
+ opt(`Thinking: ${thinkingLabel(current.thinking)}`, "Select thinking level"),
1697
+ ...buildResetOptions(orchestrator, basePath),
1698
+ opt("Back", "Return to the previous menu"),
1699
+ ]);
1700
+ if (!choice || choice === "Back") return BACK;
1701
+ if (choice.startsWith("Model:")) {
1702
+ const model = await pickModel(ctx, current.model);
1703
+ if (!model) continue;
1704
+ applyScopeChoice(orchestrator, [...basePath, "model"], model, await pickScope(ctx, orchestrator));
1705
+ continue;
1706
+ }
1707
+ if (choice.startsWith("Thinking:")) {
1708
+ const thinking = await pickThinking(ctx, true, orchestrator, [...basePath, "thinking"]);
1709
+ if (!thinking) continue;
1710
+ applyScopeChoice(orchestrator, [...basePath, "thinking"], thinking, await pickScope(ctx, orchestrator));
1711
+ continue;
1712
+ }
1713
+ await maybeHandleResetChoice(orchestrator, ctx, choice, basePath);
1714
+ }
1715
+ }
1716
+
1717
+ async function addPresetVariant(
1718
+ orchestrator: Orchestrator,
1719
+ ctx: any,
1720
+ group: PresetGroup,
1721
+ presetName: string,
1722
+ ): Promise<void> {
1723
+ const variantName = await promptSafeName(ctx, "Agent name");
1724
+ if (!variantName) return;
1725
+ if (orchestrator.config.agents.subagents.presetGroups[group].presets?.[presetName]?.agents?.[variantName]) {
1726
+ ctx.ui.notify(`Agent '${variantName}' already exists.`, "warning");
1727
+ return;
1728
+ }
1729
+ const model = await pickModel(ctx);
1730
+ if (!model) return;
1731
+ const thinking = await pickThinking(ctx, true);
1732
+ if (!thinking) return;
1733
+ const scope = await pickScope(ctx, orchestrator);
1734
+ if (!scope) return;
1735
+ applyConfigChange(orchestrator, scope, ["agents", "subagents", "presetGroups", group, "presets", presetName, "agents", variantName], {
1736
+ enabled: true,
1737
+ model,
1738
+ thinking,
1739
+ });
1740
+ }
1741
+
1742
+ async function removePresetVariant(
1743
+ orchestrator: Orchestrator,
1744
+ ctx: any,
1745
+ group: PresetGroup,
1746
+ presetName: string,
1747
+ variantName: string,
1748
+ ): Promise<void> {
1749
+ const variantPath = ["agents", "subagents", "presetGroups", group, "presets", presetName, "agents", variantName];
1750
+ const presetAgentsPath = ["agents", "subagents", "presetGroups", group, "presets", presetName, "agents"];
1751
+ const scopes = getOwnedScopes(orchestrator, variantPath);
1752
+ if (scopes.length === 0) return;
1753
+ const scope = scopes.length === 1 ? scopes[0]! : await pickScopeFromOwned(ctx, orchestrator, variantPath);
1754
+ if (!scope) return;
1755
+ const rawPresetValue = getRawScopeValue(orchestrator, scope, presetAgentsPath);
1756
+ if (!rawPresetValue || typeof rawPresetValue !== "object" || Array.isArray(rawPresetValue)) {
1757
+ ctx.ui.notify("No override in selected scope.", "info");
1758
+ return;
1759
+ }
1760
+ const rawPreset = rawPresetValue as Record<string, VariantConfig>;
1761
+ if (!Object.prototype.hasOwnProperty.call(rawPreset, variantName)) {
1762
+ ctx.ui.notify("Agent is inherited and cannot be removed in selected scope.", "info");
1763
+ return;
1764
+ }
1765
+ if (Object.keys(rawPreset).length <= 1) {
1766
+ try {
1767
+ const nextGlobal = structuredClone(readRawConfig(GLOBAL_CONFIG_PATH));
1768
+ const nextProject = structuredClone(readRawConfig(getProjectConfigPath(orchestrator.cwd)));
1769
+ const target = scope === "global" ? nextGlobal : nextProject;
1770
+ deleteNestedValue(target, presetAgentsPath);
1771
+ mergeConfigLayers(nextGlobal, nextProject);
1772
+ } catch {
1773
+ ctx.ui.notify("Cannot delete the last agent in this preset.", "warning");
1774
+ return;
1775
+ }
1776
+ clearConfigOverride(orchestrator, scope, presetAgentsPath);
1777
+ return;
1778
+ }
1779
+ const nextPreset = structuredClone(rawPreset);
1780
+ delete nextPreset[variantName];
1781
+ applyConfigChange(orchestrator, scope, presetAgentsPath, nextPreset);
1782
+ }
1783
+
1784
+ async function showPresetVariantEditor(
1785
+ orchestrator: Orchestrator,
1786
+ ctx: any,
1787
+ group: PresetGroup,
1788
+ presetName: string,
1789
+ variantName: string,
1790
+ ): Promise<typeof BACK> {
1791
+ while (true) {
1792
+ const variant = orchestrator.config.agents.subagents.presetGroups[group].presets?.[presetName]?.agents?.[variantName];
1793
+ if (!variant) return BACK;
1794
+ const variantPath = ["agents", "subagents", "presetGroups", group, "presets", presetName, "agents", variantName];
1795
+ const options: OptionInput[] = [
1796
+ opt(`Enabled: ${isEnabled(variant) ? "Yes" : "No"}`, "Toggle enabled state"),
1797
+ opt(`Model: ${variant.model}`, "Select model"),
1798
+ opt(`Thinking: ${thinkingLabel(variant.thinking)}`, "Select thinking level"),
1799
+ ];
1800
+ if (getOwnedScopes(orchestrator, variantPath).length > 0) {
1801
+ options.push(opt("Delete", "Delete this agent override"));
1802
+ }
1803
+ options.push(opt("Back", "Return to the previous menu"));
1804
+ const choice = await selectOption(ctx, `Agent "${variantName}"`, options);
1805
+ if (!choice || choice === "Back") return BACK;
1806
+ if (choice.startsWith("Model:")) {
1807
+ const model = await pickModel(ctx, variant.model);
1808
+ if (!model) continue;
1809
+ applyScopeChoice(orchestrator, [...variantPath, "model"], model, await pickScope(ctx, orchestrator));
1810
+ continue;
1811
+ }
1812
+ if (choice.startsWith("Thinking:")) {
1813
+ const thinking = await pickThinking(ctx, true, orchestrator, [...variantPath, "thinking"]);
1814
+ if (!thinking) continue;
1815
+ applyScopeChoice(orchestrator, [...variantPath, "thinking"], thinking, await pickScope(ctx, orchestrator));
1816
+ continue;
1817
+ }
1818
+ if (choice.startsWith("Enabled:")) {
1819
+ await showBooleanSetting(orchestrator, ctx, "Enabled", [...variantPath, "enabled"]);
1820
+ continue;
1821
+ }
1822
+ const confirm = await selectOption(ctx, "Confirm delete?", [
1823
+ opt("Yes, delete", "This cannot be undone"),
1824
+ opt("Back", "Cancel"),
1825
+ ]);
1826
+ if (confirm !== "Yes, delete") continue;
1827
+ await removePresetVariant(orchestrator, ctx, group, presetName, variantName);
1828
+ return BACK;
1829
+ }
1830
+ }
1831
+
1832
+ async function showPresetAgentsMenu(
1833
+ orchestrator: Orchestrator,
1834
+ ctx: any,
1835
+ group: PresetGroup,
1836
+ presetName: string,
1837
+ ): Promise<typeof BACK> {
1838
+ while (true) {
1839
+ const preset = orchestrator.config.agents.subagents.presetGroups[group].presets?.[presetName];
1840
+ if (!preset) return BACK;
1841
+ const agents = preset.agents ?? {};
1842
+ const options: OptionInput[] = [];
1843
+ const byTitle = new Map<string, string>();
1844
+ for (const [variantName, variant] of Object.entries(agents)) {
1845
+ const tag = isEnabled(variant) ? "" : " (disabled)";
1846
+ const title = `Agent "${variantName}"${tag}`;
1847
+ options.push(opt(title, `${variant.model} / ${thinkingLabel(variant.thinking)}`));
1848
+ byTitle.set(title, variantName);
1849
+ }
1850
+ options.push(opt("New agent", "Add a new agent"));
1851
+ options.push(opt("Back", "Return to the previous menu"));
1852
+ const choice = await selectOption(ctx, "Agents", options);
1853
+ if (!choice || choice === "Back") return BACK;
1854
+ if (choice === "New agent") {
1855
+ await addPresetVariant(orchestrator, ctx, group, presetName);
1856
+ continue;
1857
+ }
1858
+ const variantName = byTitle.get(choice);
1859
+ if (!variantName) continue;
1860
+ await showPresetVariantEditor(orchestrator, ctx, group, presetName, variantName);
1861
+ }
1862
+ }
1863
+
1864
+ function deletePresetFromScope(orchestrator: Orchestrator, scope: Scope, group: PresetGroup, presetName: string): void {
1865
+ const groupPath = ["agents", "subagents", "presetGroups", group, "presets"];
1866
+ const rawGroup = getRawScopeValue(orchestrator, scope, groupPath);
1867
+ if (!rawGroup || typeof rawGroup !== "object" || Array.isArray(rawGroup)) return;
1868
+ const nextGroup = structuredClone(rawGroup as Record<string, { enabled?: boolean; agents: Record<string, VariantConfig> }>);
1869
+ delete nextGroup[presetName];
1870
+ if (Object.keys(nextGroup).length === 0) {
1871
+ clearConfigOverride(orchestrator, scope, groupPath);
1872
+ } else {
1873
+ applyConfigChange(orchestrator, scope, groupPath, nextGroup);
1874
+ }
1875
+ }
1876
+
1877
+ function isPresetDisabled(orchestrator: Orchestrator, group: PresetGroup, presetName: string): boolean {
1878
+ const preset = orchestrator.config.agents.subagents.presetGroups[group].presets[presetName];
1879
+ return preset?.enabled === false;
1880
+ }
1881
+
1882
+ function setPresetDisabled(orchestrator: Orchestrator, group: PresetGroup, presetName: string, disabled: boolean, scope: Scope): void {
1883
+ applyConfigChange(
1884
+ orchestrator,
1885
+ scope,
1886
+ ["agents", "subagents", "presetGroups", group, "presets", presetName, "enabled"],
1887
+ !disabled,
1888
+ );
1889
+ }
1890
+
1891
+ async function showPresetEditor(
1892
+ orchestrator: Orchestrator,
1893
+ ctx: any,
1894
+ group: PresetGroup,
1895
+ presetName: string,
1896
+ ): Promise<typeof BACK> {
1897
+ while (true) {
1898
+ const preset = orchestrator.config.agents.subagents.presetGroups[group].presets?.[presetName];
1899
+ if (!preset) return BACK;
1900
+ const isDefault = orchestrator.config.agents.subagents.presetGroups[group].default === presetName;
1901
+ const disabled = isPresetDisabled(orchestrator, group, presetName);
1902
+ const presetPath = ["agents", "subagents", "presetGroups", group, "presets", presetName];
1903
+ const defaultPath = ["agents", "subagents", "presetGroups", group, "default"];
1904
+ const options: OptionInput[] = [
1905
+ opt(`Enabled: ${disabled ? "No" : "Yes"}`, "Enable or disable this preset"),
1906
+ opt("Agents", `${Object.keys(preset.agents ?? {}).length} agents`),
1907
+ ];
1908
+ if (!isDefault) {
1909
+ options.push(opt("Use as default", "Set as default preset"));
1910
+ }
1911
+ options.push(...buildResetOptions(orchestrator, presetPath));
1912
+ if (getOwnedScopes(orchestrator, presetPath).length > 0) {
1913
+ options.push(opt("Delete", "Delete this preset override"));
1914
+ }
1915
+ options.push(opt("Back", "Return to the previous menu"));
1916
+ const choice = await selectOption(ctx, `Preset "${presetName}"${isDefault ? " (default)" : ""}`, options);
1917
+ if (!choice || choice === "Back") return BACK;
1918
+ if (choice.startsWith("Enabled:")) {
1919
+ await showPresetEnabledSetting(orchestrator, ctx, group, presetName);
1920
+ continue;
1921
+ }
1922
+ if (choice === "Use as default") {
1923
+ applyScopeChoice(orchestrator, defaultPath, presetName, await pickScope(ctx, orchestrator));
1924
+ continue;
1925
+ }
1926
+ if (choice === "Agents") {
1927
+ await showPresetAgentsMenu(orchestrator, ctx, group, presetName);
1928
+ continue;
1929
+ }
1930
+ if (choice === "Delete") {
1931
+ if (isDefault) {
1932
+ ctx.ui.notify("Cannot delete the default preset. Change the default first.", "warning");
1933
+ continue;
1934
+ }
1935
+ const confirm = await selectOption(ctx, "Confirm delete?", [
1936
+ opt("Yes, delete", "This cannot be undone"),
1937
+ opt("Back", "Cancel"),
1938
+ ]);
1939
+ if (confirm !== "Yes, delete") continue;
1940
+ const scope = await pickScopeFromOwned(ctx, orchestrator, presetPath);
1941
+ if (!scope) continue;
1942
+ deletePresetFromScope(orchestrator, scope, group, presetName);
1943
+ return BACK;
1944
+ }
1945
+ await maybeHandleResetChoice(orchestrator, ctx, choice, presetPath);
1946
+ }
1947
+ }
1948
+
1949
+ async function showPresetEnabledSetting(
1950
+ orchestrator: Orchestrator,
1951
+ ctx: any,
1952
+ group: PresetGroup,
1953
+ presetName: string,
1954
+ ): Promise<void> {
1955
+ const enabledPath = ["agents", "subagents", "presetGroups", group, "presets", presetName, "enabled"];
1956
+ const info = getConfigSourceInfo(orchestrator, enabledPath);
1957
+ const yesTitle = withTags("Yes", formatSourceTags(true, info));
1958
+ const noTitle = withTags("No", formatSourceTags(false, info));
1959
+ const choice = await selectOption(ctx, "Enabled", [
1960
+ yesTitle,
1961
+ noTitle,
1962
+ opt("Back", "Return to the previous menu"),
1963
+ ]);
1964
+ if (!choice || choice === "Back") return;
1965
+ if (choice === yesTitle) {
1966
+ const scope = await pickScope(ctx, orchestrator);
1967
+ if (!scope) return;
1968
+ setPresetDisabled(orchestrator, group, presetName, false, scope);
1969
+ } else if (choice === noTitle) {
1970
+ const scope = await pickScope(ctx, orchestrator);
1971
+ if (!scope) return;
1972
+ setPresetDisabled(orchestrator, group, presetName, true, scope);
1973
+ }
1974
+ }
1975
+
1976
+ async function addNewPreset(orchestrator: Orchestrator, ctx: any, group: PresetGroup): Promise<void> {
1977
+ const name = await promptSafeName(ctx, "Preset name");
1978
+ if (!name) return;
1979
+ if (orchestrator.config.agents.subagents.presetGroups[group].presets?.[name]) {
1980
+ ctx.ui.notify(`Preset '${name}' already exists.`, "warning");
1981
+ return;
1982
+ }
1983
+ const variantName = await promptSafeName(ctx, "Initial agent name");
1984
+ if (!variantName) return;
1985
+ const model = await pickModel(ctx);
1986
+ if (!model) return;
1987
+ const thinking = await pickThinking(ctx, true);
1988
+ if (!thinking) return;
1989
+ const scope = await pickScope(ctx, orchestrator);
1990
+ if (!scope) return;
1991
+ applyConfigChange(orchestrator, scope, ["agents", "subagents", "presetGroups", group, "presets", name], {
1992
+ enabled: true,
1993
+ agents: {
1994
+ [variantName]: { enabled: true, model, thinking },
1995
+ },
1996
+ });
1997
+ }
1998
+
1999
+ async function showPresetSettings(
2000
+ orchestrator: Orchestrator,
2001
+ ctx: any,
2002
+ group: PresetGroup,
2003
+ title: string,
2004
+ ): Promise<typeof BACK> {
2005
+ while (true) {
2006
+ const presets = orchestrator.config.agents.subagents.presetGroups[group].presets ?? {};
2007
+ const defaultName = orchestrator.config.agents.subagents.presetGroups[group].default;
2008
+ const options: OptionInput[] = [];
2009
+ const byTitle = new Map<string, string>();
2010
+ for (const [presetName, preset] of Object.entries(presets)) {
2011
+ const tags: string[] = [];
2012
+ if (presetName === defaultName) tags.push("default");
2013
+ if (isPresetDisabled(orchestrator, group, presetName)) tags.push("disabled");
2014
+ const tagStr = tags.length > 0 ? ` (${tags.join(", ")})` : "";
2015
+ const optionTitle = `Preset "${presetName}"${tagStr}`;
2016
+ options.push(opt(optionTitle, enabledPresetSummary(preset.agents)));
2017
+ byTitle.set(optionTitle, presetName);
2018
+ }
2019
+ options.push(opt("New preset", "Create preset"));
2020
+ options.push(opt("Back", "Return to the previous menu"));
2021
+ const choice = await selectOption(ctx, title, options);
2022
+ if (!choice || choice === "Back") return BACK;
2023
+ if (choice === "New preset") {
2024
+ await addNewPreset(orchestrator, ctx, group);
2025
+ continue;
2026
+ }
2027
+ const presetName = byTitle.get(choice);
2028
+ if (!presetName) continue;
2029
+ await showPresetEditor(orchestrator, ctx, group, presetName);
2030
+ }
2031
+ }
2032
+
2033
+ async function showSubagentSettings(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2034
+ while (true) {
2035
+ const options: OptionInput[] = SUBAGENT_ROLES.map(({ role, label, description }) => {
2036
+ const current = orchestrator.config.agents.subagents.simple[role];
2037
+ return opt(label, `${current.model} / ${thinkingLabel(current.thinking)} — ${description}`);
2038
+ });
2039
+ for (const item of PRESET_GROUP_ITEMS) {
2040
+ options.push(opt(item.label, `${Object.keys(orchestrator.config.agents.subagents.presetGroups[item.group].presets ?? {}).length} presets`));
2041
+ }
2042
+ options.push(opt("Back", "Return to the previous menu"));
2043
+ const choice = await selectOption(ctx, "Subagents", options);
2044
+ if (!choice || choice === "Back") return BACK;
2045
+ const simple = SUBAGENT_ROLES.find((item) => item.label === choice);
2046
+ if (simple) {
2047
+ await showSimpleSubagentEditor(orchestrator, ctx, simple.role, simple.label);
2048
+ continue;
2049
+ }
2050
+ const group = PRESET_GROUP_ITEMS.find((item) => item.label === choice);
2051
+ if (!group) continue;
2052
+ await showPresetSettings(orchestrator, ctx, group.group, group.label);
2053
+ }
2054
+ }
2055
+
2056
+ function getCommandScope(orchestrator: Orchestrator, keyPath: string[]): Scope {
2057
+ const info = getConfigSourceInfo(orchestrator, keyPath);
2058
+ if (info.source === "project") return "project";
2059
+ if (info.source === "global") return "global";
2060
+ return "project";
2061
+ }
2062
+
2063
+ function ensureUniqueCommandId(existing: Record<string, unknown>, base: string): string {
2064
+ if (!Object.prototype.hasOwnProperty.call(existing, base)) return base;
2065
+ let i = 2;
2066
+ while (Object.prototype.hasOwnProperty.call(existing, `${base}-${i}`)) i += 1;
2067
+ return `${base}-${i}`;
2068
+ }
2069
+
2070
+ function commandIdFromRun(run: string): string {
2071
+ const base = run
2072
+ .toLowerCase()
2073
+ .replace(/[^a-z0-9]+/g, "-")
2074
+ .replace(/^-+|-+$/g, "")
2075
+ .slice(0, 48);
2076
+ return base || "command";
2077
+ }
2078
+
2079
+ async function showAfterEditCommands(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2080
+ while (true) {
2081
+ const commands = orchestrator.config.commands.afterEdit;
2082
+ const entries = Object.entries(commands);
2083
+ const options: OptionInput[] = [];
2084
+ const byTitle = new Map<string, string>();
2085
+ const usedTitles = new Set<string>();
2086
+ for (const [id, cmd] of entries) {
2087
+ const title = makeUniqueTitle(`Command "${id}"`, usedTitles);
2088
+ const globsCount = (cmd.globs ?? []).length;
2089
+ const enabledTag = isEnabled(cmd) ? "" : " [disabled]";
2090
+ options.push(opt(`${title}${enabledTag}`, `${globsCount} patterns — ${cmd.run}`));
2091
+ byTitle.set(`${title}${enabledTag}`, id);
2092
+ }
2093
+ options.push(opt("New command", "Add command"));
2094
+ options.push(...buildResetOptions(orchestrator, ["commands", "afterEdit"]));
2095
+ options.push(opt("Back", "Return to the previous menu"));
2096
+ const choice = await selectOption(ctx, `After file edit: ${entries.length} commands`, options);
2097
+ if (!choice || choice === "Back") return BACK;
2098
+ if (choice === "New command") {
2099
+ const run = await promptRequiredInput(ctx, "Command to run");
2100
+ if (!run) continue;
2101
+ const globInput = await promptRequiredInput(ctx, "Glob patterns (comma-separated)");
2102
+ if (!globInput) {
2103
+ ctx.ui.notify("At least one file pattern is required.", "warning");
2104
+ continue;
2105
+ }
2106
+ const patterns = parseCommaSeparated(globInput);
2107
+ if (patterns.length === 0) {
2108
+ ctx.ui.notify("At least one file pattern is required.", "warning");
2109
+ continue;
2110
+ }
2111
+ const scope = await pickScope(ctx, orchestrator);
2112
+ if (!scope) continue;
2113
+ const baseId = commandIdFromRun(run);
2114
+ const id = ensureUniqueCommandId(commands, baseId);
2115
+ applyConfigChange(orchestrator, scope, ["commands", "afterEdit", id], { run, globs: patterns, enabled: true });
2116
+ continue;
2117
+ }
2118
+ if (await maybeHandleResetChoice(orchestrator, ctx, choice, ["commands", "afterEdit"])) continue;
2119
+ const id = byTitle.get(choice);
2120
+ if (!id) continue;
2121
+ const commandPath = ["commands", "afterEdit", id];
2122
+ while (true) {
2123
+ const command = orchestrator.config.commands.afterEdit[id];
2124
+ if (!command) break;
2125
+ const commandChoice = await selectOption(ctx, `Command "${id}"`, [
2126
+ opt("Edit command", command.run),
2127
+ opt("Triggers", `${(command.globs ?? []).length} patterns`),
2128
+ opt(`Enabled: ${isEnabled(command) ? "Yes" : "No"}`, "Toggle command"),
2129
+ opt("Delete command", "Remove this command"),
2130
+ opt("Back", "Return to previous menu"),
2131
+ ]);
2132
+ if (!commandChoice || commandChoice === "Back") break;
2133
+ if (commandChoice === "Edit command") {
2134
+ const run = await promptRequiredInput(ctx, `Command (current: ${command.run})`);
2135
+ if (!run) continue;
2136
+ const scope = await pickScope(ctx, orchestrator);
2137
+ if (!scope) continue;
2138
+ applyConfigChange(orchestrator, scope, [...commandPath, "run"], run);
2139
+ continue;
2140
+ }
2141
+ if (commandChoice.startsWith("Enabled:")) {
2142
+ await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"]);
2143
+ continue;
2144
+ }
2145
+ if (commandChoice === "Triggers") {
2146
+ while (true) {
2147
+ const current = orchestrator.config.commands.afterEdit[id];
2148
+ if (!current) break;
2149
+ const currentGlobs = current.globs ?? [];
2150
+ const patternUsed = new Set<string>();
2151
+ const patternOptions: OptionInput[] = currentGlobs.map((glob) => {
2152
+ const title = makeUniqueTitle(`Pattern "${slugify(glob, 50)}"`, patternUsed);
2153
+ return opt(title, glob);
2154
+ });
2155
+ patternOptions.push(opt("New pattern", "Add trigger pattern"));
2156
+ patternOptions.push(opt("Back", "Return to previous menu"));
2157
+ const patternChoice = await selectOption(ctx, "File patterns", patternOptions);
2158
+ if (!patternChoice || patternChoice === "Back") break;
2159
+ if (patternChoice === "New pattern") {
2160
+ const value = await promptRequiredInput(ctx, "Pattern");
2161
+ if (!value) continue;
2162
+ const scope = await pickScope(ctx, orchestrator);
2163
+ if (!scope) continue;
2164
+ applyConfigChange(orchestrator, scope, [...commandPath, "globs"], [...currentGlobs, value]);
2165
+ continue;
2166
+ }
2167
+ const patternGlob = patternOptions.find((o) => (typeof o === "string" ? o : o.title) === patternChoice);
2168
+ const actualGlob = patternGlob && typeof patternGlob !== "string" ? patternGlob.description : undefined;
2169
+ const patternIndex = actualGlob ? currentGlobs.indexOf(actualGlob) : -1;
2170
+ if (patternIndex < 0) continue;
2171
+ const action = await selectOption(ctx, `Pattern "${slugify(currentGlobs[patternIndex]!, 50)}"`, [
2172
+ opt("Edit", currentGlobs[patternIndex]!),
2173
+ opt("Delete", "Remove this pattern"),
2174
+ opt("Back", "Return to previous menu"),
2175
+ ]);
2176
+ if (!action || action === "Back") continue;
2177
+ const nextGlob = [...currentGlobs];
2178
+ if (action === "Delete") {
2179
+ const confirm = await selectOption(ctx, "Confirm delete?", [
2180
+ opt("Yes, delete", "This cannot be undone"),
2181
+ opt("Back", "Cancel"),
2182
+ ]);
2183
+ if (confirm !== "Yes, delete") continue;
2184
+ nextGlob.splice(patternIndex, 1);
2185
+ } else {
2186
+ const value = await promptRequiredInput(ctx, `Pattern (current: ${currentGlobs[patternIndex]})`);
2187
+ if (!value) continue;
2188
+ nextGlob[patternIndex] = value;
2189
+ }
2190
+ const scope = await pickScope(ctx, orchestrator);
2191
+ if (!scope) continue;
2192
+ applyConfigChange(orchestrator, scope, [...commandPath, "globs"], nextGlob);
2193
+ }
2194
+ continue;
2195
+ }
2196
+ const confirm = await selectOption(ctx, "Confirm delete?", [
2197
+ opt("Yes, delete", "This cannot be undone"),
2198
+ opt("Back", "Cancel"),
2199
+ ]);
2200
+ if (confirm !== "Yes, delete") continue;
2201
+ const scope = await pickScope(ctx, orchestrator);
2202
+ if (!scope) continue;
2203
+ clearConfigOverride(orchestrator, scope, commandPath);
2204
+ break;
2205
+ }
2206
+ }
2207
+ }
2208
+
2209
+ async function showAfterImplementCommands(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2210
+ while (true) {
2211
+ const commands = orchestrator.config.commands.afterImplement;
2212
+ const entries = Object.entries(commands);
2213
+ const options: OptionInput[] = [];
2214
+ const byTitle = new Map<string, string>();
2215
+ const usedTitles = new Set<string>();
2216
+ for (const [id, cmd] of entries) {
2217
+ const enabledTag = isEnabled(cmd) ? "" : " [disabled]";
2218
+ const title = makeUniqueTitle(`Command "${id}"${enabledTag}`, usedTitles);
2219
+ options.push(opt(title, cmd.run));
2220
+ byTitle.set(title, id);
2221
+ }
2222
+ options.push(opt("New command", "Add command"));
2223
+ options.push(...buildResetOptions(orchestrator, ["commands", "afterImplement"]));
2224
+ options.push(opt("Back", "Return to the previous menu"));
2225
+ const choice = await selectOption(ctx, `After implementation: ${entries.length} commands`, options);
2226
+ if (!choice || choice === "Back") return BACK;
2227
+ if (choice === "New command") {
2228
+ const run = await promptRequiredInput(ctx, "Command to run");
2229
+ if (!run) continue;
2230
+ const scope = await pickScope(ctx, orchestrator);
2231
+ if (!scope) continue;
2232
+ const id = ensureUniqueCommandId(commands, commandIdFromRun(run));
2233
+ applyConfigChange(orchestrator, scope, ["commands", "afterImplement", id], { run, enabled: true });
2234
+ continue;
2235
+ }
2236
+ if (await maybeHandleResetChoice(orchestrator, ctx, choice, ["commands", "afterImplement"])) continue;
2237
+ const id = byTitle.get(choice);
2238
+ if (!id) continue;
2239
+ const commandPath = ["commands", "afterImplement", id];
2240
+ while (true) {
2241
+ const command = orchestrator.config.commands.afterImplement[id];
2242
+ if (!command) break;
2243
+ const commandChoice = await selectOption(ctx, `Command "${id}"`, [
2244
+ opt("Edit command", command.run),
2245
+ opt(`Enabled: ${isEnabled(command) ? "Yes" : "No"}`, "Toggle command"),
2246
+ opt("Delete command", "Remove this command"),
2247
+ opt("Back", "Return to previous menu"),
2248
+ ]);
2249
+ if (!commandChoice || commandChoice === "Back") break;
2250
+ if (commandChoice === "Edit command") {
2251
+ const run = await promptRequiredInput(ctx, `Command (current: ${command.run})`);
2252
+ if (!run) continue;
2253
+ const scope = await pickScope(ctx, orchestrator);
2254
+ if (!scope) continue;
2255
+ applyConfigChange(orchestrator, scope, [...commandPath, "run"], run);
2256
+ continue;
2257
+ }
2258
+ if (commandChoice.startsWith("Enabled:")) {
2259
+ await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"]);
2260
+ continue;
2261
+ }
2262
+ const confirm = await selectOption(ctx, "Confirm delete?", [
2263
+ opt("Yes, delete", "This cannot be undone"),
2264
+ opt("Back", "Cancel"),
2265
+ ]);
2266
+ if (confirm !== "Yes, delete") continue;
2267
+ const scope = await pickScope(ctx, orchestrator);
2268
+ if (!scope) continue;
2269
+ clearConfigOverride(orchestrator, scope, commandPath);
2270
+ break;
2271
+ }
2272
+ }
2273
+ }
2274
+
2275
+ async function showCommandsSettings(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2276
+ while (true) {
2277
+ const afterEditCount = Object.keys(orchestrator.config.commands.afterEdit).length;
2278
+ const afterImplementCount = Object.keys(orchestrator.config.commands.afterImplement).length;
2279
+ const choice = await selectOption(ctx, "Commands", [
2280
+ opt(
2281
+ `After file edit: ${afterEditCount} commands`,
2282
+ `${afterEditCount} commands`,
2283
+ ),
2284
+ opt(
2285
+ `After implementation: ${afterImplementCount} commands`,
2286
+ `${afterImplementCount} commands`,
2287
+ ),
2288
+ opt("Back", "Return to the previous menu"),
2289
+ ]);
2290
+ if (!choice || choice === "Back") return BACK;
2291
+ if (choice.startsWith("After file edit:")) {
2292
+ await showAfterEditCommands(orchestrator, ctx);
2293
+ continue;
2294
+ }
2295
+ await showAfterImplementCommands(orchestrator, ctx);
2296
+ }
2297
+ }
2298
+
2299
+ async function showTimeoutsSettings(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2300
+ while (true) {
2301
+ const timeoutEntries: TimeoutEntry[] = [
2302
+ {
2303
+ key: "performance.commands.afterEdit",
2304
+ path: ["performance", "commands", "afterEdit"],
2305
+ value: orchestrator.config.performance.commands.afterEdit,
2306
+ },
2307
+ {
2308
+ key: "performance.commands.afterImplement",
2309
+ path: ["performance", "commands", "afterImplement"],
2310
+ value: orchestrator.config.performance.commands.afterImplement,
2311
+ },
2312
+ {
2313
+ key: "performance.internals.subagentStale",
2314
+ path: ["performance", "internals", "subagentStale"],
2315
+ value: orchestrator.config.performance.internals.subagentStale,
2316
+ },
2317
+ {
2318
+ key: "performance.internals.taskLockStale",
2319
+ path: ["performance", "internals", "taskLockStale"],
2320
+ value: orchestrator.config.performance.internals.taskLockStale,
2321
+ },
2322
+ {
2323
+ key: "performance.internals.taskLockRefresh",
2324
+ path: ["performance", "internals", "taskLockRefresh"],
2325
+ value: orchestrator.config.performance.internals.taskLockRefresh,
2326
+ },
2327
+ ];
2328
+ const options: OptionInput[] = timeoutEntries.map((entry) => {
2329
+ const value = entry.value;
2330
+ const key = entry.key;
2331
+ return opt(`${TIMEOUT_LABELS[key]}: ${formatDuration(value)}`, "Edit timeout");
2332
+ });
2333
+ options.push(opt("Back", "Return to the previous menu"));
2334
+ const choice = await selectOption(ctx, "Timeouts", options);
2335
+ if (!choice || choice === "Back") return BACK;
2336
+ const entry = timeoutEntries.find((item) => choice.startsWith(`${TIMEOUT_LABELS[item.key]}:`));
2337
+ if (!entry) continue;
2338
+ while (true) {
2339
+ const current = getNestedValue(orchestrator.config, entry.path);
2340
+ if (typeof current !== "number") break;
2341
+ const action = await selectOption(ctx, `${TIMEOUT_LABELS[entry.key]}: ${formatDuration(current)}`, [
2342
+ opt("Edit", "Set timeout value"),
2343
+ ...buildResetOptions(orchestrator, entry.path),
2344
+ opt("Back", "Return to the previous menu"),
2345
+ ]);
2346
+ if (!action || action === "Back") break;
2347
+ if (action === "Edit") {
2348
+ const input = await promptRequiredInput(
2349
+ ctx,
2350
+ `New value (current: ${formatDuration(current)}, e.g. 30s, 5m, 1h, or milliseconds)`,
2351
+ );
2352
+ if (!input) continue;
2353
+ const parsed = parseDuration(input);
2354
+ if (parsed === null) {
2355
+ ctx.ui.notify("Invalid duration format.", "warning");
2356
+ continue;
2357
+ }
2358
+ applyScopeChoice(orchestrator, entry.path, parsed, await pickScope(ctx, orchestrator));
2359
+ continue;
2360
+ }
2361
+ await maybeHandleResetChoice(orchestrator, ctx, action, entry.path);
2362
+ }
2363
+ }
2364
+ }
2365
+
2366
+ async function showPerformanceSettings(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2367
+ while (true) {
2368
+ const choice = await selectOption(ctx, "Performance", [
2369
+ opt("Timeouts", "Timeout configuration"),
2370
+ opt("Back", "Return to the previous menu"),
2371
+ ]);
2372
+ if (!choice || choice === "Back") return BACK;
2373
+ await showTimeoutsSettings(orchestrator, ctx);
2374
+ }
2375
+ }
2376
+
2377
+ async function showBooleanSetting(
2378
+ orchestrator: Orchestrator,
2379
+ ctx: any,
2380
+ title: string,
2381
+ keyPath: string[],
2382
+ ): Promise<void> {
2383
+ while (true) {
2384
+ const info = getConfigSourceInfo(orchestrator, keyPath);
2385
+ const yesTitle = withTags("Yes", formatSourceTags(true, info));
2386
+ const noTitle = withTags("No", formatSourceTags(false, info));
2387
+ const choice = await selectOption(ctx, title, [
2388
+ yesTitle,
2389
+ noTitle,
2390
+ ...buildResetOptions(orchestrator, keyPath),
2391
+ opt("Back", "Return to the previous menu"),
2392
+ ]);
2393
+ if (!choice || choice === "Back") return;
2394
+ if (choice === yesTitle) {
2395
+ applyScopeChoice(orchestrator, keyPath, true, await pickScope(ctx, orchestrator));
2396
+ continue;
2397
+ }
2398
+ if (choice === noTitle) {
2399
+ applyScopeChoice(orchestrator, keyPath, false, await pickScope(ctx, orchestrator));
2400
+ continue;
2401
+ }
2402
+ await maybeHandleResetChoice(orchestrator, ctx, choice, keyPath);
2403
+ }
2404
+ }
2405
+
2406
+ async function showInvertedBooleanSetting(
2407
+ orchestrator: Orchestrator,
2408
+ ctx: any,
2409
+ title: string,
2410
+ keyPath: string[],
2411
+ ): Promise<void> {
2412
+ while (true) {
2413
+ const info = getConfigSourceInfo(orchestrator, keyPath);
2414
+ const yesTitle = withTags("Yes", formatSourceTags(false, info));
2415
+ const noTitle = withTags("No", formatSourceTags(true, info));
2416
+ const choice = await selectOption(ctx, title, [
2417
+ yesTitle,
2418
+ noTitle,
2419
+ ...buildResetOptions(orchestrator, keyPath),
2420
+ opt("Back", "Return to the previous menu"),
2421
+ ]);
2422
+ if (!choice || choice === "Back") return;
2423
+ if (choice === yesTitle) {
2424
+ applyScopeChoice(orchestrator, keyPath, false, await pickScope(ctx, orchestrator));
2425
+ continue;
2426
+ }
2427
+ if (choice === noTitle) {
2428
+ applyScopeChoice(orchestrator, keyPath, true, await pickScope(ctx, orchestrator));
2429
+ continue;
2430
+ }
2431
+ await maybeHandleResetChoice(orchestrator, ctx, choice, keyPath);
2432
+ }
2433
+ }
2434
+
2435
+ async function showLogLevelSetting(orchestrator: Orchestrator, ctx: any): Promise<void> {
2436
+ const levels: Array<{ value: PiPiConfig["general"]["logLevel"]; label: string }> = [
2437
+ { value: "debug", label: "Debug" },
2438
+ { value: "info", label: "Info" },
2439
+ { value: "warn", label: "Warning" },
2440
+ { value: "error", label: "Error" },
2441
+ ];
2442
+ while (true) {
2443
+ const info = getConfigSourceInfo(orchestrator, ["general", "logLevel"]);
2444
+ const options: OptionInput[] = levels.map((entry) => withTags(entry.label, formatSourceTags(entry.value, info)));
2445
+ options.push(...buildResetOptions(orchestrator, ["general", "logLevel"]));
2446
+ options.push(opt("Back", "Return to the previous menu"));
2447
+ const choice = await selectOption(ctx, "Log level", options);
2448
+ if (!choice || choice === "Back") return;
2449
+ const picked = levels.find((entry) => choice.startsWith(entry.label));
2450
+ if (picked) {
2451
+ const scope = await pickScope(ctx, orchestrator);
2452
+ if (!scope) continue;
2453
+ applyConfigChange(orchestrator, scope, ["general", "logLevel"], picked.value);
2454
+ setLogLevel(orchestrator.config.general.logLevel);
2455
+ continue;
2456
+ }
2457
+ await maybeHandleResetChoice(orchestrator, ctx, choice, ["general", "logLevel"]);
2458
+ setLogLevel(orchestrator.config.general.logLevel);
2459
+ }
2460
+ }
2461
+
2462
+ async function showGeneralSettings(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2463
+ while (true) {
2464
+ const choice = await selectOption(ctx, "General", [
2465
+ opt(`Commit automatically: ${orchestrator.config.general.autoCommit ? "Yes" : "No"}`, "Enable or disable auto commits"),
2466
+ opt(`Ignore configs from other repos: ${orchestrator.config.general.loadExtraRepoConfigs ? "No" : "Yes"}`, "Load only root repo config"),
2467
+ opt(`Log level: ${logLevelLabel(orchestrator.config.general.logLevel)}`, "Logging verbosity"),
2468
+ opt(`Tracing: ${orchestrator.config.general.tracing ? "Yes" : "No"}`, "Capture full session traces to .pp/logs/traces/"),
2469
+ opt("Flant AI Infrastructure", "Configure corporate AI model provider"),
2470
+ opt("Back", "Return to the previous menu"),
2471
+ ]);
2472
+ if (!choice || choice === "Back") return BACK;
2473
+ if (choice.startsWith("Commit automatically:")) {
2474
+ await showBooleanSetting(orchestrator, ctx, "Commit automatically", ["general", "autoCommit"]);
2475
+ continue;
2476
+ }
2477
+ if (choice.startsWith("Ignore configs from other repos:")) {
2478
+ await showInvertedBooleanSetting(orchestrator, ctx, "Ignore configs from other repos", ["general", "loadExtraRepoConfigs"]);
2479
+ continue;
2480
+ }
2481
+ if (choice.startsWith("Log level:")) {
2482
+ await showLogLevelSetting(orchestrator, ctx);
2483
+ continue;
2484
+ }
2485
+ if (choice.startsWith("Tracing:")) {
2486
+ await showBooleanSetting(orchestrator, ctx, "Tracing", ["general", "tracing"]);
2487
+ continue;
2488
+ }
2489
+ await showFlantInfraMenu(orchestrator, ctx);
2490
+ }
2491
+ }
2492
+
2493
+ async function showAgentsSettings(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2494
+ while (true) {
2495
+ const choice = await selectOption(ctx, "Agents", [
2496
+ opt("Orchestrators", "Orchestrator and subagent configuration"),
2497
+ opt("Subagents", "Simple subagents and preset groups"),
2498
+ opt("Back", "Return to the previous menu"),
2499
+ ]);
2500
+ if (!choice || choice === "Back") return BACK;
2501
+ if (choice === "Orchestrators") {
2502
+ await showOrchestratorsSettings(orchestrator, ctx);
2503
+ continue;
2504
+ }
2505
+ await showSubagentSettings(orchestrator, ctx);
2506
+ }
2507
+ }
2508
+
2509
+ async function showReposSettings(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2510
+ while (true) {
2511
+ const repos = orchestrator.active?.state.repos ?? [];
2512
+ if (repos.length === 0) {
2513
+ await selectOption(ctx, "No repos registered yet. The agent will register repos when it starts working.", [
2514
+ opt("Back", "Return to the previous menu"),
2515
+ ]);
2516
+ return BACK;
2517
+ }
2518
+
2519
+ const options: OptionInput[] = repos.map((repo) => ({
2520
+ title: repo.path,
2521
+ description: `base: ${repo.baseBranch ?? "(not set)"}${repo.isRoot ? " (root)" : ""}`,
2522
+ }));
2523
+ options.push(opt("Back", "Return to the previous menu"));
2524
+
2525
+ const choice = await selectOption(ctx, "Repos", options);
2526
+ if (!choice || choice === "Back") return BACK;
2527
+
2528
+ const repo = repos.find((item) => item.path === choice);
2529
+ if (!repo) continue;
2530
+
2531
+ const repoOptions: OptionInput[] = [
2532
+ opt("Change base branch", `currently: ${repo.baseBranch ?? "(not set)"}`),
2533
+ opt("Back", "Return to repo list"),
2534
+ ];
2535
+ const repoChoice = await selectOption(ctx, `${repo.path}${repo.isRoot ? " (root)" : ""}`, repoOptions);
2536
+ if (!repoChoice || repoChoice === "Back") continue;
2537
+
2538
+ if (repoChoice === "Change base branch") {
2539
+ const value = await ctx.ui.input("Base branch (e.g. origin/main):");
2540
+ if (value === undefined || value === null) continue;
2541
+ repo.baseBranch = String(value).trim() || undefined;
2542
+ saveTask(orchestrator.active!.dir, orchestrator.active!.state);
2543
+ unregisterAgentDefinitions(orchestrator.pi);
2544
+ orchestrator.registerAgents();
2545
+ ctx.ui.notify(`Base branch set to: ${repo.baseBranch ?? "(cleared)"}`, "info");
2546
+ }
2547
+ }
2548
+ }
2549
+
2550
+ async function showLspSettings(ctx: any): Promise<typeof BACK> {
2551
+ while (true) {
2552
+ const choice = await selectOption(ctx, "LSP", [
2553
+ opt("Restart all servers", "Stop all servers. They reinitialize on next use"),
2554
+ opt("Back", "Return to the previous menu"),
2555
+ ]);
2556
+ if (!choice || choice === "Back") return BACK;
2557
+
2558
+ const api = (globalThis as any)[Symbol.for("pi-lsp:api")] as {
2559
+ restart?: (menuCtx: any) => Promise<void>;
2560
+ } | undefined;
2561
+ if (!api?.restart) {
2562
+ ctx.ui.notify("LSP API is not available.", "warning");
2563
+ continue;
2564
+ }
2565
+ try {
2566
+ await api.restart(ctx);
2567
+ } catch (error: any) {
2568
+ const message = error instanceof Error ? error.message : String(error);
2569
+ ctx.ui.notify(`Failed to restart LSP servers: ${message}`, "error");
2570
+ }
2571
+ }
2572
+ }
2573
+
2574
+ async function showSettingsMenu(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2575
+ while (true) {
2576
+ const options: OptionInput[] = [
2577
+ opt("General", "Commit, log level, Flant AI, repos"),
2578
+ opt("Agents", "Orchestrator and subagent configuration"),
2579
+ opt("Commands", "After file edit and after implementation"),
2580
+ opt("Performance", "Timeout configuration"),
2581
+ opt("LSP", "Language server controls"),
2582
+ opt("Back", "Return to the previous menu"),
2583
+ ];
2584
+
2585
+ const choice = await selectOption(ctx, "Settings", options);
2586
+ if (!choice || choice === "Back") return BACK;
2587
+
2588
+ if (choice === "General") await showGeneralSettings(orchestrator, ctx);
2589
+ else if (choice === "Agents") await showAgentsSettings(orchestrator, ctx);
2590
+ else if (choice === "Commands") await showCommandsSettings(orchestrator, ctx);
2591
+ else if (choice === "Performance") await showPerformanceSettings(orchestrator, ctx);
2592
+ else if (choice === "LSP") await showLspSettings(ctx);
2593
+ }
2594
+ }
2595
+
2596
+ // First phases (brainstorm/debug/review) are always interactive and can never run
2597
+ // autonomously — autonomous configs only ever cover plan/implement.
2598
+ function autonomousPhasesForTask(type: TaskType): string[] {
2599
+ if (type === "implement" || type === "debug" || type === "review") return ["plan", "implement"];
2600
+ return [];
2601
+ }
2602
+
2603
+ function defaultAutonomousReviewPreset(type: TaskType, phase: string): string {
2604
+ if (type === "review" && phase === "review") return "deep";
2605
+ return "regular";
2606
+ }
2607
+
2608
+ function buildDefaultAutonomousConfig(type: TaskType): AutonomousConfig {
2609
+ const phases = autonomousPhasesForTask(type);
2610
+ const out: AutonomousConfig = { phases: {} };
2611
+ for (const phase of phases) {
2612
+ out.phases[phase] = {
2613
+ reviewPreset: defaultAutonomousReviewPreset(type, phase),
2614
+ maxReviewPasses: 3,
2615
+ ...(phase === "plan" ? { plannerPreset: "regular" } : {}),
2616
+ };
2617
+ }
2618
+ return out;
2619
+ }
2620
+
2621
+ async function showTaskModePicker(ctx: any): Promise<TaskMode | "back"> {
2622
+ const mode = await selectOption(ctx, "Mode", [
2623
+ opt("Guided", "User gates at every phase transition"),
2624
+ opt("Autonomous", "Full pipeline with automatic phase transitions"),
2625
+ opt("Back", "Return to the previous menu"),
2626
+ ]);
2627
+ if (!mode || mode === "Back") return "back";
2628
+ return mode === "Autonomous" ? "autonomous" : "guided";
2629
+ }
2630
+
2631
+ export async function pickMaxReviewPasses(ctx: any, current: number): Promise<number | null> {
2632
+ const currentLabel = current >= 999 ? "-" : String(current);
2633
+ while (true) {
2634
+ const input = await ctx.ui.input(
2635
+ `Max review passes (enter a positive integer, or "-" for unlimited) [${currentLabel}]`,
2636
+ );
2637
+ if (input === undefined || input === null) return null;
2638
+ const trimmed = String(input).trim();
2639
+ if (trimmed === "") return null;
2640
+ if (trimmed === "-") return 999;
2641
+ if (!/^\d+$/.test(trimmed)) {
2642
+ ctx.ui.notify('Please enter a positive integer, or "-" for unlimited.', "warning");
2643
+ continue;
2644
+ }
2645
+ const parsed = Number.parseInt(trimmed, 10);
2646
+ if (parsed <= 0) {
2647
+ ctx.ui.notify('Please enter a positive integer, or "-" for unlimited.', "warning");
2648
+ continue;
2649
+ }
2650
+ return parsed;
2651
+ }
2652
+ }
2653
+
2654
+ async function showAutonomousPhaseSettings(
2655
+ orchestrator: Orchestrator,
2656
+ ctx: any,
2657
+ type: TaskType,
2658
+ phase: string,
2659
+ config: AutonomousConfig,
2660
+ ): Promise<void> {
2661
+ const phaseConfig = config.phases[phase] ?? {
2662
+ reviewPreset: defaultAutonomousReviewPreset(type, phase),
2663
+ maxReviewPasses: 3,
2664
+ ...(phase === "plan" ? { plannerPreset: "regular" } : {}),
2665
+ };
2666
+ config.phases[phase] = phaseConfig;
2667
+
2668
+ while (true) {
2669
+ const reviewPreset = phaseConfig.reviewPreset ?? defaultAutonomousReviewPreset(type, phase);
2670
+ const maxReview = phaseConfig.maxReviewPasses >= 999 ? "No limit" : String(phaseConfig.maxReviewPasses);
2671
+ const options: OptionInput[] = [
2672
+ opt(`Review preset: ${reviewPreset}`, "Select review preset"),
2673
+ ];
2674
+ if (phase === "plan") {
2675
+ options.push(opt(`Planner preset: ${phaseConfig.plannerPreset ?? "regular"}`, "Select planner preset"));
2676
+ }
2677
+ options.push(opt(`Max review passes: ${maxReview}`, "Safety cap for autonomous review loops"));
2678
+ options.push(opt("Back", "Return to autonomous settings"));
2679
+
2680
+ const choice = await selectOption(ctx, `${phase.charAt(0).toUpperCase()}${phase.slice(1)} phase`, options);
2681
+ if (!choice || choice === "Back") return;
2682
+
2683
+ if (choice.startsWith("Review preset:")) {
2684
+ const group = getReviewPresetGroup(phase);
2685
+ const picked = await pickPreset(ctx, orchestrator, group, "Review preset");
2686
+ if (picked) phaseConfig.reviewPreset = picked;
2687
+ continue;
2688
+ }
2689
+
2690
+ if (choice.startsWith("Planner preset:")) {
2691
+ const picked = await pickPreset(ctx, orchestrator, "planners", "Planner preset");
2692
+ if (picked) phaseConfig.plannerPreset = picked;
2693
+ continue;
2694
+ }
2695
+
2696
+ const pickedMax = await pickMaxReviewPasses(ctx, phaseConfig.maxReviewPasses);
2697
+ if (pickedMax !== null) phaseConfig.maxReviewPasses = pickedMax;
2698
+ }
2699
+ }
2700
+
2701
+ async function showAutonomousSettings(
2702
+ orchestrator: Orchestrator,
2703
+ ctx: any,
2704
+ type: TaskType,
2705
+ ): Promise<AutonomousConfig | null> {
2706
+ const config = buildDefaultAutonomousConfig(type);
2707
+ const phases = autonomousPhasesForTask(type);
2708
+
2709
+ while (true) {
2710
+ const options: OptionInput[] = [
2711
+ opt("Start", "Begin with current autonomous settings"),
2712
+ ...phases.map((phase) => opt(`${phase.charAt(0).toUpperCase()}${phase.slice(1)} phase`, "Configure presets and review passes")),
2713
+ opt("Back", "Return to mode selection"),
2714
+ ];
2715
+ const choice = await selectOption(ctx, "Autonomous", options);
2716
+ if (!choice || choice === "Back") return null;
2717
+ if (choice === "Start") return config;
2718
+
2719
+ const phase = choice.replace(/ phase$/, "").toLowerCase();
2720
+ if (!phases.includes(phase)) continue;
2721
+ await showAutonomousPhaseSettings(orchestrator, ctx, type, phase, config);
2722
+ }
2723
+ }
2724
+
2725
+ async function pickModeForTaskStart(
2726
+ orchestrator: Orchestrator,
2727
+ ctx: any,
2728
+ type: TaskType,
2729
+ ): Promise<{ mode: TaskMode; autonomousConfig?: AutonomousConfig } | null> {
2730
+ while (true) {
2731
+ const mode = await showTaskModePicker(ctx);
2732
+ if (mode === "back") return null;
2733
+ if (mode === "guided") return { mode: "guided" };
2734
+ const autonomousConfig = await showAutonomousSettings(orchestrator, ctx, type);
2735
+ if (!autonomousConfig) continue;
2736
+ return { mode: "autonomous", autonomousConfig };
2737
+ }
2738
+ }
2739
+
2740
+ function resumeOptionTitle(t: TaskInfo): string {
2741
+ return taskName(t.dir);
2742
+ }
2743
+
2744
+ function resumeOptionDescription(t: TaskInfo): string {
2745
+ const age = taskAge(t.state);
2746
+ const phase = t.state.phase === t.type ? t.type : `${t.type}/${t.state.phase}`;
2747
+ return `${phase}, ${age}`;
2748
+ }
2749
+
2750
+ async function showResumeMenu(
2751
+ orchestrator: Orchestrator,
2752
+ ctx: any,
2753
+ type: TaskType | undefined,
2754
+ emptyMessage: string,
2755
+ ): Promise<typeof BACK | "started"> {
2756
+ while (true) {
2757
+ const tasks = listTasks(orchestrator.cwd, type);
2758
+ if (tasks.length === 0) {
2759
+ ctx.ui.notify(emptyMessage, "info");
2760
+ return BACK;
2761
+ }
2762
+
2763
+ const options: OptionInput[] = tasks.map((t) => ({
2764
+ title: resumeOptionTitle(t),
2765
+ description: resumeOptionDescription(t),
2766
+ }));
2767
+ options.push({ title: "Back", description: "Return to the previous menu" });
2768
+
2769
+ const choice = await selectOption(ctx, "Resume", options);
2770
+ if (!choice || choice === "Back") return BACK;
2771
+
2772
+ const task = tasks.find((t) => resumeOptionTitle(t) === choice);
2773
+ if (!task) continue;
2774
+ const result = await resumeTask(orchestrator, ctx, task);
2775
+ if (result.ok) return "started";
2776
+ }
2777
+ }
2778
+
2779
+ function fromOptionTitle(t: TaskInfo): string {
2780
+ return taskName(t.dir);
2781
+ }
2782
+
2783
+ function fromOptionDescription(t: TaskInfo, cwd: string): string {
2784
+ const age = taskAge(t.state);
2785
+ const rel = relative(join(cwd, ".pp", "state"), t.dir);
2786
+ return `${t.type}, ${age} — ${rel}`;
2787
+ }
2788
+
2789
+ async function showFromMenu(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK | "started"> {
2790
+ while (true) {
2791
+ const tasks = listCompletedFromTasks(orchestrator.cwd);
2792
+ if (tasks.length === 0) {
2793
+ ctx.ui.notify("No completed brainstorm/debug/review tasks with artifacts found.", "info");
2794
+ return BACK;
2795
+ }
2796
+
2797
+ const options: OptionInput[] = tasks.map((t) => ({
2798
+ title: fromOptionTitle(t),
2799
+ description: fromOptionDescription(t, orchestrator.cwd),
2800
+ }));
2801
+ options.push({ title: "Back", description: "Return to the previous menu" });
2802
+
2803
+ const choice = await selectOption(ctx, "From", options);
2804
+ if (!choice || choice === "Back") return BACK;
2805
+
2806
+ const selected = tasks.find((t) => fromOptionTitle(t) === choice);
2807
+ if (!selected) continue;
2808
+
2809
+ const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "implement");
2810
+ if (!modeSelection) continue;
2811
+ await orchestrator.startTask(ctx, "implement", "implement", selected.dir, true, modeSelection.mode);
2812
+ if (orchestrator.active) {
2813
+ orchestrator.active.state.autonomousConfig = modeSelection.autonomousConfig;
2814
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
2815
+ }
2816
+ return "started";
2817
+ }
2818
+ }
2819
+
2820
+ async function showImplementMenu(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK | "started"> {
2821
+ while (true) {
2822
+ const choice = await selectOption(ctx, "Implement", [
2823
+ { title: "New", description: "Start a new implementation from scratch" },
2824
+ { title: "From", description: "Continue from a completed brainstorm or debug task" },
2825
+ { title: "Resume", description: "Resume a paused implementation" },
2826
+ { title: "Back", description: "Return to the previous menu" },
2827
+ ]);
2828
+ if (!choice || choice === "Back") return BACK;
2829
+
2830
+ if (choice === "New") {
2831
+ const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "implement");
2832
+ if (!modeSelection) continue;
2833
+ await orchestrator.startTask(ctx, "implement", "implement", undefined, undefined, modeSelection.mode);
2834
+ if (orchestrator.active) {
2835
+ orchestrator.active.state.autonomousConfig = modeSelection.autonomousConfig;
2836
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
2837
+ }
2838
+ return "started";
2839
+ }
2840
+
2841
+ if (choice === "From") {
2842
+ const result = await showFromMenu(orchestrator, ctx);
2843
+ if (result === "started") return result;
2844
+ continue;
2845
+ }
2846
+
2847
+ const result = await showResumeMenu(orchestrator, ctx, "implement", "No paused implement tasks found.");
2848
+ if (result === "started") return result;
2849
+ }
2850
+ }
2851
+
2852
+ function buildPrContext(parsed: any): { prUrl: string | null; prContext: string | null } {
869
2853
  const title = typeof parsed?.title === "string" ? parsed.title.trim() : "";
870
2854
  const body = typeof parsed?.body === "string" ? parsed.body.trim() : "";
871
2855
  const commentsRaw = Array.isArray(parsed?.comments)
@@ -895,47 +2879,56 @@ function buildPrContext(parsed: any): { prUrl: string | null; prContext: string
895
2879
  return { prUrl, prContext: parts.length > 0 ? parts.join("\n\n") : null };
896
2880
  }
897
2881
 
898
- async function detectCurrentPrContext(orchestrator: Orchestrator): Promise<{ prUrl: string | null; prContext: string | null }> {
899
- try {
900
- const prResult = await orchestrator.pi.exec("gh", ["pr", "view", "--json", "url,title,body,comments"], {
901
- cwd: orchestrator.cwd,
902
- timeout: 10000,
903
- });
904
- if (prResult.code !== 0) return { prUrl: null, prContext: null };
905
- const parsed = JSON.parse(prResult.stdout);
906
- return buildPrContext(parsed);
907
- } catch {
908
- return { prUrl: null, prContext: null };
2882
+ async function detectCurrentPrContext(orchestrator: Orchestrator, repos: RepoInfo[]): Promise<RepoPrContext[]> {
2883
+ const results: RepoPrContext[] = [];
2884
+ for (const repo of repos) {
2885
+ try {
2886
+ const prResult = await orchestrator.pi.exec("gh", ["pr", "view", "--json", "url,title,body,comments"], {
2887
+ cwd: repo.path,
2888
+ timeout: 10000,
2889
+ });
2890
+ if (prResult.code !== 0) continue;
2891
+ const parsed = JSON.parse(prResult.stdout);
2892
+ const pr = buildPrContext(parsed);
2893
+ if (pr.prUrl || pr.prContext) {
2894
+ results.push({ repoPath: repo.path, prUrl: pr.prUrl, prContext: pr.prContext });
2895
+ }
2896
+ } catch {}
909
2897
  }
2898
+ return results;
910
2899
  }
911
2900
 
912
- async function openCodeReviewInPlannotator(orchestrator: Orchestrator, diffType?: string, defaultBranch?: string): Promise<string> {
913
- const payload: Record<string, unknown> = { cwd: orchestrator.cwd };
914
- if (diffType) payload.diffType = diffType;
915
- if (defaultBranch) payload.defaultBranch = defaultBranch;
2901
+ async function openCodeReviewInPlannotator(
2902
+ orchestrator: Orchestrator,
2903
+ payload: { cwd: string; diffType?: string; defaultBranch?: string },
2904
+ ): Promise<{ status: "approved" | "needs_changes" | "error"; feedback?: string; error?: string }> {
2905
+ const requestPayload: Record<string, unknown> = { cwd: payload.cwd };
2906
+ if (payload.diffType) requestPayload.diffType = payload.diffType;
2907
+ if (payload.defaultBranch) requestPayload.defaultBranch = payload.defaultBranch;
916
2908
 
917
2909
  return await new Promise((resolve) => {
918
2910
  let handled = false;
2911
+ const timer = setTimeout(() => {
2912
+ if (!handled) resolve({ status: "error", error: "Plannotator is not available." });
2913
+ }, 30000);
919
2914
  orchestrator.pi.events.emit("plannotator:request", {
920
2915
  requestId: crypto.randomUUID(),
921
2916
  action: "code-review",
922
- payload,
2917
+ payload: requestPayload,
923
2918
  respond: (response: any) => {
924
2919
  handled = true;
2920
+ clearTimeout(timer);
925
2921
  if (response?.status !== "handled") {
926
- resolve(`Plannotator is not available${response?.error ? `: ${response.error}` : "."}`);
2922
+ resolve({ status: "error", error: response?.error || "Plannotator is not available." });
927
2923
  return;
928
2924
  }
929
2925
  const approved = !!response?.result?.approved;
930
2926
  const feedback = typeof response?.result?.feedback === "string" && response.result.feedback.trim().length > 0
931
- ? `\n\nFeedback:\n${response.result.feedback}`
932
- : "";
933
- resolve(approved ? "Plannotator approved the review." : `Plannotator requested changes.${feedback}`);
2927
+ ? response.result.feedback
2928
+ : undefined;
2929
+ resolve({ status: approved ? "approved" : "needs_changes", feedback });
934
2930
  },
935
2931
  });
936
- setTimeout(() => {
937
- if (!handled) resolve("Plannotator is not available.");
938
- }, 30000);
939
2932
  });
940
2933
  }
941
2934
 
@@ -945,10 +2938,16 @@ async function startReviewTask(
945
2938
  userRequestContent: string,
946
2939
  researchContent: string | null,
947
2940
  description: string,
2941
+ mode?: TaskMode,
2942
+ autonomousConfig?: AutonomousConfig,
948
2943
  ): Promise<"started" | typeof BACK> {
949
- await orchestrator.startTask(ctx, "review", description);
2944
+ await orchestrator.startTask(ctx, "review", description, undefined, undefined, mode);
950
2945
  if (!orchestrator.active || orchestrator.active.type !== "review") return BACK;
951
- writeFileSync(join(orchestrator.active.dir, "USER_REQUEST.md"), userRequestContent, "utf-8");
2946
+ orchestrator.active.state.autonomousConfig = autonomousConfig;
2947
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
2948
+ const repos = getRegisteredRepos(orchestrator);
2949
+ const userRequestWithRepos = appendRepoContext(userRequestContent, repos);
2950
+ writeFileSync(join(orchestrator.active.dir, "USER_REQUEST.md"), userRequestWithRepos, "utf-8");
952
2951
  if (researchContent) {
953
2952
  writeFileSync(join(orchestrator.active.dir, "RESEARCH.md"), researchContent, "utf-8");
954
2953
  }
@@ -957,13 +2956,16 @@ async function startReviewTask(
957
2956
 
958
2957
  async function showReviewMenu(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK | "started"> {
959
2958
  while (true) {
960
- const choice = await selectOption(ctx, "Review", [
2959
+ const options: OptionInput[] = [
961
2960
  { title: "Current branch", description: "Review changes on current branch vs base" },
2961
+ { title: "Last commit", description: "Review changes in the most recent commit" },
2962
+ { title: "Since commit", description: "Review all changes since a specific commit" },
962
2963
  { title: "Uncommitted changes", description: "Review working directory changes" },
963
2964
  { title: "Describe", description: "Describe what to review and let the agent figure it out" },
964
2965
  { title: "Resume", description: "Resume a previously unfinished review" },
965
2966
  { title: "Back", description: "Return to the previous menu" },
966
- ]);
2967
+ ];
2968
+ const choice = await selectOption(ctx, "Review", options);
967
2969
  if (!choice || choice === "Back") return BACK;
968
2970
 
969
2971
  if (choice === "Resume") {
@@ -973,38 +2975,132 @@ async function showReviewMenu(orchestrator: Orchestrator, ctx: any): Promise<typ
973
2975
  }
974
2976
 
975
2977
  if (choice === "Current branch") {
976
- const base = await detectDefaultBranch(orchestrator.pi, orchestrator.cwd, orchestrator.config);
977
- const pr = await detectCurrentPrContext(orchestrator);
978
-
979
- const urLines = [`# User Request\nReview current branch changes (${base}..HEAD)`];
980
- if (pr.prUrl) urLines.push(`PR: ${pr.prUrl}`);
2978
+ const repos = getRegisteredRepos(orchestrator);
2979
+ const repoRanges = await Promise.all(
2980
+ repos.map(async (repo) => `- ${formatRepoLabel(repo)}: ${await detectDefaultBranch(orchestrator, repos, repo.path)}..HEAD`),
2981
+ );
2982
+ const prContexts = await detectCurrentPrContext(orchestrator, repos);
2983
+
2984
+ const urLines = [
2985
+ "# User Request",
2986
+ "Review current branch changes across registered repositories.",
2987
+ "",
2988
+ "Diff ranges:",
2989
+ ...repoRanges,
2990
+ ];
2991
+ const prUrls = prContexts
2992
+ .filter((pr) => pr.prUrl)
2993
+ .map((pr) => `- ${pr.repoPath}: ${pr.prUrl}`);
2994
+ if (prUrls.length > 0) {
2995
+ urLines.push("", "Open PRs:", ...prUrls);
2996
+ }
981
2997
  urLines.push("", "## Problem", "Review and identify issues in the code changes.", "", "## Constraints", "Focus on correctness, edge cases, style, missing tests, potential bugs.");
982
2998
  const urContent = urLines.join("\n") + "\n";
983
2999
 
984
- let resContent: string | null = null;
985
- if (pr.prContext) {
986
- resContent = ["## PR Context", pr.prContext, "", "## Affected Code", "(to be filled during review)", "", "## Architecture Context", "(to be filled during review)"].join("\n") + "\n";
3000
+ let resContent = [
3001
+ "## Affected Code",
3002
+ "(to be filled during review)",
3003
+ "",
3004
+ "## Architecture Context",
3005
+ "(to be filled during review)",
3006
+ "",
3007
+ "## Constraints & Edge Cases",
3008
+ "- MUST: Review all changed code across the registered repositories",
3009
+ "- RISK: Issues can span repository boundaries",
3010
+ ].join("\n") + "\n";
3011
+ if (prContexts.length > 0) {
3012
+ const prContextBlocks = prContexts
3013
+ .map((pr) => {
3014
+ const lines = [`${pr.repoPath}`];
3015
+ if (pr.prUrl) lines.push(`URL: ${pr.prUrl}`);
3016
+ if (pr.prContext) lines.push(pr.prContext);
3017
+ return lines.join("\n");
3018
+ })
3019
+ .join("\n\n");
3020
+ resContent = appendResearchOpenQuestions(resContent, `PR context:\n${prContextBlocks}`);
987
3021
  }
988
3022
 
989
- const description = await promptDescription(ctx, "Describe the review (optional)", "review");
990
- if (!description) continue;
991
- return startReviewTask(orchestrator, ctx, urContent, resContent, description);
3023
+ const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "review");
3024
+ if (!modeSelection) continue;
3025
+ const description = "review-current-branch";
3026
+ return startReviewTask(orchestrator, ctx, urContent, resContent, description, modeSelection.mode, modeSelection.autonomousConfig);
3027
+ }
3028
+
3029
+ if (choice === "Last commit") {
3030
+ const repos = getRegisteredRepos(orchestrator);
3031
+ const urContent = [
3032
+ "# User Request",
3033
+ "Review last commit changes across registered repositories",
3034
+ "",
3035
+ "## Problem",
3036
+ "Review and identify issues in the most recent commit.",
3037
+ "",
3038
+ "## Constraints",
3039
+ "Focus on correctness, edge cases, style, missing tests, potential bugs.",
3040
+ "",
3041
+ ].join("\n");
3042
+ const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "review");
3043
+ if (!modeSelection) continue;
3044
+ return startReviewTask(orchestrator, ctx, urContent, null, "review-last-commit", modeSelection.mode, modeSelection.autonomousConfig);
3045
+ }
3046
+
3047
+ if (choice === "Since commit") {
3048
+ const repos = getRegisteredRepos(orchestrator);
3049
+ const repoOptions: OptionInput[] = repos.map((repo) => ({
3050
+ title: formatRepoLabel(repo),
3051
+ description: "Choose repository for commit range",
3052
+ }));
3053
+ repoOptions.push({ title: "Back", description: "Return to the previous menu" });
3054
+ const repoChoice = await selectOption(ctx, "Select repository", repoOptions);
3055
+ if (!repoChoice || repoChoice === "Back") continue;
3056
+ const selectedRepo = repos.find((repo) => formatRepoLabel(repo) === repoChoice);
3057
+ if (!selectedRepo) continue;
3058
+ const pickedHash = await pickCommitForRepo(orchestrator, ctx, selectedRepo);
3059
+ if (!pickedHash) continue;
3060
+
3061
+ const urContent = [
3062
+ "# User Request",
3063
+ `Review changes in ${selectedRepo.path} since commit ${pickedHash}`,
3064
+ "",
3065
+ "## Problem",
3066
+ `Review and identify issues in all changes in ${selectedRepo.path} since ${pickedHash}.`,
3067
+ "",
3068
+ "## Constraints",
3069
+ "Focus on correctness, edge cases, style, missing tests, potential bugs.",
3070
+ "",
3071
+ ].join("\n");
3072
+ const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "review");
3073
+ if (!modeSelection) continue;
3074
+ return startReviewTask(orchestrator, ctx, urContent, null, "review-since-commit", modeSelection.mode, modeSelection.autonomousConfig);
992
3075
  }
993
3076
 
994
3077
  if (choice === "Uncommitted changes") {
995
- const urContent = "# User Request\nReview uncommitted changes\n\n## Problem\nReview and identify issues in uncommitted working directory changes.\n\n## Constraints\nFocus on correctness, edge cases, style, missing tests, potential bugs.\n";
996
- const description = await promptDescription(ctx, "Describe the review (optional)", "review");
997
- if (!description) continue;
998
- return startReviewTask(orchestrator, ctx, urContent, null, description);
3078
+ const repos = getRegisteredRepos(orchestrator);
3079
+ const urContent = [
3080
+ "# User Request",
3081
+ "Review uncommitted changes across registered repositories",
3082
+ "",
3083
+ "## Problem",
3084
+ "Review and identify issues in uncommitted working directory changes.",
3085
+ "",
3086
+ "## Constraints",
3087
+ "Focus on correctness, edge cases, style, missing tests, potential bugs.",
3088
+ "",
3089
+ ].join("\n");
3090
+ const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "review");
3091
+ if (!modeSelection) continue;
3092
+ return startReviewTask(orchestrator, ctx, urContent, null, "review-uncommitted", modeSelection.mode, modeSelection.autonomousConfig);
999
3093
  }
1000
3094
 
1001
3095
  const input = await ctx.ui.input("Describe what to review");
1002
3096
  if (input === undefined || input === null) continue;
1003
3097
  const trimmed = String(input).trim();
1004
- if (!trimmed) continue;
3098
+ const description = trimmed || "review";
1005
3099
 
1006
- const urContent = `# User Request\n${trimmed}\n\n## Problem\n${trimmed}\n\n## Constraints\nFocus on correctness, edge cases, style, missing tests, potential bugs.\n`;
1007
- return startReviewTask(orchestrator, ctx, urContent, null, trimmed);
3100
+ const urContent = `# User Request\n${description}\n\n## Problem\n${description}\n\n## Constraints\nFocus on correctness, edge cases, style, missing tests, potential bugs.\n`;
3101
+ const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "review");
3102
+ if (!modeSelection) continue;
3103
+ return startReviewTask(orchestrator, ctx, urContent, null, description, modeSelection.mode, modeSelection.autonomousConfig);
1008
3104
  }
1009
3105
  }
1010
3106
 
@@ -1012,7 +3108,6 @@ async function showTaskTypeMenu(
1012
3108
  orchestrator: Orchestrator,
1013
3109
  ctx: any,
1014
3110
  type: TaskType,
1015
- inputPrompt: string,
1016
3111
  ): Promise<typeof BACK | "started"> {
1017
3112
  while (true) {
1018
3113
  const choice = await selectOption(ctx, type.charAt(0).toUpperCase() + type.slice(1), [
@@ -1023,9 +3118,19 @@ async function showTaskTypeMenu(
1023
3118
  if (!choice || choice === "Back") return BACK;
1024
3119
 
1025
3120
  if (choice === "New") {
1026
- const description = await promptDescription(ctx, inputPrompt, type);
1027
- if (!description) continue;
1028
- await orchestrator.startTask(ctx, type, description);
3121
+ let mode: TaskMode | undefined;
3122
+ let autonomousConfig: AutonomousConfig | undefined;
3123
+ if (type === "debug") {
3124
+ const modeSelection = await pickModeForTaskStart(orchestrator, ctx, type);
3125
+ if (!modeSelection) continue;
3126
+ mode = modeSelection.mode;
3127
+ autonomousConfig = modeSelection.autonomousConfig;
3128
+ }
3129
+ await orchestrator.startTask(ctx, type, type, undefined, undefined, mode);
3130
+ if (orchestrator.active) {
3131
+ orchestrator.active.state.autonomousConfig = autonomousConfig;
3132
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
3133
+ }
1029
3134
  return "started";
1030
3135
  }
1031
3136
 
@@ -1037,23 +3142,24 @@ async function showTaskTypeMenu(
1037
3142
  async function showTaskMenu(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK | "started"> {
1038
3143
  while (true) {
1039
3144
  const choice = await selectOption(ctx, "Task", [
1040
- { title: "Debug", description: "Diagnose an issue. Then (optionally) fix it" },
1041
- { title: "Brainstorm", description: "Explore and brainstorm. Then (optionally) plan and implement" },
1042
- { title: "Implement", description: "Brainstorm, plan and implement" },
1043
- { title: "Review", description: "Review code changes, diffs, or pull requests" },
3145
+ { title: "Implement", description: "Want to make some changes? Research any topic or a codebase, brainstorm solutions and implement the chosen one" },
3146
+ { title: "Debug", description: "Something is broken? Investigate it. If there is an issue — brainstorm solutions and fix it" },
3147
+ { title: "Brainstorm", description: "No idea where to start? Research any topic or a codebase. If there is a problem to solve — brainstorm solutions and solve it" },
3148
+ { title: "Review", description: "Want to ensure that some commits or a GitHub PR are good to go? Review it. Even fix it yourself, if you want" },
3149
+ { title: "Quick", description: "Quick freeform task — no phases, no reviews, just work" },
1044
3150
  { title: "Resume", description: "Resume a previously unfinished task" },
1045
3151
  { title: "Back", description: "Return to the previous menu" },
1046
3152
  ]);
1047
3153
  if (!choice || choice === "Back") return BACK;
1048
3154
 
1049
3155
  if (choice === "Debug") {
1050
- const result = await showTaskTypeMenu(orchestrator, ctx, "debug", "Describe the task");
3156
+ const result = await showTaskTypeMenu(orchestrator, ctx, "debug");
1051
3157
  if (result === "started") return "started";
1052
3158
  continue;
1053
3159
  }
1054
3160
 
1055
3161
  if (choice === "Brainstorm") {
1056
- const result = await showTaskTypeMenu(orchestrator, ctx, "brainstorm", "Describe the task");
3162
+ const result = await showTaskTypeMenu(orchestrator, ctx, "brainstorm");
1057
3163
  if (result === "started") return "started";
1058
3164
  continue;
1059
3165
  }
@@ -1070,6 +3176,11 @@ async function showTaskMenu(orchestrator: Orchestrator, ctx: any): Promise<typeo
1070
3176
  continue;
1071
3177
  }
1072
3178
 
3179
+ if (choice === "Quick") {
3180
+ await orchestrator.startTask(ctx, "quick", "quick");
3181
+ return "started";
3182
+ }
3183
+
1073
3184
  if (choice === "Resume") {
1074
3185
  const result = await showResumeMenu(orchestrator, ctx, undefined, "No paused tasks found.");
1075
3186
  if (result === "started") return "started";
@@ -1082,7 +3193,7 @@ async function showNoActiveMenu(orchestrator: Orchestrator, ctx: any): Promise<s
1082
3193
  while (true) {
1083
3194
  const choice = await selectOption(ctx, "/pp", [
1084
3195
  { title: "Task", description: "Start a new task or resume a paused one" },
1085
- { title: "Info", description: "Subagents, LSP, usage, and task status" },
3196
+ { title: "Info", description: "Subagents, usage, and task status" },
1086
3197
  { title: "Settings", description: "Flant AI and other configuration" },
1087
3198
  { title: "Back", description: "Close this menu" },
1088
3199
  ]);
@@ -1103,25 +3214,20 @@ async function showNoActiveMenu(orchestrator: Orchestrator, ctx: any): Promise<s
1103
3214
  }
1104
3215
  }
1105
3216
 
1106
- function getReviewLabels(orchestrator: Orchestrator): { autoLabel: string; deepLabel: string } {
1107
- const byKind = orchestrator.active?.state.reviewPassByKind ?? {};
1108
- const autoCount = byKind["auto"] ?? 0;
1109
- const deepCount = byKind["auto-deep"] ?? 0;
3217
+ function getReviewLabels(orchestrator: Orchestrator): { autoLabel: string } {
3218
+ const phase = orchestrator.active?.state.phase;
3219
+ const byPhase = (phase && orchestrator.active?.state.reviewPassByKind?.[phase]) ?? {};
3220
+ const autoCount = byPhase["auto"] ?? 0;
1110
3221
  const autoLabel = autoCount > 0 ? `Auto review (pass ${autoCount + 1})` : "Auto review";
1111
- const deepLabel = deepCount > 0 ? `Auto deep review (pass ${deepCount + 1})` : "Auto deep review";
1112
- return { autoLabel, deepLabel };
3222
+ return { autoLabel };
1113
3223
  }
1114
3224
 
1115
- function hasEnabledReviewers(orchestrator: Orchestrator, kind: "auto" | "auto-deep"): boolean {
3225
+ function hasEnabledReviewers(orchestrator: Orchestrator, presetName?: string): boolean {
1116
3226
  if (!orchestrator.active) return false;
1117
3227
  const phase = orchestrator.active.state.phase;
1118
- const config = kind === "auto-deep" ? deepReviewConfig(orchestrator.config) : orchestrator.config;
1119
- const reviewers = phase === "brainstorm"
1120
- ? config.brainstormReviewers
1121
- : phase === "plan"
1122
- ? config.planReviewers
1123
- : config.codeReviewers;
1124
- return Object.values(reviewers).some((v) => v.enabled);
3228
+ const group = getReviewPresetGroup(phase);
3229
+ const reviewers = resolvePreset(orchestrator.config, group, presetName);
3230
+ return Object.values(reviewers).some((v) => isEnabled(v));
1125
3231
  }
1126
3232
 
1127
3233
  function handleReviewResult(ctx: any, text: string): { continueLoop: boolean; text?: string } {
@@ -1132,6 +3238,44 @@ function handleReviewResult(ctx: any, text: string): { continueLoop: boolean; te
1132
3238
  return { continueLoop: false, text };
1133
3239
  }
1134
3240
 
3241
+ async function showQuickTaskMenu(
3242
+ orchestrator: Orchestrator,
3243
+ ctx: any,
3244
+ summary: string,
3245
+ mode: MenuMode,
3246
+ ): Promise<string> {
3247
+ while (true) {
3248
+ if (!orchestrator.active) return "No active task.";
3249
+ const task = orchestrator.active;
3250
+ const headerLines = [`/pp\n\nTask: ${task.type}\nPhase: ${task.state.phase}`];
3251
+ if (summary !== "/pp") headerLines.push(`\n\n${summary}`);
3252
+ const menuTitle = headerLines.join("");
3253
+
3254
+ const choice = await selectOption(ctx, menuTitle, [
3255
+ opt("Complete", "Mark task as done and clean up"),
3256
+ opt("Pause", "Suspend task to resume later"),
3257
+ opt("Info", "Subagents, usage, and task status"),
3258
+ opt("Settings", "Flant AI and other configuration"),
3259
+ opt("Back", "Return to the prompt and keep working"),
3260
+ ]);
3261
+ if (!choice || choice === "Back") return "";
3262
+ if (choice === "Info") {
3263
+ await showInfoMenu(orchestrator, ctx);
3264
+ continue;
3265
+ }
3266
+ if (choice === "Settings") {
3267
+ await showSettingsMenu(orchestrator, ctx);
3268
+ continue;
3269
+ }
3270
+ if (choice === "Pause") {
3271
+ const text = await pauseTask(orchestrator, ctx);
3272
+ return mode === "tool" ? text : "";
3273
+ }
3274
+ const text = await finishTask(orchestrator, ctx);
3275
+ return mode === "tool" ? text : "";
3276
+ }
3277
+ }
3278
+
1135
3279
  export async function showActiveTaskMenu(
1136
3280
  orchestrator: Orchestrator,
1137
3281
  ctx: any,
@@ -1144,27 +3288,59 @@ export async function showActiveTaskMenu(
1144
3288
  if (!orchestrator.active) return "No active task.";
1145
3289
 
1146
3290
  const task = orchestrator.active;
3291
+ if (task.type === "quick") {
3292
+ return showQuickTaskMenu(orchestrator, ctx, summary, mode);
3293
+ }
1147
3294
  const phase = task.state.phase;
1148
3295
  const step = task.state.step;
3296
+ const effectiveMode = getEffectivePhaseMode(task.state);
1149
3297
 
1150
- const waiting = step === "await_planners" || step === "await_reviewers";
1151
- const { autoLabel, deepLabel } = getReviewLabels(orchestrator);
3298
+ // Suppress phase-advancing/review actions while a transition is in flight
3299
+ // (controller pending/compacting/resuming) OR while awaiting subagents. The
3300
+ // controller's isRunning() predicate already folds in the await_* steps.
3301
+ const waiting = !orchestrator.transitionController.isRunning();
3302
+ const { autoLabel } = getReviewLabels(orchestrator);
1152
3303
  const isReviewPhase = phase === "review";
1153
3304
  const hasPlannotator = phase === "plan" || phase === "implement" || isReviewPhase;
1154
3305
 
1155
3306
  const opt = (title: string, description: string): OptionInput => ({ title, description });
1156
3307
 
3308
+ if (effectiveMode === "autonomous") {
3309
+ const autoChoice = await selectOption(ctx, `/pp\n\nTask: ${task.type}\nPhase: ${phase}${summary !== "/pp" ? `\n\n${summary}` : ""}`, [
3310
+ opt("Complete task", "Mark task as done and clean up"),
3311
+ opt("Pause task", "Suspend task to resume later"),
3312
+ opt("Info", "Subagents, usage, and task status"),
3313
+ opt("Settings", "Flant AI and other configuration"),
3314
+ opt("Back", "Return to the prompt and keep working"),
3315
+ ]);
3316
+ if (!autoChoice || autoChoice === "Back") return "";
3317
+ if (autoChoice === "Info") {
3318
+ await showInfoMenu(orchestrator, ctx);
3319
+ continue;
3320
+ }
3321
+ if (autoChoice === "Settings") {
3322
+ await showSettingsMenu(orchestrator, ctx);
3323
+ continue;
3324
+ }
3325
+ if (autoChoice === "Complete task") {
3326
+ const text = await finishTask(orchestrator, ctx);
3327
+ return mode === "tool" ? text : "";
3328
+ }
3329
+ const text = await pauseTask(orchestrator, ctx);
3330
+ return mode === "tool" ? text : "";
3331
+ }
3332
+
1157
3333
  const options: OptionInput[] = [];
1158
3334
  options.push(opt("Next", "Complete, pause, or continue to next phase"));
1159
3335
  if (!waiting) {
1160
3336
  options.push(opt("Review", "Auto review, Plannotator, or manual review"));
1161
3337
  }
1162
- options.push(opt("Info", "Subagents, LSP, usage, and task status"));
3338
+ options.push(opt("Info", "Subagents, usage, and task status"));
1163
3339
  options.push(opt("Settings", "Flant AI and other configuration"));
1164
3340
  options.push(opt("Back", "Return to the prompt and keep working"));
1165
3341
 
1166
3342
  const headerLines = [`/pp\n\nTask: ${task.type}\nPhase: ${phase}`];
1167
- if (summary !== "/pp") headerLines.push(`\n${summary}`);
3343
+ if (summary !== "/pp") headerLines.push(`\n\n${summary}`);
1168
3344
  const menuTitle = headerLines.join("");
1169
3345
  const choice = await selectOption(ctx, menuTitle, options);
1170
3346
  if (!choice || choice === "Back") {
@@ -1179,7 +3355,6 @@ export async function showActiveTaskMenu(
1179
3355
  await showSettingsMenu(orchestrator, ctx);
1180
3356
  continue;
1181
3357
  }
1182
-
1183
3358
  if (mode === "command") {
1184
3359
  await abortCurrentWork(orchestrator, ctx);
1185
3360
  }
@@ -1205,19 +3380,39 @@ export async function showActiveTaskMenu(
1205
3380
  const text = await finishTask(orchestrator, ctx);
1206
3381
  return mode === "tool" ? text : "";
1207
3382
  }
3383
+ const next = nextPhase(task.type, phase);
3384
+
3385
+ if (
3386
+ next === "plan" &&
3387
+ task.type === "brainstorm"
3388
+ ) {
3389
+ const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "implement");
3390
+ if (!modeSelection) continue;
3391
+ task.state.mode = modeSelection.mode;
3392
+ task.state.effectiveMode = undefined;
3393
+ task.state.autonomousConfig = modeSelection.autonomousConfig;
3394
+ saveTask(task.dir, task.state);
3395
+ }
3396
+
3397
+ let plannerPreset: string | undefined;
3398
+ if (next === "plan") {
3399
+ if (getEffectiveMode(task.state) !== "autonomous") {
3400
+ const pickedPlannerPreset = await pickPreset(ctx, orchestrator, "planners", "Planner preset");
3401
+ if (!pickedPlannerPreset) continue;
3402
+ plannerPreset = pickedPlannerPreset;
3403
+ }
3404
+ }
1208
3405
  finalizeReviewCycle(task);
1209
- const result = await orchestrator.transitionToNextPhase(ctx);
3406
+ const result = await orchestrator.transitionToNextPhase(ctx, plannerPreset);
1210
3407
  if (!result.ok) return `Transition blocked: ${result.error}`;
1211
- if (orchestrator.phaseCompactionPending || orchestrator.taskDoneCompactionPending) return "";
1212
- const curStep = orchestrator.active?.state.step;
1213
- if (curStep === "await_planners" || curStep === "await_reviewers") return "";
3408
+ // Transition is now owned by the controller (state running) or we're
3409
+ // awaiting subagents; either way the menu returns empty.
1214
3410
  return "";
1215
3411
  }
1216
3412
 
1217
3413
  if (choice === "Review") {
1218
3414
  const reviewOptions: OptionInput[] = [
1219
3415
  opt(autoLabel, "Run automated review with configured reviewers"),
1220
- opt(deepLabel, "Run automated review with higher thinking level"),
1221
3416
  ];
1222
3417
  if (hasPlannotator) {
1223
3418
  reviewOptions.push(opt("Review in Plannotator", phase === "plan" ? "Open plan review in browser" : "Open code diff review in browser"));
@@ -1238,56 +3433,70 @@ export async function showActiveTaskMenu(
1238
3433
  if (handled.continueLoop) continue;
1239
3434
  return handled.text ?? text;
1240
3435
  }
1241
- const diffChoice = await selectOption(ctx, "Review in Plannotator", [
1242
- opt("All branch changes", "Committed changes vs base branch"),
1243
- opt("Last commit", "Changes in the most recent commit"),
1244
- opt("Recent commits", "Choose how many recent commits to review"),
1245
- opt("Uncommitted changes", "Working directory changes"),
1246
- opt("Back", "Return to the previous menu"),
1247
- ]);
1248
- if (!diffChoice || diffChoice === "Back") continue;
1249
-
1250
- let diffType: string | undefined;
1251
- let defaultBranch: string | undefined;
1252
- if (diffChoice === "All branch changes") {
1253
- diffType = "branch";
1254
- } else if (diffChoice === "Last commit") {
1255
- diffType = "last-commit";
1256
- } else if (diffChoice === "Recent commits") {
1257
- let commits: Array<{ hash: string; message: string; age: string }> = [];
1258
- try {
1259
- const logResult = await orchestrator.pi.exec(
1260
- "git", ["log", "--oneline", "--format=%h\t%s\t%cr", "-15"],
1261
- { cwd: orchestrator.cwd, timeout: 5000 },
1262
- );
1263
- if (logResult.code === 0 && logResult.stdout.trim()) {
1264
- commits = logResult.stdout.trim().split("\n").map((line) => {
1265
- const [hash, message, age] = line.split("\t");
1266
- return { hash: hash || "", message: message || "", age: age || "" };
1267
- }).filter((c) => c.hash);
3436
+ const repos = getRegisteredRepos(orchestrator);
3437
+ const summaries: string[] = [];
3438
+ let stopReviewing = false;
3439
+
3440
+ for (const repo of repos) {
3441
+ if (stopReviewing) break;
3442
+
3443
+ while (true) {
3444
+ const diffChoice = await selectOption(ctx, `Review: ${formatRepoLabel(repo)}`, [
3445
+ opt("All branch changes", "Committed changes vs base branch"),
3446
+ opt("Last commit", "Changes in the most recent commit"),
3447
+ opt("Since commit", "Review all changes since a specific commit"),
3448
+ opt("Uncommitted changes", "Working directory changes"),
3449
+ opt("Skip this repo", "Move to the next repository"),
3450
+ opt("Done (stop reviewing)", "Stop iterating repositories"),
3451
+ ]);
3452
+
3453
+ if (!diffChoice || diffChoice === "Done (stop reviewing)") {
3454
+ stopReviewing = true;
3455
+ break;
1268
3456
  }
1269
- } catch {}
1270
- if (commits.length === 0) {
1271
- ctx.ui.notify("No commits found.", "info");
1272
- continue;
3457
+ if (diffChoice === "Skip this repo") {
3458
+ summaries.push(`${formatRepoLabel(repo)}: SKIPPED`);
3459
+ break;
3460
+ }
3461
+
3462
+ let diffType: string | undefined;
3463
+ let defaultBranch: string | undefined;
3464
+
3465
+ if (diffChoice === "All branch changes") {
3466
+ diffType = "branch";
3467
+ defaultBranch = await detectDefaultBranch(orchestrator, repos, repo.path);
3468
+ } else if (diffChoice === "Last commit") {
3469
+ diffType = "last-commit";
3470
+ } else if (diffChoice === "Since commit") {
3471
+ const pickedHash = await pickCommitForRepo(orchestrator, ctx, repo);
3472
+ if (!pickedHash) continue;
3473
+ diffType = "branch";
3474
+ defaultBranch = pickedHash;
3475
+ } else {
3476
+ diffType = "uncommitted";
3477
+ }
3478
+
3479
+ const result = await openCodeReviewInPlannotator(orchestrator, {
3480
+ cwd: repo.path,
3481
+ diffType,
3482
+ defaultBranch,
3483
+ });
3484
+ if (result.status === "error") {
3485
+ summaries.push(`${formatRepoLabel(repo)}: ERROR${result.error ? ` — ${result.error}` : ""}`);
3486
+ } else if (result.status === "approved") {
3487
+ summaries.push(`${formatRepoLabel(repo)}: APPROVED`);
3488
+ } else {
3489
+ summaries.push(`${formatRepoLabel(repo)}: NEEDS_CHANGES${result.feedback ? `\nFeedback: ${result.feedback}` : ""}`);
3490
+ }
3491
+ break;
1273
3492
  }
1274
- const commitOptions: OptionInput[] = commits.map((c) => ({
1275
- title: `${c.hash} ${c.message}`,
1276
- description: c.age,
1277
- }));
1278
- commitOptions.push(opt("Back", "Return to the previous menu"));
1279
- const picked = await selectOption(ctx, "Review changes since:", commitOptions);
1280
- if (!picked || picked === "Back") continue;
1281
- const pickedHash = picked.split(" ")[0];
1282
- if (!pickedHash) continue;
1283
- diffType = "branch";
1284
- defaultBranch = pickedHash;
1285
- } else {
1286
- diffType = "uncommitted";
1287
3493
  }
1288
3494
 
1289
- const text = await openCodeReviewInPlannotator(orchestrator, diffType, defaultBranch);
1290
- ctx.ui.notify(text, "info");
3495
+ if (summaries.length > 0) {
3496
+ ctx.ui.notify(`Plannotator review summary:\n\n${summaries.join("\n\n")}`, "info");
3497
+ } else {
3498
+ ctx.ui.notify("No repositories were reviewed in Plannotator.", "info");
3499
+ }
1291
3500
  continue;
1292
3501
  }
1293
3502
 
@@ -1300,14 +3509,15 @@ export async function showActiveTaskMenu(
1300
3509
  return continueMessage;
1301
3510
  }
1302
3511
 
3512
+ const reviewPreset = await pickPreset(ctx, orchestrator, getReviewPresetGroup(phase), "Review preset");
3513
+ if (!reviewPreset) continue;
1303
3514
  finalizeReviewCycle(task);
1304
- const kind = reviewChoice === autoLabel ? "auto" as const : "auto-deep" as const;
1305
- if (!hasEnabledReviewers(orchestrator, kind)) {
3515
+ if (!hasEnabledReviewers(orchestrator, reviewPreset)) {
1306
3516
  const label = phase === "brainstorm" ? "brainstorm" : phase === "plan" ? "plan" : "code";
1307
3517
  ctx.ui.notify(`No ${label} reviewers enabled.`, "info");
1308
3518
  continue;
1309
3519
  }
1310
- const text = await enterReviewCycle(orchestrator, ctx, kind);
3520
+ const text = await enterReviewCycle(orchestrator, ctx, reviewPreset);
1311
3521
  const curStep = orchestrator.active?.state.step;
1312
3522
  if (curStep === "await_reviewers") return "";
1313
3523
  const handled = handleReviewResult(ctx, text);