tide-commander 1.97.0 → 1.99.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 (71) hide show
  1. package/dist/assets/{BossLogsModal-CT25hD17.js → BossLogsModal-CDel834o.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-9rS7AFkZ.js → BossSpawnModal-BB9wL5VV.js} +1 -1
  3. package/dist/assets/{ControlsModal-D-mymoM7.js → ControlsModal-D5RE5MvT.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-Ae-ZCeeP.js → DockerLogsModal-B27P1JpZ.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-DLOOpM0K.js → EmbeddedEditor-DP1jqsT_.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-C9NLhWLo.js → GmailOAuthSetup-DvuL5G8Q.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-1kzgrPV6.js → GoogleOAuthSetup-CG6bSCjv.js} +1 -1
  8. package/dist/assets/{IframeModal-DKS0IFsr.js → IframeModal-ClnUGmJV.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-CBvKOeud.js → IntegrationsPanel-0vdfxZRq.js} +2 -2
  10. package/dist/assets/{LogViewerModal-Dlt8JfVg.js → LogViewerModal-DLQlrZ4O.js} +1 -1
  11. package/dist/assets/{MonitoringModal-BM1IEZv6.js → MonitoringModal-DiC9TNCy.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-B1-HUHWZ.js → PM2LogsModal-BgPrnaP5.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-DXmYo7fp.js → RestoreArchivedAreaModal-CIN1OrOW.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-CuUxSaPb.js → Scene2DCanvas-Bap6brvv.js} +1 -1
  15. package/dist/assets/{SceneManager-UD3IHY20.js → SceneManager-CidCW0PR.js} +1 -1
  16. package/dist/assets/{SkillsPanel-DjRBVrO2.js → SkillsPanel-DHQTPaP2.js} +1 -1
  17. package/dist/assets/{SlackMultiInstanceSetup-Csp81Dqn.js → SlackMultiInstanceSetup-CQK4D89W.js} +1 -1
  18. package/dist/assets/{SpawnModal-dg0mH3d9.js → SpawnModal-Cx_k3HHC.js} +1 -1
  19. package/dist/assets/{SubordinateAssignmentModal-CeBPRNNX.js → SubordinateAssignmentModal-C1DOv51H.js} +1 -1
  20. package/dist/assets/TriggerManagerPanel-jP5RBK2L.js +9 -0
  21. package/dist/assets/{WorkflowEditorPanel-IIsptZgp.js → WorkflowEditorPanel-Dh6mZ8M4.js} +1 -1
  22. package/dist/assets/{index-h-IcmGfB.js → index-B-ttQFx4.js} +2 -2
  23. package/dist/assets/index-BXnThzaG.js +11 -0
  24. package/dist/assets/index-CK8NcQSU.css +1 -0
  25. package/dist/assets/{index-CNDUxsGy.js → index-CYwFXTQZ.js} +1 -1
  26. package/dist/assets/{index-sDgBtEgH.js → index-Cwlm-Pqi.js} +3 -3
  27. package/dist/assets/index-D96LXKm4.js +1 -0
  28. package/dist/assets/{index-BGh9tRSy.js → index-DP5sMNS9.js} +1 -1
  29. package/dist/assets/{index-CsyPNc8u.js → index-DfEbuBH8.js} +1 -1
  30. package/dist/assets/{index-DEI-vrXk.js → index-DsKaX6TJ.js} +1 -1
  31. package/dist/assets/{index-CIqkVLo1.js → index-enJvXAbe.js} +1 -1
  32. package/dist/assets/main-B7wf_xU_.js +214 -0
  33. package/dist/assets/main-DLzFxLC1.css +1 -0
  34. package/dist/assets/{web-BmPSJLwQ.js → web-BHmmnvF7.js} +1 -1
  35. package/dist/assets/{web-Dggt4D4N.js → web-IGuhG0xr.js} +1 -1
  36. package/dist/assets/{web-BgPjNMBK.js → web-SOehUGgT.js} +1 -1
  37. package/dist/index.html +2 -2
  38. package/dist/locales/en/config.json +60 -1
  39. package/dist/locales/en/terminal.json +10 -0
  40. package/dist/src/packages/server/claude/backend.js +42 -0
  41. package/dist/src/packages/server/claude/permission-prompt-server.mjs +188 -0
  42. package/dist/src/packages/server/claude/runner/process-lifecycle.js +3 -0
  43. package/dist/src/packages/server/claude/runner/stdout-pipeline.js +8 -0
  44. package/dist/src/packages/server/claude/runner/tmux-helper.js +14 -0
  45. package/dist/src/packages/server/data/event-queries.js +143 -1
  46. package/dist/src/packages/server/data/migrations/007_whatsapp_messages.sql +48 -0
  47. package/dist/src/packages/server/index.js +1 -0
  48. package/dist/src/packages/server/integrations/gmail/gmail-client.js +139 -24
  49. package/dist/src/packages/server/integrations/gmail/gmail-routes.js +162 -0
  50. package/dist/src/packages/server/integrations/whatsapp/whatsapp-routes.js +81 -0
  51. package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +29 -0
  52. package/dist/src/packages/server/routes/agent-prompt.js +57 -0
  53. package/dist/src/packages/server/routes/index.js +8 -1
  54. package/dist/src/packages/server/routes/skills.js +193 -0
  55. package/dist/src/packages/server/routes/system.js +156 -0
  56. package/dist/src/packages/server/routes/trigger-routes.js +74 -17
  57. package/dist/src/packages/server/routes/webhook-signatures.js +20 -7
  58. package/dist/src/packages/server/services/agent-prompt-service.js +100 -0
  59. package/dist/src/packages/server/services/index.js +1 -0
  60. package/dist/src/packages/server/services/self-update-service.js +191 -0
  61. package/dist/src/packages/server/websocket/handler.js +2 -1
  62. package/dist/src/packages/server/websocket/listeners/agent-prompt-listeners.js +13 -0
  63. package/dist/src/packages/server/websocket/listeners/index.js +2 -0
  64. package/dist/src/packages/shared/whatsapp-types.js +1 -0
  65. package/package.json +2 -2
  66. package/dist/assets/TriggerManagerPanel-D1QPpFhP.js +0 -9
  67. package/dist/assets/index-BdGz_GAe.css +0 -1
  68. package/dist/assets/index-CR9w26tq.js +0 -1
  69. package/dist/assets/index-vJkimYqD.js +0 -1
  70. package/dist/assets/main-BV_IuaBg.css +0 -1
  71. package/dist/assets/main-klWBzHh0.js +0 -214
