@justjoshn/pi-pstack 0.15.36-pi.11 → 0.15.36-pi.12

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/src/mode.ts CHANGED
@@ -13,6 +13,32 @@ export interface ActiveMode {
13
13
  path?: string;
14
14
  }
15
15
 
16
+ /**
17
+ * Cursor's custom-mode lifecycle: a mode is `entered` until a request carries it, then `active`. Leaving or switching
18
+ * away from an `active` mode leaves it `exited` until the next request says so once.
19
+ */
20
+ export interface ModeState {
21
+ active?: ActiveMode & { lifecycle: 'entered' | 'active' };
22
+ exited?: string;
23
+ }
24
+
25
+ const left = (state: ModeState): string | undefined => (state.active?.lifecycle === 'active' ? state.active.skill : state.exited);
26
+
27
+ export function enterMode(state: ModeState, mode: ActiveMode): ModeState {
28
+ const exited = left(state);
29
+ return { active: { ...mode, lifecycle: 'entered' }, ...(exited === undefined ? {} : { exited }) };
30
+ }
31
+
32
+ export function exitMode(state: ModeState): ModeState {
33
+ const exited = left(state);
34
+ return exited === undefined ? {} : { exited };
35
+ }
36
+
37
+ /** What a request leaves behind: the mode it carried is now active, and its exit notice has been sent. */
38
+ export function afterRequest(state: ModeState): ModeState {
39
+ return state.active === undefined ? {} : { active: { ...state.active, lifecycle: 'active' } };
40
+ }
41
+
16
42
  export type ModeCommand = { kind: 'status' } | { kind: 'off' } | { kind: 'set'; skill: string; task: string };
17
43
 
