@flame0510/project-aether 1.1.12 → 1.1.14

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 (72) hide show
  1. package/README.md +4 -1
  2. package/app/agents/PageClient.tsx +140 -34
  3. package/app/agents/create/PageClient.tsx +11 -4
  4. package/app/api/agents/[id]/recreate/route.ts +3 -3
  5. package/app/api/agents/[id]/route.ts +3 -1
  6. package/app/api/agents/channels-summary/route.ts +48 -17
  7. package/app/api/agents/create/route.ts +32 -13
  8. package/app/api/agents/download-image/route.ts +2 -0
  9. package/app/api/agents/image-status/route.ts +94 -85
  10. package/app/api/agents/route.ts +10 -53
  11. package/app/api/agents/token/route.ts +4 -1
  12. package/app/api/auth/check/route.ts +2 -23
  13. package/app/api/auth/login/route.ts +3 -35
  14. package/app/api/auth/logout/route.ts +1 -1
  15. package/app/api/auth/status/route.ts +2 -21
  16. package/app/api/config/env/route.ts +7 -3
  17. package/app/api/config/restart/route.ts +2 -0
  18. package/app/api/containers/route.ts +3 -34
  19. package/app/api/crons/route.ts +2 -0
  20. package/app/api/debug/route.ts +2 -0
  21. package/app/api/envcheck/route.ts +3 -1
  22. package/app/api/gateway/agent/route.ts +8 -6
  23. package/app/api/gateway/provider/route.ts +43 -5
  24. package/app/api/gateway/route.ts +104 -136
  25. package/app/api/gateway/sync.ts +12 -15
  26. package/app/api/models/route.ts +2 -0
  27. package/app/api/provider/upstream.ts +12 -3
  28. package/app/api/setup/agent-image/route.ts +8 -3
  29. package/app/api/setup/password/route.ts +32 -37
  30. package/app/api/setup/restart/route.ts +8 -2
  31. package/app/api/skills/delete/route.js +1 -1
  32. package/app/api/skills/promote/route.js +1 -1
  33. package/app/api/skills/route.js +89 -97
  34. package/app/api/skills/save/route.js +18 -5
  35. package/app/api/update-check/route.ts +4 -23
  36. package/app/api/wizard/complete/route.ts +7 -3
  37. package/app/api/wizard/reset/route.ts +8 -3
  38. package/app/api/wizard/status/route.ts +8 -5
  39. package/app/components/AuthGuard.tsx +29 -8
  40. package/app/components/Skeleton.tsx +71 -0
  41. package/app/components/ui/ModalityIcons.tsx +157 -0
  42. package/app/components/ui/index.ts +1 -0
  43. package/app/crons/PageClient.tsx +32 -7
  44. package/app/gateway/PageClient.tsx +104 -16
  45. package/bin/rev4a.js +35 -24
  46. package/docs/ARCHITECTURE.md +31 -14
  47. package/docs/FRONTEND-ARCHITECTURE.md +1 -0
  48. package/docs/REV4A.md +36 -6
  49. package/docs/dev/API-REFERENCE.md +13 -8
  50. package/docs/dev/GATEWAY.md +42 -1
  51. package/docs/dev/PROVIDERS.md +4 -3
  52. package/docs/rag/REV4A-OVERVIEW.md +1 -1
  53. package/instrumentation.ts +18 -0
  54. package/lib/agent-setup.ts +8 -9
  55. package/lib/buildAgentImage.ts +5 -1
  56. package/lib/channelManager.ts +23 -16
  57. package/lib/db.ts +0 -10
  58. package/lib/docker-exec.ts +195 -0
  59. package/lib/docker-socket.ts +121 -0
  60. package/lib/docker-utils.ts +22 -48
  61. package/lib/openclaw-cron.ts +81 -48
  62. package/lib/openrouter-pricing.ts +184 -0
  63. package/lib/rev4a-auth.d.ts +4 -1
  64. package/lib/rev4a-auth.js +68 -20
  65. package/lib/rev4a-paths.ts +19 -0
  66. package/model-pricing.json +50 -50
  67. package/models.config.json +2 -2
  68. package/package.json +4 -1
  69. package/proxy.ts +68 -0
  70. package/scripts/refresh-model-pricing.mjs +171 -0
  71. package/lib/auth.ts +0 -28
  72. package/lib/requireAuth.tsx +0 -38