@@ -0,0 +1,48 @@
1
+ -- Migration 007: WhatsApp Messages
2
+ -- Mirrors slack_messages: every inbound and outbound WhatsApp message captured
3
+ -- by the upstream WS bridge is persisted here so chat history is queryable from
4
+ -- SQLite. message_id + session_id together are unique so redelivered echoes
5
+ -- (server restart, upstream replay) don't double-insert.
6
+
7
+ CREATE TABLE whatsapp_messages (
8
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9
+ session_id TEXT NOT NULL,
10
+ message_id TEXT,
11
+ chat_id TEXT NOT NULL,
12
+ is_group INTEGER NOT NULL DEFAULT 0,
13
+ group_name TEXT,
14
+ from_jid TEXT NOT NULL,
15
+ from_name TEXT,
16
+ direction TEXT NOT NULL,
17
+ body TEXT NOT NULL,
18
+ message_type TEXT NOT NULL,
19
+ media_mimetype TEXT,
20
+ media_size INTEGER,
21
+ media_filename TEXT,
22
+ media_path TEXT,
23
+ audio_transcription TEXT,
24
+ agent_id TEXT,
25
+ workflow_instance_id TEXT,
26
+ raw_event TEXT,
27
+ timestamp INTEGER NOT NULL,
28
+ received_at INTEGER NOT NULL
29
+ );
30
+
31
+ CREATE UNIQUE INDEX idx_whatsapp_messages_unique
32
+ ON whatsapp_messages(session_id, message_id)
33
+ WHERE message_id IS NOT NULL;
34
+
35
+ CREATE INDEX idx_whatsapp_messages_chat
36
+ ON whatsapp_messages(session_id, chat_id, timestamp DESC);
37
+
38
+ CREATE INDEX idx_whatsapp_messages_timestamp
39
+ ON whatsapp_messages(timestamp);
40
+
41
+ CREATE INDEX idx_whatsapp_messages_direction
42
+ ON whatsapp_messages(direction);
43
+
44
+ CREATE INDEX idx_whatsapp_messages_workflow
45
+ ON whatsapp_messages(workflow_instance_id);
46
+
47
+ CREATE INDEX idx_whatsapp_messages_agent
48
+ ON whatsapp_messages(agent_id);
@@ -74,6 +74,7 @@ async function main() {
74
74
  eventDb: {
75
75
  logTriggerFire: eventQueries.logTriggerFire,
76
76
  logSlackMessage: eventQueries.logSlackMessage,
77
+ logWhatsAppMessage: eventQueries.logWhatsAppMessage,
77
78
  logEmailMessage: eventQueries.logEmailMessage,
78
79
  logApprovalEvent: eventQueries.logApprovalEvent,
79
80
  logDocumentGeneration: eventQueries.logDocumentGeneration,
@@ -383,6 +383,140 @@ function parseGmailMessage(msg) {
383
383
  attachmentsMeta: attachmentsMeta.length > 0 ? attachmentsMeta : undefined,
384
384
  };
385
385
  }
386
+ export class GmailAttachmentNotAuthenticatedError extends Error {
387
+ constructor() { super('Gmail not authenticated'); this.name = 'GmailAttachmentNotAuthenticatedError'; }
388
+ }
389
+ export class GmailAttachmentNotFoundError extends Error {
390
+ constructor() { super('attachment not found'); this.name = 'GmailAttachmentNotFoundError'; }
391
+ }
392
+ export class GmailAttachmentTooLargeError extends Error {
393
+ bytes;
394
+ cap;
395
+ constructor(bytes, cap) {
396
+ super(`attachment too large (${bytes} > ${cap})`);
397
+ this.bytes = bytes;
398
+ this.cap = cap;
399
+ this.name = 'GmailAttachmentTooLargeError';
400
+ }
401
+ }
402
+ export async function dumpGmailMessageParts(messageId) {
403
+ if (!gmail)
404
+ throw new GmailAttachmentNotAuthenticatedError();
405
+ let res;
406
+ try {
407
+ res = await gmail.users.messages.get({
408
+ userId: 'me',
409
+ id: messageId,
410
+ format: 'full',
411
+ });
412
+ }
413
+ catch (err) {
414
+ const e = err;
415
+ if (e.code === 404 || e.status === 404)
416
+ return null;
417
+ throw err;
418
+ }
419
+ const out = [];
420
+ function walk(part, depth) {
421
+ if (!part)
422
+ return;
423
+ out.push({
424
+ partId: part.partId ?? '',
425
+ mimeType: part.mimeType ?? '',
426
+ filename: part.filename ?? '',
427
+ attachmentId: part.body?.attachmentId ?? null,
428
+ size: part.body?.size ?? 0,
429
+ depth,
430
+ });
431
+ if (part.parts)
432
+ for (const p of part.parts)
433
+ walk(p, depth + 1);
434
+ }
435
+ walk(res.data.payload ?? undefined, 0);
436
+ return { found: true, parts: out };
437
+ }
438
+ export async function resolveGmailAttachmentPart(messageId, identifier, filenameHint) {
439
+ if (!gmail)
440
+ throw new GmailAttachmentNotAuthenticatedError();
441
+ let res;
442
+ try {
443
+ res = await gmail.users.messages.get({
444
+ userId: 'me',
445
+ id: messageId,
446
+ format: 'full',
447
+ });
448
+ }
449
+ catch (err) {
450
+ const e = err;
451
+ if (e.code === 404 || e.status === 404)
452
+ return null;
453
+ throw err;
454
+ }
455
+ const candidates = [];
456
+ function walk(part) {
457
+ if (!part)
458
+ return;
459
+ const attId = part.body?.attachmentId;
460
+ if (attId) {
461
+ candidates.push({
462
+ realAttachmentId: attId,
463
+ filename: part.filename || '',
464
+ mimeType: part.mimeType || 'application/octet-stream',
465
+ size: part.body?.size ?? 0,
466
+ partId: part.partId ?? '',
467
+ });
468
+ }
469
+ if (part.parts)
470
+ for (const p of part.parts)
471
+ walk(p);
472
+ }
473
+ walk(res.data.payload ?? undefined);
474
+ const id = identifier.trim();
475
+ const norm = (s) => s.normalize('NFC').toLowerCase().trim();
476
+ const hint = filenameHint ? norm(filenameHint) : undefined;
477
+ for (const c of candidates)
478
+ if (c.realAttachmentId === id)
479
+ return c;
480
+ for (const c of candidates)
481
+ if (c.partId === id)
482
+ return c;
483
+ if (hint) {
484
+ for (const c of candidates)
485
+ if (norm(c.filename) === hint)
486
+ return c;
487
+ }
488
+ for (const c of candidates)
489
+ if (norm(c.filename) === norm(id))
490
+ return c;
491
+ return null;
492
+ }
493
+ export async function fetchGmailAttachmentBuffer(messageId, attachmentId) {
494
+ if (!gmail)
495
+ throw new GmailAttachmentNotAuthenticatedError();
496
+ const { MAX_ATTACHMENT_BYTES } = await import('../../services/attachment-downloader.js');
497
+ let res;
498
+ try {
499
+ res = await gmail.users.messages.attachments.get({
500
+ userId: 'me',
501
+ messageId,
502
+ id: attachmentId,
503
+ });
504
+ }
505
+ catch (err) {
506
+ const e = err;
507
+ if (e.code === 404 || e.status === 404)
508
+ throw new GmailAttachmentNotFoundError();
509
+ throw err;
510
+ }
511
+ const b64 = res.data?.data;
512
+ if (!b64)
513
+ throw new GmailAttachmentNotFoundError();
514
+ const buf = Buffer.from(b64, 'base64url');
515
+ if (buf.byteLength > MAX_ATTACHMENT_BYTES) {
516
+ throw new GmailAttachmentTooLargeError(buf.byteLength, MAX_ATTACHMENT_BYTES);
517
+ }
518
+ return { buffer: buf, size: buf.byteLength };
519
+ }
386
520
  /**
387
521
  * Download a single Gmail attachment by id and persist it to disk under
388
522
  * `<TEMP_DIR>/triggers/gmail/<messageId>/<sanitized-filename>`. Mirrors what
@@ -396,7 +530,6 @@ export async function downloadGmailAttachment(messageId, meta) {
396
530
  ctx?.log.warn('Gmail not authenticated — skipping attachment download');
397
531
  return null;
398
532
  }
399
- // Lazy-import the shared cap + temp root so we keep one source of truth.
400
533
  const { MAX_ATTACHMENT_BYTES, TRIGGER_ATTACHMENT_ROOT } = await import('../../services/attachment-downloader.js');
401
534
  if (meta.size > MAX_ATTACHMENT_BYTES) {
402
535
  ctx?.log.warn(`Gmail attachment ${meta.filename} (${meta.size}B) exceeds cap; skipping`);
@@ -404,15 +537,10 @@ export async function downloadGmailAttachment(messageId, meta) {
404
537
  }
405
538
  const path = await import('path');
406
539
  const fs = await import('fs/promises');
407
- // Sanitize segments the same way attachment-downloader does — strip
408
- // path separators / `..` / control chars so a hostile filename can't
409
- // escape the trigger dir.
410
540
  const safe = (s) => s.replace(/[\\/\x00-\x1f\x7f]/g, '_').replace(/\.{2,}/g, '_').slice(0, 200) || 'file';
411
541
  const targetDir = path.join(TRIGGER_ATTACHMENT_ROOT, 'gmail', safe(messageId));
412
542
  const finalName = safe(meta.filename) || 'file';
413
543
  const targetPath = path.join(targetDir, finalName);
414
- // Idempotency: if a file with the expected size already lives at the
415
- // target path, skip the network round-trip.
416
544
  try {
417
545
  const st = await fs.stat(targetPath);
418
546
  if (st.isFile() && (meta.size === 0 || st.size === meta.size)) {
@@ -421,36 +549,23 @@ export async function downloadGmailAttachment(messageId, meta) {
421
549
  }
422
550
  catch { /* not cached */ }
