@flame0510/project-aether 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +1 -0
  2. package/agent-templates/README.md +42 -22
  3. package/agent-templates/base-image/Dockerfile +42 -33
  4. package/agent-templates/base-image/entrypoint.sh +67 -12
  5. package/app/agents/BrowserAccessSection.tsx +510 -0
  6. package/app/agents/ChannelManager.tsx +19 -11
  7. package/app/agents/ImageDownloadBanner.tsx +53 -19
  8. package/app/agents/ModelSection.tsx +4 -1
  9. package/app/agents/PageClient.tsx +629 -167
  10. package/app/agents/UpdateSection.tsx +300 -0
  11. package/app/agents/create/PageClient.tsx +11 -49
  12. package/app/agents/create/page.tsx +14 -28
  13. package/app/api/agents/[id]/backup/route.ts +26 -69
  14. package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
  15. package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
  16. package/app/api/agents/[id]/cold-backup/route.ts +56 -0
  17. package/app/api/agents/[id]/devices/route.ts +126 -0
  18. package/app/api/agents/[id]/invite-link/route.ts +53 -0
  19. package/app/api/agents/[id]/lifecycle/route.ts +3 -0
  20. package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
  21. package/app/api/agents/[id]/recreate/route.ts +33 -163
  22. package/app/api/agents/[id]/restart/route.ts +5 -0
  23. package/app/api/agents/[id]/restore/route.ts +40 -70
  24. package/app/api/agents/[id]/route.ts +38 -150
  25. package/app/api/agents/[id]/update/rollback/route.ts +30 -0
  26. package/app/api/agents/[id]/update/route.ts +50 -0
  27. package/app/api/agents/activity-summary/route.ts +67 -0
  28. package/app/api/agents/create/route.ts +38 -92
  29. package/app/api/agents/devices-summary/route.ts +37 -0
  30. package/app/api/agents/download-image/route.ts +16 -9
  31. package/app/api/agents/image-status/route.ts +31 -111
  32. package/app/api/agents/route.ts +25 -49
  33. package/app/api/agents/token/route.ts +33 -10
  34. package/app/api/assistant/route.ts +2 -2
  35. package/app/api/gateway/agent/route.ts +14 -0
  36. package/app/api/gateway/provider/balance/route.ts +5 -2
  37. package/app/api/gateway/sync.ts +97 -14
  38. package/app/api/setup/agent-image/route.ts +14 -42
  39. package/app/api/version/route.ts +2 -1
  40. package/app/components/DashboardToolbar.tsx +1 -1
  41. package/app/gateway/PageClient.tsx +27 -32
  42. package/bin/rev4a.js +43 -41
  43. package/daemon.js +6 -6
  44. package/docs/ARCHITECTURE.md +107 -9
  45. package/docs/FRONTEND-ARCHITECTURE.md +8 -1
  46. package/docs/REV4A.md +54 -17
  47. package/docs/dev/API-REFERENCE.md +573 -105
  48. package/docs/dev/DATABASE.md +96 -0
  49. package/docs/dev/GATEWAY.md +21 -6
  50. package/docs/rag/DATA-FRESHNESS.md +6 -4
  51. package/docs/rag/GLOSSARY.md +12 -3
  52. package/docs/rag/REV4A-OVERVIEW.md +18 -5
  53. package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -2
  54. package/instrumentation.ts +43 -0
  55. package/lib/agent-busy.ts +21 -0
  56. package/lib/agent-devices.ts +361 -0
  57. package/lib/agent-edit-state.ts +108 -0
  58. package/lib/agent-edit.ts +149 -0
  59. package/lib/agent-images.ts +375 -0
  60. package/lib/agent-ports-server.ts +78 -0
  61. package/lib/agent-ports.ts +91 -0
  62. package/lib/agent-recreate-state.ts +108 -0
  63. package/lib/agent-recreate.ts +359 -0
  64. package/lib/agent-restore-state.ts +107 -0
  65. package/lib/agent-restore.ts +141 -0
  66. package/lib/agent-setup.ts +66 -17
  67. package/lib/agent-update-state.ts +122 -0
  68. package/lib/agent-update.ts +456 -0
  69. package/lib/agent-versions.json +14 -0
  70. package/lib/agent-versions.ts +80 -0
  71. package/lib/buildAgentImage.ts +88 -290
  72. package/lib/channelManager.ts +149 -102
  73. package/lib/cold-backup.ts +354 -0
  74. package/lib/credentials/delivery.ts +3 -3
  75. package/lib/db-bootstrap.mjs +76 -0
  76. package/lib/docker-utils.ts +3 -3
  77. package/lib/provider-balance.ts +33 -12
  78. package/package.json +1 -1
