tide-commander 1.87.0 → 1.89.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-S3Rke-8g.js → BossLogsModal-BK6N5fG2.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-BjWGNCnz.js → BossSpawnModal-BTy-lus4.js} +1 -1
  3. package/dist/assets/{ControlsModal-6yfU0XjZ.js → ControlsModal-B4MhaF1V.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-CYq0hNz6.js → DockerLogsModal-C33dAwy1.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-ZBdqRDqm.js → EmbeddedEditor-BfjjT-GF.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-BcV5jAse.js → GmailOAuthSetup-TQyjHs3_.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-DyUW_STE.js → GoogleOAuthSetup-DAIzYKy8.js} +1 -1
  8. package/dist/assets/{IframeModal-D9A3dUUc.js → IframeModal-g8tC4aah.js} +1 -1
  9. package/dist/assets/IntegrationsPanel-CuKr7702.js +2 -0
  10. package/dist/assets/{LogViewerModal-BWkbY7wa.js → LogViewerModal-DO45Kea0.js} +1 -1
  11. package/dist/assets/{MonitoringModal-AZzokZAZ.js → MonitoringModal-OIwmagj2.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-q98eiBfq.js → PM2LogsModal-BRQzSiFN.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-CTxRP2qE.js → RestoreArchivedAreaModal-CBRN9Xpb.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-C11dztp1.js → Scene2DCanvas-4J4ZefT6.js} +1 -1
  15. package/dist/assets/{SceneManager-CsW9MYrD.js → SceneManager-DZsJcYvW.js} +1 -1
  16. package/dist/assets/{SkillsPanel-BeZr9w6E.js → SkillsPanel-DHk7h3Ja.js} +1 -1
  17. package/dist/assets/SlackMultiInstanceSetup-Dp1q2zM1.js +2 -0
  18. package/dist/assets/{SpawnModal-DY_KM6lX.js → SpawnModal-CfozYMNI.js} +1 -1
  19. package/dist/assets/{SubordinateAssignmentModal-D6RvjGX9.js → SubordinateAssignmentModal-BBfbpVUr.js} +1 -1
  20. package/dist/assets/{TriggerManagerPanel-BmqjXv9T.js → TriggerManagerPanel-DQw9nt1r.js} +2 -2
  21. package/dist/assets/{WorkflowEditorPanel-Rd5ZjJmt.js → WorkflowEditorPanel-BM2ec8CS.js} +1 -1
  22. package/dist/assets/{index-DSvJOrb7.js → index-BiAZinYH.js} +2 -2
  23. package/dist/assets/{index-BYVHgVEo.js → index-BqbR55dr.js} +1 -1
  24. package/dist/assets/{index-DRGyDtmm.js → index-CcSJA57k.js} +1 -1
  25. package/dist/assets/{index-BtJyOo4p.js → index-DNEUJDeO.js} +1 -1
  26. package/dist/assets/{index-BoORE9Q1.js → index-DY9w7IcH.js} +1 -1
  27. package/dist/assets/{index-DHHRkTG1.js → index-bcwTXJ6F.js} +1 -1
  28. package/dist/assets/index-fZfyvIUZ.js +2 -0
  29. package/dist/assets/{index-BxaEkSIx.js → index-jXkaBxIq.js} +3 -3
  30. package/dist/assets/index-xEvpFBA8.js +8 -0
  31. package/dist/assets/main-Bw5ZddEN.css +1 -0
  32. package/dist/assets/main-D-YFCprA.js +213 -0
  33. package/dist/assets/{web-D3zCwsS9.js → web-BrBkKQlr.js} +1 -1
  34. package/dist/assets/{web-DS0FHmg8.js → web-DCu3NTho.js} +1 -1
  35. package/dist/assets/{web-DEq3Te_H.js → web-DX588C-g.js} +1 -1
  36. package/dist/index.html +2 -2
  37. package/dist/src/packages/server/data/builtin-skills/explore-database.js +175 -0
  38. package/dist/src/packages/server/data/builtin-skills/index.js +2 -0
  39. package/dist/src/packages/server/data/event-queries.js +2 -0
  40. package/dist/src/packages/server/data/index.js +56 -2
  41. package/dist/src/packages/server/data/migrations/006_slack_messages_integration_instance.sql +9 -0
  42. package/dist/src/packages/server/index.js +2 -1
  43. package/dist/src/packages/server/integrations/gmail/gmail-trigger-handler.js +9 -1
  44. package/dist/src/packages/server/integrations/slack/index.js +65 -19
  45. package/dist/src/packages/server/integrations/slack/slack-client.js +44 -602
  46. package/dist/src/packages/server/integrations/slack/slack-config.js +229 -29
  47. package/dist/src/packages/server/integrations/slack/slack-instance-manifest.js +150 -0
  48. package/dist/src/packages/server/integrations/slack/slack-instance.js +801 -0
  49. package/dist/src/packages/server/integrations/slack/slack-polling-client.js +522 -0
  50. package/dist/src/packages/server/integrations/slack/slack-routes.js +243 -24
  51. package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +53 -20
  52. package/dist/src/packages/server/integrations/slack/slack-watermark-store.js +124 -0
  53. package/dist/src/packages/server/integrations/whatsapp/index.js +5 -4
  54. package/dist/src/packages/server/integrations/whatsapp/whatsapp-client.js +10 -0
  55. package/dist/src/packages/server/integrations/whatsapp/whatsapp-routes.js +68 -0
  56. package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +127 -0
  57. package/dist/src/packages/server/routes/database.js +221 -0
  58. package/dist/src/packages/server/routes/files.js +219 -18
  59. package/dist/src/packages/server/routes/index.js +2 -0
  60. package/dist/src/packages/server/services/building-service.js +41 -0
  61. package/dist/src/packages/server/services/database-service.js +61 -9
  62. package/dist/src/packages/server/services/index.js +1 -0
  63. package/dist/src/packages/server/services/ssh-tunnel-service.js +255 -0
  64. package/dist/src/packages/server/websocket/handler.js +2 -1
  65. package/dist/src/packages/server/websocket/handlers/database-handler.js +35 -0
  66. package/package.json +3 -1
  67. package/dist/assets/IntegrationsPanel-CHaNJBJW.js +0 -2
  68. package/dist/assets/index-BOr_tbLK.js +0 -2
  69. package/dist/assets/index-Co7njQ0Q.js +0 -8
  70. package/dist/assets/main-BrZe9Zbd.js +0 -201
  71. package/dist/assets/main-kpU9m5LW.css +0 -1
