context-mode 1.0.123 → 1.0.125

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.
@@ -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
@@ -61,11 +61,28 @@ export declare class NodeSQLiteAdapter {
61
61
  * bundled SQLite always ships with FTS5.
62
62
  */
63
63
  export declare function nodeSqliteHasFts5(DatabaseSync: any): boolean;
64
+ /**
65
+ * Returns true when the current runtime ships a built-in SQLite binding:
66
+ * - Bun has `bun:sqlite` always
67
+ * - Node has `node:sqlite` since 22.5 (no flag since 22.13)
68
+ *
69
+ * Mirrors the helper in hooks/ensure-deps.mjs:61. Exported so the platform
70
+ * gate in loadDatabase() can be unit-tested without spawning a child
71
+ * process. `versionsOverride` and `bunOverride` are injection points for
72
+ * tests — production callers pass nothing.
73
+ *
74
+ * Widening the gate from `process.platform === "linux"` to this helper is
75
+ * required for Node 26 on macOS arm64 (#551): Node 26 removed
76
+ * `info.This()` from V8 PropertyCallbackInfo, breaking better-sqlite3
77
+ * 12.9.0's native compile. Using node:sqlite sidesteps the native addon
78
+ * entirely on every platform that has it.
79
+ */
80
+ export declare function hasModernSqlite(versionsOverride?: NodeJS.ProcessVersions, bunOverride?: unknown): boolean;
64
81
  /**
65
82
  * Lazy-load the SQLite driver for the current runtime.
66
83
  * Bun → bun:sqlite via BunSQLiteAdapter (issue #45).
67
- * Linux Node → node:sqlite via NodeSQLiteAdapter when it ships FTS5 (#228, #461).
68
- * Other Node (or Linux Node without FTS5) → better-sqlite3 (native addon).
84
+ * Modern Node (>= 22.5) → node:sqlite via NodeSQLiteAdapter when it ships FTS5 (#228, #461, #551).
85
+ * Other Node (or modern Node without FTS5) → better-sqlite3 (native addon).
69
86
  */
70
87
  export declare function loadDatabase(): typeof DatabaseConstructor;
71
88
  /**
package/build/db-base.js CHANGED
@@ -184,11 +184,39 @@ export function nodeSqliteHasFts5(DatabaseSync) {
184
184
  catch { /* probe never opened or already closed */ }
185
185
  }
186
186
  }
187
+ /**
188
+ * Returns true when the current runtime ships a built-in SQLite binding:
189
+ * - Bun has `bun:sqlite` always
190
+ * - Node has `node:sqlite` since 22.5 (no flag since 22.13)
191
+ *
192
+ * Mirrors the helper in hooks/ensure-deps.mjs:61. Exported so the platform
193
+ * gate in loadDatabase() can be unit-tested without spawning a child
194
+ * process. `versionsOverride` and `bunOverride` are injection points for
195
+ * tests — production callers pass nothing.
196
+ *
197
+ * Widening the gate from `process.platform === "linux"` to this helper is
198
+ * required for Node 26 on macOS arm64 (#551): Node 26 removed
199
+ * `info.This()` from V8 PropertyCallbackInfo, breaking better-sqlite3
200
+ * 12.9.0's native compile. Using node:sqlite sidesteps the native addon
201
+ * entirely on every platform that has it.
202
+ */
203
+ export function hasModernSqlite(versionsOverride, bunOverride) {
204
+ const bun = bunOverride !== undefined ? bunOverride : globalThis.Bun;
205
+ if (typeof bun !== "undefined" && bun !== null)
206
+ return true;
207
+ const versions = versionsOverride ?? process.versions;
208
+ const [majorStr, minorStr] = (versions.node ?? "0.0.0").split(".");
209
+ const major = Number(majorStr);
210
+ const minor = Number(minorStr);
211
+ if (!Number.isFinite(major) || !Number.isFinite(minor))
212
+ return false;
213
+ return major > 22 || (major === 22 && minor >= 5);
214
+ }
187
215
  /**
188
216
  * Lazy-load the SQLite driver for the current runtime.
189
217
  * Bun → bun:sqlite via BunSQLiteAdapter (issue #45).
190
- * Linux Node → node:sqlite via NodeSQLiteAdapter when it ships FTS5 (#228, #461).
191
- * Other Node (or Linux Node without FTS5) → better-sqlite3 (native addon).
218
+ * Modern Node (>= 22.5) → node:sqlite via NodeSQLiteAdapter when it ships FTS5 (#228, #461, #551).
219
+ * Other Node (or modern Node without FTS5) → better-sqlite3 (native addon).
192
220
  */
