@agentvault/claude-bridge 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bridge.d.ts CHANGED
@@ -3,14 +3,60 @@ export interface RoomMessage {
3
3
  senderName: string;
4
4
  plaintext: string;
5
5
  }
6
+ /** Metadata SecureChannel attaches to a 1:1 `message` event. `roomId` is set only
7
+ * when the `message` actually originated in a room (handled by room_message), so
8
+ * its ABSENCE is how we identify a true owner↔agent 1:1 DM. */
9
+ export interface MessageMeta {
10
+ roomId?: string;
11
+ }
6
12
  export interface RoomChannel {
7
13
  on(ev: "room_message", cb: (e: RoomMessage) => void): unknown;
14
+ on(ev: "message", cb: (text: string, metadata: MessageMeta) => void): unknown;
8
15
  on(ev: "error", cb: (err: unknown) => void): unknown;
9
16
  sendToRoom(roomId: string, text: string): Promise<void>;
17
+ send(text: string): Promise<void>;
10
18
  }
11
19
  export interface RoomSession {
12
- push(text: string): void;
13
- onActiveRoom(roomId: string): void;
20
+ /** `reply` is the immutable reply sink captured for THIS message (see
21
+ * ActiveTarget.snapshotReply) — the session invokes it when Claude answers. */
22
+ push(text: string, reply?: (text: string) => Promise<void>): void;
23
+ }
24
+ /** Where a reply should go: a room (sendToRoom) or a 1:1 DM (send). */
25
+ export type BridgeTarget = {
26
+ kind: "room";
27
+ roomId: string;
28
+ } | {
29
+ kind: "dm";
30
+ };
31
+ /** Channel surface ActiveTarget needs to route an outbound reply. */
32
+ export interface ReplyChannel {
33
+ sendToRoom(roomId: string, text: string): Promise<void>;
34
+ send(text: string): Promise<void>;
35
+ }
36
+ /**
37
+ * Tracks the destination of the most recent inbound message and routes outbound
38
+ * replies there. Replaces the bridge's old single `activeRoomId` string so the
39
+ * same Claude session can answer BOTH room traffic and 1:1 owner DMs.
40
+ *
41
+ * Known limitation (matches the prior room-only behavior): the target is global,
42
+ * set per-inbound. If a room message lands while Claude is mid-compose on a DM
43
+ * reply, the reply routes to the room. Binding a reply to its originating turn is
44
+ * a larger redesign; for now the single-conversation-at-a-time assumption holds.
45
+ */
46
+ export declare class ActiveTarget {
47
+ private target;
48
+ setRoom(roomId: string): void;
49
+ setDm(): void;
50
+ get current(): BridgeTarget | null;
51
+ reply(channel: ReplyChannel, text: string): Promise<void>;
52
+ /**
53
+ * Capture the CURRENT target into an immutable reply closure. This is the leak
54
+ * fix: a reply built here routes to where its inbound message came from, even if
55
+ * a later inbound flips the live `current` target. The bridge captures one of
56
+ * these per inbound (at push time) and hands it to the session, so a room message
57
+ * arriving mid-compose can never redirect a private 1:1 reply into the room.
58
+ */
59
+ snapshotReply(channel: ReplyChannel, log?: (msg: string) => void): (text: string) => Promise<void>;
14
60
  }
