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

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/plan.ts CHANGED
@@ -1,28 +1,47 @@
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';
4
+ import { createRequire } from 'node:module';
3
5
  import { join, resolve, sep } from 'node:path';
4
6
 
5
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
7
+ import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, stripFrontmatter } from '@earendil-works/pi-coding-agent';
6
8
  import { Type } from 'typebox';
9
+ import type * as YAML from 'yaml';
10
+ import { canAsk } from './modes.ts';
7
11
  import { plansDir } from './paths.ts';
8
12
  import { createReadonlyBashTool, releaseReadonlySandbox } from './readonly-shell.ts';
13
+ import type { TodoItem } from './todo.ts';
9
14
 
10
15
  export const PLAN_ENTRY = 'pstack/plan';
11
16
  const STATUS_KEY = 'pstack-plan';
12
17
 
18
+ // Pi's local package installer does not install dependencies, so missing YAML must not block unrelated tools.
19
+ let yaml: typeof YAML | undefined;
20
+ try {
21
+ yaml = createRequire(import.meta.url)('yaml') as typeof YAML;
22
+ } catch (error) {
23
+ if ((error as NodeJS.ErrnoException).code !== 'MODULE_NOT_FOUND') throw error;
24
+ }
25
+
13
26
  /**
14
27
  * Pi's `bash` cannot be swapped for the sandboxed shell by name without replacing it for the whole session, so plan
15
28
  * mode offers the sandboxed shell under its own name and makes it the only shell while plan mode is on.
16
29
  */
17
30
  export const PLAN_BASH = 'bash_readonly';
18
31
 
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';
32
+ /** Cursor's plan tool: it writes the plan file the user reviews and builds. */
33
+ export const PLAN_SAVE = 'CreatePlan';
34
+
35
+ /** Cursor's tool for moving between agent and plan mode with the user's consent. */
36
+ export const SWITCH_MODE = 'SwitchMode';
21
37
 
22
38
  /** 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;
39
+ 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
40
  const ALLOWED = new Set<string>(PLAN_TOOLS);
25
41
 
42
+ /** Cursor's plan-mode guardrail: only markdown files are edited while planning. */
43
+ const MARKDOWN = /\.(?:md|markdown|mdc)$/i;
44
+
26
45
  export type PlanCommand = { kind: 'off' } | { kind: 'build' } | { kind: 'on'; task: string };
27
46
 
