@sanqianx/ai-coding-event-bridge-console 0.5.1 → 0.7.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.
Files changed (36) hide show
  1. package/README.md +15 -4
  2. package/console/index.html +37 -11
  3. package/package.json +2 -5
  4. package/src/agent-detect.js +1 -1
  5. package/src/bin.js +28 -5
  6. package/src/bridge/bin/commit-boundary.js +64 -0
  7. package/src/bridge/connectors/capture-guard.js +24 -0
  8. package/src/bridge/connectors/claude-code/hook-entry.js +45 -0
  9. package/src/bridge/connectors/claude-code/notify.js +57 -0
  10. package/src/bridge/connectors/codex/cursor-store.js +83 -0
  11. package/src/bridge/connectors/codex/hook-entry.js +215 -0
  12. package/src/bridge/connectors/codex/session-parser.js +100 -0
  13. package/src/bridge/connectors/opencode/hook-entry.js +55 -0
  14. package/src/bridge/core/bridge.js +246 -0
  15. package/src/bridge/core/commit-projection.js +288 -0
  16. package/src/bridge/core/compaction.js +23 -0
  17. package/src/bridge/core/consumer-registry.js +111 -0
  18. package/src/bridge/core/event-schema.js +127 -0
  19. package/src/bridge/core/fs-utils.js +81 -0
  20. package/src/bridge/core/journal-router.js +39 -0
  21. package/src/bridge/core/journal.js +507 -0
  22. package/src/bridge/core/lock.js +96 -0
  23. package/src/bridge/core/normalizer.js +114 -0
  24. package/src/bridge/core/paths.js +12 -0
  25. package/src/bridge/core/project-registry.js +176 -0
  26. package/src/bridge/core/repo-context.js +188 -0
  27. package/src/bridge/core/runtime-home.js +188 -0
  28. package/src/bridge/core/semver.js +23 -0
  29. package/src/bridge/core/turn-identity.js +127 -0
  30. package/src/bridge/index.js +86 -0
  31. package/src/bridge/installers/claude-code/installer.js +181 -0
  32. package/src/bridge/installers/codex/installer.js +114 -0
  33. package/src/bridge/installers/opencode/installer.js +96 -0
  34. package/src/bridge/installers/zcode/installer.js +188 -0
  35. package/src/bridge/query/conversation-query.js +194 -0
  36. package/src/server.js +54 -5
