pi-local-agents-only 0.1.3 → 0.1.5

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 ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.5 - 2026-04-07
4
+
5
+ - strip already-loaded global `AGENTS.md` / `CLAUDE.md` context reliably instead of rereading live files from disk
6
+ - remove the now-empty `# Project Context` section when only global context was loaded
7
+ - handle prompts whose custom prompt text also mentions the `# Project Context` heading
8
+ - make `/local-agents-only off` report when the repo is still enabled via the global allowlist or `PI_LOCAL_AGENTS_ONLY`
9
+ - document the repo-marker-only behavior of `/local-agents-only off`
10
+ - add integration tests against pi's real system prompt builder and command UX regressions
package/README.md CHANGED
@@ -30,6 +30,8 @@ Disable for the current repo:
30
30
  /local-agents-only off
31
31
  ```
32
32
 
33
+ `/local-agents-only off` clears the repo marker only. If the repo is still enabled via `/local-agents-only global-on` or `PI_LOCAL_AGENTS_ONLY=1`, it remains enabled until you also run `/local-agents-only global-off` or unset the env var.
34
+
33
35
  Enable or disable via the global allowlist:
34
36
 
35
37
  ```bash
@@ -49,6 +51,8 @@ Repo opt-in uses this marker file:
49
51
  .pi/local-agents-only
50
52
  ```
51
53
 
54
+ For git repos, marker and global allowlist activation apply across linked worktrees.
55
+
52
56
  Env override for one run:
53
57
 
54
58
  ```bash
@@ -57,3 +61,5 @@ PI_LOCAL_AGENTS_ONLY=0 pi
57
61
  ```
58
62
 
59
63
  This changes the prompt the model sees. It does not change pi's startup header.
64
+
65
+ If you toggle it during an existing session, start a fresh turn or `/new` for the cleanest verification.
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Purpose: Strip pi's global AGENTS.md and CLAUDE.md blocks from the effective prompt for opted-in projects.
3
- * Responsibilities: Detect repo opt-in state, manage repo and global toggles, and remove matching global context blocks before model calls.
3
+ * Responsibilities: Detect repo and worktree opt-in state, manage repo and global toggles, add a local-only guardrail, and remove matching global context blocks before model calls.
4
4
  * Scope: Works as a pi extension package. It changes only the prompt the model sees, not pi's startup header.
5
5
  * Usage: Install the package, then use `/local-agents-only on|off|status|global-on|global-off`.
6
- * Invariants/Assumptions: pi injects context files as `## /absolute/path\n\n<file contents>\n\n`.
6
+ * Invariants/Assumptions: pi injects context files as `## /absolute/path\n\n<file contents>\n\n`; git worktrees that share a common git dir should share local-agents-only state.
7
7
  */
8
8
 
9
+ import { execFileSync } from "node:child_process";
9
10
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
11
  import { homedir } from "node:os";
11
12
  import { dirname, join, resolve } from "node:path";
@@ -15,6 +16,10 @@ const MARKER = join(".pi", COMMAND);
15
16
  const GLOBAL_CONTEXT_FILES = ["AGENTS.md", "CLAUDE.md"];
16
17
  const ENV_TRUE = ["1", "true", "yes", "on"];
17
18
  const ENV_FALSE = ["0", "false", "no", "off"];
19
+ const PROJECT_CONTEXT_HEADER = "\n\n# Project Context\n\nProject-specific instructions and guidelines:\n\n";
20
+ const SKILLS_HEADER = "\n\nThe following skills provide specialized instructions for specific tasks.";
21
+ const DATE_HEADER = "\nCurrent date:";
22
+ const CONTEXT_BLOCK_HEADER = /^## ([^\n]+(?:AGENTS|CLAUDE)\.md)\n\n/gm;
18
23
 
