@chatpanel/gateway 0.6.77 → 0.6.79

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/gateway",
3
- "version": "0.6.77",
3
+ "version": "0.6.79",
4
4
  "description": "Local privacy gateway \u2014 redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/router.js CHANGED
@@ -60,6 +60,10 @@ export function resolveDestination(model, cfg, kind, { destination = '' } = {})
60
60
  // matches its own agent destination here — so it ALWAYS goes to the bridge).
61
61
  (model && dests.find((d) => Array.isArray(d.models) && d.models.includes(model)))
62
62
  || (model && dests.find((d) => d.id === model || d.agent === model))
63
+ // `claude/opus` names an agent AND the model it should run: the agent's own destination,
64
+ // never the backend default — which is codex, and "Codex exited 1" on a model it has no
65
+ // idea about was how a whole team came back empty.
66
+ || (model && model.includes('/') && dests.find((d) => d.type === 'agent' && (d.agent === model.slice(0, model.indexOf('/')) || d.id === model.slice(0, model.indexOf('/')))))
63
67
  // No match: fall back to the BACKEND's natural default — an API destination on the
64
68
  // api backend, an agent on the bridge backend. Never silently send an unknown
65
69
  // model name to a CLI agent (that's why gemma must not hit codex).
package/src/server.js CHANGED
@@ -34,6 +34,7 @@ import { installTimestampedConsole } from './log.js';
34
34
  import { saveBackupSecret, clearBackupSecret, loadBackupSecret, hasBackupSecret } from './history-store.js';
35
35
  import { createMemoryStore } from './memory-store.js';
36
36
  import { createPrefsStore } from './prefs-store.js';
37
+ import { createTeamStore } from './team-store.js';
37
38
  import { createHistoryStore } from './sqlite-store.js';
38
39
  import { ingestBackups } from './backup-ingest.js';
39
40
  import * as nerEngine from './ner-engine.js';
@@ -57,7 +58,7 @@ import * as openai from './openai.js';
57
58
  import * as responses from './responses.js';
58
59
  import * as anthropic from './anthropic.js';
59
60
 
60
- export const VERSION = '0.6.77';
61
+ export const VERSION = '0.6.79';
61
62
 
62
63
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
63
64
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -66,6 +67,11 @@ export const VERSION = '0.6.77';
66
67
  const historyStore = await createHistoryStore();
67
68
  const memoryStore = await createMemoryStore();
68
69
  const prefsStore = createPrefsStore();
70
+ const teamStore = createTeamStore();
71
+ // Who is watching prefs change — a client with a live subscription is told the moment a
72
+ // section is written by the other client, instead of waiting for its next focus.
73
+ const prefsWatchers = new Set();
74
+ const notifyPrefs = (applied, by) => { for (const fn of prefsWatchers) { try { fn({ applied, by, revision: prefsStore.revision }); } catch { /* gone */ } } };
69
75
 
70
76
  // OBSERVABILITY — a ring of "which agent read what, when", persisted across restarts (the
71
77
  // gateway updates often; an empty panel after each restart reads as "nothing is set up").
