dsh-hitl 0.1.0

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,17 @@
1
+ # The dsh-hitl bundle patch: one host row.
2
+ #
3
+ # The row's `config.protect` list mounts HITL on tools without writing code:
4
+ # each entry is { tool, ...hitlOptions } and mirrors ctx.hitl.protect(tool, options).
5
+ # Plugins mount through the `hitl` service instead:
6
+ #
7
+ # ctx.inject(['hitl'], (ctx) => {
8
+ # ctx.hitl.protect('bash', { countdown: { seconds: 30, action: 'reject' } })
9
+ # })
10
+ #
11
+ # A patch replaces the whole `config` of a row it overrides, so keep every key
12
+ # this row owns when you edit it from a later layer.
13
+ - insert:
14
+ - id: hitl
15
+ name: 'dsh-hitl'
16
+ config:
17
+ protect: []
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="HITL">
2
+ <rect x="4" y="12" width="56" height="40" rx="8" fill="none" stroke="#247bbf" stroke-width="3"/>
3
+ <circle cx="20" cy="28" r="4" fill="#247bbf"/>
4
+ <path d="M32 26h20M32 34h14" stroke="#247bbf" stroke-width="3" stroke-linecap="round"/>
5
+ <path d="M18 46h28" stroke="#247bbf" stroke-width="3" stroke-linecap="round" opacity="0.55"/>
6
+ </svg>
package/index.js ADDED
@@ -0,0 +1,629 @@
1
+ /**
2
+ * dsh-hitl host half: the human-in-the-loop gate for tool calls.
3
+ *
4
+ * Other tools and plugins mount HITL through the `hitl` service this plugin
5
+ * provides (`ctx.hitl.protect(...)`), or a user mounts them from this row's
6
+ * `config.protect` list. A mounted call is intercepted on `tools/pre-execute`,
7
+ * the proposal is broadcast to every connected browser, and the human's answer
8
+ * becomes the pre-execution decision: allow, deny with the user's own words, or
9
+ * deny with the revised proposal.
10
+ *
11
+ * The browser half is `client.js`; the two halves meet on this plugin's own
12
+ * same-origin HTTP route (a Server-Sent-Events downlink plus one JSON uplink),
13
+ * because a profile-installed bundle can neither add a generated Remote
14
+ * namespace nor extend the shipped forwarded-event allowlist. The route is
15
+ * closed by a per-process token injected into the served index.
16
+ */
17
+
18
+ import { randomUUID, timingSafeEqual } from 'node:crypto'
19
+
20
+ import {
21
+ DEFAULT_ENDPOINT, ERROR_CODES, GLOBAL_KEY, LIMITS, OUTCOMES, errorBody, frameHoldAck,
22
+ framePing, frameRequest, frameSettled, frameSnapshot, okBody, parseUplink, sseChunk,
23
+ } from './lib/protocol.js'
24
+ import { buildRequest, capReason, describeDecision, isRevision, revisionContext } from './lib/fields.js'
25
+ import { DEFAULT_HOLD_GRACE_MS, createPendingRegistry } from './lib/pending.js'
26
+ import { matchMount, mountApplies, normalizeMount, normalizeProtectList } from './lib/resolve.js'
27
+
28
+ /** Plugin name, as the row and diagnostics label it. */
29
+ export const name = 'hitl'
30
+
31
+ /** Error identities the model reads alongside a denied call. */
32
+ const FAILURES = {
33
+ rejected: { name: 'HitlRejected', code: 'HITL_REJECTED' },
34
+ revised: { name: 'HitlRevised', code: 'HITL_REVISED' },
35
+ timeout: { name: 'HitlTimeout', code: 'HITL_TIMEOUT' },
36
+ unavailable: { name: 'HitlUnavailable', code: 'HITL_UNAVAILABLE' },
37
+ }
38
+
39
+ /** Heartbeat period of one SSE downlink. */
40
+ const HEARTBEAT_MS = 15000
41
+
42
+ /** Loopback forms a decision request may arrive from. */
43
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
44
+
45
+ function normalizeEndpoint(value) {
46
+ if (typeof value !== 'string' || value === '') return DEFAULT_ENDPOINT
47
+ const trimmed = value.startsWith('/') ? value : `/${value}`
48
+ return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
49
+ }
50
+
51
+ function positiveInteger(value, fallback) {
52
+ return Number.isSafeInteger(value) && value > 0 ? value : fallback
53
+ }
54
+
55
+ /** Process-wide symbol holding the tokens this process has handed to pages. */
56
+ const TOKEN_REGISTRY = Symbol.for('dsh-hitl.decision-tokens')
57
+
58
+ /** How many previously issued tokens stay acceptable, oldest dropped first. */
59
+ const TOKEN_MEMORY = 8
60
+
61
+ /**
62
+ * The decision token for this process.
63
+ *
64
+ * The token is injected into every served page, so rotating it on a plugin
65
+ * reload would strand every open page until it is refreshed: each page keeps
66
+ * presenting the token it booted with, and its event stream would never open
67
+ * again. The first activation in a process therefore mints the token, every
68
+ * later activation reuses it, and every token this process issued stays
69
+ * acceptable until the registry's bound drops it.
70
+ * @returns the token to inject and accept.
71
+ */
72
+ function processTokens() {
73
+ const existing = globalThis[TOKEN_REGISTRY]
74
+ if (existing instanceof Set) return existing
75
+ const registry = new Set()
76
+ Object.defineProperty(globalThis, TOKEN_REGISTRY, { value: registry, enumerable: false })
77
+ return registry
78
+ }
79
+
80
+ /**
81
+ * Register the HITL host half.
82
+ * @param ctx - host Cordis context of this plugin row.
83
+ * @param config - `{ protect, endpoint, holdGraceMs }` from the row.
84
+ */
85
+ export function apply(ctx, config = {}) {
86
+ const options = config === null || typeof config !== 'object' ? {} : config
87
+ const warn = message => {
88
+ const line = `dsh-hitl: ${message}`
89
+ try {
90
+ if (ctx.logger !== undefined && typeof ctx.logger.warn === 'function') ctx.logger.warn(line)
91
+ else console.warn(line)
92
+ } catch {
93
+ console.warn(line)
94
+ }
95
+ }
96
+
97
+ const tokens = processTokens()
98
+ const token = tokens.size === 0 ? randomUUID() : [...tokens][0]
99
+ tokens.add(token)
100
+ while (tokens.size > TOKEN_MEMORY) {
101
+ const oldest = [...tokens][0]
102
+ if (oldest === token) break
103
+ tokens.delete(oldest)
104
+ }
105
+ const endpoint = normalizeEndpoint(options.endpoint)
106
+ const mounts = []
107
+ const clients = new Set()
108
+ const resolvers = new Map()
109
+ const cleanups = new Map()
110
+ const revisions = new Map()
111
+
112
+ const registry = createPendingRegistry({
113
+ holdGraceMs: positiveInteger(options.holdGraceMs, DEFAULT_HOLD_GRACE_MS),
114
+ onSettle: (entry, settlement) => {
115
+ const cleanup = cleanups.get(entry.id)
116
+ if (cleanup !== undefined) {
117
+ cleanups.delete(entry.id)
118
+ try {
119
+ cleanup()
120
+ } catch (error) {
121
+ warn(`abort listener cleanup failed: ${String(error)}`)
122
+ }
123
+ }
124
+ broadcast(frameSettled(entry.id, settlement.source))
125
+ const resolve = resolvers.get(entry.id)
126
+ resolvers.delete(entry.id)
127
+ if (resolve !== undefined) resolve(settlement)
128
+ },
129
+ })
130
+
131
+ /**
132
+ * Re-read one request's countdown as it stands right now.
133
+ *
134
+ * The request object is built once, when the call is gated, so its
135
+ * `remainingMs` is the countdown's full length. A browser that receives that
136
+ * object later — on a reconnect snapshot, or after a client-module reload —
137
+ * would restart the visible countdown from the full length while the host
138
+ * clock kept running, and the panel would then claim time the call no longer
139
+ * has. Every frame therefore carries the host's own remainder.
140
+ */
141
+ function liveRequest(request) {
142
+ if (request.countdown === null || request.countdown === undefined) return request
143
+ const remainingMs = registry.remaining(request.id)
144
+ if (remainingMs === null) return request
145
+ return { ...request, countdown: { ...request.countdown, remainingMs, held: registry.held(request.id) } }
146
+ }
147
+
148
+ /** Push one frame to every connected browser, dropping the ones that fail. */
149
+ function broadcast(frame) {
150
+ const payload = sseChunk(frame)
151
+ for (const client of clients) {
152
+ try {
153
+ client.res.write(payload)
154
+ } catch (error) {
155
+ warn(`dropping a decision client: ${String(error)}`)
156
+ dropClient(client)
157
+ }
158
+ }
159
+ }
160
+
161
+ function dropClient(client) {
162
+ if (!clients.delete(client)) return
163
+ try {
164
+ clearInterval(client.heartbeat)
165
+ } catch {
166
+ /* the heartbeat is best-effort cleanup */
167
+ }
168
+ }
169
+
170
+ // ── mounting ───────────────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * Mount HITL on one tool matcher.
174
+ *
175
+ * Pass the calling plugin's context as `owner` to tie the mount to that
176
+ * plugin's fiber: unloading the plugin then removes the mount even if the
177
+ * returned disposer is never called. Without an owner the mount lives until
178
+ * the disposer runs or this plugin unloads, so keep it in your own effect.
179
+ *
180
+ * @param matcher - tool name, `*` glob, RegExp, array of those, or a predicate over the call.
181
+ * @param mountOptions - title, fields, layout, labels, buttons, countdown, reject, modify, whenUnavailable.
182
+ * @param owner - the calling plugin's Cordis context, for lifetime binding.
183
+ * @returns a disposer removing this exact mount.
184
+ */
185
+ function protect(matcher, mountOptions = {}, owner) {
186
+ const normalized = normalizeMount({ matcher, ...mountOptions }, 'hitl.protect()')
187
+ for (const message of normalized.warnings) warn(message)
188
+ if (!normalized.ok) throw new TypeError(`dsh-hitl: unusable tool matcher ${String(matcher)}`)
189
+ mounts.push(normalized.mount)
190
+ const release = () => {
191
+ const index = mounts.indexOf(normalized.mount)
192
+ if (index !== -1) mounts.splice(index, 1)
193
+ }
194
+ // A mount must not outlive the plugin that asked for it, and returning a
195
+ // disposer from `apply` is not a lifetime: only an effect on the caller's
196
+ // own context is disposed when that plugin unloads.
197
+ if (owner !== undefined && owner !== null && typeof owner.effect === 'function') {
198
+ try {
199
+ owner.effect(() => release, `dsh-hitl: mount ${normalized.mount.describe}`)
200
+ } catch (error) {
201
+ warn(`the mount for ${normalized.mount.describe} could not be tied to its owner: ${String(error)}`)
202
+ }
203
+ }
204
+ return release
205
+ }
206
+
207
+ /** Remove every mount whose matcher reads like the given one; returns how many. */
208
+ function unprotect(matcher) {
209
+ const target = normalizeMount({ matcher }, 'hitl.unprotect()')
210
+ if (!target.ok) return 0
211
+ const before = mounts.length
212
+ for (let index = mounts.length - 1; index >= 0; index -= 1) {
213
+ if (mounts[index].describe === target.mount.describe) mounts.splice(index, 1)
214
+ }
215
+ return before - mounts.length
216
+ }
217
+
218
+ const service = {
219
+ protect,
220
+ unprotect,
221
+ /** Every mount currently registered, newest last. */
222
+ list: () => mounts.map(mount => ({
223
+ matcher: mount.describe,
224
+ layout: mount.options.layout,
225
+ title: mount.options.title,
226
+ countdown: mount.options.countdown,
227
+ rejectFeedback: mount.options.reject.feedback,
228
+ modify: mount.options.modify.mode,
229
+ whenUnavailable: mount.options.whenUnavailable,
230
+ })),
231
+ /** Every decision still waiting for a human, oldest first. */
232
+ pending: () => registry.list().map(request => ({
233
+ id: request.id,
234
+ sessionId: request.sessionId,
235
+ toolName: request.toolName,
236
+ createdAt: request.createdAt,
237
+ })),
238
+ }
239
+
240
+ const rowMounts = normalizeProtectList(options.protect, 'config.protect')
241
+ for (const message of rowMounts.warnings) warn(message)
242
+ mounts.push(...rowMounts.mounts)
243
+
244
+ // ── the gate ───────────────────────────────────────────────────────────────
245
+
246
+ function unavailable(exec, reason) {
247
+ return {
248
+ kind: 'deny',
249
+ reason: capReason(`HITL: ${reason}, so tool ${JSON.stringify(exec.name)} did not run (fail-closed).`),
250
+ info: FAILURES.unavailable,
251
+ }
252
+ }
253
+
254
+ /** Wait for one human decision, or for the host to withdraw the request. */
255
+ function waitForDecision(id, request, mount, exec) {
256
+ return new Promise(resolve => {
257
+ const countdown = mount.options.countdown === null ? null : {
258
+ remainingMs: mount.options.countdown.seconds * 1000,
259
+ action: mount.options.countdown.action,
260
+ freezeOnInteract: mount.options.countdown.freezeOnInteract,
261
+ }
262
+ resolvers.set(id, resolve)
263
+ const opened = registry.open({ id, request, countdown })
264
+ if (!opened.ok) {
265
+ resolvers.delete(id)
266
+ resolve({ decision: { kind: 'cancel' }, source: OUTCOMES.host })
267
+ return
268
+ }
269
+ const signal = exec.signal
270
+ if (signal !== undefined && typeof signal.addEventListener === 'function') {
271
+ if (signal.aborted) {
272
+ registry.abort(id)
273
+ } else {
274
+ const onAbort = () => { registry.abort(id) }
275
+ signal.addEventListener('abort', onAbort, { once: true })
276
+ cleanups.set(id, () => { signal.removeEventListener('abort', onAbort) })
277
+ }
278
+ }
279
+ broadcast(frameRequest(liveRequest(request)))
280
+ })
281
+ }
282
+
283
+ /**
284
+ * Map one settled decision onto the pre-execution decision vocabulary.
285
+ * @param exec - the pending call, for its name and call id.
286
+ * @param request - the wire request the human decided on.
287
+ * @param mount - the mount that gated the call.
288
+ * @param settlement - `{ decision, source }` from the registry.
289
+ * @returns the decision the tool registry applies.
290
+ */
291
+ function toPreToolDecision(exec, request, mount, settlement) {
292
+ if (settlement.decision.kind === 'approve') return { kind: 'allow' }
293
+ if (settlement.decision.kind === 'cancel') return { kind: 'cancel' }
294
+ const revising = isRevision(settlement.decision)
295
+ if (revising && mount.options.modify.mode === 'allow-and-inform' && settlement.source === OUTCOMES.user) {
296
+ if (exec.callId !== undefined) revisions.set(String(exec.callId), { request, decision: settlement.decision })
297
+ return { kind: 'allow' }
298
+ }
299
+ const info = settlement.source === OUTCOMES.timeout
300
+ ? FAILURES.timeout
301
+ : (revising ? FAILURES.revised : FAILURES.rejected)
302
+ return {
303
+ kind: 'deny',
304
+ reason: capReason(describeDecision(request, settlement.decision, settlement.source)),
305
+ info,
306
+ }
307
+ }
308
+
309
+ async function gate(exec, next) {
310
+ let mount
311
+ try {
312
+ mount = matchMount(mounts, exec)
313
+ } catch (error) {
314
+ warn(`a mount matcher failed: ${String(error)}`)
315
+ return next()
316
+ }
317
+ if (mount === undefined || !mountApplies(mount, exec)) return next()
318
+ const sessionId = exec.agent === undefined || exec.agent === null ? undefined : exec.agent.id
319
+ if (typeof sessionId !== 'string' || sessionId === '') {
320
+ warn(`tool ${JSON.stringify(exec.name)} is mounted on HITL but its call carries no agent to route a decision through`)
321
+ return unavailable(exec, 'the call has no agent to route a decision through')
322
+ }
323
+ if (clients.size === 0 && mount.options.whenUnavailable === 'reject') {
324
+ warn(`tool ${JSON.stringify(exec.name)} needs a human decision but no browser is connected`)
325
+ return unavailable(exec, 'no browser is connected to decide')
326
+ }
327
+ const id = `hitl:${String(exec.callId ?? randomUUID())}`
328
+ try {
329
+ const request = buildRequest({ execution: exec, mount, sessionId, id })
330
+ const settlement = await waitForDecision(id, request, mount, exec)
331
+ return toPreToolDecision(exec, request, mount, settlement)
332
+ } catch (error) {
333
+ warn(`tool ${JSON.stringify(exec.name)} could not be put to a human: ${String(error)}`)
334
+ registry.abort(id)
335
+ return unavailable(exec, 'the HITL panel failed to open')
336
+ }
337
+ }
338
+
339
+ ctx.on('tools/pre-execute', (exec, next) => gate(exec, next), { prepend: true })
340
+
341
+ // ── approved-with-edits context ────────────────────────────────────────────
342
+
343
+ /**
344
+ * Hand the user's revision to the model next to the result it approved, in
345
+ * the `allow-and-inform` modify mode. The message is built to the shape
346
+ * `createUserMessage` produces, because a plain bundle cannot import that
347
+ * factory; `source.kind` is the standard `user` one.
348
+ */
349
+ ctx.on('tools/post-execute', async (exec, result, next) => {
350
+ const decision = await next()
351
+ if (revisions.size === 0 || exec.callId === undefined) return decision
352
+ const key = String(exec.callId)
353
+ const revision = revisions.get(key)
354
+ if (revision === undefined) return decision
355
+ revisions.delete(key)
356
+ try {
357
+ const message = {
358
+ id: randomUUID(),
359
+ role: 'user',
360
+ source: { kind: 'user' },
361
+ content: [{ type: 'text', text: capReason(revisionContext(revision.request, revision.decision)) }],
362
+ }
363
+ return { ...decision, additionalContexts: [...(decision.additionalContexts ?? []), message] }
364
+ } catch (error) {
365
+ warn(`the revision context for ${key} was dropped: ${String(error)}`)
366
+ return decision
367
+ }
368
+ })
369
+
370
+ // ── transport ──────────────────────────────────────────────────────────────
371
+
372
+ function authorized(req, queryToken) {
373
+ const header = req.headers.authorization
374
+ const bearer = typeof header === 'string' && header.startsWith('Bearer ') ? header.slice(7) : undefined
375
+ const presented = typeof queryToken === 'string' && queryToken !== '' ? queryToken : bearer
376
+ if (typeof presented !== 'string' || presented === '') return false
377
+ const candidate = Buffer.from(presented)
378
+ let accepted = false
379
+ for (const known of tokens) {
380
+ const expected = Buffer.from(known)
381
+ // Every candidate is compared, so timing does not reveal which token matched.
382
+ if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) accepted = true
383
+ }
384
+ return accepted
385
+ }
386
+
387
+ function loopback(req) {
388
+ const address = req.socket?.remoteAddress
389
+ return typeof address === 'string' && LOOPBACK.has(address)
390
+ }
391
+
392
+ function sameOrigin(req) {
393
+ const origin = req.headers.origin
394
+ if (typeof origin !== 'string' || origin === '') return true
395
+ const host = req.headers.host
396
+ if (typeof host !== 'string' || host === '') return false
397
+ try {
398
+ return new URL(origin).host === host
399
+ } catch {
400
+ return false
401
+ }
402
+ }
403
+
404
+ function sendJson(res, status, body) {
405
+ const text = JSON.stringify(body)
406
+ res.writeHead(status, {
407
+ 'content-type': 'application/json; charset=utf-8',
408
+ 'content-length': Buffer.byteLength(text),
409
+ })
410
+ res.end(text)
411
+ }
412
+
413
+ function refuse(res, status, code, message) {
414
+ sendJson(res, status, errorBody(code, message))
415
+ }
416
+
417
+ function openEvents(req, res, url) {
418
+ if (!loopback(req) || !authorized(req, url.searchParams.get('token'))) {
419
+ refuse(res, 401, ERROR_CODES.unauthorized, 'a valid decision token is required')
420
+ return
421
+ }
422
+ res.writeHead(200, {
423
+ 'content-type': 'text/event-stream; charset=utf-8',
424
+ 'cache-control': 'no-cache, no-transform',
425
+ connection: 'keep-alive',
426
+ 'x-accel-buffering': 'no',
427
+ })
428
+ const client = { res, heartbeat: undefined }
429
+ // A real frame, not an SSE comment: only a frame proves to the browser half
430
+ // that this stream is still the live one, so its watchdog can tell a silent
431
+ // orphaned stream from a healthy one.
432
+ client.heartbeat = setInterval(() => {
433
+ try {
434
+ res.write(sseChunk(framePing()))
435
+ } catch {
436
+ dropClient(client)
437
+ }
438
+ }, HEARTBEAT_MS)
439
+ if (typeof client.heartbeat?.unref === 'function') client.heartbeat.unref()
440
+ clients.add(client)
441
+ try {
442
+ res.write(sseChunk(frameSnapshot(registry.list().map(liveRequest))))
443
+ } catch (error) {
444
+ warn(`the pending snapshot could not be written: ${String(error)}`)
445
+ dropClient(client)
446
+ return
447
+ }
448
+ req.on('close', () => { dropClient(client) })
449
+ req.on('error', () => { dropClient(client) })
450
+ }
451
+
452
+ function readBody(req) {
453
+ return new Promise((resolve, reject) => {
454
+ const chunks = []
455
+ let size = 0
456
+ req.on('data', chunk => {
457
+ size += chunk.length
458
+ if (size > LIMITS.maxBodyBytes) {
459
+ reject(new Error('payload too large'))
460
+ req.destroy()
461
+ return
462
+ }
463
+ chunks.push(chunk)
464
+ })
465
+ req.on('end', () => { resolve(Buffer.concat(chunks).toString('utf8')) })
466
+ req.on('error', reject)
467
+ })
468
+ }
469
+
470
+ async function decide(req, res, url) {
471
+ if (!loopback(req) || !authorized(req, url.searchParams.get('token')) || !sameOrigin(req)) {
472
+ refuse(res, 401, ERROR_CODES.unauthorized, 'a valid decision token is required')
473
+ return
474
+ }
475
+ const contentType = req.headers['content-type']
476
+ if (typeof contentType !== 'string' || !contentType.includes('application/json')) {
477
+ refuse(res, 400, ERROR_CODES.badPayload, 'content-type must be application/json')
478
+ return
479
+ }
480
+ let raw
481
+ try {
482
+ raw = await readBody(req)
483
+ } catch (error) {
484
+ refuse(res, 413, ERROR_CODES.badPayload, error instanceof Error ? error.message : String(error))
485
+ return
486
+ }
487
+ let payload
488
+ try {
489
+ payload = JSON.parse(raw === '' ? 'null' : raw)
490
+ } catch {
491
+ refuse(res, 400, ERROR_CODES.badPayload, 'the body must be JSON')
492
+ return
493
+ }
494
+ const parsed = parseUplink(payload)
495
+ if (!parsed.ok) {
496
+ refuse(res, 400, parsed.code, parsed.message)
497
+ return
498
+ }
499
+ const uplink = parsed.value
500
+ if (uplink.type === 'hold') {
501
+ const held = registry.hold(uplink.id, uplink.held)
502
+ if (!held.ok) {
503
+ refuse(res, held.code === ERROR_CODES.unknownRequest ? 404 : 409, held.code, 'that decision is no longer open')
504
+ return
505
+ }
506
+ broadcast(frameHoldAck(uplink.id, held.held, held.expiresAt, registry.remaining(uplink.id)))
507
+ sendJson(res, 200, {
508
+ ...okBody(true),
509
+ held: held.held,
510
+ ...(held.expiresAt === undefined ? {} : { expiresAt: held.expiresAt }),
511
+ })
512
+ return
513
+ }
514
+ const settled = registry.settle(uplink.id, uplink.decision)
515
+ if (!settled.accepted) {
516
+ refuse(res, settled.code === ERROR_CODES.unknownRequest ? 404 : 409, settled.code, 'that decision is no longer open')
517
+ return
518
+ }
519
+ sendJson(res, 200, okBody(true))
520
+ }
521
+
522
+ function handler(req, res) {
523
+ let url
524
+ try {
525
+ url = new URL(req.url ?? '/', 'http://localhost')
526
+ } catch {
527
+ refuse(res, 400, ERROR_CODES.badPayload, 'unreadable request target')
528
+ return
529
+ }
530
+ if (url.pathname === `${endpoint}/events`) {
531
+ if (req.method !== 'GET') {
532
+ refuse(res, 405, ERROR_CODES.badPayload, 'the event stream is a GET route')
533
+ return
534
+ }
535
+ openEvents(req, res, url)
536
+ return
537
+ }
538
+ if (url.pathname === `${endpoint}/status`) {
539
+ // Read-only troubleshooting surface: no token, no payload, loopback only.
540
+ // It exists so a developer can answer "is a browser attached, what is
541
+ // gated right now, and how many tokens did this process issue" without
542
+ // reading logs or guessing.
543
+ if (req.method !== 'GET' || !loopback(req)) {
544
+ refuse(res, 401, ERROR_CODES.unauthorized, 'the status route is a loopback GET')
545
+ return
546
+ }
547
+ sendJson(res, 200, {
548
+ ok: true,
549
+ clients: clients.size,
550
+ tokens: tokens.size,
551
+ protecting: service.list(),
552
+ pending: service.pending().map(request => ({
553
+ ...request,
554
+ remainingMs: registry.remaining(request.id),
555
+ held: registry.held(request.id),
556
+ })),
557
+ })
558
+ return
559
+ }
560
+ if (url.pathname === `${endpoint}/decide`) {
561
+ if (req.method !== 'POST') {
562
+ refuse(res, 405, ERROR_CODES.badPayload, 'decisions arrive on POST')
563
+ return
564
+ }
565
+ void decide(req, res, url).catch((error) => {
566
+ warn(`the decision route failed: ${String(error)}`)
567
+ try {
568
+ refuse(res, 500, ERROR_CODES.hostGone, 'the host could not accept that decision')
569
+ } catch {
570
+ /* the response is already gone */
571
+ }
572
+ })
573
+ return
574
+ }
575
+ refuse(res, 404, ERROR_CODES.badPayload, 'unknown HITL route')
576
+ }
577
+
578
+ ctx.inject(['webServer'], webCtx => {
579
+ webCtx.effect(
580
+ () => webCtx.webServer.register({ kind: 'prefix', path: endpoint, handler }),
581
+ 'dsh-hitl: decision transport',
582
+ )
583
+ })
584
+
585
+ ctx.on('webserver/index-inject', table => {
586
+ table.push({ kind: 'global', name: GLOBAL_KEY, value: { token, endpoint } })
587
+ })
588
+
589
+ // ── lifetime ───────────────────────────────────────────────────────────────
590
+
591
+ ctx.effect(() => {
592
+ const disposers = [ctx.provide('hitl', service)]
593
+ return () => {
594
+ for (const dispose of disposers.reverse()) {
595
+ try {
596
+ dispose()
597
+ } catch (error) {
598
+ warn(`service disposal failed: ${String(error)}`)
599
+ }
600
+ }
601
+ }
602
+ }, 'dsh-hitl: hitl service')
603
+
604
+ ctx.effect(() => () => {
605
+ registry.drain(OUTCOMES.host)
606
+ // End, do not merely forget: a reload leaves each open socket owned by a
607
+ // dead closure, and a browser cannot see that. Closing it makes the browser
608
+ // reconnect to the next instance instead of waiting on a silent stream.
609
+ for (const client of [...clients]) {
610
+ dropClient(client)
611
+ try {
612
+ client.res.end()
613
+ } catch {
614
+ /* the socket is already gone */
615
+ }
616
+ }
617
+ for (const cleanup of cleanups.values()) {
618
+ try {
619
+ cleanup()
620
+ } catch {
621
+ /* listeners are already being removed with the fiber */
622
+ }
623
+ }
624
+ mounts.length = 0
625
+ revisions.clear()
626
+ resolvers.clear()
627
+ cleanups.clear()
628
+ }, 'dsh-hitl: shutdown')
629
+ }