@wibeco/bridge 0.2.16 → 0.2.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,8 @@ Dependency-light TypeScript foundations for normalizing agent hooks and sending
7
7
  - Adapters copy only explicit allow-listed metadata. Prompt text, tool input/output, file content,
8
8
  shell command text, messages, and transcripts are never captured by default.
9
9
  - Redaction is defense in depth, not permission to send arbitrary payloads.
10
- - Offline files are created with owner-only permissions. Set `WIBE_QUEUE_PATH` to relocate the queue.
10
+ - Offline files are created with owner-only permissions and separated by Wibe project. Set
11
+ `WIBE_QUEUE_PATH` to relocate the queue directory.
11
12
  - Device authorization opens Wibe in the browser and stores the resulting
12
13
  revocable project-scoped bearer token in the operating-system keychain.
13
14
  - Event batches are schema-validated, bounded, idempotent, and sent only to the
@@ -41,14 +42,18 @@ saved token was rejected or revoked.
41
42
 
42
43
  `setup` keeps reviewable templates under `.wibe/integrations/<adapter>` and
43
44
  installs native project configuration only when the destination file does not
44
- already exist. Existing Cursor, Claude Code, or Codex configuration is never
45
- overwritten; merge the staged template when the project already has one.
45
+ already exist. Existing hooks and unrelated agent configuration are never
46
+ overwritten. Wibe-managed activity instructions and an existing Wibe MCP URL
47
+ are refreshed to the repository's current project.
46
48
 
47
49
  `WIBE_APP_URL` overrides the default local Wibe URL during setup.
48
- `WIBE_QUEUE_PATH` optionally relocates the owner-only offline queue. CI and
50
+ `WIBE_QUEUE_PATH` optionally relocates the owner-only per-project queue directory. If a legacy
51
+ file path is supplied, project queues are stored beside it under a `.d` suffix. CI and
49
52
  headless environments can inject `WIBE_ACCESS_TOKEN`, `WIBE_PROJECT_ID`,
50
53
  `WIBE_ORGANIZATION_ID`, `WIBE_REPOSITORY_ID`, and `WIBE_DEVICE_ID`; interactive
51
- developer machines should use the keychain-backed browser flow.
54
+ developer machines should use the keychain-backed browser flow. Environment
55
+ credentials are accepted only when they match that repository's `.wibe/project.json`.
52
56
 
53
- Without a device credential, `emit` keeps normalized events in the offline
54
- queue and never sends them elsewhere.
57
+ Without a device credential, `emit` keeps normalized events in a repository-scoped pending queue.
58
+ Setup adopts only that repository's pending events. Legacy identity-less queues are quarantined
59
+ and reported by `wibe doctor`; they are never delivered into an arbitrary project.
@@ -514,74 +514,107 @@ var SignedBatchClient = class {
514
514
  return this.flush();
515
515
  }
