cli-jaw 2.17.43 → 2.17.44

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 (223) hide show
  1. package/README.md +13 -2
  2. package/dist/bin/commands/slack.js +73 -7
  3. package/dist/bin/commands/slack.js.map +1 -1
  4. package/dist/server.js +46 -1
  5. package/dist/server.js.map +1 -1
  6. package/dist/src/agent/calendar-context.js +23 -0
  7. package/dist/src/agent/calendar-context.js.map +1 -0
  8. package/dist/src/agent/claude-runtime-run.js +10 -1
  9. package/dist/src/agent/claude-runtime-run.js.map +1 -1
  10. package/dist/src/agent/cursor-acp-models.js +139 -0
  11. package/dist/src/agent/cursor-acp-models.js.map +1 -0
  12. package/dist/src/agent/events/cursor.js +28 -4
  13. package/dist/src/agent/events/cursor.js.map +1 -1
  14. package/dist/src/agent/lifecycle-handler.js +8 -2
  15. package/dist/src/agent/lifecycle-handler.js.map +1 -1
  16. package/dist/src/agent/native-runtime-run.js +22 -1
  17. package/dist/src/agent/native-runtime-run.js.map +1 -1
  18. package/dist/src/agent/runtime/acp/config.js +76 -11
  19. package/dist/src/agent/runtime/acp/config.js.map +1 -1
  20. package/dist/src/agent/runtime/acp/cursor-session.js +6 -1
  21. package/dist/src/agent/runtime/acp/cursor-session.js.map +1 -1
  22. package/dist/src/agent/runtime/liveness.js +24 -0
  23. package/dist/src/agent/runtime/liveness.js.map +1 -0
  24. package/dist/src/agent/runtime/start-failure.js +61 -0
  25. package/dist/src/agent/runtime/start-failure.js.map +1 -0
  26. package/dist/src/agent/spawn/queue.js +11 -6
  27. package/dist/src/agent/spawn/queue.js.map +1 -1
  28. package/dist/src/agent/spawn.js +59 -17
  29. package/dist/src/agent/spawn.js.map +1 -1
  30. package/dist/src/cli/api-auth.js +6 -0
  31. package/dist/src/cli/api-auth.js.map +1 -1
  32. package/dist/src/manager/design/watcher.js +10 -2
  33. package/dist/src/manager/design/watcher.js.map +1 -1
  34. package/dist/src/memory/heartbeat.js +6 -0
  35. package/dist/src/memory/heartbeat.js.map +1 -1
  36. package/dist/src/messaging/channel-validate.js +5 -3
  37. package/dist/src/messaging/channel-validate.js.map +1 -1
  38. package/dist/src/messaging/redact.js +30 -8
  39. package/dist/src/messaging/redact.js.map +1 -1
  40. package/dist/src/messaging/send.js +7 -0
  41. package/dist/src/messaging/send.js.map +1 -1
  42. package/dist/src/orchestrator/collect.js +5 -3
  43. package/dist/src/orchestrator/collect.js.map +1 -1
  44. package/dist/src/orchestrator/gateway.js +10 -3
  45. package/dist/src/orchestrator/gateway.js.map +1 -1
  46. package/dist/src/orchestrator/pipeline.js +21 -9
  47. package/dist/src/orchestrator/pipeline.js.map +1 -1
  48. package/dist/src/orchestrator/request-registry.js +2 -0
  49. package/dist/src/orchestrator/request-registry.js.map +1 -1
  50. package/dist/src/prompt/builder.js +74 -6
  51. package/dist/src/prompt/builder.js.map +1 -1
  52. package/dist/src/prompt/templates/a1-system.md +22 -5
  53. package/dist/src/routes/messaging.js +105 -27
  54. package/dist/src/routes/messaging.js.map +1 -1
  55. package/dist/src/routes/settings.js +20 -8
  56. package/dist/src/routes/settings.js.map +1 -1
  57. package/dist/src/routes/slack-tools.js +176 -0
  58. package/dist/src/routes/slack-tools.js.map +1 -0
  59. package/dist/src/slack/action-rate.js +43 -0
  60. package/dist/src/slack/action-rate.js.map +1 -0
  61. package/dist/src/slack/action-runtime.js +243 -0
  62. package/dist/src/slack/action-runtime.js.map +1 -0
  63. package/dist/src/slack/action-store.js +84 -0
  64. package/dist/src/slack/action-store.js.map +1 -0
  65. package/dist/src/slack/action-types.js +6 -0
  66. package/dist/src/slack/action-types.js.map +1 -0
  67. package/dist/src/slack/actions-canvas.js +108 -0
  68. package/dist/src/slack/actions-canvas.js.map +1 -0
  69. package/dist/src/slack/actions-channel.js +88 -0
  70. package/dist/src/slack/actions-channel.js.map +1 -0
  71. package/dist/src/slack/actions-interactions.js +204 -0
  72. package/dist/src/slack/actions-interactions.js.map +1 -0
  73. package/dist/src/slack/actions-lists.js +308 -0
  74. package/dist/src/slack/actions-lists.js.map +1 -0
  75. package/dist/src/slack/actions-message.js +117 -0
  76. package/dist/src/slack/actions-message.js.map +1 -0
  77. package/dist/src/slack/actions-resources.js +62 -0
  78. package/dist/src/slack/actions-resources.js.map +1 -0
  79. package/dist/src/slack/actions-rts.js +88 -0
  80. package/dist/src/slack/actions-rts.js.map +1 -0
  81. package/dist/src/slack/actions-schedule.js +214 -0
  82. package/dist/src/slack/actions-schedule.js.map +1 -0
  83. package/dist/src/slack/actions.js +17 -0
  84. package/dist/src/slack/actions.js.map +1 -0
  85. package/dist/src/slack/api.js +56 -9
  86. package/dist/src/slack/api.js.map +1 -1
  87. package/dist/src/slack/blocks.js +162 -0
  88. package/dist/src/slack/blocks.js.map +1 -0
  89. package/dist/src/slack/bot.js +562 -378
  90. package/dist/src/slack/bot.js.map +1 -1
  91. package/dist/src/slack/commands.js +24 -5
  92. package/dist/src/slack/commands.js.map +1 -1
  93. package/dist/src/slack/context.js +15 -4
  94. package/dist/src/slack/context.js.map +1 -1
  95. package/dist/src/slack/conversation.js +135 -33
  96. package/dist/src/slack/conversation.js.map +1 -1
  97. package/dist/src/slack/events.js +41 -17
  98. package/dist/src/slack/events.js.map +1 -1
  99. package/dist/src/slack/format.js +181 -0
  100. package/dist/src/slack/format.js.map +1 -1
  101. package/dist/src/slack/history.js +133 -8
  102. package/dist/src/slack/history.js.map +1 -1
  103. package/dist/src/slack/ingress.js +26 -9
  104. package/dist/src/slack/ingress.js.map +1 -1
  105. package/dist/src/slack/interaction-store.js +56 -0
  106. package/dist/src/slack/interaction-store.js.map +1 -0
  107. package/dist/src/slack/manifest.js +3 -0
  108. package/dist/src/slack/manifest.js.map +1 -1
  109. package/dist/src/slack/message-content.js +132 -0
  110. package/dist/src/slack/message-content.js.map +1 -0
  111. package/dist/src/slack/message.js +58 -0
  112. package/dist/src/slack/message.js.map +1 -0
  113. package/dist/src/slack/notice-transport.js +49 -19
  114. package/dist/src/slack/notice-transport.js.map +1 -1
  115. package/dist/src/slack/operator-auth.js +62 -0
  116. package/dist/src/slack/operator-auth.js.map +1 -0
  117. package/dist/src/slack/progress-activity.js +210 -0
  118. package/dist/src/slack/progress-activity.js.map +1 -0
  119. package/dist/src/slack/progress-detail.js +473 -0
  120. package/dist/src/slack/progress-detail.js.map +1 -0
  121. package/dist/src/slack/progress-files.js +70 -0
  122. package/dist/src/slack/progress-files.js.map +1 -0
  123. package/dist/src/slack/progress-lifecycle.js +223 -0
  124. package/dist/src/slack/progress-lifecycle.js.map +1 -0
  125. package/dist/src/slack/progress-restore.js +81 -0
  126. package/dist/src/slack/progress-restore.js.map +1 -0
  127. package/dist/src/slack/progress.js +325 -143
  128. package/dist/src/slack/progress.js.map +1 -1
  129. package/dist/src/slack/quote.js +227 -0
  130. package/dist/src/slack/quote.js.map +1 -0
  131. package/dist/src/slack/render-features.js +105 -0
  132. package/dist/src/slack/render-features.js.map +1 -0
  133. package/dist/src/slack/reply-delivery.js +110 -0
  134. package/dist/src/slack/reply-delivery.js.map +1 -0
  135. package/dist/src/slack/rts-output-store.js +143 -0
  136. package/dist/src/slack/rts-output-store.js.map +1 -0
  137. package/dist/src/slack/scope-status.js +17 -3
  138. package/dist/src/slack/scope-status.js.map +1 -1
  139. package/dist/src/slack/search-quote.js +70 -0
  140. package/dist/src/slack/search-quote.js.map +1 -0
  141. package/dist/src/slack/search.js +54 -0
  142. package/dist/src/slack/search.js.map +1 -0
  143. package/dist/src/slack/send-handler.js +11 -7
  144. package/dist/src/slack/send-handler.js.map +1 -1
  145. package/dist/src/slack/send-only-client.js +116 -79
  146. package/dist/src/slack/send-only-client.js.map +1 -1
  147. package/dist/src/slack/table-content.js +253 -0
  148. package/dist/src/slack/table-content.js.map +1 -0
  149. package/dist/src/slack/table-verification.js +64 -0
  150. package/dist/src/slack/table-verification.js.map +1 -0
  151. package/dist/src/slack/task-input.js +55 -0
  152. package/dist/src/slack/task-input.js.map +1 -0
  153. package/dist/src/slack/tool-access.js +127 -0
  154. package/dist/src/slack/tool-access.js.map +1 -0
  155. package/dist/src/slack/tool-capabilities.js +47 -0
  156. package/dist/src/slack/tool-capabilities.js.map +1 -0
  157. package/dist/src/slack/tool-context.js +91 -0
  158. package/dist/src/slack/tool-context.js.map +1 -0
  159. package/dist/src/slack/verified-workspace.js +17 -6
  160. package/dist/src/slack/verified-workspace.js.map +1 -1
  161. package/dist/src/trace/redact.js +2 -1
  162. package/dist/src/trace/redact.js.map +1 -1
  163. package/package.json +1 -1
  164. package/public/dist/.vite/manifest.json +102 -102
  165. package/public/dist/assets/{Agent-CQ4EBgY8.js → Agent-CNad_A6t.js} +1 -1
  166. package/public/dist/assets/{ChannelEnablementControl-JsYvNMlM.js → ChannelEnablementControl-DwUDKjX9.js} +4 -4
  167. package/public/dist/assets/{ChannelSetupEntry-BpyRWEUE.js → ChannelSetupEntry-DH0AuNNX.js} +1 -1
  168. package/public/dist/assets/{ChannelsDiscord-DNfvz1jn.js → ChannelsDiscord-7NmoTjR7.js} +1 -1
  169. package/public/dist/assets/{ChannelsSlack-DzpxIdgW.js → ChannelsSlack-Ba0ax6iT.js} +1 -1
  170. package/public/dist/assets/{ChannelsTelegram-CUhFc5s6.js → ChannelsTelegram-DKA9atf2.js} +1 -1
  171. package/public/dist/assets/{app-Q0prKOFx.js → app-9OQNfmqw.js} +4 -4
  172. package/public/dist/assets/{bgtask-badge-DAcWaUfS.js → bgtask-badge-BvxHqMGi.js} +1 -1
  173. package/public/dist/assets/bgtask-badge-DeSZJGNN.js +1 -0
  174. package/public/dist/assets/{employees-CWS3Juca.js → employees-CkUqN4oh.js} +1 -1
  175. package/public/dist/assets/{iframe-renderer-DCDKBMb7.js → iframe-renderer-C2gchtOj.js} +2 -2
  176. package/public/dist/assets/iframe-renderer-DfvACXIn.js +1 -0
  177. package/public/dist/assets/{manager-v6GYfoPB.js → manager-rb5X-4DJ.js} +1 -1
  178. package/public/dist/assets/{memory-DhNdKZLO.js → memory-BiWzpoBq.js} +1 -1
  179. package/public/dist/assets/memory-Ckp1Eq5a.js +1 -0
  180. package/public/dist/assets/message-history-Bv0l_t7O.js +1 -0
  181. package/public/dist/assets/{message-history-DbRTl1P0.js → message-history-CuFefS02.js} +1 -1
  182. package/public/dist/assets/{render-DP8SJtdZ.js → render-DUGqq9Mu.js} +1 -1
  183. package/public/dist/assets/{settings-85n9NjvX.js → settings-BqUHavbH.js} +1 -1
  184. package/public/dist/assets/settings-C81Kf9uX.js +1 -0
  185. package/public/dist/assets/{settings-C9_D-pn0.js → settings-DQ1fLygB.js} +31 -30
  186. package/public/dist/assets/{settings-core-BcVD2FgV.js → settings-core-CzdXfoOK.js} +1 -1
  187. package/public/dist/assets/settings-core-DO_5M7Lv.js +1 -0
  188. package/public/dist/assets/{settings-embedding-BSpq785l.js → settings-embedding-BAKFrVLV.js} +1 -1
  189. package/public/dist/assets/{settings-types-B09BmpYe.js → settings-types-D3cd6SWy.js} +1 -1
  190. package/public/dist/assets/{sidebar-BVhLb5Ft.js → sidebar-CvGbp9x5.js} +3 -3
  191. package/public/dist/assets/skills-DFpPmMUR.js +1 -0
  192. package/public/dist/assets/{skills-X7qxmHka.js → skills-abLMqY5_.js} +1 -1
  193. package/public/dist/assets/slash-commands-BU58xYih.js +1 -0
  194. package/public/dist/assets/{slash-commands-BnDH4IU_.js → slash-commands-D8o9pqOS.js} +1 -1
  195. package/public/dist/assets/{trace-drawer-DasaS1z4.js → trace-drawer-C6RvkUAd.js} +1 -1
  196. package/public/dist/assets/{ui-BtiBTXng.js → ui-Bxapl_Eu.js} +1 -1
  197. package/public/dist/assets/ui-CUB4iwz3.js +1 -0
  198. package/public/dist/index.html +1 -1
  199. package/public/dist/manager/index.html +1 -1
  200. package/public/dist/settings/index.html +1 -1
  201. package/public/js/features/settings-cli-status.ts +11 -1
  202. package/public/js/features/settings-types.ts +21 -0
  203. package/public/locales/en.json +34 -1
  204. package/public/locales/ja.json +34 -1
  205. package/public/locales/ko.json +34 -1
  206. package/public/locales/zh.json +34 -1
  207. package/scripts/check-native-load.cjs +17 -4
  208. package/scripts/release-gates.mjs +84 -3
  209. package/scripts/service-artifact.mjs +157 -0
  210. package/scripts/service-shell/zsh/.zlogin +23 -0
  211. package/scripts/service-shell/zsh/.zlogout +23 -0
  212. package/scripts/service-shell/zsh/.zprofile +23 -0
  213. package/scripts/service-shell/zsh/.zshenv +23 -0
  214. package/scripts/service-shell/zsh/.zshrc +23 -0
  215. package/public/dist/assets/bgtask-badge-DHA2OQm8.js +0 -1
  216. package/public/dist/assets/iframe-renderer-RpvBly2x.js +0 -1
  217. package/public/dist/assets/memory-4YH8sz0L.js +0 -1
  218. package/public/dist/assets/message-history-lVcLpAw8.js +0 -1
  219. package/public/dist/assets/settings--EGy3_B9.js +0 -1
  220. package/public/dist/assets/settings-core-8wBl1mg-.js +0 -1
  221. package/public/dist/assets/skills-6GFkHTlQ.js +0 -1
  222. package/public/dist/assets/slash-commands-qu8fPAZ3.js +0 -1
  223. package/public/dist/assets/ui-BucJ1zMA.js +0 -1
