@narumitw/pi-plan-mode 0.14.1 → 0.15.1
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/README.md +25 -31
- package/package.json +2 -2
- package/src/completion-tool.ts +70 -0
- package/src/message-transform.ts +43 -3
- package/src/plan-mode.ts +215 -102
- package/src/prompt.ts +16 -15
- package/src/settings.ts +138 -13
- package/src/state.ts +128 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@narumitw/pi-plan-mode) [](https://pi.dev) [](./LICENSE)
|
|
4
4
|
|
|
5
|
-
`@narumitw/pi-plan-mode` adds a Codex-like `/plan` collaboration mode to Pi. Plan mode is for read-only exploration, clarifying questions, and a
|
|
5
|
+
`@narumitw/pi-plan-mode` adds a Codex-like `/plan` collaboration mode to Pi. Plan mode is for read-only exploration, clarifying questions, and a structured implementation-ready plan before any code mutation happens.
|
|
6
6
|
|
|
7
7
|
Pi core intentionally does not ship a built-in plan mode; this package provides one as an independently installable extension.
|
|
8
8
|
|
|
@@ -13,14 +13,17 @@ Pi core intentionally does not ship a built-in plan mode; this package provides
|
|
|
13
13
|
- Enables built-in read-only tools by default while Plan mode is active.
|
|
14
14
|
- Disables extension and custom tools by default, with a `/plan tools` selector for explicit user-risk opt-in.
|
|
15
15
|
- Blocks `update_plan`, mutating built-in tools, and unsafe shell forms such as writes, substitutions, background jobs, dependency installs, and mutating Git commands.
|
|
16
|
-
- Injects Codex-like Plan mode instructions: explore first, ask decision questions for high-impact ambiguity, do not mutate files, and
|
|
17
|
-
- Adds
|
|
18
|
-
-
|
|
16
|
+
- Injects Codex-like Plan mode instructions: explore first, ask decision questions for high-impact ambiguity, do not mutate files, and finalize only when decision-complete.
|
|
17
|
+
- Adds required `plan_mode_question` and `plan_mode_complete` tools for structured questions and completion.
|
|
18
|
+
- Presents the complete plan and prompts you to implement, stay in Plan mode, or exit and discard it.
|
|
19
|
+
- Keeps legacy `<proposed_plan>` responses compatible without advertising XML as the primary workflow.
|
|
19
20
|
- Shows Plan mode state in Pi's statusline as `plan active` or `plan ready`; `@narumitw/pi-statusline` adds the default `📝` icon unless configured otherwise.
|
|
20
21
|
- Persists Plan mode state in the Pi session so resume restores the mode.
|
|
21
22
|
|
|
22
23
|
## 📦 Install
|
|
23
24
|
|
|
25
|
+
This release requires Pi 0.80.6 or newer.
|
|
26
|
+
|
|
24
27
|
```bash
|
|
25
28
|
pi install npm:@narumitw/pi-plan-mode
|
|
26
29
|
```
|
|
@@ -43,13 +46,17 @@ pi -e ./extensions/pi-plan-mode
|
|
|
43
46
|
/plan
|
|
44
47
|
/plan <prompt>
|
|
45
48
|
/plan tools
|
|
49
|
+
/plan show
|
|
50
|
+
/plan finalize
|
|
51
|
+
/plan implement
|
|
52
|
+
/plan exit
|
|
46
53
|
```
|
|
47
54
|
|
|
48
|
-
Use `/plan` to enter Plan mode before writing your planning prompt. Use `/plan <prompt>` to enter Plan mode and immediately submit `<prompt>` as the first Plan-mode user message. Use `/plan tools` to choose which tools are active while Plan mode is enabled; the selector is paginated at 10 tools per page.
|
|
55
|
+
Use `/plan` to enter Plan mode before writing your planning prompt. Use `/plan <prompt>` to enter Plan mode and immediately submit `<prompt>` as the first Plan-mode user message. Use `/plan tools` to choose which tools are active while Plan mode is enabled; the selector is paginated at 10 tools per page. `/plan show` displays the stored plan without starting a model turn, `/plan finalize` explicitly asks the agent to complete the plan or ask one remaining material question, and `/plan implement` hands a stored plan to a normal implementation turn. `show` and `implement` fail closed when no plan is stored; `finalize` requires active Plan mode.
|
|
49
56
|
|
|
50
57
|
When Plan mode is active, ask the agent to design the change. The agent may inspect files and run read-only commands, but it should not edit files or execute the implementation. It should explore first, then use structured questions when your preference or a tradeoff materially changes the plan.
|
|
51
58
|
|
|
52
|
-
By default, Plan mode manages only Pi's built-in tools: `read`, limited `bash`, available read-only built-ins such as `grep`, `find`, and `ls`, plus the required `plan_mode_question`
|
|
59
|
+
By default, Plan mode manages only Pi's built-in tools: `read`, limited `bash`, available read-only built-ins such as `grep`, `find`, and `ls`, plus the required `plan_mode_question` and `plan_mode_complete` tools. Built-in `edit` and `write` are blocked. `update_plan` is also blocked because it tracks execution progress rather than conversational planning. Extension and custom tools are disabled by default because Pi tools do not expose standardized mutability metadata; enable them from `/plan tools` only when you accept the risk for that session. For example, you can opt into `firecrawl_scrape`, `firecrawl_search`, or `lsp_diagnostics` if those extensions are loaded and you want to use them during planning.
|
|
53
60
|
|
|
54
61
|
Limited `bash` uses a fail-closed policy. It accepts common inspection commands, read-only Git and npm queries, pipelines and command lists composed entirely of accepted commands, plus selected checks such as `npm test`, `npm run typecheck`, and `cargo test`. It rejects output/input redirects, command substitution, subshells, background jobs, mutating flags, dependency changes, editors, and unknown commands. Tests and builds may still write ignored caches or build artifacts and may execute project-defined hooks; enable or invoke them only when the repository is trusted. This is extension-level risk reduction, not an OS sandbox.
|
|
55
62
|
|
|
@@ -57,34 +64,18 @@ Limited `bash` uses a fail-closed policy. It accepts common inspection commands,
|
|
|
57
64
|
|
|
58
65
|
Pi activates tools by tool name. The `/plan tools` selector stores selections by name and shows each currently effective tool's source from Pi metadata, such as `built-in`, a user extension path, or a project extension path. If an extension overrides a built-in tool with the same name, Pi exposes the effective tool for that name and the selector shows that source.
|
|
59
66
|
|
|
60
|
-
A complete Plan mode answer should appear only after the agent has resolved discoverable facts and
|
|
61
|
-
|
|
62
|
-
```xml
|
|
63
|
-
<proposed_plan>
|
|
64
|
-
# Title
|
|
65
|
-
|
|
66
|
-
## Summary
|
|
67
|
-
...
|
|
67
|
+
A complete Plan mode answer should appear only after the agent has resolved discoverable facts and high-impact user decisions. The agent must call `plan_mode_complete({ plan })` alone as its final action, passing the complete Markdown plan. The tool rejects empty or whitespace-only plans and plans longer than 50,000 JavaScript characters; it does not truncate. Its visible result contains the full plan, and versioned result details let the extension restore it safely from the active session branch.
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
...
|
|
69
|
+
`plan_mode_complete` uses Pi's `terminate: true` hint. Termination is best effort: if a model puts it in a parallel tool batch, Pi terminates the batch early only when every finalized sibling tool also terminates. The prompt therefore requires the completion call to be standalone and last. The extension deliberately does not infer completion from phrases such as “I will present the plan,” and it does not automatically retry a turn with no plan because research and clarification turns may legitimately remain unfinished. If a turn ends without a plan, Plan mode stays active; use `/plan finalize` for explicit recovery.
|
|
71
70
|
|
|
72
|
-
|
|
73
|
-
...
|
|
71
|
+
Legacy sessions and models may still submit one non-empty `<proposed_plan>` block with tags on their own lines. That compatibility path remains accepted, but it is not the primary workflow. Empty, malformed, unclosed, or multiple legacy blocks keep Plan mode active and produce a warning.
|
|
74
72
|
|
|
75
|
-
|
|
76
|
-
...
|
|
77
|
-
</proposed_plan>
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
The tags must be on their own lines and the block must be non-empty, complete, and unique within the response. Empty, malformed, unclosed, or multiple blocks keep Plan mode active and produce a warning so the plan can be corrected.
|
|
81
|
-
|
|
82
|
-
After a valid proposed plan is detected, `/plan` lets you choose whether to implement the plan, stay in Plan mode, or exit Plan mode. Choosing implementation disables Plan mode, restores full tool access, and immediately starts an implementation turn with the proposed plan. Choosing Stay keeps the plan ready while you decide what to do next; to revise the plan, choose Stay and type your revision feedback in the normal prompt. When that next Plan-mode turn starts, the previous plan is no longer treated as the latest implementable plan unless the agent produces an updated `<proposed_plan>`. Choosing exit/off disables Plan mode and discards the proposed plan so it is not carried into later non-plan turns.
|
|
73
|
+
After completion, `/plan` opens the ready actions when interactive UI is available. Choosing implementation—or running `/plan implement`—disables Plan mode, restores full tool access, and starts an implementation turn with the stored plan. Choosing Stay keeps the plan ready. Revision feedback starts another Plan-mode turn and clears the previous implementable plan until an updated completion arrives. For clarification-only follow-ups, the agent answers and resubmits the complete unchanged plan so it remains implementable. Exit/off discards the plan and removes its completion result from later non-Plan model context. Without interactive UI, the plan remains visible in the tool result and stored as `plan ready`; use `/plan show`, `/plan implement`, or `/plan exit` directly.
|
|
83
74
|
|
|
84
75
|
While Plan mode is enabled, the extension also publishes a compact status for Pi statuslines. With `@narumitw/pi-statusline`, this appears in the extension status area:
|
|
85
76
|
|
|
86
77
|
- `plan active`: Plan mode is enabled and still gathering context or drafting a plan.
|
|
87
|
-
- `plan ready`: A
|
|
78
|
+
- `plan ready`: A completed plan is stored until you implement it, continue planning, or exit Plan mode.
|
|
88
79
|
|
|
89
80
|
You can also exit directly. Direct exit discards the latest proposed plan instead of treating it as an implementation request:
|
|
90
81
|
|
|
@@ -94,7 +85,7 @@ You can also exit directly. Direct exit discards the latest proposed plan instea
|
|
|
94
85
|
|
|
95
86
|
## ⚙️ Thinking level
|
|
96
87
|
|
|
97
|
-
Plan mode inherits Pi's current thinking level by default. To request a fixed level only while Plan mode is active, create `$PI_CODING_AGENT_DIR/plan-mode.json` (normally `~/.pi/agent/plan-mode.json`):
|
|
88
|
+
Plan mode inherits Pi's current thinking level by default. To request a fixed level only while Plan mode is active, create `$PI_CODING_AGENT_DIR/pi-plan-mode.json` (normally `~/.pi/agent/pi-plan-mode.json`):
|
|
98
89
|
|
|
99
90
|
```json
|
|
100
91
|
{
|
|
@@ -102,7 +93,9 @@ Plan mode inherits Pi's current thinking level by default. To request a fixed le
|
|
|
102
93
|
}
|
|
103
94
|
```
|
|
104
95
|
|
|
105
|
-
Supported values are `inherit`, `off`, `minimal`, `low`, `medium`, `high`, and `
|
|
96
|
+
Supported values are `inherit`, `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. The extension snapshots the prior level and restores it on exit only if the level still matches the value it applied; a manual change made during Plan mode is preserved. The settings file is optional, is read at session start, and never changes Pi's default setting. Invalid settings produce a warning and fall back to `inherit`.
|
|
97
|
+
|
|
98
|
+
Compatibility: a valid legacy `plan-mode.json` is migrated automatically to `pi-plan-mode.json`. If both files exist, the new filename takes precedence.
|
|
106
99
|
|
|
107
100
|
## 🧠 Codex-like behavior
|
|
108
101
|
|
|
@@ -111,10 +104,11 @@ This extension maps Codex's `ModeKind::Plan` behavior onto Pi's extension API:
|
|
|
111
104
|
- Plan mode is a conversational collaboration mode, not TODO/progress tracking.
|
|
112
105
|
- `/plan <prompt>` follows Codex behavior by switching to Plan mode before submitting the inline prompt.
|
|
113
106
|
- The agent should use `plan_mode_question` for important non-discoverable preferences or tradeoffs before finalizing.
|
|
107
|
+
- The agent completes with a standalone `plan_mode_complete` tool call instead of relying on semantic prose detection.
|
|
114
108
|
- `update_plan` checklist use is blocked while Plan mode is active.
|
|
115
|
-
- The implementation boundary is explicit: Plan mode restores tools before starting implementation, choosing implementation immediately triggers a normal agent turn with full tool access, and plain exit/off discards the
|
|
109
|
+
- The implementation boundary is explicit: Plan mode restores tools before starting implementation, choosing implementation immediately triggers a normal agent turn with full tool access, and plain exit/off discards the stored plan.
|
|
116
110
|
- Pi extension safety is approximated with tool classification and fail-closed bash filtering; non-built-in tools remain user-selected at user risk because Pi does not expose standardized tool mutability metadata.
|
|
117
|
-
- Unlike native Codex,
|
|
111
|
+
- Unlike native Codex, this extension uses a terminating Pi tool plus an `agent_settled` ready flow; Pi cannot provide sandbox-level enforcement.
|
|
118
112
|
|
|
119
113
|
## 🗂️ Package layout
|
|
120
114
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-plan-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "Pi extension that adds a Codex-like read-only /plan collaboration mode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@biomejs/biome": "2.5.3",
|
|
32
|
-
"@earendil-works/pi-coding-agent": "0.80.
|
|
32
|
+
"@earendil-works/pi-coding-agent": "0.80.6",
|
|
33
33
|
"typescript": "6.0.3"
|
|
34
34
|
},
|
|
35
35
|
"repository": {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const PLAN_MODE_COMPLETE_TOOL_NAME = "plan_mode_complete";
|
|
2
|
+
export const PLAN_MODE_COMPLETE_VERSION = 1;
|
|
3
|
+
export const PLAN_MODE_MAX_CHARS = 50_000;
|
|
4
|
+
|
|
5
|
+
export type PlanModeCompletionDetails = {
|
|
6
|
+
version: typeof PLAN_MODE_COMPLETE_VERSION;
|
|
7
|
+
source: typeof PLAN_MODE_COMPLETE_TOOL_NAME;
|
|
8
|
+
plan: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const PLAN_MODE_COMPLETE_PARAMS = {
|
|
12
|
+
type: "object",
|
|
13
|
+
additionalProperties: false,
|
|
14
|
+
required: ["plan"],
|
|
15
|
+
properties: {
|
|
16
|
+
plan: {
|
|
17
|
+
type: "string",
|
|
18
|
+
minLength: 1,
|
|
19
|
+
maxLength: PLAN_MODE_MAX_CHARS,
|
|
20
|
+
description: "The complete decision-ready implementation plan in Markdown.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
type NormalizePlanModeCompletionResult =
|
|
26
|
+
| { ok: true; plan: string }
|
|
27
|
+
| { ok: false; error: string };
|
|
28
|
+
|
|
29
|
+
export function normalizePlanModeCompletion(input: unknown): NormalizePlanModeCompletionResult {
|
|
30
|
+
if (!isRecord(input) || typeof input.plan !== "string") {
|
|
31
|
+
return { ok: false, error: "plan must be a string" };
|
|
32
|
+
}
|
|
33
|
+
const plan = input.plan.trim();
|
|
34
|
+
if (!plan) return { ok: false, error: "plan must not be empty" };
|
|
35
|
+
if (plan.length > PLAN_MODE_MAX_CHARS) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
error: `plan must not exceed ${PLAN_MODE_MAX_CHARS} characters`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return { ok: true, plan };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function planFromCompletionDetails(value: unknown) {
|
|
45
|
+
if (!isRecord(value)) return undefined;
|
|
46
|
+
if (
|
|
47
|
+
value.version !== PLAN_MODE_COMPLETE_VERSION ||
|
|
48
|
+
value.source !== PLAN_MODE_COMPLETE_TOOL_NAME
|
|
49
|
+
) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const normalized = normalizePlanModeCompletion({ plan: value.plan });
|
|
53
|
+
return normalized.ok ? normalized.plan : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function planModeCompleted(plan: string) {
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: "text" as const, text: `**Proposed Plan**\n\n${plan}` }],
|
|
59
|
+
details: {
|
|
60
|
+
version: PLAN_MODE_COMPLETE_VERSION,
|
|
61
|
+
source: PLAN_MODE_COMPLETE_TOOL_NAME,
|
|
62
|
+
plan,
|
|
63
|
+
} satisfies PlanModeCompletionDetails,
|
|
64
|
+
terminate: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
69
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
70
|
+
}
|
package/src/message-transform.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { PLAN_MODE_COMPLETE_TOOL_NAME } from "./completion-tool.js";
|
|
2
|
+
|
|
1
3
|
const PLAN_CONTEXT_MESSAGE_TYPE = "plan-mode-context";
|
|
2
4
|
const PROPOSED_PLAN_MESSAGE_TYPE = "proposed-plan";
|
|
3
5
|
const PROPOSED_PLAN_PATTERN = /^<proposed_plan>[\t ]*\r?\n([\s\S]*?)\r?\n<\/proposed_plan>[\t ]*$/gm;
|
|
@@ -56,14 +58,47 @@ export function messageContainsLegacyPlanModeContextArtifact(message: unknown) {
|
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
export function messageContainsInactivePlanModeArtifact(message: unknown) {
|
|
59
|
-
|
|
61
|
+
const candidate = unwrapSessionMessage(message);
|
|
62
|
+
return (
|
|
63
|
+
candidate.customType === PROPOSED_PLAN_MESSAGE_TYPE ||
|
|
64
|
+
(candidate.role === "toolResult" && candidate.toolName === PLAN_MODE_COMPLETE_TOOL_NAME)
|
|
65
|
+
);
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
export function stripProposedPlanBlocksFromMessage<T>(message: T): T {
|
|
69
|
+
return replaceAssistantContent(message, stripProposedPlanBlocksFromContent);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function stripPlanModeCompletionCallsFromMessage<T>(message: T): T {
|
|
73
|
+
return replaceAssistantContent(message, (content) => {
|
|
74
|
+
if (!Array.isArray(content)) return content;
|
|
75
|
+
const nextContent = content.filter((block) => {
|
|
76
|
+
const candidate = block as { type?: string; name?: string };
|
|
77
|
+
return !(
|
|
78
|
+
candidate.type === "toolCall" && candidate.name === PLAN_MODE_COMPLETE_TOOL_NAME
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
return nextContent.length === content.length ? content : nextContent;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function isEmptyAssistantMessage(message: unknown) {
|
|
86
|
+
const candidate = unwrapSessionMessage(message);
|
|
87
|
+
return (
|
|
88
|
+
candidate.role === "assistant" &&
|
|
89
|
+
Array.isArray(candidate.content) &&
|
|
90
|
+
candidate.content.length === 0
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function replaceAssistantContent<T>(
|
|
95
|
+
message: T,
|
|
96
|
+
transform: (content: unknown) => unknown,
|
|
97
|
+
): T {
|
|
63
98
|
const candidate = unwrapSessionMessage(message);
|
|
64
99
|
if (candidate.role !== "assistant") return message;
|
|
65
100
|
|
|
66
|
-
const content =
|
|
101
|
+
const content = transform(candidate.content);
|
|
67
102
|
if (content === candidate.content) return message;
|
|
68
103
|
|
|
69
104
|
if (isSessionMessageEntry(message)) {
|
|
@@ -74,7 +109,12 @@ export function stripProposedPlanBlocksFromMessage<T>(message: T): T {
|
|
|
74
109
|
|
|
75
110
|
function unwrapSessionMessage(message: unknown) {
|
|
76
111
|
const entry = message as { message?: unknown };
|
|
77
|
-
return (entry.message ?? message) as {
|
|
112
|
+
return (entry.message ?? message) as {
|
|
113
|
+
role?: string;
|
|
114
|
+
customType?: string;
|
|
115
|
+
toolName?: string;
|
|
116
|
+
content?: unknown;
|
|
117
|
+
};
|
|
78
118
|
}
|
|
79
119
|
|
|
80
120
|
function isSessionMessageEntry<T>(message: T): message is T & { message: SessionMessage } {
|
package/src/plan-mode.ts
CHANGED
|
@@ -3,12 +3,20 @@ import type {
|
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
ToolInfo,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
PLAN_MODE_COMPLETE_PARAMS,
|
|
8
|
+
PLAN_MODE_COMPLETE_TOOL_NAME,
|
|
9
|
+
normalizePlanModeCompletion,
|
|
10
|
+
planModeCompleted,
|
|
11
|
+
} from "./completion-tool.js";
|
|
6
12
|
import {
|
|
7
13
|
extractProposedPlan,
|
|
14
|
+
isEmptyAssistantMessage,
|
|
8
15
|
latestAssistantText,
|
|
9
16
|
parseProposedPlan,
|
|
10
17
|
messageContainsInactivePlanModeArtifact,
|
|
11
18
|
messageContainsLegacyPlanModeContextArtifact,
|
|
19
|
+
stripPlanModeCompletionCallsFromMessage,
|
|
12
20
|
stripProposedPlanBlocksFromMessage,
|
|
13
21
|
} from "./message-transform.js";
|
|
14
22
|
import { buildPlanModePrompt } from "./prompt.js";
|
|
@@ -30,11 +38,14 @@ import {
|
|
|
30
38
|
} from "./tool-policy.js";
|
|
31
39
|
import {
|
|
32
40
|
configuredThinkingLevel,
|
|
33
|
-
PLAN_MODE_THINKING_LEVELS,
|
|
34
41
|
readPlanModeSettings,
|
|
35
|
-
type PlanModeFixedThinkingLevel,
|
|
36
42
|
type PlanModeSettings,
|
|
37
43
|
} from "./settings.js";
|
|
44
|
+
import {
|
|
45
|
+
restorePlanModeState,
|
|
46
|
+
type PlanCompletionSource,
|
|
47
|
+
type PlanModeState,
|
|
48
|
+
} from "./state.js";
|
|
38
49
|
|
|
39
50
|
const STATE_ENTRY_TYPE = "plan-mode-state";
|
|
40
51
|
const STATUS_KEY = "plan-mode";
|
|
@@ -50,35 +61,21 @@ interface CommandArgumentCompletion {
|
|
|
50
61
|
description?: string;
|
|
51
62
|
}
|
|
52
63
|
|
|
53
|
-
interface
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
selectedToolNames?: string[];
|
|
58
|
-
selectedToolKeys?: string[];
|
|
59
|
-
previousThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
60
|
-
appliedThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
61
|
-
manualThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
64
|
+
interface ReadyPresentationIntent {
|
|
65
|
+
nonce: number;
|
|
66
|
+
plan: string;
|
|
67
|
+
source: PlanCompletionSource;
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
type SessionEntry = {
|
|
65
|
-
type?: string;
|
|
66
|
-
customType?: string;
|
|
67
|
-
data?: Partial<PlanModeState>;
|
|
68
|
-
message?: SessionMessage;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
type SessionMessage = {
|
|
72
|
-
role?: string;
|
|
73
|
-
content?: unknown;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
70
|
type TextBlock = {
|
|
77
71
|
type?: string;
|
|
78
72
|
text?: string;
|
|
79
73
|
};
|
|
80
74
|
|
|
81
75
|
const PLAN_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [
|
|
76
|
+
{ value: "show", label: "show", description: "Show the completed plan" },
|
|
77
|
+
{ value: "finalize", label: "finalize", description: "Request a completed plan" },
|
|
78
|
+
{ value: "implement", label: "implement", description: "Implement the completed plan" },
|
|
82
79
|
{ value: "exit", label: "exit", description: "Leave Plan mode" },
|
|
83
80
|
{ value: "off", label: "off", description: "Leave Plan mode" },
|
|
84
81
|
{ value: "tools", label: "tools", description: "Select tools allowed in Plan mode" },
|
|
@@ -88,6 +85,8 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
88
85
|
let state: PlanModeState = { enabled: false, awaitingAction: false };
|
|
89
86
|
let settings: PlanModeSettings = { thinkingLevel: "inherit" };
|
|
90
87
|
let previousTools: string[] | undefined;
|
|
88
|
+
let readyPresentationIntent: ReadyPresentationIntent | undefined;
|
|
89
|
+
let nextReadyPresentationNonce = 0;
|
|
91
90
|
|
|
92
91
|
pi.registerFlag("plan", {
|
|
93
92
|
description: "Start in Codex-like Plan mode",
|
|
@@ -140,12 +139,50 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
140
139
|
},
|
|
141
140
|
});
|
|
142
141
|
|
|
142
|
+
pi.registerTool({
|
|
143
|
+
name: PLAN_MODE_COMPLETE_TOOL_NAME,
|
|
144
|
+
label: "Complete plan",
|
|
145
|
+
description:
|
|
146
|
+
"Submit the complete decision-ready implementation plan for user review. Only available while Plan mode is active, and must be the final standalone action.",
|
|
147
|
+
promptSnippet: "Submit the final Plan-mode implementation plan",
|
|
148
|
+
promptGuidelines: [
|
|
149
|
+
"Call plan_mode_complete alone as the final action only after the implementation plan is decision-complete.",
|
|
150
|
+
],
|
|
151
|
+
parameters: PLAN_MODE_COMPLETE_PARAMS,
|
|
152
|
+
async execute(_toolCallId, params: unknown, _signal, _onUpdate, ctx) {
|
|
153
|
+
if (!state.enabled) {
|
|
154
|
+
throw new Error("plan_mode_complete is only available while Plan mode is active");
|
|
155
|
+
}
|
|
156
|
+
const parsed = normalizePlanModeCompletion(params);
|
|
157
|
+
if (!parsed.ok) throw new Error(parsed.error);
|
|
158
|
+
|
|
159
|
+
acceptCompletedPlan(parsed.plan, PLAN_MODE_COMPLETE_TOOL_NAME, ctx);
|
|
160
|
+
return planModeCompleted(parsed.plan);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
143
164
|
pi.registerCommand("plan", {
|
|
144
165
|
description: "Enter or manage Codex-like Plan mode",
|
|
145
166
|
getArgumentCompletions: completePlanArguments,
|
|
146
167
|
handler: async (args, ctx) => {
|
|
147
168
|
const prompt = args.trim();
|
|
148
169
|
const command = prompt.toLowerCase();
|
|
170
|
+
if (command === "show") {
|
|
171
|
+
showStoredPlan(ctx);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (command === "finalize") {
|
|
175
|
+
requestFinalPlan(ctx);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (command === "implement") {
|
|
179
|
+
if (!state.enabled || !state.latestPlan?.trim()) {
|
|
180
|
+
ctx.ui.notify("No completed plan is available to implement.", "warning");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
startImplementation(ctx);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
149
186
|
if (command === "exit" || command === "off") {
|
|
150
187
|
exitPlanMode(ctx);
|
|
151
188
|
ctx.ui.notify("Plan mode disabled. Proposed plan discarded.", "info");
|
|
@@ -170,12 +207,14 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
170
207
|
});
|
|
171
208
|
|
|
172
209
|
pi.on("session_start", async (_event, ctx) => {
|
|
210
|
+
readyPresentationIntent = undefined;
|
|
173
211
|
settings = { thinkingLevel: "inherit" };
|
|
174
212
|
const loadedSettings = await readPlanModeSettings();
|
|
175
213
|
if (loadedSettings.kind === "loaded") settings = loadedSettings.settings;
|
|
176
214
|
else if (loadedSettings.kind === "invalid") {
|
|
177
215
|
ctx.ui.notify(`pi-plan-mode settings ignored: ${loadedSettings.reason}`, "warning");
|
|
178
216
|
}
|
|
217
|
+
if (loadedSettings.notice) ctx.ui.notify(loadedSettings.notice, "warning");
|
|
179
218
|
restoreState(ctx);
|
|
180
219
|
if (pi.getFlag("plan") === true) state.enabled = true;
|
|
181
220
|
if (state.enabled) {
|
|
@@ -199,6 +238,7 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
199
238
|
});
|
|
200
239
|
|
|
201
240
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
241
|
+
readyPresentationIntent = undefined;
|
|
202
242
|
captureManualThinkingLevel();
|
|
203
243
|
persistState();
|
|
204
244
|
if (state.enabled) {
|
|
@@ -249,14 +289,22 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
249
289
|
return {
|
|
250
290
|
messages: messagesWithoutLegacyPlanContext
|
|
251
291
|
.filter((message: unknown) => !messageContainsInactivePlanModeArtifact(message))
|
|
252
|
-
.map(stripProposedPlanBlocksFromMessage)
|
|
292
|
+
.map(stripProposedPlanBlocksFromMessage)
|
|
293
|
+
.map(stripPlanModeCompletionCallsFromMessage)
|
|
294
|
+
.filter((message: unknown) => !isEmptyAssistantMessage(message)),
|
|
253
295
|
};
|
|
254
296
|
});
|
|
255
297
|
|
|
256
298
|
pi.on("before_agent_start", (event, ctx) => {
|
|
257
299
|
if (!state.enabled) return;
|
|
258
300
|
if (state.latestPlan || state.awaitingAction) {
|
|
259
|
-
|
|
301
|
+
readyPresentationIntent = undefined;
|
|
302
|
+
state = {
|
|
303
|
+
...state,
|
|
304
|
+
latestPlan: undefined,
|
|
305
|
+
latestPlanSource: undefined,
|
|
306
|
+
awaitingAction: false,
|
|
307
|
+
};
|
|
260
308
|
persistState();
|
|
261
309
|
updateUi(ctx);
|
|
262
310
|
}
|
|
@@ -279,30 +327,36 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
279
327
|
updateUi(ctx);
|
|
280
328
|
return;
|
|
281
329
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
state = { ...state, latestPlan: proposedPlan, awaitingAction: true };
|
|
285
|
-
persistState();
|
|
286
|
-
updateUi(ctx);
|
|
330
|
+
acceptCompletedPlan(parsedPlan.plan, "legacy_proposed_plan", ctx);
|
|
331
|
+
});
|
|
287
332
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
333
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
334
|
+
const intent = readyPresentationIntent;
|
|
335
|
+
if (!intent || !readyPresentationIsCurrent(intent)) return;
|
|
336
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
|
|
292
337
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
338
|
+
readyPresentationIntent = undefined;
|
|
339
|
+
try {
|
|
340
|
+
if (intent.source === "legacy_proposed_plan") {
|
|
341
|
+
pi.sendMessage(
|
|
342
|
+
{
|
|
343
|
+
customType: PROPOSED_PLAN_MESSAGE_TYPE,
|
|
344
|
+
content: `**Proposed Plan**\n\n${intent.plan}`,
|
|
345
|
+
display: true,
|
|
346
|
+
},
|
|
347
|
+
{ triggerTurn: false },
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (ctx.hasUI && completedPlanIsCurrent(intent)) {
|
|
351
|
+
await showPlanReadyMenu(ctx);
|
|
352
|
+
}
|
|
353
|
+
} catch (error: unknown) {
|
|
354
|
+
if (!isStaleExtensionContextError(error)) throw error;
|
|
355
|
+
}
|
|
302
356
|
});
|
|
303
357
|
|
|
304
358
|
function enterPlanMode(ctx: ExtensionContext) {
|
|
305
|
-
if (!state.enabled) previousTools =
|
|
359
|
+
if (!state.enabled) previousTools = withoutRequiredPlanModeTools(safeGetActiveTools());
|
|
306
360
|
state = { ...state, enabled: true, awaitingAction: false };
|
|
307
361
|
activatePlanModeTools();
|
|
308
362
|
applyPlanThinkingLevel();
|
|
@@ -321,10 +375,12 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
321
375
|
|
|
322
376
|
function exitPlanMode(ctx: ExtensionContext) {
|
|
323
377
|
const wasEnabled = state.enabled;
|
|
378
|
+
readyPresentationIntent = undefined;
|
|
324
379
|
state = {
|
|
325
380
|
...state,
|
|
326
381
|
enabled: false,
|
|
327
382
|
latestPlan: undefined,
|
|
383
|
+
latestPlanSource: undefined,
|
|
328
384
|
awaitingAction: false,
|
|
329
385
|
manualThinkingLevel: undefined,
|
|
330
386
|
};
|
|
@@ -349,17 +405,82 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
349
405
|
}
|
|
350
406
|
}
|
|
351
407
|
|
|
352
|
-
function
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
408
|
+
function acceptCompletedPlan(
|
|
409
|
+
plan: string,
|
|
410
|
+
source: PlanCompletionSource,
|
|
411
|
+
ctx: ExtensionContext,
|
|
412
|
+
) {
|
|
413
|
+
if (
|
|
414
|
+
state.enabled &&
|
|
415
|
+
state.awaitingAction &&
|
|
416
|
+
state.latestPlan === plan &&
|
|
417
|
+
state.latestPlanSource === source
|
|
418
|
+
) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
state = {
|
|
422
|
+
...state,
|
|
423
|
+
latestPlan: plan,
|
|
424
|
+
latestPlanSource: source,
|
|
425
|
+
awaitingAction: true,
|
|
426
|
+
};
|
|
427
|
+
readyPresentationIntent = {
|
|
428
|
+
nonce: ++nextReadyPresentationNonce,
|
|
429
|
+
plan,
|
|
430
|
+
source,
|
|
431
|
+
};
|
|
432
|
+
persistState();
|
|
433
|
+
updateUi(ctx);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function completedPlanIsCurrent(intent: ReadyPresentationIntent) {
|
|
437
|
+
return (
|
|
438
|
+
state.enabled &&
|
|
439
|
+
state.awaitingAction &&
|
|
440
|
+
state.latestPlan === intent.plan &&
|
|
441
|
+
state.latestPlanSource === intent.source
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function readyPresentationIsCurrent(intent: ReadyPresentationIntent) {
|
|
446
|
+
return completedPlanIsCurrent(intent) && readyPresentationIntent?.nonce === intent.nonce;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function showStoredPlan(ctx: ExtensionContext) {
|
|
450
|
+
const plan = state.latestPlan?.trim();
|
|
451
|
+
if (!state.enabled || !plan) {
|
|
452
|
+
ctx.ui.notify("No completed plan is available. Use /plan finalize when planning is complete.", "info");
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
pi.sendMessage(
|
|
457
|
+
{
|
|
458
|
+
customType: PROPOSED_PLAN_MESSAGE_TYPE,
|
|
459
|
+
content: `**Proposed Plan**\n\n${plan}`,
|
|
460
|
+
display: true,
|
|
461
|
+
},
|
|
462
|
+
{ triggerTurn: false },
|
|
463
|
+
);
|
|
464
|
+
} catch (error: unknown) {
|
|
465
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
466
|
+
ctx.ui.notify(`Unable to show completed plan: ${detail}`, "error");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function requestFinalPlan(ctx: ExtensionContext) {
|
|
471
|
+
if (!state.enabled) {
|
|
472
|
+
ctx.ui.notify("Plan mode is not active. Use /plan first.", "warning");
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
sendPlanModeUserMessage(
|
|
476
|
+
"Finalize the current implementation plan now. If any material decision remains, use plan_mode_question instead. Otherwise call plan_mode_complete alone as your final action with the complete decision-ready plan.",
|
|
477
|
+
ctx,
|
|
478
|
+
);
|
|
359
479
|
}
|
|
360
480
|
|
|
361
481
|
function startImplementation(ctx: ExtensionContext) {
|
|
362
482
|
const plan = state.latestPlan?.trim();
|
|
483
|
+
const source = state.latestPlanSource;
|
|
363
484
|
exitPlanMode(ctx);
|
|
364
485
|
|
|
365
486
|
if (!plan) {
|
|
@@ -373,7 +494,7 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
373
494
|
);
|
|
374
495
|
if (!sent) {
|
|
375
496
|
enterPlanMode(ctx);
|
|
376
|
-
state = { ...state, latestPlan: plan, awaitingAction: true };
|
|
497
|
+
state = { ...state, latestPlan: plan, latestPlanSource: source, awaitingAction: true };
|
|
377
498
|
persistState();
|
|
378
499
|
updateUi(ctx);
|
|
379
500
|
}
|
|
@@ -393,10 +514,19 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
393
514
|
"Stay in Plan mode",
|
|
394
515
|
"Exit Plan mode",
|
|
395
516
|
]
|
|
396
|
-
: [
|
|
517
|
+
: [
|
|
518
|
+
"Request final plan",
|
|
519
|
+
"Configure Plan-mode tools",
|
|
520
|
+
"Stay in Plan mode",
|
|
521
|
+
"Exit Plan mode",
|
|
522
|
+
];
|
|
397
523
|
const choice = await ctx.ui.select(planStatusText(), choices);
|
|
398
524
|
if (choice === "Show latest proposed plan") {
|
|
399
|
-
ctx
|
|
525
|
+
showStoredPlan(ctx);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (choice === "Request final plan") {
|
|
529
|
+
requestFinalPlan(ctx);
|
|
400
530
|
return;
|
|
401
531
|
}
|
|
402
532
|
if (choice === "Implement this plan") {
|
|
@@ -497,7 +627,7 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
497
627
|
}
|
|
498
628
|
|
|
499
629
|
function activatePlanModeTools() {
|
|
500
|
-
previousTools ??=
|
|
630
|
+
previousTools ??= withoutRequiredPlanModeTools(safeGetActiveTools());
|
|
501
631
|
applyPlanModeTools();
|
|
502
632
|
}
|
|
503
633
|
|
|
@@ -507,7 +637,9 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
507
637
|
|
|
508
638
|
function planModeToolNames() {
|
|
509
639
|
const tools = selectableTools();
|
|
510
|
-
if (tools.length === 0)
|
|
640
|
+
if (tools.length === 0) {
|
|
641
|
+
return ["read", "bash", PLAN_MODE_QUESTION_TOOL_NAME, PLAN_MODE_COMPLETE_TOOL_NAME];
|
|
642
|
+
}
|
|
511
643
|
|
|
512
644
|
const selectedNames = planModeSelectedNames(tools);
|
|
513
645
|
return withRequiredPlanModeTools(
|
|
@@ -549,7 +681,10 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
549
681
|
|
|
550
682
|
function selectableTools() {
|
|
551
683
|
return safeGetAllTools()
|
|
552
|
-
.filter(
|
|
684
|
+
.filter(
|
|
685
|
+
(tool) =>
|
|
686
|
+
tool.name !== PLAN_MODE_QUESTION_TOOL_NAME && tool.name !== PLAN_MODE_COMPLETE_TOOL_NAME,
|
|
687
|
+
)
|
|
553
688
|
.sort(compareTools);
|
|
554
689
|
}
|
|
555
690
|
|
|
@@ -567,7 +702,7 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
567
702
|
|
|
568
703
|
function restoreTools() {
|
|
569
704
|
const restoredTools = previousTools ?? DEFAULT_TOOLS;
|
|
570
|
-
pi.setActiveTools(
|
|
705
|
+
pi.setActiveTools(withoutRequiredPlanModeTools(restoredTools));
|
|
571
706
|
previousTools = undefined;
|
|
572
707
|
}
|
|
573
708
|
|
|
@@ -620,7 +755,7 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
620
755
|
|
|
621
756
|
function deactivatePlanModeQuestionTool() {
|
|
622
757
|
const activeTools = safeGetActiveTools();
|
|
623
|
-
const filteredTools =
|
|
758
|
+
const filteredTools = withoutRequiredPlanModeTools(activeTools);
|
|
624
759
|
if (filteredTools.length !== activeTools.length) {
|
|
625
760
|
pi.setActiveTools(filteredTools);
|
|
626
761
|
}
|
|
@@ -639,30 +774,7 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
639
774
|
}
|
|
640
775
|
|
|
641
776
|
function restoreState(ctx: ExtensionContext) {
|
|
642
|
-
state =
|
|
643
|
-
const entries = ctx.sessionManager.getEntries() as SessionEntry[];
|
|
644
|
-
const entry = entries
|
|
645
|
-
.filter((candidate) => candidate.type === "custom" && candidate.customType === STATE_ENTRY_TYPE)
|
|
646
|
-
.pop();
|
|
647
|
-
if (!isRecord(entry?.data)) return;
|
|
648
|
-
const enabled = entry.data.enabled === true;
|
|
649
|
-
state = {
|
|
650
|
-
enabled,
|
|
651
|
-
latestPlan:
|
|
652
|
-
enabled && typeof entry.data.latestPlan === "string" ? entry.data.latestPlan : undefined,
|
|
653
|
-
awaitingAction: enabled && entry.data.awaitingAction === true,
|
|
654
|
-
selectedToolNames: stringArray(entry.data.selectedToolNames),
|
|
655
|
-
selectedToolKeys: stringArray(entry.data.selectedToolKeys),
|
|
656
|
-
previousThinkingLevel: enabled
|
|
657
|
-
? fixedThinkingLevel(entry.data.previousThinkingLevel)
|
|
658
|
-
: undefined,
|
|
659
|
-
appliedThinkingLevel: enabled
|
|
660
|
-
? fixedThinkingLevel(entry.data.appliedThinkingLevel)
|
|
661
|
-
: undefined,
|
|
662
|
-
manualThinkingLevel: enabled
|
|
663
|
-
? fixedThinkingLevel(entry.data.manualThinkingLevel)
|
|
664
|
-
: undefined,
|
|
665
|
-
};
|
|
777
|
+
state = restorePlanModeState(ctx.sessionManager.getBranch(), STATE_ENTRY_TYPE);
|
|
666
778
|
}
|
|
667
779
|
|
|
668
780
|
function updateUi(ctx: ExtensionContext) {
|
|
@@ -676,7 +788,7 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
676
788
|
ctx.ui.setWidget(PLAN_WIDGET_KEY, [
|
|
677
789
|
"Plan mode: planning",
|
|
678
790
|
formatToolSummary(),
|
|
679
|
-
"
|
|
791
|
+
"Finish with plan_mode_complete when decision-ready.",
|
|
680
792
|
]);
|
|
681
793
|
} else {
|
|
682
794
|
ctx.ui.setWidget(PLAN_WIDGET_KEY, undefined);
|
|
@@ -697,7 +809,7 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
697
809
|
function planStatusText() {
|
|
698
810
|
if (!state.enabled) return "Plan mode is off.";
|
|
699
811
|
if (state.latestPlan) return `Plan mode is active and a proposed plan is ready. ${formatToolSummary()}`;
|
|
700
|
-
return `Plan mode is active. ${formatToolSummary()} Explore, ask, and
|
|
812
|
+
return `Plan mode is active. ${formatToolSummary()} Explore, ask, and finish with plan_mode_complete when decision-ready.`;
|
|
701
813
|
}
|
|
702
814
|
|
|
703
815
|
function formatToolSummary() {
|
|
@@ -761,32 +873,33 @@ function unique(values: string[]) {
|
|
|
761
873
|
return Array.from(new Set(values));
|
|
762
874
|
}
|
|
763
875
|
|
|
764
|
-
function
|
|
765
|
-
return
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
? unique(value)
|
|
771
|
-
: undefined;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
function fixedThinkingLevel(value: unknown): PlanModeFixedThinkingLevel | undefined {
|
|
775
|
-
return typeof value === "string" &&
|
|
776
|
-
value !== "inherit" &&
|
|
777
|
-
PLAN_MODE_THINKING_LEVELS.includes(value as (typeof PLAN_MODE_THINKING_LEVELS)[number])
|
|
778
|
-
? (value as PlanModeFixedThinkingLevel)
|
|
779
|
-
: undefined;
|
|
876
|
+
function isStaleExtensionContextError(error: unknown) {
|
|
877
|
+
return (
|
|
878
|
+
error instanceof Error &&
|
|
879
|
+
(error.message.includes("This extension ctx is stale after session replacement or reload") ||
|
|
880
|
+
error.message.includes("Extension context is no longer active"))
|
|
881
|
+
);
|
|
780
882
|
}
|
|
781
883
|
|
|
782
884
|
export function withRequiredPlanModeTools(toolNames: string[]) {
|
|
783
|
-
return unique([
|
|
885
|
+
return unique([
|
|
886
|
+
...withoutRequiredPlanModeTools(toolNames),
|
|
887
|
+
PLAN_MODE_QUESTION_TOOL_NAME,
|
|
888
|
+
PLAN_MODE_COMPLETE_TOOL_NAME,
|
|
889
|
+
]);
|
|
784
890
|
}
|
|
785
891
|
|
|
786
892
|
export function withoutPlanModeQuestionTool(toolNames: string[]) {
|
|
787
893
|
return toolNames.filter((toolName) => toolName !== PLAN_MODE_QUESTION_TOOL_NAME);
|
|
788
894
|
}
|
|
789
895
|
|
|
896
|
+
function withoutRequiredPlanModeTools(toolNames: string[]) {
|
|
897
|
+
return toolNames.filter(
|
|
898
|
+
(toolName) =>
|
|
899
|
+
toolName !== PLAN_MODE_QUESTION_TOOL_NAME && toolName !== PLAN_MODE_COMPLETE_TOOL_NAME,
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
|
|
790
903
|
function invalidPlanMessage(kind: "empty" | "multiple" | "malformed" | "unclosed") {
|
|
791
904
|
const detail = {
|
|
792
905
|
empty: "the block is empty",
|
package/src/prompt.ts
CHANGED
|
@@ -32,27 +32,28 @@ You are in Plan Mode, a Codex-like collaboration mode for producing a decision-c
|
|
|
32
32
|
- Use plan_mode_question for important preferences, tradeoffs, or assumption locks that cannot be discovered by non-mutating exploration. Ask 1-3 concise questions with 2-4 meaningful options. Do not include filler options.
|
|
33
33
|
- If plan_mode_question returns cancelled or ui_unavailable, do not jump straight to a final plan when the missing answer is high impact. Ask one concise plain-text question or proceed only with a clearly stated low-risk assumption.
|
|
34
34
|
|
|
35
|
-
##
|
|
35
|
+
## Ending each turn
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
Every Plan-mode turn that advances or finalizes the plan must end in exactly one of these ways:
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
- If a material decision remains, use plan_mode_question. If interactive UI is unavailable, ask one concise plain-text question instead.
|
|
40
|
+
- If the implementation plan is decision-complete, call plan_mode_complete alone as your final action. Do not call other tools in the same batch and do not emit a normal assistant response after it.
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
...
|
|
42
|
+
If a follow-up asks only for clarification and does not change or challenge the plan, answer it directly, then call plan_mode_complete alone as the final action with the complete unchanged plan so it remains available for implementation.
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
...
|
|
44
|
+
Never end with prose that merely announces you are about to present, write, or finalize the plan. Submit the actual plan with plan_mode_complete in that turn.
|
|
47
45
|
|
|
48
|
-
##
|
|
49
|
-
...
|
|
46
|
+
## Completion rule
|
|
50
47
|
|
|
51
|
-
|
|
52
|
-
...
|
|
53
|
-
</proposed_plan>
|
|
48
|
+
Only call plan_mode_complete when the plan leaves no implementation decisions unresolved. Pass the complete plan as Markdown with:
|
|
54
49
|
|
|
55
|
-
|
|
50
|
+
- A clear title
|
|
51
|
+
- A brief summary
|
|
52
|
+
- Important changes to behavior, public APIs, interfaces, or types
|
|
53
|
+
- Test cases and verification scenarios
|
|
54
|
+
- Explicit assumptions and defaults chosen where needed
|
|
56
55
|
|
|
57
|
-
|
|
56
|
+
Keep the plan concise, human and agent digestible, and free of open decisions. Prefer grouped behavior-level changes over file-by-file or symbol-by-symbol inventories. Do not ask "should I proceed?"; plan_mode_complete opens the Plan-mode ready flow.
|
|
57
|
+
|
|
58
|
+
If the user requests revisions after a completed plan, the next plan_mode_complete call must contain a complete replacement, not a delta. If there is not enough information for a complete replacement, continue planning with plan_mode_question instead of calling plan_mode_complete.`;
|
|
58
59
|
}
|
package/src/settings.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { access, link, lstat, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
3
4
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
|
|
5
|
-
export const PLAN_MODE_SETTINGS_FILE = "plan-mode.json";
|
|
6
|
+
export const PLAN_MODE_SETTINGS_FILE = "pi-plan-mode.json";
|
|
7
|
+
const LEGACY_PLAN_MODE_SETTINGS_FILE = "plan-mode.json";
|
|
6
8
|
export const PLAN_MODE_THINKING_LEVELS = [
|
|
7
9
|
"inherit",
|
|
8
10
|
"off",
|
|
@@ -11,6 +13,7 @@ export const PLAN_MODE_THINKING_LEVELS = [
|
|
|
11
13
|
"medium",
|
|
12
14
|
"high",
|
|
13
15
|
"xhigh",
|
|
16
|
+
"max",
|
|
14
17
|
] as const;
|
|
15
18
|
|
|
16
19
|
export type PlanModeThinkingLevel = (typeof PLAN_MODE_THINKING_LEVELS)[number];
|
|
@@ -19,9 +22,9 @@ export interface PlanModeSettings {
|
|
|
19
22
|
thinkingLevel: PlanModeThinkingLevel;
|
|
20
23
|
}
|
|
21
24
|
export type PlanModeSettingsLoadResult =
|
|
22
|
-
| { kind: "missing" }
|
|
23
|
-
| { kind: "invalid"; reason: string }
|
|
24
|
-
| { kind: "loaded"; settings: PlanModeSettings };
|
|
25
|
+
| { kind: "missing"; notice?: string }
|
|
26
|
+
| { kind: "invalid"; reason: string; notice?: string }
|
|
27
|
+
| { kind: "loaded"; settings: PlanModeSettings; notice?: string };
|
|
25
28
|
|
|
26
29
|
export function normalizePlanModeSettings(value: unknown): PlanModeSettings | undefined {
|
|
27
30
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
@@ -34,22 +37,127 @@ export function normalizePlanModeSettings(value: unknown): PlanModeSettings | un
|
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
export async function readPlanModeSettings(
|
|
37
|
-
settingsPath
|
|
40
|
+
settingsPath?: string,
|
|
38
41
|
): Promise<PlanModeSettingsLoadResult> {
|
|
42
|
+
if (settingsPath) return readSettingsFile(settingsPath);
|
|
43
|
+
const canonicalPath = join(getAgentDir(), PLAN_MODE_SETTINGS_FILE);
|
|
44
|
+
const canonical = await readSettingsFile(canonicalPath);
|
|
45
|
+
const legacyPath = join(getAgentDir(), LEGACY_PLAN_MODE_SETTINGS_FILE);
|
|
46
|
+
if (canonical.kind !== "missing") {
|
|
47
|
+
return (await exists(legacyPath))
|
|
48
|
+
? {
|
|
49
|
+
...canonical,
|
|
50
|
+
notice: `${LEGACY_PLAN_MODE_SETTINGS_FILE} ignored because ${PLAN_MODE_SETTINGS_FILE} takes precedence.`,
|
|
51
|
+
}
|
|
52
|
+
: canonical;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const legacySnapshot = await readSettingsSnapshot(legacyPath);
|
|
56
|
+
const legacy = legacySnapshot.result;
|
|
57
|
+
const raced = await readSettingsFile(canonicalPath);
|
|
58
|
+
if (raced.kind !== "missing") return raced;
|
|
59
|
+
if (legacy.kind !== "loaded") return legacy;
|
|
60
|
+
let installedIdentity: FileIdentity;
|
|
61
|
+
try {
|
|
62
|
+
installedIdentity = await installFileExclusively(
|
|
63
|
+
canonicalPath,
|
|
64
|
+
legacySnapshot.contents ?? "",
|
|
65
|
+
);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const created = await readSettingsFile(canonicalPath);
|
|
68
|
+
if (created.kind !== "missing") {
|
|
69
|
+
return {
|
|
70
|
+
...created,
|
|
71
|
+
notice: `${LEGACY_PLAN_MODE_SETTINGS_FILE} ignored because ${PLAN_MODE_SETTINGS_FILE} was created concurrently.`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
...legacy,
|
|
76
|
+
notice: `Plan-mode settings migration failed: ${formatError(error)}. The legacy file was used for this session.`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (!(await fileContentsEqual(legacyPath, legacySnapshot.contents ?? ""))) {
|
|
80
|
+
const removed = await removeFileIfIdentityMatches(
|
|
81
|
+
canonicalPath,
|
|
82
|
+
installedIdentity,
|
|
83
|
+
legacySnapshot.contents ?? "",
|
|
84
|
+
);
|
|
85
|
+
return {
|
|
86
|
+
...legacy,
|
|
87
|
+
notice: removed
|
|
88
|
+
? `${LEGACY_PLAN_MODE_SETTINGS_FILE} changed during migration; the stale ${PLAN_MODE_SETTINGS_FILE} snapshot was removed.`
|
|
89
|
+
: `${LEGACY_PLAN_MODE_SETTINGS_FILE} changed during migration, but ${PLAN_MODE_SETTINGS_FILE} was replaced concurrently and takes precedence on the next load.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
await rm(legacyPath);
|
|
94
|
+
return {
|
|
95
|
+
...legacy,
|
|
96
|
+
notice: `Plan-mode settings migrated from ${LEGACY_PLAN_MODE_SETTINGS_FILE} to ${PLAN_MODE_SETTINGS_FILE}.`,
|
|
97
|
+
};
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return {
|
|
100
|
+
...legacy,
|
|
101
|
+
notice: `Plan-mode settings migrated to ${PLAN_MODE_SETTINGS_FILE}, but ${LEGACY_PLAN_MODE_SETTINGS_FILE} could not be removed: ${formatError(error)}.`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type FileIdentity = { dev: number; ino: number };
|
|
107
|
+
|
|
108
|
+
async function installFileExclusively(filePath: string, contents: string): Promise<FileIdentity> {
|
|
109
|
+
const tempFile = join(dirname(filePath), `.${PLAN_MODE_SETTINGS_FILE}.${randomUUID()}.tmp`);
|
|
110
|
+
try {
|
|
111
|
+
await writeFile(tempFile, contents, { encoding: "utf8", flag: "wx" });
|
|
112
|
+
const identity = await lstat(tempFile);
|
|
113
|
+
await link(tempFile, filePath);
|
|
114
|
+
return { dev: identity.dev, ino: identity.ino };
|
|
115
|
+
} finally {
|
|
116
|
+
await rm(tempFile, { force: true }).catch(() => undefined);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function removeFileIfIdentityMatches(
|
|
121
|
+
filePath: string,
|
|
122
|
+
expected: FileIdentity,
|
|
123
|
+
expectedContents: string,
|
|
124
|
+
) {
|
|
125
|
+
try {
|
|
126
|
+
const current = await lstat(filePath);
|
|
127
|
+
if (current.dev !== expected.dev || current.ino !== expected.ino) return false;
|
|
128
|
+
if ((await readFile(filePath, "utf8")) !== expectedContents) return false;
|
|
129
|
+
await rm(filePath);
|
|
130
|
+
return true;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function readSettingsFile(settingsPath: string): Promise<PlanModeSettingsLoadResult> {
|
|
137
|
+
return (await readSettingsSnapshot(settingsPath)).result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function readSettingsSnapshot(settingsPath: string): Promise<{
|
|
141
|
+
result: PlanModeSettingsLoadResult;
|
|
142
|
+
contents?: string;
|
|
143
|
+
}> {
|
|
39
144
|
let contents: string;
|
|
40
145
|
try {
|
|
41
146
|
contents = await readFile(settingsPath, "utf8");
|
|
42
147
|
} catch (error: unknown) {
|
|
43
|
-
if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
|
|
44
|
-
return { kind: "invalid", reason: formatError(error) };
|
|
148
|
+
if (isNodeError(error) && error.code === "ENOENT") return { result: { kind: "missing" } };
|
|
149
|
+
return { result: { kind: "invalid", reason: formatError(error) } };
|
|
45
150
|
}
|
|
46
151
|
try {
|
|
47
152
|
const settings = normalizePlanModeSettings(JSON.parse(contents) as unknown);
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
:
|
|
153
|
+
return {
|
|
154
|
+
contents,
|
|
155
|
+
result: settings
|
|
156
|
+
? { kind: "loaded", settings }
|
|
157
|
+
: { kind: "invalid", reason: "invalid settings shape" },
|
|
158
|
+
};
|
|
51
159
|
} catch (error: unknown) {
|
|
52
|
-
return { kind: "invalid", reason: formatError(error) };
|
|
160
|
+
return { contents, result: { kind: "invalid", reason: formatError(error) } };
|
|
53
161
|
}
|
|
54
162
|
}
|
|
55
163
|
|
|
@@ -59,6 +167,23 @@ export function configuredThinkingLevel(
|
|
|
59
167
|
return settings.thinkingLevel === "inherit" ? undefined : settings.thinkingLevel;
|
|
60
168
|
}
|
|
61
169
|
|
|
170
|
+
async function fileContentsEqual(path: string, expected: string) {
|
|
171
|
+
try {
|
|
172
|
+
return (await readFile(path, "utf8")) === expected;
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function exists(path: string) {
|
|
179
|
+
try {
|
|
180
|
+
await access(path);
|
|
181
|
+
return true;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
62
187
|
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
63
188
|
return error instanceof Error && "code" in error;
|
|
64
189
|
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PLAN_MODE_COMPLETE_TOOL_NAME,
|
|
3
|
+
normalizePlanModeCompletion,
|
|
4
|
+
planFromCompletionDetails,
|
|
5
|
+
} from "./completion-tool.js";
|
|
6
|
+
import {
|
|
7
|
+
PLAN_MODE_THINKING_LEVELS,
|
|
8
|
+
type PlanModeFixedThinkingLevel,
|
|
9
|
+
} from "./settings.js";
|
|
10
|
+
|
|
11
|
+
export type PlanCompletionSource =
|
|
12
|
+
| typeof PLAN_MODE_COMPLETE_TOOL_NAME
|
|
13
|
+
| "legacy_proposed_plan";
|
|
14
|
+
|
|
15
|
+
export interface PlanModeState {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
latestPlan?: string;
|
|
18
|
+
latestPlanSource?: PlanCompletionSource;
|
|
19
|
+
awaitingAction: boolean;
|
|
20
|
+
selectedToolNames?: string[];
|
|
21
|
+
selectedToolKeys?: string[];
|
|
22
|
+
previousThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
23
|
+
appliedThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
24
|
+
manualThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type SessionEntry = {
|
|
28
|
+
type?: string;
|
|
29
|
+
customType?: string;
|
|
30
|
+
data?: unknown;
|
|
31
|
+
message?: {
|
|
32
|
+
role?: string;
|
|
33
|
+
toolName?: string;
|
|
34
|
+
details?: unknown;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function restorePlanModeState(entries: unknown[], stateEntryType: string): PlanModeState {
|
|
39
|
+
const branch = entries as SessionEntry[];
|
|
40
|
+
let stateEntryIndex = -1;
|
|
41
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
42
|
+
const candidate = branch[index];
|
|
43
|
+
if (candidate?.type === "custom" && candidate.customType === stateEntryType) {
|
|
44
|
+
stateEntryIndex = index;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const entry = branch[stateEntryIndex];
|
|
49
|
+
if (!isRecord(entry?.data)) return { enabled: false, awaitingAction: false };
|
|
50
|
+
|
|
51
|
+
const enabled = entry.data.enabled === true;
|
|
52
|
+
const persistedSource = enabled
|
|
53
|
+
? planCompletionSource(entry.data.latestPlanSource)
|
|
54
|
+
: undefined;
|
|
55
|
+
const persistedPlan = enabled
|
|
56
|
+
? normalizePersistedPlan(entry.data.latestPlan, persistedSource)
|
|
57
|
+
: undefined;
|
|
58
|
+
const recoveredPlan =
|
|
59
|
+
enabled && !persistedPlan
|
|
60
|
+
? latestCompletionPlan(branch.slice(stateEntryIndex + 1))
|
|
61
|
+
: undefined;
|
|
62
|
+
const latestPlan = persistedPlan ?? recoveredPlan;
|
|
63
|
+
return {
|
|
64
|
+
enabled,
|
|
65
|
+
latestPlan,
|
|
66
|
+
latestPlanSource: enabled
|
|
67
|
+
? (persistedPlan ? persistedSource : undefined) ??
|
|
68
|
+
(recoveredPlan ? PLAN_MODE_COMPLETE_TOOL_NAME : undefined)
|
|
69
|
+
: undefined,
|
|
70
|
+
awaitingAction: enabled && latestPlan !== undefined,
|
|
71
|
+
selectedToolNames: stringArray(entry.data.selectedToolNames),
|
|
72
|
+
selectedToolKeys: stringArray(entry.data.selectedToolKeys),
|
|
73
|
+
previousThinkingLevel: enabled
|
|
74
|
+
? fixedThinkingLevel(entry.data.previousThinkingLevel)
|
|
75
|
+
: undefined,
|
|
76
|
+
appliedThinkingLevel: enabled
|
|
77
|
+
? fixedThinkingLevel(entry.data.appliedThinkingLevel)
|
|
78
|
+
: undefined,
|
|
79
|
+
manualThinkingLevel: enabled
|
|
80
|
+
? fixedThinkingLevel(entry.data.manualThinkingLevel)
|
|
81
|
+
: undefined,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizePersistedPlan(value: unknown, source: PlanCompletionSource | undefined) {
|
|
86
|
+
if (typeof value !== "string") return undefined;
|
|
87
|
+
if (source === PLAN_MODE_COMPLETE_TOOL_NAME) {
|
|
88
|
+
const normalized = normalizePlanModeCompletion({ plan: value });
|
|
89
|
+
return normalized.ok ? normalized.plan : undefined;
|
|
90
|
+
}
|
|
91
|
+
return value.trim() || undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function latestCompletionPlan(entries: SessionEntry[]) {
|
|
95
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
96
|
+
const message = entries[index]?.message;
|
|
97
|
+
if (message?.role !== "toolResult" || message.toolName !== PLAN_MODE_COMPLETE_TOOL_NAME) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const plan = planFromCompletionDetails(message.details);
|
|
101
|
+
if (plan) return plan;
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function planCompletionSource(value: unknown): PlanCompletionSource | undefined {
|
|
107
|
+
return value === PLAN_MODE_COMPLETE_TOOL_NAME || value === "legacy_proposed_plan"
|
|
108
|
+
? value
|
|
109
|
+
: undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function fixedThinkingLevel(value: unknown): PlanModeFixedThinkingLevel | undefined {
|
|
113
|
+
return typeof value === "string" &&
|
|
114
|
+
value !== "inherit" &&
|
|
115
|
+
PLAN_MODE_THINKING_LEVELS.includes(value as (typeof PLAN_MODE_THINKING_LEVELS)[number])
|
|
116
|
+
? (value as PlanModeFixedThinkingLevel)
|
|
117
|
+
: undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function stringArray(value: unknown) {
|
|
121
|
+
return Array.isArray(value) && value.every((item): item is string => typeof item === "string")
|
|
122
|
+
? Array.from(new Set(value))
|
|
123
|
+
: undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
127
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
128
|
+
}
|