@hank-warren/pi-plan-mode 0.1.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/LICENSE +21 -0
- package/NOTICE.md +7 -0
- package/README.md +313 -0
- package/index.ts +1 -0
- package/package.json +47 -0
- package/src/active-implementation-menu.ts +68 -0
- package/src/auto-permissions-delegation.ts +122 -0
- package/src/command.ts +30 -0
- package/src/completion-tool.ts +91 -0
- package/src/extension-runtime.ts +24 -0
- package/src/fresh-implementation.ts +213 -0
- package/src/implementation-retention.ts +122 -0
- package/src/index.ts +1 -0
- package/src/interactive-ui.ts +5 -0
- package/src/message-transform.ts +232 -0
- package/src/plan-action-controller.ts +103 -0
- package/src/plan-action-menus.ts +197 -0
- package/src/plan-export-controller.ts +38 -0
- package/src/plan-export-screen.ts +19 -0
- package/src/plan-export.ts +145 -0
- package/src/plan-launch-menu.ts +122 -0
- package/src/plan-mode.ts +1037 -0
- package/src/presentation.ts +108 -0
- package/src/prompt.ts +67 -0
- package/src/question-tool.ts +273 -0
- package/src/required-tools.ts +22 -0
- package/src/saved-plan-menu.ts +93 -0
- package/src/saved-plan-preflight.ts +39 -0
- package/src/settings-menu.ts +384 -0
- package/src/settings.ts +420 -0
- package/src/state.ts +167 -0
- package/src/tool-policy.ts +563 -0
- package/src/tool-selection.ts +98 -0
package/src/settings.ts
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { mkdir, open, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
SAFE_GH_SUBCOMMAND_PATHS,
|
|
8
|
+
SAFE_GIT_SUBCOMMANDS,
|
|
9
|
+
type SafeGhSubcommandPath,
|
|
10
|
+
type SafeGitSubcommand,
|
|
11
|
+
type SafeSubcommands,
|
|
12
|
+
} from "./tool-policy.js";
|
|
13
|
+
|
|
14
|
+
export const PLAN_MODE_SETTINGS_FILE = "pi-plan-mode.json";
|
|
15
|
+
const LEGACY_PLAN_MODE_SETTINGS_FILE = "plan-mode.json";
|
|
16
|
+
const MAX_SETTINGS_BYTES = 64 * 1024;
|
|
17
|
+
export const PLAN_MODE_THINKING_LEVELS = [
|
|
18
|
+
"inherit",
|
|
19
|
+
"off",
|
|
20
|
+
"minimal",
|
|
21
|
+
"low",
|
|
22
|
+
"medium",
|
|
23
|
+
"high",
|
|
24
|
+
"xhigh",
|
|
25
|
+
"max",
|
|
26
|
+
] as const;
|
|
27
|
+
export const IMPLEMENTATION_PLAN_RETENTIONS = [
|
|
28
|
+
"keep",
|
|
29
|
+
"clear-on-start",
|
|
30
|
+
"clear-after-first-run",
|
|
31
|
+
] as const;
|
|
32
|
+
export const PLAN_MODE_BASH_POLICIES = ["limited", "auto-permissions"] as const;
|
|
33
|
+
export const DEFAULT_PLAN_EXPORT_PATH = "PLAN.md";
|
|
34
|
+
const MAX_PLAN_EXPORT_PATH_LENGTH = 4096;
|
|
35
|
+
|
|
36
|
+
export type PlanModeThinkingLevel = (typeof PLAN_MODE_THINKING_LEVELS)[number];
|
|
37
|
+
export type ImplementationPlanRetention = (typeof IMPLEMENTATION_PLAN_RETENTIONS)[number];
|
|
38
|
+
export type PlanModeBashPolicy = (typeof PLAN_MODE_BASH_POLICIES)[number];
|
|
39
|
+
export type PlanModeFixedThinkingLevel = Exclude<PlanModeThinkingLevel, "inherit">;
|
|
40
|
+
export interface PlanModeSettings {
|
|
41
|
+
thinkingLevel: PlanModeThinkingLevel;
|
|
42
|
+
defaultPlanTools?: string[];
|
|
43
|
+
implementationPlanRetention?: ImplementationPlanRetention;
|
|
44
|
+
defaultPlanExportPath?: string;
|
|
45
|
+
bashPolicy?: PlanModeBashPolicy;
|
|
46
|
+
safeSubcommands?: SafeSubcommands;
|
|
47
|
+
}
|
|
48
|
+
export interface PlanModeSettingsPatch {
|
|
49
|
+
thinkingLevel?: PlanModeThinkingLevel;
|
|
50
|
+
defaultPlanTools?: readonly string[] | null;
|
|
51
|
+
implementationPlanRetention?: ImplementationPlanRetention;
|
|
52
|
+
defaultPlanExportPath?: string | null;
|
|
53
|
+
bashPolicy?: PlanModeBashPolicy;
|
|
54
|
+
}
|
|
55
|
+
export interface UpdatePlanModeSettingsOptions {
|
|
56
|
+
settingsPath?: string;
|
|
57
|
+
legacySettingsPath?: string;
|
|
58
|
+
signal?: AbortSignal;
|
|
59
|
+
beforeRename?: (temporaryPath: string, settingsPath: string) => Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
export type PlanModeSettingsLoadResult =
|
|
62
|
+
| { kind: "missing"; notice?: string }
|
|
63
|
+
| { kind: "invalid"; reason: string; notice?: string }
|
|
64
|
+
| { kind: "loaded"; settings: PlanModeSettings; notice?: string };
|
|
65
|
+
|
|
66
|
+
type SettingsDocument = Record<string, unknown>;
|
|
67
|
+
type SettingsSnapshot = {
|
|
68
|
+
result: PlanModeSettingsLoadResult;
|
|
69
|
+
document?: SettingsDocument;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const mutationQueues = new Map<string, Promise<void>>();
|
|
73
|
+
|
|
74
|
+
export function planModeSettingsPath() {
|
|
75
|
+
return join(getAgentDir(), PLAN_MODE_SETTINGS_FILE);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function legacyPlanModeSettingsPath() {
|
|
79
|
+
return join(getAgentDir(), LEGACY_PLAN_MODE_SETTINGS_FILE);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function normalizePlanModeSettings(value: unknown): PlanModeSettings | undefined {
|
|
83
|
+
if (!isSettingsDocument(value)) return undefined;
|
|
84
|
+
const thinkingLevel = Object.hasOwn(value, "thinkingLevel")
|
|
85
|
+
? Reflect.get(value, "thinkingLevel")
|
|
86
|
+
: "inherit";
|
|
87
|
+
if (!PLAN_MODE_THINKING_LEVELS.includes(thinkingLevel as PlanModeThinkingLevel)) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const settings: PlanModeSettings = {
|
|
91
|
+
thinkingLevel: thinkingLevel as PlanModeThinkingLevel,
|
|
92
|
+
};
|
|
93
|
+
if (Object.hasOwn(value, "defaultPlanTools")) {
|
|
94
|
+
const defaultPlanTools = normalizeToolNames(Reflect.get(value, "defaultPlanTools"));
|
|
95
|
+
if (!defaultPlanTools) return undefined;
|
|
96
|
+
settings.defaultPlanTools = defaultPlanTools;
|
|
97
|
+
}
|
|
98
|
+
if (Object.hasOwn(value, "implementationPlanRetention")) {
|
|
99
|
+
const implementationPlanRetention = Reflect.get(value, "implementationPlanRetention");
|
|
100
|
+
if (
|
|
101
|
+
!IMPLEMENTATION_PLAN_RETENTIONS.includes(
|
|
102
|
+
implementationPlanRetention as ImplementationPlanRetention,
|
|
103
|
+
)
|
|
104
|
+
) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
settings.implementationPlanRetention =
|
|
108
|
+
implementationPlanRetention as ImplementationPlanRetention;
|
|
109
|
+
}
|
|
110
|
+
if (Object.hasOwn(value, "defaultPlanExportPath")) {
|
|
111
|
+
const defaultPlanExportPath = normalizePlanExportPath(
|
|
112
|
+
Reflect.get(value, "defaultPlanExportPath"),
|
|
113
|
+
);
|
|
114
|
+
if (!defaultPlanExportPath) return undefined;
|
|
115
|
+
settings.defaultPlanExportPath = defaultPlanExportPath;
|
|
116
|
+
}
|
|
117
|
+
if (Object.hasOwn(value, "bashPolicy")) {
|
|
118
|
+
const bashPolicy = Reflect.get(value, "bashPolicy");
|
|
119
|
+
if (!PLAN_MODE_BASH_POLICIES.includes(bashPolicy as PlanModeBashPolicy)) return undefined;
|
|
120
|
+
settings.bashPolicy = bashPolicy as PlanModeBashPolicy;
|
|
121
|
+
}
|
|
122
|
+
if (Object.hasOwn(value, "safeSubcommands")) {
|
|
123
|
+
const safeSubcommands = normalizeSafeSubcommands(Reflect.get(value, "safeSubcommands"));
|
|
124
|
+
if (!safeSubcommands) return undefined;
|
|
125
|
+
settings.safeSubcommands = safeSubcommands;
|
|
126
|
+
}
|
|
127
|
+
return settings;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalizeToolNames(value: unknown) {
|
|
131
|
+
if (
|
|
132
|
+
!Array.isArray(value) ||
|
|
133
|
+
!value.every((item): item is string => typeof item === "string" && item.trim().length > 0)
|
|
134
|
+
) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
return Array.from(new Set(value));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function normalizePlanExportPath(value: unknown) {
|
|
141
|
+
if (typeof value !== "string") return undefined;
|
|
142
|
+
const normalized = value.trim();
|
|
143
|
+
if (
|
|
144
|
+
!normalized ||
|
|
145
|
+
normalized.length > MAX_PLAN_EXPORT_PATH_LENGTH ||
|
|
146
|
+
!/[^@\s]/u.test(normalized) ||
|
|
147
|
+
[...normalized].some((character) => {
|
|
148
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
149
|
+
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
|
150
|
+
})
|
|
151
|
+
) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
return normalized;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function normalizeSafeSubcommands(value: unknown): SafeSubcommands | undefined {
|
|
158
|
+
if (!isSettingsDocument(value)) return undefined;
|
|
159
|
+
if (Object.keys(value).some((key) => key !== "git" && key !== "gh")) return undefined;
|
|
160
|
+
|
|
161
|
+
const safeSubcommands: SafeSubcommands = {};
|
|
162
|
+
if (Object.hasOwn(value, "git")) {
|
|
163
|
+
const git = normalizeKnownValues(Reflect.get(value, "git"), SAFE_GIT_SUBCOMMANDS);
|
|
164
|
+
if (!git) return undefined;
|
|
165
|
+
safeSubcommands.git = git as SafeGitSubcommand[];
|
|
166
|
+
}
|
|
167
|
+
if (Object.hasOwn(value, "gh")) {
|
|
168
|
+
const gh = normalizeKnownValues(Reflect.get(value, "gh"), SAFE_GH_SUBCOMMAND_PATHS);
|
|
169
|
+
if (!gh) return undefined;
|
|
170
|
+
safeSubcommands.gh = gh as SafeGhSubcommandPath[];
|
|
171
|
+
}
|
|
172
|
+
return safeSubcommands;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeKnownValues(value: unknown, supported: readonly string[]) {
|
|
176
|
+
if (
|
|
177
|
+
!Array.isArray(value) ||
|
|
178
|
+
!value.every((item): item is string => typeof item === "string" && supported.includes(item))
|
|
179
|
+
) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
return Array.from(new Set(value));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function readPlanModeSettings(
|
|
186
|
+
settingsPath?: string,
|
|
187
|
+
): Promise<PlanModeSettingsLoadResult> {
|
|
188
|
+
if (settingsPath) {
|
|
189
|
+
await awaitPlanModeSettingsWrites(settingsPath);
|
|
190
|
+
return (await readSettingsSnapshot(settingsPath)).result;
|
|
191
|
+
}
|
|
192
|
+
const canonicalPath = planModeSettingsPath();
|
|
193
|
+
await awaitPlanModeSettingsWrites(canonicalPath);
|
|
194
|
+
const canonical = await readSettingsSnapshot(canonicalPath);
|
|
195
|
+
const legacyPath = legacyPlanModeSettingsPath();
|
|
196
|
+
if (canonical.result.kind !== "missing") {
|
|
197
|
+
return (await pathExists(legacyPath))
|
|
198
|
+
? {
|
|
199
|
+
...canonical.result,
|
|
200
|
+
notice: `${LEGACY_PLAN_MODE_SETTINGS_FILE} ignored because ${PLAN_MODE_SETTINGS_FILE} takes precedence.`,
|
|
201
|
+
}
|
|
202
|
+
: canonical.result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const legacy = await readSettingsSnapshot(legacyPath);
|
|
206
|
+
const raced = await readSettingsSnapshot(canonicalPath);
|
|
207
|
+
if (raced.result.kind !== "missing") return raced.result;
|
|
208
|
+
return legacy.result.kind === "loaded"
|
|
209
|
+
? {
|
|
210
|
+
...legacy.result,
|
|
211
|
+
notice: `Using legacy ${LEGACY_PLAN_MODE_SETTINGS_FILE}; rename it to ${PLAN_MODE_SETTINGS_FILE}. The legacy file was not modified.`,
|
|
212
|
+
}
|
|
213
|
+
: legacy.result;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function updatePlanModeSettings(
|
|
217
|
+
patch: PlanModeSettingsPatch,
|
|
218
|
+
options: UpdatePlanModeSettingsOptions = {},
|
|
219
|
+
): Promise<PlanModeSettings> {
|
|
220
|
+
const settingsPath = options.settingsPath ?? planModeSettingsPath();
|
|
221
|
+
const legacySettingsPath =
|
|
222
|
+
options.legacySettingsPath ?? (options.settingsPath ? undefined : legacyPlanModeSettingsPath());
|
|
223
|
+
return enqueueMutation(settingsPath, async () => {
|
|
224
|
+
options.signal?.throwIfAborted();
|
|
225
|
+
const current = await readSettingsDocumentForUpdate(settingsPath, legacySettingsPath);
|
|
226
|
+
const updated: SettingsDocument = { ...current };
|
|
227
|
+
if (patch.thinkingLevel !== undefined) updated.thinkingLevel = patch.thinkingLevel;
|
|
228
|
+
if (patch.defaultPlanTools === null) delete updated.defaultPlanTools;
|
|
229
|
+
else if (patch.defaultPlanTools !== undefined) {
|
|
230
|
+
updated.defaultPlanTools = [...patch.defaultPlanTools];
|
|
231
|
+
}
|
|
232
|
+
if (patch.implementationPlanRetention !== undefined) {
|
|
233
|
+
updated.implementationPlanRetention = patch.implementationPlanRetention;
|
|
234
|
+
}
|
|
235
|
+
if (patch.defaultPlanExportPath === null) delete updated.defaultPlanExportPath;
|
|
236
|
+
else if (patch.defaultPlanExportPath !== undefined) {
|
|
237
|
+
updated.defaultPlanExportPath = patch.defaultPlanExportPath;
|
|
238
|
+
}
|
|
239
|
+
if (patch.bashPolicy !== undefined) updated.bashPolicy = patch.bashPolicy;
|
|
240
|
+
const settings = normalizePlanModeSettings(updated);
|
|
241
|
+
if (!settings) throw invalidSettingsError(settingsPath, "invalid settings shape");
|
|
242
|
+
await publishSettings(settingsPath, updated, options.signal, options.beforeRename);
|
|
243
|
+
return settings;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function awaitPlanModeSettingsWrites(
|
|
248
|
+
settingsPath = planModeSettingsPath(),
|
|
249
|
+
): Promise<void> {
|
|
250
|
+
await mutationQueues.get(settingsPath);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function enqueueMutation<T>(settingsPath: string, mutation: () => Promise<T>): Promise<T> {
|
|
254
|
+
const previous = mutationQueues.get(settingsPath) ?? Promise.resolve();
|
|
255
|
+
const result = previous.then(mutation, mutation);
|
|
256
|
+
const settled = result.then(
|
|
257
|
+
() => undefined,
|
|
258
|
+
() => undefined,
|
|
259
|
+
);
|
|
260
|
+
mutationQueues.set(settingsPath, settled);
|
|
261
|
+
void settled.finally(() => {
|
|
262
|
+
if (mutationQueues.get(settingsPath) === settled) mutationQueues.delete(settingsPath);
|
|
263
|
+
});
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function readSettingsDocumentForUpdate(
|
|
268
|
+
settingsPath: string,
|
|
269
|
+
legacySettingsPath: string | undefined,
|
|
270
|
+
): Promise<SettingsDocument> {
|
|
271
|
+
const canonical = await readSettingsSnapshot(settingsPath);
|
|
272
|
+
if (canonical.result.kind === "loaded") return canonical.document ?? {};
|
|
273
|
+
if (canonical.result.kind === "invalid") {
|
|
274
|
+
throw invalidSettingsError(settingsPath, canonical.result.reason);
|
|
275
|
+
}
|
|
276
|
+
if (!legacySettingsPath) return {};
|
|
277
|
+
|
|
278
|
+
const legacy = await readSettingsSnapshot(legacySettingsPath);
|
|
279
|
+
const raced = await readSettingsSnapshot(settingsPath);
|
|
280
|
+
if (raced.result.kind === "loaded") return raced.document ?? {};
|
|
281
|
+
if (raced.result.kind === "invalid") {
|
|
282
|
+
throw invalidSettingsError(settingsPath, raced.result.reason);
|
|
283
|
+
}
|
|
284
|
+
if (legacy.result.kind === "invalid") {
|
|
285
|
+
throw invalidSettingsError(legacySettingsPath, legacy.result.reason);
|
|
286
|
+
}
|
|
287
|
+
return legacy.document ?? {};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function readSettingsSnapshot(settingsPath: string): Promise<SettingsSnapshot> {
|
|
291
|
+
let contents: string;
|
|
292
|
+
try {
|
|
293
|
+
contents = await readSettingsContents(settingsPath);
|
|
294
|
+
} catch (error: unknown) {
|
|
295
|
+
if (isNodeError(error) && error.code === "ENOENT") return { result: { kind: "missing" } };
|
|
296
|
+
return { result: { kind: "invalid", reason: safeReadError(error) } };
|
|
297
|
+
}
|
|
298
|
+
let parsed: unknown;
|
|
299
|
+
try {
|
|
300
|
+
parsed = JSON.parse(contents) as unknown;
|
|
301
|
+
} catch {
|
|
302
|
+
return { result: { kind: "invalid", reason: "invalid JSON" } };
|
|
303
|
+
}
|
|
304
|
+
const settings = normalizePlanModeSettings(parsed);
|
|
305
|
+
if (!settings || !isSettingsDocument(parsed)) {
|
|
306
|
+
return { result: { kind: "invalid", reason: "invalid settings shape" } };
|
|
307
|
+
}
|
|
308
|
+
return { document: parsed, result: { kind: "loaded", settings } };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function readSettingsContents(settingsPath: string): Promise<string> {
|
|
312
|
+
const flags = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0);
|
|
313
|
+
const handle = await open(settingsPath, flags);
|
|
314
|
+
try {
|
|
315
|
+
const stats = await handle.stat();
|
|
316
|
+
if (!stats.isFile()) throw new Error("settings path is not a regular file");
|
|
317
|
+
if (stats.size > MAX_SETTINGS_BYTES) {
|
|
318
|
+
throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`);
|
|
319
|
+
}
|
|
320
|
+
const buffer = Buffer.alloc(MAX_SETTINGS_BYTES + 1);
|
|
321
|
+
let offset = 0;
|
|
322
|
+
while (offset < buffer.byteLength) {
|
|
323
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, offset);
|
|
324
|
+
if (bytesRead === 0) break;
|
|
325
|
+
offset += bytesRead;
|
|
326
|
+
}
|
|
327
|
+
if (offset > MAX_SETTINGS_BYTES) {
|
|
328
|
+
throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`);
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
|
332
|
+
buffer.subarray(0, offset),
|
|
333
|
+
);
|
|
334
|
+
} catch {
|
|
335
|
+
throw new Error("settings file is not valid UTF-8");
|
|
336
|
+
}
|
|
337
|
+
} finally {
|
|
338
|
+
await handle.close();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function publishSettings(
|
|
343
|
+
settingsPath: string,
|
|
344
|
+
document: SettingsDocument,
|
|
345
|
+
signal?: AbortSignal,
|
|
346
|
+
beforeRename?: (temporaryPath: string, settingsPath: string) => Promise<void>,
|
|
347
|
+
): Promise<void> {
|
|
348
|
+
signal?.throwIfAborted();
|
|
349
|
+
const contents = `${JSON.stringify(document, null, 2)}\n`;
|
|
350
|
+
if (Buffer.byteLength(contents, "utf8") > MAX_SETTINGS_BYTES) {
|
|
351
|
+
throw new Error(`settings document exceeds ${MAX_SETTINGS_BYTES} bytes`);
|
|
352
|
+
}
|
|
353
|
+
const directory = dirname(settingsPath);
|
|
354
|
+
await mkdir(directory, { recursive: true });
|
|
355
|
+
signal?.throwIfAborted();
|
|
356
|
+
const temporaryPath = join(
|
|
357
|
+
directory,
|
|
358
|
+
`.${basename(settingsPath)}.${process.pid}.${randomUUID()}.tmp`,
|
|
359
|
+
);
|
|
360
|
+
try {
|
|
361
|
+
await writeFile(temporaryPath, contents, {
|
|
362
|
+
encoding: "utf8",
|
|
363
|
+
flag: "wx",
|
|
364
|
+
mode: 0o600,
|
|
365
|
+
signal,
|
|
366
|
+
});
|
|
367
|
+
await beforeRename?.(temporaryPath, settingsPath);
|
|
368
|
+
signal?.throwIfAborted();
|
|
369
|
+
await rename(temporaryPath, settingsPath);
|
|
370
|
+
} finally {
|
|
371
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function isSettingsDocument(value: unknown): value is SettingsDocument {
|
|
376
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function pathExists(path: string) {
|
|
380
|
+
try {
|
|
381
|
+
const handle = await open(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
382
|
+
await handle.close();
|
|
383
|
+
return true;
|
|
384
|
+
} catch (error: unknown) {
|
|
385
|
+
return !(isNodeError(error) && error.code === "ENOENT");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function invalidSettingsError(settingsPath: string, reason: string) {
|
|
390
|
+
return new Error(`pi-plan-mode settings at ${settingsPath} are invalid: ${reason}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
394
|
+
return error instanceof Error && "code" in error;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function safeReadError(error: unknown) {
|
|
398
|
+
if (isNodeError(error) && error.code === "ELOOP") return "settings path is not a regular file";
|
|
399
|
+
return error instanceof Error ? error.message : String(error);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function configuredThinkingLevel(
|
|
403
|
+
settings: PlanModeSettings,
|
|
404
|
+
): PlanModeFixedThinkingLevel | undefined {
|
|
405
|
+
return settings.thinkingLevel === "inherit" ? undefined : settings.thinkingLevel;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function configuredImplementationPlanRetention(
|
|
409
|
+
settings: PlanModeSettings,
|
|
410
|
+
): ImplementationPlanRetention {
|
|
411
|
+
return settings.implementationPlanRetention ?? "keep";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function configuredPlanExportPath(settings: PlanModeSettings) {
|
|
415
|
+
return settings.defaultPlanExportPath ?? DEFAULT_PLAN_EXPORT_PATH;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function configuredBashPolicy(settings: PlanModeSettings): PlanModeBashPolicy {
|
|
419
|
+
return settings.bashPolicy ?? "limited";
|
|
420
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizePlanModeCompletion,
|
|
3
|
+
PLAN_MODE_COMPLETE_TOOL_NAME,
|
|
4
|
+
planFromCompletionDetails,
|
|
5
|
+
} from "./completion-tool.js";
|
|
6
|
+
import {
|
|
7
|
+
IMPLEMENTATION_PLAN_RETENTIONS,
|
|
8
|
+
type ImplementationPlanRetention,
|
|
9
|
+
PLAN_MODE_THINKING_LEVELS,
|
|
10
|
+
type PlanModeFixedThinkingLevel,
|
|
11
|
+
} from "./settings.js";
|
|
12
|
+
|
|
13
|
+
export type PlanCompletionSource = typeof PLAN_MODE_COMPLETE_TOOL_NAME | "legacy_proposed_plan";
|
|
14
|
+
|
|
15
|
+
export interface ActiveImplementationPlan {
|
|
16
|
+
id: string;
|
|
17
|
+
plan: string;
|
|
18
|
+
source: PlanCompletionSource;
|
|
19
|
+
startedAt: number;
|
|
20
|
+
retention?: ImplementationPlanRetention;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SavedPlan {
|
|
24
|
+
plan: string;
|
|
25
|
+
source: PlanCompletionSource;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PlanModeState {
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
latestPlan?: string;
|
|
31
|
+
latestPlanSource?: PlanCompletionSource;
|
|
32
|
+
awaitingAction: boolean;
|
|
33
|
+
savedPlan?: SavedPlan;
|
|
34
|
+
activeImplementation?: ActiveImplementationPlan;
|
|
35
|
+
selectedToolNames?: string[];
|
|
36
|
+
selectedToolKeys?: string[];
|
|
37
|
+
previousThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
38
|
+
appliedThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
39
|
+
manualThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type SessionEntry = {
|
|
43
|
+
type?: string;
|
|
44
|
+
customType?: string;
|
|
45
|
+
data?: unknown;
|
|
46
|
+
message?: {
|
|
47
|
+
role?: string;
|
|
48
|
+
toolName?: string;
|
|
49
|
+
details?: unknown;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function restorePlanModeState(entries: unknown[], stateEntryType: string): PlanModeState {
|
|
54
|
+
const branch = entries as SessionEntry[];
|
|
55
|
+
let stateEntryIndex = -1;
|
|
56
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
57
|
+
const candidate = branch[index];
|
|
58
|
+
if (candidate?.type === "custom" && candidate.customType === stateEntryType) {
|
|
59
|
+
stateEntryIndex = index;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const entry = branch[stateEntryIndex];
|
|
64
|
+
if (!isRecord(entry?.data)) return { enabled: false, awaitingAction: false };
|
|
65
|
+
|
|
66
|
+
const enabled = entry.data.enabled === true;
|
|
67
|
+
const persistedSource = enabled ? planCompletionSource(entry.data.latestPlanSource) : undefined;
|
|
68
|
+
const persistedPlan = enabled ? normalizePersistedPlan(entry.data.latestPlan) : undefined;
|
|
69
|
+
const recoveredPlan =
|
|
70
|
+
enabled && !persistedPlan ? latestCompletionPlan(branch.slice(stateEntryIndex + 1)) : undefined;
|
|
71
|
+
const latestPlan = persistedPlan ?? recoveredPlan;
|
|
72
|
+
const activeImplementation = enabled
|
|
73
|
+
? undefined
|
|
74
|
+
: normalizeActiveImplementation(entry.data.activeImplementation);
|
|
75
|
+
const savedPlan =
|
|
76
|
+
enabled || activeImplementation ? undefined : normalizeSavedPlan(entry.data.savedPlan);
|
|
77
|
+
return {
|
|
78
|
+
enabled,
|
|
79
|
+
latestPlan,
|
|
80
|
+
latestPlanSource: enabled
|
|
81
|
+
? ((persistedPlan ? persistedSource : undefined) ??
|
|
82
|
+
(recoveredPlan ? PLAN_MODE_COMPLETE_TOOL_NAME : undefined))
|
|
83
|
+
: undefined,
|
|
84
|
+
awaitingAction: enabled && latestPlan !== undefined,
|
|
85
|
+
savedPlan,
|
|
86
|
+
activeImplementation,
|
|
87
|
+
selectedToolNames: stringArray(entry.data.selectedToolNames),
|
|
88
|
+
selectedToolKeys: stringArray(entry.data.selectedToolKeys),
|
|
89
|
+
previousThinkingLevel: enabled
|
|
90
|
+
? fixedThinkingLevel(entry.data.previousThinkingLevel)
|
|
91
|
+
: undefined,
|
|
92
|
+
appliedThinkingLevel: enabled ? fixedThinkingLevel(entry.data.appliedThinkingLevel) : undefined,
|
|
93
|
+
manualThinkingLevel: enabled ? fixedThinkingLevel(entry.data.manualThinkingLevel) : undefined,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeSavedPlan(value: unknown): SavedPlan | undefined {
|
|
98
|
+
if (!isRecord(value)) return undefined;
|
|
99
|
+
const source = planCompletionSource(value.source);
|
|
100
|
+
const normalized = normalizePlanModeCompletion({ plan: value.plan });
|
|
101
|
+
if (!source || !normalized.ok) return undefined;
|
|
102
|
+
return { plan: normalized.plan, source };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeActiveImplementation(value: unknown): ActiveImplementationPlan | undefined {
|
|
106
|
+
if (!isRecord(value)) return undefined;
|
|
107
|
+
const id =
|
|
108
|
+
typeof value.id === "string" && /^[A-Za-z0-9._:-]{1,128}$/u.test(value.id)
|
|
109
|
+
? value.id
|
|
110
|
+
: undefined;
|
|
111
|
+
const source = planCompletionSource(value.source);
|
|
112
|
+
const normalized = normalizePlanModeCompletion({ plan: value.plan });
|
|
113
|
+
const startedAt =
|
|
114
|
+
typeof value.startedAt === "number" &&
|
|
115
|
+
Number.isSafeInteger(value.startedAt) &&
|
|
116
|
+
value.startedAt >= 0
|
|
117
|
+
? value.startedAt
|
|
118
|
+
: undefined;
|
|
119
|
+
if (!id || !source || !normalized.ok || startedAt === undefined) return undefined;
|
|
120
|
+
const retention = IMPLEMENTATION_PLAN_RETENTIONS.includes(
|
|
121
|
+
value.retention as ImplementationPlanRetention,
|
|
122
|
+
)
|
|
123
|
+
? (value.retention as ImplementationPlanRetention)
|
|
124
|
+
: "keep";
|
|
125
|
+
return { id, plan: normalized.plan, source, startedAt, retention };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizePersistedPlan(value: unknown) {
|
|
129
|
+
const normalized = normalizePlanModeCompletion({ plan: value });
|
|
130
|
+
return normalized.ok ? normalized.plan : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function latestCompletionPlan(entries: SessionEntry[]) {
|
|
134
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
135
|
+
const message = entries[index]?.message;
|
|
136
|
+
if (message?.role !== "toolResult" || message.toolName !== PLAN_MODE_COMPLETE_TOOL_NAME) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const plan = planFromCompletionDetails(message.details);
|
|
140
|
+
if (plan) return plan;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function planCompletionSource(value: unknown): PlanCompletionSource | undefined {
|
|
146
|
+
return value === PLAN_MODE_COMPLETE_TOOL_NAME || value === "legacy_proposed_plan"
|
|
147
|
+
? value
|
|
148
|
+
: undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function fixedThinkingLevel(value: unknown): PlanModeFixedThinkingLevel | undefined {
|
|
152
|
+
return typeof value === "string" &&
|
|
153
|
+
value !== "inherit" &&
|
|
154
|
+
PLAN_MODE_THINKING_LEVELS.includes(value as (typeof PLAN_MODE_THINKING_LEVELS)[number])
|
|
155
|
+
? (value as PlanModeFixedThinkingLevel)
|
|
156
|
+
: undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function stringArray(value: unknown) {
|
|
160
|
+
return Array.isArray(value) && value.every((item): item is string => typeof item === "string")
|
|
161
|
+
? Array.from(new Set(value))
|
|
162
|
+
: undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
166
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
167
|
+
}
|