tide-commander 1.146.0 → 1.147.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 (49) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/assets/{BossLogsModal-BR0IXO2N.js → BossLogsModal-Cn8IVdqn.js} +1 -1
  3. package/dist/assets/{BossSpawnModal-x75-j2da.js → BossSpawnModal-Don-RZyh.js} +1 -1
  4. package/dist/assets/{ControlsModal-DVil4qEv.js → ControlsModal-C9--xWPi.js} +1 -1
  5. package/dist/assets/{DockerLogsModal-CFREftxm.js → DockerLogsModal-DSGnpfm5.js} +1 -1
  6. package/dist/assets/{EmbeddedEditor-BB1dXjgA.js → EmbeddedEditor-DfM3SrO7.js} +1 -1
  7. package/dist/assets/{GmailOAuthSetup-Bt4lYIib.js → GmailOAuthSetup-DBWs7uzU.js} +1 -1
  8. package/dist/assets/{GoogleOAuthSetup-D9WbR8pi.js → GoogleOAuthSetup-yQjT5QzU.js} +1 -1
  9. package/dist/assets/{IframeModal-VRxo8uW0.js → IframeModal-DUTIyP5I.js} +1 -1
  10. package/dist/assets/{IntegrationsPanel-CPGny7vx.js → IntegrationsPanel-DPURuiaz.js} +2 -2
  11. package/dist/assets/{LogViewerModal-bkAbzlbh.js → LogViewerModal-CDZvTdfO.js} +1 -1
  12. package/dist/assets/{MonitoringModal-CkpuOazc.js → MonitoringModal-Dq2EJgbH.js} +1 -1
  13. package/dist/assets/{PM2LogsModal-Cllh-p5p.js → PM2LogsModal-C3dlYlGv.js} +1 -1
  14. package/dist/assets/{RestoreArchivedAreaModal-DLjiody0.js → RestoreArchivedAreaModal-BMFk0G8D.js} +1 -1
  15. package/dist/assets/{Scene2DCanvas-UxkEcgli.js → Scene2DCanvas-DlumJCbl.js} +1 -1
  16. package/dist/assets/{SceneManager-3z1c1ca8.js → SceneManager-UnhMFja8.js} +1 -1
  17. package/dist/assets/{SkillsPanel-Opv6_qsA.js → SkillsPanel-D326VBDa.js} +1 -1
  18. package/dist/assets/{SlackMultiInstanceSetup-BRBXqcBK.js → SlackMultiInstanceSetup-CA4nm925.js} +1 -1
  19. package/dist/assets/{SpawnModal-Cgr2So6H.js → SpawnModal-B1QOjZPe.js} +1 -1
  20. package/dist/assets/{StatisticsModal-CQ9zX_te.js → StatisticsModal-BUpvkJXA.js} +1 -1
  21. package/dist/assets/{SubordinateAssignmentModal-DYl_KFut.js → SubordinateAssignmentModal-DtVsKDqV.js} +1 -1
  22. package/dist/assets/{TriggerManagerPanel-DteLW6l2.js → TriggerManagerPanel-OD14xVMw.js} +1 -1
  23. package/dist/assets/{WorkflowEditorPanel-C_OQpYuO.js → WorkflowEditorPanel-B4UF3rcS.js} +1 -1
  24. package/dist/assets/{area-logos-CcA80suT.js → area-logos-Uc9SAeYg.js} +1 -1
  25. package/dist/assets/{index-CtzZOKXq.js → index-BC3Y_BAQ.js} +1 -1
  26. package/dist/assets/{index-DQhrNqbn.js → index-BD6swoQU.js} +3 -3
  27. package/dist/assets/index-C4mf6B5U.js +1 -0
  28. package/dist/assets/index-COHImd74.js +1 -0
  29. package/dist/assets/{index-CIuAGpI4.js → index-CScFEdm8.js} +1 -1
  30. package/dist/assets/{index-CCFfkD_H.js → index-CmmpVZ8w.js} +1 -1
  31. package/dist/assets/{index-BMCNBI0L.js → index-CuHJm43U.js} +1 -1
  32. package/dist/assets/{index-DhL1siJA.js → index-DkDzXtdj.js} +1 -1
  33. package/dist/assets/{index-DtUDyvyC.js → index-xBtKu0I8.js} +2 -2
  34. package/dist/assets/{main-BhpZ8DMy.js → main-Bp6V_-sL.js} +6 -6
  35. package/dist/assets/{web-5BrjMiIU.js → web-B-xc0c6e.js} +1 -1
  36. package/dist/assets/{web-Cx4v8f8d.js → web-BuDeKWBN.js} +1 -1
  37. package/dist/assets/{web-DpsRRzsh.js → web-DlzqHyn8.js} +1 -1
  38. package/dist/index.html +1 -1
  39. package/dist/locales/en/config.json +2 -1
  40. package/dist/locales/es/config.json +2 -1
  41. package/dist/src/packages/server/data/event-queries.js +24 -0
  42. package/dist/src/packages/server/data/migrations/008_slack_messages_chan_ts_index.sql +5 -0
  43. package/dist/src/packages/server/index.js +2 -0
  44. package/dist/src/packages/server/integrations/slack/slack-config.js +13 -0
  45. package/dist/src/packages/server/integrations/slack/slack-instance.js +67 -3
  46. package/dist/src/packages/server/integrations/slack/slack-polling-client.js +149 -68
  47. package/package.json +1 -1
  48. package/dist/assets/index-ClQaYJNq.js +0 -1
  49. package/dist/assets/index-k4vAPWgE.js +0 -1