@@ -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-D-YFCprA.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 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-D-YFCprA.js";import{ImpactStyle as i,NotificationType as r}from"./index-BiAZinYH.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 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-D-YFCprA.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-D-YFCprA.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-Bw5ZddEN.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,
@@ -75,6 +75,7 @@ function slackRowToEvent(row) {
75
75
  workflowInstanceId: row.workflow_instance_id ?? undefined,
76
76
  rawEvent: fromJson(row.raw_event),
77
77
  receivedAt: row.received_at,
78
+ integrationInstanceId: row.integration_instance_id ?? 'default',
78
79
  };
79
80
  }
80
81
  export function logSlackMessage(msg) {
@@ -91,6 +92,7 @@ export function logSlackMessage(msg) {
91
92
  workflow_instance_id: msg.workflowInstanceId ?? null,
92
93
  raw_event: toJson(msg.rawEvent),
93
94
  received_at: msg.receivedAt,
95
+ integration_instance_id: msg.integrationInstanceId ?? 'default',
94
96
  });
95
97
  }
96
98
  function emailRowToEvent(row) {
@@ -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
  }
@@ -0,0 +1,9 @@
1
+ -- Multi-instance Slack support
2
+ -- Adds an instance id column to slack_messages so two side-by-side Slack
3
+ -- integrations (e.g. xoxb- bot + xoxp- personal token) keep their inbound
4
+ -- messages distinguishable. Existing rows are tagged 'default' to match the
5
+ -- pre-multi-instance singleton.
6
+
7
+ ALTER TABLE slack_messages ADD COLUMN integration_instance_id TEXT NOT NULL DEFAULT 'default';
8
+
9
+ CREATE INDEX IF NOT EXISTS idx_slack_messages_instance ON slack_messages(integration_instance_id);
@@ -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
  };
@@ -1,24 +1,52 @@
1
1
  /**
2
2
  * Slack Integration Plugin
3
3
  * Exports slackPlugin implementing IntegrationPlugin.
4
- * Wires together slack-client, slack-routes, slack-trigger-handler, slack-skill, and slack-config.
4
+ *
5
+ * Multi-instance: this plugin owns N independent Slack connections (e.g. one
6
+ * for a team bot using xoxb-, one for the user's personal xoxp- token). Each
7
+ * instance has its own config file, secret keys, and watermark file, but they
8
+ * share the same dispatcher pipeline (so triggers / wait-for-reply / WS
9
+ * broadcasts work uniformly).
10
+ *
11
+ * The IntegrationPlugin contract is per-plugin, not per-instance — Slack
12
+ * exposes its instance management through a custom settings component
13
+ * (registered via `getCustomSettingsComponent()`) and dedicated /instances
14
+ * CRUD routes.
5
15
  */
6
16
  import * as slackClient from './slack-client.js';
7
- import slackRoutes from './slack-routes.js';
17
+ import slackRoutes, { setIntegrationContextForRoutes } from './slack-routes.js';
8
18
  import { slackTriggerHandler } from './slack-trigger-handler.js';
9
19
  import { slackSkill } from './slack-skill.js';
