@luckydraw/cumulus 0.27.40 → 0.28.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 (99) hide show
  1. package/dist/gateway/adapters/webchat.d.ts +5 -7
  2. package/dist/gateway/adapters/webchat.d.ts.map +1 -1
  3. package/dist/gateway/adapters/webchat.js +204 -57
  4. package/dist/gateway/adapters/webchat.js.map +1 -1
  5. package/dist/gateway/config.d.ts +48 -0
  6. package/dist/gateway/config.d.ts.map +1 -1
  7. package/dist/gateway/config.js +90 -0
  8. package/dist/gateway/config.js.map +1 -1
  9. package/dist/gateway/daemon.d.ts.map +1 -1
  10. package/dist/gateway/daemon.js +268 -27
  11. package/dist/gateway/daemon.js.map +1 -1
  12. package/dist/gateway/gateway-agents-mcp.js +570 -31
  13. package/dist/gateway/gateway-agents-mcp.js.map +1 -1
  14. package/dist/gateway/hooks.d.ts +101 -0
  15. package/dist/gateway/hooks.d.ts.map +1 -0
  16. package/dist/gateway/hooks.js +200 -0
  17. package/dist/gateway/hooks.js.map +1 -0
  18. package/dist/gateway/scheduler.d.ts +50 -0
  19. package/dist/gateway/scheduler.d.ts.map +1 -0
  20. package/dist/gateway/scheduler.js +263 -0
  21. package/dist/gateway/scheduler.js.map +1 -0
  22. package/dist/gateway/server.d.ts +16 -1
  23. package/dist/gateway/server.d.ts.map +1 -1
  24. package/dist/gateway/server.js +451 -22
  25. package/dist/gateway/server.js.map +1 -1
  26. package/dist/gateway/static/blex.min.js +74 -17
  27. package/dist/gateway/static/chat.html +1 -4
  28. package/dist/gateway/static/dashboard.html +151 -0
  29. package/dist/gateway/static/dashboard.js +229 -0
  30. package/dist/gateway/static/widget.js +656 -460
  31. package/dist/index.d.ts +13 -5
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +9 -3
  34. package/dist/index.js.map +1 -1
  35. package/dist/lib/agentic-loop.d.ts +124 -0
  36. package/dist/lib/agentic-loop.d.ts.map +1 -0
  37. package/dist/lib/agentic-loop.js +338 -0
  38. package/dist/lib/agentic-loop.js.map +1 -0
  39. package/dist/lib/config.d.ts +28 -15
  40. package/dist/lib/config.d.ts.map +1 -1
  41. package/dist/lib/config.js +47 -41
  42. package/dist/lib/config.js.map +1 -1
  43. package/dist/lib/gateway.d.ts +34 -3
  44. package/dist/lib/gateway.d.ts.map +1 -1
  45. package/dist/lib/gateway.js +584 -37
  46. package/dist/lib/gateway.js.map +1 -1
  47. package/dist/lib/huggingface-provider.d.ts +26 -0
  48. package/dist/lib/huggingface-provider.d.ts.map +1 -0
  49. package/dist/lib/huggingface-provider.js +338 -0
  50. package/dist/lib/huggingface-provider.js.map +1 -0
  51. package/dist/lib/provider.d.ts +71 -0
  52. package/dist/lib/provider.d.ts.map +1 -0
  53. package/dist/lib/provider.js +90 -0
  54. package/dist/lib/provider.js.map +1 -0
  55. package/dist/lib/tools/ask-user-question.d.ts +14 -0
  56. package/dist/lib/tools/ask-user-question.d.ts.map +1 -0
  57. package/dist/lib/tools/ask-user-question.js +74 -0
  58. package/dist/lib/tools/ask-user-question.js.map +1 -0
  59. package/dist/lib/tools/bash.d.ts +6 -0
  60. package/dist/lib/tools/bash.d.ts.map +1 -0
  61. package/dist/lib/tools/bash.js +85 -0
  62. package/dist/lib/tools/bash.js.map +1 -0
  63. package/dist/lib/tools/edit.d.ts +6 -0
  64. package/dist/lib/tools/edit.d.ts.map +1 -0
  65. package/dist/lib/tools/edit.js +79 -0
  66. package/dist/lib/tools/edit.js.map +1 -0
  67. package/dist/lib/tools/glob.d.ts +6 -0
  68. package/dist/lib/tools/glob.d.ts.map +1 -0
  69. package/dist/lib/tools/glob.js +91 -0
  70. package/dist/lib/tools/glob.js.map +1 -0
  71. package/dist/lib/tools/grep.d.ts +6 -0
  72. package/dist/lib/tools/grep.d.ts.map +1 -0
  73. package/dist/lib/tools/grep.js +110 -0
  74. package/dist/lib/tools/grep.js.map +1 -0
  75. package/dist/lib/tools/index.d.ts +29 -0
  76. package/dist/lib/tools/index.d.ts.map +1 -0
  77. package/dist/lib/tools/index.js +52 -0
  78. package/dist/lib/tools/index.js.map +1 -0
  79. package/dist/lib/tools/read.d.ts +6 -0
  80. package/dist/lib/tools/read.d.ts.map +1 -0
  81. package/dist/lib/tools/read.js +80 -0
  82. package/dist/lib/tools/read.js.map +1 -0
  83. package/dist/lib/tools/tool-search.d.ts +30 -0
  84. package/dist/lib/tools/tool-search.d.ts.map +1 -0
  85. package/dist/lib/tools/tool-search.js +108 -0
  86. package/dist/lib/tools/tool-search.js.map +1 -0
  87. package/dist/lib/tools/web-fetch.d.ts +9 -0
  88. package/dist/lib/tools/web-fetch.d.ts.map +1 -0
  89. package/dist/lib/tools/web-fetch.js +114 -0
  90. package/dist/lib/tools/web-fetch.js.map +1 -0
  91. package/dist/lib/tools/web-search.d.ts +12 -0
  92. package/dist/lib/tools/web-search.d.ts.map +1 -0
  93. package/dist/lib/tools/web-search.js +89 -0
  94. package/dist/lib/tools/web-search.js.map +1 -0
  95. package/dist/lib/tools/write.d.ts +6 -0
  96. package/dist/lib/tools/write.d.ts.map +1 -0
  97. package/dist/lib/tools/write.js +44 -0
  98. package/dist/lib/tools/write.js.map +1 -0
  99. package/package.json +3 -2
