@hank-warren/pi-plan-mode 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/package.json +2 -2
- package/src/command.ts +1 -1
- package/src/completion-tool.ts +11 -6
- package/src/extension-runtime.ts +0 -11
- package/src/fresh-implementation.ts +2 -2
- package/src/lifecycle.ts +79 -0
- package/src/plan-export.ts +4 -4
- package/src/plan-file.ts +1 -5
- package/src/plan-launch-menu.ts +1 -1
- package/src/plan-mode.ts +174 -213
- package/src/presentation.ts +54 -12
- package/src/prompt.ts +26 -6
- package/src/question-tool.ts +26 -10
- package/src/settings-menu.ts +6 -48
- package/src/settings-watch.ts +60 -0
- package/src/settings.ts +12 -69
- package/src/state.ts +0 -44
package/src/presentation.ts
CHANGED
|
@@ -1,10 +1,59 @@
|
|
|
1
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
-
import
|
|
1
|
+
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import {
|
|
3
|
+
getMarkdownTheme,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type ExtensionContext,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
3
7
|
import { readPlanFile } from "./plan-file.js";
|
|
4
8
|
import type { PlanModeState } from "./state.js";
|
|
5
9
|
|
|
6
10
|
const STATUS_KEY = "plan-mode";
|
|
7
11
|
const PLAN_WIDGET_KEY = "plan-mode-plan";
|
|
12
|
+
export const PLAN_CARD_ENTRY_TYPE = "plan-mode-card";
|
|
13
|
+
|
|
14
|
+
type PlanCardData = { title: string; plan: string };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Persisted entry data is input, not a guarantee.
|
|
18
|
+
*
|
|
19
|
+
* The renderer runs against whatever is on disk, which may predate a field, be
|
|
20
|
+
* truncated by a partial write, or have been hand-edited. Pi contains a
|
|
21
|
+
* renderer throw as an inline `[plan-mode-card] renderer failed: …` box —
|
|
22
|
+
* survivable, but a needlessly ugly way to say "this card is old".
|
|
23
|
+
*/
|
|
24
|
+
function planCardData(value: unknown): PlanCardData | undefined {
|
|
25
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
26
|
+
const { title, plan } = value as { title?: unknown; plan?: unknown };
|
|
27
|
+
return typeof title === "string" && typeof plan === "string"
|
|
28
|
+
? (value as PlanCardData)
|
|
29
|
+
: undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The completed-plan card, as a display-only session entry.
|
|
34
|
+
*
|
|
35
|
+
* A custom *entry* rather than a message, which is what buys the property a
|
|
36
|
+
* message could not: Pi maps a `custom` entry to no context messages at all
|
|
37
|
+
* and skips it during compaction, so the plan stays visible and restorable in
|
|
38
|
+
* the transcript while never entering model context and never costing a
|
|
39
|
+
* compaction budget. The model gets a one-line `Plan saved to <path>.` from
|
|
40
|
+
* `plan_mode_complete` instead, and reads the durable file when it implements.
|
|
41
|
+
*
|
|
42
|
+
* pi-loop's approval card is the same mechanism for the same reason
|
|
43
|
+
* (`packages/pi-loop/src/presentation.ts`).
|
|
44
|
+
*/
|
|
45
|
+
export function registerPlanModeCardRenderer(pi: ExtensionAPI): void {
|
|
46
|
+
pi.registerEntryRenderer(PLAN_CARD_ENTRY_TYPE, (entry) => {
|
|
47
|
+
const data = planCardData(entry.data);
|
|
48
|
+
if (!data) return new Text("Plan card unavailable.", 0, 0);
|
|
49
|
+
return new Markdown(
|
|
50
|
+
`**${data.title}**\n\n${data.plan}`,
|
|
51
|
+
0,
|
|
52
|
+
0,
|
|
53
|
+
getMarkdownTheme(),
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
8
57
|
|
|
9
58
|
/**
|
|
10
59
|
* The one thing both surfaces render.
|
|
@@ -20,9 +69,9 @@ const PLAN_WIDGET_KEY = "plan-mode-plan";
|
|
|
20
69
|
* characters do not justify a shared package; a user reading a footer
|
|
21
70
|
* justifies the consistency.
|
|
22
71
|
*/
|
|
23
|
-
|
|
72
|
+
type PlanModePhase = "drafting" | "revising" | "ready" | "implementing";
|
|
24
73
|
|
|
25
|
-
|
|
74
|
+
interface PlanModeView {
|
|
26
75
|
phase: PlanModePhase;
|
|
27
76
|
/** The footer line: plain text with a glyph, no colour. */
|
|
28
77
|
footer: string;
|
|
@@ -152,14 +201,7 @@ export function showPlanModePlan(
|
|
|
152
201
|
plan: string,
|
|
153
202
|
) {
|
|
154
203
|
try {
|
|
155
|
-
pi.
|
|
156
|
-
{
|
|
157
|
-
customType: "proposed-plan",
|
|
158
|
-
content: `**${title}**\n\n${plan}`,
|
|
159
|
-
display: true,
|
|
160
|
-
},
|
|
161
|
-
{ triggerTurn: false },
|
|
162
|
-
);
|
|
204
|
+
pi.appendEntry<PlanCardData>(PLAN_CARD_ENTRY_TYPE, { title, plan });
|
|
163
205
|
} catch (error: unknown) {
|
|
164
206
|
const detail = error instanceof Error ? error.message : String(error);
|
|
165
207
|
ctx.ui.notify(`Unable to show completed plan: ${detail}`, "error");
|
package/src/prompt.ts
CHANGED
|
@@ -41,9 +41,29 @@ const QUESTION_TOOL_PROFILES: Record<string, QuestionToolProfile> = {
|
|
|
41
41
|
*
|
|
42
42
|
* The default keeps the exported function callable with no arguments and keeps
|
|
43
43
|
* a standalone `pi-plan-mode` install reading exactly as it did before.
|
|
44
|
+
*
|
|
45
|
+
* Passing `null` builds the headless variant: no interactive question tool is
|
|
46
|
+
* active in that session, so naming one would tell the model to call a tool it
|
|
47
|
+
* cannot see. It asks in plain text instead.
|
|
44
48
|
*/
|
|
45
|
-
export function buildPlanModePrompt(questionTool: string = PLAN_MODE_QUESTION_TOOL) {
|
|
46
|
-
const tool =
|
|
49
|
+
export function buildPlanModePrompt(questionTool: string | null = PLAN_MODE_QUESTION_TOOL) {
|
|
50
|
+
const tool =
|
|
51
|
+
questionTool === null
|
|
52
|
+
? undefined
|
|
53
|
+
: (QUESTION_TOOL_PROFILES[questionTool] ??
|
|
54
|
+
QUESTION_TOOL_PROFILES[PLAN_MODE_QUESTION_TOOL]);
|
|
55
|
+
const askBullet = tool
|
|
56
|
+
? `Use ${tool.name} for important preferences, tradeoffs, or assumption locks that cannot be discovered by non-mutating exploration. ${tool.bounds} Do not include filler options.`
|
|
57
|
+
: "This session has no interactive question tool, so ask in plain text: put important preferences, tradeoffs, or assumption locks that non-mutating exploration cannot settle in your reply as 1-3 concise questions with 2-4 meaningful options each. Do not include filler options, and never call a question tool that is not in your tool set.";
|
|
58
|
+
const declineBullet = tool
|
|
59
|
+
? `${tool.decline}, do not jump straight to a final plan when the missing answer is high impact. Ask one concise plain-text question or proceed only with a clearly stated low-risk assumption.`
|
|
60
|
+
: "If the question goes unanswered, do not jump straight to a final plan when the missing answer is high impact. Ask it again more concisely, or proceed only with a clearly stated low-risk assumption recorded in the plan.";
|
|
61
|
+
const endingBullet = tool
|
|
62
|
+
? `If a material decision remains, use ${tool.name}. If interactive UI is unavailable, ask one concise plain-text question instead.`
|
|
63
|
+
: "If a material decision remains, ask one concise plain-text question instead.";
|
|
64
|
+
const revisionClause = tool
|
|
65
|
+
? `continue planning with ${tool.name} instead of calling plan_mode_complete`
|
|
66
|
+
: "continue planning with a plain-text question instead of calling plan_mode_complete";
|
|
47
67
|
return `${PLAN_CONTEXT_MARKER}
|
|
48
68
|
# Plan Mode (Conversational)
|
|
49
69
|
|
|
@@ -73,14 +93,14 @@ You are in Plan Mode, a collaboration mode for producing a decision-complete imp
|
|
|
73
93
|
## Phase 3 — Implementation chat
|
|
74
94
|
|
|
75
95
|
- Once intent is stable, keep asking until the spec is decision-complete: approach, interfaces, data flow, edge cases/failure modes, testing and acceptance criteria, and any migration or compatibility constraints.
|
|
76
|
-
-
|
|
77
|
-
- ${
|
|
96
|
+
- ${askBullet}
|
|
97
|
+
- ${declineBullet}
|
|
78
98
|
|
|
79
99
|
## Ending each turn
|
|
80
100
|
|
|
81
101
|
Every Plan-mode turn that advances or finalizes the plan must end in exactly one of these ways:
|
|
82
102
|
|
|
83
|
-
-
|
|
103
|
+
- ${endingBullet}
|
|
84
104
|
- If the implementation plan is decision-complete, call plan_mode_complete alone as your final action. Do not call other tools in the same batch and do not emit a normal assistant response after it.
|
|
85
105
|
|
|
86
106
|
If a follow-up asks only for clarification and does not change or challenge the plan, answer it directly, then call plan_mode_complete alone as the final action with the complete unchanged plan so it remains available for implementation.
|
|
@@ -101,7 +121,7 @@ Keep the plan concise, human and agent digestible, and free of open decisions. P
|
|
|
101
121
|
|
|
102
122
|
The plan is saved to a durable file, so it survives compaction and can be re-read at any time.
|
|
103
123
|
|
|
104
|
-
If the user requests revisions after a completed plan, the next plan_mode_complete call must contain a complete replacement, not a delta. If there is not enough information for a complete replacement,
|
|
124
|
+
If the user requests revisions after a completed plan, the next plan_mode_complete call must contain a complete replacement, not a delta. If there is not enough information for a complete replacement, ${revisionClause}.`;
|
|
105
125
|
}
|
|
106
126
|
|
|
107
127
|
/**
|
package/src/question-tool.ts
CHANGED
|
@@ -2,12 +2,12 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
|
|
3
3
|
export const PLAN_MODE_QUESTION_TOOL_NAME = "plan_mode_question";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
type PlanModeQuestionOption = {
|
|
6
6
|
label: string;
|
|
7
7
|
description?: string;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
type PlanModeQuestion = {
|
|
11
11
|
id: string;
|
|
12
12
|
header: string;
|
|
13
13
|
question: string;
|
|
@@ -152,11 +152,13 @@ export async function answerPlanModeQuestions(
|
|
|
152
152
|
questions: PlanModeQuestion[],
|
|
153
153
|
ctx: ExtensionContext,
|
|
154
154
|
lifecycle: { isCurrent(): boolean; isEnabled(): boolean },
|
|
155
|
+
signal?: AbortSignal,
|
|
155
156
|
) {
|
|
156
157
|
const answers = await askPlanModeQuestions(
|
|
157
158
|
questions,
|
|
158
159
|
ctx,
|
|
159
|
-
() => lifecycle.isCurrent() && lifecycle.isEnabled(),
|
|
160
|
+
() => lifecycle.isCurrent() && lifecycle.isEnabled() && !signal?.aborted,
|
|
161
|
+
signal,
|
|
160
162
|
);
|
|
161
163
|
if (!lifecycle.isCurrent()) {
|
|
162
164
|
return planModeQuestionCancelled(
|
|
@@ -182,22 +184,26 @@ export async function answerPlanModeQuestions(
|
|
|
182
184
|
return planModeQuestionAnswered(questions, answers);
|
|
183
185
|
}
|
|
184
186
|
|
|
185
|
-
|
|
187
|
+
async function askPlanModeQuestions(
|
|
186
188
|
questions: PlanModeQuestion[],
|
|
187
189
|
ctx: ExtensionContext,
|
|
188
190
|
shouldContinue: () => boolean = () => true,
|
|
191
|
+
signal?: AbortSignal,
|
|
189
192
|
): Promise<PlanModeQuestionAnswer[] | undefined> {
|
|
190
193
|
const answers: PlanModeQuestionAnswer[] = [];
|
|
191
194
|
for (const question of questions) {
|
|
195
|
+
if (!shouldContinue() || signal?.aborted) return undefined;
|
|
192
196
|
const choices = question.options.map(formatPlanModeQuestionChoice);
|
|
193
197
|
const otherChoice = `${question.options.length + 1}. Other (free-form)`;
|
|
194
|
-
const choice = await
|
|
195
|
-
...choices,
|
|
196
|
-
|
|
197
|
-
|
|
198
|
+
const choice = await raceWithAbort(
|
|
199
|
+
ctx.ui.select(`${question.header}: ${question.question}`, [...choices, otherChoice]),
|
|
200
|
+
signal,
|
|
201
|
+
);
|
|
198
202
|
if (!shouldContinue() || !choice) return undefined;
|
|
199
203
|
if (choice === otherChoice) {
|
|
200
|
-
const customAnswer = (
|
|
204
|
+
const customAnswer = (
|
|
205
|
+
await raceWithAbort(ctx.ui.editor(question.question, ""), signal)
|
|
206
|
+
)?.trim();
|
|
201
207
|
if (!shouldContinue() || !customAnswer) return undefined;
|
|
202
208
|
answers.push({
|
|
203
209
|
id: question.id,
|
|
@@ -223,11 +229,21 @@ export async function askPlanModeQuestions(
|
|
|
223
229
|
return answers;
|
|
224
230
|
}
|
|
225
231
|
|
|
232
|
+
async function raceWithAbort<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T | undefined> {
|
|
233
|
+
if (!signal) return operation;
|
|
234
|
+
if (signal.aborted) return undefined;
|
|
235
|
+
return new Promise<T | undefined>((resolve, reject) => {
|
|
236
|
+
const abort = () => resolve(undefined);
|
|
237
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
238
|
+
operation.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
226
242
|
function formatPlanModeQuestionChoice(option: PlanModeQuestionOption, index: number) {
|
|
227
243
|
return `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`;
|
|
228
244
|
}
|
|
229
245
|
|
|
230
|
-
|
|
246
|
+
function planModeQuestionAnswered(
|
|
231
247
|
questions: PlanModeQuestion[],
|
|
232
248
|
answers: PlanModeQuestionAnswer[],
|
|
233
249
|
) {
|
package/src/settings-menu.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
import { defineMenu, type RunMenuResult, runMenu } from "@narumitw/pi-tui-kit";
|
|
4
3
|
import { planExportDestination } from "./plan-export.js";
|
|
@@ -16,17 +15,13 @@ import {
|
|
|
16
15
|
interface SettingsMenuState {
|
|
17
16
|
kind: "valid" | "invalid";
|
|
18
17
|
settings: PlanModeSettings;
|
|
19
|
-
notice?: string;
|
|
20
18
|
reason?: string;
|
|
21
|
-
/** The removed `thinkingLevel` key is still in the file. legacy: delete in 1.4.0 */
|
|
22
|
-
hasLegacyThinkingLevel?: boolean;
|
|
23
19
|
}
|
|
24
20
|
|
|
25
|
-
|
|
21
|
+
interface PlanModeSettingsMenuOptions {
|
|
26
22
|
signal: AbortSignal;
|
|
27
23
|
isCurrent(): boolean;
|
|
28
24
|
settingsPath?: string;
|
|
29
|
-
legacySettingsPath?: string;
|
|
30
25
|
readSettings?: (settingsPath?: string) => Promise<PlanModeSettingsLoadResult>;
|
|
31
26
|
updateSettings?: (
|
|
32
27
|
patch: PlanModeSettingsPatch,
|
|
@@ -49,19 +44,9 @@ export async function showPlanModeSettings(
|
|
|
49
44
|
const loadState = async (): Promise<SettingsMenuState> => {
|
|
50
45
|
const loaded = await readSettings(options.settingsPath);
|
|
51
46
|
if (loaded.kind === "invalid") {
|
|
52
|
-
return {
|
|
53
|
-
kind: "invalid",
|
|
54
|
-
settings: {},
|
|
55
|
-
notice: loaded.notice,
|
|
56
|
-
reason: loaded.reason,
|
|
57
|
-
};
|
|
47
|
+
return { kind: "invalid", settings: {}, reason: loaded.reason };
|
|
58
48
|
}
|
|
59
|
-
return {
|
|
60
|
-
kind: "valid",
|
|
61
|
-
settings: loaded.kind === "loaded" ? loaded.settings : {},
|
|
62
|
-
notice: loaded.notice,
|
|
63
|
-
hasLegacyThinkingLevel: await hasLegacyThinkingLevel(settingsPath),
|
|
64
|
-
};
|
|
49
|
+
return { kind: "valid", settings: loaded.kind === "loaded" ? loaded.settings : {} };
|
|
65
50
|
};
|
|
66
51
|
|
|
67
52
|
const menu = defineMenu<SettingsMenuState, Screen, Action, ExtensionContext>({
|
|
@@ -73,7 +58,7 @@ export async function showPlanModeSettings(
|
|
|
73
58
|
: {
|
|
74
59
|
kind: "settings",
|
|
75
60
|
title: "Plan Mode Settings",
|
|
76
|
-
lines: settingsLines(settingsPath
|
|
61
|
+
lines: settingsLines(settingsPath),
|
|
77
62
|
items: [
|
|
78
63
|
{
|
|
79
64
|
id: "defaultPlanExportPath",
|
|
@@ -132,11 +117,7 @@ export async function showPlanModeSettings(
|
|
|
132
117
|
) {
|
|
133
118
|
if (signal.aborted || !options.isCurrent()) return { kind: "rejected" as const };
|
|
134
119
|
try {
|
|
135
|
-
const saved = await updateSettings(patch, {
|
|
136
|
-
settingsPath: options.settingsPath,
|
|
137
|
-
legacySettingsPath: options.legacySettingsPath,
|
|
138
|
-
signal,
|
|
139
|
-
});
|
|
120
|
+
const saved = await updateSettings(patch, { settingsPath: options.settingsPath, signal });
|
|
140
121
|
if (options.isCurrent()) options.onSaved(saved);
|
|
141
122
|
if (signal.aborted || !options.isCurrent()) return { kind: "rejected" as const };
|
|
142
123
|
actionCtx.ui.notify(successMessage, "info");
|
|
@@ -153,35 +134,13 @@ export async function showPlanModeSettings(
|
|
|
153
134
|
}
|
|
154
135
|
}
|
|
155
136
|
|
|
156
|
-
function settingsLines(settingsPath: string
|
|
137
|
+
function settingsLines(settingsPath: string) {
|
|
157
138
|
return [
|
|
158
139
|
`User settings · ${safeTerminalText(settingsPath)}`,
|
|
159
140
|
"The export destination applies to its next action.",
|
|
160
|
-
// legacy: delete in 1.4.0
|
|
161
|
-
...(state.hasLegacyThinkingLevel
|
|
162
|
-
? [
|
|
163
|
-
"thinkingLevel is no longer used — thinking is a session setting and Plan mode never changes it.",
|
|
164
|
-
]
|
|
165
|
-
: []),
|
|
166
|
-
...(state.notice ? [safeTerminalText(state.notice)] : []),
|
|
167
141
|
];
|
|
168
142
|
}
|
|
169
143
|
|
|
170
|
-
/**
|
|
171
|
-
* The removed key is preserved verbatim on save, so the only way to know it is
|
|
172
|
-
* still there is to look at the file. A read failure simply hides the notice.
|
|
173
|
-
*
|
|
174
|
-
* legacy: delete in 1.4.0
|
|
175
|
-
*/
|
|
176
|
-
async function hasLegacyThinkingLevel(settingsPath: string) {
|
|
177
|
-
try {
|
|
178
|
-
const parsed: unknown = JSON.parse(await readFile(settingsPath, "utf8"));
|
|
179
|
-
return typeof parsed === "object" && parsed !== null && Object.hasOwn(parsed, "thinkingLevel");
|
|
180
|
-
} catch {
|
|
181
|
-
return false;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
144
|
function invalidScreen(settingsPath: string, state: SettingsMenuState) {
|
|
186
145
|
return {
|
|
187
146
|
kind: "detail" as const,
|
|
@@ -189,7 +148,6 @@ function invalidScreen(settingsPath: string, state: SettingsMenuState) {
|
|
|
189
148
|
lines: [
|
|
190
149
|
`Invalid settings file. Fix ${safeTerminalText(settingsPath)} before saving.`,
|
|
191
150
|
safeTerminalText(state.reason ?? "The settings file is invalid."),
|
|
192
|
-
...(state.notice ? [safeTerminalText(state.notice)] : []),
|
|
193
151
|
],
|
|
194
152
|
hint: "back" as const,
|
|
195
153
|
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { watch } from "node:fs";
|
|
2
|
+
import { basename, dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
interface SettingsWatcherOptions {
|
|
5
|
+
/** The settings file to follow; its directory is what actually gets watched. */
|
|
6
|
+
path: string;
|
|
7
|
+
debounceMs: number;
|
|
8
|
+
onChange(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Follows one settings file for out-of-band edits.
|
|
13
|
+
*
|
|
14
|
+
* The watch is on the file's *directory* rather than the file itself: saves go
|
|
15
|
+
* through a temp file and an atomic rename, and a watch bound to the old inode
|
|
16
|
+
* would go deaf after the first one. One hand-edit or menu save also fans out
|
|
17
|
+
* into several filesystem events (temp file created, renamed into place), so
|
|
18
|
+
* `debounceMs` collapses them into a single `onChange`.
|
|
19
|
+
*/
|
|
20
|
+
export function createSettingsWatcher(options: SettingsWatcherOptions) {
|
|
21
|
+
const watchedFile = basename(options.path);
|
|
22
|
+
let watcher: ReturnType<typeof watch> | undefined;
|
|
23
|
+
let reloadTimer: ReturnType<typeof setTimeout> | undefined;
|
|
24
|
+
|
|
25
|
+
const stop = () => {
|
|
26
|
+
if (reloadTimer) {
|
|
27
|
+
clearTimeout(reloadTimer);
|
|
28
|
+
reloadTimer = undefined;
|
|
29
|
+
}
|
|
30
|
+
watcher?.close();
|
|
31
|
+
watcher = undefined;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
start() {
|
|
36
|
+
stop();
|
|
37
|
+
try {
|
|
38
|
+
const started = watch(dirname(options.path), { persistent: false }, (event, changed) => {
|
|
39
|
+
if (event !== "rename" && event !== "change") return;
|
|
40
|
+
// A null filename means the platform could not name the entry; reload
|
|
41
|
+
// rather than miss the edit. The directory holds other churn, so a
|
|
42
|
+
// named entry that is not ours is ignored.
|
|
43
|
+
if (changed && changed.toString() !== watchedFile) return;
|
|
44
|
+
if (reloadTimer) clearTimeout(reloadTimer);
|
|
45
|
+
reloadTimer = setTimeout(() => {
|
|
46
|
+
reloadTimer = undefined;
|
|
47
|
+
options.onChange();
|
|
48
|
+
}, options.debounceMs);
|
|
49
|
+
});
|
|
50
|
+
started.on("error", stop);
|
|
51
|
+
watcher = started;
|
|
52
|
+
} catch {
|
|
53
|
+
// An unwatchable directory only costs the live reload; settings still
|
|
54
|
+
// load at session start.
|
|
55
|
+
stop();
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
stop,
|
|
59
|
+
};
|
|
60
|
+
}
|
package/src/settings.ts
CHANGED
|
@@ -5,7 +5,6 @@ import { basename, dirname, join } from "node:path";
|
|
|
5
5
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
7
|
export const PLAN_MODE_SETTINGS_FILE = "pi-plan-mode.json";
|
|
8
|
-
const LEGACY_PLAN_MODE_SETTINGS_FILE = "plan-mode.json";
|
|
9
8
|
const MAX_SETTINGS_BYTES = 64 * 1024;
|
|
10
9
|
export const DEFAULT_PLAN_EXPORT_PATH = "PLAN.md";
|
|
11
10
|
const MAX_PLAN_EXPORT_PATH_LENGTH = 4096;
|
|
@@ -18,14 +17,13 @@ export interface PlanModeSettingsPatch {
|
|
|
18
17
|
}
|
|
19
18
|
export interface UpdatePlanModeSettingsOptions {
|
|
20
19
|
settingsPath?: string;
|
|
21
|
-
legacySettingsPath?: string;
|
|
22
20
|
signal?: AbortSignal;
|
|
23
21
|
beforeRename?: (temporaryPath: string, settingsPath: string) => Promise<void>;
|
|
24
22
|
}
|
|
25
23
|
export type PlanModeSettingsLoadResult =
|
|
26
|
-
| { kind: "missing"
|
|
27
|
-
| { kind: "invalid"; reason: string
|
|
28
|
-
| { kind: "loaded"; settings: PlanModeSettings
|
|
24
|
+
| { kind: "missing" }
|
|
25
|
+
| { kind: "invalid"; reason: string }
|
|
26
|
+
| { kind: "loaded"; settings: PlanModeSettings };
|
|
29
27
|
|
|
30
28
|
type SettingsDocument = Record<string, unknown>;
|
|
31
29
|
type SettingsSnapshot = {
|
|
@@ -39,10 +37,6 @@ export function planModeSettingsPath() {
|
|
|
39
37
|
return join(getAgentDir(), PLAN_MODE_SETTINGS_FILE);
|
|
40
38
|
}
|
|
41
39
|
|
|
42
|
-
function legacyPlanModeSettingsPath() {
|
|
43
|
-
return join(getAgentDir(), LEGACY_PLAN_MODE_SETTINGS_FILE);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
40
|
/**
|
|
47
41
|
* Unknown top-level keys are tolerated and preserved on save. Settings removed
|
|
48
42
|
* over time (defaultPlanTools, bashPolicy, safeSubcommands,
|
|
@@ -80,34 +74,10 @@ function normalizePlanExportPath(value: unknown) {
|
|
|
80
74
|
}
|
|
81
75
|
|
|
82
76
|
export async function readPlanModeSettings(
|
|
83
|
-
settingsPath
|
|
77
|
+
settingsPath = planModeSettingsPath(),
|
|
84
78
|
): Promise<PlanModeSettingsLoadResult> {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return (await readSettingsSnapshot(settingsPath)).result;
|
|
88
|
-
}
|
|
89
|
-
const canonicalPath = planModeSettingsPath();
|
|
90
|
-
await awaitPlanModeSettingsWrites(canonicalPath);
|
|
91
|
-
const canonical = await readSettingsSnapshot(canonicalPath);
|
|
92
|
-
const legacyPath = legacyPlanModeSettingsPath();
|
|
93
|
-
if (canonical.result.kind !== "missing") {
|
|
94
|
-
return (await pathExists(legacyPath))
|
|
95
|
-
? {
|
|
96
|
-
...canonical.result,
|
|
97
|
-
notice: `${LEGACY_PLAN_MODE_SETTINGS_FILE} ignored because ${PLAN_MODE_SETTINGS_FILE} takes precedence.`,
|
|
98
|
-
}
|
|
99
|
-
: canonical.result;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const legacy = await readSettingsSnapshot(legacyPath);
|
|
103
|
-
const raced = await readSettingsSnapshot(canonicalPath);
|
|
104
|
-
if (raced.result.kind !== "missing") return raced.result;
|
|
105
|
-
return legacy.result.kind === "loaded"
|
|
106
|
-
? {
|
|
107
|
-
...legacy.result,
|
|
108
|
-
notice: `Using legacy ${LEGACY_PLAN_MODE_SETTINGS_FILE}; rename it to ${PLAN_MODE_SETTINGS_FILE}. The legacy file was not modified.`,
|
|
109
|
-
}
|
|
110
|
-
: legacy.result;
|
|
79
|
+
await awaitPlanModeSettingsWrites(settingsPath);
|
|
80
|
+
return (await readSettingsSnapshot(settingsPath)).result;
|
|
111
81
|
}
|
|
112
82
|
|
|
113
83
|
export function updatePlanModeSettings(
|
|
@@ -115,11 +85,9 @@ export function updatePlanModeSettings(
|
|
|
115
85
|
options: UpdatePlanModeSettingsOptions = {},
|
|
116
86
|
): Promise<PlanModeSettings> {
|
|
117
87
|
const settingsPath = options.settingsPath ?? planModeSettingsPath();
|
|
118
|
-
const legacySettingsPath =
|
|
119
|
-
options.legacySettingsPath ?? (options.settingsPath ? undefined : legacyPlanModeSettingsPath());
|
|
120
88
|
return enqueueMutation(settingsPath, async () => {
|
|
121
89
|
options.signal?.throwIfAborted();
|
|
122
|
-
const current = await readSettingsDocumentForUpdate(settingsPath
|
|
90
|
+
const current = await readSettingsDocumentForUpdate(settingsPath);
|
|
123
91
|
const updated: SettingsDocument = { ...current };
|
|
124
92
|
if (patch.defaultPlanExportPath === null) delete updated.defaultPlanExportPath;
|
|
125
93
|
else if (patch.defaultPlanExportPath !== undefined) {
|
|
@@ -152,27 +120,12 @@ function enqueueMutation<T>(settingsPath: string, mutation: () => Promise<T>): P
|
|
|
152
120
|
return result;
|
|
153
121
|
}
|
|
154
122
|
|
|
155
|
-
async function readSettingsDocumentForUpdate(
|
|
156
|
-
settingsPath
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const canonical = await readSettingsSnapshot(settingsPath);
|
|
160
|
-
if (canonical.result.kind === "loaded") return canonical.document ?? {};
|
|
161
|
-
if (canonical.result.kind === "invalid") {
|
|
162
|
-
throw invalidSettingsError(settingsPath, canonical.result.reason);
|
|
163
|
-
}
|
|
164
|
-
if (!legacySettingsPath) return {};
|
|
165
|
-
|
|
166
|
-
const legacy = await readSettingsSnapshot(legacySettingsPath);
|
|
167
|
-
const raced = await readSettingsSnapshot(settingsPath);
|
|
168
|
-
if (raced.result.kind === "loaded") return raced.document ?? {};
|
|
169
|
-
if (raced.result.kind === "invalid") {
|
|
170
|
-
throw invalidSettingsError(settingsPath, raced.result.reason);
|
|
171
|
-
}
|
|
172
|
-
if (legacy.result.kind === "invalid") {
|
|
173
|
-
throw invalidSettingsError(legacySettingsPath, legacy.result.reason);
|
|
123
|
+
async function readSettingsDocumentForUpdate(settingsPath: string): Promise<SettingsDocument> {
|
|
124
|
+
const snapshot = await readSettingsSnapshot(settingsPath);
|
|
125
|
+
if (snapshot.result.kind === "invalid") {
|
|
126
|
+
throw invalidSettingsError(settingsPath, snapshot.result.reason);
|
|
174
127
|
}
|
|
175
|
-
return
|
|
128
|
+
return snapshot.result.kind === "loaded" ? (snapshot.document ?? {}) : {};
|
|
176
129
|
}
|
|
177
130
|
|
|
178
131
|
async function readSettingsSnapshot(settingsPath: string): Promise<SettingsSnapshot> {
|
|
@@ -264,16 +217,6 @@ function isSettingsDocument(value: unknown): value is SettingsDocument {
|
|
|
264
217
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
265
218
|
}
|
|
266
219
|
|
|
267
|
-
async function pathExists(path: string) {
|
|
268
|
-
try {
|
|
269
|
-
const handle = await open(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
270
|
-
await handle.close();
|
|
271
|
-
return true;
|
|
272
|
-
} catch (error: unknown) {
|
|
273
|
-
return !(isNodeError(error) && error.code === "ENOENT");
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
220
|
function invalidSettingsError(settingsPath: string, reason: string) {
|
|
278
221
|
return new Error(`pi-plan-mode settings at ${settingsPath} are invalid: ${reason}`);
|
|
279
222
|
}
|
package/src/state.ts
CHANGED
|
@@ -39,42 +39,6 @@ function newestStateEntry(entries: unknown[], stateEntryType: string): SessionEn
|
|
|
39
39
|
return undefined;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
// legacy: delete in 1.4.0
|
|
43
|
-
const LEGACY_THINKING_LEVELS = [
|
|
44
|
-
"off",
|
|
45
|
-
"minimal",
|
|
46
|
-
"low",
|
|
47
|
-
"medium",
|
|
48
|
-
"high",
|
|
49
|
-
"xhigh",
|
|
50
|
-
"max",
|
|
51
|
-
] as const;
|
|
52
|
-
|
|
53
|
-
// legacy: delete in 1.4.0
|
|
54
|
-
export type LegacyThinkingCapture = {
|
|
55
|
-
previous: (typeof LEGACY_THINKING_LEVELS)[number];
|
|
56
|
-
applied: (typeof LEGACY_THINKING_LEVELS)[number];
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Reads the thinking-level capture written by pi-plan-mode <= 1.2.1, so a
|
|
61
|
-
* session interrupted while Plan mode held a raised level can have the user's
|
|
62
|
-
* level put back once. Both halves must be present and valid: a partial or
|
|
63
|
-
* absent capture is nothing to repair.
|
|
64
|
-
*
|
|
65
|
-
* legacy: delete in 1.4.0
|
|
66
|
-
*/
|
|
67
|
-
export function readLegacyThinkingCapture(
|
|
68
|
-
entries: unknown[],
|
|
69
|
-
stateEntryType: string,
|
|
70
|
-
): LegacyThinkingCapture | undefined {
|
|
71
|
-
const entry = newestStateEntry(entries, stateEntryType);
|
|
72
|
-
if (!isRecord(entry?.data)) return undefined;
|
|
73
|
-
const previous = legacyThinkingLevel(entry.data.previousThinkingLevel);
|
|
74
|
-
const applied = legacyThinkingLevel(entry.data.appliedThinkingLevel);
|
|
75
|
-
return previous && applied ? { previous, applied } : undefined;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
42
|
/**
|
|
79
43
|
* Persisted paths are only trusted when they are absolute and free of NUL, so
|
|
80
44
|
* malformed state can never redirect a read or a delete to a relative target.
|
|
@@ -86,14 +50,6 @@ function absolutePath(value: unknown) {
|
|
|
86
50
|
return normalized;
|
|
87
51
|
}
|
|
88
52
|
|
|
89
|
-
// legacy: delete in 1.4.0
|
|
90
|
-
function legacyThinkingLevel(value: unknown): (typeof LEGACY_THINKING_LEVELS)[number] | undefined {
|
|
91
|
-
return typeof value === "string" &&
|
|
92
|
-
LEGACY_THINKING_LEVELS.includes(value as (typeof LEGACY_THINKING_LEVELS)[number])
|
|
93
|
-
? (value as (typeof LEGACY_THINKING_LEVELS)[number])
|
|
94
|
-
: undefined;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
53
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
98
54
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
99
55
|
}
|