@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.
Files changed (31) hide show
  1. package/dist/core/config/settings-manager.d.ts +2 -0
  2. package/dist/core/extensions/runner.js +1 -0
  3. package/dist/core/extensions/types.d.ts +2 -0
  4. package/dist/extensions/defaults/AGENT.md +11 -1
  5. package/dist/extensions/defaults/plan/enter-plan-mode-tool.d.ts +1 -1
  6. package/dist/extensions/defaults/plan/enter-plan-mode-tool.js +12 -1
  7. package/dist/extensions/defaults/plan/exit-plan-mode-tool.d.ts +1 -1
  8. package/dist/extensions/defaults/plan/exit-plan-mode-tool.js +44 -8
  9. package/dist/extensions/defaults/plan/index.js +96 -24
  10. package/dist/extensions/defaults/plan/plan-file-manager.d.ts +8 -2
  11. package/dist/extensions/defaults/plan/plan-file-manager.js +90 -3
  12. package/dist/extensions/defaults/plan/plan-permissions.d.ts +1 -1
  13. package/dist/extensions/defaults/plan/plan-permissions.js +78 -19
  14. package/dist/extensions/defaults/plan/types.d.ts +32 -2
  15. package/dist/extensions/defaults/plan/types.js +1 -0
  16. package/dist/modes/acp/acp-mode.js +1 -0
  17. package/dist/modes/interactive/interactive-mode.d.ts +1 -0
  18. package/dist/modes/interactive/interactive-mode.js +20 -0
  19. package/dist/modes/rpc/rpc-mode.js +12 -0
  20. package/dist/modes/rpc/rpc-types.d.ts +6 -0
  21. package/dist/node_modules/@pencil-agent/ai/models.generated.d.ts +7 -109
  22. package/dist/node_modules/@pencil-agent/ai/models.generated.js +40 -142
  23. package/package.json +2 -1
  24. package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/200/273/347/273/223.md" +0 -251
  25. package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/212/245/345/221/212.md" +0 -123
  26. package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210.md" +0 -1222
  27. 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
  28. 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
  29. package/docs/loop /351/207/215/346/236/204/350/256/241/345/210/222.md" +0 -321
  30. package/docs/loop-usage-examples.md +0 -215
  31. 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?: {
@@ -66,6 +66,7 @@ const noOpUIContext = {
66
66
  setEditorText: () => { },
67
67
  getEditorText: () => "",
68
68
  editor: async () => undefined,
69
+ openExternalEditor: async () => false,
69
70
  setEditorComponent: () => { },
70
71
  get theme() {
71
72
  return theme;
@@ -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 { PlanSessionState } from "./types.js";
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 { PlanSessionState } from "./types.js";
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 { writeFileSync } from "node:fs";
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
- try {
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 getSessionState(api) {
23
- return getPlanSessionState(api.events);
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, shouldQuery) {
29
- const sessionState = getSessionState(api);
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
- if (shouldQuery) {
35
- // Send a follow-up message to trigger the agent to start planning
36
- api.sendUserMessage("I've entered plan mode. Please start exploring and designing an approach.", { deliverAs: "followUp" });
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
- const sessionState = getSessionState(api);
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 shouldQuery = trimmed.length > 0 && trimmed !== "open";
85
- await enterPlanMode(api, ctx, shouldQuery);
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
- const existingPlan = getPlan(api.events) || "";
95
- const editedContent = await ctx.ui.editor("Edit Plan", existingPlan);
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
- else {
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
- const sessionState = getSessionState(api);
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
- getPlansDirectory();
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 { PlanSessionState } from "./types.js";
8
- export declare function getPlanSessionState(bus: unknown): PlanSessionState;
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
- export function getPlanSessionState(bus) {
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
- if (cachedPlansDir)
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;