tide-commander 1.87.0 → 1.88.1

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 (57) hide show
  1. package/dist/assets/{BossLogsModal-S3Rke-8g.js → BossLogsModal-BXQh_FI_.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-BjWGNCnz.js → BossSpawnModal-BevC86-H.js} +1 -1
  3. package/dist/assets/{ControlsModal-6yfU0XjZ.js → ControlsModal-OTv1pMsh.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-CYq0hNz6.js → DockerLogsModal-oLpkSRWn.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-ZBdqRDqm.js → EmbeddedEditor-Ch6-Ql2A.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-BcV5jAse.js → GmailOAuthSetup-wOwZdO76.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-DyUW_STE.js → GoogleOAuthSetup-ChVRBoAO.js} +1 -1
  8. package/dist/assets/{IframeModal-D9A3dUUc.js → IframeModal-BAQxs9VC.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-CHaNJBJW.js → IntegrationsPanel-YFcOTDdT.js} +2 -2
  10. package/dist/assets/{LogViewerModal-BWkbY7wa.js → LogViewerModal-CSAEl5nL.js} +1 -1
  11. package/dist/assets/{MonitoringModal-AZzokZAZ.js → MonitoringModal-Bgu6BN93.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-q98eiBfq.js → PM2LogsModal-D0iQKsKn.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-CTxRP2qE.js → RestoreArchivedAreaModal-SDm4CHQ_.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-C11dztp1.js → Scene2DCanvas-kZMJfRRF.js} +1 -1
  15. package/dist/assets/{SceneManager-CsW9MYrD.js → SceneManager-CcMFDU1A.js} +1 -1
  16. package/dist/assets/{SkillsPanel-BeZr9w6E.js → SkillsPanel-DKT8TwSH.js} +1 -1
  17. package/dist/assets/{SpawnModal-DY_KM6lX.js → SpawnModal-DKfic48e.js} +1 -1
  18. package/dist/assets/{SubordinateAssignmentModal-D6RvjGX9.js → SubordinateAssignmentModal-BfSS7J4H.js} +1 -1
  19. package/dist/assets/{TriggerManagerPanel-BmqjXv9T.js → TriggerManagerPanel-C8zZf9rr.js} +2 -2
  20. package/dist/assets/{WorkflowEditorPanel-Rd5ZjJmt.js → WorkflowEditorPanel-BsEbQzL1.js} +1 -1
  21. package/dist/assets/{index-DHHRkTG1.js → index-BW6pasQS.js} +1 -1
  22. package/dist/assets/{index-DSvJOrb7.js → index-Bm2GprIp.js} +2 -2
  23. package/dist/assets/{index-BoORE9Q1.js → index-Cn6pTvu9.js} +1 -1
  24. package/dist/assets/{index-BYVHgVEo.js → index-Cq-zN7dW.js} +1 -1
  25. package/dist/assets/{index-BtJyOo4p.js → index-CrveGM3a.js} +1 -1
  26. package/dist/assets/index-DBbK5bs6.js +8 -0
  27. package/dist/assets/index-DeGYOmjB.js +2 -0
  28. package/dist/assets/{index-DRGyDtmm.js → index-JfilXCJq.js} +1 -1
  29. package/dist/assets/{index-BxaEkSIx.js → index-NPF6_aDv.js} +3 -3
  30. package/dist/assets/{main-kpU9m5LW.css → main-Cm8LaUxX.css} +1 -1
  31. package/dist/assets/main-CwfJmt2N.js +209 -0
  32. package/dist/assets/{web-DS0FHmg8.js → web-CN0zYUhV.js} +1 -1
  33. package/dist/assets/{web-D3zCwsS9.js → web-Dik5OZ1r.js} +1 -1
  34. package/dist/assets/{web-DEq3Te_H.js → web-JhU3bMzp.js} +1 -1
  35. package/dist/index.html +2 -2
  36. package/dist/src/packages/server/data/builtin-skills/explore-database.js +175 -0
  37. package/dist/src/packages/server/data/builtin-skills/index.js +2 -0
  38. package/dist/src/packages/server/data/index.js +56 -2
  39. package/dist/src/packages/server/index.js +2 -1
  40. package/dist/src/packages/server/integrations/gmail/gmail-trigger-handler.js +9 -1
  41. package/dist/src/packages/server/integrations/whatsapp/index.js +5 -4
  42. package/dist/src/packages/server/integrations/whatsapp/whatsapp-client.js +10 -0
  43. package/dist/src/packages/server/integrations/whatsapp/whatsapp-routes.js +68 -0
  44. package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +113 -0
  45. package/dist/src/packages/server/routes/database.js +221 -0
  46. package/dist/src/packages/server/routes/files.js +128 -18
  47. package/dist/src/packages/server/routes/index.js +2 -0
  48. package/dist/src/packages/server/services/building-service.js +41 -0
  49. package/dist/src/packages/server/services/database-service.js +61 -9
  50. package/dist/src/packages/server/services/index.js +1 -0
  51. package/dist/src/packages/server/services/ssh-tunnel-service.js +255 -0
  52. package/dist/src/packages/server/websocket/handler.js +2 -1
  53. package/dist/src/packages/server/websocket/handlers/database-handler.js +35 -0
  54. package/package.json +3 -1
  55. package/dist/assets/index-BOr_tbLK.js +0 -2
  56. package/dist/assets/index-Co7njQ0Q.js +0 -8
  57. package/dist/assets/main-BrZe9Zbd.js +0 -201
