pi-local-agents-only 0.1.3 → 0.1.4

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
@@ -49,6 +49,8 @@ Repo opt-in uses this marker file:
49
49
  .pi/local-agents-only
50
50
  ```
51
51
 
52
+ For git repos, marker and global allowlist activation apply across linked worktrees.
53
+
52
54
  Env override for one run:
53
55
 
54
56
  ```bash
@@ -57,3 +59,5 @@ PI_LOCAL_AGENTS_ONLY=0 pi
57
59
  ```
58
60
 
59
61
  This changes the prompt the model sees. It does not change pi's startup header.
62
+
63
+ 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";
@@ -29,19 +30,54 @@ const getAgentDir = () => {
29
30
  const normalizePath = (path) => resolve(path).replace(/\\/g, "/");
30
31
  const CONFIG = () => join(getAgentDir(), `${COMMAND}.json`);
31
32
  const getMarkerPath = (projectRoot) => join(projectRoot, MARKER);
32
- const readProjects = (configPath = CONFIG()) => {
33
+ const uniqueSorted = (values) => [...new Set(values.map(normalizePath))].sort();
34
+ const walkUp = (start, predicate) => {
35
+ let current = resolve(start);
36
+ while (true) {
37
+ if (predicate(current)) {
38
+ return current;
39
+ }
40
+ const parent = dirname(current);
41
+ if (parent === current) {
42
+ return;
43
+ }
44
+ current = parent;
45
+ }
46
+ };
47
+ const runGit = (start, args) => {
33
48
  try {
34
- const { projects = [] } = JSON.parse(readFileSync(configPath, "utf8"));
35
- return Array.isArray(projects) ? projects.map(normalizePath) : [];
49
+ return execFileSync("git", args, {
50
+ cwd: resolve(start),
51
+ encoding: "utf8",
52
+ stdio: ["ignore", "pipe", "ignore"],
53
+ }).trim();
36
54
  } catch {
37
- return [];
55
+ return;
38
56
  }
39
57
  };
40
- const writeProjects = (projects, configPath = CONFIG()) => {
58
+ const readConfig = (configPath = CONFIG()) => {
59
+ try {
60
+ const { projects = [], repositories = [] } = JSON.parse(readFileSync(configPath, "utf8"));
61
+ return {
62
+ projects: Array.isArray(projects) ? projects.map(normalizePath) : [],
63
+ repositories: Array.isArray(repositories) ? repositories.map(normalizePath) : [],
64
+ };
65
+ } catch {
66
+ return { projects: [], repositories: [] };
67
+ }
68
+ };
69
+ const writeConfig = ({ projects, repositories }, configPath = CONFIG()) => {
41
70
  mkdirSync(dirname(configPath), { recursive: true });
42
71
  writeFileSync(
43
72
  configPath,
44
- JSON.stringify({ projects: [...new Set(projects.map(normalizePath))].sort() }, null, 2) + "\n",
73
+ JSON.stringify(
74
+ {
75
+ projects: uniqueSorted(projects),
76
+ repositories: uniqueSorted(repositories),
77
+ },
78
+ null,
79
+ 2,
80
+ ) + "\n",
45
81
  );
46
82
  };
47
83
  const getEnvToggle = (value = process.env.PI_LOCAL_AGENTS_ONLY) => {
@@ -53,40 +89,104 @@ const getEnvToggle = (value = process.env.PI_LOCAL_AGENTS_ONLY) => {
53
89
  return false;
54
90
  }
55
91
  };
92
+ const getGlobalContextPaths = (agentDir = getAgentDir()) =>
93
+ GLOBAL_CONTEXT_FILES.map((name) => join(agentDir, name)).filter((path) => existsSync(path));
56
94
  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
- });
95
+ getGlobalContextPaths(agentDir).map((path) => `## ${path}\n\n${readFileSync(path, "utf8")}\n\n`);
96
+ const getGitTopLevel = (start) => {
97
+ const topLevel = runGit(start, ["rev-parse", "--show-toplevel"]);
98
+ return topLevel ? normalizePath(topLevel) : undefined;
99
+ };
100
+ const getGitCommonDir = (start) => {
101
+ const commonDir = runGit(start, ["rev-parse", "--git-common-dir"]);
102
+ return commonDir ? normalizePath(resolve(start, commonDir)) : undefined;
103
+ };
104
+ const getWorktreeRoots = (start) => {
105
+ const list = runGit(start, ["worktree", "list", "--porcelain"]);
106
+ if (!list) {
107
+ return [];
108
+ }
109
+ return uniqueSorted(
110
+ list
111
+ .split(/\r?\n/u)
112
+ .filter((line) => line.startsWith("worktree "))
113
+ .map((line) => line.slice("worktree ".length)),
114
+ );
115
+ };
116
+ const getProjectState = (start = process.cwd()) => {
117
+ const normalizedStart = normalizePath(start);
118
+ const gitTopLevel = getGitTopLevel(normalizedStart);
119
+ const projectRoot =
120
+ gitTopLevel ||
121
+ walkUp(normalizedStart, (dir) => existsSync(getMarkerPath(dir))) ||
122
+ walkUp(normalizedStart, (dir) => existsSync(join(dir, ".pi"))) ||
123
+ normalizedStart;
124
+ const worktreeRoots = getWorktreeRoots(normalizedStart);
125
+ return {
126
+ start: normalizedStart,
127
+ projectRoot: normalizePath(projectRoot),
128
+ repoId: getGitCommonDir(normalizedStart) || normalizePath(projectRoot),
129
+ worktreeRoots:
130
+ worktreeRoots.length > 0 ? uniqueSorted([projectRoot, ...worktreeRoots]) : [normalizePath(projectRoot)],
131
+ };
132
+ };
133
+ const getMarkerRoots = (state) => uniqueSorted([state.projectRoot, ...state.worktreeRoots]);
134
+ const hasMarker = (state) => getMarkerRoots(state).some((root) => existsSync(getMarkerPath(root)));
135
+ const writeMarkers = (state) => {
136
+ for (const root of getMarkerRoots(state)) {
137
+ mkdirSync(dirname(getMarkerPath(root)), { recursive: true });
138
+ writeFileSync(getMarkerPath(root), "\n");
139
+ }
140
+ };
141
+ const clearMarkers = (state) => {
142
+ for (const root of getMarkerRoots(state)) {
143
+ rmSync(getMarkerPath(root), { force: true });
144
+ }
145
+ };
146
+ const buildLocalOnlyNotice = (agentDir = getAgentDir()) => {
147
+ const paths = getGlobalContextPaths(agentDir);
148
+ if (paths.length === 0) {
149
+ return "";
150
+ }
151
+ return [
152
+ "# Local Context Mode",
153
+ "This repo is in local-agents-only mode.",
154
+ "Ignore instructions from these global context files even if they appear in older session messages, summaries, or retries:",
155
+ ...paths.map((path) => `- ${path}`),
156
+ "Follow only repo-local AGENTS.md or CLAUDE.md guidance for this project.",
157
+ ].join("\n");
158
+ };
159
+ const applyLocalOnlyPrompt = (prompt, agentDir = getAgentDir()) => {
160
+ const stripped = stripGlobalBlocks(prompt, getGlobalBlocks(agentDir));
161
+ const notice = buildLocalOnlyNotice(agentDir);
162
+ return notice ? `${stripped}\n\n${notice}` : stripped;
163
+ };
61
164
  const setStatus = (ctx) => {
62
165
  if (!ctx.hasUI) {
63
166
  return;
64
167
  }
65
- const mode = getMode(findProjectRoot(ctx.cwd));
168
+ const mode = getMode(ctx.cwd);
66
169
  ctx.ui.setStatus(COMMAND, mode.enabled ? `AGENTS: local-only (${mode.source})` : undefined);
67
170
  };