@@ -0,0 +1,157 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Modality badges for a model.
5
+ *
6
+ * Reads the `modality` string that ships in models.config.json (and matches
7
+ * OpenRouter's `architecture.modality` format verbatim), e.g.
8
+ * "text+image+file->text" → accepts text, images, files
9
+ * "text+image+file+audio+video->text+image" → also *produces* images
10
+ *
11
+ * Inputs are shown as icons; a non-text output is called out separately,
12
+ * since "can read an image" and "can generate an image" are very different
13
+ * capabilities and collapsing them would be misleading.
14
+ */
15
+
16
+ interface ModalitySpec {
17
+ label: string;
18
+ icon: React.ReactNode;
19
+ }
20
+
21
+ const ICONS: Record<string, ModalitySpec> = {
22
+ text: {
23
+ label: 'Text',
24
+ icon: (
25
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
26
+ <path d="M3 4h10M3 8h10M3 12h6" />
27
+ </svg>
28
+ ),
29
+ },
30
+ image: {
31
+ label: 'Images',
32
+ icon: (
33
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4">
34
+ <rect x="2" y="3" width="12" height="10" rx="1.5" />
35
+ <circle cx="6" cy="6.5" r="1.2" />
36
+ <path d="M3 11.5l3.2-3 2.3 2.2 2-1.8 2.5 2.6" strokeLinecap="round" strokeLinejoin="round" />
37
+ </svg>
38
+ ),
39
+ },
40
+ file: {
41
+ label: 'Files / PDF',
42
+ icon: (
43
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round">
44
+ <path d="M9 2H4.5A1.5 1.5 0 0 0 3 3.5v9A1.5 1.5 0 0 0 4.5 14h7a1.5 1.5 0 0 0 1.5-1.5V6z" />
45
+ <path d="M9 2v4h4" />
46
+ </svg>
47
+ ),
48
+ },
49
+ audio: {
50
+ label: 'Audio',
51
+ icon: (
52
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
53
+ <path d="M8 2.5v11" />
54
+ <path d="M5 5.5v5M11 5.5v5" />
55
+ <path d="M2.5 7.5v1M13.5 7.5v1" />
56
+ </svg>
57
+ ),
58
+ },
59
+ video: {
60
+ label: 'Video',
61
+ icon: (
62
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round">
63
+ <rect x="2" y="4" width="8.5" height="8" rx="1.5" />
64
+ <path d="M10.5 8l3.5-2v6l-3.5-2z" />
65
+ </svg>
66
+ ),
67
+ },
68
+ };
69
+
70
+ export interface ParsedModality {
71
+ inputs: string[];
72
+ outputs: string[];
73
+ }
74
+
75
+ /** Split "text+image->text+image" into its input and output lists. */
76
+ export function parseModality(modality: string | undefined | null): ParsedModality | null {
77
+ if (!modality || typeof modality !== 'string') return null;
78
+ const [rawIn, rawOut] = modality.split('->');
79
+ if (!rawIn) return null;
80
+ const split = (s: string) => s.split('+').map((p) => p.trim()).filter(Boolean);
81
+ return { inputs: split(rawIn), outputs: rawOut ? split(rawOut) : [] };
82
+ }
83
+
84
+ /** Own-property lookup: a token like "constructor" must not resolve to Object.prototype. */
85
+ function iconFor(token: string): ModalitySpec | null {
86
+ return Object.hasOwn(ICONS, token) ? ICONS[token] : null;
87
+ }
88
+
89
+ interface ModalityIconsProps {
90
+ modality?: string | null;
91
+ /**
92
+ * Router models (OpenRouter Auto) advertise the union of everything they
93
+ * might route to, which reads as a guarantee it isn't. They already show
94
+ * "Dynamic" for price; capabilities get the same treatment rather than
95
+ * concrete icons.
96
+ */
97
+ dynamic?: boolean;
98
+ }
99
+
100
+ export default function ModalityIcons({ modality, dynamic }: ModalityIconsProps) {
101
+ if (dynamic) {
102
+ return (
103
+ <span
104
+ title="Routed to a different model per request — capabilities vary"
105
+ style={{ fontSize: 10, color: 'var(--text-dim)', fontStyle: 'italic' }}
106
+ >
107
+ varies
108
+ </span>
109
+ );
110
+ }
111
+
112
+ const parsed = parseModality(modality);
113
+ if (!parsed) return null;
114
+
115
+ // Anything the model can produce beyond plain text is worth surfacing:
116
+ // only 15 of 422 upstream models do, and "reads images" vs "makes images"
117
+ // are very different capabilities.
118
+ const generates = parsed.outputs.filter((o) => o !== 'text');
119
+
120
+ return (
121
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, verticalAlign: 'middle' }}>
122
+ {parsed.inputs.map((input) => {
123
+ const spec = iconFor(input);
124
+ if (!spec) return null;
125
+ return (
126
+ <span
127
+ key={input}
128
+ title={`Accepts ${spec.label.toLowerCase()}`}
129
+ aria-label={`Accepts ${spec.label.toLowerCase()}`}
130
+ style={{ display: 'inline-flex', color: 'var(--text-dim)', opacity: 0.75 }}
131
+ >
132
+ {spec.icon}
133
+ </span>
134
+ );
135
+ })}
136
+
137
+ {generates.map((out) => {
138
+ const spec = iconFor(out);
139
+ if (!spec) return null;
140
+ return (
141
+ <span
142
+ key={`out-${out}`}
143
+ title={`Generates ${spec.label.toLowerCase()}`}
144
+ aria-label={`Generates ${spec.label.toLowerCase()}`}
145
+ style={{
146
+ display: 'inline-flex', alignItems: 'center', gap: 2,
147
+ color: 'var(--violet)', fontSize: 9, fontWeight: 600,
148
+ }}
149
+ >
150
+ <span aria-hidden="true">→</span>
151
+ {spec.icon}
152
+ </span>
153
+ );
154
+ })}
155
+ </span>
156
+ );
157
+ }
@@ -17,3 +17,4 @@ export * from './tokens';
17
17
  export * from './Tabs';
