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