@flame0510/project-aether 1.1.9

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 (290) hide show
  1. package/README.md +150 -0
  2. package/agent-templates/README.md +37 -0
  3. package/agent-templates/argus/AGENTS.md +52 -0
  4. package/agent-templates/argus/IDENTITY.md +7 -0
  5. package/agent-templates/argus/MEMORY.md +9 -0
  6. package/agent-templates/argus/SOUL.md +24 -0
  7. package/agent-templates/argus/TOOLS.md +8 -0
  8. package/agent-templates/atlas/AGENTS.md +47 -0
  9. package/agent-templates/atlas/HEARTBEAT.md +10 -0
  10. package/agent-templates/atlas/IDENTITY.md +6 -0
  11. package/agent-templates/atlas/MEMORY.md +9 -0
  12. package/agent-templates/atlas/SOUL.md +21 -0
  13. package/agent-templates/atlas/TOOLS.md +4 -0
  14. package/agent-templates/base-image/.trigger +0 -0
  15. package/agent-templates/base-image/Dockerfile +90 -0
  16. package/agent-templates/base-image/entrypoint.sh +37 -0
  17. package/agent-templates/custom/AGENTS.md +29 -0
  18. package/agent-templates/custom/BOOTSTRAP.md +56 -0
  19. package/agent-templates/custom/MEMORY.md +8 -0
  20. package/agent-templates/prometheus/AGENTS.md +37 -0
  21. package/agent-templates/prometheus/IDENTITY.md +6 -0
  22. package/agent-templates/prometheus/MEMORY.md +9 -0
  23. package/agent-templates/prometheus/SOUL.md +21 -0
  24. package/agent-templates/prometheus/TOOLS.md +4 -0
  25. package/app/agents/ChannelManager.tsx +465 -0
  26. package/app/agents/ImageDownloadBanner.tsx +159 -0
  27. package/app/agents/PageClient.tsx +1285 -0
  28. package/app/agents/create/PageClient.tsx +437 -0
  29. package/app/agents/create/page.tsx +126 -0
  30. package/app/agents/loading.tsx +14 -0
  31. package/app/agents/page.tsx +5 -0
  32. package/app/api/agents/[id]/backup/route.ts +169 -0
  33. package/app/api/agents/[id]/channels/pairing/route.ts +69 -0
  34. package/app/api/agents/[id]/channels/route.ts +14 -0
  35. package/app/api/agents/[id]/channels/telegram/route.ts +50 -0
  36. package/app/api/agents/[id]/lifecycle/route.ts +94 -0
  37. package/app/api/agents/[id]/recreate/route.ts +199 -0
  38. package/app/api/agents/[id]/restart/route.ts +33 -0
  39. package/app/api/agents/[id]/restore/route.ts +81 -0
  40. package/app/api/agents/[id]/route.ts +280 -0
  41. package/app/api/agents/channels-summary/route.ts +39 -0
  42. package/app/api/agents/config-json/route.ts +58 -0
  43. package/app/api/agents/create/route.ts +488 -0
  44. package/app/api/agents/download-image/route.ts +32 -0
  45. package/app/api/agents/image-status/route.ts +126 -0
  46. package/app/api/agents/route.ts +213 -0
  47. package/app/api/agents/token/route.ts +94 -0
  48. package/app/api/agents-active/route.ts +190 -0
  49. package/app/api/agents-config/route.ts +629 -0
  50. package/app/api/alerts/smoke/route.ts +26 -0
  51. package/app/api/aliases/add/route.ts +28 -0
  52. package/app/api/aliases/remove/route.ts +27 -0
  53. package/app/api/assistant/route.ts +204 -0
  54. package/app/api/auth/check/route.ts +38 -0
  55. package/app/api/auth/login/route.ts +85 -0
  56. package/app/api/auth/logout/route.ts +7 -0
  57. package/app/api/auth/status/route.ts +33 -0
  58. package/app/api/config/env/route.ts +132 -0
  59. package/app/api/config/restart/route.ts +26 -0
  60. package/app/api/containers/route.ts +75 -0
  61. package/app/api/cost-override/route.ts +46 -0
  62. package/app/api/costs/route.ts +84 -0
  63. package/app/api/credentials/[id]/reveal/route.ts +21 -0
  64. package/app/api/credentials/[id]/route.ts +52 -0
  65. package/app/api/credentials/[id]/sync/route.ts +58 -0
  66. package/app/api/credentials/detect/route.ts +246 -0
  67. package/app/api/credentials/route.ts +33 -0
  68. package/app/api/crons/[id]/route.ts +69 -0
  69. package/app/api/crons/route.ts +12 -0
  70. package/app/api/crons/runs/route.ts +31 -0
  71. package/app/api/debug/route.ts +11 -0
  72. package/app/api/envcheck/route.ts +18 -0
  73. package/app/api/events/route.ts +24 -0
  74. package/app/api/gateway/agent/route.ts +154 -0
  75. package/app/api/gateway/provider/balance/route.ts +38 -0
  76. package/app/api/gateway/provider/keys.ts +38 -0
  77. package/app/api/gateway/provider/oauth/route.ts +304 -0
  78. package/app/api/gateway/provider/route.ts +209 -0
  79. package/app/api/gateway/route.ts +223 -0
  80. package/app/api/gateway/sync.ts +189 -0
  81. package/app/api/lineage/route.ts +54 -0
  82. package/app/api/memory-context/route.ts +15 -0
  83. package/app/api/metrics/route.ts +35 -0
  84. package/app/api/models/route.ts +77 -0
  85. package/app/api/plugins/route.js +37 -0
  86. package/app/api/provider/upstream.ts +142 -0
  87. package/app/api/provider/v1/chat/completions/route.ts +146 -0
  88. package/app/api/provider/v1/models/route.ts +167 -0
  89. package/app/api/provider-usage/route.ts +629 -0
  90. package/app/api/session/route.ts +39 -0
  91. package/app/api/sessions/[id]/route.ts +38 -0
  92. package/app/api/sessions/route.ts +23 -0
  93. package/app/api/setup/agent-image/route.ts +102 -0
  94. package/app/api/setup/password/route.ts +111 -0
  95. package/app/api/setup/restart/route.ts +76 -0
  96. package/app/api/skills/delete/route.js +105 -0
  97. package/app/api/skills/promote/route.js +129 -0
  98. package/app/api/skills/route.js +203 -0
  99. package/app/api/skills/save/route.js +142 -0
  100. package/app/api/stats/route.ts +34 -0
  101. package/app/api/stats-since/route.ts +20 -0
  102. package/app/api/stream/route.ts +70 -0
  103. package/app/api/system-health/route.ts +233 -0
  104. package/app/api/tool-calls/route.ts +24 -0
  105. package/app/api/tools-config/route.ts +163 -0
  106. package/app/api/update-check/route.ts +127 -0
  107. package/app/api/version/route.ts +25 -0
  108. package/app/api/wizard/complete/route.ts +32 -0
  109. package/app/api/wizard/reset/route.ts +28 -0
  110. package/app/api/wizard/status/route.ts +28 -0
  111. package/app/api/workspace/route.ts +392 -0
  112. package/app/api/workspace/stream/route.ts +136 -0
  113. package/app/components/AppShell.tsx +33 -0
  114. package/app/components/AuthGuard.tsx +91 -0
  115. package/app/components/CostBreakdown.tsx +37 -0
  116. package/app/components/DashboardHeader.tsx +73 -0
  117. package/app/components/DashboardLayout.tsx +76 -0
  118. package/app/components/DashboardToolbar.tsx +61 -0
  119. package/app/components/FloatingActions.tsx +33 -0
  120. package/app/components/Icons.tsx +78 -0
  121. package/app/components/LineageGraphPage.tsx +142 -0
  122. package/app/components/LiveFeed.tsx +48 -0
  123. package/app/components/MobileBottomNav.tsx +84 -0
  124. package/app/components/ModelPickerModal.tsx +94 -0
  125. package/app/components/PasswordInput.tsx +86 -0
  126. package/app/components/PulseChat.tsx +362 -0
  127. package/app/components/Rev4aLoader.tsx +48 -0
  128. package/app/components/SessionDrawer.tsx +263 -0
  129. package/app/components/SessionTopology.tsx +289 -0
  130. package/app/components/Sidebar.tsx +294 -0
  131. package/app/components/Skeleton.tsx +133 -0
  132. package/app/components/SystemCockpit.tsx +169 -0
  133. package/app/components/VersionBanner.tsx +105 -0
  134. package/app/components/WizardButton.tsx +30 -0
  135. package/app/components/ui/Badge.tsx +30 -0
  136. package/app/components/ui/Button.tsx +59 -0
  137. package/app/components/ui/ConfirmModal.tsx +46 -0
  138. package/app/components/ui/FilterBar.tsx +33 -0
  139. package/app/components/ui/Input.tsx +25 -0
  140. package/app/components/ui/ItemList.tsx +83 -0
  141. package/app/components/ui/LoadingSpinner.tsx +30 -0
  142. package/app/components/ui/Metric.tsx +22 -0
  143. package/app/components/ui/Modal.tsx +42 -0
  144. package/app/components/ui/Page.tsx +18 -0
  145. package/app/components/ui/Pill.tsx +6 -0
  146. package/app/components/ui/PropertyList.tsx +24 -0
  147. package/app/components/ui/Select.tsx +26 -0
  148. package/app/components/ui/StatusCard.tsx +32 -0
  149. package/app/components/ui/Surface.tsx +14 -0
  150. package/app/components/ui/Tabs.tsx +55 -0
  151. package/app/components/ui/TemplateOption.tsx +59 -0
  152. package/app/components/ui/Toast.tsx +44 -0
  153. package/app/components/ui/index.ts +19 -0
  154. package/app/components/ui/tokens.ts +18 -0
  155. package/app/config/PageClient.tsx +293 -0
  156. package/app/config/loading.tsx +10 -0
  157. package/app/config/page.tsx +7 -0
  158. package/app/containers/ContainersClient.tsx +129 -0
  159. package/app/containers/loading.tsx +21 -0
  160. package/app/containers/page.tsx +5 -0
  161. package/app/containers/terminal/[id]/TerminalClient.tsx +313 -0
  162. package/app/containers/terminal/[id]/page.tsx +6 -0
  163. package/app/credentials/PageClient.tsx +598 -0
  164. package/app/credentials/loading.tsx +10 -0
  165. package/app/credentials/page.tsx +7 -0
  166. package/app/crons/PageClient.tsx +538 -0
  167. package/app/crons/loading.tsx +10 -0
  168. package/app/crons/page.tsx +5 -0
  169. package/app/design-system.ts +57 -0
  170. package/app/gateway/PageClient.tsx +1048 -0
  171. package/app/gateway/loading.tsx +24 -0
  172. package/app/gateway/page.tsx +5 -0
  173. package/app/globals.css +1474 -0
  174. package/app/layout.tsx +45 -0
  175. package/app/lib/models-context.tsx +41 -0
  176. package/app/lineage/loading.tsx +10 -0
  177. package/app/lineage/page.tsx +13 -0
  178. package/app/loading.tsx +16 -0
  179. package/app/login/page.tsx +160 -0
  180. package/app/memory/MemoryContextPageClient.tsx +196 -0
  181. package/app/memory/loading.tsx +10 -0
  182. package/app/memory/page.tsx +8 -0
  183. package/app/page.tsx +31 -0
  184. package/app/plugins/PageClient.tsx +296 -0
  185. package/app/plugins/loading.tsx +10 -0
  186. package/app/plugins/page.tsx +5 -0
  187. package/app/setup/PageClient.tsx +227 -0
  188. package/app/setup/loading.tsx +21 -0
  189. package/app/setup/page.tsx +7 -0
  190. package/app/skills/PageClient.tsx +499 -0
  191. package/app/skills/loading.tsx +10 -0
  192. package/app/skills/page.tsx +5 -0
  193. package/app/tools/ToolsPageClient.tsx +308 -0
  194. package/app/tools/loading.tsx +10 -0
  195. package/app/tools/page.tsx +62 -0
  196. package/app/tools/tools-catalog.ts +141 -0
  197. package/app/wizard/PageClient.tsx +485 -0
  198. package/app/wizard/icons.tsx +89 -0
  199. package/app/wizard/loading.tsx +24 -0
  200. package/app/wizard/page.tsx +7 -0
  201. package/app/wizard/storage.ts +33 -0
  202. package/app/wizard/useWizard.ts +101 -0
  203. package/app/workspace/WorkspaceClient.tsx +633 -0
  204. package/app/workspace/loading.tsx +36 -0
  205. package/app/workspace/page.tsx +8 -0
  206. package/bin/docker-entrypoint.sh +21 -0
  207. package/bin/postinstall.js +20 -0
  208. package/bin/rev4a.js +696 -0
  209. package/daemon.js +783 -0
  210. package/docs/ARCHITECTURE.md +557 -0
  211. package/docs/CONTAINER-TERMINAL.md +280 -0
  212. package/docs/DESIGN-SYSTEM.md +99 -0
  213. package/docs/FRONTEND-ARCHITECTURE.md +119 -0
  214. package/docs/REV4A.md +504 -0
  215. package/docs/dev/API-REFERENCE.md +1835 -0
  216. package/docs/dev/DATABASE.md +224 -0
  217. package/docs/dev/GATEWAY.md +300 -0
  218. package/docs/dev/PROVIDERS.md +229 -0
  219. package/docs/dev/SESSION-MAINTENANCE-PLAN.md +607 -0
  220. package/docs/dev/WORKSPACE.md +199 -0
  221. package/docs/rag/DATA-FRESHNESS.md +109 -0
  222. package/docs/rag/GLOSSARY.md +125 -0
  223. package/docs/rag/REV4A-OVERVIEW.md +135 -0
  224. package/docs/rag/WHAT-I-CAN-ANSWER.md +124 -0
  225. package/lib/agent-setup.ts +130 -0
  226. package/lib/alerts.ts +115 -0
  227. package/lib/apiFetch.ts +7 -0
  228. package/lib/auth.ts +28 -0
  229. package/lib/billing.ts +100 -0
  230. package/lib/buildAgentImage.ts +346 -0
  231. package/lib/channelManager.ts +386 -0
  232. package/lib/container.ts +60 -0
  233. package/lib/credentials/db.ts +86 -0
  234. package/lib/credentials/delivery.ts +448 -0
  235. package/lib/credentials/detect.ts +149 -0
  236. package/lib/credentials/hash.ts +9 -0
  237. package/lib/credentials/providers.ts +110 -0
  238. package/lib/credentials/vault.ts +195 -0
  239. package/lib/db-bootstrap.d.ts +3 -0
  240. package/lib/db-bootstrap.mjs +144 -0
  241. package/lib/db.ts +25 -0
  242. package/lib/docker-utils.ts +60 -0
  243. package/lib/hooks/useDashboard.ts +264 -0
  244. package/lib/hooks/useRev4aTimezone.ts +28 -0
  245. package/lib/hooks/useScreenshot.ts +30 -0
  246. package/lib/memory-context.ts +385 -0
  247. package/lib/model-catalogue.ts +131 -0
  248. package/lib/model-pricing.ts +63 -0
  249. package/lib/openclaw-cron.ts +350 -0
  250. package/lib/patterns/ApiAdapter.ts +73 -0
  251. package/lib/patterns/ApiClient.ts +65 -0
  252. package/lib/patterns/EventBus.ts +113 -0
  253. package/lib/patterns/FilterStrategy.ts +79 -0
  254. package/lib/patterns/SessionFactory.ts +117 -0
  255. package/lib/patterns/sessionPresentation.ts +117 -0
  256. package/lib/provider-balance.ts +163 -0
  257. package/lib/requireAuth.tsx +38 -0
  258. package/lib/rev4a-auth.d.ts +3 -0
  259. package/lib/rev4a-auth.js +36 -0
  260. package/lib/rev4a-paths.ts +145 -0
  261. package/lib/timezone.ts +46 -0
  262. package/lib/types/index.ts +96 -0
  263. package/lib/utils/format.ts +82 -0
  264. package/lineage.js +56 -0
  265. package/model-pricing.json +482 -0
  266. package/models.config.json +844 -0
  267. package/next-env.d.ts +6 -0
  268. package/next.config.mjs +7 -0
  269. package/package.json +82 -0
  270. package/public/apple-touch-icon.png +0 -0
  271. package/public/favicon-64.png +0 -0
  272. package/public/favicon.svg +58 -0
  273. package/public/icon-192-maskable.png +0 -0
  274. package/public/icon-192.png +0 -0
  275. package/public/icon-512-maskable.png +0 -0
  276. package/public/icon-512.png +0 -0
  277. package/public/manifest.json +38 -0
  278. package/public/rev4a-logo.png +0 -0
  279. package/public/rev4a-logo.svg +58 -0
  280. package/rev4a-rules/AGENTS.md +37 -0
  281. package/scripts/add-auth.cjs +185 -0
  282. package/scripts/backup.sh +66 -0
  283. package/scripts/check-providers.mjs +143 -0
  284. package/scripts/docker/traefik/docker-compose.yml +32 -0
  285. package/scripts/docker/traefik/dynamic/.gitkeep +18 -0
  286. package/scripts/docker/traefik/traefik.yml +37 -0
  287. package/scripts/remove-auth-profile.js +56 -0
  288. package/scripts/restore.sh +82 -0
  289. package/terminal-ws-server.js +139 -0
  290. package/tsconfig.json +42 -0