@@ -25,8 +25,8 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
25
25
  '/agents': {
26
26
  title: 'Agents',
27
27
  description: 'List of all Docker containers that run OpenClaw agents. Shows status (running/exited/paused), image, ports, IP, and creation time. Includes a top banner for the agent base image status: yellow warning when outdated or missing, animated progress bar when downloading from registry.',
28
- actions: ['Filter by status: all, running, or exited', 'Click an agent to see its details (environment variables, auth token, backups, Telegram channels, and the Model section that sets its primary model and fallbacks)', 'Create a new agent using the "Create Agent" button', 'Download the agent base image when the banner shows it missing or outdated'],
29
- hints: 'The status bullet tells you at a glance if an agent is running (green), exited (red), or paused (yellow). The base image banner shows when an updated version is available on the registry — download to pick up CLI updates.',
28
+ actions: ['Filter by status: all, running, or exited', 'Click an agent to see its details (environment variables, backups, Telegram channels, and the Model section that sets its primary model and fallbacks)', 'Create a new agent using the "Create Agent" button', 'Download the agent base image when the banner shows it missing or outdated'],
29
+ hints: 'The status bullet tells you at a glance if an agent is running (green), exited (red), or paused (yellow). The base image banner shows when a newer supported version is published on the registry — downloading makes it available to create and update agents on; it changes no agent by itself.',
30
30
  },
31
31
  '/agents/create': {
32
32
  title: 'Create Agent',
@@ -8,6 +8,7 @@
8
8
  * { "containerName": "openclaw-atlas", "model": "rev4a/deepseek-flash", "fallbacks": ["rev4a/deepseek-v4-pro"] }
9
9
  */
10
10
  import { NextRequest, NextResponse } from 'next/server';
11
+ import { agentBusyReason } from '@/lib/agent-busy';
11
12
  import { execSync } from 'child_process';
12
13
  import { requireAuthJWT } from '@/lib/rev4a-auth';
13
14
 
@@ -90,6 +91,19 @@ export async function PUT(request: NextRequest) {
90
91
  return NextResponse.json({ status: 'error', error: 'model is required' }, { status: 400 });
91
92
  }
92
93
 
94
+ // Refuse while a long operation is running on this agent: the write would race the
95
+ // recreate/migration that is replacing the container underneath it.
96
+ try {
97
+ const agentId = execSync(
98
+ `docker inspect --format '{{index .Config.Labels "AGENT_ID"}}' ${safeContainer}`,
99
+ { encoding: 'utf-8', timeout: 5000 },
100
+ ).trim();
101
+ if (agentId) {
102
+ const busy = await agentBusyReason(agentId);
103
+ if (busy) return NextResponse.json({ status: 'error', error: busy }, { status: 409 });
104
+ }
105
+ } catch { /* no container or no label: the rest of the route reports it */ }
106
+
93
107
  // Read current config
94
108
  const raw = readFileFromContainer(safeContainer, '/root/.openclaw/openclaw.json');
95
109
  if (!raw) {
@@ -7,7 +7,7 @@
7
7
  * Supported providers: deepseek, openrouter, glm
8
8
  */
9
9
  import { NextRequest, NextResponse } from 'next/server';
10
- import { fetchProviderBalance, type BalanceProvider } from '@/lib/provider-balance';
10
+ import { fetchProviderBalance, BalanceUnsupportedError, type BalanceProvider } from '@/lib/provider-balance';
11
11
  import { requireAuthJWT } from '@/lib/rev4a-auth';
12
12
 
13
13
  export const dynamic = 'force-dynamic';
@@ -33,6 +33,9 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
33
33
  return NextResponse.json({ provider, ...result });
34
34
  } catch (e: unknown) {
35
35
  const message = e instanceof Error ? e.message : String(e);
36
- return NextResponse.json({ provider, error: message }, { status: 502 });
36
+ // A permanent "no balance API for this account" is reported distinctly, so
37
+ // the UI can hide the badge instead of offering a retry that never works.
38
+ const reason = e instanceof BalanceUnsupportedError ? 'no-balance-api' : undefined;
39
+ return NextResponse.json({ provider, error: message, ...(reason && { reason }) }, { status: 502 });
37
40
  }
38
41
  }
@@ -19,6 +19,11 @@ import { readProviderKeys } from './provider/keys';
19
19
  import { bundledCatalogueStatus, loadOfferedModels, type ModelConfigEntry } from '@/lib/model-catalogue';
20
20
  import { singleFileFromTar } from '@/lib/container-file';
21
21
  import { detectProviderGatewayUrl } from '@/lib/docker-utils';
22
+ import { isSupportedVersion, modelInputFor, parseOpenClawVersion } from '@/lib/agent-versions';
23
+ import { activeUpdateAgentIds } from '@/lib/agent-update-state';
24
+ import { activeRecreateAgentIds } from '@/lib/agent-recreate-state';
25
+ import { activeRestoreAgentIds } from '@/lib/agent-restore-state';
26
+ import { activeEditAgentIds } from '@/lib/agent-edit-state';
22
27
 
