@chatpanel/bridge 0.2.11 → 0.2.12

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/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "type": "module",
5
- "description": "Local bridge that exposes the AI coding agents installed on your machine — Claude Code (Agent SDK), Codex (CLI), and Gemini CLI — to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
5
+ "description": "Local bridge that exposes the AI coding agents installed on your machine — Claude Code (CLI), Codex (CLI), and Gemini CLI — to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
6
6
  "keywords": [
7
7
  "chatpanel",
8
8
  "claude-code",
@@ -36,9 +36,7 @@
36
36
  "dev": "node --watch src/server.js",
37
37
  "build:bin": "bash scripts/build-binaries.sh"
38
38
  },
39
- "dependencies": {
40
- "@anthropic-ai/claude-agent-sdk": "^0.1.0"
41
- },
39
+ "dependencies": {},
42
40
  "publishConfig": {
43
41
  "registry": "https://registry.npmjs.org/",
44
42
  "access": "public"
@@ -1,195 +1,189 @@
1
- // Claude Code engine — embeds the Claude Agent SDK using your *local* Claude
2
- // Code login (or ANTHROPIC_API_KEY). It streams text deltas and surfaces tool
3
- // use so the extension can show what the agent is doing.
1
+ // Claude Code engine — drives the Claude Code CLI (`claude --print`) directly,
2
+ // the SAME way the Codex engine drives `codex exec`. No Agent SDK, no bundled
3
+ // cli.js, no native `sharp` dependency so it works in any environment that has
4
+ // `claude` on PATH (npm install, native installer, or `npx`), and there's nothing
5
+ // to resolve inside a compiled binary (the old "/$bunfs/root/cli.js" failure).
4
6
  //
5
- // By default the agent can READ your code (Read/Grep/Glob/WebFetch) but cannot
6
- // write or run shell commands unless the agent's permissionMode is set to
7
- // 'acceptEdits' or 'bypassPermissions' in ChatPanel Settings. The working
8
- // directory comes from the agent config (defaults to the bridge's cwd).
7
+ // It uses your *local* Claude Code login. By default the agent can READ your code
8
+ // (Read/Grep/Glob/WebFetch/…) but cannot write or run shell commands unless the
9
+ // agent's permissionMode is 'acceptEdits' or 'bypassPermissions' in ChatPanel
10
+ // Settings. The working directory comes from the agent config.
9
11
 
10
- import path from 'node:path';
12
+ import { spawn } from 'node:child_process';
11
13
  import os from 'node:os';
12
- import { existsSync } from 'node:fs';
13
- import { findAgentBin, isCompiledBinary } from '../env.js';
14
-
15
- let sdkPromise = null;
16
- function loadSdk() {
17
- if (!sdkPromise) sdkPromise = import('@anthropic-ai/claude-agent-sdk').catch(() => null);
18
- return sdkPromise;
19
- }
14
+ import path from 'node:path';
15
+ import { findAgentBin } from '../env.js';
20
16
 
21
- // Where the Claude Code CLI lives. The SDK ships a bundled cli.js, but inside a
22
- // compiled binary that file is on a virtual FS that child processes can't reach
23
- // (it fails on Windows as "B:\~BUN\cli.js"). So in a binary we point the SDK at
24
- // the user's INSTALLED Claude Code instead preferring the real cli.js next to
25
- // the npm shim (modern Node won't spawn a .cmd directly).
26
- function claudeExecutable() {
27
- if (process.env.CHATPANEL_CLAUDE_PATH) return process.env.CHATPANEL_CLAUDE_PATH;
28
- if (!isCompiledBinary()) return undefined; // under node/bun the bundled cli.js works
29
- const bin = findAgentBin('claude');
30
- if (!bin) return undefined;
31
- const dir = path.dirname(bin);
32
- // The npm install ships cli.js; the native installer ships cli-wrapper.cjs
33
- // (a JS entry next to a platform binary). Prefer either runnable JS entry over
34
- // the native `claude` binary, since the SDK runs it with its own JS runtime.
35
- const pkgDirs = [
36
- path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code'),
37
- path.join(dir, '..', 'lib', 'node_modules', '@anthropic-ai', 'claude-code'),
38
- // npm global on macOS/Homebrew symlinks bin/claude → ../lib/node_modules/...,
39
- // so dir is already the package's own bin/ in the native install.
40
- path.join(dir, '..'),
41
- ];
42
- for (const p of pkgDirs) {
43
- for (const entry of ['cli.js', 'cli-wrapper.cjs']) {
44
- const c = path.join(p, entry);
45
- if (existsSync(c)) return c;
46
- }
47
- }
48
- return bin;
49
- }
17
+ const TIMEOUT_MS = Number(process.env.CHATPANEL_CLAUDE_TIMEOUT_MS) || 180_000;
18
+ // Read-only tools allowed without approval in headless mode; writes/shell are
19
+ // gated behind the agent's permission mode.
20
+ const READONLY_TOOLS = ['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'TodoWrite', 'Task'];
50
21
 
22
+ let installed = false;
23
+ let lastProbe = 0;
51
24
  export async function available() {
52
- const sdk = await loadSdk();
53
- if (!sdk) {
54
- return { ok: false, reason: 'Agent SDK not installed (npm i in bridge/)' };
55
- }
56
- // In a compiled binary the bundled CLI is unreachable, so Claude Code must be
57
- // installed locally. (Under node/bun the bundled CLI works, so this is skipped.)
58
- if (isCompiledBinary() && !process.env.CHATPANEL_CLAUDE_PATH && !findAgentBin('claude')) {
59
- return {
60
- ok: false,
61
- reason: 'Claude Code not found. Install it (npm i -g @anthropic-ai/claude-code), or run the bridge with `npx @chatpanel/bridge`.',
62
- };
25
+ // Availability = "is claude findable on PATH" (mirrors the codex engine), NOT
26
+ // "does `claude --version` exit 0" (which fails when it just needs login).
27
+ if (!installed && Date.now() - lastProbe > 4000) {
28
+ lastProbe = Date.now();
29
+ try {
30
+ installed = !!findAgentBin('claude');
31
+ } catch {
32
+ installed = false;
33
+ }
63
34
  }
64
- return { ok: true };
35
+ return installed
36
+ ? { ok: true }
37
+ : { ok: false, reason: 'Claude Code not found on PATH. Install it (npm i -g @anthropic-ai/claude-code) and run `claude` once to log in.' };
65
38
  }
66
39
 
67
- const READONLY_TOOLS = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'TodoWrite', 'Task']);
68
-
69
- // Build a single prompt that carries the conversation history. The bridge is
70
- // stateless, so we replay the chat each turn.
40
+ // The bridge is stateless, so we replay the conversation as a single prompt.
71
41
  function buildPrompt(messages) {
72
42
  const history = messages.slice(0, -1);
73
43
  const last = messages[messages.length - 1];
74
44
  let prompt = '';
75
45
  if (history.length) {
76
46
  prompt += 'Conversation so far:\n';
77
- for (const m of history) {
78
- prompt += `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}\n\n`;
79
- }
47
+ for (const m of history) prompt += `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}\n\n`;
80
48
  prompt += '---\n\n';
81
49
  }
82
50
  prompt += last ? last.content : '';
83
51
  return prompt;
84
52
  }
85
53
 
86
- export async function chat({ messages, system, options }, emit) {
87
- const sdk = await loadSdk();
88
- if (!sdk) throw new Error('Claude Agent SDK not installed. Run `npm install` in bridge/.');
89
- const { query } = sdk;
54
+ // Spawn `claude` and stream its stream-json output, forwarding events via `emit`.
55
+ // `extraArgs` lets complete() run a tool-free single shot. Resolves with the final
56
+ // result text once the process closes 0.
57
+ function runClaude({ prompt, args, cwd, emit }) {
58
+ const bin = findAgentBin('claude') || 'claude';
59
+ return new Promise((resolve, reject) => {
60
+ let child;
61
+ try {
62
+ child = spawn(bin, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: process.env });
63
+ } catch (e) {
64
+ return reject(new Error(`Failed to start claude: ${e.message}`));
65
+ }
90
66
 
91
- const permissionMode = options.permissionMode || 'default';
92
- // No project configured → a neutral cwd, so the agent doesn't fixate on
93
- // whatever directory the bridge happens to be running in.
94
- const cwd = options.workingDir ? path.resolve(options.workingDir) : os.homedir();
95
- const writesAllowed = permissionMode === 'acceptEdits' || permissionMode === 'bypassPermissions';
96
-
97
- // Approve read-only tools always; gate writes/shell behind the chosen mode.
98
- const canUseTool = async (toolName) => {
99
- if (READONLY_TOOLS.has(toolName) || writesAllowed) return { behavior: 'allow', updatedInput: undefined };
100
- return { behavior: 'deny', message: `${toolName} blocked — set this agent's permission mode to acceptEdits/bypassPermissions in ChatPanel to enable it.` };
101
- };
102
-
103
- let streamedAny = false;
104
- let resultText = '';
105
-
106
- const iterator = query({
107
- prompt: buildPrompt(messages),
108
- options: {
109
- cwd,
110
- permissionMode,
111
- includePartialMessages: true,
112
- canUseTool,
113
- // Default: load your ~/.claude + project settings so your skills, MCP
114
- // servers and CLAUDE.md apply. Turn the agent's "Use my local skills &
115
- // config" off to run clean.
116
- settingSources: options.useLocalConfig === false ? [] : ['user', 'project'],
117
- // Native Claude Code system prompt. Only append the user's OWN system
118
- // prompt if they set one — no ChatPanel persona is injected, so the agent
119
- // is exactly as capable as it is in the terminal.
120
- systemPrompt: system
121
- ? { type: 'preset', preset: 'claude_code', append: system }
122
- : { type: 'preset', preset: 'claude_code' },
123
- ...(options.model ? { model: options.model } : {}),
124
- ...(claudeExecutable() ? { pathToClaudeCodeExecutable: claudeExecutable() } : {}),
125
- ...(process.env.CHATPANEL_MAX_TURNS ? { maxTurns: Number(process.env.CHATPANEL_MAX_TURNS) } : {}),
126
- },
127
- });
67
+ let stdout = '';
68
+ let stderr = '';
69
+ let streamedAny = false;
70
+ let resultText = '';
71
+
72
+ const timer = setTimeout(() => {
73
+ child.kill('SIGKILL');
74
+ reject(new Error(`Claude Code timed out after ${Math.round(TIMEOUT_MS / 1000)}s.`));
75
+ }, TIMEOUT_MS);
128
76
 
129
- for await (const message of iterator) {
130
- if (message.type === 'stream_event') {
131
- const ev = message.event;
132
- if (ev?.type === 'content_block_delta') {
133
- if (ev.delta?.type === 'text_delta') {
134
- streamedAny = true;
135
- emit({ type: 'delta', text: ev.delta.text });
136
- } else if (ev.delta?.type === 'thinking_delta') {
137
- // Extended thinking — stream the reasoning text to the panel.
138
- emit({ type: 'reasoning', text: ev.delta.thinking || '' });
77
+ child.stdout.on('data', (d) => {
78
+ stdout += d.toString();
79
+ let nl;
80
+ while ((nl = stdout.indexOf('\n')) >= 0) {
81
+ const line = stdout.slice(0, nl).trim();
82
+ stdout = stdout.slice(nl + 1);
83
+ if (!line.startsWith('{')) continue;
84
+ let msg;
85
+ try {
86
+ msg = JSON.parse(line);
87
+ } catch {
88
+ continue; // not a JSON event line
139
89
  }
90
+ const r = handleMessage(msg, emit, streamedAny);
91
+ if (r.streamed) streamedAny = true;
92
+ if (r.result != null) resultText = r.result;
140
93
  }
141
- } else if (message.type === 'assistant') {
142
- for (const block of message.message.content) {
143
- if (block.type === 'tool_use') {
144
- emit({ type: 'tool', name: block.name, summary: toolSummary(block) });
145
- } else if (block.type === 'text' && !streamedAny) {
146
- // Partials were unavailable — stream the whole block.
147
- streamedAny = true;
148
- emit({ type: 'delta', text: block.text });
149
- }
94
+ });
95
+ child.stderr.on('data', (d) => (stderr += d.toString()));
96
+ child.on('error', (e) => {
97
+ clearTimeout(timer);
98
+ reject(e);
99
+ });
100
+ child.on('close', (code) => {
101
+ clearTimeout(timer);
102
+ if (code === 0) resolve({ streamedAny, resultText });
103
+ else reject(new Error(`Claude Code exited ${code}: ${stderr.trim().split('\n').pop() || 'failed'}`));
104
+ });
105
+
106
+ child.stdin.write(prompt);
107
+ child.stdin.end();
108
+ });
109
+ }
110
+
111
+ // Map one stream-json message to emit() calls. Returns { streamed, result }.
112
+ // The CLI's stream-json mirrors the SDK message shapes.
113
+ function handleMessage(msg, emit, alreadyStreamed) {
114
+ const out = { streamed: false, result: null };
115
+ if (msg.type === 'stream_event') {
116
+ const ev = msg.event;
117
+ if (ev?.type === 'content_block_delta') {
118
+ if (ev.delta?.type === 'text_delta') {
119
+ out.streamed = true;
120
+ emit({ type: 'delta', text: ev.delta.text });
121
+ } else if (ev.delta?.type === 'thinking_delta') {
122
+ emit({ type: 'reasoning', text: ev.delta.thinking || '' });
150
123
  }
151
- } else if (message.type === 'result') {
152
- if (message.subtype === 'success') resultText = message.result || '';
153
- else if (message.subtype !== 'success') {
154
- emit({ type: 'status', text: `(${message.subtype})` });
124
+ }
125
+ } else if (msg.type === 'assistant') {
126
+ for (const block of msg.message?.content || []) {
127
+ if (block.type === 'tool_use') {
128
+ emit({ type: 'tool', name: block.name, summary: toolSummary(block) });
129
+ } else if (block.type === 'text' && !alreadyStreamed) {
130
+ out.streamed = true;
131
+ emit({ type: 'delta', text: block.text });
155
132
  }
156
133
  }
134
+ } else if (msg.type === 'result') {
135
+ if (msg.subtype === 'success') out.result = msg.result || '';
136
+ else emit({ type: 'status', text: `(${msg.subtype})` });
157
137
  }
138
+ return out;
139
+ }
140
+
141
+ export async function chat({ messages, system, options }, emit) {
142
+ const permissionMode = options.permissionMode || 'default';
143
+ const cwd = options.workingDir ? path.resolve(options.workingDir) : os.homedir();
144
+
145
+ const args = ['--print', '--output-format', 'stream-json', '--include-partial-messages', '--verbose'];
146
+
147
+ // Gate writes/shell behind the chosen mode; otherwise restrict to read-only
148
+ // tools so headless runs never block on an approval prompt.
149
+ if (permissionMode === 'bypassPermissions') args.push('--permission-mode', 'bypassPermissions');
150
+ else if (permissionMode === 'acceptEdits') args.push('--permission-mode', 'acceptEdits');
151
+ else args.push('--allowedTools', ...READONLY_TOOLS);
152
+
153
+ // Native Claude Code behavior; append the user's own system prompt if they set
154
+ // one (no ChatPanel persona injected).
155
+ if (system) args.push('--append-system-prompt', system);
156
+ if (options.model) args.push('--model', options.model);
157
+ // Default loads your ~/.claude + project settings (skills, MCP, CLAUDE.md).
158
+ // "Use my local skills & config" off → run clean.
159
+ if (options.useLocalConfig === false) args.push('--setting-sources', '');
158
160
 
161
+ const { streamedAny, resultText } = await runClaude({ prompt: buildPrompt(messages), args, cwd, emit });
159
162
  emit({ type: 'done', text: streamedAny ? '' : resultText });
160
163
  }
161
164
 
162
165
  // A fast, tool-free single-shot completion — used for prompt autocomplete. No
163
- // claude_code preset, no tools, no local config: just a quick text continuation
164
- // from a fast model (Haiku by default). Returns the completion string.
166
+ // tools, no local config: just a quick text continuation from a fast model.
165
167
  export async function complete({ prompt, system, model }) {
166
- const sdk = await loadSdk();
167
- if (!sdk) throw new Error('Claude Agent SDK not installed.');
168
- const { query } = sdk;
168
+ const args = [
169
+ '--print',
170
+ '--output-format', 'stream-json',
171
+ '--verbose',
172
+ '--disallowedTools', 'Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'Bash', 'Edit', 'Write', 'Task', 'TodoWrite',
173
+ '--setting-sources', '',
174
+ '--model', model || 'haiku',
175
+ '--system-prompt', system || "Continue the user's text briefly. Reply with only the continuation.",
176
+ ];
169
177
  let text = '';
170
- const iterator = query({
178
+ const { resultText } = await runClaude({
171
179
  prompt,
172
- options: {
173
- cwd: os.homedir(),
174
- permissionMode: 'default',
175
- allowedTools: [], // no tools pure text completion
176
- maxTurns: 1,
177
- settingSources: [], // skip CLAUDE.md / MCP for a tiny completion
178
- systemPrompt: system || "Continue the user's text briefly. Reply with only the continuation.",
179
- model: model || 'haiku',
180
- ...(claudeExecutable() ? { pathToClaudeCodeExecutable: claudeExecutable() } : {}),
180
+ args,
181
+ cwd: os.homedir(),
182
+ emit: (e) => {
183
+ if (e.type === 'delta') text += e.text;
181
184
  },
182
185
  });
183
- for await (const message of iterator) {
184
- if (message.type === 'assistant') {
185
- for (const block of message.message.content) {
186
- if (block.type === 'text') text += block.text;
187
- }
188
- } else if (message.type === 'result' && message.subtype === 'success' && !text) {
189
- text = message.result || '';
190
- }
191
- }
192
- return text.trim();
186
+ return (text || resultText || '').trim();
193
187
  }
194
188
 
195
189
  function toolSummary(block) {
package/src/server.js CHANGED
@@ -22,7 +22,7 @@ import * as gemini from './engines/gemini.js';
22
22
  import { installService, uninstallService, serviceStatus } from './service.js';
23
23
  import { enrichPath, findAgentBin } from './env.js';
24
24
 
25
- const VERSION = '0.2.10';
25
+ const VERSION = '0.2.12';
26
26
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
27
27
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
28
28