diff --git a/plugins/agentbridge/server/bridge-server.js b/plugins/agentbridge/server/bridge-server.js index 9e711f2..06b07e4 100755 --- a/plugins/agentbridge/server/bridge-server.js +++ b/plugins/agentbridge/server/bridge-server.js @@ -14707,10 +14707,10 @@ function defineNumber(value, fallback) { } var BUILD_INFO = Object.freeze({ version: defineString("0.1.24", "0.0.0-source"), - commit: defineString("a5a6005", "source"), + commit: defineString("8284e8a", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("5f8ef4c63fe4", "source") + codeHash: defineString("0a63b984bf2a", "source") }); function sameRuntimeContract(a, b) { if (!a || !b) diff --git a/plugins/agentbridge/server/daemon.js b/plugins/agentbridge/server/daemon.js index 0848857..960ce5e 100755 --- a/plugins/agentbridge/server/daemon.js +++ b/plugins/agentbridge/server/daemon.js @@ -3,9 +3,9 @@ var __require = import.meta.require; // src/daemon.ts -import { existsSync as existsSync8, realpathSync as realpathSync2, rmSync as rmSync2 } from "fs"; +import { existsSync as existsSync8, realpathSync as realpathSync3, rmSync as rmSync2 } from "fs"; import { homedir as homedir5 } from "os"; -import { join as join11 } from "path"; +import { join as join12 } from "path"; import { randomUUID as randomUUID4 } from "crypto"; // src/contract-version.ts @@ -30,10 +30,10 @@ function defineNumber(value, fallback) { } var BUILD_INFO = Object.freeze({ version: defineString("0.1.24", "0.0.0-source"), - commit: defineString("a5a6005", "source"), + commit: defineString("8284e8a", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("5f8ef4c63fe4", "source") + codeHash: defineString("0a63b984bf2a", "source") }); function daemonStatusBuildInfo() { return { ...BUILD_INFO }; @@ -6810,6 +6810,532 @@ class RoomManager { } } +// src/broker-client.ts +class BrokerClient { + opts; + ws = null; + identity = null; + subscriptions = new Set; + outbox = []; + eventHandlers = []; + closed = false; + authFailed = false; + reconnectAttempt = 0; + reconnectTimer = null; + connectPromise = null; + resolveConnect = null; + rejectConnect = null; + log; + mkWs; + baseMs; + maxMs; + maxOutbox; + constructor(opts) { + this.opts = opts; + this.log = opts.log ?? (() => {}); + this.mkWs = opts.wsFactory ?? ((url) => new WebSocket(url)); + this.baseMs = opts.reconnectBaseMs ?? 250; + this.maxMs = opts.reconnectMaxMs ?? 1e4; + this.maxOutbox = opts.maxOutbox ?? 1000; + } + get connected() { + return this.ws !== null && this.ws.readyState === WebSocket.OPEN && this.identity !== null; + } + get whoami() { + return this.identity; + } + get queuedCount() { + return this.outbox.length; + } + connect() { + if (this.closed) + return Promise.reject(new Error("client closed")); + if (this.connectPromise) + return this.connectPromise; + this.connectPromise = new Promise((resolve, reject) => { + this.resolveConnect = resolve; + this.rejectConnect = reject; + }); + this.openSocket(); + return this.connectPromise; + } + subscribe(topic) { + this.subscriptions.add(topic); + if (this.connected) + this.sendRaw({ type: "subscribe", topic }); + } + unsubscribe(topic) { + this.subscriptions.delete(topic); + if (this.connected) + this.sendRaw({ type: "unsubscribe", topic }); + } + publish(topic, envelope) { + if (this.connected) { + this.sendRaw({ type: "publish", topic, envelope }); + return; + } + if (this.outbox.length >= this.maxOutbox) { + this.outbox.shift(); + this.log(`outbox full (${this.maxOutbox}) \u2014 dropped oldest queued message`); + } + this.outbox.push({ topic, envelope }); + } + onEvent(handler) { + this.eventHandlers.push(handler); + } + close() { + this.closed = true; + this.clearReconnectTimer(); + this.teardownSocket(); + if (this.rejectConnect) { + const reject = this.rejectConnect; + this.resolveConnect = null; + this.rejectConnect = null; + reject(new Error("client closed")); + } + } + openSocket() { + this.clearReconnectTimer(); + this.teardownSocket(); + const ws = this.mkWs(this.opts.url); + this.ws = ws; + ws.onopen = () => { + this.sendRaw({ type: "hello", token: this.opts.token, presence: this.opts.presence }); + }; + ws.onmessage = (ev) => { + let msg; + try { + msg = JSON.parse(ev.data); + } catch { + return; + } + if (typeof msg !== "object" || msg === null || typeof msg.type !== "string") + return; + if (msg.type === "welcome") { + this.identity = msg.identity; + this.reconnectAttempt = 0; + for (const topic of this.subscriptions) + this.sendRaw({ type: "subscribe", topic }); + this.flushOutbox(); + this.log(`connected as ${msg.identity.id}`); + if (this.resolveConnect) { + const resolve = this.resolveConnect; + this.resolveConnect = null; + this.rejectConnect = null; + resolve(msg.identity); + } + } else if (msg.type === "auth_error") { + this.authFailed = true; + if (this.rejectConnect) { + const reject = this.rejectConnect; + this.resolveConnect = null; + this.rejectConnect = null; + reject(new Error("broker auth failed")); + } + } else if (msg.type === "event") { + for (const h of this.eventHandlers) { + try { + h(msg.topic, msg.envelope); + } catch (e) { + this.log(`event handler threw: ${String(e)}`); + } + } + } + }; + ws.onclose = () => { + if (this.ws !== ws) + return; + this.ws = null; + this.identity = null; + if (!this.closed && !this.authFailed) + this.scheduleReconnect(); + }; + ws.onerror = () => {}; + } + teardownSocket() { + const old = this.ws; + if (!old) + return; + this.ws = null; + this.identity = null; + old.onopen = null; + old.onmessage = null; + old.onclose = null; + old.onerror = null; + try { + old.close(); + } catch {} + } + clearReconnectTimer() { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + flushOutbox() { + if (this.outbox.length === 0) + return; + const pending = this.outbox.splice(0, this.outbox.length); + for (const { topic, envelope } of pending) + this.sendRaw({ type: "publish", topic, envelope }); + this.log(`flushed ${pending.length} queued message(s)`); + } + sendRaw(msg) { + try { + this.ws?.send(JSON.stringify(msg)); + } catch (e) { + this.log(`send failed: ${String(e)}`); + } + } + scheduleReconnect() { + if (this.closed || this.reconnectTimer) + return; + const delay = Math.min(this.maxMs, this.baseMs * 2 ** this.reconnectAttempt); + this.reconnectAttempt++; + this.log(`reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (this.closed) + return; + this.openSocket(); + }, delay); + } +} + +// src/room-service.ts +import { realpathSync as realpathSync2 } from "fs"; + +class RoomService { + store; + constructor(store) { + this.store = store; + } + async createRoom(roomId, name, createdBy) { + await this.store.createRoom(roomId, name, createdBy); + } + async getRoom(roomId) { + return this.store.getRoom(roomId); + } + async listRooms() { + return this.store.listRooms(); + } + async join(roomId, agentId) { + await this.store.addMember(roomId, agentId); + } + async leave(roomId, agentId) { + await this.store.removeMember(roomId, agentId); + } + async getMembers(roomId) { + return this.store.getMembers(roomId); + } + async getRoomsForAgent(agentId) { + return this.store.getRoomsForAgent(agentId); + } + async isMember(roomId, agentId) { + return (await this.store.getMembers(roomId)).includes(agentId); + } + async mapCwd(workspacePath, roomId) { + await this.store.mapCwd(this.normalizeCwd(workspacePath), roomId); + } + async resolveRoomForCwd(workspacePath) { + return this.store.getRoomForCwd(this.normalizeCwd(workspacePath)); + } + async autoJoinByCwd(workspacePath, agentId) { + const roomId = await this.resolveRoomForCwd(workspacePath); + if (!roomId) + return null; + const already = await this.isMember(roomId, agentId); + if (!already) + await this.join(roomId, agentId); + return { roomId, joined: !already }; + } + normalizeCwd(workspacePath) { + try { + return realpathSync2(workspacePath); + } catch { + return workspacePath; + } + } +} + +// src/collab-store.ts +import { chmodSync as chmodSync3, mkdirSync as mkdirSync6, readFileSync as readFileSync9 } from "fs"; +import { dirname as dirname3, join as join11 } from "path"; + +// src/backbone/store/sqlite-store.ts +import { Database } from "bun:sqlite"; + +class SqliteStore { + db; + closed = false; + constructor(path) { + this.db = new Database(path); + this.db.exec("PRAGMA journal_mode=WAL"); + this.db.exec(` + CREATE TABLE IF NOT EXISTS identities ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS agents ( + agent_id TEXT PRIMARY KEY, + person_id TEXT NOT NULL, + type TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + started_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS workspace_sessions ( + workspace_path TEXT, + agent_type TEXT, + last_session_id TEXT NOT NULL, + PRIMARY KEY (workspace_path, agent_type) + ); + CREATE TABLE IF NOT EXISTS rooms ( + room_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_by TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS room_members ( + room_id TEXT, + agent_id TEXT, + PRIMARY KEY (room_id, agent_id) + ); + CREATE TABLE IF NOT EXISTS cwd_room_map ( + workspace_path TEXT PRIMARY KEY, + room_id TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS room_events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + room_id TEXT NOT NULL, + envelope TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS room_whiteboard ( + room_id TEXT PRIMARY KEY, + data TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS pending_deliveries ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + target_agent_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + envelope TEXT NOT NULL, + UNIQUE (target_agent_id, idempotency_key) + ); + CREATE TABLE IF NOT EXISTS auth_tokens ( + token TEXT PRIMARY KEY, + identity_id TEXT NOT NULL + ); + `); + } + async upsertIdentity(id, displayName) { + this.db.query("INSERT INTO identities(id, display_name) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET display_name=excluded.display_name").run(id, displayName); + return { id, displayName }; + } + async getIdentity(id) { + const row = this.db.query("SELECT id, display_name FROM identities WHERE id=?").get(id); + return row ? { id: row.id, displayName: row.display_name } : null; + } + async upsertAgent(agentId, personId, type) { + this.db.query("INSERT INTO agents(agent_id, person_id, type) VALUES(?, ?, ?) ON CONFLICT(agent_id) DO UPDATE SET person_id=excluded.person_id, type=excluded.type").run(agentId, personId, type); + } + async getAgent(agentId) { + const row = this.db.query("SELECT agent_id, person_id, type FROM agents WHERE agent_id=?").get(agentId); + return row ? { agentId: row.agent_id, personId: row.person_id, type: row.type } : null; + } + async recordSession(sessionId, agentId, startedAt) { + this.db.query("INSERT OR REPLACE INTO sessions(session_id, agent_id, started_at) VALUES(?, ?, ?)").run(sessionId, agentId, startedAt); + } + async getLastSession(workspacePath, agentType) { + const row = this.db.query("SELECT last_session_id FROM workspace_sessions WHERE workspace_path=? AND agent_type=?").get(workspacePath, agentType); + return row ? row.last_session_id : null; + } + async setLastSession(workspacePath, agentType, sessionId) { + this.db.query("INSERT INTO workspace_sessions(workspace_path, agent_type, last_session_id) VALUES(?, ?, ?) ON CONFLICT(workspace_path, agent_type) DO UPDATE SET last_session_id=excluded.last_session_id").run(workspacePath, agentType, sessionId); + } + async createRoom(roomId, name, createdBy) { + this.db.query("INSERT OR IGNORE INTO rooms(room_id, name, created_by) VALUES(?, ?, ?)").run(roomId, name, createdBy); + } + async getRoom(roomId) { + const row = this.db.query("SELECT room_id, name, created_by FROM rooms WHERE room_id=?").get(roomId); + return row ? { roomId: row.room_id, name: row.name, createdBy: row.created_by } : null; + } + async listRooms() { + const rows = this.db.query("SELECT room_id, name, created_by FROM rooms").all(); + return rows.map((r) => ({ roomId: r.room_id, name: r.name, createdBy: r.created_by })); + } + async addMember(roomId, agentId) { + this.db.query("INSERT OR IGNORE INTO room_members(room_id, agent_id) VALUES(?, ?)").run(roomId, agentId); + } + async removeMember(roomId, agentId) { + this.db.query("DELETE FROM room_members WHERE room_id=? AND agent_id=?").run(roomId, agentId); + } + async getMembers(roomId) { + const rows = this.db.query("SELECT agent_id FROM room_members WHERE room_id=?").all(roomId); + return rows.map((r) => r.agent_id); + } + async getRoomsForAgent(agentId) { + const rows = this.db.query("SELECT room_id FROM room_members WHERE agent_id=?").all(agentId); + return rows.map((r) => r.room_id); + } + async mapCwd(workspacePath, roomId) { + this.db.query("INSERT INTO cwd_room_map(workspace_path, room_id) VALUES(?, ?) ON CONFLICT(workspace_path) DO UPDATE SET room_id=excluded.room_id").run(workspacePath, roomId); + } + async getRoomForCwd(workspacePath) { + const row = this.db.query("SELECT room_id FROM cwd_room_map WHERE workspace_path=?").get(workspacePath); + return row ? row.room_id : null; + } + async appendEvent(roomId, envelope) { + this.db.query("INSERT INTO room_events(room_id, envelope) VALUES(?, ?)").run(roomId, JSON.stringify(envelope)); + } + async getRecentEvents(roomId, limit) { + if (limit <= 0) + return []; + const rows = this.db.query("SELECT envelope FROM room_events WHERE room_id=? ORDER BY seq DESC LIMIT ?").all(roomId, limit); + return rows.map((r) => JSON.parse(r.envelope)); + } + async getWhiteboard(roomId) { + const row = this.db.query("SELECT data FROM room_whiteboard WHERE room_id=?").get(roomId); + return row ? JSON.parse(row.data) : null; + } + async saveWhiteboard(roomId, whiteboard) { + this.db.query("INSERT INTO room_whiteboard(room_id, data) VALUES(?, ?) ON CONFLICT(room_id) DO UPDATE SET data=excluded.data").run(roomId, JSON.stringify(whiteboard)); + } + async enqueuePending(targetAgentId, envelope) { + this.db.query("INSERT OR IGNORE INTO pending_deliveries(target_agent_id, idempotency_key, envelope) VALUES(?, ?, ?)").run(targetAgentId, envelope.idempotencyKey, JSON.stringify(envelope)); + } + async drainPending(targetAgentId) { + const rows = this.db.query("SELECT envelope FROM pending_deliveries WHERE target_agent_id=? ORDER BY seq").all(targetAgentId); + this.db.query("DELETE FROM pending_deliveries WHERE target_agent_id=?").run(targetAgentId); + return rows.map((r) => JSON.parse(r.envelope)); + } + async issueToken(token, identityId) { + this.db.query("INSERT INTO auth_tokens(token, identity_id) VALUES(?, ?) ON CONFLICT(token) DO UPDATE SET identity_id=excluded.identity_id").run(token, identityId); + } + async resolveToken(token) { + const row = this.db.query("SELECT identity_id FROM auth_tokens WHERE token=?").get(token); + return row ? row.identity_id : null; + } + async listTokens() { + const rows = this.db.query("SELECT token, identity_id FROM auth_tokens").all(); + return rows.map((r) => ({ token: r.token, identityId: r.identity_id })); + } + async close() { + if (this.closed) + return; + this.closed = true; + this.db.close(); + } +} + +// src/collab-store.ts +var DEFAULT_BROKER_URL = "ws://127.0.0.1:4700/ws"; +function resolveDbPath(explicit) { + if (explicit) + return explicit; + const env = process.env.AGENTBRIDGE_COLLAB_DB; + if (env && env.length > 0) + return env; + return join11(new StateDirResolver().dir, "collab.db"); +} +function resolveBrokerUrl(explicit) { + if (explicit) + return explicit; + const env = process.env.AGENTBRIDGE_BROKER_URL; + if (env && env.length > 0) + return env; + return DEFAULT_BROKER_URL; +} +function readAuthToken(dbPath) { + try { + const token = readFileSync9(join11(dirname3(dbPath), "auth-token"), "utf-8").trim(); + return token === "" ? null : token; + } catch { + return null; + } +} +function openStore(dbPath) { + const dir = dirname3(dbPath); + mkdirSync6(dir, { recursive: true, mode: 448 }); + chmodSync3(dir, 448); + return new SqliteStore(dbPath); +} + +// src/room-bridge.ts +var INERT = { stop: () => {}, roomId: null }; +var SEEN_CAP = 500; +function label(env) { + const dn = env.payload?.displayName; + return env.from?.name || (typeof dn === "string" ? dn : "") || env.from?.agentId || "\u67D0\u6210\u5458"; +} +function renderRoomEvent(env) { + const who = label(env); + switch (env.kind) { + case "task_completed": { + const p = env.payload ?? {}; + const where = [p.repo, p.branch].filter(Boolean).join("@"); + const loc = [where, p.commit].filter(Boolean).join(" "); + const unblocks = p.unblocks && p.unblocks.length > 0 ? ` \xB7 \u89E3\u9501: ${p.unblocks.join(", ")}` : ""; + return `\uD83C\uDFC1 ${who} \u5B8C\u6210\u4EFB\u52A1\uFF1A${p.summary ?? "(\u65E0\u6458\u8981)"}${loc ? ` (${loc})` : ""}${unblocks}`; + } + case "member_joined": { + const host = env.payload?.host; + return `\uD83D\uDC4B ${who} \u52A0\u5165\u623F\u95F4${typeof host === "string" && host ? `\uFF08${host}\uFF09` : ""}`; + } + case "member_left": + return `\uD83D\uDC4B ${who} \u79BB\u5F00\u623F\u95F4`; + default: + return null; + } +} +async function startRoomBridge(deps) { + const log = deps.log ?? (() => {}); + const dbPath = resolveDbPath(deps.dbPath); + const token = readAuthToken(dbPath); + if (!token) { + log("room bridge: not logged in (no auth-token) \u2014 inactive"); + return INERT; + } + const ownStore = !deps.store; + const store = deps.store ?? openStore(dbPath); + let roomId; + try { + roomId = await new RoomService(store).resolveRoomForCwd(deps.cwd); + } finally { + if (ownStore) + await store.close(); + } + if (!roomId) { + log(`room bridge: ${deps.cwd} not mapped to a room \u2014 inactive`); + return INERT; + } + const room = roomId; + const seen = new Set; + const client = new BrokerClient({ + url: resolveBrokerUrl(deps.brokerUrl), + token, + presence: { agentType: "claude" }, + log + }); + client.onEvent((_topic, env) => { + const key = env.idempotencyKey; + if (typeof key === "string" && key.length > 0) { + if (seen.has(key)) + return; + seen.add(key); + if (seen.size > SEEN_CAP) + seen.delete(seen.values().next().value); + } + const text = renderRoomEvent(env); + if (text) + deps.emit(text); + }); + client.subscribe(room); + client.connect().catch((e) => log(`room bridge: connect failed \u2014 ${String(e)}`)); + log(`room bridge: subscribed to room ${room}`); + return { stop: () => client.close(), roomId: room }; +} + // src/daemon.ts var stateDir = new StateDirResolver; stateDir.ensure(); @@ -6904,6 +7430,7 @@ var pendingSteerDispatches = new Map; var BUSY_RETRY_ADVISORY_MS = 15000; var shuttingDown = false; var bootDeadlineTimer = null; +var roomBridge = null; var lastAttachStatusSentTs = 0; var ATTACH_STATUS_COOLDOWN_MS = 30000; var LIVENESS_PROBE_TIMEOUT_MS = parsePositiveIntEnv("AGENTBRIDGE_LIVENESS_PROBE_TIMEOUT_MS", 3000, log); @@ -6920,7 +7447,7 @@ var budgetCoordinator = null; function pairCwd() { const raw = process.cwd(); try { - return realpathSync2(raw); + return realpathSync3(raw); } catch { return raw; } @@ -6929,7 +7456,7 @@ function budgetGuardStateDir() { const override = process.env.BUDGET_STATE_DIR; if (override && override.trim() !== "") return override.trim(); - return join11(homedir5(), ".budget-guard"); + return join12(homedir5(), ".budget-guard"); } function resumeClaimTtlSec() { const totalMs = RESUME_CONFIRM_TIMEOUT_MS * RESUME_INJECT_MAX_ATTEMPTS + RESUME_INJECT_RETRY_MS * Math.max(0, RESUME_INJECT_MAX_ATTEMPTS - 1); @@ -6965,7 +7492,7 @@ function readResumeSignals() { let checkpointExists = false; let checkpointPath; try { - checkpointPath = join11(pairCwd(), ".agent", "checkpoint.md"); + checkpointPath = join12(pairCwd(), ".agent", "checkpoint.md"); checkpointExists = existsSync8(checkpointPath); } catch (error) { log(`resume signal: checkpoint stat failed: ${error instanceof Error ? error.message : String(error)}`); @@ -8102,6 +8629,8 @@ function shutdown(reason, exitCode = 0) { controlServer?.stop(); controlServer = null; codex.stop(); + roomBridge?.stop(); + roomBridge = null; removePidFile(); removeStatusFile(); removeControlToken(); @@ -8152,3 +8681,13 @@ writePidFile(); writeControlTokenPostBind(); armBootDeadline(); bootCodex(); +startRoomBridge({ + cwd: process.cwd(), + emit: (text) => emitToClaude(systemMessage("system_room_event", text)), + log +}).then((handle) => { + if (shuttingDown) + handle.stop(); + else + roomBridge = handle; +}).catch((e) => log(`room bridge start failed: ${String(e)}`)); diff --git a/src/cli/publish.ts b/src/cli/publish.ts index 77ee240..a3d5ca0 100644 --- a/src/cli/publish.ts +++ b/src/cli/publish.ts @@ -16,15 +16,13 @@ */ import { execFileSync } from "node:child_process"; -import { chmodSync, mkdirSync, readFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { BrokerClient } from "../broker-client"; import { buildTaskCompletedEnvelope } from "../task-completed"; import { PublishThrottle } from "../publish-throttle"; import { RoomService } from "../room-service"; -import { SqliteStore } from "../backbone/store/sqlite-store"; import type { Store } from "../backbone/store"; -import { StateDirResolver } from "../state-dir"; +import { openStore, readAuthToken, resolveBrokerUrl, resolveDbPath } from "../collab-store"; const DEFAULT_THROTTLE_MS = 8 * 60 * 60 * 1000; // 8h: a given commit announces ~once per session const DEFAULT_CONNECT_TIMEOUT_MS = 3_000; @@ -110,39 +108,6 @@ function gitContext(cwd: string): { repo?: string; branch?: string; commit?: str }; } -function resolveDbPath(explicit?: string): string { - if (explicit) return explicit; - const env = process.env.AGENTBRIDGE_COLLAB_DB; - if (env && env.length > 0) return env; - return join(new StateDirResolver().dir, "collab.db"); -} - -function resolveBrokerUrl(explicit?: string): string { - if (explicit) return explicit; - const env = process.env.AGENTBRIDGE_BROKER_URL; - if (env && env.length > 0) return env; - return `ws://127.0.0.1:4700/ws`; -} - -/** Open the collab Store with the same 0700 lockdown as `abg auth login` (raw PSK tokens + PII). */ -function openStore(dbPath: string): SqliteStore { - const dir = dirname(dbPath); - mkdirSync(dir, { recursive: true, mode: 0o700 }); - chmodSync(dir, 0o700); - return new SqliteStore(dbPath); -} - -/** Read the logged-in token from `/auth-token` and resolve it to an identity id. */ -/** Read the logged-in PSK token from `/auth-token`. No Store needed — cheap login gate. */ -function readAuthToken(dbPath: string): string | null { - try { - const token = readFileSync(join(dirname(dbPath), "auth-token"), "utf-8").trim(); - return token === "" ? null : token; - } catch { - return null; - } -} - /** connect() reconnects forever against a down broker — race it against a timeout so the hook never hangs. */ async function connectWithTimeout(client: BrokerClient, ms: number): Promise { let timer: ReturnType | undefined; diff --git a/src/collab-store.ts b/src/collab-store.ts new file mode 100644 index 0000000..f0897f4 --- /dev/null +++ b/src/collab-store.ts @@ -0,0 +1,48 @@ +/** + * Shared collab-store resolution helpers (§11.1) — the local path/secret/URL + * lookups every collab entrypoint needs. Extracted so `abg publish`, the daemon + * room bridge, and future consumers resolve the collab DB, auth token, and broker + * URL identically (and lock the dir down identically), instead of each re-deriving + * them. The collab DB holds raw PSK tokens + PII, so its dir is forced to 0700. + */ + +import { chmodSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { SqliteStore } from "./backbone/store/sqlite-store"; +import { StateDirResolver } from "./state-dir"; + +export const DEFAULT_BROKER_URL = "ws://127.0.0.1:4700/ws"; + +/** Resolve the collab DB path: explicit > AGENTBRIDGE_COLLAB_DB > `/collab.db`. */ +export function resolveDbPath(explicit?: string): string { + if (explicit) return explicit; + const env = process.env.AGENTBRIDGE_COLLAB_DB; + if (env && env.length > 0) return env; + return join(new StateDirResolver().dir, "collab.db"); +} + +/** Resolve the broker URL: explicit > AGENTBRIDGE_BROKER_URL > local default. */ +export function resolveBrokerUrl(explicit?: string): string { + if (explicit) return explicit; + const env = process.env.AGENTBRIDGE_BROKER_URL; + if (env && env.length > 0) return env; + return DEFAULT_BROKER_URL; +} + +/** Read the logged-in PSK token from `/auth-token`. No Store needed — cheap login gate. */ +export function readAuthToken(dbPath: string): string | null { + try { + const token = readFileSync(join(dirname(dbPath), "auth-token"), "utf-8").trim(); + return token === "" ? null : token; + } catch { + return null; + } +} + +/** Open the collab Store, locking the containing dir to 0700 (raw PSK tokens + PII live there). */ +export function openStore(dbPath: string): SqliteStore { + const dir = dirname(dbPath); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + return new SqliteStore(dbPath); +} diff --git a/src/daemon.ts b/src/daemon.ts index 13c7e81..4ea8561 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -62,6 +62,7 @@ import { BoundedMessageBuffer } from "./delivery-buffer"; import { ConnectionSession, type ControlSocketData } from "./connection-session"; import { AgentRegistry } from "./agent-registry"; import { RoomManager } from "./room-manager"; +import { startRoomBridge, type RoomBridgeHandle } from "./room-bridge"; const stateDir = new StateDirResolver(); stateDir.ensure(); @@ -235,6 +236,8 @@ const pendingSteerDispatches = new Map(); const BUSY_RETRY_ADVISORY_MS = 15_000; let shuttingDown = false; let bootDeadlineTimer: ReturnType | null = null; +/** v3 last-mile: forwards broker room events into this Claude session (§11.1). Null until bootstrapped / when inert. */ +let roomBridge: RoomBridgeHandle | null = null; let lastAttachStatusSentTs = 0; const ATTACH_STATUS_COOLDOWN_MS = 30_000; // Don't re-send status on rapid reattach @@ -2284,6 +2287,8 @@ function shutdown(reason: string, exitCode = 0) { controlServer?.stop(); controlServer = null; codex.stop(); + roomBridge?.stop(); + roomBridge = null; removePidFile(); removeStatusFile(); removeControlToken(); @@ -2368,3 +2373,20 @@ writeControlTokenPostBind(); // the only thing that releases the control port. bootCodex clears it on success. armBootDeadline(); void bootCodex(); + +// v3 last-mile (§11.1): connect this session to the control-plane broker and +// inject room events (task_completed / presence) into Claude. Fail-inert — a +// not-logged-in / non-collab user (no auth-token, or this cwd not mapped to a +// room) starts nothing, so the v1 single-machine flow is untouched. Injected as +// a `system_room_event` notice (source:"codex"), which renders in Claude and is +// structurally ineligible for the Claude→Codex reply path (no loop). +void startRoomBridge({ + cwd: process.cwd(), + emit: (text) => emitToClaude(systemMessage("system_room_event", text)), + log, +}) + .then((handle) => { + if (shuttingDown) handle.stop(); // a shutdown that raced our async start + else roomBridge = handle; + }) + .catch((e) => log(`room bridge start failed: ${String(e)}`)); diff --git a/src/integration-test/room-bridge.test.ts b/src/integration-test/room-bridge.test.ts new file mode 100644 index 0000000..afa7db6 --- /dev/null +++ b/src/integration-test/room-bridge.test.ts @@ -0,0 +1,101 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Broker } from "../broker"; +import { BrokerClient } from "../broker-client"; +import { InMemoryStore } from "../backbone/store/memory-store"; +import { IdentityService } from "../backbone/identity-service"; +import { StorePskIdentityProvider } from "../backbone/identity/store-psk-identity-provider"; +import { RoomService } from "../room-service"; +import { buildTaskCompletedEnvelope } from "../task-completed"; +import { startRoomBridge } from "../room-bridge"; + +const ROOM = "checkout"; + +async function delay(ms: number): Promise { + await new Promise((r) => setTimeout(r, ms)); +} +async function waitFor(cond: () => boolean, timeoutMs = 2000): Promise { + const start = performance.now(); + while (!cond()) { + if (performance.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await delay(10); + } +} + +async function setup(opts: { mapCwd?: boolean; writeToken?: boolean } = {}) { + const dir = mkdtempSync(join(tmpdir(), "agentbridge-roombridge-")); + const store = new InMemoryStore(); + const svc = new IdentityService(store); + await svc.registerIdentity("alice@x.com", "Alice"); + await svc.registerIdentity("bob@x.com", "Bob"); + const tokenA = await svc.issueToken("alice@x.com"); + const tokenB = await svc.issueToken("bob@x.com"); + const rooms = new RoomService(store); + await rooms.createRoom(ROOM, "Checkout", "alice@x.com"); + await rooms.join(ROOM, "alice@x.com"); + await rooms.join(ROOM, "bob@x.com"); + if (opts.mapCwd !== false) await rooms.mapCwd(dir, ROOM); + if (opts.writeToken !== false) writeFileSync(join(dir, "auth-token"), tokenA, { mode: 0o600 }); + const broker = new Broker({ store, identityProvider: new StorePskIdentityProvider(store), host: "127.0.0.1", port: 0, log: () => {} }); + const { port } = broker.start(); + return { dir, store, tokenA, tokenB, broker, url: `ws://127.0.0.1:${port}/ws`, dbPath: join(dir, "collab.db") }; +} + +describe("startRoomBridge — last-mile broker→session injection (§11.1)", () => { + let cleanup: Array<() => void> = []; + afterEach(() => { + for (const fn of cleanup) fn(); + cleanup = []; + }); + + test("inert when not logged in: roomId null, never connects, emit never fires", async () => { + const { dir, store, broker, url, dbPath } = await setup({ writeToken: false }); + cleanup.push(() => broker.stop(), () => rmSync(dir, { recursive: true, force: true })); + const emitted: string[] = []; + const handle = await startRoomBridge({ cwd: dir, emit: (t) => emitted.push(t), store, dbPath, brokerUrl: url }); + expect(handle.roomId).toBeNull(); + await delay(80); + expect(emitted).toEqual([]); + }); + + test("inert when cwd is not mapped to a room", async () => { + const { dir, store, broker, url, dbPath } = await setup({ mapCwd: false }); + cleanup.push(() => broker.stop(), () => rmSync(dir, { recursive: true, force: true })); + const handle = await startRoomBridge({ cwd: dir, emit: () => {}, store, dbPath, brokerUrl: url }); + expect(handle.roomId).toBeNull(); + }); + + test("live: a peer's task_completed is rendered and injected once (deduped on redelivery)", async () => { + const { dir, store, tokenB, broker, url, dbPath } = await setup(); + cleanup.push(() => broker.stop(), () => rmSync(dir, { recursive: true, force: true })); + + const emitted: string[] = []; + const handle = await startRoomBridge({ cwd: dir, emit: (t) => emitted.push(t), store, dbPath, brokerUrl: url }); + expect(handle.roomId).toBe(ROOM); + cleanup.push(() => handle.stop()); + await delay(80); // let the bridge connect + subscribe + + // bob publishes a task_completed to the room. + const bob = new BrokerClient({ url, token: tokenB }); + await bob.connect(); + cleanup.push(() => bob.close()); + const env = buildTaskCompletedEnvelope({ + roomId: ROOM, + from: { agentId: "bob@x.com", agentType: "codex" }, + summary: "checkout flow shipped", + repo: "app", + branch: "main", + }); + bob.publish(ROOM, env); + await waitFor(() => emitted.length >= 1); + expect(emitted[0]).toContain("🏁"); + expect(emitted[0]).toContain("checkout flow shipped"); + + // Re-publish the SAME envelope (same idempotencyKey) → deduped, still one injection. + bob.publish(ROOM, env); + await delay(150); + expect(emitted.length).toBe(1); + }); +}); diff --git a/src/room-bridge.ts b/src/room-bridge.ts new file mode 100644 index 0000000..4d524f1 --- /dev/null +++ b/src/room-bridge.ts @@ -0,0 +1,138 @@ +/** + * The "last mile" (§11.1): bridge the always-on control-plane broker into a live + * agent session. The daemon starts ONE RoomBridge at boot; it connects to the + * broker as the logged-in identity, subscribes to the cwd-resolved room, and + * injects each room event (task_completed / member_joined / member_left) into the + * Claude session as a system notice. + * + * FAIL-INERT by construction: not logged in (no auth-token) or this cwd isn't + * mapped to a room ⇒ the bridge never starts, so a v1-only / non-collab user is + * completely unaffected. The broker connection auto-reconnects, so a broker that + * isn't up yet (or restarts) doesn't need the daemon to retry. + * + * CONTROL PLANE ONLY: it forwards structured Envelopes (a rendered one-line + * notice), never repo files — code sync is git's job (§2.6). + */ + +import { BrokerClient } from "./broker-client"; +import { RoomService } from "./room-service"; +import { openStore, readAuthToken, resolveBrokerUrl, resolveDbPath } from "./collab-store"; +import type { Store } from "./backbone/store"; +import type { Envelope } from "./backbone/envelope"; + +export interface RoomBridgeDeps { + /** The pair's project dir, resolved to a room via the cwd→room map (§2.4). */ + cwd: string; + /** Inject a rendered one-line room-event notice into the live Claude session. */ + emit: (text: string) => void; + log?: (msg: string) => void; + // --- test seams --- + dbPath?: string; + brokerUrl?: string; + store?: Store; +} + +export interface RoomBridgeHandle { + stop(): void; + /** The resolved room, or null when the bridge stayed inert (not logged in / no room). */ + roomId: string | null; +} + +const INERT: RoomBridgeHandle = { stop: () => {}, roomId: null }; +const SEEN_CAP = 500; // bounded idempotency-key memory — drop a redelivered envelope once + +function label(env: Envelope): string { + const dn = (env.payload as { displayName?: unknown } | undefined)?.displayName; + return env.from?.name || (typeof dn === "string" ? dn : "") || env.from?.agentId || "某成员"; +} + +/** + * Render a room Envelope into a one-line Chinese notice, or null for kinds the + * MVP doesn't surface (those are simply not injected — never a raw payload dump). + */ +export function renderRoomEvent(env: Envelope): string | null { + const who = label(env); + switch (env.kind) { + case "task_completed": { + const p = (env.payload ?? {}) as { + summary?: string; + repo?: string; + branch?: string; + commit?: string; + unblocks?: string[]; + }; + const where = [p.repo, p.branch].filter(Boolean).join("@"); + const loc = [where, p.commit].filter(Boolean).join(" "); + const unblocks = p.unblocks && p.unblocks.length > 0 ? ` · 解锁: ${p.unblocks.join(", ")}` : ""; + return `🏁 ${who} 完成任务:${p.summary ?? "(无摘要)"}${loc ? ` (${loc})` : ""}${unblocks}`; + } + case "member_joined": { + const host = (env.payload as { host?: unknown } | undefined)?.host; + return `👋 ${who} 加入房间${typeof host === "string" && host ? `(${host})` : ""}`; + } + case "member_left": + return `👋 ${who} 离开房间`; + default: + return null; + } +} + +/** + * Start the room bridge for `cwd`. Returns an INERT handle (roomId=null) when not + * logged in or the cwd has no room — never throws, so a daemon boot is never + * blocked by collab being absent. + */ +export async function startRoomBridge(deps: RoomBridgeDeps): Promise { + const log = deps.log ?? (() => {}); + const dbPath = resolveDbPath(deps.dbPath); + const token = readAuthToken(dbPath); + if (!token) { + log("room bridge: not logged in (no auth-token) — inactive"); + return INERT; + } + + const ownStore = !deps.store; + const store = deps.store ?? openStore(dbPath); + let roomId: string | null; + try { + roomId = await new RoomService(store).resolveRoomForCwd(deps.cwd); + } finally { + // The bridge only needs the Store to resolve the room; the BrokerClient holds + // the live connection. Close our own handle so we don't pin the DB open. + if (ownStore) await store.close(); + } + if (!roomId) { + log(`room bridge: ${deps.cwd} not mapped to a room — inactive`); + return INERT; + } + + const room = roomId; + const seen = new Set(); + const client = new BrokerClient({ + url: resolveBrokerUrl(deps.brokerUrl), + token, + presence: { agentType: "claude" }, + log, + }); + + client.onEvent((_topic, env) => { + // Dedup any redelivery (e.g. offline replay racing a live copy) by idempotency + // key, so the same event is never injected twice. + const key = env.idempotencyKey; + if (typeof key === "string" && key.length > 0) { + if (seen.has(key)) return; + seen.add(key); + if (seen.size > SEEN_CAP) seen.delete(seen.values().next().value as string); // bounded FIFO-ish + } + const text = renderRoomEvent(env); + if (text) deps.emit(text); + }); + client.subscribe(room); // queued in the subscription set; sent on the first welcome + // Fire the connection but don't block daemon boot on it; BrokerClient reconnects + // on its own, so a broker that isn't up yet will be picked up later. A bad token + // rejects (won't retry) — swallow it; everything else stays pending + retries. + client.connect().catch((e) => log(`room bridge: connect failed — ${String(e)}`)); + log(`room bridge: subscribed to room ${room}`); + + return { stop: () => client.close(), roomId: room }; +} diff --git a/src/unit-test/room-bridge-render.test.ts b/src/unit-test/room-bridge-render.test.ts new file mode 100644 index 0000000..c1b18d5 --- /dev/null +++ b/src/unit-test/room-bridge-render.test.ts @@ -0,0 +1,86 @@ +import { describe, test, expect } from "bun:test"; +import { renderRoomEvent } from "../room-bridge"; +import { buildTaskCompletedEnvelope } from "../task-completed"; +import { buildPresenceEnvelope } from "../presence"; +import type { Envelope } from "../backbone/envelope"; + +describe("renderRoomEvent — broker Envelope → one-line Claude notice", () => { + test("task_completed: summary + repo@branch commit + unblocks", () => { + const env = buildTaskCompletedEnvelope({ + roomId: "r1", + from: { agentId: "bob@x.com", agentType: "codex" }, + summary: "auth contract landed", + repo: "app", + branch: "main", + commit: "abc123", + unblocks: ["alice@x.com"], + }); + const text = renderRoomEvent(env)!; + expect(text).toContain("🏁"); + expect(text).toContain("bob@x.com"); // task_completed has no displayName ⇒ agentId + expect(text).toContain("auth contract landed"); + expect(text).toContain("app@main"); + expect(text).toContain("abc123"); + expect(text).toContain("解锁: alice@x.com"); + }); + + test("task_completed: minimal (summary only) omits the location parens and unblocks", () => { + const env = buildTaskCompletedEnvelope({ + roomId: "r1", + from: { agentId: "bob@x.com", agentType: "codex" }, + summary: "done", + }); + const text = renderRoomEvent(env)!; + expect(text).toBe("🏁 bob@x.com 完成任务:done"); + }); + + test("member_joined: uses displayName + host when present", () => { + const env = buildPresenceEnvelope({ + kind: "member_joined", + roomId: "r1", + agentId: "alice@x.com", + displayName: "Alice", + meta: { host: "tailnet-1" }, + }); + expect(renderRoomEvent(env)).toBe("👋 Alice 加入房间(tailnet-1)"); + }); + + test("member_left: displayName, no host", () => { + const env = buildPresenceEnvelope({ kind: "member_left", roomId: "r1", agentId: "alice@x.com", displayName: "Alice" }); + expect(renderRoomEvent(env)).toBe("👋 Alice 离开房间"); + }); + + test("unknown kinds are not rendered (null, never a raw payload dump)", () => { + const env: Envelope = { + roomId: "r1", + messageId: "m", + traceId: "t", + idempotencyKey: "k", + from: { agentId: "x", agentType: "claude" }, + kind: "some_future_kind", + payload: { secret: "leak" }, + timestamp: 1, + deliveryMode: "online_only", + }; + expect(renderRoomEvent(env)).toBeNull(); + }); + + test("label falls back from.name → payload.displayName → agentId → 某成员", () => { + const base = { + roomId: "r1", + messageId: "m", + traceId: "t", + idempotencyKey: "k", + kind: "member_left" as const, + timestamp: 1, + deliveryMode: "online_only" as const, + }; + expect(renderRoomEvent({ ...base, from: { agentId: "id", agentType: "c", name: "Named" }, payload: {} })).toBe( + "👋 Named 离开房间", + ); + expect(renderRoomEvent({ ...base, from: { agentId: "id", agentType: "c" }, payload: { displayName: "DN" } })).toBe( + "👋 DN 离开房间", + ); + expect(renderRoomEvent({ ...base, from: { agentId: "id", agentType: "c" }, payload: {} })).toBe("👋 id 离开房间"); + }); +});