diff --git a/.github/workflows/vsix-release.yml b/.github/workflows/vsix-release.yml
new file mode 100644
index 0000000..efc31c5
--- /dev/null
+++ b/.github/workflows/vsix-release.yml
@@ -0,0 +1,77 @@
+name: Build & Release VSIX
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ upload_to_release:
+ description: 'Upload VSIX to the latest GitHub release'
+ required: false
+ default: 'false'
+ type: boolean
+
+permissions:
+ contents: write
+
+jobs:
+ build-vsix:
+ name: Build VSIX
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install dependencies
+ working-directory: vscode-extension
+ run: npm install
+
+ - name: Lint extension source files
+ run: |
+ ERRORS=0
+ for file in vscode-extension/extension.js vscode-extension/agent-bridge.mjs; do
+ if [ -f "$file" ]; then
+ if ! node --check "$file" 2>/tmp/lint-err.txt; then
+ echo "::error file=${file}::Syntax error in ${file}"
+ cat /tmp/lint-err.txt
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "::notice::${file} β OK"
+ fi
+ fi
+ done
+ if [ "$ERRORS" -gt 0 ]; then
+ echo "::error::${ERRORS} extension file(s) have syntax errors"
+ exit 1
+ fi
+
+ - name: Package VSIX
+ working-directory: vscode-extension
+ run: npm run package
+
+ - name: Find VSIX file
+ id: find_vsix
+ working-directory: vscode-extension
+ run: |
+ VSIX_FILE=$(ls *.vsix | head -1)
+ echo "vsix_file=$VSIX_FILE" >> "$GITHUB_OUTPUT"
+ echo "vsix_path=vscode-extension/$VSIX_FILE" >> "$GITHUB_OUTPUT"
+
+ - name: Upload VSIX as workflow artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ steps.find_vsix.outputs.vsix_file }}
+ path: ${{ steps.find_vsix.outputs.vsix_path }}
+ retention-days: 30
+
+ - name: Upload VSIX to GitHub Release
+ if: github.event_name == 'release' || inputs.upload_to_release == 'true'
+ uses: softprops/action-gh-release@v2
+ with:
+ files: ${{ steps.find_vsix.outputs.vsix_path }}
+ tag_name: ${{ github.event_name == 'release' && github.ref_name || '' }}
diff --git a/.gitignore b/.gitignore
index 9e57b6b..73bdadf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,9 @@ dist/
build/
*.tgz
+# Bundled v2 source (copied by prepackage script, not committed)
+vscode-extension/v2/
+
# Environment
.env
.env.local
diff --git a/README.md b/README.md
index 80f7a69..c8fb771 100644
--- a/README.md
+++ b/README.md
@@ -7,12 +7,13 @@
-
+
+
> **Automated Nightly Releases** β Open Claude Code automatically detects new [Claude Code](https://www.npmjs.com/package/@anthropic-ai/claude-code) releases, runs 903+ tests to verify zero regressions, and publishes verified builds with AI-powered discovery analysis. See [Releases](https://github.com/ruvnet/open-claude-code/releases) | [ADR-001](docs/adr/ADR-001-nightly-verified-release-pipeline.md) | [pi.ruv.io](https://pi.ruv.io)
@@ -42,6 +43,59 @@ npx @ruvnet/open-claude-code "what files are in this directory?"
---
+## π₯οΈ VSCode Extension
+
+A **Cursor-style AI coding assistant** built directly into VSCode β no terminal required.
+
+### Quick install (pre-built VSIX)
+
+The extension package is included in the repo and ready to install:
+
+```bash
+code --install-extension vscode-extension/open-claude-code-1.4.0.vsix
+```
+
+Or use **Extensions β β¦ β Install from VSIXβ¦** and pick the file from the `vscode-extension/` folder.
+
+### Build from source
+
+```bash
+cd vscode-extension
+npm install
+npm run package # prepackage β package β postpackage
+code --install-extension open-claude-code-1.4.0.vsix
+```
+
+### Highlights
+
+- **Dedicated activity bar icon** β opens a full Cursor-style chat panel in the sidebar
+- **`@claude` chat participant** β access Claude directly from VS Code's built-in Chat panel
+- **Full tool access** β all 25+ agent tools (Read, Write, Edit, Bash, Glob, Grep, WebFetch, β¦)
+- **Rich markdown + syntax highlighting** β code blocks with copy & Apply-to-file buttons
+- **Copy whole answer** β β Copy button on every assistant reply copies the full response to the clipboard
+- **Session memory** β the model remembers the **full conversation from the beginning** across VS Code restarts (Claude Premium-style); auto-saves after every response and restores on reopen
+- **Chat history** β History button shows all past conversations (Cursor-style panel); "New" auto-saves the current session; sessions persist across VS Code restarts; up to 30 sessions are retained
+- **βΆ Resume** β any past session can be fully resumed: all messages are restored in the chat panel and the model's context is re-injected into the agent bridge
+- **β Settings shortcut** β gear button in the header opens the extension settings directly β no need to navigate through the marketplace
+- **Streaming responses** β tokens arrive in real time with an animated cursor
+- **Tool visualization** β collapsible cards showing each tool execution and result
+- **`@file` context injection** β type `@filename` or click π to add a file to the prompt
+- **`@git` context chip** β type `@git` or click the git button to inject current branch, changed files, and diff summary into the conversation
+- **`@errors` context chip** β type `@errors` or click the β button to inject all workspace errors and warnings (VSCode diagnostics) into the conversation
+- **`@openfiles` autocomplete** β type `@openfiles` to pick from your currently open editor tabs
+- **Auto-attach active file** β toggle the π button (or `openClaudeCode.autoAttachActiveFile` setting) to automatically include the active editor with every message
+- **Context-full warning** β a banner appears when the context window exceeds 85%, with a quick "New chat" shortcut
+- **Per-message response time** β each assistant reply shows how long it took to generate (visible on hover)
+- **Ctrl/Cmd+Enter** β additional keyboard shortcut for sending a message
+- **Multi-provider** β Anthropic Claude, OpenAI GPT, Google Gemini, NVIDIA NIM
+- **Model & permission-mode selector** β switch model and mode directly from the UI
+- **Session stats** β token count, cost estimate, and elapsed time always visible
+- **Proactive workspace analysis** β the agent explores your project automatically before answering; never asks you to paste code
+
+See [`vscode-extension/README.md`](./vscode-extension/README.md) for the full setup and configuration guide.
+
+---
+
## π§ What Is This?
**Open Claude Code** is a ground-up open source rebuild of Anthropic's [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code), informed by [ruDevolution's](https://github.com/ruvnet/rudevolution) AI-powered decompilation of the published npm package.
@@ -301,8 +355,13 @@ AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... occ -m bedrock/claude-sonnet "he
# Google Vertex
GOOGLE_APPLICATION_CREDENTIALS=... occ -m vertex/claude-sonnet "hello"
+
+# NVIDIA NIM (kimi-k2.5, deepseek-r1, and other thinking models supported)
+NVIDIA_API_KEY=nvapi-... occ -m kimi-k2.5 "hello"
```
+> **Note β NVIDIA models (Kimi K2.5, DeepSeek R1):** These models support **full tool-calling by default** β they can Read, Write, Bash, Grep, and run all 25+ agent tools exactly like Cursor or opencode. Set `NVIDIA_THINKING_MODE=true` (or toggle the `openClaudeCode.nvidiaThinkingMode` setting in the VSCode extension) to opt into extended reasoning mode; in that mode tools are replaced with a rich workspace snapshot injected into the system prompt (file tree + key file contents), since NVIDIA NIM does not allow tools and thinking simultaneously.
+
---
## π MCP Integration
@@ -351,6 +410,81 @@ This is a **clean-room implementation** β no leaked source used. Architecture
---
+## π What's New
+
+### v1.4.0 β Context Injection, Auto-Attach & UX Improvements
+
+**Richer context injection**
+- **`@git` chip** β type `@git` in the chat input (or click the new git toolbar button) to instantly inject the current branch name, `git status`, and `git diff --stat` as a context chip. The content is appended inline to your message so the model has full awareness of your working-tree state
+- **`@errors` chip** β type `@errors` (or click the β toolbar button) to pull all workspace errors and warnings from the VSCode diagnostics API into the conversation, grouped by severity
+- **`@openfiles` autocomplete** β type `@openfiles` to populate the `@` autocomplete dropdown with every file currently open in the editor, making it easy to add multiple files without typing paths
+- **Auto-attach active file** β click the new π toolbar button (or set `openClaudeCode.autoAttachActiveFile: true` in settings) to automatically include the currently active editor file with every message you send β no need to type `@` every time
+
+**UX improvements**
+- **Context-full warning banner** β a visible warning appears above the input when context usage exceeds 85%, with a one-click "New chat" shortcut to start fresh before responses degrade
+- **Per-message response time** β each assistant reply now shows how long it took to generate (e.g. `3.2s`) in the message header, visible on hover
+- **Ctrl/Cmd+Enter** β added as a second keyboard shortcut for sending messages (in addition to plain Enter)
+- **Consistent keyboard hint** β the input area now shows `Enter / Ctrl+Enter send` to surface the new shortcut
+
+### v1.3.1 β Bridge Stability & Session Memory Fixes
+
+- **Session memory loss fix** β agent bridge now auto-injects the full session history whenever a new bridge process is created (covers crashes, settings changes, and VS Code restarts)
+- **Bridge queue stability** β message queue is drained reliably on bridge restart so in-flight requests are not lost
+- **Max-turns UX hint** β when the agent loop hits its turn limit a visible `β Max turns reached` notice is shown with instructions to continue
+
+### v1.3.0 β Session Memory (Claude Premium-style)
+
+**Session Memory algorithm** β the model now remembers the full conversation from the very beginning, just like Claude premium sessions:
+
+- **Auto-persist active session** β the current conversation is automatically saved to VS Code `globalState` after every response. If you close and reopen VS Code, your conversation is fully restored β messages in the chat panel **and** the model's context in the bridge subprocess
+- **Session resume from history** β every past conversation in the History panel now has a **βΆ Resume** button. Clicking it:
+ 1. Saves your current in-progress chat to history
+ 2. Restores all messages of the selected session in the main chat panel
+ 3. Re-injects the full conversation into the agent loop (`resume` command in `agent-bridge.mjs`) so the model has complete memory of everything said β no context lost
+- **New `resume` bridge protocol** β `agent-bridge.mjs` accepts `{"type":"resume","messages":[β¦]}` which converts the stored UI message format back to the Anthropic/OpenAI API message format and sets it directly on the agent loop's state
+- **Auto-clear on new chat** β starting a new conversation clears the persisted active session so the next restart begins fresh
+
+### v1.2.0 β Chat History, Settings Button & Copy Answer
+
+**New UI features** _(this PR)_
+- **β Settings button** β a gear icon added to the chat header opens `openClaudeCode` settings directly; no more navigating through the Extensions marketplace
+- **β Copy whole answer** β each finalized assistant reply now has a `β Copy` button in the message header; one click copies the full raw markdown response to the clipboard
+- **Chat history panel (Cursor-style)** β clicking the new **History** header button opens a full-overlay panel listing all past sessions. Pressing **New** auto-saves the current conversation to history first. Sessions are persisted in VS Code `globalState` and survive restarts; up to 30 sessions are retained
+- **Cursor-style session navigation** β click any session in the history list to view its messages read-only (with Copy buttons intact); a Back button returns to the session list
+
+**Fix: Proactive workspace analysis for all models**
+- All models now receive a strong agentic system prompt declaring the workspace `cwd` and instructing them to explore files with LS / Glob / Read / Grep / Bash before answering β never asking the user to paste code
+- New `buildWorkspaceSnapshot` helper recursively walks the workspace (skipping `node_modules`, `.git`, `dist`, etc.) and returns a compact indented file tree capped at 200 entries
+- New `buildWorkspaceContent` helper reads key project files (README, package.json, entry points, etc.) and returns their contents for inline injection β capped at 64 KB total
+- **Kimi K2.5 and DeepSeek R1 now use full tool-calling by default** β Read, Write, Bash, Grep, and all 25+ tools work exactly like Cursor or opencode; thinking/reasoning mode is an opt-in setting (`NVIDIA_THINKING_MODE=true` or `openClaudeCode.nvidiaThinkingMode` in VSCode settings)
+- When thinking mode IS enabled a rich workspace snapshot (file tree + key file contents) is injected into the system prompt with a purpose-built thinking-model system prompt
+- Extension model descriptions updated; version bumped to 1.2.0
+
+### v1.1.0 β VSCode Extension & Bug Fixes
+
+**VSCode Extension β Cursor-style sidebar panel** _(PR #2)_
+- New dedicated activity bar icon with a full-screen Cursor-style chat panel
+- Rich markdown rendering, syntax-highlighted code blocks, copy & Apply-to-file buttons
+- Streaming responses with animated cursor, tool visualization cards, extended thinking blocks
+- `@file` context injection, file picker, model/mode selector, session stats, and stop button
+- Pre-built VSIX committed to `vscode-extension/open-claude-code-1.1.0.vsix` β install with one command
+
+**VSCode Extension β `@claude` Chat Participant** _(PR #1)_
+- New `vscode-extension/` package exposing the `v2/src` agent loop as a native VSCode Chat participant
+- Long-lived `agent-bridge.mjs` subprocess keeps conversation history across turns
+- API key stored in VSCode `SecretStorage` β never written to disk in plaintext
+- `/clear` and `/model` slash commands; configurable permission mode, max turns, and tool visibility
+
+**Fix: NVIDIA NIM thinking models** _(PR #3)_
+- `kimi-k2.5`, `deepseek-r1`, and other NVIDIA thinking models no longer crash with HTTP 400
+- Tool array is now omitted for thinking models (the two parameters are mutually exclusive)
+- Clean system prompt (without tool descriptions) is used for thinking-model calls
+
+**VSCode extension package now tracked in git** _(PR #4)_
+- `*.vsix` removed from `.gitignore` β the built package lives at `vscode-extension/open-claude-code-1.1.0.vsix`
+
+---
+
## π Related
- [ruDevolution](https://github.com/ruvnet/rudevolution) β AI-Powered JavaScript Decompiler
diff --git a/scripts/last-known-claude-version.txt b/scripts/last-known-claude-version.txt
index 7b71b36..6b81aaa 100644
--- a/scripts/last-known-claude-version.txt
+++ b/scripts/last-known-claude-version.txt
@@ -1 +1 @@
-2.1.112
+2.1.177
diff --git a/v2/src/config/env.mjs b/v2/src/config/env.mjs
index 9ec0e15..265d3f0 100644
--- a/v2/src/config/env.mjs
+++ b/v2/src/config/env.mjs
@@ -17,6 +17,7 @@ export const ENV_SCHEMA = {
OPENAI_BASE_URL: { type: 'string', default: 'https://api.openai.com/v1', description: 'OpenAI-compatible base URL' },
GOOGLE_API_KEY: { type: 'string', description: 'Google AI API key' },
GEMINI_API_KEY: { type: 'string', description: 'Alias for GOOGLE_API_KEY' },
+ NVIDIA_API_KEY: { type: 'string', description: 'NVIDIA NIM API key (integrate.api.nvidia.com)' },
// Model Configuration
CLAUDE_CODE_MAX_OUTPUT_TOKENS: { type: 'number', default: 16384, description: 'Max output tokens' },
diff --git a/v2/src/core/agent-loop.mjs b/v2/src/core/agent-loop.mjs
index 36e7a3f..bee803e 100644
--- a/v2/src/core/agent-loop.mjs
+++ b/v2/src/core/agent-loop.mjs
@@ -4,9 +4,27 @@
*/
import { streamResponse, accumulateStream } from './streaming.mjs';
import { ContextManager } from './context-manager.mjs';
-import { buildSystemPrompt } from './system-prompt.mjs';
+import { buildSystemPrompt, buildWorkspaceSnapshot, buildWorkspaceContent, buildThinkingModelSystemPrompt } from './system-prompt.mjs';
+import { isNvidiaModel } from './providers.mjs';
import fs from 'fs';
import path from 'path';
+
+/**
+ * NVIDIA NIM models that CAN use chat_template_kwargs.thinking=true for
+ * extended reasoning β but only when NVIDIA_THINKING_MODE=true is set.
+ *
+ * By default (NVIDIA_THINKING_MODE unset / false) these models work in
+ * standard tool-calling mode: Read, Write, Bash, Grep, etc. all work.
+ *
+ * When NVIDIA_THINKING_MODE=true the thinking flag is added and tools are
+ * omitted (NVIDIA NIM rejects the combination), falling back to workspace
+ * snapshot injection.
+ */
+const NVIDIA_THINKING_CAPABLE_MODELS = new Set([
+ 'moonshotai/kimi-k2.5',
+ 'deepseek-ai/deepseek-r1',
+]);
+
export function createAgentLoop({ model, tools, permissions, settings, hooks }) {
const contextManager = new ContextManager(settings.maxContextTokens || 180000);
@@ -21,6 +39,7 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
const state = {
messages: [],
systemPrompt: promptResult.full,
+ systemPromptStatic: promptResult.staticPrefix, // tool-free prefix for providers that don't use tools
turnCount: 0,
tokenUsage: { input: 0, output: 0 },
model,
@@ -53,14 +72,16 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
yield { type: 'stream_request_start', turn: state.turnCount };
- // Detect provider and call API
- const provider = detectProvider(model);
+ // Detect provider and call API β read state.model so that model
+ // switches (via handleModelSwitch) take effect on the next turn.
+ const currentModel = state.model;
+ const provider = detectProvider(currentModel);
let response;
try {
if (settings.stream !== false) {
// Streaming mode
- response = await callApiStreaming(provider, model, state, tools.list(), settings);
+ response = await callApiStreaming(provider, currentModel, state, tools.list(), settings);
const collectedContent = [];
let currentText = '';
let currentThinking = '';
@@ -87,7 +108,7 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
response = response.accumulated;
} else {
// Non-streaming mode
- response = await callApi(provider, model, state, tools.list(), settings);
+ response = await callApi(provider, currentModel, state, tools.list(), settings);
}
} catch (err) {
yield { type: 'error', message: err.message };
@@ -207,17 +228,18 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
function detectProvider(model) {
if (model.startsWith('gpt-') || model.startsWith('o1') || model.startsWith('o3')) return 'openai';
if (model.startsWith('gemini')) return 'google';
+ if (isNvidiaModel(model)) return 'nvidia';
return 'anthropic';
}
async function callApi(provider, model, state, toolDefs, settings) {
- const callers = { anthropic: callAnthropic, openai: callOpenAI, google: callGoogle };
+ const callers = { anthropic: callAnthropic, openai: callOpenAI, google: callGoogle, nvidia: callNvidia };
const caller = callers[provider] || callers.anthropic;
return caller(model, state, toolDefs, settings, false);
}
async function callApiStreaming(provider, model, state, toolDefs, settings) {
- const callers = { anthropic: callAnthropic, openai: callOpenAI, google: callGoogle };
+ const callers = { anthropic: callAnthropic, openai: callOpenAI, google: callGoogle, nvidia: callNvidia };
const caller = callers[provider] || callers.anthropic;
return caller(model, state, toolDefs, settings, true);
}
@@ -365,6 +387,349 @@ async function callGoogle(model, state, toolDefs, settings, stream) {
return convertGoogleResponse(data);
}
+/**
+ * NVIDIA NIM β OpenAI-compatible chat completions endpoint.
+ *
+ * Uses the same message format as OpenAI but targets
+ * https://integrate.api.nvidia.com/v1/chat/completions.
+ *
+ * For thinking-capable models (kimi-k2.5, deepseek-r1) the
+ * `chat_template_kwargs: { thinking: true }` parameter is added
+ * automatically so the model returns its reasoning trace.
+ *
+ * Streaming uses the standard OpenAI SSE format (data: {...} / data: [DONE]).
+ * The thinking content is surfaced as a "thinking" text block so the existing
+ * agent-loop thinking display works without modification.
+ */
+async function callNvidia(model, state, toolDefs, settings, stream) {
+ const apiKey = process.env.NVIDIA_API_KEY;
+ if (!apiKey) throw new Error('NVIDIA_API_KEY not set');
+
+ // Thinking mode is opt-in: only enabled when NVIDIA_THINKING_MODE=true.
+ // By default, capable models (kimi-k2.5, deepseek-r1) use standard
+ // function-calling mode β tools work exactly as in any other provider.
+ const thinkingEnabled = process.env.NVIDIA_THINKING_MODE === 'true';
+ const supportsThinking = thinkingEnabled && NVIDIA_THINKING_CAPABLE_MODELS.has(model);
+
+ // When thinking mode is active the tool-list suffix would be misleading
+ // (NVIDIA NIM rejects tools + thinking together), so swap in a special
+ // system prompt with a rich workspace snapshot instead.
+ let systemPrompt = state.systemPrompt;
+ if (supportsThinking) {
+ if (!state.systemPromptStatic) {
+ process.stderr.write('[open-claude-code] Warning: systemPromptStatic missing β falling back to full system prompt for ' + model + '\n');
+ }
+ const base = state.systemPromptStatic || state.systemPrompt;
+ const workspaceContent = buildWorkspaceContent(process.cwd());
+ systemPrompt = buildThinkingModelSystemPrompt(base, workspaceContent.summary);
+ }
+ const effectiveState = supportsThinking
+ ? { ...state, systemPrompt }
+ : state;
+
+ // Build OpenAI-style messages
+ const messages = buildOpenAIMessages(effectiveState);
+
+ const body = {
+ model,
+ messages,
+ max_tokens: settings.maxTokens || 16384,
+ temperature: 1.00,
+ top_p: 1.00,
+ stream: !!stream,
+ ...(supportsThinking && {
+ chat_template_kwargs: { thinking: true },
+ }),
+ // Include tools unless thinking mode is active (NVIDIA NIM rejects
+ // the combination of chat_template_kwargs.thinking + tools).
+ ...(!supportsThinking && toolDefs.length > 0 && {
+ tools: toolDefs.map(t => ({
+ type: 'function',
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
+ })),
+ }),
+ };
+
+ const res = await fetch('https://integrate.api.nvidia.com/v1/chat/completions', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${apiKey}`,
+ 'Accept': stream ? 'text/event-stream' : 'application/json',
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!res.ok) {
+ const err = await res.text();
+ throw new Error(`NVIDIA API error ${res.status}: ${err}`);
+ }
+
+ if (stream) {
+ // Stream with the generic OpenAI-style SSE parser
+ const collected = [];
+ const eventGenerator = async function* () {
+ for await (const event of streamOpenAIResponse(res)) {
+ collected.push(event);
+ yield event;
+ }
+ };
+ return {
+ events: eventGenerator(),
+ get accumulated() {
+ return accumulateOpenAIStream(collected);
+ },
+ };
+ }
+
+ const data = await res.json();
+ return convertNvidiaResponse(data);
+}
+
+/**
+ * Build an OpenAI-style messages array from agent loop state.
+ * Handles system prompt, plain-text turns, and tool_result turns.
+ */
+function buildOpenAIMessages(state) {
+ const messages = [];
+ if (state.systemPrompt) {
+ messages.push({ role: 'system', content: state.systemPrompt });
+ }
+ for (const msg of state.messages) {
+ if (typeof msg.content === 'string') {
+ messages.push({ role: msg.role, content: msg.content });
+ } else if (Array.isArray(msg.content)) {
+ for (const block of msg.content) {
+ if (block.type === 'tool_result') {
+ messages.push({
+ role: 'tool',
+ tool_call_id: block.tool_use_id,
+ content: typeof block.content === 'string'
+ ? block.content
+ : JSON.stringify(block.content),
+ });
+ } else if (block.type === 'text') {
+ messages.push({ role: msg.role, content: block.text });
+ }
+ }
+ }
+ }
+ return messages;
+}
+
+/**
+ * Convert a non-streaming NVIDIA response to the internal format.
+ * NVIDIA NIM returns the same shape as OpenAI, but may include a
+ * "thinking" field directly on the message for supported models.
+ */
+function convertNvidiaResponse(data) {
+ const choice = data.choices?.[0];
+ if (!choice) throw new Error('No choices in NVIDIA response');
+
+ const content = [];
+
+ // Some NVIDIA models surface thinking as a separate message field
+ const thinkingText = choice.message?.thinking || choice.message?.reasoning_content;
+ if (thinkingText) {
+ content.push({ type: 'thinking', thinking: thinkingText });
+ }
+
+ if (choice.message?.content) {
+ content.push({ type: 'text', text: choice.message.content });
+ }
+
+ if (choice.message?.tool_calls) {
+ for (const tc of choice.message.tool_calls) {
+ content.push({
+ type: 'tool_use',
+ id: tc.id,
+ name: tc.function.name,
+ input: (() => {
+ try { return JSON.parse(tc.function.arguments || '{}'); } catch { return {}; }
+ })(),
+ });
+ }
+ }
+
+ return {
+ content,
+ stop_reason: choice.finish_reason === 'stop' ? 'end_turn' : (choice.finish_reason || 'end_turn'),
+ usage: {
+ input_tokens: data.usage?.prompt_tokens || 0,
+ output_tokens: data.usage?.completion_tokens || 0,
+ },
+ };
+}
+
+/**
+ * Parse an OpenAI-style SSE stream into events.
+ * Each line has the format "data: {json}" or "data: [DONE]".
+ * Thinking content in delta is surfaced as a synthetic
+ * content_block_delta with type "thinking_delta" so the agent loop
+ * can display it without changes.
+ */
+async function* streamOpenAIResponse(response) {
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+ let thinkingBuffer = '';
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+
+ const lines = buffer.split('\n');
+ buffer = lines.pop(); // keep incomplete last line
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed === ':') continue;
+ if (!trimmed.startsWith('data:')) continue;
+
+ const raw = trimmed.slice(5).trim();
+ if (raw === '[DONE]') {
+ yield { type: 'done' };
+ return;
+ }
+
+ let chunk;
+ try { chunk = JSON.parse(raw); } catch { continue; }
+
+ // Surface API errors returned inside the SSE stream (HTTP 200 with error body)
+ if (chunk.error) {
+ throw new Error(chunk.error.message || JSON.stringify(chunk.error));
+ }
+
+ const delta = chunk.choices?.[0]?.delta;
+ if (!delta) continue;
+
+ // Thinking token (deepseek-r1 / kimi-k2.5)
+ const thinkingDelta = delta.reasoning_content || delta.thinking;
+ if (thinkingDelta) {
+ thinkingBuffer += thinkingDelta;
+ yield {
+ type: 'content_block_delta',
+ delta: { type: 'thinking_delta', thinking: thinkingDelta },
+ };
+ }
+
+ // Regular text content
+ if (delta.content) {
+ // If we have accumulated thinking, emit block boundaries once
+ if (thinkingBuffer) {
+ thinkingBuffer = '';
+ }
+ yield {
+ type: 'content_block_delta',
+ delta: { type: 'text_delta', text: delta.content },
+ };
+ }
+
+ // Tool calls
+ if (delta.tool_calls) {
+ for (const tc of delta.tool_calls) {
+ yield {
+ type: 'content_block_delta',
+ delta: {
+ type: 'tool_call_delta',
+ index: tc.index,
+ id: tc.id,
+ name: tc.function?.name,
+ partial_json: tc.function?.arguments || '',
+ },
+ };
+ }
+ }
+
+ // Finish reason
+ const finishReason = chunk.choices?.[0]?.finish_reason;
+ if (finishReason) {
+ yield {
+ type: 'message_delta',
+ delta: { stop_reason: finishReason === 'stop' ? 'end_turn' : finishReason },
+ };
+ }
+ }
+ }
+
+ // Flush remaining buffer
+ if (buffer.trim()) {
+ const trimmed = buffer.trim();
+ if (trimmed.startsWith('data:')) {
+ const raw = trimmed.slice(5).trim();
+ if (raw && raw !== '[DONE]') {
+ try {
+ const chunk = JSON.parse(raw);
+ const delta = chunk.choices?.[0]?.delta;
+ if (delta?.content) {
+ yield {
+ type: 'content_block_delta',
+ delta: { type: 'text_delta', text: delta.content },
+ };
+ }
+ } catch {
+ // ignore malformed final chunk
+ }
+ }
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+}
+
+/**
+ * Accumulate streamed OpenAI-style events into an internal message object.
+ */
+function accumulateOpenAIStream(events) {
+ const message = {
+ content: [],
+ stop_reason: null,
+ usage: { input_tokens: 0, output_tokens: 0 },
+ };
+
+ let textContent = '';
+ let thinkingContent = '';
+ const toolCallMap = {}; // index -> { id, name, arguments }
+
+ for (const event of events) {
+ if (event.type === 'content_block_delta') {
+ if (event.delta?.type === 'thinking_delta') {
+ thinkingContent += event.delta.thinking || '';
+ } else if (event.delta?.type === 'text_delta') {
+ textContent += event.delta.text || '';
+ } else if (event.delta?.type === 'tool_call_delta') {
+ const idx = event.delta.index ?? 0;
+ if (!toolCallMap[idx]) {
+ toolCallMap[idx] = { id: event.delta.id || `call_${idx}`, name: event.delta.name || '', arguments: '' };
+ }
+ if (event.delta.name) toolCallMap[idx].name = event.delta.name;
+ if (event.delta.id) toolCallMap[idx].id = event.delta.id;
+ toolCallMap[idx].arguments += event.delta.partial_json || '';
+ }
+ } else if (event.type === 'message_delta') {
+ if (event.delta?.stop_reason) message.stop_reason = event.delta.stop_reason;
+ }
+ }
+
+ if (thinkingContent) {
+ message.content.push({ type: 'thinking', thinking: thinkingContent });
+ }
+ if (textContent) {
+ message.content.push({ type: 'text', text: textContent });
+ }
+ for (const tc of Object.values(toolCallMap)) {
+ let input = {};
+ try { input = JSON.parse(tc.arguments || '{}'); } catch { input = {}; }
+ message.content.push({ type: 'tool_use', id: tc.id, name: tc.name, input });
+ }
+
+ if (!message.stop_reason) message.stop_reason = 'end_turn';
+ return message;
+}
+
function convertOpenAIResponse(data) {
const choice = data.choices?.[0];
if (!choice) throw new Error('No choices in OpenAI response');
diff --git a/v2/src/core/providers.mjs b/v2/src/core/providers.mjs
index 0af0aef..f3edbdd 100644
--- a/v2/src/core/providers.mjs
+++ b/v2/src/core/providers.mjs
@@ -1,7 +1,7 @@
/**
* Multi-Provider β unified provider config and request/response transforms.
*
- * Supports: Anthropic, OpenAI, Google, Bedrock (stub), Vertex (stub).
+ * Supports: Anthropic, OpenAI, Google, NVIDIA, Bedrock (stub), Vertex (stub).
* Each provider defines endpoint, auth headers, and optional transforms.
*/
@@ -140,6 +140,47 @@ const PROVIDERS = {
},
},
+ /**
+ * NVIDIA NIM β OpenAI-compatible endpoint hosted at integrate.api.nvidia.com.
+ *
+ * Supported models include:
+ * moonshotai/kimi-k2.5 β Kimi K2.5 with extended thinking
+ * nvidia/llama-3.1-nemotron-70b-instruct
+ * meta/llama-3.1-405b-instruct
+ * meta/llama-3.3-70b-instruct
+ * mistralai/mistral-large-2-instruct
+ * mistralai/mixtral-8x22b-instruct-v0.1
+ * google/gemma-3-27b-it
+ * deepseek-ai/deepseek-r1
+ *
+ * Auth: Bearer token via NVIDIA_API_KEY environment variable.
+ * Endpoint: https://integrate.api.nvidia.com/v1/chat/completions
+ * Streaming: OpenAI-style SSE (data: {...} lines ending with data: [DONE]).
+ */
+ nvidia: {
+ name: 'NVIDIA NIM',
+ endpoint: 'https://integrate.api.nvidia.com/v1/chat/completions',
+ envKey: 'NVIDIA_API_KEY',
+ authHeader(key) {
+ return {
+ 'Authorization': `Bearer ${key}`,
+ 'Content-Type': 'application/json',
+ };
+ },
+ models: [
+ 'moonshotai/kimi-k2.5',
+ 'nvidia/llama-3.1-nemotron-70b-instruct',
+ 'meta/llama-3.1-405b-instruct',
+ 'meta/llama-3.3-70b-instruct',
+ 'mistralai/mistral-large-2-instruct',
+ 'mistralai/mixtral-8x22b-instruct-v0.1',
+ 'google/gemma-3-27b-it',
+ 'deepseek-ai/deepseek-r1',
+ ],
+ /** Models that support extended thinking via chat_template_kwargs */
+ thinkingModels: ['moonshotai/kimi-k2.5', 'deepseek-ai/deepseek-r1'],
+ },
+
bedrock: {
name: 'AWS Bedrock',
endpoint: null, // Dynamic based on region
@@ -178,9 +219,25 @@ export function getProvider(model) {
if (model.startsWith('claude') || model.startsWith('anthropic')) return PROVIDERS.anthropic;
if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3')) return PROVIDERS.openai;
if (model.startsWith('gemini')) return PROVIDERS.google;
+ // NVIDIA-hosted models use a namespaced format: "publisher/model-name"
+ if (isNvidiaModel(model)) return PROVIDERS.nvidia;
return PROVIDERS.anthropic; // default
}
+/**
+ * Return true when the model ID belongs to the NVIDIA NIM catalogue.
+ * NVIDIA models use the format "publisher/model-name".
+ */
+export function isNvidiaModel(model) {
+ if (!model || !model.includes('/')) return false;
+ const [publisher] = model.split('/');
+ const nvidiaPublishers = new Set([
+ 'moonshotai', 'nvidia', 'meta', 'mistralai', 'google',
+ 'deepseek-ai', 'microsoft', 'qwen', 'baichuan-inc',
+ ]);
+ return nvidiaPublishers.has(publisher);
+}
+
/**
* Get a provider by name.
* @param {string} name
diff --git a/v2/src/core/system-prompt.mjs b/v2/src/core/system-prompt.mjs
index a748cac..e68087a 100644
--- a/v2/src/core/system-prompt.mjs
+++ b/v2/src/core/system-prompt.mjs
@@ -6,11 +6,192 @@
* - Merges in order (global -> project -> local)
* - Splits at cache boundary (static prefix cached, dynamic suffix not)
* - Includes tool schemas in the system prompt
+ * - Exports buildWorkspaceSnapshot for injecting a file-tree into prompts
+ * - Exports buildWorkspaceContent for injecting key file contents into prompts
+ * (used for thinking models that cannot make live tool calls)
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
+// Directories to skip when building the workspace snapshot.
+const SNAPSHOT_EXCLUDE = new Set([
+ 'node_modules', '.git', 'dist', 'build', 'out', '.next', '.nuxt',
+ '__pycache__', '.cache', 'coverage', '.nyc_output', '.turbo',
+]);
+
+// Dot-files/dirs are hidden by default; these are shown because they are
+// commonly checked into source control and useful for project analysis.
+const SNAPSHOT_INCLUDE_DOTFILES = new Set([
+ '.env.example', '.eslintrc', '.eslintrc.js', '.eslintrc.json', '.eslintrc.yml',
+ '.prettierrc', '.prettierrc.js', '.prettierrc.json', '.prettierrc.yml',
+ '.babelrc', '.babelrc.js', '.gitignore', '.dockerignore', '.editorconfig',
+]);
+
+/**
+ * Build a compact, indented directory tree for a workspace.
+ *
+ * The tree is capped at `maxFiles` entries so it never bloats the prompt.
+ * Directories that match SNAPSHOT_EXCLUDE are skipped entirely.
+ * Any I/O error returns an empty string gracefully.
+ *
+ * @param {string} [cwd] - root directory to scan (defaults to process.cwd())
+ * @param {number} [maxFiles=200] - maximum number of entries to include
+ * @returns {string} indented tree string, or '' on error / empty workspace
+ */
+export function buildWorkspaceSnapshot(cwd = process.cwd(), maxFiles = 200) {
+ const lines = [];
+ let count = 0;
+
+ function walk(dir, indent) {
+ if (count >= maxFiles) return;
+ let entries;
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ // Directories first, then files, both sorted alphabetically
+ entries.sort((a, b) => {
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ });
+ for (const entry of entries) {
+ if (count >= maxFiles) {
+ lines.push(indent + 'β¦ (truncated)');
+ return;
+ }
+ if (SNAPSHOT_EXCLUDE.has(entry.name)) continue;
+ if (entry.name.startsWith('.') && !SNAPSHOT_INCLUDE_DOTFILES.has(entry.name)) continue;
+ lines.push(indent + entry.name + (entry.isDirectory() ? '/' : ''));
+ count++;
+ if (entry.isDirectory()) {
+ walk(path.join(dir, entry.name), indent + ' ');
+ }
+ }
+ }
+
+ try {
+ walk(path.resolve(cwd), '');
+ } catch {
+ return '';
+ }
+
+ return lines.join('\n');
+}
+
+// Priority-ordered list of project meta/config files to read for thinking models.
+// These give the model the most structural insight per token spent.
+const CONTENT_PRIORITY_FILES = [
+ // Documentation
+ 'README.md', 'readme.md', 'README.txt',
+ // Package / dependency manifests
+ 'package.json', 'Cargo.toml', 'pyproject.toml', 'setup.py', 'setup.cfg',
+ 'go.mod', 'pom.xml', 'build.gradle', 'composer.json', 'Gemfile',
+ // Entry points
+ 'index.js', 'index.mjs', 'index.ts', 'main.js', 'main.mjs', 'main.ts',
+ 'main.py', '__main__.py', 'app.py', 'app.js', 'app.ts',
+ 'src/index.js', 'src/index.mjs', 'src/index.ts',
+ 'src/main.js', 'src/main.mjs', 'src/main.ts', 'src/main.py',
+ // Config
+ 'CLAUDE.md', '.claude/CLAUDE.md',
+ 'tsconfig.json', '.eslintrc.json', '.prettierrc.json',
+ 'Makefile', 'Dockerfile',
+];
+
+/**
+ * Build a rich workspace context string that includes:
+ * 1. The compact file-tree snapshot (always)
+ * 2. Contents of high-value project files (README, package.json, entry points, etc.)
+ *
+ * This is intended for thinking models (e.g. Kimi K2.5, DeepSeek R1) that cannot
+ * make live tool calls. By providing actual file contents up front, the model can
+ * give accurate, project-specific answers without needing tool access.
+ *
+ * @param {string} [cwd] - workspace root (defaults to process.cwd())
+ * @param {object} [opts]
+ * @param {number} [opts.maxFileBytes=8192] - max bytes to include per file
+ * @param {number} [opts.maxTotalBytes=65536] - hard cap on total injected content
+ * @returns {{ tree: string, files: Array<{path: string, content: string}>, summary: string }}
+ */
+export function buildWorkspaceContent(cwd = process.cwd(), opts = {}) {
+ const { maxFileBytes = 8192, maxTotalBytes = 65536 } = opts;
+ const root = path.resolve(cwd);
+
+ // 1. Build the file tree
+ const tree = buildWorkspaceSnapshot(root);
+
+ // 2. Collect priority file contents
+ const files = [];
+ let totalBytes = 0;
+
+ for (const rel of CONTENT_PRIORITY_FILES) {
+ if (totalBytes >= maxTotalBytes) break;
+ const abs = path.join(root, rel);
+ if (!fs.existsSync(abs)) continue;
+ try {
+ const stat = fs.statSync(abs);
+ if (!stat.isFile()) continue;
+ let content = fs.readFileSync(abs, 'utf-8');
+ const originalLength = content.length;
+ if (originalLength > maxFileBytes) {
+ content = content.slice(0, maxFileBytes) + `\n... (truncated β ${originalLength - maxFileBytes} more bytes)`;
+ }
+ const contentLength = content.length;
+ if (totalBytes + contentLength > maxTotalBytes) break;
+ files.push({ path: rel, content });
+ totalBytes += contentLength;
+ } catch { /* skip unreadable */ }
+ }
+
+ // 3. Build the formatted summary string
+ const parts = [];
+
+ if (tree) {
+ parts.push('## Workspace file structure\n\n```\n' + tree + '\n```');
+ }
+
+ for (const { path: filePath, content } of files) {
+ parts.push(`## File: ${filePath}\n\n\`\`\`\n${content}\n\`\`\``);
+ }
+
+ return {
+ tree,
+ files,
+ summary: parts.join('\n\n'),
+ };
+}
+
+/**
+ * Build the system prompt text for thinking models (Kimi K2.5, DeepSeek R1).
+ *
+ * Unlike the standard system prompt (which instructs the model to call tools),
+ * this version acknowledges that no tools are available and instead points the
+ * model to the pre-injected workspace content below the prompt.
+ *
+ * @param {string} staticBase - the static prefix of the normal system prompt
+ * @param {string} workspaceSummary - output of buildWorkspaceContent().summary
+ * @returns {string}
+ */
+export function buildThinkingModelSystemPrompt(staticBase, workspaceSummary) {
+ const header = [
+ `You are an AI coding assistant with access to a snapshot of the user's workspace.`,
+ ``,
+ `IMPORTANT: You are operating in thinking mode. Live tool calls (Read, Write, Bash, Grep, etc.)`,
+ `are NOT available in this session. Instead, a snapshot of the key project files and the`,
+ `complete workspace file tree has been embedded below. Use this snapshot to answer questions`,
+ `accurately and in full β never say you cannot see the project or ask the user to paste code.`,
+ ``,
+ `When the snapshot does not contain a file the user mentions, say so clearly and offer to`,
+ `reason from the available context.`,
+ ].join('\n');
+
+ const parts = [header];
+ if (staticBase) parts.push(staticBase);
+ if (workspaceSummary) parts.push('---\n\n# Workspace snapshot (read-only)\n\n' + workspaceSummary);
+ return parts.join('\n\n');
+}
+
/**
* Load all CLAUDE.md files and merge them in order.
* @param {string} [cwd] - current working directory
@@ -67,7 +248,24 @@ export function buildSystemPrompt({ cwd, tools, override, addDirs } = {}) {
return { staticPrefix: override, dynamicSuffix: '', full: override };
}
- const parts = ['You are an AI coding assistant.'];
+ const workspaceRoot = path.resolve(cwd || process.cwd());
+ const basePreamble = [
+ `You are an AI coding assistant with direct access to the user's workspace on disk.`,
+ `Current working directory: ${workspaceRoot}`,
+ ``,
+ `## Workspace exploration rules`,
+ ``,
+ `- When the user asks about their project, code, or files, ALWAYS use your tools to`,
+ ` explore the workspace first β do NOT ask the user to paste or share anything.`,
+ `- Use LS / Glob to discover files, Read to inspect their contents, Grep to search`,
+ ` for patterns, and Bash to run commands (tests, builds, linters, git log, etc.).`,
+ `- Start broad (list the root directory) then drill into relevant subdirectories.`,
+ `- Prefer reading the actual source over guessing from file names alone.`,
+ `- Never say "I don't see any files" or ask the user to share code β you can read`,
+ ` the workspace directly with your tools.`,
+ ].join('\n');
+
+ const parts = [basePreamble];
// Load CLAUDE.md files
const mdFiles = loadClaudeMdFiles(cwd);
diff --git a/v2/src/permissions/checker.mjs b/v2/src/permissions/checker.mjs
index c34d67b..4aa1b2d 100644
--- a/v2/src/permissions/checker.mjs
+++ b/v2/src/permissions/checker.mjs
@@ -42,7 +42,7 @@ export function createPermissionChecker(config = {}) {
return true;
case 'auto': return true; // AI decides
case 'dontAsk': return false; // deny everything not pre-approved
- case 'plan': return toolName === 'Read' || toolName === 'Glob' || toolName === 'Grep';
+ case 'plan': return ['Read', 'Glob', 'Grep', 'LS', 'WebFetch', 'WebSearch', 'ToolSearch'].includes(toolName);
case 'default':
default:
// In default mode, safe tools pass through
diff --git a/v2/test/test.mjs b/v2/test/test.mjs
index ca1ff08..f4ce346 100644
--- a/v2/test/test.mjs
+++ b/v2/test/test.mjs
@@ -1148,10 +1148,13 @@ assert(foundTruncated, 'Micro-compaction truncates old tool results');
section('Phase 2: System Prompt');
-import { buildSystemPrompt, loadClaudeMdFiles, toCacheBlocks } from '../src/core/system-prompt.mjs';
+import { buildSystemPrompt, loadClaudeMdFiles, toCacheBlocks, buildWorkspaceSnapshot } from '../src/core/system-prompt.mjs';
const prompt = buildSystemPrompt({ cwd: '/tmp' });
assertIncludes(prompt.full, 'AI coding assistant', 'System prompt has base text');
+assertIncludes(prompt.full, 'Current working directory:', 'System prompt declares cwd');
+assertIncludes(prompt.full, 'Workspace exploration rules', 'System prompt has exploration rules');
+assertIncludes(prompt.full, 'do NOT ask the user to paste', 'System prompt forbids asking user to paste code');
assertType(prompt.staticPrefix, 'string', 'Has static prefix');
assertType(prompt.dynamicSuffix, 'string', 'Has dynamic suffix');
@@ -1169,6 +1172,20 @@ assertEqual(blocks.length, 2, 'Two cache blocks');
assertEqual(blocks[0].cache_control.type, 'ephemeral', 'Static block cached');
assert(blocks[1].cache_control === undefined, 'Dynamic block not cached');
+// buildWorkspaceSnapshot
+assertType(buildWorkspaceSnapshot, 'function', 'buildWorkspaceSnapshot is exported');
+const snapshot = buildWorkspaceSnapshot('/tmp');
+assertType(snapshot, 'string', 'Snapshot returns a string');
+// Non-existent path returns empty string gracefully
+const snapshotBad = buildWorkspaceSnapshot('/nonexistent_path_xyz_12345');
+assertEqual(snapshotBad, '', 'Non-existent path returns empty string');
+// Snapshot respects maxFiles cap: with maxFiles=1, we expect at most 1 entry
+// plus a possible "β¦ (truncated)" line = 2 non-blank lines maximum. Allow
+// up to 3 to accommodate edge cases where /tmp itself is almost empty.
+const snapshotCapped = buildWorkspaceSnapshot('/tmp', 1);
+const snapshotLines = snapshotCapped.split('\n').filter(l => l.trim());
+assert(snapshotLines.length <= 3, 'Snapshot respects maxFiles cap (at most 1 entry + truncation line)');
+
// ---------- Phase 3: CLI, UI, Commands Tests ----------
section('Phase 3: CLI Args (full flags)');
diff --git a/vscode-extension/.vscodeignore b/vscode-extension/.vscodeignore
new file mode 100644
index 0000000..c45405b
--- /dev/null
+++ b/vscode-extension/.vscodeignore
@@ -0,0 +1,4 @@
+.vscode/**
+node_modules/**
+.gitignore
+*.vsix
diff --git a/vscode-extension/LICENSE b/vscode-extension/LICENSE
new file mode 100644
index 0000000..2dd524a
--- /dev/null
+++ b/vscode-extension/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 rUv
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/vscode-extension/README.md b/vscode-extension/README.md
new file mode 100644
index 0000000..59d6d03
--- /dev/null
+++ b/vscode-extension/README.md
@@ -0,0 +1,271 @@
+# Open Claude Code β VSCode Extension
+
+A **Cursor-style AI coding assistant** built directly into VSCode β no terminal required.
+
+---
+
+## Features
+
+### ποΈ Proactive workspace analysis (new in v1.2)
+- **Automatic workspace exploration** β before answering questions the agent scans your project with LS, Glob, Read, and Grep instead of asking you to paste code
+- **Rich workspace injection for thinking models** β Kimi K2.5 and DeepSeek R1 receive a full workspace snapshot (file tree + key file contents: README, package.json, entry points, etc.) directly in their system prompt, so they have genuine project understanding even though NVIDIA NIM prevents live tool calls during thinking mode
+- **Never "I can't see your files"** β the system prompt explicitly forbids asking you to share code; the agent reads files directly or uses the pre-injected snapshot
+
+### π₯οΈ Cursor-style Sidebar Panel (new in v1.1)
+- **Dedicated activity bar icon** β opens a full chat panel in the VS Code sidebar
+- **β Settings button** β gear icon in the header opens the `openClaudeCode` settings section directly β no navigating through the Extensions marketplace
+- **Chat history** β the **History** header button opens a Cursor-style panel listing all past sessions; sessions auto-save when you click **New** and persist across VS Code restarts (up to 30 sessions stored in `globalState`)
+- **Copy whole answer** β β Copy button on every assistant reply copies the full response to the clipboard (visible on hover)
+- **Rich markdown rendering** β headers, tables, bold/italic, blockquotes
+- **Syntax-highlighted code blocks** β JavaScript, TypeScript, Python, Go, Rust, JSON, Bash and more
+- **Copy button on every code block** β one click to copy to clipboard
+- **Apply to file** β apply AI-suggested code directly to the active editor or pick a file
+- **Streaming responses** β see tokens arrive in real-time with animated cursor
+- **Tool visualization** β collapsible cards showing each tool execution and result
+- **Extended thinking** β expandable thinking blocks when the model reasons
+- **@file context** β type `@filename` in the input to inject file contents into the prompt
+- **File picker** β add any workspace file to context with the π button
+- **Model & mode selector** β switch model and permission mode directly from the UI
+- **Session stats** β token count, cost estimate, and elapsed time always visible
+- **Stop button** β cancel generation at any time
+- **New conversation** β clear history with one click (auto-saves the current session)
+
+### π¬ `@claude` Chat Participant (VSCode built-in chat)
+- Ask questions, request code changes, and run agentic tools without leaving the editor
+- **Full tool access** β the same 25+ tools as the CLI (Read, Write, Edit, Bash, Glob, Grep, WebFetch, β¦)
+- **Multi-provider** β Anthropic Claude, OpenAI GPT, Google Gemini, NVIDIA NIM
+- **Conversation memory** β history is maintained across turns in the same VS Code session
+- **Slash commands** β `/clear` to reset, `/model` to switch models mid-session
+- **Configurable permission mode** β control how aggressively the agent modifies your files
+
+---
+
+## Requirements
+
+- **VSCode 1.90+** (Chat API required)
+- **Node.js 18+** on your PATH
+- An **API key** for at least one supported provider (Anthropic, OpenAI, Google, or NVIDIA)
+
+---
+
+## Quick Start (already have VSCode installed)
+
+1. **Install the extension** β load from VSIX or press F5 in the repo (see [Installation](#installation) below).
+2. **Open the chat panel** β click the **β¦** icon in the Activity Bar (left sidebar).
+3. **Follow the setup guide** β the welcome screen walks you through getting and entering an API key.
+
+ Or run **Open Claude Code: Set API Key** from the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`).
+
+4. **Start chatting** β type a message and press **Enter**.
+
+> The first time you send a message the extension starts a Node.js agent subprocess in your workspace directory. This may take a second or two.
+
+---
+
+## Installation
+
+### Option A β from VSIX (recommended for local use)
+
+1. Install [`vsce`](https://github.com/microsoft/vscode-vsce) if you haven't already:
+ ```bash
+ npm install -g @vscode/vsce
+ ```
+2. From the `vscode-extension/` directory, build the VSIX:
+ ```bash
+ cd vscode-extension
+ npm install
+ npm run package
+ ```
+ The `prepackage` step automatically copies the `v2/src` engine into the
+ extension bundle so all functionality is available after installation.
+3. Install it in VSCode:
+ ```bash
+ code --install-extension open-claude-code-1.2.0.vsix
+ ```
+ Or use **Extensions β β¦ β Install from VSIXβ¦** in the VSCode UI.
+
+### Option B β load as an unpacked extension (development)
+
+1. Open the repo in VSCode.
+2. Press **F5** to launch a new Extension Development Host window.
+3. In the new window, open any project folder and use `@claude` in the Chat panel.
+
+---
+
+## Setup
+
+### API key β which provider?
+
+| Provider | Where to get a key | Environment variable |
+|----------|--------------------|----------------------|
+| **Anthropic** (recommended) | [console.anthropic.com/settings/keys](https://console.anthropic.com/settings/keys) | `ANTHROPIC_API_KEY` |
+| **OpenAI** | [platform.openai.com/api-keys](https://platform.openai.com/api-keys) | `OPENAI_API_KEY` |
+| **Google** | [aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey) | `GOOGLE_API_KEY` or `GEMINI_API_KEY` |
+| **NVIDIA NIM** | [integrate.api.nvidia.com](https://integrate.api.nvidia.com) | `NVIDIA_API_KEY` |
+
+### Option 1 β Command Palette (recommended)
+
+Run **Open Claude Code: Set API Key** (`Ctrl+Shift+P` / `Cmd+Shift+P`).
+
+The key is stored securely in VSCode's [SecretStorage](https://code.visualstudio.com/api/references/vscode-api#SecretStorage) β it is never written to disk in plaintext.
+
+### Option 2 β VS Code Settings (NVIDIA key only)
+
+Open Settings (`Ctrl+,`), search for `openClaudeCode.nvidiaApiKey`, and paste your `nvapi-...` key.
+
+### Option 3 β Environment variable
+
+Set the variable before launching VSCode:
+```bash
+export ANTHROPIC_API_KEY=sk-ant-...
+code .
+```
+
+---
+
+## Usage
+
+### Sidebar chat panel (recommended)
+
+Click the **β¦ Claude Code** icon in the activity bar (left sidebar) to open the chat panel, then:
+- Type your message and press **Enter** to send (Shift+Enter for a new line)
+- Use `@filename` to inject file contents into the prompt
+- Click **π** to pick a file from the workspace
+- Click **History** to browse and reopen past conversations (sessions auto-save when you click **New**)
+- Click **New** to start a fresh conversation (saves the current session first)
+- Click **β** (gear) to open the extension settings directly from the chat panel
+- Use the **Model** and **Mode** dropdowns to configure the agent
+
+When Claude suggests code, every code block has:
+- **Copy** β copy the code to clipboard
+- **Apply to fileβ¦** β apply the code to the active editor (or pick a file)
+
+Every assistant message also has a **β Copy** button (visible on hover) that copies the full response text.
+
+### @claude chat participant (VSCode built-in chat)
+
+Open the **Chat** panel (`Ctrl+Alt+I` / `Cmd+Alt+I`) and type:
+
+```
+@claude explain this codebase
+@claude fix the bug in src/server.js
+@claude write unit tests for utils.ts
+@claude what does the `loadSettings` function do?
+```
+
+### Slash commands
+
+| Command | Description |
+|---------|-------------|
+| `@claude /clear` | Reset conversation history |
+| `@claude /model claude-opus-4-6` | Switch model for this session |
+
+### Command Palette commands
+
+| Command | Description |
+|---------|-------------|
+| **Open Claude Code: Set API Key** | Store your API key securely |
+| **Open Claude Code: Clear Session** | Reset conversation history |
+| **Open Claude Code: Show Status** | Show bridge status, model, and key info |
+| **Open Claude Code: Open Chat Panel** | Focus the sidebar chat panel |
+| **Open Claude Code: Apply Code to Active File** | Paste code into the active editor |
+
+---
+
+## Configuration
+
+Open **Settings** (`Ctrl+,`) and search for `openClaudeCode`:
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| `openClaudeCode.model` | `claude-sonnet-4-6` | AI model to use |
+| `openClaudeCode.nvidiaApiKey` | _(empty)_ | NVIDIA NIM API key (`nvapi-...`) |
+| `openClaudeCode.nvidiaThinkingMode` | `false` | Enable extended reasoning mode for Kimi K2.5 / DeepSeek R1 (disables live tools) |
+| `openClaudeCode.permissionMode` | `default` | How the agent handles file/shell permissions |
+| `openClaudeCode.maxTurns` | `20` | Maximum agentic tool-use turns per request |
+| `openClaudeCode.showToolOutput` | `true` | Show tool progress and results in chat |
+| `openClaudeCode.enableWebviewPanel` | `true` | Show the Cursor-style sidebar chat panel |
+
+### Permission modes
+
+| Mode | Description |
+|------|-------------|
+| `default` | Ask before each tool use (safest) |
+| `auto` | Automatically approve safe operations |
+| `plan` | Read-only β no file writes or shell commands |
+| `acceptEdits` | Approve file edits without prompting |
+| `bypassPermissions` | Skip all permission checks |
+
+---
+
+## How it works
+
+The extension spawns **`agent-bridge.mjs`** as a long-lived Node.js subprocess in your workspace directory. The bridge imports the Open Claude Code agent loop from `../v2/src/` and speaks a simple newline-delimited JSON protocol over stdin/stdout.
+
+```
+ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+β VS Code Extension Host β
+β β
+β βββββββββββββββββββββββββββ ββββββββββββββββββββββ β
+β β ClaudeCodeViewProvider β β ChatParticipant β β
+β β (Cursor-style sidebar) β β (@claude) β β
+β ββββββββββββ¬βββββββββββββββ βββββββββββ¬βββββββββββ β
+β β postMessage / onMessage β β
+β ββββββββββββββ¬βββββββββββββββ β
+β β child_process.spawn β
+ββββββββββββββββββββββββββββΌββββββββββββββββββββββββββ-β
+ βΌ
+ agent-bridge.mjs (ESM Node.js subprocess)
+ β createAgentLoop + 25 tools
+ βΌ
+ Anthropic / OpenAI / Google API
+```
+
+The subprocess persists across chat turns so the agent's conversation history is maintained. Clicking **New** (or running the **Clear Session** command) resets the history.
+
+---
+
+### NVIDIA models β Kimi K2.5 and DeepSeek R1
+
+These models are supported in two modes. **Tool-calling mode is the default** and works exactly like Cursor or opencode β the model reads files, runs Bash, greps for patterns, and edits code like any other agent model.
+
+#### Default: full tool-calling mode
+
+Just select **moonshotai/kimi-k2.5** or **deepseek-ai/deepseek-r1**, enter your `NVIDIA_API_KEY` in Settings, and start chatting. The model has access to all tools: Read, Write, Edit, Bash, Glob, Grep, and more.
+
+#### Optional: extended thinking (reasoning) mode
+
+If you want the model to show its step-by-step reasoning, enable the **nvidiaThinkingMode** setting:
+
+1. Open Settings (`Ctrl+,`), search for `openClaudeCode.nvidiaThinkingMode`, and set it to **true**.
+2. Run **Open Claude Code: Clear Session** so the bridge restarts with the new setting.
+
+In thinking mode the NVIDIA NIM API does not accept live tool calls alongside the thinking flag, so tools are replaced with a rich workspace snapshot injected into the system prompt:
+- **File tree** β the full indented directory structure of your project
+- **Key file contents** β README, package.json/Cargo.toml/pyproject.toml, main entry points, and other high-value project files (up to ~64 KB total)
+
+| Mode | Tools | Thinking trace | Best for |
+|------|-------|---------------|---------|
+| Tool-calling (default) | β
Full access | β | Multi-step coding tasks, file edits, grep, bash |
+| Thinking (`nvidiaThinkingMode: true`) | β | β
| Deep analysis, architecture review, explanations |
+
+---
+
+## Troubleshooting
+
+**"Failed to start agent"**
+- Make sure you have set your API key (see Setup above).
+- Check the **Output** panel β **Open Claude Code** channel for subprocess stderr logs.
+
+**The agent hangs or doesn't respond**
+- Run **Open Claude Code: Clear Session** to restart the bridge.
+- Check that Node.js 18+ is on your PATH: `node --version`.
+
+**Tool calls fail with permission errors**
+- Change `openClaudeCode.permissionMode` to `auto` or `acceptEdits` in settings.
+
+---
+
+## License
+
+MIT
diff --git a/vscode-extension/agent-bridge.mjs b/vscode-extension/agent-bridge.mjs
new file mode 100644
index 0000000..260708e
--- /dev/null
+++ b/vscode-extension/agent-bridge.mjs
@@ -0,0 +1,213 @@
+#!/usr/bin/env node
+/**
+ * agent-bridge.mjs
+ *
+ * Long-lived subprocess that runs the Open Claude Code agent loop and
+ * communicates with the VSCode extension over stdin/stdout using
+ * newline-delimited JSON (ndjson).
+ *
+ * Protocol (stdin β bridge):
+ * {"type":"run", "message":""}
+ * {"type":"reset"}
+ * {"type":"model", "model":""}
+ *
+ * Protocol (bridge β stdout):
+ * {"type":"stream_event", "text":"..."}
+ * {"type":"assistant", "content":"..."}
+ * {"type":"tool_progress", "tool":"Bash","status":"running"}
+ * {"type":"result", "tool":"Bash","result":"..."}
+ * {"type":"thinking", "text":"..."}
+ * {"type":"compaction", "count":1}
+ * {"type":"hookPermissionResult", "tool":"...","allowed":false}
+ * {"type":"stop", "reason":"end_turn"}
+ * {"type":"error", "message":"..."}
+ * {"type":"ready"}
+ *
+ * Environment variables consumed:
+ * ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, GEMINI_API_KEY,
+ * NVIDIA_API_KEY
+ * ANTHROPIC_MODEL β initial model override
+ * CLAUDE_CODE_PERMISSION_MODE
+ * CLAUDE_CODE_MAX_TURNS
+ *
+ * v2/src resolution:
+ * When installed from a VSIX the v2 source is bundled inside the extension
+ * directory as ./v2/src (copied by the prepackage script).
+ * When running from source (development / F5) ../v2/src is used instead.
+ */
+
+import { fileURLToPath, pathToFileURL } from 'url';
+import path from 'path';
+import { existsSync } from 'fs';
+import readline from 'readline';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+// ---------------------------------------------------------------------------
+// Locate v2/src at runtime so the bridge works in both packaged and dev modes.
+// ---------------------------------------------------------------------------
+function findV2Src() {
+ const candidates = [
+ path.join(__dirname, 'v2', 'src'), // installed from VSIX (bundled)
+ path.join(__dirname, '..', 'v2', 'src'), // development β sibling directory
+ ];
+ for (const candidate of candidates) {
+ if (existsSync(path.join(candidate, 'core', 'agent-loop.mjs'))) {
+ return candidate;
+ }
+ }
+ throw new Error(
+ 'Cannot locate v2/src. Checked:\n' +
+ candidates.map(c => ' ' + c).join('\n') + '\n' +
+ 'Run `npm run package` from the vscode-extension/ directory to bundle the source.'
+ );
+}
+
+const V2_SRC = findV2Src();
+const v2url = (rel) => pathToFileURL(path.join(V2_SRC, rel)).href;
+
+// Dynamic imports β resolved against the path found above.
+const { createAgentLoop } = await import(v2url('core/agent-loop.mjs'));
+const { createToolRegistry } = await import(v2url('tools/registry.mjs'));
+const { createPermissionChecker } = await import(v2url('permissions/checker.mjs'));
+const { loadSettings } = await import(v2url('config/settings.mjs'));
+const { HookEngine } = await import(v2url('hooks/engine.mjs'));
+const { AgentLoader } = await import(v2url('agents/loader.mjs'));
+const { SkillsLoader } = await import(v2url('skills/loader.mjs'));
+
+// Redirect console.error/warn to stderr so we don't pollute the ndjson stream.
+// (It already goes to stderr by default, but belt-and-suspenders.)
+const originalStderr = process.stderr.write.bind(process.stderr);
+
+function emit(obj) {
+ process.stdout.write(JSON.stringify(obj) + '\n');
+}
+
+async function init() {
+ let settings;
+ try {
+ settings = await loadSettings();
+ } catch (err) {
+ emit({ type: 'error', message: `Failed to load settings: ${err.message}` });
+ process.exit(1);
+ }
+
+ const tools = createToolRegistry();
+ const permissions = createPermissionChecker(settings.permissions);
+ const hooks = new HookEngine(settings.hooks || {});
+
+ // Load agents and skills
+ const agentLoader = new AgentLoader();
+ agentLoader.load();
+ const skillsLoader = new SkillsLoader();
+ skillsLoader.load();
+
+ const skillTool = tools.get('Skill');
+ if (skillTool) skillTool._skillsLoader = skillsLoader;
+
+ const loop = createAgentLoop({
+ model: settings.model || 'claude-sonnet-4-6',
+ tools,
+ permissions,
+ settings,
+ hooks,
+ });
+
+ loop.state._agentLoader = agentLoader;
+ loop.state._skillsLoader = skillsLoader;
+ loop.state._hooks = settings.hooks;
+ loop.state._permissionMode = settings.permissions?.defaultMode || 'default';
+
+ emit({ type: 'ready' });
+
+ // ββ Message loop ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
+
+ // Serialize requests so they never interleave.
+ let queue = Promise.resolve();
+
+ rl.on('line', (line) => {
+ const trimmed = line.trim();
+ if (!trimmed) return;
+
+ let msg;
+ try {
+ msg = JSON.parse(trimmed);
+ } catch {
+ emit({ type: 'error', message: `Bad JSON from extension: ${trimmed}` });
+ return;
+ }
+
+ if (msg.type === 'reset') {
+ queue = queue.then(() => handleReset(loop));
+ } else if (msg.type === 'run') {
+ queue = queue.then(() => handleRun(loop, msg.message));
+ } else if (msg.type === 'model') {
+ queue = queue.then(() => handleModelSwitch(loop, msg.model));
+ } else if (msg.type === 'resume') {
+ queue = queue.then(() => handleResume(loop, msg.messages || []));
+ }
+ });
+
+ rl.on('close', () => process.exit(0));
+}
+
+async function handleRun(loop, message) {
+ if (!message || typeof message !== 'string') {
+ emit({ type: 'error', message: 'run message must have a non-empty "message" string' });
+ emit({ type: 'stop', reason: 'error' });
+ return;
+ }
+ try {
+ for await (const event of loop.run(message)) {
+ emit(event);
+ }
+ } catch (err) {
+ emit({ type: 'error', message: err.message });
+ emit({ type: 'stop', reason: 'error' });
+ }
+}
+
+async function handleReset(loop) {
+ loop.state.messages = [];
+ loop.state.turnCount = 0;
+ loop.state.tokenUsage = { input: 0, output: 0 };
+ emit({ type: 'ready' });
+}
+
+async function handleModelSwitch(loop, model) {
+ if (model && typeof model === 'string') {
+ loop.state.model = model;
+ }
+ emit({ type: 'ready' });
+}
+
+/**
+ * Restore conversation history into the agent loop so the model remembers
+ * the full session from the beginning (like Claude Premium session memory).
+ *
+ * UI messages (type:'user'/'assistant', text:'...') are converted to the
+ * API message format used by the agent loop.
+ */
+async function handleResume(loop, messages) {
+ loop.state.messages = messages
+ .filter(m => (m.type === 'user' || m.type === 'assistant') && m.text)
+ .map(m => {
+ if (m.type === 'user') {
+ return { role: 'user', content: m.text };
+ }
+ // Assistant messages use content-block array format for API compatibility
+ return { role: 'assistant', content: [{ type: 'text', text: m.text }] };
+ });
+ loop.state.turnCount = messages.filter(m => m.type === 'user').length;
+ // Token usage is reset to zero because the stored messages contain only plain
+ // text (not the original API token counts). The stats bar will show usage for
+ // new turns going forward; this is correct and expected behaviour on resume.
+ loop.state.tokenUsage = { input: 0, output: 0 };
+ emit({ type: 'ready' });
+}
+
+init().catch((err) => {
+ emit({ type: 'error', message: err.message });
+ process.exit(1);
+});
diff --git a/vscode-extension/extension.js b/vscode-extension/extension.js
new file mode 100644
index 0000000..6f176f4
--- /dev/null
+++ b/vscode-extension/extension.js
@@ -0,0 +1,1167 @@
+'use strict';
+/**
+ * extension.js β Open Claude Code VSCode Extension
+ *
+ * Provides two interfaces:
+ *
+ * 1. Custom Webview Panel (Cursor-style sidebar)
+ * - Activity bar icon β dedicated chat panel
+ * - Rich HTML/CSS/JS UI with markdown, syntax highlighting, code apply, @file mentions
+ * - Streaming token display, tool visualization, model/mode switching
+ *
+ * 2. Chat Participant (@claude) β kept for backwards compatibility
+ * - Forwards messages to the shared agent-bridge subprocess
+ *
+ * Commands:
+ * Open Claude Code: Set API Key
+ * Open Claude Code: Clear Session
+ * Open Claude Code: Show Status
+ * Open Claude Code: Open Chat Panel
+ * Open Claude Code: Apply Code to Active File
+ */
+
+const vscode = require('vscode');
+const { spawn, execFile } = require('child_process');
+const path = require('path');
+const fs = require('fs');
+
+const PARTICIPANT_ID = 'open-claude-code.claude';
+const BRIDGE_SCRIPT = path.join(__dirname, 'agent-bridge.mjs');
+// Maximum number of user/assistant messages kept in a persisted session.
+// Applies to both the in-progress activeSession and updated history entries.
+const MAX_SESSION_MESSAGES = 200;
+
+// ββ Rate-limit retry βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+/** Backoff delays (ms) for successive retry attempts */
+const RETRY_DELAYS_MS = [3000, 8000, 20000];
+
+/**
+ * Returns true when the error message looks like a transient rate-limit /
+ * server-overload error that is worth retrying automatically.
+ * @param {string} msg
+ */
+function isRateLimitError(msg) {
+ return /rate.?limit|overload|too.?many.?request|capacity|529|503|quota/i.test(msg || '');
+}
+
+// ββ AgentBridge βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+/**
+ * Manages a single long-lived agent-bridge.mjs child process.
+ * Serializes requests so concurrent messages don't interleave.
+ */
+class AgentBridge {
+ constructor(cwd, env) {
+ this._cwd = cwd;
+ this._env = env;
+ this._proc = null;
+ this._lineBuffer = '';
+ this._currentHandler = null;
+ this._queue = Promise.resolve();
+ this._started = false;
+ }
+
+ start() {
+ if (this._started) return;
+ this._started = true;
+
+ this._proc = spawn(process.execPath, [BRIDGE_SCRIPT], {
+ cwd: this._cwd,
+ env: { ...process.env, ...this._env },
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+
+ this._proc.stdout.setEncoding('utf8');
+ this._proc.stdout.on('data', (chunk) => {
+ this._lineBuffer += chunk;
+ const lines = this._lineBuffer.split('\n');
+ this._lineBuffer = lines.pop();
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (trimmed) this._dispatch(trimmed);
+ }
+ });
+
+ this._proc.stderr.setEncoding('utf8');
+ this._proc.stderr.on('data', (data) => {
+ console.error('[open-claude-code bridge]', data.trim());
+ });
+
+ this._proc.on('exit', (code, signal) => {
+ this._started = false;
+ const handler = this._currentHandler;
+ this._currentHandler = null;
+ if (handler) {
+ const hint = 'Click Retry to continue β your session memory will be restored.';
+ const reason = signal
+ ? `signal=${signal}`
+ : `exit code ${code}`;
+ handler({
+ type: 'error',
+ message: `Agent process stopped unexpectedly (${reason}). ${hint}`,
+ });
+ }
+ });
+ }
+
+ _dispatch(line) {
+ let event;
+ try { event = JSON.parse(line); } catch {
+ console.error('[open-claude-code bridge] bad JSON:', line);
+ return;
+ }
+ if (this._currentHandler) this._currentHandler(event);
+ }
+
+ run(message, onEvent) {
+ // Use the two-arg form of .then() so a rejected queue resolves before
+ // attempting the new run (prevents a stuck queue after bridge errors).
+ this._queue = this._queue.then(
+ () => this._doRun(message, onEvent),
+ () => this._doRun(message, onEvent),
+ );
+ return this._queue;
+ }
+
+ _doRun(message, onEvent) {
+ return new Promise((resolve) => {
+ this._currentHandler = (event) => {
+ onEvent(event);
+ if (event.type === 'stop' || event.type === 'error') {
+ this._currentHandler = null;
+ resolve();
+ }
+ };
+ if (!this._send({ type: 'run', message })) {
+ this._currentHandler = null;
+ onEvent({ type: 'error', message: 'Agent bridge is not running.' });
+ onEvent({ type: 'stop', reason: 'error' });
+ resolve();
+ }
+ });
+ }
+
+ reset() {
+ this._queue = this._queue.then(
+ () => new Promise((resolve) => {
+ this._currentHandler = (event) => {
+ if (event.type === 'ready' || event.type === 'error') {
+ this._currentHandler = null;
+ resolve();
+ }
+ };
+ if (!this._send({ type: 'reset' })) {
+ this._currentHandler = null;
+ resolve();
+ }
+ })
+ );
+ return this._queue;
+ }
+
+ switchModel(model) {
+ this._queue = this._queue.then(
+ () => new Promise((resolve) => {
+ this._currentHandler = (event) => {
+ if (event.type === 'ready' || event.type === 'error') {
+ this._currentHandler = null;
+ resolve();
+ }
+ };
+ if (!this._send({ type: 'model', model })) {
+ this._currentHandler = null;
+ resolve();
+ }
+ })
+ );
+ return this._queue;
+ }
+
+ /**
+ * Restore conversation history into the agent loop so the model remembers
+ * the full session from the beginning (Claude Premium-style session memory).
+ */
+ resume(messages) {
+ this._queue = this._queue.then(
+ () => new Promise((resolve) => {
+ this._currentHandler = (event) => {
+ if (event.type === 'ready' || event.type === 'error') {
+ this._currentHandler = null;
+ resolve();
+ }
+ };
+ if (!this._send({ type: 'resume', messages })) {
+ // Bridge not running β skip gracefully; a new bridge will be
+ // created with auto-injected history by getBridge().
+ this._currentHandler = null;
+ resolve();
+ }
+ })
+ );
+ return this._queue;
+ }
+
+ /**
+ * Write a JSON message to the bridge's stdin.
+ * Returns true on success, false if the bridge is not running or write fails.
+ * Never throws so callers can use a simple boolean check.
+ */
+ _send(obj) {
+ if (!this._proc || !this._started) return false;
+ try {
+ this._proc.stdin.write(JSON.stringify(obj) + '\n');
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ get isRunning() { return this._started && !!this._proc; }
+
+ dispose() {
+ if (this._proc) {
+ this._proc.stdin.end();
+ this._proc.kill();
+ this._proc = null;
+ }
+ this._started = false;
+ }
+}
+
+// ββ Extension state ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+/** @type {AgentBridge | null} */
+let bridge = null;
+
+/** @type {vscode.ExtensionContext | null} */
+let extensionContext = null;
+
+/** @type {ClaudeCodeViewProvider | null} */
+let viewProvider = null;
+
+async function getBridge() {
+ if (bridge && bridge.isRunning) return bridge;
+
+ const config = vscode.workspace.getConfiguration('openClaudeCode');
+ const model = config.get('model') || 'claude-sonnet-4-6';
+ const permissionMode = config.get('permissionMode') || 'default';
+
+ const anthropicKey =
+ (await extensionContext.secrets.get('openClaudeCode.apiKey')) ||
+ process.env.ANTHROPIC_API_KEY || '';
+ const openaiKey = process.env.OPENAI_API_KEY || '';
+ const googleKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY || '';
+ const nvidiaKey = config.get('nvidiaApiKey') || process.env.NVIDIA_API_KEY || '';
+
+ const env = {};
+ if (anthropicKey) env.ANTHROPIC_API_KEY = anthropicKey;
+ if (openaiKey) env.OPENAI_API_KEY = openaiKey;
+ if (googleKey) env.GOOGLE_API_KEY = googleKey;
+ if (nvidiaKey) env.NVIDIA_API_KEY = nvidiaKey;
+ env.ANTHROPIC_MODEL = model;
+ env.CLAUDE_CODE_PERMISSION_MODE = permissionMode;
+ env.CLAUDE_CODE_MAX_TURNS = String(config.get('maxTurns') || 20);
+ env.NVIDIA_THINKING_MODE = String(config.get('nvidiaThinkingMode') || false);
+
+ const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd();
+
+ bridge = new AgentBridge(cwd, env);
+ bridge.start();
+
+ // ββ Auto-restore session memory βββββββββββββββββββββββββββββββββββββββββ
+ // Whenever a fresh bridge is created (after a crash, settings change, or
+ // VS Code restart) inject the last active session so the model always
+ // remembers the full conversation without the user having to do anything.
+ // This is queued before any subsequent run() call so history is always
+ // loaded before the first user message is processed.
+ const savedSession = extensionContext
+ ? extensionContext.globalState.get('openClaudeCode.activeSession')
+ : null;
+ if (savedSession?.messages?.length > 0) {
+ bridge.resume(savedSession.messages);
+ }
+
+ return bridge;
+}
+
+// ββ ClaudeCodeViewProvider (Webview sidebar) βββββββββββββββββββββββββββββββββ
+
+class ClaudeCodeViewProvider {
+ constructor(context) {
+ this._context = context;
+ this._view = null;
+ this._isCancelled = false;
+ this._tokenUsage = { input: 0, output: 0 };
+ this._cost = 0;
+ }
+
+ resolveWebviewView(webviewView) {
+ this._view = webviewView;
+
+ webviewView.webview.options = {
+ enableScripts: true,
+ localResourceRoots: [
+ vscode.Uri.joinPath(this._context.extensionUri, 'media'),
+ ],
+ };
+
+ webviewView.webview.html = this._getHtmlForWebview(webviewView.webview);
+
+ webviewView.webview.onDidReceiveMessage(
+ (msg) => this._handleWebviewMessage(msg),
+ null,
+ this._context.subscriptions
+ );
+ }
+
+ postMessage(msg) {
+ if (this._view) {
+ this._view.webview.postMessage(msg);
+ }
+ }
+
+ async _handleWebviewMessage(msg) {
+ switch (msg.type) {
+ case 'ready': {
+ const config = vscode.workspace.getConfiguration('openClaudeCode');
+ const hasApiKey = !!(
+ (await extensionContext.secrets.get('openClaudeCode.apiKey')) ||
+ process.env.ANTHROPIC_API_KEY ||
+ process.env.OPENAI_API_KEY ||
+ process.env.GOOGLE_API_KEY ||
+ process.env.GEMINI_API_KEY ||
+ process.env.NVIDIA_API_KEY ||
+ config.get('nvidiaApiKey')
+ );
+ // Restore any active session persisted before the last VS Code restart
+ const activeSession = this._context.globalState.get('openClaudeCode.activeSession');
+ const activeMessages = (activeSession && Array.isArray(activeSession.messages) && activeSession.messages.length > 0)
+ ? activeSession.messages : null;
+ // Also restore the session ID so continued messages update the right history entry
+ const activeSessionId = (activeSession && activeSession.sessionId) || null;
+ this.postMessage({
+ type: 'initialized',
+ model: config.get('model') || 'claude-sonnet-4-6',
+ mode: config.get('permissionMode') || 'default',
+ thinkingMode: !!config.get('nvidiaThinkingMode'),
+ autoAttachActiveFile: !!config.get('autoAttachActiveFile'),
+ hasApiKey,
+ activeSession: activeMessages,
+ activeSessionId,
+ });
+ break;
+ }
+
+ case 'runCommand': {
+ if (msg.command) {
+ vscode.commands.executeCommand(msg.command, ...(msg.args || []));
+ }
+ break;
+ }
+
+ case 'send': {
+ await this._runPrompt(msg.message, msg.contextFiles, msg.fileRefs);
+ break;
+ }
+
+ case 'clear': {
+ if (bridge && bridge.isRunning) await bridge.reset();
+ this._tokenUsage = { input: 0, output: 0 };
+ this._cost = 0;
+ this.postMessage({ type: 'sessionCleared' });
+ break;
+ }
+
+ case 'cancel': {
+ this._isCancelled = true;
+ break;
+ }
+
+ case 'model': {
+ const config = vscode.workspace.getConfiguration('openClaudeCode');
+ await config.update('model', msg.model, vscode.ConfigurationTarget.Global);
+ if (bridge && bridge.isRunning) await bridge.switchModel(msg.model);
+ this.postMessage({ type: 'modelChanged', model: msg.model });
+ break;
+ }
+
+ case 'mode': {
+ const config = vscode.workspace.getConfiguration('openClaudeCode');
+ await config.update('permissionMode', msg.mode, vscode.ConfigurationTarget.Global);
+ if (bridge) { bridge.dispose(); bridge = null; }
+ break;
+ }
+
+ case 'thinkingMode': {
+ const config = vscode.workspace.getConfiguration('openClaudeCode');
+ await config.update('nvidiaThinkingMode', !!msg.enabled, vscode.ConfigurationTarget.Global);
+ // Restart bridge so NVIDIA_THINKING_MODE env var is re-read
+ if (bridge) { bridge.dispose(); bridge = null; }
+ this.postMessage({ type: 'thinkingModeChanged', enabled: !!msg.enabled });
+ break;
+ }
+
+ case 'applyCode': {
+ await this._applyCodeToActiveEditor(msg.code, msg.language);
+ break;
+ }
+
+ case 'applyCodeToFile': {
+ await this._applyCodeWithFilePicker(msg.code, msg.language);
+ break;
+ }
+
+ case 'copyToClipboard': {
+ await vscode.env.clipboard.writeText(msg.text || '');
+ break;
+ }
+
+ case 'pickFile': {
+ const uris = await vscode.window.showOpenDialog({
+ canSelectMany: false,
+ openLabel: 'Add to context',
+ });
+ if (uris && uris[0]) {
+ await this._addFileToContext(uris[0].fsPath);
+ }
+ break;
+ }
+
+ case 'addContextFile': {
+ if (msg.path) await this._addFileToContext(msg.path);
+ break;
+ }
+
+ case 'fileSearch': {
+ const results = await this._searchFiles(msg.query || '');
+ this.postMessage({ type: 'fileSearchResults', files: results });
+ break;
+ }
+
+ case 'saveSession': {
+ if (msg.messages && msg.messages.length > 0) {
+ const sessions = this._context.globalState.get('openClaudeCode.chatHistory', []);
+ const firstUser = msg.messages.find(m => m.type === 'user');
+ const title = firstUser
+ ? firstUser.text.slice(0, 80).replace(/\n/g, ' ')
+ : 'Untitled conversation';
+ sessions.unshift({
+ id: Date.now().toString(),
+ title,
+ createdAt: Date.now(),
+ messages: msg.messages,
+ });
+ // Keep the 30 most recent sessions
+ if (sessions.length > 30) sessions.length = 30;
+ await this._context.globalState.update('openClaudeCode.chatHistory', sessions);
+ }
+ break;
+ }
+
+ case 'getHistory': {
+ const sessions = this._context.globalState.get('openClaudeCode.chatHistory', []);
+ const sessionList = sessions.map(s => ({
+ id: s.id,
+ title: s.title,
+ createdAt: s.createdAt,
+ messageCount: s.messages ? s.messages.length : 0,
+ }));
+ this.postMessage({ type: 'historyData', sessions: sessionList });
+ break;
+ }
+
+ case 'loadSession': {
+ const sessions = this._context.globalState.get('openClaudeCode.chatHistory', []);
+ const session = sessions.find(s => s.id === msg.id);
+ if (session) {
+ // Include id so the webview can track which session is being viewed
+ this.postMessage({ type: 'sessionData', id: session.id, messages: session.messages || [] });
+ }
+ break;
+ }
+
+ case 'resumeFromHistory': {
+ // Direct-resume (Cursor-style): load and immediately switch to a history session.
+ const sessions = this._context.globalState.get('openClaudeCode.chatHistory', []);
+ const session = sessions.find(s => s.id === msg.id);
+ if (session) {
+ this.postMessage({ type: 'resumeFromHistoryData', id: session.id, messages: session.messages || [] });
+ }
+ break;
+ }
+
+ case 'updateSession': {
+ // Update an existing history entry (after adding new messages to a resumed session).
+ const sessions = this._context.globalState.get('openClaudeCode.chatHistory', []);
+ const sess = sessions.find(s => s.id === msg.id);
+ if (sess && Array.isArray(msg.messages) && msg.messages.length > 0) {
+ sess.messages = msg.messages.slice(-MAX_SESSION_MESSAGES);
+ sess.messageCount = sess.messages.length;
+ const firstUser = sess.messages.find(m => m.type === 'user');
+ if (firstUser) sess.title = firstUser.text.slice(0, 80).replace(/\n/g, ' ');
+ await this._context.globalState.update('openClaudeCode.chatHistory', sessions);
+ }
+ break;
+ }
+
+ case 'renameSession': {
+ const sessions = this._context.globalState.get('openClaudeCode.chatHistory', []);
+ const sess = sessions.find(s => s.id === msg.id);
+ if (sess && msg.title) {
+ sess.title = String(msg.title).slice(0, 120);
+ await this._context.globalState.update('openClaudeCode.chatHistory', sessions);
+ const sessionList = sessions.map(s => ({
+ id: s.id, title: s.title, createdAt: s.createdAt,
+ messageCount: s.messages ? s.messages.length : 0,
+ }));
+ this.postMessage({ type: 'historyData', sessions: sessionList });
+ }
+ break;
+ }
+
+ case 'deleteSession': {
+ let sessions = this._context.globalState.get('openClaudeCode.chatHistory', []);
+ sessions = sessions.filter(s => s.id !== msg.id);
+ await this._context.globalState.update('openClaudeCode.chatHistory', sessions);
+ const sessionList = sessions.map(s => ({
+ id: s.id, title: s.title, createdAt: s.createdAt,
+ messageCount: s.messages ? s.messages.length : 0,
+ }));
+ this.postMessage({ type: 'historyData', sessions: sessionList });
+ break;
+ }
+
+ case 'autoSaveSession': {
+ // Persist the current in-progress session so it survives VS Code restarts.
+ // Called by the webview after every completed response (stop event).
+ if (msg.messages && msg.messages.length > 0) {
+ // Cap at 200 messages (individual user/assistant entries) to avoid
+ // unbounded growth of the active session storage. This is separate
+ // from the 30-session cap on the chat history archive.
+ const capped = msg.messages.slice(-MAX_SESSION_MESSAGES);
+ await this._context.globalState.update('openClaudeCode.activeSession', {
+ messages: capped,
+ // Track which history session this is so it can be updated on restart
+ sessionId: msg.sessionId || null,
+ savedAt: Date.now(),
+ });
+ } else {
+ await this._context.globalState.update('openClaudeCode.activeSession', null);
+ }
+ break;
+ }
+
+ case 'resumeSession': {
+ // Restore conversation history into the agent bridge so the model
+ // remembers the full session (Claude Premium-style session memory).
+ if (msg.messages && msg.messages.length > 0) {
+ try {
+ const agentBridge = await getBridge();
+ await agentBridge.resume(msg.messages);
+ } catch (err) {
+ this.postMessage({ type: 'error', message: 'Failed to resume session: ' + err.message });
+ }
+ }
+ break;
+ }
+
+ case 'exportConversation': {
+ const content = String(msg.markdown || '');
+ const defaultName = 'conversation-' + new Date().toISOString().slice(0, 10) + '.md';
+ const defaultUri = vscode.Uri.file(
+ path.join(
+ vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd(),
+ defaultName
+ )
+ );
+ const uri = await vscode.window.showSaveDialog({
+ defaultUri,
+ filters: { 'Markdown': ['md'] },
+ title: 'Export conversation as Markdown',
+ });
+ if (uri) {
+ await vscode.workspace.fs.writeFile(uri, Buffer.from(content, 'utf8'));
+ vscode.window.showInformationMessage('Exported to ' + path.basename(uri.fsPath));
+ }
+ break;
+ }
+
+ case 'getActiveFileContent': {
+ const editor = vscode.window.activeTextEditor;
+ if (editor) {
+ this.postMessage({
+ type: 'activeFileContent',
+ content: editor.document.getText(),
+ fileName: path.basename(editor.document.fileName),
+ });
+ } else {
+ this.postMessage({ type: 'activeFileContent', content: null, fileName: null });
+ }
+ break;
+ }
+
+ case 'getPinnedFiles': {
+ const config = vscode.workspace.getConfiguration('openClaudeCode');
+ const pinned = (config.get('pinnedFiles') || []).filter(p => {
+ try { return fs.existsSync(p); } catch { return false; }
+ });
+ this.postMessage({
+ type: 'pinnedFiles',
+ files: pinned.map(p => ({ name: path.basename(p), path: p })),
+ });
+ break;
+ }
+
+ case 'pinFile': {
+ const config = vscode.workspace.getConfiguration('openClaudeCode');
+ const pinned = [...(config.get('pinnedFiles') || [])];
+ if (msg.path && !pinned.includes(msg.path)) {
+ pinned.push(msg.path);
+ await config.update('pinnedFiles', pinned, vscode.ConfigurationTarget.Global);
+ }
+ break;
+ }
+
+ case 'unpinFile': {
+ const config = vscode.workspace.getConfiguration('openClaudeCode');
+ const pinned = (config.get('pinnedFiles') || []).filter(p => p !== msg.path);
+ await config.update('pinnedFiles', pinned, vscode.ConfigurationTarget.Global);
+ break;
+ }
+
+ case 'runInTerminal': {
+ // Send shell code directly to the integrated terminal.
+ // Reuse an existing "Claude Code" terminal if one is already open.
+ const code = String(msg.code || '').trim();
+ if (!code) break;
+ let terminal = vscode.window.terminals.find(t => t.name === 'Claude Code');
+ if (!terminal) {
+ terminal = vscode.window.createTerminal({ name: 'Claude Code' });
+ }
+ terminal.show(true); // true = preserve editor focus
+ terminal.sendText(code);
+ break;
+ }
+
+ case 'addActiveFile': {
+ // Add the currently active editor file to the webview context chips.
+ const editor = vscode.window.activeTextEditor;
+ if (editor) {
+ const filePath = editor.document.fileName;
+ const fileName = path.basename(filePath);
+ this.postMessage({
+ type: 'fileContent',
+ name: fileName,
+ path: filePath,
+ });
+ } else {
+ vscode.window.showInformationMessage('No active editor β open a file first.');
+ }
+ break;
+ }
+
+ case 'getGitContext': {
+ const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
+ if (!cwd) {
+ this.postMessage({ type: 'gitContext', content: '(no workspace folder open)', branch: '' });
+ break;
+ }
+ const run = (args) => new Promise((resolve) => {
+ execFile('git', args, { cwd, timeout: 5000 }, (err, stdout) => {
+ resolve(err ? '' : stdout.trim());
+ });
+ });
+ const [branch, status, diffStat] = await Promise.all([
+ run(['rev-parse', '--abbrev-ref', 'HEAD']),
+ run(['status', '--short']),
+ run(['diff', '--stat']),
+ ]);
+ const parts = [];
+ if (branch) parts.push(`Branch: ${branch}`);
+ parts.push(status ? `Changed files:\n${status}` : 'Working tree clean');
+ if (diffStat) parts.push(`Diff summary:\n${diffStat}`);
+ this.postMessage({ type: 'gitContext', content: parts.join('\n\n'), branch: branch || '' });
+ break;
+ }
+
+ case 'getWorkspaceDiagnostics': {
+ const diags = [];
+ for (const [uri, ds] of vscode.languages.getDiagnostics()) {
+ const rel = vscode.workspace.asRelativePath(uri);
+ for (const d of ds) {
+ if (d.severity > 1) continue; // skip hints and info
+ diags.push({
+ file: rel,
+ line: d.range.start.line + 1,
+ col: d.range.start.character + 1,
+ severity: d.severity === 0 ? 'error' : 'warning',
+ message: d.message,
+ source: d.source || '',
+ });
+ }
+ }
+ diags.sort((a, b) => (a.severity === b.severity ? 0 : a.severity === 'error' ? -1 : 1));
+ this.postMessage({ type: 'workspaceDiagnostics', diagnostics: diags });
+ break;
+ }
+
+ case 'getOpenEditors': {
+ const files = [];
+ const seen = new Set();
+ for (const group of (vscode.window.tabGroups?.all || [])) {
+ for (const tab of group.tabs) {
+ const uri = tab.input?.uri;
+ if (uri && !seen.has(uri.fsPath)) {
+ seen.add(uri.fsPath);
+ files.push({
+ name: path.basename(uri.fsPath),
+ path: uri.fsPath,
+ relativePath: vscode.workspace.asRelativePath(uri.fsPath),
+ });
+ }
+ }
+ }
+ this.postMessage({ type: 'openEditors', files });
+ break;
+ }
+
+ case 'toggleAutoAttach': {
+ const config = vscode.workspace.getConfiguration('openClaudeCode');
+ const next = !config.get('autoAttachActiveFile');
+ await config.update('autoAttachActiveFile', next, vscode.ConfigurationTarget.Global);
+ this.postMessage({ type: 'autoAttachState', enabled: next });
+ break;
+ }
+
+ default:
+ break;
+ }
+ }
+
+ async _runPrompt(message, contextFilePaths, fileRefs) {
+ this._isCancelled = false;
+
+ let fullPrompt = message;
+
+ // Inject context file contents
+ const allPaths = new Set(contextFilePaths || []);
+ if (fileRefs && fileRefs.length > 0) {
+ const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
+ if (ws) {
+ for (const ref of fileRefs) {
+ const abs = path.resolve(ws, ref);
+ if (fs.existsSync(abs)) allPaths.add(abs);
+ }
+ }
+ }
+
+ // Auto-attach active editor when the setting is on (works even if no
+ // other context files were explicitly added by the user).
+ const promptConfig = vscode.workspace.getConfiguration('openClaudeCode');
+ if (promptConfig.get('autoAttachActiveFile')) {
+ const editor = vscode.window.activeTextEditor;
+ if (editor) allPaths.add(editor.document.fileName);
+ }
+
+ if (allPaths.size > 0) {
+ const fileContents = [];
+ for (const fp of allPaths) {
+ try {
+ const content = fs.readFileSync(fp, 'utf8');
+ const rel = vscode.workspace.asRelativePath(fp);
+ fileContents.push('\n\n--- File: ' + rel + ' ---\n' + content);
+ } catch {
+ // skip unreadable files
+ }
+ }
+ if (fileContents.length > 0) {
+ fullPrompt = message + '\n\n[Context files:]' + fileContents.join('');
+ }
+ }
+
+ let agentBridge;
+ try {
+ agentBridge = await getBridge();
+ } catch (err) {
+ this.postMessage({ type: 'error', message: 'Failed to start agent: ' + err.message });
+ this.postMessage({ type: 'stop' });
+ return;
+ }
+
+ // ββ Retry loop (auto-recovers from rate-limit / overload errors) ββββββββ
+ for (let attempt = 0; ; attempt++) {
+ if (this._isCancelled) return;
+
+ let retryErrorMsg = null;
+
+ await agentBridge.run(fullPrompt, (event) => {
+ if (this._isCancelled) return;
+ // Intercept retryable errors β don't forward yet; we may recover
+ if (event.type === 'error' && isRateLimitError(event.message)) {
+ retryErrorMsg = event.message;
+ return;
+ }
+ this.postMessage(event);
+ });
+
+ if (this._isCancelled) return;
+
+ if (!retryErrorMsg) {
+ // Normal completion β stop was already forwarded by onEvent
+ this.postMessage({ type: 'stop' });
+ return;
+ }
+
+ // Retryable error β decide whether to retry or give up
+ if (attempt >= RETRY_DELAYS_MS.length) {
+ // All retries exhausted
+ this.postMessage({
+ type: 'error',
+ message: retryErrorMsg + ` (failed after ${attempt + 1} attempts)`,
+ });
+ this.postMessage({ type: 'stop' });
+ return;
+ }
+
+ const delaySec = Math.ceil(RETRY_DELAYS_MS[attempt] / 1000);
+ this.postMessage({
+ type: 'retrying',
+ attempt: attempt + 1,
+ delaySeconds: delaySec,
+ maxAttempts: RETRY_DELAYS_MS.length,
+ });
+
+ // Countdown ticks emitted every second so the UI can show a live timer
+ await new Promise((resolve) => {
+ let remaining = delaySec - 1;
+ const tick = setInterval(() => {
+ if (this._isCancelled) { clearInterval(tick); resolve(); return; }
+ if (remaining <= 0) { clearInterval(tick); resolve(); return; }
+ this.postMessage({
+ type: 'retrying',
+ attempt: attempt + 1,
+ delaySeconds: remaining,
+ maxAttempts: RETRY_DELAYS_MS.length,
+ });
+ remaining--;
+ }, 1000);
+ });
+
+ if (this._isCancelled) return;
+
+ // Re-acquire bridge in case it restarted during the wait
+ try {
+ agentBridge = await getBridge();
+ } catch (err) {
+ this.postMessage({ type: 'error', message: 'Failed to restart agent: ' + err.message });
+ this.postMessage({ type: 'stop' });
+ return;
+ }
+ }
+ }
+
+ async _applyCodeToActiveEditor(code) {
+ const editor = vscode.window.activeTextEditor;
+ if (!editor) {
+ vscode.window.showWarningMessage('No active editor. Open a file first.');
+ return;
+ }
+ await editor.edit((editBuilder) => {
+ if (!editor.selection.isEmpty) {
+ editBuilder.replace(editor.selection, code);
+ } else {
+ const lastLine = editor.document.lineCount - 1;
+ const lastChar = editor.document.lineAt(lastLine).text.length;
+ const end = new vscode.Position(lastLine, lastChar);
+ editBuilder.insert(end, '\n' + code);
+ }
+ });
+ await vscode.commands.executeCommand('workbench.action.files.save');
+ vscode.window.showInformationMessage('Code applied to ' + path.basename(editor.document.fileName));
+ }
+
+ async _applyCodeWithFilePicker(code, language) {
+ const ext = languageToExt(language);
+ const uris = await vscode.window.showOpenDialog({
+ canSelectMany: false,
+ openLabel: 'Apply to this file',
+ filters: ext ? { [language || 'code']: [ext] } : undefined,
+ });
+ if (!uris || !uris[0]) return;
+
+ const doc = await vscode.workspace.openTextDocument(uris[0]);
+ const editor = await vscode.window.showTextDocument(doc);
+ await editor.edit((eb) => {
+ const fullRange = new vscode.Range(
+ doc.positionAt(0),
+ doc.positionAt(doc.getText().length)
+ );
+ eb.replace(fullRange, code);
+ });
+ await vscode.commands.executeCommand('workbench.action.files.save');
+ }
+
+ async _addFileToContext(filePath) {
+ const name = path.basename(filePath);
+ this.postMessage({ type: 'fileContent', path: filePath, name });
+ }
+
+ async _searchFiles(query) {
+ const pattern = query ? ('**/*' + query + '*') : '**/*';
+ const uris = await vscode.workspace.findFiles(pattern, '**/node_modules/**', 20);
+ return uris.map((u) => ({
+ name: path.basename(u.fsPath),
+ path: u.fsPath,
+ relativePath: vscode.workspace.asRelativePath(u.fsPath),
+ }));
+ }
+
+ _getHtmlForWebview(webview) {
+ const cssUri = webview.asWebviewUri(
+ vscode.Uri.joinPath(this._context.extensionUri, 'media', 'chat.css')
+ );
+ const jsUri = webview.asWebviewUri(
+ vscode.Uri.joinPath(this._context.extensionUri, 'media', 'chat.js')
+ );
+
+ const templatePath = path.join(this._context.extensionPath, 'media', 'chat.html');
+ let html = fs.readFileSync(templatePath, 'utf8');
+
+ const nonce = generateNonce();
+ const csp = [
+ "default-src 'none'",
+ "style-src " + webview.cspSource + " 'unsafe-inline'",
+ "script-src 'nonce-" + nonce + "'",
+ "img-src " + webview.cspSource + " data: https:",
+ "font-src " + webview.cspSource,
+ ].join('; ');
+
+ html = html
+ .replace('', '')
+ .replace('', cssUri.toString())
+ .replace(//g, jsUri.toString())
+ .replace('
+