@prplkane/roundtable 0.0.0-stage → 0.6.4

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +529 -0
  2. package/LICENSE +21 -0
  3. package/README.md +189 -3
  4. package/SECURITY.md +299 -0
  5. package/docs/workflow.md +1344 -0
  6. package/package.json +52 -6
  7. package/scripts/agent.mjs +40 -0
  8. package/scripts/export.mjs +20 -0
  9. package/scripts/mcp.mjs +175 -0
  10. package/scripts/run-debate.mjs +70 -0
  11. package/scripts/server.mjs +28 -0
  12. package/scripts/setup.mjs +182 -0
  13. package/skills/roundtable/SKILL.md +57 -0
  14. package/src/adapters/agy.js +7 -0
  15. package/src/adapters/catalogue.js +49 -0
  16. package/src/adapters/claude.js +6 -0
  17. package/src/adapters/codex.js +6 -0
  18. package/src/adapters/common.js +304 -0
  19. package/src/adapters/config.js +432 -0
  20. package/src/adapters/endpoint.js +178 -0
  21. package/src/adapters/errors.js +69 -0
  22. package/src/adapters/index.js +11 -0
  23. package/src/adapters/models.js +106 -0
  24. package/src/adapters/quota.js +305 -0
  25. package/src/adapters/spawn.js +178 -0
  26. package/src/agent-client.js +108 -0
  27. package/src/cli.js +44 -0
  28. package/src/contract.js +256 -0
  29. package/src/diagnostics.js +133 -0
  30. package/src/doctor.js +101 -0
  31. package/src/engine.js +902 -0
  32. package/src/estimate.js +331 -0
  33. package/src/export.js +307 -0
  34. package/src/fake.js +75 -0
  35. package/src/folder-picker.js +46 -0
  36. package/src/history.js +220 -0
  37. package/src/local-http.js +39 -0
  38. package/src/local-server.js +110 -0
  39. package/src/notes.js +16 -0
  40. package/src/personas.js +49 -0
  41. package/src/preflight.js +126 -0
  42. package/src/progress.js +30 -0
  43. package/src/prompts.js +278 -0
  44. package/src/research.js +113 -0
  45. package/src/review-view.js +157 -0
  46. package/src/seats.js +272 -0
  47. package/src/server.js +928 -0
  48. package/src/settings.js +27 -0
  49. package/src/setup-toml.js +124 -0
  50. package/src/snapshot.js +93 -0
  51. package/src/store.js +303 -0
  52. package/src/terminal.js +77 -0
  53. package/src/terms.js +52 -0
  54. package/src/ui/app.js +1967 -0
  55. package/src/ui/avatars.js +132 -0
  56. package/src/ui/ceremony.js +87 -0
  57. package/src/ui/design/app.css +714 -0
  58. package/src/ui/design/preset-dusk.css +91 -0
  59. package/src/ui/design/tokens.css +129 -0
  60. package/src/ui/diagnostics.js +61 -0
  61. package/src/ui/help.js +122 -0
  62. package/src/ui/icons.js +64 -0
  63. package/src/ui/index.html +272 -0
  64. package/src/ui/loader.js +73 -0
  65. package/src/ui/logo.svg +4 -0
  66. package/src/ui/scan.js +57 -0
  67. package/src/ui/scribe.js +64 -0
  68. package/src/ui/sound.js +80 -0
  69. package/src/validate.js +112 -0
  70. package/src/version.js +5 -0