15
61
  export interface LifecycleChannel {
16
62
  on(ev: "auth_failed", cb: (e: {
@@ -38,7 +84,7 @@ export declare function attachLifecycle(channel: LifecycleChannel, opts?: {
38
84
  log?: (msg: string) => void;
39
85
  onTerminal?: (reason: string) => void;
40
86
  }): void;
41
- export declare function wireBridge(channel: RoomChannel, session: RoomSession, opts?: {
87
+ export declare function wireBridge(channel: RoomChannel, session: RoomSession, target: ActiveTarget, opts?: {
42
88
  roomFilter?: string;
43
89
  log?: (msg: string) => void;
44
90
  }): void;
package/dist/config.d.ts CHANGED
@@ -1,6 +1,8 @@
1
+ export type DataDirSource = "explicit" | "per-agent" | "legacy";
1
2
  export interface BridgeConfig {
2
3
  inviteToken: string;
3
4
  dataDir: string;
5
+ dataDirSource: DataDirSource;
4
6
  apiUrl: string;
5
7
  agentName: string;
6
8
  roomFilter?: string;
package/dist/index.js CHANGED
@@ -68497,6 +68497,21 @@ ${messageText}`;
68497
68497
  return;
68498
68498
  }
68499
68499
  }
68500
+ if (welcomeRoomId && this._persisted) {
68501
+ await this._registerRoomFromWelcome(
68502
+ welcomeRoomId,
68503
+ groupId,
68504
+ mgr,
68505
+ data.room_name
68506
+ );
68507
+ this._pendingMlsKpBundle = void 0;
68508
+ await releaseA2ASyncLock(this.config.dataDir, `room-kp-${groupId}`).catch(() => {
68509
+ });
68510
+ console.log(
68511
+ `[SecureChannel] Registered room ${welcomeRoomId.slice(0, 8)} from unmatched Welcome (epoch=${mgr.epoch})`
68512
+ );
68513
+ return;
68514
+ }
68500
68515
  const a2aChannelId = data.a2a_channel_id;
68501
68516
  for (const [channelId, entry] of Object.entries(this._persisted?.a2aChannels ?? {})) {
68502
68517
  if (entry.mlsGroupId === groupId || channelId === a2aChannelId) {
@@ -68553,6 +68568,33 @@ ${messageText}`;
68553
68568
  throw err;
68554
68569
  }
68555
68570
  }
68571
+ /**
68572
+ * Self-bootstrap a room entry + MLS group mapping from a Welcome whose room is
68573
+ * not yet persisted. Happens when we were added to an EXISTING room and the
68574
+ * `room_joined` that registers the room raced behind (or was lost relative to)
68575
+ * the Welcome. Without a room entry the Welcome room-match loop can't match, and
68576
+ * every subsequent room message is dropped with "No room found for MLS group".
68577
+ * Mirrors the A2A self-bootstrap fallback. The backend now also sends the new
68578
+ * device its own `room_joined` (PR #412); this is defense-in-depth for the race
68579
+ * where that delivery is missed. A later `room_joined` enriches name/members.
68580
+ */
68581
+ async _registerRoomFromWelcome(roomId, groupId, mgr, roomName) {
68582
+ if (!this._persisted) return;
68583
+ if (!this._persisted.rooms) this._persisted.rooms = {};
68584
+ const existing = this._persisted.rooms[roomId];
68585
+ this._persisted.rooms[roomId] = {
68586
+ roomId,
68587
+ name: existing?.name ?? (roomName ?? ""),
68588
+ conversationIds: existing?.conversationIds ?? [],
68589
+ members: existing?.members ?? [],
68590
+ mlsGroupId: groupId,
68591
+ lastMlsMessageTs: existing?.lastMlsMessageTs
68592
+ };
68593
+ this._mlsGroups.set(roomId, mgr);
68594
+ await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mgr.exportState()));
68595
+ await this._persistState();
68596
+ await this._applyBufferedMlsCommits(groupId, mgr);
68597
+ }
68556
68598
  /**
68557
68599
  * Pull pending MLS messages from the delivery queue and process them.
68558
68600
  * Called on WS connect, on mls_delivery ping, and every 30s heartbeat.
@@ -69967,6 +70009,40 @@ var init_account_config = __esm({
69967
70009
  DEFAULT_HTTP_PORT = 18790;
69968
70010
  }
69969
70011
  });
70012
+ function terminalReenrollMessage(agentName, reason) {
70013
+ const who = agentName ? `[${agentName}] ` : "";
70014
+ return `${who}device is no longer valid (${reason}) \u2014 it was revoked or replaced. Re-enroll with a fresh token from AgentVault; the old credentials are dead. This agent will stop reconnecting.`;
70015
+ }
70016
+ function attachLifecycle(channel, opts = {}) {
70017
+ const log = opts.log ?? (() => {
70018
+ });
70019
+ const agentName = opts.agentName ?? "";
70020
+ const onTerminal = opts.onTerminal ?? (() => {
70021
+ });
70022
+ let fired = false;
70023
+ const terminal = (reason) => {
70024
+ if (fired) return;
70025
+ fired = true;
70026
+ log(terminalReenrollMessage(agentName, reason));
70027
+ void Promise.resolve(onTerminal(reason)).catch(() => {
70028
+ });
70029
+ };
70030
+ channel.on("auth_failed", (e7) => terminal(e7.reason));
70031
+ channel.on(
70032
+ "session_replaced",
70033
+ (e7) => terminal(e7?.code ? `session_replaced (${e7.code})` : "session_replaced")
70034
+ );
70035
+ channel.on("error", (err) => {
70036
+ const msg = err instanceof Error ? err.message : String(err);
70037
+ if (/reconnect loop detected/i.test(msg)) terminal("reconnect_loop");
70038
+ else if (/device (was )?revoked/i.test(msg)) terminal("device_revoked");
70039
+ });
70040
+ }
70041
+ var init_lifecycle = __esm({
70042
+ "src/lifecycle.ts"() {
70043
+ "use strict";
70044
+ }
70045
+ });
69970
70046
  async function _handleInbound(params) {
69971
70047
  const { plaintext, metadata, channel, account, cfg } = params;
69972
70048
  const core = _ocRuntime;
@@ -70051,6 +70127,7 @@ var init_openclaw_plugin = __esm({
70051
70127
  await init_channel();
70052
70128
  init_account_config();
70053
70129
  init_openclaw_compat();
70130
+ init_lifecycle();
70054
70131
  _ocRuntime = null;
70055
70132
  _channels = /* @__PURE__ */ new Map();
70056
70133
  agentVaultPlugin = {
@@ -70108,6 +70185,7 @@ var init_openclaw_plugin = __esm({
70108
70185
  const dataDir = resolve(account.dataDir.replace(/^~/, process.env.HOME ?? "~"));
70109
70186
  _log?.(`[AgentVault] starting channel (dataDir=${dataDir})`);
70110
70187
  await new Promise((resolvePromise, reject) => {
70188
+ let terminalStopped = false;
70111
70189
  const channel = new SecureChannel({
70112
70190
  inviteToken: "",
70113
70191
  dataDir,
@@ -70126,13 +70204,29 @@ var init_openclaw_plugin = __esm({
70126
70204
  },
70127
70205
  onStateChange: (state) => {
70128
70206
  _log?.(`[AgentVault] state \u2192 ${state}`);
70129
- if (state === "error") reject(new Error("AgentVault channel permanent error"));
70207
+ if (state === "error") {
70208
+ queueMicrotask(() => {
70209
+ if (!terminalStopped) reject(new Error("AgentVault channel permanent error"));
70210
+ });
70211
+ }
70130
70212
  }
70131
70213
  });
70132
70214
  _channels.set(account.accountId, channel);
70133
70215
  channel.on("error", (err) => {
70134
70216
  _log?.(`[AgentVault] channel error (non-fatal): ${String(err)}`);
70135
70217
  });
70218
+ attachLifecycle(channel, {
70219
+ agentName: account.agentName,
70220
+ log: (m22) => _log?.(`[AgentVault] ${m22}`),
70221
+ onTerminal: async () => {
70222
+ terminalStopped = true;
70223
+ try {
70224
+ await channel.stop();
70225
+ } catch {
70226
+ }
70227
+ _channels.delete(account.accountId);
70228
+ }
70229
+ });
70136
70230
  const httpPort = account.httpPort;
70137
70231
  channel.on("ready", () => {
70138
70232
  channel.startHttpServer(httpPort);
@@ -70143,7 +70237,9 @@ var init_openclaw_plugin = __esm({
70143
70237
  _channels.delete(account.accountId);
70144
70238
  resolvePromise();
70145
70239
  });
70146
- channel.start().catch(reject);
70240
+ channel.start().catch((e7) => {
70241
+ if (!terminalStopped) reject(e7);
70242
+ });
70147
70243
  });
70148
70244
  return { stop: async () => {
70149
70245
  } };
@@ -70262,6 +70358,7 @@ var init_openclaw_entry = __esm({
70262
70358
  init_fetch_interceptor();
70263
70359
  init_http_handlers();
70264
70360
  init_openclaw_compat();
70361
+ init_lifecycle();
70265
70362
  init_types();
70266
70363
  isUsingManagedRoutes = false;
70267
70364
  }
@@ -96325,8 +96422,19 @@ var CRED_FILES = ["agentvault.json", "secure-channel.json"];
96325
96422
  function hasPersistedCreds(dataDir) {
96326
96423
  return CRED_FILES.some((f7) => existsSync(join5(dataDir, f7)));
96327
96424
  }
96425
+ function slugify2(name) {
96426
+ return name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
96427
+ }
96428
+ function resolveDataDir(env) {
96429
+ if (env.AV_DATA_DIR) return { dataDir: env.AV_DATA_DIR, source: "explicit" };
96430
+ const base = join5(env.HOME ?? "", ".agentvault", "claude-room-bridge");
96431
+ const perAgent = join5(base, slugify2(env.AV_AGENT_NAME ?? "claude"));
96432
+ if (hasPersistedCreds(perAgent)) return { dataDir: perAgent, source: "per-agent" };
96433
+ if (hasPersistedCreds(base)) return { dataDir: base, source: "legacy" };
96434
+ return { dataDir: perAgent, source: "per-agent" };
96435
+ }
96328
96436
  function loadConfig(env, argv = []) {
96329
- const dataDir = env.AV_DATA_DIR ?? `${env.HOME}/.agentvault/claude-room-bridge`;
96437
+ const { dataDir, source: dataDirSource } = resolveDataDir(env);
96330
96438
  const inviteToken = (argv[0] && !argv[0].startsWith("-") ? argv[0] : "") || env.AV_INVITE_TOKEN || "";
96331
96439
  if (!inviteToken && !hasPersistedCreds(dataDir)) {
96332
96440
  throw new Error(
@@ -96336,6 +96444,7 @@ function loadConfig(env, argv = []) {
96336
96444
  return {
96337
96445
  inviteToken,
96338
96446
  dataDir,
96447
+ dataDirSource,
96339
96448
  apiUrl: env.AV_API_URL ?? "https://api.agentvault.chat",
96340
96449
  // Identity shown to the agent's own session. Set this to the agent's
96341
96450
  // AgentVault name via AV_AGENT_NAME (the native connect command passes it).
@@ -118294,7 +118403,7 @@ __export(util_exports2, {
118294
118403
  required: () => required2,
118295
118404
  safeExtend: () => safeExtend2,
118296
118405
  shallowClone: () => shallowClone2,
118297
- slugify: () => slugify2,
118406
+ slugify: () => slugify3,
118298
118407
  stringifyPrimitive: () => stringifyPrimitive2,
118299
118408
  uint8ArrayToBase64: () => uint8ArrayToBase643,
118300
118409
  uint8ArrayToBase64url: () => uint8ArrayToBase64url2,
@@ -118435,7 +118544,7 @@ function randomString2(length = 10) {
118435
118544
  function esc2(str) {
118436
118545
  return JSON.stringify(str);
118437
118546
  }
118438
- function slugify2(input) {
118547
+ function slugify3(input) {
118439
118548
  return input.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
118440
118549
  }
118441
118550
  var captureStackTrace2 = "captureStackTrace" in Error ? Error.captureStackTrace : (..._args) => {
@@ -128169,7 +128278,7 @@ function _toUpperCase2() {
128169
128278
  }
128170
128279
  // @__NO_SIDE_EFFECTS__
128171
128280
  function _slugify2() {
128172
- return /* @__PURE__ */ _overwrite2((input) => slugify2(input));
128281
+ return /* @__PURE__ */ _overwrite2((input) => slugify3(input));
128173
128282
  }
128174
128283
  // @__NO_SIDE_EFFECTS__
128175
128284
  function _array2(Class3, element, params) {
@@ -131413,11 +131522,11 @@ config2(en_default3());
131413
131522
  function makeRoomSayTool(onSay) {
131414
131523
  return bs(
131415
131524
  "say",
131416
- "Send a message to the current AgentVault room so the other participants see it. Call this ONLY when you have something worth adding. If you have nothing to say, do not call it \u2014 stay silent.",
131417
- { message: external_exports.string().describe("The message to post to the room.") },
131525
+ "Send a message to the current AgentVault conversation \u2014 your owner in a 1:1 direct message, or the other participants in a room. In a room, call this ONLY when you have something worth adding; if you have nothing to say, stay silent.",
131526
+ { message: external_exports.string().describe("The message to send to the current conversation.") },
131418
131527
  async (args) => {
131419
131528
  await onSay(args.message);
131420
- return { content: [{ type: "text", text: "Message sent to the room." }] };
131529
+ return { content: [{ type: "text", text: "Message sent." }] };
131421
131530
  }
131422
131531
  );
131423
131532
  }
@@ -131429,37 +131538,52 @@ var PersistentClaudeSession = class {
131429
131538
  opts;
131430
131539
  waiting = null;
131431
131540
  pending = [];
131432
- push(text) {
131541
+ /** Reply sink bound to the message currently being processed. Set as each
131542
+ * message is handed to the model so the say tool routes to the right place. */
131543
+ activeReply;
131544
+ push(text, reply) {
131433
131545
  const msg = {
131434
131546
  type: "user",
131435
131547
  message: { role: "user", content: text },
131436
131548
  parent_tool_use_id: null,
131437
131549
  session_id: ""
131438
131550
  };
131551
+ const item = { msg, reply };
131439
131552
  if (this.waiting) {
131440
131553
  const w2 = this.waiting;
131441
131554
  this.waiting = null;
131442
- w2(msg);
131555
+ w2(item);
131443
131556
  } else {
131444
- this.pending.push(msg);
131557
+ this.pending.push(item);
131445
131558
  }
131446
131559
  }
131560
+ /** Route a say-tool message to the reply bound to the in-flight message, falling
131561
+ * back to opts.onSay. This is what closes the DM→room leak: the destination is
131562
+ * the one captured for the message being answered, not a live global target. */
131563
+ async deliver(text) {
131564
+ if (this.activeReply) await this.activeReply(text);
131565
+ else if (this.opts.onSay) await this.opts.onSay(text);
131566
+ }
131447
131567
  async *input() {
131448
131568
  while (true) {
131449
131569
  if (this.pending.length > 0) {
131450
- yield this.pending.shift();
131570
+ const item2 = this.pending.shift();
131571
+ this.activeReply = item2.reply;
131572
+ yield item2.msg;
131451
131573
  continue;
131452
131574
  }
131453
- yield await new Promise((resolve2) => {
131575
+ const item = await new Promise((resolve2) => {
131454
131576
  this.waiting = resolve2;
131455
131577
  });
131578
+ this.activeReply = item.reply;
131579
+ yield item.msg;
131456
131580
  }
131457
131581
  }
131458
131582
  async start() {
131459
131583
  const roomServer = _s({
131460
131584
  name: "room",
131461
131585
  version: "0.2.0",
131462
- tools: [makeRoomSayTool(this.opts.onSay)]
131586
+ tools: [makeRoomSayTool((text) => this.deliver(text))]
131463
131587
  });
131464
131588
  const sdkOptions = {
131465
131589
  model: this.opts.model,
@@ -131505,7 +131629,47 @@ var PersistentClaudeSession = class {
131505
131629
  };
131506
131630
 
131507
131631
  // src/bridge.ts
131508
- function attachLifecycle(channel, opts = {}) {
131632
+ var ActiveTarget = class {
131633
+ target = null;
131634
+ setRoom(roomId) {
131635
+ this.target = { kind: "room", roomId };
131636
+ }
131637
+ setDm() {
131638
+ this.target = { kind: "dm" };
131639
+ }
131640
+ get current() {
131641
+ return this.target;
131642
+ }
131643
+ async reply(channel, text) {
131644
+ const t7 = this.target;
131645
+ if (!t7) return;
131646
+ if (t7.kind === "room") await channel.sendToRoom(t7.roomId, text);
131647
+ else await channel.send(text);
131648
+ }
131649
+ /**
131650
+ * Capture the CURRENT target into an immutable reply closure. This is the leak
131651
+ * fix: a reply built here routes to where its inbound message came from, even if
131652
+ * a later inbound flips the live `current` target. The bridge captures one of
131653
+ * these per inbound (at push time) and hands it to the session, so a room message
131654
+ * arriving mid-compose can never redirect a private 1:1 reply into the room.
131655
+ */
131656
+ snapshotReply(channel, log = () => {
131657
+ }) {
131658
+ const t7 = this.target;
131659
+ const where = !t7 ? "nowhere" : t7.kind === "room" ? `room ${t7.roomId.slice(0, 8)}` : "1:1 owner";
131660
+ return async (text) => {
131661
+ if (!t7) return;
131662
+ try {
131663
+ if (t7.kind === "room") await channel.sendToRoom(t7.roomId, text);
131664
+ else await channel.send(text);
131665
+ log(`said to ${where}`);
131666
+ } catch (err) {
131667
+ log(`send failed to ${where}: ${err instanceof Error ? err.message : String(err)}`);
131668
+ }
131669
+ };
131670
+ }
131671
+ };
131672
+ function attachLifecycle2(channel, opts = {}) {
131509
131673
  const log = opts.log ?? (() => {
131510
131674
  });
131511
131675
  const onTerminal = opts.onTerminal ?? (() => process.exit(1));
@@ -131525,7 +131689,7 @@ function attachLifecycle(channel, opts = {}) {
131525
131689
  if (/reconnect loop detected/i.test(msg)) terminal("reconnect_loop");
131526
131690
  });
131527
131691
  }
131528
- function wireBridge(channel, session, opts = {}) {
131692
+ function wireBridge(channel, session, target, opts = {}) {
131529
131693
  const log = opts.log ?? (() => {
131530
131694
  });
131531
131695
  channel.on("error", (err) => {
@@ -131535,8 +131699,14 @@ function wireBridge(channel, session, opts = {}) {
131535
131699
  channel.on("room_message", (e7) => {
131536
131700
  if (opts.roomFilter && e7.roomId !== opts.roomFilter) return;
131537
131701
  log(`inbound from ${e7.senderName} in ${e7.roomId.slice(0, 8)}`);
131538
- session.onActiveRoom(e7.roomId);
131539
- session.push(`[${e7.senderName}]: ${e7.plaintext}`);
131702
+ target.setRoom(e7.roomId);
131703
+ session.push(`[${e7.senderName}]: ${e7.plaintext}`, target.snapshotReply(channel, log));
131704
+ });
131705
+ channel.on("message", (text, metadata) => {
131706
+ if (metadata?.roomId) return;
131707
+ log("inbound 1:1 DM from owner");
131708
+ target.setDm();
131709
+ session.push(text, target.snapshotReply(channel, log));
131540
131710
  });
131541
131711
  }
131542
131712
 
@@ -131549,7 +131719,9 @@ async function main() {
131549
131719
  "[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"
131550
131720
  );
131551
131721
  }
131552
- let activeRoomId = cfg.roomFilter ?? "";
131722
+ console.error(`[bridge] data dir: ${cfg.dataDir} (${cfg.dataDirSource})`);
131723
+ const target = new ActiveTarget();
131724
+ if (cfg.roomFilter) target.setRoom(cfg.roomFilter);
131553
131725
  const channel = new SecureChannel({
131554
131726
  inviteToken: cfg.inviteToken,
131555
131727
  dataDir: cfg.dataDir,
@@ -131559,28 +131731,22 @@ async function main() {
131559
131731
  });
131560
131732
  const session = new PersistentClaudeSession({
131561
131733
  model: cfg.model,
131562
- systemPrompt: cfg.systemPrompt ?? `You are ${cfg.agentName}, an AI agent collaborating with other agents in an AgentVault room. That is your identity in this room \u2014 introduce yourself by that name and do not claim to be any other agent. You see every room message. To speak, call the room_say tool \u2014 and only when you have something worth adding. If you have nothing to contribute, stay silent (do not call room_say). Keep messages concise.`,
131563
- // Claude speaks by calling room_sayposted to the active room.
131564
- onSay: async (text) => {
131565
- if (!activeRoomId) return;
131566
- try {
131567
- await channel.sendToRoom(activeRoomId, text);
131568
- console.error(`[bridge] said in ${activeRoomId.slice(0, 8)}`);
131569
- } catch (err) {
131570
- console.error(`[bridge] send failed to ${activeRoomId.slice(0, 8)}:`, err);
131571
- }
131572
- },
131734
+ systemPrompt: cfg.systemPrompt ?? `You are ${cfg.agentName}, an AI agent on AgentVault. You talk with your owner in 1:1 direct messages and collaborate with other agents in shared rooms. That is your identity \u2014 introduce yourself by that name and do not claim to be any other agent. To speak, call the say tool. In a 1:1 with your owner, reply to what they say. In a room you see every message \u2014 call say only when you have something worth adding, and otherwise stay silent. Keep messages concise.`,
131735
+ // Claude speaks by calling the say tool the session routes it to the reply
131736
+ // bound to the message being answered (wireBridge captures that per inbound via
131737
+ // ActiveTarget.snapshotReply, which also logs the "said to …" line). This
131738
+ // per-message binding is what prevents a private 1:1 reply from leaking into a
131739
+ // room when room traffic arrives mid-compose.
131573
131740
  // Assistant reasoning that wasn't sent — log a short trace only.
131574
131741
  onObserve: (text) => console.error(`[bridge] observed (${text.length} chars, not sent)`)
131575
131742
  });
131576
131743
  wireBridge(
131577
131744
  channel,
131578
- { push: (t7) => session.push(t7), onActiveRoom: (r7) => {
131579
- activeRoomId = r7;
131580
- } },
131745
+ { push: (t7, reply) => session.push(t7, reply) },
131746
+ target,
131581
131747
  { roomFilter: cfg.roomFilter, log: (m6) => console.error("[bridge] " + m6) }
131582
131748
  );
131583
- attachLifecycle(channel, {
131749
+ attachLifecycle2(channel, {
131584
131750
  log: (m6) => console.error("[bridge] " + m6)
131585
131751
  });
131586
131752
  channel.on("state", (s10) => console.error(`[bridge] channel state: ${JSON.stringify(s10)}`));
package/dist/session.d.ts CHANGED
@@ -45,9 +45,11 @@ export type QueryFn = (args: {
45
45
  type: string;
46
46
  [k: string]: unknown;
47
47
  }>;
48
+ type ReplySink = (text: string) => void | Promise<void>;
48
49
  export interface SessionOpts {
49
- /** Called when Claude chooses to speak (via the room_say tool). */
50
- onSay: (text: string) => void | Promise<void>;
50
+ /** Fallback reply sink when a message carries no per-message reply (e.g. a
51
+ * proactive say). Per-message replies passed to push() take precedence. */
52
+ onSay?: ReplySink;
51
53
  /** Called with assistant text that is NOT sent to the room (for logging). */
52
54
  onObserve?: (text: string) => void;
53
55
  model?: string;
@@ -58,9 +60,17 @@ export declare class PersistentClaudeSession {
58
60
  private opts;
59
61
  private waiting;
60
62
  private pending;
63
+ /** Reply sink bound to the message currently being processed. Set as each
64
+ * message is handed to the model so the say tool routes to the right place. */
65
+ private activeReply?;
61
66
  constructor(opts: SessionOpts);
62
- push(text: string): void;
67
+ push(text: string, reply?: ReplySink): void;
68
+ /** Route a say-tool message to the reply bound to the in-flight message, falling
69
+ * back to opts.onSay. This is what closes the DM→room leak: the destination is
70
+ * the one captured for the message being answered, not a live global target. */
71
+ deliver(text: string): Promise<void>;
63
72
  private input;
64
73
  start(): Promise<void>;
65
74
  }
75
+ export {};
66
76
  //# sourceMappingURL=session.d.ts.map
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@agentvault/claude-bridge",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
- "description": "AgentVault Claude Room Bridge — daemon for bridging AI agents into secure E2E-encrypted AgentVault rooms.",
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",
7
7
  "types": "dist/index.d.ts",
8
8
  "bin": {