comfy-pr 1.0.1 → 1.2.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 CHANGED
@@ -42,6 +42,9 @@ import { searchNotion } from "@/lib/notion/search";
42
42
  // Video ability
43
43
  import { readVideo } from "@/lib/video/read-video";
44
44
 
45
+ // Feedback ability
46
+ import { postFeedback, type FeedbackType } from "@/lib/slack/feedback";
47
+
45
48
  /**
46
49
  * Load environment variables from .env.local in the project root
47
50
  * This allows prbot to work from unknown directory
@@ -1132,6 +1135,43 @@ async function main() {
1132
1135
  .demandCommand(1, "Please specify a debug subcommand")
1133
1136
  .help();
1134
1137
  })
1138
+ .command(
1139
+ "feedback",
1140
+ "Submit feedback (bugs, feature requests, errors) to the private #prbot-feedback Slack channel",
1141
+ (y) =>
1142
+ y
1143
+ .option("message", {
1144
+ alias: "m",
1145
+ type: "string",
1146
+ describe: "Feedback message describing the issue or request",
1147
+ demandOption: true,
1148
+ })
1149
+ .option("type", {
1150
+ alias: "t",
1151
+ type: "string",
1152
+ choices: ["bug", "feature", "error", "other"] as const,
1153
+ describe: "Type of feedback",
1154
+ default: "other",
1155
+ })
1156
+ .option("context", {
1157
+ type: "string",
1158
+ describe: "Additional context (error output, command that failed, etc.)",
1159
+ })
1160
+ .option("source", {
1161
+ type: "string",
1162
+ describe: "Who or what is submitting this (e.g. amp-agent, user name)",
1163
+ }),
1164
+ async (args) => {
1165
+ await loadEnvLocal();
1166
+ const ts = await postFeedback({
1167
+ message: args.message as string,
1168
+ type: args.type as FeedbackType,
1169
+ context: args.context as string | undefined,
1170
+ source: args.source as string | undefined,
1171
+ });
1172
+ console.log(`✓ Feedback posted to #prbot-feedback (ts: ${ts})`);
1173
+ },
1174
+ )
1135
1175
  .demandCommand(1, "Please specify a command")
1136
1176
  .strict()
1137
1177
  .help()
@@ -1157,6 +1197,14 @@ async function main() {
1157
1197
  " prbot slack download-file -f F123ABC -o ./downloaded.pdf",
1158
1198
  " prbot slack file-info -f F123ABC",
1159
1199
  " prbot notion search -q 'ComfyUI setup' -l 5",
1200
+ "",
1201
+ "Feedback — report bugs, missing features, or errors (posts to private Slack):",
1202
+ " prbot feedback -m 'Missing pr read command' -t feature",
1203
+ " prbot feedback -m 'search returned 0 for known PRs' -t bug --context '...'",
1204
+ " prbot feedback -m 'ReferenceError: X is not defined' -t error --source agent",
1205
+ "",
1206
+ "AI agents: if a command fails, is missing, or behaves unexpectedly,",
1207
+ "please run `prbot feedback` to report it so we can fix it.",
1160
1208
  ].join("\n"),
1161
1209
  ).argv;
1162
1210
 
package/bot/slack-bot.ts CHANGED
@@ -11,9 +11,7 @@ import { yaml } from "@/src/utils/yaml";
11
11
  import { SocketModeClient } from "@slack/socket-mode";
12
12
  import {} from "@slack/bolt";
13
13
  import DIE from "@snomiao/die";
14
- import { spawn } from "node:child_process";
15
14
  import { compareBy } from "comparing";
16
- import { fromStdio } from "from-node-stream";
17
15
  import { mkdir } from "fs/promises";
18
16
  import sflow from "sflow";
19
17
  import winston from "winston";
@@ -25,16 +23,15 @@ import { parseSlackMessageToMarkdown } from "@/lib/slack/parseSlackMessageToMark
25
23
  import { slackTsToISO } from "@/lib/slack/slackTsToISO";
26
24
  import { safeSlackPostMessage, safeSlackUpdateMessage } from "@/lib/slack/safeSlackMessage";
27
25
  import { slackMessageUrlParse } from "@/app/tasks/gh-design/slackMessageUrlParse";
28
- import { TerminalTextRender } from "terminal-render";
29
26
  import minimist from "minimist";
30
27
  import { loadClaudeMd, loadSkills } from "./templateLoader";
31
- import path from "path";
32
28
  import { appendFile } from "fs/promises";
33
29
  import fsp from "fs/promises";
34
30
  import { mdFmt } from "@/app/tasks/gh-desktop-release-notification/upsertSlackMessage";
35
31
  import { getSlackChannelName } from "@/lib/slack";
36
32
  import { SlackBotState } from "./state";
37
33
  import { ErrorCollector } from "./error-collector";
34
+ import { query, type Query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
38
35
 
39
36
  export const SLACK_ORG_DOMAIN_NAME = "comfy-organization";
40
37
  // Configure winston logger
@@ -256,10 +253,10 @@ export async function startSlackBot() {
256
253
  // );
257
254
 
258
255
  const msgs = await fsp
259
- .readFile("./msgs.yaml", "utf-8")
256
+ .readFile("./inbox.yaml", "utf-8")
260
257
  .then((s) => yaml.parse(s))
261
258
  .then((e) => z.object({ missed: z.string().array() }).parseAsync(e));
262
- await fsp.writeFile("./msgs.yaml", "missed: []");
259
+ await fsp.writeFile("./inbox.yaml", "missed: []");
263
260
 
264
261
  // clean the file
265
262
  //
@@ -377,7 +374,7 @@ export async function startSlackBot() {
377
374
  const hasBotMention = text.includes(`<@${botUserId}>`);
378
375
 
379
376
  // Handle DM messages (channel_type: "im") and treat them like app mentions
380
- const isDM = messageEvent.channel_type === "im";
377
+ const isDM = messageEvent.channel_type === "im" || messageEvent.channel_type === "mpdm";
381
378
 
382
379
  if (
383
380
  (isDM || hasBotMention) &&
@@ -1004,241 +1001,108 @@ IMPORTANT WORKSPACE CONVENTIONS:
1004
1001
  logger.info(`Spawning agent in ${botWorkingDir} with prompt: ${JSON.stringify(agentPrompt)}`);
1005
1002
  // todo: spawn in a worker user
1006
1003
 
1007
- // await Bun.$.cwd(botWorkingDir)`claude-yes -- solve-everything-in=TODO.md, PROMPT.txt, current bot args --working-dir=${botWorkingDir} --slack-channel=${event.channel} --slack-thread-ts=${quickRespondMsg.ts!}`
1008
-
1009
- // Create dedicated log files for this task (before spawning)
1004
+ // Create dedicated log files for this task
1010
1005
  const taskLogDir = `${botWorkingDir}/.logs`;
1011
1006
  await mkdir(taskLogDir, { recursive: true });
1012
- const stdoutLogPath = `${taskLogDir}/claude-yes-stdout.log`;
1013
- const stderrLogPath = `${taskLogDir}/claude-yes-stderr.log`;
1007
+ const agentLogPath = `${taskLogDir}/agent-output.log`;
1014
1008
  const statusLogPath = `${taskLogDir}/STATUS.txt`;
1015
1009
 
1016
- // create a user for task
1017
- const exitCodePromise = Promise.withResolvers<number | null>();
1018
- const sh = (() => {
1019
- // if (process.env.CLI === "amp") {
1020
- // // use amp
1021
- // }
1022
- // const continueArgs: string[] = [];
1023
- // if (botworkingdir/.claude-yes have content)
1024
- // then continueArgs.push('--continue')
1025
- // TODO: maybe use smarter way to detect if need continue
1026
- // if (existsSync(`${botWorkingDir}/.claude-yes`)) {
1027
- // // const stat = Bun.statSync(`${botWorkingDir}/.claude-yes`)
1028
- // // if (stat.isDirectory && stat.size > 0) {
1029
- // // }
1030
- // continueArgs.push('--continue')
1031
- // }
1032
- // const cmd = `bunx claude-yes -i=1d -- ${Bun.$.escape(agentPrompt)}`;
1033
- // const cli = cmd.split(" ")[0];
1034
- const cli = "claude-yes"; // Use the globally installed claude-yes (via bun)
1035
- // Pass prompt to read PROMPT.txt and TODO.md
1036
- const args = [
1037
- "--exit-on-idle=1m",
1038
- "--",
1039
- "Please read PROMPT.txt and TODO.md in the current directory and complete all tasks listed there.",
1040
- ];
1041
- logger.info(
1042
- `Spawning process: ${cli} ${args.join(" ")} in ${botWorkingDir} with env GH_TOKEN_COMFY_PR_BOT=[REDACTED]`,
1043
- );
1044
- const shell = spawn(cli, args, {
1045
- cwd: botWorkingDir,
1046
- env: {
1047
- ...process.env,
1048
- GH_TOKEN: process.env.GH_TOKEN_COMFY_PR_BOT || DIE("missing GH_TOKEN_COMFY_PR_BOT env"),
1049
- GITHUB_TOKEN: process.env.GH_TOKEN_COMFY_PR_BOT || DIE("missing GH_TOKEN_COMFY_PR_BOT env"),
1050
- },
1051
- });
1052
-
1053
- // check if p spawned successfully
1054
- shell.on("error", (err) => {
1055
- logger.error(`Failed to start ${cli} process for task ${workspaceId}:`, { err });
1056
- });
1057
- shell.on("exit", (code, signal) => {
1058
- logger.info(`process for task ${workspaceId} exited with code ${code} and signal ${signal}`);
1059
- exitCodePromise.resolve(code);
1060
- });
1061
-
1062
- // Auto-answer the trust prompt (option 1 = "Yes, proceed")
1063
- if (shell.stdin) {
1064
- shell.stdin.write("1\n");
1065
- }
1066
-
1067
- // Stream stderr to log file and logger (async operations moved outside)
1068
- shell.stderr?.on("data", (data) => {
1069
- const text = data.toString();
1070
- appendFile(stderrLogPath, text).catch(() => {});
1071
- logger.warn(`[${cli} stderr]:`, { data: text });
1072
- });
1073
-
1074
- // Check if stdout/stderr are available
1075
- if (!shell.stdout) {
1076
- logger.error(`Process ${cli} has no stdout stream!`);
1077
- }
1078
- if (!shell.stderr) {
1079
- logger.warn(`Process ${cli} has no stderr stream`);
1080
- }
1081
-
1082
- return shell;
1083
- })();
1084
-
1085
- // Write initial status
1086
- await Bun.write(
1087
- statusLogPath,
1088
- `Started: ${new Date().toISOString()}\nPID: ${sh.pid}\nStatus: Running\nLog: ${stdoutLogPath}\n`,
1089
- );
1090
-
1091
1010
  const isDebugMode = process.env.DEBUG === "true" || process.env.DEBUG === "1";
1092
1011
 
1093
- logger.info(`Spawned claude-yes process with PID ${sh.pid} for task ${workspaceId}`);
1094
- if (isDebugMode) {
1095
- logger.info(`📝 Real-time logs: tail -f ${stdoutLogPath}`);
1096
- logger.info(`📊 Status file: cat ${statusLogPath}`);
1097
- logger.info(`💡 Debug commands: prbot debug watch ${botWorkingDir}`);
1098
- }
1099
-
1100
1012
  // Start error collector to monitor workspace for errors
1101
1013
  const errorLogPath = `${taskLogDir}/COLLECTED_ERRORS.md`;
1102
1014
  const errorCollector = new ErrorCollector({
1103
1015
  workspaceDir: botWorkingDir,
1104
1016
  outputLogPath: errorLogPath,
1105
1017
  onError: isDebugMode
1106
- ? (errorPath, content) => {
1107
- logger.warn(`⚠️ Error detected in workspace: ${errorPath}`);
1018
+ ? (errorPath: string, content: string) => {
1019
+ logger.warn(`Error detected in workspace: ${errorPath}`);
1108
1020
  logger.warn(`Error content preview: ${content.substring(0, 500)}...`);
1109
1021
  }
1110
1022
  : undefined,
1111
- checkInterval: 10000, // Check every 10 seconds
1023
+ checkInterval: 10000,
1112
1024
  });
1113
1025
  await errorCollector.start();
1114
- if (isDebugMode) {
1115
- logger.info(`🔍 Error collector started, errors will be logged to: ${errorLogPath}`);
1116
- }
1117
1026
 
1118
- await sflow(
1119
- [""], // Initial Prompt to start the agent, could be empty
1120
- )
1121
- .merge(
1122
- // append messages from taskInputFlow
1123
- sflow(taskInputFlow.readable)
1124
- // send original message and then write '\n' after 1s delay to simulate user press Enter
1125
- .map(async (awaitableText) => awaitableText),
1126
- )
1127
- .by(fromStdio(sh))
1128
- // convert buffer to string and write to log file
1129
- .map(async (buffer) => {
1130
- if (buffer === undefined || buffer === null) {
1131
- logger.warn(`Received undefined/null buffer from process ${workspaceId}`);
1132
- return "";
1027
+ // --- Claude Agent SDK ---
1028
+ logger.info(
1029
+ `Spawning agent via SDK in ${botWorkingDir} with env GH_TOKEN_COMFY_PR_BOT=[REDACTED]`,
1030
+ );
1031
+
1032
+ const sdkPrompt =
1033
+ "Please read PROMPT.txt and TODO.md in the current directory and complete all tasks listed there.";
1034
+
1035
+ const abortController = new AbortController();
1036
+
1037
+ // Handle follow-up messages: when user sends more messages in the thread,
1038
+ // pipe them to the running agent via streamInput
1039
+ let agentQuery: Query | null = null;
1040
+
1041
+ // Drain taskInputFlow into the SDK agent
1042
+ const inputDrainPromise = (async () => {
1043
+ const reader = taskInputFlow.readable.getReader();
1044
+ try {
1045
+ while (true) {
1046
+ const { done, value } = await reader.read();
1047
+ 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)}`);
1061
+ }
1133
1062
  }
1134
- const text = buffer.toString();
1135
- // Write raw output to dedicated stdout log file
1136
- await appendFile(stdoutLogPath, text).catch(() => {});
1137
- return text;
1138
- })
1063
+ } catch {
1064
+ // taskInputFlow closed
1065
+ }
1066
+ })();
1139
1067
 
1140
- // pipe to /botWorkingDir/.logs/bot-<date>.log to claude input
1141
- .forkTo(async (e) => {
1142
- const logDate = new Date().toISOString().split("T")[0];
1143
- await mkdir(path.resolve(`${botWorkingDir}/.logs`), { recursive: true });
1144
- await e.forEach(
1145
- async (chunk) => await appendFile(`${botWorkingDir}/.logs/bot-${logDate}.log`, chunk),
1146
- );
1147
- })
1148
- // show loading icon when unknown output activity, and remove the loading icon after idle for 5s
1149
- .forkTo(async (e) => {
1150
- const idleWaiter = new IdleWaiter();
1151
- let isThinking = false;
1152
- return await e
1153
- .forEach(async () => {
1154
- idleWaiter.ping();
1155
- if (!isThinking && quickRespondMsg.ts && quickRespondMsg.channel) {
1156
- isThinking = true;
1157
- const msgChannel = quickRespondMsg.channel;
1158
- const msgTs = quickRespondMsg.ts;
1159
- await slack.reactions
1160
- .add({
1161
- name: "loading",
1162
- channel: msgChannel,
1163
- timestamp: msgTs,
1164
- })
1165
- .catch(() => {});
1166
- idleWaiter.wait(5e3).finally(async () => {
1167
- await slack.reactions
1168
- .remove({
1169
- name: "loading",
1170
- channel: msgChannel,
1171
- timestamp: msgTs,
1172
- })
1173
- .catch(() => {});
1174
- isThinking = false;
1175
- });
1176
- }
1177
- })
1178
- .onFlush(async () => {
1179
- // remove loading icon
1180
- if (isThinking && quickRespondMsg.ts && quickRespondMsg.channel) {
1181
- isThinking = false;
1182
- await slack.reactions
1183
- .remove({
1184
- name: "loading",
1185
- channel: quickRespondMsg.channel,
1186
- timestamp: quickRespondMsg.ts,
1187
- })
1188
- .catch(() => {});
1189
- }
1190
- })
1191
- .run();
1192
- })
1068
+ // Track agent output for Slack updates
1069
+ let agentOutput = "";
1070
+ let lastSentOutput = "";
1071
+ const idleWaiter = new IdleWaiter();
1072
+ let isThinking = false;
1193
1073
 
1194
- // Render terminal text to plain text and show live updates in slack
1195
- .forkTo(async (e) => {
1196
- const tr = new TerminalTextRender();
1197
- let sent = "";
1198
- let lastOutputs: string[] = []; // keep 3 last outputs to detect stability
1199
-
1200
- // logger.info('Rendered chunk size:', rendered.length, 'lines: ', rendered.split(/\r|\n/).length);
1201
- const id = setInterval(async () => {
1202
- const renderedText = tr.render();
1203
- // diff from last, and send stable lines
1204
- const common = commonPrefix(renderedText, ...lastOutputs);
1205
- const newStable = renderedText.slice(0, common.length);
1206
- // logger.debug({ common, newStable, lastOutputs, renderedText });
1207
-
1208
- if (newStable !== sent) {
1209
- const news = newStable.slice(sent.length);
1210
- sent = newStable; // agent outputs have new lines to send
1211
- if (news) logger.debug(JSON.stringify({ news }));
1212
- logger.info(
1213
- `New stable output detected, length: ${newStable.length}, news length: ${news.length}`,
1214
- );
1074
+ // Slack update logic extracted so it can be called from interval and finally
1075
+ let lastSlackUpdateTime = 0;
1076
+ const MIN_SLACK_UPDATE_INTERVAL_MS = 10_000; // minimum 10s between LLM-synthesized updates
1077
+ const sendSlackUpdate = async () => {
1078
+ if (agentOutput === lastSentOutput || !agentOutput) return;
1215
1079
 
1216
- const rawTerminalOutput = tr.render().split("\n").slice(-80).join("\n");
1217
- const my_internal_thoughts = cleanTerminalOutput(rawTerminalOutput);
1218
- // const my_internal_thoughts = tr.tail(80);
1219
- logger.debug(
1220
- "Raw terminal output (before cleaning): " +
1221
- yaml.stringify({ preview: rawTerminalOutput.slice(0, 200) }),
1222
- );
1223
- logger.info(
1224
- "Cleaned output preview: " +
1225
- yaml.stringify({
1226
- preview: my_internal_thoughts.slice(0, 200),
1227
- news_preview: news.slice(0, 200),
1228
- }),
1229
- );
1080
+ const now = Date.now();
1081
+ if (now - lastSlackUpdateTime < MIN_SLACK_UPDATE_INTERVAL_MS) return;
1082
+ lastSlackUpdateTime = now;
1230
1083
 
1231
- // send update to slack
1232
- const updateText = sent || "_(no output yet)_";
1233
- const contexts = {
1234
- my_internal_thoughts,
1235
- news,
1236
- user_original_intent: resp.user_intent,
1237
- my_response_md_original: quickRespondMsg.text || "",
1238
- };
1239
- const updateResponseResp = (await zChatCompletion({
1240
- my_response_md_updated: z.string(),
1241
- })`
1084
+ const news = agentOutput.slice(lastSentOutput.length);
1085
+ lastSentOutput = agentOutput;
1086
+
1087
+ const my_internal_thoughts = agentOutput.split("\n").slice(-80).join("\n");
1088
+ logger.info(
1089
+ "Agent output preview: " +
1090
+ yaml.stringify({
1091
+ preview: my_internal_thoughts.slice(0, 200),
1092
+ news_preview: news.slice(0, 200),
1093
+ }),
1094
+ );
1095
+
1096
+ // GPT-4o synthesis for Slack update
1097
+ const contexts = {
1098
+ my_internal_thoughts,
1099
+ news,
1100
+ user_original_intent: resp.user_intent,
1101
+ my_response_md_original: quickRespondMsg.text || "",
1102
+ };
1103
+ const updateResponseResp = (await zChatCompletion({
1104
+ my_response_md_updated: z.string(),
1105
+ })`
1242
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.
1243
1107
 
1244
1108
  RULES:
@@ -1288,127 +1152,187 @@ ${yaml.stringify(contexts)}
1288
1152
 
1289
1153
  `) as { my_response_md_updated: string };
1290
1154
 
1291
- // Log raw my_response_md_updated to JSONL file for debugging
1292
- const responseLogEntry = {
1293
- timestamp: new Date().toISOString(),
1294
- workspaceId,
1295
- stage: "raw_from_claude",
1296
- my_response_md_updated_raw: updateResponseResp.my_response_md_updated,
1297
- my_internal_thoughts_preview: my_internal_thoughts.slice(0, 500),
1298
- my_response_md_original: quickRespondMsg.text || "",
1299
- };
1300
- await appendFile(
1301
- ".logs/my_response_md_updated.jsonl",
1302
- JSON.stringify(responseLogEntry) + "\n",
1303
- ).catch(() => {});
1304
-
1305
- const updated_response_full = await mdFmt(
1306
- updateResponseResp.my_response_md_updated
1307
- .trim()
1308
- .replace(/^__NOTHING_CHANGED__$/m, quickRespondMsg.text || ""),
1309
- );
1155
+ // Log raw response
1156
+ await appendFile(
1157
+ ".logs/my_response_md_updated.jsonl",
1158
+ JSON.stringify({
1159
+ timestamp: new Date().toISOString(),
1160
+ workspaceId,
1161
+ stage: "raw_from_claude",
1162
+ my_response_md_updated_raw: updateResponseResp.my_response_md_updated,
1163
+ my_internal_thoughts_preview: my_internal_thoughts.slice(0, 500),
1164
+ my_response_md_original: quickRespondMsg.text || "",
1165
+ }) + "\n",
1166
+ ).catch(() => {});
1167
+
1168
+ const updated_response_full = await mdFmt(
1169
+ updateResponseResp.my_response_md_updated
1170
+ .trim()
1171
+ .replace(/^__NOTHING_CHANGED__$/m, quickRespondMsg.text || ""),
1172
+ );
1310
1173
 
1311
- // truncate to 4000 chars, from the middle, replace to '...TRUNCATED...'
1312
- const my_response_md_updated =
1313
- updated_response_full.length > 4000
1314
- ? updated_response_full.slice(0, 2000) +
1315
- "\n\n...TRUNCATED...\n\n" +
1316
- updated_response_full.slice(-2000)
1317
- : updated_response_full;
1318
-
1319
- // Log final processed my_response_md_updated
1320
- const finalLogEntry = {
1321
- timestamp: new Date().toISOString(),
1322
- workspaceId,
1323
- stage: "final_processed",
1324
- my_response_md_updated_final: my_response_md_updated,
1325
- was_truncated: updated_response_full.length > 4000,
1326
- original_length: updated_response_full.length,
1327
- };
1328
- await appendFile(
1329
- ".logs/my_response_md_updated.jsonl",
1330
- JSON.stringify(finalLogEntry) + "\n",
1331
- ).catch(() => {});
1332
-
1333
- if (quickRespondMsg.ts && quickRespondMsg.channel) {
1334
- await safeSlackUpdateMessage(slack, {
1335
- channel: quickRespondMsg.channel,
1336
- ts: quickRespondMsg.ts,
1337
- text: my_response_md_updated, // Fallback text for notifications
1338
- blocks: [
1339
- {
1340
- type: "markdown",
1341
- text: my_response_md_updated,
1342
- },
1343
- ],
1344
- });
1345
- logger.debug("Updated quick respond message in slack:", {
1346
- url: `https://${event.team}.slack.com/archives/${quickRespondMsg.channel}/p${quickRespondMsg.ts.replace(".", "")}`,
1347
- });
1174
+ // Truncate to 4000 chars from the middle
1175
+ const my_response_md_updated =
1176
+ updated_response_full.length > 4000
1177
+ ? updated_response_full.slice(0, 2000) +
1178
+ "\n\n...TRUNCATED...\n\n" +
1179
+ updated_response_full.slice(-2000)
1180
+ : updated_response_full;
1181
+
1182
+ await appendFile(
1183
+ ".logs/my_response_md_updated.jsonl",
1184
+ JSON.stringify({
1185
+ timestamp: new Date().toISOString(),
1186
+ workspaceId,
1187
+ stage: "final_processed",
1188
+ my_response_md_updated_final: my_response_md_updated,
1189
+ was_truncated: updated_response_full.length > 4000,
1190
+ original_length: updated_response_full.length,
1191
+ }) + "\n",
1192
+ ).catch(() => {});
1348
1193
 
1349
- // update quickRespondMsg content
1350
- quickRespondMsg.text = my_response_md_updated;
1351
- await SlackBotState.set(`task-quick-respond-msg-${eventId}`, {
1352
- ts: quickRespondMsg.ts,
1353
- text: quickRespondMsg.text,
1354
- channel: event.channel,
1355
- url: `https://${SLACK_ORG_DOMAIN_NAME}.slack.com/archives/${event.channel}/p${quickRespondMsg.ts.replace(".", "")}`,
1356
- });
1357
- }
1358
- }
1194
+ if (quickRespondMsg.ts && quickRespondMsg.channel) {
1195
+ await safeSlackUpdateMessage(slack, {
1196
+ channel: quickRespondMsg.channel,
1197
+ ts: quickRespondMsg.ts,
1198
+ text: my_response_md_updated,
1199
+ blocks: [{ type: "markdown", text: my_response_md_updated }],
1200
+ });
1201
+ quickRespondMsg.text = my_response_md_updated;
1202
+ await SlackBotState.set(`task-quick-respond-msg-${eventId}`, {
1203
+ ts: quickRespondMsg.ts,
1204
+ text: quickRespondMsg.text,
1205
+ channel: event.channel,
1206
+ url: `https://${SLACK_ORG_DOMAIN_NAME}.slack.com/archives/${event.channel}/p${quickRespondMsg.ts.replace(".", "")}`,
1207
+ });
1208
+ }
1209
+ };
1359
1210
 
1360
- lastOutputs.push(renderedText);
1361
- if (lastOutputs.length > 3) {
1362
- lastOutputs.shift();
1363
- }
1364
- }, 1e3);
1211
+ // Periodic Slack update interval
1212
+ const slackUpdateInterval = setInterval(sendSlackUpdate, 10e3);
1213
+
1214
+ // Run the agent
1215
+ let exitCode: number | null = 0;
1216
+ try {
1217
+ agentQuery = query({
1218
+ prompt: sdkPrompt,
1219
+ options: {
1220
+ cwd: botWorkingDir,
1221
+ permissionMode: "bypassPermissions",
1222
+ allowDangerouslySkipPermissions: true,
1223
+ settingSources: ["project"], // loads CLAUDE.md from cwd
1224
+ maxTurns: 200,
1225
+ persistSession: false,
1226
+ 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
+ },
1233
+ stderr: (data: string) => {
1234
+ logger.warn(`[agent stderr]: ${data}`);
1235
+ },
1236
+ },
1237
+ });
1365
1238
 
1366
- await e
1367
- .forEach(async (chunk) => {
1368
- if (chunk === undefined || chunk === null) {
1369
- logger.warn(`Terminal render received undefined/null chunk for task ${workspaceId}`);
1370
- return;
1371
- }
1372
- if (chunk === "") {
1373
- // Empty string is valid, just skip rendering
1374
- return;
1375
- }
1376
- try {
1377
- const rendered = tr.write(chunk);
1378
- } catch (err) {
1379
- logger.error(`Error writing chunk to terminal render for task ${workspaceId}:`, {
1380
- err,
1381
- chunkType: typeof chunk,
1382
- chunkLength: chunk?.length,
1383
- });
1239
+ await Bun.write(
1240
+ statusLogPath,
1241
+ `Started: ${new Date().toISOString()}\nStatus: Running (SDK)\nLog: ${agentLogPath}\n`,
1242
+ );
1243
+
1244
+ for await (const message of agentQuery) {
1245
+ // Loading indicator management
1246
+ idleWaiter.ping();
1247
+ if (!isThinking && quickRespondMsg.ts && quickRespondMsg.channel) {
1248
+ isThinking = true;
1249
+ const msgChannel = quickRespondMsg.channel;
1250
+ const msgTs = quickRespondMsg.ts;
1251
+ slack.reactions
1252
+ .add({ name: "loading", channel: msgChannel, timestamp: msgTs })
1253
+ .catch(() => {});
1254
+ idleWaiter.wait(5e3).finally(async () => {
1255
+ await slack.reactions
1256
+ .remove({ name: "loading", channel: msgChannel, timestamp: msgTs })
1257
+ .catch(() => {});
1258
+ isThinking = false;
1259
+ });
1260
+ }
1261
+
1262
+ // Process SDK messages
1263
+ if (message.type === "assistant") {
1264
+ const textBlocks = (message.message.content as Array<{ type: string; text?: string }>)
1265
+ .filter(
1266
+ (block): block is { type: "text"; text: string } =>
1267
+ block.type === "text" && typeof block.text === "string",
1268
+ )
1269
+ .map((block) => block.text);
1270
+ if (textBlocks.length > 0) {
1271
+ const text = textBlocks.join("\n");
1272
+ agentOutput += text + "\n";
1273
+ await appendFile(agentLogPath, text + "\n").catch(() => {});
1274
+ logger.debug(`Agent assistant text (${text.length} chars): ${text.slice(0, 200)}`);
1275
+ }
1276
+ } else if (message.type === "result") {
1277
+ if (message.subtype === "success") {
1278
+ exitCode = 0;
1279
+ logger.info(
1280
+ `Agent completed successfully. Turns: ${message.num_turns}, Cost: $${message.total_cost_usd.toFixed(4)}, Duration: ${(message.duration_ms / 1000).toFixed(1)}s`,
1281
+ );
1282
+ // Append final result to output for last Slack update
1283
+ if ("result" in message && message.result) {
1284
+ agentOutput += "\n" + message.result;
1384
1285
  }
1286
+ } else {
1287
+ exitCode = 1;
1288
+ const errors = "errors" in message ? (message as { errors: string[] }).errors : [];
1289
+ logger.error(
1290
+ `Agent failed (${message.subtype}). Turns: ${message.num_turns}, Errors: ${errors.join(", ")}`,
1291
+ );
1292
+ }
1293
+ await appendFile(
1294
+ agentLogPath,
1295
+ `\n--- Result: ${message.subtype} | Turns: ${message.num_turns} | Cost: $${message.total_cost_usd.toFixed(4)} ---\n`,
1296
+ ).catch(() => {});
1297
+ } else {
1298
+ // Log other message types for debugging
1299
+ logger.debug(
1300
+ `SDK message: ${message.type}${"subtype" in message ? `.${(message as { subtype: string }).subtype}` : ""}`,
1301
+ );
1302
+ }
1303
+ }
1304
+ } catch (err) {
1305
+ exitCode = 1;
1306
+ logger.error("Agent SDK error:", { err });
1307
+ } finally {
1308
+ clearInterval(slackUpdateInterval);
1309
+ // Remove loading icon if still showing
1310
+ if (isThinking && quickRespondMsg.ts && quickRespondMsg.channel) {
1311
+ await slack.reactions
1312
+ .remove({
1313
+ name: "loading",
1314
+ channel: quickRespondMsg.channel,
1315
+ timestamp: quickRespondMsg.ts,
1385
1316
  })
1386
- .onFlush(() => clearInterval(id))
1387
- .run();
1388
- })
1389
-
1390
- // show contents in console if needed for debugging
1391
- // .forkTo((e) => e.pipeTo(fromWritable(process.stdout)))
1392
- // .forkTo((e) => e.pipeTo(fromWritable(process.stdout)))
1393
- .run();
1317
+ .catch(() => {});
1318
+ }
1319
+ // Send one final Slack update with complete output
1320
+ lastSlackUpdateTime = 0; // bypass throttle for final update
1321
+ await sendSlackUpdate().catch((err) => logger.error("Final Slack update error:", { err }));
1322
+ // Cancel input drain
1323
+ abortController.abort();
1324
+ }
1394
1325
 
1395
1326
  TaskInputFlows.delete(workspaceId);
1396
1327
 
1397
1328
  // Stop error collector
1398
1329
  errorCollector.stop();
1399
- if (isDebugMode) {
1400
- logger.info(`🔍 Error collector stopped for task ${workspaceId}`);
1401
- }
1402
-
1403
- // check exit code, checkmark if claude-yes exited 0, cross if not
1404
-
1405
- const exitCode = await exitCodePromise.promise;
1406
1330
 
1407
- // Update final status
1331
+ // Final status
1408
1332
  const finalStatus = exitCode === 0 ? "Completed Successfully" : `Failed (exit code ${exitCode})`;
1409
1333
  await Bun.write(
1410
1334
  statusLogPath,
1411
- `Started: ${new Date().toISOString()}\nPID: ${sh.pid}\nStatus: ${finalStatus}\nExit Code: ${exitCode}\nEnded: ${new Date().toISOString()}\nLogs: ${stdoutLogPath}\nErrors: ${errorLogPath}\n`,
1335
+ `Status: ${finalStatus}\nEnded: ${new Date().toISOString()}\nErrors: ${errorLogPath}\n`,
1412
1336
  ).catch(() => {});
1413
1337
 
1414
1338
  if (exitCode !== 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comfy-pr",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Make PRs that publishes ComfyUI Custom Nodes to [ComfyUI Registry]( https://registry.comfy.org/ ).",
5
5
  "keywords": [],
6
6
  "license": "ISC",
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "bin": {
13
13
  "comfy-pr": "./src/cli.ts",
14
+ "cpr": "./src/cli.ts",
14
15
  "pr-bot": "./bot/cli.ts",
15
16
  "prbot": "./bot/cli.ts"
16
17
  },
@@ -49,6 +50,8 @@
49
50
  "release": "npx semantic-release",
50
51
  "start": "next start",
51
52
  "test": "bun test",
53
+ "subtree:pull": "bash scripts/subtree-sync.sh pull",
54
+ "subtree:push": "bash scripts/subtree-sync.sh push",
52
55
  "test:watch": "bun test --watch",
53
56
  "vercel:build": "vercel build",
54
57
  "vercel:dev": "vercel dev"
@@ -56,6 +59,7 @@
56
59
  "dependencies": {
57
60
  "@ai-sdk/anthropic": "^3.0.23",
58
61
  "@ai-sdk/openai": "^3.0.19",
62
+ "@anthropic-ai/claude-agent-sdk": "^0.2.85",
59
63
  "@auth/mongodb-adapter": "^3.4.2",
60
64
  "@ctrl/mac-address": "^3.0.3",
61
65
  "@google/generative-ai": "^0.24.1",
package/src/cli.ts CHANGED
@@ -1,40 +1,77 @@
1
1
  #!/usr/bin/env bun
2
- import DIE from "@snomiao/die";
3
2
  import { readFile } from "fs/promises";
4
- import { argv, $ as zx } from "zx";
3
+ import { hideBin } from "yargs/helpers";
4
+ import yargs from "yargs/yargs";
5
5
  import { checkComfyActivated } from "./checkComfyActivated";
6
6
  import { createComfyRegistryPullRequests } from "./createComfyRegistryPullRequests";
7
- zx.verbose = true;
8
-
9
- if (argv.help) {
10
- console.log(
11
- `
12
- bunx comfy-pr --repolist repos.txt one repo per-line
13
- bunx comfy-pr [...GITHUB_REPO_URLS] github repos
14
- bunx cross-env REPO=https://github.com/OWNER/REPO bunx comfy-pr
15
- `.trim(),
16
- );
17
- }
18
-
19
- {
20
- await checkComfyActivated();
21
7
 
8
+ async function resolveRepos(args: {
9
+ repolist?: string;
10
+ _: (string | number)[];
11
+ }): Promise<string[]> {
22
12
  const envRepos =
23
13
  process.env.REPO?.split("\n")
24
14
  .map((e) => e.trim())
25
15
  .filter(Boolean) || [];
26
- const argvRepos = argv._.filter((a) => !a.endsWith(import.meta.filename));
16
+
17
+ const argvRepos = args._.map(String).filter(Boolean);
18
+
27
19
  const listRepos =
28
- (argv.repolist &&
29
- (await readFile(argv.repolist, "utf8").catch(() => ""))
20
+ (args.repolist &&
21
+ (await readFile(args.repolist, "utf8").catch(() => ""))
30
22
  .split("\n")
31
23
  .map((e) => e.trim())
32
24
  .filter(Boolean)) ||
33
25
  [];
34
- const repos = (listRepos.length && listRepos) ||
26
+
27
+ const repos =
28
+ (listRepos.length && listRepos) ||
35
29
  (argvRepos.length && argvRepos) ||
36
- (envRepos.length && envRepos) || [DIE("Missing PR target, please set env.REPO")];
37
- for await (const upstreamUrl of repos) {
38
- await createComfyRegistryPullRequests(upstreamUrl);
30
+ (envRepos.length && envRepos) ||
31
+ [];
32
+
33
+ if (repos.length === 0) {
34
+ console.error("Error: No repos specified. Provide URLs as args, --repolist, or REPO env var.");
35
+ process.exit(1);
39
36
  }
37
+ return repos;
40
38
  }
39
+
40
+ const cli = yargs(hideBin(process.argv))
41
+ .scriptName("cpr")
42
+ .usage("$0 <command> [options]")
43
+ .command(
44
+ "create [repos..]",
45
+ "Create registry publish PRs for ComfyUI custom nodes",
46
+ (yargs) =>
47
+ yargs
48
+ .positional("repos", {
49
+ describe: "GitHub repository URLs",
50
+ type: "string",
51
+ array: true,
52
+ })
53
+ .option("repolist", {
54
+ alias: "l",
55
+ type: "string",
56
+ describe: "File with one repo URL per line",
57
+ }),
58
+ async (args) => {
59
+ await checkComfyActivated();
60
+ const repos = await resolveRepos({ repolist: args.repolist, _: args.repos || [] });
61
+ for (const url of repos) {
62
+ await createComfyRegistryPullRequests(url);
63
+ }
64
+ },
65
+ )
66
+ .example("$0 create https://github.com/owner/repo", "Create PR for a single repo")
67
+ .example("$0 create --repolist repos.txt", "Create PRs from a file (one URL per line)")
68
+ .example("$0 create url1 url2 url3", "Create PRs for multiple repos")
69
+ .example("REPO=https://github.com/owner/repo $0 create", "Create PR via env variable")
70
+ .demandCommand(1, "Please specify a command. Run with --help to see available commands.")
71
+ .strict()
72
+ .help()
73
+ .alias("h", "help")
74
+ .version()
75
+ .alias("v", "version");
76
+
77
+ await cli.parse();