neoagent 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Proactive personal AI agent with no limits",
5
5
  "license": "MIT",
6
6
  "main": "server/index.js",
package/server/index.js CHANGED
@@ -12,7 +12,10 @@ const fs = require('fs');
12
12
 
13
13
  const db = require('./db/database');
14
14
  const { requireAuth, requireNoAuth } = require('./middleware/auth');
15
- const { sanitizeError, detectPromptInjection } = require('./utils/security');
15
+ const { sanitizeError } = require('./utils/security');
16
+ const { setupConsoleInterceptor } = require('./utils/logger');
17
+ const { setupTelnyxWebhook } = require('./routes/telnyx');
18
+ const { startServices } = require('./services/manager');
16
19
 
17
20
  const app = express();
18
21
  const httpServer = createServer(app);
@@ -24,40 +27,7 @@ const io = new SocketIO(httpServer, {
24
27
  });
25
28
 
26
29
  // ── Console Log Interceptor ──
27
- const logHistory = [];
28
- const MAX_LOG_HISTORY = 200;
29
-
30
- function broadcastLog(type, args) {
31
- const msg = Array.from(args).map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
32
- const logEntry = { type, message: msg, timestamp: new Date().toISOString() };
33
- logHistory.push(logEntry);
34
- if (logHistory.length > MAX_LOG_HISTORY) logHistory.shift();
35
- // Broadcast only to authenticated user rooms, never to unauthenticated sockets
36
- for (const [, socket] of io.sockets.sockets) {
37
- const uid = socket.request?.session?.userId;
38
- if (uid) socket.emit('server:log', logEntry);
39
- }
40
- }
41
-
42
- const originalConsole = {
43
- log: console.log,
44
- error: console.error,
45
- warn: console.warn,
46
- info: console.info
47
- };
48
-
49
- console.log = function (...args) { originalConsole.log.apply(console, args); broadcastLog('log', args); };
50
- console.error = function (...args) { originalConsole.error.apply(console, args); broadcastLog('error', args); };
51
- console.warn = function (...args) { originalConsole.warn.apply(console, args); broadcastLog('warn', args); };
52
- console.info = function (...args) { originalConsole.info.apply(console, args); broadcastLog('info', args); };
53
-
54
- io.on('connection', (socket) => {
55
- socket.on('client:request_logs', () => {
56
- // Only serve log history to authenticated sockets
57
- if (!socket.request?.session?.userId) return;
58
- socket.emit('server:log_history', logHistory);
59
- });
60
- });
30
+ setupConsoleInterceptor(io);
61
31
 
