@geminixiang/mama 0.2.0-beta.0 → 0.2.0-beta.10

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