thebird 1.2.82 → 1.2.84

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.
@@ -29,13 +29,15 @@ jobs:
29
29
  - name: Bump patch version
30
30
  run: |
31
31
  git fetch --tags --force
32
- for i in 1 2 3 4 5; do
32
+ HIGHEST=$(git tag -l 'v[0-9]*' | sort -V | tail -1 | sed 's/^v//')
33
+ CURRENT=$(node -p "require('./package.json').version")
34
+ BASE="$HIGHEST"
35
+ if [ -z "$BASE" ] || [ "$(printf '%s\n%s' "$CURRENT" "$BASE" | sort -V | tail -1)" = "$CURRENT" ]; then BASE="$CURRENT"; fi
36
+ node -e "const p=require('./package.json');p.version='$BASE';require('fs').writeFileSync('./package.json', JSON.stringify(p,null,2)+'\n')"
37
+ NEW_VERSION=$(npm version patch --no-git-tag-version)
38
+ while git rev-parse "$NEW_VERSION" >/dev/null 2>&1; do
39
+ echo "tag $NEW_VERSION exists, bumping again"
33
40
  NEW_VERSION=$(npm version patch --no-git-tag-version)
34
- if git rev-parse "${NEW_VERSION}" >/dev/null 2>&1; then
35
- echo "Tag ${NEW_VERSION} already exists, bumping again (attempt $i)"
36
- continue
37
- fi
38
- break
39
41
  done
40
42
  git add package.json
41
43
  git commit -m "chore: bump to ${NEW_VERSION}"
