@narumitw/pi-plan-mode 0.12.0 → 0.13.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 +3 -2
- package/package.json +1 -1
- package/src/message-transform.ts +98 -0
- package/src/plan-mode.ts +26 -443
- package/src/prompt.ts +55 -0
- package/src/question-tool.ts +207 -0
- package/src/tool-policy.ts +67 -0
package/README.md
CHANGED
|
@@ -104,14 +104,15 @@ This extension maps Codex's `ModeKind::Plan` behavior onto Pi's extension API:
|
|
|
104
104
|
```txt
|
|
105
105
|
extensions/pi-plan-mode/
|
|
106
106
|
├── src/
|
|
107
|
-
│
|
|
107
|
+
│ ├── plan-mode.ts # Pi entrypoint and mode state
|
|
108
|
+
│ └── *.ts # Package-local prompt, policy, question, and message modules
|
|
108
109
|
├── README.md
|
|
109
110
|
├── LICENSE
|
|
110
111
|
├── tsconfig.json
|
|
111
112
|
└── package.json
|
|
112
113
|
```
|
|
113
114
|
|
|
114
|
-
The package exposes its Pi extension through `package.json`:
|
|
115
|
+
Only `plan-mode.ts` is a Pi entrypoint; the other source modules are internal. The package exposes its Pi extension through `package.json`:
|
|
115
116
|
|
|
116
117
|
```json
|
|
117
118
|
{
|
package/package.json
CHANGED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const PLAN_CONTEXT_MESSAGE_TYPE = "plan-mode-context";
|
|
2
|
+
const PROPOSED_PLAN_MESSAGE_TYPE = "proposed-plan";
|
|
3
|
+
const PROPOSED_PLAN_PATTERN = /<proposed_plan>\s*([\s\S]*?)\s*<\/proposed_plan>/i;
|
|
4
|
+
const PROPOSED_PLAN_BLOCK_PATTERN = /<proposed_plan>\s*[\s\S]*?\s*<\/proposed_plan>/gi;
|
|
5
|
+
|
|
6
|
+
type SessionMessage = {
|
|
7
|
+
role?: string;
|
|
8
|
+
content?: unknown;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type TextBlock = {
|
|
12
|
+
type?: string;
|
|
13
|
+
text?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function extractProposedPlan(text: string) {
|
|
17
|
+
const match = PROPOSED_PLAN_PATTERN.exec(text);
|
|
18
|
+
return match?.[1]?.trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function latestAssistantText(messages: unknown) {
|
|
22
|
+
if (!Array.isArray(messages)) return "";
|
|
23
|
+
for (const entry of [...messages].reverse()) {
|
|
24
|
+
const message = (entry as { message?: SessionMessage })?.message ?? (entry as SessionMessage);
|
|
25
|
+
if (message?.role !== "assistant") continue;
|
|
26
|
+
const text = messageText(message);
|
|
27
|
+
if (text) return text;
|
|
28
|
+
}
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function messageContainsLegacyPlanModeContextArtifact(message: unknown) {
|
|
33
|
+
return unwrapSessionMessage(message).customType === PLAN_CONTEXT_MESSAGE_TYPE;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function messageContainsInactivePlanModeArtifact(message: unknown) {
|
|
37
|
+
return unwrapSessionMessage(message).customType === PROPOSED_PLAN_MESSAGE_TYPE;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function stripProposedPlanBlocksFromMessage<T>(message: T): T {
|
|
41
|
+
const candidate = unwrapSessionMessage(message);
|
|
42
|
+
if (candidate.role !== "assistant") return message;
|
|
43
|
+
|
|
44
|
+
const content = stripProposedPlanBlocksFromContent(candidate.content);
|
|
45
|
+
if (content === candidate.content) return message;
|
|
46
|
+
|
|
47
|
+
if (isSessionMessageEntry(message)) {
|
|
48
|
+
return { ...message, message: { ...candidate, content } };
|
|
49
|
+
}
|
|
50
|
+
return { ...candidate, content } as T;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function unwrapSessionMessage(message: unknown) {
|
|
54
|
+
const entry = message as { message?: unknown };
|
|
55
|
+
return (entry.message ?? message) as { role?: string; customType?: string; content?: unknown };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isSessionMessageEntry<T>(message: T): message is T & { message: SessionMessage } {
|
|
59
|
+
return typeof message === "object" && message !== null && "message" in message;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function stripProposedPlanBlocksFromContent(content: unknown) {
|
|
63
|
+
if (typeof content === "string") return stripProposedPlanBlocks(content);
|
|
64
|
+
if (!Array.isArray(content)) return content;
|
|
65
|
+
|
|
66
|
+
let changed = false;
|
|
67
|
+
const nextContent = content.map((block) => {
|
|
68
|
+
const textBlock = block as TextBlock;
|
|
69
|
+
if (textBlock.type !== "text" || typeof textBlock.text !== "string") return block;
|
|
70
|
+
|
|
71
|
+
const text = stripProposedPlanBlocks(textBlock.text);
|
|
72
|
+
if (text === textBlock.text) return block;
|
|
73
|
+
|
|
74
|
+
changed = true;
|
|
75
|
+
return { ...textBlock, text };
|
|
76
|
+
});
|
|
77
|
+
return changed ? nextContent : content;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function stripProposedPlanBlocks(text: string) {
|
|
81
|
+
return text.replace(PROPOSED_PLAN_BLOCK_PATTERN, "");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function messageText(message: SessionMessage) {
|
|
85
|
+
return contentText(message.content);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function contentText(content: unknown): string {
|
|
89
|
+
if (typeof content === "string") return content;
|
|
90
|
+
if (!Array.isArray(content)) return "";
|
|
91
|
+
return content
|
|
92
|
+
.map((block) => {
|
|
93
|
+
const textBlock = block as TextBlock;
|
|
94
|
+
return textBlock.type === "text" && typeof textBlock.text === "string" ? textBlock.text : "";
|
|
95
|
+
})
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.join("\n");
|
|
98
|
+
}
|
package/src/plan-mode.ts
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
extractProposedPlan,
|
|
4
|
+
latestAssistantText,
|
|
5
|
+
messageContainsInactivePlanModeArtifact,
|
|
6
|
+
messageContainsLegacyPlanModeContextArtifact,
|
|
7
|
+
stripProposedPlanBlocksFromMessage,
|
|
8
|
+
} from "./message-transform.js";
|
|
9
|
+
import { buildPlanModePrompt } from "./prompt.js";
|
|
10
|
+
import {
|
|
11
|
+
askPlanModeQuestions,
|
|
12
|
+
normalizePlanModeQuestionParams,
|
|
13
|
+
PLAN_MODE_QUESTION_PARAMS,
|
|
14
|
+
PLAN_MODE_QUESTION_TOOL_NAME,
|
|
15
|
+
planModeQuestionAnswered,
|
|
16
|
+
planModeQuestionCancelled,
|
|
17
|
+
} from "./question-tool.js";
|
|
18
|
+
import {
|
|
19
|
+
canSelectToolInPlanMode,
|
|
20
|
+
isBuiltinTool,
|
|
21
|
+
isSafeCommand,
|
|
22
|
+
readCommand,
|
|
23
|
+
SAFE_BUILTIN_PLAN_TOOLS,
|
|
24
|
+
} from "./tool-policy.js";
|
|
2
25
|
|
|
3
26
|
const STATE_ENTRY_TYPE = "plan-mode-state";
|
|
4
27
|
const STATUS_KEY = "plan-mode";
|
|
5
28
|
const PLAN_WIDGET_KEY = "plan-mode-plan";
|
|
6
|
-
const PLAN_CONTEXT_MESSAGE_TYPE = "plan-mode-context";
|
|
7
29
|
const PROPOSED_PLAN_MESSAGE_TYPE = "proposed-plan";
|
|
8
|
-
const PLAN_MODE_QUESTION_TOOL_NAME = "plan_mode_question";
|
|
9
|
-
const PLAN_CONTEXT_MARKER = "[CODEX-LIKE PLAN MODE ACTIVE]";
|
|
10
|
-
const SAFE_BUILTIN_PLAN_TOOLS = new Set(["read", "bash", "grep", "find", "ls"]);
|
|
11
30
|
const BLOCKED_BUILTIN_TOOLS = new Set(["edit", "write"]);
|
|
12
31
|
const DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
|
|
13
32
|
const TOOL_SELECTOR_PAGE_SIZE = 10;
|
|
14
|
-
const PROPOSED_PLAN_PATTERN = /<proposed_plan>\s*([\s\S]*?)\s*<\/proposed_plan>/i;
|
|
15
|
-
const PROPOSED_PLAN_BLOCK_PATTERN = /<proposed_plan>\s*[\s\S]*?\s*<\/proposed_plan>/gi;
|
|
16
33
|
|
|
17
34
|
interface CommandArgumentCompletion {
|
|
18
35
|
value: string;
|
|
@@ -45,144 +62,12 @@ type TextBlock = {
|
|
|
45
62
|
text?: string;
|
|
46
63
|
};
|
|
47
64
|
|
|
48
|
-
type PlanModeQuestionOption = {
|
|
49
|
-
label: string;
|
|
50
|
-
description?: string;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
type PlanModeQuestion = {
|
|
54
|
-
id: string;
|
|
55
|
-
header: string;
|
|
56
|
-
question: string;
|
|
57
|
-
options: PlanModeQuestionOption[];
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
type PlanModeQuestionParams = {
|
|
61
|
-
questions: PlanModeQuestion[];
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
type PlanModeQuestionAnswer = {
|
|
65
|
-
id: string;
|
|
66
|
-
header: string;
|
|
67
|
-
question: string;
|
|
68
|
-
answer: string;
|
|
69
|
-
wasCustom: boolean;
|
|
70
|
-
optionIndex?: number;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
type PlanModeQuestionReason = "cancelled" | "ui_unavailable" | "plan_mode_inactive" | "invalid_input";
|
|
74
|
-
|
|
75
|
-
type PlanModeQuestionDetails = {
|
|
76
|
-
cancelled: boolean;
|
|
77
|
-
reason?: PlanModeQuestionReason;
|
|
78
|
-
questions: PlanModeQuestion[];
|
|
79
|
-
answers?: PlanModeQuestionAnswer[];
|
|
80
|
-
};
|
|
81
|
-
|
|
82
65
|
const PLAN_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [
|
|
83
66
|
{ value: "exit", label: "exit", description: "Leave Plan mode" },
|
|
84
67
|
{ value: "off", label: "off", description: "Leave Plan mode" },
|
|
85
68
|
{ value: "tools", label: "tools", description: "Select tools allowed in Plan mode" },
|
|
86
69
|
];
|
|
87
70
|
|
|
88
|
-
const PLAN_MODE_QUESTION_PARAMS = {
|
|
89
|
-
type: "object",
|
|
90
|
-
additionalProperties: false,
|
|
91
|
-
required: ["questions"],
|
|
92
|
-
properties: {
|
|
93
|
-
questions: {
|
|
94
|
-
type: "array",
|
|
95
|
-
minItems: 1,
|
|
96
|
-
maxItems: 3,
|
|
97
|
-
description: "Questions to show the user. Prefer 1 and do not exceed 3.",
|
|
98
|
-
items: {
|
|
99
|
-
type: "object",
|
|
100
|
-
additionalProperties: false,
|
|
101
|
-
required: ["id", "header", "question", "options"],
|
|
102
|
-
properties: {
|
|
103
|
-
id: {
|
|
104
|
-
type: "string",
|
|
105
|
-
description: "Stable identifier for mapping answers (snake_case).",
|
|
106
|
-
},
|
|
107
|
-
header: {
|
|
108
|
-
type: "string",
|
|
109
|
-
description: "Short header label shown in the UI (12 or fewer chars).",
|
|
110
|
-
},
|
|
111
|
-
question: {
|
|
112
|
-
type: "string",
|
|
113
|
-
description: "Single-sentence prompt shown to the user.",
|
|
114
|
-
},
|
|
115
|
-
options: {
|
|
116
|
-
type: "array",
|
|
117
|
-
minItems: 2,
|
|
118
|
-
maxItems: 4,
|
|
119
|
-
description:
|
|
120
|
-
"Provide 2-4 mutually exclusive choices. Put the recommended option first when there is a clear default.",
|
|
121
|
-
items: {
|
|
122
|
-
type: "object",
|
|
123
|
-
additionalProperties: false,
|
|
124
|
-
required: ["label", "description"],
|
|
125
|
-
properties: {
|
|
126
|
-
label: {
|
|
127
|
-
type: "string",
|
|
128
|
-
description: "User-facing label (1-5 words).",
|
|
129
|
-
},
|
|
130
|
-
description: {
|
|
131
|
-
type: "string",
|
|
132
|
-
description: "One short sentence explaining impact/tradeoff if selected.",
|
|
133
|
-
},
|
|
134
|
-
},
|
|
135
|
-
},
|
|
136
|
-
},
|
|
137
|
-
},
|
|
138
|
-
},
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
} as const;
|
|
142
|
-
|
|
143
|
-
const MUTATING_BASH_PATTERNS = [
|
|
144
|
-
/\brm\b/i,
|
|
145
|
-
/\brmdir\b/i,
|
|
146
|
-
/\bmv\b/i,
|
|
147
|
-
/\bcp\b/i,
|
|
148
|
-
/\bmkdir\b/i,
|
|
149
|
-
/\btouch\b/i,
|
|
150
|
-
/\bchmod\b/i,
|
|
151
|
-
/\bchown\b/i,
|
|
152
|
-
/\bchgrp\b/i,
|
|
153
|
-
/\bln\b/i,
|
|
154
|
-
/\btee\b/i,
|
|
155
|
-
/\btruncate\b/i,
|
|
156
|
-
/\bdd\b/i,
|
|
157
|
-
/(^|[^<])>(?!>)/,
|
|
158
|
-
/>>/,
|
|
159
|
-
/\bnpm\s+(install|uninstall|update|ci|link|publish|version)\b/i,
|
|
160
|
-
/\byarn\s+(add|remove|install|publish|upgrade)\b/i,
|
|
161
|
-
/\bpnpm\s+(add|remove|install|publish|update)\b/i,
|
|
162
|
-
/\bbun\s+(add|remove|install|update|publish)\b/i,
|
|
163
|
-
/\bpip\s+(install|uninstall)\b/i,
|
|
164
|
-
/\buv\s+(add|remove|sync|lock|pip\s+install)\b/i,
|
|
165
|
-
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|switch|stash|cherry-pick|revert|tag|init|clone)\b/i,
|
|
166
|
-
/\bsudo\b/i,
|
|
167
|
-
/\bsu\b/i,
|
|
168
|
-
/\bkill\b/i,
|
|
169
|
-
/\bpkill\b/i,
|
|
170
|
-
/\bkillall\b/i,
|
|
171
|
-
/\breboot\b/i,
|
|
172
|
-
/\bshutdown\b/i,
|
|
173
|
-
/\bsystemctl\s+(start|stop|restart|enable|disable)\b/i,
|
|
174
|
-
/\bservice\s+\S+\s+(start|stop|restart)\b/i,
|
|
175
|
-
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
176
|
-
];
|
|
177
|
-
|
|
178
|
-
const SAFE_BASH_PATTERNS = [
|
|
179
|
-
/^\s*(cat|head|tail|less|more|grep|find|ls|pwd|echo|printf|wc|sort|uniq|diff|file|stat|du|df|tree|which|whereis|type|env|printenv|uname|whoami|id|date|uptime|ps|jq|awk|rg|fd|bat|eza)\b/i,
|
|
180
|
-
/^\s*sed\s+-n\b/i,
|
|
181
|
-
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get|ls-files|grep)\b/i,
|
|
182
|
-
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)\b/i,
|
|
183
|
-
/^\s*(node|python|python3|npm|tsc|biome|ruff|ty)\s+--version\b/i,
|
|
184
|
-
];
|
|
185
|
-
|
|
186
71
|
export default function planMode(pi: ExtensionAPI) {
|
|
187
72
|
let state: PlanModeState = { enabled: false, awaitingAction: false };
|
|
188
73
|
let previousTools: string[] | undefined;
|
|
@@ -693,10 +578,6 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
693
578
|
}
|
|
694
579
|
}
|
|
695
580
|
|
|
696
|
-
function isBuiltinTool(tool: ToolInfo) {
|
|
697
|
-
return tool.sourceInfo.source === "builtin";
|
|
698
|
-
}
|
|
699
|
-
|
|
700
581
|
export function completePlanArguments(argumentPrefix: string): CommandArgumentCompletion[] | null {
|
|
701
582
|
const prefix = argumentPrefix.trimStart().toLowerCase();
|
|
702
583
|
if (prefix === "") return [...PLAN_COMMAND_COMPLETIONS];
|
|
@@ -706,11 +587,6 @@ export function completePlanArguments(argumentPrefix: string): CommandArgumentCo
|
|
|
706
587
|
return matches.length > 0 ? [...matches] : null;
|
|
707
588
|
}
|
|
708
589
|
|
|
709
|
-
export function canSelectToolInPlanMode(tool: ToolInfo) {
|
|
710
|
-
if (isBuiltinTool(tool)) return SAFE_BUILTIN_PLAN_TOOLS.has(tool.name);
|
|
711
|
-
return true;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
590
|
function toolNameFromLegacyKey(key: string, tools: ToolInfo[]) {
|
|
715
591
|
const directName = tools.find((tool) => tool.name === key)?.name;
|
|
716
592
|
if (directName) return directName;
|
|
@@ -756,299 +632,6 @@ export function withoutPlanModeQuestionTool(toolNames: string[]) {
|
|
|
756
632
|
return toolNames.filter((toolName) => toolName !== PLAN_MODE_QUESTION_TOOL_NAME);
|
|
757
633
|
}
|
|
758
634
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
export function normalizePlanModeQuestionParams(input: unknown): NormalizePlanModeQuestionParamsResult {
|
|
764
|
-
if (!isRecord(input) || !Array.isArray(input.questions)) {
|
|
765
|
-
return { ok: false, error: "questions must be an array" };
|
|
766
|
-
}
|
|
767
|
-
if (input.questions.length < 1 || input.questions.length > 3) {
|
|
768
|
-
return { ok: false, error: "questions must contain 1-3 items" };
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
const questions: PlanModeQuestion[] = [];
|
|
772
|
-
for (const [questionIndex, rawQuestion] of input.questions.entries()) {
|
|
773
|
-
if (!isRecord(rawQuestion)) {
|
|
774
|
-
return { ok: false, error: `question ${questionIndex + 1} must be an object` };
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
const id = stringField(rawQuestion.id);
|
|
778
|
-
const header = stringField(rawQuestion.header);
|
|
779
|
-
const question = stringField(rawQuestion.question);
|
|
780
|
-
if (!id || !header || !question) {
|
|
781
|
-
return {
|
|
782
|
-
ok: false,
|
|
783
|
-
error: `question ${questionIndex + 1} requires non-empty id, header, and question`,
|
|
784
|
-
};
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
if (!Array.isArray(rawQuestion.options)) {
|
|
788
|
-
return { ok: false, error: `question ${questionIndex + 1} options must be an array` };
|
|
789
|
-
}
|
|
790
|
-
if (rawQuestion.options.length < 2 || rawQuestion.options.length > 4) {
|
|
791
|
-
return { ok: false, error: `question ${questionIndex + 1} options must contain 2-4 items` };
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
const options: PlanModeQuestionOption[] = [];
|
|
795
|
-
for (const [optionIndex, rawOption] of rawQuestion.options.entries()) {
|
|
796
|
-
if (!isRecord(rawOption)) {
|
|
797
|
-
return {
|
|
798
|
-
ok: false,
|
|
799
|
-
error: `question ${questionIndex + 1} option ${optionIndex + 1} must be an object`,
|
|
800
|
-
};
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
const label = stringField(rawOption.label);
|
|
804
|
-
if (!label) {
|
|
805
|
-
return {
|
|
806
|
-
ok: false,
|
|
807
|
-
error: `question ${questionIndex + 1} option ${optionIndex + 1} requires a label`,
|
|
808
|
-
};
|
|
809
|
-
}
|
|
810
|
-
const description = stringField(rawOption.description);
|
|
811
|
-
if (!description) {
|
|
812
|
-
return {
|
|
813
|
-
ok: false,
|
|
814
|
-
error: `question ${questionIndex + 1} option ${optionIndex + 1} requires a description`,
|
|
815
|
-
};
|
|
816
|
-
}
|
|
817
|
-
options.push({ label, description });
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
questions.push({ id, header, question, options });
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
return { ok: true, questions };
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
async function askPlanModeQuestions(
|
|
827
|
-
questions: PlanModeQuestion[],
|
|
828
|
-
ctx: ExtensionContext,
|
|
829
|
-
): Promise<PlanModeQuestionAnswer[] | undefined> {
|
|
830
|
-
const answers: PlanModeQuestionAnswer[] = [];
|
|
831
|
-
for (const question of questions) {
|
|
832
|
-
const choices = question.options.map(formatPlanModeQuestionChoice);
|
|
833
|
-
const otherChoice = `${question.options.length + 1}. Other (free-form)`;
|
|
834
|
-
const choice = await ctx.ui.select(`${question.header}: ${question.question}`, [...choices, otherChoice]);
|
|
835
|
-
if (!choice) return undefined;
|
|
836
|
-
|
|
837
|
-
if (choice === otherChoice) {
|
|
838
|
-
const customAnswer = (await ctx.ui.editor(question.question, ""))?.trim();
|
|
839
|
-
if (!customAnswer) return undefined;
|
|
840
|
-
answers.push({
|
|
841
|
-
id: question.id,
|
|
842
|
-
header: question.header,
|
|
843
|
-
question: question.question,
|
|
844
|
-
answer: customAnswer,
|
|
845
|
-
wasCustom: true,
|
|
846
|
-
});
|
|
847
|
-
continue;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
const optionIndex = choices.indexOf(choice);
|
|
851
|
-
const option = question.options[optionIndex];
|
|
852
|
-
if (!option) return undefined;
|
|
853
|
-
answers.push({
|
|
854
|
-
id: question.id,
|
|
855
|
-
header: question.header,
|
|
856
|
-
question: question.question,
|
|
857
|
-
answer: option.label,
|
|
858
|
-
wasCustom: false,
|
|
859
|
-
optionIndex: optionIndex + 1,
|
|
860
|
-
});
|
|
861
|
-
}
|
|
862
|
-
return answers;
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
function formatPlanModeQuestionChoice(option: PlanModeQuestionOption, index: number) {
|
|
866
|
-
return `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`;
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
function planModeQuestionAnswered(questions: PlanModeQuestion[], answers: PlanModeQuestionAnswer[]) {
|
|
870
|
-
return {
|
|
871
|
-
content: [{ type: "text" as const, text: formatPlanModeQuestionPayload({ cancelled: false, answers }) }],
|
|
872
|
-
details: { cancelled: false, questions, answers } satisfies PlanModeQuestionDetails,
|
|
873
|
-
};
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
function planModeQuestionCancelled(
|
|
877
|
-
questions: PlanModeQuestion[],
|
|
878
|
-
reason: PlanModeQuestionReason,
|
|
879
|
-
message: string,
|
|
880
|
-
) {
|
|
881
|
-
return {
|
|
882
|
-
content: [{ type: "text" as const, text: formatPlanModeQuestionPayload({ cancelled: true, reason, message }) }],
|
|
883
|
-
details: { cancelled: true, reason, questions } satisfies PlanModeQuestionDetails,
|
|
884
|
-
};
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
function formatPlanModeQuestionPayload(payload: {
|
|
888
|
-
cancelled: boolean;
|
|
889
|
-
reason?: PlanModeQuestionReason;
|
|
890
|
-
message?: string;
|
|
891
|
-
answers?: PlanModeQuestionAnswer[];
|
|
892
|
-
}) {
|
|
893
|
-
return JSON.stringify(payload, null, 2);
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
897
|
-
return typeof value === "object" && value !== null;
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
function stringField(value: unknown) {
|
|
901
|
-
return typeof value === "string" ? value.trim() : undefined;
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
function buildPlanModePrompt() {
|
|
905
|
-
return `${PLAN_CONTEXT_MARKER}
|
|
906
|
-
# Plan Mode (Conversational)
|
|
907
|
-
|
|
908
|
-
You are in Plan Mode, a Codex-like collaboration mode for producing a decision-complete implementation plan. Chat your way to the plan before finalizing it. A final plan must leave no implementation decisions unresolved.
|
|
909
|
-
|
|
910
|
-
## Mode rules
|
|
911
|
-
|
|
912
|
-
- Stay in Plan Mode until a developer or extension explicitly exits it.
|
|
913
|
-
- Treat requests to implement as requests to plan the implementation; do not edit files or carry out the plan.
|
|
914
|
-
- Do not use update_plan/TODO tooling in Plan Mode; Plan Mode is conversational planning, not execution progress tracking.
|
|
915
|
-
- Plan Mode manages built-in tool safety only. Non-built-in tools are disabled by default and may be enabled by the user at their own risk.
|
|
916
|
-
- Do not perform mutating actions: no edit/write tools, no patching, no formatting that rewrites files, no dependency installation, no commits, no migrations.
|
|
917
|
-
|
|
918
|
-
## Phase 1 — Ground in the environment
|
|
919
|
-
|
|
920
|
-
- Explore first and ask second. Use non-mutating exploration to read files, search, inspect configuration, run read-only checks, and resolve discoverable facts.
|
|
921
|
-
- Before asking the user any question, perform at least one targeted non-mutating exploration pass unless no local environment or repository is available.
|
|
922
|
-
- Do not ask questions that can be answered from repository or system truth. Ask only when multiple plausible choices remain, a needed identifier/context is missing, or the ambiguity is product intent.
|
|
923
|
-
|
|
924
|
-
## Phase 2 — Intent chat
|
|
925
|
-
|
|
926
|
-
- Keep asking until you can clearly state the goal, success criteria, in/out of scope, constraints, current state, and key preferences/tradeoffs.
|
|
927
|
-
- Bias toward questions over guessing: if a high-impact ambiguity remains, do not produce a proposed plan yet.
|
|
928
|
-
|
|
929
|
-
## Phase 3 — Implementation chat
|
|
930
|
-
|
|
931
|
-
- Once intent is stable, keep asking until the spec is decision-complete: approach, interfaces, data flow, edge cases/failure modes, testing and acceptance criteria, and any migration or compatibility constraints.
|
|
932
|
-
- 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.
|
|
933
|
-
- 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.
|
|
934
|
-
|
|
935
|
-
## Finalization rule
|
|
936
|
-
|
|
937
|
-
Only output the final plan when it is decision-complete and leaves no decisions to the implementer. When presenting the official plan, output exactly one proposed plan block and keep the tags exactly as shown:
|
|
938
|
-
|
|
939
|
-
<proposed_plan>
|
|
940
|
-
# Title
|
|
941
|
-
|
|
942
|
-
## Summary
|
|
943
|
-
...
|
|
944
|
-
|
|
945
|
-
## Key Changes
|
|
946
|
-
...
|
|
947
|
-
|
|
948
|
-
## Test Plan
|
|
949
|
-
...
|
|
950
|
-
|
|
951
|
-
## Assumptions
|
|
952
|
-
...
|
|
953
|
-
</proposed_plan>
|
|
954
|
-
|
|
955
|
-
Keep the proposed plan concise, human and agent digestible, and free of open decisions. Do not ask "should I proceed?" in the final output; the Plan-mode ready menu handles implementation, staying in Plan mode, or exit.`;
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
function readCommand(input: unknown) {
|
|
959
|
-
const command = input as { command?: unknown } | undefined;
|
|
960
|
-
return typeof command?.command === "string" ? command.command : "";
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
export function isSafeCommand(command: string) {
|
|
964
|
-
const trimmed = command.trim();
|
|
965
|
-
if (!trimmed) return false;
|
|
966
|
-
if (MUTATING_BASH_PATTERNS.some((pattern) => pattern.test(trimmed))) return false;
|
|
967
|
-
return SAFE_BASH_PATTERNS.some((pattern) => pattern.test(trimmed));
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
export function extractProposedPlan(text: string) {
|
|
971
|
-
const match = PROPOSED_PLAN_PATTERN.exec(text);
|
|
972
|
-
return match?.[1]?.trim();
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
export function latestAssistantText(messages: unknown) {
|
|
976
|
-
if (!Array.isArray(messages)) return "";
|
|
977
|
-
for (const entry of [...messages].reverse()) {
|
|
978
|
-
const message = (entry as { message?: SessionMessage })?.message ?? (entry as SessionMessage);
|
|
979
|
-
if (message?.role !== "assistant") continue;
|
|
980
|
-
const text = messageText(message);
|
|
981
|
-
if (text) return text;
|
|
982
|
-
}
|
|
983
|
-
return "";
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
function messageContainsLegacyPlanModeContextArtifact(message: unknown) {
|
|
987
|
-
const candidate = unwrapSessionMessage(message);
|
|
988
|
-
return candidate.customType === PLAN_CONTEXT_MESSAGE_TYPE;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
function messageContainsInactivePlanModeArtifact(message: unknown) {
|
|
992
|
-
const candidate = unwrapSessionMessage(message);
|
|
993
|
-
return candidate.customType === PROPOSED_PLAN_MESSAGE_TYPE;
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
export function stripProposedPlanBlocksFromMessage<T>(message: T): T {
|
|
997
|
-
const candidate = unwrapSessionMessage(message);
|
|
998
|
-
if (candidate.role !== "assistant") return message;
|
|
999
|
-
|
|
1000
|
-
const content = stripProposedPlanBlocksFromContent(candidate.content);
|
|
1001
|
-
if (content === candidate.content) return message;
|
|
1002
|
-
|
|
1003
|
-
if (isSessionMessageEntry(message)) {
|
|
1004
|
-
return { ...message, message: { ...candidate, content } };
|
|
1005
|
-
}
|
|
1006
|
-
return { ...candidate, content } as T;
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
function unwrapSessionMessage(message: unknown) {
|
|
1010
|
-
const entry = message as { message?: unknown };
|
|
1011
|
-
return (entry.message ?? message) as { role?: string; customType?: string; content?: unknown };
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
function isSessionMessageEntry<T>(message: T): message is T & { message: SessionMessage } {
|
|
1015
|
-
return typeof message === "object" && message !== null && "message" in message;
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
function stripProposedPlanBlocksFromContent(content: unknown) {
|
|
1019
|
-
if (typeof content === "string") return stripProposedPlanBlocks(content);
|
|
1020
|
-
if (!Array.isArray(content)) return content;
|
|
1021
|
-
|
|
1022
|
-
let changed = false;
|
|
1023
|
-
const nextContent = content.map((block) => {
|
|
1024
|
-
const textBlock = block as TextBlock;
|
|
1025
|
-
if (textBlock.type !== "text" || typeof textBlock.text !== "string") return block;
|
|
1026
|
-
|
|
1027
|
-
const text = stripProposedPlanBlocks(textBlock.text);
|
|
1028
|
-
if (text === textBlock.text) return block;
|
|
1029
|
-
|
|
1030
|
-
changed = true;
|
|
1031
|
-
return { ...textBlock, text };
|
|
1032
|
-
});
|
|
1033
|
-
return changed ? nextContent : content;
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
export function stripProposedPlanBlocks(text: string) {
|
|
1037
|
-
return text.replace(PROPOSED_PLAN_BLOCK_PATTERN, "");
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
function messageText(message: SessionMessage) {
|
|
1041
|
-
return contentText(message.content);
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
function contentText(content: unknown): string {
|
|
1045
|
-
if (typeof content === "string") return content;
|
|
1046
|
-
if (!Array.isArray(content)) return "";
|
|
1047
|
-
return content
|
|
1048
|
-
.map((block) => {
|
|
1049
|
-
const textBlock = block as TextBlock;
|
|
1050
|
-
return textBlock.type === "text" && typeof textBlock.text === "string" ? textBlock.text : "";
|
|
1051
|
-
})
|
|
1052
|
-
.filter(Boolean)
|
|
1053
|
-
.join("\n");
|
|
1054
|
-
}
|
|
635
|
+
export { extractProposedPlan, latestAssistantText, stripProposedPlanBlocks, stripProposedPlanBlocksFromMessage } from "./message-transform.js";
|
|
636
|
+
export { normalizePlanModeQuestionParams } from "./question-tool.js";
|
|
637
|
+
export { canSelectToolInPlanMode, isSafeCommand } from "./tool-policy.js";
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const PLAN_CONTEXT_MARKER = "[CODEX-LIKE PLAN MODE ACTIVE]";
|
|
2
|
+
|
|
3
|
+
export function buildPlanModePrompt() {
|
|
4
|
+
return `${PLAN_CONTEXT_MARKER}
|
|
5
|
+
# Plan Mode (Conversational)
|
|
6
|
+
|
|
7
|
+
You are in Plan Mode, a Codex-like collaboration mode for producing a decision-complete implementation plan. Chat your way to the plan before finalizing it. A final plan must leave no implementation decisions unresolved.
|
|
8
|
+
|
|
9
|
+
## Mode rules
|
|
10
|
+
|
|
11
|
+
- Stay in Plan Mode until a developer or extension explicitly exits it.
|
|
12
|
+
- Treat requests to implement as requests to plan the implementation; do not edit files or carry out the plan.
|
|
13
|
+
- Do not use update_plan/TODO tooling in Plan Mode; Plan Mode is conversational planning, not execution progress tracking.
|
|
14
|
+
- Plan Mode manages built-in tool safety only. Non-built-in tools are disabled by default and may be enabled by the user at their own risk.
|
|
15
|
+
- Do not perform mutating actions: no edit/write tools, no patching, no formatting that rewrites files, no dependency installation, no commits, no migrations.
|
|
16
|
+
|
|
17
|
+
## Phase 1 — Ground in the environment
|
|
18
|
+
|
|
19
|
+
- Explore first and ask second. Use non-mutating exploration to read files, search, inspect configuration, run read-only checks, and resolve discoverable facts.
|
|
20
|
+
- Before asking the user any question, perform at least one targeted non-mutating exploration pass unless no local environment or repository is available.
|
|
21
|
+
- Do not ask questions that can be answered from repository or system truth. Ask only when multiple plausible choices remain, a needed identifier/context is missing, or the ambiguity is product intent.
|
|
22
|
+
|
|
23
|
+
## Phase 2 — Intent chat
|
|
24
|
+
|
|
25
|
+
- Keep asking until you can clearly state the goal, success criteria, in/out of scope, constraints, current state, and key preferences/tradeoffs.
|
|
26
|
+
- Bias toward questions over guessing: if a high-impact ambiguity remains, do not produce a proposed plan yet.
|
|
27
|
+
|
|
28
|
+
## Phase 3 — Implementation chat
|
|
29
|
+
|
|
30
|
+
- Once intent is stable, keep asking until the spec is decision-complete: approach, interfaces, data flow, edge cases/failure modes, testing and acceptance criteria, and any migration or compatibility constraints.
|
|
31
|
+
- 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.
|
|
32
|
+
- 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.
|
|
33
|
+
|
|
34
|
+
## Finalization rule
|
|
35
|
+
|
|
36
|
+
Only output the final plan when it is decision-complete and leaves no decisions to the implementer. When presenting the official plan, output exactly one proposed plan block and keep the tags exactly as shown:
|
|
37
|
+
|
|
38
|
+
<proposed_plan>
|
|
39
|
+
# Title
|
|
40
|
+
|
|
41
|
+
## Summary
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
## Key Changes
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
## Test Plan
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
## Assumptions
|
|
51
|
+
...
|
|
52
|
+
</proposed_plan>
|
|
53
|
+
|
|
54
|
+
Keep the proposed plan concise, human and agent digestible, and free of open decisions. Do not ask "should I proceed?" in the final output; the Plan-mode ready menu handles implementation, staying in Plan mode, or exit.`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const PLAN_MODE_QUESTION_TOOL_NAME = "plan_mode_question";
|
|
4
|
+
|
|
5
|
+
export type PlanModeQuestionOption = {
|
|
6
|
+
label: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type PlanModeQuestion = {
|
|
11
|
+
id: string;
|
|
12
|
+
header: string;
|
|
13
|
+
question: string;
|
|
14
|
+
options: PlanModeQuestionOption[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type PlanModeQuestionAnswer = {
|
|
18
|
+
id: string;
|
|
19
|
+
header: string;
|
|
20
|
+
question: string;
|
|
21
|
+
answer: string;
|
|
22
|
+
wasCustom: boolean;
|
|
23
|
+
optionIndex?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type PlanModeQuestionReason = "cancelled" | "ui_unavailable" | "plan_mode_inactive" | "invalid_input";
|
|
27
|
+
|
|
28
|
+
type PlanModeQuestionDetails = {
|
|
29
|
+
cancelled: boolean;
|
|
30
|
+
reason?: PlanModeQuestionReason;
|
|
31
|
+
questions: PlanModeQuestion[];
|
|
32
|
+
answers?: PlanModeQuestionAnswer[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const PLAN_MODE_QUESTION_PARAMS = {
|
|
36
|
+
type: "object",
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
required: ["questions"],
|
|
39
|
+
properties: {
|
|
40
|
+
questions: {
|
|
41
|
+
type: "array",
|
|
42
|
+
minItems: 1,
|
|
43
|
+
maxItems: 3,
|
|
44
|
+
description: "Questions to show the user. Prefer 1 and do not exceed 3.",
|
|
45
|
+
items: {
|
|
46
|
+
type: "object",
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
required: ["id", "header", "question", "options"],
|
|
49
|
+
properties: {
|
|
50
|
+
id: { type: "string", description: "Stable identifier for mapping answers (snake_case)." },
|
|
51
|
+
header: { type: "string", description: "Short header label shown in the UI (12 or fewer chars)." },
|
|
52
|
+
question: { type: "string", description: "Single-sentence prompt shown to the user." },
|
|
53
|
+
options: {
|
|
54
|
+
type: "array",
|
|
55
|
+
minItems: 2,
|
|
56
|
+
maxItems: 4,
|
|
57
|
+
description:
|
|
58
|
+
"Provide 2-4 mutually exclusive choices. Put the recommended option first when there is a clear default.",
|
|
59
|
+
items: {
|
|
60
|
+
type: "object",
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
required: ["label", "description"],
|
|
63
|
+
properties: {
|
|
64
|
+
label: { type: "string", description: "User-facing label (1-5 words)." },
|
|
65
|
+
description: {
|
|
66
|
+
type: "string",
|
|
67
|
+
description: "One short sentence explaining impact/tradeoff if selected.",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
type NormalizePlanModeQuestionParamsResult =
|
|
79
|
+
| { ok: true; questions: PlanModeQuestion[] }
|
|
80
|
+
| { ok: false; error: string };
|
|
81
|
+
|
|
82
|
+
export function normalizePlanModeQuestionParams(input: unknown): NormalizePlanModeQuestionParamsResult {
|
|
83
|
+
if (!isRecord(input) || !Array.isArray(input.questions)) {
|
|
84
|
+
return { ok: false, error: "questions must be an array" };
|
|
85
|
+
}
|
|
86
|
+
if (input.questions.length < 1 || input.questions.length > 3) {
|
|
87
|
+
return { ok: false, error: "questions must contain 1-3 items" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const questions: PlanModeQuestion[] = [];
|
|
91
|
+
for (const [questionIndex, rawQuestion] of input.questions.entries()) {
|
|
92
|
+
if (!isRecord(rawQuestion)) {
|
|
93
|
+
return { ok: false, error: `question ${questionIndex + 1} must be an object` };
|
|
94
|
+
}
|
|
95
|
+
const id = stringField(rawQuestion.id);
|
|
96
|
+
const header = stringField(rawQuestion.header);
|
|
97
|
+
const question = stringField(rawQuestion.question);
|
|
98
|
+
if (!id || !header || !question) {
|
|
99
|
+
return { ok: false, error: `question ${questionIndex + 1} requires non-empty id, header, and question` };
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(rawQuestion.options)) {
|
|
102
|
+
return { ok: false, error: `question ${questionIndex + 1} options must be an array` };
|
|
103
|
+
}
|
|
104
|
+
if (rawQuestion.options.length < 2 || rawQuestion.options.length > 4) {
|
|
105
|
+
return { ok: false, error: `question ${questionIndex + 1} options must contain 2-4 items` };
|
|
106
|
+
}
|
|
107
|
+
const options: PlanModeQuestionOption[] = [];
|
|
108
|
+
for (const [optionIndex, rawOption] of rawQuestion.options.entries()) {
|
|
109
|
+
if (!isRecord(rawOption)) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
error: `question ${questionIndex + 1} option ${optionIndex + 1} must be an object`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const label = stringField(rawOption.label);
|
|
116
|
+
if (!label) {
|
|
117
|
+
return { ok: false, error: `question ${questionIndex + 1} option ${optionIndex + 1} requires a label` };
|
|
118
|
+
}
|
|
119
|
+
const description = stringField(rawOption.description);
|
|
120
|
+
if (!description) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
error: `question ${questionIndex + 1} option ${optionIndex + 1} requires a description`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
options.push({ label, description });
|
|
127
|
+
}
|
|
128
|
+
questions.push({ id, header, question, options });
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, questions };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function askPlanModeQuestions(
|
|
134
|
+
questions: PlanModeQuestion[],
|
|
135
|
+
ctx: ExtensionContext,
|
|
136
|
+
): Promise<PlanModeQuestionAnswer[] | undefined> {
|
|
137
|
+
const answers: PlanModeQuestionAnswer[] = [];
|
|
138
|
+
for (const question of questions) {
|
|
139
|
+
const choices = question.options.map(formatPlanModeQuestionChoice);
|
|
140
|
+
const otherChoice = `${question.options.length + 1}. Other (free-form)`;
|
|
141
|
+
const choice = await ctx.ui.select(`${question.header}: ${question.question}`, [...choices, otherChoice]);
|
|
142
|
+
if (!choice) return undefined;
|
|
143
|
+
if (choice === otherChoice) {
|
|
144
|
+
const customAnswer = (await ctx.ui.editor(question.question, ""))?.trim();
|
|
145
|
+
if (!customAnswer) return undefined;
|
|
146
|
+
answers.push({
|
|
147
|
+
id: question.id,
|
|
148
|
+
header: question.header,
|
|
149
|
+
question: question.question,
|
|
150
|
+
answer: customAnswer,
|
|
151
|
+
wasCustom: true,
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const optionIndex = choices.indexOf(choice);
|
|
156
|
+
const option = question.options[optionIndex];
|
|
157
|
+
if (!option) return undefined;
|
|
158
|
+
answers.push({
|
|
159
|
+
id: question.id,
|
|
160
|
+
header: question.header,
|
|
161
|
+
question: question.question,
|
|
162
|
+
answer: option.label,
|
|
163
|
+
wasCustom: false,
|
|
164
|
+
optionIndex: optionIndex + 1,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return answers;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function formatPlanModeQuestionChoice(option: PlanModeQuestionOption, index: number) {
|
|
171
|
+
return `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function planModeQuestionAnswered(questions: PlanModeQuestion[], answers: PlanModeQuestionAnswer[]) {
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text" as const, text: formatPlanModeQuestionPayload({ cancelled: false, answers }) }],
|
|
177
|
+
details: { cancelled: false, questions, answers } satisfies PlanModeQuestionDetails,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function planModeQuestionCancelled(
|
|
182
|
+
questions: PlanModeQuestion[],
|
|
183
|
+
reason: PlanModeQuestionReason,
|
|
184
|
+
message: string,
|
|
185
|
+
) {
|
|
186
|
+
return {
|
|
187
|
+
content: [{ type: "text" as const, text: formatPlanModeQuestionPayload({ cancelled: true, reason, message }) }],
|
|
188
|
+
details: { cancelled: true, reason, questions } satisfies PlanModeQuestionDetails,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function formatPlanModeQuestionPayload(payload: {
|
|
193
|
+
cancelled: boolean;
|
|
194
|
+
reason?: PlanModeQuestionReason;
|
|
195
|
+
message?: string;
|
|
196
|
+
answers?: PlanModeQuestionAnswer[];
|
|
197
|
+
}) {
|
|
198
|
+
return JSON.stringify(payload, null, 2);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
202
|
+
return typeof value === "object" && value !== null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function stringField(value: unknown) {
|
|
206
|
+
return typeof value === "string" ? value.trim() : undefined;
|
|
207
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const SAFE_BUILTIN_PLAN_TOOLS = new Set(["read", "bash", "grep", "find", "ls"]);
|
|
4
|
+
|
|
5
|
+
const MUTATING_BASH_PATTERNS = [
|
|
6
|
+
/\brm\b/i,
|
|
7
|
+
/\brmdir\b/i,
|
|
8
|
+
/\bmv\b/i,
|
|
9
|
+
/\bcp\b/i,
|
|
10
|
+
/\bmkdir\b/i,
|
|
11
|
+
/\btouch\b/i,
|
|
12
|
+
/\bchmod\b/i,
|
|
13
|
+
/\bchown\b/i,
|
|
14
|
+
/\bchgrp\b/i,
|
|
15
|
+
/\bln\b/i,
|
|
16
|
+
/\btee\b/i,
|
|
17
|
+
/\btruncate\b/i,
|
|
18
|
+
/\bdd\b/i,
|
|
19
|
+
/(^|[^<])>(?!>)/,
|
|
20
|
+
/>>/,
|
|
21
|
+
/\bnpm\s+(install|uninstall|update|ci|link|publish|version)\b/i,
|
|
22
|
+
/\byarn\s+(add|remove|install|publish|upgrade)\b/i,
|
|
23
|
+
/\bpnpm\s+(add|remove|install|publish|update)\b/i,
|
|
24
|
+
/\bbun\s+(add|remove|install|update|publish)\b/i,
|
|
25
|
+
/\bpip\s+(install|uninstall)\b/i,
|
|
26
|
+
/\buv\s+(add|remove|sync|lock|pip\s+install)\b/i,
|
|
27
|
+
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|switch|stash|cherry-pick|revert|tag|init|clone)\b/i,
|
|
28
|
+
/\bsudo\b/i,
|
|
29
|
+
/\bsu\b/i,
|
|
30
|
+
/\bkill\b/i,
|
|
31
|
+
/\bpkill\b/i,
|
|
32
|
+
/\bkillall\b/i,
|
|
33
|
+
/\breboot\b/i,
|
|
34
|
+
/\bshutdown\b/i,
|
|
35
|
+
/\bsystemctl\s+(start|stop|restart|enable|disable)\b/i,
|
|
36
|
+
/\bservice\s+\S+\s+(start|stop|restart)\b/i,
|
|
37
|
+
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const SAFE_BASH_PATTERNS = [
|
|
41
|
+
/^\s*(cat|head|tail|less|more|grep|find|ls|pwd|echo|printf|wc|sort|uniq|diff|file|stat|du|df|tree|which|whereis|type|env|printenv|uname|whoami|id|date|uptime|ps|jq|awk|rg|fd|bat|eza)\b/i,
|
|
42
|
+
/^\s*sed\s+-n\b/i,
|
|
43
|
+
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get|ls-files|grep)\b/i,
|
|
44
|
+
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)\b/i,
|
|
45
|
+
/^\s*(node|python|python3|npm|tsc|biome|ruff|ty)\s+--version\b/i,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export function isBuiltinTool(tool: ToolInfo) {
|
|
49
|
+
return tool.sourceInfo.source === "builtin";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function canSelectToolInPlanMode(tool: ToolInfo) {
|
|
53
|
+
if (isBuiltinTool(tool)) return SAFE_BUILTIN_PLAN_TOOLS.has(tool.name);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readCommand(input: unknown) {
|
|
58
|
+
const command = input as { command?: unknown } | undefined;
|
|
59
|
+
return typeof command?.command === "string" ? command.command : "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isSafeCommand(command: string) {
|
|
63
|
+
const trimmed = command.trim();
|
|
64
|
+
if (!trimmed) return false;
|
|
65
|
+
if (MUTATING_BASH_PATTERNS.some((pattern) => pattern.test(trimmed))) return false;
|
|
66
|
+
return SAFE_BASH_PATTERNS.some((pattern) => pattern.test(trimmed));
|
|
67
|
+
}
|