@volter/supercode-orchestrator 0.4.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.
@@ -0,0 +1,615 @@
1
+ // The `api_server`-compatible HTTP surface (ORC-9): Hermes's own client API,
2
+ // served by this loop, behind the same three-method adapter trait
3
+ // (`connect(emit)` / `send(target, content)` / `close()`).
4
+ //
5
+ // EVERY route below quotes the Hermes handler it was matched against, from
6
+ // `~/.hermes/hermes-agent/gateway/platforms/api_server.py` and its
7
+ // `api_server_runs.py` sibling at the pinned 0.21.0 tree. Where Hermes's JSON
8
+ // carries a field the IR has no source for, the field is returned EMPTY and
9
+ // named in README.md § "The api_server surface"; nothing is invented.
10
+ //
11
+ // WHERE IT LISTENS. Its own `node:http` server on
12
+ // `platforms.api_server.extra.host|port` — exactly Hermes's keys
13
+ // (`api_server.py:1515-1519`: `extra.get("host", …)` / `extra.get("port")`).
14
+ // It is NOT mounted on the loop's webhook listener: that one binds
15
+ // `gateway.webhook_port`, a different Hermes key with a different meaning, and
16
+ // hosting both on one socket would make one port answer for two configured
17
+ // addresses. `webhook.mjs` is untouched.
18
+ //
19
+ // AUTH. `Authorization: Bearer <key>` against `platforms.api_server.extra.key`
20
+ // — Hermes's `_check_auth` (`api_server.py:1916-1968`), whose expected key is
21
+ // `extra.get("key", API_SERVER_KEY)` (`:1520`). `X-Hermes-Session-Key` is NOT
22
+ // an auth credential in Hermes: `_parse_session_key_header` (`:2403-2440`)
23
+ // reads it as the caller-declared CONVERSATION scope and requires the API key
24
+ // to be configured before honouring it. It is read with that meaning here.
25
+ //
26
+ // NO PRIVATE PATH (§3). Everything this surface makes the loop do enters as an
27
+ // `inbound` event: a chat is an inbound, a run is an inbound on a fresh
28
+ // binding, a steer is an inbound queued behind the running turn (§4.1 step 5),
29
+ // a stop is the `/reset` chat command (§2.7), and an approval is the numeric
30
+ // reply §4.2's permission prompt asks for. The surface reads runtime state
31
+ // (§2.8) to build its event stream; it never writes it.
32
+
33
+ import { surfaceKeyString } from '../ir.mjs';
34
+ import { leaseId } from '../reducer.mjs';
35
+
36
+ /** Hermes's own defaults (`api_server.py` DEFAULT_HOST / DEFAULT_PORT). */
37
+ export const DEFAULT_HOST = '127.0.0.1';
38
+ export const DEFAULT_PORT = 8090;
39
+
40
+ const MAX_BODY = 1024 * 1024;
41
+
42
+ /** `POST /api/sessions` mints `api_<epoch>_<hex>` when the body names no id (`api_server.py:4443`). */
43
+ function mintSessionId(at, n) {
44
+ return `api_${Math.floor(Date.parse(at) / 1000)}_${n.toString(16).padStart(8, '0')}`;
45
+ }
46
+
47
+ /** Hermes's OpenAI-shaped error envelope (`_openai_error`). */
48
+ function errorBody(message, code = null, type = 'invalid_request_error') {
49
+ const error = { message, type };
50
+ if (code) error.code = code;
51
+ return { error };
52
+ }
53
+
54
+ export class ApiServerAdapter {
55
+ /**
56
+ * @param {object} o
57
+ * @param {string} [o.platform] always `api_server`: the block name IS the platform (§2.4)
58
+ * @param {string} [o.profile] the profile that owns the block
59
+ * @param {object} [o.channel] the loaded `ChannelConfig`; `extra.key` is a `{dotenv}` ref
60
+ * @param {Map<string,string>} [o.vault] resolved at `connect`, never earlier
61
+ * @param {object} o.state the OrchestratorState: read for §2.8 runtime and for bindings
62
+ * @param {() => string} [o.clock]
63
+ * @param {(level: string, message: string) => void} [o.log]
64
+ */
65
+ constructor({ platform = 'api_server', profile = 'default', channel = null, vault = new Map(), state = null, clock = null, log = () => {} }) {
66
+ this.platform = platform;
67
+ this.profile = profile;
68
+ this.channel = channel;
69
+ this.vault = vault;
70
+ this.state = state;
71
+ this.clock = clock ?? (() => new Date().toISOString());
72
+ this.log = log;
73
+ this.emit = null;
74
+ this.server = null;
75
+ this.port = null;
76
+ this.key = null;
77
+ this.counter = 0;
78
+ /** session id -> the Hermes-shaped session row this surface owns. */
79
+ this.sessions = new Map();
80
+ /** run id -> the pollable run status (`hermes.run`). */
81
+ this.runs = new Map();
82
+ /** keyString -> [resolve] for the `/chat` calls waiting on this turn's answer. */
83
+ this.waiting = new Map();
84
+ /** run id -> Set<ServerResponse> subscribed to the SSE stream. */
85
+ this.streams = new Map();
86
+ /** run id -> what the stream has already reported, so a poll never repeats itself. */
87
+ this.watermarks = new Map();
88
+ this.poll = null;
89
+ this.version = '0';
90
+ /** `node:crypto`'s constant-time compare, resolved at `connect` (this module imports no node module eagerly). */
91
+ this.timingSafeEqual = (a, b) => a.equals(b);
92
+ }
93
+
94
+ extra(name, fallback = undefined) {
95
+ const e = this.channel?.extra ?? {};
96
+ return e[`extra.${name}`] ?? e[name] ?? fallback;
97
+ }
98
+
99
+ /** `extra.key`, a `${NAME}` reference, resolved from the vault (`.env`), then the environment, as Hermes does. */
100
+ resolveKey() {
101
+ const ref = this.channel?.credentials?.['extra.key'] ?? this.channel?.credentials?.key ?? null;
102
+ if (!ref) return null;
103
+ const name = ref.dotenv ?? ref.env;
104
+ return this.vault.get(name) ?? process.env[name] ?? null;
105
+ }
106
+
107
+ /** The user id every request from this surface speaks as; the profile's access (Hermes's own stores) gates it like any other sender. */
108
+ get sender() {
109
+ return String(this.extra('user_id', 'api'));
110
+ }
111
+
112
+ // ------------------------------------------------------------ the trait
113
+
114
+ async connect(emit) {
115
+ this.emit = emit;
116
+ this.key = this.resolveKey();
117
+ const { createServer } = await import('node:http');
118
+ const { readFileSync } = await import('node:fs');
119
+ const { fileURLToPath } = await import('node:url');
120
+ ({ timingSafeEqual: this.timingSafeEqual } = await import('node:crypto'));
121
+ // `/v1/health` reports this package's real version, never a literal that
122
+ // goes stale the next time it is published.
123
+ try { this.version = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version; } catch { this.version = '0'; }
124
+ const host = String(this.extra('host', DEFAULT_HOST));
125
+ const port = Number(this.extra('port', DEFAULT_PORT));
126
+ // a request the router cannot handle (a malformed URL, bad percent-encoding,
127
+ // a close mid-request) is that request's 500, never the daemon's exit
128
+ this.server = createServer((req, res) => {
129
+ this.route(req, res).catch((error) => {
130
+ this.log?.('warn', `api-server: ${req.method} ${req.url}: ${error?.message ?? error}`);
131
+ if (!res.headersSent) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'internal error' } })); }
132
+ else res.end();
133
+ });
134
+ });
135
+ await new Promise((resolve, reject) => {
136
+ this.server.once('error', reject);
137
+ this.server.listen(port, host, () => { this.server.removeListener('error', reject); resolve(); });
138
+ });
139
+ this.server.unref?.();
140
+ this.port = this.server.address().port;
141
+ const every = Number(this.extra('stream_poll_ms', 10));
142
+ this.poll = setInterval(() => this.pump(), every);
143
+ this.poll.unref?.();
144
+ this.log('info', `api_server listening on http://${host}:${this.port}`);
145
+ return { state: 'up' };
146
+ }
147
+
148
+ /**
149
+ * `Deliver(obligation)` (§5) for this platform: the loop's answer for a
150
+ * surface this server owns. It resolves whatever `/chat` call is waiting on
151
+ * that key, appends the assistant message to the session's transcript, and
152
+ * finishes the run when the key is a run's. Returns the handle the loop
153
+ * records as `Obligation.posted.message_id` (§2.6).
154
+ */
155
+ async send(target, content) {
156
+ const text = content.text ?? '';
157
+ const keyString = surfaceKeyString(target);
158
+ const id = `apisrv-${++this.counter}`;
159
+ const at = content.at ?? this.clock();
160
+ const session = this.sessions.get(String(target.chat_id ?? ''));
161
+ if (session) session.messages.push({ id, session_id: session.id, role: 'assistant', content: text, timestamp: at });
162
+ const waiters = this.waiting.get(keyString);
163
+ if (waiters?.length) {
164
+ this.waiting.delete(keyString);
165
+ for (const resolve of waiters) resolve({ id, text, at });
166
+ }
167
+ // A run finishes on the turn's FINAL, not on any message the loop sends.
168
+ // §4.2 deletes `runtime.turns[lease]` before it queues the final's reply
169
+ // and keeps it while a permission prompt goes out mid-turn, so an empty
170
+ // turn slot is what tells the two apart — no new state, no new flag.
171
+ const lid = this.leaseFor(keyString);
172
+ const midTurn = Boolean(lid && this.state?.runtime?.turns?.[lid]);
173
+ const run = this.runs.get(String(target.chat_id ?? ''));
174
+ if (run && !midTurn && !['completed', 'failed', 'cancelled', 'stopping'].includes(run.status)) {
175
+ run.status = 'completed';
176
+ run.updated_at = Date.parse(at) / 1000;
177
+ run.last_event = 'run.completed';
178
+ run.output = text;
179
+ delete run.approval;
180
+ this.frame(run.run_id, { event: 'run.completed', run_id: run.run_id, timestamp: run.updated_at, output: text, usage: {} });
181
+ this.closeStream(run.run_id);
182
+ }
183
+ return { ok: true, message_id: id };
184
+ }
185
+
186
+ async close() {
187
+ this.emit = null;
188
+ if (this.poll) { clearInterval(this.poll); this.poll = null; }
189
+ for (const id of [...this.streams.keys()]) this.closeStream(id);
190
+ for (const waiters of this.waiting.values()) for (const resolve of waiters) resolve(null);
191
+ this.waiting.clear();
192
+ if (this.server) { const s = this.server; this.server = null; await new Promise((r) => s.close(() => r())); }
193
+ }
194
+
195
+ // ------------------------------------------------------------ the loop side
196
+
197
+ /** The surface key for one of this platform's conversations (§2.3). */
198
+ keyFor(chatId) {
199
+ return { platform: this.platform, kind: 'dm', chat_id: String(chatId) };
200
+ }
201
+
202
+ /** The one door into the loop: an `inbound` event, same callback loopback gets. */
203
+ inbound(chatId, text, { message_id = null } = {}) {
204
+ if (!this.emit) throw new Error('api_server adapter is not connected');
205
+ return this.emit({
206
+ profile: this.profile,
207
+ key: this.keyFor(chatId),
208
+ sender: this.sender,
209
+ sender_name: null,
210
+ text,
211
+ message_id,
212
+ is_me: false,
213
+ is_bot: false,
214
+ mention: false,
215
+ });
216
+ }
217
+
218
+ /** Wait for the loop's reply on this key; `timeout_ms` from the block, else 120 s. */
219
+ answer(chatId) {
220
+ const keyString = surfaceKeyString(this.keyFor(chatId));
221
+ return new Promise((resolve) => {
222
+ const list = this.waiting.get(keyString) ?? [];
223
+ list.push(resolve);
224
+ this.waiting.set(keyString, list);
225
+ const ms = Number(this.extra('timeout_ms', 120_000));
226
+ const t = setTimeout(() => {
227
+ const held = this.waiting.get(keyString) ?? [];
228
+ const at = held.indexOf(resolve);
229
+ if (at >= 0) held.splice(at, 1);
230
+ resolve(null);
231
+ }, ms);
232
+ t.unref?.();
233
+ });
234
+ }
235
+
236
+ /** The profile whose runtime is serving this key — routes may move it (§2.3). */
237
+ leaseFor(keyString) {
238
+ const rt = this.state?.runtime ?? {};
239
+ for (const name of Object.keys(this.state?.profiles ?? {})) {
240
+ const lid = leaseId(name, keyString);
241
+ if (rt.turns?.[lid] || rt.leases?.[lid]) return lid;
242
+ }
243
+ return null;
244
+ }
245
+
246
+ /** The pending permission request on this key, if the worker raised one (§2.8). */
247
+ pendingRequest(keyString) {
248
+ const pending = this.state?.runtime?.pending_requests ?? {};
249
+ for (const [id, p] of Object.entries(pending)) if (p.keyString === keyString) return { id, ...p };
250
+ return null;
251
+ }
252
+
253
+ /**
254
+ * The run event stream, derived from §2.8 runtime state on a timer. The loop
255
+ * publishes no event bus and ORC-9 may not add one, so the stream watches
256
+ * the three things the runtime already holds for a live turn — the turn's
257
+ * accumulating text buffer, the pending permission requests, and (through
258
+ * `send`) the obligation the turn's `final` produces.
259
+ */
260
+ pump() {
261
+ const stamp = Date.parse(this.clock()) / 1000;
262
+ for (const [runId, run] of this.runs) {
263
+ if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') continue;
264
+ const keyString = surfaceKeyString(this.keyFor(runId));
265
+ const mark = this.watermarks.get(runId) ?? { delta: 0, request: null };
266
+ const lid = this.leaseFor(keyString);
267
+ const buffer = lid ? this.state?.runtime?.turns?.[lid]?.buffer ?? '' : '';
268
+ if (buffer.length > mark.delta) {
269
+ this.frame(runId, { event: 'message.delta', run_id: runId, timestamp: stamp, delta: buffer.slice(mark.delta) });
270
+ mark.delta = buffer.length;
271
+ }
272
+ const pending = this.pendingRequest(keyString);
273
+ if (pending && pending.id !== mark.request) {
274
+ mark.request = pending.id;
275
+ run.status = 'waiting_for_approval';
276
+ run.last_event = 'approval.request';
277
+ // `_approval_event_choices` shape (api_server_runs.py:760-767): the
278
+ // choices this loop can actually answer are the worker's own options.
279
+ run.updated_at = stamp;
280
+ run.approval = { event: 'approval.request', run_id: runId, request_id: pending.id, choices: (pending.options ?? []).map((o) => o.name ?? o.kind ?? '') };
281
+ this.frame(runId, { ...run.approval, timestamp: stamp });
282
+ }
283
+ this.watermarks.set(runId, mark);
284
+ }
285
+ }
286
+
287
+ // ------------------------------------------------------------ SSE
288
+
289
+ frame(runId, payload) {
290
+ const body = `data: ${JSON.stringify(payload)}\n\n`;
291
+ for (const res of this.streams.get(runId) ?? []) { try { res.write(body); } catch { /* client gone */ } }
292
+ }
293
+
294
+ closeStream(runId) {
295
+ for (const res of this.streams.get(runId) ?? []) { try { res.write(': stream closed\n\n'); res.end(); } catch { /* already gone */ } }
296
+ this.streams.delete(runId);
297
+ }
298
+
299
+ // ------------------------------------------------------------ routing
300
+
301
+ /**
302
+ * Hermes compares the bearer token with `hmac.compare_digest`
303
+ * (`api_server.py:1957`) — a constant-time compare, so a wrong key leaks no
304
+ * prefix through timing. `webhook.mjs`'s signature check does the same; this
305
+ * is that, for the same reason.
306
+ */
307
+ authorized(req) {
308
+ if (!this.key) return true; // no key configured: Hermes's own no-key branch (`_check_auth`, api_server.py:1922-1926)
309
+ const header = String(req.headers.authorization ?? '');
310
+ if (!header.startsWith('Bearer ')) return false;
311
+ const given = Buffer.from(header.slice(7).trim());
312
+ const expected = Buffer.from(this.key);
313
+ return given.length === expected.length && this.timingSafeEqual(given, expected);
314
+ }
315
+
316
+ async route(req, res) {
317
+ const url = new URL(req.url ?? '/', 'http://localhost');
318
+ const path = url.pathname;
319
+ const method = (req.method ?? 'GET').toUpperCase();
320
+ const send = (status, body, headers = {}) => {
321
+ res.writeHead(status, { 'content-type': 'application/json', ...headers });
322
+ res.end(JSON.stringify(body));
323
+ };
324
+
325
+ // GET /v1/health — `api_server.py:3277-3281`, the ONE route with no
326
+ // `_check_auth`. `{"status","platform","version"}`; `platform` names the
327
+ // server that answers, so it is this one, not "hermes-agent".
328
+ if (method === 'GET' && (path === '/v1/health' || path === '/health')) {
329
+ return send(200, { status: 'ok', platform: 'supercode-orchestrator', version: this.version });
330
+ }
331
+
332
+ if (!this.authorized(req)) {
333
+ // `_check_auth`'s own body (`api_server.py:1966-1968`).
334
+ return send(401, { error: { message: 'Invalid gateway API key (API_SERVER_KEY)', type: 'gateway_auth_error', code: 'gateway_auth_failed' } });
335
+ }
336
+
337
+ // GET /v1/capabilities — `api_server.py:3427-3475`. Every flag is this
338
+ // loop's own answer; a surface it does not serve reads `false`, never a
339
+ // copy of Hermes's `true`.
340
+ if (method === 'GET' && path === '/v1/capabilities') {
341
+ return send(200, {
342
+ object: 'hermes.api_server.capabilities',
343
+ platform: 'supercode-orchestrator',
344
+ model: '',
345
+ auth: { type: 'bearer', required: Boolean(this.key) },
346
+ runtime: {
347
+ mode: 'server_agent',
348
+ tool_execution: 'server',
349
+ split_runtime: false,
350
+ description: 'The orchestrator opens a binding on this surface and drives the profile\'s worker through its own door (docs/ORCHESTRATOR-IR.md §4.1).',
351
+ },
352
+ features: {
353
+ chat_completions: false,
354
+ chat_completions_streaming: false,
355
+ responses_api: false,
356
+ responses_streaming: false,
357
+ run_submission: true,
358
+ runs_idempotency: { supported: false, durable: false, retention_seconds: 0 },
359
+ run_status: true,
360
+ run_events_sse: true,
361
+ run_stop: true,
362
+ run_steer: true,
363
+ run_approval_response: true,
364
+ session_create: true,
365
+ session_chat: true,
366
+ session_chat_stream: false,
367
+ session_messages: true,
368
+ },
369
+ session_key_header: 'X-Hermes-Session-Key',
370
+ });
371
+ }
372
+
373
+ // POST /api/sessions — `api_server.py:4422-4541`. Body `{id|session_id,
374
+ // title, source, system_prompt, model}`; 409 on an id that exists; 201
375
+ // `{"object":"hermes.session","session":{…}}` shaped by `_session_response`
376
+ // (`:4283-4302`).
377
+ if (method === 'POST' && path === '/api/sessions') {
378
+ const body = await this.readJson(req);
379
+ if (body === null) return send(400, errorBody('Invalid JSON in request body'));
380
+ const at = this.clock();
381
+ const raw = body.id ?? body.session_id;
382
+ const id = raw ? String(raw).trim() : mintSessionId(at, ++this.counter);
383
+ if (!id || /[\r\n\0]/.test(id)) return send(400, errorBody('Invalid session ID', 'invalid_session_id'));
384
+ if (this.sessions.has(id)) return send(409, errorBody(`Session already exists: ${id}`, 'session_exists'));
385
+ this.sessions.set(id, {
386
+ id, source: String(body.source ?? 'api_server'), title: body.title ?? null,
387
+ started_at: Date.parse(at) / 1000, messages: [], system_prompt: body.system_prompt ?? null,
388
+ });
389
+ return send(201, { object: 'hermes.session', session: this.sessionResponse(id) });
390
+ }
391
+
392
+ const chat = /^\/api\/sessions\/([^/]+)\/chat$/.exec(path);
393
+ // POST /api/sessions/{id}/chat — `api_server.py:4720-4833`. Body's user
394
+ // turn is `message` ∥ `input` (`_session_chat_user_message`, `:931-943`);
395
+ // the reply is `{"object":"hermes.session.chat.completion","session_id",
396
+ // "message":{"role":"assistant","content"},"usage","runtime"}` with the
397
+ // `X-Hermes-Session-Id` header (`:4804-4833`).
398
+ if (method === 'POST' && chat) {
399
+ const id = decodeURIComponent(chat[1]);
400
+ if (!this.sessions.has(id)) return send(404, errorBody(`Session not found: ${id}`, 'session_not_found'));
401
+ const body = await this.readJson(req);
402
+ if (body === null) return send(400, errorBody('Invalid JSON in request body'));
403
+ const text = body.message ?? body.input;
404
+ if (typeof text !== 'string' || !text.trim()) return send(400, errorBody("Missing 'message' field", 'missing_message'));
405
+ const session = this.sessions.get(id);
406
+ const at = this.clock();
407
+ session.messages.push({ id: `apisrv-in-${++this.counter}`, session_id: id, role: 'user', content: text, timestamp: at });
408
+ const waiter = this.answer(id);
409
+ await this.inbound(id, text);
410
+ const answer = await waiter;
411
+ const headers = { 'X-Hermes-Session-Id': id };
412
+ const declared = String(req.headers['x-hermes-session-key'] ?? '').trim();
413
+ if (declared) headers['X-Hermes-Session-Key'] = declared;
414
+ return send(200, {
415
+ object: 'hermes.session.chat.completion',
416
+ session_id: id,
417
+ message: { role: 'assistant', content: answer?.text ?? '' },
418
+ usage: {}, // EMPTY: the IR records no token accounting (README)
419
+ runtime: {}, // EMPTY: the IR records no provider/model routing (README)
420
+ }, headers);
421
+ }
422
+
423
+ const messages = /^\/api\/sessions\/([^/]+)\/messages$/.exec(path);
424
+ // GET /api/sessions/{id}/messages — `api_server.py:4613-4670`.
425
+ // `{"object":"list","session_id","data":[…],"pagination":{limit,offset,
426
+ // order,returned}}`; rows shaped by `_message_response` (`:4305-4312`).
427
+ if (method === 'GET' && messages) {
428
+ const id = decodeURIComponent(messages[1]);
429
+ const session = this.sessions.get(id);
430
+ if (!session) return send(404, errorBody(`Session not found: ${id}`, 'session_not_found'));
431
+ const rawLimit = url.searchParams.get('limit');
432
+ const rawOffset = url.searchParams.get('offset') ?? '0';
433
+ const order = url.searchParams.get('order');
434
+ if (order !== null && order !== 'oldest' && order !== 'latest') return send(400, errorBody('order must be one of: oldest, latest', 'invalid_pagination'));
435
+ const offset = Number(rawOffset);
436
+ const requested = rawLimit === null ? null : Number(rawLimit);
437
+ if (!Number.isInteger(offset) || offset < 0 || (requested !== null && (!Number.isInteger(requested) || requested < 0))) {
438
+ return send(400, errorBody('limit and offset must be non-negative integers', 'invalid_pagination'));
439
+ }
440
+ const limit = requested === null ? 500 : Math.min(requested, 500);
441
+ const rows = session.messages.slice(offset, offset + limit);
442
+ return send(200, {
443
+ object: 'list',
444
+ session_id: id,
445
+ data: rows.map((m) => ({
446
+ id: m.id, session_id: m.session_id, role: m.role, content: m.content,
447
+ // EMPTY, all four: the IR keeps no per-message tool or token record
448
+ tool_call_id: null, tool_calls: null, tool_name: null, token_count: null,
449
+ timestamp: m.timestamp, finish_reason: null, reasoning: null,
450
+ })),
451
+ pagination: { limit, offset, order: order ?? (requested === null ? 'latest' : 'oldest'), returned: rows.length },
452
+ });
453
+ }
454
+
455
+ // POST /v1/runs — `api_server_runs.py:405-1038`. `input` is required
456
+ // (`:483-485`); a fresh run answers 202 `{"run_id","status":"started",
457
+ // "replayed":false}` (`:1034-1038`).
458
+ if (method === 'POST' && path === '/v1/runs') {
459
+ const body = await this.readJson(req);
460
+ if (body === null) return send(400, errorBody('Invalid JSON'));
461
+ const raw = body.input;
462
+ const text = typeof raw === 'string' ? raw : Array.isArray(raw) ? String(raw[raw.length - 1]?.content ?? '') : '';
463
+ if (!text) return send(400, errorBody("Missing 'input' field"));
464
+ const at = this.clock();
465
+ const runId = `run_${Date.parse(at)}_${(++this.counter).toString(36)}`;
466
+ this.runs.set(runId, {
467
+ object: 'hermes.run', run_id: runId, status: 'running',
468
+ created_at: Date.parse(at) / 1000, updated_at: Date.parse(at) / 1000,
469
+ session_id: runId, // the run IS its binding: one job-less turn on a fresh surface
470
+ model: '', // EMPTY: the IR does not name the provider model here
471
+ last_event: 'run.started', output: null, error: null,
472
+ });
473
+ this.frame(runId, { event: 'run.started', run_id: runId, timestamp: Date.parse(at) / 1000 });
474
+ void this.inbound(runId, text);
475
+ return send(202, { run_id: runId, status: 'started', replayed: false });
476
+ }
477
+
478
+ const run = /^\/v1\/runs\/([^/]+)(\/(events|approval|steer|stop))?$/.exec(path);
479
+ if (run) {
480
+ const runId = decodeURIComponent(run[1]);
481
+ const record = this.runs.get(runId);
482
+ if (!record) return send(404, errorBody(`Run not found: ${runId}`, 'run_not_found'));
483
+ const tail = run[3] ?? null;
484
+
485
+ // GET /v1/runs/{run_id} — `api_server_runs.py:1074-1105`: the pollable
486
+ // status object `_set_run_status` builds (`:116-137`).
487
+ if (method === 'GET' && tail === null) return send(200, record);
488
+
489
+ // GET /v1/runs/{run_id}/events — `api_server_runs.py:1108-1170`:
490
+ // `text/event-stream`, `data: <json>` frames, `: stream closed` at the end.
491
+ if (method === 'GET' && tail === 'events') {
492
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });
493
+ const set = this.streams.get(runId) ?? new Set();
494
+ set.add(res);
495
+ this.streams.set(runId, set);
496
+ // A late subscriber is caught up on the lifecycle it missed: the run's
497
+ // start, the approval it is blocked on, and its terminal event. Text
498
+ // deltas are live-only — the loop keeps no transcript to replay.
499
+ res.write(`data: ${JSON.stringify({ event: 'run.started', run_id: runId, timestamp: record.created_at })}\n\n`);
500
+ if (record.approval) res.write(`data: ${JSON.stringify({ ...record.approval, timestamp: record.updated_at })}\n\n`);
501
+ if (record.status === 'completed') { res.write(`data: ${JSON.stringify({ event: 'run.completed', run_id: runId, timestamp: record.updated_at, output: record.output ?? '', usage: {} })}\n\n`); this.closeStream(runId); }
502
+ req.on('close', () => { this.streams.get(runId)?.delete(res); });
503
+ return undefined;
504
+ }
505
+
506
+ // POST /v1/runs/{run_id}/approval — `api_server_runs.py:1174-1295`.
507
+ // Choices `once|session|always|deny` with `approve|approved|allow`
508
+ // aliasing to `once` (`:1201-1203`); 409 `approval_not_active` when no
509
+ // approval session is open (`:1250-1258`) and `approval_not_pending`
510
+ // when nothing is waiting (`:1276-1284`); the body is
511
+ // `{"object":"hermes.run.approval_response","run_id","choice","resolved"}`.
512
+ if (method === 'POST' && tail === 'approval') {
513
+ const body = await this.readJson(req);
514
+ if (body === null) return send(400, errorBody('Invalid JSON'));
515
+ const alias = { approve: 'once', approved: 'once', allow: 'once' };
516
+ const asked = String(body.choice ?? '').trim().toLowerCase();
517
+ const choice = alias[asked] ?? asked;
518
+ if (!['once', 'session', 'always', 'deny'].includes(choice)) {
519
+ return send(400, errorBody('Invalid approval choice; expected one of: always, deny, once, session', 'invalid_approval_choice'));
520
+ }
521
+ const keyString = surfaceKeyString(this.keyFor(runId));
522
+ const pending = this.pendingRequest(keyString);
523
+ if (!pending) return send(409, errorBody(`Run has no pending approval: ${runId}`, 'approval_not_pending'));
524
+ // §4.2's relay asks the surface for a NUMBER; that numeric reply is the
525
+ // only door into `pending_requests`, so the Hermes choice is resolved
526
+ // to the worker's own option and answered as that number.
527
+ const options = pending.options ?? [];
528
+ const want = choice === 'deny'
529
+ ? options.findIndex((o) => /reject|deny/i.test(o.kind ?? '') || /deny|reject/i.test(o.name ?? ''))
530
+ : options.findIndex((o) => /allow/i.test(o.kind ?? '') || /allow/i.test(o.name ?? ''));
531
+ const index = want >= 0 ? want : 0;
532
+ await this.inbound(runId, String(index + 1));
533
+ record.status = 'running';
534
+ record.last_event = 'approval.responded';
535
+ delete record.approval;
536
+ this.frame(runId, { event: 'approval.responded', run_id: runId, timestamp: Date.parse(this.clock()) / 1000, choice, resolved: 1 });
537
+ return send(200, { object: 'hermes.run.approval_response', run_id: runId, choice, resolved: 1 });
538
+ }
539
+
540
+ // POST /v1/runs/{run_id}/steer — `api_server_runs.py:1312-1386`. Text is
541
+ // `input` ∥ `message` ∥ `text` (`:1354`); only a `running` run accepts it
542
+ // (`:1341-1350`); body `{"object":"hermes.run.steer","run_id","accepted"}`.
543
+ if (method === 'POST' && tail === 'steer') {
544
+ const body = await this.readJson(req);
545
+ if (body === null) return send(400, errorBody('Invalid JSON'));
546
+ const text = String(body.input ?? body.message ?? body.text ?? '').trim();
547
+ if (!text) return send(400, errorBody("Missing non-empty steer text; expected 'input', 'message', or 'text'.", 'invalid_steer_input'));
548
+ if (record.status !== 'running') return send(409, errorBody(`Run is not currently accepting steer input: ${runId}`, 'run_not_accepting_steer'));
549
+ // §4.1 step 5: an inbound arriving during a running turn is queued
550
+ // behind it and sent when it ends — the IR's own steer.
551
+ await this.inbound(runId, text);
552
+ record.last_event = 'run.steered';
553
+ this.frame(runId, { event: 'run.steered', run_id: runId, timestamp: Date.parse(this.clock()) / 1000, accepted: true });
554
+ return send(200, { object: 'hermes.run.steer', run_id: runId, accepted: true });
555
+ }
556
+
557
+ // POST /v1/runs/{run_id}/stop — `api_server_runs.py:1390-1455`. A run
558
+ // already in a terminal state answers with its status (`:1425-1432`);
559
+ // otherwise `{"run_id","status":"stopping"}` (`:1455`).
560
+ if (method === 'POST' && tail === 'stop') {
561
+ if (['completed', 'failed', 'cancelled', 'interrupted'].includes(record.status)) return send(200, record);
562
+ record.status = 'stopping';
563
+ record.last_event = 'run.stopping';
564
+ record.updated_at = Date.parse(this.clock()) / 1000;
565
+ // §2.7's `/reset` is the loop's own "abandon this conversation": it
566
+ // ends the binding, which emits `runtime_close` for its worker (§4.5).
567
+ // Once that lands the run is terminal — Hermes's own `run.cancelled`
568
+ // (`api_server_runs.py:723`), not a `stopping` that never resolves.
569
+ await this.inbound(runId, '/reset');
570
+ record.status = 'cancelled';
571
+ record.last_event = 'run.cancelled';
572
+ record.updated_at = Date.parse(this.clock()) / 1000;
573
+ this.frame(runId, { event: 'run.cancelled', run_id: runId, timestamp: record.updated_at });
574
+ this.closeStream(runId);
575
+ return send(200, { run_id: runId, status: 'stopping' });
576
+ }
577
+ }
578
+
579
+ return send(404, errorBody(`Unknown route: ${method} ${path}`, 'not_found'));
580
+ }
581
+
582
+ /** `_session_response`'s client-safe projection (`api_server.py:4283-4302`). */
583
+ sessionResponse(id) {
584
+ const s = this.sessions.get(id);
585
+ return {
586
+ id: s.id, source: s.source, title: s.title, started_at: s.started_at,
587
+ ended_at: null, end_reason: null,
588
+ message_count: s.messages.length,
589
+ // EMPTY, all of them: the IR keeps no token or cost accounting (README)
590
+ user_id: null, model: null, tool_call_count: null, input_tokens: null, output_tokens: null,
591
+ parent_session_id: null,
592
+ has_system_prompt: Boolean(s.system_prompt),
593
+ has_model_config: false,
594
+ };
595
+ }
596
+
597
+ readJson(req) {
598
+ return new Promise((resolve) => {
599
+ const chunks = [];
600
+ let size = 0;
601
+ req.on('data', (c) => {
602
+ size += c.length;
603
+ if (size > MAX_BODY) { req.destroy(); resolve(null); return; }
604
+ chunks.push(c);
605
+ });
606
+ req.on('end', () => {
607
+ const body = Buffer.concat(chunks).toString('utf8');
608
+ if (!body.trim()) { resolve({}); return; }
609
+ try { const parsed = JSON.parse(body); resolve(parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null); }
610
+ catch { resolve(null); }
611
+ });
612
+ req.on('error', () => resolve(null));
613
+ });
614
+ }
615
+ }