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

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 (124) hide show
  1. package/README.md +69 -41
  2. package/dist/adapter.d.ts +14 -4
  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 +8 -5
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +252 -98
  8. package/dist/adapters/discord/bot.js.map +1 -1
  9. package/dist/adapters/discord/context.d.ts.map +1 -1
  10. package/dist/adapters/discord/context.js +83 -21
  11. package/dist/adapters/discord/context.js.map +1 -1
  12. package/dist/adapters/shared.d.ts +71 -0
  13. package/dist/adapters/shared.d.ts.map +1 -0
  14. package/dist/adapters/shared.js +168 -0
  15. package/dist/adapters/shared.js.map +1 -0
  16. package/dist/adapters/slack/bot.d.ts +5 -21
  17. package/dist/adapters/slack/bot.d.ts.map +1 -1
  18. package/dist/adapters/slack/bot.js +148 -150
  19. package/dist/adapters/slack/bot.js.map +1 -1
  20. package/dist/adapters/slack/branch-manager.d.ts +21 -0
  21. package/dist/adapters/slack/branch-manager.d.ts.map +1 -0
  22. package/dist/adapters/slack/branch-manager.js +96 -0
  23. package/dist/adapters/slack/branch-manager.js.map +1 -0
  24. package/dist/adapters/slack/context.d.ts.map +1 -1
  25. package/dist/adapters/slack/context.js +92 -56
  26. package/dist/adapters/slack/context.js.map +1 -1
  27. package/dist/adapters/slack/session.d.ts +3 -0
  28. package/dist/adapters/slack/session.d.ts.map +1 -0
  29. package/dist/adapters/slack/session.js +16 -0
  30. package/dist/adapters/slack/session.js.map +1 -0
  31. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  32. package/dist/adapters/telegram/bot.js +89 -103
  33. package/dist/adapters/telegram/bot.js.map +1 -1
  34. package/dist/adapters/telegram/context.d.ts.map +1 -1
  35. package/dist/adapters/telegram/context.js +40 -14
  36. package/dist/adapters/telegram/context.js.map +1 -1
  37. package/dist/agent.d.ts +2 -1
  38. package/dist/agent.d.ts.map +1 -1
  39. package/dist/agent.js +71 -142
  40. package/dist/agent.js.map +1 -1
  41. package/dist/bindings.d.ts.map +1 -1
  42. package/dist/bindings.js +3 -2
  43. package/dist/bindings.js.map +1 -1
  44. package/dist/config.d.ts +2 -0
  45. package/dist/config.d.ts.map +1 -1
  46. package/dist/config.js +16 -3
  47. package/dist/config.js.map +1 -1
  48. package/dist/context.d.ts +11 -1
  49. package/dist/context.d.ts.map +1 -1
  50. package/dist/context.js +100 -16
  51. package/dist/context.js.map +1 -1
  52. package/dist/events.d.ts +7 -0
  53. package/dist/events.d.ts.map +1 -1
  54. package/dist/events.js +61 -30
  55. package/dist/events.js.map +1 -1
  56. package/dist/fs-atomic.d.ts +10 -0
  57. package/dist/fs-atomic.d.ts.map +1 -0
  58. package/dist/fs-atomic.js +45 -0
  59. package/dist/fs-atomic.js.map +1 -0
  60. package/dist/{login.d.ts → login/index.d.ts} +1 -1
  61. package/dist/login/index.d.ts.map +1 -0
  62. package/dist/{login.js → login/index.js} +1 -1
  63. package/dist/login/index.js.map +1 -0
  64. package/dist/{link-server.d.ts → login/portal.d.ts} +5 -4
  65. package/dist/login/portal.d.ts.map +1 -0
  66. package/dist/login/portal.js +1453 -0
  67. package/dist/login/portal.js.map +1 -0
  68. package/dist/{link-token.d.ts → login/session.d.ts} +1 -1
  69. package/dist/login/session.d.ts.map +1 -0
  70. package/dist/{link-token.js → login/session.js} +1 -1
  71. package/dist/login/session.js.map +1 -0
  72. package/dist/main.d.ts.map +1 -1
  73. package/dist/main.js +89 -19
  74. package/dist/main.js.map +1 -1
  75. package/dist/provisioner.d.ts +17 -2
  76. package/dist/provisioner.d.ts.map +1 -1
  77. package/dist/provisioner.js +84 -5
  78. package/dist/provisioner.js.map +1 -1
  79. package/dist/session-policy.d.ts +13 -0
  80. package/dist/session-policy.d.ts.map +1 -0
  81. package/dist/session-policy.js +23 -0
  82. package/dist/session-policy.js.map +1 -0
  83. package/dist/session-store.d.ts +31 -1
  84. package/dist/session-store.d.ts.map +1 -1
  85. package/dist/session-store.js +168 -6
  86. package/dist/session-store.js.map +1 -1
  87. package/dist/session-view/command.d.ts +5 -0
  88. package/dist/session-view/command.d.ts.map +1 -0
  89. package/dist/session-view/command.js +11 -0
  90. package/dist/session-view/command.js.map +1 -0
  91. package/dist/session-view/portal.d.ts +11 -0
  92. package/dist/session-view/portal.d.ts.map +1 -0
  93. package/dist/session-view/portal.js +795 -0
  94. package/dist/session-view/portal.js.map +1 -0
  95. package/dist/session-view/service.d.ts +34 -0
  96. package/dist/session-view/service.d.ts.map +1 -0
  97. package/dist/session-view/service.js +416 -0
  98. package/dist/session-view/service.js.map +1 -0
  99. package/dist/session-view/store.d.ts +16 -0
  100. package/dist/session-view/store.d.ts.map +1 -0
  101. package/dist/session-view/store.js +38 -0
  102. package/dist/session-view/store.js.map +1 -0
  103. package/dist/store.d.ts +3 -6
  104. package/dist/store.d.ts.map +1 -1
  105. package/dist/store.js +15 -35
  106. package/dist/store.js.map +1 -1
  107. package/dist/tools/event.d.ts +2 -0
  108. package/dist/tools/event.d.ts.map +1 -1
  109. package/dist/tools/event.js +21 -3
  110. package/dist/tools/event.js.map +1 -1
  111. package/dist/tools/index.d.ts +2 -0
  112. package/dist/tools/index.d.ts.map +1 -1
  113. package/dist/tools/index.js.map +1 -1
  114. package/dist/vault.d.ts.map +1 -1
  115. package/dist/vault.js +11 -55
  116. package/dist/vault.js.map +1 -1
  117. package/package.json +7 -8
  118. package/dist/link-server.d.ts.map +0 -1
  119. package/dist/link-server.js +0 -899
  120. package/dist/link-server.js.map +0 -1
  121. package/dist/link-token.d.ts.map +0 -1
  122. package/dist/link-token.js.map +0 -1
  123. package/dist/login.d.ts.map +0 -1
  124. package/dist/login.js.map +0 -1