@@ -1 +1 @@
1
- import{ck as a}from"./main-BrZe9Zbd.js";import{ImpactStyle as i,NotificationType as r}from"./index-DSvJOrb7.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class h extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{h as HapticsWeb};
1
+ import{ck as a}from"./main-CwfJmt2N.js";import{ImpactStyle as i,NotificationType as r}from"./index-Bm2GprIp.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class h extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{h as HapticsWeb};
@@ -1 +1 @@
1
- import{ck as s}from"./main-BrZe9Zbd.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class l extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i instanceof Error&&i.name==="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{l as LocalNotificationsWeb};
1
+ import{ck as s}from"./main-CwfJmt2N.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class l extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i instanceof Error&&i.name==="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{l as LocalNotificationsWeb};
@@ -1 +1 @@
1
- import{ck as t}from"./main-BrZe9Zbd.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class o extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{o as AppWeb};
1
+ import{ck as t}from"./main-CwfJmt2N.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class o extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{o as AppWeb};
package/dist/index.html CHANGED
@@ -22,10 +22,10 @@
22
22
  <link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png" />
23
23
  <link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png" />
24
24
  <title>Tide Commander</title>
25
- <script type="module" crossorigin src="/assets/main-BrZe9Zbd.js"></script>
25
+ <script type="module" crossorigin src="/assets/main-CwfJmt2N.js"></script>
26
26
  <link rel="modulepreload" crossorigin href="/assets/vendor-react--Eh9ivFN.js">
27
27
  <link rel="modulepreload" crossorigin href="/assets/vendor-three-Chj50gSY.js">
28
- <link rel="stylesheet" crossorigin href="/assets/main-kpU9m5LW.css">
28
+ <link rel="stylesheet" crossorigin href="/assets/main-Cm8LaUxX.css">
29
29
  </head>
30
30
  <body>
31
31
  <div id="app"></div>