18
44
  export function parseModeArgs(args: string): ModeCommand {
@@ -31,41 +57,53 @@ interface BranchEntry {
31
57
 
32
58
  const SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
33
59
 
60
+ /** A stored mode as a skill name only: a session file can come from elsewhere, so a stored path is ignored and a value that is not a plausible skill name (it would reach the system prompt) counts as no mode. */
61
+ function storedSkill(data: unknown): string | undefined {
62
+ const skill = (data as { skill?: unknown } | null)?.skill;
63
+ return typeof skill === 'string' && SKILL_NAME.test(skill) ? skill : undefined;
64
+ }
65
+
34
66
  /**
35
- * The last mode entry on the branch, as a skill name only: a session file can come from elsewhere, so a stored path
36
- * is ignored and a value that is not a plausible skill name (it would reach the system prompt) counts as no mode.
67
+ * Replays the branch into the mode state: each mode entry enters or leaves a mode, and an assistant message after it
68
+ * shows a request carried the mode. Skills are resolved by name, never by a stored path.
37
69
  */
38
- export function modeFromBranch(entries: readonly BranchEntry[]): ActiveMode | undefined {
39
- for (let index = entries.length - 1; index >= 0; index -= 1) {
40
- const entry = entries[index];
41
- if (entry?.type !== 'custom' || entry.customType !== MODE_ENTRY) continue;
42
- const skill = (entry.data as { skill?: unknown } | null)?.skill;
43
- return typeof skill === 'string' && SKILL_NAME.test(skill) ? { skill } : undefined;
70
+ export function modeFromBranch(entries: readonly BranchEntry[], resolve: (skill: string) => ActiveMode = (skill) => ({ skill })): ModeState {
71
+ let state: ModeState = {};
72
+ for (const entry of entries) {
73
+ if (entry.type === 'custom' && entry.customType === MODE_ENTRY) {
74
+ const skill = storedSkill(entry.data);
75
+ state = skill === undefined ? exitMode(state) : enterMode(state, resolve(skill));
76
+ } else if (entry.type === 'message' && (entry as { message?: { role?: string } }).message?.role === 'assistant') state = afterRequest(state);
44
77
  }
45
- return undefined;
78
+ return state;
79
+ }
80
+
81
+ /** Cursor's custom-mode notices for the next request (CLI `custom-modes` module). */
82
+ export function modeNotice(state: ModeState): string | undefined {
83
+ const lines: string[] = [];
84
+ const { active, exited } = state;
85
+ if (exited !== undefined && exited !== active?.skill) lines.push(`The user has exited ${exited} custom mode. Stop following that mode's skill. Continue without those constraints.`);
86
+ if (active?.path === undefined && active !== undefined) lines.push(`Mode ${active.skill} is on, but no loaded skill has that name. Tell the user to run /mode off or /mode <skill>.`);
87
+ else if (active?.path !== undefined && !existsSync(active.path)) lines.push(`Mode ${active.skill} is on, but its skill file ${active.path} no longer exists. Tell the user to run /mode off or /mode <skill>.`);
88
+ else if (active?.lifecycle === 'entered')
89
+ lines.push(`The user has entered ${active.skill} custom mode. Follow the skill instructions in ${JSON.stringify(active.path)} until the user exits or switches modes. Do not drift back to default agent behavior.`);
90
+ else if (active !== undefined)
91
+ lines.push(
92
+ `Reminder: you are still in ${active.skill} mode. Follow that mode's skill on this turn. Do not drop it because the request looks small or you already have context. Do not drift back to default agent behavior. If you cannot state the mode's hard rules, read ${JSON.stringify(active.path)} before acting.`,
93
+ );
94
+ return lines.length === 0 ? undefined : lines.join('\n\n');
46
95
  }
47
96
 
48
97
  /**
49
- * The mode text for a request. Cursor attaches the skill once, on the turn that enters the mode, then sends a reminder
50
- * that names the skill path each turn. Pi rebuilds its prompt per request, so the body rides the first request after
51
- * entry and a reminder follows. A mode whose body was never sent re-sends it, so a restart cannot leave a mode
52
- * unread.
98
+ * The skill a mode keeps in context on every request until the user leaves it, in the block Pi's own /skill:name
99
+ * expansion builds. Cursor keeps the attached skill durable in the conversation; a section keeps it in every request,
100
+ * through compaction and restarts, and Pi re-sends a section only when it changes.
53
101
  */
54
- export function modeSection(mode: ActiveMode, includeBody: boolean): string {
55
- if (mode.path === undefined) return `Mode ${mode.skill} is on, but no loaded skill has that name. Tell the user to run /mode off or /mode <skill>.`;
56
- if (!existsSync(mode.path)) return `Mode ${mode.skill} is on, but its skill file ${mode.path} no longer exists. Tell the user to run /mode off or /mode <skill>.`;
57
- if (!includeBody)
58
- return `Reminder: you are still in ${mode.skill} mode. Follow that mode's skill on this turn. Do not drop it because the request looks small or you already have context. Do not drift back to default agent behavior. If you cannot state the mode's hard rules, read ${JSON.stringify(mode.path)} before acting.`;
59
- // The frontmatter, including `reminder`, stays out of the model's context in a mode (MODE-004).
102
+ export function modeSkill(mode: ActiveMode): string | undefined {
103
+ if (mode.path === undefined || !existsSync(mode.path)) return undefined;
104
+ // The frontmatter stays out of the model's context in a mode (MODE-004).
60
105
  const body = stripFrontmatter(readFileSync(mode.path, 'utf8')).trim();
61
- return [
62
- `The user has entered ${mode.skill} custom mode. Follow the skill instructions in ${JSON.stringify(mode.path)} until the user exits or switches modes. Do not drift back to default agent behavior.`,
63
- `<skill name="${mode.skill}" location="${mode.path}">`,
64
- `References are relative to ${dirname(mode.path)}.`,
65
- '',
66
- body,
67
- '</skill>',
68
- ].join('\n');
106
+ return [`<skill name="${mode.skill}" location="${mode.path}">`, `References are relative to ${dirname(mode.path)}.`, '', body, '</skill>'].join('\n');
69
107
  }
70
108
 
71
109
  // A mode's badge shows the skill's `icon` (default: lightning) and `color`. Icon names map to terminal glyphs and
@@ -108,20 +146,22 @@ function loadedSkills(pi: ExtensionAPI): Map<string, string> {
108
146
  return skills;
109
147
  }
110
148
 
111
- export function registerMode(pi: ExtensionAPI): { section: () => string | undefined } {
112
- let active: ActiveMode | undefined;
113
- // The mode whose body the conversation already carries; cleared on entry, exit, and session change.
114
- let bodySent: string | undefined;
149
+ /** The mode sections of one request: Cursor's notice, and the skill the mode keeps in context. */
150
+ export interface ModeSections {
151
+ pstack_mode?: string;
152
+ pstack_mode_skill?: string;
153
+ }
154
+
155
+ export function registerMode(pi: ExtensionAPI): { sections: () => ModeSections } {
156
+ let state: ModeState = {};
115
157
  const show = (ctx: ExtensionContext) => {
116
158
  if (!ctx.hasUI) return;
117
- const badge = active === undefined ? undefined : modeBadge(active);
159
+ const badge = state.active === undefined ? undefined : modeBadge(state.active);
118
160
  ctx.ui.setStatus(STATUS_KEY, badge === undefined ? undefined : badge.color === undefined ? badge.text : ctx.ui.theme.fg(badge.color, badge.text));
119
161
  };
120
162
  // The skill is looked up by name among loaded skills; a stored path is never read, since it can be stale or crafted.
121
163
  const restore = (ctx: ExtensionContext) => {
122
- const stored = modeFromBranch(ctx.sessionManager.getBranch());
123
- active = stored === undefined ? undefined : (tryResolve(stored.skill) ?? { skill: stored.skill });
124
- bodySent = undefined;
164
+ state = modeFromBranch(ctx.sessionManager.getBranch(), (skill) => tryResolve(skill) ?? { skill });
125
165
  show(ctx);
126
166
  };
127
167
  pi.on('session_start', (_event, ctx) => restore(ctx));
@@ -145,12 +185,12 @@ export function registerMode(pi: ExtensionAPI): { section: () => string | undefi
145
185
 
146
186
  const handle = (args: string, ctx: ExtensionCommandContext) => {
147
187
  const command = parseModeArgs(args);
188
+ const { active } = state;
148
189
  if (command.kind === 'status') return ctx.ui.notify(active === undefined ? 'No mode is on.' : `Mode on: ${active.skill} (${active.path ?? 'no loaded skill has that name'}).`, 'info');
149
- active = command.kind === 'off' ? undefined : resolveSkill(command.skill);
150
- bodySent = undefined;
151
- pi.appendEntry(MODE_ENTRY, active === undefined ? null : { skill: active.skill });
190
+ state = command.kind === 'off' ? exitMode(state) : enterMode(state, resolveSkill(command.skill));
191
+ pi.appendEntry(MODE_ENTRY, state.active === undefined ? null : { skill: state.active.skill });
152
192
  show(ctx);
153
- ctx.ui.notify(active === undefined ? 'Mode off.' : `Mode on: ${active.skill}. /mode off exits.`, 'info');
193
+ ctx.ui.notify(state.active === undefined ? 'Mode off.' : `Mode on: ${state.active.skill}. /mode off exits.`, 'info');
154
194
  if (command.kind === 'set' && command.task !== '') pi.sendUserMessage(command.task, { expandPromptTemplates: true, ...(ctx.isIdle() ? {} : { deliverAs: 'followUp' as const }) });
155
195
  return undefined;
156
196
  };
@@ -168,13 +208,11 @@ export function registerMode(pi: ExtensionAPI): { section: () => string | undefi
168
208
  });
169
209
 
170
210
  return {
171
- section: () => {
172
- if (active === undefined) return undefined;
173
- const includeBody = bodySent !== active.skill;
174
- // Only a request that actually carried the body counts as sent, so a missing skill file cannot silence the body
175
- // after the file comes back.
176
- if (includeBody && active.path !== undefined && existsSync(active.path)) bodySent = active.skill;
177
- return modeSection(active, includeBody);
211
+ sections: () => {
212
+ const notice = modeNotice(state);
213
+ const skill = state.active === undefined ? undefined : modeSkill(state.active);
214
+ state = afterRequest(state);
215
+ return { ...(notice === undefined ? {} : { pstack_mode: notice }), ...(skill === undefined ? {} : { pstack_mode_skill: skill }) };
178
216
  },
179
217
  };
180
218
  }
package/src/plan.ts CHANGED
@@ -1,9 +1,11 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
2
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
4
  import { join, resolve, sep } from 'node:path';
4
5
 
5
6
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
6
7
  import { Type } from 'typebox';
8
+ import { canAsk } from './modes.ts';
7
9
  import { plansDir } from './paths.ts';
8
10
  import { createReadonlyBashTool, releaseReadonlySandbox } from './readonly-shell.ts';
9
11
 
@@ -16,13 +18,19 @@ const STATUS_KEY = 'pstack-plan';
16
18
  */
17
19
  export const PLAN_BASH = 'bash_readonly';
18
20
 
19
- /** The tool that saves a plan artifact, as Cursor's plan mode saves one under `~/.cursor/plans`. */
20
- export const PLAN_SAVE = 'create_plan';
21
+ /** Cursor's plan tool: it writes the plan file the user reviews and builds. */
22
+ export const PLAN_SAVE = 'CreatePlan';
23
+
24
+ /** Cursor's tool for moving between agent and plan mode with the user's consent. */
25
+ export const SWITCH_MODE = 'SwitchMode';
21
26
 
22
27
  /** The tools plan mode keeps active. Names that are not registered are ignored by `setActiveTools`. */
23
- export const PLAN_TOOLS = ['read', 'grep', 'find', 'ls', PLAN_BASH, PLAN_SAVE, 'AskQuestion', 'TodoWrite', 'agent_status', 'Task', 'stop_agent'] as const;
28
+ export const PLAN_TOOLS = ['read', 'grep', 'find', 'ls', 'edit', 'write', PLAN_BASH, PLAN_SAVE, SWITCH_MODE, 'AskQuestion', 'TodoWrite', 'agent_status', 'Task', 'stop_agent'] as const;
24
29
  const ALLOWED = new Set<string>(PLAN_TOOLS);
25
30
 
31
+ /** Cursor's plan-mode guardrail: only markdown files are edited while planning. */
32
+ const MARKDOWN = /\.(?:md|markdown|mdc)$/i;
33
+
26
34
  export type PlanCommand = { kind: 'off' } | { kind: 'build' } | { kind: 'on'; task: string };
27
35
 
28
36
  export function parsePlanArgs(args: string): PlanCommand {
@@ -74,46 +82,136 @@ export function planFromBranch(entries: readonly BranchEntry[], plansDirectory =
74
82
  }
75
83
 
76
84
  /** Why a call is refused in plan mode, or undefined when it may run. */
77
- export function planBlockReason(toolName: string): string | undefined {
85
+ export function planBlockReason(toolName: string, input: Record<string, unknown> = {}): string | undefined {
86
+ if (toolName === 'write' || toolName === 'edit') {
87
+ const path = input['path'];
88
+ if (typeof path === 'string' && MARKDOWN.test(path)) return undefined;
89
+ return `Plan mode is on: ${toolName} may change only markdown files while planning. Put the change in the plan. If the user asked you to build it now, switch to agent mode with ${SWITCH_MODE} first.`;
90
+ }
78
91
  if (ALLOWED.has(toolName)) return undefined;
79
- if (toolName === 'write' || toolName === 'edit') return `Plan mode is on: ${toolName} is blocked, since plan mode changes no files. Put the change in the plan; the user runs /plan off to execute it.`;
80
92
  if (toolName === 'bash') return `Plan mode is on: use ${PLAN_BASH}, the read-only sandboxed shell, instead of bash.`;
81
93
  return `Plan mode is on: ${toolName} is not available. Plan mode allows only ${PLAN_TOOLS.join(', ')}.`;
82
94
  }
83
95
 
96
+ /** Cursor's plan-mode instructions, with Pi's tool names. */
84
97
  export function planSection(planFile: string | undefined): string {
85
98
  return [
86
- 'Plan mode is on until the user runs /plan off. Do not change files or state.',
87
- `Your tools are read-only: read, grep, find, ls, ${PLAN_BASH} (a shell in an OS sandbox that denies writes and network access), ${PLAN_SAVE} to save the plan to a file, AskQuestion, TodoWrite, agent_status, Task, whose subagents always run readonly: a fresh one runs in this checkout, never in a worktree, and a resume or fork stays where its agent ran, and stop_agent to stop one. write and edit are blocked.`,
88
- 'Investigate the code and context the task needs. When a real decision belongs to the user (a product choice or a tradeoff you cannot settle from evidence), ask with AskQuestion; settle facts yourself.',
89
- `Finish with a plan: the goal; numbered steps, each with how to verify it; and the risks and open questions. Save it with ${PLAN_SAVE} so the user can review the file and build it, and also put it in your reply.`,
90
- planFile === undefined ? 'The user leaves plan mode with /plan off to execute the plan.' : `The saved plan is ${planFile}. /plan build leaves plan mode and starts the work; /plan shows the file.`,
99
+ 'Plan mode is on. The user does not want you to execute yet. Do not edit files other than markdown files, do not run tools that change anything (no config changes, no commits), and do not otherwise change the system. This overrides any other instruction to make edits. Instead:',
100
+ "1. Answer the user's request by researching it.",
101
+ '2. If you do not have enough information to make an accurate plan, ask the user. If an instruction is ambiguous, ask the user to clarify it.',
102
+ '3. If the request is too broad, ask questions that narrow the scope of the plan, one or two critical questions at a time.',
103
+ '4. If several valid implementations would each change the plan significantly, ask the user which one to use.',
104
+ '5. Ask your questions right away, at the start. Read first only when five files or fewer will likely answer them.',
105
+ `6. When your research is done, present the plan by calling ${PLAN_SAVE}, which asks the user to confirm it. Make no changes until the user confirms.`,
106
+ '7. Keep the plan concise, specific, and actionable. Cite file paths and the essential code, and link each file you mention as a markdown link, such as [src/foo.ts](src/foo.ts).',
107
+ '8. Keep the plan proportional to the request. Do not over-engineer a simple task.',
108
+ '9. Do not use emojis in the plan.',
109
+ '10. To speed up research, launch parallel explore subagents with Task to cover different parts of the codebase or different angles at once.',
110
+ '11. When the plan explains architecture, data flows, or other relationships, consider a mermaid diagram. In mermaid, use no spaces in node IDs (camelCase, PascalCase, or underscores); quote edge labels and node labels that contain parentheses, brackets, commas, or colons, such as A["Process (main)"] and A -->|"O(1) lookup"| B; do not use end, subgraph, graph, or flowchart as node IDs; give each subgraph an ID and a bracketed label, such as subgraph auth [Authentication Flow]; keep angle brackets and HTML entities out of labels; set no colors or styles; and do not use click.',
111
+ '12. Ask every question with the AskQuestion tool.',
112
+ `Guardrails: edit only markdown files in plan mode. While the user is refining the plan, stay in plan mode and keep your edits in markdown. If the user explicitly asks you to build, implement, or write the code now, call ${SWITCH_MODE} with target_mode_id agent before you make any other edit.`,
113
+ `Your tools here: read, grep, find, ls, ${PLAN_BASH} (a shell in an OS sandbox that denies file writes and network access), edit and write for markdown files only, ${PLAN_SAVE}, ${SWITCH_MODE}, AskQuestion, TodoWrite, agent_status, Task (its subagents run readonly in this checkout), and stop_agent.`,
114
+ planFile === undefined
115
+ ? 'The user leaves plan mode with /plan off.'
116
+ : `The plan is saved at ${planFile}. To change it, edit that file; do not call ${PLAN_SAVE} again. The user builds it with /plan build, or leaves plan mode with /plan off.`,
91
117
  ].join('\n');
92
118
  }
93
119
 
94
- /** A plan file name in Cursor's shape: the sanitized title, its first 50 characters, then the id's first 8. */
95
- export function planFileName(title: string, id: string): string {
96
- const clean = title
120
+ export interface PlanTodo {
121
+ id: string;
122
+ content: string;
123
+ }
124
+
125
+ /** The plan's title as Cursor finds it: the first level-1 heading, else the first line of text. */
126
+ export function planTitle(plan: string): string {
127
+ const heading = /^#\s+(.+)$/m.exec(plan)?.[1];
128
+ if (heading !== undefined) return heading.trim().slice(0, 50);
129
+ const line = plan.split('\n').find((candidate) => candidate.trim() !== '' && !candidate.trim().startsWith('#'));
130
+ return line === undefined ? 'Plan' : line.trim().slice(0, 50);
131
+ }
132
+
133
+ /** A plan file name in Cursor's shape: the sanitized name, its first 50 characters, then 8 hex characters of the chat. */
134
+ export function planFileName(name: string, chatKey: string): string {
135
+ const clean = name
97
136
  .replace(/[^a-zA-Z0-9-_ ]/g, '')
98
137
  .trim()
99
138
  .slice(0, 50);
100
- return `${clean === '' ? 'Plan' : clean}-${id.slice(0, 8)}.plan.md`;
139
+ return `${clean === '' ? 'Plan' : clean}-${chatKey.slice(0, 8)}.plan.md`;
140
+ }
141
+
142
+ const yamlString = (value: string) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')}"`;
143
+
144
+ /** The plan file as Cursor writes it: the chat id in a comment, the todos as front matter, then the plan. */
145
+ export function planFileContent(sessionId: string, plan: string, todos: readonly PlanTodo[]): string {
146
+ const lines = [`<!-- ${sessionId} -->`];
147
+ if (todos.length > 0) {
148
+ lines.push('---', 'todos:');
149
+ for (const todo of todos) lines.push(` - id: ${yamlString(todo.id)}`, ` content: ${yamlString(todo.content)}`, ' status: pending');
150
+ lines.push('isProject: false', '---');
151
+ }
152
+ lines.push(plan);
153
+ return lines.join('\n');
101
154
  }
102
155
 
103
- /** Writes the plan under the agent's `plans` folder and returns its path. */
104
- export function savePlan(title: string, content: string, dir = plansDir()): string {
156
+ /**
157
+ * Writes the plan under the plans folder and returns its path. A chat keeps one file per plan name, as Cursor keys the
158
+ * name by its conversation; Pi's session ids start with a timestamp, so the 8 characters come from a hash of the id.
159
+ */
160
+ export function savePlan(sessionId: string, plan: string, name: string | undefined, todos: readonly PlanTodo[], dir = plansDir()): string {
105
161
  mkdirSync(dir, { recursive: true, mode: 0o700 });
106
- const id = randomUUID();
107
- const path = join(dir, planFileName(title, id));
108
- writeFileSync(path, `<!-- plan: ${id} -->\n\n${content.trim()}\n`, { mode: 0o600 });
162
+ const chatKey = createHash('sha256').update(sessionId).digest('hex');
163
+ const path = join(dir, planFileName(name === undefined || name.trim() === '' ? planTitle(plan) : name, chatKey));
164
+ writeFileSync(path, planFileContent(sessionId, plan, todos), { mode: 0o600 });
109
165
  return path;
110
166
  }
111
167
 
112
- const PlanSaveParams = Type.Object({
113
- name: Type.Optional(Type.String({ description: 'Short plan title, used for the file name. Defaults to the first line of the plan.' })),
114
- content: Type.String({ description: 'The full plan in Markdown: the goal, numbered steps with how to verify each, and the risks and open questions.' }),
168
+ /** The plan text the build turn carries: the file without Cursor's chat comment and todo front matter. */
169
+ export function planBody(file: string): string {
170
+ return readFileSync(file, 'utf8')
171
+ .replace(/^<!-- [^\n]*-->\n/, '')
172
+ .replace(/^---\n[\s\S]*?\n---\n/, '')
173
+ .trim();
174
+ }
175
+
176
+ const CreatePlanParams = Type.Object({
177
+ plan: Type.String({ description: "A detailed, concrete plan for accomplishing the user's request" }),
178
+ name: Type.Optional(Type.String({ description: 'A short 3-4 word name for the plan. Give it only on the first CreatePlan call, so the plan file name stays stable.' })),
179
+ overview: Type.Optional(Type.String({ description: 'A 1-2 sentence high-level description of what the plan accomplishes' })),
180
+ todos: Type.Optional(
181
+ Type.Array(Type.Object({ id: Type.String({ description: 'Unique identifier for the todo' }), content: Type.String({ description: 'Description of the todo task' }) }), { description: 'Array of implementation todos' }),
182
+ ),
183
+ });
184
+
185
+ // Cursor's CreatePlan rules: a glanceable markdown plan with a title, todos, no tables, and edits in the file afterwards.
186
+ const CREATE_PLAN_DESCRIPTION = [
187
+ "Create a concise plan for the user's request. Call it at the end of the planning phase to finalize and store the plan; the user is then asked to confirm it.",
188
+ '',
189
+ 'Write the plan in markdown with sections and headers. Keep it concise and actionable: the minimum detail the user needs to understand and act on it. Name the few most important files you will change and the existing code you will reuse, and cite file paths and essential snippets. Do not use markdown tables in the plan; use bullet lists. The first line must be a level-1 markdown heading with the plan title.',
190
+ '',
191
+ 'Organize implementation work as todos: each a clear, specific, actionable task with a unique id (such as "setup-auth") and descriptive content. A simple plan needs a few high-level todos or none.',
192
+ '',
193
+ 'Each call creates a new plan file and returns its path. To update an existing plan, read and edit that file with your file tools; do not call this tool again. Ask clarifying questions with AskQuestion before you call it, not inside the plan. Focus on the meaningful decisions rather than low-level detail. A good plan is glanceable, not a wall of text.',
194
+ ].join('\n');
195
+
196
+ const SwitchModeParams = Type.Object({
197
+ target_mode_id: Type.Enum(['plan', 'agent'] as const, { type: 'string', description: "The mode to switch to: 'plan' or 'agent'." }),
198
+ explanation: Type.Optional(Type.String({ description: 'Why the switch helps, shown to the user who approves it.' })),
115
199
  });
116
200
 
201
+ // Cursor's SwitchMode rules: switch proactively when the work changes shape, rarely otherwise, and only with consent.
202
+ const SWITCH_MODE_DESCRIPTION = [
203
+ 'Switch the interaction mode to match the task. Agent mode is the default, with every tool for making changes. Plan mode is read-only collaborative planning before any code: research, questions, and a plan the user confirms.',
204
+ '',
205
+ 'Switch to plan mode proactively when the task has several valid approaches with significant tradeoffs, needs an architectural decision, touches many files or systems, has unclear requirements you must explore first, or would otherwise need several clarifying questions. Switch to agent mode once the plan is settled, or when the user asks you to build it now.',
206
+ '',
207
+ 'Do not switch for a simple, clear task, in the middle of implementation that is going well, for a minor clarifying question, or when the current mode is working. Explain briefly in explanation why you switch. The user must approve every switch.',
208
+ ].join('\n');
209
+
210
+ /** The plan review choices Cursor offers after a plan; building in the cloud appears only in a git repository. */
211
+ export const BUILD_LOCALLY = 'Yes, build locally';
212
+ export const BUILD_IN_WORKER = 'Yes, build in cloud';
213
+ export const PROPOSE_CHANGES = 'No, propose changes';
214
+
117
215
  // Pi's read-only built-ins and the sandboxed shell are always on in plan mode. The other plan tools stay on only
118
216
  // if they were on before: pstack turns Task off at the depth limit, and plan mode must not hand it back.
119
217
  const ALWAYS = ['read', 'grep', 'find', 'ls', PLAN_BASH, PLAN_SAVE];
@@ -128,28 +226,45 @@ export function buildRequest(planFile: string, content: string): string {
128
226
  return [`Build the plan saved at ${planFile}. Follow it.`, '', content.trim()].join('\n');
129
227
  }
130
228
 
131
- export function registerPlanMode(pi: ExtensionAPI): { section: () => string | undefined; enabled: () => boolean } {
229
+ const isGitRepository = (cwd: string): boolean => {
230
+ try {
231
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, stdio: 'ignore' });
232
+ return true;
233
+ } catch {
234
+ return false;
235
+ }
236
+ };
237
+
238
+ export interface PlanModeOptions {
239
+ /** Starts the build in an isolated worktree worker, Pi's counterpart to Cursor's "build in cloud". Returns the agent ID. */
240
+ buildInWorker?: (planFile: string, request: string) => Promise<string>;
241
+ }
242
+
243
+ export function registerPlanMode(pi: ExtensionAPI, options: PlanModeOptions = {}): { section: () => string | undefined; enabled: () => boolean } {
132
244
  let state: PlanState = { enabled: false };
245
+ // A plan saved during the current run, which the user reviews once the run settles.
246
+ let review: string | undefined;
133
247
 
134
248
  // Inactive until plan mode turns it on.
135
249
  pi.registerTool({ ...createReadonlyBashTool(PLAN_BASH), defaultActive: false });
136
250
  pi.registerTool({
137
251
  name: PLAN_SAVE,
138
- label: 'Save plan',
139
- description: 'Save the plan to a file so the user can review it and build it. Available only in plan mode. Returns the file path.',
140
- promptSnippet: 'Save the plan to a file',
141
- parameters: PlanSaveParams,
252
+ label: 'Create plan',
253
+ description: CREATE_PLAN_DESCRIPTION,
254
+ promptSnippet: 'Save the plan to a file the user reviews and builds',
255
+ parameters: CreatePlanParams,
142
256
  exposure: 'model-only',
143
257
  executionMode: 'sequential',
144
258
  defaultActive: false,
145
259
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
146
- async execute(_id, params) {
260
+ async execute(_id, params, _signal, _onUpdate, ctx) {
147
261
  if (!state.enabled) throw new Error(`${PLAN_SAVE} is available only in plan mode. Enter it with /plan.`);
148
- const title = params.name ?? params.content.trim().split('\n')[0] ?? '';
149
- const planFile = savePlan(title.replace(/^#+\s*/, ''), params.content);
262
+ const planFile = savePlan(ctx.sessionManager.getSessionId(), params.plan, params.name, params.todos ?? []);
150
263
  state = { ...state, planFile };
151
- pi.appendEntry(PLAN_ENTRY, { ...state, planFile });
152
- return { content: [{ type: 'text' as const, text: `Plan saved to ${planFile}. /plan build starts the work.` }], details: { planFile } };
264
+ review = planFile;
265
+ pi.appendEntry(PLAN_ENTRY, state);
266
+ const overview = params.overview === undefined || params.overview.trim() === '' ? '' : `\n${params.overview.trim()}`;
267
+ return { content: [{ type: 'text' as const, text: `Created the plan file ${planFile}.${overview}\nTo change the plan, edit that file; do not call ${PLAN_SAVE} again.` }], details: { planFile } };
153
268
  },
154
269
  });
155
270
  pi.on('session_shutdown', releaseReadonlySandbox);
@@ -171,8 +286,11 @@ export function registerPlanMode(pi: ExtensionAPI): { section: () => string | un
171
286
  const restore = (ctx: ExtensionContext) => {
172
287
  const stored = planFromBranch(ctx.sessionManager.getBranch());
173
288
  state = { ...stored };
289
+ review = undefined;
174
290
  if (stored.enabled) enable(stored.toolsBefore ?? state.toolsBefore ?? pi.getActiveTools());
175
291
  else if (pi.getActiveTools().some((name) => name === PLAN_BASH || name === PLAN_SAVE)) pi.setActiveTools(pi.getActiveTools().filter((name) => name !== PLAN_BASH && name !== PLAN_SAVE));
292
+ // SwitchMode needs someone to approve the switch, so a run nobody watches does not get it.
293
+ if (!canAsk(ctx) && pi.getActiveTools().includes(SWITCH_MODE)) pi.setActiveTools(pi.getActiveTools().filter((name) => name !== SWITCH_MODE));
176
294
  show(ctx);
177
295
  };
178
296
  pi.on('session_start', (_event, ctx) => restore(ctx));
@@ -183,24 +301,76 @@ export function registerPlanMode(pi: ExtensionAPI): { section: () => string | un
183
301
  // so the restriction never reaches an agent's record.
184
302
  pi.on('tool_call', (event) => {
185
303
  if (!state.enabled) return undefined;
186
- const reason = planBlockReason(event.toolName);
304
+ const reason = planBlockReason(event.toolName, event.input as Record<string, unknown>);
187
305
  return reason === undefined ? undefined : { block: true, reason };
188
306
  });
189
307
 
308
+ const build = (ctx: ExtensionContext, planFile: string) => {
309
+ if (state.enabled) disable();
310
+ pi.appendEntry(PLAN_ENTRY, state);
311
+ show(ctx);
312
+ ctx.ui.notify(`Building ${planFile}.`, 'info');
313
+ pi.sendUserMessage(buildRequest(planFile, planBody(planFile)), { expandPromptTemplates: true, ...(ctx.isIdle() ? {} : { deliverAs: 'followUp' as const }) });
314
+ };
315
+
316
+ const reviewPlan = async (ctx: ExtensionContext, planFile: string) => {
317
+ const worker = options.buildInWorker !== undefined && isGitRepository(ctx.cwd);
318
+ const choice = await ctx.ui.select(`Plan saved at ${planFile}. Build it?`, worker ? [BUILD_LOCALLY, BUILD_IN_WORKER, PROPOSE_CHANGES] : [BUILD_LOCALLY, PROPOSE_CHANGES]);
319
+ if (!state.enabled || state.planFile !== planFile) return;
320
+ if (choice === BUILD_LOCALLY) build(ctx, planFile);
321
+ else if (choice === BUILD_IN_WORKER && options.buildInWorker !== undefined) {
322
+ disable();
323
+ pi.appendEntry(PLAN_ENTRY, state);
324
+ show(ctx);
325
+ const agentId = await options.buildInWorker(planFile, buildRequest(planFile, planBody(planFile)));
326
+ ctx.ui.notify(`Building ${planFile} in an isolated worker (agent ${agentId}). Its result arrives as a Task notification.`, 'info');
327
+ }
328
+ };
329
+
330
+ // After a run that saved a plan, the user picks what happens next, as Cursor's plan card asks. The dialog runs on
331
+ // its own so that other settle handlers, such as a loop's next tick, do not wait for the user's answer.
332
+ pi.on('agent_settled', (event, ctx) => {
333
+ const planFile = review;
334
+ review = undefined;
335
+ if (event.aborted || planFile === undefined || !state.enabled || !canAsk(ctx) || !existsSync(planFile)) return;
336
+ reviewPlan(ctx, planFile).catch((error: Error) => ctx.ui.notify(`Could not start the plan build: ${error.message}`, 'error'));
337
+ });
338
+
339
+ pi.registerTool({
340
+ name: SWITCH_MODE,
341
+ label: 'Switch mode',
342
+ description: SWITCH_MODE_DESCRIPTION,
343
+ promptSnippet: 'Switch between agent mode and plan mode, with the user approving',
344
+ parameters: SwitchModeParams,
345
+ exposure: 'model-only',
346
+ executionMode: 'sequential',
347
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
348
+ async execute(_id, params, _signal, _onUpdate, ctx) {
349
+ const current = state.enabled ? 'plan' : 'agent';
350
+ if (params.target_mode_id === current) return { content: [{ type: 'text' as const, text: `Already in ${current} mode.` }], details: { mode: current, switched: false } };
351
+ if (!canAsk(ctx)) throw new Error('A mode switch needs the user to approve it, and nobody can approve one in this run.');
352
+ const label = params.target_mode_id === 'plan' ? 'Plan' : 'Agent';
353
+ const approved = await ctx.ui.confirm(`Switch to ${label} mode?`, params.explanation?.trim() ?? '');
354
+ if (!approved) return { content: [{ type: 'text' as const, text: `The user declined the switch to ${params.target_mode_id} mode. Stay in ${current} mode.` }], details: { mode: current, switched: false } };
355
+ if (params.target_mode_id === 'plan') enable(pi.getActiveTools());
356
+ else disable();
357
+ pi.appendEntry(PLAN_ENTRY, state);
358
+ show(ctx);
359
+ const text =
360
+ params.target_mode_id === 'plan' ? `Switched to plan mode. Follow these instructions from now on:\n${planSection(state.planFile)}` : 'Switched to agent mode. Plan mode restrictions no longer apply, and every tool is back.';
361
+ return { content: [{ type: 'text' as const, text }], details: { mode: params.target_mode_id, switched: true } };
362
+ },
363
+ });
364
+
190
365
  const handle = (args: string, ctx: ExtensionCommandContext) => {
191
366
  const command = parsePlanArgs(args);
192
367
  if (command.kind === 'build') {
193
368
  const planFile = state.planFile;
194
369
  if (planFile === undefined || !existsSync(planFile)) {
195
- ctx.ui.notify('No saved plan to build. Run /plan, then have the agent save the plan with create_plan. /plan off leaves plan mode.', 'error');
370
+ ctx.ui.notify(`No saved plan to build. Run /plan, then have the agent save the plan with ${PLAN_SAVE}. /plan off leaves plan mode.`, 'error');
196
371
  return undefined;
197
372
  }
198
- const content = readFileSync(planFile, 'utf8').replace(/^<!-- plan: [^>]*-->\n\n/, '');
199
- if (state.enabled) disable();
200
- pi.appendEntry(PLAN_ENTRY, state);
201
- show(ctx);
202
- ctx.ui.notify(`Building ${planFile}.`, 'info');
203
- pi.sendUserMessage(buildRequest(planFile, content), { expandPromptTemplates: true, ...(ctx.isIdle() ? {} : { deliverAs: 'followUp' as const }) });
373
+ build(ctx, planFile);
204
374
  return undefined;
205
375
  }
206
376
  if (command.kind === 'off') {
@@ -215,7 +385,7 @@ export function registerPlanMode(pi: ExtensionAPI): { section: () => string | un
215
385
  };
216
386
 
217
387
  pi.registerCommand('plan', {
218
- description: 'Plan mode: read-only tools, investigate and save a plan. /plan [task] turns it on; /plan build starts the saved plan; /plan off exits; the footer shows plan',
388
+ description: 'Plan mode: read-only research, then a plan to review. /plan [task] turns it on; /plan build starts the saved plan; /plan off exits; the footer shows plan',
219
389
  getArgumentCompletions: (prefix) => ['off', 'build'].filter((name) => name.startsWith(prefix.trim())).map((name) => ({ value: name, label: name })),
220
390
  handler: async (args, ctx) => {
221
391
  handle(args, ctx);
package/src/sections.ts CHANGED
@@ -96,7 +96,9 @@ export function hostSection(facts: HostFacts): string {
96
96
  facts.loopAvailable ? SUBSCRIPTIONS_LINE : NO_SUBSCRIPTIONS_LINE,
97
97
  ...agentToolsLine(facts.activeTools),
98
98
  facts.loopAvailable ? LOOP_LINE : NO_LOOP_LINE,
99
- "- Image generation: codemode's models.generateImages() when codemode is on and an image model is configured. Otherwise say no image tool is available and use a mermaid diagram or a table.",
99
+ facts.activeTools.includes('GenerateImage')
100
+ ? '- Image generation: the GenerateImage tool, which saves each image to the artifacts folder below and shows it in its result.'
101
+ : '- Image generation: no image model is configured, so this session has no GenerateImage tool. Say so and use a mermaid diagram or a table instead.',
100
102
  '- The user keeps a skill on every turn with `/mode <skill>` (exit `/mode off`) and plans without edits with `/plan` (exit `/plan off`).',
101
103
  '- PR operations use the forge command the step gives, such as `gh pr create`. Bugbot comments on a GitHub PR are ordinary review threads; skip a step that starts, waits for, or configures Bugbot when the PR has no Bugbot check or comment.',
102
104
  '- MCP tools are named mcp__<server>__<tool>; `pi mcp list` shows the servers.',
@@ -132,5 +134,8 @@ export function subagentsSection(agents: readonly AgentDefinition[], models: rea
132
134
  '- A Pi model <provider>/<model-id> or <model-id>, optionally with :<level> from the thinking levels the model lists below. A level the model does not list is rejected.',
133
135
  "- Model parameters: <model>[effort=<level>,fast=true|false]. effort is the thinking level, fast=true picks the model's -fast id when one is listed, and [] or fast=false keeps the standard model.",
134
136
  `Available models: ${shown.join(', ') || 'none with configured credentials'}${more}`,
137
+ 'Use inherit unless the user or a skill asks for another model. When the user names a model family without a version, use the newest listed version of that family.',
138
+ 'If the user asks for a model that is not listed, do not substitute another model or guess: skip launching that subagent and tell the user which model was unavailable and which models are available.',
139
+ 'When you tell the user which model a subagent used, name it the way the user named it, not by its model ID, unless the user used the ID.',
135
140
  ].join('\n');
136
141
  }