18
18
  export * from './ItemList';
19
19
  export * from './TemplateOption';
20
+ export { default as ModalityIcons, parseModality, type ParsedModality } from './ModalityIcons';
@@ -3,7 +3,7 @@
3
3
  import { useEffect, useState, useRef } from 'react';
4
4
  import { useResponsive } from '../design-system';
5
5
  import { Button, Pill } from '../components/ui';
6
- import { SkeletonLines } from '../components/Skeleton';
6
+ import { CronJobsSkeleton, CronRunsSkeleton } from '../components/Skeleton';
7
7
  import { apiFetch } from '@/lib/apiFetch';
8
8
  import { useRev4aTimezone } from '@/lib/hooks/useRev4aTimezone';
9
9
  import { formatDateTimeInTimezone } from '@/lib/timezone';
@@ -266,9 +266,28 @@ export default function CronsPage() {
266
266
 
267
267
  useEffect(() => {
268
268
  const controller = new AbortController();
269
- void load(controller.signal);
270
- const t = setInterval(() => { if (!controller.signal.aborted) void load(controller.signal); }, 15_000);
271
- return () => { controller.abort(); clearInterval(t); };
269
+ let timer: ReturnType<typeof setTimeout> | undefined;
270
+
271
+ // Chained setTimeout rather than setInterval, deliberately.
272
+ //
273
+ // GET /api/crons runs `openclaw cron list --json` inside every agent
274
+ // container. That command takes ~5s per agent and the route walks them in
275
+ // sequence, so the response time scales with the number of agents and is
276
+ // already well past 15s on a multi-agent host. setInterval fires on schedule
277
+ // whether or not the previous call has come back, so those requests pile up
278
+ // — and since the route blocks the server's event loop while it runs, the
279
+ // whole dashboard stops answering for as long as this page stays open.
280
+ //
281
+ // Scheduling the next run only once the current one has settled turns the
282
+ // delay into a gap between polls instead of a deadline, so the loop can
283
+ // never outrun the endpoint no matter how slow it gets.
284
+ const poll = async () => {
285
+ await load(controller.signal);
286
+ if (!controller.signal.aborted) timer = setTimeout(poll, 15_000);
287
+ };
288
+ void poll();
289
+
290
+ return () => { controller.abort(); if (timer) clearTimeout(timer); };
272
291
  }, []);
273
292
 
274
293
  return (
@@ -278,7 +297,9 @@ export default function CronsPage() {
278
297
  <span className="crons-header__meta">{runs.length} runs loaded</span>
279
298
  </div>
280
299
 
281
- {loading && <SkeletonLines count={4} />}
300
+ {/* No loader here: the skeleton lives inside each column, so the page keeps
301
+ its real layout while loading instead of showing a stack of bars above
302
+ two empty panels. */}
282
303
 
283
304
  {/* Agent filter bar */}
284
305
  {agentFilters.length > 1 && (
@@ -307,9 +328,12 @@ export default function CronsPage() {
307
328
  {/* Scheduled jobs */}
308
329
  <section style={{ width: isMobile ? '100%' : '40%', borderRight: isMobile ? 'none' : '1px solid var(--border)', display: isMobile && tab !== 'jobs' ? 'none' : 'flex', flexDirection: 'column', minHeight: 0 }}>
309
330
  <div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)', fontSize: 10, color: '#555', flexShrink: 0 }}>
310
- SCHEDULED JOBS ({filteredJobs.length})
331
+ {/* Hide the count on the cold load — "(0)" beside a skeleton reads as
332
+ "no jobs", which is precisely what isn't known yet. */}
333
+ SCHEDULED JOBS{loading && filteredJobs.length === 0 ? '' : ` (${filteredJobs.length})`}
311
334
  </div>
312
335
  <div style={{ flex: 1, overflowY: 'auto' }}>
336
+ {loading && filteredJobs.length === 0 && <CronJobsSkeleton />}
313
337
  {filteredJobs.length === 0 && !loading && (
314
338
  <div style={{ padding: '20px 12px', fontSize: 11, color: '#555' }}>No scheduled jobs found</div>
315
339
  )}
@@ -471,10 +495,11 @@ export default function CronsPage() {
471
495
  RUN HISTORY {selectedJob ? `— ${selectedJob.name ?? selectedJob.id?.slice(0, 8) ?? ''}` : ''}
472
496
  </div>
473
497
  <div style={{ flex: 1, overflowY: 'auto' }}>
498
+ {loading && !selectedJob && <CronRunsSkeleton />}
474
499
  {!selectedJob && !loading && (
475
500
  <div style={{ padding: '20px 12px', fontSize: 11, color: '#555' }}>Select a cron job to view its run history</div>
476
501
  )}
477
- {selectedJob && runsLoading && <SkeletonLines count={3} />}
502
+ {selectedJob && runsLoading && <CronRunsSkeleton />}
478
503
  {selectedJob && !runsLoading && runsError && (
479
504
  <div style={{ padding: '20px 12px', fontSize: 11, color: 'var(--red)' }}>{runsError}</div>
480
505
  )}
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from 'react';
4
- import { Button, ConfirmModal, LoadingSpinner, Metric, Page, PageHeader, Pill, Surface, Tabs, type Tone } from '../components/ui';
4
+ import { Button, ConfirmModal, Input, LoadingSpinner, Metric, ModalityIcons, Page, PageHeader, Pill, Surface, Tabs, type Tone } from '../components/ui';
5
5
  import PasswordInput from '../components/PasswordInput';
6
6
  import { Skeleton } from '../components/Skeleton';
7
7
 
@@ -14,6 +14,12 @@ interface ModelInfo {
14
14
  id: string;
15
15
  name: string;
16
16
  enabled: boolean;
17
+ /** USD per 1M tokens. -1/-1 means the model routes dynamically. */
18
+ pricing?: { input: number; output: number };
19
+ /** Set when the price came from the live OpenRouter catalogue, not the static file. */
20
+ pricingLive?: boolean;
21
+ /** e.g. "text+image+file->text" — curated, matches what sync.ts provisions. */
22
+ modality?: string;
17
23
  }
18
24
 
19
25
  interface GatewayProvider {
@@ -345,6 +351,9 @@ export default function GatewayPageClient() {
345
351
  const [revealedKeys, setRevealedKeys] = useState<Record<string, string | null>>({});
346
352
  const [revealing, setRevealing] = useState<Record<string, boolean>>({});
347
353
 
354
+ // Per-provider model filter (purely local, no requests)
355
+ const [modelSearch, setModelSearch] = useState<Record<string, string>>({});
356
+
348
357
  // Agent edit state
349
358
  const [editingAgent, setEditingAgent] = useState<string | null>(null);
350
359
  const [agentModelInput, setAgentModelInput] = useState('');
@@ -431,6 +440,24 @@ export default function GatewayPageClient() {
431
440
  return `${f(p.input)} / ${f(p.output)}`;
432
441
  }
433
442
 
443
+ /**
444
+ * Local model filter. Matches against both the display name and the id,
445
+ * because the upstream vendor often only appears in one of them — e.g.
446
+ * "Kimi K2 (OR)" has id `openrouter/moonshotai/kimi-k2`, so searching
447
+ * either "kimi" or "moonshot" has to find it.
448
+ *
449
+ * Every whitespace-separated term must match somewhere, so "kimi code"
450
+ * narrows rather than widening.
451
+ */
452
+ function filterModels<T extends { id: string; name: string }>(models: T[], query: string): T[] {
453
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
454
+ if (terms.length === 0) return models;
455
+ return models.filter((m) => {
456
+ const haystack = `${m.name} ${m.id}`.toLowerCase();
457
+ return terms.every((t) => haystack.includes(t));
458
+ });
459
+ }
460
+
434
461
  /* ---- Overlay helper ---- */
435
462
  async function withOverlay<T>(message: string, fn: () => Promise<T>): Promise<T> {
436
463
  setOverlay({ active: true, message });
@@ -904,33 +931,93 @@ export default function GatewayPageClient() {
904
931
  )}
905
932
 
906
933
  {/* Models with checkboxes */}
907
- {p.models.length > 0 && (
934
+ {p.models.length > 0 && (() => {
935
+ const query = modelSearch[p.provider] || '';
936
+ const visibleModels = filterModels(p.models, query);
937
+ // Below this many, everything fits on screen and a
938
+ // search box is just noise.
939
+ const showSearch = p.models.length > 6;
940
+ return (
908
941
  <div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
909
- <div style={{ fontSize: 11, color: 'var(--text-dim)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
910
- Models
911
- <span style={{ marginLeft: 8, textTransform: 'none', letterSpacing: 'normal', color: 'var(--violet)', opacity: 0.7, fontSize: 10 }}>
912
- in / out per 1M tokens
913
- </span>
942
+ <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
943
+ <div style={{ fontSize: 11, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
944
+ Models
945
+ <span style={{ marginLeft: 8, textTransform: 'none', letterSpacing: 'normal', color: 'var(--violet)', opacity: 0.7, fontSize: 10 }}>
946
+ in / out per 1M tokens
947
+ </span>
948
+ </div>
949
+ {query && (
950
+ <span style={{ fontSize: 10, color: 'var(--text-dim)' }}>
951
+ {visibleModels.length} of {p.models.length}
952
+ </span>
953
+ )}
914
954
  </div>
955
+
956
+ {showSearch && (
957
+ <div style={{ marginBottom: 8 }}>
958
+ {/* Deliberately type="text": Safari clears type="search"
959
+ on Escape via a `search` event React never sees,
960
+ leaving an empty box over a still-filtered list. */}
961
+ <Input
962
+ type="text"
963
+ value={query}
964
+ placeholder="Filter by name or provider — e.g. kimi, moonshot, flash"
965
+ aria-label={`Filter ${p.label} models`}
966
+ onChange={(e) => setModelSearch((prev) => ({ ...prev, [p.provider]: e.target.value }))}
967
+ />
968
+ </div>
969
+ )}
970
+
915
971
  <div style={{ display: 'grid', gap: 4, maxHeight: 360, overflowY: 'auto' }}>
916
- {p.models.map((m) => {
972
+ {visibleModels.length === 0 && (
973
+ <div style={{ fontSize: 12, color: 'var(--text-dim)', padding: '8px 4px' }}>
974
+ No model matches “{query}”.
975
+ </div>
976
+ )}
977
+ {visibleModels.map((m) => {
917
978
  const modelMsg = messages[`model-${m.id}`];
979
+ const pricing = m.pricing;
980
+ // -1/-1 is OpenRouter's marker for router models,
981
+ // whose price and capabilities both vary per call.
982
+ const isDynamic = pricing?.input === -1 && pricing?.output === -1;
918
983
  return (
919
- <label key={m.id} style={{
920
- display: 'flex', alignItems: 'center', gap: 10, padding: '5px 8px', borderRadius: 0,
984
+ // Only the name/id are inside the <label>: clicking
985
+ // anywhere in a label toggles its checkbox, and the
986
+ // badges below carry tooltips people will click to
987
+ // read — which would silently flip the model and
988
+ // restart every agent container.
989
+ <div key={m.id} style={{
990
+ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 8px',
921
991
  background: m.enabled ? 'rgba(var(--green-rgb,34,197,94),0.06)' : 'transparent',
922
- cursor: overlay.active ? 'wait' : 'pointer', fontSize: 12,
992
+ fontSize: 12,
923
993
  }}>
924
994
  <input type="checkbox" checked={m.enabled} disabled={overlay.active}
995
+ id={`model-${m.id}`}
925
996
  onChange={(e) => handleToggleModel(m.id, e.target.checked)}
926
997
  style={{ accentColor: 'var(--green)', cursor: 'pointer' }} />
927
- <div style={{ flex: 1 }}>
928
- <div style={{ fontWeight: m.enabled ? 500 : 400 }}>{m.name}</div>
998
+ <div style={{ flex: 1, minWidth: 0 }}>
999
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
1000
+ <label
1001
+ htmlFor={`model-${m.id}`}
1002
+ style={{ fontWeight: m.enabled ? 500 : 400, cursor: overlay.active ? 'wait' : 'pointer' }}
1003
+ >
1004
+ {m.name}
1005
+ </label>
1006
+ <ModalityIcons modality={m.modality} dynamic={isDynamic} />
1007
+ </div>
929
1008
  <div style={{ fontSize: 11, color: 'var(--text-dim)', fontFamily: 'monospace' }}>
930
1009
  {m.id}
931
1010
  <span style={{ marginLeft: 8, color: 'var(--violet)', fontWeight: 500 }}>
932
- {fmtPrice((m as any).pricing)}
1011
+ {fmtPrice(pricing)}
933
1012
  </span>
1013
+ {m.pricingLive && (
1014
+ <span
1015
+ title="Live price from the OpenRouter API"
1016
+ style={{ marginLeft: 6, display: 'inline-block', verticalAlign: 'middle' }}
1017
+ >
1018
+ <Pill tone="success">LIVE</Pill>
1019
+ </span>
1020
+ )}
934
1021
  </div>
935
1022
  </div>
936
1023
  {modelMsg && (
@@ -938,12 +1025,13 @@ export default function GatewayPageClient() {
938
1025
  {modelMsg.text}
939
1026
  </span>
940
1027
  )}
941
- </label>
1028
+ </div>
942
1029
  );
943
1030
  })}
944
1031
  </div>
945
1032
  </div>
946
- )}
1033
+ );
1034
+ })()}
947
1035
 
948
1036
  {msg && (
949
1037
  <div className={msg.type === 'success' ? 'msg-success' : 'msg-error'}>
package/bin/rev4a.js CHANGED
@@ -29,8 +29,14 @@ const os = require('os');
29
29
  const ROOT = join(__dirname, '..');
30
30
  const NEXT_DIR = join(ROOT, '.next');
31
31
  const PKG_JSON = join(ROOT, 'package.json');
32
- // Config lives outside the npm package so it survives npm update / reinstall
33
- const CONFIG_DIR = join(os.homedir(), '.config', 'rev4a');
32
+ // Config lives outside the npm package so it survives npm update / reinstall.
33
+ // REV4A_DATA_DIR must be honoured here exactly as rev4aDataDir() honours it in
34
+ // lib/rev4a-paths.ts: this file creates the shared directories that the app then
35
+ // bind-mounts into agents, so a divergence would have serve writing the system
36
+ // rules to one path while every agent mounted another — silently empty.
37
+ // It can only come from the real process environment, never from the .env file:
38
+ // that file is looked up under this very directory.
39
+ const CONFIG_DIR = process.env.REV4A_DATA_DIR || join(os.homedir(), '.config', 'rev4a');
34
40
  const DATA_DIR = join(CONFIG_DIR, 'data');
35
41
  const ENV_SOURCE = join(CONFIG_DIR, '.env');
36
42
  const ENV_LINK = join(ROOT, '.env');
@@ -213,14 +219,26 @@ function ensureNetwork() {
213
219
  // ── Rev4a system rules ───────────────────────────────────────────────────
214
220
 
215
221
  function ensureRev4aRules() {
216
- // Ensure all Rev4a host directories exist (shared-skills, rev4a-rules, …).
217
- // Defined centrally in lib/agent-setup so create & recreate routes stay in sync.
222
+ // These must mirror SHARED_SKILLS_DIR / REV4A_RULES_DIR in lib/rev4a-paths.ts.
223
+ // This file is CommonJS and runs before .next exists (it is what builds it),
224
+ // so it cannot import from lib/ — same reason CONFIG_DIR is recomputed above.
225
+ //
226
+ // It previously tried `require('.next/server/lib/agent-setup.js')` to reuse
227
+ // ensureHostDirs(). Next bundles route code and never emits that path, so the
228
+ // require always threw into an empty catch and the directories were never
229
+ // created here at all.
230
+ const sharedDir = join(CONFIG_DIR, 'shared');
231
+ const sharedSkillsDir = join(sharedDir, 'shared-skills');
232
+ const rev4aRulesDir = join(sharedDir, 'rev4a-rules');
233
+
218
234
  try {
219
- const { ensureHostDirs } = require(join(ROOT, '.next', 'server', 'lib', 'agent-setup.js'));
220
- if (typeof ensureHostDirs === 'function') ensureHostDirs();
221
- } catch { /* .next not built yet — dirs may already exist */ }
235
+ mkdirSync(sharedSkillsDir, { recursive: true });
236
+ mkdirSync(rev4aRulesDir, { recursive: true });
237
+ } catch (err) {
238
+ log('RULES', `⚠ Could not create shared directories: ${err.message}`);
239
+ return;
240
+ }
222
241
 
223
- const rev4aRulesDir = '/docker/rev4a-rules';
224
242
  const ruleFile = join(rev4aRulesDir, 'AGENTS.md');
225
243
  const source = join(ROOT, 'rev4a-rules', 'AGENTS.md');
226
244
 
@@ -241,14 +259,10 @@ function ensureRev4aRules() {
241
259
  }
242
260
 
243
261
  try {
244
- const sudo = isRoot() ? '' : 'sudo ';
245
- if (!existsSync(rev4aRulesDir)) {
246
- execSync(`${sudo}mkdir -p ${rev4aRulesDir}`);
247
- }
248
- const tmpFile = `/tmp/rev4a-rules-${Date.now()}.md`;
249
- writeFileSync(tmpFile, sourceContent, 'utf-8');
250
- execSync(`${sudo}cp ${tmpFile} ${ruleFile} && ${sudo}rm ${tmpFile}`);
251
- log('RULES', 'Deployed rev4a system rules to /docker/rev4a-rules/');
262
+ // Direct write — the old /tmp + `sudo cp` + `sudo rm` dance was only
263
+ // needed because the destination sat outside the user's home.
264
+ writeFileSync(ruleFile, sourceContent, 'utf-8');
265
+ log('RULES', `Deployed rev4a system rules to ${rev4aRulesDir}/`);
252
266
  } catch (err) {
253
267
  log('RULES', `⚠ Failed to deploy rev4a rules: ${err.message}`);
254
268
  }
@@ -539,14 +553,11 @@ const args = process.argv.slice(2);
539
553
  const cmd = args[0];
540
554
 
541
555
  const BANNER = `
542
- ██
543
- ███████╗ ██╗ █████╗ ██╗
544
- ██╔═══██╗ ██║ ██╔══██╗ ██║
545
- ██║ ██║ ██║ ████████║ ██║
546
- ██║ ██║ ██║ ██╔════╝ ██║
547
- ███████╔╝ ██████╗ ██║ ██║
548
- ╚══════╝ ╚═════╝ ╚═╝ ╚═╝
549
-
556
+ ████ █████ █ █ █ █ ███
557
+ █ █ █ █ █ █ █ █ █
558
+ ████ ████ █ █ █████ █████
559
+ █ █ █ █ █ █ █ █
560
+ █ █ █████ █ █ █ █
550
561
  `;
551
562
 
552
563
  const HELP_TEXT = `
@@ -55,7 +55,7 @@
55
55
  │ │
56
56
  │ ┌──────────────────────────────────────────────────────────┐ │
57
57
  │ │ SHARED VOLUMES │ │
58
- │ │ • /docker/shared-skills → read-only on all agents │ │
58
+ │ │ • ~/.config/rev4a/shared/shared-skills → all agents (ro) │ │
59
59
  │ │ • /docker/shared-repos → shared project repos │ │
60
60
  │ └──────────────────────────────────────────────────────────┘ │
61
61
  │ └──────────────────────────────────────────────────────────┘ │
@@ -107,14 +107,14 @@ The central container, running the Next.js dashboard + orchestration API.
107
107
 
108
108
  **Volume mounts (target):**
109
109
  ```
110
- /path/to/rev4a/data → /data (persistent)
111
- /var/run/docker.sock → /var/run/docker.sock
112
- /docker/shared-skills → /data/.openclaw/shared-skills (ro)
113
- /docker/shared-repos → /data/repos (rw)
110
+ /path/to/rev4a/data → /data (persistent)
111
+ /var/run/docker.sock → /var/run/docker.sock
112
+ ~/.config/rev4a/shared/shared-skills → /data/.openclaw/shared-skills (ro)
113
+ /docker/shared-repos → /data/repos (rw)
114
114
  ```
115
115
 
116
116
  **Rev4a System Rules** (`rev4a-rules/AGENTS.md`):
117
- - Versioned in the repo, deployed to `/docker/rev4a-rules/` on every `rev4a serve` start
117
+ - Versioned in the repo, deployed to `~/.config/rev4a/shared/rev4a-rules/` on every `rev4a serve` start
118
118
  - Mounted read-only into every agent at `/root/.openclaw/workspace/.rev4a/AGENTS.md`
119
119
  - Injected into every session via the `bootstrap-extra-files` hook (glob `.rev4a/*.md`)
120
120
  - Immutable by agents — enforced by the `:ro` mount
@@ -132,9 +132,9 @@ copies the entrypoint script, and applies baseline gateway config patches.
132
132
 
133
133
  **Volume mounts (per agent):**
134
134
  ```
135
- /docker/shared-skills → /data/.openclaw/shared-skills (ro)
136
- /docker/rev4a-rules → /root/.openclaw/workspace/.rev4a (ro)
137
- agent-{name}-data (named vol) → /root (rw, per-agent persistent state)
135
+ ~/.config/rev4a/shared/shared-skills → /data/.openclaw/shared-skills (ro)
136
+ ~/.config/rev4a/shared/rev4a-rules → /root/.openclaw/workspace/.rev4a (ro)
137
+ agent-{name}-data (named vol) → /root (rw, per-agent persistent state)
138
138
  ```
139
139
 
140
140
  All shared volumes are defined centrally in `lib/agent-setup.ts`
@@ -147,7 +147,6 @@ and guaranteed at runtime by `applyRuntimeConfig()`.
147
147
  AGENT_NAME=<agent-name>
148
148
  AGENT_TEMPLATE=<template>
149
149
  OPENCLAW_GATEWAY_TOKEN=<shared agent token>
150
- AGENT_HOSTNAME=<host-ip>:<portStart>
151
150
  TZ=Europe/Rome
152
151
  ```
153
152
 
@@ -287,9 +286,9 @@ GET /api/agents List all agents (Docker containers with AGENT
287
286
 
288
287
  ## 6. Shared Volumes
289
288
 
290
- ### Shared Skills (`/docker/shared-skills`)
289
+ ### Shared Skills (`~/.config/rev4a/shared/shared-skills`)
291
290
  ```
292
- /docker/shared-skills/
291
+ ~/.config/rev4a/shared/shared-skills/
293
292
  my-custom-skill/ # promoted from an agent workspace
294
293
  SKILL.md
295
294
  another-skill/
@@ -306,12 +305,30 @@ This keeps the layout simple and avoids hierarchy debates at this stage.
306
305
  **Auto-configuration:**
307
306
  - The agent base image (`openclaw-agent-base`) pre-configures `skills.load.extraDirs: ["/data/.openclaw/shared-skills"]` so every new agent discovers shared skills automatically.
308
307
  - Both create and recreate routes guarantee this via `applyRuntimeConfig()` from `lib/agent-setup.ts`.
309
- - On first install, create `/docker/shared-skills` with `mkdir -p /docker/shared-skills && chmod 2775 /docker/shared-skills`.
308
+ - No manual setup: `rev4a serve` creates both shared directories during
309
+ preflight (`ensureRev4aRules()` in `bin/rev4a.js`). They live under
310
+ `REV4A_DATA`, so a plain `mkdir` suffices — no `sudo`, no permission
311
+ bits to set.
312
+
313
+ > These directories used to live at `/docker/shared-skills` and
314
+ > `/docker/rev4a-rules`, which needed `sudo` on Linux and could not exist at
315
+ > all on macOS — the root volume is a sealed APFS system volume, so `/docker`
316
+ > is not creatable even as root, and agent creation failed with
317
+ > `mounts denied`. Being under the user's home also puts them inside the
318
+ > directories Docker Desktop shares with its Linux VM by default, so bind
319
+ > mounts work with no extra configuration.
320
+ >
321
+ > Upgrading an existing install: move any promoted skills from
322
+ > `/docker/shared-skills` into the new location, then hit **Recreate** on each
323
+ > agent so its container picks up the new mount source. Containers freeze
324
+ > their mounts at creation time, so running agents keep using the old path
325
+ > until recreated. `rev4a-rules/AGENTS.md` needs no migration — it is
326
+ > regenerated from the package on every start.
310
327
 
311
328
  ### Rev4a System Rules (`rev4a-rules/`)
312
329
 
313
330
  Versioned in the repo at `rev4a-rules/AGENTS.md`. Deployed to
314
- `/docker/rev4a-rules/` on every `rev4a serve` startup via
331
+ `~/.config/rev4a/shared/rev4a-rules/` on every `rev4a serve` startup via
315
332
  `ensureRev4aRules()` in `bin/rev4a.js`. Mounted read-only into every
316
333
  agent container and injected into sessions through the
317
334
  `bootstrap-extra-files` hook (glob `.rev4a/*.md`).
@@ -34,6 +34,7 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
34
34
  | `PropertyList` | `PropertyList.tsx` | 2-column grid for label/value pairs. Children rendered as `<Fragment>` — do not wrap in extra `<div>`. |
35
35
  | `TemplateOption` | `TemplateOption.tsx` | Agent template selection card with avatar, description, optional badge. Centralises create-agent card styling. |
36
36
  | `Pill` | `Pill.tsx` | Small status/attribute label. Variants: `default`, `accent`. |
37
+ | `ModalityIcons` | `ModalityIcons.tsx` | Capability badges parsed from a model's `modality` string (`"text+image->text"`). Input types render as icons; a non-text **output** is called out separately, since reading an image and generating one are different capabilities. Pass `dynamic` for router models, which advertise the union of everything they might route to and so show "varies" instead. |
37
38
  | `Metric` | `Metric.tsx` | Metric card with title, value, subtitle, tone. |
38
39
  | `StatusCard` | `StatusCard.tsx` | Health/status report card. |
39
40
  | `Surface` | `Surface.tsx` | Shared panel/card surface, variant prop. |