19
24
  const getAgentDir = () => {
20
25
  const env = process.env.PI_CODING_AGENT_DIR;
@@ -29,19 +34,54 @@ const getAgentDir = () => {
29
34
  const normalizePath = (path) => resolve(path).replace(/\\/g, "/");
30
35
  const CONFIG = () => join(getAgentDir(), `${COMMAND}.json`);
31
36
  const getMarkerPath = (projectRoot) => join(projectRoot, MARKER);
32
- const readProjects = (configPath = CONFIG()) => {
37
+ const uniqueSorted = (values) => [...new Set(values.map(normalizePath))].sort();
38
+ const walkUp = (start, predicate) => {
39
+ let current = resolve(start);
40
+ while (true) {
41
+ if (predicate(current)) {
42
+ return current;
43
+ }
44
+ const parent = dirname(current);
45
+ if (parent === current) {
46
+ return;
47
+ }
48
+ current = parent;
49
+ }
50
+ };
51
+ const runGit = (start, args) => {
33
52
  try {
34
- const { projects = [] } = JSON.parse(readFileSync(configPath, "utf8"));
35
- return Array.isArray(projects) ? projects.map(normalizePath) : [];
53
+ return execFileSync("git", args, {
54
+ cwd: resolve(start),
55
+ encoding: "utf8",
56
+ stdio: ["ignore", "pipe", "ignore"],
57
+ }).trim();
36
58
  } catch {
37
- return [];
59
+ return;
60
+ }
61
+ };
62
+ const readConfig = (configPath = CONFIG()) => {
63
+ try {
64
+ const { projects = [], repositories = [] } = JSON.parse(readFileSync(configPath, "utf8"));
65
+ return {
66
+ projects: Array.isArray(projects) ? projects.map(normalizePath) : [],
67
+ repositories: Array.isArray(repositories) ? repositories.map(normalizePath) : [],
68
+ };
69
+ } catch {
70
+ return { projects: [], repositories: [] };
38
71
  }
39
72
  };
40
- const writeProjects = (projects, configPath = CONFIG()) => {
73
+ const writeConfig = ({ projects, repositories }, configPath = CONFIG()) => {
41
74
  mkdirSync(dirname(configPath), { recursive: true });
42
75
  writeFileSync(
43
76
  configPath,
44
- JSON.stringify({ projects: [...new Set(projects.map(normalizePath))].sort() }, null, 2) + "\n",
77
+ JSON.stringify(
78
+ {
79
+ projects: uniqueSorted(projects),
80
+ repositories: uniqueSorted(repositories),
81
+ },
82
+ null,
83
+ 2,
84
+ ) + "\n",
45
85
  );
46
86
  };
47
87
  const getEnvToggle = (value = process.env.PI_LOCAL_AGENTS_ONLY) => {
@@ -53,79 +93,220 @@ const getEnvToggle = (value = process.env.PI_LOCAL_AGENTS_ONLY) => {
53
93
  return false;
54
94
  }
55
95
  };
56
- const getGlobalBlocks = (agentDir = getAgentDir()) =>
57
- GLOBAL_CONTEXT_FILES.flatMap((name) => {
58
- const path = join(agentDir, name);
59
- return existsSync(path) ? [`## ${path}\n\n${readFileSync(path, "utf8")}\n\n`] : [];
60
- });
96
+ const getGlobalContextPaths = (agentDir = getAgentDir()) => GLOBAL_CONTEXT_FILES.map((name) => join(agentDir, name));
97
+ const getExistingGlobalContextPaths = (agentDir = getAgentDir()) =>
98
+ getGlobalContextPaths(agentDir).filter((path) => existsSync(path));
99
+ const getContextSectionEnd = (prompt, offset) => {
100
+ const candidates = [prompt.indexOf(SKILLS_HEADER, offset), prompt.indexOf(DATE_HEADER, offset)].filter(
101
+ (index) => index !== -1,
102
+ );
103
+ return candidates.length > 0 ? Math.min(...candidates) : prompt.length;
104
+ };
105
+ const getContextBlocks = (contextSection) => {
106
+ const matches = [...contextSection.matchAll(CONTEXT_BLOCK_HEADER)];
107
+ return matches.map((match, index) => ({
108
+ path: match[1],
109
+ start: match.index,
110
+ end: index + 1 < matches.length ? matches[index + 1].index : contextSection.length,
111
+ }));
112
+ };
113
+ const stripGlobalContext = (prompt, globalPaths = getGlobalContextPaths()) => {
114
+ const sectionStart = prompt.lastIndexOf(PROJECT_CONTEXT_HEADER);
115
+ if (sectionStart === -1) {
116
+ return { prompt, removedPaths: [] };
117
+ }
118
+ const contextStart = sectionStart + PROJECT_CONTEXT_HEADER.length;
119
+ const sectionEnd = getContextSectionEnd(prompt, contextStart);
120
+ const contextSection = prompt.slice(contextStart, sectionEnd);
121
+ const blocks = getContextBlocks(contextSection);
122
+ if (blocks.length === 0) {
123
+ return { prompt, removedPaths: [] };
124
+ }
125
+ const globalPathKeys = new Set(globalPaths.map(normalizePath));
126
+ const keptBlocks = [];
127
+ const removedPaths = [];
128
+ for (const block of blocks) {
129
+ const blockText = contextSection.slice(block.start, block.end);
130
+ if (globalPathKeys.has(normalizePath(block.path))) {
131
+ removedPaths.push(block.path);
132
+ } else {
133
+ keptBlocks.push(blockText);
134
+ }
135
+ }
136
+ if (removedPaths.length === 0) {
137
+ return { prompt, removedPaths: [] };
138
+ }
139
+ const prefix = prompt.slice(0, sectionStart);
140
+ const suffix = prompt.slice(sectionEnd);
141
+ if (keptBlocks.length === 0) {
142
+ return { prompt: `${prefix}${suffix}`, removedPaths: uniqueSorted(removedPaths) };
143
+ }
144
+ return {
145
+ prompt: `${prefix}${PROJECT_CONTEXT_HEADER}${keptBlocks.join("")}${suffix}`,
146
+ removedPaths: uniqueSorted(removedPaths),
147
+ };
148
+ };
149
+ const getGitTopLevel = (start) => {
150
+ const topLevel = runGit(start, ["rev-parse", "--show-toplevel"]);
151
+ return topLevel ? normalizePath(topLevel) : undefined;
152
+ };
153
+ const getGitCommonDir = (start) => {
154
+ const commonDir = runGit(start, ["rev-parse", "--git-common-dir"]);
155
+ return commonDir ? normalizePath(resolve(start, commonDir)) : undefined;
156
+ };
157
+ const getWorktreeRoots = (start) => {
158
+ const list = runGit(start, ["worktree", "list", "--porcelain"]);
159
+ if (!list) {
160
+ return [];
161
+ }
162
+ return uniqueSorted(
163
+ list
164
+ .split(/\r?\n/u)
165
+ .filter((line) => line.startsWith("worktree "))
166
+ .map((line) => line.slice("worktree ".length)),
167
+ );
168
+ };
169
+ const getProjectState = (start = process.cwd()) => {
170
+ const normalizedStart = normalizePath(start);
171
+ const gitTopLevel = getGitTopLevel(normalizedStart);
172
+ const projectRoot =
173
+ gitTopLevel ||
174
+ walkUp(normalizedStart, (dir) => existsSync(getMarkerPath(dir))) ||
175
+ walkUp(normalizedStart, (dir) => existsSync(join(dir, ".pi"))) ||
176
+ normalizedStart;
177
+ const worktreeRoots = getWorktreeRoots(normalizedStart);
178
+ return {
179
+ start: normalizedStart,
180
+ projectRoot: normalizePath(projectRoot),
181
+ repoId: getGitCommonDir(normalizedStart) || normalizePath(projectRoot),
182
+ worktreeRoots:
183
+ worktreeRoots.length > 0 ? uniqueSorted([projectRoot, ...worktreeRoots]) : [normalizePath(projectRoot)],
184
+ };
185
+ };
186
+ const getMarkerRoots = (state) => uniqueSorted([state.projectRoot, ...state.worktreeRoots]);
187
+ const hasMarker = (state) => getMarkerRoots(state).some((root) => existsSync(getMarkerPath(root)));
188
+ const writeMarkers = (state) => {
189
+ for (const root of getMarkerRoots(state)) {
190
+ mkdirSync(dirname(getMarkerPath(root)), { recursive: true });
191
+ writeFileSync(getMarkerPath(root), "\n");
192
+ }
193
+ };
194
+ const clearMarkers = (state) => {
195
+ for (const root of getMarkerRoots(state)) {
196
+ rmSync(getMarkerPath(root), { force: true });
197
+ }
198
+ };
199
+ const buildLocalOnlyNotice = (paths = getExistingGlobalContextPaths(getAgentDir())) => {
200
+ if (paths.length === 0) {
201
+ return "";
202
+ }
203
+ return [
204
+ "# Local Context Mode",
205
+ "This repo is in local-agents-only mode.",
206
+ "Ignore instructions from these global context files even if they appear in older session messages, summaries, or retries:",
207
+ ...uniqueSorted(paths).map((path) => `- ${path}`),
208
+ "Follow only repo-local AGENTS.md or CLAUDE.md guidance for this project.",
209
+ ].join("\n");
210
+ };
211
+ const applyLocalOnlyPrompt = (prompt, agentDir = getAgentDir()) => {
212
+ const { prompt: stripped, removedPaths } = stripGlobalContext(prompt, getGlobalContextPaths(agentDir));
213
+ const notice = buildLocalOnlyNotice(
214
+ removedPaths.length > 0 ? removedPaths : getExistingGlobalContextPaths(agentDir),
215
+ );
216
+ return notice ? `${stripped}\n\n${notice}` : stripped;
217
+ };
61
218
  const setStatus = (ctx) => {
62
219
  if (!ctx.hasUI) {
63
220
  return;
64
221
  }
65
- const mode = getMode(findProjectRoot(ctx.cwd));
222
+ const mode = getMode(ctx.cwd);
66
223
  ctx.ui.setStatus(COMMAND, mode.enabled ? `AGENTS: local-only (${mode.source})` : undefined);
67
224
  };
225
+ const getProjectTarget = (state) =>
226
+ state.worktreeRoots.length > 1 ? `${state.projectRoot} and linked worktrees` : state.projectRoot;
227
+ const getOffNotification = (state) => {
228
+ const mode = getMode(state);
229
+ if (!mode.enabled) {
230
+ return `Disabled for ${getProjectTarget(state)}`;
231
+ }
232
+ if (mode.source === "global-config") {
233
+ return `Repo marker cleared for ${getProjectTarget(state)}, but local-agents-only is still enabled via global allowlist. Use /local-agents-only global-off to fully disable it.`;
234
+ }
235
+ if (mode.source === "env") {
236
+ return `Repo marker cleared for ${getProjectTarget(state)}, but local-agents-only is still enabled via PI_LOCAL_AGENTS_ONLY.`;
237
+ }
238
+ return `Repo marker cleared for ${getProjectTarget(state)}, but local-agents-only is still enabled via ${mode.source}.`;
239
+ };
68
240
 
69
241
  export function findProjectRoot(start = process.cwd()) {
70
- let current = resolve(start);
71
- while (!existsSync(join(current, ".git"))) {
72
- const parent = dirname(current);
73
- if (parent === current) {
74
- break;
75
- }
76
- current = parent;
77
- }
78
- return current;
242
+ return getProjectState(start).projectRoot;
79
243
  }
80
244
 
81
- export function getMode(projectRoot, envValue = process.env.PI_LOCAL_AGENTS_ONLY, configPath = CONFIG()) {
245
+ export function getMode(start = process.cwd(), envValue = process.env.PI_LOCAL_AGENTS_ONLY, configPath = CONFIG()) {
246
+ const state = typeof start === "string" ? getProjectState(start) : start;
82
247
  const envToggle = getEnvToggle(envValue);
83
248
  if (envToggle !== undefined) {
84
249
  return { enabled: envToggle, source: "env" };
85
250
  }
86
- if (existsSync(getMarkerPath(projectRoot))) {
251
+ if (hasMarker(state)) {
87
252
  return { enabled: true, source: "marker" };
88
253
  }
89
- if (readProjects(configPath).includes(normalizePath(projectRoot))) {
254
+ const { projects, repositories } = readConfig(configPath);
255
+ if (repositories.includes(state.repoId)) {
256
+ return { enabled: true, source: "global-config" };
257
+ }
258
+ if (projects.includes(state.projectRoot) || state.worktreeRoots.some((root) => projects.includes(root))) {
90
259
  return { enabled: true, source: "global-config" };
91
260
  }
92
261
  return { enabled: false, source: "default" };
93
262
  }
94
263
 
95
- export function stripGlobalBlocks(prompt, blocks = getGlobalBlocks()) {
96
- return blocks.reduce((nextPrompt, block) => nextPrompt.replace(block, ""), prompt);
264
+ export function stripGlobalBlocks(prompt, globalPaths = getGlobalContextPaths()) {
265
+ return stripGlobalContext(prompt, globalPaths).prompt;
97
266
  }
98
267
 
99
268
  export default function localAgentsOnly(pi) {
100
269
  pi.registerCommand(COMMAND, {
101
270
  description: "Use only repo-local AGENTS prompt context",
102
271
  handler: async (args, ctx) => {
103
- const projectRoot = findProjectRoot(ctx.cwd);
272
+ const state = getProjectState(ctx.cwd);
104
273
  switch ((args.trim() || "status").toLowerCase()) {
105
274
  case "on":
106
- mkdirSync(dirname(getMarkerPath(projectRoot)), { recursive: true });
107
- writeFileSync(getMarkerPath(projectRoot), "\n");
275
+ writeMarkers(state);
108
276
  setStatus(ctx);
109
- ctx.ui.notify(`Enabled at ${getMarkerPath(projectRoot)}`, "info");
277
+ ctx.ui.notify(`Enabled for ${state.projectRoot}${state.worktreeRoots.length > 1 ? ` across ${state.worktreeRoots.length} worktrees` : ""}`, "info");
110
278
  return;
111
279
  case "off":
112
- rmSync(getMarkerPath(projectRoot), { force: true });
280
+ clearMarkers(state);
113
281
  setStatus(ctx);
114
- ctx.ui.notify("Disabled for this repo", "info");
282
+ ctx.ui.notify(getOffNotification(state), "info");
115
283
  return;
116
- case "global-on":
117
- writeProjects([...readProjects(), projectRoot]);
284
+ case "global-on": {
285
+ const config = readConfig();
286
+ writeConfig({
287
+ projects: [...config.projects, ...state.worktreeRoots],
288
+ repositories: [...config.repositories, state.repoId],
289
+ });
118
290
  setStatus(ctx);
119
- ctx.ui.notify(`Global allowlist enabled for ${normalizePath(projectRoot)}`, "info");
291
+ ctx.ui.notify(`Global allowlist enabled for ${state.projectRoot}`, "info");
120
292
  return;
121
- case "global-off":
122
- writeProjects(readProjects().filter((path) => path !== normalizePath(projectRoot)));
293
+ }
294
+ case "global-off": {
295
+ const config = readConfig();
296
+ writeConfig({
297
+ projects: config.projects.filter((path) => !state.worktreeRoots.includes(path)),
298
+ repositories: config.repositories.filter((id) => id !== state.repoId),
299
+ });
123
300
  setStatus(ctx);
124
- ctx.ui.notify(`Global allowlist disabled for ${normalizePath(projectRoot)}`, "info");
301
+ ctx.ui.notify(`Global allowlist disabled for ${state.projectRoot}`, "info");
125
302
  return;
303
+ }
126
304
  case "status": {
127
- const mode = getMode(projectRoot);
128
- ctx.ui.notify(`local-agents-only: ${mode.enabled ? `enabled via ${mode.source}` : "disabled"}`, "info");
305
+ const mode = getMode(state);
306
+ ctx.ui.notify(
307
+ `local-agents-only: ${mode.enabled ? `enabled via ${mode.source}` : "disabled"} (${state.projectRoot})`,
308
+ "info",
309
+ );
129
310
  return;
130
311
  }
131
312
  default:
@@ -145,6 +326,6 @@ export default function localAgentsOnly(pi) {
145
326
  });
146
327
 
147
328
  pi.on("before_agent_start", (event, ctx) => {
148
- return getMode(findProjectRoot(ctx.cwd)).enabled ? { systemPrompt: stripGlobalBlocks(event.systemPrompt) } : undefined;
329
+ return getMode(ctx.cwd).enabled ? { systemPrompt: applyLocalOnlyPrompt(event.systemPrompt) } : undefined;
149
330
  });
150
331
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-local-agents-only",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Pi extension that strips global AGENTS.md and CLAUDE.md from the effective prompt for selected projects.",
5
5
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
6
6
  "license": "MIT",
@@ -14,7 +14,7 @@
14
14
  "url": "https://github.com/fitchmultz/pi-local-agents-only/issues"
15
15
  },
16
16
  "homepage": "https://github.com/fitchmultz/pi-local-agents-only#readme",
17
- "files": ["extensions", "README.md"],
17
+ "files": ["extensions", "README.md", "CHANGELOG.md", "LICENSE"],
18
18
  "pi": {
19
19
  "extensions": ["./extensions"]
20
20
  },