context-mode 1.0.123 → 1.0.124

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.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "1.0.123"
9
+ "version": "1.0.124"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "context-mode",
14
14
  "source": "./",
15
15
  "description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
16
- "version": "1.0.123",
16
+ "version": "1.0.124",
17
17
  "author": {
18
18
  "name": "Mert Koseoğlu"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.123",
3
+ "version": "1.0.124",
4
4
  "description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -3,7 +3,7 @@
3
3
  "name": "Context Mode",
4
4
  "kind": "tool",
5
5
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
6
- "version": "1.0.123",
6
+ "version": "1.0.124",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.123",
3
+ "version": "1.0.124",
4
4
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -29,11 +29,55 @@ export declare function __resetClaudeCodePluginCacheForTests(): void;
29
29
  */
30
30
  export declare function __seedClaudeCodePluginCacheMissForTests(): void;
31
31
  /**
32
- * High-confidence env vars per platform, checked in priority order.
33
- * Single source of truth consumed by detectPlatform() below and by
34
- * tests that need to clear platform-related env vars deterministically.
32
+ * Tag for each PLATFORM_ENV_VARS row.
33
+ * - `workspace`: env var names a project/working directory. Used by
34
+ * `resolveProjectDir({ strictPlatform })` to form the candidate list,
35
+ * and by Pi's bridge to scrub foreign workspace vars on child spawn.
36
+ * - `identification`: env var only signals which host is running; carries
37
+ * no project path. NEVER scrubbed (some are load-bearing, e.g.
38
+ * CLAUDE_PLUGIN_ROOT for hook integrations).
39
+ *
40
+ * Issue #545 — algorithmic env-leak fix. The split allows resolveProjectDir
41
+ * to derive ALLOW (own workspace vars) and BAN (other platforms' workspace
42
+ * vars) sets from a single registry, satisfying MUST-3 (15 adapters equal).
43
+ */
44
+ export type EnvVarRole = "workspace" | "identification";
45
+ export interface PlatformEnvEntry {
46
+ readonly name: string;
47
+ readonly role: EnvVarRole;
48
+ /**
49
+ * When `false`, this entry is NOT used as a high-confidence detection
50
+ * signal — only consumed by `workspaceEnvVarsFor`/`foreignWorkspaceEnv`
51
+ * (project-dir cascade and bridge env scrub). Use for consumer-set
52
+ * workspace vars that the host runtime never emits itself, so that a
53
+ * stale env var on an unrelated host does not misclassify the platform.
54
+ * Default: `true` (entry participates in detection).
55
+ *
56
+ * Issue #542 — PI_PROJECT_DIR / PI_WORKSPACE_DIR are consumer-set and
57
+ * MUST NOT trigger Pi detection on their own.
58
+ */
59
+ readonly detect?: boolean;
60
+ }
61
+ export declare const PLATFORM_ENV_VARS: ReadonlyMap<PlatformId, readonly PlatformEnvEntry[]>;
62
+ /**
63
+ * Backwards-compat shim: legacy `string[]` shape used by detection logic and
64
+ * by tests that iterate the registry to clear env vars. Always returns the
65
+ * names in registry order.
66
+ */
67
+ export declare function getEnvVarNames(platform: PlatformId): string[];
68
+ /**
69
+ * Issue #545 — return only role=workspace env var names for a platform, in
70
+ * registry order. Empty array for adapters with no workspace var (e.g.
71
+ * codex, kilo, zed, antigravity, openclaw, kiro). Consumed by
72
+ * `resolveProjectDir({ strictPlatform })` to build the cascade.
73
+ */
74
+ export declare function workspaceEnvVarsFor(platform: PlatformId): string[];
75
+ /**
76
+ * Issue #545 — return the union of workspace env vars from ALL platforms
77
+ * EXCEPT the given one. Consumed by Pi's bridge env scrub (strip foreign
78
+ * workspace vars from spawned MCP child) and by the matrix regression test.
35
79
  */
36
- export declare const PLATFORM_ENV_VARS: readonly [readonly ["claude-code", readonly ["CLAUDE_CODE_ENTRYPOINT", "CLAUDE_PLUGIN_ROOT", "CLAUDE_PROJECT_DIR", "CLAUDE_SESSION_ID"]], readonly ["antigravity", readonly ["ANTIGRAVITY_CLI_ALIAS"]], readonly ["cursor", readonly ["CURSOR_TRACE_ID", "CURSOR_CLI"]], readonly ["kilo", readonly ["KILO", "KILO_PID"]], readonly ["opencode", readonly ["OPENCODE", "OPENCODE_PID"]], readonly ["zed", readonly ["ZED_SESSION_ID", "ZED_TERM"]], readonly ["codex", readonly ["CODEX_THREAD_ID", "CODEX_CI"]], readonly ["gemini-cli", readonly ["GEMINI_PROJECT_DIR", "GEMINI_CLI"]], readonly ["vscode-copilot", readonly ["VSCODE_PID", "VSCODE_CWD"]], readonly ["jetbrains-copilot", readonly ["IDEA_INITIAL_DIRECTORY"]], readonly ["qwen-code", readonly ["QWEN_PROJECT_DIR"]], readonly ["omp", readonly ["PI_CODING_AGENT_DIR"]], readonly ["pi", readonly ["PI_CONFIG_DIR", "PI_SESSION_FILE", "PI_COMPILED"]]];
80
+ export declare function foreignWorkspaceEnv(platform: PlatformId): Set<string>;
37
81
  /**
38
82
  * Sync map from platform identifier → home-relative path segments where that
39
83
  * platform stores its config. Mirrors the `super([...])` argument passed by
@@ -59,10 +59,15 @@ export function __seedClaudeCodePluginCacheMissForTests() {
59
59
  }
60
60
  /**
61
61
  * High-confidence env vars per platform, checked in priority order.
62
- * Single source of truth — consumed by detectPlatform() below and by
63
- * tests that need to clear platform-related env vars deterministically.
62
+ * Single source of truth — consumed by detectPlatform() below, by
63
+ * `resolveProjectDir({ strictPlatform })` for cascade construction, and by
64
+ * Pi's bridge env scrub. Tests also iterate this map to clear platform-
65
+ * related env vars deterministically.
66
+ *
67
+ * The map shape is `Map<PlatformId, ReadonlyArray<PlatformEnvEntry>>`. Use
68
+ * `getEnvVarNames(p)` to get just the names (legacy `string[]` shape).
64
69
  */
65
- export const PLATFORM_ENV_VARS = [
70
+ const _PLATFORM_ENV_VARS_RAW = [
66
71
  // Order matters: forks listed BEFORE the fork's parent so collision
67
72
  // detection works. Every entry verified against platform's own runtime
68
73
  // source code (PR #376 follow-up: full audit, May 2026 — see git blame).
@@ -75,49 +80,84 @@ export const PLATFORM_ENV_VARS = [
75
80
  // are the disambiguators for issue #539 (Claude Code running inside a
76
81
  // VS Code integrated terminal that has VSCODE_PID set). They MUST be
77
82
  // checked here so detect resolves to claude-code BEFORE falling through
78
- // to vscode-copilot at line 70 below.
83
+ // to vscode-copilot below.
79
84
  ["claude-code", [
80
- "CLAUDE_CODE_ENTRYPOINT",
81
- "CLAUDE_PLUGIN_ROOT",
82
- "CLAUDE_PROJECT_DIR",
83
- "CLAUDE_SESSION_ID",
85
+ { name: "CLAUDE_CODE_ENTRYPOINT", role: "identification" },
86
+ { name: "CLAUDE_PLUGIN_ROOT", role: "identification" },
87
+ { name: "CLAUDE_PROJECT_DIR", role: "workspace" },
88
+ { name: "CLAUDE_SESSION_ID", role: "identification" },
84
89
  ]],
85
90
  // antigravity (Electron/VSCode fork) — google-gemini/gemini-cli
86
91
  // packages/core/src/ide/detect-ide.ts checks ANTIGRAVITY_CLI_ALIAS as the
87
92
  // canonical Antigravity marker. Listed before vscode-copilot.
88
- ["antigravity", ["ANTIGRAVITY_CLI_ALIAS"]],
93
+ ["antigravity", [
94
+ { name: "ANTIGRAVITY_CLI_ALIAS", role: "identification" },
95
+ ]],
89
96
  // cursor (VSCode fork) — listed before vscode-copilot. CURSOR_TRACE_ID has
90
97
  // 800+ hits in major OSS detection libs (Vercel Next.js, Bun, Google
91
- // gemini-cli, Nx, CrewAI).
92
- ["cursor", ["CURSOR_TRACE_ID", "CURSOR_CLI"]],
98
+ // gemini-cli, Nx, CrewAI). CURSOR_CWD is the documented workspace var
99
+ // (issue #521) — listed first so workspace cascade picks it up.
100
+ ["cursor", [
101
+ { name: "CURSOR_CWD", role: "workspace" },
102
+ { name: "CURSOR_TRACE_ID", role: "identification" },
103
+ { name: "CURSOR_CLI", role: "identification" },
104
+ ]],
93
105
  // kilo (OpenCode fork) — Kilo-Org/kilocode packages/opencode/src/index.ts:138 + 139
94
- // sets `process.env.KILO = 1` + `process.env.KILO_PID = String(process.pid)`.
95
- ["kilo", ["KILO", "KILO_PID"]],
106
+ // sets `process.env.KILO = 1` + `process.env.KILO_PID = String(process.pid)`.
107
+ ["kilo", [
108
+ { name: "KILO", role: "identification" },
109
+ { name: "KILO_PID", role: "identification" },
110
+ ]],
96
111
  // opencode — sst/opencode packages/opencode/src/index.ts:108-109 sets
97
112
  // OPENCODE=1 + OPENCODE_PID=<pid> on every CLI invocation.
98
- ["opencode", ["OPENCODE", "OPENCODE_PID"]],
113
+ // OPENCODE_PROJECT_DIR is the documented workspace var (consumed by the
114
+ // legacy resolver cascade) — listed first so the workspace cascade picks
115
+ // it up under strict mode.
116
+ ["opencode", [
117
+ { name: "OPENCODE_PROJECT_DIR", role: "workspace" },
118
+ { name: "OPENCODE", role: "identification" },
119
+ { name: "OPENCODE_PID", role: "identification" },
120
+ ]],
99
121
  // zed — zed-industries/zed crates/terminal/src/terminal.rs sets ZED_TERM=true
100
122
  // in `insert_zed_terminal_env()`. Google's gemini-cli uses ZED_SESSION_ID.
101
- ["zed", ["ZED_SESSION_ID", "ZED_TERM"]],
123
+ ["zed", [
124
+ { name: "ZED_SESSION_ID", role: "identification" },
125
+ { name: "ZED_TERM", role: "identification" },
126
+ ]],
102
127
  // codex — openai/codex codex-rs/core/src/exec_env.rs sets CODEX_THREAD_ID
103
128
  // per exec; unified_exec/process_manager.rs sets CODEX_CI in CI mode.
104
- ["codex", ["CODEX_THREAD_ID", "CODEX_CI"]],
129
+ ["codex", [
130
+ { name: "CODEX_THREAD_ID", role: "identification" },
131
+ { name: "CODEX_CI", role: "identification" },
132
+ ]],
105
133
  // gemini-cli — GEMINI_PROJECT_DIR per google-gemini/gemini-cli
106
134
  // docs/hooks/index.md; GEMINI_CLI is the MCP-server sentinel.
107
- ["gemini-cli", ["GEMINI_PROJECT_DIR", "GEMINI_CLI"]],
135
+ ["gemini-cli", [
136
+ { name: "GEMINI_PROJECT_DIR", role: "workspace" },
137
+ { name: "GEMINI_CLI", role: "identification" },
138
+ ]],
108
139
  // vscode-copilot — VSCODE_PID + VSCODE_CWD set by microsoft/vscode bootstrap.
109
140
  // Listed AFTER cursor and antigravity since they inherit these vars as forks.
110
- ["vscode-copilot", ["VSCODE_PID", "VSCODE_CWD"]],
141
+ ["vscode-copilot", [
142
+ { name: "VSCODE_CWD", role: "workspace" },
143
+ { name: "VSCODE_PID", role: "identification" },
144
+ ]],
111
145
  // jetbrains-copilot — IDEA_INITIAL_DIRECTORY set by JetBrains launcher.
112
146
  // (IDEA_HOME and JETBRAINS_CLIENT_ID removed — no source-line evidence.)
113
- ["jetbrains-copilot", ["IDEA_INITIAL_DIRECTORY"]],
147
+ ["jetbrains-copilot", [
148
+ { name: "IDEA_INITIAL_DIRECTORY", role: "workspace" },
149
+ ]],
114
150
  // qwen-code — QWEN_PROJECT_DIR per QwenLM/qwen-code docs/users/features/hooks.md.
115
151
  // (QWEN_SESSION_ID removed — 0 hits in qwen-code repository.)
116
- ["qwen-code", ["QWEN_PROJECT_DIR"]],
152
+ ["qwen-code", [
153
+ { name: "QWEN_PROJECT_DIR", role: "workspace" },
154
+ ]],
117
155
  // omp (can1357/oh-my-pi). PI_CODING_AGENT_DIR is the upstream
118
156
  // agent-dir override per `packages/utils/src/dirs.ts:193`. Listed
119
157
  // BEFORE pi so OMP is not misclassified as Pi when both are installed.
120
- ["omp", ["PI_CODING_AGENT_DIR"]],
158
+ ["omp", [
159
+ { name: "PI_CODING_AGENT_DIR", role: "workspace" },
160
+ ]],
121
161
  // pi — Issue #542 marker correction. PI_PROJECT_DIR is a consumer-set
122
162
  // var (read by src/adapters/pi/extension.ts) but is NOT auto-set by
123
163
  // the Pi runtime — verified against
@@ -126,11 +166,62 @@ export const PLATFORM_ENV_VARS = [
126
166
  // PI_CONFIG_DIR (config dir override), PI_SESSION_FILE (active session
127
167
  // path), and PI_COMPILED (binary build marker). PI_CODING_AGENT_DIR is
128
168
  // owned by OMP above; keep it there.
129
- ["pi", ["PI_CONFIG_DIR", "PI_SESSION_FILE", "PI_COMPILED"]],
169
+ //
170
+ // Issue #545 — PI_WORKSPACE_DIR / PI_PROJECT_DIR are workspace vars set
171
+ // by Pi's bridge so the resolver picks them up under strict mode.
172
+ // PI_WORKSPACE_DIR comes first (extension-set, freshest) before
173
+ // PI_PROJECT_DIR (user override) per registry-author cascade order.
174
+ ["pi", [
175
+ // Issue #545 — workspace vars set by Pi's bridge so resolveProjectDir
176
+ // under strict mode picks them up. detect=false because PI_*_DIR are
177
+ // consumer-set and must NOT misclassify a non-Pi host as Pi (#542).
178
+ { name: "PI_WORKSPACE_DIR", role: "workspace", detect: false },
179
+ { name: "PI_PROJECT_DIR", role: "workspace", detect: false },
180
+ { name: "PI_CONFIG_DIR", role: "identification" },
181
+ { name: "PI_SESSION_FILE", role: "identification" },
182
+ { name: "PI_COMPILED", role: "identification" },
183
+ ]],
130
184
  // openclaw — removed (runtime never sets OPENCLAW_HOME or OPENCLAW_CLI;
131
185
  // detection falls through to ~/.openclaw/ config-dir tier below).
132
186
  // kiro — not listed (no auto-set process env vars; ~/.kiro/ config-dir tier).
133
187
  ];
188
+ export const PLATFORM_ENV_VARS = new Map(_PLATFORM_ENV_VARS_RAW);
189
+ /**
190
+ * Backwards-compat shim: legacy `string[]` shape used by detection logic and
191
+ * by tests that iterate the registry to clear env vars. Always returns the
192
+ * names in registry order.
193
+ */
194
+ export function getEnvVarNames(platform) {
195
+ return (PLATFORM_ENV_VARS.get(platform) ?? []).map((e) => e.name);
196
+ }
197
+ /**
198
+ * Issue #545 — return only role=workspace env var names for a platform, in
199
+ * registry order. Empty array for adapters with no workspace var (e.g.
200
+ * codex, kilo, zed, antigravity, openclaw, kiro). Consumed by
201
+ * `resolveProjectDir({ strictPlatform })` to build the cascade.
202
+ */
203
+ export function workspaceEnvVarsFor(platform) {
204
+ return (PLATFORM_ENV_VARS.get(platform) ?? [])
205
+ .filter((e) => e.role === "workspace")
206
+ .map((e) => e.name);
207
+ }
208
+ /**
209
+ * Issue #545 — return the union of workspace env vars from ALL platforms
210
+ * EXCEPT the given one. Consumed by Pi's bridge env scrub (strip foreign
211
+ * workspace vars from spawned MCP child) and by the matrix regression test.
212
+ */
213
+ export function foreignWorkspaceEnv(platform) {
214
+ const ban = new Set();
215
+ for (const [p, vars] of PLATFORM_ENV_VARS) {
216
+ if (p === platform)
217
+ continue;
218
+ for (const v of vars) {
219
+ if (v.role === "workspace")
220
+ ban.add(v.name);
221
+ }
222
+ }
223
+ return ban;
224
+ }
134
225
  /**
135
226
  * Sync map from platform identifier → home-relative path segments where that
136
227
  * platform stores its config. Mirrors the `super([...])` argument passed by
@@ -204,7 +295,7 @@ export function detectPlatform(clientInfo) {
204
295
  }
205
296
  // ── High confidence: environment variables ─────────────
206
297
  for (const [platform, vars] of PLATFORM_ENV_VARS) {
207
- if (vars.some((v) => process.env[v])) {
298
+ if (vars.some((v) => v.detect !== false && process.env[v.name])) {
208
299
  // Issue #539 belt-and-suspenders: VSCODE_PID/VSCODE_CWD are exported
209
300
  // by VS Code into EVERY child process — including a Claude Code CLI
210
301
  // launched from the integrated terminal. If env vars alone want to
@@ -224,7 +315,7 @@ export function detectPlatform(clientInfo) {
224
315
  return {
225
316
  platform,
226
317
  confidence: "high",
227
- reason: `${vars.join(" or ")} env var set`,
318
+ reason: `${vars.filter((v) => v.detect !== false).map((v) => v.name).join(" or ")} env var set`,
228
319
  };
229
320
  }
230
321
  }
@@ -102,7 +102,7 @@ function getPlatform() {
102
102
  for (const [platform, vars] of PLATFORM_ENV_VARS) {
103
103
  if (platform !== "kilo" && platform !== "opencode")
104
104
  continue;
105
- if (vars.some((v) => process.env[v])) {
105
+ if (vars.some((v) => process.env[v.name])) {
106
106
  return platform;
107
107
  }
108
108
  }
@@ -31,5 +31,33 @@ export declare let _mcpBridgeReady: Promise<void>;
31
31
  * Exported for unit tests.
32
32
  */
33
33
  export declare function isPiShortCircuitArgv(argv: readonly string[]): boolean;
34
+ /**
35
+ * Issue #545 — Pi workspace resolver.
36
+ *
37
+ * Pi's runtime sets PI_CONFIG_DIR to ~/.pi (its CONFIG dir, not the user's
38
+ * project). The extension previously used this as the project anchor, which
39
+ * meant every Pi session re-rooted under ~/.pi — collapsing all of a user's
40
+ * projects into a single phantom workspace. This helper picks the user's
41
+ * actual project directory while NEVER returning a path equal to or under
42
+ * ~/.pi/.
43
+ *
44
+ * Cascade:
45
+ * 1. PI_WORKSPACE_DIR — set by Pi's bridge (extension-set, freshest)
46
+ * 2. PI_PROJECT_DIR — legacy/user override
47
+ * 3. PWD — shell-set, survives process.chdir
48
+ * 4. cwd — last resort
49
+ *
50
+ * Each candidate is rejected if it equals ~/.pi or lives under ~/.pi/. If
51
+ * every candidate is poisoned, falls back to homedir() as a safe non-config
52
+ * anchor — caller may still render a "no project context" notice but the
53
+ * function stays total.
54
+ */
55
+ export declare function resolvePiWorkspaceDir(opts: {
56
+ env: Record<string, string | undefined>;
57
+ pwd: string | undefined;
58
+ cwd: string;
59
+ /** Optional override for tests; defaults to `os.homedir()`. */
60
+ home?: string;
61
+ }): string;
34
62
  /** Pi extension default export. Called once by Pi runtime with the extension API. */
35
63
  export default function piExtension(pi: any): void;
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { createHash } from "node:crypto";
14
14
  import { existsSync, mkdirSync } from "node:fs";
15
+ import { homedir } from "node:os";
15
16
  import { join, resolve, dirname } from "node:path";
16
17
  import { fileURLToPath, pathToFileURL } from "node:url";
17
18
  import { SessionDB } from "../../session/db.js";
@@ -228,16 +229,65 @@ export function isPiShortCircuitArgv(argv) {
228
229
  return false;
229
230
  return PI_SHORT_CIRCUIT_TOKENS.has(argv[0]);
230
231
  }
232
+ /**
233
+ * Issue #545 — Pi workspace resolver.
234
+ *
235
+ * Pi's runtime sets PI_CONFIG_DIR to ~/.pi (its CONFIG dir, not the user's
236
+ * project). The extension previously used this as the project anchor, which
237
+ * meant every Pi session re-rooted under ~/.pi — collapsing all of a user's
238
+ * projects into a single phantom workspace. This helper picks the user's
239
+ * actual project directory while NEVER returning a path equal to or under
240
+ * ~/.pi/.
241
+ *
242
+ * Cascade:
243
+ * 1. PI_WORKSPACE_DIR — set by Pi's bridge (extension-set, freshest)
244
+ * 2. PI_PROJECT_DIR — legacy/user override
245
+ * 3. PWD — shell-set, survives process.chdir
246
+ * 4. cwd — last resort
247
+ *
248
+ * Each candidate is rejected if it equals ~/.pi or lives under ~/.pi/. If
249
+ * every candidate is poisoned, falls back to homedir() as a safe non-config
250
+ * anchor — caller may still render a "no project context" notice but the
251
+ * function stays total.
252
+ */
253
+ export function resolvePiWorkspaceDir(opts) {
254
+ const home = opts.home ?? homedir();
255
+ const piConfigDir = join(home, ".pi");
256
+ const isUnderPi = (p) => {
257
+ if (!p)
258
+ return true;
259
+ if (p === piConfigDir)
260
+ return true;
261
+ // Match both POSIX (/) and Windows (\) child-of relations.
262
+ return p.startsWith(piConfigDir + "/") || p.startsWith(piConfigDir + "\\");
263
+ };
264
+ const candidates = [
265
+ opts.env.PI_WORKSPACE_DIR,
266
+ opts.env.PI_PROJECT_DIR,
267
+ opts.pwd,
268
+ opts.cwd,
269
+ ];
270
+ for (const c of candidates) {
271
+ if (c && !isUnderPi(c))
272
+ return c;
273
+ }
274
+ return home;
275
+ }
231
276
  // ── Extension entry point ────────────────────────────────
232
277
  /** Pi extension default export. Called once by Pi runtime with the extension API. */
233
278
  export default function piExtension(pi) {
234
279
  const buildDir = dirname(fileURLToPath(import.meta.url));
235
280
  const pluginRoot = resolve(buildDir, "..", "..", "..");
236
- // Issue #542 — Pi's runtime sets PI_CONFIG_DIR (not PI_PROJECT_DIR).
237
- // PI_PROJECT_DIR remains supported as a legacy override for callers
238
- // that historically synthesized it. Cwd is the universal final
239
- // fallback.
240
- const projectDir = process.env.PI_CONFIG_DIR || process.env.PI_PROJECT_DIR || process.cwd();
281
+ // Issue #545 — Pi workspace resolver. PI_CONFIG_DIR is Pi's CONFIG dir
282
+ // (~/.pi), NOT the user's workspace; using it as the project anchor
283
+ // collapsed every Pi session into a single phantom workspace. The
284
+ // dedicated resolver picks PI_WORKSPACE_DIR > PI_PROJECT_DIR > PWD > cwd
285
+ // and refuses to return any path under ~/.pi/.
286
+ const projectDir = resolvePiWorkspaceDir({
287
+ env: process.env,
288
+ pwd: process.env.PWD,
289
+ cwd: process.cwd(),
290
+ });
241
291
  const db = getOrCreateDB();
242
292
  // ── 1. session_start — Initialize session ──────────────
243
293
  pi.on("session_start", (_event, ctx) => {
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { spawn, execSync } from "node:child_process";
24
24
  import { detectRuntimes } from "../../runtime.js";
25
+ import { foreignWorkspaceEnv } from "../detect.js";
25
26
  // ── Fork-bomb prevention (#516) ──────────────────────────────────────
26
27
  //
27
28
  // Original bug: `spawn(process.execPath, [serverScript])` recursively
@@ -145,6 +146,20 @@ export class MCPStdioClient {
145
146
  ...this.env,
146
147
  [BRIDGE_DEPTH_ENV]: String(Number.isFinite(depth) ? depth + 1 : 1),
147
148
  };
149
+ // Issue #545 — scrub foreign workspace env vars before spawn.
150
+ //
151
+ // Pi's MCP bridge inherits the host shell env (including a prior
152
+ // `claude` invocation's CLAUDE_PROJECT_DIR). Without this scrub, the
153
+ // spawned MCP server resolves getProjectDir() to the foreign workspace
154
+ // and Pi's sessions write into the wrong project. The ban list is
155
+ // derived ALGORITHMICALLY from PLATFORM_ENV_VARS (every other adapter's
156
+ // workspace-role vars), so adding adapter #16 grows the scrub
157
+ // automatically — no edit to this file. Identification vars
158
+ // (CLAUDE_PLUGIN_ROOT etc.) and the universal escape hatch
159
+ // (CONTEXT_MODE_PROJECT_DIR) are NEVER scrubbed.
160
+ for (const banned of foreignWorkspaceEnv("pi")) {
161
+ delete childEnv[banned];
162
+ }
148
163
  this._spawnEnv = childEnv;
149
164
  this.child = spawn(runtime, [this.serverScript], {
150
165
  // Pipe stderr (#472 round-3): swallowing it via "ignore" hides
package/build/server.js CHANGED
@@ -197,18 +197,30 @@ function getProjectDir() {
197
197
  // modified Claude Code session's cwd — wrong project entirely. Gate the
198
198
  // path on detected platform so non-Claude hosts skip the heuristic and
199
199
  // fall through to PWD/cwd cleanly.
200
+ //
201
+ // Issue #545 (v1.0.124): pass strictPlatform for ALL adapters so the
202
+ // env-var cascade is built ALGORITHMICALLY from the platform's own
203
+ // workspace vars + universal escape hatch — foreign workspace vars (e.g.
204
+ // CLAUDE_PROJECT_DIR leaked into Pi's MCP child env from the user's shell)
205
+ // cannot win, regardless of cascade order. start.mjs intentionally does
206
+ // NOT pass strictPlatform — host detection is unreliable at the entrypoint
207
+ // and the legacy literal cascade is preserved there for semver safety.
200
208
  let transcriptsRoot;
209
+ let strictPlatform;
201
210
  try {
202
- if (detectPlatform().platform === "claude-code") {
211
+ const detected = detectPlatform().platform;
212
+ strictPlatform = detected;
213
+ if (detected === "claude-code") {
203
214
  transcriptsRoot = join(homedir(), ".claude", "projects");
204
215
  }
205
216
  }
206
- catch { /* detection failure — leave undefined, resolver skips heuristic */ }
217
+ catch { /* detection failure — leave both undefined, resolver uses legacy cascade */ }
207
218
  return resolveProjectDir({
208
219
  env: process.env,
209
220
  cwd: process.cwd(),
210
221
  pwd: process.env.PWD,
211
222
  transcriptsRoot,
223
+ strictPlatform,
212
224
  });
213
225
  }
214
226
  /**
@@ -1,3 +1,4 @@
1
+ import type { PlatformId } from "../adapters/types.js";
1
2
  /**
2
3
  * Project-dir resolution helpers — shared between `start.mjs` (the MCP entry
3
4
  * point) and `src/server.ts getProjectDir()` (the consumer).
@@ -76,4 +77,14 @@ export declare function resolveProjectDir(opts: {
76
77
  pwd: string | undefined;
77
78
  /** Optional override; production code passes `~/.claude/projects`. */
78
79
  transcriptsRoot?: string;
80
+ /**
81
+ * Issue #545 — opt-in tightening. When set, the candidate list is built
82
+ * algorithmically from `workspaceEnvVarsFor(strictPlatform)` plus the
83
+ * universal escape hatch. Foreign workspace vars (e.g. CLAUDE_PROJECT_DIR
84
+ * leaked into Pi's MCP child env) cannot win, regardless of cascade order.
85
+ *
86
+ * When `undefined`, the legacy literal candidate order is used (semver lock
87
+ * for `start.mjs` and any non-strict consumer).
88
+ */
89
+ strictPlatform?: PlatformId;
79
90
  }): string;
@@ -1,5 +1,32 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { workspaceEnvVarsFor } from "../adapters/detect.js";
4
+ /**
5
+ * Universal escape hatch. NEVER appears in any platform's foreignWorkspaceEnv()
6
+ * (because it isn't registered in PLATFORM_ENV_VARS), so it survives strict
7
+ * mode and bridge env scrubs. Documented as the cross-strict user override
8
+ * for every adapter (set in `~/.<host>/mcp.json` env when nothing else works).
9
+ */
10
+ const UNIVERSAL_WORKSPACE_ENV = ["CONTEXT_MODE_PROJECT_DIR"];
11
+ /**
12
+ * Frozen legacy candidate list — preserves bit-for-bit behavior of every
13
+ * non-strict caller (`start.mjs` and any caller that doesn't pass
14
+ * `strictPlatform`). Order is locked for semver compatibility.
15
+ *
16
+ * If a new adapter is added, DO NOT add its workspace var here — register it
17
+ * in `PLATFORM_ENV_VARS` and let strict callers pick it up via
18
+ * `workspaceEnvVarsFor(platform)`. Strict mode is the default forward path.
19
+ */
20
+ const LEGACY_NON_STRICT_CANDIDATES = [
21
+ "CLAUDE_PROJECT_DIR",
22
+ "GEMINI_PROJECT_DIR",
23
+ "VSCODE_CWD",
24
+ "OPENCODE_PROJECT_DIR",
25
+ "PI_PROJECT_DIR",
26
+ "IDEA_INITIAL_DIRECTORY",
27
+ "CURSOR_CWD",
28
+ "CONTEXT_MODE_PROJECT_DIR",
29
+ ];
3
30
  /**
4
31
  * Project-dir resolution helpers — shared between `start.mjs` (the MCP entry
5
32
  * point) and `src/server.ts getProjectDir()` (the consumer).
@@ -145,26 +172,17 @@ export function resolveProjectDirFromTranscript(opts) {
145
172
  * operation of project-independent tools (sandbox execute, fetch).
146
173
  */
147
174
  export function resolveProjectDir(opts) {
148
- const { env, cwd, pwd, transcriptsRoot } = opts;
149
- const candidates = [
150
- env.CLAUDE_PROJECT_DIR,
151
- env.GEMINI_PROJECT_DIR,
152
- env.VSCODE_CWD,
153
- env.OPENCODE_PROJECT_DIR,
154
- env.PI_PROJECT_DIR,
155
- env.IDEA_INITIAL_DIRECTORY,
156
- // Issue #521: Cursor MCP env override. The cursor adapter already
157
- // trusts CURSOR_CWD for hook input resolution (adapters/cursor/index.ts:581);
158
- // mirror that trust here so ctx_stats / SessionDB / hash see the workspace
159
- // path on Cursor. Whether Cursor itself sets this on MCP child spawn is
160
- // unconfirmed — but documenting it as a supported override gives users a
161
- // documented escape hatch (`~/.cursor/mcp.json` env: { CURSOR_CWD: "..." }).
162
- env.CURSOR_CWD,
163
- env.CONTEXT_MODE_PROJECT_DIR,
164
- ];
165
- for (const c of candidates) {
166
- if (c && !isPluginInstallPath(c))
167
- return c;
175
+ const { env, cwd, pwd, transcriptsRoot, strictPlatform } = opts;
176
+ // Build candidate list. Strict path: own workspace vars + universal escape
177
+ // hatch — NO foreign workspace vars, in any order, can win. Non-strict
178
+ // path: frozen legacy literal order for backwards compatibility.
179
+ const candidateVars = strictPlatform
180
+ ? [...workspaceEnvVarsFor(strictPlatform), ...UNIVERSAL_WORKSPACE_ENV]
181
+ : LEGACY_NON_STRICT_CANDIDATES;
182
+ for (const name of candidateVars) {
183
+ const v = env[name];
184
+ if (v && !isPluginInstallPath(v))
185
+ return v;
168
186
  }
169
187
  if (transcriptsRoot) {
170
188
  const fromTranscript = resolveProjectDirFromTranscript({ projectsRoot: transcriptsRoot });