@mxalbert/context-mode 2.0.2 → 2.0.3

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": "2.0.2"
9
+ "version": "2.0.3"
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": "2.0.2",
16
+ "version": "2.0.3",
17
17
  "author": {
18
18
  "name": "Mert Koseoğlu"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
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": "2.0.2",
6
+ "version": "2.0.3",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mxalbert/context-mode",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
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",
package/README.md CHANGED
@@ -1208,7 +1208,7 @@ Indexed content persists in a per-project SQLite database at `~/.context-mode/co
1208
1208
  - **Cache hit (within TTL):** Returns a cache hint (~0.3KB) instead of re-fetching (48KB+). Model proceeds to `ctx_search`.
1209
1209
  - **Cache miss (TTL expired):** Re-fetches silently. No user action needed.
1210
1210
  - **`ttl: 0`** or **`force: true`:** Bypasses cache and re-fetches regardless of freshness.
1211
- - **14-day cleanup:** Content databases and sources older than 14 days are removed on startup.
1211
+ - **14-day cleanup:** Sources older than 14 days are removed from the knowledge base on startup. Content DB files themselves are never auto-deleted — an open-but-idle store is indistinguishable from an abandoned one by timestamps alone, and deleting a live store's files causes unrecoverable I/O errors. Use `ctx_purge` to reclaim disk space.
1212
1212
 
1213
1213
  This means `--continue` sessions preserve indexed docs across restarts. No re-fetching, no wasted context tokens.
1214
1214
 
@@ -49,8 +49,9 @@
49
49
  * - No routing file auto-write (avoid dirtying project trees)
50
50
  * - Session cleanup happens at plugin init (no SessionStart)
51
51
  */
52
- import { type PluginGlobalState } from "./index.js";
53
- import { type PluginClient, type V2SetupContext } from "./v2.js";
52
+ import { SessionDB } from "../../session/db.js";
53
+ import { AdapterPlatformType, OpenCodeAdapter, type PluginGlobalState } from "./index.js";
54
+ import { type PluginClient, type PluginClientAppLogBodyExtra, type V2SetupContext } from "./v2.js";
54
55
  type PluginContext = {
55
56
  client: PluginClient;
56
57
  directory: string;
@@ -192,6 +193,63 @@ export declare function __resetPluginStateForTests(): void;
192
193
  export declare function __getPluginGlobalState(): PluginGlobalState;
193
194
  /** Test-only: close the cached fd and clear the sink cache so each test gets a fresh sink. */
194
195
  export declare function __resetPluginLogSinkForTests(): void;
196
+ type EmitLevel = "info" | "warn" | "error";
197
+ /**
198
+ * Per-plugin-process state shared by the v1 `server()` path and the v2
199
+ * `setup()` path. Both flavors bridge to the SAME handler functions built
200
+ * over this runtime — logic is never duplicated across flavors.
201
+ */
202
+ interface PluginRuntime {
203
+ ctx: PluginContext;
204
+ platform: AdapterPlatformType;
205
+ adapter: OpenCodeAdapter;
206
+ projectDir: string;
207
+ db: SessionDB;
208
+ routing: {
209
+ routePreToolUse: (...args: unknown[]) => any;
210
+ /** v2-native availability signal (hooks/core/routing.mjs) — optional: older routing copies lack it. */
211
+ setContextModeToolsAvailable?: (available: boolean) => void;
212
+ /**
213
+ * v2 permission-evaluate routing (hooks/core/routing.mjs) — optional:
214
+ * older routing copies lack it, and the permission bridge no-ops (fail
215
+ * open, host decision untouched) when absent.
216
+ */
217
+ routePermissionEvaluate?: (action: unknown, resources: unknown, projectDir: unknown, platform: unknown) => {
218
+ effect: "allow" | "deny" | "ask";
219
+ message?: string;
220
+ } | null;
221
+ };
222
+ routingBlock: string;
223
+ autoInjectionMod: {
224
+ buildAutoInjection: (events: unknown) => string;
225
+ };
226
+ captureAgentsMd: (sessionId: string) => void;
227
+ buildNativeTools: () => Promise<Record<string, NativeToolDefinition>>;
228
+ logger: (message?: string, extra?: PluginClientAppLogBodyExtra) => Promise<void>;
229
+ safeLog: (message?: string, extra?: PluginClientAppLogBodyExtra) => Promise<void>;
230
+ logHookError: (hookName: string, err: unknown, sessionId?: string) => void;
231
+ logOnce: (key: string, message: string, level?: EmitLevel) => void;
232
+ /**
233
+ * Liveness gate for v2-registered callbacks (defense-in-depth). Set to
234
+ * true by teardownV2 BEFORE any dispose / claim release: hosts whose
235
+ * registration calls succeed but return NO dispose handle cannot be fully
236
+ * unregistered, so their stale callbacks are neutralized instead — every
237
+ * v2 entry point below checks this flag and no-ops (no throw, no DB touch)
238
+ * once the runtime is torn down. The v1 path never tears a runtime down.
239
+ */
240
+ closed: boolean;
241
+ }
242
+ /**
243
+ * The v2 permission-evaluate bridge handler. Extracted (and exported) so the
244
+ * behavioral contracts are unit-testable: malformed events and a routing
245
+ * throw must leave the host decision untouched (fail-open), and a torn-down
246
+ * runtime must no-op (liveness gate). `route` is pre-bound to the runtime's
247
+ * projectDir/platform by the caller.
248
+ */
249
+ export declare function createV2PermissionEvaluateHandler(rt: Pick<PluginRuntime, "closed" | "logHookError">, route: (action: string, resources: string[]) => {
250
+ effect: "allow" | "deny" | "ask";
251
+ message?: string;
252
+ } | null): (event: unknown) => Promise<undefined>;
195
253
  /**
196
254
  * Plugin factory. Called once when a v1 host (KiloCode/OpenCode ≤ v1) loads
197
255
  * the plugin. Returns an object mapping hook event names to async handler
@@ -1283,6 +1283,85 @@ async function registerEventBusV2(ctx, rt, handlers) {
1283
1283
  dispose: hostDispose ? chainDisposes([abortDispose, hostDispose]) : abortDispose,
1284
1284
  };
1285
1285
  }
1286
+ /**
1287
+ * OPTIONAL: register the v2 permission "evaluate" hook — the surface that
1288
+ * restores true ask/confirmation semantics on OpenCode v2 (verified against
1289
+ * opencode2 beta-19135 live probe; see .research/opencode-v2-permission-hooks.md).
1290
+ *
1291
+ * The host asserts every core-tool action (shell, read, …) through its
1292
+ * permission system before execution and lets the hook MUTATE the decision
1293
+ * (event.effect / event.message). The bridge routes the event through the
1294
+ * shared routing engine's routePermissionEvaluate and applies its verdict:
1295
+ *
1296
+ * - deny → effect "deny" — blocked with the policy reason (normally
1297
+ * pre-empted by the execute.before throw which fires earlier —
1298
+ * this is defense-in-depth for assert paths execute.before cannot see)
1299
+ * - ask → effect "ask" — an interactive confirmation in TUI runs,
1300
+ * restoring the user's ask intent even when host allow rules would
1301
+ * auto-approve; under `--auto` the host auto-approves the ask too
1302
+ * (explicit opt-in, verified live)
1303
+ * - allow → effect "allow" — skips the host's default-ask prompt for
1304
+ * commands the user's own GLOBAL permission rules pre-approve
1305
+ * - null → untouched — the host decision (its rules / default ask / --auto)
1306
+ * stays exactly as computed
1307
+ *
1308
+ * Plugin-registered tools (the ctx_* tools themselves) are NOT
1309
+ * permission-evaluated (empirically verified — see
1310
+ * .research/opencode-v2-empirical-test-results.md), so their gating stays in
1311
+ * tool.execute.before; this hook only covers host core-tool actions, with
1312
+ * zero overlap or double-handling: execute.before keeps deny/modify/context,
1313
+ * and its Stage-1 ask already falls through (opencode is not in
1314
+ * ASK_CAPABLE_PLATFORMS), so the ask decision is expressed HERE, once.
1315
+ *
1316
+ * Fail-open: a routing throw leaves the event untouched (mirrors the
1317
+ * execute.before catch — a routing failure must never brick tool calls).
1318
+ */
1319
+ async function registerPermissionHookV2(ctx, rt) {
1320
+ const permissionHook = ctx?.permission && typeof ctx.permission.hook === "function" ? ctx.permission.hook : undefined;
1321
+ if (!permissionHook || !ctx?.permission)
1322
+ return { ok: false };
1323
+ const routePermissionEvaluate = rt.routing.routePermissionEvaluate;
1324
+ const handler = createV2PermissionEvaluateHandler(rt, (action, resources) => {
1325
+ if (typeof routePermissionEvaluate !== "function")
1326
+ return null; // older routing copy — no-op
1327
+ return routePermissionEvaluate(action, resources, rt.projectDir, rt.platform);
1328
+ });
1329
+ return tryRegister(permissionHook, ctx.permission, "evaluate", handler);
1330
+ }
1331
+ /**
1332
+ * The v2 permission-evaluate bridge handler. Extracted (and exported) so the
1333
+ * behavioral contracts are unit-testable: malformed events and a routing
1334
+ * throw must leave the host decision untouched (fail-open), and a torn-down
1335
+ * runtime must no-op (liveness gate). `route` is pre-bound to the runtime's
1336
+ * projectDir/platform by the caller.
1337
+ */
1338
+ export function createV2PermissionEvaluateHandler(rt, route) {
1339
+ return async (event) => {
1340
+ if (rt.closed)
1341
+ return undefined; // torn down — silent no-op
1342
+ const ev = (event ?? {});
1343
+ const action = typeof ev.action === "string" ? ev.action : "";
1344
+ const resources = Array.isArray(ev.resources)
1345
+ ? ev.resources.filter((r) => typeof r === "string")
1346
+ : [];
1347
+ if (!action || resources.length === 0)
1348
+ return undefined;
1349
+ let routed = null;
1350
+ try {
1351
+ routed = route(action, resources);
1352
+ }
1353
+ catch (err) {
1354
+ rt.logHookError("v2.permission.evaluate", err, v2SessionIdOf(ev));
1355
+ return undefined; // fail-open — host decision untouched
1356
+ }
1357
+ if (!routed)
1358
+ return undefined;
1359
+ ev.effect = routed.effect;
1360
+ if (typeof routed.message === "string")
1361
+ ev.message = routed.message;
1362
+ return undefined; // mutation is the contract — the return value is ignored
1363
+ };
1364
+ }
1286
1365
  /**
1287
1366
  * MANDATORY: register the native ctx_* tools via ctx.tool.transform(editor)
1288
1367
  * (verified v2 API). The ToolEditor receives one ToolInfo per ctx_* tool:
@@ -1591,7 +1670,16 @@ async function setupV2(ctx) {
1591
1670
  missingMessage: "context-mode v2: event bus unavailable (no ctx.event.subscribe) — per-turn token/cost capture inactive",
1592
1671
  });
1593
1672
  disposes.push(...eventBus.disposes);
1594
- rt.logOnce("v2-setup-complete", `context-mode v2 setup complete: native tools via ctx.tool.transform; tool hooks via ${toolRegs.via}; session context: ${sessionContext.status}; prompt capture: ${promptCapture.status}; event bus: ${eventBus.status}`, "info");
1673
+ // Optional: permission "evaluate" hook true ask/allow security
1674
+ // semantics on permission-evaluated actions (see registerPermissionHookV2).
1675
+ const permissionHook = await attemptOptionalV2(activeRt, {
1676
+ surfacePresent: typeof ctx?.permission?.hook === "function",
1677
+ register: () => registerPermissionHookV2(ctx, activeRt),
1678
+ logKeyMissing: "v2-permission-hook-missing",
1679
+ missingMessage: "context-mode v2: permission evaluate hook unavailable (no ctx.permission.hook) — ask/allow security semantics inactive; deny enforcement stays on tool.execute.before",
1680
+ });
1681
+ disposes.push(...permissionHook.disposes);
1682
+ rt.logOnce("v2-setup-complete", `context-mode v2 setup complete: native tools via ctx.tool.transform; tool hooks via ${toolRegs.via}; session context: ${sessionContext.status}; prompt capture: ${promptCapture.status}; event bus: ${eventBus.status}; permission hook: ${permissionHook.status}`, "info");
1595
1683
  return async () => {
1596
1684
  // Cleanup: unregister everything registered, close the runtime DB
1597
1685
  // handle, then release the activation claim so a reload can re-claim.
@@ -87,6 +87,17 @@ export type V2SetupContext = {
87
87
  signal?: AbortSignal;
88
88
  }) => unknown;
89
89
  };
90
+ /**
91
+ * v2 permission domain (verified against opencode2 beta-19135 live probe:
92
+ * ctx.permission.hook("evaluate", cb) fires for every core-tool permission
93
+ * assert with a MUTABLE event { action, resources, source?, effect, message? }
94
+ * — hook mutations of effect/message win the decision). Optional: older v2
95
+ * builds and all v1 hosts lack the surface; the plugin degrades to the
96
+ * execute.before throw-based behavior there.
97
+ */
98
+ permission?: {
99
+ hook?: (name: string, cb: (event: unknown) => unknown) => unknown;
100
+ };
90
101
  };
91
102
  /**
92
103
  * Convert a Zod (v3-classic) schema into a JSON-Schema object — the shared
package/build/server.js CHANGED
@@ -21,7 +21,7 @@ import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime
21
21
  import { classifyNonZeroExit } from "./exit-classify.js";
22
22
  import { startLifecycleGuard, noteMcpActivity, noteRequestStart, noteRequestEnd, attachMcpActivityTap } from "./lifecycle.js";
23
23
  import { charSafePrefix } from "./truncate.js";
24
- import { describeStorageDirectorySource, ensureWritableStorageDir, formatStorageDirectoryError, hashProjectDirCanonical, hashProjectDirLegacy, resolveContentStorePath, resolveContentStorageDir, resolveDefaultSessionDir, resolveSessionDbPath, resolveSessionStorageDir, resolveStatsStorageDir, SessionDB, StorageDirectoryError, } from "./session/db.js";
24
+ import { describeStorageDirectorySource, ensureWritableStorageDir, formatStorageDirectoryError, hashProjectDirCanonical, hashProjectDirLegacy, resolveContentStorePath, resolveContentStorageDir, resolveDefaultSessionDir, resolveSessionDbPath, resolveSessionPath, resolveSessionStorageDir, resolveStatsStorageDir, SessionDB, StorageDirectoryError, } from "./session/db.js";
25
25
  import { purgeSession } from "./session/purge.js";
26
26
  import { NPM_LATEST_URL, PACKAGE_NAME, PLUGIN_KEY } from "./package-identity.js";
27
27
  import { emitCacheHitEvent, emitIndexWriteEvent, emitSandboxExecuteEvent, } from "./session/event-emit.js";
@@ -370,8 +370,19 @@ process.on("exit", () => { try {
370
370
  unlinkSync(CM_FS_PRELOAD);
371
371
  }
372
372
  catch { /* best effort */ } });
373
- // Lazy singleton — no DB overhead unless index/search is used
374
- let _store = null;
373
+ // Lazy per-project store cache — no DB overhead unless index/search is used.
374
+ // Keyed by resolved dbPath: multi-project hosts (opencode web serves MANY
375
+ // projects inside ONE process, each plugin tool call arriving with its own
376
+ // projectDir via withProjectDirOverride) get one store per project. A single
377
+ // cached instance would funnel every project's reads/writes into whichever
378
+ // project ran the first ctx_* tool — cross-project knowledge-base exposure
379
+ // AND one long-lived connection whose files other processes' startup
380
+ // cleanups may unlink (surfaces as disk I/O error, SQLITE_IOERR_VNODE).
381
+ // Mirrors the #645 SessionDB singleton re-keying pattern applied to the
382
+ // Pi/OMP/OpenClaw plugins.
383
+ const _stores = new Map();
384
+ /** True once the process-wide startup cleanups have run (see getStore). */
385
+ let _startupCleanupsRan = false;
375
386
  /**
376
387
  * Build the FK-attribution object passed to every ContentStore.index*() call
377
388
  * in this process. CLAUDE_SESSION_ID is the only MCP-side handle we have on
@@ -434,27 +445,34 @@ export function resolveSessionIdFromSessionDB(opts) {
434
445
  }
435
446
  }
436
447
  /**
437
- * Auto-index session events files written by SessionStart hook.
438
- * Scans ~/.claude/context-mode/sessions/ for *-events.md files.
439
- * CLAUDE_PROJECT_DIR is NOT available to MCP servers only to hooks —
440
- * so we glob-scan instead of computing a specific hash.
441
- * Files are consumed (deleted) after indexing to prevent double-indexing.
442
- * Called on every getStore() readdirSync is sub-millisecond when no files match.
448
+ * Auto-index the CURRENT project's session events file written by the
449
+ * SessionStart hook (same per-project path derivation the hook uses —
450
+ * canonical hash + worktree suffix). The file is consumed (deleted) after
451
+ * indexing to prevent double-indexing. Called on every getStore().
452
+ *
453
+ * Scoped to this project deliberately: the events filename is per-project,
454
+ * and a multi-project host (opencode web) shares one sessions directory
455
+ * across projects. The previous implementation glob-scanned the whole
456
+ * directory, so whichever project ran the first ctx_* tool indexed — and
457
+ * consumed — every OTHER project's pending events into its own store.
443
458
  */
444
459
  function maybeIndexSessionEvents(store) {
445
460
  try {
446
461
  const sessionsDir = getSessionDir();
447
462
  if (!existsSync(sessionsDir))
448
463
  return;
449
- const files = readdirSync(sessionsDir).filter(f => f.endsWith("-events.md"));
450
- for (const file of files) {
451
- const filePath = join(sessionsDir, file);
452
- try {
453
- store.index({ path: filePath, source: "session-events", attribution: currentAttribution() });
454
- unlinkSync(filePath);
455
- }
456
- catch { /* best-effort per file */ }
464
+ const eventsPath = resolveSessionPath({
465
+ projectDir: getProjectDir(),
466
+ sessionsDir,
467
+ ext: "-events.md",
468
+ });
469
+ if (!existsSync(eventsPath))
470
+ return;
471
+ try {
472
+ store.index({ path: eventsPath, source: "session-events", attribution: currentAttribution() });
473
+ unlinkSync(eventsPath);
457
474
  }
475
+ catch { /* best-effort */ }
458
476
  }
459
477
  catch { /* best-effort — session continuity never blocks tools */ }
460
478
  }
@@ -632,19 +650,27 @@ function getStorePath() {
632
650
  return resolveContentStorePath({ projectDir: getProjectDir(), contentDir: dir });
633
651
  }
634
652
  function getStore() {
635
- if (!_store) {
636
- // Content DB cleanup on fresh start is handled by SessionStart hook.
637
- // Server just opens whatever DB exists (or creates new if hook deleted it).
638
- const dbPath = getStorePath();
639
- _store = new ContentStore(dbPath);
653
+ // Opens whatever DB exists (or creates a new one). Routine retention
654
+ // never deletes per-platform DB FILES it is row-level only
655
+ // (cleanupStaleSources below) and on-disk reclamation is ctx_purge's
656
+ // job (rationale in the startup block below). The only file-deleting
657
+ // exception is the constructor's corruption-recovery path.
658
+ const dbPath = getStorePath();
659
+ let store = _stores.get(dbPath);
660
+ if (!store) {
661
+ store = new ContentStore(dbPath);
662
+ _stores.set(dbPath, store);
640
663
  // Wire deny-policy hook: store re-checks the Read deny list before
641
664
  // re-reading any file_path during auto-refresh. Catches policy edits
642
- // made after a file was originally indexed. See #442 round-3.
643
- _store.setDenyChecker((filePath) => {
665
+ // made after a file was originally indexed. See #442 round-3. The
666
+ // project dir is pinned at creation — the store is project-scoped, so
667
+ // its deny policy stays this project's even when other projects make
668
+ // calls into the same process.
669
+ const storeProjectDir = getProjectDir();
670
+ store.setDenyChecker((filePath) => {
644
671
  try {
645
- const projectDir = getProjectDir();
646
- const denyGlobs = readToolDenyPatterns("Read", projectDir);
647
- const r = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir);
672
+ const denyGlobs = readToolDenyPatterns("Read", storeProjectDir);
673
+ const r = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", storeProjectDir);
648
674
  return r.denied;
649
675
  }
650
676
  catch {
@@ -652,22 +678,36 @@ function getStore() {
652
678
  return true;
653
679
  }
654
680
  });
655
- // One-time startup cleanup: remove stale content DBs (>14 days)
681
+ // Per-store: drop this project's sources untouched for 14 days.
656
682
  try {
657
- const contentDir = dirname(getStorePath());
658
- cleanupStaleContentDBs(contentDir, 14);
659
- _store.cleanupStaleSources(14);
660
- // Also clean legacy shared dir from before platform isolation
661
- const legacyDir = join(homedir(), ".context-mode", "content");
662
- if (existsSync(legacyDir))
663
- cleanupStaleContentDBs(legacyDir, 0);
683
+ store.cleanupStaleSources(14);
664
684
  }
665
685
  catch { /* best-effort */ }
666
- // Also clean old PID-based DBs from migration
667
- cleanupStaleDBs();
686
+ // Process-wide, once: sweep the legacy shared content dir (pre-platform-
687
+ // isolation) and orphaned PID-based tmpdir DBs from the migration. Runs
688
+ // on FIRST store creation only — not once per project.
689
+ //
690
+ // The per-platform content dir gets NO automatic file deletion: mtime
691
+ // cannot distinguish a long-idle LIVE connection (a web host with a
692
+ // project open but untouched for weeks) from an abandoned DB, and
693
+ // unlinking an open store's db/-wal/-shm surfaces as unrecoverable
694
+ // disk I/O errors (SQLITE_IOERR_VNODE on macOS). Retention inside
695
+ // each store is handled logically above (cleanupStaleSources);
696
+ // on-disk reclamation is ctx_purge's job.
697
+ if (!_startupCleanupsRan) {
698
+ _startupCleanupsRan = true;
699
+ try {
700
+ const legacyDir = join(homedir(), ".context-mode", "content");
701
+ if (existsSync(legacyDir))
702
+ cleanupStaleContentDBs(legacyDir, 0);
703
+ }
704
+ catch { /* best-effort */ }
705
+ // Also clean old PID-based DBs from migration
706
+ cleanupStaleDBs();
707
+ }
668
708
  }
669
- maybeIndexSessionEvents(_store);
670
- return _store;
709
+ maybeIndexSessionEvents(store);
710
+ return store;
671
711
  }
672
712
  // ─────────────────────────────────────────────────────────
673
713
  // Session stats — track context consumption per tool
@@ -4629,12 +4669,17 @@ EXAMPLE: ctx_purge(confirm: true, scope: "project")`,
4629
4669
  storePathForPurge = getStorePath();
4630
4670
  }