@@ -0,0 +1,175 @@
1
+ const BT3 = '```';
2
+ export const exploreDatabase = {
3
+ slug: 'explore-database',
4
+ name: 'Explore Database',
5
+ description: 'Inspect database buildings via the Tide Commander API: list connections, browse databases/tables, read schemas, and run SQL.',
6
+ allowedTools: ['Bash(curl:*)', 'Bash(jq:*)'],
7
+ content: `# Explore Database Skill
8
+
9
+ This skill lets you inspect any **database building** in Tide Commander through the
10
+ internal HTTP API. You can list connections, list databases/tables, read schemas,
11
+ and execute SQL — all without leaving the curl pattern.
12
+
13
+ All endpoints live under \`/api/database/\` and use the same auth header as every
14
+ other Tide Commander skill (\`X-Auth-Token\`). Passwords are never returned by the
15
+ API; responses include a \`hasPassword\` boolean instead.
16
+
17
+ ---
18
+
19
+ ## Endpoint Reference
20
+
21
+ | Endpoint | Method | Purpose |
22
+ |---|---|---|
23
+ | \`/api/database/buildings\` | GET | List every database building (redacted connections) |
24
+ | \`/api/database/buildings/{buildingId}\` | GET | One database building with all its connections |
25
+ | \`/api/database/buildings/{buildingId}/connections/{connectionId}/test\` | POST | Verify the connection works |
26
+ | \`/api/database/buildings/{buildingId}/connections/{connectionId}/databases\` | GET | List databases on the server |
27
+ | \`/api/database/buildings/{buildingId}/connections/{connectionId}/databases/{database}/tables\` | GET | List tables/views in a database |
28
+ | \`/api/database/buildings/{buildingId}/connections/{connectionId}/databases/{database}/tables/{table}/columns\` | GET | **Describe table — columns only** (lean) |
29
+ | \`/api/database/buildings/{buildingId}/connections/{connectionId}/databases/{database}/tables/{table}/schema\` | GET | Full schema: columns + indexes + foreign keys |
30
+ | \`/api/database/buildings/{buildingId}/connections/{connectionId}/query\` | POST | Run SQL — body \`{database, query, limit?, recordHistory?}\` |
31
+ | \`/api/database/buildings/{buildingId}/history\` | GET | Recent query history (\`?limit=N\`) |
32
+
33
+ Engines supported: **mysql, postgresql, oracle, sqlite, mssql**. SSH tunnels are
34
+ applied automatically when configured on the connection.
35
+
36
+ ---
37
+
38
+ ## Step 1: Discover database buildings
39
+
40
+ ${BT3}bash
41
+ # List all DB buildings + their connections (redacted)
42
+ curl -s http://localhost:5174/api/database/buildings | jq
43
+
44
+ # Compact view: just id/name/engine of each connection
45
+ curl -s http://localhost:5174/api/database/buildings \\
46
+ | jq '.buildings[] | {id, name, conns: [.connections[] | {id, engine, host, port, database}]}'
47
+ ${BT3}
48
+
49
+ Pick the **buildingId** and **connectionId** you want to explore. The \`activeConnectionId\`
50
+ field hints at the one the user typically works with.
51
+
52
+ ## Step 2: Verify the connection
53
+
54
+ Always run a test before any heavy query — it brings up SSH tunnels and validates
55
+ credentials cheaply.
56
+
57
+ ${BT3}bash
58
+ curl -s -X POST http://localhost:5174/api/database/buildings/BUILDING_ID/connections/CONNECTION_ID/test
59
+ # → {"success":true,"serverVersion":"8.0.36"} or {"success":false,"error":"..."}
60
+ ${BT3}
61
+
62
+ ## Step 3: Browse databases and tables
63
+
64
+ ${BT3}bash
65
+ # List databases on the server
66
+ curl -s http://localhost:5174/api/database/buildings/BUILDING_ID/connections/CONNECTION_ID/databases | jq
67
+
68
+ # List tables/views in a specific database
69
+ curl -s http://localhost:5174/api/database/buildings/BUILDING_ID/connections/CONNECTION_ID/databases/MY_DB/tables | jq
70
+
71
+ # Compact: just names
72
+ curl -s http://localhost:5174/api/database/buildings/BUILDING_ID/connections/CONNECTION_ID/databases/MY_DB/tables \\
73
+ | jq '.tables | map(.name)'
74
+ ${BT3}
75
+
76
+ For Oracle, "database" means **schema/owner** (e.g. \`SCOTT\`, \`HR\`). For SQLite the
77
+ "database" is the file path or \`main\`.
78
+
79
+ ## Step 4: Inspect a table's schema
80
+
81
+ ${BT3}bash
82
+ curl -s http://localhost:5174/api/database/buildings/BUILDING_ID/connections/CONNECTION_ID/databases/MY_DB/tables/users/schema | jq
83
+ ${BT3}
84
+
85
+ Returns \`{ database, table, columns, indexes, foreignKeys }\`. Use this to
86
+ discover columns before writing a SELECT.
87
+
88
+ ## Step 5: Run SQL
89
+
90
+ ${BT3}bash
91
+ # Read query — limit defaults to 1000, capped at 10000
92
+ curl -s -X POST http://localhost:5174/api/database/buildings/BUILDING_ID/connections/CONNECTION_ID/query \\
93
+ -H "Content-Type: application/json" \\
94
+ -d '{"database":"MY_DB","query":"SELECT id, email FROM users WHERE created_at > NOW() - INTERVAL 1 DAY","limit":50}'
95
+
96
+ # Persist into the building's query history (visible in the UI)
97
+ curl -s -X POST http://localhost:5174/api/database/buildings/BUILDING_ID/connections/CONNECTION_ID/query \\
98
+ -H "Content-Type: application/json" \\
99
+ -d '{"database":"MY_DB","query":"SELECT COUNT(*) AS n FROM users","recordHistory":true}'
100
+
101
+ # Mutating statements work too — affectedRows is reported instead of rows
102
+ curl -s -X POST http://localhost:5174/api/database/buildings/BUILDING_ID/connections/CONNECTION_ID/query \\
103
+ -H "Content-Type: application/json" \\
104
+ -d '{"database":"MY_DB","query":"UPDATE users SET active = 1 WHERE id = 42"}'
105
+ ${BT3}
106
+
107
+ **Response shape (success):**
108
+ ${BT3}json
109
+ {
110
+ "id": "q_1700000000000_abc123",
111
+ "status": "success",
112
+ "duration": 12,
113
+ "rows": [{"id":1,"email":"a@b.com"}],
114
+ "fields": [{"name":"id","type":"INT"}, {"name":"email","type":"VARCHAR"}],
115
+ "rowCount": 1
116
+ }
117
+ ${BT3}
118
+
119
+ **Response shape (error):** \`{ "status": "error", "error": "...", "errorCode": "..." }\`
120
+ The HTTP status is \`400\` for SQL errors and \`500\` only for unexpected failures.
121
+
122
+ ## Step 6: Review history
123
+
124
+ ${BT3}bash
125
+ curl -s "http://localhost:5174/api/database/buildings/BUILDING_ID/history?limit=20" | jq '.history[] | {executedAt, status, duration, query}'
126
+ ${BT3}
127
+
128
+ Only queries explicitly run with \`recordHistory:true\` (or executed via the UI)
129
+ appear here.
130
+
131
+ ---
132
+
133
+ ## Important Rules
134
+
135
+ 1. **LIMIT is automatic.** SELECTs without a LIMIT/TOP/FETCH clause are wrapped
136
+ server-side using the \`limit\` body field (default 1000, max 10000). You don't
137
+ need to add it manually for safety, but you can.
138
+ 2. **One query per request.** The execute endpoint runs a single statement; do
139
+ not send multi-statement scripts.
140
+ 3. **Mutations are real.** \`UPDATE\`, \`DELETE\`, \`DROP\`, etc. execute against the
141
+ live connection — confirm the building/connection IDs before destructive ops.
142
+ 4. **Engine quirks:**
143
+ - Oracle: pass the **schema name** as \`{database}\`.
144
+ - SQLite: typically \`main\`. Pragma queries work as raw SQL.
145
+ - SQL Server: \`TOP n\` is auto-injected for SELECTs without it.
146
+ 5. **Never log credentials.** Connection objects from the API never include
147
+ passwords — \`hasPassword: true\` is the only signal.
148
+
149
+ ## Example Workflow
150
+
151
+ ${BT3}bash
152
+ # 1) Find DB buildings and pick one
153
+ curl -s http://localhost:5174/api/database/buildings \\
154
+ | jq '.buildings[] | {id, name, activeConnectionId}'
155
+
156
+ # 2) Verify
157
+ curl -s -X POST http://localhost:5174/api/database/buildings/building_xxx/connections/conn_yyy/test
158
+
159
+ # 3) Pick a database, list its tables
160
+ curl -s http://localhost:5174/api/database/buildings/building_xxx/connections/conn_yyy/databases | jq
161
+ curl -s http://localhost:5174/api/database/buildings/building_xxx/connections/conn_yyy/databases/app_prod/tables \\
162
+ | jq '.tables | map(.name)'
163
+
164
+ # 4) Inspect a table
165
+ curl -s http://localhost:5174/api/database/buildings/building_xxx/connections/conn_yyy/databases/app_prod/tables/orders/schema \\
166
+ | jq '.columns | map({name, type, primaryKey, nullable})'
167
+
168
+ # 5) Run an exploratory query
169
+ curl -s -X POST http://localhost:5174/api/database/buildings/building_xxx/connections/conn_yyy/query \\
170
+ -H "Content-Type: application/json" \\
171
+ -d '{"database":"app_prod","query":"SELECT status, COUNT(*) c FROM orders GROUP BY status"}' \\
172
+ | jq '.rows'
173
+ ${BT3}
174
+ `,
175
+ };
@@ -13,6 +13,7 @@ import { streamingExec } from './streaming-exec.js';
13
13
  import { bitbucketPR } from './bitbucket-pr.js';