@@ -0,0 +1,507 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { randomUUID } = require('crypto');
6
+ const { CrossProcessLock } = require('./lock');
7
+ const { appendLineSync, writeJsonAtomicSync, readJsonIfExists, ensureDirSync } = require('./fs-utils');
8
+ const { repoIdentityKey, sameRepoIdentity, isValidRepoIdentityV1, turnScopeKey } = require('./repo-context');
9
+ const { validateAndNormalizeEvent, evidenceLevelFor, clampConfidence, raiseConfidenceToEvidence } = require('./event-schema');
10
+
11
+ const EVENT_SCHEMA = 'ai-coding-event/v1';
12
+ const BOUNDARY_SCHEMA = 'git-commit-boundary/v1';
13
+
14
+ const EVENT_ENVELOPE_FIELDS = [
15
+ 'source',
16
+ 'eventType',
17
+ 'role',
18
+ 'content',
19
+ 'repoIdentity',
20
+ 'projectPath',
21
+ 'sessionId',
22
+ 'turnId',
23
+ 'identityConfidence',
24
+ 'branch',
25
+ 'headAtCapture',
26
+ 'capturedAt',
27
+ 'captureStatus',
28
+ 'rawEventType'
29
+ ];
30
+
31
+ const BOUNDARY_FACT_FIELDS = [
32
+ 'commitSha',
33
+ 'parents',
34
+ 'branch',
35
+ 'subject',
36
+ 'committedAt',
37
+ 'projectId',
38
+ 'operationId'
39
+ ];
40
+
41
+ class JournalValidationError extends Error {
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = 'JournalValidationError';
45
+ }
46
+ }
47
+
48
+ class JournalCorruptError extends Error {
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = 'JournalCorruptError';
52
+ }
53
+ }
54
+
55
+ function turnKey(repoIdentity, turnId) {
56
+ return `${turnScopeKey(repoIdentity)}::${turnId}`;
57
+ }
58
+
59
+ class Journal {
60
+ constructor(journalDir, { lockStaleMs, lockTimeoutMs } = {}) {
61
+ this.journalDir = journalDir;
62
+ ensureDirSync(journalDir);
63
+ this.eventsFile = path.join(journalDir, 'events.jsonl');
64
+ this.stateFile = path.join(journalDir, 'state.json');
65
+ this.lock = new CrossProcessLock(path.join(journalDir, 'journal.lock'), {
66
+ staleMs: lockStaleMs,
67
+ timeoutMs: lockTimeoutMs
68
+ });
69
+ this._state = null;
70
+ if (!fs.existsSync(this.eventsFile)) {
71
+ const fd = fs.openSync(this.eventsFile, 'a');
72
+ fs.closeSync(fd);
73
+ }
74
+ }
75
+
76
+ get firstSequence() {
77
+ return this._state ? this._state.firstSequence : 0;
78
+ }
79
+
80
+ get lastSequence() {
81
+ return this._state ? this._state.lastSequence : 0;
82
+ }
83
+
84
+ async _withJournalLock(fn) {
85
+ return this.lock.withLock(async () => {
86
+ await this._refreshState();
87
+ return fn();
88
+ });
89
+ }
90
+
91
+ _validateEvent(event) {
92
+ if (!event || typeof event !== 'object') {
93
+ throw new JournalValidationError('event must be an object');
94
+ }
95
+ if (typeof event.source !== 'string' || !event.source) {
96
+ throw new JournalValidationError('event.source is required');
97
+ }
98
+ if (typeof event.eventType !== 'string' || !event.eventType) {
99
+ throw new JournalValidationError('event.eventType is required');
100
+ }
101
+ if (event.sequence !== undefined) {
102
+ throw new JournalValidationError('event.sequence is journal-owned and must not be supplied');
103
+ }
104
+ if (event.eventId !== undefined) {
105
+ throw new JournalValidationError('event.eventId is journal-owned and must not be supplied');
106
+ }
107
+ if (event.turnId !== undefined && event.turnId !== null && typeof event.turnId !== 'string') {
108
+ throw new JournalValidationError('event.turnId must be a string or null when present');
109
+ }
110
+ if (event.sessionId !== undefined && event.sessionId !== null && typeof event.sessionId !== 'string') {
111
+ throw new JournalValidationError('event.sessionId must be a string or null');
112
+ }
113
+ }
114
+
115
+ _buildEventRecord(sequence, event) {
116
+ const record = {
117
+ schema: EVENT_SCHEMA,
118
+ eventId: randomUUID(),
119
+ sequence,
120
+ capturedAt: new Date().toISOString()
121
+ };
122
+ for (const field of EVENT_ENVELOPE_FIELDS) {
123
+ if (event[field] !== undefined) record[field] = event[field];
124
+ }
125
+ if (event.meta !== undefined) record.meta = event.meta;
126
+ return record;
127
+ }
128
+
129
+ _buildBoundaryRecord(sequence, repoIdentity, gitFacts) {
130
+ const record = {
131
+ schema: BOUNDARY_SCHEMA,
132
+ eventType: 'git_commit_boundary',
133
+ sequence,
134
+ repoIdentity,
135
+ openTurnIdsAtCommit: [],
136
+ previousRepoBoundarySequence: this._state.lastBoundaryByRepo[repoIdentityKey(repoIdentity)] || null,
137
+ capturedAt: new Date().toISOString()
138
+ };
139
+ for (const field of BOUNDARY_FACT_FIELDS) {
140
+ if (gitFacts[field] !== undefined) record[field] = gitFacts[field];
141
+ }
142
+ if (gitFacts.meta !== undefined) record.meta = gitFacts.meta;
143
+ return record;
144
+ }
145
+
146
+ _applyProjection(record) {
147
+ const { eventType, turnId, repoIdentity, sessionId } = record;
148
+ const scopeKey = turnScopeKey(repoIdentity);
149
+ if (eventType === 'user_prompt' && turnId) {
150
+ const key = turnKey(repoIdentity, turnId);
151
+ const existing = this._state.openTurns[key];
152
+ if (existing) {
153
+ existing.lastSequence = record.sequence;
154
+ if (sessionId !== undefined) existing.sessionId = sessionId;
155
+ } else {
156
+ this._state.openTurns[key] = {
157
+ repoIdentity,
158
+ turnId,
159
+ sessionId: sessionId === undefined ? null : sessionId,
160
+ startSequence: record.sequence,
161
+ endSequence: null,
162
+ lastSequence: record.sequence
163
+ };
164
+ }
165
+ return;
166
+ }
167
+ if (eventType === 'assistant_response' && turnId) {
168
+ const key = turnKey(repoIdentity, turnId);
169
+ const existing = this._state.openTurns[key];
170
+ if (existing) {
171
+ existing.endSequence = record.sequence;
172
+ delete this._state.openTurns[key];
173
+ }
174
+ return;
175
+ }
176
+ if (eventType === 'assistant_response' && !turnId && sessionId) {
177
+ // Same rule as turn-identity: bind by session only when it is unambiguous.
178
+ const matches = Object.values(this._state.openTurns).filter(
179
+ (turn) => turnScopeKey(turn.repoIdentity) === scopeKey && turn.sessionId === sessionId
180
+ );
181
+ if (matches.length === 1) {
182
+ matches[0].endSequence = record.sequence;
183
+ delete this._state.openTurns[turnKey(repoIdentity, matches[0].turnId)];
184
+ }
185
+ return;
186
+ }
187
+ if (eventType === 'session_end' && sessionId) {
188
+ for (const key of Object.keys(this._state.openTurns)) {
189
+ const turn = this._state.openTurns[key];
190
+ if (turnScopeKey(turn.repoIdentity) === scopeKey && turn.sessionId === sessionId) {
191
+ turn.endSequence = record.sequence;
192
+ delete this._state.openTurns[key];
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ _applyRecordToState(record) {
199
+ if (record.schema === BOUNDARY_SCHEMA) {
200
+ const boundaryKey = repoIdentityKey(record.repoIdentity);
201
+ if (boundaryKey) {
202
+ this._state.lastBoundaryByRepo[boundaryKey] = record.sequence;
203
+ }
204
+ } else {
205
+ this._applyProjection(record);
206
+ }
207
+ if (record.sequence > this._state.lastSequence) {
208
+ this._state.lastSequence = record.sequence;
209
+ }
210
+ }
211
+
212
+ _saveState() {
213
+ writeJsonAtomicSync(this.stateFile, this._state);
214
+ }
215
+
216
+ _scanFrom(offset) {
217
+ const size = fs.statSync(this.eventsFile).size;
218
+ if (size <= offset) return { records: [], completeBytes: 0 };
219
+ const fd = fs.openSync(this.eventsFile, 'r');
220
+ let raw;
221
+ try {
222
+ const buf = Buffer.alloc(size - offset);
223
+ const read = fs.readSync(fd, buf, 0, buf.length, offset);
224
+ raw = buf.toString('utf8', 0, read);
225
+ } finally {
226
+ fs.closeSync(fd);
227
+ }
228
+ const records = [];
229
+ let completeBytes = 0;
230
+ const chunks = raw.split('\n');
231
+ for (let i = 0; i < chunks.length; i++) {
232
+ const chunk = chunks[i];
233
+ if (i === chunks.length - 1) break; // trailing bytes without newline = partial tail
234
+ const line = chunk;
235
+ if (line.length > 0) {
236
+ let parsed;
237
+ try {
238
+ parsed = JSON.parse(line);
239
+ } catch (_) {
240
+ parsed = null;
241
+ }
242
+ if (!parsed || typeof parsed.sequence !== 'number') {
243
+ throw new JournalCorruptError(
244
+ `corrupt journal line at byte offset ${offset + completeBytes}: ${line.slice(0, 80)}`
245
+ );
246
+ }
247
+ records.push(parsed);
248
+ }
249
+ completeBytes += Buffer.byteLength(line, 'utf8') + 1;
250
+ }
251
+ return { records, completeBytes };
252
+ }
253
+
254
+ _truncateTo(byteSize) {
255
+ const fd = fs.openSync(this.eventsFile, 'r+');
256
+ try {
257
+ fs.ftruncateSync(fd, byteSize);
258
+ fs.fsyncSync(fd);
259
+ } finally {
260
+ fs.closeSync(fd);
261
+ }
262
+ }
263
+
264
+ async _refreshState() {
265
+ const size = fs.statSync(this.eventsFile).size;
266
+ const saved = readJsonIfExists(this.stateFile);
267
+ if (!saved || typeof saved.lastSequence !== 'number' || typeof saved.openTurns !== 'object' || saved.openTurns === null) {
268
+ this._fullRebuild();
269
+ return;
270
+ }
271
+ this._state = saved;
272
+ if (typeof this._state.lastBoundaryByRepo !== 'object' || this._state.lastBoundaryByRepo === null) {
273
+ this._state.lastBoundaryByRepo = {};
274
+ }
275
+ if (typeof this._state.firstSequence !== 'number') {
276
+ this._state.firstSequence = this._state.lastSequence ? 1 : 0;
277
+ }
278
+ const flushed = typeof saved.bytesFlushed === 'number' ? saved.bytesFlushed : 0;
279
+ if (size > flushed) {
280
+ this._recoverTail(flushed);
281
+ }
282
+ }
283
+
284
+ _fullRebuild() {
285
+ const { records, completeBytes } = this._scanFrom(0);
286
+ if (completeBytes < fs.statSync(this.eventsFile).size) {
287
+ this._truncateTo(completeBytes);
288
+ }
289
+ this._state = {
290
+ firstSequence: records.length ? records[0].sequence : 0,
291
+ lastSequence: records.length ? records[records.length - 1].sequence : 0,
292
+ lastBoundaryByRepo: {},
293
+ openTurns: {},
294
+ bytesFlushed: completeBytes
295
+ };
296
+ for (const record of records) {
297
+ this._applyRecordToState(record);
298
+ }
299
+ this._saveState();
300
+ }
301
+
302
+ _recoverTail(fromOffset) {
303
+ const { records, completeBytes } = this._scanFrom(fromOffset);
304
+ const goodEnd = fromOffset + completeBytes;
305
+ if (goodEnd < fs.statSync(this.eventsFile).size) {
306
+ this._truncateTo(goodEnd);
307
+ }
308
+ for (const record of records) {
309
+ this._applyRecordToState(record);
310
+ }
311
+ this._state.bytesFlushed = goodEnd;
312
+ this._saveState();
313
+ }
314
+
315
+ async appendEvent(event) {
316
+ this._validateEvent(event);
317
+ return this._withJournalLock(async () => {
318
+ const sequence = this._state.lastSequence + 1;
319
+ const record = this._buildEventRecord(sequence, event);
320
+ appendLineSync(this.eventsFile, `${JSON.stringify(record)}\n`);
321
+ this._applyRecordToState(record);
322
+ this._state.bytesFlushed = fs.statSync(this.eventsFile).size;
323
+ this._saveState();
324
+ return { eventId: record.eventId, sequence };
325
+ });
326
+ }
327
+
328
+ // Canonical conversation append path. Turn identity is resolved here, under
329
+ // the journal lock, so the durable record itself carries the canonical
330
+ // turnId — projection-only inference is never the source of truth.
331
+ //
332
+ // user_prompt : trustworthy client turnId wins, else Bridge mints
333
+ // turn_<uuid> and persists it in the record.
334
+ // assistant : client turnId wins; else bind to the single open turn
335
+ // of the SAME workspace + session; zero or several open
336
+ // candidates => stays null, confidence <= partial,
337
+ // captureStatus partial/gap (never guessed).
338
+ // session_end : closes the workspace+session open turns; no content
339
+ // is fabricated.
340
+ //
341
+ // Duplicate reprocessing of an event carrying a stable eventKey returns the
342
+ // already persisted record instead of minting a second turn identity.
343
+ async appendConversationEvent(event) {
344
+ return this._withJournalLock(async () => {
345
+ const normalized = validateAndNormalizeEvent(event);
346
+
347
+ if (normalized.eventKey !== undefined) {
348
+ if (typeof normalized.eventKey !== 'string' || !normalized.eventKey) {
349
+ throw new JournalValidationError('event.eventKey must be a non-empty string when present');
350
+ }
351
+ const { records } = this._scanFrom(0);
352
+ const existing = records.find(
353
+ (record) => record.eventKey === normalized.eventKey && record.schema === EVENT_SCHEMA
354
+ );
355
+ if (existing) {
356
+ return { eventId: existing.eventId, sequence: existing.sequence, turnId: existing.turnId || null, duplicate: true };
357
+ }
358
+ }
359
+
360
+ if (normalized.eventType === 'user_prompt' && !normalized.turnId) {
361
+ normalized.turnId = `turn_${randomUUID()}`;
362
+ } else if (normalized.eventType === 'assistant_response' && !normalized.turnId && normalized.sessionId) {
363
+ const scopeKey = turnScopeKey(normalized.repoIdentity);
364
+ const candidates = Object.values(this._state.openTurns).filter(
365
+ (turn) => turnScopeKey(turn.repoIdentity) === scopeKey && turn.sessionId === normalized.sessionId
366
+ );
367
+ if (candidates.length === 1) {
368
+ normalized.turnId = candidates[0].turnId;
369
+ } else {
370
+ normalized.identityConfidence = clampConfidence(normalized.identityConfidence, 'partial');
371
+ if (normalized.captureStatus === 'complete') normalized.captureStatus = 'partial';
372
+ normalized.meta = { ...(normalized.meta || {}), turnBinding: candidates.length === 0 ? 'no-open-turn' : 'ambiguous-open-turns' };
373
+ }
374
+ }
375
+
376
+ // Assignment can only raise evidence; clamp upward to the evidence level.
377
+ const evidence = evidenceLevelFor({ sessionId: normalized.sessionId, turnId: normalized.turnId });
378
+ normalized.identityConfidence = raiseConfidenceToEvidence(normalized.identityConfidence, evidence);
379
+
380
+ const sequence = this._state.lastSequence + 1;
381
+ const record = this._buildEventRecord(sequence, normalized);
382
+ if (normalized.eventKey !== undefined) record.eventKey = normalized.eventKey;
383
+ appendLineSync(this.eventsFile, `${JSON.stringify(record)}\n`);
384
+ this._applyRecordToState(record);
385
+ this._state.bytesFlushed = fs.statSync(this.eventsFile).size;
386
+ this._saveState();
387
+ return { eventId: record.eventId, sequence, turnId: record.turnId || null, duplicate: false };
388
+ });
389
+ }
390
+
391
+ async appendCommitBoundary(repoIdentity, gitFacts = {}) {
392
+ const boundaryKey = repoIdentityKey(repoIdentity);
393
+ if (!boundaryKey || (typeof repoIdentity !== 'string' && !isValidRepoIdentityV1(repoIdentity))) {
394
+ throw new JournalValidationError('repoIdentity (repo-identity/v1 or legacy string) is required for a commit boundary');
395
+ }
396
+ if (gitFacts.commitSha !== undefined && typeof gitFacts.commitSha !== 'string') {
397
+ throw new JournalValidationError('gitFacts.commitSha must be a string when present');
398
+ }
399
+ return this._withJournalLock(async () => {
400
+ const record = this._buildBoundaryRecord(this._state.lastSequence + 1, repoIdentity, gitFacts);
401
+ record.openTurnIdsAtCommit = Object.values(this._state.openTurns)
402
+ .filter((turn) => sameRepoIdentity(turn.repoIdentity, repoIdentity))
403
+ .map((turn) => turn.turnId);
404
+ appendLineSync(this.eventsFile, `${JSON.stringify(record)}\n`);
405
+ this._state.bytesFlushed = fs.statSync(this.eventsFile).size;
406
+ this._state.lastSequence = record.sequence;
407
+ this._state.lastBoundaryByRepo[boundaryKey] = record.sequence;
408
+ this._saveState();
409
+ return {
410
+ sequence: record.sequence,
411
+ openTurnIdsAtCommit: record.openTurnIdsAtCommit.slice(),
412
+ previousRepoBoundarySequence: record.previousRepoBoundarySequence
413
+ };
414
+ });
415
+ }
416
+
417
+ async readEvents({ fromSequence, toSequence, limit, filter } = {}) {
418
+ return this._withJournalLock(async () => {
419
+ const from = Math.max(
420
+ fromSequence === undefined ? this._state.firstSequence : fromSequence,
421
+ this._state.firstSequence
422
+ );
423
+ const to = toSequence === undefined ? this._state.lastSequence : toSequence;
424
+ const out = [];
425
+ const { records } = this._scanFrom(0);
426
+ for (const record of records) {
427
+ if (record.sequence < from || record.sequence > to) continue;
428
+ if (filter && !filter(record)) continue;
429
+ out.push(record);
430
+ if (limit && out.length >= limit) break;
431
+ }
432
+ return out;
433
+ });
434
+ }
435
+
436
+ async getOpenTurns(repoIdentity) {
437
+ return this._withJournalLock(async () =>
438
+ Object.values(this._state.openTurns)
439
+ .filter((turn) => !repoIdentity || sameRepoIdentity(turn.repoIdentity, repoIdentity))
440
+ .map((turn) => ({ ...turn }))
441
+ );
442
+ }
443
+
444
+ async getBounds() {
445
+ return this._withJournalLock(async () => ({
446
+ firstSequence: this._state.firstSequence,
447
+ lastSequence: this._state.lastSequence
448
+ }));
449
+ }
450
+
451
+ _compactTo(target) {
452
+ const { records } = this._scanFrom(0);
453
+ const kept = records.filter((record) => record.sequence > target);
454
+ const removedCount = records.length - kept.length;
455
+ const tmp = `${this.eventsFile}.compact`;
456
+ const fd = fs.openSync(tmp, 'w');
457
+ try {
458
+ fs.fsyncSync(fd);
459
+ } finally {
460
+ fs.closeSync(fd);
461
+ }
462
+ for (const record of kept) {
463
+ appendLineSync(tmp, `${JSON.stringify(record)}\n`);
464
+ }
465
+ fs.renameSync(tmp, this.eventsFile);
466
+ this._state.firstSequence = target + 1;
467
+ this._state.bytesFlushed = fs.statSync(this.eventsFile).size;
468
+ this._saveState();
469
+ return removedCount;
470
+ }
471
+
472
+ /**
473
+ * Drop the durable prefix through `target` after its conversations were
474
+ * sealed into <store>/commits (the sealed files are the archive of record).
475
+ * Turns whose events fell below the trim point leave the open-turn state:
476
+ * a reply that arrives later lands as an orphan assistant event and is
477
+ * sealed into the next commit's 未配对事件 appendix by design.
478
+ */
479
+ async trimThrough(target) {
480
+ if (!Number.isInteger(target) || target < 0) {
481
+ throw new JournalValidationError('trimThrough requires a non-negative integer sequence');
482
+ }
483
+ return this._withJournalLock(async () => {
484
+ if (target < this._state.firstSequence) {
485
+ return { trimmedThrough: this._state.firstSequence - 1, removedCount: 0, reason: 'already-trimmed' };
486
+ }
487
+ const removedCount = this._compactTo(target);
488
+ let droppedOpenTurns = 0;
489
+ for (const key of Object.keys(this._state.openTurns)) {
490
+ if (this._state.openTurns[key].lastSequence <= target) {
491
+ delete this._state.openTurns[key];
492
+ droppedOpenTurns += 1;
493
+ }
494
+ }
495
+ if (droppedOpenTurns > 0) this._saveState();
496
+ return { trimmedThrough: target, removedCount, droppedOpenTurns, reason: 'trimmed' };
497
+ });
498
+ }
499
+ }
500
+
501
+ module.exports = {
502
+ Journal,
503
+ JournalValidationError,
504
+ JournalCorruptError,
505
+ EVENT_SCHEMA,
506
+ BOUNDARY_SCHEMA
507
+ };
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { ensureDirSync } = require('./fs-utils');
6
+
7
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
8
+
9
+ class LockError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = 'LockError';
13
+ }
14
+ }
15
+
16
+ class CrossProcessLock {
17
+ constructor(lockFile, { staleMs = 30000, pollMs = 15, timeoutMs = 60000 } = {}) {
18
+ this.lockFile = lockFile;
19
+ this.staleMs = staleMs;
20
+ this.pollMs = pollMs;
21
+ this.timeoutMs = timeoutMs;
22
+ this._held = false;
23
+ this._chain = Promise.resolve();
24
+ }
25
+
26
+ async acquire() {
27
+ if (this._held) throw new LockError('lock already held by this handle');
28
+ // Materialize the lock directory on first real use so that merely
29
+ // constructing a registry/journal never creates a Bridge home.
30
+ ensureDirSync(path.dirname(this.lockFile));
31
+ const deadline = Date.now() + this.timeoutMs;
32
+ for (;;) {
33
+ try {
34
+ const fd = fs.openSync(this.lockFile, 'wx');
35
+ try {
36
+ fs.writeSync(fd, `${JSON.stringify({ pid: process.pid, at: Date.now() })}\n`);
37
+ } finally {
38
+ fs.closeSync(fd);
39
+ }
40
+ this._held = true;
41
+ return;
42
+ } catch (err) {
43
+ // EEXIST: lock file present. EPERM/EBUSY: Windows reports these when
44
+ // another process holds the file without a sharing mode — still "locked".
45
+ if (!err || !['EEXIST', 'EPERM', 'EBUSY'].includes(err.code)) throw err;
46
+ }
47
+ try {
48
+ const st = fs.statSync(this.lockFile);
49
+ if (Date.now() - st.mtimeMs > this.staleMs) {
50
+ fs.unlinkSync(this.lockFile);
51
+ continue;
52
+ }
53
+ } catch (_) {
54
+ // Lock vanished between open failure and stat; retry immediately.
55
+ }
56
+ if (Date.now() > deadline) {
57
+ throw new LockError(`timed out acquiring lock ${this.lockFile}`);
58
+ }
59
+ await sleep(this.pollMs);
60
+ }
61
+ }
62
+
63
+ release() {
64
+ if (!this._held) return;
65
+ this._held = false;
66
+ try {
67
+ // A lock is one file, never a directory. unlink also avoids Node 24's
68
+ // Windows rm(force) no-op on some non-ASCII filenames.
69
+ fs.unlinkSync(this.lockFile);
70
+ } catch (error) {
71
+ if (!error || error.code !== 'ENOENT') throw error;
72
+ }
73
+ }
74
+
75
+ async withLock(fn) {
76
+ let release = null;
77
+ const run = async () => {
78
+ await this.acquire();
79
+ release = () => this.release();
80
+ try {
81
+ return await fn();
82
+ } finally {
83
+ release();
84
+ }
85
+ };
86
+ const result = this._chain.then(run, run);
87
+ this._chain = result.then(() => undefined, () => undefined);
88
+ return result;
89
+ }
90
+ }
91
+
92
+ function lockPathFor(homeDir, name) {
93
+ return path.join(homeDir, 'locks', name);
94
+ }
95
+
96
+ module.exports = { CrossProcessLock, lockPathFor, LockError };