@@ -10,9 +10,12 @@
10
10
  * GET /api/threads — list all threads
11
11
  * DELETE /api/thread/:name — delete thread (requires X-Confirm: delete)
12
12
  * POST /api/migrate — sync thread + clone repo + set projectDir
13
+ * POST /api/media/upload — upload file to media directory (returns URL)
14
+ * GET /media/* — serve uploaded media files (no auth)
13
15
  * GET /health — health check (no auth)
14
16
  */
15
17
  import { execFileSync } from 'child_process';
18
+ import * as crypto from 'crypto';
16
19
  import * as fs from 'fs';
17
20
  import * as http from 'http';
18
21
  import * as os from 'os';
@@ -21,11 +24,12 @@ import { WebSocketServer } from 'ws';
21
24
  import { loadThreadConfig, saveThreadConfig } from '../lib/config.js';
22
25
  import { loadEmbeddings, generateMissingEmbeddings } from '../lib/embeddings.js';
23
26
  import { ExecutorRegistry } from '../lib/executor.js';
24
- import { sendMessage, getOrCreateThread, clearThreadCache, } from '../lib/gateway.js';
27
+ import { sendMessage, getOrCreateThread, clearThreadCache, getActiveSubprocessCount, } from '../lib/gateway.js';
25
28
  import { catchUpSegmentation, loadSegmentBoundaries, saveSegmentBoundaries, } from '../lib/segments.js';
26
29
  import { listTemplates, scaffoldFromTemplate } from '../lib/templates.js';
27
- import { serveStaticFile } from './adapters/webchat.js';
30
+ import { serveStaticFile, getMediaDir } from './adapters/webchat.js';
28
31
  import { authenticate } from './auth.js';
32
+ import { handleEmailHook, handleFormHook, handleGenericWebhook, } from './hooks.js';
29
33
  import { configureVapid, sendNotification, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe, } from './push.js';
30
34
  const threadQueues = new Map();
31
35
  const threadBusy = new Map();
@@ -88,6 +92,7 @@ async function processMessage(threadName, item, opts) {
88
92
  extraMcpServers: opts.extraMcpServers,
89
93
  gatewayAgentsConfig: opts.gatewayAgentsConfig,
90
94
  executorRegistry: opts.executorRegistry,
95
+ hfApiKey: opts.hfApiKey,
91
96
  signal: ac.signal,
92
97
  onToken: text => sendSSE('token', { text }),
93
98
  onSegment: segment => sendSSE('segment', segment),
@@ -113,6 +118,83 @@ async function processMessage(threadName, item, opts) {
113
118
  }
114
119
  }
