@flame0510/project-aether 1.4.0 → 1.4.2

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.
@@ -770,6 +770,14 @@ function currentPortRange(ports: string): string {
770
770
  return min === max ? `${min}` : `${min}-${max}`;
771
771
  }
772
772
 
773
+ /** The agent's own published host ports, from the `3700:3000, 3701:3001` string. */
774
+ function ownHostPorts(ports: string): number[] {
775
+ return (ports || '')
776
+ .split(',')
777
+ .map((p) => parseInt(p.trim().split(':')[0] ?? '', 10))
778
+ .filter((n) => Number.isInteger(n));
779
+ }
780
+
773
781
  function EditAgentModal({ agent, onClose, onSaved, onStarted }: {
774
782
  agent: Agent;
775
783
  onClose: () => void;
@@ -780,20 +788,37 @@ function EditAgentModal({ agent, onClose, onSaved, onStarted }: {
780
788
  }) {
781
789
  const initialName = agent.displayName || agent.name;
782
790
  const initialPorts = currentPortRange(agent.ports);
791
+ const ownPorts = useMemo(() => ownHostPorts(agent.ports), [agent.ports]);
783
792
  const [displayName, setDisplayName] = useState(initialName);
784
793
  const [portRange, setPortRange] = useState(initialPorts);
785
794
  const [saving, setSaving] = useState(false);
786
795
  const [error, setError] = useState('');
796
+ // The ports other containers hold, fetched when the panel opens: the same set the
797
+ // create form gets as a page prop, from the same lookup (`GET /api/agents/ports`).
798
+ const [usedPorts, setUsedPorts] = useState<Set<number> | null>(null);
799
+
800
+ useEffect(() => {
801
+ const controller = new AbortController();
802
+ fetch('/api/agents/ports', { signal: controller.signal })
803
+ .then((res) => (res.ok ? res.json() : null))
804
+ .then((data: { ports?: number[] } | null) => {
805
+ if (data && Array.isArray(data.ports)) setUsedPorts(new Set(data.ports));
806
+ })
807
+ .catch((e: unknown) => { if ((e as Error)?.name !== 'AbortError') setUsedPorts(null); });
808
+ return () => controller.abort();
809
+ }, []);
787
810
 
788
- // Client-side shape checks (the same messages as the API). Whether a port is already
789
- // published by another agent is checked by the server, which knows every container
790
- // and excludes this agent's own block.
811
+ // The same rules and messages as agent creation (lib/agent-ports.ts). This agent's own
812
+ // block is excluded, so keeping or shifting it is not a conflict with itself; every
813
+ // other container counts, running or stopped.
791
814
  const nameError = displayName.trim() ? '' : 'Display name must not be empty';
792
815
  const portError = useMemo(() => {
793
816
  if (!portRange.trim()) return '';
794
- const r = validatePortInput(portRange);
817
+ const others = new Set(usedPorts ?? []);
818
+ for (const p of ownPorts) others.delete(p);
819
+ const r = validatePortInput(portRange, others);
795
820
  return r.valid ? '' : r.error;
796
- }, [portRange]);
821
+ }, [portRange, usedPorts, ownPorts]);
797
822
 
798
823
  const nameChanged = displayName.trim() !== initialName;
799
824
  const portsChanged = portRange.trim() !== initialPorts;
@@ -882,7 +907,7 @@ function EditAgentModal({ agent, onClose, onSaved, onStarted }: {
882
907
  {error && <div style={{ fontSize: 11, color: '#ef4444' }}>{error}</div>}
883
908
  <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
884
909
  <Button variant="secondary" size="sm" onClick={onClose} disabled={saving}>Cancel</Button>
885
- <Button variant="primary" size="sm" onClick={handleSave} disabled={saving}>
910
+ <Button variant="primary" size="sm" onClick={handleSave} disabled={saving || !!nameError || !!portError}>
886
911
  {saving ? 'Saving...' : 'Save & Recreate'}
887
912
  </Button>
888
913
  </div>
@@ -60,6 +60,20 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
60
60
  const [selectedTemplate, setSelectedTemplate] = useState<string>('');
61
61
  const [agentName, setAgentName] = useState('');
62
62
  const [portRange, setPortRange] = useState<string>('');
63
+ // The server render seeds this; the same endpoint the Edit panel uses refreshes it, so
64
+ // both flows validate against the same numbers (every container, running or stopped).
65
+ const [usedPorts, setUsedPorts] = useState<number[]>(initialUsedPorts);
66
+
67
+ useEffect(() => {
68
+ const controller = new AbortController();
69
+ fetch('/api/agents/ports', { signal: controller.signal })
70
+ .then((res) => (res.ok ? res.json() : null))
71
+ .then((data: { ports?: number[] } | null) => {
72
+ if (data && Array.isArray(data.ports)) setUsedPorts(data.ports);
73
+ })
74
+ .catch(() => { /* the server still validates on submit */ });
75
+ return () => controller.abort();
76
+ }, []);
63
77
  const [selectedModel, setSelectedModel] = useState<string>(
64
78
  models.length > 0 ? models[0].id : ''
65
79
  );
@@ -73,11 +87,11 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
73
87
  setSelectedTemplate(id);
74
88
  setAgentName('');
75
89
  // Auto-assign port block: find first free 10-port block
76
- if (initialUsedPorts.length === 0) {
90
+ if (usedPorts.length === 0) {
77
91
  setPortRange('3700-3709');
78
92
  } else {
79
- // Simple client-side block finder
80
- const block = initialUsedPorts.length ? findAvailablePortBlock(new Set(initialUsedPorts)) : null;
93
+ // Same block finder as the server: the first free 10-port block.
94
+ const block = findAvailablePortBlock(new Set(usedPorts));
81
95
  setPortRange(block ? `${block}-${block + 9}` : '');
82
96
  }
83
97
  };
@@ -91,9 +105,9 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
91
105
 
92
106
  const portError = useMemo(() => {
93
107
  if (!portRange.trim()) return '';
94
- const res = validatePortInput(portRange, new Set(initialUsedPorts));
108
+ const res = validatePortInput(portRange, new Set(usedPorts));
95
109
  return res.valid ? '' : res.error;
96
- }, [portRange, initialUsedPorts]);
110
+ }, [portRange, usedPorts]);
97
111
 
98
112
  const canProceed = (): boolean => {
99
113
  if (step === 'template') return selectedTemplate !== '';
@@ -2,6 +2,8 @@ import { execSync } from 'child_process';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import { DB_FILE, AGENTS_TOKEN_FILE, templatesDir } from '@/lib/rev4a-paths';
5
+ import { REV4A_PORT } from '@/lib/agent-ports';
6
+ import { getUsedHostPorts } from '@/lib/agent-ports-server';
5
7
  import { loadOfferedModels } from '@/lib/model-catalogue';
6
8
  import CreatePageClient from './PageClient';
7
9
 
@@ -45,19 +47,21 @@ async function getModels(): Promise<ModelInfo[]> {
45
47
  async function getUsedNames(): Promise<string[]> {
46
48
  try {
47
49
  const raw = execSync(
48
- `docker ps --filter "label=AGENT_ID" --format '{{.Names}}'`,
50
+ `docker ps -a --filter "label=AGENT_ID" --format '{{.Names}}'`,
49
51
  { encoding: 'utf-8', timeout: 5000 },
50
52
  ).trim();
51
53
  if (!raw) return [];
52
54
 
53
- // Read AGENT_NAME env from each container (display name)
55
+ // Read AGENT_NAME from each container's inspect: a stopped agent still owns its
56
+ // display name, and `docker exec` cannot reach a stopped container.
54
57
  const names: string[] = [];
55
58
  for (const name of raw.split('\n').filter(Boolean)) {
56
59
  try {
57
- const displayName = execSync(
58
- `docker exec ${name} sh -c 'echo $AGENT_NAME'`,
60
+ const env = execSync(
61
+ `docker inspect ${name} --format '{{range .Config.Env}}{{println .}}{{end}}'`,
59
62
  { encoding: 'utf-8', timeout: 3000 },
60
- ).trim();
63
+ );
64
+ const displayName = env.match(/^AGENT_NAME=(.*)$/m)?.[1]?.trim();
61
65
  if (displayName) names.push(displayName);
62
66
  } catch { /* skip */ }
63
67
  }
@@ -68,29 +72,11 @@ async function getUsedNames(): Promise<string[]> {
68
72
  }
69
73
 
70
74
  async function getUsedPorts(): Promise<number[]> {
71
- try {
72
- const raw = execSync(
73
- `docker ps --format '{{.Ports}}'`,
74
- { encoding: 'utf-8', timeout: 5000 },
75
- ).trim();
76
- const ports: number[] = [];
77
- if (raw) {
78
- for (const line of raw.split('\n')) {
79
- const matches = line.match(/(\d+)->/g);
80
- if (matches) {
81
- matches.forEach((m) => {
82
- const p = parseInt(m.replace('->', ''), 10);
83
- if (!isNaN(p)) ports.push(p);
84
- });
85
- }
86
- }
87
- }
88
- // Add Rev4a port as reserved so the client won't suggest it
89
- ports.push(3740);
90
- return [...new Set(ports)];
91
- } catch {
92
- return [3740];
93
- }
75
+ // One source for port occupancy (lib/agent-ports-server.ts): it reads every
76
+ // container's bindings, so the block of a stopped agent is offered to nobody.
77
+ const ports = await getUsedHostPorts();
78
+ ports.add(REV4A_PORT);
79
+ return [...ports];
94
80
  }
95
81
 
96
82
  export default async function CreateAgentPage() {
@@ -123,25 +123,27 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
123
123
  );
124
124
  }
125
125
 
126
- // Check if name is already in use by a running container
126
+ // Check if name is already in use by a container, running or not: a stopped agent
127
+ // keeps its name and its port block, so both would clash on start.
127
128
  const existing = execSync(
128
- `docker ps --filter "label=AGENT_ID=${containerName}" --format '{{.Names}}'`,
129
+ `docker ps -a --filter "label=AGENT_ID=${containerName}" --format '{{.Names}}'`,
129
130
  { encoding: 'utf-8', timeout: 5000 },
130
131
  ).trim();
131
132
  if (existing) {
132
133
  return NextResponse.json(
133
- { success: false, error: `Agent '${displayName}' is already running (container: ${existing})` },
134
+ { success: false, error: `Agent '${displayName}' already exists (container: ${existing})` },
134
135
  { status: 409 },
135
136
  );
136
137
  }
137
138
 
138
- // Resolve port range
139
+ // Resolve port range. One lookup, shared by both paths below.
140
+ const usedPorts = await getUsedHostPorts();
139
141
  let isBlock = false;
140
142
  let portStart: number = 0;
141
143
  let portEnd: number = 0;
142
144
 
143
145
  if (portInput) {
144
- const v = validatePortInput(portInput, getUsedHostPorts());
146
+ const v = validatePortInput(portInput, usedPorts);
145
147
  if (!v.valid) {
146
148
  return NextResponse.json(
147
149
  { success: false, error: v.error },
@@ -152,7 +154,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
152
154
  portStart = v.start;
153
155
  portEnd = v.end;
154
156
  } else {
155
- portStart = findAvailablePortBlock(getUsedHostPorts());
157
+ portStart = findAvailablePortBlock(usedPorts);
156
158
  portEnd = portStart + DEFAULT_BLOCK_SIZE - 1;
157
159
  isBlock = true;
158
160
  }
@@ -0,0 +1,19 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
3
+ import { getUsedHostPorts } from '@/lib/agent-ports-server';
4
+
5
+ export const dynamic = 'force-dynamic';
6
+
7
+ /**
8
+ * GET /api/agents/ports
9
+ *
10
+ * The host ports held by any container, running or stopped — the set the port validators
11
+ * in lib/agent-ports.ts take. The create form receives it as a page prop; the Edit panel
12
+ * fetches it when it opens, so both flows validate a range against the same numbers and
13
+ * the same rules, and give the same message.
14
+ */
15
+ export async function GET(request: Request): Promise<NextResponse> {
16
+ const denied = await requireAuthJWT(request); if (denied) return denied as any;
17
+ const ports = await getUsedHostPorts();
18
+ return NextResponse.json({ ports: [...ports].sort((a, b) => a - b) });
19
+ }
@@ -15,7 +15,8 @@ export const dynamic = 'force-dynamic';
15
15
  * GET /api/version
16
16
  *
17
17
  * Returns the installed Rev4a version from package.json.
18
- * No auth required.
18
+ *
19
+ * Auth: the rev4a_token cookie or Authorization: Bearer <REV4A_TOKEN>.
19
20
  */
20
21
  export async function GET(request: NextRequest): Promise<NextResponse> {
21
22
  const denied = await requireAuthJWT(request); if (denied) return denied as any;
@@ -170,13 +170,29 @@ The central container, running the Next.js dashboard + orchestration API.
170
170
  `prerecreate` archives are pruned to the newest two. The same `recreateAgentContainer()`
171
171
  serves the edit route (`PATCH` → 202, no backup) and the Update action.
172
172
  `agentBusyReason()` (`lib/agent-busy.ts`) covers it for every route that changes an agent.
173
- - **Host ports** are shared between creation and the edit flow by `lib/agent-ports.ts`
174
- (pure: `validatePortInput`, `findAvailablePortBlock`, `portMappingArgs`) and
175
- `lib/agent-ports-server.ts` (`getUsedHostPorts()`, the Docker lookup, server-only since
176
- client components cannot import `child_process`). One block maps to the container's
177
- gateway port 3000 (`3700-3709` → `3700-3709:3000-3009`); both flows produce the same
178
- validation messages, and the edit excludes the agent's own ports so its block never
179
- conflicts with itself.
173
+ - **Host ports** have one source of truth: `lib/agent-ports.ts` holds the pure rules
174
+ (`validatePortInput`, `findAvailablePortBlock`, `portMappingArgs`,
175
+ `hostPortsFromBindings`, `hostPortsFromArgs`) and `lib/agent-ports-server.ts` answers
176
+ which ports are taken (`getHostPortHolders()`, `getUsedHostPorts()`; server-only, it
177
+ talks to the Docker socket). Creation, the create form, the edit flow and every
178
+ container rebuild (update, recreate, rollback) read their numbers from there. One block
179
+ maps to the container's gateway port 3000 (`3700-3709` → `3700-3709:3000-3009`); all
180
+ flows produce the same validation messages, and a flow rebuilding a container excludes
181
+ that container's own ports, so its block never conflicts with itself.
182
+ **A stopped container still holds its block**, but neither `docker ps -a --format
183
+ '{{.Ports}}'` nor the Docker API's container list reports its ports (verified on Docker
184
+ 29: both return nothing). The lookup therefore reads each container's
185
+ `HostConfig.PortBindings` — the only source covering the stopped ones. Without that, a
186
+ new agent could be handed the block of a stopped agent, and the clash only surfaced
187
+ later, when that agent was started again (`Bind for 0.0.0.0:3730 failed: port is
188
+ already allocated`), after the container it was replacing had been removed.
189
+ `recreateAgentContainer()` refuses such a clash **before** removing anything, naming the
190
+ container that holds each port, so the agent stays up and the operator changes the range
191
+ from the Edit panel; a failed `docker run` no longer leaves a container behind.
192
+ The two forms validate a range **as it is typed** against that same set — the create
193
+ wizard through its page prop plus `GET /api/agents/ports`, the Edit panel through the
194
+ endpoint when it opens — so both show the same message and disable their action while
195
+ the range is refused, instead of letting the server refuse it on submit.
180
196
  - **Agent edits** are `lib/agent-edit.ts`, with their state in the `agent_edits` table
181
197
  (`lib/agent-edit-state.ts`): the display name (`AGENT_NAME`) and/or the port range are
182
198
  applied by rebuilding the container on the image it already runs — **no backup**, the
@@ -48,7 +48,7 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
48
48
  | `ImageDownloadBanner` | `app/agents/ImageDownloadBanner.tsx` | Agent image banner on the Agents page. Polls `/api/agents/image-status` every 2 s; offers **Download Image** when no supported version is downloaded, **Download <version>** when the registry publishes a newer one, and shows the download in progress and its completion. Downloading changes no agent. |
49
49
  | `BrowserAccessSection` / `OpenControlUiButton` | `app/agents/BrowserAccessSection.tsx` | Browser access to one agent's Control UI, in its detail panel: requests waiting for approval (Approve / Reject) and approved browsers (Rename / Revoke), refreshed every 5 s while mounted. A successful approve, reject, rename or revoke updates the list at once, since the refresh behind it runs the OpenClaw CLI and takes seconds; a read started before the mutation is discarded. On agents that require approval, "Invite link" fetches `/api/agents/[id]/invite-link` and shows the link in a read-only field with Copy, which uses the Clipboard API in a secure context and the field's selection over plain HTTP, plus a warning when the link uses localhost. `OpenControlUiButton` opens `/api/agents/[id]/open-control-ui` in a new tab inside the click; that route redirects to a one-time link that pairs the browser with no approval, or to the plain token link when none can be issued. Used on the agent cards and in the panel. |
50
50
  | `ChannelManager` / `ChannelSection` | `app/agents/ChannelManager.tsx`, `ChannelSection` inline in `app/agents/PageClient.tsx` | Telegram, in the agent detail panel (`ChannelSection` is the card that opens the modal; the modal title is the agent's display name). Reads `GET /channels`; lists pending pairing requests with **Approve** (`POST /channels/pairing`) and approved senders with **Revoke** after a confirm (`DELETE /channels/pairing?senderId=`). Pending comes from `openclaw pairing list`, approved from OpenClaw's pairing store (`lib/channelManager.ts`). Polls pairings every 5 s while open; one keyed busy state per action. |
51
- | `EditAgentModal` / `EditBanner` | `app/agents/PageClient.tsx` | Rename/ports editing. The modal has **Display Name** and **Host Port** (same input and validation messages as the create wizard, from `lib/agent-ports.ts`), sends `PATCH /api/agents/[id]` and closes on `202`; `EditBanner` (top of the agent detail) polls `GET /api/agents/[id]` every 2 s while an edit is `rebuilding`, resumes on mount, and refetches the agent once it finishes, so a reload or a navigation shows the running edit instead of allowing a second (the server answers 409). No backup is taken: the volume is untouched. |
51
+ | `EditAgentModal` / `EditBanner` | `app/agents/PageClient.tsx` | Rename/ports editing. The modal has **Display Name** and **Host Port** (same input and validation messages as the create wizard, from `lib/agent-ports.ts`), sends `PATCH /api/agents/[id]` and closes on `202`; `EditBanner` (top of the agent detail) polls `GET /api/agents/[id]` every 2 s while an edit is `rebuilding`, resumes on mount, and refetches the agent once it finishes, so a reload or a navigation shows the running edit instead of allowing a second (the server answers 409). No backup is taken: the volume is untouched. The port range is validated **as it is typed**: the modal fetches `GET /api/agents/ports` when it opens (the same set the create form uses, every container, running or stopped) and excludes the agent's own block, so an occupied range shows the API's message inline and the Save button stays disabled — the create wizard does the same with the same endpoint. |
52
52
  | `ModelSection` | `app/agents/ModelSection.tsx` | Primary model and fallbacks for one agent, in its detail panel. Explicit save, no restart. The model is a property of the agent, not of the gateway. Tags models the catalogue marks `deprecated`. |
53
53
  | `ModelsProvider` / `useModels` | `app/lib/models-context.tsx` | The client's single model list, from `/api/models`. Whatever changes what is offered calls `refresh()`: the Gateway page after a toggle, a key save or removal, or a sync, and the first-run wizard after saving keys. Everything else only reads, PulseChat included. |
54
54
  | `WizardPageClient` + step components | `app/wizard/PageClient.tsx` | Multi-step first-run wizard plus its frame shell, with mobile-first CSS. |
@@ -704,6 +704,27 @@ List running agent containers (Docker containers with `AGENT_ID` label) with ful
704
704
 
705
705
  ---
706
706
 
707
+ ### `GET /api/agents/ports`
708
+ The host ports held by any container, running or stopped: the set the validators in
709
+ `lib/agent-ports.ts` take, sorted ascending. Backed by the shared lookup
710
+ (`getUsedHostPorts()`, `lib/agent-ports-server.ts`), which reads every container's
711
+ `HostConfig.PortBindings` — a stopped container keeps its block while `docker ps -a` and
712
+ the API's container list report nothing for it.
713
+
714
+ **Auth:** `rev4a_token` cookie or `Authorization: Bearer <REV4A_TOKEN>`
715
+
716
+ **Response:**
717
+ ```json
718
+ { "ports": [3700, 3701, 3710, 3711, 3741, 5555] }
719
+ ```
720
+
721
+ The Edit panel fetches it when it opens and the create form refreshes it on mount (the
722
+ page also receives it as a prop), so both validate a range as the operator types, against
723
+ the same numbers and with the same messages, and both disable their action while the range
724
+ is refused.
725
+
726
+ ---
727
+
707
728
  ### `GET /api/agents/models-summary`
708
729
  One row per agent container, running or not: the model it runs and whether that
709
730
  model is still in good standing. Feeds the model chip on the agent cards. Running
@@ -795,7 +816,7 @@ Create a new agent container from a template.
795
816
  |---|---|---|
796
817
  | `name` | yes | Display name (`AGENT_NAME`); the container name and `AGENT_ID` are generated (`agent_<hex>`) |
797
818
  | `template` | yes | Template name (directory in `agent-templates/`) |
798
- | `portRange` | no | Optional port range (e.g. `3700-3709`) or single port (e.g. `3700`). Default: auto-assigned 10-port block |
819
+ | `portRange` | no | Optional port range (e.g. `3700-3709`) or single port (e.g. `3700`). Default: auto-assigned 10-port block, chosen by `findAvailablePortBlock()` |
799
820
  | `model` | no | Primary model id. Must be offered by this deployment (in the catalogue, enabled, provider has a key), in any form the proxy accepts; otherwise `400`. Defaults to `rev4a/deepseek/deepseek-flash`, validated the same way |
800
821
  | `fallbacks` | no | Array of fallback model IDs |
801
822
 
@@ -804,6 +825,9 @@ Create a new agent container from a template.
804
825
  - Single port: `-p <port>:3000` — maps a single host port to container port 3000
805
826
  - Control UI: the wizard's Open button goes through `GET /api/agents/[id]/open-control-ui`, like the Agents page
806
827
  - Port 3740 is reserved for Rev4a
828
+ - A port held by any container, running or stopped, is refused (`400`): the shared lookup
829
+ reads every container's bindings (`lib/agent-ports-server.ts`), because a stopped
830
+ container keeps its allocation while `docker ps -a` and the API's list report none
807
831
  - Image: `openclaw-agent-base:<version>`, the newest supported OpenClaw version downloaded here; `409` when none is
808
832
 
809
833
  **Response:**
@@ -886,12 +910,23 @@ touch the agent answers `409`.
886
910
 
887
911
  `portRange` is `"<start>"` or `"<start>-<end>"`, mapped onto the gateway port 3000
888
912
  (`3700-3709` → `3700-3709:3000-3009`). It is validated with the same rules and messages as
889
- agent creation (`lib/agent-ports.ts`): a range that holds Rev4a's port `3740`, an invalid
890
- format, or a port already published by **another** agent answers `400`
913
+ agent creation (`lib/agent-ports.ts`) against the shared lookup
914
+ (`getUsedHostPorts()`, `lib/agent-ports-server.ts`): a range that holds Rev4a's port
915
+ `3740`, an invalid format, or a port already held by **another** container answers `400`
891
916
  (`Ports already in use: 3711, 3712`) before anything is rebuilt. The agent's own published
892
917
  ports are excluded, so keeping or shifting its block is not a conflict with itself.
918
+ A port held by a **stopped** container counts as in use: the lookup reads every
919
+ container's `HostConfig.PortBindings`, because both `docker ps -a --format '{{.Ports}}'`
920
+ and the API's container list report nothing for a container that is not running.
893
921
  At least one field is required; an empty `displayName` is rejected.
894
922
 
923
+ The rebuild itself (`recreateAgentContainer()`) re-checks the ports it is about to
924
+ publish against every other container and refuses **before removing anything** when one
925
+ is taken, naming the holder: `Port 3700 is already published by agent_24a68ac9 (Drill
926
+ Wake). Change this agent's port range in the Edit panel, then retry: nothing was
927
+ changed.` The row lands in `failed` with that message and the agent keeps running. The
928
+ same guard protects Update and Rollback, whose rows record the same text.
929
+
895
930
  **Response (202):**
896
931
  ```json
897
932
  { "started": true, "id": 4 }
@@ -2102,16 +2137,16 @@ Update audio or timezone config in `openclaw.json`.
2102
2137
  ## Version & WebSocket
2103
2138
 
2104
2139
  ### `GET /api/version`
2105
- Return the installed OpenClaw CLI version.
2140
+ Return the installed Rev4a version, read from `package.json`.
2106
2141
 
2107
- **Auth:** browser cookie
2142
+ **Auth:** `rev4a_token` cookie or `Authorization: Bearer <REV4A_TOKEN>`
2108
2143
 
2109
2144
  **Response:**
2110
2145
  ```json
2111
- { "version": "openclaw x.y.z" }
2146
+ { "version": "1.4.0" }
2112
2147
  ```
2113
2148
 
2114
- **Fallback:** `{ "version": "unknown" }` if the command fails.
2149
+ **Fallback:** `{ "version": "0.0.0" }` if `package.json` cannot be read.
2115
2150
 
2116
2151
  ### Terminal WebSocket
2117
2152
 
package/lib/agent-edit.ts CHANGED
@@ -8,10 +8,10 @@
8
8
  * cannot start on top of the first (lib/agent-busy.ts answers 409 while one runs).
9
9
  */
10
10
  import { isValidAgentId } from '@/lib/container';
11
- import { portMappingArgs, validatePortInput } from '@/lib/agent-ports';
11
+ import { hostPortsFromBindings, portMappingArgs, validatePortInput } from '@/lib/agent-ports';
12
12
  import { getUsedHostPorts } from '@/lib/agent-ports-server';
13
- import { resolveRecreateImage, runDocker } from '@/lib/agent-images';
14
- import { inspectAgentContainer, recreateAgentContainer } from '@/lib/agent-recreate';
13
+ import { resolveRecreateImage } from '@/lib/agent-images';
14
+ import { inspectAgentContainer, recreateAgentContainer, startAndVerifyContainer } from '@/lib/agent-recreate';
15
15
  import { agentBusyReason } from '@/lib/agent-busy';
16
16
  import { waitForGatewayReady } from '@/lib/agent-readiness';
17
17
  import { applyRuntimeConfig } from '@/lib/agent-setup';
@@ -29,22 +29,14 @@ export class EditRefusedError extends Error {}
29
29
  * with itself — while a conflict with another agent still fails **before** the container
30
30
  * is removed.
31
31
  */
32
- function parsePortArgs(portRange: string, ownPorts: number[]): string[] {
33
- const used = getUsedHostPorts();
32
+ async function parsePortArgs(portRange: string, ownPorts: number[]): Promise<string[]> {
33
+ const used = await getUsedHostPorts();
34
34
  for (const p of ownPorts) used.delete(p);
35
35
  const result = validatePortInput(portRange, used);
36
36
  if (!result.valid) throw new EditRefusedError(result.error);
37
37
  return portMappingArgs(result.start, result.end);
38
38
  }
39
39
 
40
- /** The agent's own published host ports, from its container inspect. */
41
- function ownHostPorts(container: { HostConfig?: { PortBindings?: Record<string, { HostPort?: string }[] | null> } }): number[] {
42
- return Object.values(container.HostConfig?.PortBindings ?? {})
43
- .flatMap((bindings) => bindings ?? [])
44
- .map((b) => Number(b?.HostPort))
45
- .filter((n) => Number.isInteger(n));
46
- }
47
-
48
40
  /** Edit jobs running in this process, so a second start for the same agent is refused. */
49
41
  const running = new Set<string>();
50
42
 
@@ -74,7 +66,7 @@ export async function startAgentEdit(agentId: string, opts: EditOptions): Promis
74
66
  if (!container) throw new EditRefusedError(`No container found with AGENT_ID '${agentId}'`);
75
67
  // A bad range is refused before any state is written, and before the container is
76
68
  // removed — the only failure mode that could leave the agent without one.
77
- const portArgs = opts.portRange ? parsePortArgs(opts.portRange, ownHostPorts(container)) : undefined;
69
+ const portArgs = opts.portRange ? await parsePortArgs(opts.portRange, hostPortsFromBindings(container)) : undefined;
78
70
 
79
71
  const id = insertEdit(agentId, displayName ?? null, portRange);
80
72
  void runEdit(id, agentId, container, displayName ?? null, portArgs).finally(() => running.delete(agentId));
@@ -149,9 +141,9 @@ export async function recoverInterruptedEdits(): Promise<number> {
149
141
  for (const agentId of ids) {
150
142
  const container = await inspectAgentContainer(agentId).catch(() => null);
151
143
  const name = container?.Name?.replace(/^\//, '');
152
- if (name && !container?.State?.Running) {
153
- await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
154
- }
144
+ if (!name || container?.State?.Running) continue;
145
+ const started = await startAndVerifyContainer(agentId, name);
146
+ if (!started.ok) console.warn(`[agent:edit] ${agentId} did not come back after an interrupted edit: ${started.error}`);
155
147
  }
156
148
  return interrupted;
157
149
  }
@@ -1,27 +1,78 @@
1
1
  /**
2
- * Server-side host-port lookup for lib/agent-ports.ts. Split out because it uses
3
- * `child_process`, which client components cannot import.
2
+ * Server-side host-port lookup for lib/agent-ports.ts. Split out because it uses the
3
+ * Docker socket, which client components cannot reach.
4
+ *
5
+ * This is the only place that answers "which host ports are taken": agent creation,
6
+ * the create form, the edit (rename/ports) flow and every container rebuild (update,
7
+ * recreate, rollback) all read their numbers from here.
8
+ *
9
+ * The numbers come from each container's `HostConfig.PortBindings`, the only source
10
+ * that also covers containers that are not running. Neither
11
+ * `docker ps -a --format '{{.Ports}}'` nor the API's container list reports ports for
12
+ * a stopped container (verified on Docker 29: both return nothing), while the
13
+ * allocation is still held — starting that container again fails with "port is
14
+ * already allocated". Reading the bindings is what stops a new agent from being given
15
+ * the block of a stopped one.
4
16
  */
5
- import { execSync } from 'child_process';
17
+ import { dockerFetch } from '@/lib/docker-socket';
18
+
19
+ export interface HostPortHolder {
20
+ /** Container name; agent containers are named after their id. */
21
+ name: string;
22
+ /** The container's `AGENT_NAME`, the name the panel shows; null for non-agent containers. */
23
+ displayName: string | null;
24
+ }
25
+
26
+ interface ContainerInspect {
27
+ Id?: string;
28
+ Name?: string;
29
+ Config?: { Env?: string[] };
30
+ HostConfig?: { PortBindings?: Record<string, { HostPort?: string }[] | null> };
31
+ }
6
32
 
7
33
  /**
8
- * Host ports published by any container, running or not: a stopped container keeps its
9
- * port allocations, so `docker run -p` on them fails just the same.
34
+ * Host ports held by containers, running or stopped, each mapped to the container that
35
+ * holds it. `excludeContainerId` drops one container from the result: the block of the
36
+ * container a rebuild is about to replace is not a conflict with itself.
10
37
  *
11
- * Parsed in JS rather than piping through grep: the old `grep -oP '\d+(?=->)'` form lost
12
- * its backslash inside a template literal, matched nothing, and offered port 3700 to
13
- * every agent; `grep -P` is also GNU-only and absent on macOS.
38
+ * Best-effort: an unreachable daemon yields an empty map, as the previous shell-based
39
+ * lookup did, rather than failing the caller.
14
40
  */
15
- export function getUsedHostPorts(): Set<number> {
41
+ export async function getHostPortHolders(opts: { excludeContainerId?: string } = {}): Promise<Map<number, HostPortHolder>> {
42
+ const holders = new Map<number, HostPortHolder>();
43
+ let list: { Id?: string }[];
16
44
  try {
17
- const raw = execSync(`docker ps -a --format '{{.Ports}}'`, { encoding: 'utf-8', timeout: 3000 });
18
- const used = new Set<number>();
19
- for (const [, port] of raw.matchAll(/(\d+)->/g)) {
20
- const n = Number(port);
21
- if (Number.isInteger(n)) used.add(n);
22
- }
23
- return used;
45
+ list = await dockerFetch<{ Id?: string }[]>('GET', '/containers/json?all=true');
24
46
  } catch {
25
- return new Set();
47
+ return holders;
26
48
  }
49
+ const ids = (Array.isArray(list) ? list : [])
50
+ .map((c) => c.Id)
51
+ .filter((id): id is string => typeof id === 'string' && id !== opts.excludeContainerId);
52
+ const infos = await Promise.all(
53
+ ids.map((id) => dockerFetch<ContainerInspect>('GET', `/containers/${id}/json`).catch(() => null)),
54
+ );
55
+ for (const info of infos) {
56
+ if (!info) continue;
57
+ const name = (info.Name ?? '').replace(/^\//, '') || (info.Id ?? '').slice(0, 12);
58
+ const agentName = (info.Config?.Env ?? []).find((entry) => entry.startsWith('AGENT_NAME='));
59
+ const holder: HostPortHolder = { name, displayName: agentName ? agentName.slice('AGENT_NAME='.length) : null };
60
+ for (const bindings of Object.values(info.HostConfig?.PortBindings ?? {})) {
61
+ for (const binding of bindings ?? []) {
62
+ const port = Number(binding?.HostPort);
63
+ if (Number.isInteger(port)) holders.set(port, holder);
64
+ }
65
+ }
66
+ }
67
+ return holders;
68
+ }
69
+
70
+ /** The port set the validators in lib/agent-ports.ts take. */
71
+ export async function getUsedHostPorts(opts: { excludeContainerId?: string } = {}): Promise<Set<number>> {
72
+ return new Set((await getHostPortHolders(opts)).keys());
73
+ }
74
+
75
+ /** How a holder reads in an error: `agent_24a68ac9 (Drill Wake)`, or the name alone. */
76
+ export function describeHolder(holder: HostPortHolder): string {
77
+ return holder.displayName ? `${holder.name} (${holder.displayName})` : holder.name;
27
78
  }
@@ -66,3 +66,26 @@ export function validatePortInput(input: string, usedPorts: ReadonlySet<number>
66
66
  export function portMappingArgs(start: number, end: number): string[] {
67
67
  return ['-p', end === start ? `${start}:3000` : `${start}-${end}:3000-${3000 + (end - start)}`];
68
68
  }
69
+
70
+ /** The host ports a container publishes, from its inspect (`HostConfig.PortBindings`). */
71
+ export function hostPortsFromBindings(container: {
72
+ HostConfig?: { PortBindings?: Record<string, { HostPort?: string }[] | null> };
73
+ }): number[] {
74
+ return Object.values(container.HostConfig?.PortBindings ?? {})
75
+ .flatMap((bindings) => bindings ?? [])
76
+ .map((b) => Number(b?.HostPort))
77
+ .filter((n) => Number.isInteger(n));
78
+ }
79
+
80
+ /** The host ports a `docker run` argument list publishes: `3700:3000` or `3700-3709:3000-3009`. */
81
+ export function hostPortsFromArgs(portArgs: readonly string[]): number[] {
82
+ const ports: number[] = [];
83
+ for (let i = 0; i < portArgs.length - 1; i++) {
84
+ if (portArgs[i] !== '-p') continue;
85
+ const host = (portArgs[i + 1] ?? '').split(':')[0];
86
+ const [start, end] = host.split('-').map(Number);
87
+ if (!Number.isInteger(start)) continue;
88
+ for (let p = start; p <= (Number.isInteger(end) ? end : start); p++) ports.push(p);
89
+ }
90
+ return ports;
91
+ }
@@ -12,6 +12,8 @@
12
12
  */
13
13
  import { dockerFetch } from '@/lib/docker-socket';
14
14
  import { runDocker, resolveRecreateImage } from '@/lib/agent-images';
15
+ import { hostPortsFromArgs } from '@/lib/agent-ports';
16
+ import { getHostPortHolders, describeHolder, type HostPortHolder } from '@/lib/agent-ports-server';
15
17
  import { getMountFlags, applyRuntimeConfig } from '@/lib/agent-setup';
16
18
  import { isValidAgentId } from '@/lib/container';
17
19
  import { BACKUP_VOLUME, coldBackupStatus, isColdBackupRunning, startColdBackup, waitForColdBackup } from '@/lib/cold-backup';
@@ -30,12 +32,13 @@ const RECREATE_READY_TIMEOUT_MS = 5 * 60 * 1000;
30
32
  const COLD_BACKUP_WAIT_TIMEOUT_MS = 30 * 60 * 1000;
31
33
 
32
34
  export interface AgentContainerInspect {
35
+ Id?: string;
33
36
  Name?: string;
34
37
  Image?: string;
35
38
  Config?: { Image?: string; Env?: string[]; Labels?: Record<string, string> };
36
39
  State?: { Running?: boolean; Status?: string };
37
40
  HostConfig?: { NetworkMode?: string; PortBindings?: Record<string, { HostPort?: string }[] | null> };
38
- NetworkSettings?: { Networks?: Record<string, unknown> };
41
+ NetworkSettings?: { Networks?: Record<string, unknown>; Ports?: Record<string, unknown> };
39
42
  }
40
43
 
41
44
  /** The container carrying `AGENT_ID=<agentId>`, inspected; null when there is none. */
@@ -48,6 +51,31 @@ export async function inspectAgentContainer(agentId: string): Promise<AgentConta
48
51
  return typeof info?.Id === 'string' ? info : null;
49
52
  }
50
53
 
54
+ /**
55
+ * Start the container and confirm it really came back. A container can report
56
+ * `running` while its network endpoint failed to attach — the state a failed port bind
57
+ * leaves behind — which would otherwise pass as a successful start.
58
+ */
59
+ export async function startAndVerifyContainer(agentId: string, name: string): Promise<{ ok: true } | { ok: false; error: string }> {
60
+ try {
61
+ await runDocker(['start', name], { timeoutMs: 60_000 });
62
+ } catch (e) {
63
+ return { ok: false, error: shortDockerError(e) };
64
+ }
65
+ const after = await inspectAgentContainer(agentId).catch(() => null);
66
+ if (!after?.State?.Running) return { ok: false, error: 'the container is not running' };
67
+ if (Object.keys(after.NetworkSettings?.Networks ?? {}).length === 0) {
68
+ return { ok: false, error: 'the container came up without a network (one of its ports is taken by another container)' };
69
+ }
70
+ return { ok: true };
71
+ }
72
+
73
+ /** A docker failure carries a whole stack trace; the first line is the useful part. */
74
+ function shortDockerError(e: unknown): string {
75
+ const message = (e as Error)?.message ?? String(e);
76
+ return message.split('\n').map((l) => l.trim()).filter(Boolean)[0] ?? message;
77
+ }
78
+
51
79
  /**
52
80
  * Remove the container and run a new one on `image` with the same parameters. The new
53
81
  * container is named after the agent id, as the create route names it. `opts.env`
@@ -88,6 +116,23 @@ export async function recreateAgentContainer(
88
116
  return ['-p', `${hostPort}:${containerPort.replace(/\/.*$/, '')}${proto}`];
89
117
  });
90
118
 
119
+ // Refuse on a port clash before anything is removed: the old container is the
120
+ // fallback the caller restarts, so a recreate refused here leaves the agent up. A
121
+ // clash cannot be recovered from by retrying, so it must not reach `docker run`.
122
+ const wanted = hostPortsFromArgs(portArgs);
123
+ if (wanted.length && container.Id) {
124
+ const held = await getHostPortHolders({ excludeContainerId: container.Id }).catch(() => new Map<number, HostPortHolder>());
125
+ const conflicts = wanted.filter((p) => held.has(p));
126
+ if (conflicts.length) {
127
+ const holders = [...new Set(conflicts.map((p) => describeHolder(held.get(p)!)))];
128
+ const many = conflicts.length > 1;
129
+ throw new Error(
130
+ `${many ? 'Ports' : 'Port'} ${conflicts.join(', ')} ${many ? 'are' : 'is'} already published by ${holders.join(', ')}. `
131
+ + 'Change this agent\'s port range in the Edit panel, then retry: nothing was changed.',
132
+ );
133
+ }
134
+ }
135
+
91
136
  const oldName = (container.Name ?? '').replace(/^\//, '') || agentId;
92
137
  await runDocker(['rm', '-f', oldName], { timeoutMs: 60_000 });
93
138
 
@@ -115,7 +160,16 @@ export async function recreateAgentContainer(
115
160
  console.warn(`[agent:recreate] docker run failed for ${agentId}, retrying:`, (first as Error).message);
116
161
  await runDocker(['rm', '-f', agentId], { timeoutMs: 60_000 }).catch(() => {});
117
162
  const retryImage = await resolveRecreateImage(container).catch(() => image);
118
- await runDocker(runArgs(retryImage), { timeoutMs: 120_000, env });
163
+ try {
164
+ await runDocker(runArgs(retryImage), { timeoutMs: 120_000, env });
165
+ } catch (second) {
166
+ // `docker run` creates the container before its network step, so a failed start
167
+ // leaves a container behind — one that reports `running` with no network and
168
+ // would be picked up as if it were the agent. Remove it, so the state the caller
169
+ // reports matches reality.
170
+ await runDocker(['rm', '-f', agentId], { timeoutMs: 60_000 }).catch(() => {});
171
+ throw second;
172
+ }
119
173
  }
120
174
  }
121
175
 
@@ -267,9 +321,9 @@ async function finishInterruptedRecreate(agentId: string): Promise<void> {
267
321
  if (running) await waitForColdBackup(agentId).catch(() => null);
268
322
  const container = await inspectAgentContainer(agentId).catch(() => null);
269
323
  const name = container?.Name?.replace(/^\//, '');
270
- if (name && !container?.State?.Running) {
271
- await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
272
- }
324
+ if (!name || container?.State?.Running) return;
325
+ const started = await startAndVerifyContainer(agentId, name);
326
+ if (!started.ok) console.warn(`[agent:recreate] ${agentId} did not come back after an interrupted recreate: ${started.error}`);
273
327
  }
274
328
 
275
329
  // ── Prerecreate retention ────────────────────────────────────────────────────
@@ -10,7 +10,7 @@
10
10
  import { isValidAgentId } from '@/lib/container';
11
11
  import { runDocker } from '@/lib/agent-images';
12
12
  import { BACKUP_VOLUME } from '@/lib/cold-backup';
13
- import { inspectAgentContainer } from '@/lib/agent-recreate';
13
+ import { inspectAgentContainer, startAndVerifyContainer } from '@/lib/agent-recreate';
14
14
  import { activeRestoreAgentIds, insertRestore, isRestoreActive, latestRestore, markInterruptedRestores, updateRestoreRow, type AgentRestoreRow } from '@/lib/agent-restore-state';
15
15
  import { agentBusyReason } from '@/lib/agent-busy';
16
16
 
@@ -60,6 +60,7 @@ export async function startAgentRestore(agentId: string, file: string): Promise<
60
60
  async function runRestore(id: number, agentId: string, file: string): Promise<void> {
61
61
  const container = await inspectAgentContainer(agentId).catch(() => null);
62
62
  const name = container?.Name?.replace(/^\//, '');
63
+ let failure: string | null = null;
63
64
  try {
64
65
  if (name && container?.State?.Running) {
65
66
  await runDocker(['stop', '-t', '30', name], { timeoutMs: 45_000 }).catch(() => {});
@@ -77,16 +78,21 @@ async function runRestore(id: number, agentId: string, file: string): Promise<vo
77
78
  ],
78
79
  { timeoutMs: EXTRACT_TIMEOUT_MS, env: { FILE: file } },
79
80
  );
80
-
81
- updateRestoreRow(id, { status: 'done' });
82
81
  } catch (e) {
83
- updateRestoreRow(id, { status: 'failed', error: (e as Error)?.message ?? String(e) });
82
+ failure = (e as Error)?.message ?? String(e);
84
83
  } finally {
85
84
  // The container is started again even when the extract failed, so the agent never
86
- // stays down. A volume with no container (volume-only agent) has nothing to start.
85
+ // stays down. The row is only `done` when it really came back: a container can
86
+ // report running while its network never attached, which is not a usable agent.
87
87
  if (name) {
88
- await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
88
+ const started = await startAndVerifyContainer(agentId, name);
89
+ if (!started.ok) {
90
+ const note = `the container did not come back: ${started.error}`;
91
+ failure = failure ? `${failure}; ${note}` : `the volume was restored, but ${note}`;
92
+ }
89
93
  }
94
+ if (failure) updateRestoreRow(id, { status: 'failed', error: failure });
95
+ else updateRestoreRow(id, { status: 'done' });
90
96
  }
91
97
  }
92
98
 
@@ -127,9 +133,9 @@ export async function recoverInterruptedRestores(): Promise<number> {
127
133
  for (const agentId of ids) {
128
134
  const container = await inspectAgentContainer(agentId).catch(() => null);
129
135
  const name = container?.Name?.replace(/^\//, '');
130
- if (name && !container?.State?.Running) {
131
- await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
132
- }
136
+ if (!name || container?.State?.Running) continue;
137
+ const started = await startAndVerifyContainer(agentId, name);
138
+ if (!started.ok) console.warn(`[agent:restore] ${agentId} did not come back after an interrupted restore: ${started.error}`);
133
139
  }
134
140
  return interrupted;
135
141
  }
@@ -32,7 +32,7 @@ import {
32
32
  runDocker,
33
33
  } from '@/lib/agent-images';
34
34
  import { BACKUP_VOLUME, coldBackupStatus, isColdBackupRunning, startColdBackup, waitForColdBackup } from '@/lib/cold-backup';
35
- import { inspectAgentContainer, recreateAgentContainer, type AgentContainerInspect } from '@/lib/agent-recreate';
35
+ import { inspectAgentContainer, recreateAgentContainer, startAndVerifyContainer, type AgentContainerInspect } from '@/lib/agent-recreate';
36
36
  import { applyRuntimeConfig } from '@/lib/agent-setup';
37
37
  import { waitForGatewayReady } from '@/lib/agent-readiness';
38
38
  import { patchRev4aProvider } from '@/app/api/gateway/sync';
@@ -251,12 +251,20 @@ async function runUpdate(id: number, agentId: string, name: string, fromVersion:
251
251
  .catch((err: unknown) => console.warn('[agent:update] image cleanup failed:', (err as Error).message));
252
252
  } catch (e) {
253
253
  // A failure before the recreate leaves the old container stopped by the backup:
254
- // start it again, so a failed update never leaves the agent down.
254
+ // start it again, so a failed update never leaves the agent down. When it does not
255
+ // come back the failure text says so — a container that is "running" with no network
256
+ // is not an agent the operator can use.
255
257
  if (stopped) {
256
258
  const container = await inspectAgentContainer(agentId).catch(() => null);
257
259
  const containerName = container?.Name?.replace(/^\//, '');
258
260
  if (containerName && !container?.State?.Running) {
259
- await runDocker(['start', containerName], { timeoutMs: 60_000 }).catch(() => {});
261
+ const started = await startAndVerifyContainer(agentId, containerName);
262
+ if (!started.ok) {
263
+ const note = `the previous container did not come back: ${started.error}`;
264
+ const message = shortError(e);
265
+ updateRow(id, { status: 'failed', error: `${message}; ${note}` });
266
+ return;
267
+ }
260
268
  }
261
269
  }
262
270
  updateRow(id, { status: 'failed', error: shortError(e) });
@@ -285,9 +293,9 @@ export async function recoverInterruptedUpdates(): Promise<number> {
285
293
  for (const agentId of ids) {
286
294
  const container = await inspectAgentContainer(agentId).catch(() => null);
287
295
  const name = container?.Name?.replace(/^\//, '');
288
- if (name && !container?.State?.Running) {
289
- await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
290
- }
296
+ if (!name || container?.State?.Running) continue;
297
+ const started = await startAndVerifyContainer(agentId, name);
298
+ if (!started.ok) console.warn(`[agent:update] ${agentId} did not come back after an interrupted update: ${started.error}`);
291
299
  }
292
300
  return interrupted;
293
301
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flame0510/project-aether",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Rev4a — Revolution for Agents. OpenClaw agent fleet orchestrator.",
5
5
  "keywords": [
6
6
  "openclaw",