@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +1531 -0
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +75 -0
  4. package/LICENSE +21 -0
  5. package/README.md +464 -0
  6. package/SECURITY.md +130 -0
  7. package/assets/accuracy-lab.html +2639 -0
  8. package/assets/api-clis-real.jpg +0 -0
  9. package/assets/bridge-console-hero.jpg +0 -0
  10. package/assets/browser-privacy.svg +151 -0
  11. package/assets/demo-orchestration.svg +74 -0
  12. package/assets/desktop-select-region.jpg +0 -0
  13. package/assets/in-page-chat.gif +0 -0
  14. package/assets/orchestration-hero.svg +126 -0
  15. package/assets/social-preview.png +0 -0
  16. package/assets/zara-accent.png +0 -0
  17. package/build/bootstrap.js +548 -0
  18. package/build/build.js +680 -0
  19. package/build/payload-entry.js +649 -0
  20. package/build/payload-signing-pub.json +7 -0
  21. package/docs/AGENT_GUIDE.md +259 -0
  22. package/docs/RELEASE.md +106 -0
  23. package/docs/SAFETY.md +112 -0
  24. package/docs/TESTING.md +181 -0
  25. package/installer/server.js +231 -0
  26. package/installer/ui/app.js +278 -0
  27. package/installer/ui/index.html +24 -0
  28. package/installer/ui/styles.css +146 -0
  29. package/package.json +95 -0
  30. package/scripts/bootstrap-e2e.mjs +650 -0
  31. package/scripts/certify-bridge.mjs +636 -0
  32. package/scripts/check-companion-surface.mjs +118 -0
  33. package/scripts/extract-welcome.mjs +64 -0
  34. package/scripts/gh-route-handler-check.mjs +57 -0
  35. package/scripts/gh-wire-test.mjs +107 -0
  36. package/scripts/publish-downloads.mjs +180 -0
  37. package/scripts/smoke-all-tools.mjs +509 -0
  38. package/scripts/smoke-live-bridge.mjs +696 -0
  39. package/scripts/splice-welcome.mjs +63 -0
  40. package/scripts/welcome-body.txt +2733 -0
  41. package/src/anthropic-client.ts +192 -0
  42. package/src/bootstrap-exe.ts +69 -0
  43. package/src/bridge.ts +2444 -0
  44. package/src/chat.ts +345 -0
  45. package/src/cli-runner.ts +239 -0
  46. package/src/cli.ts +649 -0
  47. package/src/config.ts +199 -0
  48. package/src/desktop-overlay.ps1 +121 -0
  49. package/src/executable-resolver.ts +330 -0
  50. package/src/handlers/agy-imagegen.ts +179 -0
  51. package/src/handlers/github-cli.ts +399 -0
  52. package/src/handlers/higgsfield-cli.ts +783 -0
  53. package/src/launch.js +337 -0
  54. package/src/mcp-server.ts +1265 -0
  55. package/src/pair-claim.ts +218 -0
  56. package/src/payload-daemon.ts +168 -0
  57. package/src/server.ts +21036 -0
  58. package/src/tool-defaults.ts +230 -0
  59. package/src/update-check.js +136 -0
  60. package/tray/build.py +76 -0
  61. package/tray/requirements.txt +2 -0
  62. 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
+ }