28
47
  export function parsePlanArgs(args: string): PlanCommand {
@@ -74,46 +93,218 @@ export function planFromBranch(entries: readonly BranchEntry[], plansDirectory =
74
93
  }
75
94
 
76
95
  /** Why a call is refused in plan mode, or undefined when it may run. */
77
- export function planBlockReason(toolName: string): string | undefined {
96
+ export function planBlockReason(toolName: string, input: Record<string, unknown> = {}): string | undefined {
97
+ if (toolName === 'write' || toolName === 'edit') {
98
+ const path = input['path'];
99
+ if (typeof path === 'string' && MARKDOWN.test(path)) return undefined;
100
+ 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.`;
101
+ }
78
102
  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
103
  if (toolName === 'bash') return `Plan mode is on: use ${PLAN_BASH}, the read-only sandboxed shell, instead of bash.`;
81
104
  return `Plan mode is on: ${toolName} is not available. Plan mode allows only ${PLAN_TOOLS.join(', ')}.`;
82
105
  }
83
106
 
107
+ /** Cursor's plan-mode instructions, with Pi's tool names. */
84
108
  export function planSection(planFile: string | undefined): string {
85
109
  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.`,
110
+ '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:',
111
+ "1. Answer the user's request by researching it.",
112
+ '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.',
113
+ '3. If the request is too broad, ask questions that narrow the scope of the plan, one or two critical questions at a time.',
114
+ '4. If several valid implementations would each change the plan significantly, ask the user which one to use.',
115
+ '5. Ask your questions right away, at the start. Read first only when five files or fewer will likely answer them.',
116
+ `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.`,
117
+ '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).',
118
+ '8. Keep the plan proportional to the request. Do not over-engineer a simple task.',
119
+ '9. Do not use emojis in the plan.',
120
+ '10. To speed up research, launch parallel explore subagents with Task to cover different parts of the codebase or different angles at once.',
121
+ '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.',
122
+ '12. Ask every question with the AskQuestion tool.',
123
+ `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.`,
124
+ `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.`,
125
+ planFile === undefined
126
+ ? 'The user leaves plan mode with /plan off.'
127
+ : `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
128
  ].join('\n');
92
129
  }
93
130
 
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
131
+ export interface PlanTodo {
132
+ id: string;
133
+ content: string;
134
+ dependencies?: string[];
135
+ }
136
+
137
+ export interface PlanProject {
138
+ is_project?: boolean;
139
+ phases?: { name: string; todos?: PlanTodo[] }[];
140
+ }
141
+
142
+ /** The plan's title as Cursor finds it: the first level-1 heading, else the first line of text. */
143
+ export function planTitle(plan: string): string {
144
+ const heading = /^#\s+(.+)$/m.exec(plan)?.[1];
145
+ if (heading !== undefined) return heading.trim().slice(0, 50);
146
+ const line = plan.split('\n').find((candidate) => candidate.trim() !== '' && !candidate.trim().startsWith('#'));
147
+ return line === undefined ? 'Plan' : line.trim().slice(0, 50);
148
+ }
149
+
150
+ /** A plan file name in Cursor's shape: the sanitized name, its first 50 characters, then 8 hex characters of the chat. */
151
+ export function planFileName(name: string, chatKey: string): string {
152
+ const clean = name
97
153
  .replace(/[^a-zA-Z0-9-_ ]/g, '')
98
154
  .trim()
99
155
  .slice(0, 50);
100
- return `${clean === '' ? 'Plan' : clean}-${id.slice(0, 8)}.plan.md`;
156
+ return `${clean === '' ? 'Plan' : clean}-${chatKey.slice(0, 8)}.plan.md`;
157
+ }
158
+
159
+ const yamlString = (value: string) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')}"`;
160
+
161
+ function planTodoLines(todo: PlanTodo): string[] {
162
+ const lines = [` - id: ${yamlString(todo.id)}`, ` content: ${yamlString(todo.content)}`, ' status: pending'];
163
+ if (todo.dependencies !== undefined && todo.dependencies.length > 0) lines.push(' dependencies:', ...todo.dependencies.map((id) => ` - ${yamlString(id)}`));
164
+ return lines;
165
+ }
166
+
167
+ /** The plan file as Cursor writes it: the chat id in a comment, the todos as front matter, then the plan. */
168
+ export function planFileContent(sessionId: string, plan: string, todos: readonly PlanTodo[], project: PlanProject = {}): string {
169
+ const lines = [`<!-- ${sessionId} -->`];
170
+ if (todos.length > 0 || project.is_project === true) {
171
+ lines.push('---');
172
+ if (todos.length > 0) lines.push('todos:', ...todos.flatMap(planTodoLines));
173
+ lines.push(`isProject: ${project.is_project === true}`);
174
+ if (project.is_project === true && project.phases !== undefined && project.phases.length > 0) {
175
+ lines.push('phases:');
176
+ for (const phase of project.phases) lines.push(` - name: ${yamlString(phase.name)}`, ' todos:', ...(phase.todos ?? []).flatMap((todo) => planTodoLines(todo).map((line) => ` ${line}`)));
177
+ }
178
+ lines.push('---');
179
+ }
180
+ lines.push(plan);
181
+ return lines.join('\n');
101
182
  }
102
183
 
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 {
184
+ /**
185
+ * Writes the plan under the plans folder and returns its path. A chat keeps one file per plan name, as Cursor keys the
186
+ * name by its conversation; Pi's session ids start with a timestamp, so the 8 characters come from a hash of the id.
187
+ */
188
+ export function savePlan(sessionId: string, plan: string, name: string | undefined, todos: readonly PlanTodo[], dir = plansDir(), project: PlanProject = {}): string {
105
189
  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 });
190
+ const chatKey = createHash('sha256').update(sessionId).digest('hex');
191
+ const path = join(dir, planFileName(name === undefined || name.trim() === '' ? planTitle(plan) : name, chatKey));
192
+ writeFileSync(path, planFileContent(sessionId, plan, todos, project), { mode: 0o600 });
109
193
  return path;
110
194
  }
111
195
 
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.' }),
196
+ /** The plan text the build turn carries: the file without Cursor's chat comment and todo front matter. */
197
+ export function planBody(file: string): string {
198
+ return stripFrontmatter(readFileSync(file, 'utf8').replace(/^<!-- [^\n]*-->\r?\n/, '')).trim();
199
+ }
200
+
201
+ function planMetadata(file: string) {
202
+ const source = readFileSync(file, 'utf8');
203
+ const comment = /^<!-- [^\n]*-->\r?\n/.exec(source)?.[0] ?? '';
204
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source.slice(comment.length));
205
+ if (frontmatter === null) return undefined;
206
+ if (yaml === undefined) throw new Error('Plan metadata needs the yaml dependency. Run bun install in the pi-pstack package directory, then reload Pi.');
207
+ const document = yaml.parseDocument(frontmatter[1] ?? '');
208
+ if (document.errors.length > 0) throw new Error(`Invalid plan metadata in ${file}: ${document.errors[0]?.message}`);
209
+ return { source, offset: comment.length + frontmatter[0].indexOf('\n') + 1, document, yaml };
210
+ }
211
+
212
+ export function planTodos(file: string): TodoItem[] {
213
+ const metadata = planMetadata(file);
214
+ if (metadata === undefined) return [];
215
+ const { isSeq, isMap, isScalar } = metadata.yaml;
216
+ const field = metadata.document.get('todos');
217
+ if (field === undefined || field === null) return [];
218
+ if (!isSeq(field)) throw new Error(`Plan todos in ${file} must be a list.`);
219
+ return field.items.map((todo) => {
220
+ if (!isMap(todo)) throw new Error(`Each plan todo in ${file} must have an id, content, and status.`);
221
+ const id = todo.get('id');
222
+ const content = todo.get('content');
223
+ const status = todo.get('status') ?? 'pending';
224
+ const dependencies = todo.get('dependencies');
225
+ if (typeof id !== 'string' || typeof content !== 'string' || !(status === 'pending' || status === 'in_progress' || status === 'completed' || status === 'cancelled')) throw new Error(`Invalid plan todo in ${file}.`);
226
+ if (dependencies !== undefined && (!isSeq(dependencies) || dependencies.items.some((dependency) => !isScalar(dependency) || typeof dependency.value !== 'string'))) throw new Error(`Invalid todo dependencies in ${file}.`);
227
+ return { id, content, status, ...(isSeq(dependencies) ? { dependencies: dependencies.toJSON() as string[] } : {}) };
228
+ });
229
+ }
230
+
231
+ export function syncPlanTodoStatuses(file: string, todos: readonly TodoItem[]): void {
232
+ const metadata = planMetadata(file);
233
+ if (metadata === undefined) return;
234
+ const { source, offset, document, yaml: parser } = metadata;
235
+ const { isSeq, isMap, isScalar } = parser;
236
+ const field = document.get('todos');
237
+ if (!isSeq(field)) return;
238
+ const statuses = new Map(todos.map((todo) => [todo.id, todo.status]));
239
+ const edits: { start: number; end: number; text: string }[] = [];
240
+ for (const todo of field.items) {
241
+ if (!isMap(todo)) continue;
242
+ const status = statuses.get(String(todo.get('id')));
243
+ const node = todo.get('status', true);
244
+ if (status === undefined || (node === undefined && status === 'pending')) continue;
245
+ if (node === undefined && todo.range != null) {
246
+ const start = offset + todo.range[0] + (todo.flow ? 1 : 0);
247
+ const indent = start - source.lastIndexOf('\n', start) - 1;
248
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
249
+ const text = todo.flow ? `status: ${status}, ` : `status: ${status}${newline}${' '.repeat(indent)}`;
250
+ edits.push({ start, end: start, text });
251
+ continue;
252
+ }
253
+ if (!isScalar(node) || node.range == null || node.value === status) continue;
254
+ const value = node.type === 'QUOTE_DOUBLE' ? JSON.stringify(status) : node.type === 'QUOTE_SINGLE' ? `'${status}'` : status;
255
+ edits.push({ start: offset + node.range[0], end: offset + node.range[1], text: value });
256
+ }
257
+ if (edits.length === 0) return;
258
+ let updated = source;
259
+ for (const edit of edits.sort((a, b) => b.start - a.start)) updated = updated.slice(0, edit.start) + edit.text + updated.slice(edit.end);
260
+ writeFileSync(file, updated);
261
+ }
262
+
263
+ const PlanTodoParams = Type.Object({
264
+ id: Type.String({ description: 'Unique identifier for the todo' }),
265
+ content: Type.String({ description: 'Description of the todo task' }),
266
+ dependencies: Type.Optional(Type.Array(Type.String(), { description: 'IDs of todos that must finish before this todo' })),
267
+ });
268
+
269
+ const CreatePlanParams = Type.Object({
270
+ plan: Type.String({ description: "A detailed, concrete plan for accomplishing the user's request" }),
271
+ 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.' })),
272
+ overview: Type.Optional(Type.String({ description: 'A 1-2 sentence high-level description of what the plan accomplishes' })),
273
+ todos: Type.Optional(Type.Array(PlanTodoParams, { description: 'Array of implementation todos' })),
274
+ is_project: Type.Optional(Type.Boolean({ description: 'Whether the plan is a project organized into phases' })),
275
+ phases: Type.Optional(Type.Array(Type.Object({ name: Type.String(), todos: Type.Optional(Type.Array(PlanTodoParams)) }), { description: 'Named project phases and their todos, saved when is_project is true' })),
276
+ });
277
+
278
+ // Cursor's CreatePlan rules: a glanceable markdown plan with a title, todos, no tables, and edits in the file afterwards.
279
+ const CREATE_PLAN_DESCRIPTION = [
280
+ "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.",
281
+ '',
282
+ '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.',
283
+ '',
284
+ '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.',
285
+ '',
286
+ '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.',
287
+ ].join('\n');
288
+
289
+ const SwitchModeParams = Type.Object({
290
+ target_mode_id: Type.Enum(['plan', 'agent'] as const, { type: 'string', description: "The mode to switch to: 'plan' or 'agent'." }),
291
+ explanation: Type.Optional(Type.String({ description: 'Why the switch helps, shown to the user who approves it.' })),
115
292
  });
116
293
 
294
+ // Cursor's SwitchMode rules: switch proactively when the work changes shape, rarely otherwise, and only with consent.
295
+ const SWITCH_MODE_DESCRIPTION = [
296
+ '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.',
297
+ '',
298
+ '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.',
299
+ '',
300
+ '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.',
301
+ ].join('\n');
302
+
303
+ /** The plan review choices Cursor offers after a plan; building in the cloud appears only in a git repository. */
304
+ export const BUILD_LOCALLY = 'Yes, build locally';
305
+ export const BUILD_IN_WORKER = 'Yes, build in cloud';
306
+ export const PROPOSE_CHANGES = 'No, propose changes';
307
+
117
308
  // Pi's read-only built-ins and the sandboxed shell are always on in plan mode. The other plan tools stay on only
118
309
  // if they were on before: pstack turns Task off at the depth limit, and plan mode must not hand it back.
119
310
  const ALWAYS = ['read', 'grep', 'find', 'ls', PLAN_BASH, PLAN_SAVE];
@@ -128,28 +319,48 @@ export function buildRequest(planFile: string, content: string): string {
128
319
  return [`Build the plan saved at ${planFile}. Follow it.`, '', content.trim()].join('\n');
129
320
  }
130
321
 
131
- export function registerPlanMode(pi: ExtensionAPI): { section: () => string | undefined; enabled: () => boolean } {
322
+ const isGitRepository = (cwd: string): boolean => {
323
+ try {
324
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, stdio: 'ignore' });
325
+ return true;
326
+ } catch {
327
+ return false;
328
+ }
329
+ };
330
+
331
+ export interface PlanModeOptions {
332
+ buildPlan?: string;
333
+ setTodos?: (todos: TodoItem[], ctx: ExtensionContext) => void;
334
+ /** Starts the build in an isolated worktree worker, Pi's counterpart to Cursor's "build in cloud". Returns the agent ID. */
335
+ buildInWorker?: (planFile: string, request: string) => Promise<string>;
336
+ }
337
+
338
+ export function registerPlanMode(pi: ExtensionAPI, options: PlanModeOptions = {}) {
132
339
  let state: PlanState = { enabled: false };
340
+ let buildPlan = options.buildPlan;
341
+ // A plan saved during the current run, which the user reviews once the run settles.
342
+ let review: string | undefined;
133
343
 
134
344
  // Inactive until plan mode turns it on.
135
345
  pi.registerTool({ ...createReadonlyBashTool(PLAN_BASH), defaultActive: false });
136
346
  pi.registerTool({
137
347
  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,
348
+ label: 'Create plan',
349
+ description: CREATE_PLAN_DESCRIPTION,
350
+ promptSnippet: 'Save the plan to a file the user reviews and builds',
351
+ parameters: CreatePlanParams,
142
352
  exposure: 'model-only',
143
353
  executionMode: 'sequential',
144
354
  defaultActive: false,
145
355
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
146
- async execute(_id, params) {
356
+ async execute(_id, params, _signal, _onUpdate, ctx) {
147
357
  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);
358
+ const planFile = savePlan(ctx.sessionManager.getSessionId(), params.plan, params.name, params.todos ?? [], undefined, params);
150
359
  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 } };
360
+ review = planFile;
361
+ pi.appendEntry(PLAN_ENTRY, state);
362
+ const overview = params.overview === undefined || params.overview.trim() === '' ? '' : `\n${params.overview.trim()}`;
363
+ 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
364
  },
154
365
  });