115
120
  }
121
+ // ─── Webhook endpoints ──────────────────────────────────────────────────────
122
+ async function handleWebhook(type, name, req, res, pipelineOpts, hooksConfig) {
123
+ if (!hooksConfig) {
124
+ jsonResponse(res, 404, { error: 'Webhooks not configured' });
125
+ return;
126
+ }
127
+ let body;
128
+ try {
129
+ body = await readBody(req);
130
+ }
131
+ catch {
132
+ jsonResponse(res, 400, { error: 'Could not read request body' });
133
+ return;
134
+ }
135
+ const hookOpts = {
136
+ basePath: pipelineOpts.basePath,
137
+ claudePath: pipelineOpts.claudePath,
138
+ sharedMcpPort: pipelineOpts.sharedMcpPort,
139
+ projectRoot: pipelineOpts.projectRoot,
140
+ extraMcpServers: pipelineOpts.extraMcpServers,
141
+ gatewayAgentsConfig: pipelineOpts.gatewayAgentsConfig,
142
+ executorRegistry: pipelineOpts.executorRegistry,
143
+ broadcastToThread: pipelineOpts.broadcastToThread,
144
+ };
145
+ try {
146
+ switch (type) {
147
+ case 'email': {
148
+ if (!hooksConfig.email?.enabled) {
149
+ jsonResponse(res, 404, { error: 'Email hooks not enabled' });
150
+ return;
151
+ }
152
+ const result = await handleEmailHook(body, req.headers, hooksConfig.email, hookOpts);
153
+ // Return 200 immediately — processing continues async
154
+ jsonResponse(res, 200, result);
155
+ return;
156
+ }
157
+ case 'form': {
158
+ if (!hooksConfig.form?.enabled) {
159
+ jsonResponse(res, 404, { error: 'Form hooks not enabled' });
160
+ return;
161
+ }
162
+ const result = await handleFormHook(body, hooksConfig.form, hookOpts);
163
+ jsonResponse(res, 200, result);
164
+ return;
165
+ }
166
+ case 'webhook': {
167
+ if (!name) {
168
+ jsonResponse(res, 400, { error: 'Missing webhook name: POST /api/hooks/webhook/:name' });
169
+ return;
170
+ }
171
+ const webhookConfig = hooksConfig.webhooks?.[name];
172
+ if (!webhookConfig) {
173
+ jsonResponse(res, 404, { error: `Webhook "${name}" not configured` });
174
+ return;
175
+ }
176
+ const result = await handleGenericWebhook(name, body, webhookConfig, hookOpts);
177
+ jsonResponse(res, 200, result);
178
+ return;
179
+ }
180
+ default:
181
+ jsonResponse(res, 404, { error: `Unknown hook type: ${type}` });
182
+ }
183
+ }
184
+ catch (err) {
185
+ const message = err instanceof Error ? err.message : String(err);
186
+ if (message.includes('Invalid webhook signature')) {
187
+ jsonResponse(res, 401, { error: 'Invalid webhook signature' });
188
+ }
189
+ else if (message.includes('Invalid JSON') || message.includes('Unexpected token')) {
190
+ jsonResponse(res, 400, { error: 'Invalid JSON body' });
191
+ }
192
+ else {
193
+ console.error(`[Gateway] Webhook error (${type}):`, err);
194
+ jsonResponse(res, 500, { error: 'Webhook processing error' });
195
+ }
196
+ }
197
+ }
116
198
  // ─── Agent endpoints ─────────────────────────────────────────────────────────
