@gotgenes/pi-permission-system 36.2.1 → 37.0.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/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [37.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v36.2.1...pi-permission-system-v37.0.0) (2026-10-02)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** **breaking:** require Pi 1.0.0 or later ([9c76ae3](https://github.com/gotgenes/pi-packages/commit/9c76ae30dd58b0a94524f753f7c72ff356231294)), closes [#970](https://github.com/gotgenes/pi-packages/issues/970)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **pi-permission-system:** keep prompt sections other extensions add, such as <mcp_servers> ([1abdfe4](https://github.com/gotgenes/pi-packages/commit/1abdfe4901a27cb8ff3d85a965fc07b3be874624)), closes [#999](https://github.com/gotgenes/pi-packages/issues/999)
18
+ * **pi-permission-system:** keep later prompt sections when a skill is denied ([dafd83b](https://github.com/gotgenes/pi-packages/commit/dafd83b075b01ef39335822f8b5122c56ee00b97)), closes [#999](https://github.com/gotgenes/pi-packages/issues/999)
19
+
20
+ ### Documentation
21
+
22
+ * **pi-permission-system:** note the Pi 1.0.0 requirement under Upgrading ([5bc932c](https://github.com/gotgenes/pi-packages/commit/5bc932cf9936728c0e3c551fdbea6a7009108958)), closes [#970](https://github.com/gotgenes/pi-packages/issues/970)
23
+ * **pi-permission-system:** record prompt changes through systemPromptOptions ([b11051f](https://github.com/gotgenes/pi-packages/commit/b11051f1365a635b67e70dea126c8a49ff9ef31b)), closes [#999](https://github.com/gotgenes/pi-packages/issues/999)
24
+
8
25
  ## [36.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v36.2.0...pi-permission-system-v36.2.1) (2026-10-01)
9
26
 
10
27
 
package/README.md CHANGED
@@ -155,6 +155,12 @@ Third-party packages are maintained by their authors; review one before granting
155
155
 
156
156
  ## Upgrading
157
157
 
158
+ ### The major after 36.x — requires Pi 1.0.0
159
+
160
+ The extension now requires `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` 1.0.0 or later.
161
+ Upgrade Pi to 1.0.0 before upgrading this package; on an older Pi, stay on the 36.x line.
162
+ No configuration change is needed.
163
+
158
164
  ### 22.0.0 — project config requires project trust
159
165
 
160
166
  Project-scoped configuration (the project `config.json` and project-agent frontmatter — both permission policy and runtime knobs such as `yoloMode`) is now loaded only when Pi reports the project as trusted.
@@ -1314,11 +1314,11 @@ permission:
1314
1314
 
1315
1315
  The extension integrates via Pi's lifecycle hooks:
1316
1316
 
1317
- | Hook | Behavior |
1318
- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1319
- | `before_agent_start` | Filters the active tool set (restrict-only), restates the tool list and guidelines at the end of the system prompt to match (except under a custom system prompt outside a subagent), and hides denied skills |
1320
- | `tool_call` | Enforces permissions for every tool invocation |
1321
- | `input` | Intercepts `/skill:<name>` requests and enforces skill policy |
1317
+ | Hook | Behavior |
1318
+ | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1319
+ | `before_agent_start` | Filters the active tool set (restrict-only), states a subagent child's own tool list and rules, and hides denied skills, all through pi's prompt options rather than a rewritten prompt |
1320
+ | `tool_call` | Enforces permissions for every tool invocation |
1321
+ | `input` | Intercepts `/skill:<name>` requests and enforces skill policy |
1322
1322
 
1323
1323
  Additional behaviors:
1324
1324
 
@@ -1326,16 +1326,18 @@ Additional behaviors:
1326
1326
  - Tool filtering is restrict-only: the active set starts from pi's already-active tools (`pi.getActiveTools()`) and only ever has denied tools removed — the permission system never activates a tool pi left off by default (e.g. `find`, `grep`, `ls`)
1327
1327
  - Policy is applied to the tool surface pi has activated over the session, not to the previous turn's filtered result, so removing a `deny` rule restores the tool it had hidden without restarting pi.
1328
1328
  A tool that stops being active for any other reason (another extension deactivating it, pi unregistering it) is not restored.
1329
- - On the turn a tool is restored, it is callable immediately but its `Available tools:` line reappears one turn later: pi builds the prompt parts an extension receives before the extension runs, so the restored tool has no one-line description to render until it is already active
1330
1329
  - A tool is removed only when every value under its surface resolves to `deny`; a surface with any reachable `allow` or `ask` pattern stays available (see [Tool Surfaces](#tool-surfaces))
1331
- - The tool list and guidelines are **relocated** rather than edited in place: the copies pi wrote are removed, and this session's own are rendered at the end of the system prompt, after the working directory pi states last.
1332
- They take the shape pi writes them in: `Available tools:` and `Guidelines:` sections after a `Current working directory:` footer through pi 0.85, and `<tools>` and `<rules>` sections after a `<cwd>` section from pi 0.86.
1333
- Each session states its own tool surface, which is what keeps a subagent child's inherited prompt byte-identical to its parent's (see [ADR 0014](decisions/0014-tool-surface-is-node-local-prose.md)); the tool list moves to the end of the prompt for every session on pi's default prompt, whether or not anything is denied.
1334
- A custom system prompt (`.pi/SYSTEM.md`, `~/.pi/agent/SYSTEM.md`, `--system-prompt`) is left exactly as pi built it: pi writes no tool list or rules under one, and this extension adds none either, so the prompt describes tools only if you wrote that yourself.
1330
+ - Every change this extension makes to the system prompt goes through pi's `systemPromptOptions`; it never returns a rewritten prompt.
1331
+ A returned prompt is sent as the whole prompt, which would drop the sections other extensions add after this one runs, such as pi's `<mcp_servers>` list of `codemode` and `deferred` MCP servers (see [ADR 0015](decisions/0015-prompt-changes-through-system-prompt-options.md)).
1332
+ - On pi's default prompt, the tool list and guidelines stay where pi writes them, as its `<tools>` and `<rules>` sections, and pi renders them from the filtered active set, so a denied tool and its guidelines are absent and a restored tool is listed on the turn it returns.
1333
+ - A custom system prompt (`.pi/SYSTEM.md`, `~/.pi/agent/SYSTEM.md`, `--system-prompt`) is left exactly as pi built it: pi writes no tool list or rules under one, and this extension adds none either, so the prompt describes tools only if you wrote that yourself.
1335
1334
  Tool filtering and enforcement still apply, and the model still receives only the allowed tools in the request's tool list.
1336
- A subagent child is the exception: its prompt is always a custom one assembled by the subagent extension, so it still gets its own tool list and rules at the end, while any custom text it inherited stays untouched.
1337
- - The rendered sections follow pi's own rules: a tool is listed only when pi supplied a one-line description for it, and the guideline bullets are the allowed tools' own contributions, then any rules another extension added to `systemPromptOptions.promptGuidelines`, around pi's built-in ones
1338
- - The prompt is recomputed and returned on every turn but is stable across turns for a stable policy/agent, so the provider's prompt cache (tools + system prefix) is preserved rather than rewritten each turn.
1335
+ A subagent child is the exception: its prompt is always a custom one assembled by the subagent extension, so it gets its own `<tools>` and `<rules>` sections, placed after the working directory, while any custom text it inherited stays untouched.
1336
+ The child's sections follow pi's own rules: a tool is listed only when pi supplied a one-line description for it, and the guideline bullets are the allowed tools' own contributions, then any rules another extension added to `systemPromptOptions.promptGuidelines`, around pi's built-in ones.
1337
+ - A subagent child running with `@gotgenes/pi-subagents` needs a release that drops the parent's `<tools>` and `<rules>` from the prompt the child inherits; with an older one, the child shows the parent's tool list as well as its own.
1338
+ - A denied skill is removed from the skills catalogue pi renders.
1339
+ A skill listed in text pi did not render, such as your own `SYSTEM.md`, is not edited out; using it is still gated.
1340
+ - The prompt options are recomputed on every turn but are stable across turns for a stable policy/agent, so the provider's prompt cache is preserved rather than rewritten each turn.
1339
1341
  A policy change is an intentional cache transition, as a mid-session agent switch already is.
1340
1342
  - Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name
1341
1343
  - Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "36.2.1",
3
+ "version": "37.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -63,13 +63,13 @@
63
63
  ]
64
64
  },
65
65
  "peerDependencies": {
66
- "@earendil-works/pi-coding-agent": ">=0.79.0",
67
- "@earendil-works/pi-tui": ">=0.79.0"
66
+ "@earendil-works/pi-coding-agent": ">=1.0.0",
67
+ "@earendil-works/pi-tui": ">=1.0.0"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@biomejs/biome": "^2.5.11",
71
- "@earendil-works/pi-coding-agent": "0.79.1",
72
- "@earendil-works/pi-tui": "0.79.1",
71
+ "@earendil-works/pi-coding-agent": "1.0.0",
72
+ "@earendil-works/pi-tui": "1.0.0",
73
73
  "@types/node": "^22.15.3",
74
74
  "rollup": "^4.63.1",
75
75
  "rollup-plugin-dts": "^6.5.1",
@@ -38,8 +38,6 @@ export type SkillPromptEntry = {
38
38
  };
39
39
 
40
40
  export type SkillPromptSection = {
41
- start: number;
42
- end: number;
43
41
  entries: ParsedSkillPromptEntry[];
44
42
  };
45
43
 
@@ -52,15 +50,6 @@ function decodeXml(value: string): string {
52
50
  .replace(/&amp;/g, "&");
53
51
  }
54
52
 
55
- function encodeXml(value: string): string {
56
- return value
57
- .replace(/&/g, "&amp;")
58
- .replace(/</g, "&lt;")
59
- .replace(/>/g, "&gt;")
60
- .replace(/"/g, "&quot;")
61
- .replace(/'/g, "&apos;");
62
- }
63
-
64
53
  function parseSkillEntries(sectionBody: string): ParsedSkillPromptEntry[] {
65
54
  const entries: ParsedSkillPromptEntry[] = [];
66
55
  const skillBlockRegex = new RegExp(SKILL_BLOCK_PATTERN, "g");
@@ -115,8 +104,6 @@ export function parseAllSkillPromptSections(
115
104
  closeStart,
116
105
  );
117
106
  sections.push({
118
- start,
119
- end,
120
107
  entries: parseSkillEntries(sectionBody),
121
108
  });
122
109
  searchStart = end;
@@ -160,95 +147,56 @@ function createResolvedSkillEntry(
160
147
  };
161
148
  }
162
149
 
163
- function renderAvailableSkillsSection(
164
- entries: readonly SkillPromptEntry[],
165
- ): string {
166
- return [
167
- AVAILABLE_SKILLS_OPEN_TAG,
168
- ...entries.flatMap((entry) => [
169
- " <skill>",
170
- ` <name>${encodeXml(entry.name)}</name>`,
171
- ` <description>${encodeXml(entry.description)}</description>`,
172
- ` <location>${encodeXml(entry.location)}</location>`,
173
- " </skill>",
174
- ]),
175
- AVAILABLE_SKILLS_CLOSE_TAG,
176
- ].join("\n");
177
- }
178
-
179
- function removePromptRange(prompt: string, start: number, end: number): string {
180
- const beforeSection = prompt.slice(0, start).replace(/\n+$/, "");
181
- const afterSection = prompt.slice(end);
182
- return `${beforeSection}${afterSection}`;
183
- }
184
-
185
- export function resolveSkillPromptEntries(
150
+ /**
151
+ * The skills listed in the prompt's `<available_skills>` catalogues that
152
+ * policy does not deny, in catalogue order: what skill path matching may
153
+ * treat as a skill's files. Edits no prompt text.
154
+ */
155
+ export function visibleSkillPromptEntries(
186
156
  prompt: string,
187
157
  permissionManager: SkillPermissionChecker,
188
158
  agentName: string | null,
189
159
  normalizer: PathNormalizer,
190
- ): { prompt: string; entries: SkillPromptEntry[] } {
191
- const sections = parseAllSkillPromptSections(prompt);
192
- if (sections.length === 0) {
193
- return { prompt, entries: [] };
194
- }
195
-
160
+ ): SkillPromptEntry[] {
196
161
  const permissionCache = new Map<string, PermissionState>();
197
- const visibleEntries: SkillPromptEntry[] = [];
198
- const replacements: Array<{ start: number; end: number; content: string }> =
199
- [];
162
+ return parseAllSkillPromptSections(prompt)
163
+ .flatMap((section) => section.entries)
164
+ .map((entry) =>
165
+ createResolvedSkillEntry(
166
+ entry,
167
+ resolvePermissionState(
168
+ entry.name,
169
+ permissionManager,
170
+ agentName,
171
+ permissionCache,
172
+ ),
173
+ normalizer,
174
+ ),
175
+ )
176
+ .filter((entry) => entry.state !== "deny");
177
+ }
200
178
 
201
- for (const section of sections) {
202
- const resolvedEntries = section.entries.map((entry) => {
203
- const state = resolvePermissionState(
204
- entry.name,
179
+ /**
180
+ * The skills policy does not deny, judged by name, in their original order.
181
+ *
182
+ * Judging the skill list itself, rather than a catalogue rendered from it,
183
+ * keeps the answer independent of whether that catalogue has been rendered yet.
184
+ */
185
+ export function withoutDeniedSkills<T extends { readonly name: string }>(
186
+ skills: readonly T[],
187
+ permissionManager: SkillPermissionChecker,
188
+ agentName: string | null,
189
+ ): T[] {
190
+ const permissionCache = new Map<string, PermissionState>();
191
+ return skills.filter(
192
+ (skill) =>
193
+ resolvePermissionState(
194
+ skill.name,
205
195
  permissionManager,
206
196
  agentName,
207
197
  permissionCache,
208
- );
209
- return createResolvedSkillEntry(entry, state, normalizer);
210
- });
211
-
212
- const visibleSectionEntries = resolvedEntries.filter(
213
- (entry) => entry.state !== "deny",
214
- );
215
- visibleEntries.push(...visibleSectionEntries);
216
-
217
- if (visibleSectionEntries.length === resolvedEntries.length) {
218
- continue;
219
- }
220
-
221
- replacements.push({
222
- start: section.start,
223
- end: section.end,
224
- content:
225
- visibleSectionEntries.length > 0
226
- ? renderAvailableSkillsSection(visibleSectionEntries)
227
- : "",
228
- });
229
- }
230
-
231
- if (replacements.length === 0) {
232
- return { prompt, entries: visibleEntries };
233
- }
234
-
235
- let sanitizedPrompt = prompt;
236
- for (let i = replacements.length - 1; i >= 0; i--) {
237
- const replacement = replacements[i];
238
- sanitizedPrompt =
239
- replacement.content.length > 0
240
- ? `${sanitizedPrompt.slice(0, replacement.start)}${replacement.content}${sanitizedPrompt.slice(replacement.end)}`
241
- : removePromptRange(
242
- sanitizedPrompt,
243
- replacement.start,
244
- replacement.end,
245
- );
246
- }
247
-
248
- return {
249
- prompt: sanitizedPrompt,
250
- entries: visibleEntries,
251
- };
198
+ ) !== "deny",
199
+ );
252
200
  }
253
201
 
254
202
  export function findSkillPathMatch(
@@ -2,36 +2,18 @@
2
2
  * The tool-surface region of a system prompt: which tools this session may
3
3
  * call, and the guidance those tools contribute.
4
4
  *
5
- * Pi writes that region near the top of its preamble, a few hundred characters
6
- * in. `@gotgenes/pi-subagents` copies everything ahead of the skills catalogue
7
- * into a child's prompt verbatim, so the child's leading bytes match its
8
- * parent's for prefix-reusing inference engines — which means editing the
9
- * region in place ends that shared prefix for every child whose allowed set
10
- * differs from its parent's (#890).
5
+ * Pi renders that region itself, as its `<tools>` and `<rules>` sections, from
6
+ * the active tool set this extension narrows — but only when it writes the
7
+ * preamble. A subagent child's prompt is always a `customPrompt`, under which
8
+ * Pi writes neither section, so a child states its own through
9
+ * `systemPromptOptions.sections`, which Pi places after its `<cwd>` section.
10
+ * This module renders those section contents.
11
11
  *
12
- * So the region is *relocated* rather than narrowed: the sections Pi wrote are
13
- * removed, and this node's own are rendered at the end of the prompt, past
14
- * everything a child inherits. Each session then states its own tool surface
15
- * and no session edits another's bytes.
16
- *
17
- * Removal is bounded to the text this package or Pi wrote. The prompt is split
18
- * at the cwd layer Pi writes last and unconditionally: a
19
- * `Current working directory:` footer through Pi 0.85, a `<cwd>` section from
20
- * 0.86. Everything after it was appended by an extension, and everything
21
- * before it is Pi's own only when Pi did not build the prompt from a
22
- * `customPrompt`. Under a custom prompt Pi writes no tool surface at all, so a
23
- * section matched above the cwd layer is a user's or another extension's, and
24
- * removing it destroyed their text (#919, #932). On the 0.86 shape even a
25
- * Pi-authored head is searched only for Pi's own `<tools>` and `<rules>`,
26
- * bounded to where Pi writes them, since the plain headers it no longer
27
- * writes would match only a context file's text.
28
- *
29
- * Rendering follows `buildSystemPrompt`'s own rules (a tool is listed only
30
- * when it has a snippet; the guideline bullets are the allowed tools' own
31
- * `promptGuidelines`, then other extensions' rules, around Pi's built-in
32
- * ones) and its shape (plain headers or tagged sections, matching the
33
- * prompt), so the block reads as the one Pi would have written for this
34
- * session's real surface.
12
+ * Rendering follows `buildSystemPrompt`'s own rules: a tool is listed only
13
+ * when it has a snippet, and the guideline bullets are the allowed tools' own
14
+ * `promptGuidelines`, then other extensions' rules, around Pi's built-in ones,
15
+ * so the sections read as the ones Pi would have written for this session's
16
+ * real surface.
35
17
  */
36
18
 
37
19
  /** What a session's tool surface renders from. */
@@ -43,78 +25,25 @@ export interface ToolSurfaceInputs {
43
25
  /** Guideline bullets each tool contributes, keyed by tool name. */
44
26
  readonly guidelinesByTool: ReadonlyMap<string, readonly string[]>;
45
27
  /**
46
- * `systemPromptOptions.promptGuidelines`: bullets Pi writes into its rules
47
- * after the tools' own.
28
+ * `systemPromptOptions.promptGuidelines`: the bullets other extensions add
29
+ * to Pi's rules after the tools' own.
48
30
  *
49
- * Only a bullet no registered tool contributes is carried. From Pi 0.86 the
50
- * field holds what other extensions added; through 0.85 it *is* the tools'
51
- * guidelines, flattened, and a denied tool's bullet must not return by this
52
- * route.
31
+ * Only a bullet no registered tool contributes is carried, so a denied
32
+ * tool's bullet cannot return by this route.
53
33
  */
54
34
  readonly promptGuidelines: readonly string[];
55
- /**
56
- * Whether Pi wrote the prompt's preamble itself.
57
- *
58
- * False when Pi assembled the prompt from `customPrompt` — a user's
59
- * SYSTEM.md, or a subagent child's assembled prompt — in which case Pi wrote
60
- * no tool-surface sections and every line above its footer belongs to
61
- * somebody else.
62
- */
63
- readonly piAuthoredPreamble: boolean;
64
35
  }
65
36
 
66
- type LineSection = {
67
- start: number;
68
- end: number;
69
- };
70
-
71
- const AVAILABLE_TOOLS_SECTION_HEADER = "Available tools:";
72
- const GUIDELINES_SECTION_HEADER = "Guidelines:";
73
-
74
- /**
75
- * Pi's filler sentence between the tool list and the guidelines.
76
- *
77
- * It refers to "the tools above", so it belongs with the list rather than with
78
- * the text the list is being moved out of.
79
- */
80
- const CUSTOM_TOOLS_FILLER_PREFIX = "In addition to the tools above";
81
-
82
- /** What Pi writes under `Available tools:` when no selected tool has a snippet. */
83
- const EMPTY_LIST_PLACEHOLDER = "(none)";
84
-
85
- /**
86
- * The first line of the footer Pi writes last, in both of its branches.
87
- *
88
- * It is the boundary between what Pi assembled and what extensions appended
89
- * after it — the same anchor `@gotgenes/pi-subagents` uses to find Pi's
90
- * session-resolved tail.
91
- */
92
- const PROMPT_FOOTER_PREFIX = "Current working directory: ";
93
-
94
- /**
95
- * The tags of the section Pi renders the working directory into from 0.86,
96
- * last among its own sections and in both of its branches.
97
- */
98
- const CWD_SECTION_OPEN = "<cwd>";
99
- const CWD_SECTION_CLOSE = "</cwd>";
100
-
101
- /** The sections Pi 0.86+ writes its tool surface into, in the order it writes them. */
102
- const TOOL_SURFACE_SECTION_NAMES = ["tools", "rules"] as const;
103
-
104
37
  /**
105
- * Opening tags of the sections Pi writes after its tool surface.
106
- *
107
- * Pi writes `<tools>` and `<rules>` directly under its preamble and ahead of
108
- * every one of these, so a match that opens or closes past the first of them
109
- * is text somebody quoted, not Pi's.
38
+ * This session's tool surface as the contents of Pi's `tools` and `rules`
39
+ * prompt sections, untagged: Pi wraps each `sections` entry in its own tags.
110
40
  */
111
- const LATER_PI_SECTION_OPENS: ReadonlySet<string> = new Set([
112
- "<docs>",
113
- "<addendum>",
114
- "<project_context>",
115
- "<skills>",
116
- CWD_SECTION_OPEN,
117
- ]);
41
+ export interface ToolSurfaceSections {
42
+ /** `- name: snippet` lines; absent when no allowed tool has a snippet. */
43
+ readonly tools?: string;
44
+ /** `- rule` lines, in `buildSystemPrompt`'s order. */
45
+ readonly rules: string;
46
+ }
118
47
 
119
48
  /** Pi's two unconditional guideline bullets, in the order it writes them. */
120
49
  const UNIVERSAL_GUIDELINES: readonly string[] = [
@@ -123,299 +52,18 @@ const UNIVERSAL_GUIDELINES: readonly string[] = [
123
52
  ];
124
53
 
125
54
  /**
126
- * Relocate the tool surface: drop the sections Pi wrote, append this session's.
55
+ * Render this session's tool surface as section contents, for a node that
56
+ * states it through `systemPromptOptions.sections`.
127
57
  *
128
- * The result always carries a tool-surface block, so a child whose inherited
129
- * identity has none — its parent's node having already relocated it — still
130
- * describes its own tools.
58
+ * The `tools` section is omitted when no allowed tool has a snippet, as Pi
59
+ * lists a tool only when it has one.
131
60
  */
132
- export function renderToolSurface(
133
- systemPrompt: string,
61
+ export function renderToolSurfaceSections(
134
62
  inputs: ToolSurfaceInputs,
135
- ): string {
136
- const lines = normalizePrompt(systemPrompt).split("\n");
137
- const { layout, tailStart } = detectPromptLayout(lines);
138
- const body = [
139
- settleRegion(
140
- lines.slice(0, tailStart),
141
- inputs.piAuthoredPreamble ? layout.removePiSurface : null,
142
- ),
143
- settleRegion(lines.slice(tailStart), layout.removeRelocatedSurface),
144
- ]
145
- .filter((region) => region.length > 0)
146
- .join("\n")
147
- .trimEnd();
148
- const block = layout.renderBlock(toolSurfaceBullets(inputs));
149
-
150
- return body.length > 0 ? `${body}\n\n${block}` : block;
151
- }
152
-
153
- /** Lines removed from one region of the prompt; the rest are returned in order. */
154
- type RegionRemoval = (lines: readonly string[]) => string[];
155
-
156
- /**
157
- * How one prompt shape bounds and writes the tool surface.
158
- *
159
- * Pi has written its prompt in more than one shape, and each shape answers the
160
- * same three questions differently, so the shape is decided once per prompt
161
- * and every answer is read off the layout it selected.
162
- */
163
- interface PromptLayout {
164
- /** Pi's own tool surface, removed from the head when Pi wrote the preamble. */
165
- readonly removePiSurface: RegionRemoval;
166
- /** A relocated block (this package's or a peer's), removed from the tail. */
167
- readonly removeRelocatedSurface: RegionRemoval;
168
- /** This session's block, in this layout's shape. */
169
- readonly renderBlock: (bullets: ToolSurfaceBullets) => string;
170
- }
171
-
172
- /** A prompt's layout, and the line at which the text extensions appended begins. */
173
- interface DetectedLayout {
174
- readonly layout: PromptLayout;
175
- readonly tailStart: number;
176
- }
177
-
178
- /**
179
- * The shape Pi wrote through 0.85: `Available tools:` and `Guidelines:`
180
- * sections in its preamble, and a `Current working directory:` footer last.
181
- */
182
- const HEADER_LAYOUT: PromptLayout = {
183
- removePiSurface: removeToolSurfaceSections,
184
- removeRelocatedSurface: removeToolSurfaceSections,
185
- renderBlock: renderHeaderBlock,
186
- };
187
-
188
- /**
189
- * The shape Pi writes from 0.86: an untagged preamble, then `<name>` sections
190
- * joined by a blank line (`<tools>` and `<rules>` among them), and a `<cwd>`
191
- * section last among its own.
192
- */
193
- const SECTION_LAYOUT: PromptLayout = {
194
- removePiSurface: removePiToolSurfaceSections,
195
- removeRelocatedSurface: removeRelocatedToolSurface,
196
- renderBlock: renderSectionBlock,
197
- };
198
-
199
- /**
200
- * The prompt's layout, read off the cwd layer Pi wrote, and where the text
201
- * extensions appended after that layer begins.
202
- *
203
- * Pi writes its cwd layer last among its own, in both of its branches: a
204
- * `Current working directory:` footer through 0.85, a `<cwd>` section from
205
- * 0.86. So whichever of the two sits later is Pi's, and a line of either shape
206
- * quoted in a context file or a custom prompt is always above it. The shape is
207
- * told apart by which layer is present, never by version sniffing.
208
- *
209
- * A prompt carrying neither is read as the header layout and treated as all
210
- * head, so a block appended to *that* prompt cannot be found and replaced, and
211
- * a custom preamble would collect a second one. Pi writes a cwd layer in both
212
- * branches of both renderers, so reaching this needs a downstream rewrite of
213
- * Pi's whole output.
214
- */
215
- function detectPromptLayout(lines: readonly string[]): DetectedLayout {
216
- const footerAt = lines.findLastIndex((line) =>
217
- line.startsWith(PROMPT_FOOTER_PREFIX),
218
- );
219
- const cwdCloseAt = lastCwdSectionClose(lines);
220
- if (cwdCloseAt > footerAt) {
221
- return { layout: SECTION_LAYOUT, tailStart: cwdCloseAt + 1 };
222
- }
223
- return {
224
- layout: HEADER_LAYOUT,
225
- tailStart: footerAt === -1 ? lines.length : footerAt + 1,
226
- };
227
- }
228
-
229
- /**
230
- * Line index of the closing tag of the last `<cwd>` section, or -1 when there
231
- * is none.
232
- *
233
- * Pi renders the section as exactly three lines (the opening tag, the
234
- * directory, the closing tag), so that is the shape matched, the way the
235
- * footer is matched by its prefix.
236
- */
237
- function lastCwdSectionClose(lines: readonly string[]): number {
238
- return lines.findLastIndex(
239
- (line, index) =>
240
- line === CWD_SECTION_CLOSE && lines[index - 2] === CWD_SECTION_OPEN,
241
- );
242
- }
243
-
244
- /**
245
- * One region's surviving text: its sections removed, when they are ours to
246
- * remove, or returned as it arrived when `removal` is `null`.
247
- *
248
- * Blank runs are collapsed only where a removal opened one, so a region this
249
- * pass took nothing out of is returned exactly as it arrived rather than
250
- * reflowed by a pass that had nothing to do with it.
251
- */
252
- function settleRegion(
253
- lines: readonly string[],
254
- removal: RegionRemoval | null,
255
- ): string {
256
- if (!removal) {
257
- return lines.join("\n");
258
- }
259
- const kept = removal(lines);
260
- const text = kept.join("\n");
261
- return kept.length === lines.length ? text : collapseExtraBlankLines(text);
262
- }
263
-
264
- /**
265
- * Remove the `Available tools:` and `Guidelines:` sections, and the filler
266
- * sentence between them, from one region.
267
- *
268
- * Each section is located by its own header, so the two are removed whether
269
- * they sit adjacent in Pi's preamble or alone in the tail — including a block
270
- * this function already produced, which is what makes it safe to apply to its
271
- * own output, and what keeps it order-independent with a second writer.
272
- */
273
- function removeToolSurfaceSections(lines: readonly string[]): string[] {
274
- let remaining = [...lines];
275
- for (const header of [
276
- AVAILABLE_TOOLS_SECTION_HEADER,
277
- GUIDELINES_SECTION_HEADER,
278
- ]) {
279
- const section = findSection(remaining, header);
280
- if (section) {
281
- remaining = [
282
- ...remaining.slice(0, section.start),
283
- ...remaining.slice(section.end),
284
- ];
285
- }
286
- }
287
-
288
- return remaining.filter(
289
- (line) => !line.trimStart().startsWith(CUSTOM_TOOLS_FILLER_PREFIX),
290
- );
291
- }
292
-
293
- /**
294
- * Remove Pi 0.86+'s own `<tools>` and `<rules>` sections, tags included.
295
- *
296
- * A section is Pi's only when it opens and closes before the first section Pi
297
- * writes after it; the plain headers `removeToolSurfaceSections` matches are
298
- * never searched here, because Pi writes none on this shape and every match
299
- * would be a user's or another extension's text.
300
- */
301
- function removePiToolSurfaceSections(head: readonly string[]): string[] {
302
- return removeTaggedToolSurface(head, laterPiSectionStart);
303
- }
304
-
305
- /**
306
- * Remove a relocated block from the extension tail, in either shape.
307
- *
308
- * This package writes the tagged shape here, and a peer writer may write
309
- * either, so both are removed: the pass stays safe to apply to its own output
310
- * and order-independent with a second writer.
311
- */
312
- function removeRelocatedToolSurface(tail: readonly string[]): string[] {
313
- return removeToolSurfaceSections(
314
- removeTaggedToolSurface(tail, (lines) => lines.length),
315
- );
316
- }
317
-
318
- /**
319
- * Remove the first `<tools>` and the first `<rules>` section, each only when
320
- * it closes before the line `limitOf` names.
321
- */
322
- function removeTaggedToolSurface(
323
- lines: readonly string[],
324
- limitOf: (lines: readonly string[]) => number,
325
- ): string[] {
326
- let remaining = [...lines];
327
- for (const name of TOOL_SURFACE_SECTION_NAMES) {
328
- const section = findTaggedSection(remaining, name, limitOf(remaining));
329
- if (section) {
330
- remaining = [
331
- ...remaining.slice(0, section.start),
332
- ...remaining.slice(section.end),
333
- ];
334
- }
335
- }
336
- return remaining;
337
- }
338
-
339
- /** Line index of the first section Pi writes after its tool surface. */
340
- function laterPiSectionStart(lines: readonly string[]): number {
341
- const at = lines.findIndex((line) => LATER_PI_SECTION_OPENS.has(line));
342
- return at === -1 ? lines.length : at;
343
- }
344
-
345
- /**
346
- * The first `<name>` section, opening tag through closing tag, that ends
347
- * before `limit`; `null` when there is none.
348
- */
349
- function findTaggedSection(
350
- lines: readonly string[],
351
- name: string,
352
- limit: number,
353
- ): LineSection | null {
354
- const start = lines.indexOf(`<${name}>`);
355
- if (start === -1 || start >= limit) {
356
- return null;
357
- }
358
- const closeAt = lines.indexOf(`</${name}>`, start + 1);
359
- if (closeAt === -1 || closeAt >= limit) {
360
- return null;
361
- }
362
- return { start, end: closeAt + 1 };
363
- }
364
-
365
- /**
366
- * This session's tool surface, as Pi 0.86+ would have rendered it: a `<tools>`
367
- * section, when any allowed tool has a snippet, then a `<rules>` section.
368
- */
369
- function renderSectionBlock(bullets: ToolSurfaceBullets): string {
370
- const sections: string[] = [];
371
- if (bullets.tools.length > 0) {
372
- sections.push(taggedSection("tools", bullets.tools));
373
- }
374
- sections.push(taggedSection("rules", bullets.rules));
375
-
376
- return sections.join("\n\n");
377
- }
378
-
379
- /** A section in the form Pi 0.86+ renders: its tags on their own lines. */
380
- function taggedSection(name: string, lines: readonly string[]): string {
381
- return [`<${name}>`, ...lines, `</${name}>`].join("\n");
382
- }
383
-
384
- /** This session's tool surface, as Pi through 0.85 would have rendered it. */
385
- function renderHeaderBlock(bullets: ToolSurfaceBullets): string {
386
- const sections: string[] = [];
387
-
388
- const toolList = renderAvailableTools(bullets.tools);
389
- if (toolList) {
390
- sections.push(toolList);
391
- }
392
- sections.push(renderGuidelines(bullets.rules));
393
-
394
- return sections.join("\n\n");
395
- }
396
-
397
- /** The `Available tools:` section, or `null` when it lists no tool. */
398
- function renderAvailableTools(bullets: readonly string[]): string | null {
399
- return bullets.length > 0
400
- ? [AVAILABLE_TOOLS_SECTION_HEADER, ...bullets].join("\n")
401
- : null;
402
- }
403
-
404
- /** The `Guidelines:` section. */
405
- function renderGuidelines(bullets: readonly string[]): string {
406
- return [GUIDELINES_SECTION_HEADER, ...bullets].join("\n");
407
- }
408
-
409
- /** The bullets a tool-surface block wraps, whatever shape wraps them. */
410
- interface ToolSurfaceBullets {
411
- /** `- name: snippet` lines; empty when no allowed tool has a snippet. */
412
- readonly tools: readonly string[];
413
- /** `- rule` lines, in `buildSystemPrompt`'s order. */
414
- readonly rules: readonly string[];
415
- }
416
-
417
- function toolSurfaceBullets(inputs: ToolSurfaceInputs): ToolSurfaceBullets {
418
- return { tools: toolBullets(inputs), rules: ruleBullets(inputs) };
63
+ ): ToolSurfaceSections {
64
+ const tools = toolBullets(inputs);
65
+ const rules = ruleBullets(inputs).join("\n");
66
+ return tools.length > 0 ? { tools: tools.join("\n"), rules } : { rules };
419
67
  }
420
68
 
421
69
  /**
@@ -436,7 +84,8 @@ function toolBullets(inputs: ToolSurfaceInputs): string[] {
436
84
  *
437
85
  * Mirrors `buildSystemPrompt`'s assembly: its conditional file-exploration
438
86
  * bullet first, then each allowed tool's own contributions, then the bullets
439
- * other extensions added, then its two unconditional bullets — de-duplicated in first-seen order, as Pi does.
87
+ * other extensions added, then its two unconditional bullets — de-duplicated
88
+ * in first-seen order, as Pi does.
440
89
  */
441
90
  function ruleBullets(inputs: ToolSurfaceInputs): string[] {
442
91
  const bullets: string[] = [];
@@ -512,48 +161,3 @@ function fileExplorationGuideline(
512
161
  }
513
162
  return "Use bash for file operations like ls, rg, find";
514
163
  }
515
-
516
- function normalizePrompt(prompt: string): string {
517
- return (prompt || "").replace(/\r\n/g, "\n");
518
- }
519
-
520
- function collapseExtraBlankLines(text: string): string {
521
- return text.replace(/\n{3,}/g, "\n\n").trimEnd();
522
- }
523
-
524
- /**
525
- * Whether the line belongs to the body of the section above it.
526
- *
527
- * Pi writes a section as its header, then bullets — or the `(none)` placeholder
528
- * when the list is empty — and separates it from what follows with a blank
529
- * line. Anything else is already outside the section, however it is punctuated:
530
- * a section that ran on to "the next line ending in a colon" swallowed the
531
- * prose in between, which is somebody else's text whenever the match was not
532
- * Pi's own (#919, #932).
533
- */
534
- function isSectionBodyLine(line: string): boolean {
535
- const trimmed = line.trim();
536
- if (trimmed.length === 0) return true; // blank line
537
- if (trimmed.startsWith("- ")) return true; // bullet
538
- if (trimmed === EMPTY_LIST_PLACEHOLDER) return true; // Pi's empty list
539
- if (line !== line.trimStart()) return true; // indented
540
- return false;
541
- }
542
-
543
- /** The header line plus its own body, or `null` when the header is absent. */
544
- function findSection(
545
- lines: readonly string[],
546
- header: string,
547
- ): LineSection | null {
548
- const start = lines.findIndex((line) => line.trim() === header);
549
- if (start === -1) {
550
- return null;
551
- }
552
-
553
- let end = start + 1;
554
- while (end < lines.length && isSectionBodyLine(lines[end])) {
555
- end += 1;
556
- }
557
-
558
- return { start, end };
559
- }
@@ -1,16 +1,20 @@
1
1
  import type {
2
2
  BeforeAgentStartEventResult,
3
3
  ExtensionContext,
4
+ NormalizedBuildSystemPromptOptions,
4
5
  } from "@earendil-works/pi-coding-agent";
5
6
  import type { SubagentDetector } from "#src/authority/subagent-detection";
6
- import { resolveSkillPromptEntries } from "#src/exposure/skill-prompt-sanitizer";
7
+ import {
8
+ visibleSkillPromptEntries,
9
+ withoutDeniedSkills,
10
+ } from "#src/exposure/skill-prompt-sanitizer";
7
11
  import {
8
12
  type RegisteredTools,
9
13
  readRegisteredTools,
10
14
  type ToolRegistry,
11
15
  } from "#src/exposure/tool-registry";
12
16
  import type { ToolSurfaceObservation } from "#src/exposure/tool-surface-baseline";
13
- import { renderToolSurface } from "#src/exposure/tool-surface-prompt";
17
+ import { renderToolSurfaceSections } from "#src/exposure/tool-surface-prompt";
14
18
  import type { DebugLogger } from "#src/logging/session-logger";
15
19
  import type { PermissionResolver } from "#src/policy/permission-resolver";
16
20
  import type { PermissionSession } from "#src/session/permission-session";
@@ -18,22 +22,20 @@ import type { TurnPreparation } from "./session-turn-prep";
18
22
 
19
23
  /** Minimal subset of BeforeAgentStartEvent used by this handler. */
20
24
  interface BeforeAgentStartPayload {
21
- systemPrompt: string;
25
+ /** The prompt rendered from `systemPromptOptions` as it stands right now. */
26
+ readonly systemPrompt: string;
22
27
  /**
23
- * The parts Pi assembled the prompt from. `toolSnippets` is what lets this
24
- * handler render the session's own tool list instead of editing the one Pi
25
- * wrote — including in a child, whose inherited identity carries none.
26
- * `customPrompt` says whether Pi wrote a preamble at all: under one, it
27
- * writes no tool surface, so there is nothing of Pi's to remove, and a root
28
- * node adds none either.
29
- * `promptGuidelines` carries rules other extensions added, which removing
30
- * Pi's own rules section would otherwise drop.
28
+ * The mutable parts Pi renders the prompt from; later handlers, and Pi
29
+ * itself, see what this handler writes here. `customPrompt` says whether Pi
30
+ * wrote a preamble at all: under one, it writes no tool surface.
31
+ * `toolSnippets` and `promptGuidelines` are what a child's own tool surface
32
+ * renders from, and `sections` is where it is stated. `skills` is the
33
+ * catalogue Pi renders, which policy narrows.
31
34
  */
32
- systemPromptOptions?: {
33
- customPrompt?: string;
34
- toolSnippets?: Record<string, string>;
35
- promptGuidelines?: readonly string[];
36
- };
35
+ systemPromptOptions: Pick<
36
+ NormalizedBuildSystemPromptOptions,
37
+ "customPrompt" | "toolSnippets" | "promptGuidelines" | "sections" | "skills"
38
+ >;
37
39
  }
38
40
 
39
41
  /**
@@ -54,17 +56,16 @@ export function shouldExposeTool(
54
56
  /**
55
57
  * Handles the `before_agent_start` event: tool filtering + prompt sanitization.
56
58
  *
57
- * Recomputes the active tool set and the returned system-prompt override on
58
- * every fire (no memoization): the override must be returned each turn so that
59
- * skill filtering is reapplied and the wire prompt stays stable across turns,
60
- * rather than letting Pi reset to its skill-unfiltered base prompt on a cache
61
- * hit.
59
+ * Every change to the prompt is stated through `event.systemPromptOptions`,
60
+ * never as a returned `systemPrompt`: a returned prompt is frozen for the run,
61
+ * so the sections an extension later in the chain adds (Pi's own
62
+ * `<mcp_servers>` among them) would never reach the provider (#999).
62
63
  *
63
- * The tool surface is relocated rather than edited in place, so a subagent
64
- * child's inherited identity stays byte-identical to its parent's (#890).
65
- * A root node whose prompt Pi built from a custom one states no tool surface,
66
- * matching Pi, which writes none there; a subagent child's prompt is always a
67
- * custom one, and it still states its own tools.
64
+ * Narrowing the active set is enough for a prompt Pi wrote: Pi renders its
65
+ * `<tools>` and `<rules>` from the reconciled active set. A subagent child's
66
+ * prompt is always a custom one, under which Pi writes neither, so a child
67
+ * states its own as `sections`, which Pi places after its `<cwd>` section. A
68
+ * root under a custom prompt states none, matching Pi.
68
69
  *
69
70
  * Constructor deps:
70
71
  * - `turnPrep` — brings the node up to date for the turn before anything reads
@@ -116,40 +117,53 @@ export class AgentPrepHandler {
116
117
  });
117
118
  }
118
119
 
119
- const toolSurfacePrompt = this.statesOwnToolSurface(event, ctx)
120
- ? renderToolSurface(event.systemPrompt, {
121
- allowedTools,
122
- toolSnippets: event.systemPromptOptions?.toolSnippets ?? {},
123
- guidelinesByTool: registered.guidelinesByTool,
124
- promptGuidelines: event.systemPromptOptions?.promptGuidelines ?? [],
125
- piAuthoredPreamble: !hasCustomPrompt(event),
126
- })
127
- : event.systemPrompt;
128
- const skillPromptResult = resolveSkillPromptEntries(
129
- toolSurfacePrompt,
120
+ const options = event.systemPromptOptions;
121
+ if (this.isSubagentUnderCustomPrompt(event, ctx)) {
122
+ const sections = renderToolSurfaceSections({
123
+ allowedTools,
124
+ toolSnippets: options.toolSnippets,
125
+ guidelinesByTool: registered.guidelinesByTool,
126
+ promptGuidelines: options.promptGuidelines,
127
+ });
128
+ // An empty section is one Pi leaves out, so this also clears a stale peer list.
129
+ options.sections.tools = sections.tools ?? "";
130
+ options.sections.rules = sections.rules;
131
+ }
132
+
133
+ // Path-match entries come from every catalogue the rendered prompt lists,
134
+ // read before the skill list is narrowed.
135
+ this.session.setActiveSkillEntries(
136
+ visibleSkillPromptEntries(
137
+ event.systemPrompt,
138
+ this.resolver,
139
+ agentName,
140
+ this.session.getPathNormalizer(),
141
+ ),
142
+ );
143
+ // Denials are judged on the list Pi renders `<skills>` from, not on the
144
+ // rendered prompt: that prompt predates this turn's tool changes, and on
145
+ // the turn `read`/`bash` return from a full denial it lists no catalogue.
146
+ options.skills = withoutDeniedSkills(
147
+ options.skills,
130
148
  this.resolver,
131
149
  agentName,
132
- this.session.getPathNormalizer(),
133
150
  );
134
- this.session.setActiveSkillEntries(skillPromptResult.entries);
135
- return skillPromptResult.prompt !== event.systemPrompt
136
- ? { systemPrompt: skillPromptResult.prompt }
137
- : {};
151
+ return {};
138
152
  }
139
153
 
140
154
  /**
141
- * Whether this node renders its own tool surface into the prompt.
155
+ * Whether this node states its own tool surface through `sections`.
142
156
  *
143
- * Every node does except a root whose prompt Pi built from a custom one:
144
- * there Pi writes no tool list or rules, and the operator's prompt is left
145
- * as Pi built it. A subagent child's prompt is always a custom one, and its
146
- * inherited identity carries no tool list, so it still states its own.
157
+ * Only a subagent child does: its prompt is always a custom one, under which
158
+ * Pi writes no tool list or rules, and its inherited identity carries none
159
+ * either. A root needs none — Pi renders its own from the narrowed active
160
+ * set, or, under an operator's custom prompt, deliberately writes none.
147
161
  */
148
- private statesOwnToolSurface(
162
+ private isSubagentUnderCustomPrompt(
149
163
  event: BeforeAgentStartPayload,
150
164
  ctx: ExtensionContext,
151
165
  ): boolean {
152
- return !hasCustomPrompt(event) || this.detector.isSubagent(ctx);
166
+ return hasCustomPrompt(event) && this.detector.isSubagent(ctx);
153
167
  }
154
168
 
155
169
  private observeToolSurface(
@@ -167,5 +181,5 @@ export class AgentPrepHandler {
167
181
  * test, so an empty string reads here the way it reads there: as none.
168
182
  */
169
183
  function hasCustomPrompt(event: BeforeAgentStartPayload): boolean {
170
- return Boolean(event.systemPromptOptions?.customPrompt);
184
+ return Boolean(event.systemPromptOptions.customPrompt);
171
185
  }