clawdbot 2026.1.4 → 2026.1.5-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +26 -1
  3. package/dist/agents/pi-embedded-runner.js +2 -0
  4. package/dist/agents/pi-embedded-subscribe.js +18 -3
  5. package/dist/agents/pi-tools.js +45 -6
  6. package/dist/agents/tools/browser-tool.js +38 -89
  7. package/dist/agents/tools/cron-tool.js +8 -8
  8. package/dist/agents/workspace.js +8 -1
  9. package/dist/auto-reply/command-detection.js +26 -0
  10. package/dist/auto-reply/reply/agent-runner.js +15 -8
  11. package/dist/auto-reply/reply/commands.js +36 -25
  12. package/dist/auto-reply/reply/directive-handling.js +4 -2
  13. package/dist/auto-reply/reply/directives.js +12 -0
  14. package/dist/auto-reply/reply/session-updates.js +2 -4
  15. package/dist/auto-reply/reply.js +26 -4
  16. package/dist/browser/config.js +22 -4
  17. package/dist/browser/profiles-service.js +3 -1
  18. package/dist/browser/profiles.js +14 -3
  19. package/dist/canvas-host/a2ui/.bundle.hash +2 -0
  20. package/dist/cli/gateway-cli.js +2 -2
  21. package/dist/cli/profile.js +81 -0
  22. package/dist/cli/program.js +10 -1
  23. package/dist/cli/run-main.js +33 -0
  24. package/dist/commands/configure.js +5 -0
  25. package/dist/commands/onboard-providers.js +1 -1
  26. package/dist/commands/setup.js +4 -1
  27. package/dist/config/defaults.js +56 -0
  28. package/dist/config/io.js +47 -6
  29. package/dist/config/paths.js +2 -2
  30. package/dist/config/port-defaults.js +32 -0
  31. package/dist/config/sessions.js +3 -2
  32. package/dist/config/validation.js +2 -2
  33. package/dist/config/zod-schema.js +16 -0
  34. package/dist/discord/monitor.js +75 -266
  35. package/dist/entry.js +16 -0
  36. package/dist/gateway/call.js +8 -1
  37. package/dist/gateway/server-methods/chat.js +1 -1
  38. package/dist/gateway/server.js +14 -3
  39. package/dist/index.js +2 -2
  40. package/dist/infra/control-ui-assets.js +118 -0
  41. package/dist/infra/dotenv.js +15 -0
  42. package/dist/infra/shell-env.js +79 -0
  43. package/dist/infra/system-events.js +50 -23
  44. package/dist/macos/relay.js +8 -2
  45. package/dist/sessions/send-policy.js +68 -0
  46. package/dist/telegram/bot.js +24 -1
  47. package/dist/utils.js +8 -2
  48. package/dist/web/auto-reply.js +18 -21
  49. package/dist/web/inbound.js +5 -1
  50. package/dist/web/qr-image.js +4 -4
  51. package/dist/web/session.js +2 -3
  52. package/docs/agent.md +0 -2
  53. package/docs/assets/markdown.css +4 -1
  54. package/docs/audio.md +0 -2
  55. package/docs/clawd.md +0 -2
  56. package/docs/configuration.md +62 -3
  57. package/docs/docs.json +9 -1
  58. package/docs/faq.md +32 -7
  59. package/docs/gateway.md +28 -0
  60. package/docs/images.md +0 -2
  61. package/docs/index.md +2 -4
  62. package/docs/mac/icon.md +1 -1
  63. package/docs/nix.md +57 -11
  64. package/docs/onboarding.md +0 -2
  65. package/docs/refactor/webagent-session.md +0 -2
  66. package/docs/research/memory.md +1 -1
  67. package/docs/skills.md +0 -2
  68. package/docs/templates/AGENTS.md +2 -2
  69. package/docs/tools.md +15 -0
  70. package/docs/whatsapp.md +2 -0
  71. package/package.json +9 -8
@@ -1,11 +1,11 @@
1
- import { ApplicationCommandOptionType, ChannelType, Client, Events, GatewayIntentBits, MessageType, Partials, } from "discord.js";
1
+ import { ChannelType, Client, Events, GatewayIntentBits, MessageType, Partials, } from "discord.js";
2
2
  import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
3
+ import { hasControlCommand } from "../auto-reply/command-detection.js";
3
4
  import { formatAgentEnvelope } from "../auto-reply/envelope.js";
