context-mode 1.0.57 → 1.0.58

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.
@@ -151,8 +151,11 @@ export class OpenCodeAdapter {
151
151
  if (this.platform === "kilo") {
152
152
  return [
153
153
  resolve("kilo.json"),
154
- resolve(".kilocode", "kilo.json"),
154
+ resolve("kilo.jsonc"),
155
+ resolve(".kilo", "kilo.json"),
156
+ resolve(".kilo", "kilo.jsonc"),
155
157
  join(homedir(), ".config", "kilo", "kilo.json"),
158
+ join(homedir(), ".config", "kilo", "kilo.jsonc"),
156
159
  ];
157
160
  }
158
161
  return [
@@ -165,7 +168,14 @@ export class OpenCodeAdapter {
165
168
  ];
166
169
  }
167
170
  getSessionDir() {
168
- const dir = join(homedir(), ".config", this.platform, "context-mode", "sessions");
171
+ let configDir;
172
+ if (process.platform === "win32") {
173
+ configDir = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
174
+ }
175
+ else {
176
+ configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
177
+ }
178
+ const dir = join(configDir, this.platform, "context-mode", "sessions");
169
179
  mkdirSync(dir, { recursive: true });
170
180
  return dir;
171
181
  }
@@ -224,25 +234,38 @@ export class OpenCodeAdapter {
224
234
  };
225
235
  }