@@ -1,3 +1,6 @@
1
+ import { verifiedSlackWorkspace } from './verified-workspace.js';
2
+ import { slackCredentialKey } from './tool-context.js';
3
+ import { isSlackMention } from './events.js';
1
4
  // ─── Slack Bot ───────────────────────────────────────
2
5
  // Slack transport implementation for the cli-jaw messaging runtime.
3
6
  // Mirrors src/discord/bot.ts structurally: init/shutdown lifecycle, an inbound
@@ -14,10 +17,13 @@ import { isResetIntent } from '../orchestrator/pipeline.js';
14
17
  import { isContinueIntent } from '../orchestrator/parser.js';
15
18
  import { setLastActiveTarget, setLatestSeenTarget, getLastActiveTarget, revokeMessagingTransport, startMessagingTransport, transportStarted, transportNotStarted, } from '../messaging/runtime.js';
16
19
  import { slackTargetFromId, resolveSlackThreadPlacement } from '../messaging/slack-target.js';
20
+ import { isRemoteTarget } from '../messaging/types.js';
21
+ import { sessionLanes } from '../orchestrator/session-lanes.js';
22
+ import { createSlackReplyDeliveryLedger } from './reply-delivery.js';
17
23
  import { buildMediaPromptMany } from '../agent/spawn.js';
18
- import { addSlackReaction, describeSlackError, removeSlackReaction, slackApi, SLACK_CLEANUP_TIMEOUT_MS, } from './api.js';
24
+ import { addSlackReaction, describeSlackError, removeSlackReaction, slackApi, } from './api.js';
19
25
  import { createAckHandle, resolveAckConfig, shouldAck, SLACK_ACK_DEFAULTS, } from '../messaging/ack-reaction.js';
20
- import { createQueueNotice, QueueNoticeRegistry, } from '../messaging/queue-notice.js';
26
+ import { QueueNoticeRegistry } from '../messaging/queue-notice.js';
21
27
  import { OutboundSendRegistry } from '../messaging/outbound-lifecycle.js';
22
28
  import { recordSlackScopeObservation, getSlackScopeStatus, describeSlackScopeGaps, resetSlackScopeStatus, } from './scope-status.js';
23
29
  import { HELLO_DEADLINE_MS, SlackSocketClient } from './socket.js';
@@ -26,16 +32,16 @@ import { runSlackAutoJoin, mergeSlackAutoJoin } from './auto-join.js';
26
32
  import { createHash } from 'node:crypto';
27
33
  import { admitIngress, getIngressJournal } from '../messaging/durable-ingress.js';
28
34
  import { getQueueNoticeStore } from '../messaging/queue-notice-store.js';
29
- import { restoreQueueNotices } from '../messaging/queue-notice-restore.js';
30
35
  import { createSlackNoticeTransport } from './notice-transport.js';
31
36
  import { currentGenerationForEnvelope } from '../messaging/ingress-generation.js';
32
37
  import { slackInboundEnvelope } from '../messaging/inbound-envelope.js';
33
38
  import { readSlackAllowlist, resolveEventText, shouldAttachSlack, shouldProcessSlackEvent } from './events.js';
34
39
  import { markThreadParticipated, threadParticipationKind, claimThreadPrefetch, commitThreadPrefetch, releaseThreadPrefetch, resetThreadPrefetchClaims, } from './thread-tracker.js';
35
40
  import { sendSlackText, getSlackSendClient } from './send-only-client.js';
36
- import { startSlackProgress, statusFromToolEvent } from './progress.js';
41
+ import { createSlackProgressLifecycle } from './progress-lifecycle.js';
42
+ import { createSlackProgressRestorer } from './progress-restore.js';
37
43
  import { createSlackForwarder, relaySlackImages } from './forwarder.js';
38
- import { nextDeliverySeq, pendingDeliveryAnchor, wasSelfDelivered } from '../messaging/turn-delivery.js';
44
+ import { nextDeliverySeq, wasSelfDelivered } from '../messaging/turn-delivery.js';
39
45
  import { shouldSkipForwarding } from '../messaging/forwarder-origin.js';
