@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-plan-mode",
3
- "version": "0.20.0",
3
+ "version": "0.27.0",
4
4
  "description": "Pi extension that adds a Codex-like read-only /plan collaboration mode.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 PROPOSED_PLAN_PATTERN = /^<proposed_plan>[\t ]*\r?\n([\s\S]*?)\r?\n<\/proposed_plan>[\t ]*$/gm;
6
- const PROPOSED_PLAN_BLOCK_PATTERN = /^<proposed_plan>[\t ]*\r?\n[\s\S]*?\r?\n<\/proposed_plan>[\t ]*$/gm;
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) return { messages: messagesWithoutLegacyPlanContext };
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
+ }