62
32
  if (!process.env.SESSION_SECRET) {
63
33
  console.warn('WARNING: SESSION_SECRET not set — using insecure default. Set it in .env before exposing this server.');
@@ -69,22 +39,17 @@ if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
69
39
 
70
40
  // ── Middleware ──
71
41
 
72
- // When running behind a TLS-terminating reverse proxy (nginx, Caddy, Cloudflare Tunnel…)
73
- // set SECURE_COOKIES=true in .env so the session cookie is marked Secure and
74
- // express trusts the X-Forwarded-* headers from the proxy.
75
42
  const SECURE_COOKIES = process.env.SECURE_COOKIES === 'true';
76
43
  if (SECURE_COOKIES) {
77
- app.set('trust proxy', 1); // trust first hop (reverse proxy)
44
+ app.set('trust proxy', 1);
78
45
  }
79
46
 
80
- // WebSocket CSP source: ws+wss on plain HTTP, wss-only on HTTPS
81
47
  const wsConnectSrc = SECURE_COOKIES ? ['wss:'] : ['ws:', 'wss:'];
82
48
 
83
49
  app.use(helmet({
84
- // Disable headers that only make sense on HTTPS — this app runs on plain HTTP (Tailscale/localhost).
85
- strictTransportSecurity: false, // HSTS: would force browser to upgrade HTTP→HTTPS permanently
86
- crossOriginOpenerPolicy: false, // COOP: ignored on non-HTTPS origins, causes browser warning
87
- originAgentCluster: false, // OAC: causes "previously placed in site-keyed cluster" warning on HTTP
50
+ strictTransportSecurity: false,
51
+ crossOriginOpenerPolicy: false,
52
+ originAgentCluster: false,
88
53
  contentSecurityPolicy: {
89
54
  directives: {
90
55
  defaultSrc: ["'self'"],
@@ -94,15 +59,13 @@ app.use(helmet({
94
59
  imgSrc: ["'self'", "data:", "blob:", "https://api.qrserver.com"],
95
60
  connectSrc: ["'self'", "https://fonts.googleapis.com", "https://fonts.gstatic.com", ...wsConnectSrc],
96
61
  fontSrc: ["'self'", "data:", "https://fonts.gstatic.com"],
97
- formAction: ["'self'"], // explicit: prevents form-submission to external origins
98
- frameAncestors: ["'self'"], // explicit: prevents iframe embedding from other origins
99
- // Disable upgrade-insecure-requests — helmet adds this by default in v7+,
100
- // which causes browsers to upgrade HTTP subresource requests to HTTPS,
101
- // breaking plain-HTTP deployments (Tailscale, localhost).
62
+ formAction: ["'self'"],
63
+ frameAncestors: ["'self'"],
102
64
  upgradeInsecureRequests: null
103
65
  }
104
66
  }
105
67
  }));
68
+
106
69
  app.use(cors({
107
70
  origin: process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',') : false,
108
71
  credentials: true
@@ -120,13 +83,12 @@ const sessionMiddleware = session({
120
83
  maxAge: 7 * 24 * 60 * 60 * 1000,
121
84
  httpOnly: true,
122
85
  sameSite: 'lax',
123
- secure: SECURE_COOKIES // true when behind HTTPS proxy; false for plain HTTP (Tailscale/localhost)
86
+ secure: SECURE_COOKIES
124
87
  }
125
88
  });
126
89
 
127
90
  app.use(sessionMiddleware);
128
91
 
129
- // Share session with Socket.IO
130
92
  io.use((socket, next) => {
131
93
  sessionMiddleware(socket.request, {}, next);
132
94
  });
@@ -146,32 +108,8 @@ app.use('/api/scheduler', require('./routes/scheduler'));
146
108
  app.use('/api/browser', require('./routes/browser'));
147
109
 
148
110
  // ── Telnyx voice webhook ──
149
- // Protected by a shared-secret token in the query string.
150
- // Set TELNYX_WEBHOOK_TOKEN in .env and append ?token=<value> to the webhook URL
151
- // you configure in the Telnyx portal / NeoAgent connect modal.
152
- app.post('/api/telnyx/webhook', (req, res, next) => {
153
- const expected = process.env.TELNYX_WEBHOOK_TOKEN;
154
- if (expected) {
155
- const provided = req.query.token || '';
156
- // Use timing-safe comparison to prevent token brute-forcing via timing oracles
157
- const crypto = require('crypto');
158
- const a = Buffer.from(provided.padEnd(expected.length));
159
- const b = Buffer.from(expected);
160
- if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
161
- console.warn('[Telnyx webhook] Rejected request with invalid or missing token');
162
- return res.status(403).send('Forbidden');
163
- }
164
- }
165
- next();
166
- }, async (req, res) => {
167
- res.status(200).send('OK'); // Acknowledge immediately
168
- const manager = app.locals.messagingManager;
169
- if (manager) await manager.handleTelnyxWebhook(req.body).catch(err => console.error('[Telnyx webhook]', err.message));
170
- });
111
+ setupTelnyxWebhook(app);
171
112
 
172
- // Telnyx-generated audio files must be publicly accessible so Telnyx's servers
173
- // can fetch them via the webhook callback URL. Directory listing is disabled and
174
- // non-audio files are rejected to minimize accidental exposure.
175
113
  app.use('/telnyx-audio', express.static(path.join(DATA_DIR, 'telnyx-audio'), {
176
114
  index: false,
177
115
  setHeaders: (res, filePath) => {
@@ -197,8 +135,6 @@ app.get('/app/*', requireAuth, (req, res) => {
197
135
  res.sendFile(path.join(__dirname, 'public', 'app.html'));
198
136
  });
199
137
 
200
- // Serve app.html and app.js explicitly behind auth so they can't be fetched
201
- // directly via the static middleware without a valid session.
202
138
  app.get('/app.html', requireAuth, (req, res) => {
203
139
  res.sendFile(path.join(__dirname, 'public', 'app.html'));
204
140
  });
@@ -218,198 +154,11 @@ app.get('/api/health', requireAuth, (req, res) => {
218
154
  });
219
155
 
220
156
  // ── Service Initialization ──
221
-
222
- const startServices = async () => {
223
- try {
224
- const { MemoryManager } = require('./services/memory/manager');
225
- const memoryManager = new MemoryManager();
226
- app.locals.memoryManager = memoryManager;
227
-
228
- const { MCPClient } = require('./services/mcp/client');
229
- const mcpClient = new MCPClient();
230
- app.locals.mcpClient = mcpClient;
231
-
232
- const { BrowserController } = require('./services/browser/controller');
233
- const browserController = new BrowserController();
234
- // Restore saved headless preference for the first user (single-user app)
235
- const headlessSetting = db.prepare('SELECT value FROM user_settings WHERE key = ? ORDER BY user_id LIMIT 1').get('headless_browser');
236
- if (headlessSetting) {
237
- const val = headlessSetting.value;
238
- browserController.headless = val !== 'false' && val !== false && val !== '0';
239
- }
240
- app.locals.browserController = browserController;
241
-
242
- const { AgentEngine } = require('./services/ai/engine');
243
- const agentEngine = new AgentEngine(io, { memoryManager, mcpClient, browserController, messagingManager: null /* set below */ });
244
- app.locals.agentEngine = agentEngine;
245
-
246
- const { MultiStepOrchestrator } = require('./services/ai/multiStep');
247
- const multiStep = new MultiStepOrchestrator(agentEngine, io);
248
- app.locals.multiStep = multiStep;
249
-
250
- const { MessagingManager } = require('./services/messaging/manager');
251
- const messagingManager = new MessagingManager(io);
252
- app.locals.messagingManager = messagingManager;
253
- // Inject messagingManager into the already-created agentEngine
254
- agentEngine.messagingManager = messagingManager;
255
-
256
- messagingManager.restoreConnections().catch(err => console.error('[Messaging] Restore error:', err.message));
257
-
258
- const users = db.prepare('SELECT id FROM users').all();
259
- for (const u of users) {
260
- mcpClient.loadFromDB(u.id).catch(err => console.error('[MCP] Auto-start error:', err.message));
261
- }
262
-
263
- // Per-user message queues: batch & combine messages while AI is busy
264
- const userQueues = {};
265
- app.locals.userQueues = userQueues;
266
-
267
- async function processMessage(userId, msg) {
268
- if (!userQueues[userId]) userQueues[userId] = { running: false, pending: [] };
269
- const q = userQueues[userId];
270
-
271
- if (q.running) {
272
- const last = q.pending[q.pending.length - 1];
273
- if (last && last.platform === msg.platform && last.chatId === msg.chatId) {
274
- last.content += '\n' + msg.content;
275
- last.messageId = msg.messageId;
276
- } else {
277
- q.pending.push({ ...msg });
278
- }
279
- return;
280
- }
281
-
282
- q.running = true;
283
- try {
284
- await messagingManager.markRead(userId, msg.platform, msg.chatId, msg.messageId).catch(() => { });
285
- await messagingManager.sendTyping(userId, msg.platform, msg.chatId, true).catch(() => { });
286
- const mediaNote = msg.localMediaPath
287
- ? `\nMedia attached at: ${msg.localMediaPath} (type: ${msg.mediaType}). You can reference or forward it with send_message media_path.`
288
- : '';
289
- // Detect and log prompt injection attempts from external sources
290
- if (detectPromptInjection(msg.content)) {
291
- console.warn(`[Security] Possible prompt injection attempt from ${msg.sender} on ${msg.platform}: ${msg.content.slice(0, 200)}`);
292
- }
293
- // Wrap external content in delimiters — prevents prompt injection from untrusted senders
294
- const isVoiceCall = msg.platform === 'telnyx' && msg.mediaType === 'voice';
295
- const isVoiceNote = !isVoiceCall && msg.mediaType === 'audio'; // e.g. WhatsApp voice notes transcribed via STT
296
- const isDiscordGuild = msg.platform === 'discord' && msg.isGroup;
297
-
298
- // Channel context block for Discord guild/channel messages
299
- const discordContext = (isDiscordGuild && Array.isArray(msg.channelContext) && msg.channelContext.length)
300
- ? '\n\nRecent channel context (oldest → newest):\n' +
301
- msg.channelContext.map(m => `[${m.author}]: ${m.content}`).join('\n')
302
- : '';
303
-
304
- const sttNote = isVoiceNote
305
- ? '\n[Note: This message was sent as a voice note and transcribed via speech-to-text. The transcription may not be perfectly accurate — words may be misheard, punctuation added automatically, and phrasing may differ from what was intended.]'
306
- : '';
307
-
308
- const prompt = isVoiceCall
309
- ? `You are on a live phone call. The caller (${msg.senderName || msg.sender}) said (transcribed via speech-to-text — may not be perfectly accurate):
310
- <caller_speech>
311
- ${msg.content}
312
- </caller_speech>
313
-
314
- Respond via send_message with platform="telnyx" and to="${msg.chatId}".
315
- Rules for voice responses:
316
- - Call send_message EXACTLY ONCE with your complete reply. Do NOT call it multiple times.
317
- - Keep it brief and conversational — this will be spoken aloud via TTS.
318
- - NO markdown, bullet points, bold, headers, or special formatting.
319
- - Speak naturally. Never say things like "How can I assist you further?" — just stop when done.
320
- - Always respond; never use [NO RESPONSE] on a live call.`
321
- : `You received a ${msg.platform} message from ${msg.senderName || msg.sender} (chat: ${msg.chatId}):\n<external_message>\n${msg.content}\n</external_message>${mediaNote}${discordContext}${sttNote}
322
-
323
- Reply to this message using send_message with platform="${msg.platform}" and to="${msg.chatId}".
324
- Text like a person: split across messages naturally when it fits the content. Never end with "anything else?" or close-out phrases — just stop when you're done.
325
- You can also send images/files by setting media_path to a local file path.
326
- If no reply is needed (e.g. the message is just an acknowledgement like "ok", "thanks", or you already said everything), call send_message with content "[NO RESPONSE]" to explicitly stay silent.`;
327
- // Get or create a persistent conversation keyed by platform + chat ID.
328
- // The engine's conversationId path handles history, time-gap markers, and compaction natively.
329
- let convRow = db.prepare(
330
- 'SELECT id FROM conversations WHERE user_id = ? AND platform = ? AND platform_chat_id = ?'
331
- ).get(userId, msg.platform, msg.chatId);
332
- if (!convRow) {
333
- const convId = require('crypto').randomUUID();
334
- db.prepare(
335
- 'INSERT INTO conversations (id, user_id, platform, platform_chat_id, title) VALUES (?, ?, ?, ?, ?)'
336
- ).run(convId, userId, msg.platform, msg.chatId, `${msg.platform} — ${msg.senderName || msg.sender || msg.chatId}`);
337
- convRow = { id: convId };
338
- }
339
- const runOpts = { triggerSource: 'messaging', conversationId: convRow.id, source: msg.platform, chatId: msg.chatId, context: { rawUserMessage: msg.content } };
340
- if (msg.localMediaPath) runOpts.mediaAttachments = [{ path: msg.localMediaPath, type: msg.mediaType }];
341
- await agentEngine.run(userId, prompt, runOpts);
342
- } finally {
343
- await messagingManager.sendTyping(userId, msg.platform, msg.chatId, false).catch(() => { });
344
- q.running = false;
345
- if (q.pending.length > 0) {
346
- const next = q.pending.shift();
347
- processMessage(userId, next);
348
- }
349
- }
350
- }
351
-
352
- // Wire messaging → agent: incoming messages trigger agent
353
- messagingManager.registerHandler(async (userId, msg) => {
354
- // Discord and Telegram handle their own access control internally
355
- if (msg.platform !== 'discord' && msg.platform !== 'telegram') {
356
- // Whitelist check: if user has set a whitelist for this platform, block unknown senders
357
- const whitelistRow = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?')
358
- .get(userId, `platform_whitelist_${msg.platform}`);
359
- if (whitelistRow) {
360
- try {
361
- const whitelist = JSON.parse(whitelistRow.value);
362
- if (Array.isArray(whitelist) && whitelist.length > 0) {
363
- const normalize = (id) => {
364
- const digits = (id || '').replace(/[^0-9]/g, '');
365
- return digits.length > 10 ? digits.slice(-10) : digits;
366
- };
367
- const senderNorm = normalize(msg.sender || msg.chatId);
368
- const allowed = whitelist.some(n => normalize(n) === senderNorm);
369
- if (!allowed) {
370
- console.log(`[Messaging] Blocked ${msg.platform} message from ${msg.sender} (not in whitelist)`);
371
- io.to(`user:${userId}`).emit('messaging:blocked_sender', {
372
- platform: msg.platform,
373
- sender: msg.sender,
374
- chatId: msg.chatId,
375
- senderName: msg.senderName || null
376
- });
377
- return;
378
- }
379
- }
380
- } catch { /* malformed whitelist, allow through */ }
381
- }
382
- }
383
-
384
- const upsertSetting = db.prepare('INSERT OR REPLACE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)');
385
- upsertSetting.run(userId, 'last_platform', msg.platform);
386
- upsertSetting.run(userId, 'last_chat_id', msg.chatId);
387
-
388
- await processMessage(userId, msg);
389
- });
390
-
391
- const { Scheduler } = require('./services/scheduler/cron');
392
- const scheduler = new Scheduler(io, agentEngine);
393
- app.locals.scheduler = scheduler;
394
- agentEngine.scheduler = scheduler;
395
- scheduler.start();
396
-
397
- const { setupWebSocket } = require('./services/websocket');
398
- setupWebSocket(io, { agentEngine, messagingManager, mcpClient, scheduler, memoryManager, app });
399
-
400
- app.locals.io = io;
401
-
402
- console.log('All services initialized');
403
- } catch (err) {
404
- console.error('Service init error:', err);
405
- }
406
- };
157
+ // Handled by services/manager.js
407
158
 
408
159
  // ── Global Error Handler ──
409
160
 
410
- // Must be registered after all routes. Sanitizes error details so internal paths
411
- // and stack traces are never exposed in API responses.
412
- app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
161
+ app.use((err, req, res, next) => {
413
162
  console.error('[Unhandled error]', err);
414
163
  const status = err.status || err.statusCode || 500;
415
164
  const message = sanitizeError(err);
@@ -421,7 +170,7 @@ app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
421
170
 
422
171
  httpServer.listen(PORT, async () => {
423
172
  console.log(`NeoAgent running on http://localhost:${PORT}`);
424
- await startServices();
173
+ await startServices(app, io);
425
174
  });
426
175
 
427
176
  // ── Graceful Shutdown ──
@@ -537,6 +537,40 @@
537
537
  <!-- Rendered dynamically by app.js -->
538
538
  </div>
539
539
  </div>
540
+ <div class="form-group">
541
+ <label class="form-label settings-inline-label">
542
+ <span>Token Usage</span>
543
+ <span class="settings-info-wrap" tabindex="0" aria-label="Token usage info">
544
+ <span class="settings-info-icon">i</span>
545
+ <span class="settings-info-pop">Run-level totals from the DB. Used to track token usage trends in this app.</span>
546
+ </span>
547
+ </label>
548
+ <div class="settings-token-box" id="tokenUsageSummary">Loading token usage…</div>
549
+ </div>
550
+ <div class="settings-update-panel" id="settingsUpdatePanel">
551
+ <div class="settings-update-head">
552
+ <div class="settings-update-title">App Update</div>
553
+ <span class="badge badge-neutral" id="updateStateBadge">Idle</span>
554
+ </div>
555
+ <div class="settings-update-progress-wrap">
556
+ <div class="settings-update-progress-bar" id="updateProgressBar"></div>
557
+ </div>
558
+ <div class="settings-update-row">
559
+ <span id="updatePhaseLabel">No update running</span>
560
+ <span id="updatePercentLabel">0%</span>
561
+ </div>
562
+ <div class="settings-update-meta" id="updateVersionMeta">Version: —</div>
563
+ <div class="settings-update-section">
564
+ <div class="settings-update-label">Changelog</div>
565
+ <ul class="settings-update-changelog" id="updateChangelog">
566
+ <li class="settings-update-empty">No changes yet</li>
567
+ </ul>
568
+ </div>
569
+ <div class="settings-update-section">
570
+ <div class="settings-update-label">Live Output</div>
571
+ <pre class="settings-update-logs" id="updateLogs">Waiting for update job output…</pre>
572
+ </div>
573
+ </div>
540
574
  </div>
541
575
  <div class="modal-footer" style="justify-content: space-between;">
542
576
  <button class="btn btn-primary" id="updateAppBtn" style="background-color: var(--color-warning);">Update
@@ -556,4 +590,4 @@
556
590
  <script src="/js/app.js"></script>
557
591
  </body>
558
592
 
559
- </html>
593
+ </html>
@@ -191,6 +191,70 @@ button, input, textarea, select {
191
191
  color: var(--text-secondary);
192
192
  text-transform: uppercase;
193
193
  }
194
+ .settings-inline-label {
195
+ display: flex;
196
+ align-items: center;
197
+ gap: 8px;
198
+ }
199
+ .settings-info-wrap {
200
+ position: relative;
201
+ display: inline-flex;
202
+ align-items: center;
203
+ justify-content: center;
204
+ width: 15px;
205
+ height: 15px;
206
+ border-radius: 999px;
207
+ border: 1px solid var(--border-light);
208
+ color: var(--text-secondary);
209
+ font-size: 10px;
210
+ line-height: 1;
211
+ cursor: help;
212
+ text-transform: none;
213
+ }
214
+ .settings-info-icon {
215
+ display: inline-flex;
216
+ align-items: center;
217
+ justify-content: center;
218
+ width: 100%;
219
+ height: 100%;
220
+ }
221
+ .settings-info-pop {
222
+ position: absolute;
223
+ top: 20px;
224
+ left: 50%;
225
+ transform: translateX(-50%);
226
+ min-width: 240px;
227
+ max-width: 280px;
228
+ padding: 8px 10px;
229
+ border-radius: 8px;
230
+ border: 1px solid var(--border-light);
231
+ background: rgba(14,14,26,.98);
232
+ color: var(--text-primary);
233
+ font-size: 11px;
234
+ font-weight: 400;
235
+ letter-spacing: 0;
236
+ text-transform: none;
237
+ line-height: 1.4;
238
+ white-space: normal;
239
+ opacity: 0;
240
+ pointer-events: none;
241
+ transition: opacity 120ms ease;
242
+ z-index: 2;
243
+ }
244
+ .settings-info-wrap:hover .settings-info-pop,
245
+ .settings-info-wrap:focus .settings-info-pop,
246
+ .settings-info-wrap:focus-within .settings-info-pop {
247
+ opacity: 1;
248
+ }
249
+ .settings-token-box {
250
+ border: 1px solid var(--border);
251
+ border-radius: 10px;
252
+ padding: 10px 12px;
253
+ background: rgba(255,255,255,.02);
254
+ color: var(--text-secondary);
255
+ font-size: 12px;
256
+ line-height: 1.45;
257
+ }
194
258
 
195
259
  /* ── Cards ── */
196
260
 
@@ -325,6 +389,110 @@ button, input, textarea, select {
325
389
  border-top: 1px solid var(--border);
326
390
  }
327
391
 
392
+ .settings-update-panel {
393
+ margin-top: 18px;
394
+ border: 1px solid var(--border);
395
+ border-radius: 12px;
396
+ padding: 14px;
397
+ background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01));
398
+ }
399
+
400
+ .settings-update-head {
401
+ display: flex;
402
+ align-items: center;
403
+ justify-content: space-between;
404
+ gap: 12px;
405
+ margin-bottom: 10px;
406
+ }
407
+
408
+ .settings-update-title {
409
+ font-size: 12px;
410
+ font-weight: 700;
411
+ letter-spacing: .6px;
412
+ text-transform: uppercase;
413
+ color: var(--text-secondary);
414
+ }
415
+
416
+ .settings-update-progress-wrap {
417
+ width: 100%;
418
+ height: 8px;
419
+ border-radius: 999px;
420
+ background: rgba(255,255,255,.08);
421
+ overflow: hidden;
422
+ }
423
+
424
+ .settings-update-progress-bar {
425
+ height: 100%;
426
+ width: 0%;
427
+ background: linear-gradient(90deg, #22c55e, #06b6d4);
428
+ border-radius: inherit;
429
+ transition: width 240ms ease;
430
+ }
431
+
432
+ .settings-update-row {
433
+ display: flex;
434
+ justify-content: space-between;
435
+ align-items: center;
436
+ gap: 10px;
437
+ margin-top: 8px;
438
+ font-size: 12px;
439
+ color: var(--text-secondary);
440
+ }
441
+
442
+ .settings-update-meta {
443
+ margin-top: 6px;
444
+ font-family: 'JetBrains Mono', monospace;
445
+ font-size: 11px;
446
+ color: var(--text-muted);
447
+ }
448
+
449
+ .settings-update-section {
450
+ margin-top: 12px;
451
+ }
452
+
453
+ .settings-update-label {
454
+ font-size: 11px;
455
+ font-weight: 600;
456
+ letter-spacing: .4px;
457
+ text-transform: uppercase;
458
+ color: var(--text-secondary);
459
+ margin-bottom: 6px;
460
+ }
461
+
462
+ .settings-update-changelog {
463
+ margin: 0;
464
+ padding-left: 18px;
465
+ max-height: 110px;
466
+ overflow-y: auto;
467
+ font-size: 12px;
468
+ color: var(--text-primary);
469
+ display: flex;
470
+ flex-direction: column;
471
+ gap: 4px;
472
+ }
473
+
474
+ .settings-update-empty {
475
+ list-style: none;
476
+ margin-left: -18px;
477
+ color: var(--text-muted);
478
+ }
479
+
480
+ .settings-update-logs {
481
+ margin: 0;
482
+ padding: 10px;
483
+ background: rgba(5,10,18,.8);
484
+ border: 1px solid var(--border);
485
+ border-radius: 8px;
486
+ font-family: 'JetBrains Mono', monospace;
487
+ font-size: 11px;
488
+ line-height: 1.45;
489
+ color: #cfd7ff;
490
+ max-height: 140px;
491
+ overflow-y: auto;
492
+ white-space: pre-wrap;
493
+ word-break: break-word;
494
+ }
495
+
328
496
  @keyframes modalIn {
329
497
  from { transform: translateY(14px) scale(.96); opacity: 0; }
330
498
  to { transform: translateY(0) scale(1); opacity: 1; }
@@ -469,4 +637,3 @@ button, input, textarea, select {
469
637
  }
470
638
 
471
639
  .animate-in { animation: fadeSlideUp 200ms cubic-bezier(.16,1,.3,1) both; }
472
-