155
366
  pi.on('session_shutdown', releaseReadonlySandbox);
@@ -169,13 +380,29 @@ export function registerPlanMode(pi: ExtensionAPI): { section: () => string | un
169
380
  };
170
381
 
171
382
  const restore = (ctx: ExtensionContext) => {
172
- const stored = planFromBranch(ctx.sessionManager.getBranch());
383
+ const branch = ctx.sessionManager.getBranch();
384
+ const stored = planFromBranch(branch);
385
+ // Pi replays tools from system messages before session_tree. A branch without one has no loadout to restore.
386
+ if (!stored.enabled && state.enabled && !branch.some((entry) => entry.type === 'message' && entry.message.role === 'system')) disable();
173
387
  state = { ...stored };
388
+ review = undefined;
174
389
  if (stored.enabled) enable(stored.toolsBefore ?? state.toolsBefore ?? pi.getActiveTools());
175
390
  else if (pi.getActiveTools().some((name) => name === PLAN_BASH || name === PLAN_SAVE)) pi.setActiveTools(pi.getActiveTools().filter((name) => name !== PLAN_BASH && name !== PLAN_SAVE));
391
+ // SwitchMode needs someone to approve the switch, so a run nobody watches does not get it.
392
+ if (!canAsk(ctx) && pi.getActiveTools().includes(SWITCH_MODE)) pi.setActiveTools(pi.getActiveTools().filter((name) => name !== SWITCH_MODE));
176
393
  show(ctx);
177
394
  };
