@gotgenes/pi-permission-system 32.0.5 → 32.0.6

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,20 @@ 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
+ ## [32.0.6](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.5...pi-permission-system-v32.0.6) (2026-09-16)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** stop a removed prompt section from swallowing the prose after it ([af79199](https://github.com/gotgenes/pi-packages/commit/af79199ac42e6b09587bf0cbb256435a176660ab))
14
+ * **pi-permission-system:** keep a custom system prompt's own tool and guideline sections ([7cce96a](https://github.com/gotgenes/pi-packages/commit/7cce96af106d4822799b4728ecf1432526a79ba1))
15
+ * **pi-permission-system:** leave an unedited prompt region byte-identical ([1f1ce66](https://github.com/gotgenes/pi-packages/commit/1f1ce669d6d19d591da1262e89b3c919300b0f75))
16
+
17
+ ### Documentation
18
+
19
+ * **pi-permission-system:** record the tool-surface removal boundary ([ebbb8f0](https://github.com/gotgenes/pi-packages/commit/ebbb8f07c77eaa22c45b83e2d43397770dde1054))
20
+ * **pi-permission-system:** record the footerless-prompt edge in the tool-surface pass ([2aa7435](https://github.com/gotgenes/pi-packages/commit/2aa74353864cf648f802a8a63cbe5b1b141c27af))
21
+
8
22
  ## [32.0.5](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.4...pi-permission-system-v32.0.5) (2026-09-16)
9
23
 
10
24
 
@@ -1178,7 +1178,8 @@ Additional behaviors:
1178
1178
  - 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
1179
1179
  - 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))
1180
1180
  - The `Available tools:` and `Guidelines:` sections 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 pi's `Current working directory:` footer.
1181
- 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, whether or not anything is denied
1181
+ 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, whether or not anything is denied.
1182
+ Only the copies pi wrote are removed: a custom system prompt (`.pi/SYSTEM.md`, `~/.pi/agent/SYSTEM.md`, `--system-prompt`) keeps its own text untouched, sections and all, because pi writes no tool surface of its own under one — so a prompt that lists tools itself is shown alongside this session's block rather than replaced by it.
1182
1183
  - 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 around pi's built-in ones
1183
1184
  - 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.
1184
1185
  A policy change is an intentional cache transition, as a mid-session agent switch already is.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "32.0.5",
3
+ "version": "32.0.6",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,9 +10,17 @@
10
10
  * differs from its parent's (#890).
11
11
  *
12
12
  * So the region is *relocated* rather than narrowed: the sections Pi wrote are
13
- * removed wherever they sit, and this node's own are rendered at the end of
14
- * the prompt, past everything a child inherits. Each session then states its
15
- * own tool surface and no session edits another's bytes.
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 Pi's `Current working directory:` footer, which it writes last and
19
+ * unconditionally: everything after it was appended by an extension, and
20
+ * everything before it is Pi's own preamble only when Pi did not build the
21
+ * prompt from a `customPrompt`. Under a custom prompt Pi writes no tool
22
+ * surface at all, so a section matched above the footer is a user's or another
23
+ * extension's — removing it destroyed their text (#919, #932).
16
24
  *
17
25
  * Rendering follows `buildSystemPrompt`'s own rules — a tool is listed only
18
26
  * when it has a snippet, and the guideline bullets are the allowed tools' own
@@ -28,6 +36,15 @@ export interface ToolSurfaceInputs {
28
36
  readonly toolSnippets: Readonly<Record<string, string>>;
29
37
  /** Guideline bullets each tool contributes, keyed by tool name. */
30
38
  readonly guidelinesByTool: ReadonlyMap<string, readonly string[]>;
39
+ /**
40
+ * Whether Pi wrote the prompt's preamble itself.
41
+ *
42
+ * False when Pi assembled the prompt from `customPrompt` — a user's
43
+ * SYSTEM.md, or a subagent child's assembled prompt — in which case Pi wrote
44
+ * no tool-surface sections and every line above its footer belongs to
45
+ * somebody else.
46
+ */
47
+ readonly piAuthoredPreamble: boolean;
31
48
  }
32
49
 
33
50
  type LineSection = {
@@ -46,6 +63,18 @@ const GUIDELINES_SECTION_HEADER = "Guidelines:";
46
63
  */
47
64
  const CUSTOM_TOOLS_FILLER_PREFIX = "In addition to the tools above";
48
65
 
66
+ /** What Pi writes under `Available tools:` when no selected tool has a snippet. */
67
+ const EMPTY_LIST_PLACEHOLDER = "(none)";
68
+
69
+ /**
70
+ * The first line of the footer Pi writes last, in both of its branches.
71
+ *
72
+ * It is the boundary between what Pi assembled and what extensions appended
73
+ * after it — the same anchor `@gotgenes/pi-subagents` uses to find Pi's
74
+ * session-resolved tail.
75
+ */
76
+ const PROMPT_FOOTER_PREFIX = "Current working directory: ";
77
+
49
78
  /** Pi's two unconditional guideline bullets, in the order it writes them. */
50
79
  const UNIVERSAL_GUIDELINES: readonly string[] = [
51
80
  "Be concise in your responses",
@@ -63,23 +92,69 @@ export function renderToolSurface(
63
92
  systemPrompt: string,
64
93
  inputs: ToolSurfaceInputs,
65
94
  ): string {
66
- const lines = removeToolSurfaceSections(
67
- normalizePrompt(systemPrompt).split("\n"),
68
- );
69
- const body = collapseExtraBlankLines(lines.join("\n"));
95
+ const lines = normalizePrompt(systemPrompt).split("\n");
96
+ const tailStart = extensionTailStart(lines);
97
+ const body = [
98
+ settleRegion(lines.slice(0, tailStart), inputs.piAuthoredPreamble),
99
+ settleRegion(lines.slice(tailStart), true),
100
+ ]
101
+ .filter((region) => region.length > 0)
102
+ .join("\n")
103
+ .trimEnd();
70
104
  const block = renderToolSurfaceBlock(inputs);
71
105
 
72
106
  return body.length > 0 ? `${body}\n\n${block}` : block;
73
107
  }
74
108
 
109
+ /**
110
+ * Where the text extensions appended begins: the line after Pi's footer, or
111
+ * the end of the prompt when nothing downstream left one.
112
+ *
113
+ * The last footer is Pi's own — it appends one after everything it assembled,
114
+ * so a line of the same shape in a custom prompt is always above it.
115
+ *
116
+ * Accepted edge: a prompt carrying no footer at all is treated as all head, so
117
+ * a block appended to *that* prompt cannot be found and replaced, and a custom
118
+ * preamble would collect a second one. Pi writes the footer last and in both
119
+ * branches, so reaching this needs a downstream rewrite of Pi's whole output —
120
+ * which has already broken `@gotgenes/pi-subagents`' identity anchor, since it
121
+ * reads the same line.
122
+ */
123
+ function extensionTailStart(lines: readonly string[]): number {
124
+ const footerAt = lines.findLastIndex((line) =>
125
+ line.startsWith(PROMPT_FOOTER_PREFIX),
126
+ );
127
+ return footerAt === -1 ? lines.length : footerAt + 1;
128
+ }
129
+
130
+ /**
131
+ * One region's surviving text: its sections removed, when they are ours to
132
+ * remove.
133
+ *
134
+ * Blank runs are collapsed only where a removal opened one, so a region this
135
+ * pass took nothing out of is returned exactly as it arrived rather than
136
+ * reflowed by a pass that had nothing to do with it.
137
+ */
138
+ function settleRegion(
139
+ lines: readonly string[],
140
+ removalAllowed: boolean,
141
+ ): string {
142
+ if (!removalAllowed) {
143
+ return lines.join("\n");
144
+ }
145
+ const kept = removeToolSurfaceSections(lines);
146
+ const text = kept.join("\n");
147
+ return kept.length === lines.length ? text : collapseExtraBlankLines(text);
148
+ }
149
+
75
150
  /**
76
151
  * Remove the `Available tools:` and `Guidelines:` sections, and the filler
77
- * sentence between them.
152
+ * sentence between them, from one region.
78
153
  *
79
154
  * Each section is located by its own header, so the two are removed whether
80
- * they sit adjacent in Pi's preamble or alone in a prompt something downstream
81
- * rewrote — including a prompt this function already produced, which is what
82
- * makes it safe to apply to its own output.
155
+ * they sit adjacent in Pi's preamble or alone in the tail — including a block
156
+ * this function already produced, which is what makes it safe to apply to its
157
+ * own output, and what keeps it order-independent with a second writer.
83
158
  */
84
159
  function removeToolSurfaceSections(lines: readonly string[]): string[] {
85
160
  let remaining = [...lines];
@@ -209,21 +284,26 @@ function collapseExtraBlankLines(text: string): string {
209
284
  return text.replace(/\n{3,}/g, "\n\n").trimEnd();
210
285
  }
211
286
 
212
- function isTopLevelSectionHeader(line: string): boolean {
213
- const trimmed = line.trim();
214
- return (
215
- trimmed.length > 0 && trimmed.endsWith(":") && !trimmed.startsWith("-")
216
- );
217
- }
218
-
287
+ /**
288
+ * Whether the line belongs to the body of the section above it.
289
+ *
290
+ * Pi writes a section as its header, then bullets — or the `(none)` placeholder
291
+ * when the list is empty — and separates it from what follows with a blank
292
+ * line. Anything else is already outside the section, however it is punctuated:
293
+ * a section that ran on to "the next line ending in a colon" swallowed the
294
+ * prose in between, which is somebody else's text whenever the match was not
295
+ * Pi's own (#919, #932).
296
+ */
219
297
  function isSectionBodyLine(line: string): boolean {
220
298
  const trimmed = line.trim();
221
299
  if (trimmed.length === 0) return true; // blank line
222
300
  if (trimmed.startsWith("- ")) return true; // bullet
301
+ if (trimmed === EMPTY_LIST_PLACEHOLDER) return true; // Pi's empty list
223
302
  if (line !== line.trimStart()) return true; // indented
224
303
  return false;
225
304
  }
226
305
 
306
+ /** The header line plus its own body, or `null` when the header is absent. */
227
307
  function findSection(
228
308
  lines: readonly string[],
229
309
  header: string,
@@ -233,25 +313,9 @@ function findSection(
233
313
  return null;
234
314
  }
235
315
 
236
- // If a subsequent recognised section header exists, use it as the boundary.
237
- // This preserves the original behaviour for the common case where sections
238
- // are adjacent (e.g. "Available tools:" followed by "Guidelines:") and
239
- // ensures any prose continuation between the two headers is also removed.
240
- for (let index = start + 1; index < lines.length; index += 1) {
241
- if (isTopLevelSectionHeader(lines[index])) {
242
- return { start, end: index };
243
- }
244
- }
245
-
246
- // No subsequent section header — stop at the first non-body line so that
247
- // content after the section (e.g. custom user notes) is not silently deleted.
248
316
  let end = start + 1;
249
- for (let index = start + 1; index < lines.length; index += 1) {
250
- if (!isSectionBodyLine(lines[index])) {
251
- end = index;
252
- break;
253
- }
254
- end = index + 1;
317
+ while (end < lines.length && isSectionBodyLine(lines[end])) {
318
+ end += 1;
255
319
  }
256
320
 
257
321
  return { start, end };
@@ -22,8 +22,11 @@ interface BeforeAgentStartPayload {
22
22
  * The parts Pi assembled the prompt from. `toolSnippets` is what lets this
23
23
  * handler render the session's own tool list instead of editing the one Pi
24
24
  * wrote — including in a child, whose inherited identity carries none.
25
+ * `customPrompt` says whether Pi wrote a preamble at all: under one, it
26
+ * writes no tool surface, so there is nothing of Pi's to remove.
25
27
  */
26
28
  systemPromptOptions?: {
29
+ customPrompt?: string;
27
30
  toolSnippets?: Record<string, string>;
28
31
  };
29
32
  }
@@ -106,6 +109,9 @@ export class AgentPrepHandler {
106
109
  allowedTools,
107
110
  toolSnippets: event.systemPromptOptions?.toolSnippets ?? {},
108
111
  guidelinesByTool: registered.guidelinesByTool,
112
+ // Pi's own `if (customPrompt)` test, so an empty string reads here the
113
+ // way it reads there: as no custom prompt at all.
114
+ piAuthoredPreamble: !event.systemPromptOptions?.customPrompt,
109
115
  });
110
116
  const skillPromptResult = resolveSkillPromptEntries(
111
117
  toolSurfacePrompt,