@narumitw/pi-plan-mode 0.24.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.24.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",
package/src/plan-mode.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  readPlanModeSettings,
33
33
  } from "./settings.js";
34
34
  import { type PlanCompletionSource, type PlanModeState, restorePlanModeState } from "./state.js";
35
+ import { enforcePlanSubagentAllowlist } from "./subagent-policy.js";
35
36
  import {
36
37
  canSelectToolInPlanMode,
37
38
  classifyPlanModeTool,
@@ -264,6 +265,14 @@ export default function planMode(pi: ExtensionAPI) {
264
265
  "Plan mode blocks update_plan because it tracks execution progress rather than conversational planning.",
265
266
  };
266
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
+ }
267
276
  const calledTool = toolByName(event.toolName);
268
277
  if (calledTool && classifyPlanModeTool(calledTool) === "blocked") {
269
278
  return {
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
+ }