@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.
Files changed (77) hide show
  1. package/README.md +7 -2
  2. package/config.template.json +4 -0
  3. package/dist/agent/dreaming/config.d.ts +6 -0
  4. package/dist/agent/dreaming/config.d.ts.map +1 -1
  5. package/dist/agent/dreaming/config.js +17 -24
  6. package/dist/agent/dreaming/config.js.map +1 -1
  7. package/dist/agent/knowledge/config.d.ts.map +1 -1
  8. package/dist/agent/knowledge/config.js +7 -13
  9. package/dist/agent/knowledge/config.js.map +1 -1
  10. package/dist/agent/runner.d.ts +87 -13
  11. package/dist/agent/runner.d.ts.map +1 -1
  12. package/dist/agent/runner.js +342 -117
  13. package/dist/agent/runner.js.map +1 -1
  14. package/dist/agent/turn-stream.d.ts +202 -0
  15. package/dist/agent/turn-stream.d.ts.map +1 -0
  16. package/dist/agent/turn-stream.js +322 -0
  17. package/dist/agent/turn-stream.js.map +1 -0
  18. package/dist/api/apps-router.d.ts.map +1 -1
  19. package/dist/api/apps-router.js +14 -2
  20. package/dist/api/apps-router.js.map +1 -1
  21. package/dist/api/gateway-router.d.ts.map +1 -1
  22. package/dist/api/gateway-router.js +2 -3
  23. package/dist/api/gateway-router.js.map +1 -1
  24. package/dist/api/line-webhook-router.d.ts +43 -1
  25. package/dist/api/line-webhook-router.d.ts.map +1 -1
  26. package/dist/api/line-webhook-router.js +286 -38
  27. package/dist/api/line-webhook-router.js.map +1 -1
  28. package/dist/api/router.d.ts +7 -1
  29. package/dist/api/router.d.ts.map +1 -1
  30. package/dist/api/router.js +328 -114
  31. package/dist/api/router.js.map +1 -1
  32. package/dist/apps/agent-manager.d.ts.map +1 -1
  33. package/dist/apps/agent-manager.js +4 -1
  34. package/dist/apps/agent-manager.js.map +1 -1
  35. package/dist/apps/installer.d.ts +75 -2
  36. package/dist/apps/installer.d.ts.map +1 -1
  37. package/dist/apps/installer.js +198 -13
  38. package/dist/apps/installer.js.map +1 -1
  39. package/dist/config/agent-env.d.ts +41 -0
  40. package/dist/config/agent-env.d.ts.map +1 -0
  41. package/dist/config/agent-env.js +154 -0
  42. package/dist/config/agent-env.js.map +1 -0
  43. package/dist/config/loader.d.ts +33 -2
  44. package/dist/config/loader.d.ts.map +1 -1
  45. package/dist/config/loader.js +33 -9
  46. package/dist/config/loader.js.map +1 -1
  47. package/dist/config/watcher.d.ts +1 -0
  48. package/dist/config/watcher.d.ts.map +1 -1
  49. package/dist/config/watcher.js +17 -1
  50. package/dist/config/watcher.js.map +1 -1
  51. package/dist/index.js +24 -51
  52. package/dist/index.js.map +1 -1
  53. package/dist/load-dotenv.d.ts +17 -0
  54. package/dist/load-dotenv.d.ts.map +1 -1
  55. package/dist/load-dotenv.js +32 -9
  56. package/dist/load-dotenv.js.map +1 -1
  57. package/dist/session/store.d.ts +9 -0
  58. package/dist/session/store.d.ts.map +1 -1
  59. package/dist/session/store.js +12 -0
  60. package/dist/session/store.js.map +1 -1
  61. package/dist/shell/bypass-dialog.d.ts +142 -0
  62. package/dist/shell/bypass-dialog.d.ts.map +1 -0
  63. package/dist/shell/bypass-dialog.js +207 -0
  64. package/dist/shell/bypass-dialog.js.map +1 -0
  65. package/dist/shell/claude-pty-shell.js +42 -3
  66. package/dist/shell/claude-pty-shell.js.map +1 -1
  67. package/dist/shell/screen.d.ts +7 -0
  68. package/dist/shell/screen.d.ts.map +1 -1
  69. package/dist/shell/screen.js +10 -1
  70. package/dist/shell/screen.js.map +1 -1
  71. package/dist/types.d.ts +20 -0
  72. package/dist/types.d.ts.map +1 -1
  73. package/dist/utils/config-num.d.ts +22 -0
  74. package/dist/utils/config-num.d.ts.map +1 -0
  75. package/dist/utils/config-num.js +34 -0
  76. package/dist/utils/config-num.js.map +1 -0
  77. package/package.json +1 -1
@@ -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 — marks SSE writes as no-op; stream continues server-side until result is saved to DB
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
- onChunk: (event) => {
2777
- try {
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 optional provide the same value used when creating the session via POST /sessions
3457
- // so the greeting message lands in the correct historyDb bucket (api-{chatId}).
3458
- // If omitted, sessionId is used as the bucket key, which creates a secondary index entry.
3459
- const chatId = typeof body.chat_id === 'string' && body.chat_id.trim() ? body.chat_id.trim() : sessionId;
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.writeHead(200, {
3515
- 'Content-Type': 'text/event-stream',
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) {