@gmickel/gno 1.23.0 → 1.25.1

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.
Files changed (68) hide show
  1. package/README.md +26 -12
  2. package/assets/skill/SKILL.md +37 -19
  3. package/assets/skill/recipes/capture-and-file.md +20 -5
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip.sha256 +1 -0
  6. package/browser-extension/dist/PRIVACY.md +55 -0
  7. package/browser-extension/dist/chunk-vn5f663b.js +50 -0
  8. package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
  9. package/browser-extension/dist/content.js +1 -0
  10. package/browser-extension/dist/manifest.json +25 -0
  11. package/browser-extension/dist/preview.html +13 -0
  12. package/browser-extension/dist/service-worker.js +40 -0
  13. package/package.json +13 -3
  14. package/spec/cli.md +141 -0
  15. package/spec/db/schema.sql +101 -0
  16. package/spec/mcp.md +10 -0
  17. package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
  18. package/spec/output-schemas/browser-clip.schema.json +586 -0
  19. package/spec/output-schemas/capture-receipt.schema.json +22 -1
  20. package/spec/output-schemas/clipper-csrf.schema.json +12 -0
  21. package/spec/output-schemas/clipper-error.schema.json +46 -0
  22. package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
  23. package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
  24. package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
  25. package/spec/output-schemas/clipper-revoke.schema.json +28 -0
  26. package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
  27. package/spec/output-schemas/setup-activation-result.schema.json +456 -0
  28. package/spec/output-schemas/setup-command-result.schema.json +93 -0
  29. package/spec/output-schemas/setup-receipt.schema.json +258 -0
  30. package/spec/output-schemas/setup-semantic-receipt.schema.json +195 -0
  31. package/src/cli/commands/completion/scripts.ts +2 -0
  32. package/src/cli/commands/embed.ts +7 -2
  33. package/src/cli/commands/setup-activation.ts +324 -0
  34. package/src/cli/commands/setup-semantic.ts +591 -0
  35. package/src/cli/commands/setup.ts +410 -0
  36. package/src/cli/program.ts +64 -0
  37. package/src/cli/setup-semantic-worker.ts +177 -0
  38. package/src/core/browser-clip-provenance.ts +139 -0
  39. package/src/core/browser-clip.ts +473 -0
  40. package/src/core/capture-write.ts +5 -0
  41. package/src/core/capture.ts +75 -18
  42. package/src/core/config-mutation.ts +94 -64
  43. package/src/core/file-lock.ts +89 -36
  44. package/src/core/folder-setup-planning.ts +453 -0
  45. package/src/core/folder-setup.ts +490 -0
  46. package/src/core/setup-activation.ts +309 -0
  47. package/src/core/setup-receipt.ts +321 -0
  48. package/src/serve/capture-service.ts +420 -0
  49. package/src/serve/clipper-body.ts +62 -0
  50. package/src/serve/clipper-capture.ts +248 -0
  51. package/src/serve/clipper-contract.ts +57 -0
  52. package/src/serve/clipper-idempotency.ts +35 -0
  53. package/src/serve/clipper-pairing.ts +297 -0
  54. package/src/serve/clipper-security-errors.ts +23 -0
  55. package/src/serve/clipper-security.ts +449 -0
  56. package/src/serve/connectors.ts +29 -2
  57. package/src/serve/public/app.tsx +8 -1
  58. package/src/serve/public/globals.built.css +1 -1
  59. package/src/serve/public/index.html +1 -0
  60. package/src/serve/public/lib/clipper-approval.ts +206 -0
  61. package/src/serve/public/pages/ClipperPairing.tsx +210 -0
  62. package/src/serve/routes/api.ts +19 -115
  63. package/src/serve/routes/clipper.ts +394 -0
  64. package/src/serve/server.ts +22 -0
  65. package/src/store/migrations/020-browser-clipper-security.ts +128 -0
  66. package/src/store/migrations/index.ts +2 -0
  67. package/src/store/sqlite/clipper-store-types.ts +104 -0
  68. package/src/store/sqlite/clipper-store.ts +496 -0
