@narumitw/pi-plan-mode 0.20.0 → 0.27.0
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 +13 -1
- package/package.json +1 -1
- package/src/message-transform.ts +16 -9
- package/src/plan-mode.ts +17 -1
- package/src/settings.ts +6 -0
- package/src/subagent-policy.ts +98 -0
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ Pi core intentionally does not ship a built-in plan mode; this package provides
|
|
|
12
12
|
- Adds `--plan` to start a session in Plan mode.
|
|
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
|
+
- Optionally limits `subagent` and `subagent_spawn` launches to named planning roles while Plan mode is active.
|
|
15
16
|
- Blocks `update_plan`, mutating built-in tools, and unsafe `bash` forms such as writes, substitutions, background jobs, dependency installs, and mutating Git commands.
|
|
16
17
|
- 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
18
|
- Adds required `plan_mode_question` and `plan_mode_complete` tools for structured questions and completion.
|
|
@@ -90,7 +91,8 @@ Create `$PI_CODING_AGENT_DIR/pi-plan-mode.json` (normally `~/.pi/agent/pi-plan-m
|
|
|
90
91
|
```json
|
|
91
92
|
{
|
|
92
93
|
"thinkingLevel": "inherit",
|
|
93
|
-
"defaultPlanTools": ["read", "bash", "grep", "find", "ls"],
|
|
94
|
+
"defaultPlanTools": ["read", "bash", "grep", "find", "ls", "subagent"],
|
|
95
|
+
"allowedPlanSubagents": ["plan-scout", "plan-researcher", "plan-reviewer"],
|
|
94
96
|
"safeSubcommands": {
|
|
95
97
|
"git": ["status", "log", "rev-parse", "blame"],
|
|
96
98
|
"gh": ["pr view", "pr list", "issue view", "issue list"]
|
|
@@ -106,6 +108,16 @@ Tool names must be non-empty strings; duplicates are removed in first-seen order
|
|
|
106
108
|
|
|
107
109
|
A selection made with `/plan tools` is stored in that Pi session and takes precedence over `defaultPlanTools` when the session resumes. The global setting remains the baseline for fresh sessions and sessions without an explicit selection.
|
|
108
110
|
|
|
111
|
+
### Allowed Plan subagents
|
|
112
|
+
|
|
113
|
+
`allowedPlanSubagents` is an optional, exact, case-sensitive allowlist of role names for subagent launches while Plan mode is active. It does not activate delegation tools by itself: add `subagent` or `subagent_spawn` to `defaultPlanTools`, or select the tool through `/plan tools`. The same role policy applies whichever activation path is used.
|
|
114
|
+
|
|
115
|
+
When configured, Plan mode checks every role in blocking `subagent` single, parallel, chain, and fan-in calls (`agent`, `tasks[].agent`, `chain[].agent`, and `aggregator.agent`) and the top-level `agent` in `subagent_spawn`. A call containing any disallowed role is rejected as a whole before the launch tool executes. Covered calls whose role fields cannot be verified are also rejected so a malformed launch cannot bypass the policy.
|
|
116
|
+
|
|
117
|
+
Omit `allowedPlanSubagents` to preserve the unrestricted role behavior for explicitly enabled delegation tools. Set it to `[]` to reject every covered launch in Plan mode. Names must be non-empty strings; duplicates are removed in first-seen order. A wrong type or an empty, whitespace-only, or non-string entry invalidates the settings file and triggers the normal warning/default fallback. Settings reload on `session_start`; removing the property removes the role restriction. Non-Plan sessions are never restricted. If no extension has registered the covered tool names, the setting is inert, so `pi-plan-mode` and `pi-subagents` remain independently installable.
|
|
118
|
+
|
|
119
|
+
This is a name check, not capability enforcement or a sandbox. `pi-plan-mode` does not inspect an allowed role's effective tools, permissions, or source. User or project definitions can change or override the meaning of an allowed name, so keep every allowed role independently read-only and retain normal project-agent trust and confirmation controls. The policy does not resolve retained agent IDs used by `subagent_send`; enabling follow-up lifecycle tools remains a separate user-risk decision.
|
|
120
|
+
|
|
109
121
|
### Safe shell subcommands
|
|
110
122
|
|
|
111
123
|
`safeSubcommands` adds reviewed command validators to limited `bash`; it is not a raw shell allowlist. Only the following exact values are accepted:
|
package/package.json
CHANGED
package/src/message-transform.ts
CHANGED
|
@@ -2,8 +2,12 @@ import { PLAN_MODE_COMPLETE_TOOL_NAME } from "./completion-tool.js";
|
|
|
2
2
|
|
|
3
3
|
const PLAN_CONTEXT_MESSAGE_TYPE = "plan-mode-context";
|
|
4
4
|
const PROPOSED_PLAN_MESSAGE_TYPE = "proposed-plan";
|
|
5
|
-
const
|
|
6
|
-
|
|
5
|
+
const PLAN_IMPLEMENTATION_HANDOFF_PREFIX =
|
|
6
|
+
"Plan mode is now disabled. Full tool access is restored. Implement this proposed plan now:";
|
|
7
|
+
const PROPOSED_PLAN_PATTERN =
|
|
8
|
+
/^<proposed_plan>[\t ]*\r?\n([\s\S]*?)\r?\n<\/proposed_plan>[\t ]*$/gm;
|
|
9
|
+
const PROPOSED_PLAN_BLOCK_PATTERN =
|
|
10
|
+
/^<proposed_plan>[\t ]*\r?\n[\s\S]*?\r?\n<\/proposed_plan>[\t ]*$/gm;
|
|
7
11
|
|
|
8
12
|
export type ProposedPlanParseResult =
|
|
9
13
|
| { kind: "absent" }
|
|
@@ -65,6 +69,14 @@ export function messageContainsInactivePlanModeArtifact(message: unknown) {
|
|
|
65
69
|
);
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
export function messageContainsPlanModeImplementationHandoff(message: unknown) {
|
|
73
|
+
const candidate = unwrapSessionMessage(message);
|
|
74
|
+
return (
|
|
75
|
+
candidate.role === "user" &&
|
|
76
|
+
contentText(candidate.content).trimStart().startsWith(PLAN_IMPLEMENTATION_HANDOFF_PREFIX)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
68
80
|
export function stripProposedPlanBlocksFromMessage<T>(message: T): T {
|
|
69
81
|
return replaceAssistantContent(message, stripProposedPlanBlocksFromContent);
|
|
70
82
|
}
|
|
@@ -74,9 +86,7 @@ export function stripPlanModeCompletionCallsFromMessage<T>(message: T): T {
|
|
|
74
86
|
if (!Array.isArray(content)) return content;
|
|
75
87
|
const nextContent = content.filter((block) => {
|
|
76
88
|
const candidate = block as { type?: string; name?: string };
|
|
77
|
-
return !(
|
|
78
|
-
candidate.type === "toolCall" && candidate.name === PLAN_MODE_COMPLETE_TOOL_NAME
|
|
79
|
-
);
|
|
89
|
+
return !(candidate.type === "toolCall" && candidate.name === PLAN_MODE_COMPLETE_TOOL_NAME);
|
|
80
90
|
});
|
|
81
91
|
return nextContent.length === content.length ? content : nextContent;
|
|
82
92
|
});
|
|
@@ -91,10 +101,7 @@ export function isEmptyAssistantMessage(message: unknown) {
|
|
|
91
101
|
);
|
|
92
102
|
}
|
|
93
103
|
|
|
94
|
-
function replaceAssistantContent<T>(
|
|
95
|
-
message: T,
|
|
96
|
-
transform: (content: unknown) => unknown,
|
|
97
|
-
): T {
|
|
104
|
+
function replaceAssistantContent<T>(message: T, transform: (content: unknown) => unknown): T {
|
|
98
105
|
const candidate = unwrapSessionMessage(message);
|
|
99
106
|
if (candidate.role !== "assistant") return message;
|
|
100
107
|
|
package/src/plan-mode.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
latestAssistantText,
|
|
11
11
|
messageContainsInactivePlanModeArtifact,
|
|
12
12
|
messageContainsLegacyPlanModeContextArtifact,
|
|
13
|
+
messageContainsPlanModeImplementationHandoff,
|
|
13
14
|
parseProposedPlan,
|
|
14
15
|
stripPlanModeCompletionCallsFromMessage,
|
|
15
16
|
stripProposedPlanBlocksFromMessage,
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
readPlanModeSettings,
|
|
32
33
|
} from "./settings.js";
|
|
33
34
|
import { type PlanCompletionSource, type PlanModeState, restorePlanModeState } from "./state.js";
|
|
35
|
+
import { enforcePlanSubagentAllowlist } from "./subagent-policy.js";
|
|
34
36
|
import {
|
|
35
37
|
canSelectToolInPlanMode,
|
|
36
38
|
classifyPlanModeTool,
|
|
@@ -263,6 +265,14 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
263
265
|
"Plan mode blocks update_plan because it tracks execution progress rather than conversational planning.",
|
|
264
266
|
};
|
|
265
267
|
}
|
|
268
|
+
if (settings.allowedPlanSubagents !== undefined) {
|
|
269
|
+
const blocked = enforcePlanSubagentAllowlist(
|
|
270
|
+
event.toolName,
|
|
271
|
+
event.input,
|
|
272
|
+
settings.allowedPlanSubagents,
|
|
273
|
+
);
|
|
274
|
+
if (blocked) return blocked;
|
|
275
|
+
}
|
|
266
276
|
const calledTool = toolByName(event.toolName);
|
|
267
277
|
if (calledTool && classifyPlanModeTool(calledTool) === "blocked") {
|
|
268
278
|
return {
|
|
@@ -292,7 +302,13 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
292
302
|
const messagesWithoutLegacyPlanContext = event.messages.filter(
|
|
293
303
|
(message: unknown) => !messageContainsLegacyPlanModeContextArtifact(message),
|
|
294
304
|
);
|
|
295
|
-
if (state.enabled)
|
|
305
|
+
if (state.enabled) {
|
|
306
|
+
return {
|
|
307
|
+
messages: messagesWithoutLegacyPlanContext.filter(
|
|
308
|
+
(message: unknown) => !messageContainsPlanModeImplementationHandoff(message),
|
|
309
|
+
),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
296
312
|
return {
|
|
297
313
|
messages: messagesWithoutLegacyPlanContext
|
|
298
314
|
.filter((message: unknown) => !messageContainsInactivePlanModeArtifact(message))
|
package/src/settings.ts
CHANGED
|
@@ -28,6 +28,7 @@ export type PlanModeFixedThinkingLevel = Exclude<PlanModeThinkingLevel, "inherit
|
|
|
28
28
|
export interface PlanModeSettings {
|
|
29
29
|
thinkingLevel: PlanModeThinkingLevel;
|
|
30
30
|
defaultPlanTools?: string[];
|
|
31
|
+
allowedPlanSubagents?: string[];
|
|
31
32
|
safeSubcommands?: SafeSubcommands;
|
|
32
33
|
}
|
|
33
34
|
export type PlanModeSettingsLoadResult =
|
|
@@ -51,6 +52,11 @@ export function normalizePlanModeSettings(value: unknown): PlanModeSettings | un
|
|
|
51
52
|
if (!defaultPlanTools) return undefined;
|
|
52
53
|
settings.defaultPlanTools = defaultPlanTools;
|
|
53
54
|
}
|
|
55
|
+
if (Object.hasOwn(value, "allowedPlanSubagents")) {
|
|
56
|
+
const allowedPlanSubagents = normalizeToolNames(Reflect.get(value, "allowedPlanSubagents"));
|
|
57
|
+
if (!allowedPlanSubagents) return undefined;
|
|
58
|
+
settings.allowedPlanSubagents = allowedPlanSubagents;
|
|
59
|
+
}
|
|
54
60
|
if (Object.hasOwn(value, "safeSubcommands")) {
|
|
55
61
|
const safeSubcommands = normalizeSafeSubcommands(Reflect.get(value, "safeSubcommands"));
|
|
56
62
|
if (!safeSubcommands) return undefined;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export interface PlanSubagentPolicyBlock {
|
|
2
|
+
block: true;
|
|
3
|
+
reason: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const COVERED_SUBAGENT_TOOLS = new Set(["subagent", "subagent_spawn"]);
|
|
7
|
+
|
|
8
|
+
export function enforcePlanSubagentAllowlist(
|
|
9
|
+
toolName: string,
|
|
10
|
+
input: unknown,
|
|
11
|
+
allowedRoleNames: readonly string[],
|
|
12
|
+
): PlanSubagentPolicyBlock | undefined {
|
|
13
|
+
if (!COVERED_SUBAGENT_TOOLS.has(toolName)) return undefined;
|
|
14
|
+
|
|
15
|
+
const requestedRoleNames =
|
|
16
|
+
toolName === "subagent_spawn" ? readSpawnRoleNames(input) : readBlockingRoleNames(input);
|
|
17
|
+
const allowedNames = unique(allowedRoleNames);
|
|
18
|
+
if (!requestedRoleNames) {
|
|
19
|
+
return {
|
|
20
|
+
block: true,
|
|
21
|
+
reason: `Plan mode could not verify subagent roles for tool '${toolName}'. ${formatAllowedRoles(allowedNames)}`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const allowed = new Set(allowedNames);
|
|
26
|
+
const disallowedNames = unique(requestedRoleNames).filter((name) => !allowed.has(name));
|
|
27
|
+
if (disallowedNames.length === 0) return undefined;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
block: true,
|
|
31
|
+
reason: `Plan mode blocks subagent role(s): ${disallowedNames.join(", ")}. ${formatAllowedRoles(allowedNames)}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readSpawnRoleNames(input: unknown) {
|
|
36
|
+
if (!isRecord(input)) return undefined;
|
|
37
|
+
const agent = readRoleName(input.agent);
|
|
38
|
+
return agent ? [agent] : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readBlockingRoleNames(input: unknown) {
|
|
42
|
+
if (!isRecord(input)) return undefined;
|
|
43
|
+
const roleNames: string[] = [];
|
|
44
|
+
let hasRoleField = false;
|
|
45
|
+
|
|
46
|
+
if (Object.hasOwn(input, "agent")) {
|
|
47
|
+
hasRoleField = true;
|
|
48
|
+
const agent = readRoleName(input.agent);
|
|
49
|
+
if (!agent) return undefined;
|
|
50
|
+
roleNames.push(agent);
|
|
51
|
+
}
|
|
52
|
+
for (const field of ["tasks", "chain"] as const) {
|
|
53
|
+
if (!Object.hasOwn(input, field)) continue;
|
|
54
|
+
hasRoleField = true;
|
|
55
|
+
const agents = readRoleArray(input[field]);
|
|
56
|
+
if (!agents) return undefined;
|
|
57
|
+
roleNames.push(...agents);
|
|
58
|
+
}
|
|
59
|
+
if (Object.hasOwn(input, "aggregator")) {
|
|
60
|
+
hasRoleField = true;
|
|
61
|
+
if (!isRecord(input.aggregator)) return undefined;
|
|
62
|
+
const agent = readRoleName(input.aggregator.agent);
|
|
63
|
+
if (!agent) return undefined;
|
|
64
|
+
roleNames.push(agent);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return hasRoleField && roleNames.length > 0 ? roleNames : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readRoleArray(value: unknown) {
|
|
71
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
72
|
+
const roleNames: string[] = [];
|
|
73
|
+
for (const item of value) {
|
|
74
|
+
if (!isRecord(item)) return undefined;
|
|
75
|
+
const agent = readRoleName(item.agent);
|
|
76
|
+
if (!agent) return undefined;
|
|
77
|
+
roleNames.push(agent);
|
|
78
|
+
}
|
|
79
|
+
return roleNames;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readRoleName(value: unknown) {
|
|
83
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
87
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatAllowedRoles(allowedRoleNames: readonly string[]) {
|
|
91
|
+
return allowedRoleNames.length > 0
|
|
92
|
+
? `Allowed Plan subagents: ${allowedRoleNames.join(", ")}.`
|
|
93
|
+
: "No subagent roles are allowed in Plan mode.";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function unique(values: readonly string[]) {
|
|
97
|
+
return Array.from(new Set(values));
|
|
98
|
+
}
|