comfy-pr 1.4.2 → 1.5.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.
package/bot/slack-bot.ts CHANGED
@@ -8,14 +8,13 @@
8
8
  */
9
9
  import { slack } from "@/lib";
10
10
  import { yaml } from "@/src/utils/yaml";
11
- import { SocketModeClient } from "@slack/socket-mode";
12
- import {} from "@slack/bolt";
11
+ import { createHmac, timingSafeEqual } from "crypto";
13
12
  import DIE from "@snomiao/die";
14
13
  import { compareBy } from "comparing";
15
14
  import { mkdir } from "fs/promises";
16
15
  import sflow from "sflow";
17
16
  import winston from "winston";
18
- import zChatCompletion from "../lib/zChat";
17
+ import zChatCompletion, { initZChat } from "../lib/zChat";
19
18
  import z from "zod";
20
19
  import { IdleWaiter } from "./IdleWaiter";
21
20
  import { RestartManager } from "./RestartManager";
@@ -32,6 +31,13 @@ import { getSlackChannelName } from "@/lib/slack";
32
31
  import { SlackBotState } from "./state";
33
32
  import { ErrorCollector } from "./error-collector";
34
33
  import { query, type Query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
34
+ import {
35
+ createTaskUser,
36
+ prepareTaskWorkspace,
37
+ cleanupStaleTaskUsers,
38
+ touchTaskUserActivity,
39
+ } from "./task-user";
40
+ import { createUserSpawner } from "./spawn-as-user";
35
41
 
36
42
  export const SLACK_ORG_DOMAIN_NAME = "comfy-organization";
37
43
  // Configure winston logger
@@ -71,6 +77,9 @@ const logger = winston.createLogger({
71
77
  });
72
78
 
73
79
  const TaskInputFlows = new Map<string, TransformStream<string, string>>();
80
+ // AbortControllers keyed by `${channel}:${ts}` of the original user message
81
+ // so a Slack reaction handler can cancel the running agent.
82
+ const TaskAbortControllers = new Map<string, AbortController>();
74
83
  // https://comfy-pr-bot.pages.dev/
75
84
  // Slack block type definition
76
85
  const zSlackBlock = z
@@ -151,6 +160,7 @@ if (import.meta.main) {
151
160
 
152
161
  export async function startSlackBot() {
153
162
  console.log("Starting ComfyPR Bot...");
163
+ await initZChat();
154
164
  const argv = minimist(process.argv.slice(2));
155
165
  const port = Number(process.env.PRBOT_PORT || DIE("missing env.PRBOT_PORT"));
156
166
 
@@ -212,34 +222,102 @@ export async function startSlackBot() {
212
222
  logger.info(`Killing port ${port} and starting server`);
213
223
  await Bun.$`npx -y kill-port ${port}`;
214
224
 
225
+ const slackSigningSecret =
226
+ process.env.SLACK_SIGNING_SECRET || DIE("missing env.SLACK_SIGNING_SECRET");
227
+
215
228
  const server = Bun.serve({
216
229
  port: port,
217
230
  fetch: async (req: Request) => {
218
231
  const url = new URL(req.url);
219
232
 
220
233
  if (url.pathname === "/status") {
221
- // Get current working tasks from state
222
234
  const workingTasks = (await SlackBotState.get("current-working-tasks")) || {
223
235
  workingMessageEvents: [],
224
236
  };
225
237
  const events = workingTasks.workingMessageEvents || [];
226
-
227
- // Build message URLs from events
228
238
  const processing_message_urls = events.map((event: z.infer<typeof zAppMentionEvent>) => {
229
239
  const tsForUrl = event.ts.replace(".", "");
230
240
  return `https://${SLACK_ORG_DOMAIN_NAME}.slack.com/archives/${event.channel}/p${tsForUrl}`;
231
241
  });
242
+ return new Response(
243
+ JSON.stringify(
244
+ {
245
+ status: TaskInputFlows.size === 0 ? "idle" : "busy",
246
+ processing_message_urls,
247
+ processing_message_urls_count: processing_message_urls.length,
248
+ },
249
+ null,
250
+ 2,
251
+ ),
252
+ { status: 200, headers: { "Content-Type": "application/json" } },
253
+ );
254
+ }
232
255
 
233
- const status = {
234
- status: TaskInputFlows.size === 0 ? "idle" : "busy",
235
- processing_message_urls,
236
- processing_message_urls_count: processing_message_urls.length,
237
- };
256
+ if (url.pathname === "/slack/events" && req.method === "POST") {
257
+ const body = await req.text();
238
258
 
239
- return new Response(JSON.stringify(status, null, 2), {
240
- status: 200,
241
- headers: { "Content-Type": "application/json" },
242
- });
259
+ // Verify Slack signature
260
+ const timestamp = req.headers.get("x-slack-request-timestamp") ?? "";
261
+ const slackSig = req.headers.get("x-slack-signature") ?? "";
262
+ if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
263
+ return new Response("Request too old", { status: 401 });
264
+ }
265
+ const hmac = createHmac("sha256", slackSigningSecret)
266
+ .update(`v0:${timestamp}:${body}`)
267
+ .digest("hex");
268
+ const expected = Buffer.from(`v0=${hmac}`);
269
+ const received = Buffer.from(slackSig);
270
+ if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
271
+ return new Response("Invalid signature", { status: 401 });
272
+ }
273
+
274
+ const payload = JSON.parse(body);
275
+
276
+ // URL verification challenge (first-time setup)
277
+ if (payload.type === "url_verification") {
278
+ return new Response(JSON.stringify({ challenge: payload.challenge }), {
279
+ headers: { "Content-Type": "application/json" },
280
+ });
281
+ }
282
+
283
+ // Event callbacks — handle async, respond 200 immediately
284
+ if (payload.type === "event_callback") {
285
+ const retryNum = req.headers.get("x-slack-retry-num");
286
+ const retryReason = req.headers.get("x-slack-retry-reason");
287
+ const eventId =
288
+ payload.event_id ||
289
+ `${payload.event?.channel ?? "-"}_${payload.event?.event_ts ?? payload.event?.ts ?? "-"}`;
290
+
291
+ const alreadySeen = await SlackBotState.get(`webhook-event-${eventId}`);
292
+ if (alreadySeen) {
293
+ logger.info(
294
+ `Ignoring duplicate webhook event ${eventId} (retry=${retryNum ?? "0"}, reason=${retryReason ?? "-"}, firstSeenAt=${new Date(alreadySeen.receivedAt).toISOString()})`,
295
+ );
296
+ return new Response("", { status: 200 });
297
+ }
298
+
299
+ // TTL 1h — Slack retries up to ~30min, so 1h covers worst case
300
+ await SlackBotState.set(
301
+ `webhook-event-${eventId}`,
302
+ { receivedAt: Date.now(), retryNum, retryReason },
303
+ 60 * 60 * 1000,
304
+ );
305
+
306
+ // Slack Events API puts the workspace id on the *envelope*
307
+ // (`payload.team_id`), not on the inner event in some shapes.
308
+ // Forward it onto the event so downstream zod schemas that
309
+ // require `team` (zAppMentionEvent, zSlackMessage filter) don't
310
+ // silently reject webhook-delivered mentions/DMs.
311
+ const event = {
312
+ ...payload.event,
313
+ team: payload.event?.team || payload.team_id,
314
+ };
315
+ handleSlackEvent(event).catch((err) =>
316
+ logger.error("Webhook event handler error", { err, eventId }),
317
+ );
318
+ }
319
+
320
+ return new Response("", { status: 200 });
243
321
  }
244
322
 
245
323
  return new Response("ComfyPR Bot is running.\n", { status: 200 });
@@ -265,32 +343,29 @@ export async function startSlackBot() {
265
343
  .run();
266
344
 
267
345
  if (argv.continue) {
268
- async () => {
269
- logger.info("BOT - --continue flag detected, resuming crashed tasks...");
346
+ logger.info("BOT - --continue flag detected, resuming crashed tasks...");
270
347
 
271
- // Read current working tasks from state
272
- const workingTasks = (await SlackBotState.get("current-working-tasks")) || {
273
- workingMessageEvents: [],
274
- };
275
- const events = workingTasks.workingMessageEvents || [];
348
+ const workingTasks = (await SlackBotState.get("current-working-tasks")) || {
349
+ workingMessageEvents: [],
350
+ };
351
+ const events = workingTasks.workingMessageEvents || [];
276
352
 
277
- if (events.length === 0) {
278
- logger.info("No working tasks to resume");
279
- } else {
280
- logger.info(`Found ${events.length} working task(s) to resume`);
353
+ if (events.length === 0) {
354
+ logger.info("No working tasks to resume");
355
+ } else {
356
+ logger.info(`Found ${events.length} working task(s) to resume`);
281
357
 
282
- for await (const event of events) {
283
- if (event && event.ts) {
284
- logger.info(
285
- `Resuming task for event ${event.ts} in channel ${await getSlackChannelName(event.channel)}, text: ${event.text}`,
286
- );
287
- await spawnBotOnSlackMessageEvent(event).catch((err) => {
288
- logger.error(`Error resuming task for event ${event.ts}`, { err });
289
- });
290
- }
358
+ for (const event of events) {
359
+ if (event && event.ts) {
360
+ logger.info(
361
+ `Resuming task for event ${event.ts} in channel ${await getSlackChannelName(event.channel)}, text: ${event.text}`,
362
+ );
363
+ spawnBotOnSlackMessageEvent(event).catch((err) => {
364
+ logger.error(`Error resuming task for event ${event.ts}`, { err });
365
+ });
291
366
  }
292
367
  }
293
- };
368
+ }
294
369
  }
295
370
 
296
371
  logger.info(`Starting ComfyPR Bot... id: ${g.instanceId}, hotId: ${g.hotId}`);
@@ -315,119 +390,165 @@ export async function startSlackBot() {
315
390
  logger.info("Smart restart manager enabled (use --no-watch to disable)");
316
391
  }
317
392
 
318
- // Initialize Socket Mode client with app-level token
319
- const socketModeClient = new SocketModeClient({
320
- appToken: process.env.SLACK_SOCKET_TOKEN || DIE("missing env.SLACK_SOCKET_TOKEN"),
321
- });
393
+ // Periodic cleanup of stale task users (every hour)
394
+ setInterval(
395
+ async () => {
396
+ try {
397
+ const workingTasks = (await SlackBotState.get("current-working-tasks")) || {
398
+ workingMessageEvents: [],
399
+ };
400
+ // Keep keys in sync with workspaceId = thread_ts || ts; otherwise
401
+ // long-running threaded tasks get marked stale and their isolated
402
+ // Linux users get deleted out from under them.
403
+ const activeIds = new Set<string>(
404
+ (workingTasks.workingMessageEvents || []).map(
405
+ (e: { ts: string; thread_ts?: string }) => e.thread_ts || e.ts,
406
+ ),
407
+ );
408
+ const cleaned = await cleanupStaleTaskUsers(activeIds);
409
+ if (cleaned.length > 0) {
410
+ logger.info(`Cleaned up ${cleaned.length} stale task user(s): ${cleaned.join(", ")}`);
411
+ }
412
+ } catch (err) {
413
+ logger.warn("Task user cleanup error", { err });
414
+ }
415
+ },
416
+ 60 * 60 * 1000,
417
+ );
322
418
 
323
- // Handle all events via events_api envelope, https://docs.slack.dev/reference/events/message
324
- socketModeClient
325
- .on("app_mention", async ({ event, body, ack }) => {
326
- const parsedEvent = await zAppMentionEvent.parseAsync(event);
419
+ logger.info(`BOT - Webhook mode active. Listening on port ${port} at /slack/events`);
420
+ }
327
421
 
328
- // Acknowledge the event as its parsed
329
- await ack();
330
- await spawnBotOnSlackMessageEvent(parsedEvent);
331
- })
332
- .on("message", async ({ event, body, ack }) => {
333
- // 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"}
334
- // Parse the message event
335
- const zSlackMessage = z
336
- .object({
337
- type: z.literal("message"),
338
- user: z.string().optional(),
339
- ts: z.string().optional(),
340
- client_msg_id: z.string().optional(),
341
- text: z.string().optional(),
342
- team: z.string().optional(),
343
- thread_ts: z.string().optional(),
344
- parent_user_id: z.string().optional(),
345
- blocks: z.array(zSlackBlock).optional(),
346
- channel: z.string().optional(),
347
- channel_type: z.string().optional(),
348
- assistant_thread: z.unknown().optional(),
349
- attachments: z.array(zSlackAttachment).optional(),
350
- event_ts: z.string().optional(),
351
- bot_id: z.string().optional(),
352
- })
353
- .passthrough();
422
+ const zSlackMessage = z
423
+ .object({
424
+ type: z.literal("message"),
425
+ user: z.string().optional(),
426
+ ts: z.string().optional(),
427
+ client_msg_id: z.string().optional(),
428
+ text: z.string().optional(),
429
+ team: z.string().optional(),
430
+ thread_ts: z.string().optional(),
431
+ parent_user_id: z.string().optional(),
432
+ blocks: z.array(zSlackBlock).optional(),
433
+ channel: z.string().optional(),
434
+ channel_type: z.string().optional(),
435
+ assistant_thread: z.unknown().optional(),
436
+ attachments: z.array(zSlackAttachment).optional(),
437
+ event_ts: z.string().optional(),
438
+ bot_id: z.string().optional(),
439
+ })
440
+ .passthrough();
354
441
 
355
- const messageEvent = zSlackMessage.parse(event);
442
+ async function handleSlackEvent(event: unknown) {
443
+ const raw = event as Record<string, unknown>;
444
+
445
+ // ❌ reaction → cancel the matching running task. The reaction is on
446
+ // the original user message, so we look up its (channel, ts) in
447
+ // TaskAbortControllers. Only the message author can cancel — this
448
+ // prevents bystanders in the channel from killing other people's tasks.
449
+ if (raw.type === "reaction_added") {
450
+ const reaction = raw.reaction as string | undefined;
451
+ const item = raw.item as { type?: string; channel?: string; ts?: string } | undefined;
452
+ const reactingUser = raw.user as string | undefined;
453
+ if (reaction === "x" && item?.type === "message" && item.channel && item.ts) {
454
+ const key = `${item.channel}:${item.ts}`;
455
+ const ac = TaskAbortControllers.get(key);
456
+ if (!ac) return;
356
457
 
357
- logger.debug("MESSAGE EVENT", { event });
358
- logger.debug("parsed_text: " + (await parseSlackMessageToMarkdown(messageEvent.text || "")));
458
+ try {
459
+ const original = await slack.conversations.replies({
460
+ channel: item.channel,
461
+ ts: item.ts,
462
+ limit: 1,
463
+ });
464
+ const author = original.messages?.[0]?.user;
465
+ if (reactingUser && author && reactingUser !== author) {
466
+ logger.info(`Ignoring ❌ from <@${reactingUser}> on task by <@${author}>`);
467
+ return;
468
+ }
469
+ } catch (err) {
470
+ logger.warn("Could not verify reaction author, allowing cancel", { err });
471
+ }
359
472
 
360
- await ack();
473
+ logger.warn(`User <@${reactingUser}> cancelled task ${key} via ❌ reaction`);
474
+ ac.abort();
475
+ await slack.reactions
476
+ .add({ name: "no_entry", channel: item.channel, timestamp: item.ts })
477
+ .catch(() => {});
478
+ }
479
+ return;
480
+ }
361
481
 
362
- // Skip bot messages
363
- if (messageEvent.bot_id) {
364
- return;
365
- }
482
+ if (raw.type === "app_mention") {
483
+ const parsedEvent = await zAppMentionEvent.parseAsync(event);
484
+ await spawnBotOnSlackMessageEvent(parsedEvent);
485
+ return;
486
+ }
366
487
 
367
- // Get my bot user ID
368
- const botUsername = "comfyprbot";
369
- // TODO: fetch botUserId by botUsername or use slack api to "get my name"
370
- const botUserId = process.env.SLACK_BOT_USER_ID || "U078499LK5K"; // ComfyPR-Bot user ID
371
-
372
- // Check if message mentions the bot
373
- const text = messageEvent.text || "";
374
- const hasBotMention = text.includes(`<@${botUserId}>`);
375
-
376
- // Handle DM messages (channel_type: "im") and treat them like app mentions
377
- const isDM = messageEvent.channel_type === "im" || messageEvent.channel_type === "mpdm";
378
-
379
- if (
380
- (isDM || hasBotMention) &&
381
- messageEvent.user &&
382
- messageEvent.text &&
383
- messageEvent.channel &&
384
- messageEvent.ts &&
385
- messageEvent.team &&
386
- messageEvent.event_ts
387
- ) {
388
- const eventType = isDM ? "DM" : "BOT MENTION";
389
- logger.debug(`${eventType} DETECTED - Processing message as app_mention`, {
390
- channel: messageEvent.channel,
391
- ts: messageEvent.ts,
392
- text: text.substring(0, 100),
393
- });
488
+ if (raw.type === "message") {
489
+ // message_changed events wrap the edited content under .message; flatten
490
+ // it so a user editing a prior request triggers a new agent run when the
491
+ // text is meaningfully different (dedup is content-hash based above).
492
+ if (raw.subtype === "message_changed" && raw.message && raw.channel) {
493
+ const inner = raw.message as Record<string, unknown>;
494
+ Object.assign(raw, inner, { channel: raw.channel, channel_type: raw.channel_type });
495
+ }
394
496
 
395
- const mentionEvent: z.infer<typeof zAppMentionEvent> = {
396
- type: "app_mention" as const,
397
- user: messageEvent.user,
398
- ts: messageEvent.ts,
399
- client_msg_id: messageEvent.client_msg_id,
400
- text: messageEvent.text,
401
- team: messageEvent.team,
402
- thread_ts: messageEvent.thread_ts,
403
- parent_user_id: messageEvent.parent_user_id,
404
- blocks: messageEvent.blocks || [],
405
- channel: messageEvent.channel,
406
- assistant_thread: messageEvent.assistant_thread,
407
- attachments: messageEvent.attachments,
408
- event_ts: messageEvent.event_ts,
409
- };
410
- await spawnBotOnSlackMessageEvent(mentionEvent);
411
- }
412
- })
413
- .on("error", (error) => {
414
- logger.error("Socket Mode error", { error });
415
- })
416
- .on("connect", () => logger.info("SOCKET - Slack connected"))
417
- .on("disconnect", () => logger.info("SOCKET - Slack disconnected"))
418
- .on("ready", () => logger.info("SOCKET - Ready to receive events"));
419
-
420
- logger.info("BOT - Connecting to Slack Socket Mode...");
421
- await socketModeClient.start();
422
- logger.info("BOT - socketModeClient.start() returned");
423
- return socketModeClient;
497
+ const messageEvent = zSlackMessage.parse(raw);
498
+ logger.debug("MESSAGE EVENT", { event });
499
+
500
+ if (messageEvent.bot_id) return;
501
+
502
+ const botUserId = process.env.SLACK_BOT_USER_ID || "U078499LK5K";
503
+ const text = messageEvent.text || "";
504
+ const hasBotMention = text.includes(`<@${botUserId}>`);
505
+ const isDM = messageEvent.channel_type === "im" || messageEvent.channel_type === "mpdm";
506
+
507
+ if (
508
+ (isDM || hasBotMention) &&
509
+ messageEvent.user &&
510
+ messageEvent.text &&
511
+ messageEvent.channel &&
512
+ messageEvent.ts &&
513
+ messageEvent.team &&
514
+ messageEvent.event_ts
515
+ ) {
516
+ const mentionEvent: z.infer<typeof zAppMentionEvent> = {
517
+ type: "app_mention" as const,
518
+ user: messageEvent.user,
519
+ ts: messageEvent.ts,
520
+ client_msg_id: messageEvent.client_msg_id,
521
+ text: messageEvent.text,
522
+ team: messageEvent.team,
523
+ thread_ts: messageEvent.thread_ts,
524
+ parent_user_id: messageEvent.parent_user_id,
525
+ blocks: messageEvent.blocks || [],
526
+ channel: messageEvent.channel,
527
+ assistant_thread: messageEvent.assistant_thread,
528
+ attachments: messageEvent.attachments,
529
+ event_ts: messageEvent.event_ts,
530
+ };
531
+ await spawnBotOnSlackMessageEvent(mentionEvent);
532
+ }
533
+ }
424
534
  }
425
535
  async function spawnBotOnSlackMessageEvent(event: z.infer<typeof zAppMentionEvent>) {
426
- // msg dedup for same content
427
- const eventProcessed = await SlackBotState.get(`msg-${event.ts}`);
428
- // if (eventProcessed?.content === event.text) return;
429
- if (+new Date() - (eventProcessed?.touchedAt ?? 0) <= 10e3) return; // debounce for 10s
430
- await SlackBotState.set(`msg-${event.ts}`, { touchedAt: +new Date(), content: event.text });
536
+ // Dedup by content hash so message edits with new intent re-trigger,
537
+ // but truly identical retries within 10s are suppressed.
538
+ const contentHash = createHmac("sha256", "msg")
539
+ .update(event.text || "")
540
+ .digest("hex")
541
+ .slice(0, 8);
542
+ const dedupKey = `msg-${event.ts}-${contentHash}`;
543
+ const eventProcessed = await SlackBotState.get(dedupKey);
544
+ if (+new Date() - (eventProcessed?.touchedAt ?? 0) <= 10e3) return;
545
+ // 1h TTL keeps the dedup window long enough to absorb Slack edit retries
546
+ // without growing the SlackBotState collection unboundedly.
547
+ await SlackBotState.set(
548
+ dedupKey,
549
+ { touchedAt: +new Date(), content: event.text },
550
+ 60 * 60 * 1000,
551
+ );
431
552
 
432
553
  logger.info(
433
554
  await parseSlackMessageToMarkdown(
@@ -559,6 +680,34 @@ async function spawnBotOnSlackMessageEvent(event: z.infer<typeof zAppMentionEven
559
680
  .toArray()
560
681
  ).toSorted(compareBy((e) => +(e.ts || 0))); // sort by ts asc
561
682
 
683
+ // Compress thread context: keep the most recent 15 messages verbatim, and
684
+ // summarize older ones with gpt-4o-mini to slash token usage on long
685
+ // threads. Skip summarization entirely if there's nothing old.
686
+ let nearbyMessagesForLLM: typeof nearbyMessages | string = nearbyMessages;
687
+ if (nearbyMessages.length > 20) {
688
+ const recent = nearbyMessages.slice(-15);
689
+ const older = nearbyMessages.slice(0, -15);
690
+ try {
691
+ const olderYaml = yaml.stringify(older);
692
+ const summary = (await zChatCompletion(z.object({ summary: z.string() }), {
693
+ model: "gpt-4o-mini",
694
+ })`Summarize the following older Slack thread messages into a tight bullet
695
+ list capturing: (1) decisions made, (2) open questions, (3) named files/PRs/URLs
696
+ mentioned, (4) any errors or constraints surfaced. Keep under 400 words.
697
+
698
+ <older-messages-yaml>
699
+ ${olderYaml}
700
+ </older-messages-yaml>`) as { summary: string };
701
+
702
+ nearbyMessagesForLLM = `## Older thread summary (${older.length} messages)\n${summary.summary}\n\n## Recent messages (${recent.length})\n${yaml.stringify(recent)}`;
703
+ logger.info(
704
+ `Compressed ${older.length} older messages → summary (${summary.summary.length} chars)`,
705
+ );
706
+ } catch (err) {
707
+ logger.warn("Older-message summarization failed, sending full thread", { err });
708
+ }
709
+ }
710
+
562
711
  const existedTaskInputFlow = TaskInputFlows.get(workspaceId);
563
712
  if (existedTaskInputFlow && false) {
564
713
  // disable for now, lets use --queue to serialize tasks
@@ -667,6 +816,7 @@ Respond in JSON format with the following fields:
667
816
  logger.warn("No existing task input flow found");
668
817
  return;
669
818
  }
819
+ await touchTaskUserActivity(workspaceId);
670
820
  const w = existedTaskInputFlow!.writable.getWriter();
671
821
  await w.write(
672
822
  await parseSlackMessageToMarkdown(
@@ -694,15 +844,45 @@ Respond in JSON format with the following fields:
694
844
  .add({ name: "eyes", channel: event.channel, timestamp: event.ts })
695
845
  .catch(() => {});
696
846
 
697
- // quick-intent-detect-respond by chatgpt, give quick plan/context responds before start heavy agent work
847
+ // Post a placeholder immediately so the user sees activity within 1–2s.
848
+ // The real intent analysis runs in parallel below and edits this same message.
849
+ type QuickRespondMsg = { ts: string; text: string; channel?: string; url?: string };
850
+ const placeholderText = "👀 受け取りました。内容を確認しています…";
851
+ const existingPlaceholder = (await SlackBotState.get(`task-quick-respond-msg-${eventId}`)) as
852
+ | QuickRespondMsg
853
+ | undefined;
854
+ let placeholderTs: string;
855
+ if (existingPlaceholder?.ts) {
856
+ placeholderTs = existingPlaceholder.ts;
857
+ } else {
858
+ const posted = await safeSlackPostMessage(slack, {
859
+ channel: event.channel,
860
+ thread_ts: event.ts,
861
+ text: placeholderText,
862
+ blocks: [{ type: "markdown", text: placeholderText }],
863
+ });
864
+ placeholderTs = posted.ts!;
865
+ await SlackBotState.set(`task-quick-respond-msg-${eventId}`, {
866
+ ts: placeholderTs,
867
+ text: placeholderText,
868
+ channel: event.channel,
869
+ url: `https://${SLACK_ORG_DOMAIN_NAME}.slack.com/archives/${event.channel}/p${placeholderTs.replace(".", "")}`,
870
+ });
871
+ }
872
+
873
+ // Intent detection — mini is fast/cheap and good enough for classification;
874
+ // falls back to 4o implicitly via retries inside zChatCompletion on failure.
698
875
  const resp = await zChatCompletion(
699
876
  z.object({
700
877
  user_intent: z.string(),
701
878
  my_respond_before_spawn_agent: z.string(),
702
879
  should_spawn_agent: z.boolean(),
880
+ // simple = lookup/single tool / quick answer; medium = multi-step research;
881
+ // complex = code change, multi-repo, lots of files, or open-ended exploration.
882
+ complexity: z.enum(["simple", "medium", "complex"]),
703
883
  }),
704
884
  {
705
- model: "gpt-4o",
885
+ model: "gpt-4o-mini",
706
886
  },
707
887
  )`
708
888
  The user mentioned me with the following message in Slack: ${event.text}
@@ -710,8 +890,14 @@ Based on this message, please determine the user's intent in a concise manner.
710
890
  Also, provide a brief response that I can send to the user immediately to acknowledge their request.
711
891
  Finally, I will spawn an agent to help with this request if necessary.
712
892
 
713
- For context, Recent messages from this thread are as follows:
714
- ${nearbyMessages.map((m) => `- User ${m.username} said: ${JSON.stringify(m.markdown)}`).join("\n\n")}
893
+ For context, recent thread messages (older ones may already be summarized):
894
+ ${
895
+ typeof nearbyMessagesForLLM === "string"
896
+ ? nearbyMessagesForLLM
897
+ : nearbyMessagesForLLM
898
+ .map((m) => `- User ${m.username} said: ${JSON.stringify(m.markdown)}`)
899
+ .join("\n\n")
900
+ }
715
901
 
716
902
  Possible Context Repos:
717
903
  - 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.
@@ -729,94 +915,34 @@ Respond in JSON format with the following fields:
729
915
  - 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."
730
916
  - 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."
731
917
  - should_spawn_agent: true if further research needed
918
+ - complexity: "simple" for quick lookups answerable with one tool call; "medium" for multi-step research across docs/code; "complex" for code changes, multi-repo work, or open-ended exploration.
732
919
  `;
733
920
 
734
921
  const myResponseMessage = await mdFmt(resp.my_respond_before_spawn_agent);
735
- // - 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.
736
922
  logger.info("Intent detection response", JSON.stringify({ resp }));
737
923
 
738
- // upsert quick respond msg
739
- type QuickRespondMsg = { ts: string; text: string; channel?: string; url?: string };
740
- const quickRespondMsg = await SlackBotState.get(`task-quick-respond-msg-${eventId}`).then(
741
- async (existing: QuickRespondMsg | undefined) => {
742
- if (existing) {
743
- await slack.reactions
744
- .remove({ name: "x", channel: existing.channel!, timestamp: existing.ts! })
745
- .catch(() => {});
746
-
747
- // if its a DM, always create a new message
748
- // if (isDM) {
749
- // const newMsg = await slack.chat.postMessage({
750
- // channel: event.channel,
751
- // thread_ts: event.ts,
752
- // text: myResponseMessage,
753
- // blocks: [
754
- // {
755
- // type: "markdown",
756
- // text: myResponseMessage,
757
- // },
758
- // ],
759
- // });
760
- // await State.set(`task-quick-respond-msg-${eventId}`, { ts: newMsg.ts!, text: myResponseMessage });
761
- // return { ...newMsg, text: myResponseMessage };
762
- // }
763
- // actually lets always post new msg for now.
764
- // if (true) {
765
- // const newMsg = await slack.chat.postMessage({
766
- // channel: event.channel,
767
- // thread_ts: event.ts,
768
- // text: myResponseMessage,
769
- // blocks: [
770
- // {
771
- // type: "markdown",
772
- // text: myResponseMessage,
773
- // },
774
- // ],
775
- // });
776
- // await State.set(`task-quick-respond-msg-${eventId}`, { ts: newMsg.ts!, text: myResponseMessage });
777
- // return { ...newMsg, text: myResponseMessage };
778
- // }
779
-
780
- const msg = await safeSlackUpdateMessage(slack, {
781
- channel: event.channel,
782
- ts: existing.ts,
783
- text: myResponseMessage, // Fallback text for notifications
784
- blocks: [
785
- {
786
- type: "markdown",
787
- text: myResponseMessage,
788
- },
789
- ],
790
- });
791
- await SlackBotState.set(`task-quick-respond-msg-${eventId}`, {
792
- ts: existing.ts,
793
- text: myResponseMessage,
794
- channel: event.channel,
795
- url: `https://${SLACK_ORG_DOMAIN_NAME}.slack.com/archives/${event.channel}/p${existing.ts.replace(".", "")}`,
796
- });
797
- return { ...msg, text: myResponseMessage };
798
- } else {
799
- const newMsg = await safeSlackPostMessage(slack, {
800
- channel: event.channel,
801
- thread_ts: event.ts,
802
- text: myResponseMessage, // Fallback text for notifications
803
- blocks: [
804
- {
805
- type: "markdown",
806
- text: myResponseMessage,
807
- },
808
- ],
809
- });
810
- await SlackBotState.set(`task-quick-respond-msg-${eventId}`, {
811
- ts: newMsg.ts!,
812
- text: myResponseMessage,
813
- channel: event.channel,
814
- url: `https://${SLACK_ORG_DOMAIN_NAME}.slack.com/archives/${event.channel}/p${newMsg.ts!.replace(".", "")}`,
815
- });
816
- return { ...newMsg, text: myResponseMessage };
817
- }
818
- },
819
- );
924
+ // Replace the earlier "👀 受け取りました" placeholder with the LLM-synthesized intro.
925
+ // placeholderTs was set during the pre-intent fast-ack above.
926
+ await slack.reactions
927
+ .remove({ name: "x", channel: event.channel, timestamp: placeholderTs })
928
+ .catch(() => {});
929
+ await safeSlackUpdateMessage(slack, {
930
+ channel: event.channel,
931
+ ts: placeholderTs,
932
+ text: myResponseMessage,
933
+ blocks: [{ type: "markdown", text: myResponseMessage }],
934
+ });
935
+ await SlackBotState.set(`task-quick-respond-msg-${eventId}`, {
936
+ ts: placeholderTs,
937
+ text: myResponseMessage,
938
+ channel: event.channel,
939
+ url: `https://${SLACK_ORG_DOMAIN_NAME}.slack.com/archives/${event.channel}/p${placeholderTs.replace(".", "")}`,
940
+ });
941
+ const quickRespondMsg: QuickRespondMsg = {
942
+ ts: placeholderTs,
943
+ text: myResponseMessage,
944
+ channel: event.channel,
945
+ };
820
946
 
821
947
  // and now, lets update quickRespondMsg freq until user is satisfied or agent finished its work
822
948
 
@@ -892,15 +1018,59 @@ Respond in JSON format with the following fields:
892
1018
  EVENT_THREAD_TS: event.thread_ts || event.ts,
893
1019
  });
894
1020
 
895
- // const taskUser = `bot-user-${workspaceId.replace(".", "-")}`;
896
- // const taskUser = `bot-user-${workspaceId.replace(".", "-")}`;
1021
+ // Create per-task Linux user for agent isolation
1022
+ const taskUser = await createTaskUser(workspaceId);
1023
+ logger.info(`Created task user: ${taskUser.username} for workspace ${workspaceId}`);
897
1024
  await mkdir(botWorkingDir, { recursive: true });
898
- // todo: create a linux user for task
899
1025
 
900
1026
  // fill initial files for agent
901
1027
 
902
1028
  await Bun.write(`${botWorkingDir}/CLAUDE.md`, CLAUDEMD);
903
1029
 
1030
+ // Download images attached to the triggering message into ./attachments/ so
1031
+ // Claude (which has vision) can open them locally instead of needing a
1032
+ // Slack-authenticated URL fetch.
1033
+ const attachmentsDir = `${botWorkingDir}/attachments`;
1034
+ const downloadedImages: { localPath: string; name: string; mimetype?: string }[] = [];
1035
+ const MAX_IMAGE_BYTES = 25 * 1024 * 1024; // 25MB — Slack's free-tier upload cap
1036
+ const triggeringFiles = nearbyMessages.find((m) => m.ts === event.ts)?.files ?? [];
1037
+ if (triggeringFiles.length > 0) {
1038
+ await mkdir(attachmentsDir, { recursive: true });
1039
+ const slackToken =
1040
+ process.env.SLACK_BOT_TOKEN || DIE("missing SLACK_BOT_TOKEN for image download");
1041
+ for (const [idx, file] of triggeringFiles.entries()) {
1042
+ const downloadUrl =
1043
+ (file as { url_private_download?: string }).url_private_download || file.url_private;
1044
+ if (!downloadUrl || !file.mimetype?.startsWith("image/")) continue;
1045
+ if (typeof file.size === "number" && file.size > MAX_IMAGE_BYTES) {
1046
+ logger.warn(
1047
+ `Skipping oversized image ${file.name} (${file.size} bytes > ${MAX_IMAGE_BYTES})`,
1048
+ );
1049
+ continue;
1050
+ }
1051
+ try {
1052
+ const resp = await fetch(downloadUrl, {
1053
+ headers: { Authorization: `Bearer ${slackToken}` },
1054
+ });
1055
+ if (!resp.ok) {
1056
+ logger.warn(`Image download failed (${resp.status}) for ${file.name}`);
1057
+ continue;
1058
+ }
1059
+ // Prefix with idx + Slack file id (when available) so two attachments
1060
+ // with the same filename don't collide and overwrite each other.
1061
+ const fileId = (file as { id?: string }).id ?? `i${idx}`;
1062
+ const baseName = (file.name || "image").replace(/[^\w.-]/g, "_");
1063
+ const safeName = `${fileId}-${baseName}`;
1064
+ const localPath = `${attachmentsDir}/${safeName}`;
1065
+ await Bun.write(localPath, await resp.bytes());
1066
+ downloadedImages.push({ localPath, name: safeName, mimetype: file.mimetype });
1067
+ logger.info(`Downloaded image ${safeName} (${file.mimetype}) → ${localPath}`);
1068
+ } catch (err) {
1069
+ logger.warn("Image download error", { err, file: file.name });
1070
+ }
1071
+ }
1072
+ }
1073
+
904
1074
  // clone https://github.com/Comfy-Org/Comfy-PR/tree/sno-bot to ./repos/prbot (branch: sno-bot)
905
1075
  const prBotRepoDir = `${botWorkingDir}/codes/Comfy-Org/pr-bot/tree/main`;
906
1076
  await mkdir(prBotRepoDir, { recursive: true });
@@ -985,10 +1155,14 @@ When a prbot CLI command fails:
985
1155
  );
986
1156
  await Bun.$`code ${botWorkingDir}`.catch(() => null); // open the working dir in vscode for debugging
987
1157
 
1158
+ const attachmentsBlock = downloadedImages.length
1159
+ ? `\nATTACHED IMAGES (downloaded into ./attachments/, open them with the Read tool to see the contents):\n${downloadedImages.map((i) => `- ./attachments/${i.name} (${i.mimetype})`).join("\n")}\n`
1160
+ : "";
1161
+
988
1162
  const agentPrompt = `
989
1163
  the @${username} intented to ${resp.user_intent}
990
1164
  Please assist them with their request using all your resources available.
991
-
1165
+ ${attachmentsBlock}
992
1166
  IMPORTANT WORKSPACE CONVENTIONS:
993
1167
  - 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
994
1168
  - Log any tool errors or failures to ./TOOLS_ERRORS.md
@@ -1020,7 +1194,9 @@ IMPORTANT WORKSPACE CONVENTIONS:
1020
1194
  logger.warn(`Error content preview: ${content.substring(0, 500)}...`);
1021
1195
  }
1022
1196
  : undefined,
1023
- checkInterval: 10000,
1197
+ // fs.watch handles real-time detection; this slow poll is a safety net
1198
+ // for FS layers that drop events.
1199
+ checkInterval: 60_000,
1024
1200
  });
1025
1201
  await errorCollector.start();
1026
1202
 
@@ -1033,35 +1209,54 @@ IMPORTANT WORKSPACE CONVENTIONS:
1033
1209
  "Please read PROMPT.txt and TODO.md in the current directory and complete all tasks listed there.";
1034
1210
 
1035
1211
  const abortController = new AbortController();
1212
+ const abortKey = `${event.channel}:${event.ts}`;
1213
+ TaskAbortControllers.set(abortKey, abortController);
1036
1214
 
1037
1215
  // Handle follow-up messages: when user sends more messages in the thread,
1038
1216
  // pipe them to the running agent via streamInput
1039
1217
  let agentQuery: Query | null = null;
1040
1218
 
1041
- // Drain taskInputFlow into the SDK agent
1219
+ // Drain taskInputFlow into the SDK agent. Buffer values that arrive before
1220
+ // `agentQuery` is created so early follow-ups aren't silently dropped.
1221
+ const earlyBuffer: string[] = [];
1222
+ let agentReady = false;
1042
1223
  const inputDrainPromise = (async () => {
1043
1224
  const reader = taskInputFlow.readable.getReader();
1225
+ const pushToAgent = async (value: string) => {
1226
+ const userMsg: SDKUserMessage = {
1227
+ type: "user" as const,
1228
+ message: { role: "user" as const, content: value },
1229
+ parent_tool_use_id: null,
1230
+ session_id: "",
1231
+ };
1232
+ await (agentQuery as Query).streamInput(
1233
+ (async function* () {
1234
+ yield userMsg;
1235
+ })(),
1236
+ );
1237
+ logger.info(`Injected follow-up message into SDK agent: ${value.slice(0, 100)}`);
1238
+ };
1044
1239
  try {
1045
1240
  while (true) {
1046
1241
  const { done, value } = await reader.read();
1047
1242
  if (done) break;
1048
- if (value && agentQuery !== null) {
1049
- const userMsg: SDKUserMessage = {
1050
- type: "user" as const,
1051
- message: { role: "user" as const, content: value },
1052
- parent_tool_use_id: null,
1053
- session_id: "",
1054
- };
1055
- await (agentQuery as Query).streamInput(
1056
- (async function* () {
1057
- yield userMsg;
1058
- })(),
1059
- );
1060
- logger.info(`Injected follow-up message into SDK agent: ${value.slice(0, 100)}`);
1243
+ if (!value) continue;
1244
+ if (agentQuery && agentReady) {
1245
+ // Drain anything queued during startup first to preserve order.
1246
+ while (earlyBuffer.length > 0) await pushToAgent(earlyBuffer.shift()!);
1247
+ await pushToAgent(value);
1248
+ } else {
1249
+ earlyBuffer.push(value);
1061
1250
  }
1062
1251
  }
1063
1252
  } catch {
1064
1253
  // taskInputFlow closed
1254
+ } finally {
1255
+ try {
1256
+ reader.releaseLock();
1257
+ } catch {
1258
+ /* already released or stream errored */
1259
+ }
1065
1260
  }
1066
1261
  })();
1067
1262
 
@@ -1071,6 +1266,10 @@ IMPORTANT WORKSPACE CONVENTIONS:
1071
1266
  const idleWaiter = new IdleWaiter();
1072
1267
  let isThinking = false;
1073
1268
 
1269
+ // Track GitHub PR URLs surfaced by the sub-agent so they always appear in 📎 成果物.
1270
+ const seenPrUrls = new Set<string>();
1271
+ const PR_URL_RE = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/g;
1272
+
1074
1273
  // Slack update logic — extracted so it can be called from interval and finally
1075
1274
  let lastSlackUpdateTime = 0;
1076
1275
  const MIN_SLACK_UPDATE_INTERVAL_MS = 10_000; // minimum 10s between LLM-synthesized updates
@@ -1084,6 +1283,8 @@ IMPORTANT WORKSPACE CONVENTIONS:
1084
1283
  const news = agentOutput.slice(lastSentOutput.length);
1085
1284
  lastSentOutput = agentOutput;
1086
1285
 
1286
+ for (const url of news.match(PR_URL_RE) ?? []) seenPrUrls.add(url);
1287
+
1087
1288
  const my_internal_thoughts = agentOutput.split("\n").slice(-80).join("\n");
1088
1289
  logger.info(
1089
1290
  "Agent output preview: " +
@@ -1093,63 +1294,66 @@ IMPORTANT WORKSPACE CONVENTIONS:
1093
1294
  }),
1094
1295
  );
1095
1296
 
1096
- // GPT-4o synthesis for Slack update
1297
+ // Route small incremental updates to the cheaper mini model (~80% cost cut).
1298
+ // Fall back to gpt-4o on large diffs or when no prior message exists.
1299
+ const hasPrior = (quickRespondMsg.text || "").length > 0;
1300
+ const updateModel = hasPrior && news.length < 2000 ? "gpt-4o-mini" : "gpt-4o";
1301
+
1097
1302
  const contexts = {
1098
1303
  my_internal_thoughts,
1099
1304
  news,
1100
1305
  user_original_intent: resp.user_intent,
1101
1306
  my_response_md_original: quickRespondMsg.text || "",
1307
+ // Auto-extracted GitHub PR URLs the agent has produced so far. The
1308
+ // prompt template instructs the model to surface every entry under
1309
+ // the 📎 成果物 section so users never miss a freshly-opened PR.
1310
+ detected_pr_urls: [...seenPrUrls],
1102
1311
  };
1103
- const updateResponseResp = (await zChatCompletion({
1104
- my_response_md_updated: z.string(),
1105
- })`
1106
- 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.
1312
+ const updateResponseResp = (await zChatCompletion(
1313
+ { my_response_md_updated: z.string() },
1314
+ { model: updateModel },
1315
+ )`
1316
+ TASK: Update my_response_md_original for Slack using the agent's new my_internal_thoughts.
1317
+ Output the FULL updated message (not a diff), preserving the section structure below.
1318
+
1319
+ SECTION STRUCTURE (keep these exact headings in this order; omit a section only if it has no content):
1320
+ ## 📋 理解
1321
+ One short line restating user intent.
1322
+
1323
+ ## 🔍 進捗
1324
+ Bulleted current progress. Append new bullets for new findings.
1325
+ Prefix in-progress bullets with "- ⏳" and completed with "- ✅".
1326
+ Keep at most 8 recent bullets; drop oldest when over limit.
1327
+
1328
+ ## 📎 成果物
1329
+ Links to deliverables (PR URLs, gist/file shares). Omit if none.
1330
+ IMPORTANT: every URL listed in contexts.detected_pr_urls MUST appear here as a bullet (e.g. "- PR: <url>"). Never drop one once it has been surfaced.
1331
+
1332
+ ## ✅ 完了
1333
+ Checklist "- [x] …" for finished subtasks. Omit if none.
1107
1334
 
1108
1335
  RULES:
1109
- - Do not remove unknown parts from my_response_md_original that are not mentioned in my_internal_thoughts.
1110
- - Preserve markdown formatting in my_response_md_original.
1111
- - If my_internal_thoughts contains new information, append it to the relevant sections in my_response_md_original.
1112
- - If my_internal_thoughts indicates completion of a task, add a "Tasks" section at the end of my_response_md_original with - [x] mark.
1113
- - Ensure my_response_md_updated is clear and concise.
1114
- - Use **bold** to highlight new sections or important updates. Remove previously highlighted sections if they're no longer relevant.
1115
- - If all information from my_internal_thoughts is already contained in my_response_md_original, return: {my_response_md_updated: "__NOTHING_CHANGED__"}
1116
-
1117
- CRITICAL FILTERING RULES (Non-negotiable):
1118
- - KEEP ONLY: User-facing progress, task completion status, findings relevant to user's intent, next steps
1119
- - REMOVE: File paths, system info, debug output, error stack traces, internal process details, development notes
1120
- - EXAMPLES OF WHAT TO REMOVE:
1121
- - "/bot/slack/channel-id/timestamp" (internal paths)
1122
- - "undefined/null received in chunk" (internal errors)
1123
- - "DEBUG: ..." (debug output)
1124
- - "✓ Created /tmp/cache/..." (internal file operations)
1125
- - "[2026-02-20T15:10:40.123Z]" (timestamps)
1336
+ - Preserve finished "- [x]" items. Never delete them.
1337
+ - If my_internal_thoughts contains brand new information, add it as a bullet under 進捗.
1338
+ - If a previous bullet is now done, flip it to (and if it ends a logical subtask, also append to 完了).
1339
+ - If truly nothing changed since my_response_md_original, return {my_response_md_updated: "__NOTHING_CHANGED__"}.
1340
+
1341
+ CRITICAL FILTERING (non-negotiable):
1342
+ - KEEP: user-facing progress, task completion, findings relevant to user's intent, PR/doc URLs
1343
+ - REMOVE: file paths, stack traces, debug output, timestamps, internal process logs, env var values
1344
+ Examples to drop: "/bot/slack/...", "DEBUG: ...", "[2026-..]", "✓ Created /tmp/...", "undefined received in chunk"
1126
1345
 
1127
1346
  TONE & LENGTH:
1128
- - KEEP message very short and informative, use url links to reference documents/repos instead of pasting large contents
1129
- - Response should be up to 16 lines maximum (agent posts long reports as .md files)
1130
- - Focus ONLY on end-user's question or intent's helpful contents
1131
- - Describe current progress in up to 7 words (less is better)
1132
- - Avoid jargon; write for non-technical users when possible
1133
-
1134
- FORMAT REQUIREMENTS:
1135
- - Output in standard markdown format (GitHub flavored)
1136
- - YOU CAN ONLY change/remove/add up to 1 line per update!
1137
- - LENGTH LIMIT: Must be within 4000 characters (system will truncate if exceeding)
1138
- - MOST IMPORTANT: Keep my_response_md_original's context and formatting mostly unchanged, only update necessary lines
1139
-
1140
- DO NOT:
1141
- - Ask the user questions
1142
- - Include error details (they're logged separately for developers)
1143
- - Show code blocks or technical configs
1144
- - Show internal process logs or environment variables
1145
- - Show any paths starting with "/" or "./"
1146
-
1147
- - Here's Contexts in YAML for your respondse:
1347
+ - Short, informative; link out instead of pasting large content
1348
+ - Up to ~16 lines total across all sections
1349
+ - Non-technical wording when possible
1350
+ - No questions to the user, no code blocks, no raw paths beginning with "/" or "./"
1351
+ - LENGTH LIMIT: <= 4000 chars (system truncates if exceeded)
1352
+ - Standard GitHub-flavored markdown
1148
1353
 
1149
1354
  <task-context-yaml>
1150
1355
  ${yaml.stringify(contexts)}
1151
1356
  </task-context-yaml>
1152
-
1153
1357
  `) as { my_response_md_updated: string };
1154
1358
 
1155
1359
  // Log raw response
@@ -1209,11 +1413,58 @@ ${yaml.stringify(contexts)}
1209
1413
  };
1210
1414
 
1211
1415
  // Periodic Slack update interval
1212
- const slackUpdateInterval = setInterval(sendSlackUpdate, 10e3);
1416
+ // Synthesizer runs less aggressively now: the agent is instructed to call
1417
+ // `prbot slack update` directly for real progress, so this interval is just
1418
+ // a safety net for agents that go quiet on Slack while still producing
1419
+ // tool output. 30s vs the old 10s further cuts LLM cost.
1420
+ const slackUpdateInterval = setInterval(sendSlackUpdate, 30e3);
1213
1421
 
1214
1422
  // Run the agent
1215
1423
  let exitCode: number | null = 0;
1216
1424
  try {
1425
+ // Prepare workspace ownership for the task user
1426
+ await prepareTaskWorkspace(taskUser.username, botWorkingDir);
1427
+
1428
+ // Cap agent turns by classified complexity to avoid runaway cost on
1429
+ // simple questions while still allowing complex tasks room to breathe.
1430
+ const turnsByComplexity = { simple: 40, medium: 100, complex: 200 } as const;
1431
+ const maxTurns = turnsByComplexity[resp.complexity] ?? 200;
1432
+ logger.info(`Agent maxTurns=${maxTurns} for complexity=${resp.complexity}`);
1433
+
1434
+ // Allowlist env vars passed into the Claude agent subprocess. The bot
1435
+ // process holds Slack signing/bot tokens that the agent never needs;
1436
+ // forwarding the entire process.env widens the blast radius if the
1437
+ // agent's bash tool is asked to dump env (it will, when prompted).
1438
+ const ghToken = process.env.GH_TOKEN_COMFY_PR_BOT || DIE("missing GH_TOKEN_COMFY_PR_BOT env");
1439
+ const passEnv: Record<string, string> = {
1440
+ HOME: taskUser.homeDir,
1441
+ USER: taskUser.username,
1442
+ LOGNAME: taskUser.username,
1443
+ PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
1444
+ LANG: process.env.LANG || "C.UTF-8",
1445
+ LC_ALL: process.env.LC_ALL || "C.UTF-8",
1446
+ TERM: process.env.TERM || "xterm-256color",
1447
+ GH_TOKEN: ghToken,
1448
+ GITHUB_TOKEN: ghToken,
1449
+ };
1450
+ // Whitelist anything the agent legitimately needs at runtime.
1451
+ for (const k of [
1452
+ "ANTHROPIC_API_KEY",
1453
+ "OPENAI_API_KEY",
1454
+ "NOTION_TOKEN",
1455
+ "SLACK_BOT_TOKEN", // agent uses prbot slack update / read
1456
+ "PRBOT_PORT",
1457
+ "PRBOT_FEEDBACK_CHANNEL",
1458
+ "MONGODB_URI",
1459
+ "DEBUG",
1460
+ "VERBOSE",
1461
+ "LOG_LEVEL",
1462
+ "NODE_ENV",
1463
+ ]) {
1464
+ const v = process.env[k];
1465
+ if (v) passEnv[k] = v;
1466
+ }
1467
+
1217
1468
  agentQuery = query({
1218
1469
  prompt: sdkPrompt,
1219
1470
  options: {
@@ -1221,20 +1472,18 @@ ${yaml.stringify(contexts)}
1221
1472
  permissionMode: "bypassPermissions",
1222
1473
  allowDangerouslySkipPermissions: true,
1223
1474
  settingSources: ["project"], // loads CLAUDE.md from cwd
1224
- maxTurns: 200,
1475
+ maxTurns,
1225
1476
  persistSession: false,
1226
1477
  abortController,
1227
- env: {
1228
- ...process.env,
1229
- GH_TOKEN: process.env.GH_TOKEN_COMFY_PR_BOT || DIE("missing GH_TOKEN_COMFY_PR_BOT env"),
1230
- GITHUB_TOKEN:
1231
- process.env.GH_TOKEN_COMFY_PR_BOT || DIE("missing GH_TOKEN_COMFY_PR_BOT env"),
1232
- },
1478
+ // Run the CLI subprocess as the per-task non-root user
1479
+ spawnClaudeCodeProcess: createUserSpawner(taskUser.username, taskUser.homeDir),
1480
+ env: passEnv,
1233
1481
  stderr: (data: string) => {
1234
1482
  logger.warn(`[agent stderr]: ${data}`);
1235
1483
  },
1236
1484
  },
1237
1485
  });
1486
+ agentReady = true;
1238
1487
 
1239
1488
  await Bun.write(
1240
1489
  statusLogPath,
@@ -1321,6 +1570,7 @@ ${yaml.stringify(contexts)}
1321
1570
  await sendSlackUpdate().catch((err) => logger.error("Final Slack update error:", { err }));
1322
1571
  // Cancel input drain
1323
1572
  abortController.abort();
1573
+ TaskAbortControllers.delete(abortKey);
1324
1574
  }
1325
1575
 
1326
1576
  TaskInputFlows.delete(workspaceId);
@@ -1384,6 +1634,9 @@ ${yaml.stringify(contexts)}
1384
1634
 
1385
1635
  // Remove task from working list
1386
1636
  await removeWorkingTask(event);
1637
+
1638
+ // Note: Task user cleanup is handled by periodic cleanupStaleTaskUsers()
1639
+ // We don't delete the user immediately in case of task resume via --continue
1387
1640
  }
1388
1641
 
1389
1642
  function sleep(ms: number) {