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/cli.ts +33 -0
- package/bot/error-collector.ts +53 -3
- package/bot/index.ts +48 -1
- package/bot/slack-bot.ts +549 -296
- package/bot/spawn-as-user.ts +82 -0
- package/bot/task-user.ts +136 -0
- package/bot/taskInputFlow.spec.ts +87 -0
- package/package.json +1 -1
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 {
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
269
|
-
logger.info("BOT - --continue flag detected, resuming crashed tasks...");
|
|
346
|
+
logger.info("BOT - --continue flag detected, resuming crashed tasks...");
|
|
270
347
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
.
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
482
|
+
if (raw.type === "app_mention") {
|
|
483
|
+
const parsedEvent = await zAppMentionEvent.parseAsync(event);
|
|
484
|
+
await spawnBotOnSlackMessageEvent(parsedEvent);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
366
487
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
//
|
|
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,
|
|
714
|
-
${
|
|
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
|
-
//
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
//
|
|
896
|
-
|
|
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
|
-
|
|
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
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
-
|
|
1110
|
-
-
|
|
1111
|
-
- If
|
|
1112
|
-
- If
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
-
-
|
|
1129
|
-
-
|
|
1130
|
-
-
|
|
1131
|
-
-
|
|
1132
|
-
-
|
|
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
|
-
|
|
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
|
|
1475
|
+
maxTurns,
|
|
1225
1476
|
persistSession: false,
|
|
1226
1477
|
abortController,
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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) {
|