@geminixiang/mikan 0.2.2 → 0.3.0

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 (78) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +1 -1
  3. package/dist/adapters/slack/bot.d.ts +1 -1
  4. package/dist/adapters/slack/bot.d.ts.map +1 -1
  5. package/dist/adapters/slack/bot.js +13 -10
  6. package/dist/adapters/slack/bot.js.map +1 -1
  7. package/dist/adapters/slack/session.d.ts +5 -5
  8. package/dist/adapters/slack/session.d.ts.map +1 -1
  9. package/dist/adapters/slack/session.js +7 -9
  10. package/dist/adapters/slack/session.js.map +1 -1
  11. package/dist/adapters/slack/thread-manager.d.ts +20 -0
  12. package/dist/adapters/slack/thread-manager.d.ts.map +1 -0
  13. package/dist/adapters/slack/thread-manager.js +14 -0
  14. package/dist/adapters/slack/thread-manager.js.map +1 -0
  15. package/dist/agent.d.ts +1 -0
  16. package/dist/agent.d.ts.map +1 -1
  17. package/dist/agent.js +16 -4
  18. package/dist/agent.js.map +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/login/portal.d.ts +1 -1
  24. package/dist/login/portal.d.ts.map +1 -1
  25. package/dist/login/portal.js.map +1 -1
  26. package/dist/login/{session.d.ts → store.d.ts} +1 -1
  27. package/dist/login/store.d.ts.map +1 -0
  28. package/dist/login/{session.js → store.js} +1 -1
  29. package/dist/login/store.js.map +1 -0
  30. package/dist/main.d.ts.map +1 -1
  31. package/dist/main.js +1 -1
  32. package/dist/main.js.map +1 -1
  33. package/dist/provisioner.d.ts +2 -0
  34. package/dist/provisioner.d.ts.map +1 -1
  35. package/dist/provisioner.js +16 -0
  36. package/dist/provisioner.js.map +1 -1
  37. package/dist/runtime/conversation-orchestrator.d.ts +5 -1
  38. package/dist/runtime/conversation-orchestrator.d.ts.map +1 -1
  39. package/dist/runtime/conversation-orchestrator.js +9 -10
  40. package/dist/runtime/conversation-orchestrator.js.map +1 -1
  41. package/dist/runtime/session-runtime.d.ts +0 -1
  42. package/dist/runtime/session-runtime.d.ts.map +1 -1
  43. package/dist/runtime/session-runtime.js +14 -15
  44. package/dist/runtime/session-runtime.js.map +1 -1
  45. package/dist/session-view/portal.d.ts.map +1 -1
  46. package/dist/session-view/portal.js +15 -15
  47. package/dist/session-view/portal.js.map +1 -1
  48. package/dist/session-view/service.d.ts +3 -3
  49. package/dist/session-view/service.d.ts.map +1 -1
  50. package/dist/session-view/service.js +128 -28
  51. package/dist/session-view/service.js.map +1 -1
  52. package/dist/sessions/chat-session-manager.d.ts +62 -0
  53. package/dist/sessions/chat-session-manager.d.ts.map +1 -0
  54. package/dist/sessions/chat-session-manager.js +439 -0
  55. package/dist/sessions/chat-session-manager.js.map +1 -0
  56. package/dist/sessions/store.d.ts +2 -22
  57. package/dist/sessions/store.d.ts.map +1 -1
  58. package/dist/sessions/store.js +31 -158
  59. package/dist/sessions/store.js.map +1 -1
  60. package/dist/tools/index.d.ts +10 -2
  61. package/dist/tools/index.d.ts.map +1 -1
  62. package/dist/tools/index.js +5 -1
  63. package/dist/tools/index.js.map +1 -1
  64. package/dist/tools/sandbox.d.ts +22 -0
  65. package/dist/tools/sandbox.d.ts.map +1 -0
  66. package/dist/tools/sandbox.js +73 -0
  67. package/dist/tools/sandbox.js.map +1 -0
  68. package/package.json +1 -1
  69. package/dist/adapters/slack/branch-manager.d.ts +0 -28
  70. package/dist/adapters/slack/branch-manager.d.ts.map +0 -1
  71. package/dist/adapters/slack/branch-manager.js +0 -117
  72. package/dist/adapters/slack/branch-manager.js.map +0 -1
  73. package/dist/conversation-history.d.ts +0 -16
  74. package/dist/conversation-history.d.ts.map +0 -1
  75. package/dist/conversation-history.js +0 -144
  76. package/dist/conversation-history.js.map +0 -1
  77. package/dist/login/session.d.ts.map +0 -1
  78. package/dist/login/session.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,52 @@ any release.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.3.0] - 2026-06-01
13
+
14
+ ### Added
15
+
16
+ - Add source directory README guides for contributors navigating adapters, commands, runtime, sessions, sandbox, tools, login, admin, and session-view code.
17
+
18
+ ### Changed
19
+
20
+ - Centralize chat session bootstrapping through `ChatSessionManager` so Slack top-level and thread sessions share the same log-based history sync path.
21
+ - Rename Slack branch/fork terminology to thread/anchor terminology across the adapter, session viewer, and documentation.
22
+ - Model session-view related sessions as parent/thread relationships, including fixed-path thread sessions without legacy parent metadata.
23
+ - Remove legacy thread fork and snapshot helpers from session storage.
24
+
25
+ ### Fixed
26
+
27
+ - Anchor session-view thread links on the root turn instead of earlier bootstrapped context messages.
28
+
29
+ ## [0.3.0-beta.0]
30
+
31
+ ### Changed
32
+
33
+ - Centralize chat session bootstrapping through `ChatSessionManager` so Slack top-level and thread sessions share the same log-based history sync path.
34
+ - Rename Slack branch/fork terminology to thread/anchor terminology across the adapter, session viewer, and documentation.
35
+ - Model session-view related sessions as parent/thread relationships, including fixed-path thread sessions without legacy parent metadata.
36
+ - Remove legacy thread fork and snapshot helpers from session storage.
37
+
38
+ ### Fixed
39
+
40
+ - Anchor session-view thread links on the root turn instead of earlier bootstrapped context messages.
41
+
42
+ ## [0.2.4] - 2026-06-01
43
+
44
+ ### Fixed
45
+
46
+ - Make Slack bot-rooted thread session seeds loadable by storing platform-history assistant metadata.
47
+
48
+ ## [0.2.3] - 2026-06-01
49
+
50
+ ### Added
51
+
52
+ - Add a sandbox resource limit tool for checking and temporarily updating managed sandbox CPU and memory limits.
53
+
54
+ ### Fixed
55
+
56
+ - Isolate Slack sessions rooted at bot messages so thread replies do not inherit unrelated channel history.
57
+
12
58
  ## [0.2.2] - 2026-05-30
13
59
 
14
60
  ### Added
package/README.md CHANGED
@@ -16,7 +16,7 @@ mikan keeps the chat record, agent session, and execution runtime separate:
16
16
  ![mikan architecture](docs/assets/architecture.png)
17
17
 
18
18
  - **Chat / conversation data** is the platform-facing record: `log.jsonl`, attachments, and conversation files.
19
- - **Session orchestration** turns platform events into agent runs, handles top-level/thread/fork scopes, and persists pi-coding-agent structured context under `sessions/*.jsonl`.
19
+ - **Session orchestration** turns platform events into agent runs, handles top-level/thread scopes, and persists pi-coding-agent structured context under `sessions/*.jsonl`.
20
20
  - **pi-coding-agent harness** runs the model loop and calls mikan tools.
21
21
  - **Sandbox runtime** is where tool commands execute: host, Docker container/image, Firecracker, or Cloudflare bridge.
22
22
  - **Vault** provides runtime credentials as env vars and mounted secret files.
