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