coc-bridge 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.0.2] — 2026-10-14
11
+
12
+ ### Added
13
+ - **Claude Code Tool Control**: Claude Code now retains 100% control of tool execution (`View`, `Edit`, `Bash`, `Glob`, `Grep`, `Write`), showing interactive diffs, user permission confirmations, and spinners instead of OpenCode silently intercepting them
14
+ - **Anthropic `tool_use` Event Streaming**: Bridges model responses directly to Anthropic SSE tool use events (`content_block_start`, `input_json_delta`, `message_delta` with `stop_reason: "tool_use"`)
15
+ - **Accurate Real-Time Token Counts**: Extracted directly from model execution metrics rather than estimated token formulas
16
+ - **`coc-bridge restore` command**: One-command rollback to revert Claude Code & VS Code back to official Anthropic defaults with zero friction
17
+ - **Automatic VS Code Settings**: Configures Claude Code VS Code extension settings with robust JSONC comment-stripping support
18
+ - **`coc-bridge models` & `coc-bridge use <id>`**: Live discovery and switching of default models saved to `~/.coc-bridge/config.json`
19
+
20
+ ## [1.0.1] — 2026-10-14
21
+
22
+ ### Fixed
23
+ - Fixed global CLI bin symlink path in `package.json` for seamless `npm install -g coc-bridge` installation
24
+ - Dynamic model discovery from `opencode models`
25
+ - Added `coc-bridge models` and `coc-bridge use <model-id>` commands
26
+ - Full daemon backgrounding with `coc-bridge start/stop/status/restart/logs`
27
+
10
28
  ## [1.0.0] — 2026-10-14
11
29
 
12
30
  ### Added
package/bin/coc-bridge.js CHANGED
@@ -302,9 +302,10 @@ ${BOLD}COMMANDS${RESET}
302
302
  ${G}status${RESET} Show whether the bridge is running
303
303
  ${G}logs${RESET} Tail the live log file
304
304
  ${G}models${RESET} List all available OpenCode models
305
- ${G}use <id>${RESET} Set the default model (saved to ~/.coc-bridge/config.json)
306
- ${G}server${RESET} Run in the foreground (useful for debugging)
307
- ${G}setup${RESET} Wire Claude Code to auto-start the bridge on login
305
+ ${G}use <id>${RESET} Switch default model (saves to ~/.coc-bridge/config.json)
306
+ ${G}server${RESET} Run in foreground (useful for debugging)
307
+ ${G}setup${RESET} Wire Claude Code & VS Code to use the bridge
308
+ ${G}restore${RESET} Revert Claude Code & VS Code back to official defaults
308
309
  ${G}doctor${RESET} Check that Node, opencode, and claude are all wired up
309
310
 
310
311
  ${BOLD}ENVIRONMENT VARIABLES${RESET}
@@ -321,7 +322,8 @@ ${BOLD}EXAMPLES${RESET}
321
322
  coc-bridge stop # Shut it down
322
323
  coc-bridge status # Is it running?
323
324
  coc-bridge logs # Watch live logs
324
- coc-bridge setup # One-time Claude Code wiring
325
+ coc-bridge setup # Wire Claude Code & VS Code
326
+ coc-bridge restore # Revert back to official Claude Code
325
327
  BRIDGE_PORT=5000 coc-bridge # Custom port