@@ -114,7 +114,7 @@ export declare class SlackBot implements Bot {
114
114
  enqueueEvent(event: BotEvent): boolean;
115
115
  private getQueue;
116
116
  private resolveQueueKey;
117
- private hasKnownForkSession;
117
+ private hasKnownThreadSession;
118
118
  private shouldTriggerSharedThreadReply;
119
119
  private buildHomeView;
120
120
  private resolveStopTarget;
@@ -1 +1 @@
1
- {"version":3,"file":"bot.d.ts","sourceRoot":"","sources":["../../../src/adapters/slack/bot.ts"],"names":[],"mappings":"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","sourcesContent":["import { SocketModeClient } from \"@slack/socket-mode\";\nimport type { KnownBlock } from \"@slack/types\";\nimport { WebClient } from \"@slack/web-api\";\nimport { existsSync, readFileSync } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { basename, join } from \"path\";\nimport type {\n Bot,\n BotAdapters,\n BotEvent,\n BotHandler,\n ChatMessage,\n ChatResponseContext,\n ChatToolResult,\n ConversationKind,\n PlatformInfo,\n} from \"../../adapter.js\";\nimport type { EventsWatcher } from \"../../events.js\";\nimport * as log from \"../../log.js\";\nimport type { Attachment, ChannelStore } from \"../../store.js\";\nimport { PRODUCT_NAME, formatForceStopped, formatNothingRunning } from \"../../ui-copy.js\";\nimport {\n appendBotResponseLog,\n appendChannelLog,\n ChannelQueue,\n resolveOnlyScopedStopTarget,\n resolveStopTarget,\n withRetry,\n} from \"../shared.js\";\nimport { evaluateAutoReplyPolicy } from \"../../trigger.js\";\nimport { createSlackAdapters } from \"./context.js\";\nimport { hasMaterializedSlackBranchSession, registerSlackForkSession } from \"./branch-manager.js\";\nimport {\n isSlackForkSessionKey,\n planSlackAdapterSession,\n planSlackEventForkRun,\n resolveSlackSessionKey,\n} from \"./session.js\";\nimport { reportUserFacingError } from \"../../sentry.js\";\n\nconst SLACK_EVENT_ANCHOR_TEXT = \"Working on it...\";\n\n// Slack WebClient errors carry either `code: \"rate_limited\"` (retry-after) or\n// the legacy `data.error === \"rate_limited\"` / 429 status shape.\nfunction slackIsRateLimited(err: Error): boolean {\n if ((err as { code?: unknown }).code === \"rate_limited\") return true;\n const data = (err as { data?: { error?: string; response?: { status?: number } } }).data;\n return data?.error === \"rate_limited\" || data?.response?.status === 429;\n}\n\nconst slackRetry = <T>(fn: () => Promise<T>): Promise<T> =>\n withRetry(fn, { isRateLimited: slackIsRateLimited });\n\nfunction collectSlackText(value: unknown, parts: string[]): void {\n if (value === null || value === undefined) return;\n if (typeof value === \"string\") {\n const trimmed = value.trim();\n if (trimmed) parts.push(trimmed);\n return;\n }\n if (Array.isArray(value)) {\n for (const item of value) collectSlackText(item, parts);\n return;\n }\n if (typeof value !== \"object\") return;\n\n const obj = value as Record<string, unknown>;\n for (const key of [\"text\", \"fallback\", \"title\", \"value\"] as const) {\n collectSlackText(obj[key], parts);\n }\n collectSlackText(obj.fields, parts);\n collectSlackText(obj.elements, parts);\n collectSlackText(obj.blocks, parts);\n}\n\nfunction buildSlackAppMessageText(event: {\n text?: string;\n blocks?: unknown[];\n attachments?: unknown[];\n}): string {\n const parts: string[] = [];\n collectSlackText(event.text, parts);\n collectSlackText(event.blocks, parts);\n collectSlackText(event.attachments, parts);\n const deduped = parts.filter((part, index) => parts.indexOf(part) === index);\n return deduped.join(\"\\n\");\n}\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface SlackEvent {\n type: \"mention\" | \"dm\";\n conversationId: string;\n conversationKind: ConversationKind;\n channel: string;\n ts: string;\n thread_ts?: string;\n user: string;\n text: string;\n files?: Array<{ name?: string; url_private_download?: string; url_private?: string }>;\n /** Processed attachments with local paths (populated after logUserMessage) */\n attachments?: Attachment[];\n /** Session key passed through to BotEvent so handleEvent uses the correct persistent session */\n sessionKey?: string;\n}\n\nexport interface SlackUser {\n id: string;\n userName: string;\n displayName: string;\n}\n\nexport interface SlackChannel {\n id: string;\n name: string;\n}\n\n// Types used by agent.ts\nexport interface ChannelInfo {\n id: string;\n name: string;\n}\n\nexport interface UserInfo {\n id: string;\n userName: string;\n displayName: string;\n}\n\nexport interface SlackContext {\n message: {\n text: string;\n rawText: string;\n user: string;\n userName?: string;\n channel: string;\n ts: string;\n attachments: Array<{ localPath: string }>;\n };\n channelName?: string;\n channels: ChannelInfo[];\n users: UserInfo[];\n respond: (text: string, shouldLog?: boolean) => Promise<void>;\n replaceMessage: (text: string) => Promise<void>;\n respondDiagnostic: (text: string) => Promise<void>;\n setTyping: (isTyping: boolean) => Promise<void>;\n uploadFile: (filePath: string, title?: string) => Promise<void>;\n setWorking: (working: boolean) => Promise<void>;\n deleteMessage: () => Promise<void>;\n}\n\n// ============================================================================\n// SlackBot\n// ============================================================================\n\nexport class SlackBot implements Bot {\n private socketClient: SocketModeClient;\n private webClient: WebClient;\n private handler: BotHandler;\n private workingDir: string;\n private store: ChannelStore;\n private botUserId: string | null = null;\n private botId: string | null = null;\n private ownMentionRegex: RegExp | null = null;\n private startupTs: string | null = null; // Messages older than this are just logged, not processed\n\n private users = new Map<string, SlackUser>();\n private channels = new Map<string, SlackChannel>();\n private queues = new Map<string, ChannelQueue>();\n private eventsWatcher: EventsWatcher | null = null;\n\n constructor(\n handler: BotHandler,\n config: { appToken: string; botToken: string; workingDir: string; store: ChannelStore },\n ) {\n this.handler = handler;\n this.workingDir = config.workingDir;\n this.store = config.store;\n this.socketClient = new SocketModeClient({\n appToken: config.appToken,\n // Default 5s is too tight: brief event-loop stalls (e.g. backfill, sync fs)\n // cause false pong timeouts; 4 in a row makes Slack drop the socket.\n clientPingTimeout: 12_000,\n });\n this.webClient = new WebClient(config.botToken);\n }\n\n setEventsWatcher(watcher: EventsWatcher): void {\n this.eventsWatcher = watcher;\n }\n\n // ==========================================================================\n // Public API\n // ==========================================================================\n\n async start(): Promise<void> {\n const auth = await this.webClient.auth.test();\n this.botUserId = auth.user_id as string;\n this.botId = typeof auth.bot_id === \"string\" ? auth.bot_id : null;\n\n await Promise.all([this.fetchUsers(), this.fetchChannels()]);\n log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);\n\n // Record startup time before opening the socket. Slack may replay older events;\n // those should be logged but not processed. Backfill runs in the background up\n // to this timestamp so startup is not blocked by one history call per channel.\n this.startupTs = (Date.now() / 1000).toFixed(6);\n\n this.setupEventHandlers();\n await this.socketClient.start();\n\n log.logConnected(\"Slack\");\n\n void this.backfillAllChannels(this.startupTs).catch((error) => {\n log.logWarning(\"Slack backfill failed\", String(error));\n });\n }\n\n getUser(userId: string): SlackUser | undefined {\n return this.users.get(userId);\n }\n\n getChannel(channelId: string): SlackChannel | undefined {\n return this.channels.get(channelId);\n }\n\n getAllUsers(): SlackUser[] {\n return Array.from(this.users.values());\n }\n\n getAllChannels(): SlackChannel[] {\n return Array.from(this.channels.values());\n }\n\n private stripOwnMention(text: string | undefined): string {\n const source = text ?? \"\";\n if (!this.botUserId) return source.trim();\n if (!this.ownMentionRegex || !this.ownMentionRegex.source.includes(this.botUserId)) {\n this.ownMentionRegex = new RegExp(`<@${this.botUserId}>`, \"gi\");\n }\n return source.replace(this.ownMentionRegex, \"\").trim();\n }\n\n async postMessage(channel: string, text: string): Promise<string> {\n return slackRetry(async () => {\n const result = await this.webClient.chat.postMessage({ channel, text });\n return result.ts as string;\n });\n }\n\n async postEphemeral(\n channel: string,\n user: string,\n text: string,\n threadTs?: string,\n ): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.chat.postEphemeral({\n channel,\n user,\n text,\n ...(threadTs ? { thread_ts: threadTs } : {}),\n });\n });\n }\n\n async postEphemeralBlocks(\n channel: string,\n user: string,\n text: string,\n blocks: object[],\n threadTs?: string,\n ): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.chat.postEphemeral({\n channel,\n user,\n text,\n blocks: blocks as KnownBlock[],\n ...(threadTs ? { thread_ts: threadTs } : {}),\n });\n });\n }\n\n async postMessageBlocks(channel: string, text: string, blocks: object[]): Promise<string> {\n return slackRetry(async () => {\n const result = await this.webClient.chat.postMessage({\n channel,\n text,\n blocks: blocks as KnownBlock[],\n });\n return result.ts as string;\n });\n }\n\n async postPrivate(conversationId: string, userId: string, text: string): Promise<void> {\n await this.postEphemeral(conversationId, userId, text);\n }\n\n async postPrivateDiagnostic(\n conversationId: string,\n userId: string,\n text: string,\n options?: { style?: \"muted\" | \"error\" },\n ): Promise<void> {\n if (options?.style !== \"muted\") {\n await this.postPrivate(\n conversationId,\n userId,\n options?.style === \"error\" ? `_${text}_` : text,\n );\n return;\n }\n const CONTEXT_TEXT_LIMIT = 3000;\n const blockText =\n text.length > CONTEXT_TEXT_LIMIT\n ? text.substring(0, CONTEXT_TEXT_LIMIT - 20) + \"\\n_(truncated)_\"\n : text;\n await this.postEphemeralBlocks(conversationId, userId, text, [\n { type: \"context\", elements: [{ type: \"mrkdwn\", text: blockText }] },\n ]);\n }\n\n async openDirectMessage(userId: string): Promise<string> {\n return slackRetry(async () => {\n const result = await this.webClient.conversations.open({ users: userId });\n const channelId = result.channel?.id;\n if (!channelId) {\n throw new Error(`Failed to open DM for user ${userId}`);\n }\n return channelId;\n });\n }\n\n async updateMessage(channel: string, ts: string, text: string): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.chat.update({ channel, ts, text });\n });\n }\n\n async deleteMessage(channel: string, ts: string): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.chat.delete({ channel, ts });\n });\n }\n\n // ==========================================================================\n // Slack Assistant API (AI assistant experience)\n // ==========================================================================\n\n /** Set the status for an assistant thread (shows \"thinking\" state) */\n async setAssistantStatus(channel: string, threadTs: string, status: string): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.assistant.threads.setStatus({\n channel_id: channel,\n thread_ts: threadTs,\n status,\n });\n });\n }\n\n async postInThread(channel: string, threadTs: string, text: string): Promise<string> {\n return slackRetry(async () => {\n // Use Block Kit section for long messages to trigger Slack's \"Show more\" collapsing (~700 chars)\n const SECTION_TEXT_LIMIT = 3000;\n if (text.length > 500) {\n const blockText =\n text.length > SECTION_TEXT_LIMIT\n ? text.substring(0, SECTION_TEXT_LIMIT - 20) + \"\\n_(truncated)_\"\n : text;\n const result = await this.webClient.chat.postMessage({\n channel,\n thread_ts: threadTs,\n text, // full text as notification fallback\n blocks: [{ type: \"section\", text: { type: \"mrkdwn\", text: blockText } }],\n });\n return result.ts as string;\n }\n const result = await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });\n return result.ts as string;\n });\n }\n\n async postInThreadBlocks(\n channel: string,\n threadTs: string,\n text: string,\n blocks: object[],\n ): Promise<string> {\n return slackRetry(async () => {\n const result = await this.webClient.chat.postMessage({\n channel,\n thread_ts: threadTs,\n text, // fallback for notifications\n blocks: blocks as KnownBlock[],\n });\n return result.ts as string;\n });\n }\n\n async uploadFile(\n channel: string,\n filePath: string,\n title?: string,\n threadTs?: string,\n ): Promise<void> {\n return slackRetry(async () => {\n const fileName = title || basename(filePath);\n const fileContent = readFileSync(filePath);\n await this.webClient.files.uploadV2({\n channel_id: channel,\n file: fileContent,\n filename: fileName,\n title: fileName,\n ...(threadTs ? { thread_ts: threadTs } : {}),\n } as Parameters<typeof this.webClient.files.uploadV2>[0]);\n });\n }\n\n logToFile(channel: string, entry: object): void {\n appendChannelLog(this.workingDir, channel, entry);\n }\n\n logBotResponse(channel: string, text: string, ts: string, threadTs?: string): void {\n appendBotResponseLog(this.workingDir, channel, text, ts, threadTs);\n }\n\n getPlatformInfo(): PlatformInfo {\n return {\n name: \"slack\",\n formattingGuide:\n \"## Slack Formatting (mrkdwn, NOT Markdown)\\nBold: *text*, Italic: _text_, Code: `code`, Block: ```code```, Links: <url|text>\\nDo NOT use **double asterisks** or [markdown](links).\",\n channels: this.getAllChannels().map((c) => ({ id: c.id, name: c.name })),\n users: this.getAllUsers().map((u) => ({\n id: u.id,\n userName: u.userName,\n displayName: u.displayName,\n })),\n diagnostics: {\n showUsageSummary: true,\n },\n };\n }\n\n // ==========================================================================\n // Events Integration\n // ==========================================================================\n\n /**\n * Enqueue an event for processing. Always queues (no \"already working\" rejection).\n * Returns true if enqueued, false if queue is full (max 5).\n */\n enqueueEvent(event: BotEvent): boolean {\n const conversationId = event.conversationId;\n const queue = this.getQueue(conversationId);\n if (queue.size() >= 5) {\n log.logWarning(\n `Event queue full for ${conversationId}, discarding: ${event.text.substring(0, 50)}`,\n );\n return false;\n }\n log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);\n queue.enqueue(async () => {\n let anchorTs: string | undefined;\n if (!event.thread_ts) {\n try {\n anchorTs = await this.postMessage(conversationId, SLACK_EVENT_ANCHOR_TEXT);\n } catch (err) {\n log.logWarning(\n `Failed to post Slack event anchor for ${conversationId}`,\n err instanceof Error ? err.message : String(err),\n );\n reportUserFacingError(err, {\n domain: \"events\",\n surface: \"event_delivery\",\n operation: \"slack_anchor_post\",\n severity: \"error\",\n platform: \"slack\",\n context: {\n conversationId,\n conversationKind: event.conversationKind,\n eventTs: event.ts,\n textLength: event.text.length,\n },\n });\n throw err;\n }\n }\n const eventPlan = planSlackEventForkRun(event, anchorTs);\n const eventForRun = eventPlan.event;\n if (eventPlan.initialMessageTs && eventForRun.sessionKey) {\n registerSlackForkSession({\n conversationDir: join(this.workingDir, conversationId),\n sessionKey: eventForRun.sessionKey,\n });\n }\n\n const runQueueKey = planSlackAdapterSession(eventForRun, {\n initialMessageTs: eventPlan.initialMessageTs,\n }).sessionKey;\n this.getQueue(runQueueKey).enqueue(async () => {\n const slackEvent: SlackEvent = {\n type: eventForRun.type as SlackEvent[\"type\"],\n conversationId,\n conversationKind: eventForRun.conversationKind,\n channel: conversationId,\n ts: eventForRun.ts,\n thread_ts: eventForRun.thread_ts,\n user: eventForRun.user,\n text: eventForRun.text,\n attachments: eventForRun.attachments?.map((attachment) => ({\n original: attachment.name,\n localPath: attachment.localPath,\n })),\n sessionKey: eventForRun.sessionKey,\n };\n const adapters = createSlackAdapters(slackEvent, this, {\n initialMessageTs: eventPlan.initialMessageTs,\n });\n return this.handler.handleEvent(eventForRun, this, adapters);\n });\n });\n return true;\n }\n\n // ==========================================================================\n // Private - Event Handlers\n // ==========================================================================\n\n private getQueue(channelId: string): ChannelQueue {\n let queue = this.queues.get(channelId);\n if (!queue) {\n queue = new ChannelQueue(\"Slack\");\n this.queues.set(channelId, queue);\n }\n return queue;\n }\n\n private resolveQueueKey(conversationId: string, sessionKey: string): string {\n if (!isSlackForkSessionKey(sessionKey)) return sessionKey;\n if (this.handler.isRunning(sessionKey)) return sessionKey;\n return this.hasKnownForkSession(conversationId, sessionKey) ? sessionKey : conversationId;\n }\n\n private hasKnownForkSession(conversationId: string, sessionKey: string): boolean {\n return hasMaterializedSlackBranchSession(join(this.workingDir, conversationId), sessionKey);\n }\n\n private shouldTriggerSharedThreadReply(channelId: string, threadTs?: string): boolean {\n if (!threadTs) return false;\n\n const sessionKey = resolveSlackSessionKey(channelId, threadTs);\n if (this.handler.isRunning(sessionKey)) return true;\n\n return this.hasKnownForkSession(channelId, sessionKey);\n }\n\n private buildHomeView(): { type: \"home\"; blocks: KnownBlock[] } {\n const blocks: object[] = [\n {\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `*${PRODUCT_NAME}*\\nStart a new task or check on running work.`,\n },\n accessory: {\n type: \"image\",\n image_url: \"https://media1.tenor.com/m/lfDATg4Bhc0AAAAC/happy-cat.gif\",\n alt_text: PRODUCT_NAME,\n },\n },\n ];\n\n // --- Running tasks ---\n const runningSessions = this.handler.getRunningSessions();\n\n blocks.push(\n { type: \"divider\" },\n {\n type: \"header\",\n text: {\n type: \"plain_text\",\n text: `Running Tasks (${runningSessions.length})`,\n emoji: true,\n },\n },\n );\n\n if (runningSessions.length === 0) {\n blocks.push({\n type: \"context\",\n elements: [{ type: \"mrkdwn\", text: \"_No tasks running right now._\" }],\n });\n } else {\n // Threshold for \"stuck\" detection (10 minutes)\n const STUCK_THRESHOLD_MS = 10 * 60 * 1000;\n\n for (const session of runningSessions) {\n const channelId = session.sessionKey.split(\":\")[0];\n const channel = this.channels.get(channelId);\n const channelName = channel ? `#${channel.name}` : channelId;\n const elapsed = Math.floor((Date.now() - session.startedAt) / 60000);\n const elapsedStr = elapsed < 1 ? \"<1 min\" : `${elapsed} min`;\n\n // Check if task might be stuck\n const lastActivity = session.lastActivityAt ? Date.now() - session.lastActivityAt : 0;\n const isStuck = lastActivity > STUCK_THRESHOLD_MS;\n const statusText = isStuck ? \"_stuck_\" : \"_running_\";\n\n // Build status line: channel · status · time · step\n let statusLine = `${statusText} · ${elapsedStr}`;\n if (session.currentTool) {\n statusLine += ` · ${session.currentTool}`;\n }\n if (isStuck && lastActivity > 0) {\n const inactiveMin = Math.floor(lastActivity / 60000);\n statusLine += ` · idle ${inactiveMin}m`;\n }\n\n // Use context block for gray small text (like \"No scheduled jobs.\")\n blocks.push({\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: `*${channelName}* · ${statusLine}`,\n },\n ],\n });\n\n // Add Force Stop button as separate element if stuck\n if (isStuck) {\n blocks.push({\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: \" \",\n },\n {\n type: \"button\",\n text: { type: \"plain_text\", text: \"Force Stop\", emoji: true },\n action_id: `force_stop_${session.sessionKey.replace(/:/g, \"_\")}`,\n style: \"danger\",\n },\n ],\n });\n }\n }\n }\n\n // --- Cron jobs ---\n const periodicEvents = this.eventsWatcher?.getPeriodicEvents() ?? [];\n\n blocks.push(\n { type: \"divider\" },\n {\n type: \"header\",\n text: {\n type: \"plain_text\",\n text: `Scheduled Jobs (${periodicEvents.length})`,\n emoji: true,\n },\n },\n );\n\n if (periodicEvents.length === 0) {\n blocks.push({\n type: \"context\",\n elements: [{ type: \"mrkdwn\", text: \"_No scheduled jobs._\" }],\n });\n } else {\n for (const ev of periodicEvents) {\n const channelLabel =\n ev.platform === \"slack\"\n ? (() => {\n const channel = this.channels.get(ev.conversationId);\n const channelName = channel ? `#${channel.name}` : ev.conversationId;\n return `${ev.platform}:${channelName}`;\n })()\n : `${ev.platform}:${ev.conversationId}`;\n const nextStr = ev.nextRun\n ? new Date(ev.nextRun).toLocaleString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n })\n : \"—\";\n blocks.push({\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `*${ev.text}*\\n└ \\`${ev.schedule}\\` · ${channelLabel} · Next: ${nextStr}`,\n },\n });\n }\n }\n\n // --- Footer ---\n blocks.push(\n { type: \"divider\" },\n {\n type: \"context\",\n elements: [\n { type: \"mrkdwn\", text: \"💡 @mention in a channel or send a DM to start a new task\" },\n ],\n },\n );\n\n return { type: \"home\", blocks: blocks as KnownBlock[] };\n }\n\n private resolveStopTarget(channelId: string, threadTs?: string): string | null {\n const directTarget = resolveStopTarget({\n handler: this.handler,\n conversationId: channelId,\n sessionKey: resolveSlackSessionKey(channelId, threadTs),\n });\n if (directTarget) return directTarget;\n if (threadTs) return null;\n return resolveOnlyScopedStopTarget(this.handler, channelId);\n }\n\n private isStopText(text: string): boolean {\n const normalized = text.trim().toLowerCase();\n return normalized === \"stop\" || normalized === \"/stop\";\n }\n\n private createCommandAdapters(\n conversationId: string,\n userId: string,\n userName: string | undefined,\n text: string,\n ts: string,\n options: { ephemeralChannelId?: string; threadTs?: string } = {},\n ): BotAdapters {\n const message: ChatMessage = {\n id: ts,\n sessionKey: conversationId,\n conversationKind: options.ephemeralChannelId ? \"shared\" : \"direct\",\n userId,\n userName,\n text,\n attachments: [],\n };\n\n const respond = async (responseText: string) => {\n if (options.ephemeralChannelId) {\n await this.postEphemeral(\n options.ephemeralChannelId,\n userId,\n responseText,\n options.threadTs,\n );\n return;\n }\n const messageTs = await this.postMessage(conversationId, responseText);\n this.logBotResponse(conversationId, responseText, messageTs);\n };\n\n const respondMuted = async (responseText: string) => {\n const CONTEXT_TEXT_LIMIT = 3000;\n const blockText =\n responseText.length > CONTEXT_TEXT_LIMIT\n ? responseText.substring(0, CONTEXT_TEXT_LIMIT - 20) + \"\\n_(truncated)_\"\n : responseText;\n const blocks = [{ type: \"context\", elements: [{ type: \"mrkdwn\", text: blockText }] }];\n if (options.ephemeralChannelId) {\n await this.postEphemeralBlocks(\n options.ephemeralChannelId,\n userId,\n responseText,\n blocks,\n options.threadTs,\n );\n return;\n }\n const messageTs = await this.postMessageBlocks(conversationId, responseText, blocks);\n this.logBotResponse(conversationId, responseText, messageTs);\n };\n\n const responseCtx: ChatResponseContext = {\n respond,\n replaceResponse: respond,\n respondDiagnostic: async (\n responseText: string,\n responseOptions?: { style?: \"muted\" | \"error\" },\n ) => {\n if (responseOptions?.style === \"muted\") {\n await respondMuted(responseText);\n return;\n }\n await respond(responseOptions?.style === \"error\" ? `_${responseText}_` : responseText);\n },\n respondToolResult: async (result: ChatToolResult) => {\n const duration = (result.durationMs / 1000).toFixed(1);\n await respond(\n `${result.isError ? \"Error\" : \"Done\"} ${result.toolName} (${duration}s)\\n${result.result}`,\n );\n },\n setTyping: async () => {},\n setWorking: async () => {},\n uploadFile: async (filePath: string, title?: string) => {\n await this.uploadFile(conversationId, filePath, title);\n },\n deleteResponse: async () => {},\n };\n\n return {\n message,\n responseCtx,\n platform: this.getPlatformInfo(),\n };\n }\n\n private createSlashCommandBot(conversationId: string, threadTs?: string): Bot {\n return {\n start: async () => {},\n postMessage: async (_channel: string, text: string) => {\n if (threadTs) {\n return this.postInThread(conversationId, threadTs, text);\n }\n return this.postMessage(conversationId, text);\n },\n updateMessage: async (channel: string, ts: string, text: string) => {\n await this.updateMessage(channel, ts, text);\n },\n enqueueEvent: (event: BotEvent) => this.enqueueEvent(event),\n getPlatformInfo: () => this.getPlatformInfo(),\n };\n }\n\n private async routeSlashLoginCommand(payload: {\n command: string;\n text?: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n const commandSuffix = payload.text?.trim();\n const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const sourceChannelId = payload.channel_id;\n const isDirectMessage = sourceChannelId.startsWith(\"D\");\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n\n this.logToFile(sourceChannelId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: commandText,\n attachments: [],\n isBot: false,\n });\n\n const event: BotEvent = {\n type: isDirectMessage ? \"dm\" : \"private_command\",\n conversationId: sourceChannelId,\n conversationKind: isDirectMessage ? \"direct\" : \"shared\",\n ts: eventTs,\n user: payload.user_id,\n text: commandText,\n attachments: [],\n sessionKey: sourceChannelId,\n };\n\n const adapters = this.createCommandAdapters(\n sourceChannelId,\n payload.user_id,\n userName,\n commandText,\n eventTs,\n isDirectMessage ? {} : { ephemeralChannelId: sourceChannelId },\n );\n\n await this.handler.handleEvent(event, this, adapters);\n }\n\n private async routeSlashNewCommand(payload: {\n command: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n const conversationId = payload.channel_id;\n if (!conversationId.startsWith(\"D\")) {\n await this.postEphemeral(\n conversationId,\n payload.user_id,\n `為了避免誤清除共享上下文,${payload.command} 目前只能在與 ${PRODUCT_NAME} 的私訊中使用。`,\n );\n return;\n }\n\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n\n this.logToFile(conversationId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: payload.command,\n attachments: [],\n isBot: false,\n });\n\n const commandBot = this.createSlashCommandBot(conversationId);\n await this.handler.handleNewCommand(conversationId, conversationId, commandBot);\n }\n\n private async routeSlashModelCommand(payload: {\n command: string;\n text?: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n const conversationId = payload.channel_id;\n const isDirectMessage = conversationId.startsWith(\"D\");\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n const commandSuffix = payload.text?.trim();\n const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;\n\n this.logToFile(conversationId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: commandText,\n attachments: [],\n isBot: false,\n });\n\n const sessionKey = conversationId;\n const event: BotEvent = {\n type: isDirectMessage ? \"dm\" : \"mention\",\n conversationId,\n conversationKind: isDirectMessage ? \"direct\" : \"shared\",\n ts: eventTs,\n user: payload.user_id,\n text: commandText,\n attachments: [],\n sessionKey,\n };\n\n const adapters = this.createCommandAdapters(\n conversationId,\n payload.user_id,\n userName,\n commandText,\n eventTs,\n isDirectMessage ? {} : { ephemeralChannelId: conversationId },\n );\n\n await this.handler.handleEvent(event, this, adapters);\n }\n\n private async routeSlashSandboxCommand(payload: {\n command: string;\n text?: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n await this.routeSlashModelCommand(payload);\n }\n\n private async routeSlashAutoReplyCommand(payload: {\n command: string;\n text?: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n await this.routeSlashModelCommand(payload);\n }\n\n private async routeSlashSessionCommand(payload: {\n command: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n thread_ts?: string;\n }): Promise<void> {\n const conversationId = payload.channel_id;\n const isDirectMessage = conversationId.startsWith(\"D\");\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n const commandText = payload.command;\n\n this.logToFile(conversationId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: commandText,\n attachments: [],\n isBot: false,\n threadTs: payload.thread_ts,\n });\n\n const sessionKey = resolveSlackSessionKey(conversationId, payload.thread_ts);\n const event: BotEvent = {\n type: isDirectMessage ? \"dm\" : \"mention\",\n conversationId,\n conversationKind: isDirectMessage ? \"direct\" : \"shared\",\n ts: eventTs,\n user: payload.user_id,\n text: commandText,\n attachments: [],\n thread_ts: payload.thread_ts,\n sessionKey,\n };\n\n const adapters = this.createCommandAdapters(\n conversationId,\n payload.user_id,\n userName,\n commandText,\n eventTs,\n isDirectMessage\n ? { threadTs: payload.thread_ts }\n : { ephemeralChannelId: conversationId, threadTs: payload.thread_ts },\n );\n\n await this.handler.handleEvent(event, this, adapters);\n }\n\n private async routeSlashAdminCommand(payload: {\n command: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n thread_ts?: string;\n }): Promise<void> {\n const conversationId = payload.channel_id;\n const isDirectMessage = conversationId.startsWith(\"D\");\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n const commandText = payload.command;\n\n this.logToFile(conversationId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: commandText,\n attachments: [],\n isBot: false,\n threadTs: payload.thread_ts,\n });\n\n const sessionKey = resolveSlackSessionKey(conversationId, payload.thread_ts);\n const event: BotEvent = {\n type: isDirectMessage ? \"dm\" : \"mention\",\n conversationId,\n conversationKind: isDirectMessage ? \"direct\" : \"shared\",\n ts: eventTs,\n user: payload.user_id,\n text: commandText,\n attachments: [],\n thread_ts: payload.thread_ts,\n sessionKey,\n };\n\n const adapters = this.createCommandAdapters(\n conversationId,\n payload.user_id,\n userName,\n commandText,\n eventTs,\n isDirectMessage\n ? { threadTs: payload.thread_ts }\n : { ephemeralChannelId: conversationId, threadTs: payload.thread_ts },\n );\n\n await this.handler.handleEvent(event, this, adapters);\n }\n\n private setupEventHandlers(): void {\n this.socketClient.on(\"disconnect\", (err: unknown) => {\n log.logWarning(\"Slack socket disconnect\", err ? String(err) : \"\");\n });\n this.socketClient.on(\"error\", (err: unknown) => {\n log.logWarning(\"Slack socket error\", err ? String(err) : \"\");\n });\n this.socketClient.on(\"unable_to_socket_mode_start\", (err: unknown) => {\n log.logWarning(\"Slack socket unable_to_start\", err ? String(err) : \"\");\n });\n\n // Channel @mentions\n this.socketClient.on(\"app_mention\", ({ event, ack }) => {\n const e = event as {\n text: string;\n channel: string;\n user: string;\n ts: string;\n thread_ts?: string;\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n };\n\n // Skip DMs (handled by message event)\n if (e.channel.startsWith(\"D\")) {\n ack();\n return;\n }\n\n // Top-level mentions use a persistent channel session.\n // Thread replies get their own isolated session (channelId:thread_ts).\n const sessionKey = resolveSlackSessionKey(e.channel, e.thread_ts);\n\n const slackEvent: SlackEvent = {\n type: \"mention\",\n conversationId: e.channel,\n conversationKind: \"shared\",\n channel: e.channel,\n ts: e.ts,\n thread_ts: e.thread_ts,\n user: e.user,\n text: this.stripOwnMention(e.text),\n files: e.files,\n sessionKey,\n };\n\n const attachmentsPromise = this.logUserMessage(slackEvent);\n\n // Only trigger processing for messages AFTER startup (not replayed old messages)\n if (this.startupTs && e.ts < this.startupTs) {\n log.logInfo(\n `[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`,\n );\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n // Check for stop command - execute immediately, don't queue!\n if (this.isStopText(slackEvent.text)) {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this);\n } else {\n this.postMessage(e.channel, formatNothingRunning(\"slack\"));\n }\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n this.getQueue(this.resolveQueueKey(e.channel, sessionKey)).enqueue(async () => {\n slackEvent.attachments = await attachmentsPromise;\n const adapters = createSlackAdapters(slackEvent, this);\n return this.handler.handleEvent(\n slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n this,\n adapters,\n );\n });\n\n ack();\n });\n\n // All messages (for logging) + DMs (for triggering)\n this.socketClient.on(\"message\", ({ event, ack }) => {\n const e = event as {\n text?: string;\n channel: string;\n user?: string;\n ts: string;\n thread_ts?: string;\n channel_type?: string;\n subtype?: string;\n bot_id?: string;\n app_id?: string;\n username?: string;\n bot_profile?: { id?: string; app_id?: string; name?: string; real_name?: string };\n blocks?: unknown[];\n attachments?: unknown[];\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n };\n\n const hasFiles = !!e.files && e.files.length > 0;\n const hasSlackContent = !!e.text || hasFiles || !!e.blocks?.length || !!e.attachments?.length;\n const isOwnBotMessage =\n (!!e.user && e.user === this.botUserId) || (!!this.botId && e.bot_id === this.botId);\n if (isOwnBotMessage) {\n ack();\n return;\n }\n\n const isExternalBotMessage = !!e.bot_id || e.subtype === \"bot_message\";\n if (isExternalBotMessage) {\n if (e.subtype !== undefined && e.subtype !== \"bot_message\" && e.subtype !== \"file_share\") {\n ack();\n return;\n }\n if (!hasSlackContent) {\n ack();\n return;\n }\n void this.logExternalBotMessage(e).catch((err) => {\n log.logWarning(\"Failed to log Slack bot message\", String(err));\n });\n ack();\n return;\n }\n\n if (!e.user) {\n ack();\n return;\n }\n if (e.subtype !== undefined && e.subtype !== \"file_share\") {\n ack();\n return;\n }\n if (!hasSlackContent) {\n ack();\n return;\n }\n\n const isDM = e.channel_type === \"im\";\n const conversationKind: ConversationKind = isDM ? \"direct\" : \"shared\";\n const isBotMention = e.text?.includes(`<@${this.botUserId}>`);\n\n // Skip channel @mentions - already handled by app_mention event\n if (!isDM && isBotMention) {\n ack();\n return;\n }\n\n const isSharedThreadReply =\n !isDM && this.shouldTriggerSharedThreadReply(e.channel, e.thread_ts);\n const sessionKey =\n isDM || isSharedThreadReply ? resolveSlackSessionKey(e.channel, e.thread_ts) : undefined;\n\n const slackEvent: SlackEvent = {\n type: isDM ? \"dm\" : \"mention\",\n conversationId: e.channel,\n conversationKind,\n channel: e.channel,\n ts: e.ts,\n thread_ts: e.thread_ts,\n user: e.user,\n text: this.stripOwnMention(e.text),\n files: e.files,\n sessionKey,\n };\n\n const attachmentsPromise = this.logUserMessage(slackEvent);\n\n // Only trigger processing for messages AFTER startup (not replayed old messages)\n if (this.startupTs && e.ts < this.startupTs) {\n log.logInfo(\n `[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`,\n );\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n // Stop command for DM or shared-channel thread reply (app_mention handles \"@mikan stop\").\n if ((isDM || (!isDM && e.thread_ts)) && this.isStopText(slackEvent.text)) {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this);\n } else {\n this.postMessage(e.channel, formatNothingRunning(\"slack\"));\n }\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n const enqueueTriggered = () => {\n const activeSessionKey =\n slackEvent.sessionKey ?? resolveSlackSessionKey(e.channel, e.thread_ts);\n // Auto-reply top-level channel messages start with no sessionKey because\n // they are only candidates until the policy allows them. Once triggered,\n // persist the resolved key on the event; otherwise the runtime fallback\n // treats the message ts as a branch session (`channel:ts`) instead of the\n // persistent top-level channel session.\n slackEvent.sessionKey = activeSessionKey;\n this.getQueue(this.resolveQueueKey(e.channel, activeSessionKey)).enqueue(async () => {\n slackEvent.attachments = await attachmentsPromise;\n const adapters = createSlackAdapters(slackEvent, this);\n return this.handler.handleEvent(\n slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n this,\n adapters,\n );\n });\n };\n\n const logOnly = () => {\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n };\n\n if (isDM || isSharedThreadReply) {\n enqueueTriggered();\n ack();\n return;\n }\n\n // Shared-channel non-mention, non-thread: gate via auto-reply policy.\n // evaluateAutoReplyPolicy never throws — judge errors/timeouts surface as\n // trigger:false with a distinct reason, and the user message has already\n // been queued for logging via logUserMessage above.\n evaluateAutoReplyPolicy({\n event: slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n workingDir: this.workingDir,\n }).then((triggerResult) => {\n if (triggerResult.trigger) enqueueTriggered();\n else logOnly();\n });\n\n ack();\n });\n\n this.socketClient.on(\"slash_commands\", async ({ body, ack }) => {\n const payload = body as {\n command?: string;\n text?: string;\n channel_id?: string;\n user_id?: string;\n user_name?: string;\n thread_ts?: string;\n };\n\n await ack();\n\n if (!payload.command || !payload.channel_id || !payload.user_id) {\n return;\n }\n\n const handlerPromise =\n payload.command === \"/pi-login\"\n ? this.routeSlashLoginCommand({\n command: payload.command,\n text: payload.text,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-new\"\n ? this.routeSlashNewCommand({\n command: payload.command,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-session\"\n ? this.routeSlashSessionCommand({\n command: payload.command,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n thread_ts: payload.thread_ts,\n })\n : payload.command === \"/pi-model\"\n ? this.routeSlashModelCommand({\n command: payload.command,\n text: payload.text,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-sandbox\"\n ? this.routeSlashSandboxCommand({\n command: payload.command,\n text: payload.text,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-auto-reply\"\n ? this.routeSlashAutoReplyCommand({\n command: payload.command,\n text: payload.text,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-admin\"\n ? this.routeSlashAdminCommand({\n command: payload.command,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n thread_ts: payload.thread_ts,\n })\n : null;\n\n if (!handlerPromise) {\n return;\n }\n\n handlerPromise.catch((err) => {\n log.logWarning(\n \"Slack slash command error\",\n err instanceof Error ? err.message : String(err),\n );\n });\n });\n\n // App Home tab\n this.socketClient.on(\"app_home_opened\", ({ event, ack }) => {\n const e = event as { user: string; tab: string };\n ack();\n if (e.tab !== \"home\") return;\n\n this.webClient.views\n .publish({\n user_id: e.user,\n view: this.buildHomeView(),\n })\n .catch((err) => {\n log.logWarning(`Failed to publish App Home view`, String(err));\n });\n });\n\n // Handle button clicks (Force Stop)\n this.socketClient.on(\"block_actions\", async ({ body, ack }) => {\n const action = body.actions?.[0];\n if (!action || !action.action_id?.startsWith(\"force_stop_\")) {\n ack();\n return;\n }\n\n ack();\n const sessionKey = action.action_id.replace(\"force_stop_\", \"\").replace(/_/g, \":\");\n const userId = body.user?.id;\n const channelId = body.container?.channel_id || sessionKey.split(\":\")[0];\n\n log.logInfo(`[Force Stop] User ${userId} requested force stop for ${sessionKey}`);\n\n // Use handler's forceStop method\n this.handler.forceStop(sessionKey);\n\n // Notify in channel\n await this.postMessage(channelId, formatForceStopped(\"slack\", userId ?? \"unknown\"));\n\n // Refresh home tab\n if (userId) {\n this.webClient.views\n .publish({\n user_id: userId,\n view: this.buildHomeView(),\n })\n .catch((err) => {\n log.logWarning(`Failed to refresh App Home view`, String(err));\n });\n }\n });\n }\n\n /**\n * Log a user message to log.jsonl after attachments are ready.\n */\n private async logUserMessage(event: SlackEvent): Promise<Attachment[]> {\n const user = this.users.get(event.user);\n let attachments: Attachment[] = [];\n let attachmentError: unknown;\n if (event.files) {\n try {\n attachments = await this.store.processAttachments(event.channel, event.files, event.ts);\n } catch (err) {\n attachmentError = err;\n }\n }\n // Always write the text log, even if attachment processing failed — we want\n // a record of the user message regardless of file-handling errors.\n this.logToFile(event.channel, {\n date: new Date(parseFloat(event.ts) * 1000).toISOString(),\n ts: event.ts,\n threadTs: event.thread_ts,\n user: event.user,\n userName: user?.userName,\n displayName: user?.displayName,\n text: event.text,\n attachments,\n isBot: false,\n });\n if (attachmentError) throw attachmentError;\n return attachments;\n }\n\n private async logExternalBotMessage(event: {\n channel: string;\n ts: string;\n thread_ts?: string;\n text?: string;\n subtype?: string;\n bot_id?: string;\n app_id?: string;\n username?: string;\n bot_profile?: { app_id?: string; name?: string; real_name?: string };\n blocks?: unknown[];\n attachments?: unknown[];\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n }): Promise<Attachment[]> {\n const attachments = event.files\n ? await this.store.processAttachments(event.channel, event.files, event.ts)\n : [];\n const botName =\n event.username ?? event.bot_profile?.name ?? event.bot_profile?.real_name ?? event.bot_id;\n this.logToFile(event.channel, {\n date: new Date(parseFloat(event.ts) * 1000).toISOString(),\n ts: event.ts,\n threadTs: event.thread_ts,\n user: event.bot_id ? `bot:${event.bot_id}` : \"external-bot\",\n userName: botName,\n displayName: botName,\n text: buildSlackAppMessageText(event),\n attachments,\n isBot: true,\n botId: event.bot_id,\n appId: event.app_id ?? event.bot_profile?.app_id,\n subtype: event.subtype,\n });\n return attachments;\n }\n\n // ==========================================================================\n // Private - Backfill\n // ==========================================================================\n\n private async getExistingTimestamps(channelId: string): Promise<Set<string>> {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n const timestamps = new Set<string>();\n if (!existsSync(logPath)) return timestamps;\n\n const content = await readFile(logPath, \"utf-8\");\n const lines = content.trim().split(\"\\n\").filter(Boolean);\n for (let i = 0; i < lines.length; i++) {\n try {\n const entry = JSON.parse(lines[i]);\n if (entry.ts) timestamps.add(entry.ts);\n } catch (err) {\n log.logWarning(\n `Skipping malformed log entry at ${logPath}:${i + 1}`,\n err instanceof Error ? err.message : String(err),\n );\n }\n }\n return timestamps;\n }\n\n private async backfillChannel(channelId: string, upperBoundTs?: string): Promise<number> {\n const existingTs = await this.getExistingTimestamps(channelId);\n\n // Find the biggest ts in log.jsonl\n let lastLoggedTs: string | undefined;\n for (const ts of existingTs) {\n if (!lastLoggedTs || parseFloat(ts) > parseFloat(lastLoggedTs)) lastLoggedTs = ts;\n }\n\n type Message = {\n user?: string;\n bot_id?: string;\n app_id?: string;\n username?: string;\n bot_profile?: { app_id?: string; name?: string; real_name?: string };\n blocks?: unknown[];\n attachments?: unknown[];\n text?: string;\n ts?: string;\n thread_ts?: string;\n subtype?: string;\n files?: Array<{ name: string }>;\n };\n const allMessages: Message[] = [];\n\n let cursor: string | undefined;\n let pageCount = 0;\n const maxPages = 3;\n\n do {\n const result = await this.webClient.conversations.history({\n channel: channelId,\n oldest: lastLoggedTs, // Only fetch messages newer than what we have\n latest: upperBoundTs, // Do not race live socket events after startup\n inclusive: false,\n limit: 1000,\n cursor,\n });\n if (result.messages) {\n allMessages.push(...(result.messages as Message[]));\n }\n cursor = result.response_metadata?.next_cursor;\n pageCount++;\n } while (cursor && pageCount < maxPages);\n\n // Filter: include mikan's messages, external app/bot messages, and user messages.\n const relevantMessages = allMessages.filter((msg) => {\n if (!msg.ts || existingTs.has(msg.ts)) return false; // Skip duplicates\n if (msg.user === this.botUserId) return true;\n const isExternalBotMessage = !!msg.bot_id || msg.subtype === \"bot_message\";\n if (isExternalBotMessage) {\n if (this.botId && msg.bot_id === this.botId) return false;\n if (\n msg.subtype !== undefined &&\n msg.subtype !== \"bot_message\" &&\n msg.subtype !== \"file_share\"\n ) {\n return false;\n }\n return (\n !!msg.text ||\n !!(msg.files && msg.files.length > 0) ||\n !!msg.blocks?.length ||\n !!msg.attachments?.length\n );\n }\n if (msg.subtype !== undefined && msg.subtype !== \"file_share\") return false;\n if (!msg.user) return false;\n if (!msg.text && (!msg.files || msg.files.length === 0)) return false;\n return true;\n });\n\n // Reverse to chronological order\n relevantMessages.reverse();\n\n // Log each message to log.jsonl\n for (const msg of relevantMessages) {\n const isMikanMessage = msg.user === this.botUserId;\n const isExternalBotMessage =\n !isMikanMessage && (!!msg.bot_id || msg.subtype === \"bot_message\");\n if (isExternalBotMessage) {\n await this.logExternalBotMessage({ ...msg, channel: channelId, ts: msg.ts! });\n continue;\n }\n\n const user = this.users.get(msg.user!);\n const text = this.stripOwnMention(msg.text);\n const attachments = msg.files\n ? await this.store.processAttachments(channelId, msg.files, msg.ts!)\n : [];\n\n this.logToFile(channelId, {\n date: new Date(parseFloat(msg.ts!) * 1000).toISOString(),\n ts: msg.ts!,\n threadTs: msg.thread_ts,\n user: isMikanMessage ? \"bot\" : msg.user!,\n userName: isMikanMessage ? undefined : user?.userName,\n displayName: isMikanMessage ? undefined : user?.displayName,\n text,\n attachments,\n isBot: isMikanMessage,\n });\n }\n\n return relevantMessages.length;\n }\n\n private async backfillAllChannels(upperBoundTs?: string): Promise<void> {\n const startTime = Date.now();\n\n // Only backfill channels that already have a log.jsonl (mikan has interacted with them before)\n const channelsToBackfill: Array<[string, SlackChannel]> = [];\n for (const [channelId, channel] of this.channels) {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n if (existsSync(logPath)) {\n channelsToBackfill.push([channelId, channel]);\n }\n }\n\n log.logBackfillStart(channelsToBackfill.length);\n\n let totalMessages = 0;\n for (const [channelId, channel] of channelsToBackfill) {\n try {\n const count = await this.backfillChannel(channelId, upperBoundTs);\n if (count > 0) log.logBackfillChannel(channel.name, count);\n totalMessages += count;\n } catch (error) {\n log.logWarning(`Failed to backfill #${channel.name}`, String(error));\n }\n\n // Add delay between channels to avoid hitting Slack rate limits\n if (channelId !== channelsToBackfill[channelsToBackfill.length - 1][0]) {\n await new Promise((resolve) => setTimeout(resolve, 500));\n }\n }\n\n const durationMs = Date.now() - startTime;\n log.logBackfillComplete(totalMessages, durationMs);\n }\n\n // ==========================================================================\n // Private - Fetch Users/Channels\n // ==========================================================================\n\n private async fetchUsers(): Promise<void> {\n let cursor: string | undefined;\n do {\n const result = await this.webClient.users.list({ limit: 200, cursor });\n const members = result.members as\n | Array<{ id?: string; name?: string; real_name?: string; deleted?: boolean }>\n | undefined;\n if (members) {\n for (const u of members) {\n if (u.id && u.name && !u.deleted) {\n this.users.set(u.id, {\n id: u.id,\n userName: u.name,\n displayName: u.real_name || u.name,\n });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n }\n\n private async fetchChannels(): Promise<void> {\n // Fetch public/private channels\n let cursor: string | undefined;\n do {\n const result = await this.webClient.conversations.list({\n types: \"public_channel,private_channel\",\n exclude_archived: true,\n limit: 200,\n cursor,\n });\n const channels = result.channels as\n | Array<{ id?: string; name?: string; is_member?: boolean }>\n | undefined;\n if (channels) {\n for (const c of channels) {\n if (c.id && c.name && c.is_member) {\n this.channels.set(c.id, { id: c.id, name: c.name });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n\n // Also fetch DM channels (IMs)\n cursor = undefined;\n do {\n const result = await this.webClient.conversations.list({\n types: \"im\",\n limit: 200,\n cursor,\n });\n const ims = result.channels as Array<{ id?: string; user?: string }> | undefined;\n if (ims) {\n for (const im of ims) {\n if (im.id) {\n // Use user's name as channel name for DMs\n const user = im.user ? this.users.get(im.user) : undefined;\n const name = user ? `DM:${user.userName}` : `DM:${im.id}`;\n this.channels.set(im.id, { id: im.id, name });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n }\n}\n"]}
1
+ {"version":3,"file":"bot.d.ts","sourceRoot":"","sources":["../../../src/adapters/slack/bot.ts"],"names":[],"mappings":"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","sourcesContent":["import { SocketModeClient } from \"@slack/socket-mode\";\nimport type { KnownBlock } from \"@slack/types\";\nimport { WebClient } from \"@slack/web-api\";\nimport { existsSync, readFileSync } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { basename, join } from \"path\";\nimport type {\n Bot,\n BotAdapters,\n BotEvent,\n BotHandler,\n ChatMessage,\n ChatResponseContext,\n ChatToolResult,\n ConversationKind,\n PlatformInfo,\n} from \"../../adapter.js\";\nimport type { EventsWatcher } from \"../../events.js\";\nimport * as log from \"../../log.js\";\nimport type { Attachment, ChannelStore } from \"../../store.js\";\nimport { PRODUCT_NAME, formatForceStopped, formatNothingRunning } from \"../../ui-copy.js\";\nimport {\n appendBotResponseLog,\n appendChannelLog,\n ChannelQueue,\n resolveOnlyScopedStopTarget,\n resolveStopTarget,\n withRetry,\n} from \"../shared.js\";\nimport { evaluateAutoReplyPolicy } from \"../../trigger.js\";\nimport { createSlackAdapters } from \"./context.js\";\nimport {\n hasMaterializedChatSession,\n registerThreadSession,\n} from \"../../sessions/chat-session-manager.js\";\nimport {\n isSlackThreadSessionKey,\n planSlackAdapterSession,\n planSlackEventAnchorRun,\n resolveSlackSessionKey,\n} from \"./session.js\";\nimport { reportUserFacingError } from \"../../sentry.js\";\n\nconst SLACK_EVENT_ANCHOR_TEXT = \"Working on it...\";\n\n// Slack WebClient errors carry either `code: \"rate_limited\"` (retry-after) or\n// the legacy `data.error === \"rate_limited\"` / 429 status shape.\nfunction slackIsRateLimited(err: Error): boolean {\n if ((err as { code?: unknown }).code === \"rate_limited\") return true;\n const data = (err as { data?: { error?: string; response?: { status?: number } } }).data;\n return data?.error === \"rate_limited\" || data?.response?.status === 429;\n}\n\nconst slackRetry = <T>(fn: () => Promise<T>): Promise<T> =>\n withRetry(fn, { isRateLimited: slackIsRateLimited });\n\nfunction collectSlackText(value: unknown, parts: string[]): void {\n if (value === null || value === undefined) return;\n if (typeof value === \"string\") {\n const trimmed = value.trim();\n if (trimmed) parts.push(trimmed);\n return;\n }\n if (Array.isArray(value)) {\n for (const item of value) collectSlackText(item, parts);\n return;\n }\n if (typeof value !== \"object\") return;\n\n const obj = value as Record<string, unknown>;\n for (const key of [\"text\", \"fallback\", \"title\", \"value\"] as const) {\n collectSlackText(obj[key], parts);\n }\n collectSlackText(obj.fields, parts);\n collectSlackText(obj.elements, parts);\n collectSlackText(obj.blocks, parts);\n}\n\nfunction buildSlackAppMessageText(event: {\n text?: string;\n blocks?: unknown[];\n attachments?: unknown[];\n}): string {\n const parts: string[] = [];\n collectSlackText(event.text, parts);\n collectSlackText(event.blocks, parts);\n collectSlackText(event.attachments, parts);\n const deduped = parts.filter((part, index) => parts.indexOf(part) === index);\n return deduped.join(\"\\n\");\n}\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface SlackEvent {\n type: \"mention\" | \"dm\";\n conversationId: string;\n conversationKind: ConversationKind;\n channel: string;\n ts: string;\n thread_ts?: string;\n user: string;\n text: string;\n files?: Array<{ name?: string; url_private_download?: string; url_private?: string }>;\n /** Processed attachments with local paths (populated after logUserMessage) */\n attachments?: Attachment[];\n /** Session key passed through to BotEvent so handleEvent uses the correct persistent session */\n sessionKey?: string;\n}\n\nexport interface SlackUser {\n id: string;\n userName: string;\n displayName: string;\n}\n\nexport interface SlackChannel {\n id: string;\n name: string;\n}\n\n// Types used by agent.ts\nexport interface ChannelInfo {\n id: string;\n name: string;\n}\n\nexport interface UserInfo {\n id: string;\n userName: string;\n displayName: string;\n}\n\nexport interface SlackContext {\n message: {\n text: string;\n rawText: string;\n user: string;\n userName?: string;\n channel: string;\n ts: string;\n attachments: Array<{ localPath: string }>;\n };\n channelName?: string;\n channels: ChannelInfo[];\n users: UserInfo[];\n respond: (text: string, shouldLog?: boolean) => Promise<void>;\n replaceMessage: (text: string) => Promise<void>;\n respondDiagnostic: (text: string) => Promise<void>;\n setTyping: (isTyping: boolean) => Promise<void>;\n uploadFile: (filePath: string, title?: string) => Promise<void>;\n setWorking: (working: boolean) => Promise<void>;\n deleteMessage: () => Promise<void>;\n}\n\n// ============================================================================\n// SlackBot\n// ============================================================================\n\nexport class SlackBot implements Bot {\n private socketClient: SocketModeClient;\n private webClient: WebClient;\n private handler: BotHandler;\n private workingDir: string;\n private store: ChannelStore;\n private botUserId: string | null = null;\n private botId: string | null = null;\n private ownMentionRegex: RegExp | null = null;\n private startupTs: string | null = null; // Messages older than this are just logged, not processed\n\n private users = new Map<string, SlackUser>();\n private channels = new Map<string, SlackChannel>();\n private queues = new Map<string, ChannelQueue>();\n private eventsWatcher: EventsWatcher | null = null;\n\n constructor(\n handler: BotHandler,\n config: { appToken: string; botToken: string; workingDir: string; store: ChannelStore },\n ) {\n this.handler = handler;\n this.workingDir = config.workingDir;\n this.store = config.store;\n this.socketClient = new SocketModeClient({\n appToken: config.appToken,\n // Default 5s is too tight: brief event-loop stalls (e.g. backfill, sync fs)\n // cause false pong timeouts; 4 in a row makes Slack drop the socket.\n clientPingTimeout: 12_000,\n });\n this.webClient = new WebClient(config.botToken);\n }\n\n setEventsWatcher(watcher: EventsWatcher): void {\n this.eventsWatcher = watcher;\n }\n\n // ==========================================================================\n // Public API\n // ==========================================================================\n\n async start(): Promise<void> {\n const auth = await this.webClient.auth.test();\n this.botUserId = auth.user_id as string;\n this.botId = typeof auth.bot_id === \"string\" ? auth.bot_id : null;\n\n await Promise.all([this.fetchUsers(), this.fetchChannels()]);\n log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);\n\n // Record startup time before opening the socket. Slack may replay older events;\n // those should be logged but not processed. Backfill runs in the background up\n // to this timestamp so startup is not blocked by one history call per channel.\n this.startupTs = (Date.now() / 1000).toFixed(6);\n\n this.setupEventHandlers();\n await this.socketClient.start();\n\n log.logConnected(\"Slack\");\n\n void this.backfillAllChannels(this.startupTs).catch((error) => {\n log.logWarning(\"Slack backfill failed\", String(error));\n });\n }\n\n getUser(userId: string): SlackUser | undefined {\n return this.users.get(userId);\n }\n\n getChannel(channelId: string): SlackChannel | undefined {\n return this.channels.get(channelId);\n }\n\n getAllUsers(): SlackUser[] {\n return Array.from(this.users.values());\n }\n\n getAllChannels(): SlackChannel[] {\n return Array.from(this.channels.values());\n }\n\n private stripOwnMention(text: string | undefined): string {\n const source = text ?? \"\";\n if (!this.botUserId) return source.trim();\n if (!this.ownMentionRegex || !this.ownMentionRegex.source.includes(this.botUserId)) {\n this.ownMentionRegex = new RegExp(`<@${this.botUserId}>`, \"gi\");\n }\n return source.replace(this.ownMentionRegex, \"\").trim();\n }\n\n async postMessage(channel: string, text: string): Promise<string> {\n return slackRetry(async () => {\n const result = await this.webClient.chat.postMessage({ channel, text });\n return result.ts as string;\n });\n }\n\n async postEphemeral(\n channel: string,\n user: string,\n text: string,\n threadTs?: string,\n ): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.chat.postEphemeral({\n channel,\n user,\n text,\n ...(threadTs ? { thread_ts: threadTs } : {}),\n });\n });\n }\n\n async postEphemeralBlocks(\n channel: string,\n user: string,\n text: string,\n blocks: object[],\n threadTs?: string,\n ): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.chat.postEphemeral({\n channel,\n user,\n text,\n blocks: blocks as KnownBlock[],\n ...(threadTs ? { thread_ts: threadTs } : {}),\n });\n });\n }\n\n async postMessageBlocks(channel: string, text: string, blocks: object[]): Promise<string> {\n return slackRetry(async () => {\n const result = await this.webClient.chat.postMessage({\n channel,\n text,\n blocks: blocks as KnownBlock[],\n });\n return result.ts as string;\n });\n }\n\n async postPrivate(conversationId: string, userId: string, text: string): Promise<void> {\n await this.postEphemeral(conversationId, userId, text);\n }\n\n async postPrivateDiagnostic(\n conversationId: string,\n userId: string,\n text: string,\n options?: { style?: \"muted\" | \"error\" },\n ): Promise<void> {\n if (options?.style !== \"muted\") {\n await this.postPrivate(\n conversationId,\n userId,\n options?.style === \"error\" ? `_${text}_` : text,\n );\n return;\n }\n const CONTEXT_TEXT_LIMIT = 3000;\n const blockText =\n text.length > CONTEXT_TEXT_LIMIT\n ? text.substring(0, CONTEXT_TEXT_LIMIT - 20) + \"\\n_(truncated)_\"\n : text;\n await this.postEphemeralBlocks(conversationId, userId, text, [\n { type: \"context\", elements: [{ type: \"mrkdwn\", text: blockText }] },\n ]);\n }\n\n async openDirectMessage(userId: string): Promise<string> {\n return slackRetry(async () => {\n const result = await this.webClient.conversations.open({ users: userId });\n const channelId = result.channel?.id;\n if (!channelId) {\n throw new Error(`Failed to open DM for user ${userId}`);\n }\n return channelId;\n });\n }\n\n async updateMessage(channel: string, ts: string, text: string): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.chat.update({ channel, ts, text });\n });\n }\n\n async deleteMessage(channel: string, ts: string): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.chat.delete({ channel, ts });\n });\n }\n\n // ==========================================================================\n // Slack Assistant API (AI assistant experience)\n // ==========================================================================\n\n /** Set the status for an assistant thread (shows \"thinking\" state) */\n async setAssistantStatus(channel: string, threadTs: string, status: string): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.assistant.threads.setStatus({\n channel_id: channel,\n thread_ts: threadTs,\n status,\n });\n });\n }\n\n async postInThread(channel: string, threadTs: string, text: string): Promise<string> {\n return slackRetry(async () => {\n // Use Block Kit section for long messages to trigger Slack's \"Show more\" collapsing (~700 chars)\n const SECTION_TEXT_LIMIT = 3000;\n if (text.length > 500) {\n const blockText =\n text.length > SECTION_TEXT_LIMIT\n ? text.substring(0, SECTION_TEXT_LIMIT - 20) + \"\\n_(truncated)_\"\n : text;\n const result = await this.webClient.chat.postMessage({\n channel,\n thread_ts: threadTs,\n text, // full text as notification fallback\n blocks: [{ type: \"section\", text: { type: \"mrkdwn\", text: blockText } }],\n });\n return result.ts as string;\n }\n const result = await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });\n return result.ts as string;\n });\n }\n\n async postInThreadBlocks(\n channel: string,\n threadTs: string,\n text: string,\n blocks: object[],\n ): Promise<string> {\n return slackRetry(async () => {\n const result = await this.webClient.chat.postMessage({\n channel,\n thread_ts: threadTs,\n text, // fallback for notifications\n blocks: blocks as KnownBlock[],\n });\n return result.ts as string;\n });\n }\n\n async uploadFile(\n channel: string,\n filePath: string,\n title?: string,\n threadTs?: string,\n ): Promise<void> {\n return slackRetry(async () => {\n const fileName = title || basename(filePath);\n const fileContent = readFileSync(filePath);\n await this.webClient.files.uploadV2({\n channel_id: channel,\n file: fileContent,\n filename: fileName,\n title: fileName,\n ...(threadTs ? { thread_ts: threadTs } : {}),\n } as Parameters<typeof this.webClient.files.uploadV2>[0]);\n });\n }\n\n logToFile(channel: string, entry: object): void {\n appendChannelLog(this.workingDir, channel, entry);\n }\n\n logBotResponse(channel: string, text: string, ts: string, threadTs?: string): void {\n appendBotResponseLog(this.workingDir, channel, text, ts, threadTs);\n }\n\n getPlatformInfo(): PlatformInfo {\n return {\n name: \"slack\",\n formattingGuide:\n \"## Slack Formatting (mrkdwn, NOT Markdown)\\nBold: *text*, Italic: _text_, Code: `code`, Block: ```code```, Links: <url|text>\\nDo NOT use **double asterisks** or [markdown](links).\",\n channels: this.getAllChannels().map((c) => ({ id: c.id, name: c.name })),\n users: this.getAllUsers().map((u) => ({\n id: u.id,\n userName: u.userName,\n displayName: u.displayName,\n })),\n diagnostics: {\n showUsageSummary: true,\n },\n };\n }\n\n // ==========================================================================\n // Events Integration\n // ==========================================================================\n\n /**\n * Enqueue an event for processing. Always queues (no \"already working\" rejection).\n * Returns true if enqueued, false if queue is full (max 5).\n */\n enqueueEvent(event: BotEvent): boolean {\n const conversationId = event.conversationId;\n const queue = this.getQueue(conversationId);\n if (queue.size() >= 5) {\n log.logWarning(\n `Event queue full for ${conversationId}, discarding: ${event.text.substring(0, 50)}`,\n );\n return false;\n }\n log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);\n queue.enqueue(async () => {\n let anchorTs: string | undefined;\n if (!event.thread_ts) {\n try {\n anchorTs = await this.postMessage(conversationId, SLACK_EVENT_ANCHOR_TEXT);\n } catch (err) {\n log.logWarning(\n `Failed to post Slack event anchor for ${conversationId}`,\n err instanceof Error ? err.message : String(err),\n );\n reportUserFacingError(err, {\n domain: \"events\",\n surface: \"event_delivery\",\n operation: \"slack_anchor_post\",\n severity: \"error\",\n platform: \"slack\",\n context: {\n conversationId,\n conversationKind: event.conversationKind,\n eventTs: event.ts,\n textLength: event.text.length,\n },\n });\n throw err;\n }\n }\n const eventPlan = planSlackEventAnchorRun(event, anchorTs);\n const eventForRun = eventPlan.event;\n if (eventPlan.initialMessageTs && eventForRun.sessionKey) {\n registerThreadSession({\n conversationDir: join(this.workingDir, conversationId),\n sessionKey: eventForRun.sessionKey,\n });\n }\n\n const runQueueKey = planSlackAdapterSession(eventForRun, {\n initialMessageTs: eventPlan.initialMessageTs,\n }).sessionKey;\n this.getQueue(runQueueKey).enqueue(async () => {\n const slackEvent: SlackEvent = {\n type: eventForRun.type as SlackEvent[\"type\"],\n conversationId,\n conversationKind: eventForRun.conversationKind,\n channel: conversationId,\n ts: eventForRun.ts,\n thread_ts: eventForRun.thread_ts,\n user: eventForRun.user,\n text: eventForRun.text,\n attachments: eventForRun.attachments?.map((attachment) => ({\n original: attachment.name,\n localPath: attachment.localPath,\n })),\n sessionKey: eventForRun.sessionKey,\n };\n const adapters = createSlackAdapters(slackEvent, this, {\n initialMessageTs: eventPlan.initialMessageTs,\n });\n return this.handler.handleEvent(eventForRun, this, adapters);\n });\n });\n return true;\n }\n\n // ==========================================================================\n // Private - Event Handlers\n // ==========================================================================\n\n private getQueue(channelId: string): ChannelQueue {\n let queue = this.queues.get(channelId);\n if (!queue) {\n queue = new ChannelQueue(\"Slack\");\n this.queues.set(channelId, queue);\n }\n return queue;\n }\n\n private resolveQueueKey(conversationId: string, sessionKey: string): string {\n if (!isSlackThreadSessionKey(sessionKey)) return sessionKey;\n if (this.handler.isRunning(sessionKey)) return sessionKey;\n return this.hasKnownThreadSession(conversationId, sessionKey) ? sessionKey : conversationId;\n }\n\n private hasKnownThreadSession(conversationId: string, sessionKey: string): boolean {\n return hasMaterializedChatSession({\n conversationDir: join(this.workingDir, conversationId),\n sessionKey,\n });\n }\n\n private shouldTriggerSharedThreadReply(channelId: string, threadTs?: string): boolean {\n if (!threadTs) return false;\n\n const sessionKey = resolveSlackSessionKey(channelId, threadTs);\n if (this.handler.isRunning(sessionKey)) return true;\n\n return this.hasKnownThreadSession(channelId, sessionKey);\n }\n\n private buildHomeView(): { type: \"home\"; blocks: KnownBlock[] } {\n const blocks: object[] = [\n {\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `*${PRODUCT_NAME}*\\nStart a new task or check on running work.`,\n },\n accessory: {\n type: \"image\",\n image_url: \"https://media1.tenor.com/m/lfDATg4Bhc0AAAAC/happy-cat.gif\",\n alt_text: PRODUCT_NAME,\n },\n },\n ];\n\n // --- Running tasks ---\n const runningSessions = this.handler.getRunningSessions();\n\n blocks.push(\n { type: \"divider\" },\n {\n type: \"header\",\n text: {\n type: \"plain_text\",\n text: `Running Tasks (${runningSessions.length})`,\n emoji: true,\n },\n },\n );\n\n if (runningSessions.length === 0) {\n blocks.push({\n type: \"context\",\n elements: [{ type: \"mrkdwn\", text: \"_No tasks running right now._\" }],\n });\n } else {\n // Threshold for \"stuck\" detection (10 minutes)\n const STUCK_THRESHOLD_MS = 10 * 60 * 1000;\n\n for (const session of runningSessions) {\n const channelId = session.sessionKey.split(\":\")[0];\n const channel = this.channels.get(channelId);\n const channelName = channel ? `#${channel.name}` : channelId;\n const elapsed = Math.floor((Date.now() - session.startedAt) / 60000);\n const elapsedStr = elapsed < 1 ? \"<1 min\" : `${elapsed} min`;\n\n // Check if task might be stuck\n const lastActivity = session.lastActivityAt ? Date.now() - session.lastActivityAt : 0;\n const isStuck = lastActivity > STUCK_THRESHOLD_MS;\n const statusText = isStuck ? \"_stuck_\" : \"_running_\";\n\n // Build status line: channel · status · time · step\n let statusLine = `${statusText} · ${elapsedStr}`;\n if (session.currentTool) {\n statusLine += ` · ${session.currentTool}`;\n }\n if (isStuck && lastActivity > 0) {\n const inactiveMin = Math.floor(lastActivity / 60000);\n statusLine += ` · idle ${inactiveMin}m`;\n }\n\n // Use context block for gray small text (like \"No scheduled jobs.\")\n blocks.push({\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: `*${channelName}* · ${statusLine}`,\n },\n ],\n });\n\n // Add Force Stop button as separate element if stuck\n if (isStuck) {\n blocks.push({\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: \" \",\n },\n {\n type: \"button\",\n text: { type: \"plain_text\", text: \"Force Stop\", emoji: true },\n action_id: `force_stop_${session.sessionKey.replace(/:/g, \"_\")}`,\n style: \"danger\",\n },\n ],\n });\n }\n }\n }\n\n // --- Cron jobs ---\n const periodicEvents = this.eventsWatcher?.getPeriodicEvents() ?? [];\n\n blocks.push(\n { type: \"divider\" },\n {\n type: \"header\",\n text: {\n type: \"plain_text\",\n text: `Scheduled Jobs (${periodicEvents.length})`,\n emoji: true,\n },\n },\n );\n\n if (periodicEvents.length === 0) {\n blocks.push({\n type: \"context\",\n elements: [{ type: \"mrkdwn\", text: \"_No scheduled jobs._\" }],\n });\n } else {\n for (const ev of periodicEvents) {\n const channelLabel =\n ev.platform === \"slack\"\n ? (() => {\n const channel = this.channels.get(ev.conversationId);\n const channelName = channel ? `#${channel.name}` : ev.conversationId;\n return `${ev.platform}:${channelName}`;\n })()\n : `${ev.platform}:${ev.conversationId}`;\n const nextStr = ev.nextRun\n ? new Date(ev.nextRun).toLocaleString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n })\n : \"—\";\n blocks.push({\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `*${ev.text}*\\n└ \\`${ev.schedule}\\` · ${channelLabel} · Next: ${nextStr}`,\n },\n });\n }\n }\n\n // --- Footer ---\n blocks.push(\n { type: \"divider\" },\n {\n type: \"context\",\n elements: [\n { type: \"mrkdwn\", text: \"💡 @mention in a channel or send a DM to start a new task\" },\n ],\n },\n );\n\n return { type: \"home\", blocks: blocks as KnownBlock[] };\n }\n\n private resolveStopTarget(channelId: string, threadTs?: string): string | null {\n const directTarget = resolveStopTarget({\n handler: this.handler,\n conversationId: channelId,\n sessionKey: resolveSlackSessionKey(channelId, threadTs),\n });\n if (directTarget) return directTarget;\n if (threadTs) return null;\n return resolveOnlyScopedStopTarget(this.handler, channelId);\n }\n\n private isStopText(text: string): boolean {\n const normalized = text.trim().toLowerCase();\n return normalized === \"stop\" || normalized === \"/stop\";\n }\n\n private createCommandAdapters(\n conversationId: string,\n userId: string,\n userName: string | undefined,\n text: string,\n ts: string,\n options: { ephemeralChannelId?: string; threadTs?: string } = {},\n ): BotAdapters {\n const message: ChatMessage = {\n id: ts,\n sessionKey: conversationId,\n conversationKind: options.ephemeralChannelId ? \"shared\" : \"direct\",\n userId,\n userName,\n text,\n attachments: [],\n };\n\n const respond = async (responseText: string) => {\n if (options.ephemeralChannelId) {\n await this.postEphemeral(\n options.ephemeralChannelId,\n userId,\n responseText,\n options.threadTs,\n );\n return;\n }\n const messageTs = await this.postMessage(conversationId, responseText);\n this.logBotResponse(conversationId, responseText, messageTs);\n };\n\n const respondMuted = async (responseText: string) => {\n const CONTEXT_TEXT_LIMIT = 3000;\n const blockText =\n responseText.length > CONTEXT_TEXT_LIMIT\n ? responseText.substring(0, CONTEXT_TEXT_LIMIT - 20) + \"\\n_(truncated)_\"\n : responseText;\n const blocks = [{ type: \"context\", elements: [{ type: \"mrkdwn\", text: blockText }] }];\n if (options.ephemeralChannelId) {\n await this.postEphemeralBlocks(\n options.ephemeralChannelId,\n userId,\n responseText,\n blocks,\n options.threadTs,\n );\n return;\n }\n const messageTs = await this.postMessageBlocks(conversationId, responseText, blocks);\n this.logBotResponse(conversationId, responseText, messageTs);\n };\n\n const responseCtx: ChatResponseContext = {\n respond,\n replaceResponse: respond,\n respondDiagnostic: async (\n responseText: string,\n responseOptions?: { style?: \"muted\" | \"error\" },\n ) => {\n if (responseOptions?.style === \"muted\") {\n await respondMuted(responseText);\n return;\n }\n await respond(responseOptions?.style === \"error\" ? `_${responseText}_` : responseText);\n },\n respondToolResult: async (result: ChatToolResult) => {\n const duration = (result.durationMs / 1000).toFixed(1);\n await respond(\n `${result.isError ? \"Error\" : \"Done\"} ${result.toolName} (${duration}s)\\n${result.result}`,\n );\n },\n setTyping: async () => {},\n setWorking: async () => {},\n uploadFile: async (filePath: string, title?: string) => {\n await this.uploadFile(conversationId, filePath, title);\n },\n deleteResponse: async () => {},\n };\n\n return {\n message,\n responseCtx,\n platform: this.getPlatformInfo(),\n };\n }\n\n private createSlashCommandBot(conversationId: string, threadTs?: string): Bot {\n return {\n start: async () => {},\n postMessage: async (_channel: string, text: string) => {\n if (threadTs) {\n return this.postInThread(conversationId, threadTs, text);\n }\n return this.postMessage(conversationId, text);\n },\n updateMessage: async (channel: string, ts: string, text: string) => {\n await this.updateMessage(channel, ts, text);\n },\n enqueueEvent: (event: BotEvent) => this.enqueueEvent(event),\n getPlatformInfo: () => this.getPlatformInfo(),\n };\n }\n\n private async routeSlashLoginCommand(payload: {\n command: string;\n text?: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n const commandSuffix = payload.text?.trim();\n const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const sourceChannelId = payload.channel_id;\n const isDirectMessage = sourceChannelId.startsWith(\"D\");\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n\n this.logToFile(sourceChannelId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: commandText,\n attachments: [],\n isBot: false,\n });\n\n const event: BotEvent = {\n type: isDirectMessage ? \"dm\" : \"private_command\",\n conversationId: sourceChannelId,\n conversationKind: isDirectMessage ? \"direct\" : \"shared\",\n ts: eventTs,\n user: payload.user_id,\n text: commandText,\n attachments: [],\n sessionKey: sourceChannelId,\n };\n\n const adapters = this.createCommandAdapters(\n sourceChannelId,\n payload.user_id,\n userName,\n commandText,\n eventTs,\n isDirectMessage ? {} : { ephemeralChannelId: sourceChannelId },\n );\n\n await this.handler.handleEvent(event, this, adapters);\n }\n\n private async routeSlashNewCommand(payload: {\n command: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n const conversationId = payload.channel_id;\n if (!conversationId.startsWith(\"D\")) {\n await this.postEphemeral(\n conversationId,\n payload.user_id,\n `為了避免誤清除共享上下文,${payload.command} 目前只能在與 ${PRODUCT_NAME} 的私訊中使用。`,\n );\n return;\n }\n\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n\n this.logToFile(conversationId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: payload.command,\n attachments: [],\n isBot: false,\n });\n\n const commandBot = this.createSlashCommandBot(conversationId);\n await this.handler.handleNewCommand(conversationId, conversationId, commandBot);\n }\n\n private async routeSlashModelCommand(payload: {\n command: string;\n text?: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n const conversationId = payload.channel_id;\n const isDirectMessage = conversationId.startsWith(\"D\");\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n const commandSuffix = payload.text?.trim();\n const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;\n\n this.logToFile(conversationId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: commandText,\n attachments: [],\n isBot: false,\n });\n\n const sessionKey = conversationId;\n const event: BotEvent = {\n type: isDirectMessage ? \"dm\" : \"mention\",\n conversationId,\n conversationKind: isDirectMessage ? \"direct\" : \"shared\",\n ts: eventTs,\n user: payload.user_id,\n text: commandText,\n attachments: [],\n sessionKey,\n };\n\n const adapters = this.createCommandAdapters(\n conversationId,\n payload.user_id,\n userName,\n commandText,\n eventTs,\n isDirectMessage ? {} : { ephemeralChannelId: conversationId },\n );\n\n await this.handler.handleEvent(event, this, adapters);\n }\n\n private async routeSlashSandboxCommand(payload: {\n command: string;\n text?: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n await this.routeSlashModelCommand(payload);\n }\n\n private async routeSlashAutoReplyCommand(payload: {\n command: string;\n text?: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n await this.routeSlashModelCommand(payload);\n }\n\n private async routeSlashSessionCommand(payload: {\n command: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n thread_ts?: string;\n }): Promise<void> {\n const conversationId = payload.channel_id;\n const isDirectMessage = conversationId.startsWith(\"D\");\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n const commandText = payload.command;\n\n this.logToFile(conversationId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: commandText,\n attachments: [],\n isBot: false,\n threadTs: payload.thread_ts,\n });\n\n const sessionKey = resolveSlackSessionKey(conversationId, payload.thread_ts);\n const event: BotEvent = {\n type: isDirectMessage ? \"dm\" : \"mention\",\n conversationId,\n conversationKind: isDirectMessage ? \"direct\" : \"shared\",\n ts: eventTs,\n user: payload.user_id,\n text: commandText,\n attachments: [],\n thread_ts: payload.thread_ts,\n sessionKey,\n };\n\n const adapters = this.createCommandAdapters(\n conversationId,\n payload.user_id,\n userName,\n commandText,\n eventTs,\n isDirectMessage\n ? { threadTs: payload.thread_ts }\n : { ephemeralChannelId: conversationId, threadTs: payload.thread_ts },\n );\n\n await this.handler.handleEvent(event, this, adapters);\n }\n\n private async routeSlashAdminCommand(payload: {\n command: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n thread_ts?: string;\n }): Promise<void> {\n const conversationId = payload.channel_id;\n const isDirectMessage = conversationId.startsWith(\"D\");\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n const commandText = payload.command;\n\n this.logToFile(conversationId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: commandText,\n attachments: [],\n isBot: false,\n threadTs: payload.thread_ts,\n });\n\n const sessionKey = resolveSlackSessionKey(conversationId, payload.thread_ts);\n const event: BotEvent = {\n type: isDirectMessage ? \"dm\" : \"mention\",\n conversationId,\n conversationKind: isDirectMessage ? \"direct\" : \"shared\",\n ts: eventTs,\n user: payload.user_id,\n text: commandText,\n attachments: [],\n thread_ts: payload.thread_ts,\n sessionKey,\n };\n\n const adapters = this.createCommandAdapters(\n conversationId,\n payload.user_id,\n userName,\n commandText,\n eventTs,\n isDirectMessage\n ? { threadTs: payload.thread_ts }\n : { ephemeralChannelId: conversationId, threadTs: payload.thread_ts },\n );\n\n await this.handler.handleEvent(event, this, adapters);\n }\n\n private setupEventHandlers(): void {\n this.socketClient.on(\"disconnect\", (err: unknown) => {\n log.logWarning(\"Slack socket disconnect\", err ? String(err) : \"\");\n });\n this.socketClient.on(\"error\", (err: unknown) => {\n log.logWarning(\"Slack socket error\", err ? String(err) : \"\");\n });\n this.socketClient.on(\"unable_to_socket_mode_start\", (err: unknown) => {\n log.logWarning(\"Slack socket unable_to_start\", err ? String(err) : \"\");\n });\n\n // Channel @mentions\n this.socketClient.on(\"app_mention\", ({ event, ack }) => {\n const e = event as {\n text: string;\n channel: string;\n user: string;\n ts: string;\n thread_ts?: string;\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n };\n\n // Skip DMs (handled by message event)\n if (e.channel.startsWith(\"D\")) {\n ack();\n return;\n }\n\n // Top-level mentions use a persistent channel session.\n // Thread replies get their own isolated session (channelId:thread_ts).\n const sessionKey = resolveSlackSessionKey(e.channel, e.thread_ts);\n\n const slackEvent: SlackEvent = {\n type: \"mention\",\n conversationId: e.channel,\n conversationKind: \"shared\",\n channel: e.channel,\n ts: e.ts,\n thread_ts: e.thread_ts,\n user: e.user,\n text: this.stripOwnMention(e.text),\n files: e.files,\n sessionKey,\n };\n\n const attachmentsPromise = this.logUserMessage(slackEvent);\n\n // Only trigger processing for messages AFTER startup (not replayed old messages)\n if (this.startupTs && e.ts < this.startupTs) {\n log.logInfo(\n `[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`,\n );\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n // Check for stop command - execute immediately, don't queue!\n if (this.isStopText(slackEvent.text)) {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this);\n } else {\n this.postMessage(e.channel, formatNothingRunning(\"slack\"));\n }\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n this.getQueue(this.resolveQueueKey(e.channel, sessionKey)).enqueue(async () => {\n slackEvent.attachments = await attachmentsPromise;\n const adapters = createSlackAdapters(slackEvent, this);\n return this.handler.handleEvent(\n slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n this,\n adapters,\n );\n });\n\n ack();\n });\n\n // All messages (for logging) + DMs (for triggering)\n this.socketClient.on(\"message\", ({ event, ack }) => {\n const e = event as {\n text?: string;\n channel: string;\n user?: string;\n ts: string;\n thread_ts?: string;\n channel_type?: string;\n subtype?: string;\n bot_id?: string;\n app_id?: string;\n username?: string;\n bot_profile?: { id?: string; app_id?: string; name?: string; real_name?: string };\n blocks?: unknown[];\n attachments?: unknown[];\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n };\n\n const hasFiles = !!e.files && e.files.length > 0;\n const hasSlackContent = !!e.text || hasFiles || !!e.blocks?.length || !!e.attachments?.length;\n const isOwnBotMessage =\n (!!e.user && e.user === this.botUserId) || (!!this.botId && e.bot_id === this.botId);\n if (isOwnBotMessage) {\n ack();\n return;\n }\n\n const isExternalBotMessage = !!e.bot_id || e.subtype === \"bot_message\";\n if (isExternalBotMessage) {\n if (e.subtype !== undefined && e.subtype !== \"bot_message\" && e.subtype !== \"file_share\") {\n ack();\n return;\n }\n if (!hasSlackContent) {\n ack();\n return;\n }\n void this.logExternalBotMessage(e).catch((err) => {\n log.logWarning(\"Failed to log Slack bot message\", String(err));\n });\n ack();\n return;\n }\n\n if (!e.user) {\n ack();\n return;\n }\n if (e.subtype !== undefined && e.subtype !== \"file_share\") {\n ack();\n return;\n }\n if (!hasSlackContent) {\n ack();\n return;\n }\n\n const isDM = e.channel_type === \"im\";\n const conversationKind: ConversationKind = isDM ? \"direct\" : \"shared\";\n const isBotMention = e.text?.includes(`<@${this.botUserId}>`);\n\n // Skip channel @mentions - already handled by app_mention event\n if (!isDM && isBotMention) {\n ack();\n return;\n }\n\n const isSharedThreadReply =\n !isDM && this.shouldTriggerSharedThreadReply(e.channel, e.thread_ts);\n const sessionKey =\n isDM || isSharedThreadReply ? resolveSlackSessionKey(e.channel, e.thread_ts) : undefined;\n\n const slackEvent: SlackEvent = {\n type: isDM ? \"dm\" : \"mention\",\n conversationId: e.channel,\n conversationKind,\n channel: e.channel,\n ts: e.ts,\n thread_ts: e.thread_ts,\n user: e.user,\n text: this.stripOwnMention(e.text),\n files: e.files,\n sessionKey,\n };\n\n const attachmentsPromise = this.logUserMessage(slackEvent);\n\n // Only trigger processing for messages AFTER startup (not replayed old messages)\n if (this.startupTs && e.ts < this.startupTs) {\n log.logInfo(\n `[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`,\n );\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n // Stop command for DM or shared-channel thread reply (app_mention handles \"@mikan stop\").\n if ((isDM || (!isDM && e.thread_ts)) && this.isStopText(slackEvent.text)) {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this);\n } else {\n this.postMessage(e.channel, formatNothingRunning(\"slack\"));\n }\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n const enqueueTriggered = () => {\n const activeSessionKey =\n slackEvent.sessionKey ?? resolveSlackSessionKey(e.channel, e.thread_ts);\n // Auto-reply top-level channel messages start with no sessionKey because\n // they are only candidates until the policy allows them. Once triggered,\n // persist the resolved key on the event; otherwise the runtime fallback\n // treats the message ts as a thread session (`channel:ts`) instead of the\n // persistent top-level channel session.\n slackEvent.sessionKey = activeSessionKey;\n this.getQueue(this.resolveQueueKey(e.channel, activeSessionKey)).enqueue(async () => {\n slackEvent.attachments = await attachmentsPromise;\n const adapters = createSlackAdapters(slackEvent, this);\n return this.handler.handleEvent(\n slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n this,\n adapters,\n );\n });\n };\n\n const logOnly = () => {\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n };\n\n if (isDM || isSharedThreadReply) {\n enqueueTriggered();\n ack();\n return;\n }\n\n // Shared-channel non-mention, non-thread: gate via auto-reply policy.\n // evaluateAutoReplyPolicy never throws — judge errors/timeouts surface as\n // trigger:false with a distinct reason, and the user message has already\n // been queued for logging via logUserMessage above.\n evaluateAutoReplyPolicy({\n event: slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n workingDir: this.workingDir,\n }).then((triggerResult) => {\n if (triggerResult.trigger) enqueueTriggered();\n else logOnly();\n });\n\n ack();\n });\n\n this.socketClient.on(\"slash_commands\", async ({ body, ack }) => {\n const payload = body as {\n command?: string;\n text?: string;\n channel_id?: string;\n user_id?: string;\n user_name?: string;\n thread_ts?: string;\n };\n\n await ack();\n\n if (!payload.command || !payload.channel_id || !payload.user_id) {\n return;\n }\n\n const handlerPromise =\n payload.command === \"/pi-login\"\n ? this.routeSlashLoginCommand({\n command: payload.command,\n text: payload.text,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-new\"\n ? this.routeSlashNewCommand({\n command: payload.command,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-session\"\n ? this.routeSlashSessionCommand({\n command: payload.command,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n thread_ts: payload.thread_ts,\n })\n : payload.command === \"/pi-model\"\n ? this.routeSlashModelCommand({\n command: payload.command,\n text: payload.text,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-sandbox\"\n ? this.routeSlashSandboxCommand({\n command: payload.command,\n text: payload.text,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-auto-reply\"\n ? this.routeSlashAutoReplyCommand({\n command: payload.command,\n text: payload.text,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-admin\"\n ? this.routeSlashAdminCommand({\n command: payload.command,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n thread_ts: payload.thread_ts,\n })\n : null;\n\n if (!handlerPromise) {\n return;\n }\n\n handlerPromise.catch((err) => {\n log.logWarning(\n \"Slack slash command error\",\n err instanceof Error ? err.message : String(err),\n );\n });\n });\n\n // App Home tab\n this.socketClient.on(\"app_home_opened\", ({ event, ack }) => {\n const e = event as { user: string; tab: string };\n ack();\n if (e.tab !== \"home\") return;\n\n this.webClient.views\n .publish({\n user_id: e.user,\n view: this.buildHomeView(),\n })\n .catch((err) => {\n log.logWarning(`Failed to publish App Home view`, String(err));\n });\n });\n\n // Handle button clicks (Force Stop)\n this.socketClient.on(\"block_actions\", async ({ body, ack }) => {\n const action = body.actions?.[0];\n if (!action || !action.action_id?.startsWith(\"force_stop_\")) {\n ack();\n return;\n }\n\n ack();\n const sessionKey = action.action_id.replace(\"force_stop_\", \"\").replace(/_/g, \":\");\n const userId = body.user?.id;\n const channelId = body.container?.channel_id || sessionKey.split(\":\")[0];\n\n log.logInfo(`[Force Stop] User ${userId} requested force stop for ${sessionKey}`);\n\n // Use handler's forceStop method\n this.handler.forceStop(sessionKey);\n\n // Notify in channel\n await this.postMessage(channelId, formatForceStopped(\"slack\", userId ?? \"unknown\"));\n\n // Refresh home tab\n if (userId) {\n this.webClient.views\n .publish({\n user_id: userId,\n view: this.buildHomeView(),\n })\n .catch((err) => {\n log.logWarning(`Failed to refresh App Home view`, String(err));\n });\n }\n });\n }\n\n /**\n * Log a user message to log.jsonl after attachments are ready.\n */\n private async logUserMessage(event: SlackEvent): Promise<Attachment[]> {\n const user = this.users.get(event.user);\n let attachments: Attachment[] = [];\n let attachmentError: unknown;\n if (event.files) {\n try {\n attachments = await this.store.processAttachments(event.channel, event.files, event.ts);\n } catch (err) {\n attachmentError = err;\n }\n }\n // Always write the text log, even if attachment processing failed — we want\n // a record of the user message regardless of file-handling errors.\n this.logToFile(event.channel, {\n date: new Date(parseFloat(event.ts) * 1000).toISOString(),\n ts: event.ts,\n threadTs: event.thread_ts,\n user: event.user,\n userName: user?.userName,\n displayName: user?.displayName,\n text: event.text,\n attachments,\n isBot: false,\n });\n if (attachmentError) throw attachmentError;\n return attachments;\n }\n\n private async logExternalBotMessage(event: {\n channel: string;\n ts: string;\n thread_ts?: string;\n text?: string;\n subtype?: string;\n bot_id?: string;\n app_id?: string;\n username?: string;\n bot_profile?: { app_id?: string; name?: string; real_name?: string };\n blocks?: unknown[];\n attachments?: unknown[];\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n }): Promise<Attachment[]> {\n const attachments = event.files\n ? await this.store.processAttachments(event.channel, event.files, event.ts)\n : [];\n const botName =\n event.username ?? event.bot_profile?.name ?? event.bot_profile?.real_name ?? event.bot_id;\n this.logToFile(event.channel, {\n date: new Date(parseFloat(event.ts) * 1000).toISOString(),\n ts: event.ts,\n threadTs: event.thread_ts,\n user: event.bot_id ? `bot:${event.bot_id}` : \"external-bot\",\n userName: botName,\n displayName: botName,\n text: buildSlackAppMessageText(event),\n attachments,\n isBot: true,\n botId: event.bot_id,\n appId: event.app_id ?? event.bot_profile?.app_id,\n subtype: event.subtype,\n });\n return attachments;\n }\n\n // ==========================================================================\n // Private - Backfill\n // ==========================================================================\n\n private async getExistingTimestamps(channelId: string): Promise<Set<string>> {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n const timestamps = new Set<string>();\n if (!existsSync(logPath)) return timestamps;\n\n const content = await readFile(logPath, \"utf-8\");\n const lines = content.trim().split(\"\\n\").filter(Boolean);\n for (let i = 0; i < lines.length; i++) {\n try {\n const entry = JSON.parse(lines[i]);\n if (entry.ts) timestamps.add(entry.ts);\n } catch (err) {\n log.logWarning(\n `Skipping malformed log entry at ${logPath}:${i + 1}`,\n err instanceof Error ? err.message : String(err),\n );\n }\n }\n return timestamps;\n }\n\n private async backfillChannel(channelId: string, upperBoundTs?: string): Promise<number> {\n const existingTs = await this.getExistingTimestamps(channelId);\n\n // Find the biggest ts in log.jsonl\n let lastLoggedTs: string | undefined;\n for (const ts of existingTs) {\n if (!lastLoggedTs || parseFloat(ts) > parseFloat(lastLoggedTs)) lastLoggedTs = ts;\n }\n\n type Message = {\n user?: string;\n bot_id?: string;\n app_id?: string;\n username?: string;\n bot_profile?: { app_id?: string; name?: string; real_name?: string };\n blocks?: unknown[];\n attachments?: unknown[];\n text?: string;\n ts?: string;\n thread_ts?: string;\n subtype?: string;\n files?: Array<{ name: string }>;\n };\n const allMessages: Message[] = [];\n\n let cursor: string | undefined;\n let pageCount = 0;\n const maxPages = 3;\n\n do {\n const result = await this.webClient.conversations.history({\n channel: channelId,\n oldest: lastLoggedTs, // Only fetch messages newer than what we have\n latest: upperBoundTs, // Do not race live socket events after startup\n inclusive: false,\n limit: 1000,\n cursor,\n });\n if (result.messages) {\n allMessages.push(...(result.messages as Message[]));\n }\n cursor = result.response_metadata?.next_cursor;\n pageCount++;\n } while (cursor && pageCount < maxPages);\n\n // Filter: include mikan's messages, external app/bot messages, and user messages.\n const relevantMessages = allMessages.filter((msg) => {\n if (!msg.ts || existingTs.has(msg.ts)) return false; // Skip duplicates\n if (msg.user === this.botUserId) return true;\n const isExternalBotMessage = !!msg.bot_id || msg.subtype === \"bot_message\";\n if (isExternalBotMessage) {\n if (this.botId && msg.bot_id === this.botId) return false;\n if (\n msg.subtype !== undefined &&\n msg.subtype !== \"bot_message\" &&\n msg.subtype !== \"file_share\"\n ) {\n return false;\n }\n return (\n !!msg.text ||\n !!(msg.files && msg.files.length > 0) ||\n !!msg.blocks?.length ||\n !!msg.attachments?.length\n );\n }\n if (msg.subtype !== undefined && msg.subtype !== \"file_share\") return false;\n if (!msg.user) return false;\n if (!msg.text && (!msg.files || msg.files.length === 0)) return false;\n return true;\n });\n\n // Reverse to chronological order\n relevantMessages.reverse();\n\n // Log each message to log.jsonl\n for (const msg of relevantMessages) {\n const isMikanMessage = msg.user === this.botUserId;\n const isExternalBotMessage =\n !isMikanMessage && (!!msg.bot_id || msg.subtype === \"bot_message\");\n if (isExternalBotMessage) {\n await this.logExternalBotMessage({ ...msg, channel: channelId, ts: msg.ts! });\n continue;\n }\n\n const user = this.users.get(msg.user!);\n const text = this.stripOwnMention(msg.text);\n const attachments = msg.files\n ? await this.store.processAttachments(channelId, msg.files, msg.ts!)\n : [];\n\n this.logToFile(channelId, {\n date: new Date(parseFloat(msg.ts!) * 1000).toISOString(),\n ts: msg.ts!,\n threadTs: msg.thread_ts,\n user: isMikanMessage ? \"bot\" : msg.user!,\n userName: isMikanMessage ? undefined : user?.userName,\n displayName: isMikanMessage ? undefined : user?.displayName,\n text,\n attachments,\n isBot: isMikanMessage,\n });\n }\n\n return relevantMessages.length;\n }\n\n private async backfillAllChannels(upperBoundTs?: string): Promise<void> {\n const startTime = Date.now();\n\n // Only backfill channels that already have a log.jsonl (mikan has interacted with them before)\n const channelsToBackfill: Array<[string, SlackChannel]> = [];\n for (const [channelId, channel] of this.channels) {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n if (existsSync(logPath)) {\n channelsToBackfill.push([channelId, channel]);\n }\n }\n\n log.logBackfillStart(channelsToBackfill.length);\n\n let totalMessages = 0;\n for (const [channelId, channel] of channelsToBackfill) {\n try {\n const count = await this.backfillChannel(channelId, upperBoundTs);\n if (count > 0) log.logBackfillChannel(channel.name, count);\n totalMessages += count;\n } catch (error) {\n log.logWarning(`Failed to backfill #${channel.name}`, String(error));\n }\n\n // Add delay between channels to avoid hitting Slack rate limits\n if (channelId !== channelsToBackfill[channelsToBackfill.length - 1][0]) {\n await new Promise((resolve) => setTimeout(resolve, 500));\n }\n }\n\n const durationMs = Date.now() - startTime;\n log.logBackfillComplete(totalMessages, durationMs);\n }\n\n // ==========================================================================\n // Private - Fetch Users/Channels\n // ==========================================================================\n\n private async fetchUsers(): Promise<void> {\n let cursor: string | undefined;\n do {\n const result = await this.webClient.users.list({ limit: 200, cursor });\n const members = result.members as\n | Array<{ id?: string; name?: string; real_name?: string; deleted?: boolean }>\n | undefined;\n if (members) {\n for (const u of members) {\n if (u.id && u.name && !u.deleted) {\n this.users.set(u.id, {\n id: u.id,\n userName: u.name,\n displayName: u.real_name || u.name,\n });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n }\n\n private async fetchChannels(): Promise<void> {\n // Fetch public/private channels\n let cursor: string | undefined;\n do {\n const result = await this.webClient.conversations.list({\n types: \"public_channel,private_channel\",\n exclude_archived: true,\n limit: 200,\n cursor,\n });\n const channels = result.channels as\n | Array<{ id?: string; name?: string; is_member?: boolean }>\n | undefined;\n if (channels) {\n for (const c of channels) {\n if (c.id && c.name && c.is_member) {\n this.channels.set(c.id, { id: c.id, name: c.name });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n\n // Also fetch DM channels (IMs)\n cursor = undefined;\n do {\n const result = await this.webClient.conversations.list({\n types: \"im\",\n limit: 200,\n cursor,\n });\n const ims = result.channels as Array<{ id?: string; user?: string }> | undefined;\n if (ims) {\n for (const im of ims) {\n if (im.id) {\n // Use user's name as channel name for DMs\n const user = im.user ? this.users.get(im.user) : undefined;\n const name = user ? `DM:${user.userName}` : `DM:${im.id}`;\n this.channels.set(im.id, { id: im.id, name });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n }\n}\n"]}
@@ -8,8 +8,8 @@ import { PRODUCT_NAME, formatForceStopped, formatNothingRunning } from "../../ui
8
8
  import { appendBotResponseLog, appendChannelLog, ChannelQueue, resolveOnlyScopedStopTarget, resolveStopTarget, withRetry, } from "../shared.js";
9
9
  import { evaluateAutoReplyPolicy } from "../../trigger.js";
10
10
  import { createSlackAdapters } from "./context.js";
11
- import { hasMaterializedSlackBranchSession, registerSlackForkSession } from "./branch-manager.js";
12
- import { isSlackForkSessionKey, planSlackAdapterSession, planSlackEventForkRun, resolveSlackSessionKey, } from "./session.js";
11
+ import { hasMaterializedChatSession, registerThreadSession, } from "../../sessions/chat-session-manager.js";
12
+ import { isSlackThreadSessionKey, planSlackAdapterSession, planSlackEventAnchorRun, resolveSlackSessionKey, } from "./session.js";
13
13
  import { reportUserFacingError } from "../../sentry.js";
14
14
  const SLACK_EVENT_ANCHOR_TEXT = "Working on it...";
15
15
  // Slack WebClient errors carry either `code: "rate_limited"` (retry-after) or
@@ -311,10 +311,10 @@ export class SlackBot {
311
311
  throw err;
312
312
  }
313
313
  }
