@nextclaw/ncp-http-agent-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NextClaw contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @nextclaw/ncp-http-agent-server
2
+
3
+ HTTP/SSE transport adapter for exposing an `NcpAgentClientEndpoint` over Hono routes.
4
+
5
+ ## Build
6
+
7
+ ```bash
8
+ pnpm -C packages/nextclaw-ncp-http-agent-server build
9
+ ```
10
+
11
+ ## API
12
+
13
+ - `createNcpHttpAgentRouter(options)` — options require `agentClientEndpoint: NcpAgentClientEndpoint`
14
+ - `mountNcpHttpAgentRoutes(app, options)`
15
+
16
+ **Options:**
17
+ - `agentClientEndpoint` — client endpoint to forward requests to (in-process adapter or remote HTTP client)
18
+ - `replayProvider` (optional) — When set, `/reconnect` replays stored events instead of forwarding to the agent. Scenario: user reconnects after network drop and wants to continue watching the previous reply; with replayProvider we replay from persistence, without it we forward to the agent.
19
+ - `basePath`, `requestTimeoutMs` — path and timeout
20
+
21
+ For in-process agent (`NcpAgentServerEndpoint`), use `createAgentClientFromServer` from `@nextclaw/ncp` to wrap it.
@@ -0,0 +1,71 @@
1
+ import { NcpResumeRequestPayload, NcpEndpointEvent, NcpAgentClientEndpoint } from '@nextclaw/ncp';
2
+ import { Hono } from 'hono';
3
+
4
+ /**
5
+ * Replays stored session events for `/reconnect`.
6
+ *
7
+ * **Scenario**: User sends a message, agent streams SSE back. Network drops mid-stream.
8
+ * User reconnects and requests `GET /reconnect?sessionId=xxx&remoteRunId=yyy` to
9
+ * "continue watching the previous reply".
10
+ *
11
+ * **Two paths**:
12
+ * - **Replay** (with replayProvider): Do not call agent. Fetch that run's events
13
+ * (message.accepted, message.text-delta, message.completed, etc.) from persistence
14
+ * and stream them in order. Use when you have session/event storage and want to
15
+ * avoid re-running the agent.
16
+ * - **Forward** (no replayProvider): Forward `message.resume-request` to the agent
17
+ * and let it recover or re-run.
18
+ *
19
+ * **Implementation**: `stream` fetches events by payload.sessionId and payload.remoteRunId
20
+ * from your storage and yields them in order.
21
+ */
22
+ type NcpHttpAgentReplayProvider = {
23
+ stream(params: {
24
+ payload: NcpResumeRequestPayload;
25
+ signal: AbortSignal;
26
+ }): AsyncIterable<NcpEndpointEvent>;
27
+ };
28
+ type NcpHttpAgentServerOptions = {
29
+ /** Client endpoint to forward requests to (in-process adapter or remote HTTP client). */
30
+ agentClientEndpoint: NcpAgentClientEndpoint;
31
+ basePath?: string;
32
+ requestTimeoutMs?: number;
33
+ /**
34
+ * Optional. When set, `/reconnect` replays stored events instead of forwarding to the agent.
35
+ * When not set, forwards `message.resume-request` to the agent.
36
+ */
37
+ replayProvider?: NcpHttpAgentReplayProvider;
38
+ };
39
+
40
+ /** Framework-agnostic HTTP handler interface for NCP agent routes. */
41
+ interface NcpHttpAgentHandler {
42
+ handleSend(request: Request): Promise<Response>;
43
+ handleReconnect(request: Request): Promise<Response>;
44
+ handleAbort(request: Request): Promise<Response>;
45
+ }
46
+ type NcpHttpAgentHandlerOptions = {
47
+ agentClientEndpoint: NcpAgentClientEndpoint;
48
+ /**
49
+ * Optional. When set, `/reconnect` replays stored events instead of forwarding to the agent.
50
+ * See NcpHttpAgentReplayProvider for details.
51
+ */
52
+ replayProvider?: NcpHttpAgentReplayProvider;
53
+ timeoutMs: number;
54
+ };
55
+
56
+ /**
57
+ * Framework-agnostic controller for NCP agent HTTP routes.
58
+ * Forwards /send and /reconnect to agentClientEndpoint; /reconnect uses replayProvider when set.
59
+ */
60
+ declare class NcpHttpAgentController implements NcpHttpAgentHandler {
61
+ private readonly options;
62
+ constructor(options: NcpHttpAgentHandlerOptions);
63
+ handleSend(request: Request): Promise<Response>;
64
+ handleReconnect(request: Request): Promise<Response>;
65
+ handleAbort(request: Request): Promise<Response>;
66
+ }
67
+
68
+ declare function createNcpHttpAgentRouter(options: NcpHttpAgentServerOptions): Hono;
69
+ declare function mountNcpHttpAgentRoutes(app: Hono, options: NcpHttpAgentServerOptions): void;
70
+
71
+ export { NcpHttpAgentController, type NcpHttpAgentHandler, type NcpHttpAgentHandlerOptions, type NcpHttpAgentReplayProvider, type NcpHttpAgentServerOptions, createNcpHttpAgentRouter, mountNcpHttpAgentRoutes };
package/dist/index.js ADDED
@@ -0,0 +1,431 @@
1
+ // src/types.ts
2
+ var DEFAULT_BASE_PATH = "/ncp/agent";
3
+ var DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
4
+
5
+ // src/parsers.ts
6
+ function normalizeBasePath(basePath) {
7
+ const raw = (basePath ?? DEFAULT_BASE_PATH).trim();
8
+ if (!raw) {
9
+ return DEFAULT_BASE_PATH;
10
+ }
11
+ const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`;
12
+ return withLeadingSlash.endsWith("/") ? withLeadingSlash.slice(0, -1) : withLeadingSlash;
13
+ }
14
+ function sanitizeTimeout(timeoutMs) {
15
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
16
+ return DEFAULT_REQUEST_TIMEOUT_MS;
17
+ }
18
+ return Math.max(1e3, Math.trunc(timeoutMs));
19
+ }
20
+ async function parseRequestEnvelope(request) {
21
+ try {
22
+ const payload = await request.json();
23
+ if (!isRecord(payload)) {
24
+ return null;
25
+ }
26
+ if (typeof payload.sessionId !== "string" || !payload.sessionId.trim()) {
27
+ return null;
28
+ }
29
+ if (!isRecord(payload.message)) {
30
+ return null;
31
+ }
32
+ return payload;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+ function parseResumePayloadFromUrl(url) {
38
+ const query = new URL(url).searchParams;
39
+ const sessionId = query.get("sessionId")?.trim();
40
+ const remoteRunId = query.get("remoteRunId")?.trim();
41
+ if (!sessionId || !remoteRunId) {
42
+ return null;
43
+ }
44
+ const fromEventIndexRaw = query.get("fromEventIndex");
45
+ const fromEventIndex = typeof fromEventIndexRaw === "string" && Number.isFinite(Number.parseInt(fromEventIndexRaw, 10)) ? Math.max(0, Number.parseInt(fromEventIndexRaw, 10)) : void 0;
46
+ return {
47
+ sessionId,
48
+ remoteRunId,
49
+ ...typeof fromEventIndex === "number" ? { fromEventIndex } : {}
50
+ };
51
+ }
52
+ async function parseAbortPayload(request) {
53
+ try {
54
+ const payload = await request.json();
55
+ if (!isRecord(payload)) {
56
+ return {};
57
+ }
58
+ const messageId = readTrimmedString(payload.messageId);
59
+ const correlationId = readTrimmedString(payload.correlationId);
60
+ const runId = readTrimmedString(payload.runId);
61
+ return {
62
+ ...messageId ? { messageId } : {},
63
+ ...correlationId ? { correlationId } : {},
64
+ ...runId ? { runId } : {}
65
+ };
66
+ } catch {
67
+ return {};
68
+ }
69
+ }
70
+ function readTrimmedString(value) {
71
+ return typeof value === "string" ? value.trim() : "";
72
+ }
73
+ function isRecord(value) {
74
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
75
+ }
76
+
77
+ // src/scope.ts
78
+ function extractScopeFromEvent(event) {
79
+ const payload = readPayload(event);
80
+ if (!payload) {
81
+ return {};
82
+ }
83
+ const runId = readString(payload.runId) ?? readString(payload.remoteRunId);
84
+ return {
85
+ sessionId: readString(payload.sessionId),
86
+ correlationId: readString(payload.correlationId),
87
+ runId
88
+ };
89
+ }
90
+ function matchesScope(scope, event) {
91
+ const eventScope = extractScopeFromEvent(event);
92
+ if (eventScope.sessionId && eventScope.sessionId !== scope.sessionId) {
93
+ return false;
94
+ }
95
+ if (scope.correlationId && eventScope.correlationId && eventScope.correlationId !== scope.correlationId) {
96
+ return false;
97
+ }
98
+ if (scope.runId && eventScope.runId && eventScope.runId !== scope.runId) {
99
+ return false;
100
+ }
101
+ return true;
102
+ }
103
+ function isTerminalEvent(event) {
104
+ switch (event.type) {
105
+ case "message.completed":
106
+ case "message.failed":
107
+ case "run.finished":
108
+ case "run.error":
109
+ case "message.abort":
110
+ return true;
111
+ default:
112
+ return false;
113
+ }
114
+ }
115
+ function readPayload(event) {
116
+ if (!("payload" in event)) {
117
+ return null;
118
+ }
119
+ return isRecord2(event.payload) ? event.payload : null;
120
+ }
121
+ function readString(value) {
122
+ return typeof value === "string" && value ? value : void 0;
123
+ }
124
+ function isRecord2(value) {
125
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
126
+ }
127
+
128
+ // src/sse-stream.ts
129
+ function createSseEventStream(events, signal) {
130
+ const encoder = new TextEncoder();
131
+ return new ReadableStream({
132
+ start: async (controller) => {
133
+ let closed = false;
134
+ const close = () => {
135
+ if (closed) {
136
+ return;
137
+ }
138
+ closed = true;
139
+ controller.close();
140
+ };
141
+ const onAbort = () => {
142
+ close();
143
+ };
144
+ signal?.addEventListener("abort", onAbort, { once: true });
145
+ try {
146
+ for await (const event of events) {
147
+ if (closed || signal?.aborted) {
148
+ break;
149
+ }
150
+ controller.enqueue(encoder.encode(toSseFrame(event.event, event.data)));
151
+ }
152
+ } finally {
153
+ signal?.removeEventListener("abort", onAbort);
154
+ close();
155
+ }
156
+ }
157
+ });
158
+ }
159
+ function buildSseResponse(stream) {
160
+ return new Response(stream, {
161
+ status: 200,
162
+ headers: {
163
+ "Content-Type": "text/event-stream; charset=utf-8",
164
+ "Cache-Control": "no-cache, no-transform",
165
+ Connection: "keep-alive",
166
+ "X-Accel-Buffering": "no"
167
+ }
168
+ });
169
+ }
170
+ function toSseFrame(eventName, data) {
171
+ return `event: ${eventName}
172
+ data: ${JSON.stringify(data)}
173
+
174
+ `;
175
+ }
176
+ function toNcpEventFrame(event) {
177
+ return {
178
+ event: "ncp-event",
179
+ data: event
180
+ };
181
+ }
182
+ function toErrorFrame(code, message) {
183
+ return {
184
+ event: "error",
185
+ data: {
186
+ code,
187
+ message
188
+ }
189
+ };
190
+ }
191
+
192
+ // src/async-queue.ts
193
+ function createAsyncQueue() {
194
+ const items = [];
195
+ let closed = false;
196
+ let pendingResolve = null;
197
+ const iterable = {
198
+ [Symbol.asyncIterator]() {
199
+ return {
200
+ next: () => {
201
+ if (items.length > 0) {
202
+ return Promise.resolve({
203
+ value: items.shift(),
204
+ done: false
205
+ });
206
+ }
207
+ if (closed) {
208
+ return Promise.resolve({
209
+ value: void 0,
210
+ done: true
211
+ });
212
+ }
213
+ return new Promise((resolve) => {
214
+ pendingResolve = resolve;
215
+ });
216
+ }
217
+ };
218
+ }
219
+ };
220
+ return {
221
+ push(item) {
222
+ if (closed) {
223
+ return;
224
+ }
225
+ if (pendingResolve) {
226
+ const resolve = pendingResolve;
227
+ pendingResolve = null;
228
+ resolve({ value: item, done: false });
229
+ return;
230
+ }
231
+ items.push(item);
232
+ },
233
+ close() {
234
+ if (closed) {
235
+ return;
236
+ }
237
+ closed = true;
238
+ if (pendingResolve) {
239
+ const resolve = pendingResolve;
240
+ pendingResolve = null;
241
+ resolve({
242
+ value: void 0,
243
+ done: true
244
+ });
245
+ }
246
+ },
247
+ iterable
248
+ };
249
+ }
250
+
251
+ // src/utils.ts
252
+ function errorMessage(error) {
253
+ if (error instanceof Error && error.message) {
254
+ return error.message;
255
+ }
256
+ return String(error ?? "Unknown error");
257
+ }
258
+ function jsonResponse(body, status = 200) {
259
+ return new Response(JSON.stringify(body), {
260
+ status,
261
+ headers: { "Content-Type": "application/json" }
262
+ });
263
+ }
264
+
265
+ // src/stream-handlers.ts
266
+ function createForwardResponse(options) {
267
+ const { requestSignal } = options;
268
+ return buildSseResponse(
269
+ createSseEventStream(createForwardSseEvents(options), requestSignal)
270
+ );
271
+ }
272
+ async function* createForwardSseEvents(options) {
273
+ const { endpoint, requestEvent, requestSignal, timeoutMs, scope } = options;
274
+ const queue = createAsyncQueue();
275
+ let timeoutId = null;
276
+ let unsubscribe = null;
277
+ let stopped = false;
278
+ const push = (frame) => {
279
+ if (!stopped) {
280
+ queue.push(frame);
281
+ }
282
+ };
283
+ const stop = () => {
284
+ if (stopped) {
285
+ return;
286
+ }
287
+ stopped = true;
288
+ if (timeoutId) {
289
+ clearTimeout(timeoutId);
290
+ timeoutId = null;
291
+ }
292
+ if (unsubscribe) {
293
+ unsubscribe();
294
+ unsubscribe = null;
295
+ }
296
+ requestSignal.removeEventListener("abort", stop);
297
+ queue.close();
298
+ };
299
+ requestSignal.addEventListener("abort", stop, { once: true });
300
+ timeoutId = setTimeout(() => {
301
+ push(toErrorFrame("TIMEOUT", "NCP HTTP stream timed out before terminal event."));
302
+ stop();
303
+ }, timeoutMs);
304
+ unsubscribe = endpoint.subscribe((event) => {
305
+ if (!matchesScope(scope, event)) {
306
+ return;
307
+ }
308
+ push(toNcpEventFrame(event));
309
+ if (isTerminalEvent(event)) {
310
+ stop();
311
+ }
312
+ });
313
+ void endpoint.emit(requestEvent).catch((error) => {
314
+ push(toErrorFrame("EMIT_FAILED", errorMessage(error)));
315
+ stop();
316
+ });
317
+ try {
318
+ for await (const frame of queue.iterable) {
319
+ yield frame;
320
+ }
321
+ } finally {
322
+ stop();
323
+ }
324
+ }
325
+ function createReplayResponse(options) {
326
+ const { signal } = options;
327
+ return buildSseResponse(
328
+ createSseEventStream(createReplaySseEvents(options), signal)
329
+ );
330
+ }
331
+ async function* createReplaySseEvents(options) {
332
+ const { replayProvider, payload, signal } = options;
333
+ try {
334
+ for await (const event of replayProvider.stream({ payload, signal })) {
335
+ if (signal.aborted) {
336
+ break;
337
+ }
338
+ yield toNcpEventFrame(event);
339
+ if (isTerminalEvent(event)) {
340
+ break;
341
+ }
342
+ }
343
+ } catch (error) {
344
+ yield toErrorFrame("REPLAY_FAILED", errorMessage(error));
345
+ }
346
+ }
347
+
348
+ // src/controller.ts
349
+ var NcpHttpAgentController = class {
350
+ constructor(options) {
351
+ this.options = options;
352
+ }
353
+ async handleSend(request) {
354
+ const { agentClientEndpoint, timeoutMs } = this.options;
355
+ const envelope = await parseRequestEnvelope(request);
356
+ if (!envelope) {
357
+ return jsonResponse(
358
+ { ok: false, error: { code: "INVALID_BODY", message: "Invalid NCP request envelope." } },
359
+ 400
360
+ );
361
+ }
362
+ return createForwardResponse({
363
+ endpoint: agentClientEndpoint,
364
+ requestEvent: { type: "message.request", payload: envelope },
365
+ requestSignal: request.signal,
366
+ timeoutMs,
367
+ scope: {
368
+ sessionId: envelope.sessionId,
369
+ correlationId: envelope.correlationId
370
+ }
371
+ });
372
+ }
373
+ async handleReconnect(request) {
374
+ const { agentClientEndpoint, replayProvider, timeoutMs } = this.options;
375
+ const resumePayload = parseResumePayloadFromUrl(request.url);
376
+ if (!resumePayload) {
377
+ return jsonResponse(
378
+ { ok: false, error: { code: "INVALID_QUERY", message: "sessionId and remoteRunId are required." } },
379
+ 400
380
+ );
381
+ }
382
+ if (replayProvider) {
383
+ return createReplayResponse({
384
+ replayProvider,
385
+ payload: resumePayload,
386
+ signal: request.signal
387
+ });
388
+ }
389
+ return createForwardResponse({
390
+ endpoint: agentClientEndpoint,
391
+ requestEvent: { type: "message.resume-request", payload: resumePayload },
392
+ requestSignal: request.signal,
393
+ timeoutMs,
394
+ scope: {
395
+ sessionId: resumePayload.sessionId,
396
+ runId: resumePayload.remoteRunId
397
+ }
398
+ });
399
+ }
400
+ async handleAbort(request) {
401
+ const { agentClientEndpoint } = this.options;
402
+ const payload = await parseAbortPayload(request);
403
+ await agentClientEndpoint.abort(payload);
404
+ return jsonResponse({ ok: true });
405
+ }
406
+ };
407
+
408
+ // src/router.ts
409
+ import { Hono } from "hono";
410
+ function createNcpHttpAgentRouter(options) {
411
+ const app = new Hono();
412
+ mountNcpHttpAgentRoutes(app, options);
413
+ return app;
414
+ }
415
+ function mountNcpHttpAgentRoutes(app, options) {
416
+ const { basePath: rawBasePath, agentClientEndpoint, replayProvider, requestTimeoutMs } = options;
417
+ const basePath = normalizeBasePath(rawBasePath);
418
+ const controller = new NcpHttpAgentController({
419
+ agentClientEndpoint,
420
+ replayProvider,
421
+ timeoutMs: sanitizeTimeout(requestTimeoutMs)
422
+ });
423
+ app.post(`${basePath}/send`, (c) => controller.handleSend(c.req.raw));
424
+ app.get(`${basePath}/reconnect`, (c) => controller.handleReconnect(c.req.raw));
425
+ app.post(`${basePath}/abort`, (c) => controller.handleAbort(c.req.raw));
426
+ }
427
+ export {
428
+ NcpHttpAgentController,
429
+ createNcpHttpAgentRouter,
430
+ mountNcpHttpAgentRoutes
431
+ };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@nextclaw/ncp-http-agent-server",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "HTTP/SSE server transport adapter for NCP agent endpoints.",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "development": "./src/index.ts",
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "dependencies": {
18
+ "hono": "^4.9.8",
19
+ "@nextclaw/ncp": "0.1.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^20.17.6",
23
+ "@typescript-eslint/eslint-plugin": "^7.18.0",
24
+ "@typescript-eslint/parser": "^7.18.0",
25
+ "eslint": "^8.57.1",
26
+ "eslint-config-prettier": "^9.1.0",
27
+ "prettier": "^3.3.3",
28
+ "tsup": "^8.3.5",
29
+ "typescript": "^5.6.3",
30
+ "vitest": "^2.1.2"
31
+ },
32
+ "scripts": {
33
+ "build": "tsup src/index.ts --format esm --dts --out-dir dist",
34
+ "lint": "eslint .",
35
+ "tsc": "tsc -p tsconfig.json",
36
+ "test": "vitest run"
37
+ }
38
+ }