@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 +1 -1
- package/src/server.js +19 -2
- package/src/team-record.js +103 -0
- package/src/team-store.js +32 -48
- package/src/toolrelay.js +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
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.
|
|
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 {
|
|
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(
|
|
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
|
|
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
|
|
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 =
|
|
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') {
|