Skip to content

Latest commit

 

History

History
308 lines (248 loc) · 28.3 KB

File metadata and controls

308 lines (248 loc) · 28.3 KB

SquareCode — Multi-Subscription AI Chat for VS Code

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.

1. What we're building

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.

What this is

  • 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.

2. Architecture overview

┌────────────────────────────────────────────────────┐
│ 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.

3. Repository layout

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.

4. Extension manifest essentials (package.json)

  • 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, id squarecode, with an icon.
    • views: one webview view squarecode.chat inside that container.
    • commands:
      • squarecode.open — open/focus the chat panel
      • squarecode.openInEditor — open the same webview as an editor-area panel (second mount)
      • squarecode.newThread — archive current transcript, start fresh
      • squarecode.addSelectionToChat — push editor selection into the composer as a context block
      • squarecode.sidecar.restart, squarecode.sidecar.login — sidecar management
    • menus.editor/context: expose addSelectionToChat.
    • configuration (settings):
      • squarecode.sidecar.mode: "managed" | "external" (default managed)
      • squarecode.sidecar.port: number (default 8317)
      • squarecode.sidecar.binaryPath: string (optional override; if empty in managed mode, download from GitHub releases)
      • squarecode.sidecar.externalUrl: string (default http://127.0.0.1:8317; used when mode = external)
      • squarecode.participants: array of participant configs (see §6)
      • squarecode.router.defaultMode: "manual" | "roundRobin" (default manual)
      • squarecode.context.autoIncludeActiveFile: boolean (default false)
      • squarecode.prompt.softLimitChars: number (default 150000) — §7's context budget

5. Transcript schema (canonical data model)

Stored as JSON. One file per thread.

{
  "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"
    }
  ]
}

Invariants (must always hold — write tests for these):

  1. seq is strictly increasing with no gaps among persisted messages (assigned only inside appendMessage).
  2. Every assistant message has a speaker matching a known participant id.
  3. 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".
  4. 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.

6. Provider & participant registry

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 (headless claude -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.

7. Prompt assembly (the shared-context mechanism)

When participant P is chosen to respond, build the request messages array as follows:

  1. 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.
  2. 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 as assistant role — the model would treat it as its own prior speech.
  3. 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.

8. Router (turn-taking)

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:

  1. @-mentions: @claude, @gpt etc. 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).
  2. @all → every enabled participant responds, sequentially, in participant-config order. @all supersedes specific mentions.
  3. 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.
  4. 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).

9. Streaming

  • Use fetch with ReadableStream parsing of SSE (data: {...} lines, [DONE] terminator) against POST /v1/chat/completions with "stream": true.
  • Forward deltas to the webview via postMessage batched on a ~50ms interval (don't post per-token). Batching lives in panel.ts — transport concern, core stays timing-free.
  • Timeouts: 30s to first byte, 120s stall timeout between deltas → mark error with 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; parse usage defensively if a chunk carries it.

10. Sidecar management (sidecar.ts)

Two modes:

  • external (simplest, build first): user runs CLIProxyAPI themselves; extension just health-checks GET {externalUrl}/v1/models and shows a status pill (green/red) in the panel header with the failure reason on hover.
  • managed: extension owns the lifecycle:
    1. On activation, check for binary at binaryPath or in globalStorageUri. If absent, download the correct platform asset from the CLIProxyAPI GitHub releases page (pinned version in a constant; fetch checksums.txt first and verify SHA256), with an explicit user-consent prompt before downloading. Extract via system tar (bsdtar handles .zip and .tar.gz on Windows 10+).
    2. Spawn with a generated config.yaml in globalStorageUri (port from settings, auth-dir under 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.
    3. 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.
    4. OAuth logins are interactive — do not try to automate them. squarecode.sidecar.login stops the managed server, opens a VS Code terminal running the binary with the chosen login flag (-claude-login, -codex-login, -login for Gemini), and instructs the user to complete the browser flow. Afterwards, restart and re-query /v1/models and refresh the participant validation.

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).

11. Webview ⇄ extension protocol

All messages are { type: string, payload: object }. Exhaustive v1 set:

Webview → extension (intents):

  • ready — webview mounted; extension replies with full state
  • sendMessage { text, targets?: string[], contextBlocks?: ContextBlock[] }
  • stopGeneration
  • newThread
  • switchThread { threadId }
  • setDefaultTarget { participantId }
  • retryMessage { messageId } — re-runs a failed/cancelled assistant turn
  • deleteMessage { messageId } — tombstone: content cleared, 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 append
  • messageStatus { messageId, status, error? }
  • sidecarStatus { ok, state, detail }
  • composerInsert { contextBlock }addSelectionToChat path (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.

12. Webview UI (v1 scope)

  • 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-it bundled, 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.

13. Persistence

  • 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}.json and continue — never crash activation over one bad thread.

14. Non-negotiable rules

  1. Subscriptions only. No code path may ask for, store, or transmit an API key in v1.
  2. 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.
  3. The transcript is law. All prompt assembly derives from it; no hidden side-channel context between participants.
  4. Sequential turns only in v1.
  5. 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.
  6. No telemetry. Zero network calls except to the sidecar and (managed mode, with consent) the pinned GitHub release download.
  7. Pure router, tested invariants. router.ts and 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.
  8. Fix the class, not the instance. Any bug fix must generalize; add a test reproducing the failure before fixing it.

15. Milestones

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.

16. Tooling & conventions

  • TypeScript strict mode. esbuild for bundling (two bundles: extension host + webview). vitest for unit tests. ESLint flat config with typescript-eslint, including the no-restricted-imports ban on vscode outside 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 to media/main.js.
  • Package for sideload with npx vsce package → install via code --install-extension squarecode-x.y.z.vsix. Marketplace publishing is out of scope.
  • Version bumps: update package.json version 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.

17. Workspace tools (repo read + write + execute) — v0.4

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 (AccessMode in src/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 PLUS write_file / edit_file / delete_file (applied via an undoable WorkspaceEdit, files saved to disk; delete → OS trash) and run_command (shell command in the workspace root).
  • Tools live in src/shared/tools.ts (pure schemas + ToolExecutor interface) and src/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 the MAX_TOOL_ROUNDS budget 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_command is 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 /F on Windows) on Stop/timeout, and commandsEnabled is 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 messages array and the ephemeral reasoning/activity channel only. Only the assistant's streamed content is 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 a realpath containment 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_text runs 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, default true) and squarecode.tools.commandsEnabled (default true; off = writes allowed but run_command never 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.