@chrysb/alphaclaw 0.5.3 → 0.5.4-beta.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.
Files changed (35) hide show
  1. package/lib/public/js/components/agent-send-modal.js +11 -25
  2. package/lib/public/js/components/doctor/index.js +6 -5
  3. package/lib/public/js/components/file-tree.js +26 -1
  4. package/lib/public/js/components/file-viewer/constants.js +2 -0
  5. package/lib/public/js/components/file-viewer/index.js +1 -0
  6. package/lib/public/js/components/file-viewer/markdown-split-view.js +2 -1
  7. package/lib/public/js/components/file-viewer/use-file-viewer.js +24 -6
  8. package/lib/public/js/components/file-viewer/utils.js +19 -0
  9. package/lib/public/js/components/google/gmail-setup-wizard.js +117 -50
  10. package/lib/public/js/components/google/index.js +45 -44
  11. package/lib/public/js/components/google/use-gmail-watch.js +2 -2
  12. package/lib/public/js/components/icons.js +13 -0
  13. package/lib/public/js/components/models-tab/index.js +5 -3
  14. package/lib/public/js/components/models-tab/provider-auth-card.js +1 -1
  15. package/lib/public/js/components/onboarding/welcome-form-step.js +9 -1
  16. package/lib/public/js/components/session-select-field.js +72 -0
  17. package/lib/public/js/components/webhooks.js +114 -44
  18. package/lib/public/js/components/welcome/use-welcome.js +41 -20
  19. package/lib/public/js/hooks/use-destination-session-selection.js +85 -0
  20. package/lib/public/js/hooks/useAgentSessions.js +14 -0
  21. package/lib/public/js/lib/api.js +10 -4
  22. package/lib/public/js/lib/clipboard.js +40 -0
  23. package/lib/public/js/lib/model-config.js +2 -2
  24. package/lib/server/auth-profiles.js +3 -0
  25. package/lib/server/constants.js +2 -2
  26. package/lib/server/db/usage/pricing.js +1 -1
  27. package/lib/server/doctor/prompt.js +32 -10
  28. package/lib/server/gmail-watch.js +49 -22
  29. package/lib/server/onboarding/github.js +1 -1
  30. package/lib/server/routes/gmail.js +5 -1
  31. package/lib/server/routes/models.js +84 -1
  32. package/lib/server/routes/system.js +28 -26
  33. package/lib/server/routes/webhooks.js +2 -2
  34. package/lib/server/webhooks.js +32 -4
  35. package/package.json +1 -1