@@ -1,78 +1,23 @@
1
1
  import { SocketModeClient } from "@slack/socket-mode";
2
2
  import { WebClient } from "@slack/web-api";
3
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
3
+ import { existsSync, readFileSync } from "fs";
4
4
  import { readFile } from "fs/promises";
5
5
  import { basename, join } from "path";
6
6
  import * as log from "../../log.js";
7
7
  import { PRODUCT_NAME, formatForceStopped, formatNothingRunning } from "../../ui-copy.js";
8
- import { createSlackAdapters } from "./context.js";
9
- // ============================================================================
10
- // Exponential backoff utility for Slack API calls
11
- // ============================================================================
12
- /**
13
- * Retry a function with exponential backoff on rate limit errors.
14
- */
15
- async function withRetry(fn, maxRetries = 3, baseDelayMs = 1000) {
16
- let lastError;
17
- for (let attempt = 0; attempt < maxRetries; attempt++) {
18
- try {
19
- return await fn();
20
- }
21
- catch (err) {
22
- lastError = err instanceof Error ? err : new Error(String(err));
23
- // Check for rate limit errors
24
- let isRateLimited = false;
25
- // Check for rate_limited error code (Slack SDK)
26
- if ("code" in lastError && lastError.code === "rate_limited") {
27
- isRateLimited = true;
28
- }
29
- // Check for rate_limited in error response
30
- if ("data" in lastError) {
31
- const data = lastError
32
- .data;
33
- if (data?.error === "rate_limited" || data?.response?.status === 429) {
34
- isRateLimited = true;
35
- }
36
- }
37
- if (isRateLimited) {
38
- const delay = baseDelayMs * Math.pow(2, attempt);
39
- log.logWarning(`Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
40
- await new Promise((resolve) => setTimeout(resolve, delay));
41
- continue;
42
- }
43
- // Non-retryable error
44
- throw lastError;
45
- }
46
- }
47
- throw lastError;
48
- }
49
- class ChannelQueue {
50
- constructor() {
51
- this.queue = [];
52
- this.processing = false;
53
- }
54
- enqueue(work) {
55
- this.queue.push(work);
56
- this.processNext();
57
- }
58
- size() {
59
- return this.queue.length;
60
- }
61
- async processNext() {
62
- if (this.processing || this.queue.length === 0)
63
- return;
64
- this.processing = true;
65
- const work = this.queue.shift();
66
- try {
67
- await work();
68
- }
69
- catch (err) {
70
- log.logWarning("Queue error", err instanceof Error ? err.message : String(err));
71
- }
72
- this.processing = false;
73
- this.processNext();
74
- }
8
+ import { appendBotResponseLog, appendChannelLog, ChannelQueue, resolveOnlyScopedStopTarget, resolveStopTarget, withRetry, } from "../shared.js";
9
+ // Slack WebClient errors carry either `code: "rate_limited"` (retry-after) or
10
+ // the legacy `data.error === "rate_limited"` / 429 status shape.
11
+ function slackIsRateLimited(err) {
12
+ if (err.code === "rate_limited")
13
+ return true;
14
+ const data = err.data;
15
+ return data?.error === "rate_limited" || data?.response?.status === 429;
75
16
  }
17
+ const slackRetry = (fn) => withRetry(fn, { isRateLimited: slackIsRateLimited });
18
+ import { createSlackAdapters } from "./context.js";
19
+ import { hasMaterializedSlackBranchSession } from "./branch-manager.js";
20
+ import { resolveSlackSessionKey } from "./session.js";
76
21
  // ============================================================================
77
22
  // SlackBot
78
23
  // ============================================================================
@@ -121,18 +66,18 @@ export class SlackBot {
121
66
  return Array.from(this.channels.values());
122
67
  }
123
68
  async postMessage(channel, text) {
124
- return withRetry(async () => {
69
+ return slackRetry(async () => {
125
70
  const result = await this.webClient.chat.postMessage({ channel, text });
126
71
  return result.ts;
127
72
  });
128
73
  }
129
74
  async postEphemeral(channel, user, text) {
130
- return withRetry(async () => {
75
+ return slackRetry(async () => {
131
76
  await this.webClient.chat.postEphemeral({ channel, user, text });
132
77
  });
133
78
  }
134
79
  async openDirectMessage(userId) {
135
- return withRetry(async () => {
80
+ return slackRetry(async () => {
136
81
  const result = await this.webClient.conversations.open({ users: userId });
137
82
  const channelId = result.channel?.id;
138
83
  if (!channelId) {
@@ -142,12 +87,12 @@ export class SlackBot {
142
87
  });
143
88
  }
144
89
  async updateMessage(channel, ts, text) {
145
- return withRetry(async () => {
90
+ return slackRetry(async () => {
146
91
  await this.webClient.chat.update({ channel, ts, text });
147
92
  });
148
93
  }
149
94
  async deleteMessage(channel, ts) {
150
- return withRetry(async () => {
95
+ return slackRetry(async () => {
151
96
  await this.webClient.chat.delete({ channel, ts });
152
97
  });
153
98
  }
@@ -156,7 +101,7 @@ export class SlackBot {
156
101
  // ==========================================================================
157
102
  /** Set the status for an assistant thread (shows "thinking" state) */
158
103
  async setAssistantStatus(channel, threadTs, status) {
159
- return withRetry(async () => {
104
+ return slackRetry(async () => {
160
105
  await this.webClient.assistant.threads.setStatus({
161
106
  channel_id: channel,
162
107
  thread_ts: threadTs,
@@ -165,7 +110,7 @@ export class SlackBot {
165
110
  });
166
111
  }
167
112
  async postInThread(channel, threadTs, text) {
168
- return withRetry(async () => {
113
+ return slackRetry(async () => {
169
114
  // Use Block Kit section for long messages to trigger Slack's "Show more" collapsing (~700 chars)
170
115
  const SECTION_TEXT_LIMIT = 3000;
171
116
  if (text.length > 500) {
@@ -185,7 +130,7 @@ export class SlackBot {
185
130
  });
186
131
  }
187
132
  async postInThreadBlocks(channel, threadTs, text, blocks) {
188
- return withRetry(async () => {
133
+ return slackRetry(async () => {
189
134
  const result = await this.webClient.chat.postMessage({
190
135
  channel,
191
136
  thread_ts: threadTs,
@@ -196,7 +141,7 @@ export class SlackBot {
196
141
  });
197
142
  }
198
143
  async uploadFile(channel, filePath, title, threadTs) {
199
- return withRetry(async () => {
144
+ return slackRetry(async () => {
200
145
  const fileName = title || basename(filePath);
201
146
  const fileContent = readFileSync(filePath);
202
147
  await this.webClient.files.uploadV2({
@@ -208,29 +153,11 @@ export class SlackBot {
208
153
  });
209
154
  });
210
155
  }
211
- /**
212
- * Log a message to log.jsonl (SYNC)
213
- * This is the ONLY place messages are written to log.jsonl
214
- */
215
156
  logToFile(channel, entry) {
216
- const dir = join(this.workingDir, channel);
217
- if (!existsSync(dir))
218
- mkdirSync(dir, { recursive: true });
219
- appendFileSync(join(dir, "log.jsonl"), `${JSON.stringify(entry)}\n`);
157
+ appendChannelLog(this.workingDir, channel, entry);
220
158
  }
221
- /**
222
- * Log a bot response to log.jsonl
223
- */
224
159
  logBotResponse(channel, text, ts, threadTs) {
225
- this.logToFile(channel, {
226
- date: new Date().toISOString(),
227
- ts,
228
- threadTs,
229
- user: "bot",
230
- text,
231
- attachments: [],
232
- isBot: true,
233
- });
160
+ appendBotResponseLog(this.workingDir, channel, text, ts, threadTs);
234
161
  }
235
162
  getPlatformInfo() {
236
163
  return {
@@ -242,6 +169,9 @@ export class SlackBot {
242
169
  userName: u.userName,
243
170
  displayName: u.displayName,
244
171
  })),
172
+ diagnostics: {
173
+ showUsageSummary: true,
174
+ },
245
175
  };
246
176
  }
247
177
  // ==========================================================================
@@ -286,11 +216,26 @@ export class SlackBot {
286
216
  getQueue(channelId) {
287
217
  let queue = this.queues.get(channelId);
288
218
  if (!queue) {
289
- queue = new ChannelQueue();
219
+ queue = new ChannelQueue("Slack");
290
220
  this.queues.set(channelId, queue);
291
221
  }
292
222
  return queue;
293
223
  }
224
+ resolveQueueKey(conversationId, sessionKey) {
225
+ if (!sessionKey.includes(":"))
226
+ return sessionKey;
227
+ return hasMaterializedSlackBranchSession(join(this.workingDir, conversationId), sessionKey)
228
+ ? sessionKey
229
+ : conversationId;
230
+ }
231
+ shouldTriggerSharedThreadReply(channelId, threadTs) {
232
+ if (!threadTs)
233
+ return false;
234
+ const sessionKey = resolveSlackSessionKey(channelId, threadTs);
235
+ if (this.handler.isRunning(sessionKey))
236
+ return true;
237
+ return hasMaterializedSlackBranchSession(join(this.workingDir, channelId), sessionKey);
238
+ }
294
239
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
295
240
  buildHomeView() {
296
241
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -427,46 +372,43 @@ export class SlackBot {
427
372
  });
428
373
  return { type: "home", blocks };
429
374
  }
430
- /**
431
- * Resolve which session key to stop.
432
- * When stop is called from a thread, the thread session (channelId:thread_ts) might
433
- * not be running — but the channel session (channelId) might be, because the bot's
434
- * reply to a top-level mention creates a thread. Check both, prefer thread first.
435
- */
436
375
  resolveStopTarget(channelId, threadTs) {
437
- if (threadTs) {
438
- const threadKey = `${channelId}:${threadTs}`;
439
- if (this.handler.isRunning(threadKey))
440
- return threadKey;
441
- // Fall back to channel session — the thread may have been spawned by a top-level run
442
- if (this.handler.isRunning(channelId))
443
- return channelId;
376
+ const directTarget = resolveStopTarget({
377
+ handler: this.handler,
378
+ conversationId: channelId,
379
+ sessionKey: threadTs ? resolveSlackSessionKey(channelId, threadTs) : undefined,
380
+ });
381
+ if (directTarget)
382
+ return directTarget;
383
+ if (threadTs)
444
384
  return null;
445
- }
446
- return this.handler.isRunning(channelId) ? channelId : null;
385
+ return resolveOnlyScopedStopTarget(this.handler, channelId);
447
386
  }
448
- createDirectCommandAdapters(conversationId, userId, userName, text, ts) {
387
+ createCommandAdapters(conversationId, userId, userName, text, ts, options = {}) {
449
388
  const message = {
450
389
  id: ts,
451
390
  sessionKey: conversationId,
452
- conversationKind: "direct",
391
+ conversationKind: options.ephemeralChannelId ? "shared" : "direct",
453
392
  userId,
454
393
  userName,
455
394
  text,
456
395
  attachments: [],
457
396
  };
397
+ const respond = async (responseText) => {
398
+ if (options.ephemeralChannelId) {
399
+ await this.postEphemeral(options.ephemeralChannelId, userId, responseText);
400
+ return;
401
+ }
402
+ const messageTs = await this.postMessage(conversationId, responseText);
403
+ this.logBotResponse(conversationId, responseText, messageTs);
404
+ };
458
405
  const responseCtx = {
459
- respond: async (responseText) => {
460
- const messageTs = await this.postMessage(conversationId, responseText);
461
- this.logBotResponse(conversationId, responseText, messageTs);
462
- },
463
- replaceResponse: async (responseText) => {
464
- const messageTs = await this.postMessage(conversationId, responseText);
465
- this.logBotResponse(conversationId, responseText, messageTs);
466
- },
467
- respondInThread: async (responseText) => {
468
- const messageTs = await this.postMessage(conversationId, responseText);
469
- this.logBotResponse(conversationId, responseText, messageTs);
406
+ respond,
407
+ replaceResponse: respond,
408
+ respondDiagnostic: respond,
409
+ respondToolResult: async (result) => {
410
+ const duration = (result.durationMs / 1000).toFixed(1);
411
+ await respond(`${result.isError ? "Error" : "Done"} ${result.toolName} (${duration}s)\n${result.result}`);
470
412
  },
471
413
  setTyping: async () => { },
472
414
  setWorking: async () => { },
@@ -530,7 +472,7 @@ export class SlackBot {
530
472
  attachments: [],
531
473
  sessionKey: targetChannelId,
532
474
  };
533
- const adapters = this.createDirectCommandAdapters(targetChannelId, payload.user_id, userName, commandText, eventTs);
475
+ const adapters = this.createCommandAdapters(targetChannelId, payload.user_id, userName, commandText, eventTs);
534
476
  await this.handler.handleEvent(event, this, adapters, false);
535
477
  }
536
478
  async routeSlashNewCommand(payload) {
@@ -554,6 +496,36 @@ export class SlackBot {
554
496
  const commandBot = this.createSlashCommandBot(conversationId);
555
497
  await this.handler.handleNew(conversationId, conversationId, commandBot);
556
498
  }
499
+ async routeSlashSessionCommand(payload) {
500
+ const conversationId = payload.channel_id;
501
+ const isDirectMessage = conversationId.startsWith("D");
502
+ const createdAt = new Date();
503
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
504
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
505
+ const commandText = payload.command;
506
+ this.logToFile(conversationId, {
507
+ date: createdAt.toISOString(),
508
+ ts: eventTs,
509
+ user: payload.user_id,
510
+ userName,
511
+ text: commandText,
512
+ attachments: [],
513
+ isBot: false,
514
+ });
515
+ const sessionKey = conversationId;
516
+ const event = {
517
+ type: "dm",
518
+ conversationId,
519
+ conversationKind: isDirectMessage ? "direct" : "shared",
520
+ ts: eventTs,
521
+ user: payload.user_id,
522
+ text: commandText,
523
+ attachments: [],
524
+ sessionKey,
525
+ };
526
+ const adapters = this.createCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: conversationId });
527
+ await this.handler.handleEvent(event, this, adapters, false);
528
+ }
557
529
  setupEventHandlers() {
558
530
  // Channel @mentions
559
531
  this.socketClient.on("app_mention", ({ event, ack }) => {
@@ -565,7 +537,7 @@ export class SlackBot {
565
537
  }
566
538
  // Top-level mentions use a persistent channel session.
567
539
  // Thread replies get their own isolated session (channelId:thread_ts).
568
- const sessionKey = e.thread_ts ? `${e.channel}:${e.thread_ts}` : e.channel;
540
+ const sessionKey = resolveSlackSessionKey(e.channel, e.thread_ts);
569
541
  const slackEvent = {
570
542
  type: "mention",
571
543
  conversationId: e.channel,
@@ -578,12 +550,13 @@ export class SlackBot {
578
550
  files: e.files,
579
551
  sessionKey,
580
552
  };
581
- // SYNC: Log to log.jsonl (ALWAYS, even for old messages)
582
- // Also downloads attachments in background and stores local paths
583
- slackEvent.attachments = this.logUserMessage(slackEvent);
553
+ const attachmentsPromise = this.logUserMessage(slackEvent);
584
554
  // Only trigger processing for messages AFTER startup (not replayed old messages)
585
555
  if (this.startupTs && e.ts < this.startupTs) {
586
556
  log.logInfo(`[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`);
557
+ void attachmentsPromise.catch((err) => {
558
+ log.logWarning("Failed to log Slack message", String(err));
559
+ });
587
560
  ack();
588
561
  return;
589
562
  }
@@ -596,10 +569,14 @@ export class SlackBot {
596
569
  else {
597
570
  this.postMessage(e.channel, formatNothingRunning("slack"));
598
571
  }
572
+ void attachmentsPromise.catch((err) => {
573
+ log.logWarning("Failed to log Slack message", String(err));
574
+ });
599
575
  ack();
600
576
  return;
601
577
  }
602
- this.getQueue(sessionKey).enqueue(() => {
578
+ this.getQueue(this.resolveQueueKey(e.channel, sessionKey)).enqueue(async () => {
579
+ slackEvent.attachments = await attachmentsPromise;
603
580
  const adapters = createSlackAdapters(slackEvent, this, false);
604
581
  return this.handler.handleEvent(slackEvent, this, adapters, false);
605
582
  });
@@ -629,6 +606,8 @@ export class SlackBot {
629
606
  ack();
630
607
  return;
631
608
  }
609
+ const isSharedThreadReply = !isDM && this.shouldTriggerSharedThreadReply(e.channel, e.thread_ts);
610
+ const sessionKey = isDM || isSharedThreadReply ? resolveSlackSessionKey(e.channel, e.thread_ts) : undefined;
632
611
  const slackEvent = {
633
612
  type: isDM ? "dm" : "mention",
634
613
  conversationId: e.channel,
@@ -639,14 +618,15 @@ export class SlackBot {
639
618
  user: e.user,
640
619
  text: (e.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim(),
641
620
  files: e.files,
642
- sessionKey: isDM ? e.channel : undefined,
621
+ sessionKey,
643
622
  };
644
- // SYNC: Log to log.jsonl (ALL messages - channel chatter and DMs)
645
- // Also downloads attachments in background and stores local paths
646
- slackEvent.attachments = this.logUserMessage(slackEvent);
623
+ const attachmentsPromise = this.logUserMessage(slackEvent);
647
624
  // Only trigger processing for messages AFTER startup (not replayed old messages)
648
625
  if (this.startupTs && e.ts < this.startupTs) {
649
626
  log.logInfo(`[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`);
627
+ void attachmentsPromise.catch((err) => {
628
+ log.logWarning("Failed to log Slack message", String(err));
629
+ });
650
630
  ack();
651
631
  return;
652
632
  }
@@ -660,28 +640,41 @@ export class SlackBot {
660
640
  else {
661
641
  this.postMessage(e.channel, formatNothingRunning("slack"));
662
642
  }
643
+ void attachmentsPromise.catch((err) => {
644
+ log.logWarning("Failed to log Slack message", String(err));
645
+ });
663
646
  ack();
664
647
  return;
665
648
  }
666
- // Only trigger handler for DMs
667
- if (isDM) {
668
- const dmSessionKey = slackEvent.sessionKey;
649
+ // Trigger handler for DMs and bare replies inside shared-channel threads.
650
+ if (isDM || isSharedThreadReply) {
651
+ const activeSessionKey = slackEvent.sessionKey ?? resolveSlackSessionKey(e.channel, e.thread_ts);
669
652
  // Check for stop command - execute immediately, don't queue!
670
653
  if (slackEvent.text.toLowerCase().trim() === "stop") {
671
- if (this.handler.isRunning(dmSessionKey)) {
672
- this.handler.handleStop(dmSessionKey, e.channel, this); // Don't await, don't queue
654
+ const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);
655
+ if (stopTarget) {
656
+ this.handler.handleStop(stopTarget, e.channel, this); // Don't await, don't queue
673
657
  }
674
658
  else {
675
659
  this.postMessage(e.channel, formatNothingRunning("slack"));
676
660
  }
661
+ void attachmentsPromise.catch((err) => {
662
+ log.logWarning("Failed to log Slack message", String(err));
663
+ });
677
664
  ack();
678
665
  return;
679
666
  }
680
- this.getQueue(dmSessionKey).enqueue(() => {
667
+ this.getQueue(this.resolveQueueKey(e.channel, activeSessionKey)).enqueue(async () => {
668
+ slackEvent.attachments = await attachmentsPromise;
681
669
  const adapters = createSlackAdapters(slackEvent, this, false);
682
670
  return this.handler.handleEvent(slackEvent, this, adapters, false);
683
671
  });
684
672
  }
673
+ else {
674
+ void attachmentsPromise.catch((err) => {
675
+ log.logWarning("Failed to log Slack message", String(err));
676
+ });
677
+ }
685
678
  ack();
686
679
  });
687
680
  this.socketClient.on("slash_commands", async ({ body, ack }) => {
@@ -705,7 +698,14 @@ export class SlackBot {
705
698
  user_id: payload.user_id,
706
699
  user_name: payload.user_name,
707
700
  })
708
- : null;
701
+ : payload.command === "/pi-session"
702
+ ? this.routeSlashSessionCommand({
703
+ command: payload.command,
704
+ channel_id: payload.channel_id,
705
+ user_id: payload.user_id,
706
+ user_name: payload.user_name,
707
+ })
708
+ : null;
709
709
  if (!handlerPromise) {
710
710
  return;
711
711
  }
@@ -758,14 +758,12 @@ export class SlackBot {
758
758
  });
759
759
  }
760
760
  /**
761
- * Log a user message to log.jsonl (SYNC)
762
- * Downloads attachments in background via store
761
+ * Log a user message to log.jsonl after attachments are ready.
763
762
  */
764
- logUserMessage(event) {
763
+ async logUserMessage(event) {
765
764
  const user = this.users.get(event.user);
766
- // Process attachments - queues downloads in background
767
765
  const attachments = event.files
768
- ? this.store.processAttachments(event.channel, event.files, event.ts)
766
+ ? await this.store.processAttachments(event.channel, event.files, event.ts)
769
767
  : [];
770
768
  this.logToFile(event.channel, {
771
769
  date: new Date(parseFloat(event.ts) * 1000).toISOString(),
@@ -850,13 +848,13 @@ export class SlackBot {
850
848
  const user = this.users.get(msg.user);
851
849
  // Strip @mentions from text (same as live messages)
852
850
  const text = (msg.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim();
853
- // Process attachments - queues downloads in background
854
851
  const attachments = msg.files
855
- ? this.store.processAttachments(channelId, msg.files, msg.ts)
852
+ ? await this.store.processAttachments(channelId, msg.files, msg.ts)
856
853
  : [];
857
854
  this.logToFile(channelId, {
858
855
  date: new Date(parseFloat(msg.ts) * 1000).toISOString(),
859
856
  ts: msg.ts,
857
+ threadTs: msg.thread_ts,
860
858
  user: isMamaMessage ? "bot" : msg.user,
861
859
  userName: isMamaMessage ? undefined : user?.userName,
862
860
  displayName: isMamaMessage ? undefined : user?.displayName,