40
46
  import { handleSlackSlashCommand } from './commands.js';
41
47
  import { logErrorText, redactOutboundText } from '../messaging/redact.js';
@@ -72,7 +78,32 @@ const pendingQueueRequestIds = new Set();
72
78
  * turn that never answered gets its notice rewritten rather than left claiming
73
79
  * the agent is still working on it.
74
80
  */
75
- const slackNoticeRegistry = new QueueNoticeRegistry();
81
+ const slackProgressRegistry = new QueueNoticeRegistry();
82
+ const slackProgressSealers = new Set();
83
+ const liveSlackProgressRequestIds = new Set();
84
+ let slackStopping = false;
85
+ const SLACK_PROGRESS_DRAIN_MS = 1500;
86
+ const SLACK_QUEUE_WAIT_MS = 300000;
87
+ const SLACK_RUN_IDLE_MS = 1200000;
88
+ function registerSlackProgressTeardown(requestId, seal, drain) {
89
+ slackProgressSealers.add(seal);
90
+ liveSlackProgressRequestIds.add(requestId);
91
+ const unregister = slackProgressRegistry.add(drain);
92
+ return () => {
93
+ unregister();
94
+ slackProgressSealers.delete(seal);
95
+ liveSlackProgressRequestIds.delete(requestId);
96
+ };
97
+ }
98
+ const slackProgressRestorer = createSlackProgressRestorer({
99
+ getStore: getQueueNoticeStore,
100
+ getToken: () => getSlackSendClient().token,
101
+ getGeneration: () => lifecycleGeneration,
102
+ isLive: id => pendingQueueRequestIds.has(id) || liveSlackProgressRequestIds.has(id),
103
+ getLocale: currentLocale,
104
+ registerDrain: drain => slackProgressRegistry.add(drain),
105
+ onError: () => log.info('[slack:progress] restore incomplete'),
106
+ });
76
107
  /**
77
108
  * In-flight ANSWER sends (#417). The notice registry above cancels cleanup;
78
109
  * this one cancels the answer body, chunk-retry sleeps, and image uploads —
@@ -85,12 +116,6 @@ const slackOutboundRegistry = new OutboundSendRegistry();
85
116
  * the running apply can still be in flight ahead of both.
86
117
  */
87
118
  const SLACK_ACK_TIMEOUT_MS = 2500;
88
- /**
89
- * Long enough for the worst honest chain — running apply, terminal remove,
90
- * terminal apply, plus one notice call — so a slow-but-working Slack finishes
91
- * inside the deadline instead of being cut off by it.
92
- */
93
- const SLACK_NOTICE_DRAIN_MS = SLACK_CLEANUP_TIMEOUT_MS + SLACK_ACK_TIMEOUT_MS * 2;
94
119
  /** Live ack config, re-read per turn so a settings change needs no restart. */