@@ -0,0 +1,85 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
2
+ import { useAgentSessions } from "./useAgentSessions.js";
3
+
4
+ export const kNoDestinationSessionValue = "__none__";
5
+
6
+ export const kDestinationSessionFilter = (sessionRow) => {
7
+ const key = String(sessionRow?.key || "").toLowerCase();
8
+ return key.includes(":direct:") || key.includes(":group:");
9
+ };
10
+
11
+ export const getDestinationFromSession = (sessionRow = null) => {
12
+ const channel = String(sessionRow?.replyChannel || "").trim();
13
+ const to = String(sessionRow?.replyTo || "").trim();
14
+ if (!channel || !to) return null;
15
+ return { channel, to };
16
+ };
17
+
18
+ export const useDestinationSessionSelection = ({
19
+ enabled = false,
20
+ resetKey = "",
21
+ } = {}) => {
22
+ const [manualSessionKey, setManualSessionKey] = useState("");
23
+ const [hasManualSelection, setHasManualSelection] = useState(false);
24
+ const {
25
+ sessions,
26
+ selectedSessionKey,
27
+ setSelectedSessionKey,
28
+ loading,
29
+ error,
30
+ } = useAgentSessions({
31
+ enabled,
32
+ filter: kDestinationSessionFilter,
33
+ });
34
+
35
+ useEffect(() => {
36
+ if (!enabled) return;
37
+ setManualSessionKey("");
38
+ setHasManualSelection(false);
39
+ }, [enabled, resetKey]);
40
+
41
+ const preferredSessionKey = useMemo(() => {
42
+ const matchingPreferredSession = sessions.find(
43
+ (sessionRow) =>
44
+ String(sessionRow?.key || "") === String(selectedSessionKey || "").trim(),
45
+ );
46
+ return String(
47
+ matchingPreferredSession?.key || sessions[0]?.key || "",
48
+ ).trim();
49
+ }, [sessions, selectedSessionKey]);
50
+
51
+ const effectiveSessionKey = hasManualSelection
52
+ ? manualSessionKey
53
+ : preferredSessionKey;
54
+
55
+ const selectedSession = useMemo(
56
+ () =>
57
+ sessions.find(
58
+ (sessionRow) =>
59
+ String(sessionRow?.key || "") === String(effectiveSessionKey || "").trim(),
60
+ ) || null,
61
+ [effectiveSessionKey, sessions],
62
+ );
63
+
64
+ const selectedDestination = useMemo(
65
+ () => getDestinationFromSession(selectedSession),
66
+ [selectedSession],
67
+ );
68
+
69
+ const setDestinationSessionKey = useCallback((key) => {
70
+ const normalizedKey = String(key || "");
71
+ setManualSessionKey(normalizedKey);
72
+ setHasManualSelection(true);
73
+ setSelectedSessionKey(normalizedKey);
74
+ }, [setSelectedSessionKey]);
75
+
76
+ return {
77
+ sessions,
78
+ loading,
79
+ error,
80
+ destinationSessionKey: effectiveSessionKey,
81
+ setDestinationSessionKey,
82
+ selectedDestinationSession: selectedSession,
83
+ selectedDestination,
84
+ };
85
+ };
@@ -119,6 +119,20 @@ export const useAgentSessions = ({ enabled = false, filter } = {}) => {
119
119
  [allSessions, filter],
120
120
  );
121
121
 
