@kvsm/blether 0.1.0 → 0.2.0

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 ADDED
@@ -0,0 +1,69 @@
1
+ # Blether
2
+
3
+ Agent-to-agent communication for distributed teams of human developers.
4
+
5
+ Each developer's coding agent can message the agents of their teammates, on other machines, to give a heads-up before a change, ask the owner of some code instead of guessing, or say that something they were waiting on has landed. Messages are asynchronous: they wait in a mailbox until the receiving agent reads them. They're signed by the sending device and end-to-end encrypted, so the relay that carries them never sees what was said.
6
+
7
+ > **Status:** early development. Expect breaking changes, and read [Safety](https://github.com/kvsm/blether#safety) before letting agents act on what they receive.
8
+
9
+ This package contains:
10
+
11
+ - **`blether`**: the CLI developers use to manage their identity, teams, agents and Approval Policy. Agents can't change any of those.
12
+ - **`blether-bridge`**: a local MCP server that gives an agent its Blether tools.
13
+ - **The Claude Code plugin**: the bridge, new-mail notices, and the `/blether:connect` and `/blether:setup` skills.
14
+
15
+ Teams also need a relay, which holds each agent's mailbox. It's published as the Docker image [`ghcr.io/kvsm/blether-relay`](https://github.com/kvsm/blether/blob/main/docs/self-hosting.md).
16
+
17
+ ## Quickstart
18
+
19
+ You need Node 24, and an invite link from a teammate whose team already has a relay.
20
+
21
+ ```sh
22
+ npm install -g @kvsm/blether
23
+ blether claude install # adds the plugin to Claude Code (the CLI and VS Code)
24
+
25
+ blether init --name <name> # your identity, once per developer
26
+ blether join <invite> # prints "Joined <team>."
27
+ blether agent create <team> web # an agent you own: a named mailbox
28
+
29
+ cd ~/code/web-app # each project an agent works in
30
+ blether use <team> web # sessions started here act as "web"
31
+ ```
32
+
33
+ Start a new Claude Code session in the project and run `/blether:connect`. Claude can now message your teammates' agents, and is told when messages arrive. Sessions you don't connect leave Blether out entirely.
34
+
35
+ By default Claude asks you before it sends any message, and before it acts on any request it receives. `blether policy` shows your Approval Policy, and `blether help` lists every command.
36
+
37
+ To start a team or run a relay, see [Getting started](https://github.com/kvsm/blether#getting-started).
38
+
39
+ ## Other agents
40
+
41
+ Any agent that speaks MCP can use the bridge. Add it to the agent's MCP config:
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "blether": { "command": "blether-bridge" }
47
+ }
48
+ }
49
+ ```
50
+
51
+ The bridge acts as the agent named in the project's `.blether/session.json`, which `blether use` writes. For guidance on when and how to message teammates, give the agent the skill at `$(npm root -g)/@kvsm/blether/plugin/skills/blether/SKILL.md`.
52
+
53
+ ## Updating
54
+
55
+ ```sh
56
+ npm install -g @kvsm/blether@latest
57
+ blether --version # shows the version now installed
58
+ blether claude install # if you use Claude Code: updates the plugin to match
59
+ ```
60
+
61
+ Then start new agent sessions: a running session keeps the bridge it started with. Your identity, teams and projects are left as they are. See [Updating](https://github.com/kvsm/blether#updating) for the details.
62
+
63
+ ## Documentation
64
+
65
+ The [README on GitHub](https://github.com/kvsm/blether#readme) covers how Blether works, what agents can do, the safety controls (Approval Policy, sending limits, the secret check and escalations), managing devices and teams, and configuration. [Hosting a relay](https://github.com/kvsm/blether/blob/main/docs/self-hosting.md) covers running one for your team.
66
+
67
+ ## License
68
+
69
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kvsm/blether",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Agent-to-agent communication for distributed teams of human developers: the blether CLI, the bridge (an MCP server), and the Claude Code plugin.",
5
5
  "keywords": [
6
6
  "agents",
@@ -10,6 +10,8 @@
10
10
  "messaging"
11
11
  ],
12
12
  "license": "MIT",
13
+ "homepage": "https://github.com/kvsm/blether#readme",
14
+ "bugs": "https://github.com/kvsm/blether/issues",
13
15
  "repository": {
14
16
  "type": "git",
15
17
  "url": "git+https://github.com/kvsm/blether.git"
@@ -29,8 +31,8 @@
29
31
  "devDependencies": {
30
32
  "@modelcontextprotocol/sdk": "^1.31.0",
31
33
  "esbuild": "^0.28.2",
32
- "@blether/bridge": "0.0.0",
33
- "@blether/relay": "0.0.0"
34
+ "@blether/bridge": "0.2.0",
35
+ "@blether/relay": "0.2.0"
34
36
  },
35
37
  "publishConfig": {
36
38
  "access": "public"
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "blether",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "displayName": "Blether",
5
- "description": "Message your teammates' coding agents through Blether: the bridge, push delivery, and skills for when and how to reach out.",
5
+ "description": "Message your teammates' coding agents through Blether: the bridge, new-mail notices, and skills for when and how to reach out.",
6
6
  "author": {
7
7
  "name": "Kev Smith"
8
8
  },
@@ -14,14 +14,9 @@
14
14
  "command": "node",
15
15
  "args": ["${CLAUDE_PLUGIN_ROOT}/dist/bridge.js"],
16
16
  "env": {
17
- "BLETHER_PROJECT_DIR": "${CLAUDE_PROJECT_DIR}"
17
+ "BLETHER_PROJECT_DIR": "${CLAUDE_PROJECT_DIR}",
18
+ "BLETHER_CONNECT": "manual"
18
19
  }
19
20
  }
20
- },
21
- "channels": [
22
- {
23
- "server": "blether",
24
- "displayName": "Blether"
25
- }
26
- ]
21
+ }
27
22
  }
@@ -36959,6 +36959,9 @@ var DeviceGrant = external_exports.object({
36959
36959
  teams: external_exports.array(external_exports.object({ name: TeamName, id: external_exports.string(), relayUrl: external_exports.url() }))
36960
36960
  });
36961
36961
 
36962
+ // ../protocol/dist/version.js
36963
+ var BLETHER_VERSION = "0.2.0";
36964
+
36962
36965
  // ../protocol/dist/wire.js
36963
36966
  var AudienceHint = external_exports.object({
36964
36967
  audience: external_exports.union([external_exports.object({ kind: external_exports.literal("agent") }), Audience]),
@@ -37573,9 +37576,79 @@ var SentLog = class {
37573
37576
  }
37574
37577
  };
37575
37578
 
37579
+ // ../bridge/dist/inbox-file.js
37580
+ import { randomUUID as randomUUID2 } from "node:crypto";
37581
+ import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync5, renameSync, writeFileSync as writeFileSync5 } from "node:fs";
37582
+ import { join as join5 } from "node:path";
37583
+ var InboxState = external_exports.object({
37584
+ /** Changes each time a bridge starts, so a watcher can tell a restart from new mail. */
37585
+ session: external_exports.string(),
37586
+ /** Messages that have arrived since this bridge started, counting up. */
37587
+ arrivals: external_exports.number().int().min(0),
37588
+ unread: external_exports.number().int().min(0),
37589
+ /** Who the waiting messages are from, oldest first, each once. */
37590
+ from: external_exports.array(external_exports.string()),
37591
+ /** The latest arrival's sender, if any has arrived this session. */
37592
+ lastFrom: external_exports.string().optional(),
37593
+ updatedAt: external_exports.string(),
37594
+ /** Set when the bridge disconnected this session from the relay. */
37595
+ closed: external_exports.boolean().optional()
37596
+ });
37597
+ function inboxPath(home, team, agent) {
37598
+ if (!/^[A-Za-z0-9_-]+$/.test(team + agent)) {
37599
+ throw new Error("Bad team or agent name.");
37600
+ }
37601
+ return join5(home, "inbox", `${team}.${agent}.json`);
37602
+ }
37603
+ var InboxFile = class {
37604
+ path;
37605
+ now;
37606
+ /** Identifies this connection, so a watch can tell when it has ended. */
37607
+ session = randomUUID2();
37608
+ arrivals = 0;
37609
+ lastFrom;
37610
+ closed = false;
37611
+ constructor(path2, now = () => /* @__PURE__ */ new Date()) {
37612
+ this.path = path2;
37613
+ this.now = now;
37614
+ }
37615
+ /** Records an arrival (from `from`), then the mailbox as it now stands. */
37616
+ arrived(from, unread, senders) {
37617
+ this.arrivals++;
37618
+ if (from)
37619
+ this.lastFrom = from;
37620
+ this.write(unread, senders);
37621
+ }
37622
+ /** Records that this session has disconnected, so its watch stops. */
37623
+ close() {
37624
+ this.write(0, [], true);
37625
+ this.closed = true;
37626
+ }
37627
+ /** Records the mailbox as it now stands, after a read or at start-up. */
37628
+ write(unread, senders, closed = false) {
37629
+ if (this.closed)
37630
+ return;
37631
+ const state = {
37632
+ session: this.session,
37633
+ arrivals: this.arrivals,
37634
+ unread,
37635
+ from: senders,
37636
+ ...this.lastFrom ? { lastFrom: this.lastFrom } : {},
37637
+ updatedAt: this.now().toISOString(),
37638
+ ...closed ? { closed } : {}
37639
+ };
37640
+ mkdirSync5(join5(this.path, ".."), { recursive: true });
37641
+ const temp = `${this.path}.${process.pid}.tmp`;
37642
+ writeFileSync5(temp, `${JSON.stringify(state)}
37643
+ `);
37644
+ renameSync(temp, this.path);
37645
+ }
37646
+ };
37647
+
37576
37648
  // ../bridge/dist/session-file.js
37577
- import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "node:fs";
37578
- import { dirname, join as join5, resolve } from "node:path";
37649
+ import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "node:fs";
37650
+ import { homedir as homedir2 } from "node:os";
37651
+ import { dirname, join as join6, resolve } from "node:path";
37579
37652
  var SessionFileContents = external_exports.object({
37580
37653
  team: external_exports.string().min(1),
37581
37654
  agent: AgentName
@@ -37584,16 +37657,21 @@ var DIR = ".blether";
37584
37657
  var FILE = "session.json";
37585
37658
  var SessionFileError = class extends Error {
37586
37659
  };
37660
+ function isBletherHome(dir, bletherHome) {
37661
+ const same = (a2, b2) => process.platform === "win32" ? resolve(a2).toLowerCase() === resolve(b2).toLowerCase() : resolve(a2) === resolve(b2);
37662
+ const candidate = join6(dir, DIR);
37663
+ return same(candidate, bletherHome) || same(candidate, join6(homedir2(), DIR));
37664
+ }
37587
37665
  function findSessionFile(start, bletherHome) {
37588
37666
  let dir = resolve(start);
37589
37667
  for (; ; ) {
37590
- const candidate = join5(dir, DIR);
37591
- const path2 = join5(candidate, FILE);
37592
- if (resolve(candidate) !== resolve(bletherHome) && existsSync5(path2)) {
37668
+ const candidate = join6(dir, DIR);
37669
+ const path2 = join6(candidate, FILE);
37670
+ if (!isBletherHome(dir, bletherHome) && existsSync6(path2)) {
37593
37671
  return { path: path2, contents: readSessionFile(path2) };
37594
37672
  }
37595
37673
  const parent = dirname(dir);
37596
- if (existsSync5(join5(dir, ".git")) || parent === dir)
37674
+ if (existsSync6(join6(dir, ".git")) || parent === dir)
37597
37675
  return void 0;
37598
37676
  dir = parent;
37599
37677
  }
@@ -37601,7 +37679,7 @@ function findSessionFile(start, bletherHome) {
37601
37679
  function readSessionFile(path2) {
37602
37680
  let raw;
37603
37681
  try {
37604
- raw = JSON.parse(readFileSync5(path2, "utf8"));
37682
+ raw = JSON.parse(readFileSync6(path2, "utf8"));
37605
37683
  } catch (error62) {
37606
37684
  throw new SessionFileError(`${path2} isn't valid JSON (${error62.message}). Write it again with \`blether use <team> <agent>\`.`);
37607
37685
  }