326
328
  `);
327
329
  }
@@ -339,8 +341,9 @@ const cmd = process.argv[2] || 'start';
339
341
  case 'server': cmdServer(); break;
340
342
  case 'models': cmdModels(); break;
341
343
  case 'use': cmdUse(process.argv[3]); break;
342
- case 'setup': require(path.join(__dirname, '..', 'scripts', 'setup-claude.js')); break;
343
- case 'doctor': require(path.join(__dirname, '..', 'scripts', 'doctor.js')); break;
344
+ case 'setup': require(path.join(__dirname, '..', 'scripts', 'setup-claude.js')); break;
345
+ case 'restore': require(path.join(__dirname, '..', 'scripts', 'restore-claude.js')); break;
346
+ case 'doctor': require(path.join(__dirname, '..', 'scripts', 'doctor.js')); break;
344
347
  case '--version':
345
348
  case '-v': {
346
349
  const pkg = require('../package.json');
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "coc-bridge",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "A zero-dependency local bridge that connects Claude Code to OpenCode's free hosted models — no API keys required.",
5
5
  "main": "src/server.js",
6
6
  "bin": {
7
- "coc-bridge": "./bin/coc-bridge.js",
8
- "coc-bridge-start": "./bin/coc-bridge.js"
7
+ "coc-bridge": "bin/coc-bridge.js",
8
+ "coc-bridge-start": "bin/coc-bridge.js"
9
9
  },
10
10
  "scripts": {
11
11
  "start": "node src/server.js",
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * coc-bridge restore — Reverts Claude Code and VS Code back to normal official settings.
4
+ * Removes all coc-bridge proxies, wrappers, and custom settings with zero friction.
5
+ */
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+ const { execSync } = require('child_process');
12
+
13
+ const GREEN = '\x1b[32m';
14
+ const YELLOW = '\x1b[33m';
15
+ const RED = '\x1b[31m';
16
+ const RESET = '\x1b[0m';
17
+ const BOLD = '\x1b[1m';
18
+ const DIM = '\x1b[2m';
19
+
20
+ function ok(msg) { console.log(`${GREEN} ✔${RESET} ${msg}`); }
21
+ function warn(msg) { console.log(`${YELLOW} ⚠${RESET} ${msg}`); }
22
+ function info(msg) { console.log(`${DIM} ℹ ${msg}${RESET}`); }
23
+ function step(msg) { console.log(`\n${BOLD}${msg}${RESET}`); }
24
+
25
+ console.log(`\n${BOLD}coc-bridge restore 🔄${RESET}`);
26
+ console.log(`${DIM}Reverting Claude Code & VS Code back to official Anthropic defaults...${RESET}`);
27
+
28
+ // ─────────────────────────────────────────────
29
+ // 1. Stop running bridge daemon
30
+ // ─────────────────────────────────────────────
31
+ step('1. Stopping coc-bridge daemon...');
32
+ try {
33
+ const pidFile = path.join(os.homedir(), '.coc-bridge', 'bridge.pid');
34
+ if (fs.existsSync(pidFile)) {
35
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
36
+ if (pid) {
37
+ process.kill(pid, 'SIGTERM');
38
+ ok(`Terminated bridge process (PID ${pid})`);
39
+ }
40
+ fs.unlinkSync(pidFile);
41
+ } else {
42
+ info('Bridge is not running.');
43
+ }
44
+ } catch (_) {
45
+ info('Bridge stopped or already inactive.');
46
+ }
47
+
48
+ // ─────────────────────────────────────────────
49
+ // 2. Restore claude binary wrapper
50
+ // ─────────────────────────────────────────────
51
+ step('2. Restoring official claude binary...');
52
+ let claudeBin;
53
+ try {
54
+ claudeBin = execSync('which claude', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
55
+ } catch (_) {}
56
+
57
+ if (claudeBin) {
58
+ const claudeBinReal = claudeBin + '-bin';
59
+ if (fs.existsSync(claudeBinReal)) {
60
+ // Overwrite the wrapper with the original executable
61
+ fs.copyFileSync(claudeBinReal, claudeBin);
62
+ fs.chmodSync(claudeBin, 0o755);
63
+ fs.unlinkSync(claudeBinReal);
64
+ ok(`Restored original binary at ${claudeBin}`);
65
+ } else {
66
+ // Check if the current file is a wrapper without backup
67
+ const content = fs.readFileSync(claudeBin, 'utf8');
68
+ if (content.includes('coc-bridge') || content.includes('ANTHROPIC_BASE_URL')) {
69
+ warn(`Claude wrapper detected, but backup binary ${claudeBinReal} was missing.`);
70
+ info('You can reinstall Claude Code cleanly via: npm install -g @anthropic-ai/claude-code');
71
+ } else {
72
+ ok(`Official claude binary is already in place (${claudeBin})`);
73
+ }
74
+ }
75
+ } else {
76
+ info('claude binary not found in PATH.');
77
+ }
78
+
79
+ // ─────────────────────────────────────────────
80
+ // 3. Clean VS Code settings
81
+ // ─────────────────────────────────────────────
82
+ step('3. Restoring VS Code settings...');
83
+
84
+ const vsCodeCandidates = [
85
+ path.join(os.homedir(), '.config', 'Code', 'User', 'settings.json'),
86
+ path.join(os.homedir(), '.var', 'app', 'com.visualstudio.code', 'config', 'Code', 'User', 'settings.json'),
87
+ path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'settings.json'),
88
+ process.env.APPDATA ? path.join(process.env.APPDATA, 'Code', 'User', 'settings.json') : null
89
+ ].filter(Boolean);
90
+
91
+ let vsCodeUpdated = false;
92
+ for (const settingsPath of vsCodeCandidates) {
93
+ if (fs.existsSync(settingsPath)) {
94
+ try {
95
+ let raw = fs.readFileSync(settingsPath, 'utf8');
96
+ let cleaned = false;
97
+
98
+ // Clean using stripJsonComments + JSON parse
99
+ try {
100
+ const stripJsonComments = str => str.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => g ? '' : m);
101
+ const json = JSON.parse(stripJsonComments(raw));
102
+ if (json['claudeCode.claudeProcessWrapper']) {
103
+ delete json['claudeCode.claudeProcessWrapper'];
104
+ cleaned = true;
105
+ }
106
+ if (json['claudeCode.environmentVariables']) {
107
+ // Remove bridge-injected variables
108
+ json['claudeCode.environmentVariables'] = json['claudeCode.environmentVariables'].filter(item => {
109
+ return !item.name.startsWith('ANTHROPIC_') && !item.name.startsWith('CLAUDE_CODE_');
110
+ });
111
+ if (json['claudeCode.environmentVariables'].length === 0) {
112
+ delete json['claudeCode.environmentVariables'];
113
+ }
114
+ cleaned = true;
115
+ }
116
+ if (cleaned) {
117
+ fs.writeFileSync(settingsPath, JSON.stringify(json, null, 4));
118
+ ok(`Restored VS Code settings: ${settingsPath}`);
119
+ vsCodeUpdated = true;
120
+ }
121
+ } catch (jsonErr) {
122
+ // If file contains JSON comments, strip wrapper lines with regex
123
+ raw = raw.replace(/"claudeCode\.claudeProcessWrapper":\s*"[^"]*",?/g, '');
124
+ fs.writeFileSync(settingsPath, raw);
125
+ ok(`Cleaned wrapper lines in: ${settingsPath}`);
126
+ vsCodeUpdated = true;
127
+ }
128
+ } catch (e) {
129
+ warn(`Could not update ${settingsPath}: ${e.message}`);
130
+ }
131
+ }
132
+ }
133
+
134
+ if (!vsCodeUpdated) {
135
+ info('No VS Code bridge overrides were found.');
136
+ }
137
+
138
+ // ─────────────────────────────────────────────
139
+ // Done
140
+ // ─────────────────────────────────────────────
141
+ console.log(`
142
+ ${GREEN}${BOLD}✔ Successfully restored!${RESET}
143
+
144
+ Claude Code and VS Code are now back to normal official Anthropic mode.
145
+ No proxies or custom wrappers are active.
146
+
147
+ ${DIM}Want coc-bridge back anytime? Just run:${RESET}
148
+ ${BOLD}coc-bridge setup${RESET}
149
+ `);
@@ -137,16 +137,82 @@ if (!fs.existsSync(logDir)) {
137
137
  ok(`Log dir exists: ${logDir}`);
138
138
  }
139
139
 
140
+ // ─────────────────────────────────────────────
141
+ // 5. Configure VS Code extension automatically
142
+ // ─────────────────────────────────────────────
143
+ step('5. Configuring VS Code Claude Code extension...');
144
+
145
+ const vsCodeCandidates = [
146
+ path.join(os.homedir(), '.config', 'Code', 'User', 'settings.json'),
147
+ path.join(os.homedir(), '.var', 'app', 'com.visualstudio.code', 'config', 'Code', 'User', 'settings.json'),
148
+ path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'settings.json'),
149
+ process.env.APPDATA ? path.join(process.env.APPDATA, 'Code', 'User', 'settings.json') : null
150
+ ].filter(Boolean);
151
+
152
+ let vsCodeConfigured = false;
153
+ for (const settingsPath of vsCodeCandidates) {
154
+ if (fs.existsSync(settingsPath)) {
155
+ try {
156
+ const raw = fs.readFileSync(settingsPath, 'utf8');
157
+ const stripJsonComments = str => str.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => g ? '' : m);
158
+ const settings = JSON.parse(stripJsonComments(raw));
159
+
160
+ settings['claudeCode.claudeProcessWrapper'] = claudeBin;
161
+ settings['claudeCode.environmentVariables'] = [
162
+ { name: 'ANTHROPIC_BASE_URL', value: 'http://127.0.0.1:4045' },
163
+ { name: 'ANTHROPIC_AUTH_TOKEN', value: 'coc-local' },
164
+ { name: 'ANTHROPIC_API_KEY', value: '' },
165
+ { name: 'CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT', value: '1' },
166
+ { name: 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY', value: '1' },
167
+ { name: 'ANTHROPIC_MODEL', value: 'opencode/space-bunny-free' },
168
+ { name: 'ANTHROPIC_CUSTOM_MODEL_OPTION_NAME', value: 'Space Bunny' },
169
+ { name: 'ANTHROPIC_DEFAULT_OPUS_MODEL', value: 'opencode/nemotron-3-ultra-free' },
170
+ { name: 'ANTHROPIC_DEFAULT_OPUS_MODEL_NAME', value: 'Nemotron 3 Ultra' },
171
+ { name: 'ANTHROPIC_DEFAULT_SONNET_MODEL', value: 'opencode/muse-spark-1.3-contributor-free' },
172
+ { name: 'ANTHROPIC_DEFAULT_SONNET_MODEL_NAME', value: 'Muse Spark 1.3' },
173
+ { name: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', value: 'opencode/cat-jam-free' },
174
+ { name: 'ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME', value: 'Cat Jam' },
175
+ { name: 'ANTHROPIC_DEFAULT_FABLE_MODEL', value: 'opencode/ling-3.1-flash-free' },
176
+ { name: 'ANTHROPIC_DEFAULT_FABLE_MODEL_NAME', value: 'Ling 3.1 Flash' }
177
+ ];
178
+
179
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 4));
180
+ ok(`Configured VS Code: ${settingsPath}`);
181
+ vsCodeConfigured = true;
182
+ } catch (e) {
183
+ warn(`Could not automatically update ${settingsPath}: ${e.message}`);
184
+ }
185
+ }
186
+ }
187
+
188
+ if (!vsCodeConfigured) {
189
+ warn('VS Code settings not found. If using VS Code, set "claudeCode.claudeProcessWrapper": "' + claudeBin + '"');
190
+ }
191
+
192
+ // ─────────────────────────────────────────────
193
+ // 6. Ensure ~/.local/bin symlink exists
194
+ // ─────────────────────────────────────────────
195
+ step('6. Ensuring CLI command availability...');
196
+ try {
197
+ const localBin = path.join(os.homedir(), '.local', 'bin');
198
+ if (!fs.existsSync(localBin)) fs.mkdirSync(localBin, { recursive: true });
199
+ const targetLink = path.join(localBin, 'coc-bridge');
200
+ if (bridgeBin && !fs.existsSync(targetLink)) {
201
+ fs.symlinkSync(bridgeBin, targetLink);
202
+ ok(`Linked coc-bridge to ${targetLink}`);
203
+ }
204
+ } catch (_) {}
205
+
140
206
  // ─────────────────────────────────────────────
141
207
  // Done
142
208
  // ─────────────────────────────────────────────
143
209
  console.log(`
