@chrysb/alphaclaw 0.8.1-beta.2 → 0.8.1-beta.3

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.
@@ -15,10 +15,10 @@ import { sendAgentMessage } from "../../lib/api.js";
15
15
  import { showToast } from "../toast.js";
16
16
  import { useAgentSessions } from "../../hooks/useAgentSessions.js";
17
17
  import {
18
- kDestinationSessionFilter,
19
18
  kNoDestinationSessionValue,
20
19
  useDestinationSessionSelection,
21
20
  } from "../../hooks/use-destination-session-selection.js";
21
+ import { kDestinationSessionFilter } from "../../lib/session-keys.js";
22
22
 
23
23
  const html = htm.bind(h);
24
24
 
@@ -1,5 +1,6 @@
1
1
  import { h } from "https://esm.sh/preact";
2
2
  import htm from "https://esm.sh/htm";
3
+ import { getSessionRowKey } from "../lib/session-keys.js";
3
4
 
4
5
  const html = htm.bind(h);
5
6
 
@@ -47,8 +48,8 @@ export const SessionSelectField = ({
47
48
  : null}
48
49
  ${sessions.map(
49
50
  (sessionRow) => html`
50
- <option value=${String(sessionRow?.key || "")}>
51
- ${String(sessionRow?.label || sessionRow?.key || "Session")}
51
+ <option value=${getSessionRowKey(sessionRow)}>
52
+ ${String(sessionRow?.label || getSessionRowKey(sessionRow) || "Session")}
52
53
  </option>
53
54
  `,
54
55
  )}
@@ -24,6 +24,7 @@ export const RequestHistory = ({
24
24
  webhookUrlWithQueryToken = "",
25
25
  bearerTokenValue = "",
26
26
  selectedWebhook = null,
27
+ refreshNonce = 0,
27
28
  }) => {
28
29
  const { state, actions } = useRequestHistory({
29
30
  selectedHookName,
@@ -31,6 +32,7 @@ export const RequestHistory = ({
31
32
  webhookUrl,
32
33
  webhookUrlWithQueryToken,
33
34
  bearerTokenValue,
35
+ refreshNonce,
34
36
  });
35
37
 
36
38
  const {
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo, useState } from "https://esm.sh/preact/hooks";
1
+ import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
2
2
  import {
3
3
  fetchWebhookRequest,
4
4
  fetchWebhookRequests,
@@ -12,6 +12,7 @@ export const useRequestHistory = ({
12
12
  webhookUrl = "",
13
13
  webhookUrlWithQueryToken = "",
14
14
  bearerTokenValue = "",
15
+ refreshNonce = 0,
15
16
  }) => {
16
17
  const [statusFilter, setStatusFilter] = useState("all");
17
18
  const [expandedRows, setExpandedRows] = useState(() => new Set());
@@ -35,6 +36,11 @@ export const useRequestHistory = ({
35
36
 
36
37
  const requests = requestsPoll.data?.requests || [];
37
38
 
39
+ useEffect(() => {
40
+ if (!selectedHookName) return;
41
+ requestsPoll.refresh();
42
+ }, [refreshNonce, requestsPoll.refresh, selectedHookName]);
43
+
38
44
  const handleRequestRowToggle = useCallback((id, isOpen) => {
39
45
  setExpandedRows((prev) => {
40
46
  const next = new Set(prev);
@@ -1,9 +1,12 @@
1
1
  import { h } from "https://esm.sh/preact";
2
+ import { useCallback, useState } from "https://esm.sh/preact/hooks";
2
3
  import htm from "https://esm.sh/htm";
3
4
  import { ActionButton } from "../../action-button.js";
4
5
  import { Badge } from "../../badge.js";
5
6
  import { ConfirmDialog } from "../../confirm-dialog.js";
6
7
  import { showToast } from "../../toast.js";
8
+ import { kNoDestinationSessionValue } from "../../../hooks/use-destination-session-selection.js";
9
+ import { getSessionRowKey } from "../../../lib/session-keys.js";
7
10
  import { formatDateTime } from "../helpers.js";
8
11
  import { RequestHistory } from "../request-history/index.js";
9
12
  import { useWebhookDetail } from "./use-webhook-detail.js";
@@ -16,10 +19,15 @@ export const WebhookDetail = ({
16
19
  onRestartRequired = () => {},
17
20
  onOpenFile = () => {},
18
21
  }) => {
22
+ const [historyRefreshNonce, setHistoryRefreshNonce] = useState(0);
23
+ const handleTestWebhookSent = useCallback(() => {
24
+ setHistoryRefreshNonce((value) => value + 1);
25
+ }, []);
19
26
  const { state, actions } = useWebhookDetail({
20
27
  selectedHookName,
21
28
  onBackToList,
22
29
  onRestartRequired,
30
+ onTestWebhookSent: handleTestWebhookSent,
23
31
  });
24
32
 
25
33
  const {
@@ -30,6 +38,12 @@ export const WebhookDetail = ({
30
38
  selectedWebhookManaged,
31
39
  selectedDeliveryAgentName,
32
40
  selectedDeliveryChannel,
41
+ selectableSessions,
42
+ loadingDestinationSessions,
43
+ destinationLoadError,
44
+ destinationSessionKey,
45
+ destinationDirty,
46
+ savingDestination,
33
47
  webhookUrl,
34
48
  oauthCallbackUrl,
35
49
  hasOauthCallback,
@@ -222,13 +236,60 @@ export const WebhookDetail = ({
222
236
  `}
223
237
 
224
238
  <div class="bg-black/20 border border-border rounded-lg p-3 space-y-2">
225
- <p class="text-xs text-gray-500">Deliver to</p>
226
- <p class="text-xs text-gray-200 font-mono ">
227
- ${selectedDeliveryAgentName}${" "}
228
- <span class="text-xs text-gray-500 font-mono"
229
- >(${selectedDeliveryChannel})</span
230
- >
231
- </p>
239
+ ${selectedWebhookManaged
240
+ ? html`
241
+ <p class="text-xs text-gray-500">Deliver to</p>
242
+ <p class="text-xs text-gray-200 font-mono">
243
+ ${selectedDeliveryAgentName}${" "}
244
+ <span class="text-xs text-gray-500 font-mono"
245
+ >(${selectedDeliveryChannel})</span
246
+ >
247
+ </p>
248
+ `
249
+ : html`
250
+ <p class="text-xs text-gray-500">Deliver to</p>
251
+ <div class="flex items-center gap-2">
252
+ <select
253
+ value=${destinationSessionKey || kNoDestinationSessionValue}
254
+ onInput=${(event) => {
255
+ const nextValue = String(event.currentTarget?.value || "");
256
+ actions.setDestinationSessionKey(
257
+ nextValue === kNoDestinationSessionValue ? "" : nextValue,
258
+ );
259
+ }}
260
+ disabled=${loadingDestinationSessions || savingDestination}
261
+ class="h-8 flex-1 bg-black/30 border border-border rounded-lg px-3 text-xs text-gray-200 focus:border-gray-500"
262
+ >
263
+ <option value=${kNoDestinationSessionValue}>Default</option>
264
+ ${loadingDestinationSessions
265
+ ? html`<option value="" disabled>Loading...</option>`
266
+ : selectableSessions.map(
267
+ (sessionRow) => html`
268
+ <option value=${getSessionRowKey(sessionRow)}>
269
+ ${String(
270
+ sessionRow?.label ||
271
+ getSessionRowKey(sessionRow) ||
272
+ "Session",
273
+ )}
274
+ </option>
275
+ `,
276
+ )}
277
+ </select>
278
+ <${ActionButton}
279
+ onClick=${actions.handleSaveDestination}
280
+ disabled=${!destinationDirty || savingDestination}
281
+ loading=${savingDestination}
282
+ tone="secondary"
283
+ size="sm"
284
+ idleLabel="Save"
285
+ loadingLabel="Saving..."
286
+ className="px-2.5 py-1"
287
+ />
288
+ </div>
289
+ ${destinationLoadError
290
+ ? html`<p class="text-xs text-red-400">${destinationLoadError}</p>`
291
+ : null}
292
+ `}
232
293
  </div>
233
294
 
234
295
  <div class="bg-black/20 border border-border rounded-lg p-3 space-y-2">
@@ -333,6 +394,7 @@ export const WebhookDetail = ({
333
394
  webhookUrl=${webhookUrl}
334
395
  webhookUrlWithQueryToken=${webhookUrlWithQueryToken}
335
396
  bearerTokenValue=${bearerTokenValue}
397
+ refreshNonce=${historyRefreshNonce}
336
398
  />
337
399
  <${ConfirmDialog}
338
400
  visible=${showRotateOauthConfirm &&
@@ -1,18 +1,72 @@
1
- import { useCallback, useMemo, useState } from "https://esm.sh/preact/hooks";
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useState,
6
+ } from "https://esm.sh/preact/hooks";
2
7
  import {
3
8
  deleteWebhook,
4
9
  fetchAgents,
5
10
  fetchWebhookDetail,
6
11
  rotateWebhookOauthCallback,
12
+ updateWebhookDestination,
7
13
  } from "../../../lib/api.js";
14
+ import {
15
+ useDestinationSessionSelection,
16
+ } from "../../../hooks/use-destination-session-selection.js";
8
17
  import { useCachedFetch } from "../../../hooks/use-cached-fetch.js";
18
+ import {
19
+ getAgentIdFromSessionKey,
20
+ getSessionRowKey,
21
+ } from "../../../lib/session-keys.js";
9
22
  import { showToast } from "../../toast.js";
10
23
  import { formatAgentFallbackName } from "../helpers.js";
11
24
 
25
+ const getWebhookDestination = (webhook = null) => {
26
+ const channel = String(webhook?.channel || "").trim();
27
+ const to = String(webhook?.to || "").trim();
28
+ if (!channel || !to) return null;
29
+ const agentId = String(webhook?.agentId || "").trim();
30
+ return {
31
+ channel,
32
+ to,
33
+ ...(agentId ? { agentId } : {}),
34
+ };
35
+ };
36
+
37
+ const findDestinationSessionKey = (sessions = [], webhook = null) => {
38
+ const destination = getWebhookDestination(webhook);
39
+ if (!destination) return "";
40
+ const destinationAgentId = String(destination?.agentId || "").trim();
41
+ const matchingSession = sessions.find((sessionRow) => {
42
+ const channel = String(sessionRow?.replyChannel || "").trim();
43
+ const to = String(sessionRow?.replyTo || "").trim();
44
+ const agentId = getAgentIdFromSessionKey(getSessionRowKey(sessionRow));
45
+ const agentMatches = destinationAgentId ? agentId === destinationAgentId : true;
46
+ return (
47
+ channel === destination.channel &&
48
+ to === destination.to &&
49
+ agentMatches
50
+ );
51
+ });
52
+ return String(matchingSession?.key || "").trim();
53
+ };
54
+
55
+ const areDestinationsEqual = (left = null, right = null) => {
56
+ if (!left && !right) return true;
57
+ if (!left || !right) return false;
58
+ return (
59
+ String(left.channel || "").trim() === String(right.channel || "").trim() &&
60
+ String(left.to || "").trim() === String(right.to || "").trim() &&
61
+ String(left.agentId || "").trim() === String(right.agentId || "").trim()
62
+ );
63
+ };
64
+
12
65
  export const useWebhookDetail = ({
13
66
  selectedHookName = "",
14
67
  onBackToList = () => {},
15
68
  onRestartRequired = () => {},
69
+ onTestWebhookSent = () => {},
16
70
  }) => {
17
71
  const [authMode, setAuthMode] = useState("headers");
18
72
  const [deleting, setDeleting] = useState(false);
@@ -21,6 +75,7 @@ export const useWebhookDetail = ({
21
75
  const [rotatingOauthCallback, setRotatingOauthCallback] = useState(false);
22
76
  const [showRotateOauthConfirm, setShowRotateOauthConfirm] = useState(false);
23
77
  const [sendingTestWebhook, setSendingTestWebhook] = useState(false);
78
+ const [savingDestination, setSavingDestination] = useState(false);
24
79
 
25
80
  const detailCacheKey = useMemo(
26
81
  () => `/api/webhooks/${encodeURIComponent(String(selectedHookName || ""))}`,
@@ -68,10 +123,57 @@ export const useWebhookDetail = ({
68
123
  formatAgentFallbackName(selectedDeliveryAgentId);
69
124
  const selectedDeliveryChannel =
70
125
  String(selectedWebhook?.channel || "last").trim() || "last";
126
+ const destinationResetKey = useMemo(
127
+ () =>
128
+ [
129
+ selectedHookName,
130
+ selectedWebhook?.agentId,
131
+ selectedWebhook?.channel,
132
+ selectedWebhook?.to,
133
+ ]
134
+ .map((value) => String(value || "").trim())
135
+ .join("|"),
136
+ [
137
+ selectedHookName,
138
+ selectedWebhook?.agentId,
139
+ selectedWebhook?.channel,
140
+ selectedWebhook?.to,
141
+ ],
142
+ );
143
+ const {
144
+ sessions: selectableSessions,
145
+ loading: loadingDestinationSessions,
146
+ error: destinationLoadError,
147
+ destinationSessionKey,
148
+ setDestinationSessionKey,
149
+ selectedDestination,
150
+ } = useDestinationSessionSelection({
151
+ enabled: !!selectedHookName && !selectedWebhookManaged,
152
+ resetKey: destinationResetKey,
153
+ });
71
154
 
72
155
  const webhookUrl = selectedWebhook?.fullUrl || `.../hooks/${selectedHookName}`;
73
156
  const oauthCallbackUrl = String(selectedWebhook?.oauthCallbackUrl || "").trim();
74
157
  const hasOauthCallback = !!oauthCallbackUrl;
158
+ const oauthCallbackTestUrl = useMemo(() => {
159
+ if (!hasOauthCallback) return "";
160
+ try {
161
+ const url = new URL(oauthCallbackUrl);
162
+ if (!url.searchParams.has("code")) {
163
+ url.searchParams.set("code", "TEST_AUTH_CODE");
164
+ }
165
+ if (!url.searchParams.has("state")) {
166
+ url.searchParams.set("state", "TEST_STATE");
167
+ }
168
+ if (!url.searchParams.has("message")) {
169
+ url.searchParams.set("message", "OAuth callback test");
170
+ }
171
+ return url.toString();
172
+ } catch {
173
+ const separator = oauthCallbackUrl.includes("?") ? "&" : "?";
174
+ return `${oauthCallbackUrl}${separator}code=TEST_AUTH_CODE&state=TEST_STATE&message=OAuth%20callback%20test`;
175
+ }
176
+ }, [hasOauthCallback, oauthCallbackUrl]);
75
177
  const webhookUrlWithQueryToken =
76
178
  selectedWebhook?.queryStringUrl ||
77
179
  `${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}token=<WEBHOOK_TOKEN>`;
@@ -133,31 +235,104 @@ export const useWebhookDetail = ({
133
235
  `curl -X POST "${webhookUrlWithQueryToken}" ` +
134
236
  `-H "Content-Type: application/json" ` +
135
237
  `-d '${webhookTestPayloadJson}'`;
238
+ const curlCommandOauth = `curl -X GET "${oauthCallbackTestUrl}"`;
136
239
 
137
240
  const effectiveAuthMode = selectedWebhookManaged ? "headers" : authMode;
138
- const activeCurlCommand =
139
- effectiveAuthMode === "query" ? curlCommandQuery : curlCommandHeaders;
241
+ const activeCurlCommand = hasOauthCallback
242
+ ? curlCommandOauth
243
+ : effectiveAuthMode === "query"
244
+ ? curlCommandQuery
245
+ : curlCommandHeaders;
140
246
 
141
247
  const refreshDetail = useCallback(() => {
142
248
  detailFetchState.refresh({ force: true });
143
249
  agentsFetchState.refresh({ force: true });
144
250
  }, [agentsFetchState.refresh, detailFetchState.refresh]);
145
251
 
252
+ useEffect(() => {
253
+ if (!selectedHookName || selectedWebhookManaged || !selectedWebhook) return;
254
+ if (!Array.isArray(selectableSessions) || selectableSessions.length <= 0) {
255
+ setDestinationSessionKey("");
256
+ return;
257
+ }
258
+ const nextKey = findDestinationSessionKey(selectableSessions, selectedWebhook);
259
+ setDestinationSessionKey(nextKey);
260
+ }, [
261
+ selectableSessions,
262
+ selectedHookName,
263
+ selectedWebhook,
264
+ selectedWebhookManaged,
265
+ setDestinationSessionKey,
266
+ ]);
267
+
268
+ const currentDestination = useMemo(
269
+ () => getWebhookDestination(selectedWebhook),
270
+ [selectedWebhook],
271
+ );
272
+ const destinationDirty = useMemo(
273
+ () => !areDestinationsEqual(currentDestination, selectedDestination),
274
+ [currentDestination, selectedDestination],
275
+ );
276
+
277
+ const handleSaveDestination = useCallback(async () => {
278
+ if (
279
+ !selectedHookName ||
280
+ selectedWebhookManaged ||
281
+ savingDestination ||
282
+ !destinationDirty
283
+ ) {
284
+ return;
285
+ }
286
+ setSavingDestination(true);
287
+ try {
288
+ const data = await updateWebhookDestination(selectedHookName, {
289
+ destination: selectedDestination || null,
290
+ });
291
+ if (data?.restartRequired) {
292
+ onRestartRequired(true);
293
+ }
294
+ if (data?.syncWarning) {
295
+ showToast(`Updated, but git-sync failed: ${data.syncWarning}`, "warning");
296
+ }
297
+ showToast("Webhook destination updated", "success");
298
+ refreshDetail();
299
+ } catch (err) {
300
+ showToast(err.message || "Could not update webhook destination", "error");
301
+ } finally {
302
+ setSavingDestination(false);
303
+ }
304
+ }, [
305
+ destinationDirty,
306
+ onRestartRequired,
307
+ refreshDetail,
308
+ savingDestination,
309
+ selectedDestination,
310
+ selectedHookName,
311
+ selectedWebhookManaged,
312
+ ]);
313
+
146
314
  const handleSendTestWebhook = useCallback(async () => {
147
315
  if (!selectedHookName || sendingTestWebhook) return;
148
316
  setSendingTestWebhook(true);
149
- const requestUrl =
150
- effectiveAuthMode === "query" ? webhookUrlWithQueryToken : webhookUrl;
151
- const headers = { "Content-Type": "application/json" };
152
- if (effectiveAuthMode === "headers") {
153
- headers.Authorization = bearerTokenValue;
154
- }
155
317
  try {
156
- const response = await fetch(requestUrl, {
157
- method: "POST",
158
- headers,
159
- body: webhookTestPayloadJson,
160
- });
318
+ const response = hasOauthCallback
319
+ ? await fetch(oauthCallbackTestUrl, {
320
+ method: "GET",
321
+ })
322
+ : await fetch(
323
+ effectiveAuthMode === "query" ? webhookUrlWithQueryToken : webhookUrl,
324
+ {
325
+ method: "POST",
326
+ headers: {
327
+ "Content-Type": "application/json",
328
+ ...(effectiveAuthMode === "headers"
329
+ ? { Authorization: bearerTokenValue }
330
+ : {}),
331
+ },
332
+ body: webhookTestPayloadJson,
333
+ },
334
+ );
335
+ onTestWebhookSent();
161
336
  const bodyText = await response.text();
162
337
  let body = null;
163
338
  try {
@@ -184,6 +359,9 @@ export const useWebhookDetail = ({
184
359
  }, [
185
360
  bearerTokenValue,
186
361
  effectiveAuthMode,
362
+ hasOauthCallback,
363
+ oauthCallbackTestUrl,
364
+ onTestWebhookSent,
187
365
  selectedHookName,
188
366
  sendingTestWebhook,
189
367
  webhookTestPayloadJson,
@@ -248,6 +426,12 @@ export const useWebhookDetail = ({
248
426
  selectedWebhookManaged,
249
427
  selectedDeliveryAgentName,
250
428
  selectedDeliveryChannel,
429
+ selectableSessions,
430
+ loadingDestinationSessions,
431
+ destinationLoadError,
432
+ destinationSessionKey,
433
+ destinationDirty,
434
+ savingDestination,
251
435
  webhookUrl,
252
436
  oauthCallbackUrl,
253
437
  hasOauthCallback,
@@ -266,9 +450,11 @@ export const useWebhookDetail = ({
266
450
  actions: {
267
451
  refreshDetail,
268
452
  setAuthMode,
453
+ setDestinationSessionKey,
269
454
  setShowDeleteConfirm,
270
455
  setDeleteTransformDir,
271
456
  setShowRotateOauthConfirm,
457
+ handleSaveDestination,
272
458
  handleDeleteConfirmed,
273
459
  handleRotateOauthCallback,
274
460
  handleSendTestWebhook,
@@ -1,27 +1,12 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
2
2
  import { useAgentSessions } from "./useAgentSessions.js";
3
+ import {
4
+ getDestinationFromSession,
5
+ kDestinationSessionFilter,
6
+ } from "../lib/session-keys.js";
3
7
 
4
8
  export const kNoDestinationSessionValue = "__none__";
5
9
 
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
- const key = String(sessionRow?.key || "").trim();
16
- const agentMatch = key.match(/^agent:([^:]+):/);
17
- const agentId = String(agentMatch?.[1] || "").trim();
18
- return {
19
- channel,
20
- to,
21
- ...(agentId ? { agentId } : {}),
22
- };
23
- };
24
-
25
10
  export const useDestinationSessionSelection = ({
26
11
  enabled = false,
27
12
  resetKey = "",
@@ -48,10 +33,10 @@ export const useDestinationSessionSelection = ({
48
33
  const preferredSessionKey = useMemo(() => {
49
34
  const matchingPreferredSession = sessions.find(
50
35
  (sessionRow) =>
51
- String(sessionRow?.key || "") === String(selectedSessionKey || "").trim(),
36
+ getSessionRowKey(sessionRow) === String(selectedSessionKey || "").trim(),
52
37
  );
53
38
  return String(
54
- matchingPreferredSession?.key || sessions[0]?.key || "",
39
+ getSessionRowKey(matchingPreferredSession) || getSessionRowKey(sessions[0]),
55
40
  ).trim();
56
41
  }, [sessions, selectedSessionKey]);
57
42
 
@@ -63,7 +48,7 @@ export const useDestinationSessionSelection = ({
63
48
  () =>
64
49
  sessions.find(
65
50
  (sessionRow) =>
66
- String(sessionRow?.key || "") === String(effectiveSessionKey || "").trim(),
51
+ getSessionRowKey(sessionRow) === String(effectiveSessionKey || "").trim(),
67
52
  ) || null,
68
53
  [effectiveSessionKey, sessions],
69
54
  );
@@ -4,6 +4,7 @@ import {
4
4
  kAgentSessionsCacheKey,
5
5
  kAgentLastSessionKey,
6
6
  } from "../lib/storage-keys.js";
7
+ import { getSessionRowKey, isDestinationSessionKey } from "../lib/session-keys.js";
7
8
 
8
9
  const readCachedSessions = () => {
9
10
  try {
@@ -38,14 +39,13 @@ const writeLastSessionKey = (key) => {
38
39
 
39
40
  const pickPreferredSession = (sessions, lastKey) => {
40
41
  if (lastKey) {
41
- const lastMatch = sessions.find((row) => String(row?.key || "") === lastKey);
42
+ const lastMatch = sessions.find((row) => getSessionRowKey(row) === lastKey);
42
43
  if (lastMatch) return lastMatch;
43
44
  }
44
45
  return (
45
- sessions.find((row) => String(row?.key || "").toLowerCase() === "agent:main:main") ||
46
+ sessions.find((row) => getSessionRowKey(row).toLowerCase() === "agent:main:main") ||
46
47
  sessions.find((row) => {
47
- const key = String(row?.key || "").toLowerCase();
48
- return key.includes(":direct:") || key.includes(":group:");
48
+ return isDestinationSessionKey(getSessionRowKey(row));
49
49
  }) ||
50
50
  sessions[0] ||
51
51
  null
@@ -81,7 +81,7 @@ export const useAgentSessions = ({ enabled = false, filter } = {}) => {
81
81
  if (cached.length > 0) {
82
82
  setAllSessions(cached);
83
83
  const preferred = pickPreferredSession(cached, lastKey);
84
- setSelectedSessionKeyState(String(preferred?.key || ""));
84
+ setSelectedSessionKeyState(getSessionRowKey(preferred));
85
85
  }
86
86
 
87
87
  const load = async () => {
@@ -95,7 +95,7 @@ export const useAgentSessions = ({ enabled = false, filter } = {}) => {
95
95
  writeCachedSessions(nextSessions);
96
96
  if (cached.length === 0 || !lastKey) {
97
97
  const preferred = pickPreferredSession(nextSessions, lastKey);
98
- setSelectedSessionKeyState(String(preferred?.key || ""));
98
+ setSelectedSessionKeyState(getSessionRowKey(preferred));
99
99
  }
100
100
  } catch (err) {
101
101
  if (!active) return;
@@ -126,15 +126,15 @@ export const useAgentSessions = ({ enabled = false, filter } = {}) => {
126
126
  return;
127
127
  }
128
128
  const hasSelectedSession = sessions.some(
129
- (row) => String(row?.key || "") === String(selectedSessionKey || ""),
129
+ (row) => getSessionRowKey(row) === String(selectedSessionKey || ""),
130
130
  );
131
131
  if (hasSelectedSession) return;
132
132
  const preferred = pickPreferredSession(sessions, readLastSessionKey());
133
- setSelectedSessionKeyState(String(preferred?.key || ""));
133
+ setSelectedSessionKeyState(getSessionRowKey(preferred));
134
134
  }, [enabled, sessions, selectedSessionKey]);
135
135
 
136
136
  const selectedSession = useMemo(
137
- () => sessions.find((row) => String(row?.key || "") === selectedSessionKey) || null,
137
+ () => sessions.find((row) => getSessionRowKey(row) === selectedSessionKey) || null,
138
138
  [sessions, selectedSessionKey],
139
139
  );
140
140
 
@@ -1158,6 +1158,20 @@ export async function deleteWebhook(name, { deleteTransformDir = false } = {}) {
1158
1158
  return parseJsonOrThrow(res, "Could not delete webhook");
1159
1159
  }
1160
1160
 
1161
+ export async function updateWebhookDestination(name, { destination = null } = {}) {
1162
+ const res = await authFetch(
1163
+ `/api/webhooks/${encodeURIComponent(name)}/destination`,
1164
+ {
1165
+ method: "PUT",
1166
+ headers: { "Content-Type": "application/json" },
1167
+ body: JSON.stringify({
1168
+ destination,
1169
+ }),
1170
+ },
1171
+ );
1172
+ return parseJsonOrThrow(res, "Could not update webhook destination");
1173
+ }
1174
+
1161
1175
  export async function createWebhookOauthCallback(name) {
1162
1176
  const res = await authFetch(
1163
1177
  `/api/webhooks/${encodeURIComponent(name)}/oauth-callback`,
@@ -0,0 +1,37 @@
1
+ export const getNormalizedSessionKey = (sessionKey = "") =>
2
+ String(sessionKey || "").trim();
3
+
4
+ export const getSessionRowKey = (sessionRow = null) =>
5
+ getNormalizedSessionKey(sessionRow?.key || sessionRow?.sessionKey || "");
6
+
7
+ export const getAgentIdFromSessionKey = (sessionKey = "") => {
8
+ const normalizedSessionKey = getNormalizedSessionKey(sessionKey);
9
+ const agentMatch = normalizedSessionKey.match(/^agent:([^:]+):/);
10
+ return String(agentMatch?.[1] || "").trim();
11
+ };
12
+
13
+ export const isDestinationSessionKey = (sessionKey = "") => {
14
+ const normalizedSessionKey = getNormalizedSessionKey(sessionKey).toLowerCase();
15
+ return (
16
+ normalizedSessionKey.includes(":direct:") ||
17
+ normalizedSessionKey.includes(":group:")
18
+ );
19
+ };
20
+
21
+ export const kDestinationSessionFilter = (sessionRow) =>
22
+ !!(
23
+ String(sessionRow?.replyChannel || "").trim() &&
24
+ String(sessionRow?.replyTo || "").trim()
25
+ ) || isDestinationSessionKey(getSessionRowKey(sessionRow));
26
+
27
+ export const getDestinationFromSession = (sessionRow = null) => {
28
+ const channel = String(sessionRow?.replyChannel || "").trim();
29
+ const to = String(sessionRow?.replyTo || "").trim();
30
+ if (!channel || !to) return null;
31
+ const agentId = getAgentIdFromSessionKey(getSessionRowKey(sessionRow));
32
+ return {
33
+ channel,
34
+ to,
35
+ ...(agentId ? { agentId } : {}),
36
+ };
37
+ };
@@ -529,7 +529,6 @@ const createOnboardingService = ({
529
529
  });
530
530
  }
531
531
  authProfiles?.syncConfigAuthReferencesForAgent?.();
532
- ensureGatewayProxyConfig(getBaseUrl(req));
533
532
 
534
533
  installGogCliSkill({ fs, openclawDir: OPENCLAW_DIR });
535
534
 
@@ -549,6 +548,8 @@ const createOnboardingService = ({
549
548
  ),
550
549
  );
551
550
 
551
+ ensureGatewayProxyConfig(getBaseUrl(req));
552
+
552
553
  try {
553
554
  const commitMsg = importMode
554
555
  ? "imported existing setup via AlphaClaw"
@@ -68,17 +68,48 @@ const registerSystemRoutes = ({
68
68
  const parseJsonFromStdout = (stdout) => {
69
69
  const raw = String(stdout || "").trim();
70
70
  if (!raw) return null;
71
- const candidateStarts = [raw.indexOf("{"), raw.indexOf("[")].filter(
72
- (idx) => idx >= 0,
73
- );
74
- for (const start of candidateStarts) {
75
- const candidate = raw.slice(start);
71
+ try {
72
+ return JSON.parse(raw);
73
+ } catch {}
74
+ const lines = raw
75
+ .split("\n")
76
+ .map((line) => line.trim())
77
+ .filter(Boolean);
78
+ for (const line of lines) {
79
+ if (!(line.startsWith("{") || line.startsWith("["))) continue;
76
80
  try {
77
- return JSON.parse(candidate);
81
+ return JSON.parse(line);
78
82
  } catch {}
79
83
  }
84
+ const candidateStarts = [raw.indexOf("{"), raw.indexOf("[")].filter((idx) => idx >= 0);
85
+ for (const start of candidateStarts) {
86
+ for (let end = raw.length; end > start; end -= 1) {
87
+ const candidate = raw.slice(start, end).trim();
88
+ if (!(candidate.endsWith("}") || candidate.endsWith("]"))) continue;
89
+ try {
90
+ return JSON.parse(candidate);
91
+ } catch {}
92
+ }
93
+ }
80
94
  return null;
81
95
  };
96
+ const getRawSessionKey = (sessionRow = {}) =>
97
+ String(sessionRow?.key || sessionRow?.sessionKey || sessionRow?.id || "").trim();
98
+ const getRawSessionsFromPayload = (payload) => {
99
+ if (Array.isArray(payload)) return payload;
100
+ const candidates = [
101
+ payload?.sessions,
102
+ payload?.items,
103
+ payload?.data?.sessions,
104
+ payload?.data?.items,
105
+ payload?.result?.sessions,
106
+ payload?.result?.items,
107
+ ];
108
+ for (const candidate of candidates) {
109
+ if (Array.isArray(candidate)) return candidate;
110
+ }
111
+ return [];
112
+ };
82
113
  const toTitleWords = (value) =>
83
114
  String(value || "")
84
115
  .trim()
@@ -218,7 +249,7 @@ const registerSystemRoutes = ({
218
249
  throw new Error(result.stderr || "Could not load agent sessions");
219
250
  }
220
251
  const payload = parseJsonFromStdout(result.stdout);
221
- const sessions = Array.isArray(payload?.sessions) ? payload.sessions : [];
252
+ const sessions = getRawSessionsFromPayload(payload);
222
253
  const config = readOpenclawConfig({
223
254
  fsModule: fs,
224
255
  openclawDir: OPENCLAW_DIR,
@@ -226,7 +257,7 @@ const registerSystemRoutes = ({
226
257
  });
227
258
  return sessions
228
259
  .filter((sessionRow) => {
229
- const key = String(sessionRow?.key || "").toLowerCase();
260
+ const key = getRawSessionKey(sessionRow).toLowerCase();
230
261
  if (!key) return false;
231
262
  if (
232
263
  key.includes(":hook:") ||
@@ -238,12 +269,17 @@ const registerSystemRoutes = ({
238
269
  return true;
239
270
  })
240
271
  .map((sessionRow) => {
241
- const key = String(sessionRow?.key || "");
272
+ const key = getRawSessionKey(sessionRow);
242
273
  const replyTarget = getSessionReplyTarget(key);
243
274
  return {
244
275
  key,
245
- sessionId: String(sessionRow?.sessionId || ""),
246
- updatedAt: Number(sessionRow?.updatedAt) || 0,
276
+ sessionId: String(sessionRow?.sessionId || sessionRow?.id || ""),
277
+ updatedAt:
278
+ Number(
279
+ sessionRow?.updatedAt ||
280
+ sessionRow?.lastActivityAt ||
281
+ sessionRow?.lastActiveAt,
282
+ ) || 0,
247
283
  label: buildSessionLabel(sessionRow, config),
248
284
  replyChannel: replyTarget.replyChannel,
249
285
  replyTo: replyTarget.replyTo,
@@ -2,6 +2,7 @@ const {
2
2
  listWebhooks,
3
3
  getWebhookDetail,
4
4
  createWebhook,
5
+ updateWebhookDestination,
5
6
  deleteWebhook,
6
7
  validateWebhookName,
7
8
  } = require("../webhooks");
@@ -69,6 +70,36 @@ const buildWebhookUrls = ({ baseUrl, name, oauthCallback = null }) => {
69
70
  };
70
71
  };
71
72
 
73
+ const buildOauthTransformSource = (name) => {
74
+ return [
75
+ "export default async function transform(payload, context) {",
76
+ " const data = payload.payload || payload || {};",
77
+ " const message = String(data.message || \"\").trim();",
78
+ " const code = String(data.code || \"\").trim();",
79
+ " const state = String(data.state || \"\").trim();",
80
+ " const error = String(data.error || \"\").trim();",
81
+ " const fallbackMessage = error",
82
+ " ? `OAuth callback error: ${error}`",
83
+ " : code",
84
+ " ? \"OAuth callback received (authorization code present)\"",
85
+ " : state",
86
+ " ? \"OAuth callback received (state present)\"",
87
+ " : \"OAuth callback received\";",
88
+ " return {",
89
+ " message: message || fallbackMessage,",
90
+ ` name: data.name || \"${name}\",`,
91
+ " wakeMode: data.wakeMode || \"now\",",
92
+ " oauth: {",
93
+ " code,",
94
+ " state,",
95
+ " error,",
96
+ " },",
97
+ " };",
98
+ "}",
99
+ "",
100
+ ].join("\n");
101
+ };
102
+
72
103
  const registerWebhookRoutes = ({
73
104
  app,
74
105
  fs,
@@ -171,7 +202,14 @@ const registerWebhookRoutes = ({
171
202
  oauthCallback = false,
172
203
  } = req.body || {};
173
204
  const name = validateWebhookName(rawName);
174
- const webhook = createWebhook({ fs, constants, name, destination });
205
+ const transformSource = oauthCallback ? buildOauthTransformSource(name) : "";
206
+ const webhook = createWebhook({
207
+ fs,
208
+ constants,
209
+ name,
210
+ destination,
211
+ transformSource,
212
+ });
175
213
  const oauthCallbackRecord = oauthCallback
176
214
  ? createOauthCallbackEntry({ hookName: name })
177
215
  : null;
@@ -209,6 +247,48 @@ const registerWebhookRoutes = ({
209
247
  }
210
248
  });
211
249
 
250
+ app.put("/api/webhooks/:name/destination", async (req, res) => {
251
+ try {
252
+ const name = validateWebhookName(req.params.name);
253
+ const detail = updateWebhookDestination({
254
+ fs,
255
+ constants,
256
+ name,
257
+ destination: req?.body?.destination ?? null,
258
+ });
259
+ const summary = getHookSummaries().find((item) => item.hookName === name);
260
+ const oauthCallback = getOauthCallbackByHookEntry(name);
261
+ const merged = mergeWebhookAndSummary({ webhook: detail, summary });
262
+ const baseUrl = getBaseUrl(req);
263
+ const urls = buildWebhookUrls({ baseUrl, name, oauthCallback });
264
+ const syncWarning = await runWebhookGitSync("update destination", name);
265
+ markRestartRequired("webhooks");
266
+ const snapshot = await getRestartSnapshot();
267
+ return res.json({
268
+ ok: true,
269
+ webhook: {
270
+ ...merged,
271
+ fullUrl: urls.fullUrl,
272
+ queryStringUrl: urls.queryStringUrl,
273
+ authHeaderValue: urls.authHeaderValue,
274
+ hasRuntimeToken: urls.hasRuntimeToken,
275
+ oauthCallbackId: urls.oauthCallbackId,
276
+ oauthCallbackUrl: urls.oauthCallbackUrl,
277
+ oauthCallbackCreatedAt: urls.oauthCallbackCreatedAt,
278
+ oauthCallbackRotatedAt: urls.oauthCallbackRotatedAt,
279
+ oauthCallbackLastUsedAt: urls.oauthCallbackLastUsedAt,
280
+ authNote:
281
+ "All hooks use WEBHOOK_TOKEN. Use Authorization: Bearer <token> or x-openclaw-token header.",
282
+ },
283
+ restartRequired: snapshot.restartRequired,
284
+ syncWarning,
285
+ });
286
+ } catch (err) {
287
+ const status = String(err.message || "").includes("not found") ? 404 : 400;
288
+ return res.status(status).json({ ok: false, error: err.message });
289
+ }
290
+ });
291
+
212
292
  app.post("/api/webhooks/:name/oauth-callback", (req, res) => {
213
293
  try {
214
294
  const name = validateWebhookName(req.params.name);
@@ -412,6 +412,51 @@ const createWebhook = ({
412
412
  return getWebhookDetail({ fs, constants, name: webhookName });
413
413
  };
414
414
 
415
+ const updateWebhookDestination = ({ fs, constants, name, destination = null }) => {
416
+ const webhookName = validateWebhookName(name);
417
+ const normalizedDestination = normalizeDestination(destination);
418
+ const { cfg, configPath } = readConfig({ fs, constants });
419
+ if (isManagedWebhook({ cfg, name: webhookName })) {
420
+ throw new Error(
421
+ `Webhook "${webhookName}" is managed and cannot be updated manually`,
422
+ );
423
+ }
424
+ const mappings = ensureHooksRoot(cfg);
425
+ const normalizedModulesChanged = normalizeMappingTransformModules(mappings);
426
+ const index = findMappingIndexByName(mappings, webhookName);
427
+ if (index === -1) {
428
+ throw new Error("Webhook not found");
429
+ }
430
+ const current = mappings[index] || {};
431
+ const agentId = resolveWebhookAgentId({
432
+ cfg,
433
+ requestedAgentId:
434
+ String(normalizedDestination?.agentId || "").trim() ||
435
+ String(current?.agentId || "").trim(),
436
+ });
437
+ const next = {
438
+ ...current,
439
+ deliver: true,
440
+ channel:
441
+ String(normalizedDestination?.channel || "").trim() ||
442
+ "last",
443
+ agentId,
444
+ };
445
+ if (String(normalizedDestination?.to || "").trim()) {
446
+ next.to = String(normalizedDestination.to).trim();
447
+ } else {
448
+ delete next.to;
449
+ }
450
+ const changed = JSON.stringify(current) !== JSON.stringify(next);
451
+ if (changed) {
452
+ mappings[index] = next;
453
+ }
454
+ if (changed || normalizedModulesChanged) {
455
+ writeConfig({ fs, configPath, cfg });
456
+ }
457
+ return getWebhookDetail({ fs, constants, name: webhookName });
458
+ };
459
+
415
460
  const deleteWebhook = ({ fs, constants, name, deleteTransformDir = false }) => {
416
461
  const webhookName = validateWebhookName(name);
417
462
  const { cfg, configPath } = readConfig({ fs, constants });
@@ -457,6 +502,7 @@ module.exports = {
457
502
  listWebhooks,
458
503
  getWebhookDetail,
459
504
  createWebhook,
505
+ updateWebhookDestination,
460
506
  deleteWebhook,
461
507
  validateWebhookName,
462
508
  getTransformRelativePath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.8.1-beta.2",
3
+ "version": "0.8.1-beta.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },