@geminixiang/mama 0.2.0-beta.2 → 0.2.0-beta.20

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 (264) hide show
  1. package/README.md +156 -392
  2. package/dist/adapter.d.ts +31 -7
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js.map +1 -1
  5. package/dist/adapters/discord/bot.d.ts +10 -5
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +347 -115
  8. package/dist/adapters/discord/bot.js.map +1 -1
  9. package/dist/adapters/discord/context.d.ts +1 -1
  10. package/dist/adapters/discord/context.d.ts.map +1 -1
  11. package/dist/adapters/discord/context.js +118 -25
  12. package/dist/adapters/discord/context.js.map +1 -1
  13. package/dist/adapters/shared.d.ts +91 -0
  14. package/dist/adapters/shared.d.ts.map +1 -0
  15. package/dist/adapters/shared.js +191 -0
  16. package/dist/adapters/shared.js.map +1 -0
  17. package/dist/adapters/slack/bot.d.ts +21 -22
  18. package/dist/adapters/slack/bot.d.ts.map +1 -1
  19. package/dist/adapters/slack/bot.js +530 -221
  20. package/dist/adapters/slack/bot.js.map +1 -1
  21. package/dist/adapters/slack/branch-manager.d.ts +28 -0
  22. package/dist/adapters/slack/branch-manager.d.ts.map +1 -0
  23. package/dist/adapters/slack/branch-manager.js +107 -0
  24. package/dist/adapters/slack/branch-manager.js.map +1 -0
  25. package/dist/adapters/slack/context.d.ts +4 -1
  26. package/dist/adapters/slack/context.d.ts.map +1 -1
  27. package/dist/adapters/slack/context.js +193 -75
  28. package/dist/adapters/slack/context.js.map +1 -1
  29. package/dist/adapters/slack/session.d.ts +38 -0
  30. package/dist/adapters/slack/session.d.ts.map +1 -0
  31. package/dist/adapters/slack/session.js +66 -0
  32. package/dist/adapters/slack/session.js.map +1 -0
  33. package/dist/adapters/slack/tools/attach.d.ts +1 -1
  34. package/dist/adapters/slack/tools/attach.d.ts.map +1 -1
  35. package/dist/adapters/slack/tools/attach.js.map +1 -1
  36. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  37. package/dist/adapters/telegram/bot.js +140 -153
  38. package/dist/adapters/telegram/bot.js.map +1 -1
  39. package/dist/adapters/telegram/context.d.ts +1 -1
  40. package/dist/adapters/telegram/context.d.ts.map +1 -1
  41. package/dist/adapters/telegram/context.js +74 -20
  42. package/dist/adapters/telegram/context.js.map +1 -1
  43. package/dist/agent.d.ts +13 -3
  44. package/dist/agent.d.ts.map +1 -1
  45. package/dist/agent.js +677 -552
  46. package/dist/agent.js.map +1 -1
  47. package/dist/commands/auto-reply.d.ts +16 -0
  48. package/dist/commands/auto-reply.d.ts.map +1 -0
  49. package/dist/commands/auto-reply.js +72 -0
  50. package/dist/commands/auto-reply.js.map +1 -0
  51. package/dist/commands/index.d.ts +5 -0
  52. package/dist/commands/index.d.ts.map +1 -0
  53. package/dist/commands/index.js +18 -0
  54. package/dist/commands/index.js.map +1 -0
  55. package/dist/commands/login.d.ts +5 -0
  56. package/dist/commands/login.d.ts.map +1 -0
  57. package/dist/commands/login.js +91 -0
  58. package/dist/commands/login.js.map +1 -0
  59. package/dist/commands/model.d.ts +14 -0
  60. package/dist/commands/model.d.ts.map +1 -0
  61. package/dist/commands/model.js +112 -0
  62. package/dist/commands/model.js.map +1 -0
  63. package/dist/commands/new.d.ts +9 -0
  64. package/dist/commands/new.d.ts.map +1 -0
  65. package/dist/commands/new.js +28 -0
  66. package/dist/commands/new.js.map +1 -0
  67. package/dist/commands/registry.d.ts +4 -0
  68. package/dist/commands/registry.d.ts.map +1 -0
  69. package/dist/commands/registry.js +9 -0
  70. package/dist/commands/registry.js.map +1 -0
  71. package/dist/commands/sandbox.d.ts +10 -0
  72. package/dist/commands/sandbox.d.ts.map +1 -0
  73. package/dist/commands/sandbox.js +88 -0
  74. package/dist/commands/sandbox.js.map +1 -0
  75. package/dist/commands/session-view.d.ts +5 -0
  76. package/dist/commands/session-view.d.ts.map +1 -0
  77. package/dist/commands/session-view.js +62 -0
  78. package/dist/commands/session-view.js.map +1 -0
  79. package/dist/commands/types.d.ts +41 -0
  80. package/dist/commands/types.d.ts.map +1 -0
  81. package/dist/commands/types.js +2 -0
  82. package/dist/commands/types.js.map +1 -0
  83. package/dist/commands/utils.d.ts +8 -0
  84. package/dist/commands/utils.d.ts.map +1 -0
  85. package/dist/commands/utils.js +14 -0
  86. package/dist/commands/utils.js.map +1 -0
  87. package/dist/config.d.ts +45 -8
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +299 -67
  90. package/dist/config.js.map +1 -1
  91. package/dist/context.d.ts +10 -42
  92. package/dist/context.d.ts.map +1 -1
  93. package/dist/context.js +14 -127
  94. package/dist/context.js.map +1 -1
  95. package/dist/events.d.ts +2 -0
  96. package/dist/events.d.ts.map +1 -1
  97. package/dist/events.js +148 -67
  98. package/dist/events.js.map +1 -1
  99. package/dist/execution-resolver.d.ts +10 -6
  100. package/dist/execution-resolver.d.ts.map +1 -1
  101. package/dist/execution-resolver.js +121 -21
  102. package/dist/execution-resolver.js.map +1 -1
  103. package/dist/file-guards.d.ts +9 -0
  104. package/dist/file-guards.d.ts.map +1 -0
  105. package/dist/file-guards.js +56 -0
  106. package/dist/file-guards.js.map +1 -0
  107. package/dist/fs-atomic.d.ts +10 -0
  108. package/dist/fs-atomic.d.ts.map +1 -0
  109. package/dist/fs-atomic.js +45 -0
  110. package/dist/fs-atomic.js.map +1 -0
  111. package/dist/index.d.ts +7 -0
  112. package/dist/index.d.ts.map +1 -0
  113. package/dist/index.js +4 -0
  114. package/dist/index.js.map +1 -0
  115. package/dist/instrument.d.ts.map +1 -1
  116. package/dist/instrument.js +2 -3
  117. package/dist/instrument.js.map +1 -1
  118. package/dist/log.d.ts +1 -12
  119. package/dist/log.d.ts.map +1 -1
  120. package/dist/log.js +12 -143
  121. package/dist/log.js.map +1 -1
  122. package/dist/{login.d.ts → login/index.d.ts} +16 -3
  123. package/dist/login/index.d.ts.map +1 -0
  124. package/dist/{login.js → login/index.js} +94 -17
  125. package/dist/login/index.js.map +1 -0
  126. package/dist/{link-server.d.ts → login/portal.d.ts} +6 -4
  127. package/dist/login/portal.d.ts.map +1 -0
  128. package/dist/login/portal.js +1544 -0
  129. package/dist/login/portal.js.map +1 -0
  130. package/dist/login/session.d.ts +26 -0
  131. package/dist/login/session.d.ts.map +1 -0
  132. package/dist/{link-token.js → login/session.js} +10 -22
  133. package/dist/login/session.js.map +1 -0
  134. package/dist/main.d.ts.map +1 -1
  135. package/dist/main.js +138 -352
  136. package/dist/main.js.map +1 -1
  137. package/dist/provisioner.d.ts +42 -11
  138. package/dist/provisioner.d.ts.map +1 -1
  139. package/dist/provisioner.js +273 -64
  140. package/dist/provisioner.js.map +1 -1
  141. package/dist/runtime/conversation-orchestrator.d.ts +40 -0
  142. package/dist/runtime/conversation-orchestrator.d.ts.map +1 -0
  143. package/dist/runtime/conversation-orchestrator.js +183 -0
  144. package/dist/runtime/conversation-orchestrator.js.map +1 -0
  145. package/dist/runtime/index.d.ts +2 -0
  146. package/dist/runtime/index.d.ts.map +1 -0
  147. package/dist/runtime/index.js +2 -0
  148. package/dist/runtime/index.js.map +1 -0
  149. package/dist/runtime/session-runtime.d.ts +26 -0
  150. package/dist/runtime/session-runtime.d.ts.map +1 -0
  151. package/dist/runtime/session-runtime.js +221 -0
  152. package/dist/runtime/session-runtime.js.map +1 -0
  153. package/dist/sandbox/cloudflare.d.ts +15 -0
  154. package/dist/sandbox/cloudflare.d.ts.map +1 -0
  155. package/dist/sandbox/cloudflare.js +137 -0
  156. package/dist/sandbox/cloudflare.js.map +1 -0
  157. package/dist/sandbox/container.d.ts +2 -1
  158. package/dist/sandbox/container.d.ts.map +1 -1
  159. package/dist/sandbox/container.js +18 -2
  160. package/dist/sandbox/container.js.map +1 -1
  161. package/dist/sandbox/firecracker.d.ts +2 -1
  162. package/dist/sandbox/firecracker.d.ts.map +1 -1
  163. package/dist/sandbox/firecracker.js +6 -0
  164. package/dist/sandbox/firecracker.js.map +1 -1
  165. package/dist/sandbox/host.d.ts +2 -1
  166. package/dist/sandbox/host.d.ts.map +1 -1
  167. package/dist/sandbox/host.js +4 -0
  168. package/dist/sandbox/host.js.map +1 -1
  169. package/dist/sandbox/index.d.ts +6 -4
  170. package/dist/sandbox/index.d.ts.map +1 -1
  171. package/dist/sandbox/index.js +9 -6
  172. package/dist/sandbox/index.js.map +1 -1
  173. package/dist/sandbox/path-context.d.ts +4 -0
  174. package/dist/sandbox/path-context.d.ts.map +1 -0
  175. package/dist/sandbox/path-context.js +20 -0
  176. package/dist/sandbox/path-context.js.map +1 -0
  177. package/dist/sandbox/types.d.ts +17 -1
  178. package/dist/sandbox/types.d.ts.map +1 -1
  179. package/dist/sandbox/types.js.map +1 -1
  180. package/dist/sentry.d.ts +20 -1
  181. package/dist/sentry.d.ts.map +1 -1
  182. package/dist/sentry.js +58 -8
  183. package/dist/sentry.js.map +1 -1
  184. package/dist/session-policy.d.ts +13 -0
  185. package/dist/session-policy.d.ts.map +1 -0
  186. package/dist/session-policy.js +23 -0
  187. package/dist/session-policy.js.map +1 -0
  188. package/dist/session-store.d.ts +33 -2
  189. package/dist/session-store.d.ts.map +1 -1
  190. package/dist/session-store.js +179 -13
  191. package/dist/session-store.js.map +1 -1
  192. package/dist/session-view/command.d.ts +5 -0
  193. package/dist/session-view/command.d.ts.map +1 -0
  194. package/dist/session-view/command.js +11 -0
  195. package/dist/session-view/command.js.map +1 -0
  196. package/dist/session-view/portal.d.ts +16 -0
  197. package/dist/session-view/portal.d.ts.map +1 -0
  198. package/dist/session-view/portal.js +1822 -0
  199. package/dist/session-view/portal.js.map +1 -0
  200. package/dist/session-view/service.d.ts +34 -0
  201. package/dist/session-view/service.d.ts.map +1 -0
  202. package/dist/session-view/service.js +427 -0
  203. package/dist/session-view/service.js.map +1 -0
  204. package/dist/session-view/store.d.ts +18 -0
  205. package/dist/session-view/store.d.ts.map +1 -0
  206. package/dist/session-view/store.js +36 -0
  207. package/dist/session-view/store.js.map +1 -0
  208. package/dist/store.d.ts +3 -6
  209. package/dist/store.d.ts.map +1 -1
  210. package/dist/store.js +22 -48
  211. package/dist/store.js.map +1 -1
  212. package/dist/tool-diagnostics.d.ts +2 -0
  213. package/dist/tool-diagnostics.d.ts.map +1 -0
  214. package/dist/tool-diagnostics.js +7 -0
  215. package/dist/tool-diagnostics.js.map +1 -0
  216. package/dist/tools/bash.d.ts +2 -2
  217. package/dist/tools/bash.d.ts.map +1 -1
  218. package/dist/tools/bash.js.map +1 -1
  219. package/dist/tools/edit.d.ts +2 -2
  220. package/dist/tools/edit.d.ts.map +1 -1
  221. package/dist/tools/edit.js.map +1 -1
  222. package/dist/tools/event.d.ts +42 -2
  223. package/dist/tools/event.d.ts.map +1 -1
  224. package/dist/tools/event.js +43 -9
  225. package/dist/tools/event.js.map +1 -1
  226. package/dist/tools/index.d.ts +2 -2
  227. package/dist/tools/index.d.ts.map +1 -1
  228. package/dist/tools/index.js +2 -2
  229. package/dist/tools/index.js.map +1 -1
  230. package/dist/tools/read.d.ts +2 -2
  231. package/dist/tools/read.d.ts.map +1 -1
  232. package/dist/tools/read.js.map +1 -1
  233. package/dist/tools/write.d.ts +2 -2
  234. package/dist/tools/write.d.ts.map +1 -1
  235. package/dist/tools/write.js.map +1 -1
  236. package/dist/trigger.d.ts +31 -0
  237. package/dist/trigger.d.ts.map +1 -0
  238. package/dist/trigger.js +98 -0
  239. package/dist/trigger.js.map +1 -0
  240. package/dist/vault-routing.d.ts +2 -7
  241. package/dist/vault-routing.d.ts.map +1 -1
  242. package/dist/vault-routing.js +6 -42
  243. package/dist/vault-routing.js.map +1 -1
  244. package/dist/vault.d.ts +22 -56
  245. package/dist/vault.d.ts.map +1 -1
  246. package/dist/vault.js +155 -263
  247. package/dist/vault.js.map +1 -1
  248. package/package.json +11 -11
  249. package/dist/bindings.d.ts +0 -44
  250. package/dist/bindings.d.ts.map +0 -1
  251. package/dist/bindings.js +0 -74
  252. package/dist/bindings.js.map +0 -1
  253. package/dist/link-server.d.ts.map +0 -1
  254. package/dist/link-server.js +0 -899
  255. package/dist/link-server.js.map +0 -1
  256. package/dist/link-token.d.ts +0 -32
  257. package/dist/link-token.d.ts.map +0 -1
  258. package/dist/link-token.js.map +0 -1
  259. package/dist/login.d.ts.map +0 -1
  260. package/dist/login.js.map +0 -1
  261. package/dist/sandbox.d.ts +0 -2
  262. package/dist/sandbox.d.ts.map +0 -1
  263. package/dist/sandbox.js +0 -2
  264. package/dist/sandbox.js.map +0 -1
