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

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 (149) hide show
  1. package/README.md +133 -78
  2. package/dist/adapter.d.ts +22 -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 -7
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +228 -69
  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 +92 -34
  11. package/dist/adapters/discord/context.js.map +1 -1
  12. package/dist/adapters/shared.d.ts +23 -0
  13. package/dist/adapters/shared.d.ts.map +1 -0
  14. package/dist/adapters/shared.js +57 -0
  15. package/dist/adapters/shared.js.map +1 -0
  16. package/dist/adapters/slack/bot.d.ts +19 -11
  17. package/dist/adapters/slack/bot.d.ts.map +1 -1
  18. package/dist/adapters/slack/bot.js +356 -96
  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 +100 -67
  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 +4 -2
  32. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  33. package/dist/adapters/telegram/bot.js +141 -74
  34. package/dist/adapters/telegram/bot.js.map +1 -1
  35. package/dist/adapters/telegram/context.d.ts.map +1 -1
  36. package/dist/adapters/telegram/context.js +49 -109
  37. package/dist/adapters/telegram/context.js.map +1 -1
  38. package/dist/adapters/telegram/html.d.ts +3 -0
  39. package/dist/adapters/telegram/html.d.ts.map +1 -0
  40. package/dist/adapters/telegram/html.js +98 -0
  41. package/dist/adapters/telegram/html.js.map +1 -0
  42. package/dist/agent.d.ts +4 -11
  43. package/dist/agent.d.ts.map +1 -1
  44. package/dist/agent.js +116 -196
  45. package/dist/agent.js.map +1 -1
  46. package/dist/bindings.d.ts +1 -20
  47. package/dist/bindings.d.ts.map +1 -1
  48. package/dist/bindings.js +1 -21
  49. package/dist/bindings.js.map +1 -1
  50. package/dist/config.d.ts +9 -27
  51. package/dist/config.d.ts.map +1 -1
  52. package/dist/config.js +89 -63
  53. package/dist/config.js.map +1 -1
  54. package/dist/context.d.ts +13 -3
  55. package/dist/context.d.ts.map +1 -1
  56. package/dist/context.js +102 -18
  57. package/dist/context.js.map +1 -1
  58. package/dist/events.d.ts +18 -6
  59. package/dist/events.d.ts.map +1 -1
  60. package/dist/events.js +86 -35
  61. package/dist/events.js.map +1 -1
  62. package/dist/execution-resolver.d.ts.map +1 -1
  63. package/dist/execution-resolver.js +1 -3
  64. package/dist/execution-resolver.js.map +1 -1
  65. package/dist/instrument.d.ts.map +1 -1
  66. package/dist/instrument.js +5 -11
  67. package/dist/instrument.js.map +1 -1
  68. package/dist/{login.d.ts → login/index.d.ts} +2 -2
  69. package/dist/login/index.d.ts.map +1 -0
  70. package/dist/{login.js → login/index.js} +2 -2
  71. package/dist/login/index.js.map +1 -0
  72. package/dist/{link-server.d.ts → login/portal.d.ts} +6 -4
  73. package/dist/login/portal.d.ts.map +1 -0
  74. package/dist/login/portal.js +1453 -0
  75. package/dist/login/portal.js.map +1 -0
  76. package/dist/{link-token.d.ts → login/session.d.ts} +1 -1
  77. package/dist/login/session.d.ts.map +1 -0
  78. package/dist/{link-token.js → login/session.js} +1 -1
  79. package/dist/login/session.js.map +1 -0
  80. package/dist/main.d.ts.map +1 -1
  81. package/dist/main.js +175 -119
  82. package/dist/main.js.map +1 -1
  83. package/dist/provisioner.d.ts +17 -43
  84. package/dist/provisioner.d.ts.map +1 -1
  85. package/dist/provisioner.js +84 -50
  86. package/dist/provisioner.js.map +1 -1
  87. package/dist/sandbox/host.d.ts +0 -2
  88. package/dist/sandbox/host.d.ts.map +1 -1
  89. package/dist/sandbox/host.js +1 -5
  90. package/dist/sandbox/host.js.map +1 -1
  91. package/dist/sentry.d.ts.map +1 -1
  92. package/dist/sentry.js +2 -0
  93. package/dist/sentry.js.map +1 -1
  94. package/dist/session-policy.d.ts +13 -0
  95. package/dist/session-policy.d.ts.map +1 -0
  96. package/dist/session-policy.js +23 -0
  97. package/dist/session-policy.js.map +1 -0
  98. package/dist/session-store.d.ts +27 -1
  99. package/dist/session-store.d.ts.map +1 -1
  100. package/dist/session-store.js +162 -9
  101. package/dist/session-store.js.map +1 -1
  102. package/dist/session-view/command.d.ts +5 -0
  103. package/dist/session-view/command.d.ts.map +1 -0
  104. package/dist/session-view/command.js +11 -0
  105. package/dist/session-view/command.js.map +1 -0
  106. package/dist/session-view/portal.d.ts +9 -0
  107. package/dist/session-view/portal.d.ts.map +1 -0
  108. package/dist/session-view/portal.js +766 -0
  109. package/dist/session-view/portal.js.map +1 -0
  110. package/dist/session-view/service.d.ts +34 -0
  111. package/dist/session-view/service.d.ts.map +1 -0
  112. package/dist/session-view/service.js +380 -0
  113. package/dist/session-view/service.js.map +1 -0
  114. package/dist/session-view/store.d.ts +16 -0
  115. package/dist/session-view/store.d.ts.map +1 -0
  116. package/dist/session-view/store.js +38 -0
  117. package/dist/session-view/store.js.map +1 -0
  118. package/dist/store.d.ts +3 -6
  119. package/dist/store.d.ts.map +1 -1
  120. package/dist/store.js +15 -35
  121. package/dist/store.js.map +1 -1
  122. package/dist/tools/event.d.ts +3 -0
  123. package/dist/tools/event.d.ts.map +1 -1
  124. package/dist/tools/event.js +27 -8
  125. package/dist/tools/event.js.map +1 -1
  126. package/dist/tools/index.d.ts +3 -0
  127. package/dist/tools/index.d.ts.map +1 -1
  128. package/dist/tools/index.js +2 -2
  129. package/dist/tools/index.js.map +1 -1
  130. package/dist/ui-copy.d.ts +1 -0
  131. package/dist/ui-copy.d.ts.map +1 -1
  132. package/dist/ui-copy.js +3 -0
  133. package/dist/ui-copy.js.map +1 -1
  134. package/dist/vault-routing.d.ts +1 -2
  135. package/dist/vault-routing.d.ts.map +1 -1
  136. package/dist/vault-routing.js +1 -7
  137. package/dist/vault-routing.js.map +1 -1
  138. package/package.json +1 -1
  139. package/dist/link-server.d.ts.map +0 -1
  140. package/dist/link-server.js +0 -839
  141. package/dist/link-server.js.map +0 -1
  142. package/dist/link-token.d.ts.map +0 -1
  143. package/dist/link-token.js.map +0 -1
  144. package/dist/login.d.ts.map +0 -1
  145. package/dist/login.js.map +0 -1
  146. package/dist/vault.test.d.ts +0 -2
  147. package/dist/vault.test.d.ts.map +0 -1
  148. package/dist/vault.test.js +0 -67
  149. package/dist/vault.test.js.map +0 -1