@@ -698,7 +704,7 @@ export function createGateway(cfg = loadConfig()) {
698
704
  // Client preferences travel between the extension and the desktop through here, and an
699
705
  // MCP server entry can carry an Authorization header — so READS are gated too, unlike
700
706
  // history and memory. A drive-by page must not learn what tools the user connected.
701
- if (pathname === '/v1/prefs' && !isAdminAuthorized(req)) {
707
+ if ((pathname === '/v1/prefs' || pathname.startsWith('/v1/prefs/') || pathname.startsWith('/v1/teams')) && !isAdminAuthorized(req)) {
702
708
  return sendJson(res, 403, { error: { message: 'prefs: extension origin or gateway token required', type: 'forbidden' } });
703
709
  }
704
710
  // The access log is who-read-what — sensitive, and writable only by the local MCP
@@ -785,6 +791,7 @@ export function createGateway(cfg = loadConfig()) {
785
791
  try {
786
792
  const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
787
793
  const out = prefsStore.put(body.sections || {}, { by: body.by || '' });
794
+ if (out.applied.length) notifyPrefs(out.applied, body.by || '');
788
795
  return sendJson(res, 200, { ok: true, ...out });
789
796
  } catch (e) {
790
797
  return sendJson(res, 400, { error: { message: `prefs write failed: ${e.message}`, type: 'prefs_error' } });
@@ -792,7 +799,87 @@ export function createGateway(cfg = loadConfig()) {
792
799
  }
793
800
  if (pathname === '/v1/prefs' && req.method === 'DELETE') {
794
801
  const section = String(url.searchParams.get('section') || '');
795
- return sendJson(res, 200, { ok: true, removed: section ? prefsStore.remove(section) : false });
802
+ const removed = section ? prefsStore.remove(section) : false;
803
+ if (removed) notifyPrefs([section], '');
804
+ return sendJson(res, 200, { ok: true, removed });
805
+ }
806
+ // Live prefs: one SSE stream a client keeps open, told which sections the OTHER client
807
+ // wrote. Without it a team defined on the desktop reached the extension at its next
808
+ // focus; with it, at once. `{ type: 'hello', revision }` first, so a reader knows where
809
+ // it stands; a heartbeat keeps proxies from closing an idle stream.
810
+ if (pathname === '/v1/prefs/events' && req.method === 'GET') {
811
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
812
+ const sendEv = (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`);
813
+ sendEv({ type: 'hello', revision: prefsStore.revision, stamps: prefsStore.stamps() });
814
+ const fn = (ev) => sendEv({ type: 'changed', ...ev });
815
+ prefsWatchers.add(fn);
816
+ const beat = setInterval(() => { try { res.write(': keep-alive\n\n'); } catch { /* closed */ } }, 25_000);
817
+ res.on('close', () => { prefsWatchers.delete(fn); clearInterval(beat); });
818
+ return undefined;
819
+ }
820
+
821
+ // --- TEAM RUNS. The board every client can read (team-store.js).
822
+ // GET /v1/teams/runs[?limit&team] → { ok, runs } newest first, no boards
823
+ // POST /v1/teams/runs { id, team, request, client } → { ok, run }
824
+ // GET /v1/teams/runs/:id[?events=1] → { ok, run } the board, the tasks, the proposal
825
+ // POST /v1/teams/runs/:id/events { events: [...] } → { ok, run } the running client appends
826
+ // GET /v1/teams/runs/:id/events[?after=seq] (SSE) replay from `after`, then live
827
+ // POST /v1/teams/runs/:id/stop → { ok, run } a stop request any client may make
828
+ // DELETE /v1/teams/runs/:id → { ok, removed }
829
+ if (pathname === '/v1/teams/runs' && req.method === 'GET') {
830
+ return sendJson(res, 200, { ok: true, runs: teamStore.list({ limit: url.searchParams.get('limit') || 50, team: url.searchParams.get('team') || '' }) });
831
+ }
832
+ if (pathname === '/v1/teams/runs' && req.method === 'POST') {
833
+ try {
834
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
835
+ return sendJson(res, 200, { ok: true, run: teamStore.create({ id: body.id, client: body.client, team: body.team, request: body.request }) });
836
+ } catch (e) {
837
+ return sendJson(res, 400, { error: { message: `team run: ${e.message}`, type: 'team_error' } });
838
+ }
839
+ }
840
+ {
841
+ const m = /^\/v1\/teams\/runs\/([a-zA-Z0-9_-]{4,64})(\/events|\/stop)?$/.exec(pathname);
842
+ if (m) {
843
+ const id = m[1];
844
+ const sub = m[2] || '';
845
+ if (!sub && req.method === 'GET') {
846
+ const run = teamStore.get(id, { events: url.searchParams.get('events') === '1' });
847
+ return run ? sendJson(res, 200, { ok: true, run }) : sendJson(res, 404, { error: { message: `no run ${id}`, type: 'not_found' } });
848
+ }
849
+ if (!sub && req.method === 'DELETE') return sendJson(res, 200, { ok: true, removed: teamStore.remove(id) });
850
+ if (sub === '/stop' && req.method === 'POST') {
851
+ const run = teamStore.stop(id);
852
+ return run ? sendJson(res, 200, { ok: true, run }) : sendJson(res, 404, { error: { message: `no run ${id}`, type: 'not_found' } });
853
+ }
854
+ if (sub === '/events' && req.method === 'POST') {
855
+ try {
856
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
857
+ return sendJson(res, 200, { ok: true, run: teamStore.append(id, body.events || []) });
858
+ } catch (e) {
859
+ return sendJson(res, e.message.startsWith('no run') ? 404 : 400, { error: { message: `team run: ${e.message}`, type: 'team_error' } });
860
+ }
861
+ }
862
+ if (sub === '/events' && req.method === 'GET') {
863
+ if (!teamStore.get(id)) return sendJson(res, 404, { error: { message: `no run ${id}`, type: 'not_found' } });
864
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
865
+ const sendEv = (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`);
866
+ // `Number(null)` is 0, not NaN: an absent `after` must mean "from the start" (-1),
867
+ // or the first event of every run is skipped.
868
+ const after = url.searchParams.has('after') ? Number(url.searchParams.get('after')) : NaN;
869
+ // The record as it stands, FIRST — it flushes the headers (writeHead alone sends
870
+ // nothing, and a reader whose fetch has not resolved can miss the opening events),
871
+ // and a late reader gets the board without a second request. Then replay from
872
+ // `after`, then tail; the watcher is registered before the replay is read and
873
+ // duplicates are dropped by seq, so nothing lands in the gap.
874
+ let last = Number.isFinite(after) ? after : -1;
875
+ sendEv({ seq: -1, type: 'hello', at: Date.now(), payload: { run: teamStore.get(id), after: last } });
876
+ const off = teamStore.watch(id, (ev) => { if (ev.seq > last) { last = ev.seq; sendEv(ev); } });
877
+ for (const ev of teamStore.eventsSince(id, last)) { last = ev.seq; sendEv(ev); }
878
+ const beat = setInterval(() => { try { res.write(': keep-alive\n\n'); } catch { /* closed */ } }, 25_000);
879
+ res.on('close', () => { off(); clearInterval(beat); });
880
+ return undefined;
881
+ }
882
+ }
796
883
  }
797
884
 
798
885
  // --- MEMORY. Small, durable facts about the user, reachable by every local agent.
@@ -0,0 +1,196 @@
1
+ // TEAM RUNS — the board every client can read, held here because the gateway is the one
2
+ // address the extension and the desktop both have.
3
+ //
4
+ // A run happens in ONE client (its models, its tools, its guards), but the extension cannot
5
+ // read the desktop's database and vice versa. So the running client APPENDS the run's
6
+ // events here as they happen — planned, started, a finding, done — and any client can read
7
+ // the run: list it, open its board, follow it live (SSE), and stop it. The store is the
8
+ // truth about what a team did; the client that ran it is just the first reader.
9
+ //
10
+ // Events are the unit, applied to a run record as they arrive (the shape @chatpanel/events
11
+ // team-run.js emits), so a reader that joins late replays and one that is watching tails.
12
+ // A run whose writer went quiet is reported as such: `staleAfterMs` past its last event a
13
+ // `running` run is marked stale, never silently kept "running" forever. Stop is a flag the
14
+ // running client sees on its own SSE stream and honours; the store cannot kill anything.
15
+ //
16
+ // Encrypted at rest with the device key like memory and prefs — a board carries the user's
17
+ // own findings — and bounded: the newest runs are kept, the oldest evicted.
18
+
19
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
20
+ import { join, dirname } from 'node:path';
21
+ import os from 'node:os';
22
+ import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
23
+
24
+ const DIR = join(os.homedir(), '.chatpanel');
25
+ const STORE_PATH = process.env.CHATPANEL_TEAMS_STORE || join(DIR, 'team-runs.enc');
26
+ const KEY_PATH = process.env.CHATPANEL_HISTORY_KEY || join(DIR, 'history-key');
27
+
28
+ export const MAX_RUNS = 200;
29
+ export const MAX_EVENTS_PER_RUN = 2000;
30
+ export const MAX_EVENT_BYTES = 64 * 1024;
31
+ export const STALE_AFTER_MS = 5 * 60_000;
32
+ const RUN_ID_RE = /^[a-zA-Z0-9_-]{4,64}$/;
33
+ const LIVE = new Set(['planning', 'running', 'merging']);
34
+
35
+ function loadOrCreateKey() {
36
+ try { if (existsSync(KEY_PATH)) return Buffer.from(readFileSync(KEY_PATH, 'utf8').trim(), 'base64'); } catch { /* regenerate */ }
37
+ const key = randomBytes(32);
38
+ mkdirSync(dirname(KEY_PATH), { recursive: true, mode: 0o700 });
39
+ writeFileSync(KEY_PATH, key.toString('base64'), { mode: 0o600 });
40
+ return key;
41
+ }
42
+ function encrypt(key, buf) {
43
+ const iv = randomBytes(12);
44
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
45
+ const ct = Buffer.concat([cipher.update(buf), cipher.final()]);
46
+ return { v: 1, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ct: ct.toString('base64') };
47
+ }
48
+ function decrypt(key, env) {
49
+ const d = createDecipheriv('aes-256-gcm', key, Buffer.from(env.iv, 'base64'));
50
+ d.setAuthTag(Buffer.from(env.tag, 'base64'));
51
+ return Buffer.concat([d.update(Buffer.from(env.ct, 'base64')), d.final()]);
52
+ }
53
+ const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
54
+
55
+ /** Apply one event to a run record. The record is the fold of its events. */
56
+ export function applyEvent(run, ev) {
57
+ const type = String(ev?.type || '');
58
+ const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : {};
59
+ run.lastEventAt = ev.at;
60
+ switch (type) {
61
+ case 'run.started':
62
+ run.team = p.team || run.team; run.request = p.request ?? run.request; run.budget = p.budget || run.budget;
63
+ run.roles = Array.isArray(p.roles) ? p.roles : run.roles; run.status = 'planning'; run.startedAt = run.startedAt || ev.at;
64
+ break;
65
+ case 'plan.ready':
66
+ run.plan = { by: p.by || 'fixed', tasks: Array.isArray(p.tasks) ? p.tasks : [] };
67
+ run.tasks = run.plan.tasks.map((t) => ({ id: t.id, role: t.role, title: t.title, status: 'pending', findings: 0 }));
68
+ run.status = 'running';
69
+ break;
70
+ case 'task.started': { const t = run.tasks.find((x) => x.id === p.taskId); if (t) { t.status = 'running'; t.startedAt = ev.at; } run.status = 'running'; break; }
71
+ case 'task.delta': { const t = run.tasks.find((x) => x.id === p.taskId); if (t) t.text = String(p.text || '').slice(0, 20_000); break; }
72
+ case 'task.finding':
73
+ if (p.finding && p.finding.text) { run.board.push({ ...p.finding, at: ev.at }); const t = run.tasks.find((x) => x.id === p.taskId); if (t) t.findings += 1; }
74
+ break;
75
+ case 'task.done':
76
+ case 'task.failed': { const t = run.tasks.find((x) => x.id === p.taskId); if (t) { t.status = p.status || (type === 'task.done' ? 'ok' : 'failed'); t.error = p.error || null; t.ms = p.ms; } break; }
77
+ case 'run.merging': run.status = 'merging'; break;
78
+ case 'run.done':
79
+ run.status = p.status || 'completed'; run.usage = p.usage || run.usage; run.proposal = p.proposal ?? run.proposal; run.endedAt = ev.at;
80
+ break;
81
+ case 'run.stop-requested': run.stopRequested = ev.at; break;
82
+ default: break;
83
+ }
84
+ return run;
85
+ }
86
+
87
+ export class TeamStore {
88
+ constructor({ storePath = STORE_PATH, now = () => Date.now(), staleAfterMs = STALE_AFTER_MS } = {}) {
89
+ this.path = storePath;
90
+ this.now = now;
91
+ this.staleAfterMs = staleAfterMs;
92
+ this._key = null;
93
+ this.runs = new Map(); // id -> { id, client, createdAt, events: [], ...folded record }
94
+ this.watchers = new Map(); // id -> Set<fn(ev)>
95
+ }
96
+ load() {
97
+ this._key = loadOrCreateKey();
98
+ try {
99
+ if (existsSync(this.path)) {
100
+ const env = JSON.parse(readFileSync(this.path, 'utf8'));
101
+ const doc = JSON.parse(decrypt(this._key, env).toString('utf8'));
102
+ for (const r of Array.isArray(doc?.runs) ? doc.runs : []) if (r?.id) this.runs.set(r.id, r);
103
+ }
104
+ } catch { this.runs = new Map(); }
105
+ return this;
106
+ }
107
+ save() {
108
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
109
+ const env = encrypt(this._key, Buffer.from(JSON.stringify({ v: 1, runs: [...this.runs.values()] }), 'utf8'));
110
+ const tmp = `${this.path}.${process.pid}.tmp`;
111
+ writeFileSync(tmp, JSON.stringify(env), { mode: 0o600 });
112
+ renameSync(tmp, this.path);
113
+ }
114
+ _fresh(id, { client = '' } = {}) {
115
+ return { id, client: String(client || '').slice(0, 40), createdAt: this.now(), lastEventAt: this.now(), status: 'planning', team: '', request: '', roles: [], plan: null, tasks: [], board: [], proposal: null, usage: null, stopRequested: null, events: [] };
116
+ }
117
+ _evict() {
118
+ if (this.runs.size <= MAX_RUNS) return;
119
+ const byAge = [...this.runs.values()].sort((a, b) => a.createdAt - b.createdAt);
120
+ for (const r of byAge.slice(0, this.runs.size - MAX_RUNS)) this.runs.delete(r.id);
121
+ }
122
+ /** The record with liveness judged NOW, never as it was last written. */
123
+ _view(run, { events = false } = {}) {
124
+ const v = clone({ ...run, events: undefined });
125
+ delete v.events;
126
+ v.stale = LIVE.has(run.status) && this.now() - run.lastEventAt > this.staleAfterMs;
127
+ if (events) v.events = clone(run.events);
128
+ return v;
129
+ }
130
+ create({ id, client, team, request } = {}) {
131
+ if (!RUN_ID_RE.test(String(id || ''))) throw new Error('run id: 4–64 of [a-zA-Z0-9_-]');
132
+ if (this.runs.has(id)) throw new Error(`run ${id} already exists`);
133
+ const run = this._fresh(id, { client });
134
+ run.team = String(team || '').slice(0, 64);
135
+ run.request = String(request || '').slice(0, 4000);
136
+ this.runs.set(id, run);
137
+ this._evict();
138
+ this.save();
139
+ return this._view(run);
140
+ }
141
+ /** Append events (the runner's `emit` shape: `{ type, at, ...payload }`). Returns the view. */
142
+ append(id, events) {
143
+ const run = this.runs.get(String(id || ''));
144
+ if (!run) throw new Error(`no run ${id}`);
145
+ const list = Array.isArray(events) ? events : [events];
146
+ let seq = run.events.length;
147
+ for (const e of list) {
148
+ if (!e || typeof e !== 'object' || !e.type) continue;
149
+ let bytes; try { bytes = Buffer.byteLength(JSON.stringify(e), 'utf8'); } catch { continue; }
150
+ if (bytes > MAX_EVENT_BYTES) continue;
151
+ if (run.events.length >= MAX_EVENTS_PER_RUN) break;
152
+ const { type, at, runId: _r, ...payload } = e;
153
+ const ev = { seq: seq++, type: String(type), at: Number(at) || this.now(), payload };
154
+ run.events.push(ev);
155
+ applyEvent(run, ev);
156
+ for (const fn of this.watchers.get(run.id) || []) { try { fn(ev); } catch { /* a dead watcher */ } }
157
+ }
158
+ this.save();
159
+ return this._view(run);
160
+ }
161
+ get(id, opts) { const r = this.runs.get(String(id || '')); return r ? this._view(r, opts) : null; }
162
+ /** Newest first, without boards — the list a lens shows. */
163
+ list({ limit = 50, team = '' } = {}) {
164
+ return [...this.runs.values()]
165
+ .filter((r) => !team || r.team === team)
166
+ .sort((a, b) => b.createdAt - a.createdAt)
167
+ .slice(0, Math.max(1, Math.min(200, Number(limit) || 50)))
168
+ .map((r) => { const v = this._view(r); return { ...v, board: undefined, tasks: v.tasks.map((t) => ({ ...t, text: undefined })), findings: r.board.length }; });
169
+ }
170
+ /** Ask the running client to stop. Recorded as an event, so watchers (the runner) see it. */
171
+ stop(id) {
172
+ const run = this.runs.get(String(id || ''));
173
+ if (!run) return null;
174
+ if (!LIVE.has(run.status)) return this._view(run);
175
+ return this.append(id, [{ type: 'run.stop-requested', at: this.now() }]);
176
+ }
177
+ /** Events from `after` (a seq) onward, for a late reader's replay. */
178
+ eventsSince(id, after = -1) {
179
+ const run = this.runs.get(String(id || ''));
180
+ if (!run) return [];
181
+ return run.events.filter((e) => e.seq > after).map(clone);
182
+ }
183
+ watch(id, fn) {
184
+ if (!this.watchers.has(id)) this.watchers.set(id, new Set());
185
+ this.watchers.get(id).add(fn);
186
+ return () => this.watchers.get(id)?.delete(fn);
187
+ }
188
+ remove(id) {
189
+ const had = this.runs.delete(String(id || ''));
190
+ if (had) this.save();
191
+ return had;
192
+ }
193
+ get size() { return this.runs.size; }
194
+ }
195
+
196
+ export function createTeamStore(opts) { return new TeamStore(opts).load(); }