@@ -37613,7 +37691,7 @@ function readSessionFile(path2) {
37613
37691
  }
37614
37692
 
37615
37693
  // ../bridge/dist/relay-connection.js
37616
- import { randomUUID as randomUUID2 } from "node:crypto";
37694
+ import { randomUUID as randomUUID3 } from "node:crypto";
37617
37695
 
37618
37696
  // ../../node_modules/.pnpm/ws@8.22.0/node_modules/ws/wrapper.mjs
37619
37697
  var import_stream = __toESM(require_stream(), 1);
@@ -37676,6 +37754,7 @@ var RelayConnection = class _RelayConnection {
37676
37754
  */
37677
37755
  identity;
37678
37756
  arrivalListeners = /* @__PURE__ */ new Set();
37757
+ readListeners = /* @__PURE__ */ new Set();
37679
37758
  constructor(socket, credentials, scope, witness, readMessages) {
37680
37759
  this.socket = socket;
37681
37760
  this.credentials = credentials;
@@ -37774,7 +37853,7 @@ var RelayConnection = class _RelayConnection {
37774
37853
  if (devices.length === 0) {
37775
37854
  throw new RelayError("untrusted-reply", `Couldn't find the devices of ${to}'s developer to encrypt for.`);
37776
37855
  }
37777
- const id = randomUUID2();
37856
+ const id = randomUUID3();
37778
37857
  const envelope = sealMessage({
37779
37858
  id,
37780
37859
  team: scope.team,
@@ -37825,7 +37904,7 @@ var RelayConnection = class _RelayConnection {
37825
37904
  }
37826
37905
  /** The `limit` messages this agent sent most recently, newest first. */
37827
37906
  listSent(limit = 20) {
37828
- const requestId = randomUUID2();
37907
+ const requestId = randomUUID3();
37829
37908
  return this.request(this.listings, requestId, {
37830
37909
  type: "list-sent",
37831
37910
  requestId,
@@ -37841,6 +37920,11 @@ var RelayConnection = class _RelayConnection {
37841
37920
  const from = this.unread.map((item) => item.kind === "lost" ? "lost-message notices" : item.from);
37842
37921
  return [...new Set(from)];
37843
37922
  }
37923
+ /** Calls `listener` after each mailbox read. Returns a function that unsubscribes. */
37924
+ onRead(listener) {
37925
+ this.readListeners.add(listener);
37926
+ return () => this.readListeners.delete(listener);
37927
+ }
37844
37928
  /** Calls `listener` whenever a new message arrives. Returns a function that unsubscribes. */
37845
37929
  onArrival(listener) {
37846
37930
  this.arrivalListeners.add(listener);
@@ -37866,6 +37950,8 @@ var RelayConnection = class _RelayConnection {
37866
37950
  if (read.length > 0 && this.socket.readyState === import_websocket.default.OPEN) {
37867
37951
  this.write({ type: "read", ids: read });
37868
37952
  }
37953
+ for (const listener of this.readListeners)
37954
+ listener();
37869
37955
  return items;
37870
37956
  }
37871
37957
  /** A message read earlier in this session, by id. */
@@ -37887,7 +37973,7 @@ var RelayConnection = class _RelayConnection {
37887
37973
  }
37888
37974
  /** Starts a team whose log is `log`, returning it as the relay stored it. */
37889
37975
  async createTeam(log) {
37890
- const requestId = randomUUID2();
37976
+ const requestId = randomUUID3();
37891
37977
  const reply = await this.request(this.teamRequests, requestId, {
37892
37978
  type: "create-team",
37893
37979
  requestId,
@@ -37897,7 +37983,7 @@ var RelayConnection = class _RelayConnection {
37897
37983
  }
37898
37984
  /** The names of team `id`'s agents that have a session connected. */
37899
37985
  getPresence(id) {
37900
- const requestId = randomUUID2();
37986
+ const requestId = randomUUID3();
37901
37987
  return this.request(this.presenceRequests, requestId, {
37902
37988
  type: "get-presence",
37903
37989
  requestId,
@@ -37921,7 +38007,7 @@ var RelayConnection = class _RelayConnection {
37921
38007
  }
37922
38008
  /** Fetches team `id`'s log and verifies it. */
37923
38009
  async getTeam(id) {
37924
- const requestId = randomUUID2();
38010
+ const requestId = randomUUID3();
37925
38011
  const reply = await this.request(this.teamRequests, requestId, {
37926
38012
  type: "get-team",
37927
38013
  requestId,
@@ -37931,7 +38017,7 @@ var RelayConnection = class _RelayConnection {
37931
38017
  }
37932
38018
  /** Appends `entry` to team `id`'s log, returning the verified result. */
37933
38019
  async appendTeam(id, entry) {
37934
- const requestId = randomUUID2();
38020
+ const requestId = randomUUID3();
37935
38021
  const reply = await this.request(this.teamRequests, requestId, {
37936
38022
  type: "append-team",
37937
38023
  requestId,
@@ -38197,7 +38283,7 @@ function take(map2, key) {
38197
38283
  }
38198
38284
 
38199
38285
  // ../bridge/dist/server.js
38200
- import { randomUUID as randomUUID3 } from "node:crypto";
38286
+ import { randomUUID as randomUUID4 } from "node:crypto";
38201
38287
 
38202
38288
  // ../../node_modules/.pnpm/zod@4.6.5/node_modules/zod/v3/helpers/util.js
38203
38289
  var util;
@@ -51027,7 +51113,7 @@ var CLAUDE_CHANNEL = "claude/channel";
51027
51113
  var CLAUDE_CHANNEL_NOTIFICATION = "notifications/claude/channel";
51028
51114
  function createSetupProblemServer(problem, { takeOver } = {}) {
51029
51115
  const explanation = `Blether isn't working in this session: ${problem}`;
51030
- const server2 = new McpServer({ name: "blether", version: "0.0.0" }, {
51116
+ const server2 = new McpServer({ name: "blether", version: BLETHER_VERSION }, {
51031
51117
  instructions: `${explanation} Blether's messaging tools are unavailable until this is fixed. If your developer asks about Blether, or you need to message another agent, tell them this and suggest the fix.`,
51032
51118
  // Declared up front so a session that takes its agent over can still
51033
51119
  // receive channel notices.
@@ -51057,9 +51143,59 @@ function createSetupProblemServer(problem, { takeOver } = {}) {
51057
51143
  }
51058
51144
  return server2;
51059
51145
  }
51146
+ function createDormantServer(connect2) {
51147
+ const server2 = new McpServer({ name: "blether", version: BLETHER_VERSION }, { capabilities: { experimental: { [CLAUDE_CHANNEL]: {} } } });
51148
+ const offerConnect = () => {
51149
+ const connectTool = server2.registerTool("connect", {
51150
+ title: "Connect to Blether",
51151
+ description: "Connects this session to Blether, so you can message the agents of other developers on your team. Call it only when your developer runs /blether:connect or asks you to connect to Blether."
51152
+ }, async () => {
51153
+ let connected;
51154
+ try {
51155
+ connected = await connect2(server2);
51156
+ } catch (error62) {
51157
+ return {
51158
+ ...text(`Couldn't connect to Blether: ${error62.message}`),
51159
+ isError: true
51160
+ };
51161
+ }
51162
+ connectTool.remove();
51163
+ const disconnectTool = server2.registerTool("disconnect", {
51164
+ title: "Disconnect from Blether",
51165
+ description: "Disconnects this session from Blether. Call it only when your developer runs /blether:disconnect or asks you to."
51166
+ }, async () => {
51167
+ const result = await connected.disconnect();
51168
+ disconnectTool.remove();
51169
+ offerConnect();
51170
+ return text(result);
51171
+ });
51172
+ return text(connected.result);
51173
+ });
51174
+ };
51175
+ offerConnect();
51176
+ return server2;
51177
+ }
51178
+ function removableTools(server2, install) {
51179
+ const added = [];
51180
+ const register = server2.registerTool.bind(server2);
51181
+ server2.registerTool = ((...args) => {
51182
+ const tool = register(...args);
51183
+ added.push(tool);
51184
+ return tool;
51185
+ });
51186
+ try {
51187
+ install();
51188
+ } finally {
51189
+ delete server2.registerTool;
51190
+ }
51191
+ return () => {
51192
+ for (const tool of added.splice(0))
51193
+ tool.remove();
51194
+ };
51195
+ }
51060
51196
  function createBridgeServer(relay, { policy = STRICTEST_POLICY, escalations, now = () => /* @__PURE__ */ new Date(), scanSecrets = scanForSecrets, sentLog, server: existing } = {}) {
51061
51197
  const pendingAtStart = escalations?.pending().length ?? 0;
51062
- const server2 = existing ?? new McpServer({ name: "blether", version: "0.0.0" }, {
51198
+ const server2 = existing ?? new McpServer({ name: "blether", version: BLETHER_VERSION }, {
51063
51199
  instructions: [
51064
51200
  INSTRUCTIONS,
51065
51201
  ...escalations ? [
@@ -51144,7 +51280,7 @@ function createBridgeServer(relay, { policy = STRICTEST_POLICY, escalations, now
51144
51280
  if (approval !== "approved") {
51145
51281
  return respond(`Not sent: ${approval}`, true);
51146
51282
  }
51147
- const fanout = randomUUID3();
51283
+ const fanout = randomUUID4();
51148
51284
  const sendOne = async (recipient) => {
51149
51285
  const receipt = await relay.send(recipient, body, {
51150
51286
  audience,
@@ -51466,6 +51602,8 @@ var REFUSAL_FIXES = {
51466
51602
  "identity-conflict": () => "The relay holds a different history for your identity than this device does. Don't add devices from two places at once; ask for help before going further."
51467
51603
  };
51468
51604
  async function startBridge(env = process.env, log = (line) => console.error(line)) {
51605
+ if (env.BLETHER_CONNECT === "manual")
51606
+ return startDormant(env, log);
51469
51607
  try {
51470
51608
  const { server: server2, connection } = await connect(env, log);
51471
51609
  return {
@@ -51500,7 +51638,65 @@ async function startBridge(env = process.env, log = (line) => console.error(line
51500
51638
  };
51501
51639
  }
51502
51640
  }
51503
- async function connect(env, log) {
51641
+ function startDormant(env, log) {
51642
+ let connected;
51643
+ const disconnect = async () => {
51644
+ const opened = connected;
51645
+ connected = void 0;
51646
+ if (!opened)
51647
+ return;
51648
+ opened.removeTools();
51649
+ try {
51650
+ opened.inbox.close();
51651
+ } catch {
51652
+ }
51653
+ await opened.connection.close();
51654
+ };
51655
+ const server2 = createDormantServer(async (server3) => {
51656
+ let opened;
51657
+ try {
51658
+ opened = await connect(env, log, server3);
51659
+ } catch (error62) {
51660
+ if (error62 instanceof SetupProblem) {
51661
+ throw new Error(error62.message, { cause: error62 });
51662
+ }
51663
+ throw error62;
51664
+ }
51665
+ connected = opened;
51666
+ log(`blether bridge connected as ${opened.agent}`);
51667
+ return {
51668
+ result: connectedGuidance(opened),
51669
+ disconnect: async () => {
51670
+ await disconnect();
51671
+ log("blether bridge disconnected");
51672
+ return "Disconnected from Blether. Your watch stops by itself, and Blether's tools are gone until your developer runs /blether:connect again.";
51673
+ }
51674
+ };
51675
+ });
51676
+ return {
51677
+ server: server2,
51678
+ close: async () => {
51679
+ await server2.close();
51680
+ await disconnect();
51681
+ }
51682
+ };
51683
+ }
51684
+ function connectedGuidance({ agent, team, inbox, pendingEscalations }) {
51685
+ const path2 = inbox.path.split("\\").join("/");
51686
+ const watch = `blether watch --inbox "${path2}" --session ${inbox.session}`;
51687
+ return [
51688
+ `Connected to Blether: you are ${agent} in team ${team.name}.`,
51689
+ INSTRUCTIONS,
51690
+ ...pendingEscalations > 0 ? [
51691
+ `${pendingEscalations} escalation(s) from earlier sessions are waiting for your developer: call list_escalations and raise them with your developer.`
51692
+ ] : [],
51693
+ "",
51694
+ "Now:",
51695
+ "1. Call read_mailbox.",
51696
+ `2. Start a watch, so you hear about new mail between prompts: use the Monitor tool (load it with ToolSearch first if it's deferred) with the command: ${watch} \u2014 the description "Blether mail for ${agent}", and timeout_ms 1800000. Each line it prints means new mail: call read_mailbox. When the watch expires, start it again. If it stops by itself (this session disconnected, or another session took the agent over), don't.`
51697
+ ].join("\n");
51698
+ }
51699
+ async function connect(env, log, into) {
51504
51700
  const home = env.BLETHER_HOME ?? defaultBletherHome();
51505
51701
  let session;
51506
51702
  try {
@@ -51547,13 +51743,13 @@ async function connect(env, log) {
51547
51743
  Messages are end-to-end encrypted; the relay sees only who messaged whom, and when.`);
51548
51744
  const found = { store, credentials, team, agent: agent.data, log };
51549
51745
  try {
51550
- return await open2(found, false);
51746
+ return await open2(found, into !== void 0, into);
51551
51747
  } catch (error62) {
51552
51748
  if (!(error62 instanceof RelayError)) {
51553
51749
  throw new SetupProblem(`Couldn't connect to the relay at ${team.relayUrl}: ${error62.message}. Is it running? Start it, then restart this session.`);
51554
51750
  }
51555
51751
  const fix = REFUSAL_FIXES[error62.code]?.(agent.data, team.name);
51556
- throw new SetupProblem(`The relay refused this session (${error62.code}): ${error62.message}${fix ? ` ${fix}` : ""}`, error62.code === "agent-in-use" ? async (server2) => (await open2(found, true, server2)).connection : void 0);
51752
+ throw new SetupProblem(`The relay refused this session (${error62.code}): ${error62.message}${fix ? ` ${fix}` : ""}`, error62.code === "agent-in-use" && !into ? async (server2) => (await open2(found, true, server2)).connection : void 0);
51557
51753
  }
51558
51754
  }
51559
51755
  async function open2({ store, credentials, team, agent, log }, takeover, server2) {
@@ -51567,13 +51763,43 @@ async function open2({ store, credentials, team, agent, log }, takeover, server2
51567
51763
  if (relay.identity && relay.identity.length > credentials.identity.length) {
51568
51764
  store.saveIdentity(relay.identity);
51569
51765
  }
51570
- const bridge = createBridgeServer(relay, {
51766
+ const inbox = new InboxFile(inboxPath(store.home, team.id, agent));
51767
+ keepInboxFile(relay, inbox);
51768
+ const escalations = new EscalationStore(store.home, team.id, agent);
51769
+ const options = {
51571
51770
  policy: new PolicyStore(store.home).load(),
51572
- escalations: new EscalationStore(store.home, team.id, agent),
51573
- sentLog: new SentLog(store.home, team.id, agent),
51574
- ...server2 ? { server: server2 } : {}
51575
- });
51576
- return { server: bridge, connection: relay };
51771
+ escalations,
51772
+ sentLog: new SentLog(store.home, team.id, agent)
51773
+ };
51774
+ let bridge;
51775
+ let removeTools = () => {
51776
+ };
51777
+ if (server2) {
51778
+ bridge = server2;
51779
+ removeTools = removableTools(server2, () => createBridgeServer(relay, { ...options, server: server2 }));
51780
+ } else {
51781
+ bridge = createBridgeServer(relay, options);
51782
+ }
51783
+ return {
51784
+ server: bridge,
51785
+ connection: relay,
51786
+ agent,
51787
+ team,
51788
+ inbox,
51789
+ removeTools,
51790
+ pendingEscalations: escalations.pending().length
51791
+ };
51792
+ }
51793
+ function keepInboxFile(relay, inbox) {
51794
+ const attempt = (write) => {
51795
+ try {
51796
+ write();
51797
+ } catch {
51798
+ }
51799
+ };
51800
+ relay.onArrival((item) => attempt(() => inbox.arrived(item.kind === "lost" ? void 0 : item.from, relay.unreadCount, relay.unreadFrom())));
51801
+ relay.onRead(() => attempt(() => inbox.write(relay.unreadCount, relay.unreadFrom())));
51802
+ void relay.settled().then(() => attempt(() => inbox.write(relay.unreadCount, relay.unreadFrom())));
51577
51803
  }
51578
51804
 
51579
51805
  // ../bridge/dist/bin.js
@@ -27617,6 +27617,9 @@ function parseDeviceGrant(text) {
27617
27617
  }
27618
27618
  }
27619
27619
 
27620
+ // ../protocol/dist/version.js
27621
+ var BLETHER_VERSION = "0.2.0";
27622
+
27620
27623
  // ../protocol/dist/wire.js
27621
27624
  var AudienceHint = external_exports.object({
27622
27625
  audience: external_exports.union([external_exports.object({ kind: external_exports.literal("agent") }), Audience]),
@@ -28007,6 +28010,7 @@ var RelayConnection = class _RelayConnection {
28007
28010
  */
28008
28011
  identity;
28009
28012
  arrivalListeners = /* @__PURE__ */ new Set();
28013
+ readListeners = /* @__PURE__ */ new Set();
28010
28014
  constructor(socket, credentials, scope, witness, readMessages) {
28011
28015
  this.socket = socket;
28012
28016
  this.credentials = credentials;
@@ -28172,6 +28176,11 @@ var RelayConnection = class _RelayConnection {
28172
28176
  const from = this.unread.map((item) => item.kind === "lost" ? "lost-message notices" : item.from);
28173
28177
  return [...new Set(from)];
28174
28178
  }
28179
+ /** Calls `listener` after each mailbox read. Returns a function that unsubscribes. */
28180
+ onRead(listener) {
28181
+ this.readListeners.add(listener);
28182
+ return () => this.readListeners.delete(listener);
28183
+ }
28175
28184
  /** Calls `listener` whenever a new message arrives. Returns a function that unsubscribes. */
28176
28185
  onArrival(listener) {
28177
28186
  this.arrivalListeners.add(listener);
@@ -28197,6 +28206,8 @@ var RelayConnection = class _RelayConnection {
28197
28206
  if (read.length > 0 && this.socket.readyState === import_websocket.default.OPEN) {
28198
28207
  this.write({ type: "read", ids: read });
28199
28208
  }
28209
+ for (const listener of this.readListeners)
28210
+ listener();
28200
28211
  return items;
28201
28212
  }
28202
28213
  /** A message read earlier in this session, by id. */
@@ -28695,6 +28706,7 @@ var PolicyStore = class {
28695
28706
 
28696
28707
  // ../bridge/dist/session-file.js
28697
28708
  import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
28709
+ import { homedir as homedir2 } from "node:os";
28698
28710
  import { dirname, join as join4, resolve } from "node:path";
28699
28711
  var SessionFileContents = external_exports.object({
28700
28712
  team: external_exports.string().min(1),
@@ -28703,7 +28715,17 @@ var SessionFileContents = external_exports.object({
28703
28715
  var DIR = ".blether";
28704
28716
  var FILE = "session.json";
28705
28717
  var GITIGNORE = "# Written by `blether use`: this developer's own agent for the project.\n*\n";
28706
- function writeSessionFile(projectDir, contents) {
28718
+ var SessionFileError = class extends Error {
28719
+ };
28720
+ function isBletherHome(dir, bletherHome) {
28721
+ const same = (a2, b2) => process.platform === "win32" ? resolve(a2).toLowerCase() === resolve(b2).toLowerCase() : resolve(a2) === resolve(b2);
28722
+ const candidate = join4(dir, DIR);
28723
+ return same(candidate, bletherHome) || same(candidate, join4(homedir2(), DIR));
28724
+ }
28725
+ function writeSessionFile(projectDir, contents, bletherHome) {
28726
+ if (isBletherHome(projectDir, bletherHome)) {
28727
+ throw new SessionFileError(`${resolve(projectDir)} is your home directory, where Blether keeps your identity, not a project. Run "blether use" from the project's root, or give it --dir <project>.`);
28728
+ }
28707
28729
  const dir = join4(projectDir, DIR);
28708
28730
  mkdirSync4(dir, { recursive: true });
28709
28731
  const ignore = join4(dir, ".gitignore");
@@ -28715,9 +28737,71 @@ function writeSessionFile(projectDir, contents) {
28715
28737
  return path;
28716
28738
  }
28717
28739
 
28740
+ // ../bridge/dist/inbox-file.js
28741
+ import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync5, renameSync, writeFileSync as writeFileSync5 } from "node:fs";
28742
+ var InboxState = external_exports.object({
28743
+ /** Changes each time a bridge starts, so a watcher can tell a restart from new mail. */
28744
+ session: external_exports.string(),
28745
+ /** Messages that have arrived since this bridge started, counting up. */
28746
+ arrivals: external_exports.number().int().min(0),
28747
+ unread: external_exports.number().int().min(0),
28748
+ /** Who the waiting messages are from, oldest first, each once. */
28749
+ from: external_exports.array(external_exports.string()),
28750
+ /** The latest arrival's sender, if any has arrived this session. */
28751
+ lastFrom: external_exports.string().optional(),
28752
+ updatedAt: external_exports.string(),
28753
+ /** Set when the bridge disconnected this session from the relay. */
28754
+ closed: external_exports.boolean().optional()
28755
+ });
28756
+ function readInboxState(path) {
28757
+ if (!existsSync5(path))
28758
+ return void 0;
28759
+ try {
28760
+ return InboxState.parse(JSON.parse(readFileSync5(path, "utf8")));
28761
+ } catch {
28762
+ return void 0;
28763
+ }
28764
+ }
28765
+ function unreadSummary(state) {
28766
+ if (!state || state.unread === 0)
28767
+ return void 0;
28768
+ return `\u{1F4EC} ${state.unread} unread Blether message${state.unread === 1 ? "" : "s"} (from ${state.from.join(", ")}). Call read_mailbox to read them.`;
28769
+ }
28770
+
28771
+ // ../bridge/dist/watch.js
28772
+ function watchInbox(path, session, emit, end, { intervalMs = 1e3 } = {}) {
28773
+ let arrivals;
28774
+ let timer;
28775
+ let stopped = false;
28776
+ const stop = () => {
28777
+ stopped = true;
28778
+ clearInterval(timer);
28779
+ };
28780
+ const check2 = () => {
28781
+ const state = readInboxState(path);
28782
+ if (!state)
28783
+ return;
28784
+ if (state.session !== session || state.closed) {
28785
+ stop();
28786
+ end(state.session === session ? "disconnected" : "taken-over");
28787
+ return;
28788
+ }
28789
+ const summary = unreadSummary(state);
28790
+ const arrived = arrivals !== void 0 && state.arrivals > arrivals;
28791
+ if (summary && (arrivals === void 0 || arrived)) {
28792
+ emit(arrived && state.lastFrom ? `New Blether message from ${state.lastFrom}. ${summary}` : summary);
28793
+ }
28794
+ arrivals = state.arrivals;
28795
+ };
28796
+ check2();
28797
+ if (!stopped)
28798
+ timer = setInterval(check2, intervalMs);
28799
+ return stop;
28800
+ }
28801
+
28718
28802
  // ../bridge/dist/claude-install.js
28719
28803
  import { spawn } from "node:child_process";
28720
- import { existsSync as existsSync5 } from "node:fs";
28804
+ import { existsSync as existsSync6 } from "node:fs";
28721
28805
  import { dirname as dirname2, join as join5, resolve as resolve2 } from "node:path";
28722
28806
  import { fileURLToPath } from "node:url";
28723
28807
  var PLUGIN_ID = "blether@blether";
@@ -28737,7 +28821,7 @@ var runClaude = (args) => new Promise((resolvePromise, reject) => {
28737
28821
  function packageRoot(from = dirname2(fileURLToPath(import.meta.url))) {
28738
28822
  let dir = resolve2(from);
28739
28823
  for (; ; ) {
28740
- if (existsSync5(join5(dir, ".claude-plugin", "marketplace.json")))
28824
+ if (existsSync6(join5(dir, ".claude-plugin", "marketplace.json")))
28741
28825
  return dir;
28742
28826
  const parent = dirname2(dir);
28743
28827
  if (parent === dir)
@@ -28801,6 +28885,9 @@ Commands:
28801
28885
  agent delete <team> <name> Delete one of your agents (or any, as Team Admin)
28802
28886
  use <team> <agent> [--dir <path>] Make sessions started in this project (or <path>) act as <agent>
28803
28887
  claude install Install (or update) the Blether plugin in Claude Code
28888
+ watch --inbox <path> --session <id>
28889
+ Print a line whenever a connected session's agent gets mail
28890
+ (the bridge gives its agent this command when it connects)
28804
28891
  escalations List messages your agents are holding for your decision
28805
28892
  status One line for your Claude Code status line: escalations waiting
28806
28893
  policy Show your Approval Policy on this device
@@ -28810,6 +28897,7 @@ Commands:
28810
28897
  outgoing: ask | ask-others | free
28811
28898
  incoming: ask | ask-impactful | free
28812
28899
  limits: most messages an agent sends in the window
28900
+ --version Show which version of Blether is installed
28813
28901
 
28814
28902
  Set BLETHER_HOME to keep Blether's files somewhere other than ~/.blether.`;
28815
28903
  var CliError = class extends Error {
@@ -28917,6 +29005,8 @@ ${USAGE}`);
28917
29005
  }
28918
29006
  case "use":
28919
29007
  return use(rest, ctx);
29008
+ case "watch":
29009
+ return watch(rest, ctx);
28920
29010
  case "claude": {
28921
29011
  const [sub] = rest;
28922
29012
  if (sub === "install")
@@ -28937,6 +29027,11 @@ ${USAGE}`);
28937
29027
  case "-h":
28938
29028
  ctx.io.out(USAGE);
28939
29029
  return command === void 0 ? 1 : 0;
29030
+ case "version":
29031
+ case "--version":
29032
+ case "-v":
29033
+ ctx.io.out(BLETHER_VERSION);
29034
+ return 0;
28940
29035
  default:
28941
29036
  throw new CliError(`Unknown command: ${command}
28942
29037
 
@@ -28950,7 +29045,7 @@ function init(args, { store, io }) {
28950
29045
  });
28951
29046
  const name = DeveloperName.safeParse(values.name);
28952
29047
  if (!name.success) {
28953
- throw new CliError('Give your name, as teammates will see it: blether init --name "Kev"');
29048
+ throw new CliError("Give your name, as teammates will see it: blether init --name <name>");
28954
29049
  }
28955
29050
  if (store.exists()) {
28956
29051
  throw new CliError(`There is already a Blether identity in ${store.home}. Run \`blether whoami\` to see it.`);
@@ -29427,6 +29522,27 @@ async function agentDelete(args, ctx) {
29427
29522
  ctx.io.out(`Deleted agent ${name} from ${record2.name}. Unread messages to it are lost, and their senders will be told. The name can be used again.`);
29428
29523
  return 0;
29429
29524
  }
29525
+ async function watch(args, ctx) {
29526
+ const { values } = parseArgs({
29527
+ args,
29528
+ options: { inbox: { type: "string" }, session: { type: "string" } }
29529
+ });
29530
+ if (!values.inbox || !values.session) {
29531
+ throw new CliError("Usage: blether watch --inbox <path> --session <id>");
29532
+ }
29533
+ const ended = await new Promise((resolve3) => {
29534
+ const stop = watchInbox(values.inbox, values.session, ctx.io.out, (why) => resolve3(why === "taken-over" ? "Blether watch stopped: another session took over this agent." : "Blether watch stopped: this session disconnected from Blether."));
29535
+ for (const signal of ["SIGINT", "SIGTERM"]) {
29536
+ process.once(signal, () => {
29537
+ stop();
29538
+ resolve3("");
29539
+ });
29540
+ }
29541
+ });
29542
+ if (ended)
29543
+ ctx.io.out(ended);
29544
+ return 0;
29545
+ }
29430
29546
  async function claudeInstall(ctx) {
29431
29547
  const root = "packageRoot" in ctx ? ctx.packageRoot : packageRoot();
29432
29548
  if (!root) {
@@ -29439,7 +29555,6 @@ async function claudeInstall(ctx) {
29439
29555
  throw new CliError(missing ? "Couldn't run `claude`. Install Claude Code, or check it's on your PATH." : error62.message);
29440
29556
  }
29441
29557
  ctx.io.out("Start a new Claude Code session to use it, and run /blether:setup in each project.");
29442
- ctx.io.out(`For push delivery, start sessions with: claude --dangerously-load-development-channels plugin:${PLUGIN_ID}`);
29443
29558
  return 0;
29444
29559
  }
29445
29560
  async function use(args, ctx) {
@@ -29463,10 +29578,14 @@ async function use(args, ctx) {
29463
29578
  if (agent.owner !== me2) {
29464
29579
  throw new CliError(`${name} belongs to another developer. Use one of yours (blether agent list ${record2.name}), or create one.`);
29465
29580
  }
29466
- const path = writeSessionFile(values.dir ?? ctx.cwd ?? process.cwd(), {
29467
- team: record2.name,
29468
- agent: name
29469
- });
29581
+ let path;
29582
+ try {
29583
+ path = writeSessionFile(values.dir ?? ctx.cwd ?? process.cwd(), { team: record2.name, agent: name }, ctx.store.home);
29584
+ } catch (error62) {
29585
+ if (error62 instanceof SessionFileError)
29586
+ throw new CliError(error62.message);
29587
+ throw error62;
29588
+ }
29470
29589
  ctx.io.out(`Sessions started in this project will act as ${name} in ${record2.name}.`);
29471
29590
  ctx.io.out(`Wrote ${path}; it's ignored by git, since the agent is yours. Restart any session already running here.`);
29472
29591
  return 0;
@@ -1,12 +1,14 @@
1
1
  ---
2
2
  name: blether
3
- description: Message teammates' agents through Blether. Use before a change that affects code or interfaces another developer owns, when you need an answer only another developer's agent has, when your work blocks or unblocks someone, or when a Blether message arrives that needs a reply.
3
+ description: Message teammates' agents through Blether, in a session the developer has connected with /blether:connect. Once connected, use before a change that affects code or interfaces another developer owns, when you need an answer only another developer's agent has, when your work blocks or unblocks someone, or when a Blether message arrives that needs a reply.
4
4
  ---
5
5
 
6
6
  # Working with your team through Blether
7
7
 
8
8
  Blether connects you to the agents of the other developers on your team. Each agent works for its own developer, in its own project. The bridge's tools (`list_agents`, `send_message`, `read_mailbox`, `sent_messages`) do the carrying, and each tool result says how to handle what it returns. This skill is about **when** to reach out and **how to write** so the other agent can act without coming back to you.
9
9
 
10
+ Blether is used only in sessions the developer connects (`/blether:connect`). If the bridge's messaging tools aren't available, this session isn't connected: carry on without Blether, don't connect on your own, and don't bring it up.
11
+
10
12
  ## When to send
11
13
 
12
14
  - **Heads-up before you change something others depend on**: an API shape, a shared schema, a config key, a library version, a file layout. Send it before you commit, saying what changes and when, so their agent can adapt or object.
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: connect
3
+ description: Connect this session to Blether, so it can message teammates' agents and hear about new mail.
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ # Connect this session to Blether
8
+
9
+ The developer wants this session to use Blether. Until now the bridge has stayed out of it.
10
+
11
+ 1. Call the bridge's `connect` tool (`mcp__plugin_blether_blether__connect`; load it with ToolSearch first if it's deferred).
12
+ 2. If it fails, tell the developer what it says. If no agent is chosen for this project, offer `/blether:setup`.
13
+ 3. If it connects, do what its result says: read the mailbox, then start the watch with the Monitor tool, exactly as given.
14
+ 4. Tell the developer in two or three lines: which agent this session is, what's waiting in the mailbox, and that you'll hear about new mail while connected. If another session was using the agent, it has been disconnected.
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: disconnect
3
+ description: Disconnect this session from Blether, so it stops messaging teammates' agents and hearing about new mail.
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ # Disconnect this session from Blether
8
+
9
+ Call the bridge's `disconnect` tool (`mcp__plugin_blether_blether__disconnect`; load it with ToolSearch first if it's deferred). The watch stops by itself. Tell the developer in one line that the session is disconnected, and that `/blether:connect` connects it again.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: setup
3
- description: Set up Blether for this project - identity, team, agent, push delivery and status line.
3
+ description: Set up Blether for this project - identity, team, agent and status line - and connect this session.
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
@@ -37,17 +37,20 @@ Then run `use <team> <agent>` in the project root. It writes `.blether/session.j
37
37
 
38
38
  Done when `use` reports the agent for this project.
39
39
 
40
- ## 4. Reload and check
40
+ ## 4. Connect and check
41
41
 
42
- Tell the developer to restart this session (or run `/mcp` and reconnect the `blether` server) so the bridge picks up the project's agent.
42
+ Setting Blether up is a request to use it, so connect this session: call the bridge's `connect` tool (`mcp__plugin_blether_blether__connect`; load it with ToolSearch first if it's deferred). It reads the project's agent when called, so there's no need to restart.
43
43
 
44
- Done when the bridge's `list_agents` shows the roster. If the bridge offers only `blether_status`, it couldn't start: call it, and fix what it says.
44
+ Done when it connects and its result's instructions are followed (read the mailbox, start the watch). If it fails, fix what it says.
45
45
 
46
- ## 5. Push delivery and visibility
46
+ ## 5. How it works, and visibility
47
47
 
48
- Explain these two options and set up the ones they want:
48
+ Tell them how Blether works from now on:
49
+
50
+ - **Sessions start without Blether.** The bridge stays out of every session until they run `/blether:connect`. A connected session can message teammates' agents and hears about new mail through a watch; `/blether:disconnect` ends that. Only one session can be connected as an agent at a time: connecting another takes the agent over.
51
+
52
+ Offer this option and set it up if they want it:
49
53
 
50
- - **Push delivery (channels)**: new messages can wake the session instead of waiting for the next mailbox check. Channels are a research preview, so Claude Code needs starting with `claude --dangerously-load-development-channels plugin:blether@blether`. Without it, nothing breaks; messages wait in the mailbox.
51
54
  - **Status line**: `blether status` prints a count of messages agents are holding for the developer's decision. Offer to add it to `statusLine` in `~/.claude/settings.json`, merging with any status line they already have.
52
55
 
53
56
  Finish with a one-paragraph summary: who they are, which team, which agent this project acts as, and what's enabled.