117
199
  async function handleListAgents(res) {
118
200
  try {
@@ -153,16 +235,11 @@ async function handleAgentInject(req, res, pipelineOpts) {
153
235
  // Resolve target thread names
154
236
  let targetNames;
155
237
  if (targets === '__broadcast__') {
156
- // Broadcast to all threads except sender
157
- if (!fs.existsSync(THREADS_DIR)) {
158
- jsonResponse(res, 200, { results: [] });
159
- return;
160
- }
161
- targetNames = fs
162
- .readdirSync(THREADS_DIR)
163
- .filter(f => f.endsWith('.jsonl'))
164
- .map(f => f.replace(/\.jsonl$/, ''))
165
- .filter(name => name !== sender);
238
+ // Broadcast-all is disabled agents must target specific threads
239
+ jsonResponse(res, 400, {
240
+ error: 'Broadcast to all agents is disabled. Use send_to_agent with specific targets.',
241
+ });
242
+ return;
166
243
  }
167
244
  else if (Array.isArray(targets)) {
168
245
  targetNames = targets;
@@ -202,7 +279,7 @@ async function handleAgentInject(req, res, pipelineOpts) {
202
279
  visibility,
203
280
  });
204
281
  // Ensure thread exists
205
- await getOrCreateThread(targetName, pipelineOpts.basePath);
282
+ await getOrCreateThread(targetName);
206
283
  // Send as a message through the pipeline (fire-and-forget)
207
284
  threadBusy.set(targetName, true);
208
285
  const broadcast = pipelineOpts.broadcastToThread;
@@ -392,6 +469,18 @@ function parseRoute(url) {
392
469
  query,
393
470
  };
394
471
  }
472
+ // Match /api/hooks/:type or /api/hooks/:type/:name
473
+ const hooksMatch = pathPart.match(/^\/api\/hooks\/([^/]+)(?:\/([^/]+))?$/);
474
+ if (hooksMatch) {
475
+ return {
476
+ path: `/api/hooks/:type${hooksMatch[2] ? '/:name' : ''}`,
477
+ params: {
478
+ type: decodeURIComponent(hooksMatch[1]),
479
+ ...(hooksMatch[2] ? { name: decodeURIComponent(hooksMatch[2]) } : {}),
480
+ },
481
+ query,
482
+ };
483
+ }
395
484
  return { path: pathPart, params: {}, query };
396
485
  }
397
486
  function readBody(req) {
@@ -407,9 +496,8 @@ function jsonResponse(res, status, data) {
407
496
  res.end(JSON.stringify(data));
408
497
  }
409
498
  // ─── Route handlers ──────────────────────────────────────────────────────────
410
- // Default threads directory — can be overridden via basePath in server options
411
499
  let THREADS_DIR = path.join(os.homedir(), '.cumulus', 'threads');
412
- /** Override the threads directory (used by startGatewayServer when basePath is set) */
500
+ /** Override the threads directory (used by tests to isolate from production) */
413
501
  export function setThreadsDir(dir) {
414
502
  THREADS_DIR = dir;
415
503
  }
@@ -773,6 +861,8 @@ async function handleUpdateThreadConfig(threadName, req, res) {
773
861
  updates.devices = body.devices;
774
862
  if (body.gitRemoteUrl !== undefined)
775
863
  updates.gitRemoteUrl = body.gitRemoteUrl;
864
+ if (body.model !== undefined)
865
+ updates.model = body.model;
776
866
  await saveThreadConfig(threadName, updates);
777
867
  const updated = await loadThreadConfig(threadName);
778
868
  jsonResponse(res, 200, { config: updated });
@@ -781,6 +871,200 @@ async function handleUpdateThreadConfig(threadName, req, res) {
781
871
  jsonResponse(res, 500, { error: String(err) });
782
872
  }
783
873
  }
874
+ // ─── Pipeline dashboard handlers ───────────────────────────
875
+ const STAGE_ORDER = [
876
+ 'prospect',
877
+ 'outreach',
878
+ 'warm-engagement',
879
+ 'proposal',
880
+ 'onboarding',
881
+ 'active-client',
882
+ 'archived',
883
+ ];
884
+ async function handleGetDashboard(res) {
885
+ try {
886
+ const files = fs.readdirSync(THREADS_DIR);
887
+ const threads = [];
888
+ for (const file of files) {
889
+ if (!file.endsWith('.config.json'))
890
+ continue;
891
+ const threadName = file.replace('.config.json', '');
892
+ const config = await loadThreadConfig(threadName);
893
+ // Skip threads without pipeline metadata
894
+ if (!config.pipeline?.stage)
895
+ continue;
896
+ // Get last activity from JSONL last line timestamp (read last 1KB only)
897
+ const historyPath = path.join(THREADS_DIR, `${threadName}.jsonl`);
898
+ let lastActivity;
899
+ try {
900
+ const stats = fs.statSync(historyPath);
901
+ if (stats.size > 0) {
902
+ const fd = fs.openSync(historyPath, 'r');
903
+ const bufferSize = Math.min(1024, stats.size);
904
+ const buffer = Buffer.alloc(bufferSize);
905
+ fs.readSync(fd, buffer, 0, bufferSize, stats.size - bufferSize);
906
+ fs.closeSync(fd);
907
+ const lines = buffer.toString().trim().split('\n');
908
+ const lastLine = lines[lines.length - 1];
909
+ if (lastLine) {
910
+ try {
911
+ const msg = JSON.parse(lastLine);
912
+ lastActivity = msg.timestamp;
913
+ }
914
+ catch {
915
+ // Malformed last line — skip
916
+ }
917
+ }
918
+ }
919
+ }
920
+ catch {
921
+ // History file may not exist
922
+ }
923
+ threads.push({
924
+ name: threadName,
925
+ stage: config.pipeline.stage,
926
+ score: config.pipeline.score ?? 0,
927
+ lastContact: config.pipeline.lastContact,
928
+ lastActivity,
929
+ nextAction: config.pipeline.nextAction,
930
+ nextActionDate: config.pipeline.nextActionDate,
931
+ });
932
+ }
933
+ // Group by stage
934
+ const byStage = {};
935
+ for (const stage of STAGE_ORDER) {
936
+ byStage[stage] = threads.filter(t => t.stage === stage);
937
+ }
938
+ // Sort threads within each stage by score (descending)
939
+ for (const stage of Object.keys(byStage)) {
940
+ byStage[stage].sort((a, b) => b.score - a.score);
941
+ }
942
+ jsonResponse(res, 200, {
943
+ threads: byStage,
944
+ stats: {
945
+ total: threads.length,
946
+ byStage: Object.fromEntries(STAGE_ORDER.map(s => [s, byStage[s].length])),
947
+ },
948
+ });
949
+ }
950
+ catch (err) {
951
+ jsonResponse(res, 500, { error: String(err) });
952
+ }
953
+ }
954
+ async function handleUpdatePipeline(threadName, req, res) {
955
+ let body;
956
+ try {
957
+ const raw = await readBody(req);
958
+ body = JSON.parse(raw);
959
+ }
960
+ catch {
961
+ jsonResponse(res, 400, { error: 'Invalid JSON body' });
962
+ return;
963
+ }
964
+ // Validate stage if provided
965
+ if (body.stage !== undefined &&
966
+ !STAGE_ORDER.includes(body.stage)) {
967
+ jsonResponse(res, 400, { error: `Invalid stage. Must be one of: ${STAGE_ORDER.join(', ')}` });
968
+ return;
969
+ }
970
+ // Validate score if provided
971
+ if (body.score !== undefined) {
972
+ const score = Number(body.score);
973
+ if (isNaN(score) || score < 0 || score > 100) {
974
+ jsonResponse(res, 400, { error: 'Score must be a number between 0 and 100' });
975
+ return;
976
+ }
977
+ }
978
+ try {
979
+ // Read-merge-write to avoid clobbering sibling pipeline fields
980
+ const existing = await loadThreadConfig(threadName);
981
+ const pipelineUpdates = {};
982
+ for (const key of ['stage', 'score', 'lastContact', 'nextAction', 'nextActionDate']) {
983
+ if (body[key] !== undefined)
984
+ pipelineUpdates[key] = body[key];
985
+ }
986
+ existing.pipeline = { ...existing.pipeline, ...pipelineUpdates };
987
+ await saveThreadConfig(threadName, existing);
988
+ jsonResponse(res, 200, { pipeline: existing.pipeline, thread: threadName });
989
+ }
990
+ catch (err) {
991
+ jsonResponse(res, 500, { error: String(err) });
992
+ }
993
+ }
994
+ // ─── Schedule handlers ─────────────────────────────────────
995
+ async function handleListSchedules(threadName, res) {
996
+ try {
997
+ const config = await loadThreadConfig(threadName);
998
+ jsonResponse(res, 200, { schedules: config.schedules || [] });
999
+ }
1000
+ catch (err) {
1001
+ jsonResponse(res, 500, { error: String(err) });
1002
+ }
1003
+ }
1004
+ async function handleCreateSchedule(threadName, req, res, scheduler) {
1005
+ let body;
1006
+ try {
1007
+ const raw = await readBody(req);
1008
+ body = JSON.parse(raw);
1009
+ }
1010
+ catch {
1011
+ jsonResponse(res, 400, { error: 'Invalid JSON body' });
1012
+ return;
1013
+ }
1014
+ if (!body.id || !body.trigger || !body.message) {
1015
+ jsonResponse(res, 400, { error: 'Missing required fields: id, trigger, message' });
1016
+ return;
1017
+ }
1018
+ if (body.trigger !== 'once' && body.trigger !== 'cron') {
1019
+ jsonResponse(res, 400, { error: 'trigger must be "once" or "cron"' });
1020
+ return;
1021
+ }
1022
+ if (body.trigger === 'once' && !body.at) {
1023
+ jsonResponse(res, 400, { error: 'Once trigger requires "at" datetime field' });
1024
+ return;
1025
+ }
1026
+ if (body.trigger === 'cron' && !body.cron) {
1027
+ jsonResponse(res, 400, { error: 'Cron trigger requires "cron" expression field' });
1028
+ return;
1029
+ }
1030
+ try {
1031
+ const config = await loadThreadConfig(threadName);
1032
+ const schedules = config.schedules || [];
1033
+ // Replace if same ID exists
1034
+ const filtered = schedules.filter(s => s.id !== body.id);
1035
+ filtered.push({
1036
+ id: body.id,
1037
+ trigger: body.trigger,
1038
+ ...(body.at ? { at: body.at } : {}),
1039
+ ...(body.cron ? { cron: body.cron } : {}),
1040
+ message: body.message,
1041
+ });
1042
+ await saveThreadConfig(threadName, { schedules: filtered });
1043
+ scheduler?.reloadThread(threadName);
1044
+ jsonResponse(res, 200, { created: true, id: body.id, trigger: body.trigger });
1045
+ }
1046
+ catch (err) {
1047
+ jsonResponse(res, 500, { error: String(err) });
1048
+ }
1049
+ }
1050
+ async function handleDeleteSchedule(threadName, scheduleId, res, scheduler) {
1051
+ try {
1052
+ const config = await loadThreadConfig(threadName);
1053
+ const schedules = config.schedules || [];
1054
+ const before = schedules.length;
1055
+ const filtered = schedules.filter(s => s.id !== scheduleId);
1056
+ if (filtered.length === before) {
1057
+ jsonResponse(res, 404, { error: `Schedule "${scheduleId}" not found` });
1058
+ return;
1059
+ }
1060
+ await saveThreadConfig(threadName, { schedules: filtered });
1061
+ scheduler?.reloadThread(threadName);
1062
+ jsonResponse(res, 200, { cancelled: true, id: scheduleId });
1063
+ }
1064
+ catch (err) {
1065
+ jsonResponse(res, 500, { error: String(err) });
1066
+ }
1067
+ }
784
1068
  async function handleListTemplates(res) {
785
1069
  try {
786
1070
  const templates = listTemplates();
@@ -1002,16 +1286,95 @@ async function handleNotify(req, res) {
1002
1286
  jsonResponse(res, 500, { error: String(err) });
1003
1287
  }
1004
1288
  }
1289
+ // ─── Media upload ────────────────────────────────────────────────────────────
1290
+ async function handleMediaUpload(req, res) {
1291
+ const mediaDir = getMediaDir();
1292
+ // Read raw body (binary)
1293
+ const chunks = [];
1294
+ for await (const chunk of req) {
1295
+ chunks.push(chunk);
1296
+ }
1297
+ const body = Buffer.concat(chunks);
1298
+ if (body.length === 0) {
1299
+ jsonResponse(res, 400, { error: 'Empty request body' });
1300
+ return;
1301
+ }
1302
+ // Max 50MB
1303
+ if (body.length > 50 * 1024 * 1024) {
1304
+ jsonResponse(res, 413, { error: 'File too large (max 50MB)' });
1305
+ return;
1306
+ }
1307
+ const contentType = req.headers['content-type'] || '';
1308
+ if (contentType.includes('multipart/form-data')) {
1309
+ const boundaryMatch = contentType.match(/boundary=(.+)/);
1310
+ if (!boundaryMatch) {
1311
+ jsonResponse(res, 400, { error: 'Missing multipart boundary' });
1312
+ return;
1313
+ }
1314
+ const boundary = boundaryMatch[1];
1315
+ const parts = parseMultipart(body, boundary);
1316
+ if (parts.length === 0) {
1317
+ jsonResponse(res, 400, { error: 'No file found in multipart body' });
1318
+ return;
1319
+ }
1320
+ const results = [];
1321
+ for (const part of parts) {
1322
+ const result = saveMediaFile(mediaDir, part.filename || 'upload', part.data);
1323
+ results.push(result);
1324
+ }
1325
+ jsonResponse(res, 200, { files: results });
1326
+ }
1327
+ else {
1328
+ // Raw binary upload — filename from query param or X-Filename header
1329
+ const urlObj = new URL(req.url || '/', `http://${req.headers.host}`);
1330
+ const filename = urlObj.searchParams.get('filename') || req.headers['x-filename'] || 'upload';
1331
+ const result = saveMediaFile(mediaDir, filename, body);
1332
+ jsonResponse(res, 200, result);
1333
+ }
1334
+ }
1335
+ function parseMultipart(body, boundary) {
1336
+ const parts = [];
1337
+ const bodyStr = body.toString('binary');
1338
+ const sections = bodyStr.split(`--${boundary}`);
1339
+ for (const section of sections) {
1340
+ if (section === '' || section.startsWith('--'))
1341
+ continue;
1342
+ const headerEnd = section.indexOf('\r\n\r\n');
1343
+ if (headerEnd === -1)
1344
+ continue;
1345
+ const headers = section.slice(0, headerEnd);
1346
+ const dataStr = section.slice(headerEnd + 4);
1347
+ const trimmed = dataStr.endsWith('\r\n') ? dataStr.slice(0, -2) : dataStr;
1348
+ const filenameMatch = headers.match(/filename="([^"]+)"/);
1349
+ const ctMatch = headers.match(/Content-Type:\s*(.+)/i);
1350
+ parts.push({
1351
+ filename: filenameMatch?.[1],
1352
+ contentType: ctMatch?.[1]?.trim(),
1353
+ data: Buffer.from(trimmed, 'binary'),
1354
+ });
1355
+ }
1356
+ return parts;
1357
+ }
1358
+ function saveMediaFile(mediaDir, originalFilename, data) {
1359
+ const ext = path.extname(originalFilename).toLowerCase() || '.bin';
1360
+ const baseName = path
1361
+ .basename(originalFilename, ext)
1362
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
1363
+ .slice(0, 64);
1364
+ const hash = crypto.createHash('md5').update(data).digest('hex').slice(0, 8);
1365
+ const filename = `${baseName}-${hash}${ext}`;
1366
+ fs.writeFileSync(path.join(mediaDir, filename), data);
1367
+ return {
1368
+ filename,
1369
+ url: `/media/${filename}`,
1370
+ size: data.length,
1371
+ };
1372
+ }
1005
1373
  // ─── Server ──────────────────────────────────────────────────────────────────
1006
1374
  export async function startGatewayServer(options) {
1007
1375
  const { port, ...pipelineOpts } = options;
1008
1376
  let currentApiKeys = options.apiKeys;
1009
- // Override threads directory if basePath is provided
1010
- if (options.basePath) {
1011
- const threadsDir = path.join(options.basePath, 'threads');
1012
- fs.mkdirSync(threadsDir, { recursive: true });
1013
- setThreadsDir(threadsDir);
1014
- }
1377
+ let scheduler;
1015
1378
  // Create or use provided executor registry
1016
1379
  const executorRegistry = options.executorRegistry || new ExecutorRegistry();
1017
1380
  pipelineOpts.executorRegistry = executorRegistry;
@@ -1036,7 +1399,11 @@ export async function startGatewayServer(options) {
1036
1399
  const { path: routePath, params, query } = parseRoute(req.url || '/');
1037
1400
  // Health check — no auth required
1038
1401
  if (routePath === '/health' && req.method === 'GET') {
1039
- jsonResponse(res, 200, { status: 'ok', timestamp: new Date().toISOString() });
1402
+ jsonResponse(res, 200, {
1403
+ status: 'ok',
1404
+ timestamp: new Date().toISOString(),
1405
+ activeSubprocesses: getActiveSubprocessCount(),
1406
+ });
1040
1407
  return;
1041
1408
  }
1042
1409
  // Static files — no auth required (/chat, /widget.js)
@@ -1048,6 +1415,12 @@ export async function startGatewayServer(options) {
1048
1415
  handleGetVapidKey(res, vapidConfig);
1049
1416
  return;
1050
1417
  }
1418
+ // POST /api/hooks/:type — inbound webhooks (no API key auth — uses signature verification)
1419
+ if ((routePath === '/api/hooks/:type' || routePath === '/api/hooks/:type/:name') &&
1420
+ req.method === 'POST') {
1421
+ await handleWebhook(params.type, params.name, req, res, pipelineOpts, options.hooks);
1422
+ return;
1423
+ }
1051
1424
  // All other routes require auth
1052
1425
  if (!authenticate(req, res, currentApiKeys))
1053
1426
  return;
@@ -1098,6 +1471,34 @@ export async function startGatewayServer(options) {
1098
1471
  await handleUpdateThreadConfig(params.name, req, res);
1099
1472
  return;
1100
1473
  }
1474
+ // PUT /api/thread/:name/pipeline — update pipeline metadata (for dashboard drag-drop)
1475
+ if (routePath === '/api/thread/:name/pipeline' && req.method === 'PUT') {
1476
+ await handleUpdatePipeline(params.name, req, res);
1477
+ return;
1478
+ }
1479
+ // GET /api/thread/:name/schedules — list schedules
1480
+ if (routePath === '/api/thread/:name/schedules' && req.method === 'GET') {
1481
+ await handleListSchedules(params.name, res);
1482
+ return;
1483
+ }
1484
+ // POST /api/thread/:name/schedules — create/update a schedule
1485
+ if (routePath === '/api/thread/:name/schedules' && req.method === 'POST') {
1486
+ await handleCreateSchedule(params.name, req, res, scheduler);
1487
+ return;
1488
+ }
1489
+ // DELETE /api/thread/:name/schedules/:id — cancel a schedule
1490
+ if (routePath.startsWith('/api/thread/:name/schedules/') && req.method === 'DELETE') {
1491
+ const scheduleId = routePath.replace('/api/thread/:name/schedules/', '');
1492
+ if (scheduleId) {
1493
+ await handleDeleteSchedule(params.name, decodeURIComponent(scheduleId), res, scheduler);
1494
+ return;
1495
+ }
1496
+ }
1497
+ // GET /api/dashboard — pipeline dashboard data
1498
+ if (routePath === '/api/dashboard' && req.method === 'GET') {
1499
+ await handleGetDashboard(res);
1500
+ return;
1501
+ }
1101
1502
  // GET /api/threads
1102
1503
  if (routePath === '/api/threads' && req.method === 'GET') {
1103
1504
  await handleListThreads(res, executorRegistry);
@@ -1113,6 +1514,26 @@ export async function startGatewayServer(options) {
1113
1514
  await handleListTemplates(res);
1114
1515
  return;
1115
1516
  }
1517
+ // GET /api/models — list available models for per-thread selection
1518
+ if (routePath === '/api/models' && req.method === 'GET') {
1519
+ // Build models list with global default marked
1520
+ let models = (options.models || []).map(m => ({ ...m }));
1521
+ if (models.length === 0) {
1522
+ // Fallback: just Claude
1523
+ models = [
1524
+ { id: 'claude', label: 'Claude (CLI)', provider: 'claude-cli', default: true },
1525
+ ];
1526
+ }
1527
+ else {
1528
+ // Clear all defaults, then set the global default (or first if none specified)
1529
+ const globalDefault = options.model || models[0].id;
1530
+ for (const m of models) {
1531
+ m.default = m.id === globalDefault ? true : undefined;
1532
+ }
1533
+ }
1534
+ jsonResponse(res, 200, { models });
1535
+ return;
1536
+ }
1116
1537
  // GET /api/agents — list agents
1117
1538
  if (routePath === '/api/agents' && req.method === 'GET') {
1118
1539
  await handleListAgents(res);
@@ -1148,6 +1569,11 @@ export async function startGatewayServer(options) {
1148
1569
  await handleNotify(req, res);
1149
1570
  return;
1150
1571
  }
1572
+ // POST /api/media/upload — upload files to media directory
1573
+ if (routePath === '/api/media/upload' && req.method === 'POST') {
1574
+ await handleMediaUpload(req, res);
1575
+ return;
1576
+ }
1151
1577
  // 404
1152
1578
  jsonResponse(res, 404, { error: 'Not found' });
1153
1579
  }
@@ -1210,6 +1636,9 @@ export async function startGatewayServer(options) {
1210
1636
  setBroadcastToThread: (fn) => {
1211
1637
  pipelineOpts.broadcastToThread = fn;
1212
1638
  },
1639
+ setScheduler: (s) => {
1640
+ scheduler = s;
1641
+ },
1213
1642
  close: async () => {
1214
1643
  // Abort any in-flight message processing
1215
1644
  threadQueues.clear();