@robota-sdk/agent-transport-http 3.0.0-beta.86 → 3.0.0-beta.88

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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @robota-sdk/agent-transport-http
2
2
 
3
+ ## 3.0.0-beta.88
4
+
5
+ ### Patch Changes
6
+
7
+ - @robota-sdk/agent-interface-session@3.0.0-beta.88
8
+ - @robota-sdk/agent-interface-transport@3.0.0-beta.88
9
+ - @robota-sdk/agent-transport@3.0.0-beta.88
10
+
11
+ ## 3.0.0-beta.87
12
+
13
+ ### Patch Changes
14
+
15
+ - Release the migrated Robota product with its public SDK namespace, robota command, compatible SDK exports, internal product configuration, and verified CLI startup and delivery corrections.
16
+ - Updated dependencies
17
+ - Updated dependencies [7d9cc66]
18
+ - @robota-sdk/agent-interface-session@3.0.0-beta.87
19
+ - @robota-sdk/agent-interface-transport@3.0.0-beta.87
20
+ - @robota-sdk/agent-transport@3.0.0-beta.87
21
+
3
22
  ## 3.0.0-beta.86
4
23
 
5
24
  ### Patch Changes
package/README.md CHANGED
@@ -92,11 +92,12 @@ Paths are relative to where the routes are mounted (`basePath`, or the prefix yo
92
92
  | ------------------------ | --------- | ----------------------------------------------------------------------------------------------------- |
93
93
  | `createHttpTransport` | function | `(options?: IHttpTransportOptions) => IHttpTransport` |
94
94
  | `createAgentRoutes` | function | `(options: IAgentRoutesOptions) => Hono` |
95
- | `IHttpTransportOptions` | interface | `{ basePath?, admission?, onStreamFailure? }` |
95
+ | `IHttpTransportOptions` | interface | `{ basePath?, admission?, onStreamFailure?, attribution? }` |
96
96
  | `IHttpTransport` | interface | The transport; adds `getApp()` and `getAdmissionToken()` to the standard transport lifecycle |
97
- | `IAgentRoutesOptions` | interface | `{ sessionFactory, admission, onStreamFailure? }` |
97
+ | `IAgentRoutesOptions` | interface | `{ sessionFactory, admission, onStreamFailure?, attribution? }` |
98
98
  | `TSessionFactory` | type | `(c: Context) => IHttpTransportSession \| Promise<IHttpTransportSession>` |
99
99
  | `IHttpTransportSession` | interface | The session capabilities the routes use; a full interactive session satisfies it |
100
+ | `TTurnAttribution` | type | Host-assigned `driverId`/`surface` for `/submit` turns; never read from the request |
100
101
  | `TStreamFailureListener` | type | Receives the details of a stream that fails after headers were sent (the client sees a generic error) |
101
102
 
102
103
  ## Related
@@ -1 +1 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("@robota-sdk/agent-transport/node"),t=require("hono"),n=require("hono/streaming");function r(e,t,n,r){let a=(t,r)=>{e.on(t,r),n.push(()=>e.off(t,r))},o=(e,n)=>t.writeSSE({event:e,data:JSON.stringify(n)}).catch(()=>{}),s,c=new Promise(e=>{s=e});return a(`text_delta`,e=>void o(`text_delta`,{delta:e})),a(`tool_start`,e=>void o(`tool_start`,e)),a(`tool_end`,e=>void o(`tool_end`,e)),a(`thinking`,e=>void o(`thinking`,{isThinking:e})),a(`complete`,async e=>{await o(`complete`,e),s()}),a(`interrupted`,async e=>{await o(`interrupted`,e),s()}),a(`error`,async e=>{await o(`error`,{message:e.message}),s()}),t.onAbort(()=>i(e,r,s)),c}function i(e,t,n){try{e.abort()}catch(e){try{t?.(e instanceof Error?e:Error(String(e)))}catch{}}finally{n()}}function a(e,t){for(let t of e)try{t()}catch{}t()}function o(e,t,n,i){return async o=>{try{let s=[];try{let n=r(e,o,s,i);await e.submit(t),await n}finally{a(s,n)}}catch(e){await s(e instanceof Error?e:Error(String(e)),o,i)}}}async function s(e,t,n){try{n?.(e)}catch{}await t.writeSSE({event:`error`,data:JSON.stringify({message:`the stream failed on the server after it opened — the turn may not have run`})}).catch(()=>{})}function c(e,t,n){let r=n.keyFor(t);return r===void 0?e.json({error:`concurrent-turn tracking is unavailable for this session, so this request cannot be served safely. Refusing rather than starting a turn that cannot be guaranteed to be yours.`},500):n.isHeld(r)||t.isExecuting()?e.json({error:`session busy — a turn is already in flight`},409):(n.hold(r),{claim:r})}function l(e,t,r){return async i=>{let a=await e(i),s=await i.req.json();if(!s.prompt||typeof s.prompt!=`string`)return i.json({error:`prompt is required`},400);if(a.whenInitialized!==void 0)try{await a.whenInitialized()}catch{return i.json({error:`the session could not be started`},500)}let l=c(i,a,t);if(l instanceof Response)return l;let{claim:u}=l,d=!1,f=()=>{d||(d=!0,t.release(u))};try{return await(0,n.streamSSE)(i,o(a,s.prompt,f,r))}catch(e){throw f(),e}}}function u(){let e=new Set;return{keyFor(e){try{let t=e.getSession().getSessionId();return typeof t==`string`&&t!==``?t:void 0}catch{return}},isHeld:t=>e.has(t),hold:t=>void e.add(t),release:t=>void e.delete(t)}}function d(n){let{sessionFactory:r,onStreamFailure:i}=n,a=new t.Hono,o=u(),s=(0,e.resolveAdmission)(n.admission);return a.use(`*`,async(t,n)=>s.token===null||(0,e.credentialMatches)(s.token,(0,e.bearerCredential)(t.req.header(`authorization`)))?n():t.json({error:`unauthorized`},401)),a.post(`/submit`,l(r,o,i)),a.post(`/command`,async e=>{let t=await r(e),n=await e.req.json();if(!n.name||typeof n.name!=`string`)return e.json({error:`name is required`},400);let i=await t.executeCommand(n.name,n.args??``,`remote`);return i?e.json(i):e.json({error:`Unknown command: ${n.name}`},404)}),a.post(`/abort`,async e=>((await r(e)).abort(),e.json({ok:!0}))),a.post(`/cancel-queue`,async e=>((await r(e)).cancelQueue(),e.json({ok:!0}))),a.get(`/messages`,async e=>{let t=await r(e);return e.json(t.getMessages())}),a.get(`/context`,async e=>{let t=await r(e);return e.json(t.getContextState())}),a.get(`/executing`,async e=>{let t=await r(e),n=o.keyFor(t),i=n!==void 0&&o.isHeld(n);return e.json({executing:i||t.isExecuting()})}),a.get(`/pending`,async e=>{let t=await r(e);return e.json({pending:t.getPendingPrompt()})}),a}function f(n){let r=null,i=null,a=(0,e.resolveAdmission)(n?.admission),o=e=>Object.assign(Error(`HTTP transport ${e}.`),{name:`TransportLifecycleError`,code:e,transportName:`http`});return{name:`http`,lifecycle:Object.freeze({kind:`service`}),attach(e){r=e},async start(){if(!r)throw o(`not-attached`);if(i)throw o(`already-started`);let e=d({sessionFactory:()=>r,admission:a,onStreamFailure:n?.onStreamFailure});i=n?.basePath?new t.Hono().route(n.basePath,e):e},async stop(){i=null,r=null},getApp(){if(!i)throw Error(`Transport not started. Call start() first.`);return i},getAdmissionToken(){return a.token}}}exports.createAgentRoutes=d,exports.createHttpTransport=f;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("@robota-sdk/agent-transport/node"),t=require("hono"),n=require("hono/streaming");function r(e,t,n,r){let a=(t,r)=>{e.on(t,r),n.push(()=>e.off(t,r))},o=(e,n)=>t.writeSSE({event:e,data:JSON.stringify(n)}).catch(()=>{}),s,c=new Promise(e=>{s=e});return a(`text_delta`,e=>void o(`text_delta`,{delta:e})),a(`tool_start`,e=>void o(`tool_start`,e)),a(`tool_end`,e=>void o(`tool_end`,e)),a(`thinking`,e=>void o(`thinking`,{isThinking:e})),a(`complete`,async e=>{await o(`complete`,e),s()}),a(`interrupted`,async e=>{await o(`interrupted`,e),s()}),a(`error`,async e=>{await o(`error`,{message:e.message}),s()}),t.onAbort(()=>i(e,r,s)),c}function i(e,t,n){try{e.abort()}catch(e){try{t?.(e instanceof Error?e:Error(String(e)))}catch{}}finally{n()}}function a(e,t){for(let t of e)try{t()}catch{}t()}function o(e,t,n,i,o){return async c=>{try{let s=[];try{let n=r(e,c,s,i);await(o===void 0?e.submit(t):e.submit(t,void 0,void 0,{...o})),await n}finally{a(s,n)}}catch(e){await s(e instanceof Error?e:Error(String(e)),c,i)}}}async function s(e,t,n){try{n?.(e)}catch{}await t.writeSSE({event:`error`,data:JSON.stringify({message:`the stream failed on the server after it opened — the turn may not have run`})}).catch(()=>{})}function c(e,t,n){let r=n.keyFor(t);return r===void 0?e.json({error:`concurrent-turn tracking is unavailable for this session, so this request cannot be served safely. Refusing rather than starting a turn that cannot be guaranteed to be yours.`},500):n.isHeld(r)||t.isExecuting()?e.json({error:`session busy — a turn is already in flight`},409):(n.hold(r),{claim:r})}function l(e,t,r,i){return async a=>{let s=await e(a),l=await a.req.json();if(!l.prompt||typeof l.prompt!=`string`)return a.json({error:`prompt is required`},400);if(s.whenInitialized!==void 0)try{await s.whenInitialized()}catch{return a.json({error:`the session could not be started`},500)}let u=c(a,s,t);if(u instanceof Response)return u;let{claim:d}=u,f=!1,p=()=>{f||(f=!0,t.release(d))};try{return await(0,n.streamSSE)(a,o(s,l.prompt,p,r,i))}catch(e){throw p(),e}}}function u(){let e=new Set;return{keyFor(e){try{let t=e.getSession().getSessionId();return typeof t==`string`&&t!==``?t:void 0}catch{return}},isHeld:t=>e.has(t),hold:t=>void e.add(t),release:t=>void e.delete(t)}}function d(n){let{sessionFactory:r,onStreamFailure:i,attribution:a}=n,o=new t.Hono,s=u(),c=(0,e.resolveAdmission)(n.admission);return o.use(`*`,async(t,n)=>c.token===null||(0,e.credentialMatches)(c.token,(0,e.bearerCredential)(t.req.header(`authorization`)))?n():t.json({error:`unauthorized`},401)),o.post(`/submit`,l(r,s,i,a)),o.post(`/command`,async e=>{let t=await r(e),n=await e.req.json();if(!n.name||typeof n.name!=`string`)return e.json({error:`name is required`},400);let i=await t.executeCommand(n.name,n.args??``,`remote`);return i?e.json(i):e.json({error:`Unknown command: ${n.name}`},404)}),o.post(`/abort`,async e=>((await r(e)).abort(),e.json({ok:!0}))),o.post(`/cancel-queue`,async e=>((await r(e)).cancelQueue(),e.json({ok:!0}))),o.get(`/messages`,async e=>{let t=await r(e);return e.json(t.getMessages())}),o.get(`/context`,async e=>{let t=await r(e);return e.json(t.getContextState())}),o.get(`/executing`,async e=>{let t=await r(e),n=s.keyFor(t),i=n!==void 0&&s.isHeld(n);return e.json({executing:i||t.isExecuting()})}),o.get(`/pending`,async e=>{let t=await r(e);return e.json({pending:t.getPendingPrompt()})}),o}function f(n){let r=null,i=null,a=(0,e.resolveAdmission)(n?.admission),o=e=>Object.assign(Error(`HTTP transport ${e}.`),{name:`TransportLifecycleError`,code:e,transportName:`http`});return{name:`http`,lifecycle:Object.freeze({kind:`service`}),attach(e){r=e},async start(){if(!r)throw o(`not-attached`);if(i)throw o(`already-started`);let e=d({sessionFactory:()=>r,admission:a,onStreamFailure:n?.onStreamFailure,attribution:n?.attribution});i=n?.basePath?new t.Hono().route(n.basePath,e):e},async stop(){i=null,r=null},getApp(){if(!i)throw Error(`Transport not started. Call start() first.`);return i},getAdmissionToken(){return a.token}}}exports.createAgentRoutes=d,exports.createHttpTransport=f;
@@ -1,5 +1,5 @@
1
1
  import { Context, Hono } from "hono";
2
- import { ISessionCommands, ISessionConversationRead, ISessionEvents, ISessionExecutionState, ISessionIdentity, ISessionTurnControl, ISessionTurnSubmission } from "@robota-sdk/agent-interface-session";
2
+ import { ISessionCommands, ISessionConversationRead, ISessionEvents, ISessionExecutionState, ISessionIdentity, ISessionTurnControl, ISessionTurnSubmission, ISubmitOptions } from "@robota-sdk/agent-interface-session";
3
3
  import "hono/streaming";
4
4
  import { ITransportAdapter, ITransportAdmission, ITransportAdmissionConfig } from "@robota-sdk/agent-interface-transport";
5
5
  //#region src/http-session.d.ts
@@ -28,6 +28,12 @@ interface IHttpTransportSession extends ISessionTurnSubmission, ISessionEvents,
28
28
  * detail at the boundary where it mounts the route.
29
29
  */
30
30
  type TStreamFailureListener = (error: Error) => void;
31
+ /**
32
+ * Who a submitted turn is attributed to, assigned by the HOST that composed the routes — never read
33
+ * from the request, because a client could otherwise claim to be the local operator or another
34
+ * surface in the usage record. Absent means the session's own default.
35
+ */
36
+ type TTurnAttribution = Pick<ISubmitOptions, 'driverId' | 'surface'>;
31
37
  //#endregion
32
38
  //#region src/submit-route.d.ts
33
39
  /**
@@ -70,6 +76,12 @@ interface IAgentRoutesOptions {
70
76
  * for a host that has its own boundary in front. It just has to be written down.
71
77
  */
72
78
  admission: ITransportAdmissionConfig | ITransportAdmission;
79
+ /**
80
+ * Who `/submit` turns are attributed to — the driver and product surface the HOST assigns, as the
81
+ * WebSocket transport's `driverId`/`surface` do. Never taken from the request body. Absent leaves
82
+ * the session's own default.
83
+ */
84
+ attribution?: TTurnAttribution;
73
85
  }
