@0xmaxma/claude-gateway 1.0.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.
Files changed (192) hide show
  1. package/README.md +791 -0
  2. package/config.template.json +87 -0
  3. package/dist/agent/context-isolation.d.ts +28 -0
  4. package/dist/agent/context-isolation.d.ts.map +1 -0
  5. package/dist/agent/context-isolation.js +80 -0
  6. package/dist/agent/context-isolation.js.map +1 -0
  7. package/dist/agent/runner.d.ts +211 -0
  8. package/dist/agent/runner.d.ts.map +1 -0
  9. package/dist/agent/runner.js +1802 -0
  10. package/dist/agent/runner.js.map +1 -0
  11. package/dist/agent/workspace-loader.d.ts +31 -0
  12. package/dist/agent/workspace-loader.d.ts.map +1 -0
  13. package/dist/agent/workspace-loader.js +207 -0
  14. package/dist/agent/workspace-loader.js.map +1 -0
  15. package/dist/api/auth.d.ts +25 -0
  16. package/dist/api/auth.d.ts.map +1 -0
  17. package/dist/api/auth.js +77 -0
  18. package/dist/api/auth.js.map +1 -0
  19. package/dist/api/cron-router.d.ts +22 -0
  20. package/dist/api/cron-router.d.ts.map +1 -0
  21. package/dist/api/cron-router.js +197 -0
  22. package/dist/api/cron-router.js.map +1 -0
  23. package/dist/api/gateway-router.d.ts +52 -0
  24. package/dist/api/gateway-router.d.ts.map +1 -0
  25. package/dist/api/gateway-router.js +215 -0
  26. package/dist/api/gateway-router.js.map +1 -0
  27. package/dist/api/router.d.ts +5 -0
  28. package/dist/api/router.d.ts.map +1 -0
  29. package/dist/api/router.js +1098 -0
  30. package/dist/api/router.js.map +1 -0
  31. package/dist/api/skills-router.d.ts +5 -0
  32. package/dist/api/skills-router.d.ts.map +1 -0
  33. package/dist/api/skills-router.js +424 -0
  34. package/dist/api/skills-router.js.map +1 -0
  35. package/dist/api/workspace-router.d.ts +4 -0
  36. package/dist/api/workspace-router.d.ts.map +1 -0
  37. package/dist/api/workspace-router.js +149 -0
  38. package/dist/api/workspace-router.js.map +1 -0
  39. package/dist/config/loader.d.ts +16 -0
  40. package/dist/config/loader.d.ts.map +1 -0
  41. package/dist/config/loader.js +235 -0
  42. package/dist/config/loader.js.map +1 -0
  43. package/dist/config/migrator.d.ts +70 -0
  44. package/dist/config/migrator.d.ts.map +1 -0
  45. package/dist/config/migrator.js +343 -0
  46. package/dist/config/migrator.js.map +1 -0
  47. package/dist/config/watcher.d.ts +30 -0
  48. package/dist/config/watcher.d.ts.map +1 -0
  49. package/dist/config/watcher.js +170 -0
  50. package/dist/config/watcher.js.map +1 -0
  51. package/dist/cron/manager.d.ts +48 -0
  52. package/dist/cron/manager.d.ts.map +1 -0
  53. package/dist/cron/manager.js +580 -0
  54. package/dist/cron/manager.js.map +1 -0
  55. package/dist/cron/scheduler.d.ts +50 -0
  56. package/dist/cron/scheduler.d.ts.map +1 -0
  57. package/dist/cron/scheduler.js +231 -0
  58. package/dist/cron/scheduler.js.map +1 -0
  59. package/dist/discord/receiver.d.ts +17 -0
  60. package/dist/discord/receiver.d.ts.map +1 -0
  61. package/dist/discord/receiver.js +108 -0
  62. package/dist/discord/receiver.js.map +1 -0
  63. package/dist/heartbeat/history.d.ts +24 -0
  64. package/dist/heartbeat/history.d.ts.map +1 -0
  65. package/dist/heartbeat/history.js +50 -0
  66. package/dist/heartbeat/history.js.map +1 -0
  67. package/dist/heartbeat/parser.d.ts +26 -0
  68. package/dist/heartbeat/parser.d.ts.map +1 -0
  69. package/dist/heartbeat/parser.js +283 -0
  70. package/dist/heartbeat/parser.js.map +1 -0
  71. package/dist/history/cleanup.d.ts +21 -0
  72. package/dist/history/cleanup.d.ts.map +1 -0
  73. package/dist/history/cleanup.js +171 -0
  74. package/dist/history/cleanup.js.map +1 -0
  75. package/dist/history/db.d.ts +27 -0
  76. package/dist/history/db.d.ts.map +1 -0
  77. package/dist/history/db.js +323 -0
  78. package/dist/history/db.js.map +1 -0
  79. package/dist/history/media-store.d.ts +27 -0
  80. package/dist/history/media-store.d.ts.map +1 -0
  81. package/dist/history/media-store.js +158 -0
  82. package/dist/history/media-store.js.map +1 -0
  83. package/dist/history/types.d.ts +62 -0
  84. package/dist/history/types.d.ts.map +1 -0
  85. package/dist/history/types.js +3 -0
  86. package/dist/history/types.js.map +1 -0
  87. package/dist/index.d.ts +3 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +483 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/logger.d.ts +3 -0
  92. package/dist/logger.d.ts.map +1 -0
  93. package/dist/logger.js +81 -0
  94. package/dist/logger.js.map +1 -0
  95. package/dist/memory/manager.d.ts +45 -0
  96. package/dist/memory/manager.d.ts.map +1 -0
  97. package/dist/memory/manager.js +178 -0
  98. package/dist/memory/manager.js.map +1 -0
  99. package/dist/session/compactor.d.ts +22 -0
  100. package/dist/session/compactor.d.ts.map +1 -0
  101. package/dist/session/compactor.js +151 -0
  102. package/dist/session/compactor.js.map +1 -0
  103. package/dist/session/process.d.ts +76 -0
  104. package/dist/session/process.d.ts.map +1 -0
  105. package/dist/session/process.js +729 -0
  106. package/dist/session/process.js.map +1 -0
  107. package/dist/session/store.d.ts +102 -0
  108. package/dist/session/store.d.ts.map +1 -0
  109. package/dist/session/store.js +533 -0
  110. package/dist/session/store.js.map +1 -0
  111. package/dist/skills/index.d.ts +9 -0
  112. package/dist/skills/index.d.ts.map +1 -0
  113. package/dist/skills/index.js +15 -0
  114. package/dist/skills/index.js.map +1 -0
  115. package/dist/skills/invoker.d.ts +16 -0
  116. package/dist/skills/invoker.d.ts.map +1 -0
  117. package/dist/skills/invoker.js +36 -0
  118. package/dist/skills/invoker.js.map +1 -0
  119. package/dist/skills/loader.d.ts +23 -0
  120. package/dist/skills/loader.d.ts.map +1 -0
  121. package/dist/skills/loader.js +183 -0
  122. package/dist/skills/loader.js.map +1 -0
  123. package/dist/skills/parser.d.ts +55 -0
  124. package/dist/skills/parser.d.ts.map +1 -0
  125. package/dist/skills/parser.js +178 -0
  126. package/dist/skills/parser.js.map +1 -0
  127. package/dist/skills/sync.d.ts +10 -0
  128. package/dist/skills/sync.d.ts.map +1 -0
  129. package/dist/skills/sync.js +130 -0
  130. package/dist/skills/sync.js.map +1 -0
  131. package/dist/skills/watcher.d.ts +16 -0
  132. package/dist/skills/watcher.d.ts.map +1 -0
  133. package/dist/skills/watcher.js +25 -0
  134. package/dist/skills/watcher.js.map +1 -0
  135. package/dist/telegram/markdown.d.ts +25 -0
  136. package/dist/telegram/markdown.d.ts.map +1 -0
  137. package/dist/telegram/markdown.js +237 -0
  138. package/dist/telegram/markdown.js.map +1 -0
  139. package/dist/telegram/receiver.d.ts +17 -0
  140. package/dist/telegram/receiver.d.ts.map +1 -0
  141. package/dist/telegram/receiver.js +104 -0
  142. package/dist/telegram/receiver.js.map +1 -0
  143. package/dist/types.d.ts +224 -0
  144. package/dist/types.d.ts.map +1 -0
  145. package/dist/types.js +3 -0
  146. package/dist/types.js.map +1 -0
  147. package/dist/ui/web-ui.d.ts +6 -0
  148. package/dist/ui/web-ui.d.ts.map +1 -0
  149. package/dist/ui/web-ui.js +227 -0
  150. package/dist/ui/web-ui.js.map +1 -0
  151. package/dist/watch/factory.d.ts +26 -0
  152. package/dist/watch/factory.d.ts.map +1 -0
  153. package/dist/watch/factory.js +43 -0
  154. package/dist/watch/factory.js.map +1 -0
  155. package/dist/webhook/manager.d.ts +34 -0
  156. package/dist/webhook/manager.d.ts.map +1 -0
  157. package/dist/webhook/manager.js +120 -0
  158. package/dist/webhook/manager.js.map +1 -0
  159. package/mcp/bun.lock +264 -0
  160. package/mcp/channel-manager.ts +115 -0
  161. package/mcp/package.json +15 -0
  162. package/mcp/router.ts +69 -0
  163. package/mcp/server.ts +129 -0
  164. package/mcp/tools/agent/handlers.ts +578 -0
  165. package/mcp/tools/agent/module.ts +163 -0
  166. package/mcp/tools/browser/module.ts +321 -0
  167. package/mcp/tools/browser/skills/open-browser/SKILL.md +89 -0
  168. package/mcp/tools/cron/client.ts +65 -0
  169. package/mcp/tools/cron/module.ts +158 -0
  170. package/mcp/tools/cron/skills/cron/SKILL.md +46 -0
  171. package/mcp/tools/discord/access.ts +197 -0
  172. package/mcp/tools/discord/client.ts +31 -0
  173. package/mcp/tools/discord/commands.ts +48 -0
  174. package/mcp/tools/discord/inbound.ts +53 -0
  175. package/mcp/tools/discord/module.ts +442 -0
  176. package/mcp/tools/discord/outbound.ts +90 -0
  177. package/mcp/tools/discord/receiver-server.ts +58 -0
  178. package/mcp/tools/discord/skills/access/SKILL.md +86 -0
  179. package/mcp/tools/discord/skills/configure/SKILL.md +53 -0
  180. package/mcp/tools/discord/threading.ts +37 -0
  181. package/mcp/tools/discord/types.ts +106 -0
  182. package/mcp/tools/skills/handlers.ts +196 -0
  183. package/mcp/tools/skills/module.ts +163 -0
  184. package/mcp/tools/telegram/module.ts +454 -0
  185. package/mcp/tools/telegram/pure.ts +224 -0
  186. package/mcp/tools/telegram/receiver-server.ts +1656 -0
  187. package/mcp/tools/telegram/skills/access/SKILL.md +169 -0
  188. package/mcp/tools/telegram/skills/configure/SKILL.md +118 -0
  189. package/mcp/tools/telegram/typing.ts +327 -0
  190. package/mcp/types.ts +128 -0
  191. package/package.json +62 -0
  192. package/resource/claude_gateway.svg +112 -0
