@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/CHANGELOG.md +15 -0
- package/README.md +10 -10
- package/agents/thermo-nuclear-code-quality-review.md +1 -1
- package/docs/native-gaps.md +1 -1
- package/docs/parity-status.md +26 -11
- package/package.json +2 -2
- package/skills/make-bot-ui/SKILL.md +1 -1
- package/skills/teach/SKILL.md +1 -1
- package/src/ask.ts +24 -10
- package/src/generate-image.ts +98 -0
- package/src/index.ts +14 -10
- package/src/loop.ts +21 -13
- package/src/mode.ts +84 -46
- package/src/plan.ts +212 -42
- package/src/sections.ts +6 -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/registry.ts +2 -1
- package/src/task/runner.ts +4 -1
- package/src/task/stop.ts +3 -3
- package/src/task/tool.ts +52 -20
- package/src/todo.ts +21 -1
- package/src/path-skills.ts +0 -104
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
|
-
*
|
|
36
|
-
*
|
|
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[]
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (entry
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
|
55
|
-
if (mode.path === undefined
|
|
56
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
20
|
-
export const PLAN_SAVE = '
|
|
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
|
|
87
|
-
|
|
88
|
-
'
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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}-${
|
|
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
|
-
/**
|
|
104
|
-
|
|
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
|
|
107
|
-
const path = join(dir, planFileName(
|
|
108
|
-
writeFileSync(path,
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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: '
|
|
139
|
-
description:
|
|
140
|
-
promptSnippet: 'Save the plan to a file',
|
|
141
|
-
parameters:
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|