@@ -1 +1 @@
1
- import{c$ as t}from"./main-BhpZ8DMy.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{c$ as t}from"./main-Bp6V_-sL.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 +1 @@
1
- import{c$ as a}from"./main-BhpZ8DMy.js";import{ImpactStyle as i,NotificationType as r}from"./index-DtUDyvyC.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{c$ as a}from"./main-Bp6V_-sL.js";import{ImpactStyle as i,NotificationType as r}from"./index-xBtKu0I8.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{c$ as s}from"./main-BhpZ8DMy.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{c$ as s}from"./main-Bp6V_-sL.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};
package/dist/index.html CHANGED
@@ -22,7 +22,7 @@
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-BhpZ8DMy.js"></script>
25
+ <script type="module" crossorigin src="/assets/main-Bp6V_-sL.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
28
  <link rel="stylesheet" crossorigin href="/assets/main-ijcbyjBn.css">
@@ -568,7 +568,8 @@
568
568
  "autoUpdatePackageManagerNotice": "Auto-update only supports npm-installed globals. Detected: {{pm}}.",
569
569
  "unattendedTitle": "Automatic updates",
570
570
  "unattendedDesc": "Install updates automatically when no agent is working (checks every 30 min)",
571
- "unattendedPendingRestart": "v{{version}} installed — will restart as soon as all agents are idle"
571
+ "unattendedPendingRestart": "v{{version}} installed — will restart as soon as all agents are idle",
572
+ "unattendedUnsupported": "Only available for npm global installs (runs on the server, not in dev mode)"
572
573
  },
573
574
  "updateBanner": {
574
575
  "newVersion": "New version v{{version}} available",
@@ -315,7 +315,8 @@
315
315
  "claudeCodeCredit": "por Anthropic, el motor de IA",
316
316
  "unattendedTitle": "Actualizaciones automáticas",
317
317
  "unattendedDesc": "Instalar actualizaciones automáticamente cuando ningún agente esté trabajando (revisa cada 30 min)",
318
- "unattendedPendingRestart": "v{{version}} instalada — se reiniciará en cuanto todos los agentes estén libres"
318
+ "unattendedPendingRestart": "v{{version}} instalada — se reiniciará en cuanto todos los agentes estén libres",
319
+ "unattendedUnsupported": "Solo disponible en instalaciones globales de npm (corre en el servidor, no en modo dev)"
319
320
  },
320
321
  "shortcuts": {
321
322
  "pressKeys": "Presione teclas...",
@@ -78,6 +78,30 @@ function slackRowToEvent(row) {
78
78
  integrationInstanceId: row.integration_instance_id ?? 'default',
79
79
  };
80
80
  }
