@robota-sdk/agent-transport-http 3.0.0-beta.78 → 3.0.0-beta.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The relay's own failure modes — the ones an HTTP request cannot conveniently reach.
3
+ *
4
+ * `reportStreamFailure` runs after the headers are out, and `onAbort` runs when the client is
5
+ * already gone; both are places where "what does the other side learn" and "does the claim come
6
+ * back" have to be asked directly.
7
+ */
8
+
9
+ import { describe, expect, it, vi } from 'vitest';
10
+
11
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
12
+
13
+ import { relayTurn, reportStreamFailure } from '../submit-stream.js';
14
+
15
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
16
+ import type { SSEStreamingApi } from 'hono/streaming';
17
+
18
+ function fakeStream(overrides: Partial<SSEStreamingApi> = {}): {
19
+ stream: SSEStreamingApi;
20
+ written: Array<{ event?: string; data: string }>;
21
+ } {
22
+ const written: Array<{ event?: string; data: string }> = [];
23
+ const stream = {
24
+ writeSSE: (message: { event?: string; data: string }) => {
25
+ written.push(message);
26
+ return Promise.resolve();
27
+ },
28
+ onAbort: () => {},
29
+ ...overrides,
30
+ } as unknown as SSEStreamingApi;
31
+ return { stream, written };
32
+ }
33
+
34
+ describe('what a client learns from a stream that failed after it opened', () => {
35
+ it('is a generic line, not the exception', async () => {
36
+ // Review, twice: this route can be mounted outside a trust boundary, and an exception ESCAPING
37
+ // the stream callback is not a message anything composed for a client — it can carry provider
38
+ // internals, paths, stack fragments. The detail goes to the package logger; the client learns
39
+ // that the stream failed, which is the part it can act on.
40
+ const { stream, written } = fakeStream();
41
+
42
+ await reportStreamFailure(new Error('ENOENT /home/op/.robota/providers/key.json'), stream);
43
+
44
+ expect(written).toHaveLength(1);
45
+ expect(written[0].data).not.toContain('key.json');
46
+ expect(written[0].data).toContain('the stream failed on the server');
47
+ });
48
+
49
+ it('swallows a write to a stream that is already gone', async () => {
50
+ const { stream } = fakeStream({
51
+ writeSSE: () => Promise.reject(new Error('closed')),
52
+ });
53
+
54
+ await expect(reportStreamFailure(new Error('x'), stream)).resolves.toBeUndefined();
55
+ });
56
+ });
57
+
58
+ describe('a failure in the callback never escapes to the runner', () => {
59
+ it('reports generically, hands the detail to the injected listener, and does not throw', async () => {
60
+ // Hono's runner follows any `onError` by writing the raw `e.message` to the stream — measured:
61
+ // the body carried the generic line AND the leak. So the boundary holds only if the callback
62
+ // catches its own failures, which is what this pins.
63
+ const detail = vi.fn();
64
+ const session = createTestInteractiveSession({
65
+ on: ((event: string) => {
66
+ if (event === 'text_delta') throw new Error('ENOENT /home/op/.robota/key.json');
67
+ }) as IInteractiveSession['on'],
68
+ });
69
+ const { stream, written } = fakeStream();
70
+
71
+ await expect(relayTurn(session, 'p', () => {}, detail)(stream)).resolves.toBeUndefined();
72
+
73
+ expect(detail).toHaveBeenCalledTimes(1);
74
+ expect(detail.mock.calls[0][0].message).toContain('key.json');
75
+ expect(written.map((w) => w.data).join('\n')).not.toContain('key.json');
76
+ expect(written.map((w) => w.data).join('\n')).toContain('the stream failed on the server');
77
+ });
78
+
79
+ it('still tells the client when the listener itself throws', async () => {
80
+ const { stream, written } = fakeStream();
81
+
82
+ await reportStreamFailure(new Error('detail'), stream, () => {
83
+ throw new Error('the host listener is broken');
84
+ });
85
+
86
+ expect(written).toHaveLength(1);
87
+ expect(written[0].data).toContain('the stream failed on the server');
88
+ });
89
+ });
90
+
91
+ describe('a client disconnect releases the claim even when abort throws', () => {
92
+ it('settles and releases', async () => {
93
+ // Review: before the claim registry this cost a listener leak; now an `abort()` that throws
94
+ // would hold the claim forever and 409 every future /submit to this session. `abort()` is typed
95
+ // as a synchronous void and the shipped one does not throw — this case is what turns that from
96
+ // a load-bearing assumption into an implementation detail.
97
+ const release = vi.fn();
98
+ // The conformant double, not a cast — the contract-cast ratchet refuses another one, and it is
99
+ // right to. `submit` resolves at once so the relay parks on `done`, which only the abort path
100
+ // settles here.
101
+ const session = createTestInteractiveSession({
102
+ abort: () => {
103
+ throw new Error('abort blew up');
104
+ },
105
+ });
106
+
107
+ let abortHandler!: () => void;
108
+ const { stream } = fakeStream({
109
+ onAbort: (handler: () => void) => {
110
+ abortHandler = handler;
111
+ },
112
+ });
113
+
114
+ const detail = vi.fn();
115
+ const relaying = relayTurn(session, 'prompt', release, detail)(stream);
116
+ // The abort handler runs inside Hono's abort dispatch, OUTSIDE the relay's try/catch — review
117
+ // traced where a rethrow lands (an unhandled rejection, not a reported failure). So it does not
118
+ // throw: the detail goes to the injected listener and the relay still settles.
119
+ expect(() => abortHandler()).not.toThrow();
120
+ await relaying;
121
+
122
+ expect(release).toHaveBeenCalledTimes(1);
123
+ expect(detail).toHaveBeenCalledTimes(1);
124
+ expect(detail.mock.calls[0][0].message).toBe('abort blew up');
125
+ });
126
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * RUNTIME-38 — the claim registry's own contract.
3
+ *
4
+ * It was exercised only through `routes.test.ts`, which asks HTTP questions and gets HTTP answers.
5
+ * Review asked for these directly, and the reason is the one this repository keeps rediscovering: a
6
+ * branch reached only through a wrapper is a branch whose behaviour is inferred. `keyFor` has three
7
+ * outcomes and two of them are about a session that misbehaves — the shapes an HTTP case would have
8
+ * to build a broken session to reach at all.
9
+ */
10
+
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
14
+
15
+ import { createTurnClaims } from '../turn-claims.js';
16
+
17
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
18
+
19
+ /**
20
+ * A session that names itself however the case needs it to.
21
+ *
22
+ * Built on the PUBLISHED conformant double rather than cast to the contract — `contract-cast-ratchet`
23
+ * refuses another cast, and it is right to: a cast is a partial re-implementation nothing checks
24
+ * against the real interface, so a member the contract gains later is silently absent from it.
25
+ */
26
+ function sessionNamed(id: string): IInteractiveSession {
27
+ return createTestInteractiveSession({
28
+ getSession: () => ({ getSessionId: () => id }) as ReturnType<IInteractiveSession['getSession']>,
29
+ });
30
+ }
31
+
32
+ describe('the key a session is claimed under', () => {
33
+ it('is the id the session declares', () => {
34
+ expect(createTurnClaims().keyFor(sessionNamed('session_a'))).toBe('session_a');
35
+ });
36
+
37
+ it('does NOT depend on object identity', () => {
38
+ // The defect the id-keying replaced: a `sessionFactory` returning a fresh wrapper per call for
39
+ // one logical session made every request look unclaimed, and the guard was silently absent.
40
+ const claims = createTurnClaims();
41
+
42
+ expect(claims.keyFor(sessionNamed('same'))).toBe(claims.keyFor(sessionNamed('same')));
43
+ });
44
+
45
+ it('is undefined when the session names itself with an empty string', () => {
46
+ // Not `''`, which is a key: every unnameable session would collide into it and 409 each other.
47
+ expect(createTurnClaims().keyFor(sessionNamed(''))).toBeUndefined();
48
+ });
49
+
50
+ it('is undefined when the session THROWS while naming itself', () => {
51
+ const throwing = createTestInteractiveSession({
52
+ getSession: () => {
53
+ throw new Error('no session bound');
54
+ },
55
+ });
56
+
57
+ expect(createTurnClaims().keyFor(throwing)).toBeUndefined();
58
+ });
59
+ });
60
+
61
+ describe('holding and releasing a claim', () => {
62
+ it('reports a key as held only between hold and release', () => {
63
+ const claims = createTurnClaims();
64
+
65
+ expect(claims.isHeld('a')).toBe(false);
66
+ claims.hold('a');
67
+ expect(claims.isHeld('a')).toBe(true);
68
+ claims.release('a');
69
+ expect(claims.isHeld('a')).toBe(false);
70
+ });
71
+
72
+ it('holds each session separately', () => {
73
+ // PER SESSION, not one flag for the router: a busy neighbour is not a reason to refuse you, and
74
+ // a single flag made one tenant's turn refuse every other tenant's.
75
+ const claims = createTurnClaims();
76
+
77
+ claims.hold('tenant_a');
78
+
79
+ expect(claims.isHeld('tenant_a')).toBe(true);
80
+ expect(claims.isHeld('tenant_b')).toBe(false);
81
+ });
82
+
83
+ it('releases a key that was never held without complaint', () => {
84
+ // The teardown path runs in a `finally` and cannot know whether the claim was taken — a throw
85
+ // there would replace the error being unwound with one about bookkeeping.
86
+ const claims = createTurnClaims();
87
+
88
+ expect(() => claims.release('never-held')).not.toThrow();
89
+ });
90
+
91
+ it('is idempotent on a second hold, so one release still frees it', () => {
92
+ // The route holds once per request and releases once per request. Stating it here means a
93
+ // future counting implementation has to decide deliberately rather than by accident.
94
+ const claims = createTurnClaims();
95
+
96
+ claims.hold('a');
97
+ claims.hold('a');
98
+ claims.release('a');
99
+
100
+ expect(claims.isHeld('a')).toBe(false);
101
+ });
102
+ });
@@ -0,0 +1,20 @@
1
+ import type {
2
+ ISessionCommands,
3
+ ISessionConversationRead,
4
+ ISessionEvents,
5
+ ISessionExecutionState,
6
+ ISessionIdentity,
7
+ ISessionTurnControl,
8
+ ISessionTurnSubmission,
9
+ } from '@robota-sdk/agent-interface-session';
10
+
11
+ /** The exact session capabilities consumed by the public HTTP transport. */
12
+ export interface IHttpTransportSession
13
+ extends
14
+ ISessionTurnSubmission,
15
+ ISessionEvents,
16
+ ISessionTurnControl,
17
+ ISessionIdentity,
18
+ ISessionCommands,
19
+ ISessionConversationRead,
20
+ ISessionExecutionState {}
@@ -5,37 +5,88 @@
5
5
  * while exposing the underlying Hono app via getApp().
6
6
  */
7
7
 
8
+ import { resolveAdmission } from '@robota-sdk/agent-transport/node';
9
+ import { Hono } from 'hono';
10
+
8
11
  import { createAgentRoutes } from './routes.js';
9
12
 
10
- import type { IInteractiveSession, ITransportAdapter } from '@robota-sdk/agent-interface-transport';
11
- import type { Hono } from 'hono';
13
+ import type { IHttpTransportSession } from './http-session.js';
14
+ import type { TStreamFailureListener } from './submit-stream.js';
15
+ import type {
16
+ ITransportAdapter,
17
+ ITransportAdmissionConfig,
18
+ ITransportLifecycleError,
19
+ } from '@robota-sdk/agent-interface-transport';
12
20
 
13
21
  export interface IHttpTransportOptions {
14
22
  /** Optional: base path prefix for routes. */
15
23
  basePath?: string;
24
+ /**
25
+ * SEC-008: what a peer must present. Omitted means SECURE — a credential is minted and
26
+ * `getAdmissionToken()` returns it for the host to hand to its client.
27
+ *
28
+ * This transport previously had no gate at all, so omitting the option used to mean "anyone may
29
+ * execute anything". Omission now means the safe thing, and running open takes saying so.
30
+ */
31
+ admission?: ITransportAdmissionConfig;
32
+ /**
33
+ * Where the DETAIL of a post-headers stream failure goes — forwarded to `createAgentRoutes`.
34
+ * Review found this seam missing: the option existed on the routes and not on the entry point
35
+ * README and the examples actually use, so "the host decides the destination" was a decision no
36
+ * host on this path could make. Same forwarding rule as `admission`, which sat beside it.
37
+ */
38
+ onStreamFailure?: TStreamFailureListener;
39
+ }
40
+
41
+ export interface IHttpTransport extends ITransportAdapter<IHttpTransportSession> {
42
+ getApp(): Hono;
43
+ getAdmissionToken(): string | null;
16
44
  }
17
45
 
18
- export function createHttpTransport(
19
- options?: IHttpTransportOptions,
20
- ): ITransportAdapter<IInteractiveSession> & { getApp(): Hono } {
21
- let session: IInteractiveSession | null = null;
46
+ export function createHttpTransport(options?: IHttpTransportOptions): IHttpTransport {
47
+ let session: IHttpTransportSession | null = null;
22
48
  let app: Hono | null = null;
49
+ // Resolved at CONSTRUCTION, not at start: a host needs the credential to hand to its client, and
50
+ // a transport that cannot mint one must fail before anything is served.
51
+ const admission = resolveAdmission(options?.admission);
52
+ const lifecycleError = (code: ITransportLifecycleError['code']): ITransportLifecycleError =>
53
+ Object.assign(new Error(`HTTP transport ${code}.`), {
54
+ name: 'TransportLifecycleError' as const,
55
+ code,
56
+ transportName: 'http',
57
+ });
23
58
 
24
59
  return {
25
60
  name: 'http',
26
- attach(s: IInteractiveSession) {
61
+ lifecycle: Object.freeze({ kind: 'service' }),
62
+ attach(s: IHttpTransportSession) {
27
63
  session = s;
28
64
  },
29
65
  async start() {
30
- if (!session) throw new Error('No session attached. Call attach() first.');
31
- app = createAgentRoutes({ sessionFactory: () => session! });
66
+ if (!session) throw lifecycleError('not-attached');
67
+ if (app) throw lifecycleError('already-started');
68
+ const routes = createAgentRoutes({
69
+ sessionFactory: () => session!,
70
+ // The decision already made, passed through — not taken apart and rebuilt for the routes to
71
+ // resolve a second time.
72
+ admission,
73
+ onStreamFailure: options?.onStreamFailure,
74
+ });
75
+ // TRANS-002 (issue #2480): `basePath` was declared and advertised but never read, so routes
76
+ // always mounted at root. It is honored here — or absent, in which case the routes ARE the app.
77
+ app = options?.basePath ? new Hono().route(options.basePath, routes) : routes;
32
78
  },
33
79
  async stop() {
34
80
  app = null;
81
+ session = null;
35
82
  },
36
83
  getApp() {
37
84
  if (!app) throw new Error('Transport not started. Call start() first.');
38
85
  return app;
39
86
  },
87
+ /** The credential this transport requires, or `null` when the host explicitly opened it. */
88
+ getAdmissionToken() {
89
+ return admission.token;
90
+ },
40
91
  };
41
92
  }
package/src/index.ts CHANGED
@@ -1,4 +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
4
  export { createHttpTransport } from './http-transport.js';
4
- export type { IHttpTransportOptions } from './http-transport.js';
5
+ export type { IHttpTransport, IHttpTransportOptions } from './http-transport.js';
6
+ export type { IHttpTransportSession } from './http-session.js';
package/src/routes.ts CHANGED
@@ -1,22 +1,49 @@
1
1
  /**
2
- * HTTP transport adapter — exposes IInteractiveSession over REST API.
2
+ * HTTP transport adapter — exposes the declared session capabilities over REST API.
3
3
  *
4
4
  * Built on Hono for Cloudflare Workers + Node.js + AWS Lambda compatibility.
5
- * Each endpoint maps 1:1 to an IInteractiveSession API method.
5
+ * Exposes the core session methods (a subset; background-task, job-group, and
6
+ * execution-workspace methods are WS-only).
6
7
  */
7
8
 
9
+ import {
10
+ bearerCredential,
11
+ credentialMatches,
12
+ resolveAdmission,
13
+ } from '@robota-sdk/agent-transport/node';
8
14
  import { Hono } from 'hono';
9
- import { streamSSE } from 'hono/streaming';
10
15
 
11
- import type { IInteractiveSession } from '@robota-sdk/agent-interface-transport';
12
- import type { Context } from 'hono';
16
+ import { submitHandler } from './submit-route.js';
17
+ import { createTurnClaims } from './turn-claims.js';
13
18
 
14
- /** Callback that resolves an IInteractiveSession from the request context. */
15
- export type TSessionFactory = (c: Context) => IInteractiveSession | Promise<IInteractiveSession>;
19
+ import type { TSessionFactory } from './submit-route.js';
20
+ import type { TStreamFailureListener } from './submit-stream.js';
21
+ import type {
22
+ ITransportAdmission,
23
+ ITransportAdmissionConfig,
24
+ } from '@robota-sdk/agent-interface-transport';
25
+
26
+ // Re-exported so existing importers keep their path; the definition and its rationale live with
27
+ // the code that enforces them, in `submit-route.ts`.
28
+ export type { TSessionFactory } from './submit-route.js';
16
29
 
17
30
  export interface IAgentRoutesOptions {
18
- /** Resolve an IInteractiveSession per request (e.g., by auth token, session ID). */
31
+ /** Resolve an HTTP session port per request (e.g., by auth token, session ID). */
19
32
  sessionFactory: TSessionFactory;
33
+ /**
34
+ * Where the DETAIL of a post-headers stream failure goes (the client gets only a generic line —
35
+ * see `submit-stream.ts`). Injected per the side-concern rule: the host decides the destination,
36
+ * and absent means the host chose to drop it.
37
+ */
38
+ onStreamFailure?: TStreamFailureListener;
39
+ /**
40
+ * SEC-008: what a peer must present to reach the session. REQUIRED — there is no shape of this
41
+ * option that means "I did not think about it", which is the state these routes shipped in.
42
+ *
43
+ * `{ open: true, openReason: '…' }` still runs with no credential, and that is a legitimate answer
44
+ * for a host that has its own boundary in front. It just has to be written down.
45
+ */
46
+ admission: ITransportAdmissionConfig | ITransportAdmission;
20
47
  }
21
48
 
22
49
  /**
@@ -30,70 +57,49 @@ export interface IAgentRoutesOptions {
30
57
  * ```
31
58
  */
32
59
  export function createAgentRoutes(options: IAgentRoutesOptions): Hono {
33
- const { sessionFactory } = options;
60
+ const { sessionFactory, onStreamFailure } = options;
34
61
  const app = new Hono();
35
62
 
36
- // POST /submit — execute prompt, stream events via SSE
37
- app.post('/submit', async (c) => {
38
- const session = await sessionFactory(c);
39
- const body = await c.req.json<{ prompt: string }>();
40
-
41
- if (!body.prompt || typeof body.prompt !== 'string') {
42
- return c.json({ error: 'prompt is required' }, 400);
63
+ // RUNTIME-38: one turn at a time, per session — `turn-claims.ts` owns what that means and why it
64
+ // is keyed by the session's declared ID rather than by object identity.
65
+ const claims = createTurnClaims();
66
+
67
+ // SEC-008: resolved ONCE, at construction, so a transport that cannot mint a credential fails to
68
+ // build rather than serving without one. Resolving per request would also mint a new token per
69
+ // request, which no peer could ever present.
70
+ // Either shape goes in: `resolveAdmission` is idempotent, so an already-resolved admission comes
71
+ // back unchanged and a config is resolved. `http-transport.ts` therefore does not have to take
72
+ // its resolved admission apart and rebuild a config for this to resolve again — two resolutions
73
+ // of one decision, and a mint on the second if the first had opened without a reason (review).
74
+ //
75
+ // This used to pick between the two with `'token' in options.admission`, and review showed that
76
+ // cannot work: BOTH interfaces declare a `token`, so the shapes differ only by VALUE. A config of
77
+ // `{ token: '' }` — documented as "mint a fresh one" — was read as pre-resolved and installed the
78
+ // EMPTY STRING as the required credential, which a peer sending an empty bearer would match. The
79
+ // discriminator is gone rather than repaired; there is nothing here left to get wrong.
80
+ const admission = resolveAdmission(options.admission);
81
+
82
+ /**
83
+ * The trust boundary, installed BEFORE every route rather than checked inside each one.
84
+ *
85
+ * Before this, `POST /submit` reached `session.submit` and `POST /command` reached
86
+ * `session.executeCommand` with nothing in between — remote arbitrary execution with no gate, and
87
+ * an unauthenticated request looked exactly like an authorised one in both directions.
88
+ *
89
+ * A missing credential and a wrong one get the same answer, deliberately: telling them apart tells
90
+ * a caller which half they got right.
91
+ */
92
+ app.use('*', async (c, next) => {
93
+ if (admission.token === null) return next();
94
+ if (credentialMatches(admission.token, bearerCredential(c.req.header('authorization')))) {
95
+ return next();
43
96
  }
44
-
45
- return streamSSE(c, async (stream) => {
46
- const cleanup: Array<() => void> = [];
47
-
48
- const subscribe = <T>(event: string, handler: (data: T) => void): void => {
49
- session.on(event as 'text_delta', handler as () => void);
50
- cleanup.push(() => session.off(event as 'text_delta', handler as () => void));
51
- };
52
-
53
- let completed = false;
54
- const done = new Promise<void>((resolve) => {
55
- subscribe('text_delta', (delta: string) => {
56
- stream.writeSSE({ event: 'text_delta', data: JSON.stringify({ delta }) });
57
- });
58
-
59
- subscribe('tool_start', (state) => {
60
- stream.writeSSE({ event: 'tool_start', data: JSON.stringify(state) });
61
- });
62
-
63
- subscribe('tool_end', (state) => {
64
- stream.writeSSE({ event: 'tool_end', data: JSON.stringify(state) });
65
- });
66
-
67
- subscribe('thinking', (isThinking: boolean) => {
68
- stream.writeSSE({ event: 'thinking', data: JSON.stringify({ isThinking }) });
69
- if (!isThinking && completed) {
70
- resolve();
71
- }
72
- });
73
-
74
- subscribe('complete', (result) => {
75
- completed = true;
76
- stream.writeSSE({ event: 'complete', data: JSON.stringify(result) });
77
- });
78
-
79
- subscribe('interrupted', (result) => {
80
- completed = true;
81
- stream.writeSSE({ event: 'interrupted', data: JSON.stringify(result) });
82
- });
83
-
84
- subscribe('error', (error: Error) => {
85
- completed = true;
86
- stream.writeSSE({ event: 'error', data: JSON.stringify({ message: error.message }) });
87
- });
88
- });
89
-
90
- await session.submit(body.prompt);
91
- await done;
92
-
93
- for (const fn of cleanup) fn();
94
- });
97
+ return c.json({ error: 'unauthorized' }, 401);
95
98
  });
96
99
 
100
+ // POST /submit — execute prompt, stream events via SSE
101
+ app.post('/submit', submitHandler(sessionFactory, claims, onStreamFailure));
102
+
97
103
  // POST /command — execute system command
98
104
  app.post('/command', async (c) => {
99
105
  const session = await sessionFactory(c);
@@ -103,7 +109,11 @@ export function createAgentRoutes(options: IAgentRoutesOptions): Hono {
103
109
  return c.json({ error: 'name is required' }, 400);
104
110
  }
105
111
 
106
- const result = await session.executeCommand(body.name, body.args ?? '');
112
+ // SEC-008: 'remote', not the default 'user'. A peer over HTTP is not the person at the keyboard,
113
+ // and defaulting to the local operator both mis-attributes the call and skips the 'remote' policy
114
+ // seam that exists to treat the two differently. Admission decided WHO may reach the session; it
115
+ // does not say who they are. (The MCP adapter had the same defect; this is its sibling.)
116
+ const result = await session.executeCommand(body.name, body.args ?? '', 'remote');
107
117
  if (!result) {
108
118
  return c.json({ error: `Unknown command: ${body.name}` }, 404);
109
119
  }
@@ -137,9 +147,16 @@ export function createAgentRoutes(options: IAgentRoutesOptions): Hono {
137
147
  });
138
148
 
139
149
  // GET /executing — check if currently executing
150
+ //
151
+ // Reports the SAME "busy" that `/submit` refuses on: the route's own claim OR the session's
152
+ // `isExecuting()`. Asking only the session lets a client polling here see `executing: false` and
153
+ // still get a 409 from `/submit`, and two endpoints disagreeing about one word is a worse answer
154
+ // than either of them alone.
140
155
  app.get('/executing', async (c) => {
141
156
  const session = await sessionFactory(c);
142
- return c.json({ executing: session.isExecuting() });
157
+ const claim = claims.keyFor(session);
158
+ const claimed = claim !== undefined && claims.isHeld(claim);
159
+ return c.json({ executing: claimed || session.isExecuting() });
143
160
  });
144
161
 
145
162
  // GET /pending — get pending queued prompt