comfy-pr 0.2.26 → 1.0.1

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 (100) hide show
  1. package/README.md +258 -157
  2. package/bot/IdleWaiter.ts +31 -0
  3. package/bot/RestartManager.spec.ts +163 -0
  4. package/bot/RestartManager.ts +190 -0
  5. package/bot/WorkingTasksManager.spec.ts +154 -0
  6. package/bot/cli.ts +1171 -0
  7. package/bot/codesearch-ai-wip.ts +351 -0
  8. package/bot/error-collector.ts +133 -0
  9. package/bot/index.ts +14 -0
  10. package/bot/restart-example.ts +99 -0
  11. package/bot/slack-bolt.ts +688 -0
  12. package/bot/slack-bot.ts +1575 -0
  13. package/bot/state.ts +19 -0
  14. package/bot/templateLoader.test.ts +84 -0
  15. package/bot/templateLoader.ts +92 -0
  16. package/next.config.ts +26 -0
  17. package/package.json +161 -41
  18. package/post-summary.ts +44 -0
  19. package/src/Authors.ts +2 -2
  20. package/src/CMNodes.ts +3 -3
  21. package/src/CNRepos.ts +30 -7
  22. package/src/CRNodes.ts +11 -9
  23. package/src/CRPulls.ts +3 -0
  24. package/src/EmailTasks.ts +27 -9
  25. package/src/FORK_OWNER.ts +3 -1
  26. package/src/FollowRules.ts +3 -3
  27. package/src/GithubIssueComments.ts +1 -1
  28. package/src/Totals.ts +9 -8
  29. package/src/WorkerInstances.ts +31 -8
  30. package/src/addCommentAction.ts +19 -11
  31. package/src/analyzePullsStatus.ts +39 -17
  32. package/src/analyzeTotals.ts +19 -11
  33. package/src/bypassRepos.ts +6 -5
  34. package/src/checkPRsFailures.ts +13 -8
  35. package/src/cli.test.ts +2 -0
  36. package/src/cli.ts +1 -1
  37. package/src/constants.ts +2 -0
  38. package/src/createComfyRegistryPRsFromCandidates.ts +20 -12
  39. package/src/createComfyRegistryPullRequests.ts +35 -10
  40. package/src/createGithubForkForRepo.ts +36 -11
  41. package/src/createGithubPullRequest.ts +83 -33
  42. package/src/createIssueComment.ts +4 -4
  43. package/src/fetchComfyRegistryNodes.ts +34 -4
  44. package/src/fetchCurrentGeoInfo.ts +3 -1
  45. package/src/fetchRelatedPullWithComments.ts +1 -1
  46. package/src/fetchRepoDescriptionMap.ts +1 -4
  47. package/src/followRuleSchema.ts +10 -4
  48. package/src/getBranchWorkingDir.ts +3 -7
  49. package/src/getRepoWorkingDir.ts +2 -3
  50. package/src/gh.spec.ts +571 -0
  51. package/src/ghData.ts +13 -0
  52. package/src/ghPageFlow.ts +33 -0
  53. package/src/ghSubscriber.ts +76 -0
  54. package/src/ghUser.ts +28 -4
  55. package/src/index.ts +13 -4
  56. package/src/initializeFollowRules.ts +11 -4
  57. package/src/logger.spec.ts +95 -0
  58. package/src/logger.ts +46 -0
  59. package/src/makeGpl3LicenseBranch.ts +66 -0
  60. package/src/makePublishBranch.ts +21 -15
  61. package/src/makeTomlBranch.ts +48 -14
  62. package/src/makeUpdateTomlLicenseBranch.ts +40 -44
  63. package/src/matchRelatedPulls.ts +9 -4
  64. package/src/normalizeGithubUrl.spec.ts +131 -0
  65. package/src/normalizeGithubUrl.ts +64 -0
  66. package/src/parseIssueUrl.spec.ts +95 -0
  67. package/src/parseIssueUrl.ts +17 -3
  68. package/src/parseOwnerRepo.spec.ts +147 -0
  69. package/src/parseOwnerRepo.ts +8 -5
  70. package/src/parsePullsState.ts +2 -2
  71. package/src/parseTitleBodyOfMarkdown.ts +1 -1
  72. package/src/pickRepoInfo.ts +37 -0
  73. package/src/postSlackMessage.ts +5 -1
  74. package/src/preload.ts +30 -27
  75. package/src/readTemplateTitle.ts +1 -3
  76. package/src/sendEmailAction.ts +14 -10
  77. package/src/sendGmail.ts +3 -3
  78. package/src/updateAuthorsForGithub.ts +53 -35
  79. package/src/updateAuthorsFromCNRepo.ts +12 -5
  80. package/src/updateCMNodesDuplicationWarnings.ts +14 -9
  81. package/src/updateCMRepos.ts +1 -1
  82. package/src/updateCNRepos.ts +14 -14
  83. package/src/updateCNReposCRPullsComments.ts +7 -5
  84. package/src/updateCNReposInfo.ts +49 -40
  85. package/src/updateCNReposPRCandidate.ts +3 -3
  86. package/src/updateCNReposPulls.ts +9 -5
  87. package/src/updateCNReposPullsDashboard.ts +11 -9
  88. package/src/updateCNReposRelatedPulls.ts +3 -3
  89. package/src/updateCRNodes.ts +24 -5
  90. package/src/updateCRRepos.ts +1 -1
  91. package/src/updateComfyTotals.ts +7 -5
  92. package/src/updateFollowRuleSet.ts +17 -9
  93. package/src/updateOutdatedPullsTemplates.ts +61 -48
  94. package/src/updateSlackMessages.ts +8 -4
  95. package/tailwind.config.ts +7 -1
  96. package/next-env.d.ts +0 -5
  97. package/src/GIT_USEREMAIL.ts +0 -5
  98. package/src/GIT_USERNAME.ts +0 -9
  99. package/src/clone_modify_push_Branches.ts +0 -21
  100. package/src/tomlFillDescription.ts +0 -17
