@agentvault/claude-bridge 0.5.7 → 0.5.8

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
@@ -132437,6 +132437,8 @@ var PersistentClaudeSession = class {
132437
132437
  turnText = "";
132438
132438
  /** In-process room MCP server — hoisted so buildSdkOptions can reference it. */
132439
132439
  roomServer;
132440
+ /** AbortController for the in-flight query(); abort() triggers it (queue timeout). */
132441
+ _abort;
132440
132442
  /**
132441
132443
  * Queue an inbound message for the model.
132442
132444
  * @param opts.autoReplyOnText — for 1:1 DMs (where the owner always expects a
@@ -132473,11 +132475,22 @@ var PersistentClaudeSession = class {
132473
132475
  if (this.activeReply) await this.activeReply(text);
132474
132476
  else if (this.opts.onSay) await this.opts.onSay(text);
132475
132477
  }
132478
+ /** Abort the in-flight query() — used by the worker queue on a per-task timeout. */
132479
+ abort() {
132480
+ this._abort?.abort();
132481
+ }
132476
132482
  async *input() {
132477
132483
  while (true) {
132478
- const item = this.pending.length > 0 ? this.pending.shift() : await new Promise((resolve3) => {
132479
- this.waiting = resolve3;
132480
- });
132484
+ let item;
132485
+ if (this.pending.length > 0) {
132486
+ item = this.pending.shift();
132487
+ } else if (this.opts.ephemeral) {
132488
+ return;
132489
+ } else {
132490
+ item = await new Promise((resolve3) => {
132491
+ this.waiting = resolve3;
132492
+ });
132493
+ }
132481
132494
  this.activeReply = item.reply;
132482
132495
  this.currentAutoReply = item.autoReplyOnText ?? false;
132483
132496
  this.currentArmedGetter = item.armed ?? (() => false);
@@ -132538,8 +132551,28 @@ var PersistentClaudeSession = class {
132538
132551
  return {
132539
132552
  ...base,
132540
132553
  permissionMode: this.opts.permissionMode ?? "auto",
132541
- settingSources: ["user", "project"],
132554
+ // FACET B (disk isolation). The workspace is writable under the Facet-A
132555
+ // allowlist, so a prior worker/armed turn could plant config files in it that
132556
+ // a later worker would otherwise ingest. We close each disk surface explicitly
132557
+ // (they are governed SEPARATELY by the SDK, so one flag is not enough):
132558
+ // - settingSources:[] — "disable filesystem settings (SDK isolation mode)":
132559
+ // no ~/.claude or project settings.json (⇒ no settings-defined hooks, the
132560
+ // RCE-grade vector: a hook is a shell command run OUTSIDE the Bash gate),
132561
+ // no CLAUDE.md. It also means no .mcp.json APPROVAL state is loaded — and
132562
+ // enableAllProjectMcpServers/enabledMcpjsonServers live only in Settings —
132563
+ // so an unapproved project .mcp.json server is never spawned in headless
132564
+ // mode. Any MCP tool that WERE discovered is still denied by the Facet-A
132565
+ // allowlist (mcp__* is not room_say/a file tool/Bash → final deny).
132566
+ // - skills:[] — settingSources does NOT gate skills discovery; [] enables
132567
+ // zero skills, so a planted <ws>/.claude/skills/*/SKILL.md description
132568
+ // cannot be injected into the worker's context.
132569
+ // Project context, if needed, is injected via the bridge-controlled systemPrompt,
132570
+ // never auto-loaded from the mutable workspace.
132571
+ settingSources: [],
132572
+ skills: [],
132542
132573
  cwd: this.opts.workspaceDir,
132574
+ ...this.opts.maxTurns != null ? { maxTurns: this.opts.maxTurns } : {},
132575
+ abortController: this._abort,
132543
132576
  // PRIMARY gate: a PreToolUse hook fires on EVERY tool call regardless of
132544
132577
  // permissionMode. canUseTool alone is bypassed in "auto" mode (the SDK's
132545
132578
  // classifier auto-approves without hitting the "ask" path) — verified live.
@@ -132549,6 +132582,7 @@ var PersistentClaudeSession = class {
132549
132582
  };
132550
132583
  }
132551
132584
  async start() {
132585
+ this._abort = new AbortController();
132552
132586
  this.roomServer = _s({
132553
132587
  name: "room",
132554
132588
  version: "0.2.0",
@@ -132580,6 +132614,96 @@ var PersistentClaudeSession = class {
132580
132614
  }
132581
132615
  };
132582
132616
 
132617
+ // src/worker-queue.ts
132618
+ var GENERIC_ERROR = "Sorry \u2014 I couldn't complete that request.";
132619
+ var WorkerQueue = class {
132620
+ constructor(deps) {
132621
+ this.deps = deps;
132622
+ }
132623
+ deps;
132624
+ q = [];
132625
+ loop = Promise.resolve();
132626
+ running = false;
132627
+ enqueue(task) {
132628
+ this.q.push(task);
132629
+ if (!this.running) {
132630
+ this.running = true;
132631
+ this.loop = this.drain();
132632
+ }
132633
+ }
132634
+ /** Resolves when the queue has processed everything enqueued so far. */
132635
+ whenDrained() {
132636
+ return this.loop;
132637
+ }
132638
+ async drain() {
132639
+ try {
132640
+ while (this.q.length > 0) {
132641
+ const task = this.q.shift();
132642
+ await this.runOne(task);
132643
+ }
132644
+ } finally {
132645
+ this.running = false;
132646
+ }
132647
+ }
132648
+ async runOne(task) {
132649
+ let session;
132650
+ let timer;
132651
+ try {
132652
+ session = this.deps.makeSession(task);
132653
+ session.push(task.instruction, task.reply, {
132654
+ autoReplyOnText: task.autoReplyOnText,
132655
+ armed: task.armed
132656
+ });
132657
+ const currentSession = session;
132658
+ const timeout = new Promise((_resolve, reject) => {
132659
+ timer = setTimeout(() => {
132660
+ currentSession.abort();
132661
+ reject(new Error("worker task timeout"));
132662
+ }, this.deps.timeoutMs);
132663
+ });
132664
+ await Promise.race([session.start(), timeout]);
132665
+ } catch (e7) {
132666
+ this.deps.log(`[worker-queue] task failed: ${e7.message}`);
132667
+ session?.abort();
132668
+ try {
132669
+ await task.reply(GENERIC_ERROR);
132670
+ } catch (replyErr) {
132671
+ this.deps.log(`[worker-queue] failed to deliver error reply: ${replyErr.message}`);
132672
+ }
132673
+ } finally {
132674
+ if (timer) clearTimeout(timer);
132675
+ }
132676
+ }
132677
+ };
132678
+
132679
+ // src/router.ts
132680
+ function makeRouter(deps) {
132681
+ return {
132682
+ push(text, reply, opts) {
132683
+ const replySink = reply ?? (() => {
132684
+ });
132685
+ if (deps.worker) {
132686
+ const isOwnerDm = opts?.autoReplyOnText === true;
132687
+ const isArmedRoom = opts?.armed?.() === true;
132688
+ if (isOwnerDm) {
132689
+ deps.queue.enqueue({ instruction: text, reply: replySink, autoReplyOnText: true });
132690
+ return;
132691
+ }
132692
+ if (isArmedRoom) {
132693
+ deps.queue.enqueue({
132694
+ instruction: text,
132695
+ reply: replySink,
132696
+ autoReplyOnText: false,
132697
+ armed: opts.armed
132698
+ });
132699
+ return;
132700
+ }
132701
+ }
132702
+ deps.listener.push(text, reply, opts);
132703
+ }
132704
+ };
132705
+ }
132706
+
132583
132707
  // src/arming.ts
132584
132708
  var ArmingState = class {
132585
132709
  armed = /* @__PURE__ */ new Set();
@@ -132943,7 +133067,7 @@ async function main() {
132943
133067
  "[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"
132944
133068
  );
132945
133069
  }
132946
- console.error(`[bridge] version: ${true ? "0.5.7" : "dev"}`);
133070
+ console.error(`[bridge] version: ${true ? "0.5.8" : "dev"}`);
132947
133071
  console.error(`[bridge] data dir: ${cfg.dataDir} (${cfg.dataDirSource})`);
132948
133072
  if (cfg.worker) {
132949
133073
  console.error(`[bridge] WORKER MODE \u2014 workspace: ${cfg.workspaceDir} \xB7 permissions: ${cfg.permissionMode} \xB7 enclave dir fenced`);
@@ -132969,9 +133093,14 @@ async function main() {
132969
133093
  agentName: cfg.agentName,
132970
133094
  platform: "node"
132971
133095
  });
132972
- const session = new PersistentClaudeSession({
133096
+ const agentSystemPrompt = 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.`;
133097
+ const deviceJwt = () => {
133098
+ const c4 = channel;
133099
+ return c4._deviceJwt ?? c4._persisted?.deviceJwt ?? null;
133100
+ };
133101
+ const listener = new PersistentClaudeSession({
132973
133102
  model: cfg.model,
132974
- 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.`,
133103
+ systemPrompt: agentSystemPrompt,
132975
133104
  // Claude speaks by calling the say tool → the session routes it to the reply
132976
133105
  // bound to the message being answered (wireBridge captures that per inbound via
132977
133106
  // ActiveTarget.snapshotReply, which also logs the "said to …" line). This
@@ -132979,23 +133108,35 @@ async function main() {
132979
133108
  // room when room traffic arrives mid-compose.
132980
133109
  // Assistant reasoning that wasn't sent — log a short trace only.
132981
133110
  onObserve: (text) => console.error(`[bridge] observed (${text.length} chars, not sent)`),
132982
- worker: cfg.worker,
132983
- workspaceDir: cfg.workspaceDir,
132984
- osIsolated: cfg.osIsolated,
132985
- permissionMode: cfg.permissionMode,
132986
- dataDir: cfg.dataDir,
132987
- // Slice 2 Plan B: audit self-report context (only used on armed-room turns).
132988
- roomId: cfg.roomFilter,
132989
- agentId: channel.deviceId,
132990
- apiUrl: cfg.apiUrl,
132991
- deviceJwt: () => {
132992
- const c4 = channel;
132993
- return c4._deviceJwt ?? c4._persisted?.deviceJwt ?? null;
132994
- }
133111
+ worker: false
132995
133112
  });
133113
+ const WORKER_MAX_TURNS = 20;
133114
+ const WORKER_TIMEOUT_MS = 5 * 6e4;
133115
+ const workerQueue = new WorkerQueue({
133116
+ makeSession: (task) => new PersistentClaudeSession({
133117
+ model: cfg.model,
133118
+ systemPrompt: agentSystemPrompt,
133119
+ onObserve: (text) => console.error(`[worker] observed (${text.length} chars, not sent)`),
133120
+ worker: true,
133121
+ ephemeral: true,
133122
+ maxTurns: WORKER_MAX_TURNS,
133123
+ workspaceDir: cfg.workspaceDir,
133124
+ osIsolated: cfg.osIsolated,
133125
+ permissionMode: cfg.permissionMode,
133126
+ dataDir: cfg.dataDir,
133127
+ // Slice 2 Plan B audit self-report — only fires on armed-room tasks (isArmedTurn).
133128
+ roomId: cfg.roomFilter,
133129
+ agentId: channel.deviceId,
133130
+ apiUrl: cfg.apiUrl,
133131
+ deviceJwt
133132
+ }),
133133
+ timeoutMs: WORKER_TIMEOUT_MS,
133134
+ log: (m6) => console.error(m6)
133135
+ });
133136
+ const router = makeRouter({ worker: !!cfg.worker, listener, queue: workerQueue });
132996
133137
  wireBridge(
132997
133138
  channel,
132998
- { push: (t7, reply, opts) => session.push(t7, reply, opts) },
133139
+ { push: (t7, reply, opts) => router.push(t7, reply, opts) },
132999
133140
  target,
133000
133141
  {
133001
133142
  roomFilter: cfg.roomFilter,
@@ -133015,7 +133156,11 @@ async function main() {
133015
133156
  "room_joined",
133016
133157
  (e7) => console.error(`[bridge] joined room ${e7.name} (${e7.roomId})`)
133017
133158
  );
133018
- await Promise.all([channel.start(), session.start()]);
133159
+ listener.start().catch((err) => {
133160
+ console.error("[bridge] fatal:", err);
133161
+ process.exit(1);
133162
+ });
133163
+ await channel.start();
133019
133164
  }
133020
133165
  main().catch((err) => {
133021
133166
  console.error("[bridge] fatal:", err);
@@ -0,0 +1,30 @@
1
+ import type { ReplySink } from "./session.js";
2
+ import type { WorkerTask } from "./worker-queue.js";
3
+ /** The push interface wireBridge drives (matches PersistentClaudeSession.push). */
4
+ export interface PushLike {
5
+ push(text: string, reply?: ReplySink, opts?: {
6
+ autoReplyOnText?: boolean;
7
+ armed?: () => boolean;
8
+ }): void;
9
+ }
10
+ export interface RouterDeps {
11
+ /** AV_WORKER=1 → tool-eligible turns run in isolated workers; false → no worker ever. */
12
+ worker: boolean;
13
+ /** Always-on locked listener (tools:[]) for untrusted/non-tool turns. */
14
+ listener: PushLike;
15
+ /** Serial queue of tool-eligible turns. */
16
+ queue: {
17
+ enqueue(task: WorkerTask): void;
18
+ };
19
+ }
20
+ /**
21
+ * Route each inbound turn to EXACTLY ONE lane (facet B). Tool-eligible turns —
22
+ * owner DMs (autoReplyOnText) and armed room turns — go to an isolated ephemeral
23
+ * worker via the queue; everything else stays on the locked listener. A room
24
+ * turn's `armed` getter is read once here at routing time to pick the lane; the
25
+ * same live getter is threaded to the worker task so a mid-turn disarm still
26
+ * denies the next tool call (Facet A D5). Locked agents (worker=false) always
27
+ * route to the listener — a strict no-op vs today.
28
+ */
29
+ export declare function makeRouter(deps: RouterDeps): PushLike;
30
+ //# sourceMappingURL=router.d.ts.map
package/dist/session.d.ts CHANGED
@@ -45,7 +45,7 @@ export type QueryFn = (args: {
45
45
  type: string;
46
46
  [k: string]: unknown;
47
47
  }>;
48
- type ReplySink = (text: string) => void | Promise<void>;
48
+ export type ReplySink = (text: string) => void | Promise<void>;
49
49
  export interface SessionOpts {
50
50
  /** Fallback reply sink when a message carries no per-message reply (e.g. a
51
51
  * proactive say). Per-message replies passed to push() take precedence. */
@@ -73,6 +73,12 @@ export interface SessionOpts {
73
73
  apiUrl?: string;
74
74
  /** Lazy getter so a JWT refreshed after construction is still picked up. */
75
75
  deviceJwt?: () => string | null;
76
+ /** Single-shot ephemeral worker: process exactly the pushed message(s) then
77
+ * terminate the input stream so start() resolves. Used by the worker queue so
78
+ * a per-task query() completes and its state is discarded. */
79
+ ephemeral?: boolean;
80
+ /** Turn cap for a worker query() (bounds a runaway tool loop). */
81
+ maxTurns?: number;
76
82
  }
77
83
  export declare class PersistentClaudeSession {
78
84
  private opts;
@@ -105,6 +111,8 @@ export declare class PersistentClaudeSession {
105
111
  private turnText;
106
112
  /** In-process room MCP server — hoisted so buildSdkOptions can reference it. */
107
113
  private roomServer;
114
+ /** AbortController for the in-flight query(); abort() triggers it (queue timeout). */
115
+ private _abort?;
108
116
  constructor(opts: SessionOpts);
109
117
  /**
110
118
  * Queue an inbound message for the model.
@@ -126,6 +134,8 @@ export declare class PersistentClaudeSession {
126
134
  * back to opts.onSay. This is what closes the DM→room leak: the destination is
127
135
  * the one captured for the message being answered, not a live global target. */
128
136
  deliver(text: string): Promise<void>;
137
+ /** Abort the in-flight query() — used by the worker queue on a per-task timeout. */
138
+ abort(): void;
129
139
  private input;
130
140
  /**
131
141
  * Build the SDK options for this session. Locked mode (default) is safe for
@@ -136,5 +146,4 @@ export declare class PersistentClaudeSession {
136
146
  private buildSdkOptions;
137
147
  start(): Promise<void>;
138
148
  }
139
- export {};
140
149
  //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1,41 @@
1
+ import type { PersistentClaudeSession, ReplySink } from "./session.js";
2
+ /** One tool-eligible turn to run in an isolated ephemeral worker. */
3
+ export type WorkerTask = {
4
+ /** The single instruction that seeds the worker's conversation. */
5
+ instruction: string;
6
+ /** Where this task's reply (room_say / #416 fallback) is sent. Per-task. */
7
+ reply: ReplySink;
8
+ /** Owner-DM turns want a plain-text reply fallback (#416). */
9
+ autoReplyOnText: boolean;
10
+ /** Armed-room turns: live getter the gate reads per tool decision (mid-turn
11
+ * disarm denies the next call). Absent for owner DMs. */
12
+ armed?: () => boolean;
13
+ };
14
+ export interface WorkerQueueDeps {
15
+ /** Build a fresh ephemeral worker session for this task (not yet started). */
16
+ makeSession: (task: WorkerTask) => PersistentClaudeSession;
17
+ /** Per-task wall-clock cap; on breach the worker is aborted and the queue advances. */
18
+ timeoutMs: number;
19
+ log: (m: string) => void;
20
+ }
21
+ /**
22
+ * Serial FIFO of tool-eligible turns. One worker runs at a time (concurrent tool
23
+ * calls against one device/workspace can interleave destructively; the owner is a
24
+ * single actor). Each task runs in a fresh ephemeral worker seeded with only its
25
+ * instruction; on error or timeout the worker is torn down, a generic message is
26
+ * sent to the task's reply, and the queue advances — a task can never wedge the
27
+ * queue (which would starve the owner's DM lane).
28
+ */
29
+ export declare class WorkerQueue {
30
+ private deps;
31
+ private q;
32
+ private loop;
33
+ private running;
34
+ constructor(deps: WorkerQueueDeps);
35
+ enqueue(task: WorkerTask): void;
36
+ /** Resolves when the queue has processed everything enqueued so far. */
37
+ whenDrained(): Promise<void>;
38
+ private drain;
39
+ private runOne;
40
+ }
41
+ //# 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.5.7",
3
+ "version": "0.5.8",
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",