context-mode 1.0.122 → 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.122"
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.122",
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.122",
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.122",
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.122",
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",
@@ -21,6 +21,12 @@ export const CLIENT_NAME_TO_PLATFORM = {
21
21
  "Kiro CLI": "kiro",
22
22
  "Pi CLI": "pi",
23
23
  "Pi Coding Agent": "pi",
24
+ // Issue #542 — Pi rebranded to OMP. Upstream
25
+ // refs/platforms/oh-my-pi/packages/coding-agent/src/mcp/client.ts:46-49
26
+ // ships clientInfo.name = "omp-coding-agent". Resolved to the OMP
27
+ // adapter (~/.omp/, PI_CODING_AGENT_DIR). Legacy "Pi CLI" /
28
+ // "Pi Coding Agent" entries above still resolve to the pi adapter.
29
+ "omp-coding-agent": "omp",
24
30
  "Zed": "zed",
25
31
  "zed": "zed",
26
32
  "qwen-code": "qwen-code",
@@ -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_PROJECT_DIR"]]];
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,56 +80,148 @@ 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"]],
121
- // pi PI_PROJECT_DIR consumed by src/adapters/pi/extension.ts:154 + src/server.ts:153
122
- // — implies the Pi runtime sets it before invoking the extension.
123
- ["pi", ["PI_PROJECT_DIR"]],
158
+ ["omp", [
159
+ { name: "PI_CODING_AGENT_DIR", role: "workspace" },
160
+ ]],
161
+ // pi — Issue #542 marker correction. PI_PROJECT_DIR is a consumer-set
162
+ // var (read by src/adapters/pi/extension.ts) but is NOT auto-set by
163
+ // the Pi runtime — verified against
164
+ // refs/platforms/oh-my-pi/packages/coding-agent/src/mcp/transports/stdio.ts:55-63
165
+ // (env passthrough only, no synthesis). The Pi runtime DOES set
166
+ // PI_CONFIG_DIR (config dir override), PI_SESSION_FILE (active session
167
+ // path), and PI_COMPILED (binary build marker). PI_CODING_AGENT_DIR is
168
+ // owned by OMP above; keep it there.
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
+ ]],
124
184
  // openclaw — removed (runtime never sets OPENCLAW_HOME or OPENCLAW_CLI;
125
185
  // detection falls through to ~/.openclaw/ config-dir tier below).
126
186
  // kiro — not listed (no auto-set process env vars; ~/.kiro/ config-dir tier).
127
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
+ }
128
225
  /**
129
226
  * Sync map from platform identifier → home-relative path segments where that
130
227
  * platform stores its config. Mirrors the `super([...])` argument passed by
@@ -198,7 +295,7 @@ export function detectPlatform(clientInfo) {
198
295
  }
199
296
  // ── High confidence: environment variables ─────────────
200
297
  for (const [platform, vars] of PLATFORM_ENV_VARS) {
201
- if (vars.some((v) => process.env[v])) {
298
+ if (vars.some((v) => v.detect !== false && process.env[v.name])) {
202
299
  // Issue #539 belt-and-suspenders: VSCODE_PID/VSCODE_CWD are exported
203
300
  // by VS Code into EVERY child process — including a Claude Code CLI
204
301
  // launched from the integrated terminal. If env vars alone want to
@@ -218,7 +315,7 @@ export function detectPlatform(clientInfo) {
218
315
  return {
219
316
  platform,
220
317
  confidence: "high",
221
- reason: `${vars.join(" or ")} env var set`,
318
+ reason: `${vars.filter((v) => v.detect !== false).map((v) => v.name).join(" or ")} env var set`,
222
319
  };
223
320
  }
224
321
  }
@@ -245,13 +342,15 @@ export function detectPlatform(clientInfo) {
245
342
  reason: "~/.codex/ directory exists",
246
343
  };
247
344
  }
248
- if (existsSync(resolve(home, ".cursor"))) {
249
- return {
250
- platform: "cursor",
251
- confidence: "medium",
252
- reason: "~/.cursor/ directory exists",
253
- };
254
- }
345
+ // Issue #542 — CLI agents BEFORE host IDEs.
346
+ //
347
+ // Cursor (a VSCode fork) is the most installed editor across our user
348
+ // base. Checking ~/.cursor/ first means every CLI agent co-installed
349
+ // with Cursor (Pi, OMP, Kiro, Qwen) silently routes through
350
+ // CursorAdapter even though the agent owns the session — Cursor merely
351
+ // hosts the terminal. Reorder: agents (.kiro/.omp/.pi/.qwen/.openclaw)
352
+ // win the medium-confidence tier, editors (~/.cursor/, ~/.vscode/,
353
+ // JetBrains) lose. Verified by the detect-config-dir.test.ts matrix.
255
354
  if (existsSync(resolve(home, ".kiro"))) {
256
355
  return {
257
356
  platform: "kiro",
@@ -288,6 +387,14 @@ export function detectPlatform(clientInfo) {
288
387
  reason: "~/.openclaw/ directory exists",
289
388
  };
290
389
  }
390
+ // Cursor / host IDEs — checked AFTER all CLI agents (issue #542).
391
+ if (existsSync(resolve(home, ".cursor"))) {
392
+ return {
393
+ platform: "cursor",
394
+ confidence: "medium",
395
+ reason: "~/.cursor/ directory exists",
396
+ };
397
+ }
291
398
  if (existsSync(resolve(home, ".config", "kilo"))) {
292
399
  return {
293
400
  platform: "kilo",
@@ -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,12 +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
- const projectDir = 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
+ });
237
291
  const db = getOrCreateDB();
238
292
  // ── 1. session_start — Initialize session ──────────────
239
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/cli.js CHANGED
@@ -125,7 +125,16 @@ if (args[0] === "doctor") {
125
125
  doctor().then((code) => process.exit(code));
126
126
  }
127
127
  else if (args[0] === "upgrade") {
128
- upgrade().catch((err) => {
128
+ // Issue #542 — accept --platform <id> from the ctx_upgrade MCP handler,
129
+ // which forwards the live MCP clientInfo's resolved PlatformId. The flag
130
+ // wins over upgrade()'s own detectPlatform() heuristic chain so an
131
+ // ambiguous config-dir collision (e.g. ~/.cursor + ~/.pi both present)
132
+ // can never misroute the upgrade.
133
+ const platformFlagIdx = args.indexOf("--platform");
134
+ const platformArg = platformFlagIdx >= 0 && args[platformFlagIdx + 1]
135
+ ? args[platformFlagIdx + 1]
136
+ : undefined;
137
+ upgrade(platformArg ? { platform: platformArg } : undefined).catch((err) => {
129
138
  const message = err instanceof Error ? err.message : String(err);
130
139
  p.log.error(color.red(message));
131
140
  process.exit(1);
@@ -601,11 +610,18 @@ async function insight(port) {
601
610
  /* -------------------------------------------------------
602
611
  * Upgrade — adapter-aware hook configuration
603
612
  * ------------------------------------------------------- */