23
28
  const PROVIDER_GATEWAY_BASE_URL = detectProviderGatewayUrl();
24
29
 
@@ -31,19 +36,57 @@ const PROVIDER_GATEWAY_BASE_URL = detectProviderGatewayUrl();
31
36
  * OpenClaw `input` array. Filters out values not in the allowed set
32
37
  * (text | image | video | audio).
33
38
  */
34
- const ALLOWED_INPUT = new Set(['text', 'image', 'video', 'audio']);
35
-
36
- function modalityToInput(modality: string): string[] {
39
+ /**
40
+ * Model `input` values an agent's OpenClaw version accepts. The list is per version
41
+ * (lib/agent-versions.json): 7.1-2 knows only `text | image`, and one value it does not
42
+ * know invalidates the whole generated catalogue ("model catalog load issue").
43
+ */
44
+ function modalityToInput(modality: string, version?: string | null): string[] {
45
+ const allowed = new Set(modelInputFor(version));
37
46
  const arrowIdx = modality.indexOf('->');
38
47
  const left = arrowIdx >= 0 ? modality.slice(0, arrowIdx) : modality;
39
- return left.split('+').map((s) => s.trim()).filter((s) => ALLOWED_INPUT.has(s));
48
+ return left.split('+').map((s) => s.trim()).filter((s) => allowed.has(s));
49
+ }
50
+
51
+ /** Where the image entrypoint records the version an agent last started on. */
52
+ const LAST_VERSION_PATH = '/root/.openclaw/.last-version';
53
+
54
+ /**
55
+ * The OpenClaw version a container runs, for its model `input` list. Best-effort and
56
+ * synchronous: the image tag when it names a version, else the `.last-version` file the
57
+ * entrypoint writes (through `docker exec` when running, `docker cp` when stopped).
58
+ * Null when nothing says — the caller then falls back to the newest version's list.
59
+ */
60
+ function containerVersionSync(name: string): string | null {
61
+ try {
62
+ const image = execFileSync('docker', ['inspect', '--format', '{{.Config.Image}}', name], {
63
+ timeout: 5000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'],
64
+ }).trim();
65
+ const fromTag = parseOpenClawVersion(image);
66
+ if (fromTag && isSupportedVersion(fromTag)) return fromTag;
67
+ } catch { /* no such container or no image: fall through */ }
68
+ try {
69
+ const raw = execFileSync('docker', ['exec', name, 'cat', LAST_VERSION_PATH], {
70
+ timeout: 5000, maxBuffer: 64 * 1024, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'],
71
+ });
72
+ const running = parseOpenClawVersion(raw);
73
+ if (running && isSupportedVersion(running)) return running;
74
+ } catch { /* not running or no file: try the stopped path */ }
75
+ try {
76
+ const file = singleFileFromTar(execFileSync('docker', ['cp', `${name}:${LAST_VERSION_PATH}`, '-'], {
77
+ timeout: 5000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
78
+ }));
79
+ const stopped = parseOpenClawVersion(file.body.toString('utf-8'));
80
+ if (stopped && isSupportedVersion(stopped)) return stopped;
81
+ } catch { /* nothing to read */ }
82
+ return null;
40
83
  }
41
84
 
42
- function getActiveRev4aModels(): { id: string; name: string; input?: string[] }[] {
85
+ function getActiveRev4aModels(version?: string | null): { id: string; name: string; input?: string[] }[] {
43
86
  return loadOfferedModels()
44
87
  .map((m) => {
45
88
  const entry: { id: string; name: string; input?: string[] } = { id: m.id, name: m.name };
46
- if (m.modality) entry.input = modalityToInput(m.modality);
89
+ if (m.modality) entry.input = modalityToInput(m.modality, version);
47
90
  return entry;
48
91
  });
49
92
  }
@@ -56,7 +99,23 @@ function firstLine(e: unknown): string {
56
99
  }
57
100
 
58
101
  /** A container carrying an AGENT_ID label, and whether it is running. */
59
- interface AgentContainer { name: string; running: boolean }
102
+ interface AgentContainer { name: string; running: boolean; agentId: string }
103
+
104
+ /**
105
+ * AGENT_IDs whose volume is being archived by a cold backup (lib/cold-backup.ts). Their
106
+ * config must not change while tar reads the volume; the next sync catches them up.
107
+ */
108
+ function agentsUnderBackup(): Set<string> {
109
+ try {
110
+ const raw = execSync(
111
+ `docker ps --filter "label=rev4a.backup.agent" --format '{{.Label "rev4a.backup.agent"}}'`,
112
+ { timeout: 5000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
113
+ ).trim();
114
+ return new Set(raw ? raw.split('\n').map((s) => s.trim()).filter(Boolean) : []);
115
+ } catch {
116
+ return new Set();
117
+ }
118
+ }
60
119
 
61
120
  /**
62
121
  * Every container carrying an AGENT_ID label, running or stopped.
@@ -74,7 +133,7 @@ function getAgentContainers(): AgentContainer[] {
74
133
  let raw: string;
75
134
  try {
76
135
  raw = execSync(
77
- `docker ps -a --filter "label=AGENT_ID" --format '{{.Names}}\t{{.State}}'`,
136
+ `docker ps -a --filter "label=AGENT_ID" --format '{{.Names}}\t{{.State}}\t{{.Label "AGENT_ID"}}'`,
78
137
  { timeout: 5000, maxBuffer: 64 * 1024, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
79
138
  ).trim();
80
139
  } catch (e) {
@@ -83,8 +142,8 @@ function getAgentContainers(): AgentContainer[] {
83
142
  if (!raw) return [];
84
143
  return raw.split('\n')
85
144
  .map((line) => {
86
- const [name, state] = line.split('\t');
87
- return { name: (name ?? '').trim(), running: (state ?? '').trim() === 'running' };
145
+ const [name, state, agentId] = line.split('\t');
146
+ return { name: (name ?? '').trim(), running: (state ?? '').trim() === 'running', agentId: (agentId ?? '').trim() };
88
147
  })
89
148
  .filter((c) => c.name);
90
149
  }
@@ -184,10 +243,10 @@ function writeContainerJson(container: AgentContainer, remotePath: string, data:
184
243
  * Shared between the create route and syncAllAgents — single source of truth
185
244
  * for the provider block injected into every agent container.
186
245
  */
187
- export function buildRev4aProviderConfig(): { baseUrl: string; apiKey: string; api: string; models: { id: string; name: string; input?: string[] }[] } {
246
+ export function buildRev4aProviderConfig(version?: string | null): { baseUrl: string; apiKey: string; api: string; models: { id: string; name: string; input?: string[] }[] } {
188
247
  const providerKeys = readProviderKeys();
189
248
  const rev4aApiKey = providerKeys['rev4a'] || '';
190
- const activeModels = getActiveRev4aModels();
249
+ const activeModels = getActiveRev4aModels(version);
191
250
  return {
192
251
  baseUrl: PROVIDER_GATEWAY_BASE_URL,
193
252
  apiKey: rev4aApiKey,
@@ -211,7 +270,8 @@ export function patchRev4aProvider(container: string): void {
211
270
  if (bundledCatalogueStatus() === 'unavailable') {
212
271
  throw new Error('models.config.json could not be read; models.providers.rev4a left unchanged');
213
272
  }
214
- const input = JSON.stringify({ models: { providers: { rev4a: buildRev4aProviderConfig() } } });
273
+ // The agent's own version: an older release rejects `input` values it does not know.
274
+ const input = JSON.stringify({ models: { providers: { rev4a: buildRev4aProviderConfig(containerVersionSync(container)) } } });
215
275
  execFileSync('docker', ['exec', '-i', container, 'sh', '-c', 'openclaw config patch --stdin'], {
216
276
  timeout: 15000,
217
277
  stdio: 'pipe',
@@ -293,9 +353,32 @@ export function syncAllAgents(): SyncOutcome {
293
353
  }
294
354
 
295
355
  outcome.total = containers.length;
356
+ const underBackup = agentsUnderBackup();
357
+ // Any long operation replaces or rewrites the container/config: skip the agent.
358
+ let busyAgents = new Set<string>();
359
+ try {
360
+ busyAgents = new Set([
361
+ ...activeUpdateAgentIds(),
362
+ ...activeRecreateAgentIds(),
363
+ ...activeRestoreAgentIds(),
364
+ ...activeEditAgentIds(),
365
+ ]);
366
+ } catch {
367
+ // No operation state readable: nothing to skip on that account.
368
+ }
296
369
  for (const container of containers) {
370
+ if (busyAgents.has(container.agentId)) {
371
+ outcome.failed.push({ container: container.name, error: 'an operation of this agent is running; it applies Rev4a config itself' });
372
+ continue;
373
+ }
374
+ if (underBackup.has(container.agentId)) {
375
+ outcome.failed.push({ container: container.name, error: 'a cold backup of this agent is running; sync again once it finishes' });
376
+ continue;
377
+ }
297
378
  try {
298
- syncContainer(container, rev4aProviderConfig);
379
+ // Per agent: the `input` list follows the version that agent runs, so a 7.x
380
+ // release never receives a modality value it would reject.
381
+ syncContainer(container, buildRev4aProviderConfig(containerVersionSync(container.name)));
299
382
  outcome.synced.push(container.name);
300
383
  if (!container.running) outcome.stopped.push(container.name);
301
384
  } catch (e: unknown) {
@@ -1,60 +1,36 @@
1
1
  /**
2
2
  * GET /api/setup/agent-image
3
3
  *
4
- * Checks if openclaw-agent-base:latest exists locally or needs downloading.
5
- * Returns status and a hint for the UI.
4
+ * Whether a supported OpenClaw version of the agent base image is downloaded.
5
+ * Returns `{ exists, version }`, the version being the newest one present.
6
6
  *
7
7
  * POST /api/setup/agent-image
8
8
  *
9
- * Downloads openclaw-agent-base:latest from ghcr.io (public registry).
10
- * Streams progress via SSE so the setup wizard can show a live progress bar.
9
+ * Downloads the newest supported version the registry publishes, and streams
10
+ * progress via SSE so the setup wizard can show a live progress bar. Nothing is
11
+ * downloaded when that version is already here.
11
12
  *
12
13
  * Only one download at a time — returns 409 if already running.
13
- * Cleans up the docker process if the client disconnects.
14
14
  */
15
15
  import { NextResponse, type NextRequest } from 'next/server';
16
16
  import { downloadAgentImage, getIsDownloading } from '@/lib/buildAgentImage';
17
- import { execSync } from 'child_process';
18
17
  import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
18
+ import { dockerAvailable } from '@/lib/docker-socket';
19
+ import { listLocalAgentImages, newestLocalSupportedVersion } from '@/lib/agent-images';
19
20
 
20
21
  export const dynamic = 'force-dynamic';
21
22
 
22
- function dockerOk(): boolean {
23
- try {
24
- execSync('docker version --format "{{.Client.Version}}" 2>/dev/null', {
25
- encoding: 'utf-8',
26
- timeout: 2000,
27
- });
28
- return true;
29
- } catch {
30
- return false;
31
- }
32
- }
33
-
34
23
  export async function GET(request: NextRequest): Promise<NextResponse> {
35
24
  const denied = await requireAuthIfConfigured(request);
36
25
  if (denied) return denied;
37
- if (!dockerOk()) {
38
- return NextResponse.json({ exists: false });
26
+ if (!dockerAvailable()) {
27
+ return NextResponse.json({ exists: false, version: null });
39
28
  }
40
29
  try {
41
- const { exec } = await import('child_process');
42
- const result = await new Promise<string>((resolve, reject) => {
43
- exec(
44
- 'docker image inspect openclaw-agent-base:latest --format "{{.ID}}" 2>/dev/null',
45
- { timeout: 5000 },
46
- (err, stdout) => {
47
- if (err) return reject(err);
48
- resolve(stdout.trim());
49
- },
50
- );
51
- });
52
- if (result) {
53
- return NextResponse.json({ exists: true, imageId: result });
54
- }
55
- return NextResponse.json({ exists: false });
30
+ const version = newestLocalSupportedVersion(await listLocalAgentImages());
31
+ return NextResponse.json({ exists: version !== null, version });
56
32
  } catch {
57
- return NextResponse.json({ exists: false });
33
+ return NextResponse.json({ exists: false, version: null });
58
34
  }
59
35
  }
60
36
 
@@ -81,11 +57,7 @@ export async function POST(request: NextRequest): Promise<Response> {
81
57
  const ctrl = controller as ReadableStreamDefaultController & { signal?: AbortSignal };
82
58
 
83
59
  try {
84
- await downloadAgentImage({
85
- force: false, // setup: skip if already exists
86
- onEvent: send,
87
- signal: ctrl.signal,
88
- });
60
+ await downloadAgentImage({ onEvent: send, signal: ctrl.signal });
89
61
  } catch (e: unknown) {
90
62
  send(JSON.stringify({
91
63
  type: 'error',
@@ -94,7 +66,7 @@ export async function POST(request: NextRequest): Promise<Response> {
94
66
  }
95
67
  controller.close();
96
68
  },
97
- cancel() { /* downloadAgentImage handles cleanup via signal */ },
69
+ cancel() { /* downloadAgentImage stops the docker process through the signal */ },
98
70
  });
99
71
 
100
72
  return new Response(stream, {
@@ -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;
@@ -32,7 +32,7 @@ export default function DashboardToolbar({ filter, onChange, agents = ['all'] }:
32
32
  className={`agent-btn ${filter.showOnlyActive ? 'active' : ''}`}
33
33
  onClick={() => onChange({ showOnlyActive: !filter.showOnlyActive })}
34
34
  >
35
- solo attivi
35
+ active only
36
36
  </button>
37
37
 
38
38
  <button
@@ -88,7 +88,7 @@ export default function GatewayPageClient() {
88
88
  const [modelSearch, setModelSearch] = useState<Record<string, string>>({});
89
89
 
90
90
  // Balance fetch state (per provider)
91
- const [balances, setBalances] = useState<Record<string, { balance: string; label?: string; loading?: boolean; error?: string } | null>>({});
91
+ const [balances, setBalances] = useState<Record<string, { balance: string; label?: string; loading?: boolean; error?: string; unsupported?: boolean } | null>>({});
92
92
  const [fetchingBalance, setFetchingBalance] = useState<Record<string, boolean>>({});
93
93
 
94
94
  // Accordion state — which provider cards are expanded
@@ -135,6 +135,9 @@ export default function GatewayPageClient() {
135
135
  const bJson = await bRes.json();
136
136
  if (bRes.ok && bJson.balance) {
137
137
  setBalances((prev) => ({ ...prev, [bp]: { balance: bJson.balance, label: bJson.label } }));
138
+ } else if (bJson.reason === 'no-balance-api') {
139
+ // Permanent: this provider has no balance API for the account — hide the badge.
140
+ setBalances((prev) => ({ ...prev, [bp]: { balance: '', unsupported: true } }));
138
141
  } else {
139
142
  setBalances((prev) => ({ ...prev, [bp]: { balance: '—', error: bJson.error || 'Failed' } }));
140
143
  }
@@ -198,6 +201,8 @@ export default function GatewayPageClient() {
198
201
  const json = await res.json();
199
202
  if (res.ok && json.balance) {
200
203
  setBalances((prev) => ({ ...prev, [provider]: { balance: json.balance, label: json.label } }));
204
+ } else if (json.reason === 'no-balance-api') {
205
+ setBalances((prev) => ({ ...prev, [provider]: { balance: '', unsupported: true } }));
201
206
  } else {
202
207
  setBalances((prev) => ({ ...prev, [provider]: { balance: '—', error: json.error || 'Failed' } }));
203
208
  }
@@ -519,43 +524,33 @@ export default function GatewayPageClient() {
519
524
  <span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--text-dim)' }}>
520
525
  {p.models.length} models
521
526
  </span>
522
- {/* Balance badge — auto-loaded for supported providers with configured key */}
523
- {p.configured && ['deepseek','openrouter','glm'].includes(p.provider) && (
524
- balances[p.provider] && !balances[p.provider]?.error ? (
525
- <span
526
- title={balances[p.provider]?.label || ''}
527
- onClick={(e) => { e.stopPropagation(); fetchBalance(p.provider); }}
528
- style={{
529
- fontSize: 11, fontWeight: 600,
530
- color: 'var(--green)',
531
- background: 'rgba(var(--green-rgb,34,197,94),0.1)',
532
- padding: '2px 8px', borderRadius: 0,
533
- border: '1px solid rgba(var(--green-rgb,34,197,94),0.2)',
534
- cursor: 'pointer',
535
- }}
536
- >
537
- {balances[p.provider]!.balance}
538
- </span>
539
- ) : (
540
- <span
541
- onClick={(e) => { e.stopPropagation(); fetchBalance(p.provider); }}
542
- style={{
543
- fontSize: 11,
544
- color: 'var(--text-dim)',
545
- opacity: 0.5,
546
- cursor: 'pointer',
547
- }}
548
- >
549
- {fetchingBalance[p.provider] ? '…' : '…'}
550
- </span>
551
- )
527
+ {/* Balance badge — auto-loaded for supported providers with configured key.
528
+ Same squared chip in every state: "..." while loading, "—" (dimmed, tooltip) on error.
529
+ Hidden entirely when the provider has no balance API for the account (permanent). */}
530
+ {p.configured && ['deepseek','openrouter','glm'].includes(p.provider) && !balances[p.provider]?.unsupported && (
531
+ <button
532
+ type="button"
533
+ title={balances[p.provider]?.error || balances[p.provider]?.label || ''}
534
+ onClick={(e) => { e.stopPropagation(); fetchBalance(p.provider); }}
535
+ style={{
536
+ fontSize: 11, fontWeight: 600,
537
+ color: 'var(--green)',
538
+ background: 'rgba(var(--green-rgb,34,197,94),0.1)',
539
+ padding: '2px 8px', borderRadius: 0,
540
+ border: '1px solid rgba(var(--green-rgb,34,197,94),0.2)',
541
+ cursor: 'pointer',
542
+ opacity: balances[p.provider]?.error ? 0.5 : 1,
543
+ }}
544
+ >
545
+ {balances[p.provider] && !balances[p.provider]?.error ? balances[p.provider]!.balance : balances[p.provider]?.error ? '—' : '...'}
546
+ </button>
552
547
  )}
553
548
  </div>
554
549
 
555
550
  {/* Accordion body — expandable */}
556
551
  {isExpanded && (
557
552
  <div style={{ padding: '0 18px 16px 18px', display: 'grid', gap: 14, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
558
- {/* API Key input con show/hide integrato */}
553
+ {/* API Key input with an inline show/hide toggle */}
559
554
  <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
560
555
  <PasswordInput
561
556
  value={apiKeyInputs[p.provider] ?? ''}
package/bin/rev4a.js CHANGED
@@ -41,8 +41,8 @@ const DATA_DIR = join(CONFIG_DIR, 'data');
41
41
  const ENV_SOURCE = join(CONFIG_DIR, '.env');
42
42
  const ENV_LINK = join(ROOT, '.env');
43
43
  const DOCKERFILE = join(ROOT, 'agent-templates', 'base-image', 'Dockerfile');
44
- const AGENT_IMAGE = 'openclaw-agent-base:latest';
45
- const AGENT_IMAGE_REGISTRY = 'ghcr.io/flame0510/rev4a/openclaw-agent-base:latest';
44
+ // Supported OpenClaw versions and image repositories, shared with the server.
45
+ const AGENT_VERSIONS = require(join(ROOT, 'lib', 'agent-versions.json'));
46
46
  const DOCKER_NETWORK = 'rev4a-network';
47
47
  // How long `serve` waits for the Docker daemon before starting without it. Like
48
48
  // REV4A_DATA_DIR it is read from the real process environment (a systemd
@@ -204,55 +204,57 @@ function ensureDocker() {
204
204
  function ensureAgentImage() {
205
205
  if (!hasCmd('docker')) return; // Docker not available — skip, warn later
206
206
 
207
- // Check if image already exists locally
207
+ // The newest OpenClaw version this release supports, kept under its version tag.
208
+ // `:latest` is never used: an agent's version decides its data schema.
209
+ const version = AGENT_VERSIONS.supported[0].version;
210
+ const localRef = `${AGENT_VERSIONS.localRepository}:${version}`;
211
+ const registry = (process.env.REV4A_AGENT_IMAGE_REGISTRY || '').trim() || AGENT_VERSIONS.registryRepository;
212
+ const remoteRef = `${registry}:${version}`;
213
+
208
214
  try {
209
- const r = execSync(
210
- `docker image inspect ${AGENT_IMAGE} --format '{{.Id}}'`,
211
- { stdio: 'pipe', timeout: 10_000 },
212
- );
215
+ const r = execSync(`docker image inspect ${localRef} --format '{{.Id}}'`, { stdio: 'pipe', timeout: 10_000 });
213
216
  if (r.toString().trim()) {
214
- log('IMAGE', `Agent base image ${AGENT_IMAGE} already exists`);
217
+ log('IMAGE', `Agent base image ${localRef} already exists`);
215
218
  return;
216
219
  }
217
220
  } catch {
218
- // Image doesn't exist locally
221
+ // Not downloaded yet
219
222
  }
220
223
 
221
- log('IMAGE', `Pulling agent base image from ${AGENT_IMAGE_REGISTRY}…`);
222
-
224
+ log('IMAGE', `Pulling agent base image ${remoteRef}…`);
223
225
  try {
224
- execSync(`docker pull ${AGENT_IMAGE_REGISTRY}`, {
225
- stdio: 'inherit',
226
- timeout: 300_000, // 5 min timeout
227
- });
228
- // Tag locally for Rev4a internal use
229
- execSync(`docker tag ${AGENT_IMAGE_REGISTRY} ${AGENT_IMAGE}`, {
230
- stdio: 'inherit',
231
- timeout: 10_000,
232
- });
233
- log('IMAGE', `Agent base image pulled and tagged as ${AGENT_IMAGE}`);
226
+ execSync(`docker pull ${remoteRef}`, { stdio: 'inherit', timeout: 1_800_000 });
227
+ execSync(`docker tag ${remoteRef} ${localRef}`, { stdio: 'inherit', timeout: 10_000 });
228
+ try {
229
+ execSync(`docker rmi ${remoteRef}`, { stdio: 'pipe', timeout: 30_000 }); // untag; the layers stay
230
+ } catch { /* keeping the registry reference is harmless */ }
231
+ log('IMAGE', `Agent base image pulled and tagged as ${localRef}`);
232
+ return;
234
233
  } catch {
235
- log('IMAGE', `⚠ Pull from registry failed — attempting local build as fallback…`);
236
-
237
- if (!existsSync(DOCKERFILE)) {
238
- log('IMAGE', `⚠ Agent base image Dockerfile not found at ${DOCKERFILE}`);
239
- log('IMAGE', ' Agent creation will be unavailable until the image is available.');
240
- return;
241
- }
234
+ log('IMAGE', '⚠ Pull from registry failed');
235
+ }
242
236
 
243
- log('IMAGE', `Building agent base image ${AGENT_IMAGE} (this will take a few minutes)…`);
244
- log('IMAGE', ' → pulling node:24-bookworm-slim + installing OpenClaw + tooling');
237
+ // A local build is only the same image when this Dockerfile builds that version.
238
+ const dockerfileVersion = existsSync(DOCKERFILE)
239
+ ? (/^ARG OPENCLAW_VERSION=(\S+)/m.exec(readFileSync(DOCKERFILE, 'utf-8')) || [])[1] || null
240
+ : null;
241
+ if (dockerfileVersion !== version) {
242
+ log('IMAGE', ` The Dockerfile here builds ${dockerfileVersion || 'no version'}, not ${version}: no local build.`);
243
+ log('IMAGE', ' Agent creation will be unavailable until the image is downloaded from the Agents page.');
244
+ return;
245
+ }
245
246
 
246
- try {
247
- execSync(`docker build -t ${AGENT_IMAGE} -f "${DOCKERFILE}" "${join(DOCKERFILE, '..')}"`, {
248
- stdio: 'inherit',
249
- timeout: 600_000, // 10 min timeout
250
- });
251
- log('IMAGE', `Agent base image built successfully`);
252
- } catch {
253
- log('IMAGE', '⚠ Agent base image build failed. Agent creation will be unavailable.');
254
- log('IMAGE', ' Pull the image manually: docker pull ' + AGENT_IMAGE_REGISTRY);
255
- }
247
+ log('IMAGE', `Building agent base image ${localRef} (this will take a few minutes)…`);
248
+ log('IMAGE', ' → pulling node:24-bookworm-slim + installing OpenClaw + tooling');
249
+ try {
250
+ execSync(
251
+ `docker build --build-arg OPENCLAW_VERSION=${version} -t ${localRef} -f "${DOCKERFILE}" "${join(DOCKERFILE, '..')}"`,
252
+ { stdio: 'inherit', timeout: 3_600_000 },
253
+ );
254
+ log('IMAGE', 'Agent base image built successfully');
255
+ } catch {
256
+ log('IMAGE', '⚠ Agent base image build failed. Agent creation will be unavailable.');
257
+ log('IMAGE', ` Pull it manually: docker pull ${remoteRef} && docker tag ${remoteRef} ${localRef}`);
256
258
  }
257
259
  }
258
260
 
@@ -639,7 +641,7 @@ EXAMPLES:
639
641
  On first run, rev4a serve automatically:
640
642
  • Generates .env (JWT secret + API token)
641
643
  • Installs Docker if missing (requires root)
642
- • Builds the agent base image (openclaw-agent-base:latest)
644
+ • Downloads the agent base image for the newest supported OpenClaw version
643
645
  • Builds the Next.js app if no build is found
644
646
  • Starts Next.js, daemon, and terminal WebSocket as a single process group
645
647
  • Cleanly stops all services on Ctrl+C / SIGTERM
package/daemon.js CHANGED
@@ -668,12 +668,12 @@ function pollSessions() {
668
668
  // ── Timeout Detection ────────────────────────────────────────────────────
669
669
  // Find sub-agents that were 'working' in the last snapshot but are no longer
670
670
  // present in the current output and have been missing for more than 10 minutes.
671
- // Questi vengono marcati come 'timeout' nella tabella events.
672
- const TIMEOUT_THRESHOLD_MS = 10 * 60 * 1000; // 10 minuti
671
+ // These are recorded as 'timeout' in the events table.
672
+ const TIMEOUT_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
673
673
  for (const [session_id, snap] of knownSessions) {
674
674
  // Only sub-agents (contain 'subagent' in the key)
675
675
  if (!session_id.includes('subagent')) continue;
676
- // Solo se erano working nell'ultimo poll
676
+ // Only if they were working in the last poll
677
677
  if (snap.status !== 'working') continue;
678
678
  // Only if not present in the current poll
679
679
  if (currentIds.has(session_id)) continue;
@@ -694,10 +694,10 @@ function pollSessions() {
694
694
  data: JSON.stringify({
695
695
  missing_for_ms: missingFor,
696
696
  last_status: snap.status,
697
- message: `Subagente missing dopo ${Math.round(missingFor / 60000)}min senza completare`,
697
+ message: `Subagent missing for ${Math.round(missingFor / 60000)} min without completing`,
698
698
  }),
699
699
  });
700
- log(`[TIMEOUT] ${session_id} missing da ${Math.round(missingFor / 60000)}min`);
700
+ log(`[TIMEOUT] ${session_id} missing for ${Math.round(missingFor / 60000)} min`);
701
701
 
702
702
  // Notify Michele via openclaw message (only if openclaw is available)
703
703
  try {
@@ -708,7 +708,7 @@ function pollSessions() {
708
708
  'message', 'send',
709
709
  '--account', 'ops',
710
710
  '--target', '297086793',
711
- '--text', `⚠️ Rev4a: agent timeout\n\`${session_id.slice(-36)}\`\nMissing for ${Math.round(missingFor / 60000)} min senza completare.`,
711
+ '--text', `⚠️ Rev4a: agent timeout\n\`${session_id.slice(-36)}\`\nMissing for ${Math.round(missingFor / 60000)} min without completing.`,
712
712
  ], { encoding: 'utf8', timeout: 10_000, killSignal: 'SIGKILL' });
713
713
  } catch (e) {
714
714
  log(`[TIMEOUT] Telegram notification failed: ${e.message}`);