@chatpanel/gateway 0.6.83 → 0.6.85

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.83",
3
+ "version": "0.6.85",
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.83';
61
+ export const VERSION = '0.6.85';
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,8 @@ 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
+ // Can a client pick this run up again? Not while its own client is live on it.
101
+ v.resumable = isResumable(v);
141
102
  if (events) v.events = clone(run.events);
142
103
  return v;
143
104
  }
@@ -179,7 +140,7 @@ export class TeamStore {
179
140
  .filter((r) => !team || r.team === team)
180
141
  .sort((a, b) => b.createdAt - a.createdAt)
181
142
  .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 }; });
143
+ .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
144
  }
184
145
  /** Ask the running client to stop. Recorded as an event, so watchers (the runner) see it. */
185
146
  /**
@@ -216,6 +177,29 @@ export class TeamStore {
216
177
  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
178
  return this.append(id, [{ type: 'board.post', at, post }]);
218
179
  }
180
+ /** The checkpoint a client resumes from — the runner's own when the run ended with one, else built from the record. */
181
+ checkpoint(id) {
182
+ const run = this.runs.get(String(id || ''));
183
+ if (!run) throw new Error(`no run ${id}`);
184
+ return checkpointFrom(this._view(run));
185
+ }
186
+ /** A person hands a task to another model, from any client: the running client's tail acts on it. */
187
+ handoff(id, { taskId, model, by = 'person', reason = '' } = {}) {
188
+ const run = this.runs.get(String(id || ''));
189
+ if (!run) throw new Error(`no run ${id}`);
190
+ if (!run.tasks.some((t) => t.id === taskId)) throw new Error(`no task ${taskId}`);
191
+ if (!model) throw new Error('a model is required');
192
+ 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) }]);
193
+ }
194
+ /** A client is taking a run over (resume): the record says so, and the old client's stop no longer applies. */
195
+ claim(id, { client = '' } = {}) {
196
+ const run = this.runs.get(String(id || ''));
197
+ if (!run) throw new Error(`no run ${id}`);
198
+ run.client = String(client || run.client || '').slice(0, 40);
199
+ run.stopRequested = null;
200
+ this.save();
201
+ return this._view(run);
202
+ }
219
203
  stop(id) {
220
204
  const run = this.runs.get(String(id || ''));
221
205
  if (!run) return null;
package/src/toolrelay.js CHANGED
@@ -40,7 +40,7 @@ export function toolsToSpecs(tools) {
40
40
  // not time since the turn began: a flat 135 s from the start killed every relayed turn
41
41
  // with more than a handful of tool rounds, at 135.1 s exactly, and the client read the
42
42
  // empty resume as "the model returned no answer". A team member's turn is many rounds.
43
- export const RELAY_IDLE_MS = 135_000; // the bridge's own tool-call timeout is 120 s
43
+ export const RELAY_IDLE_MS = 200_000; // longer than the bridge's own idle (180 s), which is the authority
44
44
 
45
45
  export function createRelaySession({ vault, redactOpts, bridgeUrl, token, harness = null, idleMs = RELAY_IDLE_MS }) {
46
46
  const id = randomUUID().slice(0, 8);
@@ -81,6 +81,10 @@ export async function pumpBridgeStream(s, handlers) {
81
81
  const payload = t.slice(5).trim();
82
82
  if (!payload || payload === '[DONE]') continue;
83
83
  let evt; try { evt = JSON.parse(payload); } catch { continue; }
84
+ // Anything the bridge sends is life: an agent running its own tools for two minutes
85
+ // sends only status events, and a session that counts only text and tool requests
86
+ // as activity ended those turns mid-work.
87
+ touchRelaySession(s.id);
84
88
  if (evt.type === 'delta' && typeof evt.text === 'string') {
85
89
  handlers.onText(evt.text);
86
90
  } else if (evt.type === 'tool_request') {