@zhushanwen/pi-plan 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/__tests__/templates.test.ts +1 -1
- package/src/tool.ts +181 -108
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-plan",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Lightweight plan mode for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"@earendil-works/pi-coding-agent": ">=0.73.0",
|
|
22
22
|
"typebox": "*",
|
|
23
23
|
"@earendil-works/pi-ai": "^0.84.4",
|
|
24
|
-
"@zhushanwen/pi-goal": "0.12.
|
|
24
|
+
"@zhushanwen/pi-goal": "0.12.2"
|
|
25
25
|
},
|
|
26
26
|
"peerDependenciesMeta": {
|
|
27
27
|
"@earendil-works/pi-ai": {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"templates/"
|
|
39
39
|
],
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@zhushanwen/pi-extension-logger": "0.4.
|
|
41
|
+
"@zhushanwen/pi-extension-logger": "0.4.1"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
44
|
"typecheck": "npx tsc --noEmit",
|
|
@@ -54,7 +54,7 @@ describe("Template system", () => {
|
|
|
54
54
|
} finally {
|
|
55
55
|
if (origEnv === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
56
56
|
else process.env.PI_CODING_AGENT_DIR = origEnv;
|
|
57
|
-
fs.rmSync(isolated, { recursive: true, force: true });
|
|
57
|
+
fs.rmSync(isolated, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
58
58
|
}
|
|
59
59
|
});
|
|
60
60
|
});
|
package/src/tool.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@earendi
|
|
|
6
6
|
import { Text } from "@earendil-works/pi-tui";
|
|
7
7
|
import { Type } from "typebox";
|
|
8
8
|
|
|
9
|
-
import type { PlanSessionMap } from "./state.js";
|
|
9
|
+
import type { PlanSessionMap, PlanState } from "./state.js";
|
|
10
10
|
import { getPlanState, persistPlanState, resetPlanState } from "./state.js";
|
|
11
11
|
import { listTemplates, loadTemplate } from "./templates.js";
|
|
12
12
|
import { updatePlanWidget } from "./widget.js";
|
|
@@ -171,6 +171,172 @@ function renderPlanResult(
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// ── Action executors (one per switch case) ─────────────────────────
|
|
175
|
+
|
|
176
|
+
/** Execute result envelope (shared shape returned by every action). */
|
|
177
|
+
interface ActionResult {
|
|
178
|
+
content: Array<{ type: "text"; text: string }>;
|
|
179
|
+
details: PlanDetails;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function executeListTemplate(projectDir: string): ActionResult {
|
|
183
|
+
const templates = listTemplates(projectDir);
|
|
184
|
+
return {
|
|
185
|
+
content: [{ type: "text" as const, text: `${templates.length} templates available` }],
|
|
186
|
+
details: { action: "list-template", templates },
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function executeSelectTemplate(
|
|
191
|
+
pi: ExtensionAPI,
|
|
192
|
+
params: Record<string, unknown>,
|
|
193
|
+
state: PlanState,
|
|
194
|
+
projectDir: string,
|
|
195
|
+
): ActionResult {
|
|
196
|
+
const templateName = params.templateName as string;
|
|
197
|
+
if (!templateName) {
|
|
198
|
+
throw new Error("templateName is required for select-template");
|
|
199
|
+
}
|
|
200
|
+
const content = loadTemplate(templateName, projectDir);
|
|
201
|
+
if (!content) {
|
|
202
|
+
throw new Error(`Template not found: ${templateName}`);
|
|
203
|
+
}
|
|
204
|
+
state.templateName = templateName;
|
|
205
|
+
state.phase = "writing";
|
|
206
|
+
persistPlanState(pi, state);
|
|
207
|
+
return {
|
|
208
|
+
content: [{ type: "text" as const, text: `Template selected: ${templateName}` }],
|
|
209
|
+
details: { action: "select-template", templateName, content, phase: state.phase },
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function executeCreateTemplate(params: Record<string, unknown>, projectDir: string): ActionResult {
|
|
214
|
+
const templateName = params.templateName as string;
|
|
215
|
+
const templateContent = params.templateContent as string;
|
|
216
|
+
if (!templateName || !templateContent) {
|
|
217
|
+
throw new Error("templateName and templateContent are required for create-template");
|
|
218
|
+
}
|
|
219
|
+
const sanitizedName = templateName.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
220
|
+
if (!sanitizedName) {
|
|
221
|
+
throw new Error("Invalid template name: must contain alphanumeric characters");
|
|
222
|
+
}
|
|
223
|
+
const templateDir = path.join(projectDir, ".pi", "plan-templates");
|
|
224
|
+
fs.mkdirSync(templateDir, { recursive: true });
|
|
225
|
+
const filePath = path.join(templateDir, `${sanitizedName}.md`);
|
|
226
|
+
fs.writeFileSync(filePath, templateContent);
|
|
227
|
+
return {
|
|
228
|
+
content: [{ type: "text" as const, text: `Template created: ${sanitizedName}` }],
|
|
229
|
+
details: {
|
|
230
|
+
action: "create-template",
|
|
231
|
+
templateName: sanitizedName,
|
|
232
|
+
templateDir: relativePath(filePath, projectDir),
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function executeAbort(
|
|
238
|
+
pi: ExtensionAPI,
|
|
239
|
+
sessions: PlanSessionMap,
|
|
240
|
+
sessionId: string,
|
|
241
|
+
ctx: ExtensionContext,
|
|
242
|
+
): ActionResult {
|
|
243
|
+
const updatedState = resetPlanState(pi, sessions, sessionId, ctx);
|
|
244
|
+
updatePlanWidget(ctx, updatedState);
|
|
245
|
+
restoreFullToolSet(pi);
|
|
246
|
+
return {
|
|
247
|
+
content: [{ type: "text" as const, text: "Plan mode aborted. Full tool access restored." }],
|
|
248
|
+
details: { action: "abort" },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Build execution options filtered by available capabilities. */
|
|
253
|
+
async function buildExecOptions(pi: ExtensionAPI): Promise<string[]> {
|
|
254
|
+
const execOptions = ["Subagent-driven execution"];
|
|
255
|
+
const hasGoal = (await import("./compact.js")).detectGoalCapability(pi);
|
|
256
|
+
if (hasGoal) execOptions.push("Goal-driven execution (/goal)");
|
|
257
|
+
execOptions.push("Single-agent (current session)");
|
|
258
|
+
execOptions.push("Modify the plan first", "Save for later");
|
|
259
|
+
return execOptions;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Map the user's execution-method choice to the chosenMode string. */
|
|
263
|
+
function chosenModeFromChoice(choice: string): string {
|
|
264
|
+
if (choice === "Subagent-driven execution") return "subagent";
|
|
265
|
+
if (choice === "Goal-driven execution (/goal)") return "goal";
|
|
266
|
+
return "single-agent";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Outcome of the complete-action execution-method prompt. */
|
|
270
|
+
type CompleteChoiceOutcome =
|
|
271
|
+
| { kind: "cancelled"; result: ActionResult }
|
|
272
|
+
| { kind: "mode"; chosenMode: string };
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Prompt the user for an execution method (no-op selection when headless).
|
|
276
|
+
* Cancel / "Modify the plan first" / "Save for later" → cancelled with a
|
|
277
|
+
* complete-cancelled result; otherwise the mapped chosenMode.
|
|
278
|
+
*/
|
|
279
|
+
async function resolveCompleteChoice(ctx: ExtensionContext, pi: ExtensionAPI): Promise<CompleteChoiceOutcome> {
|
|
280
|
+
const execOptions = await buildExecOptions(pi);
|
|
281
|
+
|
|
282
|
+
if (typeof ctx.ui.select !== "function") {
|
|
283
|
+
return { kind: "mode", chosenMode: "single-agent" };
|
|
284
|
+
}
|
|
285
|
+
const choice = await ctx.ui.select("Plan is ready. Choose execution method:", execOptions);
|
|
286
|
+
if (!choice || choice === "Modify the plan first" || choice === "Save for later") {
|
|
287
|
+
return {
|
|
288
|
+
kind: "cancelled",
|
|
289
|
+
result: {
|
|
290
|
+
content: [
|
|
291
|
+
{ type: "text" as const, text: `User chose: ${choice ?? "cancelled"}. Staying in plan mode.` },
|
|
292
|
+
],
|
|
293
|
+
details: { action: "complete-cancelled", reason: choice ?? "cancelled" },
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
return { kind: "mode", chosenMode: chosenModeFromChoice(choice) };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** complete action: prompt for execution mode, persist final phase, restore tools, reset state. */
|
|
301
|
+
async function executeComplete(
|
|
302
|
+
pi: ExtensionAPI,
|
|
303
|
+
ctx: ExtensionContext,
|
|
304
|
+
params: Record<string, unknown>,
|
|
305
|
+
state: PlanState,
|
|
306
|
+
sessions: PlanSessionMap,
|
|
307
|
+
sessionId: string,
|
|
308
|
+
projectDir: string,
|
|
309
|
+
): Promise<ActionResult> {
|
|
310
|
+
const choice = await resolveCompleteChoice(ctx, pi);
|
|
311
|
+
if (choice.kind === "cancelled") {
|
|
312
|
+
return choice.result;
|
|
313
|
+
}
|
|
314
|
+
const chosenMode = choice.chosenMode;
|
|
315
|
+
|
|
316
|
+
// Persist final phase before cleanup
|
|
317
|
+
const planFilePath = state.planFilePath;
|
|
318
|
+
const isolation = (params.isolation as string) ?? "direct";
|
|
319
|
+
state.phase = "complete";
|
|
320
|
+
persistPlanState(pi, state);
|
|
321
|
+
|
|
322
|
+
// Restore full tool set
|
|
323
|
+
restoreFullToolSet(pi);
|
|
324
|
+
|
|
325
|
+
// Execute completion handler (compact/tree setup)
|
|
326
|
+
const { handlePlanComplete } = await import("./compact.js");
|
|
327
|
+
handlePlanComplete(pi, ctx, state, isolation, chosenMode);
|
|
328
|
+
|
|
329
|
+
// Reset state and clear widget — same as abort
|
|
330
|
+
const updatedState = resetPlanState(pi, sessions, sessionId, ctx);
|
|
331
|
+
updatePlanWidget(ctx, updatedState);
|
|
332
|
+
|
|
333
|
+
const displayPath = relativePath(planFilePath, projectDir);
|
|
334
|
+
return {
|
|
335
|
+
content: [{ type: "text" as const, text: `Plan approved. File: ${displayPath}` }],
|
|
336
|
+
details: { action: "complete", planFilePath: displayPath, isolation, execMode: chosenMode },
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
174
340
|
// ── Register tool ──────────────────────────────────────────────────
|
|
175
341
|
|
|
176
342
|
export function registerPlanTool(
|
|
@@ -240,113 +406,20 @@ export function registerPlanTool(
|
|
|
240
406
|
const projectDir = ctx.cwd;
|
|
241
407
|
|
|
242
408
|
switch (action) {
|
|
243
|
-
case "list-template":
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (!content) {
|
|
258
|
-
throw new Error(`Template not found: ${templateName}`);
|
|
259
|
-
}
|
|
260
|
-
state.templateName = templateName;
|
|
261
|
-
state.phase = "writing";
|
|
262
|
-
persistPlanState(pi, state);
|
|
263
|
-
return {
|
|
264
|
-
content: [{ type: "text" as const, text: `Template selected: ${templateName}` }],
|
|
265
|
-
details: { action: "select-template", templateName, content, phase: state.phase },
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
case "create-template": {
|
|
270
|
-
const templateName = params.templateName as string;
|
|
271
|
-
const templateContent = params.templateContent as string;
|
|
272
|
-
if (!templateName || !templateContent) {
|
|
273
|
-
throw new Error("templateName and templateContent are required for create-template");
|
|
274
|
-
}
|
|
275
|
-
const sanitizedName = templateName.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
276
|
-
if (!sanitizedName) {
|
|
277
|
-
throw new Error("Invalid template name: must contain alphanumeric characters");
|
|
278
|
-
}
|
|
279
|
-
const templateDir = path.join(projectDir, ".pi", "plan-templates");
|
|
280
|
-
fs.mkdirSync(templateDir, { recursive: true });
|
|
281
|
-
const filePath = path.join(templateDir, `${sanitizedName}.md`);
|
|
282
|
-
fs.writeFileSync(filePath, templateContent);
|
|
283
|
-
return {
|
|
284
|
-
content: [{ type: "text" as const, text: `Template created: ${sanitizedName}` }],
|
|
285
|
-
details: {
|
|
286
|
-
action: "create-template",
|
|
287
|
-
templateName: sanitizedName,
|
|
288
|
-
templateDir: relativePath(filePath, projectDir),
|
|
289
|
-
},
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
case "complete": {
|
|
294
|
-
// Build execution options filtered by available capabilities
|
|
295
|
-
const execOptions = ["Subagent-driven execution"];
|
|
296
|
-
const hasGoal = (await import("./compact.js")).detectGoalCapability(pi);
|
|
297
|
-
if (hasGoal) execOptions.push("Goal-driven execution (/goal)");
|
|
298
|
-
execOptions.push("Single-agent (current session)");
|
|
299
|
-
execOptions.push("Modify the plan first", "Save for later");
|
|
300
|
-
|
|
301
|
-
let chosenMode = "single-agent";
|
|
302
|
-
if (typeof ctx.ui.select === "function") {
|
|
303
|
-
const choice = await ctx.ui.select("Plan is ready. Choose execution method:", execOptions);
|
|
304
|
-
if (!choice || choice === "Modify the plan first" || choice === "Save for later") {
|
|
305
|
-
return {
|
|
306
|
-
content: [
|
|
307
|
-
{ type: "text" as const, text: `User chose: ${choice ?? "cancelled"}. Staying in plan mode.` },
|
|
308
|
-
],
|
|
309
|
-
details: { action: "complete-cancelled", reason: choice ?? "cancelled" },
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
if (choice === "Subagent-driven execution") chosenMode = "subagent";
|
|
313
|
-
else if (choice === "Goal-driven execution (/goal)") chosenMode = "goal";
|
|
314
|
-
else chosenMode = "single-agent";
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Persist final phase before cleanup
|
|
318
|
-
const planFilePath = state.planFilePath;
|
|
319
|
-
const isolation = (params.isolation as string) ?? "direct";
|
|
320
|
-
state.phase = "complete";
|
|
321
|
-
persistPlanState(pi, state);
|
|
322
|
-
|
|
323
|
-
// Restore full tool set
|
|
324
|
-
restoreFullToolSet(pi);
|
|
325
|
-
|
|
326
|
-
// Execute completion handler (compact/tree setup)
|
|
327
|
-
const { handlePlanComplete } = await import("./compact.js");
|
|
328
|
-
handlePlanComplete(pi, ctx, state, isolation, chosenMode);
|
|
329
|
-
|
|
330
|
-
// Reset state and clear widget — same as abort
|
|
331
|
-
const updatedState = resetPlanState(pi, sessions, sessionId, ctx);
|
|
332
|
-
updatePlanWidget(ctx, updatedState);
|
|
333
|
-
|
|
334
|
-
const displayPath = relativePath(planFilePath, projectDir);
|
|
335
|
-
return {
|
|
336
|
-
content: [{ type: "text" as const, text: `Plan approved. File: ${displayPath}` }],
|
|
337
|
-
details: { action: "complete", planFilePath: displayPath, isolation, execMode: chosenMode },
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
case "abort": {
|
|
342
|
-
const updatedState = resetPlanState(pi, sessions, sessionId, ctx);
|
|
343
|
-
updatePlanWidget(ctx, updatedState);
|
|
344
|
-
restoreFullToolSet(pi);
|
|
345
|
-
return {
|
|
346
|
-
content: [{ type: "text" as const, text: "Plan mode aborted. Full tool access restored." }],
|
|
347
|
-
details: { action: "abort" },
|
|
348
|
-
};
|
|
349
|
-
}
|
|
409
|
+
case "list-template":
|
|
410
|
+
return executeListTemplate(projectDir);
|
|
411
|
+
|
|
412
|
+
case "select-template":
|
|
413
|
+
return executeSelectTemplate(pi, params, state, projectDir);
|
|
414
|
+
|
|
415
|
+
case "create-template":
|
|
416
|
+
return executeCreateTemplate(params, projectDir);
|
|
417
|
+
|
|
418
|
+
case "complete":
|
|
419
|
+
return await executeComplete(pi, ctx, params, state, sessions, sessionId, projectDir);
|
|
420
|
+
|
|
421
|
+
case "abort":
|
|
422
|
+
return executeAbort(pi, sessions, sessionId, ctx);
|
|
350
423
|
}
|
|
351
424
|
},
|
|
352
425
|
});
|