@hank-warren/pi-plan-mode 1.5.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 +11 -0
- package/package.json +2 -2
- package/src/command.ts +1 -1
- package/src/completion-tool.ts +5 -5
- 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 +95 -177
- package/src/presentation.ts +2 -2
- package/src/question-tool.ts +4 -4
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# @hank-warren/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 1.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d0c46a5: Drop two legacy paths that were past their delete-by date:
|
|
8
|
+
|
|
9
|
+
- The one-shot repair of a thinking level left raised by a pre-1.3.0 session, and the "thinkingLevel is no longer used" row in the settings menu. An unknown `thinkingLevel` key in `pi-plan-mode.json` is still preserved verbatim on save.
|
|
10
|
+
- The `plan-mode.json` settings fallback and its "Using legacy…" / "ignored because…" notices. Only `$PI_CODING_AGENT_DIR/pi-plan-mode.json` is read now; a host still on the old filename gets defaults and should rename the file.
|
|
11
|
+
|
|
12
|
+
Internally, the settings watcher and the menu/workflow lifecycle moved into their own modules and the state transitions share one helper; nothing else about `/plan` changed. `engines.node` now states Pi's own floor, `>=22.19.0`.
|
|
13
|
+
|
|
3
14
|
## 1.5.0
|
|
4
15
|
|
|
5
16
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-plan-mode",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Plan mode for Pi: research and design with a durable plan file that survives compaction.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"homepage": "https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-plan-mode#readme",
|
|
24
24
|
"engines": {
|
|
25
|
-
"node": ">=
|
|
25
|
+
"node": ">=22.19.0"
|
|
26
26
|
},
|
|
27
27
|
"pi": {
|
|
28
28
|
"extensions": [
|
package/src/command.ts
CHANGED
package/src/completion-tool.ts
CHANGED
|
@@ -2,10 +2,10 @@ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { Markdown } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
4
|
export const PLAN_MODE_COMPLETE_TOOL_NAME = "plan_mode_complete";
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
const PLAN_MODE_COMPLETE_VERSION = 1;
|
|
6
|
+
const PLAN_MODE_MAX_CHARS = 50_000;
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
type PlanModeCompletionDetails = {
|
|
9
9
|
version: typeof PLAN_MODE_COMPLETE_VERSION;
|
|
10
10
|
source: typeof PLAN_MODE_COMPLETE_TOOL_NAME;
|
|
11
11
|
plan: string;
|
|
@@ -43,7 +43,7 @@ export function normalizePlanModeCompletion(input: unknown): NormalizePlanModeCo
|
|
|
43
43
|
return { ok: true, plan };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
function planFromCompletionDetails(value: unknown) {
|
|
47
47
|
if (!isRecord(value)) return undefined;
|
|
48
48
|
if (
|
|
49
49
|
value.version !== PLAN_MODE_COMPLETE_VERSION ||
|
|
@@ -78,7 +78,7 @@ type PlanModeCompletionRenderResult = {
|
|
|
78
78
|
details?: unknown;
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
function planModeCompletionMarkdown(result: PlanModeCompletionRenderResult) {
|
|
82
82
|
const content = result.content
|
|
83
83
|
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
84
84
|
.map((block) => block.text)
|
package/src/extension-runtime.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { LegacyThinkingCapture } from "./state.js";
|
|
3
2
|
|
|
4
3
|
type AgentSettledHandler = (event: unknown, ctx: ExtensionContext) => unknown;
|
|
5
4
|
|
|
@@ -11,16 +10,6 @@ export function onAgentSettled(pi: ExtensionAPI, handler: AgentSettledHandler) {
|
|
|
11
10
|
).on("agent_settled", handler);
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
/**
|
|
15
|
-
* Only the one-shot migration that undoes a pre-1.3.0 thinking-level change
|
|
16
|
-
* still calls this. Plan mode never sets the thinking level otherwise.
|
|
17
|
-
*
|
|
18
|
-
* legacy: delete in 1.4.0
|
|
19
|
-
*/
|
|
20
|
-
export function setPlanThinkingLevel(pi: ExtensionAPI, level: LegacyThinkingCapture["previous"]) {
|
|
21
|
-
(pi.setThinkingLevel as unknown as (level: string) => void)(level);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
13
|
export function isStaleExtensionContextError(error: unknown) {
|
|
25
14
|
return (
|
|
26
15
|
error instanceof Error &&
|
|
@@ -5,7 +5,7 @@ import type { PlanModeState } from "./state.js";
|
|
|
5
5
|
type NewSessionOptions = Exclude<Parameters<ExtensionCommandContext["newSession"]>[0], undefined>;
|
|
6
6
|
type ReplacementContext = Parameters<NonNullable<NewSessionOptions["withSession"]>>[0];
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
interface FreshImplementationRequest {
|
|
9
9
|
plan: string;
|
|
10
10
|
planPath: string;
|
|
11
11
|
stateEntryType: string;
|
|
@@ -18,7 +18,7 @@ interface FreshImplementationFromStateOptions {
|
|
|
18
18
|
stateEntryType: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
type FreshImplementationResult =
|
|
22
22
|
| { kind: "started" }
|
|
23
23
|
| { kind: "cancelled" }
|
|
24
24
|
| { kind: "partial" }
|
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A scope captured when deferred work starts: the signal that work should race
|
|
3
|
+
* against, and the question "is what I was started for still the current
|
|
4
|
+
* thing?".
|
|
5
|
+
*/
|
|
6
|
+
export interface LifecycleScope {
|
|
7
|
+
readonly signal: AbortSignal;
|
|
8
|
+
isCurrent(): boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Two nested generations decide whether deferred Plan-mode work may still act.
|
|
13
|
+
*
|
|
14
|
+
* The session generation moves when Pi replaces or shuts down the session: a
|
|
15
|
+
* menu, a settings reload, or a question left waiting from the previous session
|
|
16
|
+
* must never write to the new one. The workflow generation moves on every
|
|
17
|
+
* enter/exit/implement, so a menu opened against one plan cannot act after the
|
|
18
|
+
* user has moved on — while a settings reload, which belongs to the session
|
|
19
|
+
* rather than to a plan, is deliberately left alone by it.
|
|
20
|
+
*
|
|
21
|
+
* The abort signal is the second half of the same rule: it stops work that is
|
|
22
|
+
* already blocked on the UI, where a generation check would never be reached.
|
|
23
|
+
*/
|
|
24
|
+
export function createLifecycle() {
|
|
25
|
+
let sessionGeneration = 0;
|
|
26
|
+
let workflowGeneration = 0;
|
|
27
|
+
let controller = new AbortController();
|
|
28
|
+
|
|
29
|
+
const sessionScope = (): LifecycleScope => {
|
|
30
|
+
const session = sessionGeneration;
|
|
31
|
+
const active = controller;
|
|
32
|
+
return {
|
|
33
|
+
signal: active.signal,
|
|
34
|
+
isCurrent: () => session === sessionGeneration && !active.signal.aborted,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ends the current session: everything captured before this call goes stale
|
|
40
|
+
* and everything waiting on the signal is aborted with `reason`. The aborted
|
|
41
|
+
* signal stays in place, so anything captured *after* it is stale too —
|
|
42
|
+
* which is what a shut-down session wants: there is no next session to be
|
|
43
|
+
* current for, and a menu opened in that window must refuse to run.
|
|
44
|
+
*/
|
|
45
|
+
const endSession = (reason: string) => {
|
|
46
|
+
sessionGeneration += 1;
|
|
47
|
+
controller.abort(new DOMException(reason, "AbortError"));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
/** The live session signal, for composing with a caller's own. */
|
|
52
|
+
get signal() {
|
|
53
|
+
return controller.signal;
|
|
54
|
+
},
|
|
55
|
+
endSession,
|
|
56
|
+
/**
|
|
57
|
+
* Ends the current session and opens the next one, whose scope is
|
|
58
|
+
* returned: work started from here races against a fresh signal.
|
|
59
|
+
*/
|
|
60
|
+
nextSession(reason: string): LifecycleScope {
|
|
61
|
+
endSession(reason);
|
|
62
|
+
controller = new AbortController();
|
|
63
|
+
return sessionScope();
|
|
64
|
+
},
|
|
65
|
+
/** Supersedes menus and prompts opened against the previous plan state. */
|
|
66
|
+
nextWorkflow() {
|
|
67
|
+
workflowGeneration += 1;
|
|
68
|
+
},
|
|
69
|
+
/** The scope for menu-scale work: stale as soon as either generation moves. */
|
|
70
|
+
capture(): LifecycleScope {
|
|
71
|
+
const session = sessionScope();
|
|
72
|
+
const workflow = workflowGeneration;
|
|
73
|
+
return {
|
|
74
|
+
signal: session.signal,
|
|
75
|
+
isCurrent: () => session.isCurrent() && workflow === workflowGeneration,
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/plan-export.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { PlanModeState } from "./state.js";
|
|
|
8
8
|
|
|
9
9
|
export { DEFAULT_PLAN_EXPORT_PATH };
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
interface PlanExportResult {
|
|
12
12
|
path: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -17,7 +17,7 @@ export interface PlanExportDestination {
|
|
|
17
17
|
resolvedPath: string;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
interface PlanExportLifecycle {
|
|
21
21
|
signal: AbortSignal;
|
|
22
22
|
isCurrent(): boolean;
|
|
23
23
|
getState?(): PlanModeState;
|
|
@@ -74,7 +74,7 @@ export async function exportStoredPlan(
|
|
|
74
74
|
return true;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
async function exportPlanToFile(
|
|
78
78
|
plan: string,
|
|
79
79
|
requestedPath: string | undefined,
|
|
80
80
|
cwd: string,
|
|
@@ -109,7 +109,7 @@ export function planExportDestination(defaultPath: string, cwd: string): PlanExp
|
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
|
|
112
|
+
function resolvePlanExportPath(
|
|
113
113
|
requestedPath: string | undefined,
|
|
114
114
|
cwd: string,
|
|
115
115
|
defaultPath = DEFAULT_PLAN_EXPORT_PATH,
|
package/src/plan-file.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { mkdir, open, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
5
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const PLANS_DIRECTORY = "plans";
|
|
8
8
|
const MAX_PLAN_BYTES = 1024 * 1024;
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -79,7 +79,3 @@ export async function readPlanFile(path: string): Promise<string | undefined> {
|
|
|
79
79
|
export async function deletePlanFile(path: string): Promise<void> {
|
|
80
80
|
await unlink(path).catch(() => undefined);
|
|
81
81
|
}
|
|
82
|
-
|
|
83
|
-
export async function planFileExists(path: string): Promise<boolean> {
|
|
84
|
-
return (await readPlanFile(path)) !== undefined;
|
|
85
|
-
}
|
package/src/plan-launch-menu.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
interface PlanLaunchMenuOptions {
|
|
5
5
|
statusText: string;
|
|
6
6
|
signal: AbortSignal;
|
|
7
7
|
isCurrent(): boolean;
|
package/src/plan-mode.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { watch } from "node:fs";
|
|
2
|
-
import { basename, dirname } from "node:path";
|
|
3
1
|
import type {
|
|
4
2
|
ExtensionAPI,
|
|
5
3
|
ExtensionCommandContext,
|
|
@@ -13,15 +11,12 @@ import {
|
|
|
13
11
|
planModeCompleted,
|
|
14
12
|
renderPlanModeCompletion,
|
|
15
13
|
} from "./completion-tool.js";
|
|
16
|
-
import {
|
|
17
|
-
isStaleExtensionContextError,
|
|
18
|
-
onAgentSettled,
|
|
19
|
-
setPlanThinkingLevel,
|
|
20
|
-
} from "./extension-runtime.js";
|
|
14
|
+
import { isStaleExtensionContextError, onAgentSettled } from "./extension-runtime.js";
|
|
21
15
|
import {
|
|
22
16
|
formatImplementationHandoff,
|
|
23
17
|
startFreshImplementationFromState,
|
|
24
18
|
} from "./fresh-implementation.js";
|
|
19
|
+
import { createLifecycle, type LifecycleScope } from "./lifecycle.js";
|
|
25
20
|
import { deletePlanFile, planFilePathForSession, readPlanFile, writePlanFile } from "./plan-file.js";
|
|
26
21
|
import { createPlanActionController } from "./plan-action-controller.js";
|
|
27
22
|
import { createPlanExportController } from "./plan-export-controller.js";
|
|
@@ -52,7 +47,8 @@ import {
|
|
|
52
47
|
planModeSettingsPath,
|
|
53
48
|
readPlanModeSettings,
|
|
54
49
|
} from "./settings.js";
|
|
55
|
-
import {
|
|
50
|
+
import { createSettingsWatcher } from "./settings-watch.js";
|
|
51
|
+
import { type PlanModeState, restorePlanModeState } from "./state.js";
|
|
56
52
|
|
|
57
53
|
const STATE_ENTRY_TYPE = "plan-mode-state";
|
|
58
54
|
const ASK_USER_AVAILABILITY_EVENT = "hank:ask-user:availability";
|
|
@@ -67,11 +63,7 @@ const ASK_USER_AVAILABILITY_EVENT = "hank:ask-user:availability";
|
|
|
67
63
|
* was once listed here; it was a pre-1.0 upstream tool that no longer exists.)
|
|
68
64
|
*/
|
|
69
65
|
const BLOCKED_TOOLS = new Set(["edit", "write"]);
|
|
70
|
-
/**
|
|
71
|
-
* One hand-edit or menu save fans out into several filesystem events (temp file
|
|
72
|
-
* created, renamed into place). Collapsing them into one re-read keeps a save
|
|
73
|
-
* to a single load.
|
|
74
|
-
*/
|
|
66
|
+
/** Long enough to collapse one save's burst of filesystem events into one read. */
|
|
75
67
|
const SETTINGS_RELOAD_DEBOUNCE_MS = 75;
|
|
76
68
|
|
|
77
69
|
/**
|
|
@@ -115,12 +107,9 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
115
107
|
let readyPresentationNonce = 0;
|
|
116
108
|
let pendingReadyNonce: number | undefined;
|
|
117
109
|
let latestCommandContext: ExtensionCommandContext | undefined;
|
|
118
|
-
let menuGeneration = 0;
|
|
119
|
-
let workflowGeneration = 0;
|
|
120
110
|
let refreshStateBeforeFirstAgentStart = false;
|
|
121
|
-
|
|
122
|
-
let
|
|
123
|
-
let settingsReloadTimer: ReturnType<typeof setTimeout> | undefined;
|
|
111
|
+
const lifecycle = createLifecycle();
|
|
112
|
+
let settingsWatcher: ReturnType<typeof createSettingsWatcher> | undefined;
|
|
124
113
|
let planToolsActivated = false;
|
|
125
114
|
let currentHasUI = false;
|
|
126
115
|
let globalQuestionAvailable = false;
|
|
@@ -166,7 +155,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
166
155
|
const planActions = createPlanActionController({
|
|
167
156
|
loadInteractiveUi,
|
|
168
157
|
getState: () => state,
|
|
169
|
-
captureLifecycle:
|
|
158
|
+
captureLifecycle: () => lifecycle.capture(),
|
|
170
159
|
statusText: planStatusText,
|
|
171
160
|
planPathLine: () => (state.planPath ? `Plan file: ${state.planPath}` : undefined),
|
|
172
161
|
getExportDestination: (ctx) => planExports.getDestination(ctx),
|
|
@@ -179,13 +168,11 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
179
168
|
exitReady: (ctx) => {
|
|
180
169
|
// Same had-plan branching as the /plan exit command: the menu must not
|
|
181
170
|
// claim a plan was discarded when none was ever completed.
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
);
|
|
188
|
-
});
|
|
171
|
+
const text =
|
|
172
|
+
state.planPath !== undefined
|
|
173
|
+
? "Plan mode disabled. Proposed plan discarded."
|
|
174
|
+
: "Plan mode disabled.";
|
|
175
|
+
void exitAndNotify(ctx, text);
|
|
189
176
|
},
|
|
190
177
|
});
|
|
191
178
|
|
|
@@ -229,20 +216,12 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
229
216
|
);
|
|
230
217
|
}
|
|
231
218
|
|
|
232
|
-
const
|
|
233
|
-
const
|
|
234
|
-
const questionSignal = signal
|
|
235
|
-
? AbortSignal.any([signal, menuController.signal])
|
|
236
|
-
: menuController.signal;
|
|
219
|
+
const menu = lifecycle.capture();
|
|
220
|
+
const questionSignal = signal ? AbortSignal.any([signal, menu.signal]) : menu.signal;
|
|
237
221
|
return answerPlanModeQuestions(
|
|
238
222
|
parsed.questions,
|
|
239
223
|
ctx,
|
|
240
|
-
{
|
|
241
|
-
isCurrent: () =>
|
|
242
|
-
sessionGeneration === menuGeneration &&
|
|
243
|
-
questionWorkflowGeneration === workflowGeneration,
|
|
244
|
-
isEnabled: () => state.enabled,
|
|
245
|
-
},
|
|
224
|
+
{ isCurrent: menu.isCurrent, isEnabled: () => state.enabled },
|
|
246
225
|
questionSignal,
|
|
247
226
|
);
|
|
248
227
|
},
|
|
@@ -288,7 +267,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
288
267
|
return;
|
|
289
268
|
}
|
|
290
269
|
enterPlanMode(ctx);
|
|
291
|
-
ctx
|
|
270
|
+
notifyEnabled(ctx);
|
|
292
271
|
return;
|
|
293
272
|
}
|
|
294
273
|
if (command === "show") {
|
|
@@ -309,8 +288,8 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
309
288
|
}
|
|
310
289
|
const exportMatch = /^export(?:\s+([\s\S]+))?$/iu.exec(prompt);
|
|
311
290
|
if (exportMatch) {
|
|
312
|
-
const
|
|
313
|
-
await planExports.export(exportMatch[1], ctx,
|
|
291
|
+
const menu = lifecycle.capture();
|
|
292
|
+
await planExports.export(exportMatch[1], ctx, menu.signal, menu.isCurrent);
|
|
314
293
|
return;
|
|
315
294
|
}
|
|
316
295
|
if (command === "exit" || command === "off") {
|
|
@@ -322,8 +301,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
322
301
|
: hadPlan
|
|
323
302
|
? "Active implementation plan cleared."
|
|
324
303
|
: "Plan mode disabled.";
|
|
325
|
-
await
|
|
326
|
-
ctx.ui.notify(notification, "info");
|
|
304
|
+
await exitAndNotify(ctx, notification);
|
|
327
305
|
return;
|
|
328
306
|
}
|
|
329
307
|
if (prompt) {
|
|
@@ -361,76 +339,48 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
361
339
|
* `ctx` to explain why, would be worse than waiting for the next write. A
|
|
362
340
|
* genuinely broken file is still reported at the next session start.
|
|
363
341
|
*/
|
|
364
|
-
const loadPlanModeSettings = async (
|
|
342
|
+
const loadPlanModeSettings = async (session: LifecycleScope, ctx?: ExtensionContext) => {
|
|
365
343
|
const loaded = await readRuntimeSettings();
|
|
366
|
-
if (
|
|
344
|
+
if (!session.isCurrent()) return;
|
|
367
345
|
if (loaded.kind === "invalid" && !ctx) return;
|
|
368
346
|
settings = loaded.kind === "loaded" ? loaded.settings : {};
|
|
369
347
|
if (!ctx) return;
|
|
370
348
|
if (loaded.kind === "invalid") {
|
|
371
349
|
ctx.ui.notify(`pi-plan-mode settings ignored: ${loaded.reason}`, "warning");
|
|
372
350
|
}
|
|
373
|
-
if (loaded.notice) ctx.ui.notify(loaded.notice, "warning");
|
|
374
351
|
};
|
|
375
352
|
|
|
376
353
|
const stopPlanModeSettingsWatch = () => {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
settingsReloadTimer = undefined;
|
|
380
|
-
}
|
|
381
|
-
settingsWatch?.close();
|
|
382
|
-
settingsWatch = undefined;
|
|
354
|
+
settingsWatcher?.stop();
|
|
355
|
+
settingsWatcher = undefined;
|
|
383
356
|
};
|
|
384
357
|
|
|
385
|
-
/**
|
|
386
|
-
|
|
387
|
-
* through a temp file and an atomic rename, and a watch bound to the old inode
|
|
388
|
-
* would go deaf after the first one.
|
|
389
|
-
*/
|
|
390
|
-
const startPlanModeSettingsWatch = (generation: number) => {
|
|
358
|
+
/** An injected reader is the only source there is, so it is never watched. */
|
|
359
|
+
const startPlanModeSettingsWatch = (session: LifecycleScope) => {
|
|
391
360
|
stopPlanModeSettingsWatch();
|
|
392
361
|
if (dependencies.readSettings) return;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
// rather than miss the edit. The agent directory holds other churn, so
|
|
400
|
-
// a named entry that is not ours is ignored.
|
|
401
|
-
if (changedFile && changedFile.toString() !== watchedFile) return;
|
|
402
|
-
if (settingsReloadTimer) clearTimeout(settingsReloadTimer);
|
|
403
|
-
settingsReloadTimer = setTimeout(() => {
|
|
404
|
-
settingsReloadTimer = undefined;
|
|
405
|
-
void loadPlanModeSettings(generation);
|
|
406
|
-
}, SETTINGS_RELOAD_DEBOUNCE_MS);
|
|
407
|
-
});
|
|
408
|
-
watcher.on("error", stopPlanModeSettingsWatch);
|
|
409
|
-
settingsWatch = watcher;
|
|
410
|
-
} catch {
|
|
411
|
-
// An unwatchable directory only costs the live reload; settings still
|
|
412
|
-
// load at session start.
|
|
413
|
-
stopPlanModeSettingsWatch();
|
|
414
|
-
}
|
|
362
|
+
settingsWatcher = createSettingsWatcher({
|
|
363
|
+
path: dependencies.settingsPath ?? planModeSettingsPath(),
|
|
364
|
+
debounceMs: SETTINGS_RELOAD_DEBOUNCE_MS,
|
|
365
|
+
onChange: () => void loadPlanModeSettings(session),
|
|
366
|
+
});
|
|
367
|
+
settingsWatcher.start();
|
|
415
368
|
};
|
|
416
369
|
|
|
417
370
|
pi.on("session_start", async (event, ctx) => {
|
|
418
|
-
const
|
|
371
|
+
const session = lifecycle.nextSession("Plan-mode session replaced");
|
|
419
372
|
planToolsActivated = false;
|
|
420
373
|
currentHasUI = ctx.hasUI;
|
|
421
374
|
reconcilePlanToolSurface(ctx.hasUI);
|
|
422
375
|
refreshStateBeforeFirstAgentStart = event.reason === "new";
|
|
423
|
-
menuController.abort(new DOMException("Plan-mode session replaced", "AbortError"));
|
|
424
|
-
menuController = new AbortController();
|
|
425
376
|
pendingReadyNonce = undefined;
|
|
426
377
|
latestCommandContext = undefined;
|
|
427
378
|
settings = {};
|
|
428
379
|
sessionPlanPath = resolveSessionPlanPath(ctx);
|
|
429
380
|
restoreState(ctx);
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
startPlanModeSettingsWatch(generation);
|
|
381
|
+
await loadPlanModeSettings(session, ctx);
|
|
382
|
+
if (!session.isCurrent()) return;
|
|
383
|
+
startPlanModeSettingsWatch(session);
|
|
434
384
|
const persistFlagActivation = pi.getFlag("plan") === true && !state.enabled;
|
|
435
385
|
if (persistFlagActivation) {
|
|
436
386
|
state = { ...state, enabled: true, awaitingAction: state.planPath !== undefined };
|
|
@@ -441,9 +391,9 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
441
391
|
});
|
|
442
392
|
|
|
443
393
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
444
|
-
|
|
394
|
+
// No re-arm: nothing may become current again until a session_start.
|
|
395
|
+
lifecycle.endSession("Plan-mode session shut down");
|
|
445
396
|
stopPlanModeSettingsWatch();
|
|
446
|
-
menuController.abort(new DOMException("Plan-mode session shut down", "AbortError"));
|
|
447
397
|
pendingReadyNonce = undefined;
|
|
448
398
|
latestCommandContext = undefined;
|
|
449
399
|
refreshStateBeforeFirstAgentStart = false;
|
|
@@ -476,9 +426,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
476
426
|
// A new turn supersedes the previous ready plan: revision feedback
|
|
477
427
|
// re-opens planning until another plan_mode_complete arrives.
|
|
478
428
|
pendingReadyNonce = undefined;
|
|
479
|
-
|
|
480
|
-
persistState();
|
|
481
|
-
updateUi(ctx);
|
|
429
|
+
setState(ctx, { awaitingAction: false });
|
|
482
430
|
}
|
|
483
431
|
if (state.enabled && !planToolsActivated) activatePlanTools(ctx.hasUI);
|
|
484
432
|
else reconcilePlanToolSurface(ctx.hasUI);
|
|
@@ -513,39 +461,55 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
513
461
|
});
|
|
514
462
|
|
|
515
463
|
function enterPlanMode(ctx: ExtensionContext) {
|
|
516
|
-
|
|
464
|
+
lifecycle.nextWorkflow();
|
|
517
465
|
activatePlanTools(ctx.hasUI);
|
|
518
|
-
|
|
519
|
-
persistState();
|
|
520
|
-
updateUi(ctx);
|
|
466
|
+
setState(ctx, { enabled: true, awaitingAction: false });
|
|
521
467
|
}
|
|
522
468
|
|
|
523
469
|
function enterPlanModeWithPrompt(prompt: string, ctx: ExtensionContext) {
|
|
524
470
|
const previousState = state;
|
|
525
471
|
const wasEnabled = state.enabled;
|
|
526
472
|
enterPlanMode(ctx);
|
|
527
|
-
if (!wasEnabled)
|
|
528
|
-
|
|
529
|
-
}
|
|
530
|
-
if (sendPlanModeUserMessage(prompt, ctx)) return;
|
|
531
|
-
state = previousState;
|
|
532
|
-
persistState();
|
|
533
|
-
updateUi(ctx);
|
|
473
|
+
if (!wasEnabled) notifyEnabled(ctx);
|
|
474
|
+
sendOrRevert(prompt, ctx, previousState);
|
|
534
475
|
}
|
|
535
476
|
|
|
536
477
|
async function exitPlanMode(ctx: ExtensionContext, options: { keepPlanFile?: boolean } = {}) {
|
|
537
|
-
|
|
478
|
+
lifecycle.nextWorkflow();
|
|
538
479
|
const planPath = state.planPath;
|
|
539
480
|
pendingReadyNonce = undefined;
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
481
|
+
setState(ctx, { enabled: false, planPath: undefined, awaitingAction: false });
|
|
482
|
+
if (planPath && !options.keepPlanFile) await deletePlanFile(planPath);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Leaves Plan mode and reports it in one step, for menus and /plan alike. */
|
|
486
|
+
function exitAndNotify(
|
|
487
|
+
ctx: ExtensionContext,
|
|
488
|
+
text: string,
|
|
489
|
+
options: { keepPlanFile?: boolean } = {},
|
|
490
|
+
) {
|
|
491
|
+
return exitPlanMode(ctx, options).then(() => ctx.ui.notify(text, "info"));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function notifyEnabled(ctx: ExtensionContext) {
|
|
495
|
+
ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** State moves as one: what is remembered, what is persisted, what is shown. */
|
|
499
|
+
function setState(ctx: ExtensionContext, patch: Partial<PlanModeState>) {
|
|
500
|
+
state = { ...state, ...patch };
|
|
546
501
|
persistState();
|
|
547
502
|
updateUi(ctx);
|
|
548
|
-
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Sends the message a state change exists to produce, and puts the previous
|
|
507
|
+
* state back when the session refuses it: a mode switch the model was never
|
|
508
|
+
* told about is worse than no switch at all.
|
|
509
|
+
*/
|
|
510
|
+
function sendOrRevert(message: string, ctx: ExtensionContext, previousState: PlanModeState) {
|
|
511
|
+
if (sendPlanModeUserMessage(message, ctx)) return;
|
|
512
|
+
setState(ctx, previousState);
|
|
549
513
|
}
|
|
550
514
|
|
|
551
515
|
function sendPlanModeUserMessage(message: string, ctx: ExtensionContext) {
|
|
@@ -573,10 +537,8 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
573
537
|
throw new Error(`Unable to save the plan to ${planPath}: ${detail}`);
|
|
574
538
|
}
|
|
575
539
|
sessionPlanPath = planPath;
|
|
576
|
-
state = { ...state, planPath, awaitingAction: true };
|
|
577
540
|
pendingReadyNonce = ++readyPresentationNonce;
|
|
578
|
-
|
|
579
|
-
updateUi(ctx);
|
|
541
|
+
setState(ctx, { planPath, awaitingAction: true });
|
|
580
542
|
showPlanModePlan(pi, ctx, "Proposed Plan", plan);
|
|
581
543
|
return planPath;
|
|
582
544
|
}
|
|
@@ -616,39 +578,28 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
616
578
|
return;
|
|
617
579
|
}
|
|
618
580
|
|
|
619
|
-
|
|
581
|
+
lifecycle.nextWorkflow();
|
|
620
582
|
const previousState = state;
|
|
621
583
|
pendingReadyNonce = undefined;
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
enabled: false,
|
|
625
|
-
awaitingAction: false,
|
|
626
|
-
planPath,
|
|
627
|
-
};
|
|
628
|
-
persistState();
|
|
629
|
-
updateUi(ctx);
|
|
630
|
-
|
|
631
|
-
if (!sendPlanModeUserMessage(formatImplementationHandoff(planPath), ctx)) {
|
|
632
|
-
state = previousState;
|
|
633
|
-
persistState();
|
|
634
|
-
updateUi(ctx);
|
|
635
|
-
}
|
|
584
|
+
setState(ctx, { enabled: false, awaitingAction: false, planPath });
|
|
585
|
+
sendOrRevert(formatImplementationHandoff(planPath), ctx, previousState);
|
|
636
586
|
}
|
|
637
587
|
|
|
638
588
|
async function showLaunchMenu(ctx: ExtensionContext) {
|
|
639
|
-
const
|
|
640
|
-
if (!
|
|
589
|
+
const menu = lifecycle.capture();
|
|
590
|
+
if (!menu.isCurrent() || menu.signal.aborted) return;
|
|
641
591
|
const ui = await loadInteractiveUi();
|
|
642
|
-
if (!
|
|
592
|
+
if (!menu.isCurrent() || menu.signal.aborted) return;
|
|
643
593
|
await ui.showPlanLaunchMenu(ctx, {
|
|
644
594
|
statusText: "Status: Off.",
|
|
645
|
-
|
|
595
|
+
signal: menu.signal,
|
|
596
|
+
isCurrent: menu.isCurrent,
|
|
646
597
|
start: (signal) => {
|
|
647
|
-
if (signal.aborted || !
|
|
598
|
+
if (signal.aborted || !menu.isCurrent()) return;
|
|
648
599
|
enterPlanMode(ctx);
|
|
649
|
-
ctx
|
|
600
|
+
notifyEnabled(ctx);
|
|
650
601
|
},
|
|
651
|
-
settings: (signal) => showSettings(ctx, signal,
|
|
602
|
+
settings: (signal) => showSettings(ctx, signal, menu.isCurrent),
|
|
652
603
|
});
|
|
653
604
|
}
|
|
654
605
|
|
|
@@ -657,27 +608,25 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
657
608
|
ctx.ui.notify(planStatusText(), "info");
|
|
658
609
|
return;
|
|
659
610
|
}
|
|
660
|
-
const
|
|
661
|
-
if (!
|
|
611
|
+
const menu = lifecycle.capture();
|
|
612
|
+
if (!menu.isCurrent() || menu.signal.aborted) return;
|
|
662
613
|
const ui = await loadInteractiveUi();
|
|
663
|
-
if (!
|
|
614
|
+
if (!menu.isCurrent() || menu.signal.aborted) return;
|
|
664
615
|
await ui.showActiveImplementationMenu(ctx, {
|
|
665
616
|
statusText: planStatusText(),
|
|
666
617
|
...(state.planPath ? { planPathLine: `Plan file: ${state.planPath}` } : {}),
|
|
667
618
|
getExportDestination: () => planExports.getDestination(ctx),
|
|
668
|
-
signal:
|
|
669
|
-
isCurrent:
|
|
619
|
+
signal: menu.signal,
|
|
620
|
+
isCurrent: menu.isCurrent,
|
|
670
621
|
show: () => showStoredPlan(pi, ctx, state),
|
|
671
|
-
exportPlan: (path, signal) => planExports.export(path, ctx, signal,
|
|
672
|
-
settings: (signal) => showSettings(ctx, signal,
|
|
622
|
+
exportPlan: (path, signal) => planExports.export(path, ctx, signal, menu.isCurrent),
|
|
623
|
+
settings: (signal) => showSettings(ctx, signal, menu.isCurrent),
|
|
673
624
|
startNew: () => {
|
|
674
625
|
enterPlanMode(ctx);
|
|
675
|
-
ctx
|
|
626
|
+
notifyEnabled(ctx);
|
|
676
627
|
},
|
|
677
628
|
clear: () => {
|
|
678
|
-
void
|
|
679
|
-
ctx.ui.notify("Active implementation plan cleared.", "info");
|
|
680
|
-
});
|
|
629
|
+
void exitAndNotify(ctx, "Active implementation plan cleared.");
|
|
681
630
|
},
|
|
682
631
|
});
|
|
683
632
|
}
|
|
@@ -704,37 +653,6 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
704
653
|
return result.kind === "closed" && "reason" in result && result.reason === "close";
|
|
705
654
|
}
|
|
706
655
|
|
|
707
|
-
function captureMenuLifecycle() {
|
|
708
|
-
const sessionGeneration = menuGeneration;
|
|
709
|
-
const planWorkflowGeneration = workflowGeneration;
|
|
710
|
-
const controller = menuController;
|
|
711
|
-
return {
|
|
712
|
-
signal: controller.signal,
|
|
713
|
-
isCurrent: () =>
|
|
714
|
-
sessionGeneration === menuGeneration &&
|
|
715
|
-
planWorkflowGeneration === workflowGeneration &&
|
|
716
|
-
!controller.signal.aborted,
|
|
717
|
-
};
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
/**
|
|
721
|
-
* pi-plan-mode <= 1.2.1 raised the thinking level while planning, and because
|
|
722
|
-
* `pi.setThinkingLevel` writes through to the user's real settings, a session
|
|
723
|
-
* that died before its restore left that change durable. If the newest state
|
|
724
|
-
* entry still carries the capture and the live level still equals what Plan
|
|
725
|
-
* mode applied, put the user's level back — once. Persisting state in the new
|
|
726
|
-
* shape drops the capture, so the next session finds nothing to repair. A
|
|
727
|
-
* user who has already moved the level themselves is left alone.
|
|
728
|
-
*
|
|
729
|
-
* legacy: delete in 1.4.0
|
|
730
|
-
*/
|
|
731
|
-
function repairLegacyThinkingLevel(ctx: ExtensionContext) {
|
|
732
|
-
const legacy = readLegacyThinkingCapture(ctx.sessionManager.getBranch(), STATE_ENTRY_TYPE);
|
|
733
|
-
if (!legacy || pi.getThinkingLevel() !== legacy.applied) return;
|
|
734
|
-
setPlanThinkingLevel(pi, legacy.previous);
|
|
735
|
-
persistState();
|
|
736
|
-
}
|
|
737
|
-
|
|
738
656
|
function resolveSessionPlanPath(ctx: ExtensionContext) {
|
|
739
657
|
try {
|
|
740
658
|
return planFilePathForSession(ctx.sessionManager.getSessionId());
|
package/src/presentation.ts
CHANGED
|
@@ -69,9 +69,9 @@ export function registerPlanModeCardRenderer(pi: ExtensionAPI): void {
|
|
|
69
69
|
* characters do not justify a shared package; a user reading a footer
|
|
70
70
|
* justifies the consistency.
|
|
71
71
|
*/
|
|
72
|
-
|
|
72
|
+
type PlanModePhase = "drafting" | "revising" | "ready" | "implementing";
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
interface PlanModeView {
|
|
75
75
|
phase: PlanModePhase;
|
|
76
76
|
/** The footer line: plain text with a glyph, no colour. */
|
|
77
77
|
footer: string;
|
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;
|
|
@@ -184,7 +184,7 @@ export async function answerPlanModeQuestions(
|
|
|
184
184
|
return planModeQuestionAnswered(questions, answers);
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
|
|
187
|
+
async function askPlanModeQuestions(
|
|
188
188
|
questions: PlanModeQuestion[],
|
|
189
189
|
ctx: ExtensionContext,
|
|
190
190
|
shouldContinue: () => boolean = () => true,
|
|
@@ -243,7 +243,7 @@ function formatPlanModeQuestionChoice(option: PlanModeQuestionOption, index: num
|
|
|
243
243
|
return `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`;
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
|
|
246
|
+
function planModeQuestionAnswered(
|
|
247
247
|
questions: PlanModeQuestion[],
|
|
248
248
|
answers: PlanModeQuestionAnswer[],
|
|
249
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
|
}
|