95
120
  function slackAckConfig() {
96
121
  return resolveAckConfig(settings["slack"]?.ack, SLACK_ACK_DEFAULTS);
@@ -169,42 +194,28 @@ function closeSlackNoticeRecord(requestId) {
169
194
  * delete the notice message and drop the record. Best-effort like every other
170
195
  * durable-notice touch; the answer is already out.
171
196
  */
172
- async function closeSlackNoticeAsAnsweredByRequestId(requestId) {
197
+ async function closeSlackNoticeAsAnsweredByRequestId(requestId, token, signal, generation) {
198
+ const current = () => !signal.aborted && generation === lifecycleGeneration && !slackStopping;
173
199
  try {
200
+ if (!current())
201
+ return;
174
202
  const record = getQueueNoticeStore()?.findByRequestId(requestId);
175
203
  if (!record)
176
204
  return;
177
- const token = getSlackSendClient().token;
178
- if (record.messageId && token) {
179
- await createSlackNoticeTransport(token, record.target.targetId, record.messageId)
180
- .delete();
205
+ if (record.messageId) {
206
+ await createSlackNoticeTransport(token, record.target.targetId, record.messageId).delete(signal);
181
207
  }
182
- closeSlackNoticeRecord(requestId);
208
+ if (current())
209
+ closeSlackNoticeRecord(requestId);
183
210
  }
184
211
  catch (e) {
185
212
  log.info('[slack:queue-notice] answered-close failed', logErrorText(e));
186
213
  }
187
214
  }
188
- /**
189
- * Rewrite notices left behind by a previous run.
190
- *
191
- * Called once the token is known, because the transport needs it — a record whose
192
- * transport cannot be built yet is kept rather than closed (#418).
193
- */
215
+ /** Restore only recorded status messages; never infer a finished execution. */
194
216
  export async function restoreSlackQueueNotices() {
195
- const store = getQueueNoticeStore();
196
- if (!store)
197
- return;
198
- const token = getSlackSendClient().token;
199
- await restoreQueueNotices({
200
- store,
201
- channel: 'slack',
202
- expiredText: t('tg.queueExpired', {}, currentLocale()),
203
- transport: (record) => (token
204
- ? createSlackNoticeTransport(token, record.target.targetId, record.messageId)
205
- : null),
206
- onError: (e) => log.info('[slack:queue-notice] restore failed', logErrorText(e)),
207
- });
217
+ if (!slackStopping)
218
+ await slackProgressRestorer.restore();
208
219
  }
209
220
  /** Stand in for a live queued-reply listener without opening a socket. */
210
221
  export function claimSlackQueueRequestForTest(requestId) {
@@ -369,30 +380,344 @@ function requiresNativeBodyDelivery(data) {
369
380
  return (data['runtimeFinality'] === 'present' || data['runtimeFinality'] === 'absent')
370
381
  && (data['runtimeStatus'] === 'done' || data['runtimeStatus'] === 'error' || data['runtimeStatus'] === 'stopped');
371
382
  }
383
+ function bodyProgressOutcome(data, delivered, latched, stopping) {
384
+ const native = requiresNativeBodyDelivery(data);
385
+ if ((native && data['runtimeStatus'] === 'stopped') || (!native && data['executionInterrupted'] === true) || latched === 'cancelled')
386
+ return 'cancelled';
387
+ if (data['collectionFailure'] === 'timeout')
388
+ return 'expired';
389
+ if ((native && data['runtimeStatus'] === 'error') || (!native && data['executionFailed'] === true)
390
+ || data['collectionFailure'] === 'error' || (!native && data['error'] === true) || latched === 'error')
391
+ return 'error';
392
+ if (!delivered)
393
+ return stopping ? 'expired' : 'error';
394
+ return 'complete';
395
+ }
396
+ const slackReplyDelivery = createSlackReplyDeliveryLedger();
397
+ const activeSlackReplyTrackers = new Map();
398
+ let slackReplyAdmissionDepth = 0;
399
+ const pendingSteerContexts = new Map();
400
+ const SLACK_PENDING_STEER_MAX = 256;
401
+ const SLACK_PENDING_STEER_TTL_MS = 300_000;
402
+ function matchesSlackReply(options, data) {
403
+ const target = data['target'];
404
+ return data['requestId'] === options.requestId
405
+ && (data['scope'] === undefined || data['scope'] === options.session.scope)
406
+ && (data['sessionId'] === undefined || data['sessionId'] === options.session.chatSessionId)
407
+ && (data['origin'] === undefined || data['origin'] === 'slack')
408
+ && (data['remoteKey'] === undefined || data['remoteKey'] === options.session.remoteKey)
409
+ && (target === undefined || (isRemoteTarget(target) && target.channel === 'slack'
410
+ && target.targetId === options.target.targetId && target.threadId === options.target.threadId
411
+ && target.guildId === options.target.guildId && target.targetKind === options.target.targetKind
412
+ && target.peerKind === options.target.peerKind && target.parentTargetId === options.target.parentTargetId));
413
+ }
414
+ function rememberPendingSteer(options) {
415
+ if (options.signal.aborted || slackStopping || options.generation !== lifecycleGeneration)
416
+ return;
417
+ pendingSteerContexts.get(options.requestId)?.dispose();
418
+ while (pendingSteerContexts.size >= SLACK_PENDING_STEER_MAX)
419
+ pendingSteerContexts.values().next().value.dispose();
420
+ const dispose = () => {
421
+ clearTimeout(timer);
422
+ options.signal.removeEventListener('abort', dispose);
423
+ slackProgressSealers.delete(dispose);
424
+ if (pendingSteerContexts.get(options.requestId)?.dispose === dispose)
425
+ pendingSteerContexts.delete(options.requestId);
426
+ };
427
+ const timer = setTimeout(dispose, SLACK_PENDING_STEER_TTL_MS);
428
+ timer.unref?.();
429
+ pendingSteerContexts.set(options.requestId, { options, dispose });
430
+ options.signal.addEventListener('abort', dispose, { once: true });
431
+ slackProgressSealers.add(dispose);
432
+ }
433
+ function observeSlackReplyControl(type, raw) {
434
+ if (!['steer_started', 'queue_update', 'queued_run_started', 'request_settled'].includes(type))
435
+ return;
436
+ const requestId = raw['requestId'];
437
+ if (typeof requestId !== 'string' || !requestId || slackStopping)
438
+ return;
439
+ // Copy control identity only; an inline producer may precede admission return.
440
+ const data = {};
441
+ for (const key of ['requestId', 'origin', 'scope', 'sessionId', 'remoteKey', 'mode', 'outcome'])
442
+ data[key] = raw[key];
443
+ if (raw['target'] !== undefined)
444
+ data['target'] = isRemoteTarget(raw['target']) ? { ...raw['target'] } : null;
445
+ const actualStart = type === 'queued_run_started' || (type === 'steer_started' && data['mode'] === 'restart');
446
+ const anchor = actualStart ? nextDeliverySeq() : undefined;
447
+ const generation = lifecycleGeneration;
448
+ const active = activeSlackReplyTrackers.get(requestId);
449
+ if (active && matchesSlackReply(active.options, data) && anchor !== undefined)
450
+ active.start(anchor);
451
+ // Boot/orphan queue starts have no admission callback to wait for. Keep their
452
+ // proof synchronous so an immediate completion cannot claim before the start.
453
+ if (!active && slackReplyAdmissionDepth === 0 && type === 'queued_run_started'
454
+ && data['origin'] === 'slack' && isRemoteTarget(data['target']) && data['target'].channel === 'slack'
455
+ && typeof data['scope'] === 'string') {
456
+ const pending = pendingSteerContexts.get(requestId);
457
+ if (!pending || matchesSlackReply(pending.options, data))
458
+ slackReplyDelivery.started(requestId, data['target'], data['scope'], anchor);
459
+ }
460
+ queueMicrotask(() => {
461
+ if (slackStopping || generation !== lifecycleGeneration)
462
+ return;
463
+ const pending = pendingSteerContexts.get(requestId);
464
+ if (!pending) {
465
+ const tracker = activeSlackReplyTrackers.get(requestId);
466
+ if (tracker) {
467
+ if (anchor !== undefined && matchesSlackReply(tracker.options, data))
468
+ tracker.start(anchor);
469
+ }
470
+ else if (type === 'queued_run_started' && data['origin'] === 'slack'
471
+ && isRemoteTarget(data['target']) && data['target'].channel === 'slack' && typeof data['scope'] === 'string') {
472
+ slackReplyDelivery.started(requestId, data['target'], data['scope'], anchor);
473
+ }
474
+ return;
475
+ }
476
+ if (!matchesSlackReply(pending.options, data))
477
+ return;
478
+ const { options } = pending;
479
+ if (options.signal.aborted || options.generation !== lifecycleGeneration) {
480
+ pending.dispose();
481
+ return;
482
+ }
483
+ if (type === 'request_settled') {
484
+ if (['steered', 'failed', 'cancelled', 'dropped'].includes(String(data['outcome']))) {
485
+ pending.dispose();
486
+ if (data['outcome'] !== 'steered')
487
+ void options.ack?.settle('failure');
488
+ }
489
+ return;
490
+ }
491
+ if (type === 'steer_started' && ['native-input', 'cancel-reprompt'].includes(String(data['mode']))) {
492
+ pending.dispose();
493
+ return;
494
+ }
495
+ if (!actualStart && type !== 'queue_update')
496
+ return;
497
+ pending.dispose();
498
+ if (anchor !== undefined)
499
+ slackReplyDelivery.started(requestId, options.target, options.session.scope, anchor);
500
+ trackSlackReply({ ...options, initialPhase: actualStart ? 'running' : 'queued' });
501
+ });
502
+ }
503
+ function trackSlackReply(options) {
504
+ const { token, target, requestId, session, locale, generation, signal, ack, recipientUserId, initialPhase } = options;
505
+ if (signal.aborted || slackStopping || generation !== lifecycleGeneration || activeSlackReplyTrackers.has(requestId))
506
+ return;
507
+ // A synchronous terminal may have reached the orphan owner before admission
508
+ // returned. Its attempted body is not proof this late observer may ACK success.
509
+ if (slackReplyDelivery.claimed(requestId, target)) {
510
+ void ack?.settle('failure');
511
+ return;
512
+ }
513
+ slackReplyDelivery.remember(requestId, target, session.scope);
514
+ let started = initialPhase === 'running';
515
+ let observedAnchor = slackReplyDelivery.anchor(requestId, target);
516
+ let disposed = false;
517
+ let shutdownSealed = false;
518
+ let executionOutcome;
519
+ let timer;
520
+ let terminal = null;
521
+ let ackSettled = false;
522
+ let display;
523
+ const current = () => !shutdownSealed && !slackStopping && !signal.aborted && generation === lifecycleGeneration;
524
+ const matches = (data) => matchesSlackReply(options, data);
525
+ async function settleAck(outcome) {
526
+ if (ackSettled)
527
+ return;
528
+ ackSettled = true;
529
+ await ack?.settle(outcome);
530
+ }
531
+ function disposeListener() {
532
+ if (disposed)
533
+ return;
534
+ disposed = true;
535
+ if (timer)
536
+ clearTimeout(timer);
537
+ removeBroadcastListener(queueHandler);
538
+ activeSlackReplyTrackers.delete(requestId);
539
+ }
540
+ function claimTerminal(run, ownsBody = false) {
541
+ if (!terminal) {
542
+ disposeListener();
543
+ // Expired tracking must not swallow a later orphan reply. A live
544
+ // body claim instead stays present until its send/relay settles.
545
+ if (!ownsBody)
546
+ pendingQueueRequestIds.delete(requestId);
547
+ terminal = run()
548
+ .catch(error => log.info('[slack:queue]', logErrorText(error)))
549
+ .finally(() => {
550
+ signal.removeEventListener('abort', abortInput);
551
+ pendingQueueRequestIds.delete(requestId);
552
+ });
553
+ }
554
+ return terminal;
555
+ }
556
+ function endTracking(outcome, reason) {
557
+ return claimTerminal(async () => {
558
+ await Promise.allSettled([
559
+ display.finish(outcome, reason ? { reason } : {}), settleAck('failure'),
560
+ ]);
561
+ });
562
+ }
563
+ function resetTimer() {
564
+ if (disposed)
565
+ return;
566
+ if (timer)
567
+ clearTimeout(timer);
568
+ timer = setTimeout(() => { void endTracking('expired'); }, started ? SLACK_RUN_IDLE_MS : SLACK_QUEUE_WAIT_MS);
569
+ timer.unref?.();
570
+ }
571
+ function abortInput() {
572
+ shutdownSealed = true;
573
+ display.seal();
574
+ void display.finish(executionOutcome ?? 'expired');
575
+ void endTracking(executionOutcome ?? 'expired');
576
+ }
577
+ function queueHandler(type, data) {
578
+ if (disposed || !matches(data))
579
+ return;
580
+ if (type === 'queued_run_started')
581
+ return; // Global receipt owner captures the anchor synchronously.
582
+ if (type === 'request_settled') {
583
+ if (started)
584
+ return; // The display owner latches outcomes; preserve salvage delivery.
585
+ if (data['outcome'] === 'failed')
586
+ void endTracking('error');
587
+ if (data['outcome'] === 'cancelled')
588
+ void endTracking('cancelled');
589
+ if (data['outcome'] === 'dropped')
590
+ void endTracking('cancelled', 'removed');
591
+ if (data['outcome'] === 'merged')
592
+ void endTracking('cancelled', 'merged');
593
+ return;
594
+ }
595
+ if (type !== 'orchestrate_done' || data['origin'] !== 'slack')
596
+ return;
597
+ void claimTerminal(() => {
598
+ let bodyActionStarted = false;
599
+ return slackReplyDelivery.deliver(requestId, target, anchor => {
600
+ bodyActionStarted = true;
601
+ display.phase('delivering');
602
+ return sessionLanes.runDetachedTurn(session.scope, async () => {
603
+ let delivered = false;
604
+ let confirmedDelivery = false;
605
+ try {
606
+ if (!current()) {
607
+ await Promise.allSettled([display.finish(executionOutcome ?? 'expired'), settleAck('failure')]);
608
+ return;
609
+ }
610
+ const requireBodyDelivery = requiresNativeBodyDelivery(data);
611
+ const rawText = String(data['text'] ?? '');
612
+ const text = data['error'] === true && !requireBodyDelivery ? t('slack.progress.failure', {}, locale)
613
+ : requireBodyDelivery && !rawText.trim() ? '' : rawText;
614
+ display.phase('delivering');
615
+ const outbound = slackOutboundRegistry.start(signal);
616
+ try {
617
+ const alreadyDelivered = text && (anchor ?? observedAnchor) !== undefined && wasSelfDelivered({ target, text, since: (anchor ?? observedAnchor) });
618
+ const sent = text ? (alreadyDelivered ? { ok: true } : await sendSlackText(token, target, text, {
619
+ signal: outbound.signal, ...(requireBodyDelivery ? { requireBodyDelivery: true } : {}),
620
+ onPosted: async () => {
621
+ // Same early-settle rule as the direct path: every chunk is
622
+ // posted, so the ACK must not wait on readback verification.
623
+ delivered = true;
624
+ const early = bodyProgressOutcome(data, true, executionOutcome, !current());
625
+ await settleAck(early === 'complete' ? 'success' : 'failure');
626
+ },
627
+ })) : { ok: false };
628
+ delivered = sent.ok;
629
+ confirmedDelivery = Boolean(alreadyDelivered) || (sent.ok && Boolean(sent.ts));
630
+ }
631
+ finally {
632
+ outbound.done();
633
+ }
634
+ const outcome = bodyProgressOutcome(data, delivered, executionOutcome, !current());
635
+ if (delivered && target.threadId)
636
+ markThreadParticipated(target.targetId, target.threadId);
637
+ await settleAck(delivered && outcome === 'complete' ? 'success' : 'failure');
638
+ await display.finish(outcome, { bodyDelivered: confirmedDelivery });
639
+ if (text && current()) {
640
+ const relay = slackOutboundRegistry.start(signal);
641
+ try {
642
+ await relaySlackImages(token, target, text, { signal: relay.signal });
643
+ }
644
+ catch (error) {
645
+ log.error('[slack:queue-relay]', logErrorText(error));
646
+ }
647
+ finally {
648
+ relay.done();
649
+ }
650
+ }
651
+ }
652
+ catch (error) {
653
+ log.error('[slack:queue-send]', logErrorText(error));
654
+ await Promise.allSettled([
655
+ settleAck('failure'),
656
+ display.finish(bodyProgressOutcome(data, delivered, executionOutcome, !current()), { bodyDelivered: confirmedDelivery }),
657
+ ]);
658
+ }
659
+ });
660
+ }).finally(async () => {
661
+ if (!bodyActionStarted) {
662
+ await Promise.allSettled([display.finish(executionOutcome ?? 'expired'), settleAck('failure')]);
663
+ }
664
+ });
665
+ }, true);
666
+ }
667
+ reserveSlackNoticeRecord(requestId, target);
668
+ display = createSlackProgressLifecycle({
669
+ token, target, requestId, scope: session.scope, sessionId: session.chatSessionId, locale,
670
+ ...(options.workingDir ? { workingDir: options.workingDir } : {}),
671
+ ...(recipientUserId ? { recipientUserId } : {}),
672
+ registerTeardown: registerSlackProgressTeardown,
673
+ onPosted: ts => attachSlackNoticeRecord(requestId, ts),
674
+ onTerminalConfirmed: () => closeSlackNoticeRecord(requestId),
675
+ onExecutionOutcome: outcome => { executionOutcome = outcome; },
676
+ onActivity: () => { if (started)
677
+ resetTimer(); },
678
+ onSeal: () => { shutdownSealed = true; void settleAck('failure'); void endTracking(executionOutcome ?? 'expired'); },
679
+ });
680
+ function observeStart(anchor) {
681
+ if (disposed || !current())
682
+ return;
683
+ observedAnchor = slackReplyDelivery.started(requestId, target, session.scope, anchor);
684
+ if (!started) {
685
+ started = true;
686
+ display.phase('running');
687
+ resetTimer();
688
+ }
689
+ }
690
+ activeSlackReplyTrackers.set(requestId, { options, start: observeStart });
691
+ addBroadcastListener(queueHandler);
692
+ pendingQueueRequestIds.add(requestId);
693
+ signal.addEventListener('abort', abortInput, { once: true });
694
+ resetTimer();
695
+ display.start({ initialPhase });
696
+ void ack?.to('running', { wasQueued: initialPhase === 'queued' });
697
+ }
372
698
  function installSlackTargetReplyForwarder() {
373
699
  if (targetReplyForwarderInstalled)
374
700
  return;
375
701
  targetReplyForwarderInstalled = true;
376
702
  addBroadcastListener((type, data) => {
377
- if (type !== 'orchestrate_done' || data["origin"] !== 'slack' || !data["text"])
703
+ observeSlackReplyControl(type, data);
704
+ if (slackStopping || type !== 'orchestrate_done' || data["origin"] !== 'slack')
378
705
  return;
379
- // Queued and steered turns only. An ordinary turn is posted by the
380
- // dispatch path that is still standing there awaiting it; answering that
381
- // here too would post every reply twice.
706
+ // Only captured queued/restart delivery authority can use this fallback.
707
+ // Ordinary direct turns retain their awaiting dispatch owner.
382
708
  //
383
709
  // Errors included: after a restart there is no waiter to show them, so
384
710
  // dropping them here means the user's message vanishes without even a
385
711
  // failure notice.
386
- //
387
712
  // A steered turn is the same shape of orphan as a queued one: ingress
388
- // returns early unless the disposition is 'new_run', so a mid-run steer
389
- // leaves NO waiter for the follow-up answer. Without this the thread went
390
- // silent after every steer while the answer completed server-side.
391
- if (data["fromQueue"] !== true && data["fromSteer"] !== true)
713
+ // returns early unless the disposition is new_run, so a mid-run steer leaves
714
+ // NO waiter for the follow-up answer (#655). Accept all three identities.
715
+ if (data["fromQueue"] !== true && data["fromSteer"] !== true && data["replyViaTarget"] !== true)
392
716
  return;
393
- const target = data["target"];
394
- if (!target || target.channel !== 'slack' || !target.targetId)
717
+ const rawTarget = data['target'];
718
+ if (!isRemoteTarget(rawTarget) || rawTarget.channel !== 'slack')
395
719
  return;
720
+ const target = { ...rawTarget };
396
721
  // A live requester is already listening for this exact result; posting
397
722
  // here too would double-post it.
398
723
  if (data["requestId"] && hasPendingQueueWaiter(String(data["requestId"])))
@@ -400,15 +725,25 @@ function installSlackTargetReplyForwarder() {
400
725
  const token = getSlackSendClient().token;
401
726
  if (!token)
402
727
  return;
403
- const text = String(data["text"]);
728
+ const generation = lifecycleGeneration;
404
729
  const requireBodyDelivery = requiresNativeBodyDelivery(data);
405
- if (requireBodyDelivery && !text.trim())
406
- return;
407
- void (async () => {
730
+ const text = data['error'] === true && !requireBodyDelivery
731
+ ? t('slack.progress.failure', {}, currentLocale()) : String(data['text'] ?? '');
732
+ const requestId = typeof data['requestId'] === 'string' ? data['requestId'] : '';
733
+ const scope = slackReplyDelivery.scope(requestId, target)
734
+ ?? (typeof data['scope'] === 'string' ? data['scope'] : 'default');
735
+ void slackReplyDelivery.deliver(requestId, target, anchor => sessionLanes.runDetachedTurn(scope, async () => {
736
+ if (generation !== lifecycleGeneration || slackStopping)
737
+ return;
738
+ if (!text || (requireBodyDelivery && !text.trim()))
739
+ return;
408
740
  const outbound = slackOutboundRegistry.start();
409
741
  try {
410
- const result = await sendSlackText(token, target, text, { signal: outbound.signal,
742
+ const alreadyDelivered = anchor !== undefined && wasSelfDelivered({ target, text, since: anchor });
743
+ const result = alreadyDelivered ? { ok: true } : await sendSlackText(token, target, text, { signal: outbound.signal,
411
744
  ...(requireBodyDelivery ? { requireBodyDelivery: true } : {}) });
745
+ if (outbound.signal.aborted || generation !== lifecycleGeneration || slackStopping)
746
+ return;
412
747
  if (!result.ok) {
413
748
  log.error('[slack:target-reply]', logErrorText(result.error || 'send failed'));
414
749
  return;
@@ -419,7 +754,7 @@ function installSlackTargetReplyForwarder() {
419
754
  // is now noise. The live waiter usually owns this; when the
420
755
  // forwarder delivers instead, it must also keep the promise.
421
756
  if (data["requestId"]) {
422
- await closeSlackNoticeAsAnsweredByRequestId(String(data["requestId"]));
757
+ await closeSlackNoticeAsAnsweredByRequestId(String(data["requestId"]), token, outbound.signal, generation);
423
758
  }
424
759
  await relaySlackImages(token, target, text, { signal: outbound.signal });
425
760
  }
@@ -429,7 +764,7 @@ function installSlackTargetReplyForwarder() {
429
764
  finally {
430
765
  outbound.done();
431
766
  }
432
- })();
767
+ })).catch(error => log.error('[slack:target-reply]', logErrorText(error)));
433
768
  });
434
769
  }
435
770
  installSlackTargetReplyForwarder();
@@ -439,8 +774,10 @@ async function slackOrchestrate(target, prompt, displayMsg, signal, dedupe = {})
439
774
  if (!client.token)
440
775
  return;
441
776
  const token = client.token;
777
+ const workingDir = typeof settings['workingDir'] === 'string' ? settings['workingDir'] : undefined;
778
+ target = { ...target };
442
779
  const chatId = target.targetId;
443
- if (signal.aborted)
780
+ if (signal.aborted || slackStopping)
444
781
  return;
445
782
  // #321: the reservation was taken before an `await` and several early
446
783
  // returns. If a reset landed in that window this delivery belongs to a dead
@@ -460,317 +797,156 @@ async function slackOrchestrate(target, prompt, displayMsg, signal, dedupe = {})
460
797
  isMention: dedupe.isMention ?? false,
461
798
  })
462
799
  : null;
463
- const result = admitSlackRun({
464
- target, prompt, displayText: displayMsg, chatId,
465
- ...(dedupe.preResolvedScope !== undefined
466
- ? { preResolvedScope: dedupe.preResolvedScope } : {}),
467
- runReply: async (ctx) => {
468
- let ackOutcome = 'failure';
469
- let ackSettled = false;
470
- try {
471
- const progress = await startSlackProgress(token, target, t('slack.progress.start', {}, currentLocale())).catch(() => null);
472
- const progressHandler = (type, data) => {
473
- if (!progress || type !== 'agent_tool')
474
- return;
475
- // Only this run's events. The old test was `origin !== 'slack'`,
476
- // which every concurrent Slack run passes, so two channels each
477
- // received the other's tool log — and a tool log carries command
478
- // lines and paths, so that crossed a channel boundary people set
479
- // up precisely to separate access (#398).
480
- //
481
- // An event with no requestId cannot prove which run it belongs to.
482
- // Letting it through would restore the old behaviour, so it is
483
- // dropped; emitAgentTool stamps the field from its run context.
484
- if (data['requestId'] !== ctx.requestId)
485
- return;
486
- // Thinking/commentary entries are live-UI-only narration.
487
- // Telegram already drops its 💭 lines; Slack's progress
488
- // placeholder gets real tool activity only, so agent
489
- // reasoning text never lands in the channel even briefly.
490
- // 💬 is the untagged-codex assistant-message badge (spark
491
- // visibility) — also narration from the channel's view.
492
- if (data['toolType'] === 'thinking' || data['icon'] === '💬')
493
- return;
494
- const line = statusFromToolEvent(data, t('slack.progress.working', {}, currentLocale()));
495
- if (line)
496
- progress.update(line);
800
+ let result;
801
+ slackReplyAdmissionDepth++;
802
+ try {
803
+ result = admitSlackRun({
804
+ target, prompt, displayText: displayMsg, chatId,
805
+ ...(dedupe.toolSource ? { toolSource: dedupe.toolSource } : {}),
806
+ ...(dedupe.preResolvedScope !== undefined
807
+ ? { preResolvedScope: dedupe.preResolvedScope } : {}),
808
+ runReply: async (ctx) => {
809
+ const locale = currentLocale();
810
+ const generation = lifecycleGeneration;
811
+ let shutdownSealed = false;
812
+ let executionOutcome;
813
+ let ackPromise = null;
814
+ const settleAck = (outcome) => {
815
+ ackPromise ??= ack?.settle(outcome) ?? Promise.resolve();
816
+ return ackPromise;
497
817
  };
498
- if (progress)
499
- addBroadcastListener(progressHandler);
500
- // Anchors the delivery claim below. Taken BEFORE the agent runs,
501
- // so only a send made during THIS turn can suppress this turn's
502
- // post an identical answer from an earlier turn must not.
818
+ let bodyAttempted = false;
819
+ let bodySucceeded = false;
820
+ let bodyConfirmed = false;
821
+ let resultData = {};
822
+ const current = () => !shutdownSealed && !slackStopping && !signal.aborted && generation === lifecycleGeneration;
823
+ if (!current())
824
+ return;
825
+ if (ctx.requestId)
826
+ reserveSlackNoticeRecord(ctx.requestId, target);
827
+ const display = createSlackProgressLifecycle({
828
+ token, target, requestId: ctx.requestId, scope: ctx.scope, sessionId: ctx.chatSessionId, locale,
829
+ ...(workingDir ? { workingDir } : {}),
830
+ ...(dedupe.recipientUserId ? { recipientUserId: dedupe.recipientUserId } : {}),
831
+ registerTeardown: registerSlackProgressTeardown,
832
+ onPosted: ts => attachSlackNoticeRecord(ctx.requestId, ts),
833
+ onTerminalConfirmed: () => closeSlackNoticeRecord(ctx.requestId),
834
+ onExecutionOutcome: outcome => { executionOutcome = outcome; },
835
+ onSeal: () => { shutdownSealed = true; void settleAck('failure'); },
836
+ });
837
+ const abortInput = () => {
838
+ display.seal();
839
+ void display.finish(executionOutcome ?? 'expired');
840
+ };
841
+ signal.addEventListener('abort', abortInput, { once: true });
842
+ display.start({ initialPhase: 'running' });
843
+ void ack?.to('running');
503
844
  const turnStartedAt = nextDeliverySeq();
504
- const collected = await withSessionScope({ scope: ctx.scope, chatSessionId: ctx.chatSessionId }, () => orchestrateAndCollectData(prompt, {
505
- origin: 'slack', target, chatId, requestId: ctx.requestId,
506
- ...(ctx.remoteKey ? { remoteKey: ctx.remoteKey } : {}),
507
- chatSessionId: ctx.chatSessionId, scope: ctx.scope, _skipInsert: true,
508
- }).finally(async () => {
509
- if (!progress)
845
+ try {
846
+ const collected = await withSessionScope({ scope: ctx.scope, chatSessionId: ctx.chatSessionId }, () => orchestrateAndCollectData(prompt, {
847
+ origin: 'slack', target, chatId, requestId: ctx.requestId,
848
+ ...(dedupe.toolSource ? { _strictRequestOwnership: true } : {}),
849
+ ...(ctx.remoteKey ? { remoteKey: ctx.remoteKey } : {}),
850
+ chatSessionId: ctx.chatSessionId, scope: ctx.scope, _skipInsert: true,
851
+ }));
852
+ resultData = collected.data;
853
+ if (!current())
510
854
  return;
511
- removeBroadcastListener(progressHandler);
512
- await progress.finish().catch(() => { });
513
- }));
514
- const text = collected.text;
515
- // A turn retired by a steer has no answer of its own: the
516
- // follow-up run owns it and the standing forwarder delivers it.
517
- // Posting anything here — placeholder or empty body — is the
518
- // "응답 없음" the user saw immediately after steering.
519
- if (collected.data?.['superseded'] === true && !text.trim()) {
520
- log.info(`[slack:out:superseded] ${target.targetId}: retired by steer`);
521
- ackOutcome = 'success';
522
- return;
855
+ // A turn retired by a steer has no answer of its own: the follow-up
856
+ // run owns it and the reply tracker delivers it. Posting the no-response
857
+ // placeholder here is what the user saw right after steering (#655).
858
+ if (resultData['superseded'] === true && !String(collected.text ?? '').trim()) {
859
+ log.info(`[slack:out:superseded] ${target.targetId}: retired by steer`);
860
+ await settleAck('success');
861
+ return;
862
+ }
863
+ const text = collected.data.collectionFailure === 'error' ? t('slack.progress.failure', {}, locale)
864
+ : collected.data.collectionFailure === 'timeout' ? t('tg.timeout', {}, locale) : collected.text;
865
+ display.phase('delivering');
866
+ const outbound = slackOutboundRegistry.start(signal);
867
+ try {
868
+ bodyAttempted = true;
869
+ const alreadyDelivered = wasSelfDelivered({ target, text, since: turnStartedAt });
870
+ const sendResult = alreadyDelivered ? { ok: true } : await sendSlackText(token, target, text, {
871
+ signal: outbound.signal,
872
+ ...(requiresNativeBodyDelivery(collected.data) ? { requireBodyDelivery: true } : {}),
873
+ onPosted: async () => {
874
+ // Every chunk is posted, so the answer is visible. Settle
875
+ // the ACK here: readback verification below can take
876
+ // seconds per chunk and must not hold the reaction (#417).
877
+ bodySucceeded = true;
878
+ const early = bodyProgressOutcome(resultData, true, executionOutcome, !current());
879
+ await settleAck(early === 'complete' ? 'success' : 'failure');
880
+ },
881
+ });
882
+ bodySucceeded = sendResult.ok;
883
+ bodyConfirmed = alreadyDelivered || (sendResult.ok && Boolean(sendResult.ts));
884
+ const outcome = bodyProgressOutcome(resultData, bodySucceeded, executionOutcome, !current());
885
+ if (bodySucceeded && target.threadId)
886
+ markThreadParticipated(target.targetId, target.threadId);
887
+ await settleAck(bodySucceeded && outcome === 'complete' ? 'success' : 'failure');
888
+ await display.finish(outcome, { bodyDelivered: bodyConfirmed });
889
+ if (current())
890
+ await relaySlackImages(token, target, text, { signal: outbound.signal });
891
+ log.info(`[slack:out${alreadyDelivered ? ':skipped-self-delivered' : ''}] ${target.targetId}: ${redactOutboundText(text).slice(0, 80)}`);
892
+ }
893
+ finally {
894
+ outbound.done();
895
+ }
523
896
  }
524
- // Scoped for shutdown cancellation (#417): the body send and the
525
- // image relay below share one abortable scope, released when the
526
- // turn settles either way.
527
- const outbound = slackOutboundRegistry.start();
528
- let sendResult;
529
- try {
530
- // The agent may already have posted this exact answer itself
531
- // through /api/channel/send while the turn was still running,
532
- // in which case posting it again is the duplicate the user
533
- // sees. Skipping the POST is all this does: the outcome below
534
- // is still success, because the user has the answer — so the
535
- // ACK still settles (#417) and the notice still closes as
536
- // answered (#418), exactly as if we had sent it.
537
- const alreadyDelivered = wasSelfDelivered({ target, text, since: turnStartedAt });
538
- sendResult = alreadyDelivered
539
- ? { ok: true }
540
- : await sendSlackText(token, target, text, { signal: outbound.signal,
541
- ...(requiresNativeBodyDelivery(collected.data) ? { requireBodyDelivery: true } : {}) });
542
- // Recorded here, settled once in the finally below. The image
543
- // relay can still throw after the text is out, and the user did
544
- // get their answer in that case — so the outcome is success and
545
- // settling twice would be a lie about which one happened.
546
- ackOutcome = sendResult.ok ? 'success' : 'failure';
547
- // A successful reply records presence, not ownership (marking
548
- // point b). Replying into a thread we were invited to does not
549
- // make the rest of that conversation ours; the default `joined`
550
- // says so, and an already-`owned` thread keeps its kind (#400).
551
- if (sendResult.ok && target.threadId) {
552
- markThreadParticipated(target.targetId, target.threadId);
897
+ catch (err) {
898
+ log.error('[slack:error]', logErrorText(err));
899
+ if (current())
900
+ executionOutcome ??= 'error';
901
+ // Never append a second diagnostic after an ambiguous body attempt.
902
+ if (!bodyAttempted && current()) {
903
+ bodyAttempted = true;
904
+ const outbound = slackOutboundRegistry.start(signal);
905
+ try {
906
+ const diagnostic = await sendSlackText(token, target, t('slack.progress.failure', {}, locale), { signal: outbound.signal });
907
+ bodySucceeded = diagnostic.ok;
908
+ bodyConfirmed = diagnostic.ok && Boolean(diagnostic.ts);
909
+ }
910
+ catch (error) {
911
+ log.error('[slack:diagnostic]', logErrorText(error));
912
+ }
913
+ finally {
914
+ outbound.done();
915
+ }
553
916
  }
554
- // Settled BEFORE the relay: the upload is now abortable but
555
- // still slow, and the reaction must not sit on `running`
556
- // while the answer is already visible (#417).
557
- await ack?.settle(ackOutcome);
558
- ackSettled = true;
559
- // Relayed even when the text above was suppressed. Whether
560
- // the agent already uploaded these bytes cannot be proven
561
- // from a path (see turn-delivery.ts), and a skip that cannot
562
- // be proven is a silent drop.
563
- await relaySlackImages(token, target, text, { signal: outbound.signal });
564
- // Logged either way, and labelled, because the previous
565
- // silence here is what made the duplicate hard to see: only
566
- // the dispatch post wrote [slack:out], so the log showed one
567
- // delivery while the user had received two.
568
- log.info(`[slack:out${alreadyDelivered ? ':skipped-self-delivered' : ''}] ${target.targetId}: ${redactOutboundText(text).slice(0, 80)}`);
569
917
  }
570
918
  finally {
571
- outbound.done();
919
+ signal.removeEventListener('abort', abortInput);
920
+ await settleAck('failure');
921
+ await display.finish(bodyProgressOutcome(resultData, bodySucceeded, executionOutcome, !current()), bodyAttempted ? { bodyDelivered: bodyConfirmed } : {});
572
922
  }
573
- }
574
- catch (err) {
575
- log.error('[slack:error]', logErrorText(err));
576
- await sendSlackText(token, target, `❌ Error: ${err.message}`).catch(() => { });
577
- }
578
- finally {
579
- // Exactly one settle per turn, whichever way the body exited.
580
- // The happy path already settled before the image relay.
581
- if (!ackSettled)
582
- await ack?.settle(ackOutcome);
583
- }
584
- },
585
- });
923
+ },
924
+ });
925
+ }
926
+ finally {
927
+ slackReplyAdmissionDepth--;
928
+ }
586
929
  // Durable commit AFTER admission, with no await in between: an event that
587
930
  // died before this line stays redeliverable, which is the whole point of
588
931
  // ordering it here rather than at reservation time.
589
932
  if (dedupe.eventKey && result.action !== 'rejected')
590
933
  commitSlackEvent(dedupe.eventKey);
591
934
  result.laneTail?.catch(error => log.error('[slack:lane]', logErrorText(error)));
592
- if (result.action === 'queued') {
593
- log.info(`[slack:queue] agent busy, queued (${result.pending} pending)`);
935
+ if (result.action === 'queued' || (result.action === 'started' && result.disposition === 'steered')) {
594
936
  const requestId = result.requestId;
595
- // Anchored when this queued run STARTS, not when it was queued: the turn
596
- // ahead of it is still running and can record a claim after this point,
597
- // which would otherwise sort as "after this turn began" and swallow the
598
- // queued answer. Until it latches it reads as Infinity, so nothing is
599
- // suppressed. A restart loses claims entirely, which keeps the #407
600
- // orphan delivery intact.
601
- const queuedAnchor = pendingDeliveryAnchor();
602
- const queuedRunStarted = (type, data) => {
603
- // Keyed on THIS request. A scope-blind "an agent started" signal can
604
- // fire for the turn ahead of this one, latching the anchor while that
605
- // turn is still running — and a claim it records afterwards would then
606
- // sort as newer than the anchor and swallow this answer.
607
- if (type === 'queued_run_started' && data['requestId'] === requestId)
608
- queuedAnchor.latch();
609
- };
610
- addBroadcastListener(queuedRunStarted);
611
- let queueTimeout;
612
- let disposed = false;
613
- const notice = createQueueNotice({
614
- expiredText: t('tg.queueExpired', {}, currentLocale()),
615
- onError: (e) => log.info('[slack:queue-notice]', logErrorText(e)),
616
- });
617
- const disposeListener = () => {
618
- if (disposed)
619
- return;
620
- disposed = true;
621
- clearTimeout(queueTimeout);
622
- removeBroadcastListener(queueHandler);
623
- removeBroadcastListener(queuedRunStarted);
624
- if (requestId)
625
- pendingQueueRequestIds.delete(requestId);
626
- };
627
- // One terminal outcome per turn, shared by whoever gets there first.
628
- //
629
- // A boolean flag would pick a winner and let the loser return
630
- // immediately — and the loser here is the shutdown drain, which would
631
- // then tear the transport down while the winner is still sending. The
632
- // broadcast bus never awaits listener promises (core/bus.ts), so nothing
633
- // else would hold it. Handing back the winner's promise makes both paths
634
- // converge on the same completion.
635
- let terminal = null;
636
- // Assigned immediately below, but referenced by finishExpired, which is
637
- // defined first. Declared here so the terminal claim can unregister.
638
- let unregister = () => { };
639
- const claimTerminal = (run) => {
640
- if (!terminal) {
641
- terminal = run().catch(e => log.info('[slack:queue]', logErrorText(e)));
642
- }
643
- return terminal;
644
- };
645
- const finishExpired = (signal) => claimTerminal(async () => {
646
- disposeListener();
647
- try {
648
- // Started together, not in sequence: awaiting the notice first can
649
- // eat the whole drain deadline before the reaction is even attempted.
650
- await Promise.allSettled([
651
- notice.close('expired', signal),
652
- ack?.settle('failure') ?? Promise.resolve(),
653
- ]);
654
- // The in-process handle just closed this notice, so the durable
655
- // record has nothing left to restore. Dropping it here is what
656
- // keeps the next boot from rewriting a message this turn already
657
- // dealt with.
658
- if (requestId)
659
- closeSlackNoticeRecord(requestId);
660
- }
661
- finally {
662
- // Centralized here, as in the Discord path: every terminal route
663
- // passes through a claim, so unregistering in one place is what
664
- // makes "exactly once" true. The plain timeout used to leave its
665
- // registry entry behind, so a later shutdown re-ran a turn that
666
- // had already finished.
667
- unregister();
668
- }
669
- });
670
- unregister = slackNoticeRegistry.add((signal) => finishExpired(signal));
671
- const queueHandler = async (type, data) => {
672
- // No !data.text gate: an empty completion must still claim the
673
- // terminal, or the notice sits until the 5-minute timeout rewrites
674
- // it to "expired" — the reported "안 없어지는" symptom for empty or
675
- // error turns. Empty text closes the notice as expired immediately.
676
- if (type !== 'orchestrate_done' || data["origin"] !== 'slack'
677
- || data["requestId"] !== requestId)
678
- return;
679
- if (disposed)
680
- return;
681
- // Dispose FIRST so a duplicate broadcast cannot double-post, but the
682
- // notice deliberately outlives it: deleting before the answer is out
683
- // would leave a failed send with neither answer nor notice.
684
- disposeListener();
685
- await claimTerminal(async () => {
686
- try {
687
- const requireBodyDelivery = requiresNativeBodyDelivery(data);
688
- const rawText = String(data["text"] ?? '');
689
- const text = requireBodyDelivery && !rawText.trim() ? '' : rawText;
690
- const outbound = slackOutboundRegistry.start();
691
- let queuedSendResult;
692
- try {
693
- // Same rule as the normal dispatch: if the queued agent
694
- // already posted this answer itself, posting it again is
695
- // the duplicate. The notice still closes as answered
696
- // because the user does have the answer.
697
- const alreadyDelivered = text
698
- && wasSelfDelivered({ target, text, since: queuedAnchor.value() });
699
- queuedSendResult = text
700
- ? (alreadyDelivered
701
- ? { ok: true }
702
- : await sendSlackText(token, target, text, { signal: outbound.signal,
703
- ...(requireBodyDelivery ? { requireBodyDelivery: true } : {}) }))
704
- : { ok: false };
705
- }
706
- finally {
707
- // Released after the body; the relay below opens its own
708
- // scope so a slow upload does not hold this one.
709
- outbound.done();
710
- }
711
- await notice.close(queuedSendResult.ok ? 'answered' : 'expired');
712
- if (queuedSendResult.ok && target.threadId) {
713
- markThreadParticipated(target.targetId, target.threadId);
714
- }
715
- // Settled before the relay for the same reason as the normal
716
- // path: the text is what the user was waiting for, and an
717
- // uncancellable upload must not hold the reaction on running.
718
- await ack?.settle(queuedSendResult.ok ? 'success' : 'failure');
719
- if (text) {
720
- const relayScope = slackOutboundRegistry.start();
721
- await relaySlackImages(token, target, text, {
722
- signal: relayScope.signal
723
- })
724
- .catch(e => log.error('[slack:queue-send]', logErrorText(e)))
725
- .finally(() => relayScope.done());
726
- }
727
- // The notice is closed, so the durable record has nothing
728
- // left to restore on the next boot (#418).
729
- if (requestId)
730
- closeSlackNoticeRecord(requestId);
731
- }
732
- finally {
733
- // Same "exactly once" reasoning as finishExpired: whichever
734
- // path claims the terminal owns the registry cleanup.
735
- unregister();
736
- }
737
- });
738
- };
739
- // Everything below is armed SYNCHRONOUSLY, before any await. A reaction
740
- // call that takes a moment must not be able to outlive the completion it
741
- // is acknowledging: without the listener and the request-id claim in
742
- // place, a fast queued job would either be missed here or answered by the
743
- // standing fallback forwarder instead (#407).
744
- addBroadcastListener(queueHandler);
745
- if (requestId)
746
- pendingQueueRequestIds.add(requestId);
747
- queueTimeout = setTimeout(() => { void finishExpired(); }, 300000);
748
- // Only now, with the lifecycle armed. Not awaited: the notice below is
749
- // what the user needs to see, and the reaction is decoration on top.
750
- void ack?.to('running', { wasQueued: true });
751
- // Reserved BEFORE the post, so the crash window falls the harmless way: a
752
- // record without an id restores to nothing, while a posted message without
753
- // a record is unreachable forever (#418).
754
- if (requestId)
755
- reserveSlackNoticeRecord(requestId, target);
756
- const posted = await sendSlackText(token, target, t('tg.queued', { count: result.pending }, currentLocale()));
757
- if (posted.ok && posted.ts) {
758
- const ts = posted.ts;
759
- // The exported factory, not an inline object: the restart path builds
760
- // the same transport, and a second copy is how the two drift.
761
- notice.bind(createSlackNoticeTransport(token, target.targetId, ts));
762
- if (requestId)
763
- attachSlackNoticeRecord(requestId, ts);
764
- }
765
- else {
766
- // No ts will ever arrive, so a deferred close would wait for a bind
767
- // that cannot happen and the drain would burn its whole deadline.
768
- notice.abandon();
769
- // Nothing was posted, so the reservation describes a message that does
770
- // not exist. Leaving it would make every later boot look for it.
771
- if (requestId)
772
- closeSlackNoticeRecord(requestId);
937
+ if (!requestId) {
938
+ await ack?.settle('failure');
939
+ return;
773
940
  }
941
+ const options = {
942
+ token, target, requestId, session: { ...result.sessionContext }, locale: currentLocale(),
943
+ generation: lifecycleGeneration, signal, ack, ...(workingDir ? { workingDir } : {}), initialPhase: 'queued',
944
+ ...(dedupe.recipientUserId ? { recipientUserId: dedupe.recipientUserId } : {}),
945
+ };
946
+ if (result.action === 'queued')
947
+ trackSlackReply(options);
948
+ else
949
+ rememberPendingSteer(options);
774
950
  return;
775
951
  }
776
952
  if (result.action === 'rejected') {
@@ -862,7 +1038,15 @@ async function runSlackMessageEvent(event, target, text, signal, opts, commitPre
862
1038
  // the message, and the conversation is already obvious in Slack's own UI.
863
1039
  displayText = buildSenderDisplay(identity, displayText);
864
1040
  }
1041
+ const sourceToken = getSlackSendClient().token;
1042
+ const workspace = sourceToken && opts.socketTeamId ? await verifiedSlackWorkspace(sourceToken).catch(() => null) : null;
1043
+ if (signal.aborted)
1044
+ return;
1045
+ const toolSource = workspace && workspace.teamId === opts.socketTeamId && event.user && sourceToken
1046
+ ? { teamId: workspace.teamId, actorId: event.user, destination: target, credentialKey: slackCredentialKey(sourceToken),
1047
+ ...(typeof event.action_token === 'string' ? { actionToken: event.action_token } : {}) } : undefined;
865
1048
  await slackOrchestrate(target, prompt, displayText, signal, {
1049
+ ...(toolSource ? { toolSource } : {}),
866
1050
  ...(opts.eventKey ? { eventKey: opts.eventKey } : {}),
867
1051
  ...(opts.reservationGeneration !== undefined
868
1052
  ? { reservationGeneration: opts.reservationGeneration } : {}),
@@ -870,8 +1054,9 @@ async function runSlackMessageEvent(event, target, text, signal, opts, commitPre
870
1054
  // The ACK anchor is the user's own message. Only this caller has the raw
871
1055
  // event, so the scope inputs are resolved here rather than re-derived.
872
1056
  ...(event.ts ? { ackTs: event.ts } : {}),
1057
+ ...(event.user ? { recipientUserId: event.user } : {}),
873
1058
  isDirect: event.channel_type === 'im',
874
- isMention: event.type === 'app_mention',
1059
+ isMention: isSlackMention(event, selfUserId),
875
1060
  });
876
1061
  }
877
1062
  /**
@@ -909,7 +1094,7 @@ async function buildInboundContextBlock(event, identity, signal, opts = {}, comm
909
1094
  // A top-level channel message has no thread to read; the channel's own
910
1095
  // recent history (ending before this event) is its context (#518 r2).
911
1096
  const isTopLevelChannel = !threadTs
912
- && (event.channel_type === 'channel' || event.channel_type === 'group');
1097
+ && (event.channel_type === 'channel' || event.channel_type === 'group' || event.channel_type === 'mpim');
913
1098
  // Independent lookups: serial would double the round trips inside a
914
1099
  // deadline that exists to stay small.
915
1100
  const [conversation, thread, channelHistory] = await Promise.all([
@@ -942,7 +1127,7 @@ async function buildInboundContextBlock(event, identity, signal, opts = {}, comm
942
1127
  if (!prior.length)
943
1128
  return block;
944
1129
  const authorIds = prior.map(message => message.user || message.botId || '').filter(Boolean);
945
- const preamble = buildThreadPreamble(formatHistoryForAgent(prior, selfUserId, cachedNameMap(teamId, authorIds)), thread?.replyCount ?? prior.length);
1130
+ const preamble = buildThreadPreamble(formatHistoryForAgent(prior, selfUserId, cachedNameMap(teamId, authorIds)), thread?.replyCount ?? prior.length, thread);
946
1131
  if (!preamble)
947
1132
  return block;
948
1133
  // History is actually going into the prompt: the claim is spent.
@@ -1057,6 +1242,11 @@ export async function handleSlackEnvelope(envelope, approvalTransport = slackApp
1057
1242
  : '';
1058
1243
  const parsed = parseApprovalCallbackData(actionId);
1059
1244
  if (!parsed) {
1245
+ const token = getSlackSendClient().token;
1246
+ if (token) {
1247
+ const { consumeSlackInteractionCallback } = await import('./actions-interactions.js');
1248
+ await consumeSlackInteractionCallback(payload, token);
1249
+ }
1060
1250
  log.info('[slack:interactive] received (not an approval action)');
1061
1251
  return;
1062
1252
  }
@@ -1114,7 +1304,7 @@ export async function handleSlackEnvelope(envelope, approvalTransport = slackApp
1114
1304
  const prefetchOwner = preResolvedScope
1115
1305
  ? getSessionOwnershipGeneration(preResolvedScope)
1116
1306
  : undefined;
1117
- if (event.type === 'app_mention' && event.channel) {
1307
+ if (isSlackMention(event, selfUserId) && event.channel) {
1118
1308
  // A top-level mention starts a thread the bot will parent, so the whole
1119
1309
  // thread belongs to it. A mention INSIDE an existing thread is an
1120
1310
  // invitation into someone else's conversation, and only that (#400).
@@ -1137,7 +1327,7 @@ export async function handleSlackEnvelope(envelope, approvalTransport = slackApp
1137
1327
  // between this function's entry and this line, so the test-and-set is atomic
1138
1328
  // against a second envelope in the same tick.
1139
1329
  const prefetchSubject = event.thread_ts
1140
- ?? ((event.channel_type === 'channel' || event.channel_type === 'group') ? '' : undefined);
1330
+ ?? ((event.channel_type === 'channel' || event.channel_type === 'group' || event.channel_type === 'mpim') ? '' : undefined);
1141
1331
  const prefetchToken = prefetchSubject !== undefined && prefetchOwner
1142
1332
  ? claimThreadPrefetch(event.channel || '', prefetchSubject, prefetchOwner)
1143
1333
  : 0;
@@ -1150,7 +1340,10 @@ export async function handleSlackEnvelope(envelope, approvalTransport = slackApp
1150
1340
  // app_mention 봉투에는 files 가 없고, 첨부를 가진 message 사본은 위
1151
1341
  // shouldProcessSlackEvent 에서 mention_via_app_mention 으로 드롭된다.
1152
1342
  // 그래서 멘션과 함께 올린 파일은 여기서 되찾지 않으면 영영 사라진다.
1153
- if (!hasFiles && event.type === 'app_mention' && event.channel && event.ts) {
1343
+ // app_mention envelopes drop files, so they need the history recovery.
1344
+ // A message.mpim mention already carries its files inline; when it has
1345
+ // none there is no twin envelope to recover from, only a wasted call.
1346
+ if (!hasFiles && event.type === 'app_mention' && isSlackMention(event, selfUserId) && event.channel && event.ts) {
1154
1347
  const recoverToken = getSlackSendClient().token;
1155
1348
  if (recoverToken) {
1156
1349
  const recovered = await recoverSlackAttachments(recoverToken, event.channel, event.ts, event.thread_ts ? { threadTs: event.thread_ts } : {});
@@ -1176,6 +1369,7 @@ export async function handleSlackEnvelope(envelope, approvalTransport = slackApp
1176
1369
  return;
1177
1370
  }
1178
1371
  prefetchHandedOff = enqueueSlackIngress(slackIngressLaneKey(target), signal => processSlackMessageEvent(event, target, text, signal, {
1372
+ ...(typeof envelope.payload?.['team_id'] === 'string' ? { socketTeamId: envelope.payload['team_id'] } : {}),
1179
1373
  prefetchToken,
1180
1374
  ...(prefetchOwner ? { prefetchOwner } : {}),
1181
1375
  preResolvedScope,
@@ -1237,11 +1431,14 @@ async function runSlackInit(ctx) {
1237
1431
  // init it belongs to.
1238
1432
  const generation = ++lifecycleGeneration;
1239
1433
  await disposeSlackRuntime();
1434
+ if (generation !== lifecycleGeneration)
1435
+ return transportNotStarted('superseded');
1240
1436
  const sc = settings["slack"];
1241
1437
  if (!sc?.enabled || !sc?.botToken) {
1242
1438
  log.info('[slack] ⏭️ Slack pending (disabled or no bot token)');
1243
1439
  return transportNotStarted('not_configured');
1244
1440
  }
1441
+ slackStopping = false;
1245
1442
  if (!sc.appToken) {
1246
1443
  // Outbound still works via the send transport; only inbound needs
1247
1444
  // the app-level token. Say so precisely instead of "failed".
@@ -1515,6 +1712,13 @@ function startSlackAutoJoin(sc, generation) {
1515
1712
  * `shutdownSlack` invalidates in-flight initializations.
1516
1713
  */
1517
1714
  async function disposeSlackRuntime() {
1715
+ slackStopping = true;
1716
+ slackProgressRestorer.abort();
1717
+ for (const seal of [...slackProgressSealers])
1718
+ seal();
1719
+ // Begin both cancellation paths before ingress drain can consume the server deadline.
1720
+ const outboundDrain = slackOutboundRegistry.drain();
1721
+ const progressDrain = slackProgressRegistry.drain(SLACK_PROGRESS_DRAIN_MS);
1518
1722
  await resetSlackIngress();
1519
1723
  // Wakes the pacing sleeps immediately instead of letting a teardown wait
1520
1724
  // out a 3-second gap between conversations.list pages.
@@ -1537,27 +1741,7 @@ async function disposeSlackRuntime() {
1537
1741
  removeBroadcastListener(forwarderHandler);
1538
1742
  forwarderHandler = null;
1539
1743
  }
1540
- // Close out queued turns rather than leaving them armed for their 5-minute
1541
- // timeout against a transport that no longer exists. Awaited and bounded:
1542
- // dropping the callbacks (as this did) leaves the notice claiming the agent
1543
- // is still working, while an unbounded await would hold shutdown open on a
1544
- // stuck vendor call. The deadline covers the worst honest chain and the
1545
- // signal cancels whatever is left.
1546
- //
1547
- // The registry is the single shutdown route, as on Discord. A parallel
1548
- // waiter set used to hold one closure per queued turn and was cleared only
1549
- // here, so a long-lived instance retained every historical turn's token,
1550
- // target, notice and ACK closure. It also stripped the drain's abort signal:
1551
- // a QueueNotice pins the signal from its FIRST close, so a waiter closing
1552
- // without one made the later shutdown signal unreachable.
1553
- // Abort in-flight answer sends FIRST (#417 review): a queued waiter that
1554
- // already claimed its terminal is mid-send, and the notice drain below
1555
- // awaits that same promise — un-aborted, a hung vendor POST would eat the
1556
- // whole notice budget and blow past the server's 5s force-exit. Aborting
1557
- // first makes the hung send settle immediately (as slack_send_aborted,
1558
- // never a vendor failure), so the notice drain only pays for cleanup.
1559
- await slackOutboundRegistry.drain();
1560
- await slackNoticeRegistry.drain(SLACK_NOTICE_DRAIN_MS);
1744
+ await Promise.allSettled([outboundDrain, progressDrain]);
1561
1745
  pendingQueueRequestIds.clear();
1562
1746
  socketClient?.stop();
1563
1747
  socketClient = null;