@geminixiang/mama 0.2.0-beta.1 → 0.2.0-beta.11

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 (271) hide show
  1. package/README.md +168 -371
  2. package/dist/adapter.d.ts +36 -12
  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 +12 -7
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +358 -135
  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 +100 -36
  12. package/dist/adapters/discord/context.js.map +1 -1
  13. package/dist/adapters/shared.d.ts +71 -0
  14. package/dist/adapters/shared.d.ts.map +1 -0
  15. package/dist/adapters/shared.js +168 -0
  16. package/dist/adapters/shared.js.map +1 -0
  17. package/dist/adapters/slack/bot.d.ts +30 -24
  18. package/dist/adapters/slack/bot.d.ts.map +1 -1
  19. package/dist/adapters/slack/bot.js +613 -224
  20. package/dist/adapters/slack/bot.js.map +1 -1
  21. package/dist/adapters/slack/branch-manager.d.ts +22 -0
  22. package/dist/adapters/slack/branch-manager.d.ts.map +1 -0
  23. package/dist/adapters/slack/branch-manager.js +97 -0
  24. package/dist/adapters/slack/branch-manager.js.map +1 -0
  25. package/dist/adapters/slack/context.d.ts +1 -1
  26. package/dist/adapters/slack/context.d.ts.map +1 -1
  27. package/dist/adapters/slack/context.js +127 -72
  28. package/dist/adapters/slack/context.js.map +1 -1
  29. package/dist/adapters/slack/session.d.ts +3 -0
  30. package/dist/adapters/slack/session.d.ts.map +1 -0
  31. package/dist/adapters/slack/session.js +16 -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 +4 -2
  37. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  38. package/dist/adapters/telegram/bot.js +193 -147
  39. package/dist/adapters/telegram/bot.js.map +1 -1
  40. package/dist/adapters/telegram/context.d.ts.map +1 -1
  41. package/dist/adapters/telegram/context.js +58 -111
  42. package/dist/adapters/telegram/context.js.map +1 -1
  43. package/dist/adapters/telegram/html.d.ts +3 -0
  44. package/dist/adapters/telegram/html.d.ts.map +1 -0
  45. package/dist/adapters/telegram/html.js +98 -0
  46. package/dist/adapters/telegram/html.js.map +1 -0
  47. package/dist/agent.d.ts +9 -13
  48. package/dist/agent.d.ts.map +1 -1
  49. package/dist/agent.js +601 -567
  50. package/dist/agent.js.map +1 -1
  51. package/dist/commands/auto-reply.d.ts +16 -0
  52. package/dist/commands/auto-reply.d.ts.map +1 -0
  53. package/dist/commands/auto-reply.js +69 -0
  54. package/dist/commands/auto-reply.js.map +1 -0
  55. package/dist/commands/index.d.ts +5 -0
  56. package/dist/commands/index.d.ts.map +1 -0
  57. package/dist/commands/index.js +19 -0
  58. package/dist/commands/index.js.map +1 -0
  59. package/dist/commands/login.d.ts +5 -0
  60. package/dist/commands/login.d.ts.map +1 -0
  61. package/dist/commands/login.js +76 -0
  62. package/dist/commands/login.js.map +1 -0
  63. package/dist/commands/model.d.ts +14 -0
  64. package/dist/commands/model.d.ts.map +1 -0
  65. package/dist/commands/model.js +112 -0
  66. package/dist/commands/model.js.map +1 -0
  67. package/dist/commands/new.d.ts +9 -0
  68. package/dist/commands/new.d.ts.map +1 -0
  69. package/dist/commands/new.js +28 -0
  70. package/dist/commands/new.js.map +1 -0
  71. package/dist/commands/registry.d.ts +7 -0
  72. package/dist/commands/registry.d.ts.map +1 -0
  73. package/dist/commands/registry.js +14 -0
  74. package/dist/commands/registry.js.map +1 -0
  75. package/dist/commands/sandbox.d.ts +10 -0
  76. package/dist/commands/sandbox.d.ts.map +1 -0
  77. package/dist/commands/sandbox.js +88 -0
  78. package/dist/commands/sandbox.js.map +1 -0
  79. package/dist/commands/session-view.d.ts +5 -0
  80. package/dist/commands/session-view.d.ts.map +1 -0
  81. package/dist/commands/session-view.js +62 -0
  82. package/dist/commands/session-view.js.map +1 -0
  83. package/dist/commands/types.d.ts +41 -0
  84. package/dist/commands/types.d.ts.map +1 -0
  85. package/dist/commands/types.js +2 -0
  86. package/dist/commands/types.js.map +1 -0
  87. package/dist/commands/utils.d.ts +8 -0
  88. package/dist/commands/utils.d.ts.map +1 -0
  89. package/dist/commands/utils.js +14 -0
  90. package/dist/commands/utils.js.map +1 -0
  91. package/dist/config.d.ts +49 -30
  92. package/dist/config.d.ts.map +1 -1
  93. package/dist/config.js +313 -75
  94. package/dist/config.js.map +1 -1
  95. package/dist/context.d.ts +10 -42
  96. package/dist/context.d.ts.map +1 -1
  97. package/dist/context.js +14 -127
  98. package/dist/context.js.map +1 -1
  99. package/dist/events.d.ts +13 -6
  100. package/dist/events.d.ts.map +1 -1
  101. package/dist/events.js +118 -64
  102. package/dist/events.js.map +1 -1
  103. package/dist/execution-resolver.d.ts +9 -5
  104. package/dist/execution-resolver.d.ts.map +1 -1
  105. package/dist/execution-resolver.js +82 -18
  106. package/dist/execution-resolver.js.map +1 -1
  107. package/dist/file-guards.d.ts +6 -0
  108. package/dist/file-guards.d.ts.map +1 -0
  109. package/dist/file-guards.js +48 -0
  110. package/dist/file-guards.js.map +1 -0
  111. package/dist/fs-atomic.d.ts +10 -0
  112. package/dist/fs-atomic.d.ts.map +1 -0
  113. package/dist/fs-atomic.js +45 -0
  114. package/dist/fs-atomic.js.map +1 -0
  115. package/dist/index.d.ts +7 -0
  116. package/dist/index.d.ts.map +1 -0
  117. package/dist/index.js +4 -0
  118. package/dist/index.js.map +1 -0
  119. package/dist/instrument.d.ts.map +1 -1
  120. package/dist/instrument.js +4 -11
  121. package/dist/instrument.js.map +1 -1
  122. package/dist/log.d.ts +1 -5
  123. package/dist/log.d.ts.map +1 -1
  124. package/dist/log.js +13 -38
  125. package/dist/log.js.map +1 -1
  126. package/dist/{login.d.ts → login/index.d.ts} +16 -4
  127. package/dist/login/index.d.ts.map +1 -0
  128. package/dist/{login.js → login/index.js} +55 -17
  129. package/dist/login/index.js.map +1 -0
  130. package/dist/{link-server.d.ts → login/portal.d.ts} +7 -4
  131. package/dist/login/portal.d.ts.map +1 -0
  132. package/dist/login/portal.js +1453 -0
  133. package/dist/login/portal.js.map +1 -0
  134. package/dist/{link-token.d.ts → login/session.d.ts} +4 -3
  135. package/dist/login/session.d.ts.map +1 -0
  136. package/dist/{link-token.js → login/session.js} +1 -1
  137. package/dist/login/session.js.map +1 -0
  138. package/dist/main.d.ts.map +1 -1
  139. package/dist/main.js +151 -373
  140. package/dist/main.js.map +1 -1
  141. package/dist/provisioner.d.ts +42 -52
  142. package/dist/provisioner.d.ts.map +1 -1
  143. package/dist/provisioner.js +256 -111
  144. package/dist/provisioner.js.map +1 -1
  145. package/dist/runtime/conversation-orchestrator.d.ts +42 -0
  146. package/dist/runtime/conversation-orchestrator.d.ts.map +1 -0
  147. package/dist/runtime/conversation-orchestrator.js +150 -0
  148. package/dist/runtime/conversation-orchestrator.js.map +1 -0
  149. package/dist/runtime/index.d.ts +2 -0
  150. package/dist/runtime/index.d.ts.map +1 -0
  151. package/dist/runtime/index.js +2 -0
  152. package/dist/runtime/index.js.map +1 -0
  153. package/dist/runtime/session-runtime.d.ts +27 -0
  154. package/dist/runtime/session-runtime.d.ts.map +1 -0
  155. package/dist/runtime/session-runtime.js +211 -0
  156. package/dist/runtime/session-runtime.js.map +1 -0
  157. package/dist/sandbox/cloudflare.d.ts +15 -0
  158. package/dist/sandbox/cloudflare.d.ts.map +1 -0
  159. package/dist/sandbox/cloudflare.js +137 -0
  160. package/dist/sandbox/cloudflare.js.map +1 -0
  161. package/dist/sandbox/container.d.ts +2 -1
  162. package/dist/sandbox/container.d.ts.map +1 -1
  163. package/dist/sandbox/container.js +5 -1
  164. package/dist/sandbox/container.js.map +1 -1
  165. package/dist/sandbox/firecracker.d.ts +2 -1
  166. package/dist/sandbox/firecracker.d.ts.map +1 -1
  167. package/dist/sandbox/firecracker.js +6 -0
  168. package/dist/sandbox/firecracker.js.map +1 -1
  169. package/dist/sandbox/host.d.ts +2 -3
  170. package/dist/sandbox/host.d.ts.map +1 -1
  171. package/dist/sandbox/host.js +5 -5
  172. package/dist/sandbox/host.js.map +1 -1
  173. package/dist/sandbox/index.d.ts +6 -4
  174. package/dist/sandbox/index.d.ts.map +1 -1
  175. package/dist/sandbox/index.js +9 -6
  176. package/dist/sandbox/index.js.map +1 -1
  177. package/dist/sandbox/path-context.d.ts +4 -0
  178. package/dist/sandbox/path-context.d.ts.map +1 -0
  179. package/dist/sandbox/path-context.js +20 -0
  180. package/dist/sandbox/path-context.js.map +1 -0
  181. package/dist/sandbox/types.d.ts +17 -1
  182. package/dist/sandbox/types.d.ts.map +1 -1
  183. package/dist/sandbox/types.js.map +1 -1
  184. package/dist/sentry.d.ts +1 -1
  185. package/dist/sentry.d.ts.map +1 -1
  186. package/dist/sentry.js +4 -2
  187. package/dist/sentry.js.map +1 -1
  188. package/dist/session-policy.d.ts +13 -0
  189. package/dist/session-policy.d.ts.map +1 -0
  190. package/dist/session-policy.js +23 -0
  191. package/dist/session-policy.js.map +1 -0
  192. package/dist/session-store.d.ts +34 -3
  193. package/dist/session-store.d.ts.map +1 -1
  194. package/dist/session-store.js +184 -22
  195. package/dist/session-store.js.map +1 -1
  196. package/dist/session-view/command.d.ts +5 -0
  197. package/dist/session-view/command.d.ts.map +1 -0
  198. package/dist/session-view/command.js +11 -0
  199. package/dist/session-view/command.js.map +1 -0
  200. package/dist/session-view/portal.d.ts +16 -0
  201. package/dist/session-view/portal.d.ts.map +1 -0
  202. package/dist/session-view/portal.js +1742 -0
  203. package/dist/session-view/portal.js.map +1 -0
  204. package/dist/session-view/service.d.ts +34 -0
  205. package/dist/session-view/service.d.ts.map +1 -0
  206. package/dist/session-view/service.js +427 -0
  207. package/dist/session-view/service.js.map +1 -0
  208. package/dist/session-view/store.d.ts +18 -0
  209. package/dist/session-view/store.d.ts.map +1 -0
  210. package/dist/session-view/store.js +39 -0
  211. package/dist/session-view/store.js.map +1 -0
  212. package/dist/store.d.ts +3 -6
  213. package/dist/store.d.ts.map +1 -1
  214. package/dist/store.js +22 -48
  215. package/dist/store.js.map +1 -1
  216. package/dist/tool-diagnostics.d.ts +2 -0
  217. package/dist/tool-diagnostics.d.ts.map +1 -0
  218. package/dist/tool-diagnostics.js +7 -0
  219. package/dist/tool-diagnostics.js.map +1 -0
  220. package/dist/tools/bash.d.ts +1 -1
  221. package/dist/tools/bash.d.ts.map +1 -1
  222. package/dist/tools/bash.js.map +1 -1
  223. package/dist/tools/edit.d.ts +1 -1
  224. package/dist/tools/edit.d.ts.map +1 -1
  225. package/dist/tools/edit.js.map +1 -1
  226. package/dist/tools/event.d.ts +43 -2
  227. package/dist/tools/event.d.ts.map +1 -1
  228. package/dist/tools/event.js +48 -13
  229. package/dist/tools/event.js.map +1 -1
  230. package/dist/tools/index.d.ts +2 -1
  231. package/dist/tools/index.d.ts.map +1 -1
  232. package/dist/tools/index.js +3 -3
  233. package/dist/tools/index.js.map +1 -1
  234. package/dist/tools/read.d.ts +1 -1
  235. package/dist/tools/read.d.ts.map +1 -1
  236. package/dist/tools/read.js.map +1 -1
  237. package/dist/tools/write.d.ts +1 -1
  238. package/dist/tools/write.d.ts.map +1 -1
  239. package/dist/tools/write.js.map +1 -1
  240. package/dist/trigger.d.ts +31 -0
  241. package/dist/trigger.d.ts.map +1 -0
  242. package/dist/trigger.js +98 -0
  243. package/dist/trigger.js.map +1 -0
  244. package/dist/ui-copy.d.ts +1 -0
  245. package/dist/ui-copy.d.ts.map +1 -1
  246. package/dist/ui-copy.js +3 -0
  247. package/dist/ui-copy.js.map +1 -1
  248. package/dist/vault-routing.d.ts +1 -7
  249. package/dist/vault-routing.d.ts.map +1 -1
  250. package/dist/vault-routing.js +6 -48
  251. package/dist/vault-routing.js.map +1 -1
  252. package/dist/vault.d.ts +21 -55
  253. package/dist/vault.d.ts.map +1 -1
  254. package/dist/vault.js +144 -263
  255. package/dist/vault.js.map +1 -1
  256. package/package.json +12 -10
  257. package/dist/bindings.d.ts +0 -63
  258. package/dist/bindings.d.ts.map +0 -1
  259. package/dist/bindings.js +0 -94
  260. package/dist/bindings.js.map +0 -1
  261. package/dist/link-server.d.ts.map +0 -1
  262. package/dist/link-server.js +0 -839
  263. package/dist/link-server.js.map +0 -1
  264. package/dist/link-token.d.ts.map +0 -1
  265. package/dist/link-token.js.map +0 -1
  266. package/dist/login.d.ts.map +0 -1
  267. package/dist/login.js.map +0 -1
  268. package/dist/vault.test.d.ts +0 -2
  269. package/dist/vault.test.d.ts.map +0 -1
  270. package/dist/vault.test.js +0 -67
  271. package/dist/vault.test.js.map +0 -1