423
551
  await fs.mkdir(targetDir, { recursive: true });
424
- let res;
552
+ let buffer;
425
553
  try {
426
- res = await gmail.users.messages.attachments.get({
427
- userId: 'me',
428
- messageId,
429
- id: meta.attachmentId,
430
- });
554
+ const fetched = await fetchGmailAttachmentBuffer(messageId, meta.attachmentId);
555
+ buffer = fetched.buffer;
431
556
  }
432
557
  catch (err) {
433
558
  ctx?.log.warn(`Gmail attachments.get failed for ${meta.filename}: ${err}`);
434
559
  return null;
435
560
  }
436
- const b64 = res.data?.data;
437
- if (!b64) {
438
- ctx?.log.warn(`Gmail attachment ${meta.filename} returned empty data`);
439
- return null;
440
- }
441
- const buf = Buffer.from(b64, 'base64url');
442
- if (buf.byteLength > MAX_ATTACHMENT_BYTES) {
443
- ctx?.log.warn(`Gmail attachment ${meta.filename} decoded to ${buf.byteLength}B (exceeds cap)`);
444
- return null;
445
- }
446
561
  try {
447
- await fs.writeFile(targetPath, buf);
562
+ await fs.writeFile(targetPath, buffer);
448
563
  }
449
564
  catch (err) {
450
565
  ctx?.log.warn(`Gmail attachment write failed for ${targetPath}: ${err}`);
451
566
  return null;
452
567
  }
