@agentvault/claude-bridge 0.6.2 → 0.6.4

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/dist/index.js CHANGED
@@ -446,6 +446,10 @@ var init_subcommand = __esm({
446
446
  }
447
447
  });
448
448
 
449
+ // src/index.ts
450
+ import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync5 } from "node:fs";
451
+ import { join as join10 } from "node:path";
452
+
449
453
  // ../plugin/dist/index.js
450
454
  import * as nc from "node:crypto";
451
455
  import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
@@ -65555,7 +65559,7 @@ var init_channel = __esm2({
65555
65559
  */
65556
65560
  sendActivitySpan(spanData) {
65557
65561
  if (!this._ws || this._ws.readyState !== WebSocket2.OPEN) return;
65558
- const pluginVersion = true ? "0.23.8" : "0.0.0-dev";
65562
+ const pluginVersion = true ? "0.23.10" : "0.0.0-dev";
65559
65563
  const agentName = this.config.agentName ?? "Agent";
65560
65564
  const resource = {
65561
65565
  "service.name": "agentvault-agent",
@@ -65796,10 +65800,120 @@ var init_channel = __esm2({
65796
65800
  this._sessions.delete(convId);
65797
65801
  delete this._persisted.sessions[convId];
65798
65802
  }
65803
+ if (room.mlsGroupId) {
65804
+ try {
65805
+ await deleteMlsState(this.config.dataDir, room.mlsGroupId);
65806
+ } catch {
65807
+ }
65808
+ }
65809
+ this._mlsGroups.delete(roomId);
65799
65810
  delete this._persisted.rooms[roomId];
65800
65811
  await this._persistState();
65801
65812
  this.emit("room_left", { roomId });
65802
65813
  }
65814
+ /**
65815
+ * #629 recurrence fix — reconcile local room state against the server on
65816
+ * connect.
65817
+ *
65818
+ * On disband the server marks each member ``"left"`` and sets
65819
+ * ``room.status="disbanded"`` but sends NO WS event to the agent's bridge, so a
65820
+ * disbanded/left room lingers in local state and replays dead MLS commit
65821
+ * history on every reconnect ("Desired gen in the past" / "invalid ghash tag").
65822
+ * ``GET /rooms`` returns ONLY active rooms the caller is a member of, so any
65823
+ * locally-persisted room ABSENT from it is terminal — prune its MLS state
65824
+ * (file + in-memory group, keyed by roomId), its sessions, and the room entry.
65825
+ *
65826
+ * Fail-safe: a failed fetch / non-ok / non-array response prunes NOTHING, so a
65827
+ * transient error can never over-prune a live room.
65828
+ */
65829
+ async _reconcileRoomsWithServer() {
65830
+ const local = this._persisted?.rooms;
65831
+ if (!local || Object.keys(local).length === 0) return;
65832
+ if (!this._deviceJwt) return;
65833
+ let activeIds;
65834
+ try {
65835
+ const res = await fetch(`${this.config.apiUrl}/api/v1/rooms`, {
65836
+ headers: { Authorization: `Bearer ${this._deviceJwt}` }
65837
+ });
65838
+ if (!res.ok) return;
65839
+ const list = await res.json();
65840
+ if (!Array.isArray(list)) return;
65841
+ activeIds = new Set(
65842
+ list.map((r22) => r22?.id).filter((id) => !!id)
65843
+ );
65844
+ } catch (err) {
65845
+ console.warn(
65846
+ `[SecureChannel] room reconcile skipped (fetch failed, pruning nothing): ${err instanceof Error ? err.message : String(err)}`
65847
+ );
65848
+ return;
65849
+ }
65850
+ const stale = Object.keys(local).filter((rid) => !activeIds.has(rid));
65851
+ if (stale.length === 0) return;
65852
+ for (const rid of stale) {
65853
+ const room = local[rid];
65854
+ if (room?.mlsGroupId) {
65855
+ try {
65856
+ await deleteMlsState(this.config.dataDir, room.mlsGroupId);
65857
+ } catch {
65858
+ }
65859
+ }
65860
+ this._mlsGroups.delete(rid);
65861
+ for (const convId of room?.conversationIds ?? []) {
65862
+ this._sessions.delete(convId);
65863
+ if (this._persisted?.sessions) delete this._persisted.sessions[convId];
65864
+ }
65865
+ delete local[rid];
65866
+ }
65867
+ await this._persistState();
65868
+ console.log(
65869
+ `[SecureChannel] Room reconcile: pruned ${stale.length} disbanded/left room(s) from local state (${stale.map((r22) => r22.slice(0, 8)).join(", ")})`
65870
+ );
65871
+ this.emit("rooms_reconciled", { pruned: stale });
65872
+ }
65873
+ /**
65874
+ * Handle an in-band `{event:"error"}` frame from the server.
65875
+ *
65876
+ * This is an APPLICATION-level error about one resource — it is NOT a
65877
+ * transport fault, and must never be treated as one. When the error names a
65878
+ * dead resource, the correct response is to PRUNE that resource; recycling
65879
+ * the connection would just replay the same doomed write.
65880
+ *
65881
+ * `conversation deleted` (#460/#481) is the server refusing a write to a
65882
+ * shared 1:1 MLS group whose conversations are all closed/deleted. It carries
65883
+ * `group_id` for exactly this purpose. Without the prune, `_persisted.mlsGroups`
65884
+ * is append-only and the 1:1 send path fans out to every entry, so the dead
65885
+ * group takes a rejected write on every message for the life of the agent
65886
+ * (loopita: group c1b1e11a, closed 2026-07-22, still bouncing 3 days later).
65887
+ *
65888
+ * Deliberately NARROW — it prunes only on this exact detail AND only when
65889
+ * `group_id` matches a persisted shared 1:1 group. Unknown ids and other
65890
+ * details prune nothing, so a server-side wording change or a room/A2A group
65891
+ * can never cost us live MLS state. Safe because `closed`/`deleted` are
65892
+ * terminal server-side, and a genuinely new group re-arrives via Welcome.
65893
+ *
65894
+ * Mirrors the #629 room prune: MLS state file, in-memory group, persisted
65895
+ * entry, then persist.
65896
+ */
65897
+ async _handleServerError(payload) {
65898
+ if (payload?.detail !== "conversation deleted") return;
65899
+ const mlsGroupId = payload.group_id;
65900
+ if (!mlsGroupId) return;
65901
+ const groups = this._persisted?.mlsGroups;
65902
+ if (!groups) return;
65903
+ const gid = Object.keys(groups).find((k2) => groups[k2]?.mlsGroupId === mlsGroupId);
65904
+ if (!gid) return;
65905
+ try {
65906
+ await deleteMlsState(this.config.dataDir, mlsGroupId);
65907
+ } catch {
65908
+ }
65909
+ this._mlsGroups.delete(`1to1-group:${gid}`);
65910
+ delete groups[gid];
65911
+ await this._persistState();
65912
+ console.log(
65913
+ `[SecureChannel] Pruned dead shared 1:1 group ${gid.slice(0, 8)} (${mlsGroupId.slice(0, 8)}) \u2014 server reports its conversation deleted`
65914
+ );
65915
+ this.emit("dm_group_pruned", { conversationGroupId: gid, mlsGroupId });
65916
+ }
65803
65917
  /**
65804
65918
  * Return info for all joined rooms.
65805
65919
  */
@@ -67150,6 +67264,9 @@ var init_channel = __esm2({
67150
67264
  await this._pullDrDeliveryQueue();
67151
67265
  await this._flushOutboundQueue();
67152
67266
  this._setState("ready");
67267
+ void this._reconcileRoomsWithServer().catch(
67268
+ (err) => console.warn(`[SecureChannel] room reconcile failed (ignored): ${err instanceof Error ? err.message : String(err)}`)
67269
+ );
67153
67270
  if (this.config.enableScanning) {
67154
67271
  this._scanEngine = new ScanEngine();
67155
67272
  await this._fetchScanRules();
@@ -67172,7 +67289,7 @@ var init_channel = __esm2({
67172
67289
  agentVersion: this.config.agentVersion ?? "0.0.0",
67173
67290
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
67174
67291
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
67175
- pluginVersion: true ? "0.23.8" : "0.0.0-dev"
67292
+ pluginVersion: true ? "0.23.10" : "0.0.0-dev"
67176
67293
  });
67177
67294
  this._telemetryReporter.startAutoFlush(3e4);
67178
67295
  }
@@ -67490,7 +67607,7 @@ var init_channel = __esm2({
67490
67607
  agentVersion: this.config.agentVersion ?? "0.0.0",
67491
67608
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
67492
67609
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
67493
- pluginVersion: true ? "0.23.8" : "0.0.0-dev"
67610
+ pluginVersion: true ? "0.23.10" : "0.0.0-dev"
67494
67611
  });
67495
67612
  this._telemetryReporter.startAutoFlush(3e4);
67496
67613
  }
@@ -67800,6 +67917,7 @@ var init_channel = __esm2({
67800
67917
  if (data.event === "error") {
67801
67918
  const detail = data.data?.detail || data.detail || "Unknown server error";
67802
67919
  console.error(`[SecureChannel] Server error: ${detail}`);
67920
+ await this._handleServerError(data.data || data);
67803
67921
  this.emit("error", new Error(`Server: ${detail}`));
67804
67922
  }
67805
67923
  if (data.event === "a2a_message_mls") {
@@ -97268,7 +97386,7 @@ var init_index = __esm2({
97268
97386
  init_skill_invoker();
97269
97387
  await init_skill_telemetry();
97270
97388
  await init_policy_enforcer();
97271
- VERSION = true ? "0.23.8" : "0.0.0-dev";
97389
+ VERSION = true ? "0.23.10" : "0.0.0-dev";
97272
97390
  }
97273
97391
  });
97274
97392
  await init_index();
@@ -132601,6 +132719,23 @@ var PersistentClaudeSession = class {
132601
132719
  abort() {
132602
132720
  this._abort?.abort();
132603
132721
  }
132722
+ /**
132723
+ * Forensic snapshot of the CURRENT turn, for the worker-queue trap.
132724
+ *
132725
+ * Read by `WorkerQueue` when a task times out or errors, BEFORE `abort()` tears
132726
+ * the worker down. It is the difference between two hangs that look identical
132727
+ * from outside (a five-minute silence) but have opposite causes:
132728
+ * composedChars > 0, sawResult false -> the model streamed then stalled
132729
+ * composedChars = 0 -> the turn never produced anything
132730
+ * Read-only; it must never mutate turn state.
132731
+ */
132732
+ snapshot() {
132733
+ return {
132734
+ composedChars: this.turnText.length,
132735
+ sawResult: this.sawResultThisTurn,
132736
+ said: this.saidThisTurn
132737
+ };
132738
+ }
132604
132739
  async *input() {
132605
132740
  while (true) {
132606
132741
  let item;
@@ -132783,7 +132918,11 @@ var WorkerQueue = class {
132783
132918
  loop = Promise.resolve();
132784
132919
  running = false;
132785
132920
  enqueue(task) {
132786
- this.q.push(task);
132921
+ this.q.push({
132922
+ task,
132923
+ enqueuedAt: Date.now(),
132924
+ queueDepthAtEnqueue: this.q.length + (this.running ? 1 : 0)
132925
+ });
132787
132926
  if (!this.running) {
132788
132927
  this.running = true;
132789
132928
  this.loop = this.drain();
@@ -132796,16 +132935,28 @@ var WorkerQueue = class {
132796
132935
  async drain() {
132797
132936
  try {
132798
132937
  while (this.q.length > 0) {
132799
- const task = this.q.shift();
132800
- await this.runOne(task);
132938
+ const entry = this.q.shift();
132939
+ await this.runOne(entry);
132801
132940
  }
132802
132941
  } finally {
132803
132942
  this.running = false;
132804
132943
  }
132805
132944
  }
132806
- async runOne(task) {
132945
+ /** Best-effort trap emit. A broken sink must never break the queue. */
132946
+ emitIncident(rec) {
132947
+ try {
132948
+ this.deps.onIncident?.(rec);
132949
+ } catch {
132950
+ }
132951
+ }
132952
+ async runOne(entry) {
132953
+ const { task } = entry;
132954
+ const startedAt = Date.now();
132955
+ const waitedMs = startedAt - entry.enqueuedAt;
132956
+ const queueDepthAtStart = this.q.length;
132807
132957
  let session;
132808
132958
  let timer;
132959
+ let timedOut = false;
132809
132960
  try {
132810
132961
  session = this.deps.makeSession(task);
132811
132962
  session.push(task.instruction, task.reply, {
@@ -132816,13 +132967,28 @@ var WorkerQueue = class {
132816
132967
  const currentSession = session;
132817
132968
  const timeout = new Promise((_resolve, reject) => {
132818
132969
  timer = setTimeout(() => {
132970
+ timedOut = true;
132819
132971
  currentSession.abort();
132820
132972
  reject(new Error("worker task timeout"));
132821
132973
  }, this.deps.timeoutMs);
132822
132974
  });
132823
132975
  await Promise.race([session.start(), timeout]);
132824
132976
  } catch (e7) {
132825
- this.deps.log(`[worker-queue] task failed: ${e7.message}`);
132977
+ const err = e7;
132978
+ this.deps.log(`[worker-queue] task failed: ${err.message}`);
132979
+ const snap = session?.snapshot?.();
132980
+ this.emitIncident({
132981
+ at: new Date(startedAt).toISOString(),
132982
+ outcome: timedOut ? "timeout" : "error",
132983
+ error: err.message,
132984
+ waitedMs,
132985
+ ranMs: Date.now() - startedAt,
132986
+ timeoutMs: this.deps.timeoutMs,
132987
+ queueDepthAtEnqueue: entry.queueDepthAtEnqueue,
132988
+ queueDepthAtStart,
132989
+ instructionChars: task.instruction.length,
132990
+ ...snap ? { session: snap } : {}
132991
+ });
132826
132992
  session?.abort();
132827
132993
  try {
132828
132994
  await task.reply(GENERIC_ERROR);
@@ -133303,7 +133469,7 @@ async function main() {
133303
133469
  "[bridge] warning: passing the invite token on the command line is visible to other local users via 'ps'. Prefer: AV_INVITE_TOKEN=\u2026 npx @agentvault/claude-bridge"
133304
133470
  );
133305
133471
  }
133306
- console.error(`[bridge] version: ${true ? "0.6.2" : "dev"}`);
133472
+ console.error(`[bridge] version: ${true ? "0.6.4" : "dev"}`);
133307
133473
  console.error(`[bridge] data dir: ${cfg.dataDir} (${cfg.dataDirSource})`);
133308
133474
  console.error(`[bridge] workspace: ${cfg.workspaceDir} \xB7 permissions: ${cfg.permissionMode} \xB7 enclave dir fenced`);
133309
133475
  if (cfg.armRoom) {
@@ -133377,7 +133543,25 @@ async function main() {
133377
133543
  deviceJwt
133378
133544
  }),
133379
133545
  timeoutMs: WORKER_TIMEOUT_MS,
133380
- log: (m6) => console.error(m6)
133546
+ log: (m6) => console.error(m6),
133547
+ // TRAP (2026-07-25). The 07-24 five-minute silence was diagnosable only by
133548
+ // luck — the drop-trap happened to record a reply landing 300.016s after the
133549
+ // ack, and 300s is WORKER_TIMEOUT_MS exactly. Nothing recorded WHY the worker
133550
+ // hung, and these logs carry no timestamps, so the evidence was already gone.
133551
+ // Append-only JSONL beside the logs, plus one stamped console line so the
133552
+ // incident is visible in bridge.error.log at the moment it happens.
133553
+ onIncident: (rec) => {
133554
+ console.error(
133555
+ `[worker-trap] ${rec.at} ${rec.outcome} after ${rec.ranMs}ms (waited ${rec.waitedMs}ms behind ${rec.queueDepthAtEnqueue}, ${rec.queueDepthAtStart} still queued)` + (rec.session ? ` composed=${rec.session.composedChars} result=${rec.session.sawResult} said=${rec.session.said}` : "")
133556
+ );
133557
+ try {
133558
+ const dir = join10(cfg.dataDir, "logs");
133559
+ mkdirSync5(dir, { recursive: true });
133560
+ appendFileSync2(join10(dir, "worker-incidents.jsonl"), JSON.stringify(rec) + "\n");
133561
+ } catch (err) {
133562
+ console.error(`[worker-trap] could not persist incident: ${err.message}`);
133563
+ }
133564
+ }
133381
133565
  });
133382
133566
  const router = makeRouter({ workAllowed, listener, queue: workerQueue });
133383
133567
  wireBridge(
package/dist/session.d.ts CHANGED
@@ -171,6 +171,21 @@ export declare class PersistentClaudeSession {
171
171
  deliver(text: string): Promise<void>;
172
172
  /** Abort the in-flight query() — used by the worker queue on a per-task timeout. */
173
173
  abort(): void;
174
+ /**
175
+ * Forensic snapshot of the CURRENT turn, for the worker-queue trap.
176
+ *
177
+ * Read by `WorkerQueue` when a task times out or errors, BEFORE `abort()` tears
178
+ * the worker down. It is the difference between two hangs that look identical
179
+ * from outside (a five-minute silence) but have opposite causes:
180
+ * composedChars > 0, sawResult false -> the model streamed then stalled
181
+ * composedChars = 0 -> the turn never produced anything
182
+ * Read-only; it must never mutate turn state.
183
+ */
184
+ snapshot(): {
185
+ composedChars: number;
186
+ sawResult: boolean;
187
+ said: boolean;
188
+ };
174
189
  private input;
175
190
  /**
176
191
  * Build the SDK options for this session. Locked mode (default) is safe for
@@ -16,20 +16,70 @@ export type WorkerTask = {
16
16
  * disarm denies the next call). Absent for owner DMs. */
17
17
  armed?: () => boolean;
18
18
  };
19
+ /**
20
+ * Forensic record of a task that did NOT complete cleanly.
21
+ *
22
+ * Exists because the 2026-07-24 "loopita went quiet for 5 minutes" incident was
23
+ * diagnosable only by coincidence: the drop-trap happened to capture that the
24
+ * reply landed 300.016s after the ack, and 300s is `WORKER_TIMEOUT_MS` exactly.
25
+ * Nothing in the bridge recorded WHY the worker hung, and the logs carry no
26
+ * timestamps at all, so the evidence was gone by the time we looked.
27
+ */
28
+ export type WorkerIncident = {
29
+ /** ISO timestamp — the bridge's own logs are unstamped, so this is the anchor. */
30
+ at: string;
31
+ outcome: "timeout" | "error";
32
+ error: string;
33
+ /**
34
+ * enqueue -> start. THE load-bearing field: it separates "this task hung" from
35
+ * "this task was stuck behind one that hung". Both look identical to the owner
36
+ * (a long silence) and have completely different fixes.
37
+ */
38
+ waitedMs: number;
39
+ /** start -> failure. Approaches timeoutMs for a true hang. */
40
+ ranMs: number;
41
+ timeoutMs: number;
42
+ /** Depth when this task was enqueued — >0 means it was already behind someone. */
43
+ queueDepthAtEnqueue: number;
44
+ /** Depth still waiting when this task began — the blast radius of this hang. */
45
+ queueDepthAtStart: number;
46
+ instructionChars: number;
47
+ /** What the worker had produced when it died, when the session can report it. */
48
+ session?: {
49
+ composedChars: number;
50
+ sawResult: boolean;
51
+ said: boolean;
52
+ };
53
+ };
19
54
  export interface WorkerQueueDeps {
20
55
  /** Build a fresh ephemeral worker session for this task (not yet started). */
21
56
  makeSession: (task: WorkerTask) => PersistentClaudeSession;
22
57
  /** Per-task wall-clock cap; on breach the worker is aborted and the queue advances. */
23
58
  timeoutMs: number;
24
59
  log: (m: string) => void;
60
+ /**
61
+ * Trap sink for non-clean outcomes. Optional, best-effort, and never allowed to
62
+ * affect the queue: a trap that can break the thing it observes is worse than
63
+ * no trap.
64
+ */
65
+ onIncident?: (rec: WorkerIncident) => void;
25
66
  }
26
67
  /**
27
68
  * Serial FIFO of tool-eligible turns. One worker runs at a time (concurrent tool
28
69
  * calls against one device/workspace can interleave destructively; the owner is a
29
70
  * single actor). Each task runs in a fresh ephemeral worker seeded with only its
30
71
  * instruction; on error or timeout the worker is torn down, a generic message is
31
- * sent to the task's reply, and the queue advances — a task can never wedge the
32
- * queue (which would starve the owner's DM lane).
72
+ * sent to the task's reply, and the queue advances.
73
+ *
74
+ * ⚠️ A task cannot wedge the queue PERMANENTLY, but it absolutely can wedge it for
75
+ * up to `timeoutMs`. Serial + a 5-minute cap means ONE hung worker delays every
76
+ * message behind it by up to five minutes, and the owner gets the generic error
77
+ * for the FIRST message while a LATER one answers normally. That is not
78
+ * hypothetical: it is the 2026-07-24 loopita incident, where the reply landed
79
+ * 300.016s after the ack. The timeout is the amplifier, not the trigger — see
80
+ * `WorkerIncident` and the `waitedMs` field, which is what tells a hung task
81
+ * apart from its victims. (This comment previously claimed a task "can never
82
+ * wedge the queue"; it could, and did.)
33
83
  */
34
84
  export declare class WorkerQueue {
35
85
  private deps;
@@ -41,6 +91,8 @@ export declare class WorkerQueue {
41
91
  /** Resolves when the queue has processed everything enqueued so far. */
42
92
  whenDrained(): Promise<void>;
43
93
  private drain;
94
+ /** Best-effort trap emit. A broken sink must never break the queue. */
95
+ private emitIncident;
44
96
  private runOne;
45
97
  }
46
98
  //# sourceMappingURL=worker-queue.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentvault/claude-bridge",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "type": "module",
5
5
  "description": "AgentVault Claude Bridge — daemon for bridging a Claude agent into secure E2E-encrypted AgentVault 1:1 direct messages and rooms.",
6
6
  "main": "dist/index.js",