4
5
  import { getReplyFromConfig } from "../auto-reply/reply.js";
5
- import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
6
6
  import { loadConfig } from "../config/config.js";
7
- import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
8
- import { danger, logVerbose, shouldLogVerbose, warn } from "../globals.js";
7
+ import { resolveSessionKey, resolveStorePath, updateLastRoute, } from "../config/sessions.js";
8
+ import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
9
9
  import { enqueueSystemEvent } from "../infra/system-events.js";
10
10
  import { getChildLogger } from "../logging.js";
11
11
  import { detectMime } from "../media/mime.js";
@@ -56,7 +56,6 @@ export async function monitorDiscordProvider(opts = {}) {
56
56
  const dmConfig = cfg.discord?.dm;
57
57
  const guildEntries = cfg.discord?.guilds;
58
58
  const allowFrom = dmConfig?.allowFrom;
59
- const slashCommand = resolveSlashCommandConfig(opts.slashCommand ?? cfg.discord?.slashCommand);
60
59
  const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
61
60
  const textLimit = resolveTextChunkLimit(cfg, "discord");
62
61
  const historyLimit = Math.max(0, opts.historyLimit ?? cfg.discord?.historyLimit ?? 20);
@@ -87,9 +86,6 @@ export async function monitorDiscordProvider(opts = {}) {
87
86
  const guildHistories = new Map();
88
87
  client.once(Events.ClientReady, () => {
89
88
  runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`);
90
- if (slashCommand.enabled) {
91
- void ensureSlashCommand(client, slashCommand, runtime);
92
- }
93
89
  });
94
90
  client.on(Events.Error, (err) => {
95
91
  runtime.error?.(danger(`client error: ${String(err)}`));
@@ -182,8 +178,24 @@ export async function monitorDiscordProvider(opts = {}) {
182
178
  guildHistories.set(message.channelId, history);
183
179
  }
184
180
  const resolvedRequireMention = channelConfig?.requireMention ?? guildInfo?.requireMention ?? true;
181
+ const hasAnyMention = Boolean(!isDirectMessage &&
182
+ (message.mentions?.everyone ||
183
+ (message.mentions?.users?.size ?? 0) > 0 ||
184
+ (message.mentions?.roles?.size ?? 0) > 0));
185
+ const commandAuthorized = resolveDiscordCommandAuthorized({
186
+ isDirectMessage,
187
+ allowFrom,
188
+ guildInfo,
189
+ author: message.author,
190
+ });
191
+ const shouldBypassMention = isGuildMessage &&
192
+ resolvedRequireMention &&
193
+ !wasMentioned &&
194
+ !hasAnyMention &&
195
+ commandAuthorized &&
196
+ hasControlCommand(baseText);
185
197
  if (isGuildMessage && resolvedRequireMention) {
186
- if (botId && !wasMentioned) {
198
+ if (botId && !wasMentioned && !shouldBypassMention) {
187
199
  logVerbose(`discord: drop guild message (mention required, botId=${botId})`);
188
200
  logger.info({
189
201
  channelId: message.channelId,
@@ -229,7 +241,18 @@ export async function monitorDiscordProvider(opts = {}) {
229
241
  }
230
242
  const systemText = resolveDiscordSystemEvent(message);
231
243
  if (systemText) {
244
+ const sessionCfg = cfg.session;
245
+ const sessionScope = sessionCfg?.scope ?? "per-sender";
246
+ const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
247
+ const sessionKey = resolveSessionKey(sessionScope, {
248
+ From: isDirectMessage
249
+ ? `discord:${message.author.id}`
250
+ : `group:${message.channelId}`,
251
+ ChatType: isDirectMessage ? "direct" : "group",
252
+ Surface: "discord",
253
+ }, mainKey);
232
254
  enqueueSystemEvent(systemText, {
255
+ sessionKey,
233
256
  contextKey: `discord:system:${message.channelId}:${message.id}`,
234
257
  });
235
258
  return;
@@ -323,11 +346,14 @@ export async function monitorDiscordProvider(opts = {}) {
323
346
  : `channel:${message.channelId}`,
324
347
  ChatType: isDirectMessage ? "direct" : "group",
325
348
  SenderName: message.member?.displayName ?? message.author.tag,
349
+ SenderId: message.author.id,
326
350
  SenderUsername: message.author.username,
327
351
  SenderTag: message.author.tag,
328
352
  GroupSubject: groupSubject,
329
353
  GroupRoom: groupRoom,
330
- GroupSpace: isGuildMessage ? guildSlug || undefined : undefined,
354
+ GroupSpace: isGuildMessage
355
+ ? (guildInfo?.id ?? guildSlug) || undefined
356
+ : undefined,
331
357
  Surface: "discord",
332
358
  WasMentioned: wasMentioned,
333
359
  MessageSid: message.id,
@@ -335,6 +361,7 @@ export async function monitorDiscordProvider(opts = {}) {
335
361
  MediaPath: media?.path,
336
362
  MediaType: media?.contentType,
337
363
  MediaUrl: media?.path,
364
+ CommandAuthorized: commandAuthorized,
338
365
  };
339
366
  const replyTarget = ctxPayload.To ?? undefined;
340
367
  if (!replyTarget) {
@@ -482,7 +509,16 @@ export async function monitorDiscordProvider(opts = {}) {
482
509
  const authorLabel = message.author?.tag ?? message.author?.username;
483
510
  const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${message.id}`;
484
511
  const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
512
+ const sessionCfg = cfg.session;
513
+ const sessionScope = sessionCfg?.scope ?? "per-sender";
514
+ const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
515
+ const sessionKey = resolveSessionKey(sessionScope, {
516
+ From: `group:${message.channelId}`,
517
+ ChatType: "group",
518
+ Surface: "discord",
519
+ }, mainKey);
485
520
  enqueueSystemEvent(text, {
521
+ sessionKey,
486
522
  contextKey: `discord:reaction:${action}:${message.id}:${user.id}:${emojiLabel}`,
487
523
  });
488
524
  }
@@ -496,162 +532,6 @@ export async function monitorDiscordProvider(opts = {}) {
496
532
  client.on(Events.MessageReactionRemove, async (reaction, user) => {
497
533
  await handleReactionEvent(reaction, user, "removed");
498
534
  });
499
- client.on(Events.InteractionCreate, async (interaction) => {
500
- try {
501
- if (!slashCommand.enabled)
502
- return;
503
- if (!interaction.isChatInputCommand())
504
- return;
505
- if (interaction.commandName !== slashCommand.name)
506
- return;
507
- if (interaction.user?.bot)
508
- return;
509
- const channelType = interaction.channel?.type;
510
- const isGroupDm = channelType === ChannelType.GroupDM;
511
- const isDirectMessage = !interaction.inGuild() && channelType === ChannelType.DM;
512
- const isGuildMessage = interaction.inGuild();
513
- if (isGroupDm && !groupDmEnabled) {
514
- logVerbose("discord: drop slash (group dms disabled)");
515
- return;
516
- }
517
- if (isDirectMessage && !dmEnabled) {
518
- logVerbose("discord: drop slash (dms disabled)");
519
- return;
520
- }
521
- if (shouldLogVerbose()) {
522
- logVerbose(`discord: slash inbound guild=${interaction.guildId ?? "dm"} channel=${interaction.channelId} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"}`);
523
- }
524
- if (isGuildMessage) {
525
- const guildInfo = resolveDiscordGuildEntry({
526
- guild: interaction.guild ?? null,
527
- guildEntries,
528
- });
529
- if (guildEntries &&
530
- Object.keys(guildEntries).length > 0 &&
531
- !guildInfo) {
532
- logVerbose(`Blocked discord guild ${interaction.guildId ?? "unknown"} (not in discord.guilds)`);
533
- return;
534
- }
535
- const channelName = interaction.channel &&
536
- "name" in interaction.channel &&
537
- typeof interaction.channel.name === "string"
538
- ? interaction.channel.name
539
- : undefined;
540
- const channelSlug = channelName
541
- ? normalizeDiscordSlug(channelName)
542
- : "";
543
- const channelConfig = resolveDiscordChannelConfig({
544
- guildInfo,
545
- channelId: interaction.channelId,
546
- channelName,
547
- channelSlug,
548
- });
549
- if (channelConfig?.allowed === false) {
550
- logVerbose(`Blocked discord channel ${interaction.channelId} not in guild channel allowlist`);
551
- return;
552
- }
553
- const userAllow = guildInfo?.users;
554
- if (Array.isArray(userAllow) && userAllow.length > 0) {
555
- const users = normalizeDiscordAllowList(userAllow, [
556
- "discord:",
557
- "user:",
558
- ]);
559
- const userOk = !users ||
560
- allowListMatches(users, {
561
- id: interaction.user.id,
562
- name: interaction.user.username,
563
- tag: interaction.user.tag,
564
- });
565
- if (!userOk) {
566
- logVerbose(`Blocked discord guild sender ${interaction.user.id} (not in guild users allowlist)`);
567
- return;
568
- }
569
- }
570
- }
571
- else if (isGroupDm) {
572
- const channelName = interaction.channel &&
573
- "name" in interaction.channel &&
574
- typeof interaction.channel.name === "string"
575
- ? interaction.channel.name
576
- : undefined;
577
- const channelSlug = channelName
578
- ? normalizeDiscordSlug(channelName)
579
- : "";
580
- const groupDmAllowed = resolveGroupDmAllow({
581
- channels: groupDmChannels,
582
- channelId: interaction.channelId,
583
- channelName,
584
- channelSlug,
585
- });
586
- if (!groupDmAllowed)
587
- return;
588
- }
589
- else if (isDirectMessage) {
590
- if (Array.isArray(allowFrom) && allowFrom.length > 0) {
591
- const allowList = normalizeDiscordAllowList(allowFrom, [
592
- "discord:",
593
- "user:",
594
- ]);
595
- const permitted = allowList &&
596
- allowListMatches(allowList, {
597
- id: interaction.user.id,
598
- name: interaction.user.username,
599
- tag: interaction.user.tag,
600
- });
601
- if (!permitted) {
602
- logVerbose(`Blocked unauthorized discord sender ${interaction.user.id} (not in allowFrom)`);
603
- return;
604
- }
605
- }
606
- }
607
- const prompt = resolveSlashPrompt(interaction.options.data);
608
- if (!prompt) {
609
- await interaction.reply({
610
- content: "Message required.",
611
- ephemeral: true,
612
- });
613
- return;
614
- }
615
- await interaction.deferReply({ ephemeral: slashCommand.ephemeral });
616
- const userId = interaction.user.id;
617
- const ctxPayload = {
618
- Body: prompt,
619
- From: `discord:${userId}`,
620
- To: `slash:${userId}`,
621
- ChatType: "direct",
622
- SenderName: interaction.user.username,
623
- Surface: "discord",
624
- WasMentioned: true,
625
- MessageSid: interaction.id,
626
- Timestamp: interaction.createdTimestamp,
627
- SessionKey: `${slashCommand.sessionPrefix}:${userId}`,
628
- };
629
- const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg);
630
- const replies = replyResult
631
- ? Array.isArray(replyResult)
632
- ? replyResult
633
- : [replyResult]
634
- : [];
635
- await deliverSlashReplies({
636
- replies,
637
- interaction,
638
- ephemeral: slashCommand.ephemeral,
639
- textLimit,
640
- });
641
- }
642
- catch (err) {
643
- runtime.error?.(danger(`slash handler failed: ${String(err)}`));
644
- if (interaction.isRepliable()) {
645
- const content = "Sorry, something went wrong handling that command.";
646
- if (interaction.deferred || interaction.replied) {
647
- await interaction.followUp({ content, ephemeral: true });
648
- }
649
- else {
650
- await interaction.reply({ content, ephemeral: true });
651
- }
652
- }
653
- }
654
- });
655
535
  await client.login(token);
656
536
  await new Promise((resolve, reject) => {
657
537
  const onAbort = () => {
@@ -914,6 +794,35 @@ export function allowListMatches(allowList, candidates) {
914
794
  return true;
915
795
  return false;
916
796
  }
797
+ function resolveDiscordCommandAuthorized(params) {
798
+ const { isDirectMessage, allowFrom, guildInfo, author } = params;
799
+ if (isDirectMessage) {
800
+ if (!Array.isArray(allowFrom) || allowFrom.length === 0)
801
+ return true;
802
+ const allowList = normalizeDiscordAllowList(allowFrom, [
803
+ "discord:",
804
+ "user:",
805
+ ]);
806
+ if (!allowList)
807
+ return true;
808
+ return allowListMatches(allowList, {
809
+ id: author.id,
810
+ name: author.username,
811
+ tag: author.tag,
812
+ });
813
+ }
814
+ const users = guildInfo?.users;
815
+ if (!Array.isArray(users) || users.length === 0)
816
+ return true;
817
+ const allowList = normalizeDiscordAllowList(users, ["discord:", "user:"]);
818
+ if (!allowList)
819
+ return true;
820
+ return allowListMatches(allowList, {
821
+ id: author.id,
822
+ name: author.username,
823
+ tag: author.tag,
824
+ });
825
+ }
917
826
  export function shouldEmitDiscordReactionNotification(params) {
918
827
  const { mode, botId, messageAuthorId, userId, userName, userTag, allowlist } = params;
919
828
  const effectiveMode = mode ?? "own";
@@ -1027,75 +936,6 @@ export function resolveGroupDmAllow(params) {
1027
936
  name: channelSlug || channelName,
1028
937
  });
1029
938
  }
1030
- async function ensureSlashCommand(client, slashCommand, runtime) {
1031
- try {
1032
- const appCommands = client.application?.commands;
1033
- if (!appCommands) {
1034
- runtime.error?.(danger("discord slash commands unavailable"));
1035
- return;
1036
- }
1037
- const existing = await appCommands.fetch();
1038
- const hasCommand = Array.from(existing.values()).some((entry) => entry.name === slashCommand.name);
1039
- if (hasCommand)
1040
- return;
1041
- await appCommands.create({
1042
- name: slashCommand.name,
1043
- description: "Ask Clawdbot a question",
1044
- options: [
1045
- {
1046
- name: "prompt",
1047
- description: "What should Clawdbot help with?",
1048
- type: ApplicationCommandOptionType.String,
1049
- required: true,
1050
- },
1051
- ],
1052
- });
1053
- runtime.log?.(`registered discord slash command /${slashCommand.name}`);
1054
- }
1055
- catch (err) {
1056
- const status = err?.status;
1057
- const code = err?.code;
1058
- const message = String(err);
1059
- const isRateLimit = status === 429 || code === 429 || /rate ?limit/i.test(message);
1060
- const text = `discord slash command setup failed: ${message}`;
1061
- if (isRateLimit) {
1062
- logVerbose(text);
1063
- runtime.error?.(warn(text));
1064
- }
1065
- else {
1066
- runtime.error?.(danger(text));
1067
- }
1068
- }
1069
- }
1070
- function resolveSlashCommandConfig(raw) {
1071
- return {
1072
- enabled: raw ? raw.enabled !== false : false,
1073
- name: raw?.name?.trim() || "clawd",
1074
- sessionPrefix: raw?.sessionPrefix?.trim() || "discord:slash",
1075
- ephemeral: raw?.ephemeral !== false,
1076
- };
1077
- }
1078
- function resolveSlashPrompt(options) {
1079
- const direct = findFirstStringOption(options);
1080
- if (direct)
1081
- return direct;
1082
- return undefined;
1083
- }
1084
- function findFirstStringOption(options) {
1085
- for (const option of options) {
1086
- if (typeof option.value === "string") {
1087
- const trimmed = option.value.trim();
1088
- if (trimmed)
1089
- return trimmed;
1090
- }
1091
- if (option.options && option.options.length > 0) {
1092
- const nested = findFirstStringOption(option.options);
1093
- if (nested)
1094
- return nested;
1095
- }
1096
- }
1097
- return undefined;
1098
- }
1099
939
  async function sendTyping(message) {
1100
940
  try {
1101
941
  const channel = message.channel;
@@ -1155,34 +995,3 @@ async function deliverReplies({ replies, target, token, runtime, replyToMode, te
1155
995
  runtime.log?.(`delivered reply to ${target}`);
1156
996
  }
1157
997
  }
1158
- async function deliverSlashReplies({ replies, interaction, ephemeral, textLimit, }) {
1159
- const messages = [];
1160
- const chunkLimit = Math.min(textLimit, 2000);
1161
- for (const payload of replies) {
1162
- const textRaw = payload.text?.trim() ?? "";
1163
- const text = textRaw && textRaw !== SILENT_REPLY_TOKEN ? textRaw : undefined;
1164
- const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
1165
- const combined = [
1166
- text ?? "",
1167
- ...mediaList.map((url) => url.trim()).filter(Boolean),
1168
- ]
1169
- .filter(Boolean)
1170
- .join("\n");
1171
- if (!combined)
1172
- continue;
1173
- for (const chunk of chunkText(combined, chunkLimit)) {
1174
- messages.push(chunk);
1175
- }
1176
- }
1177
- if (messages.length === 0) {
1178
- await interaction.editReply({
1179
- content: "No response was generated for that command.",
1180
- });
1181
- return;
1182
- }
1183
- const [first, ...rest] = messages;
1184
- await interaction.editReply({ content: first });
1185
- for (const message of rest) {
1186
- await interaction.followUp({ content: message, ephemeral });
1187
- }
1188
- }
package/dist/entry.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import process from "node:process";
3
+ import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";
4
+ const parsed = parseCliProfileArgs(process.argv);
5
+ if (!parsed.ok) {
6
+ // Keep it simple; Commander will handle rich help/errors after we strip flags.
7
+ console.error(`[clawdbot] ${parsed.error}`);
8
+ process.exit(2);
9
+ }
10
+ if (parsed.profile) {
11
+ applyCliProfileEnv({ profile: parsed.profile });
12
+ // Keep Commander and ad-hoc argv checks consistent.
13
+ process.argv = parsed.argv;
14
+ }
15
+ const { runCli } = await import("./cli/run-main.js");
16
+ await runCli(process.argv);
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { loadConfig, resolveGatewayPort } from "../config/config.js";
3
+ import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
3
4
  import { GatewayClient } from "./client.js";
4
5
  import { PROTOCOL_VERSION } from "./protocol/index.js";
5
6
  export async function callGateway(opts) {
@@ -9,13 +10,19 @@ export async function callGateway(opts) {
9
10
  const remote = isRemoteMode ? config.gateway?.remote : undefined;
10
11
  const authToken = config.gateway?.auth?.token;
11
12
  const localPort = resolveGatewayPort(config);
13
+ const tailnetIPv4 = pickPrimaryTailnetIPv4();
14
+ const bindMode = config.gateway?.bind ?? "loopback";
15
+ const preferTailnet = bindMode === "tailnet" || (bindMode === "auto" && !!tailnetIPv4);
16
+ const localUrl = preferTailnet && tailnetIPv4
17
+ ? `ws://${tailnetIPv4}:${localPort}`
18
+ : `ws://127.0.0.1:${localPort}`;
12
19
  const url = (typeof opts.url === "string" && opts.url.trim().length > 0
13
20
  ? opts.url.trim()
14
21
  : undefined) ||
15
22
  (typeof remote?.url === "string" && remote.url.trim().length > 0
16
23
  ? remote.url.trim()
17
24
  : undefined) ||
18
- `ws://127.0.0.1:${localPort}`;
25
+ localUrl;
19
26
  const token = (typeof opts.token === "string" && opts.token.trim().length > 0
20
27
  ? opts.token.trim()
21
28
  : undefined) ||
@@ -79,7 +79,7 @@ export const chatHandlers = {
79
79
  context.bridgeSendToSession(sessionKey, "chat", payload);
80
80
  respond(true, { ok: true, aborted: true });
81
81
  },
82
- "chat.send": async ({ params, respond, context, client: _client, isWebchatConnect: _isWebchatConnect, }) => {
82
+ "chat.send": async ({ params, respond, context }) => {
83
83
  if (!validateChatSendParams(params)) {
84
84
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid chat.send params: ${formatValidationErrors(validateChatSendParams.errors)}`));
85
85
  return;
@@ -10,6 +10,7 @@ import { createCanvasHostHandler, startCanvasHost, } from "../canvas-host/server
10
10
  import { createDefaultDeps } from "../cli/deps.js";
11
11
  import { getHealthSnapshot } from "../commands/health.js";
12
12
  import { CONFIG_PATH_CLAWDBOT, isNixMode, loadConfig, migrateLegacyConfig, readConfigFileSnapshot, STATE_DIR_CLAWDBOT, writeConfigFile, } from "../config/config.js";
13
+ import { deriveDefaultBridgePort, deriveDefaultCanvasHostPort, } from "../config/port-defaults.js";
13
14
  import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
14
15
  import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js";
15
16
  import { appendCronRunLog, resolveCronRunLogPath } from "../cron/run-log.js";
@@ -195,6 +196,8 @@ async function refreshHealthSnapshot(_opts) {
195
196
  return healthRefresh;
196
197
  }
197
198
  export async function startGatewayServer(port = 18789, opts = {}) {
199
+ // Ensure all default port derivations (browser/bridge/canvas) see the actual runtime port.
200
+ process.env.CLAWDBOT_GATEWAY_PORT = String(port);
198
201
  const configSnapshot = await readConfigFileSnapshot();
199
202
  if (configSnapshot.legacyIssues.length > 0) {
200
203
  if (isNixMode) {
@@ -559,9 +562,11 @@ export async function startGatewayServer(port = 18789, opts = {}) {
559
562
  }
560
563
  if (process.env.CLAWDBOT_BRIDGE_PORT !== undefined) {
561
564
  const parsed = Number.parseInt(process.env.CLAWDBOT_BRIDGE_PORT, 10);
562
- return Number.isFinite(parsed) && parsed > 0 ? parsed : 18790;
565
+ return Number.isFinite(parsed) && parsed > 0
566
+ ? parsed
567
+ : deriveDefaultBridgePort(port);
563
568
  }
564
- return 18790;
569
+ return deriveDefaultBridgePort(port);
565
570
  })();
566
571
  const bridgeHost = (() => {
567
572
  // Back-compat: allow an env var override when no bind policy is configured.
@@ -586,10 +591,16 @@ export async function startGatewayServer(port = 18789, opts = {}) {
586
591
  return "0.0.0.0";
587
592
  })();
588
593
  const canvasHostPort = (() => {
594
+ if (process.env.CLAWDBOT_CANVAS_HOST_PORT !== undefined) {
595
+ const parsed = Number.parseInt(process.env.CLAWDBOT_CANVAS_HOST_PORT, 10);
596
+ if (Number.isFinite(parsed) && parsed > 0)
597
+ return parsed;
598
+ return deriveDefaultCanvasHostPort(port);
599
+ }
589
600
  const configured = cfgAtStart.canvasHost?.port;
590
601
  if (typeof configured === "number" && configured > 0)
591
602
  return configured;
592
- return 18793;
603
+ return deriveDefaultCanvasHostPort(port);
593
604
  })();
594
605
  if (canvasHostEnabled && bridgeEnabled && bridgeHost) {
595
606
  try {
package/dist/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import process from "node:process";
3
3
  import { fileURLToPath } from "node:url";
4
- import dotenv from "dotenv";
5
4
  import { getReplyFromConfig } from "./auto-reply/reply.js";
6
5
  import { applyTemplate } from "./auto-reply/templating.js";
7
6
  import { createDefaultDeps } from "./cli/deps.js";
@@ -10,6 +9,7 @@ import { waitForever } from "./cli/wait.js";
10
9
  import { loadConfig } from "./config/config.js";
11
10
  import { deriveSessionKey, loadSessionStore, resolveSessionKey, resolveStorePath, saveSessionStore, } from "./config/sessions.js";
12
11
  import { ensureBinary } from "./infra/binaries.js";
12
+ import { loadDotEnv } from "./infra/dotenv.js";
13
13
  import { normalizeEnv } from "./infra/env.js";
14
14
  import { isMainModule } from "./infra/is-main.js";
15
15
  import { ensureClawdbotCliOnPath } from "./infra/path-env.js";
@@ -19,7 +19,7 @@ import { enableConsoleCapture } from "./logging.js";
19
19
  import { runCommandWithTimeout, runExec } from "./process/exec.js";
20
20
  import { monitorWebProvider } from "./provider-web.js";
21
21
  import { assertProvider, normalizeE164, toWhatsappJid } from "./utils.js";
22
- dotenv.config({ quiet: true });
22
+ loadDotEnv({ quiet: true });
23
23
  normalizeEnv();
24
24
  ensureClawdbotCliOnPath();
25
25
  // Capture all console output into structured logs while keeping stdout/stderr behavior.
@@ -0,0 +1,118 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { runCommandWithTimeout, runExec } from "../process/exec.js";
4
+ import { defaultRuntime } from "../runtime.js";
5
+ export function resolveControlUiRepoRoot(argv1 = process.argv[1]) {
6
+ if (!argv1)
7
+ return null;
8
+ const normalized = path.resolve(argv1);
9
+ const parts = normalized.split(path.sep);
10
+ const srcIndex = parts.lastIndexOf("src");
11
+ if (srcIndex !== -1) {
12
+ const root = parts.slice(0, srcIndex).join(path.sep);
13
+ if (fs.existsSync(path.join(root, "ui", "vite.config.ts")))
14
+ return root;
15
+ }
16
+ let dir = path.dirname(normalized);
17
+ for (let i = 0; i < 8; i++) {
18
+ if (fs.existsSync(path.join(dir, "package.json")) &&
19
+ fs.existsSync(path.join(dir, "ui", "vite.config.ts"))) {
20
+ return dir;
21
+ }
22
+ const parent = path.dirname(dir);
23
+ if (parent === dir)
24
+ break;
25
+ dir = parent;
26
+ }
27
+ return null;
28
+ }
29
+ export function resolveControlUiDistIndexPath(argv1 = process.argv[1]) {
30
+ if (!argv1)
31
+ return null;
32
+ const normalized = path.resolve(argv1);
33
+ const distDir = path.dirname(normalized);
34
+ if (path.basename(distDir) !== "dist")
35
+ return null;
36
+ return path.join(distDir, "control-ui", "index.html");
37
+ }
38
+ function summarizeCommandOutput(text) {
39
+ const lines = text
40
+ .split(/\r?\n/g)
41
+ .map((l) => l.trim())
42
+ .filter(Boolean);
43
+ if (!lines.length)
44
+ return undefined;
45
+ const last = lines.at(-1);
46
+ if (!last)
47
+ return undefined;
48
+ return last.length > 240 ? `${last.slice(0, 239)}…` : last;
49
+ }
50
+ export async function ensureControlUiAssetsBuilt(runtime = defaultRuntime, opts) {
51
+ const indexFromDist = resolveControlUiDistIndexPath(process.argv[1]);
52
+ if (indexFromDist && fs.existsSync(indexFromDist)) {
53
+ return { ok: true, built: false };
54
+ }
55
+ const repoRoot = resolveControlUiRepoRoot(process.argv[1]);
56
+ if (!repoRoot) {
57
+ const hint = indexFromDist
58
+ ? `Missing Control UI assets at ${indexFromDist}`
59
+ : "Missing Control UI assets";
60
+ return {
61
+ ok: false,
62
+ built: false,
63
+ message: `${hint}. Build them with \`pnpm ui:build\`.`,
64
+ };
65
+ }
66
+ const indexPath = path.join(repoRoot, "dist", "control-ui", "index.html");
67
+ if (fs.existsSync(indexPath)) {
68
+ return { ok: true, built: false };
69
+ }
70
+ const pnpmWhich = process.platform === "win32" ? "where" : "which";
71
+ const pnpm = await runExec(pnpmWhich, ["pnpm"])
72
+ .then((r) => r.stdout
73
+ .split(/\r?\n/g)
74
+ .map((l) => l.trim())
75
+ .find(Boolean) ?? "")
76
+ .catch(() => "");
77
+ if (!pnpm) {
78
+ return {
79
+ ok: false,
80
+ built: false,
81
+ message: "Control UI assets not found and pnpm missing. Install pnpm, then run `pnpm ui:build`.",
82
+ };
83
+ }
84
+ runtime.log("Control UI assets missing; building (pnpm ui:build)…");
85
+ const ensureInstalled = !fs.existsSync(path.join(repoRoot, "ui", "node_modules"));
86
+ if (ensureInstalled) {
87
+ const install = await runCommandWithTimeout([pnpm, "ui:install"], {
88
+ cwd: repoRoot,
89
+ timeoutMs: opts?.timeoutMs ?? 10 * 60_000,
90
+ });
91
+ if (install.code !== 0) {
92
+ return {
93
+ ok: false,
94
+ built: false,
95
+ message: `Control UI install failed: ${summarizeCommandOutput(install.stderr) ?? `exit ${install.code}`}`,
96
+ };
97
+ }
98
+ }
99
+ const build = await runCommandWithTimeout([pnpm, "ui:build"], {
100
+ cwd: repoRoot,
101
+ timeoutMs: opts?.timeoutMs ?? 10 * 60_000,
102
+ });
103
+ if (build.code !== 0) {
104
+ return {
105
+ ok: false,
106
+ built: false,
107
+ message: `Control UI build failed: ${summarizeCommandOutput(build.stderr) ?? `exit ${build.code}`}`,
108
+ };
109
+ }
110
+ if (!fs.existsSync(indexPath)) {
111
+ return {
112
+ ok: false,
113
+ built: true,
114
+ message: `Control UI build completed but ${indexPath} is still missing.`,
115
+ };
116
+ }
117
+ return { ok: true, built: true };
118
+ }