144
210
  ${GREEN}${BOLD}✅ Setup complete!${RESET}
145
211
 
146
- Run ${BOLD}claude${RESET} as normal — coc-bridge will auto-start in the background.
147
-
148
- Verify anytime with:
149
- ${BOLD}coc-bridge doctor${RESET}
212
+ • Terminal: Run ${BOLD}claude${RESET} as normal — coc-bridge auto-starts in background.
213
+ • VS Code : Open the Claude Code extension — models are connected!
214
+ • Switch models: ${BOLD}coc-bridge models${RESET} / ${BOLD}coc-bridge use <id>${RESET}
215
+ • Want normal Claude back? Run: ${BOLD}coc-bridge restore${RESET}
150
216
 
151
217
  Logs: ${logDir}/bridge.log
152
218
  `);
package/src/server.js CHANGED
@@ -9,6 +9,8 @@ const fs = require('fs');
9
9
  const PORT = parseInt(process.env.BRIDGE_PORT || process.env.PORT || '4045', 10);
10
10
  const MAX_PAYLOAD_BYTES = 50 * 1024 * 1024; // 50MB
11
11
  const CONFIG_FILE = path.join(os.homedir(), '.coc-bridge', 'config.json');
12
+ const SANDBOX_DIR = path.join(os.tmpdir(), 'coc-bridge-sandbox');
13
+ try { fs.mkdirSync(SANDBOX_DIR, { recursive: true }); } catch (_) {}
12
14
 
13
15
  // ─── Config (persists user's chosen default model) ────────────────────────────
14
16
  function readConfig() {
@@ -126,60 +128,174 @@ function resolveModel(requested) {
126
128
 
127
129
 
128
130
  /**
129
- * Convert Anthropic message format into a clean prompt string for OpenCode CLI
131
+ * Extract tool calls from model output.
132
+ * Supports:
133
+ * 1. ```tool_call ... ``` or ```json ... ``` blocks
134
+ * 2. <tool_call> ... </tool_call> XML tags
135
+ * 3. Bare JSON object containing "name" and "input" matching an allowed tool
136
+ */
137
+ function extractToolCalls(text, allowedTools = []) {
138
+ if (!text) return { text: '', toolCalls: [] };
139
+ const toolCalls = [];
140
+
141
+ // 1. Match markdown blocks
142
+ const blockRegex = /```(?:tool_call|json)?\s*\n?([\s\S]*?)\n?```/gi;
143
+ let match;
144
+ let textSegments = [];
145
+ let lastIndex = 0;
146
+
147
+ while ((match = blockRegex.exec(text)) !== null) {
148
+ let isTool = false;
149
+ try {
150
+ const parsed = JSON.parse(match[1].trim());
151
+ if (parsed && typeof parsed.name === 'string' && parsed.input !== undefined) {
152
+ const matchedTool = allowedTools.length === 0
153
+ ? parsed.name
154
+ : allowedTools.find(t => t.toLowerCase() === parsed.name.toLowerCase());
155
+ if (matchedTool) {
156
+ toolCalls.push({ name: matchedTool, input: parsed.input });
157
+ textSegments.push(text.slice(lastIndex, match.index));
158
+ lastIndex = blockRegex.lastIndex;
159
+ isTool = true;
160
+ }
161
+ }
162
+ } catch (_) {}
163
+ }
164
+
165
+ if (toolCalls.length > 0) {
166
+ textSegments.push(text.slice(lastIndex));
167
+ const cleanText = textSegments.join('\n').replace(/\n{3,}/g, '\n\n').trim();
168
+ return { text: cleanText, toolCalls };
169
+ }
170
+
171
+ // 2. Match <tool_call> tags
172
+ const xmlRegex = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/gi;
173
+ lastIndex = 0;
174
+ textSegments = [];
175
+ while ((match = xmlRegex.exec(text)) !== null) {
176
+ try {
177
+ const parsed = JSON.parse(match[1].trim());
178
+ if (parsed && typeof parsed.name === 'string' && parsed.input !== undefined) {
179
+ const matchedTool = allowedTools.length === 0
180
+ ? parsed.name
181
+ : allowedTools.find(t => t.toLowerCase() === parsed.name.toLowerCase());
182
+ if (matchedTool) {
183
+ toolCalls.push({ name: matchedTool, input: parsed.input });
184
+ textSegments.push(text.slice(lastIndex, match.index));
185
+ lastIndex = xmlRegex.lastIndex;
186
+ }
187
+ }
188
+ } catch (_) {}
189
+ }
190
+
191
+ if (toolCalls.length > 0) {
192
+ textSegments.push(text.slice(lastIndex));
193
+ const cleanText = textSegments.join('\n').replace(/\n{3,}/g, '\n\n').trim();
194
+ return { text: cleanText, toolCalls };
195
+ }
196
+
197
+ // 3. Match bare JSON object with "name" and "input"
198
+ const bareMatch = text.match(/(\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"input"\s*:\s*\{[\s\S]*?\}\s*\})/);
199
+ if (bareMatch) {
200
+ try {
201
+ const parsed = JSON.parse(bareMatch[1].trim());
202
+ if (parsed && typeof parsed.name === 'string' && parsed.input !== undefined) {
203
+ const matchedTool = allowedTools.length === 0
204
+ ? parsed.name
205
+ : allowedTools.find(t => t.toLowerCase() === parsed.name.toLowerCase());
206
+ if (matchedTool) {
207
+ const pre = text.slice(0, bareMatch.index).trim();
208
+ const post = text.slice(bareMatch.index + bareMatch[1].length).trim();
209
+ return {
210
+ text: [pre, post].filter(Boolean).join('\n'),
211
+ toolCalls: [{ name: matchedTool, input: parsed.input }]
212
+ };
213
+ }
214
+ }
215
+ } catch (_) {}
216
+ }
217
+
218
+ return { text: text.trim(), toolCalls: [] };
219
+ }
220
+
221
+ /**
222
+ * Convert Anthropic message format into a clean prompt string for OpenCode CLI,
223
+ * with full tool schemas injected so Claude Code controls tool execution.
130
224
  */
131
225
  function buildPrompt(body) {
132
226
  let promptParts = [];
227
+ const allowedTools = [];
228
+
229
+ // 1. Tool schemas and instructions
230
+ if (Array.isArray(body.tools) && body.tools.length > 0) {
231
+ let toolsDesc = '## Available Tools & Instructions\n';
232
+ toolsDesc += 'You are acting as the AI model for Claude Code. You do NOT have any built-in tools or direct filesystem access.\n';
233
+ toolsDesc += 'Do NOT attempt to use any internal OpenCode tools (like read, glob, shell, or edit).\n';
234
+ toolsDesc += 'Instead, to view files, make edits, run terminal commands, or list directories, you MUST emit a tool call in this exact JSON block:\n\n';
235
+ toolsDesc += '```tool_call\n';
236
+ toolsDesc += '{"name": "<ToolName>", "input": { ...arguments according to input schema... }}\n';
237
+ toolsDesc += '```\n\n';
238
+ toolsDesc += 'Claude Code will execute the tool on the user\'s local machine and return the result to you in the next message.\n';
239
+ toolsDesc += 'When you have enough information to answer the user without tools, reply with your normal explanation and NO tool_call blocks.\n\n';
240
+ toolsDesc += 'Tools available to request:\n';
241
+
242
+ for (const tool of body.tools) {
243
+ if (tool && tool.name) {
244
+ allowedTools.push(tool.name);
245
+ toolsDesc += `\n### Tool: ${tool.name}\n`;
246
+ if (tool.description) toolsDesc += `Description: ${tool.description}\n`;
247
+ if (tool.input_schema) toolsDesc += `Input Schema: ${JSON.stringify(tool.input_schema)}\n`;
248
+ }
249
+ }
250
+
251
+ promptParts.push(`[System Instructions & Tools]\n${toolsDesc}\n`);
252
+ }
133
253
 
