@chrysb/alphaclaw 0.7.2-beta.4 → 0.7.2-beta.6

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.
@@ -272,7 +272,6 @@ export const useFileViewer = ({
272
272
  );
273
273
  } catch (saveError) {
274
274
  const message = saveError.message || "Could not save file";
275
- setError(message);
276
275
  showToast(message, "error");
277
276
  } finally {
278
277
  setSaving(false);
@@ -3,15 +3,17 @@ import htm from "https://esm.sh/htm";
3
3
  import { useCallback, useEffect, useRef, useState } from "https://esm.sh/preact/hooks";
4
4
  import { ActionButton } from "../../action-button.js";
5
5
  import { Badge } from "../../badge.js";
6
+ import { ConfirmDialog } from "../../confirm-dialog.js";
6
7
  import { ComputerLineIcon, FileCopyLineIcon } from "../../icons.js";
7
8
  import { LoadingSpinner } from "../../loading-spinner.js";
8
9
  import { copyTextToClipboard } from "../../../lib/clipboard.js";
9
- import { fetchNodeBrowserStatusForNode } from "../../../lib/api.js";
10
+ import { fetchNodeBrowserStatusForNode, removeNode } from "../../../lib/api.js";
11
+ import { OverflowMenu, OverflowMenuItem } from "../../overflow-menu.js";
10
12
  import { readUiSettings, updateUiSettings } from "../../../lib/ui-settings.js";
11
13
  import { showToast } from "../../toast.js";
12
14
 
13
15
  const html = htm.bind(h);
14
- const kBrowserCheckTimeoutMs = 10000;
16
+ const kBrowserCheckTimeoutMs = 15000;
15
17
  const kBrowserPollIntervalMs = 10000;
16
18
  const kBrowserAttachStateByNodeKey = "nodesBrowserAttachStateByNode";
17
19
 
@@ -108,6 +110,7 @@ export const ConnectedNodesCard = ({
108
110
  loading = false,
109
111
  error = "",
110
112
  connectInfo = null,
113
+ onRefreshNodes = async () => {},
111
114
  }) => {
112
115
  const [browserStatusByNodeId, setBrowserStatusByNodeId] = useState({});
113
116
  const [browserErrorByNodeId, setBrowserErrorByNodeId] = useState({});
@@ -115,6 +118,9 @@ export const ConnectedNodesCard = ({
115
118
  const [browserAttachStateByNodeId, setBrowserAttachStateByNodeId] = useState(() =>
116
119
  readBrowserAttachStateByNode(),
117
120
  );
121
+ const [menuOpenNodeId, setMenuOpenNodeId] = useState("");
122
+ const [removeDialogNode, setRemoveDialogNode] = useState(null);
123
+ const [removingNodeId, setRemovingNodeId] = useState("");
118
124
  const browserPollCursorRef = useRef(0);
119
125
  const browserCheckInFlightNodeIdRef = useRef("");
120
126
 
@@ -200,6 +206,33 @@ export const ConnectedNodesCard = ({
200
206
  });
201
207
  }, [setBrowserAttachStateForNode]);
202
208
 
209
+ const handleOpenNodeMenu = useCallback((nodeId) => {
210
+ const normalizedNodeId = String(nodeId || "").trim();
211
+ if (!normalizedNodeId) return;
212
+ setMenuOpenNodeId((currentNodeId) =>
213
+ currentNodeId === normalizedNodeId ? "" : normalizedNodeId,
214
+ );
215
+ }, []);
216
+
217
+ const handleRemoveNode = useCallback(async () => {
218
+ const nodeId = String(removeDialogNode?.nodeId || "").trim();
219
+ if (!nodeId || removingNodeId) return;
220
+ setRemovingNodeId(nodeId);
221
+ try {
222
+ await removeNode(nodeId);
223
+ // Removing a device should also clear local browser-attach state for that node.
224
+ handleDetachNodeBrowser(nodeId);
225
+ showToast("Device removed", "success");
226
+ setRemoveDialogNode(null);
227
+ setMenuOpenNodeId("");
228
+ await onRefreshNodes();
229
+ } catch (removeError) {
230
+ showToast(removeError.message || "Could not remove node", "error");
231
+ } finally {
232
+ setRemovingNodeId("");
233
+ }
234
+ }, [handleDetachNodeBrowser, onRefreshNodes, removeDialogNode, removingNodeId]);
235
+
203
236
  useEffect(() => {
204
237
  if (checkingBrowserNodeId) return;
205
238
  const pollableNodeIds = nodes
@@ -310,7 +343,30 @@ export const ConnectedNodesCard = ({
310
343
  ${node?.nodeId || ""}
311
344
  </div>
312
345
  </div>
313
- ${renderNodeStatusBadge(node)}
346
+ <div class="flex items-center gap-1.5">
347
+ ${renderNodeStatusBadge(node)}
348
+ ${node?.paired
349
+ ? html`
350
+ <${OverflowMenu}
351
+ open=${menuOpenNodeId === nodeId}
352
+ ariaLabel="Open node actions"
353
+ title="Open node actions"
354
+ onClose=${() => setMenuOpenNodeId("")}
355
+ onToggle=${() => handleOpenNodeMenu(nodeId)}
356
+ >
357
+ <${OverflowMenuItem}
358
+ className="text-red-300 hover:text-red-200"
359
+ onClick=${() => {
360
+ setMenuOpenNodeId("");
361
+ setRemoveDialogNode(node);
362
+ }}
363
+ >
364
+ Remove device
365
+ </${OverflowMenuItem}>
366
+ </${OverflowMenu}>
367
+ `
368
+ : null}
369
+ </div>
314
370
  </div>
315
371
  <div class="flex flex-wrap gap-2 text-[11px] text-gray-500">
316
372
  <span>platform: <code>${node?.platform || "unknown"}</code></span>
@@ -459,5 +515,22 @@ export const ConnectedNodesCard = ({
459
515
  </div>
460
516
  `}
461
517
  </div>
518
+ <${ConfirmDialog}
519
+ visible=${!!removeDialogNode}
520
+ title="Remove device?"
521
+ message=${removeDialogNode?.connected
522
+ ? "This device is currently connected. Removing it will disconnect and remove the paired device from this gateway (equivalent to running openclaw devices remove for this device id). The device can reconnect and pair again later."
523
+ : "This removes the paired device from this gateway (equivalent to running openclaw devices remove for this device id). The device can reconnect and pair again later."}
524
+ confirmLabel="Remove device"
525
+ confirmLoadingLabel="Removing..."
526
+ confirmTone="warning"
527
+ confirmLoading=${Boolean(removingNodeId)}
528
+ confirmDisabled=${Boolean(removingNodeId)}
529
+ onCancel=${() => {
530
+ if (removingNodeId) return;
531
+ setRemoveDialogNode(null);
532
+ }}
533
+ onConfirm=${handleRemoveNode}
534
+ />
462
535
  `;
463
536
  };
@@ -40,6 +40,7 @@ export const NodesTab = ({ onRestartRequired = () => {} }) => {
40
40
  loading=${state.loadingNodes}
41
41
  error=${state.nodesError}
42
42
  connectInfo=${state.connectInfo}
43
+ onRefreshNodes=${actions.refreshNodes}
43
44
  />
44
45
 
45
46
  <${BrowserAttachCard} />
@@ -47,7 +48,6 @@ export const NodesTab = ({ onRestartRequired = () => {} }) => {
47
48
  <${NodesSetupWizard}
48
49
  visible=${state.wizardVisible}
49
50
  nodes=${state.nodes}
50
- pending=${state.pending}
51
51
  refreshNodes=${actions.refreshNodes}
52
52
  onRestartRequired=${onRestartRequired}
53
53
  onClose=${actions.closeWizard}
@@ -3,6 +3,7 @@ import htm from "https://esm.sh/htm";
3
3
  import { ModalShell } from "../../modal-shell.js";
4
4
  import { ActionButton } from "../../action-button.js";
5
5
  import { CloseIcon, FileCopyLineIcon } from "../../icons.js";
6
+ import { DevicePairings } from "../../device-pairings.js";
6
7
  import { copyTextToClipboard } from "../../../lib/clipboard.js";
7
8
  import { showToast } from "../../toast.js";
8
9
  import { useSetupWizard } from "./use-setup-wizard.js";
@@ -12,8 +13,6 @@ const html = htm.bind(h);
12
13
  const kWizardSteps = [
13
14
  "Install OpenClaw CLI",
14
15
  "Connect Node",
15
- "Approve Node",
16
- "Set Gateway Routing",
17
16
  ];
18
17
 
19
18
  const renderCommandBlock = ({ command = "", onCopy = () => {} }) => html`
@@ -44,7 +43,6 @@ const copyAndToast = async (value, label = "text") => {
44
43
  export const NodesSetupWizard = ({
45
44
  visible = false,
46
45
  nodes = [],
47
- pending = [],
48
46
  refreshNodes = async () => {},
49
47
  onRestartRequired = () => {},
50
48
  onClose = () => {},
@@ -52,13 +50,11 @@ export const NodesSetupWizard = ({
52
50
  const state = useSetupWizard({
53
51
  visible,
54
52
  nodes,
55
- pending,
56
53
  refreshNodes,
57
54
  onRestartRequired,
58
55
  onClose,
59
56
  });
60
57
  const isFinalStep = state.step === kWizardSteps.length - 1;
61
- const canApproveSelectedNode = state.selectedNode?.paired === false;
62
58
 
63
59
  return html`
64
60
  <${ModalShell}
@@ -127,101 +123,26 @@ export const NodesSetupWizard = ({
127
123
  onCopy: () =>
128
124
  copyAndToast(state.connectCommand || "", "command"),
129
125
  })}
130
- <div class="rounded-lg border border-border bg-black/20 px-3 py-2 text-xs text-gray-400">
131
- ${state.pendingSelectableNodes.length
132
- ? `${state.pendingSelectableNodes.length} pending node${state.pendingSelectableNodes.length === 1
133
- ? ""
134
- : "s"} detected. Continue to Step 3 to approve.`
135
- : "Pairing requests will show up here. Checks every 3s."}
136
- </div>
137
- </div>
138
- `
139
- : null}
140
-
141
- ${state.step === 2
142
- ? html`
143
- <div class="space-y-2">
144
- <div class="text-xs text-gray-500">
145
- Select the node to approve after you run the connect command.
146
- </div>
147
- <div class="text-xs text-gray-500">
148
- This list refreshes automatically every
149
- ${" "}
150
- <code>${Math.round(state.nodeDiscoveryPollIntervalMs / 1000)}s</code>
151
- ${" "}
152
- while this step is open.
153
- </div>
154
- <div class="flex items-center gap-2">
155
- <select
156
- value=${state.selectedNodeId}
157
- oninput=${(event) => state.setSelectedNodeId(event.target.value)}
158
- class="flex-1 min-w-0 bg-black/30 border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-gray-500 focus:outline-none"
159
- >
160
- <option value="">
161
- ${state.selectableNodes.length
162
- ? "Select node..."
163
- : "No nodes found yet"}
164
- </option>
165
- ${state.selectableNodes.map(
166
- (entry) => html`
167
- <option value=${entry.nodeId}>
168
- ${entry.displayName} (${entry.nodeId.slice(0, 12)}...)${entry.paired ===
169
- false
170
- ? " · pending approval"
171
- : " · already paired"}
172
- </option>
173
- `,
174
- )}
175
- </select>
176
- <${ActionButton}
177
- onClick=${state.refreshNodeList}
178
- idleLabel="Refresh"
179
- tone="secondary"
180
- size="sm"
181
- />
182
- </div>
183
- ${state.selectedNodeId && !canApproveSelectedNode
126
+ ${state.devicePending.length
184
127
  ? html`
185
- <div class="text-xs text-yellow-300">
186
- This node is already paired. Pick a
187
- ${" "}
188
- <code>pending approval</code>
189
- ${" "}
190
- node to continue.
191
- </div>
128
+ <${DevicePairings}
129
+ pending=${state.devicePending}
130
+ onApprove=${state.handleDeviceApprove}
131
+ onReject=${state.handleDeviceReject}
132
+ />
192
133
  `
193
- : null}
194
- </div>
195
- `
196
- : null}
197
-
198
- ${state.step === 3
199
- ? html`
200
- <div class="space-y-2">
201
- <div class="text-xs text-gray-500">
202
- This step only updates gateway routing
203
- (<code>tools.exec.host=node</code>, target node, and gateway
204
- ask/security defaults).
205
- </div>
206
- <div class="rounded-lg border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-200">
207
- Node-host permissions are local to the connected machine and are
208
- not managed from this wizard yet.
209
- </div>
210
- <${ActionButton}
211
- onClick=${async () => {
212
- const ok = await state.applyGatewayNodeRouting();
213
- if (ok) {
214
- await refreshNodes();
215
- state.completeWizard();
216
- }
217
- }}
218
- loading=${state.configuring}
219
- idleLabel="Apply Gateway Routing + Finish"
220
- loadingLabel="Applying..."
221
- tone="primary"
222
- size="sm"
223
- disabled=${!state.selectedNodeId}
224
- />
134
+ : state.selectedPairedNode && !state.selectedPairedNode.connected
135
+ ? html`
136
+ <div class="rounded-lg border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-200">
137
+ Node is paired but currently disconnected. Run the node
138
+ command again on your device, then Finish will enable.
139
+ </div>
140
+ `
141
+ : html`
142
+ <div class="rounded-lg border border-border bg-black/20 px-3 py-2 text-xs text-gray-400">
143
+ Pairing request will show up here. Checks every 3s.
144
+ </div>
145
+ `}
225
146
  </div>
226
147
  `
227
148
  : null}
@@ -241,31 +162,29 @@ export const NodesSetupWizard = ({
241
162
  ${isFinalStep
242
163
  ? html`
243
164
  <${ActionButton}
244
- onClick=${onClose}
245
- idleLabel="Close"
246
- tone="secondary"
165
+ onClick=${async () => {
166
+ const ok = await state.applyGatewayNodeRouting();
167
+ if (!ok) return;
168
+ await refreshNodes();
169
+ state.completeWizard();
170
+ }}
171
+ loading=${state.configuring}
172
+ idleLabel="Finish"
173
+ loadingLabel="Finishing..."
174
+ tone="primary"
247
175
  size="md"
248
176
  className="w-full justify-center"
177
+ disabled=${!state.canFinish}
249
178
  />
250
179
  `
251
180
  : html`
252
181
  <${ActionButton}
253
- onClick=${async () => {
254
- if (state.step === 2) {
255
- const approved = await state.approveSelectedNode();
256
- if (!approved) return;
257
- }
258
- state.setStep(Math.min(kWizardSteps.length - 1, state.step + 1));
259
- }}
260
- loading=${state.step === 2 &&
261
- state.approvingNodeId === state.selectedNodeId}
262
- idleLabel=${state.step === 2 ? "Approve" : "Next"}
263
- loadingLabel="Approving..."
182
+ onClick=${() =>
183
+ state.setStep(Math.min(kWizardSteps.length - 1, state.step + 1))}
184
+ idleLabel="Next"
264
185
  tone="primary"
265
186
  size="md"
266
187
  className="w-full justify-center"
267
- disabled=${state.step === 2 &&
268
- (!state.selectedNodeId || !canApproveSelectedNode)}
269
188
  />
270
189
  `}
271
190
  </div>
@@ -6,9 +6,11 @@ import {
6
6
  useState,
7
7
  } from "https://esm.sh/preact/hooks";
8
8
  import {
9
- approveNode,
9
+ approveDevice,
10
+ fetchDevicePairings,
10
11
  fetchNodeConnectInfo,
11
- saveNodeExecConfig,
12
+ rejectDevice,
13
+ routeExecToNode,
12
14
  } from "../../../lib/api.js";
13
15
  import { showToast } from "../../toast.js";
14
16
 
@@ -17,7 +19,6 @@ const kNodeDiscoveryPollIntervalMs = 3000;
17
19
  export const useSetupWizard = ({
18
20
  visible = false,
19
21
  nodes = [],
20
- pending = [],
21
22
  refreshNodes = async () => {},
22
23
  onRestartRequired = () => {},
23
24
  onClose = () => {},
@@ -27,15 +28,14 @@ export const useSetupWizard = ({
27
28
  const [loadingConnectInfo, setLoadingConnectInfo] = useState(false);
28
29
  const [displayName, setDisplayName] = useState("My Mac Node");
29
30
  const [selectedNodeId, setSelectedNodeId] = useState("");
30
- const [approvingNodeId, setApprovingNodeId] = useState("");
31
31
  const [configuring, setConfiguring] = useState(false);
32
+ const [devicePending, setDevicePending] = useState([]);
32
33
  const refreshInFlightRef = useRef(false);
33
34
 
34
35
  useEffect(() => {
35
36
  if (!visible) return;
36
37
  setStep(0);
37
38
  setSelectedNodeId("");
38
- setApprovingNodeId("");
39
39
  setConfiguring(false);
40
40
  }, [visible]);
41
41
 
@@ -54,38 +54,29 @@ export const useSetupWizard = ({
54
54
  });
55
55
  }, [visible]);
56
56
 
57
- const selectableNodes = useMemo(() => {
58
- const all = [
59
- ...pending.map((entry) => ({ ...entry, pendingApproval: true })),
60
- ...nodes.map((entry) => ({ ...entry, pendingApproval: false })),
61
- ];
57
+ const pairedNodes = useMemo(() => {
62
58
  const seen = new Set();
63
59
  const unique = [];
64
- for (const entry of all) {
65
- const nodeId = String(entry?.nodeId || entry?.id || "").trim();
60
+ for (const entry of nodes) {
61
+ const nodeId = String(entry?.nodeId || "").trim();
66
62
  if (!nodeId || seen.has(nodeId)) continue;
63
+ if (entry?.paired === false) continue;
67
64
  seen.add(nodeId);
68
65
  unique.push({
69
66
  nodeId,
70
67
  displayName: String(entry?.displayName || entry?.name || nodeId),
71
- paired: entry?.pendingApproval ? false : entry?.paired !== false,
72
68
  connected: entry?.connected === true,
73
69
  });
74
70
  }
75
71
  return unique;
76
- }, [nodes, pending]);
72
+ }, [nodes]);
77
73
 
78
- const pendingSelectableNodes = useMemo(
79
- () => selectableNodes.filter((entry) => entry.paired === false),
80
- [selectableNodes],
81
- );
82
-
83
- const selectedNode = useMemo(
74
+ const selectedPairedNode = useMemo(
84
75
  () =>
85
- selectableNodes.find(
76
+ pairedNodes.find(
86
77
  (entry) => entry.nodeId === String(selectedNodeId || "").trim(),
87
78
  ) || null,
88
- [selectableNodes, selectedNodeId],
79
+ [pairedNodes, selectedNodeId],
89
80
  );
90
81
 
91
82
  const connectCommand = useMemo(() => {
@@ -114,13 +105,18 @@ export const useSetupWizard = ({
114
105
  refreshInFlightRef.current = true;
115
106
  try {
116
107
  await refreshNodes();
108
+ const deviceData = await fetchDevicePairings();
109
+ const pendingList = Array.isArray(deviceData?.pending)
110
+ ? deviceData.pending
111
+ : [];
112
+ setDevicePending(pendingList);
117
113
  } finally {
118
114
  refreshInFlightRef.current = false;
119
115
  }
120
116
  }, [refreshNodes]);
121
117
 
122
118
  useEffect(() => {
123
- if (!visible || (step !== 1 && step !== 2)) return;
119
+ if (!visible || step !== 1) return;
124
120
  let active = true;
125
121
  const poll = async () => {
126
122
  if (!active) return;
@@ -137,45 +133,49 @@ export const useSetupWizard = ({
137
133
  }, [refreshNodeList, step, visible]);
138
134
 
139
135
  useEffect(() => {
140
- if (!visible || step !== 2) return;
141
- const hasSelected = selectableNodes.some(
136
+ if (!visible || step !== 1) return;
137
+ const hasSelected = pairedNodes.some(
142
138
  (entry) => entry.nodeId === String(selectedNodeId || "").trim(),
143
139
  );
144
- if (hasSelected) return;
140
+ const normalizedDisplayName = String(displayName || "").trim().toLowerCase();
145
141
  const preferredNode =
146
- pendingSelectableNodes.find((entry) => entry.paired === false) || selectableNodes[0];
142
+ pairedNodes.find(
143
+ (entry) =>
144
+ String(entry?.displayName || "")
145
+ .trim()
146
+ .toLowerCase() === normalizedDisplayName,
147
+ ) || pairedNodes[0];
147
148
  if (!preferredNode) return;
149
+ if (hasSelected && String(selectedNodeId || "").trim() === preferredNode.nodeId) return;
148
150
  setSelectedNodeId(preferredNode.nodeId);
149
- }, [pendingSelectableNodes, selectableNodes, selectedNodeId, step, visible]);
151
+ }, [displayName, pairedNodes, selectedNodeId, step, visible]);
150
152
 
151
- const approveSelectedNode = useCallback(async () => {
152
- const nodeId = String(selectedNodeId || "").trim();
153
- if (!nodeId || approvingNodeId) return false;
154
- setApprovingNodeId(nodeId);
153
+ const handleDeviceApprove = useCallback(async (requestId) => {
155
154
  try {
156
- await approveNode(nodeId);
157
- showToast("Node approved", "success");
158
- await refreshNodes();
159
- return true;
155
+ await approveDevice(requestId);
156
+ showToast("Pairing approved", "success");
157
+ await refreshNodeList();
160
158
  } catch (err) {
161
- showToast(err.message || "Could not approve node", "error");
162
- return false;
163
- } finally {
164
- setApprovingNodeId("");
159
+ showToast(err.message || "Could not approve pairing", "error");
160
+ }
161
+ }, [refreshNodeList]);
162
+
163
+ const handleDeviceReject = useCallback(async (requestId) => {
164
+ try {
165
+ await rejectDevice(requestId);
166
+ showToast("Pairing rejected", "info");
167
+ await refreshNodeList();
168
+ } catch (err) {
169
+ showToast(err.message || "Could not reject pairing", "error");
165
170
  }
166
- }, [approvingNodeId, refreshNodes, selectedNodeId]);
171
+ }, [refreshNodeList]);
167
172
 
168
173
  const applyGatewayNodeRouting = useCallback(async () => {
169
174
  const nodeId = String(selectedNodeId || "").trim();
170
175
  if (!nodeId || configuring) return false;
171
176
  setConfiguring(true);
172
177
  try {
173
- await saveNodeExecConfig({
174
- host: "node",
175
- security: "allowlist",
176
- ask: "on-miss",
177
- node: nodeId,
178
- });
178
+ await routeExecToNode(nodeId);
179
179
  onRestartRequired(true);
180
180
  showToast("Gateway routing now points to the selected node", "success");
181
181
  return true;
@@ -200,15 +200,16 @@ export const useSetupWizard = ({
200
200
  setDisplayName,
201
201
  selectedNodeId,
202
202
  setSelectedNodeId,
203
- selectedNode,
204
- selectableNodes,
205
- pendingSelectableNodes,
206
- approvingNodeId,
203
+ pairedNodes,
204
+ selectedPairedNode,
205
+ devicePending,
207
206
  configuring,
207
+ canFinish: Boolean(selectedPairedNode?.connected),
208
208
  connectCommand,
209
209
  refreshNodeList,
210
210
  nodeDiscoveryPollIntervalMs: kNodeDiscoveryPollIntervalMs,
211
- approveSelectedNode,
211
+ handleDeviceApprove,
212
+ handleDeviceReject,
212
213
  applyGatewayNodeRouting,
213
214
  completeWizard,
214
215
  };
@@ -8,6 +8,9 @@ export const useNodesTab = () => {
8
8
  const [wizardVisible, setWizardVisible] = useState(false);
9
9
  const [connectInfo, setConnectInfo] = useState(null);
10
10
  const [refreshingNodes, setRefreshingNodes] = useState(false);
11
+ const pairedNodes = Array.isArray(connectedNodesState.nodes)
12
+ ? connectedNodesState.nodes.filter((entry) => entry?.paired !== false)
13
+ : [];
11
14
 
12
15
  useEffect(() => {
13
16
  fetchNodeConnectInfo()
@@ -32,7 +35,7 @@ export const useNodesTab = () => {
32
35
  return {
33
36
  state: {
34
37
  wizardVisible,
35
- nodes: connectedNodesState.nodes,
38
+ nodes: pairedNodes,
36
39
  pending: connectedNodesState.pending,
37
40
  loadingNodes: connectedNodesState.loading,
38
41
  refreshingNodes,
@@ -679,6 +679,34 @@ export const approveNode = async (nodeId) => {
679
679
  return parseJsonOrThrow(res, "Could not approve node");
680
680
  };
681
681
 
682
+ export const removeNode = async (nodeId) => {
683
+ const safeNodeId = encodeURIComponent(String(nodeId || ""));
684
+ const res = await authFetch(`/api/nodes/${safeNodeId}`, {
685
+ method: "DELETE",
686
+ });
687
+ return parseJsonOrThrow(res, "Could not remove node");
688
+ };
689
+
690
+ export const routeExecToNode = async (nodeId) => {
691
+ const safeNodeId = encodeURIComponent(String(nodeId || ""));
692
+ const controller = new AbortController();
693
+ const timeoutId = setTimeout(() => controller.abort(), 20000);
694
+ try {
695
+ const res = await authFetch(`/api/nodes/${safeNodeId}/route`, {
696
+ method: "POST",
697
+ signal: controller.signal,
698
+ });
699
+ return parseJsonOrThrow(res, "Could not route execution to node");
700
+ } catch (error) {
701
+ if (String(error?.name || "") === "AbortError") {
702
+ throw new Error("Routing timed out. Gateway may be restarting or unavailable.");
703
+ }
704
+ throw error;
705
+ } finally {
706
+ clearTimeout(timeoutId);
707
+ }
708
+ };
709
+
682
710
  export const fetchNodeConnectInfo = async () => {
683
711
  const res = await authFetch("/api/nodes/connect-info");
684
712
  return parseJsonOrThrow(res, "Could not load connect info");
@@ -1132,10 +1160,12 @@ export const fetchFileContent = async (filePath) => {
1132
1160
  };
1133
1161
 
1134
1162
  export const saveFileContent = async (filePath, content) => {
1163
+ const normalizedPath = String(filePath || "");
1164
+ const normalizedContent = typeof content === "string" ? content : String(content ?? "");
1135
1165
  const res = await authFetch("/api/browse/write", {
1136
1166
  method: "PUT",
1137
1167
  headers: { "Content-Type": "application/json" },
1138
- body: JSON.stringify({ path: filePath, content }),
1168
+ body: JSON.stringify({ path: normalizedPath, content: normalizedContent }),
1139
1169
  });
1140
1170
  return parseJsonOrThrow(res, "Could not save file");
1141
1171
  };
@@ -7,8 +7,9 @@ const kAllowedExecHosts = new Set(["gateway", "node"]);
7
7
  const kAllowedExecSecurity = new Set(["deny", "allowlist", "full"]);
8
8
  const kAllowedExecAsk = new Set(["off", "on-miss", "always"]);
9
9
  const kSafeNodeIdPattern = /^[\w\-:.]+$/;
10
- const kNodeBrowserInvokeTimeoutMs = 5000;
11
- const kNodeBrowserCliTimeoutMs = 9000;
10
+ const kNodeBrowserInvokeTimeoutMs = 15000;
11
+ const kNodeBrowserCliTimeoutMs = 18000;
12
+ const kNodeRouteCliTimeoutMs = 12000;
12
13
 
13
14
  const quoteCliArg = (value) => quoteShellArg(value, { strategy: "single" });
14
15
 
@@ -34,6 +35,31 @@ const parseNodesStatus = (stdout) => {
34
35
  return { nodes, pending };
35
36
  };
36
37
 
38
+ const parseNodesPending = (stdout) => {
39
+ const parsed = parseJsonObjectFromNoisyOutput(stdout) || {};
40
+ const list = Array.isArray(parsed.pending)
41
+ ? parsed.pending
42
+ : Array.isArray(parsed.requests)
43
+ ? parsed.requests
44
+ : Array.isArray(parsed.nodes)
45
+ ? parsed.nodes
46
+ : [];
47
+ return list
48
+ .map((entry) => {
49
+ if (!entry || typeof entry !== "object") return null;
50
+ const requestId = String(entry.requestId || entry.id || "").trim();
51
+ const nodeId = String(entry.nodeId || requestId).trim();
52
+ if (!nodeId) return null;
53
+ return {
54
+ ...entry,
55
+ id: requestId || nodeId,
56
+ nodeId,
57
+ paired: false,
58
+ };
59
+ })
60
+ .filter(Boolean);
61
+ };
62
+
37
63
  const parseNodeBrowserStatus = (stdout) => {
38
64
  const parsed = parseJsonObjectFromNoisyOutput(stdout) || {};
39
65
  const payload =
@@ -146,15 +172,29 @@ const registerNodeRoutes = ({
146
172
  fsModule,
147
173
  }) => {
148
174
  app.get("/api/nodes", async (_req, res) => {
149
- const result = await clawCmd("nodes status --json", { quiet: true });
150
- if (!result.ok) {
175
+ const statusResult = await clawCmd("nodes status --json", { quiet: true });
176
+ if (!statusResult.ok) {
151
177
  return res.status(500).json({
152
178
  ok: false,
153
- error: result.stderr || "Could not load nodes status",
179
+ error: statusResult.stderr || "Could not load nodes status",
154
180
  });
155
181
  }
156
- const status = parseNodesStatus(result.stdout);
157
- return res.json({ ok: true, ...status });
182
+ const status = parseNodesStatus(statusResult.stdout);
183
+ const pendingResult = await clawCmd("nodes pending --json", { quiet: true });
184
+ const pending = pendingResult.ok
185
+ ? parseNodesPending(pendingResult.stdout)
186
+ : status.pending;
187
+ const pendingById = new Map();
188
+ for (const entry of pending) {
189
+ const nodeId = String(entry?.nodeId || entry?.id || "").trim();
190
+ if (!nodeId || pendingById.has(nodeId)) continue;
191
+ pendingById.set(nodeId, entry);
192
+ }
193
+ return res.json({
194
+ ok: true,
195
+ nodes: status.nodes,
196
+ pending: Array.from(pendingById.values()),
197
+ });
158
198
  });
159
199
 
160
200
  app.post("/api/nodes/:id/approve", async (req, res) => {
@@ -172,6 +212,51 @@ const registerNodeRoutes = ({
172
212
  return res.json({ ok: true });
173
213
  });
174
214
 
215
+ app.post("/api/nodes/:id/route", async (req, res) => {
216
+ const nodeId = String(req.params.id || "").trim();
217
+ if (!nodeId || !kSafeNodeIdPattern.test(nodeId)) {
218
+ return res.status(400).json({ ok: false, error: "Invalid node id" });
219
+ }
220
+ const commands = [
221
+ "config set tools.exec.host 'node'",
222
+ "config set tools.exec.security 'allowlist'",
223
+ "config set tools.exec.ask 'on-miss'",
224
+ `config set tools.exec.node ${quoteCliArg(nodeId)}`,
225
+ ];
226
+ for (const command of commands) {
227
+ const result = await clawCmd(command, {
228
+ quiet: true,
229
+ timeoutMs: kNodeRouteCliTimeoutMs,
230
+ });
231
+ if (!result.ok) {
232
+ return res.status(500).json({
233
+ ok: false,
234
+ error:
235
+ result.stderr ||
236
+ `Could not apply node routing (${command})`,
237
+ });
238
+ }
239
+ }
240
+ return res.json({ ok: true, restartRequired: true, nodeId });
241
+ });
242
+
243
+ app.delete("/api/nodes/:id", async (req, res) => {
244
+ const nodeId = String(req.params.id || "").trim();
245
+ if (!nodeId || !kSafeNodeIdPattern.test(nodeId)) {
246
+ return res.status(400).json({ ok: false, error: "Invalid node id" });
247
+ }
248
+ const result = await clawCmd(`devices remove ${quoteCliArg(nodeId)}`, {
249
+ quiet: true,
250
+ });
251
+ if (!result.ok) {
252
+ return res.status(500).json({
253
+ ok: false,
254
+ error: result.stderr || "Could not remove node",
255
+ });
256
+ }
257
+ return res.json({ ok: true, nodeId });
258
+ });
259
+
175
260
  app.get("/api/nodes/connect-info", async (req, res) => {
176
261
  const baseUrl = resolveSetupUiBaseUrl(req);
177
262
  const parsed = parseBaseUrlParts(baseUrl);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.7.2-beta.4",
3
+ "version": "0.7.2-beta.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -31,7 +31,7 @@
31
31
  "dependencies": {
32
32
  "express": "^4.21.0",
33
33
  "http-proxy": "^1.18.1",
34
- "openclaw": "2026.3.12",
34
+ "openclaw": "2026.3.13",
35
35
  "ws": "^8.19.0"
36
36
  },
37
37
  "devDependencies": {