@pencil-agent/nano-pencil 1.12.0 → 1.13.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/dist/core/config/settings-manager.d.ts +2 -0
- package/dist/core/extensions/runner.js +1 -0
- package/dist/core/extensions/types.d.ts +2 -0
- package/dist/extensions/defaults/AGENT.md +11 -1
- package/dist/extensions/defaults/plan/enter-plan-mode-tool.d.ts +1 -1
- package/dist/extensions/defaults/plan/enter-plan-mode-tool.js +12 -1
- package/dist/extensions/defaults/plan/exit-plan-mode-tool.d.ts +1 -1
- package/dist/extensions/defaults/plan/exit-plan-mode-tool.js +44 -8
- package/dist/extensions/defaults/plan/index.js +96 -24
- package/dist/extensions/defaults/plan/plan-file-manager.d.ts +8 -2
- package/dist/extensions/defaults/plan/plan-file-manager.js +90 -3
- package/dist/extensions/defaults/plan/plan-permissions.d.ts +1 -1
- package/dist/extensions/defaults/plan/plan-permissions.js +78 -19
- package/dist/extensions/defaults/plan/types.d.ts +32 -2
- package/dist/extensions/defaults/plan/types.js +1 -0
- package/dist/modes/acp/acp-mode.js +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts +1 -0
- package/dist/modes/interactive/interactive-mode.js +20 -0
- package/dist/modes/rpc/rpc-mode.js +12 -0
- package/dist/modes/rpc/rpc-types.d.ts +6 -0
- package/dist/node_modules/@pencil-agent/ai/models.generated.d.ts +7 -109
- package/dist/node_modules/@pencil-agent/ai/models.generated.js +40 -142
- package/package.json +2 -1
- package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/200/273/347/273/223.md" +0 -251
- package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/212/245/345/221/212.md" +0 -123
- package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210.md" +0 -1222
- package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210/345/256/236/347/216/260/346/212/245/345/221/212.md" +0 -158
- package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210/345/257/271/346/257/224/345/210/206/346/236/220.md" +0 -128
- package/docs/loop /351/207/215/346/236/204/350/256/241/345/210/222.md" +0 -321
- package/docs/loop-usage-examples.md +0 -215
- package/docs/planmode.md +0 -1987
|
@@ -87,6 +87,8 @@ export interface Settings {
|
|
|
87
87
|
autocompleteMaxVisible?: number;
|
|
88
88
|
showHardwareCursor?: boolean;
|
|
89
89
|
markdown?: MarkdownSettings;
|
|
90
|
+
/** Directory for plan mode files. Relative paths must stay inside the project root. */
|
|
91
|
+
plansDirectory?: string;
|
|
90
92
|
/** NanoMem / Dream settings */
|
|
91
93
|
nanomem?: {
|
|
92
94
|
autoDream?: {
|
|
@@ -105,6 +105,8 @@ export interface ExtensionUIContext {
|
|
|
105
105
|
getEditorText(): string;
|
|
106
106
|
/** Show a multi-line editor for text editing. */
|
|
107
107
|
editor(title: string, prefill?: string): Promise<string | undefined>;
|
|
108
|
+
/** Open an existing file in the user's external editor. Returns true when the editor exits successfully. */
|
|
109
|
+
openExternalEditor(filePath: string, title?: string): Promise<boolean>;
|
|
108
110
|
/**
|
|
109
111
|
* Set a custom editor component via factory function.
|
|
110
112
|
* Pass undefined to restore the default editor.
|
|
@@ -6,6 +6,16 @@ Member List
|
|
|
6
6
|
link-world/index.ts: Internet access extension, provides internet-search Skill after setup
|
|
7
7
|
mcp/index.ts: MCP protocol integration extension, MCP guidance resources
|
|
8
8
|
presence/index.ts: AI-driven opening + idle presence lines, uses NanoMemEngine episodes/preferences/lessons + git/cwd snapshot, injects latest line into agent systemPrompt every turn for main-conversation perception, 30s debounce + idle in-flight lock, configurable via settings.presence.enabled, PRESENCE_MESSAGE_TYPE renderer
|
|
9
|
+
plan/index.ts: Plan Mode extension entry, /plan /plan:validate /plan:approve commands, EnterPlanMode/ExitPlanMode tools, permission gating, TUI status/widget, workflow prompt injection
|
|
10
|
+
plan/types.ts: Plan mode types, persisted state payload, attachment variants, permission result model
|
|
11
|
+
plan/plan-file-manager.ts: Plan file path management, slug generation, settings-aware plans directory, plan state hydration/serialization, resume/fork copy helpers
|
|
12
|
+
plan/plan-permissions.ts: Plan mode tool permission gating, exact plan-file write checks, read-only bash allowlist, agent/tool blocking policy
|
|
13
|
+
plan/plan-workflow-prompt.ts: Plan mode full/sparse workflow prompt, reentry prompt, exit prompt, EnterPlanMode/ExitPlanMode tool result text
|
|
14
|
+
plan/enter-plan-mode-tool.ts: EnterPlanMode tool for model-initiated plan mode entry and UI state activation
|
|
15
|
+
plan/exit-plan-mode-tool.ts: ExitPlanMode tool for user-approved plan mode exit, validation, teammate approval, UI cleanup
|
|
16
|
+
plan/plan-agents.ts: Explore/Plan subagent prompt helpers and read-only tool list for plan mode
|
|
17
|
+
plan/plan-validation.ts: Plan structure validation for Context, Approach, Files/Changes, and Verification sections
|
|
18
|
+
plan/teammate-approval.ts: Teammate plan approval mailbox integration and approval/waiting message formatting
|
|
9
19
|
subagent/index.ts: SubAgent extension entry, /subagent:/subagent:run/:stop/:status/:report/:apply commands, SUBAGENT_MESSAGE_TYPE renderer
|
|
10
20
|
subagent/subagent-parser.ts: SubAgent command parsing, parseSubAgentCommand/buildSubAgentHelp
|
|
11
21
|
subagent/subagent-runner.ts: SubAgent orchestration — research (read-only) and implement (isolated worktree) roles, diff preview and apply flow
|
|
@@ -49,4 +59,4 @@ team/TESTING.md: Manual & smoke-test guide for Phase B AgentTeam
|
|
|
49
59
|
|
|
50
60
|
Rule: Members complete, one item per line, parent links valid, precise terms first
|
|
51
61
|
|
|
52
|
-
[COVENANT]: Update this file header on changes and verify against parent AGENT.md
|
|
62
|
+
[COVENANT]: Update this file header on changes and verify against parent AGENT.md
|
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
* [HERE]: extensions/defaults/plan/enter-plan-mode-tool.ts - EnterPlanMode tool for model-initiated plan mode entry
|
|
6
6
|
*/
|
|
7
7
|
import type { ExtensionAPI, ToolDefinition } from "../../../core/extensions/types.js";
|
|
8
|
-
import type
|
|
8
|
+
import { type PlanSessionState } from "./types.js";
|
|
9
9
|
export declare function createEnterPlanModeTool(api: ExtensionAPI, getSessionState: () => PlanSessionState, isChannelsEnabled: () => boolean): ToolDefinition;
|
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
* [HERE]: extensions/defaults/plan/enter-plan-mode-tool.ts - EnterPlanMode tool for model-initiated plan mode entry
|
|
6
6
|
*/
|
|
7
7
|
import { Type } from "@sinclair/typebox";
|
|
8
|
+
import { PLAN_CUSTOM_TYPE } from "./types.js";
|
|
8
9
|
import { handlePlanModeTransition } from "./plan-permissions.js";
|
|
9
10
|
import { getEnterPlanModeToolResult } from "./plan-workflow-prompt.js";
|
|
11
|
+
import { getPlanFilePath, getPlansDirectory, serializePlanSessionState } from "./plan-file-manager.js";
|
|
10
12
|
// ============================================================================
|
|
11
13
|
// Schema
|
|
12
14
|
// ============================================================================
|
|
13
|
-
const EnterPlanModeInputSchema = Type.Object({});
|
|
15
|
+
const EnterPlanModeInputSchema = Type.Object({}, { additionalProperties: false });
|
|
14
16
|
// ============================================================================
|
|
15
17
|
// Tool creation
|
|
16
18
|
// ============================================================================
|
|
@@ -21,6 +23,7 @@ export function createEnterPlanModeTool(api, getSessionState, isChannelsEnabled)
|
|
|
21
23
|
description: "Switch to plan mode to design an approach before coding. Use this when the task is complex and requires planning before implementation.",
|
|
22
24
|
parameters: EnterPlanModeInputSchema,
|
|
23
25
|
execute: async (_toolCallId, _input, _signal, _onUpdate, ctx) => {
|
|
26
|
+
getPlansDirectory(ctx.getSettings().plansDirectory, ctx.cwd);
|
|
24
27
|
const sessionState = getSessionState();
|
|
25
28
|
// Block in agent contexts (agents shouldn't enter plan mode themselves)
|
|
26
29
|
// In nanoPencil, we check if we're in a subagent context via the event bus
|
|
@@ -41,6 +44,14 @@ export function createEnterPlanModeTool(api, getSessionState, isChannelsEnabled)
|
|
|
41
44
|
handlePlanModeTransition(sessionState);
|
|
42
45
|
sessionState.state.prePlanMode = previousMode;
|
|
43
46
|
sessionState.state.mode = "plan";
|
|
47
|
+
api.appendEntry(PLAN_CUSTOM_TYPE, serializePlanSessionState(sessionState));
|
|
48
|
+
ctx.ui.setStatus("plan", "Plan mode");
|
|
49
|
+
ctx.ui.setWidget("plan-mode", [
|
|
50
|
+
"PLAN MODE",
|
|
51
|
+
`Plan: ${getPlanFilePath(api.events)}`,
|
|
52
|
+
"Read-only except the plan file",
|
|
53
|
+
"Use /plan open to edit; ExitPlanMode requests approval",
|
|
54
|
+
], { placement: "aboveEditor" });
|
|
44
55
|
return {
|
|
45
56
|
content: [{
|
|
46
57
|
type: "text",
|
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
* [HERE]: extensions/defaults/plan/exit-plan-mode-tool.ts - ExitPlanMode tool for model-requested plan approval
|
|
6
6
|
*/
|
|
7
7
|
import type { ExtensionAPI, ToolDefinition } from "../../../core/extensions/types.js";
|
|
8
|
-
import type
|
|
8
|
+
import { type PlanSessionState } from "./types.js";
|
|
9
9
|
export declare function createExitPlanModeTool(api: ExtensionAPI, getSessionState: () => PlanSessionState, hasAgentTool: () => boolean): ToolDefinition;
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* [HERE]: extensions/defaults/plan/exit-plan-mode-tool.ts - ExitPlanMode tool for model-requested plan approval
|
|
6
6
|
*/
|
|
7
7
|
import { Type } from "@sinclair/typebox";
|
|
8
|
-
import {
|
|
8
|
+
import { PLAN_CUSTOM_TYPE } from "./types.js";
|
|
9
9
|
import { handlePlanModeExit } from "./plan-permissions.js";
|
|
10
|
-
import { getPlanFilePath, getPlan } from "./plan-file-manager.js";
|
|
10
|
+
import { getPlanFilePath, getPlan, getPlansDirectory, serializePlanSessionState, writePlan, } from "./plan-file-manager.js";
|
|
11
11
|
import { getExitPlanModeApprovedResult } from "./plan-workflow-prompt.js";
|
|
12
12
|
import { validatePlan, formatValidationMessage } from "./plan-validation.js";
|
|
13
13
|
import { isInTeammateContext, submitPlanToLeader, formatPlanSubmittedMessage, } from "./teammate-approval.js";
|
|
@@ -34,6 +34,7 @@ export function createExitPlanModeTool(api, getSessionState, hasAgentTool) {
|
|
|
34
34
|
description: "Present your plan for approval and start coding. Only usable in plan mode after writing a plan.",
|
|
35
35
|
parameters: ExitPlanModeInputSchema,
|
|
36
36
|
execute: async (_toolCallId, input, _signal, _onUpdate, ctx) => {
|
|
37
|
+
getPlansDirectory(ctx.getSettings().plansDirectory, ctx.cwd);
|
|
37
38
|
const sessionState = getSessionState();
|
|
38
39
|
// Validate: must be in plan mode
|
|
39
40
|
if (sessionState.state.mode !== "plan") {
|
|
@@ -58,13 +59,9 @@ export function createExitPlanModeTool(api, getSessionState, hasAgentTool) {
|
|
|
58
59
|
// If input.plan was provided, write it back to the plan file
|
|
59
60
|
const planWasEdited = inputPlan !== undefined;
|
|
60
61
|
if (planWasEdited && plan !== null) {
|
|
61
|
-
|
|
62
|
-
writeFileSync(planFilePath, plan, "utf-8");
|
|
63
|
-
}
|
|
64
|
-
catch (err) {
|
|
65
|
-
console.error(`Failed to write updated plan file: ${err}`);
|
|
66
|
-
}
|
|
62
|
+
writePlan(api.events, plan);
|
|
67
63
|
}
|
|
64
|
+
sessionState.state.planSnapshot = plan ?? undefined;
|
|
68
65
|
// Plan validation
|
|
69
66
|
if (!forceExit) {
|
|
70
67
|
const validation = validatePlan(plan || "");
|
|
@@ -82,6 +79,9 @@ export function createExitPlanModeTool(api, getSessionState, hasAgentTool) {
|
|
|
82
79
|
}
|
|
83
80
|
// Check if we're in teammate context
|
|
84
81
|
if (isInTeammateContext()) {
|
|
82
|
+
if (!plan || plan.trim().length === 0) {
|
|
83
|
+
throw new Error(`No plan file found at ${planFilePath}. Please write your plan to this file before calling ExitPlanMode.`);
|
|
84
|
+
}
|
|
85
85
|
// Submit plan to leader for approval
|
|
86
86
|
try {
|
|
87
87
|
const { requestId } = await submitPlanToLeader(planFilePath, plan || "");
|
|
@@ -89,6 +89,7 @@ export function createExitPlanModeTool(api, getSessionState, hasAgentTool) {
|
|
|
89
89
|
// Mark as awaiting approval - mode is still "plan" until approved
|
|
90
90
|
sessionState.state.mode = "plan"; // Keep in plan mode
|
|
91
91
|
// Note: handlePlanModeExit is NOT called here - we stay in plan mode
|
|
92
|
+
api.appendEntry(PLAN_CUSTOM_TYPE, serializePlanSessionState(sessionState));
|
|
92
93
|
return {
|
|
93
94
|
content: [{
|
|
94
95
|
type: "text",
|
|
@@ -102,8 +103,43 @@ export function createExitPlanModeTool(api, getSessionState, hasAgentTool) {
|
|
|
102
103
|
console.error(`Failed to submit plan to leader: ${err}`);
|
|
103
104
|
}
|
|
104
105
|
}
|
|
106
|
+
if (!ctx.hasUI) {
|
|
107
|
+
return {
|
|
108
|
+
content: [{
|
|
109
|
+
type: "text",
|
|
110
|
+
text: "ExitPlanMode requires user approval, but no interactive UI is available. Stay in plan mode and ask the user to approve from an interactive session.",
|
|
111
|
+
}],
|
|
112
|
+
isError: true,
|
|
113
|
+
details: null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const preview = plan && plan.trim().length > 0
|
|
117
|
+
? plan.trim().slice(0, 1200)
|
|
118
|
+
: "No plan content was written.";
|
|
119
|
+
const approved = await ctx.ui.confirm("Exit plan mode?", [
|
|
120
|
+
`Plan file: ${planFilePath}`,
|
|
121
|
+
"",
|
|
122
|
+
preview,
|
|
123
|
+
plan && plan.length > 1200 ? "\n...(truncated)" : "",
|
|
124
|
+
"",
|
|
125
|
+
"Approve this plan and allow implementation mode?",
|
|
126
|
+
].join("\n"));
|
|
127
|
+
if (!approved) {
|
|
128
|
+
return {
|
|
129
|
+
content: [{
|
|
130
|
+
type: "text",
|
|
131
|
+
text: "User rejected exiting plan mode. Stay in plan mode, revise the plan file, and call ExitPlanMode again when ready.",
|
|
132
|
+
}],
|
|
133
|
+
isError: true,
|
|
134
|
+
details: null,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
105
137
|
// Normal exit: restore permissions
|
|
106
138
|
handlePlanModeExit(sessionState);
|
|
139
|
+
api.appendEntry(PLAN_CUSTOM_TYPE, serializePlanSessionState(sessionState));
|
|
140
|
+
// Clear plan mode status in TUI footer
|
|
141
|
+
ctx.ui.setStatus("plan", undefined);
|
|
142
|
+
ctx.ui.setWidget("plan-mode", undefined);
|
|
107
143
|
// Build result message
|
|
108
144
|
const resultText = getExitPlanModeApprovedResult(plan, planFilePath, planWasEdited, hasAgentTool());
|
|
109
145
|
// Notify user
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* [TO]: Auto-loaded by builtin-extensions.ts as a default extension
|
|
5
5
|
* [HERE]: extensions/defaults/plan/index.ts - main plan mode extension entry point
|
|
6
6
|
*/
|
|
7
|
-
import { getPlanSessionState, getPlanFilePath, getPlan, writePlan, getPlansDirectory, } from "./plan-file-manager.js";
|
|
7
|
+
import { getPlanSessionState, getPlanFilePath, getPlan, writePlan, getPlansDirectory, serializePlanSessionState, } from "./plan-file-manager.js";
|
|
8
8
|
import { handlePlanModeTransition, shouldAllowToolCall, } from "./plan-permissions.js";
|
|
9
9
|
import { getPlanModeInstructions, getPlanModeExitInstructions, getPlanModeReentryInstructions, } from "./plan-workflow-prompt.js";
|
|
10
10
|
import { createEnterPlanModeTool } from "./enter-plan-mode-tool.js";
|
|
11
11
|
import { createExitPlanModeTool } from "./exit-plan-mode-tool.js";
|
|
12
|
+
import { PLAN_CUSTOM_TYPE } from "./types.js";
|
|
12
13
|
// ============================================================================
|
|
13
14
|
// Constants
|
|
14
15
|
// ============================================================================
|
|
@@ -16,24 +17,56 @@ const PLAN_ATTACHMENT_CONFIG = {
|
|
|
16
17
|
TURNS_BETWEEN_ATTACHMENTS: 3,
|
|
17
18
|
FULL_REMINDER_EVERY_N: 3,
|
|
18
19
|
};
|
|
20
|
+
function countHumanTurns(ctx) {
|
|
21
|
+
return ctx.sessionManager.getBranch().filter((entry) => {
|
|
22
|
+
if (entry.type !== "message")
|
|
23
|
+
return false;
|
|
24
|
+
if (entry.message.role !== "user")
|
|
25
|
+
return false;
|
|
26
|
+
const content = entry.message.content;
|
|
27
|
+
return typeof content === "string"
|
|
28
|
+
? content.trim().length > 0
|
|
29
|
+
: content.some((block) => block.type === "text" && block.text.trim().length > 0);
|
|
30
|
+
}).length;
|
|
31
|
+
}
|
|
19
32
|
// ============================================================================
|
|
20
33
|
// State helpers
|
|
21
34
|
// ============================================================================
|
|
22
|
-
function
|
|
23
|
-
|
|
35
|
+
function preparePlansDirectory(ctx) {
|
|
36
|
+
const settings = ctx.getSettings();
|
|
37
|
+
getPlansDirectory(settings.plansDirectory, ctx.cwd);
|
|
38
|
+
}
|
|
39
|
+
function getSessionState(api, ctx) {
|
|
40
|
+
return getPlanSessionState(api.events, ctx?.sessionManager.getSessionId(), ctx?.sessionManager.getEntries());
|
|
41
|
+
}
|
|
42
|
+
function persistPlanState(api, sessionState) {
|
|
43
|
+
api.appendEntry(PLAN_CUSTOM_TYPE, serializePlanSessionState(sessionState));
|
|
44
|
+
}
|
|
45
|
+
function setPlanModeUi(ctx, api) {
|
|
46
|
+
const planFilePath = getPlanFilePath(api.events);
|
|
47
|
+
ctx.ui.setStatus("plan", "Plan mode");
|
|
48
|
+
ctx.ui.setWidget("plan-mode", [
|
|
49
|
+
"PLAN MODE",
|
|
50
|
+
`Plan: ${planFilePath}`,
|
|
51
|
+
"Read-only except the plan file",
|
|
52
|
+
"Use /plan open to edit; ExitPlanMode requests approval",
|
|
53
|
+
], { placement: "aboveEditor" });
|
|
24
54
|
}
|
|
25
55
|
// ============================================================================
|
|
26
56
|
// Plan mode entry helper
|
|
27
57
|
// ============================================================================
|
|
28
|
-
async function enterPlanMode(api, ctx,
|
|
29
|
-
|
|
58
|
+
async function enterPlanMode(api, ctx, description) {
|
|
59
|
+
preparePlansDirectory(ctx);
|
|
60
|
+
const sessionState = getSessionState(api, ctx);
|
|
30
61
|
const previousMode = sessionState.state.mode;
|
|
31
62
|
handlePlanModeTransition(sessionState);
|
|
32
63
|
sessionState.state.prePlanMode = previousMode;
|
|
33
64
|
sessionState.state.mode = "plan";
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
65
|
+
setPlanModeUi(ctx, api);
|
|
66
|
+
persistPlanState(api, sessionState);
|
|
67
|
+
ctx.ui.notify("Enabled plan mode. Read-only except the plan file.", "info");
|
|
68
|
+
if (description) {
|
|
69
|
+
api.sendUserMessage(description, { deliverAs: "followUp" });
|
|
37
70
|
}
|
|
38
71
|
}
|
|
39
72
|
// ============================================================================
|
|
@@ -47,6 +80,7 @@ function hasTool(api, name) {
|
|
|
47
80
|
// Plan display helper
|
|
48
81
|
// ============================================================================
|
|
49
82
|
function displayPlan(api, ctx) {
|
|
83
|
+
preparePlansDirectory(ctx);
|
|
50
84
|
const planFilePath = getPlanFilePath(api.events);
|
|
51
85
|
const planContent = getPlan(api.events);
|
|
52
86
|
if (!planContent) {
|
|
@@ -58,6 +92,10 @@ function displayPlan(api, ctx) {
|
|
|
58
92
|
`Path: ${planFilePath}`,
|
|
59
93
|
"",
|
|
60
94
|
planContent,
|
|
95
|
+
"",
|
|
96
|
+
(process.env.VISUAL || process.env.EDITOR)
|
|
97
|
+
? `Use /plan open to edit this plan in ${process.env.VISUAL || process.env.EDITOR}.`
|
|
98
|
+
: "Use /plan open to edit this plan.",
|
|
61
99
|
];
|
|
62
100
|
ctx.ui.notify(output.join("\n"), "info");
|
|
63
101
|
}
|
|
@@ -76,31 +114,46 @@ export default async function planExtension(api) {
|
|
|
76
114
|
// /plan command handler
|
|
77
115
|
// =========================================================================
|
|
78
116
|
const handlePlanCommand = async (args, ctx) => {
|
|
79
|
-
|
|
117
|
+
preparePlansDirectory(ctx);
|
|
118
|
+
const sessionState = getSessionState(api, ctx);
|
|
80
119
|
const currentMode = sessionState.state.mode;
|
|
81
120
|
// Not in plan mode: enter plan mode
|
|
82
121
|
if (currentMode !== "plan") {
|
|
83
122
|
const trimmed = args.trim();
|
|
84
|
-
const
|
|
85
|
-
await enterPlanMode(api, ctx,
|
|
86
|
-
ctx.ui.notify("Enabled plan mode", "info");
|
|
123
|
+
const description = trimmed.length > 0 && trimmed !== "open" ? trimmed : "";
|
|
124
|
+
await enterPlanMode(api, ctx, description);
|
|
87
125
|
return;
|
|
88
126
|
}
|
|
89
127
|
// Already in plan mode
|
|
90
128
|
const trimmed = args.trim();
|
|
91
129
|
if (trimmed === "open") {
|
|
92
|
-
// Open plan file in inline editor
|
|
93
130
|
const planFilePath = getPlanFilePath(api.events);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (editedContent !== undefined) {
|
|
97
|
-
// User saved the plan
|
|
98
|
-
writePlan(api.events, editedContent);
|
|
99
|
-
ctx.ui.notify(`Plan saved to: ${planFilePath}`, "info");
|
|
131
|
+
if (!getPlan(api.events)) {
|
|
132
|
+
writePlan(api.events, "");
|
|
100
133
|
}
|
|
101
|
-
|
|
134
|
+
if (process.env.VISUAL || process.env.EDITOR) {
|
|
135
|
+
const opened = await ctx.ui.openExternalEditor(planFilePath, "Edit Plan");
|
|
136
|
+
if (opened) {
|
|
137
|
+
const state = getSessionState(api, ctx);
|
|
138
|
+
state.state.planSnapshot = getPlan(api.events) ?? undefined;
|
|
139
|
+
persistPlanState(api, state);
|
|
140
|
+
ctx.ui.notify(`Opened plan in editor: ${planFilePath}`, "info");
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
ctx.ui.notify("Failed to open plan in external editor.", "warning");
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const editedContent = await ctx.ui.editor("Edit Plan", getPlan(api.events) || "");
|
|
148
|
+
if (editedContent === undefined) {
|
|
102
149
|
ctx.ui.notify("Plan editing cancelled.", "info");
|
|
150
|
+
return;
|
|
103
151
|
}
|
|
152
|
+
writePlan(api.events, editedContent);
|
|
153
|
+
const state = getSessionState(api, ctx);
|
|
154
|
+
state.state.planSnapshot = editedContent;
|
|
155
|
+
persistPlanState(api, state);
|
|
156
|
+
ctx.ui.notify(`Plan saved to: ${planFilePath}`, "info");
|
|
104
157
|
return;
|
|
105
158
|
}
|
|
106
159
|
// Display plan content
|
|
@@ -108,6 +161,7 @@ export default async function planExtension(api) {
|
|
|
108
161
|
};
|
|
109
162
|
api.registerCommand("plan", {
|
|
110
163
|
description: "Enable plan mode or view the current session plan",
|
|
164
|
+
getArgumentCompletions: (argumentPrefix) => "open".startsWith(argumentPrefix.trim()) ? [{ value: "open", label: "open" }] : null,
|
|
111
165
|
handler: handlePlanCommand,
|
|
112
166
|
});
|
|
113
167
|
// =========================================================================
|
|
@@ -202,7 +256,7 @@ export default async function planExtension(api) {
|
|
|
202
256
|
input: event.input,
|
|
203
257
|
};
|
|
204
258
|
const planFilePath = getPlanFilePath(api.events);
|
|
205
|
-
const result = shouldAllowToolCall(toolCall, planFilePath);
|
|
259
|
+
const result = shouldAllowToolCall(toolCall, planFilePath, api.cwd);
|
|
206
260
|
if (!result.allowed) {
|
|
207
261
|
return { block: true, reason: result.reason };
|
|
208
262
|
}
|
|
@@ -211,11 +265,13 @@ export default async function planExtension(api) {
|
|
|
211
265
|
// System prompt injection: plan mode workflow
|
|
212
266
|
// =========================================================================
|
|
213
267
|
api.on("before_agent_start", async (_event, ctx) => {
|
|
214
|
-
|
|
268
|
+
preparePlansDirectory(ctx);
|
|
269
|
+
const sessionState = getSessionState(api, ctx);
|
|
215
270
|
const { mode, needsPlanModeExitAttachment, hasExitedPlanModeInSession, planAttachmentCount } = sessionState.state;
|
|
216
271
|
// Exit attachment: injected once after plan mode exit
|
|
217
272
|
if (mode !== "plan" && needsPlanModeExitAttachment) {
|
|
218
273
|
sessionState.state.needsPlanModeExitAttachment = false;
|
|
274
|
+
persistPlanState(api, sessionState);
|
|
219
275
|
const planFilePath = getPlanFilePath(api.events);
|
|
220
276
|
const planExists = getPlan(api.events) !== null;
|
|
221
277
|
return {
|
|
@@ -224,11 +280,19 @@ export default async function planExtension(api) {
|
|
|
224
280
|
}
|
|
225
281
|
// Plan mode: inject workflow prompt
|
|
226
282
|
if (mode === "plan") {
|
|
283
|
+
setPlanModeUi(ctx, api);
|
|
227
284
|
const planFilePath = getPlanFilePath(api.events);
|
|
228
285
|
const existingPlan = getPlan(api.events);
|
|
286
|
+
const humanTurns = countHumanTurns(ctx);
|
|
287
|
+
if (sessionState.state.lastPlanAttachmentHumanTurn !== undefined &&
|
|
288
|
+
humanTurns - sessionState.state.lastPlanAttachmentHumanTurn < PLAN_ATTACHMENT_CONFIG.TURNS_BETWEEN_ATTACHMENTS) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
229
291
|
// Reentry detection
|
|
230
292
|
if (hasExitedPlanModeInSession && existingPlan !== null) {
|
|
231
293
|
sessionState.state.hasExitedPlanModeInSession = false;
|
|
294
|
+
sessionState.state.lastPlanAttachmentHumanTurn = humanTurns;
|
|
295
|
+
persistPlanState(api, sessionState);
|
|
232
296
|
// Prepend reentry instructions
|
|
233
297
|
const reentry = getPlanModeReentryInstructions(planFilePath);
|
|
234
298
|
const workflow = getPlanModeInstructions(sessionState, planFilePath, existingPlan, "full");
|
|
@@ -240,6 +304,9 @@ export default async function planExtension(api) {
|
|
|
240
304
|
const shouldFullReminder = planAttachmentCount % PLAN_ATTACHMENT_CONFIG.FULL_REMINDER_EVERY_N === 0;
|
|
241
305
|
const reminderType = shouldFullReminder ? "full" : "sparse";
|
|
242
306
|
sessionState.state.planAttachmentCount += 1;
|
|
307
|
+
sessionState.state.lastPlanAttachmentHumanTurn = humanTurns;
|
|
308
|
+
sessionState.state.planSnapshot = existingPlan ?? undefined;
|
|
309
|
+
persistPlanState(api, sessionState);
|
|
243
310
|
const prompt = getPlanModeInstructions(sessionState, planFilePath, existingPlan, reminderType);
|
|
244
311
|
return {
|
|
245
312
|
appendSystemPrompt: prompt,
|
|
@@ -249,9 +316,14 @@ export default async function planExtension(api) {
|
|
|
249
316
|
// =========================================================================
|
|
250
317
|
// Session lifecycle
|
|
251
318
|
// =========================================================================
|
|
252
|
-
api.on("session_start", () => {
|
|
319
|
+
api.on("session_start", (_event, ctx) => {
|
|
253
320
|
// Initialize plan directory
|
|
254
|
-
|
|
321
|
+
preparePlansDirectory(ctx);
|
|
322
|
+
// Restore plan mode status if we're in plan mode (session restored while in plan mode)
|
|
323
|
+
const sessionState = getSessionState(api, ctx);
|
|
324
|
+
if (sessionState.state.mode === "plan") {
|
|
325
|
+
setPlanModeUi(ctx, api);
|
|
326
|
+
}
|
|
255
327
|
});
|
|
256
328
|
api.on("session_shutdown", () => {
|
|
257
329
|
// Cleanup: nothing needed for plan mode
|
|
@@ -4,17 +4,23 @@
|
|
|
4
4
|
* [TO]: Consumed by plan extension tools, workflow prompts, permission gating
|
|
5
5
|
* [HERE]: extensions/defaults/plan/plan-file-manager.ts - plan file path management and I/O
|
|
6
6
|
*/
|
|
7
|
-
import type
|
|
8
|
-
|
|
7
|
+
import { type PlanSessionState, type PlanStateEntryData } from "./types.js";
|
|
8
|
+
import type { SessionEntry } from "../../../core/session/session-manager.js";
|
|
9
|
+
export declare function getPlanSessionState(bus: unknown, sessionId?: string, entries?: SessionEntry[]): PlanSessionState;
|
|
10
|
+
export declare function hydratePlanSessionState(sessionState: PlanSessionState, sessionId: string, entries: SessionEntry[]): void;
|
|
11
|
+
export declare function serializePlanSessionState(sessionState: PlanSessionState): PlanStateEntryData;
|
|
9
12
|
export declare function generatePlanSlug(): string;
|
|
10
13
|
export declare function getPlansDirectory(settingsPlansDir?: string, cwd?: string): string;
|
|
11
14
|
export declare function resetPlansDirectoryCache(): void;
|
|
12
15
|
export declare function getPlanSlug(bus: unknown): string;
|
|
13
16
|
export declare function setPlanSlug(bus: unknown, slug: string): void;
|
|
14
17
|
export declare function clearPlanSlug(bus: unknown): void;
|
|
18
|
+
export declare function clearAllPlanSlugs(): void;
|
|
15
19
|
export declare function getPlanFilePath(bus: unknown, agentId?: string): string;
|
|
16
20
|
export declare function getPlan(bus: unknown, agentId?: string): string | null;
|
|
17
21
|
export declare function writePlan(bus: unknown, content: string, agentId?: string): boolean;
|
|
18
22
|
export declare function planExists(bus: unknown, agentId?: string): boolean;
|
|
19
23
|
export declare function copyPlanForResume(sourceBus: unknown, targetBus: unknown): Promise<boolean>;
|
|
20
24
|
export declare function copyPlanForFork(sourceBus: unknown, targetBus: unknown): Promise<boolean>;
|
|
25
|
+
export declare function copyPlanFileToNewSlug(bus: unknown): boolean;
|
|
26
|
+
export declare function copyPlanFile(sourcePath: string, targetPath: string): boolean;
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* [TO]: Consumed by plan extension tools, workflow prompts, permission gating
|
|
5
5
|
* [HERE]: extensions/defaults/plan/plan-file-manager.ts - plan file path management and I/O
|
|
6
6
|
*/
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { dirname, join, resolve, sep } from "node:path";
|
|
10
|
+
import { PLAN_CUSTOM_TYPE, } from "./types.js";
|
|
10
11
|
// ============================================================================
|
|
11
12
|
// Word lists for slug generation
|
|
12
13
|
// ============================================================================
|
|
@@ -53,7 +54,8 @@ const MAX_SLUG_RETRIES = 10;
|
|
|
53
54
|
// State management
|
|
54
55
|
// ============================================================================
|
|
55
56
|
const stateByBus = new WeakMap();
|
|
56
|
-
|
|
57
|
+
const allSessionStates = new Set();
|
|
58
|
+
export function getPlanSessionState(bus, sessionId, entries) {
|
|
57
59
|
const eventBus = bus;
|
|
58
60
|
let state = stateByBus.get(eventBus);
|
|
59
61
|
if (!state) {
|
|
@@ -64,13 +66,65 @@ export function getPlanSessionState(bus) {
|
|
|
64
66
|
needsPlanModeExitAttachment: false,
|
|
65
67
|
hasExitedPlanModeInSession: false,
|
|
66
68
|
planAttachmentCount: 0,
|
|
69
|
+
sessionId,
|
|
67
70
|
},
|
|
68
71
|
planSlugCache: undefined,
|
|
69
72
|
};
|
|
70
73
|
stateByBus.set(eventBus, state);
|
|
74
|
+
allSessionStates.add(state);
|
|
75
|
+
}
|
|
76
|
+
if (sessionId && state.state.hydratedSessionId !== sessionId) {
|
|
77
|
+
hydratePlanSessionState(state, sessionId, entries ?? []);
|
|
71
78
|
}
|
|
72
79
|
return state;
|
|
73
80
|
}
|
|
81
|
+
export function hydratePlanSessionState(sessionState, sessionId, entries) {
|
|
82
|
+
sessionState.state.hydratedSessionId = sessionId;
|
|
83
|
+
sessionState.state.sessionId = sessionId;
|
|
84
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
85
|
+
const entry = entries[i];
|
|
86
|
+
if (entry.type !== "custom" || entry.customType !== PLAN_CUSTOM_TYPE)
|
|
87
|
+
continue;
|
|
88
|
+
const data = entry.data;
|
|
89
|
+
if (!data || data.version !== 1)
|
|
90
|
+
continue;
|
|
91
|
+
if (data.sessionId && data.sessionId !== sessionId)
|
|
92
|
+
continue;
|
|
93
|
+
sessionState.state.mode = data.mode ?? "default";
|
|
94
|
+
sessionState.state.prePlanMode = data.prePlanMode ?? "default";
|
|
95
|
+
sessionState.state.needsPlanModeExitAttachment = data.needsPlanModeExitAttachment ?? false;
|
|
96
|
+
sessionState.state.hasExitedPlanModeInSession = data.hasExitedPlanModeInSession ?? false;
|
|
97
|
+
sessionState.state.planAttachmentCount = data.planAttachmentCount ?? 0;
|
|
98
|
+
sessionState.state.lastPlanAttachmentHumanTurn = data.lastPlanAttachmentHumanTurn;
|
|
99
|
+
sessionState.state.planSlug = data.planSlug;
|
|
100
|
+
sessionState.state.planSnapshot = data.planSnapshot;
|
|
101
|
+
sessionState.planSlugCache = data.planSlug;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
sessionState.state.mode = "default";
|
|
105
|
+
sessionState.state.prePlanMode = "default";
|
|
106
|
+
sessionState.state.needsPlanModeExitAttachment = false;
|
|
107
|
+
sessionState.state.hasExitedPlanModeInSession = false;
|
|
108
|
+
sessionState.state.planAttachmentCount = 0;
|
|
109
|
+
sessionState.state.lastPlanAttachmentHumanTurn = undefined;
|
|
110
|
+
sessionState.state.planSlug = undefined;
|
|
111
|
+
sessionState.state.planSnapshot = undefined;
|
|
112
|
+
sessionState.planSlugCache = undefined;
|
|
113
|
+
}
|
|
114
|
+
export function serializePlanSessionState(sessionState) {
|
|
115
|
+
return {
|
|
116
|
+
version: 1,
|
|
117
|
+
sessionId: sessionState.state.sessionId,
|
|
118
|
+
mode: sessionState.state.mode,
|
|
119
|
+
prePlanMode: sessionState.state.prePlanMode,
|
|
120
|
+
needsPlanModeExitAttachment: sessionState.state.needsPlanModeExitAttachment,
|
|
121
|
+
hasExitedPlanModeInSession: sessionState.state.hasExitedPlanModeInSession,
|
|
122
|
+
planAttachmentCount: sessionState.state.planAttachmentCount,
|
|
123
|
+
lastPlanAttachmentHumanTurn: sessionState.state.lastPlanAttachmentHumanTurn,
|
|
124
|
+
planSlug: sessionState.planSlugCache ?? sessionState.state.planSlug,
|
|
125
|
+
planSnapshot: sessionState.state.planSnapshot,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
74
128
|
// ============================================================================
|
|
75
129
|
// Slug generation
|
|
76
130
|
// ============================================================================
|
|
@@ -84,13 +138,18 @@ export function generatePlanSlug() {
|
|
|
84
138
|
// Plans directory
|
|
85
139
|
// ============================================================================
|
|
86
140
|
let cachedPlansDir;
|
|
141
|
+
let cachedPlansDirKey;
|
|
87
142
|
export function getPlansDirectory(settingsPlansDir, cwd) {
|
|
88
|
-
|
|
143
|
+
const key = `${cwd ?? ""}\0${settingsPlansDir ?? ""}`;
|
|
144
|
+
if (cachedPlansDir && !settingsPlansDir && !cwd)
|
|
145
|
+
return cachedPlansDir;
|
|
146
|
+
if (cachedPlansDir && cachedPlansDirKey === key)
|
|
89
147
|
return cachedPlansDir;
|
|
90
148
|
if (settingsPlansDir && cwd) {
|
|
91
149
|
const resolved = resolve(cwd, settingsPlansDir);
|
|
92
150
|
if (!resolved.startsWith(cwd + sep) && resolved !== cwd) {
|
|
93
151
|
// Out of project root, fall back to default
|
|
152
|
+
mkdirSync(DEFAULT_PLANS_DIR, { recursive: true });
|
|
94
153
|
cachedPlansDir = DEFAULT_PLANS_DIR;
|
|
95
154
|
}
|
|
96
155
|
else {
|
|
@@ -102,10 +161,12 @@ export function getPlansDirectory(settingsPlansDir, cwd) {
|
|
|
102
161
|
mkdirSync(DEFAULT_PLANS_DIR, { recursive: true });
|
|
103
162
|
cachedPlansDir = DEFAULT_PLANS_DIR;
|
|
104
163
|
}
|
|
164
|
+
cachedPlansDirKey = key;
|
|
105
165
|
return cachedPlansDir;
|
|
106
166
|
}
|
|
107
167
|
export function resetPlansDirectoryCache() {
|
|
108
168
|
cachedPlansDir = undefined;
|
|
169
|
+
cachedPlansDirKey = undefined;
|
|
109
170
|
}
|
|
110
171
|
// ============================================================================
|
|
111
172
|
// Plan slug caching (per session)
|
|
@@ -124,15 +185,24 @@ export function getPlanSlug(bus) {
|
|
|
124
185
|
}
|
|
125
186
|
slug = slug;
|
|
126
187
|
sessionState.planSlugCache = slug;
|
|
188
|
+
sessionState.state.planSlug = slug;
|
|
127
189
|
return slug;
|
|
128
190
|
}
|
|
129
191
|
export function setPlanSlug(bus, slug) {
|
|
130
192
|
const sessionState = getPlanSessionState(bus);
|
|
131
193
|
sessionState.planSlugCache = slug;
|
|
194
|
+
sessionState.state.planSlug = slug;
|
|
132
195
|
}
|
|
133
196
|
export function clearPlanSlug(bus) {
|
|
134
197
|
const sessionState = getPlanSessionState(bus);
|
|
135
198
|
sessionState.planSlugCache = undefined;
|
|
199
|
+
sessionState.state.planSlug = undefined;
|
|
200
|
+
}
|
|
201
|
+
export function clearAllPlanSlugs() {
|
|
202
|
+
for (const sessionState of allSessionStates) {
|
|
203
|
+
sessionState.planSlugCache = undefined;
|
|
204
|
+
sessionState.state.planSlug = undefined;
|
|
205
|
+
}
|
|
136
206
|
}
|
|
137
207
|
// ============================================================================
|
|
138
208
|
// Plan file path
|
|
@@ -198,3 +268,20 @@ export async function copyPlanForFork(sourceBus, targetBus) {
|
|
|
198
268
|
writePlan(targetBus, content);
|
|
199
269
|
return true;
|
|
200
270
|
}
|
|
271
|
+
export function copyPlanFileToNewSlug(bus) {
|
|
272
|
+
const content = getPlan(bus);
|
|
273
|
+
if (content === null)
|
|
274
|
+
return false;
|
|
275
|
+
clearPlanSlug(bus);
|
|
276
|
+
return writePlan(bus, content);
|
|
277
|
+
}
|
|
278
|
+
export function copyPlanFile(sourcePath, targetPath) {
|
|
279
|
+
try {
|
|
280
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
281
|
+
copyFileSync(sourcePath, targetPath);
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -10,6 +10,6 @@ import type { PlanSessionState } from "./types.js";
|
|
|
10
10
|
* Check if a tool call is allowed in plan mode.
|
|
11
11
|
* Returns { allowed: true } or { allowed: false, reason: string }.
|
|
12
12
|
*/
|
|
13
|
-
export declare function shouldAllowToolCall(toolCall: ToolCallInput, planFilePath: string): ToolPermissionResult;
|
|
13
|
+
export declare function shouldAllowToolCall(toolCall: ToolCallInput, planFilePath: string, cwd?: string): ToolPermissionResult;
|
|
14
14
|
export declare function handlePlanModeTransition(sessionState: PlanSessionState): void;
|
|
15
15
|
export declare function handlePlanModeExit(sessionState: PlanSessionState): void;
|