@agentvault/claude-bridge 0.6.3 → 0.6.5

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.9" : "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",
@@ -65866,6 +65870,50 @@ var init_channel = __esm2({
65866
65870
  );
65867
65871
  this.emit("rooms_reconciled", { pruned: stale });
65868
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
+ }
65869
65917
  /**
65870
65918
  * Return info for all joined rooms.
65871
65919
  */
@@ -67241,7 +67289,7 @@ var init_channel = __esm2({
67241
67289
  agentVersion: this.config.agentVersion ?? "0.0.0",
67242
67290
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
67243
67291
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
67244
- pluginVersion: true ? "0.23.9" : "0.0.0-dev"
67292
+ pluginVersion: true ? "0.23.10" : "0.0.0-dev"
67245
67293
  });
67246
67294
  this._telemetryReporter.startAutoFlush(3e4);
67247
67295
  }
@@ -67559,7 +67607,7 @@ var init_channel = __esm2({
67559
67607
  agentVersion: this.config.agentVersion ?? "0.0.0",
67560
67608
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
67561
67609
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
67562
- pluginVersion: true ? "0.23.9" : "0.0.0-dev"
67610
+ pluginVersion: true ? "0.23.10" : "0.0.0-dev"
67563
67611
  });
67564
67612
  this._telemetryReporter.startAutoFlush(3e4);
67565
67613
  }
@@ -67869,6 +67917,7 @@ var init_channel = __esm2({
67869
67917
  if (data.event === "error") {
67870
67918
  const detail = data.data?.detail || data.detail || "Unknown server error";
67871
67919
  console.error(`[SecureChannel] Server error: ${detail}`);
67920
+ await this._handleServerError(data.data || data);
67872
67921
  this.emit("error", new Error(`Server: ${detail}`));
67873
67922
  }
67874
67923
  if (data.event === "a2a_message_mls") {
@@ -97337,7 +97386,7 @@ var init_index = __esm2({
97337
97386
  init_skill_invoker();
97338
97387
  await init_skill_telemetry();
97339
97388
  await init_policy_enforcer();
97340
- VERSION = true ? "0.23.9" : "0.0.0-dev";
97389
+ VERSION = true ? "0.23.10" : "0.0.0-dev";
97341
97390
  }
97342
97391
  });
97343
97392
  await init_index();
@@ -132670,6 +132719,23 @@ var PersistentClaudeSession = class {
132670
132719
  abort() {
132671
132720
  this._abort?.abort();
132672
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
+ }
132673
132739
  async *input() {
132674
132740
  while (true) {
132675
132741
  let item;
@@ -132852,7 +132918,11 @@ var WorkerQueue = class {
132852
132918
  loop = Promise.resolve();
132853
132919
  running = false;
132854
132920
  enqueue(task) {
132855
- this.q.push(task);
132921
+ this.q.push({
132922
+ task,
132923
+ enqueuedAt: Date.now(),
132924
+ queueDepthAtEnqueue: this.q.length + (this.running ? 1 : 0)
132925
+ });
132856
132926
  if (!this.running) {
132857
132927
  this.running = true;
132858
132928
  this.loop = this.drain();
@@ -132865,16 +132935,28 @@ var WorkerQueue = class {
132865
132935
  async drain() {
132866
132936
  try {
132867
132937
  while (this.q.length > 0) {
132868
- const task = this.q.shift();
132869
- await this.runOne(task);
132938
+ const entry = this.q.shift();
132939
+ await this.runOne(entry);
132870
132940
  }
132871
132941
  } finally {
132872
132942
  this.running = false;
132873
132943
  }
132874
132944
  }
132875
- 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;
132876
132957
  let session;
132877
132958
  let timer;
132959
+ let timedOut = false;
132878
132960
  try {
132879
132961
  session = this.deps.makeSession(task);
132880
132962
  session.push(task.instruction, task.reply, {
@@ -132885,13 +132967,28 @@ var WorkerQueue = class {
132885
132967
  const currentSession = session;
132886
132968
  const timeout = new Promise((_resolve, reject) => {
132887
132969
  timer = setTimeout(() => {
132970
+ timedOut = true;
132888
132971
  currentSession.abort();
132889
132972
  reject(new Error("worker task timeout"));
132890
132973
  }, this.deps.timeoutMs);
132891
132974
  });
132892
132975
  await Promise.race([session.start(), timeout]);
132893
132976
  } catch (e7) {
132894
- 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
+ });
132895
132992
  session?.abort();
132896
132993
  try {
132897
132994
  await task.reply(GENERIC_ERROR);
@@ -133230,7 +133327,7 @@ function wireBridge(channel, session, target, opts = {}) {
133230
133327
  }
133231
133328
  channel.on("error", (err) => {
133232
133329
  const msg = err instanceof Error ? err.message : String(err);
133233
- log(`channel error (auto-reconnecting): ${msg}`);
133330
+ log(`channel error (non-fatal, not a reconnect trigger): ${msg}`);
133234
133331
  });
133235
133332
  const hush = new RoomHushState();
133236
133333
  channel.on("room_hushed", (e7) => {
@@ -133372,7 +133469,7 @@ async function main() {
133372
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"
133373
133470
  );
133374
133471
  }
133375
- console.error(`[bridge] version: ${true ? "0.6.3" : "dev"}`);
133472
+ console.error(`[bridge] version: ${true ? "0.6.5" : "dev"}`);
133376
133473
  console.error(`[bridge] data dir: ${cfg.dataDir} (${cfg.dataDirSource})`);
133377
133474
  console.error(`[bridge] workspace: ${cfg.workspaceDir} \xB7 permissions: ${cfg.permissionMode} \xB7 enclave dir fenced`);
133378
133475
  if (cfg.armRoom) {
@@ -133446,7 +133543,25 @@ async function main() {
133446
133543
  deviceJwt
133447
133544
  }),
133448
133545
  timeoutMs: WORKER_TIMEOUT_MS,
133449
- 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
+ }
133450
133565
  });
133451
133566
  const router = makeRouter({ workAllowed, listener, queue: workerQueue });
133452
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.3",
3
+ "version": "0.6.5",
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",