@robota-sdk/agent-transport-http 3.0.0-beta.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routes.ts ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * HTTP transport adapter — exposes the declared session capabilities over REST API.
3
+ *
4
+ * Built on Hono for Cloudflare Workers + Node.js + AWS Lambda compatibility.
5
+ * Exposes the core session methods (a subset; background-task, job-group, and
6
+ * execution-workspace methods are WS-only).
7
+ */
8
+
9
+ import {
10
+ bearerCredential,
11
+ credentialMatches,
12
+ resolveAdmission,
13
+ } from '@robota-sdk/agent-transport/node';
14
+ import { Hono } from 'hono';
15
+
16
+ import { createHttpOpenPrompts } from './open-prompts.js';
17
+ import { answerPromptHandler, listPromptsHandler } from './prompt-routes.js';
18
+ import { submitHandler } from './submit-route.js';
19
+ import { createTurnClaims } from './turn-claims.js';
20
+
21
+ import type { TSessionFactory } from './submit-route.js';
22
+ import type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';
23
+ import type {
24
+ ITransportAdmission,
25
+ ITransportAdmissionConfig,
26
+ } from '@robota-sdk/agent-interface-transport';
27
+
28
+ // Re-exported so existing importers keep their path; the definition and its rationale live with
29
+ // the code that enforces them, in `submit-route.ts`.
30
+ export type { TSessionFactory } from './submit-route.js';
31
+
32
+ export interface IAgentRoutesOptions {
33
+ /** Resolve an HTTP session port per request (e.g., by auth token, session ID). */
34
+ sessionFactory: TSessionFactory;
35
+ /**
36
+ * Where the DETAIL of a post-headers stream failure goes (the client gets only a generic line —
37
+ * see `submit-stream.ts`). Injected per the side-concern rule: the host decides the destination,
38
+ * and absent means the host chose to drop it.
39
+ */
40
+ onStreamFailure?: TStreamFailureListener;
41
+ /**
42
+ * SEC-008: what a peer must present to reach the session. REQUIRED — there is no shape of this
43
+ * option that means "I did not think about it", which is the state these routes shipped in.
44
+ *
45
+ * `{ open: true, openReason: '…' }` still runs with no credential, and that is a legitimate answer
46
+ * for a host that has its own boundary in front. It just has to be written down.
47
+ */
48
+ admission: ITransportAdmissionConfig | ITransportAdmission;
49
+ /**
50
+ * Who `/submit` turns and prompt answers are attributed to — the driver and product surface the
51
+ * HOST assigns, as the WebSocket transport's `driverId`/`surface` do. Never taken from the request
52
+ * body. Absent leaves the session's own default.
53
+ */
54
+ attribution?: TTurnAttribution;
55
+ }
56
+
57
+ /**
58
+ * Create a Hono router with all agent HTTP endpoints.
59
+ *
60
+ * Usage:
61
+ * ```typescript
62
+ * const routes = createAgentRoutes({ sessionFactory });
63
+ * app.route('/agent', routes); // mount on existing app
64
+ * export default routes; // or use standalone (CF Workers)
65
+ * ```
66
+ */
67
+ export function createAgentRoutes(options: IAgentRoutesOptions): Hono {
68
+ const { sessionFactory, onStreamFailure, attribution } = options;
69
+ const app = new Hono();
70
+
71
+ // RUNTIME-38: one turn at a time, per session — `turn-claims.ts` owns what that means and why it
72
+ // is keyed by the session's declared ID rather than by object identity.
73
+ const claims = createTurnClaims();
74
+ const openPrompts = createHttpOpenPrompts();
75
+
76
+ // SEC-008: resolved ONCE, at construction, so a transport that cannot mint a credential fails to
77
+ // build rather than serving without one. Resolving per request would also mint a new token per
78
+ // request, which no peer could ever present.
79
+ // Either shape goes in: `resolveAdmission` is idempotent, so an already-resolved admission comes
80
+ // back unchanged and a config is resolved. `http-transport.ts` therefore does not have to take
81
+ // its resolved admission apart and rebuild a config for this to resolve again — two resolutions
82
+ // of one decision, and a mint on the second if the first had opened without a reason (review).
83
+ //
84
+ // This used to pick between the two with `'token' in options.admission`, and review showed that
85
+ // cannot work: BOTH interfaces declare a `token`, so the shapes differ only by VALUE. A config of
86
+ // `{ token: '' }` — documented as "mint a fresh one" — was read as pre-resolved and installed the
87
+ // EMPTY STRING as the required credential, which a peer sending an empty bearer would match. The
88
+ // discriminator is gone rather than repaired; there is nothing here left to get wrong.
89
+ const admission = resolveAdmission(options.admission);
90
+
91
+ /**
92
+ * The trust boundary, installed BEFORE every route rather than checked inside each one.
93
+ *
94
+ * Before this, `POST /submit` reached `session.submit` and `POST /command` reached
95
+ * `session.executeCommand` with nothing in between — remote arbitrary execution with no gate, and
96
+ * an unauthenticated request looked exactly like an authorised one in both directions.
97
+ *
98
+ * A missing credential and a wrong one get the same answer, deliberately: telling them apart tells
99
+ * a caller which half they got right.
100
+ */
101
+ app.use('*', async (c, next) => {
102
+ if (admission.token === null) return next();
103
+ if (credentialMatches(admission.token, bearerCredential(c.req.header('authorization')))) {
104
+ return next();
105
+ }
106
+ return c.json({ error: 'unauthorized' }, 401);
107
+ });
108
+
109
+ // POST /submit — execute prompt, stream events via SSE
110
+ app.post(
111
+ '/submit',
112
+ submitHandler({ sessionFactory, claims, openPrompts, onStreamFailure, attribution }),
113
+ );
114
+
115
+ // GET /prompts — the open permission/ask prompts a prompt-receiving /submit stream forwarded
116
+ app.get('/prompts', listPromptsHandler({ sessionFactory, claims, openPrompts, attribution }));
117
+
118
+ // POST /prompts/:id — answer one of them
119
+ app.post(
120
+ '/prompts/:id',
121
+ answerPromptHandler({ sessionFactory, claims, openPrompts, attribution }),
122
+ );
123
+
124
+ // POST /command — execute system command
125
+ app.post('/command', async (c) => {
126
+ const session = await sessionFactory(c);
127
+ const body = await c.req.json<{ name: string; args?: string }>();
128
+
129
+ if (!body.name || typeof body.name !== 'string') {
130
+ return c.json({ error: 'name is required' }, 400);
131
+ }
132
+
133
+ // SEC-008: 'remote', not the default 'user'. A peer over HTTP is not the person at the keyboard,
134
+ // and defaulting to the local operator both mis-attributes the call and skips the 'remote' policy
135
+ // seam that exists to treat the two differently. Admission decided WHO may reach the session; it
136
+ // does not say who they are. (The MCP adapter had the same defect; this is its sibling.)
137
+ const result = await session.executeCommand(body.name, body.args ?? '', 'remote');
138
+ if (!result) {
139
+ return c.json({ error: `Unknown command: ${body.name}` }, 404);
140
+ }
141
+ return c.json(result);
142
+ });
143
+
144
+ // POST /abort — abort current execution
145
+ app.post('/abort', async (c) => {
146
+ const session = await sessionFactory(c);
147
+ session.abort();
148
+ return c.json({ ok: true });
149
+ });
150
+
151
+ // POST /cancel-queue — cancel queued prompt
152
+ app.post('/cancel-queue', async (c) => {
153
+ const session = await sessionFactory(c);
154
+ session.cancelQueue();
155
+ return c.json({ ok: true });
156
+ });
157
+
158
+ // GET /messages — get message history
159
+ app.get('/messages', async (c) => {
160
+ const session = await sessionFactory(c);
161
+ return c.json(session.getMessages());
162
+ });
163
+
164
+ // GET /context — get context window state
165
+ app.get('/context', async (c) => {
166
+ const session = await sessionFactory(c);
167
+ return c.json(session.getContextState());
168
+ });
169
+
170
+ // GET /executing — check if currently executing
171
+ //
172
+ // Reports the SAME "busy" that `/submit` refuses on: the route's own claim OR the session's
173
+ // `isExecuting()`. Asking only the session lets a client polling here see `executing: false` and
174
+ // still get a 409 from `/submit`, and two endpoints disagreeing about one word is a worse answer
175
+ // than either of them alone.
176
+ app.get('/executing', async (c) => {
177
+ const session = await sessionFactory(c);
178
+ const claim = claims.keyFor(session);
179
+ const claimed = claim !== undefined && claims.isHeld(claim);
180
+ return c.json({ executing: claimed || session.isExecuting() });
181
+ });
182
+
183
+ // GET /pending — get pending queued prompt
184
+ app.get('/pending', async (c) => {
185
+ const session = await sessionFactory(c);
186
+ return c.json({ pending: session.getPendingPrompt() });
187
+ });
188
+
189
+ return app;
190
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * `POST /submit` — the admission decision and the handler that hands an admitted turn to the relay.
3
+ *
4
+ * Split from `routes.ts` under the file-size ceiling, along the same seam the review rounds carved:
5
+ * the route factory is a LIST of routes, and this one carries the concurrency-admission decision
6
+ * plus the claim lifecycle, which is a different job from mounting endpoints.
7
+ */
8
+
9
+ import { streamSSE } from 'hono/streaming';
10
+
11
+ import { relayTurn } from './submit-stream.js';
12
+
13
+ import type { IHttpTransportSession } from './http-session.js';
14
+ import type { IHttpOpenPrompts } from './open-prompts.js';
15
+ import type { IRelayPrompts, TStreamFailureListener, TTurnAttribution } from './submit-stream.js';
16
+ import type { ITurnClaims } from './turn-claims.js';
17
+ import type { Context } from 'hono';
18
+
19
+ /**
20
+ * Callback that resolves an HTTP session port from the request context.
21
+ *
22
+ * It need NOT return the same object twice for the same logical session. It briefly did: `/submit`
23
+ * keyed its concurrent-turn claim on object identity, so a factory building a fresh wrapper per
24
+ * call — a proxy, an adapter, a `{...session}` copy — defeated the guard in silence. The first
25
+ * answer was to document that as a requirement callers had to keep, and a requirement neither the
26
+ * type system nor a test can check is not a contract, it is a hope.
27
+ *
28
+ * What the session already promises is enough. `getSession(): { getSessionId(): string }` names the
29
+ * session, the claim is keyed by that name, and the requirement is gone rather than written down.
30
+ *
31
+ * The session must therefore BE nameable. `/submit` refuses with 500 when `getSessionId()` returns
32
+ * nothing, because without an id the concurrent-turn guarantee does not exist and serving anyway
33
+ * would hand back the bug to whoever could least tell. The contract types that method as returning
34
+ * a `string`, so a conformant session never meets it.
35
+ *
36
+ * (Review moved this block here: it sat on the RE-EXPORT in `routes.ts` after the split, where the
37
+ * person editing the definition would never see it.)
38
+ */
39
+ export type TSessionFactory = (
40
+ c: Context,
41
+ ) => IHttpTransportSession | Promise<IHttpTransportSession>;
42
+
43
+ /**
44
+ * Admit this request to ONE turn on its session, or answer why not.
45
+ *
46
+ * Returns the claim key it HOLDS on success — the caller owes its release — or the refusal
47
+ * Response. A named unit, and review is why: the admission decision (nameable? busy? claim) is what
48
+ * this route decides, independently testable from the stream wiring it hands the turn to.
49
+ */
50
+ function admitTurn(
51
+ c: Context,
52
+ session: IHttpTransportSession,
53
+ claims: ITurnClaims,
54
+ ): Response | { claim: string } {
55
+ // RUNTIME-38: the session is single-threaded (one turn at a time) and shared across requests, so a
56
+ // concurrent /submit would cross-subscribe to the same emitter and interleave two clients' events.
57
+ //
58
+ // The route CLAIMS the turn rather than asking whether one is running, and that difference is the
59
+ // whole fix. `session.isExecuting()` is set inside `submit()` past an `await ensureInitialized()`
60
+ // — an unconditional suspension point — so two requests both read `false`, both call `submit`, and
61
+ // the loser coalesces into the pending queue while its HTTP response streams back the WINNER'S
62
+ // events. One turn delivered to two clients as if each had asked for it, which is worse than the
63
+ // race the earlier comment described and then wrongly declared absent.
64
+ //
65
+ // A synchronous flag on the route closes it because there is no suspension point between reading
66
+ // and setting it. It is released in the stream's `finally`, so a turn that throws does not leave
67
+ // the route wedged.
68
+ //
69
+ // BOTH are asked because neither subsumes the other. The claim is what this ROUTE knows;
70
+ // `isExecuting()` is what the SESSION knows, including a turn started by another surface — the
71
+ // TUI, a WS client, a previous process — which the claim cannot see.
72
+ // A session that cannot name itself is REFUSED, not served racily. The busy check is a guarantee
73
+ // only while the claim is keyed by a real id: with no id it falls back to `isExecuting()` alone,
74
+ // which the comment above documents as the racy read this route exists to stop relying on.
75
+ // Serving anyway would degrade the fix back into the bug for exactly the callers who could not
76
+ // be told they were affected.
77
+ //
78
+ // `getSession(): { getSessionId(): string }` is required by the contract, so this is unreachable
79
+ // for a conformant session. It is a 500 rather than a 503: the session is not temporarily
80
+ // unavailable, it does not meet the contract this route is built on, which is a defect on the
81
+ // server side of the boundary and not a condition that clears on retry.
82
+ const claim = claims.keyFor(session);
83
+ if (claim === undefined) {
84
+ return c.json(
85
+ {
86
+ // No internal method signature: this route can be mounted outside a trust boundary, and
87
+ // the caller cannot act on `getSession().getSessionId()` anyway — it names a contract
88
+ // they do not implement. The HOST needs the detail and gets it from the comment above
89
+ // this branch; the client needs to know its request was refused and why in its own terms.
90
+ error:
91
+ 'concurrent-turn tracking is unavailable for this session, so this request cannot be ' +
92
+ 'served safely. Refusing rather than starting a turn that cannot be guaranteed to be ' +
93
+ 'yours.',
94
+ },
95
+ 500,
96
+ );
97
+ }
98
+ if (claims.isHeld(claim) || session.isExecuting()) {
99
+ return c.json({ error: 'session busy — a turn is already in flight' }, 409);
100
+ }
101
+ claims.hold(claim);
102
+ return { claim };
103
+ }
104
+
105
+ export interface ISubmitRouteDeps {
106
+ readonly sessionFactory: TSessionFactory;
107
+ readonly claims: ITurnClaims;
108
+ readonly openPrompts: IHttpOpenPrompts;
109
+ readonly onStreamFailure?: TStreamFailureListener;
110
+ readonly attribution?: TTurnAttribution;
111
+ }
112
+
113
+ export function submitHandler(deps: ISubmitRouteDeps) {
114
+ const { sessionFactory, claims, openPrompts, onStreamFailure, attribution } = deps;
115
+ return async (c: Context) => {
116
+ const session = await sessionFactory(c);
117
+ const body = await c.req.json<{ prompt: string; receivePrompts?: unknown }>();
118
+
119
+ if (!body.prompt || typeof body.prompt !== 'string') {
120
+ return c.json({ error: 'prompt is required' }, 400);
121
+ }
122
+ if (body.receivePrompts !== undefined && typeof body.receivePrompts !== 'boolean') {
123
+ return c.json({ error: 'receivePrompts must be a boolean' }, 400);
124
+ }
125
+
126
+ // A session still building itself cannot name itself yet, and the claim needs its name. The
127
+ // claim itself is still taken synchronously after this, so two requests that both waited here
128
+ // start one turn between them.
129
+ if (session.whenInitialized !== undefined) {
130
+ try {
131
+ await session.whenInitialized();
132
+ } catch {
133
+ // The reason can name host paths or settings; the client only needs to know it failed.
134
+ return c.json({ error: 'the session could not be started' }, 500);
135
+ }
136
+ }
137
+
138
+ const admitted = admitTurn(c, session, claims);
139
+ if (admitted instanceof Response) {
140
+ return admitted;
141
+ }
142
+ const { claim } = admitted;
143
+ // SINGLE-SHOT, and review traced the double-release it prevents: the relay's teardown releases
144
+ // this claim on the normal path, and if a future Hono ever rejected the outer promise AFTER the
145
+ // callback completed, the catch below would release the SAME KEY a second time — by which point
146
+ // another request may hold it, and the second release would free that request's claim, which is
147
+ // the cross-talk this registry exists to prevent. A release that has happened is not a release
148
+ // that can happen again.
149
+ let released = false;
150
+ const releaseOnce = (): void => {
151
+ if (!released) {
152
+ released = true;
153
+ // The stream that held this session's prompts is gone; what is still open is the session's
154
+ // to settle, and no longer this route's to list or answer.
155
+ openPrompts.clear(claim);
156
+ claims.release(claim);
157
+ }
158
+ };
159
+ const prompts: IRelayPrompts | undefined =
160
+ body.receivePrompts === true
161
+ ? {
162
+ record: (frame) => openPrompts.record(claim, frame),
163
+ forget: (id) => openPrompts.forget(claim, id),
164
+ }
165
+ : undefined;
166
+
167
+ // The claim is taken OUTSIDE the callback, so its release cannot live only in the callback's
168
+ // `finally` — a claim whose release is not in the same protected region is a lock, not a claim,
169
+ // which this file states one level in. If `streamSSE` throws before it ever runs the callback,
170
+ // this releases and rethrows; the callback's own `finally` covers everything after it starts.
171
+ //
172
+ // REPRODUCED at the module boundary: `routes-streamsse-throw.test.ts` mocks `hono/streaming`
173
+ // to throw before the callback ever runs and asserts the second request is not 409 — a leaked
174
+ // claim is what this catch exists to prevent. Two rounds shipped it as honestly-labelled
175
+ // unverified code (a throw from `session.on` lands INSIDE the callback, where the inner
176
+ // `finally` releases — measured, a case built that way is green with this catch removed), and
177
+ // review supplied the third option: stub the boundary, not Hono's internals.
178
+ try {
179
+ // NO third argument, deliberately: Hono's runner follows any `onError` by writing the raw
180
+ // `e.message` to the stream, so the only leak-proof shape is a callback nothing escapes —
181
+ // `relayTurn` catches its own failures and reports them generically. An onError here would be
182
+ // dead code that turns into a leak the day it stops being dead.
183
+ // `await`, so the catch below covers a REJECTION as well as a synchronous throw. Today's
184
+ // Hono returns the Response synchronously and only the sync path is real — but review is
185
+ // right that a version returning a rejecting promise would sail past a bare `return`, and
186
+ // the cost of closing that class is one keyword.
187
+ return await streamSSE(
188
+ c,
189
+ relayTurn(session, body.prompt, releaseOnce, onStreamFailure, attribution, prompts),
190
+ );
191
+ } catch (error) {
192
+ releaseOnce();
193
+ throw error;
194
+ }
195
+ };
196
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * RUNTIME-14 / RUNTIME-38 — relaying ONE turn to an SSE client, and letting go of it afterwards.
3
+ *
4
+ * Split out of `routes.ts` under the file-size ceiling. The route's job is to decide whether this
5
+ * request may start a turn; relaying the turn's events, tearing the subscriptions down, and telling
6
+ * a client whose stream broke after the headers went out is a different job with its own failure
7
+ * modes, and it was four fifths of the handler.
8
+ */
9
+
10
+ import type { IHttpTransportSession } from './http-session.js';
11
+ import type { THttpPromptFrame } from './open-prompts.js';
12
+ import type { SSEStreamingApi } from 'hono/streaming';
13
+ import type {
14
+ IAskRequestEvent,
15
+ IPermissionRequestEvent,
16
+ IPromptResolvedEvent,
17
+ ISubmitOptions,
18
+ } from '@robota-sdk/agent-interface-session';
19
+
20
+ /**
21
+ * Where the DETAIL of a post-headers stream failure goes — injected, never imported.
22
+ *
23
+ * The first version imported `createLogger` from `agent-core`, and review held it against two
24
+ * authorities at once: `project-structure.md` declares this package contract-pure (transports
25
+ * depend on the contracts and the protocol, nothing else), and its side-concern rule says a logging
26
+ * destination "must be injected, not imported directly". The host that mounts this router decides
27
+ * where diagnostics go; a module-level singleton decided it for every host, with no override.
28
+ *
29
+ * Absent, the detail is dropped — by the HOST's explicit default, which is a different thing from
30
+ * this module deleting it: the client still learns the stream failed, and the host was offered the
31
+ * detail at the boundary where it mounts the route.
32
+ */
33
+ export type TStreamFailureListener = (error: Error) => void;
34
+
35
+ /**
36
+ * Who a submitted turn is attributed to, assigned by the HOST that composed the routes — never read
37
+ * from the request, because a client could otherwise claim to be the local operator or another
38
+ * surface in the usage record. Absent means the session's own default.
39
+ */
40
+ export type TTurnAttribution = Pick<ISubmitOptions, 'driverId' | 'surface'>;
41
+
42
+ /**
43
+ * Where a prompt-receiving stream reports the prompts it forwarded, so a later request can list or
44
+ * answer them. Present only when the client asked to receive prompts.
45
+ */
46
+ export interface IRelayPrompts {
47
+ record(frame: THttpPromptFrame): void;
48
+ forget(id: string): void;
49
+ }
50
+
51
+ /**
52
+ * Wire every relay subscription and the abort path; answer with the promise that settles when the
53
+ * turn is over (or refused one).
54
+ *
55
+ * The subscriptions are wired OUTSIDE the promise executor. Inside it, a throw from `session.on` —
56
+ * a bad handler, an EventEmitter listener cap — is caught by the Promise constructor and becomes an
57
+ * already-rejected `done` instead of propagating. Execution then reaches `await session.submit`, so
58
+ * a REAL TURN is consumed with only some of its listeners attached and nothing to relay it, and the
59
+ * rejection surfaces afterwards at `await done` — by which point the turn is gone.
60
+ */
61
+ function wireRelay(
62
+ session: IHttpTransportSession,
63
+ stream: SSEStreamingApi,
64
+ cleanup: Array<() => void>,
65
+ onFailure: TStreamFailureListener | undefined,
66
+ prompts: IRelayPrompts | undefined,
67
+ ): Promise<void> {
68
+ const subscribe = <T>(event: string, handler: (data: T) => void): void => {
69
+ session.on(event as 'text_delta', handler as () => void);
70
+ cleanup.push(() => session.off(event as 'text_delta', handler as () => void));
71
+ };
72
+
73
+ // RUNTIME-14: await + catch every SSE write so a write to a client-closed stream is a blessed
74
+ // no-op, not an unhandled rejection (post-headers errors bypass Hono's onError).
75
+ const write = (event: string, data: unknown): Promise<void> =>
76
+ stream.writeSSE({ event, data: JSON.stringify(data) }).catch(() => {
77
+ // allow-fallback: client closed the stream mid-write — nothing to deliver; the finally teardown
78
+ // (RUNTIME-14) removes the listeners, so this write has nothing left to do.
79
+ });
80
+
81
+ let settle!: () => void;
82
+ const done = new Promise<void>((resolve) => {
83
+ settle = resolve;
84
+ });
85
+
86
+ subscribe('text_delta', (delta: string) => void write('text_delta', { delta }));
87
+ subscribe('tool_start', (state) => void write('tool_start', state));
88
+ subscribe('tool_end', (state) => void write('tool_end', state));
89
+ subscribe('thinking', (isThinking: boolean) => void write('thinking', { isThinking }));
90
+ if (prompts !== undefined) subscribePrompts(subscribe, write, prompts);
91
+
92
+ // Flush the terminal event before resolving, so the resolve → cleanup → stream-close
93
+ // continuation cannot race ahead of the write.
94
+ subscribe('complete', async (result) => {
95
+ await write('complete', result);
96
+ settle();
97
+ });
98
+ subscribe('interrupted', async (result) => {
99
+ await write('interrupted', result);
100
+ settle();
101
+ });
102
+ subscribe('error', async (error: Error) => {
103
+ await write('error', { message: error.message });
104
+ settle();
105
+ });
106
+
107
+ // RUNTIME-14: on client disconnect, CANCEL the underlying run (not merely stop writing) and
108
+ // unblock `done` so the finally teardown runs — otherwise `done` would never resolve and the
109
+ // listeners leak.
110
+ stream.onAbort(() => onClientAbort(session, onFailure, settle));
111
+
112
+ return done;
113
+ }
114
+
115
+ /**
116
+ * Forward the turn's permission and ask prompts, and their settlement, to a client that said it can
117
+ * answer them.
118
+ *
119
+ * Only such a client subscribes: the session parks a prompt only while someone listens for it, so a
120
+ * client that cannot answer must not listen, or the turn would wait on a prompt nobody settles
121
+ * instead of failing it closed at once.
122
+ */
123
+ function subscribePrompts(
124
+ subscribe: <T>(event: string, handler: (data: T) => void) => void,
125
+ write: (event: string, data: unknown) => Promise<void>,
126
+ prompts: IRelayPrompts,
127
+ ): void {
128
+ subscribe('permission_request', (event: IPermissionRequestEvent) => {
129
+ prompts.record({ type: 'permission_request', event });
130
+ void write('permission_request', event);
131
+ });
132
+ subscribe('ask_request', (event: IAskRequestEvent) => {
133
+ prompts.record({ type: 'ask_request', event });
134
+ void write('ask_request', event);
135
+ });
136
+ subscribe('prompt_resolved', (event: IPromptResolvedEvent) => {
137
+ prompts.forget(event.id);
138
+ void write('prompt_resolved', event);
139
+ });
140
+ }
141
+
142
+ /**
143
+ * The abort path, alone, because every clause of it is load-bearing and review shaped each one.
144
+ *
145
+ * `settle` in a `finally`: an `abort()` that throws used to cost a listener leak, and with the
146
+ * claim registry it costs the SESSION — `done` never resolves, the teardown never runs, the claim
147
+ * is held forever, and every future /submit is 409.
148
+ *
149
+ * The throw is REPORTED, not rethrown: this handler runs inside Hono's abort dispatch, OUTSIDE the
150
+ * relay's try/catch, so a rethrow is an unhandled rejection rather than a reported failure. The
151
+ * detail goes where every other stream failure's detail goes.
152
+ */
153
+ function onClientAbort(
154
+ session: IHttpTransportSession,
155
+ onFailure: TStreamFailureListener | undefined,
156
+ settle: () => void,
157
+ ): void {
158
+ try {
159
+ session.abort();
160
+ } catch (error) {
161
+ // allow-fallback: an abort that throws is the HOST's news, not the runner's
162
+ try {
163
+ onFailure?.(error instanceof Error ? error : new Error(String(error)));
164
+ } catch {
165
+ // allow-fallback: a listener that throws must not replace the abort path's settle
166
+ // Same rule as reportStreamFailure: the host's listener failing is the host's to see.
167
+ }
168
+ } finally {
169
+ settle();
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Teardown that cannot take the claim with it.
175
+ *
176
+ * Each entry individually, and review is why: these are `session.off` calls, and one that throws
177
+ * inside a bare loop would end the caller's `finally` before `release()` ran — the same
178
+ * claim-held-forever the abort path closes, reintroduced two lines down. A failed unsubscribe
179
+ * forfeits one listener; taking the release with it forfeits the session.
180
+ */
181
+ function teardown(cleanup: Array<() => void>, release: () => void): void {
182
+ for (const fn of cleanup) {
183
+ try {
184
+ fn();
185
+ } catch {
186
+ // allow-fallback: a failed unsubscribe must not take the claim release with it
187
+ // The listener stays attached — the lesser loss, and the one the next turn can survive.
188
+ }
189
+ }
190
+ // RUNTIME-38: released here for the same reason — a turn that throws must not wedge the session
191
+ // it claimed.
192
+ release();
193
+ }
194
+
195
+ /**
196
+ * The `streamSSE` callback for one submitted prompt.
197
+ *
198
+ * `release` is called in the teardown, ALWAYS — on completion, on error, and on client disconnect.
199
+ * It is passed in rather than taken here because the claim is made before the stream opens: a claim
200
+ * whose release lives only inside the stream is a lock that outlives the request that took it.
201
+ * (This block sat on `wireRelay` for one round after the split stranded it — the stale-doc shape
202
+ * this file keeps writing about, caught doing it.)
203
+ */
204
+ export function relayTurn(
205
+ session: IHttpTransportSession,
206
+ prompt: string,
207
+ release: () => void,
208
+ onFailure?: TStreamFailureListener,
209
+ attribution?: TTurnAttribution,
210
+ prompts?: IRelayPrompts,
211
+ ): (stream: SSEStreamingApi) => Promise<void> {
212
+ return async (stream) => {
213
+ // NOTHING may escape this callback, and the reason is measured rather than stylistic. Hono's
214
+ // `streamSSE` runner calls its `onError` argument and then UNCONDITIONALLY writes
215
+ // `event: error, data: e.message` to the stream — so an escaped exception reaches the client
216
+ // verbatim no matter what the handler withheld. The probe that found it saw TWO error events:
217
+ // this module's generic line, then Hono's raw one. The only place the trust boundary can be
218
+ // held is here, before the throw reaches the runner at all.
219
+ try {
220
+ // The inner `try` opens BEFORE the wiring: a throw from `session.on` inside `wireRelay`
221
+ // leaves the session claimed, and the `finally` is what releases it — opened later, every
222
+ // later request gets 409 forever.
223
+ const cleanup: Array<() => void> = [];
224
+ try {
225
+ const done = wireRelay(session, stream, cleanup, onFailure, prompts);
226
+ await (attribution === undefined
227
+ ? session.submit(prompt)
228
+ : session.submit(prompt, undefined, undefined, { ...attribution }));
229
+ await done;
230
+ } finally {
231
+ // RUNTIME-14: teardown ALWAYS runs — on completion, error, OR client disconnect — so the
232
+ // session event listeners can never leak, and the claim always comes back.
233
+ teardown(cleanup, release);
234
+ }
235
+ } catch (error) {
236
+ await reportStreamFailure(
237
+ error instanceof Error ? error : new Error(String(error)),
238
+ stream,
239
+ onFailure,
240
+ );
241
+ }
242
+ };
243
+ }
244
+
245
+ /**
246
+ * What to tell a client whose stream failed AFTER the response headers were sent.
247
+ *
248
+ * A throw at that point cannot become an error status — the client already has a 200 and an open
249
+ * stream. Without this the throw is an UNHANDLED rejection: it left the process silently in a
250
+ * browser and turned the `quality` job red under vitest, which is how it was found.
251
+ *
252
+ * By the time this runs, `relayTurn`'s teardown has removed the listeners and released the claim.
253
+ * This handler owes only the telling.
254
+ *
255
+ * The CLIENT gets a generic line and the HOST gets the detail, and review moved this three times.
256
+ * The first version sent `error.message` verbatim; the second sent it to a package logger imported
257
+ * from `agent-core`, which the layering rule refuses (see `TStreamFailureListener`). What survives
258
+ * both rounds is the boundary itself: an exception escaping the stream callback is not a message
259
+ * anything composed for a client — it can carry provider internals, paths, or stack fragments,
260
+ * exactly what the 500 branch in `routes.ts` withholds at the same boundary.
261
+ *
262
+ * The session's own `error` EVENT is different and stays verbatim in `relayTurn` above: that is the
263
+ * session's client-facing error channel, worded by the session (`humanizeApiError`) and relayed
264
+ * identically by the WS transport. One is a message meant for the client; this one never was.
265
+ */
266
+ export async function reportStreamFailure(
267
+ error: Error,
268
+ stream: SSEStreamingApi,
269
+ onFailure?: TStreamFailureListener,
270
+ ): Promise<void> {
271
+ try {
272
+ onFailure?.(error);
273
+ } catch {
274
+ // allow-fallback: a listener that throws must not take the client's only notification with it
275
+ // The listener is the host's code; its failure is the host's to see, and the write below is the
276
+ // one thing this function owes the client either way.
277
+ }
278
+ await stream
279
+ .writeSSE({
280
+ event: 'error',
281
+ data: JSON.stringify({
282
+ message: 'the stream failed on the server after it opened — the turn may not have run',
283
+ }),
284
+ })
285
+ .catch(() => {
286
+ // allow-fallback: the stream is already gone, which is the one case where there is nobody
287
+ // left to tell. Rethrowing here would restore the unhandled rejection this exists to remove.
288
+ });
289
+ }