604
- async function upgrade() {
613
+ async function upgrade(opts) {
605
614
  if (process.stdout.isTTY)
606
615
  console.clear();
607
- // Detect platform
608
- const detection = detectPlatform();
616
+ // Issue #542 — when the MCP ctx_upgrade handler threads through an
617
+ // explicit --platform <id> (resolved from live clientInfo), trust it
618
+ // over the local heuristic chain. detectPlatform() with no args cannot
619
+ // see the MCP handshake and falls through to the config-dir tier,
620
+ // which misdetects Pi/OMP installs as Cursor on systems where both
621
+ // ~/.cursor/ and ~/.pi/ exist.
622
+ const detection = opts?.platform
623
+ ? { platform: opts.platform, confidence: "high", reason: `--platform ${opts.platform} from ctx_upgrade handler` }
624
+ : detectPlatform();
609
625
  const adapter = await getAdapter(detection.platform);
610
626
  p.intro(color.bgCyan(color.black(" context-mode upgrade ")));
611
627
  p.log.info(`Platform: ${color.cyan(adapter.name)}` +
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
  /**
@@ -2745,12 +2757,27 @@ server.registerTool("ctx_upgrade", {
2745
2757
  }
2746
2758
  }
2747
2759
  catch { /* best effort — don't block upgrade */ }
2760
+ // Issue #542 — thread MCP clientInfo into the spawned upgrade
2761
+ // process. detectPlatform() runs IN-PROCESS here (no spawn boundary)
2762
+ // so clientInfo from the MCP handshake is the highest-confidence
2763
+ // signal available. We forward the resolved PlatformId as a
2764
+ // --platform flag (cross-shell safe on POSIX, Git Bash, PowerShell,
2765
+ // and cmd.exe — unlike env-var prefixes). If detection fails we
2766
+ // skip the flag and let upgrade()'s own detectPlatform() fall back.
2767
+ let platformFlag = "";
2768
+ try {
2769
+ const { detectPlatform } = await import("./adapters/detect.js");
2770
+ const clientInfo = server.server.getClientVersion();
2771
+ const signal = detectPlatform(clientInfo ?? undefined);
2772
+ platformFlag = ` --platform ${signal.platform}`;
2773
+ }
2774
+ catch { /* best effort — fall back to upgrade()'s own detect */ }
2748
2775
  let cmd;
2749
2776
  if (existsSync(bundlePath)) {
2750
- cmd = `${buildNodeCommand(bundlePath)} upgrade`;
2777
+ cmd = `${buildNodeCommand(bundlePath)} upgrade${platformFlag}`;
2751
2778
  }
2752
2779
  else if (existsSync(fallbackPath)) {
2753
- cmd = `${buildNodeCommand(fallbackPath)} upgrade`;
2780
+ cmd = `${buildNodeCommand(fallbackPath)} upgrade${platformFlag}`;
2754
2781
  }
2755
2782
  else {
2756
2783
  // Inline fallback: neither CLI file exists (e.g. marketplace installs).
@@ -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;