516
516
  async flush() {
517
- const queuedEvents = await this.options.queue.peek(this.batchSize);
518
- if (queuedEvents.length === 0) return { sent: 0, remaining: 0 };
519
- const events4 = queuedEvents.filter((event) => eventTypeForHook(event));
520
- const ignoredIds = queuedEvents.filter((event) => !eventTypeForHook(event)).map((event) => event.id);
521
- if (ignoredIds.length > 0) await this.options.queue.remove(ignoredIds);
522
- if (events4.length === 0) {
523
- return { sent: 0, remaining: await this.options.queue.size() };
524
- }
525
- const body = JSON.stringify({
526
- batch_id: randomUUID2(),
527
- events: events4.map((event) => toEnvelope(event, this.options))
528
- });
529
- try {
530
- const response = await this.request(this.options.endpoint, {
531
- method: "POST",
532
- body,
533
- signal: AbortSignal.timeout(this.timeoutMs),
534
- headers: {
535
- "content-type": "application/json",
536
- "user-agent": "wibe-bridge/1",
537
- authorization: `Bearer ${this.options.accessToken}`
538
- }
517
+ return this.options.queue.withTransaction(async (queue) => {
518
+ const queuedEvents = await queue.peek(this.batchSize);
519
+ if (queuedEvents.length === 0) return { sent: 0, remaining: 0 };
520
+ const events4 = queuedEvents.filter((event) => eventTypeForHook(event));
521
+ const ignoredIds = queuedEvents.filter((event) => !eventTypeForHook(event)).map((event) => event.id);
522
+ if (ignoredIds.length > 0) await queue.remove(ignoredIds);
523
+ if (events4.length === 0) {
524
+ return { sent: 0, remaining: await queue.size() };
525
+ }
526
+ const body = JSON.stringify({
527
+ batch_id: randomUUID2(),
528
+ events: events4.map((event) => toEnvelope(event, this.options))
539
529
  });
540
- if (!response.ok) {
541
- const requestId = response.headers.get("x-request-id");
542
- let detail = "";
543
- try {
544
- const body2 = await response.json();
545
- const code = typeof body2.code === "string" ? body2.code.slice(0, 80) : void 0;
546
- const message = typeof body2.error === "string" ? body2.error.slice(0, 160) : void 0;
547
- detail = [code, message].filter(Boolean).join(": ");
548
- } catch {
530
+ try {
531
+ const response = await this.request(this.options.endpoint, {
532
+ method: "POST",
533
+ body,
534
+ signal: AbortSignal.timeout(this.timeoutMs),
535
+ headers: {
536
+ "content-type": "application/json",
537
+ "user-agent": "wibe-bridge/1",
538
+ authorization: `Bearer ${this.options.accessToken}`
539
+ }
540
+ });
541
+ if (!response.ok) {
542
+ const requestId = response.headers.get("x-request-id");
543
+ let detail = "";
544
+ try {
545
+ const responseBody = await response.json();
546
+ const code = typeof responseBody.code === "string" ? responseBody.code.slice(0, 80) : void 0;
547
+ const message = typeof responseBody.error === "string" ? responseBody.error.slice(0, 160) : void 0;
548
+ detail = [code, message].filter(Boolean).join(": ");
549
+ } catch {
550
+ }
551
+ const suffix = [
552
+ detail ? `: ${detail}` : "",
553
+ requestId ? ` (request ${requestId.slice(0, 80)})` : ""
554
+ ].join("");
555
+ throw new Error(`Collector returned HTTP ${response.status}${suffix}`);
549
556
  }
550
- const suffix = [
551
- detail ? `: ${detail}` : "",
552
- requestId ? ` (request ${requestId.slice(0, 80)})` : ""
553
- ].join("");
554
- throw new Error(`Collector returned HTTP ${response.status}${suffix}`);
557
+ await queue.remove(events4.map((event) => event.id));
558
+ return { sent: events4.length, remaining: await queue.size() };
559
+ } catch (error) {
560
+ return {
561
+ sent: 0,
562
+ remaining: await queue.size(),
563
+ error: error instanceof Error ? error.message : String(error)
564
+ };
555
565
  }
556
- await this.options.queue.remove(events4.map((event) => event.id));
557
- return { sent: events4.length, remaining: await this.options.queue.size() };
558
- } catch (error) {
559
- return {
560
- sent: 0,
561
- remaining: await this.options.queue.size(),
562
- error: error instanceof Error ? error.message : String(error)
563
- };
564
- }
566
+ });
565
567
  }
566
568
  };
567
569
 
568
570
  // src/queue.ts
569
- import { mkdir, readFile, rename, writeFile } from "fs/promises";
571
+ import { randomUUID as randomUUID3 } from "crypto";
572
+ import {
573
+ mkdir,
574
+ open,
575
+ readFile,
576
+ rename,
577
+ stat,
578
+ unlink,
579
+ writeFile
580
+ } from "fs/promises";
570
581
  import { dirname } from "path";
571
582
  var MemoryOfflineQueue = class {
572
583
  events = [];
584
+ operation = Promise.resolve();
573
585
  async enqueue(events4) {
574
- this.events.push(...events4);
586
+ await this.withTransaction(async () => {
587
+ this.events.push(...events4);
588
+ });
575
589
  }
576
590
  async peek(limit) {
577
- return this.events.slice(0, Math.max(0, limit));
591
+ return this.withTransaction((queue) => queue.peek(limit));
578
592
  }
579
593
  async remove(ids) {
580
- const removed = new Set(ids);
581
- this.events = this.events.filter((event) => !removed.has(event.id));
594
+ await this.withTransaction((queue) => queue.remove(ids));
582
595
  }
583
596
  async size() {
584
- return this.events.length;
597
+ return this.withTransaction((queue) => queue.size());
598
+ }
599
+ async withTransaction(operation) {
600
+ return this.serialize(
601
+ () => operation({
602
+ peek: async (limit) => this.events.slice(0, Math.max(0, limit)),
603
+ remove: async (ids) => {
604
+ const removed = new Set(ids);
605
+ this.events = this.events.filter((event) => !removed.has(event.id));
606
+ },
607
+ size: async () => this.events.length
608
+ })
609
+ );
610
+ }
611
+ async serialize(operation) {
612
+ const next = this.operation.then(operation);
613
+ this.operation = next.then(
614
+ () => void 0,
615
+ () => void 0
616
+ );
617
+ return next;
585
618
  }
586
619
  };
587
620
  var JsonFileOfflineQueue = class {
@@ -591,21 +624,39 @@ var JsonFileOfflineQueue = class {
591
624
  filePath;
592
625
  operation = Promise.resolve();
593
626
  async enqueue(events4) {
594
- await this.update((current) => [...current, ...events4]);
627
+ await this.withTransaction(async () => {
628
+ await this.updateUnlocked((current) => [...current, ...events4]);
629
+ });
595
630
  }
596
631
  async peek(limit) {
597
- await this.operation;
598
- return (await this.read()).slice(0, Math.max(0, limit));
632
+ return this.withTransaction((queue) => queue.peek(limit));
599
633
  }
600
634
  async remove(ids) {
601
- const removed = new Set(ids);
602
- await this.update((current) => current.filter((event) => !removed.has(event.id)));
635
+ await this.withTransaction((queue) => queue.remove(ids));
603
636
  }
604
637
  async size() {
605
- await this.operation;
606
- return (await this.read()).length;
638
+ return this.withTransaction((queue) => queue.size());
639
+ }
640
+ async withTransaction(operation) {
641
+ return this.serialize(async () => {
642
+ const release = await this.acquireLock();
643
+ try {
644
+ return await operation({
645
+ peek: async (limit) => (await this.readUnlocked()).slice(0, Math.max(0, limit)),
646
+ remove: async (ids) => {
647
+ const removed = new Set(ids);
648
+ await this.updateUnlocked(
649
+ (current) => current.filter((event) => !removed.has(event.id))
650
+ );
651
+ },
652
+ size: async () => (await this.readUnlocked()).length
653
+ });
654
+ } finally {
655
+ await release();
656
+ }
657
+ });
607
658
  }
608
- async read() {
659
+ async readUnlocked() {
609
660
  try {
610
661
  const data = JSON.parse(await readFile(this.filePath, "utf8"));
611
662
  return canonicalHookEventSchema.array().parse(data);
@@ -614,21 +665,78 @@ var JsonFileOfflineQueue = class {
614
665
  throw error;
615
666
  }
616
667
  }
617
- async update(transform) {
618
- const next = this.operation.then(async () => {
619
- const events4 = transform(await this.read());
620
- await mkdir(dirname(this.filePath), { recursive: true, mode: 448 });
621
- const temporary = `${this.filePath}.${process.pid}.tmp`;
622
- await writeFile(temporary, JSON.stringify(events4), { encoding: "utf8", mode: 384 });
623
- await rename(temporary, this.filePath);
668
+ async updateUnlocked(transform) {
669
+ const events4 = transform(await this.readUnlocked());
670
+ await mkdir(dirname(this.filePath), { recursive: true, mode: 448 });
671
+ const temporary = `${this.filePath}.${process.pid}.${randomUUID3()}.tmp`;
672
+ await writeFile(temporary, JSON.stringify(events4), {
673
+ encoding: "utf8",
674
+ mode: 384
624
675
  });
625
- this.operation = next.catch(() => void 0);
626
- await next;
676
+ await rename(temporary, this.filePath);
677
+ }
678
+ async serialize(operation) {
679
+ const next = this.operation.then(operation);
680
+ this.operation = next.then(
681
+ () => void 0,
682
+ () => void 0
683
+ );
684
+ return next;
685
+ }
686
+ async acquireLock() {
687
+ const lockPath = `${this.filePath}.lock`;
688
+ const token = randomUUID3();
689
+ const startedAt = Date.now();
690
+ await mkdir(dirname(this.filePath), { recursive: true, mode: 448 });
691
+ while (true) {
692
+ try {
693
+ const handle = await open(lockPath, "wx", 384);
694
+ try {
695
+ await handle.writeFile(
696
+ JSON.stringify({ pid: process.pid, token, createdAt: Date.now() }),
697
+ "utf8"
698
+ );
699
+ } catch (error) {
700
+ await handle.close().catch(() => void 0);
701
+ await unlink(lockPath).catch(() => void 0);
702
+ throw error;
703
+ }
704
+ await handle.close();
705
+ return async () => {
706
+ try {
707
+ const current = JSON.parse(
708
+ await readFile(lockPath, "utf8")
709
+ );
710
+ if (current.token === token) await unlink(lockPath);
711
+ } catch (error) {
712
+ if (error.code !== "ENOENT") throw error;
713
+ }
714
+ };
715
+ } catch (error) {
716
+ if (error.code !== "EEXIST") throw error;
717
+ }
718
+ try {
719
+ const details = await stat(lockPath);
720
+ if (Date.now() - details.mtimeMs > 3e4) {
721
+ const stalePath = `${lockPath}.${process.pid}.${token}.stale`;
722
+ await rename(lockPath, stalePath);
723
+ await unlink(stalePath).catch(() => void 0);
724
+ continue;
725
+ }
726
+ } catch (error) {
727
+ if (error.code === "ENOENT") continue;
728
+ throw error;
729
+ }
730
+ if (Date.now() - startedAt > 15e3) {
731
+ throw new Error(`Timed out waiting for offline queue lock: ${lockPath}`);
732
+ }
733
+ await new Promise((resolve2) => setTimeout(resolve2, 25));
734
+ }
627
735
  }
628
736
  };
629
737
 
630
738
  // src/presence.ts
631
- import { createHash, randomUUID as randomUUID3 } from "crypto";
739
+ import { createHash, randomUUID as randomUUID4 } from "crypto";
632
740
  import { spawn } from "child_process";
633
741
  import { mkdir as mkdir2, readFile as readFile2, readdir, rm, writeFile as writeFile2 } from "fs/promises";
634
742
  import { homedir } from "os";
@@ -642,7 +750,7 @@ async function startPresenceSession(source, sessionId, cwd = process.cwd()) {
642
750
  const statePath = presenceStatePath(source, sessionId, cwd);
643
751
  await rm(statePath, { force: true });
644
752
  await mkdir2(presenceDirectory(), { recursive: true, mode: 448 });
645
- const instanceId = randomUUID3();
753
+ const instanceId = randomUUID4();
646
754
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
647
755
  const cliPath = process.argv[1];
648
756
  const state = {
@@ -19,12 +19,21 @@ import {
19
19
  startPresenceSession,
20
20
  stopPresenceSession,
21
21
  updatePresenceSession
22
- } from "./chunk-K7TK7FHF.js";
22
+ } from "./chunk-4WIGVKDR.js";
23
23
 
24
24
  // src/cli/commands.ts
25
- import { access, cp, mkdir, readFile, writeFile } from "fs/promises";
25
+ import { createHash } from "crypto";
26
+ import { realpathSync } from "fs";
27
+ import {
28
+ access,
29
+ cp,
30
+ mkdir,
31
+ readFile,
32
+ rename,
33
+ writeFile
34
+ } from "fs/promises";
26
35
  import { homedir, hostname } from "os";
27
- import { basename, join, resolve } from "path";
36
+ import { basename, extname, join, resolve } from "path";
28
37
  import { fileURLToPath } from "url";
29
38
  import { execFile } from "child_process";
30
39
 
@@ -307,10 +316,12 @@ async function setupCommand(requestedAdapter, options = {}) {
307
316
  appUrl,
308
317
  options.projectId
309
318
  );
319
+ await adoptPendingQueue(cwd, options.projectId);
310
320
  const heartbeat2 = await sendVerificationHeartbeat(
311
321
  existingCredential,
312
322
  adapter,
313
- "setup"
323
+ "setup",
324
+ cwd
314
325
  );
315
326
  return {
316
327
  exitCode: heartbeat2.error ? 1 : 0,
@@ -397,7 +408,13 @@ Confirm code ${authorization.user_code}
397
408
  appUrl,
398
409
  token.projectId
399
410
  );
400
- const heartbeat = await sendVerificationHeartbeat(credential, adapter, "setup");
411
+ await adoptPendingQueue(cwd, token.projectId);
412
+ const heartbeat = await sendVerificationHeartbeat(
413
+ credential,
414
+ adapter,
415
+ "setup",
416
+ cwd
417
+ );
401
418
  return {
402
419
  exitCode: heartbeat.error ? 1 : 0,
403
420
  message: `${heartbeat.error ? `Connected ${adapter} to Wibe, but the verification heartbeat could not be delivered (${heartbeat.error}). It remains safe to retry with wibe doctor. Installed or refreshed ${installedNativeFiles.length} native config file(s); unmanaged configuration was left untouched.` : `Connected ${adapter} to Wibe and verified the event connection. Installed or refreshed ${installedNativeFiles.length} native config file(s); unmanaged configuration was left untouched and reviewable templates are at ${destination}.`}${adapterSetupNextSteps(adapter)}`
@@ -577,7 +594,13 @@ Install the Wibe GitHub App on ${repository}, then return here.
577
594
  appUrl,
578
595
  snapshot.project_id
579
596
  );
580
- const heartbeat = await sendVerificationHeartbeat(credential, adapter, "setup");
597
+ await adoptPendingQueue(cwd, snapshot.project_id);
598
+ const heartbeat = await sendVerificationHeartbeat(
599
+ credential,
600
+ adapter,
601
+ "setup",
602
+ cwd
603
+ );
581
604
  if (heartbeat.error) {
582
605
  return {
583
606
  exitCode: 1,
@@ -620,9 +643,11 @@ async function waitForOnboarding(appUrl, sessionId, sessionSecret, ready) {
620
643
  throw new Error("Timed out waiting for the browser step to finish.");
621
644
  }
622
645
  async function statusCommand(cwd = process.cwd()) {
646
+ const legacyQueue = await quarantineLegacyQueue();
623
647
  const repo = await detectRepository(cwd);
624
648
  const credential = await loadCredential(cwd);
625
- const queue = queuePath();
649
+ const queue = credential ? projectQueuePath(credential.projectId) : pendingQueuePath(repo?.root ?? cwd);
650
+ const queueBacklog = await new JsonFileOfflineQueue(queue).size();
626
651
  const installed = (await Promise.all(
627
652
  ADAPTERS.map(async (adapter) => ({
628
653
  adapter,
@@ -638,7 +663,9 @@ async function statusCommand(cwd = process.cwd()) {
638
663
  connected: Boolean(credential),
639
664
  projectId: credential?.projectId ?? null,
640
665
  eventEndpoint: credential ? `${credential.appUrl}/api/events/batch` : null,
641
- offlineQueue: queue
666
+ offlineQueue: queue,
667
+ offlineQueueBacklog: queueBacklog,
668
+ legacyQueue: legacyQueue.pending ? legacyQueue.quarantined : null
642
669
  },
643
670
  null,
644
671
  2
@@ -757,8 +784,11 @@ async function emitCommand(adapter, eventName, input) {
757
784
  const publishableEvents = [event, ...repositoryEvents].filter(
758
785
  (candidate) => eventTypeForHook(candidate)
759
786
  );
760
- const queue = new JsonFileOfflineQueue(queuePath());
761
787
  const credential = await loadCredential(process.cwd());
788
+ await quarantineLegacyQueue();
789
+ const queue = new JsonFileOfflineQueue(
790
+ credential ? projectQueuePath(credential.projectId) : pendingQueuePath(repo?.root ?? process.cwd())
791
+ );
762
792
  if (!credential) {
763
793
  if (publishableEvents.length) await queue.enqueue(publishableEvents);
764
794
  return {
@@ -797,6 +827,7 @@ async function shareProgressCommand(options, cwd = process.cwd()) {
797
827
  if (!projectConfig || !credential) {
798
828
  throw new Error("Wibe is not authorized in this repository. Run wibe setup first.");
799
829
  }
830
+ await quarantineLegacyQueue();
800
831
  const summary = options.summary?.trim() ?? "";
801
832
  const wordCount = summary.split(/\s+/).filter(Boolean).length;
802
833
  if (wordCount < 20 || wordCount > 55 || summary.length > 500) {
@@ -860,7 +891,7 @@ async function shareProgressCommand(options, cwd = process.cwd()) {
860
891
  projectId: credential.projectId,
861
892
  repositoryId: credential.repositoryId,
862
893
  deviceId: credential.deviceId,
863
- queue: new JsonFileOfflineQueue(queuePath())
894
+ queue: new JsonFileOfflineQueue(projectQueuePath(credential.projectId))
864
895
  }).capture(event);
865
896
  return {
866
897
  exitCode: result.error ? 1 : 0,
@@ -868,16 +899,29 @@ async function shareProgressCommand(options, cwd = process.cwd()) {
868
899
  };
869
900
  }
870
901
  async function doctorCommand(cwd = process.cwd(), requestedRepository) {
902
+ const legacyQueue = await quarantineLegacyQueue();
871
903
  const credential = await loadCredential(cwd);
872
904
  const projectConfig = await readProjectConfig(join(cwd, ".wibe", "project.json"));
905
+ const repositoryForQueue = await detectRepository(cwd);
906
+ const queuePath = credential ? projectQueuePath(credential.projectId) : pendingQueuePath(repositoryForQueue?.root ?? cwd);
907
+ let queueBacklog;
908
+ let queueError;
909
+ try {
910
+ queueBacklog = await new JsonFileOfflineQueue(queuePath).size();
911
+ } catch (error) {
912
+ queueError = error instanceof Error ? error.message : String(error);
913
+ }
873
914
  const expectedRepository = requestedRepository ? normalizeGitHubRepository(requestedRepository) : projectConfig?.repository;
874
915
  if (requestedRepository && !expectedRepository) {
875
916
  throw new Error(
876
917
  `Expected repository "${requestedRepository}" is not a valid GitHub owner/repository.`
877
918
  );
878
919
  }
879
- const repository = await detectRepository(cwd);
920
+ const repository = repositoryForQueue;
880
921
  const repositoryMatches = expectedRepository ? matchesGitHubRepository(repository?.remote, expectedRepository) : true;
922
+ const credentialMatchesProjectConfig = Boolean(
923
+ credential && projectConfig && credential.projectId === projectConfig.projectId && credential.appUrl.replace(/\/$/, "") === projectConfig.appUrl.replace(/\/$/, "")
924
+ );
881
925
  let adapter;
882
926
  let adapterError;
883
927
  try {
@@ -885,11 +929,17 @@ async function doctorCommand(cwd = process.cwd(), requestedRepository) {
885
929
  } catch (error) {
886
930
  adapterError = error instanceof Error ? error.message : String(error);
887
931
  }
888
- const heartbeat = credential && adapter && repositoryMatches ? await sendVerificationHeartbeat(credential, adapter, "doctor") : void 0;
889
- const nativeConfig = adapter ? await validateNativeConfigs(adapter, cwd) : { hooks: false, mcp: false, activityRule: false, projectTrust: void 0 };
932
+ const heartbeat = credential && adapter && repositoryMatches && credentialMatchesProjectConfig ? await sendVerificationHeartbeat(credential, adapter, "doctor", cwd) : void 0;
933
+ const nativeConfig = adapter ? await validateNativeConfigs(adapter, cwd, projectConfig?.projectId) : { hooks: false, mcp: false, activityRule: false, projectTrust: void 0 };
890
934
  const trustNotes = adapter === "codex" && nativeConfig.projectTrust === void 0 ? [
891
935
  "note Codex project trust could not be verified because the user config was not found; trust this project in Codex and rerun doctor"
892
936
  ] : [];
937
+ const queueNotes = [
938
+ `note project queue ${queuePath} contains ${queueBacklog ?? "unknown"} event(s)`,
939
+ ...legacyQueue.pending ? [
940
+ `note legacy shared queue is quarantined at ${legacyQueue.quarantined} and will not be delivered automatically`
941
+ ] : []
942
+ ];
893
943
  const checks = [
894
944
  ["node", Number(process.versions.node.split(".")[0]) >= 20],
895
945
  ["git repository", Boolean(repository)],
@@ -902,6 +952,14 @@ async function doctorCommand(cwd = process.cwd(), requestedRepository) {
902
952
  ["device authorization", Boolean(credential)],
903
953
  ["event endpoint", Boolean(credential?.appUrl)],
904
954
  ["project scope", Boolean(credential?.projectId)],
955
+ [
956
+ "project config matches device credential",
957
+ credentialMatchesProjectConfig
958
+ ],
959
+ [
960
+ queueError ? `project queue (${queueError})` : "project queue",
961
+ queueError === void 0
962
+ ],
905
963
  [
906
964
  adapterError ? `adapter detection (${adapterError})` : "adapter detection",
907
965
  Boolean(adapter)
@@ -927,13 +985,14 @@ async function doctorCommand(cwd = process.cwd(), requestedRepository) {
927
985
  exitCode: failures ? 1 : 0,
928
986
  message: [
929
987
  ...checks.map(([name, okay]) => `${okay ? "ok" : "missing"} ${name}`),
930
- ...trustNotes
988
+ ...trustNotes,
989
+ ...queueNotes
931
990
  ].join("\n")
932
991
  };
933
992
  }
934
993
  async function loadCredential(cwd) {
935
994
  if (process.env.WIBE_ACCESS_TOKEN && process.env.WIBE_PROJECT_ID && process.env.WIBE_ORGANIZATION_ID && process.env.WIBE_DEVICE_ID) {
936
- return {
995
+ const credential = {
937
996
  appUrl: process.env.WIBE_APP_URL ?? "http://localhost:3000",
938
997
  accessToken: process.env.WIBE_ACCESS_TOKEN,
939
998
  projectId: process.env.WIBE_PROJECT_ID,
@@ -941,17 +1000,22 @@ async function loadCredential(cwd) {
941
1000
  repositoryId: process.env.WIBE_REPOSITORY_ID,
942
1001
  deviceId: process.env.WIBE_DEVICE_ID
943
1002
  };
1003
+ const project = await readProjectConfig(join(cwd, ".wibe", "project.json"));
1004
+ if (!project || project.projectId !== credential.projectId || project.appUrl.replace(/\/$/, "") !== credential.appUrl.replace(/\/$/, "")) {
1005
+ return null;
1006
+ }
1007
+ return credential;
944
1008
  }
945
1009
  try {
946
- const project = JSON.parse(
947
- await readFile(join(cwd, ".wibe", "project.json"), "utf8")
948
- );
949
- if (!project.projectId) return null;
1010
+ const project = await readProjectConfig(join(cwd, ".wibe", "project.json"));
1011
+ if (!project) return null;
950
1012
  const stored = await new SystemCredentialStore().get(
951
1013
  "dev.wibe.bridge",
952
1014
  project.projectId
953
1015
  );
954
- return stored ? JSON.parse(stored) : null;
1016
+ if (!stored) return null;
1017
+ const credential = JSON.parse(stored);
1018
+ return credential.projectId === project.projectId && credential.appUrl.replace(/\/$/, "") === project.appUrl.replace(/\/$/, "") ? credential : null;
955
1019
  } catch {
956
1020
  return null;
957
1021
  }
@@ -982,8 +1046,19 @@ async function installNativeConfigs(adapter, source, cwd, appUrl, projectId) {
982
1046
  for (const [sourceName, destinationName] of files) {
983
1047
  const destinationPath = join(cwd, destinationName);
984
1048
  const destinationExists = await exists(destinationPath);
1049
+ const isMcpConfig = destinationName === ".cursor/mcp.json" || destinationName === ".mcp.json" || destinationName === ".codex/config.toml";
985
1050
  const isCursorActivityRule = adapter === "cursor" && destinationName === ".cursor/rules/wibe-activity.mdc";
986
1051
  if (destinationExists) {
1052
+ if (isMcpConfig) {
1053
+ if (await refreshMcpConfig(
1054
+ adapter,
1055
+ destinationPath,
1056
+ `${appUrl}/api/mcp/projects/${projectId}`
1057
+ )) {
1058
+ installed.push(destinationName);
1059
+ }
1060
+ continue;
1061
+ }
987
1062
  if (!isCursorActivityRule) continue;
988
1063
  const existingRule = await readFile(destinationPath, "utf8");
989
1064
  if (!existingRule.includes("# Wibe activity") || !existingRule.includes("wibe_share_progress")) {
@@ -1016,6 +1091,37 @@ async function installNativeConfigs(adapter, source, cwd, appUrl, projectId) {
1016
1091
  }
1017
1092
  return installed;
1018
1093
  }
1094
+ async function refreshMcpConfig(adapter, path, resourceUrl) {
1095
+ const current = await readFile(path, "utf8");
1096
+ if (adapter === "codex") {
1097
+ const next = current.replace(
1098
+ /(\[mcp_servers\.wibe\][\s\S]*?^\s*url\s*=\s*)["'][^"']*\/api\/mcp(?:\/projects\/[0-9a-f-]+)?\/?["']/m,
1099
+ `$1${JSON.stringify(resourceUrl)}`
1100
+ );
1101
+ if (next === current) return false;
1102
+ await writeFile(path, next, { mode: 384 });
1103
+ return true;
1104
+ }
1105
+ let parsed;
1106
+ try {
1107
+ const value = JSON.parse(current);
1108
+ if (!isRecord(value)) return false;
1109
+ parsed = value;
1110
+ } catch {
1111
+ return false;
1112
+ }
1113
+ if (!isRecord(parsed.mcpServers) || !isRecord(parsed.mcpServers.wibe)) {
1114
+ return false;
1115
+ }
1116
+ const existingUrl = parsed.mcpServers.wibe.url;
1117
+ if (typeof existingUrl !== "string" || !existingUrl.includes("/api/mcp")) {
1118
+ return false;
1119
+ }
1120
+ parsed.mcpServers.wibe.url = resourceUrl;
1121
+ await writeFile(path, `${JSON.stringify(parsed, null, 2)}
1122
+ `, { mode: 384 });
1123
+ return true;
1124
+ }
1019
1125
  function renderTemplate(template, appUrl, projectId) {
1020
1126
  return template.replaceAll("${env:WIBE_APP_URL}", appUrl).replaceAll("${WIBE_APP_URL}", appUrl).replaceAll("${WIBE_PROJECT_ID}", projectId);
1021
1127
  }
@@ -1045,8 +1151,69 @@ async function ensureClaudeImportsAgents(path) {
1045
1151
  await writeFile(path, next, { mode: 420 });
1046
1152
  return true;
1047
1153
  }
1048
- function queuePath() {
1049
- return process.env.WIBE_QUEUE_PATH ?? join(homedir(), ".wibe", "events.json");
1154
+ function queueRoot() {
1155
+ const override = process.env.WIBE_QUEUE_PATH?.trim();
1156
+ if (!override) return join(homedir(), ".wibe", "queue");
1157
+ return extname(override) ? `${override}.d` : override;
1158
+ }
1159
+ function projectQueuePath(projectId) {
1160
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
1161
+ projectId
1162
+ )) {
1163
+ throw new Error("Wibe project ID must be a UUID.");
1164
+ }
1165
+ return join(queueRoot(), `${projectId}.json`);
1166
+ }
1167
+ function pendingQueuePath(cwd) {
1168
+ let canonicalRoot = resolve(cwd);
1169
+ try {
1170
+ canonicalRoot = realpathSync.native(canonicalRoot);
1171
+ } catch {
1172
+ }
1173
+ const repositoryHash = createHash("sha256").update(canonicalRoot).digest("hex").slice(0, 24);
1174
+ return join(queueRoot(), `pending-${repositoryHash}.json`);
1175
+ }
1176
+ function legacyQueuePaths() {
1177
+ const override = process.env.WIBE_QUEUE_PATH?.trim();
1178
+ if (override && extname(override)) {
1179
+ return {
1180
+ current: override,
1181
+ quarantined: `${override.slice(0, -extname(override).length)}.legacy${extname(override)}`
1182
+ };
1183
+ }
1184
+ const directory = override || join(homedir(), ".wibe");
1185
+ return {
1186
+ current: join(directory, "events.json"),
1187
+ quarantined: join(directory, "events.legacy.json")
1188
+ };
1189
+ }
1190
+ async function quarantineLegacyQueue() {
1191
+ const paths = legacyQueuePaths();
1192
+ if (await exists(paths.current)) {
1193
+ if (!await exists(paths.quarantined)) {
1194
+ await rename(paths.current, paths.quarantined).catch((error) => {
1195
+ if (error.code !== "ENOENT") throw error;
1196
+ });
1197
+ }
1198
+ }
1199
+ return {
1200
+ ...paths,
1201
+ pending: await exists(paths.current) || await exists(paths.quarantined)
1202
+ };
1203
+ }
1204
+ async function adoptPendingQueue(cwd, projectId) {
1205
+ await quarantineLegacyQueue();
1206
+ const repository = await detectRepository(cwd);
1207
+ const pending = new JsonFileOfflineQueue(
1208
+ pendingQueuePath(repository?.root ?? cwd)
1209
+ );
1210
+ const project = new JsonFileOfflineQueue(projectQueuePath(projectId));
1211
+ while (true) {
1212
+ const events = await pending.peek(100);
1213
+ if (!events.length) return;
1214
+ await project.enqueue(events);
1215
+ await pending.remove(events.map((event) => event.id));
1216
+ }
1050
1217
  }
1051
1218
  async function exists(path) {
1052
1219
  try {
@@ -1056,7 +1223,7 @@ async function exists(path) {
1056
1223
  return false;
1057
1224
  }
1058
1225
  }
1059
- async function validateNativeConfigs(adapter, cwd) {
1226
+ async function validateNativeConfigs(adapter, cwd, projectId) {
1060
1227
  if (adapter === "codex") {
1061
1228
  const hooks = await validJson(
1062
1229
  join(cwd, ".codex", "hooks.json"),
@@ -1065,14 +1232,19 @@ async function validateNativeConfigs(adapter, cwd) {
1065
1232
  let mcp = false;
1066
1233
  try {
1067
1234
  const config = await readFile(join(cwd, ".codex", "config.toml"), "utf8");
1068
- mcp = config.includes("[mcp_servers.wibe]") && config.includes("/api/mcp");
1235
+ mcp = config.includes("[mcp_servers.wibe]") && Boolean(
1236
+ projectId && config.includes(`/api/mcp/projects/${projectId}`)
1237
+ );
1069
1238
  } catch {
1070
1239
  mcp = false;
1071
1240
  }
1072
1241
  return {
1073
1242
  hooks,
1074
1243
  mcp,
1075
- activityRule: await validWibeActivityInstructions(join(cwd, "AGENTS.md")),
1244
+ activityRule: await validWibeActivityInstructions(
1245
+ join(cwd, "AGENTS.md"),
1246
+ projectId
1247
+ ),
1076
1248
  projectTrust: await codexProjectTrust(cwd)
1077
1249
  };
1078
1250
  }
@@ -1083,19 +1255,21 @@ async function validateNativeConfigs(adapter, cwd) {
1083
1255
  mcp: await validJson(mcpPath, (value) => {
1084
1256
  if (!isRecord(value.mcpServers)) return false;
1085
1257
  const wibe = value.mcpServers.wibe;
1086
- return isRecord(wibe) && typeof wibe.url === "string" && wibe.url.replace(/\/$/, "").endsWith("/api/mcp");
1258
+ return isRecord(wibe) && typeof wibe.url === "string" && Boolean(
1259
+ projectId && wibe.url.replace(/\/$/, "").endsWith(`/api/mcp/projects/${projectId}`)
1260
+ );
1087
1261
  }),
1088
1262
  activityRule: adapter === "cursor" ? await validText(
1089
1263
  join(cwd, ".cursor", "rules", "wibe-activity.mdc"),
1090
- (value) => value.includes("alwaysApply: true") && value.includes("wibe_share_progress") && value.includes("wibe share-progress")
1091
- ) : await validWibeActivityInstructions(join(cwd, "AGENTS.md")),
1264
+ (value) => value.includes("alwaysApply: true") && Boolean(projectId && value.includes(projectId)) && value.includes("wibe_share_progress") && value.includes("wibe share-progress")
1265
+ ) : await validWibeActivityInstructions(join(cwd, "AGENTS.md"), projectId),
1092
1266
  projectTrust: void 0
1093
1267
  };
1094
1268
  }
1095
- function validWibeActivityInstructions(path) {
1269
+ function validWibeActivityInstructions(path, projectId) {
1096
1270
  return validText(
1097
1271
  path,
1098
- (value) => value.includes(WIBE_ACTIVITY_BEGIN) && value.includes(WIBE_ACTIVITY_END) && value.includes("wibe_share_progress") && value.includes("wibe share-progress")
1272
+ (value) => value.includes(WIBE_ACTIVITY_BEGIN) && value.includes(WIBE_ACTIVITY_END) && Boolean(projectId && value.includes(projectId)) && value.includes("wibe_share_progress") && value.includes("wibe share-progress")
1099
1273
  );
1100
1274
  }
1101
1275
  async function codexProjectTrust(cwd) {
@@ -1255,8 +1429,8 @@ async function assertExpectedRepository(cwd, expected) {
1255
1429
  `Repository mismatch: expected GitHub repository "${expected}", but found ${actual}. Run this command from the expected repository root or correct the origin remote, then retry.`
1256
1430
  );
1257
1431
  }
1258
- async function sendVerificationHeartbeat(credential, adapter, reason) {
1259
- const repository = await detectRepository(process.cwd());
1432
+ async function sendVerificationHeartbeat(credential, adapter, reason, cwd) {
1433
+ const repository = await detectRepository(cwd);
1260
1434
  const workingTree = repository ? await detectWorkingTreeMetrics(repository.root) : void 0;
1261
1435
  return new SignedBatchClient({
1262
1436
  endpoint: `${credential.appUrl}/api/events/batch`,
package/dist/cli.js CHANGED
@@ -7,10 +7,10 @@ import {
7
7
  setupCommand,
8
8
  shareProgressCommand,
9
9
  statusCommand
10
- } from "./chunk-E2RWE6UW.js";
10
+ } from "./chunk-YLC4TF4F.js";
11
11
  import {
12
12
  runPresenceHeartbeat
13
- } from "./chunk-K7TK7FHF.js";
13
+ } from "./chunk-4WIGVKDR.js";
14
14
 
15
15
  // src/cli.ts
16
16
  var HELP = `wibe-bridge <command>
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  emitCommand
4
- } from "./chunk-E2RWE6UW.js";
5
- import "./chunk-K7TK7FHF.js";
4
+ } from "./chunk-YLC4TF4F.js";
5
+ import "./chunk-4WIGVKDR.js";
6
6
 
7
7
  // src/codex-hook.ts
8
8
  async function main() {
package/dist/index.d.ts CHANGED
@@ -228,18 +228,27 @@ declare function pollDeviceToken(input: {
228
228
  }>;
229
229
  type AgentName = "cursor" | "claude-code" | "codex";
230
230
 
231
+ interface OfflineQueueTransaction {
232
+ peek(limit: number): Promise<CanonicalHookEvent[]>;
233
+ remove(ids: readonly string[]): Promise<void>;
234
+ size(): Promise<number>;
235
+ }
231
236
  interface OfflineQueue {
232
237
  enqueue(events: readonly CanonicalHookEvent[]): Promise<void>;
233
238
  peek(limit: number): Promise<CanonicalHookEvent[]>;
234
239
  remove(ids: readonly string[]): Promise<void>;
235
240
  size(): Promise<number>;
241
+ withTransaction<T>(operation: (queue: OfflineQueueTransaction) => Promise<T>): Promise<T>;
236
242
  }
237
243
  declare class MemoryOfflineQueue implements OfflineQueue {
238
244
  private events;
245
+ private operation;
239
246
  enqueue(events: readonly CanonicalHookEvent[]): Promise<void>;
240
247
  peek(limit: number): Promise<CanonicalHookEvent[]>;
241
248
  remove(ids: readonly string[]): Promise<void>;
242
249
  size(): Promise<number>;
250
+ withTransaction<T>(operation: (queue: OfflineQueueTransaction) => Promise<T>): Promise<T>;
251
+ private serialize;
243
252
  }
244
253
  declare class JsonFileOfflineQueue implements OfflineQueue {
245
254
  private readonly filePath;
@@ -249,8 +258,11 @@ declare class JsonFileOfflineQueue implements OfflineQueue {
249
258
  peek(limit: number): Promise<CanonicalHookEvent[]>;
250
259
  remove(ids: readonly string[]): Promise<void>;
251
260
  size(): Promise<number>;
252
- private read;
253
- private update;
261
+ withTransaction<T>(operation: (queue: OfflineQueueTransaction) => Promise<T>): Promise<T>;
262
+ private readUnlocked;
263
+ private updateUnlocked;
264
+ private serialize;
265
+ private acquireLock;
254
266
  }
255
267
 
256
268
  interface SignedBatchClientOptions {
@@ -346,4 +358,4 @@ declare function sanitizeRemote(remote: string): string;
346
358
  declare function normalizeGitHubRepository(value: string): string | undefined;
347
359
  declare function matchesGitHubRepository(remote: string | undefined, expected: string): boolean;
348
360
 
349
- export { type AgentSource, type CanonicalHookEvent, type CredentialStore, type DeviceAuthorization, type DeviceTokenResponse, type FlushResult, type HookEventKind, JsonFileOfflineQueue, MemoryOfflineQueue, type OfflineQueue, PRESENCE_HEARTBEAT_INTERVAL_MS, type PresenceMetrics, type RedactionOptions, type RepositoryInfo, type RepositoryTransition, type SafeValue, SignedBatchClient, type SignedBatchClientOptions, type StoredCredential, SystemCredentialStore, type WorkingTreeMetrics, agentSourceSchema, canonicalHookEventSchema, classifyHeadTransition, createHookEvent, detectRepository, detectWorkingTreeMetrics, deviceAuthorizationSchema, deviceTokenResponseSchema, eventTypeForHook, hookEventKindSchema, isObservedPush, mapClaudeCodeHook, mapCodexHook, mapCursorHook, matchesGitHubRepository, normalizeGitHubRepository, observeRepositoryTransitions, pollDeviceToken, presenceStatePath, redact, requestDeviceAuthorization, resolveLatestPresenceSession, runPresenceHeartbeat, safeMetadata, safeValueSchema, sanitizeRemote, startPresenceSession, stopPresenceSession, updatePresenceSession };
361
+ export { type AgentSource, type CanonicalHookEvent, type CredentialStore, type DeviceAuthorization, type DeviceTokenResponse, type FlushResult, type HookEventKind, JsonFileOfflineQueue, MemoryOfflineQueue, type OfflineQueue, type OfflineQueueTransaction, PRESENCE_HEARTBEAT_INTERVAL_MS, type PresenceMetrics, type RedactionOptions, type RepositoryInfo, type RepositoryTransition, type SafeValue, SignedBatchClient, type SignedBatchClientOptions, type StoredCredential, SystemCredentialStore, type WorkingTreeMetrics, agentSourceSchema, canonicalHookEventSchema, classifyHeadTransition, createHookEvent, detectRepository, detectWorkingTreeMetrics, deviceAuthorizationSchema, deviceTokenResponseSchema, eventTypeForHook, hookEventKindSchema, isObservedPush, mapClaudeCodeHook, mapCodexHook, mapCursorHook, matchesGitHubRepository, normalizeGitHubRepository, observeRepositoryTransitions, pollDeviceToken, presenceStatePath, redact, requestDeviceAuthorization, resolveLatestPresenceSession, runPresenceHeartbeat, safeMetadata, safeValueSchema, sanitizeRemote, startPresenceSession, stopPresenceSession, updatePresenceSession };
package/dist/index.js CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  startPresenceSession,
34
34
  stopPresenceSession,
35
35
  updatePresenceSession
36
- } from "./chunk-K7TK7FHF.js";
36
+ } from "./chunk-4WIGVKDR.js";
37
37
  export {
38
38
  JsonFileOfflineQueue,
39
39
  MemoryOfflineQueue,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wibeco/bridge",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
4
4
  "description": "Privacy-first live activity bridge for Cursor, Claude Code, and Codex.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,5 +13,7 @@ stored in the operating-system keychain; hooks do not require repository secrets
13
13
  allow-list excludes prompt, content, messages, command text, tool arguments/results, and transcripts.
14
14
 
15
15
  `mcp.json.example` connects Claude Code to Wibe's remote MCP endpoint at
16
- `${env:WIBE_APP_URL}/api/mcp`. The MCP client completes OAuth separately from the bridge device token.
17
- Set `WIBE_APP_URL` to the same Wibe origin used during setup.
16
+ `${env:WIBE_APP_URL}/api/mcp/projects/${WIBE_PROJECT_ID}`. The project-specific resource keeps this
17
+ repository's OAuth grant isolated from other Wibe projects on the same machine. The MCP client
18
+ completes OAuth separately from the bridge device token. Set `WIBE_APP_URL` to the same Wibe origin
19
+ used during setup.
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "wibe": {
4
4
  "type": "http",
5
- "url": "${WIBE_APP_URL}/api/mcp"
5
+ "url": "${WIBE_APP_URL}/api/mcp/projects/${WIBE_PROJECT_ID}"
6
6
  }
7
7
  }
8
8
  }
@@ -9,8 +9,10 @@ and transcripts are not retained.
9
9
  Run `wibe setup` to authorize a revocable, project-scoped device token stored in the operating-system
10
10
  keychain. Hooks do not require repository signing secrets.
11
11
 
12
- The MCP block connects Codex to Wibe's remote `/api/mcp` endpoint. Codex completes OAuth separately
13
- from the bridge device token. Set `WIBE_APP_URL` to the same Wibe origin used during setup.
12
+ The MCP block connects Codex to a project-specific remote
13
+ `/api/mcp/projects/${WIBE_PROJECT_ID}` endpoint, keeping this repository's OAuth grant isolated from
14
+ other Wibe projects on the same machine. Codex completes OAuth separately from the bridge device
15
+ token. Set `WIBE_APP_URL` to the same Wibe origin used during setup.
14
16
  Project-local Codex configuration loads only after the project is trusted, and command hooks must be
15
17
  reviewed in `/hooks`. Session hooks own a metadata-only 45-second heartbeat that ends cleanly with
16
18
  `SessionEnd`.
@@ -1,2 +1,2 @@
1
1
  [mcp_servers.wibe]
2
- url = "${WIBE_APP_URL}/api/mcp"
2
+ url = "${WIBE_APP_URL}/api/mcp/projects/${WIBE_PROJECT_ID}"
@@ -17,5 +17,7 @@ stored in the operating-system keychain; hooks do not require repository secrets
17
17
  prompt, content, file body, command text, tool arguments/results, and transcripts.
18
18
 
19
19
  `mcp.json.example` connects Cursor to Wibe's remote MCP endpoint at
20
- `${env:WIBE_APP_URL}/api/mcp`. Cursor completes the endpoint's OAuth flow separately from the bridge
21
- device token. Set `WIBE_APP_URL` to the same Wibe origin used during setup.
20
+ `${env:WIBE_APP_URL}/api/mcp/projects/${WIBE_PROJECT_ID}`. The project-specific resource keeps this
21
+ repository's OAuth grant isolated from other Wibe projects on the same machine. Cursor completes
22
+ OAuth separately from the bridge device token. Set `WIBE_APP_URL` to the same Wibe origin used
23
+ during setup.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "mcpServers": {
3
3
  "wibe": {
4
- "url": "${env:WIBE_APP_URL}/api/mcp"
4
+ "url": "${env:WIBE_APP_URL}/api/mcp/projects/${WIBE_PROJECT_ID}"
5
5
  }
6
6
  }
7
7
  }