@@ -0,0 +1,392 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { execFileSync, execSync } from 'child_process';
4
+ import { NextResponse, type NextRequest } from 'next/server';
5
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
6
+
7
+ const LOCAL_ROOT = (process.env.REV4A_WORKSPACE_ROOT || '').replace(/\/+$/, '/');
8
+ const HAS_LOCAL_WORKSPACE = LOCAL_ROOT.length > 0;
9
+ const IGNORED_DIRS = new Set(['node_modules', '.trash', '.git', '.next', 'cache']);
10
+ const TEXT_EXTENSIONS = new Set(['.md', '.json', '.txt', '.html', '.py', '.css', '.js', '.ts', '.tsx', '.yaml', '.yml', '.env', '.sh', '.mjs', '.cjs', '.jsx']);
11
+ const BINARY_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.pdf', '.ico']);
12
+ const MIME_MAP: Record<string, string> = {
13
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
14
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
15
+ '.pdf': 'application/pdf', '.ico': 'image/x-icon',
16
+ };
17
+
18
+ interface Workspace {
19
+ id: string;
20
+ label: string;
21
+ type: 'host' | 'container';
22
+ path: string;
23
+ containerName?: string;
24
+ }
25
+
26
+ interface WorkspaceEntry {
27
+ name: string;
28
+ path: string;
29
+ relPath: string;
30
+ type: 'file' | 'directory';
31
+ size: number;
32
+ mtimeMs: number;
33
+ isDirectory: boolean;
34
+ isFile: boolean;
35
+ }
36
+
37
+ function shellEscape(value: string): string {
38
+ return `'${value.replace(/'/g, `'\\''`)}'`;
39
+ }
40
+
41
+ function getWorkspace(id: string): Workspace | null {
42
+ if (id === 'local' && HAS_LOCAL_WORKSPACE) {
43
+ return { id: 'local', label: 'Local', type: 'host', path: LOCAL_ROOT };
44
+ }
45
+ if (id.startsWith('container-')) {
46
+ const name = id.slice(10);
47
+ return { id, label: name, type: 'container', path: '/root/.openclaw/workspace/', containerName: name };
48
+ }
49
+ return null;
50
+ }
51
+
52
+ function listWorkspaces(): Workspace[] {
53
+ const workspaces: Workspace[] = [];
54
+ if (HAS_LOCAL_WORKSPACE) {
55
+ workspaces.push({ id: 'local', label: 'Local', type: 'host', path: LOCAL_ROOT });
56
+ }
57
+
58
+ // Discover container workspaces from Docker
59
+ try {
60
+ const output = execFileSync(
61
+ 'docker',
62
+ ['ps', '--filter', 'label=AGENT_ID', '--format', '{{.Names}}|{{.Label "AGENT_ID"}}'],
63
+ { timeout: 5000, encoding: 'utf-8' },
64
+ ).trim();
65
+ if (output) {
66
+ for (const line of output.split('\n')) {
67
+ const [name, agentId] = line.split('|');
68
+ if (name && agentId) {
69
+ // Read AGENT_NAME env for display name (fallback to agentId)
70
+ let displayName = '';
71
+ try {
72
+ displayName = execFileSync(
73
+ 'docker',
74
+ ['exec', name, 'sh', '-c', 'printenv AGENT_NAME'],
75
+ { timeout: 3000, encoding: 'utf-8' },
76
+ ).trim();
77
+ } catch { /* use fallback */ }
78
+ workspaces.push({
79
+ id: `container-${name}`,
80
+ label: displayName || `${agentId} (${name})`,
81
+ type: 'container',
82
+ path: '/root/.openclaw/workspace/',
83
+ containerName: name,
84
+ });
85
+ }
86
+ }
87
+ }
88
+ } catch {
89
+ // Docker not available, host-only
90
+ }
91
+
92
+ return workspaces;
93
+ }
94
+
95
+ function compareWorkspaceEntries(a: WorkspaceEntry, b: WorkspaceEntry): number {
96
+ const aSegs = a.relPath.split('/');
97
+ const bSegs = b.relPath.split('/');
98
+ const minLen = Math.min(aSegs.length, bSegs.length);
99
+
100
+ for (let i = 0; i < minLen; i++) {
101
+ if (aSegs[i] !== bSegs[i]) {
102
+ const aIsDir = i < aSegs.length - 1 || a.type === 'directory';
103
+ const bIsDir = i < bSegs.length - 1 || b.type === 'directory';
104
+ if (aIsDir !== bIsDir) {
105
+ return aIsDir ? -1 : 1;
106
+ }
107
+ return aSegs[i].localeCompare(bSegs[i]);
108
+ }
109
+ }
110
+
111
+ if (aSegs.length < bSegs.length) return -1;
112
+ if (aSegs.length > bSegs.length) return 1;
113
+ return 0;
114
+ }
115
+
116
+ function listHostDir(dirPath: string): Record<string, unknown>[] {
117
+ const entries: Record<string, unknown>[] = [];
118
+ try {
119
+ const dirs = fs.readdirSync(dirPath, { withFileTypes: true });
120
+ for (const entry of dirs) {
121
+ if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
122
+ const absPath = path.join(dirPath, entry.name);
123
+ const isDir = entry.isDirectory();
124
+ const stat = fs.statSync(absPath, { throwIfNoEntry: false });
125
+ entries.push({
126
+ name: entry.name,
127
+ isDirectory: isDir,
128
+ isFile: !isDir,
129
+ path: absPath,
130
+ size: stat?.size ?? 0,
131
+ mtimeMs: stat?.mtimeMs ?? 0,
132
+ });
133
+ }
134
+ } catch { /* empty */ }
135
+ return entries;
136
+ }
137
+
138
+ function listHostTree(rootPath: string): WorkspaceEntry[] {
139
+ const entries: WorkspaceEntry[] = [];
140
+
141
+ function walk(dirPath: string, prefix = ''): void {
142
+ let dirEntries: fs.Dirent[] = [];
143
+ try {
144
+ dirEntries = fs.readdirSync(dirPath, { withFileTypes: true });
145
+ } catch {
146
+ return;
147
+ }
148
+
149
+ for (const entry of dirEntries) {
150
+ if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
151
+ const absPath = path.join(dirPath, entry.name);
152
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
153
+
154
+ let stat: fs.Stats;
155
+ try {
156
+ stat = fs.statSync(absPath);
157
+ } catch {
158
+ continue;
159
+ }
160
+
161
+ const isDirectory = entry.isDirectory();
162
+ entries.push({
163
+ name: entry.name,
164
+ path: absPath,
165
+ relPath,
166
+ type: isDirectory ? 'directory' : 'file',
167
+ size: isDirectory ? 0 : stat.size,
168
+ mtimeMs: stat.mtimeMs,
169
+ isDirectory,
170
+ isFile: !isDirectory,
171
+ });
172
+
173
+ if (isDirectory) walk(absPath, relPath);
174
+ }
175
+ }
176
+
177
+ walk(rootPath);
178
+ entries.sort(compareWorkspaceEntries);
179
+ return entries;
180
+ }
181
+
182
+ function readHostFile(filePath: string): { content: string; isBinary: boolean; buffer?: Buffer } {
183
+ const ext = path.extname(filePath).toLowerCase();
184
+ if (BINARY_EXTENSIONS.has(ext)) {
185
+ return { content: '', isBinary: true, buffer: fs.readFileSync(filePath) };
186
+ }
187
+ return { content: fs.readFileSync(filePath, 'utf8'), isBinary: false };
188
+ }
189
+
190
+ function listContainerDir(containerName: string, dirPath: string): Record<string, unknown>[] {
191
+ const entries: Record<string, unknown>[] = [];
192
+ try {
193
+ const output = execSync(
194
+ `docker exec ${containerName} ls -1Ap ${dirPath}`,
195
+ { timeout: 5000, encoding: 'utf-8' },
196
+ ).trim();
197
+ if (!output) return entries;
198
+ for (const name of output.split('\n')) {
199
+ if (!name || name === '.' || name === '..' || name.startsWith('.') || IGNORED_DIRS.has(name.replace('/', ''))) continue;
200
+ const isDir = name.endsWith('/');
201
+ entries.push({ name: isDir ? name.slice(0, -1) : name, isDirectory: isDir, isFile: !isDir });
202
+ }
203
+ } catch { /* empty */ }
204
+ return entries;
205
+ }
206
+
207
+ function listContainerTree(containerName: string, rootPath: string): WorkspaceEntry[] {
208
+ const entries: WorkspaceEntry[] = [];
209
+ const escapedRoot = shellEscape(rootPath);
210
+ const findCommand = `cd ${escapedRoot} && find . \\( -name node_modules -o -name .git -o -name .next -o -name .trash -o -name cache \\) -prune -o -mindepth 1 -printf '%P|%y|%s|%T@\\n'`;
211
+ const command = `docker exec ${containerName} sh -lc ${shellEscape(findCommand)}`;
212
+
213
+ try {
214
+ const output = execSync(command, { timeout: 10000, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }).trim();
215
+ if (!output) return entries;
216
+
217
+ for (const line of output.split('\n')) {
218
+ if (!line) continue;
219
+ const [relPath, rawType, rawSize, rawMtime] = line.split('|');
220
+ if (!relPath || relPath.startsWith('.')) continue;
221
+ const isDirectory = rawType === 'd';
222
+ entries.push({
223
+ name: path.posix.basename(relPath),
224
+ path: path.posix.join(rootPath, relPath),
225
+ relPath,
226
+ type: isDirectory ? 'directory' : 'file',
227
+ size: isDirectory ? 0 : Number(rawSize || 0),
228
+ mtimeMs: Math.round(Number(rawMtime || 0) * 1000),
229
+ isDirectory,
230
+ isFile: !isDirectory,
231
+ });
232
+ }
233
+ } catch {
234
+ return entries;
235
+ }
236
+
237
+ entries.sort(compareWorkspaceEntries);
238
+ return entries;
239
+ }
240
+
241
+ function treeResponse(workspace: Workspace, workspaceId: string, targetPath: string, entries: WorkspaceEntry[]) {
242
+ return NextResponse.json({
243
+ workspace: workspaceId,
244
+ label: workspace.label,
245
+ path: targetPath,
246
+ root: targetPath,
247
+ type: workspace.type,
248
+ entries,
249
+ tree: entries,
250
+ files: entries,
251
+ });
252
+ }
253
+
254
+ function readContainerFile(containerName: string, filePath: string): { content: string; isBinary: boolean } {
255
+ const ext = path.extname(filePath).toLowerCase();
256
+ const isBinary = BINARY_EXTENSIONS.has(ext);
257
+ try {
258
+ const content = execSync(
259
+ `docker exec ${containerName} cat ${filePath}`,
260
+ { timeout: 5000, encoding: 'utf-8' },
261
+ );
262
+ return { content: content ?? '', isBinary };
263
+ } catch {
264
+ return { content: '', isBinary };
265
+ }
266
+ }
267
+
268
+ export async function GET(request: NextRequest): Promise<NextResponse> {
269
+ const denied = await requireAuthJWT(request); if (denied) return denied as any;
270
+ const url = new URL(request.url);
271
+ const action = url.searchParams.get('action');
272
+ let ws = url.searchParams.get('workspace');
273
+ const filePath = url.searchParams.get('path');
274
+ const wantsTree = url.searchParams.get('tree') === '1';
275
+
276
+ // action=list → list available workspaces
277
+ if (action === 'list') {
278
+ return NextResponse.json({ workspaces: listWorkspaces() });
279
+ }
280
+
281
+ // No workspace specified → default to first available
282
+ if (!ws) {
283
+ const all = listWorkspaces();
284
+ ws = all.length > 0 ? all[0].id : '';
285
+ }
286
+
287
+ const workspace = getWorkspace(ws);
288
+ if (!workspace) {
289
+ return NextResponse.json({ error: `Unknown workspace: ${ws}` }, { status: 400 });
290
+ }
291
+
292
+ if (workspace.type === 'host') {
293
+ const targetPath = filePath ? path.resolve(filePath) : workspace.path;
294
+
295
+ // Security: allow only LOCAL_ROOT and subpaths
296
+ if (!targetPath.startsWith(path.resolve(LOCAL_ROOT))) {
297
+ return NextResponse.json({ error: 'Access denied' }, { status: 403 });
298
+ }
299
+
300
+ if (!fs.existsSync(targetPath)) {
301
+ return NextResponse.json({ error: 'Path not found' }, { status: 404 });
302
+ }
303
+
304
+ if (wantsTree) {
305
+ return treeResponse(workspace, ws, targetPath, listHostTree(targetPath));
306
+ }
307
+
308
+ if (fs.statSync(targetPath).isDirectory()) {
309
+ const files = listHostDir(targetPath);
310
+ return NextResponse.json({ workspace: ws, label: workspace.label, path: targetPath, type: 'host', files });
311
+ }
312
+
313
+ const result = readHostFile(targetPath);
314
+ if (result.isBinary && result.buffer) {
315
+ const mime = MIME_MAP[path.extname(targetPath).toLowerCase()] || 'application/octet-stream';
316
+ return new NextResponse(new Uint8Array(result.buffer), {
317
+ headers: { 'Content-Type': mime, 'Content-Length': String(result.buffer.length) },
318
+ });
319
+ }
320
+
321
+ return NextResponse.json({ workspace: ws, label: workspace.label, path: targetPath, type: 'host', content: result.content });
322
+ }
323
+
324
+ // Container workspace
325
+ if (workspace.type === 'container' && workspace.containerName) {
326
+ const targetPath = filePath || workspace.path;
327
+
328
+ if (wantsTree) {
329
+ return treeResponse(workspace, ws, targetPath, listContainerTree(workspace.containerName, targetPath));
330
+ }
331
+
332
+ if (filePath) {
333
+ // Check if it's a directory
334
+ try {
335
+ const isDir = execSync(
336
+ `docker exec ${workspace.containerName} test -d ${targetPath} && echo YES || echo NO`,
337
+ { timeout: 5000, encoding: 'utf-8' },
338
+ ).trim();
339
+ if (isDir === 'YES') {
340
+ const files = listContainerDir(workspace.containerName, targetPath);
341
+ return NextResponse.json({ workspace: ws, label: workspace.label, path: targetPath, type: 'container', files });
342
+ }
343
+ } catch { /* fall through to read */ }
344
+ }
345
+
346
+ const result = readContainerFile(workspace.containerName, targetPath);
347
+ return NextResponse.json({ workspace: ws, label: workspace.label, path: targetPath, type: 'container', content: result.content });
348
+ }
349
+
350
+ return NextResponse.json({ error: 'Unsupported workspace type' }, { status: 400 });
351
+ }
352
+
353
+ export async function PUT(request: NextRequest): Promise<NextResponse> {
354
+ const denied = await requireAuthJWT(request); if (denied) return denied as any;
355
+ try {
356
+ const body = (await request.json()) as { workspace?: string; path?: string; content?: string };
357
+ const ws = body?.workspace || 'local';
358
+ const { path: filePath, content } = body ?? {};
359
+ if (!ws || !filePath || typeof content !== 'string') {
360
+ return NextResponse.json({ error: 'Missing workspace, path, or content' }, { status: 400 });
361
+ }
362
+
363
+ const workspace = getWorkspace(ws);
364
+ if (!workspace) {
365
+ return NextResponse.json({ error: `Unknown workspace: ${ws}` }, { status: 400 });
366
+ }
367
+
368
+ if (workspace.type === 'host') {
369
+ const normalizedPath = path.resolve(filePath);
370
+ if (!normalizedPath.startsWith(path.resolve(LOCAL_ROOT))) {
371
+ return NextResponse.json({ error: 'Access denied' }, { status: 403 });
372
+ }
373
+ fs.mkdirSync(path.dirname(normalizedPath), { recursive: true });
374
+ fs.writeFileSync(normalizedPath, content, 'utf8');
375
+ return NextResponse.json({ ok: true });
376
+ }
377
+
378
+ if (workspace.type === 'container' && workspace.containerName) {
379
+ execSync(
380
+ `docker exec -i ${workspace.containerName} sh -c 'mkdir -p $(dirname '${
381
+ filePath.replace(/'/g, "'\\''")
382
+ }') && cat > ${filePath.replace(/'/g, "'\\''")}' << 'EOFINNER'\n${content}\nEOFINNER`,
383
+ { timeout: 10000, encoding: 'utf-8' },
384
+ );
385
+ return NextResponse.json({ ok: true });
386
+ }
387
+
388
+ return NextResponse.json({ error: 'Unsupported workspace type' }, { status: 400 });
389
+ } catch (e: unknown) {
390
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
391
+ }
392
+ }
@@ -0,0 +1,136 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { type NextRequest } from 'next/server';
4
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ const ROOT_PATH = process.env.REV4A_WORKSPACE_ROOT || '';
9
+ const STREAM_ENABLED = ROOT_PATH.length > 0;
10
+ const POLL_MS = 3000;
11
+ const IGNORED_DIRS = new Set(['node_modules', '.trash']);
12
+ const ALLOWED_EXT = new Set(['.md', '.json', '.txt', '.html', '.py', '.css', '.js', '.ts', '.tsx', '.yaml', '.yml', '.env', '.sh', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico']);
13
+
14
+ interface SnapshotEntry {
15
+ path: string;
16
+ rel_path: string;
17
+ type: 'file' | 'directory';
18
+ size: number;
19
+ mtimeMs: number;
20
+ }
21
+
22
+ interface WorkspaceChange extends SnapshotEntry {
23
+ change: 'added' | 'modified' | 'removed';
24
+ }
25
+
26
+ function shouldIgnoreName(name: string): boolean {
27
+ return name.startsWith('.') || IGNORED_DIRS.has(name);
28
+ }
29
+
30
+ function collectSnapshot(): Map<string, SnapshotEntry> {
31
+ const snapshot = new Map<string, SnapshotEntry>();
32
+
33
+ function walk(dir: string, prefix = ''): void {
34
+ let entries: fs.Dirent[] = [];
35
+ try {
36
+ entries = fs.readdirSync(dir, { withFileTypes: true });
37
+ } catch {
38
+ return;
39
+ }
40
+
41
+ for (const entry of entries) {
42
+ if (shouldIgnoreName(entry.name)) continue;
43
+ const absPath = path.join(dir, entry.name);
44
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
45
+
46
+ let stat: fs.Stats;
47
+ try {
48
+ stat = fs.statSync(absPath);
49
+ } catch {
50
+ continue;
51
+ }
52
+
53
+ if (entry.isDirectory()) {
54
+ snapshot.set(absPath, { path: absPath, rel_path: relPath, type: 'directory', size: 0, mtimeMs: stat.mtimeMs });
55
+ walk(absPath, relPath);
56
+ continue;
57
+ }
58
+
59
+ const ext = path.extname(entry.name).toLowerCase();
60
+ if (!ALLOWED_EXT.has(ext)) continue;
61
+ snapshot.set(absPath, { path: absPath, rel_path: relPath, type: 'file', size: stat.size, mtimeMs: stat.mtimeMs });
62
+ }
63
+ }
64
+
65
+ walk(ROOT_PATH);
66
+ return snapshot;
67
+ }
68
+
69
+ function diffSnapshots(previous: Map<string, SnapshotEntry>, next: Map<string, SnapshotEntry>): WorkspaceChange[] {
70
+ const changes: WorkspaceChange[] = [];
71
+
72
+ for (const [key, nextEntry] of next) {
73
+ const prevEntry = previous.get(key);
74
+ if (!prevEntry) {
75
+ changes.push({ ...nextEntry, change: 'added' });
76
+ continue;
77
+ }
78
+ if (prevEntry.mtimeMs !== nextEntry.mtimeMs || prevEntry.size !== nextEntry.size || prevEntry.type !== nextEntry.type) {
79
+ changes.push({ ...nextEntry, change: 'modified' });
80
+ }
81
+ }
82
+
83
+ for (const [key, prevEntry] of previous) {
84
+ if (!next.has(key)) changes.push({ ...prevEntry, change: 'removed' });
85
+ }
86
+
87
+ return changes;
88
+ }
89
+
90
+ export async function GET(request: NextRequest): Promise<Response> {
91
+ const denied = await requireAuthJWT(request); if (denied) return denied as any;
92
+ if (!STREAM_ENABLED) {
93
+ return new Response('Workspace stream disabled (REV4A_WORKSPACE_ROOT not set)', { status: 404 });
94
+ }
95
+
96
+ const encoder = new TextEncoder();
97
+ let previous = collectSnapshot();
98
+
99
+ const stream = new ReadableStream({
100
+ start(controller) {
101
+ const send = (event: string, data: unknown) => {
102
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
103
+ };
104
+
105
+ send('workspace_ready', { type: 'workspace_ready', ts: Date.now(), root: ROOT_PATH, count: previous.size });
106
+
107
+ const interval = setInterval(() => {
108
+ try {
109
+ const next = collectSnapshot();
110
+ const changes = diffSnapshots(previous, next);
111
+ previous = next;
112
+ if (changes.length > 0) {
113
+ send('workspace_changed', { type: 'workspace_changed', ts: Date.now(), changed: changes.slice(0, 200), truncated: changes.length > 200 });
114
+ } else {
115
+ send('heartbeat', { type: 'heartbeat', ts: Date.now() });
116
+ }
117
+ } catch (error) {
118
+ send('workspace_error', { type: 'workspace_error', ts: Date.now(), error: error instanceof Error ? error.message : String(error) });
119
+ }
120
+ }, POLL_MS);
121
+
122
+ request.signal.addEventListener('abort', () => {
123
+ clearInterval(interval);
124
+ controller.close();
125
+ });
126
+ },
127
+ });
128
+
129
+ return new Response(stream, {
130
+ headers: {
131
+ 'Content-Type': 'text/event-stream',
132
+ 'Cache-Control': 'no-cache, no-transform',
133
+ Connection: 'keep-alive',
134
+ },
135
+ });
136
+ }
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import { usePathname } from 'next/navigation';
4
+ import AuthGuard from './AuthGuard';
5
+ import Sidebar from './Sidebar';
6
+ import FloatingActions from './FloatingActions';
7
+ import VersionBanner from './VersionBanner';
8
+
9
+ export default function AppShell({ children }: { children: React.ReactNode }) {
10
+ const pathname = usePathname();
11
+ const isLogin = pathname === '/login';
12
+ const isSetup = pathname === '/setup';
13
+ const isWizard = pathname === '/wizard';
14
+
15
+ if (isLogin || isSetup || isWizard) {
16
+ return <>{children}</>;
17
+ }
18
+
19
+ return (
20
+ <AuthGuard>
21
+ <div className="app-shell">
22
+ <VersionBanner />
23
+ <div className="app-shell__body">
24
+ <Sidebar />
25
+ <main className="app-shell__content">
26
+ {children}
27
+ </main>
28
+ </div>
29
+ <FloatingActions />
30
+ </div>
31
+ </AuthGuard>
32
+ );
33
+ }
@@ -0,0 +1,91 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { useRouter, usePathname } from 'next/navigation';
5
+
6
+ // Cache auth result per session to avoid re-checking on every navigation
7
+ const authCache = { valid: false, checked: false, ts: 0 };
8
+ const AUTH_CACHE_TTL = 30_000; // 30s before re-check
9
+
10
+ export default function AuthGuard({ children }: { children: React.ReactNode }) {
11
+ const router = useRouter();
12
+ const pathname = usePathname();
13
+ const [redirecting, setRedirecting] = useState(false);
14
+ const checkingRef = useRef(false);
15
+
16
+ useEffect(() => {
17
+ // /setup: always accessible
18
+ if (pathname === '/setup') return;
19
+
20
+ // /login: public, check if already authenticated
21
+ if (pathname === '/login') {
22
+ fetch('/api/auth/status')
23
+ .then((r) => r.json())
24
+ .then((d: { configured: boolean }) => {
25
+ if (!d.configured) router.replace('/setup');
26
+ })
27
+ .catch(() => {});
28
+ return;
29
+ }
30
+
31
+ // Already checking — prevent duplicate
32
+ if (checkingRef.current) return;
33
+
34
+ // Cache hit — skip check
35
+ if (authCache.checked && authCache.valid && (Date.now() - authCache.ts) < AUTH_CACHE_TTL) {
36
+ return; // Still valid, no re-check
37
+ }
38
+
39
+ checkingRef.current = true;
40
+
41
+ // Protected routes — check auth in background
42
+ fetch('/api/auth/check')
43
+ .then(async (r) => {
44
+ if (!r.ok) {
45
+ // Not authenticated — redirect
46
+ authCache.valid = false;
47
+ authCache.checked = true;
48
+ const status = await fetch('/api/auth/status').then((r2) => r2.json());
49
+ setRedirecting(true);
50
+ router.replace(status.configured ? '/login' : '/setup');
51
+ return;
52
+ }
53
+
54
+ // Auth valid
55
+ authCache.valid = true;
56
+ authCache.checked = true;
57
+ authCache.ts = Date.now();
58
+
59
+ if (pathname === '/wizard') return;
60
+
61
+ // Check wizard status
62
+ const [statusRes, wizardRes] = await Promise.all([
63
+ fetch('/api/auth/status'),
64
+ fetch('/api/wizard/status'),
65
+ ]);
66
+ const { configured } = await statusRes.json();
67
+ const { done: wizardDone } = await wizardRes.json();
68
+
69
+ if (!configured && !wizardDone) {
70
+ setRedirecting(true);
71
+ router.replace('/wizard');
72
+ }
73
+ })
74
+ .catch(async () => {
75
+ try {
76
+ const status = await fetch('/api/auth/status').then((r) => r.json());
77
+ setRedirecting(true);
78
+ router.replace(status.configured ? '/login' : '/setup');
79
+ } catch {
80
+ setRedirecting(true);
81
+ router.replace('/login');
82
+ }
83
+ })
84
+ .finally(() => {
85
+ checkingRef.current = false;
86
+ });
87
+ }, [pathname, router]);
88
+
89
+ // NEVER hide children — always render, auth check happens in background
90
+ return <>{children}</>;
91
+ }
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+
3
+ import type { ModelCost } from '@/lib/types';
4
+ import { formatUsd } from '@/lib/utils/format';
5
+ import { SkeletonLines } from './Skeleton';
6
+
7
+ interface CostBreakdownProps {
8
+ byModel: ModelCost[];
9
+ loading?: boolean;
10
+ }
11
+
12
+ export default function CostBreakdown({ byModel, loading = false }: CostBreakdownProps) {
13
+ return (
14
+ <article className="panel cost-panel">
15
+ <h2>Costs by Model (Today)</h2>
16
+ <div>
17
+ {loading ? (
18
+ <div style={{ padding: 12 }}>
19
+ <SkeletonLines count={4} />
20
+ </div>
21
+ ) : byModel.length === 0 ? (
22
+ <div className="cost-row">
23
+ <span className="k">no data</span>
24
+ <span className="v">$0.00</span>
25
+ </div>
26
+ ) : (
27
+ byModel.map((row, index) => (
28
+ <div key={`${row.model ?? 'unknown'}-${index}`} className="cost-row">
29
+ <span className="k">{row.model ?? 'unknown'}</span>
30
+ <span className="v">{formatUsd(row.cost_usd)}</span>
31
+ </div>
32
+ ))
33
+ )}
34
+ </div>
35
+ </article>
36
+ );
37
+ }