@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
package/daemon.js ADDED
@@ -0,0 +1,783 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Rev4a Daemon — SQLite event logger
4
+ * Polls OpenClaw sessions every 30s, writes events to SQLite.
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const os = require('os');
10
+ const fs = require('fs');
11
+ const { spawnSync, execSync } = require('child_process');
12
+ const Database = require('better-sqlite3');
13
+ const path = require('path');
14
+
15
+ const DB_PATH = process.env.REV4A_DB || path.join(os.homedir(), '.config', 'rev4a', 'data', 'events.db');
16
+ const POLL_INTERVAL_MS = 30_000;
17
+ const POLL_INTERVAL_ACTIVE_MS = 15_000; // 15s when working sessions detected
18
+
19
+ // Cost rates per 1M tokens (separate in/out pricing)
20
+ const MODEL_PRICING = {
21
+ 'claude-sonnet-4': { in: 3.00, out: 15.00 },
22
+ 'claude-opus-4': { in: 15.00, out: 75.00 },
23
+ 'gpt-5-mini': { in: 0.15, out: 0.60 },
24
+ 'codex': { in: 3.00, out: 15.00 },
25
+ 'gemini': { in: 0.075, out: 0.30 },
26
+ 'flash': { in: 0.075, out: 0.30 },
27
+ 'deepseek': { in: 0.55, out: 2.19 },
28
+ 'default': { in: 3.00, out: 15.00 },
29
+ };
30
+
31
+ // Alias diretti per modelli che non matchano per substring
32
+ const MODEL_ALIASES = {
33
+ 'cheap': 'flash',
34
+ 'fast': 'claude-sonnet-4',
35
+ 'big': 'claude-opus-4',
36
+ 'coder': 'codex',
37
+ 'pro': 'gemini',
38
+ 'reason': 'deepseek',
39
+ };
40
+
41
+
42
+ function estimateCost(model, tokensIn, tokensOut) {
43
+ if (!tokensIn && !tokensOut) return 0;
44
+ const m = (model || '').toLowerCase();
45
+ // Check direct aliases first (e.g. 'cheap' → 'flash')
46
+ const aliasKey = Object.keys(MODEL_ALIASES).find(a => m === a || m.endsWith('/' + a));
47
+ const resolved = aliasKey ? MODEL_ALIASES[aliasKey] : null;
48
+ const key = resolved
49
+ || Object.keys(MODEL_PRICING).find(k => k !== 'default' && m.includes(k))
50
+ || 'default';
51
+ const p = MODEL_PRICING[key];
52
+ return ((tokensIn || 0) / 1_000_000 * p.in) + ((tokensOut || 0) / 1_000_000 * p.out);
53
+ }
54
+
55
+ // ── DB Setup ────────────────────────────────────────────────────────────────
56
+
57
+ let db;
58
+ try {
59
+ db = new Database(DB_PATH);
60
+ db.pragma('journal_mode = WAL');
61
+ db.pragma('synchronous = NORMAL');
62
+ log('✅ Database connected, WAL mode active');
63
+ } catch (err) {
64
+ console.error(`[FATAL] Cannot open DB at ${DB_PATH}: ${err.message}`);
65
+ console.error(`Verify: path exists, permissions on ${path.dirname(DB_PATH)}/, disk space`);
66
+ process.exit(1);
67
+ }
68
+
69
+ process.on('uncaughtException', (err) => {
70
+ console.error(`[UNCAUGHT] ${err.message}\n${err.stack}`);
71
+ console.error('Daemon will attempt restart via watchdog cron');
72
+ // NON uscire — il watchdog lo vede da healthcheck
73
+ });
74
+
75
+ db.exec(`
76
+ CREATE TABLE IF NOT EXISTS system_metrics (
77
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
78
+ ts INTEGER NOT NULL,
79
+ cpu_percent REAL,
80
+ ram_used_mb INTEGER,
81
+ ram_total_mb INTEGER,
82
+ disk_used_gb REAL,
83
+ disk_total_gb REAL,
84
+ load_avg_1m REAL
85
+ );
86
+ CREATE INDEX IF NOT EXISTS idx_metrics_ts ON system_metrics(ts);
87
+ `);
88
+
89
+ db.exec(`
90
+ CREATE TABLE IF NOT EXISTS sessions (
91
+ session_id TEXT PRIMARY KEY,
92
+ parent_id TEXT,
93
+ label TEXT,
94
+ model TEXT,
95
+ tokens_in INTEGER DEFAULT 0,
96
+ tokens_out INTEGER DEFAULT 0,
97
+ cost_usd REAL DEFAULT 0,
98
+ status TEXT DEFAULT 'idle',
99
+ task_preview TEXT,
100
+ started_at INTEGER,
101
+ ended_at INTEGER,
102
+ updated_at INTEGER,
103
+ trello_card_url TEXT
104
+ );
105
+
106
+ CREATE TABLE IF NOT EXISTS events (
107
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
108
+ ts INTEGER NOT NULL,
109
+ session_id TEXT NOT NULL,
110
+ type TEXT NOT NULL,
111
+ data TEXT
112
+ );
113
+
114
+ CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, ts);
115
+ CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at);
116
+ `);
117
+
118
+ // Add trello_card_url column if it doesn't exist (migration)
119
+ try {
120
+ db.exec(`ALTER TABLE sessions ADD COLUMN trello_card_url TEXT`);
121
+ } catch (e) {
122
+ // Column already exists — ignore
123
+ }
124
+
125
+ // Fix 1 — Add ended_at column (migration safe)
126
+ try {
127
+ db.exec(`ALTER TABLE sessions ADD COLUMN ended_at INTEGER`);
128
+ } catch (e) {
129
+ // Column already exists — ignore
130
+ }
131
+
132
+ // Prepared statements
133
+ const upsertSession = db.prepare(`
134
+ INSERT INTO sessions (session_id, parent_id, label, model, tokens_in, tokens_out, cost_usd, status, task_preview, started_at, ended_at, updated_at, trello_card_url)
135
+ VALUES (@session_id, @parent_id, @label, @model, @tokens_in, @tokens_out, @cost_usd, @status, @task_preview, @started_at, @ended_at, @updated_at, @trello_card_url)
136
+ ON CONFLICT(session_id) DO UPDATE SET
137
+ model = excluded.model,
138
+ tokens_in = excluded.tokens_in,
139
+ tokens_out = excluded.tokens_out,
140
+ cost_usd = excluded.cost_usd,
141
+ status = excluded.status,
142
+ -- Only update updated_at if tokens actually changed (real activity)
143
+ updated_at = CASE
144
+ WHEN excluded.tokens_in != sessions.tokens_in OR excluded.tokens_out != sessions.tokens_out
145
+ OR excluded.status != sessions.status
146
+ THEN excluded.updated_at
147
+ ELSE sessions.updated_at
148
+ END,
149
+ ended_at = CASE
150
+ WHEN excluded.ended_at IS NOT NULL THEN excluded.ended_at
151
+ ELSE sessions.ended_at
152
+ END,
153
+ trello_card_url = COALESCE(excluded.trello_card_url, sessions.trello_card_url),
154
+ -- Update parent_id only if the incoming one is better (not null and not equal to main default)
155
+ parent_id = CASE
156
+ WHEN excluded.parent_id IS NOT NULL AND excluded.parent_id != sessions.parent_id
157
+ AND (sessions.parent_id IS NULL OR sessions.parent_id LIKE '%:main')
158
+ THEN excluded.parent_id
159
+ ELSE sessions.parent_id
160
+ END,
161
+ -- Update label only if the current one is the raw key (not a declared name)
162
+ label = CASE
163
+ WHEN sessions.label IS NULL OR sessions.label LIKE 'agent:%'
164
+ THEN excluded.label
165
+ ELSE sessions.label
166
+ END
167
+ `);
168
+
169
+ const insertEvent = db.prepare(`
170
+ INSERT INTO events (ts, session_id, type, data) VALUES (@ts, @session_id, @type, @data)
171
+ `);
172
+
173
+ const getSession = db.prepare(`SELECT * FROM sessions WHERE session_id = ?`);
174
+
175
+ // Track known sessions across polls
176
+ const knownSessions = new Map(); // session_id -> { status, tokens_in, tokens_out }
177
+ let pollCount = 0;
178
+
179
+ // ── System Metrics ───────────────────────────────────────────────────────────
180
+
181
+ const insertMetric = db.prepare(`
182
+ INSERT INTO system_metrics (ts, cpu_percent, ram_used_mb, ram_total_mb, disk_used_gb, disk_total_gb, load_avg_1m)
183
+ VALUES (@ts, @cpu_percent, @ram_used_mb, @ram_total_mb, @disk_used_gb, @disk_total_gb, @load_avg_1m)
184
+ `);
185
+ const pruneMetrics = db.prepare(`DELETE FROM system_metrics WHERE ts < ?`);
186
+ const getLastTwoMetrics = db.prepare(`SELECT cpu_percent, ram_used_mb, ram_total_mb FROM system_metrics ORDER BY ts DESC LIMIT 2`);
187
+
188
+ // Anomaly cooldown: track last anomaly alert timestamps
189
+ const lastAnomalyAlert = { cpu: 0, ram: 0 };
190
+ const ANOMALY_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
191
+
192
+ function getCpuPercent() {
193
+ // Container-accurate CPU: prefer cgroup v2 usage delta over host cumulative ticks.
194
+ const nowWallUsec = Number(process.hrtime.bigint() / 1000n);
195
+ const cgroupUsageUsec = readCgroupUsageUsec();
196
+
197
+ if (cgroupUsageUsec !== null) {
198
+ if (lastCpuSample && lastCpuSample.cgroupUsageUsec !== null) {
199
+ const deltaUsageUsec = cgroupUsageUsec - lastCpuSample.cgroupUsageUsec;
200
+ const deltaWallUsec = nowWallUsec - lastCpuSample.wallUsec;
201
+ if (deltaUsageUsec >= 0 && deltaWallUsec > 0) {
202
+ const limitCores = getCpuLimitCores();
203
+ const raw = (deltaUsageUsec / deltaWallUsec) / limitCores * 100;
204
+ const clamped = Math.max(0, Math.min(100, raw));
205
+ lastCpuSample = { wallUsec: nowWallUsec, cgroupUsageUsec, host: null };
206
+ return Number(clamped.toFixed(2));
207
+ }
208
+ }
209
+ lastCpuSample = { wallUsec: nowWallUsec, cgroupUsageUsec, host: null };
210
+ return 0;
211
+ }
212
+
213
+ const host = readHostCpuSample();
214
+ if (lastCpuSample && lastCpuSample.host) {
215
+ const deltaTotal = host.total - lastCpuSample.host.total;
216
+ const deltaIdle = host.idle - lastCpuSample.host.idle;
217
+ if (deltaTotal > 0) {
218
+ const busy = Math.max(0, deltaTotal - deltaIdle);
219
+ const raw = (busy / deltaTotal) * 100;
220
+ const clamped = Math.max(0, Math.min(100, raw));
221
+ lastCpuSample = { wallUsec: nowWallUsec, cgroupUsageUsec: null, host };
222
+ return Number(clamped.toFixed(2));
223
+ }
224
+ }
225
+ lastCpuSample = { wallUsec: nowWallUsec, cgroupUsageUsec: null, host };
226
+ return 0;
227
+ }
228
+
229
+ const CGROUP_CPU_STAT = '/sys/fs/cgroup/cpu.stat';
230
+ const CGROUP_CPU_MAX = '/sys/fs/cgroup/cpu.max';
231
+ const CGROUP_CPUSET_EFFECTIVE = '/sys/fs/cgroup/cpuset.cpus.effective';
232
+ let lastCpuSample = null;
233
+
234
+ function readText(filePath) {
235
+ try {
236
+ return fs.readFileSync(filePath, 'utf8').trim();
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
242
+ function parseCpuListCount(value) {
243
+ if (!value) return 0;
244
+ let count = 0;
245
+ for (const token of value.split(',')) {
246
+ const part = token.trim();
247
+ if (!part) continue;
248
+ if (part.includes('-')) {
249
+ const [startRaw, endRaw] = part.split('-');
250
+ const start = Number(startRaw);
251
+ const end = Number(endRaw);
252
+ if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
253
+ count += (end - start + 1);
254
+ }
255
+ } else {
256
+ const cpu = Number(part);
257
+ if (Number.isFinite(cpu)) count += 1;
258
+ }
259
+ }
260
+ return count;
261
+ }
262
+
263
+ function getCpuLimitCores() {
264
+ const cpuMax = readText(CGROUP_CPU_MAX);
265
+ if (cpuMax) {
266
+ const [quotaRaw, periodRaw] = cpuMax.split(/\s+/);
267
+ if (quotaRaw && quotaRaw !== 'max') {
268
+ const quota = Number(quotaRaw);
269
+ const period = Number(periodRaw);
270
+ if (Number.isFinite(quota) && Number.isFinite(period) && quota > 0 && period > 0) {
271
+ return Math.max(quota / period, 0.001);
272
+ }
273
+ }
274
+ }
275
+
276
+ const cpusetCount = parseCpuListCount(readText(CGROUP_CPUSET_EFFECTIVE));
277
+ if (cpusetCount > 0) return cpusetCount;
278
+
279
+ return os.cpus().length || 1;
280
+ }
281
+
282
+ function readCgroupUsageUsec() {
283
+ const stat = readText(CGROUP_CPU_STAT);
284
+ if (!stat) return null;
285
+ const line = stat.split('\n').find((x) => x.startsWith('usage_usec '));
286
+ if (!line) return null;
287
+ const usage = Number(line.split(/\s+/)[1]);
288
+ if (!Number.isFinite(usage)) return null;
289
+ return usage;
290
+ }
291
+
292
+ function readHostCpuSample() {
293
+ const cpus = os.cpus();
294
+ let idle = 0;
295
+ let total = 0;
296
+ for (const cpu of cpus) {
297
+ idle += cpu.times.idle;
298
+ total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.irq + cpu.times.idle;
299
+ }
300
+ return { idle, total };
301
+ }
302
+
303
+ function getDiskStats() {
304
+ try {
305
+ const result = spawnSync('df', ['-BG', '/data', '--output=used,size'], {
306
+ encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL',
307
+ });
308
+ if (result.status !== 0 || !result.stdout) return { used: 0, total: 0 };
309
+ const lines = result.stdout.trim().split('\n');
310
+ // lines[0] = header, lines[1] = data
311
+ if (lines.length < 2) return { used: 0, total: 0 };
312
+ const parts = lines[1].trim().split(/\s+/);
313
+ const used = parseFloat(parts[0]) || 0; // already in GB (BG flag strips G)
314
+ const total = parseFloat(parts[1]) || 0;
315
+ return { used, total };
316
+ } catch (e) {
317
+ return { used: 0, total: 0 };
318
+ }
319
+ }
320
+
321
+ function collectSystemMetrics() {
322
+ const now = Math.floor(Date.now() / 1000);
323
+ const cpu_percent = getCpuPercent();
324
+ const ram_total = os.totalmem();
325
+ const ram_free = os.freemem();
326
+ const ram_used_mb = Math.round((ram_total - ram_free) / 1024 / 1024);
327
+ const ram_total_mb = Math.round(ram_total / 1024 / 1024);
328
+ const disk = getDiskStats();
329
+ const load_avg_1m = parseFloat(os.loadavg()[0].toFixed(2));
330
+
331
+ insertMetric.run({
332
+ ts: now,
333
+ cpu_percent,
334
+ ram_used_mb,
335
+ ram_total_mb,
336
+ disk_used_gb: disk.used,
337
+ disk_total_gb: disk.total,
338
+ load_avg_1m,
339
+ });
340
+
341
+ // Prune old data (keep 30 days)
342
+ pruneMetrics.run(now - 30 * 24 * 3600);
343
+
344
+ // Anomaly detection — check last 2 consecutive samples
345
+ const recent = getLastTwoMetrics.all();
346
+ if (recent.length === 2) {
347
+ const nowMs = Date.now();
348
+ // CPU >85% for 2 consecutive samples
349
+ if (recent[0].cpu_percent > 85 && recent[1].cpu_percent > 85) {
350
+ if (nowMs - lastAnomalyAlert.cpu > ANOMALY_COOLDOWN_MS) {
351
+ lastAnomalyAlert.cpu = nowMs;
352
+ insertEvent.run({
353
+ ts: Date.now(),
354
+ session_id: 'system',
355
+ type: 'system_anomaly',
356
+ data: JSON.stringify({
357
+ metric: 'cpu',
358
+ values: [recent[1].cpu_percent, recent[0].cpu_percent],
359
+ threshold: 85,
360
+ message: `CPU alta: ${recent[0].cpu_percent}% per 2 campioni consecutivi`,
361
+ }),
362
+ });
363
+ log(`[ANOMALY] CPU alta: ${recent[0].cpu_percent}%`);
364
+ }
365
+ }
366
+ // RAM >90% for 2 consecutive samples
367
+ const ram0pct = recent[0].ram_total_mb > 0 ? Math.round(recent[0].ram_used_mb / recent[0].ram_total_mb * 100) : 0;
368
+ const ram1pct = recent[1].ram_total_mb > 0 ? Math.round(recent[1].ram_used_mb / recent[1].ram_total_mb * 100) : 0;
369
+ if (ram0pct > 90 && ram1pct > 90) {
370
+ if (nowMs - lastAnomalyAlert.ram > ANOMALY_COOLDOWN_MS) {
371
+ lastAnomalyAlert.ram = nowMs;
372
+ insertEvent.run({
373
+ ts: Date.now(),
374
+ session_id: 'system',
375
+ type: 'system_anomaly',
376
+ data: JSON.stringify({
377
+ metric: 'ram',
378
+ values: [ram1pct, ram0pct],
379
+ threshold: 90,
380
+ message: `RAM alta: ${ram0pct}% per 2 campioni consecutivi`,
381
+ }),
382
+ });
383
+ log(`[ANOMALY] RAM alta: ${ram0pct}%`);
384
+ }
385
+ }
386
+ }
387
+
388
+ log(`[METRICS] CPU:${cpu_percent}% RAM:${ram_used_mb}/${ram_total_mb}MB Disk:${disk.used}/${disk.total}GB Load:${load_avg_1m}`);
389
+ }
390
+
391
+ // ── Helpers ─────────────────────────────────────────────────────────────────
392
+
393
+ function log(msg) {
394
+ console.log(`[${new Date().toISOString()}] ${msg}`);
395
+ }
396
+
397
+ function extractTrelloUrl(text) {
398
+ if (!text) return null;
399
+ const m = text.match(/https:\/\/trello\.com\/c\/[a-zA-Z0-9]+/);
400
+ return m ? m[0] : null;
401
+ }
402
+
403
+ function parseSessionKey(key) { // e.g. "agent:website:subagent:abc123" or "agent:website:main"
404
+ if (!key) return { label: key, parent_id: null };
405
+ const parts = key.split(':');
406
+ // agent:<agentId>:main → root session
407
+ // agent:<agentId>:subagent:<uuid> → child session
408
+ if (parts.length >= 3 && parts[2] === 'main') {
409
+ return { label: key, parent_id: null };
410
+ }
411
+ if (parts.length >= 4 && parts[2] === 'subagent') {
412
+ // parent is agent:<agentId>:main
413
+ const parent = `${parts[0]}:${parts[1]}:main`;
414
+ return { label: key, parent_id: parent };
415
+ }
416
+ return { label: key, parent_id: null };
417
+ }
418
+
419
+ function inferStatus(session, prevSnapshot) {
420
+ // Strong failure signals from OpenClaw win immediately.
421
+ if (session.abortedLastRun || session.error || session.result === 'error' || session.status === 'error') return 'error';
422
+
423
+ const ageMs = session.ageMs || 0;
424
+ const tokensIn = session.inputTokens || 0;
425
+ const tokensOut = session.outputTokens || 0;
426
+
427
+ // If tokens changed since last poll → session is actively processing
428
+ if (prevSnapshot) {
429
+ const tokenDelta = (tokensIn + tokensOut) - (prevSnapshot.tokens_in + prevSnapshot.tokens_out);
430
+ if (tokenDelta > 0) return 'working';
431
+ }
432
+
433
+ // Updated very recently (< 3 min) and fresh → still active
434
+ if (ageMs < 180_000 && session.totalTokensFresh) return 'working';
435
+
436
+ // Updated in last 3–30 min without token change → idle (waiting/paused)
437
+ if (ageMs < 1_800_000) return 'idle';
438
+
439
+ // Not updated for > 30 min → completed
440
+ return 'completed';
441
+ }
442
+
443
+ function getOrphanResolution(session, snap, ageMs) {
444
+ const tokens = (session.inputTokens || 0) + (session.outputTokens || 0);
445
+ const kind = String(session.kind || '');
446
+ const hadFailureHints = Boolean(session.abortedLastRun || session.error || session.result === 'error');
447
+ if (hadFailureHints) return 'error';
448
+ if (tokens === 0 && kind === 'spawn-child') return 'error';
449
+ if (snap?.status === 'working' && tokens === 0 && ageMs >= 2 * 60 * 1000) return 'error';
450
+ return 'completed';
451
+ }
452
+
453
+ function orphanEventType(status) {
454
+ return status === 'error' ? 'fail' : 'complete';
455
+ }
456
+
457
+ // ── Main Poll Loop ───────────────────────────────────────────────────────────
458
+
459
+ let openclawMissing = false;
460
+
461
+ function pollSessions() {
462
+ // If openclaw is not installed, skip polling entirely
463
+ if (!openclawMissing) {
464
+ try {
465
+ execSync('which openclaw', { stdio: 'ignore', timeout: 3000 });
466
+ } catch {
467
+ openclawMissing = true;
468
+ log('OpenClaw not found — daemon idle (no sessions to poll)');
469
+ return;
470
+ }
471
+ } else {
472
+ return;
473
+ }
474
+
475
+ log('Polling OpenClaw sessions...');
476
+
477
+ let output;
478
+ {
479
+ const result = spawnSync(
480
+ 'openclaw', ['sessions', '--json', '--all-agents'],
481
+ {
482
+ encoding: 'utf8',
483
+ timeout: 25_000,
484
+ killSignal: 'SIGKILL',
485
+ maxBuffer: 10 * 1024 * 1024,
486
+ env: { ...process.env },
487
+ stdio: ['ignore', 'pipe', 'pipe'],
488
+ }
489
+ );
490
+ if (result.error || result.status !== 0) {
491
+ log(`WARN: openclaw sessions failed: ${result.error?.message || '(exit ' + result.status + ')'}`);
492
+ return;
493
+ }
494
+ output = result.stdout;
495
+ }
496
+
497
+ let data;
498
+ try {
499
+ // Strip any trailing non-JSON lines (e.g. stray log output from openclaw on stdout)
500
+ // Find the last closing brace of the top-level JSON object
501
+ const jsonEnd = output.lastIndexOf('}');
502
+ const cleanOutput = jsonEnd !== -1 ? output.slice(0, jsonEnd + 1) : output;
503
+ data = JSON.parse(cleanOutput);
504
+ } catch (err) {
505
+ log(`ERROR: Failed to parse JSON: ${err.message}`);
506
+ log(`ERROR: Raw output (first 500 chars): ${output.slice(0, 500)}`);
507
+ return; // graceful: skip this poll cycle, retry next interval
508
+ }
509
+
510
+ const sessions = data.sessions || [];
511
+ const now = Date.now();
512
+ let newCount = 0;
513
+ let updatedCount = 0;
514
+
515
+ const batchInsertEvents = db.transaction((evts) => {
516
+ for (const evt of evts) insertEvent.run(evt);
517
+ });
518
+
519
+ const pendingEvents = [];
520
+
521
+ // Load declared lineage (takes priority over key parsing)
522
+ let declaredLineage = {}; // child_id → parent_id
523
+ let declaredNames = {}; // child_id → agent_name
524
+ try {
525
+ db.prepare(`CREATE TABLE IF NOT EXISTS lineage (
526
+ child_id TEXT PRIMARY KEY,
527
+ parent_id TEXT NOT NULL,
528
+ agent_name TEXT,
529
+ declared_at INTEGER NOT NULL
530
+ )`).run();
531
+ try { db.prepare('ALTER TABLE lineage ADD COLUMN agent_name TEXT').run(); } catch(e) {}
532
+ const rows = db.prepare('SELECT child_id, parent_id, agent_name FROM lineage').all();
533
+ for (const r of rows) {
534
+ declaredLineage[r.child_id] = r.parent_id;
535
+ if (r.agent_name) declaredNames[r.child_id] = r.agent_name;
536
+ }
537
+ } catch (e) { /* table not yet created, skip */ }
538
+
539
+ for (const s of sessions) {
540
+ const session_id = s.key;
541
+
542
+ // Skip Telegram channel/group sessions (multi-user).
543
+ // Keep Telegram direct sessions (agent:ops:telegram:argus:direct:...) as root nodes
544
+ // since they are the parent of all sub-agents spawned via Telegram.
545
+ if (session_id.includes(':telegram:') && !session_id.includes(':direct:')) continue;
546
+ const { label, parent_id: inferredParent } = parseSessionKey(s.key);
547
+ // Use parentSessionKey if present, otherwise declared lineage, otherwise inferred (always null now)
548
+ const parent_id = s.parentSessionKey || declaredLineage[session_id] || inferredParent;
549
+ const model = s.model || null;
550
+ const tokens_in = s.inputTokens || 0;
551
+ const tokens_out = s.outputTokens || 0;
552
+ const cost_usd = estimateCost(model, tokens_in, tokens_out);
553
+ const started_at = s.updatedAt ? s.updatedAt - (s.ageMs || 0) : now;
554
+ const updated_at = s.updatedAt || now;
555
+
556
+ const isNew = !knownSessions.has(session_id);
557
+ const prevSnapshot = knownSessions.get(session_id);
558
+
559
+ const task_preview_val = s.task || s.task_preview || s.label || null;
560
+ const trello_card_url = extractTrelloUrl(task_preview_val);
561
+ // Use declared name if available, otherwise label from key
562
+ const effectiveLabel = declaredNames[session_id] || label;
563
+
564
+ const status = inferStatus(s, prevSnapshot);
565
+
566
+ // Fix 1: write ended_at when session transitions to completed/error
567
+ let ended_at = null;
568
+ if (status === 'completed' || status === 'error') {
569
+ // Preserve existing ended_at if already set; otherwise set it now
570
+ const existing = getSession.get(session_id);
571
+ ended_at = (existing && existing.ended_at) ? existing.ended_at : now;
572
+ }
573
+
574
+ upsertSession.run({
575
+ session_id,
576
+ parent_id,
577
+ label: effectiveLabel,
578
+ model,
579
+ tokens_in,
580
+ tokens_out,
581
+ cost_usd,
582
+ status,
583
+ task_preview: task_preview_val,
584
+ started_at,
585
+ ended_at,
586
+ updated_at,
587
+ trello_card_url,
588
+ });
589
+
590
+ if (isNew) {
591
+ newCount++;
592
+ pendingEvents.push({
593
+ ts: now,
594
+ session_id,
595
+ type: 'spawn',
596
+ data: JSON.stringify({ model, parent_id, tokens: tokens_in + tokens_out }),
597
+ });
598
+ } else if (prevSnapshot && prevSnapshot.status !== 'completed' && status === 'completed') {
599
+ pendingEvents.push({
600
+ ts: now,
601
+ session_id,
602
+ type: 'complete',
603
+ data: JSON.stringify({ cost_usd, tokens: tokens_in + tokens_out }),
604
+ });
605
+ } else if (prevSnapshot && prevSnapshot.status !== 'error' && status === 'error') {
606
+ pendingEvents.push({
607
+ ts: now,
608
+ session_id,
609
+ type: 'fail',
610
+ data: JSON.stringify({ cost_usd, tokens: tokens_in + tokens_out, reason: 'openclaw_error_signal' }),
611
+ });
612
+ }
613
+
614
+ knownSessions.set(session_id, {
615
+ status,
616
+ tokens_in,
617
+ tokens_out,
618
+ updatedAt: Date.now(),
619
+ kind: s.kind,
620
+ abortedLastRun: s.abortedLastRun,
621
+ error: s.error,
622
+ result: s.result,
623
+ });
624
+ updatedCount++;
625
+ }
626
+
627
+ if (pendingEvents.length > 0) batchInsertEvents(pendingEvents);
628
+
629
+ // Auto-close orphan sessions: sessions that were working/idle but disappeared
630
+ // from openclaw sessions for > 5 minutes (10+ poll cycles) → mark as completed/error.
631
+ // Grace period protects against transient poll gaps or partial openclaw output.
632
+ const currentIds = new Set(sessions.filter(s => !s.key.includes(':telegram:')).map(s => s.key));
633
+ const fiveMinAgo = now - 5 * 60 * 1000;
634
+ if (currentIds.size > 0) {
635
+ const orphanCandidates = db.prepare(`
636
+ SELECT session_id, status, updated_at, tokens_in, tokens_out, ended_at
637
+ FROM sessions
638
+ WHERE status IN ('working','idle')
639
+ AND updated_at < ?
640
+ AND session_id NOT IN (${Array.from(currentIds).map(() => '?').join(',')})
641
+ `).all(fiveMinAgo, ...currentIds);
642
+ const orphanEvents = [];
643
+ let orphanCount = 0;
644
+ for (const row of orphanCandidates) {
645
+ const snap = knownSessions.get(row.session_id);
646
+ const resolved = getOrphanResolution({
647
+ inputTokens: row.tokens_in,
648
+ outputTokens: row.tokens_out,
649
+ kind: snap?.kind,
650
+ abortedLastRun: snap?.abortedLastRun,
651
+ error: snap?.error,
652
+ result: snap?.result,
653
+ }, snap, now - Number(row.updated_at || 0));
654
+ const ended_at = row.ended_at || now;
655
+ db.prepare(`UPDATE sessions SET status=?, ended_at=?, updated_at=? WHERE session_id = ?`).run(resolved, ended_at, now, row.session_id);
656
+ orphanEvents.push({
657
+ ts: now,
658
+ session_id: row.session_id,
659
+ type: orphanEventType(resolved),
660
+ data: JSON.stringify({ reason: 'orphan_close', prior_status: row.status, resolved_status: resolved }),
661
+ });
662
+ orphanCount++;
663
+ }
664
+ if (orphanEvents.length > 0) batchInsertEvents(orphanEvents);
665
+ if (orphanCount > 0) log(`Orphan cleanup: ${orphanCount} zombie sessions auto-closed (${orphanEvents.filter(e => e.type === 'fail').length} errors)`);
666
+ }
667
+
668
+ // ── Timeout Detection ────────────────────────────────────────────────────
669
+ // Find sub-agents that were 'working' in the last snapshot but are no longer
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
673
+ for (const [session_id, snap] of knownSessions) {
674
+ // Only sub-agents (contain 'subagent' in the key)
675
+ if (!session_id.includes('subagent')) continue;
676
+ // Solo se erano working nell'ultimo poll
677
+ if (snap.status !== 'working') continue;
678
+ // Only if not present in the current poll
679
+ if (currentIds.has(session_id)) continue;
680
+ // Only if missing long enough
681
+ const missingFor = now - (snap.updatedAt || now);
682
+ if (missingFor < TIMEOUT_THRESHOLD_MS) continue;
683
+ // Prevent logging the same timeout multiple times (check if already logged)
684
+ const alreadyLogged = db.prepare(
685
+ `SELECT id FROM events WHERE session_id = ? AND type = 'spawn_timeout' LIMIT 1`
686
+ ).get(session_id);
687
+ if (alreadyLogged) continue;
688
+
689
+ // Insert timeout event
690
+ insertEvent.run({
691
+ ts: now,
692
+ session_id,
693
+ type: 'spawn_timeout',
694
+ data: JSON.stringify({
695
+ missing_for_ms: missingFor,
696
+ last_status: snap.status,
697
+ message: `Subagente missing dopo ${Math.round(missingFor / 60000)}min senza completare`,
698
+ }),
699
+ });
700
+ log(`[TIMEOUT] ${session_id} missing da ${Math.round(missingFor / 60000)}min`);
701
+
702
+ // Notify Michele via openclaw message (only if openclaw is available)
703
+ try {
704
+ if (!openclawMissing) {
705
+ execSync('which openclaw', { stdio: 'ignore', timeout: 3000 });
706
+ }
707
+ spawnSync('openclaw', [
708
+ 'message', 'send',
709
+ '--account', 'ops',
710
+ '--target', '297086793',
711
+ '--text', `⚠️ Rev4a: agent timeout\n\`${session_id.slice(-36)}\`\nMissing for ${Math.round(missingFor / 60000)} min senza completare.`,
712
+ ], { encoding: 'utf8', timeout: 10_000, killSignal: 'SIGKILL' });
713
+ } catch (e) {
714
+ log(`[TIMEOUT] Telegram notification failed: ${e.message}`);
715
+ }
716
+ }
717
+
718
+ // Collect system metrics after each poll
719
+ try { collectSystemMetrics(); } catch (e) { log(`[METRICS ERROR] ${e.message}`); }
720
+
721
+ // Force names and parents declared via lineage — overrides any previous label (including "Sub-agente")
722
+ const updateLabel = db.prepare('UPDATE sessions SET label = ?, parent_id = ? WHERE session_id = ?');
723
+ const applyLineage = db.transaction(() => {
724
+ for (const [child_id, agent_name] of Object.entries(declaredNames)) {
725
+ const parent_id = declaredLineage[child_id] || null;
726
+ updateLabel.run(agent_name, parent_id, child_id);
727
+ }
728
+ });
729
+ applyLineage();
730
+
731
+ log(`Poll done: ${sessions.length} sessions (${newCount} new, ${updatedCount} updated, ${pendingEvents.length} events written)`);
732
+
733
+ // Force WAL checkpoint so the VPS reads updated data from the main file
734
+ db.pragma('wal_checkpoint(PASSIVE)');
735
+
736
+ // Retention policy: delete cron sessions older than 7 days
737
+ const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
738
+ const r1 = db.prepare(`DELETE FROM sessions WHERE session_id LIKE '%:cron:%' AND status = 'completed' AND updated_at < ?`).run(sevenDaysAgo);
739
+ const r2 = db.prepare(`DELETE FROM events WHERE session_id LIKE '%:cron:%' AND ts < ?`).run(sevenDaysAgo);
740
+ if (r1.changes > 0 || r2.changes > 0) {
741
+ log(`Retention cleanup: ${r1.changes} cron sessions, ${r2.changes} cron events deleted`);
742
+ }
743
+
744
+ // Prune knownSessions — delete completed sessions da >30 giorni
745
+ const cutoff = Date.now() - (30 * 24 * 60 * 60 * 1000);
746
+ for (const [id, snap] of knownSessions) {
747
+ if (snap.status === 'completed' && snap.updatedAt && snap.updatedAt < cutoff) {
748
+ knownSessions.delete(id);
749
+ }
750
+ }
751
+
752
+ // WAL checkpoint FULL ogni 10 poll
753
+ pollCount = (pollCount || 0) + 1;
754
+ if (pollCount % 10 === 0) {
755
+ db.pragma('wal_checkpoint(FULL)');
756
+ log(`WAL checkpoint FULL executed (poll #${pollCount})`);
757
+ }
758
+ }
759
+
760
+ // ── Start ────────────────────────────────────────────────────────────────────
761
+
762
+ log('Rev4a Daemon starting...');
763
+ log(`DB: ${DB_PATH}`);
764
+ log(`Poll interval: ${POLL_INTERVAL_MS / 1000}s`);
765
+
766
+ // Run immediately, then on interval
767
+ pollSessions();
768
+ const timer = setInterval(pollSessions, POLL_INTERVAL_MS);
769
+
770
+ // Graceful shutdown
771
+ process.on('SIGTERM', () => {
772
+ log('SIGTERM received, shutting down...');
773
+ clearInterval(timer);
774
+ db.close();
775
+ process.exit(0);
776
+ });
777
+
778
+ process.on('SIGINT', () => {
779
+ log('SIGINT received, shutting down...');
780
+ clearInterval(timer);
781
+ db.close();
782
+ process.exit(0);
783
+ });