@empir3/empir3-bridge 0.3.21
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 +1531 -0
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +75 -0
- package/LICENSE +21 -0
- package/README.md +464 -0
- package/SECURITY.md +130 -0
- package/assets/accuracy-lab.html +2639 -0
- package/assets/api-clis-real.jpg +0 -0
- package/assets/bridge-console-hero.jpg +0 -0
- package/assets/browser-privacy.svg +151 -0
- package/assets/demo-orchestration.svg +74 -0
- package/assets/desktop-select-region.jpg +0 -0
- package/assets/in-page-chat.gif +0 -0
- package/assets/orchestration-hero.svg +126 -0
- package/assets/social-preview.png +0 -0
- package/assets/zara-accent.png +0 -0
- package/build/bootstrap.js +548 -0
- package/build/build.js +680 -0
- package/build/payload-entry.js +649 -0
- package/build/payload-signing-pub.json +7 -0
- package/docs/AGENT_GUIDE.md +259 -0
- package/docs/RELEASE.md +106 -0
- package/docs/SAFETY.md +112 -0
- package/docs/TESTING.md +181 -0
- package/installer/server.js +231 -0
- package/installer/ui/app.js +278 -0
- package/installer/ui/index.html +24 -0
- package/installer/ui/styles.css +146 -0
- package/package.json +95 -0
- package/scripts/bootstrap-e2e.mjs +650 -0
- package/scripts/certify-bridge.mjs +636 -0
- package/scripts/check-companion-surface.mjs +118 -0
- package/scripts/extract-welcome.mjs +64 -0
- package/scripts/gh-route-handler-check.mjs +57 -0
- package/scripts/gh-wire-test.mjs +107 -0
- package/scripts/publish-downloads.mjs +180 -0
- package/scripts/smoke-all-tools.mjs +509 -0
- package/scripts/smoke-live-bridge.mjs +696 -0
- package/scripts/splice-welcome.mjs +63 -0
- package/scripts/welcome-body.txt +2733 -0
- package/src/anthropic-client.ts +192 -0
- package/src/bootstrap-exe.ts +69 -0
- package/src/bridge.ts +2444 -0
- package/src/chat.ts +345 -0
- package/src/cli-runner.ts +239 -0
- package/src/cli.ts +649 -0
- package/src/config.ts +199 -0
- package/src/desktop-overlay.ps1 +121 -0
- package/src/executable-resolver.ts +330 -0
- package/src/handlers/agy-imagegen.ts +179 -0
- package/src/handlers/github-cli.ts +399 -0
- package/src/handlers/higgsfield-cli.ts +783 -0
- package/src/launch.js +337 -0
- package/src/mcp-server.ts +1265 -0
- package/src/pair-claim.ts +218 -0
- package/src/payload-daemon.ts +168 -0
- package/src/server.ts +21036 -0
- package/src/tool-defaults.ts +230 -0
- package/src/update-check.js +136 -0
- package/tray/build.py +76 -0
- package/tray/requirements.txt +2 -0
- package/tray/tray.py +1843 -0
package/src/chat.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat orchestration — picks a runner (api | cli), runs the tool-use
|
|
3
|
+
* loop, persists every turn, yields a unified event stream the HTTP/SSE
|
|
4
|
+
* layer can serialize to the overlay.
|
|
5
|
+
*
|
|
6
|
+
* Responsibilities split:
|
|
7
|
+
* - anthropic-client.ts streams raw model events
|
|
8
|
+
* - cli-runner.ts streams the same shape from a `claude` subprocess
|
|
9
|
+
* - this file picks one, drives the tool-use loop in API mode, and
|
|
10
|
+
* dispatches tool calls back through the bridge's HTTP API
|
|
11
|
+
*
|
|
12
|
+
* Tool-use loop (API mode only):
|
|
13
|
+
* 1. Send messages + tools to the API, stream events
|
|
14
|
+
* 2. On tool_use_start → dispatch via /api/command, capture result
|
|
15
|
+
* 3. After message_end, if stop_reason === 'tool_use':
|
|
16
|
+
* append assistant turn + tool_result turn to messages, restart
|
|
17
|
+
* 4. Loop caps at config.maxLoopIterations
|
|
18
|
+
*
|
|
19
|
+
* CLI mode currently runs without tool-use (see cli-runner.ts header).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync } from 'fs';
|
|
23
|
+
import { join } from 'path';
|
|
24
|
+
import { homedir } from 'os';
|
|
25
|
+
import { randomUUID } from 'crypto';
|
|
26
|
+
import { streamMessages, type AnthropicMessage, type AnthropicTool, type StreamEvent } from './anthropic-client.js';
|
|
27
|
+
import { streamCli } from './cli-runner.js';
|
|
28
|
+
import { loadConfig, type BridgeConfig, configReady } from './config.js';
|
|
29
|
+
import { TOOL_META } from './tool-defaults.js';
|
|
30
|
+
|
|
31
|
+
const CONV_DIR = join(homedir(), '.empir3-bridge', 'conversations');
|
|
32
|
+
|
|
33
|
+
// ── Public chat-event shape (what the server SSE relays) ─────────
|
|
34
|
+
|
|
35
|
+
export type ChatEvent =
|
|
36
|
+
| { type: 'message_start'; conversationId: string; role: 'assistant' }
|
|
37
|
+
| { type: 'text_delta'; text: string }
|
|
38
|
+
| { type: 'tool_use'; id: string; name: string; input: unknown }
|
|
39
|
+
| { type: 'tool_result'; id: string; name: string; ok: boolean; output: string }
|
|
40
|
+
| { type: 'usage'; inputTokens: number; outputTokens: number }
|
|
41
|
+
| { type: 'message_end'; stopReason: string | null; iterations: number }
|
|
42
|
+
| { type: 'error'; message: string };
|
|
43
|
+
|
|
44
|
+
export interface StreamChatRequest {
|
|
45
|
+
messages: AnthropicMessage[];
|
|
46
|
+
conversationId?: string;
|
|
47
|
+
modeOverride?: 'api' | 'cli';
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
bridgeBaseUrl?: string; // default localhost:<bridgePort> at server start; injected by server.ts
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DEFAULT_SYSTEM_PROMPT =
|
|
53
|
+
'You are Claude, running inside a local browser-bridge daemon. The user is viewing a web page in a Chrome window controlled by this bridge. ' +
|
|
54
|
+
'When you need to see or interact with the page, call the appropriate browser_* tool. Prefer browser_snapshot over browser_screenshot — it returns the accessibility tree with element refs (e0, e1, etc) which are cheaper and more reliable than coordinates. ' +
|
|
55
|
+
'Be concise. Only act when the user actually needs an action.';
|
|
56
|
+
|
|
57
|
+
// ── Tool input schemas mirror src/mcp-server.ts so what the model
|
|
58
|
+
// sees in API mode is identical to what Claude Code sees through MCP.
|
|
59
|
+
|
|
60
|
+
const TOOL_SCHEMAS: Record<string, AnthropicTool['input_schema']> = {
|
|
61
|
+
bridge_overlay_reinject: { type: 'object', properties: {} },
|
|
62
|
+
browser_status: { type: 'object', properties: {} },
|
|
63
|
+
browser_text: { type: 'object', properties: {} },
|
|
64
|
+
browser_screenshot: { type: 'object', properties: {} },
|
|
65
|
+
desktop_monitors: { type: 'object', properties: {} },
|
|
66
|
+
desktop_screenshot: { type: 'object', properties: { monitor: { type: 'string', description: 'all, primary, DISPLAY1, DISPLAY2, or full device name. Default: all' } } },
|
|
67
|
+
browser_snapshot: { type: 'object', properties: { filter: { type: 'string', enum: ['interactive', 'all'], description: 'Default: interactive' } } },
|
|
68
|
+
browser_navigate: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] },
|
|
69
|
+
browser_scroll: { type: 'object', properties: { y: { type: 'number', description: 'Vertical pixels (positive=down, negative=up)' }, x: { type: 'number' } }, required: ['y'] },
|
|
70
|
+
browser_refresh: { type: 'object', properties: {} },
|
|
71
|
+
browser_click: { type: 'object', properties: { selector: { type: 'string' } }, required: ['selector'] },
|
|
72
|
+
browser_click_ref: { type: 'object', properties: { ref: { type: 'string', description: 'Element ref from snapshot (e.g. "e5")' } }, required: ['ref'] },
|
|
73
|
+
browser_click_xy: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' } }, required: ['x', 'y'] },
|
|
74
|
+
desktop_click: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' }, monitor: { type: 'string', description: 'Optional monitor id. When supplied, x/y are monitor-relative.' }, double: { type: 'boolean' }, button: { type: 'string', enum: ['left', 'right', 'middle'] } }, required: ['x', 'y'] },
|
|
75
|
+
desktop_hover: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' }, monitor: { type: 'string', description: 'Optional monitor id. When supplied, x/y are monitor-relative.' } }, required: ['x', 'y'] },
|
|
76
|
+
desktop_drag: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' }, toX: { type: 'number' }, toY: { type: 'number' }, monitor: { type: 'string', description: 'Optional monitor id. When supplied, both endpoints are monitor-relative.' }, durationMs: { type: 'number' }, steps: { type: 'number' }, button: { type: 'string', enum: ['left', 'right', 'middle'] } }, required: ['x', 'y', 'toX', 'toY'] },
|
|
77
|
+
browser_type: { type: 'object', properties: { selector: { type: 'string' }, text: { type: 'string' } }, required: ['selector', 'text'] },
|
|
78
|
+
browser_type_ref: { type: 'object', properties: { ref: { type: 'string' }, text: { type: 'string' } }, required: ['ref', 'text'] },
|
|
79
|
+
browser_press: { type: 'object', properties: { key: { type: 'string', description: 'e.g. "Enter", "Tab", "Control+a"' } }, required: ['key'] },
|
|
80
|
+
browser_highlight: { type: 'object', properties: { selector: { type: 'string' } }, required: ['selector'] },
|
|
81
|
+
browser_evaluate: { type: 'object', properties: { script: { type: 'string', description: 'JS expression to evaluate on the page' } }, required: ['script'] },
|
|
82
|
+
browser_chat: { type: 'object', properties: { message: { type: 'string' } }, required: ['message'] },
|
|
83
|
+
browser_read_chat: { type: 'object', properties: { limit: { type: 'number' } } },
|
|
84
|
+
browser_record_start: { type: 'object', properties: {} },
|
|
85
|
+
browser_record_stop: { type: 'object', properties: { name: { type: 'string' } } },
|
|
86
|
+
browser_play: { type: 'object', properties: { recording: { type: 'string' }, speed: { type: 'number' }, variables: { type: 'object' } }, required: ['recording'] },
|
|
87
|
+
browser_recordings: { type: 'object', properties: {} },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function buildToolDefs(cfg: BridgeConfig): AnthropicTool[] {
|
|
91
|
+
const tools: AnthropicTool[] = [];
|
|
92
|
+
for (const meta of TOOL_META) {
|
|
93
|
+
if (!cfg.enabledTools[meta.name]) continue;
|
|
94
|
+
const schema = TOOL_SCHEMAS[meta.name];
|
|
95
|
+
if (!schema) continue;
|
|
96
|
+
tools.push({ name: meta.name, description: meta.blurb, input_schema: schema });
|
|
97
|
+
}
|
|
98
|
+
return tools;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Bridge-side dispatch ────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
async function dispatchTool(name: string, input: any, bridgeBaseUrl: string): Promise<{ ok: boolean; output: string }> {
|
|
104
|
+
// /api/command wraps responses as { ok, result } | { ok:false, error }.
|
|
105
|
+
// Unwrap to result so dispatch sites can read fields directly.
|
|
106
|
+
const post = async (cmd: any) => {
|
|
107
|
+
const r = await fetch(`${bridgeBaseUrl}/api/command`, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: { 'Content-Type': 'application/json' },
|
|
110
|
+
body: JSON.stringify(cmd),
|
|
111
|
+
});
|
|
112
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text().catch(() => '')}`);
|
|
113
|
+
const env = await r.json();
|
|
114
|
+
if (env && typeof env === 'object' && 'ok' in env) {
|
|
115
|
+
if (!env.ok) throw new Error(env.error || 'command failed');
|
|
116
|
+
return env.result ?? {};
|
|
117
|
+
}
|
|
118
|
+
return env;
|
|
119
|
+
};
|
|
120
|
+
const get = async (path: string) => {
|
|
121
|
+
const r = await fetch(`${bridgeBaseUrl}${path}`);
|
|
122
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
123
|
+
return r.json();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
switch (name) {
|
|
128
|
+
case 'browser_status': return ok(JSON.stringify(await get('/api/status'), null, 2));
|
|
129
|
+
case 'bridge_overlay_reinject': return ok(JSON.stringify(await post({ type: 'overlay_reinject', reason: 'chat' }), null, 2));
|
|
130
|
+
case 'browser_text': return ok((await post({ type: 'text' })).text || '(no text)');
|
|
131
|
+
case 'browser_snapshot': {
|
|
132
|
+
const r = await post({ type: 'snapshot', filter: input?.filter || 'interactive', format: 'compact' });
|
|
133
|
+
return ok(typeof r.snapshot === 'string' ? r.snapshot : JSON.stringify(r.snapshot, null, 2));
|
|
134
|
+
}
|
|
135
|
+
case 'browser_screenshot': {
|
|
136
|
+
const r = await fetch(`${bridgeBaseUrl}/api/screenshot?quality=50`);
|
|
137
|
+
if (!r.ok) return fail(`screenshot HTTP ${r.status}`);
|
|
138
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
139
|
+
return ok(`[screenshot captured: ${buf.byteLength} bytes JPEG]`);
|
|
140
|
+
}
|
|
141
|
+
case 'desktop_monitors': return ok(JSON.stringify(await post({ type: 'desktop_monitors' }), null, 2));
|
|
142
|
+
case 'desktop_screenshot': return ok(JSON.stringify(await post({ type: 'desktop_screenshot', monitor: input?.monitor || 'all' }), null, 2));
|
|
143
|
+
case 'browser_navigate': return ok(`Navigated to: ${(await post({ type: 'navigate', url: input.url })).url}`);
|
|
144
|
+
case 'browser_scroll': {
|
|
145
|
+
const r = await post({ type: 'scroll', x: input.x || 0, y: input.y });
|
|
146
|
+
return ok(JSON.stringify({ requested: r.scrolled, moved: r.moved, position: r.position, scroll: r.scroll }, null, 2));
|
|
147
|
+
}
|
|
148
|
+
case 'browser_refresh': { await post({ type: 'refresh' }); return ok('Page refreshed'); }
|
|
149
|
+
case 'browser_click': { await post({ type: 'click', selector: input.selector }); return ok(`Clicked: ${input.selector}`); }
|
|
150
|
+
case 'browser_click_ref': { await post({ type: 'click_ref', ref: input.ref }); return ok(`Clicked ref: ${input.ref}`); }
|
|
151
|
+
case 'browser_click_xy': { await post({ type: 'click_xy', x: input.x, y: input.y }); return ok(`Clicked coordinates: ${input.x},${input.y}`); }
|
|
152
|
+
case 'desktop_click': {
|
|
153
|
+
const r = await post({ type: 'desktop_click', x: input.x, y: input.y, monitor: input.monitor, space: input.monitor ? 'monitor' : 'desktop', double: !!input.double, button: input.button || 'left' });
|
|
154
|
+
return ok(JSON.stringify(r, null, 2));
|
|
155
|
+
}
|
|
156
|
+
case 'desktop_hover': {
|
|
157
|
+
const r = await post({ type: 'desktop_hover', x: input.x, y: input.y, monitor: input.monitor, space: input.monitor ? 'monitor' : 'desktop' });
|
|
158
|
+
return ok(JSON.stringify(r, null, 2));
|
|
159
|
+
}
|
|
160
|
+
case 'desktop_drag': {
|
|
161
|
+
const r = await post({ type: 'desktop_drag', x: input.x, y: input.y, toX: input.toX, toY: input.toY, monitor: input.monitor, space: input.monitor ? 'monitor' : 'desktop', durationMs: input.durationMs, steps: input.steps, button: input.button || 'left' });
|
|
162
|
+
return ok(JSON.stringify(r, null, 2));
|
|
163
|
+
}
|
|
164
|
+
case 'browser_type': { await post({ type: 'type', selector: input.selector, text: input.text }); return ok(`Typed into ${input.selector}`); }
|
|
165
|
+
case 'browser_type_ref': { await post({ type: 'type_ref', ref: input.ref, text: input.text }); return ok(`Typed into ref:${input.ref}`); }
|
|
166
|
+
case 'browser_press': { await post({ type: 'press', text: input.key }); return ok(`Pressed: ${input.key}`); }
|
|
167
|
+
case 'browser_highlight': { await post({ type: 'highlight', selector: input.selector }); return ok(`Highlighted: ${input.selector}`); }
|
|
168
|
+
case 'browser_evaluate': return ok(JSON.stringify(await post({ type: 'evaluate', script: input.script }), null, 2));
|
|
169
|
+
case 'browser_chat': { await post({ type: 'chat', message: input.message }); return ok(`Sent to overlay: ${input.message}`); }
|
|
170
|
+
case 'browser_read_chat': {
|
|
171
|
+
const messages = await get('/api/chat');
|
|
172
|
+
const limit = typeof input?.limit === 'number' ? input.limit : 20;
|
|
173
|
+
const recent = (messages as any[]).slice(-limit);
|
|
174
|
+
if (recent.length === 0) return ok('(no messages)');
|
|
175
|
+
return ok(recent.map(m => `[${m.from}] ${m.text}`).join('\n'));
|
|
176
|
+
}
|
|
177
|
+
case 'browser_record_start': return ok(`Recording started at ${(await post({ type: 'record_start' })).startUrl}`);
|
|
178
|
+
case 'browser_record_stop': {
|
|
179
|
+
const r = await post({ type: 'record_stop', text: input?.name });
|
|
180
|
+
return ok(`Saved ${r.saved} (${r.actionCount} actions, ${(r.duration / 1000).toFixed(1)}s)`);
|
|
181
|
+
}
|
|
182
|
+
case 'browser_play': {
|
|
183
|
+
const r = await post({ type: 'play', recording: input.recording, speed: input.speed || 1, variables: input.variables || {} });
|
|
184
|
+
return ok(`Playback: ${r.passed}/${r.total} passed, ${r.failed} failed`);
|
|
185
|
+
}
|
|
186
|
+
case 'browser_recordings': {
|
|
187
|
+
const list = await get('/api/recordings') as any[];
|
|
188
|
+
return ok(list.length === 0 ? '(no recordings)' : list.map(r => `${r.name} (${r.actionCount} actions, ${(r.duration / 1000).toFixed(1)}s)`).join('\n'));
|
|
189
|
+
}
|
|
190
|
+
default: return fail(`Unknown tool: ${name}`);
|
|
191
|
+
}
|
|
192
|
+
} catch (e: any) {
|
|
193
|
+
return fail(e?.message || String(e));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const ok = (output: string) => ({ ok: true, output });
|
|
197
|
+
const fail = (output: string) => ({ ok: false, output });
|
|
198
|
+
|
|
199
|
+
// ── Persistence ────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
function ensureConvDir() {
|
|
202
|
+
if (!existsSync(CONV_DIR)) mkdirSync(CONV_DIR, { recursive: true });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function persistTurn(conversationId: string, entry: any) {
|
|
206
|
+
try {
|
|
207
|
+
ensureConvDir();
|
|
208
|
+
appendFileSync(join(CONV_DIR, `${conversationId}.jsonl`), JSON.stringify({ ...entry, ts: new Date().toISOString() }) + '\n');
|
|
209
|
+
} catch { /* swallow — persistence is best-effort */ }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function listConversations(): Array<{ id: string; size: number; mtime: string }> {
|
|
213
|
+
try {
|
|
214
|
+
ensureConvDir();
|
|
215
|
+
const files = readdirSync(CONV_DIR).filter(f => f.endsWith('.jsonl'));
|
|
216
|
+
return files.map(f => {
|
|
217
|
+
const stat = require('fs').statSync(join(CONV_DIR, f));
|
|
218
|
+
return { id: f.replace(/\.jsonl$/, ''), size: stat.size, mtime: stat.mtime.toISOString() };
|
|
219
|
+
}).sort((a, b) => b.mtime.localeCompare(a.mtime));
|
|
220
|
+
} catch { return []; }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function readConversation(id: string): any[] {
|
|
224
|
+
try {
|
|
225
|
+
const path = join(CONV_DIR, `${id}.jsonl`);
|
|
226
|
+
if (!existsSync(path)) return [];
|
|
227
|
+
return readFileSync(path, 'utf-8')
|
|
228
|
+
.split('\n')
|
|
229
|
+
.filter(l => l.trim())
|
|
230
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
231
|
+
.filter(Boolean);
|
|
232
|
+
} catch { return []; }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Main entry ─────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
export async function* streamChat(req: StreamChatRequest): AsyncGenerator<ChatEvent> {
|
|
238
|
+
const cfg = loadConfig();
|
|
239
|
+
const ready = configReady(cfg);
|
|
240
|
+
if (!ready.ready) { yield { type: 'error', message: ready.reason || 'Config not ready' }; return; }
|
|
241
|
+
|
|
242
|
+
const mode = req.modeOverride || cfg.mode;
|
|
243
|
+
const conversationId = req.conversationId || randomUUID();
|
|
244
|
+
const bridgeBaseUrl = req.bridgeBaseUrl || `http://localhost:3006`;
|
|
245
|
+
const system = cfg.systemPrompt || DEFAULT_SYSTEM_PROMPT;
|
|
246
|
+
const tools = buildToolDefs(cfg);
|
|
247
|
+
|
|
248
|
+
yield { type: 'message_start', conversationId, role: 'assistant' };
|
|
249
|
+
|
|
250
|
+
// Persist the user's most recent turn (if any) so the conversation
|
|
251
|
+
// log lines up with the prompt that produced this stream.
|
|
252
|
+
const lastUser = [...req.messages].reverse().find(m => m.role === 'user');
|
|
253
|
+
if (lastUser) persistTurn(conversationId, { role: 'user', content: lastUser.content });
|
|
254
|
+
|
|
255
|
+
// CLI mode: no tool-use loop in v0.1.0. Also no multi-turn history —
|
|
256
|
+
// the `claude` CLI treats each input line as its own turn and responds
|
|
257
|
+
// separately, which produces N replies instead of one when we replay
|
|
258
|
+
// a conversation. Until v0.1.1 wires up --resume <session-id>, we send
|
|
259
|
+
// only the latest user message and rely on the CLI for that single turn.
|
|
260
|
+
if (mode === 'cli') {
|
|
261
|
+
const latestUser = [...req.messages].reverse().find(m => m.role === 'user');
|
|
262
|
+
const cliMessages = latestUser ? [latestUser] : req.messages;
|
|
263
|
+
let assistantText = '';
|
|
264
|
+
for await (const ev of streamCli({
|
|
265
|
+
cliPath: cfg.claudeCliPath,
|
|
266
|
+
model: cfg.model,
|
|
267
|
+
system,
|
|
268
|
+
messages: cliMessages,
|
|
269
|
+
signal: req.signal,
|
|
270
|
+
})) {
|
|
271
|
+
if (ev.type === 'text_delta') { assistantText += ev.text; yield ev; }
|
|
272
|
+
else if (ev.type === 'usage') yield ev;
|
|
273
|
+
else if (ev.type === 'message_end') yield { type: 'message_end', stopReason: ev.stopReason, iterations: 1 };
|
|
274
|
+
else if (ev.type === 'error') yield ev;
|
|
275
|
+
}
|
|
276
|
+
if (assistantText) persistTurn(conversationId, { role: 'assistant', content: assistantText, mode: 'cli' });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// API mode with tool-use loop.
|
|
281
|
+
const messages: AnthropicMessage[] = [...req.messages];
|
|
282
|
+
let iter = 0;
|
|
283
|
+
while (iter < cfg.maxLoopIterations) {
|
|
284
|
+
iter++;
|
|
285
|
+
let assistantText = '';
|
|
286
|
+
const toolUses: Array<{ id: string; name: string; input: any }> = [];
|
|
287
|
+
let lastStopReason: string | null = null;
|
|
288
|
+
let inputTokens = 0;
|
|
289
|
+
let outputTokens = 0;
|
|
290
|
+
|
|
291
|
+
for await (const ev of streamMessages({
|
|
292
|
+
apiKey: cfg.anthropicApiKey,
|
|
293
|
+
model: cfg.model,
|
|
294
|
+
maxTokens: cfg.maxTokens,
|
|
295
|
+
system,
|
|
296
|
+
messages,
|
|
297
|
+
tools,
|
|
298
|
+
signal: req.signal,
|
|
299
|
+
})) {
|
|
300
|
+
if (ev.type === 'text_delta') { assistantText += ev.text; yield ev; }
|
|
301
|
+
else if (ev.type === 'tool_use_start') {
|
|
302
|
+
toolUses.push({ id: ev.id, name: ev.name, input: ev.input });
|
|
303
|
+
yield { type: 'tool_use', id: ev.id, name: ev.name, input: ev.input };
|
|
304
|
+
}
|
|
305
|
+
else if (ev.type === 'usage') { inputTokens = ev.inputTokens; outputTokens = ev.outputTokens; yield ev; }
|
|
306
|
+
else if (ev.type === 'message_end') { lastStopReason = ev.stopReason; }
|
|
307
|
+
else if (ev.type === 'error') { yield ev; persistTurn(conversationId, { role: 'error', message: ev.message }); return; }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Append assistant's turn to history. Preserve tool_use blocks so the
|
|
311
|
+
// next call has a complete record.
|
|
312
|
+
const assistantContent: any[] = [];
|
|
313
|
+
if (assistantText) assistantContent.push({ type: 'text', text: assistantText });
|
|
314
|
+
for (const tu of toolUses) assistantContent.push({ type: 'tool_use', id: tu.id, name: tu.name, input: tu.input });
|
|
315
|
+
if (assistantContent.length > 0) {
|
|
316
|
+
messages.push({ role: 'assistant', content: assistantContent });
|
|
317
|
+
persistTurn(conversationId, { role: 'assistant', content: assistantContent, mode: 'api', stopReason: lastStopReason, inputTokens, outputTokens, iter });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (lastStopReason !== 'tool_use' || toolUses.length === 0) {
|
|
321
|
+
yield { type: 'message_end', stopReason: lastStopReason, iterations: iter };
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Defense-in-depth: refuse any tool name that's not in the enabled list.
|
|
326
|
+
// (anthropic-client should never emit one because we filter the tools
|
|
327
|
+
// array, but trust nothing.)
|
|
328
|
+
const toolResults: any[] = [];
|
|
329
|
+
for (const tu of toolUses) {
|
|
330
|
+
let result: { ok: boolean; output: string };
|
|
331
|
+
if (!cfg.enabledTools[tu.name]) {
|
|
332
|
+
result = { ok: false, output: `Tool ${tu.name} is disabled in bridge settings (localhost:3006/settings).` };
|
|
333
|
+
} else {
|
|
334
|
+
result = await dispatchTool(tu.name, tu.input, bridgeBaseUrl);
|
|
335
|
+
}
|
|
336
|
+
yield { type: 'tool_result', id: tu.id, name: tu.name, ok: result.ok, output: result.output };
|
|
337
|
+
toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: result.output, ...(result.ok ? {} : { is_error: true }) });
|
|
338
|
+
persistTurn(conversationId, { role: 'tool_result', toolUseId: tu.id, name: tu.name, ok: result.ok, output: result.output });
|
|
339
|
+
}
|
|
340
|
+
messages.push({ role: 'user', content: toolResults });
|
|
341
|
+
// Loop continues with the tool results in context.
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
yield { type: 'error', message: `Tool-use loop exceeded ${cfg.maxLoopIterations} iterations` };
|
|
345
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude CLI runner — spawns the user's `claude` binary in stream-json
|
|
3
|
+
* mode and translates its stdout into the same StreamEvent shape that
|
|
4
|
+
* anthropic-client emits. So chat.ts can call either runner without
|
|
5
|
+
* branching on mode beyond the entry point.
|
|
6
|
+
*
|
|
7
|
+
* Why subprocess instead of API: a user with Claude Max already pays
|
|
8
|
+
* Anthropic for inference. Routing through their CLI means they don't
|
|
9
|
+
* pay twice.
|
|
10
|
+
*
|
|
11
|
+
* Tool integration: NOT wired here. v0.1.0 CLI mode = plain chat. To
|
|
12
|
+
* add browser tools to the CLI session, pass `--mcp-config <path>`
|
|
13
|
+
* pointing at this bridge's MCP server (deferred to v0.1.1 — the MCP
|
|
14
|
+
* shim already exists at dist/mcp-server.cjs, we just need a temp
|
|
15
|
+
* config file generator that filters by enabledTools). API mode in
|
|
16
|
+
* chat.ts runs the full tool-use loop today.
|
|
17
|
+
*
|
|
18
|
+
* Wave 2 reuse: this same module is the pattern Wave 2 M2.2 uses to
|
|
19
|
+
* route Empir3-server-driven turns through the user's local CLI.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
|
|
23
|
+
import type { AnthropicMessage, StreamEvent } from './anthropic-client.js';
|
|
24
|
+
|
|
25
|
+
export interface CliStreamRequest {
|
|
26
|
+
cliPath: string;
|
|
27
|
+
model?: string;
|
|
28
|
+
system?: string;
|
|
29
|
+
messages: AnthropicMessage[];
|
|
30
|
+
signal?: AbortSignal;
|
|
31
|
+
cwd?: string;
|
|
32
|
+
extraArgs?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SIGTERM_GRACE_MS = 5000;
|
|
36
|
+
|
|
37
|
+
export async function* streamCli(req: CliStreamRequest): AsyncGenerator<StreamEvent> {
|
|
38
|
+
const args = ['--print', '--output-format', 'stream-json', '--input-format', 'stream-json', '--verbose'];
|
|
39
|
+
if (req.model) args.push('--model', req.model);
|
|
40
|
+
if (req.extraArgs && req.extraArgs.length) args.push(...req.extraArgs);
|
|
41
|
+
|
|
42
|
+
// Build the input turn. The CLI's stream-json input format takes one JSON
|
|
43
|
+
// line per turn — system+messages are flattened into a single user line
|
|
44
|
+
// for simple chat. Multi-turn history is replayed as separate lines.
|
|
45
|
+
const stdinPayload = buildStreamJsonInput(req);
|
|
46
|
+
|
|
47
|
+
// On Windows, the npm-installed `claude` ships as both a bare unix shim
|
|
48
|
+
// and a `.cmd` batch shim. Node's spawn can only execute the `.cmd` shim
|
|
49
|
+
// directly. If the saved config still points at a path without an
|
|
50
|
+
// extension, transparently rewrite to the `.cmd` companion.
|
|
51
|
+
let cliPath = req.cliPath;
|
|
52
|
+
if (process.platform === 'win32' && !/\.(cmd|exe|bat|ps1)$/i.test(cliPath)) {
|
|
53
|
+
const fs = require('fs') as typeof import('fs');
|
|
54
|
+
if (fs.existsSync(cliPath + '.cmd')) cliPath = cliPath + '.cmd';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let child: ChildProcessWithoutNullStreams;
|
|
58
|
+
try {
|
|
59
|
+
// Node 18.20+/20.12+ refuse to spawn `.cmd`/`.bat` directly on Windows
|
|
60
|
+
// for security (CVE-2024-27980). The fix: spawn cmd.exe directly (an
|
|
61
|
+
// .exe, so no CVE applies) and pass the .cmd path as a properly-escaped
|
|
62
|
+
// arg. `shell: true` + quoted command is NOT used here because cmd.exe
|
|
63
|
+
// misinterprets the double-quote + backslash combo in Windows paths,
|
|
64
|
+
// silently dropping the `\` in `C:\` and failing with "not recognized".
|
|
65
|
+
const isWinShim = process.platform === 'win32' && /\.(cmd|bat)$/i.test(cliPath);
|
|
66
|
+
if (isWinShim) {
|
|
67
|
+
child = spawn('cmd.exe', ['/d', '/s', '/c', cliPath, ...args], {
|
|
68
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
69
|
+
cwd: req.cwd,
|
|
70
|
+
windowsHide: true,
|
|
71
|
+
});
|
|
72
|
+
} else {
|
|
73
|
+
child = spawn(cliPath, args, {
|
|
74
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
75
|
+
cwd: req.cwd,
|
|
76
|
+
windowsHide: true,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
} catch (e: any) {
|
|
80
|
+
yield { type: 'error', message: `Failed to spawn claude CLI at ${cliPath}: ${e?.message || String(e)}` };
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Hook abort signal — SIGTERM, then SIGKILL after grace period.
|
|
85
|
+
const abortHandler = () => {
|
|
86
|
+
try { child.kill('SIGTERM'); } catch { /* ignore */ }
|
|
87
|
+
setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* ignore */ } }, SIGTERM_GRACE_MS);
|
|
88
|
+
};
|
|
89
|
+
if (req.signal) {
|
|
90
|
+
if (req.signal.aborted) abortHandler();
|
|
91
|
+
else req.signal.addEventListener('abort', abortHandler, { once: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
child.stdin.write(stdinPayload);
|
|
95
|
+
child.stdin.end();
|
|
96
|
+
|
|
97
|
+
// Pump stdout chunks into a buffered queue the generator drains.
|
|
98
|
+
const queue: StreamEvent[] = [];
|
|
99
|
+
let waiter: ((v: void) => void) | null = null;
|
|
100
|
+
const wake = () => { if (waiter) { const w = waiter; waiter = null; w(); } };
|
|
101
|
+
|
|
102
|
+
let exited = false;
|
|
103
|
+
let exitCode = 0;
|
|
104
|
+
let stderrBuffer = '';
|
|
105
|
+
let stopReason: string | null = null;
|
|
106
|
+
let inputTokens = 0;
|
|
107
|
+
let outputTokens = 0;
|
|
108
|
+
let lineBuffer = '';
|
|
109
|
+
const pendingTools = new Map<number, { id: string; name: string; jsonBuffer: string }>();
|
|
110
|
+
|
|
111
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
112
|
+
lineBuffer += chunk.toString('utf-8');
|
|
113
|
+
const lines = lineBuffer.split('\n');
|
|
114
|
+
lineBuffer = lines.pop() || '';
|
|
115
|
+
for (const raw of lines) {
|
|
116
|
+
const line = raw.trim();
|
|
117
|
+
if (!line) continue;
|
|
118
|
+
let ev: any;
|
|
119
|
+
try { ev = JSON.parse(line); } catch { continue; }
|
|
120
|
+
handleCliEvent(ev);
|
|
121
|
+
}
|
|
122
|
+
wake();
|
|
123
|
+
});
|
|
124
|
+
child.stderr.on('data', (chunk: Buffer) => { stderrBuffer += chunk.toString('utf-8'); });
|
|
125
|
+
child.on('close', code => { exitCode = code ?? -1; exited = true; wake(); });
|
|
126
|
+
child.on('error', err => { stderrBuffer += `\n[spawn error] ${err.message}`; exited = true; wake(); });
|
|
127
|
+
|
|
128
|
+
function handleCliEvent(ev: any) {
|
|
129
|
+
// Token-level streaming deltas — preferred shape, matches API client.
|
|
130
|
+
if (ev.type === 'stream_event' && ev.event?.type === 'content_block_delta') {
|
|
131
|
+
const d = ev.event.delta;
|
|
132
|
+
if (d?.type === 'text_delta' && typeof d.text === 'string') {
|
|
133
|
+
queue.push({ type: 'text_delta', text: d.text });
|
|
134
|
+
} else if (d?.type === 'input_json_delta' && typeof d.partial_json === 'string') {
|
|
135
|
+
const idx = ev.event.index;
|
|
136
|
+
const pending = pendingTools.get(idx);
|
|
137
|
+
if (pending) pending.jsonBuffer += d.partial_json;
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (ev.type === 'stream_event' && ev.event?.type === 'content_block_start') {
|
|
142
|
+
const block = ev.event.content_block;
|
|
143
|
+
if (block?.type === 'tool_use') {
|
|
144
|
+
pendingTools.set(ev.event.index, { id: block.id, name: block.name, jsonBuffer: '' });
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (ev.type === 'stream_event' && ev.event?.type === 'content_block_stop') {
|
|
149
|
+
const idx = ev.event.index;
|
|
150
|
+
const pending = pendingTools.get(idx);
|
|
151
|
+
if (pending) {
|
|
152
|
+
let parsed: unknown = {};
|
|
153
|
+
if (pending.jsonBuffer) { try { parsed = JSON.parse(pending.jsonBuffer); } catch { parsed = {}; } }
|
|
154
|
+
queue.push({ type: 'tool_use_start', id: pending.id, name: pending.name, input: parsed });
|
|
155
|
+
pendingTools.delete(idx);
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Per-turn assistant fallback — when token-level streaming isn't
|
|
161
|
+
// available, the CLI still emits a complete `assistant` event. Push
|
|
162
|
+
// its text once so we don't lose the message.
|
|
163
|
+
if (ev.type === 'assistant' && ev.message?.content) {
|
|
164
|
+
const blocks = ev.message.content as Array<{ type?: string; text?: string }>;
|
|
165
|
+
const text = blocks.filter(b => b.type === 'text').map(b => b.text || '').join('');
|
|
166
|
+
// Only emit fallback text if we never streamed any deltas for this turn.
|
|
167
|
+
// Heuristic: if the queue's last text_delta is empty, emit; else assume
|
|
168
|
+
// streaming already covered it. We can't reliably check — just always
|
|
169
|
+
// emit and let the consumer dedupe via its own message buffer if needed.
|
|
170
|
+
// Tradeoff acknowledged: a minor risk of double-text vs total loss when
|
|
171
|
+
// CLI doesn't stream. CLIs we've tested do stream, so this is rarely hit.
|
|
172
|
+
if (text) queue.push({ type: 'text_delta', text });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (ev.type === 'result') {
|
|
177
|
+
if (ev.usage?.input_tokens) inputTokens = ev.usage.input_tokens;
|
|
178
|
+
if (ev.usage?.output_tokens) outputTokens = ev.usage.output_tokens;
|
|
179
|
+
if (ev.is_error || ev.subtype === 'error_during_execution' || ev.subtype === 'error_max_turns') {
|
|
180
|
+
const detail = typeof ev.result === 'string' && ev.result.trim()
|
|
181
|
+
? ev.result.trim()
|
|
182
|
+
: `${ev.subtype || 'error'} (status ${ev.api_error_status ?? 'unknown'})`;
|
|
183
|
+
queue.push({ type: 'error', message: `[CLI ${ev.subtype || 'error'}] ${detail}` });
|
|
184
|
+
stopReason = 'error';
|
|
185
|
+
} else if (ev.subtype === 'success' || ev.stop_reason === 'end_turn') {
|
|
186
|
+
stopReason = 'end_turn';
|
|
187
|
+
} else if (ev.stop_reason === 'max_turns') {
|
|
188
|
+
stopReason = 'max_turns';
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Drain loop: yield queued events as the subprocess produces them.
|
|
194
|
+
while (true) {
|
|
195
|
+
while (queue.length > 0) {
|
|
196
|
+
yield queue.shift()!;
|
|
197
|
+
}
|
|
198
|
+
if (exited) break;
|
|
199
|
+
await new Promise<void>(r => { waiter = r; });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (exitCode !== 0 && stopReason !== 'error') {
|
|
203
|
+
const msg = stderrBuffer.trim() || `claude CLI exited with code ${exitCode}`;
|
|
204
|
+
yield { type: 'error', message: msg };
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (inputTokens > 0 || outputTokens > 0) {
|
|
209
|
+
yield { type: 'usage', inputTokens, outputTokens };
|
|
210
|
+
}
|
|
211
|
+
yield { type: 'message_end', stopReason };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Convert AnthropicMessage[] to the CLI's stream-json input format.
|
|
216
|
+
* Each user message becomes one JSONL line on stdin. Assistant messages
|
|
217
|
+
* are echoed back as turn history. The CLI assembles them into a
|
|
218
|
+
* conversation context.
|
|
219
|
+
*/
|
|
220
|
+
function buildStreamJsonInput(req: CliStreamRequest): string {
|
|
221
|
+
const lines: string[] = [];
|
|
222
|
+
for (const m of req.messages) {
|
|
223
|
+
const content = typeof m.content === 'string' ? m.content : flattenContent(m.content);
|
|
224
|
+
lines.push(JSON.stringify({
|
|
225
|
+
type: m.role,
|
|
226
|
+
message: { role: m.role, content: [{ type: 'text', text: content }] },
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
return lines.join('\n') + '\n';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function flattenContent(blocks: Exclude<AnthropicMessage['content'], string>): string {
|
|
233
|
+
return blocks.map(b => {
|
|
234
|
+
if (b.type === 'text') return b.text;
|
|
235
|
+
if (b.type === 'tool_result') return `[tool_result for ${b.tool_use_id}]\n${b.content}`;
|
|
236
|
+
if (b.type === 'tool_use') return `[tool_use: ${b.name}]`;
|
|
237
|
+
return '';
|
|
238
|
+
}).join('\n');
|
|
239
|
+
}
|