@@ -0,0 +1,1575 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * ComfyPR Bot
5
+ *
6
+ * Slack
7
+
8
+ */
9
+ import { slack } from "@/lib";
10
+ import { yaml } from "@/src/utils/yaml";
11
+ import { SocketModeClient } from "@slack/socket-mode";
12
+ import {} from "@slack/bolt";
13
+ import DIE from "@snomiao/die";
14
+ import { spawn } from "node:child_process";
15
+ import { compareBy } from "comparing";
16
+ import { fromStdio } from "from-node-stream";
17
+ import { mkdir } from "fs/promises";
18
+ import sflow from "sflow";
19
+ import winston from "winston";
20
+ import zChatCompletion from "../lib/zChat";
21
+ import z from "zod";
22
+ import { IdleWaiter } from "./IdleWaiter";
23
+ import { RestartManager } from "./RestartManager";
24
+ import { parseSlackMessageToMarkdown } from "@/lib/slack/parseSlackMessageToMarkdown";
25
+ import { slackTsToISO } from "@/lib/slack/slackTsToISO";
26
+ import { safeSlackPostMessage, safeSlackUpdateMessage } from "@/lib/slack/safeSlackMessage";
27
+ import { slackMessageUrlParse } from "@/app/tasks/gh-design/slackMessageUrlParse";
28
+ import { TerminalTextRender } from "terminal-render";
29
+ import minimist from "minimist";
30
+ import { loadClaudeMd, loadSkills } from "./templateLoader";
31
+ import path from "path";
32
+ import { appendFile } from "fs/promises";
33
+ import fsp from "fs/promises";
34
+ import { mdFmt } from "@/app/tasks/gh-desktop-release-notification/upsertSlackMessage";
35
+ import { getSlackChannelName } from "@/lib/slack";
36
+ import { SlackBotState } from "./state";
37
+ import { ErrorCollector } from "./error-collector";
38
+
39
+ export const SLACK_ORG_DOMAIN_NAME = "comfy-organization";
40
+ // Configure winston logger
41
+ const logDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
42
+ const logger = winston.createLogger({
43
+ level: process.env.VERBOSE ? "debug" : process.env.LOG_LEVEL || "info",
44
+ format: winston.format.combine(
45
+ winston.format.timestamp(),
46
+ winston.format.errors({ stack: true }),
47
+ winston.format.printf(({ timestamp, level, message, ...meta }) => {
48
+ const metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : "";
49
+ return `[${timestamp}] [${level.toUpperCase()}] ${message}${metaStr ? "\n" + metaStr : ""}`;
50
+ }),
51
+ ),
52
+ transports: [
53
+ new winston.transports.Console({
54
+ format: winston.format.combine(
55
+ winston.format.colorize(),
56
+ winston.format.printf(({ timestamp, level, message, ...meta }) => {
57
+ const metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : "";
58
+ return `[${timestamp}] ${level}: ${message}${metaStr ? "\n" + metaStr : ""}`;
59
+ }),
60
+ ),
61
+ }),
62
+ new winston.transports.File({
63
+ filename: `./.logs/bot-${logDate}.log`,
64
+ level: "debug",
65
+ format: winston.format.combine(
66
+ winston.format.timestamp(),
67
+ winston.format.printf(({ timestamp, level, message, ...meta }) => {
68
+ const metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : "";
69
+ return `[${timestamp}] [${level.toUpperCase()}] ${message}${metaStr ? "\n" + metaStr : ""}`;
70
+ }),
71
+ ),
72
+ }),
73
+ ],
74
+ });
75
+
76
+ const TaskInputFlows = new Map<string, TransformStream<string, string>>();
77
+ // https://comfy-pr-bot.pages.dev/
78
+ // Slack block type definition
79
+ const zSlackBlock = z
80
+ .object({
81
+ type: z.string(),
82
+ block_id: z.string().optional(),
83
+ elements: z.array(z.unknown()).optional(),
84
+ })
85
+ .passthrough();
86
+
87
+ // Slack attachment type definition
88
+ const zSlackAttachment = z
89
+ .object({
90
+ title: z.string().optional(),
91
+ title_link: z.string().optional(),
92
+ text: z.string().optional(),
93
+ fallback: z.string().optional(),
94
+ image_url: z.string().optional(),
95
+ from_url: z.string().optional(),
96
+ })
97
+ .passthrough();
98
+
99
+ const zAppMentionEvent = z.object({
100
+ type: z.literal("app_mention"),
101
+ user: z.string(),
102
+ ts: z.string(),
103
+ client_msg_id: z.string().optional(),
104
+ text: z.string(),
105
+ team: z.string(),
106
+ thread_ts: z.string().optional(),
107
+ parent_user_id: z.string().optional(),
108
+ blocks: z.array(zSlackBlock),
109
+ channel: z.string(),
110
+ assistant_thread: z.unknown().optional(),
111
+ attachments: z.array(zSlackAttachment).optional(),
112
+ event_ts: z.string(),
113
+ });
114
+
115
+ // Helper functions to manage current working tasks
116
+ async function addWorkingTask(event: z.infer<typeof zAppMentionEvent>) {
117
+ const workingTasks = (await SlackBotState.get("current-working-tasks")) || {
118
+ workingMessageEvents: [],
119
+ };
120
+ const events = workingTasks.workingMessageEvents || [];
121
+
122
+ // Check if event already exists (by ts and channel)
123
+ const exists = events.some(
124
+ (e: z.infer<typeof zAppMentionEvent>) => e.ts === event.ts && e.channel === event.channel,
125
+ );
126
+ if (!exists) {
127
+ events.push(event);
128
+ await SlackBotState.set("current-working-tasks", { workingMessageEvents: events });
129
+ logger.info(`Added task to working list: ${event.ts} (total: ${events.length})`);
130
+ }
131
+ }
132
+
133
+ async function removeWorkingTask(event: z.infer<typeof zAppMentionEvent>) {
134
+ const workingTasks = (await SlackBotState.get("current-working-tasks")) || {
135
+ workingMessageEvents: [],
136
+ };
137
+ const events = workingTasks.workingMessageEvents || [];
138
+
139
+ // Remove event by ts and channel
140
+ const filtered = events.filter(
141
+ (e: z.infer<typeof zAppMentionEvent>) => !(e.ts === event.ts && e.channel === event.channel),
142
+ );
143
+ await SlackBotState.set("current-working-tasks", { workingMessageEvents: filtered });
144
+ logger.info(`Removed task from working list: ${event.ts} (remaining: ${filtered.length})`);
145
+ }
146
+ const g = globalThis as typeof globalThis & { instanceId?: string; hotId?: string };
147
+ const now = new Date().toISOString();
148
+ g.instanceId ??= now;
149
+ g.hotId = now;
150
+
151
+ if (import.meta.main) {
152
+ await startSlackBot();
153
+ }
154
+
155
+ export async function startSlackBot() {
156
+ console.log("Starting ComfyPR Bot...");
157
+ const argv = minimist(process.argv.slice(2));
158
+ const port = Number(process.env.PRBOT_PORT || DIE("missing env.PRBOT_PORT"));
159
+
160
+ // Step 1: Health check (only for non-PTY launches)
161
+ const isHumanLaunched = process.stdin.isTTY;
162
+
163
+ if (!isHumanLaunched) {
164
+ // Non-PTY launch (PM2): Poll for 10 seconds to ensure port is continuously unhealthy
165
+ logger.info(`Detected non-PTY launch - polling for 10s to ensure port ${port} is unhealthy`);
166
+
167
+ const pollDuration = 10000; // 10 seconds
168
+ const pollInterval = 1000; // 1 second
169
+ const startTime = Date.now();
170
+ let healthyInstanceFound = false;
171
+
172
+ while (Date.now() - startTime < pollDuration) {
173
+ try {
174
+ const statusResp = await fetch(`http://localhost:${port}/status`, {
175
+ signal: AbortSignal.timeout(1000),
176
+ });
177
+
178
+ if (statusResp.ok) {
179
+ // Found a healthy instance - abort and exit
180
+ const statusData = await statusResp.json();
181
+ healthyInstanceFound = true;
182
+
183
+ // Try to get PID of existing process
184
+ let existingPid = "unknown";
185
+ try {
186
+ const lsofOutput = await Bun.$`lsof -ti:${port}`.text();
187
+ existingPid = lsofOutput.trim();
188
+ } catch {}
189
+
190
+ logger.info(
191
+ `Healthy instance detected (PID: ${existingPid}) - aborting launch to avoid conflict`,
192
+ );
193
+ logger.info(`Status: ${JSON.stringify(statusData)}`);
194
+ process.exit(0);
195
+ }
196
+ } catch (err) {
197
+ // Port is unhealthy/unreachable - this is expected
198
+ logger.debug(
199
+ `Health check: port ${port} is unhealthy (${Date.now() - startTime}ms elapsed)`,
200
+ );
201
+ }
202
+
203
+ await sleep(pollInterval);
204
+ }
205
+
206
+ if (!healthyInstanceFound) {
207
+ logger.info(`Port ${port} remained unhealthy for 10s - proceeding to launch`);
208
+ }
209
+ } else {
210
+ // PTY launch (human): Skip health check entirely
211
+ logger.info(`Detected PTY launch - skipping health check`);
212
+ }
213
+
214
+ // Step 2: Kill port and launch
215
+ logger.info(`Killing port ${port} and starting server`);
216
+ await Bun.$`npx -y kill-port ${port}`;
217
+
218
+ const server = Bun.serve({
219
+ port: port,
220
+ fetch: async (req: Request) => {
221
+ const url = new URL(req.url);
222
+
223
+ if (url.pathname === "/status") {
224
+ // Get current working tasks from state
225
+ const workingTasks = (await SlackBotState.get("current-working-tasks")) || {
226
+ workingMessageEvents: [],
227
+ };
228
+ const events = workingTasks.workingMessageEvents || [];
229
+
230
+ // Build message URLs from events
231
+ const processing_message_urls = events.map((event: z.infer<typeof zAppMentionEvent>) => {
232
+ const tsForUrl = event.ts.replace(".", "");
233
+ return `https://${SLACK_ORG_DOMAIN_NAME}.slack.com/archives/${event.channel}/p${tsForUrl}`;
234
+ });
235
+
236
+ const status = {
237
+ status: TaskInputFlows.size === 0 ? "idle" : "busy",
238
+ processing_message_urls,
239
+ processing_message_urls_count: processing_message_urls.length,
240
+ };
241
+
242
+ return new Response(JSON.stringify(status, null, 2), {
243
+ status: 200,
244
+ headers: { "Content-Type": "application/json" },
245
+ });
246
+ }
247
+
248
+ return new Response("ComfyPR Bot is running.\n", { status: 200 });
249
+ },
250
+ });
251
+
252
+ // const missedMsg = "https://comfy-organization.slack.com/archives/C09QKKXK8RX/p1767849032076329?thread_ts=1767838632.470639&cid=C09QKKXK8RX"
253
+ // const missedMsg = "https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1767893546609709?thread_ts=1767862331.962569&cid=C0A4XMHANP3"
254
+ // await spawnBotOnSlackMessageUrl(
255
+ // "https://comfy-organization.slack.com/archives/D09GGTE7S00/p1769576340892099",
256
+ // );
257
+
258
+ const msgs = await fsp
259
+ .readFile("./msgs.yaml", "utf-8")
260
+ .then((s) => yaml.parse(s))
261
+ .then((e) => z.object({ missed: z.string().array() }).parseAsync(e));
262
+ await fsp.writeFile("./msgs.yaml", "missed: []");
263
+
264
+ // clean the file
265
+ //
266
+ sflow(msgs.missed)
267
+ .forEach((url) => spawnBotOnSlackMessageUrl(url))
268
+ .run();
269
+
270
+ if (argv.continue) {
271
+ async () => {
272
+ logger.info("BOT - --continue flag detected, resuming crashed tasks...");
273
+
274
+ // Read current working tasks from state
275
+ const workingTasks = (await SlackBotState.get("current-working-tasks")) || {
276
+ workingMessageEvents: [],
277
+ };
278
+ const events = workingTasks.workingMessageEvents || [];
279
+
280
+ if (events.length === 0) {
281
+ logger.info("No working tasks to resume");
282
+ } else {
283
+ logger.info(`Found ${events.length} working task(s) to resume`);
284
+
285
+ for await (const event of events) {
286
+ if (event && event.ts) {
287
+ logger.info(
288
+ `Resuming task for event ${event.ts} in channel ${await getSlackChannelName(event.channel)}, text: ${event.text}`,
289
+ );
290
+ await spawnBotOnSlackMessageEvent(event).catch((err) => {
291
+ logger.error(`Error resuming task for event ${event.ts}`, { err });
292
+ });
293
+ }
294
+ }
295
+ }
296
+ };
297
+ }
298
+
299
+ logger.info(`Starting ComfyPR Bot... id: ${g.instanceId}, hotId: ${g.hotId}`);
300
+
301
+ // Setup smart restart manager (only restart when bot is idle)
302
+ if (!argv["no-watch"]) {
303
+ const restartManager = new RestartManager({
304
+ watchPaths: ["bot", "src", "lib"],
305
+ isIdle: () => TaskInputFlows.size === 0,
306
+ onRestart: () => {
307
+ logger.warn("🔄 Restarting bot process...");
308
+ process.exit(0);
309
+ },
310
+ idleCheckInterval: 5000,
311
+ debounceDelay: 1000,
312
+ logger: {
313
+ info: (msg, meta) => logger.info(`[RestartManager] ${msg}`, meta),
314
+ warn: (msg, meta) => logger.warn(`[RestartManager] ${msg}`, meta),
315
+ },
316
+ });
317
+ restartManager.start();
318
+ logger.info("Smart restart manager enabled (use --no-watch to disable)");
319
+ }
320
+
321
+ // Initialize Socket Mode client with app-level token
322
+ const socketModeClient = new SocketModeClient({
323
+ appToken: process.env.SLACK_SOCKET_TOKEN || DIE("missing env.SLACK_SOCKET_TOKEN"),
324
+ });
325
+
326
+ // Handle all events via events_api envelope, https://docs.slack.dev/reference/events/message
327
+ socketModeClient
328
+ .on("app_mention", async ({ event, body, ack }) => {
329
+ const parsedEvent = await zAppMentionEvent.parseAsync(event);
330
+
331
+ // Acknowledge the event as its parsed
332
+ await ack();
333
+ await spawnBotOnSlackMessageEvent(parsedEvent);
334
+ })
335
+ .on("message", async ({ event, body, ack }) => {
336
+ // bot-1 | msg: {"type":"message","user":"U04F3GHTG2X","ts":"1767100459.669809","client_msg_id":"2fed13c0-9739-4888-a4f6-b876c25f1407","text":"test","team":"T0462DJ9G3C","blocks":[{"type":"rich_text","block_id":"gB9fq","elements":[{"type":"rich_text_section","elements":[{"type":"text","text":"test"}]}]}],"channel":"C0A6Y4AU52L","event_ts":"1767100459.669809","channel_type":"channel"}
337
+ // Parse the message event
338
+ const zSlackMessage = z
339
+ .object({
340
+ type: z.literal("message"),
341
+ user: z.string().optional(),
342
+ ts: z.string().optional(),
343
+ client_msg_id: z.string().optional(),
344
+ text: z.string().optional(),
345
+ team: z.string().optional(),
346
+ thread_ts: z.string().optional(),
347
+ parent_user_id: z.string().optional(),
348
+ blocks: z.array(zSlackBlock).optional(),
349
+ channel: z.string().optional(),
350
+ channel_type: z.string().optional(),
351
+ assistant_thread: z.unknown().optional(),
352
+ attachments: z.array(zSlackAttachment).optional(),
353
+ event_ts: z.string().optional(),
354
+ bot_id: z.string().optional(),
355
+ })
356
+ .passthrough();
357
+
358
+ const messageEvent = zSlackMessage.parse(event);
359
+
360
+ logger.debug("MESSAGE EVENT", { event });
361
+ logger.debug("parsed_text: " + (await parseSlackMessageToMarkdown(messageEvent.text || "")));
362
+
363
+ await ack();
364
+
365
+ // Skip bot messages
366
+ if (messageEvent.bot_id) {
367
+ return;
368
+ }
369
+
370
+ // Get my bot user ID
371
+ const botUsername = "comfyprbot";
372
+ // TODO: fetch botUserId by botUsername or use slack api to "get my name"
373
+ const botUserId = process.env.SLACK_BOT_USER_ID || "U078499LK5K"; // ComfyPR-Bot user ID
374
+
375
+ // Check if message mentions the bot
376
+ const text = messageEvent.text || "";
377
+ const hasBotMention = text.includes(`<@${botUserId}>`);
378
+
379
+ // Handle DM messages (channel_type: "im") and treat them like app mentions
380
+ const isDM = messageEvent.channel_type === "im";
381
+
382
+ if (
383
+ (isDM || hasBotMention) &&
384
+ messageEvent.user &&
385
+ messageEvent.text &&
386
+ messageEvent.channel &&
387
+ messageEvent.ts &&
388
+ messageEvent.team &&
389
+ messageEvent.event_ts
390
+ ) {
391
+ const eventType = isDM ? "DM" : "BOT MENTION";
392
+ logger.debug(`${eventType} DETECTED - Processing message as app_mention`, {
393
+ channel: messageEvent.channel,
394
+ ts: messageEvent.ts,
395
+ text: text.substring(0, 100),
396
+ });
397
+
398
+ const mentionEvent: z.infer<typeof zAppMentionEvent> = {
399
+ type: "app_mention" as const,
400
+ user: messageEvent.user,
401
+ ts: messageEvent.ts,
402
+ client_msg_id: messageEvent.client_msg_id,
403
+ text: messageEvent.text,
404
+ team: messageEvent.team,
405
+ thread_ts: messageEvent.thread_ts,
406
+ parent_user_id: messageEvent.parent_user_id,
407
+ blocks: messageEvent.blocks || [],
408
+ channel: messageEvent.channel,
409
+ assistant_thread: messageEvent.assistant_thread,
410
+ attachments: messageEvent.attachments,
411
+ event_ts: messageEvent.event_ts,
412
+ };
413
+ await spawnBotOnSlackMessageEvent(mentionEvent);
414
+ }
415
+ })
416
+ .on("error", (error) => {
417
+ logger.error("Socket Mode error", { error });
418
+ })
419
+ .on("connect", () => logger.info("SOCKET - Slack connected"))
420
+ .on("disconnect", () => logger.info("SOCKET - Slack disconnected"))
421
+ .on("ready", () => logger.info("SOCKET - Ready to receive events"));
422
+
423
+ logger.info("BOT - Connecting to Slack Socket Mode...");
424
+ await socketModeClient.start();
425
+ logger.info("BOT - socketModeClient.start() returned");
426
+ return socketModeClient;
427
+ }
428
+ async function spawnBotOnSlackMessageEvent(event: z.infer<typeof zAppMentionEvent>) {
429
+ // msg dedup for same content
430
+ const eventProcessed = await SlackBotState.get(`msg-${event.ts}`);
431
+ // if (eventProcessed?.content === event.text) return;
432
+ if (+new Date() - (eventProcessed?.touchedAt ?? 0) <= 10e3) return; // debounce for 10s
433
+ await SlackBotState.set(`msg-${event.ts}`, { touchedAt: +new Date(), content: event.text });
434
+
435
+ logger.info(
436
+ await parseSlackMessageToMarkdown(
437
+ `SPAWN - Received Slack app_mention event in channel <#${event.channel}> from user <@${event.user}>`,
438
+ ),
439
+ );
440
+
441
+ // whitelist channel name #comfypr-bot, for security reason, only runs agent against @mention messages in #comfyprbot channel
442
+ // you can forward other channel messages to #comfyprbot if needed, and the bot will read the context from the original thread messages
443
+ // DMs are also allowed to spawn agents directly
444
+ const channelInfo = await slack.conversations.info({ channel: event.channel });
445
+ const channelName = channelInfo.channel?.name;
446
+ const isDM = channelInfo.channel?.is_im === true;
447
+ const isAgentChannel = isDM || channelName?.match(/^(comfypr-bot|pr-bot)\b/); //starts with comfyprbot or pr-bot, will spawn agent without requireing @mention
448
+
449
+ const user =
450
+ (await slack.users.info({ user: event.user })).user ||
451
+ DIE("failed to fetch user info of <@" + event.user + ">");
452
+ if (user?.is_restricted || user?.is_ultra_restricted) {
453
+ logger.info(`User ${event.user} is a guest user, skipping processing.`);
454
+ return;
455
+ }
456
+
457
+ const username =
458
+ user.name ||
459
+ user.id?.replace(/(.*)/, "<@$1>") ||
460
+ DIE("failed to get username of <@" + event.user + ">");
461
+
462
+ // task state
463
+ const workspaceId = event.thread_ts || event.ts;
464
+ const eventId = event.channel + "_" + event.ts;
465
+ logger.info(
466
+ `Processing Slack app_mention event in channel ${event.channel} (isAgentChannel: ${isAgentChannel}) with workspaceId: ${workspaceId}`,
467
+ );
468
+ const botWorkingDir = `/bot/slack/${sanitized(channelName || username)}/${workspaceId.replace(".", "-")}`;
469
+ const task = await SlackBotState.get(`task-${workspaceId}`);
470
+
471
+ // Allow append messages to running task
472
+
473
+ // grab 100 most nearby messages in this thread or channel
474
+ const nearbyMessagesResp = await slack.conversations.replies({
475
+ channel: event.channel,
476
+ ts: workspaceId,
477
+ limit: 100,
478
+ });
479
+ // Type definitions for Slack message components
480
+ type SlackFile = {
481
+ name?: string;
482
+ title?: string;
483
+ mimetype?: string;
484
+ size?: number;
485
+ url_private?: string;
486
+ permalink?: string;
487
+ };
488
+
489
+ type SlackReaction = {
490
+ name?: string;
491
+ count?: number;
492
+ };
493
+
494
+ const nearbyMessages = (
495
+ await sflow(nearbyMessagesResp.messages || [])
496
+ .map(async (m) => ({
497
+ username: await slack.users
498
+ .info({ user: m.user || DIE("missing user id in message") })
499
+ .then((res) => res.user?.name || "<@" + m.user + ">"),
500
+ markdown: await parseSlackMessageToMarkdown(m.text || ""),
501
+ ts: m.ts,
502
+ iso: slackTsToISO(m.ts || DIE("missing ts")),
503
+ ...(m.files &&
504
+ m.files.length > 0 && {
505
+ files: m.files.map((f: unknown) => {
506
+ const file = f as SlackFile;
507
+ return {
508
+ name: file.name,
509
+ title: file.title,
510
+ mimetype: file.mimetype,
511
+ size: file.size,
512
+ url_private: file.url_private,
513
+ permalink: file.permalink,
514
+ };
515
+ }),
516
+ }),
517
+ ...(m.attachments &&
518
+ m.attachments.length > 0 && {
519
+ attachments: await Promise.all(
520
+ m.attachments.map(async (a: unknown) => {
521
+ const attachment = a as z.infer<typeof zSlackAttachment>;
522
+ // Parse from_url to extract channel name
523
+ let from_channel: string | undefined;
524
+ if (attachment.from_url) {
525
+ try {
526
+ const parsed = slackMessageUrlParse(attachment.from_url);
527
+ const channelInfo = await slack.conversations.info({ channel: parsed.channel });
528
+ from_channel = channelInfo.channel?.name
529
+ ? `#${channelInfo.channel.name}`
530
+ : undefined;
531
+ } catch {
532
+ // Ignore parsing errors
533
+ }
534
+ }
535
+ return {
536
+ title: attachment.title,
537
+ title_link: attachment.title_link,
538
+ text: attachment.text
539
+ ? await parseSlackMessageToMarkdown(attachment.text)
540
+ : undefined,
541
+ fallback: attachment.fallback
542
+ ? await parseSlackMessageToMarkdown(attachment.fallback)
543
+ : undefined,
544
+ image_url: attachment.image_url,
545
+ from_url: attachment.from_url,
546
+ from_channel, // Add resolved channel name
547
+ };
548
+ }),
549
+ ),
550
+ }),
551
+ ...(m.reactions &&
552
+ m.reactions.length > 0 && {
553
+ reactions: m.reactions.map((r: unknown) => {
554
+ const reaction = r as SlackReaction;
555
+ return {
556
+ name: reaction.name,
557
+ count: reaction.count,
558
+ };
559
+ }),
560
+ }),
561
+ }))
562
+ .toArray()
563
+ ).toSorted(compareBy((e) => +(e.ts || 0))); // sort by ts asc
564
+
565
+ const existedTaskInputFlow = TaskInputFlows.get(workspaceId);
566
+ if (existedTaskInputFlow && false) {
567
+ // disable for now, lets use --queue to serialize tasks
568
+ // while agent still running, user sent new message in the same thread very quickly
569
+ // lets understand the user's intent and give a quick response, and then append the msg to the existing agent input flow
570
+ // const threadMessages = await pageFlow(undefined as undefined | string, async (cursor, limit = 100) => {
571
+ // const resp = await slack.conversations.replies({
572
+ // channel: event.channel,
573
+ // ts: workspaceId,
574
+ // cursor,
575
+ // limit,
576
+ // });
577
+ // return {
578
+ // data: resp.messages || [],
579
+ // next: resp.response_metadata?.next_cursor,
580
+ // };
581
+ // })
582
+ // .flat()
583
+ // .map(async (m) => ({
584
+ // ts: slackTsToISO(m.ts || DIE("missing ts")),
585
+ // username: await slack.users
586
+ // .info({ user: m.user || DIE("missing user id in message") })
587
+ // .then((res) => res.user?.name || "<@" + m.user + ">"),
588
+ // markdown: await parseSlackMessageToMarkdown(m.text || ""),
589
+ // }))
590
+ // .toArray();
591
+
592
+ // use LLM to understand the new message intent
593
+ const action = await zChatCompletion(
594
+ z.object({
595
+ user_intent: z.string(),
596
+ my_quick_respond: z.string(),
597
+ stop_existing_task: z.boolean(),
598
+ msg_to_append_to_agent: z.string(),
599
+ }),
600
+ { model: "gpt-4o" },
601
+ )`
602
+ The user sent a new message in a Slack thread where I am already assisting them with an ongoing task. The new message is as follows:
603
+ ${event.text}
604
+
605
+ The thread's recent messages are:
606
+ ${((data: string) => {
607
+ logger.debug("Thread messages:", { data });
608
+ return data;
609
+ })(
610
+ yaml.stringify(
611
+ nearbyMessages.toSorted(compareBy((e) => +(e.ts || 0))), // sort by ts asc
612
+ ),
613
+ )}
614
+
615
+ Based on the new message and the thread context,
616
+
617
+ Please analyze the new message and determine:
618
+ 1. The user's intent behind this new message.
619
+ 2. A quick response I can send to the user right away to acknowledge their new message.
620
+ 3. Whether I should append this new message to the existing task's input flow for further processing.
621
+ 4. Whether I should stop the existing task based on this new message.
622
+
623
+ Respond in JSON format with the following fields:
624
+ - user_intent: A brief description of the user's intent regarding the new message.
625
+ - my_quick_respond: A short message I can send to the user immediately.
626
+ - stop_existing_task: true or false, indicating whether to stop the existing task.
627
+ - msg_to_append_to_agent: The content of the new message to append to the existing task's input flow. Use empty string "" if not applicable.
628
+ `;
629
+ logger.info("New message intent analysis", { action });
630
+
631
+ // send quick response
632
+ const myQuickRespondMsg = await safeSlackPostMessage(slack, {
633
+ channel: event.channel,
634
+ thread_ts: event.ts,
635
+ text: action.my_quick_respond, // Fallback text for notifications
636
+ blocks: [
637
+ {
638
+ type: "markdown",
639
+ text: action.my_quick_respond,
640
+ },
641
+ ],
642
+ });
643
+
644
+ if (action.stop_existing_task) {
645
+ // stop existing task
646
+ TaskInputFlows.delete(workspaceId);
647
+ await safeSlackPostMessage(slack, {
648
+ channel: event.channel,
649
+ thread_ts: event.thread_ts || event.ts,
650
+ text: `The existing task has been stopped as per your request.`, // Fallback text for notifications
651
+ blocks: [
652
+ {
653
+ type: "markdown",
654
+ text: `The existing task has been stopped as per your request.`,
655
+ },
656
+ ],
657
+ });
658
+ await SlackBotState.set(`task-${workspaceId}`, {
659
+ ...(await SlackBotState.get(`task-${workspaceId}`)),
660
+ status: "stopped_by_user",
661
+ });
662
+
663
+ // Remove task from working list
664
+ await removeWorkingTask(event);
665
+
666
+ return "existing task stopped by user";
667
+ }
668
+ if (action.msg_to_append_to_agent && action.msg_to_append_to_agent.trim()) {
669
+ if (!existedTaskInputFlow) {
670
+ logger.warn("No existing task input flow found");
671
+ return;
672
+ }
673
+ const w = existedTaskInputFlow!.writable.getWriter();
674
+ await w.write(
675
+ await parseSlackMessageToMarkdown(
676
+ `New message from <@${event.user}> in the thread:\n${event.text}\n\nMy quick response to the user: ${action.my_quick_respond}\n\n`,
677
+ ),
678
+ );
679
+ w.releaseLock();
680
+ logger.info(`Appended new message to existing task ${workspaceId} input flow`);
681
+ return "msg appended to existing task";
682
+ }
683
+ return;
684
+ }
685
+
686
+ const taskInputFlow = new TransformStream<string, string>();
687
+ TaskInputFlows.set(workspaceId, taskInputFlow); // able to append more inputs later
688
+
689
+ // mark that msg as seeing
690
+ await SlackBotState.set(`task-${workspaceId}`, {
691
+ ...(await SlackBotState.get(`task-${workspaceId}`)),
692
+ status: "checking",
693
+ event,
694
+ startTime: Date.now(),
695
+ });
696
+ await slack.reactions
697
+ .add({ name: "eyes", channel: event.channel, timestamp: event.ts })
698
+ .catch(() => {});
699
+
700
+ // quick-intent-detect-respond by chatgpt, give quick plan/context responds before start heavy agent work
701
+ const resp = await zChatCompletion(
702
+ z.object({
703
+ user_intent: z.string(),
704
+ my_respond_before_spawn_agent: z.string(),
705
+ should_spawn_agent: z.boolean(),
706
+ }),
707
+ {
708
+ model: "gpt-4o",
709
+ },
710
+ )`
711
+ The user mentioned me with the following message in Slack: ${event.text}
712
+ Based on this message, please determine the user's intent in a concise manner.
713
+ Also, provide a brief response that I can send to the user immediately to acknowledge their request.
714
+ Finally, I will spawn an agent to help with this request if necessary.
715
+
716
+ For context, Recent messages from this thread are as follows:
717
+ ${nearbyMessages.map((m) => `- User ${m.username} said: ${JSON.stringify(m.markdown)}`).join("\n\n")}
718
+
719
+ Possible Context Repos:
720
+ - https://github.com/comfyanonymous/ComfyUI: The main ComfyUI repository containing the core application logic and features. Its a python backend to run unknown machine learning models and solves various machine learning tasks.
721
+ - https://github.com/Comfy-Org/ComfyUI_frontend: The frontend codebase for ComfyuUI, built with Vue and TypeScript.
722
+ - https://github.com/Comfy-Org/docs: Documentation for ComfyUI, including setup guides, tutorials, and API references.
723
+ - https://github.com/Comfy-Org/desktop: The desktop application for ComfyUI, providing a user-friendly interface and additional functionalities.
724
+ - https://github.com/Comfy-Org/registry: The registry.comfy.org, where users can share and discover ComfyUI custom-nodes, and extensions.
725
+ - https://github.com/Comfy-Org/workflow_templates: A collection of official shared workflow templates for ComfyUI to help users get started quickly.
726
+
727
+ - https://github.com/Comfy-Org/comfy-api: A RESTful API service for comfy-registry, it stores custom-node metadatas and user profile/billings informations.
728
+
729
+ - And also other repos under Comfy-Org organization on GitHub.
730
+
731
+ Respond in JSON format with the following fields:
732
+ - user_intent: A brief description of the user's intent. e.g. "The user is asking for help with setting up a CI/CD pipeline."
733
+ - my_respond_before_spawn_agent: A short message I can send to the user right away. e.g. "Got it, let me look into that for you."
734
+ - should_spawn_agent: true if further research needed
735
+ `;
736
+
737
+ const myResponseMessage = await mdFmt(resp.my_respond_before_spawn_agent);
738
+ // - spawn_agent?: true or false, indicating whether an agent is needed to handle this request. e.g. if the user is asking for complex tasks like searching the web, managing repositories, or interacting with other services, or need to check original thread, set this to true.
739
+ logger.info("Intent detection response", JSON.stringify({ resp }));
740
+
741
+ // upsert quick respond msg
742
+ type QuickRespondMsg = { ts: string; text: string; channel?: string; url?: string };
743
+ const quickRespondMsg = await SlackBotState.get(`task-quick-respond-msg-${eventId}`).then(
744
+ async (existing: QuickRespondMsg | undefined) => {
745
+ if (existing) {
746
+ await slack.reactions
747
+ .remove({ name: "x", channel: existing.channel!, timestamp: existing.ts! })
748
+ .catch(() => {});
749
+
750
+ // if its a DM, always create a new message
751
+ // if (isDM) {
752
+ // const newMsg = await slack.chat.postMessage({
753
+ // channel: event.channel,
754
+ // thread_ts: event.ts,
755
+ // text: myResponseMessage,
756
+ // blocks: [
757
+ // {
758
+ // type: "markdown",
759
+ // text: myResponseMessage,
760
+ // },
761
+ // ],
762
+ // });
763
+ // await State.set(`task-quick-respond-msg-${eventId}`, { ts: newMsg.ts!, text: myResponseMessage });
764
+ // return { ...newMsg, text: myResponseMessage };
765
+ // }
766
+ // actually lets always post new msg for now.
767
+ // if (true) {
768
+ // const newMsg = await slack.chat.postMessage({
769
+ // channel: event.channel,
770
+ // thread_ts: event.ts,
771
+ // text: myResponseMessage,
772
+ // blocks: [
773
+ // {
774
+ // type: "markdown",
775
+ // text: myResponseMessage,
776
+ // },
777
+ // ],
778
+ // });
779
+ // await State.set(`task-quick-respond-msg-${eventId}`, { ts: newMsg.ts!, text: myResponseMessage });
780
+ // return { ...newMsg, text: myResponseMessage };
781
+ // }
782
+
783
+ const msg = await safeSlackUpdateMessage(slack, {
784
+ channel: event.channel,
785
+ ts: existing.ts,
786
+ text: myResponseMessage, // Fallback text for notifications
787
+ blocks: [
788
+ {
789
+ type: "markdown",
790
+ text: myResponseMessage,
791
+ },
792
+ ],
793
+ });
794
+ await SlackBotState.set(`task-quick-respond-msg-${eventId}`, {
795
+ ts: existing.ts,
796
+ text: myResponseMessage,
797
+ channel: event.channel,
798
+ url: `https://${SLACK_ORG_DOMAIN_NAME}.slack.com/archives/${event.channel}/p${existing.ts.replace(".", "")}`,
799
+ });
800
+ return { ...msg, text: myResponseMessage };
801
+ } else {
802
+ const newMsg = await safeSlackPostMessage(slack, {
803
+ channel: event.channel,
804
+ thread_ts: event.ts,
805
+ text: myResponseMessage, // Fallback text for notifications
806
+ blocks: [
807
+ {
808
+ type: "markdown",
809
+ text: myResponseMessage,
810
+ },
811
+ ],
812
+ });
813
+ await SlackBotState.set(`task-quick-respond-msg-${eventId}`, {
814
+ ts: newMsg.ts!,
815
+ text: myResponseMessage,
816
+ channel: event.channel,
817
+ url: `https://${SLACK_ORG_DOMAIN_NAME}.slack.com/archives/${event.channel}/p${newMsg.ts!.replace(".", "")}`,
818
+ });
819
+ return { ...newMsg, text: myResponseMessage };
820
+ }
821
+ },
822
+ );
823
+
824
+ // and now, lets update quickRespondMsg freq until user is satisfied or agent finished its work
825
+
826
+ // spawn agent if needed & allowed
827
+ // if (!resp.should_spawn_agent) {
828
+ // // update status
829
+ // await slack.reactions.remove({ name: 'eyes', channel: event.channel, timestamp: event.ts, });
830
+ // await slack.reactions.add({ name: 'white_check_mark', channel: event.channel, timestamp: event.ts, });j
831
+ // await State.set(`task-${workspaceId}`, { ...await State.get(`task-${workspaceId}`), status: 'done' });
832
+ // return 'no agent spawned'
833
+ // }
834
+
835
+ // The problem not easy to solve in original thread, lets forward this message to #prbot channel, and then spawn agent using that message.
836
+ // if (!isAgentChannel) {
837
+ // // update status, remove eye, add forwarding reaction
838
+ // await slack.reactions.remove({ name: "eyes", channel: event.channel, timestamp: event.ts }).catch(() => { });
839
+ // await slack.reactions.add({ name: "arrow_right", channel: event.channel, timestamp: event.ts }).catch(() => { });
840
+
841
+ // const originalMessageUrl = `https://${event.team}.slack.com/archives/${event.channel}/p${event.ts.replace(".", "")}`;
842
+ // // forward msg to #prbot channel, mention original msg user:content, and the original msg url for agent to read
843
+ // const agentChannelId =
844
+ // (await slack.conversations.list({ types: "public_channel" })).channels?.find((c) => c.name === "pr-bot")?.id ||
845
+ // DIE("failed to find #prbot channel id");
846
+ // // this is a user facing msg to tell user we are forwarding the msg
847
+ // const text = `Forwarded message from <@${event.user}> in <#${event.channel}>:\n${await parseSlackMessageToMarkdown(event.text)}\n\nYou can view the original message here: ${originalMessageUrl}`;
848
+ // const forwardedMsg = await slack.chat.postMessage({
849
+ // channel: agentChannelId,
850
+ // text,
851
+ // });
852
+
853
+ // // mention forwarded msg in original thread says I will continue there
854
+ // await slack.chat.update({
855
+ // channel: event.channel,
856
+ // ts: quickRespondMsg.ts!,
857
+ // markdown_text: `${myResponseMessage}\n\nI have forwarded your message to <#${agentChannelId}>. I will continue the research there.`,
858
+ // });
859
+ // await State.set(`task-${workspaceId}`, { ...(await State.get(`task-${workspaceId}`)), status: "forward_to_pr_bot_channel" });
860
+
861
+ // // process the forwarded message in agent channel
862
+ // return await spawnBotOnSlackMessageEvent({
863
+ // ...event,
864
+ // channel: agentChannelId,
865
+ // ts: forwardedMsg.ts!,
866
+ // thread_ts: undefined,
867
+ // text: forwardedMsg.text || "",
868
+ // });
869
+ // }
870
+
871
+ await SlackBotState.set(`task-${workspaceId}`, {
872
+ ...(await SlackBotState.get(`task-${workspaceId}`)),
873
+ status: "thinking",
874
+ event,
875
+ });
876
+
877
+ // Add task to working list
878
+ await addWorkingTask(event);
879
+
880
+ slack.reactions
881
+ .remove({ name: "eyes", channel: event.channel, timestamp: event.ts })
882
+ .catch(() => {});
883
+ slack.reactions
884
+ .add({ name: "thinking_face", channel: event.channel, timestamp: event.ts })
885
+ .catch(() => {});
886
+
887
+ const CLAUDEMD = loadClaudeMd({
888
+ EVENT_CHANNEL: event.channel,
889
+ QUICK_RESPOND_MSG_TS: quickRespondMsg.ts!,
890
+ USERNAME: username,
891
+ NEARBY_MESSAGES_YAML: yaml.stringify(nearbyMessages),
892
+ EVENT_TEXT_JSON: JSON.stringify(await parseSlackMessageToMarkdown(event.text)),
893
+ USER_INTENT: resp.user_intent,
894
+ MY_RESPONSE_MESSAGE_JSON: JSON.stringify(myResponseMessage),
895
+ EVENT_THREAD_TS: event.thread_ts || event.ts,
896
+ });
897
+
898
+ // const taskUser = `bot-user-${workspaceId.replace(".", "-")}`;
899
+ // const taskUser = `bot-user-${workspaceId.replace(".", "-")}`;
900
+ await mkdir(botWorkingDir, { recursive: true });
901
+ // todo: create a linux user for task
902
+
903
+ // fill initial files for agent
904
+
905
+ await Bun.write(`${botWorkingDir}/CLAUDE.md`, CLAUDEMD);
906
+
907
+ // clone https://github.com/Comfy-Org/Comfy-PR/tree/sno-bot to ./repos/prbot (branch: sno-bot)
908
+ const prBotRepoDir = `${botWorkingDir}/codes/Comfy-Org/pr-bot/tree/main`;
909
+ await mkdir(prBotRepoDir, { recursive: true });
910
+ await Bun.$`git clone --branch main https://github.com/Comfy-Org/Comfy-PR ${prBotRepoDir}`.catch(
911
+ () => null,
912
+ );
913
+
914
+ // await Bun.write(`${botWorkingDir}/PROMPT.txt`, agentPrompt);
915
+
916
+ // Add Claude Skills to working dir (.claude/skills)
917
+ // Reference: https://docs.claude.ai/en/claude-code/skills
918
+ const skillsBase = `${botWorkingDir}/.claude/skills`;
919
+ await mkdir(skillsBase, { recursive: true });
920
+ const skills = loadSkills({
921
+ EVENT_CHANNEL: event.channel,
922
+ QUICK_RESPOND_MSG_TS: quickRespondMsg.ts!,
923
+ EVENT_THREAD_TS: event.thread_ts || event.ts,
924
+ });
925
+
926
+ for (const [dir, content] of Object.entries(skills)) {
927
+ const p = `${skillsBase}/${dir}`;
928
+ await mkdir(p, { recursive: true });
929
+ await Bun.write(`${p}/SKILL.md`, content);
930
+ }
931
+
932
+ // Index file to make skills easy to discover alongside CLAUDE.md
933
+ await Bun.write(
934
+ `${botWorkingDir}/SKILLS.txt`,
935
+ `
936
+ Available Skills (.claude/skills):
937
+ - slack-messaging: Communicate in Slack threads using prbot slack commands.
938
+ - slack-file-sharing: Upload and download files, share deliverables with users.
939
+ - github-pr-bot: Delegate all code changes via prbot pr command.
940
+ - code-search: Search ComfyUI code using prbot code search.
941
+ - github-issue-search: Search issues and PRs using prbot github-issue search.
942
+ - notion-search: Discover and cite internal Notion pages using prbot notion search.
943
+ - registry-search: Search custom nodes using prbot registry search.
944
+ - repo-reading: Clone and inspect Comfy-Org repos read-only, or use prbot code search.
945
+ - web-research: Pull in external context and cite sources.
946
+
947
+ Open the corresponding SKILL.md under .claude/skills/<name>/ for details.
948
+ `,
949
+ );
950
+
951
+ await Bun.write(
952
+ `${botWorkingDir}/TODO.md`,
953
+ `
954
+ # Task TODOs
955
+
956
+ - Analyze the user's request and gather necessary information.
957
+ - Search relevant documents, codebases, and resources using prbot CLI:
958
+ - Code search: prbot code search --query="<search terms>" [--repo=<owner/repo>]
959
+ - Issue search: prbot github-issue search --query="<search terms>"
960
+ - Notion search: prbot notion search --query="<search terms>"
961
+ - Registry search: prbot registry search --query="<search terms>"
962
+ - Coordinate with prbot agents for unknown coding tasks:
963
+ - prbot pr --repo=<owner/repo> --prompt="<detailed coding task>"
964
+ - For each deliverable: save to ./deliverable-<name>.md then immediately upload to Slack.
965
+ - Compile findings and provide a comprehensive response to the user.
966
+
967
+ ## GitHub Changes
968
+ - IMPORTANT: Remember to use the prbot CLI for unknown GitHub code changes:
969
+ prbot pr --repo=<owner/repo> [--branch=<branch>] --prompt="<detailed coding task>"
970
+
971
+ ## Deliverables Convention
972
+ - ALWAYS save any document, guide, report, or artifact to: ./deliverable-<name>.md
973
+ - Then IMMEDIATELY post to Slack (smart-post: short → inline message, long → file upload):
974
+ prbot slack post --channel=<channel> --file=./deliverable-<name>.md --title="<title>" --comment="<summary>" --thread=<thread_ts>
975
+ - Examples:
976
+ - ./deliverable-research-report.md
977
+ - ./deliverable-analysis.md
978
+ - ./deliverable-summary.md
979
+
980
+ ## Tool Error Recovery
981
+ When a prbot CLI command fails:
982
+ 1. Record error to ./TOOLS_ERRORS.md (command, error, context)
983
+ 2. Read the failing tool's source in ./codes/Comfy-Org/Comfy-PR/tree/sno-bot to diagnose
984
+ 3. Spawn a fix via: prbot pr --repo=Comfy-Org/Comfy-PR --prompt="Fix <tool>: <error>. Root cause: <analysis>. Fix: <change>"
985
+ 4. Workaround to complete the user's task while the fix PR is open
986
+
987
+ `,
988
+ );
989
+ await Bun.$`code ${botWorkingDir}`.catch(() => null); // open the working dir in vscode for debugging
990
+
991
+ const agentPrompt = `
992
+ the @${username} intented to ${resp.user_intent}
993
+ Please assist them with their request using all your resources available.
994
+
995
+ IMPORTANT WORKSPACE CONVENTIONS:
996
+ - Save ALL deliverables (documents, guides, reports, summaries, analysis, code snippets, etc.) to ./deliverable-<name>.md in the current workspace directory. For example: ./deliverable-draft-pr-guide.md, ./deliverable-research-report.md
997
+ - Log any tool errors or failures to ./TOOLS_ERRORS.md
998
+ - Keep deliverables self-contained and well-formatted so they can be shared directly with the user
999
+ `;
1000
+
1001
+ // Write PROMPT.txt so claude-yes can read the user's intent
1002
+ await Bun.write(`${botWorkingDir}/PROMPT.txt`, agentPrompt);
1003
+
1004
+ logger.info(`Spawning agent in ${botWorkingDir} with prompt: ${JSON.stringify(agentPrompt)}`);
1005
+ // todo: spawn in a worker user
1006
+
1007
+ // await Bun.$.cwd(botWorkingDir)`claude-yes -- solve-everything-in=TODO.md, PROMPT.txt, current bot args --working-dir=${botWorkingDir} --slack-channel=${event.channel} --slack-thread-ts=${quickRespondMsg.ts!}`
1008
+
1009
+ // Create dedicated log files for this task (before spawning)
1010
+ const taskLogDir = `${botWorkingDir}/.logs`;
1011
+ await mkdir(taskLogDir, { recursive: true });
1012
+ const stdoutLogPath = `${taskLogDir}/claude-yes-stdout.log`;
1013
+ const stderrLogPath = `${taskLogDir}/claude-yes-stderr.log`;
1014
+ const statusLogPath = `${taskLogDir}/STATUS.txt`;
1015
+
1016
+ // create a user for task
1017
+ const exitCodePromise = Promise.withResolvers<number | null>();
1018
+ const sh = (() => {
1019
+ // if (process.env.CLI === "amp") {
1020
+ // // use amp
1021
+ // }
1022
+ // const continueArgs: string[] = [];
1023
+ // if (botworkingdir/.claude-yes have content)
1024
+ // then continueArgs.push('--continue')
1025
+ // TODO: maybe use smarter way to detect if need continue
1026
+ // if (existsSync(`${botWorkingDir}/.claude-yes`)) {
1027
+ // // const stat = Bun.statSync(`${botWorkingDir}/.claude-yes`)
1028
+ // // if (stat.isDirectory && stat.size > 0) {
1029
+ // // }
1030
+ // continueArgs.push('--continue')
1031
+ // }
1032
+ // const cmd = `bunx claude-yes -i=1d -- ${Bun.$.escape(agentPrompt)}`;
1033
+ // const cli = cmd.split(" ")[0];
1034
+ const cli = "claude-yes"; // Use the globally installed claude-yes (via bun)
1035
+ // Pass prompt to read PROMPT.txt and TODO.md
1036
+ const args = [
1037
+ "--exit-on-idle=1m",
1038
+ "--",
1039
+ "Please read PROMPT.txt and TODO.md in the current directory and complete all tasks listed there.",
1040
+ ];
1041
+ logger.info(
1042
+ `Spawning process: ${cli} ${args.join(" ")} in ${botWorkingDir} with env GH_TOKEN_COMFY_PR_BOT=[REDACTED]`,
1043
+ );
1044
+ const shell = spawn(cli, args, {
1045
+ cwd: botWorkingDir,
1046
+ env: {
1047
+ ...process.env,
1048
+ GH_TOKEN: process.env.GH_TOKEN_COMFY_PR_BOT || DIE("missing GH_TOKEN_COMFY_PR_BOT env"),
1049
+ GITHUB_TOKEN: process.env.GH_TOKEN_COMFY_PR_BOT || DIE("missing GH_TOKEN_COMFY_PR_BOT env"),
1050
+ },
1051
+ });
1052
+
1053
+ // check if p spawned successfully
1054
+ shell.on("error", (err) => {
1055
+ logger.error(`Failed to start ${cli} process for task ${workspaceId}:`, { err });
1056
+ });
1057
+ shell.on("exit", (code, signal) => {
1058
+ logger.info(`process for task ${workspaceId} exited with code ${code} and signal ${signal}`);
1059
+ exitCodePromise.resolve(code);
1060
+ });
1061
+
1062
+ // Auto-answer the trust prompt (option 1 = "Yes, proceed")
1063
+ if (shell.stdin) {
1064
+ shell.stdin.write("1\n");
1065
+ }
1066
+
1067
+ // Stream stderr to log file and logger (async operations moved outside)
1068
+ shell.stderr?.on("data", (data) => {
1069
+ const text = data.toString();
1070
+ appendFile(stderrLogPath, text).catch(() => {});
1071
+ logger.warn(`[${cli} stderr]:`, { data: text });
1072
+ });
1073
+
1074
+ // Check if stdout/stderr are available
1075
+ if (!shell.stdout) {
1076
+ logger.error(`Process ${cli} has no stdout stream!`);
1077
+ }
1078
+ if (!shell.stderr) {
1079
+ logger.warn(`Process ${cli} has no stderr stream`);
1080
+ }
1081
+
1082
+ return shell;
1083
+ })();
1084
+
1085
+ // Write initial status
1086
+ await Bun.write(
1087
+ statusLogPath,
1088
+ `Started: ${new Date().toISOString()}\nPID: ${sh.pid}\nStatus: Running\nLog: ${stdoutLogPath}\n`,
1089
+ );
1090
+
1091
+ const isDebugMode = process.env.DEBUG === "true" || process.env.DEBUG === "1";
1092
+
1093
+ logger.info(`Spawned claude-yes process with PID ${sh.pid} for task ${workspaceId}`);
1094
+ if (isDebugMode) {
1095
+ logger.info(`📝 Real-time logs: tail -f ${stdoutLogPath}`);
1096
+ logger.info(`📊 Status file: cat ${statusLogPath}`);
1097
+ logger.info(`💡 Debug commands: prbot debug watch ${botWorkingDir}`);
1098
+ }
1099
+
1100
+ // Start error collector to monitor workspace for errors
1101
+ const errorLogPath = `${taskLogDir}/COLLECTED_ERRORS.md`;
1102
+ const errorCollector = new ErrorCollector({
1103
+ workspaceDir: botWorkingDir,
1104
+ outputLogPath: errorLogPath,
1105
+ onError: isDebugMode
1106
+ ? (errorPath, content) => {
1107
+ logger.warn(`⚠️ Error detected in workspace: ${errorPath}`);
1108
+ logger.warn(`Error content preview: ${content.substring(0, 500)}...`);
1109
+ }
1110
+ : undefined,
1111
+ checkInterval: 10000, // Check every 10 seconds
1112
+ });
1113
+ await errorCollector.start();
1114
+ if (isDebugMode) {
1115
+ logger.info(`🔍 Error collector started, errors will be logged to: ${errorLogPath}`);
1116
+ }
1117
+
1118
+ await sflow(
1119
+ [""], // Initial Prompt to start the agent, could be empty
1120
+ )
1121
+ .merge(
1122
+ // append messages from taskInputFlow
1123
+ sflow(taskInputFlow.readable)
1124
+ // send original message and then write '\n' after 1s delay to simulate user press Enter
1125
+ .map(async (awaitableText) => awaitableText),
1126
+ )
1127
+ .by(fromStdio(sh))
1128
+ // convert buffer to string and write to log file
1129
+ .map(async (buffer) => {
1130
+ if (buffer === undefined || buffer === null) {
1131
+ logger.warn(`Received undefined/null buffer from process ${workspaceId}`);
1132
+ return "";
1133
+ }
1134
+ const text = buffer.toString();
1135
+ // Write raw output to dedicated stdout log file
1136
+ await appendFile(stdoutLogPath, text).catch(() => {});
1137
+ return text;
1138
+ })
1139
+
1140
+ // pipe to /botWorkingDir/.logs/bot-<date>.log to claude input
1141
+ .forkTo(async (e) => {
1142
+ const logDate = new Date().toISOString().split("T")[0];
1143
+ await mkdir(path.resolve(`${botWorkingDir}/.logs`), { recursive: true });
1144
+ await e.forEach(
1145
+ async (chunk) => await appendFile(`${botWorkingDir}/.logs/bot-${logDate}.log`, chunk),
1146
+ );
1147
+ })
1148
+ // show loading icon when unknown output activity, and remove the loading icon after idle for 5s
1149
+ .forkTo(async (e) => {
1150
+ const idleWaiter = new IdleWaiter();
1151
+ let isThinking = false;
1152
+ return await e
1153
+ .forEach(async () => {
1154
+ idleWaiter.ping();
1155
+ if (!isThinking && quickRespondMsg.ts && quickRespondMsg.channel) {
1156
+ isThinking = true;
1157
+ const msgChannel = quickRespondMsg.channel;
1158
+ const msgTs = quickRespondMsg.ts;
1159
+ await slack.reactions
1160
+ .add({
1161
+ name: "loading",
1162
+ channel: msgChannel,
1163
+ timestamp: msgTs,
1164
+ })
1165
+ .catch(() => {});
1166
+ idleWaiter.wait(5e3).finally(async () => {
1167
+ await slack.reactions
1168
+ .remove({
1169
+ name: "loading",
1170
+ channel: msgChannel,
1171
+ timestamp: msgTs,
1172
+ })
1173
+ .catch(() => {});
1174
+ isThinking = false;
1175
+ });
1176
+ }
1177
+ })
1178
+ .onFlush(async () => {
1179
+ // remove loading icon
1180
+ if (isThinking && quickRespondMsg.ts && quickRespondMsg.channel) {
1181
+ isThinking = false;
1182
+ await slack.reactions
1183
+ .remove({
1184
+ name: "loading",
1185
+ channel: quickRespondMsg.channel,
1186
+ timestamp: quickRespondMsg.ts,
1187
+ })
1188
+ .catch(() => {});
1189
+ }
1190
+ })
1191
+ .run();
1192
+ })
1193
+
1194
+ // Render terminal text to plain text and show live updates in slack
1195
+ .forkTo(async (e) => {
1196
+ const tr = new TerminalTextRender();
1197
+ let sent = "";
1198
+ let lastOutputs: string[] = []; // keep 3 last outputs to detect stability
1199
+
1200
+ // logger.info('Rendered chunk size:', rendered.length, 'lines: ', rendered.split(/\r|\n/).length);
1201
+ const id = setInterval(async () => {
1202
+ const renderedText = tr.render();
1203
+ // diff from last, and send stable lines
1204
+ const common = commonPrefix(renderedText, ...lastOutputs);
1205
+ const newStable = renderedText.slice(0, common.length);
1206
+ // logger.debug({ common, newStable, lastOutputs, renderedText });
1207
+
1208
+ if (newStable !== sent) {
1209
+ const news = newStable.slice(sent.length);
1210
+ sent = newStable; // agent outputs have new lines to send
1211
+ if (news) logger.debug(JSON.stringify({ news }));
1212
+ logger.info(
1213
+ `New stable output detected, length: ${newStable.length}, news length: ${news.length}`,
1214
+ );
1215
+
1216
+ const rawTerminalOutput = tr.render().split("\n").slice(-80).join("\n");
1217
+ const my_internal_thoughts = cleanTerminalOutput(rawTerminalOutput);
1218
+ // const my_internal_thoughts = tr.tail(80);
1219
+ logger.debug(
1220
+ "Raw terminal output (before cleaning): " +
1221
+ yaml.stringify({ preview: rawTerminalOutput.slice(0, 200) }),
1222
+ );
1223
+ logger.info(
1224
+ "Cleaned output preview: " +
1225
+ yaml.stringify({
1226
+ preview: my_internal_thoughts.slice(0, 200),
1227
+ news_preview: news.slice(0, 200),
1228
+ }),
1229
+ );
1230
+
1231
+ // send update to slack
1232
+ const updateText = sent || "_(no output yet)_";
1233
+ const contexts = {
1234
+ my_internal_thoughts,
1235
+ news,
1236
+ user_original_intent: resp.user_intent,
1237
+ my_response_md_original: quickRespondMsg.text || "",
1238
+ };
1239
+ const updateResponseResp = (await zChatCompletion({
1240
+ my_response_md_updated: z.string(),
1241
+ })`
1242
+ TASK: Update my my_response_md_original based on agent's my_internal_thoughts findings, and give me my_response_md_updated to post in slack.
1243
+
1244
+ RULES:
1245
+ - Do not remove unknown parts from my_response_md_original that are not mentioned in my_internal_thoughts.
1246
+ - Preserve markdown formatting in my_response_md_original.
1247
+ - If my_internal_thoughts contains new information, append it to the relevant sections in my_response_md_original.
1248
+ - If my_internal_thoughts indicates completion of a task, add a "Tasks" section at the end of my_response_md_original with - [x] mark.
1249
+ - Ensure my_response_md_updated is clear and concise.
1250
+ - Use **bold** to highlight new sections or important updates. Remove previously highlighted sections if they're no longer relevant.
1251
+ - If all information from my_internal_thoughts is already contained in my_response_md_original, return: {my_response_md_updated: "__NOTHING_CHANGED__"}
1252
+
1253
+ CRITICAL FILTERING RULES (Non-negotiable):
1254
+ - KEEP ONLY: User-facing progress, task completion status, findings relevant to user's intent, next steps
1255
+ - REMOVE: File paths, system info, debug output, error stack traces, internal process details, development notes
1256
+ - EXAMPLES OF WHAT TO REMOVE:
1257
+ - "/bot/slack/channel-id/timestamp" (internal paths)
1258
+ - "undefined/null received in chunk" (internal errors)
1259
+ - "DEBUG: ..." (debug output)
1260
+ - "✓ Created /tmp/cache/..." (internal file operations)
1261
+ - "[2026-02-20T15:10:40.123Z]" (timestamps)
1262
+
1263
+ TONE & LENGTH:
1264
+ - KEEP message very short and informative, use url links to reference documents/repos instead of pasting large contents
1265
+ - Response should be up to 16 lines maximum (agent posts long reports as .md files)
1266
+ - Focus ONLY on end-user's question or intent's helpful contents
1267
+ - Describe current progress in up to 7 words (less is better)
1268
+ - Avoid jargon; write for non-technical users when possible
1269
+
1270
+ FORMAT REQUIREMENTS:
1271
+ - Output in standard markdown format (GitHub flavored)
1272
+ - YOU CAN ONLY change/remove/add up to 1 line per update!
1273
+ - LENGTH LIMIT: Must be within 4000 characters (system will truncate if exceeding)
1274
+ - MOST IMPORTANT: Keep my_response_md_original's context and formatting mostly unchanged, only update necessary lines
1275
+
1276
+ DO NOT:
1277
+ - Ask the user questions
1278
+ - Include error details (they're logged separately for developers)
1279
+ - Show code blocks or technical configs
1280
+ - Show internal process logs or environment variables
1281
+ - Show any paths starting with "/" or "./"
1282
+
1283
+ - Here's Contexts in YAML for your respondse:
1284
+
1285
+ <task-context-yaml>
1286
+ ${yaml.stringify(contexts)}
1287
+ </task-context-yaml>
1288
+
1289
+ `) as { my_response_md_updated: string };
1290
+
1291
+ // Log raw my_response_md_updated to JSONL file for debugging
1292
+ const responseLogEntry = {
1293
+ timestamp: new Date().toISOString(),
1294
+ workspaceId,
1295
+ stage: "raw_from_claude",
1296
+ my_response_md_updated_raw: updateResponseResp.my_response_md_updated,
1297
+ my_internal_thoughts_preview: my_internal_thoughts.slice(0, 500),
1298
+ my_response_md_original: quickRespondMsg.text || "",
1299
+ };
1300
+ await appendFile(
1301
+ ".logs/my_response_md_updated.jsonl",
1302
+ JSON.stringify(responseLogEntry) + "\n",
1303
+ ).catch(() => {});
1304
+
1305
+ const updated_response_full = await mdFmt(
1306
+ updateResponseResp.my_response_md_updated
1307
+ .trim()
1308
+ .replace(/^__NOTHING_CHANGED__$/m, quickRespondMsg.text || ""),
1309
+ );
1310
+
1311
+ // truncate to 4000 chars, from the middle, replace to '...TRUNCATED...'
1312
+ const my_response_md_updated =
1313
+ updated_response_full.length > 4000
1314
+ ? updated_response_full.slice(0, 2000) +
1315
+ "\n\n...TRUNCATED...\n\n" +
1316
+ updated_response_full.slice(-2000)
1317
+ : updated_response_full;
1318
+
1319
+ // Log final processed my_response_md_updated
1320
+ const finalLogEntry = {
1321
+ timestamp: new Date().toISOString(),
1322
+ workspaceId,
1323
+ stage: "final_processed",
1324
+ my_response_md_updated_final: my_response_md_updated,
1325
+ was_truncated: updated_response_full.length > 4000,
1326
+ original_length: updated_response_full.length,
1327
+ };
1328
+ await appendFile(
1329
+ ".logs/my_response_md_updated.jsonl",
1330
+ JSON.stringify(finalLogEntry) + "\n",
1331
+ ).catch(() => {});
1332
+
1333
+ if (quickRespondMsg.ts && quickRespondMsg.channel) {
1334
+ await safeSlackUpdateMessage(slack, {
1335
+ channel: quickRespondMsg.channel,
1336
+ ts: quickRespondMsg.ts,
1337
+ text: my_response_md_updated, // Fallback text for notifications
1338
+ blocks: [
1339
+ {
1340
+ type: "markdown",
1341
+ text: my_response_md_updated,
1342
+ },
1343
+ ],
1344
+ });
1345
+ logger.debug("Updated quick respond message in slack:", {
1346
+ url: `https://${event.team}.slack.com/archives/${quickRespondMsg.channel}/p${quickRespondMsg.ts.replace(".", "")}`,
1347
+ });
1348
+
1349
+ // update quickRespondMsg content
1350
+ quickRespondMsg.text = my_response_md_updated;
1351
+ await SlackBotState.set(`task-quick-respond-msg-${eventId}`, {
1352
+ ts: quickRespondMsg.ts,
1353
+ text: quickRespondMsg.text,
1354
+ channel: event.channel,
1355
+ url: `https://${SLACK_ORG_DOMAIN_NAME}.slack.com/archives/${event.channel}/p${quickRespondMsg.ts.replace(".", "")}`,
1356
+ });
1357
+ }
1358
+ }
1359
+
1360
+ lastOutputs.push(renderedText);
1361
+ if (lastOutputs.length > 3) {
1362
+ lastOutputs.shift();
1363
+ }
1364
+ }, 1e3);
1365
+
1366
+ await e
1367
+ .forEach(async (chunk) => {
1368
+ if (chunk === undefined || chunk === null) {
1369
+ logger.warn(`Terminal render received undefined/null chunk for task ${workspaceId}`);
1370
+ return;
1371
+ }
1372
+ if (chunk === "") {
1373
+ // Empty string is valid, just skip rendering
1374
+ return;
1375
+ }
1376
+ try {
1377
+ const rendered = tr.write(chunk);
1378
+ } catch (err) {
1379
+ logger.error(`Error writing chunk to terminal render for task ${workspaceId}:`, {
1380
+ err,
1381
+ chunkType: typeof chunk,
1382
+ chunkLength: chunk?.length,
1383
+ });
1384
+ }
1385
+ })
1386
+ .onFlush(() => clearInterval(id))
1387
+ .run();
1388
+ })
1389
+
1390
+ // show contents in console if needed for debugging
1391
+ // .forkTo((e) => e.pipeTo(fromWritable(process.stdout)))
1392
+ // .forkTo((e) => e.pipeTo(fromWritable(process.stdout)))
1393
+ .run();
1394
+
1395
+ TaskInputFlows.delete(workspaceId);
1396
+
1397
+ // Stop error collector
1398
+ errorCollector.stop();
1399
+ if (isDebugMode) {
1400
+ logger.info(`🔍 Error collector stopped for task ${workspaceId}`);
1401
+ }
1402
+
1403
+ // check exit code, checkmark if claude-yes exited 0, cross if not
1404
+
1405
+ const exitCode = await exitCodePromise.promise;
1406
+
1407
+ // Update final status
1408
+ const finalStatus = exitCode === 0 ? "Completed Successfully" : `Failed (exit code ${exitCode})`;
1409
+ await Bun.write(
1410
+ statusLogPath,
1411
+ `Started: ${new Date().toISOString()}\nPID: ${sh.pid}\nStatus: ${finalStatus}\nExit Code: ${exitCode}\nEnded: ${new Date().toISOString()}\nLogs: ${stdoutLogPath}\nErrors: ${errorLogPath}\n`,
1412
+ ).catch(() => {});
1413
+
1414
+ if (exitCode !== 0) {
1415
+ logger.error(`claude-yes process for task ${workspaceId} exited with code ${exitCode}`);
1416
+ // those error tasks will got retry after a restart
1417
+ // update my slack message reactions shows a cross mark and update it appending a error happened and say will retry later
1418
+ await slack.reactions
1419
+ .remove({ name: "thinking_face", channel: event.channel, timestamp: event.ts })
1420
+ .catch(() => {});
1421
+ if (quickRespondMsg.ts && quickRespondMsg.channel) {
1422
+ await slack.reactions
1423
+ .add({ name: "x", channel: quickRespondMsg.channel, timestamp: quickRespondMsg.ts })
1424
+ .catch(() => {});
1425
+ const errorText = await mdFmt(
1426
+ (quickRespondMsg.text || "") +
1427
+ `\n\n:warning: An error occurred while processing this request <@snomiao>, I will try it again later`,
1428
+ );
1429
+ await safeSlackUpdateMessage(slack, {
1430
+ channel: event.channel,
1431
+ ts: quickRespondMsg.ts,
1432
+ text: errorText, // Fallback text for notifications
1433
+ blocks: [
1434
+ {
1435
+ type: "markdown",
1436
+ text: errorText,
1437
+ },
1438
+ ],
1439
+ });
1440
+ }
1441
+ }
1442
+
1443
+ // claude exited as no more inputs/outputs for a while, update the status message
1444
+ await slack.reactions
1445
+ .remove({ name: "thinking_face", channel: event.channel, timestamp: event.ts })
1446
+ .catch(() => {});
1447
+ await slack.reactions
1448
+ .add({ name: "white_check_mark", channel: event.channel, timestamp: event.ts })
1449
+ .catch(() => {});
1450
+ const taskState = await SlackBotState.get(`task-${workspaceId}`);
1451
+ const endTime = Date.now();
1452
+ const responseDuration = taskState?.startTime ? endTime - taskState.startTime : undefined;
1453
+
1454
+ await SlackBotState.set(`task-${workspaceId}`, {
1455
+ ...taskState,
1456
+ status: "done",
1457
+ endTime,
1458
+ responseDuration,
1459
+ });
1460
+
1461
+ // Remove task from working list
1462
+ await removeWorkingTask(event);
1463
+ }
1464
+
1465
+ function sleep(ms: number) {
1466
+ return new Promise((resolve) => setTimeout(resolve, ms));
1467
+ }
1468
+
1469
+ async function getSlackMessageFromUrl(url: string) {
1470
+ const { ts, channel } = slackMessageUrlParse(url);
1471
+ const page = await slack.conversations.history({
1472
+ channel,
1473
+ limit: 1,
1474
+ inclusive: true,
1475
+ latest: ts,
1476
+ });
1477
+ return page.messages?.[0] || DIE("not found");
1478
+ }
1479
+
1480
+ function commonPrefix(...args: string[]): string {
1481
+ if (args.length === 0) return "";
1482
+ let prefix = args[0];
1483
+ for (let i = 1; i < args.length; i++) {
1484
+ let j = 0;
1485
+ while (j < prefix.length && j < args[i].length && prefix[j] === args[i][j]) {
1486
+ j++;
1487
+ }
1488
+ prefix = prefix.slice(0, j);
1489
+ if (prefix === "") break;
1490
+ }
1491
+ return prefix;
1492
+ }
1493
+
1494
+ /**
1495
+ * Clean terminal output by removing ANSI codes, debug info, and system paths
1496
+ * This ensures Claude only sees user-meaningful progress information
1497
+ */
1498
+ function cleanTerminalOutput(text: string): string {
1499
+ // Remove ANSI color codes and escape sequences
1500
+ text = text.replace(/\x1b\[[0-9;]*m/g, "");
1501
+ text = text.replace(/\x1b\[[^m]*m/g, "");
1502
+ text = text.replace(/\u0007/g, ""); // Bell character
1503
+ text = text.replace(/\r/g, ""); // Carriage returns
1504
+
1505
+ // Remove box drawing characters (Claude Code banner)
1506
+ text = text.replace(/[▐▛▜▘▝█▌▙▟▞▚░▒▓│┃├┤┬┴┼─═║╔╗╚╝╠╣╦╩╬]/g, "");
1507
+
1508
+ // Filter lines to remove debug noise
1509
+ const lines = text.split("\n").filter((line) => {
1510
+ const trimmed = line.trim();
1511
+
1512
+ // Skip empty or whitespace-only lines
1513
+ if (!trimmed) return true;
1514
+
1515
+ // Skip timestamp-prefixed log lines (multiple formats)
1516
+ // Format 1: [2026-02-20T15:10:40.123Z]
1517
+ if (/^\[[\d\-T:.Z]+\]/.test(trimmed)) return false;
1518
+ // Format 2: 2026-02-20 15:42:09 [info]:
1519
+ if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+\[/.test(trimmed)) return false;
1520
+
1521
+ // Skip warning lines
1522
+ if (/^⚠|^Warning:|^WARN:|^\[warn\]/i.test(trimmed)) return false;
1523
+
1524
+ // Skip debug/verbose/trace/info prefixed lines
1525
+ if (/^(DEBUG|VERBOSE|TRACE|INFO):/i.test(trimmed)) return false;
1526
+ if (/\[(debug|verbose|trace|info)\]:/i.test(trimmed)) return false;
1527
+
1528
+ // Skip claude-yes specific output
1529
+ if (/\[claude-yes\]|claude-yes|Spawned claude|PID \d+/i.test(trimmed)) return false;
1530
+ if (/Claude Code v\d|Opus \d|Claude Max/i.test(trimmed)) return false;
1531
+
1532
+ // Skip lines containing system paths anywhere
1533
+ if (/\/bot\/slack\/|\/codes\/|\.logs\/|\/repos\/|\/tmp\//i.test(trimmed)) return false;
1534
+
1535
+ // Skip undefined/null error indicators
1536
+ if (/received undefined\/null|undefined\/null/i.test(trimmed)) return false;
1537
+
1538
+ // Skip deprecation warnings
1539
+ if (/deprecated|--exit-on-idle|-e are deprecated/i.test(trimmed)) return false;
1540
+
1541
+ // Skip pure terminal control output or lines that are mostly special chars
1542
+ if (/^(\s*|cursor\s+|bell|bel|\x07)$/i.test(trimmed)) return false;
1543
+
1544
+ // Skip lines that are mostly whitespace or contain only special characters
1545
+ if (/^[\s\u2000-\u206F\u2500-\u257F]*$/.test(trimmed)) return false;
1546
+
1547
+ return true;
1548
+ });
1549
+
1550
+ return lines.join("\n").trim();
1551
+ }
1552
+ function sanitized(name: string) {
1553
+ return name.replace(/[^a-zA-Z0-9-_]/g, "_").slice(0, 50);
1554
+ }
1555
+
1556
+ export async function spawnBotOnSlackMessageUrl(url: string) {
1557
+ const { team, channel, ts } = await slackMessageUrlParse(url);
1558
+ const event = await slack.conversations
1559
+ .replies({
1560
+ channel: channel,
1561
+ ts: ts,
1562
+ limit: 1,
1563
+ })
1564
+ .then((res) => res.messages?.[0] || DIE("failed to fetch message from slack"));
1565
+ logger.info("Processing missed message " + JSON.stringify({ url, event }));
1566
+ // Parse the event to ensure it matches the expected type
1567
+ const mentionEvent = zAppMentionEvent.parse({
1568
+ ...event,
1569
+ type: "app_mention",
1570
+ user: event.user || "",
1571
+ channel: channel,
1572
+ event_ts: event.ts || ts,
1573
+ });
1574
+ await spawnBotOnSlackMessageEvent(mentionEvent);
1575
+ }