@@ -1,77 +1,57 @@
1
1
  import { SocketModeClient } from "@slack/socket-mode";
2
2
  import { WebClient } from "@slack/web-api";
3
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
3
+ import { existsSync, readFileSync } from "fs";
4
4
  import { readFile } from "fs/promises";
5
5
  import { basename, join } from "path";
6
6
  import * as log from "../../log.js";
7
7
  import { PRODUCT_NAME, formatForceStopped, formatNothingRunning } from "../../ui-copy.js";
8
+ import { appendBotResponseLog, appendChannelLog, ChannelQueue, resolveOnlyScopedStopTarget, resolveStopTarget, withRetry, } from "../shared.js";
9
+ import { evaluateAutoReplyPolicy } from "../../trigger.js";
8
10
  import { createSlackAdapters } from "./context.js";
9
- // ============================================================================
10
- // Exponential backoff utility for Slack API calls
11
- // ============================================================================
12
- /**
13
- * Retry a function with exponential backoff on rate limit errors.
14
- */
15
- async function withRetry(fn, maxRetries = 3, baseDelayMs = 1000) {
16
- let lastError;
17
- for (let attempt = 0; attempt < maxRetries; attempt++) {
18
- try {
19
- return await fn();
20
- }
21
- catch (err) {
22
- lastError = err instanceof Error ? err : new Error(String(err));
23
- // Check for rate limit errors
24
- let isRateLimited = false;
25
- // Check for rate_limited error code (Slack SDK)
26
- if ("code" in lastError && lastError.code === "rate_limited") {
27
- isRateLimited = true;
28
- }
29
- // Check for rate_limited in error response
30
- if ("data" in lastError) {
31
- const data = lastError
32
- .data;
33
- if (data?.error === "rate_limited" || data?.response?.status === 429) {
34
- isRateLimited = true;
35
- }
36
- }
37
- if (isRateLimited) {
38
- const delay = baseDelayMs * Math.pow(2, attempt);
39
- log.logWarning(`Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
40
- await new Promise((resolve) => setTimeout(resolve, delay));
41
- continue;
42
- }
43
- // Non-retryable error
44
- throw lastError;
45
- }
46
- }
47
- throw lastError;
11
+ import { hasMaterializedSlackBranchSession, registerSlackForkSession } from "./branch-manager.js";
12
+ import { isSlackForkSessionKey, planSlackAdapterSession, planSlackEventForkRun, resolveSlackSessionKey, } from "./session.js";
13
+ import { reportUserFacingError } from "../../sentry.js";
14
+ const SLACK_EVENT_ANCHOR_TEXT = "Working on it...";
15
+ // Slack WebClient errors carry either `code: "rate_limited"` (retry-after) or
16
+ // the legacy `data.error === "rate_limited"` / 429 status shape.
17
+ function slackIsRateLimited(err) {
18
+ if (err.code === "rate_limited")
19
+ return true;
20
+ const data = err.data;
21
+ return data?.error === "rate_limited" || data?.response?.status === 429;
48
22
  }
49
- class ChannelQueue {
50
- constructor() {
51
- this.queue = [];
52
- this.processing = false;
23
+ const slackRetry = (fn) => withRetry(fn, { isRateLimited: slackIsRateLimited });
24
+ function collectSlackText(value, parts) {
25
+ if (value === null || value === undefined)
26
+ return;
27
+ if (typeof value === "string") {
28
+ const trimmed = value.trim();
29
+ if (trimmed)
30
+ parts.push(trimmed);
31
+ return;
53
32
  }
54
- enqueue(work) {
55
- this.queue.push(work);
56
- this.processNext();
33
+ if (Array.isArray(value)) {
34
+ for (const item of value)
35
+ collectSlackText(item, parts);
36
+ return;
57
37
  }
58
- size() {
59
- return this.queue.length;
60
- }
61
- async processNext() {
62
- if (this.processing || this.queue.length === 0)
63
- return;
64
- this.processing = true;
65
- const work = this.queue.shift();
66
- try {
67
- await work();
68
- }
69
- catch (err) {
70
- log.logWarning("Queue error", err instanceof Error ? err.message : String(err));
71
- }
72
- this.processing = false;
73
- this.processNext();
38
+ if (typeof value !== "object")
39
+ return;
40
+ const obj = value;
41
+ for (const key of ["text", "fallback", "title", "value"]) {
42
+ collectSlackText(obj[key], parts);
74
43
  }
44
+ collectSlackText(obj.fields, parts);
45
+ collectSlackText(obj.elements, parts);
46
+ collectSlackText(obj.blocks, parts);
47
+ }
48
+ function buildSlackAppMessageText(event) {
49
+ const parts = [];
50
+ collectSlackText(event.text, parts);
51
+ collectSlackText(event.blocks, parts);
52
+ collectSlackText(event.attachments, parts);
53
+ const deduped = parts.filter((part, index) => parts.indexOf(part) === index);
54
+ return deduped.join("\n");
75
55
  }
76
56
  // ============================================================================
77
57
  // SlackBot
@@ -79,6 +59,8 @@ class ChannelQueue {
79
59
  export class SlackBot {
80
60
  constructor(handler, config) {
81
61
  this.botUserId = null;
62
+ this.botId = null;
63
+ this.ownMentionRegex = null;
82
64
  this.startupTs = null; // Messages older than this are just logged, not processed
83
65
  this.users = new Map();
84
66
  this.channels = new Map();
@@ -87,7 +69,12 @@ export class SlackBot {
87
69
  this.handler = handler;
88
70
  this.workingDir = config.workingDir;
89
71
  this.store = config.store;
90
- this.socketClient = new SocketModeClient({ appToken: config.appToken });
72
+ this.socketClient = new SocketModeClient({
73
+ appToken: config.appToken,
74
+ // Default 5s is too tight: brief event-loop stalls (e.g. backfill, sync fs)
75
+ // cause false pong timeouts; 4 in a row makes Slack drop the socket.
76
+ clientPingTimeout: 12_000,
77
+ });
91
78
  this.webClient = new WebClient(config.botToken);
92
79
  }
93
80
  setEventsWatcher(watcher) {
@@ -99,14 +86,19 @@ export class SlackBot {
99
86
  async start() {
100
87
  const auth = await this.webClient.auth.test();
101
88
  this.botUserId = auth.user_id;
89
+ this.botId = typeof auth.bot_id === "string" ? auth.bot_id : null;
102
90
  await Promise.all([this.fetchUsers(), this.fetchChannels()]);
103
91
  log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);
104
- await this.backfillAllChannels();
92
+ // Record startup time before opening the socket. Slack may replay older events;
93
+ // those should be logged but not processed. Backfill runs in the background up
94
+ // to this timestamp so startup is not blocked by one history call per channel.
95
+ this.startupTs = (Date.now() / 1000).toFixed(6);
105
96
  this.setupEventHandlers();
106
97
  await this.socketClient.start();
107
- // Record startup time - messages older than this are just logged, not processed
108
- this.startupTs = (Date.now() / 1000).toFixed(6);
109
- log.logConnected();
98
+ log.logConnected("Slack");
99
+ void this.backfillAllChannels(this.startupTs).catch((error) => {
100
+ log.logWarning("Slack backfill failed", String(error));
101
+ });
110
102
  }
111
103
  getUser(userId) {
112
104
  return this.users.get(userId);
@@ -120,19 +112,70 @@ export class SlackBot {
120
112
  getAllChannels() {
121
113
  return Array.from(this.channels.values());
122
114
  }
115
+ stripOwnMention(text) {
116
+ const source = text ?? "";
117
+ if (!this.botUserId)
118
+ return source.trim();
119
+ if (!this.ownMentionRegex || !this.ownMentionRegex.source.includes(this.botUserId)) {
120
+ this.ownMentionRegex = new RegExp(`<@${this.botUserId}>`, "gi");
121
+ }
122
+ return source.replace(this.ownMentionRegex, "").trim();
123
+ }
123
124
  async postMessage(channel, text) {
124
- return withRetry(async () => {
125
+ return slackRetry(async () => {
125
126
  const result = await this.webClient.chat.postMessage({ channel, text });
126
127
  return result.ts;
127
128
  });
128
129
  }
129
- async postEphemeral(channel, user, text) {
130
- return withRetry(async () => {
131
- await this.webClient.chat.postEphemeral({ channel, user, text });
130
+ async postEphemeral(channel, user, text, threadTs) {
131
+ return slackRetry(async () => {
132
+ await this.webClient.chat.postEphemeral({
133
+ channel,
134
+ user,
135
+ text,
136
+ ...(threadTs ? { thread_ts: threadTs } : {}),
137
+ });
138
+ });
139
+ }
140
+ async postEphemeralBlocks(channel, user, text, blocks, threadTs) {
141
+ return slackRetry(async () => {
142
+ await this.webClient.chat.postEphemeral({
143
+ channel,
144
+ user,
145
+ text,
146
+ blocks: blocks,
147
+ ...(threadTs ? { thread_ts: threadTs } : {}),
148
+ });
132
149
  });
133
150
  }
151
+ async postMessageBlocks(channel, text, blocks) {
152
+ return slackRetry(async () => {
153
+ const result = await this.webClient.chat.postMessage({
154
+ channel,
155
+ text,
156
+ blocks: blocks,
157
+ });
158
+ return result.ts;
159
+ });
160
+ }
161
+ async postPrivate(conversationId, userId, text) {
162
+ await this.postEphemeral(conversationId, userId, text);
163
+ }
164
+ async postPrivateDiagnostic(conversationId, userId, text, options) {
165
+ if (options?.style !== "muted") {
166
+ await this.postPrivate(conversationId, userId, options?.style === "error" ? `_${text}_` : text);
167
+ return;
168
+ }
169
+ const CONTEXT_TEXT_LIMIT = 3000;
170
+ const blockText = text.length > CONTEXT_TEXT_LIMIT
171
+ ? text.substring(0, CONTEXT_TEXT_LIMIT - 20) + "\n_(truncated)_"
172
+ : text;
173
+ await this.postEphemeralBlocks(conversationId, userId, text, [
174
+ { type: "context", elements: [{ type: "mrkdwn", text: blockText }] },
175
+ ]);
176
+ }
134
177
  async openDirectMessage(userId) {
135
- return withRetry(async () => {
178
+ return slackRetry(async () => {
136
179
  const result = await this.webClient.conversations.open({ users: userId });
137
180
  const channelId = result.channel?.id;
138
181
  if (!channelId) {
@@ -142,12 +185,12 @@ export class SlackBot {
142
185
  });
143
186
  }
144
187
  async updateMessage(channel, ts, text) {
145
- return withRetry(async () => {
188
+ return slackRetry(async () => {
146
189
  await this.webClient.chat.update({ channel, ts, text });
147
190
  });
148
191
  }
149
192
  async deleteMessage(channel, ts) {
150
- return withRetry(async () => {
193
+ return slackRetry(async () => {
151
194
  await this.webClient.chat.delete({ channel, ts });
152
195
  });
153
196
  }
@@ -156,7 +199,7 @@ export class SlackBot {
156
199
  // ==========================================================================
157
200
  /** Set the status for an assistant thread (shows "thinking" state) */
158
201
  async setAssistantStatus(channel, threadTs, status) {
159
- return withRetry(async () => {
202
+ return slackRetry(async () => {
160
203
  await this.webClient.assistant.threads.setStatus({
161
204
  channel_id: channel,
162
205
  thread_ts: threadTs,
@@ -165,7 +208,7 @@ export class SlackBot {
165
208
  });
166
209
  }
167
210
  async postInThread(channel, threadTs, text) {
168
- return withRetry(async () => {
211
+ return slackRetry(async () => {
169
212
  // Use Block Kit section for long messages to trigger Slack's "Show more" collapsing (~700 chars)
170
213
  const SECTION_TEXT_LIMIT = 3000;
171
214
  if (text.length > 500) {
@@ -185,7 +228,7 @@ export class SlackBot {
185
228
  });
186
229
  }
187
230
  async postInThreadBlocks(channel, threadTs, text, blocks) {
188
- return withRetry(async () => {
231
+ return slackRetry(async () => {
189
232
  const result = await this.webClient.chat.postMessage({
190
233
  channel,
191
234
  thread_ts: threadTs,
@@ -196,7 +239,7 @@ export class SlackBot {
196
239
  });
197
240
  }
198
241
  async uploadFile(channel, filePath, title, threadTs) {
199
- return withRetry(async () => {
242
+ return slackRetry(async () => {
200
243
  const fileName = title || basename(filePath);
201
244
  const fileContent = readFileSync(filePath);
202
245
  await this.webClient.files.uploadV2({
@@ -208,29 +251,11 @@ export class SlackBot {
208
251
  });
209
252
  });
210
253
  }
211
- /**
212
- * Log a message to log.jsonl (SYNC)
213
- * This is the ONLY place messages are written to log.jsonl
214
- */
215
254
  logToFile(channel, entry) {
216
- const dir = join(this.workingDir, channel);
217
- if (!existsSync(dir))
218
- mkdirSync(dir, { recursive: true });
219
- appendFileSync(join(dir, "log.jsonl"), `${JSON.stringify(entry)}\n`);
255
+ appendChannelLog(this.workingDir, channel, entry);
220
256
  }
221
- /**
222
- * Log a bot response to log.jsonl
223
- */
224
257
  logBotResponse(channel, text, ts, threadTs) {
225
- this.logToFile(channel, {
226
- date: new Date().toISOString(),
227
- ts,
228
- threadTs,
229
- user: "bot",
230
- text,
231
- attachments: [],
232
- isBot: true,
233
- });
258
+ appendBotResponseLog(this.workingDir, channel, text, ts, threadTs);
234
259
  }
235
260
  getPlatformInfo() {
236
261
  return {
@@ -242,6 +267,9 @@ export class SlackBot {
242
267
  userName: u.userName,
243
268
  displayName: u.displayName,
244
269
  })),
270
+ diagnostics: {
271
+ showUsageSummary: true,
272
+ },
245
273
  };
246
274
  }
247
275
  // ==========================================================================
@@ -259,24 +287,62 @@ export class SlackBot {
259
287
  return false;
260
288
  }
261
289
  log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);
262
- queue.enqueue(() => {
263
- const slackEvent = {
264
- type: event.type,
265
- conversationId,
266
- conversationKind: event.conversationKind,
267
- channel: conversationId,
268
- ts: event.ts,
269
- thread_ts: event.thread_ts,
270
- user: event.user,
271
- text: event.text,
272
- attachments: event.attachments?.map((attachment) => ({
273
- original: attachment.name,
274
- localPath: attachment.localPath,
275
- })),
276
- sessionKey: event.sessionKey,
277
- };
278
- const adapters = createSlackAdapters(slackEvent, this, true);
279
- return this.handler.handleEvent(event, this, adapters, true);
290
+ queue.enqueue(async () => {
291
+ let anchorTs;
292
+ if (!event.thread_ts) {
293
+ try {
294
+ anchorTs = await this.postMessage(conversationId, SLACK_EVENT_ANCHOR_TEXT);
295
+ }
296
+ catch (err) {
297
+ log.logWarning(`Failed to post Slack event anchor for ${conversationId}`, err instanceof Error ? err.message : String(err));
298
+ reportUserFacingError(err, {
299
+ domain: "events",
300
+ surface: "event_delivery",
301
+ operation: "slack_anchor_post",
302
+ severity: "error",
303
+ platform: "slack",
304
+ context: {
305
+ conversationId,
306
+ conversationKind: event.conversationKind,
307
+ eventTs: event.ts,
308
+ textLength: event.text.length,
309
+ },
310
+ });
311
+ throw err;
312
+ }
313
+ }
314
+ const eventPlan = planSlackEventForkRun(event, anchorTs);
315
+ const eventForRun = eventPlan.event;
316
+ if (eventPlan.initialMessageTs && eventForRun.sessionKey) {
317
+ registerSlackForkSession({
318
+ conversationDir: join(this.workingDir, conversationId),
319
+ sessionKey: eventForRun.sessionKey,
320
+ });
321
+ }
322
+ const runQueueKey = planSlackAdapterSession(eventForRun, {
323
+ initialMessageTs: eventPlan.initialMessageTs,
324
+ }).sessionKey;
325
+ this.getQueue(runQueueKey).enqueue(async () => {
326
+ const slackEvent = {
327
+ type: eventForRun.type,
328
+ conversationId,
329
+ conversationKind: eventForRun.conversationKind,
330
+ channel: conversationId,
331
+ ts: eventForRun.ts,
332
+ thread_ts: eventForRun.thread_ts,
333
+ user: eventForRun.user,
334
+ text: eventForRun.text,
335
+ attachments: eventForRun.attachments?.map((attachment) => ({
336
+ original: attachment.name,
337
+ localPath: attachment.localPath,
338
+ })),
339
+ sessionKey: eventForRun.sessionKey,
340
+ };
341
+ const adapters = createSlackAdapters(slackEvent, this, {
342
+ initialMessageTs: eventPlan.initialMessageTs,
343
+ });
344
+ return this.handler.handleEvent(eventForRun, this, adapters);
345
+ });
280
346
  });
281
347
  return true;
282
348
  }
@@ -286,11 +352,29 @@ export class SlackBot {
286
352
  getQueue(channelId) {
287
353
  let queue = this.queues.get(channelId);
288
354
  if (!queue) {
289
- queue = new ChannelQueue();
355
+ queue = new ChannelQueue("Slack");
290
356
  this.queues.set(channelId, queue);
291
357
  }
292
358
  return queue;
293
359
  }
360
+ resolveQueueKey(conversationId, sessionKey) {
361
+ if (!isSlackForkSessionKey(sessionKey))
362
+ return sessionKey;
363
+ if (this.handler.isRunning(sessionKey))
364
+ return sessionKey;
365
+ return this.hasKnownForkSession(conversationId, sessionKey) ? sessionKey : conversationId;
366
+ }
367
+ hasKnownForkSession(conversationId, sessionKey) {
368
+ return hasMaterializedSlackBranchSession(join(this.workingDir, conversationId), sessionKey);
369
+ }
370
+ shouldTriggerSharedThreadReply(channelId, threadTs) {
371
+ if (!threadTs)
372
+ return false;
373
+ const sessionKey = resolveSlackSessionKey(channelId, threadTs);
374
+ if (this.handler.isRunning(sessionKey))
375
+ return true;
376
+ return this.hasKnownForkSession(channelId, sessionKey);
377
+ }
294
378
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
295
379
  buildHomeView() {
296
380
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -427,46 +511,66 @@ export class SlackBot {
427
511
  });
428
512
  return { type: "home", blocks };
429
513
  }
430
- /**
431
- * Resolve which session key to stop.
432
- * When stop is called from a thread, the thread session (channelId:thread_ts) might
433
- * not be running — but the channel session (channelId) might be, because the bot's
434
- * reply to a top-level mention creates a thread. Check both, prefer thread first.
435
- */
436
514
  resolveStopTarget(channelId, threadTs) {
437
- if (threadTs) {
438
- const threadKey = `${channelId}:${threadTs}`;
439
- if (this.handler.isRunning(threadKey))
440
- return threadKey;
441
- // Fall back to channel session — the thread may have been spawned by a top-level run
442
- if (this.handler.isRunning(channelId))
443
- return channelId;
515
+ const directTarget = resolveStopTarget({
516
+ handler: this.handler,
517
+ conversationId: channelId,
518
+ sessionKey: resolveSlackSessionKey(channelId, threadTs),
519
+ });
520
+ if (directTarget)
521
+ return directTarget;
522
+ if (threadTs)
444
523
  return null;
445
- }
446
- return this.handler.isRunning(channelId) ? channelId : null;
524
+ return resolveOnlyScopedStopTarget(this.handler, channelId);
525
+ }
526
+ isStopText(text) {
527
+ const normalized = text.trim().toLowerCase();
528
+ return normalized === "stop" || normalized === "/stop";
447
529
  }
448
- createDirectCommandAdapters(conversationId, userId, userName, text, ts) {
530
+ createCommandAdapters(conversationId, userId, userName, text, ts, options = {}) {
449
531
  const message = {
450
532
  id: ts,
451
533
  sessionKey: conversationId,
452
- conversationKind: "direct",
534
+ conversationKind: options.ephemeralChannelId ? "shared" : "direct",
453
535
  userId,
454
536
  userName,
455
537
  text,
456
538
  attachments: [],
457
539
  };
540
+ const respond = async (responseText) => {
541
+ if (options.ephemeralChannelId) {
542
+ await this.postEphemeral(options.ephemeralChannelId, userId, responseText, options.threadTs);
543
+ return;
544
+ }
545
+ const messageTs = await this.postMessage(conversationId, responseText);
546
+ this.logBotResponse(conversationId, responseText, messageTs);
547
+ };
548
+ const respondMuted = async (responseText) => {
549
+ const CONTEXT_TEXT_LIMIT = 3000;
550
+ const blockText = responseText.length > CONTEXT_TEXT_LIMIT
551
+ ? responseText.substring(0, CONTEXT_TEXT_LIMIT - 20) + "\n_(truncated)_"
552
+ : responseText;
553
+ const blocks = [{ type: "context", elements: [{ type: "mrkdwn", text: blockText }] }];
554
+ if (options.ephemeralChannelId) {
555
+ await this.postEphemeralBlocks(options.ephemeralChannelId, userId, responseText, blocks, options.threadTs);
556
+ return;
557
+ }
558
+ const messageTs = await this.postMessageBlocks(conversationId, responseText, blocks);
559
+ this.logBotResponse(conversationId, responseText, messageTs);
560
+ };
458
561
  const responseCtx = {
459
- respond: async (responseText) => {
460
- const messageTs = await this.postMessage(conversationId, responseText);
461
- this.logBotResponse(conversationId, responseText, messageTs);
462
- },
463
- replaceResponse: async (responseText) => {
464
- const messageTs = await this.postMessage(conversationId, responseText);
465
- this.logBotResponse(conversationId, responseText, messageTs);
562
+ respond,
563
+ replaceResponse: respond,
564
+ respondDiagnostic: async (responseText, responseOptions) => {
565
+ if (responseOptions?.style === "muted") {
566
+ await respondMuted(responseText);
567
+ return;
568
+ }
569
+ await respond(responseOptions?.style === "error" ? `_${responseText}_` : responseText);
466
570
  },
467
- respondInThread: async (responseText) => {
468
- const messageTs = await this.postMessage(conversationId, responseText);
469
- this.logBotResponse(conversationId, responseText, messageTs);
571
+ respondToolResult: async (result) => {
572
+ const duration = (result.durationMs / 1000).toFixed(1);
573
+ await respond(`${result.isError ? "Error" : "Done"} ${result.toolName} (${duration}s)\n${result.result}`);
470
574
  },
471
575
  setTyping: async () => { },
472
576
  setWorking: async () => { },
@@ -504,11 +608,8 @@ export class SlackBot {
504
608
  const eventTs = (createdAt.getTime() / 1000).toFixed(6);
505
609
  const sourceChannelId = payload.channel_id;
506
610
  const isDirectMessage = sourceChannelId.startsWith("D");
507
- const targetChannelId = isDirectMessage
508
- ? sourceChannelId
509
- : await this.openDirectMessage(payload.user_id);
510
611
  const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
511
- this.logToFile(targetChannelId, {
612
+ this.logToFile(sourceChannelId, {
512
613
  date: createdAt.toISOString(),
513
614
  ts: eventTs,
514
615
  user: payload.user_id,
@@ -517,21 +618,18 @@ export class SlackBot {
517
618
  attachments: [],
518
619
  isBot: false,
519
620
  });
520
- if (!isDirectMessage) {
521
- await this.postEphemeral(sourceChannelId, payload.user_id, `我已私訊你 ${PRODUCT_NAME} 的登入連結,請到私訊完成設定。`);
522
- }
523
621
  const event = {
524
- type: "dm",
525
- conversationId: targetChannelId,
526
- conversationKind: "direct",
622
+ type: isDirectMessage ? "dm" : "private_command",
623
+ conversationId: sourceChannelId,
624
+ conversationKind: isDirectMessage ? "direct" : "shared",
527
625
  ts: eventTs,
528
626
  user: payload.user_id,
529
627
  text: commandText,
530
628
  attachments: [],
531
- sessionKey: targetChannelId,
629
+ sessionKey: sourceChannelId,
532
630
  };
533
- const adapters = this.createDirectCommandAdapters(targetChannelId, payload.user_id, userName, commandText, eventTs);
534
- await this.handler.handleEvent(event, this, adapters, false);
631
+ const adapters = this.createCommandAdapters(sourceChannelId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: sourceChannelId });
632
+ await this.handler.handleEvent(event, this, adapters);
535
633
  }
536
634
  async routeSlashNewCommand(payload) {
537
635
  const conversationId = payload.channel_id;
@@ -552,9 +650,89 @@ export class SlackBot {
552
650
  isBot: false,
553
651
  });
554
652
  const commandBot = this.createSlashCommandBot(conversationId);
555
- await this.handler.handleNew(conversationId, conversationId, commandBot);
653
+ await this.handler.handleNewCommand(conversationId, conversationId, commandBot);
654
+ }
655
+ async routeSlashModelCommand(payload) {
656
+ const conversationId = payload.channel_id;
657
+ const isDirectMessage = conversationId.startsWith("D");
658
+ const createdAt = new Date();
659
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
660
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
661
+ const commandSuffix = payload.text?.trim();
662
+ const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;
663
+ this.logToFile(conversationId, {
664
+ date: createdAt.toISOString(),
665
+ ts: eventTs,
666
+ user: payload.user_id,
667
+ userName,
668
+ text: commandText,
669
+ attachments: [],
670
+ isBot: false,
671
+ });
672
+ const sessionKey = conversationId;
673
+ const event = {
674
+ type: isDirectMessage ? "dm" : "mention",
675
+ conversationId,
676
+ conversationKind: isDirectMessage ? "direct" : "shared",
677
+ ts: eventTs,
678
+ user: payload.user_id,
679
+ text: commandText,
680
+ attachments: [],
681
+ sessionKey,
682
+ };
683
+ const adapters = this.createCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: conversationId });
684
+ await this.handler.handleEvent(event, this, adapters);
685
+ }
686
+ async routeSlashSandboxCommand(payload) {
687
+ await this.routeSlashModelCommand(payload);
688
+ }
689
+ async routeSlashAutoReplyCommand(payload) {
690
+ await this.routeSlashModelCommand(payload);
691
+ }
692
+ async routeSlashSessionCommand(payload) {
693
+ const conversationId = payload.channel_id;
694
+ const isDirectMessage = conversationId.startsWith("D");
695
+ const createdAt = new Date();
696
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
697
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
698
+ const commandText = payload.command;
699
+ this.logToFile(conversationId, {
700
+ date: createdAt.toISOString(),
701
+ ts: eventTs,
702
+ user: payload.user_id,
703
+ userName,
704
+ text: commandText,
705
+ attachments: [],
706
+ isBot: false,
707
+ threadTs: payload.thread_ts,
708
+ });
709
+ const sessionKey = resolveSlackSessionKey(conversationId, payload.thread_ts);
710
+ const event = {
711
+ type: isDirectMessage ? "dm" : "mention",
712
+ conversationId,
713
+ conversationKind: isDirectMessage ? "direct" : "shared",
714
+ ts: eventTs,
715
+ user: payload.user_id,
716
+ text: commandText,
717
+ attachments: [],
718
+ thread_ts: payload.thread_ts,
719
+ sessionKey,
720
+ };
721
+ const adapters = this.createCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage
722
+ ? { threadTs: payload.thread_ts }
723
+ : { ephemeralChannelId: conversationId, threadTs: payload.thread_ts });
724
+ await this.handler.handleEvent(event, this, adapters);
556
725
  }
557
726
  setupEventHandlers() {
727
+ this.socketClient.on("disconnect", (err) => {
728
+ log.logWarning("Slack socket disconnect", err ? String(err) : "");
729
+ });
730
+ this.socketClient.on("error", (err) => {
731
+ log.logWarning("Slack socket error", err ? String(err) : "");
732
+ });
733
+ this.socketClient.on("unable_to_socket_mode_start", (err) => {
734
+ log.logWarning("Slack socket unable_to_start", err ? String(err) : "");
735
+ });
558
736
  // Channel @mentions
559
737
  this.socketClient.on("app_mention", ({ event, ack }) => {
560
738
  const e = event;
@@ -565,7 +743,7 @@ export class SlackBot {
565
743
  }
566
744
  // Top-level mentions use a persistent channel session.
567
745
  // Thread replies get their own isolated session (channelId:thread_ts).
568
- const sessionKey = e.thread_ts ? `${e.channel}:${e.thread_ts}` : e.channel;
746
+ const sessionKey = resolveSlackSessionKey(e.channel, e.thread_ts);
569
747
  const slackEvent = {
570
748
  type: "mention",
571
749
  conversationId: e.channel,
@@ -574,21 +752,22 @@ export class SlackBot {
574
752
  ts: e.ts,
575
753
  thread_ts: e.thread_ts,
576
754
  user: e.user,
577
- text: e.text.replace(/<@[A-Z0-9]+>/gi, "").trim(),
755
+ text: this.stripOwnMention(e.text),
578
756
  files: e.files,
579
757
  sessionKey,
580
758
  };
581
- // SYNC: Log to log.jsonl (ALWAYS, even for old messages)
582
- // Also downloads attachments in background and stores local paths
583
- slackEvent.attachments = this.logUserMessage(slackEvent);
759
+ const attachmentsPromise = this.logUserMessage(slackEvent);
584
760
  // Only trigger processing for messages AFTER startup (not replayed old messages)
585
761
  if (this.startupTs && e.ts < this.startupTs) {
586
762
  log.logInfo(`[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`);
763
+ void attachmentsPromise.catch((err) => {
764
+ log.logWarning("Failed to log Slack message", String(err));
765
+ });
587
766
  ack();
588
767
  return;
589
768
  }
590
769
  // Check for stop command - execute immediately, don't queue!
591
- if (slackEvent.text.toLowerCase().trim() === "stop") {
770
+ if (this.isStopText(slackEvent.text)) {
592
771
  const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);
593
772
  if (stopTarget) {
594
773
  this.handler.handleStop(stopTarget, e.channel, this);
@@ -596,20 +775,46 @@ export class SlackBot {
596
775
  else {
597
776
  this.postMessage(e.channel, formatNothingRunning("slack"));
598
777
  }
778
+ void attachmentsPromise.catch((err) => {
779
+ log.logWarning("Failed to log Slack message", String(err));
780
+ });
599
781
  ack();
600
782
  return;
601
783
  }
602
- this.getQueue(sessionKey).enqueue(() => {
603
- const adapters = createSlackAdapters(slackEvent, this, false);
604
- return this.handler.handleEvent(slackEvent, this, adapters, false);
784
+ this.getQueue(this.resolveQueueKey(e.channel, sessionKey)).enqueue(async () => {
785
+ slackEvent.attachments = await attachmentsPromise;
786
+ const adapters = createSlackAdapters(slackEvent, this);
787
+ return this.handler.handleEvent(slackEvent, this, adapters);
605
788
  });
606
789
  ack();
607
790
  });
608
791
  // All messages (for logging) + DMs (for triggering)
609
792
  this.socketClient.on("message", ({ event, ack }) => {
610
793
  const e = event;
611
- // Skip bot messages, edits, etc.
612
- if (e.bot_id || !e.user || e.user === this.botUserId) {
794
+ const hasFiles = !!e.files && e.files.length > 0;
795
+ const hasSlackContent = !!e.text || hasFiles || !!e.blocks?.length || !!e.attachments?.length;
796
+ const isOwnBotMessage = (!!e.user && e.user === this.botUserId) || (!!this.botId && e.bot_id === this.botId);
797
+ if (isOwnBotMessage) {
798
+ ack();
799
+ return;
800
+ }
801
+ const isExternalBotMessage = !!e.bot_id || e.subtype === "bot_message";
802
+ if (isExternalBotMessage) {
803
+ if (e.subtype !== undefined && e.subtype !== "bot_message" && e.subtype !== "file_share") {
804
+ ack();
805
+ return;
806
+ }
807
+ if (!hasSlackContent) {
808
+ ack();
809
+ return;
810
+ }
811
+ void this.logExternalBotMessage(e).catch((err) => {
812
+ log.logWarning("Failed to log Slack bot message", String(err));
813
+ });
814
+ ack();
815
+ return;
816
+ }
817
+ if (!e.user) {
613
818
  ack();
614
819
  return;
615
820
  }
@@ -617,7 +822,7 @@ export class SlackBot {
617
822
  ack();
618
823
  return;
619
824
  }
620
- if (!e.text && (!e.files || e.files.length === 0)) {
825
+ if (!hasSlackContent) {
621
826
  ack();
622
827
  return;
623
828
  }
@@ -629,6 +834,8 @@ export class SlackBot {
629
834
  ack();
630
835
  return;
631
836
  }
837
+ const isSharedThreadReply = !isDM && this.shouldTriggerSharedThreadReply(e.channel, e.thread_ts);
838
+ const sessionKey = isDM || isSharedThreadReply ? resolveSlackSessionKey(e.channel, e.thread_ts) : undefined;
632
839
  const slackEvent = {
633
840
  type: isDM ? "dm" : "mention",
634
841
  conversationId: e.channel,
@@ -637,22 +844,22 @@ export class SlackBot {
637
844
  ts: e.ts,
638
845
  thread_ts: e.thread_ts,
639
846
  user: e.user,
640
- text: (e.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim(),
847
+ text: this.stripOwnMention(e.text),
641
848
  files: e.files,
642
- sessionKey: isDM ? e.channel : undefined,
849
+ sessionKey,
643
850
  };
644
- // SYNC: Log to log.jsonl (ALL messages - channel chatter and DMs)
645
- // Also downloads attachments in background and stores local paths
646
- slackEvent.attachments = this.logUserMessage(slackEvent);
851
+ const attachmentsPromise = this.logUserMessage(slackEvent);
647
852
  // Only trigger processing for messages AFTER startup (not replayed old messages)
648
853
  if (this.startupTs && e.ts < this.startupTs) {
649
854
  log.logInfo(`[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`);
855
+ void attachmentsPromise.catch((err) => {
856
+ log.logWarning("Failed to log Slack message", String(err));
857
+ });
650
858
  ack();
651
859
  return;
652
860
  }
653
- // Check for stop command in channel threads (without @mention)
654
- // app_mention handles "@mama stop", but bare "stop" in a thread comes here
655
- if (!isDM && e.thread_ts && slackEvent.text.toLowerCase().trim() === "stop") {
861
+ // Stop command for DM or shared-channel thread reply (app_mention handles "@mama stop").
862
+ if ((isDM || (!isDM && e.thread_ts)) && this.isStopText(slackEvent.text)) {
656
863
  const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);
657
864
  if (stopTarget) {
658
865
  this.handler.handleStop(stopTarget, e.channel, this);
@@ -660,28 +867,49 @@ export class SlackBot {
660
867
  else {
661
868
  this.postMessage(e.channel, formatNothingRunning("slack"));
662
869
  }
870
+ void attachmentsPromise.catch((err) => {
871
+ log.logWarning("Failed to log Slack message", String(err));
872
+ });
663
873
  ack();
664
874
  return;
665
875
  }
666
- // Only trigger handler for DMs
667
- if (isDM) {
668
- const dmSessionKey = slackEvent.sessionKey;
669
- // Check for stop command - execute immediately, don't queue!
670
- if (slackEvent.text.toLowerCase().trim() === "stop") {
671
- if (this.handler.isRunning(dmSessionKey)) {
672
- this.handler.handleStop(dmSessionKey, e.channel, this); // Don't await, don't queue
673
- }
674
- else {
675
- this.postMessage(e.channel, formatNothingRunning("slack"));
676
- }
677
- ack();
678
- return;
679
- }
680
- this.getQueue(dmSessionKey).enqueue(() => {
681
- const adapters = createSlackAdapters(slackEvent, this, false);
682
- return this.handler.handleEvent(slackEvent, this, adapters, false);
876
+ const enqueueTriggered = () => {
877
+ const activeSessionKey = slackEvent.sessionKey ?? resolveSlackSessionKey(e.channel, e.thread_ts);
878
+ // Auto-reply top-level channel messages start with no sessionKey because
879
+ // they are only candidates until the policy allows them. Once triggered,
880
+ // persist the resolved key on the event; otherwise the runtime fallback
881
+ // treats the message ts as a branch session (`channel:ts`) instead of the
882
+ // persistent top-level channel session.
883
+ slackEvent.sessionKey = activeSessionKey;
884
+ this.getQueue(this.resolveQueueKey(e.channel, activeSessionKey)).enqueue(async () => {
885
+ slackEvent.attachments = await attachmentsPromise;
886
+ const adapters = createSlackAdapters(slackEvent, this);
887
+ return this.handler.handleEvent(slackEvent, this, adapters);
888
+ });
889
+ };
890
+ const logOnly = () => {
891
+ void attachmentsPromise.catch((err) => {
892
+ log.logWarning("Failed to log Slack message", String(err));
683
893
  });
894
+ };
895
+ if (isDM || isSharedThreadReply) {
896
+ enqueueTriggered();
897
+ ack();
898
+ return;
684
899
  }
900
+ // Shared-channel non-mention, non-thread: gate via auto-reply policy.
901
+ // evaluateAutoReplyPolicy never throws — judge errors/timeouts surface as
902
+ // trigger:false with a distinct reason, and the user message has already
903
+ // been queued for logging via logUserMessage above.
904
+ evaluateAutoReplyPolicy({
905
+ event: slackEvent,
906
+ workingDir: this.workingDir,
907
+ }).then((triggerResult) => {
908
+ if (triggerResult.trigger)
909
+ enqueueTriggered();
910
+ else
911
+ logOnly();
912
+ });
685
913
  ack();
686
914
  });
687
915
  this.socketClient.on("slash_commands", async ({ body, ack }) => {
@@ -705,7 +933,39 @@ export class SlackBot {
705
933
  user_id: payload.user_id,
706
934
  user_name: payload.user_name,
707
935
  })
708
- : null;
936
+ : payload.command === "/pi-session"
937
+ ? this.routeSlashSessionCommand({
938
+ command: payload.command,
939
+ channel_id: payload.channel_id,
940
+ user_id: payload.user_id,
941
+ user_name: payload.user_name,
942
+ thread_ts: payload.thread_ts,
943
+ })
944
+ : payload.command === "/pi-model"
945
+ ? this.routeSlashModelCommand({
946
+ command: payload.command,
947
+ text: payload.text,
948
+ channel_id: payload.channel_id,
949
+ user_id: payload.user_id,
950
+ user_name: payload.user_name,
951
+ })
952
+ : payload.command === "/pi-sandbox"
953
+ ? this.routeSlashSandboxCommand({
954
+ command: payload.command,
955
+ text: payload.text,
956
+ channel_id: payload.channel_id,
957
+ user_id: payload.user_id,
958
+ user_name: payload.user_name,
959
+ })
960
+ : payload.command === "/pi-auto-reply"
961
+ ? this.routeSlashAutoReplyCommand({
962
+ command: payload.command,
963
+ text: payload.text,
964
+ channel_id: payload.channel_id,
965
+ user_id: payload.user_id,
966
+ user_name: payload.user_name,
967
+ })
968
+ : null;
709
969
  if (!handlerPromise) {
710
970
  return;
711
971
  }
@@ -758,15 +1018,22 @@ export class SlackBot {
758
1018
  });
759
1019
  }
760
1020
  /**
761
- * Log a user message to log.jsonl (SYNC)
762
- * Downloads attachments in background via store
1021
+ * Log a user message to log.jsonl after attachments are ready.
763
1022
  */
764
- logUserMessage(event) {
1023
+ async logUserMessage(event) {
765
1024
  const user = this.users.get(event.user);
766
- // Process attachments - queues downloads in background
767
- const attachments = event.files
768
- ? this.store.processAttachments(event.channel, event.files, event.ts)
769
- : [];
1025
+ let attachments = [];
1026
+ let attachmentError;
1027
+ if (event.files) {
1028
+ try {
1029
+ attachments = await this.store.processAttachments(event.channel, event.files, event.ts);
1030
+ }
1031
+ catch (err) {
1032
+ attachmentError = err;
1033
+ }
1034
+ }
1035
+ // Always write the text log, even if attachment processing failed — we want
1036
+ // a record of the user message regardless of file-handling errors.
770
1037
  this.logToFile(event.channel, {
771
1038
  date: new Date(parseFloat(event.ts) * 1000).toISOString(),
772
1039
  ts: event.ts,
@@ -778,6 +1045,29 @@ export class SlackBot {
778
1045
  attachments,
779
1046
  isBot: false,
780
1047
  });
1048
+ if (attachmentError)
1049
+ throw attachmentError;
1050
+ return attachments;
1051
+ }
1052
+ async logExternalBotMessage(event) {
1053
+ const attachments = event.files
1054
+ ? await this.store.processAttachments(event.channel, event.files, event.ts)
1055
+ : [];
1056
+ const botName = event.username ?? event.bot_profile?.name ?? event.bot_profile?.real_name ?? event.bot_id;
1057
+ this.logToFile(event.channel, {
1058
+ date: new Date(parseFloat(event.ts) * 1000).toISOString(),
1059
+ ts: event.ts,
1060
+ threadTs: event.thread_ts,
1061
+ user: event.bot_id ? `bot:${event.bot_id}` : "external-bot",
1062
+ userName: botName,
1063
+ displayName: botName,
1064
+ text: buildSlackAppMessageText(event),
1065
+ attachments,
1066
+ isBot: true,
1067
+ botId: event.bot_id,
1068
+ appId: event.app_id ?? event.bot_profile?.app_id,
1069
+ subtype: event.subtype,
1070
+ });
781
1071
  return attachments;
782
1072
  }
783
1073
  // ==========================================================================
@@ -790,23 +1080,25 @@ export class SlackBot {
790
1080
  return timestamps;
791
1081
  const content = await readFile(logPath, "utf-8");
792
1082
  const lines = content.trim().split("\n").filter(Boolean);
793
- for (const line of lines) {
1083
+ for (let i = 0; i < lines.length; i++) {
794
1084
  try {
795
- const entry = JSON.parse(line);
1085
+ const entry = JSON.parse(lines[i]);
796
1086
  if (entry.ts)
797
1087
  timestamps.add(entry.ts);
798
1088
  }
799
- catch { }
1089
+ catch (err) {
1090
+ log.logWarning(`Skipping malformed log entry at ${logPath}:${i + 1}`, err instanceof Error ? err.message : String(err));
1091
+ }
800
1092
  }
801
1093
  return timestamps;
802
1094
  }
803
- async backfillChannel(channelId) {
1095
+ async backfillChannel(channelId, upperBoundTs) {
804
1096
  const existingTs = await this.getExistingTimestamps(channelId);
805
1097
  // Find the biggest ts in log.jsonl
806
- let latestTs;
1098
+ let lastLoggedTs;
807
1099
  for (const ts of existingTs) {
808
- if (!latestTs || parseFloat(ts) > parseFloat(latestTs))
809
- latestTs = ts;
1100
+ if (!lastLoggedTs || parseFloat(ts) > parseFloat(lastLoggedTs))
1101
+ lastLoggedTs = ts;
810
1102
  }
811
1103
  const allMessages = [];
812
1104
  let cursor;
@@ -815,7 +1107,8 @@ export class SlackBot {
815
1107
  do {
816
1108
  const result = await this.webClient.conversations.history({
817
1109
  channel: channelId,
818
- oldest: latestTs, // Only fetch messages newer than what we have
1110
+ oldest: lastLoggedTs, // Only fetch messages newer than what we have
1111
+ latest: upperBoundTs, // Do not race live socket events after startup
819
1112
  inclusive: false,
820
1113
  limit: 1000,
821
1114
  cursor,
@@ -826,14 +1119,26 @@ export class SlackBot {
826
1119
  cursor = result.response_metadata?.next_cursor;
827
1120
  pageCount++;
828
1121
  } while (cursor && pageCount < maxPages);
829
- // Filter: include mama's messages, exclude other bots, skip already logged
1122
+ // Filter: include mama's messages, external app/bot messages, and user messages.
830
1123
  const relevantMessages = allMessages.filter((msg) => {
831
1124
  if (!msg.ts || existingTs.has(msg.ts))
832
1125
  return false; // Skip duplicates
833
1126
  if (msg.user === this.botUserId)
834
1127
  return true;
835
- if (msg.bot_id)
836
- return false;
1128
+ const isExternalBotMessage = !!msg.bot_id || msg.subtype === "bot_message";
1129
+ if (isExternalBotMessage) {
1130
+ if (this.botId && msg.bot_id === this.botId)
1131
+ return false;
1132
+ if (msg.subtype !== undefined &&
1133
+ msg.subtype !== "bot_message" &&
1134
+ msg.subtype !== "file_share") {
1135
+ return false;
1136
+ }
1137
+ return (!!msg.text ||
1138
+ !!(msg.files && msg.files.length > 0) ||
1139
+ !!msg.blocks?.length ||
1140
+ !!msg.attachments?.length);
1141
+ }
837
1142
  if (msg.subtype !== undefined && msg.subtype !== "file_share")
838
1143
  return false;
839
1144
  if (!msg.user)
@@ -847,16 +1152,20 @@ export class SlackBot {
847
1152
  // Log each message to log.jsonl
848
1153
  for (const msg of relevantMessages) {
849
1154
  const isMamaMessage = msg.user === this.botUserId;
1155
+ const isExternalBotMessage = !isMamaMessage && (!!msg.bot_id || msg.subtype === "bot_message");
1156
+ if (isExternalBotMessage) {
1157
+ await this.logExternalBotMessage({ ...msg, channel: channelId, ts: msg.ts });
1158
+ continue;
1159
+ }
850
1160
  const user = this.users.get(msg.user);
851
- // Strip @mentions from text (same as live messages)
852
- const text = (msg.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim();
853
- // Process attachments - queues downloads in background
1161
+ const text = this.stripOwnMention(msg.text);
854
1162
  const attachments = msg.files
855
- ? this.store.processAttachments(channelId, msg.files, msg.ts)
1163
+ ? await this.store.processAttachments(channelId, msg.files, msg.ts)
856
1164
  : [];
857
1165
  this.logToFile(channelId, {
858
1166
  date: new Date(parseFloat(msg.ts) * 1000).toISOString(),
859
1167
  ts: msg.ts,
1168
+ threadTs: msg.thread_ts,
860
1169
  user: isMamaMessage ? "bot" : msg.user,
861
1170
  userName: isMamaMessage ? undefined : user?.userName,
862
1171
  displayName: isMamaMessage ? undefined : user?.displayName,
@@ -867,7 +1176,7 @@ export class SlackBot {
867
1176
  }
868
1177
  return relevantMessages.length;
869
1178
  }
870
- async backfillAllChannels() {
1179
+ async backfillAllChannels(upperBoundTs) {
871
1180
  const startTime = Date.now();
872
1181
  // Only backfill channels that already have a log.jsonl (mama has interacted with them before)
873
1182
  const channelsToBackfill = [];
@@ -881,7 +1190,7 @@ export class SlackBot {
881
1190
  let totalMessages = 0;
882
1191
  for (const [channelId, channel] of channelsToBackfill) {
883
1192
  try {
884
- const count = await this.backfillChannel(channelId);
1193
+ const count = await this.backfillChannel(channelId, upperBoundTs);
885
1194
  if (count > 0)
886
1195
  log.logBackfillChannel(channel.name, count);
887
1196
  totalMessages += count;