@chatpanel/bridge 0.11.4 → 0.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.11.4",
3
+ "version": "0.11.5",
4
4
  "type": "module",
5
5
  "description": "Local bridge that exposes the AI coding agents installed on your machine \u2014 Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) \u2014 to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
6
6
  "keywords": [
@@ -24,7 +24,7 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
24
24
  // The service and everything under it. `config.js` is deliberately absent: it resolves paths
25
25
  // and reads the bridge token from disk, and the bridge already knows both.
26
26
  const FILES = [
27
- 'normalize.js', 'pairing.js', 'invoke.js', 'stream.js', 'bridge.js',
27
+ 'normalize.js', 'pairing.js', 'invoke.js', 'stream.js', 'bridge.js', 'gateway.js',
28
28
  'eventlog.js', 'service.js', 'adapters/telegram.js',
29
29
  ];
30
30
 
@@ -46,6 +46,12 @@ export function startTelegram({
46
46
  savePairing = async () => {},
47
47
  appender, // createEventLog(...) or nullEventLog()
48
48
  agent = 'claude',
49
+ // Which transport answers, and with what. `bridge` runs a CLI agent on this machine;
50
+ // `gateway` reaches any destination the user configured there — an API provider or, via the
51
+ // gateway's own bridge backend, the same CLI agents. The adapter must not be able to tell
52
+ // which it has: both expose chat()/cancel() and fold into one reply state.
53
+ transport = bridge,
54
+ model = '',
49
55
  system = '',
50
56
  redact = { tier: 'basic' },
51
57
  privacy = 'standard',
@@ -113,7 +119,7 @@ export function startTelegram({
113
119
  }
114
120
  if (name === 'stop') {
115
121
  const st = chatState(norm.chatId);
116
- const ok = await bridge.cancel(st.runId, { baseUrl, token });
122
+ const ok = await transport.cancel(st.runId, { baseUrl, token });
117
123
  st.runId = null;
118
124
  return void send(norm.chatId, ok ? '⏹ stopped.' : 'nothing running.');
119
125
  }
@@ -151,15 +157,16 @@ export function startTelegram({
151
157
  const messages = [...st.history, { role: 'user', content: redacted }];
152
158
 
153
159
  try {
154
- const finalState = await bridge.chat(
155
- { agent, system, messages, images, options: { reach } },
160
+ const finalState = await transport.chat(
161
+ { agent, model, system, messages, images, options: { reach } },
156
162
  {
157
163
  baseUrl, token, signal,
158
164
  onEvent: (ev, state) => {
159
- if (ev.type === 'run') st.runId = state.runId;
160
- // Throttled live edit: restore the user's own values + egress-scrub fresh secrets, so
161
- // the user watches real text stream in but the provider never carries a leaked secret.
162
- if ((ev.type === 'delta' || ev.type === 'done') && replyId && (ev.type === 'done' || gate.ready())) {
165
+ if (state.runId) st.runId = state.runId;
166
+ // Driven by the folded STATE, not by an event's `type`: the bridge emits
167
+ // {type:'delta'} and the gateway emits OpenAI chunks, and this has to work on both.
168
+ // The `first !== shown` guard below makes a no-text event a no-op anyway.
169
+ if (replyId && (state.done || gate.ready())) {
163
170
  const text = outboundText(state.text, st.vault, { privacy });
164
171
  const first = splitForTelegram(text || '…')[0];
165
172
  if (first && first !== shown) { shown = first; edit(norm.chatId, replyId, first).catch(() => {}); }
@@ -0,0 +1,106 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-channels/src/gateway.js (npm @chatpanel/channels).
3
+ // Edit there, then run: npm run sync:channels
4
+ //
5
+ // Vendored rather than depended on: the bridge ships zero runtime dependencies so a
6
+ // curl one-liner install cannot fail on someone's registry, and so the compiled
7
+ // single-file binary has nothing to resolve. Package imports are rewritten to the
8
+ // vendored engines (src/pii, src/events) by the sync script.
9
+
10
+ // Gateway backend: reach ANY configured destination — an API provider or a CLI agent —
11
+ // through the ChatPanel gateway's OpenAI-compatible endpoint.
12
+ //
13
+ // channel → POST http://127.0.0.1:4320/v1/chat/completions { model, messages, stream }
14
+ //
15
+ // WHY THIS EXISTS ALONGSIDE bridge.js. The bridge runs CLI agents, and that is all it can
16
+ // answer with — so a phone could only ever talk to Claude Code or Codex, never to the OpenAI,
17
+ // Anthropic or local-model endpoints the same user already configured. Those live in the
18
+ // gateway, which persists its `destinations` (0600, keys included) and routes a model id to
19
+ // an API provider OR back to the bridge for an agent. So the gateway is the superset, and
20
+ // pointing a channel at it is what makes "answer from my phone" work with every target the
21
+ // user has rather than a subset.
22
+ //
23
+ // NO NEW SECRET. The alternative was teaching the bridge to hold provider API keys, which
24
+ // would have put them on disk a second time, in a second format, with a second thing to
25
+ // rotate. The gateway already holds them and already guards them; this borrows the routing
26
+ // instead of copying the credentials.
27
+ //
28
+ // The /v1 data plane is deliberately unauthenticated for local clients — that is the
29
+ // gateway's product — so there is no token to present here, and none to leak.
30
+
31
+ import { parseSse, foldOpenAiEvent, initialState } from './stream.js';
32
+
33
+ // Per-turn aborts, so /stop can cancel a gateway turn the way it cancels a bridge run. The
34
+ // bridge hands out a run id for this; OpenAI's API has no such handle, so we mint one and
35
+ // keep the controller behind it rather than leaving /stop silently broken on this transport.
36
+ const inflight = new Map();
37
+
38
+ /**
39
+ * Drive one turn against a gateway destination. Same shape as bridge.chat so the adapter does
40
+ * not know which transport it has: streams events to onEvent(ev, state) and returns the folded
41
+ * final state.
42
+ */
43
+ export async function chat({ model = '', system = '', messages = [], options = {} }, {
44
+ baseUrl, signal, onEvent = () => {},
45
+ } = {}) {
46
+ const runId = `gw_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
47
+ const ac = new AbortController();
48
+ inflight.set(runId, ac);
49
+ // The caller's signal (the poll loop shutting down) must still abort the turn.
50
+ const relay = () => ac.abort();
51
+ signal?.addEventListener?.('abort', relay, { once: true });
52
+
53
+ let state = initialState();
54
+ const emit = (ev) => { state = foldOpenAiEvent(state, ev); onEvent(ev, state); };
55
+ emit({ type: 'run', id: runId });
56
+
57
+ try {
58
+ const res = await fetch(`${String(baseUrl).replace(/\/$/, '')}/v1/chat/completions`, {
59
+ method: 'POST',
60
+ headers: { 'content-type': 'application/json' },
61
+ body: JSON.stringify({
62
+ model,
63
+ stream: true,
64
+ messages: [
65
+ ...(system ? [{ role: 'system', content: system }] : []),
66
+ ...messages.map((m) => ({ role: m.role, content: String(m.content ?? '') })),
67
+ ],
68
+ // Carried through so a capped phone's reach still reaches the router. The gateway
69
+ // ignores what it does not know, which is what keeps an older gateway working.
70
+ ...(options?.reach ? { chatpanel: { reach: options.reach } } : {}),
71
+ }),
72
+ signal: ac.signal,
73
+ });
74
+ if (!res.ok || !res.body) {
75
+ const detail = await res.text().catch(() => '');
76
+ throw new Error(`gateway ${res.status}${detail ? `: ${detail.slice(0, 300)}` : ''}`);
77
+ }
78
+ let buffer = '';
79
+ const decoder = new TextDecoder();
80
+ for await (const chunk of res.body) {
81
+ buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
82
+ const { events, rest } = parseSse(buffer);
83
+ buffer = rest;
84
+ for (const ev of events) emit(ev);
85
+ }
86
+ // OpenAI streams end with `data: [DONE]`, which is not JSON and is dropped by parseSse —
87
+ // so a stream that ended cleanly still has to be marked done here.
88
+ if (!state.done) state = { ...state, done: true };
89
+ return state;
90
+ } catch (e) {
91
+ if (ac.signal.aborted) return { ...state, done: true };
92
+ return { ...state, done: true, error: e?.message || String(e) };
93
+ } finally {
94
+ inflight.delete(runId);
95
+ signal?.removeEventListener?.('abort', relay);
96
+ }
97
+ }
98
+
99
+ /** Stop a run by the id emitted as the first {type:'run'} event. Best-effort, like the bridge's. */
100
+ export async function cancel(runId) {
101
+ const ac = runId && inflight.get(runId);
102
+ if (!ac) return false;
103
+ ac.abort();
104
+ inflight.delete(runId);
105
+ return true;
106
+ }
@@ -27,11 +27,22 @@
27
27
  import path from 'node:path';
28
28
  import { readFile, writeFile, mkdir, rm, stat } from 'node:fs/promises';
29
29
  import { createPairingStore } from './pairing.js';
30
+ import * as bridgeTransport from './bridge.js';
31
+ import * as gateway from './gateway.js';
32
+
33
+ // The gateway's fixed local port (see chatpanel-gateway: 4319 bridge / 4320 gateway).
34
+ const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:4320';
30
35
  import { createEventLog } from './eventlog.js';
31
36
  import { startTelegram } from './adapters/telegram.js';
32
37
 
33
38
  const TELEGRAM_API = 'https://api.telegram.org';
34
- export const DEFAULT_SETTINGS = Object.freeze({ agent: 'claude', privacy: 'standard', tier: 'basic' });
39
+ // `agent` routes through the bridge (a CLI on this machine). `model` routes through the
40
+ // gateway, which reaches every destination the user configured there — API providers AND, via
41
+ // its own bridge backend, the same agents. They are mutually exclusive: update() clears one
42
+ // when the other is set, because "which thing answers" is one choice, not two.
43
+ export const DEFAULT_SETTINGS = Object.freeze({
44
+ agent: 'claude', model: '', gatewayUrl: DEFAULT_GATEWAY_URL, privacy: 'standard', tier: 'basic',
45
+ });
35
46
 
36
47
  // Restart backoff. A long-poll that dies (network drop, laptop asleep, Telegram hiccup) must
37
48
  // come back on its own — a channel nobody is watching is exactly the one that must self-heal —
@@ -120,9 +131,13 @@ export function createChannelService({
120
131
  function spawnLoop(botToken) {
121
132
  controller = new AbortController();
122
133
  running = true;
134
+ // One choice, resolved here so the adapter never has to ask "which kind am I?".
135
+ const viaGateway = !!settings.model;
123
136
  const done = startAdapter({
124
137
  botToken,
125
- baseUrl: bridge.baseUrl,
138
+ transport: viaGateway ? gateway : bridgeTransport,
139
+ model: viaGateway ? settings.model : '',
140
+ baseUrl: viaGateway ? (settings.gatewayUrl || DEFAULT_GATEWAY_URL) : bridge.baseUrl,
126
141
  token: bridge.token,
127
142
  pairing,
128
143
  savePairing,
@@ -232,7 +247,13 @@ export function createChannelService({
232
247
  async update(patch = {}) {
233
248
  await load();
234
249
  const next = { ...settings };
235
- for (const k of ['agent', 'privacy', 'tier', 'system']) if (patch[k] != null) next[k] = patch[k];
250
+ for (const k of ['agent', 'model', 'gatewayUrl', 'privacy', 'tier', 'system']) {
251
+ if (patch[k] != null) next[k] = patch[k];
252
+ }
253
+ // Picking one target unpicks the other. Without this a stale `model` would silently win
254
+ // over the agent the user just chose, and the screen would disagree with the machine.
255
+ if (patch.agent != null && patch.model == null) next.model = '';
256
+ if (patch.model) next.agent = '';
236
257
  settings = next;
237
258
  await saveSettings();
238
259
  // Settings are read when the loop starts, so a change only lands on a restart.
@@ -273,7 +294,16 @@ export function createChannelService({
273
294
  bot: bot ? { ...bot } : null,
274
295
  error: lastError,
275
296
  paired: pairing.list(),
276
- settings: { agent: settings.agent, privacy: settings.privacy, tier: settings.tier },
297
+ settings: {
298
+ agent: settings.agent,
299
+ model: settings.model || '',
300
+ gatewayUrl: settings.gatewayUrl || DEFAULT_GATEWAY_URL,
301
+ privacy: settings.privacy,
302
+ tier: settings.tier,
303
+ },
304
+ // Which transport a message will actually take, so a screen can say so rather than
305
+ // inferring it from two fields and getting it wrong.
306
+ via: settings.model ? 'gateway' : 'bridge',
277
307
  };
278
308
  },
279
309
  };
@@ -35,6 +35,26 @@ export function foldEvent(state, ev) {
35
35
  }
36
36
  }
37
37
 
38
+ /**
39
+ * Fold one OpenAI-style chunk (what the gateway streams) into the same reply state, so a
40
+ * caller cannot tell which transport it has. Shares parseSse: `data: [DONE]` is not JSON and
41
+ * is dropped there, which is why the transport marks `done` itself when the stream ends.
42
+ */
43
+ export function foldOpenAiEvent(state, ev) {
44
+ if (ev?.type === 'run') return { ...state, runId: ev.id || state.runId };
45
+ // An error can arrive as a streamed frame rather than an HTTP status.
46
+ if (ev?.error) return { ...state, done: true, error: ev.error.message || String(ev.error) };
47
+ const choice = ev?.choices?.[0];
48
+ let next = state;
49
+ const delta = choice?.delta?.content;
50
+ if (typeof delta === 'string' && delta) next = { ...next, text: next.text + delta };
51
+ // Non-streaming replies (a gateway destination that cannot stream) carry the whole message.
52
+ const whole = choice?.message?.content;
53
+ if (!next.text && typeof whole === 'string' && whole) next = { ...next, text: whole };
54
+ if (choice?.finish_reason) next = { ...next, done: true };
55
+ return next;
56
+ }
57
+
38
58
  /**
39
59
  * Pull complete SSE events out of a growing buffer. Returns the parsed events and the
40
60
  * UNCONSUMED tail (a partial frame still arriving), which the caller prepends next read.
package/src/server.js CHANGED
@@ -67,7 +67,7 @@ import {
67
67
  // Hardcoded (not read from package.json) so it survives Bun's single-file
68
68
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
69
69
  // this drifts from package.json, so the two can't silently diverge.
70
- const VERSION = '0.11.4';
70
+ const VERSION = '0.11.5';
71
71
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
72
72
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
73
73