226
236
  readSettings() {
227
- // Try project-local paths first, then global config
228
- // const paths = this.getConfigFilePaths();
229
- // for (const configPath of paths) {
230
237
  this.settingsPath = undefined;
231
- for (const configPath of this.paths()) {
238
+ const configPaths = this.paths();
239
+ const globalPaths = new Set(configPaths.filter(p => p.includes(homedir())));
240
+ let firstValidSettings = null;
241
+ let firstValidPath;
242
+ for (const configPath of configPaths) {
232
243
  try {
233
244
  const raw = readFileSync(configPath, "utf-8");
234
- this.settingsPath = configPath;
235
245
  const text = configPath.endsWith(".jsonc") ? stripJsonComments(raw) : raw;
236
- return JSON.parse(text);
246
+ const settings = JSON.parse(text);
247
+ if (!firstValidSettings) {
248
+ firstValidSettings = settings;
249
+ firstValidPath = configPath;
250
+ }
251
+ const isGlobalConfig = globalPaths.has(configPath);
252
+ if (this.hasContextModePlugin(settings) || isGlobalConfig) {
253
+ this.settingsPath = configPath;
254
+ return settings;
255
+ }
237
256
  }
238
257
  catch {
239
258
  continue;
240
259
  }
241
260
  }
261
+ if (firstValidSettings) {
262
+ this.settingsPath = firstValidPath;
263
+ return firstValidSettings;
264
+ }
242
265
  return null;
243
266
  }
244
267
  writeSettings(settings) {
245
- // Write to opencode.json/kilo.json in current directory
268
+ // Write to opencode.json(c)/kilo.json(c) in current directory
246
269
  writeFileSync(this.getSettingsPath(), JSON.stringify(settings, null, 2) + "\n", "utf-8");
247
270
  }
248
271
  // ── Diagnostics (doctor) ─────────────────────────────────
@@ -259,9 +282,8 @@ export class OpenCodeAdapter {
259
282
  return results;
260
283
  }
261
284
  // Check for "context-mode" in plugin array
262
- const plugins = settings.plugin;
263
- if (plugins && Array.isArray(plugins)) {
264
- const hasPlugin = plugins.some((p) => p.includes("context-mode"));
285
+ const hasPlugin = this.hasContextModePlugin(settings);
286
+ if (Array.isArray(settings.plugin)) {
265
287
  results.push({
266
288
  check: "Plugin registration",
267
289
  status: hasPlugin ? "pass" : "fail",
@@ -277,7 +299,7 @@ export class OpenCodeAdapter {
277
299
  results.push({
278
300
  check: "Plugin registration",
279
301
  status: "fail",
280
- message: "No plugin array found in opencode.json",
302
+ message: `No plugin array found in ${this.platform}.json or ${this.platform}.jsonc`,
281
303
  fix: "context-mode upgrade",
282
304
  });
283
305
  }
@@ -285,7 +307,7 @@ export class OpenCodeAdapter {
285
307
  results.push({
286
308
  check: "SessionStart hook",
287
309
  status: "warn",
288
- message: "SessionStart not supported in OpenCode (see issues #14808, #5409)",
310
+ message: `SessionStart not supported in ${this.name} (see issues #14808, #5409)`,
289
311
  });
290
312
  return results;
291
313
  }
@@ -298,21 +320,17 @@ export class OpenCodeAdapter {
298
320
  message: `Could not read ${this.platform}.json or ${this.platform}.jsonc`,
299
321
  };
300
322
  }
301
- const plugins = settings.plugin;
302
- if (plugins && Array.isArray(plugins)) {
303
- const hasPlugin = plugins.some((p) => p.includes("context-mode"));
304
- if (hasPlugin) {
305
- return {
306
- check: "Plugin registration",
307
- status: "pass",
308
- message: "context-mode found in plugin array",
309
- };
310
- }
323
+ if (this.hasContextModePlugin(settings)) {
324
+ return {
325
+ check: "Plugin registration",
326
+ status: "pass",
327
+ message: "context-mode found in plugin array",
328
+ };
311
329
  }
312
330
  return {
313
331
  check: "Plugin registration",
314
332
  status: "fail",
315
- message: "context-mode not found in opencode.json plugin array",
333
+ message: `context-mode not found in ${this.platform}.json plugin array`,
316
334
  fix: "context-mode upgrade",
317
335
  };
318
336
  }
@@ -347,20 +365,23 @@ export class OpenCodeAdapter {
347
365
  return changes;
348
366
  }
349
367
  backupSettings() {
350
- this.settingsPath = undefined;
351
- for (const configPath of this.paths()) {
368
+ const check = this.checkPluginRegistration();
369
+ if (!this.settingsPath)
370
+ return null;
371
+ if (check.status === "pass") {
372
+ return this.settingsPath;
373
+ }
374
+ else {
352
375
  try {
353
- accessSync(configPath, constants.R_OK);
354
- this.settingsPath = configPath;
355
- const backupPath = configPath + ".bak";
356
- copyFileSync(configPath, backupPath);
376
+ accessSync(this.settingsPath, constants.R_OK);
377
+ const backupPath = this.settingsPath + ".bak";
378
+ copyFileSync(this.settingsPath, backupPath);
357
379
  return backupPath;
358
380
  }
359
381
  catch {
360
- continue;
382
+ return null;
361
383
  }
362
384
  }
363
- return null;
364
385
  }
365
386
  setHookPermissions(_pluginRoot) {
366
387
  // OpenCode uses TS plugin paradigm — no shell scripts to chmod
@@ -370,6 +391,13 @@ export class OpenCodeAdapter {
370
391
  // OpenCode manages plugins through npm/opencode.json — no separate registry
371
392
  }
372
393
  // ── Internal helpers ───────────────────────────────────
394
+ /**
395
+ * Check whether a settings object has the context-mode plugin registered.
396
+ */
397
+ hasContextModePlugin(settings) {
398
+ const plugins = settings.plugin;
399
+ return Array.isArray(plugins) && plugins.some((p) => typeof p === "string" && p.includes("context-mode"));
400
+ }
373
401
  /**
374
402
  * Extract session ID from OpenCode hook input.
375
403
  * OpenCode uses camelCase sessionID.
package/build/cli.js CHANGED
@@ -17,7 +17,7 @@ import { execFileSync } from "node:child_process";
17
17
  import { readFileSync, writeFileSync, cpSync, accessSync, existsSync, rmSync, closeSync, openSync, chmodSync, constants } from "node:fs";
18
18
  import { request as httpsRequest } from "node:https";
19
19
  import { resolve, dirname, join } from "node:path";
20
- import { tmpdir, devNull } from "node:os";
20
+ import { tmpdir, devNull, homedir } from "node:os";
21
21
  import { fileURLToPath, pathToFileURL } from "node:url";
22
22
  import { detectRuntimes, getRuntimeSummary, hasBunRuntime, getAvailableLanguages, } from "./runtime.js";
23
23
  // ── Adapter imports ──────────────────────────────────────
@@ -98,7 +98,7 @@ else {
98
98
  export function toUnixPath(p) {
99
99
  return p.replace(/\\/g, "/");
100
100
  }
101
- function getPluginRoot() {
101
+ function defaultPluginRoot() {
102
102
  const __filename = fileURLToPath(import.meta.url);
103
103
  const __dirname = dirname(__filename);
104
104
  // build/cli.js or src/cli.ts → go up one level; cli.bundle.mjs at project root → stay here
@@ -108,6 +108,23 @@ function getPluginRoot() {
108
108
  }
109
109
  return __dirname;
110
110
  }
111
+ // Opencode/Kilocode install plugins from npm into .cache folder
112
+ function cachePluginRoot(platform) {
113
+ if (process.platform === "win32") {
114
+ const localApp = process.env.LOCALAPPDATA;
115
+ if (localApp)
116
+ return resolve(localApp, platform, "node_modules", "context-mode");
117
+ return resolve(homedir(), "AppData", "Local", platform, "node_modules", "context-mode");
118
+ }
119
+ return resolve(homedir(), ".cache", platform, "node_modules", "context-mode");
120
+ }
121
+ function getPluginRoot() {
122
+ const platform = detectPlatform().platform;
123
+ if (platform === 'opencode' || platform === 'kilo') {
124
+ return cachePluginRoot(platform);
125
+ }
126
+ return defaultPluginRoot();
127
+ }
111
128
  function getLocalVersion() {
112
129
  try {
113
130
  const pkg = JSON.parse(readFileSync(resolve(getPluginRoot(), "package.json"), "utf-8"));
@@ -358,6 +375,8 @@ async function upgrade() {
358
375
  const newVersion = newPkg.version ?? "unknown";
359
376
  if (newVersion === localVersion) {
360
377
  p.log.success(color.green("Already on latest") + ` — v${localVersion}`);
378
+ rmSync(tmpDir, { recursive: true, force: true });
379
+ return;
361
380
  }
362
381
  else {
363
382
  p.log.info(`Update available: ${color.yellow("v" + localVersion)} → ${color.green("v" + newVersion)}`);
@@ -412,23 +431,25 @@ async function upgrade() {
412
431
  timeout: 60000,
413
432
  });
414
433
  s.stop("Dependencies ready");
415
- // Rebuild native addons for current Node.js ABI (fixes #131)
416
- s.start("Rebuilding native addons");
417
- try {
418
- execFileSync("npm", ["rebuild", "better-sqlite3"], {
419
- cwd: pluginRoot,
420
- stdio: "pipe",
421
- timeout: 60000,
422
- });
423
- s.stop(color.green("Native addons rebuilt"));
424
- changes.push("Rebuilt better-sqlite3 for current Node.js");
425
- }
426
- catch (err) {
427
- const message = err instanceof Error ? err.message : String(err);
428
- s.stop(color.yellow("Native addon rebuild warning"));
429
- p.log.warn(color.yellow("better-sqlite3 rebuild issue") +
430
- ` ${message}` +
431
- color.dim(`\n Try manually: cd "${pluginRoot}" && npm rebuild better-sqlite3`));
434
+ if (detection.platform !== 'opencode' && detection.platform !== 'kilo') {
435
+ // Rebuild native addons for current Node.js ABI (fixes #131)
436
+ s.start("Rebuilding native addons");
437
+ try {
438
+ execFileSync("npm", ["rebuild", "better-sqlite3"], {
439
+ cwd: pluginRoot,
440
+ stdio: "pipe",
441
+ timeout: 60000,
442
+ });
443
+ s.stop(color.green("Native addons rebuilt"));
444
+ changes.push("Rebuilt better-sqlite3 for current Node.js");
445
+ }
446
+ catch (err) {
447
+ const message = err instanceof Error ? err.message : String(err);
448
+ s.stop(color.yellow("Native addon rebuild warning"));
449
+ p.log.warn(color.yellow("better-sqlite3 rebuild issue") +
450
+ ` ${message}` +
451
+ color.dim(`\n Try manually: cd "${pluginRoot}" && npm rebuild better-sqlite3`));
452
+ }
432
453
  }
433
454
  // Update global npm
434
455
  s.start("Updating npm global package");
@@ -465,10 +486,13 @@ async function upgrade() {
465
486
  // Step 3: Backup settings — adapter-aware
466
487
  p.log.step(`Backing up ${adapter.name} settings...`);
467
488
  const backupPath = adapter.backupSettings();
468
- if (backupPath) {
489
+ if (backupPath?.endsWith(".bak")) {
469
490
  p.log.success(color.green("Backup created") + color.dim(" -> " + backupPath));
470
491
  changes.push("Backed up settings");
471
492
  }
493
+ else if (backupPath) {
494
+ p.log.success(color.green("Backup skipped") + color.dim(" — no changes needed"));
495
+ }
472
496
  else {
473
497
  p.log.warn(color.yellow("No existing settings to backup") +
474
498
  " — a new one will be created");
@@ -69,13 +69,13 @@ export declare function closeDB(db: DatabaseInstance): void;
69
69
  */
70
70
  export declare function defaultDBPath(prefix?: string): string;
71
71
  /**
72
- * SQLiteBase minimal base class that handles open/close/cleanup lifecycle.
73
- *
74
- * Subclasses call `super(dbPath)` to open the database with WAL pragmas
75
- * applied, then implement `initSchema()` and `prepareStatements()`.
76
- *
77
- * The `db` getter exposes the raw `DatabaseInstance` to subclasses only.
72
+ * Retry a DB operation with exponential backoff on SQLITE_BUSY errors.
73
+ * Catches errors containing "SQLITE_BUSY" or "database is locked" and
74
+ * retries up to 3 times with delays: 100ms, 500ms, 2000ms.
75
+ * If all retries fail, throws a descriptive error.
76
+ * Pass custom delays for testing (e.g., [0, 0, 0] to skip waits).
78
77
  */
78
+ export declare function withRetry<T>(fn: () => T, delays?: number[]): T;
79
79
  export declare abstract class SQLiteBase {
80
80
  #private;
81
81
  constructor(dbPath: string);
@@ -89,6 +89,7 @@ export declare abstract class SQLiteBase {
89
89
  get dbPath(): string;
90
90
  /** Close the database connection without deleting files. */
91
91
  close(): void;
92
+ protected withRetry<T>(fn: () => T): T;
92
93
  /**
93
94
  * Close the connection and delete all associated DB files (main, WAL, SHM).
94
95
  * Call on process exit or at end of session lifecycle.
package/build/db-base.js CHANGED
@@ -178,6 +178,38 @@ export function defaultDBPath(prefix = "context-mode") {
178
178
  return join(tmpdir(), `${prefix}-${process.pid}.db`);
179
179
  }
180
180
  // ─────────────────────────────────────────────────────────
181
+ // Retry helper
182
+ // ─────────────────────────────────────────────────────────
183
+ /**
184
+ * Retry a DB operation with exponential backoff on SQLITE_BUSY errors.
185
+ * Catches errors containing "SQLITE_BUSY" or "database is locked" and
186
+ * retries up to 3 times with delays: 100ms, 500ms, 2000ms.
187
+ * If all retries fail, throws a descriptive error.
188
+ * Pass custom delays for testing (e.g., [0, 0, 0] to skip waits).
189
+ */
190
+ export function withRetry(fn, delays = [100, 500, 2000]) {
191
+ let lastError;
192
+ for (let attempt = 0; attempt <= delays.length; attempt++) {
193
+ try {
194
+ return fn();
195
+ }
196
+ catch (err) {
197
+ const msg = err instanceof Error ? err.message : String(err);
198
+ if (!msg.includes("SQLITE_BUSY") && !msg.includes("database is locked")) {
199
+ throw err;
200
+ }
201
+ lastError = err instanceof Error ? err : new Error(msg);
202
+ if (attempt < delays.length) {
203
+ const delay = delays[attempt];
204
+ const start = Date.now();
205
+ while (Date.now() - start < delay) { /* busy-wait for sync retry */ }
206
+ }
207
+ }
208
+ }
209
+ throw new Error(`SQLITE_BUSY: database is locked after ${delays.length} retries. ` +
210
+ `Original error: ${lastError?.message}`);
211
+ }
212
+ // ─────────────────────────────────────────────────────────
181
213
  // Base class
182
214
  // ─────────────────────────────────────────────────────────
183
215
  /**
@@ -188,13 +220,40 @@ export function defaultDBPath(prefix = "context-mode") {
188
220
  *
189
221
  * The `db` getter exposes the raw `DatabaseInstance` to subclasses only.
190
222
  */
223
+ /**
224
+ * Track all live DatabaseInstance objects so we can close them on process exit.
225
+ * Prevents better-sqlite3 segfaults caused by V8 garbage-collecting Database
226
+ * objects after the native addon context is already torn down.
227
+ *
228
+ * Uses a global symbol so the set and exit handler survive vitest's module
229
+ * re-imports within the same fork process (ESM isolate mode clears
230
+ * module-level state but globalThis persists).
231
+ */
232
+ const _kLiveDBs = Symbol.for("__context_mode_live_dbs__");
233
+ const _liveDBs = (() => {
234
+ const g = globalThis;
235
+ if (!g[_kLiveDBs]) {
236
+ g[_kLiveDBs] = new Set();
237
+ process.on("exit", () => {
238
+ for (const db of g[_kLiveDBs]) {
239
+ try {
240
+ db.close();
241
+ }
242
+ catch { /* already closed */ }
243
+ }
244
+ g[_kLiveDBs].clear();
245
+ });
246
+ }
247
+ return g[_kLiveDBs];
248
+ })();
191
249
  export class SQLiteBase {
192
250
  #dbPath;
193
251
  #db;
194
252
  constructor(dbPath) {
195
253
  const Database = loadDatabase();
196
254
  this.#dbPath = dbPath;
197
- this.#db = new Database(dbPath, { timeout: 5000 });
255
+ this.#db = new Database(dbPath, { timeout: 30000 });
256
+ _liveDBs.add(this.#db);
198
257
  applyWALPragmas(this.#db);
199
258
  this.initSchema();
200
259
  this.prepareStatements();
@@ -209,13 +268,18 @@ export class SQLiteBase {
209
268
  }
210
269
  /** Close the database connection without deleting files. */
211
270
  close() {
271
+ _liveDBs.delete(this.#db);
212
272
  closeDB(this.#db);
213
273
  }
274
+ withRetry(fn) {
275
+ return withRetry(fn);
276
+ }
214
277
  /**
215
278
  * Close the connection and delete all associated DB files (main, WAL, SHM).
216
279
  * Call on process exit or at end of session lifecycle.
217
280
  */
218
281
  cleanup() {
282
+ _liveDBs.delete(this.#db);
219
283
  closeDB(this.#db);
220
284
  deleteDBFiles(this.#dbPath);
221
285
  }
@@ -53,7 +53,7 @@ interface CompactingHookOutput {
53
53
  /**
54
54
  * OpenCode plugin factory. Called once when OpenCode loads the plugin.
55
55
  * Returns an object mapping hook event names to async handler functions.
56
- */
56
+ */
57
57
  export declare const ContextModePlugin: (ctx: PluginContext) => Promise<{
58
58
  "tool.execute.before": (input: BeforeHookInput, output: BeforeHookOutput) => Promise<void>;
59
59
  "tool.execute.after": (input: AfterHookInput, output: AfterHookOutput) => Promise<void>;
@@ -14,37 +14,25 @@
14
14
  * - No routing file auto-write (avoid dirtying project trees)
15
15
  * - Session cleanup happens at plugin init (no SessionStart)
16
16
  */
17
- import { createHash, randomUUID } from "node:crypto";
18
- import { mkdirSync } from "node:fs";
19
- import { homedir } from "node:os";
20
- import { dirname, join, resolve } from "node:path";
17
+ import { randomUUID } from "node:crypto";
18
+ import { dirname, resolve } from "node:path";
21
19
  import { fileURLToPath, pathToFileURL } from "node:url";
22
20
  import { SessionDB } from "./session/db.js";
23
21
  import { extractEvents } from "./session/extract.js";
24
22
  import { buildResumeSnapshot } from "./session/snapshot.js";
23
+ import { OpenCodeAdapter } from "./adapters/opencode/index.js";
25
24
  // ── Helpers ───────────────────────────────────────────────
26
25
  function getPlatform() {
27
26
  return process.env.KILO ? "kilo" : "opencode";
28
27
  }
29
- function getSessionDir() {
30
- const dir = join(homedir(), ".config", getPlatform(), "context-mode", "sessions");
31
- mkdirSync(dir, { recursive: true });
32
- return dir;
33
- }
34
- function getDBPath(projectDir) {
35
- const hash = createHash("sha256")
36
- .update(projectDir)
37
- .digest("hex")
38
- .slice(0, 16);
39
- return join(getSessionDir(), `${hash}.db`);
40
- }
41
28
  // ── Plugin Factory ────────────────────────────────────────
42
29
  /**
43
30
  * OpenCode plugin factory. Called once when OpenCode loads the plugin.
44
31
  * Returns an object mapping hook event names to async handler functions.
45
- */
32
+ */
46
33
  export const ContextModePlugin = async (ctx) => {
47
34
  // Resolve build dir from compiled JS location
35
+ const adapter = new OpenCodeAdapter(getPlatform());
48
36
  const buildDir = dirname(fileURLToPath(import.meta.url));
49
37
  // Load routing module (ESM .mjs, lives outside build/ in hooks/)
50
38
  const routingPath = resolve(buildDir, "..", "hooks", "core", "routing.mjs");
@@ -52,7 +40,7 @@ export const ContextModePlugin = async (ctx) => {
52
40
  await routing.initSecurity(buildDir);
53
41
  // Initialize session
54
42
  const projectDir = ctx.directory;
55
- const db = new SessionDB({ dbPath: getDBPath(projectDir) });
43
+ const db = new SessionDB({ dbPath: adapter.getSessionDBPath(projectDir) });
56
44
  const sessionId = randomUUID();
57
45
  db.ensureSession(sessionId, projectDir);
58
46
  // Clean up old sessions on startup (replaces SessionStart hook)
package/build/store.d.ts CHANGED
@@ -17,6 +17,8 @@ export declare function cleanupStaleDBs(): number;
17
17
  /**
18
18
  * Clean up stale per-project content store DBs older than maxAgeDays.
19
19
  * Scans the given directory for *.db files and checks mtime.
20
+ * Also detects zombie processes holding WAL locks — if a WAL file exists
21
+ * but the owning PID is dead, the DB files are cleaned up regardless of age.
20
22
  */
21
23
  export declare function cleanupStaleContentDBs(contentDir: string, maxAgeDays: number): number;
22
24
  export declare class ContentStore {
package/build/store.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * Use for documentation, API references, and any content where
8
8
  * you need EXACT text later — not summaries.
9
9
  */
10
- import { loadDatabase, applyWALPragmas, closeDB } from "./db-base.js";
10
+ import { loadDatabase, applyWALPragmas, closeDB, withRetry } from "./db-base.js";
11
11
  import { readFileSync, readdirSync, unlinkSync, existsSync, statSync } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
@@ -116,9 +116,24 @@ export function cleanupStaleDBs() {
116
116
  catch { /* ignore readdir errors */ }
117
117
  return cleaned;
118
118
  }
119
+ /**
120
+ * Check if a PID is still alive (not a zombie holding a WAL lock).
121
+ * Returns true if the process exists, false if it's dead.
122
+ */
123
+ function isProcessAlive(pid) {
124
+ try {
125
+ process.kill(pid, 0);
126
+ return true;
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
119
132
  /**
120
133
  * Clean up stale per-project content store DBs older than maxAgeDays.
121
134
  * Scans the given directory for *.db files and checks mtime.
135
+ * Also detects zombie processes holding WAL locks — if a WAL file exists
136
+ * but the owning PID is dead, the DB files are cleaned up regardless of age.
122
137
  */
123
138
  export function cleanupStaleContentDBs(contentDir, maxAgeDays) {
124
139
  let cleaned = 0;
@@ -131,7 +146,25 @@ export function cleanupStaleContentDBs(contentDir, maxAgeDays) {
131
146
  try {
132
147
  const filePath = join(contentDir, file);
133
148
  const mtime = statSync(filePath).mtimeMs;
134
- if (mtime < cutoff) {
149
+ let shouldClean = mtime < cutoff;
150
+ // Detect zombie processes holding WAL locks:
151
+ // If a WAL file exists, try to read the WAL header to extract the PID.
152
+ // WAL files from dead processes can block new connections.
153
+ if (!shouldClean) {
154
+ const walPath = filePath + "-wal";
155
+ if (existsSync(walPath)) {
156
+ try {
157
+ const walStat = statSync(walPath);
158
+ // If WAL file is non-empty and DB hasn't been modified in >1 hour,
159
+ // the owning process may be dead — check via mtime staleness
160
+ if (walStat.size > 0 && (Date.now() - walStat.mtimeMs) > 3600_000) {
161
+ shouldClean = true;
162
+ }
163
+ }
164
+ catch { /* ignore WAL check errors */ }
165
+ }
166
+ }
167
+ if (shouldClean) {
135
168
  for (const suffix of ["", "-wal", "-shm"]) {
136
169
  try {
137
170
  unlinkSync(filePath + suffix);
@@ -234,7 +267,7 @@ export class ContentStore {
234
267
  const Database = loadDatabase();
235
268
  this.#dbPath =
236
269
  dbPath ?? join(tmpdir(), `context-mode-${process.pid}.db`);
237
- this.#db = new Database(this.#dbPath, { timeout: 5000 });
270
+ this.#db = new Database(this.#dbPath, { timeout: 30000 });
238
271
  applyWALPragmas(this.#db);
239
272
  this.#initSchema();
240
273
  this.#prepareStatements();
@@ -496,7 +529,7 @@ export class ContentStore {
496
529
  const text = content ?? readFileSync(path, "utf-8");
497
530
  const label = source ?? path ?? "untitled";
498
531
  const chunks = this.#chunkMarkdown(text);
499
- return this.#insertChunks(chunks, label, text);
532
+ return withRetry(() => this.#insertChunks(chunks, label, text));
500
533
  }
501
534
  // ── Index Plain Text ──
502
535
  /**
@@ -613,7 +646,7 @@ export class ContentStore {
613
646
  stmt = this.#stmtSearchPorter;
614
647
  params = [sanitized, limit];
615
648
  }
616
- return this.#mapSearchRows(stmt.all(...params));
649
+ return withRetry(() => this.#mapSearchRows(stmt.all(...params)));
617
650
  }
618
651
  // ── Trigram Search (Layer 2) ──
619
652
  searchTrigram(query, limit = 3, source, mode = "AND", contentType, sourceMatchMode = "like") {