@chatpanel/gateway 0.6.76 → 0.6.78

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.76",
3
+ "version": "0.6.78",
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": {
@@ -0,0 +1,149 @@
1
+ // CLIENT PREFERENCES — the settings every ChatPanel client shares, held here because the
2
+ // gateway is the one address the extension and the desktop both have.
3
+ //
4
+ // A document of SECTIONS (the MCP server list, the skills, the search engines — see
5
+ // @chatpanel/events/client-prefs.js), each stamped with when it was last written. A client
6
+ // pushes the sections it changed with its own stamps; the store keeps the newer stamp per
7
+ // section and tells the pusher which of its sections lost, so it can take the other side's
8
+ // copy. Per-section last-writer-wins: a section is one screen a person edits, and merging two
9
+ // edits of one list key by key would make a list neither of them made.
10
+ //
11
+ // ENCRYPTED AT REST with the same device key as memory and history — an MCP server entry can
12
+ // carry an Authorization header, and this file must not be the plaintext copy of it.
13
+
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
15
+ import { join, dirname } from 'node:path';
16
+ import os from 'node:os';
17
+ import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
18
+
19
+ const DIR = join(os.homedir(), '.chatpanel');
20
+ const STORE_PATH = process.env.CHATPANEL_PREFS_STORE || join(DIR, 'prefs-store.enc');
21
+ const KEY_PATH = process.env.CHATPANEL_HISTORY_KEY || join(DIR, 'history-key');
22
+
23
+ /** A section id is a short word; anything else is refused before it is stored. */
24
+ const SECTION_ID_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
25
+ /** One section's JSON, serialized — a list of a thousand MCP servers is not a preference. */
26
+ const MAX_SECTION_BYTES = 512 * 1024;
27
+
28
+ function loadOrCreateKey() {
29
+ try {
30
+ if (existsSync(KEY_PATH)) return Buffer.from(readFileSync(KEY_PATH, 'utf8').trim(), 'base64');
31
+ } catch { /* regenerate below */ }
32
+ const key = randomBytes(32);
33
+ mkdirSync(dirname(KEY_PATH), { recursive: true, mode: 0o700 });
34
+ writeFileSync(KEY_PATH, key.toString('base64'), { mode: 0o600 });
35
+ return key;
36
+ }
37
+
38
+ function encrypt(key, buf) {
39
+ const iv = randomBytes(12);
40
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
41
+ const ct = Buffer.concat([cipher.update(buf), cipher.final()]);
42
+ return { v: 1, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ct: ct.toString('base64') };
43
+ }
44
+
45
+ function decrypt(key, env) {
46
+ const d = createDecipheriv('aes-256-gcm', key, Buffer.from(env.iv, 'base64'));
47
+ d.setAuthTag(Buffer.from(env.tag, 'base64'));
48
+ return Buffer.concat([d.update(Buffer.from(env.ct, 'base64')), d.final()]);
49
+ }
50
+
51
+ export class PrefsStore {
52
+ constructor({ storePath = STORE_PATH } = {}) {
53
+ this.path = storePath;
54
+ this._key = null;
55
+ this.sections = {}; // id -> { value, updatedAt, by }
56
+ this.revision = 0; // bumps on every accepted write, so a client can ask "anything new?"
57
+ }
58
+
59
+ load() {
60
+ this._key = loadOrCreateKey();
61
+ try {
62
+ if (existsSync(this.path)) {
63
+ const env = JSON.parse(readFileSync(this.path, 'utf8'));
64
+ const doc = JSON.parse(decrypt(this._key, env).toString('utf8'));
65
+ this.sections = doc?.sections && typeof doc.sections === 'object' ? doc.sections : {};
66
+ this.revision = Number(doc?.revision) || 0;
67
+ }
68
+ } catch {
69
+ // An unreadable store is an empty one, never a crash: the clients still hold their own
70
+ // copies and will push them back on the next change.
71
+ this.sections = {};
72
+ this.revision = 0;
73
+ }
74
+ return this;
75
+ }
76
+
77
+ save() {
78
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
79
+ const env = encrypt(this._key, Buffer.from(JSON.stringify({ v: 1, revision: this.revision, sections: this.sections }), 'utf8'));
80
+ // Write beside, then rename: a crash mid-write leaves the old file, not half of a new one.
81
+ const tmp = `${this.path}.${process.pid}.tmp`;
82
+ writeFileSync(tmp, JSON.stringify(env), { mode: 0o600 });
83
+ renameSync(tmp, this.path);
84
+ }
85
+
86
+ /** Every section, or one. Values are the caller's to keep — they are copies. */
87
+ get(id = '') {
88
+ if (id) {
89
+ const s = this.sections[id];
90
+ return s ? { [id]: { value: clone(s.value), updatedAt: s.updatedAt, by: s.by || '' } } : {};
91
+ }
92
+ const out = {};
93
+ for (const [k, s] of Object.entries(this.sections)) out[k] = { value: clone(s.value), updatedAt: s.updatedAt, by: s.by || '' };
94
+ return out;
95
+ }
96
+
97
+ /** Just the stamps — enough for a client to decide whether it needs the values. */
98
+ stamps() {
99
+ const out = {};
100
+ for (const [k, s] of Object.entries(this.sections)) out[k] = s.updatedAt;
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * Merge a client's stamped sections in. A section is taken when its stamp is newer than
106
+ * the one held (or nothing is held); otherwise the client is told it lost and gets the
107
+ * held copy back. Returns `{ applied, kept, sections }` where `sections` holds the current
108
+ * copies of everything the client sent — the client writes `kept` ones over its own.
109
+ */
110
+ put(incoming, { by = '' } = {}) {
111
+ const applied = [];
112
+ const kept = [];
113
+ const sections = {};
114
+ for (const [id, entry] of Object.entries(incoming || {})) {
115
+ if (!SECTION_ID_RE.test(id)) continue;
116
+ const updatedAt = Number(entry?.updatedAt) || 0;
117
+ if (!updatedAt || entry?.value === undefined) continue;
118
+ let bytes;
119
+ try { bytes = Buffer.byteLength(JSON.stringify(entry.value), 'utf8'); } catch { continue; }
120
+ if (bytes > MAX_SECTION_BYTES) continue;
121
+ const held = this.sections[id];
122
+ if (!held || updatedAt > held.updatedAt) {
123
+ this.sections[id] = { value: clone(entry.value), updatedAt, by: String(by || '').slice(0, 40) };
124
+ applied.push(id);
125
+ } else {
126
+ kept.push(id);
127
+ }
128
+ const cur = this.sections[id];
129
+ sections[id] = { value: clone(cur.value), updatedAt: cur.updatedAt, by: cur.by || '' };
130
+ }
131
+ if (applied.length) { this.revision += 1; this.save(); }
132
+ return { applied, kept, sections, revision: this.revision };
133
+ }
134
+
135
+ /** Forget one section entirely — the user removed the feature's settings, not just emptied them. */
136
+ remove(id) {
137
+ if (!this.sections[id]) return false;
138
+ delete this.sections[id];
139
+ this.revision += 1;
140
+ this.save();
141
+ return true;
142
+ }
143
+ }
144
+
145
+ const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
146
+
147
+ export function createPrefsStore(opts) {
148
+ return new PrefsStore(opts).load();
149
+ }
package/src/server.js CHANGED
@@ -33,6 +33,8 @@ import { startNer, ensureNer } from './ner.js';
33
33
  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
+ import { createPrefsStore } from './prefs-store.js';
37
+ import { createTeamStore } from './team-store.js';
36
38
  import { createHistoryStore } from './sqlite-store.js';
37
39
  import { ingestBackups } from './backup-ingest.js';
38
40
  import * as nerEngine from './ner-engine.js';
@@ -56,7 +58,7 @@ import * as openai from './openai.js';
56
58
  import * as responses from './responses.js';
57
59
  import * as anthropic from './anthropic.js';
58
60
 
59
- export const VERSION = '0.6.76';
61
+ export const VERSION = '0.6.78';
60
62
 
61
63
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
62
64
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -64,6 +66,12 @@ export const VERSION = '0.6.76';
64
66
  // See docs/architecture-data-tiers.
65
67
  const historyStore = await createHistoryStore();
66
68
  const memoryStore = await createMemoryStore();
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 */ } } };
67
75
 