14
14
  import { pm2Logs } from './pm2-logs.js';
15
15
  import { createBuilding } from './create-building.js';
16
+ import { exploreDatabase } from './explore-database.js';
16
17
  import { releasePipeline } from './release-pipeline.js';
17
18
  import { taskLabel } from './task-label.js';
18
19
  import { agentTracking } from './agent-tracking.js';
@@ -34,6 +35,7 @@ export const BUILTIN_SKILLS = [
34
35
  bitbucketPR,
35
36
  pm2Logs,
36
37
  createBuilding,
38
+ exploreDatabase,
37
39
  releasePipeline,
38
40
  taskLabel,
39
41
  agentTracking,
@@ -323,6 +323,58 @@ export function getClaudeProjectDir(cwd) {
323
323
  // Buildings cache to avoid reading from disk on every call
324
324
  let buildingsCache = null;
325
325
  let buildingsCacheMtime = 0;
326
+ // Sensitive fields on a DatabaseConnection that should be encrypted at rest.
327
+ // Kept as a typed list so adding a new sensitive field surfaces a compile error
328
+ // instead of silently leaking plaintext.
329
+ const DB_CONNECTION_SECRET_KEYS = ['password'];
330
+ const SSH_TUNNEL_SECRET_KEYS = ['password', 'privateKey', 'passphrase'];
331
+ function encryptIfPresent(value) {
332
+ if (!value)
333
+ return value;
334
+ if (isEncrypted(value))
335
+ return value;
336
+ try {
337
+ return encryptValue(value);
338
+ }
339
+ catch {
340
+ return value;
341
+ }
342
+ }
343
+ function decryptIfEncrypted(value) {
344
+ if (!value)
345
+ return value;
346
+ if (!isEncrypted(value))
347
+ return value;
348
+ try {
349
+ return decryptValue(value);
350
+ }
351
+ catch (err) {
352
+ log.error(' Failed to decrypt building credential:', err);
353
+ return '';
354
+ }
355
+ }
356
+ function transformBuildingCredentials(buildings, mode) {
357
+ const transform = mode === 'encrypt' ? encryptIfPresent : decryptIfEncrypted;
358
+ return buildings.map(b => {
359
+ if (b.type !== 'database' || !b.database?.connections?.length)
360
+ return b;
361
+ const connections = b.database.connections.map(conn => {
362
+ const next = { ...conn };
363
+ for (const key of DB_CONNECTION_SECRET_KEYS) {
364
+ next[key] = transform(next[key]);
365
+ }
366
+ if (next.ssh) {
367
+ const ssh = { ...next.ssh };
368
+ for (const key of SSH_TUNNEL_SECRET_KEYS) {
369
+ ssh[key] = transform(ssh[key]);
370
+ }
371
+ next.ssh = ssh;
372
+ }
373
+ return next;
374
+ });
375
+ return { ...b, database: { ...b.database, connections } };
376
+ });
377
+ }
326
378
  /**
327
379
  * Load buildings from disk (cached, invalidated by file mtime)
328
380
  */
@@ -340,7 +392,7 @@ export function loadBuildings() {
340
392
  catch { /* proceed to read */ }
341
393
  const data = safeReadJsonSync(BUILDINGS_FILE, 'Buildings');
342
394
  if (data?.buildings) {
343
- buildingsCache = data.buildings;
395
+ buildingsCache = transformBuildingCredentials(data.buildings, 'decrypt');
344
396
  try {
345
397
  buildingsCacheMtime = fs.statSync(BUILDINGS_FILE).mtimeMs;
346
398
  }
@@ -358,7 +410,9 @@ export function loadBuildings() {
358
410
  export function saveBuildings(buildings) {
359
411
  ensureDataDir();
360
412
  try {
361
- atomicWriteJsonSync(BUILDINGS_FILE, { buildings, savedAt: Date.now(), version: '1.0.0' });
413
+ const persisted = transformBuildingCredentials(buildings, 'encrypt');
414
+ atomicWriteJsonSync(BUILDINGS_FILE, { buildings: persisted, savedAt: Date.now(), version: '1.0.0' });
415
+ // Cache the in-memory (decrypted) view so callers keep working with plaintext
362
416
  buildingsCache = buildings;
363
417
  buildingsCacheMtime = fs.statSync(BUILDINGS_FILE).mtimeMs;
364
418
  }
@@ -7,7 +7,7 @@ import { createServer } from 'http';
7
7
  import { createServer as createHttpsServer } from 'https';
8
8
  import fs from 'node:fs';
9
9
  import { createApp } from './app.js';
10
- import { agentService, runtimeService, bossService, skillService, customClassService, secretsService, buildingService, eventRetentionService, triggerService, workflowService } from './services/index.js';
10
+ import { agentService, runtimeService, bossService, skillService, customClassService, secretsService, buildingService, eventRetentionService, triggerService, workflowService, databaseService } from './services/index.js';
11
11
  import * as websocket from './websocket/handler.js';
12
12
  import { getDataDir } from './data/index.js';
13
13
  import { initEventDb, closeEventDb } from './data/event-db.js';
@@ -212,6 +212,7 @@ async function main() {
212
212
  buildingService.stopDockerStatusPolling();
213
213
  buildingService.stopTerminalStatusPolling();
214
214
  buildingService.cleanupAllTerminals();
215
+ await databaseService.closeAllConnections();
215
216
  await runtimeService.shutdown();
216
217
  agentService.shutdownSessionHistory();
217
218
  agentService.flushPersistAgents();
@@ -47,6 +47,10 @@ export const gmailTriggerHandler = {
47
47
  extractVariables(trigger, event) {
48
48
  const msg = event.data;
49
49
  void trigger;
50
+ const labels = msg.labels ?? [];
51
+ // Gmail tags sent messages with the SENT label. Anything else is treated
52
+ // as inbound (covers INBOX, drafts, all-mail, etc.).
53
+ const direction = labels.includes('SENT') ? 'outbound' : 'inbound';
50
54
  return {
51
55
  'email.from': msg.from,
52
56
  'email.to': msg.to.join(', '),
@@ -57,10 +61,14 @@ export const gmailTriggerHandler = {
57
61
  'email.date': new Date(msg.date).toISOString(),
58
62
  'email.hasAttachments': String(msg.hasAttachments),
59
63
  'email.attachments': msg.attachmentNames?.join(', ') || '',
64
+ 'email.direction': direction,
65
+ 'email.labels': labels.join(', '),
60
66
  };
61
67
  },
62
68
  formatEventForLLM(event) {
63
69
  const msg = event.data;
64
- return `Email from ${msg.from}\nSubject: ${msg.subject}\nDate: ${new Date(msg.date).toISOString()}\n\n${msg.body}`;
70
+ const labels = msg.labels ?? [];
71
+ const direction = labels.includes('SENT') ? 'outbound' : 'inbound';
72
+ return `Email (${direction}) from ${msg.from}\nTo: ${msg.to.join(', ')}\nSubject: ${msg.subject}\nDate: ${new Date(msg.date).toISOString()}\n\n${msg.body}`;
65
73
  },
66
74
  };
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { createWhatsAppRoutes } from './whatsapp-routes.js';
12
12
  import { whatsappConfigSchema, getConfigValues, setConfigValues, loadConfig, WHATSAPP_API_KEY_SECRET, } from './whatsapp-config.js';
13
- import { createWhatsAppTriggerHandler, } from './whatsapp-trigger-handler.js';
13
+ import { createWhatsAppTriggerHandler, whatsappTriggerHandler, } from './whatsapp-trigger-handler.js';
14
14
  import { whatsappSkill } from './whatsapp-skill.js';
15
15
  let integrationCtx = null;
16
16
  let lastChecked = 0;
@@ -99,9 +99,10 @@ export const whatsappPlugin = {
99
99
  return [whatsappSkill];
100
100
  },
101
101
  getTriggerHandler() {
102
- // The TriggerHandler interface here is the trigger-service one.
103
- // Our incoming-message bridge is in-process and not surfaced through it.
104
- return null;
102
+ // Real trigger-service handler. Subscribes to the in-process bridge's
103
+ // notifyTriggerSubscribers stream so events flow into trigger evaluation
104
+ // even across bridge restarts.
105
+ return whatsappTriggerHandler;
105
106
  },
106
107
  getStatus() {
107
108
  const config = loadConfig();
@@ -39,6 +39,16 @@ export class WhatsAppClient {
39
39
  const data = await this.request('GET', `/api/sessions/${encodeURIComponent(sessionId)}/contacts`);
40
40
  return Array.isArray(data) ? data : [];
41
41
  }
42
+ async syncChatMessages(sessionId, chatId, count = 50) {
43
+ return this.request('POST', `/api/sessions/${encodeURIComponent(sessionId)}/chats/${encodeURIComponent(chatId)}/sync-messages?count=${encodeURIComponent(String(count))}`, {});
44
+ }
45
+ async syncContacts(sessionId) {
46
+ return this.request('POST', `/api/sessions/${encodeURIComponent(sessionId)}/sync-contacts`, {});
47
+ }
48
+ async getChatMessages(sessionId, chatId, limit = 50) {
49
+ const data = await this.request('GET', `/api/sessions/${encodeURIComponent(sessionId)}/chats/${encodeURIComponent(chatId)}/messages?limit=${encodeURIComponent(String(limit))}`);
50
+ return Array.isArray(data) ? data : [];
51
+ }
42
52
  async sendMediaUrl(sessionId, to, mediaUrl, caption, options) {
43
53
  // Upstream body shape: { to, url, caption?, filename? } (type is auto-detected
44
54
  // from the fetched URL's Content-Type header). `type` is forwarded for
@@ -198,6 +198,74 @@ export function createWhatsAppRoutes(ctx) {
198
198
  res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
199
199
  }
200
200
  });
201
+ // ─── POST /sessions/:sessionId/chats/:chatId/sync-messages — Force on-demand history sync for a chat ───
202
+ router.post('/sessions/:sessionId/chats/:chatId/sync-messages', async (req, res) => {
203
+ const built = getClient();
204
+ if ('error' in built) {
205
+ res.status(built.status).json({ error: built.error });
206
+ return;
207
+ }
208
+ const rawCount = typeof req.query.count === 'string' ? Number(req.query.count) : NaN;
209
+ const count = Number.isFinite(rawCount) && rawCount > 0 ? Math.floor(rawCount) : 50;
210
+ try {
211
+ const result = await built.client.syncChatMessages(req.params.sessionId, req.params.chatId, count);
212
+ res.json({ success: true, data: result });
213
+ }
214
+ catch (err) {
215
+ log.error(`WhatsApp syncChatMessages error: ${err}`);
216
+ res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
217
+ }
218
+ });
219
+ // ─── POST /sessions/:sessionId/sync-contacts — Force address-book resync via Baileys app-state ───
220
+ router.post('/sessions/:sessionId/sync-contacts', async (req, res) => {
221
+ const built = getClient();
222
+ if ('error' in built) {
223
+ res.status(built.status).json({ error: built.error });
224
+ return;
225
+ }
226
+ try {
227
+ const result = await built.client.syncContacts(req.params.sessionId);
228
+ res.json({ success: true, data: result });
229
+ }
230
+ catch (err) {
231
+ log.error(`WhatsApp syncContacts error: ${err}`);
232
+ res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
233
+ }
234
+ });
235
+ // ─── GET /sessions/:sessionId/contacts — List contacts for a session ───
236
+ router.get('/sessions/:sessionId/contacts', async (req, res) => {
237
+ const built = getClient();
238
+ if ('error' in built) {
239
+ res.status(built.status).json({ error: built.error });
240
+ return;
241
+ }
242
+ try {
243
+ const result = await built.client.getContacts(req.params.sessionId);
244
+ res.json({ success: true, data: result });
245
+ }
246
+ catch (err) {
247
+ log.error(`WhatsApp getContacts error: ${err}`);
248
+ res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
249
+ }
250
+ });
251
+ // ─── GET /sessions/:sessionId/chats/:chatId/messages — Fetch recent messages for a chat ───
252
+ router.get('/sessions/:sessionId/chats/:chatId/messages', async (req, res) => {
253
+ const built = getClient();
254
+ if ('error' in built) {
255
+ res.status(built.status).json({ error: built.error });
256
+ return;
257
+ }
258
+ const rawLimit = typeof req.query.limit === 'string' ? Number(req.query.limit) : NaN;
259
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.floor(rawLimit) : 50;
260
+ try {
261
+ const result = await built.client.getChatMessages(req.params.sessionId, req.params.chatId, limit);
262
+ res.json({ success: true, data: result });
263
+ }
264
+ catch (err) {
265
+ log.error(`WhatsApp getChatMessages error: ${err}`);
266
+ res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
267
+ }
268
+ });
201
269
  // ─── POST /send-message — Send a text message via Baileys ───
202
270
  router.post('/send-message', async (req, res) => {
203
271
  const body = (req.body ?? {});
@@ -13,6 +13,21 @@ import { WhatsAppWsClient } from './whatsapp-ws-client.js';
13
13
  import { MessageDedupeCache } from './message-dedupe.js';
14
14
  import { ContactNameCache } from './contact-name-cache.js';
15
15
  import { WhatsAppClient } from './whatsapp-client.js';
16
+ // Module-level subscriber set fed by the in-process bridge whenever a normalized
17
+ // WhatsApp message is broadcast. The trigger-service-facing handler below
18
+ // registers its callback here, so subscribers stay attached even when the bridge
19
+ // restarts (e.g. on config change).
20
+ const triggerSubscribers = new Set();
21
+ function notifyTriggerSubscribers(payload) {
22
+ for (const cb of triggerSubscribers) {
23
+ try {
24
+ cb(payload);
25
+ }
26
+ catch {
27
+ // Subscriber errors must not break the bridge — swallow.
28
+ }
29
+ }
30
+ }
16
31
  // Dedupe cache config. The upstream whatsapp-api fires BOTH `message` and
17
32
  // `message_create` for the same Baileys event on inbound DMs (see
18
33
  // providers/baileysSessionManager.js around the `messages.upsert` handler — it
@@ -105,6 +120,7 @@ export function createWhatsAppTriggerHandler(ctx) {
105
120
  !!payload.from;
106
121
  if (!needsEnrichment) {
107
122
  ctx.broadcast({ type: 'whatsapp_message', payload });
123
+ notifyTriggerSubscribers(payload);
108
124
  return;
109
125
  }
110
126
  void contactNames.lookup(msg.sessionId, payload.from).then((name) => {
@@ -112,9 +128,11 @@ export function createWhatsAppTriggerHandler(ctx) {
112
128
  if (sanitized)
113
129
  payload.fromName = sanitized;
114
130
  ctx.broadcast({ type: 'whatsapp_message', payload });
131
+ notifyTriggerSubscribers(payload);
115
132
  }, (err) => {
116
133
  ctx.log.warn(`WhatsApp contact lookup failed: ${err}`);
117
134
  ctx.broadcast({ type: 'whatsapp_message', payload });
135
+ notifyTriggerSubscribers(payload);
118
136
  });
119
137
  }
120
138
  // Other event types (message_ack, group_join, group_leave, hello, error) are
@@ -216,6 +234,7 @@ function normalizeBaileysMessage(sessionId, eventName, data, ts, baseUrl) {
216
234
  mediaType,
217
235
  mediaUrl,
218
236
  direction,
237
+ chatId: remoteJid ?? from,
219
238
  };
220
239
  }
221
240
  function extractContent(envelope, message) {
@@ -355,3 +374,97 @@ export function sanitizeFromName(name, fromJid) {
355
374
  }
356
375
  return trimmed;
357
376
  }
377
+ // Chat IDs that represent non-message channels (status updates, broadcast
378
+ // lists). The bridge surfaces them as messages because Baileys delivers
379
+ // status posts via `messages.upsert`, but they're not 1:1 / group chats and
380
+ // most triggers shouldn't fire on them.
381
+ function isStatusOrBroadcastChat(chatId) {
382
+ if (!chatId)
383
+ return false;
384
+ if (chatId === 'status@broadcast')
385
+ return true;
386
+ if (chatId.endsWith('@broadcast'))
387
+ return true;
388
+ return false;
389
+ }
390
+ let triggerUnsubscribe = null;
391
+ export const whatsappTriggerHandler = {
392
+ triggerType: 'whatsapp',
393
+ async startListening(onEvent) {
394
+ const cb = (msg) => {
395
+ onEvent({
396
+ source: 'whatsapp',
397
+ type: 'message',
398
+ data: msg,
399
+ timestamp: msg.timestamp,
400
+ });
401
+ };
402
+ triggerSubscribers.add(cb);
403
+ triggerUnsubscribe = () => triggerSubscribers.delete(cb);
404
+ },
405
+ async stopListening() {
406
+ if (triggerUnsubscribe) {
407
+ triggerUnsubscribe();
408
+ triggerUnsubscribe = null;
409
+ }
410
+ },
411
+ structuralMatch(trigger, event) {
412
+ const msg = event.data;
413
+ const cfg = trigger.config;
414
+ // Drop status updates and broadcast-list messages by default — they're
415
+ // surfaced through the same bridge as real messages but represent stories /
416
+ // broadcasts, not conversations. Opt-in via includeStatuses.
417
+ if (!cfg.includeStatuses && isStatusOrBroadcastChat(msg.chatId))
418
+ return false;
419
+ if (cfg.direction && cfg.direction !== 'any' && msg.direction !== cfg.direction)
420
+ return false;
421
+ if (cfg.groupOnly && !msg.isGroup)
422
+ return false;
423
+ if (cfg.dmOnly && msg.isGroup)
424
+ return false;
425
+ if (cfg.sessionId && msg.sessionId !== cfg.sessionId)
426
+ return false;
427
+ if (cfg.fromFilter?.length) {
428
+ const fromLower = msg.from.toLowerCase();
429
+ if (!cfg.fromFilter.some(f => fromLower.includes(f.toLowerCase())))
430
+ return false;
431
+ }
432
+ if (cfg.bodyPattern) {
433
+ try {
434
+ if (!new RegExp(cfg.bodyPattern, 'i').test(msg.body))
435
+ return false;
436
+ }
437
+ catch {
438
+ return false;
439
+ }
440
+ }
441
+ return true;
442
+ },
443
+ extractVariables(trigger, event) {
444
+ const msg = event.data;
445
+ void trigger;
446
+ return {
447
+ 'whatsapp.from': msg.from,
448
+ 'whatsapp.fromName': msg.fromName ?? '',
449
+ 'whatsapp.body': msg.body,
450
+ 'whatsapp.sessionId': msg.sessionId,
451
+ 'whatsapp.chatId': msg.chatId,
452
+ 'whatsapp.isGroup': String(msg.isGroup),
453
+ 'whatsapp.groupName': msg.groupName ?? '',
454
+ 'whatsapp.direction': msg.direction,
455
+ 'whatsapp.mediaType': msg.mediaType ?? '',
456
+ 'whatsapp.mediaUrl': msg.mediaUrl ?? '',
457
+ 'whatsapp.timestamp': new Date(msg.timestamp).toISOString(),
458
+ };
459
+ },
460
+ formatEventForLLM(event) {
461
+ const msg = event.data;
462
+ const sender = msg.fromName ? `${msg.fromName} (${msg.from})` : msg.from;
463
+ const channel = msg.isGroup ? `group "${msg.groupName ?? msg.from}"` : 'DM';
464
+ const verb = msg.direction === 'outbound' ? 'sent' : 'received';
465
+ const media = msg.mediaType
466
+ ? `\n[${msg.mediaType} attachment${msg.mediaUrl ? ` ${msg.mediaUrl}` : ''}]`
467
+ : '';
468
+ return `WhatsApp message ${verb} in ${channel}\nFrom: ${sender}\nSession: ${msg.sessionId}\nTime: ${new Date(msg.timestamp).toISOString()}${media}\n\n${msg.body}`;
469
+ },
470
+ };