134
- // Add system instruction if present
254
+ // 2. Add system prompt from Claude Code
135
255
  if (body.system) {
256
+ let sysText = '';
136
257
  if (typeof body.system === 'string') {
137
- promptParts.push(`[System Instructions]\n${body.system}\n`);
258
+ sysText = body.system;
138
259
  } else if (Array.isArray(body.system)) {
139
- const sysText = body.system.map(s => s.text || '').join('\n');
140
- if (sysText.trim()) {
141
- promptParts.push(`[System Instructions]\n${sysText}\n`);
142
- }
260
+ sysText = body.system.map(s => (typeof s === 'string' ? s : (s.text || ''))).join('\n');
261
+ }
262
+ if (sysText.trim()) {
263
+ promptParts.push(`[Claude Code Instructions]\n${sysText.trim()}\n`);
143
264
  }
144
265
  }
145
266
 
146
- // Add messages history
267
+ // 3. Add message history
147
268
  if (Array.isArray(body.messages)) {
148
269
  for (const msg of body.messages) {
149
270
  const role = msg.role === 'assistant' ? 'Assistant' : 'User';
150
- let content = '';
151
271
 
152
272
  if (typeof msg.content === 'string') {
153
- content = msg.content;
273
+ promptParts.push(`${role}: ${msg.content.trim()}`);
154
274
  } else if (Array.isArray(msg.content)) {
155
- content = msg.content
156
- .map(part => {
157
- if (part.type === 'text') {
158
- return part.text || '';
159
- }
160
- if (part.type === 'tool_use') {
161
- const input = typeof part.input === 'object' ? JSON.stringify(part.input) : (part.input || '');
162
- return `[Executed Tool: ${part.name}(${input})]`;
163
- }
164
- if (part.type === 'tool_result') {
165
- const resContent = typeof part.content === 'string' ? part.content : JSON.stringify(part.content || '');
166
- return `[Tool Result: ${resContent}]`;
167
- }
168
- return '';
169
- })
170
- .filter(Boolean)
171
- .join('\n');
172
- }
173
-
174
- if (content.trim()) {
175
- promptParts.push(`${role}: ${content}`);
275
+ let textParts = [];
276
+ for (const part of msg.content) {
277
+ if (part.type === 'text' && part.text) {
278
+ textParts.push(part.text);
279
+ } else if (part.type === 'tool_use') {
280
+ textParts.push(`\`\`\`tool_call\n${JSON.stringify({ name: part.name, input: part.input })}\n\`\`\``);
281
+ } else if (part.type === 'tool_result') {
282
+ const resContent = typeof part.content === 'string'
283
+ ? part.content
284
+ : (Array.isArray(part.content)
285
+ ? part.content.map(c => c.text || JSON.stringify(c)).join('\n')
286
+ : JSON.stringify(part.content || ''));
287
+ textParts.push(`[Tool Result for ${part.tool_use_id || 'tool'}]:\n${resContent}`);
288
+ }
289
+ }
290
+ if (textParts.length > 0) {
291
+ promptParts.push(`${role}: ${textParts.join('\n\n')}`);
292
+ }
176
293
  }
177
294
  }
178
295
  }