178
- pi.on('session_start', (_event, ctx) => restore(ctx));
395
+ pi.on('session_start', (_event, ctx) => {
396
+ restore(ctx);
397
+ const initialPlan = buildPlan;
398
+ buildPlan = undefined;
399
+ if (initialPlan === undefined) return;
400
+ const planFile = storedPlanFile(initialPlan, plansDir());
401
+ if (planFile === undefined) throw new Error('The plan build must use a file inside the pi-pstack plans directory.');
402
+ prepareBuild(ctx, planFile);
403
+ state = { enabled: false, planFile };
404
+ pi.appendEntry(PLAN_ENTRY, state);
405
+ });
179
406
  pi.on('session_tree', (_event, ctx) => restore(ctx));
180
407
 
181
408
  // Active tools decide what the model is offered; this decides what may run, including names the model
@@ -183,24 +410,83 @@ export function registerPlanMode(pi: ExtensionAPI): { section: () => string | un
183
410
  // so the restriction never reaches an agent's record.
184
411
  pi.on('tool_call', (event) => {
185
412
  if (!state.enabled) return undefined;
186
- const reason = planBlockReason(event.toolName);
413
+ const reason = planBlockReason(event.toolName, event.input as Record<string, unknown>);
187
414
  return reason === undefined ? undefined : { block: true, reason };
188
415
  });
189
416
 
417
+ const prepareBuild = (ctx: ExtensionContext, planFile: string) => {
418
+ const todos = planTodos(planFile).filter((todo) => todo.status !== 'completed' && todo.status !== 'cancelled');
419
+ options.setTodos?.(todos, ctx);
420
+ };
421
+
422
+ const build = (ctx: ExtensionContext, planFile: string) => {
423
+ prepareBuild(ctx, planFile);
424
+ if (state.enabled) disable();
425
+ pi.appendEntry(PLAN_ENTRY, state);
426
+ show(ctx);
427
+ ctx.ui.notify(`Building ${planFile}.`, 'info');
428
+ pi.sendUserMessage(buildRequest(planFile, planBody(planFile)), { expandPromptTemplates: true, ...(ctx.isIdle() ? {} : { deliverAs: 'followUp' as const }) });
429
+ };
430
+
431
+ const reviewPlan = async (ctx: ExtensionContext, planFile: string) => {
432
+ const worker = options.buildInWorker !== undefined && isGitRepository(ctx.cwd);
433
+ const choice = await ctx.ui.select(`Plan saved at ${planFile}. Build it?`, worker ? [BUILD_LOCALLY, BUILD_IN_WORKER, PROPOSE_CHANGES] : [BUILD_LOCALLY, PROPOSE_CHANGES]);
434
+ if (!state.enabled || state.planFile !== planFile) return;
435
+ if (choice === BUILD_LOCALLY) build(ctx, planFile);
436
+ else if (choice === BUILD_IN_WORKER && options.buildInWorker !== undefined) {
437
+ prepareBuild(ctx, planFile);
438
+ disable();
439
+ pi.appendEntry(PLAN_ENTRY, state);
440
+ show(ctx);
441
+ const agentId = await options.buildInWorker(planFile, buildRequest(planFile, planBody(planFile)));
442
+ ctx.ui.notify(`Building ${planFile} in an isolated worker (agent ${agentId}). Its result arrives as a Task notification.`, 'info');
443
+ }
444
+ };
445
+
446
+ // After a run that saved a plan, the user picks what happens next, as Cursor's plan card asks. The dialog runs on
447
+ // its own so that other settle handlers, such as a loop's next tick, do not wait for the user's answer.
448
+ pi.on('agent_settled', (event, ctx) => {
449
+ const planFile = review;
450
+ review = undefined;
451
+ if (event.aborted || planFile === undefined || !state.enabled || !canAsk(ctx) || !existsSync(planFile)) return;
452
+ reviewPlan(ctx, planFile).catch((error: Error) => ctx.ui.notify(`Could not start the plan build: ${error.message}`, 'error'));
453
+ });
454
+
455
+ pi.registerTool({
456
+ name: SWITCH_MODE,
457
+ label: 'Switch mode',
458
+ description: SWITCH_MODE_DESCRIPTION,
459
+ promptSnippet: 'Switch between agent mode and plan mode, with the user approving',
460
+ parameters: SwitchModeParams,
461
+ exposure: 'model-only',
462
+ executionMode: 'sequential',
463
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
464
+ async execute(_id, params, _signal, _onUpdate, ctx) {
465
+ const current = state.enabled ? 'plan' : 'agent';
466
+ if (params.target_mode_id === current) return { content: [{ type: 'text' as const, text: `Already in ${current} mode.` }], details: { mode: current, switched: false } };
467
+ if (!canAsk(ctx)) throw new Error('A mode switch needs the user to approve it, and nobody can approve one in this run.');
468
+ const label = params.target_mode_id === 'plan' ? 'Plan' : 'Agent';
469
+ const approved = await ctx.ui.confirm(`Switch to ${label} mode?`, params.explanation?.trim() ?? '');
470
+ 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 } };
471
+ if (params.target_mode_id === 'plan') enable(pi.getActiveTools());
472
+ else disable();
473
+ pi.appendEntry(PLAN_ENTRY, state);
474
+ show(ctx);
475
+ const text =
476
+ 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.';
477
+ return { content: [{ type: 'text' as const, text }], details: { mode: params.target_mode_id, switched: true } };
478
+ },
479
+ });
480
+
190
481
  const handle = (args: string, ctx: ExtensionCommandContext) => {
191
482
  const command = parsePlanArgs(args);
192
483
  if (command.kind === 'build') {
193
484
  const planFile = state.planFile;
194
485
  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');
486
+ 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
487
  return undefined;
197
488
  }
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 }) });
489
+ build(ctx, planFile);
204
490
  return undefined;