@@ -10,6 +10,10 @@
10
10
  import { posix as pathPosix } from "node:path";
11
11
 
12
12
  import { buildUri } from "../app/constants";
13
+ import {
14
+ browserClipProvenanceSchema,
15
+ type BrowserClipProvenance,
16
+ } from "./browser-clip-provenance";
13
17
  import {
14
18
  resolveNoteCreatePlan,
15
19
  type NoteCollisionPolicy,
@@ -59,9 +63,13 @@ export interface CaptureSource {
59
63
  mime?: string;
60
64
  ext?: string;
61
65
  author?: string;
66
+ canonicalUrl?: string;
67
+ site?: string;
68
+ publishedAt?: string;
62
69
  observedAt?: string;
63
70
  capturedAt: string;
64
71
  externalId?: string;
72
+ browserClip?: BrowserClipProvenance;
65
73
  }
66
74
 
67
75
  export interface CaptureIndexStatus {
@@ -117,6 +125,7 @@ export interface CapturePlan {
117
125
  source: CaptureSource;
118
126
  openedExisting: boolean;
119
127
  createdWithSuffix: boolean;
128
+ provenanceConflict: boolean;
120
129
  collisionPolicy: NoteCollisionPolicy;
121
130
  collisionPolicyResult: CaptureCollisionPolicyResult;
122
131
  overwrite: boolean;
@@ -126,6 +135,7 @@ export interface PlanCaptureOptions {
126
135
  input: CaptureInput;
127
136
  existingRelPaths: Iterable<string>;
128
137
  diskRelPaths?: Iterable<string>;
138
+ existingProvenanceByRelPath?: ReadonlyMap<string, string>;
129
139
  now?: Date;
130
140
  }
131
141
 
@@ -145,7 +155,7 @@ const VALID_COLLISION_POLICIES = new Set<NoteCollisionPolicy>([
145
155
  "open_existing",
146
156
  "create_with_suffix",
147
157
  ]);
148
- const URL_SOURCE_FIELDS = new Set(["url", "uri"]);
158
+ const URL_SOURCE_FIELDS = new Set(["url", "uri", "canonicalUrl"]);
149
159
  const LEGACY_SOURCE_FIELD_MAP: Record<string, keyof CaptureSource> = {
150
160
  gno_source_docid: "docid",
151
161
  gno_source_uri: "uri",
@@ -160,6 +170,9 @@ const CAPTURE_SOURCE_STRING_KEYS = new Set([
160
170
  "mime",
161
171
  "ext",
162
172
  "author",
173
+ "canonicalUrl",
174
+ "site",
175
+ "publishedAt",
163
176
  "externalId",
164
177
  ]);
165
178
 
@@ -264,27 +277,46 @@ function normalizeSource(
264
277
  continue;
265
278
  }
266
279
  if (key === "capturedAt") {
267
- normalized.capturedAt = normalizeIsoDate(
268
- String(value),
269
- "source.capturedAt"
270
- );
280
+ if (typeof value !== "string") {
281
+ throw new Error("source.capturedAt must be a string.");
282
+ }
283
+ normalized.capturedAt = normalizeIsoDate(value, "source.capturedAt");
271
284
  continue;
272
285
  }
273
286
  if (key === "observedAt") {
274
- normalized.observedAt = normalizeIsoDate(
275
- String(value),
276
- "source.observedAt"
277
- );
287
+ if (typeof value !== "string") {
288
+ throw new Error("source.observedAt must be a string.");
289
+ }
290
+ normalized.observedAt = normalizeIsoDate(value, "source.observedAt");
291
+ continue;
292
+ }
293
+ if (key === "publishedAt") {
294
+ if (typeof value !== "string") {
295
+ throw new Error("source.publishedAt must be a string.");
296
+ }
297
+ normalized.publishedAt = /^\d{4}-\d{2}-\d{2}$/.test(value)
298
+ ? value
299
+ : normalizeIsoDate(value, "source.publishedAt");
300
+ continue;
301
+ }
302
+ if (key === "browserClip") {
303
+ normalized.browserClip = browserClipProvenanceSchema.parse(value);
278
304
  continue;
279
305
  }
280
306
  if (URL_SOURCE_FIELDS.has(key)) {
307
+ if (typeof value !== "string") {
308
+ throw new Error(`source.${key} must be a string.`);
309
+ }
281
310
  try {
282
- new URL(String(value));
311
+ new URL(value);
283
312
  } catch {
284
313
  throw new Error(`source.${key} must be a valid URL.`);
285
314
  }
286
315
  }
287
316
  if (CAPTURE_SOURCE_STRING_KEYS.has(key)) {
317
+ if (typeof value !== "string") {
318
+ throw new Error(`source.${key} must be a string.`);
319
+ }
288
320
  normalized[
289
321
  key as keyof Pick<
290
322
  CaptureSource,
@@ -295,9 +327,11 @@ function normalizeSource(
295
327
  | "mime"
296
328
  | "ext"
297
329
  | "author"
330
+ | "canonicalUrl"
331
+ | "site"
298
332
  | "externalId"
299
333
  >
300
- ] = String(value);
334
+ ] = value;
301
335
  }
302
336
  }
303
337
 
@@ -489,6 +523,16 @@ export function extractCaptureSourceFromFrontmatter(
489
523
  .trim() as keyof CaptureSource;
490
524
  const nestedValue = nested.slice(nestedColon + 1).trim();
491
525
  if (nestedValue) {
526
+ if (nestedKey === "browserClip") {
527
+ try {
528
+ const parsed = JSON.parse(nestedValue) as unknown;
529
+ const provenance = browserClipProvenanceSchema.safeParse(parsed);
530
+ if (provenance.success) source.browserClip = provenance.data;
531
+ } catch {
532
+ // Ignore malformed optional browser provenance.
533
+ }
534
+ continue;
535
+ }
492
536
  source[nestedKey] = stripYamlString(nestedValue) as never;
493
537
  }
494
538
  }
@@ -664,6 +708,13 @@ export function planCapture(options: PlanCaptureOptions): CapturePlan {
664
708
  overwrite ? [] : existing
665
709
  );
666
710
  const overwritten = overwrite && existing.has(createPlan.relPath);
711
+ const clipIdentity = source.browserClip?.clipIdentity;
712
+ const provenanceConflict =
713
+ createPlan.openedExisting &&
714
+ clipIdentity !== undefined &&
715
+ options.existingProvenanceByRelPath?.get(createPlan.relPath) !==
716
+ clipIdentity;
717
+ const openedExisting = createPlan.openedExisting && !provenanceConflict;
667
718
 
668
719
  return {
669
720
  collection: options.input.collection,
@@ -675,16 +726,19 @@ export function planCapture(options: PlanCaptureOptions): CapturePlan {
675
726
  title,
676
727
  tags: contentTags,
677
728
  source,
678
- openedExisting: createPlan.openedExisting,
729
+ openedExisting,
679
730
  createdWithSuffix: createPlan.createdWithSuffix,
731
+ provenanceConflict,
680
732
  collisionPolicy,
681
733
  collisionPolicyResult: overwritten
682
734
  ? "overwritten"
683
- : createPlan.openedExisting
684
- ? "opened_existing"
685
- : createPlan.createdWithSuffix
686
- ? "created_with_suffix"
687
- : "created",
735
+ : provenanceConflict
736
+ ? "conflict"
737
+ : openedExisting
738
+ ? "opened_existing"
739
+ : createPlan.createdWithSuffix
740
+ ? "created_with_suffix"
741
+ : "created",
688
742
  overwrite,
689
743
  };
690
744
  }
@@ -705,7 +759,10 @@ export function buildCaptureReceipt(input: {
705
759
  collection: input.plan.collection,
706
760
  relPath: input.plan.relPath,
707
761
  absPath: input.absPath,
708
- created: !input.plan.openedExisting && !overwritten,
762
+ created:
763
+ !input.plan.openedExisting &&
764
+ !input.plan.provenanceConflict &&
765
+ !overwritten,
709
766
  openedExisting: input.plan.openedExisting,
710
767
  createdWithSuffix: input.plan.createdWithSuffix,
711
768
  overwritten,
@@ -13,15 +13,27 @@ import {
13
13
  normalizeConfigContentTypes,
14
14
  saveConfig,
15
15
  } from "../config";
16
+ import { withWriteLock } from "./file-lock";
16
17
 
17
18
  export interface ConfigMutationContext {
18
19
  store: SqliteAdapter;
19
20
  configPath?: string;
20
21
  onConfigUpdated: (config: Config) => void;
22
+ /**
23
+ * Optional cross-process serialization boundary. The in-memory mutex remains
24
+ * authoritative within one process; callers sharing a config across
25
+ * processes must additionally share this OS-backed lock path.
26
+ */
27
+ writeLockPath?: string;
28
+ /**
29
+ * Runs after the selected config is durably present and before store projection.
30
+ * Setup recovery uses this boundary to persist a truthful resumable receipt.
31
+ */
32
+ afterConfigSaved?: (config: Config) => Promise<void> | void;
21
33
  }
22
34
 
23
35
  export type MutationResult<T = void> =
24
- | { ok: true; config: Config; value?: T }
36
+ | { ok: true; config: Config; value?: T; skipSave?: boolean }
25
37
  | { ok: false; error: string; code: string };
26
38
 
27
39
  export type ApplyConfigResult<T = void> =
@@ -50,72 +62,90 @@ export async function applyConfigChange<T = void>(
50
62
  try {
51
63
  await previousMutex;
52
64
 
53
- const loadResult = await loadConfig(ctx.configPath);
54
- if (!loadResult.ok) {
55
- return {
56
- ok: false,
57
- error: loadResult.error.message,
58
- code: "LOAD_ERROR",
59
- };
60
- }
61
- for (const warning of formatConfigWarnings(loadResult.warnings)) {
62
- console.warn(warning);
63
- }
64
-
65
- const mutationResult = await mutate(loadResult.value);
66
- if (!mutationResult.ok) {
67
- return {
68
- ok: false,
69
- error: mutationResult.error,
70
- code: mutationResult.code,
71
- };
72
- }
73
-
74
- const normalized = normalizeConfigContentTypes(mutationResult.config);
75
- for (const warning of formatConfigWarnings(normalized.warnings)) {
76
- console.warn(warning);
77
- }
78
- const newConfig = normalized.config;
79
- const saveResult = await saveConfig(newConfig, ctx.configPath);
80
- if (!saveResult.ok) {
81
- return {
82
- ok: false,
83
- error: saveResult.error.message,
84
- code: "SAVE_ERROR",
85
- };
86
- }
87
-
88
- const syncCollResult = await ctx.store.syncCollections(
89
- newConfig.collections
90
- );
91
- if (!syncCollResult.ok) {
92
- console.warn(
93
- `Config saved but DB sync failed: ${syncCollResult.error.message}`
65
+ const applyFreshConfigChange = async (): Promise<ApplyConfigResult<T>> => {
66
+ const loadResult = await loadConfig(ctx.configPath);
67
+ if (!loadResult.ok) {
68
+ return {
69
+ ok: false,
70
+ error: loadResult.error.message,
71
+ code: "LOAD_ERROR",
72
+ };
73
+ }
74
+ for (const warning of formatConfigWarnings(loadResult.warnings)) {
75
+ console.warn(warning);
76
+ }
77
+
78
+ const mutationResult = await mutate(loadResult.value);
79
+ if (!mutationResult.ok) {
80
+ return {
81
+ ok: false,
82
+ error: mutationResult.error,
83
+ code: mutationResult.code,
84
+ };
85
+ }
86
+
87
+ const normalized = normalizeConfigContentTypes(mutationResult.config);
88
+ for (const warning of formatConfigWarnings(normalized.warnings)) {
89
+ console.warn(warning);
90
+ }
91
+ const newConfig = normalized.config;
92
+ if (!mutationResult.skipSave) {
93
+ const saveResult = await saveConfig(newConfig, ctx.configPath);
94
+ if (!saveResult.ok) {
95
+ return {
96
+ ok: false,
97
+ error: saveResult.error.message,
98
+ code: "SAVE_ERROR",
99
+ };
100
+ }
101
+ }
102
+
103
+ await ctx.afterConfigSaved?.(newConfig);
104
+
105
+ const syncCollResult = await ctx.store.syncCollections(
106
+ newConfig.collections
94
107
  );
95
- return {
96
- ok: false,
97
- error: `DB sync failed: ${syncCollResult.error.message}`,
98
- code: "SYNC_ERROR",
99
- };
100
- }
101
-
102
- const syncCtxResult = await ctx.store.syncContexts(
103
- newConfig.contexts ?? []
104
- );
105
- if (!syncCtxResult.ok) {
106
- console.warn(
107
- `Config saved but context sync failed: ${syncCtxResult.error.message}`
108
+ if (!syncCollResult.ok) {
109
+ console.warn(
110
+ `Config saved but DB sync failed: ${syncCollResult.error.message}`
111
+ );
112
+ return {
113
+ ok: false,
114
+ error: `DB sync failed: ${syncCollResult.error.message}`,
115
+ code: "SYNC_ERROR",
116
+ };
117
+ }
118
+
119
+ const syncCtxResult = await ctx.store.syncContexts(
120
+ newConfig.contexts ?? []
108
121
  );
109
- return {
110
- ok: false,
111
- error: `Context sync failed: ${syncCtxResult.error.message}`,
112
- code: "SYNC_ERROR",
113
- };
122
+ if (!syncCtxResult.ok) {
123
+ console.warn(
124
+ `Config saved but context sync failed: ${syncCtxResult.error.message}`
125
+ );
126
+ return {
127
+ ok: false,
128
+ error: `Context sync failed: ${syncCtxResult.error.message}`,
129
+ code: "SYNC_ERROR",
130
+ };
131
+ }
132
+
133
+ ctx.onConfigUpdated(newConfig);
134
+
135
+ return { ok: true, config: newConfig, value: mutationResult.value };
136
+ };
137
+
138
+ if (!ctx.writeLockPath) {
139
+ return await applyFreshConfigChange();
140
+ }
141
+ try {
142
+ return await withWriteLock(ctx.writeLockPath, applyFreshConfigChange);
143
+ } catch (error) {
144
+ if (error instanceof Error && error.message.startsWith("LOCKED:")) {
145
+ return { ok: false, error: error.message, code: "LOCKED" };
146
+ }
147
+ throw error;
114
148
  }
115
-
116
- ctx.onConfigUpdated(newConfig);
117
-
118
- return { ok: true, config: newConfig, value: mutationResult.value };
119
149
  } finally {
120
150
  resolveMutex();
121
151
  }
@@ -4,8 +4,9 @@
4
4
  * @module src/core/file-lock
5
5
  */
6
6
 
7
- // node:fs/promises for mkdir/rm (no Bun equivalent for filesystem structure ops)
8
- import { mkdir, rm } from "node:fs/promises";
7
+ import { Database } from "bun:sqlite";
8
+ // node:fs/promises provides recursive directory creation without a Bun equivalent.
9
+ import { mkdir } from "node:fs/promises";
9
10
  // node:path for dirname (no Bun path utils)
10
11
  import { dirname } from "node:path";
11
12
 
@@ -13,8 +14,8 @@ import { MCP_ERRORS } from "./errors";
13
14
  const DEFAULT_TIMEOUT_MS = 5000;
14
15
  const HOLD_SECONDS = 60 * 60 * 24 * 365;
15
16
  const READY_TOKEN = "READY";
16
- const DIRECTORY_LOCK_SUFFIX = ".dir";
17
- const DIRECTORY_LOCK_POLL_MS = 50;
17
+ const SQLITE_LOCK_SUFFIX = ".sqlite";
18
+ const MAX_BUSY_TIMEOUT_MS = 60_000;
18
19
 
19
20
  export interface WriteLockHandle {
20
21
  release: () => Promise<void>;
@@ -35,6 +36,7 @@ function resolveLockCommand(): LockCommand | null {
35
36
  return {
36
37
  path: lockfPath,
37
38
  args: (lockPath, timeoutSeconds, holdCommand) => [
39
+ "-k",
38
40
  "-t",
39
41
  String(timeoutSeconds),
40
42
  lockPath,
@@ -50,6 +52,7 @@ function resolveLockCommand(): LockCommand | null {
50
52
  return {
51
53
  path: flockPath,
52
54
  args: (lockPath, timeoutSeconds, holdCommand) => [
55
+ "--no-fork",
53
56
  "-w",
54
57
  String(timeoutSeconds),
55
58
  lockPath,
@@ -67,10 +70,6 @@ function buildHoldCommand(): string {
67
70
  return `printf '${READY_TOKEN}\\n'; exec sleep ${HOLD_SECONDS}`;
68
71
  }
69
72
 
70
- function delay(ms: number): Promise<void> {
71
- return new Promise((resolve) => setTimeout(resolve, ms));
72
- }
73
-
74
73
  async function waitForReady(
75
74
  proc: ReturnType<typeof Bun.spawn>
76
75
  ): Promise<boolean> {
@@ -96,33 +95,73 @@ async function waitForReady(
96
95
  }
97
96
  }
98
97
 
99
- async function acquireDirectoryLock(
98
+ async function terminateLockProcess(
99
+ proc: ReturnType<typeof Bun.spawn>
100
+ ): Promise<void> {
101
+ if (process.platform === "win32") {
102
+ if (proc.exitCode === null) proc.kill();
103
+ } else {
104
+ try {
105
+ process.kill(-proc.pid, "SIGTERM");
106
+ } catch {
107
+ if (proc.exitCode === null) proc.kill();
108
+ }
109
+ }
110
+ await proc.exited.catch(() => undefined);
111
+ }
112
+
113
+ function sqliteLockPath(lockPath: string): string {
114
+ return `${lockPath}${SQLITE_LOCK_SUFFIX}`;
115
+ }
116
+
117
+ function normalizedBusyTimeout(timeoutMs: number): number {
118
+ if (!Number.isFinite(timeoutMs)) {
119
+ return DEFAULT_TIMEOUT_MS;
120
+ }
121
+ return Math.min(Math.max(0, Math.floor(timeoutMs)), MAX_BUSY_TIMEOUT_MS);
122
+ }
123
+
124
+ function isSqliteLockContention(cause: unknown): boolean {
125
+ if (cause === null || typeof cause !== "object") {
126
+ return false;
127
+ }
128
+ const code = "code" in cause ? cause.code : undefined;
129
+ return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED";
130
+ }
131
+
132
+ export async function acquireSqliteWriteLock(
100
133
  lockPath: string,
101
134
  timeoutMs: number
102
135
  ): Promise<WriteLockHandle | null> {
103
- const directoryLockPath = `${lockPath}${DIRECTORY_LOCK_SUFFIX}`;
104
- await mkdir(dirname(directoryLockPath), { recursive: true });
136
+ const databasePath = sqliteLockPath(lockPath);
137
+ await mkdir(dirname(databasePath), { recursive: true });
105
138
 
106
- const deadline = Date.now() + Math.max(0, timeoutMs);
107
- while (true) {
108
- try {
109
- await mkdir(directoryLockPath);
110
- return {
111
- release: async () => {
112
- await rm(directoryLockPath, { force: true, recursive: true });
113
- },
114
- };
115
- } catch (error) {
116
- const code = (error as { code?: string }).code;
117
- if (code !== "EEXIST") {
118
- throw error;
119
- }
120
- if (Date.now() >= deadline) {
121
- return null;
122
- }
123
- await delay(Math.min(DIRECTORY_LOCK_POLL_MS, deadline - Date.now()));
139
+ const database = new Database(databasePath, { create: true });
140
+ try {
141
+ database.exec(`PRAGMA busy_timeout = ${normalizedBusyTimeout(timeoutMs)}`);
142
+ database.exec("BEGIN IMMEDIATE");
143
+ } catch (cause) {
144
+ database.close();
145
+ if (isSqliteLockContention(cause)) {
146
+ return null;
124
147
  }
148
+ throw cause;
125
149
  }
150
+
151
+ let released = false;
152
+ return {
153
+ release: async () => {
154
+ if (released) {
155
+ return;
156
+ }
157
+ released = true;
158
+ try {
159
+ database.exec("ROLLBACK");
160
+ } finally {
161
+ database.close();
162
+ }
163
+ },
164
+ };
126
165
  }
127
166
 
128
167
  export async function acquireWriteLock(
@@ -131,7 +170,7 @@ export async function acquireWriteLock(
131
170
  ): Promise<WriteLockHandle | null> {
132
171
  const cmd = resolveLockCommand();
133
172
  if (!cmd) {
134
- return acquireDirectoryLock(lockPath, timeoutMs);
173
+ return acquireSqliteWriteLock(lockPath, timeoutMs);
135
174
  }
136
175
 
137
176
  await mkdir(dirname(lockPath), { recursive: true });
@@ -141,6 +180,7 @@ export async function acquireWriteLock(
141
180
  const proc = Bun.spawn(
142
181
  [cmd.path, ...cmd.args(lockPath, timeoutSeconds, holdCommand)],
143
182
  {
183
+ detached: true,
144
184
  stdout: "pipe",
145
185
  stderr: "pipe",
146
186
  }
@@ -148,19 +188,32 @@ export async function acquireWriteLock(
148
188
 
149
189
  const ready = await waitForReady(proc);
150
190
  if (!ready) {
151
- proc.kill();
152
- await proc.exited.catch(() => undefined);
191
+ await terminateLockProcess(proc);
153
192
  return null;
154
193
  }
155
194
 
156
195
  return {
157
- release: async () => {
158
- proc.kill();
159
- await proc.exited.catch(() => undefined);
160
- },
196
+ release: () => terminateLockProcess(proc),
161
197
  };
162
198
  }
163
199
 
200
+ export async function withSqliteWriteLock<T>(
201
+ lockPath: string,
202
+ fn: () => Promise<T>,
203
+ timeoutMs: number = DEFAULT_TIMEOUT_MS
204
+ ): Promise<T> {
205
+ const lock = await acquireSqliteWriteLock(lockPath, timeoutMs);
206
+ if (!lock) {
207
+ throw new Error(`${MCP_ERRORS.LOCKED.code}: ${MCP_ERRORS.LOCKED.message}`);
208
+ }
209
+
210
+ try {
211
+ return await fn();
212
+ } finally {
213
+ await lock.release();
214
+ }
215
+ }
216
+
164
217
  export async function withWriteLock<T>(
165
218
  lockPath: string,
166
219
  fn: () => Promise<T>,