122
+ useEffect(() => {
123
+ if (!enabled) return;
124
+ if (sessions.length === 0) {
125
+ if (selectedSessionKey) setSelectedSessionKeyState("");
126
+ return;
127
+ }
128
+ const hasSelectedSession = sessions.some(
129
+ (row) => String(row?.key || "") === String(selectedSessionKey || ""),
130
+ );
131
+ if (hasSelectedSession) return;
132
+ const preferred = pickPreferredSession(sessions, readLastSessionKey());
133
+ setSelectedSessionKeyState(String(preferred?.key || ""));
134
+ }, [enabled, sessions, selectedSessionKey]);
135
+
122
136
  const selectedSession = useMemo(
123
137
  () => sessions.find((row) => String(row?.key || "") === selectedSessionKey) || null,
124
138
  [sessions, selectedSessionKey],
@@ -163,11 +163,14 @@ export const saveGmailConfig = async ({
163
163
  return parseJsonOrThrow(res, "Could not save Gmail watch config");
164
164
  };
165
165
 
166
- export const startGmailWatch = async (accountId) => {
166
+ export const startGmailWatch = async (accountId, { destination = null } = {}) => {
167
167
  const res = await authFetch("/api/gmail/watch/start", {
168
168
  method: "POST",
169
169
  headers: { "Content-Type": "application/json" },
170
- body: JSON.stringify({ accountId: String(accountId || "") }),
170
+ body: JSON.stringify({
171
+ accountId: String(accountId || ""),
172
+ ...(destination ? { destination } : {}),
173
+ }),
171
174
  });
172
175
  return parseJsonOrThrow(res, "Could not start Gmail watch");
173
176
  };
@@ -655,11 +658,14 @@ export async function fetchWebhookDetail(name) {
655
658
  return parseJsonOrThrow(res, "Could not load webhook detail");
656
659
  }
657
660
 
658
- export async function createWebhook(name) {
661
+ export async function createWebhook(name, { destination = null } = {}) {
659
662
  const res = await authFetch("/api/webhooks", {
660
663
  method: "POST",
661
664
  headers: { "Content-Type": "application/json" },
662
- body: JSON.stringify({ name }),
665
+ body: JSON.stringify({
666
+ name,
667
+ ...(destination ? { destination } : {}),
668
+ }),
663
669
  });
664
670
  return parseJsonOrThrow(res, "Could not create webhook");
665
671
  }
@@ -0,0 +1,40 @@
1
+ export const copyTextToClipboard = async (value) => {
2
+ const text = String(value || "");
3
+ if (!text) return false;
4
+
5
+ try {
6
+ if (navigator?.clipboard?.writeText) {
7
+ await navigator.clipboard.writeText(text);
8
+ return true;
9
+ }
10
+ } catch {}
11
+
12
+ let fallbackElement = null;
13
+ let appendedFallbackElement = false;
14
+ try {
15
+ if (
16
+ !document?.createElement ||
17
+ !document?.body?.appendChild ||
18
+ !document?.body?.removeChild ||
19
+ typeof document.execCommand !== "function"
20
+ ) {
21
+ return false;
22
+ }
23
+
24
+ fallbackElement = document.createElement("textarea");
25
+ fallbackElement.value = text;
26
+ fallbackElement.setAttribute("readonly", "");
27
+ fallbackElement.style.position = "fixed";
28
+ fallbackElement.style.opacity = "0";
29
+ document.body.appendChild(fallbackElement);
30
+ appendedFallbackElement = true;
31
+ fallbackElement.select();
32
+ return document.execCommand("copy");
33
+ } catch {
34
+ return false;
35
+ } finally {
36
+ if (fallbackElement && appendedFallbackElement) {
37
+ document.body.removeChild(fallbackElement);
38
+ }
39
+ }
40
+ };
@@ -22,8 +22,8 @@ export const kFeaturedModelDefs = [
22
22
  preferredKeys: ["openai-codex/gpt-5.3-codex", "openai-codex/gpt-5.2-codex"],
23
23
  },
24
24
  {
25
- label: "Gemini 3",
26
- preferredKeys: ["google/gemini-3-pro-preview", "google/gemini-3-flash-preview"],
25
+ label: "Gemini 3.1 Pro",
26
+ preferredKeys: ["google/gemini-3.1-pro-preview", "google/gemini-3-flash-preview"],
27
27
  },
28
28
  ];
29
29
 
@@ -41,6 +41,8 @@ const credentialMode = (credential) => {
41
41
  const getEnvVarForApiKeyProvider = (provider) =>
42
42
  kApiKeyEnvVarByProvider[String(provider || "").trim()] || "";
43
43
 
44
+ const listApiKeyProviders = () => Object.keys(kApiKeyEnvVarByProvider);
45
+
44
46
  const getDefaultProfileIdForApiKeyProvider = (provider) => {
45
47
  const normalized = String(provider || "").trim();
46
48
  return normalized ? `${normalized}:default` : "";
@@ -339,6 +341,7 @@ const createAuthProfiles = () => {
339
341
  upsertApiKeyProfileForEnvVar,
340
342
  removeApiKeyProfileForEnvVar,
341
343
  getEnvVarForApiKeyProvider,
344
+ listApiKeyProviders,
342
345
  getDefaultProfileIdForApiKeyProvider,
343
346
  getModelConfig,
344
347
  setModelConfig,
@@ -118,9 +118,9 @@ const kFallbackOnboardingModels = [
118
118
  label: "OpenAI GPT-5.1 Codex",
119
119
  },
120
120
  {
121
- key: "google/gemini-3-pro-preview",
121
+ key: "google/gemini-3.1-pro-preview",
122
122
  provider: "google",
123
- label: "Gemini 3 Pro Preview",
123
+ label: "Gemini 3.1 Pro",
124
124
  },
125
125
  {
126
126
  key: "google/gemini-3-flash-preview",
@@ -13,7 +13,7 @@ const kGlobalModelPricing = {
13
13
  "gpt-4.1": { input: 2.0, output: 8.0 },
14
14
  "gpt-4o": { input: 2.5, output: 10.0 },
15
15
  "gpt-4o-mini": { input: 0.15, output: 0.6 },
16
- "gemini-3-pro-preview": { input: 2.0, output: 12.0 },
16
+ "gemini-3.1-pro-preview": { input: 2.0, output: 12.0 },
17
17
  "gemini-3-flash-preview": { input: 0.5, output: 3.0 },
18
18
  "gemini-2.0-flash": { input: 0.1, output: 0.4 },
19
19
  };
@@ -12,17 +12,37 @@ const renderList = (items = []) =>
12
12
  const renderContextFileList = (files = []) =>
13
13
  files.map((file) => `\`${file.path}\``).join(", ");
14
14
 
15
- const renderResolvedCards = (cards = []) => {
15
+ const renderHistoricalCards = (cards = []) => {
16
16
  if (!cards.length) return "";
17
- const lines = cards.map(
18
- (card) =>
19
- `- [${card.status}] ${card.title}` +
20
- (card.category ? ` (${card.category})` : ""),
21
- );
17
+ const dismissedLines = cards
18
+ .filter((card) => card?.status === "dismissed")
19
+ .map(
20
+ (card) =>
21
+ `- [${card.status}] ${card.title}` +
22
+ (card.category ? ` (${card.category})` : ""),
23
+ );
24
+ const fixedLines = cards
25
+ .filter((card) => card?.status === "fixed")
26
+ .map(
27
+ (card) =>
28
+ `- [${card.status}] ${card.title}` +
29
+ (card.category ? ` (${card.category})` : ""),
30
+ );
31
+ const sections = [];
32
+ if (dismissedLines.length) {
33
+ sections.push(
34
+ `Previously dismissed findings (do not re-suggest these):\n${dismissedLines.join("\n")}`,
35
+ );
36
+ }
37
+ if (fixedLines.length) {
38
+ sections.push(
39
+ `Previou findings marked as fixed (context only; re-suggest them if they are still present):\n${fixedLines.join("\n")}`,
40
+ );
41
+ }
42
+ if (!sections.length) return "";
22
43
  return `
23
44
 
24
- Previously resolved findings (do not re-suggest these):
25
- ${lines.join("\n")}
45
+ ${sections.join("\n\n")}
26
46
  `;
27
47
  };
28
48
 
@@ -115,13 +135,15 @@ Return exactly this JSON shape:
115
135
  ]
116
136
  }
117
137
 
118
- ${renderResolvedCards(resolvedCards)}Constraints:
138
+ ${renderHistoricalCards(resolvedCards)}Constraints:
119
139
  - Maximum 12 cards
120
140
  - Use relative paths in evidence and targetPaths
121
141
  - Include startLine (and optionally endLine) in evidence and targetPaths when the finding relates to a specific section of a file
122
142
  - targetPaths items can be strings or objects with { path, startLine? }
123
143
  - Do not include duplicate cards
124
- - Do not re-suggest findings that appear in the "Previously resolved" list above
144
+ - Do not re-suggest findings that appear in the "Previously dismissed" list above
145
+ - Previously fixed findings may be re-suggested if the underlying issue is still present
146
+ - If a previously fixed finding is still present, you may call that out in the summary or card wording
125
147
  - Do not create cards for healthy default-template behavior
126
148
  - Do not create cards whose primary recommendation is to refactor AlphaClaw-managed file structure
127
149
  - fixPrompt must only reference files the agent can edit. Never suggest editing files listed in "AlphaClaw locked/managed paths" above — they are managed by AlphaClaw, so manual edits would be lost.
@@ -61,6 +61,47 @@ const parseProjectIdFromTopicPath = (topicPath = "") => {
61
61
  return match?.[1] ? String(match[1]) : "";
62
62
  };
63
63
 
64
+ const normalizeDestination = (destination = null) => {
65
+ if (!destination || typeof destination !== "object") return null;
66
+ const channel = String(destination?.channel || "").trim();
67
+ const to = String(destination?.to || "").trim();
68
+ if (!channel && !to) return null;
69
+ if (!channel || !to) {
70
+ throw new Error("destination.channel and destination.to are required");
71
+ }
72
+ return { channel, to };
73
+ };
74
+
75
+ const buildGmailTransformSource = (destination = null) => {
76
+ const normalizedDestination = normalizeDestination(destination);
77
+ return [
78
+ "export default async function transform(payload) {",
79
+ " const data = payload?.payload || payload || {};",
80
+ " const messages = Array.isArray(data.messages) ? data.messages : [];",
81
+ " const first = messages[0] || {};",
82
+ " const from = String(first.from || \"unknown sender\").trim();",
83
+ " const subject = String(first.subject || \"(no subject)\").trim();",
84
+ " const snippet = String(first.snippet || \"\").trim();",
85
+ " return {",
86
+ " message: `New email from ${from}\\nSubject: ${subject}\\n${snippet}`.trim(),",
87
+ " messages,",
88
+ ' name: "Gmail",',
89
+ ' wakeMode: "now",',
90
+ ...(normalizedDestination
91
+ ? [
92
+ ` channel: ${JSON.stringify(normalizedDestination.channel)},`,
93
+ ` to: ${JSON.stringify(normalizedDestination.to)},`,
94
+ ]
95
+ : []),
96
+ " };",
97
+ "}",
98
+ "",
99
+ ].join("\n");
100
+ };
101
+
102
+ const getGmailTransformAbsolutePath = (constants) =>
103
+ path.join(constants.OPENCLAW_DIR, "hooks/transforms/gmail/gmail-transform.mjs");
104
+
64
105
  const ensureTopicPathForClient = ({
65
106
  state,
66
107
  client,
@@ -209,7 +250,7 @@ const createGmailWatchService = ({
209
250
  return { token, changed: true };
210
251
  };
211
252
 
212
- const ensureHooksPreset = () => {
253
+ const ensureHooksPreset = ({ destination = null } = {}) => {
213
254
  const configPath = path.join(constants.OPENCLAW_DIR, "openclaw.json");
214
255
  if (!fs.existsSync(configPath)) {
215
256
  throw new Error("openclaw.json not found. Complete onboarding first.");
@@ -253,23 +294,7 @@ const createGmailWatchService = ({
253
294
  wakeMode: "now",
254
295
  transform: { module: gmailTransformModulePath },
255
296
  },
256
- transformSource: [
257
- "export default async function transform(payload) {",
258
- " const data = payload?.payload || payload || {};",
259
- " const messages = Array.isArray(data.messages) ? data.messages : [];",
260
- " const first = messages[0] || {};",
261
- " const from = String(first.from || \"unknown sender\").trim();",
262
- " const subject = String(first.subject || \"(no subject)\").trim();",
263
- " const snippet = String(first.snippet || \"\").trim();",
264
- " return {",
265
- " message: `New email from ${from}\\nSubject: ${subject}\\n${snippet}`.trim(),",
266
- " messages,",
267
- ' name: "Gmail",',
268
- ' wakeMode: "now",',
269
- " };",
270
- "}",
271
- "",
272
- ].join("\n"),
297
+ transformSource: buildGmailTransformSource(destination),
273
298
  });
274
299
  const webhookAfter = fs.readFileSync(configPath, "utf8");
275
300
  if (webhookBefore !== webhookAfter) {
@@ -278,9 +303,9 @@ const createGmailWatchService = ({
278
303
  return { changed };
279
304
  };
280
305
 
281
- const ensureHookWiring = () => {
306
+ const ensureHookWiring = ({ destination = null } = {}) => {
282
307
  const webhook = ensureWebhookToken();
283
- const hooks = ensureHooksPreset();
308
+ const hooks = ensureHooksPreset({ destination });
284
309
  const changed = webhook.changed || hooks.changed;
285
310
  if (changed) markRestartRequired("gmail-watch");
286
311
  return { webhookToken: webhook.token, changed };
@@ -341,6 +366,7 @@ const createGmailWatchService = ({
341
366
  const pushEndpoint = `${baseUrl}/gmail-pubsub?token=${encodeURIComponent(
342
367
  String(push.token || ""),
343
368
  )}`;
369
+ const transformExists = fs.existsSync(getGmailTransformAbsolutePath(constants));
344
370
  const commands =
345
371
  projectId && push.token
346
372
  ? {
@@ -358,6 +384,7 @@ const createGmailWatchService = ({
358
384
  subscriptionName,
359
385
  pushEndpoint,
360
386
  commands,
387
+ transformExists,
361
388
  configured: Boolean(topicPath && push.token && projectId),
362
389
  };
363
390
  };
@@ -448,7 +475,7 @@ const createGmailWatchService = ({
448
475
  };
449
476
  };
450
477
 
451
- const startWatch = async ({ accountId, req }) => {
478
+ const startWatch = async ({ accountId, req, destination = null }) => {
452
479
  let state = readState();
453
480
  const account = getGoogleAccountById(state, accountId);
454
481
  if (!account) throw new Error("Google account not found");
@@ -466,7 +493,7 @@ const createGmailWatchService = ({
466
493
  state = ensuredTopic.state;
467
494
  const topicPath = ensuredTopic.topicPath;
468
495
 
469
- const { webhookToken } = ensureHookWiring();
496
+ const { webhookToken } = ensureHookWiring({ destination });
470
497
  const watchStart = await runGogForAccount({
471
498
  account,
472
499
  command:
@@ -109,7 +109,7 @@ const verifyGithubRepoForOnboarding = async ({
109
109
  return {
110
110
  ok: false,
111
111
  status: 400,
112
- error: `Repository "${repoUrl}" already exists and is not empty. Did you mean to use "Import existing setup"?`,
112
+ error: `Repository "${repoUrl}" already exists and is not empty. To import, use "Import existing setup" instead.`,
113
113
  };
114
114
  }
115
115
  const commitCheckDetails = await parseGithubErrorMessage(commitsRes);
@@ -60,7 +60,11 @@ const registerGmailRoutes = ({
60
60
  try {
61
61
  const accountId = String(req.body?.accountId || "").trim();
62
62
  if (!accountId) return res.status(400).json({ ok: false, error: "accountId is required" });
63
- const result = await gmailWatchService.startWatch({ accountId, req });
63
+ const result = await gmailWatchService.startWatch({
64
+ accountId,
65
+ req,
66
+ destination: req.body?.destination || null,
67
+ });
64
68
  const snapshot = await getRestartSnapshot();
65
69
  return res.json({
66
70
  ...result,
@@ -34,6 +34,62 @@ const registerModelRoutes = ({
34
34
  return next;
35
35
  };
36
36
 
37
+ const readEnvVarMap = () => {
38
+ if (typeof readEnvFile !== "function") return new Map();
39
+ return new Map(
40
+ (readEnvFile() || []).map((entry) => [
41
+ String(entry?.key || "").trim(),
42
+ String(entry?.value || "").trim(),
43
+ ]),
44
+ );
45
+ };
46
+
47
+ const buildEnvBackedProfiles = (agentId) => {
48
+ const envMap = readEnvVarMap();
49
+ const providers = authProfiles.listApiKeyProviders?.() || [];
50
+ return providers.flatMap((provider) => {
51
+ const envKey = authProfiles.getEnvVarForApiKeyProvider?.(provider);
52
+ const envValue = String(envMap.get(envKey) || "").trim();
53
+ if (!envKey || !envValue) return [];
54
+ const profileId =
55
+ authProfiles.getDefaultProfileIdForApiKeyProvider?.(provider) ||
56
+ `${provider}:default`;
57
+ return [
58
+ {
59
+ id: profileId,
60
+ type: "api_key",
61
+ provider,
62
+ key: envValue,
63
+ },
64
+ ];
65
+ });
66
+ };
67
+
68
+ const mergeProfilesWithEnvFallback = (profiles, agentId) => {
69
+ const mergedProfiles = Array.isArray(profiles) ? [...profiles] : [];
70
+ const profileIndexById = new Map(
71
+ mergedProfiles.map((profile, index) => [profile?.id, index]),
72
+ );
73
+ for (const envProfile of buildEnvBackedProfiles(agentId)) {
74
+ const existingIndex = profileIndexById.get(envProfile.id);
75
+ if (existingIndex === undefined) {
76
+ profileIndexById.set(envProfile.id, mergedProfiles.length);
77
+ mergedProfiles.push(envProfile);
78
+ continue;
79
+ }
80
+ const existingProfile = mergedProfiles[existingIndex] || {};
81
+ const existingValue = String(
82
+ existingProfile?.key || existingProfile?.token || existingProfile?.access || "",
83
+ ).trim();
84
+ if (existingValue) continue;
85
+ mergedProfiles[existingIndex] = {
86
+ ...existingProfile,
87
+ ...envProfile,
88
+ };
89
+ }
90
+ return mergedProfiles;
91
+ };
92
+
37
93
  const syncEnvVarsForProfiles = (profiles) => {
38
94
  if (
39
95
  !Array.isArray(profiles) ||
@@ -62,6 +118,28 @@ const registerModelRoutes = ({
62
118
  reloadEnv();
63
119
  };
64
120
 
121
+ const syncProfilesFromEnvVars = (agentId) => {
122
+ if (
123
+ typeof readEnvFile !== "function" ||
124
+ typeof authProfiles.upsertApiKeyProfileForEnvVar !== "function" ||
125
+ typeof authProfiles.removeApiKeyProfileForEnvVar !== "function"
126
+ ) {
127
+ return;
128
+ }
129
+ const envMap = readEnvVarMap();
130
+ const providers = authProfiles.listApiKeyProviders?.() || [];
131
+ for (const provider of providers) {
132
+ const envKey = authProfiles.getEnvVarForApiKeyProvider?.(provider);
133
+ if (!envKey) continue;
134
+ const envValue = String(envMap.get(envKey) || "").trim();
135
+ if (!envValue) {
136
+ authProfiles.removeApiKeyProfileForEnvVar(provider, agentId);
137
+ continue;
138
+ }
139
+ authProfiles.upsertApiKeyProfileForEnvVar(provider, envValue, agentId);
140
+ }
141
+ };
142
+
65
143
  // ── Existing CLI-backed catalog/status routes ──
66
144
 
67
145
  app.get("/api/models", async (req, res) => {
@@ -135,7 +213,10 @@ const registerModelRoutes = ({
135
213
  try {
136
214
  const { primary, configuredModels } = authProfiles.getModelConfig();
137
215
  const agentId = req.query.agentId || undefined;
138
- const profiles = authProfiles.listProfiles(agentId);
216
+ const profiles = mergeProfilesWithEnvFallback(
217
+ authProfiles.listProfiles(agentId),
218
+ agentId,
219
+ );
139
220
  const store = authProfiles.loadAuthStore(agentId);
140
221
  res.json({
141
222
  ok: true,
@@ -179,6 +260,8 @@ const registerModelRoutes = ({
179
260
  syncEnvVarsForProfiles(profiles);
180
261
  }
181
262
 
263
+ syncProfilesFromEnvVars(agentId);
264
+
182
265
  if (authOrder && typeof authOrder === "object") {
183
266
  for (const [provider, order] of Object.entries(authOrder)) {
184
267
  if (Array.isArray(order)) {
@@ -107,29 +107,7 @@ const registerSystemRoutes = ({
107
107
  String(entry?.value || ""),
108
108
  ]),
109
109
  );
110
- const providers = [
111
- "anthropic",
112
- "openai",
113
- "google",
114
- "opencode",
115
- "openrouter",
116
- "zai",
117
- "vercel-ai-gateway",
118
- "kilocode",
119
- "xai",
120
- "mistral",
121
- "cerebras",
122
- "moonshot",
123
- "kimi-coding",
124
- "volcengine",
125
- "byteplus",
126
- "synthetic",
127
- "minimax",
128
- "voyage",
129
- "groq",
130
- "deepgram",
131
- "vllm",
132
- ];
110
+ const providers = authProfiles.listApiKeyProviders?.() || [];
133
111
  for (const provider of providers) {
134
112
  const envKey = authProfiles.getEnvVarForApiKeyProvider?.(provider);
135
113
  if (!envKey) continue;
@@ -141,6 +119,30 @@ const registerSystemRoutes = ({
141
119
  authProfiles.upsertApiKeyProfileForEnvVar(provider, value);
142
120
  }
143
121
  };
122
+ const getSessionReplyTarget = (sessionKey = "") => {
123
+ const key = String(sessionKey || "");
124
+ const telegramDirectMatch = key.match(/:telegram:direct:([^:]+)$/);
125
+ if (telegramDirectMatch) {
126
+ return {
127
+ replyChannel: "telegram",
128
+ replyTo: String(telegramDirectMatch[1] || ""),
129
+ };
130
+ }
131
+ const telegramTopicMatch = key.match(
132
+ /:telegram:group:([^:]+):topic:([^:]+)$/,
133
+ );
134
+ if (telegramTopicMatch) {
135
+ return {
136
+ replyChannel: "telegram",
137
+ replyTo: `${String(telegramTopicMatch[1] || "")}:${String(telegramTopicMatch[2] || "")}`,
138
+ };
139
+ }
140
+ return {
141
+ replyChannel: "",
142
+ replyTo: "",
143
+ };
144
+ };
145
+
144
146
  const listSendableAgentSessions = async () => {
145
147
  const result = await clawCmd("sessions --json", { quiet: true });
146
148
  if (!result.ok) {
@@ -163,14 +165,14 @@ const registerSystemRoutes = ({
163
165
  })
164
166
  .map((sessionRow) => {
165
167
  const key = String(sessionRow?.key || "");
166
- const telegramMatch = key.match(/:telegram:direct:([^:]+)$/);
168
+ const replyTarget = getSessionReplyTarget(key);
167
169
  return {
168
170
  key,
169
171
  sessionId: String(sessionRow?.sessionId || ""),
170
172
  updatedAt: Number(sessionRow?.updatedAt) || 0,
171
173
  label: buildSessionLabel(sessionRow),
172
- replyChannel: telegramMatch ? "telegram" : "",
173
- replyTo: telegramMatch ? String(telegramMatch[1] || "") : "",
174
+ replyChannel: replyTarget.replyChannel,
175
+ replyTo: replyTarget.replyTo,
174
176
  };
175
177
  })
176
178
  .sort((a, b) => b.updatedAt - a.updatedAt);
@@ -136,9 +136,9 @@ const registerWebhookRoutes = ({
136
136
 
137
137
  app.post("/api/webhooks", async (req, res) => {
138
138
  try {
139
- const { name: rawName } = req.body || {};
139
+ const { name: rawName, destination = null } = req.body || {};
140
140
  const name = validateWebhookName(rawName);
141
- const webhook = createWebhook({ fs, constants, name });
141
+ const webhook = createWebhook({ fs, constants, name, destination });
142
142
  const baseUrl = getBaseUrl(req);
143
143
  const urls = buildWebhookUrls({ baseUrl, name });
144
144
  const syncWarning = await runWebhookGitSync("create", name);