package/src/server.js ADDED
@@ -0,0 +1,928 @@
1
+ // One static page plus SSE on localhost. The server owns the live debate, the gate and the
2
+ // auto-continue timer; the browser only renders events and posts the human's answers.
3
+ import http from 'node:http';
4
+ import { spawn } from 'node:child_process';
5
+ import { randomBytes } from 'node:crypto';
6
+ import { readFile } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { adapters, ADAPTERS, quotaReaders, poolsByAccount } from './adapters/index.js';
10
+ import { profileHome, killAll, liveChildren, resolveBinary, runProcess, cleanEnv } from './adapters/spawn.js';
11
+ import { checkPool, STATUS, endpointLamp, createPreflightCache } from './preflight.js';
12
+ import { retired, quotaGroup } from './adapters/models.js';
13
+ import { vendorCatalogue } from './adapters/catalogue.js';
14
+ import { createDebate, DEFAULTS } from './engine.js';
15
+ import { normaliseSeats, normaliseChairman, MOODS, COLOURS, poolKey, poolVendor, poolAccount, lightestModel } from './seats.js';
16
+ import { slugFor, openDebateDir, rebuildDebate } from './store.js';
17
+ import { exportDebate, validateTarget, decisionRecord } from './export.js';
18
+ import { rollup, hasUsableRound } from './engine.js';
19
+ import { roundDeltas, updateCalibration, migrateCalibration, CALIBRATION_VERSION, project } from './estimate.js';
20
+ import { fakeRunner, fakeQuota } from './fake.js';
21
+ import { validateDebateRequest, validateMessage, validateAccept, BODY_LIMIT } from './validate.js';
22
+ import { createDiagnostics, diagnosticAction, validateBrowserDiagnostics } from './diagnostics.js';
23
+ import { answerMessage } from './review-view.js';
24
+ import { createSettingsStore } from './settings.js';
25
+ import { createTermsRecord, termsRefusal } from './terms.js';
26
+ import { noteRecord, storedNote } from './notes.js';
27
+ import { reviewSnapshot } from './snapshot.js';
28
+ import { pickFolder } from './folder-picker.js';
29
+ import { dataDirectory, legacyHistory, acquireDataLock, checkedHistoryPath, removeHistory, createUsageStore, createHistoryIndex } from './history.js';
30
+ import { VERSION } from './version.js';
31
+ import { LOCAL_API_VERSION } from './local-server.js';
32
+ import { terminalBanner, paintVendor } from './terminal.js';
33
+
34
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
35
+ const UI = path.join(ROOT, 'src', 'ui');
36
+ const FAKE_CONSENSUS = process.argv.includes('--fake-consensus'); // scripted seats that all agree
37
+ const FAKE = process.argv.includes('--fake') || FAKE_CONSENSUS;
38
+ // ROUNDTABLE_DEBATES_DIR: where transcripts, calibration and pools live (tests point it at a
39
+ // temporary folder so nothing they run teaches the real projection). Scripted seats default to
40
+ // their own folder, so a fake debate never sits next to real ones.
41
+ const DEBATES_DIR = dataDirectory({ fake: FAKE });
42
+ const releaseDataLock = acquireDataLock(DEBATES_DIR);
43
+ process.once('exit', releaseDataLock);
44
+ // an explicit folder is the person's choice; only the defaults have older places to look in
45
+ const legacy = process.env.ROUNDTABLE_DEBATES_DIR ? [] : legacyHistory(DEBATES_DIR, { root: ROOT, fake: FAKE });
46
+ for (const { dir, missing } of legacy) console.warn(`Older history remains in ${dir}: ${missing} review${missing === 1 ? '' : 's'} not in ${DEBATES_DIR}. Stop any server using that folder, then move those review folders into ${DEBATES_DIR}; see Local data and backups.`);
47
+ const legacyDataDirs = legacy.map((x) => x.dir);
48
+ const diagnostics = createDiagnostics(DEBATES_DIR, { mode: FAKE ? 'demo' : 'live', version: VERSION });
49
+ diagnostics.write({ code: 'server-start', action: 'startup' });
50
+ const PORT = Number(process.env.PORT) || (FAKE ? 4748 : 4747);
51
+ // Per-launch capability: every request that changes something carries it. The page gets it in a
52
+ // meta tag when it is served; scripts take it from the startup line (ROUNDTABLE_TOKEN).
53
+ const TOKEN = randomBytes(16).toString('hex');
54
+ const INSTANCE_ID = randomBytes(16).toString('hex');
55
+ // Set on the page as an HttpOnly cookie: the browser returns it on the page's own requests, and no
56
+ // script, the agent client included, reads it from the page. Part of the board check (fromBoard).
57
+ const BOARD_COOKIE = randomBytes(16).toString('hex');
58
+ const HOSTS = new Set([`localhost:${PORT}`, `127.0.0.1:${PORT}`]);
59
+ const ORIGINS = new Set([...HOSTS].map((h) => `http://${h}`));
60
+ const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.md': 'text/plain; charset=utf-8' };
61
+
62
+ const debates = new Map(); // slug -> live debate record
63
+ let shuttingDown = false;
64
+ let pendingWrites = 0;
65
+
66
+ // The board's acknowledgment of a vendor's terms notice (config.js terms, src/terms.js). Until it
67
+ // is given, nothing reads that vendor's model list or quota in the background; a debate whose
68
+ // request carried options.acknowledgeTermsRisk may still call and read it for itself.
69
+ const TERMS_FILE = path.join(DEBATES_DIR, FAKE ? '_terms.fake.json' : '_terms.json');
70
+ const termsRecord = createTermsRecord(TERMS_FILE, { adapters: ADAPTERS, onError: (e) => {
71
+ diagnostics.write({ code: 'storage-failed', action: 'terms' }, e);
72
+ console.warn('[terms] Settings unreadable; restore the file and restart before saving. Antigravity stays held until then.');
73
+ } });
74
+ const termsHeld = (vendor, acknowledged = false) => !acknowledged && !termsRecord.acknowledged(vendor);
75
+
76
+ // Model ids and effort levels per vendor. `agy models` is a network call, so it is read once in
77
+ // the background at boot, or once Antigravity's terms notice is acknowledged; the setup screen
78
+ // gets the static hints until it lands.
79
+ let catalogue = null;
80
+ const staticCatalogue = await vendorCatalogue({ agyModels: async () => [] });
81
+ const readCatalogue = () => (FAKE || termsHeld('agy') ? vendorCatalogue({ agyModels: async () => [] }) : vendorCatalogue())
82
+ .then((c) => { catalogue = c; return c; })
83
+ .catch((e) => { diagnostics.write({ code: 'status-failed', action: 'catalogue' }, e); console.error('[catalogue]', e.message); return vendorCatalogue({ agyModels: async () => [] }).then((c) => (catalogue = c)); });
84
+ let catalogueReady = readCatalogue();
85
+ const effortsByVendor = () => Object.fromEntries(Object.entries(catalogue || staticCatalogue).map(([v, c]) => [v, c.efforts]));
86
+ const modelsByVendor = () => Object.fromEntries(Object.entries(catalogue || staticCatalogue).map(([v, c]) => [v, c.models || []]));
87
+
88
+ // The quota picture. Baselines: tokens per call per vendor from every transcript on disk.
89
+ // Calibration: how much of each vendor window one round took, measured between the quota
90
+ // snapshot at one round's start and the next; persisted so the next debate projects from it.
91
+ const usageStore = createUsageStore(DEBATES_DIR);
92
+ usageStore.sync((e) => {
93
+ diagnostics.write({ code: 'transcript-warning', action: 'startup', line: e.line }, e);
94
+ console.warn(`[transcript] ${e.message}`);
95
+ });
96
+ const baselines = usageStore.baselines();
97
+ // The fake board must never teach the real projection: it measures into its own file.
98
+ const CALIB_FILE = path.join(DEBATES_DIR, FAKE ? '_calibration.fake.json' : '_calibration.json');
99
+ const calibrationStore = createSettingsStore(CALIB_FILE, { onError: (e) => {
100
+ diagnostics.write({ code: 'storage-failed', action: 'calibration' }, e);
101
+ console.warn('[calibration] Settings unreadable; restore the file and restart before saving.');
102
+ } });
103
+ let calibration = {};
104
+ const saveCalibration = () => { try { calibrationStore.save({ _version: CALIBRATION_VERSION, ...calibration }); } catch (e) { diagnostics.write({ code: 'storage-failed', action: 'calibration' }, e); console.warn('[calibration]', e.message); } };
105
+ try {
106
+ const m = migrateCalibration(calibrationStore.value, { subsetVendors: Object.keys(ADAPTERS).filter((v) => ADAPTERS[v].cacheSemantics === 'subset') });
107
+ calibration = m.calibration;
108
+ if (m.dropped.length) { console.log(`[calibration] dropped ${m.dropped.join(', ')}: measured before cached input was counted once`); saveCalibration(); }
109
+ } catch { /* none yet */ }
110
+ const snapshotQuota = () => JSON.parse(JSON.stringify(quota));
111
+
112
+ // A remaining-pool figure the user types in for a vendor whose CLI reports no live window (agy
113
+ // when its /usage read gives nothing). Kept with its time so the strip can say how old it is, and
114
+ // decremented on the board by each debate's weighted tokens at the user's own rate when given.
115
+ const POOLS_FILE = path.join(DEBATES_DIR, FAKE ? '_pools.fake.json' : '_pools.json');
116
+ const poolStore = createSettingsStore(POOLS_FILE, { onError: (e) => {
117
+ diagnostics.write({ code: 'storage-failed', action: 'pools' }, e);
118
+ console.warn('[pools] Settings unreadable; restore the file and restart before saving.');
119
+ } });
120
+ let pools = poolStore.value;
121
+ function setPool(vendor, body) {
122
+ const remaining = Number(body.remaining);
123
+ if (!ADAPTERS[vendor]) throw new Error('Unknown vendor.');
124
+ if (!Number.isFinite(remaining) || remaining < 0) throw new Error('Remaining must be a finite number of 0 or more.');
125
+ const perToken = body.perToken == null || body.perToken === '' ? null : Number(body.perToken);
126
+ if (perToken != null && !(Number.isFinite(perToken) && perToken >= 0)) throw new Error('Units per token must be a finite number of 0 or more.');
127
+ const next = { ...pools, [vendor]: { remaining, unit: String(body.unit || 'credits').slice(0, 24), perToken, note: String(body.note || '').slice(0, 120), at: Date.now() } };
128
+ poolStore.save(next);
129
+ pools = next;
130
+ for (const d of debates.values()) if (activeDebate(d) || d.clients.size) push(d, { type: 'pool', vendor, pool: pools[vendor] });
131
+ return pools[vendor];
132
+ }
133
+
134
+ // Round r of debate d is over (the next round started, or the debate stopped): compare the
135
+ // vendor windows now with those at the round's start, against the tokens that round spent.
136
+ function settleRound(d, r) {
137
+ if (r < 0 || !d.qAtRound[r] || d.settled.has(r)) return;
138
+ d.settled.add(r);
139
+ const prev = d.qAtRound[r], next = snapshotQuota();
140
+ // Tokens per vendor, and per vendor:model when every seat of that vendor in the round ran one
141
+ // model (a window delta cannot be split between two models in the same round).
142
+ // keyed by pool (seats.poolKey): two profiles of one vendor draw from two windows
143
+ const tokens = {}, models = {}, unknown = new Set();
144
+ for (const e of d.events) {
145
+ if (e.type !== 'attempt' || e.round !== r || e.kind === 'verdict') continue;
146
+ const pool = keyOf(e.seat.vendor, e.seat.account);
147
+ if (!e.usage) { unknown.add(pool); continue; }
148
+ const t = (e.usage.input || 0) + (e.usage.cacheRead || 0) + (e.usage.cacheWrite || 0) + (e.usage.output || 0);
149
+ tokens[pool] = (tokens[pool] || 0) + t;
150
+ (models[pool] ??= new Set()).add(e.seat.model || 'default');
151
+ }
152
+ let changed = false;
153
+ for (const [pool, t] of Object.entries(tokens)) {
154
+ if (unknown.has(pool)) continue; // a partial token count cannot calibrate the window's move
155
+ const { deltas, keys } = roundDeltas(prev[pool], next[pool], pool, models[pool]);
156
+ if (!deltas.length) continue;
157
+ for (const key of keys) calibration = updateCalibration(calibration, key, deltas, t);
158
+ changed = true;
159
+ for (const key of keys) for (const x of debates.values()) if (activeDebate(x) || x.clients.size) push(x, { type: 'calibration', vendor: pool, key, windows: calibration[key], round: r, tokens: t, deltas });
160
+ }
161
+ if (changed) saveCalibration();
162
+ }
163
+
164
+ // At the end of a debate the separately read vendors (codex, agy) have usually not been re-read
165
+ // since their last reply, so the last round is settled only after one fresh read of each such vendor.
166
+ async function settleLast(d, r) {
167
+ const pools = new Set(d.events.filter((e) => e.type === 'reply' && e.round === r).map((e) => keyOf(e.seat.vendor, e.seat.account)));
168
+ await Promise.all([...pools].map((k) => refreshQuota(k, { acknowledged: d.termsAcknowledged })));
169
+ settleRound(d, r);
170
+ }
171
+
172
+ function watchRounds(d, payload) {
173
+ if (payload.type === 'round-start') { settleRound(d, payload.round - 1); d.qAtRound[payload.round] = snapshotQuota(); }
174
+ if (payload.type === 'stopped') settleLast(d, payload.rounds - 1).catch((e) => console.warn('[calibration]', e.message));
175
+ }
176
+
177
+ // Every event carries an id (generation:index) so a board that reconnects sends Last-Event-ID
178
+ // and gets only what it missed. A record rebuilt from disk after a restart has a new generation,
179
+ // so such a board gets the whole list again and resets on its `started` event.
180
+ function sseLine(d, i, replayed = false) { return `id: ${d.gen}:${i}\ndata: ${JSON.stringify(replayed ? { ...d.events[i], replayed: true } : d.events[i])}\n\n`; }
181
+ function push(d, event) {
182
+ let code;
183
+ if (event.cancelReason) code = 'call-cancelled';
184
+ else if ((event.type === 'attempt' || event.type === 'research') && event.errorKind) code = 'provider-failed';
185
+ else if ((event.type === 'reply' && event.valid === false) || (event.type === 'verdict' && event.failed)) code = event.failed && event.errorKind !== 'format' ? 'provider-failed' : 'reply-invalid';
186
+ else if (event.type === 'reprompt') code = 'reply-invalid';
187
+ else if (event.type === 'error') code = 'engine-failed';
188
+ else if (event.type === 'export-failed') code = 'export-failed';
189
+ else if (event.type === 'transcript-warning') code = 'transcript-warning';
190
+ else if (event.type === 'budget') code = 'provider-failed';
191
+ if (code && !event.diagnostic) {
192
+ const diagnostic = diagnostics.write({ code, action: event.type === 'export-failed' ? 'export' : 'events',
193
+ vendor: event.seat?.vendor, kind: event.errorKind || (event.type === 'budget' ? 'budget' : undefined), cancelReason: event.cancelReason, round: event.round, line: event.line,
194
+ progress: event.progress, systemCode: event.systemCode });
195
+ event = { ...event, diagnostic };
196
+ }
197
+ d.events.push(event);
198
+ const line = sseLine(d, d.events.length - 1);
199
+ for (const res of d.clients) res.write(line);
200
+ }
201
+ function replayFrom(d, lastEventId) {
202
+ const m = /^(\d+):(\d+)$/.exec(lastEventId || '');
203
+ return m && Number(m[1]) === d.gen ? Number(m[2]) + 1 : 0;
204
+ }
205
+
206
+ // Active engines and connected boards are never evicted. Inactive replay bodies are bounded;
207
+ // summaries have a separate small cache and never require keeping the transcript in memory.
208
+ function pruneReplays(keep = null) {
209
+ for (const [id, d] of debates) {
210
+ if (debates.size <= 32) break;
211
+ if (d !== keep && !activeDebate(d) && !d.clients.size) debates.delete(id);
212
+ }
213
+ }
214
+
215
+ function recoverFailed(d) {
216
+ if (!d.needsRecovery) return;
217
+ const warnings = [];
218
+ const store = openDebateDir(DEBATES_DIR, d.slug, { create: false });
219
+ const rb = store && rebuildDebate(store.readTranscript({ onWarning: (w) => warnings.push(w) }));
220
+ if (!rb) throw new Error('The saved review needs repair before it can resume.');
221
+ d.store = store;
222
+ d.debate = null; d.rebuilt = rb; d.finished = rb.finished; d.interrupted = !rb.finished;
223
+ d.needsRecovery = false; d.gen = Date.now(); d.events = [];
224
+ for (const event of rb.events) push(d, { ...event, replayed: true });
225
+ for (const warning of warnings) push(d, { ...warning, type: 'transcript-warning' });
226
+ }
227
+
228
+ // A slug the server does not hold in memory (after a restart): rebuild the record from its
229
+ // transcript. Finished debates replay read-only; an unfinished one is marked interrupted and
230
+ // can be resumed through POST .../resume.
231
+ function loadDebate(slug) {
232
+ if (!/^[a-z0-9-]+$/.test(slug)) return null;
233
+ const store = openDebateDir(DEBATES_DIR, slug, { create: false });
234
+ if (!store) return null;
235
+ const warnings = [];
236
+ const rebuilt = rebuildDebate(store.readTranscript({ onWarning: (w) => warnings.push(w) }));
237
+ if (!rebuilt) return null;
238
+ rebuilt.events.splice(1 + rebuilt.recoveryWarnings.length, 0, ...warnings.map((w) => ({ ...w, type: 'transcript-warning' })));
239
+ const d = { slug, gen: Date.now(), events: [...rebuilt.events], clients: new Set(), pending: null, timer: null, store, qAtRound: {}, settled: new Set(), rebuilt, interrupted: !rebuilt.finished, finished: rebuilt.finished };
240
+ for (const [vendor, q] of Object.entries(quota)) push(d, { type: 'quota', vendor, quota: q });
241
+ for (const [key, windows] of Object.entries(calibration)) push(d, { type: 'calibration', vendor: key.split(':')[0], key, windows });
242
+ for (const [vendor, pool] of Object.entries(pools)) push(d, { type: 'pool', vendor, pool });
243
+ debates.set(slug, d);
244
+ pruneReplays(d);
245
+ console.log(`[${slug}] loaded from disk: ${rebuilt.finished ? 'finished' : `interrupted after round ${rebuilt.nextRound - 1}`}`);
246
+ return d;
247
+ }
248
+
249
+ // Continue an interrupted debate: the engine is recreated from the field-notes row (seats with
250
+ // their recorded session ids, used when memory is 'resume') and runs from the first incomplete
251
+ // round. A message belongs to the last complete round, or precedes round zero if none completed.
252
+ function resumeDebate(d, body = {}) {
253
+ recoverFailed(d);
254
+ if (!d.interrupted) throw new Error(d.finished ? 'This debate is finished.' : 'This debate is running.');
255
+ prepareTranscript(d);
256
+ const rb = d.rebuilt;
257
+ const message = String(body.message || '').trim();
258
+ if (message) {
259
+ const round = rb.nextRound - 1;
260
+ const row = { type: 'human', round, ...noteRecord({ text: message, source: body.source }) };
261
+ d.store.append(row);
262
+ (round < 0 ? rb.initialNotes : (rb.humanNotes[round] ??= [])).push(storedNote(row));
263
+ push(d, row);
264
+ }
265
+ d.interrupted = false;
266
+ attachEngine(d, { fieldNotes: rb.fieldNotes, seats: rb.seats, chairman: rb.chairman, options: { ...DEFAULTS, ...(rb.options || {}) }, resume: { rounds: rb.rounds, humanNotes: rb.humanNotes, initialNotes: rb.initialNotes, ledger: rb.ledger, policyFailures: rb.policyFailures, research: rb.research, deadlineAt: rb.deadlineAt } });
267
+ console.log(`[${d.slug}] resumed from round ${rb.nextRound}`);
268
+ }
269
+
270
+ // The human's decision, kept apart from the chairman's recommendation: an `accepted` row, and
271
+ // the record re-exported where the field notes asked for one.
272
+ function acceptDecision(d, body) {
273
+ if (!d.finished) throw new Error('Accept after the debate has finished.');
274
+ const rb = rebuildDebate(d.store.readTranscript());
275
+ const { option, note } = validateAccept(body, rb.fieldNotes.options);
276
+ prepareTranscript(d);
277
+ const row = { type: 'accepted', option, note, t: new Date().toISOString() };
278
+ d.store.append(row);
279
+ push(d, row);
280
+ if (rb.fieldNotes.exportTo) autoExport(d, rb.fieldNotes.exportTo);
281
+ return { ok: true, option };
282
+ }
283
+
284
+ // Pre-flight for the seat lamps: per pool key, the binary, the CLI's own login status and the
285
+ // quota, with no model call. GET reads snapshots only. Authenticated refreshes share bounded
286
+ // work and cache results for a minute (a quota read spawns the codex app-server).
287
+ const preflightCache = createPreflightCache({ check: (key) => {
288
+ const vendor = poolVendor(key), account = poolAccount(key);
289
+ return checkPool({ vendor, account, key }, {
290
+ resolve: (v) => (FAKE ? true : resolveBinary(ADAPTERS[v].binary, ADAPTERS[v].npmBin)),
291
+ status: loginStatus,
292
+ quota: async (k) => { if (readers[vendor]) await refreshQuota(k); return quota[k] || null; },
293
+ });
294
+ } });
295
+ async function loginStatus(vendor, account) {
296
+ if (FAKE) return true;
297
+ const s = STATUS[vendor];
298
+ if (!s) return (catalogue || await catalogueReady)?.[vendor]?.models?.length ? true : null; // agy: its model list answered
299
+ const home = account && ADAPTERS[vendor].home ? { env: ADAPTERS[vendor].home.env, dir: profileHome(vendor, account) } : null;
300
+ try {
301
+ const r = await runProcess(resolveBinary(ADAPTERS[vendor].binary, ADAPTERS[vendor].npmBin), s.args, { env: cleanEnv(home), timeoutMs: 20_000 });
302
+ const out = `${r.stdout}\n${r.stderr}`;
303
+ return s.plan ? { loggedIn: s.read(out), plan: s.plan(out) } : s.read(out);
304
+ } catch (e) { diagnostics.write({ code: 'status-failed', action: 'preflight', vendor }, e); return null; }
305
+ }
306
+ // endpoints: { [key]: { url, keyEnv } } for API pools, from the board (POST only: it names a
307
+ // variable of this process, so the launch token is required). No request leaves this server
308
+ // for an endpoint pool (preflight.js endpointLamp).
309
+ async function preflight(keys, { refresh = false, fresh = false, endpoints = {} } = {}) {
310
+ return Promise.all(keys.map(async (key) => {
311
+ const vendor = poolVendor(key), account = poolAccount(key);
312
+ if (!Object.hasOwn(ADAPTERS, vendor)) return { key, state: 'danger', kind: 'missing', note: `unknown vendor ${vendor}` };
313
+ if (ADAPTERS[vendor].kind === 'api') {
314
+ const ep = refresh ? endpoints[key] || {} : {};
315
+ const url = typeof ep.url === 'string' ? ep.url.slice(0, 500) : '', keyEnv = typeof ep.keyEnv === 'string' ? ep.keyEnv.slice(0, 64) : null;
316
+ return { key, vendor, account, installed: true, loggedIn: null, plan: null, quota: null, ...endpointLamp({ url, keyEnv: keyEnv || null, keySet: !!(keyEnv && process.env[keyEnv]) }) };
317
+ }
318
+ // not even a status check before the person acknowledges the vendor's terms notice
319
+ if (termsHeld(vendor)) return { key, vendor, account, installed: null, loggedIn: null, plan: null, quota: null, state: 'warn', kind: 'terms',
320
+ note: `held: nothing is sent to ${ADAPTERS[vendor].binary} until you acknowledge ${ADAPTERS[vendor].label}'s terms notice on its card` };
321
+ return refresh ? preflightCache.refresh(key, { fresh }) : preflightCache.read(key);
322
+ }));
323
+ }
324
+
325
+ // The debates on disk, newest first, summarised for the setup screen's list: the same status and
326
+ // pick the decision record would carry, plus whether this server is running it now.
327
+ function activeDebate(d) {
328
+ return !!d && (d.synthesizing || !d.finished && !d.interrupted && d.debate?.state.status !== 'error' || liveChildren(d.slug).length > 0);
329
+ }
330
+ const historyIndex = createHistoryIndex(DEBATES_DIR, { onWarning: (e, slug) => {
331
+ diagnostics.write({ code: 'transcript-warning', action: 'history', line: e.line }, e);
332
+ console.warn(`[list] ${slug}: ${e.message}`);
333
+ } });
334
+ async function listDebates() {
335
+ return (await historyIndex.list()).map(({ usage: u, finished, ...row }) => {
336
+ if (row.state === 'corrupt') return row;
337
+ const state = activeDebate(debates.get(row.slug)) ? 'running' : finished ? 'finished' : 'interrupted';
338
+ return { ...row, state, status: state === 'finished' ? row.status : state,
339
+ tokens: u.tokenCoverage?.reported === 0 ? null : u.input + u.cacheRead + u.cacheWrite + u.output,
340
+ tokensPartial: !!(u.tokenCoverage && u.tokenCoverage.reported < u.tokenCoverage.of),
341
+ cost: u.cost, costPartial: !!(u.costCoverage && u.costCoverage.reported < u.costCoverage.of) };
342
+ });
343
+ }
344
+
345
+ function deleteSessions(ids, finishedOnly = false) {
346
+ if (!Array.isArray(ids) || !ids.length || ids.length > 1000 || new Set(ids).size !== ids.length)
347
+ throw Object.assign(new Error('Choose between 1 and 1,000 distinct saved sessions.'), { status: 400 });
348
+ // Validate the entire selection before changing anything. No await between these checks
349
+ // and removal: a queued Resume or Write request cannot become active halfway through.
350
+ const records = ids.map((id) => {
351
+ checkedHistoryPath(DEBATES_DIR, id);
352
+ const d = debates.get(id) || loadDebate(id);
353
+ if (!d) throw Object.assign(new Error('This saved session was not found. Refresh history.'), { status: 404 });
354
+ if (activeDebate(d)) throw Object.assign(new Error('This session is still running or waiting for input. Stop it before deleting it.'), { status: 409 });
355
+ if (finishedOnly && !d.finished) throw Object.assign(new Error('The selection includes an unfinished session. Refresh history and try again.'), { status: 409 });
356
+ const rows = d.store.readTranscript();
357
+ return { d, rows };
358
+ });
359
+ for (const { d, rows } of records) usageStore.record(d.slug, rows);
360
+ usageStore.save(true); // a failed statistics write must prevent deletion
361
+ Object.assign(baselines, usageStore.baselines());
362
+ for (const { d } of records) {
363
+ removeHistory(DEBATES_DIR, d.slug);
364
+ historyIndex.invalidate(d.slug);
365
+ push(d, { type: 'deleted' });
366
+ for (const client of d.clients) client.end();
367
+ d.clients.clear(); clearTimeout(d.timer); debates.delete(d.slug);
368
+ }
369
+ return { deleted: ids, usage: usageStore.summary(FAKE) };
370
+ }
371
+
372
+ // A finished debate whose verdict was skipped, failed or never written: run the chairman alone,
373
+ // from the transcript (so it works after a restart), under the budget. The new verdict row
374
+ // supersedes the old one.
375
+ // body.chairman (optional): write it with another vendor, for example when the first chairman's
376
+ // quota ran out. Checked like the setup screen's chairman.
377
+ function synthesizeDebate(d, body = {}) {
378
+ recoverFailed(d);
379
+ if (!d.finished) throw new Error(d.interrupted ? 'This debate was interrupted; resume it first.' : 'This debate is still running.');
380
+ if (d.synthesizing) throw new Error('The verdict is being written.');
381
+ const rb = rebuildDebate(d.store.readTranscript());
382
+ if (rb.synthesis === 'done') throw new Error('This debate already has its verdict.');
383
+ if (!hasUsableRound(rb.rounds, rb.seats.length)) throw new Error('No usable replies were collected. Start a new review before requesting a verdict.');
384
+ let chairman = rb.chairman;
385
+ if (body.chairman) {
386
+ for (const k of ['vendor', 'model', 'effort', 'account', 'keyEnv', 'endpoint']) if (body.chairman[k] != null && (typeof body.chairman[k] !== 'string' || body.chairman[k].length > (k === 'endpoint' ? 500 : 200))) throw new Error(`chairman ${k}: text of at most ${k === 'endpoint' ? 500 : 200} characters`);
387
+ chairman = normaliseChairman(body.chairman, { vendors: ADAPTERS, efforts: effortsByVendor() });
388
+ if (!chairman) throw new Error('chairman vendor: one of ' + Object.keys(ADAPTERS).join(', '));
389
+ }
390
+ prepareTranscript(d);
391
+ d.synthesizing = true;
392
+ d.finished = false;
393
+ attachEngine(d, { fieldNotes: rb.fieldNotes, seats: rb.seats, chairman, options: { ...DEFAULTS, ...(rb.options || {}) }, synthesisOnly: true,
394
+ resume: { rounds: rb.rounds, humanNotes: rb.humanNotes, initialNotes: rb.initialNotes, ledger: rb.ledger, policyFailures: rb.policyFailures, research: rb.research, stopReason: rb.stopReason } });
395
+ console.log(`[${d.slug}] writing the verdict`);
396
+ }
397
+
398
+ // Remaining quota is per account, not per debate: one snapshot per pool key (`codex`, or
399
+ // `codex@work` for a named profile; seats.poolKey), shared by every board. Each vendor's is
400
+ // read separately (readers): claude and agy with -p /usage, codex through its app-server; agy's
401
+ // comes in groups of windows per model, and a claude reply's own figure stands in when a read fails.
402
+ const quota = {}; // pool key -> { windows, plan, at, groups? }
403
+ const readers = FAKE ? { claude: async () => fakeQuota('claude'), codex: async () => fakeQuota('codex'), agy: async () => fakeQuota('agy') } : quotaReaders;
404
+ const keyOf = (vendor, account) => poolKey(vendor, account, poolsByAccount(ADAPTERS[vendor]));
405
+ // A vendor whose CLI is not on PATH gets no quota read and no startup line: a CLI that is not
406
+ // here is not a failed read. Demo mode seats every vendor with scripted replies.
407
+ const installed = (vendor) => { if (FAKE || ADAPTERS[vendor]?.kind !== 'cli') return true; try { resolveBinary(ADAPTERS[vendor].binary, ADAPTERS[vendor].npmBin); return true; } catch { return false; } };
408
+
409
+ function setQuota(key, q) {
410
+ if (!q) return;
411
+ quota[key] = q;
412
+ for (const d of debates.values()) if (activeDebate(d) || d.clients.size) push(d, { type: 'quota', vendor: key, quota: q });
413
+ }
414
+
415
+ // One read per pool at a time: a read asked for while one is running shares its result.
416
+ // acknowledged: the debate asking carried options.acknowledgeTermsRisk (termsHeld).
417
+ const reading = new Map();
418
+ function refreshQuota(key, { acknowledged = false } = {}) {
419
+ const vendor = poolVendor(key), account = poolAccount(key);
420
+ // a reader that stopped itself (agy's latch, quota.js) said why once; no read, no repeat
421
+ if (!readers[vendor] || readers[vendor].stopped?.() || termsHeld(vendor, acknowledged) || !installed(vendor)) return Promise.resolve(null);
422
+ if (reading.has(key)) return reading.get(key);
423
+ // The readers resolve null on every failure (timeout, spawn error, no result) rather than
424
+ // throwing, so say so here or a dead read is invisible on the console. Resolves to the figure
425
+ // read, or null.
426
+ const p = (async () => {
427
+ try {
428
+ const q = await readers[vendor]({ home: account ? profileHome(vendor, account) : null });
429
+ if (!q) { diagnostics.write({ code: 'status-failed', action: 'quota', vendor }); console.warn(`[quota] ${paintVendor(ADAPTERS[vendor], { text: key })}: no figure (not logged in, or the read failed or timed out); the strip keeps the last one`); }
430
+ setQuota(key, q);
431
+ return q;
432
+ } catch (e) { diagnostics.write({ code: 'status-failed', action: 'quota', vendor }, e); console.error(`[quota] ${key}: ${e.message}`); return null; } finally { reading.delete(key); }
433
+ })();
434
+ reading.set(key, p);
435
+ return p;
436
+ }
437
+
438
+ function answerGate(d, answer) {
439
+ if (!d.pending) return false;
440
+ clearTimeout(d.timer); d.timer = null;
441
+ const { resolve } = d.pending;
442
+ d.pending = null;
443
+ push(d, { type: 'gate-closed', action: answer.action });
444
+ resolve(answer);
445
+ return true;
446
+ }
447
+
448
+ // Every attended window is bounded. Extending it never answers a question or adds a note.
449
+ function scheduleGate(d, duration, extended = false) {
450
+ clearTimeout(d.timer);
451
+ const pending = d.pending;
452
+ const deadline = Math.min(Math.max(Date.now() + duration, extended ? pending.autoContinueAt || 0 : 0), pending.info.deadlineAt ?? Infinity);
453
+ pending.autoContinueAt = deadline;
454
+ push(d, { type: 'countdown', deadline, extended });
455
+ d.timer = setTimeout(() => {
456
+ if (d.pending !== pending) return;
457
+ const expired = Date.now() >= (pending.info.deadlineAt ?? Infinity);
458
+ answerGate(d, { action: expired ? 'stop' : 'continue', reason: expired ? 'wall-clock' : 'input-timeout' });
459
+ }, Math.max(0, deadline - Date.now()));
460
+ return deadline;
461
+ }
462
+
463
+ // Opt browser requests into hidden-mood shortcuts; ordinary agent requests keep custom text.
464
+ // This is a UI distinction, not an authorization boundary: a custom client can obtain the cookie
465
+ // and forge these headers. The launch token and Host/Origin checks still protect every write.
466
+ const cookie = (req, name) => (req.headers.cookie || '').split(/;\s*/).find((c) => c.startsWith(`${name}=`))?.slice(name.length + 1);
467
+ let pickingFolder = false;
468
+ const fromBoard = (req) => cookie(req, 'rt_board') === BOARD_COOKIE && ORIGINS.has(req.headers.origin || '') && req.headers['sec-fetch-site'] === 'same-origin';
469
+ function prepareDebate(raw, { board = false } = {}) {
470
+ const body = validateDebateRequest(raw, { adapters: ADAPTERS });
471
+ const fieldNotes = body.fieldNotes;
472
+ if (fieldNotes.exportTo) fieldNotes.exportTo = validateTarget(fieldNotes.exportTo, { forbid: [DEBATES_DIR] }); // checked up front, not when the verdict lands
473
+ const seats = normaliseSeats(body.seats, { vendors: ADAPTERS, efforts: effortsByVendor(), models: modelsByVendor(), hidden: board });
474
+ // a retired model fails its first call: refuse it here, with what to use instead
475
+ for (const s of [...seats, ...(body.chairman ? [body.chairman] : [])]) {
476
+ const r = s?.model && retired(s.vendor, String(s.model).trim());
477
+ if (r) throw new Error(`${ADAPTERS[s.vendor].label} retired ${s.model} (${r.date}); use ${r.instead}.`);
478
+ }
479
+ const chairman = normaliseChairman(body.chairman, { vendors: ADAPTERS, efforts: effortsByVendor() });
480
+ // the board sends acknowledgeTermsRisk once the person has acknowledged on a card; an agent must send it
481
+ const unacknowledged = FAKE ? null : termsRefusal([...seats, chairman, body.options.research], body.options, ADAPTERS);
482
+ if (unacknowledged) throw unacknowledged;
483
+ if (!FAKE && !body.options.allowLimitedIsolation && [...seats, chairman, body.options.research].some((s) => s && ADAPTERS[s.vendor]?.limitedIsolation)) {
484
+ // the board sends this whenever a card it shows runs on Antigravity; an agent must send it
485
+ throw Object.assign(new Error('Antigravity cannot be fully isolated. Send options.allowLimitedIsolation: true once the person accepts that, or choose another provider.'), { status: 400 });
486
+ }
487
+ const options = { ...DEFAULTS, ...body.options };
488
+ // the board always names the searcher's settings, even "default"; an agent may leave them out
489
+ const named = ['model', 'effort', 'account'].some((k) => raw?.options?.research && Object.hasOwn(raw.options.research, k));
490
+ if (options.research) options.research = searcherSettings(options.research, named);
491
+ return { fieldNotes, seats, chairman, options };
492
+ }
493
+
494
+ // The searcher runs on its own settings, checked like the chairman's. A request that names none
495
+ // gets its vendor's lightest model at low effort: finding and quoting sources needs little reasoning.
496
+ function searcherSettings(research, named) {
497
+ const v = research.vendor;
498
+ const efforts = effortsByVendor()[v] || ADAPTERS[v].effortLevels || [];
499
+ const s = normaliseChairman({ vendor: v, model: named ? research.model : lightestModel(v, modelsByVendor()[v]), effort: named ? research.effort : efforts.includes('low') ? 'low' : undefined, account: research.account }, { vendors: ADAPTERS, efforts: effortsByVendor(), role: 'Searcher' });
500
+ const gone = s.model && retired(v, s.model);
501
+ if (gone) throw Object.assign(new Error(`Searcher: ${ADAPTERS[v].label} retired ${s.model} (${gone.date}); use ${gone.instead}.`), { status: 400 });
502
+ const { model, effort, account, ...rest } = research;
503
+ return { ...rest, ...(s.model ? { model: s.model } : {}), ...(s.effort ? { effort: s.effort } : {}), ...(s.account ? { account: s.account } : {}) };
504
+ }
505
+
506
+ function startDebate(raw, opts) {
507
+ const { fieldNotes, seats, chairman, options } = prepareDebate(raw, opts);
508
+ const slug = slugFor(fieldNotes.decision, DEBATES_DIR);
509
+ const store = openDebateDir(DEBATES_DIR, slug);
510
+ const d = { slug, gen: Date.now(), events: [], clients: new Set(), pending: null, timer: null, store, qAtRound: {}, settled: new Set(), interrupted: false, finished: false };
511
+ debates.set(slug, d);
512
+ for (const [vendor, q] of Object.entries(quota)) push(d, { type: 'quota', vendor, quota: q }); // replayed to late boards
513
+ for (const [key, windows] of Object.entries(calibration)) push(d, { type: 'calibration', vendor: key.split(':')[0], key, windows });
514
+ for (const [vendor, pool] of Object.entries(pools)) push(d, { type: 'pool', vendor, pool });
515
+ // a named profile's pool has not been read yet: read it before round 0 is measured
516
+ for (const k of new Set([...seats, ...(chairman ? [chairman] : [])].map((s) => keyOf(s.vendor, s.account)))) if (!quota[k]) refreshQuota(k, { acknowledged: options.acknowledgeTermsRisk === true });
517
+ attachEngine(d, { fieldNotes, seats, chairman, options });
518
+ console.log(`[${slug}] started: ${seats.map((s) => `${s.label}/${s.lens}${s.model ? `/${s.model}` : ''}${s.effort ? `/${s.effort}` : ''}${s.account ? `@${s.account}` : ''}`).join(', ')}`);
519
+ return slug;
520
+ }
521
+
522
+ // Finish any pending transcript repair before a write, then tell connected boards where the
523
+ // exact original was saved. Read-only history and export do not repair the source transcript.
524
+ function prepareTranscript(d) {
525
+ const recovery = d.store.prepareAppend();
526
+ if (recovery) push(d, { ...recovery, type: 'transcript-warning' });
527
+ }
528
+
529
+ // Write the decision record into a codebase: on request from the board or the script, or the
530
+ // moment the verdict lands when the field notes name an "Export to" directory.
531
+ function exportRecord(d, target) {
532
+ const abs = validateTarget(target, { forbid: [DEBATES_DIR] });
533
+ const warnings = [];
534
+ const rows = d.store.readTranscript({ onWarning: (w) => warnings.push(w) });
535
+ const out = exportDebate(rows, d.slug, abs, { forbid: [DEBATES_DIR], warnings });
536
+ push(d, { type: 'exported', path: out.path, indexPath: out.indexPath, status: out.meta.status, chosen: out.meta.chosen });
537
+ console.log(`[${d.slug}] exported to ${out.path}`);
538
+ return out;
539
+ }
540
+ function autoExport(d, target) {
541
+ if (!target) return;
542
+ try { exportRecord(d, target); } catch (e) { push(d, { type: 'export-failed', error: e.message }); console.warn(`[${d.slug}] export: ${e.message}`); }
543
+ }
544
+
545
+ // Build the runner and the gate for a record and run the engine on it (a new debate, or an
546
+ // interrupted one continued from its transcript).
547
+ function attachEngine(d, { fieldNotes, seats, chairman, options, resume = null, synthesisOnly = false }) {
548
+ const store = d.store;
549
+ // The request carried the terms acknowledgment, so this debate may also read that vendor's quota.
550
+ // A debate resumed or given a new chairman after the board recorded it may call it too: a
551
+ // transcript from before the acknowledgment existed has no flag to carry.
552
+ d.termsAcknowledged = options.acknowledgeTermsRisk === true;
553
+ // A seat with a named account profile runs with that profile's CLI state directory (under the
554
+ // OS user-data dir, logged in by the user); every other seat shares the CLI's real one.
555
+ const call = FAKE ? fakeRunner({ consensus: FAKE_CONSENSUS }) : (vendor, prompt, sessionId, opts) =>
556
+ adapters[vendor](prompt, sessionId, {
557
+ cwd: store.seatDir(opts.seat?.id || 'chairman'), home: opts.seat?.account && ADAPTERS[vendor].home ? profileHome(vendor, opts.seat.account) : null,
558
+ endpoint: opts.seat?.endpoint ? { url: opts.seat.endpoint, keyEnv: opts.seat.keyEnv || null } : null, // an API seat: where to send the round
559
+ timeoutMs: opts.timeoutMs, prevCost: opts.prevCost, prevUsage: opts.prevUsage, model: opts.model || undefined, effort: opts.effort || undefined, tag: d.slug,
560
+ allowLimitedIsolation: opts.allowLimitedIsolation,
561
+ acknowledgeTermsRisk: opts.acknowledgeTermsRisk === true || termsRecord.acknowledged(vendor),
562
+ research: opts.research,
563
+ keepSession: opts.keepSession === true,
564
+ minimal: !!opts.seat?.mood?.hidden, // a hidden mood runs as cheaply as its vendor allows
565
+ });
566
+ // After every reply the vendor's quota is read again (not awaited) where a free read exists, so
567
+ // one source fills its strip and calibration compares windows of the same shape: claude's
568
+ // /usage names a per-model week its reply does not, and gives reset times to the minute. The
569
+ // figure a reply carried is used when there is no read, or the read gave nothing.
570
+ const runner = async (vendor, ...rest) => {
571
+ if (shuttingDown) throw Object.assign(new Error('The server is shutting down.'), { code: 'KILLED', kind: 'killed' });
572
+ const r = await call(vendor, ...rest);
573
+ const key = keyOf(vendor, rest[2]?.seat?.account);
574
+ if (!shuttingDown) {
575
+ if (readers[vendor] && !readers[vendor].stopped?.()) refreshQuota(key, { acknowledged: d.termsAcknowledged }).then((q) => { if (!q && r.quota && !shuttingDown) setQuota(key, r.quota); });
576
+ else if (r.quota) setQuota(key, r.quota);
577
+ }
578
+ return r;
579
+ };
580
+
581
+ const gate = (info) => new Promise((resolve) => {
582
+ d.pending = { resolve, info };
583
+ scheduleGate(d, info.autoContinueMs);
584
+ });
585
+
586
+ d.debate = createDebate({
587
+ fieldNotes, seats, chairman, options, runner, gate, store, resume, baselines, provenance: FAKE ? { fake: true } : {}, synthesisOnly,
588
+ // agy: the limit group a seat's model draws on, from the latest quota read, so a used-up
589
+ // group benches only its own seats; null for a vendor with one set of limits
590
+ limitGroup: (seat) => quotaGroup(quota[keyOf(seat.vendor, seat.account)]?.groups, seat.model),
591
+ cancel: (reason) => { const n = killAll(d.slug); if (n) console.log(`[${d.slug}] ${reason === 'human' ? 'stop' : reason === 'finish' ? 'finish discussion' : reason === 'interrupted' ? 'server shutdown' : reason === 'error' ? 'engine error' : 'wall clock'}: cancelled ${n} call${n === 1 ? '' : 's'}`); },
592
+ emit: (type, payload) => {
593
+ // run() rejects with the original Error after emitting this. Its catch below logs a
594
+ // sanitized source location and sends one useful failure instead of exposing internals.
595
+ if (type === 'error') { d.synthesizing = false; return; }
596
+ // the engine ended an unanswered gate at the deadline: close this side of it too
597
+ if (type === 'gate-expired' && d.pending) { clearTimeout(d.timer); d.timer = null; d.pending = null; push(d, { type: 'gate-closed', action: 'stop' }); }
598
+ push(d, payload); watchRounds(d, payload);
599
+ if (type === 'done') {
600
+ d.finished = true; d.synthesizing = false;
601
+ try { usageStore.record(d.slug, d.store.readTranscript()); usageStore.save(); Object.assign(baselines, usageStore.baselines()); }
602
+ catch (error) { diagnostics.write({ code: 'storage-failed', action: 'history' }, error); }
603
+ autoExport(d, fieldNotes.exportTo);
604
+ pruneReplays(d);
605
+ }
606
+ },
607
+ });
608
+ d.runPromise = d.debate.run().catch((e) => {
609
+ d.synthesizing = false;
610
+ clearTimeout(d.timer); d.timer = null; d.pending = null;
611
+ d.needsRecovery = true; d.interrupted = true; d.finished = false;
612
+ const diagnostic = diagnostics.write({ code: 'engine-failed', action: 'events' }, e);
613
+ try { recoverFailed(d); } catch { /* retry reading on Resume after storage is repaired */ }
614
+ push(d, { type: 'error', recoverable: true, recoveryFinished: d.finished,
615
+ message: 'The review stopped unexpectedly. Fix any storage problem, then resume or request the missing verdict from saved history.', diagnostic });
616
+ pruneReplays(d);
617
+ console.error(`[${d.slug}]`, e.message);
618
+ });
619
+ }
620
+
621
+ function json(res, code, obj) {
622
+ res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' });
623
+ res.end(JSON.stringify(obj));
624
+ }
625
+
626
+ function requestError(res, status, message, action, error) {
627
+ if (status === 401) message = 'This page belongs to an earlier server session. Copy any unsent note, reload, then check the review before retrying.';
628
+ if (['EACCES', 'EPERM', 'EROFS'].includes(error?.code)) message = 'Could not save or read local data. Check data-folder permissions, then review the saved state before retrying.';
629
+ else if (error?.code === 'ENOSPC') message = 'The disk is full. Free some space, then review the saved state before retrying.';
630
+ else if (status >= 500) message = 'The server could not complete this action. Your request may have partly completed. Review the saved state and save the error log before retrying.';
631
+ const location = ['lens', 'moodText'].includes(error?.field) && Number.isInteger(error?.seatIndex) && error.seatIndex >= 0 && error.seatIndex < 8
632
+ ? { field: error.field, seatIndex: error.seatIndex } : {};
633
+ const diagnostic = diagnostics.write({ code: 'request-failed', action, status, line: error?.line, ...location }, error);
634
+ return json(res, status, { error: message, diagnostic, ...location });
635
+ }
636
+
637
+ async function readBody(req) {
638
+ const tooBig = () => Object.assign(new Error(`Request body over ${BODY_LIMIT / 1024} KB.`), { status: 413, drain: true });
639
+ if (Number(req.headers['content-length']) > BODY_LIMIT) throw tooBig();
640
+ const chunks = [];
641
+ let size = 0;
642
+ for await (const chunk of req) {
643
+ size += chunk.length;
644
+ if (size > BODY_LIMIT) throw tooBig(); // a streamed body with no length: stop keeping it
645
+ chunks.push(chunk);
646
+ }
647
+ const s = Buffer.concat(chunks).toString('utf8');
648
+ try { return s ? JSON.parse(s) : {}; } catch { throw Object.assign(new Error('Request body is not JSON.'), { status: 400 }); }
649
+ }
650
+
651
+ // Every request: the Host must be this server (a DNS-rebound name is refused), and an Origin,
652
+ // when the browser sends one, must be this server's page. Every request that changes state: the
653
+ // launch token, POST only, a JSON body. Returns an error [status, message] or null.
654
+ function refuse(req, isRead) {
655
+ if (!HOSTS.has(req.headers.host || '')) return [403, 'Unknown Host.'];
656
+ if (req.headers.origin && !ORIGINS.has(req.headers.origin)) return [403, 'Cross-origin request refused.'];
657
+ if (isRead) return null;
658
+ if (req.method !== 'POST') return [405, 'POST only.'];
659
+ if (req.headers['x-roundtable-token'] !== TOKEN) return [401, 'Missing or wrong x-roundtable-token.'];
660
+ if (!/^application\/json(;|$)/i.test(req.headers['content-type'] || '')) return [415, 'Send application/json.'];
661
+ return null;
662
+ }
663
+
664
+ async function serveFile(res, file) {
665
+ try {
666
+ const data = await readFile(file);
667
+ res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream' });
668
+ res.end(data);
669
+ } catch {
670
+ res.writeHead(404); res.end('not found');
671
+ }
672
+ }
673
+
674
+ // The page holds the launch token in a meta tag, so a script that is not this server's own must
675
+ // never run in it: every response says so, so an escaping miss in the board cannot become a
676
+ // write. Inline styles stay allowed (seat colours are CSS variables set per card); fetch, SSE
677
+ // and downloads are same-origin, nothing may frame the page, and no type is sniffed.
678
+ const HEADERS = {
679
+ 'content-security-policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
680
+ 'x-content-type-options': 'nosniff',
681
+ 'referrer-policy': 'no-referrer',
682
+ };
683
+
684
+ const server = http.createServer(async (req, res) => {
685
+ const url = new URL(req.url, `http://localhost:${PORT}`);
686
+ const p = url.pathname;
687
+ for (const [k, v] of Object.entries(HEADERS)) res.setHeader(k, v);
688
+ try {
689
+ const isRead = req.method === 'GET' && (!p.startsWith('/api/') || p === '/api/health' || p === '/api/seats' || p === '/api/debates' || p === '/api/preflight' || /\/(events|state)$/.test(p));
690
+ const no = refuse(req, isRead);
691
+ if (no) return requestError(res, no[0], no[1], diagnosticAction(p));
692
+ if (shuttingDown && !isRead) return requestError(res, 503, 'The server is shutting down.', diagnosticAction(p));
693
+ if (!isRead) {
694
+ pendingWrites++;
695
+ let counted = true;
696
+ const release = () => { if (counted) { counted = false; pendingWrites--; } };
697
+ res.once('finish', release); res.once('close', release);
698
+ }
699
+ if (req.method === 'GET' && p === '/api/health') return json(res, 200, {
700
+ service: 'roundtable', apiVersion: LOCAL_API_VERSION, version: VERSION, instanceId: INSTANCE_ID,
701
+ mode: FAKE ? 'demo' : 'live', dataDir: DEBATES_DIR, shuttingDown,
702
+ activeReviews: [...debates.values()].filter(activeDebate).length,
703
+ });
704
+ if (p === '/api/shutdown' && req.method === 'POST') {
705
+ const body = await readBody(req);
706
+ if (body?.instanceId !== INSTANCE_ID) return requestError(res, 409, 'The server instance changed. Read its status before stopping it.', 'shutdown');
707
+ if (pendingWrites > 1 || [...debates.values()].some(activeDebate)) return requestError(res, 409, 'The server has an active review or another write in progress. Finish or explicitly stop the review before stopping the server.', 'shutdown');
708
+ json(res, 200, { ok: true, stopping: true });
709
+ shutdown('requested while idle');
710
+ return;
711
+ }
712
+ if (p === '/') {
713
+ const html = (await readFile(path.join(UI, 'index.html'), 'utf8'))
714
+ .replace('<meta name="roundtable-token" content="">', `<meta name="roundtable-token" content="${TOKEN}">`)
715
+ .replace('<span class="end" id="st-version"></span>', `<span class="end" id="st-version">v${VERSION}</span>`);
716
+ res.writeHead(200, { 'content-type': MIME['.html'], 'cache-control': 'no-store', 'set-cookie': `rt_board=${BOARD_COOKIE}; HttpOnly; SameSite=Strict; Path=/` });
717
+ return res.end(html);
718
+ }
719
+ if (req.method === 'GET' && p.startsWith('/ui/')) return serveFile(res, path.join(UI, path.normalize(p.slice(4))));
720
+ if (req.method === 'GET' && p === '/docs/workflow.md') return serveFile(res, path.join(ROOT, 'docs', 'workflow.md'));
721
+ if (req.method === 'GET' && p.startsWith('/src/') && p.endsWith('.js')) return serveFile(res, path.join(ROOT, path.normalize(p)));
722
+
723
+ if (req.method === 'GET' && p === '/api/seats') {
724
+ const cat = catalogue || (url.searchParams.get('cached') === '1' ? staticCatalogue : await catalogueReady);
725
+ return json(res, 200, {
726
+ runtime: { mode: FAKE ? 'demo' : 'live', dataDir: DEBATES_DIR, cataloguePending: !catalogue, ...(legacyDataDirs.length ? { legacyDataDirs } : {}), diagnostics: diagnostics.status() },
727
+ vendors: Object.entries(ADAPTERS).map(([vendor, a]) => ({
728
+ vendor, kind: a.kind, label: a.label, colour: a.colour, usageUrl: a.usageUrl || null, quota: quota[vendor] || null, profiles: poolsByAccount(a), limitedIsolation: a.limitedIsolation === true, search: !!a.research,
729
+ terms: termsRecord.status(vendor), pool: a.pool, catalogue: cat[vendor],
730
+ })),
731
+ moods: Object.fromEntries(Object.entries(MOODS).map(([k, v]) => [k, v])),
732
+ colours: COLOURS,
733
+ defaults: DEFAULTS,
734
+ baselines,
735
+ calibration,
736
+ pools,
737
+ });
738
+ }
739
+ if (p === '/api/diagnostics/report' && req.method === 'POST') {
740
+ const events = validateBrowserDiagnostics(await readBody(req));
741
+ const saved = events.map((e) => diagnostics.write(e)).filter((e) => e.saved).map((e) => e.id);
742
+ return json(res, 200, { saved, ...diagnostics.status() });
743
+ }
744
+ if (p === '/api/diagnostics' && req.method === 'POST') {
745
+ await readBody(req);
746
+ return json(res, 200, diagnostics.read());
747
+ }
748
+ const pm = p.match(/^\/api\/pools\/([a-z]+)$/);
749
+ if (pm && req.method === 'POST') return json(res, 200, { pool: setPool(pm[1], await readBody(req)) });
750
+ // The person's acknowledgment of a vendor's terms notice, or its withdrawal. Only the board page
751
+ // records it; an agent sends options.acknowledgeTermsRisk with each request instead. Once given,
752
+ // the model list and quota are read, and the answer carries the new list for the card.
753
+ const tm = p.match(/^\/api\/terms\/([a-z]+)$/);
754
+ if (tm && req.method === 'POST') {
755
+ const body = await readBody(req);
756
+ if (!fromBoard(req)) return requestError(res, 403, 'Only the board records this acknowledgment. An agent request sends options.acknowledgeTermsRisk: true instead, once the person has agreed.', 'terms');
757
+ if (typeof body?.acknowledged !== 'boolean') throw new Error('acknowledged must be true or false.');
758
+ const terms = termsRecord.set(tm[1], body.acknowledged);
759
+ console.log(`[terms] ${ADAPTERS[tm[1]].label} terms notice ${body.acknowledged ? 'acknowledged on the board' : 'acknowledgment withdrawn'}`);
760
+ if (body.acknowledged) { catalogueReady = readCatalogue(); await catalogueReady; refreshQuota(tm[1]); }
761
+ return json(res, 200, { terms, catalogue: catalogue?.[tm[1]] || null });
762
+ }
763
+ if (req.method === 'GET' && p === '/api/debates') return json(res, 200, { debates: await listDebates(), usage: usageStore.summary(FAKE) });
764
+ if (req.method === 'POST' && (p === '/api/debates/clear' || p === '/api/debates/clear-finished')) {
765
+ const body = await readBody(req);
766
+ // Keep the older endpoint's finished-only contract for existing clients.
767
+ return json(res, 200, deleteSessions(body?.ids, p === '/api/debates/clear-finished'));
768
+ }
769
+ const deletion = p.match(/^\/api\/debates\/([^/]+)\/delete$/);
770
+ if (req.method === 'POST' && deletion) { await readBody(req); return json(res, 200, deleteSessions([deletion[1]])); }
771
+ // Opens this machine's folder dialog for the export fields; one at a time. A POST, so it needs
772
+ // the launch token like every other action.
773
+ if (req.method === 'POST' && p === '/api/pick-folder') {
774
+ await readBody(req);
775
+ if (pickingFolder) return requestError(res, 409, 'A folder dialog is already open. Finish or cancel it first.', 'pick-folder');
776
+ pickingFolder = true;
777
+ try { const chosen = await pickFolder(); return json(res, 200, chosen ? { path: chosen } : { cancelled: true }); }
778
+ finally { pickingFolder = false; }
779
+ }
780
+ if (req.method === 'POST' && p === '/api/preview') {
781
+ const plan = prepareDebate(await readBody(req), { board: fromBoard(req) });
782
+ return json(res, 200, { valid: true, mode: FAKE ? 'demo' : 'live',
783
+ plannedCalls: plan.seats.length * (plan.options.critiqueRounds + 1) + 1 + (plan.options.research ? 1 : 0),
784
+ projection: project({ seats: plan.seats, chairman: plan.chairman || { vendor: plan.seats[0].vendor }, critiqueRounds: plan.options.critiqueRounds, memory: plan.options.memory, research: plan.options.research, baselines }),
785
+ note: 'Estimate before retries. Checking does not start a review or call a model.' });
786
+ }
787
+ if (p === '/api/preflight' && (req.method === 'GET' || req.method === 'POST')) {
788
+ // GET never executes, even with ?fresh. POST {pools, endpoints, fresh} refreshes under
789
+ // the launch token; forced refresh still shares in-flight work and obeys the cooldown.
790
+ const refresh = req.method === 'POST';
791
+ const body = refresh ? await readBody(req) : { pools: String(url.searchParams.get('pools') || '').split(',') };
792
+ if (body.fresh != null && typeof body.fresh !== 'boolean') throw new Error('fresh must be a boolean.');
793
+ const list = Array.isArray(body.pools) ? body.pools : String(body.pools || '').split(',');
794
+ const keys = [...new Set(list.map((k) => String(k).trim()).filter((k) => /^[a-z]+(@[a-z0-9][a-z0-9_.-]{0,39})?$/i.test(k)))].slice(0, 12);
795
+ const endpoints = req.method === 'POST' && body.endpoints && typeof body.endpoints === 'object' ? body.endpoints : {};
796
+ return json(res, 200, { pools: await preflight(keys, { refresh, fresh: body.fresh === true, endpoints }) });
797
+ }
798
+ if (req.method === 'POST' && p === '/api/debates') {
799
+ const body = await readBody(req);
800
+ return json(res, 200, { slug: startDebate(body, { board: fromBoard(req) }) });
801
+ }
802
+ const m = p.match(/^\/api\/debates\/([^/]+)\/(events|state|continue|finish|stop|hold|resume|export|synthesize|accept)$/);
803
+ if (m) {
804
+ let d;
805
+ try { d = debates.get(m[1]) || loadDebate(m[1]); } catch (e) {
806
+ if (m[2] !== 'events' || e.code !== 'TRANSCRIPT_CORRUPT') throw e;
807
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
808
+ const diagnostic = diagnostics.write({ code: 'transcript-warning', action: 'events', line: e.line }, e);
809
+ res.end(`data: ${JSON.stringify({ type: 'error', message: e.message, fatal: true, diagnostic })}\n\n`);
810
+ return;
811
+ }
812
+ if (!d) return requestError(res, 404, 'This review was not found on this server. Return to Debates and choose an existing review.', diagnosticAction(p));
813
+ if (m[2] === 'state' && req.method === 'GET') return json(res, 200, reviewSnapshot(d));
814
+ if (m[2] === 'events') {
815
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
816
+ res.flushHeaders(); // let an up-to-date browser reconnect even when there is nothing to replay
817
+ const from = replayFrom(d, req.headers['last-event-id']);
818
+ // A known id means the board was watching and lost the connection: what follows it is
819
+ // new to that board, so endings can still show and sound. With no id, or one from an
820
+ // earlier server run, the board starts over and everything is history.
821
+ const asHistory = from === 0;
822
+ for (let i = from; i < d.events.length; i++) res.write(sseLine(d, i, asHistory));
823
+ d.clients.add(res);
824
+ req.on('close', () => { d.clients.delete(res); pruneReplays(); });
825
+ return;
826
+ }
827
+ const body = validateMessage(await readBody(req));
828
+ if (m[2] === 'stop') {
829
+ if (d.finished || d.interrupted) return requestError(res, 400, d.finished ? 'This debate is finished; it replays read-only.' : 'This debate was interrupted; resume it first.', 'stop');
830
+ // Cancellation is independent of the question the client last saw. Question drafts
831
+ // are not answers to a later gate; only an explicit general note is saved by Stop.
832
+ if (!answerGate(d, { action: 'stop', message: body.message || '', source: body.source })) {
833
+ d.debate.requestStop(body.message || '', body.source);
834
+ }
835
+ return json(res, 200, { ok: true, ...(body.answers?.length ? { warning: 'The review was stopped. Question answers were not submitted.' } : {}) });
836
+ }
837
+ if (['continue', 'finish', 'hold'].includes(m[2]) && body.round != null && d.pending?.info.round !== body.round)
838
+ return requestError(res, 409, 'The review has moved to another step. Your answers were not sent. Read the current questions before retrying.', m[2]);
839
+ if (m[2] === 'hold' && (body.answers?.length || body.message.trim()))
840
+ return requestError(res, 400, 'More time does not submit answers or notes. Use Continue or agent answer to send them.', 'hold');
841
+ if (body.answers?.length) {
842
+ if (!d.pending) return requestError(res, 409, 'The review is not waiting for answers. Your draft was not sent.', m[2]);
843
+ body.message = validateMessage({ message: answerMessage(d.pending.info.questions, body.answers, body.message) }).message;
844
+ }
845
+ if (m[2] === 'resume') { resumeDebate(d, body); return json(res, 200, { ok: true }); }
846
+ if (m[2] === 'synthesize') { synthesizeDebate(d, body); return json(res, 200, { ok: true }); }
847
+ if (m[2] === 'accept') return json(res, 200, acceptDecision(d, body));
848
+ if (m[2] === 'export') {
849
+ if (!d.finished) return requestError(res, 400, 'Export after the verdict; the record needs the whole debate.', 'export');
850
+ const out = exportRecord(d, body.target);
851
+ return json(res, 200, { path: out.path, indexPath: out.indexPath, status: out.meta.status, chosen: out.meta.chosen });
852
+ }
853
+ if (d.finished || d.interrupted) return requestError(res, 400, d.finished ? 'This debate is finished; it replays read-only.' : 'This debate was interrupted; resume it first.', m[2]);
854
+ if (m[2] === 'finish') {
855
+ if (!d.debate.canFinish()) return requestError(res, 409, 'Finish with verdict needs a completed discussion round with a usable reply. Use Stop to cancel everything.', 'finish');
856
+ if (!answerGate(d, { action: 'finish', message: body.message || '', source: body.source })) d.debate.requestFinish(body.message || '', body.source);
857
+ return json(res, 200, { ok: true });
858
+ }
859
+ if (m[2] === 'continue') return answerGate(d, { action: 'continue', message: body.message || '', source: body.source }) ? json(res, 200, { ok: true })
860
+ : requestError(res, 409, 'The review is no longer waiting for input. Your note was not sent. Check the current round before retrying.', 'continue');
861
+ if (m[2] === 'hold') {
862
+ if (!d.pending) return requestError(res, 409, 'The response window has closed. Read the current state before requesting more time.', 'hold');
863
+ const deadline = scheduleGate(d, d.pending.info.questionWaitMs || DEFAULTS.questionWaitMs, true);
864
+ return json(res, 200, { ok: true, autoContinueAt: deadline });
865
+ }
866
+ }
867
+ res.writeHead(404); res.end('not found');
868
+ } catch (e) {
869
+ // An oversized body: answer, close the connection, and discard the rest unread.
870
+ if (e.drain) { res.setHeader('connection', 'close'); req.resume(); }
871
+ const status = e.status || (e instanceof TypeError || e instanceof ReferenceError || e.code && e.code !== 'TRANSCRIPT_CORRUPT' ? 500 : 400);
872
+ requestError(res, status, e.message, p === '/api/debates' && req.method === 'POST' ? 'create' : diagnosticAction(p), e);
873
+ }
874
+ });
875
+
876
+ // Open the board in the browser when a person started the server from a terminal: not for the
877
+ // tests, not for an editor's preview, not with --no-open or ROUNDTABLE_NO_OPEN=1.
878
+ function openBrowser(url) {
879
+ const cmd = process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
880
+ try { spawn(cmd[0], cmd[1], { stdio: 'ignore', detached: true, windowsHide: true }).on('error', () => {}).unref(); return true; } catch { return false; }
881
+ }
882
+ // A port that cannot be taken is said plainly: in use, or a sandbox that forbids listening
883
+ // (Codex on Linux, for one), where the board has to be started from a normal terminal instead.
884
+ server.on('error', (e) => {
885
+ diagnostics.write({ code: 'server-start', action: 'startup' }, e);
886
+ const why = e.code === 'EADDRINUSE' ? `port ${PORT} is in use. Check roundtable server status, or start with --port`
887
+ : ['EPERM', 'EACCES'].includes(e.code) ? `this environment does not allow a local server on 127.0.0.1:${PORT} (${e.code}). Start the board from a normal terminal with roundtable server start; the assistant's tools then connect to it`
888
+ : e.message;
889
+ console.error(`[server] cannot listen: ${why}`);
890
+ process.exit(1);
891
+ });
892
+ server.listen(PORT, '127.0.0.1', () => {
893
+ const url = `http://localhost:${PORT}`;
894
+ const open = process.stdout.isTTY && !process.argv.includes('--no-open') && process.env.ROUNDTABLE_NO_OPEN !== '1' && !process.env.CI;
895
+ const opened = open && openBrowser(url);
896
+ process.stdout.write(terminalBanner());
897
+ console.log(`roundtable${FAKE ? ' (fake seats)' : ''}: ${url} token ${TOKEN}`);
898
+ console.log(`mode: ${FAKE ? 'demo (scripted replies and usage)' : 'live'}; data: ${DEBATES_DIR}`);
899
+ const found = Object.entries(ADAPTERS).filter(([v, a]) => a.kind === 'cli' && installed(v)).map(([, a]) => paintVendor(a));
900
+ if (!FAKE) console.log(found.length ? `providers: ${found.join(', ')} (CLIs on PATH; an API endpoint is seated on the board)` : 'providers: no Claude, Codex or Antigravity CLI on PATH; seat an API endpoint on the board, or install one and restart. roundtable doctor has details');
901
+ console.log('diagnostics: _diagnostics in the data folder; use Save error log on the board. Nothing is uploaded.');
902
+ console.log(`${opened ? 'the board is open in your browser' : `open ${url} in your browser`}; Ctrl+C here stops the server and every CLI it started`);
903
+ });
904
+
905
+ // No CLI outlives the server: on a signal or an uncaught error every registered child goes
906
+ // (process tree on Windows, process group elsewhere) before the process exits.
907
+ function shutdown(why, code = 0) {
908
+ if (shuttingDown) return;
909
+ shuttingDown = true;
910
+ const n = liveChildren().length;
911
+ for (const d of debates.values()) {
912
+ d.debate?.requestInterrupt();
913
+ answerGate(d, { action: 'interrupt' });
914
+ for (const client of d.clients) client.end();
915
+ d.clients.clear();
916
+ }
917
+ killAll();
918
+ console.log(`[server] ${why}${n ? `, killed ${n} CLI process${n === 1 ? '' : 'es'}` : ''}`);
919
+ const exit = () => { killAll(); process.exit(code); };
920
+ const closed = new Promise((resolve) => server.close(resolve));
921
+ Promise.allSettled([closed, ...[...debates.values()].map((d) => d.runPromise)]).then(exit);
922
+ setTimeout(exit, 1500).unref();
923
+ }
924
+ process.on('SIGINT', () => shutdown('SIGINT'));
925
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
926
+ process.on('uncaughtException', (e) => { diagnostics.write({ code: 'server-crash', action: 'unknown' }, e); console.error('[server] uncaught:', e); shutdown('uncaught error', 1); });
927
+ process.on('unhandledRejection', (e) => { diagnostics.write({ code: 'server-crash', action: 'unknown' }, e); console.error('[server] unhandled rejection:', e); });
928
+ for (const vendor of Object.keys(readers)) refreshQuota(vendor); // one read each at start: claude and agy -p /usage, the codex app-server; agy only once its terms notice is acknowledged