@milanglacier/pi-plan-mode 0.5.1

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/index.ts ADDED
@@ -0,0 +1,240 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ import { keyHint } from "@earendil-works/pi-coding-agent";
5
+ import { Text } from "@earendil-works/pi-tui";
6
+ import { mkdir, writeFile } from "node:fs/promises";
7
+ import path from "node:path";
8
+
9
+ import { registerPlanModeCommand } from "./flow";
10
+ import { resolveActivePlanFilePath } from "./plan-files";
11
+ import { loadPlanModePrompt } from "./prompts";
12
+ import { registerRequestUserInputTool } from "./request-user-input";
13
+ import { RequestUserInputSchema, SetPlanSchema } from "./schemas";
14
+ import { CONTEXT_ENTRY_TYPE, createPlanModeStateManager } from "./state";
15
+
16
+ function summarizeSnippet(text: string, maxLength: number = 120): string {
17
+ const singleLine = text.replaceAll(/\s+/g, " ").trim();
18
+ if (!singleLine) {
19
+ return "";
20
+ }
21
+ if (singleLine.length <= maxLength) {
22
+ return singleLine;
23
+ }
24
+ return `${singleLine.slice(0, maxLength - 3)}...`;
25
+ }
26
+
27
+ interface SetPlanDetails {
28
+ plan: string;
29
+ }
30
+
31
+ interface PlanModeExitDetails {
32
+ planFilePath: string;
33
+ planText?: string;
34
+ }
35
+
36
+ const PLAN_MODE_EXIT_ENTRY_TYPE = "pi-plan:exit";
37
+ const STARTUP_REFRESH_DELAY_MS = 250;
38
+
39
+ export default function (pi: ExtensionAPI) {
40
+ const stateManager = createPlanModeStateManager(pi);
41
+
42
+ pi.registerMessageRenderer(PLAN_MODE_EXIT_ENTRY_TYPE, (message, { expanded }, theme) => {
43
+ const render = (text: string) => new Text(text, 1, 0, (segment) => theme.bg("customMessageBg", segment));
44
+ const details = message.details as PlanModeExitDetails | undefined;
45
+ const title = String(message.content || "Plan mode ended.");
46
+ const lines = [theme.fg("accent", theme.bold(title))];
47
+
48
+ if (!details?.planFilePath) {
49
+ return render(lines.join("\n"));
50
+ }
51
+
52
+ if (!details.planText?.trim()) {
53
+ lines.push(theme.fg("warning", "No plan created."));
54
+ return render(lines.join("\n"));
55
+ }
56
+
57
+ lines.push(theme.fg("muted", `Plan file: ${details.planFilePath}`));
58
+ if (!expanded) {
59
+ lines.push(theme.fg("dim", keyHint("expandTools", "to expand")));
60
+ return render(lines.join("\n"));
61
+ }
62
+
63
+ lines.push("");
64
+ lines.push(details.planText);
65
+ return render(lines.join("\n"));
66
+ });
67
+
68
+ pi.registerTool({
69
+ description:
70
+ "Overwrite the plan file with the full latest plan text. Call this whenever the plan changes so the plan file stays canonical.",
71
+ async execute(
72
+ _toolCallId,
73
+ params: { plan: string },
74
+ _signal,
75
+ _onUpdate,
76
+ ctx,
77
+ ): Promise<AgentToolResult<SetPlanDetails>> {
78
+ if (!stateManager.getState().active) {
79
+ return {
80
+ isError: true,
81
+ content: [
82
+ {
83
+ type: "text",
84
+ text: "set_plan is only available while plan mode is active.",
85
+ },
86
+ ],
87
+ };
88
+ }
89
+
90
+ const planFilePath = resolveActivePlanFilePath(ctx, stateManager.getState().planFilePath);
91
+ if (!planFilePath) {
92
+ return {
93
+ isError: true,
94
+ content: [
95
+ {
96
+ type: "text",
97
+ text: "No active plan file. Restart plan mode and try again.",
98
+ },
99
+ ],
100
+ };
101
+ }
102
+
103
+ const plan = String(params.plan ?? "").trim();
104
+ if (!plan) {
105
+ return {
106
+ isError: true,
107
+ content: [
108
+ {
109
+ type: "text",
110
+ text: "set_plan requires non-empty plan text.",
111
+ },
112
+ ],
113
+ };
114
+ }
115
+
116
+ await mkdir(path.dirname(planFilePath), { recursive: true });
117
+ await writeFile(planFilePath, `${plan}\n`, "utf8");
118
+
119
+ if (stateManager.getState().planFilePath !== planFilePath) {
120
+ stateManager.setState(ctx, {
121
+ ...stateManager.getState(),
122
+ planFilePath,
123
+ });
124
+ }
125
+ return {
126
+ content: [{ type: "text", text: "Plan written." }],
127
+ details: {
128
+ plan,
129
+ },
130
+ };
131
+ },
132
+ label: "set_plan",
133
+ name: "set_plan",
134
+ parameters: SetPlanSchema,
135
+ renderCall(args, theme) {
136
+ const preview = summarizeSnippet(String(args.plan ?? ""), 90);
137
+ return new Text(
138
+ `${theme.fg("toolTitle", theme.bold("set_plan "))}${theme.fg("muted", preview || "(empty)")}`,
139
+ 0,
140
+ 0,
141
+ );
142
+ },
143
+ renderResult(result, { expanded, isPartial }, theme) {
144
+ if (isPartial) {
145
+ return new Text(theme.fg("muted", "Writing plan..."), 0, 0);
146
+ }
147
+
148
+ const details = result.details as SetPlanDetails | undefined;
149
+ if (!details?.plan) {
150
+ const text = result.content.find((item) => item.type === "text");
151
+ return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
152
+ }
153
+
154
+ if (!expanded) {
155
+ return new Text(
156
+ `${theme.fg("success", "Plan written.")}\n${theme.fg("dim", keyHint("expandTools", "to view plan"))}`,
157
+ 0,
158
+ 0,
159
+ );
160
+ }
161
+
162
+ return new Text(`${theme.fg("success", "Plan written.")}\n${details.plan}`, 0, 0);
163
+ },
164
+ });
165
+
166
+ registerRequestUserInputTool(pi, {
167
+ getState: stateManager.getState,
168
+ requestUserInputSchema: RequestUserInputSchema,
169
+ });
170
+
171
+
172
+ registerPlanModeCommand(pi, {
173
+ onPlanModeExited: ({ planFilePath, planText }) => {
174
+ pi.sendMessage({
175
+ customType: PLAN_MODE_EXIT_ENTRY_TYPE,
176
+ content: "Plan mode ended.",
177
+ display: true,
178
+ details: {
179
+ planFilePath,
180
+ planText,
181
+ },
182
+ });
183
+ },
184
+ stateManager,
185
+ });
186
+
187
+ pi.on("before_agent_start", async () => {
188
+ stateManager.syncTools();
189
+ if (!stateManager.getState().active) {
190
+ return;
191
+ }
192
+
193
+ const prompt = await loadPlanModePrompt();
194
+ return {
195
+ message: {
196
+ content: prompt,
197
+ customType: CONTEXT_ENTRY_TYPE,
198
+ display: false,
199
+ },
200
+ };
201
+ });
202
+
203
+ let startupRefreshTimer: ReturnType<typeof setTimeout> | undefined;
204
+ const cancelStartupRefresh = () => {
205
+ if (!startupRefreshTimer) {
206
+ return;
207
+ }
208
+ clearTimeout(startupRefreshTimer);
209
+ startupRefreshTimer = undefined;
210
+ };
211
+ const refreshState = (ctx: ExtensionContext) => {
212
+ cancelStartupRefresh();
213
+ stateManager.refresh(ctx);
214
+ };
215
+
216
+ pi.on("session_start", async (_event, ctx) => {
217
+ cancelStartupRefresh();
218
+ startupRefreshTimer = setTimeout(() => {
219
+ startupRefreshTimer = undefined;
220
+ stateManager.refresh(ctx);
221
+ }, STARTUP_REFRESH_DELAY_MS);
222
+ startupRefreshTimer.unref?.();
223
+ });
224
+
225
+ pi.on("session_switch", async (_event, ctx) => {
226
+ refreshState(ctx);
227
+ });
228
+
229
+ pi.on("session_tree", async (_event, ctx) => {
230
+ refreshState(ctx);
231
+ });
232
+
233
+ pi.on("session_fork", async (_event, ctx) => {
234
+ refreshState(ctx);
235
+ });
236
+
237
+ pi.on("session_shutdown", async () => {
238
+ cancelStartupRefresh();
239
+ });
240
+ }
package/install.mjs ADDED
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from "node:child_process";
4
+
5
+ const PACKAGE_NAME = "@milanglacier/pi-plan-mode";
6
+
7
+ function parseArgs(argv) {
8
+ const args = argv.slice(2);
9
+ let local = false;
10
+ let remove = false;
11
+ let help = false;
12
+
13
+ for (const arg of args) {
14
+ if (arg === "--local" || arg === "-l") {
15
+ local = true;
16
+ } else if (arg === "--remove" || arg === "-r") {
17
+ remove = true;
18
+ } else if (arg === "--help" || arg === "-h") {
19
+ help = true;
20
+ } else {
21
+ console.error(`Unknown argument: ${arg}`);
22
+ process.exit(1);
23
+ }
24
+ }
25
+
26
+ return { help, local, remove };
27
+ }
28
+
29
+ function printHelp() {
30
+ console.log(
31
+ `
32
+ pi-plan — install the pi-plan-mode extension into pi
33
+
34
+ Usage:
35
+ npx @milanglacier/pi-plan-mode Install globally
36
+ npx @milanglacier/pi-plan-mode --local Install into project .pi/settings.json
37
+ npx @milanglacier/pi-plan-mode --remove Remove from pi
38
+
39
+ Options:
40
+ -l, --local Install project-locally instead of globally
41
+ -r, --remove Remove the package from pi
42
+ -h, --help Show this help
43
+
44
+ Direct install:
45
+ pi install npm:${PACKAGE_NAME}
46
+ `.trim(),
47
+ );
48
+ }
49
+
50
+ function findPi() {
51
+ try {
52
+ execFileSync("pi", ["--version"], { stdio: "ignore" });
53
+ return "pi";
54
+ } catch {
55
+ console.error("Error: 'pi' command not found. Install pi-coding-agent first:");
56
+ console.error(" npm install -g @earendil-works/pi-coding-agent");
57
+ process.exit(1);
58
+ }
59
+ }
60
+
61
+ function run(pi, command, args) {
62
+ try {
63
+ execFileSync(pi, [command, ...args], { stdio: "pipe", timeout: 60_000 });
64
+ return { ok: true, status: "ok" };
65
+ } catch (error) {
66
+ const stderr = error?.stderr?.toString?.().trim?.() ?? "";
67
+ if (stderr.includes("already installed") || stderr.includes("already exists")) {
68
+ return { ok: true, status: "already-installed" };
69
+ }
70
+ if (stderr.includes("not installed") || stderr.includes("not found") || stderr.includes("No such")) {
71
+ return { ok: true, status: "already-removed" };
72
+ }
73
+ if (stderr) {
74
+ console.error(stderr.split("\n")[0]);
75
+ }
76
+ return { ok: false, status: "error" };
77
+ }
78
+ }
79
+
80
+ const opts = parseArgs(process.argv);
81
+ if (opts.help) {
82
+ printHelp();
83
+ process.exit(0);
84
+ }
85
+
86
+ const pi = findPi();
87
+ const source = `npm:${PACKAGE_NAME}`;
88
+ const localFlag = opts.local ? ["-l"] : [];
89
+ const result = opts.remove ? run(pi, "remove", [source, ...localFlag]) : run(pi, "install", [source, ...localFlag]);
90
+
91
+ if (!result.ok) {
92
+ process.exit(1);
93
+ }
94
+
95
+ if (opts.remove) {
96
+ console.log(
97
+ result.status === "already-removed"
98
+ ? "\n✅ @milanglacier/pi-plan-mode is already absent from pi."
99
+ : "\n✅ Removed @milanglacier/pi-plan-mode from pi.",
100
+ );
101
+ } else {
102
+ console.log(
103
+ result.status === "already-installed"
104
+ ? "\n✅ @milanglacier/pi-plan-mode is already installed in pi."
105
+ : "\n✅ Installed @milanglacier/pi-plan-mode into pi. Restart pi to load it.",
106
+ );
107
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@milanglacier/pi-plan-mode",
3
+ "version": "0.5.1",
4
+ "description": "Planning mode extension for pi with persistent plan files and branch-aware planning.",
5
+ "keywords": [
6
+ "branch-planning",
7
+ "pi",
8
+ "pi-coding-agent",
9
+ "pi-package",
10
+ "plan-mode",
11
+ "planning",
12
+ "research"
13
+ ],
14
+ "homepage": "https://github.com/milanglacier/pi-plan-mode",
15
+ "bugs": {
16
+ "url": "https://github.com/milanglacier/pi-plan-mode/issues"
17
+ },
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/milanglacier/pi-plan-mode.git"
22
+ },
23
+ "bin": {
24
+ "pi-plan": "install.mjs"
25
+ },
26
+ "files": [
27
+ "*.ts",
28
+ "*.mjs",
29
+ "qna",
30
+ "prompts",
31
+ "README.md",
32
+ "!**/*.test.ts",
33
+ "!tests/**"
34
+ ],
35
+ "type": "module",
36
+ "scripts": {
37
+ "test": "vitest run"
38
+ },
39
+ "devDependencies": {
40
+ "vitest": "^4.1.8"
41
+ },
42
+ "peerDependencies": {
43
+ "@earendil-works/pi-agent-core": ">=0.56.1",
44
+ "@earendil-works/pi-ai": ">=0.56.1",
45
+ "@earendil-works/pi-coding-agent": ">=0.56.1",
46
+ "@earendil-works/pi-tui": ">=0.56.1",
47
+ "typebox": "*"
48
+ },
49
+ "pi": {
50
+ "extensions": [
51
+ "./index.ts"
52
+ ]
53
+ }
54
+ }
package/plan-files.ts ADDED
@@ -0,0 +1,154 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ import { resolvePlanFilePath } from "./utils";
7
+
8
+ export function getPlanFilePathForSession(ctx: ExtensionContext): string {
9
+ const sessionFile = ctx.sessionManager.getSessionFile();
10
+ if (!sessionFile) {
11
+ return path.join(ctx.sessionManager.getSessionDir(), `${ctx.sessionManager.getSessionId()}.plan.md`);
12
+ }
13
+
14
+ const parsed = path.parse(sessionFile);
15
+ return path.join(parsed.dir, `${parsed.name}.plan.md`);
16
+ }
17
+
18
+ export function resolveActivePlanFilePath(ctx: ExtensionContext, planFilePath: string | undefined): string {
19
+ if (planFilePath && planFilePath.trim().length > 0) {
20
+ return planFilePath;
21
+ }
22
+ return getPlanFilePathForSession(ctx);
23
+ }
24
+
25
+ export function buildTimestampedPlanFilename(sessionId: string): string {
26
+ const timestamp = new Date().toISOString().replaceAll(/[.:]/g, "-");
27
+ const safeSessionId = sessionId.replaceAll(/[^a-zA-Z0-9._-]/g, "-");
28
+ return `${timestamp}-${safeSessionId}.plan.md`;
29
+ }
30
+
31
+ export async function createFreshPlanFilePath(ctx: ExtensionContext, baseDir: string): Promise<string> {
32
+ const baseFilename = buildTimestampedPlanFilename(ctx.sessionManager.getSessionId());
33
+ let candidate = path.join(baseDir, baseFilename);
34
+ if (!(await pathExists(candidate))) {
35
+ return candidate;
36
+ }
37
+
38
+ const parsed = path.parse(baseFilename);
39
+ for (let counter = 1; counter <= 999; counter++) {
40
+ candidate = path.join(baseDir, `${parsed.name}-${counter}${parsed.ext}`);
41
+ if (!(await pathExists(candidate))) {
42
+ return candidate;
43
+ }
44
+ }
45
+
46
+ return path.join(baseDir, `${parsed.name}-${Date.now()}${parsed.ext}`);
47
+ }
48
+
49
+ export async function resolvePlanLocationInput(ctx: ExtensionContext, rawLocation: string): Promise<string | null> {
50
+ const trimmed = rawLocation.trim();
51
+ if (!trimmed) {
52
+ return null;
53
+ }
54
+
55
+ const resolvedPath = resolvePlanFilePath(ctx.cwd, trimmed);
56
+ if (!resolvedPath) {
57
+ return null;
58
+ }
59
+
60
+ let isDirectory = /[\\/]$/.test(trimmed);
61
+ try {
62
+ const pathStats = await stat(resolvedPath);
63
+ if (pathStats.isDirectory()) {
64
+ isDirectory = true;
65
+ }
66
+ } catch (error) {
67
+ const { code } = error as { code?: string };
68
+ if (code !== "ENOENT") {
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ if (isDirectory) {
74
+ return path.join(resolvedPath, buildTimestampedPlanFilename(ctx.sessionManager.getSessionId()));
75
+ }
76
+
77
+ return resolvedPath;
78
+ }
79
+
80
+ export async function movePlanFile(sourcePath: string | undefined, targetPath: string): Promise<void> {
81
+ await mkdir(path.dirname(targetPath), { recursive: true });
82
+
83
+ if (!sourcePath) {
84
+ await ensurePlanFileExists(targetPath);
85
+ return;
86
+ }
87
+
88
+ if (sourcePath === targetPath) {
89
+ await ensurePlanFileExists(targetPath);
90
+ return;
91
+ }
92
+
93
+ try {
94
+ await rename(sourcePath, targetPath);
95
+ return;
96
+ } catch (error) {
97
+ const { code } = error as { code?: string };
98
+ if (code === "ENOENT") {
99
+ await ensurePlanFileExists(targetPath);
100
+ return;
101
+ }
102
+ if (code === "EXDEV") {
103
+ const existingContent = await readFile(sourcePath, "utf8");
104
+ await writeFile(targetPath, existingContent, "utf8");
105
+ try {
106
+ await unlink(sourcePath);
107
+ } catch (unlinkError) {
108
+ const unlinkCode = (unlinkError as { code?: string }).code;
109
+ if (unlinkCode !== "ENOENT") {
110
+ throw unlinkError;
111
+ }
112
+ }
113
+ return;
114
+ }
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ export async function ensurePlanFileExists(planFilePath: string): Promise<void> {
120
+ await mkdir(path.dirname(planFilePath), { recursive: true });
121
+ await writeFile(planFilePath, "", { encoding: "utf8", flag: "a" });
122
+ }
123
+
124
+ export async function resetPlanFile(planFilePath: string): Promise<void> {
125
+ await mkdir(path.dirname(planFilePath), { recursive: true });
126
+ await writeFile(planFilePath, "", "utf8");
127
+ }
128
+
129
+ export async function readPlanFile(planFilePath: string | undefined): Promise<string | undefined> {
130
+ if (!planFilePath) {
131
+ return undefined;
132
+ }
133
+ try {
134
+ return await readFile(planFilePath, "utf8");
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
140
+ export async function pathExists(filePath: string | undefined): Promise<boolean> {
141
+ if (!filePath) {
142
+ return false;
143
+ }
144
+ try {
145
+ await stat(filePath);
146
+ return true;
147
+ } catch (error) {
148
+ const { code } = error as { code?: string };
149
+ if (code === "ENOENT") {
150
+ return false;
151
+ }
152
+ throw error;
153
+ }
154
+ }
@@ -0,0 +1,13 @@
1
+ [PLAN MODE ACTIVE] Create a concrete implementation plan only.
2
+
3
+ Guidance:
4
+
5
+ - Focus on planning and analysis; do not write implementation code in this mode.
6
+ - Start with direct local inspection for obvious, self-contained questions.
7
+ - Use direct local inspection for codebase exploration and validation.
8
+ - Use web_search/fetch_url when external references are needed.
9
+ - Ask clarifying questions when requirements or constraints are unclear, preferably via request_user_input for short multiple-choice questions.
10
+ - Avoid pedantic questions about obvious defaults; make reasonable assumptions and continue.
11
+ - Keep a single up-to-date plan in the plan file by calling set_plan whenever the plan changes.
12
+ - Include the goal at the top of the plan.
13
+ - Before exiting plan mode, ensure set_plan has the full latest plan text.
package/prompts.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const PLAN_MODE_PROMPT_FILENAME = "PLAN.prompt.md";
7
+
8
+ function getBundledPromptPath(): string {
9
+ return path.join(import.meta.dirname, "prompts", PLAN_MODE_PROMPT_FILENAME);
10
+ }
11
+
12
+ async function readNonEmptyFile(filePath: string): Promise<string | null> {
13
+ try {
14
+ const content = await readFile(filePath, "utf8");
15
+ const trimmed = content.trim();
16
+ return trimmed.length > 0 ? trimmed : null;
17
+ } catch (error) {
18
+ const { code } = error as NodeJS.ErrnoException;
19
+ if (code === "ENOENT") {
20
+ return null;
21
+ }
22
+ throw error;
23
+ }
24
+ }
25
+
26
+ export async function loadPlanModePrompt(options?: {
27
+ agentDirPath?: string;
28
+ bundledPromptPath?: string;
29
+ }): Promise<string> {
30
+ const agentDirPath = options?.agentDirPath ?? getAgentDir();
31
+ const bundledPromptPath = options?.bundledPromptPath ?? getBundledPromptPath();
32
+ const overridePromptPath = path.join(agentDirPath, PLAN_MODE_PROMPT_FILENAME);
33
+
34
+ const overridePrompt = await readNonEmptyFile(overridePromptPath);
35
+ if (overridePrompt) {
36
+ return overridePrompt;
37
+ }
38
+
39
+ const bundledPrompt = await readNonEmptyFile(bundledPromptPath);
40
+ if (bundledPrompt) {
41
+ return bundledPrompt;
42
+ }
43
+
44
+ throw new Error(`Plan mode prompt is missing or empty: ${bundledPromptPath}`);
45
+ }
package/qna/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./pi-tui-loader.js";
2
+ export * from "./qna-tui.js";
3
+ export * from "./scroll-select.js";