@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/CHANGELOG.md +28 -0
- package/README.md +14 -14
- package/agents/thermo-nuclear-code-quality-review.md +1 -1
- package/automations/benny/FOR_AGENTS.md +1 -1
- package/automations/benny/skills/reproduce-and-fix-issues/SKILL.md +3 -3
- package/automations/benny/skills/setup-benny/SKILL.md +3 -3
- package/automations/benny/skills/triage-issue-reports/SKILL.md +2 -2
- package/docs/native-gaps.md +1 -1
- package/docs/parity-status.md +55 -11
- package/package.json +4 -3
- package/skills/make-bot-ui/SKILL.md +1 -1
- package/skills/pr-review-canvas/SKILL.md +1 -1
- package/skills/statechart/scripts/test/cli.test.ts +10 -1
- package/skills/teach/SKILL.md +1 -1
- package/src/ask.ts +24 -10
- package/src/child-env.ts +4 -0
- package/src/generate-image.ts +98 -0
- package/src/index.ts +19 -11
- package/src/loop.ts +113 -60
- package/src/mode.ts +84 -46
- package/src/plan.ts +338 -46
- package/src/process-lock.ts +76 -0
- package/src/readonly-shell.ts +6 -8
- package/src/sections.ts +6 -1
- package/src/subscriptions/durable-store.ts +2 -75
- package/src/subscriptions/index.ts +2 -17
- package/src/subscriptions/supervisor.ts +1 -1
- package/src/task/agents.ts +39 -3
- package/src/task/card.ts +4 -2
- package/src/task/format.ts +17 -3
- package/src/task/records.ts +37 -32
- package/src/task/registry.ts +2 -1
- package/src/task/runner.ts +4 -1
- package/src/task/stop.ts +3 -3
- package/src/task/tool.ts +62 -22
- package/src/todo.ts +50 -7
- package/src/path-skills.ts +0 -104
package/src/plan.ts
CHANGED
|
@@ -1,28 +1,47 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
/**
|
|
20
|
-
export const PLAN_SAVE = '
|
|
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
|
|
87
|
-
|
|
88
|
-
'
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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}-${
|
|
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
|
-
/**
|
|
104
|
-
|
|
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
|
|
107
|
-
const path = join(dir, planFileName(
|
|
108
|
-
writeFileSync(path,
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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: '
|
|
139
|
-
description:
|
|
140
|
-
promptSnippet: 'Save the plan to a file',
|
|
141
|
-
parameters:
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
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
|
|
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) =>
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
+
}
|
package/src/readonly-shell.ts
CHANGED
|
@@ -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
|
-
//
|
|
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:
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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
|
-
|
|
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
|
}
|