Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions plugins/agentbridge/server/bridge-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -14707,10 +14707,10 @@ function defineNumber(value, fallback) {
}
var BUILD_INFO = Object.freeze({
version: defineString("0.1.24", "0.0.0-source"),
commit: defineString("6b31d53", "source"),
commit: defineString("3b71186", "source"),
bundle: defineBundle("plugin"),
contractVersion: defineNumber(1, CONTRACT_VERSION),
codeHash: defineString("539a761f8766", "source")
codeHash: defineString("d4792cf81ed0", "source")
});
function sameRuntimeContract(a, b) {
if (!a || !b)
Expand Down
4 changes: 2 additions & 2 deletions plugins/agentbridge/server/daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ function defineNumber(value, fallback) {
}
var BUILD_INFO = Object.freeze({
version: defineString("0.1.24", "0.0.0-source"),
commit: defineString("6b31d53", "source"),
commit: defineString("3b71186", "source"),
bundle: defineBundle("plugin"),
contractVersion: defineNumber(1, CONTRACT_VERSION),
codeHash: defineString("539a761f8766", "source")
codeHash: defineString("d4792cf81ed0", "source")
});
function daemonStatusBuildInfo() {
return { ...BUILD_INFO };
Expand Down
11 changes: 11 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ async function main(command: string | undefined, restArgs: string[]) {
const { runBroker } = await import("./cli/broker");
await runBroker(restArgs);
break;
case "room":
const { runRoom } = await import("./cli/room");
await runRoom(restArgs);
break;
case "join":
const { runJoin } = await import("./cli/room");
await runJoin(restArgs);
break;
case "--help":
case "-h":
case undefined:
Expand Down Expand Up @@ -173,6 +181,9 @@ Commands:
budget [--json] Show both agents' subscription quota snapshot (5h/weekly, drift, pause state)
auth login --id <email|github> --name <displayName>
Issue a collaboration PSK token and write it to <state>/auth-token (0600)
room create <name> | room list
Create a collaboration room (id = slugified name) or list rooms
join <roomId> Join a room and auto-join this directory next time (§2.4)
logs [--codex] [-f] [-n N]
Tail this pair's daemon log (or the codex wrapper log with
--codex). -n N: last N lines (default 100). -f: follow/stream.
Expand Down
188 changes: 188 additions & 0 deletions src/cli/room.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* `abg room create/list` + `abg join` — collaboration room CLI (§2.3–2.4).
*
* A room = one requirement/workflow, cross-repo + cross-person. Membership binds
* to the logical agent (the logged-in collab identity) and is persistent. `join`
* also records a cwd→room mapping so this directory auto-joins next time (§2.4).
*
* Shares the same collab Store + 0700 directory lockdown as `abg auth login` /
* `abg broker start`; the logged-in identity is resolved from `<state>/auth-token`.
*/

import { chmodSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { RoomService } from "../room-service";
import { SqliteStore } from "../backbone/store/sqlite-store";
import type { RoomRecord, Store } from "../backbone/store";
import { StateDirResolver } from "../state-dir";

/** Resolve the collab DB path: explicit > env override > `<state>/collab.db`. */
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 currently logged-in collab identity id from `<collabDir>/auth-token`
* (written by `abg auth login`). The token file is a local secret; a missing or
* unresolvable token means the user has not logged in yet.
*/
export async function currentIdentityId(store: Store, dbPath: string): Promise<string> {
const tokenFile = join(dirname(dbPath), "auth-token");
let token: string;
try {
token = readFileSync(tokenFile, "utf-8").trim();
} catch {
throw new Error("未找到登录令牌,请先运行 abg auth login");
}
if (token === "") throw new Error("登录令牌为空,请先运行 abg auth login");
const identityId = await store.resolveToken(token);
if (!identityId) throw new Error("登录令牌无效,请先运行 abg auth login");
return identityId;
}

/**
* Turn a human room name into a room id: lowercase, whitespace→`-`, keep unicode
* letters/numbers (Chinese-first, so "结账" is valid) + dash, drop everything
* else, collapse runs of `-`, trim leading/trailing `-`. Throws when nothing
* usable remains (e.g. a name of only punctuation).
*/
export function slugify(name: string): string {
// Keep unicode letters/numbers (the project is Chinese-first, so "结账" is a
// valid room id) + dash; whitespace → dash; drop everything else. The room id
// is an internal topic key / Store key, not a URL, so CJK is fine.
const slug = name
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^\p{L}\p{N}-]/gu, "")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
if (slug === "") throw new Error(`无法从「${name}」生成有效的房间 ID(需含字母或数字)`);
return slug;
}

/** Open the collab Store with the same 0700 lockdown as `abg auth login`. */
function openStore(dbPath: string): SqliteStore {
const dir = dirname(dbPath);
// The collab DB holds raw PSK tokens + PII; lock the containing dir to 0700
// (matches auth.ts/broker.ts — bun:sqlite files are 0644 so dir is the gate).
mkdirSync(dir, { recursive: true, mode: 0o700 });
chmodSync(dir, 0o700);
return new SqliteStore(dbPath);
}

/**
* Create a room owned by the logged-in identity (roomId = slugify(name)), join
* the creator to it, and map the cwd so this directory auto-joins next time. If
* the slug already exists it is reused (created=false) — the creator still joins.
*/
export async function createRoom(opts: {
name: string;
cwd?: string;
dbPath?: string;
}): Promise<{ roomId: string; created: boolean }> {
const dbPath = resolveDbPath(opts.dbPath);
const store = openStore(dbPath);
try {
const roomId = slugify(opts.name);
const createdBy = await currentIdentityId(store, dbPath);
const svc = new RoomService(store);
const existed = (await svc.getRoom(roomId)) !== null;
await svc.createRoom(roomId, opts.name, createdBy); // INSERT OR IGNORE — reuse if existed
await svc.join(roomId, createdBy); // the creator is a member
await svc.mapCwd(opts.cwd ?? process.cwd(), roomId);
return { roomId, created: !existed };
} finally {
await store.close();
}
}

/** List all rooms in the collab store. */
export async function listRooms(opts: { dbPath?: string }): Promise<RoomRecord[]> {
const dbPath = resolveDbPath(opts.dbPath);
const store = openStore(dbPath);
try {
return await new RoomService(store).listRooms();
} finally {
await store.close();
}
}

/**
* Join the logged-in identity to a room and map the cwd to it (so the same
* directory auto-joins next time, §2.4). Throws if the room does not exist.
*/
export async function joinRoom(opts: {
roomId: string;
cwd?: string;
dbPath?: string;
}): Promise<{ roomId: string; agentId: string }> {
const dbPath = resolveDbPath(opts.dbPath);
const store = openStore(dbPath);
try {
const agentId = await currentIdentityId(store, dbPath);
const svc = new RoomService(store);
if ((await svc.getRoom(opts.roomId)) === null) {
throw new Error(`房间不存在:${opts.roomId}(先用 abg room create 创建)`);
}
await svc.join(opts.roomId, agentId);
await svc.mapCwd(opts.cwd ?? process.cwd(), opts.roomId);
return { roomId: opts.roomId, agentId };
} finally {
await store.close();
}
}

const ROOM_USAGE = "用法:abg room create <name> | abg room list";

/** Dispatch `abg room <subcommand>`: `create <name>` / `list`. */
export async function runRoom(args: string[]): Promise<void> {
const sub = args[0];
switch (sub) {
case "create": {
const name = args.slice(1).join(" ").trim();
if (!name) {
console.error("缺少房间名称。");
console.error(ROOM_USAGE);
process.exit(1);
return;
}
const { roomId, created } = await createRoom({ name });
console.log(
created
? `已创建房间 ${roomId}(${name}),你已加入;该目录今后会自动加入`
: `房间 ${roomId} 已存在,已为你加入;该目录今后会自动加入`,
);
break;
}
case "list": {
const rooms = await listRooms({});
if (rooms.length === 0) {
console.log("(暂无房间)");
break;
}
for (const r of rooms) {
console.log(`${r.roomId}\t${r.name}\t${r.createdBy}`);
}
break;
}
default:
console.error(`未知的 room 子命令:${sub ?? "(空)"}`);
console.error(ROOM_USAGE);
process.exit(1);
}
}

/** Dispatch `abg join <roomId>`. */
export async function runJoin(args: string[]): Promise<void> {
const roomId = args[0];
if (!roomId) {
console.error("用法:abg join <roomId>");
process.exit(1);
return;
}
const result = await joinRoom({ roomId });
console.log(`已加入房间 ${result.roomId}(agent ${result.agentId});该目录今后会自动加入`);
}
83 changes: 83 additions & 0 deletions src/room-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { realpathSync } from "node:fs";
import type { Store, RoomRecord } from "./backbone/store";

export interface AutoJoinResult {
roomId: string;
/** true if this call newly joined the agent; false if it was already a member. */
joined: boolean;
}

/**
* §2.3–2.4 room service over a Store.
*
* A room = one requirement/workflow, cross-repo + cross-person. Anyone can create
* a room; others join. Membership binds to a LOGICAL AGENT id and is PERSISTENT
* (survives restart) — never a session id (§2.3). Three join paths (§2.4): the
* cwd→room map (automatic), explicit `join`, and worktree (just another cwd).
*
* The cwd→room map keys on the REALPATH of the workspace dir so symlinks/`..`
* don't fork a room; it never writes anything into the repo (no marker files that
* could be committed). Broker subscription on join is the adapter's job
* (BrokerClient.subscribe) — this service owns only the persistent membership.
*/
export class RoomService {
constructor(private readonly store: Store) {}

// --- rooms ---
async createRoom(roomId: string, name: string, createdBy: string): Promise<void> {
await this.store.createRoom(roomId, name, createdBy);
}
async getRoom(roomId: string): Promise<RoomRecord | null> {
return this.store.getRoom(roomId);
}
async listRooms(): Promise<RoomRecord[]> {
return this.store.listRooms();
}

// --- membership (persistent, bound to logical agent id) ---
async join(roomId: string, agentId: string): Promise<void> {
await this.store.addMember(roomId, agentId);
}
async leave(roomId: string, agentId: string): Promise<void> {
await this.store.removeMember(roomId, agentId);
}
async getMembers(roomId: string): Promise<string[]> {
return this.store.getMembers(roomId);
}
async getRoomsForAgent(agentId: string): Promise<string[]> {
return this.store.getRoomsForAgent(agentId);
}
async isMember(roomId: string, agentId: string): Promise<boolean> {
return (await this.store.getMembers(roomId)).includes(agentId);
}

// --- cwd → room map (§2.4 automatic join) ---
async mapCwd(workspacePath: string, roomId: string): Promise<void> {
await this.store.mapCwd(this.normalizeCwd(workspacePath), roomId);
}
async resolveRoomForCwd(workspacePath: string): Promise<string | null> {
return this.store.getRoomForCwd(this.normalizeCwd(workspacePath));
}

/**
* Resolve `workspacePath` to its mapped room and join `agentId` to it if not
* already a member. Returns null when the cwd has no mapping (caller falls back
* to an explicit join). The primary auto-join path (§2.4).
*/
async autoJoinByCwd(workspacePath: string, agentId: string): Promise<AutoJoinResult | null> {
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 };
}

/** Realpath the workspace dir so symlinks/`..` map to the same room; fall back on error. */
private normalizeCwd(workspacePath: string): string {
try {
return realpathSync(workspacePath);
} catch {
return workspacePath;
}
}
}
Loading