74
86
  /**
75
87
  * Create a Hono router with all agent HTTP endpoints.
@@ -102,6 +114,8 @@ interface IHttpTransportOptions {
102
114
  * host on this path could make. Same forwarding rule as `admission`, which sat beside it.
103
115
  */
104
116
  onStreamFailure?: TStreamFailureListener;
117
+ /** Who `/submit` turns are attributed to — forwarded to `createAgentRoutes`, like `admission`. */
118
+ attribution?: TTurnAttribution;
105
119
  }
106
120
  interface IHttpTransport extends ITransportAdapter<IHttpTransportSession> {
107
121
  getApp(): Hono;
@@ -109,5 +123,5 @@ interface IHttpTransport extends ITransportAdapter<IHttpTransportSession> {
109
123
  }
110
124
  declare function createHttpTransport(options?: IHttpTransportOptions): IHttpTransport;
111
125
  //#endregion
112
- export { type IAgentRoutesOptions, type IHttpTransport, type IHttpTransportOptions, type IHttpTransportSession, type TSessionFactory, type TStreamFailureListener, createAgentRoutes, createHttpTransport };
126
+ export { type IAgentRoutesOptions, type IHttpTransport, type IHttpTransportOptions, type IHttpTransportSession, type TSessionFactory, type TStreamFailureListener, type TTurnAttribution, createAgentRoutes, createHttpTransport };
113
127
  //# sourceMappingURL=index.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/http-session.ts","../../src/submit-stream.ts","../../src/submit-route.ts","../../src/routes.ts","../../src/http-transport.ts"],"mappings":";;;;;;UAWiB,8BAEb,wBACA,gBACA,qBACA,kBACA,kBACA,0BACA;;;;;;EAMF,oBAAoB;;;;;;;;;;;;;;;;;KCAV,0BAA0B,OAAO;;;;;;;;;;;;;;;;;;;;;;;KCYjC,mBACV,GAAG,YACA,wBAAwB,QAAQ;;;UCVpB;;EAEf,gBAAgB;;;;;;EAMhB,kBAAkB;;;;;;;;EAQlB,WAAW,4BAA4B;;;;;;;;;;;;iBAazB,kBAAkB,SAAS,sBAAsB;;;UCtChD;;EAEf;;;;;;;;EAQA,YAAY;;;;;;;EAOZ,kBAAkB;;UAGH,uBAAuB,kBAAkB;EACxD,UAAU;EACV;;iBAGc,oBAAoB,UAAU,wBAAwB"}
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/http-session.ts","../../src/submit-stream.ts","../../src/submit-route.ts","../../src/routes.ts","../../src/http-transport.ts"],"mappings":";;;;;;UAWiB,8BAEb,wBACA,gBACA,qBACA,kBACA,kBACA,0BACA;;;;;;EAMF,oBAAoB;;;;;;;;;;;;;;;;;KCCV,0BAA0B,OAAO;;;;;;KAOjC,mBAAmB,KAAK;;;;;;;;;;;;;;;;;;;;;;;KCIxB,mBACV,GAAG,YACA,wBAAwB,QAAQ;;;UCVpB;;EAEf,gBAAgB;;;;;;EAMhB,kBAAkB;;;;;;;;EAQlB,WAAW,4BAA4B;;;;;;EAMvC,cAAc;;;;;;;;;;;;iBAaA,kBAAkB,SAAS,sBAAsB;;;UC5ChD;;EAEf;;;;;;;;EAQA,YAAY;;;;;;;EAOZ,kBAAkB;;EAElB,cAAc;;UAGC,uBAAuB,kBAAkB;EACxD,UAAU;EACV;;iBAGc,oBAAoB,UAAU,wBAAwB"}
@@ -1,6 +1,6 @@
1
1
  import { Context, Hono } from "hono";
2
2
  import "hono/streaming";
3
- import { ISessionCommands, ISessionConversationRead, ISessionEvents, ISessionExecutionState, ISessionIdentity, ISessionTurnControl, ISessionTurnSubmission } from "@robota-sdk/agent-interface-session";
3
+ import { ISessionCommands, ISessionConversationRead, ISessionEvents, ISessionExecutionState, ISessionIdentity, ISessionTurnControl, ISessionTurnSubmission, ISubmitOptions } from "@robota-sdk/agent-interface-session";
4
4
  import { ITransportAdapter, ITransportAdmission, ITransportAdmissionConfig } from "@robota-sdk/agent-interface-transport";
5
5
  //#region src/http-session.d.ts
6
6
  /** The exact session capabilities consumed by the public HTTP transport. */
@@ -28,6 +28,12 @@ interface IHttpTransportSession extends ISessionTurnSubmission, ISessionEvents,
28
28
  * detail at the boundary where it mounts the route.
29
29
  */
30
30
  type TStreamFailureListener = (error: Error) => void;
31
+ /**
32
+ * Who a submitted turn is attributed to, assigned by the HOST that composed the routes — never read
33
+ * from the request, because a client could otherwise claim to be the local operator or another
34
+ * surface in the usage record. Absent means the session's own default.
35
+ */
36
+ type TTurnAttribution = Pick<ISubmitOptions, 'driverId' | 'surface'>;
31
37
  //#endregion
32
38
  //#region src/submit-route.d.ts
33
39
  /**
@@ -70,6 +76,12 @@ interface IAgentRoutesOptions {
70
76
  * for a host that has its own boundary in front. It just has to be written down.
71
77
  */
72
78
  admission: ITransportAdmissionConfig | ITransportAdmission;
79
+ /**
80
+ * Who `/submit` turns are attributed to — the driver and product surface the HOST assigns, as the
81
+ * WebSocket transport's `driverId`/`surface` do. Never taken from the request body. Absent leaves
82
+ * the session's own default.
83
+ */
84
+ attribution?: TTurnAttribution;
73
85
  }
74
86
  /**
75
87
  * Create a Hono router with all agent HTTP endpoints.
@@ -102,6 +114,8 @@ interface IHttpTransportOptions {
102
114
  * host on this path could make. Same forwarding rule as `admission`, which sat beside it.
103
115
  */
104
116
  onStreamFailure?: TStreamFailureListener;
117
+ /** Who `/submit` turns are attributed to — forwarded to `createAgentRoutes`, like `admission`. */
118
+ attribution?: TTurnAttribution;
105
119
  }