@@ -91,8 +91,8 @@ function buildStream(provider) {
91
91
  if (provider.type === 'gemini') {
92
92
  return streamGemini({ model: provider.model, messages: provider.messages, tools: TOOLS, apiKey: provider.apiKey, maxOutputTokens: 8192 }).fullStream;
93
93
  }
94
- if (provider.type === 'kilo') {
95
- return streamKiloHTTP({ url: provider.baseUrl, model: provider.model, messages: provider.messages });
94
+ if (provider.type === 'kilo' || provider.type === 'opencode') {
95
+ return streamKiloHTTP({ url: provider.baseUrl, model: provider.model, messages: provider.messages, providerType: provider.type });
96
96
  }
97
97
  const url = (provider.baseUrl || '').replace(/\/$/, '') + '/chat/completions';
98
98
  return streamOpenAI({ url, apiKey: provider.apiKey, messages: provider.messages, model: provider.model, tools: TOOLS, maxOutputTokens: 8192 });
package/docs/app.js CHANGED
@@ -12,6 +12,7 @@ const PROVIDERS = {
12
12
  deepseek: { label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', keyPlaceholder: 'DEEPSEEK_API_KEY', models: ['deepseek-chat', 'deepseek-reasoner'] },
13
13
  cerebras: { label: 'Cerebras', baseUrl: 'https://api.cerebras.ai/v1', keyPlaceholder: 'CEREBRAS_API_KEY', models: ['gpt-oss-120b', 'llama3.1-8b'] },
14
14
  kilo: { label: 'Kilo Code', baseUrl: 'http://localhost:4780', keyPlaceholder: '(no key needed)', models: ['x-ai/grok-code-fast-1:optimized:free', 'openrouter/free', 'kilo-auto/free'] },
15
+ opencode: { label: 'opencode', baseUrl: 'http://localhost:4790', keyPlaceholder: '(no key needed)', models: ['x-ai/grok-code-fast-1:optimized:free', 'openrouter/free', 'kilo-auto/free'] },
15
16
  custom: { label: 'Custom (OpenAI-compat)', baseUrl: '', keyPlaceholder: 'API_KEY', models: [] },
16
17
  };
17
18
 
@@ -95,6 +96,21 @@ class BirdChat extends HTMLElement {
95
96
  this.setState({ providerType: type, baseUrl, model, models: [], apiKey: localStorage.getItem('provider_api_key') || '' });
96
97
  }
97
98
 
99
+ renderBaseUrlInput() {
100
+ const { providerType, baseUrl } = this.state;
101
+ if (providerType !== 'custom' && providerType !== 'kilo' && providerType !== 'opencode') return null;
102
+ const ph = providerType === 'kilo' ? 'http://localhost:4780' : (providerType === 'opencode' ? 'http://localhost:4790' : 'https://your-endpoint/v1');
103
+ return html`<input type="text" class="tui-input" style="flex:1;min-width:140px" placeholder=${ph} value=${baseUrl}
104
+ onchange=${e => { localStorage.setItem('provider_base_url', e.target.value); this.setState({ baseUrl: e.target.value }); }} />`;
105
+ }
106
+ renderApiKeyInput() {
107
+ const { providerType, apiKey } = this.state;
108
+ if (providerType === 'kilo' || providerType === 'opencode') return null;
109
+ const provDef = PROVIDERS[providerType] || PROVIDERS.custom;
110
+ return html`<input id="api-key-input" type="password" class="tui-input" style="flex:1;min-width:120px" placeholder=${provDef.keyPlaceholder} value=${apiKey}
111
+ onchange=${e => { const v = e.target.value.trim(); localStorage.setItem('provider_api_key', v); this.setState({ apiKey: v }); if (v) this.loadModels(); }} />`;
112
+ }
113
+
98
114
  render() {
99
115
  const { messages, streaming, model, apiKey, models, modelsLoading, status, providerType, baseUrl, streamingText } = this.state;
100
116
  const provDef = PROVIDERS[providerType] || PROVIDERS.custom;
@@ -108,18 +124,8 @@ class BirdChat extends HTMLElement {
108
124
  <div class="tui-toolbar">
109
125
  <label>provider:</label>
110
126
  <select class="tui-select" onchange=${e => this.setProvider(e.target.value)}>${provOpts}</select>
111
- ${(providerType === 'custom' || providerType === 'kilo') ? html`
112
- <input type="text" class="tui-input" style="flex:1;min-width:140px"
113
- placeholder=${providerType === 'kilo' ? 'http://localhost:4780' : 'https://your-endpoint/v1'} value=${baseUrl}
114
- onchange=${e => { localStorage.setItem('provider_base_url', e.target.value); this.setState({ baseUrl: e.target.value }); }} />` : ''}
115
- ${providerType !== 'kilo' ? html`<input id="api-key-input" type="password" class="tui-input" style="flex:1;min-width:120px"
116
- placeholder=${provDef.keyPlaceholder} value=${apiKey}
117
- onchange=${e => {
118
- const v = e.target.value.trim();
119
- localStorage.setItem('provider_api_key', v);
120
- this.setState({ apiKey: v });
121
- if (v) this.loadModels();
122
- }} />` : ''}
127
+ ${this.renderBaseUrlInput()}
128
+ ${this.renderApiKeyInput()}
123
129
  ${modelsLoading
124
130
  ? html`<span class="tui-spinner"></span>`
125
131
  : html`<select class="tui-select" value=${model}
@@ -129,7 +135,7 @@ class BirdChat extends HTMLElement {
129
135
 
130
136
  <div id="msg-list" class="tui-msglist">
131
137
  ${messages.map((m, i) => html`
132
- <div key=${i} class=${'tui-msg ' + m.role}>${m.content}</div>`)}
138
+ <div key=${i} class=${'tui-msg ' + m.role}>${typeof m.content === 'string' ? m.content : (m.content || []).filter(b => b.type === 'text').map(b => b.text).join('')}</div>`)}
133
139
  ${streaming && !streamingText && html`<div class="tui-msg assistant"><span class="tui-spinner"></span> thinking...</div>`}
134
140
  </div>
135
141
 
@@ -152,7 +158,7 @@ class BirdChat extends HTMLElement {
152
158
  const text = input?.value.trim();
153
159
  if (!text || this.state.streaming) return;
154
160
  const { apiKey, model, providerType, baseUrl } = this.state;
155
- if (!apiKey && providerType !== 'kilo') { this.setState({ status: 'Enter an API key above.' }); return; }
161
+ if (!apiKey && providerType !== 'kilo' && providerType !== 'opencode') { this.setState({ status: 'Enter an API key above.' }); return; }
156
162
  input.value = '';
157
163
  input.style.height = 'auto';
158
164
  const normalizedMessages = [...this.state.messages, { role: 'user', content: text }].map(m => ({
@@ -1,47 +1,67 @@
1
- import { mirrorFromSandbox } from './kilo-fs-mirror.js';
2
-
3
- export async function* streamKiloHTTP({ url, model, messages }) {
1
+ export async function* streamKiloHTTP({ url, model, messages, providerType }) {
4
2
  yield { type: 'start-step' };
5
3
  const base = (url || 'http://localhost:4780').replace(/\/$/, '');
6
- const fsBase = base.replace(/:\d+$/, ':4781');
4
+ const isOpencode = providerType === 'opencode';
5
+ const dbgKey = isOpencode ? 'opencode' : 'kilo';
7
6
  let sessRes;
8
7
  try { sessRes = await fetch(base + '/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); }
9
- catch (e) { throw new Error('kilo serve not reachable at ' + base + ' — start it with: node start-kilo.js --origin ' + location.origin); }
10
- if (!sessRes.ok) throw new Error('kilo /session ' + sessRes.status + ': ' + await sessRes.text());
8
+ catch (e) { throw new Error(dbgKey + ' serve not reachable at ' + base + ' — start it with: ' + dbgKey + ' serve --port ' + (new URL(base).port || (isOpencode ? 4790 : 4780)) + ' --cors ' + location.origin); }
9
+ if (!sessRes.ok) throw new Error('/session ' + sessRes.status + ': ' + await sessRes.text());
11
10
  const { id: sessionId } = await sessRes.json();
12
- Object.assign(window.__debug = window.__debug || {}, { kilo: { sessionId, url: base, fsBase, writes: [], lastStatus: null } });
13
-
14
- const es = new EventSource(base + '/event');
15
- const pendingWrites = new Set();
16
- es.onmessage = (ev) => {
17
- try {
18
- const msg = JSON.parse(ev.data);
19
- if (msg.type === 'message.part.updated') {
20
- const part = msg.properties?.part;
21
- if (part?.type === 'tool' && part.state?.status === 'completed' && (part.tool === 'write' || part.tool === 'edit')) {
22
- const abs = part.state.input?.filePath;
23
- if (abs) pendingWrites.add(abs);
24
- }
25
- }
26
- } catch (_) {}
27
- };
11
+ Object.assign(window.__debug = window.__debug || {}, { [dbgKey]: { sessionId, url: base, lastStatus: null } });
28
12
 
29
13
  const userText = messages.filter(m => m.role === 'user').map(m =>
30
14
  typeof m.content === 'string' ? m.content : (m.content || []).filter(b => b.type === 'text').map(b => b.text).join('')
31
15
  ).join('\n');
32
16
 
33
- const body = { parts: [{ type: 'text', text: userText }], providerID: 'kilo', modelID: model || 'x-ai/grok-code-fast-1:optimized:free' };
34
- const msgRes = await fetch(base + '/session/' + sessionId + '/message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
35
- window.__debug.kilo.lastStatus = msgRes.status;
36
- if (!msgRes.ok) { es.close(); throw new Error('kilo message ' + msgRes.status + ': ' + await msgRes.text()); }
37
- const result = await msgRes.json();
38
- window.__debug.kilo.lastResult = result;
39
- es.close();
40
- const mirrored = await mirrorFromSandbox(fsBase, [...pendingWrites]);
41
- window.__debug.kilo.writes = mirrored;
42
- if (mirrored.length) window.refreshPreview?.();
43
- const textParts = (result.parts || []).filter(p => p.type === 'text');
44
- for (const tp of textParts) yield { type: 'text-delta', textDelta: tp.text };
45
- if (mirrored.length) yield { type: 'text-delta', textDelta: '\n\n[mirrored to preview: ' + mirrored.join(', ') + ']' };
46
- yield { type: 'finish-step', finishReason: result.info?.finish || 'stop' };
17
+ const modelId = model || 'x-ai/grok-code-fast-1:optimized:free';
18
+ const body = { parts: [{ type: 'text', text: userText }], agent: 'hermes-llm' };
19
+ if (isOpencode) body.model = { providerID: 'kilo', modelID: modelId };
20
+ else { body.providerID = 'kilo'; body.modelID = modelId; }
21
+
22
+ let text = '';
23
+ if (isOpencode) {
24
+ const es = new EventSource(base + '/event');
25
+ const textByPart = new Map();
26
+ let done = false;
27
+ const pending = [];
28
+ let resolveNext = null;
29
+ const push = ev => { pending.push(ev); if (resolveNext) { const r = resolveNext; resolveNext = null; r(); } };
30
+ es.onmessage = e => {
31
+ try {
32
+ const m = JSON.parse(e.data);
33
+ if (m.type === 'message.part.updated') {
34
+ const part = m.properties?.part;
35
+ if (part?.sessionID === sessionId && part.type === 'text') {
36
+ const prior = textByPart.get(part.id) || '';
37
+ const txt = part.text || '';
38
+ if (txt.length > prior.length) { push({ type:'text-delta', textDelta: txt.slice(prior.length) }); textByPart.set(part.id, txt); }
39
+ }
40
+ } else if (m.type === 'message.updated') {
41
+ const info = m.properties?.info;
42
+ if (info?.sessionID === sessionId && info.role === 'assistant' && info.time?.completed) {
43
+ done = true; if (resolveNext) { const r = resolveNext; resolveNext = null; r(); }
44
+ }
45
+ }
46
+ } catch (_) {}
47
+ };
48
+ const msgRes = await fetch(base + '/session/' + sessionId + '/message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
49
+ window.__debug[dbgKey].lastStatus = msgRes.status;
50
+ if (!msgRes.ok) { es.close(); throw new Error('message ' + msgRes.status + ': ' + await msgRes.text()); }
51
+ const deadline = Date.now() + 180000;
52
+ while (!done || pending.length) {
53
+ if (pending.length) { const ev = pending.shift(); if (ev.type === 'text-delta') text += ev.textDelta; yield ev; continue; }
54
+ if (Date.now() > deadline) break;
55
+ await new Promise(r => { resolveNext = r; setTimeout(r, 5000); });
56
+ }
57
+ es.close();
58
+ } else {
59
+ const msgRes = await fetch(base + '/session/' + sessionId + '/message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
60
+ window.__debug[dbgKey].lastStatus = msgRes.status;
61
+ if (!msgRes.ok) throw new Error('message ' + msgRes.status + ': ' + await msgRes.text());
62
+ const result = await msgRes.json();
63
+ window.__debug[dbgKey].lastResult = result;
64
+ for (const tp of (result.parts || []).filter(p => p.type === 'text')) { text += tp.text; yield { type: 'text-delta', textDelta: tp.text }; }
65
+ }
66
+ yield { type: 'finish-step', finishReason: 'stop' };
47
67
  }
package/index.js CHANGED
@@ -99,5 +99,6 @@ const { streamRouter, generateRouter, createRouter } = require('./lib/router-str
99
99
  const { cloudGenerate, streamCloud, cloudStream } = require('./lib/cloud-generate');
100
100
  const { ensureAuth, login: oauthLogin } = require('./lib/oauth');
101
101
  const { BridgeError, AuthError, RateLimitError, TimeoutError, ContextWindowError, ContentPolicyError, ProviderError, classifyError, redactKeys } = require('./lib/errors');
102
+ const { streamACP, generateACP } = require('./lib/providers/acp');
102
103
 
103
- module.exports = { streamGemini, createFullStream, generateGemini, streamRouter, generateRouter, createRouter, convertMessages, convertTools, cleanSchema, GeminiError, BridgeError, AuthError, RateLimitError, TimeoutError, ContextWindowError, ContentPolicyError, ProviderError, classifyError, redactKeys, cloudGenerate, streamCloud, cloudStream, ensureAuth, oauthLogin };
104
+ module.exports = { streamGemini, createFullStream, generateGemini, streamRouter, generateRouter, createRouter, convertMessages, convertTools, cleanSchema, GeminiError, BridgeError, AuthError, RateLimitError, TimeoutError, ContextWindowError, ContentPolicyError, ProviderError, classifyError, redactKeys, cloudGenerate, streamCloud, cloudStream, ensureAuth, oauthLogin, streamACP, generateACP };
@@ -0,0 +1,88 @@
1
+ const { BridgeError } = require('../errors');
2
+
3
+ async function postJson(url, body) {
4
+ const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
5
+ if (!res.ok) { const t = await res.text(); throw new BridgeError(t, { status: res.status, retryable: res.status === 429 || res.status >= 500 }); }
6
+ return res.json();
7
+ }
8
+
9
+ function subscribeSSE(url, onEvent) {
10
+ const ctrl = new AbortController();
11
+ (async () => {
12
+ try {
13
+ const res = await fetch(url, { signal: ctrl.signal });
14
+ if (!res.ok || !res.body) return;
15
+ const reader = res.body.getReader();
16
+ const dec = new TextDecoder();
17
+ let buf = '';
18
+ while (true) {
19
+ const { done, value } = await reader.read();
20
+ if (done) return;
21
+ buf += dec.decode(value, { stream: true });
22
+ let i;
23
+ while ((i = buf.indexOf('\n\n')) >= 0) {
24
+ const chunk = buf.slice(0, i); buf = buf.slice(i + 2);
25
+ const line = chunk.split('\n').find(l => l.startsWith('data:'));
26
+ if (!line) continue;
27
+ try { onEvent(JSON.parse(line.slice(5))); } catch (_) {}
28
+ }
29
+ }
30
+ } catch (e) { if (e.name !== 'AbortError') throw e; }
31
+ })();
32
+ return () => ctrl.abort();
33
+ }
34
+
35
+ function toUserText(messages) {
36
+ return messages.filter(m => m.role === 'user').map(m =>
37
+ typeof m.content === 'string' ? m.content : (m.content || []).filter(b => b.type === 'text').map(b => b.text).join('')
38
+ ).join('\n');
39
+ }
40
+
41
+ async function* streamACP({ url, model, messages, onStepFinish }) {
42
+ yield { type: 'start-step' };
43
+ const base = (url || 'http://localhost:4780').replace(/\/$/, '');
44
+ const { id: sessionId } = await postJson(base + '/session', {});
45
+ const queue = []; let resolveNext = null; let done = false;
46
+ const push = ev => { queue.push(ev); if (resolveNext) { const r = resolveNext; resolveNext = null; r(); } };
47
+ const textSeen = new Map();
48
+ const toolState = new Map();
49
+ const unsubscribe = subscribeSSE(base + '/event', (msg) => {
50
+ if (msg.type !== 'message.part.updated') return;
51
+ const part = msg.properties?.part;
52
+ if (!part) return;
53
+ if (part.type === 'text' && part.messageID) {
54
+ const prior = textSeen.get(part.id) || '';
55
+ const txt = part.text || '';
56
+ if (txt.length > prior.length) { push({ type: 'text-delta', textDelta: txt.slice(prior.length) }); textSeen.set(part.id, txt); }
57
+ } else if (part.type === 'tool') {
58
+ const cid = part.callID; const st = part.state?.status;
59
+ if (st === 'running' && !toolState.has(cid)) { toolState.set(cid, { name: part.tool, args: part.state.input || {} }); push({ type: 'tool-call', toolCallId: cid, toolName: part.tool, args: part.state.input || {} }); }
60
+ else if (st === 'completed' && toolState.has(cid) && !toolState.get(cid).completed) { toolState.get(cid).completed = true; push({ type: 'tool-result', toolCallId: cid, toolName: part.tool, args: part.state.input || {}, result: part.state.output || '' }); }
61
+ }
62
+ });
63
+ const promptPromise = postJson(base + '/session/' + sessionId + '/message', {
64
+ parts: [{ type: 'text', text: toUserText(messages) }],
65
+ providerID: 'kilo',
66
+ modelID: model || 'x-ai/grok-code-fast-1:optimized:free',
67
+ }).finally(() => { done = true; if (resolveNext) { const r = resolveNext; resolveNext = null; r(); } });
68
+ while (!done || queue.length) {
69
+ if (queue.length) { yield queue.shift(); continue; }
70
+ await new Promise(r => { resolveNext = r; });
71
+ }
72
+ const result = await promptPromise;
73
+ unsubscribe();
74
+ yield { type: 'finish-step', finishReason: result.info?.finish || 'stop' };
75
+ if (onStepFinish) await onStepFinish();
76
+ }
77
+
78
+ async function generateACP(opts) {
79
+ let text = '';
80
+ const toolCalls = [];
81
+ for await (const ev of streamACP(opts)) {
82
+ if (ev.type === 'text-delta') text += ev.textDelta;
83
+ else if (ev.type === 'tool-call') toolCalls.push({ id: ev.toolCallId, name: ev.toolName, args: ev.args });
84
+ }
85
+ return { text, toolCalls };
86
+ }
87
+
88
+ module.exports = { streamACP, generateACP };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thebird",
3
- "version": "1.2.82",
3
+ "version": "1.2.84",
4
4
  "description": "Anthropic SDK to Gemini streaming bridge — drop-in proxy that translates Anthropic message format and tool calls to Google Gemini",
5
5
  "scripts": {
6
6
  "start": "node serve.js"
package/start-kilo.js CHANGED
@@ -1,45 +1,33 @@
1
1
  #!/usr/bin/env node
2
2
  const { spawn } = require('child_process');
3
- const http = require('http');
4
3
  const fs = require('fs');
5
4
  const path = require('path');
6
5
  const os = require('os');
7
6
  const args = process.argv.slice(2);
8
7
  const get = (f, d) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : d; };
9
- const kiloPort = get('--port', '4780');
10
- const fsPort = get('--fs-port', '4781');
8
+ const kiloPort = get('--kilo-port', '4780');
9
+ const ocPort = get('--opencode-port', '4790');
11
10
  const origin = get('--origin', 'http://localhost:8787');
12
11
  const sandbox = path.resolve(get('--sandbox', '.sandbox'));
13
12
  fs.mkdirSync(sandbox, { recursive: true });
14
- const kiloWin = process.env.USERPROFILE + '\\AppData\\Roaming\\npm\\node_modules\\@kilocode\\cli\\node_modules\\@kilocode\\cli-windows-x64\\bin\\kilo.exe';
15
- const bin = os.platform() === 'win32' && fs.existsSync(kiloWin) ? kiloWin : 'kilo';
16
- const kilo = spawn(bin, ['serve', '--port', kiloPort, '--hostname', '127.0.0.1', '--cors', origin], { stdio: 'inherit', env: process.env, cwd: sandbox });
17
- const cors = { 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Methods': 'GET,OPTIONS', 'Access-Control-Allow-Headers': 'content-type' };
18
- const srv = http.createServer((req, res) => {
19
- if (req.method === 'OPTIONS') { res.writeHead(204, cors); res.end(); return; }
20
- const rel = decodeURIComponent(req.url.replace(/^\/+/, '').split('?')[0]);
21
- if (rel === '__list') {
22
- const out = [];
23
- const walk = d => { for (const e of fs.readdirSync(d, { withFileTypes: true })) {
24
- if (e.name.startsWith('.')) continue;
25
- const full = path.join(d, e.name);
26
- if (e.isDirectory()) walk(full);
27
- else out.push(path.relative(sandbox, full).replace(/\\/g, '/'));
28
- }};
29
- try { walk(sandbox); } catch (e) {}
30
- res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
31
- res.end(JSON.stringify(out));
32
- return;
33
- }
34
- const full = path.resolve(path.join(sandbox, rel));
35
- if (!full.startsWith(sandbox)) { res.writeHead(403, cors); res.end('forbidden'); return; }
36
- fs.readFile(full, (err, data) => {
37
- if (err) { res.writeHead(404, cors); res.end('not found'); return; }
38
- const ct = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.md': 'text/plain' }[path.extname(rel)] || 'application/octet-stream';
39
- res.writeHead(200, { ...cors, 'Content-Type': ct });
40
- res.end(data);
41
- });
42
- });
43
- srv.listen(fsPort, '127.0.0.1', () => console.log('[fs-bridge] sandbox=' + sandbox + ' serving http://127.0.0.1:' + fsPort));
44
- kilo.on('exit', c => { srv.close(); process.exit(c || 0); });
45
- process.on('SIGINT', () => { kilo.kill(); srv.close(); });
13
+ try { fs.writeFileSync(path.join(sandbox, '.gitignore'), '*\n!.gitignore\n'); } catch (e) {}
14
+
15
+ const isWin = os.platform() === 'win32';
16
+ const kiloBin = isWin ? process.env.USERPROFILE + '\\AppData\\Roaming\\npm\\node_modules\\@kilocode\\cli\\node_modules\\@kilocode\\cli-windows-x64\\bin\\kilo.exe' : 'kilo';
17
+ const ocBin = isWin ? process.env.USERPROFILE + '\\AppData\\Roaming\\npm\\node_modules\\opencode-windows-x64\\bin\\opencode.exe' : 'opencode';
18
+
19
+ const procs = [];
20
+ const launch = (name, bin, port) => {
21
+ if (!args.includes('--no-' + name) && fs.existsSync(bin)) {
22
+ const p = spawn(bin, ['serve', '--port', port, '--hostname', '127.0.0.1', '--cors', origin], { stdio: 'inherit', env: process.env, cwd: sandbox });
23
+ procs.push(p);
24
+ console.log(`[${name}] serve --port ${port} pid ${p.pid}`);
25
+ } else if (!fs.existsSync(bin)) console.log(`[${name}] skip (${bin} not found)`);
26
+ };
27
+ launch('kilo', kiloBin, kiloPort);
28
+ launch('opencode', ocBin, ocPort);
29
+
30
+ const stop = () => { for (const p of procs) { try { p.kill(); } catch (e) {} } process.exit(0); };
31
+ process.on('SIGINT', stop);
32
+ process.on('SIGTERM', stop);
33
+ Promise.all(procs.map(p => new Promise(r => p.on('exit', r)))).then(() => process.exit(0));
@@ -1,15 +0,0 @@
1
- export async function mirrorFromSandbox(fsBase, _touchedPaths) {
2
- const listRes = await fetch(fsBase + '/__list');
3
- if (!listRes.ok) return [];
4
- const relFiles = await listRes.json();
5
- const snap = window.__debug.idbSnapshot || (window.__debug.idbSnapshot = {});
6
- const mirrored = [];
7
- for (const rel of relFiles) {
8
- const r = await fetch(fsBase + '/' + rel);
9
- if (!r.ok) continue;
10
- const content = await r.text();
11
- if (snap[rel] !== content) { snap[rel] = content; mirrored.push(rel); }
12
- }
13
- if (mirrored.length) { window.__debug.idbPersist?.(); window.__debug.shell?.onPreviewWrite?.(); }
14
- return mirrored;
15
- }