4631
4671
  catch { /* best effort — store path may be unresolvable on fresh install */ }
4632
- if (_store) {
4633
- try {
4634
- _store.cleanup();
4672
+ if (storePathForPurge) {
4673
+ // Close ONLY the calling project's store — other projects' stores in a
4674
+ // multi-project host (opencode web) are neither purged nor disturbed.
4675
+ const openStore = _stores.get(storePathForPurge);
4676
+ if (openStore) {
4677
+ try {
4678
+ openStore.cleanup();
4679
+ }
4680
+ catch { /* best effort */ }
4681
+ _stores.delete(storePathForPurge);
4635
4682
  }
4636
- catch { /* best effort */ }
4637
- _store = null;
4638
4683
  }
4639
4684
  // FTS5 store: pass contentDir so purgeSession sweeps BOTH canonical
4640
4685
  // and legacy raw-casing variants (dual-hash, mirrors session events).
@@ -4906,8 +4951,15 @@ async function main() {
4906
4951
  // Clean up own DB + backgrounded processes + preload script on shutdown
4907
4952
  const shutdown = () => {
4908
4953
  executor.cleanupBackgrounded();
4909
- if (_store)
4910
- _store.close(); // persist DB for --continue sessions
4954
+ // Persist every open per-project DB for --continue sessions. Cleared
4955
+ // after close so a repeated shutdown call cannot double-close a store.
4956
+ for (const openStore of _stores.values()) {
4957
+ try {
4958
+ openStore.close();
4959
+ }
4960
+ catch { /* best effort */ }
4961
+ }
4962
+ _stores.clear();
4911
4963
  try {
4912
4964
  unlinkSync(CM_FS_PRELOAD);
4913
4965
  }
@@ -131,7 +131,8 @@ export function purgeSession(opts) {
131
131
  //
132
132
  // Caller is responsible for closing any persistent ContentStore
133
133
  // handle BEFORE invoking purgeSession (Windows file lock). The
134
- // ctx_purge handler does this via _store?.cleanup() before delegating.
134
+ // ctx_purge handler does this via the per-project store cache's
135
+ // cleanup() before delegating.
135
136
  const ftsTargets = [];
136
137
  if (storePath && existsSync(storePath))
137
138
  ftsTargets.push(storePath);
package/build/store.d.ts CHANGED
@@ -19,9 +19,28 @@ export declare function sanitizeTrigramQuery(query: string, mode?: "AND" | "OR")
19
19
  export declare function cleanupStaleDBs(): number;
20
20
  /**
21
21
  * Clean up stale per-project content store DBs older than maxAgeDays.
22
- * Scans the given directory for *.db files and checks mtime.
23
- * Also detects zombie processes holding WAL locksif a WAL file exists
24
- * but the owning PID is dead, the DB files are cleaned up regardless of age.
22
+ * Scans the given directory for *.db files and unlinks each (plus its
23
+ * -wal/-shm sidecars) when its EFFECTIVE last-write timethe newer of the
24
+ * main .db mtime and a non-empty -wal mtime exceeds the cutoff.
25
+ *
26
+ * Why the -wal mtime participates: in WAL mode commits touch only the -wal
27
+ * file; the main .db mtime advances at checkpoint. A main-only check can
28
+ * delete an actively-used store that simply hasn't checkpointed within the
29
+ * window. Conversely, a non-empty -wal older than some threshold is NOT
30
+ * proof the owning process is dead — a live-but-idle connection (a session
31
+ * quiet over lunch) is indistinguishable from a crashed process by mtime
32
+ * alone. The pre-fix heuristic deleted such DBs unconditionally after 1h,
33
+ * which on macOS unlinks the files under an open connection and surfaces
34
+ * as disk I/O error (SQLITE_IOERR_VNODE) on every later write — no amount
35
+ * of retrying recovers an invalidated fd.
36
+ *
37
+ * Contract: a -wal can only EXTEND a DB's life (its mtime counts as the
38
+ * latest write), never shorten it. A WAL never triggers deletion on its
39
+ * own — the way the old 1-hour zombie rule did — and once the whole DB
40
+ * (main + non-empty wal) is beyond maxAgeDays, the caller's retention
41
+ * policy owns the consequences. Callers that cannot tolerate deleting a
42
+ * possibly-live DB (the per-platform content dir — see getStore() in
43
+ * src/server.ts) must not call this with a nonzero maxAgeDays.
25
44
  */
26
45
  export declare function cleanupStaleContentDBs(contentDir: string, maxAgeDays: number): number;
27
46
  export declare class ContentStore {
package/build/store.js CHANGED
@@ -158,24 +158,30 @@ export function cleanupStaleDBs() {
158
158
  catch { /* ignore readdir errors */ }
159
159
  return cleaned;
160
160
  }
161
- /**
162
- * Check if a PID is still alive (not a zombie holding a WAL lock).
163
- * Returns true if the process exists, false if it's dead.
164
- */
165
- function isProcessAlive(pid) {
166
- try {
167
- process.kill(pid, 0);
168
- return true;
169
- }
170
- catch {
171
- return false;
172
- }
173
- }
174
161
  /**
175
162
  * Clean up stale per-project content store DBs older than maxAgeDays.
176
- * Scans the given directory for *.db files and checks mtime.
177
- * Also detects zombie processes holding WAL locksif a WAL file exists
178
- * but the owning PID is dead, the DB files are cleaned up regardless of age.
163
+ * Scans the given directory for *.db files and unlinks each (plus its
164
+ * -wal/-shm sidecars) when its EFFECTIVE last-write timethe newer of the
165
+ * main .db mtime and a non-empty -wal mtime exceeds the cutoff.
166
+ *
167
+ * Why the -wal mtime participates: in WAL mode commits touch only the -wal
168
+ * file; the main .db mtime advances at checkpoint. A main-only check can
169
+ * delete an actively-used store that simply hasn't checkpointed within the
170
+ * window. Conversely, a non-empty -wal older than some threshold is NOT
171
+ * proof the owning process is dead — a live-but-idle connection (a session
172
+ * quiet over lunch) is indistinguishable from a crashed process by mtime
173
+ * alone. The pre-fix heuristic deleted such DBs unconditionally after 1h,
174
+ * which on macOS unlinks the files under an open connection and surfaces
175
+ * as disk I/O error (SQLITE_IOERR_VNODE) on every later write — no amount
176
+ * of retrying recovers an invalidated fd.
177
+ *
178
+ * Contract: a -wal can only EXTEND a DB's life (its mtime counts as the
179
+ * latest write), never shorten it. A WAL never triggers deletion on its
180
+ * own — the way the old 1-hour zombie rule did — and once the whole DB
181
+ * (main + non-empty wal) is beyond maxAgeDays, the caller's retention
182
+ * policy owns the consequences. Callers that cannot tolerate deleting a
183
+ * possibly-live DB (the per-platform content dir — see getStore() in
184
+ * src/server.ts) must not call this with a nonzero maxAgeDays.
179
185
  */
180
186
  export function cleanupStaleContentDBs(contentDir, maxAgeDays) {
181
187
  let cleaned = 0;
@@ -187,26 +193,18 @@ export function cleanupStaleContentDBs(contentDir, maxAgeDays) {
187
193
  for (const file of files) {
188
194
  try {
189
195
  const filePath = join(contentDir, file);
190
- const mtime = statSync(filePath).mtimeMs;
191
- let shouldClean = mtime < cutoff;
192
- // Detect zombie processes holding WAL locks:
193
- // If a WAL file exists, try to read the WAL header to extract the PID.
194
- // WAL files from dead processes can block new connections.
195
- if (!shouldClean) {
196
- const walPath = filePath + "-wal";
197
- if (existsSync(walPath)) {
198
- try {
199
- const walStat = statSync(walPath);
200
- // If WAL file is non-empty and DB hasn't been modified in >1 hour,
201
- // the owning process may be dead — check via mtime staleness
202
- if (walStat.size > 0 && (Date.now() - walStat.mtimeMs) > 3600_000) {
203
- shouldClean = true;
204
- }
205
- }
206
- catch { /* ignore WAL check errors */ }
196
+ // Effective last-write: newer of main .db and non-empty -wal. A fresh
197
+ // WAL is evidence of a live (or recently-live) connection — it can
198
+ // only push the cutoff later, never mark a fresh DB stale.
199
+ let effectiveMs = statSync(filePath).mtimeMs;
200
+ try {
201
+ const walStat = statSync(filePath + "-wal");
202
+ if (walStat.size > 0 && walStat.mtimeMs > effectiveMs) {
203
+ effectiveMs = walStat.mtimeMs;
207
204
  }
208
205
  }
209
- if (shouldClean) {
206
+ catch { /* no -wal — main mtime stands */ }
207
+ if (effectiveMs < cutoff) {
210
208
  for (const suffix of ["", "-wal", "-shm"]) {
211
209
  try {
212
210
  unlinkSync(filePath + suffix);