106
120
  interface IHttpTransport extends ITransportAdapter<IHttpTransportSession> {
107
121
  getApp(): Hono;
@@ -109,5 +123,5 @@ interface IHttpTransport extends ITransportAdapter<IHttpTransportSession> {
109
123
  }
110
124
  declare function createHttpTransport(options?: IHttpTransportOptions): IHttpTransport;
111
125
  //#endregion
112
- export { type IAgentRoutesOptions, type IHttpTransport, type IHttpTransportOptions, type IHttpTransportSession, type TSessionFactory, type TStreamFailureListener, createAgentRoutes, createHttpTransport };
126
+ export { type IAgentRoutesOptions, type IHttpTransport, type IHttpTransportOptions, type IHttpTransportSession, type TSessionFactory, type TStreamFailureListener, type TTurnAttribution, createAgentRoutes, createHttpTransport };
113
127
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/http-session.ts","../../src/submit-stream.ts","../../src/submit-route.ts","../../src/routes.ts","../../src/http-transport.ts"],"mappings":";;;;;;UAWiB,8BAEb,wBACA,gBACA,qBACA,kBACA,kBACA,0BACA;;;;;;EAMF,oBAAoB;;;;;;;;;;;;;;;;;KCAV,0BAA0B,OAAO;;;;;;;;;;;;;;;;;;;;;;;KCYjC,mBACV,GAAG,YACA,wBAAwB,QAAQ;;;UCVpB;;EAEf,gBAAgB;;;;;;EAMhB,kBAAkB;;;;;;;;EAQlB,WAAW,4BAA4B;;;;;;;;;;;;iBAazB,kBAAkB,SAAS,sBAAsB;;;UCtChD;;EAEf;;;;;;;;EAQA,YAAY;;;;;;;EAOZ,kBAAkB;;UAGH,uBAAuB,kBAAkB;EACxD,UAAU;EACV;;iBAGc,oBAAoB,UAAU,wBAAwB"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/http-session.ts","../../src/submit-stream.ts","../../src/submit-route.ts","../../src/routes.ts","../../src/http-transport.ts"],"mappings":";;;;;;UAWiB,8BAEb,wBACA,gBACA,qBACA,kBACA,kBACA,0BACA;;;;;;EAMF,oBAAoB;;;;;;;;;;;;;;;;;KCCV,0BAA0B,OAAO;;;;;;KAOjC,mBAAmB,KAAK;;;;;;;;;;;;;;;;;;;;;;;KCIxB,mBACV,GAAG,YACA,wBAAwB,QAAQ;;;UCVpB;;EAEf,gBAAgB;;;;;;EAMhB,kBAAkB;;;;;;;;EAQlB,WAAW,4BAA4B;;;;;;EAMvC,cAAc;;;;;;;;;;;;iBAaA,kBAAkB,SAAS,sBAAsB;;;UC5ChD;;EAEf;;;;;;;;EAQA,YAAY;;;;;;;EAOZ,kBAAkB;;EAElB,cAAc;;UAGC,uBAAuB,kBAAkB;EACxD,UAAU;EACV;;iBAGc,oBAAoB,UAAU,wBAAwB"}
@@ -1,2 +1,2 @@
1
- import{bearerCredential as e,credentialMatches as t,resolveAdmission as n}from"@robota-sdk/agent-transport/node";import{Hono as r}from"hono";import{streamSSE as i}from"hono/streaming";function a(e,t,n,r){let i=(t,r)=>{e.on(t,r),n.push(()=>e.off(t,r))},a=(e,n)=>t.writeSSE({event:e,data:JSON.stringify(n)}).catch(()=>{}),s,c=new Promise(e=>{s=e});return i(`text_delta`,e=>void a(`text_delta`,{delta:e})),i(`tool_start`,e=>void a(`tool_start`,e)),i(`tool_end`,e=>void a(`tool_end`,e)),i(`thinking`,e=>void a(`thinking`,{isThinking:e})),i(`complete`,async e=>{await a(`complete`,e),s()}),i(`interrupted`,async e=>{await a(`interrupted`,e),s()}),i(`error`,async e=>{await a(`error`,{message:e.message}),s()}),t.onAbort(()=>o(e,r,s)),c}function o(e,t,n){try{e.abort()}catch(e){try{t?.(e instanceof Error?e:Error(String(e)))}catch{}}finally{n()}}function s(e,t){for(let t of e)try{t()}catch{}t()}function c(e,t,n,r){return async i=>{try{let o=[];try{let n=a(e,i,o,r);await e.submit(t),await n}finally{s(o,n)}}catch(e){await l(e instanceof Error?e:Error(String(e)),i,r)}}}async function l(e,t,n){try{n?.(e)}catch{}await t.writeSSE({event:`error`,data:JSON.stringify({message:`the stream failed on the server after it opened — the turn may not have run`})}).catch(()=>{})}function u(e,t,n){let r=n.keyFor(t);return r===void 0?e.json({error:`concurrent-turn tracking is unavailable for this session, so this request cannot be served safely. Refusing rather than starting a turn that cannot be guaranteed to be yours.`},500):n.isHeld(r)||t.isExecuting()?e.json({error:`session busy — a turn is already in flight`},409):(n.hold(r),{claim:r})}function d(e,t,n){return async r=>{let a=await e(r),o=await r.req.json();if(!o.prompt||typeof o.prompt!=`string`)return r.json({error:`prompt is required`},400);if(a.whenInitialized!==void 0)try{await a.whenInitialized()}catch{return r.json({error:`the session could not be started`},500)}let s=u(r,a,t);if(s instanceof Response)return s;let{claim:l}=s,d=!1,f=()=>{d||(d=!0,t.release(l))};try{return await i(r,c(a,o.prompt,f,n))}catch(e){throw f(),e}}}function f(){let e=new Set;return{keyFor(e){try{let t=e.getSession().getSessionId();return typeof t==`string`&&t!==``?t:void 0}catch{return}},isHeld:t=>e.has(t),hold:t=>void e.add(t),release:t=>void e.delete(t)}}function p(i){let{sessionFactory:a,onStreamFailure:o}=i,s=new r,c=f(),l=n(i.admission);return s.use(`*`,async(n,r)=>l.token===null||t(l.token,e(n.req.header(`authorization`)))?r():n.json({error:`unauthorized`},401)),s.post(`/submit`,d(a,c,o)),s.post(`/command`,async e=>{let t=await a(e),n=await e.req.json();if(!n.name||typeof n.name!=`string`)return e.json({error:`name is required`},400);let r=await t.executeCommand(n.name,n.args??``,`remote`);return r?e.json(r):e.json({error:`Unknown command: ${n.name}`},404)}),s.post(`/abort`,async e=>((await a(e)).abort(),e.json({ok:!0}))),s.post(`/cancel-queue`,async e=>((await a(e)).cancelQueue(),e.json({ok:!0}))),s.get(`/messages`,async e=>{let t=await a(e);return e.json(t.getMessages())}),s.get(`/context`,async e=>{let t=await a(e);return e.json(t.getContextState())}),s.get(`/executing`,async e=>{let t=await a(e),n=c.keyFor(t),r=n!==void 0&&c.isHeld(n);return e.json({executing:r||t.isExecuting()})}),s.get(`/pending`,async e=>{let t=await a(e);return e.json({pending:t.getPendingPrompt()})}),s}function m(e){let t=null,i=null,a=n(e?.admission),o=e=>Object.assign(Error(`HTTP transport ${e}.`),{name:`TransportLifecycleError`,code:e,transportName:`http`});return{name:`http`,lifecycle:Object.freeze({kind:`service`}),attach(e){t=e},async start(){if(!t)throw o(`not-attached`);if(i)throw o(`already-started`);let n=p({sessionFactory:()=>t,admission:a,onStreamFailure:e?.onStreamFailure});i=e?.basePath?new r().route(e.basePath,n):n},async stop(){i=null,t=null},getApp(){if(!i)throw Error(`Transport not started. Call start() first.`);return i},getAdmissionToken(){return a.token}}}export{p as createAgentRoutes,m as createHttpTransport};
1
+ import{bearerCredential as e,credentialMatches as t,resolveAdmission as n}from"@robota-sdk/agent-transport/node";import{Hono as r}from"hono";import{streamSSE as i}from"hono/streaming";function a(e,t,n,r){let i=(t,r)=>{e.on(t,r),n.push(()=>e.off(t,r))},a=(e,n)=>t.writeSSE({event:e,data:JSON.stringify(n)}).catch(()=>{}),s,c=new Promise(e=>{s=e});return i(`text_delta`,e=>void a(`text_delta`,{delta:e})),i(`tool_start`,e=>void a(`tool_start`,e)),i(`tool_end`,e=>void a(`tool_end`,e)),i(`thinking`,e=>void a(`thinking`,{isThinking:e})),i(`complete`,async e=>{await a(`complete`,e),s()}),i(`interrupted`,async e=>{await a(`interrupted`,e),s()}),i(`error`,async e=>{await a(`error`,{message:e.message}),s()}),t.onAbort(()=>o(e,r,s)),c}function o(e,t,n){try{e.abort()}catch(e){try{t?.(e instanceof Error?e:Error(String(e)))}catch{}}finally{n()}}function s(e,t){for(let t of e)try{t()}catch{}t()}function c(e,t,n,r,i){return async o=>{try{let c=[];try{let n=a(e,o,c,r);await(i===void 0?e.submit(t):e.submit(t,void 0,void 0,{...i})),await n}finally{s(c,n)}}catch(e){await l(e instanceof Error?e:Error(String(e)),o,r)}}}async function l(e,t,n){try{n?.(e)}catch{}await t.writeSSE({event:`error`,data:JSON.stringify({message:`the stream failed on the server after it opened — the turn may not have run`})}).catch(()=>{})}function u(e,t,n){let r=n.keyFor(t);return r===void 0?e.json({error:`concurrent-turn tracking is unavailable for this session, so this request cannot be served safely. Refusing rather than starting a turn that cannot be guaranteed to be yours.`},500):n.isHeld(r)||t.isExecuting()?e.json({error:`session busy — a turn is already in flight`},409):(n.hold(r),{claim:r})}function d(e,t,n,r){return async a=>{let o=await e(a),s=await a.req.json();if(!s.prompt||typeof s.prompt!=`string`)return a.json({error:`prompt is required`},400);if(o.whenInitialized!==void 0)try{await o.whenInitialized()}catch{return a.json({error:`the session could not be started`},500)}let l=u(a,o,t);if(l instanceof Response)return l;let{claim:d}=l,f=!1,p=()=>{f||(f=!0,t.release(d))};try{return await i(a,c(o,s.prompt,p,n,r))}catch(e){throw p(),e}}}function f(){let e=new Set;return{keyFor(e){try{let t=e.getSession().getSessionId();return typeof t==`string`&&t!==``?t:void 0}catch{return}},isHeld:t=>e.has(t),hold:t=>void e.add(t),release:t=>void e.delete(t)}}function p(i){let{sessionFactory:a,onStreamFailure:o,attribution:s}=i,c=new r,l=f(),u=n(i.admission);return c.use(`*`,async(n,r)=>u.token===null||t(u.token,e(n.req.header(`authorization`)))?r():n.json({error:`unauthorized`},401)),c.post(`/submit`,d(a,l,o,s)),c.post(`/command`,async e=>{let t=await a(e),n=await e.req.json();if(!n.name||typeof n.name!=`string`)return e.json({error:`name is required`},400);let r=await t.executeCommand(n.name,n.args??``,`remote`);return r?e.json(r):e.json({error:`Unknown command: ${n.name}`},404)}),c.post(`/abort`,async e=>((await a(e)).abort(),e.json({ok:!0}))),c.post(`/cancel-queue`,async e=>((await a(e)).cancelQueue(),e.json({ok:!0}))),c.get(`/messages`,async e=>{let t=await a(e);return e.json(t.getMessages())}),c.get(`/context`,async e=>{let t=await a(e);return e.json(t.getContextState())}),c.get(`/executing`,async e=>{let t=await a(e),n=l.keyFor(t),r=n!==void 0&&l.isHeld(n);return e.json({executing:r||t.isExecuting()})}),c.get(`/pending`,async e=>{let t=await a(e);return e.json({pending:t.getPendingPrompt()})}),c}function m(e){let t=null,i=null,a=n(e?.admission),o=e=>Object.assign(Error(`HTTP transport ${e}.`),{name:`TransportLifecycleError`,code:e,transportName:`http`});return{name:`http`,lifecycle:Object.freeze({kind:`service`}),attach(e){t=e},async start(){if(!t)throw o(`not-attached`);if(i)throw o(`already-started`);let n=p({sessionFactory:()=>t,admission:a,onStreamFailure:e?.onStreamFailure,attribution:e?.attribution});i=e?.basePath?new r().route(e.basePath,n):n},async stop(){i=null,t=null},getApp(){if(!i)throw Error(`Transport not started. Call start() first.`);return i},getAdmissionToken(){return a.token}}}export{p as createAgentRoutes,m as createHttpTransport};
2
2
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/submit-stream.ts","../../src/submit-route.ts","../../src/turn-claims.ts","../../src/routes.ts","../../src/http-transport.ts"],"sourcesContent":["/**\n * RUNTIME-14 / RUNTIME-38 — relaying ONE turn to an SSE client, and letting go of it afterwards.\n *\n * Split out of `routes.ts` under the file-size ceiling. The route's job is to decide whether this\n * request may start a turn; relaying the turn's events, tearing the subscriptions down, and telling\n * a client whose stream broke after the headers went out is a different job with its own failure\n * modes, and it was four fifths of the handler.\n */\n\nimport type { IHttpTransportSession } from './http-session.js';\nimport type { SSEStreamingApi } from 'hono/streaming';\n\n/**\n * Where the DETAIL of a post-headers stream failure goes — injected, never imported.\n *\n * The first version imported `createLogger` from `agent-core`, and review held it against two\n * authorities at once: `project-structure.md` declares this package contract-pure (transports\n * depend on the contracts and the protocol, nothing else), and its side-concern rule says a logging\n * destination \"must be injected, not imported directly\". The host that mounts this router decides\n * where diagnostics go; a module-level singleton decided it for every host, with no override.\n *\n * Absent, the detail is dropped — by the HOST's explicit default, which is a different thing from\n * this module deleting it: the client still learns the stream failed, and the host was offered the\n * detail at the boundary where it mounts the route.\n */\nexport type TStreamFailureListener = (error: Error) => void;\n\n/**\n * Wire every relay subscription and the abort path; answer with the promise that settles when the\n * turn is over (or refused one).\n *\n * The subscriptions are wired OUTSIDE the promise executor. Inside it, a throw from `session.on` —\n * a bad handler, an EventEmitter listener cap — is caught by the Promise constructor and becomes an\n * already-rejected `done` instead of propagating. Execution then reaches `await session.submit`, so\n * a REAL TURN is consumed with only some of its listeners attached and nothing to relay it, and the\n * rejection surfaces afterwards at `await done` — by which point the turn is gone.\n */\nfunction wireRelay(\n session: IHttpTransportSession,\n stream: SSEStreamingApi,\n cleanup: Array<() => void>,\n onFailure: TStreamFailureListener | undefined,\n): Promise<void> {\n const subscribe = <T>(event: string, handler: (data: T) => void): void => {\n session.on(event as 'text_delta', handler as () => void);\n cleanup.push(() => session.off(event as 'text_delta', handler as () => void));\n };\n\n // RUNTIME-14: await + catch every SSE write so a write to a client-closed stream is a blessed\n // no-op, not an unhandled rejection (post-headers errors bypass Hono's onError).\n const write = (event: string, data: unknown): Promise<void> =>\n stream.writeSSE({ event, data: JSON.stringify(data) }).catch(() => {\n // allow-fallback: client closed the stream mid-write — nothing to deliver; the finally teardown\n // (RUNTIME-14) removes the listeners, so this write has nothing left to do.\n });\n\n let settle!: () => void;\n const done = new Promise<void>((resolve) => {\n settle = resolve;\n });\n\n subscribe('text_delta', (delta: string) => void write('text_delta', { delta }));\n subscribe('tool_start', (state) => void write('tool_start', state));\n subscribe('tool_end', (state) => void write('tool_end', state));\n subscribe('thinking', (isThinking: boolean) => void write('thinking', { isThinking }));\n\n // Flush the terminal event before resolving, so the resolve → cleanup → stream-close\n // continuation cannot race ahead of the write.\n subscribe('complete', async (result) => {\n await write('complete', result);\n settle();\n });\n subscribe('interrupted', async (result) => {\n await write('interrupted', result);\n settle();\n });\n subscribe('error', async (error: Error) => {\n await write('error', { message: error.message });\n settle();\n });\n\n // RUNTIME-14: on client disconnect, CANCEL the underlying run (not merely stop writing) and\n // unblock `done` so the finally teardown runs — otherwise `done` would never resolve and the\n // listeners leak.\n stream.onAbort(() => onClientAbort(session, onFailure, settle));\n\n return done;\n}\n\n/**\n * The abort path, alone, because every clause of it is load-bearing and review shaped each one.\n *\n * `settle` in a `finally`: an `abort()` that throws used to cost a listener leak, and with the\n * claim registry it costs the SESSION — `done` never resolves, the teardown never runs, the claim\n * is held forever, and every future /submit is 409.\n *\n * The throw is REPORTED, not rethrown: this handler runs inside Hono's abort dispatch, OUTSIDE the\n * relay's try/catch, so a rethrow is an unhandled rejection rather than a reported failure. The\n * detail goes where every other stream failure's detail goes.\n */\nfunction onClientAbort(\n session: IHttpTransportSession,\n onFailure: TStreamFailureListener | undefined,\n settle: () => void,\n): void {\n try {\n session.abort();\n } catch (error) {\n // allow-fallback: an abort that throws is the HOST's news, not the runner's\n try {\n onFailure?.(error instanceof Error ? error : new Error(String(error)));\n } catch {\n // allow-fallback: a listener that throws must not replace the abort path's settle\n // Same rule as reportStreamFailure: the host's listener failing is the host's to see.\n }\n } finally {\n settle();\n }\n}\n\n/**\n * Teardown that cannot take the claim with it.\n *\n * Each entry individually, and review is why: these are `session.off` calls, and one that throws\n * inside a bare loop would end the caller's `finally` before `release()` ran — the same\n * claim-held-forever the abort path closes, reintroduced two lines down. A failed unsubscribe\n * forfeits one listener; taking the release with it forfeits the session.\n */\nfunction teardown(cleanup: Array<() => void>, release: () => void): void {\n for (const fn of cleanup) {\n try {\n fn();\n } catch {\n // allow-fallback: a failed unsubscribe must not take the claim release with it\n // The listener stays attached — the lesser loss, and the one the next turn can survive.\n }\n }\n // RUNTIME-38: released here for the same reason — a turn that throws must not wedge the session\n // it claimed.\n release();\n}\n\n/**\n * The `streamSSE` callback for one submitted prompt.\n *\n * `release` is called in the teardown, ALWAYS — on completion, on error, and on client disconnect.\n * It is passed in rather than taken here because the claim is made before the stream opens: a claim\n * whose release lives only inside the stream is a lock that outlives the request that took it.\n * (This block sat on `wireRelay` for one round after the split stranded it — the stale-doc shape\n * this file keeps writing about, caught doing it.)\n */\nexport function relayTurn(\n session: IHttpTransportSession,\n prompt: string,\n release: () => void,\n onFailure?: TStreamFailureListener,\n): (stream: SSEStreamingApi) => Promise<void> {\n return async (stream) => {\n // NOTHING may escape this callback, and the reason is measured rather than stylistic. Hono's\n // `streamSSE` runner calls its `onError` argument and then UNCONDITIONALLY writes\n // `event: error, data: e.message` to the stream — so an escaped exception reaches the client\n // verbatim no matter what the handler withheld. The probe that found it saw TWO error events:\n // this module's generic line, then Hono's raw one. The only place the trust boundary can be\n // held is here, before the throw reaches the runner at all.\n try {\n // The inner `try` opens BEFORE the wiring: a throw from `session.on` inside `wireRelay`\n // leaves the session claimed, and the `finally` is what releases it — opened later, every\n // later request gets 409 forever.\n const cleanup: Array<() => void> = [];\n try {\n const done = wireRelay(session, stream, cleanup, onFailure);\n await session.submit(prompt);\n await done;\n } finally {\n // RUNTIME-14: teardown ALWAYS runs — on completion, error, OR client disconnect — so the\n // session event listeners can never leak, and the claim always comes back.\n teardown(cleanup, release);\n }\n } catch (error) {\n await reportStreamFailure(\n error instanceof Error ? error : new Error(String(error)),\n stream,\n onFailure,\n );\n }\n };\n}\n\n/**\n * What to tell a client whose stream failed AFTER the response headers were sent.\n *\n * A throw at that point cannot become an error status — the client already has a 200 and an open\n * stream. Without this the throw is an UNHANDLED rejection: it left the process silently in a\n * browser and turned the `quality` job red under vitest, which is how it was found.\n *\n * By the time this runs, `relayTurn`'s teardown has removed the listeners and released the claim.\n * This handler owes only the telling.\n *\n * The CLIENT gets a generic line and the HOST gets the detail, and review moved this three times.\n * The first version sent `error.message` verbatim; the second sent it to a package logger imported\n * from `agent-core`, which the layering rule refuses (see `TStreamFailureListener`). What survives\n * both rounds is the boundary itself: an exception escaping the stream callback is not a message\n * anything composed for a client — it can carry provider internals, paths, or stack fragments,\n * exactly what the 500 branch in `routes.ts` withholds at the same boundary.\n *\n * The session's own `error` EVENT is different and stays verbatim in `relayTurn` above: that is the\n * session's client-facing error channel, worded by the session (`humanizeApiError`) and relayed\n * identically by the WS transport. One is a message meant for the client; this one never was.\n */\nexport async function reportStreamFailure(\n error: Error,\n stream: SSEStreamingApi,\n onFailure?: TStreamFailureListener,\n): Promise<void> {\n try {\n onFailure?.(error);\n } catch {\n // allow-fallback: a listener that throws must not take the client's only notification with it\n // The listener is the host's code; its failure is the host's to see, and the write below is the\n // one thing this function owes the client either way.\n }\n await stream\n .writeSSE({\n event: 'error',\n data: JSON.stringify({\n message: 'the stream failed on the server after it opened — the turn may not have run',\n }),\n })\n .catch(() => {\n // allow-fallback: the stream is already gone, which is the one case where there is nobody\n // left to tell. Rethrowing here would restore the unhandled rejection this exists to remove.\n });\n}\n","/**\n * `POST /submit` — the admission decision and the handler that hands an admitted turn to the relay.\n *\n * Split from `routes.ts` under the file-size ceiling, along the same seam the review rounds carved:\n * the route factory is a LIST of routes, and this one carries the concurrency-admission decision\n * plus the claim lifecycle, which is a different job from mounting endpoints.\n */\n\nimport { streamSSE } from 'hono/streaming';\n\nimport { relayTurn } from './submit-stream.js';\n\nimport type { IHttpTransportSession } from './http-session.js';\nimport type { TStreamFailureListener } from './submit-stream.js';\nimport type { ITurnClaims } from './turn-claims.js';\nimport type { Context } from 'hono';\n\n/**\n * Callback that resolves an HTTP session port from the request context.\n *\n * It need NOT return the same object twice for the same logical session. It briefly did: `/submit`\n * keyed its concurrent-turn claim on object identity, so a factory building a fresh wrapper per\n * call — a proxy, an adapter, a `{...session}` copy — defeated the guard in silence. The first\n * answer was to document that as a requirement callers had to keep, and a requirement neither the\n * type system nor a test can check is not a contract, it is a hope.\n *\n * What the session already promises is enough. `getSession(): { getSessionId(): string }` names the\n * session, the claim is keyed by that name, and the requirement is gone rather than written down.\n *\n * The session must therefore BE nameable. `/submit` refuses with 500 when `getSessionId()` returns\n * nothing, because without an id the concurrent-turn guarantee does not exist and serving anyway\n * would hand back the bug to whoever could least tell. The contract types that method as returning\n * a `string`, so a conformant session never meets it.\n *\n * (Review moved this block here: it sat on the RE-EXPORT in `routes.ts` after the split, where the\n * person editing the definition would never see it.)\n */\nexport type TSessionFactory = (\n c: Context,\n) => IHttpTransportSession | Promise<IHttpTransportSession>;\n\n/**\n * Admit this request to ONE turn on its session, or answer why not.\n *\n * Returns the claim key it HOLDS on success — the caller owes its release — or the refusal\n * Response. A named unit, and review is why: the admission decision (nameable? busy? claim) is what\n * this route decides, independently testable from the stream wiring it hands the turn to.\n */\nfunction admitTurn(\n c: Context,\n session: IHttpTransportSession,\n claims: ITurnClaims,\n): Response | { claim: string } {\n // RUNTIME-38: the session is single-threaded (one turn at a time) and shared across requests, so a\n // concurrent /submit would cross-subscribe to the same emitter and interleave two clients' events.\n //\n // The route CLAIMS the turn rather than asking whether one is running, and that difference is the\n // whole fix. `session.isExecuting()` is set inside `submit()` past an `await ensureInitialized()`\n // — an unconditional suspension point — so two requests both read `false`, both call `submit`, and\n // the loser coalesces into the pending queue while its HTTP response streams back the WINNER'S\n // events. One turn delivered to two clients as if each had asked for it, which is worse than the\n // race the earlier comment described and then wrongly declared absent.\n //\n // A synchronous flag on the route closes it because there is no suspension point between reading\n // and setting it. It is released in the stream's `finally`, so a turn that throws does not leave\n // the route wedged.\n //\n // BOTH are asked because neither subsumes the other. The claim is what this ROUTE knows;\n // `isExecuting()` is what the SESSION knows, including a turn started by another surface — the\n // TUI, a WS client, a previous process — which the claim cannot see.\n // A session that cannot name itself is REFUSED, not served racily. The busy check is a guarantee\n // only while the claim is keyed by a real id: with no id it falls back to `isExecuting()` alone,\n // which the comment above documents as the racy read this route exists to stop relying on.\n // Serving anyway would degrade the fix back into the bug for exactly the callers who could not\n // be told they were affected.\n //\n // `getSession(): { getSessionId(): string }` is required by the contract, so this is unreachable\n // for a conformant session. It is a 500 rather than a 503: the session is not temporarily\n // unavailable, it does not meet the contract this route is built on, which is a defect on the\n // server side of the boundary and not a condition that clears on retry.\n const claim = claims.keyFor(session);\n if (claim === undefined) {\n return c.json(\n {\n // No internal method signature: this route can be mounted outside a trust boundary, and\n // the caller cannot act on `getSession().getSessionId()` anyway — it names a contract\n // they do not implement. The HOST needs the detail and gets it from the comment above\n // this branch; the client needs to know its request was refused and why in its own terms.\n error:\n 'concurrent-turn tracking is unavailable for this session, so this request cannot be ' +\n 'served safely. Refusing rather than starting a turn that cannot be guaranteed to be ' +\n 'yours.',\n },\n 500,\n );\n }\n if (claims.isHeld(claim) || session.isExecuting()) {\n return c.json({ error: 'session busy — a turn is already in flight' }, 409);\n }\n claims.hold(claim);\n return { claim };\n}\n\nexport function submitHandler(\n sessionFactory: TSessionFactory,\n claims: ITurnClaims,\n onStreamFailure?: TStreamFailureListener,\n) {\n return async (c: Context) => {\n const session = await sessionFactory(c);\n const body = await c.req.json<{ prompt: string }>();\n\n if (!body.prompt || typeof body.prompt !== 'string') {\n return c.json({ error: 'prompt is required' }, 400);\n }\n\n // A session still building itself cannot name itself yet, and the claim needs its name. The\n // claim itself is still taken synchronously after this, so two requests that both waited here\n // start one turn between them.\n if (session.whenInitialized !== undefined) {\n try {\n await session.whenInitialized();\n } catch {\n // The reason can name host paths or settings; the client only needs to know it failed.\n return c.json({ error: 'the session could not be started' }, 500);\n }\n }\n\n const admitted = admitTurn(c, session, claims);\n if (admitted instanceof Response) {\n return admitted;\n }\n const { claim } = admitted;\n // SINGLE-SHOT, and review traced the double-release it prevents: the relay's teardown releases\n // this claim on the normal path, and if a future Hono ever rejected the outer promise AFTER the\n // callback completed, the catch below would release the SAME KEY a second time — by which point\n // another request may hold it, and the second release would free that request's claim, which is\n // the cross-talk this registry exists to prevent. A release that has happened is not a release\n // that can happen again.\n let released = false;\n const releaseOnce = (): void => {\n if (!released) {\n released = true;\n claims.release(claim);\n }\n };\n\n // The claim is taken OUTSIDE the callback, so its release cannot live only in the callback's\n // `finally` — a claim whose release is not in the same protected region is a lock, not a claim,\n // which this file states one level in. If `streamSSE` throws before it ever runs the callback,\n // this releases and rethrows; the callback's own `finally` covers everything after it starts.\n //\n // REPRODUCED at the module boundary: `routes-streamsse-throw.test.ts` mocks `hono/streaming`\n // to throw before the callback ever runs and asserts the second request is not 409 — a leaked\n // claim is what this catch exists to prevent. Two rounds shipped it as honestly-labelled\n // unverified code (a throw from `session.on` lands INSIDE the callback, where the inner\n // `finally` releases — measured, a case built that way is green with this catch removed), and\n // review supplied the third option: stub the boundary, not Hono's internals.\n try {\n // NO third argument, deliberately: Hono's runner follows any `onError` by writing the raw\n // `e.message` to the stream, so the only leak-proof shape is a callback nothing escapes —\n // `relayTurn` catches its own failures and reports them generically. An onError here would be\n // dead code that turns into a leak the day it stops being dead.\n // `await`, so the catch below covers a REJECTION as well as a synchronous throw. Today's\n // Hono returns the Response synchronously and only the sync path is real — but review is\n // right that a version returning a rejecting promise would sail past a bare `return`, and\n // the cost of closing that class is one keyword.\n return await streamSSE(c, relayTurn(session, body.prompt, releaseOnce, onStreamFailure));\n } catch (error) {\n releaseOnce();\n throw error;\n }\n };\n}\n","/**\n * RUNTIME-38 — who currently holds the one turn a session can run.\n *\n * Split out of `routes.ts`, which had grown past its size ceiling: routing is one job and deciding\n * whether a second turn may start on a session is another, and the second is the only part of that\n * file with state of its own.\n *\n * PER SESSION, not one flag for the router. `sessionFactory` resolves a session per request (the\n * documented multi-tenant shape), so a single flag made one tenant's turn refuse every other\n * tenant's — a busy neighbour is not a reason to refuse you, and that is a worse defect than the\n * race it closed.\n */\n\nimport type { IHttpTransportSession } from './http-session.js';\n\nexport interface ITurnClaims {\n /** The key this session is claimed under, or `undefined` when it will not name itself. */\n keyFor(session: IHttpTransportSession): string | undefined;\n isHeld(key: string): boolean;\n hold(key: string): void;\n release(key: string): void;\n}\n\n/**\n * A claim registry keyed by the session's declared ID, not by object identity.\n *\n * Keying on the OBJECT makes identity-stability a requirement of every caller that nothing can\n * check: a `sessionFactory` returning a fresh wrapper per call for the same logical session — a\n * proxy, an adapter, a spread copy — defeats the guard in silence, and every request looks\n * unclaimed. `getSession(): { getSessionId(): string }` already names the session, so asking for the\n * id turns an unenforceable requirement into no requirement at all.\n *\n * A claim is released in the caller's `finally`, so this does not grow with the number of sessions\n * the host has ever served — which is what weak keying would otherwise buy.\n */\nexport function createTurnClaims(): ITurnClaims {\n const held = new Set<string>();\n\n return {\n keyFor(session) {\n try {\n // No `?.`: `getSession(): { getSessionId(): string }` is non-nullable, and optional chaining\n // here would suggest the contract allows an absent session when it does not. A session that\n // breaks the contract anyway is caught below.\n const id = session.getSession().getSessionId();\n return typeof id === 'string' && id !== '' ? id : undefined;\n } catch {\n // allow-fallback: an unnameable session must not be guessed at\n // A session that throws while naming itself has not told us who it is, and guessing would\n // claim the wrong turn. The CALLER refuses on `undefined`, so nothing is swallowed here.\n //\n // `undefined` rather than a placeholder — a shared fallback key would collide every\n // unnameable session into one claim and refuse them all as each other's neighbours. There is\n // no `isExecuting()` fallback on the submit path, and an earlier comment said there was.\n return undefined;\n }\n },\n isHeld: (key) => held.has(key),\n hold: (key) => void held.add(key),\n release: (key) => void held.delete(key),\n };\n}\n","/**\n * HTTP transport adapter — exposes the declared session capabilities over REST API.\n *\n * Built on Hono for Cloudflare Workers + Node.js + AWS Lambda compatibility.\n * Exposes the core session methods (a subset; background-task, job-group, and\n * execution-workspace methods are WS-only).\n */\n\nimport {\n bearerCredential,\n credentialMatches,\n resolveAdmission,\n} from '@robota-sdk/agent-transport/node';\nimport { Hono } from 'hono';\n\nimport { submitHandler } from './submit-route.js';\nimport { createTurnClaims } from './turn-claims.js';\n\nimport type { TSessionFactory } from './submit-route.js';\nimport type { TStreamFailureListener } from './submit-stream.js';\nimport type {\n ITransportAdmission,\n ITransportAdmissionConfig,\n} from '@robota-sdk/agent-interface-transport';\n\n// Re-exported so existing importers keep their path; the definition and its rationale live with\n// the code that enforces them, in `submit-route.ts`.\nexport type { TSessionFactory } from './submit-route.js';\n\nexport interface IAgentRoutesOptions {\n /** Resolve an HTTP session port per request (e.g., by auth token, session ID). */\n sessionFactory: TSessionFactory;\n /**\n * Where the DETAIL of a post-headers stream failure goes (the client gets only a generic line —\n * see `submit-stream.ts`). Injected per the side-concern rule: the host decides the destination,\n * and absent means the host chose to drop it.\n */\n onStreamFailure?: TStreamFailureListener;\n /**\n * SEC-008: what a peer must present to reach the session. REQUIRED — there is no shape of this\n * option that means \"I did not think about it\", which is the state these routes shipped in.\n *\n * `{ open: true, openReason: '…' }` still runs with no credential, and that is a legitimate answer\n * for a host that has its own boundary in front. It just has to be written down.\n */\n admission: ITransportAdmissionConfig | ITransportAdmission;\n}\n\n/**\n * Create a Hono router with all agent HTTP endpoints.\n *\n * Usage:\n * ```typescript\n * const routes = createAgentRoutes({ sessionFactory });\n * app.route('/agent', routes); // mount on existing app\n * export default routes; // or use standalone (CF Workers)\n * ```\n */\nexport function createAgentRoutes(options: IAgentRoutesOptions): Hono {\n const { sessionFactory, onStreamFailure } = options;\n const app = new Hono();\n\n // RUNTIME-38: one turn at a time, per session — `turn-claims.ts` owns what that means and why it\n // is keyed by the session's declared ID rather than by object identity.\n const claims = createTurnClaims();\n\n // SEC-008: resolved ONCE, at construction, so a transport that cannot mint a credential fails to\n // build rather than serving without one. Resolving per request would also mint a new token per\n // request, which no peer could ever present.\n // Either shape goes in: `resolveAdmission` is idempotent, so an already-resolved admission comes\n // back unchanged and a config is resolved. `http-transport.ts` therefore does not have to take\n // its resolved admission apart and rebuild a config for this to resolve again — two resolutions\n // of one decision, and a mint on the second if the first had opened without a reason (review).\n //\n // This used to pick between the two with `'token' in options.admission`, and review showed that\n // cannot work: BOTH interfaces declare a `token`, so the shapes differ only by VALUE. A config of\n // `{ token: '' }` — documented as \"mint a fresh one\" — was read as pre-resolved and installed the\n // EMPTY STRING as the required credential, which a peer sending an empty bearer would match. The\n // discriminator is gone rather than repaired; there is nothing here left to get wrong.\n const admission = resolveAdmission(options.admission);\n\n /**\n * The trust boundary, installed BEFORE every route rather than checked inside each one.\n *\n * Before this, `POST /submit` reached `session.submit` and `POST /command` reached\n * `session.executeCommand` with nothing in between — remote arbitrary execution with no gate, and\n * an unauthenticated request looked exactly like an authorised one in both directions.\n *\n * A missing credential and a wrong one get the same answer, deliberately: telling them apart tells\n * a caller which half they got right.\n */\n app.use('*', async (c, next) => {\n if (admission.token === null) return next();\n if (credentialMatches(admission.token, bearerCredential(c.req.header('authorization')))) {\n return next();\n }\n return c.json({ error: 'unauthorized' }, 401);\n });\n\n // POST /submit — execute prompt, stream events via SSE\n app.post('/submit', submitHandler(sessionFactory, claims, onStreamFailure));\n\n // POST /command — execute system command\n app.post('/command', async (c) => {\n const session = await sessionFactory(c);\n const body = await c.req.json<{ name: string; args?: string }>();\n\n if (!body.name || typeof body.name !== 'string') {\n return c.json({ error: 'name is required' }, 400);\n }\n\n // SEC-008: 'remote', not the default 'user'. A peer over HTTP is not the person at the keyboard,\n // and defaulting to the local operator both mis-attributes the call and skips the 'remote' policy\n // seam that exists to treat the two differently. Admission decided WHO may reach the session; it\n // does not say who they are. (The MCP adapter had the same defect; this is its sibling.)\n const result = await session.executeCommand(body.name, body.args ?? '', 'remote');\n if (!result) {\n return c.json({ error: `Unknown command: ${body.name}` }, 404);\n }\n return c.json(result);\n });\n\n // POST /abort — abort current execution\n app.post('/abort', async (c) => {\n const session = await sessionFactory(c);\n session.abort();\n return c.json({ ok: true });\n });\n\n // POST /cancel-queue — cancel queued prompt\n app.post('/cancel-queue', async (c) => {\n const session = await sessionFactory(c);\n session.cancelQueue();\n return c.json({ ok: true });\n });\n\n // GET /messages — get message history\n app.get('/messages', async (c) => {\n const session = await sessionFactory(c);\n return c.json(session.getMessages());\n });\n\n // GET /context — get context window state\n app.get('/context', async (c) => {\n const session = await sessionFactory(c);\n return c.json(session.getContextState());\n });\n\n // GET /executing — check if currently executing\n //\n // Reports the SAME \"busy\" that `/submit` refuses on: the route's own claim OR the session's\n // `isExecuting()`. Asking only the session lets a client polling here see `executing: false` and\n // still get a 409 from `/submit`, and two endpoints disagreeing about one word is a worse answer\n // than either of them alone.\n app.get('/executing', async (c) => {\n const session = await sessionFactory(c);\n const claim = claims.keyFor(session);\n const claimed = claim !== undefined && claims.isHeld(claim);\n return c.json({ executing: claimed || session.isExecuting() });\n });\n\n // GET /pending — get pending queued prompt\n app.get('/pending', async (c) => {\n const session = await sessionFactory(c);\n return c.json({ pending: session.getPendingPrompt() });\n });\n\n return app;\n}\n","/**\n * ITransportAdapter implementation for HTTP transport.\n *\n * Wraps createAgentRoutes into the unified ITransportAdapter interface\n * while exposing the underlying Hono app via getApp().\n */\n\nimport { resolveAdmission } from '@robota-sdk/agent-transport/node';\nimport { Hono } from 'hono';\n\nimport { createAgentRoutes } from './routes.js';\n\nimport type { IHttpTransportSession } from './http-session.js';\nimport type { TStreamFailureListener } from './submit-stream.js';\nimport type {\n ITransportAdapter,\n ITransportAdmissionConfig,\n ITransportLifecycleError,\n} from '@robota-sdk/agent-interface-transport';\n\nexport interface IHttpTransportOptions {\n /** Optional: base path prefix for routes. */\n basePath?: string;\n /**\n * SEC-008: what a peer must present. Omitted means SECURE — a credential is minted and\n * `getAdmissionToken()` returns it for the host to hand to its client.\n *\n * This transport previously had no gate at all, so omitting the option used to mean \"anyone may\n * execute anything\". Omission now means the safe thing, and running open takes saying so.\n */\n admission?: ITransportAdmissionConfig;\n /**\n * Where the DETAIL of a post-headers stream failure goes — forwarded to `createAgentRoutes`.\n * Review found this seam missing: the option existed on the routes and not on the entry point\n * README and the examples actually use, so \"the host decides the destination\" was a decision no\n * host on this path could make. Same forwarding rule as `admission`, which sat beside it.\n */\n onStreamFailure?: TStreamFailureListener;\n}\n\nexport interface IHttpTransport extends ITransportAdapter<IHttpTransportSession> {\n getApp(): Hono;\n getAdmissionToken(): string | null;\n}\n\nexport function createHttpTransport(options?: IHttpTransportOptions): IHttpTransport {\n let session: IHttpTransportSession | null = null;\n let app: Hono | null = null;\n // Resolved at CONSTRUCTION, not at start: a host needs the credential to hand to its client, and\n // a transport that cannot mint one must fail before anything is served.\n const admission = resolveAdmission(options?.admission);\n const lifecycleError = (code: ITransportLifecycleError['code']): ITransportLifecycleError =>\n Object.assign(new Error(`HTTP transport ${code}.`), {\n name: 'TransportLifecycleError' as const,\n code,\n transportName: 'http',\n });\n\n return {\n name: 'http',\n lifecycle: Object.freeze({ kind: 'service' }),\n attach(s: IHttpTransportSession) {\n session = s;\n },\n async start() {\n if (!session) throw lifecycleError('not-attached');\n if (app) throw lifecycleError('already-started');\n const routes = createAgentRoutes({\n sessionFactory: () => session!,\n // The decision already made, passed through — not taken apart and rebuilt for the routes to\n // resolve a second time.\n admission,\n onStreamFailure: options?.onStreamFailure,\n });\n // TRANS-002 (issue #2480): `basePath` was declared and advertised but never read, so routes\n // always mounted at root. It is honored here — or absent, in which case the routes ARE the app.\n app = options?.basePath ? new Hono().route(options.basePath, routes) : routes;\n },\n async stop() {\n app = null;\n session = null;\n },\n getApp() {\n if (!app) throw new Error('Transport not started. Call start() first.');\n return app;\n },\n /** The credential this transport requires, or `null` when the host explicitly opened it. */\n getAdmissionToken() {\n return admission.token;\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/submit-stream.ts","../../src/submit-route.ts","../../src/turn-claims.ts","../../src/routes.ts","../../src/http-transport.ts"],"sourcesContent":["/**\n * RUNTIME-14 / RUNTIME-38 — relaying ONE turn to an SSE client, and letting go of it afterwards.\n *\n * Split out of `routes.ts` under the file-size ceiling. The route's job is to decide whether this\n * request may start a turn; relaying the turn's events, tearing the subscriptions down, and telling\n * a client whose stream broke after the headers went out is a different job with its own failure\n * modes, and it was four fifths of the handler.\n */\n\nimport type { IHttpTransportSession } from './http-session.js';\nimport type { SSEStreamingApi } from 'hono/streaming';\nimport type { ISubmitOptions } from '@robota-sdk/agent-interface-session';\n\n/**\n * Where the DETAIL of a post-headers stream failure goes — injected, never imported.\n *\n * The first version imported `createLogger` from `agent-core`, and review held it against two\n * authorities at once: `project-structure.md` declares this package contract-pure (transports\n * depend on the contracts and the protocol, nothing else), and its side-concern rule says a logging\n * destination \"must be injected, not imported directly\". The host that mounts this router decides\n * where diagnostics go; a module-level singleton decided it for every host, with no override.\n *\n * Absent, the detail is dropped — by the HOST's explicit default, which is a different thing from\n * this module deleting it: the client still learns the stream failed, and the host was offered the\n * detail at the boundary where it mounts the route.\n */\nexport type TStreamFailureListener = (error: Error) => void;\n\n/**\n * Who a submitted turn is attributed to, assigned by the HOST that composed the routes — never read\n * from the request, because a client could otherwise claim to be the local operator or another\n * surface in the usage record. Absent means the session's own default.\n */\nexport type TTurnAttribution = Pick<ISubmitOptions, 'driverId' | 'surface'>;\n\n/**\n * Wire every relay subscription and the abort path; answer with the promise that settles when the\n * turn is over (or refused one).\n *\n * The subscriptions are wired OUTSIDE the promise executor. Inside it, a throw from `session.on` —\n * a bad handler, an EventEmitter listener cap — is caught by the Promise constructor and becomes an\n * already-rejected `done` instead of propagating. Execution then reaches `await session.submit`, so\n * a REAL TURN is consumed with only some of its listeners attached and nothing to relay it, and the\n * rejection surfaces afterwards at `await done` — by which point the turn is gone.\n */\nfunction wireRelay(\n session: IHttpTransportSession,\n stream: SSEStreamingApi,\n cleanup: Array<() => void>,\n onFailure: TStreamFailureListener | undefined,\n): Promise<void> {\n const subscribe = <T>(event: string, handler: (data: T) => void): void => {\n session.on(event as 'text_delta', handler as () => void);\n cleanup.push(() => session.off(event as 'text_delta', handler as () => void));\n };\n\n // RUNTIME-14: await + catch every SSE write so a write to a client-closed stream is a blessed\n // no-op, not an unhandled rejection (post-headers errors bypass Hono's onError).\n const write = (event: string, data: unknown): Promise<void> =>\n stream.writeSSE({ event, data: JSON.stringify(data) }).catch(() => {\n // allow-fallback: client closed the stream mid-write — nothing to deliver; the finally teardown\n // (RUNTIME-14) removes the listeners, so this write has nothing left to do.\n });\n\n let settle!: () => void;\n const done = new Promise<void>((resolve) => {\n settle = resolve;\n });\n\n subscribe('text_delta', (delta: string) => void write('text_delta', { delta }));\n subscribe('tool_start', (state) => void write('tool_start', state));\n subscribe('tool_end', (state) => void write('tool_end', state));\n subscribe('thinking', (isThinking: boolean) => void write('thinking', { isThinking }));\n\n // Flush the terminal event before resolving, so the resolve → cleanup → stream-close\n // continuation cannot race ahead of the write.\n subscribe('complete', async (result) => {\n await write('complete', result);\n settle();\n });\n subscribe('interrupted', async (result) => {\n await write('interrupted', result);\n settle();\n });\n subscribe('error', async (error: Error) => {\n await write('error', { message: error.message });\n settle();\n });\n\n // RUNTIME-14: on client disconnect, CANCEL the underlying run (not merely stop writing) and\n // unblock `done` so the finally teardown runs — otherwise `done` would never resolve and the\n // listeners leak.\n stream.onAbort(() => onClientAbort(session, onFailure, settle));\n\n return done;\n}\n\n/**\n * The abort path, alone, because every clause of it is load-bearing and review shaped each one.\n *\n * `settle` in a `finally`: an `abort()` that throws used to cost a listener leak, and with the\n * claim registry it costs the SESSION — `done` never resolves, the teardown never runs, the claim\n * is held forever, and every future /submit is 409.\n *\n * The throw is REPORTED, not rethrown: this handler runs inside Hono's abort dispatch, OUTSIDE the\n * relay's try/catch, so a rethrow is an unhandled rejection rather than a reported failure. The\n * detail goes where every other stream failure's detail goes.\n */\nfunction onClientAbort(\n session: IHttpTransportSession,\n onFailure: TStreamFailureListener | undefined,\n settle: () => void,\n): void {\n try {\n session.abort();\n } catch (error) {\n // allow-fallback: an abort that throws is the HOST's news, not the runner's\n try {\n onFailure?.(error instanceof Error ? error : new Error(String(error)));\n } catch {\n // allow-fallback: a listener that throws must not replace the abort path's settle\n // Same rule as reportStreamFailure: the host's listener failing is the host's to see.\n }\n } finally {\n settle();\n }\n}\n\n/**\n * Teardown that cannot take the claim with it.\n *\n * Each entry individually, and review is why: these are `session.off` calls, and one that throws\n * inside a bare loop would end the caller's `finally` before `release()` ran — the same\n * claim-held-forever the abort path closes, reintroduced two lines down. A failed unsubscribe\n * forfeits one listener; taking the release with it forfeits the session.\n */\nfunction teardown(cleanup: Array<() => void>, release: () => void): void {\n for (const fn of cleanup) {\n try {\n fn();\n } catch {\n // allow-fallback: a failed unsubscribe must not take the claim release with it\n // The listener stays attached — the lesser loss, and the one the next turn can survive.\n }\n }\n // RUNTIME-38: released here for the same reason — a turn that throws must not wedge the session\n // it claimed.\n release();\n}\n\n/**\n * The `streamSSE` callback for one submitted prompt.\n *\n * `release` is called in the teardown, ALWAYS — on completion, on error, and on client disconnect.\n * It is passed in rather than taken here because the claim is made before the stream opens: a claim\n * whose release lives only inside the stream is a lock that outlives the request that took it.\n * (This block sat on `wireRelay` for one round after the split stranded it — the stale-doc shape\n * this file keeps writing about, caught doing it.)\n */\nexport function relayTurn(\n session: IHttpTransportSession,\n prompt: string,\n release: () => void,\n onFailure?: TStreamFailureListener,\n attribution?: TTurnAttribution,\n): (stream: SSEStreamingApi) => Promise<void> {\n return async (stream) => {\n // NOTHING may escape this callback, and the reason is measured rather than stylistic. Hono's\n // `streamSSE` runner calls its `onError` argument and then UNCONDITIONALLY writes\n // `event: error, data: e.message` to the stream — so an escaped exception reaches the client\n // verbatim no matter what the handler withheld. The probe that found it saw TWO error events:\n // this module's generic line, then Hono's raw one. The only place the trust boundary can be\n // held is here, before the throw reaches the runner at all.\n try {\n // The inner `try` opens BEFORE the wiring: a throw from `session.on` inside `wireRelay`\n // leaves the session claimed, and the `finally` is what releases it — opened later, every\n // later request gets 409 forever.\n const cleanup: Array<() => void> = [];\n try {\n const done = wireRelay(session, stream, cleanup, onFailure);\n await (attribution === undefined\n ? session.submit(prompt)\n : session.submit(prompt, undefined, undefined, { ...attribution }));\n await done;\n } finally {\n // RUNTIME-14: teardown ALWAYS runs — on completion, error, OR client disconnect — so the\n // session event listeners can never leak, and the claim always comes back.\n teardown(cleanup, release);\n }\n } catch (error) {\n await reportStreamFailure(\n error instanceof Error ? error : new Error(String(error)),\n stream,\n onFailure,\n );\n }\n };\n}\n\n/**\n * What to tell a client whose stream failed AFTER the response headers were sent.\n *\n * A throw at that point cannot become an error status — the client already has a 200 and an open\n * stream. Without this the throw is an UNHANDLED rejection: it left the process silently in a\n * browser and turned the `quality` job red under vitest, which is how it was found.\n *\n * By the time this runs, `relayTurn`'s teardown has removed the listeners and released the claim.\n * This handler owes only the telling.\n *\n * The CLIENT gets a generic line and the HOST gets the detail, and review moved this three times.\n * The first version sent `error.message` verbatim; the second sent it to a package logger imported\n * from `agent-core`, which the layering rule refuses (see `TStreamFailureListener`). What survives\n * both rounds is the boundary itself: an exception escaping the stream callback is not a message\n * anything composed for a client — it can carry provider internals, paths, or stack fragments,\n * exactly what the 500 branch in `routes.ts` withholds at the same boundary.\n *\n * The session's own `error` EVENT is different and stays verbatim in `relayTurn` above: that is the\n * session's client-facing error channel, worded by the session (`humanizeApiError`) and relayed\n * identically by the WS transport. One is a message meant for the client; this one never was.\n */\nexport async function reportStreamFailure(\n error: Error,\n stream: SSEStreamingApi,\n onFailure?: TStreamFailureListener,\n): Promise<void> {\n try {\n onFailure?.(error);\n } catch {\n // allow-fallback: a listener that throws must not take the client's only notification with it\n // The listener is the host's code; its failure is the host's to see, and the write below is the\n // one thing this function owes the client either way.\n }\n await stream\n .writeSSE({\n event: 'error',\n data: JSON.stringify({\n message: 'the stream failed on the server after it opened — the turn may not have run',\n }),\n })\n .catch(() => {\n // allow-fallback: the stream is already gone, which is the one case where there is nobody\n // left to tell. Rethrowing here would restore the unhandled rejection this exists to remove.\n });\n}\n","/**\n * `POST /submit` — the admission decision and the handler that hands an admitted turn to the relay.\n *\n * Split from `routes.ts` under the file-size ceiling, along the same seam the review rounds carved:\n * the route factory is a LIST of routes, and this one carries the concurrency-admission decision\n * plus the claim lifecycle, which is a different job from mounting endpoints.\n */\n\nimport { streamSSE } from 'hono/streaming';\n\nimport { relayTurn } from './submit-stream.js';\n\nimport type { IHttpTransportSession } from './http-session.js';\nimport type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';\nimport type { ITurnClaims } from './turn-claims.js';\nimport type { Context } from 'hono';\n\n/**\n * Callback that resolves an HTTP session port from the request context.\n *\n * It need NOT return the same object twice for the same logical session. It briefly did: `/submit`\n * keyed its concurrent-turn claim on object identity, so a factory building a fresh wrapper per\n * call — a proxy, an adapter, a `{...session}` copy — defeated the guard in silence. The first\n * answer was to document that as a requirement callers had to keep, and a requirement neither the\n * type system nor a test can check is not a contract, it is a hope.\n *\n * What the session already promises is enough. `getSession(): { getSessionId(): string }` names the\n * session, the claim is keyed by that name, and the requirement is gone rather than written down.\n *\n * The session must therefore BE nameable. `/submit` refuses with 500 when `getSessionId()` returns\n * nothing, because without an id the concurrent-turn guarantee does not exist and serving anyway\n * would hand back the bug to whoever could least tell. The contract types that method as returning\n * a `string`, so a conformant session never meets it.\n *\n * (Review moved this block here: it sat on the RE-EXPORT in `routes.ts` after the split, where the\n * person editing the definition would never see it.)\n */\nexport type TSessionFactory = (\n c: Context,\n) => IHttpTransportSession | Promise<IHttpTransportSession>;\n\n/**\n * Admit this request to ONE turn on its session, or answer why not.\n *\n * Returns the claim key it HOLDS on success — the caller owes its release — or the refusal\n * Response. A named unit, and review is why: the admission decision (nameable? busy? claim) is what\n * this route decides, independently testable from the stream wiring it hands the turn to.\n */\nfunction admitTurn(\n c: Context,\n session: IHttpTransportSession,\n claims: ITurnClaims,\n): Response | { claim: string } {\n // RUNTIME-38: the session is single-threaded (one turn at a time) and shared across requests, so a\n // concurrent /submit would cross-subscribe to the same emitter and interleave two clients' events.\n //\n // The route CLAIMS the turn rather than asking whether one is running, and that difference is the\n // whole fix. `session.isExecuting()` is set inside `submit()` past an `await ensureInitialized()`\n // — an unconditional suspension point — so two requests both read `false`, both call `submit`, and\n // the loser coalesces into the pending queue while its HTTP response streams back the WINNER'S\n // events. One turn delivered to two clients as if each had asked for it, which is worse than the\n // race the earlier comment described and then wrongly declared absent.\n //\n // A synchronous flag on the route closes it because there is no suspension point between reading\n // and setting it. It is released in the stream's `finally`, so a turn that throws does not leave\n // the route wedged.\n //\n // BOTH are asked because neither subsumes the other. The claim is what this ROUTE knows;\n // `isExecuting()` is what the SESSION knows, including a turn started by another surface — the\n // TUI, a WS client, a previous process — which the claim cannot see.\n // A session that cannot name itself is REFUSED, not served racily. The busy check is a guarantee\n // only while the claim is keyed by a real id: with no id it falls back to `isExecuting()` alone,\n // which the comment above documents as the racy read this route exists to stop relying on.\n // Serving anyway would degrade the fix back into the bug for exactly the callers who could not\n // be told they were affected.\n //\n // `getSession(): { getSessionId(): string }` is required by the contract, so this is unreachable\n // for a conformant session. It is a 500 rather than a 503: the session is not temporarily\n // unavailable, it does not meet the contract this route is built on, which is a defect on the\n // server side of the boundary and not a condition that clears on retry.\n const claim = claims.keyFor(session);\n if (claim === undefined) {\n return c.json(\n {\n // No internal method signature: this route can be mounted outside a trust boundary, and\n // the caller cannot act on `getSession().getSessionId()` anyway — it names a contract\n // they do not implement. The HOST needs the detail and gets it from the comment above\n // this branch; the client needs to know its request was refused and why in its own terms.\n error:\n 'concurrent-turn tracking is unavailable for this session, so this request cannot be ' +\n 'served safely. Refusing rather than starting a turn that cannot be guaranteed to be ' +\n 'yours.',\n },\n 500,\n );\n }\n if (claims.isHeld(claim) || session.isExecuting()) {\n return c.json({ error: 'session busy — a turn is already in flight' }, 409);\n }\n claims.hold(claim);\n return { claim };\n}\n\nexport function submitHandler(\n sessionFactory: TSessionFactory,\n claims: ITurnClaims,\n onStreamFailure?: TStreamFailureListener,\n attribution?: TTurnAttribution,\n) {\n return async (c: Context) => {\n const session = await sessionFactory(c);\n const body = await c.req.json<{ prompt: string }>();\n\n if (!body.prompt || typeof body.prompt !== 'string') {\n return c.json({ error: 'prompt is required' }, 400);\n }\n\n // A session still building itself cannot name itself yet, and the claim needs its name. The\n // claim itself is still taken synchronously after this, so two requests that both waited here\n // start one turn between them.\n if (session.whenInitialized !== undefined) {\n try {\n await session.whenInitialized();\n } catch {\n // The reason can name host paths or settings; the client only needs to know it failed.\n return c.json({ error: 'the session could not be started' }, 500);\n }\n }\n\n const admitted = admitTurn(c, session, claims);\n if (admitted instanceof Response) {\n return admitted;\n }\n const { claim } = admitted;\n // SINGLE-SHOT, and review traced the double-release it prevents: the relay's teardown releases\n // this claim on the normal path, and if a future Hono ever rejected the outer promise AFTER the\n // callback completed, the catch below would release the SAME KEY a second time — by which point\n // another request may hold it, and the second release would free that request's claim, which is\n // the cross-talk this registry exists to prevent. A release that has happened is not a release\n // that can happen again.\n let released = false;\n const releaseOnce = (): void => {\n if (!released) {\n released = true;\n claims.release(claim);\n }\n };\n\n // The claim is taken OUTSIDE the callback, so its release cannot live only in the callback's\n // `finally` — a claim whose release is not in the same protected region is a lock, not a claim,\n // which this file states one level in. If `streamSSE` throws before it ever runs the callback,\n // this releases and rethrows; the callback's own `finally` covers everything after it starts.\n //\n // REPRODUCED at the module boundary: `routes-streamsse-throw.test.ts` mocks `hono/streaming`\n // to throw before the callback ever runs and asserts the second request is not 409 — a leaked\n // claim is what this catch exists to prevent. Two rounds shipped it as honestly-labelled\n // unverified code (a throw from `session.on` lands INSIDE the callback, where the inner\n // `finally` releases — measured, a case built that way is green with this catch removed), and\n // review supplied the third option: stub the boundary, not Hono's internals.\n try {\n // NO third argument, deliberately: Hono's runner follows any `onError` by writing the raw\n // `e.message` to the stream, so the only leak-proof shape is a callback nothing escapes —\n // `relayTurn` catches its own failures and reports them generically. An onError here would be\n // dead code that turns into a leak the day it stops being dead.\n // `await`, so the catch below covers a REJECTION as well as a synchronous throw. Today's\n // Hono returns the Response synchronously and only the sync path is real — but review is\n // right that a version returning a rejecting promise would sail past a bare `return`, and\n // the cost of closing that class is one keyword.\n return await streamSSE(\n c,\n relayTurn(session, body.prompt, releaseOnce, onStreamFailure, attribution),\n );\n } catch (error) {\n releaseOnce();\n throw error;\n }\n };\n}\n","/**\n * RUNTIME-38 — who currently holds the one turn a session can run.\n *\n * Split out of `routes.ts`, which had grown past its size ceiling: routing is one job and deciding\n * whether a second turn may start on a session is another, and the second is the only part of that\n * file with state of its own.\n *\n * PER SESSION, not one flag for the router. `sessionFactory` resolves a session per request (the\n * documented multi-tenant shape), so a single flag made one tenant's turn refuse every other\n * tenant's — a busy neighbour is not a reason to refuse you, and that is a worse defect than the\n * race it closed.\n */\n\nimport type { IHttpTransportSession } from './http-session.js';\n\nexport interface ITurnClaims {\n /** The key this session is claimed under, or `undefined` when it will not name itself. */\n keyFor(session: IHttpTransportSession): string | undefined;\n isHeld(key: string): boolean;\n hold(key: string): void;\n release(key: string): void;\n}\n\n/**\n * A claim registry keyed by the session's declared ID, not by object identity.\n *\n * Keying on the OBJECT makes identity-stability a requirement of every caller that nothing can\n * check: a `sessionFactory` returning a fresh wrapper per call for the same logical session — a\n * proxy, an adapter, a spread copy — defeats the guard in silence, and every request looks\n * unclaimed. `getSession(): { getSessionId(): string }` already names the session, so asking for the\n * id turns an unenforceable requirement into no requirement at all.\n *\n * A claim is released in the caller's `finally`, so this does not grow with the number of sessions\n * the host has ever served — which is what weak keying would otherwise buy.\n */\nexport function createTurnClaims(): ITurnClaims {\n const held = new Set<string>();\n\n return {\n keyFor(session) {\n try {\n // No `?.`: `getSession(): { getSessionId(): string }` is non-nullable, and optional chaining\n // here would suggest the contract allows an absent session when it does not. A session that\n // breaks the contract anyway is caught below.\n const id = session.getSession().getSessionId();\n return typeof id === 'string' && id !== '' ? id : undefined;\n } catch {\n // allow-fallback: an unnameable session must not be guessed at\n // A session that throws while naming itself has not told us who it is, and guessing would\n // claim the wrong turn. The CALLER refuses on `undefined`, so nothing is swallowed here.\n //\n // `undefined` rather than a placeholder — a shared fallback key would collide every\n // unnameable session into one claim and refuse them all as each other's neighbours. There is\n // no `isExecuting()` fallback on the submit path, and an earlier comment said there was.\n return undefined;\n }\n },\n isHeld: (key) => held.has(key),\n hold: (key) => void held.add(key),\n release: (key) => void held.delete(key),\n };\n}\n","/**\n * HTTP transport adapter — exposes the declared session capabilities over REST API.\n *\n * Built on Hono for Cloudflare Workers + Node.js + AWS Lambda compatibility.\n * Exposes the core session methods (a subset; background-task, job-group, and\n * execution-workspace methods are WS-only).\n */\n\nimport {\n bearerCredential,\n credentialMatches,\n resolveAdmission,\n} from '@robota-sdk/agent-transport/node';\nimport { Hono } from 'hono';\n\nimport { submitHandler } from './submit-route.js';\nimport { createTurnClaims } from './turn-claims.js';\n\nimport type { TSessionFactory } from './submit-route.js';\nimport type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';\nimport type {\n ITransportAdmission,\n ITransportAdmissionConfig,\n} from '@robota-sdk/agent-interface-transport';\n\n// Re-exported so existing importers keep their path; the definition and its rationale live with\n// the code that enforces them, in `submit-route.ts`.\nexport type { TSessionFactory } from './submit-route.js';\n\nexport interface IAgentRoutesOptions {\n /** Resolve an HTTP session port per request (e.g., by auth token, session ID). */\n sessionFactory: TSessionFactory;\n /**\n * Where the DETAIL of a post-headers stream failure goes (the client gets only a generic line —\n * see `submit-stream.ts`). Injected per the side-concern rule: the host decides the destination,\n * and absent means the host chose to drop it.\n */\n onStreamFailure?: TStreamFailureListener;\n /**\n * SEC-008: what a peer must present to reach the session. REQUIRED — there is no shape of this\n * option that means \"I did not think about it\", which is the state these routes shipped in.\n *\n * `{ open: true, openReason: '…' }` still runs with no credential, and that is a legitimate answer\n * for a host that has its own boundary in front. It just has to be written down.\n */\n admission: ITransportAdmissionConfig | ITransportAdmission;\n /**\n * Who `/submit` turns are attributed to — the driver and product surface the HOST assigns, as the\n * WebSocket transport's `driverId`/`surface` do. Never taken from the request body. Absent leaves\n * the session's own default.\n */\n attribution?: TTurnAttribution;\n}\n\n/**\n * Create a Hono router with all agent HTTP endpoints.\n *\n * Usage:\n * ```typescript\n * const routes = createAgentRoutes({ sessionFactory });\n * app.route('/agent', routes); // mount on existing app\n * export default routes; // or use standalone (CF Workers)\n * ```\n */\nexport function createAgentRoutes(options: IAgentRoutesOptions): Hono {\n const { sessionFactory, onStreamFailure, attribution } = options;\n const app = new Hono();\n\n // RUNTIME-38: one turn at a time, per session — `turn-claims.ts` owns what that means and why it\n // is keyed by the session's declared ID rather than by object identity.\n const claims = createTurnClaims();\n\n // SEC-008: resolved ONCE, at construction, so a transport that cannot mint a credential fails to\n // build rather than serving without one. Resolving per request would also mint a new token per\n // request, which no peer could ever present.\n // Either shape goes in: `resolveAdmission` is idempotent, so an already-resolved admission comes\n // back unchanged and a config is resolved. `http-transport.ts` therefore does not have to take\n // its resolved admission apart and rebuild a config for this to resolve again — two resolutions\n // of one decision, and a mint on the second if the first had opened without a reason (review).\n //\n // This used to pick between the two with `'token' in options.admission`, and review showed that\n // cannot work: BOTH interfaces declare a `token`, so the shapes differ only by VALUE. A config of\n // `{ token: '' }` — documented as \"mint a fresh one\" — was read as pre-resolved and installed the\n // EMPTY STRING as the required credential, which a peer sending an empty bearer would match. The\n // discriminator is gone rather than repaired; there is nothing here left to get wrong.\n const admission = resolveAdmission(options.admission);\n\n /**\n * The trust boundary, installed BEFORE every route rather than checked inside each one.\n *\n * Before this, `POST /submit` reached `session.submit` and `POST /command` reached\n * `session.executeCommand` with nothing in between — remote arbitrary execution with no gate, and\n * an unauthenticated request looked exactly like an authorised one in both directions.\n *\n * A missing credential and a wrong one get the same answer, deliberately: telling them apart tells\n * a caller which half they got right.\n */\n app.use('*', async (c, next) => {\n if (admission.token === null) return next();\n if (credentialMatches(admission.token, bearerCredential(c.req.header('authorization')))) {\n return next();\n }\n return c.json({ error: 'unauthorized' }, 401);\n });\n\n // POST /submit — execute prompt, stream events via SSE\n app.post('/submit', submitHandler(sessionFactory, claims, onStreamFailure, attribution));\n\n // POST /command — execute system command\n app.post('/command', async (c) => {\n const session = await sessionFactory(c);\n const body = await c.req.json<{ name: string; args?: string }>();\n\n if (!body.name || typeof body.name !== 'string') {\n return c.json({ error: 'name is required' }, 400);\n }\n\n // SEC-008: 'remote', not the default 'user'. A peer over HTTP is not the person at the keyboard,\n // and defaulting to the local operator both mis-attributes the call and skips the 'remote' policy\n // seam that exists to treat the two differently. Admission decided WHO may reach the session; it\n // does not say who they are. (The MCP adapter had the same defect; this is its sibling.)\n const result = await session.executeCommand(body.name, body.args ?? '', 'remote');\n if (!result) {\n return c.json({ error: `Unknown command: ${body.name}` }, 404);\n }\n return c.json(result);\n });\n\n // POST /abort — abort current execution\n app.post('/abort', async (c) => {\n const session = await sessionFactory(c);\n session.abort();\n return c.json({ ok: true });\n });\n\n // POST /cancel-queue — cancel queued prompt\n app.post('/cancel-queue', async (c) => {\n const session = await sessionFactory(c);\n session.cancelQueue();\n return c.json({ ok: true });\n });\n\n // GET /messages — get message history\n app.get('/messages', async (c) => {\n const session = await sessionFactory(c);\n return c.json(session.getMessages());\n });\n\n // GET /context — get context window state\n app.get('/context', async (c) => {\n const session = await sessionFactory(c);\n return c.json(session.getContextState());\n });\n\n // GET /executing — check if currently executing\n //\n // Reports the SAME \"busy\" that `/submit` refuses on: the route's own claim OR the session's\n // `isExecuting()`. Asking only the session lets a client polling here see `executing: false` and\n // still get a 409 from `/submit`, and two endpoints disagreeing about one word is a worse answer\n // than either of them alone.\n app.get('/executing', async (c) => {\n const session = await sessionFactory(c);\n const claim = claims.keyFor(session);\n const claimed = claim !== undefined && claims.isHeld(claim);\n return c.json({ executing: claimed || session.isExecuting() });\n });\n\n // GET /pending — get pending queued prompt\n app.get('/pending', async (c) => {\n const session = await sessionFactory(c);\n return c.json({ pending: session.getPendingPrompt() });\n });\n\n return app;\n}\n","/**\n * ITransportAdapter implementation for HTTP transport.\n *\n * Wraps createAgentRoutes into the unified ITransportAdapter interface\n * while exposing the underlying Hono app via getApp().\n */\n\nimport { resolveAdmission } from '@robota-sdk/agent-transport/node';\nimport { Hono } from 'hono';\n\nimport { createAgentRoutes } from './routes.js';\n\nimport type { IHttpTransportSession } from './http-session.js';\nimport type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';\nimport type {\n ITransportAdapter,\n ITransportAdmissionConfig,\n ITransportLifecycleError,\n} from '@robota-sdk/agent-interface-transport';\n\nexport interface IHttpTransportOptions {\n /** Optional: base path prefix for routes. */\n basePath?: string;\n /**\n * SEC-008: what a peer must present. Omitted means SECURE — a credential is minted and\n * `getAdmissionToken()` returns it for the host to hand to its client.\n *\n * This transport previously had no gate at all, so omitting the option used to mean \"anyone may\n * execute anything\". Omission now means the safe thing, and running open takes saying so.\n */\n admission?: ITransportAdmissionConfig;\n /**\n * Where the DETAIL of a post-headers stream failure goes — forwarded to `createAgentRoutes`.\n * Review found this seam missing: the option existed on the routes and not on the entry point\n * README and the examples actually use, so \"the host decides the destination\" was a decision no\n * host on this path could make. Same forwarding rule as `admission`, which sat beside it.\n */\n onStreamFailure?: TStreamFailureListener;\n /** Who `/submit` turns are attributed to — forwarded to `createAgentRoutes`, like `admission`. */\n attribution?: TTurnAttribution;\n}\n\nexport interface IHttpTransport extends ITransportAdapter<IHttpTransportSession> {\n getApp(): Hono;\n getAdmissionToken(): string | null;\n}\n\nexport function createHttpTransport(options?: IHttpTransportOptions): IHttpTransport {\n let session: IHttpTransportSession | null = null;\n let app: Hono | null = null;\n // Resolved at CONSTRUCTION, not at start: a host needs the credential to hand to its client, and\n // a transport that cannot mint one must fail before anything is served.\n const admission = resolveAdmission(options?.admission);\n const lifecycleError = (code: ITransportLifecycleError['code']): ITransportLifecycleError =>\n Object.assign(new Error(`HTTP transport ${code}.`), {\n name: 'TransportLifecycleError' as const,\n code,\n transportName: 'http',\n });\n\n return {\n name: 'http',\n lifecycle: Object.freeze({ kind: 'service' }),\n attach(s: IHttpTransportSession) {\n session = s;\n },\n async start() {\n if (!session) throw lifecycleError('not-attached');\n if (app) throw lifecycleError('already-started');\n const routes = createAgentRoutes({\n sessionFactory: () => session!,\n // The decision already made, passed through — not taken apart and rebuilt for the routes to\n // resolve a second time.\n admission,\n onStreamFailure: options?.onStreamFailure,\n attribution: options?.attribution,\n });\n // TRANS-002 (issue #2480): `basePath` was declared and advertised but never read, so routes\n // always mounted at root. It is honored here — or absent, in which case the routes ARE the app.\n app = options?.basePath ? new Hono().route(options.basePath, routes) : routes;\n },\n async stop() {\n app = null;\n session = null;\n },\n getApp() {\n if (!app) throw new Error('Transport not started. Call start() first.');\n return app;\n },\n /** The credential this transport requires, or `null` when the host explicitly opened it. */\n getAdmissionToken() {\n return admission.token;\n },\n };\n}\n"],"mappings":"wLA6CA,SAAS,EACP,EACA,EACA,EACA,EACe,CACf,IAAM,GAAgB,EAAe,IAAqC,CACxE,EAAQ,GAAG,EAAuB,CAAqB,EACvD,EAAQ,SAAW,EAAQ,IAAI,EAAuB,CAAqB,CAAC,CAC9E,EAIM,GAAS,EAAe,IAC5B,EAAO,SAAS,CAAE,QAAO,KAAM,KAAK,UAAU,CAAI,CAAE,CAAC,CAAC,CAAC,UAAY,CAGnE,CAAC,EAEC,EACE,EAAO,IAAI,QAAe,GAAY,CAC1C,EAAS,CACX,CAAC,EA2BD,OAzBA,EAAU,aAAe,GAAkB,KAAK,EAAM,aAAc,CAAE,OAAM,CAAC,CAAC,EAC9E,EAAU,aAAe,GAAU,KAAK,EAAM,aAAc,CAAK,CAAC,EAClE,EAAU,WAAa,GAAU,KAAK,EAAM,WAAY,CAAK,CAAC,EAC9D,EAAU,WAAa,GAAwB,KAAK,EAAM,WAAY,CAAE,YAAW,CAAC,CAAC,EAIrF,EAAU,WAAY,KAAO,IAAW,CACtC,MAAM,EAAM,WAAY,CAAM,EAC9B,EAAO,CACT,CAAC,EACD,EAAU,cAAe,KAAO,IAAW,CACzC,MAAM,EAAM,cAAe,CAAM,EACjC,EAAO,CACT,CAAC,EACD,EAAU,QAAS,KAAO,IAAiB,CACzC,MAAM,EAAM,QAAS,CAAE,QAAS,EAAM,OAAQ,CAAC,EAC/C,EAAO,CACT,CAAC,EAKD,EAAO,YAAc,EAAc,EAAS,EAAW,CAAM,CAAC,EAEvD,CACT,CAaA,SAAS,EACP,EACA,EACA,EACM,CACN,GAAI,CACF,EAAQ,MAAM,CAChB,OAAS,EAAO,CAEd,GAAI,CACF,IAAY,aAAiB,MAAQ,EAAY,MAAM,OAAO,CAAK,CAAC,CAAC,CACvE,MAAQ,CAGR,CACF,QAAU,CACR,EAAO,CACT,CACF,CAUA,SAAS,EAAS,EAA4B,EAA2B,CACvE,IAAK,IAAM,KAAM,EACf,GAAI,CACF,EAAG,CACL,MAAQ,CAGR,CAIF,EAAQ,CACV,CAWA,SAAgB,EACd,EACA,EACA,EACA,EACA,EAC4C,CAC5C,OAAO,KAAO,IAAW,CAOvB,GAAI,CAIF,IAAM,EAA6B,CAAC,EACpC,GAAI,CACF,IAAM,EAAO,EAAU,EAAS,EAAQ,EAAS,CAAS,EAC1D,MAAO,IAAgB,IAAA,GACnB,EAAQ,OAAO,CAAM,EACrB,EAAQ,OAAO,EAAQ,IAAA,GAAW,IAAA,GAAW,CAAE,GAAG,CAAY,CAAC,GACnE,MAAM,CACR,QAAU,CAGR,EAAS,EAAS,CAAO,CAC3B,CACF,OAAS,EAAO,CACd,MAAM,EACJ,aAAiB,MAAQ,EAAY,MAAM,OAAO,CAAK,CAAC,EACxD,EACA,CACF,CACF,CACF,CACF,CAuBA,eAAsB,EACpB,EACA,EACA,EACe,CACf,GAAI,CACF,IAAY,CAAK,CACnB,MAAQ,CAIR,CACA,MAAM,EACH,SAAS,CACR,MAAO,QACP,KAAM,KAAK,UAAU,CACnB,QAAS,6EACX,CAAC,CACH,CAAC,CAAC,CACD,UAAY,CAGb,CAAC,CACL,CCnMA,SAAS,EACP,EACA,EACA,EAC8B,CA4B9B,IAAM,EAAQ,EAAO,OAAO,CAAO,EAoBnC,OAnBI,IAAU,IAAA,GACL,EAAE,KACP,CAKE,MACE,gLAGJ,EACA,GACF,EAEE,EAAO,OAAO,CAAK,GAAK,EAAQ,YAAY,EACvC,EAAE,KAAK,CAAE,MAAO,4CAA6C,EAAG,GAAG,GAE5E,EAAO,KAAK,CAAK,EACV,CAAE,OAAM,EACjB,CAEA,SAAgB,EACd,EACA,EACA,EACA,EACA,CACA,OAAO,KAAO,IAAe,CAC3B,IAAM,EAAU,MAAM,EAAe,CAAC,EAChC,EAAO,MAAM,EAAE,IAAI,KAAyB,EAElD,GAAI,CAAC,EAAK,QAAU,OAAO,EAAK,QAAW,SACzC,OAAO,EAAE,KAAK,CAAE,MAAO,oBAAqB,EAAG,GAAG,EAMpD,GAAI,EAAQ,kBAAoB,IAAA,GAC9B,GAAI,CACF,MAAM,EAAQ,gBAAgB,CAChC,MAAQ,CAEN,OAAO,EAAE,KAAK,CAAE,MAAO,kCAAmC,EAAG,GAAG,CAClE,CAGF,IAAM,EAAW,EAAU,EAAG,EAAS,CAAM,EAC7C,GAAI,aAAoB,SACtB,OAAO,EAET,GAAM,CAAE,SAAU,EAOd,EAAW,GACT,MAA0B,CACzB,IACH,EAAW,GACX,EAAO,QAAQ,CAAK,EAExB,EAaA,GAAI,CASF,OAAO,MAAM,EACX,EACA,EAAU,EAAS,EAAK,OAAQ,EAAa,EAAiB,CAAW,CAC3E,CACF,OAAS,EAAO,CAEd,MADA,EAAY,EACN,CACR,CACF,CACF,CC9IA,SAAgB,GAAgC,CAC9C,IAAM,EAAO,IAAI,IAEjB,MAAO,CACL,OAAO,EAAS,CACd,GAAI,CAIF,IAAM,EAAK,EAAQ,WAAW,CAAC,CAAC,aAAa,EAC7C,OAAO,OAAO,GAAO,UAAY,IAAO,GAAK,EAAK,IAAA,EACpD,MAAQ,CAQN,MACF,CACF,EACA,OAAS,GAAQ,EAAK,IAAI,CAAG,EAC7B,KAAO,GAAQ,KAAK,EAAK,IAAI,CAAG,EAChC,QAAU,GAAQ,KAAK,EAAK,OAAO,CAAG,CACxC,CACF,CCGA,SAAgB,EAAkB,EAAoC,CACpE,GAAM,CAAE,iBAAgB,kBAAiB,eAAgB,EACnD,EAAM,IAAI,EAIV,EAAS,EAAiB,EAe1B,EAAY,EAAiB,EAAQ,SAAS,EAwFpD,OA5EA,EAAI,IAAI,IAAK,MAAO,EAAG,IACjB,EAAU,QAAU,MACpB,EAAkB,EAAU,MAAO,EAAiB,EAAE,IAAI,OAAO,eAAe,CAAC,CAAC,EAC7E,EAAK,EAEP,EAAE,KAAK,CAAE,MAAO,cAAe,EAAG,GAAG,CAC7C,EAGD,EAAI,KAAK,UAAW,EAAc,EAAgB,EAAQ,EAAiB,CAAW,CAAC,EAGvF,EAAI,KAAK,WAAY,KAAO,IAAM,CAChC,IAAM,EAAU,MAAM,EAAe,CAAC,EAChC,EAAO,MAAM,EAAE,IAAI,KAAsC,EAE/D,GAAI,CAAC,EAAK,MAAQ,OAAO,EAAK,MAAS,SACrC,OAAO,EAAE,KAAK,CAAE,MAAO,kBAAmB,EAAG,GAAG,EAOlD,IAAM,EAAS,MAAM,EAAQ,eAAe,EAAK,KAAM,EAAK,MAAQ,GAAI,QAAQ,EAIhF,OAHK,EAGE,EAAE,KAAK,CAAM,EAFX,EAAE,KAAK,CAAE,MAAO,oBAAoB,EAAK,MAAO,EAAG,GAAG,CAGjE,CAAC,EAGD,EAAI,KAAK,SAAU,KAAO,MAExB,MADsB,EAAe,CAAC,EAAA,CAC9B,MAAM,EACP,EAAE,KAAK,CAAE,GAAI,EAAK,CAAC,EAC3B,EAGD,EAAI,KAAK,gBAAiB,KAAO,MAE/B,MADsB,EAAe,CAAC,EAAA,CAC9B,YAAY,EACb,EAAE,KAAK,CAAE,GAAI,EAAK,CAAC,EAC3B,EAGD,EAAI,IAAI,YAAa,KAAO,IAAM,CAChC,IAAM,EAAU,MAAM,EAAe,CAAC,EACtC,OAAO,EAAE,KAAK,EAAQ,YAAY,CAAC,CACrC,CAAC,EAGD,EAAI,IAAI,WAAY,KAAO,IAAM,CAC/B,IAAM,EAAU,MAAM,EAAe,CAAC,EACtC,OAAO,EAAE,KAAK,EAAQ,gBAAgB,CAAC,CACzC,CAAC,EAQD,EAAI,IAAI,aAAc,KAAO,IAAM,CACjC,IAAM,EAAU,MAAM,EAAe,CAAC,EAChC,EAAQ,EAAO,OAAO,CAAO,EAC7B,EAAU,IAAU,IAAA,IAAa,EAAO,OAAO,CAAK,EAC1D,OAAO,EAAE,KAAK,CAAE,UAAW,GAAW,EAAQ,YAAY,CAAE,CAAC,CAC/D,CAAC,EAGD,EAAI,IAAI,WAAY,KAAO,IAAM,CAC/B,IAAM,EAAU,MAAM,EAAe,CAAC,EACtC,OAAO,EAAE,KAAK,CAAE,QAAS,EAAQ,iBAAiB,CAAE,CAAC,CACvD,CAAC,EAEM,CACT,CC/HA,SAAgB,EAAoB,EAAiD,CACnF,IAAI,EAAwC,KACxC,EAAmB,KAGjB,EAAY,EAAiB,GAAS,SAAS,EAC/C,EAAkB,GACtB,OAAO,OAAW,MAAM,kBAAkB,EAAK,EAAE,EAAG,CAClD,KAAM,0BACN,OACA,cAAe,MACjB,CAAC,EAEH,MAAO,CACL,KAAM,OACN,UAAW,OAAO,OAAO,CAAE,KAAM,SAAU,CAAC,EAC5C,OAAO,EAA0B,CAC/B,EAAU,CACZ,EACA,MAAM,OAAQ,CACZ,GAAI,CAAC,EAAS,MAAM,EAAe,cAAc,EACjD,GAAI,EAAK,MAAM,EAAe,iBAAiB,EAC/C,IAAM,EAAS,EAAkB,CAC/B,mBAAsB,EAGtB,YACA,gBAAiB,GAAS,gBAC1B,YAAa,GAAS,WACxB,CAAC,EAGD,EAAM,GAAS,SAAW,IAAI,EAAK,CAAC,CAAC,MAAM,EAAQ,SAAU,CAAM,EAAI,CACzE,EACA,MAAM,MAAO,CACX,EAAM,KACN,EAAU,IACZ,EACA,QAAS,CACP,GAAI,CAAC,EAAK,MAAU,MAAM,4CAA4C,EACtE,OAAO,CACT,EAEA,mBAAoB,CAClB,OAAO,EAAU,KACnB,CACF,CACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-transport-http",
3
- "version": "3.0.0-beta.86",
3
+ "version": "3.0.0-beta.88",
4
4
  "description": "HTTP (Hono) transport for the Robota SDK",
5
5
  "type": "module",
6
6
  "main": "dist/node/index.js",
@@ -31,15 +31,6 @@
31
31
  },
