From 3603bd60df26e5ef4ea9f809e904a45911506e5e Mon Sep 17 00:00:00 2001 From: Steven Roomberg Date: Sun, 16 Aug 2026 21:17:56 +0000 Subject: [PATCH 1/4] feat: add VS Code adapter and extract @chorus/client Share JoinClient/RelayServer in packages/client for reuse across hosts. Add packages/vscode with share/join/chat commands and a Session sidebar that speaks the same chorus-relay wire protocol as the OpenCode plugin. --- CHANGELOG.md | 2 + README.md | 15 +- bun.lock | 32 ++ docs/DECISIONS.md | 5 +- docs/STATUS.md | 7 + package.json | 16 +- packages/client/dist/index.d.ts | 3 + packages/client/dist/index.d.ts.map | 1 + packages/client/dist/index.js | 3 + packages/client/dist/index.js.map | 1 + packages/client/dist/join.d.ts | 30 ++ packages/client/dist/join.d.ts.map | 1 + packages/client/dist/join.js | 123 ++++++ packages/client/dist/join.js.map | 1 + packages/client/dist/relay.d.ts | 63 ++++ packages/client/dist/relay.d.ts.map | 1 + packages/client/dist/relay.js | 270 ++++++++++++++ packages/client/dist/relay.js.map | 1 + packages/client/package.json | 28 ++ packages/client/src/index.ts | 10 + packages/client/src/join.ts | 158 ++++++++ packages/client/src/relay.ts | 341 +++++++++++++++++ packages/{plugin => client}/test/join.test.ts | 4 +- .../{plugin => client}/test/relay.test.ts | 2 +- packages/client/tsconfig.json | 19 + packages/plugin/dist/index.d.ts | 2 +- packages/plugin/dist/join/index.d.ts | 30 +- packages/plugin/dist/join/index.d.ts.map | 2 +- packages/plugin/dist/join/index.js | 124 +------ packages/plugin/dist/join/index.js.map | 2 +- packages/plugin/dist/relay/index.d.ts | 63 +--- packages/plugin/dist/relay/index.d.ts.map | 2 +- packages/plugin/dist/relay/index.js | 273 +------------- packages/plugin/dist/relay/index.js.map | 2 +- packages/plugin/package.json | 3 +- packages/plugin/src/join/index.ts | 160 +------- packages/plugin/src/relay/index.ts | 349 +----------------- packages/vscode/README.md | 61 +++ packages/vscode/media/chorus.svg | 5 + packages/vscode/package.json | 135 +++++++ packages/vscode/src/controller.ts | 317 ++++++++++++++++ packages/vscode/src/extension.ts | 156 ++++++++ packages/vscode/src/format.ts | 19 + packages/vscode/src/sessionView.ts | 134 +++++++ packages/vscode/test/format.test.ts | 40 ++ packages/vscode/tsconfig.json | 19 + 46 files changed, 2025 insertions(+), 1010 deletions(-) create mode 100644 packages/client/dist/index.d.ts create mode 100644 packages/client/dist/index.d.ts.map create mode 100644 packages/client/dist/index.js create mode 100644 packages/client/dist/index.js.map create mode 100644 packages/client/dist/join.d.ts create mode 100644 packages/client/dist/join.d.ts.map create mode 100644 packages/client/dist/join.js create mode 100644 packages/client/dist/join.js.map create mode 100644 packages/client/dist/relay.d.ts create mode 100644 packages/client/dist/relay.d.ts.map create mode 100644 packages/client/dist/relay.js create mode 100644 packages/client/dist/relay.js.map create mode 100644 packages/client/package.json create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/src/join.ts create mode 100644 packages/client/src/relay.ts rename packages/{plugin => client}/test/join.test.ts (96%) rename packages/{plugin => client}/test/relay.test.ts (98%) create mode 100644 packages/client/tsconfig.json create mode 100644 packages/vscode/README.md create mode 100644 packages/vscode/media/chorus.svg create mode 100644 packages/vscode/package.json create mode 100644 packages/vscode/src/controller.ts create mode 100644 packages/vscode/src/extension.ts create mode 100644 packages/vscode/src/format.ts create mode 100644 packages/vscode/src/sessionView.ts create mode 100644 packages/vscode/test/format.test.ts create mode 100644 packages/vscode/tsconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 70b2cea..7d0f12e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- VS Code extension (`packages/vscode`) — share/join/chat over the same relay protocol; Session sidebar + commands +- Extract `@chorus/client` (`JoinClient` + `RelayServer`) for reuse by OpenCode, VS Code, and future adapters - Real-time shared transcript for all agents: fan out collaborator prompts to every joiner, abort joiner local LLM, mirror `[Host]`/`[name]`/`[AI]` (prefer web UI for live view) - Joiner mirrors host prompts and AI replies into its OpenCode session transcript (`[Host]:` / `[AI]:`), including history replay on join (replaces toast-only viewing) - Fix: prevent host/joiner feedback loop when the same agent both shares and joins (block join while sharing; never mirror while hosting; drop echoed `[AI]/`/`[Host]:` collab lines) diff --git a/README.md b/README.md index e0ff948..c4744e5 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Collaborative OpenCode session sharing. Pair-program a live AI session from anot ## How it works -Chorus is an [OpenCode](https://github.com/sst/opencode) plugin plus a **Rust WebSocket relay** (`chorus-relay`). +Chorus is an [OpenCode](https://github.com/sst/opencode) plugin (plus VS Code / other adapters) and a **Rust WebSocket relay** (`chorus-relay`). ``` Host runs opencode → plugin loads @@ -50,22 +50,29 @@ One monorepo, two ecosystems, one wire contract: | Path | Artifact | Description | |---|---|---| | `packages/plugin` | npm `@chorus/plugin` | OpenCode plugin — tools, hooks, spawns/manages relay | +| `packages/client` | npm `@chorus/client` | Shared `JoinClient` + `RelayServer` for host adapters | +| `packages/vscode` | VS Code extension `chorus` | Share/join Chorus sessions from VS Code | +| `packages/zed` | Zed extension `chorus` | Joiner adapter (WASM + MCP → `chorus-zed-helper`) | | `packages/shared` | npm `@chorus/shared` | TypeScript types + codecs for joiner and host-control protocols | | `crates/chorus-relay` | `chorus-relay` binary | Rust WebSocket relay (`/ws` joiners, `/host` control plane) | +| `crates/chorus-zed-helper` | `chorus-zed-helper` binary | Native Chorus join client (CLI + MCP) for Zed | | `protocol/` | fixtures (not published) | Canonical JSON examples both TS and Rust must deserialize | -Root `package.json` scripts are the only task entry (`build`, `test`, `typecheck`). Bun workspaces own `packages/*`; Cargo owns `crates/*`. +Root `package.json` scripts are the only task entry (`build`, `test`, `typecheck`). Bun workspaces own `packages/*`; Cargo owns `crates/*` (Zed’s `packages/zed` is built separately for `wasm32-wasip2`). ## Development ```sh bun install -bun run build # release relay + TS packages -bun run test # relay tests + TS/Bun tests (includes protocol fixtures) +bun run build # release relay + helper + TS packages +bun run test # relay/helper tests + TS/Bun tests (includes protocol fixtures) bun run typecheck cargo test -p chorus-relay +cargo test -p chorus-zed-helper ``` +Zed joiner (optional): see [packages/zed/README.md](packages/zed/README.md) for Install Dev Extension + `chorus-zed-helper` setup. + ### Multi-agent local testing Run several isolated OpenCode servers on one machine (each with Chorus loaded) and drive share/join over the HTTP API: diff --git a/bun.lock b/bun.lock index e543bac..0f639d2 100644 --- a/bun.lock +++ b/bun.lock @@ -8,12 +8,25 @@ "typescript": "^5.7.3", }, }, + "packages/client": { + "name": "@chorus/client", + "version": "0.1.0", + "dependencies": { + "@chorus/shared": "workspace:*", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^22.10.7", + "typescript": "^5.7.3", + }, + }, "packages/plugin": { "name": "@chorus/plugin", "version": "0.1.6", "dependencies": { "@aws-sdk/client-s3": "^3.726.0", "@aws-sdk/lib-storage": "^3.726.0", + "@chorus/client": "workspace:*", "@chorus/shared": "workspace:*", "zod": "4.1.8", }, @@ -41,6 +54,19 @@ "vitest": "^2.1.8", }, }, + "packages/vscode": { + "name": "chorus", + "version": "0.1.0", + "dependencies": { + "@chorus/client": "workspace:*", + "@chorus/shared": "workspace:*", + }, + "devDependencies": { + "@types/node": "^22.10.7", + "@types/vscode": "^1.90.0", + "typescript": "^5.7.3", + }, + }, }, "packages": { "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], @@ -107,6 +133,8 @@ "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], + "@chorus/client": ["@chorus/client@workspace:packages/client"], + "@chorus/plugin": ["@chorus/plugin@workspace:packages/plugin"], "@chorus/shared": ["@chorus/shared@workspace:packages/shared"], @@ -235,6 +263,8 @@ "@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], + "@types/vscode": ["@types/vscode@1.125.0", "", {}, "sha512-0icm/ZQAaism87P0ekHqi4/Ju9du+Tm0RUW+y7vqRsxY2cY0FNRX1nAnaW7nT6npPt2tfHiheZ55Zm9UhqonFA=="], + "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], "@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="], @@ -265,6 +295,8 @@ "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + "chorus": ["chorus@workspace:packages/vscode"], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 38e25fc..b1bd64b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -48,8 +48,9 @@ Recommended shape: ``` - **v1:** OpenCode adapter = this repo’s plugin + local **Rust** `chorus-relay` -- **v1.x:** keep `@chorus/shared` as the stable joiner + host-control contract -- **v2:** optional second adapter (Codex app-server is the best #2) only after OpenCode UX is solid +- **v1.x:** keep `@chorus/shared` as the stable joiner + host-control contract; `@chorus/client` holds `JoinClient` / `RelayServer` for non-OpenCode hosts +- **v1.x adapters in-monorepo:** VS Code (`packages/vscode`) and Zed (when present) — same wire protocol, host-specific UI only +- **v2:** optional further adapters (Codex app-server remains a strong candidate) once OpenCode UX is solid; split adapter packages into separate repos only when publish/CI ownership diverges **Do not** make Claude Code / Amp the primary host — large audiences, but proprietary control planes. Treat them as distribution adapters later, not the core. diff --git a/docs/STATUS.md b/docs/STATUS.md index da924ee..678d4d5 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -55,8 +55,15 @@ Differentiation vs nearby OpenCode plugins (`opencode-live`, `opencode-sessions` `scripts/multi-agent.ts` (via `bun run multi-agent`) can spawn N isolated `opencode serve` instances with the Chorus plugin, then automate `/chorus-share` + `/chorus-join` through OpenCode’s `/session/:id/command` API. Also includes `relay-stress` for concurrent protocol joiners without OpenCode. +## Adapter surface (monorepo) + +- **OpenCode** (`packages/plugin`) — primary host; full LLM loop + transcript mirror. +- **VS Code** (`packages/vscode`) — share/join via `@chorus/client`; sidebar transcript; joiner `collab.input` works against an OpenCode host. VS Code share does **not** drive OpenCode’s model — publish host lines manually or pair with OpenCode. +- **Zed** — joiner-first scaffold when present under `packages/zed` / related crates (WASM extension limits; helper binary pattern). + ## Explicit non-goals (for now) - Rebuilding a full coding agent harness from scratch. - Competing with multi-agent orchestration plugins. - Making the browser app the primary UX again (optional later for non-OpenCode observers). +- Splitting the monorepo until adapter publish cadence actually diverges. diff --git a/package.json b/package.json index cefc530..cb9b84a 100644 --- a/package.json +++ b/package.json @@ -7,15 +7,19 @@ ], "scripts": { "build:relay": "cargo build -p chorus-relay --release", + "build:zed-helper": "cargo build -p chorus-zed-helper --release", "build:shared": "bun run --filter @chorus/shared build", + "build:client": "bun run --filter @chorus/client build", "build:plugin": "bun run --filter @chorus/plugin build", - "build:ts": "bun run build:shared && bun run build:plugin", - "build": "bun run build:relay && bun run build:ts", + "build:vscode": "bun run --filter chorus build", + "build:ts": "bun run build:shared && bun run build:client && bun run build:plugin && bun run build:vscode", + "build": "bun run build:relay && bun run build:zed-helper && bun run build:ts", "test:relay": "cargo test -p chorus-relay", - "test:ts": "bun run build:shared && bun run --filter @chorus/shared test && bun run --filter @chorus/plugin test", - "test": "bun run build:relay && bun run test:relay && bun run test:ts", - "typecheck": "bun run build:shared && bun run --filter @chorus/shared typecheck && bun run --filter @chorus/plugin typecheck", - "clean": "bun run --filter @chorus/shared clean && bun run --filter @chorus/plugin clean && cargo clean", + "test:zed-helper": "cargo test -p chorus-zed-helper", + "test:ts": "bun run build:shared && bun run build:client && bun run --filter @chorus/shared test && bun run --filter @chorus/client test && bun run --filter @chorus/plugin test && bun run --filter chorus test", + "test": "bun run build:relay && bun run test:relay && bun run build:zed-helper && bun run test:zed-helper && bun run test:ts", + "typecheck": "bun run build:shared && bun run build:client && bun run --filter @chorus/shared typecheck && bun run --filter @chorus/client typecheck && bun run --filter @chorus/plugin typecheck && bun run --filter chorus typecheck", + "clean": "bun run --filter @chorus/shared clean && bun run --filter @chorus/client clean && bun run --filter @chorus/plugin clean && bun run --filter chorus clean && cargo clean", "multi-agent": "bun scripts/multi-agent.ts", "test:multi-agent": "bun scripts/multi-agent.ts setup --agents 2 && bun scripts/multi-agent.ts up --agents 2 && bun scripts/multi-agent.ts smoke && bun scripts/multi-agent.ts pair && bun scripts/multi-agent.ts down", "test:relay-stress": "bun scripts/multi-agent.ts relay-stress --agents 5", diff --git a/packages/client/dist/index.d.ts b/packages/client/dist/index.d.ts new file mode 100644 index 0000000..97ea899 --- /dev/null +++ b/packages/client/dist/index.d.ts @@ -0,0 +1,3 @@ +export { JoinClient, type JoinState, type JoinStatus, } from "./join.js"; +export { RelayServer, relayOptionsFromEnv, type RelayServerOptions, } from "./relay.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/client/dist/index.d.ts.map b/packages/client/dist/index.d.ts.map new file mode 100644 index 0000000..1beee6d --- /dev/null +++ b/packages/client/dist/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,KAAK,SAAS,EACd,KAAK,UAAU,GAChB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,WAAW,EACX,mBAAmB,EACnB,KAAK,kBAAkB,GACxB,MAAM,YAAY,CAAC"} \ No newline at end of file diff --git a/packages/client/dist/index.js b/packages/client/dist/index.js new file mode 100644 index 0000000..c71c32b --- /dev/null +++ b/packages/client/dist/index.js @@ -0,0 +1,3 @@ +export { JoinClient, } from "./join.js"; +export { RelayServer, relayOptionsFromEnv, } from "./relay.js"; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/client/dist/index.js.map b/packages/client/dist/index.js.map new file mode 100644 index 0000000..37cad03 --- /dev/null +++ b/packages/client/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,GAGX,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,WAAW,EACX,mBAAmB,GAEpB,MAAM,YAAY,CAAC"} \ No newline at end of file diff --git a/packages/client/dist/join.d.ts b/packages/client/dist/join.d.ts new file mode 100644 index 0000000..8472425 --- /dev/null +++ b/packages/client/dist/join.d.ts @@ -0,0 +1,30 @@ +import type { SessionEvent, ConnectedUser } from "@chorus/shared"; +export type JoinStatus = "connecting" | "connected" | "disconnected" | "error"; +export interface JoinState { + status: JoinStatus; + sessionId: string; + users: ConnectedUser[]; + recentEvents: SessionEvent[]; + error?: string; +} +export declare class JoinClient { + private readonly relayUrl; + private readonly token; + private readonly displayName; + private ws; + private state; + private onEvent?; + private onChatMessage?; + private onTyping?; + constructor(relayUrl: string, token: string, displayName: string); + connect(): Promise; + sendInput(content: string): void; + sendChat(content: string): void; + setChatHandler(fn: (displayName: string | undefined, content: string) => void): void; + setTypingHandler(fn: (displayName: string | undefined) => void): void; + setEventHandler(fn: (event: SessionEvent) => void): void; + sendTyping(): void; + getState(): Readonly; + disconnect(): void; +} +//# sourceMappingURL=join.d.ts.map \ No newline at end of file diff --git a/packages/client/dist/join.d.ts.map b/packages/client/dist/join.d.ts.map new file mode 100644 index 0000000..961b323 --- /dev/null +++ b/packages/client/dist/join.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"join.d.ts","sourceRoot":"","sources":["../src/join.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAOlE,MAAM,MAAM,UAAU,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAC;AAE/E,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,UAAU,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,YAAY,EAAE,YAAY,EAAE,CAAC;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,qBAAa,UAAU;IAQnB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAT9B,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,KAAK,CAAY;IACzB,OAAO,CAAC,OAAO,CAAC,CAAgC;IAChD,OAAO,CAAC,aAAa,CAAC,CAA6D;IACnF,OAAO,CAAC,QAAQ,CAAC,CAA4C;gBAG1C,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM;IAUtC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAoFxB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAKhC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAK/B,cAAc,CAAC,EAAE,EAAE,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI;IAIpF,gBAAgB,CAAC,EAAE,EAAE,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,GAAG,IAAI;IAIrE,eAAe,CAAC,EAAE,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,GAAG,IAAI;IAIxD,UAAU,IAAI,IAAI;IAKlB,QAAQ,IAAI,QAAQ,CAAC,SAAS,CAAC;IAI/B,UAAU,IAAI,IAAI;CAKnB"} \ No newline at end of file diff --git a/packages/client/dist/join.js b/packages/client/dist/join.js new file mode 100644 index 0000000..1d3bbc5 --- /dev/null +++ b/packages/client/dist/join.js @@ -0,0 +1,123 @@ +import { encodeMessage, decodeServerMessage, } from "@chorus/shared"; +export class JoinClient { + relayUrl; + token; + displayName; + ws = null; + state; + onEvent; + onChatMessage; + onTyping; + constructor(relayUrl, token, displayName) { + this.relayUrl = relayUrl; + this.token = token; + this.displayName = displayName; + this.state = { + status: "connecting", + sessionId: "", + users: [], + recentEvents: [], + }; + } + connect() { + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.relayUrl); + this.ws = ws; + ws.onopen = () => { + ws.send(encodeMessage({ type: "auth", token: this.token, displayName: this.displayName })); + }; + ws.onmessage = (ev) => { + let msg; + try { + msg = decodeServerMessage(ev.data); + } + catch { + return; + } + switch (msg.type) { + case "session.history": + this.state.recentEvents = msg.events.slice(-50); + this.state.status = "connected"; + resolve(); + break; + case "session.event": + this.state.recentEvents = [...this.state.recentEvents.slice(-49), msg.event]; + this.onEvent?.(msg.event); + break; + case "user.list": + this.state.users = msg.users; + break; + case "user.joined": + this.state.users = [...this.state.users, msg.user]; + break; + case "user.left": + this.state.users = this.state.users.filter((u) => u.userId !== msg.userId); + break; + case "user.role_changed": + this.state.users = this.state.users.map((u) => u.userId === msg.userId ? { ...u, role: msg.role } : u); + break; + case "chat.message": + this.onChatMessage?.(msg.message.displayName, msg.message.content); + break; + case "user.typing": + this.onTyping?.(msg.displayName); + break; + case "session.closed": + this.state.status = "disconnected"; + ws.close(); + break; + case "error": + this.state.status = "error"; + this.state.error = msg.message; + if (this.state.recentEvents.length === 0) { + reject(new Error(msg.message)); + } + break; + } + }; + ws.onerror = () => { + this.state.status = "error"; + this.state.error = "Connection error"; + reject(new Error("WebSocket connection error")); + }; + ws.onclose = () => { + if (this.state.status === "connected") { + this.state.status = "disconnected"; + } + }; + }); + } + sendInput(content) { + if (!this.ws || this.state.status !== "connected") + return; + this.ws.send(encodeMessage({ type: "collab.input", content })); + } + sendChat(content) { + if (!this.ws || this.state.status !== "connected") + return; + this.ws.send(encodeMessage({ type: "chat.send", content })); + } + setChatHandler(fn) { + this.onChatMessage = fn; + } + setTypingHandler(fn) { + this.onTyping = fn; + } + setEventHandler(fn) { + this.onEvent = fn; + } + sendTyping() { + if (!this.ws || this.state.status !== "connected") + return; + this.ws.send(encodeMessage({ type: "typing" })); + } + getState() { + return this.state; + } + disconnect() { + this.ws?.close(); + this.ws = null; + this.state.status = "disconnected"; + } +} +//# sourceMappingURL=join.js.map \ No newline at end of file diff --git a/packages/client/dist/join.js.map b/packages/client/dist/join.js.map new file mode 100644 index 0000000..0063d25 --- /dev/null +++ b/packages/client/dist/join.js.map @@ -0,0 +1 @@ +{"version":3,"file":"join.js","sourceRoot":"","sources":["../src/join.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/client/dist/relay.d.ts b/packages/client/dist/relay.d.ts new file mode 100644 index 0000000..aad2cee --- /dev/null +++ b/packages/client/dist/relay.d.ts @@ -0,0 +1,63 @@ +import type { SessionEvent, SessionToken, UserRole } from "@chorus/shared"; +export type RelayServerOptions = { + /** Host running the relay (default 127.0.0.1). */ + host?: string; + /** When set with external mode, attach instead of spawning. */ + hostToken?: string; + /** + * Attach to an already-running relay (e.g. on the Docker host). + * When true, requires hostToken and does not spawn/kill a subprocess. + */ + external?: boolean; +}; +/** + * Resolve relay connection settings from env. + * + * External attach (relay already running elsewhere): + * CHORUS_RELAY_HOST=host.docker.internal:7742 + * CHORUS_HOST_TOKEN= + * CHORUS_EXTERNAL_RELAY=1 (optional; implied when HOST_TOKEN is set) + */ +export declare function relayOptionsFromEnv(defaultPort: number): { + port: number; + opts: RelayServerOptions; +}; +/** + * Manages the Rust `chorus-relay` subprocess and the host control WebSocket. + * Joiner-facing protocol on `/ws` is unchanged; adapters talk to `/host`. + */ +export declare class RelayServer { + private readonly port; + private child; + private ws; + private hostToken; + private running; + private clients; + private readonly host; + private readonly external; + private pendingToken; + private onInjectInput?; + private onChatMessage?; + private onTyping?; + constructor(port: number, opts?: RelayServerOptions); + setInputHandler(fn: (content: string, userId: string, displayName?: string) => Promise): void; + setChatHandler(fn: (displayName: string | undefined, content: string) => void): void; + setTypingHandler(fn: (displayName: string | undefined) => void): void; + start(): Promise; + private statusUrl; + private hostWsUrl; + private waitForPort; + private connectHost; + private handleHostMessage; + private send; + issueToken(sessionId: string, role?: UserRole, ttlMs?: number): Promise; + pushEvent(event: SessionEvent): void; + sendChat(displayName: string | undefined, content: string): void; + stop(): void; + get isRunning(): boolean; + get clientCount(): number; + getPort(): number; + getHost(): string; + isExternal(): boolean; +} +//# sourceMappingURL=relay.d.ts.map \ No newline at end of file diff --git a/packages/client/dist/relay.d.ts.map b/packages/client/dist/relay.d.ts.map new file mode 100644 index 0000000..4200a73 --- /dev/null +++ b/packages/client/dist/relay.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"relay.d.ts","sourceRoot":"","sources":["../src/relay.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAwB3E,MAAM,MAAM,kBAAkB,GAAG;IAC/B,kDAAkD;IAClD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAUF;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,kBAAkB,CAAC;CAC1B,CAgBA;AAED;;;GAGG;AACH,qBAAa,WAAW;IAkBpB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAjBvB,OAAO,CAAC,KAAK,CAA6B;IAC1C,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,SAAS,CAAM;IACvB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAU;IACnC,OAAO,CAAC,YAAY,CAGJ;IAEhB,OAAO,CAAC,aAAa,CAAC,CAA2E;IACjG,OAAO,CAAC,aAAa,CAAC,CAA6D;IACnF,OAAO,CAAC,QAAQ,CAAC,CAA4C;gBAG1C,IAAI,EAAE,MAAM,EAC7B,IAAI,GAAE,kBAAuB;IAO/B,eAAe,CACb,EAAE,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAC3E,IAAI;IAIP,cAAc,CAAC,EAAE,EAAE,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI;IAIpF,gBAAgB,CAAC,EAAE,EAAE,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,GAAG,IAAI;IAI/D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAqC5B,OAAO,CAAC,SAAS;IAIjB,OAAO,CAAC,SAAS;YAIH,WAAW;IAmBzB,OAAO,CAAC,WAAW;IAoCnB,OAAO,CAAC,iBAAiB;IAgDzB,OAAO,CAAC,IAAI;IAKZ,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,GAAE,QAAiB,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IAiB7F,SAAS,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI;IAIpC,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI;IAIhE,IAAI,IAAI,IAAI;IAqBZ,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,OAAO,IAAI,MAAM;IAIjB,OAAO,IAAI,MAAM;IAIjB,UAAU,IAAI,OAAO;CAGtB"} \ No newline at end of file diff --git a/packages/client/dist/relay.js b/packages/client/dist/relay.js new file mode 100644 index 0000000..e5ec6c4 --- /dev/null +++ b/packages/client/dist/relay.js @@ -0,0 +1,270 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomBytes } from "node:crypto"; +import { encodeHostMessage, decodeRelayToHost, } from "@chorus/shared"; +function resolveRelayBin() { + if (process.env["CHORUS_RELAY_BIN"]) + return process.env["CHORUS_RELAY_BIN"]; + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + join(here, "../../../target/release/chorus-relay"), + join(here, "../../../target/debug/chorus-relay"), + join(here, "../../../../target/release/chorus-relay"), + join(here, "../../../../target/debug/chorus-relay"), + ]; + for (const path of candidates) { + if (existsSync(path)) + return path; + } + return "chorus-relay"; +} +function parseRelayHost(raw, fallbackPort) { + if (!raw) + return { host: "127.0.0.1", port: fallbackPort }; + const trimmed = raw.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""); + const [hostPart, portPart] = trimmed.split(":"); + const port = portPart ? parseInt(portPart, 10) : fallbackPort; + return { host: hostPart || "127.0.0.1", port: Number.isFinite(port) ? port : fallbackPort }; +} +/** + * Resolve relay connection settings from env. + * + * External attach (relay already running elsewhere): + * CHORUS_RELAY_HOST=host.docker.internal:7742 + * CHORUS_HOST_TOKEN= + * CHORUS_EXTERNAL_RELAY=1 (optional; implied when HOST_TOKEN is set) + */ +export function relayOptionsFromEnv(defaultPort) { + const parsed = parseRelayHost(process.env["CHORUS_RELAY_HOST"], defaultPort); + const hostToken = process.env["CHORUS_HOST_TOKEN"]; + const external = process.env["CHORUS_EXTERNAL_RELAY"] === "1" || + process.env["CHORUS_EXTERNAL_RELAY"] === "true" || + Boolean(hostToken && process.env["CHORUS_RELAY_HOST"]); + return { + port: parsed.port, + opts: { + host: parsed.host, + hostToken: hostToken || undefined, + external, + }, + }; +} +/** + * Manages the Rust `chorus-relay` subprocess and the host control WebSocket. + * Joiner-facing protocol on `/ws` is unchanged; adapters talk to `/host`. + */ +export class RelayServer { + port; + child = null; + ws = null; + hostToken = ""; + running = false; + clients = 0; + host; + external; + pendingToken = null; + onInjectInput; + onChatMessage; + onTyping; + constructor(port, opts = {}) { + this.port = port; + this.host = opts.host ?? "127.0.0.1"; + this.external = Boolean(opts.external); + this.hostToken = opts.hostToken ?? ""; + } + setInputHandler(fn) { + this.onInjectInput = fn; + } + setChatHandler(fn) { + this.onChatMessage = fn; + } + setTypingHandler(fn) { + this.onTyping = fn; + } + async start() { + if (this.running) + return; + if (this.external) { + if (!this.hostToken) { + throw new Error("External relay mode requires CHORUS_HOST_TOKEN (and usually CHORUS_RELAY_HOST)."); + } + await this.waitForPort(); + await this.connectHost(); + this.running = true; + return; + } + this.hostToken = this.hostToken || randomBytes(32).toString("hex"); + const bin = resolveRelayBin(); + this.child = spawn(bin, ["--port", String(this.port), "--bind", "0.0.0.0", "--host-token", this.hostToken], { + stdio: ["ignore", "ignore", "pipe"], + env: { ...process.env }, + }); + this.child.on("exit", () => { + this.running = false; + this.ws = null; + }); + await this.waitForPort(); + await this.connectHost(); + this.running = true; + } + statusUrl() { + return `http://${this.host}:${this.port}/status`; + } + hostWsUrl() { + return `ws://${this.host}:${this.port}/host`; + } + async waitForPort(timeoutMs = 8000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(this.statusUrl(), { signal: AbortSignal.timeout(1000) }); + if (res.ok) + return; + } + catch { + // not up yet + } + await new Promise((r) => setTimeout(r, 40)); + } + throw new Error(this.external + ? `External chorus-relay not reachable at ${this.host}:${this.port}. Is it running on the host?` + : `chorus-relay did not become ready on port ${this.port}. ` + + `Is the binary available? (CHORUS_RELAY_BIN or cargo build -p chorus-relay --release)`); + } + connectHost() { + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.hostWsUrl()); + this.ws = ws; + const timer = setTimeout(() => reject(new Error("host control connect timeout")), 5000); + ws.onopen = () => { + this.send({ type: "host.auth", token: this.hostToken }); + }; + ws.onmessage = (ev) => { + let msg; + try { + msg = decodeRelayToHost(String(ev.data)); + } + catch { + return; + } + this.handleHostMessage(msg, () => { + clearTimeout(timer); + resolve(); + }); + }; + ws.onerror = () => { + clearTimeout(timer); + reject(new Error("host control WebSocket error")); + }; + ws.onclose = () => { + this.ws = null; + this.running = false; + }; + }); + } + handleHostMessage(msg, onReady) { + switch (msg.type) { + case "host.ready": + onReady?.(); + break; + case "token.issued": { + const { type: _t, ...token } = msg; + this.pendingToken?.resolve(token); + this.pendingToken = null; + break; + } + case "collab.input": + this.onInjectInput?.(msg.content, msg.userId, msg.displayName)?.catch(console.error); + break; + case "chat.message": + this.onChatMessage?.(msg.message.displayName, msg.message.content); + break; + case "user.typing": + this.onTyping?.(msg.displayName); + break; + case "user.joined": + this.clients += 1; + break; + case "user.left": + this.clients = Math.max(0, this.clients - 1); + break; + case "user.list": + this.clients = msg.users.length; + break; + case "status": + this.clients = msg.clients; + break; + case "error": + this.pendingToken?.reject(new Error(msg.message)); + this.pendingToken = null; + break; + } + } + send(msg) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) + return; + this.ws.send(encodeHostMessage(msg)); + } + issueToken(sessionId, role = "edit", ttlMs) { + return new Promise((resolve, reject) => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + reject(new Error("relay host control not connected")); + return; + } + this.pendingToken = { resolve, reject }; + this.send({ type: "token.issue", sessionId, role, ttlMs }); + setTimeout(() => { + if (this.pendingToken) { + this.pendingToken.reject(new Error("token.issue timed out")); + this.pendingToken = null; + } + }, 5000); + }); + } + pushEvent(event) { + this.send({ type: "session.event", event }); + } + sendChat(displayName, content) { + this.send({ type: "chat.send", content, displayName }); + } + stop() { + if (!this.external) { + this.send({ type: "host.close" }); + } + try { + this.ws?.close(); + } + catch { + // ignore + } + this.ws = null; + if (this.child && !this.child.killed) { + this.child.kill("SIGTERM"); + setTimeout(() => { + if (this.child && !this.child.killed) + this.child.kill("SIGKILL"); + }, 1000).unref?.(); + } + this.child = null; + this.running = false; + this.clients = 0; + } + get isRunning() { + return this.running; + } + get clientCount() { + return this.clients; + } + getPort() { + return this.port; + } + getHost() { + return this.host; + } + isExternal() { + return this.external; + } +} +//# sourceMappingURL=relay.js.map \ No newline at end of file diff --git a/packages/client/dist/relay.js.map b/packages/client/dist/relay.js.map new file mode 100644 index 0000000..263dfdc --- /dev/null +++ b/packages/client/dist/relay.js.map @@ -0,0 +1 @@ +{"version":3,"file":"relay.js","sourceRoot":"","sources":["../src/relay.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..cfb9fb2 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,28 @@ +{ + "name": "@chorus/client", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "bun test test/join.test.ts test/relay.test.ts", + "clean": "rm -rf dist" + }, + "dependencies": { + "@chorus/shared": "workspace:*" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^22.10.7", + "typescript": "^5.7.3" + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..671ab2f --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,10 @@ +export { + JoinClient, + type JoinState, + type JoinStatus, +} from "./join.js"; +export { + RelayServer, + relayOptionsFromEnv, + type RelayServerOptions, +} from "./relay.js"; diff --git a/packages/client/src/join.ts b/packages/client/src/join.ts new file mode 100644 index 0000000..1d5290e --- /dev/null +++ b/packages/client/src/join.ts @@ -0,0 +1,158 @@ +import type { SessionEvent, ConnectedUser } from "@chorus/shared"; +import { + encodeMessage, + decodeServerMessage, + type ServerMessage, +} from "@chorus/shared"; + +export type JoinStatus = "connecting" | "connected" | "disconnected" | "error"; + +export interface JoinState { + status: JoinStatus; + sessionId: string; + users: ConnectedUser[]; + recentEvents: SessionEvent[]; + error?: string; +} + +export class JoinClient { + private ws: WebSocket | null = null; + private state: JoinState; + private onEvent?: (event: SessionEvent) => void; + private onChatMessage?: (displayName: string | undefined, content: string) => void; + private onTyping?: (displayName: string | undefined) => void; + + constructor( + private readonly relayUrl: string, + private readonly token: string, + private readonly displayName: string + ) { + this.state = { + status: "connecting", + sessionId: "", + users: [], + recentEvents: [], + }; + } + + connect(): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.relayUrl); + this.ws = ws; + + ws.onopen = () => { + ws.send(encodeMessage({ type: "auth", token: this.token, displayName: this.displayName })); + }; + + ws.onmessage = (ev) => { + let msg: ServerMessage; + try { + msg = decodeServerMessage(ev.data as string); + } catch { + return; + } + + switch (msg.type) { + case "session.history": + this.state.recentEvents = msg.events.slice(-50); + this.state.status = "connected"; + resolve(); + break; + + case "session.event": + this.state.recentEvents = [...this.state.recentEvents.slice(-49), msg.event]; + this.onEvent?.(msg.event); + break; + + case "user.list": + this.state.users = msg.users; + break; + + case "user.joined": + this.state.users = [...this.state.users, msg.user]; + break; + + case "user.left": + this.state.users = this.state.users.filter((u) => u.userId !== msg.userId); + break; + + case "user.role_changed": + this.state.users = this.state.users.map((u) => + u.userId === msg.userId ? { ...u, role: msg.role } : u + ); + break; + + case "chat.message": + this.onChatMessage?.(msg.message.displayName, msg.message.content); + break; + + case "user.typing": + this.onTyping?.(msg.displayName); + break; + + case "session.closed": + this.state.status = "disconnected"; + ws.close(); + break; + + case "error": + this.state.status = "error"; + this.state.error = msg.message; + if (this.state.recentEvents.length === 0) { + reject(new Error(msg.message)); + } + break; + } + }; + + ws.onerror = () => { + this.state.status = "error"; + this.state.error = "Connection error"; + reject(new Error("WebSocket connection error")); + }; + + ws.onclose = () => { + if (this.state.status === "connected") { + this.state.status = "disconnected"; + } + }; + }); + } + + sendInput(content: string): void { + if (!this.ws || this.state.status !== "connected") return; + this.ws.send(encodeMessage({ type: "collab.input", content })); + } + + sendChat(content: string): void { + if (!this.ws || this.state.status !== "connected") return; + this.ws.send(encodeMessage({ type: "chat.send", content })); + } + + setChatHandler(fn: (displayName: string | undefined, content: string) => void): void { + this.onChatMessage = fn; + } + + setTypingHandler(fn: (displayName: string | undefined) => void): void { + this.onTyping = fn; + } + + setEventHandler(fn: (event: SessionEvent) => void): void { + this.onEvent = fn; + } + + sendTyping(): void { + if (!this.ws || this.state.status !== "connected") return; + this.ws.send(encodeMessage({ type: "typing" })); + } + + getState(): Readonly { + return this.state; + } + + disconnect(): void { + this.ws?.close(); + this.ws = null; + this.state.status = "disconnected"; + } +} diff --git a/packages/client/src/relay.ts b/packages/client/src/relay.ts new file mode 100644 index 0000000..1ee7151 --- /dev/null +++ b/packages/client/src/relay.ts @@ -0,0 +1,341 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomBytes } from "node:crypto"; +import type { SessionEvent, SessionToken, UserRole } from "@chorus/shared"; +import { + encodeHostMessage, + decodeRelayToHost, + type HostToRelay, + type RelayToHost, +} from "@chorus/shared"; + +function resolveRelayBin(): string { + if (process.env["CHORUS_RELAY_BIN"]) return process.env["CHORUS_RELAY_BIN"]; + + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + join(here, "../../../target/release/chorus-relay"), + join(here, "../../../target/debug/chorus-relay"), + join(here, "../../../../target/release/chorus-relay"), + join(here, "../../../../target/debug/chorus-relay"), + ]; + for (const path of candidates) { + if (existsSync(path)) return path; + } + return "chorus-relay"; +} + +export type RelayServerOptions = { + /** Host running the relay (default 127.0.0.1). */ + host?: string; + /** When set with external mode, attach instead of spawning. */ + hostToken?: string; + /** + * Attach to an already-running relay (e.g. on the Docker host). + * When true, requires hostToken and does not spawn/kill a subprocess. + */ + external?: boolean; +}; + +function parseRelayHost(raw: string | undefined, fallbackPort: number): { host: string; port: number } { + if (!raw) return { host: "127.0.0.1", port: fallbackPort }; + const trimmed = raw.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""); + const [hostPart, portPart] = trimmed.split(":"); + const port = portPart ? parseInt(portPart, 10) : fallbackPort; + return { host: hostPart || "127.0.0.1", port: Number.isFinite(port) ? port : fallbackPort }; +} + +/** + * Resolve relay connection settings from env. + * + * External attach (relay already running elsewhere): + * CHORUS_RELAY_HOST=host.docker.internal:7742 + * CHORUS_HOST_TOKEN= + * CHORUS_EXTERNAL_RELAY=1 (optional; implied when HOST_TOKEN is set) + */ +export function relayOptionsFromEnv(defaultPort: number): { + port: number; + opts: RelayServerOptions; +} { + const parsed = parseRelayHost(process.env["CHORUS_RELAY_HOST"], defaultPort); + const hostToken = process.env["CHORUS_HOST_TOKEN"]; + const external = + process.env["CHORUS_EXTERNAL_RELAY"] === "1" || + process.env["CHORUS_EXTERNAL_RELAY"] === "true" || + Boolean(hostToken && process.env["CHORUS_RELAY_HOST"]); + + return { + port: parsed.port, + opts: { + host: parsed.host, + hostToken: hostToken || undefined, + external, + }, + }; +} + +/** + * Manages the Rust `chorus-relay` subprocess and the host control WebSocket. + * Joiner-facing protocol on `/ws` is unchanged; adapters talk to `/host`. + */ +export class RelayServer { + private child: ChildProcess | null = null; + private ws: WebSocket | null = null; + private hostToken = ""; + private running = false; + private clients = 0; + private readonly host: string; + private readonly external: boolean; + private pendingToken: { + resolve: (t: SessionToken) => void; + reject: (e: Error) => void; + } | null = null; + + private onInjectInput?: (content: string, userId: string, displayName?: string) => Promise; + private onChatMessage?: (displayName: string | undefined, content: string) => void; + private onTyping?: (displayName: string | undefined) => void; + + constructor( + private readonly port: number, + opts: RelayServerOptions = {} + ) { + this.host = opts.host ?? "127.0.0.1"; + this.external = Boolean(opts.external); + this.hostToken = opts.hostToken ?? ""; + } + + setInputHandler( + fn: (content: string, userId: string, displayName?: string) => Promise + ): void { + this.onInjectInput = fn; + } + + setChatHandler(fn: (displayName: string | undefined, content: string) => void): void { + this.onChatMessage = fn; + } + + setTypingHandler(fn: (displayName: string | undefined) => void): void { + this.onTyping = fn; + } + + async start(): Promise { + if (this.running) return; + + if (this.external) { + if (!this.hostToken) { + throw new Error( + "External relay mode requires CHORUS_HOST_TOKEN (and usually CHORUS_RELAY_HOST)." + ); + } + await this.waitForPort(); + await this.connectHost(); + this.running = true; + return; + } + + this.hostToken = this.hostToken || randomBytes(32).toString("hex"); + const bin = resolveRelayBin(); + + this.child = spawn( + bin, + ["--port", String(this.port), "--bind", "0.0.0.0", "--host-token", this.hostToken], + { + stdio: ["ignore", "ignore", "pipe"], + env: { ...process.env }, + } + ); + + this.child.on("exit", () => { + this.running = false; + this.ws = null; + }); + + await this.waitForPort(); + await this.connectHost(); + this.running = true; + } + + private statusUrl(): string { + return `http://${this.host}:${this.port}/status`; + } + + private hostWsUrl(): string { + return `ws://${this.host}:${this.port}/host`; + } + + private async waitForPort(timeoutMs = 8000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(this.statusUrl(), { signal: AbortSignal.timeout(1000) }); + if (res.ok) return; + } catch { + // not up yet + } + await new Promise((r) => setTimeout(r, 40)); + } + throw new Error( + this.external + ? `External chorus-relay not reachable at ${this.host}:${this.port}. Is it running on the host?` + : `chorus-relay did not become ready on port ${this.port}. ` + + `Is the binary available? (CHORUS_RELAY_BIN or cargo build -p chorus-relay --release)` + ); + } + + private connectHost(): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.hostWsUrl()); + this.ws = ws; + + const timer = setTimeout(() => reject(new Error("host control connect timeout")), 5000); + + ws.onopen = () => { + this.send({ type: "host.auth", token: this.hostToken }); + }; + + ws.onmessage = (ev) => { + let msg: RelayToHost; + try { + msg = decodeRelayToHost(String(ev.data)); + } catch { + return; + } + this.handleHostMessage(msg, () => { + clearTimeout(timer); + resolve(); + }); + }; + + ws.onerror = () => { + clearTimeout(timer); + reject(new Error("host control WebSocket error")); + }; + + ws.onclose = () => { + this.ws = null; + this.running = false; + }; + }); + } + + private handleHostMessage(msg: RelayToHost, onReady?: () => void): void { + switch (msg.type) { + case "host.ready": + onReady?.(); + break; + + case "token.issued": { + const { type: _t, ...token } = msg; + this.pendingToken?.resolve(token); + this.pendingToken = null; + break; + } + + case "collab.input": + this.onInjectInput?.(msg.content, msg.userId, msg.displayName)?.catch(console.error); + break; + + case "chat.message": + this.onChatMessage?.(msg.message.displayName, msg.message.content); + break; + + case "user.typing": + this.onTyping?.(msg.displayName); + break; + + case "user.joined": + this.clients += 1; + break; + + case "user.left": + this.clients = Math.max(0, this.clients - 1); + break; + + case "user.list": + this.clients = msg.users.length; + break; + + case "status": + this.clients = msg.clients; + break; + + case "error": + this.pendingToken?.reject(new Error(msg.message)); + this.pendingToken = null; + break; + } + } + + private send(msg: HostToRelay): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + this.ws.send(encodeHostMessage(msg)); + } + + issueToken(sessionId: string, role: UserRole = "edit", ttlMs?: number): Promise { + return new Promise((resolve, reject) => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + reject(new Error("relay host control not connected")); + return; + } + this.pendingToken = { resolve, reject }; + this.send({ type: "token.issue", sessionId, role, ttlMs }); + setTimeout(() => { + if (this.pendingToken) { + this.pendingToken.reject(new Error("token.issue timed out")); + this.pendingToken = null; + } + }, 5000); + }); + } + + pushEvent(event: SessionEvent): void { + this.send({ type: "session.event", event }); + } + + sendChat(displayName: string | undefined, content: string): void { + this.send({ type: "chat.send", content, displayName }); + } + + stop(): void { + if (!this.external) { + this.send({ type: "host.close" }); + } + try { + this.ws?.close(); + } catch { + // ignore + } + this.ws = null; + if (this.child && !this.child.killed) { + this.child.kill("SIGTERM"); + setTimeout(() => { + if (this.child && !this.child.killed) this.child.kill("SIGKILL"); + }, 1000).unref?.(); + } + this.child = null; + this.running = false; + this.clients = 0; + } + + get isRunning(): boolean { + return this.running; + } + + get clientCount(): number { + return this.clients; + } + + getPort(): number { + return this.port; + } + + getHost(): string { + return this.host; + } + + isExternal(): boolean { + return this.external; + } +} diff --git a/packages/plugin/test/join.test.ts b/packages/client/test/join.test.ts similarity index 96% rename from packages/plugin/test/join.test.ts rename to packages/client/test/join.test.ts index 16d7686..b5c5331 100644 --- a/packages/plugin/test/join.test.ts +++ b/packages/client/test/join.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { JoinClient } from "../src/join/index.js"; -import { RelayServer } from "../src/relay/index.js"; +import { JoinClient } from "../src/index.js"; +import { RelayServer } from "../src/index.js"; const TEST_PORT = 17743; diff --git a/packages/plugin/test/relay.test.ts b/packages/client/test/relay.test.ts similarity index 98% rename from packages/plugin/test/relay.test.ts rename to packages/client/test/relay.test.ts index c112ddd..dffb96f 100644 --- a/packages/plugin/test/relay.test.ts +++ b/packages/client/test/relay.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { RelayServer } from "../src/relay/index.js"; +import { RelayServer } from "../src/index.js"; import type { ServerMessage } from "@chorus/shared"; import { encodeMessage, decodeServerMessage } from "@chorus/shared"; diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..bdbdd6e --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["bun", "node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/plugin/dist/index.d.ts b/packages/plugin/dist/index.d.ts index 50d5b9a..a0fe2ce 100644 --- a/packages/plugin/dist/index.d.ts +++ b/packages/plugin/dist/index.d.ts @@ -84,9 +84,9 @@ export default function chorusPlugin(input: PluginInput): Promise<{ description: string; args: { role: z.ZodOptional>; }; execute(args: { diff --git a/packages/plugin/dist/join/index.d.ts b/packages/plugin/dist/join/index.d.ts index 8660d88..a119e06 100644 --- a/packages/plugin/dist/join/index.d.ts +++ b/packages/plugin/dist/join/index.d.ts @@ -1,30 +1,2 @@ -import type { SessionEvent, ConnectedUser } from "@chorus/shared"; -export type JoinStatus = "connecting" | "connected" | "disconnected" | "error"; -export interface JoinState { - status: JoinStatus; - sessionId: string; - users: ConnectedUser[]; - recentEvents: SessionEvent[]; - error?: string; -} -export declare class JoinClient { - private readonly relayUrl; - private readonly token; - private readonly displayName; - private ws; - private state; - private onEvent?; - private onChatMessage?; - private onTyping?; - constructor(relayUrl: string, token: string, displayName: string); - connect(): Promise; - sendInput(content: string): void; - sendChat(content: string): void; - setChatHandler(fn: (displayName: string | undefined, content: string) => void): void; - setTypingHandler(fn: (displayName: string | undefined) => void): void; - setEventHandler(fn: (event: SessionEvent) => void): void; - sendTyping(): void; - getState(): Readonly; - disconnect(): void; -} +export { JoinClient, type JoinState, type JoinStatus } from "@chorus/client"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/plugin/dist/join/index.d.ts.map b/packages/plugin/dist/join/index.d.ts.map index 200e854..76e7d85 100644 --- a/packages/plugin/dist/join/index.d.ts.map +++ b/packages/plugin/dist/join/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/join/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAOlE,MAAM,MAAM,UAAU,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAC;AAE/E,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,UAAU,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,YAAY,EAAE,YAAY,EAAE,CAAC;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,qBAAa,UAAU;IAQnB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAT9B,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,KAAK,CAAY;IACzB,OAAO,CAAC,OAAO,CAAC,CAAgC;IAChD,OAAO,CAAC,aAAa,CAAC,CAA6D;IACnF,OAAO,CAAC,QAAQ,CAAC,CAA4C;gBAG1C,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM;IAUtC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAqFxB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAKhC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAK/B,cAAc,CAAC,EAAE,EAAE,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI;IAIpF,gBAAgB,CAAC,EAAE,EAAE,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,GAAG,IAAI;IAIrE,eAAe,CAAC,EAAE,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,GAAG,IAAI;IAIxD,UAAU,IAAI,IAAI;IAKlB,QAAQ,IAAI,QAAQ,CAAC,SAAS,CAAC;IAI/B,UAAU,IAAI,IAAI;CAKnB"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/join/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC"} \ No newline at end of file diff --git a/packages/plugin/dist/join/index.js b/packages/plugin/dist/join/index.js index 4bdb03c..a40d6b6 100644 --- a/packages/plugin/dist/join/index.js +++ b/packages/plugin/dist/join/index.js @@ -1,124 +1,2 @@ -import { encodeMessage, decodeServerMessage, } from "@chorus/shared"; -export class JoinClient { - relayUrl; - token; - displayName; - ws = null; - state; - onEvent; - onChatMessage; - onTyping; - constructor(relayUrl, token, displayName) { - this.relayUrl = relayUrl; - this.token = token; - this.displayName = displayName; - this.state = { - status: "connecting", - sessionId: "", - users: [], - recentEvents: [], - }; - } - connect() { - return new Promise((resolve, reject) => { - const ws = new WebSocket(this.relayUrl); - this.ws = ws; - ws.onopen = () => { - ws.send(encodeMessage({ type: "auth", token: this.token, displayName: this.displayName })); - }; - ws.onmessage = (ev) => { - let msg; - try { - msg = decodeServerMessage(ev.data); - } - catch { - return; - } - switch (msg.type) { - case "session.history": - this.state.recentEvents = msg.events.slice(-50); - this.state.status = "connected"; - resolve(); - break; - case "session.event": - this.state.recentEvents = [...this.state.recentEvents.slice(-49), msg.event]; - this.onEvent?.(msg.event); - break; - case "user.list": - this.state.users = msg.users; - break; - case "user.joined": - this.state.users = [...this.state.users, msg.user]; - break; - case "user.left": - this.state.users = this.state.users.filter((u) => u.userId !== msg.userId); - break; - case "user.role_changed": - this.state.users = this.state.users.map((u) => u.userId === msg.userId ? { ...u, role: msg.role } : u); - break; - case "chat.message": - this.onChatMessage?.(msg.message.displayName, msg.message.content); - break; - case "user.typing": - this.onTyping?.(msg.displayName); - break; - case "session.closed": - this.state.status = "disconnected"; - ws.close(); - break; - case "error": - this.state.status = "error"; - this.state.error = msg.message; - if (this.state.recentEvents.length === 0) { - // Failed before we ever got history — reject the connect promise - reject(new Error(msg.message)); - } - break; - } - }; - ws.onerror = () => { - this.state.status = "error"; - this.state.error = "Connection error"; - reject(new Error("WebSocket connection error")); - }; - ws.onclose = () => { - if (this.state.status === "connected") { - this.state.status = "disconnected"; - } - }; - }); - } - sendInput(content) { - if (!this.ws || this.state.status !== "connected") - return; - this.ws.send(encodeMessage({ type: "collab.input", content })); - } - sendChat(content) { - if (!this.ws || this.state.status !== "connected") - return; - this.ws.send(encodeMessage({ type: "chat.send", content })); - } - setChatHandler(fn) { - this.onChatMessage = fn; - } - setTypingHandler(fn) { - this.onTyping = fn; - } - setEventHandler(fn) { - this.onEvent = fn; - } - sendTyping() { - if (!this.ws || this.state.status !== "connected") - return; - this.ws.send(encodeMessage({ type: "typing" })); - } - getState() { - return this.state; - } - disconnect() { - this.ws?.close(); - this.ws = null; - this.state.status = "disconnected"; - } -} +export { JoinClient } from "@chorus/client"; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/plugin/dist/join/index.js.map b/packages/plugin/dist/join/index.js.map index 2f3266a..dea2dc6 100644 --- a/packages/plugin/dist/join/index.js.map +++ b/packages/plugin/dist/join/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/join/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/join/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAmC,MAAM,gBAAgB,CAAC"} \ No newline at end of file diff --git a/packages/plugin/dist/relay/index.d.ts b/packages/plugin/dist/relay/index.d.ts index 35d0a01..66be079 100644 --- a/packages/plugin/dist/relay/index.d.ts +++ b/packages/plugin/dist/relay/index.d.ts @@ -1,63 +1,2 @@ -import type { SessionEvent, SessionToken, UserRole } from "@chorus/shared"; -export type RelayServerOptions = { - /** Host running the relay (default 127.0.0.1). */ - host?: string; - /** When set with external mode, attach instead of spawning. */ - hostToken?: string; - /** - * Attach to an already-running relay (e.g. on the Docker host). - * When true, requires hostToken and does not spawn/kill a subprocess. - */ - external?: boolean; -}; -/** - * Resolve relay connection settings from env. - * - * External attach (relay already running elsewhere): - * CHORUS_RELAY_HOST=host.docker.internal:7742 - * CHORUS_HOST_TOKEN= - * CHORUS_EXTERNAL_RELAY=1 (optional; implied when HOST_TOKEN is set) - */ -export declare function relayOptionsFromEnv(defaultPort: number): { - port: number; - opts: RelayServerOptions; -}; -/** - * Manages the Rust `chorus-relay` subprocess and the host control WebSocket. - * Joiner-facing protocol on `/ws` is unchanged; the plugin talks to `/host`. - */ -export declare class RelayServer { - private readonly port; - private child; - private ws; - private hostToken; - private running; - private clients; - private readonly host; - private readonly external; - private pendingToken; - private onInjectInput?; - private onChatMessage?; - private onTyping?; - constructor(port: number, opts?: RelayServerOptions); - setInputHandler(fn: (content: string, userId: string, displayName?: string) => Promise): void; - setChatHandler(fn: (displayName: string | undefined, content: string) => void): void; - setTypingHandler(fn: (displayName: string | undefined) => void): void; - start(): Promise; - private statusUrl; - private hostWsUrl; - private waitForPort; - private connectHost; - private handleHostMessage; - private send; - issueToken(sessionId: string, role?: UserRole, ttlMs?: number): Promise; - pushEvent(event: SessionEvent): void; - sendChat(displayName: string | undefined, content: string): void; - stop(): void; - get isRunning(): boolean; - get clientCount(): number; - getPort(): number; - getHost(): string; - isExternal(): boolean; -} +export { RelayServer, relayOptionsFromEnv, type RelayServerOptions, } from "@chorus/client"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/plugin/dist/relay/index.d.ts.map b/packages/plugin/dist/relay/index.d.ts.map index c29c9a2..eb3fc36 100644 --- a/packages/plugin/dist/relay/index.d.ts.map +++ b/packages/plugin/dist/relay/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/relay/index.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAwB3E,MAAM,MAAM,kBAAkB,GAAG;IAC/B,kDAAkD;IAClD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAWF;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,kBAAkB,CAAC;CAC1B,CAgBA;AAED;;;GAGG;AACH,qBAAa,WAAW;IAkBpB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAjBvB,OAAO,CAAC,KAAK,CAA6B;IAC1C,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,SAAS,CAAM;IACvB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAU;IACnC,OAAO,CAAC,YAAY,CAGJ;IAEhB,OAAO,CAAC,aAAa,CAAC,CAA2E;IACjG,OAAO,CAAC,aAAa,CAAC,CAA6D;IACnF,OAAO,CAAC,QAAQ,CAAC,CAA4C;gBAG1C,IAAI,EAAE,MAAM,EAC7B,IAAI,GAAE,kBAAuB;IAO/B,eAAe,CACb,EAAE,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAC3E,IAAI;IAIP,cAAc,CAAC,EAAE,EAAE,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI;IAIpF,gBAAgB,CAAC,EAAE,EAAE,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,GAAG,IAAI;IAI/D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAqC5B,OAAO,CAAC,SAAS;IAIjB,OAAO,CAAC,SAAS;YAIH,WAAW;IAmBzB,OAAO,CAAC,WAAW;IAoCnB,OAAO,CAAC,iBAAiB;IAgDzB,OAAO,CAAC,IAAI;IAKZ,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,GAAE,QAAiB,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IAiB7F,SAAS,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI;IAIpC,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI;IAIhE,IAAI,IAAI,IAAI;IAuBZ,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,OAAO,IAAI,MAAM;IAIjB,OAAO,IAAI,MAAM;IAIjB,UAAU,IAAI,OAAO;CAGtB"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/relay/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,mBAAmB,EACnB,KAAK,kBAAkB,GACxB,MAAM,gBAAgB,CAAC"} \ No newline at end of file diff --git a/packages/plugin/dist/relay/index.js b/packages/plugin/dist/relay/index.js index 145498c..947f81c 100644 --- a/packages/plugin/dist/relay/index.js +++ b/packages/plugin/dist/relay/index.js @@ -1,273 +1,2 @@ -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { randomBytes } from "node:crypto"; -import { encodeHostMessage, decodeRelayToHost, } from "@chorus/shared"; -function resolveRelayBin() { - if (process.env["CHORUS_RELAY_BIN"]) - return process.env["CHORUS_RELAY_BIN"]; - const here = dirname(fileURLToPath(import.meta.url)); - const candidates = [ - join(here, "../../../../target/release/chorus-relay"), - join(here, "../../../../target/debug/chorus-relay"), - join(here, "../../../../../target/release/chorus-relay"), - join(here, "../../../../../target/debug/chorus-relay"), - ]; - for (const path of candidates) { - if (existsSync(path)) - return path; - } - return "chorus-relay"; -} -function parseRelayHost(raw, fallbackPort) { - if (!raw) - return { host: "127.0.0.1", port: fallbackPort }; - // Accept host, host:port, or ws(s)://host:port[/path] - const trimmed = raw.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""); - const [hostPart, portPart] = trimmed.split(":"); - const port = portPart ? parseInt(portPart, 10) : fallbackPort; - return { host: hostPart || "127.0.0.1", port: Number.isFinite(port) ? port : fallbackPort }; -} -/** - * Resolve relay connection settings from env. - * - * External attach (relay already running elsewhere): - * CHORUS_RELAY_HOST=host.docker.internal:7742 - * CHORUS_HOST_TOKEN= - * CHORUS_EXTERNAL_RELAY=1 (optional; implied when HOST_TOKEN is set) - */ -export function relayOptionsFromEnv(defaultPort) { - const parsed = parseRelayHost(process.env["CHORUS_RELAY_HOST"], defaultPort); - const hostToken = process.env["CHORUS_HOST_TOKEN"]; - const external = process.env["CHORUS_EXTERNAL_RELAY"] === "1" || - process.env["CHORUS_EXTERNAL_RELAY"] === "true" || - Boolean(hostToken && process.env["CHORUS_RELAY_HOST"]); - return { - port: parsed.port, - opts: { - host: parsed.host, - hostToken: hostToken || undefined, - external, - }, - }; -} -/** - * Manages the Rust `chorus-relay` subprocess and the host control WebSocket. - * Joiner-facing protocol on `/ws` is unchanged; the plugin talks to `/host`. - */ -export class RelayServer { - port; - child = null; - ws = null; - hostToken = ""; - running = false; - clients = 0; - host; - external; - pendingToken = null; - onInjectInput; - onChatMessage; - onTyping; - constructor(port, opts = {}) { - this.port = port; - this.host = opts.host ?? "127.0.0.1"; - this.external = Boolean(opts.external); - this.hostToken = opts.hostToken ?? ""; - } - setInputHandler(fn) { - this.onInjectInput = fn; - } - setChatHandler(fn) { - this.onChatMessage = fn; - } - setTypingHandler(fn) { - this.onTyping = fn; - } - async start() { - if (this.running) - return; - if (this.external) { - if (!this.hostToken) { - throw new Error("External relay mode requires CHORUS_HOST_TOKEN (and usually CHORUS_RELAY_HOST)."); - } - await this.waitForPort(); - await this.connectHost(); - this.running = true; - return; - } - this.hostToken = this.hostToken || randomBytes(32).toString("hex"); - const bin = resolveRelayBin(); - this.child = spawn(bin, ["--port", String(this.port), "--bind", "0.0.0.0", "--host-token", this.hostToken], { - stdio: ["ignore", "ignore", "pipe"], - env: { ...process.env }, - }); - this.child.on("exit", () => { - this.running = false; - this.ws = null; - }); - await this.waitForPort(); - await this.connectHost(); - this.running = true; - } - statusUrl() { - return `http://${this.host}:${this.port}/status`; - } - hostWsUrl() { - return `ws://${this.host}:${this.port}/host`; - } - async waitForPort(timeoutMs = 8000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const res = await fetch(this.statusUrl(), { signal: AbortSignal.timeout(1000) }); - if (res.ok) - return; - } - catch { - // not up yet - } - await new Promise((r) => setTimeout(r, 40)); - } - throw new Error(this.external - ? `External chorus-relay not reachable at ${this.host}:${this.port}. Is it running on the host?` - : `chorus-relay did not become ready on port ${this.port}. ` + - `Is the binary available? (CHORUS_RELAY_BIN or cargo build -p chorus-relay --release)`); - } - connectHost() { - return new Promise((resolve, reject) => { - const ws = new WebSocket(this.hostWsUrl()); - this.ws = ws; - const timer = setTimeout(() => reject(new Error("host control connect timeout")), 5000); - ws.onopen = () => { - this.send({ type: "host.auth", token: this.hostToken }); - }; - ws.onmessage = (ev) => { - let msg; - try { - msg = decodeRelayToHost(String(ev.data)); - } - catch { - return; - } - this.handleHostMessage(msg, () => { - clearTimeout(timer); - resolve(); - }); - }; - ws.onerror = () => { - clearTimeout(timer); - reject(new Error("host control WebSocket error")); - }; - ws.onclose = () => { - this.ws = null; - this.running = false; - }; - }); - } - handleHostMessage(msg, onReady) { - switch (msg.type) { - case "host.ready": - onReady?.(); - break; - case "token.issued": { - const { type: _t, ...token } = msg; - this.pendingToken?.resolve(token); - this.pendingToken = null; - break; - } - case "collab.input": - this.onInjectInput?.(msg.content, msg.userId, msg.displayName)?.catch(console.error); - break; - case "chat.message": - this.onChatMessage?.(msg.message.displayName, msg.message.content); - break; - case "user.typing": - this.onTyping?.(msg.displayName); - break; - case "user.joined": - this.clients += 1; - break; - case "user.left": - this.clients = Math.max(0, this.clients - 1); - break; - case "user.list": - this.clients = msg.users.length; - break; - case "status": - this.clients = msg.clients; - break; - case "error": - this.pendingToken?.reject(new Error(msg.message)); - this.pendingToken = null; - break; - } - } - send(msg) { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) - return; - this.ws.send(encodeHostMessage(msg)); - } - issueToken(sessionId, role = "edit", ttlMs) { - return new Promise((resolve, reject) => { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - reject(new Error("relay host control not connected")); - return; - } - this.pendingToken = { resolve, reject }; - this.send({ type: "token.issue", sessionId, role, ttlMs }); - setTimeout(() => { - if (this.pendingToken) { - this.pendingToken.reject(new Error("token.issue timed out")); - this.pendingToken = null; - } - }, 5000); - }); - } - pushEvent(event) { - this.send({ type: "session.event", event }); - } - sendChat(displayName, content) { - this.send({ type: "chat.send", content, displayName }); - } - stop() { - // Only tear down session state on relays we own. External relays stay up - // so container agents can reconnect across test runs. - if (!this.external) { - this.send({ type: "host.close" }); - } - try { - this.ws?.close(); - } - catch { - // ignore - } - this.ws = null; - if (this.child && !this.child.killed) { - this.child.kill("SIGTERM"); - setTimeout(() => { - if (this.child && !this.child.killed) - this.child.kill("SIGKILL"); - }, 1000).unref?.(); - } - this.child = null; - this.running = false; - this.clients = 0; - } - get isRunning() { - return this.running; - } - get clientCount() { - return this.clients; - } - getPort() { - return this.port; - } - getHost() { - return this.host; - } - isExternal() { - return this.external; - } -} +export { RelayServer, relayOptionsFromEnv, } from "@chorus/client"; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/plugin/dist/relay/index.js.map b/packages/plugin/dist/relay/index.js.map index d5ef0e4..8250733 100644 --- a/packages/plugin/dist/relay/index.js.map +++ b/packages/plugin/dist/relay/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/relay/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/relay/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,mBAAmB,GAEpB,MAAM,gBAAgB,CAAC"} \ No newline at end of file diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 23c49cb..ca7dcbf 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -15,13 +15,14 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", - "test": "vitest run test/access.test.ts test/backup.test.ts && bun test test/relay.test.ts test/join.test.ts", + "test": "vitest run test/access.test.ts test/backup.test.ts", "test:watch": "vitest", "clean": "rm -rf dist" }, "dependencies": { "@aws-sdk/client-s3": "^3.726.0", "@aws-sdk/lib-storage": "^3.726.0", + "@chorus/client": "workspace:*", "@chorus/shared": "workspace:*", "zod": "4.1.8" }, diff --git a/packages/plugin/src/join/index.ts b/packages/plugin/src/join/index.ts index f8b9dd8..030eff9 100644 --- a/packages/plugin/src/join/index.ts +++ b/packages/plugin/src/join/index.ts @@ -1,159 +1 @@ -import type { SessionEvent, ConnectedUser } from "@chorus/shared"; -import { - encodeMessage, - decodeServerMessage, - type ServerMessage, -} from "@chorus/shared"; - -export type JoinStatus = "connecting" | "connected" | "disconnected" | "error"; - -export interface JoinState { - status: JoinStatus; - sessionId: string; - users: ConnectedUser[]; - recentEvents: SessionEvent[]; - error?: string; -} - -export class JoinClient { - private ws: WebSocket | null = null; - private state: JoinState; - private onEvent?: (event: SessionEvent) => void; - private onChatMessage?: (displayName: string | undefined, content: string) => void; - private onTyping?: (displayName: string | undefined) => void; - - constructor( - private readonly relayUrl: string, - private readonly token: string, - private readonly displayName: string - ) { - this.state = { - status: "connecting", - sessionId: "", - users: [], - recentEvents: [], - }; - } - - connect(): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(this.relayUrl); - this.ws = ws; - - ws.onopen = () => { - ws.send(encodeMessage({ type: "auth", token: this.token, displayName: this.displayName })); - }; - - ws.onmessage = (ev) => { - let msg: ServerMessage; - try { - msg = decodeServerMessage(ev.data as string); - } catch { - return; - } - - switch (msg.type) { - case "session.history": - this.state.recentEvents = msg.events.slice(-50); - this.state.status = "connected"; - resolve(); - break; - - case "session.event": - this.state.recentEvents = [...this.state.recentEvents.slice(-49), msg.event]; - this.onEvent?.(msg.event); - break; - - case "user.list": - this.state.users = msg.users; - break; - - case "user.joined": - this.state.users = [...this.state.users, msg.user]; - break; - - case "user.left": - this.state.users = this.state.users.filter((u) => u.userId !== msg.userId); - break; - - case "user.role_changed": - this.state.users = this.state.users.map((u) => - u.userId === msg.userId ? { ...u, role: msg.role } : u - ); - break; - - case "chat.message": - this.onChatMessage?.(msg.message.displayName, msg.message.content); - break; - - case "user.typing": - this.onTyping?.(msg.displayName); - break; - - case "session.closed": - this.state.status = "disconnected"; - ws.close(); - break; - - case "error": - this.state.status = "error"; - this.state.error = msg.message; - if (this.state.recentEvents.length === 0) { - // Failed before we ever got history — reject the connect promise - reject(new Error(msg.message)); - } - break; - } - }; - - ws.onerror = () => { - this.state.status = "error"; - this.state.error = "Connection error"; - reject(new Error("WebSocket connection error")); - }; - - ws.onclose = () => { - if (this.state.status === "connected") { - this.state.status = "disconnected"; - } - }; - }); - } - - sendInput(content: string): void { - if (!this.ws || this.state.status !== "connected") return; - this.ws.send(encodeMessage({ type: "collab.input", content })); - } - - sendChat(content: string): void { - if (!this.ws || this.state.status !== "connected") return; - this.ws.send(encodeMessage({ type: "chat.send", content })); - } - - setChatHandler(fn: (displayName: string | undefined, content: string) => void): void { - this.onChatMessage = fn; - } - - setTypingHandler(fn: (displayName: string | undefined) => void): void { - this.onTyping = fn; - } - - setEventHandler(fn: (event: SessionEvent) => void): void { - this.onEvent = fn; - } - - sendTyping(): void { - if (!this.ws || this.state.status !== "connected") return; - this.ws.send(encodeMessage({ type: "typing" })); - } - - getState(): Readonly { - return this.state; - } - - disconnect(): void { - this.ws?.close(); - this.ws = null; - this.state.status = "disconnected"; - } -} +export { JoinClient, type JoinState, type JoinStatus } from "@chorus/client"; diff --git a/packages/plugin/src/relay/index.ts b/packages/plugin/src/relay/index.ts index ba54fa1..345245c 100644 --- a/packages/plugin/src/relay/index.ts +++ b/packages/plugin/src/relay/index.ts @@ -1,344 +1,5 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { randomBytes } from "node:crypto"; -import type { SessionEvent, SessionToken, UserRole } from "@chorus/shared"; -import { - encodeHostMessage, - decodeRelayToHost, - type HostToRelay, - type RelayToHost, -} from "@chorus/shared"; - -function resolveRelayBin(): string { - if (process.env["CHORUS_RELAY_BIN"]) return process.env["CHORUS_RELAY_BIN"]; - - const here = dirname(fileURLToPath(import.meta.url)); - const candidates = [ - join(here, "../../../../target/release/chorus-relay"), - join(here, "../../../../target/debug/chorus-relay"), - join(here, "../../../../../target/release/chorus-relay"), - join(here, "../../../../../target/debug/chorus-relay"), - ]; - for (const path of candidates) { - if (existsSync(path)) return path; - } - return "chorus-relay"; -} - -export type RelayServerOptions = { - /** Host running the relay (default 127.0.0.1). */ - host?: string; - /** When set with external mode, attach instead of spawning. */ - hostToken?: string; - /** - * Attach to an already-running relay (e.g. on the Docker host). - * When true, requires hostToken and does not spawn/kill a subprocess. - */ - external?: boolean; -}; - -function parseRelayHost(raw: string | undefined, fallbackPort: number): { host: string; port: number } { - if (!raw) return { host: "127.0.0.1", port: fallbackPort }; - // Accept host, host:port, or ws(s)://host:port[/path] - const trimmed = raw.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""); - const [hostPart, portPart] = trimmed.split(":"); - const port = portPart ? parseInt(portPart, 10) : fallbackPort; - return { host: hostPart || "127.0.0.1", port: Number.isFinite(port) ? port : fallbackPort }; -} - -/** - * Resolve relay connection settings from env. - * - * External attach (relay already running elsewhere): - * CHORUS_RELAY_HOST=host.docker.internal:7742 - * CHORUS_HOST_TOKEN= - * CHORUS_EXTERNAL_RELAY=1 (optional; implied when HOST_TOKEN is set) - */ -export function relayOptionsFromEnv(defaultPort: number): { - port: number; - opts: RelayServerOptions; -} { - const parsed = parseRelayHost(process.env["CHORUS_RELAY_HOST"], defaultPort); - const hostToken = process.env["CHORUS_HOST_TOKEN"]; - const external = - process.env["CHORUS_EXTERNAL_RELAY"] === "1" || - process.env["CHORUS_EXTERNAL_RELAY"] === "true" || - Boolean(hostToken && process.env["CHORUS_RELAY_HOST"]); - - return { - port: parsed.port, - opts: { - host: parsed.host, - hostToken: hostToken || undefined, - external, - }, - }; -} - -/** - * Manages the Rust `chorus-relay` subprocess and the host control WebSocket. - * Joiner-facing protocol on `/ws` is unchanged; the plugin talks to `/host`. - */ -export class RelayServer { - private child: ChildProcess | null = null; - private ws: WebSocket | null = null; - private hostToken = ""; - private running = false; - private clients = 0; - private readonly host: string; - private readonly external: boolean; - private pendingToken: { - resolve: (t: SessionToken) => void; - reject: (e: Error) => void; - } | null = null; - - private onInjectInput?: (content: string, userId: string, displayName?: string) => Promise; - private onChatMessage?: (displayName: string | undefined, content: string) => void; - private onTyping?: (displayName: string | undefined) => void; - - constructor( - private readonly port: number, - opts: RelayServerOptions = {} - ) { - this.host = opts.host ?? "127.0.0.1"; - this.external = Boolean(opts.external); - this.hostToken = opts.hostToken ?? ""; - } - - setInputHandler( - fn: (content: string, userId: string, displayName?: string) => Promise - ): void { - this.onInjectInput = fn; - } - - setChatHandler(fn: (displayName: string | undefined, content: string) => void): void { - this.onChatMessage = fn; - } - - setTypingHandler(fn: (displayName: string | undefined) => void): void { - this.onTyping = fn; - } - - async start(): Promise { - if (this.running) return; - - if (this.external) { - if (!this.hostToken) { - throw new Error( - "External relay mode requires CHORUS_HOST_TOKEN (and usually CHORUS_RELAY_HOST)." - ); - } - await this.waitForPort(); - await this.connectHost(); - this.running = true; - return; - } - - this.hostToken = this.hostToken || randomBytes(32).toString("hex"); - const bin = resolveRelayBin(); - - this.child = spawn( - bin, - ["--port", String(this.port), "--bind", "0.0.0.0", "--host-token", this.hostToken], - { - stdio: ["ignore", "ignore", "pipe"], - env: { ...process.env }, - } - ); - - this.child.on("exit", () => { - this.running = false; - this.ws = null; - }); - - await this.waitForPort(); - await this.connectHost(); - this.running = true; - } - - private statusUrl(): string { - return `http://${this.host}:${this.port}/status`; - } - - private hostWsUrl(): string { - return `ws://${this.host}:${this.port}/host`; - } - - private async waitForPort(timeoutMs = 8000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const res = await fetch(this.statusUrl(), { signal: AbortSignal.timeout(1000) }); - if (res.ok) return; - } catch { - // not up yet - } - await new Promise((r) => setTimeout(r, 40)); - } - throw new Error( - this.external - ? `External chorus-relay not reachable at ${this.host}:${this.port}. Is it running on the host?` - : `chorus-relay did not become ready on port ${this.port}. ` + - `Is the binary available? (CHORUS_RELAY_BIN or cargo build -p chorus-relay --release)` - ); - } - - private connectHost(): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(this.hostWsUrl()); - this.ws = ws; - - const timer = setTimeout(() => reject(new Error("host control connect timeout")), 5000); - - ws.onopen = () => { - this.send({ type: "host.auth", token: this.hostToken }); - }; - - ws.onmessage = (ev) => { - let msg: RelayToHost; - try { - msg = decodeRelayToHost(String(ev.data)); - } catch { - return; - } - this.handleHostMessage(msg, () => { - clearTimeout(timer); - resolve(); - }); - }; - - ws.onerror = () => { - clearTimeout(timer); - reject(new Error("host control WebSocket error")); - }; - - ws.onclose = () => { - this.ws = null; - this.running = false; - }; - }); - } - - private handleHostMessage(msg: RelayToHost, onReady?: () => void): void { - switch (msg.type) { - case "host.ready": - onReady?.(); - break; - - case "token.issued": { - const { type: _t, ...token } = msg; - this.pendingToken?.resolve(token); - this.pendingToken = null; - break; - } - - case "collab.input": - this.onInjectInput?.(msg.content, msg.userId, msg.displayName)?.catch(console.error); - break; - - case "chat.message": - this.onChatMessage?.(msg.message.displayName, msg.message.content); - break; - - case "user.typing": - this.onTyping?.(msg.displayName); - break; - - case "user.joined": - this.clients += 1; - break; - - case "user.left": - this.clients = Math.max(0, this.clients - 1); - break; - - case "user.list": - this.clients = msg.users.length; - break; - - case "status": - this.clients = msg.clients; - break; - - case "error": - this.pendingToken?.reject(new Error(msg.message)); - this.pendingToken = null; - break; - } - } - - private send(msg: HostToRelay): void { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; - this.ws.send(encodeHostMessage(msg)); - } - - issueToken(sessionId: string, role: UserRole = "edit", ttlMs?: number): Promise { - return new Promise((resolve, reject) => { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - reject(new Error("relay host control not connected")); - return; - } - this.pendingToken = { resolve, reject }; - this.send({ type: "token.issue", sessionId, role, ttlMs }); - setTimeout(() => { - if (this.pendingToken) { - this.pendingToken.reject(new Error("token.issue timed out")); - this.pendingToken = null; - } - }, 5000); - }); - } - - pushEvent(event: SessionEvent): void { - this.send({ type: "session.event", event }); - } - - sendChat(displayName: string | undefined, content: string): void { - this.send({ type: "chat.send", content, displayName }); - } - - stop(): void { - // Only tear down session state on relays we own. External relays stay up - // so container agents can reconnect across test runs. - if (!this.external) { - this.send({ type: "host.close" }); - } - try { - this.ws?.close(); - } catch { - // ignore - } - this.ws = null; - if (this.child && !this.child.killed) { - this.child.kill("SIGTERM"); - setTimeout(() => { - if (this.child && !this.child.killed) this.child.kill("SIGKILL"); - }, 1000).unref?.(); - } - this.child = null; - this.running = false; - this.clients = 0; - } - - get isRunning(): boolean { - return this.running; - } - - get clientCount(): number { - return this.clients; - } - - getPort(): number { - return this.port; - } - - getHost(): string { - return this.host; - } - - isExternal(): boolean { - return this.external; - } -} +export { + RelayServer, + relayOptionsFromEnv, + type RelayServerOptions, +} from "@chorus/client"; diff --git a/packages/vscode/README.md b/packages/vscode/README.md new file mode 100644 index 0000000..7233f84 --- /dev/null +++ b/packages/vscode/README.md @@ -0,0 +1,61 @@ +# Chorus VS Code extension + +VS Code adapter for [Chorus](../../README.md) — share or join a live collaborative AI session over `chorus-relay`. + +## What it does + +| Command | Behavior | +|---|---| +| **Chorus: Share Session** | Spawns/attaches `chorus-relay`, issues a join token, copies `/chorus-join …` to the clipboard | +| **Chorus: Join Session** | Connects to `/ws` with token + host | +| **Chorus: Send Prompt to Host** | Joiner `collab.input` into the shared session | +| **Chorus: Publish Host Message** | Host `session.event` fan-out to joiners | +| **Chorus: Send Chat Message** | Side-channel chat (not LLM history) | +| **Chorus: Leave / Stop / Status** | Disconnect / tear down / inspect state | + +The **Chorus** activity-bar panel shows the mirrored transcript and a compose box. + +## Honest scope (v1) + +- Speaks the same wire protocol as the OpenCode plugin (`@chorus/shared` + `@chorus/client`). +- VS Code is **not** an OpenCode host: it does not drive OpenCode’s LLM loop. When you **share** from VS Code, collaborator prompts appear in the panel/notifications; publish host/AI lines manually (or pair with an OpenCode host that owns the model). +- When you **join** an OpenCode-hosted session, prompts you send are real `collab.input` and the host transcript streams into the panel. + +## Prerequisites + +```sh +# from repo root +cargo build -p chorus-relay --release +bun install +bun run build:ts +``` + +Ensure `chorus-relay` is on `PATH`, or set `chorus.relayBin` / `CHORUS_RELAY_BIN`. + +## Install (dev) + +1. Build this package: `bun run --filter chorus build` +2. In VS Code: **Extensions: Install from Location…** → select `packages/vscode` + (or use the [VS Code Extension Development Host](https://code.visualstudio.com/api/get-started/your-first-extension) with this folder as the extension root) +3. Command Palette → **Chorus: Share Session** / **Join Session** + +## Settings + +| Setting | Default | Meaning | +|---|---|---| +| `chorus.port` | `7742` | Relay listen port when sharing | +| `chorus.displayName` | OS user | Name shown to peers | +| `chorus.relayBin` | — | Path to `chorus-relay` | +| `chorus.publicHost` | LAN IP:port | Advertised join host | + +Env vars from the root README (`CHORUS_RELAY_HOST`, `CHORUS_HOST_TOKEN`, `CHORUS_EXTERNAL_RELAY`, …) still apply for Docker/external relays. + +## Layout + +``` +packages/vscode/ + src/extension.ts # activate + commands + src/controller.ts # JoinClient + RelayServer orchestration + src/sessionView.ts # sidebar webview + src/format.ts # transcript line formatting +``` diff --git a/packages/vscode/media/chorus.svg b/packages/vscode/media/chorus.svg new file mode 100644 index 0000000..dfa18ef --- /dev/null +++ b/packages/vscode/media/chorus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/vscode/package.json b/packages/vscode/package.json new file mode 100644 index 0000000..dd869da --- /dev/null +++ b/packages/vscode/package.json @@ -0,0 +1,135 @@ +{ + "name": "chorus", + "displayName": "Chorus", + "description": "Pair-program a live Chorus AI session from VS Code — share or join over chorus-relay.", + "version": "0.1.0", + "publisher": "chorus", + "private": true, + "license": "MIT", + "type": "module", + "engines": { + "vscode": "^1.90.0" + }, + "categories": ["Chat", "Other"], + "activationEvents": [ + "onCommand:chorus.share", + "onCommand:chorus.join", + "onCommand:chorus.leave", + "onCommand:chorus.stop", + "onCommand:chorus.chat", + "onCommand:chorus.status", + "onCommand:chorus.sendPrompt", + "onCommand:chorus.publishHostMessage", + "onView:chorus.session" + ], + "main": "./dist/extension.js", + "contributes": { + "commands": [ + { + "command": "chorus.share", + "title": "Chorus: Share Session", + "category": "Chorus" + }, + { + "command": "chorus.join", + "title": "Chorus: Join Session", + "category": "Chorus" + }, + { + "command": "chorus.leave", + "title": "Chorus: Leave Session", + "category": "Chorus" + }, + { + "command": "chorus.stop", + "title": "Chorus: Stop Sharing", + "category": "Chorus" + }, + { + "command": "chorus.chat", + "title": "Chorus: Send Chat Message", + "category": "Chorus" + }, + { + "command": "chorus.status", + "title": "Chorus: Show Status", + "category": "Chorus" + }, + { + "command": "chorus.sendPrompt", + "title": "Chorus: Send Prompt to Host", + "category": "Chorus" + }, + { + "command": "chorus.publishHostMessage", + "title": "Chorus: Publish Host Message", + "category": "Chorus" + }, + { + "command": "chorus.openSession", + "title": "Chorus: Open Session Panel", + "category": "Chorus" + } + ], + "configuration": { + "title": "Chorus", + "properties": { + "chorus.port": { + "type": "number", + "default": 7742, + "description": "Default chorus-relay listen port when sharing." + }, + "chorus.displayName": { + "type": "string", + "default": "", + "description": "Display name shown to collaborators (defaults to OS username)." + }, + "chorus.relayBin": { + "type": "string", + "default": "", + "description": "Path to chorus-relay binary (overrides CHORUS_RELAY_BIN / PATH)." + }, + "chorus.publicHost": { + "type": "string", + "default": "", + "description": "Host:port advertised in join commands (overrides CHORUS_PUBLIC_HOST)." + } + } + }, + "viewsContainers": { + "activitybar": [ + { + "id": "chorus", + "title": "Chorus", + "icon": "media/chorus.svg" + } + ] + }, + "views": { + "chorus": [ + { + "type": "webview", + "id": "chorus.session", + "name": "Session" + } + ] + } + }, + "scripts": { + "build": "tsc -p ./", + "typecheck": "tsc -p ./ --noEmit", + "watch": "tsc -p ./ -w", + "test": "bun test test/format.test.ts", + "clean": "rm -rf dist", + "package": "npm run build" + }, + "dependencies": { + "@chorus/client": "workspace:*", + "@chorus/shared": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.7", + "@types/vscode": "^1.90.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/vscode/src/controller.ts b/packages/vscode/src/controller.ts new file mode 100644 index 0000000..713c628 --- /dev/null +++ b/packages/vscode/src/controller.ts @@ -0,0 +1,317 @@ +import { networkInterfaces } from "node:os"; +import * as vscode from "vscode"; +import { + JoinClient, + RelayServer, + relayOptionsFromEnv, + type JoinState, +} from "@chorus/client"; +import type { SessionEvent, UserRole } from "@chorus/shared"; +import { formatSessionLine, newEventId } from "./format.js"; + +export type ChorusMode = "idle" | "sharing" | "joined"; + +export type TranscriptLine = { + id: string; + text: string; + at: number; + kind: "session" | "chat" | "system"; +}; + +function getLanIp(): string { + const nets = networkInterfaces(); + for (const iface of Object.values(nets)) { + for (const net of iface ?? []) { + if (net.family === "IPv4" && !net.internal) return net.address; + } + } + return "localhost"; +} + +/** + * Host/joiner controller for the VS Code adapter. + * Speaks the same `/host` + `/ws` contracts as the OpenCode plugin. + */ +export class ChorusController { + private mode: ChorusMode = "idle"; + private relay: RelayServer | null = null; + private joinClient: JoinClient | null = null; + private sessionId = `vscode-${Date.now().toString(36)}`; + private readonly transcript: TranscriptLine[] = []; + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChange = this._onDidChange.event; + + constructor( + private readonly output: vscode.OutputChannel, + private readonly statusBar: vscode.StatusBarItem + ) { + this.refreshStatus(); + } + + getMode(): ChorusMode { + return this.mode; + } + + getTranscript(): readonly TranscriptLine[] { + return this.transcript; + } + + getJoinState(): JoinState | null { + return this.joinClient?.getState() ?? null; + } + + getShareSummary(): { + sharing: boolean; + clients?: number; + port?: number; + host?: string; + external?: boolean; + } { + if (!this.relay || this.mode !== "sharing") return { sharing: false }; + return { + sharing: true, + clients: this.relay.clientCount, + port: this.relay.getPort(), + host: this.relay.getHost(), + external: this.relay.isExternal(), + }; + } + + private cfg() { + return vscode.workspace.getConfiguration("chorus"); + } + + private displayName(): string { + const configured = this.cfg().get("displayName")?.trim(); + if (configured) return configured; + return process.env["USER"] ?? process.env["USERNAME"] ?? "vscode"; + } + + private defaultPort(): number { + return this.cfg().get("port") ?? 7742; + } + + private publicJoinHost(port: number, relayHost: string, external: boolean): string { + const fromSettings = this.cfg().get("publicHost")?.trim(); + if (fromSettings) return fromSettings; + if (process.env["CHORUS_PUBLIC_HOST"]) return process.env["CHORUS_PUBLIC_HOST"]; + if (external && relayHost && relayHost !== "127.0.0.1") { + return `${relayHost}:${port}`; + } + return `${getLanIp()}:${port}`; + } + + private append(line: TranscriptLine): void { + this.transcript.push(line); + if (this.transcript.length > 500) this.transcript.shift(); + this.output.appendLine(line.text); + this._onDidChange.fire(); + } + + private appendSystem(text: string): void { + this.append({ id: newEventId(), text, at: Date.now(), kind: "system" }); + } + + private appendSession(event: SessionEvent): void { + const text = formatSessionLine(event); + if (!text) return; + this.append({ id: event.id, text, at: event.timestamp, kind: "session" }); + } + + private refreshStatus(): void { + if (this.mode === "sharing" && this.relay) { + this.statusBar.text = `$(broadcast) Chorus sharing :${this.relay.getPort()} (${this.relay.clientCount})`; + this.statusBar.tooltip = "Chorus is sharing — click for status"; + } else if (this.mode === "joined" && this.joinClient) { + const st = this.joinClient.getState(); + this.statusBar.text = `$(organization) Chorus joined (${st.users.length})`; + this.statusBar.tooltip = "Chorus joined — click for status"; + } else { + this.statusBar.text = "$(circle-slash) Chorus"; + this.statusBar.tooltip = "Chorus idle — Share or Join a session"; + } + this.statusBar.command = "chorus.status"; + this._onDidChange.fire(); + } + + async share(role: UserRole = "edit"): Promise { + if (this.mode === "joined") { + await this.leave(); + } + + const relayBin = this.cfg().get("relayBin")?.trim(); + if (relayBin) process.env["CHORUS_RELAY_BIN"] = relayBin; + + const { port, opts } = relayOptionsFromEnv(this.defaultPort()); + if (!this.relay) { + this.relay = new RelayServer(port, opts); + this.relay.setInputHandler(async (content, userId, displayName) => { + const label = displayName ?? userId.slice(0, 8); + const labeled = `[${label}]: ${content}`; + const event: SessionEvent = { + id: newEventId(), + sessionId: this.sessionId, + type: "user", + payload: labeled, + timestamp: Date.now(), + }; + this.appendSession(event); + this.relay?.pushEvent(event); + void vscode.window.showInformationMessage(`Chorus prompt from ${label}: ${content}`); + }); + this.relay.setChatHandler((name, content) => { + const text = `💬 [${name ?? "guest"}]: ${content}`; + this.append({ id: newEventId(), text, at: Date.now(), kind: "chat" }); + void vscode.window.showInformationMessage(text); + }); + this.relay.setTypingHandler((name) => { + this.statusBar.text = `$(edit) ${name ?? "someone"} typing…`; + setTimeout(() => this.refreshStatus(), 2000).unref?.(); + }); + } + + if (!this.relay.isRunning) { + await this.relay.start(); + } + + this.mode = "sharing"; + const token = await this.relay.issueToken(this.sessionId, role); + const joinHost = this.publicJoinHost( + this.relay.getPort(), + this.relay.getHost(), + this.relay.isExternal() + ); + const joinCommand = `/chorus-join token="${token.token}" host="${joinHost}"`; + this.appendSystem( + this.relay.isExternal() + ? `Attached to external relay ${this.relay.getHost()}:${this.relay.getPort()}` + : `chorus-relay started on port ${this.relay.getPort()}` + ); + this.appendSystem(`Share ready (${role}). Collaborator command:\n${joinCommand}`); + this.refreshStatus(); + + await vscode.env.clipboard.writeText(joinCommand); + void vscode.window.showInformationMessage( + `Chorus sharing — join command copied to clipboard.`, + "Copy again" + ).then((choice) => { + if (choice === "Copy again") void vscode.env.clipboard.writeText(joinCommand); + }); + + return joinCommand; + } + + async join(token: string, host: string, name?: string): Promise { + if (this.mode === "sharing") { + throw new Error("Stop sharing (Chorus: Stop Sharing) before joining another session."); + } + if (this.joinClient) { + this.joinClient.disconnect(); + this.joinClient = null; + } + + const displayName = name?.trim() || this.displayName(); + const jc = new JoinClient(`ws://${host}/ws`, token, displayName); + await jc.connect(); + + jc.setChatHandler((msgName, content) => { + const text = `💬 [${msgName ?? "host"}]: ${content}`; + this.append({ id: newEventId(), text, at: Date.now(), kind: "chat" }); + void vscode.window.showInformationMessage(text); + }); + jc.setTypingHandler((typingName) => { + this.statusBar.text = `$(edit) ${typingName ?? "someone"} typing…`; + setTimeout(() => this.refreshStatus(), 2000).unref?.(); + }); + jc.setEventHandler((event) => this.appendSession(event)); + + for (const event of jc.getState().recentEvents) { + this.appendSession(event); + } + + this.joinClient = jc; + this.mode = "joined"; + this.appendSystem(`Joined ${host} as ${displayName}`); + this.refreshStatus(); + } + + async leave(): Promise { + this.joinClient?.disconnect(); + this.joinClient = null; + if (this.mode === "joined") this.mode = "idle"; + this.appendSystem("Left shared session"); + this.refreshStatus(); + } + + stop(): void { + this.mode = "idle"; + this.relay?.stop(); + this.relay = null; + this.appendSystem("Stopped sharing"); + this.refreshStatus(); + } + + sendChat(message: string): void { + if (this.mode === "sharing" && this.relay) { + this.relay.sendChat(this.displayName(), message); + this.append({ + id: newEventId(), + text: `💬 [${this.displayName()}]: ${message}`, + at: Date.now(), + kind: "chat", + }); + return; + } + if (this.mode === "joined" && this.joinClient?.getState().status === "connected") { + this.joinClient.sendTyping(); + this.joinClient.sendChat(message); + this.append({ + id: newEventId(), + text: `💬 [${this.displayName()}]: ${message}`, + at: Date.now(), + kind: "chat", + }); + return; + } + throw new Error("Not currently sharing or joined."); + } + + /** Joiner → host collab.input */ + sendPrompt(content: string): void { + if (this.mode !== "joined" || !this.joinClient) { + throw new Error("Join a session first to send prompts to the host."); + } + this.joinClient.sendInput(content); + this.appendSystem(`→ sent prompt to host (${content.slice(0, 80)}${content.length > 80 ? "…" : ""})`); + } + + /** Host publishes a user/assistant line onto the shared transcript. */ + publishHostMessage(content: string, type: "user" | "assistant" = "user"): void { + if (this.mode !== "sharing" || !this.relay) { + throw new Error("Share a session first to publish host messages."); + } + const event: SessionEvent = { + id: newEventId(), + sessionId: this.sessionId, + type, + payload: content, + timestamp: Date.now(), + }; + this.relay.pushEvent(event); + this.appendSession(event); + } + + statusText(): string { + const share = this.getShareSummary(); + const join = this.joinClient + ? { joined: true, ...this.joinClient.getState() } + : { joined: false }; + return JSON.stringify({ mode: this.mode, ...share, ...join }, null, 2); + } + + dispose(): void { + this.joinClient?.disconnect(); + this.relay?.stop(); + this._onDidChange.dispose(); + } +} diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts new file mode 100644 index 0000000..6ee7d53 --- /dev/null +++ b/packages/vscode/src/extension.ts @@ -0,0 +1,156 @@ +import * as vscode from "vscode"; +import type { UserRole } from "@chorus/shared"; +import { ChorusController } from "./controller.js"; +import { SessionViewProvider } from "./sessionView.js"; + +export function activate(context: vscode.ExtensionContext): void { + const output = vscode.window.createOutputChannel("Chorus"); + const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); + statusBar.show(); + + const controller = new ChorusController(output, statusBar); + const viewProvider = new SessionViewProvider(controller); + + context.subscriptions.push( + output, + statusBar, + controller, + vscode.window.registerWebviewViewProvider(SessionViewProvider.viewType, viewProvider) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.share", async () => { + try { + const rolePick = await vscode.window.showQuickPick( + [ + { label: "edit", description: "Can send prompts (default)" }, + { label: "view", description: "Read-only" }, + { label: "admin", description: "Full control" }, + ], + { title: "Chorus share role for join token" } + ); + const role = (rolePick?.label ?? "edit") as UserRole; + const joinCommand = await controller.share(role); + output.show(true); + void vscode.commands.executeCommand("chorus.session.focus"); + return joinCommand; + } catch (err) { + void vscode.window.showErrorMessage(`Chorus share failed: ${String(err)}`); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.join", async () => { + try { + const token = await vscode.window.showInputBox({ + title: "Chorus join token", + prompt: "Paste the token from the host's share command", + ignoreFocusOut: true, + }); + if (!token) return; + + const host = await vscode.window.showInputBox({ + title: "Chorus relay host", + prompt: "host:port (e.g. 192.168.1.5:7742)", + ignoreFocusOut: true, + value: "127.0.0.1:7742", + }); + if (!host) return; + + const name = await vscode.window.showInputBox({ + title: "Display name", + prompt: "Optional — shown to collaborators", + ignoreFocusOut: true, + }); + + await controller.join(token.trim(), host.trim(), name?.trim() || undefined); + output.show(true); + void vscode.window.showInformationMessage("Joined Chorus session"); + } catch (err) { + void vscode.window.showErrorMessage(`Chorus join failed: ${String(err)}`); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.leave", async () => { + await controller.leave(); + void vscode.window.showInformationMessage("Left Chorus session"); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.stop", () => { + controller.stop(); + void vscode.window.showInformationMessage("Stopped Chorus sharing"); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.chat", async () => { + try { + const message = await vscode.window.showInputBox({ + title: "Chorus chat", + prompt: "Side-channel message (does not enter the LLM transcript)", + ignoreFocusOut: true, + }); + if (!message) return; + controller.sendChat(message); + } catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.sendPrompt", async () => { + try { + const content = await vscode.window.showInputBox({ + title: "Send prompt to Chorus host", + prompt: "Forwarded as collab.input into the shared session", + ignoreFocusOut: true, + }); + if (!content) return; + controller.sendPrompt(content); + } catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.publishHostMessage", async () => { + try { + const content = await vscode.window.showInputBox({ + title: "Publish host message", + prompt: "Pushed to all joiners as a session user event", + ignoreFocusOut: true, + }); + if (!content) return; + controller.publishHostMessage(content, "user"); + } catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.status", async () => { + const text = controller.statusText(); + output.appendLine(text); + output.show(true); + await vscode.window.showInformationMessage(`Chorus mode: ${controller.getMode()}`); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.openSession", async () => { + await vscode.commands.executeCommand("chorus.session.focus"); + }) + ); +} + +export function deactivate(): void { + // disposables handled via subscriptions +} diff --git a/packages/vscode/src/format.ts b/packages/vscode/src/format.ts new file mode 100644 index 0000000..2082ae5 --- /dev/null +++ b/packages/vscode/src/format.ts @@ -0,0 +1,19 @@ +import type { SessionEvent } from "@chorus/shared"; + +const LABELED_LINE = /^\[[^\]]+\]:\s/; + +/** Format a shared session event for the Chorus panel / notifications. */ +export function formatSessionLine(event: SessionEvent): string | null { + const payload = + typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload); + if (event.type === "user") { + if (LABELED_LINE.test(payload)) return payload; + return `[Host]: ${payload}`; + } + if (event.type === "assistant") return `[AI]: ${payload}`; + return `[${event.type}]: ${payload}`; +} + +export function newEventId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} diff --git a/packages/vscode/src/sessionView.ts b/packages/vscode/src/sessionView.ts new file mode 100644 index 0000000..42d5455 --- /dev/null +++ b/packages/vscode/src/sessionView.ts @@ -0,0 +1,134 @@ +import * as vscode from "vscode"; +import type { ChorusController, TranscriptLine } from "./controller.js"; + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function lineClass(line: TranscriptLine): string { + if (line.kind === "chat") return "chat"; + if (line.kind === "system") return "system"; + if (line.text.startsWith("[AI]:")) return "ai"; + if (line.text.startsWith("[Host]:")) return "host"; + return "user"; +} + +export class SessionViewProvider implements vscode.WebviewViewProvider { + public static readonly viewType = "chorus.session"; + private view?: vscode.WebviewView; + + constructor(private readonly controller: ChorusController) { + controller.onDidChange(() => this.render()); + } + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken + ): void { + this.view = webviewView; + webviewView.webview.options = { enableScripts: true }; + webviewView.webview.onDidReceiveMessage((msg) => { + if (msg?.type === "chat" && typeof msg.text === "string") { + try { + this.controller.sendChat(msg.text); + } catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + } + if (msg?.type === "prompt" && typeof msg.text === "string") { + try { + this.controller.sendPrompt(msg.text); + } catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + } + if (msg?.type === "publish" && typeof msg.text === "string") { + try { + this.controller.publishHostMessage(msg.text, "user"); + } catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + } + }); + this.render(); + } + + private render(): void { + if (!this.view) return; + const mode = this.controller.getMode(); + const lines = this.controller.getTranscript(); + const body = lines + .map( + (l) => + `
${escapeHtml(l.text)}
` + ) + .join(""); + + this.view.webview.html = ` + + + + + + +
Mode: ${escapeHtml(mode)}
+
${body || "
Idle — Share or Join a Chorus session.
"}
+
+
+ + +
+
+ +
+
+ + +`; + } +} diff --git a/packages/vscode/test/format.test.ts b/packages/vscode/test/format.test.ts new file mode 100644 index 0000000..2dc4efc --- /dev/null +++ b/packages/vscode/test/format.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "bun:test"; +import { formatSessionLine } from "../src/format.ts"; + +describe("formatSessionLine", () => { + it("labels plain user events as Host", () => { + expect( + formatSessionLine({ + id: "1", + sessionId: "s", + type: "user", + payload: "hello", + timestamp: 1, + }) + ).toBe("[Host]: hello"); + }); + + it("keeps already-labeled collaborator lines", () => { + expect( + formatSessionLine({ + id: "1", + sessionId: "s", + type: "user", + payload: "[Ada]: refactor", + timestamp: 1, + }) + ).toBe("[Ada]: refactor"); + }); + + it("labels assistant events as AI", () => { + expect( + formatSessionLine({ + id: "1", + sessionId: "s", + type: "assistant", + payload: "sure", + timestamp: 1, + }) + ).toBe("[AI]: sure"); + }); +}); diff --git a/packages/vscode/tsconfig.json b/packages/vscode/tsconfig.json new file mode 100644 index 0000000..3d6bd83 --- /dev/null +++ b/packages/vscode/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true, + "declaration": true, + "types": ["node", "vscode"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} From a698d9e74a89e2da775f21dbe82fe30937411131 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Aug 2026 21:18:29 +0000 Subject: [PATCH 2/4] chore: update dist [skip ci] --- packages/vscode/dist/controller.d.ts | 55 +++++ packages/vscode/dist/controller.js | 267 ++++++++++++++++++++++++ packages/vscode/dist/controller.js.map | 1 + packages/vscode/dist/extension.d.ts | 3 + packages/vscode/dist/extension.js | 124 +++++++++++ packages/vscode/dist/extension.js.map | 1 + packages/vscode/dist/format.d.ts | 4 + packages/vscode/dist/format.js | 17 ++ packages/vscode/dist/format.js.map | 1 + packages/vscode/dist/sessionView.d.ts | 10 + packages/vscode/dist/sessionView.js | 130 ++++++++++++ packages/vscode/dist/sessionView.js.map | 1 + 12 files changed, 614 insertions(+) create mode 100644 packages/vscode/dist/controller.d.ts create mode 100644 packages/vscode/dist/controller.js create mode 100644 packages/vscode/dist/controller.js.map create mode 100644 packages/vscode/dist/extension.d.ts create mode 100644 packages/vscode/dist/extension.js create mode 100644 packages/vscode/dist/extension.js.map create mode 100644 packages/vscode/dist/format.d.ts create mode 100644 packages/vscode/dist/format.js create mode 100644 packages/vscode/dist/format.js.map create mode 100644 packages/vscode/dist/sessionView.d.ts create mode 100644 packages/vscode/dist/sessionView.js create mode 100644 packages/vscode/dist/sessionView.js.map diff --git a/packages/vscode/dist/controller.d.ts b/packages/vscode/dist/controller.d.ts new file mode 100644 index 0000000..08c69f2 --- /dev/null +++ b/packages/vscode/dist/controller.d.ts @@ -0,0 +1,55 @@ +import * as vscode from "vscode"; +import { type JoinState } from "@chorus/client"; +import type { UserRole } from "@chorus/shared"; +export type ChorusMode = "idle" | "sharing" | "joined"; +export type TranscriptLine = { + id: string; + text: string; + at: number; + kind: "session" | "chat" | "system"; +}; +/** + * Host/joiner controller for the VS Code adapter. + * Speaks the same `/host` + `/ws` contracts as the OpenCode plugin. + */ +export declare class ChorusController { + private readonly output; + private readonly statusBar; + private mode; + private relay; + private joinClient; + private sessionId; + private readonly transcript; + private readonly _onDidChange; + readonly onDidChange: vscode.Event; + constructor(output: vscode.OutputChannel, statusBar: vscode.StatusBarItem); + getMode(): ChorusMode; + getTranscript(): readonly TranscriptLine[]; + getJoinState(): JoinState | null; + getShareSummary(): { + sharing: boolean; + clients?: number; + port?: number; + host?: string; + external?: boolean; + }; + private cfg; + private displayName; + private defaultPort; + private publicJoinHost; + private append; + private appendSystem; + private appendSession; + private refreshStatus; + share(role?: UserRole): Promise; + join(token: string, host: string, name?: string): Promise; + leave(): Promise; + stop(): void; + sendChat(message: string): void; + /** Joiner → host collab.input */ + sendPrompt(content: string): void; + /** Host publishes a user/assistant line onto the shared transcript. */ + publishHostMessage(content: string, type?: "user" | "assistant"): void; + statusText(): string; + dispose(): void; +} diff --git a/packages/vscode/dist/controller.js b/packages/vscode/dist/controller.js new file mode 100644 index 0000000..2d0eac8 --- /dev/null +++ b/packages/vscode/dist/controller.js @@ -0,0 +1,267 @@ +import { networkInterfaces } from "node:os"; +import * as vscode from "vscode"; +import { JoinClient, RelayServer, relayOptionsFromEnv, } from "@chorus/client"; +import { formatSessionLine, newEventId } from "./format.js"; +function getLanIp() { + const nets = networkInterfaces(); + for (const iface of Object.values(nets)) { + for (const net of iface ?? []) { + if (net.family === "IPv4" && !net.internal) + return net.address; + } + } + return "localhost"; +} +/** + * Host/joiner controller for the VS Code adapter. + * Speaks the same `/host` + `/ws` contracts as the OpenCode plugin. + */ +export class ChorusController { + output; + statusBar; + mode = "idle"; + relay = null; + joinClient = null; + sessionId = `vscode-${Date.now().toString(36)}`; + transcript = []; + _onDidChange = new vscode.EventEmitter(); + onDidChange = this._onDidChange.event; + constructor(output, statusBar) { + this.output = output; + this.statusBar = statusBar; + this.refreshStatus(); + } + getMode() { + return this.mode; + } + getTranscript() { + return this.transcript; + } + getJoinState() { + return this.joinClient?.getState() ?? null; + } + getShareSummary() { + if (!this.relay || this.mode !== "sharing") + return { sharing: false }; + return { + sharing: true, + clients: this.relay.clientCount, + port: this.relay.getPort(), + host: this.relay.getHost(), + external: this.relay.isExternal(), + }; + } + cfg() { + return vscode.workspace.getConfiguration("chorus"); + } + displayName() { + const configured = this.cfg().get("displayName")?.trim(); + if (configured) + return configured; + return process.env["USER"] ?? process.env["USERNAME"] ?? "vscode"; + } + defaultPort() { + return this.cfg().get("port") ?? 7742; + } + publicJoinHost(port, relayHost, external) { + const fromSettings = this.cfg().get("publicHost")?.trim(); + if (fromSettings) + return fromSettings; + if (process.env["CHORUS_PUBLIC_HOST"]) + return process.env["CHORUS_PUBLIC_HOST"]; + if (external && relayHost && relayHost !== "127.0.0.1") { + return `${relayHost}:${port}`; + } + return `${getLanIp()}:${port}`; + } + append(line) { + this.transcript.push(line); + if (this.transcript.length > 500) + this.transcript.shift(); + this.output.appendLine(line.text); + this._onDidChange.fire(); + } + appendSystem(text) { + this.append({ id: newEventId(), text, at: Date.now(), kind: "system" }); + } + appendSession(event) { + const text = formatSessionLine(event); + if (!text) + return; + this.append({ id: event.id, text, at: event.timestamp, kind: "session" }); + } + refreshStatus() { + if (this.mode === "sharing" && this.relay) { + this.statusBar.text = `$(broadcast) Chorus sharing :${this.relay.getPort()} (${this.relay.clientCount})`; + this.statusBar.tooltip = "Chorus is sharing — click for status"; + } + else if (this.mode === "joined" && this.joinClient) { + const st = this.joinClient.getState(); + this.statusBar.text = `$(organization) Chorus joined (${st.users.length})`; + this.statusBar.tooltip = "Chorus joined — click for status"; + } + else { + this.statusBar.text = "$(circle-slash) Chorus"; + this.statusBar.tooltip = "Chorus idle — Share or Join a session"; + } + this.statusBar.command = "chorus.status"; + this._onDidChange.fire(); + } + async share(role = "edit") { + if (this.mode === "joined") { + await this.leave(); + } + const relayBin = this.cfg().get("relayBin")?.trim(); + if (relayBin) + process.env["CHORUS_RELAY_BIN"] = relayBin; + const { port, opts } = relayOptionsFromEnv(this.defaultPort()); + if (!this.relay) { + this.relay = new RelayServer(port, opts); + this.relay.setInputHandler(async (content, userId, displayName) => { + const label = displayName ?? userId.slice(0, 8); + const labeled = `[${label}]: ${content}`; + const event = { + id: newEventId(), + sessionId: this.sessionId, + type: "user", + payload: labeled, + timestamp: Date.now(), + }; + this.appendSession(event); + this.relay?.pushEvent(event); + void vscode.window.showInformationMessage(`Chorus prompt from ${label}: ${content}`); + }); + this.relay.setChatHandler((name, content) => { + const text = `💬 [${name ?? "guest"}]: ${content}`; + this.append({ id: newEventId(), text, at: Date.now(), kind: "chat" }); + void vscode.window.showInformationMessage(text); + }); + this.relay.setTypingHandler((name) => { + this.statusBar.text = `$(edit) ${name ?? "someone"} typing…`; + setTimeout(() => this.refreshStatus(), 2000).unref?.(); + }); + } + if (!this.relay.isRunning) { + await this.relay.start(); + } + this.mode = "sharing"; + const token = await this.relay.issueToken(this.sessionId, role); + const joinHost = this.publicJoinHost(this.relay.getPort(), this.relay.getHost(), this.relay.isExternal()); + const joinCommand = `/chorus-join token="${token.token}" host="${joinHost}"`; + this.appendSystem(this.relay.isExternal() + ? `Attached to external relay ${this.relay.getHost()}:${this.relay.getPort()}` + : `chorus-relay started on port ${this.relay.getPort()}`); + this.appendSystem(`Share ready (${role}). Collaborator command:\n${joinCommand}`); + this.refreshStatus(); + await vscode.env.clipboard.writeText(joinCommand); + void vscode.window.showInformationMessage(`Chorus sharing — join command copied to clipboard.`, "Copy again").then((choice) => { + if (choice === "Copy again") + void vscode.env.clipboard.writeText(joinCommand); + }); + return joinCommand; + } + async join(token, host, name) { + if (this.mode === "sharing") { + throw new Error("Stop sharing (Chorus: Stop Sharing) before joining another session."); + } + if (this.joinClient) { + this.joinClient.disconnect(); + this.joinClient = null; + } + const displayName = name?.trim() || this.displayName(); + const jc = new JoinClient(`ws://${host}/ws`, token, displayName); + await jc.connect(); + jc.setChatHandler((msgName, content) => { + const text = `💬 [${msgName ?? "host"}]: ${content}`; + this.append({ id: newEventId(), text, at: Date.now(), kind: "chat" }); + void vscode.window.showInformationMessage(text); + }); + jc.setTypingHandler((typingName) => { + this.statusBar.text = `$(edit) ${typingName ?? "someone"} typing…`; + setTimeout(() => this.refreshStatus(), 2000).unref?.(); + }); + jc.setEventHandler((event) => this.appendSession(event)); + for (const event of jc.getState().recentEvents) { + this.appendSession(event); + } + this.joinClient = jc; + this.mode = "joined"; + this.appendSystem(`Joined ${host} as ${displayName}`); + this.refreshStatus(); + } + async leave() { + this.joinClient?.disconnect(); + this.joinClient = null; + if (this.mode === "joined") + this.mode = "idle"; + this.appendSystem("Left shared session"); + this.refreshStatus(); + } + stop() { + this.mode = "idle"; + this.relay?.stop(); + this.relay = null; + this.appendSystem("Stopped sharing"); + this.refreshStatus(); + } + sendChat(message) { + if (this.mode === "sharing" && this.relay) { + this.relay.sendChat(this.displayName(), message); + this.append({ + id: newEventId(), + text: `💬 [${this.displayName()}]: ${message}`, + at: Date.now(), + kind: "chat", + }); + return; + } + if (this.mode === "joined" && this.joinClient?.getState().status === "connected") { + this.joinClient.sendTyping(); + this.joinClient.sendChat(message); + this.append({ + id: newEventId(), + text: `💬 [${this.displayName()}]: ${message}`, + at: Date.now(), + kind: "chat", + }); + return; + } + throw new Error("Not currently sharing or joined."); + } + /** Joiner → host collab.input */ + sendPrompt(content) { + if (this.mode !== "joined" || !this.joinClient) { + throw new Error("Join a session first to send prompts to the host."); + } + this.joinClient.sendInput(content); + this.appendSystem(`→ sent prompt to host (${content.slice(0, 80)}${content.length > 80 ? "…" : ""})`); + } + /** Host publishes a user/assistant line onto the shared transcript. */ + publishHostMessage(content, type = "user") { + if (this.mode !== "sharing" || !this.relay) { + throw new Error("Share a session first to publish host messages."); + } + const event = { + id: newEventId(), + sessionId: this.sessionId, + type, + payload: content, + timestamp: Date.now(), + }; + this.relay.pushEvent(event); + this.appendSession(event); + } + statusText() { + const share = this.getShareSummary(); + const join = this.joinClient + ? { joined: true, ...this.joinClient.getState() } + : { joined: false }; + return JSON.stringify({ mode: this.mode, ...share, ...join }, null, 2); + } + dispose() { + this.joinClient?.disconnect(); + this.relay?.stop(); + this._onDidChange.dispose(); + } +} +//# sourceMappingURL=controller.js.map \ No newline at end of file diff --git a/packages/vscode/dist/controller.js.map b/packages/vscode/dist/controller.js.map new file mode 100644 index 0000000..b4cf42f --- /dev/null +++ b/packages/vscode/dist/controller.js.map @@ -0,0 +1 @@ +{"version":3,"file":"controller.js","sourceRoot":"","sources":["../src/controller.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/vscode/dist/extension.d.ts b/packages/vscode/dist/extension.d.ts new file mode 100644 index 0000000..c838f76 --- /dev/null +++ b/packages/vscode/dist/extension.d.ts @@ -0,0 +1,3 @@ +import * as vscode from "vscode"; +export declare function activate(context: vscode.ExtensionContext): void; +export declare function deactivate(): void; diff --git a/packages/vscode/dist/extension.js b/packages/vscode/dist/extension.js new file mode 100644 index 0000000..30cd519 --- /dev/null +++ b/packages/vscode/dist/extension.js @@ -0,0 +1,124 @@ +import * as vscode from "vscode"; +import { ChorusController } from "./controller.js"; +import { SessionViewProvider } from "./sessionView.js"; +export function activate(context) { + const output = vscode.window.createOutputChannel("Chorus"); + const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); + statusBar.show(); + const controller = new ChorusController(output, statusBar); + const viewProvider = new SessionViewProvider(controller); + context.subscriptions.push(output, statusBar, controller, vscode.window.registerWebviewViewProvider(SessionViewProvider.viewType, viewProvider)); + context.subscriptions.push(vscode.commands.registerCommand("chorus.share", async () => { + try { + const rolePick = await vscode.window.showQuickPick([ + { label: "edit", description: "Can send prompts (default)" }, + { label: "view", description: "Read-only" }, + { label: "admin", description: "Full control" }, + ], { title: "Chorus share role for join token" }); + const role = (rolePick?.label ?? "edit"); + const joinCommand = await controller.share(role); + output.show(true); + void vscode.commands.executeCommand("chorus.session.focus"); + return joinCommand; + } + catch (err) { + void vscode.window.showErrorMessage(`Chorus share failed: ${String(err)}`); + } + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.join", async () => { + try { + const token = await vscode.window.showInputBox({ + title: "Chorus join token", + prompt: "Paste the token from the host's share command", + ignoreFocusOut: true, + }); + if (!token) + return; + const host = await vscode.window.showInputBox({ + title: "Chorus relay host", + prompt: "host:port (e.g. 192.168.1.5:7742)", + ignoreFocusOut: true, + value: "127.0.0.1:7742", + }); + if (!host) + return; + const name = await vscode.window.showInputBox({ + title: "Display name", + prompt: "Optional — shown to collaborators", + ignoreFocusOut: true, + }); + await controller.join(token.trim(), host.trim(), name?.trim() || undefined); + output.show(true); + void vscode.window.showInformationMessage("Joined Chorus session"); + } + catch (err) { + void vscode.window.showErrorMessage(`Chorus join failed: ${String(err)}`); + } + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.leave", async () => { + await controller.leave(); + void vscode.window.showInformationMessage("Left Chorus session"); + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.stop", () => { + controller.stop(); + void vscode.window.showInformationMessage("Stopped Chorus sharing"); + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.chat", async () => { + try { + const message = await vscode.window.showInputBox({ + title: "Chorus chat", + prompt: "Side-channel message (does not enter the LLM transcript)", + ignoreFocusOut: true, + }); + if (!message) + return; + controller.sendChat(message); + } + catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.sendPrompt", async () => { + try { + const content = await vscode.window.showInputBox({ + title: "Send prompt to Chorus host", + prompt: "Forwarded as collab.input into the shared session", + ignoreFocusOut: true, + }); + if (!content) + return; + controller.sendPrompt(content); + } + catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.publishHostMessage", async () => { + try { + const content = await vscode.window.showInputBox({ + title: "Publish host message", + prompt: "Pushed to all joiners as a session user event", + ignoreFocusOut: true, + }); + if (!content) + return; + controller.publishHostMessage(content, "user"); + } + catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.status", async () => { + const text = controller.statusText(); + output.appendLine(text); + output.show(true); + await vscode.window.showInformationMessage(`Chorus mode: ${controller.getMode()}`); + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.openSession", async () => { + await vscode.commands.executeCommand("chorus.session.focus"); + })); +} +export function deactivate() { + // disposables handled via subscriptions +} +//# sourceMappingURL=extension.js.map \ No newline at end of file diff --git a/packages/vscode/dist/extension.js.map b/packages/vscode/dist/extension.js.map new file mode 100644 index 0000000..a27b22e --- /dev/null +++ b/packages/vscode/dist/extension.js.map @@ -0,0 +1 @@ +{"version":3,"file":"extension.js","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/vscode/dist/format.d.ts b/packages/vscode/dist/format.d.ts new file mode 100644 index 0000000..60b1469 --- /dev/null +++ b/packages/vscode/dist/format.d.ts @@ -0,0 +1,4 @@ +import type { SessionEvent } from "@chorus/shared"; +/** Format a shared session event for the Chorus panel / notifications. */ +export declare function formatSessionLine(event: SessionEvent): string | null; +export declare function newEventId(): string; diff --git a/packages/vscode/dist/format.js b/packages/vscode/dist/format.js new file mode 100644 index 0000000..cb3b2d0 --- /dev/null +++ b/packages/vscode/dist/format.js @@ -0,0 +1,17 @@ +const LABELED_LINE = /^\[[^\]]+\]:\s/; +/** Format a shared session event for the Chorus panel / notifications. */ +export function formatSessionLine(event) { + const payload = typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload); + if (event.type === "user") { + if (LABELED_LINE.test(payload)) + return payload; + return `[Host]: ${payload}`; + } + if (event.type === "assistant") + return `[AI]: ${payload}`; + return `[${event.type}]: ${payload}`; +} +export function newEventId() { + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} +//# sourceMappingURL=format.js.map \ No newline at end of file diff --git a/packages/vscode/dist/format.js.map b/packages/vscode/dist/format.js.map new file mode 100644 index 0000000..2f15aad --- /dev/null +++ b/packages/vscode/dist/format.js.map @@ -0,0 +1 @@ +{"version":3,"file":"format.js","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"AAEA,MAAM,YAAY,GAAG,gBAAgB,CAAC;AAEtC,0EAA0E;AAC1E,MAAM,UAAU,iBAAiB,CAAC,KAAmB;IACnD,MAAM,OAAO,GACX,OAAO,KAAK,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IACpF,IAAI,KAAK,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;QAC1B,IAAI,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC;YAAE,OAAO,OAAO,CAAC;QAC/C,OAAO,WAAW,OAAO,EAAE,CAAC;IAC9B,CAAC;IACD,IAAI,KAAK,CAAC,IAAI,KAAK,WAAW;QAAE,OAAO,SAAS,OAAO,EAAE,CAAC;IAC1D,OAAO,IAAI,KAAK,CAAC,IAAI,MAAM,OAAO,EAAE,CAAC;AACvC,CAAC;AAED,MAAM,UAAU,UAAU;IACxB,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC;AACpE,CAAC"} \ No newline at end of file diff --git a/packages/vscode/dist/sessionView.d.ts b/packages/vscode/dist/sessionView.d.ts new file mode 100644 index 0000000..d3fed7b --- /dev/null +++ b/packages/vscode/dist/sessionView.d.ts @@ -0,0 +1,10 @@ +import * as vscode from "vscode"; +import type { ChorusController } from "./controller.js"; +export declare class SessionViewProvider implements vscode.WebviewViewProvider { + private readonly controller; + static readonly viewType = "chorus.session"; + private view?; + constructor(controller: ChorusController); + resolveWebviewView(webviewView: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken): void; + private render; +} diff --git a/packages/vscode/dist/sessionView.js b/packages/vscode/dist/sessionView.js new file mode 100644 index 0000000..195c17d --- /dev/null +++ b/packages/vscode/dist/sessionView.js @@ -0,0 +1,130 @@ +import * as vscode from "vscode"; +function escapeHtml(s) { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} +function lineClass(line) { + if (line.kind === "chat") + return "chat"; + if (line.kind === "system") + return "system"; + if (line.text.startsWith("[AI]:")) + return "ai"; + if (line.text.startsWith("[Host]:")) + return "host"; + return "user"; +} +export class SessionViewProvider { + controller; + static viewType = "chorus.session"; + view; + constructor(controller) { + this.controller = controller; + controller.onDidChange(() => this.render()); + } + resolveWebviewView(webviewView, _context, _token) { + this.view = webviewView; + webviewView.webview.options = { enableScripts: true }; + webviewView.webview.onDidReceiveMessage((msg) => { + if (msg?.type === "chat" && typeof msg.text === "string") { + try { + this.controller.sendChat(msg.text); + } + catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + } + if (msg?.type === "prompt" && typeof msg.text === "string") { + try { + this.controller.sendPrompt(msg.text); + } + catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + } + if (msg?.type === "publish" && typeof msg.text === "string") { + try { + this.controller.publishHostMessage(msg.text, "user"); + } + catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + } + }); + this.render(); + } + render() { + if (!this.view) + return; + const mode = this.controller.getMode(); + const lines = this.controller.getTranscript(); + const body = lines + .map((l) => `
${escapeHtml(l.text)}
`) + .join(""); + this.view.webview.html = ` + + + + + + +
Mode: ${escapeHtml(mode)}
+
${body || "
Idle — Share or Join a Chorus session.
"}
+
+
+ + +
+
+ +
+
+ + +`; + } +} +//# sourceMappingURL=sessionView.js.map \ No newline at end of file diff --git a/packages/vscode/dist/sessionView.js.map b/packages/vscode/dist/sessionView.js.map new file mode 100644 index 0000000..4b6b3e7 --- /dev/null +++ b/packages/vscode/dist/sessionView.js.map @@ -0,0 +1 @@ +{"version":3,"file":"sessionView.js","sourceRoot":"","sources":["../src/sessionView.ts"],"names":[],"mappings":"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"} \ No newline at end of file From 444b7c2b5adb23aa2a4658bd54ecc7d04051896e Mon Sep 17 00:00:00 2001 From: Steven Roomberg Date: Sun, 16 Aug 2026 21:18:35 +0000 Subject: [PATCH 3/4] feat(zed): add joiner extension and chorus-zed-helper Ship a practical Zed v1 adapter: WASM extension launches a native helper as an MCP context server. Helper speaks Chorus /ws (join, chat, collab.input, status) with CLI + daemon control and integration tests. --- .gitignore | 1 + Cargo.lock | 16 + Cargo.toml | 4 +- crates/chorus-zed-helper/Cargo.toml | 29 + crates/chorus-zed-helper/src/client.rs | 353 ++++++++ crates/chorus-zed-helper/src/control.rs | 101 +++ crates/chorus-zed-helper/src/lib.rs | 10 + crates/chorus-zed-helper/src/main.rs | 341 +++++++ crates/chorus-zed-helper/src/mcp.rs | 300 +++++++ crates/chorus-zed-helper/tests/join_client.rs | 127 +++ docs/DECISIONS.md | 2 +- docs/STATUS.md | 2 + packages/zed/Cargo.lock | 829 ++++++++++++++++++ packages/zed/Cargo.toml | 17 + packages/zed/README.md | 102 +++ packages/zed/extension.toml | 11 + packages/zed/src/lib.rs | 27 + 17 files changed, 2270 insertions(+), 2 deletions(-) create mode 100644 crates/chorus-zed-helper/Cargo.toml create mode 100644 crates/chorus-zed-helper/src/client.rs create mode 100644 crates/chorus-zed-helper/src/control.rs create mode 100644 crates/chorus-zed-helper/src/lib.rs create mode 100644 crates/chorus-zed-helper/src/main.rs create mode 100644 crates/chorus-zed-helper/src/mcp.rs create mode 100644 crates/chorus-zed-helper/tests/join_client.rs create mode 100644 packages/zed/Cargo.lock create mode 100644 packages/zed/Cargo.toml create mode 100644 packages/zed/README.md create mode 100644 packages/zed/extension.toml create mode 100644 packages/zed/src/lib.rs diff --git a/.gitignore b/.gitignore index 96466e4..143d31e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ coverage/ playwright-report/ test-results/ target/ +packages/zed/target/ .multi-agent/ *.env *.env.local diff --git a/Cargo.lock b/Cargo.lock index e14f344..abf8ceb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,6 +175,22 @@ dependencies = [ "url", ] +[[package]] +name = "chorus-zed-helper" +version = "0.1.0" +dependencies = [ + "chorus-relay", + "clap", + "futures-util", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite 0.26.2", + "tracing", + "tracing-subscriber", + "url", +] + [[package]] name = "clap" version = "4.6.5" diff --git a/Cargo.toml b/Cargo.toml index 265d60d..a9cad15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,8 @@ [workspace] resolver = "2" -members = ["crates/chorus-relay"] +members = ["crates/chorus-relay", "crates/chorus-zed-helper"] +exclude = ["packages/zed"] +# packages/zed is a standalone Zed extension (wasm32-wasip2) built by Zed / cargo --target. [workspace.package] edition = "2021" diff --git a/crates/chorus-zed-helper/Cargo.toml b/crates/chorus-zed-helper/Cargo.toml new file mode 100644 index 0000000..67bbbeb --- /dev/null +++ b/crates/chorus-zed-helper/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "chorus-zed-helper" +version = "0.1.0" +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Native Chorus join client for the Zed extension (CLI + MCP)" +publish = false + +[[bin]] +name = "chorus-zed-helper" +path = "src/main.rs" + +[dependencies] +chorus-relay = { path = "../chorus-relay" } +clap = { version = "4", features = ["derive", "env"] } +futures-util = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = "0.26" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2" + +[dev-dependencies] +chorus-relay = { path = "../chorus-relay" } +tokio-tungstenite = "0.26" +futures-util = "0.3" diff --git a/crates/chorus-zed-helper/src/client.rs b/crates/chorus-zed-helper/src/client.rs new file mode 100644 index 0000000..da941c9 --- /dev/null +++ b/crates/chorus-zed-helper/src/client.rs @@ -0,0 +1,353 @@ +use std::sync::Arc; +use std::time::Duration; + +use chorus_relay::protocol::{ + ChatMessage, ClientMessage, ConnectedUser, ServerMessage, SessionEvent, +}; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use tokio::sync::{mpsc, RwLock}; +use tokio::task::JoinHandle; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum JoinStatus { + Disconnected, + Connecting, + Connected, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionSnapshot { + pub status: JoinStatus, + pub host: String, + pub display_name: String, + pub session_id: Option, + pub users: Vec, + pub recent_events: Vec, + pub recent_chat: Vec, + pub last_error: Option, +} + +impl SessionSnapshot { + fn new(host: &str, display_name: &str) -> Self { + Self { + status: JoinStatus::Disconnected, + host: host.to_string(), + display_name: display_name.to_string(), + session_id: None, + users: Vec::new(), + recent_events: Vec::new(), + recent_chat: Vec::new(), + last_error: None, + } + } +} + +/// Live joiner WebSocket client for the Chorus `/ws` surface. +pub struct JoinClient { + inner: Arc>, + outbound: mpsc::UnboundedSender, + reader: Option>, +} + +struct Inner { + snapshot: SessionSnapshot, +} + +impl JoinClient { + /// Connect to `ws://host/ws` (or `wss://`), authenticate, and wait for history. + pub async fn connect( + host: &str, + token: &str, + display_name: &str, + ) -> Result { + let ws_url = normalize_ws_url(host)?; + let (ws, _) = connect_async(&ws_url) + .await + .map_err(|e| format!("connect failed: {e}"))?; + + let (mut write, mut read) = ws.split(); + let auth = ClientMessage::Auth { + token: token.to_string(), + display_name: Some(display_name.to_string()), + }; + write + .send(Message::Text( + serde_json::to_string(&auth) + .map_err(|e| e.to_string())? + .into(), + )) + .await + .map_err(|e| format!("auth send failed: {e}"))?; + + let mut snapshot = SessionSnapshot::new(host, display_name); + snapshot.status = JoinStatus::Connecting; + + // Wait until session.history (connected) or error / timeout. + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + if tokio::time::Instant::now() > deadline { + return Err("timed out waiting for session.history".into()); + } + let next = tokio::time::timeout(Duration::from_secs(5), read.next()) + .await + .map_err(|_| "timed out waiting for relay message".to_string())?; + let msg = match next { + Some(Ok(Message::Text(t))) => t, + Some(Ok(Message::Close(frame))) => { + return Err(format!("connection closed before auth: {frame:?}")); + } + Some(Ok(_)) => continue, + Some(Err(e)) => return Err(format!("ws error: {e}")), + None => return Err("connection closed before auth".into()), + }; + let server: ServerMessage = + serde_json::from_str(&msg).map_err(|e| format!("bad server json: {e}"))?; + apply_server_message(&mut snapshot, &server); + match server { + ServerMessage::SessionHistory { .. } => break, + ServerMessage::Error { message, .. } => { + return Err(message); + } + _ => continue, + } + } + + let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::(); + let inner = Arc::new(RwLock::new(Inner { snapshot })); + + let writer_inner = Arc::clone(&inner); + let reader = tokio::spawn(async move { + loop { + tokio::select! { + outbound = outbound_rx.recv() => { + let Some(msg) = outbound else { break; }; + let Ok(text) = serde_json::to_string(&msg) else { continue; }; + if write.send(Message::Text(text.into())).await.is_err() { + let mut guard = writer_inner.write().await; + guard.snapshot.status = JoinStatus::Disconnected; + break; + } + } + incoming = read.next() => { + match incoming { + Some(Ok(Message::Text(t))) => { + if let Ok(server) = serde_json::from_str::(&t) { + let mut guard = writer_inner.write().await; + apply_server_message(&mut guard.snapshot, &server); + if matches!(server, ServerMessage::SessionClosed) { + break; + } + } + } + Some(Ok(Message::Close(_))) | None => { + let mut guard = writer_inner.write().await; + guard.snapshot.status = JoinStatus::Disconnected; + break; + } + Some(Ok(_)) => {} + Some(Err(e)) => { + let mut guard = writer_inner.write().await; + guard.snapshot.status = JoinStatus::Error; + guard.snapshot.last_error = Some(e.to_string()); + break; + } + } + } + } + } + }); + + Ok(Self { + inner, + outbound: outbound_tx, + reader: Some(reader), + }) + } + + pub async fn snapshot(&self) -> SessionSnapshot { + self.inner.read().await.snapshot.clone() + } + + pub fn send_chat(&self, content: &str) -> Result<(), String> { + self.outbound + .send(ClientMessage::ChatSend { + content: content.to_string(), + }) + .map_err(|_| "not connected".to_string()) + } + + pub fn send_prompt(&self, content: &str) -> Result<(), String> { + self.outbound + .send(ClientMessage::CollabInput { + content: content.to_string(), + }) + .map_err(|_| "not connected".to_string()) + } + + pub fn send_typing(&self) -> Result<(), String> { + self.outbound + .send(ClientMessage::Typing) + .map_err(|_| "not connected".to_string()) + } + + pub async fn disconnect(mut self) { + drop(self.outbound); + if let Some(handle) = self.reader.take() { + let _ = tokio::time::timeout(Duration::from_secs(1), handle).await; + } + } +} + +fn normalize_ws_url(host: &str) -> Result { + let trimmed = host.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return Err("host is empty".into()); + } + if trimmed.starts_with("ws://") || trimmed.starts_with("wss://") { + if trimmed.ends_with("/ws") { + return Ok(trimmed.to_string()); + } + return Ok(format!("{trimmed}/ws")); + } + // Bare host:port or host + Ok(format!("ws://{trimmed}/ws")) +} + +fn apply_server_message(snapshot: &mut SessionSnapshot, msg: &ServerMessage) { + match msg { + ServerMessage::SessionHistory { events } => { + snapshot.status = JoinStatus::Connected; + snapshot.recent_events = events.clone(); + if let Some(first) = events.first() { + snapshot.session_id = Some(first.session_id.clone()); + } + snapshot.last_error = None; + } + ServerMessage::SessionEvent { event } => { + if snapshot.session_id.is_none() { + snapshot.session_id = Some(event.session_id.clone()); + } + snapshot.recent_events.push(event.clone()); + if snapshot.recent_events.len() > 50 { + let drain = snapshot.recent_events.len() - 50; + snapshot.recent_events.drain(0..drain); + } + } + ServerMessage::ChatMessage { message } => { + snapshot.recent_chat.push(message.clone()); + if snapshot.recent_chat.len() > 50 { + let drain = snapshot.recent_chat.len() - 50; + snapshot.recent_chat.drain(0..drain); + } + } + ServerMessage::UserList { users } => { + snapshot.users = users.clone(); + } + ServerMessage::UserJoined { user } => { + snapshot.users.push(user.clone()); + } + ServerMessage::UserLeft { user_id } => { + snapshot.users.retain(|u| u.user_id != *user_id); + } + ServerMessage::UserRoleChanged { user_id, role } => { + for u in &mut snapshot.users { + if u.user_id == *user_id { + u.role = role.clone(); + } + } + } + ServerMessage::SessionClosed => { + snapshot.status = JoinStatus::Disconnected; + } + ServerMessage::Error { message, .. } => { + snapshot.status = JoinStatus::Error; + snapshot.last_error = Some(message.clone()); + } + ServerMessage::UserTyping { .. } => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn normalize_urls() { + assert_eq!( + normalize_ws_url("127.0.0.1:7742").unwrap(), + "ws://127.0.0.1:7742/ws" + ); + assert_eq!( + normalize_ws_url("ws://127.0.0.1:7742").unwrap(), + "ws://127.0.0.1:7742/ws" + ); + assert_eq!( + normalize_ws_url("ws://127.0.0.1:7742/ws").unwrap(), + "ws://127.0.0.1:7742/ws" + ); + } + + #[test] + fn apply_history_and_chat() { + let mut snap = SessionSnapshot::new("h", "Alice"); + apply_server_message( + &mut snap, + &ServerMessage::SessionHistory { + events: vec![SessionEvent { + id: "e1".into(), + session_id: "sess".into(), + event_type: "message.created".into(), + payload: json!({"text": "hi"}), + timestamp: 1, + }], + }, + ); + assert_eq!(snap.status, JoinStatus::Connected); + assert_eq!(snap.session_id.as_deref(), Some("sess")); + + apply_server_message( + &mut snap, + &ServerMessage::ChatMessage { + message: ChatMessage { + id: "c1".into(), + session_id: "sess".into(), + user_id: "u1".into(), + display_name: Some("Bob".into()), + content: "yo".into(), + timestamp: 2, + }, + }, + ); + assert_eq!(snap.recent_chat.len(), 1); + } + + #[test] + fn client_messages_match_fixtures_shape() { + let auth = serde_json::to_value(ClientMessage::Auth { + token: "abc123".into(), + display_name: Some("Alice".into()), + }) + .unwrap(); + assert_eq!(auth["type"], "auth"); + assert_eq!(auth["token"], "abc123"); + assert_eq!(auth["displayName"], "Alice"); + + let chat = serde_json::to_value(ClientMessage::ChatSend { + content: "hello chat".into(), + }) + .unwrap(); + assert_eq!(chat["type"], "chat.send"); + + let input = serde_json::to_value(ClientMessage::CollabInput { + content: "fix the bug".into(), + }) + .unwrap(); + assert_eq!(input["type"], "collab.input"); + } +} diff --git a/crates/chorus-zed-helper/src/control.rs b/crates/chorus-zed-helper/src/control.rs new file mode 100644 index 0000000..c113e2e --- /dev/null +++ b/crates/chorus-zed-helper/src/control.rs @@ -0,0 +1,101 @@ +//! JSON-line control protocol between CLI subcommands and the background daemon. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::client::{JoinStatus, SessionSnapshot}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "cmd", rename_all = "snake_case")] +pub enum ControlRequest { + Ping, + Join { + host: String, + token: String, + #[serde(default = "default_display_name")] + display_name: String, + }, + Leave, + Chat { + content: String, + }, + Prompt { + content: String, + }, + Status, + Shutdown, +} + +fn default_display_name() -> String { + "Zed".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ControlResponse { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub snapshot: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +impl ControlResponse { + pub fn ok_msg(message: impl Into) -> Self { + Self { + ok: true, + error: None, + snapshot: None, + message: Some(message.into()), + } + } + + pub fn ok_status(snapshot: SessionSnapshot) -> Self { + Self { + ok: true, + error: None, + snapshot: Some(snapshot), + message: None, + } + } + + pub fn err(error: impl Into) -> Self { + Self { + ok: false, + error: Some(error.into()), + snapshot: None, + message: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct DaemonState { + pub status: Option, + pub host: Option, +} + +pub fn runtime_dir() -> PathBuf { + if let Ok(dir) = std::env::var("CHORUS_ZED_RUNTIME_DIR") { + return PathBuf::from(dir); + } + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + return PathBuf::from(dir).join("chorus-zed"); + } + std::env::temp_dir().join(format!( + "chorus-zed-{}", + std::env::var("USER").unwrap_or_else(|_| "user".into()) + )) +} + +pub fn socket_path() -> PathBuf { + runtime_dir().join("control.sock") +} + +pub fn pid_path() -> PathBuf { + runtime_dir().join("daemon.pid") +} diff --git a/crates/chorus-zed-helper/src/lib.rs b/crates/chorus-zed-helper/src/lib.rs new file mode 100644 index 0000000..9fb6048 --- /dev/null +++ b/crates/chorus-zed-helper/src/lib.rs @@ -0,0 +1,10 @@ +//! Chorus join client used by the Zed helper binary (CLI + MCP). +//! +//! Speaks the joiner `/ws` contract from `crates/chorus-relay` / `protocol/`. + +pub mod client; +pub mod control; +pub mod mcp; + +pub use client::{JoinClient, JoinStatus, SessionSnapshot}; +pub use control::{ControlRequest, ControlResponse, DaemonState}; diff --git a/crates/chorus-zed-helper/src/main.rs b/crates/chorus-zed-helper/src/main.rs new file mode 100644 index 0000000..92bf394 --- /dev/null +++ b/crates/chorus-zed-helper/src/main.rs @@ -0,0 +1,341 @@ +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use clap::{Parser, Subcommand}; +use chorus_zed_helper::client::JoinClient; +use chorus_zed_helper::control::{ + pid_path, runtime_dir, socket_path, ControlRequest, ControlResponse, +}; +use chorus_zed_helper::mcp::{self, format_status}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::Mutex; + +#[derive(Parser, Debug)] +#[command( + name = "chorus-zed-helper", + about = "Chorus join client for Zed (CLI + MCP). Joiner-only — does not host/share." +)] +struct Args { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Start the background daemon that holds the WebSocket session + Daemon, + /// Connect to a Chorus relay as a joiner + Join { + /// Relay host:port (e.g. 192.168.1.10:7742) or ws:// URL + #[arg(long, env = "CHORUS_RELAY_HOST")] + host: String, + /// Join token from the host share command + #[arg(long)] + token: String, + /// Display name shown to collaborators + #[arg(long, default_value = "Zed", env = "CHORUS_DISPLAY_NAME")] + name: String, + }, + /// Disconnect from the current session + Leave, + /// Send a side-channel chat message + Chat { + /// Message text + content: String, + }, + /// Forward a prompt into the host session (`collab.input`) + Prompt { + /// Prompt text + content: String, + }, + /// Print connection status + Status, + /// Run as an MCP stdio server for Zed + Mcp, + /// Shut down the background daemon + Shutdown, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + match args.command { + Commands::Daemon => run_daemon().await?, + Commands::Mcp => { + // MCP uses blocking stdin reads; keep a multi-thread runtime handle. + let handle = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || mcp::run_stdio(handle)) + .await + .map_err(|e| e.to_string())??; + } + Commands::Join { host, token, name } => { + ensure_daemon().await?; + let resp = request(ControlRequest::Join { + host, + token, + display_name: name, + }) + .await?; + print_response(&resp); + if !resp.ok { + std::process::exit(1); + } + } + Commands::Leave => { + let resp = request(ControlRequest::Leave).await?; + print_response(&resp); + if !resp.ok { + std::process::exit(1); + } + } + Commands::Chat { content } => { + let resp = request(ControlRequest::Chat { content }).await?; + print_response(&resp); + if !resp.ok { + std::process::exit(1); + } + } + Commands::Prompt { content } => { + let resp = request(ControlRequest::Prompt { content }).await?; + print_response(&resp); + if !resp.ok { + std::process::exit(1); + } + } + Commands::Status => { + match request(ControlRequest::Status).await { + Ok(resp) => { + print_response(&resp); + if !resp.ok { + std::process::exit(1); + } + } + Err(_) => { + println!("status: disconnected\n(daemon not running)"); + } + } + } + Commands::Shutdown => { + match request(ControlRequest::Shutdown).await { + Ok(resp) => print_response(&resp), + Err(_) => println!("daemon not running"), + } + } + } + Ok(()) +} + +fn print_response(resp: &ControlResponse) { + if let Some(snap) = &resp.snapshot { + print!("{}", format_status(snap)); + } else if let Some(msg) = &resp.message { + println!("{msg}"); + } else if let Some(err) = &resp.error { + eprintln!("error: {err}"); + } else if resp.ok { + println!("ok"); + } +} + +async fn ensure_daemon() -> Result<(), String> { + if ping_daemon().await.is_ok() { + return Ok(()); + } + let dir = runtime_dir(); + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let sock = socket_path(); + if sock.exists() { + let _ = fs::remove_file(&sock); + } + + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let child = Command::new(&exe) + .arg("daemon") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("failed to spawn daemon: {e}"))?; + + fs::write(pid_path(), child.id().to_string()).map_err(|e| e.to_string())?; + + for _ in 0..50 { + if ping_daemon().await.is_ok() { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err("daemon did not become ready".into()) +} + +async fn ping_daemon() -> Result<(), String> { + let resp = request(ControlRequest::Ping).await?; + if resp.ok { + Ok(()) + } else { + Err(resp.error.unwrap_or_else(|| "ping failed".into())) + } +} + +async fn request(req: ControlRequest) -> Result { + let path = socket_path(); + let mut stream = UnixStream::connect(&path) + .await + .map_err(|e| format!("connect control socket: {e}"))?; + let line = serde_json::to_string(&req).map_err(|e| e.to_string())?; + stream + .write_all(line.as_bytes()) + .await + .map_err(|e| e.to_string())?; + stream + .write_all(b"\n") + .await + .map_err(|e| e.to_string())?; + + let mut reader = BufReader::new(stream); + let mut response = String::new(); + reader + .read_line(&mut response) + .await + .map_err(|e| e.to_string())?; + serde_json::from_str(response.trim()).map_err(|e| format!("bad control response: {e}")) +} + +async fn run_daemon() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), + ) + .with_writer(std::io::stderr) + .init(); + + let dir = runtime_dir(); + fs::create_dir_all(&dir)?; + let sock: PathBuf = socket_path(); + if sock.exists() { + fs::remove_file(&sock)?; + } + + let listener = UnixListener::bind(&sock)?; + let session: Mutex> = Mutex::new(None); + + loop { + let (stream, _) = listener.accept().await?; + let mut reader = BufReader::new(stream); + let mut line = String::new(); + if reader.read_line(&mut line).await? == 0 { + continue; + } + let req: ControlRequest = match serde_json::from_str(line.trim()) { + Ok(r) => r, + Err(e) => { + let mut stream = reader.into_inner(); + let resp = ControlResponse::err(format!("bad request: {e}")); + let body = serde_json::to_string(&resp)?; + stream.write_all(body.as_bytes()).await?; + stream.write_all(b"\n").await?; + continue; + } + }; + + let (resp, shutdown) = handle_control(&session, req).await; + let mut stream = reader.into_inner(); + let body = serde_json::to_string(&resp)?; + stream.write_all(body.as_bytes()).await?; + stream.write_all(b"\n").await?; + if shutdown { + break; + } + } + + let _ = fs::remove_file(sock); + let _ = fs::remove_file(pid_path()); + Ok(()) +} + +async fn handle_control( + session: &Mutex>, + req: ControlRequest, +) -> (ControlResponse, bool) { + match req { + ControlRequest::Ping => (ControlResponse::ok_msg("pong"), false), + ControlRequest::Shutdown => { + let mut guard = session.lock().await; + if let Some(client) = guard.take() { + client.disconnect().await; + } + (ControlResponse::ok_msg("shutting down"), true) + } + ControlRequest::Join { + host, + token, + display_name, + } => match JoinClient::connect(&host, &token, &display_name).await { + Ok(client) => { + let snap = client.snapshot().await; + let mut guard = session.lock().await; + if let Some(old) = guard.take() { + old.disconnect().await; + } + *guard = Some(client); + (ControlResponse::ok_status(snap), false) + } + Err(e) => (ControlResponse::err(e), false), + }, + ControlRequest::Leave => { + let mut guard = session.lock().await; + if let Some(client) = guard.take() { + client.disconnect().await; + (ControlResponse::ok_msg("left session"), false) + } else { + (ControlResponse::ok_msg("not connected"), false) + } + } + ControlRequest::Chat { content } => { + let guard = session.lock().await; + match guard.as_ref() { + Some(client) => match client.send_chat(&content) { + Ok(()) => (ControlResponse::ok_msg("chat sent"), false), + Err(e) => (ControlResponse::err(e), false), + }, + None => (ControlResponse::err("not connected"), false), + } + } + ControlRequest::Prompt { content } => { + let guard = session.lock().await; + match guard.as_ref() { + Some(client) => match client.send_prompt(&content) { + Ok(()) => (ControlResponse::ok_msg("prompt sent"), false), + Err(e) => (ControlResponse::err(e), false), + }, + None => (ControlResponse::err("not connected"), false), + } + } + ControlRequest::Status => { + let guard = session.lock().await; + match guard.as_ref() { + Some(client) => { + let snap = client.snapshot().await; + (ControlResponse::ok_status(snap), false) + } + None => ( + ControlResponse::ok_status(chorus_zed_helper::client::SessionSnapshot { + status: chorus_zed_helper::client::JoinStatus::Disconnected, + host: String::new(), + display_name: String::new(), + session_id: None, + users: vec![], + recent_events: vec![], + recent_chat: vec![], + last_error: None, + }), + false, + ), + } + } + } +} diff --git a/crates/chorus-zed-helper/src/mcp.rs b/crates/chorus-zed-helper/src/mcp.rs new file mode 100644 index 0000000..9855c04 --- /dev/null +++ b/crates/chorus-zed-helper/src/mcp.rs @@ -0,0 +1,300 @@ +//! Minimal stdio MCP server exposing Chorus join tools to Zed's Agent Panel. + +use std::io::{BufRead, BufReader, Write}; +use std::sync::Arc; + +use serde_json::{json, Value}; +use tokio::runtime::Handle; +use tokio::sync::Mutex; + +use crate::client::{JoinClient, JoinStatus}; + +struct Session { + client: Option, +} + +/// Run an MCP server on stdin/stdout (blocking; requires a Tokio handle). +pub fn run_stdio(handle: Handle) -> Result<(), String> { + let session = Arc::new(Mutex::new(Session { client: None })); + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout(); + let mut reader = BufReader::new(stdin.lock()); + + loop { + let msg = match read_message(&mut reader)? { + Some(v) => v, + None => break, + }; + + let id = msg.get("id").cloned(); + let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); + let params = msg.get("params").cloned().unwrap_or(json!({})); + + // Notifications (no id) — ignore after handling initialize side-effects if any. + if id.is_none() { + continue; + } + + let result = match method { + "initialize" => Ok(json!({ + "protocolVersion": "2024-11-05", + "capabilities": { "tools": {} }, + "serverInfo": { + "name": "chorus-zed", + "version": env!("CARGO_PKG_VERSION") + } + })), + "tools/list" => Ok(json!({ "tools": tool_defs() })), + "tools/call" => { + let name = params + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or(""); + let args = params + .get("arguments") + .cloned() + .unwrap_or(json!({})); + handle.block_on(call_tool(session.clone(), name, args)) + } + "ping" => Ok(json!({})), + _ => Err(format!("method not found: {method}")), + }; + + let response = match result { + Ok(value) => json!({ + "jsonrpc": "2.0", + "id": id, + "result": value + }), + Err(err) => json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32000, "message": err } + }), + }; + write_message(&mut stdout, &response)?; + } + Ok(()) +} + +fn tool_defs() -> Vec { + vec![ + json!({ + "name": "chorus_join", + "description": "Join an existing Chorus collaborative session as a joiner (connect to relay /ws).", + "inputSchema": { + "type": "object", + "properties": { + "host": { + "type": "string", + "description": "Relay host:port (e.g. 192.168.1.10:7742) or full ws:// URL" + }, + "token": { "type": "string", "description": "Join token from the host" }, + "display_name": { + "type": "string", + "description": "Display name shown to collaborators (default: Zed)" + } + }, + "required": ["host", "token"] + } + }), + json!({ + "name": "chorus_leave", + "description": "Leave the current Chorus session and disconnect from the relay.", + "inputSchema": { "type": "object", "properties": {} } + }), + json!({ + "name": "chorus_chat", + "description": "Send a side-channel chat message (does not become an LLM prompt).", + "inputSchema": { + "type": "object", + "properties": { + "content": { "type": "string", "description": "Chat message text" } + }, + "required": ["content"] + } + }), + json!({ + "name": "chorus_prompt", + "description": "Send a collaborative prompt (collab.input) into the host OpenCode session. Requires edit role.", + "inputSchema": { + "type": "object", + "properties": { + "content": { "type": "string", "description": "Prompt text for the host session" } + }, + "required": ["content"] + } + }), + json!({ + "name": "chorus_status", + "description": "Show join connection status, users, and recent session/chat events.", + "inputSchema": { "type": "object", "properties": {} } + }), + ] +} + +async fn call_tool( + session: Arc>, + name: &str, + args: Value, +) -> Result { + match name { + "chorus_join" => { + let host = args + .get("host") + .and_then(|v| v.as_str()) + .ok_or("host is required")? + .to_string(); + let token = args + .get("token") + .and_then(|v| v.as_str()) + .ok_or("token is required")? + .to_string(); + let display_name = args + .get("display_name") + .and_then(|v| v.as_str()) + .unwrap_or("Zed") + .to_string(); + + let client = JoinClient::connect(&host, &token, &display_name).await?; + let snap = client.snapshot().await; + let mut guard = session.lock().await; + if let Some(old) = guard.client.take() { + old.disconnect().await; + } + guard.client = Some(client); + Ok(tool_text(format!( + "Joined Chorus session on {} as {} (status={:?}, users={})", + snap.host, + snap.display_name, + snap.status, + snap.users.len() + ))) + } + "chorus_leave" => { + let mut guard = session.lock().await; + if let Some(client) = guard.client.take() { + client.disconnect().await; + Ok(tool_text("Left Chorus session.".into())) + } else { + Ok(tool_text("Not connected.".into())) + } + } + "chorus_chat" => { + let content = args + .get("content") + .and_then(|v| v.as_str()) + .ok_or("content is required")?; + let guard = session.lock().await; + let client = guard.client.as_ref().ok_or("not connected — call chorus_join first")?; + client.send_chat(content)?; + Ok(tool_text(format!("Chat sent: {content}"))) + } + "chorus_prompt" => { + let content = args + .get("content") + .and_then(|v| v.as_str()) + .ok_or("content is required")?; + let guard = session.lock().await; + let client = guard.client.as_ref().ok_or("not connected — call chorus_join first")?; + client.send_prompt(content)?; + Ok(tool_text(format!("Prompt forwarded to host: {content}"))) + } + "chorus_status" => { + let guard = session.lock().await; + match guard.client.as_ref() { + None => Ok(tool_text( + "status: disconnected\n(no active Chorus join)".into(), + )), + Some(client) => { + let snap = client.snapshot().await; + Ok(tool_text(format_status(&snap))) + } + } + } + other => Err(format!("unknown tool: {other}")), + } +} + +fn tool_text(text: String) -> Value { + json!({ + "content": [{ "type": "text", "text": text }], + "isError": false + }) +} + +pub fn format_status(snap: &crate::client::SessionSnapshot) -> String { + let mut out = String::new(); + out.push_str(&format!("status: {:?}\n", snap.status)); + out.push_str(&format!("host: {}\n", snap.host)); + out.push_str(&format!("displayName: {}\n", snap.display_name)); + if let Some(id) = &snap.session_id { + out.push_str(&format!("sessionId: {id}\n")); + } + if let Some(err) = &snap.last_error { + out.push_str(&format!("error: {err}\n")); + } + out.push_str(&format!("users ({})", snap.users.len())); + if snap.users.is_empty() { + out.push('\n'); + } else { + out.push('\n'); + for u in &snap.users { + let name = u.display_name.as_deref().unwrap_or("(anonymous)"); + out.push_str(&format!(" - {} [{}] {:?}\n", name, u.user_id, u.role)); + } + } + out.push_str(&format!( + "recentEvents: {} | recentChat: {}\n", + snap.recent_events.len(), + snap.recent_chat.len() + )); + if snap.status == JoinStatus::Connected { + for chat in snap.recent_chat.iter().rev().take(5).collect::>().into_iter().rev() + { + let name = chat.display_name.as_deref().unwrap_or(&chat.user_id); + out.push_str(&format!(" chat <{name}> {}\n", chat.content)); + } + } + out +} + +fn read_message(reader: &mut R) -> Result, String> { + let mut headers = String::new(); + loop { + let mut line = String::new(); + let n = reader + .read_line(&mut line) + .map_err(|e| format!("stdin read: {e}"))?; + if n == 0 { + return Ok(None); + } + if line == "\r\n" || line == "\n" { + break; + } + headers.push_str(&line); + } + + let mut content_length: Option = None; + for header in headers.lines() { + let lower = header.to_ascii_lowercase(); + if let Some(rest) = lower.strip_prefix("content-length:") { + content_length = rest.trim().parse().ok(); + } + } + let len = content_length.ok_or("missing Content-Length")?; + let mut buf = vec![0u8; len]; + reader + .read_exact(&mut buf) + .map_err(|e| format!("body read: {e}"))?; + let value = serde_json::from_slice(&buf).map_err(|e| format!("json: {e}"))?; + Ok(Some(value)) +} + +fn write_message(writer: &mut W, value: &Value) -> Result<(), String> { + let body = serde_json::to_vec(value).map_err(|e| e.to_string())?; + write!(writer, "Content-Length: {}\r\n\r\n", body.len()).map_err(|e| e.to_string())?; + writer.write_all(&body).map_err(|e| e.to_string())?; + writer.flush().map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/crates/chorus-zed-helper/tests/join_client.rs b/crates/chorus-zed-helper/tests/join_client.rs new file mode 100644 index 0000000..bebe9a5 --- /dev/null +++ b/crates/chorus-zed-helper/tests/join_client.rs @@ -0,0 +1,127 @@ +//! Integration: JoinClient against a live chorus-relay (auth, chat, collab.input). + +use std::time::Duration; + +use chorus_relay::protocol::{HostToRelay, RelayToHost, UserRole}; +use chorus_relay::server::{serve, RelayConfig}; +use chorus_zed_helper::client::{JoinClient, JoinStatus}; +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +type WsStream = + tokio_tungstenite::WebSocketStream>; + +async fn start_relay(port: u16, host_token: &str) { + let token = host_token.to_string(); + tokio::spawn(async move { + serve(RelayConfig { + port, + host_token: token, + bind: "127.0.0.1".into(), + }) + .await + .expect("relay failed"); + }); + for _ in 0..50 { + if tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .is_ok() + { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("relay not ready on {port}"); +} + +async fn connect_host(port: u16, host_token: &str) -> WsStream { + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}/host")) + .await + .expect("host connect"); + ws.send(Message::Text( + serde_json::to_string(&HostToRelay::HostAuth { + token: host_token.into(), + }) + .unwrap() + .into(), + )) + .await + .unwrap(); + let msg = recv_json::(&mut ws).await; + assert!(matches!(msg, RelayToHost::HostReady { .. })); + let _ = tokio::time::timeout(Duration::from_millis(100), ws.next()).await; + ws +} + +async fn recv_json(ws: &mut WsStream) -> T { + loop { + let msg = ws.next().await.expect("ended").expect("ws"); + match msg { + Message::Text(t) => return serde_json::from_str(&t).expect("json"), + Message::Close(frame) => panic!("close: {frame:?}"), + _ => continue, + } + } +} + +async fn issue_token(host: &mut WsStream) -> String { + host.send(Message::Text( + serde_json::to_string(&HostToRelay::TokenIssue { + session_id: "sess-zed".into(), + role: Some(UserRole::Edit), + ttl_ms: None, + }) + .unwrap() + .into(), + )) + .await + .unwrap(); + loop { + let msg = recv_json::(host).await; + if let RelayToHost::TokenIssued { token } = msg { + return token.token; + } + } +} + +#[tokio::test] +async fn join_client_auth_chat_and_prompt() { + let port = 17942; + let host_token = "zed-helper-test-token"; + start_relay(port, host_token).await; + let mut host = connect_host(port, host_token).await; + let token = issue_token(&mut host).await; + + let client = JoinClient::connect(&format!("127.0.0.1:{port}"), &token, "ZedTester") + .await + .expect("join"); + let snap = client.snapshot().await; + assert_eq!(snap.status, JoinStatus::Connected); + + client.send_chat("hello from zed helper").unwrap(); + // Host should receive chat.message + let mut saw_chat = false; + for _ in 0..20 { + let msg = recv_json::(&mut host).await; + if let RelayToHost::ChatMessage { message } = msg { + assert_eq!(message.content, "hello from zed helper"); + saw_chat = true; + break; + } + } + assert!(saw_chat, "host did not receive chat"); + + client.send_prompt("fix the bug").unwrap(); + let mut saw_prompt = false; + for _ in 0..20 { + let msg = recv_json::(&mut host).await; + if let RelayToHost::CollabInput { content, .. } = msg { + assert_eq!(content, "fix the bug"); + saw_prompt = true; + break; + } + } + assert!(saw_prompt, "host did not receive collab.input"); + + client.disconnect().await; +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index b1bd64b..d46f91c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -49,7 +49,7 @@ Recommended shape: - **v1:** OpenCode adapter = this repo’s plugin + local **Rust** `chorus-relay` - **v1.x:** keep `@chorus/shared` as the stable joiner + host-control contract; `@chorus/client` holds `JoinClient` / `RelayServer` for non-OpenCode hosts -- **v1.x adapters in-monorepo:** VS Code (`packages/vscode`) and Zed (when present) — same wire protocol, host-specific UI only +- **v1.x adapters in-monorepo:** VS Code (`packages/vscode`) and Zed (`packages/zed` + `crates/chorus-zed-helper`) — same wire protocol, host-specific UI only. Zed v1 is joiner/MCP-only (no host share). - **v2:** optional further adapters (Codex app-server remains a strong candidate) once OpenCode UX is solid; split adapter packages into separate repos only when publish/CI ownership diverges **Do not** make Claude Code / Amp the primary host — large audiences, but proprietary control planes. Treat them as distribution adapters later, not the core. diff --git a/docs/STATUS.md b/docs/STATUS.md index 678d4d5..b103378 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -14,6 +14,8 @@ Chorus is an **OpenCode↔OpenCode** LAN collaboration stack: The browser companion (`packages/web`) was intentionally removed. Joiners now mirror the host transcript into their OpenCode session (`[Host]:` / `[AI]:` lines via `noReply` inject); side-channel chat/typing remain toasts. The in-process Bun relay has been replaced by `crates/chorus-relay`. +**Editor adapters (in progress):** VS Code (`packages/vscode`) and Zed (`packages/zed` + `crates/chorus-zed-helper`). Zed v1 is **joiner-only** via MCP/CLI — no host share and no OpenCode transcript injection. + Differentiation vs nearby OpenCode plugins (`opencode-live`, `opencode-sessions`, `opencode-ensemble`, `opencode-relay`): those target **multi-agent / same-DB sync**. Chorus targets **multi-human** pair programming on one live AI session. ## Priority backlog diff --git a/packages/zed/Cargo.lock b/packages/zed/Cargo.lock new file mode 100644 index 0000000..7aec8ee --- /dev/null +++ b/packages/zed/Cargo.lock @@ -0,0 +1,829 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "auditable-serde" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7bf8143dfc3c0258df908843e169b5cc5fcf76c7718bd66135ef4a9cd558c5" +dependencies = [ + "semver", + "serde", + "serde_json", + "topological-sort", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chorus-zed" +version = "0.1.0" +dependencies = [ + "zed_extension_api", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "spdx" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" +dependencies = [ + "smallvec", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "topological-sort" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "wasm-encoder" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb72f02e7fbf07183443b27b0f3d4144abf8c114189f2e088ed95b696a7822" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1ef0faabbbba6674e97a56bee857ccddf942785a336c8b47b42373c922a91d" +dependencies = [ + "anyhow", + "auditable-serde", + "flate2", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "url", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10fb6648689b3929d56bbc7eb1acf70c9a42a29eb5358c67c10f54dbd5d695de" +dependencies = [ + "wit-bindgen-rt", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92fa781d4f2ff6d3f27f3cc9b74a73327b31ca0dc4a3ef25a0ce2983e0e5af9b" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621" +dependencies = [ + "bitflags", + "futures", + "once_cell", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad19eec017904e04c60719592a803ee5da76cb51c81e3f6fbf9457f59db49799" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zed_extension_api" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/zed/Cargo.toml b/packages/zed/Cargo.toml new file mode 100644 index 0000000..4db7fa4 --- /dev/null +++ b/packages/zed/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "chorus-zed" +version = "0.1.0" +edition = "2021" +license = "MIT" +publish = false +description = "Zed extension that launches the Chorus join helper as an MCP context server" + +# Standalone from the monorepo Cargo workspace (wasm32-wasip2 via Zed). +[workspace] + +[lib] +crate-type = ["cdylib"] +path = "src/lib.rs" + +[dependencies] +zed_extension_api = "0.7.0" diff --git a/packages/zed/README.md b/packages/zed/README.md new file mode 100644 index 0000000..09f12f3 --- /dev/null +++ b/packages/zed/README.md @@ -0,0 +1,102 @@ +# Chorus Zed extension (v1 — joiner) + +Join an existing Chorus collaborative session from [Zed](https://zed.dev) via a **native helper** + **MCP context server**. + +Zed extensions run as `wasm32-wasip2` and cannot hold long-lived WebSocket UIs themselves. This package is the thin WASM adapter; `crates/chorus-zed-helper` speaks the Chorus `/ws` protocol. + +## What works (v1) + +| Capability | How | +|---|---| +| Join a host’s relay | MCP tool `chorus_join` or CLI `chorus-zed-helper join` | +| Leave | `chorus_leave` / `leave` | +| Side-channel chat | `chorus_chat` / `chat` | +| Forward prompts (`collab.input`) | `chorus_prompt` / `prompt` (needs `edit` role) | +| Status / recent events | `chorus_status` / `status` | + +## What does **not** work (honest limits) + +- **Host share / stop** — Zed cannot inject into an OpenCode agent session. Hosting still requires the OpenCode plugin (`/chorus-share`). +- **OpenCode transcript mirroring** — session events are visible via `chorus_status` / helper state, not injected into a local agent transcript. +- **Extension slash commands** — Zed removed extension slash commands; use MCP tools (or the CLI). Tool names mirror OpenCode’s `/chorus-join`, `/chorus-leave`, `/chorus-chat`, `/chorus-status`. + +## Architecture + +``` +Zed Agent Panel + └─ MCP (stdio) ──► chorus-zed-helper mcp + └─ WebSocket ──► chorus-relay /ws + └─ host OpenCode plugin +``` + +CLI subcommands talk to a short-lived background daemon over a Unix socket so `join` / `chat` / `status` share one connection. + +## Install the native helper + +From the monorepo root: + +```sh +cargo build -p chorus-zed-helper --release +# put it on PATH, e.g.: +cp target/release/chorus-zed-helper ~/.local/bin/ +``` + +Or: + +```sh +cargo install --path crates/chorus-zed-helper +``` + +Verify: + +```sh +chorus-zed-helper --help +``` + +## Install Dev Extension in Zed + +1. Build/install `chorus-zed-helper` on `PATH` (above). +2. Install the `wasm32-wasip2` target if needed: `rustup target add wasm32-wasip2`. +3. In Zed: command palette → **zed: extensions**. +4. Click **Install Dev Extension**. +5. Select this directory: `packages/zed` (the folder that contains `extension.toml`). +6. Enable the **Chorus** context server in Agent / MCP settings if it is not auto-enabled. +7. Confirm tools `chorus_join`, `chorus_leave`, `chorus_chat`, `chorus_prompt`, `chorus_status` appear. + +If the context server fails to start, Zed cannot find `chorus-zed-helper`. Fix `PATH`, or register a manual context server in `settings.json`: + +```json +{ + "context_servers": { + "chorus": { + "command": "/absolute/path/to/chorus-zed-helper", + "args": ["mcp"] + } + } +} +``` + +## CLI usage (without Zed) + +```sh +# Host must already be sharing via OpenCode /chorus-share +chorus-zed-helper join --host 192.168.1.10:7742 --token --name Alex +chorus-zed-helper status +chorus-zed-helper chat "hey — looking at the failing test" +chorus-zed-helper prompt "fix the flaky auth test" +chorus-zed-helper leave +``` + +## Develop / test + +```sh +# Helper unit + integration tests (spins up chorus-relay) +cargo test -p chorus-zed-helper + +# Extension WASM (optional local check; Zed also builds on Install Dev Extension) +cd packages/zed +rustup target add wasm32-wasip2 +cargo build --target wasm32-wasip2 +``` + +Protocol fixtures live in `/protocol`; Rust types are shared via `chorus-relay::protocol`. diff --git a/packages/zed/extension.toml b/packages/zed/extension.toml new file mode 100644 index 0000000..4340768 --- /dev/null +++ b/packages/zed/extension.toml @@ -0,0 +1,11 @@ +id = "chorus" +name = "Chorus" +description = "Join Chorus collaborative AI sessions from Zed (joiner via native helper + MCP)" +version = "0.1.0" +schema_version = 1 +authors = ["Steven Roomberg"] +repository = "https://github.com/sroomberg/chorus" + +[context_servers.chorus] +name = "Chorus" +description = "Join/leave Chorus sessions, side-channel chat, and collab prompts" diff --git a/packages/zed/src/lib.rs b/packages/zed/src/lib.rs new file mode 100644 index 0000000..dcb7d0d --- /dev/null +++ b/packages/zed/src/lib.rs @@ -0,0 +1,27 @@ +use zed_extension_api::{self as zed, Command, ContextServerId, Project, Result}; + +struct ChorusExtension; + +impl zed::Extension for ChorusExtension { + fn new() -> Self { + Self + } + + fn context_server_command( + &mut self, + _context_server_id: &ContextServerId, + _project: &Project, + ) -> Result { + // The WebSocket join client lives in the native `chorus-zed-helper` binary. + // Install it on PATH (`cargo install --path crates/chorus-zed-helper` from the repo, + // or `cargo build -p chorus-zed-helper --release` and copy `target/release/chorus-zed-helper`). + // Override the binary path via Zed settings `context_servers.chorus` env if needed. + Ok(Command { + command: "chorus-zed-helper".into(), + args: vec!["mcp".into()], + env: vec![], + }) + } +} + +zed::register_extension!(ChorusExtension); From a29eb58973a9e2e8df12da82de5944841e9a2db7 Mon Sep 17 00:00:00 2001 From: sroomberg Date: Wed, 19 Aug 2026 09:00:04 -0400 Subject: [PATCH 4/4] Remove the Zed adapter from this pull request. Keep the VS Code extension and shared client on this branch; Zed moves to a follow-up PR. --- .gitignore | 1 - CHANGELOG.md | 3 +- Cargo.lock | 16 - Cargo.toml | 4 +- README.md | 13 +- crates/chorus-zed-helper/Cargo.toml | 29 - crates/chorus-zed-helper/src/client.rs | 390 -------- crates/chorus-zed-helper/src/control.rs | 105 --- crates/chorus-zed-helper/src/lib.rs | 10 - crates/chorus-zed-helper/src/main.rs | 367 -------- crates/chorus-zed-helper/src/mcp.rs | 323 ------- crates/chorus-zed-helper/tests/join_client.rs | 127 --- docs/DECISIONS.md | 2 +- docs/STATUS.md | 3 +- package.json | 9 +- packages/zed/Cargo.lock | 829 ------------------ packages/zed/Cargo.toml | 17 - packages/zed/README.md | 110 --- packages/zed/extension.toml | 11 - packages/zed/src/lib.rs | 27 - scripts/zed-e2e.ts | 128 --- 21 files changed, 11 insertions(+), 2513 deletions(-) delete mode 100644 crates/chorus-zed-helper/Cargo.toml delete mode 100644 crates/chorus-zed-helper/src/client.rs delete mode 100644 crates/chorus-zed-helper/src/control.rs delete mode 100644 crates/chorus-zed-helper/src/lib.rs delete mode 100644 crates/chorus-zed-helper/src/main.rs delete mode 100644 crates/chorus-zed-helper/src/mcp.rs delete mode 100644 crates/chorus-zed-helper/tests/join_client.rs delete mode 100644 packages/zed/Cargo.lock delete mode 100644 packages/zed/Cargo.toml delete mode 100644 packages/zed/README.md delete mode 100644 packages/zed/extension.toml delete mode 100644 packages/zed/src/lib.rs delete mode 100644 scripts/zed-e2e.ts diff --git a/.gitignore b/.gitignore index 143d31e..96466e4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ coverage/ playwright-report/ test-results/ target/ -packages/zed/target/ .multi-agent/ *.env *.env.local diff --git a/CHANGELOG.md b/CHANGELOG.md index 2af4b09..f564e01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,8 @@ - Session access control: host approval, required display names, repo/email/domain gates (`session.policy` / `auth.pending` / `host.approve|deny`) - Configurable git remote prefixes and host rewrites; layered `chorus.json` org settings -- Hybrid OpenCode security e2e, VS Code/Zed adapter e2e, and Cloud Agent install script +- Hybrid OpenCode security e2e, VS Code adapter e2e, and Cloud Agent install script - VS Code extension (`packages/vscode`) — share/join/chat; Session sidebar; approve/deny -- Zed joiner extension (`packages/zed`) + `chorus-zed-helper` (MCP/CLI) - Extract `@chorus/client` (`JoinClient` + `RelayServer`) for reuse by OpenCode, VS Code, and future adapters - Real-time shared transcript for all agents: fan out collaborator prompts to every joiner, abort joiner local LLM, mirror `[Host]`/`[name]`/`[AI]` (prefer web UI for live view) - Joiner mirrors host prompts and AI replies into its OpenCode session transcript (`[Host]:` / `[AI]:`), including history replay on join (replaces toast-only viewing) diff --git a/Cargo.lock b/Cargo.lock index abf8ceb..e14f344 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,22 +175,6 @@ dependencies = [ "url", ] -[[package]] -name = "chorus-zed-helper" -version = "0.1.0" -dependencies = [ - "chorus-relay", - "clap", - "futures-util", - "serde", - "serde_json", - "tokio", - "tokio-tungstenite 0.26.2", - "tracing", - "tracing-subscriber", - "url", -] - [[package]] name = "clap" version = "4.6.5" diff --git a/Cargo.toml b/Cargo.toml index a9cad15..265d60d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,6 @@ [workspace] resolver = "2" -members = ["crates/chorus-relay", "crates/chorus-zed-helper"] -exclude = ["packages/zed"] -# packages/zed is a standalone Zed extension (wasm32-wasip2) built by Zed / cargo --target. +members = ["crates/chorus-relay"] [workspace.package] edition = "2021" diff --git a/README.md b/README.md index e3a780f..1567ca3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Collaborative OpenCode session sharing. Pair-program a live AI session from anot ## How it works -Chorus is an [OpenCode](https://github.com/sst/opencode) plugin (plus VS Code / other adapters) and a **Rust WebSocket relay** (`chorus-relay`). +Chorus is an [OpenCode](https://github.com/sst/opencode) plugin (plus a VS Code adapter) and a **Rust WebSocket relay** (`chorus-relay`). ``` Host runs opencode → plugin loads @@ -52,27 +52,22 @@ One monorepo, two ecosystems, one wire contract: | `packages/plugin` | npm `@chorus/plugin` | OpenCode plugin — tools, hooks, spawns/manages relay | | `packages/client` | npm `@chorus/client` | Shared `JoinClient` + `RelayServer` for host adapters | | `packages/vscode` | VS Code extension `chorus` | Share/join Chorus sessions from VS Code | -| `packages/zed` | Zed extension `chorus` | Joiner adapter (WASM + MCP → `chorus-zed-helper`) | | `packages/shared` | npm `@chorus/shared` | TypeScript types + codecs for joiner and host-control protocols | | `crates/chorus-relay` | `chorus-relay` binary | Rust WebSocket relay (`/ws` joiners, `/host` control plane) | -| `crates/chorus-zed-helper` | `chorus-zed-helper` binary | Native Chorus join client (CLI + MCP) for Zed | | `protocol/` | fixtures (not published) | Canonical JSON examples both TS and Rust must deserialize | -Root `package.json` scripts are the only task entry (`build`, `test`, `typecheck`). Bun workspaces own `packages/*`; Cargo owns `crates/*` (Zed’s `packages/zed` is built separately for `wasm32-wasip2`). +Root `package.json` scripts are the only task entry (`build`, `test`, `typecheck`). Bun workspaces own `packages/*`; Cargo owns `crates/*`. ## Development ```sh bun install -bun run build # release relay + helper + TS packages -bun run test # relay/helper tests + TS/Bun tests (includes protocol fixtures) +bun run build # release relay + TS packages +bun run test # relay tests + TS/Bun tests (includes protocol fixtures) bun run typecheck cargo test -p chorus-relay -cargo test -p chorus-zed-helper ``` -Zed joiner (optional): see [packages/zed/README.md](packages/zed/README.md) for Install Dev Extension + `chorus-zed-helper` setup. - ### Multi-agent local testing Run several isolated OpenCode servers on one machine (each with Chorus loaded) and drive share/join over the HTTP API: diff --git a/crates/chorus-zed-helper/Cargo.toml b/crates/chorus-zed-helper/Cargo.toml deleted file mode 100644 index 67bbbeb..0000000 --- a/crates/chorus-zed-helper/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "chorus-zed-helper" -version = "0.1.0" -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "Native Chorus join client for the Zed extension (CLI + MCP)" -publish = false - -[[bin]] -name = "chorus-zed-helper" -path = "src/main.rs" - -[dependencies] -chorus-relay = { path = "../chorus-relay" } -clap = { version = "4", features = ["derive", "env"] } -futures-util = "0.3" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tokio = { version = "1", features = ["full"] } -tokio-tungstenite = "0.26" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -url = "2" - -[dev-dependencies] -chorus-relay = { path = "../chorus-relay" } -tokio-tungstenite = "0.26" -futures-util = "0.3" diff --git a/crates/chorus-zed-helper/src/client.rs b/crates/chorus-zed-helper/src/client.rs deleted file mode 100644 index 5b6ab9f..0000000 --- a/crates/chorus-zed-helper/src/client.rs +++ /dev/null @@ -1,390 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use chorus_relay::protocol::{ - ChatMessage, ClientMessage, ConnectedUser, ServerMessage, SessionEvent, -}; -use futures_util::{SinkExt, StreamExt}; -use serde::{Deserialize, Serialize}; -use tokio::sync::{mpsc, RwLock}; -use tokio::task::JoinHandle; -use tokio_tungstenite::{connect_async, tungstenite::Message}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum JoinStatus { - Disconnected, - Connecting, - Pending, - Connected, - Error, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionSnapshot { - pub status: JoinStatus, - pub host: String, - pub display_name: String, - pub session_id: Option, - pub users: Vec, - pub recent_events: Vec, - pub recent_chat: Vec, - pub last_error: Option, -} - -impl SessionSnapshot { - fn new(host: &str, display_name: &str) -> Self { - Self { - status: JoinStatus::Disconnected, - host: host.to_string(), - display_name: display_name.to_string(), - session_id: None, - users: Vec::new(), - recent_events: Vec::new(), - recent_chat: Vec::new(), - last_error: None, - } - } -} - -/// Live joiner WebSocket client for the Chorus `/ws` surface. -pub struct JoinClient { - inner: Arc>, - outbound: mpsc::UnboundedSender, - reader: Option>, -} - -struct Inner { - snapshot: SessionSnapshot, -} - -impl JoinClient { - /// Connect to `ws://host/ws` (or `wss://`), authenticate, and wait for - /// `session.history` (active) or `auth.pending` (awaiting host approval). - pub async fn connect( - host: &str, - token: &str, - display_name: &str, - repo_remote: Option<&str>, - email: Option<&str>, - ) -> Result { - let name = display_name.trim(); - if name.is_empty() { - return Err("display name is required".into()); - } - let ws_url = normalize_ws_url(host)?; - let (ws, _) = connect_async(&ws_url) - .await - .map_err(|e| format!("connect failed: {e}"))?; - - let (mut write, mut read) = ws.split(); - let auth = ClientMessage::Auth { - token: token.to_string(), - display_name: name.to_string(), - repo_remote: repo_remote - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string), - email: email - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_ascii_lowercase), - }; - write - .send(Message::Text( - serde_json::to_string(&auth) - .map_err(|e| e.to_string())? - .into(), - )) - .await - .map_err(|e| format!("auth send failed: {e}"))?; - - let mut snapshot = SessionSnapshot::new(host, name); - snapshot.status = JoinStatus::Connecting; - - // Wait until session.history (connected), auth.pending, deny/error, or timeout. - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); - loop { - if tokio::time::Instant::now() > deadline { - return Err("timed out waiting for session.history or auth.pending".into()); - } - let next = tokio::time::timeout(Duration::from_secs(5), read.next()) - .await - .map_err(|_| "timed out waiting for relay message".to_string())?; - let msg = match next { - Some(Ok(Message::Text(t))) => t, - Some(Ok(Message::Close(frame))) => { - return Err(format!("connection closed before auth: {frame:?}")); - } - Some(Ok(_)) => continue, - Some(Err(e)) => return Err(format!("ws error: {e}")), - None => return Err("connection closed before auth".into()), - }; - let server: ServerMessage = - serde_json::from_str(&msg).map_err(|e| format!("bad server json: {e}"))?; - apply_server_message(&mut snapshot, &server); - match server { - ServerMessage::SessionHistory { .. } | ServerMessage::AuthPending { .. } => break, - ServerMessage::AuthDenied { message } => { - return Err(message); - } - ServerMessage::Error { message, .. } => { - return Err(message); - } - _ => continue, - } - } - - let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::(); - let inner = Arc::new(RwLock::new(Inner { snapshot })); - - let writer_inner = Arc::clone(&inner); - let reader = tokio::spawn(async move { - loop { - tokio::select! { - outbound = outbound_rx.recv() => { - let Some(msg) = outbound else { break; }; - let Ok(text) = serde_json::to_string(&msg) else { continue; }; - if write.send(Message::Text(text.into())).await.is_err() { - let mut guard = writer_inner.write().await; - guard.snapshot.status = JoinStatus::Disconnected; - break; - } - } - incoming = read.next() => { - match incoming { - Some(Ok(Message::Text(t))) => { - if let Ok(server) = serde_json::from_str::(&t) { - let mut guard = writer_inner.write().await; - apply_server_message(&mut guard.snapshot, &server); - if matches!(server, ServerMessage::SessionClosed) { - break; - } - } - } - Some(Ok(Message::Close(_))) | None => { - let mut guard = writer_inner.write().await; - guard.snapshot.status = JoinStatus::Disconnected; - break; - } - Some(Ok(_)) => {} - Some(Err(e)) => { - let mut guard = writer_inner.write().await; - guard.snapshot.status = JoinStatus::Error; - guard.snapshot.last_error = Some(e.to_string()); - break; - } - } - } - } - } - }); - - Ok(Self { - inner, - outbound: outbound_tx, - reader: Some(reader), - }) - } - - pub async fn snapshot(&self) -> SessionSnapshot { - self.inner.read().await.snapshot.clone() - } - - pub fn send_chat(&self, content: &str) -> Result<(), String> { - // Pending joiners are blocked by the relay; fail closed client-side too. - // Snapshot check is best-effort (async); relay enforces the real gate. - self.outbound - .send(ClientMessage::ChatSend { - content: content.to_string(), - }) - .map_err(|_| "not connected".to_string()) - } - - pub fn send_prompt(&self, content: &str) -> Result<(), String> { - self.outbound - .send(ClientMessage::CollabInput { - content: content.to_string(), - }) - .map_err(|_| "not connected".to_string()) - } - - pub fn send_typing(&self) -> Result<(), String> { - self.outbound - .send(ClientMessage::Typing) - .map_err(|_| "not connected".to_string()) - } - - pub async fn disconnect(mut self) { - drop(self.outbound); - if let Some(handle) = self.reader.take() { - let _ = tokio::time::timeout(Duration::from_secs(1), handle).await; - } - } -} - -fn normalize_ws_url(host: &str) -> Result { - let trimmed = host.trim().trim_end_matches('/'); - if trimmed.is_empty() { - return Err("host is empty".into()); - } - if trimmed.starts_with("ws://") || trimmed.starts_with("wss://") { - if trimmed.ends_with("/ws") { - return Ok(trimmed.to_string()); - } - return Ok(format!("{trimmed}/ws")); - } - // Bare host:port or host - Ok(format!("ws://{trimmed}/ws")) -} - -fn apply_server_message(snapshot: &mut SessionSnapshot, msg: &ServerMessage) { - match msg { - ServerMessage::SessionHistory { events } => { - snapshot.status = JoinStatus::Connected; - snapshot.recent_events = events.clone(); - if let Some(first) = events.first() { - snapshot.session_id = Some(first.session_id.clone()); - } - snapshot.last_error = None; - } - ServerMessage::AuthPending { .. } => { - snapshot.status = JoinStatus::Pending; - snapshot.last_error = None; - } - ServerMessage::AuthDenied { message } => { - snapshot.status = JoinStatus::Error; - snapshot.last_error = Some(message.clone()); - } - ServerMessage::SessionEvent { event } => { - if snapshot.session_id.is_none() { - snapshot.session_id = Some(event.session_id.clone()); - } - snapshot.recent_events.push(event.clone()); - if snapshot.recent_events.len() > 50 { - let drain = snapshot.recent_events.len() - 50; - snapshot.recent_events.drain(0..drain); - } - // Approval often arrives as history/events after pending. - if snapshot.status == JoinStatus::Pending { - snapshot.status = JoinStatus::Connected; - } - } - ServerMessage::ChatMessage { message } => { - snapshot.recent_chat.push(message.clone()); - if snapshot.recent_chat.len() > 50 { - let drain = snapshot.recent_chat.len() - 50; - snapshot.recent_chat.drain(0..drain); - } - } - ServerMessage::UserList { users } => { - snapshot.users = users.clone(); - } - ServerMessage::UserJoined { user } => { - snapshot.users.push(user.clone()); - } - ServerMessage::UserLeft { user_id } => { - snapshot.users.retain(|u| u.user_id != *user_id); - } - ServerMessage::UserRoleChanged { user_id, role } => { - for u in &mut snapshot.users { - if u.user_id == *user_id { - u.role = role.clone(); - } - } - } - ServerMessage::SessionClosed => { - snapshot.status = JoinStatus::Disconnected; - } - ServerMessage::Error { message, .. } => { - snapshot.status = JoinStatus::Error; - snapshot.last_error = Some(message.clone()); - } - ServerMessage::UserTyping { .. } => {} - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn normalize_urls() { - assert_eq!( - normalize_ws_url("127.0.0.1:7742").unwrap(), - "ws://127.0.0.1:7742/ws" - ); - assert_eq!( - normalize_ws_url("ws://127.0.0.1:7742").unwrap(), - "ws://127.0.0.1:7742/ws" - ); - assert_eq!( - normalize_ws_url("ws://127.0.0.1:7742/ws").unwrap(), - "ws://127.0.0.1:7742/ws" - ); - } - - #[test] - fn apply_history_and_chat() { - let mut snap = SessionSnapshot::new("h", "Alice"); - apply_server_message( - &mut snap, - &ServerMessage::SessionHistory { - events: vec![SessionEvent { - id: "e1".into(), - session_id: "sess".into(), - event_type: "message.created".into(), - payload: json!({"text": "hi"}), - timestamp: 1, - }], - }, - ); - assert_eq!(snap.status, JoinStatus::Connected); - assert_eq!(snap.session_id.as_deref(), Some("sess")); - - apply_server_message( - &mut snap, - &ServerMessage::ChatMessage { - message: ChatMessage { - id: "c1".into(), - session_id: "sess".into(), - user_id: "u1".into(), - display_name: Some("Bob".into()), - content: "yo".into(), - timestamp: 2, - }, - }, - ); - assert_eq!(snap.recent_chat.len(), 1); - } - - #[test] - fn client_messages_match_fixtures_shape() { - let auth = serde_json::to_value(ClientMessage::Auth { - token: "abc123".into(), - display_name: "Alice".into(), - repo_remote: Some("https://github.com/acme/app.git".into()), - email: Some("alice@acme.com".into()), - }) - .unwrap(); - assert_eq!(auth["type"], "auth"); - assert_eq!(auth["token"], "abc123"); - assert_eq!(auth["displayName"], "Alice"); - assert_eq!(auth["repoRemote"], "https://github.com/acme/app.git"); - assert_eq!(auth["email"], "alice@acme.com"); - - let chat = serde_json::to_value(ClientMessage::ChatSend { - content: "hello chat".into(), - }) - .unwrap(); - assert_eq!(chat["type"], "chat.send"); - - let input = serde_json::to_value(ClientMessage::CollabInput { - content: "fix the bug".into(), - }) - .unwrap(); - assert_eq!(input["type"], "collab.input"); - } -} diff --git a/crates/chorus-zed-helper/src/control.rs b/crates/chorus-zed-helper/src/control.rs deleted file mode 100644 index 4a1061a..0000000 --- a/crates/chorus-zed-helper/src/control.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! JSON-line control protocol between CLI subcommands and the background daemon. - -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; - -use crate::client::{JoinStatus, SessionSnapshot}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "cmd", rename_all = "snake_case")] -pub enum ControlRequest { - Ping, - Join { - host: String, - token: String, - #[serde(default = "default_display_name")] - display_name: String, - #[serde(default)] - repo_remote: Option, - #[serde(default)] - email: Option, - }, - Leave, - Chat { - content: String, - }, - Prompt { - content: String, - }, - Status, - Shutdown, -} - -fn default_display_name() -> String { - "Zed".to_string() -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ControlResponse { - pub ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub snapshot: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, -} - -impl ControlResponse { - pub fn ok_msg(message: impl Into) -> Self { - Self { - ok: true, - error: None, - snapshot: None, - message: Some(message.into()), - } - } - - pub fn ok_status(snapshot: SessionSnapshot) -> Self { - Self { - ok: true, - error: None, - snapshot: Some(snapshot), - message: None, - } - } - - pub fn err(error: impl Into) -> Self { - Self { - ok: false, - error: Some(error.into()), - snapshot: None, - message: None, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct DaemonState { - pub status: Option, - pub host: Option, -} - -pub fn runtime_dir() -> PathBuf { - if let Ok(dir) = std::env::var("CHORUS_ZED_RUNTIME_DIR") { - return PathBuf::from(dir); - } - if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { - return PathBuf::from(dir).join("chorus-zed"); - } - std::env::temp_dir().join(format!( - "chorus-zed-{}", - std::env::var("USER").unwrap_or_else(|_| "user".into()) - )) -} - -pub fn socket_path() -> PathBuf { - runtime_dir().join("control.sock") -} - -pub fn pid_path() -> PathBuf { - runtime_dir().join("daemon.pid") -} diff --git a/crates/chorus-zed-helper/src/lib.rs b/crates/chorus-zed-helper/src/lib.rs deleted file mode 100644 index 9fb6048..0000000 --- a/crates/chorus-zed-helper/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Chorus join client used by the Zed helper binary (CLI + MCP). -//! -//! Speaks the joiner `/ws` contract from `crates/chorus-relay` / `protocol/`. - -pub mod client; -pub mod control; -pub mod mcp; - -pub use client::{JoinClient, JoinStatus, SessionSnapshot}; -pub use control::{ControlRequest, ControlResponse, DaemonState}; diff --git a/crates/chorus-zed-helper/src/main.rs b/crates/chorus-zed-helper/src/main.rs deleted file mode 100644 index bd2ad07..0000000 --- a/crates/chorus-zed-helper/src/main.rs +++ /dev/null @@ -1,367 +0,0 @@ -use std::fs; -use std::path::PathBuf; -use std::process::{Command, Stdio}; -use std::time::Duration; - -use clap::{Parser, Subcommand}; -use chorus_zed_helper::client::JoinClient; -use chorus_zed_helper::control::{ - pid_path, runtime_dir, socket_path, ControlRequest, ControlResponse, -}; -use chorus_zed_helper::mcp::{self, format_status}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::{UnixListener, UnixStream}; -use tokio::sync::Mutex; - -#[derive(Parser, Debug)] -#[command( - name = "chorus-zed-helper", - about = "Chorus join client for Zed (CLI + MCP). Joiner-only — does not host/share." -)] -struct Args { - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand, Debug)] -enum Commands { - /// Start the background daemon that holds the WebSocket session - Daemon, - /// Connect to a Chorus relay as a joiner - Join { - /// Relay host:port (e.g. 192.168.1.10:7742) or ws:// URL - #[arg(long, env = "CHORUS_RELAY_HOST")] - host: String, - /// Join token from the host share command - #[arg(long)] - token: String, - /// Display name shown to collaborators (required non-empty) - #[arg(long, default_value = "Zed", env = "CHORUS_DISPLAY_NAME")] - name: String, - /// Optional git remote when the host enabled a same-repo gate - #[arg(long, env = "CHORUS_REPO_REMOTE")] - repo_remote: Option, - /// Optional email when the host enabled an allowedEmailDomain gate - #[arg(long, env = "CHORUS_EMAIL")] - email: Option, - }, - /// Disconnect from the current session - Leave, - /// Send a side-channel chat message - Chat { - /// Message text - content: String, - }, - /// Forward a prompt into the host session (`collab.input`) - Prompt { - /// Prompt text - content: String, - }, - /// Print connection status - Status, - /// Run as an MCP stdio server for Zed - Mcp, - /// Shut down the background daemon - Shutdown, -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - let args = Args::parse(); - match args.command { - Commands::Daemon => run_daemon().await?, - Commands::Mcp => { - // MCP uses blocking stdin reads; keep a multi-thread runtime handle. - let handle = tokio::runtime::Handle::current(); - tokio::task::spawn_blocking(move || mcp::run_stdio(handle)) - .await - .map_err(|e| e.to_string())??; - } - Commands::Join { - host, - token, - name, - repo_remote, - email, - } => { - ensure_daemon().await?; - let resp = request(ControlRequest::Join { - host, - token, - display_name: name, - repo_remote, - email, - }) - .await?; - print_response(&resp); - if !resp.ok { - std::process::exit(1); - } - } - Commands::Leave => { - let resp = request(ControlRequest::Leave).await?; - print_response(&resp); - if !resp.ok { - std::process::exit(1); - } - } - Commands::Chat { content } => { - let resp = request(ControlRequest::Chat { content }).await?; - print_response(&resp); - if !resp.ok { - std::process::exit(1); - } - } - Commands::Prompt { content } => { - let resp = request(ControlRequest::Prompt { content }).await?; - print_response(&resp); - if !resp.ok { - std::process::exit(1); - } - } - Commands::Status => { - match request(ControlRequest::Status).await { - Ok(resp) => { - print_response(&resp); - if !resp.ok { - std::process::exit(1); - } - } - Err(_) => { - println!("status: disconnected\n(daemon not running)"); - } - } - } - Commands::Shutdown => { - match request(ControlRequest::Shutdown).await { - Ok(resp) => print_response(&resp), - Err(_) => println!("daemon not running"), - } - } - } - Ok(()) -} - -fn print_response(resp: &ControlResponse) { - if let Some(snap) = &resp.snapshot { - print!("{}", format_status(snap)); - } else if let Some(msg) = &resp.message { - println!("{msg}"); - } else if let Some(err) = &resp.error { - eprintln!("error: {err}"); - } else if resp.ok { - println!("ok"); - } -} - -async fn ensure_daemon() -> Result<(), String> { - if ping_daemon().await.is_ok() { - return Ok(()); - } - let dir = runtime_dir(); - fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - let sock = socket_path(); - if sock.exists() { - let _ = fs::remove_file(&sock); - } - - let exe = std::env::current_exe().map_err(|e| e.to_string())?; - let child = Command::new(&exe) - .arg("daemon") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| format!("failed to spawn daemon: {e}"))?; - - fs::write(pid_path(), child.id().to_string()).map_err(|e| e.to_string())?; - - for _ in 0..50 { - if ping_daemon().await.is_ok() { - return Ok(()); - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - Err("daemon did not become ready".into()) -} - -async fn ping_daemon() -> Result<(), String> { - let resp = request(ControlRequest::Ping).await?; - if resp.ok { - Ok(()) - } else { - Err(resp.error.unwrap_or_else(|| "ping failed".into())) - } -} - -async fn request(req: ControlRequest) -> Result { - let path = socket_path(); - let mut stream = UnixStream::connect(&path) - .await - .map_err(|e| format!("connect control socket: {e}"))?; - let line = serde_json::to_string(&req).map_err(|e| e.to_string())?; - stream - .write_all(line.as_bytes()) - .await - .map_err(|e| e.to_string())?; - stream - .write_all(b"\n") - .await - .map_err(|e| e.to_string())?; - - let mut reader = BufReader::new(stream); - let mut response = String::new(); - reader - .read_line(&mut response) - .await - .map_err(|e| e.to_string())?; - serde_json::from_str(response.trim()).map_err(|e| format!("bad control response: {e}")) -} - -async fn run_daemon() -> Result<(), Box> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), - ) - .with_writer(std::io::stderr) - .init(); - - let dir = runtime_dir(); - fs::create_dir_all(&dir)?; - let sock: PathBuf = socket_path(); - if sock.exists() { - fs::remove_file(&sock)?; - } - - let listener = UnixListener::bind(&sock)?; - let session: Mutex> = Mutex::new(None); - - loop { - let (stream, _) = listener.accept().await?; - let mut reader = BufReader::new(stream); - let mut line = String::new(); - if reader.read_line(&mut line).await? == 0 { - continue; - } - let req: ControlRequest = match serde_json::from_str(line.trim()) { - Ok(r) => r, - Err(e) => { - let mut stream = reader.into_inner(); - let resp = ControlResponse::err(format!("bad request: {e}")); - let body = serde_json::to_string(&resp)?; - stream.write_all(body.as_bytes()).await?; - stream.write_all(b"\n").await?; - continue; - } - }; - - let (resp, shutdown) = handle_control(&session, req).await; - let mut stream = reader.into_inner(); - let body = serde_json::to_string(&resp)?; - stream.write_all(body.as_bytes()).await?; - stream.write_all(b"\n").await?; - if shutdown { - break; - } - } - - let _ = fs::remove_file(sock); - let _ = fs::remove_file(pid_path()); - Ok(()) -} - -async fn handle_control( - session: &Mutex>, - req: ControlRequest, -) -> (ControlResponse, bool) { - match req { - ControlRequest::Ping => (ControlResponse::ok_msg("pong"), false), - ControlRequest::Shutdown => { - let mut guard = session.lock().await; - if let Some(client) = guard.take() { - client.disconnect().await; - } - (ControlResponse::ok_msg("shutting down"), true) - } - ControlRequest::Join { - host, - token, - display_name, - repo_remote, - email, - } => { - match JoinClient::connect( - &host, - &token, - &display_name, - repo_remote.as_deref(), - email.as_deref(), - ) - .await - { - Ok(client) => { - let snap = client.snapshot().await; - let mut guard = session.lock().await; - if let Some(old) = guard.take() { - old.disconnect().await; - } - *guard = Some(client); - (ControlResponse::ok_status(snap), false) - } - Err(e) => (ControlResponse::err(e), false), - } - } - ControlRequest::Leave => { - let mut guard = session.lock().await; - if let Some(client) = guard.take() { - client.disconnect().await; - (ControlResponse::ok_msg("left session"), false) - } else { - (ControlResponse::ok_msg("not connected"), false) - } - } - ControlRequest::Chat { content } => { - let guard = session.lock().await; - match guard.as_ref() { - Some(client) => match client.send_chat(&content) { - Ok(()) => (ControlResponse::ok_msg("chat sent"), false), - Err(e) => (ControlResponse::err(e), false), - }, - None => (ControlResponse::err("not connected"), false), - } - } - ControlRequest::Prompt { content } => { - let guard = session.lock().await; - match guard.as_ref() { - Some(client) => match client.send_prompt(&content) { - Ok(()) => (ControlResponse::ok_msg("prompt sent"), false), - Err(e) => (ControlResponse::err(e), false), - }, - None => (ControlResponse::err("not connected"), false), - } - } - ControlRequest::Status => { - let guard = session.lock().await; - match guard.as_ref() { - Some(client) => { - let snap = client.snapshot().await; - (ControlResponse::ok_status(snap), false) - } - None => ( - ControlResponse::ok_status(chorus_zed_helper::client::SessionSnapshot { - status: chorus_zed_helper::client::JoinStatus::Disconnected, - host: String::new(), - display_name: String::new(), - session_id: None, - users: vec![], - recent_events: vec![], - recent_chat: vec![], - last_error: None, - }), - false, - ), - } - } - } -} diff --git a/crates/chorus-zed-helper/src/mcp.rs b/crates/chorus-zed-helper/src/mcp.rs deleted file mode 100644 index 97473de..0000000 --- a/crates/chorus-zed-helper/src/mcp.rs +++ /dev/null @@ -1,323 +0,0 @@ -//! Minimal stdio MCP server exposing Chorus join tools to Zed's Agent Panel. - -use std::io::{BufRead, BufReader, Write}; -use std::sync::Arc; - -use serde_json::{json, Value}; -use tokio::runtime::Handle; -use tokio::sync::Mutex; - -use crate::client::{JoinClient, JoinStatus}; - -struct Session { - client: Option, -} - -/// Run an MCP server on stdin/stdout (blocking; requires a Tokio handle). -pub fn run_stdio(handle: Handle) -> Result<(), String> { - let session = Arc::new(Mutex::new(Session { client: None })); - let stdin = std::io::stdin(); - let mut stdout = std::io::stdout(); - let mut reader = BufReader::new(stdin.lock()); - - loop { - let msg = match read_message(&mut reader)? { - Some(v) => v, - None => break, - }; - - let id = msg.get("id").cloned(); - let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); - let params = msg.get("params").cloned().unwrap_or(json!({})); - - // Notifications (no id) — ignore after handling initialize side-effects if any. - if id.is_none() { - continue; - } - - let result = match method { - "initialize" => Ok(json!({ - "protocolVersion": "2024-11-05", - "capabilities": { "tools": {} }, - "serverInfo": { - "name": "chorus-zed", - "version": env!("CARGO_PKG_VERSION") - } - })), - "tools/list" => Ok(json!({ "tools": tool_defs() })), - "tools/call" => { - let name = params - .get("name") - .and_then(|n| n.as_str()) - .unwrap_or(""); - let args = params - .get("arguments") - .cloned() - .unwrap_or(json!({})); - handle.block_on(call_tool(session.clone(), name, args)) - } - "ping" => Ok(json!({})), - _ => Err(format!("method not found: {method}")), - }; - - let response = match result { - Ok(value) => json!({ - "jsonrpc": "2.0", - "id": id, - "result": value - }), - Err(err) => json!({ - "jsonrpc": "2.0", - "id": id, - "error": { "code": -32000, "message": err } - }), - }; - write_message(&mut stdout, &response)?; - } - Ok(()) -} - -fn tool_defs() -> Vec { - vec![ - json!({ - "name": "chorus_join", - "description": "Join an existing Chorus collaborative session as a joiner (connect to relay /ws).", - "inputSchema": { - "type": "object", - "properties": { - "host": { - "type": "string", - "description": "Relay host:port (e.g. 192.168.1.10:7742) or full ws:// URL" - }, - "token": { "type": "string", "description": "Join token from the host" }, - "display_name": { - "type": "string", - "description": "Required display name shown to collaborators" - }, - "repo_remote": { - "type": "string", - "description": "Optional git remote URL when the host enabled a same-repo gate" - }, - "email": { - "type": "string", - "description": "Optional email when the host enabled an allowedEmailDomain gate" - } - }, - "required": ["host", "token", "display_name"] - } - }), - json!({ - "name": "chorus_leave", - "description": "Leave the current Chorus session and disconnect from the relay.", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "chorus_chat", - "description": "Send a side-channel chat message (does not become an LLM prompt).", - "inputSchema": { - "type": "object", - "properties": { - "content": { "type": "string", "description": "Chat message text" } - }, - "required": ["content"] - } - }), - json!({ - "name": "chorus_prompt", - "description": "Send a collaborative prompt (collab.input) into the host OpenCode session. Requires edit role.", - "inputSchema": { - "type": "object", - "properties": { - "content": { "type": "string", "description": "Prompt text for the host session" } - }, - "required": ["content"] - } - }), - json!({ - "name": "chorus_status", - "description": "Show join connection status, users, and recent session/chat events.", - "inputSchema": { "type": "object", "properties": {} } - }), - ] -} - -async fn call_tool( - session: Arc>, - name: &str, - args: Value, -) -> Result { - match name { - "chorus_join" => { - let host = args - .get("host") - .and_then(|v| v.as_str()) - .ok_or("host is required")? - .to_string(); - let token = args - .get("token") - .and_then(|v| v.as_str()) - .ok_or("token is required")? - .to_string(); - let display_name = args - .get("display_name") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .ok_or("display_name is required and must be non-empty")? - .to_string(); - let repo_remote = args - .get("repo_remote") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()); - let email = args - .get("email") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()); - - let client = - JoinClient::connect(&host, &token, &display_name, repo_remote, email).await?; - let snap = client.snapshot().await; - let mut guard = session.lock().await; - if let Some(old) = guard.client.take() { - old.disconnect().await; - } - guard.client = Some(client); - Ok(tool_text(format!( - "Joined Chorus session on {} as {} (status={:?}, users={})", - snap.host, - snap.display_name, - snap.status, - snap.users.len() - ))) - } - "chorus_leave" => { - let mut guard = session.lock().await; - if let Some(client) = guard.client.take() { - client.disconnect().await; - Ok(tool_text("Left Chorus session.".into())) - } else { - Ok(tool_text("Not connected.".into())) - } - } - "chorus_chat" => { - let content = args - .get("content") - .and_then(|v| v.as_str()) - .ok_or("content is required")?; - let guard = session.lock().await; - let client = guard.client.as_ref().ok_or("not connected — call chorus_join first")?; - client.send_chat(content)?; - Ok(tool_text(format!("Chat sent: {content}"))) - } - "chorus_prompt" => { - let content = args - .get("content") - .and_then(|v| v.as_str()) - .ok_or("content is required")?; - let guard = session.lock().await; - let client = guard.client.as_ref().ok_or("not connected — call chorus_join first")?; - client.send_prompt(content)?; - Ok(tool_text(format!("Prompt forwarded to host: {content}"))) - } - "chorus_status" => { - let guard = session.lock().await; - match guard.client.as_ref() { - None => Ok(tool_text( - "status: disconnected\n(no active Chorus join)".into(), - )), - Some(client) => { - let snap = client.snapshot().await; - Ok(tool_text(format_status(&snap))) - } - } - } - other => Err(format!("unknown tool: {other}")), - } -} - -fn tool_text(text: String) -> Value { - json!({ - "content": [{ "type": "text", "text": text }], - "isError": false - }) -} - -pub fn format_status(snap: &crate::client::SessionSnapshot) -> String { - let mut out = String::new(); - out.push_str(&format!("status: {:?}\n", snap.status)); - out.push_str(&format!("host: {}\n", snap.host)); - out.push_str(&format!("displayName: {}\n", snap.display_name)); - if let Some(id) = &snap.session_id { - out.push_str(&format!("sessionId: {id}\n")); - } - if let Some(err) = &snap.last_error { - out.push_str(&format!("error: {err}\n")); - } - out.push_str(&format!("users ({})", snap.users.len())); - if snap.users.is_empty() { - out.push('\n'); - } else { - out.push('\n'); - for u in &snap.users { - out.push_str(&format!( - " - {} [{}] {:?} ({:?})\n", - u.display_name, u.user_id, u.role, u.status - )); - } - } - out.push_str(&format!( - "recentEvents: {} | recentChat: {}\n", - snap.recent_events.len(), - snap.recent_chat.len() - )); - if snap.status == JoinStatus::Connected || snap.status == JoinStatus::Pending { - for chat in snap.recent_chat.iter().rev().take(5).collect::>().into_iter().rev() - { - let name = chat.display_name.as_deref().unwrap_or(&chat.user_id); - out.push_str(&format!(" chat <{name}> {}\n", chat.content)); - } - } - out -} - -fn read_message(reader: &mut R) -> Result, String> { - let mut headers = String::new(); - loop { - let mut line = String::new(); - let n = reader - .read_line(&mut line) - .map_err(|e| format!("stdin read: {e}"))?; - if n == 0 { - return Ok(None); - } - if line == "\r\n" || line == "\n" { - break; - } - headers.push_str(&line); - } - - let mut content_length: Option = None; - for header in headers.lines() { - let lower = header.to_ascii_lowercase(); - if let Some(rest) = lower.strip_prefix("content-length:") { - content_length = rest.trim().parse().ok(); - } - } - let len = content_length.ok_or("missing Content-Length")?; - let mut buf = vec![0u8; len]; - reader - .read_exact(&mut buf) - .map_err(|e| format!("body read: {e}"))?; - let value = serde_json::from_slice(&buf).map_err(|e| format!("json: {e}"))?; - Ok(Some(value)) -} - -fn write_message(writer: &mut W, value: &Value) -> Result<(), String> { - let body = serde_json::to_vec(value).map_err(|e| e.to_string())?; - write!(writer, "Content-Length: {}\r\n\r\n", body.len()).map_err(|e| e.to_string())?; - writer.write_all(&body).map_err(|e| e.to_string())?; - writer.flush().map_err(|e| e.to_string())?; - Ok(()) -} diff --git a/crates/chorus-zed-helper/tests/join_client.rs b/crates/chorus-zed-helper/tests/join_client.rs deleted file mode 100644 index 6bc7cf9..0000000 --- a/crates/chorus-zed-helper/tests/join_client.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Integration: JoinClient against a live chorus-relay (auth, chat, collab.input). - -use std::time::Duration; - -use chorus_relay::protocol::{HostToRelay, RelayToHost, UserRole}; -use chorus_relay::server::{serve, RelayConfig}; -use chorus_zed_helper::client::{JoinClient, JoinStatus}; -use futures_util::{SinkExt, StreamExt}; -use tokio_tungstenite::{connect_async, tungstenite::Message}; - -type WsStream = - tokio_tungstenite::WebSocketStream>; - -async fn start_relay(port: u16, host_token: &str) { - let token = host_token.to_string(); - tokio::spawn(async move { - serve(RelayConfig { - port, - host_token: token, - bind: "127.0.0.1".into(), - }) - .await - .expect("relay failed"); - }); - for _ in 0..50 { - if tokio::net::TcpStream::connect(("127.0.0.1", port)) - .await - .is_ok() - { - return; - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - panic!("relay not ready on {port}"); -} - -async fn connect_host(port: u16, host_token: &str) -> WsStream { - let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}/host")) - .await - .expect("host connect"); - ws.send(Message::Text( - serde_json::to_string(&HostToRelay::HostAuth { - token: host_token.into(), - }) - .unwrap() - .into(), - )) - .await - .unwrap(); - let msg = recv_json::(&mut ws).await; - assert!(matches!(msg, RelayToHost::HostReady { .. })); - let _ = tokio::time::timeout(Duration::from_millis(100), ws.next()).await; - ws -} - -async fn recv_json(ws: &mut WsStream) -> T { - loop { - let msg = ws.next().await.expect("ended").expect("ws"); - match msg { - Message::Text(t) => return serde_json::from_str(&t).expect("json"), - Message::Close(frame) => panic!("close: {frame:?}"), - _ => continue, - } - } -} - -async fn issue_token(host: &mut WsStream) -> String { - host.send(Message::Text( - serde_json::to_string(&HostToRelay::TokenIssue { - session_id: "sess-zed".into(), - role: Some(UserRole::Edit), - ttl_ms: None, - }) - .unwrap() - .into(), - )) - .await - .unwrap(); - loop { - let msg = recv_json::(host).await; - if let RelayToHost::TokenIssued { token } = msg { - return token.token; - } - } -} - -#[tokio::test] -async fn join_client_auth_chat_and_prompt() { - let port = 17942; - let host_token = "zed-helper-test-token"; - start_relay(port, host_token).await; - let mut host = connect_host(port, host_token).await; - let token = issue_token(&mut host).await; - - let client = JoinClient::connect(&format!("127.0.0.1:{port}"), &token, "ZedTester", None, None) - .await - .expect("join"); - let snap = client.snapshot().await; - assert_eq!(snap.status, JoinStatus::Connected); - - client.send_chat("hello from zed helper").unwrap(); - // Host should receive chat.message - let mut saw_chat = false; - for _ in 0..20 { - let msg = recv_json::(&mut host).await; - if let RelayToHost::ChatMessage { message } = msg { - assert_eq!(message.content, "hello from zed helper"); - saw_chat = true; - break; - } - } - assert!(saw_chat, "host did not receive chat"); - - client.send_prompt("fix the bug").unwrap(); - let mut saw_prompt = false; - for _ in 0..20 { - let msg = recv_json::(&mut host).await; - if let RelayToHost::CollabInput { content, .. } = msg { - assert_eq!(content, "fix the bug"); - saw_prompt = true; - break; - } - } - assert!(saw_prompt, "host did not receive collab.input"); - - client.disconnect().await; -} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d46f91c..e292275 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -49,7 +49,7 @@ Recommended shape: - **v1:** OpenCode adapter = this repo’s plugin + local **Rust** `chorus-relay` - **v1.x:** keep `@chorus/shared` as the stable joiner + host-control contract; `@chorus/client` holds `JoinClient` / `RelayServer` for non-OpenCode hosts -- **v1.x adapters in-monorepo:** VS Code (`packages/vscode`) and Zed (`packages/zed` + `crates/chorus-zed-helper`) — same wire protocol, host-specific UI only. Zed v1 is joiner/MCP-only (no host share). +- **v1.x adapters in-monorepo:** VS Code (`packages/vscode`) — same wire protocol, host-specific UI only. - **v2:** optional further adapters (Codex app-server remains a strong candidate) once OpenCode UX is solid; split adapter packages into separate repos only when publish/CI ownership diverges **Do not** make Claude Code / Amp the primary host — large audiences, but proprietary control planes. Treat them as distribution adapters later, not the core. diff --git a/docs/STATUS.md b/docs/STATUS.md index 2f898be..54b826a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -14,7 +14,7 @@ Chorus is an **OpenCode↔OpenCode** LAN collaboration stack: The browser companion (`packages/web`) was intentionally removed. Joiners now mirror the host transcript into their OpenCode session (`[Host]:` / `[AI]:` lines via `noReply` inject); side-channel chat/typing remain toasts. The in-process Bun relay has been replaced by `crates/chorus-relay`. -**Editor adapters (in progress):** VS Code (`packages/vscode`) and Zed (`packages/zed` + `crates/chorus-zed-helper`). Zed v1 is **joiner-only** via MCP/CLI — no host share and no OpenCode transcript injection. +**Editor adapters (in progress):** VS Code (`packages/vscode`). Differentiation vs nearby OpenCode plugins (`opencode-live`, `opencode-sessions`, `opencode-ensemble`, `opencode-relay`): those target **multi-agent / same-DB sync**. Chorus targets **multi-human** pair programming on one live AI session. @@ -71,7 +71,6 @@ Differentiation vs nearby OpenCode plugins (`opencode-live`, `opencode-sessions` - **OpenCode** (`packages/plugin`) — primary host; full LLM loop + transcript mirror. - **VS Code** (`packages/vscode`) — share/join via `@chorus/client`; sidebar transcript; joiner `collab.input` works against an OpenCode host. VS Code share does **not** drive OpenCode’s model — publish host lines manually or pair with OpenCode. -- **Zed** — joiner-first scaffold when present under `packages/zed` / related crates (WASM extension limits; helper binary pattern). ## Explicit non-goals (for now) diff --git a/package.json b/package.json index 667d172..d8de85d 100644 --- a/package.json +++ b/package.json @@ -7,17 +7,15 @@ ], "scripts": { "build:relay": "cargo build -p chorus-relay --release", - "build:zed-helper": "cargo build -p chorus-zed-helper --release", "build:shared": "bun run --filter @chorus/shared build", "build:client": "bun run --filter @chorus/client build", "build:plugin": "bun run --filter @chorus/plugin build", "build:vscode": "bun run --filter chorus build", "build:ts": "bun run build:shared && bun run build:client && bun run build:plugin && bun run build:vscode", - "build": "bun run build:relay && bun run build:zed-helper && bun run build:ts", + "build": "bun run build:relay && bun run build:ts", "test:relay": "cargo test -p chorus-relay", - "test:zed-helper": "cargo test -p chorus-zed-helper", "test:ts": "bun run build:shared && bun run build:client && bun run --filter @chorus/shared test && bun run --filter @chorus/client test && bun run --filter @chorus/plugin test && bun run --filter chorus test", - "test": "bun run build:relay && bun run test:relay && bun run build:zed-helper && bun run test:zed-helper && bun run test:ts", + "test": "bun run build:relay && bun run test:relay && bun run test:ts", "typecheck": "bun run build:shared && bun run build:client && bun run --filter @chorus/shared typecheck && bun run --filter @chorus/client typecheck && bun run --filter @chorus/plugin typecheck && bun run --filter chorus typecheck", "clean": "bun run --filter @chorus/shared clean && bun run --filter @chorus/client clean && bun run --filter @chorus/plugin clean && bun run --filter chorus clean && cargo clean", "multi-agent": "bun scripts/multi-agent.ts", @@ -26,9 +24,8 @@ "test:relay-stress": "bun scripts/multi-agent.ts relay-stress --agents 5", "test:security-e2e": "bun scripts/security-e2e.ts", "test:vscode-e2e": "bun scripts/vscode-e2e.ts", - "test:zed-e2e": "bun scripts/zed-e2e.ts", "test:plugin-e2e": "bun run test:multi-agent && bun run test:security-e2e", - "test:adapters-e2e": "bun run test:vscode-e2e && bun run test:zed-e2e", + "test:adapters-e2e": "bun run test:vscode-e2e", "docker-agents": "bun scripts/docker-agents.ts", "test:docker-agents": "bun scripts/docker-agents.ts up --agents 2 && bun scripts/docker-agents.ts smoke && bun scripts/docker-agents.ts pair && bun scripts/docker-agents.ts down" }, diff --git a/packages/zed/Cargo.lock b/packages/zed/Cargo.lock deleted file mode 100644 index 7aec8ee..0000000 --- a/packages/zed/Cargo.lock +++ /dev/null @@ -1,829 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "auditable-serde" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7bf8143dfc3c0258df908843e169b5cc5fcf76c7718bd66135ef4a9cd558c5" -dependencies = [ - "semver", - "serde", - "serde_json", - "topological-sort", -] - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chorus-zed" -version = "0.1.0" -dependencies = [ - "zed_extension_api", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-executor" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - -[[package]] -name = "futures-macro" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "icu_collections" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" - -[[package]] -name = "icu_properties" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" - -[[package]] -name = "icu_provider" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.119", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "spdx" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" -dependencies = [ - "smallvec", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "topological-sort" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "wasm-encoder" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80bb72f02e7fbf07183443b27b0f3d4144abf8c114189f2e088ed95b696a7822" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1ef0faabbbba6674e97a56bee857ccddf942785a336c8b47b42373c922a91d" -dependencies = [ - "anyhow", - "auditable-serde", - "flate2", - "indexmap", - "serde", - "serde_derive", - "serde_json", - "spdx", - "url", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "wit-bindgen" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10fb6648689b3929d56bbc7eb1acf70c9a42a29eb5358c67c10f54dbd5d695de" -dependencies = [ - "wit-bindgen-rt", - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92fa781d4f2ff6d3f27f3cc9b74a73327b31ca0dc4a3ef25a0ce2983e0e5af9b" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rt" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621" -dependencies = [ - "bitflags", - "futures", - "once_cell", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.119", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad19eec017904e04c60719592a803ee5da76cb51c81e3f6fbf9457f59db49799" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.119", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zed_extension_api" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0" -dependencies = [ - "serde", - "serde_json", - "wit-bindgen", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/zed/Cargo.toml b/packages/zed/Cargo.toml deleted file mode 100644 index 4db7fa4..0000000 --- a/packages/zed/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "chorus-zed" -version = "0.1.0" -edition = "2021" -license = "MIT" -publish = false -description = "Zed extension that launches the Chorus join helper as an MCP context server" - -# Standalone from the monorepo Cargo workspace (wasm32-wasip2 via Zed). -[workspace] - -[lib] -crate-type = ["cdylib"] -path = "src/lib.rs" - -[dependencies] -zed_extension_api = "0.7.0" diff --git a/packages/zed/README.md b/packages/zed/README.md deleted file mode 100644 index 34f4f52..0000000 --- a/packages/zed/README.md +++ /dev/null @@ -1,110 +0,0 @@ -# Chorus Zed extension (v1 — joiner) - -Join an existing Chorus collaborative session from [Zed](https://zed.dev) via a **native helper** + **MCP context server**. - -Zed extensions run as `wasm32-wasip2` and cannot hold long-lived WebSocket UIs themselves. This package is the thin WASM adapter; `crates/chorus-zed-helper` speaks the Chorus `/ws` protocol. - -## What works (v1) - -| Capability | How | -|---|---| -| Join a host’s relay | MCP tool `chorus_join` or CLI `chorus-zed-helper join` | -| Leave | `chorus_leave` / `leave` | -| Side-channel chat | `chorus_chat` / `chat` | -| Forward prompts (`collab.input`) | `chorus_prompt` / `prompt` (needs `edit` role) | -| Status / recent events | `chorus_status` / `status` | - -## What does **not** work (honest limits) - -- **Host share / stop** — Zed cannot inject into an OpenCode agent session. Hosting still requires the OpenCode plugin (`/chorus-share`). -- **OpenCode transcript mirroring** — session events are visible via `chorus_status` / helper state, not injected into a local agent transcript. -- **Extension slash commands** — Zed removed extension slash commands; use MCP tools (or the CLI). Tool names mirror OpenCode’s `/chorus-join`, `/chorus-leave`, `/chorus-chat`, `/chorus-status`. - -## Access control - -Aligned with the OpenCode plugin / `chorus-relay` session gates: - -- **Required display name** (`display_name` / `--name`) -- **Host approval** — join may return `pending` until the host runs `chorus-approve` -- **Optional repo gate** — pass `repo_remote` / `--repo-remote` when the host bound `session.policy.repoRemote` - -## Architecture - -``` -Zed Agent Panel - └─ MCP (stdio) ──► chorus-zed-helper mcp - └─ WebSocket ──► chorus-relay /ws - └─ host OpenCode plugin -``` - -CLI subcommands talk to a short-lived background daemon over a Unix socket so `join` / `chat` / `status` share one connection. - -## Install the native helper - -From the monorepo root: - -```sh -cargo build -p chorus-zed-helper --release -# put it on PATH, e.g.: -cp target/release/chorus-zed-helper ~/.local/bin/ -``` - -Or: - -```sh -cargo install --path crates/chorus-zed-helper -``` - -Verify: - -```sh -chorus-zed-helper --help -``` - -## Install Dev Extension in Zed - -1. Build/install `chorus-zed-helper` on `PATH` (above). -2. Install the `wasm32-wasip2` target if needed: `rustup target add wasm32-wasip2`. -3. In Zed: command palette → **zed: extensions**. -4. Click **Install Dev Extension**. -5. Select this directory: `packages/zed` (the folder that contains `extension.toml`). -6. Enable the **Chorus** context server in Agent / MCP settings if it is not auto-enabled. -7. Confirm tools `chorus_join`, `chorus_leave`, `chorus_chat`, `chorus_prompt`, `chorus_status` appear. - -If the context server fails to start, Zed cannot find `chorus-zed-helper`. Fix `PATH`, or register a manual context server in `settings.json`: - -```json -{ - "context_servers": { - "chorus": { - "command": "/absolute/path/to/chorus-zed-helper", - "args": ["mcp"] - } - } -} -``` - -## CLI usage (without Zed) - -```sh -# Host must already be sharing via OpenCode /chorus-share -chorus-zed-helper join --host 192.168.1.10:7742 --token --name Alex -chorus-zed-helper status -chorus-zed-helper chat "hey — looking at the failing test" -chorus-zed-helper prompt "fix the flaky auth test" -chorus-zed-helper leave -``` - -## Develop / test - -```sh -# Helper unit + integration tests (spins up chorus-relay) -cargo test -p chorus-zed-helper - -# Extension WASM (optional local check; Zed also builds on Install Dev Extension) -cd packages/zed -rustup target add wasm32-wasip2 -cargo build --target wasm32-wasip2 -``` - -Protocol fixtures live in `/protocol`; Rust types are shared via `chorus-relay::protocol`. diff --git a/packages/zed/extension.toml b/packages/zed/extension.toml deleted file mode 100644 index 4340768..0000000 --- a/packages/zed/extension.toml +++ /dev/null @@ -1,11 +0,0 @@ -id = "chorus" -name = "Chorus" -description = "Join Chorus collaborative AI sessions from Zed (joiner via native helper + MCP)" -version = "0.1.0" -schema_version = 1 -authors = ["Steven Roomberg"] -repository = "https://github.com/sroomberg/chorus" - -[context_servers.chorus] -name = "Chorus" -description = "Join/leave Chorus sessions, side-channel chat, and collab prompts" diff --git a/packages/zed/src/lib.rs b/packages/zed/src/lib.rs deleted file mode 100644 index dcb7d0d..0000000 --- a/packages/zed/src/lib.rs +++ /dev/null @@ -1,27 +0,0 @@ -use zed_extension_api::{self as zed, Command, ContextServerId, Project, Result}; - -struct ChorusExtension; - -impl zed::Extension for ChorusExtension { - fn new() -> Self { - Self - } - - fn context_server_command( - &mut self, - _context_server_id: &ContextServerId, - _project: &Project, - ) -> Result { - // The WebSocket join client lives in the native `chorus-zed-helper` binary. - // Install it on PATH (`cargo install --path crates/chorus-zed-helper` from the repo, - // or `cargo build -p chorus-zed-helper --release` and copy `target/release/chorus-zed-helper`). - // Override the binary path via Zed settings `context_servers.chorus` env if needed. - Ok(Command { - command: "chorus-zed-helper".into(), - args: vec!["mcp".into()], - env: vec![], - }) - } -} - -zed::register_extension!(ChorusExtension); diff --git a/scripts/zed-e2e.ts b/scripts/zed-e2e.ts deleted file mode 100644 index 28e8d49..0000000 --- a/scripts/zed-e2e.ts +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env bun -/** - * Zed helper integration against a live chorus-relay: - * join (pending) → approve → chat + collab.input → leave. - */ -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { join, resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { RelayServer } from "../packages/client/src/index.ts"; - -const REPO = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const PORT = parseInt(process.env["CHORUS_ZED_E2E_PORT"] ?? "18743", 10); -const RUNTIME = join(REPO, ".multi-agent", "zed-e2e-runtime"); - -function helperBin(): string { - if (process.env["CHORUS_ZED_HELPER_BIN"]) return process.env["CHORUS_ZED_HELPER_BIN"]; - for (const p of [ - join(REPO, "target/release/chorus-zed-helper"), - join(REPO, "target/debug/chorus-zed-helper"), - ]) { - if (existsSync(p)) return p; - } - throw new Error("chorus-zed-helper not found; cargo build -p chorus-zed-helper --release"); -} - -function runHelper(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> { - return new Promise((resolveP, reject) => { - const child = spawn(helperBin(), args, { - env: { - ...process.env, - CHORUS_ZED_RUNTIME_DIR: RUNTIME, - }, - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (d) => { - stdout += String(d); - }); - child.stderr.on("data", (d) => { - stderr += String(d); - }); - child.on("error", reject); - child.on("close", (code) => resolveP({ code: code ?? 1, stdout, stderr })); - }); -} - -async function waitUntil(fn: () => boolean, label: string, ms = 8000): Promise { - const deadline = Date.now() + ms; - while (Date.now() < deadline) { - if (fn()) return; - await new Promise((r) => setTimeout(r, 50)); - } - throw new Error(`timeout: ${label}`); -} - -async function main() { - const relay = new RelayServer(PORT); - await relay.start(); - console.log(`✓ relay on :${PORT}`); - - const received: string[] = []; - const pending: string[] = []; - relay.setInputHandler(async (content) => { - received.push(content); - }); - relay.setUserPendingHandler((u) => pending.push(u.userId)); - relay.setSessionPolicy({ requireApproval: true }); - - try { - const token = (await relay.issueToken("zed-e2e", "edit")).token; - const host = `127.0.0.1:${PORT}`; - - console.log("→ chorus-zed-helper join (pending)"); - const joined = await runHelper(["join", "--host", host, "--token", token, "--name", "ZedE2E"]); - if (joined.code !== 0) { - throw new Error(`join failed: ${joined.stderr || joined.stdout}`); - } - console.log(joined.stdout.slice(0, 400)); - await waitUntil(() => pending.length > 0, "host pending"); - - const statusPending = await runHelper(["status"]); - if (!/Pending|pending/i.test(`${statusPending.stdout}${statusPending.stderr}`)) { - console.log(statusPending.stdout); - // Snapshot JSON may serialize as "pending" - if (!/"status"\s*:\s*"pending"/i.test(statusPending.stdout) && !/Pending/.test(statusPending.stdout)) { - throw new Error(`expected pending status, got:\n${statusPending.stdout}`); - } - } - console.log("✓ helper pending"); - - console.log("→ approve"); - relay.approveUser(pending[0]!); - await waitUntil(async () => { - /* poll via helper */ - return true; - }, "noop", 200); - for (let i = 0; i < 40; i++) { - const st = await runHelper(["status"]); - if (/Connected|connected/i.test(st.stdout) && !/Pending/.test(st.stdout)) break; - if (/"status"\s*:\s*"connected"/i.test(st.stdout)) break; - await new Promise((r) => setTimeout(r, 100)); - if (i === 39) throw new Error(`never connected:\n${st.stdout}`); - } - console.log("✓ helper admitted"); - - const prompt = await runHelper(["prompt", "from-zed-helper"]); - if (prompt.code !== 0) throw new Error(`prompt failed: ${prompt.stderr || prompt.stdout}`); - await waitUntil(() => received.includes("from-zed-helper"), "collab.input"); - console.log("✓ collab.input"); - - const chat = await runHelper(["chat", "zed-side-channel"]); - if (chat.code !== 0) throw new Error(`chat failed: ${chat.stderr || chat.stdout}`); - console.log("✓ chat"); - - await runHelper(["leave"]); - console.log("\nZed helper e2e passed."); - } finally { - await runHelper(["leave"]).catch(() => {}); - relay.stop(); - } -} - -main().catch((e) => { - console.error(e); - process.exit(1); -});