@pixelsnis/pi-plan-mode 0.1.2
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/README.md +103 -0
- package/index.ts +1060 -0
- package/package.json +40 -0
- package/plan-file.ts +95 -0
- package/review-ui.ts +88 -0
- package/skills/plan-writing/SKILL.md +86 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1060 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { lstat, open, readFile, realpath, rename, unlink } from "node:fs/promises";
|
|
5
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
import { createPlanPath, readPlan, writePlan } from "./plan-file.ts";
|
|
9
|
+
import { showPlanReview, type ReviewChoice } from "./review-ui.ts";
|
|
10
|
+
|
|
11
|
+
const CONFIG_NAME = "plan-mode.json";
|
|
12
|
+
const STATE_TYPE = "plan-mode-state";
|
|
13
|
+
const PLAN_TOOLS = new Set(["read", "grep", "find", "ls", "plan_save", "plan_present"]);
|
|
14
|
+
const READ_ONLY_BASH_COMMANDS = new Set([
|
|
15
|
+
"pwd", "ls", "find", "grep", "rg", "cat", "head", "tail", "wc", "file", "stat",
|
|
16
|
+
]);
|
|
17
|
+
const GIT_READ_ONLY_SUBCOMMANDS = new Set(["status", "diff", "log", "show"]);
|
|
18
|
+
|
|
19
|
+
/** Parse plain words, quoted literals, pipelines, and safe && chains. Anything
|
|
20
|
+
* the small grammar cannot represent is rejected instead of being delegated to a shell. */
|
|
21
|
+
function parseReadOnlyBashCommand(command: unknown): string[][][] | undefined {
|
|
22
|
+
if (typeof command !== "string" || !command.trim() || /[\r\n\u2028\u2029\0\x00-\x08\x0b-\x1f\x7f]/.test(command)) return undefined;
|
|
23
|
+
|
|
24
|
+
const conjunctions: string[][][] = [];
|
|
25
|
+
let pipelines: string[][] = [];
|
|
26
|
+
let stage: string[] = [];
|
|
27
|
+
let word = "";
|
|
28
|
+
let hasWord = false;
|
|
29
|
+
let quote: "'" | '"' | undefined;
|
|
30
|
+
const finishWord = () => {
|
|
31
|
+
if (!hasWord) return;
|
|
32
|
+
stage.push(word);
|
|
33
|
+
word = "";
|
|
34
|
+
hasWord = false;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < command.length;) {
|
|
38
|
+
const char = command[i]!;
|
|
39
|
+
if (quote === "'") {
|
|
40
|
+
if (char === "'") quote = undefined;
|
|
41
|
+
else word += char;
|
|
42
|
+
i++;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (quote === '"') {
|
|
46
|
+
if (char === '"') {
|
|
47
|
+
quote = undefined;
|
|
48
|
+
i++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (char === "$" || char === "`" || char === "!") return undefined;
|
|
52
|
+
if (char === "\\") {
|
|
53
|
+
const next = command[i + 1];
|
|
54
|
+
if (next === undefined || next === "!") return undefined;
|
|
55
|
+
if (next === "$" || next === "`" || next === '"' || next === "\\") {
|
|
56
|
+
word += next;
|
|
57
|
+
i += 2;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// In double quotes, a backslash before any other character is literal.
|
|
61
|
+
word += char;
|
|
62
|
+
i++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
word += char;
|
|
66
|
+
i++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (char === "'" || char === '"') {
|
|
70
|
+
quote = char;
|
|
71
|
+
hasWord = true;
|
|
72
|
+
i++;
|
|
73
|
+
} else if (char === "|") {
|
|
74
|
+
if (command[i + 1] === "|") return undefined;
|
|
75
|
+
finishWord();
|
|
76
|
+
if (!stage.length) return undefined;
|
|
77
|
+
pipelines.push(stage);
|
|
78
|
+
stage = [];
|
|
79
|
+
i++;
|
|
80
|
+
} else if (char === "&") {
|
|
81
|
+
if (command[i + 1] !== "&") return undefined;
|
|
82
|
+
finishWord();
|
|
83
|
+
if (!stage.length) return undefined;
|
|
84
|
+
pipelines.push(stage);
|
|
85
|
+
conjunctions.push(pipelines);
|
|
86
|
+
pipelines = [];
|
|
87
|
+
stage = [];
|
|
88
|
+
i += 2;
|
|
89
|
+
} else if (char === " " || char === "\t") {
|
|
90
|
+
finishWord();
|
|
91
|
+
i++;
|
|
92
|
+
} else if (/[A-Za-z0-9_./:=,+@%-]/.test(char)) {
|
|
93
|
+
word += char;
|
|
94
|
+
hasWord = true;
|
|
95
|
+
i++;
|
|
96
|
+
} else {
|
|
97
|
+
// This excludes redirects, substitutions, glob expansion, comments,
|
|
98
|
+
// escaped syntax, and all other shell grammar/operators.
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (quote) return undefined;
|
|
103
|
+
finishWord();
|
|
104
|
+
if (!stage.length) return undefined;
|
|
105
|
+
pipelines.push(stage);
|
|
106
|
+
conjunctions.push(pipelines);
|
|
107
|
+
return conjunctions;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const FIND_VALUE_PREDICATES = new Set([
|
|
111
|
+
"-amin", "-anewer", "-atime", "-cmin", "-cnewer", "-ctime", "-gid", "-group",
|
|
112
|
+
"-iname", "-inum", "-ipath", "-iregex", "-iwholename", "-maxdepth", "-mindepth",
|
|
113
|
+
"-mmin", "-mtime", "-name", "-newer", "-path", "-perm", "-printf", "-regex",
|
|
114
|
+
"-size", "-samefile", "-type", "-uid", "-user", "-wholename",
|
|
115
|
+
]);
|
|
116
|
+
const FIND_SAFE_PREDICATES = new Set([
|
|
117
|
+
"-a", "-and", "-daystart", "-depth", "-empty", "-executable", "-false", "-follow",
|
|
118
|
+
"-ignore_readdir_race", "-ls", "-mount", "-noleaf", "-noignore_readdir_race", "-not",
|
|
119
|
+
"-o", "-or", "-print", "-print0", "-quit", "-readable", "-true", "-writable", "-xdev", ",",
|
|
120
|
+
]);
|
|
121
|
+
const FIND_TYPES = new Set(["b", "c", "d", "f", "l", "p", "s", "D", "w"]);
|
|
122
|
+
|
|
123
|
+
function isReadOnlyFind(args: string[]): boolean {
|
|
124
|
+
if (args.length === 0) return false;
|
|
125
|
+
for (let i = 0; i < args.length; i++) {
|
|
126
|
+
const arg = args[i]!;
|
|
127
|
+
if (/^-(?:exec|ok|delete|fprint|fprintf|fls)/i.test(arg)) return false;
|
|
128
|
+
if (FIND_VALUE_PREDICATES.has(arg)) {
|
|
129
|
+
if (i + 1 >= args.length) return false;
|
|
130
|
+
if (arg === "-type" && !FIND_TYPES.has(args[i + 1]!)) return false;
|
|
131
|
+
if ((arg === "-maxdepth" || arg === "-mindepth") && !/^\d+$/.test(args[i + 1]!)) return false;
|
|
132
|
+
i++;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (FIND_SAFE_PREDICATES.has(arg) || arg === "-H" || arg === "-L" || arg === "-P") continue;
|
|
136
|
+
// Starting paths are inert operands. Unknown find options/actions fail closed.
|
|
137
|
+
if (arg.startsWith("-")) return false;
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hasDangerousReadOption(command: string, args: string[]): boolean {
|
|
143
|
+
if ((command === "rg" || command === "grep") && args.some((arg) => /^--pre(?:-glob)?(?:=|$)/.test(arg))) return true;
|
|
144
|
+
if (command === "tail" && args.some((arg) => /^-[^-]*[fF]/.test(arg) || /^--follow(?:=|$)/.test(arg))) return true;
|
|
145
|
+
if (command === "file" && args.some((arg) => /^--compile(?:=|$)/.test(arg) || /^-[^-]*C/.test(arg))) return true;
|
|
146
|
+
if (command === "git") {
|
|
147
|
+
const blockedGitOptions = /^(?:--(?:output|ext-diff|textconv|paginate|pager|exec-path|config-env)(?:=|$)|-c$|-o)/;
|
|
148
|
+
if (args.some((arg) => blockedGitOptions.test(arg))) return true;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isReadOnlyBashCommand(command: unknown): boolean {
|
|
154
|
+
const conjunctions = parseReadOnlyBashCommand(command);
|
|
155
|
+
if (!conjunctions) return false;
|
|
156
|
+
return conjunctions.every((pipelines) => pipelines.every((tokens) => {
|
|
157
|
+
const [name, ...args] = tokens;
|
|
158
|
+
if (!name) return false;
|
|
159
|
+
if (name === "git") {
|
|
160
|
+
return GIT_READ_ONLY_SUBCOMMANDS.has(args[0] ?? "") && !hasDangerousReadOption(name, args);
|
|
161
|
+
}
|
|
162
|
+
if (!READ_ONLY_BASH_COMMANDS.has(name)) return false;
|
|
163
|
+
if (name === "find") return isReadOnlyFind(args);
|
|
164
|
+
return !hasDangerousReadOption(name, args);
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Intentionally identical in Plan and Build. Mode-specific state is a separate
|
|
169
|
+
// custom context message so toggling modes never rebuilds the system prompt.
|
|
170
|
+
const STATIC_SYSTEM_INSTRUCTIONS = `\n\n## Plan-mode extension workflow\nThe packaged skill named plan-writing contains the canonical, generic implementation-planning instructions. Whenever the extension context says Plan mode is active, load and follow the discovered plan-writing skill before drafting or refining a plan. Use this extension's plan_save tool to write the plan to its extension-owned file, then call plan_present with only the exact relative path returned by plan_save. plan_present displays that file for explicit user review; do not send the plan inline, treat a tool call as approval, or execute it until the user approves in the review UI. In Plan mode, take no resource-mutating action: plan_save is the only write capability. This instruction is fixed across modes; the extension supplies the current mode separately.`;
|
|
171
|
+
|
|
172
|
+
type ModelRef = { provider: string; id: string };
|
|
173
|
+
type Mode = "plan" | "build";
|
|
174
|
+
type PiThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
175
|
+
type ConfiguredEffort = "default" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
176
|
+
type ModeModelConfig = { id: string; effort: ConfiguredEffort };
|
|
177
|
+
type ProfileConfig = { plan?: ModeModelConfig; build?: ModeModelConfig };
|
|
178
|
+
type PlanConfig = {
|
|
179
|
+
profiles: Record<string, ProfileConfig>;
|
|
180
|
+
profileOrder: string[];
|
|
181
|
+
selectedProfile?: string;
|
|
182
|
+
legacy: boolean;
|
|
183
|
+
};
|
|
184
|
+
type LoadedPlanConfig = { config: PlanConfig; path: string; raw?: string; error?: string };
|
|
185
|
+
type ProfileSelection = { name: string; profile: ProfileConfig };
|
|
186
|
+
type PersistedState = {
|
|
187
|
+
mode: Mode;
|
|
188
|
+
planPath?: string;
|
|
189
|
+
prePlanModel?: ModelRef;
|
|
190
|
+
prePlanEffort?: PiThinkingLevel;
|
|
191
|
+
};
|
|
192
|
+
type PendingApproval = {
|
|
193
|
+
token: string;
|
|
194
|
+
choice: "execute-new" | "execute-here";
|
|
195
|
+
path: string;
|
|
196
|
+
sessionId: string;
|
|
197
|
+
profileName?: string;
|
|
198
|
+
profileSnapshot?: ProfileConfig;
|
|
199
|
+
};
|
|
200
|
+
type FreshHandoff = {
|
|
201
|
+
token: string;
|
|
202
|
+
path: string;
|
|
203
|
+
ownerSessionId: string;
|
|
204
|
+
profileName?: string;
|
|
205
|
+
profileSnapshot?: ProfileConfig;
|
|
206
|
+
};
|
|
207
|
+
const FRESH_HANDOFF_TYPE = "plan-mode-fresh-handoff";
|
|
208
|
+
const FRESH_HANDOFF_USED_TYPE = "plan-mode-fresh-handoff-used";
|
|
209
|
+
|
|
210
|
+
function modelRef(model: ExtensionContext["model"]): ModelRef | undefined {
|
|
211
|
+
return model ? { provider: model.provider, id: model.id } : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function modelKey(model: ModelRef | undefined): string | undefined {
|
|
215
|
+
return model ? `${model.provider}/${model.id}` : undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function parseModelRef(value: unknown, key: string): string | undefined {
|
|
219
|
+
if (value === undefined) return undefined;
|
|
220
|
+
if (typeof value !== "string" || value.trim() !== value) {
|
|
221
|
+
throw new Error(`${key} must be a provider/model-id string`);
|
|
222
|
+
}
|
|
223
|
+
const slash = value.indexOf("/");
|
|
224
|
+
if (slash <= 0 || slash === value.length - 1 || /\s/.test(value)) {
|
|
225
|
+
throw new Error(`${key} must have the form provider/model-id`);
|
|
226
|
+
}
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseModeModel(value: unknown, key: string): ModeModelConfig | undefined {
|
|
231
|
+
if (value === undefined) return undefined;
|
|
232
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
233
|
+
throw new Error(`${key} must be an object with id and effort`);
|
|
234
|
+
}
|
|
235
|
+
const record = value as Record<string, unknown>;
|
|
236
|
+
const unknownKeys = Object.keys(record).filter((field) => field !== "id" && field !== "effort");
|
|
237
|
+
if (unknownKeys.length) throw new Error(`${key} has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
238
|
+
const id = parseModelRef(record.id, `${key}.id`);
|
|
239
|
+
if (!id) throw new Error(`${key}.id is required`);
|
|
240
|
+
const effort = record.effort;
|
|
241
|
+
if (effort !== "default" && effort !== "low" && effort !== "medium" && effort !== "high" && effort !== "xhigh" && effort !== "max") {
|
|
242
|
+
throw new Error(`${key}.effort must be default, low, medium, high, xhigh, or max`);
|
|
243
|
+
}
|
|
244
|
+
return { id, effort };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isPiThinkingLevel(value: unknown): value is PiThinkingLevel {
|
|
248
|
+
return value === "off" || value === "minimal" || value === "low" || value === "medium" ||
|
|
249
|
+
value === "high" || value === "xhigh" || value === "max";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function toPiThinkingLevel(effort: ConfiguredEffort): PiThinkingLevel | undefined {
|
|
253
|
+
if (effort === "default") return undefined;
|
|
254
|
+
return effort;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function configFilePath(): string {
|
|
258
|
+
const configDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
259
|
+
return resolve(configDir, CONFIG_NAME);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function emptyConfig(): PlanConfig {
|
|
263
|
+
return { profiles: Object.create(null) as Record<string, ProfileConfig>, profileOrder: [], legacy: false };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function assertNoDuplicateProfileKeys(raw: string): string[] {
|
|
267
|
+
let index = 0;
|
|
268
|
+
const profileOrder: string[] = [];
|
|
269
|
+
function skipWhitespace(): void {
|
|
270
|
+
while (/\s/.test(raw[index] ?? "")) index++;
|
|
271
|
+
}
|
|
272
|
+
function readString(): string {
|
|
273
|
+
const start = index++;
|
|
274
|
+
while (index < raw.length) {
|
|
275
|
+
const char = raw[index++];
|
|
276
|
+
if (char === "\\") index++;
|
|
277
|
+
else if (char === '"') break;
|
|
278
|
+
}
|
|
279
|
+
return JSON.parse(raw.slice(start, index)) as string;
|
|
280
|
+
}
|
|
281
|
+
function readObject(checkProfileKeys: boolean, isRoot = false): void {
|
|
282
|
+
index++; // {
|
|
283
|
+
skipWhitespace();
|
|
284
|
+
if (raw[index] === "}") { index++; return; }
|
|
285
|
+
const seen = new Set<string>();
|
|
286
|
+
while (index < raw.length) {
|
|
287
|
+
skipWhitespace();
|
|
288
|
+
const key = readString();
|
|
289
|
+
if (seen.has(key)) throw new Error(checkProfileKeys ? `duplicate profile key: ${key}` : `duplicate JSON key: ${key}`);
|
|
290
|
+
seen.add(key);
|
|
291
|
+
if (checkProfileKeys) profileOrder.push(key);
|
|
292
|
+
skipWhitespace();
|
|
293
|
+
index++; // : (JSON.parse has already validated the syntax)
|
|
294
|
+
skipWhitespace();
|
|
295
|
+
readValue(isRoot && key === "profiles");
|
|
296
|
+
skipWhitespace();
|
|
297
|
+
if (raw[index] === "}") { index++; return; }
|
|
298
|
+
index++; // ,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function readValue(checkProfileKeys = false): void {
|
|
302
|
+
skipWhitespace();
|
|
303
|
+
if (raw[index] === "{") return readObject(checkProfileKeys);
|
|
304
|
+
if (raw[index] === "[") {
|
|
305
|
+
index++;
|
|
306
|
+
skipWhitespace();
|
|
307
|
+
if (raw[index] === "]") { index++; return; }
|
|
308
|
+
while (index < raw.length) {
|
|
309
|
+
readValue();
|
|
310
|
+
skipWhitespace();
|
|
311
|
+
if (raw[index] === "]") { index++; return; }
|
|
312
|
+
index++; // ,
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (raw[index] === '"') { readString(); return; }
|
|
317
|
+
while (index < raw.length && !/[\s,}\]]/.test(raw[index]!)) index++;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
skipWhitespace();
|
|
321
|
+
if (raw[index] === "{") readObject(false, true);
|
|
322
|
+
return profileOrder;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function parseConfig(raw: string): PlanConfig {
|
|
326
|
+
const value: unknown = JSON.parse(raw);
|
|
327
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
328
|
+
throw new Error("configuration must be a JSON object");
|
|
329
|
+
}
|
|
330
|
+
const profileOrder = assertNoDuplicateProfileKeys(raw);
|
|
331
|
+
const record = value as Record<string, unknown>;
|
|
332
|
+
const hasProfiles = Object.prototype.hasOwnProperty.call(record, "profiles");
|
|
333
|
+
const hasLegacyPair = Object.prototype.hasOwnProperty.call(record, "plan") ||
|
|
334
|
+
Object.prototype.hasOwnProperty.call(record, "build");
|
|
335
|
+
|
|
336
|
+
if (hasProfiles && hasLegacyPair) throw new Error("profiles cannot be mixed with top-level plan/build settings");
|
|
337
|
+
if (hasProfiles) {
|
|
338
|
+
const unknownKeys = Object.keys(record).filter((key) => key !== "profiles" && key !== "selectedProfile");
|
|
339
|
+
if (unknownKeys.length) throw new Error(`unknown key(s): ${unknownKeys.join(", ")}`);
|
|
340
|
+
const source = record.profiles;
|
|
341
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
|
342
|
+
throw new Error("profiles must be an object containing 1–5 named profiles");
|
|
343
|
+
}
|
|
344
|
+
const names = profileOrder;
|
|
345
|
+
if (names.length < 1 || names.length > 5) throw new Error(`profiles must contain 1–5 entries; found ${names.length}`);
|
|
346
|
+
const profiles = Object.create(null) as Record<string, ProfileConfig>;
|
|
347
|
+
for (const name of names) {
|
|
348
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name) || name.trim() !== name ||
|
|
349
|
+
name === "__proto__" || name === "constructor" || name === "prototype") {
|
|
350
|
+
throw new Error(`invalid profile name ${JSON.stringify(name)}; use a safe single token such as codex`);
|
|
351
|
+
}
|
|
352
|
+
const item = (source as Record<string, unknown>)[name];
|
|
353
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
354
|
+
throw new Error(`profiles.${name} must contain both plan and build settings`);
|
|
355
|
+
}
|
|
356
|
+
const pair = item as Record<string, unknown>;
|
|
357
|
+
const unknownFields = Object.keys(pair).filter((field) => field !== "plan" && field !== "build");
|
|
358
|
+
if (unknownFields.length) throw new Error(`profiles.${name} has unknown key(s): ${unknownFields.join(", ")}`);
|
|
359
|
+
const plan = parseModeModel(pair.plan, `profiles.${name}.plan`);
|
|
360
|
+
const build = parseModeModel(pair.build, `profiles.${name}.build`);
|
|
361
|
+
if (!plan || !build) throw new Error(`profiles.${name} requires both plan and build settings`);
|
|
362
|
+
profiles[name] = { plan, build };
|
|
363
|
+
}
|
|
364
|
+
let selectedProfile: string | undefined;
|
|
365
|
+
if (Object.prototype.hasOwnProperty.call(record, "selectedProfile")) {
|
|
366
|
+
if (typeof record.selectedProfile !== "string") throw new Error("selectedProfile must name a configured profile");
|
|
367
|
+
selectedProfile = record.selectedProfile;
|
|
368
|
+
if (!Object.prototype.hasOwnProperty.call(profiles, selectedProfile)) {
|
|
369
|
+
throw new Error(`selectedProfile ${JSON.stringify(selectedProfile)} does not name a configured profile`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return { profiles, profileOrder: names, selectedProfile, legacy: false };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const unknownKeys = Object.keys(record).filter((key) => key !== "plan" && key !== "build");
|
|
376
|
+
if (unknownKeys.length) throw new Error(`unknown key(s): ${unknownKeys.join(", ")}`);
|
|
377
|
+
const plan = parseModeModel(record.plan, "plan");
|
|
378
|
+
const build = parseModeModel(record.build, "build");
|
|
379
|
+
const profiles = Object.create(null) as Record<string, ProfileConfig>;
|
|
380
|
+
const legacyOrder = plan || build ? ["default"] : [];
|
|
381
|
+
if (legacyOrder.length) profiles.default = { plan, build };
|
|
382
|
+
return { profiles, profileOrder: legacyOrder, legacy: true };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function loadConfig(): Promise<LoadedPlanConfig> {
|
|
386
|
+
const path = configFilePath();
|
|
387
|
+
let raw: string;
|
|
388
|
+
try {
|
|
389
|
+
raw = await readFile(path, "utf8");
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { config: emptyConfig(), path };
|
|
392
|
+
return { config: emptyConfig(), path, error: `Cannot read ${path}: ${String(error)}` };
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
return { config: parseConfig(raw), path, raw };
|
|
396
|
+
} catch (error) {
|
|
397
|
+
return {
|
|
398
|
+
config: emptyConfig(),
|
|
399
|
+
path,
|
|
400
|
+
raw,
|
|
401
|
+
error: `Invalid ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function profileSelection(config: PlanConfig): ProfileSelection | undefined {
|
|
407
|
+
const name = config.selectedProfile ?? config.profileOrder[0];
|
|
408
|
+
return name && Object.prototype.hasOwnProperty.call(config.profiles, name)
|
|
409
|
+
? { name, profile: config.profiles[name]! }
|
|
410
|
+
: undefined;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function sameProfile(a: ProfileConfig | undefined, b: ProfileConfig | undefined): boolean {
|
|
414
|
+
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function settingsRef(settings: ModeModelConfig | undefined): ModelRef | undefined {
|
|
418
|
+
if (!settings) return undefined;
|
|
419
|
+
const slash = settings.id.indexOf("/");
|
|
420
|
+
return { provider: settings.id.slice(0, slash), id: settings.id.slice(slash + 1) };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function selectedConfigText(config: PlanConfig, name: string): string {
|
|
424
|
+
const entries = config.profileOrder.map((profileName) => {
|
|
425
|
+
const formatted = JSON.stringify(config.profiles[profileName]!, null, 2)!.replaceAll("\n", "\n ");
|
|
426
|
+
return ` ${JSON.stringify(profileName)}: ${formatted}`;
|
|
427
|
+
});
|
|
428
|
+
return `{
|
|
429
|
+
"profiles": {
|
|
430
|
+
${entries.join(",\n")}
|
|
431
|
+
},
|
|
432
|
+
"selectedProfile": ${JSON.stringify(name)}
|
|
433
|
+
}\n`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function readRawConfig(path: string): Promise<string | undefined> {
|
|
437
|
+
try {
|
|
438
|
+
return await readFile(path, "utf8");
|
|
439
|
+
} catch (error) {
|
|
440
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
441
|
+
throw error;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function writeConfigAtomically(path: string, expectedRaw: string | undefined, content: string): Promise<void> {
|
|
446
|
+
const temporaryPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
447
|
+
let temporaryExists = false;
|
|
448
|
+
try {
|
|
449
|
+
const file = await open(temporaryPath, "wx", 0o600);
|
|
450
|
+
temporaryExists = true;
|
|
451
|
+
try {
|
|
452
|
+
await file.writeFile(content, { encoding: "utf8" });
|
|
453
|
+
await file.sync();
|
|
454
|
+
} finally {
|
|
455
|
+
await file.close();
|
|
456
|
+
}
|
|
457
|
+
if (await readRawConfig(path) !== expectedRaw) {
|
|
458
|
+
throw new Error("plan-mode.json changed during profile selection; no settings were overwritten");
|
|
459
|
+
}
|
|
460
|
+
await rename(temporaryPath, path);
|
|
461
|
+
temporaryExists = false;
|
|
462
|
+
} finally {
|
|
463
|
+
if (temporaryExists) await unlink(temporaryPath).catch(() => {});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function latestState(ctx: ExtensionContext): PersistedState | undefined {
|
|
468
|
+
const branch = ctx.sessionManager.getBranch();
|
|
469
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
470
|
+
const entry = branch[i] as { type?: string; customType?: string; data?: unknown };
|
|
471
|
+
if (entry.type !== "custom" || entry.customType !== STATE_TYPE || !entry.data || typeof entry.data !== "object") continue;
|
|
472
|
+
const data = entry.data as Record<string, unknown>;
|
|
473
|
+
if (data.mode !== "plan" && data.mode !== "build") continue;
|
|
474
|
+
const ref = data.prePlanModel;
|
|
475
|
+
const prePlanModel = ref && typeof ref === "object" &&
|
|
476
|
+
typeof (ref as ModelRef).provider === "string" && typeof (ref as ModelRef).id === "string"
|
|
477
|
+
? { provider: (ref as ModelRef).provider, id: (ref as ModelRef).id }
|
|
478
|
+
: undefined;
|
|
479
|
+
return {
|
|
480
|
+
mode: data.mode,
|
|
481
|
+
planPath: typeof data.planPath === "string" ? data.planPath : undefined,
|
|
482
|
+
prePlanModel,
|
|
483
|
+
prePlanEffort: isPiThinkingLevel(data.prePlanEffort) ? data.prePlanEffort : undefined,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
return undefined;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function findModel(ctx: ExtensionContext, ref: ModelRef): Promise<NonNullable<ExtensionContext["model"]>> {
|
|
490
|
+
const model = ctx.modelRegistry.find(ref.provider, ref.id);
|
|
491
|
+
if (!model) throw new Error(`Model not found: ${ref.provider}/${ref.id}`);
|
|
492
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
493
|
+
if (!auth.ok) throw new Error(`Model is unavailable: ${auth.error}`);
|
|
494
|
+
return model;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function makeSlug(value: string): string {
|
|
498
|
+
return value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "plan";
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function gitAdminDir(pi: ExtensionAPI, cwd: string): Promise<string | undefined> {
|
|
502
|
+
const root = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd }).catch(() => undefined);
|
|
503
|
+
if (!root || root.code !== 0 || !root.stdout.trim()) return undefined;
|
|
504
|
+
const admin = await pi.exec("git", ["-C", root.stdout.trim(), "rev-parse", "--absolute-git-dir"], { cwd }).catch(() => undefined);
|
|
505
|
+
return admin?.code === 0 && admin.stdout.trim() ? admin.stdout.trim() : undefined;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function isOwnedPlanPath(pi: ExtensionAPI, ctx: ExtensionContext, path: string, ownerSessionId = ctx.sessionManager.getSessionId()): Promise<boolean> {
|
|
509
|
+
const sessionKey = makeSlug(ownerSessionId).slice(-20) || "session";
|
|
510
|
+
const filename = basename(path);
|
|
511
|
+
const prefix = `plan-${sessionKey}-`;
|
|
512
|
+
if (resolve(path) !== path || !filename.startsWith(prefix) || !/^\d+-plan\.md$/.test(filename.slice(prefix.length))) return false;
|
|
513
|
+
try {
|
|
514
|
+
const folder = dirname(path);
|
|
515
|
+
if (await realpath(folder) !== folder) return false;
|
|
516
|
+
const fileInfo = await lstat(path);
|
|
517
|
+
if (fileInfo.isSymbolicLink() || !fileInfo.isFile() || fileInfo.nlink > 1) return false;
|
|
518
|
+
const admin = await gitAdminDir(pi, ctx.cwd);
|
|
519
|
+
if (admin) {
|
|
520
|
+
const expected = join(await realpath(resolve(admin)), "implementation-plans");
|
|
521
|
+
if (folder === expected) return true;
|
|
522
|
+
}
|
|
523
|
+
const tempRoot = await realpath(tmpdir());
|
|
524
|
+
return dirname(folder) === tempRoot && basename(folder).startsWith(`${makeSlug(basename(resolve(ctx.cwd)))}-implementation-plans.`);
|
|
525
|
+
} catch {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function formatPlanPath(path: string, cwd: string): string {
|
|
531
|
+
return relative(cwd, path);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function planContext(mode: Mode, path: string | undefined): string {
|
|
535
|
+
if (mode === "plan") {
|
|
536
|
+
return `[PLAN MODE ACTIVE]\nYou are planning only. Take no resource-mutating actions. The tool guard permits read, grep, find, ls, plan_save, and plan_present; Bash is conditionally available only for simple read-only commands from pwd, ls, find, grep, rg, cat, head, tail, wc, file, stat, and Git status/diff/log/show. Pipelines and && chains are allowed only when every command independently passes the same checks; for example, pwd && ls -la, rg -n 'PLAN_TOOLS\\b' extensions/plan-mode/index.ts, rg -n '.*;$' extensions/plan-mode/index.ts, and rg -n 'PLAN_TOOLS|READ_ONLY_BASH_COMMANDS' extensions/plan-mode/index.ts | head -5 && git status --short --branch. Single-quoted text is literal; double-quoted text rejects unescaped $, backticks, and !. Other shell chaining/operators (including ;, ||, and &), redirection, substitutions, unrecognized commands, mutating/execution options, and interactive !/!! commands are blocked. Load and follow the discovered plan-writing skill before creating or refining the implementation plan. Inspect relevant project sources and instructions; keep all planning edits inside the extension-owned plan file. Use plan_save({content}) to create/update that file, then call plan_present({path}) with only the exact relative path returned by plan_save. Never present the plan inline or execute it. Only an explicit approval selection inside the review UI authorizes execution.\nCurrent plan file: ${path ?? "not created yet; plan_save will create it"}`;
|
|
537
|
+
}
|
|
538
|
+
return `[BUILD MODE ACTIVE]\nThe user has switched to Build mode. Follow the latest user request normally with the available tools. If the user approved a plan, read the approved plan file and follow its steps; if it is missing, stop and ask rather than guessing.\nPlan file: ${path ?? "none"}`;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export default function planMode(pi: ExtensionAPI): void {
|
|
542
|
+
let mode: Mode = "build";
|
|
543
|
+
let planPath: string | undefined;
|
|
544
|
+
let prePlanModel: ModelRef | undefined;
|
|
545
|
+
let prePlanEffort: PiThinkingLevel | undefined;
|
|
546
|
+
let config: PlanConfig = emptyConfig();
|
|
547
|
+
let configError: string | undefined;
|
|
548
|
+
let pendingApproval: PendingApproval | undefined;
|
|
549
|
+
let reviewActive = false;
|
|
550
|
+
let profileChangeInProgress = false;
|
|
551
|
+
let modeTransitionInProgress = false;
|
|
552
|
+
let planPathPromise: Promise<string> | undefined;
|
|
553
|
+
|
|
554
|
+
const persistState = () => {
|
|
555
|
+
pi.appendEntry(STATE_TYPE, { mode, planPath, prePlanModel, prePlanEffort } satisfies PersistedState);
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
const updateBadge = (ctx: ExtensionContext) => {
|
|
559
|
+
const selected = profileSelection(config);
|
|
560
|
+
const suffix = selected ? ` · ${selected.name}` : "";
|
|
561
|
+
const label = mode === "plan"
|
|
562
|
+
? ctx.ui.theme.fg("text", ctx.ui.theme.bold(`PLAN · read-only${suffix}`))
|
|
563
|
+
: ctx.ui.theme.fg("muted", ctx.ui.theme.bold(`BUILD${suffix}`));
|
|
564
|
+
// A component avoids the one-column padding added to string-array widgets.
|
|
565
|
+
// The trailing blank row separates this badge from the built-in footer.
|
|
566
|
+
ctx.ui.setWidget("plan-mode-indicator", () => ({
|
|
567
|
+
render: () => [label, " "],
|
|
568
|
+
invalidate: () => {},
|
|
569
|
+
}), { placement: "belowEditor" });
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const notifyConfigError = (ctx: ExtensionContext) => {
|
|
573
|
+
if (configError) ctx.ui.notify(configError, "error");
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
async function switchToRef(ctx: ExtensionContext, ref: ModelRef | undefined): Promise<void> {
|
|
577
|
+
if (!ref) return;
|
|
578
|
+
const model = await findModel(ctx, ref);
|
|
579
|
+
if (modelKey(modelRef(ctx.model)) === modelKey(ref)) return;
|
|
580
|
+
if (!(await pi.setModel(model))) throw new Error(`Pi could not activate ${ref.provider}/${ref.id}; check credentials and model scope`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function restoreActiveSettings(ctx: ExtensionContext, ref: ModelRef | undefined, effort: PiThinkingLevel): Promise<void> {
|
|
584
|
+
try {
|
|
585
|
+
if (ref && modelKey(modelRef(ctx.model)) !== modelKey(ref)) await switchToRef(ctx, ref);
|
|
586
|
+
} catch {
|
|
587
|
+
// Best effort: retain the original activation error if restoration is unavailable.
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
if (pi.getThinkingLevel() !== effort) pi.setThinkingLevel(effort);
|
|
591
|
+
} catch {
|
|
592
|
+
// Best effort: retain the original activation error if restoration is unavailable.
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function applyModeSettings(
|
|
597
|
+
ctx: ExtensionContext,
|
|
598
|
+
settings: ModeModelConfig | undefined,
|
|
599
|
+
fallbackModel?: ModelRef,
|
|
600
|
+
fallbackEffort?: PiThinkingLevel,
|
|
601
|
+
): Promise<void> {
|
|
602
|
+
const previousModel = modelRef(ctx.model);
|
|
603
|
+
const previousEffort = pi.getThinkingLevel();
|
|
604
|
+
const configuredRef = settings ? settingsRef(settings) : fallbackModel;
|
|
605
|
+
const effort = settings && settings.effort !== "default"
|
|
606
|
+
? toPiThinkingLevel(settings.effort)
|
|
607
|
+
: fallbackEffort;
|
|
608
|
+
try {
|
|
609
|
+
await switchToRef(ctx, configuredRef);
|
|
610
|
+
if (effort !== undefined && pi.getThinkingLevel() !== effort) pi.setThinkingLevel(effort);
|
|
611
|
+
} catch (error) {
|
|
612
|
+
await restoreActiveSettings(ctx, previousModel, previousEffort);
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function toggleMode(ctx: ExtensionCommandContext): Promise<void> {
|
|
618
|
+
if (!ctx.isIdle()) {
|
|
619
|
+
ctx.ui.notify("Wait for the current agent turn to finish before changing modes.", "warning");
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (profileChangeInProgress || modeTransitionInProgress) {
|
|
623
|
+
ctx.ui.notify("A model or mode change is already in progress.", "warning");
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
modeTransitionInProgress = true;
|
|
627
|
+
try {
|
|
628
|
+
const loaded = await loadConfig();
|
|
629
|
+
config = loaded.config;
|
|
630
|
+
configError = loaded.error;
|
|
631
|
+
notifyConfigError(ctx);
|
|
632
|
+
const selected = profileSelection(config);
|
|
633
|
+
if (mode === "build") {
|
|
634
|
+
const previous = modelRef(ctx.model);
|
|
635
|
+
const previousEffort = pi.getThinkingLevel();
|
|
636
|
+
await applyModeSettings(ctx, selected?.profile.plan);
|
|
637
|
+
mode = "plan";
|
|
638
|
+
prePlanModel = previous;
|
|
639
|
+
prePlanEffort = previousEffort;
|
|
640
|
+
planPath = undefined;
|
|
641
|
+
planPathPromise = undefined;
|
|
642
|
+
} else {
|
|
643
|
+
await applyModeSettings(ctx, selected?.profile.build, prePlanModel, prePlanEffort);
|
|
644
|
+
mode = "build";
|
|
645
|
+
pendingApproval = undefined;
|
|
646
|
+
}
|
|
647
|
+
persistState();
|
|
648
|
+
updateBadge(ctx);
|
|
649
|
+
ctx.ui.notify(mode === "plan" ? "Plan mode enabled. Only the extension-owned plan file may be written." : "Build mode enabled.", "info");
|
|
650
|
+
} catch (error) {
|
|
651
|
+
ctx.ui.notify(`Mode unchanged: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
652
|
+
} finally {
|
|
653
|
+
modeTransitionInProgress = false;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function makePlanPath(ctx: ExtensionContext): Promise<string> {
|
|
658
|
+
if (planPath) return planPath;
|
|
659
|
+
if (!planPathPromise) {
|
|
660
|
+
planPathPromise = (async () => {
|
|
661
|
+
const gitDir = await gitAdminDir(pi, ctx.cwd);
|
|
662
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
663
|
+
return createPlanPath({ cwd: ctx.cwd, sessionId: sessionId || randomUUID(), gitAdminDir: gitDir });
|
|
664
|
+
})();
|
|
665
|
+
}
|
|
666
|
+
return planPathPromise;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
pi.on("before_agent_start", async (event, ctx) => ({
|
|
670
|
+
// This exact suffix is independent of mode and is never changed by /plan.
|
|
671
|
+
systemPrompt: `${event.systemPrompt}${STATIC_SYSTEM_INSTRUCTIONS}`,
|
|
672
|
+
message: {
|
|
673
|
+
customType: "plan-mode-context",
|
|
674
|
+
content: planContext(mode, planPath ? formatPlanPath(planPath, ctx.cwd) : undefined),
|
|
675
|
+
display: false,
|
|
676
|
+
},
|
|
677
|
+
}));
|
|
678
|
+
|
|
679
|
+
pi.on("tool_call", (event) => {
|
|
680
|
+
if (mode !== "plan" || PLAN_TOOLS.has(event.toolName)) return;
|
|
681
|
+
if (event.toolName === "bash" && isReadOnlyBashCommand(event.input.command)) return;
|
|
682
|
+
return {
|
|
683
|
+
block: true,
|
|
684
|
+
reason: event.toolName === "bash"
|
|
685
|
+
? "Plan mode permits Bash only for simple read-only pwd/ls/find/grep/rg/cat/head/tail/wc/file/stat commands and Git status/diff/log/show, including pipelines and && chains where every command is permitted (for example, pwd && ls -la or rg -n 'PLAN_TOOLS\\b' extensions/plan-mode/index.ts). Other shell chaining/operators (including ;, ||, and &), redirection, substitutions, unlisted commands, mutating/execution options, and interactive !/!! commands are blocked."
|
|
686
|
+
: `Plan mode blocks ${event.toolName}: no resource-mutating or unreviewed tools are available. Use read/grep/find/ls or an approved read-only Bash command to inspect, plan_save for the plan file, and plan_present for approval. Toggle with /plan only when you intend to build.`,
|
|
687
|
+
};
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
// Also prevent interactive ! commands from bypassing the Plan-mode tool gate.
|
|
691
|
+
pi.on("user_bash", () => {
|
|
692
|
+
if (mode !== "plan") return;
|
|
693
|
+
return { result: { output: "Plan mode blocks shell commands. Use the read-only agent tools or switch to Build mode.", exitCode: 1, cancelled: false, truncated: false } };
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
pi.registerTool({
|
|
697
|
+
name: "plan_save",
|
|
698
|
+
label: "Save Plan",
|
|
699
|
+
description: "Write or refine the current implementation plan in the extension-owned plan file. This is the only write operation permitted in Plan mode. Returns the path relative to the session working directory; it never accepts a destination path.",
|
|
700
|
+
parameters: Type.Object({ content: Type.String({ description: "Complete plan Markdown to create or replace" }) }, { additionalProperties: false }),
|
|
701
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
702
|
+
if (mode !== "plan") throw new Error("plan_save is available only in Plan mode");
|
|
703
|
+
const target = await makePlanPath(ctx);
|
|
704
|
+
await withFileMutationQueue(target, async () => writePlan(target, params.content));
|
|
705
|
+
planPath = target;
|
|
706
|
+
persistState();
|
|
707
|
+
const displayPath = formatPlanPath(target, ctx.cwd);
|
|
708
|
+
return {
|
|
709
|
+
content: [{ type: "text", text: `Plan saved: ${displayPath}` }],
|
|
710
|
+
details: { path: displayPath },
|
|
711
|
+
};
|
|
712
|
+
},
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
pi.registerTool({
|
|
716
|
+
name: "plan_present",
|
|
717
|
+
label: "Review Plan",
|
|
718
|
+
description: "Present the extension-owned plan file in a scrollable review UI. Supply only the exact path relative to the session working directory returned by plan_save; never supply an absolute path, alternate spelling, or plan contents. The user chooses refinement, approval, or cancel in the UI.",
|
|
719
|
+
parameters: Type.Object({ path: Type.String({ description: "Exact relative path returned by plan_save, based on the session working directory" }) }, { additionalProperties: false }),
|
|
720
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
721
|
+
if (mode !== "plan") throw new Error("plan_present is available only in Plan mode");
|
|
722
|
+
if (ctx.mode !== "tui") {
|
|
723
|
+
return { content: [{ type: "text", text: "Interactive plan approval requires Pi TUI mode. No approval was recorded." }], details: {}, terminate: true };
|
|
724
|
+
}
|
|
725
|
+
if (reviewActive) throw new Error("A plan review is already open");
|
|
726
|
+
if (!planPath || isAbsolute(params.path) || resolve(ctx.cwd, params.path) !== planPath ||
|
|
727
|
+
params.path !== formatPlanPath(planPath, ctx.cwd)) {
|
|
728
|
+
throw new Error("plan_present accepts only the exact current extension-owned plan path relative to the session working directory");
|
|
729
|
+
}
|
|
730
|
+
reviewActive = true;
|
|
731
|
+
try {
|
|
732
|
+
const reviewedText = await readPlan(planPath);
|
|
733
|
+
const choice: ReviewChoice | undefined = await showPlanReview(ctx, formatPlanPath(planPath, ctx.cwd), reviewedText);
|
|
734
|
+
if (!choice || choice === "cancel") {
|
|
735
|
+
ctx.ui.notify("Plan review cancelled. Plan mode remains active.", "info");
|
|
736
|
+
return { content: [{ type: "text", text: "Review cancelled; no approval was recorded." }], details: { choice: "cancel" }, terminate: true };
|
|
737
|
+
}
|
|
738
|
+
if (choice === "refine") {
|
|
739
|
+
ctx.ui.notify("Refine the plan in chat. Plan mode remains active.", "info");
|
|
740
|
+
return { content: [{ type: "text", text: "Refinement requested. The user can now describe changes in chat." }], details: { choice }, terminate: true };
|
|
741
|
+
}
|
|
742
|
+
const token = randomUUID();
|
|
743
|
+
const selected = profileSelection(config);
|
|
744
|
+
pendingApproval = {
|
|
745
|
+
token,
|
|
746
|
+
choice,
|
|
747
|
+
path: planPath,
|
|
748
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
749
|
+
profileName: selected?.name,
|
|
750
|
+
profileSnapshot: selected ? { ...selected.profile } : undefined,
|
|
751
|
+
};
|
|
752
|
+
pi.sendUserMessage(`/plan-handoff ${token}`, { deliverAs: "followUp", expandPromptTemplates: true });
|
|
753
|
+
return {
|
|
754
|
+
content: [{ type: "text", text: "Plan approval recorded. Handoff queued." }],
|
|
755
|
+
details: { choice },
|
|
756
|
+
terminate: true,
|
|
757
|
+
};
|
|
758
|
+
} finally {
|
|
759
|
+
reviewActive = false;
|
|
760
|
+
}
|
|
761
|
+
},
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
pi.registerCommand("plan", {
|
|
765
|
+
description: "Toggle Plan / Build mode",
|
|
766
|
+
handler: async (_args, ctx) => toggleMode(ctx),
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
pi.registerCommand("plan-profile", {
|
|
770
|
+
description: "Show or switch the active Plan / Build model profile",
|
|
771
|
+
getArgumentCompletions: (prefix) => {
|
|
772
|
+
const names = config.profileOrder.filter((name) => name.startsWith(prefix));
|
|
773
|
+
return names.length ? names.map((name) => ({ value: name, label: name })) : null;
|
|
774
|
+
},
|
|
775
|
+
handler: async (args, ctx) => {
|
|
776
|
+
const tokens = args.trim() ? args.trim().split(/\s+/) : [];
|
|
777
|
+
if (tokens.length === 0) {
|
|
778
|
+
const loaded = await loadConfig();
|
|
779
|
+
if (loaded.error) ctx.ui.notify(loaded.error, "error");
|
|
780
|
+
const selected = profileSelection(loaded.config);
|
|
781
|
+
const names = loaded.config.profileOrder;
|
|
782
|
+
ctx.ui.notify(
|
|
783
|
+
`Selected profile: ${selected?.name ?? "(none)"}\nAvailable profiles: ${names.length ? names.join(", ") : "(none configured)"}`,
|
|
784
|
+
loaded.error ? "warning" : "info",
|
|
785
|
+
);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const usage = () => {
|
|
790
|
+
const names = config.profileOrder;
|
|
791
|
+
return `Usage: /plan-profile <name>\nAvailable profiles: ${names.length ? names.join(", ") : "(none configured)"}`;
|
|
792
|
+
};
|
|
793
|
+
if (tokens.length !== 1 || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(tokens[0]!)) {
|
|
794
|
+
ctx.ui.notify(usage(), "warning");
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (!ctx.isIdle()) {
|
|
798
|
+
ctx.ui.notify("Wait for the current agent turn to finish before switching profiles.", "warning");
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
if (reviewActive) {
|
|
802
|
+
ctx.ui.notify("Close the plan review before switching profiles.", "warning");
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (profileChangeInProgress || modeTransitionInProgress) {
|
|
806
|
+
ctx.ui.notify("A profile or mode change is already in progress.", "warning");
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
profileChangeInProgress = true;
|
|
811
|
+
try {
|
|
812
|
+
await withFileMutationQueue(configFilePath(), async () => {
|
|
813
|
+
const latest = await loadConfig();
|
|
814
|
+
if (latest.error) throw new Error(latest.error);
|
|
815
|
+
const name = tokens[0]!;
|
|
816
|
+
if (!Object.prototype.hasOwnProperty.call(latest.config.profiles, name)) {
|
|
817
|
+
config = latest.config;
|
|
818
|
+
throw new Error(`Unknown profile ${JSON.stringify(name)}. ${usage()}`);
|
|
819
|
+
}
|
|
820
|
+
const previousSelection = profileSelection(latest.config);
|
|
821
|
+
const target = latest.config.profiles[name]!;
|
|
822
|
+
const previousModel = modelRef(ctx.model);
|
|
823
|
+
const previousEffort = pi.getThinkingLevel();
|
|
824
|
+
try {
|
|
825
|
+
const currentSettings = mode === "plan" ? target.plan : target.build;
|
|
826
|
+
if (currentSettings) await findModel(ctx, settingsRef(currentSettings)!);
|
|
827
|
+
await applyModeSettings(ctx, currentSettings);
|
|
828
|
+
const canPersistSelection = latest.config.legacy
|
|
829
|
+
? Boolean(target.plan && target.build)
|
|
830
|
+
: latest.config.selectedProfile !== name;
|
|
831
|
+
if (canPersistSelection) {
|
|
832
|
+
if (latest.raw === undefined) throw new Error("Configuration disappeared before profile selection could be saved");
|
|
833
|
+
await writeConfigAtomically(latest.path, latest.raw, selectedConfigText(latest.config, name));
|
|
834
|
+
}
|
|
835
|
+
} catch (error) {
|
|
836
|
+
await restoreActiveSettings(ctx, previousModel, previousEffort);
|
|
837
|
+
throw error;
|
|
838
|
+
}
|
|
839
|
+
config = latest.config.legacy && (!target.plan || !target.build)
|
|
840
|
+
? latest.config
|
|
841
|
+
: { ...latest.config, selectedProfile: name, legacy: false };
|
|
842
|
+
configError = undefined;
|
|
843
|
+
if (previousSelection?.name !== name) pendingApproval = undefined;
|
|
844
|
+
updateBadge(ctx);
|
|
845
|
+
});
|
|
846
|
+
ctx.ui.notify(`Plan-mode profile selected: ${tokens[0]}`, "info");
|
|
847
|
+
} catch (error) {
|
|
848
|
+
ctx.ui.notify(`Profile unchanged: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
849
|
+
} finally {
|
|
850
|
+
profileChangeInProgress = false;
|
|
851
|
+
}
|
|
852
|
+
},
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
// This command runs in the new session's runtime, so its local `pi` can
|
|
856
|
+
// select the configured Build model and effort without touching the source runtime.
|
|
857
|
+
pi.registerCommand("plan-build-start", {
|
|
858
|
+
description: "Internal one-time fresh-session build handoff",
|
|
859
|
+
handler: async (args, ctx) => {
|
|
860
|
+
const token = args.trim();
|
|
861
|
+
const branch = ctx.sessionManager.getBranch();
|
|
862
|
+
let handoff: FreshHandoff | undefined;
|
|
863
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
864
|
+
const entry = branch[i] as { type?: string; customType?: string; data?: unknown };
|
|
865
|
+
if (entry.type !== "custom" || entry.customType !== FRESH_HANDOFF_TYPE || !entry.data || typeof entry.data !== "object") continue;
|
|
866
|
+
const data = entry.data as Partial<FreshHandoff>;
|
|
867
|
+
if (data.token === token && typeof data.path === "string" &&
|
|
868
|
+
typeof data.ownerSessionId === "string") {
|
|
869
|
+
handoff = data as FreshHandoff;
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
const wasUsed = branch.some((entry) => entry.type === "custom" &&
|
|
874
|
+
(entry as { customType?: string; data?: { token?: string } }).customType === FRESH_HANDOFF_USED_TYPE &&
|
|
875
|
+
(entry as { data?: { token?: string } }).data?.token === token);
|
|
876
|
+
if (!handoff || wasUsed) {
|
|
877
|
+
ctx.ui.notify("No unused approved fresh-session handoff exists.", "error");
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (!(await isOwnedPlanPath(pi, ctx, handoff.path, handoff.ownerSessionId))) {
|
|
881
|
+
ctx.ui.notify("Approved plan path is missing or not owned by the source session. Nothing was executed.", "error");
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
try {
|
|
885
|
+
await readPlan(handoff.path);
|
|
886
|
+
} catch (error) {
|
|
887
|
+
ctx.ui.notify(`Approved plan cannot be read: ${String(error)}. Nothing was executed.`, "error");
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
const latest = await loadConfig();
|
|
891
|
+
if (latest.error) {
|
|
892
|
+
ctx.ui.notify(`${latest.error}. Nothing was executed.`, "error");
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
const selected = profileSelection(latest.config);
|
|
896
|
+
if (selected?.name !== handoff.profileName || !sameProfile(selected?.profile, handoff.profileSnapshot)) {
|
|
897
|
+
ctx.ui.notify("The approved model profile changed before the fresh-session handoff. Review the plan again; nothing was executed.", "warning");
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
config = latest.config;
|
|
901
|
+
configError = undefined;
|
|
902
|
+
if (selected?.profile.build) {
|
|
903
|
+
try {
|
|
904
|
+
await applyModeSettings(ctx, selected.profile.build);
|
|
905
|
+
} catch (error) {
|
|
906
|
+
ctx.ui.notify(`Configured Build model unavailable in the fresh session: ${String(error)}. Nothing was executed.`, "error");
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
pi.appendEntry(FRESH_HANDOFF_USED_TYPE, { token });
|
|
911
|
+
mode = "build";
|
|
912
|
+
persistState();
|
|
913
|
+
updateBadge(ctx);
|
|
914
|
+
ctx.ui.notify("Starting Build in the fresh session.", "info");
|
|
915
|
+
const kickoff = `Implement the complete approved plan below in this fresh Pi session.
|
|
916
|
+
|
|
917
|
+
Plan file: ${formatPlanPath(handoff.path, ctx.cwd)}
|
|
918
|
+
|
|
919
|
+
Before editing:
|
|
920
|
+
1. Inspect git status --short --branch from the existing working directory. Preserve existing changes.
|
|
921
|
+
2. Read project instructions for the files in the plan.
|
|
922
|
+
3. Read the plan context, constraints, relevant assumptions, all implementation steps, and named source documents/interfaces.
|
|
923
|
+
4. If repository state conflicts with the plan or a required decision is missing, stop and report that issue instead of guessing.
|
|
924
|
+
|
|
925
|
+
Implement the plan in order. Run only verification authorized by the plan or required by project instructions. At completion, report changed files, commands and results, remaining issues, and any commit created.`;
|
|
926
|
+
pi.sendUserMessage(kickoff);
|
|
927
|
+
},
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
pi.registerCommand("plan-handoff", {
|
|
931
|
+
description: "Internal one-time plan approval handoff",
|
|
932
|
+
handler: async (args, ctx) => {
|
|
933
|
+
const approval = pendingApproval;
|
|
934
|
+
const supplied = args.trim();
|
|
935
|
+
if (!approval || !supplied || supplied !== approval.token) {
|
|
936
|
+
ctx.ui.notify("No matching one-time plan approval exists.", "error");
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
pendingApproval = undefined; // one shot, even on failed or cancelled handoff
|
|
940
|
+
await ctx.waitForIdle();
|
|
941
|
+
if (profileChangeInProgress || modeTransitionInProgress || ctx.hasPendingMessages() || mode !== "plan" ||
|
|
942
|
+
ctx.sessionManager.getSessionId() !== approval.sessionId || planPath !== approval.path) {
|
|
943
|
+
ctx.ui.notify("Approval became stale or another message is queued. Review the plan again before executing.", "warning");
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
try {
|
|
947
|
+
await readPlan(approval.path);
|
|
948
|
+
} catch (error) {
|
|
949
|
+
ctx.ui.notify(`Approved plan is no longer readable: ${String(error)}. Nothing was executed.`, "error");
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const latest = await loadConfig();
|
|
954
|
+
if (latest.error) {
|
|
955
|
+
ctx.ui.notify(`${latest.error}. Approval is stale; review the plan again.`, "error");
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const selected = profileSelection(latest.config);
|
|
959
|
+
if (selected?.name !== approval.profileName || !sameProfile(selected?.profile, approval.profileSnapshot)) {
|
|
960
|
+
ctx.ui.notify("The selected model profile changed after review. Review the plan again before executing.", "warning");
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
config = latest.config;
|
|
964
|
+
configError = undefined;
|
|
965
|
+
const profile = selected?.profile;
|
|
966
|
+
const buildSettings = profile?.build;
|
|
967
|
+
const expectedBuildRef = settingsRef(buildSettings) ??
|
|
968
|
+
(approval.choice === "execute-here" ? prePlanModel : undefined);
|
|
969
|
+
|
|
970
|
+
if (expectedBuildRef) {
|
|
971
|
+
try {
|
|
972
|
+
await findModel(ctx, expectedBuildRef);
|
|
973
|
+
} catch (error) {
|
|
974
|
+
ctx.ui.notify(`Build model unavailable: ${error instanceof Error ? error.message : String(error)}. Plan mode remains active.`, "error");
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (approval.choice === "execute-here") {
|
|
980
|
+
try {
|
|
981
|
+
await applyModeSettings(ctx, buildSettings, prePlanModel, prePlanEffort);
|
|
982
|
+
} catch (error) {
|
|
983
|
+
ctx.ui.notify(`Could not switch to the configured Build model and effort: ${String(error)}. Plan mode remains active.`, "error");
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
mode = "build";
|
|
987
|
+
persistState();
|
|
988
|
+
updateBadge(ctx);
|
|
989
|
+
const kickoff = `Execute the approved implementation plan at ${formatPlanPath(approval.path, ctx.cwd)}. Read it, follow the plan in order, and stop if the file is unavailable or project state conflicts with it.`;
|
|
990
|
+
pi.sendUserMessage(kickoff);
|
|
991
|
+
ctx.ui.notify("Approved. Continuing execution in this session.", "info");
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const parentSession = ctx.sessionManager.getSessionFile();
|
|
996
|
+
const freshToken = randomUUID();
|
|
997
|
+
const setup = async (sessionManager: SessionManager) => {
|
|
998
|
+
sessionManager.appendCustomEntry(FRESH_HANDOFF_TYPE, {
|
|
999
|
+
token: freshToken,
|
|
1000
|
+
path: approval.path,
|
|
1001
|
+
ownerSessionId: approval.sessionId,
|
|
1002
|
+
profileName: approval.profileName,
|
|
1003
|
+
profileSnapshot: approval.profileSnapshot,
|
|
1004
|
+
} satisfies FreshHandoff);
|
|
1005
|
+
};
|
|
1006
|
+
try {
|
|
1007
|
+
const result = await ctx.newSession({
|
|
1008
|
+
...(parentSession ? { parentSession } : {}),
|
|
1009
|
+
setup,
|
|
1010
|
+
withSession: async (replacementCtx) => {
|
|
1011
|
+
await replacementCtx.sendUserMessage(`/plan-build-start ${freshToken}`, { expandPromptTemplates: true });
|
|
1012
|
+
},
|
|
1013
|
+
});
|
|
1014
|
+
if (result.cancelled) ctx.ui.notify("New-session handoff cancelled. The original Plan session is unchanged.", "info");
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
ctx.ui.notify(`Could not start the new Build session: ${String(error)}. Nothing was executed.`, "error");
|
|
1017
|
+
}
|
|
1018
|
+
},
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1022
|
+
const loaded = await loadConfig();
|
|
1023
|
+
config = loaded.config;
|
|
1024
|
+
configError = loaded.error;
|
|
1025
|
+
mode = "build";
|
|
1026
|
+
planPath = undefined;
|
|
1027
|
+
prePlanModel = undefined;
|
|
1028
|
+
prePlanEffort = undefined;
|
|
1029
|
+
pendingApproval = undefined;
|
|
1030
|
+
planPathPromise = undefined;
|
|
1031
|
+
const restored = latestState(ctx);
|
|
1032
|
+
if (restored) {
|
|
1033
|
+
mode = restored.mode;
|
|
1034
|
+
planPath = restored.planPath;
|
|
1035
|
+
prePlanModel = restored.prePlanModel;
|
|
1036
|
+
prePlanEffort = restored.prePlanEffort;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
if (configError) ctx.ui.notify(configError, "error");
|
|
1040
|
+
if (planPath && !(await isOwnedPlanPath(pi, ctx, planPath))) {
|
|
1041
|
+
planPath = undefined;
|
|
1042
|
+
planPathPromise = undefined;
|
|
1043
|
+
ctx.ui.notify("Saved plan path was missing or did not belong to this session; create a new plan before review.", "warning");
|
|
1044
|
+
if (mode === "plan") persistState();
|
|
1045
|
+
}
|
|
1046
|
+
updateBadge(ctx);
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
pi.on("resources_discover", async (_event, ctx) => {
|
|
1050
|
+
const selected = profileSelection(config);
|
|
1051
|
+
const modeSettings = mode === "plan" ? selected?.profile.plan : selected?.profile.build;
|
|
1052
|
+
if (modeSettings) {
|
|
1053
|
+
try {
|
|
1054
|
+
await applyModeSettings(ctx, modeSettings);
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
ctx.ui.notify(`Configured ${mode} model is unavailable: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
});
|
|
1060
|
+
}
|