Working name "Roundtable" — rename freely. This file is the canonical project brief and rules file. If any other doc conflicts with this one, this one wins.
A VS Code extension providing a single inline chat panel where multiple AI models — each authenticated via the user's existing consumer subscriptions (Claude Max, ChatGPT Plus/Pro, Gemini, etc.), NOT API keys — participate in one shared-context conversation.
Core insight: the backends are stateless OpenAI-compatible endpoints (via CLIProxyAPI). There is no per-model memory to sync. The extension maintains one canonical transcript, and whenever any model speaks, it receives the entire transcript (with speaker labels) as its message history. Every participant is therefore always "on the same page" by construction.
- Not an agent. No tool use, no file editing, no terminal execution in v1. It is a conversation surface. (Agentic features are a possible v3+.)
- Not a Copilot Chat model provider. We own the whole UI via a webview. Do not use the Language Model Chat Provider API.
- Not an API-key product. Zero API-key code paths in v1. Subscriptions only, via the CLIProxyAPI sidecar.
┌────────────────────────────────────────────────────┐
│ VS Code │
│ ┌──────────────┐ postMessage ┌─────────────┐ │
│ │ Webview UI │◄───────────────►│ Extension │ │
│ │ (chat panel) │ │ Host (Node) │ │
│ └──────────────┘ │ - Orchestr.│ │
│ │ - Router │ │
│ │ - Sidecar │ │
│ │ manager │ │
│ └──────┬──────┘ │
└──────────────────────────────────────────┼─────────┘
│ HTTP (OpenAI-compatible, SSE)
┌──────▼──────┐
│ CLIProxyAPI │ localhost:8317
│ (sidecar) │
└──────┬──────┘
┌────────────────┼────────────────┐
▼ ▼ ▼
Claude OAuth Codex OAuth Gemini OAuth
(Max sub) (ChatGPT sub) (Google acct)
- Extension host (TypeScript, Node): owns transcript state, provider registry, turn router, streaming, persistence, and the CLIProxyAPI sidecar lifecycle.
- Webview: pure presentation + input. Dumb by design. All state lives in the extension host; the webview renders what it's told and emits user intents.
- CLIProxyAPI: open-source Go binary (github.com/router-for-me/CLIProxyAPI). Wraps subscription OAuth logins and exposes
POST /v1/chat/completions(OpenAI-compatible, streaming supported) on localhost. The extension spawns and supervises it.
roundtable/
├── CLAUDE.md # this file (canonical rules)
├── package.json # extension manifest
├── tsconfig.json
├── esbuild.js # bundler script (esbuild, not webpack)
├── src/
│ ├── extension.ts # activate/deactivate, command registration
│ ├── orchestrator.ts # transcript state machine, turn execution
│ ├── router.ts # decides which participant(s) respond
│ ├── providers.ts # provider/model registry, endpoint mapping
│ ├── sidecar.ts # CLIProxyAPI download/spawn/health/kill
│ ├── streaming.ts # SSE client for /v1/chat/completions
│ ├── contextInjector.ts # active-file / selection context blocks
│ ├── persistence.ts # transcript load/save
│ └── panel.ts # webview panel creation, message bridge
├── media/ # webview assets
│ ├── main.js # webview-side logic (vanilla JS or Preact)
│ ├── main.css
│ └── index.html # template (CSP-compliant)
├── test/
│ └── *.test.ts # vitest unit tests (router, orchestrator, schema)
└── .vscode/
└── launch.json # F5 Extension Development Host config
engines.vscode:^1.90.0or later.main:./dist/extension.js(esbuild bundle).activationEvents: activate on the view or the open command only — no*activation.- Contributes:
viewsContainers.activitybar: one container, idroundtable, with an icon.views: one webview viewroundtable.chatinside that container. (Also register a command to open it as an editor-area panel for wide-screen use — same webview code, two mounts.)commands:roundtable.open— open/focus the chat panelroundtable.newThread— archive current transcript, start freshroundtable.addSelectionToChat— push editor selection into the composer as a context blockroundtable.sidecar.restart,roundtable.sidecar.login— sidecar management
menus.editor/context: exposeaddSelectionToChat.configuration(settings):roundtable.sidecar.mode:"managed" | "external"(defaultmanaged)roundtable.sidecar.port: number (default8317)roundtable.sidecar.binaryPath: string (optional override; if empty in managed mode, download from GitHub releases)roundtable.sidecar.externalUrl: string (used when mode = external)roundtable.participants: array of participant configs (see §6)roundtable.router.defaultMode:"manual" | "roundRobin"(defaultmanual)roundtable.context.autoIncludeActiveFile: boolean (defaultfalse)
Stored as JSON. One file per thread.
Invariants (must always hold — write tests for these):
seqis strictly increasing with no gaps among persisted messages.- Every
assistantmessage has aspeakermatching a known participant id. - A message with
status: "streaming"is never persisted to disk; persist only on completion, error, or cancellation (partial content saved with the terminal status). - The transcript is the single source of truth. The webview never mutates it directly — it sends intents; the orchestrator mutates and broadcasts the new state.
Two layers, deliberately separated:
- Provider: an endpoint + protocol. In v1 there is effectively one provider: the CLIProxyAPI base URL (
http://127.0.0.1:<port>/v1), speaking OpenAI Chat Completions with SSE streaming. Keep the abstraction thin but present so a direct-CLI provider (headlessclaude -p/codex exec) can be added later without touching the orchestrator. - Participant: a named seat at the table. Config shape:
{
"id": "claude", // stable, used in @-mentions and transcript
"displayName": "Claude",
"color": "#D97706", // avatar/label accent in UI
"model": "claude-opus-4-8", // model string passed to the endpoint
"systemPrompt": "optional per-participant persona/instructions",
"enabled": true
}On startup and on demand, query GET /v1/models from the sidecar to surface which model strings are actually available, and validate participant configs against it (warn, don't block — model lists shift).
When participant P is chosen to respond, build the request messages array as follows:
- System message (always first), composed of:
- A framing preamble, something like: "You are {displayName}, one of several AI assistants in a shared group conversation with a human user. Other participants: {list}. Messages are labeled with their speaker. Respond as yourself; do not impersonate other participants; do not prefix your reply with your own name."
- The participant's own
systemPrompt, if set.
- The full transcript, mapped:
- User messages →
role: "user", content prefixed[User]plus rendered context blocks (fenced code with path/range header). - P's own past messages →
role: "assistant", content unprefixed. - Other participants' messages →
role: "user"with content prefixed[{displayName}]:. This is deliberate: OpenAI-compatible endpoints only model a two-party conversation, so everyone else's speech is presented as incoming context. Do not send other models' output asassistantrole — the model would treat it as its own prior speech.
- User messages →
- System messages in the transcript (thread-level notes) →
role: "system"appended after the main system message, or inlined as[System]-prefixed user content if the endpoint rejects multiple system messages. Handle both.
Context budget: naive full-replay is fine for v1. Add a character-count guard: if the assembled prompt exceeds a configurable soft limit (default ~150k chars), drop oldest messages first but ALWAYS keep the system message and the N most recent messages, and inject one [System] Earlier messages omitted for length. marker. No summarization in v1 — omission markers only, so behavior stays predictable.
router.ts exposes one pure function: given (transcript, user message, settings) → ordered list of participant ids to respond. Pure = trivially testable.
Rules, in priority order:
- @-mentions:
@claude,@gptetc. in the user message → those participants respond, in mention order. Multiple mentions = sequential responses (each later responder sees the earlier one's reply, because prompt assembly always uses the live transcript). @all→ every enabled participant responds, sequentially, in participant-config order.- No mention, manual mode (default): the participant currently selected in the UI's "speaking to" selector responds. The selector defaults to the last-addressed participant.
- No mention, roundRobin mode: next enabled participant after the previous assistant speaker.
Sequential only in v1 — no parallel responses. It keeps the shared-context invariant simple (each response is appended before the next request is assembled) and avoids quota spikes.
Cancellation: a visible Stop button aborts the in-flight SSE request. Partial content is persisted with status: "cancelled" and rendered with a "(stopped)" marker. If a multi-participant turn (e.g. @all) is stopped, remaining queued responders are skipped.
- Use
fetchwithReadableStreamparsing of SSE (data: {...}lines,[DONE]terminator) againstPOST /v1/chat/completionswith"stream": true. - Forward deltas to the webview via
postMessagebatched on a ~50ms interval (don't post per-token). - Timeouts: 30s to first byte, 120s stall timeout between deltas → mark
errorwith a human-readable message. - On HTTP 429 or 5xx from the sidecar: surface the provider's error body in the message bubble (these often contain quota-reset info). One automatic retry on 5xx only, never on 429.
Two modes:
- external (simplest, build first): user runs CLIProxyAPI themselves; extension just health-checks
GET {externalUrl}/v1/modelsand shows a status pill (green/red) in the panel header with the failure reason on hover. - managed: extension owns the lifecycle:
- On activation, check for binary at
binaryPathor inglobalStorageUri. If absent, download the correct platform asset from the CLIProxyAPI GitHub releases page (pin a known-good version in a constant; verify checksum), with an explicit user-consent prompt before downloading. - Spawn with a generated
config.yamlinglobalStorageUri(port from settings,auth-dirunder global storage, remote management disabled, no API-key auth for localhost). - Health-check loop; restart with backoff (max 3 attempts) on crash; kill on
deactivate. - OAuth logins are interactive — do not try to automate them.
roundtable.sidecar.loginopens a VS Code terminal running the binary with the chosen--*-loginflag and instructs the user to complete the browser flow. Afterwards, re-query/v1/modelsand refresh the participant validation.
- On activation, check for binary at
Never log, transmit, or read the contents of the OAuth token files. The extension's only interface to auth state is /v1/models and request success/failure.
All messages are { type: string, payload: object }. Exhaustive v1 set:
Webview → extension (intents):
ready— webview mounted; extension replies with full statesendMessage{ text, targets?: string[] }stopGenerationnewThreadswitchThread{ threadId }setDefaultTarget{ participantId }retryMessage{ messageId }— re-runs a failed/cancelled assistant turndeleteMessage{ messageId }— removes a message (and renumbers nothing; seq gaps are forbidden, so implement as tombstone:contentcleared,status: "deleted", excluded from prompt assembly)openSettings,requestLogin{ provider }
Extension → webview (state):
state{ transcript, participants, sidecarStatus, threads: [{id,title,updatedAt}] }— full sync (on ready, thread switch, and any structural change)delta{ messageId, contentDelta }— streaming appendmessageStatus{ messageId, status, error? }sidecarStatus{ ok: boolean, detail: string }
Rule: the webview holds a render-model only. Any doubt about state → request/receive full state. Correctness over cleverness.
- Header: thread title, thread switcher dropdown, sidecar status pill, "speaking to" participant selector, New Thread button.
- Message list: speaker label + accent color per participant, markdown rendering (use
markdown-itbundled into the webview; sanitize HTML), fenced code blocks with copy button, collapsed-by-default context blocks on user messages. - Composer: multiline textarea, Enter to send / Shift+Enter newline,
@triggers a mention autocomplete of enabled participants, Stop button while streaming, small token/length hint of the assembled-prompt size for the current target. - Must respect VS Code theming (use
--vscode-*CSS variables throughout; no hardcoded colors except participant accents). - Strict CSP: no remote scripts, no inline event handlers, nonce on the script tag.
- Threads saved as individual JSON files under
context.globalStorageUri/threads/(cross-workspace by default; a workspace-scoped mode can come later). - Write-behind: debounce saves 500ms after last mutation; also save on deactivate.
- On load, validate against the schema (version field present, invariants hold); if a file is corrupt, rename it
*.corrupt-{ts}.jsonand continue — never crash activation over one bad thread.
- Subscriptions only. No code path may ask for, store, or transmit an API key in v1.
- ToS honesty in UX. First-run notice (one-time, dismissible): using subscription accounts outside official clients may violate provider Terms of Service and can lead to rate limiting or account suspension; the user proceeds at their own discretion. Do not bury this.
- The transcript is law. All prompt assembly derives from it; no hidden side-channel context between participants.
- Sequential turns only in v1.
- Fail loud, degrade soft. Provider errors render in-thread with real error text; sidecar death shows a red status pill with a restart action; nothing silently no-ops.
- No telemetry. Zero network calls except to the sidecar and (managed mode, with consent) the pinned GitHub release download.
- Pure router, tested invariants.
router.tsand prompt assembly must be pure functions with unit tests covering: mention parsing (including@all, unknown mentions, mid-word@), role-mapping of other participants' messages, seq invariants, tombstone exclusion, and context-budget truncation. - Fix the class, not the instance. Any bug fix must generalize; add a test reproducing the failure before fixing it.
M1 — Skeleton (prove the loop): external-mode sidecar only. Panel opens, one hardcoded participant, send → full-transcript prompt assembly → streamed reply renders. Persistence of a single thread.
M2 — The table: participant registry from settings, @-mention router, @all, sequential multi-responder turns, speaker-labeled role mapping, manual/roundRobin modes, stop/retry/tombstone.
M3 — Sidecar managed mode: download-with-consent, spawn/supervise, login command flow, status pill, /v1/models validation.
M4 — Polish: multi-thread management, selection/file context injection + editor context menu, context-budget truncation, prompt-size hint, mention autocomplete, first-run ToS notice, theming pass.
Each milestone must build (npm run compile), pass tests (npm test), and run in the F5 Extension Development Host before starting the next.
- TypeScript strict mode. esbuild for bundling (fast, standard for extensions). vitest for unit tests. ESLint with the standard
@typescript-eslintconfig. - No frameworks in the webview unless it earns its weight; vanilla + small helpers preferred, Preact acceptable if state complexity demands it.
- Package for sideload with
npx vsce package→ install viacode --install-extension roundtable-x.y.z.vsix. Marketplace publishing is out of scope. - Version bumps: update
package.jsonversion on every packaged build.
{ "version": 1, "id": "uuid", "title": "auto-generated or user-set", "createdAt": "ISO-8601", "updatedAt": "ISO-8601", "participants": ["claude", "gpt"], // participant ids active in this thread "messages": [ { "id": "uuid", "seq": 0, // monotonic, gap-free "role": "user" | "assistant" | "system", "speaker": "user" | "<participantId>", // who actually said it "content": "string (markdown)", "contextBlocks": [ // optional, user messages only { "kind": "file" | "selection", "path": "src/foo.ts", "range": [10, 42], "content": "..." } ], "targets": ["claude"], // which participants this message was addressed to (user msgs) "status": "complete" | "streaming" | "error" | "cancelled", "error": "string?", // populated when status = error "usage": { "inputTokens": 0, "outputTokens": 0 }, // if the endpoint reports it "createdAt": "ISO-8601" } ] }