CLAUDE.md · diff

git:20261028.194d625 to git:20261030.0c58749

3 added, 2 removed. Audit A to A.

# CLAUDE.md
## Project Overview
Damocles embeds an AI coding agent (Claude, GPT and others) in a Vue webview, shipped as a VS Code extension and an Electron desktop app. The agent runs on the **pi** runtime (`@earendil-works/pi-coding-agent`), the only backend.
## Development Commands
```bash
npm run build # extension + webview
npm run build:desktop # Electron main, preloads, shell and overlay renderers, hosts and workers
npm run dev:desktop # fetch assets and Electron, build, launch (accepts --user-data-dir <path>)
npm run test:desktop # same setup, then the Playwright Electron suite; -- <spec> for one spec
node scripts/test-desktop-wsl.mjs --distro Ubuntu-24.04 [specs...] # desktop suite on Linux, in WSL; its only copy is ~/damocles-e2e, delete any other copy you make
npm run test:remote # same setup, the server packages and Docker sshd images the selected specs need, then the remote suite; -- <spec> for one spec, -- --grep <tag> for a tag (@windows-target runs only when named); the two daemon-upgrade tests need DAMOCLES_E2E_REFERENCE_PACKAGE (e2e/remote/support/reference-package.mjs builds it)
npm run dist # package the desktop app for this OS into dist-desktop/ (macOS needs Xcode 26+)
npm run dist:linux-wsl -- --distro Ubuntu-24.04 # Linux deb + rpm from Windows
npm run build:server -- --target <target> # remote server package into dist/server-packages/; node scripts/verify-server-package.mjs --target <target> <archive> checks it
node scripts/protocol-compat.mjs [--check] # write (or check) src/remote/protocol-snapshot.json after a wire contract change
DAMOCLES_E2E_PACKAGED_APP=<built app> npm run test:desktop:packaged # e2e subset against a packaged app
npm run dev # watch mode
npm run typecheck
npm run lint
npm test # Vitest, whole repo; Node >=24.16 (24.15 on Windows crashes workers, nodejs/node#63620)
npm run package # VSIX
npm run package:linux-wsl -- --target linux-x64 --distro Ubuntu # Linux VSIX from Windows
```
Generated files: run the command and commit its output. `generate:icons` (from `resources/icon.png`), `generate:profiles` (team catalog, from `agent-profiles/`), `generate:reference-words` (memory gate word lists), `generate:settings-schema` (after a desktop settings change), `generate:file-icons` (desktop Material icon associations, after bumping `material-icon-theme`). `sync:profiles` reports upstream agency-agents diffs (`--apply` copies). F5 launches the Extension Development Host.
## Architecture
- **Seam:** `PiSession` (interface `src/core/chat-session.ts`) is the only producer of session messages in the webview contract (`ExtensionToWebviewMessage`, `src/shared/types/messages.ts`). Cross-session views (`/usage`, `/stats`) post from their router handlers.
- **Layers:** `src/core` is host-neutral and never imports `vscode` or `electron` (eslint and `tsconfig.core.json` enforce it). `src/platform` holds the host interfaces core uses, `src/vscode` implements them and holds `extension.ts`, and `src/desktop` is the Electron app, the only place `electron` may be imported; its renderers (`shell/`, the overlay) never import it. On desktop the engine runs in the "Damocles Core" utility process (`src/desktop/core-host/index.ts`, forked by `main/core-process.ts` and `main/bootstrap.ts`, restarted after a crash at most 3 times in 5 minutes, then halted until Restart Engine): ChatPanelProvider, source control, the worktree services and every git queue. Its platform is `src/desktop/core-host/platform`, which reaches main only through the core protocol (`src/desktop/core-protocol`, one ordered `parentPort` channel, served by `main/core-services/`); the per-process services both processes build are in `src/desktop/local-services`, and none of the three imports electron or desktop main. The host-neutral workspace modules (files, tree listing, search, the Quick Open listing, formatting, terminal backends) are in `src/desktop/workspace` under the same bans, and main reaches them only through `workspaces.forProject` (`main/workspace/`; elsewhere main's value imports from them are limited to `MAIN_WORKSPACE_IMPORTS` in `eslint.config.mjs`), keeping every model and UI rule itself. The per-user remote daemon (`src/desktop/daemon`, `dist/daemon.js`, which the desktop package never ships) is electron-free under the same bans and hosts the workspace modules, a Damocles Core and the pty and formatter hosts on a target; the wire both sides speak (`src/remote`: codec, framing, mux, handshake) imports only Node built-ins, never vscode, electron, `src/core` or `src/desktop`. Desktop main reaches the engine only through `CoreClient` (`main/core-client.ts`, served by `core-host/core-service.ts`; source control through `main/scm/scm-client.ts`): mirrors, events and requests, with value imports from `src/core` limited to `MAIN_CORE_IMPORTS` in `eslint.config.mjs` and none from `src/desktop/core-host`. Preloads expose `window.damoclesBridge` (chat and page views), `window.damoclesShell` and `window.damoclesOverlay`.
- **Builds:** esbuild → `dist/extension.js` (externals `scripts/extension-externals.mjs`, from which `scripts/sync-vscodeignore.mjs` derives the VSIX allowlist) and `dist/desktop/` plus hosts and workers in `dist/` (externals `scripts/desktop-externals.mjs`); Vite → `dist/webview/` (shadcn-vue, Tailwind, Shiki) and `dist/desktop-shell/`.
- **Desktop shell:** every menu item, app shortcut and palette entry comes from `src/desktop/main/commands.ts`; menus and dialogs render in the top-most overlay view, toasts only in the popup window; browser pages are editor tabs of their chat. Renderer security: "Desktop host" in `docs/invariants.md`.
- **Aliases:** `@shared/*` → `src/shared/*`, `@/*` → `src/webview/*`.
### Key Modules (`src/core/`; VS Code-only code in `src/vscode/`)
| Module | Purpose |
| --- | --- |
| `pi-session/` | Agent backend: `PiSession`, process-global `PiRuntime` (providers, auth, keys), one `FolderRuntime` per folder. `pi-stream-adapter.ts` + `tool-normalization.ts` map pi events onto webview shapes; `agent-records.ts` owns subagent and team data paths |
| `chat-panel/` | Panels, session manager, settings, message routing, history |
| `permission-handler/` | Tool permissions (approval, question, plan, skill) |
| `memory/` | Memory + fact graph, extraction, delta injection (`injection/`), `node:sqlite`/FTS5 |
| `compass/` | Knowledge graph: tree-sitter → SQLite → MCP tools (off by default) |
| `scm/` | Desktop source control engine (`git-runner.ts`, `scm-service.ts`; hunk staging `staging.ts`, `blame.ts`, `timeline.ts`, `merge.ts`, the Graph's `history.ts` and `graph-lanes.ts`), run in Damocles Core (`src/desktop/core-host/scm.ts`); its UI side is `src/desktop/main/scm/` |
| `worktrees/` | Worktree chats (desktop): the registry, creation, Apply/Merge/Update/Delete, hand-off file moves, the leftover scan and `personalConfigRoot`; panel side in `chat-panel/panel-worktrees.ts`, session side in `pi-session/session-worktree.ts` |
| `usage-stats/` | `/stats` spend index |
| `process-launcher/` | Worker-thread pool that starts every process Damocles owns off the host thread (`docs/perf.md` lists the exceptions); session jobs, tree kill, `runProcess` |
| `web-access/` | Web tools (off by default); SSRF-guarded `safe-fetch.ts` |
| `browser/` | Patchright browser + tools, scoped by `agent-scope.ts` (off by default) |
| `team/` | Multi-agent teams (off by default) |
| `voice/` | Speech-to-text + Jarvis sidecar (off by default) |
| `paths.ts`, `asset-sources.ts` | `~/.damocles` paths; `.claude`/`.codex` skill and command sources |
### Patterns
- Each module has an `index.ts` facade; managers receive deps via constructor.
- Heavy modules (Memory, Compass) read config in the constructor and defer work to `ensureInitialized()`.
- Messages route through domain-handler registries: `message-router/handlers/` (extension), `message-handler/handlers/` (webview).
### Invariants
Rationale and failure modes: **`docs/invariants.md`**. Read the subsystem's section before changing it.
- pi is the only engine; never add harness selection. Map pi events onto existing webview shapes in `pi-stream-adapter.ts`; add a message type only for a capability no shape can carry.
+ - Core never renders user-facing text: it sends `LocalizedText` (`l()`), which each view or host renders in its own language; a webview store keeps it and only a template or computed renders it; `english()` is the model's. See "Engine and message contract".
- pi's run state (running, settling, idle) is read only through the session's one `RunLifecycle` (`pi-session/run-lifecycle.ts`), never `isStreaming`, `isIdle` or `waitForIdle`; `pi-run-state-reads.test.ts` enforces it. See "pi's run lifecycle".
- Tool-result image data never travels in a tool, history, subagent or team message or sits in a Pinia store: those carry `imageCount`, and `ToolResultImages.vue` fetches via `requestToolResultImages`.
- `Write` is the only text-file creation path; `Edit` cannot create. `GenerateImage` only creates new images and is gated as category `write`, never via `GATEABLE_MODULE_NAMES`.
- Browser/Compass/Web/Image/MCP tools are deferred until `ToolSearch` loads them: keep them in pi's eligible set, never name one in a prompt without an adjacent `ToolSearch` step, and pass every deferral site `deferrableToolNames()`. The ToolSearch menu equals the loadable set; report what pi activated and sanitize third-party MCP text.
- Nested (subagent/team) MCP arrives as `customTools` frozen at spawn from one descriptor read and one resource-server read, never via the shared registrar. The grant is uniform; `disallowed_tools` (exact, case-sensitive) is the only opt-out.
- Read-only agents and team reviewers get plan mode's shell rule in every mode and are deliberately not capability-capped (they may call a non-annotated MCP tool through `canUseTool`). See "Teams and subagents" before changing it.
- The MCP client is `@earendil-works/pi-mcp`; Damocles owns the host layer (`mcp/`). Never load pi's built-in MCP extension, which keeps per-instance state where Damocles shares one instance per folder. See "MCP client and config sources".
- A custom tool under a pi built-in's name (`bash`, `grep`, `find`, `write`, `ls`) replaces it: keep the exact lowercase name and never add an `excludeTools` entry for it, which drops the replacement too.
- The shell process-lifetime path has no timer, interval or process-table read (`tools/process-tree.ts`). `createShellJob` stays the first statement after `spawn` in the launcher worker, or a background job becomes unkillable.
- A cancel note is user turn content, never appended to a tool result. A steer is a user message whose first line is `STEER_INSTRUCTION_PREFIX`; never merge peer or tool text into one, which would give it operator authority.
- An overlay's z-index comes from the shared stack (`useOverlayEscape.ts`), never a fixed `z-` class; popper content inside one binds `usePopperZIndex`, and a modal dialog joins via `useDialogLayer`.
- A chat's settings read passes its folder (`settingsFolderOf`) only for a `CHAT_SETTING_KEYS` key (`"scope": "resource"`), and project and local files apply only while the folder is trusted. Core decides write scopes per setter (D37), never the renderer; from a remote chat's scope tab a setter writes that tab's file (`settingsViewWriteScope`), and the tab lists only the keys core says it may write. The desktop settings overlay is a `PanelManager.attachView` of the selected chat (a remote chat's User tab: a local chat), limited to `SETTINGS_VIEW_MESSAGES` and `SETTINGS_VIEW_REQUESTS`, with host prompts only through `webview-prompts.ts`.
- Renderers read only `--d-*` tokens; `src/webview/styles/tokens.css` alone reads `--vscode-*`. Desktop fills the tokens from `src/desktop/main/theme.ts` (text at WCAG AA); `--s-*` and `--d-ansi-*` are desktop-only.
- Renderer sizes are rem (px ÷ 16); px only for border, ring and outline widths, blur, shadows and main's native view bounds. Icons use a `size-*` class, script-side sizes `remPx`, and layouts container queries, never viewport ones. ESLint and stylelint enforce it.
- A chat working in its chat worktree is isolated from its project folder in every mode, YOLO included, before any hook's force-allow (`worktree-isolation.ts`, D62); it is a wrong-copy guard, not a sandbox (rules: `docs/worktree-isolation.md`). Where a chat works is its session's latest `damocles.worktree` entry; an existing session is found by its file, never by `piSessionDir` of the current folder, and its agent data lives beside that file. Several chats may share one chat worktree; a working folder has its own MCP scope, trusted as its project. A hand-off changes no file or registry record before the conversation is claimed, and the place entry is written by the resumed session. A finish action holds its worktree, runs only on the core that gave the hold, and core refuses its chats' sends meanwhile, on every send path (`PiSession.promptPi`); a hand-off in flight blocks both. An unparsable `registry.json` keeps its last records, across a core restart too (`CoreInit.worktreeRecords`), and refuses writes. See "Worktree chats".
- All tool calls go through `permission-gate.ts`. Runtime blocks use `formatPolicyBlockReason` (an unasked deny only via `buildUnaskedDenyResult`); only a real user rejection uses `formatDenyReason`. Only a user deny without feedback or an opted-in hook sets `terminate`. A check that cannot decide blocks every tool, reads included.
- A conversation is live in one panel of one process (`claimStoredSession` plus the cross-process lease); a session-file write without a live session goes through `writeUnderSessionLease`. A team member's or subagent's session file belongs to the run that opened it until that run settled, a held stop included (`agent-file-owner.ts`). Session-keyed registries on `PiRuntime` and `FolderRuntime` unregister only the caller's own entry.
- A resume, restore or hand-off on a panel pi holds another conversation in shows its conversation only once pi switched to it (`openResumeTarget`), and a failed switch leaves the panel on the stored conversation it held. A panel that has not started shows it first and starts after the replay; a replay that read no file lets go of it (`letGoOfUnreadTarget`), and a start that cannot open it tells the user and keeps it as the target. Every send names the conversation the webview showed, and core refuses one for another after its waits (`PiSession.sendsTo`). See "Session store".
- Nothing appends to a deleted session file: every holder detaches first (`detachFromDeletedSession`), a writer re-checks liveness after each `await`, and no delete is sequenced off a promise that resolves on a failed replacement.
- Every Node bundle maps `fs` to `original-fs` under Electron (`scripts/esbuild-original-fs.mjs`), so a user's `.asar` is a file and stays deletable; Damocles' own packed files are read only through `src/core/resource-fs.ts`, and a bundled library that uses `fs`, directly or through `graceful-fs`, is in `REVIEWED_PACKAGES` or is an external. See "Desktop packaging and updates".
- Damocles reads other tools' config (`.claude`, `.codex`, `.pi`, `.mcp.json`) and writes only under `.damocles`, except the user-run Manage Unsafe Repositories, Add to .gitignore, a remote host's Allow background work (`loginctl enable-linger` for the host's own user, `daemon/linger.ts`) and its Set up passwordless login (Damocles' own key appended to the target user's `authorized_keys`, or `administrators_authorized_keys` on a Windows admin, as ssh-copy-id does, `daemon/authorized-keys.ts`), its Remove Damocles server (its own server files, and only when their boxes are checked the key line it installed and the linger it enabled, `main/remote/remove-server.ts`) and a host's own install folder (server packages in `<folder>/<user>`, never `<folder>` itself). Repository-authored input (instructions, skills, hooks, `.pi/`, MCP, permission rules) applies only in a trusted window, and on trust grant with no reload.
- Single sources of truth: plan content is the plan file (`getPlanContent()`), plan version is core's stamp on the `ExitPlanMode` result (`plan-version.ts`), plan guidance is `plan-mode-guidance.ts`, and the system prompt is `agent-start.ts` (pi's `customPrompt`, a constant, plus one named section per runtime input; returning `systemPrompt` drops every section).
- The pi extension is process-global: hold no instance-wide session state, route on `ctx.sessionManager.getSessionId()`, and queue checkpoint git work instead of awaiting it in a handler.
- - Memory injection is a delta whose state comes only from each injection message's `details`. Neutralize emitted tag names in stored text; never register a `context` handler or rewrite a past injection, which breaks the prompt-cache prefix. The one request-time rewrite is the model-switch fold (`context_with_system`, `model-switch-prompt.ts`): system messages only, and the prefix moves only at a model switch.
+ - Memory injection is a delta whose state comes only from each injection message's `details`. Neutralize emitted tag names in stored text; never register a `context` handler or rewrite a past injection, which breaks the prompt-cache prefix. The one request-time rewrite is the identity fold (`context_with_system`, `identity-prompt-fold.ts`): system messages only, and the prefix moves only at a change of the chat's model or environment (its working folder and the git, platform, shell and OS facts beside it).
- An `agent_before_settle` handler returns `{ entries: [...event.entries, draft] }` and holds a turn open with `continue: true`, never `session.prompt()`.
- Page output is hostile: redact and bound it at capture, with linear-time patterns. Browser tools resolve tabs via the caller's `BrowserAgentScope`, never a global active page.
- Team delivery branches on `TeamMessage.kind`, never rendered text; the extension computes verification fingerprints, which fail visibly. `ReviewCoverage` (`review-coverage.ts`) is the one owner of review gating.
- `team_standby` and `team_report_complete` end the turn from the engine. Register a turn decider via `installTurnDecider`; assigning `agent.finishTurn` drops every extension `turn_end` handler.
- A team agent's work fields are per attempt and its usage is cumulative. The runner, `persistence.ts`, `TeamRunLog` and the webview store must agree, and agent times come only from team-log timestamps.
- Effort badges show pi's effective `thinkingLevel` after clamping, and only for a reasoning model, unless its catalog entry sets `supportsEffort: false` or pi's `compat.supportsReasoningEffort` is false (`effortBadgeApplies`, `pi-session/pi-models.ts`).
- - One publisher each: account state, picker providers included, `PiSession.publishAccountInfo()` (called wherever an input changes, never once-guarded), the model list's `availableModels` marks `publishAvailableModels()` (on every account republish), session state `publishSessionState()` (a new prompt kind registers through `PermissionState`), the chat's model `publishModel()` (the model the chat shows, which pi may run on a fallback until a send adopts the shown model or refuses; the context window is the adopted pi model's, the static table only a pre-adoption fallback), the chat review counter `publishChatChanges()` (desktop only, computed by `chat-review.ts` off pi's handlers), and `worktreeState` `SessionWorktree.publish()` (desktop only, `HostCapabilities.worktrees`), with `publishStored()` for the place a resumed file gives before its session starts. The webview never infers or pre-shows them.
+ - One publisher each: account state, picker providers included, `PiSession.publishAccountInfo()` (called wherever an input changes, once per panel for that change: the runtime for the sessions it holds, the panels' listeners for the rest; never once-guarded), the model list's `availableModels` marks `publishAvailableModels()` (on every account republish), session state `publishSessionState()` (a new prompt kind registers through `PermissionState`), the chat's model `publishModel()` (the model the chat shows, which pi may run on a fallback until a send adopts the shown model or refuses; the context window is the adopted pi model's, the static table only a pre-adoption fallback), the chat review counter `publishChatChanges()` (desktop only, computed by `chat-review.ts` off pi's handlers), and `worktreeState` `SessionWorktree.publish()` (desktop only, `HostCapabilities.worktrees`), with `publishStored()` for the place a resumed file gives before its session starts. The webview never infers or pre-shows them.
- A stored sign-in beats the key saved in Damocles unless that provider's "Prefer API key" is on (`openaiRuntimeKeyWanted`, `providerRuntimeKeyWanted`). The key sync never logs out an `oauth` credential, and logs out a stored `api_key` only in its one-time legacy sweep (`PROVIDER_KEY_SWEEP_MARKER_PATH`), of the providers whose def sets `legacySweep`. OpenAI stays out of `CUSTOM_PROVIDER_DEFS`: `openai-runtime-key.ts` applies its runtime key and `openai-key-migration.ts` migrates its legacy key, under the rule and status reads in `openai-auth.ts`.
- A subagent's model and effort are configuration (`Agent` has no `model` or `thinking` param). Every background subagent outcome reaches the parent once per branch; the branch and agent files are the truth, the `AgentManager` map a cache.
- Internal sub-calls use `PiRuntime.runStructuredCompletion` and classifier calls `PiRuntime.runClassification`; never hand-roll `completeSimple` or call `classify()`. Never mutate `process.env` for per-session config.
- Spend follows `src/shared/usage-accounting.ts`. Never bill from `history-loader.ts`'s context snapshot; status bar totals travel only in `sessionUsage`.
- Process starts go through `process-launcher/`; lint refuses a `child_process` or `cross-spawn` import outside the files `eslint.config.mjs` allows (exceptions and reasons in `docs/perf.md`). On Windows every start, on any thread, holds the process's spawn lock (`process-launcher/spawn-lock.ts`) around its synchronous `spawn`, or a child started beside it inherits its pipes; a worker that starts processes adopts the lock from its `workerData` (`docs/perf.md` "Concurrent starts on Windows"). Spawns use absolute paths: git via `resolveGitExecutable` (`src/core/git-executable.ts`), OS tools via `src/core/system-tools.ts` or a fixed path; `system-tools.test.ts` fails on a bare program name. `core/scm` runs git only in a trusted project (Clone excepted, its project starts untrusted; and the credential relay's target-helper probe, which outside one reads only the system and global config from an empty folder), as argv with `GIT_EDITOR=true` and `GIT_TERMINAL_PROMPT=0`; a parent repository opens only after D9's answer, and askpass variables go only on calls that may need credentials, never into `process.env`.
- Main builds every page through `showPage` (`src/shared/panel-html.ts`); a desktop core, a remote one included, only names the page's kind and never sends HTML.
- A remote core never writes this computer's files or changes its credentials: the daemon's routes and the token broker refuse it (`remote-client-writes.test.ts`). See "Remote daemon and wire".
- ssh starts only through `main/remote/ssh/ssh-args.ts`, and a connection gets the fixed option set with exactly one `ForwardAgent` (`yes` only while the host's switch is on and the host is known to be POSIX), `-o IdentitiesOnly=yes -o IdentityFile=<~/.damocles/ssh/id_ed25519>` ahead of the saved options once passwordless login is set up and verified, `--` before the destination, no alias, host or user starting with `-`, and only allowlisted options (`ssh-command-parse.ts`); a saved address the parser's rules refuse fails the connect (`destinationRefused`), never silently dropped. Askpass variables go on that ssh call alone, from main's own askpass server; on Windows `SSH_ASKPASS` is the native `ssh-askpass.exe`, never a `.cmd`. A remembered password answers only a prompt that names exactly one hop, and nothing is bound when any hop has a ProxyCommand; on OpenSSH before 8.4 (or a version `ssh -V` does not name) a passphrase is never reused or remembered and a password only on a single-hop connection. Stage and removal parameters enter their scripts only through `shellQuote`/`psQuote`, the package hash is checked before unpacking, and Damocles writes nothing under the client's `~/.ssh` (its key lives in `~/.damocles/ssh/`). A target's git reaches this computer's credential manager only through the relay (`credential-relay.ts`): an https `get` after consent, whose nonce terminals and agent shells get without the prompt capability; `store` and `erase` never run this computer's credential manager, an erase reaches this computer only when it names the relayed password's SHA-256, and `fill` runs from an empty folder without repository-local `GIT_*` variables. See "Remote daemon and wire".
- The daemon starts processes on its launcher, except its forks (Core, the pty and formatter hosts) and the bridge's detached daemon start, all in `daemon/process-starts.ts` under the spawn lock (`docs/perf.md` "Starts outside the launcher").
- A wire contract change (`src/remote/protocol-version.ts`) is additive and named in `FEATURES`, or bumps `PROTOCOL_VERSION`; the frozen handshake and control commands never change. `protocol-compat.mjs` refuses to write a breaking snapshot unless `PROTOCOL_PRE_RELEASE` is true and `--allow-breaking` is passed, and CI checks the contracts against the base branch's snapshot.
- Search the code and memory observations for implementation gotchas before assuming.
## Permission Modes
| Mode | Behavior |
| --- | --- |
| `plan` | Bash/PowerShell classified by `readonly-shell.ts` (proven read-only auto-runs, else prompts); Edit/Write outside the plan file and GenerateImage blocked; memory, Compass and enabled MCP available |
| `default` | Diff for Edit/Write; prompts GenerateImage and Bash/PowerShell |
| `acceptEdits` | Auto-approves Edit/Write/GenerateImage, except a path a link carries outside the cwd; prompts Bash/PowerShell |
Read-only tools auto-approve in every mode unless an ask rule matches. Rules combine across all settings files as deny > ask > allow, and an ask rule prompts in every mode, after plan mode's blocks. `dangerouslySkipPermissions` (YOLO) approves everything except worktree isolation's blocks (D62), which apply in every mode. A desktop engine restart reopens each chat with the mode and YOLO its ended core published (`ChatEntry.permissions`), never the defaults; a relaunch starts from the defaults. Detail: "Permissions and plan mode".
## Code Quality Standards
- Self-documenting, functional over OOP, related code kept close.
- Tailwind over custom CSS; controls are shadcn-vue parts from `src/webview/components/ui/` (add a missing one there), custom markup only with a one-line reason.
## Behavioral Principles
- **Think before coding:** state assumptions; if a request is unclear, ask. Push back when a simpler approach exists.
- **Simplicity first:** minimum code; no speculative features, abstractions, configurability or impossible-case handling.
- **Surgical changes:** touch only what the request needs; remove only what your change made unused, and mention pre-existing dead code instead of deleting it.
- **Goal-driven:** define success first; for a bug, write a failing test, then make it pass. State a brief plan for multi-step work.