32
32
  "./package.json": "./package.json"
33
33
  },
34
- "repository": {
35
- "type": "git",
36
- "url": "https://github.com/woojubb/robota.git",
37
- "directory": "packages/agent-transport-http"
38
- },
39
- "homepage": "https://docs.robota.io/en/packages/agent-transport-http/",
40
- "bugs": {
41
- "url": "https://github.com/woojubb/robota/issues"
42
- },
43
34
  "engines": {
44
35
  "node": ">=22.12.0"
45
36
  },
@@ -50,9 +41,9 @@
50
41
  ],
51
42
  "dependencies": {
52
43
  "hono": "^4.12.32",
53
- "@robota-sdk/agent-interface-session": "3.0.0-beta.86",
54
- "@robota-sdk/agent-interface-transport": "3.0.0-beta.86",
55
- "@robota-sdk/agent-transport": "3.0.0-beta.86"
44
+ "@robota-sdk/agent-transport": "3.0.0-beta.88",
45
+ "@robota-sdk/agent-interface-transport": "3.0.0-beta.88",
46
+ "@robota-sdk/agent-interface-session": "3.0.0-beta.88"
56
47
  },
57
48
  "devDependencies": {
58
49
  "rimraf": "^5.0.10",
@@ -67,6 +58,14 @@
67
58
  "volta": {
68
59
  "extends": "../../package.json"
69
60
  },
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "https://github.com/woojubb/robota"
64
+ },
65
+ "bugs": {
66
+ "url": "https://github.com/woojubb/robota/issues"
67
+ },
68
+ "homepage": "https://robota.io/",
70
69
  "scripts": {
71
70
  "build": "rimraf dist && tsdown",
72
71
  "build:js": "pnpm run build",
@@ -366,6 +366,31 @@ describe('HTTP Transport Routes', () => {
366
366
  return res.text();
367
367
  }
368
368
 
369
+ it('POST /submit attributes the turn to the host-assigned driver and surface, never the client', async () => {
370
+ const session = createEmitterSession('complete', { ok: true });
371
+ const app = createAgentRoutes({
372
+ sessionFactory: () => session,
373
+ admission: { open: true, openReason: 'this case is about attribution, not admission' },
374
+ attribution: { driverId: 'remote:http', surface: 'remote' },
375
+ });
376
+ const res = await app.request('/submit', {
377
+ method: 'POST',
378
+ headers: { 'Content-Type': 'application/json' },
379
+ body: JSON.stringify({ prompt: 'hi', driverId: 'owner', surface: 'cli' }),
380
+ });
381
+ await res.text();
382
+ expect(session.submit).toHaveBeenCalledWith('hi', undefined, undefined, {
383
+ driverId: 'remote:http',
384
+ surface: 'remote',
385
+ });
386
+ });
387
+
388
+ it('POST /submit leaves attribution to the session when the host assigns none', async () => {
389
+ const session = createEmitterSession('complete', { ok: true });
390
+ await requestSubmit(session);
391
+ expect(session.submit).toHaveBeenCalledWith('hi');
392
+ });
393
+
369
394
  it('POST /submit receives the complete event without a trailing thinking(false)', async () => {
370
395
  const session = createEmitterSession('complete', { ok: true });
371
396
  const body = await requestSubmit(session);
@@ -1020,7 +1045,7 @@ describe('a session that builds itself in the background', () => {
1020
1045
  });
1021
1046
 
1022
1047
  it('answers 500 without the reason when the session fails to start', async () => {
1023
- const failed = Promise.reject(new Error('settings at /home/me/.robota are broken'));
1048
+ const failed = Promise.reject(new Error('settings at /home/me/.fixture-state are broken'));
1024
1049
  const session = createStartingSession(failed);
1025
1050
 
1026
1051
  const response = await submit(session);
@@ -39,7 +39,7 @@ describe('what a client learns from a stream that failed after it opened', () =>
39
39
  // that the stream failed, which is the part it can act on.
40
40
  const { stream, written } = fakeStream();
41
41
 
42
- await reportStreamFailure(new Error('ENOENT /home/op/.robota/providers/key.json'), stream);
42
+ await reportStreamFailure(new Error('ENOENT /home/op/.fixture-state/providers/key.json'), stream);
43
43
 
44
44
  expect(written).toHaveLength(1);
45
45
  expect(written[0].data).not.toContain('key.json');
@@ -63,7 +63,7 @@ describe('a failure in the callback never escapes to the runner', () => {
63
63
  const detail = vi.fn();
64
64
  const session = createTestInteractiveSession({
65
65
  on: ((event: string) => {
66
- if (event === 'text_delta') throw new Error('ENOENT /home/op/.robota/key.json');
66
+ if (event === 'text_delta') throw new Error('ENOENT /home/op/.fixture-state/key.json');
67
67
  }) as IInteractiveSession['on'],
68
68
  });
69
69
  const { stream, written } = fakeStream();
@@ -11,7 +11,7 @@ import { Hono } from 'hono';
11
11
  import { createAgentRoutes } from './routes.js';
12
12
 
13
13
  import type { IHttpTransportSession } from './http-session.js';
14
- import type { TStreamFailureListener } from './submit-stream.js';
14
+ import type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';
15
15
  import type {
16
16
  ITransportAdapter,
17
17
  ITransportAdmissionConfig,
@@ -36,6 +36,8 @@ export interface IHttpTransportOptions {
36
36
  * host on this path could make. Same forwarding rule as `admission`, which sat beside it.
37
37
  */
38
38
  onStreamFailure?: TStreamFailureListener;
39
+ /** Who `/submit` turns are attributed to — forwarded to `createAgentRoutes`, like `admission`. */
40
+ attribution?: TTurnAttribution;
39
41
  }
40
42
 
41
43
  export interface IHttpTransport extends ITransportAdapter<IHttpTransportSession> {
@@ -71,6 +73,7 @@ export function createHttpTransport(options?: IHttpTransportOptions): IHttpTrans
71
73
  // resolve a second time.
72
74
  admission,
73
75
  onStreamFailure: options?.onStreamFailure,
76
+ attribution: options?.attribution,
74
77
  });
75
78
  // TRANS-002 (issue #2480): `basePath` was declared and advertised but never read, so routes
76
79
  // always mounted at root. It is honored here — or absent, in which case the routes ARE the app.
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { createAgentRoutes } from './routes.js';
2
2
  export type { IAgentRoutesOptions, TSessionFactory } from './routes.js';
3
- export type { TStreamFailureListener } from './submit-stream.js';
3
+ export type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';
4
4
  export { createHttpTransport } from './http-transport.js';
5
5
  export type { IHttpTransport, IHttpTransportOptions } from './http-transport.js';
6
6
  export type { IHttpTransportSession } from './http-session.js';
package/src/routes.ts CHANGED
@@ -17,7 +17,7 @@ import { submitHandler } from './submit-route.js';
17
17
  import { createTurnClaims } from './turn-claims.js';
18
18
 
19
19
  import type { TSessionFactory } from './submit-route.js';
20
- import type { TStreamFailureListener } from './submit-stream.js';
20
+ import type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';
21
21
  import type {
22
22
  ITransportAdmission,
23
23
  ITransportAdmissionConfig,
@@ -44,6 +44,12 @@ export interface IAgentRoutesOptions {
44
44
  * for a host that has its own boundary in front. It just has to be written down.
45
45
  */
46
46
  admission: ITransportAdmissionConfig | ITransportAdmission;
47
+ /**
48
+ * Who `/submit` turns are attributed to — the driver and product surface the HOST assigns, as the
49
+ * WebSocket transport's `driverId`/`surface` do. Never taken from the request body. Absent leaves
50
+ * the session's own default.
51
+ */
52
+ attribution?: TTurnAttribution;
47
53
  }
48
54
 
49
55
  /**
@@ -57,7 +63,7 @@ export interface IAgentRoutesOptions {
57
63
  * ```
58
64
  */
59
65
  export function createAgentRoutes(options: IAgentRoutesOptions): Hono {
60
- const { sessionFactory, onStreamFailure } = options;
66
+ const { sessionFactory, onStreamFailure, attribution } = options;
61
67
  const app = new Hono();
62
68
 
63
69
  // RUNTIME-38: one turn at a time, per session — `turn-claims.ts` owns what that means and why it
@@ -98,7 +104,7 @@ export function createAgentRoutes(options: IAgentRoutesOptions): Hono {
98
104
  });
99
105
 
100
106
  // POST /submit — execute prompt, stream events via SSE
101
- app.post('/submit', submitHandler(sessionFactory, claims, onStreamFailure));
107
+ app.post('/submit', submitHandler(sessionFactory, claims, onStreamFailure, attribution));
102
108
 
103
109
  // POST /command — execute system command
104
110
  app.post('/command', async (c) => {
@@ -11,7 +11,7 @@ import { streamSSE } from 'hono/streaming';
11
11
  import { relayTurn } from './submit-stream.js';
12
12
 
13
13
  import type { IHttpTransportSession } from './http-session.js';
14
- import type { TStreamFailureListener } from './submit-stream.js';
14
+ import type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';
15
15
  import type { ITurnClaims } from './turn-claims.js';
16
16
  import type { Context } from 'hono';
17
17
 
@@ -105,6 +105,7 @@ export function submitHandler(
105
105
  sessionFactory: TSessionFactory,
106
106
  claims: ITurnClaims,
107
107
  onStreamFailure?: TStreamFailureListener,
108
+ attribution?: TTurnAttribution,
108
109
  ) {
109
110
  return async (c: Context) => {
110
111
  const session = await sessionFactory(c);
@@ -165,7 +166,10 @@ export function submitHandler(
165
166
  // Hono returns the Response synchronously and only the sync path is real — but review is
166
167
  // right that a version returning a rejecting promise would sail past a bare `return`, and
167
168
  // the cost of closing that class is one keyword.
168
- return await streamSSE(c, relayTurn(session, body.prompt, releaseOnce, onStreamFailure));
169
+ return await streamSSE(
170
+ c,
171
+ relayTurn(session, body.prompt, releaseOnce, onStreamFailure, attribution),
172
+ );
169
173
  } catch (error) {
170
174
  releaseOnce();
171
175
  throw error;
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { IHttpTransportSession } from './http-session.js';
11
11
  import type { SSEStreamingApi } from 'hono/streaming';
12
+ import type { ISubmitOptions } from '@robota-sdk/agent-interface-session';
12
13
 
13
14
  /**
14
15
  * Where the DETAIL of a post-headers stream failure goes — injected, never imported.
@@ -25,6 +26,13 @@ import type { SSEStreamingApi } from 'hono/streaming';
25
26
  */
26
27
  export type TStreamFailureListener = (error: Error) => void;
27
28
 
29
+ /**
30
+ * Who a submitted turn is attributed to, assigned by the HOST that composed the routes — never read
31
+ * from the request, because a client could otherwise claim to be the local operator or another
32
+ * surface in the usage record. Absent means the session's own default.
33
+ */
34
+ export type TTurnAttribution = Pick<ISubmitOptions, 'driverId' | 'surface'>;
35
+
28
36
  /**
29
37
  * Wire every relay subscription and the abort path; answer with the promise that settles when the
30
38
  * turn is over (or refused one).
@@ -154,6 +162,7 @@ export function relayTurn(
154
162
  prompt: string,
155
163
  release: () => void,
156
164
  onFailure?: TStreamFailureListener,
165
+ attribution?: TTurnAttribution,
157
166
  ): (stream: SSEStreamingApi) => Promise<void> {
158
167
  return async (stream) => {
159
168
  // NOTHING may escape this callback, and the reason is measured rather than stylistic. Hono's
@@ -169,7 +178,9 @@ export function relayTurn(
169
178
  const cleanup: Array<() => void> = [];
170
179
  try {
171
180
  const done = wireRelay(session, stream, cleanup, onFailure);
172
- await session.submit(prompt);
181
+ await (attribution === undefined
182
+ ? session.submit(prompt)
183
+ : session.submit(prompt, undefined, undefined, { ...attribution }));
173
184
  await done;
174
185
  } finally {
175
186
  // RUNTIME-14: teardown ALWAYS runs — on completion, error, OR client disconnect — so the