68
76
  // OBSERVABILITY — a ring of "which agent read what, when", persisted across restarts (the
69
77
  // gateway updates often; an empty panel after each restart reads as "nothing is set up").
@@ -128,7 +136,7 @@ function originAllowed(origin, cfg) {
128
136
 
129
137
  function setCors(res, origin) {
130
138
  res.setHeader('Access-Control-Allow-Origin', origin || '*');
131
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
139
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
132
140
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-ChatPanel-Token');
133
141
  res.setHeader('Vary', 'Origin');
134
142
  }
@@ -693,6 +701,12 @@ export function createGateway(cfg = loadConfig()) {
693
701
  && req.method === 'POST' && !isAdminAuthorized(req)) {
694
702
  return sendJson(res, 403, { error: { message: 'memory write — extension origin or gateway token required', type: 'forbidden' } });
695
703
  }
704
+ // Client preferences travel between the extension and the desktop through here, and an
705
+ // MCP server entry can carry an Authorization header — so READS are gated too, unlike
706
+ // history and memory. A drive-by page must not learn what tools the user connected.
707
+ if ((pathname === '/v1/prefs' || pathname.startsWith('/v1/prefs/') || pathname.startsWith('/v1/teams')) && !isAdminAuthorized(req)) {
708
+ return sendJson(res, 403, { error: { message: 'prefs: extension origin or gateway token required', type: 'forbidden' } });
709
+ }
696
710
  // The access log is who-read-what — sensitive, and writable only by the local MCP
697
711
  // process (which sends the gateway token). Extension Origin or token for both the
698
712
  // read (dashboard) and the report (MCP child); a drive-by page has neither.
@@ -764,6 +778,110 @@ export function createGateway(cfg = loadConfig()) {
764
778
  return sendJson(res, 400, { error: { message: `ingest failed: ${e.message}`, type: 'ingest_error' } });
765
779
  }
766
780
  }