314
- const eventPlan = planSlackEventForkRun(event, anchorTs);
314
+ const eventPlan = planSlackEventAnchorRun(event, anchorTs);
315
315
  const eventForRun = eventPlan.event;
316
316
  if (eventPlan.initialMessageTs && eventForRun.sessionKey) {
317
- registerSlackForkSession({
317
+ registerThreadSession({
318
318
  conversationDir: join(this.workingDir, conversationId),
319
319
  sessionKey: eventForRun.sessionKey,
320
320
  });
@@ -358,14 +358,17 @@ export class SlackBot {
358
358
  return queue;
359
359
  }
360
360
  resolveQueueKey(conversationId, sessionKey) {
361
- if (!isSlackForkSessionKey(sessionKey))
361
+ if (!isSlackThreadSessionKey(sessionKey))
362
362
  return sessionKey;
363
363
  if (this.handler.isRunning(sessionKey))
364
364
  return sessionKey;
365
- return this.hasKnownForkSession(conversationId, sessionKey) ? sessionKey : conversationId;
365
+ return this.hasKnownThreadSession(conversationId, sessionKey) ? sessionKey : conversationId;
366
366
  }
367
- hasKnownForkSession(conversationId, sessionKey) {
368
- return hasMaterializedSlackBranchSession(join(this.workingDir, conversationId), sessionKey);
367
+ hasKnownThreadSession(conversationId, sessionKey) {
368
+ return hasMaterializedChatSession({
369
+ conversationDir: join(this.workingDir, conversationId),
370
+ sessionKey,
371
+ });
369
372
  }
370
373
  shouldTriggerSharedThreadReply(channelId, threadTs) {
371
374
  if (!threadTs)
@@ -373,7 +376,7 @@ export class SlackBot {
373
376
  const sessionKey = resolveSlackSessionKey(channelId, threadTs);
374
377
  if (this.handler.isRunning(sessionKey))
375
378
  return true;
376
- return this.hasKnownForkSession(channelId, sessionKey);
379
+ return this.hasKnownThreadSession(channelId, sessionKey);
377
380
  }
378
381
  buildHomeView() {
379
382
  const blocks = [
@@ -910,7 +913,7 @@ export class SlackBot {
910
913
  // Auto-reply top-level channel messages start with no sessionKey because
911
914
  // they are only candidates until the policy allows them. Once triggered,
912
915
  // persist the resolved key on the event; otherwise the runtime fallback
913
- // treats the message ts as a branch session (`channel:ts`) instead of the
916
+ // treats the message ts as a thread session (`channel:ts`) instead of the
914
917
  // persistent top-level channel session.
915
918
  slackEvent.sessionKey = activeSessionKey;
916
919
  this.getQueue(this.resolveQueueKey(e.channel, activeSessionKey)).enqueue(async () => {