205
491
  }
206
492
  if (command.kind === 'off') {
@@ -215,12 +501,18 @@ export function registerPlanMode(pi: ExtensionAPI): { section: () => string | un
215
501
  };
216
502
 
217
503
  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',
504
+ 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
505
  getArgumentCompletions: (prefix) => ['off', 'build'].filter((name) => name.startsWith(prefix.trim())).map((name) => ({ value: name, label: name })),
220
506
  handler: async (args, ctx) => {
221
507
  handle(args, ctx);
222
508
  },
223
509
  });
224
510
 
225
- return { section: () => (state.enabled ? planSection(state.planFile) : undefined), enabled: () => state.enabled };
511
+ return {
512
+ section: () => (state.enabled ? planSection(state.planFile) : undefined),
513
+ enabled: () => state.enabled,
514
+ syncTodos: (todos: readonly TodoItem[]) => {
515
+ if (state.planFile !== undefined && existsSync(state.planFile)) syncPlanTodoStatuses(state.planFile, todos);
516
+ },
517
+ };
226
518
  }
@@ -0,0 +1,76 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ function zombie(pid: number): boolean {
6
+ if (process.platform !== 'linux') return false;
7
+ try {
8
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
9
+ const close = stat.lastIndexOf(')');
10
+ return close !== -1 && stat.slice(close + 2, close + 3) === 'Z';
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ export function alive(pid: number): boolean {
17
+ if (!Number.isInteger(pid) || pid <= 0) return false;
18
+ try {
19
+ process.kill(pid, 0);
20
+ return !zombie(pid);
21
+ } catch (error) {
22
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
23
+ }
24
+ }
25
+
26
+ export type Lease = { token: string; release: () => void };
27
+
28
+ /** A holder token keeps a stale reaper from unlinking a replacement owner's lease. */
29
+ export function acquireLease(path: string): Lease | undefined {
30
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
31
+ const token = `${process.pid}-${randomUUID()}`;
32
+ const temporary = `${path}.${token}.tmp`;
33
+ mkdirSync(temporary, { mode: 0o700 });
34
+ try {
35
+ writeFileSync(join(temporary, token), '', { mode: 0o600 });
36
+ for (let attempt = 0; attempt < 4; attempt += 1) {
37
+ try {
38
+ renameSync(temporary, path);
39
+ return {
40
+ token,
41
+ release: () => {
42
+ rmSync(join(path, token), { force: true });
43
+ try {
44
+ rmdirSync(path);
45
+ } catch (error) {
46
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
47
+ }
48
+ },
49
+ };
50
+ } catch (error) {
51
+ if (!['ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
52
+ }
53
+ let holders: string[];
54
+ try {
55
+ holders = readdirSync(path);
56
+ } catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
58
+ throw error;
59
+ }
60
+ for (const holder of holders) {
61
+ const pid = Number(/^(\d+)-[a-f0-9-]+$/.exec(holder)?.[1]);
62
+ if (!Number.isInteger(pid)) throw new Error(`Invalid process lock ${path}. Retain it for diagnosis.`);
63
+ if (alive(pid)) return undefined;
64
+ rmSync(join(path, holder), { force: true });
65
+ }
66
+ try {
67
+ rmdirSync(path);
68
+ } catch (error) {
69
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
70
+ }
71
+ }
72
+ return undefined;
73
+ } finally {
74
+ rmSync(temporary, { recursive: true, force: true });
75
+ }
76
+ }
@@ -8,11 +8,9 @@ import { type BashSpawnContext, createBashTool, type ExtensionAPI, type Extensio
8
8
  // denyWrite names the paths the sandbox makes writable for itself (/tmp/claude is a dependency default), and
9
9
  // denyRead drops the dependency's writable home defaults (~/.npm/_logs, ~/.claude/debug). Seatbelt is
10
10
  // last-match-wins with deny emitted after allow; on Linux bubblewrap binds deny last the same way.
11
- // allowAllUnixSockets skips apply-seccomp: on GitHub-hosted runners (and other nested-userns hosts) that
12
- // helper fails writing /proc/self/setgroups even with setuid bwrap. Write and network denial still come
13
- // from bubblewrap / sandbox-exec; only AF_UNIX blocking is dropped.
11
+ // Unix sockets must stay blocked too: a host service can mutate files on a command's behalf.
14
12
  const READ_ONLY_POLICY: SandboxRuntimeConfig = {
15
- network: { allowedDomains: [], deniedDomains: [], allowAllUnixSockets: true },
13
+ network: { allowedDomains: [], deniedDomains: [], allowAllUnixSockets: false },
16
14
  filesystem: {
17
15
  denyRead: ['~/.npm/_logs', '~/.claude/debug'],
18
16
  allowWrite: [],
@@ -40,10 +38,10 @@ function sandboxReady(): Promise<Sandbox> {
40
38
  try {
41
39
  const { SandboxManager } = await import('@anthropic-ai/sandbox-runtime');
42
40
  if (!SandboxManager.isSupportedPlatform()) throw new Error(`${process.platform} is not supported`);
43
- // initialize verifies the sandbox dependencies (bubblewrap, socat, sandbox-exec) before it starts.
44
- // A second initialize is a no-op once another consumer has started the manager, and several
45
- // Linux flags (notably allowAllUnixSockets) are read from the manager config, not the per-call
46
- // wrap override — so updateConfig installs our policy even when we lost the initialize race.
41
+ const socketWarning = SandboxManager.checkDependencies().warnings.find((warning) => warning.includes('unix socket access not restricted'));
42
+ if (socketWarning !== undefined) throw new Error(socketWarning);
43
+ // initialize is a no-op if another consumer started the manager. Linux socket isolation also
44
+ // reads the shared config, so both it and the per-command override must carry this policy.
47
45
  await SandboxManager.initialize(READ_ONLY_POLICY);
48
46
  SandboxManager.updateConfig(READ_ONLY_POLICY);
49
47
  return SandboxManager;
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
  }