@@ -1,78 +1,56 @@
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, linkSync, readFileSync } from "fs";
4
4
  import { readFile } from "fs/promises";
5
5
  import { basename, join } from "path";
6
- import { parseLoginCommand } from "../../login.js";
7
6
  import * as log from "../../log.js";
8
- import { PRODUCT_NAME, formatAlreadyWorking, formatForceStopped, formatNothingRunning, } from "../../ui-copy.js";
7
+ import { PRODUCT_NAME, formatForceStopped, formatNothingRunning } from "../../ui-copy.js";
8
+ import { appendBotResponseLog, appendChannelLog, ChannelQueue, resolveOnlyScopedStopTarget, resolveStopTarget, withRetry, } from "../shared.js";
9
+ import { getThreadSessionFile } from "../../session-store.js";
10
+ import { evaluateAutoReplyPolicy } from "../../trigger.js";
9
11
  import { createSlackAdapters } from "./context.js";
10
- // ============================================================================
11
- // Exponential backoff utility for Slack API calls
12
- // ============================================================================
13
- /**
14
- * Retry a function with exponential backoff on rate limit errors.
15
- */
16
- async function withRetry(fn, maxRetries = 3, baseDelayMs = 1000) {
17
- let lastError;
18
- for (let attempt = 0; attempt < maxRetries; attempt++) {
19
- try {
20
- return await fn();
21
- }
22
- catch (err) {
23
- lastError = err instanceof Error ? err : new Error(String(err));
24
- // Check for rate limit errors
25
- let isRateLimited = false;
26
- // Check for rate_limited error code (Slack SDK)
27
- if ("code" in lastError && lastError.code === "rate_limited") {
28
- isRateLimited = true;
29
- }
30
- // Check for rate_limited in error response
31
- if ("data" in lastError) {
32
- const data = lastError
33
- .data;
34
- if (data?.error === "rate_limited" || data?.response?.status === 429) {
35
- isRateLimited = true;
36
- }
37
- }
38
- if (isRateLimited) {
39
- const delay = baseDelayMs * Math.pow(2, attempt);
40
- log.logWarning(`Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
41
- await new Promise((resolve) => setTimeout(resolve, delay));
42
- continue;
43
- }
44
- // Non-retryable error
45
- throw lastError;
46
- }
47
- }
48
- throw lastError;
12
+ import { hasMaterializedSlackBranchSession } from "./branch-manager.js";
13
+ import { resolveSlackSessionKey } from "./session.js";
14
+ // Slack WebClient errors carry either `code: "rate_limited"` (retry-after) or
15
+ // the legacy `data.error === "rate_limited"` / 429 status shape.
16
+ function slackIsRateLimited(err) {
17
+ if (err.code === "rate_limited")
18
+ return true;
19
+ const data = err.data;
20
+ return data?.error === "rate_limited" || data?.response?.status === 429;
49
21
  }
50
- class ChannelQueue {
51
- constructor() {
52
- this.queue = [];
53
- this.processing = false;
54
- }
55
- enqueue(work) {
56
- this.queue.push(work);
57
- this.processNext();
22
+ const slackRetry = (fn) => withRetry(fn, { isRateLimited: slackIsRateLimited });
23
+ function collectSlackText(value, parts) {
24
+ if (value === null || value === undefined)
25
+ return;
26
+ if (typeof value === "string") {
27
+ const trimmed = value.trim();
28
+ if (trimmed)
29
+ parts.push(trimmed);
30
+ return;
58
31
  }
59
- size() {
60
- return this.queue.length;
32
+ if (Array.isArray(value)) {
33
+ for (const item of value)
34
+ collectSlackText(item, parts);
35
+ return;
61
36
  }
62
- async processNext() {
63
- if (this.processing || this.queue.length === 0)
64
- return;
65
- this.processing = true;
66
- const work = this.queue.shift();
67
- try {
68
- await work();
69
- }
70
- catch (err) {
71
- log.logWarning("Queue error", err instanceof Error ? err.message : String(err));
72
- }
73
- this.processing = false;
74
- this.processNext();
37
+ if (typeof value !== "object")
38
+ return;
39
+ const obj = value;
40
+ for (const key of ["text", "fallback", "title", "value"]) {
41
+ collectSlackText(obj[key], parts);
75
42
  }
43
+ collectSlackText(obj.fields, parts);
44
+ collectSlackText(obj.elements, parts);
45
+ collectSlackText(obj.blocks, parts);
46
+ }
47
+ function buildSlackAppMessageText(event) {
48
+ const parts = [];
49
+ collectSlackText(event.text, parts);
50
+ collectSlackText(event.blocks, parts);
51
+ collectSlackText(event.attachments, parts);
52
+ const deduped = parts.filter((part, index) => parts.indexOf(part) === index);
53
+ return deduped.join("\n");
76
54
  }
77
55
  // ============================================================================
78
56
  // SlackBot
@@ -80,6 +58,8 @@ class ChannelQueue {
80
58
  export class SlackBot {
81
59
  constructor(handler, config) {
82
60
  this.botUserId = null;
61
+ this.botId = null;
62
+ this.ownMentionRegex = null;
83
63
  this.startupTs = null; // Messages older than this are just logged, not processed
84
64
  this.users = new Map();
85
65
  this.channels = new Map();
@@ -88,33 +68,24 @@ export class SlackBot {
88
68
  this.handler = handler;
89
69
  this.workingDir = config.workingDir;
90
70
  this.store = config.store;
91
- this.socketClient = new SocketModeClient({ appToken: config.appToken });
71
+ this.socketClient = new SocketModeClient({
72
+ appToken: config.appToken,
73
+ // Default 5s is too tight: brief event-loop stalls (e.g. backfill, sync fs)
74
+ // cause false pong timeouts; 4 in a row makes Slack drop the socket.
75
+ clientPingTimeout: 12_000,
76
+ });
92
77
  this.webClient = new WebClient(config.botToken);
93
78
  }
94
79
  setEventsWatcher(watcher) {
95
80
  this.eventsWatcher = watcher;
96
81
  }
97
- toBotEvent(event) {
98
- return {
99
- type: event.type,
100
- conversationId: event.channel,
101
- ts: event.ts,
102
- thread_ts: event.thread_ts,
103
- user: event.user,
104
- text: event.text,
105
- attachments: event.attachments?.map((attachment) => ({
106
- name: attachment.original,
107
- localPath: attachment.localPath,
108
- })),
109
- sessionKey: event.sessionKey,
110
- };
111
- }
112
82
  // ==========================================================================
113
83
  // Public API
114
84
  // ==========================================================================
115
85
  async start() {
116
86
  const auth = await this.webClient.auth.test();
117
87
  this.botUserId = auth.user_id;
88
+ this.botId = typeof auth.bot_id === "string" ? auth.bot_id : null;
118
89
  await Promise.all([this.fetchUsers(), this.fetchChannels()]);
119
90
  log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);
120
91
  await this.backfillAllChannels();
@@ -122,7 +93,7 @@ export class SlackBot {
122
93
  await this.socketClient.start();
123
94
  // Record startup time - messages older than this are just logged, not processed
124
95
  this.startupTs = (Date.now() / 1000).toFixed(6);
125
- log.logConnected();
96
+ log.logConnected("Slack");
126
97
  }
127
98
  getUser(userId) {
128
99
  return this.users.get(userId);
@@ -136,19 +107,74 @@ export class SlackBot {
136
107
  getAllChannels() {
137
108
  return Array.from(this.channels.values());
138
109
  }
139
- async postMessage(conversationId, text) {
140
- return withRetry(async () => {
141
- const result = await this.webClient.chat.postMessage({ channel: conversationId, text });
110
+ stripOwnMention(text) {
111
+ const source = text ?? "";
112
+ if (!this.botUserId)
113
+ return source.trim();
114
+ if (!this.ownMentionRegex || !this.ownMentionRegex.source.includes(this.botUserId)) {
115
+ this.ownMentionRegex = new RegExp(`<@${this.botUserId}>`, "gi");
116
+ }
117
+ return source.replace(this.ownMentionRegex, "").trim();
118
+ }
119
+ async postMessage(channel, text) {
120
+ return slackRetry(async () => {
121
+ const result = await this.webClient.chat.postMessage({ channel, text });
142
122
  return result.ts;
143
123
  });
144
124
  }
145
- async updateMessage(conversationId, ts, text) {
146
- return withRetry(async () => {
147
- await this.webClient.chat.update({ channel: conversationId, ts, text });
125
+ async postEphemeral(channel, user, text) {
126
+ return slackRetry(async () => {
127
+ await this.webClient.chat.postEphemeral({ channel, user, text });
128
+ });
129
+ }
130
+ async postEphemeralBlocks(channel, user, text, blocks) {
131
+ return slackRetry(async () => {
132
+ await this.webClient.chat.postEphemeral({ channel, user, text, blocks: blocks });
133
+ });
134
+ }
135
+ async postMessageBlocks(channel, text, blocks) {
136
+ return slackRetry(async () => {
137
+ const result = await this.webClient.chat.postMessage({
138
+ channel,
139
+ text,
140
+ blocks: blocks,
141
+ });
142
+ return result.ts;
143
+ });
144
+ }
145
+ async postPrivate(conversationId, userId, text) {
146
+ await this.postEphemeral(conversationId, userId, text);
147
+ }
148
+ async postPrivateDiagnostic(conversationId, userId, text, options) {
149
+ if (options?.style !== "muted") {
150
+ await this.postPrivate(conversationId, userId, options?.style === "error" ? `_${text}_` : text);
151
+ return;
152
+ }
153
+ const CONTEXT_TEXT_LIMIT = 3000;
154
+ const blockText = text.length > CONTEXT_TEXT_LIMIT
155
+ ? text.substring(0, CONTEXT_TEXT_LIMIT - 20) + "\n_(truncated)_"
156
+ : text;
157
+ await this.postEphemeralBlocks(conversationId, userId, text, [
158
+ { type: "context", elements: [{ type: "mrkdwn", text: blockText }] },
159
+ ]);
160
+ }
161
+ async openDirectMessage(userId) {
162
+ return slackRetry(async () => {
163
+ const result = await this.webClient.conversations.open({ users: userId });
164
+ const channelId = result.channel?.id;
165
+ if (!channelId) {
166
+ throw new Error(`Failed to open DM for user ${userId}`);
167
+ }
168
+ return channelId;
169
+ });
170
+ }
171
+ async updateMessage(channel, ts, text) {
172
+ return slackRetry(async () => {
173
+ await this.webClient.chat.update({ channel, ts, text });
148
174
  });
149
175
  }
150
176
  async deleteMessage(channel, ts) {
151
- return withRetry(async () => {
177
+ return slackRetry(async () => {
152
178
  await this.webClient.chat.delete({ channel, ts });
153
179
  });
154
180
  }
@@ -157,7 +183,7 @@ export class SlackBot {
157
183
  // ==========================================================================
158
184
  /** Set the status for an assistant thread (shows "thinking" state) */
159
185
  async setAssistantStatus(channel, threadTs, status) {
160
- return withRetry(async () => {
186
+ return slackRetry(async () => {
161
187
  await this.webClient.assistant.threads.setStatus({
162
188
  channel_id: channel,
163
189
  thread_ts: threadTs,
@@ -166,7 +192,7 @@ export class SlackBot {
166
192
  });
167
193
  }
168
194
  async postInThread(channel, threadTs, text) {
169
- return withRetry(async () => {
195
+ return slackRetry(async () => {
170
196
  // Use Block Kit section for long messages to trigger Slack's "Show more" collapsing (~700 chars)
171
197
  const SECTION_TEXT_LIMIT = 3000;
172
198
  if (text.length > 500) {
@@ -186,7 +212,7 @@ export class SlackBot {
186
212
  });
187
213
  }
188
214
  async postInThreadBlocks(channel, threadTs, text, blocks) {
189
- return withRetry(async () => {
215
+ return slackRetry(async () => {
190
216
  const result = await this.webClient.chat.postMessage({
191
217
  channel,
192
218
  thread_ts: threadTs,
@@ -197,7 +223,7 @@ export class SlackBot {
197
223
  });
198
224
  }
199
225
  async uploadFile(channel, filePath, title, threadTs) {
200
- return withRetry(async () => {
226
+ return slackRetry(async () => {
201
227
  const fileName = title || basename(filePath);
202
228
  const fileContent = readFileSync(filePath);
203
229
  await this.webClient.files.uploadV2({
@@ -209,29 +235,32 @@ export class SlackBot {
209
235
  });
210
236
  });
211
237
  }
212
- /**
213
- * Log a message to log.jsonl (SYNC)
214
- * This is the ONLY place messages are written to log.jsonl
215
- */
216
238
  logToFile(channel, entry) {
217
- const channelDir = join(this.workingDir, channel);
218
- if (!existsSync(channelDir))
219
- mkdirSync(channelDir, { recursive: true });
220
- appendFileSync(join(channelDir, "log.jsonl"), `${JSON.stringify(entry)}\n`);
239
+ appendChannelLog(this.workingDir, channel, entry);
221
240
  }
222
- /**
223
- * Log a bot response to log.jsonl
224
- */
225
241
  logBotResponse(channel, text, ts, threadTs) {
226
- this.logToFile(channel, {
227
- date: new Date().toISOString(),
228
- ts,
229
- threadTs,
230
- user: "bot",
231
- text,
232
- attachments: [],
233
- isBot: true,
234
- });
242
+ appendBotResponseLog(this.workingDir, channel, text, ts, threadTs);
243
+ }
244
+ aliasSyntheticEventThread(channel, threadTs, eventTs) {
245
+ const conversationDir = join(this.workingDir, channel);
246
+ const source = getThreadSessionFile(conversationDir, `${channel}:${eventTs}`);
247
+ const target = getThreadSessionFile(conversationDir, `${channel}:${threadTs}`);
248
+ if (source === target)
249
+ return;
250
+ try {
251
+ linkSync(source, target);
252
+ log.logInfo(`Aliased synthetic event session ${source} -> ${target}`);
253
+ }
254
+ catch (err) {
255
+ const code = err.code;
256
+ if (code === "EEXIST")
257
+ return;
258
+ if (code === "ENOENT") {
259
+ log.logWarning(`Cannot alias synthetic event session; source missing: ${source}`);
260
+ return;
261
+ }
262
+ log.logWarning(`Failed to alias synthetic event session ${source} -> ${target}`, err instanceof Error ? err.message : String(err));
263
+ }
235
264
  }
236
265
  getPlatformInfo() {
237
266
  return {
@@ -243,6 +272,9 @@ export class SlackBot {
243
272
  userName: u.userName,
244
273
  displayName: u.displayName,
245
274
  })),
275
+ diagnostics: {
276
+ showUsageSummary: true,
277
+ },
246
278
  };
247
279
  }
248
280
  // ==========================================================================
@@ -253,20 +285,27 @@ export class SlackBot {
253
285
  * Returns true if enqueued, false if queue is full (max 5).
254
286
  */
255
287
  enqueueEvent(event) {
256
- const queue = this.getQueue(event.conversationId);
288
+ const conversationId = event.conversationId;
289
+ const queue = this.getQueue(conversationId);
257
290
  if (queue.size() >= 5) {
258
- log.logWarning(`Event queue full for ${event.conversationId}, discarding: ${event.text.substring(0, 50)}`);
291
+ log.logWarning(`Event queue full for ${conversationId}, discarding: ${event.text.substring(0, 50)}`);
259
292
  return false;
260
293
  }
261
- log.logInfo(`Enqueueing event for ${event.conversationId}: ${event.text.substring(0, 50)}`);
294
+ log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);
262
295
  queue.enqueue(() => {
263
296
  const slackEvent = {
264
- type: "mention",
265
- channel: event.conversationId,
297
+ type: event.type,
298
+ conversationId,
299
+ conversationKind: event.conversationKind,
300
+ channel: conversationId,
266
301
  ts: event.ts,
267
302
  thread_ts: event.thread_ts,
268
303
  user: event.user,
269
304
  text: event.text,
305
+ attachments: event.attachments?.map((attachment) => ({
306
+ original: attachment.name,
307
+ localPath: attachment.localPath,
308
+ })),
270
309
  sessionKey: event.sessionKey,
271
310
  };
272
311
  const adapters = createSlackAdapters(slackEvent, this, true);
@@ -280,11 +319,28 @@ export class SlackBot {
280
319
  getQueue(channelId) {
281
320
  let queue = this.queues.get(channelId);
282
321
  if (!queue) {
283
- queue = new ChannelQueue();
322
+ queue = new ChannelQueue("Slack");
284
323
  this.queues.set(channelId, queue);
285
324
  }
286
325
  return queue;
287
326
  }
327
+ resolveQueueKey(conversationId, sessionKey) {
328
+ if (!sessionKey.includes(":"))
329
+ return sessionKey;
330
+ if (sessionKey.includes(":event:"))
331
+ return sessionKey;
332
+ return hasMaterializedSlackBranchSession(join(this.workingDir, conversationId), sessionKey)
333
+ ? sessionKey
334
+ : conversationId;
335
+ }
336
+ shouldTriggerSharedThreadReply(channelId, threadTs) {
337
+ if (!threadTs)
338
+ return false;
339
+ const sessionKey = resolveSlackSessionKey(channelId, threadTs);
340
+ if (this.handler.isRunning(sessionKey))
341
+ return true;
342
+ return hasMaterializedSlackBranchSession(join(this.workingDir, channelId), sessionKey);
343
+ }
288
344
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
289
345
  buildHomeView() {
290
346
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -387,21 +443,22 @@ export class SlackBot {
387
443
  });
388
444
  }
389
445
  else {
390
- const timestampFormatter = new Intl.DateTimeFormat(undefined, {
391
- month: "short",
392
- day: "numeric",
393
- hour: "2-digit",
394
- minute: "2-digit",
395
- });
396
446
  for (const ev of periodicEvents) {
397
447
  const channelLabel = ev.platform === "slack"
398
448
  ? (() => {
399
- const channel = this.channels.get(ev.channelId);
400
- const channelName = channel ? `#${channel.name}` : ev.channelId;
449
+ const channel = this.channels.get(ev.conversationId);
450
+ const channelName = channel ? `#${channel.name}` : ev.conversationId;
401
451
  return `${ev.platform}:${channelName}`;
402
452
  })()
403
- : `${ev.platform}:${ev.channelId}`;
404
- const nextStr = ev.nextRun ? timestampFormatter.format(new Date(ev.nextRun)) : "—";
453
+ : `${ev.platform}:${ev.conversationId}`;
454
+ const nextStr = ev.nextRun
455
+ ? new Date(ev.nextRun).toLocaleString("en-US", {
456
+ month: "short",
457
+ day: "numeric",
458
+ hour: "2-digit",
459
+ minute: "2-digit",
460
+ })
461
+ : "—";
405
462
  blocks.push({
406
463
  type: "section",
407
464
  text: {
@@ -420,25 +477,224 @@ export class SlackBot {
420
477
  });
421
478
  return { type: "home", blocks };
422
479
  }
423
- /**
424
- * Resolve which session key to stop.
425
- * When stop is called from a thread, the thread session (channelId:thread_ts) might
426
- * not be running — but the channel session (channelId) might be, because the bot's
427
- * reply to a top-level mention creates a thread. Check both, prefer thread first.
428
- */
429
480
  resolveStopTarget(channelId, threadTs) {
430
- if (threadTs) {
431
- const threadKey = `${channelId}:${threadTs}`;
432
- if (this.handler.isRunning(threadKey))
433
- return threadKey;
434
- // Fall back to channel session — the thread may have been spawned by a top-level run
435
- if (this.handler.isRunning(channelId))
436
- return channelId;
481
+ const directTarget = resolveStopTarget({
482
+ handler: this.handler,
483
+ conversationId: channelId,
484
+ sessionKey: resolveSlackSessionKey(channelId, threadTs),
485
+ });
486
+ if (directTarget)
487
+ return directTarget;
488
+ if (threadTs)
437
489
  return null;
490
+ return resolveOnlyScopedStopTarget(this.handler, channelId);
491
+ }
492
+ isStopText(text) {
493
+ const normalized = text.trim().toLowerCase();
494
+ return normalized === "stop" || normalized === "/stop";
495
+ }
496
+ createCommandAdapters(conversationId, userId, userName, text, ts, options = {}) {
497
+ const message = {
498
+ id: ts,
499
+ sessionKey: conversationId,
500
+ conversationKind: options.ephemeralChannelId ? "shared" : "direct",
501
+ userId,
502
+ userName,
503
+ text,
504
+ attachments: [],
505
+ };
506
+ const respond = async (responseText) => {
507
+ if (options.ephemeralChannelId) {
508
+ await this.postEphemeral(options.ephemeralChannelId, userId, responseText);
509
+ return;
510
+ }
511
+ const messageTs = await this.postMessage(conversationId, responseText);
512
+ this.logBotResponse(conversationId, responseText, messageTs);
513
+ };
514
+ const respondMuted = async (responseText) => {
515
+ const CONTEXT_TEXT_LIMIT = 3000;
516
+ const blockText = responseText.length > CONTEXT_TEXT_LIMIT
517
+ ? responseText.substring(0, CONTEXT_TEXT_LIMIT - 20) + "\n_(truncated)_"
518
+ : responseText;
519
+ const blocks = [{ type: "context", elements: [{ type: "mrkdwn", text: blockText }] }];
520
+ if (options.ephemeralChannelId) {
521
+ await this.postEphemeralBlocks(options.ephemeralChannelId, userId, responseText, blocks);
522
+ return;
523
+ }
524
+ const messageTs = await this.postMessageBlocks(conversationId, responseText, blocks);
525
+ this.logBotResponse(conversationId, responseText, messageTs);
526
+ };
527
+ const responseCtx = {
528
+ respond,
529
+ replaceResponse: respond,
530
+ respondDiagnostic: async (responseText, responseOptions) => {
531
+ if (responseOptions?.style === "muted") {
532
+ await respondMuted(responseText);
533
+ return;
534
+ }
535
+ await respond(responseOptions?.style === "error" ? `_${responseText}_` : responseText);
536
+ },
537
+ respondToolResult: async (result) => {
538
+ const duration = (result.durationMs / 1000).toFixed(1);
539
+ await respond(`${result.isError ? "Error" : "Done"} ${result.toolName} (${duration}s)\n${result.result}`);
540
+ },
541
+ setTyping: async () => { },
542
+ setWorking: async () => { },
543
+ uploadFile: async (filePath, title) => {
544
+ await this.uploadFile(conversationId, filePath, title);
545
+ },
546
+ deleteResponse: async () => { },
547
+ };
548
+ return {
549
+ message,
550
+ responseCtx,
551
+ platform: this.getPlatformInfo(),
552
+ };
553
+ }
554
+ createSlashCommandBot(conversationId, threadTs) {
555
+ return {
556
+ start: async () => { },
557
+ postMessage: async (_channel, text) => {
558
+ if (threadTs) {
559
+ return this.postInThread(conversationId, threadTs, text);
560
+ }
561
+ return this.postMessage(conversationId, text);
562
+ },
563
+ updateMessage: async (channel, ts, text) => {
564
+ await this.updateMessage(channel, ts, text);
565
+ },
566
+ enqueueEvent: (event) => this.enqueueEvent(event),
567
+ getPlatformInfo: () => this.getPlatformInfo(),
568
+ };
569
+ }
570
+ async routeSlashLoginCommand(payload) {
571
+ const commandSuffix = payload.text?.trim();
572
+ const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;
573
+ const createdAt = new Date();
574
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
575
+ const sourceChannelId = payload.channel_id;
576
+ const isDirectMessage = sourceChannelId.startsWith("D");
577
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
578
+ this.logToFile(sourceChannelId, {
579
+ date: createdAt.toISOString(),
580
+ ts: eventTs,
581
+ user: payload.user_id,
582
+ userName,
583
+ text: commandText,
584
+ attachments: [],
585
+ isBot: false,
586
+ });
587
+ const event = {
588
+ type: isDirectMessage ? "dm" : "private_command",
589
+ conversationId: sourceChannelId,
590
+ conversationKind: isDirectMessage ? "direct" : "shared",
591
+ ts: eventTs,
592
+ user: payload.user_id,
593
+ text: commandText,
594
+ attachments: [],
595
+ sessionKey: sourceChannelId,
596
+ };
597
+ const adapters = this.createCommandAdapters(sourceChannelId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: sourceChannelId });
598
+ await this.handler.handleEvent(event, this, adapters, false);
599
+ }
600
+ async routeSlashNewCommand(payload) {
601
+ const conversationId = payload.channel_id;
602
+ if (!conversationId.startsWith("D")) {
603
+ await this.postEphemeral(conversationId, payload.user_id, `為了避免誤清除共享上下文,${payload.command} 目前只能在與 ${PRODUCT_NAME} 的私訊中使用。`);
604
+ return;
438
605
  }
439
- return this.handler.isRunning(channelId) ? channelId : null;
606
+ const createdAt = new Date();
607
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
608
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
609
+ this.logToFile(conversationId, {
610
+ date: createdAt.toISOString(),
611
+ ts: eventTs,
612
+ user: payload.user_id,
613
+ userName,
614
+ text: payload.command,
615
+ attachments: [],
616
+ isBot: false,
617
+ });
618
+ const commandBot = this.createSlashCommandBot(conversationId);
619
+ await this.handler.handleNewCommand(conversationId, conversationId, commandBot);
620
+ }
621
+ async routeSlashModelCommand(payload) {
622
+ const conversationId = payload.channel_id;
623
+ const isDirectMessage = conversationId.startsWith("D");
624
+ const createdAt = new Date();
625
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
626
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
627
+ const commandSuffix = payload.text?.trim();
628
+ const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;
629
+ this.logToFile(conversationId, {
630
+ date: createdAt.toISOString(),
631
+ ts: eventTs,
632
+ user: payload.user_id,
633
+ userName,
634
+ text: commandText,
635
+ attachments: [],
636
+ isBot: false,
637
+ });
638
+ const sessionKey = conversationId;
639
+ const event = {
640
+ type: isDirectMessage ? "dm" : "mention",
641
+ conversationId,
642
+ conversationKind: isDirectMessage ? "direct" : "shared",
643
+ ts: eventTs,
644
+ user: payload.user_id,
645
+ text: commandText,
646
+ attachments: [],
647
+ sessionKey,
648
+ };
649
+ const adapters = this.createCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: conversationId });
650
+ await this.handler.handleEvent(event, this, adapters, false);
651
+ }
652
+ async routeSlashSandboxCommand(payload) {
653
+ await this.routeSlashModelCommand(payload);
654
+ }
655
+ async routeSlashAutoReplyCommand(payload) {
656
+ await this.routeSlashModelCommand(payload);
657
+ }
658
+ async routeSlashSessionCommand(payload) {
659
+ const conversationId = payload.channel_id;
660
+ const isDirectMessage = conversationId.startsWith("D");
661
+ const createdAt = new Date();
662
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
663
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
664
+ const commandText = payload.command;
665
+ this.logToFile(conversationId, {
666
+ date: createdAt.toISOString(),
667
+ ts: eventTs,
668
+ user: payload.user_id,
669
+ userName,
670
+ text: commandText,
671
+ attachments: [],
672
+ isBot: false,
673
+ });
674
+ const sessionKey = conversationId;
675
+ const event = {
676
+ type: isDirectMessage ? "dm" : "mention",
677
+ conversationId,
678
+ conversationKind: isDirectMessage ? "direct" : "shared",
679
+ ts: eventTs,
680
+ user: payload.user_id,
681
+ text: commandText,
682
+ attachments: [],
683
+ sessionKey,
684
+ };
685
+ const adapters = this.createCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: conversationId });
686
+ await this.handler.handleEvent(event, this, adapters, false);
440
687
  }