193
221
  export function loadDatabase() {
194
222
  if (!_Database) {
@@ -211,13 +239,19 @@ export function loadDatabase() {
211
239
  return adapter;
212
240
  };
213
241
  }
214
- else if (process.platform === "linux") {
215
- // Linux — try node:sqlite to avoid native addon SIGSEGV (nodejs/node#62515).
216
- // node:sqlite is built into Node >= 22.5, no flag needed since 22.13.
217
- // Probe FTS5 support before committing some Linux Node builds ship
218
- // node:sqlite without FTS5, which would silently break ctx_search (#461).
219
- // The probe runs at most once per process (cached via _Database below),
220
- // so the cost of opening an in-memory DatabaseSync is negligible.
242
+ else if (hasModernSqlite()) {
243
+ // Any Node >= 22.5 — try node:sqlite to avoid the native addon path
244
+ // entirely. Historically this was Linux-only (avoiding the Linux
245
+ // SIGSEGV per nodejs/node#62515, #228), but Node 26 also broke
246
+ // better-sqlite3's native compile on macOS arm64 by removing
247
+ // V8 `info.This()` (#551). The built-in `node:sqlite` ships its
248
+ // own SQLite, so it sidesteps both issues at once.
249
+ //
250
+ // Probe FTS5 support before committing — some Node builds ship
251
+ // node:sqlite without FTS5, which would silently break ctx_search
252
+ // (#461). The probe runs at most once per process (cached via
253
+ // _Database below), so the cost of an in-memory DatabaseSync is
254
+ // negligible.
221
255
  let DatabaseSync = null;
222
256
  try {
223
257
  // Array.join() prevents esbuild from resolving the specifier at bundle time
@@ -236,16 +270,16 @@ export function loadDatabase() {
236
270
  };
237
271
  }
238
272
  else {
239
- // node:sqlite missing or built without FTS5 — fall through to better-sqlite3.
240
- // Trade-off: this reintroduces the native-addon path that #228 routed
241
- // around (Linux SIGSEGV per nodejs/node#62515). Deliberate a visible
242
- // crash on the rare unstable build is preferable to a silent
243
- // "no such module: fts5" on every ctx_search call.
273
+ // node:sqlite missing or built without FTS5 — fall through to
274
+ // better-sqlite3. Trade-off: on Node 26 + macOS this may now hit
275
+ // the V8 ABI break (#551). A visible crash on the rare
276
+ // unstable build is preferable to silent "no such module: fts5"
277
+ // on every ctx_search call.
244
278
  _Database = require("better-sqlite3");
245
279
  }
246
280
  }
247
281
  else {
248
- // Non-Linux Node.jsuse better-sqlite3.
282
+ // Old Node (< 22.5) without bun:sqlite fall back to better-sqlite3.
249
283
  _Database = require("better-sqlite3");
250
284
  }
251
285
  }
package/build/executor.js CHANGED
@@ -23,6 +23,7 @@ const SCRIPT_EXT = {
23
23
  perl: "pl",
24
24
  r: "R",
25
25
  elixir: "exs",
26
+ csharp: "csx",
26
27
  };
27
28
  /** Pure helper — exported for unit testing. Returns "script" or "script.<ext>". */
28
29
  export function buildScriptFilename(language, platform, shellPath) {
@@ -223,7 +224,7 @@ export class PolyglotExecutor {
223
224
  // .exe paths now (#506), but if it falls back to the bare "bun" string
224
225
  // on Windows that resolution typically goes through a `bun.cmd` shim
225
226
  // (npm i -g bun) which CreateProcess can't execute without cmd.exe.
226
- const needsShell = isWin && ["tsx", "ts-node", "elixir", "bun"].includes(cmd[0]);
227
+ const needsShell = isWin && ["tsx", "ts-node", "elixir", "bun", "dotnet-script"].includes(cmd[0]);
227
228
  // On Windows with Git Bash, pass the script as `bash -c "source /posix/path"`
228
229
  // rather than `bash /path/to/script.sh`. This avoids MSYS2 path mangling
229
230
  // while still allowing MSYS_NO_PATHCONV to protect non-ASCII paths in commands.
@@ -412,6 +413,30 @@ export class PolyglotExecutor {
412
413
  "R_PROFILE", // site-wide R profile
413
414
  "R_PROFILE_USER", // user R profile
414
415
  "R_HOME", // R installation override
416
+ // .NET / C# — runtime/startup hooks, additional deps
417
+ "DOTNET_STARTUP_HOOKS", // injects managed assemblies on startup
418
+ "DOTNET_ADDITIONAL_DEPS", // additional .deps.json injection
419
+ "DOTNET_SHARED_STORE", // shared assembly probe path injection
420
+ "DOTNET_ROOT", // arbitrary .NET runtime override
421
+ "DOTNET_ROOT(x86)", // 32-bit override
422
+ "DOTNET_HOST_PATH", // host binary substitution
423
+ // .NET / C# — profiler attach (loads arbitrary DLL into dotnet host)
424
+ // and IPC-based debugger/IL injection. PR #546 follow-up.
425
+ // learn.microsoft.com/en-us/dotnet/core/runtime-config/debugging-profiling
426
+ "CORECLR_PROFILER", // CLSID of profiler to attach
427
+ "CORECLR_PROFILER_PATH", // path to profiler DLL
428
+ "CORECLR_PROFILER_PATH_32", // 32-bit specific profiler DLL
429
+ "CORECLR_PROFILER_PATH_64", // 64-bit specific profiler DLL
430
+ "CORECLR_PROFILER_PATH_ARM32", // ARM32 specific profiler DLL
431
+ "CORECLR_PROFILER_PATH_ARM64", // ARM64 specific profiler DLL
432
+ "CORECLR_ENABLE_PROFILING", // gates profiler load
433
+ "DOTNET_PROFILER_PATH", // cross-platform alias
434
+ "DOTNET_PROFILER_PATH_32",
435
+ "DOTNET_PROFILER_PATH_64",
436
+ "DOTNET_PROFILER_PATH_ARM32",
437
+ "DOTNET_PROFILER_PATH_ARM64",
438
+ "DOTNET_DiagnosticPorts", // peer attach via diagnostic IPC
439
+ "DOTNET_BUNDLE_EXTRACT_BASE_DIR", // single-file extraction hijack
415
440
  // Dynamic linker — shared library injection
416
441
  "LD_PRELOAD", // loads .so before all others (Linux)
417
442
  "DYLD_INSERT_LIBRARIES", // macOS equivalent of LD_PRELOAD
@@ -431,10 +456,17 @@ export class PolyglotExecutor {
431
456
  "GIT_SSH_COMMAND", // arbitrary ssh command
432
457
  "GIT_ASKPASS", // arbitrary credential command
433
458
  ]);
434
- // Start with parent env, then strip dangerous vars and apply overrides
459
+ // Start with parent env, then strip dangerous vars and apply overrides.
460
+ // The `COMPlus_` prefix sweep covers every COMPlus_* synonym of the
461
+ // DOTNET_* runtime knobs (.NET back-compat alias — case-insensitive).
462
+ // PR #546 follow-up: closes the alias bypass for the explicit denylist
463
+ // entries above.
435
464
  const env = {};
436
465
  for (const [key, val] of Object.entries(process.env)) {
437
- if (val !== undefined && !DENIED.has(key) && !key.startsWith("BASH_FUNC_")) {
466
+ if (val !== undefined &&
467
+ !DENIED.has(key) &&
468
+ !key.startsWith("BASH_FUNC_") &&
469
+ !/^COMPlus_/i.test(key)) {
438
470
  env[key] = val;
439
471
  }
440
472
  }
@@ -508,6 +540,11 @@ export class PolyglotExecutor {
508
540
  return `FILE_CONTENT_PATH <- ${escaped}\nfile_path <- FILE_CONTENT_PATH\nFILE_CONTENT <- readLines(FILE_CONTENT_PATH, warn=FALSE, encoding="UTF-8")\nFILE_CONTENT <- paste(FILE_CONTENT, collapse="\\n")\n${code}`;
509
541
  case "elixir":
510
542
  return `file_content_path = ${escaped}\nfile_path = file_content_path\nfile_content = File.read!(file_content_path)\n${code}`;
543
+ case "csharp":
544
+ // .csx forbids `using` directives after any other top-level statement
545
+ // (CS1529). User code inside executeFile must use fully-qualified type
546
+ // names (e.g. `System.Text.Json.JsonDocument`) instead of `using`.
547
+ return `var FILE_CONTENT_PATH = ${escaped};\nvar file_path = FILE_CONTENT_PATH;\nvar FILE_CONTENT = System.IO.File.ReadAllText(FILE_CONTENT_PATH);\n${code}`;
511
548
  }
512
549
  }
513
550
  }
@@ -1,5 +1,5 @@
1
1
  export declare function isAllowlistedShell(shellPath: string): boolean;
2
- export type Language = "javascript" | "typescript" | "python" | "shell" | "ruby" | "go" | "rust" | "php" | "perl" | "r" | "elixir";
2
+ export type Language = "javascript" | "typescript" | "python" | "shell" | "ruby" | "go" | "rust" | "php" | "perl" | "r" | "elixir" | "csharp";
3
3
  export interface RuntimeInfo {
4
4
  command: string;
5
5
  available: boolean;
@@ -18,6 +18,7 @@ export interface RuntimeMap {
18
18
  perl: string | null;
19
19
  r: string | null;
20
20
  elixir: string | null;
21
+ csharp: string | null;
21
22
  }
22
23
  export declare function detectRuntimes(): RuntimeMap;
23
24
  export declare function hasBunRuntime(): boolean;
package/build/runtime.js CHANGED
@@ -232,6 +232,7 @@ export function detectRuntimes() {
232
232
  ? "r"
233
233
  : null,
234
234
  elixir: commandExists("elixir") ? "elixir" : null,
235
+ csharp: commandExists("dotnet-script") ? "dotnet-script" : null,
235
236
  };
236
237
  }
237
238
  export function hasBunRuntime() {
@@ -269,6 +270,8 @@ export function getRuntimeSummary(runtimes) {
269
270
  lines.push(` R: ${runtimes.r} (${getVersion(runtimes.r)})`);
270
271
  if (runtimes.elixir)
271
272
  lines.push(` Elixir: ${runtimes.elixir} (${getVersion(runtimes.elixir)})`);
273
+ if (runtimes.csharp)
274
+ lines.push(` C#: ${runtimes.csharp} (${getVersion(runtimes.csharp)})`);
272
275
  if (!bunPreferred) {
273
276
  lines.push("");
274
277
  lines.push(" Tip: Install Bun for 3-5x faster JS/TS execution → https://bun.sh");
@@ -295,6 +298,8 @@ export function getAvailableLanguages(runtimes) {
295
298
  langs.push("r");
296
299
  if (runtimes.elixir)
297
300
  langs.push("elixir");
301
+ if (runtimes.csharp)
302
+ langs.push("csharp");
298
303
  return langs;
299
304
  }
300
305
  export function buildCommand(runtimes, language, filePath) {
@@ -376,5 +381,10 @@ export function buildCommand(runtimes, language, filePath) {
376
381
  throw new Error("Elixir not available. Install elixir.");
377
382
  }
378
383
  return ["elixir", filePath];
384
+ case "csharp":
385
+ if (!runtimes.csharp) {
386
+ throw new Error("C# not available. Install dotnet-script via `dotnet tool install -g dotnet-script`.");
387
+ }
388
+ return [runtimes.csharp, filePath];
379
389
  }
380
390
  }
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
  /**
@@ -917,11 +929,12 @@ server.registerTool("ctx_execute", {
917
929
  "perl",
918
930
  "r",
919
931
  "elixir",
932
+ "csharp",
920
933
  ])
921
934
  .describe("Runtime language"),
922
935
  code: z
923
936
  .string()
924
- .describe("Source code to execute. Use console.log (JS/TS), print (Python/Ruby/Perl/R), echo (Shell), echo (PHP), fmt.Println (Go), or IO.puts (Elixir) to output a summary to context."),
937
+ .describe("Source code to execute. Use console.log (JS/TS), print (Python/Ruby/Perl/R), echo (Shell), echo (PHP), fmt.Println (Go), IO.puts (Elixir), or Console.WriteLine (C#) to output a summary to context."),
925
938
  timeout: z
926
939
  .coerce.number()
927
940
  .optional()
@@ -1212,11 +1225,12 @@ server.registerTool("ctx_execute_file", {
1212
1225
  "perl",
1213
1226
  "r",
1214
1227
  "elixir",
1228
+ "csharp",
1215
1229
  ])
1216
1230
  .describe("Runtime language"),
1217
1231
  code: z
1218
1232
  .string()
1219
- .describe("Code to process FILE_CONTENT (file_content in Elixir). Print summary via console.log/print/echo/IO.puts."),
1233
+ .describe("Code to process FILE_CONTENT (file_content in Elixir). Print summary via console.log/print/echo/IO.puts/Console.WriteLine."),
1220
1234
  timeout: z
1221
1235
  .coerce.number()
1222
1236
  .optional()
@@ -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 });