81
+ /**
82
+ * Channels + threads with Slack activity since `sinceMs` for one integration
83
+ * instance. Seeds the polling reconciler's thread registry on startup so
84
+ * per-thread sweeps survive process restarts. Only rows that carry a
85
+ * thread_ts count — a thread where an agent engaged always has at least the
86
+ * bot's own mirrored reply.
87
+ */
88
+ export function recentSlackThreadActivity(instanceId, sinceMs) {
89
+ const rows = queryMany(`SELECT channel_id, thread_ts, MAX(received_at) AS last_ms
90
+ FROM slack_messages
91
+ WHERE integration_instance_id = ? AND thread_ts IS NOT NULL AND received_at > ?
92
+ GROUP BY channel_id, thread_ts`, [instanceId, sinceMs]);
93
+ return rows.map((r) => ({ channelId: r.channel_id, threadTs: r.thread_ts, lastActivityMs: r.last_ms }));
94
+ }
95
+ /**
96
+ * True when a message with this (channel, ts) is already logged for the given
97
+ * integration instance. Used by the Slack socket-mode reconciler to skip
98
+ * messages the live socket already dispatched — including across process
99
+ * restarts, which the in-memory dedup set alone can't cover.
100
+ */
101
+ export function hasSlackMessage(instanceId, channelId, ts) {
102
+ const row = queryOne('SELECT id FROM slack_messages WHERE channel_id = ? AND ts = ? AND integration_instance_id = ? LIMIT 1', [channelId, ts, instanceId]);
103
+ return row !== undefined;
104
+ }
81
105
  export function logSlackMessage(msg) {
82
106
  return insertOne('slack_messages', {
83
107
  ts: msg.ts,
@@ -0,0 +1,5 @@
1
+ -- Fast (channel_id, ts) point lookups for the Slack socket-mode reconciler
2
+ -- dedup check: the reconciler re-fetches recent history every cycle and must
3
+ -- cheaply skip messages Socket Mode already dispatched, including across
4
+ -- process restarts (where the in-memory dedup set is empty).
5
+ CREATE INDEX IF NOT EXISTS idx_slack_messages_chan_ts ON slack_messages(channel_id, ts);
@@ -79,6 +79,8 @@ async function main() {
79
79
  eventDb: {
80
80
  logTriggerFire: eventQueries.logTriggerFire,
81
81
  logSlackMessage: eventQueries.logSlackMessage,
82
+ hasSlackMessage: eventQueries.hasSlackMessage,
83
+ recentSlackThreadActivity: eventQueries.recentSlackThreadActivity,
82
84
  logWhatsAppMessage: eventQueries.logWhatsAppMessage,
83
85
  logEmailMessage: eventQueries.logEmailMessage,
84
86
  logApprovalEvent: eventQueries.logApprovalEvent,
@@ -18,6 +18,7 @@ const DEFAULT_CONFIG = {
18
18
  pollingDmsAlways: true,
19
19
  pollingMinMsBetweenCalls: 1500,
20
20
  pollingUseSearch: false,
21
+ socketReconcileEnabled: true,
21
22
  mirrorOwnMessages: false,
22
23
  reactOnTrigger: true,
23
24
  currentMode: 'none',
@@ -194,6 +195,14 @@ export const slackConfigSchema = [
194
195
  defaultValue: false,
195
196
  group: 'Polling',
196
197
  },
198
+ {
199
+ key: 'socketReconcileEnabled',
200
+ label: 'Socket Mode: polling reconciler',
201
+ type: 'boolean',
202
+ description: 'When inbound mode is Socket, also run the polling sweep in the background to recover messages the socket dropped (disconnects, ping-timeout flaps, process restarts). Uses the Polling settings above; duplicates are filtered by (channel, ts). Default on.',
203
+ defaultValue: true,
204
+ group: 'Polling',
205
+ },
197
206
  {
198
207
  key: 'mirrorOwnMessages',
199
208
  label: 'Mirror messages I send too',
@@ -251,6 +260,7 @@ export function getConfigValues(secrets, instanceId = DEFAULT_INSTANCE_ID) {
251
260
  pollingDmsAlways: config.pollingDmsAlways ?? true,
252
261
  pollingMinMsBetweenCalls: config.pollingMinMsBetweenCalls ?? 1500,
253
262
  pollingUseSearch: config.pollingUseSearch ?? false,
263
+ socketReconcileEnabled: config.socketReconcileEnabled ?? true,
254
264
  mirrorOwnMessages: config.mirrorOwnMessages ?? false,
255
265
  reactOnTrigger: config.reactOnTrigger ?? true,
256
266
  currentMode: config.currentMode ?? 'none',
@@ -303,6 +313,9 @@ export async function setConfigValues(values, secrets, instanceId = DEFAULT_INST
303
313
  if (typeof values.pollingUseSearch === 'boolean') {
304
314
  updates.pollingUseSearch = values.pollingUseSearch;
305
315
  }
316
+ if (typeof values.socketReconcileEnabled === 'boolean') {
317
+ updates.socketReconcileEnabled = values.socketReconcileEnabled;
318
+ }
306
319
  if (typeof values.mirrorOwnMessages === 'boolean') {
307
320
  updates.mirrorOwnMessages = values.mirrorOwnMessages;
308
321
  }
@@ -62,9 +62,26 @@ export class SlackInstance {
62
62
  nameCache = new SlackNameCache();
63
63
  messageListeners = new Set();
64
64
  replyWaiters = new Set();
65
+ /**
66
+ * (channel:ts) keys already dispatched by THIS process. With the socket +
67
+ * reconciler hybrid both transports funnel into dispatchInboundMessage, so
68
+ * every message can arrive twice — the Set (insertion-ordered, capped)
69
+ * short-circuits the overlap synchronously; the SQLite check in the
70
+ * dispatcher covers keys dispatched before a restart wiped this Set.
71
+ */
72
+ dispatchedKeys = new Set();
73
+ static DISPATCHED_KEYS_MAX = 4096;
65
74
  constructor(id) {
66
75
  this.id = id;
67
76
  }
77
+ rememberDispatched(key) {
78
+ this.dispatchedKeys.add(key);
79
+ if (this.dispatchedKeys.size > SlackInstance.DISPATCHED_KEYS_MAX) {
80
+ const oldest = this.dispatchedKeys.values().next().value;
81
+ if (oldest !== undefined)
82
+ this.dispatchedKeys.delete(oldest);
83
+ }
84
+ }
68
85
  setContext(ctx) {
69
86
  this.ctx = ctx;
70
87
  }
@@ -140,6 +157,16 @@ export class SlackInstance {
140
157
  this.setupSocketHandlers();
141
158
  await this.socketClient.start();
142
159
  this.ctx?.log.info(`Slack[${this.id}] connected as @${botName} (${botUserId}) via socket mode`);
160
+ if (config.socketReconcileEnabled !== false) {
161
+ // Socket Mode has no replay: envelopes dropped during disconnects,
162
+ // ping-timeout flaps, or process restarts are lost forever. Run the
163
+ // polling sweep alongside as a reconciler — the (channel, ts) dedup
164
+ // in dispatchInboundMessage suppresses the overlap, so agents see
165
+ // each message exactly once: live via socket, or ≤1 poll cycle
166
+ // later if the socket dropped it.
167
+ await this.startPollingMode();
168
+ this.ctx?.log.info(`Slack[${this.id}] reconciler armed: polling sweep running alongside Socket Mode`);
169
+ }
143
170
  }
144
171
  else {
145
172
  await this.startPollingMode();
@@ -174,7 +201,7 @@ export class SlackInstance {
174
201
  this.pollingClient = new SlackPollingClient({
175
202
  webClient: asPollingWebClient(this.webClient),
176
203
  watermarkStore,
177
- dispatch: (event) => this.dispatchInboundMessage(event),
204
+ dispatch: (event) => this.dispatchInboundMessage(event, 'poll'),
178
205
  intervalSec: config.pollingIntervalSec ?? 45,
179
206
  backfillMessageCap: config.pollingBackfillMessageCap ?? 100,
180
207
  backfillSeconds: config.pollingBackfillSeconds ?? 24 * 60 * 60,
@@ -193,6 +220,20 @@ export class SlackInstance {
193
220
  payload: { instanceId: this.id, reason },
194
221
  });
195
222
  });
223
+ // Seed thread tracking from recent SQLite history so threads that were
224
+ // active before this process started are still swept — the in-memory
225
+ // registry dies with the process, and tsx-watch restarts are frequent.
226
+ // New registrations re-read from the thread parent and the (channel, ts)
227
+ // dedup drops what was already delivered, so this also retro-recovers
228
+ // replies that were lost while no reconciler was watching.
229
+ const seedSinceMs = Date.now() - 6 * 60 * 60 * 1000;
230
+ const seeds = this.ctx?.eventDb.recentSlackThreadActivity?.(this.id, seedSinceMs) ?? [];
231
+ for (const seed of seeds) {
232
+ this.pollingClient.noteThreadActivity(seed.channelId, seed.threadTs, seed.lastActivityMs);
233
+ }
234
+ if (seeds.length > 0) {
235
+ this.ctx?.log.info(`Slack[${this.id}] thread registry seeded with ${seeds.length} recent thread(s)`);
236
+ }
196
237
  await this.pollingClient.start();
197
238
  }
198
239
  /**
@@ -257,13 +298,36 @@ export class SlackInstance {
257
298
  * subtypes / self / empty messages, logs to SQLite, broadcasts WS, fires
258
299
  * trigger listeners, and resolves reply waiters.
259
300
  */
260
- async dispatchInboundMessage(event) {
301
+ async dispatchInboundMessage(event, source = 'socket') {
261
302
  if (event.subtype && SKIP_MESSAGE_SUBTYPES.has(event.subtype))
262
303
  return;
263
304
  const hasFiles = Array.isArray(event.files) && event.files.length > 0;
264
305
  const text = event.text ?? '';
265
306
  if (!text && !hasFiles)
266
307
  return;
308
+ // Hybrid dedup: with the reconciler running alongside Socket Mode, every
309
+ // message can reach this dispatcher twice (live socket now, polling sweep
310
+ // ≤1 cycle later). Key on (channel, ts) — unique per Slack message. Both
311
+ // checks are synchronous (Set + better-sqlite3), so marking BEFORE the
312
+ // first await closes the in-process socket/poller race; the SQLite check
313
+ // covers messages dispatched before a restart wiped the in-memory Set.
314
+ if (event.ts) {
315
+ const dedupKey = `${event.channel}:${event.ts}`;
316
+ if (this.dispatchedKeys.has(dedupKey))
317
+ return;
318
+ if (this.ctx?.eventDb.hasSlackMessage?.(this.id, event.channel, event.ts)) {
319
+ this.rememberDispatched(dedupKey);
320
+ return;
321
+ }
322
+ this.rememberDispatched(dedupKey);
323
+ }
324
+ // Feed the reconciler's thread registry: any message carrying a thread_ts
325
+ // marks that thread active so the per-thread sweep keeps watching it —
326
+ // replies on parents behind the channel watermark never resurface in
327
+ // conversations.history, so this is the only way polling can see them.
328
+ if (event.thread_ts && event.thread_ts !== event.ts) {
329
+ this.pollingClient?.noteThreadActivity(event.channel, event.thread_ts);
330
+ }
267
331
  const config = loadConfig(this.id);
268
332
  const isOwnMessage = !!event.user && event.user === config.botUserId;
269
333
  // Skip own messages unless this instance is configured to mirror them.
@@ -322,7 +386,7 @@ export class SlackInstance {
322
386
  // direction, file count, and resolved-flag for name lookups (Y/N), no
323
387
  // names/emails/text.
324
388
  const resolvedFlag = `userResolved=${userName && userName !== userId ? 'Y' : 'N'} channelResolved=${channelName ? 'Y' : 'N'}`;
325
- this.ctx?.log.info(`Slack[${this.id}] dispatch: channel=${event.channel} user=${userId || '-'} ts=${event.ts} direction=${direction} files=${files?.length ?? 0}${event.thread_ts && event.thread_ts !== event.ts ? ` thread=${event.thread_ts}` : ''} ${resolvedFlag}`);
389
+ this.ctx?.log.info(`Slack[${this.id}] dispatch: channel=${event.channel} user=${userId || '-'} ts=${event.ts} direction=${direction} via=${source} files=${files?.length ?? 0}${event.thread_ts && event.thread_ts !== event.ts ? ` thread=${event.thread_ts}` : ''} ${resolvedFlag}`);
326
390
  this.ctx?.eventDb.logSlackMessage({
327
391
  ts: event.ts,
328
392
  threadTs: event.thread_ts,
@@ -30,6 +30,10 @@
30
30
  */
31
31
  import { createLogger } from '../../utils/logger.js';
32
32
  const log = createLogger('SlackPolling');
33
+ /** Tracked threads expire after this much inactivity. */
34
+ const THREAD_TTL_MS = 6 * 60 * 60 * 1000;
35
+ /** Per-channel cap on tracked threads — oldest activity evicted first. */
36
+ const MAX_TRACKED_THREADS_PER_CHANNEL = 10;
33
37
  const DEFAULT_SCHEDULER = {
34
38
  setTimeout: (cb, ms) => setTimeout(cb, ms),
35
39
  clearTimeout: (h) => clearTimeout(h),
@@ -95,6 +99,16 @@ export class SlackPollingClient {
95
99
  channelBackoffUntil = new Map();
96
100
  /** ChannelId set known from the most recent list refresh. */
97
101
  knownChannels = new Set();
102
+ /**
103
+ * Per-thread watermarks ("option 2" of the parent heuristic): channelId →
104
+ * threadTs → tracking state. `lastTs` is sweep-owned — it only advances as
105
+ * sweepThreads() reads replies in order, so a mid-thread reply the live
106
+ * socket dropped is still fetched on the next sweep (the dispatcher's
107
+ * (channel, ts) dedup drops the ones already delivered). New registrations
108
+ * start at lastTs = threadTs, i.e. the first sweep re-reads the whole
109
+ * thread and recovers any hole.
110
+ */
111
+ threadRegistry = new Map();
98
112
  /**
99
113
  * Search-mode only: the newest message `ts` observed in the previous search
100
114
  * cycle (dispatched or not). Lets the next cycle stop paging as soon as it
@@ -370,6 +384,119 @@ export class SlackPollingClient {
370
384
  *
371
385
  * Returns the number of messages dispatched.
372
386
  */
387
+ /**
388
+ * Register/refresh a tracked thread. Fed by three sources: the parent
389
+ * heuristic in pollChannel, every dispatched message carrying a thread_ts
390
+ * (both transports — the instance dispatcher calls this), and the SQLite
391
+ * seed on startup so tracking survives process restarts.
392
+ *
393
+ * Deliberately does NOT advance `lastTs` for known threads — that stays
394
+ * sweep-owned so a mid-thread reply the socket dropped can't be skipped
395
+ * because a newer reply arrived live first. `activityMs` defaults to now;
396
+ * the startup seed passes the stored time so stale threads still expire.
397
+ */
398
+ noteThreadActivity(channelId, threadTs, activityMs) {
399
+ let threads = this.threadRegistry.get(channelId);
400
+ if (!threads) {
401
+ threads = new Map();
402
+ this.threadRegistry.set(channelId, threads);
403
+ }
404
+ const existing = threads.get(threadTs);
405
+ if (existing) {
406
+ existing.lastActivityMs = Math.max(existing.lastActivityMs, activityMs ?? this.now());
407
+ return;
408
+ }
409
+ if (threads.size >= MAX_TRACKED_THREADS_PER_CHANNEL) {
410
+ let oldestKey = null;
411
+ let oldestMs = Infinity;
412
+ for (const [key, state] of threads) {
413
+ if (state.lastActivityMs < oldestMs) {
414
+ oldestMs = state.lastActivityMs;
415
+ oldestKey = key;
416
+ }
417
+ }
418
+ if (oldestKey)
419
+ threads.delete(oldestKey);
420
+ }
421
+ threads.set(threadTs, { lastTs: threadTs, lastActivityMs: activityMs ?? this.now() });
422
+ }
423
+ /**
424
+ * Per-thread watermark sweep: fetch new replies for every tracked thread in
425
+ * this channel, independently of whether the parent resurfaces in
426
+ * conversations.history (it never does once the channel watermark passes
427
+ * it — the exact gap that made "thread replies on old parents" invisible
428
+ * to polling). Downstream (channel, ts) dedup makes overlap with the live
429
+ * socket harmless.
430
+ */
431
+ async sweepThreads(channelId) {
432
+ const threads = this.threadRegistry.get(channelId);
433
+ if (!threads || threads.size === 0)
434
+ return 0;
435
+ let dispatched = 0;
436
+ const nowMs = this.now();
437
+ for (const [threadTs, state] of threads) {
438
+ if (!this.running)
439
+ break;
440
+ if (nowMs - state.lastActivityMs > THREAD_TTL_MS) {
441
+ threads.delete(threadTs);
442
+ continue;
443
+ }
444
+ try {
445
+ const res = await this.paceCall(() => this.webClient.conversations.replies({
446
+ channel: channelId,
447
+ ts: threadTs,
448
+ oldest: state.lastTs,
449
+ limit: 100,
450
+ }));
451
+ const replies = (res.messages ?? []).filter((r) => !!r && typeof r.ts === 'string' && r.ts !== threadTs && tsGt(r.ts, state.lastTs));
452
+ if (replies.length === 0)
453
+ continue;
454
+ const ordered = [...replies].sort((a, b) => tsCmp(a.ts, b.ts));
455
+ for (const reply of ordered) {
456
+ const replyEvent = {
457
+ ts: reply.ts,
458
+ thread_ts: reply.thread_ts ?? threadTs,
459
+ channel: channelId,
460
+ user: reply.user,
461
+ text: reply.text,
462
+ subtype: reply.subtype,
463
+ files: Array.isArray(reply.files) ? reply.files : undefined,
464
+ };
465
+ try {
466
+ await this.dispatch(replyEvent);
467
+ dispatched += 1;
468
+ }
469
+ catch (err) {
470
+ log.error(`dispatch error (thread sweep) channel=${channelId} ts=${reply.ts}: ${describeErr(err)}`);
471
+ }
472
+ if (tsGt(reply.ts, state.lastTs)) {
473
+ state.lastTs = reply.ts;
474
+ state.lastActivityMs = nowMs;
475
+ }
476
+ }
477
+ }
478
+ catch (err) {
479
+ if (isInvalidAuthError(err))
480
+ throw err;
481
+ if (isRateLimitedError(err)) {
482
+ const seconds = readRetryAfter(err) ?? 60;
483
+ const until = this.now() + seconds * 1000;
484
+ if (until > this.globalPauseUntil)
485
+ this.globalPauseUntil = until;
486
+ log.warn(`429 on thread sweep channel=${channelId} thread=${threadTs}, deferred ${seconds}s`);
487
+ break; // Remaining threads would hit the same pause — next cycle retries.
488
+ }
489
+ const msg = describeErr(err);
490
+ if (msg.includes('thread_not_found') || msg.includes('message_not_found')) {
491
+ threads.delete(threadTs); // Parent deleted — stop tracking.
492
+ }
493
+ else {
494
+ log.warn(`thread sweep failed channel=${channelId} thread=${threadTs}: ${msg}`);
495
+ }
496
+ }
497
+ }
498
+ return dispatched;
499
+ }
373
500
  async pollChannel(channelId) {
374
501
  const wm = this.watermarkStore.get(channelId);
375
502
  const isFirstSight = !wm;
@@ -388,8 +515,15 @@ export class SlackPollingClient {
388
515
  limit,
389
516
  }));
390
517
  const messages = (res.messages ?? []).filter((m) => !!m && typeof m.ts === 'string');
391
- if (messages.length === 0)
392
- return 0;
518
+ if (messages.length === 0) {
519
+ // No new channel-level messages — but replies on tracked threads never
520
+ // resurface in history, so the thread sweep must still run.
521
+ const swept = await this.sweepThreads(channelId);
522
+ if (this.channelBackoffUntil.has(channelId)) {
523
+ this.channelBackoffUntil.delete(channelId);
524
+ }
525
+ return swept;
526
+ }
393
527
  // Slack returns newest-first; dispatch in chronological order so trigger
394
528
  // listeners and dedupe see them in real-world order. BigInt sort avoids
395
529
  // the precision loss `parseFloat` had on Slack's 16-sig-fig timestamps.
@@ -420,74 +554,21 @@ export class SlackPollingClient {
420
554
  if (tsGt(msg.ts, highestTs)) {
421
555
  highestTs = msg.ts;
422
556
  }
423
- // ─── Cheap thread-reply heuristic ───
424
- // If this message is a thread parent (reply_count > 0) and the latest
425
- // reply is newer than what we've already seen for this channel, fetch
426
- // the new replies. This catches replies in threads whose parent is in
427
- // the current cycle's history window which is the majority case for
428
- // active conversations. Older threads with new replies are still
429
- // missed (their parent doesn't re-surface in history) — option 2 would
430
- // fix that with per-thread watermarks.
431
- const parentTs = msg.ts;
432
- const replyCount = msg.reply_count ?? 0;
433
- const latestReply = msg.latest_reply;
434
- const repliesAreNew = !!latestReply && tsGt(latestReply, wm?.lastTs ?? '0');
435
- if (replyCount > 0 && repliesAreNew) {
436
- const oldestForReplies = wm?.lastTs ?? parentTs;
437
- try {
438
- const repliesRes = await this.paceCall(() => this.webClient.conversations.replies({
439
- channel: channelId,
440
- ts: parentTs,
441
- oldest: oldestForReplies,
442
- limit: 100,
443
- }));
444
- const replies = (repliesRes.messages ?? []).filter((r) => !!r && typeof r.ts === 'string' && r.ts !== parentTs);
445
- // conversations.replies returns oldest-first, but be defensive.
446
- const orderedReplies = [...replies].sort((a, b) => tsCmp(a.ts, b.ts));
447
- for (const reply of orderedReplies) {
448
- // Defense: skip ts already at or below the watermark (handles overlap).
449
- if (wm && tsLte(reply.ts, wm.lastTs))
450
- continue;
451
- const replyEvent = {
452
- ts: reply.ts,
453
- thread_ts: reply.thread_ts ?? parentTs,
454
- channel: channelId,
455
- user: reply.user,
456
- text: reply.text,
457
- subtype: reply.subtype,
458
- files: Array.isArray(reply.files) ? reply.files : undefined,
459
- };
460
- try {
461
- await this.dispatch(replyEvent);
462
- dispatched += 1;
463
- }
464
- catch (err) {
465
- log.error(`dispatch error (reply) channel=${channelId} ts=${reply.ts}: ${describeErr(err)}`);
466
- }
467
- if (tsGt(reply.ts, highestTs)) {
468
- highestTs = reply.ts;
469
- }
470
- }
471
- }
472
- catch (err) {
473
- // 429 / invalid_auth on replies fetch are non-fatal for this cycle —
474
- // we already dispatched the parent. Bubble auth errors to the outer
475
- // caller so the loop halts; rate limits just skip this thread.
476
- if (isInvalidAuthError(err))
477
- throw err;
478
- if (isRateLimitedError(err)) {
479
- const seconds = readRetryAfter(err) ?? 60;
480
- const until = this.now() + seconds * 1000;
481
- if (until > this.globalPauseUntil)
482
- this.globalPauseUntil = until;
483
- log.warn(`429 on conversations.replies channel=${channelId} thread=${parentTs}, deferred ${seconds}s`);
484
- }
485
- else {
486
- log.warn(`replies fetch failed channel=${channelId} thread=${parentTs}: ${describeErr(err)}`);
487
- }
488
- }
557
+ // ─── Thread tracking ───
558
+ // Any parent with replies gets registered; the per-thread sweep at the
559
+ // end of this cycle (sweepThreads) is the single path that fetches and
560
+ // dispatches replies. Once the channel watermark passes the parent it
561
+ // never resurfaces in history — the registry is what keeps its future
562
+ // replies visible (the old inline "latest_reply" heuristic could not
563
+ // see replies on parents behind the watermark).
564
+ if ((msg.reply_count ?? 0) > 0) {
565
+ this.noteThreadActivity(channelId, msg.ts);
489
566
  }
490
567
  }
568
+ // Sweep tracked threads too. Thread-reply ts values deliberately do NOT
569
+ // advance the channel watermark here — that stays owned by the history
570
+ // fetch above; the sweep keeps its own per-thread watermarks.
571
+ dispatched += await this.sweepThreads(channelId);
491
572
  if (highestTs !== (wm?.lastTs ?? '0')) {
492
573
  await this.watermarkStore.set(channelId, highestTs);
493
574
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tide-commander",
3
- "version": "1.146.0",
3
+ "version": "1.147.1",
4
4
  "description": "Visual multi-agent orchestrator and manager for Claude Code with 3D/2D interface",
5
5
  "repository": {
6
6
  "type": "git",