@0xmaxma/claude-gateway 1.8.2 → 1.8.3
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/README.md +7 -2
- package/config.template.json +4 -0
- package/dist/agent/dreaming/config.d.ts +6 -0
- package/dist/agent/dreaming/config.d.ts.map +1 -1
- package/dist/agent/dreaming/config.js +17 -24
- package/dist/agent/dreaming/config.js.map +1 -1
- package/dist/agent/knowledge/config.d.ts.map +1 -1
- package/dist/agent/knowledge/config.js +7 -13
- package/dist/agent/knowledge/config.js.map +1 -1
- package/dist/agent/runner.d.ts +87 -13
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +342 -117
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/turn-stream.d.ts +202 -0
- package/dist/agent/turn-stream.d.ts.map +1 -0
- package/dist/agent/turn-stream.js +322 -0
- package/dist/agent/turn-stream.js.map +1 -0
- package/dist/api/apps-router.d.ts.map +1 -1
- package/dist/api/apps-router.js +14 -2
- package/dist/api/apps-router.js.map +1 -1
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +2 -3
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/api/line-webhook-router.d.ts +43 -1
- package/dist/api/line-webhook-router.d.ts.map +1 -1
- package/dist/api/line-webhook-router.js +286 -38
- package/dist/api/line-webhook-router.js.map +1 -1
- package/dist/api/router.d.ts +7 -1
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +328 -114
- package/dist/api/router.js.map +1 -1
- package/dist/apps/agent-manager.d.ts.map +1 -1
- package/dist/apps/agent-manager.js +4 -1
- package/dist/apps/agent-manager.js.map +1 -1
- package/dist/apps/installer.d.ts +75 -2
- package/dist/apps/installer.d.ts.map +1 -1
- package/dist/apps/installer.js +198 -13
- package/dist/apps/installer.js.map +1 -1
- package/dist/config/agent-env.d.ts +41 -0
- package/dist/config/agent-env.d.ts.map +1 -0
- package/dist/config/agent-env.js +154 -0
- package/dist/config/agent-env.js.map +1 -0
- package/dist/config/loader.d.ts +33 -2
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +33 -9
- package/dist/config/loader.js.map +1 -1
- package/dist/config/watcher.d.ts +1 -0
- package/dist/config/watcher.d.ts.map +1 -1
- package/dist/config/watcher.js +17 -1
- package/dist/config/watcher.js.map +1 -1
- package/dist/index.js +24 -51
- package/dist/index.js.map +1 -1
- package/dist/load-dotenv.d.ts +17 -0
- package/dist/load-dotenv.d.ts.map +1 -1
- package/dist/load-dotenv.js +32 -9
- package/dist/load-dotenv.js.map +1 -1
- package/dist/session/store.d.ts +9 -0
- package/dist/session/store.d.ts.map +1 -1
- package/dist/session/store.js +12 -0
- package/dist/session/store.js.map +1 -1
- package/dist/shell/bypass-dialog.d.ts +142 -0
- package/dist/shell/bypass-dialog.d.ts.map +1 -0
- package/dist/shell/bypass-dialog.js +207 -0
- package/dist/shell/bypass-dialog.js.map +1 -0
- package/dist/shell/claude-pty-shell.js +42 -3
- package/dist/shell/claude-pty-shell.js.map +1 -1
- package/dist/shell/screen.d.ts +7 -0
- package/dist/shell/screen.d.ts.map +1 -1
- package/dist/shell/screen.js +10 -1
- package/dist/shell/screen.js.map +1 -1
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/config-num.d.ts +22 -0
- package/dist/utils/config-num.d.ts.map +1 -0
- package/dist/utils/config-num.js +34 -0
- package/dist/utils/config-num.js.map +1 -0
- package/package.json +1 -1
package/dist/api/router.js
CHANGED
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.isValidSessionId = isValidSessionId;
|
|
37
37
|
exports.isValidAgentId = isValidAgentId;
|
|
38
|
+
exports.openSseStream = openSseStream;
|
|
38
39
|
exports.createApiRouter = createApiRouter;
|
|
39
40
|
const express_1 = require("express");
|
|
40
41
|
const crypto_1 = require("crypto");
|
|
@@ -44,6 +45,8 @@ const os = __importStar(require("os"));
|
|
|
44
45
|
const path = __importStar(require("path"));
|
|
45
46
|
const child_process_1 = require("child_process");
|
|
46
47
|
const runner_1 = require("../agent/runner");
|
|
48
|
+
const turn_stream_1 = require("../agent/turn-stream");
|
|
49
|
+
const agent_env_1 = require("../config/agent-env");
|
|
47
50
|
const auth_1 = require("./auth");
|
|
48
51
|
const media_store_1 = require("../history/media-store");
|
|
49
52
|
const db_1 = require("../history/db");
|
|
@@ -96,6 +99,149 @@ function isValidSessionId(v) {
|
|
|
96
99
|
function isValidAgentId(v) {
|
|
97
100
|
return typeof v === 'string' && AGENT_ID_RE.test(v);
|
|
98
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* The one place a turn's stream events become SSE frames (#421).
|
|
104
|
+
*
|
|
105
|
+
* All three streaming producers — POST /messages, the cross-channel
|
|
106
|
+
* chat-session POST, and POST /greeting — plus the re-attach GET below go
|
|
107
|
+
* through this, so the buffering/replay path has exactly one implementation
|
|
108
|
+
* instead of the three hand-rolled copies it replaced.
|
|
109
|
+
*
|
|
110
|
+
* `seq` is the event's per-turn sequence number; a client feeds the last one it
|
|
111
|
+
* saw back as `after_seq` to resume. It is absent only for events emitted
|
|
112
|
+
* outside a turn (built-in commands answered locally), where there is nothing
|
|
113
|
+
* to resume onto.
|
|
114
|
+
*/
|
|
115
|
+
function createSseCallbacks(res, meta) {
|
|
116
|
+
// `request_id` correlates a frame with the POST that started the turn. The
|
|
117
|
+
// resume endpoint fills it in from the turn record when the reconnecting
|
|
118
|
+
// client could not name one, so every frame that belongs to a turn carries it;
|
|
119
|
+
// the field is omitted only when there is genuinely no turn id to report
|
|
120
|
+
// (events emitted outside a turn), never filled with a stand-in.
|
|
121
|
+
const ids = () => ({
|
|
122
|
+
...(meta.requestId ? { request_id: meta.requestId } : {}),
|
|
123
|
+
session_id: meta.sessionId,
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
onChunk: (event, seq) => {
|
|
127
|
+
try {
|
|
128
|
+
res.write(`data: ${JSON.stringify({ ...event, seq })}\n\n`);
|
|
129
|
+
}
|
|
130
|
+
catch { /* client gone */ }
|
|
131
|
+
},
|
|
132
|
+
onDone: (fullText, attachments, seq) => {
|
|
133
|
+
try {
|
|
134
|
+
const frame = {
|
|
135
|
+
type: 'result',
|
|
136
|
+
text: fullText,
|
|
137
|
+
seq,
|
|
138
|
+
...ids(),
|
|
139
|
+
duration_ms: Date.now() - meta.startTime,
|
|
140
|
+
};
|
|
141
|
+
if (attachments?.length)
|
|
142
|
+
frame['attachments'] = attachments;
|
|
143
|
+
res.write(`data: ${JSON.stringify(frame)}\n\n`);
|
|
144
|
+
res.write('data: [DONE]\n\n');
|
|
145
|
+
}
|
|
146
|
+
catch { /* client gone */ }
|
|
147
|
+
// Always close: a throw mid-frame must not leave the response half-open,
|
|
148
|
+
// which would hang a client that is waiting for [DONE].
|
|
149
|
+
finally {
|
|
150
|
+
try {
|
|
151
|
+
res.end();
|
|
152
|
+
}
|
|
153
|
+
catch { /* client gone */ }
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
onError: (err, seq) => {
|
|
157
|
+
try {
|
|
158
|
+
// `code` lets a client separate the hard cap (TIMEOUT — the turn was
|
|
159
|
+
// interrupted, do not wait for it) from a crash (PROCESS_EXITED) or a
|
|
160
|
+
// transport failure, instead of string-matching `message`.
|
|
161
|
+
const code = (0, turn_stream_1.errorCode)(err);
|
|
162
|
+
res.write(`data: ${JSON.stringify({ type: 'error', message: err.message, ...(code ? { code } : {}), seq, ...ids() })}\n\n`);
|
|
163
|
+
}
|
|
164
|
+
catch { /* client gone */ }
|
|
165
|
+
finally {
|
|
166
|
+
try {
|
|
167
|
+
res.end();
|
|
168
|
+
}
|
|
169
|
+
catch { /* client gone */ }
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
// Another connection resumed this turn — hand the stream over and close
|
|
173
|
+
// this socket rather than leaving it hanging for a frame that will never come.
|
|
174
|
+
onDisplaced: () => { try {
|
|
175
|
+
res.end();
|
|
176
|
+
}
|
|
177
|
+
catch { /* already gone */ } },
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* How often an idle SSE stream emits a comment frame. Well under the 60s
|
|
182
|
+
* `proxy_read_timeout` nginx and Caddy default to.
|
|
183
|
+
*/
|
|
184
|
+
const SSE_KEEPALIVE_MS = 15000;
|
|
185
|
+
/**
|
|
186
|
+
* Write SSE headers and start the idle keepalive. Exported for tests: the
|
|
187
|
+
* keepalive is time-based and only observable on a stream deliberately held
|
|
188
|
+
* open, which is exactly what an end-to-end HTTP test cannot do.
|
|
189
|
+
*/
|
|
190
|
+
function openSseStream(res) {
|
|
191
|
+
// Every caller reaches here after at least one `await` (a session-exists
|
|
192
|
+
// check, reading GREETING.md), so the client may already be gone — and then
|
|
193
|
+
// 'close' has fired before the listeners below exist and nothing would ever
|
|
194
|
+
// clear the interval. Bail before writing to a socket that is not there.
|
|
195
|
+
if (res.writableEnded || res.destroyed)
|
|
196
|
+
return;
|
|
197
|
+
res.writeHead(200, {
|
|
198
|
+
'Content-Type': 'text/event-stream',
|
|
199
|
+
'Cache-Control': 'no-cache',
|
|
200
|
+
'Connection': 'keep-alive',
|
|
201
|
+
'X-Accel-Buffering': 'no',
|
|
202
|
+
});
|
|
203
|
+
res.flushHeaders();
|
|
204
|
+
res.socket?.setNoDelay(true);
|
|
205
|
+
// A turn can go minutes without producing an event — a long Bash call, a slow
|
|
206
|
+
// model — and a reverse proxy reads that silence as a dead connection and
|
|
207
|
+
// closes it. #421 makes that recoverable, not free: the client still has to
|
|
208
|
+
// notice and re-attach. A comment frame (`:` prefix) is discarded by every
|
|
209
|
+
// SSE parser per spec, so this keeps the socket warm without reaching the
|
|
210
|
+
// client's event handler or consuming a `seq`.
|
|
211
|
+
let keepalive;
|
|
212
|
+
const stop = () => { if (keepalive !== undefined) {
|
|
213
|
+
clearInterval(keepalive);
|
|
214
|
+
keepalive = undefined;
|
|
215
|
+
} };
|
|
216
|
+
keepalive = setInterval(() => {
|
|
217
|
+
// A tick can land in the window between `res.end()` and 'finish' — arbitrarily
|
|
218
|
+
// wide when the reader is slow, since the body has to drain first. Writing
|
|
219
|
+
// there is a write-after-end, which http does NOT throw synchronously: it
|
|
220
|
+
// emits 'error' on the response a tick later, so `try/catch` never sees it
|
|
221
|
+
// and an unhandled one takes the whole gateway down through the
|
|
222
|
+
// uncaughtException handler in index.ts. Check the state instead.
|
|
223
|
+
if (res.writableEnded || res.destroyed) {
|
|
224
|
+
stop();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
res.write(': keepalive\n\n');
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
stop();
|
|
232
|
+
}
|
|
233
|
+
}, SSE_KEEPALIVE_MS);
|
|
234
|
+
// unref so a forgotten stream cannot by itself hold the process (or a test
|
|
235
|
+
// runner) open; the listeners below are the real cleanup.
|
|
236
|
+
keepalive.unref?.();
|
|
237
|
+
res.on('close', stop);
|
|
238
|
+
res.on('finish', stop);
|
|
239
|
+
// Last line of defence for the same class of failure: any asynchronous write
|
|
240
|
+
// error on this response — from the keepalive above or from a frame written by
|
|
241
|
+
// createSseCallbacks — is a dead client, not a reason to exit(1). An 'error'
|
|
242
|
+
// listener is what keeps the emit from becoming an uncaught exception.
|
|
243
|
+
res.on('error', stop);
|
|
244
|
+
}
|
|
99
245
|
function maskToken(token) {
|
|
100
246
|
if (token.length <= 12)
|
|
101
247
|
return '•'.repeat(token.length);
|
|
@@ -401,8 +547,24 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
|
|
|
401
547
|
// by the upstream provider, not the local config list.
|
|
402
548
|
const modelStr = typeof requestModel === 'string' ? requestModel.trim() : undefined;
|
|
403
549
|
const requestId = (0, crypto_1.randomUUID)();
|
|
404
|
-
const sessionId = session_id ?? (0, crypto_1.randomUUID)();
|
|
405
550
|
const chatIdStr = chat_id.trim();
|
|
551
|
+
// A client-supplied session_id resumes an existing session and nothing else.
|
|
552
|
+
// It used to be minted on the spot when unknown, so a typo quietly forked a
|
|
553
|
+
// second session named "Session N" instead of continuing the intended one —
|
|
554
|
+
// and since API.md has always documented this field as "resume an existing
|
|
555
|
+
// session", the silent create was never the contract anyone was promised.
|
|
556
|
+
//
|
|
557
|
+
// The lookup is scoped to this chat's index (api-{chatId}), so it doubles as
|
|
558
|
+
// the scope check: a session belonging to another chat is simply not found.
|
|
559
|
+
if (session_id !== undefined && !(await runner.apiSessionExists(chatIdStr, session_id))) {
|
|
560
|
+
res.status(404).json({
|
|
561
|
+
code: 'SESSION_NOT_FOUND',
|
|
562
|
+
error: `No session '${session_id}' in chat '${chatIdStr}'`,
|
|
563
|
+
hint: 'Create it with POST /v1/agents/:agentId/sessions, or omit session_id to start a new one.',
|
|
564
|
+
});
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const sessionId = session_id ?? (0, crypto_1.randomUUID)();
|
|
406
568
|
const startTime = Date.now();
|
|
407
569
|
const timeoutMs = typeof timeout_ms === 'number' && timeout_ms > 0 && timeout_ms <= 600000
|
|
408
570
|
? timeout_ms
|
|
@@ -416,47 +578,12 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
|
|
|
416
578
|
// SSE streaming mode
|
|
417
579
|
let onClientDisconnect;
|
|
418
580
|
try {
|
|
419
|
-
const sseCallbacks = {
|
|
420
|
-
onChunk: (event) => {
|
|
421
|
-
try {
|
|
422
|
-
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
423
|
-
}
|
|
424
|
-
catch { /* client gone */ }
|
|
425
|
-
},
|
|
426
|
-
onDone: (fullText, attachments) => {
|
|
427
|
-
try {
|
|
428
|
-
const resultEvent = { type: 'result', text: fullText, request_id: requestId, session_id: sessionId, duration_ms: Date.now() - startTime };
|
|
429
|
-
if (attachments.length)
|
|
430
|
-
resultEvent['attachments'] = attachments;
|
|
431
|
-
res.write(`data: ${JSON.stringify(resultEvent)}\n\n`);
|
|
432
|
-
res.write('data: [DONE]\n\n');
|
|
433
|
-
res.end();
|
|
434
|
-
}
|
|
435
|
-
catch { /* client gone */ }
|
|
436
|
-
},
|
|
437
|
-
onError: (err) => {
|
|
438
|
-
try {
|
|
439
|
-
res.write(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`);
|
|
440
|
-
res.end();
|
|
441
|
-
}
|
|
442
|
-
catch { /* client gone */ }
|
|
443
|
-
},
|
|
444
|
-
};
|
|
445
|
-
const openSseStream = () => {
|
|
446
|
-
res.writeHead(200, {
|
|
447
|
-
'Content-Type': 'text/event-stream',
|
|
448
|
-
'Cache-Control': 'no-cache',
|
|
449
|
-
'Connection': 'keep-alive',
|
|
450
|
-
'X-Accel-Buffering': 'no',
|
|
451
|
-
});
|
|
452
|
-
res.flushHeaders();
|
|
453
|
-
};
|
|
581
|
+
const sseCallbacks = createSseCallbacks(res, { requestId, sessionId, startTime });
|
|
454
582
|
// Built-in command — emit its response through the same SSE callbacks as a
|
|
455
583
|
// normal reply. Commands bypass the 409 preflight (they must work mid-session,
|
|
456
584
|
// e.g. /stop) and never reach Claude.
|
|
457
585
|
if (isBuiltinCommand) {
|
|
458
|
-
openSseStream();
|
|
459
|
-
res.socket?.setNoDelay(true);
|
|
586
|
+
openSseStream(res);
|
|
460
587
|
try {
|
|
461
588
|
const { responseText } = await runner.executeApiCommand(sessionId, chatIdStr, trimmedMessage, { skipPersist: skipUserMessage, model: modelStr });
|
|
462
589
|
sseCallbacks.onChunk({ type: 'text_delta', text: responseText });
|
|
@@ -472,12 +599,13 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
|
|
|
472
599
|
res.status(409).json({ error: 'Session already has a pending request' });
|
|
473
600
|
return;
|
|
474
601
|
}
|
|
475
|
-
openSseStream();
|
|
476
|
-
res.socket?.setNoDelay(true);
|
|
602
|
+
openSseStream(res);
|
|
477
603
|
const agentCfg = agentConfigs.get(agentId);
|
|
478
604
|
const allowTools = agentCfg.allow_tools ?? !!apiKey.allow_tools;
|
|
479
|
-
onClientDisconnect = await runner.sendApiMessageStream(sessionId, chatIdStr, trimmedMessage, sseCallbacks, { timeoutMs, allowTools, mediaFiles: validatedMediaFiles, model: modelStr, skipUserMessage, imageParams: validatedImageParams });
|
|
480
|
-
// Client disconnect —
|
|
605
|
+
onClientDisconnect = await runner.sendApiMessageStream(sessionId, chatIdStr, trimmedMessage, sseCallbacks, { timeoutMs, allowTools, mediaFiles: validatedMediaFiles, model: modelStr, skipUserMessage, imageParams: validatedImageParams, requestId });
|
|
606
|
+
// Client disconnect — detaches this connection's sink. The turn keeps
|
|
607
|
+
// running and keeps buffering, so the client can resume it on a new
|
|
608
|
+
// connection via GET …/sessions/:sessionId/stream?after_seq= (#421).
|
|
481
609
|
res.on('close', onClientDisconnect);
|
|
482
610
|
}
|
|
483
611
|
catch (err) {
|
|
@@ -533,7 +661,10 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
|
|
|
533
661
|
}
|
|
534
662
|
catch (err) {
|
|
535
663
|
const code = err.code;
|
|
536
|
-
if (code === 'TIMEOUT') {
|
|
664
|
+
if (code === 'TIMEOUT' || code === 'TIMEOUT_SOFT') {
|
|
665
|
+
// Both are 504 on this endpoint: the caller waited as long as it
|
|
666
|
+
// agreed to and got nothing. They differ only in what happens to the
|
|
667
|
+
// turn afterwards, which a synchronous caller cannot observe.
|
|
537
668
|
res.status(504).json({ error: 'Agent response timeout' });
|
|
538
669
|
}
|
|
539
670
|
else if (code === 'CONFLICT') {
|
|
@@ -768,9 +899,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
|
|
|
768
899
|
// Wizard API — stateful multi-step agent creation
|
|
769
900
|
// ──────────────────────────────────────────────────────────────
|
|
770
901
|
function getAgentsBaseDir() {
|
|
771
|
-
return configPath
|
|
772
|
-
? path.join(path.dirname(configPath), 'agents')
|
|
773
|
-
: path.join(os.homedir(), '.claude-gateway', 'agents');
|
|
902
|
+
return (0, agent_env_1.agentsDirForConfig)(configPath);
|
|
774
903
|
}
|
|
775
904
|
function getTelegramStateDir(agentId) {
|
|
776
905
|
const agentsBase = getAgentsBaseDir();
|
|
@@ -2771,39 +2900,12 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
|
|
|
2771
2900
|
}
|
|
2772
2901
|
const senderName = typeof body.senderName === 'string' ? body.senderName : undefined;
|
|
2773
2902
|
let cleanup;
|
|
2903
|
+
const requestId = (0, crypto_1.randomUUID)();
|
|
2904
|
+
const startTime = Date.now();
|
|
2774
2905
|
try {
|
|
2775
|
-
const sseCallbacks = {
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
2779
|
-
}
|
|
2780
|
-
catch { /* client gone */ }
|
|
2781
|
-
},
|
|
2782
|
-
onDone: (fullText) => {
|
|
2783
|
-
try {
|
|
2784
|
-
res.write(`data: ${JSON.stringify({ type: 'result', text: fullText, session_id: sessionId })}\n\n`);
|
|
2785
|
-
res.write('data: [DONE]\n\n');
|
|
2786
|
-
res.end();
|
|
2787
|
-
}
|
|
2788
|
-
catch { /* client gone */ }
|
|
2789
|
-
},
|
|
2790
|
-
onError: (err) => {
|
|
2791
|
-
try {
|
|
2792
|
-
res.write(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`);
|
|
2793
|
-
res.end();
|
|
2794
|
-
}
|
|
2795
|
-
catch { /* client gone */ }
|
|
2796
|
-
},
|
|
2797
|
-
};
|
|
2798
|
-
res.writeHead(200, {
|
|
2799
|
-
'Content-Type': 'text/event-stream',
|
|
2800
|
-
'Cache-Control': 'no-cache',
|
|
2801
|
-
'Connection': 'keep-alive',
|
|
2802
|
-
'X-Accel-Buffering': 'no',
|
|
2803
|
-
});
|
|
2804
|
-
res.flushHeaders();
|
|
2805
|
-
res.socket?.setNoDelay(true);
|
|
2806
|
-
cleanup = await runner.sendMessageToSession(rawChatId, source, sessionId, content.trim(), senderName, sseCallbacks, { timeoutMs: DEFAULT_TIMEOUT_MS });
|
|
2906
|
+
const sseCallbacks = createSseCallbacks(res, { requestId, sessionId, startTime });
|
|
2907
|
+
openSseStream(res);
|
|
2908
|
+
cleanup = await runner.sendMessageToSession(rawChatId, source, sessionId, content.trim(), senderName, sseCallbacks, { timeoutMs: DEFAULT_TIMEOUT_MS, requestId });
|
|
2807
2909
|
res.on('close', cleanup);
|
|
2808
2910
|
}
|
|
2809
2911
|
catch (err) {
|
|
@@ -3430,6 +3532,125 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
|
|
|
3430
3532
|
res.status(500).json({ error: err.message });
|
|
3431
3533
|
}
|
|
3432
3534
|
});
|
|
3535
|
+
/**
|
|
3536
|
+
* GET /api/v1/agents/:agentId/sessions/:sessionId/stream?after_seq=N&request_id=…
|
|
3537
|
+
*
|
|
3538
|
+
* Re-attach to a session's current turn (#421). Replays every buffered event
|
|
3539
|
+
* after `after_seq` — omit it to replay the turn from its first event — then
|
|
3540
|
+
* keeps streaming live events on the same connection, terminating with the
|
|
3541
|
+
* same `result` + `[DONE]` frames the original connection would have got.
|
|
3542
|
+
*
|
|
3543
|
+
* Attaching is never a conflict: `409` still means "you tried to start a
|
|
3544
|
+
* second turn", never "you tried to resume the first one". A turn that is
|
|
3545
|
+
* gone (never existed, or past its replay grace window) answers `410`, which
|
|
3546
|
+
* tells the client to read history instead.
|
|
3547
|
+
*
|
|
3548
|
+
* Unlike the sibling session routes this needs no `chat_id`: the turn buffer
|
|
3549
|
+
* is keyed by session id, and a client resuming after a reload may well have
|
|
3550
|
+
* nothing but the session id left.
|
|
3551
|
+
*/
|
|
3552
|
+
router.get('/v1/agents/:agentId/sessions/:sessionId/stream', auth, (req, res) => {
|
|
3553
|
+
const { agentId, sessionId } = req.params;
|
|
3554
|
+
const apiKey = req.apiKey;
|
|
3555
|
+
if (!(0, auth_1.canAccessAgent)(apiKey, agentId)) {
|
|
3556
|
+
res.status(403).json({ error: `API key has no access to agent '${agentId}'` });
|
|
3557
|
+
return;
|
|
3558
|
+
}
|
|
3559
|
+
const runner = agentRunners.get(agentId);
|
|
3560
|
+
if (!runner) {
|
|
3561
|
+
res.status(404).json({ error: `Agent '${agentId}' not found` });
|
|
3562
|
+
return;
|
|
3563
|
+
}
|
|
3564
|
+
if (!isValidSessionId(sessionId)) {
|
|
3565
|
+
res.status(400).json({ error: 'session_id must be 1-64 alphanumeric characters, hyphens, or underscores' });
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
const rawAfterSeq = req.query['after_seq'];
|
|
3569
|
+
const afterSeq = rawAfterSeq === undefined || rawAfterSeq === '' ? 0 : Number(rawAfterSeq);
|
|
3570
|
+
if (!Number.isInteger(afterSeq) || afterSeq < 0) {
|
|
3571
|
+
res.status(400).json({ error: 'after_seq must be a non-negative integer' });
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
const requestId = typeof req.query['request_id'] === 'string' ? req.query['request_id'] : undefined;
|
|
3575
|
+
// A cursor is only meaningful relative to the turn that produced it, and
|
|
3576
|
+
// seq numbering restarts at 1 for every turn. attach() rejects a cursor that
|
|
3577
|
+
// runs *past* the current turn's last event (CURSOR_AHEAD), but a stale
|
|
3578
|
+
// cursor from turn N lands harmlessly *inside* turn N+1 whenever N+1 has
|
|
3579
|
+
// already emitted that many events — and then replays N+1's tail as if the
|
|
3580
|
+
// client had been watching it all along, with the whole earlier part of that
|
|
3581
|
+
// turn silently missing. Only `request_id` distinguishes the two, so
|
|
3582
|
+
// resuming mid-turn requires it. `after_seq=0` still does not: replaying
|
|
3583
|
+
// whichever turn is current from its first event is well defined either way.
|
|
3584
|
+
if (afterSeq > 0 && !requestId) {
|
|
3585
|
+
res.status(400).json({
|
|
3586
|
+
error: 'request_id is required when after_seq > 0',
|
|
3587
|
+
hint: 'Retry without after_seq to replay the current turn from its first event.',
|
|
3588
|
+
});
|
|
3589
|
+
return;
|
|
3590
|
+
}
|
|
3591
|
+
// The SSE headers must not go out until the attach is known to succeed —
|
|
3592
|
+
// otherwise a 410 would arrive as a half-open text/event-stream. attach()
|
|
3593
|
+
// replays synchronously, so the first replayed event opens the stream and
|
|
3594
|
+
// an attach that fails never writes at all.
|
|
3595
|
+
let opened = false;
|
|
3596
|
+
const ensureOpen = () => { if (!opened) {
|
|
3597
|
+
opened = true;
|
|
3598
|
+
openSseStream(res);
|
|
3599
|
+
} };
|
|
3600
|
+
// Both halves of the frame metadata come from the turn itself, and must be
|
|
3601
|
+
// read before attach() — attach replays synchronously into the sink built
|
|
3602
|
+
// from them.
|
|
3603
|
+
// • `duration_ms` on a replayed terminal frame means the age of the *turn*,
|
|
3604
|
+
// which is what the original connection would have reported. Timing from
|
|
3605
|
+
// the reconnect would make a turn resumed near its end look instant.
|
|
3606
|
+
// • `request_id` is echoed from the turn when the client did not name one.
|
|
3607
|
+
// A client that reloaded has no token, and the endpoint demands one for
|
|
3608
|
+
// any later `after_seq > 0` — so omitting it here would strand that
|
|
3609
|
+
// client on replay-from-zero forever.
|
|
3610
|
+
const turn = runner.turnStreamInfo(sessionId);
|
|
3611
|
+
const callbacks = createSseCallbacks(res, {
|
|
3612
|
+
requestId: requestId ?? turn?.requestId,
|
|
3613
|
+
sessionId,
|
|
3614
|
+
startTime: turn?.startedAt ?? Date.now(),
|
|
3615
|
+
});
|
|
3616
|
+
const inner = (0, turn_stream_1.callbackSink)({
|
|
3617
|
+
onChunk: (event, seq) => { ensureOpen(); callbacks.onChunk(event, seq); },
|
|
3618
|
+
onDone: (text, attachments, seq) => { ensureOpen(); callbacks.onDone(text, attachments, seq); },
|
|
3619
|
+
onError: (err, seq) => { ensureOpen(); callbacks.onError(err, seq); },
|
|
3620
|
+
onDisplaced: callbacks.onDisplaced,
|
|
3621
|
+
});
|
|
3622
|
+
const attached = runner.attachTurnStream(sessionId, inner, { afterSeq, requestId });
|
|
3623
|
+
if (!attached.ok) {
|
|
3624
|
+
const body = {
|
|
3625
|
+
gone: { code: 'TURN_GONE', error: 'No resumable turn for this session', hint: 'Read the session history instead.' },
|
|
3626
|
+
mismatch: { code: 'TURN_MISMATCH', error: 'That request_id is not the session\'s current turn', hint: 'Read the session history instead.' },
|
|
3627
|
+
// Recoverable without history, like CURSOR_AHEAD: only the cursor is
|
|
3628
|
+
// unusable. Dropping it replays whatever the bounded buffer still holds
|
|
3629
|
+
// — the first frame's `seq` says how much came before it — and ends with
|
|
3630
|
+
// the terminal `result`, which carries the turn's full text.
|
|
3631
|
+
truncated: {
|
|
3632
|
+
code: 'TURN_TRUNCATED',
|
|
3633
|
+
error: 'Buffered events at that cursor have been evicted',
|
|
3634
|
+
hint: 'Retry without after_seq to replay this turn from the oldest event still buffered.',
|
|
3635
|
+
},
|
|
3636
|
+
// Recoverable without history, unlike its three siblings: the turn is
|
|
3637
|
+
// live, only the cursor is stale. Say so, or a client follows the
|
|
3638
|
+
// generic hint and abandons a stream it could still have joined.
|
|
3639
|
+
ahead: {
|
|
3640
|
+
code: 'CURSOR_AHEAD',
|
|
3641
|
+
error: 'after_seq is past this turn\'s last event — the cursor belongs to an earlier turn',
|
|
3642
|
+
hint: 'Retry without after_seq to replay this turn from its first event.',
|
|
3643
|
+
},
|
|
3644
|
+
}[attached.reason];
|
|
3645
|
+
res.status(410).json(body);
|
|
3646
|
+
return;
|
|
3647
|
+
}
|
|
3648
|
+
// A turn still in flight with the client already at the head replays
|
|
3649
|
+
// nothing, so open the stream explicitly rather than leaving the client
|
|
3650
|
+
// waiting on headers until the next live event.
|
|
3651
|
+
ensureOpen();
|
|
3652
|
+
res.on('close', attached.detach);
|
|
3653
|
+
});
|
|
3433
3654
|
// POST /api/v1/agents/:agentId/greeting — stream a proactive welcome from GREETING.md into an existing session
|
|
3434
3655
|
router.post('/v1/agents/:agentId/greeting', auth, async (req, res) => {
|
|
3435
3656
|
const { agentId } = req.params;
|
|
@@ -3453,10 +3674,30 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
|
|
|
3453
3674
|
res.status(400).json({ error: 'session_id must be 1-64 alphanumeric characters, hyphens, or underscores' });
|
|
3454
3675
|
return;
|
|
3455
3676
|
}
|
|
3456
|
-
// chat_id is
|
|
3457
|
-
//
|
|
3458
|
-
//
|
|
3459
|
-
|
|
3677
|
+
// chat_id is required: it names the historyDb bucket (api-{chatId}) the greeting
|
|
3678
|
+
// lands in. It used to fall back to sessionId, which quietly filed the greeting
|
|
3679
|
+
// under a bucket of its own — a second index the real chat never reads, so the
|
|
3680
|
+
// greeting vanished from history while still consuming the session.
|
|
3681
|
+
const chatId = typeof body.chat_id === 'string' ? body.chat_id.trim() : '';
|
|
3682
|
+
if (!chatId) {
|
|
3683
|
+
res.status(400).json({ error: 'chat_id is required and must be a non-empty string' });
|
|
3684
|
+
return;
|
|
3685
|
+
}
|
|
3686
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(chatId)) {
|
|
3687
|
+
res.status(400).json({ error: 'chat_id must be 1-64 alphanumeric characters, hyphens, or underscores' });
|
|
3688
|
+
return;
|
|
3689
|
+
}
|
|
3690
|
+
// Same rule as POST /messages: session_id names an existing session, it does not
|
|
3691
|
+
// mint one. Greeting has no "omit to start fresh" branch at all — the session is
|
|
3692
|
+
// always pre-created by POST /sessions — so an unknown id is unambiguously wrong.
|
|
3693
|
+
if (!(await runner.apiSessionExists(chatId, sessionId))) {
|
|
3694
|
+
res.status(404).json({
|
|
3695
|
+
code: 'SESSION_NOT_FOUND',
|
|
3696
|
+
error: `No session '${sessionId}' in chat '${chatId}'`,
|
|
3697
|
+
hint: 'Create it with POST /v1/agents/:agentId/sessions first.',
|
|
3698
|
+
});
|
|
3699
|
+
return;
|
|
3700
|
+
}
|
|
3460
3701
|
if (runner.hasActiveApiSession(sessionId)) {
|
|
3461
3702
|
res.status(409).json({ error: 'Session already has a pending request' });
|
|
3462
3703
|
return;
|
|
@@ -3486,40 +3727,13 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
|
|
|
3486
3727
|
console.error(`[api] Failed to delete GREETING.md for '${agentId}': ${e.message}`);
|
|
3487
3728
|
}
|
|
3488
3729
|
let onClientDisconnect;
|
|
3730
|
+
const requestId = (0, crypto_1.randomUUID)();
|
|
3731
|
+
const startTime = Date.now();
|
|
3489
3732
|
try {
|
|
3490
|
-
const sseCallbacks = {
|
|
3491
|
-
onChunk: (event) => {
|
|
3492
|
-
try {
|
|
3493
|
-
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
3494
|
-
}
|
|
3495
|
-
catch { /* client gone */ }
|
|
3496
|
-
},
|
|
3497
|
-
onDone: (fullText) => {
|
|
3498
|
-
try {
|
|
3499
|
-
res.write(`data: ${JSON.stringify({ type: 'result', text: fullText, session_id: sessionId })}\n\n`);
|
|
3500
|
-
res.write('data: [DONE]\n\n');
|
|
3501
|
-
res.end();
|
|
3502
|
-
}
|
|
3503
|
-
catch { /* client gone */ }
|
|
3504
|
-
},
|
|
3505
|
-
onError: (err) => {
|
|
3506
|
-
try {
|
|
3507
|
-
res.write(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`);
|
|
3508
|
-
res.end();
|
|
3509
|
-
}
|
|
3510
|
-
catch { /* client gone */ }
|
|
3511
|
-
},
|
|
3512
|
-
};
|
|
3733
|
+
const sseCallbacks = createSseCallbacks(res, { requestId, sessionId, startTime });
|
|
3513
3734
|
// Preflight conflict check already done above; throw-based check catches races after headers
|
|
3514
|
-
res
|
|
3515
|
-
|
|
3516
|
-
'Cache-Control': 'no-cache',
|
|
3517
|
-
'Connection': 'keep-alive',
|
|
3518
|
-
'X-Accel-Buffering': 'no',
|
|
3519
|
-
});
|
|
3520
|
-
res.flushHeaders();
|
|
3521
|
-
res.socket?.setNoDelay(true);
|
|
3522
|
-
onClientDisconnect = await runner.sendApiMessageStream(sessionId, chatId, content, sseCallbacks, { timeoutMs: DEFAULT_TIMEOUT_MS, skipUserMessage: true });
|
|
3735
|
+
openSseStream(res);
|
|
3736
|
+
onClientDisconnect = await runner.sendApiMessageStream(sessionId, chatId, content, sseCallbacks, { timeoutMs: DEFAULT_TIMEOUT_MS, skipUserMessage: true, requestId });
|
|
3523
3737
|
res.on('close', onClientDisconnect);
|
|
3524
3738
|
}
|
|
3525
3739
|
catch (err) {
|