@chatpanel/gateway 0.6.84 → 0.6.86

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.84",
3
+ "version": "0.6.86",
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/server.js CHANGED
@@ -58,7 +58,7 @@ import * as openai from './openai.js';
58
58
  import * as responses from './responses.js';
59
59
  import * as anthropic from './anthropic.js';
60
60
 
61
- export const VERSION = '0.6.84';
61
+ export const VERSION = '0.6.86';
62
62
 
63
63
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
64
64
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -829,6 +829,9 @@ export function createGateway(cfg = loadConfig()) {
829
829
  // POST /v1/teams/runs/:id/answer { threadId, text, by } → { ok, run } a person answers an ask (0.6.81)
830
830
  // POST /v1/teams/runs/:id/decide { postId, status, by } → { ok, run } approve / reject a post
831
831
  // POST /v1/teams/runs/:id/post { threadId, text, kind?, replyTo?, by } → { ok, run }
832
+ // POST /v1/teams/runs/:id/handoff { taskId, model, by, reason } → { ok, run } continue a task on another model (0.6.85)
833
+ // GET /v1/teams/runs/:id/checkpoint → { ok, checkpoint } what resumeTeam needs, from the record
834
+ // POST /v1/teams/runs/:id/claim { client } → { ok, run } a client takes a stopped/stale run over
832
835
  // POST /v1/teams/runs/:id/stop → { ok, run } a stop request any client may make
833
836
  // DELETE /v1/teams/runs/:id → { ok, removed }
834
837
  if (pathname === '/v1/teams/runs' && req.method === 'GET') {
@@ -843,7 +846,7 @@ export function createGateway(cfg = loadConfig()) {
843
846
  }
844
847
  }
845
848
  {
846
- const m = /^\/v1\/teams\/runs\/([a-zA-Z0-9_-]{4,64})(\/events|\/stop|\/answer|\/decide|\/post)?$/.exec(pathname);
849
+ const m = /^\/v1\/teams\/runs\/([a-zA-Z0-9_-]{4,64})(\/events|\/stop|\/answer|\/decide|\/post|\/handoff|\/checkpoint|\/claim)?$/.exec(pathname);
847
850
  if (m) {
848
851
  const id = m[1];
849
852
  const sub = m[2] || '';
@@ -858,6 +861,20 @@ export function createGateway(cfg = loadConfig()) {
858
861
  }
859
862
  // The board, from a person on ANY client (0.6.81): answer an ask, decide on a post,
860
863
  // post a note. Each is appended as events, so the running client's tail sees it.
864
+ // A task's continuation from another client (0.6.85): hand a task to another model; read
865
+ // the checkpoint to resume a run whose client went away; claim it when resuming.
866
+ if (sub === '/checkpoint' && req.method === 'GET') {
867
+ try { return sendJson(res, 200, { ok: true, checkpoint: teamStore.checkpoint(id) }); } catch (e) { return sendJson(res, 404, { error: { message: `team run: ${e.message}`, type: 'team_error' } }); }
868
+ }
869
+ if ((sub === '/handoff' || sub === '/claim') && req.method === 'POST') {
870
+ try {
871
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
872
+ const run = sub === '/handoff' ? teamStore.handoff(id, { taskId: body.taskId, model: body.model, by: body.by, reason: body.reason }) : teamStore.claim(id, { client: body.client });
873
+ return sendJson(res, 200, { ok: true, run });
874
+ } catch (e) {
875
+ return sendJson(res, e.message.startsWith('no ') ? 404 : 400, { error: { message: `team run: ${e.message}`, type: 'team_error' } });
876
+ }
877
+ }
861
878
  if ((sub === '/answer' || sub === '/decide' || sub === '/post') && req.method === 'POST') {
862
879
  try {
863
880
  const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
@@ -0,0 +1,103 @@
1
+ // VENDORED from @chatpanel/events/team-record.js — edit there, then copy over.
2
+ // The run record — a team run folded from its events, the same way on the gateway's store
3
+ // and in either client. One fold, so what the desktop shows, what the extension shows and
4
+ // what the store holds never disagree; and enough on the record to RESUME the run from
5
+ // anywhere: the plan, every task's status and transcript, the board, the spend.
6
+ //
7
+ // The gateway vendors this file (its store used to carry a mirror of it, which is the copy
8
+ // that drifts). `checkpointFrom(run)` is what a client hands `resumeTeam` after reading a
9
+ // run the process that started it no longer runs.
10
+
11
+ import { foldBoard, emptyBoardState } from './team-board.js';
12
+
13
+ export const LIVE_RUN_STATUSES = Object.freeze(['planning', 'running', 'merging', 'waiting']);
14
+ export const RESUMABLE_RUN_STATUSES = Object.freeze(['waiting', 'stopped', 'failed', 'partial', 'over-budget', 'answered']);
15
+ const TASK_TEXT_MAX = 20_000;
16
+
17
+ export function emptyRun({ id, client = '', now = Date.now() } = {}) {
18
+ return { id, client: String(client || '').slice(0, 40), createdAt: now, lastEventAt: now, status: 'planning', team: '', request: '', roles: [], plan: null, tasks: [], board: [], threads: emptyBoardState(), checkpoint: null, proposal: null, usage: null, stopRequested: null, startedAt: null, endedAt: null };
19
+ }
20
+
21
+ const taskOf = (run, id) => run.tasks.find((x) => x.id === id);
22
+
23
+ /** Fold one event (`{ type, at, payload }`, or a flat `{ type, at, ...payload }`) into the record. */
24
+ export function foldRun(run, ev) {
25
+ const type = String(ev?.type || '');
26
+ const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : (ev || {});
27
+ const at = Number(ev?.at) || Date.now();
28
+ run.lastEventAt = at;
29
+ switch (type) {
30
+ case 'run.started':
31
+ case 'run.resumed':
32
+ run.team = p.team || run.team; run.request = p.request ?? run.request; run.budget = p.budget || run.budget;
33
+ run.roles = Array.isArray(p.roles) ? p.roles : run.roles; run.status = 'planning'; run.startedAt = run.startedAt || at;
34
+ if (type === 'run.resumed') { run.endedAt = null; run.checkpoint = null; run.resumedAt = at; }
35
+ break;
36
+ case 'plan.ready':
37
+ run.plan = { by: p.by || 'fixed', tasks: Array.isArray(p.tasks) ? p.tasks : [] };
38
+ // A resume replays the plan: keep what the tasks already hold (transcripts, attempts).
39
+ run.tasks = run.plan.tasks.map((t) => ({ ...(taskOf(run, t.id) || {}), id: t.id, role: t.role, title: t.title, status: taskOf(run, t.id)?.status === 'ok' ? 'ok' : 'pending', findings: taskOf(run, t.id)?.findings || 0 }));
40
+ run.status = 'running';
41
+ break;
42
+ case 'task.started': { const t = taskOf(run, p.taskId); if (t) { t.status = 'running'; t.startedAt = at; t.error = null; } run.status = 'running'; break; }
43
+ case 'task.model': { const t = taskOf(run, p.taskId); if (t) { t.model = p.model; t.attempts = [...(t.attempts || []), { model: p.model, at, attempt: p.attempt }]; } break; }
44
+ case 'task.step': { const t = taskOf(run, p.taskId); if (t && Array.isArray(p.steps)) t.transcript = [...(t.transcript || []), ...p.steps]; break; }
45
+ case 'task.handoff': { const t = taskOf(run, p.taskId); if (t) { t.model = p.to; t.handoffs = [...(t.handoffs || []), { from: p.from, to: p.to, by: p.by, reason: p.reason, at }]; } break; }
46
+ case 'task.tool': { const t = taskOf(run, p.taskId); if (t) t.tools = (t.tools || 0) + 1; break; }
47
+ case 'task.delta': { const t = taskOf(run, p.taskId); if (t) t.text = String(p.text || '').slice(0, TASK_TEXT_MAX); break; }
48
+ case 'task.finding':
49
+ if (p.finding && p.finding.text) { run.board.push({ ...p.finding, at }); const t = taskOf(run, p.taskId); if (t) t.findings += 1; }
50
+ break;
51
+ case 'task.waiting': { const t = taskOf(run, p.taskId); if (t) { t.status = 'waiting'; t.waitingOn = p.threadId; } break; }
52
+ case 'task.done':
53
+ case 'task.failed': { const t = taskOf(run, p.taskId); if (t) { t.status = p.status || (type === 'task.done' ? 'ok' : 'failed'); t.error = p.error || null; t.ms = p.ms; t.endedAt = at; } break; }
54
+ case 'run.merging': run.status = 'merging'; break;
55
+ case 'run.waiting': run.status = 'running'; break;
56
+ case 'run.usage': run.usage = p.usage || run.usage; break;
57
+ case 'run.done':
58
+ run.status = p.status || 'completed'; run.usage = p.usage || run.usage; run.proposal = p.proposal ?? run.proposal; run.endedAt = at;
59
+ if (p.checkpoint) run.checkpoint = p.checkpoint;
60
+ break;
61
+ case 'run.stop-requested': run.stopRequested = at; break;
62
+ case 'board.thread': case 'board.post': case 'board.decision': case 'board.thread-status':
63
+ run.threads = foldBoard(run.threads || emptyBoardState(), ev);
64
+ if (type === 'board.thread-status' && p.status !== 'waiting' && run.status === 'waiting' && !(run.threads.threads || []).some((t) => t.kind === 'ask' && t.status === 'waiting')) run.status = 'answered';
65
+ break;
66
+ default: break;
67
+ }
68
+ return run;
69
+ }
70
+
71
+ /** Fold a whole event list into a fresh record. */
72
+ export function runFromEvents(id, events, opts = {}) {
73
+ const run = emptyRun({ id, ...opts });
74
+ for (const ev of events || []) foldRun(run, ev);
75
+ return run;
76
+ }
77
+
78
+ /**
79
+ * What `resumeTeam` needs, from the record alone — the runner's own checkpoint when the run
80
+ * ended with one, else one built from the folded tasks: whatever the process that ran it
81
+ * managed to write before it went. A task recorded `running` (its process died) resumes
82
+ * from its transcript like a waiting one.
83
+ */
84
+ export function checkpointFrom(run) {
85
+ if (!run?.plan?.tasks?.length) return null;
86
+ if (run.checkpoint?.plan?.tasks?.length) return { ...run.checkpoint, board: run.threads && run.threads.threads?.length >= (run.checkpoint.board?.threads?.length || 0) ? run.threads : run.checkpoint.board };
87
+ return {
88
+ runId: run.id,
89
+ startedAt: run.startedAt || run.createdAt,
90
+ plan: run.plan,
91
+ tasks: run.tasks.map((t) => ({ id: t.id, role: t.role, title: t.title, status: t.status === 'running' || t.status === 'pending' ? 'stopped' : t.status, text: t.text || '', error: t.error || null, transcript: t.transcript || [], attempts: t.attempts || [], usage: null })),
92
+ board: run.threads || emptyBoardState(),
93
+ budget: { cap: run.usage?.cap || run.budget || {}, spent: run.usage?.spent || {} },
94
+ budgetAsked: false,
95
+ };
96
+ }
97
+
98
+ /** Can this record be picked up again? Not one that completed, not one still being run by a live client. */
99
+ export function isResumable(run) {
100
+ if (!run?.plan?.tasks?.length) return false;
101
+ if (RESUMABLE_RUN_STATUSES.includes(run.status)) return true;
102
+ return !!run.stale && LIVE_RUN_STATUSES.includes(run.status); // its client went away mid-run
103
+ }
package/src/team-store.js CHANGED
@@ -20,7 +20,8 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from '
20
20
  import { join, dirname } from 'node:path';
21
21
  import os from 'node:os';
22
22
  import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
23
- import { foldBoard, emptyBoardState } from './team-board.js';
23
+ import { emptyBoardState } from './team-board.js';
24
+ import { foldRun, emptyRun, checkpointFrom, isResumable, LIVE_RUN_STATUSES } from './team-record.js';
24
25
 
25
26
  const DIR = join(os.homedir(), '.chatpanel');
26
27
  const STORE_PATH = process.env.CHATPANEL_TEAMS_STORE || join(DIR, 'team-runs.enc');
@@ -31,7 +32,7 @@ export const MAX_EVENTS_PER_RUN = 2000;
31
32
  export const MAX_EVENT_BYTES = 64 * 1024;
32
33
  export const STALE_AFTER_MS = 5 * 60_000;
33
34
  const RUN_ID_RE = /^[a-zA-Z0-9_-]{4,64}$/;
34
- const LIVE = new Set(['planning', 'running', 'merging']);
35
+ const LIVE = new Set(LIVE_RUN_STATUSES);
35
36
 
36
37
  function loadOrCreateKey() {
37
38
  try { if (existsSync(KEY_PATH)) return Buffer.from(readFileSync(KEY_PATH, 'utf8').trim(), 'base64'); } catch { /* regenerate */ }
@@ -53,50 +54,8 @@ function decrypt(key, env) {
53
54
  }
54
55
  const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
55
56
 
56
- /** Apply one event to a run record. The record is the fold of its events. */
57
- export function applyEvent(run, ev) {
58
- const type = String(ev?.type || '');
59
- const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : {};
60
- run.lastEventAt = ev.at;
61
- switch (type) {
62
- case 'run.started':
63
- run.team = p.team || run.team; run.request = p.request ?? run.request; run.budget = p.budget || run.budget;
64
- run.roles = Array.isArray(p.roles) ? p.roles : run.roles; run.status = 'planning'; run.startedAt = run.startedAt || ev.at;
65
- break;
66
- case 'plan.ready':
67
- run.plan = { by: p.by || 'fixed', tasks: Array.isArray(p.tasks) ? p.tasks : [] };
68
- run.tasks = run.plan.tasks.map((t) => ({ id: t.id, role: t.role, title: t.title, status: 'pending', findings: 0 }));
69
- run.status = 'running';
70
- break;
71
- 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; }
72
- 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; }
73
- case 'task.finding':
74
- 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; }
75
- break;
76
- case 'task.done':
77
- 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; }
78
- case 'run.merging': run.status = 'merging'; break;
79
- case 'run.done':
80
- run.status = p.status || 'completed'; run.usage = p.usage || run.usage; run.proposal = p.proposal ?? run.proposal; run.endedAt = ev.at;
81
- break;
82
- case 'run.stop-requested': run.stopRequested = ev.at; break;
83
- // The board as a message board (events 0.81): threads, posts, replies, decisions, asks.
84
- // Folded by the shared fold, so this record and both clients agree on it.
85
- case 'board.thread': case 'board.post': case 'board.decision': case 'board.thread-status':
86
- run.threads = foldBoard(run.threads || emptyBoardState(), ev);
87
- if (type === 'board.thread-status' && p.status !== 'waiting' && run.status === 'waiting' && !(run.threads.threads || []).some((t) => t.kind === 'ask' && t.status === 'waiting')) run.status = 'answered';
88
- break;
89
- case 'task.waiting': { const t = run.tasks.find((x) => x.id === p.taskId); if (t) { t.status = 'waiting'; t.waitingOn = p.threadId; } break; }
90
- case 'task.model': { const t = run.tasks.find((x) => x.id === p.taskId); if (t) t.model = p.model; break; }
91
- case 'task.tool': { const t = run.tasks.find((x) => x.id === p.taskId); if (t) t.tools = (t.tools || 0) + 1; break; }
92
- case 'run.usage': run.usage = p.usage || run.usage; break;
93
- case 'run.waiting': run.status = 'running'; break;
94
- case 'run.resumed': run.status = 'running'; run.endedAt = null; run.checkpoint = null; break;
95
- default: break;
96
- }
97
- if (type === 'run.done' && p.checkpoint) run.checkpoint = p.checkpoint;
98
- return run;
99
- }
57
+ /** Apply one event to a run record the shared fold (team-record.js, vendored from @chatpanel/events). */
58
+ export function applyEvent(run, ev) { return foldRun(run, ev); }
100
59
 
101
60
  export class TeamStore {
102
61
  constructor({ storePath = STORE_PATH, now = () => Date.now(), staleAfterMs = STALE_AFTER_MS } = {}) {
@@ -126,7 +85,7 @@ export class TeamStore {
126
85
  renameSync(tmp, this.path);
127
86
  }
128
87
  _fresh(id, { client = '' } = {}) {
129
- return { id, client: String(client || '').slice(0, 40), createdAt: this.now(), lastEventAt: this.now(), status: 'planning', team: '', request: '', roles: [], plan: null, tasks: [], board: [], threads: emptyBoardState(), checkpoint: null, proposal: null, usage: null, stopRequested: null, events: [] };
88
+ return { ...emptyRun({ id, client, now: this.now() }), events: [] };
130
89
  }
131
90
  _evict() {
132
91
  if (this.runs.size <= MAX_RUNS) return;
@@ -138,6 +97,11 @@ export class TeamStore {
138
97
  const v = clone({ ...run, events: undefined });
139
98
  delete v.events;
140
99
  v.stale = LIVE.has(run.status) && this.now() - run.lastEventAt > this.staleAfterMs;
100
+ // How long since the running client last wrote — a person who knows the process died
101
+ // does not have to wait for the stale mark to pick the run up.
102
+ v.quietMs = LIVE.has(run.status) ? Math.max(0, this.now() - run.lastEventAt) : 0;
103
+ // Can a client pick this run up again? Not while its own client is live on it.
104
+ v.resumable = isResumable(v);
141
105
  if (events) v.events = clone(run.events);
142
106
  return v;
143
107
  }
@@ -179,7 +143,7 @@ export class TeamStore {
179
143
  .filter((r) => !team || r.team === team)
180
144
  .sort((a, b) => b.createdAt - a.createdAt)
181
145
  .slice(0, Math.max(1, Math.min(200, Number(limit) || 50)))
182
- .map((r) => { const v = this._view(r); return { ...v, board: undefined, threads: undefined, checkpoint: undefined, tasks: v.tasks.map((t) => ({ ...t, text: undefined })), findings: r.board.length, waiting: (r.threads?.threads || []).filter((t) => t.kind === 'ask' && t.status === 'waiting').length }; });
146
+ .map((r) => { const v = this._view(r); return { ...v, board: undefined, threads: undefined, checkpoint: undefined, tasks: v.tasks.map((t) => ({ ...t, text: undefined, transcript: undefined })), findings: r.board.length, waiting: (r.threads?.threads || []).filter((t) => t.kind === 'ask' && t.status === 'waiting').length }; });
183
147
  }
184
148
  /** Ask the running client to stop. Recorded as an event, so watchers (the runner) see it. */
185
149
  /**
@@ -216,6 +180,29 @@ export class TeamStore {
216
180
  const post = { id: `pp_${randomBytes(4).toString('hex')}`, threadId, by: String(by || 'person').slice(0, 40), kind: ['note', 'question', 'decision'].includes(kind) ? kind : 'note', text: String(text || '').slice(0, 4000), refs: [], replyTo: replyTo || null, status: 'open', at };
217
181
  return this.append(id, [{ type: 'board.post', at, post }]);
218
182
  }
183
+ /** The checkpoint a client resumes from — the runner's own when the run ended with one, else built from the record. */
184
+ checkpoint(id) {
185
+ const run = this.runs.get(String(id || ''));
186
+ if (!run) throw new Error(`no run ${id}`);
187
+ return checkpointFrom(this._view(run));
188
+ }
189
+ /** A person hands a task to another model, from any client: the running client's tail acts on it. */
190
+ handoff(id, { taskId, model, by = 'person', reason = '' } = {}) {
191
+ const run = this.runs.get(String(id || ''));
192
+ if (!run) throw new Error(`no run ${id}`);
193
+ if (!run.tasks.some((t) => t.id === taskId)) throw new Error(`no task ${taskId}`);
194
+ if (!model) throw new Error('a model is required');
195
+ return this.append(id, [{ type: 'task.handoff-requested', at: this.now(), taskId, model: String(model).slice(0, 120), by: String(by || 'person').slice(0, 40), reason: String(reason || '').slice(0, 400) }]);
196
+ }
197
+ /** A client is taking a run over (resume): the record says so, and the old client's stop no longer applies. */
198
+ claim(id, { client = '' } = {}) {
199
+ const run = this.runs.get(String(id || ''));
200
+ if (!run) throw new Error(`no run ${id}`);
201
+ run.client = String(client || run.client || '').slice(0, 40);
202
+ run.stopRequested = null;
203
+ this.save();
204
+ return this._view(run);
205
+ }
219
206
  stop(id) {
220
207
  const run = this.runs.get(String(id || ''));
221
208
  if (!run) return null;