179
296
 
180
- // If there's an agent prompt turn, make sure Assistant is ready to answer
181
297
  promptParts.push('Assistant:');
182
- return promptParts.join('\n\n');
298
+ return { prompt: promptParts.join('\n\n'), allowedTools };
183
299
  }
184
300
 
185
301
  /**
@@ -196,14 +312,15 @@ function handleMessagesRequest(req, res, reqBody) {
196
312
  }
197
313
 
198
314
  const modelName = resolveModel(body.model);
199
- const prompt = buildPrompt(body);
315
+ const { prompt, allowedTools } = buildPrompt(body);
200
316
  const isStreaming = Boolean(body.stream);
201
317
  const messageId = `msg_${crypto.randomBytes(12).toString('hex')}`;
202
318
 
203
- console.log(`[Bridge] Incoming request for model: ${modelName} (requested: ${body.model || 'none'}, streaming=${isStreaming})`);
319
+ console.log(`[Bridge] Request: ${modelName} (tools: ${allowedTools.length}, streaming=${isStreaming})`);
204
320
 
205
- // Spawn OpenCode CLI as child process
206
- const child = spawn(OPENCODE_BIN, ['run', '-m', modelName], {
321
+ // Run in empty sandbox dir so OpenCode cannot touch user files directly
322
+ const child = spawn(OPENCODE_BIN, ['run', '-m', modelName, '--format', 'json'], {
323
+ cwd: SANDBOX_DIR,
207
324
  stdio: ['pipe', 'pipe', 'pipe'],
208
325
  env: { ...process.env }
209
326
  });
@@ -226,108 +343,185 @@ function handleMessagesRequest(req, res, reqBody) {
226
343
  }
227
344
  });
228
345
 
229
- // Write prompt to stdin and close
230
346
  child.stdin.write(prompt);
231
347
  child.stdin.end();
232
348
 
233
- let accumulatedOutput = '';
349
+ let stdoutBuffer = '';
350
+ let accumulatedText = '';
234
351
  let accumulatedError = '';
352
+ let reportedTokens = null;
353
+
354
+ child.stdout.on('data', chunk => {
355
+ stdoutBuffer += chunk.toString();
356
+ const lines = stdoutBuffer.split('\n');
357
+ stdoutBuffer = lines.pop(); // keep partial line
358
+
359
+ for (const line of lines) {
360
+ if (!line.trim()) continue;
361
+ try {
362
+ const ev = JSON.parse(line);
363
+ if (ev.type === 'text' && ev.part && typeof ev.part.text === 'string') {
364
+ accumulatedText += ev.part.text;
365
+ } else if (ev.type === 'step_finish' && ev.part && ev.part.tokens) {
366
+ reportedTokens = ev.part.tokens;
367
+ }
368
+ } catch (_) {
369
+ accumulatedText += line + '\n';
370
+ }
371
+ }
372
+ });
235
373
 
236
- if (isStreaming) {
237
- // Anthropic SSE headers
238
- res.writeHead(200, {
239
- 'Content-Type': 'text/event-stream; charset=utf-8',
240
- 'Cache-Control': 'no-cache',
241
- 'Connection': 'keep-alive'
242
- });
374
+ child.stderr.on('data', chunk => {
375
+ accumulatedError += chunk.toString();
376
+ });
243
377
 
244
- // 1. message_start
245
- res.write(`event: message_start\ndata: ${JSON.stringify({
246
- type: 'message_start',
247
- message: {
248
- id: messageId,
249
- type: 'message',
250
- role: 'assistant',
251
- model: modelName,
252
- content: [],
253
- stop_reason: null,
254
- stop_sequence: null,
255
- usage: { input_tokens: Math.ceil(prompt.length / 4), output_tokens: 1 }
256
- }
257
- })}\n\n`);
258
-
259
- // 2. content_block_start
260
- res.write(`event: content_block_start\ndata: ${JSON.stringify({
261
- type: 'content_block_start',
262
- index: 0,
263
- content_block: { type: 'text', text: '' }
264
- })}\n\n`);
265
-
266
- // Stream chunks as they arrive from OpenCode stdout
267
- child.stdout.on('data', chunk => {
268
- const text = chunk.toString();
269
- accumulatedOutput += text;
270
- res.write(`event: content_block_delta\ndata: ${JSON.stringify({
271
- type: 'content_block_delta',
272
- index: 0,
273
- delta: { type: 'text_delta', text: text }
274
- })}\n\n`);
275
- });
378
+ child.on('close', code => {
379
+ clearTimeout(timer);
276
380
 
277
- child.stderr.on('data', chunk => {
278
- accumulatedError += chunk.toString();
279
- });
381
+ // Process any remaining buffered stdout
382
+ if (stdoutBuffer.trim()) {
383
+ try {
384
+ const ev = JSON.parse(stdoutBuffer);
385
+ if (ev.type === 'text' && ev.part && typeof ev.part.text === 'string') {
386
+ accumulatedText += ev.part.text;
387
+ } else if (ev.type === 'step_finish' && ev.part && ev.part.tokens) {
388
+ reportedTokens = ev.part.tokens;
389
+ }
390
+ } catch (_) {
391
+ accumulatedText += stdoutBuffer;
392
+ }
393
+ }
280
394
 
281
- child.on('close', code => {
282
- clearTimeout(timer);
283
- if (code !== 0 && !accumulatedOutput.trim()) {
284
- console.error(`[Bridge] OpenCode exited with code ${code}: ${accumulatedError}`);
285
- const errMsg = accumulatedError.trim() || `OpenCode CLI process exited with code ${code}`;
395
+ if (code !== 0 && !accumulatedText.trim()) {
396
+ console.error(`[Bridge] OpenCode exited with code ${code}: ${accumulatedError}`);
397
+ const errMsg = accumulatedError.trim() || `OpenCode process exited with code ${code}`;
398
+ if (isStreaming) {
399
+ res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8' });
286
400
  res.write(`event: error\ndata: ${JSON.stringify({ type: 'error', error: { message: errMsg } })}\n\n`);
287
401
  res.end();
288
- return;
402
+ } else {
403
+ res.writeHead(500, { 'Content-Type': 'application/json' });
404
+ res.end(JSON.stringify({ error: { type: 'api_error', message: errMsg } }));
289
405
  }
406
+ return;
407
+ }
290
408
 
291
- // 3. content_block_stop
292
- res.write(`event: content_block_stop\ndata: ${JSON.stringify({
293
- type: 'content_block_stop',
294
- index: 0
409
+ // Token accounting
410
+ const inputTokens = (reportedTokens && reportedTokens.input) || Math.ceil(prompt.length / 4);
411
+ const outputTokens = (reportedTokens && reportedTokens.output)
412
+ ? (reportedTokens.output + (reportedTokens.reasoning || 0))
413
+ : Math.ceil(accumulatedText.length / 4);
414
+
415
+ // Extract tool calls
416
+ const { text: cleanText, toolCalls } = extractToolCalls(accumulatedText, allowedTools);
417
+ const hasToolCalls = toolCalls.length > 0;
418
+ const stopReason = hasToolCalls ? 'tool_use' : 'end_turn';
419
+
420
+ if (isStreaming) {
421
+ res.writeHead(200, {
422
+ 'Content-Type': 'text/event-stream; charset=utf-8',
423
+ 'Cache-Control': 'no-cache',
424
+ 'Connection': 'keep-alive'
425
+ });
426
+
427
+ // 1. message_start
428
+ res.write(`event: message_start\ndata: ${JSON.stringify({
429
+ type: 'message_start',
430
+ message: {
431
+ id: messageId,
432
+ type: 'message',
433
+ role: 'assistant',
434
+ model: modelName,
435
+ content: [],
436
+ stop_reason: null,
437
+ stop_sequence: null,
438
+ usage: { input_tokens: inputTokens, output_tokens: 1 }
439
+ }
295
440
  })}\n\n`);
296
441
 
442
+ let blockIndex = 0;
443
+
444
+ // 2. Text block (if clean text exists)
445
+ if (cleanText.length > 0) {
446
+ res.write(`event: content_block_start\ndata: ${JSON.stringify({
447
+ type: 'content_block_start',
448
+ index: blockIndex,
449
+ content_block: { type: 'text', text: '' }
450
+ })}\n\n`);
451
+
452
+ res.write(`event: content_block_delta\ndata: ${JSON.stringify({
453
+ type: 'content_block_delta',
454
+ index: blockIndex,
455
+ delta: { type: 'text_delta', text: cleanText }
456
+ })}\n\n`);
457
+
458
+ res.write(`event: content_block_stop\ndata: ${JSON.stringify({
459
+ type: 'content_block_stop',
460
+ index: blockIndex
461
+ })}\n\n`);
462
+
463
+ blockIndex++;
464
+ }
465
+
466
+ // 3. Tool use blocks
467
+ for (const tc of toolCalls) {
468
+ const toolUseId = `toolu_${crypto.randomBytes(12).toString('hex')}`;
469
+
470
+ res.write(`event: content_block_start\ndata: ${JSON.stringify({
471
+ type: 'content_block_start',
472
+ index: blockIndex,
473
+ content_block: {
474
+ type: 'tool_use',
475
+ id: toolUseId,
476
+ name: tc.name,
477
+ input: {}
478
+ }
479
+ })}\n\n`);
480
+
481
+ res.write(`event: content_block_delta\ndata: ${JSON.stringify({
482
+ type: 'content_block_delta',
483
+ index: blockIndex,
484
+ delta: {
485
+ type: 'input_json_delta',
486
+ partial_json: JSON.stringify(tc.input)
487
+ }
488
+ })}\n\n`);
489
+
490
+ res.write(`event: content_block_stop\ndata: ${JSON.stringify({
491
+ type: 'content_block_stop',
492
+ index: blockIndex
493
+ })}\n\n`);
494
+
495
+ blockIndex++;
496
+ }
497
+
297
498
  // 4. message_delta
298
499
  res.write(`event: message_delta\ndata: ${JSON.stringify({
299
500
  type: 'message_delta',
300
- delta: { stop_reason: 'end_turn', stop_sequence: null },
301
- usage: { output_tokens: Math.ceil(accumulatedOutput.length / 4) }
501
+ delta: { stop_reason: stopReason, stop_sequence: null },
502
+ usage: { output_tokens: outputTokens }
302
503
  })}\n\n`);
303
504
 
304
505
  // 5. message_stop
305
506
  res.write(`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`);
306
507
  res.end();
307
- });
308
-
309
- } else {
310
- // Non-streaming response
311
- child.stdout.on('data', chunk => {
312
- accumulatedOutput += chunk.toString();
313
- });
314
-
315
- child.stderr.on('data', chunk => {
316
- accumulatedError += chunk.toString();
317
- });
318
508
 
319
- child.on('close', code => {
320
- clearTimeout(timer);
321
- if (code !== 0 && !accumulatedOutput.trim()) {
322
- console.error(`[Bridge] OpenCode exited with code ${code}: ${accumulatedError}`);
323
- res.writeHead(500, { 'Content-Type': 'application/json' });
324
- res.end(JSON.stringify({
325
- error: {
326
- type: 'api_error',
327
- message: accumulatedError.trim() || `OpenCode CLI failed with exit code ${code}`
328
- }
329
- }));
330
- return;
509
+ } else {
510
+ // Non-streaming response
511
+ const content = [];
512
+ if (cleanText.length > 0) {
513
+ content.push({ type: 'text', text: cleanText });
514
+ }
515
+ for (const tc of toolCalls) {
516
+ content.push({
517
+ type: 'tool_use',
518
+ id: `toolu_${crypto.randomBytes(12).toString('hex')}`,
519
+ name: tc.name,
520
+ input: tc.input
521
+ });
522
+ }
523
+ if (content.length === 0) {
524
+ content.push({ type: 'text', text: accumulatedText.trim() });
331
525
  }
332
526
 
333
527
  const responsePayload = {
@@ -335,24 +529,19 @@ function handleMessagesRequest(req, res, reqBody) {
335
529
  type: 'message',
336
530
  role: 'assistant',
337
531
  model: modelName,
338
- content: [
339
- {
340
- type: 'text',
341
- text: accumulatedOutput.trim()
342
- }
343
- ],
344
- stop_reason: 'end_turn',
532
+ content,
533
+ stop_reason: stopReason,
345
534
  stop_sequence: null,
346
535
  usage: {
347
- input_tokens: Math.ceil(prompt.length / 4),
348
- output_tokens: Math.ceil(accumulatedOutput.length / 4)
536
+ input_tokens: inputTokens,
537
+ output_tokens: outputTokens
349
538
  }
350
539
  };
351
540
 
352
541
  res.writeHead(200, { 'Content-Type': 'application/json' });
353
542
  res.end(JSON.stringify(responsePayload));
354
- });
355
- }
543
+ }
544
+ });
356
545
  }
357
546
 
358
547
  // HTTP Server