This file is the canonical project brief and rules file (adapted from
squarecode initial plan.md, working name "Roundtable", with the amendments approved in the build plan). 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.
- A multi-model chat surface that is ALSO a lightweight coding agent over the folder open in VS Code. Every send picks an access tier (composer 🔒 toggle, default read-only): read-only (list/read/find/search) or Full (also create/edit/delete files via an undoable edit, and run shell commands with per-command user approval). See §17. (Amended 2026-07-11: the original "not an agent / no tool use" framing was a miscommunication by a prior editor and has been superseded — repo read AND write/execute access are product requirements.)
- 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.
SquareCode/
├── CLAUDE.md # this file (canonical rules)
├── package.json # extension manifest
├── tsconfig.base.json / tsconfig.json / tsconfig.test.json
├── esbuild.js # bundler: dist/extension.js (node) + media/main.js (browser)
├── eslint.config.mjs # incl. the vscode-import ban (see Tooling)
├── vitest.config.ts
├── src/
│ ├── extension.ts # activate/deactivate, command registration [may import vscode]
│ ├── panel.ts # webview mounts (view + editor panel), bridge, HTML template [vscode]
│ ├── settings.ts # settings adapter → plain typed objects [vscode]
│ ├── sidecar.ts # CLIProxyAPI health/download/spawn/login [vscode]
│ ├── contextInjector.ts # active-file / selection context blocks [vscode]
│ ├── shared/ # types shared with the webview — NO vscode/node/DOM imports
│ │ ├── transcript.ts # transcript/message/participant types
│ │ └── protocol.ts # webview⇄host message unions
│ ├── transcript.ts # transcript mutation API + schema validator [pure core]
│ ├── persistence.ts # transcript load/save (ThreadStore) [pure core]
│ ├── providers.ts # provider/model registry, participant validation [pure core]
│ ├── router.ts # decides which participant(s) respond [pure core]
│ ├── prompt.ts # shared-context prompt assembly [pure core]
│ ├── streaming.ts # SSE client for /v1/chat/completions [pure core]
│ ├── orchestrator.ts # transcript state machine, turn execution [pure core]
│ └── webview/ # webview TS source, bundled to media/main.js
├── media/
│ ├── main.js # BUILD ARTIFACT (gitignored)
│ ├── main.css
│ └── icons/squarecode.svg
├── test/
│ └── *.test.ts # vitest unit tests (router, orchestrator, schema, …)
└── .vscode/
└── launch.json # F5 Extension Development Host config
Structural law: modules marked [pure core] and everything in src/shared/ must never import vscode (ESLint-enforced) so they run under vitest in plain Node. Webview HTML is a template literal in panel.ts (no media/index.html) because nonce/asWebviewUri/cspSource are runtime values.
engines.vscode:^1.93.0.main:./dist/extension.js(esbuild bundle).activationEvents:[]— auto-generated from contributions (view + commands only); no*activation.- Contributes:
viewsContainers.activitybar: one container, idsquarecode, with an icon.views: one webview viewsquarecode.chatinside that container.commands:squarecode.open— open/focus the chat panelsquarecode.openInEditor— open the same webview as an editor-area panel (second mount)squarecode.newThread— archive current transcript, start freshsquarecode.addSelectionToChat— push editor selection into the composer as a context blocksquarecode.sidecar.restart,squarecode.sidecar.login— sidecar management
menus.editor/context: exposeaddSelectionToChat.configuration(settings):squarecode.sidecar.mode:"managed" | "external"(defaultmanaged)squarecode.sidecar.port: number (default8317)squarecode.sidecar.binaryPath: string (optional override; if empty in managed mode, download from GitHub releases)squarecode.sidecar.externalUrl: string (defaulthttp://127.0.0.1:8317; used when mode = external)squarecode.participants: array of participant configs (see §6)squarecode.router.defaultMode:"manual" | "roundRobin"(defaultmanual)squarecode.context.autoIncludeActiveFile: boolean (defaultfalse)squarecode.prompt.softLimitChars: number (default150000) — §7's context budget
Stored as JSON. One file per thread.
Invariants (must always hold — write tests for these):
seqis strictly increasing with no gaps among persisted messages (assigned only insideappendMessage).- 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). A persisted"streaming"found on load (crash) is repaired to"cancelled". - 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). Always send a stable User-Agent header — CLIProxyAPI's /v1/models routes by UA.
Seat/model control amendment (2026-07-12): When squarecode.participants is empty, derive one participant seat per connected provider family from the sidecar's live model list. The model picker is scoped to the selected seat's provider family, so changing an engine variant cannot change who answers. Persist model and effort choices for these auto-derived seats as SeatOverrides in extension globalState; never silently write them into user settings. An explicit squarecode.participants configuration still wins outright and provides manual control of the table.
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.
Tombstoned (deleted) messages, the live streaming placeholder, and empty-content terminal messages are excluded. Cancelled/error messages WITH partial content are included — a stopped partial is real shared context.
Context budget: naive full-replay is fine for v1. Character-count guard: if the assembled prompt exceeds squarecode.prompt.softLimitChars (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.@allsupersedes specific mentions.- 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.
Mention grammar: @ preceded by start-of-string or a non-word character, token [A-Za-z0-9_-]+, case-insensitive against known ids + all. Mid-word @ (foo@claude, x@gpt.com) is not a mention. Unknown mentions are ignored (fall through). Mentions inside code fences ARE parsed — documented v1 limitation.
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.
Bonfire (v0.3) amendment: the per-send Bonfire preset runs an internal pipeline for a single responder turn — planner → parallel text-only subagent calls → adversarial critique → synthesis, all at max effort. This does not violate the sequential-turns law: the transcript still receives exactly one reply per responder; the internal calls are ephemeral coordination whose text never persists (same contract as reasoning). Be honest in all UX/docs: Bonfire is quota spent on answer quality, not agentic file-editing workflows. Bonfire's internal subagents remain text-only (no tools); the normal (non-Bonfire) turn path DOES grant participants the read-only workspace tools of §17.
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. On a provider error, by contrast, the queue continues to the next responder (fail loud, degrade soft).
- 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). Batching lives inpanel.ts— transport concern, core stays timing-free. - 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 — and only if zero deltas were delivered — never on 429.
- Do not send
stream_options.include_usage; parseusagedefensively if a chunk carries it.
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 (pinned version in a constant; fetchchecksums.txtfirst and verify SHA256), with an explicit user-consent prompt before downloading. Extract via systemtar(bsdtar handles .zip and .tar.gz on Windows 10+). - Spawn with a generated
config.yamlinglobalStorageUri(port from settings,auth-dirunder global storage,host: "127.0.0.1",api-keys: [], remote management disabled, control panel disabled, telemetry/file-logging off). Regenerate the config on every start. - Health-check loop; restart with backoff (max 3 attempts: 1s/2s/4s) on crash; kill on
deactivate(Windows:taskkill /PID <pid> /T /F— process trees). Lockfile + port-conflict handling: attach read-only to a foreign healthy proxy, never kill it. - OAuth logins are interactive — do not try to automate them.
squarecode.sidecar.loginstops the managed server, opens a VS Code terminal running the binary with the chosen login flag (-claude-login,-codex-login,-loginfor Gemini), and instructs the user to complete the browser flow. Afterwards, restart and 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 (completion detection may watch auth-dir filenames/mtimes — never contents; never log filenames, they can embed account emails).
All messages are { type: string, payload: object }. Exhaustive v1 set:
Webview → extension (intents):
ready— webview mounted; extension replies with full statesendMessage{ text, targets?: string[], contextBlocks?: ContextBlock[] }stopGenerationnewThreadswitchThread{ threadId }setDefaultTarget{ participantId }retryMessage{ messageId }— re-runs a failed/cancelled assistant turndeleteMessage{ messageId }— tombstone:contentcleared,status: "deleted", excluded from prompt assembly (seq gaps are forbidden)openSettings,requestLogin{ provider }estimatePrompt{ draftText, targets }— prompt-size hint (M4)dismissTosNotice— first-run notice dismissed (M4)
Extension → webview (state):
state{ transcript, participants, sidecarStatus, threads, currentThreadId, defaultTarget, routerMode, isGenerating, showTosNotice }— full sync (on ready, thread switch, and any structural change)delta{ messageId, contentDelta }— streaming appendmessageStatus{ messageId, status, error? }sidecarStatus{ ok, state, detail }composerInsert{ contextBlock }—addSelectionToChatpath (M4)promptEstimate{ targets, chars, approxTokens, overBudget }(M4)
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 (
markdown-itbundled,html:false— no raw 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, applied via CSSOM custom properties). - Strict CSP: no remote scripts, no inline event handlers, no inline styles, 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. Atomic writes:
.tmp+ rename. - 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, in-panel banner): 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 (two bundles: extension host + webview). vitest for unit tests. ESLint flat config with
typescript-eslint, including theno-restricted-importsban onvscodeoutside the five shell files (extension.ts,panel.ts,settings.ts,sidecar.ts,contextInjector.ts). - No frameworks in the webview; vanilla TS with a small keyed reconciler. Webview source lives in
src/webview/(TypeScript), bundled tomedia/main.js. - Package for sideload with
npx vsce package→ install viacode --install-extension squarecode-x.y.z.vsix. Marketplace publishing is out of scope. - Version bumps: update
package.jsonversion on every packaged build. - The full build plan (interfaces, test matrices, verification checklists) lives in the approved plan file; this document is the canonical product rules.
The models MUST be able to see AND change the code in the folder VS Code has open — a project chat that can only read (or only talk) is useless for real dev. Delivered as OpenAI-compatible function calling, gated by a per-send access tier.
- Access tiers (
AccessModeinsrc/shared/tools.ts; the composer 🔒 toggle, sticky within a session, ALWAYS mounts read-only, glows a warning color while Full):read(default):list_dir,read_file,find_files,search_text. No prompts — reads are safe.full: the read tools PLUSwrite_file/edit_file/delete_file(applied via an undoableWorkspaceEdit, files saved to disk; delete → OS trash) andrun_command(shell command in the workspace root).
- Tools live in
src/shared/tools.ts(pure schemas +ToolExecutorinterface) andsrc/workspaceTools.ts(the vscode-backed executor — a sixth shell file on the ESLint allowlist). - The loop lives in the pure-core orchestrator (
runToolLoop): stream → if the model requested tool calls, execute them and stream again with results appended → repeat until a final text answer or theMAX_TOOL_ROUNDSbudget is spent (then one forced tools-off round). With tools inactive it runs exactly one round — byte-identical to the pre-tools path. - Command approval:
run_commandis NEVER silent. Each command prompts the user Allow / Deny / Allow Commands in this Session (the session grant is revoked when you start or switch threads, or the extension deactivates; there is deliberately no per-exact-command allowlist). Commands are killed as a process TREE (taskkill /T /Fon Windows) on Stop/timeout, andcommandsEnabledis enforced both in the offered schema AND the executor. Writes are NOT prompted — they apply as a single undoable edit (Ctrl+Z / Timeline; a file with pre-existing unsaved edits is left dirty rather than force-saved) because reversibility, not a modal, is the right guard for file changes. Reads and writes share one source of truth (the open editor buffer when a file is open). - Ephemerality firewall (same contract as reasoning): tool calls/results are NEVER persisted to the transcript — they ride the live request
messagesarray and the ephemeral reasoning/activity channel only. Only the assistant's streamedcontentis persisted; pre-tool narration in a round that ends in tool calls is rolled onto the thinking channel, not the reply. Prompt assembly still derives solely from the transcript (§14.3 holds). - Sequential-turns law (§14.4) holds: the whole loop is one responder's single turn.
- Safety (defense in depth): every model-supplied path passes
sanitizeRelPath(rejects absolute/..) AND arealpathcontainment check (realWithin) so neither..nor a symlink can escape the workspace roots — reads or writes. Writes additionally refuse protected paths (.git) and confirm containment before applying. Reads are size-capped (large files partial-read, never fully buffered), binaries skipped (UTF‑16-aware), grep bounded in files/matches with a wall-clock deadline and a catastrophic-regex reject. Tool-level errors are returned to the model as text (fail loud, degrade soft). Known residual:search_textruns a model regex synchronously — a crafted exponential pattern on one long line is bounded but not fully interruptible (hard sandbox is a follow-up). - Control:
squarecode.tools.enabled(master, defaulttrue) andsquarecode.tools.commandsEnabled(defaulttrue; off = writes allowed butrun_commandnever offered). Tools auto-inactive when no folder is open. Bonfire subagents remain text-only. UI: a'tools'turn phase ("Reading workspace") + a 🔧 activity trace on the thinking channel. - ToS honesty (§14.2) still applies: writing files and running commands from a subscription-authed model is the user's own machine and files, but is even further from "official client" behavior than chat — keep the framing honest.
{ "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"], // RESOLVED router output (audit trail), user msgs "status": "complete" | "streaming" | "error" | "cancelled" | "deleted", "error": "string?", // populated when status = error "usage": { "inputTokens": 0, "outputTokens": 0 }, // if the endpoint reports it "createdAt": "ISO-8601" } ] }