68
171
 
69
172
  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;
173
+ return getProjectState(start).projectRoot;
79
174
  }
80
175
 
81
- export function getMode(projectRoot, envValue = process.env.PI_LOCAL_AGENTS_ONLY, configPath = CONFIG()) {
176
+ export function getMode(start = process.cwd(), envValue = process.env.PI_LOCAL_AGENTS_ONLY, configPath = CONFIG()) {
177
+ const state = typeof start === "string" ? getProjectState(start) : start;
82
178
  const envToggle = getEnvToggle(envValue);
83
179
  if (envToggle !== undefined) {
84
180
  return { enabled: envToggle, source: "env" };
85
181
  }
86
- if (existsSync(getMarkerPath(projectRoot))) {
182
+ if (hasMarker(state)) {
87
183
  return { enabled: true, source: "marker" };
88
184
  }
89
- if (readProjects(configPath).includes(normalizePath(projectRoot))) {
185
+ const { projects, repositories } = readConfig(configPath);
186
+ if (repositories.includes(state.repoId)) {
187
+ return { enabled: true, source: "global-config" };
188
+ }
189
+ if (projects.includes(state.projectRoot) || state.worktreeRoots.some((root) => projects.includes(root))) {
90
190
  return { enabled: true, source: "global-config" };
91
191
  }
92
192
  return { enabled: false, source: "default" };
@@ -100,32 +200,44 @@ export default function localAgentsOnly(pi) {
100
200
  pi.registerCommand(COMMAND, {
101
201
  description: "Use only repo-local AGENTS prompt context",
102
202
  handler: async (args, ctx) => {
103
- const projectRoot = findProjectRoot(ctx.cwd);
203
+ const state = getProjectState(ctx.cwd);
104
204
  switch ((args.trim() || "status").toLowerCase()) {
105
205
  case "on":
106
- mkdirSync(dirname(getMarkerPath(projectRoot)), { recursive: true });
107
- writeFileSync(getMarkerPath(projectRoot), "\n");
206
+ writeMarkers(state);
108
207
  setStatus(ctx);
109
- ctx.ui.notify(`Enabled at ${getMarkerPath(projectRoot)}`, "info");
208
+ ctx.ui.notify(`Enabled for ${state.projectRoot}${state.worktreeRoots.length > 1 ? ` across ${state.worktreeRoots.length} worktrees` : ""}`, "info");
110
209
  return;
111
210
  case "off":
112
- rmSync(getMarkerPath(projectRoot), { force: true });
211
+ clearMarkers(state);
113
212
  setStatus(ctx);
114
- ctx.ui.notify("Disabled for this repo", "info");
213
+ ctx.ui.notify(`Disabled for ${state.projectRoot}${state.worktreeRoots.length > 1 ? ` and linked worktrees` : ""}`, "info");
115
214
  return;
116
- case "global-on":
117
- writeProjects([...readProjects(), projectRoot]);
215
+ case "global-on": {
216
+ const config = readConfig();
217
+ writeConfig({
218
+ projects: [...config.projects, ...state.worktreeRoots],
219
+ repositories: [...config.repositories, state.repoId],
220
+ });
118
221
  setStatus(ctx);
119
- ctx.ui.notify(`Global allowlist enabled for ${normalizePath(projectRoot)}`, "info");
222
+ ctx.ui.notify(`Global allowlist enabled for ${state.projectRoot}`, "info");
120
223
  return;
121
- case "global-off":
122
- writeProjects(readProjects().filter((path) => path !== normalizePath(projectRoot)));
224
+ }
225
+ case "global-off": {
226
+ const config = readConfig();
227
+ writeConfig({
228
+ projects: config.projects.filter((path) => !state.worktreeRoots.includes(path)),
229
+ repositories: config.repositories.filter((id) => id !== state.repoId),
230
+ });
123
231
  setStatus(ctx);
124
- ctx.ui.notify(`Global allowlist disabled for ${normalizePath(projectRoot)}`, "info");
232
+ ctx.ui.notify(`Global allowlist disabled for ${state.projectRoot}`, "info");
125
233
  return;
234
+ }
126
235
  case "status": {
127
- const mode = getMode(projectRoot);
128
- ctx.ui.notify(`local-agents-only: ${mode.enabled ? `enabled via ${mode.source}` : "disabled"}`, "info");
236
+ const mode = getMode(state);
237
+ ctx.ui.notify(
238
+ `local-agents-only: ${mode.enabled ? `enabled via ${mode.source}` : "disabled"} (${state.projectRoot})`,
239
+ "info",
240
+ );
129
241
  return;
130
242
  }
131
243
  default:
@@ -145,6 +257,6 @@ export default function localAgentsOnly(pi) {
145
257
  });
146
258
 
147
259
  pi.on("before_agent_start", (event, ctx) => {
148
- return getMode(findProjectRoot(ctx.cwd)).enabled ? { systemPrompt: stripGlobalBlocks(event.systemPrompt) } : undefined;
260
+ return getMode(ctx.cwd).enabled ? { systemPrompt: applyLocalOnlyPrompt(event.systemPrompt) } : undefined;
149
261
  });
150
262
  }
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.4",
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", "LICENSE"],
18
18
  "pi": {
19
19
  "extensions": ["./extensions"]
20
20
  },