453
- return { path: targetPath, bytesOnDisk: buf.byteLength, filename: finalName, mimeType: meta.mimeType };
568
+ return { path: targetPath, bytesOnDisk: buffer.byteLength, filename: finalName, mimeType: meta.mimeType };
454
569
  }
455
570
  export async function getThread(threadId) {
456
571
  if (!gmail)
@@ -4,10 +4,42 @@
4
4
  * Mounted at /api/email/ by the integration registry.
5
5
  */
6
6
  import { Router } from 'express';
7
+ import * as path from 'path';
8
+ import * as fsp from 'fs/promises';
7
9
  import * as gmailClient from './gmail-client.js';
10
+ import { GmailAttachmentNotAuthenticatedError, GmailAttachmentNotFoundError, GmailAttachmentTooLargeError, } from './gmail-client.js';
8
11
  import { createLogger } from '../../utils/logger.js';
9
12
  const log = createLogger('GmailRoutes');
10
13
  const router = Router();
14
+ const SAVE_PATH_WHITELIST = ['/home/riven/obsidian', '/home/riven/d', '/tmp'];
15
+ function sanitizeFilename(name) {
16
+ if (typeof name !== 'string')
17
+ return null;
18
+ const trimmed = name.trim();
19
+ if (!trimmed)
20
+ return null;
21
+ if (/[/\\]/.test(trimmed))
22
+ return null;
23
+ if (/[\x00-\x1f]/.test(trimmed))
24
+ return null;
25
+ const cleaned = trimmed.slice(0, 200);
26
+ return cleaned.length > 0 ? cleaned : null;
27
+ }
28
+ function isWithinWhitelist(absPath) {
29
+ const resolved = path.resolve(absPath);
30
+ return SAVE_PATH_WHITELIST.some((root) => {
31
+ const rootResolved = path.resolve(root);
32
+ return resolved === rootResolved || resolved.startsWith(rootResolved + path.sep);
33
+ });
34
+ }
35
+ function buildContentDisposition(filename) {
36
+ const asciiFallback = filename
37
+ .replace(/[^\x20-\x7e]/g, '_')
38
+ .replace(/"/g, "'")
39
+ .slice(0, 200) || 'attachment';
40
+ const encoded = encodeURIComponent(filename);
41
+ return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
42
+ }
11
43
  // POST /api/email/send — Send an email
12
44
  router.post('/send', async (req, res) => {
13
45
  try {
@@ -208,4 +240,134 @@ router.post('/polling/stop', (req, res) => {
208
240
  res.status(500).json({ error: `Failed to stop polling: ${err instanceof Error ? err.message : err}` });
209
241
  }
210
242
  });
243
+ router.get('/messages/:messageId/parts', async (req, res) => {
244
+ const messageId = (req.params.messageId ?? '').trim();
245
+ if (!messageId) {
246
+ res.status(400).json({ error: 'invalid messageId' });
247
+ return;
248
+ }
249
+ try {
250
+ const dump = await gmailClient.dumpGmailMessageParts(messageId);
251
+ if (!dump) {
252
+ res.status(404).json({ error: 'message not found' });
253
+ return;
254
+ }
255
+ res.json(dump);
256
+ }
257
+ catch (err) {
258
+ if (err instanceof GmailAttachmentNotAuthenticatedError) {
259
+ res.status(503).json({ error: 'Gmail not authenticated' });
260
+ return;
261
+ }
262
+ log.error(`Gmail message parts dump error: ${err}`);
263
+ res.status(500).json({ error: 'failed to dump message' });
264
+ }
265
+ });
266
+ router.post('/messages/:messageId/attachments/:attachmentId/download', async (req, res) => {
267
+ const messageId = (req.params.messageId ?? '').trim();
268
+ const attachmentId = (req.params.attachmentId ?? '').trim();
269
+ if (!messageId) {
270
+ res.status(400).json({ error: 'invalid messageId' });
271
+ return;
272
+ }
273
+ if (!attachmentId) {
274
+ res.status(400).json({ error: 'invalid attachmentId' });
275
+ return;
276
+ }
277
+ const savePathRaw = typeof req.query.savePath === 'string' ? req.query.savePath : undefined;
278
+ const filenameOverrideRaw = typeof req.query.filename === 'string' ? req.query.filename : undefined;
279
+ let filenameOverride = null;
280
+ if (filenameOverrideRaw !== undefined) {
281
+ filenameOverride = sanitizeFilename(filenameOverrideRaw);
282
+ if (!filenameOverride) {
283
+ res.status(400).json({ error: 'invalid filename' });
284
+ return;
285
+ }
286
+ }
287
+ let resolvedSavePath = null;
288
+ if (savePathRaw) {
289
+ if (!path.isAbsolute(savePathRaw)) {
290
+ res.status(400).json({ error: 'savePath outside whitelist' });
291
+ return;
292
+ }
293
+ resolvedSavePath = path.resolve(savePathRaw);
294
+ if (!isWithinWhitelist(resolvedSavePath)) {
295
+ res.status(400).json({ error: 'savePath outside whitelist' });
296
+ return;
297
+ }
298
+ }
299
+ try {
300
+ const part = await gmailClient.resolveGmailAttachmentPart(messageId, attachmentId, filenameOverrideRaw);
301
+ if (!part) {
302
+ res.status(404).json({ error: 'attachment not found' });
303
+ return;
304
+ }
305
+ const resolvedMime = part.mimeType || 'application/octet-stream';
306
+ const metaFilename = sanitizeFilename(part.filename) ?? 'attachment';
307
+ const resolvedFilename = filenameOverride ?? metaFilename;
308
+ const { buffer, size } = await gmailClient.fetchGmailAttachmentBuffer(messageId, part.realAttachmentId);
309
+ if (!resolvedSavePath) {
310
+ res.setHeader('Content-Type', resolvedMime);
311
+ res.setHeader('Content-Disposition', buildContentDisposition(resolvedFilename));
312
+ res.setHeader('Content-Length', String(size));
313
+ res.status(200).send(buffer);
314
+ return;
315
+ }
316
+ let targetPath;
317
+ let targetDir;
318
+ let isDir = false;
319
+ try {
320
+ const st = await fsp.stat(resolvedSavePath);
321
+ isDir = st.isDirectory();
322
+ }
323
+ catch {
324
+ const raw = savePathRaw ?? '';
325
+ isDir = raw.endsWith('/') || raw.endsWith(path.sep);
326
+ }
327
+ if (isDir) {
328
+ targetDir = resolvedSavePath;
329
+ targetPath = path.join(resolvedSavePath, resolvedFilename);
330
+ }
331
+ else {
332
+ const baseFromPath = path.basename(resolvedSavePath);
333
+ const safeBase = filenameOverride ?? sanitizeFilename(baseFromPath);
334
+ if (!safeBase) {
335
+ res.status(400).json({ error: 'invalid filename' });
336
+ return;
337
+ }
338
+ targetDir = path.dirname(resolvedSavePath);
339
+ targetPath = path.join(targetDir, safeBase);
340
+ }
341
+ if (!isWithinWhitelist(targetPath) || !isWithinWhitelist(targetDir)) {
342
+ res.status(400).json({ error: 'savePath outside whitelist' });
343
+ return;
344
+ }
345
+ try {
346
+ await fsp.mkdir(targetDir, { recursive: true });
347
+ await fsp.writeFile(targetPath, buffer);
348
+ }
349
+ catch (err) {
350
+ log.error(`Gmail attachment write failed for ${targetPath}: ${err}`);
351
+ res.status(500).json({ error: 'failed to write attachment' });
352
+ return;
353
+ }
354
+ res.json({ ok: true, path: targetPath, size });
355
+ }
356
+ catch (err) {
357
+ if (err instanceof GmailAttachmentNotAuthenticatedError) {
358
+ res.status(503).json({ error: 'Gmail not authenticated' });
359
+ return;
360
+ }
361
+ if (err instanceof GmailAttachmentNotFoundError) {
362
+ res.status(404).json({ error: 'attachment not found' });
363
+ return;
364
+ }
365
+ if (err instanceof GmailAttachmentTooLargeError) {
366
+ res.status(413).json({ error: 'attachment too large' });
367
+ return;
368
+ }
369
+ log.error(`Gmail attachment download error: ${err}`);
370
+ res.status(500).json({ error: 'failed to download attachment' });
371
+ }
372
+ });
211
373
  export default router;
@@ -10,6 +10,7 @@ import { createLogger } from '../../utils/logger.js';
10
10
  import { WhatsAppClient } from './whatsapp-client.js';
11
11
  import { loadConfig, updateConfig, WHATSAPP_API_KEY_SECRET, } from './whatsapp-config.js';
12
12
  import { syncBridge } from './index.js';
13
+ import { getWhatsAppChatsList, getWhatsAppMessagesByChatPaged, } from '../../data/event-queries.js';
13
14
  import { getConfig as getNotificationConfig, updateConfig as updateNotificationConfig, clearConfig as clearNotificationConfig, getDefaultConfig as getDefaultNotificationConfig, WHATSAPP_NOTIFICATION_EVENT_TYPES, } from '../../services/whatsapp-notification-config-service.js';
14
15
  const log = createLogger('WhatsAppRoutes');
15
16
  /** Build the router. Closes over the integration context for secret access. */
@@ -379,6 +380,86 @@ export function createWhatsAppRoutes(ctx) {
379
380
  res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
380
381
  }
381
382
  });
383
+ // ─── GET /chats/:sessionId — chat summaries ───
384
+ router.get('/chats/:sessionId', (req, res) => {
385
+ const sessionId = req.params.sessionId.trim();
386
+ if (!sessionId) {
387
+ res.status(400).json({ error: 'sessionId is required' });
388
+ return;
389
+ }
390
+ try {
391
+ const chats = getWhatsAppChatsList(sessionId);
392
+ const body = { chats };
393
+ res.json(body);
394
+ }
395
+ catch (err) {
396
+ log.error(`WhatsApp chats list error: ${err}`);
397
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
398
+ }
399
+ });
400
+ // ─── GET /chats/:sessionId/:chatId/messages — paged history ───
401
+ router.get('/chats/:sessionId/:chatId/messages', (req, res) => {
402
+ const sessionId = req.params.sessionId.trim();
403
+ const chatId = req.params.chatId.trim();
404
+ if (!sessionId || !chatId) {
405
+ res.status(400).json({ error: 'sessionId and chatId are required' });
406
+ return;
407
+ }
408
+ const cursorRaw = req.query.cursor;
409
+ let cursor;
410
+ if (typeof cursorRaw === 'string' && cursorRaw.length > 0) {
411
+ const parsed = Number(cursorRaw);
412
+ if (!Number.isFinite(parsed)) {
413
+ res.status(400).json({ error: 'invalid cursor' });
414
+ return;
415
+ }
416
+ cursor = parsed;
417
+ }
418
+ const limitRaw = req.query.limit;
419
+ let limit;
420
+ if (typeof limitRaw === 'string' && limitRaw.length > 0) {
421
+ const parsed = Number(limitRaw);
422
+ if (!Number.isFinite(parsed) || parsed <= 0) {
423
+ res.status(400).json({ error: 'invalid limit' });
424
+ return;
425
+ }
426
+ limit = parsed;
427
+ }
428
+ const directionRaw = req.query.direction;
429
+ let direction;
430
+ if (typeof directionRaw === 'string' && directionRaw.length > 0) {
431
+ if (directionRaw !== 'inbound' && directionRaw !== 'outbound') {
432
+ res.status(400).json({ error: 'invalid direction' });
433
+ return;
434
+ }
435
+ direction = directionRaw;
436
+ }
437
+ const typeRaw = req.query.type;
438
+ const allowedTypes = [
439
+ 'text', 'image', 'audio', 'video', 'document',
440
+ 'sticker', 'location', 'contact', 'reaction', 'unknown',
441
+ ];
442
+ let type;
443
+ if (typeof typeRaw === 'string' && typeRaw.length > 0) {
444
+ if (!allowedTypes.includes(typeRaw)) {
445
+ res.status(400).json({ error: 'invalid type' });
446
+ return;
447
+ }
448
+ type = typeRaw;
449
+ }
450
+ try {
451
+ const page = getWhatsAppMessagesByChatPaged(sessionId, chatId, { cursor, limit, direction, type });
452
+ const body = {
453
+ messages: page.messages,
454
+ nextCursor: page.nextCursor === null ? null : String(page.nextCursor),
455
+ };
456
+ res.json(body);
457
+ }
458
+ catch (err) {
459
+ log.error(`WhatsApp messages page error: ${err}`);
460
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
461
+ }
462
+ });
382
463
  return router;
383
464
  }
384
465
  export default createWhatsAppRoutes;
@@ -121,6 +121,33 @@ export function createWhatsAppTriggerHandler(ctx) {
121
121
  function isRunning() {
122
122
  return client !== null;
123
123
  }
124
+ function persist(payload) {
125
+ try {
126
+ ctx.eventDb.logWhatsAppMessage({
127
+ sessionId: payload.sessionId,
128
+ messageId: payload.messageId,
129
+ chatId: payload.chatId,
130
+ isGroup: payload.isGroup,
131
+ groupName: payload.groupName,
132
+ fromJid: payload.from,
133
+ fromName: payload.fromName,
134
+ direction: payload.direction,
135
+ body: payload.body,
136
+ messageType: payload.mediaType ?? 'text',
137
+ mediaMimetype: payload.mediaMimetype,
138
+ mediaSize: payload.mediaSize,
139
+ mediaFilename: payload.mediaFilename,
140
+ mediaPath: payload.mediaPath,
141
+ audioTranscription: payload.audioTranscription,
142
+ rawEvent: payload,
143
+ timestamp: payload.timestamp,
144
+ receivedAt: Date.now(),
145
+ });
146
+ }
147
+ catch (err) {
148
+ ctx.log.warn(`WhatsApp logWhatsAppMessage failed: ${err}`);
149
+ }
150
+ }
124
151
  function extractMessageId(data) {
125
152
  if (!data || typeof data !== 'object')
126
153
  return undefined;
@@ -191,6 +218,7 @@ export function createWhatsAppTriggerHandler(ctx) {
191
218
  const needsMediaDownload = !!payload.mediaType &&
192
219
  !!payload.messageId;
193
220
  if (!needsContactEnrich && !needsGroupEnrich && !needsMediaDownload) {
221
+ persist(payload);
194
222
  ctx.broadcast({ type: 'whatsapp_message', payload });
195
223
  notifyTriggerSubscribers(payload);
196
224
  return;
@@ -292,6 +320,7 @@ export function createWhatsAppTriggerHandler(ctx) {
292
320
  })()
293
321
  : Promise.resolve();
294
322
  void Promise.all([contactPromise, groupPromise, mediaPromise]).then(() => {
323
+ persist(payload);
295
324
  ctx.broadcast({ type: 'whatsapp_message', payload });
296
325
  notifyTriggerSubscribers(payload);
297
326
  });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Agent Prompt Routes
3
+ *
4
+ * MCP perm-prompt bridge endpoints:
5
+ * - POST /api/agent-prompt MCP server enqueues a request; blocks until UI responds
6
+ * - POST /api/agent-prompt/:id/respond UI submits the user's answer
7
+ * - GET /api/agent-prompt/pending list pending prompts (debug / reconnect)
8
+ */
9
+ import { Router } from 'express';
10
+ import * as agentPromptService from '../services/agent-prompt-service.js';
11
+ import { createLogger } from '../utils/logger.js';
12
+ const log = createLogger('AgentPromptRoutes');
13
+ const router = Router();
14
+ router.post('/agent-prompt', async (req, res) => {
15
+ try {
16
+ const { id, agentId, tool, input } = req.body || {};
17
+ if (!id || !agentId || !tool) {
18
+ res.status(400).json({ error: 'Missing required fields: id, agentId, tool' });
19
+ return;
20
+ }
21
+ if (tool !== 'AskUserQuestion' && tool !== 'ExitPlanMode') {
22
+ res.status(400).json({ error: `Unsupported tool: ${tool}` });
23
+ return;
24
+ }
25
+ const response = await agentPromptService.createPrompt({
26
+ id, agentId, tool,
27
+ input: input || {},
28
+ });
29
+ res.json(response);
30
+ }
31
+ catch (err) {
32
+ log.error('Prompt request error:', err);
33
+ res.status(500).json({ requestId: req.body?.id, approved: false, reason: `Server error: ${err?.message ?? err}` });
34
+ }
35
+ });
36
+ router.post('/agent-prompt/:id/respond', (req, res) => {
37
+ const { id } = req.params;
38
+ const { approved, answers, reason } = req.body || {};
39
+ if (typeof approved !== 'boolean') {
40
+ res.status(400).json({ error: 'approved (boolean) is required' });
41
+ return;
42
+ }
43
+ const ok = agentPromptService.respondToPrompt({ requestId: id, approved, answers, reason });
44
+ if (!ok) {
45
+ res.status(404).json({ error: 'No pending prompt with that id' });
46
+ return;
47
+ }
48
+ res.json({ ok: true });
49
+ });
50
+ router.get('/agent-prompt/pending', (req, res) => {
51
+ const agentId = typeof req.query.agentId === 'string' ? req.query.agentId : undefined;
52
+ const prompts = agentId
53
+ ? agentPromptService.getPendingPromptsForAgent(agentId)
54
+ : agentPromptService.getPendingPrompts();
55
+ res.json(prompts);
56
+ });
57
+ export default router;
@@ -6,6 +6,7 @@ import { Router, raw } from 'express';
6
6
  import agentsRouter, { setBroadcast as setAgentsBroadcast } from './agents.js';
7
7
  import filesRouter from './files.js';
8
8
  import permissionsRouter from './permissions.js';
9
+ import agentPromptRouter from './agent-prompt.js';
9
10
  import notificationsRouter, { setBroadcast as setNotificationBroadcast } from './notifications.js';
10
11
  import execRouter, { setBroadcast as setExecBroadcast } from './exec.js';
11
12
  import focusAgentRouter, { setBroadcast as setFocusAgentBroadcast } from './focus-agent.js';
@@ -25,6 +26,8 @@ import workflowRouter from './workflow-routes.js';
25
26
  import sessionsRouter from './sessions.js';
26
27
  import databaseRouter from './database.js';
27
28
  import buildingsRouter, { setBroadcast as setBuildingsBroadcast } from './buildings.js';
29
+ import skillsRouter, { setBroadcast as setSkillsBroadcast } from './skills.js';
30
+ import systemRouter from './system.js';
28
31
  import { getPlugins } from '../integrations/integration-registry.js';
29
32
  const router = Router();
30
33
  // Health check
@@ -52,6 +55,8 @@ router.use('/workflows', workflowRouter);
52
55
  router.use('/sessions', sessionsRouter);
53
56
  router.use('/database', databaseRouter);
54
57
  router.use('/buildings', buildingsRouter);
58
+ router.use('/skills', skillsRouter);
59
+ router.use('/system', systemRouter);
55
60
  // Integration plugin routes (e.g. /api/slack/*, /api/documents/*, /api/jira/*)
56
61
  // Uses lazy lookup so plugins can be registered after route setup
57
62
  router.use((req, res, next) => {
@@ -69,6 +74,8 @@ router.use((req, res, next) => {
69
74
  router.use('/config', raw({ type: 'application/zip', limit: '100mb' }), configRouter);
70
75
  // Permission routes are mounted at root level since they're called as /api/permission-request
71
76
  router.use('/', permissionsRouter);
77
+ // Agent prompt routes — root-level: /api/agent-prompt and /api/agent-prompt/:id/respond
78
+ router.use('/', agentPromptRouter);
72
79
  // Export the broadcast setters for WebSocket handler to use
73
- export { setNotificationBroadcast, setExecBroadcast, setFocusAgentBroadcast, setAgentsBroadcast, setTriggerBroadcast, setBuildingsBroadcast };
80
+ export { setNotificationBroadcast, setExecBroadcast, setFocusAgentBroadcast, setAgentsBroadcast, setTriggerBroadcast, setBuildingsBroadcast, setSkillsBroadcast };
74
81
  export default router;