441
688
  setupEventHandlers() {
689
+ this.socketClient.on("disconnect", (err) => {
690
+ log.logWarning("Slack socket disconnect", err ? String(err) : "");
691
+ });
692
+ this.socketClient.on("error", (err) => {
693
+ log.logWarning("Slack socket error", err ? String(err) : "");
694
+ });
695
+ this.socketClient.on("unable_to_socket_mode_start", (err) => {
696
+ log.logWarning("Slack socket unable_to_start", err ? String(err) : "");
697
+ });
442
698
  // Channel @mentions
443
699
  this.socketClient.on("app_mention", ({ event, ack }) => {
444
700
  const e = event;
@@ -449,28 +705,31 @@ export class SlackBot {
449
705
  }
450
706
  // Top-level mentions use a persistent channel session.
451
707
  // Thread replies get their own isolated session (channelId:thread_ts).
452
- const sessionKey = e.thread_ts ? `${e.channel}:${e.thread_ts}` : e.channel;
708
+ const sessionKey = resolveSlackSessionKey(e.channel, e.thread_ts);
453
709
  const slackEvent = {
454
710
  type: "mention",
711
+ conversationId: e.channel,
712
+ conversationKind: "shared",
455
713
  channel: e.channel,
456
714
  ts: e.ts,
457
715
  thread_ts: e.thread_ts,
458
716
  user: e.user,
459
- text: e.text.replace(/<@[A-Z0-9]+>/gi, "").trim(),
717
+ text: this.stripOwnMention(e.text),
460
718
  files: e.files,
461
719
  sessionKey,
462
720
  };
463
- // SYNC: Log to log.jsonl (ALWAYS, even for old messages)
464
- // Also downloads attachments in background and stores local paths
465
- slackEvent.attachments = this.logUserMessage(slackEvent);
721
+ const attachmentsPromise = this.logUserMessage(slackEvent);
466
722
  // Only trigger processing for messages AFTER startup (not replayed old messages)
467
723
  if (this.startupTs && e.ts < this.startupTs) {
468
724
  log.logInfo(`[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`);
725
+ void attachmentsPromise.catch((err) => {
726
+ log.logWarning("Failed to log Slack message", String(err));
727
+ });
469
728
  ack();
470
729
  return;
471
730
  }
472
731
  // Check for stop command - execute immediately, don't queue!
473
- if (slackEvent.text.toLowerCase().trim() === "stop") {
732
+ if (this.isStopText(slackEvent.text)) {
474
733
  const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);
475
734
  if (stopTarget) {
476
735
  this.handler.handleStop(stopTarget, e.channel, this);
@@ -478,32 +737,46 @@ export class SlackBot {
478
737
  else {
479
738
  this.postMessage(e.channel, formatNothingRunning("slack"));
480
739
  }
740
+ void attachmentsPromise.catch((err) => {
741
+ log.logWarning("Failed to log Slack message", String(err));
742
+ });
481
743
  ack();
482
744
  return;
483
745
  }
484
- // Check for login command
485
- if (parseLoginCommand(slackEvent.text)) {
486
- void this.handler.handleLogin("slack", e.user, e.channel, this, slackEvent.text, false);
487
- ack();
488
- return;
489
- }
490
- // SYNC: Check if busy (per-thread)
491
- if (this.handler.isRunning(sessionKey)) {
492
- this.postMessage(e.channel, formatAlreadyWorking("slack", "@mama stop", { scope: "thread" }));
493
- }
494
- else {
495
- this.getQueue(sessionKey).enqueue(() => {
496
- const adapters = createSlackAdapters(slackEvent, this, false);
497
- return this.handler.handleEvent(this.toBotEvent(slackEvent), this, adapters, false);
498
- });
499
- }
746
+ this.getQueue(this.resolveQueueKey(e.channel, sessionKey)).enqueue(async () => {
747
+ slackEvent.attachments = await attachmentsPromise;
748
+ const adapters = createSlackAdapters(slackEvent, this, false);
749
+ return this.handler.handleEvent(slackEvent, this, adapters, false);
750
+ });
500
751
  ack();
501
752
  });
502
753
  // All messages (for logging) + DMs (for triggering)
503
754
  this.socketClient.on("message", ({ event, ack }) => {
504
755
  const e = event;
505
- // Skip bot messages, edits, etc.
506
- if (e.bot_id || !e.user || e.user === this.botUserId) {
756
+ const hasFiles = !!e.files && e.files.length > 0;
757
+ const hasSlackContent = !!e.text || hasFiles || !!e.blocks?.length || !!e.attachments?.length;
758
+ const isOwnBotMessage = (!!e.user && e.user === this.botUserId) || (!!this.botId && e.bot_id === this.botId);
759
+ if (isOwnBotMessage) {
760
+ ack();
761
+ return;
762
+ }
763
+ const isExternalBotMessage = !!e.bot_id || e.subtype === "bot_message";
764
+ if (isExternalBotMessage) {
765
+ if (e.subtype !== undefined && e.subtype !== "bot_message" && e.subtype !== "file_share") {
766
+ ack();
767
+ return;
768
+ }
769
+ if (!hasSlackContent) {
770
+ ack();
771
+ return;
772
+ }
773
+ void this.logExternalBotMessage(e).catch((err) => {
774
+ log.logWarning("Failed to log Slack bot message", String(err));
775
+ });
776
+ ack();
777
+ return;
778
+ }
779
+ if (!e.user) {
507
780
  ack();
508
781
  return;
509
782
  }
@@ -511,39 +784,44 @@ export class SlackBot {
511
784
  ack();
512
785
  return;
513
786
  }
514
- if (!e.text && (!e.files || e.files.length === 0)) {
787
+ if (!hasSlackContent) {
515
788
  ack();
516
789
  return;
517
790
  }
518
791
  const isDM = e.channel_type === "im";
792
+ const conversationKind = isDM ? "direct" : "shared";
519
793
  const isBotMention = e.text?.includes(`<@${this.botUserId}>`);
520
794
  // Skip channel @mentions - already handled by app_mention event
521
795
  if (!isDM && isBotMention) {
522
796
  ack();
523
797
  return;
524
798
  }
799
+ const isSharedThreadReply = !isDM && this.shouldTriggerSharedThreadReply(e.channel, e.thread_ts);
800
+ const sessionKey = isDM || isSharedThreadReply ? resolveSlackSessionKey(e.channel, e.thread_ts) : undefined;
525
801
  const slackEvent = {
526
802
  type: isDM ? "dm" : "mention",
803
+ conversationId: e.channel,
804
+ conversationKind,
527
805
  channel: e.channel,
528
806
  ts: e.ts,
529
807
  thread_ts: e.thread_ts,
530
808
  user: e.user,
531
- text: (e.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim(),
809
+ text: this.stripOwnMention(e.text),
532
810
  files: e.files,
533
- sessionKey: isDM ? e.channel : undefined,
811
+ sessionKey,
534
812
  };
535
- // SYNC: Log to log.jsonl (ALL messages - channel chatter and DMs)
536
- // Also downloads attachments in background and stores local paths
537
- slackEvent.attachments = this.logUserMessage(slackEvent);
813
+ const attachmentsPromise = this.logUserMessage(slackEvent);
538
814
  // Only trigger processing for messages AFTER startup (not replayed old messages)
539
815
  if (this.startupTs && e.ts < this.startupTs) {
540
816
  log.logInfo(`[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`);
817
+ void attachmentsPromise.catch((err) => {
818
+ log.logWarning("Failed to log Slack message", String(err));
819
+ });
541
820
  ack();
542
821
  return;
543
822
  }
544
- // Check for stop command in channel threads (without @mention)
545
- // app_mention handles "@mama stop", but bare "stop" in a thread comes here
546
- if (!isDM && e.thread_ts && slackEvent.text.toLowerCase().trim() === "stop") {
823
+ // Stop command for DM or shared-channel thread reply (app_mention handles "@mama stop").
824
+ if ((isDM || (!isDM && e.thread_ts)) && this.isStopText(slackEvent.text)) {
547
825
  const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);
548
826
  if (stopTarget) {
549
827
  this.handler.handleStop(stopTarget, e.channel, this);
@@ -551,41 +829,105 @@ export class SlackBot {
551
829
  else {
552
830
  this.postMessage(e.channel, formatNothingRunning("slack"));
553
831
  }
832
+ void attachmentsPromise.catch((err) => {
833
+ log.logWarning("Failed to log Slack message", String(err));
834
+ });
554
835
  ack();
555
836
  return;
556
837
  }
557
- // Only trigger handler for DMs
558
- if (isDM) {
559
- const dmSessionKey = slackEvent.sessionKey;
560
- // Check for stop command - execute immediately, don't queue!
561
- if (slackEvent.text.toLowerCase().trim() === "stop") {
562
- if (this.handler.isRunning(dmSessionKey)) {
563
- this.handler.handleStop(dmSessionKey, e.channel, this); // Don't await, don't queue
564
- }
565
- else {
566
- this.postMessage(e.channel, formatNothingRunning("slack"));
567
- }
568
- ack();
569
- return;
570
- }
571
- // Check for login command
572
- if (parseLoginCommand(slackEvent.text)) {
573
- void this.handler.handleLogin("slack", e.user, e.channel, this, slackEvent.text, true);
574
- ack();
575
- return;
576
- }
577
- if (this.handler.isRunning(dmSessionKey)) {
578
- this.postMessage(e.channel, formatAlreadyWorking("slack", "stop"));
579
- }
580
- else {
581
- this.getQueue(dmSessionKey).enqueue(() => {
582
- const adapters = createSlackAdapters(slackEvent, this, false);
583
- return this.handler.handleEvent(this.toBotEvent(slackEvent), this, adapters, false);
584
- });
585
- }
838
+ const enqueueTriggered = () => {
839
+ const activeSessionKey = slackEvent.sessionKey ?? resolveSlackSessionKey(e.channel, e.thread_ts);
840
+ this.getQueue(this.resolveQueueKey(e.channel, activeSessionKey)).enqueue(async () => {
841
+ slackEvent.attachments = await attachmentsPromise;
842
+ const adapters = createSlackAdapters(slackEvent, this, false);
843
+ return this.handler.handleEvent(slackEvent, this, adapters, false);
844
+ });
845
+ };
846
+ const logOnly = () => {
847
+ void attachmentsPromise.catch((err) => {
848
+ log.logWarning("Failed to log Slack message", String(err));
849
+ });
850
+ };
851
+ if (isDM || isSharedThreadReply) {
852
+ enqueueTriggered();
853
+ ack();
854
+ return;
586
855
  }
856
+ // Shared-channel non-mention, non-thread: gate via auto-reply policy.
857
+ // evaluateAutoReplyPolicy never throws — judge errors/timeouts surface as
858
+ // trigger:false with a distinct reason, and the user message has already
859
+ // been queued for logging via logUserMessage above.
860
+ evaluateAutoReplyPolicy({
861
+ event: slackEvent,
862
+ workingDir: this.workingDir,
863
+ }).then((triggerResult) => {
864
+ if (triggerResult.trigger)
865
+ enqueueTriggered();
866
+ else
867
+ logOnly();
868
+ });
587
869
  ack();
588
870
  });
871
+ this.socketClient.on("slash_commands", async ({ body, ack }) => {
872
+ const payload = body;
873
+ await ack();
874
+ if (!payload.command || !payload.channel_id || !payload.user_id) {
875
+ return;
876
+ }
877
+ const handlerPromise = payload.command === "/pi-login"
878
+ ? this.routeSlashLoginCommand({
879
+ command: payload.command,
880
+ text: payload.text,
881
+ channel_id: payload.channel_id,
882
+ user_id: payload.user_id,
883
+ user_name: payload.user_name,
884
+ })
885
+ : payload.command === "/pi-new"
886
+ ? this.routeSlashNewCommand({
887
+ command: payload.command,
888
+ channel_id: payload.channel_id,
889
+ user_id: payload.user_id,
890
+ user_name: payload.user_name,
891
+ })
892
+ : payload.command === "/pi-session"
893
+ ? this.routeSlashSessionCommand({
894
+ command: payload.command,
895
+ channel_id: payload.channel_id,
896
+ user_id: payload.user_id,
897
+ user_name: payload.user_name,
898
+ })
899
+ : payload.command === "/pi-model"
900
+ ? this.routeSlashModelCommand({
901
+ command: payload.command,
902
+ text: payload.text,
903
+ channel_id: payload.channel_id,
904
+ user_id: payload.user_id,
905
+ user_name: payload.user_name,
906
+ })
907
+ : payload.command === "/pi-sandbox"
908
+ ? this.routeSlashSandboxCommand({
909
+ command: payload.command,
910
+ text: payload.text,
911
+ channel_id: payload.channel_id,
912
+ user_id: payload.user_id,
913
+ user_name: payload.user_name,
914
+ })
915
+ : payload.command === "/pi-auto-reply"
916
+ ? this.routeSlashAutoReplyCommand({
917
+ command: payload.command,
918
+ text: payload.text,
919
+ channel_id: payload.channel_id,
920
+ user_id: payload.user_id,
921
+ user_name: payload.user_name,
922
+ })
923
+ : null;
924
+ if (!handlerPromise) {
925
+ return;
926
+ }
927
+ handlerPromise.catch((err) => {
928
+ log.logWarning("Slack slash command error", err instanceof Error ? err.message : String(err));
929
+ });
930
+ });
589
931
  // App Home tab
590
932
  this.socketClient.on("app_home_opened", ({ event, ack }) => {
591
933
  const e = event;
@@ -616,8 +958,7 @@ export class SlackBot {
616
958
  // Use handler's forceStop method
617
959
  this.handler.forceStop(sessionKey);
618
960
  // Notify in channel
619
- const actorLabel = userId ? `<@${userId}>` : "someone";
620
- await this.postMessage(channelId, formatForceStopped("slack", actorLabel));
961
+ await this.postMessage(channelId, formatForceStopped("slack", userId ?? "unknown"));
621
962
  // Refresh home tab
622
963
  if (userId) {
623
964
  this.webClient.views
@@ -632,15 +973,22 @@ export class SlackBot {
632
973
  });
633
974
  }
634
975
  /**
635
- * Log a user message to log.jsonl (SYNC)
636
- * Downloads attachments in background via store
976
+ * Log a user message to log.jsonl after attachments are ready.
637
977
  */
638
- logUserMessage(event) {
978
+ async logUserMessage(event) {
639
979
  const user = this.users.get(event.user);
640
- // Process attachments - queues downloads in background
641
- const attachments = event.files
642
- ? this.store.processAttachments(event.channel, event.files, event.ts)
643
- : [];
980
+ let attachments = [];
981
+ let attachmentError;
982
+ if (event.files) {
983
+ try {
984
+ attachments = await this.store.processAttachments(event.channel, event.files, event.ts);
985
+ }
986
+ catch (err) {
987
+ attachmentError = err;
988
+ }
989
+ }
990
+ // Always write the text log, even if attachment processing failed — we want
991
+ // a record of the user message regardless of file-handling errors.
644
992
  this.logToFile(event.channel, {
645
993
  date: new Date(parseFloat(event.ts) * 1000).toISOString(),
646
994
  ts: event.ts,
@@ -652,6 +1000,29 @@ export class SlackBot {
652
1000
  attachments,
653
1001
  isBot: false,
654
1002
  });
1003
+ if (attachmentError)
1004
+ throw attachmentError;
1005
+ return attachments;
1006
+ }
1007
+ async logExternalBotMessage(event) {
1008
+ const attachments = event.files
1009
+ ? await this.store.processAttachments(event.channel, event.files, event.ts)
1010
+ : [];
1011
+ const botName = event.username ?? event.bot_profile?.name ?? event.bot_profile?.real_name ?? event.bot_id;
1012
+ this.logToFile(event.channel, {
1013
+ date: new Date(parseFloat(event.ts) * 1000).toISOString(),
1014
+ ts: event.ts,
1015
+ threadTs: event.thread_ts,
1016
+ user: event.bot_id ? `bot:${event.bot_id}` : "external-bot",
1017
+ userName: botName,
1018
+ displayName: botName,
1019
+ text: buildSlackAppMessageText(event),
1020
+ attachments,
1021
+ isBot: true,
1022
+ botId: event.bot_id,
1023
+ appId: event.app_id ?? event.bot_profile?.app_id,
1024
+ subtype: event.subtype,
1025
+ });
655
1026
  return attachments;
656
1027
  }
657
1028
  // ==========================================================================
@@ -664,13 +1035,15 @@ export class SlackBot {
664
1035
  return timestamps;
665
1036
  const content = await readFile(logPath, "utf-8");
666
1037
  const lines = content.trim().split("\n").filter(Boolean);
667
- for (const line of lines) {
1038
+ for (let i = 0; i < lines.length; i++) {
668
1039
  try {
669
- const entry = JSON.parse(line);
1040
+ const entry = JSON.parse(lines[i]);
670
1041
  if (entry.ts)
671
1042
  timestamps.add(entry.ts);
672
1043
  }
673
- catch { }
1044
+ catch (err) {
1045
+ log.logWarning(`Skipping malformed log entry at ${logPath}:${i + 1}`, err instanceof Error ? err.message : String(err));
1046
+ }
674
1047
  }
675
1048
  return timestamps;
676
1049
  }
@@ -700,14 +1073,26 @@ export class SlackBot {
700
1073
  cursor = result.response_metadata?.next_cursor;
701
1074
  pageCount++;
702
1075
  } while (cursor && pageCount < maxPages);
703
- // Filter: include mama's messages, exclude other bots, skip already logged
1076
+ // Filter: include mama's messages, external app/bot messages, and user messages.
704
1077
  const relevantMessages = allMessages.filter((msg) => {
705
1078
  if (!msg.ts || existingTs.has(msg.ts))
706
1079
  return false; // Skip duplicates
707
1080
  if (msg.user === this.botUserId)
708
1081
  return true;
709
- if (msg.bot_id)
710
- return false;
1082
+ const isExternalBotMessage = !!msg.bot_id || msg.subtype === "bot_message";
1083
+ if (isExternalBotMessage) {
1084
+ if (this.botId && msg.bot_id === this.botId)
1085
+ return false;
1086
+ if (msg.subtype !== undefined &&
1087
+ msg.subtype !== "bot_message" &&
1088
+ msg.subtype !== "file_share") {
1089
+ return false;
1090
+ }
1091
+ return (!!msg.text ||
1092
+ !!(msg.files && msg.files.length > 0) ||
1093
+ !!msg.blocks?.length ||
1094
+ !!msg.attachments?.length);
1095
+ }
711
1096
  if (msg.subtype !== undefined && msg.subtype !== "file_share")
712
1097
  return false;
713
1098
  if (!msg.user)
@@ -721,16 +1106,20 @@ export class SlackBot {
721
1106
  // Log each message to log.jsonl
722
1107
  for (const msg of relevantMessages) {
723
1108
  const isMamaMessage = msg.user === this.botUserId;
1109
+ const isExternalBotMessage = !isMamaMessage && (!!msg.bot_id || msg.subtype === "bot_message");
1110
+ if (isExternalBotMessage) {
1111
+ await this.logExternalBotMessage({ ...msg, channel: channelId, ts: msg.ts });
1112
+ continue;
1113
+ }
724
1114
  const user = this.users.get(msg.user);
725
- // Strip @mentions from text (same as live messages)
726
- const text = (msg.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim();
727
- // Process attachments - queues downloads in background
1115
+ const text = this.stripOwnMention(msg.text);
728
1116
  const attachments = msg.files
729
- ? this.store.processAttachments(channelId, msg.files, msg.ts)
1117
+ ? await this.store.processAttachments(channelId, msg.files, msg.ts)
730
1118
  : [];
731
1119
  this.logToFile(channelId, {
732
1120
  date: new Date(parseFloat(msg.ts) * 1000).toISOString(),
733
1121
  ts: msg.ts,
1122
+ threadTs: msg.thread_ts,
734
1123
  user: isMamaMessage ? "bot" : msg.user,
735
1124
  userName: isMamaMessage ? undefined : user?.userName,
736
1125
  displayName: isMamaMessage ? undefined : user?.displayName,