@@ -0,0 +1,1098 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.createApiRouter = createApiRouter;
37
+ const express_1 = require("express");
38
+ const crypto_1 = require("crypto");
39
+ const fs = __importStar(require("fs"));
40
+ const fsp = __importStar(require("fs/promises"));
41
+ const os = __importStar(require("os"));
42
+ const path = __importStar(require("path"));
43
+ const auth_1 = require("./auth");
44
+ const media_store_1 = require("../history/media-store");
45
+ const db_1 = require("../history/db");
46
+ const MAX_MESSAGE_LENGTH = 10000;
47
+ const DEFAULT_TIMEOUT_MS = 60000;
48
+ const AGENT_ID_RE = /^[a-z][a-z0-9_-]{1,31}$/;
49
+ const SAFE_FILENAME_RE = /^[a-zA-Z0-9._\-() ]+$/;
50
+ // ---------------------------------------------------------------------------
51
+ // In-memory rate limiter for media uploads (per API key)
52
+ // ---------------------------------------------------------------------------
53
+ const UPLOAD_RATE_LIMIT = 20; // max uploads
54
+ const UPLOAD_RATE_WINDOW_MS = 60000; // per 60 seconds
55
+ const uploadRateMap = new Map();
56
+ function checkUploadRateLimit(apiKeyValue) {
57
+ const now = Date.now();
58
+ const entry = uploadRateMap.get(apiKeyValue);
59
+ if (!entry || now >= entry.resetAt) {
60
+ uploadRateMap.set(apiKeyValue, { count: 1, resetAt: now + UPLOAD_RATE_WINDOW_MS });
61
+ return true;
62
+ }
63
+ if (entry.count >= UPLOAD_RATE_LIMIT)
64
+ return false;
65
+ entry.count++;
66
+ return true;
67
+ }
68
+ // Periodically evict expired entries so uploadRateMap doesn't grow indefinitely
69
+ setInterval(() => {
70
+ const now = Date.now();
71
+ for (const [key, entry] of uploadRateMap) {
72
+ if (now >= entry.resetAt)
73
+ uploadRateMap.delete(key);
74
+ }
75
+ }, UPLOAD_RATE_WINDOW_MS).unref();
76
+ /** Derive a stable short ID from an API key value (never log the raw key). */
77
+ function apiKeyId(key) {
78
+ return (0, crypto_1.createHash)('sha256').update(key).digest('hex').slice(0, 16);
79
+ }
80
+ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models) {
81
+ const router = (0, express_1.Router)();
82
+ const auth = (0, auth_1.createApiAuthMiddleware)(apiKeys);
83
+ // Closure-scoped lock: serialises concurrent writes to config.json within this router instance.
84
+ let configWriteLock = Promise.resolve();
85
+ async function writeAgentsToConfigImpl(cfgPath, mutate, newId) {
86
+ const raw = await fsp.readFile(cfgPath, 'utf-8');
87
+ const config = JSON.parse(raw);
88
+ if (newId) {
89
+ const exists = config.agents.some((a) => a.id === newId);
90
+ if (exists)
91
+ throw Object.assign(new Error(`Agent '${newId}' already exists in config`), { code: 'DUPLICATE' });
92
+ }
93
+ mutate(config.agents);
94
+ const tmp = cfgPath + '.tmp.' + (0, crypto_1.randomUUID)();
95
+ await fsp.writeFile(tmp, JSON.stringify(config, null, 2), 'utf-8');
96
+ await fsp.rename(tmp, cfgPath);
97
+ }
98
+ function writeAgentsToConfig(cfgPath, mutate, newId) {
99
+ const next = configWriteLock.catch(() => { }).then(() => writeAgentsToConfigImpl(cfgPath, mutate, newId));
100
+ configWriteLock = next.catch(() => { });
101
+ return next;
102
+ }
103
+ /**
104
+ * GET /api/v1/commands
105
+ *
106
+ * Return the list of slash commands available in the chat UI. No auth required.
107
+ */
108
+ router.get('/v1/commands', (_req, res) => {
109
+ res.json({
110
+ commands: [
111
+ { name: '/session', description: 'Show current session info (name, message count, context %)' },
112
+ { name: '/clear', description: 'Clear current session history' },
113
+ { name: '/compact', description: 'Summarise old history and keep only recent messages' },
114
+ { name: '/stop', description: 'Interrupt the in-flight turn' },
115
+ { name: '/restart', description: 'Graceful session restart' },
116
+ { name: '/model', description: 'Show the current AI model' },
117
+ ],
118
+ });
119
+ });
120
+ /**
121
+ * POST /api/v1/agents/:agentId/messages
122
+ *
123
+ * Send a message to an agent and receive its response synchronously.
124
+ * Body: { message: string, chat_id: string, session_id?: string }
125
+ */
126
+ router.post('/v1/agents/:agentId/messages', auth, async (req, res) => {
127
+ const { agentId } = req.params;
128
+ const apiKey = req.apiKey;
129
+ if (!(0, auth_1.canAccessAgent)(apiKey, agentId)) {
130
+ res.status(403).json({ error: `API key has no access to agent '${agentId}'` });
131
+ return;
132
+ }
133
+ const runner = agentRunners.get(agentId);
134
+ if (!runner) {
135
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
136
+ return;
137
+ }
138
+ const body = req.body;
139
+ const { message, chat_id, session_id, stream, timeout_ms, media_files } = body;
140
+ if (!message || typeof message !== 'string' || !message.trim()) {
141
+ res.status(400).json({ error: 'message is required and must be a non-empty string' });
142
+ return;
143
+ }
144
+ if (message.length > MAX_MESSAGE_LENGTH) {
145
+ res.status(400).json({ error: `message too long (max ${MAX_MESSAGE_LENGTH} characters)` });
146
+ return;
147
+ }
148
+ if (!chat_id || typeof chat_id !== 'string' || !chat_id.trim()) {
149
+ res.status(400).json({ error: 'chat_id is required and must be a non-empty string' });
150
+ return;
151
+ }
152
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(chat_id.trim())) {
153
+ res.status(400).json({ error: 'chat_id must be 1-64 alphanumeric characters, hyphens, or underscores' });
154
+ return;
155
+ }
156
+ if (session_id !== undefined && typeof session_id !== 'string') {
157
+ res.status(400).json({ error: 'session_id must be a string if provided' });
158
+ return;
159
+ }
160
+ // Validate media_files
161
+ let validatedMediaFiles;
162
+ if (media_files !== undefined) {
163
+ if (!Array.isArray(media_files) || media_files.some((f) => typeof f !== 'string')) {
164
+ res.status(400).json({ error: 'media_files must be an array of strings' });
165
+ return;
166
+ }
167
+ if (media_files.length > 5) {
168
+ res.status(400).json({ error: 'media_files exceeds maximum of 5 images per message' });
169
+ return;
170
+ }
171
+ // Validate each path is within agent media root (path traversal guard)
172
+ const routerAgentsBaseDir = runner.getAgentsBaseDir();
173
+ for (const f of media_files) {
174
+ try {
175
+ media_store_1.MediaStore.resolvePath(routerAgentsBaseDir, agentId, f);
176
+ }
177
+ catch {
178
+ res.status(400).json({ error: `Invalid media path: ${f}` });
179
+ return;
180
+ }
181
+ }
182
+ validatedMediaFiles = media_files;
183
+ }
184
+ const requestId = (0, crypto_1.randomUUID)();
185
+ const sessionId = session_id ?? (0, crypto_1.randomUUID)();
186
+ const chatIdStr = chat_id.trim();
187
+ const startTime = Date.now();
188
+ const timeoutMs = typeof timeout_ms === 'number' && timeout_ms > 0 && timeout_ms <= 600000
189
+ ? timeout_ms
190
+ : DEFAULT_TIMEOUT_MS;
191
+ // Slash command dispatch — runs instead of sending to Claude
192
+ if (message.trim().startsWith('/')) {
193
+ try {
194
+ const result = await runner.executeApiCommand(sessionId, chatIdStr, message.trim());
195
+ res.json({ command: message.trim(), session_id: sessionId, result });
196
+ }
197
+ catch (err) {
198
+ res.status(500).json({ error: err.message ?? 'Command failed' });
199
+ }
200
+ return;
201
+ }
202
+ if (stream) {
203
+ // SSE streaming mode
204
+ let cleanup;
205
+ try {
206
+ const sseCallbacks = {
207
+ onChunk: (event) => {
208
+ try {
209
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
210
+ }
211
+ catch { /* client gone */ }
212
+ },
213
+ onDone: (fullText) => {
214
+ try {
215
+ res.write(`data: ${JSON.stringify({ type: 'result', text: fullText, request_id: requestId, session_id: sessionId, duration_ms: Date.now() - startTime })}\n\n`);
216
+ res.write('data: [DONE]\n\n');
217
+ res.end();
218
+ }
219
+ catch { /* client gone */ }
220
+ },
221
+ onError: (err) => {
222
+ try {
223
+ res.write(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`);
224
+ res.end();
225
+ }
226
+ catch { /* client gone */ }
227
+ },
228
+ };
229
+ // Preflight conflict check — return 409 JSON before SSE headers are sent
230
+ if (runner.hasActiveApiSession(sessionId)) {
231
+ res.status(409).json({ error: 'Session already has a pending request' });
232
+ return;
233
+ }
234
+ res.writeHead(200, {
235
+ 'Content-Type': 'text/event-stream',
236
+ 'Cache-Control': 'no-cache',
237
+ 'Connection': 'keep-alive',
238
+ 'X-Accel-Buffering': 'no',
239
+ });
240
+ res.flushHeaders();
241
+ res.socket?.setNoDelay(true);
242
+ const agentCfg = agentConfigs.get(agentId);
243
+ const allowTools = agentCfg.allow_tools ?? !!apiKey.allow_tools;
244
+ cleanup = await runner.sendApiMessageStream(sessionId, chatIdStr, message.trim(), sseCallbacks, { timeoutMs, allowTools, mediaFiles: validatedMediaFiles });
245
+ // Client disconnect -> cleanup
246
+ res.on('close', cleanup);
247
+ }
248
+ catch (err) {
249
+ const code = err.code;
250
+ if (!res.headersSent) {
251
+ if (code === 'CONFLICT') {
252
+ res.status(409).json({ error: 'Session already has a pending request' });
253
+ }
254
+ else {
255
+ res.status(500).json({ error: 'Internal error' });
256
+ }
257
+ }
258
+ else {
259
+ try {
260
+ res.write(`data: ${JSON.stringify({ type: 'error', message: 'Internal error' })}\n\n`);
261
+ res.end();
262
+ }
263
+ catch { /* client gone */ }
264
+ }
265
+ }
266
+ }
267
+ else {
268
+ // Synchronous mode (existing behavior)
269
+ try {
270
+ const agentCfgSync = agentConfigs.get(agentId);
271
+ const allowToolsSync = agentCfgSync.allow_tools ?? !!apiKey.allow_tools;
272
+ const response = await runner.sendApiMessage(sessionId, chatIdStr, message.trim(), {
273
+ timeoutMs,
274
+ allowTools: allowToolsSync,
275
+ mediaFiles: validatedMediaFiles,
276
+ });
277
+ res.json({
278
+ request_id: requestId,
279
+ agent_id: agentId,
280
+ response,
281
+ session_id: sessionId,
282
+ duration_ms: Date.now() - startTime,
283
+ });
284
+ }
285
+ catch (err) {
286
+ const code = err.code;
287
+ if (code === 'TIMEOUT') {
288
+ res.status(504).json({ error: 'Agent response timeout' });
289
+ }
290
+ else if (code === 'CONFLICT') {
291
+ res.status(409).json({ error: 'Session already has a pending request' });
292
+ }
293
+ else {
294
+ res.status(500).json({ error: 'Internal error' });
295
+ }
296
+ }
297
+ }
298
+ });
299
+ /**
300
+ * GET /api/v1/models
301
+ *
302
+ * List all supported Claude models from gateway config (falls back to defaults).
303
+ */
304
+ router.get('/v1/models', auth, (_req, res) => {
305
+ res.json({ models: (models ?? []).map((m) => ({ id: m.id, name: m.label, alias: m.alias, contextWindow: m.contextWindow, multiplier: m.multiplier ?? 1 })) });
306
+ });
307
+ /**
308
+ * GET /api/v1/agents
309
+ *
310
+ * List agents scoped to the API key. Admin keys see all agents.
311
+ */
312
+ router.get('/v1/agents', auth, (req, res) => {
313
+ const apiKey = req.apiKey;
314
+ const agents = [...agentConfigs.entries()]
315
+ .filter(([id]) => (0, auth_1.canAccessAgent)(apiKey, id))
316
+ .map(([id, cfg]) => ({
317
+ id,
318
+ description: cfg.description,
319
+ model: cfg.claude?.model ?? null,
320
+ allow_tools: cfg.allow_tools ?? false,
321
+ }));
322
+ res.json({ agents });
323
+ });
324
+ /**
325
+ * GET /api/v1/agents/sessions
326
+ *
327
+ * List all sessions across all agents. Admin only.
328
+ * Queries each agent's history DB sequentially and returns a nested agents → sessions structure.
329
+ */
330
+ router.get('/v1/agents/sessions', auth, async (req, res) => {
331
+ const apiKey = req.apiKey;
332
+ if (!(0, auth_1.isAdmin)(apiKey)) {
333
+ res.status(403).json({ error: 'Admin key required' });
334
+ return;
335
+ }
336
+ const agents = await Promise.all([...agentRunners.entries()].map(async ([agentId, runner]) => {
337
+ const cfg = agentConfigs.get(agentId);
338
+ const [sessions, nameMap] = await Promise.all([
339
+ Promise.resolve(runner.getHistoryDb().listSessions()),
340
+ runner.getAllSessionNames(),
341
+ ]);
342
+ return {
343
+ agentId,
344
+ description: cfg?.description ?? '',
345
+ sessions: sessions.map((s) => ({ ...s, sessionName: nameMap.get(s.sessionId) ?? null })),
346
+ };
347
+ }));
348
+ res.json({ agents });
349
+ });
350
+ /**
351
+ * POST /api/v1/agents
352
+ *
353
+ * Create a new agent entry in config.json. Requires admin key.
354
+ * Body: { id, description, model? }
355
+ */
356
+ router.post('/v1/agents', auth, async (req, res) => {
357
+ const apiKey = req.apiKey;
358
+ if (!(0, auth_1.isAdmin)(apiKey)) {
359
+ res.status(403).json({ error: 'Admin key required to create agents' });
360
+ return;
361
+ }
362
+ if (!configPath) {
363
+ res.status(501).json({ error: 'Agent management not available (no configPath)' });
364
+ return;
365
+ }
366
+ const body = req.body;
367
+ const { id, description, model } = body;
368
+ if (!id || typeof id !== 'string' || !AGENT_ID_RE.test(id)) {
369
+ res.status(400).json({ error: 'id must match pattern [a-z][a-z0-9_-]{1,31}' });
370
+ return;
371
+ }
372
+ if (!description || typeof description !== 'string' || !description.trim()) {
373
+ res.status(400).json({ error: 'description is required' });
374
+ return;
375
+ }
376
+ if (agentConfigs.has(id)) {
377
+ res.status(409).json({ error: `Agent '${id}' already exists` });
378
+ return;
379
+ }
380
+ const workspace = path.join('~', '.claude-gateway', 'agents', id, 'workspace');
381
+ const workspaceAbs = path.join(os.homedir(), '.claude-gateway', 'agents', id, 'workspace');
382
+ const newAgent = {
383
+ id,
384
+ description: description.trim(),
385
+ workspace,
386
+ env: path.join('~', '.claude-gateway', 'agents', id, 'workspace', '.env'),
387
+ claude: {
388
+ model: typeof model === 'string' && model.trim() ? model.trim() : 'claude-sonnet-4-6',
389
+ dangerouslySkipPermissions: false,
390
+ extraFlags: [],
391
+ },
392
+ };
393
+ // Write config first — if this fails, no workspace is created (avoids orphaned directories).
394
+ try {
395
+ await writeAgentsToConfig(configPath, (agents) => agents.push(newAgent), id);
396
+ }
397
+ catch (err) {
398
+ const code = err.code;
399
+ if (code === 'DUPLICATE') {
400
+ res.status(409).json({ error: `Agent '${id}' already exists` });
401
+ }
402
+ else {
403
+ res.status(500).json({ error: `Failed to write config: ${err.message}` });
404
+ }
405
+ return;
406
+ }
407
+ // Config written successfully — now create workspace directory and stub files.
408
+ const stubFiles = {
409
+ 'AGENTS.md': `# Agent: ${id}\n\n${description.trim()}\n`,
410
+ 'SOUL.md': `# Soul\n\n`,
411
+ 'USER.md': `# User Profile\n\n`,
412
+ 'MEMORY.md': `# Memory\n\n`,
413
+ };
414
+ try {
415
+ fs.mkdirSync(workspaceAbs, { recursive: true });
416
+ for (const [filename, stub] of Object.entries(stubFiles)) {
417
+ const filePath = path.join(workspaceAbs, filename);
418
+ if (!fs.existsSync(filePath)) {
419
+ fs.writeFileSync(filePath, stub, 'utf8');
420
+ }
421
+ }
422
+ }
423
+ catch (err) {
424
+ // Config was already written — log the workspace failure but still return 201.
425
+ // The agent is valid; workspace will be auto-created on next gateway start.
426
+ console.error(`[api] Warning: agent '${id}' created in config but workspace setup failed: ${err.message}`);
427
+ }
428
+ res.status(201).json({ agent: { id, description: newAgent.description, model: newAgent.claude.model } });
429
+ });
430
+ /**
431
+ * PATCH /api/v1/agents/:agentId
432
+ *
433
+ * Update agent description and/or model. Requires write access to the agent.
434
+ * Body: { description?, model? }
435
+ */
436
+ router.patch('/v1/agents/:agentId', auth, async (req, res) => {
437
+ const apiKey = req.apiKey;
438
+ const { agentId } = req.params;
439
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
440
+ res.status(403).json({ error: 'Write permission required' });
441
+ return;
442
+ }
443
+ if (!configPath) {
444
+ res.status(501).json({ error: 'Agent management not available (no configPath)' });
445
+ return;
446
+ }
447
+ if (!agentConfigs.has(agentId)) {
448
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
449
+ return;
450
+ }
451
+ const body = req.body;
452
+ const { description, model, allow_tools } = body;
453
+ if (description !== undefined && (typeof description !== 'string' || !description.trim())) {
454
+ res.status(400).json({ error: 'description must be a non-empty string' });
455
+ return;
456
+ }
457
+ if (model !== undefined && (typeof model !== 'string' || !model.trim())) {
458
+ res.status(400).json({ error: 'model must be a non-empty string' });
459
+ return;
460
+ }
461
+ if (allow_tools !== undefined && typeof allow_tools !== 'boolean') {
462
+ res.status(400).json({ error: 'allow_tools must be a boolean' });
463
+ return;
464
+ }
465
+ try {
466
+ await writeAgentsToConfig(configPath, (agents) => {
467
+ const agent = agents.find((a) => a.id === agentId);
468
+ if (!agent)
469
+ return;
470
+ if (description !== undefined)
471
+ agent.description = description.trim();
472
+ if (model !== undefined) {
473
+ const claude = agent.claude;
474
+ if (claude)
475
+ claude.model = model.trim();
476
+ }
477
+ if (allow_tools !== undefined)
478
+ agent.allow_tools = allow_tools;
479
+ });
480
+ }
481
+ catch (err) {
482
+ res.status(500).json({ error: `Failed to write config: ${err.message}` });
483
+ return;
484
+ }
485
+ // Sync in-memory map with what was written to disk
486
+ const cfg = agentConfigs.get(agentId);
487
+ if (description !== undefined)
488
+ cfg.description = description.trim();
489
+ if (model !== undefined && cfg.claude)
490
+ cfg.claude.model = model.trim();
491
+ if (allow_tools !== undefined)
492
+ cfg.allow_tools = allow_tools;
493
+ res.json({ agent: { id: agentId, description: cfg.description, model: cfg.claude?.model, allow_tools: cfg.allow_tools ?? false } });
494
+ });
495
+ /**
496
+ * DELETE /api/v1/agents/:agentId
497
+ *
498
+ * Remove agent from config.json and stop the running runner. Requires admin key.
499
+ */
500
+ router.delete('/v1/agents/:agentId', auth, async (req, res) => {
501
+ const apiKey = req.apiKey;
502
+ if (!(0, auth_1.isAdmin)(apiKey)) {
503
+ res.status(403).json({ error: 'Admin key required' });
504
+ return;
505
+ }
506
+ if (!configPath) {
507
+ res.status(501).json({ error: 'Agent management not available (no configPath)' });
508
+ return;
509
+ }
510
+ const { agentId } = req.params;
511
+ if (!agentConfigs.has(agentId)) {
512
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
513
+ return;
514
+ }
515
+ try {
516
+ await writeAgentsToConfig(configPath, (agents) => {
517
+ const idx = agents.findIndex((a) => a.id === agentId);
518
+ if (idx !== -1)
519
+ agents.splice(idx, 1);
520
+ });
521
+ }
522
+ catch (err) {
523
+ res.status(500).json({ error: `Failed to write config: ${err.message}` });
524
+ return;
525
+ }
526
+ // Stop and remove the running runner so the agent no longer responds after deletion.
527
+ const runner = agentRunners.get(agentId);
528
+ if (runner) {
529
+ try {
530
+ await runner.stop();
531
+ }
532
+ catch { /* ignore stop errors */ }
533
+ agentRunners.delete(agentId);
534
+ db_1.HistoryDB.evictDir(runner.getAgentDir(), agentId);
535
+ }
536
+ agentConfigs.delete(agentId);
537
+ res.json({ success: true, id: agentId });
538
+ });
539
+ // ─── Chat History API ─────────────────────────────────────────────────────────
540
+ /**
541
+ * GET /api/v1/agents/:agentId/chats
542
+ * List all chats (across all channels) for an agent from the history DB.
543
+ */
544
+ router.get('/v1/agents/:agentId/chats', auth, (req, res) => {
545
+ const { agentId } = req.params;
546
+ const apiKey = req.apiKey;
547
+ if (!(0, auth_1.canAccessAgent)(apiKey, agentId)) {
548
+ res.status(403).json({ error: `API key has no access to agent '${agentId}'` });
549
+ return;
550
+ }
551
+ const runner = agentRunners.get(agentId);
552
+ if (!runner) {
553
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
554
+ return;
555
+ }
556
+ const chats = runner.getHistoryDb().listChats();
557
+ res.json({ chats });
558
+ });
559
+ /**
560
+ * GET /api/v1/agents/:agentId/chats/:chatId/sessions
561
+ * List sessions for a specific chat (delegated to SessionStore).
562
+ * chatId format: "telegram-{rawId}" | "discord-{rawId}"
563
+ */
564
+ router.get('/v1/agents/:agentId/chats/:chatId/sessions', auth, async (req, res) => {
565
+ const { agentId, chatId } = req.params;
566
+ const apiKey = req.apiKey;
567
+ if (!(0, auth_1.canAccessAgent)(apiKey, agentId)) {
568
+ res.status(403).json({ error: `API key has no access to agent '${agentId}'` });
569
+ return;
570
+ }
571
+ const runner = agentRunners.get(agentId);
572
+ if (!runner) {
573
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
574
+ return;
575
+ }
576
+ const { source, rawChatId } = parseHistoryChatId(chatId);
577
+ if (source !== 'telegram' && source !== 'discord') {
578
+ res.status(400).json({ error: 'Sessions endpoint only supports telegram/discord chats' });
579
+ return;
580
+ }
581
+ try {
582
+ const index = await runner.listSessionsForChat(rawChatId, source);
583
+ res.json(index);
584
+ }
585
+ catch (err) {
586
+ res.status(500).json({ error: err.message });
587
+ }
588
+ });
589
+ /**
590
+ * GET /api/v1/agents/:agentId/chats/:chatId/messages
591
+ * Paginated message history (cursor-based).
592
+ * Query: limit, before (ts ms), after (ts ms), session_id
593
+ */
594
+ router.get('/v1/agents/:agentId/chats/:chatId/messages', auth, (req, res) => {
595
+ const { agentId, chatId } = req.params;
596
+ const apiKey = req.apiKey;
597
+ if (!(0, auth_1.canAccessAgent)(apiKey, agentId)) {
598
+ res.status(403).json({ error: `API key has no access to agent '${agentId}'` });
599
+ return;
600
+ }
601
+ const runner = agentRunners.get(agentId);
602
+ if (!runner) {
603
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
604
+ return;
605
+ }
606
+ const query = req.query;
607
+ const limit = query['limit'] ? Math.min(parseInt(query['limit'], 10) || 50, 200) : 50;
608
+ const before = query['before'] ? parseInt(query['before'], 10) : undefined;
609
+ const after = query['after'] ? parseInt(query['after'], 10) : undefined;
610
+ const sessionId = query['session_id'] ?? undefined;
611
+ const page = runner.getHistoryDb().getMessages(chatId, { limit, before, after, sessionId });
612
+ res.json(page);
613
+ });
614
+ /**
615
+ * GET /api/v1/agents/:agentId/chats/:chatId/messages/search
616
+ * Full-text search using SQLite FTS5.
617
+ * Query: q, limit, offset
618
+ */
619
+ router.get('/v1/agents/:agentId/chats/:chatId/messages/search', auth, (req, res) => {
620
+ const { agentId, chatId } = req.params;
621
+ const apiKey = req.apiKey;
622
+ if (!(0, auth_1.canAccessAgent)(apiKey, agentId)) {
623
+ res.status(403).json({ error: `API key has no access to agent '${agentId}'` });
624
+ return;
625
+ }
626
+ const runner = agentRunners.get(agentId);
627
+ if (!runner) {
628
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
629
+ return;
630
+ }
631
+ const query = req.query;
632
+ const q = (query['q'] ?? '').trim();
633
+ if (!q) {
634
+ res.status(400).json({ error: 'q is required' });
635
+ return;
636
+ }
637
+ const limit = query['limit'] ? Math.min(parseInt(query['limit'], 10) || 20, 100) : 20;
638
+ const offset = query['offset'] ? parseInt(query['offset'], 10) : 0;
639
+ const page = runner.getHistoryDb().searchMessages(chatId, q, { limit, offset });
640
+ res.json(page);
641
+ });
642
+ /**
643
+ * POST /api/v1/agents/:agentId/chats/:chatId/sessions/:sessionId/messages
644
+ * Inject a message into an existing channel session (cross-channel continuation).
645
+ * Streams the assistant response as SSE.
646
+ * Body: { content: string, senderName?: string }
647
+ */
648
+ router.post('/v1/agents/:agentId/chats/:chatId/sessions/:sessionId/messages', auth, async (req, res) => {
649
+ const { agentId, chatId, sessionId } = req.params;
650
+ const apiKey = req.apiKey;
651
+ if (!(0, auth_1.canAccessAgent)(apiKey, agentId)) {
652
+ res.status(403).json({ error: `API key has no access to agent '${agentId}'` });
653
+ return;
654
+ }
655
+ const runner = agentRunners.get(agentId);
656
+ if (!runner) {
657
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
658
+ return;
659
+ }
660
+ const { source, rawChatId } = parseHistoryChatId(chatId);
661
+ if (source !== 'telegram' && source !== 'discord') {
662
+ res.status(400).json({ error: 'Cross-channel messaging only supported for telegram/discord chats' });
663
+ return;
664
+ }
665
+ const body = req.body;
666
+ const content = body.content;
667
+ if (!content || typeof content !== 'string' || !content.trim()) {
668
+ res.status(400).json({ error: 'content is required and must be a non-empty string' });
669
+ return;
670
+ }
671
+ if (content.length > MAX_MESSAGE_LENGTH) {
672
+ res.status(400).json({ error: `content too long (max ${MAX_MESSAGE_LENGTH} characters)` });
673
+ return;
674
+ }
675
+ const senderName = typeof body.senderName === 'string' ? body.senderName : undefined;
676
+ let cleanup;
677
+ try {
678
+ const sseCallbacks = {
679
+ onChunk: (event) => {
680
+ try {
681
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
682
+ }
683
+ catch { /* client gone */ }
684
+ },
685
+ onDone: (fullText) => {
686
+ try {
687
+ res.write(`data: ${JSON.stringify({ type: 'result', text: fullText, session_id: sessionId })}\n\n`);
688
+ res.write('data: [DONE]\n\n');
689
+ res.end();
690
+ }
691
+ catch { /* client gone */ }
692
+ },
693
+ onError: (err) => {
694
+ try {
695
+ res.write(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`);
696
+ res.end();
697
+ }
698
+ catch { /* client gone */ }
699
+ },
700
+ };
701
+ res.writeHead(200, {
702
+ 'Content-Type': 'text/event-stream',
703
+ 'Cache-Control': 'no-cache',
704
+ 'Connection': 'keep-alive',
705
+ 'X-Accel-Buffering': 'no',
706
+ });
707
+ res.flushHeaders();
708
+ res.socket?.setNoDelay(true);
709
+ cleanup = await runner.sendMessageToSession(rawChatId, source, sessionId, content.trim(), senderName, sseCallbacks, { timeoutMs: DEFAULT_TIMEOUT_MS });
710
+ res.on('close', cleanup);
711
+ }
712
+ catch (err) {
713
+ if (!res.headersSent) {
714
+ res.status(500).json({ error: 'Internal error' });
715
+ }
716
+ else {
717
+ try {
718
+ res.write(`data: ${JSON.stringify({ type: 'error', message: 'Internal error' })}\n\n`);
719
+ res.end();
720
+ }
721
+ catch { /* client gone */ }
722
+ }
723
+ }
724
+ });
725
+ /**
726
+ * POST /api/v1/agents/:agentId/media
727
+ * Upload a media file as raw binary body (image/* or application/pdf).
728
+ * Headers: Content-Type (mime type), X-Filename (optional original filename)
729
+ * Body: raw file bytes
730
+ * Returns: { mediaPath: string } — relative path usable in message mediaFiles[]
731
+ */
732
+ router.post('/v1/agents/:agentId/media', auth, (req, res, next) => {
733
+ // Buffer raw body up to maxUploadBytes; express.json/urlencoded don't handle binary
734
+ const mimeType = (req.headers['content-type'] ?? '').split(';')[0].trim();
735
+ if (!media_store_1.MediaStore.isAllowedMime(mimeType)) {
736
+ res.status(415).json({ error: 'Unsupported file type. Allowed: image/*, application/pdf' });
737
+ return;
738
+ }
739
+ const chunks = [];
740
+ let size = 0;
741
+ req.on('data', (chunk) => {
742
+ size += chunk.length;
743
+ if (size > media_store_1.MediaStore.maxUploadBytes) {
744
+ if (!res.headersSent)
745
+ res.status(413).json({ error: `File too large (max ${media_store_1.MediaStore.maxUploadBytes / 1024 / 1024}MB)` });
746
+ req.destroy();
747
+ return;
748
+ }
749
+ chunks.push(chunk);
750
+ });
751
+ req.on('end', () => {
752
+ req.rawFileBuffer = Buffer.concat(chunks);
753
+ req.rawFileMime = mimeType;
754
+ next();
755
+ });
756
+ req.on('error', () => {
757
+ if (!res.headersSent)
758
+ res.status(500).json({ error: 'Upload stream error' });
759
+ });
760
+ }, async (req, res) => {
761
+ const { agentId } = req.params;
762
+ const apiKey = req.apiKey;
763
+ if (!(0, auth_1.canAccessAgent)(apiKey, agentId)) {
764
+ res.status(403).json({ error: `API key has no access to agent '${agentId}'` });
765
+ return;
766
+ }
767
+ const runner = agentRunners.get(agentId);
768
+ if (!runner) {
769
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
770
+ return;
771
+ }
772
+ // Rate limit: max 20 uploads/min per API key
773
+ const keyHash = (0, crypto_1.createHash)('sha256').update(apiKey.key).digest('hex').slice(0, 16);
774
+ if (!checkUploadRateLimit(keyHash)) {
775
+ res.status(429).json({ error: 'Too many uploads. Limit: 20 per minute.' });
776
+ return;
777
+ }
778
+ const buf = req.rawFileBuffer;
779
+ const mimeType = req.rawFileMime ?? '';
780
+ if (!buf || buf.length === 0) {
781
+ res.status(400).json({ error: 'No file body received' });
782
+ return;
783
+ }
784
+ // X-Filename: validate length, strip to basename, allow only safe characters
785
+ const rawFilename = req.headers['x-filename'] ?? 'upload';
786
+ if (rawFilename.length > 255) {
787
+ res.status(400).json({ error: 'X-Filename too long (max 255 chars)' });
788
+ return;
789
+ }
790
+ const baseName = path.basename(rawFilename).replace(/\s+/g, '_');
791
+ const safeBaseName = SAFE_FILENAME_RE.test(baseName) ? baseName : 'upload';
792
+ const rawExt = path.extname(safeBaseName).replace(/[^a-zA-Z0-9.]/g, '').slice(0, 10);
793
+ const ext = rawExt || (mimeType.includes('pdf') ? '.pdf' : '.bin');
794
+ const tmpFile = path.join(os.tmpdir(), `gw-${Date.now()}${ext}`);
795
+ try {
796
+ await fsp.writeFile(tmpFile, buf);
797
+ const agentsBaseDir = runner.getAgentsBaseDir();
798
+ // Store under ui-upload/{keyId}/ so each API key's uploads are isolated
799
+ const keySubdir = `ui-upload/${apiKeyId(apiKey.key)}`;
800
+ const mediaPath = media_store_1.MediaStore.copyToMedia(agentsBaseDir, agentId, keySubdir, tmpFile);
801
+ res.json({ mediaPath });
802
+ }
803
+ catch (err) {
804
+ res.status(500).json({ error: `Upload failed: ${err.message}` });
805
+ }
806
+ finally {
807
+ fsp.unlink(tmpFile).catch(() => { });
808
+ }
809
+ });
810
+ /**
811
+ * GET /api/v1/agents/:agentId/media/*filepath
812
+ * Serve a media file. Validates path stays within agent's media directory.
813
+ */
814
+ router.get('/v1/agents/:agentId/media/*', auth, (req, res) => {
815
+ const { agentId } = req.params;
816
+ const apiKey = req.apiKey;
817
+ if (!(0, auth_1.canAccessAgent)(apiKey, agentId)) {
818
+ res.status(403).json({ error: `API key has no access to agent '${agentId}'` });
819
+ return;
820
+ }
821
+ const runner = agentRunners.get(agentId);
822
+ if (!runner) {
823
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
824
+ return;
825
+ }
826
+ const wildcardParam = req.params['0'] ?? '';
827
+ const agentsBaseDir = runner.getAgentsBaseDir();
828
+ let absPath;
829
+ try {
830
+ absPath = media_store_1.MediaStore.resolvePath(agentsBaseDir, agentId, wildcardParam);
831
+ }
832
+ catch {
833
+ res.status(400).json({ error: 'Invalid path' });
834
+ return;
835
+ }
836
+ if (!fs.existsSync(absPath)) {
837
+ res.status(404).json({ error: 'Not found' });
838
+ return;
839
+ }
840
+ // Cache media files for 7 days — content is immutable once written
841
+ res.setHeader('Cache-Control', 'private, max-age=604800, immutable');
842
+ res.setHeader('X-Content-Type-Options', 'nosniff');
843
+ res.setHeader('X-Frame-Options', 'DENY');
844
+ res.sendFile(absPath);
845
+ });
846
+ // Schedule hourly cleanup of stale staging uploads (files older than 24h)
847
+ setInterval(() => {
848
+ const firstRunner = agentRunners.values().next().value;
849
+ if (firstRunner) {
850
+ try {
851
+ media_store_1.MediaStore.cleanupStaging(firstRunner.getAgentsBaseDir());
852
+ }
853
+ catch { /* non-critical */ }
854
+ }
855
+ }, 60 * 60 * 1000).unref(); // unref so cleanup timer doesn't keep process alive
856
+ // ──────────────────────────────────────────────────────────────
857
+ // Model management
858
+ // ──────────────────────────────────────────────────────────────
859
+ /**
860
+ * PUT /api/v1/agents/:agentId/model
861
+ *
862
+ * Set the active model for an agent. Persists to config.json.
863
+ */
864
+ router.put('/v1/agents/:agentId/model', auth, async (req, res) => {
865
+ const { agentId } = req.params;
866
+ const apiKey = req.apiKey;
867
+ if (!(0, auth_1.isAdmin)(apiKey)) {
868
+ res.status(403).json({ error: 'Admin key required' });
869
+ return;
870
+ }
871
+ const runner = agentRunners.get(agentId);
872
+ if (!runner) {
873
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
874
+ return;
875
+ }
876
+ const body = req.body;
877
+ const { model: newModel } = body;
878
+ if (!newModel || typeof newModel !== 'string') {
879
+ res.status(400).json({ error: 'model is required' });
880
+ return;
881
+ }
882
+ try {
883
+ await runner.setModel(newModel);
884
+ res.json({ model: newModel });
885
+ }
886
+ catch (err) {
887
+ const code = err.code;
888
+ if (code === 'UNKNOWN_MODEL') {
889
+ res.status(400).json({ error: `Unknown model: ${newModel}` });
890
+ }
891
+ else {
892
+ res.status(500).json({ error: 'Failed to set model' });
893
+ }
894
+ }
895
+ });
896
+ // ──────────────────────────────────────────────────────────────
897
+ // API Session management (/v1/agents/:agentId/sessions/...)
898
+ // ──────────────────────────────────────────────────────────────
899
+ function resolveApiSession(req, res) {
900
+ const { agentId } = req.params;
901
+ const apiKey = req.apiKey;
902
+ if (!(0, auth_1.canAccessAgent)(apiKey, agentId)) {
903
+ res.status(403).json({ error: `API key has no access to agent '${agentId}'` });
904
+ return null;
905
+ }
906
+ const runner = agentRunners.get(agentId);
907
+ if (!runner) {
908
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
909
+ return null;
910
+ }
911
+ const chatId = (req.query['chat_id'] ?? req.body?.['chat_id']);
912
+ if (!chatId || typeof chatId !== 'string' || !chatId.trim()) {
913
+ res.status(400).json({ error: 'chat_id is required' });
914
+ return null;
915
+ }
916
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(chatId.trim())) {
917
+ res.status(400).json({ error: 'chat_id must be 1-64 alphanumeric characters, hyphens, or underscores' });
918
+ return null;
919
+ }
920
+ return { runner, agentId, chatId: chatId.trim() };
921
+ }
922
+ /**
923
+ * GET /api/v1/agents/:agentId/sessions
924
+ * List API sessions for a chat_id.
925
+ */
926
+ router.get('/v1/agents/:agentId/sessions', auth, async (req, res) => {
927
+ const ctx = resolveApiSession(req, res);
928
+ if (!ctx)
929
+ return;
930
+ const { runner, agentId, chatId } = ctx;
931
+ try {
932
+ const index = await runner.listApiSessions(chatId);
933
+ res.json(index);
934
+ }
935
+ catch (err) {
936
+ res.status(500).json({ error: err.message });
937
+ }
938
+ });
939
+ /**
940
+ * POST /api/v1/agents/:agentId/sessions
941
+ * Create a new API session. Optionally auto-names from a prompt.
942
+ */
943
+ router.post('/v1/agents/:agentId/sessions', auth, async (req, res) => {
944
+ const ctx = resolveApiSession(req, res);
945
+ if (!ctx)
946
+ return;
947
+ const { runner, agentId, chatId } = ctx;
948
+ const body = req.body;
949
+ const promptText = typeof body.prompt === 'string' ? body.prompt.trim() : undefined;
950
+ const explicitName = typeof body.name === 'string' ? body.name.trim() : undefined;
951
+ try {
952
+ const meta = await runner.createApiSession(chatId, promptText, explicitName);
953
+ res.status(201).json({ sessionId: meta.id, sessionName: meta.name, createdAt: meta.createdAt });
954
+ }
955
+ catch (err) {
956
+ res.status(500).json({ error: err.message });
957
+ }
958
+ });
959
+ /**
960
+ * GET /api/v1/agents/:agentId/sessions/:sessionId/info
961
+ */
962
+ router.get('/v1/agents/:agentId/sessions/:sessionId/info', auth, async (req, res) => {
963
+ const ctx = resolveApiSession(req, res);
964
+ if (!ctx)
965
+ return;
966
+ const { runner, chatId } = ctx;
967
+ const { sessionId } = req.params;
968
+ try {
969
+ const info = await runner.getApiSessionInfo(chatId, sessionId);
970
+ if (!info) {
971
+ res.status(404).json({ error: 'Session not found' });
972
+ return;
973
+ }
974
+ res.json(info);
975
+ }
976
+ catch (err) {
977
+ res.status(500).json({ error: err.message });
978
+ }
979
+ });
980
+ /**
981
+ * PATCH /api/v1/agents/:agentId/sessions/:sessionId
982
+ * Rename a session.
983
+ */
984
+ router.patch('/v1/agents/:agentId/sessions/:sessionId', auth, async (req, res) => {
985
+ const ctx = resolveApiSession(req, res);
986
+ if (!ctx)
987
+ return;
988
+ const { runner, chatId } = ctx;
989
+ const { sessionId } = req.params;
990
+ const body = req.body;
991
+ const sessionName = typeof body.sessionName === 'string' ? body.sessionName.trim() : undefined;
992
+ if (!sessionName) {
993
+ res.status(400).json({ error: 'sessionName is required' });
994
+ return;
995
+ }
996
+ try {
997
+ await runner.renameApiSession(chatId, sessionId, sessionName);
998
+ res.json({ sessionId, sessionName });
999
+ }
1000
+ catch (err) {
1001
+ res.status(500).json({ error: err.message });
1002
+ }
1003
+ });
1004
+ /**
1005
+ * DELETE /api/v1/agents/:agentId/sessions/:sessionId
1006
+ */
1007
+ router.delete('/v1/agents/:agentId/sessions/:sessionId', auth, async (req, res) => {
1008
+ const ctx = resolveApiSession(req, res);
1009
+ if (!ctx)
1010
+ return;
1011
+ const { runner, chatId } = ctx;
1012
+ const { sessionId } = req.params;
1013
+ try {
1014
+ await runner.deleteApiSession(chatId, sessionId);
1015
+ res.status(204).send();
1016
+ }
1017
+ catch (err) {
1018
+ res.status(500).json({ error: err.message });
1019
+ }
1020
+ });
1021
+ /**
1022
+ * POST /api/v1/agents/:agentId/sessions/:sessionId/clear
1023
+ */
1024
+ router.post('/v1/agents/:agentId/sessions/:sessionId/clear', auth, async (req, res) => {
1025
+ const ctx = resolveApiSession(req, res);
1026
+ if (!ctx)
1027
+ return;
1028
+ const { runner, chatId } = ctx;
1029
+ const { sessionId } = req.params;
1030
+ try {
1031
+ const result = await runner.executeApiCommand(sessionId, chatId, '/clear');
1032
+ res.json(result);
1033
+ }
1034
+ catch (err) {
1035
+ res.status(500).json({ error: err.message });
1036
+ }
1037
+ });
1038
+ /**
1039
+ * POST /api/v1/agents/:agentId/sessions/:sessionId/compact
1040
+ */
1041
+ router.post('/v1/agents/:agentId/sessions/:sessionId/compact', auth, async (req, res) => {
1042
+ const ctx = resolveApiSession(req, res);
1043
+ if (!ctx)
1044
+ return;
1045
+ const { runner, chatId } = ctx;
1046
+ const { sessionId } = req.params;
1047
+ try {
1048
+ const result = await runner.executeApiCommand(sessionId, chatId, '/compact');
1049
+ res.json(result);
1050
+ }
1051
+ catch (err) {
1052
+ res.status(500).json({ error: err.message });
1053
+ }
1054
+ });
1055
+ /**
1056
+ * POST /api/v1/agents/:agentId/sessions/:sessionId/stop
1057
+ */
1058
+ router.post('/v1/agents/:agentId/sessions/:sessionId/stop', auth, async (req, res) => {
1059
+ const ctx = resolveApiSession(req, res);
1060
+ if (!ctx)
1061
+ return;
1062
+ const { runner, chatId } = ctx;
1063
+ const { sessionId } = req.params;
1064
+ try {
1065
+ const result = await runner.executeApiCommand(sessionId, chatId, '/stop');
1066
+ res.json(result);
1067
+ }
1068
+ catch (err) {
1069
+ res.status(500).json({ error: err.message });
1070
+ }
1071
+ });
1072
+ /**
1073
+ * POST /api/v1/agents/:agentId/sessions/:sessionId/restart
1074
+ */
1075
+ router.post('/v1/agents/:agentId/sessions/:sessionId/restart', auth, async (req, res) => {
1076
+ const ctx = resolveApiSession(req, res);
1077
+ if (!ctx)
1078
+ return;
1079
+ const { runner, chatId } = ctx;
1080
+ const { sessionId } = req.params;
1081
+ try {
1082
+ const result = await runner.executeApiCommand(sessionId, chatId, '/restart');
1083
+ res.json(result);
1084
+ }
1085
+ catch (err) {
1086
+ res.status(500).json({ error: err.message });
1087
+ }
1088
+ });
1089
+ return router;
1090
+ }
1091
+ function parseHistoryChatId(fullChatId) {
1092
+ if (fullChatId.startsWith('telegram-'))
1093
+ return { source: 'telegram', rawChatId: fullChatId.slice(9) };
1094
+ if (fullChatId.startsWith('discord-'))
1095
+ return { source: 'discord', rawChatId: fullChatId.slice(8) };
1096
+ return { source: 'api', rawChatId: fullChatId };
1097
+ }
1098
+ //# sourceMappingURL=router.js.map