10
- import { slackConfigSchema, getConfigValues, setConfigValues, loadConfig } from './slack-config.js';
20
+ import { slackConfigSchema, getConfigValues, setConfigValues, loadConfig, } from './slack-config.js';
21
+ import { getInstance, listInstances, DEFAULT_INSTANCE_ID, } from './slack-instance.js';
22
+ import { listInstanceMetas } from './slack-instance-manifest.js';
11
23
  let integrationCtx = null;
12
24
  export const slackPlugin = {
13
25
  id: 'slack',
14
26
  name: 'Slack',
15
- description: 'Bidirectional Slack messaging for agents',
27
+ description: 'Bidirectional Slack messaging for agents (multi-instance)',
16
28
  routePrefix: '/slack',
17
29
  async init(ctx) {
18
30
  integrationCtx = ctx;
19
- await slackClient.init(ctx);
31
+ setIntegrationContextForRoutes(ctx);
32
+ // Bring up every instance from the manifest. The default instance is
33
+ // always present (the manifest auto-creates it); user-added instances
34
+ // come in alongside it.
35
+ const metas = listInstanceMetas();
36
+ for (const meta of metas) {
37
+ const inst = getInstance(meta.id);
38
+ inst.setContext(ctx);
39
+ try {
40
+ await inst.init();
41
+ }
42
+ catch (err) {
43
+ ctx.log.error(`Slack[${meta.id}] init failed: ${err}`);
44
+ // One bad instance shouldn't take the others down.
45
+ }
46
+ }
20
47
  },
21
48
  async shutdown() {
49
+ setIntegrationContextForRoutes(null);
22
50
  await slackClient.shutdown();
23
51
  },
24
52
  getRoutes() {
@@ -30,15 +58,25 @@ export const slackPlugin = {
30
58
  getTriggerHandler() {
31
59
  return slackTriggerHandler;
32
60
  },
61
+ /**
62
+ * Status reflects the default instance (back-compat for the generic UI).
63
+ * Per-instance status is exposed through GET /api/slack/instances and the
64
+ * custom settings component reads from there.
65
+ */
33
66
  getStatus() {
34
- return slackClient.getStatus();
67
+ return getInstance(DEFAULT_INSTANCE_ID).getStatus();
35
68
  },
36
69
  getConfigSchema() {
37
70
  return slackConfigSchema;
38
71
  },
72
+ /**
73
+ * The generic UI calls getConfig once and gets the default-instance values.
74
+ * The custom multi-instance UI uses /api/slack/instances/:id for per-instance
75
+ * config.
76
+ */
39
77
  getConfig() {
40
78
  if (!integrationCtx) {
41
- const config = loadConfig();
79
+ const config = loadConfig(DEFAULT_INSTANCE_ID);
42
80
  return {
43
81
  enabled: config.enabled,
44
82
  defaultChannelId: config.defaultChannelId || '',
@@ -46,28 +84,36 @@ export const slackPlugin = {
46
84
  SLACK_APP_TOKEN: '',
47
85
  };
48
86
  }
49
- return getConfigValues(integrationCtx.secrets);
87
+ return getConfigValues(integrationCtx.secrets, DEFAULT_INSTANCE_ID);
50
88
  },
51
89
  async setConfig(config) {
52
90
  if (!integrationCtx)
53
91
  throw new Error('Slack not initialized');
54
- await setConfigValues(config, integrationCtx.secrets);
55
- // Auto-connect or disconnect based on new config state
56
- const updated = loadConfig();
92
+ await setConfigValues(config, integrationCtx.secrets, DEFAULT_INSTANCE_ID);
93
+ // Reconnect the default instance based on its new config / secrets.
94
+ const updated = loadConfig(DEFAULT_INSTANCE_ID);
57
95
  const botToken = integrationCtx.secrets.get('SLACK_BOT_TOKEN');
58
- const appToken = integrationCtx.secrets.get('SLACK_APP_TOKEN');
59
- if (updated.enabled && botToken && appToken) {
60
- // Tokens + enabled: (re)connect
96
+ const inst = getInstance(DEFAULT_INSTANCE_ID);
97
+ if (updated.enabled && botToken) {
61
98
  try {
62
- await slackClient.reconnect();
99
+ await inst.reconnect();
63
100
  }
64
101
  catch {
65
- // Error is already captured in config status by reconnect()
102
+ // Error captured via updateConfig's status:'error' path.
66
103
  }
67
104
  }
68
- else if (!updated.enabled && slackClient.isConnected()) {
69
- // Disabled: disconnect
70
- await slackClient.disconnect();
105
+ else if (!updated.enabled && inst.isConnected()) {
106
+ await inst.disconnect();
71
107
  }
72
108
  },
109
+ /**
110
+ * Tells the frontend to render the custom multi-instance UI instead of the
111
+ * generic single-form schema renderer. The component name is mapped client-
112
+ * side in IntegrationsPanel.tsx.
113
+ */
114
+ getCustomSettingsComponent() {
115
+ return 'slack-multi-instance';
116
+ },
73
117
  };
118
+ // Re-export for use by routes / trigger handler.
119
+ export { listInstances };