@@ -3,10 +3,11 @@ import { WebClient } from "@slack/web-api";
3
3
  import { appendFileSync, existsSync, mkdirSync, 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";
9
8
  import { createSlackAdapters } from "./context.js";
9
+ import { hasMaterializedSlackBranchSession } from "./branch-manager.js";
10
+ import { resolveSlackSessionKey } from "./session.js";
10
11
  // ============================================================================
11
12
  // Exponential backoff utility for Slack API calls
12
13
  // ============================================================================
@@ -94,21 +95,6 @@ export class SlackBot {
94
95
  setEventsWatcher(watcher) {
95
96
  this.eventsWatcher = watcher;
96
97
  }
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
98
  // ==========================================================================
113
99
  // Public API
114
100
  // ==========================================================================
@@ -136,15 +122,30 @@ export class SlackBot {
136
122
  getAllChannels() {
137
123
  return Array.from(this.channels.values());
138
124
  }
139
- async postMessage(conversationId, text) {
125
+ async postMessage(channel, text) {
140
126
  return withRetry(async () => {
141
- const result = await this.webClient.chat.postMessage({ channel: conversationId, text });
127
+ const result = await this.webClient.chat.postMessage({ channel, text });
142
128
  return result.ts;
143
129
  });
144
130
  }
145
- async updateMessage(conversationId, ts, text) {
131
+ async postEphemeral(channel, user, text) {
132
+ return withRetry(async () => {
133
+ await this.webClient.chat.postEphemeral({ channel, user, text });
134
+ });
135
+ }
136
+ async openDirectMessage(userId) {
137
+ return withRetry(async () => {
138
+ const result = await this.webClient.conversations.open({ users: userId });
139
+ const channelId = result.channel?.id;
140
+ if (!channelId) {
141
+ throw new Error(`Failed to open DM for user ${userId}`);
142
+ }
143
+ return channelId;
144
+ });
145
+ }
146
+ async updateMessage(channel, ts, text) {
146
147
  return withRetry(async () => {
147
- await this.webClient.chat.update({ channel: conversationId, ts, text });
148
+ await this.webClient.chat.update({ channel, ts, text });
148
149
  });
149
150
  }
150
151
  async deleteMessage(channel, ts) {
@@ -214,10 +215,10 @@ export class SlackBot {
214
215
  * This is the ONLY place messages are written to log.jsonl
215
216
  */
216
217
  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`);
218
+ const dir = join(this.workingDir, channel);
219
+ if (!existsSync(dir))
220
+ mkdirSync(dir, { recursive: true });
221
+ appendFileSync(join(dir, "log.jsonl"), `${JSON.stringify(entry)}\n`);
221
222
  }
222
223
  /**
223
224
  * Log a bot response to log.jsonl
@@ -243,6 +244,9 @@ export class SlackBot {
243
244
  userName: u.userName,
244
245
  displayName: u.displayName,
245
246
  })),
247
+ diagnostics: {
248
+ showUsageSummary: true,
249
+ },
246
250
  };
247
251
  }
248
252
  // ==========================================================================
@@ -253,20 +257,27 @@ export class SlackBot {
253
257
  * Returns true if enqueued, false if queue is full (max 5).
254
258
  */
255
259
  enqueueEvent(event) {
256
- const queue = this.getQueue(event.conversationId);
260
+ const conversationId = event.conversationId;
261
+ const queue = this.getQueue(conversationId);
257
262
  if (queue.size() >= 5) {
258
- log.logWarning(`Event queue full for ${event.conversationId}, discarding: ${event.text.substring(0, 50)}`);
263
+ log.logWarning(`Event queue full for ${conversationId}, discarding: ${event.text.substring(0, 50)}`);
259
264
  return false;
260
265
  }
261
- log.logInfo(`Enqueueing event for ${event.conversationId}: ${event.text.substring(0, 50)}`);
266
+ log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);
262
267
  queue.enqueue(() => {
263
268
  const slackEvent = {
264
- type: "mention",
265
- channel: event.conversationId,
269
+ type: event.type,
270
+ conversationId,
271
+ conversationKind: event.conversationKind,
272
+ channel: conversationId,
266
273
  ts: event.ts,
267
274
  thread_ts: event.thread_ts,
268
275
  user: event.user,
269
276
  text: event.text,
277
+ attachments: event.attachments?.map((attachment) => ({
278
+ original: attachment.name,
279
+ localPath: attachment.localPath,
280
+ })),
270
281
  sessionKey: event.sessionKey,
271
282
  };
272
283
  const adapters = createSlackAdapters(slackEvent, this, true);
@@ -285,6 +296,13 @@ export class SlackBot {
285
296
  }
286
297
  return queue;
287
298
  }
299
+ resolveQueueKey(conversationId, sessionKey) {
300
+ if (!sessionKey.includes(":"))
301
+ return sessionKey;
302
+ return hasMaterializedSlackBranchSession(join(this.workingDir, conversationId), sessionKey)
303
+ ? sessionKey
304
+ : conversationId;
305
+ }
288
306
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
289
307
  buildHomeView() {
290
308
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -387,21 +405,22 @@ export class SlackBot {
387
405
  });
388
406
  }
389
407
  else {
390
- const timestampFormatter = new Intl.DateTimeFormat(undefined, {
391
- month: "short",
392
- day: "numeric",
393
- hour: "2-digit",
394
- minute: "2-digit",
395
- });
396
408
  for (const ev of periodicEvents) {
397
409
  const channelLabel = ev.platform === "slack"
398
410
  ? (() => {
399
- const channel = this.channels.get(ev.channelId);
400
- const channelName = channel ? `#${channel.name}` : ev.channelId;
411
+ const channel = this.channels.get(ev.conversationId);
412
+ const channelName = channel ? `#${channel.name}` : ev.conversationId;
401
413
  return `${ev.platform}:${channelName}`;
402
414
  })()
403
- : `${ev.platform}:${ev.channelId}`;
404
- const nextStr = ev.nextRun ? timestampFormatter.format(new Date(ev.nextRun)) : "—";
415
+ : `${ev.platform}:${ev.conversationId}`;
416
+ const nextStr = ev.nextRun
417
+ ? new Date(ev.nextRun).toLocaleString("en-US", {
418
+ month: "short",
419
+ day: "numeric",
420
+ hour: "2-digit",
421
+ minute: "2-digit",
422
+ })
423
+ : "—";
405
424
  blocks.push({
406
425
  type: "section",
407
426
  text: {
@@ -425,18 +444,223 @@ export class SlackBot {
425
444
  * When stop is called from a thread, the thread session (channelId:thread_ts) might
426
445
  * not be running — but the channel session (channelId) might be, because the bot's
427
446
  * reply to a top-level mention creates a thread. Check both, prefer thread first.
447
+ *
448
+ * For top-level stop requests, fall back to the only running scoped thread session in
449
+ * this conversation when there is exactly one candidate. This keeps DM/channel stop
450
+ * useful after thread sessions were introduced without guessing between multiple threads.
428
451
  */
429
452
  resolveStopTarget(channelId, threadTs) {
430
453
  if (threadTs) {
431
- const threadKey = `${channelId}:${threadTs}`;
454
+ const threadKey = resolveSlackSessionKey(channelId, threadTs);
432
455
  if (this.handler.isRunning(threadKey))
433
456
  return threadKey;
434
- // Fall back to channel session — the thread may have been spawned by a top-level run
435
457
  if (this.handler.isRunning(channelId))
436
458
  return channelId;
437
459
  return null;
438
460
  }
439
- return this.handler.isRunning(channelId) ? channelId : null;
461
+ if (this.handler.isRunning(channelId))
462
+ return channelId;
463
+ const runningInConversation = this.handler
464
+ .getRunningSessions()
465
+ .map((session) => session.sessionKey)
466
+ .filter((sessionKey) => sessionKey.startsWith(`${channelId}:`));
467
+ return runningInConversation.length === 1 ? runningInConversation[0] : null;
468
+ }
469
+ createDirectCommandAdapters(conversationId, userId, userName, text, ts) {
470
+ const message = {
471
+ id: ts,
472
+ sessionKey: conversationId,
473
+ conversationKind: "direct",
474
+ userId,
475
+ userName,
476
+ text,
477
+ attachments: [],
478
+ };
479
+ const responseCtx = {
480
+ respond: async (responseText) => {
481
+ const messageTs = await this.postMessage(conversationId, responseText);
482
+ this.logBotResponse(conversationId, responseText, messageTs);
483
+ },
484
+ replaceResponse: async (responseText) => {
485
+ const messageTs = await this.postMessage(conversationId, responseText);
486
+ this.logBotResponse(conversationId, responseText, messageTs);
487
+ },
488
+ respondDiagnostic: async (responseText) => {
489
+ const messageTs = await this.postMessage(conversationId, responseText);
490
+ this.logBotResponse(conversationId, responseText, messageTs);
491
+ },
492
+ respondToolResult: async (result) => {
493
+ const duration = (result.durationMs / 1000).toFixed(1);
494
+ const text = `${result.isError ? "Error" : "Done"} ${result.toolName} (${duration}s)\n${result.result}`;
495
+ const messageTs = await this.postMessage(conversationId, text);
496
+ this.logBotResponse(conversationId, text, messageTs);
497
+ },
498
+ setTyping: async () => { },
499
+ setWorking: async () => { },
500
+ uploadFile: async (filePath, title) => {
501
+ await this.uploadFile(conversationId, filePath, title);
502
+ },
503
+ deleteResponse: async () => { },
504
+ };
505
+ return {
506
+ message,
507
+ responseCtx,
508
+ platform: this.getPlatformInfo(),
509
+ };
510
+ }
511
+ createPrivateSessionCommandAdapters(conversationId, userId, userName, text, ts, options) {
512
+ const message = {
513
+ id: ts,
514
+ sessionKey: conversationId,
515
+ conversationKind: options.ephemeralChannelId ? "shared" : "direct",
516
+ userId,
517
+ userName,
518
+ text,
519
+ attachments: [],
520
+ };
521
+ let hasResponded = false;
522
+ const respondPrivately = async (responseText) => {
523
+ if (options.ephemeralChannelId) {
524
+ await this.postEphemeral(options.ephemeralChannelId, userId, responseText);
525
+ return;
526
+ }
527
+ const messageTs = await this.postMessage(conversationId, responseText);
528
+ this.logBotResponse(conversationId, responseText, messageTs);
529
+ };
530
+ const responseCtx = {
531
+ respond: async (responseText) => {
532
+ hasResponded = true;
533
+ await respondPrivately(responseText);
534
+ },
535
+ replaceResponse: async (responseText) => {
536
+ if (!hasResponded) {
537
+ hasResponded = true;
538
+ }
539
+ await respondPrivately(responseText);
540
+ },
541
+ respondDiagnostic: async (responseText) => {
542
+ await respondPrivately(responseText);
543
+ },
544
+ respondToolResult: async (result) => {
545
+ const duration = (result.durationMs / 1000).toFixed(1);
546
+ const formatted = `${result.isError ? "Error" : "Done"} ${result.toolName} (${duration}s)\n${result.result}`;
547
+ await respondPrivately(formatted);
548
+ },
549
+ setTyping: async () => { },
550
+ setWorking: async () => { },
551
+ uploadFile: async (filePath, title) => {
552
+ await this.uploadFile(conversationId, filePath, title);
553
+ },
554
+ deleteResponse: async () => { },
555
+ };
556
+ return {
557
+ message,
558
+ responseCtx,
559
+ platform: this.getPlatformInfo(),
560
+ };
561
+ }
562
+ createSlashCommandBot(conversationId, threadTs) {
563
+ return {
564
+ start: async () => { },
565
+ postMessage: async (_channel, text) => {
566
+ if (threadTs) {
567
+ return this.postInThread(conversationId, threadTs, text);
568
+ }
569
+ return this.postMessage(conversationId, text);
570
+ },
571
+ updateMessage: async (channel, ts, text) => {
572
+ await this.updateMessage(channel, ts, text);
573
+ },
574
+ enqueueEvent: (event) => this.enqueueEvent(event),
575
+ getPlatformInfo: () => this.getPlatformInfo(),
576
+ };
577
+ }
578
+ async routeSlashLoginCommand(payload) {
579
+ const commandSuffix = payload.text?.trim();
580
+ const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;
581
+ const createdAt = new Date();
582
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
583
+ const sourceChannelId = payload.channel_id;
584
+ const isDirectMessage = sourceChannelId.startsWith("D");
585
+ const targetChannelId = isDirectMessage
586
+ ? sourceChannelId
587
+ : await this.openDirectMessage(payload.user_id);
588
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
589
+ this.logToFile(targetChannelId, {
590
+ date: createdAt.toISOString(),
591
+ ts: eventTs,
592
+ user: payload.user_id,
593
+ userName,
594
+ text: commandText,
595
+ attachments: [],
596
+ isBot: false,
597
+ });
598
+ if (!isDirectMessage) {
599
+ await this.postEphemeral(sourceChannelId, payload.user_id, `我已私訊你 ${PRODUCT_NAME} 的登入連結,請到私訊完成設定。`);
600
+ }
601
+ const event = {
602
+ type: "dm",
603
+ conversationId: targetChannelId,
604
+ conversationKind: "direct",
605
+ ts: eventTs,
606
+ user: payload.user_id,
607
+ text: commandText,
608
+ attachments: [],
609
+ sessionKey: targetChannelId,
610
+ };
611
+ const adapters = this.createDirectCommandAdapters(targetChannelId, payload.user_id, userName, commandText, eventTs);
612
+ await this.handler.handleEvent(event, this, adapters, false);
613
+ }
614
+ async routeSlashNewCommand(payload) {
615
+ const conversationId = payload.channel_id;
616
+ if (!conversationId.startsWith("D")) {
617
+ await this.postEphemeral(conversationId, payload.user_id, `為了避免誤清除共享上下文,${payload.command} 目前只能在與 ${PRODUCT_NAME} 的私訊中使用。`);
618
+ return;
619
+ }
620
+ const createdAt = new Date();
621
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
622
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
623
+ this.logToFile(conversationId, {
624
+ date: createdAt.toISOString(),
625
+ ts: eventTs,
626
+ user: payload.user_id,
627
+ userName,
628
+ text: payload.command,
629
+ attachments: [],
630
+ isBot: false,
631
+ });
632
+ const commandBot = this.createSlashCommandBot(conversationId);
633
+ await this.handler.handleNew(conversationId, conversationId, commandBot);
634
+ }
635
+ async routeSlashSessionCommand(payload) {
636
+ const conversationId = payload.channel_id;
637
+ const isDirectMessage = conversationId.startsWith("D");
638
+ const createdAt = new Date();
639
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
640
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
641
+ const commandText = payload.command;
642
+ this.logToFile(conversationId, {
643
+ date: createdAt.toISOString(),
644
+ ts: eventTs,
645
+ user: payload.user_id,
646
+ userName,
647
+ text: commandText,
648
+ attachments: [],
649
+ isBot: false,
650
+ });
651
+ const sessionKey = conversationId;
652
+ const event = {
653
+ type: "dm",
654
+ conversationId,
655
+ conversationKind: isDirectMessage ? "direct" : "shared",
656
+ ts: eventTs,
657
+ user: payload.user_id,
658
+ text: commandText,
659
+ attachments: [],
660
+ sessionKey,
661
+ };
662
+ const adapters = this.createPrivateSessionCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: conversationId });
663
+ await this.handler.handleEvent(event, this, adapters, false);
440
664
  }
441
665
  setupEventHandlers() {
442
666
  // Channel @mentions
@@ -449,9 +673,11 @@ export class SlackBot {
449
673
  }
450
674
  // Top-level mentions use a persistent channel session.
451
675
  // Thread replies get their own isolated session (channelId:thread_ts).
452
- const sessionKey = e.thread_ts ? `${e.channel}:${e.thread_ts}` : e.channel;
676
+ const sessionKey = resolveSlackSessionKey(e.channel, e.thread_ts);
453
677
  const slackEvent = {
454
678
  type: "mention",
679
+ conversationId: e.channel,
680
+ conversationKind: "shared",
455
681
  channel: e.channel,
456
682
  ts: e.ts,
457
683
  thread_ts: e.thread_ts,
@@ -460,12 +686,13 @@ export class SlackBot {
460
686
  files: e.files,
461
687
  sessionKey,
462
688
  };
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);
689
+ const attachmentsPromise = this.logUserMessage(slackEvent);
466
690
  // Only trigger processing for messages AFTER startup (not replayed old messages)
467
691
  if (this.startupTs && e.ts < this.startupTs) {
468
692
  log.logInfo(`[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`);
693
+ void attachmentsPromise.catch((err) => {
694
+ log.logWarning("Failed to log Slack message", String(err));
695
+ });
469
696
  ack();
470
697
  return;
471
698
  }
@@ -478,25 +705,17 @@ export class SlackBot {
478
705
  else {
479
706
  this.postMessage(e.channel, formatNothingRunning("slack"));
480
707
  }
708
+ void attachmentsPromise.catch((err) => {
709
+ log.logWarning("Failed to log Slack message", String(err));
710
+ });
481
711
  ack();
482
712
  return;
483
713
  }
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
- }
714
+ this.getQueue(this.resolveQueueKey(e.channel, sessionKey)).enqueue(async () => {
715
+ slackEvent.attachments = await attachmentsPromise;
716
+ const adapters = createSlackAdapters(slackEvent, this, false);
717
+ return this.handler.handleEvent(slackEvent, this, adapters, false);
718
+ });
500
719
  ack();
501
720
  });
502
721
  // All messages (for logging) + DMs (for triggering)
@@ -516,28 +735,34 @@ export class SlackBot {
516
735
  return;
517
736
  }
518
737
  const isDM = e.channel_type === "im";
738
+ const conversationKind = isDM ? "direct" : "shared";
519
739
  const isBotMention = e.text?.includes(`<@${this.botUserId}>`);
520
740
  // Skip channel @mentions - already handled by app_mention event
521
741
  if (!isDM && isBotMention) {
522
742
  ack();
523
743
  return;
524
744
  }
745
+ const isSharedThreadReply = !isDM && !!e.thread_ts;
746
+ const sessionKey = isDM || isSharedThreadReply ? resolveSlackSessionKey(e.channel, e.thread_ts) : undefined;
525
747
  const slackEvent = {
526
748
  type: isDM ? "dm" : "mention",
749
+ conversationId: e.channel,
750
+ conversationKind,
527
751
  channel: e.channel,
528
752
  ts: e.ts,
529
753
  thread_ts: e.thread_ts,
530
754
  user: e.user,
531
755
  text: (e.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim(),
532
756
  files: e.files,
533
- sessionKey: isDM ? e.channel : undefined,
757
+ sessionKey,
534
758
  };
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);
759
+ const attachmentsPromise = this.logUserMessage(slackEvent);
538
760
  // Only trigger processing for messages AFTER startup (not replayed old messages)
539
761
  if (this.startupTs && e.ts < this.startupTs) {
540
762
  log.logInfo(`[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`);
763
+ void attachmentsPromise.catch((err) => {
764
+ log.logWarning("Failed to log Slack message", String(err));
765
+ });
541
766
  ack();
542
767
  return;
543
768
  }
@@ -551,41 +776,79 @@ export class SlackBot {
551
776
  else {
552
777
  this.postMessage(e.channel, formatNothingRunning("slack"));
553
778
  }
779
+ void attachmentsPromise.catch((err) => {
780
+ log.logWarning("Failed to log Slack message", String(err));
781
+ });
554
782
  ack();
555
783
  return;
556
784
  }
557
- // Only trigger handler for DMs
558
- if (isDM) {
559
- const dmSessionKey = slackEvent.sessionKey;
785
+ // Trigger handler for DMs and bare replies inside shared-channel threads.
786
+ if (isDM || isSharedThreadReply) {
787
+ const activeSessionKey = slackEvent.sessionKey ?? resolveSlackSessionKey(e.channel, e.thread_ts);
560
788
  // Check for stop command - execute immediately, don't queue!
561
789
  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
790
+ const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);
791
+ if (stopTarget) {
792
+ this.handler.handleStop(stopTarget, e.channel, this); // Don't await, don't queue
564
793
  }
565
794
  else {
566
795
  this.postMessage(e.channel, formatNothingRunning("slack"));
567
796
  }
797
+ void attachmentsPromise.catch((err) => {
798
+ log.logWarning("Failed to log Slack message", String(err));
799
+ });
568
800
  ack();
569
801
  return;
570
802
  }
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
- }
803
+ this.getQueue(this.resolveQueueKey(e.channel, activeSessionKey)).enqueue(async () => {
804
+ slackEvent.attachments = await attachmentsPromise;
805
+ const adapters = createSlackAdapters(slackEvent, this, false);
806
+ return this.handler.handleEvent(slackEvent, this, adapters, false);
807
+ });
808
+ }
809
+ else {
810
+ void attachmentsPromise.catch((err) => {
811
+ log.logWarning("Failed to log Slack message", String(err));
812
+ });
586
813
  }
587
814
  ack();
588
815
  });
816
+ this.socketClient.on("slash_commands", async ({ body, ack }) => {
817
+ const payload = body;
818
+ await ack();
819
+ if (!payload.command || !payload.channel_id || !payload.user_id) {
820
+ return;
821
+ }
822
+ const handlerPromise = payload.command === "/pi-login"
823
+ ? this.routeSlashLoginCommand({
824
+ command: payload.command,
825
+ text: payload.text,
826
+ channel_id: payload.channel_id,
827
+ user_id: payload.user_id,
828
+ user_name: payload.user_name,
829
+ })
830
+ : payload.command === "/pi-new"
831
+ ? this.routeSlashNewCommand({
832
+ command: payload.command,
833
+ channel_id: payload.channel_id,
834
+ user_id: payload.user_id,
835
+ user_name: payload.user_name,
836
+ })
837
+ : payload.command === "/pi-session"
838
+ ? this.routeSlashSessionCommand({
839
+ command: payload.command,
840
+ channel_id: payload.channel_id,
841
+ user_id: payload.user_id,
842
+ user_name: payload.user_name,
843
+ })
844
+ : null;
845
+ if (!handlerPromise) {
846
+ return;
847
+ }
848
+ handlerPromise.catch((err) => {
849
+ log.logWarning("Slack slash command error", err instanceof Error ? err.message : String(err));
850
+ });
851
+ });
589
852
  // App Home tab
590
853
  this.socketClient.on("app_home_opened", ({ event, ack }) => {
591
854
  const e = event;
@@ -616,8 +879,7 @@ export class SlackBot {
616
879
  // Use handler's forceStop method
617
880
  this.handler.forceStop(sessionKey);
618
881
  // Notify in channel
619
- const actorLabel = userId ? `<@${userId}>` : "someone";
620
- await this.postMessage(channelId, formatForceStopped("slack", actorLabel));
882
+ await this.postMessage(channelId, formatForceStopped("slack", userId ?? "unknown"));
621
883
  // Refresh home tab
622
884
  if (userId) {
623
885
  this.webClient.views
@@ -632,14 +894,12 @@ export class SlackBot {
632
894
  });
633
895
  }
634
896
  /**
635
- * Log a user message to log.jsonl (SYNC)
636
- * Downloads attachments in background via store
897
+ * Log a user message to log.jsonl after attachments are ready.
637
898
  */
638
- logUserMessage(event) {
899
+ async logUserMessage(event) {
639
900
  const user = this.users.get(event.user);
640
- // Process attachments - queues downloads in background
641
901
  const attachments = event.files
642
- ? this.store.processAttachments(event.channel, event.files, event.ts)
902
+ ? await this.store.processAttachments(event.channel, event.files, event.ts)
643
903
  : [];
644
904
  this.logToFile(event.channel, {
645
905
  date: new Date(parseFloat(event.ts) * 1000).toISOString(),
@@ -724,13 +984,13 @@ export class SlackBot {
724
984
  const user = this.users.get(msg.user);
725
985
  // Strip @mentions from text (same as live messages)
726
986
  const text = (msg.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim();
727
- // Process attachments - queues downloads in background
728
987
  const attachments = msg.files
729
- ? this.store.processAttachments(channelId, msg.files, msg.ts)
988
+ ? await this.store.processAttachments(channelId, msg.files, msg.ts)
730
989
  : [];
731
990
  this.logToFile(channelId, {
732
991
  date: new Date(parseFloat(msg.ts) * 1000).toISOString(),
733
992
  ts: msg.ts,
993
+ threadTs: msg.thread_ts,
734
994
  user: isMamaMessage ? "bot" : msg.user,
735
995
  userName: isMamaMessage ? undefined : user?.userName,
736
996
  displayName: isMamaMessage ? undefined : user?.displayName,