781
+ // --- CLIENT PREFERENCES — the settings every client shares (prefs-store.js).
782
+ // GET /v1/prefs[?section=id][&stamps=1] → { ok, revision, sections: { id: { value, updatedAt, by } } }
783
+ // POST /v1/prefs { sections: { id: { value, updatedAt } }, by } → { ok, revision, applied, kept, sections }
784
+ // DELETE /v1/prefs?section=id → { ok, removed }
785
+ if (pathname === '/v1/prefs' && req.method === 'GET') {
786
+ const section = String(url.searchParams.get('section') || '');
787
+ if (url.searchParams.get('stamps')) return sendJson(res, 200, { ok: true, revision: prefsStore.revision, stamps: prefsStore.stamps() });
788
+ return sendJson(res, 200, { ok: true, revision: prefsStore.revision, sections: prefsStore.get(section) });
789
+ }
790
+ if (pathname === '/v1/prefs' && (req.method === 'POST' || req.method === 'PUT')) {
791
+ try {
792
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
793
+ const out = prefsStore.put(body.sections || {}, { by: body.by || '' });
794
+ if (out.applied.length) notifyPrefs(out.applied, body.by || '');
795
+ return sendJson(res, 200, { ok: true, ...out });
796
+ } catch (e) {
797
+ return sendJson(res, 400, { error: { message: `prefs write failed: ${e.message}`, type: 'prefs_error' } });
798
+ }
799
+ }
800
+ if (pathname === '/v1/prefs' && req.method === 'DELETE') {
801
+ const section = String(url.searchParams.get('section') || '');
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
+ }
883
+ }
884
+
767
885
  // --- MEMORY. Small, durable facts about the user, reachable by every local agent.
768
886
  // GET /v1/memory/list → { memories }
769
887
  // POST /v1/memory/recall { text, scopes } → { memories, block }
@@ -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(); }