pi-x-ide 1.2.0 → 1.4.0

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/src/pi/zed.ts ADDED
@@ -0,0 +1,515 @@
1
+ import { copyFileSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent" with {
6
+ "resolution-mode": "import",
7
+ };
8
+ import type { EditorSelectionSnapshot, SelectionRange } from "../shared/protocol";
9
+ import { resolvePiConfigEnv, isProcessEnvOrPiConfigOverlay } from "../shared/config";
10
+ import { snapshotKey } from "../shared/format";
11
+ import { isPathInsideOrEqual } from "../shared/paths";
12
+ import { setLatestSelection, clearLatestSelection } from "./context";
13
+ import type { PiIdeRuntime } from "./state";
14
+ import { updateIdeUi } from "./ui";
15
+
16
+ export const PI_X_IDE_ZED_DB_ENV = "PI_X_IDE_ZED_DB";
17
+ export const ZED_POLL_INTERVAL_MS = 1000;
18
+
19
+ // ── Terminal detection ──────────────────────────────────────────
20
+
21
+ export function isZedTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
22
+ const configuredEnv = resolvePiConfigEnv(env);
23
+ return configuredEnv.ZED_TERM === "true" || configuredEnv.TERM_PROGRAM?.toLowerCase() === "zed";
24
+ }
25
+
26
+ export function isWsl(env: NodeJS.ProcessEnv = process.env): boolean {
27
+ const configuredEnv = resolvePiConfigEnv(env);
28
+ if (configuredEnv.WSL_DISTRO_NAME || configuredEnv.WSL_INTEROP) return true;
29
+ if (!isProcessEnvOrPiConfigOverlay(configuredEnv)) return false;
30
+ try {
31
+ return /microsoft|wsl/i.test(readFileSync("/proc/version", "utf8"));
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ export function normalizeZedPathForHost(input: string, env: NodeJS.ProcessEnv = process.env): string {
38
+ const configuredEnv = resolvePiConfigEnv(env);
39
+ if (!input || !isWsl(configuredEnv)) return input;
40
+
41
+ const driveMatch = input.match(/^([a-zA-Z]):[\\/](.*)$/);
42
+ if (driveMatch) {
43
+ const drive = driveMatch[1].toLowerCase();
44
+ const rest = driveMatch[2].replaceAll("\\", "/");
45
+ return `/mnt/${drive}/${rest}`;
46
+ }
47
+
48
+ const uncMatch = input.match(/^\\\\(?:wsl\$|wsl\.localhost)\\([^\\]+)\\(.*)$/i);
49
+ if (uncMatch) {
50
+ const distro = uncMatch[1];
51
+ const rest = uncMatch[2].replaceAll("\\", "/");
52
+ const currentDistro = configuredEnv.WSL_DISTRO_NAME;
53
+ if (!currentDistro || distro.toLowerCase() === currentDistro.toLowerCase()) {
54
+ return `/${rest}`;
55
+ }
56
+ }
57
+
58
+ return input;
59
+ }
60
+
61
+ // ── DB path resolution ─────────────────────────────────────────
62
+
63
+ export function resolveZedDbPath(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string | undefined {
64
+ const configuredEnv = resolvePiConfigEnv(env);
65
+ const override = configuredEnv[PI_X_IDE_ZED_DB_ENV]?.trim();
66
+ if (override) {
67
+ const normalizedOverride = normalizeZedPathForHost(override, configuredEnv);
68
+ return isFile(normalizedOverride) ? normalizedOverride : undefined;
69
+ }
70
+
71
+ const candidates = [
72
+ resolve(home, ".local", "share", "zed", "db", "0-stable", "db.sqlite"), // Linux
73
+ resolve(home, "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"), // macOS
74
+ resolve(home, "AppData", "Local", "Zed", "db", "0-stable", "db.sqlite"), // Windows
75
+ ...zedDbCandidatesFromWindowsEnv(configuredEnv),
76
+ ...zedDbCandidatesFromWslMount(configuredEnv),
77
+ ];
78
+
79
+ return candidates.find(isFile);
80
+ }
81
+
82
+ function zedDbCandidatesFromWindowsEnv(env: NodeJS.ProcessEnv): string[] {
83
+ const localAppData = env.LOCALAPPDATA?.trim();
84
+ if (localAppData) return [resolve(normalizeZedPathForHost(localAppData, env), "Zed", "db", "0-stable", "db.sqlite")];
85
+
86
+ const userProfile = env.USERPROFILE?.trim();
87
+ if (userProfile) {
88
+ return [
89
+ resolve(normalizeZedPathForHost(userProfile, env), "AppData", "Local", "Zed", "db", "0-stable", "db.sqlite"),
90
+ ];
91
+ }
92
+
93
+ return [];
94
+ }
95
+
96
+ function zedDbCandidatesFromWslMount(env: NodeJS.ProcessEnv): string[] {
97
+ if (!isWsl(env)) return [];
98
+ const usersRoot = "/mnt/c/Users";
99
+ try {
100
+ return readdirSync(usersRoot, { withFileTypes: true })
101
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
102
+ .map((entry) => resolve(usersRoot, entry.name, "AppData", "Local", "Zed", "db", "0-stable", "db.sqlite"));
103
+ } catch {
104
+ return [];
105
+ }
106
+ }
107
+
108
+ function isFile(path: string): boolean {
109
+ try {
110
+ return statSync(path).isFile();
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ interface ZedDatabaseHandle {
117
+ db: DatabaseSync;
118
+ cleanup: () => void;
119
+ }
120
+
121
+ function openZedDatabase(dbPath: string, env: NodeJS.ProcessEnv = process.env): ZedDatabaseHandle | undefined {
122
+ const configuredEnv = resolvePiConfigEnv(env);
123
+ // Direct open on live WAL-mode databases can succeed at construction time
124
+ // but fail on the first query on WSL/Windows mounts. Always snapshot
125
+ // under WSL to avoid "disk I/O error" during SQL execution.
126
+ if (isWsl(configuredEnv)) return openZedDatabaseSnapshot(dbPath);
127
+
128
+ try {
129
+ return { db: new DatabaseSync(dbPath, { readOnly: true }), cleanup: () => undefined };
130
+ } catch {
131
+ // Fall back to snapshot if direct open throws (e.g. file lock on non-WSL).
132
+ return openZedDatabaseSnapshot(dbPath);
133
+ }
134
+ }
135
+
136
+ function openZedDatabaseSnapshot(dbPath: string): ZedDatabaseHandle | undefined {
137
+ let snapshotDir: string | undefined;
138
+
139
+ try {
140
+ snapshotDir = mkdtempSync(join(tmpdir(), "pi-x-ide-zed-db-"));
141
+ const cleanupDir = snapshotDir;
142
+ const snapshotPath = join(cleanupDir, "db.sqlite");
143
+
144
+ // Copy the main DB file plus any WAL/SHM sidecars.
145
+ copyFileSync(dbPath, snapshotPath);
146
+ for (const suffix of ["-wal", "-shm"]) {
147
+ const sidecarPath = `${dbPath}${suffix}`;
148
+ if (isFile(sidecarPath)) copyFileSync(sidecarPath, `${snapshotPath}${suffix}`);
149
+ }
150
+
151
+ // Merge WAL changes into the main database file so we can open
152
+ // read-only afterwards without hitting a disk I/O error on
153
+ // cross-filesystem mounts (WSL / Windows).
154
+ try {
155
+ const dbRW = new DatabaseSync(snapshotPath);
156
+ dbRW.exec("PRAGMA wal_checkpoint(TRUNCATE)");
157
+ dbRW.close();
158
+ } catch {
159
+ // Checkpoint may fail—continue with the stale main DB.
160
+ }
161
+
162
+ return {
163
+ db: new DatabaseSync(snapshotPath, { readOnly: true }),
164
+ cleanup: () => rmSync(cleanupDir, { recursive: true, force: true }),
165
+ };
166
+ } catch {
167
+ if (snapshotDir) rmSync(snapshotDir, { recursive: true, force: true });
168
+ return undefined;
169
+ }
170
+ }
171
+
172
+ // ── Workspace path parsing ─────────────────────────────────────
173
+
174
+ export function parseZedWorkspacePaths(value: string | null): string[] {
175
+ if (!value) return [];
176
+ const trimmed = value.trim();
177
+ if (!trimmed) return [];
178
+
179
+ // Try JSON array. If it looks like JSON but is malformed, treat it as invalid
180
+ // instead of accidentally interpreting the raw JSON-ish text as a path.
181
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
182
+ try {
183
+ const parsed = JSON.parse(trimmed) as unknown;
184
+ return Array.isArray(parsed)
185
+ ? parsed.filter((item): item is string => typeof item === "string" && item.length > 0)
186
+ : [];
187
+ } catch {
188
+ return [];
189
+ }
190
+ }
191
+
192
+ return trimmed
193
+ .split(/\r?\n/)
194
+ .map((line) => line.trim())
195
+ .filter((line) => line.length > 0);
196
+ }
197
+
198
+ // ── UTF-8 byte-offset conversion ──────────────────────────────
199
+
200
+ function utf8ByteOffsetToStringIndex(text: string, byteOffset: number): number {
201
+ if (byteOffset <= 0) return 0;
202
+
203
+ const encoder = new TextEncoder();
204
+ let bytes = 0;
205
+
206
+ for (let index = 0; index < text.length; ) {
207
+ const codePoint = text.codePointAt(index);
208
+ if (codePoint === undefined) return text.length;
209
+
210
+ const nextIndex = index + (codePoint > 0xffff ? 2 : 1);
211
+ bytes += encoder.encode(text.slice(index, nextIndex)).length;
212
+ if (bytes >= byteOffset) return nextIndex;
213
+ index = nextIndex;
214
+ }
215
+
216
+ return text.length;
217
+ }
218
+
219
+ function byteOffsetToSelection(
220
+ text: string,
221
+ startByte: number,
222
+ endByte: number,
223
+ ): { start: { line: number; character: number }; end: { line: number; character: number } } {
224
+ const startIndex = utf8ByteOffsetToStringIndex(text, startByte);
225
+ const endIndex = utf8ByteOffsetToStringIndex(text, endByte);
226
+
227
+ const lines = text.split("\n");
228
+ let startLine = 0;
229
+ let startChar = 0;
230
+ let remaining = startIndex;
231
+ for (let lineIdx = 0; lineIdx < lines.length && remaining >= 0; lineIdx += 1) {
232
+ const lineLen = lines[lineIdx].length + 1; // +1 for the \n
233
+ if (remaining <= lines[lineIdx].length) {
234
+ startLine = lineIdx;
235
+ startChar = remaining;
236
+ break;
237
+ }
238
+ remaining -= lineLen;
239
+ }
240
+
241
+ let endLine = 0;
242
+ let endChar = 0;
243
+ remaining = endIndex;
244
+ for (let lineIdx = 0; lineIdx < lines.length && remaining >= 0; lineIdx += 1) {
245
+ const lineLen = lines[lineIdx].length + 1;
246
+ if (remaining <= lines[lineIdx].length) {
247
+ endLine = lineIdx;
248
+ endChar = remaining;
249
+ break;
250
+ }
251
+ remaining -= lineLen;
252
+ }
253
+
254
+ return {
255
+ start: { line: startLine, character: startChar },
256
+ end: { line: endLine, character: endChar },
257
+ };
258
+ }
259
+
260
+ // ── SQLite query ───────────────────────────────────────────────
261
+
262
+ function scoreWorkspace(workspacePaths: string | null, cwd: string, env: NodeJS.ProcessEnv): number {
263
+ const normalizedCwd = normalizeZedPathForHost(cwd, env);
264
+ return parseZedWorkspacePaths(workspacePaths).reduce((best, workspacePath) => {
265
+ const normalizedWorkspacePath = normalizeZedPathForHost(workspacePath, env);
266
+ if (isPathInsideOrEqual(normalizedWorkspacePath, normalizedCwd)) {
267
+ const resolved = resolve(normalizedWorkspacePath);
268
+ return Math.max(best, resolved.length);
269
+ }
270
+ return best;
271
+ }, 0);
272
+ }
273
+
274
+ export interface ResolveZedSelectionOptions {
275
+ dbPath: string;
276
+ cwd: string;
277
+ now?: number;
278
+ readFile?: (path: string) => string;
279
+ env?: NodeJS.ProcessEnv;
280
+ }
281
+
282
+ export function resolveZedSelection(options: ResolveZedSelectionOptions): EditorSelectionSnapshot | undefined {
283
+ const { dbPath, cwd, readFile = (path) => readFileSync(path, "utf8"), env: inputEnv = process.env } = options;
284
+ const env = resolvePiConfigEnv(inputEnv);
285
+ const dbHandle = openZedDatabase(dbPath, env);
286
+ if (!dbHandle) return undefined;
287
+
288
+ const { db, cleanup } = dbHandle;
289
+
290
+ try {
291
+ const rows = db
292
+ .prepare(
293
+ `SELECT i.kind AS item_kind,
294
+ e.item_id AS editor_id,
295
+ i.workspace_id,
296
+ w.paths AS workspace_paths,
297
+ w.timestamp,
298
+ e.buffer_path
299
+ FROM items i
300
+ JOIN panes p ON p.pane_id = i.pane_id AND p.workspace_id = i.workspace_id
301
+ JOIN workspaces w ON w.workspace_id = i.workspace_id
302
+ LEFT JOIN editors e ON e.item_id = i.item_id AND e.workspace_id = i.workspace_id
303
+ WHERE i.active = 1 AND p.active = 1
304
+ ORDER BY w.timestamp DESC`,
305
+ )
306
+ .all() as Array<{
307
+ item_kind: string;
308
+ editor_id: string | null;
309
+ workspace_id: string;
310
+ workspace_paths: string | null;
311
+ timestamp: number;
312
+ buffer_path: string | null;
313
+ }>;
314
+
315
+ // Filter to Editor rows only, score by workspace match.
316
+ // editor_id may be string or number — Zed uses INTEGER primary keys,
317
+ // node:sqlite returns them as JS numbers.
318
+ const scored = rows
319
+ .filter(
320
+ (row): row is typeof row & { editor_id: string | number; buffer_path: string } =>
321
+ row.item_kind === "Editor" && row.editor_id != null && typeof row.buffer_path === "string",
322
+ )
323
+ .map((row) => ({
324
+ ...row,
325
+ score: scoreWorkspace(row.workspace_paths, cwd, env),
326
+ }))
327
+ .filter((row) => row.score > 0);
328
+
329
+ if (scored.length === 0) return undefined;
330
+
331
+ // Pick best: highest score, then latest timestamp
332
+ scored.sort((a, b) => b.score - a.score || b.timestamp - a.timestamp);
333
+ const best = scored[0];
334
+ if (!best) return undefined;
335
+
336
+ const { editor_id, workspace_id, buffer_path, workspace_paths } = best;
337
+ const normalizedBufferPath = normalizeZedPathForHost(buffer_path, env);
338
+
339
+ // Determine workspace folder from matching path
340
+ const workspaceFolder = bestWorkspaceFolder(workspace_paths, cwd, env);
341
+
342
+ // Query editor contents
343
+ let contents: string | undefined;
344
+ const contentRow = db
345
+ .prepare("SELECT contents FROM editors WHERE item_id = ? AND workspace_id = ?")
346
+ .get(editor_id, workspace_id) as { contents: string | null | undefined } | undefined;
347
+
348
+ if (contentRow && typeof contentRow.contents === "string") {
349
+ contents = contentRow.contents;
350
+ } else {
351
+ // Fall back to reading the file on disk.
352
+ try {
353
+ contents = readFile(normalizedBufferPath);
354
+ } catch {
355
+ return undefined;
356
+ }
357
+ }
358
+
359
+ if (contents === undefined) return undefined;
360
+
361
+ // Query selections
362
+ const selectionRows = db
363
+ .prepare(
364
+ "SELECT start AS selection_start, end AS selection_end FROM editor_selections WHERE editor_id = ? AND workspace_id = ?",
365
+ )
366
+ .all(editor_id, workspace_id) as Array<{ selection_start: number; selection_end: number }>;
367
+
368
+ const ranges: SelectionRange[] = [];
369
+ for (const sel of selectionRows) {
370
+ const rawStart = sel.selection_start;
371
+ const rawEnd = sel.selection_end;
372
+
373
+ // Normalize reversed ranges
374
+ const start = Math.min(rawStart, rawEnd);
375
+ const end = Math.max(rawStart, rawEnd);
376
+
377
+ // Skip empty caret positions
378
+ if (start >= end) continue;
379
+
380
+ const text = contents.slice(
381
+ utf8ByteOffsetToStringIndex(contents, start),
382
+ utf8ByteOffsetToStringIndex(contents, end),
383
+ );
384
+
385
+ if (!text) continue;
386
+
387
+ const selection = byteOffsetToSelection(contents, start, end);
388
+
389
+ ranges.push({ text, selection });
390
+ }
391
+
392
+ return {
393
+ source: "zed",
394
+ filePath: normalizedBufferPath,
395
+ workspaceFolder,
396
+ ranges,
397
+ receivedAt: options.now ?? Date.now(),
398
+ };
399
+ } catch {
400
+ return undefined;
401
+ } finally {
402
+ try {
403
+ db.close();
404
+ } finally {
405
+ cleanup();
406
+ }
407
+ }
408
+ }
409
+
410
+ function bestWorkspaceFolder(workspacePaths: string | null, cwd: string, env: NodeJS.ProcessEnv): string | undefined {
411
+ const paths = parseZedWorkspacePaths(workspacePaths).map((workspacePath) =>
412
+ normalizeZedPathForHost(workspacePath, env),
413
+ );
414
+ const normalizedCwd = normalizeZedPathForHost(cwd, env);
415
+ const matches = paths.filter((workspacePath) => isPathInsideOrEqual(workspacePath, normalizedCwd));
416
+ if (matches.length === 0) return paths[0];
417
+ return matches.sort((a, b) => resolve(b).length - resolve(a).length)[0];
418
+ }
419
+
420
+ // ── Polling lifecycle ─────────────────────────────────────────
421
+
422
+ export function stopZedPolling(runtime: PiIdeRuntime): void {
423
+ if (runtime.zedPollTimer) {
424
+ clearTimeout(runtime.zedPollTimer);
425
+ runtime.zedPollTimer = undefined;
426
+ }
427
+ runtime.zedPollSelectionKey = undefined;
428
+ runtime.zedPollWalMtimeMs = undefined;
429
+ }
430
+
431
+ export function startZedPolling(
432
+ runtime: PiIdeRuntime,
433
+ ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "ui">,
434
+ options?: {
435
+ dbPath?: string;
436
+ intervalMs?: number;
437
+ generation?: number;
438
+ env?: NodeJS.ProcessEnv;
439
+ },
440
+ ): boolean {
441
+ const env = resolvePiConfigEnv(options?.env ?? process.env);
442
+ if (!isZedTerminal(env)) return false;
443
+
444
+ const dbPath = options?.dbPath ?? resolveZedDbPath(env);
445
+ if (!dbPath) return false;
446
+
447
+ const intervalMs = options?.intervalMs ?? ZED_POLL_INTERVAL_MS;
448
+ const generation = options?.generation;
449
+
450
+ // Set connection status and clear any stale WebSocket candidate state.
451
+ runtime.connection?.disconnect();
452
+ runtime.connection = undefined;
453
+ runtime.currentCandidate = undefined;
454
+ runtime.connectionStatus = "connected";
455
+ runtime.connectedServer = { name: "Zed", ide: "zed" };
456
+ runtime.connectionMessage = undefined;
457
+ updateIdeUi(runtime, ctx as ExtensionContext);
458
+
459
+ const schedule = () => {
460
+ if (runtime.zedPollTimer) return; // already stopped
461
+ runtime.zedPollTimer = setTimeout(() => {
462
+ runtime.zedPollTimer = undefined;
463
+
464
+ // Guard: session generation changed
465
+ if (generation !== undefined && runtime.sessionGeneration !== generation) return;
466
+ // Guard: WebSocket has taken over
467
+ if (runtime.connection && runtime.connection !== undefined) {
468
+ // connection is an IdeConnection — if WebSocket took over, stop
469
+ if (runtime.connectedServer?.ide !== "zed") return;
470
+ }
471
+
472
+ // Check whether the WAL sidecar has changed since the last poll.
473
+ // On WSL the DB snapshot is expensive (~10 MB copy + checkpoint),
474
+ // so skip the work when nothing changed in the editor.
475
+ const walPath = `${dbPath}-wal`;
476
+ let walMtimeMs: number | undefined;
477
+ try {
478
+ walMtimeMs = statSync(walPath).mtimeMs;
479
+ } catch {
480
+ // WAL absent — always poll (Zed may be in a different journal mode).
481
+ }
482
+ if (
483
+ walMtimeMs !== undefined &&
484
+ runtime.zedPollWalMtimeMs !== undefined &&
485
+ walMtimeMs === runtime.zedPollWalMtimeMs
486
+ ) {
487
+ schedule();
488
+ return;
489
+ }
490
+ runtime.zedPollWalMtimeMs = walMtimeMs;
491
+
492
+ let snapshot: EditorSelectionSnapshot | undefined;
493
+ try {
494
+ snapshot = resolveZedSelection({ dbPath, cwd: ctx.cwd, env });
495
+ } catch {
496
+ snapshot = undefined;
497
+ }
498
+
499
+ if (snapshot) {
500
+ const key = snapshotKey(snapshot);
501
+ if (key !== runtime.zedPollSelectionKey) {
502
+ runtime.zedPollSelectionKey = key;
503
+ setLatestSelection(runtime, snapshot, ctx as ExtensionContext);
504
+ }
505
+ } else {
506
+ clearLatestSelection(runtime, ctx as ExtensionContext);
507
+ }
508
+
509
+ schedule();
510
+ }, intervalMs);
511
+ };
512
+
513
+ schedule();
514
+ return true;
515
+ }
@@ -0,0 +1,100 @@
1
+ export type ConfigEnvValueType = "string" | "number" | "boolean";
2
+
3
+ export interface ConfigEnvOption {
4
+ readonly type: readonly ConfigEnvValueType[];
5
+ readonly description: string;
6
+ readonly default?: string;
7
+ }
8
+
9
+ export interface ConfigEnvPatternOption extends ConfigEnvOption {
10
+ readonly pattern: string;
11
+ }
12
+
13
+ export const CONFIG_ENV_VALUE_TYPES = ["string", "number", "boolean"] as const satisfies readonly ConfigEnvValueType[];
14
+
15
+ export function isConfigEnvValue(value: unknown): value is string | number | boolean {
16
+ return CONFIG_ENV_VALUE_TYPES.some((type) => typeof value === type);
17
+ }
18
+
19
+ export const CONFIG_ENV_OPTIONS = {
20
+ PI_X_IDE_LOCK_DIR: {
21
+ type: ["string"],
22
+ default: "~/.pi/pi-x-ide/lock",
23
+ description: "Directory containing Pi x IDE lock files. Defaults to ~/.pi/pi-x-ide/lock.",
24
+ },
25
+ PI_X_IDE_AUTO_INSTALL: {
26
+ type: ["string", "number", "boolean"],
27
+ default: "enabled",
28
+ description: "Controls VS Code-family extension auto-install. Values 0, false, and off disable it.",
29
+ },
30
+ PI_X_IDE_ZED_DB: {
31
+ type: ["string"],
32
+ description: "Override path to Zed's SQLite database.",
33
+ },
34
+ TERM_PROGRAM: {
35
+ type: ["string"],
36
+ description: "Terminal program marker used to detect VS Code, Cursor, Windsurf, or Zed.",
37
+ },
38
+ VSCODE_CWD: {
39
+ type: ["string"],
40
+ description: "VS Code-family cwd marker and IDE path hint.",
41
+ },
42
+ VSCODE_PID: {
43
+ type: ["string", "number"],
44
+ description: "VS Code-family process marker.",
45
+ },
46
+ VSCODE_IPC_HOOK_CLI: {
47
+ type: ["string"],
48
+ description: "VS Code-family IPC marker and IDE path hint.",
49
+ },
50
+ VSCODE_GIT_IPC_HANDLE: {
51
+ type: ["string"],
52
+ description: "VS Code-family Git IPC marker and IDE path hint.",
53
+ },
54
+ ZED_TERM: {
55
+ type: ["string", "boolean"],
56
+ description: "Zed terminal marker. Pi x IDE detects Zed when this is true.",
57
+ },
58
+ WSL_DISTRO_NAME: {
59
+ type: ["string"],
60
+ description: "WSL distribution name used for WSL path normalization and Zed database discovery.",
61
+ },
62
+ WSL_INTEROP: {
63
+ type: ["string"],
64
+ description: "WSL interop marker used for WSL path normalization and Zed database discovery.",
65
+ },
66
+ LOCALAPPDATA: {
67
+ type: ["string"],
68
+ description: "Windows local application data directory used to find Zed's database.",
69
+ },
70
+ USERPROFILE: {
71
+ type: ["string"],
72
+ description: "Windows user profile directory used to find Zed's database when LOCALAPPDATA is unavailable.",
73
+ },
74
+ PATH: {
75
+ type: ["string"],
76
+ description: "Executable search path used to find code, cursor, and windsurf CLIs.",
77
+ },
78
+ Path: {
79
+ type: ["string"],
80
+ description: "Windows-style executable search path used to find code, cursor, and windsurf CLIs.",
81
+ },
82
+ path: {
83
+ type: ["string"],
84
+ description: "Lowercase executable search path used to find code, cursor, and windsurf CLIs.",
85
+ },
86
+ PATHEXT: {
87
+ type: ["string"],
88
+ description: "Windows executable extensions used when searching for IDE CLIs.",
89
+ },
90
+ } as const satisfies Record<string, ConfigEnvOption>;
91
+
92
+ export const CONFIG_ENV_PATTERN_OPTIONS = [
93
+ {
94
+ pattern: "^(CURSOR|WINDSURF|CODEIUM).*",
95
+ type: ["string", "number", "boolean"],
96
+ description: "IDE-specific marker used to detect Cursor or Windsurf terminals.",
97
+ },
98
+ ] as const satisfies readonly ConfigEnvPatternOption[];
99
+
100
+ export type ConfigEnvOptionName = keyof typeof CONFIG_ENV_OPTIONS;
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { resolve } from "node:path";
4
+ import { isConfigEnvValue } from "./config-options";
5
+
6
+ export const PI_CONFIG_FILE = "config.json";
7
+
8
+ const processEnvOverlays = new WeakSet<NodeJS.ProcessEnv>();
9
+
10
+ export function resolvePiConfigPath(home: string = homedir()): string {
11
+ return resolve(home, ".pi", PI_CONFIG_FILE);
12
+ }
13
+
14
+ export function readPiConfigEnv(configPath: string = resolvePiConfigPath()): NodeJS.ProcessEnv {
15
+ let parsed: unknown;
16
+ try {
17
+ parsed = JSON.parse(readFileSync(configPath, "utf8")) as unknown;
18
+ } catch {
19
+ return {};
20
+ }
21
+
22
+ if (!isRecord(parsed) || !isRecord(parsed.env)) return {};
23
+
24
+ return Object.fromEntries(
25
+ Object.entries(parsed.env)
26
+ .filter((entry): entry is [string, string | number | boolean] => {
27
+ const [key, value] = entry;
28
+ return key.length > 0 && isConfigEnvValue(value);
29
+ })
30
+ .map(([key, value]) => [key, String(value)]),
31
+ );
32
+ }
33
+
34
+ export function resolvePiConfigEnv(
35
+ env: NodeJS.ProcessEnv = process.env,
36
+ options: { configPath?: string } = {},
37
+ ): NodeJS.ProcessEnv {
38
+ if (env !== process.env && !options.configPath) return env;
39
+
40
+ const configEnv = readPiConfigEnv(options.configPath);
41
+ if (Object.keys(configEnv).length === 0) return env;
42
+
43
+ const merged = { ...configEnv, ...env };
44
+ if (env === process.env) processEnvOverlays.add(merged);
45
+ return merged;
46
+ }
47
+
48
+ export function isProcessEnvOrPiConfigOverlay(env: NodeJS.ProcessEnv): boolean {
49
+ return env === process.env || processEnvOverlays.has(env);
50
+ }
51
+
52
+ function isRecord(value: unknown): value is Record<string, unknown> {
53
+ return typeof value === "object" && value !== null && !Array.isArray(value);
54
+ }
@@ -1,11 +1,12 @@
1
1
  import { homedir } from "node:os";
2
2
  import { isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { resolvePiConfigEnv } from "./config";
3
4
  import { LOCK_DIR_ENV } from "./protocol";
4
5
 
5
6
  export function resolveLockDir(env: NodeJS.ProcessEnv = process.env): string {
6
- return env[LOCK_DIR_ENV] && env[LOCK_DIR_ENV].trim()
7
- ? resolve(env[LOCK_DIR_ENV])
8
- : resolve(homedir(), ".pi", "pi-x-ide");
7
+ const configuredEnv = resolvePiConfigEnv(env);
8
+ const override = configuredEnv[LOCK_DIR_ENV]?.trim();
9
+ return override ? resolve(override) : resolve(homedir(), ".pi", "pi-x-ide", "lock");
9
10
  }
10
11
 
11
12
  export function normalizePath(input: string): string {