@aion0/forge 0.2.20 → 0.2.22

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.
@@ -0,0 +1,42 @@
1
+ import { NextResponse } from 'next/server';
2
+ import {
3
+ getNotifications,
4
+ getUnreadCount,
5
+ markRead,
6
+ markAllRead,
7
+ deleteNotification,
8
+ } from '@/lib/notifications';
9
+
10
+ // GET /api/notifications — list notifications + unread count
11
+ export async function GET(req: Request) {
12
+ const { searchParams } = new URL(req.url);
13
+ const limit = parseInt(searchParams.get('limit') || '50');
14
+ const offset = parseInt(searchParams.get('offset') || '0');
15
+
16
+ const notifications = getNotifications(limit, offset);
17
+ const unread = getUnreadCount();
18
+
19
+ return NextResponse.json({ notifications, unread });
20
+ }
21
+
22
+ // POST /api/notifications — actions: markRead, markAllRead, delete
23
+ export async function POST(req: Request) {
24
+ const body = await req.json();
25
+
26
+ if (body.action === 'markRead' && body.id) {
27
+ markRead(body.id);
28
+ return NextResponse.json({ ok: true });
29
+ }
30
+
31
+ if (body.action === 'markAllRead') {
32
+ markAllRead();
33
+ return NextResponse.json({ ok: true });
34
+ }
35
+
36
+ if (body.action === 'delete' && body.id) {
37
+ deleteNotification(body.id);
38
+ return NextResponse.json({ ok: true });
39
+ }
40
+
41
+ return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
42
+ }
@@ -1,20 +1,23 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import { join } from 'node:path';
3
3
  import { execSync } from 'node:child_process';
4
+ import { readFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
4
6
 
5
7
  export async function POST() {
6
8
  try {
7
- // Run upgrade with cache bypass
8
- const output = execSync(
9
- 'cd /tmp && npm install -g @aion0/forge@latest --prefer-online 2>&1',
10
- { encoding: 'utf-8', timeout: 120000 }
11
- );
12
-
13
- // Verify the installed version
14
- const pkgRoot = execSync('npm root -g', { encoding: 'utf-8', timeout: 5000 }).trim();
9
+ // Get global npm root first (before any cwd changes)
10
+ const pkgRoot = execSync('npm root -g', { encoding: 'utf-8', timeout: 5000, cwd: homedir() }).trim();
15
11
  const forgeRoot = join(pkgRoot, '@aion0', 'forge');
16
12
 
17
- // Install devDependencies for build (npm -g doesn't install them)
13
+ // Upgrade from npm use cwd instead of cd
14
+ execSync('npm install -g @aion0/forge@latest --prefer-online 2>&1', {
15
+ encoding: 'utf-8',
16
+ timeout: 120000,
17
+ cwd: homedir(),
18
+ });
19
+
20
+ // Install devDependencies for build
18
21
  try {
19
22
  execSync('npm install --include=dev 2>&1', { cwd: forgeRoot, timeout: 120000 });
20
23
  } catch {}
@@ -22,7 +25,7 @@ export async function POST() {
22
25
  // Read installed version
23
26
  let installedVersion = '';
24
27
  try {
25
- const pkg = JSON.parse(require('fs').readFileSync(join(forgeRoot, 'package.json'), 'utf-8'));
28
+ const pkg = JSON.parse(readFileSync(join(forgeRoot, 'package.json'), 'utf-8'));
26
29
  installedVersion = pkg.version;
27
30
  } catch {}
28
31
 
@@ -34,7 +37,7 @@ export async function POST() {
34
37
  const msg = e instanceof Error ? e.message : String(e);
35
38
  return NextResponse.json({
36
39
  ok: false,
37
- error: `Upgrade failed: ${msg.slice(0, 200)}`,
40
+ error: `Upgrade failed: ${msg.slice(0, 300)}`,
38
41
  });
39
42
  }
40
43
  }
@@ -12,9 +12,12 @@ const CURRENT_VERSION = (() => {
12
12
  }
13
13
  })();
14
14
 
15
- // Cache npm version check for 1 hour
15
+ // Cache npm version check for 10 minutes
16
16
  let cachedLatest: { version: string; checkedAt: number } | null = null;
17
- const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
17
+ const CACHE_TTL = 10 * 60 * 1000;
18
+
19
+ // Track which versions we already notified about (avoid duplicates)
20
+ let notifiedVersion = '';
18
21
 
19
22
  async function getLatestVersion(force = false): Promise<string> {
20
23
  if (!force && cachedLatest && Date.now() - cachedLatest.checkedAt < CACHE_TTL) {
@@ -52,7 +55,20 @@ export async function GET(req: Request) {
52
55
  const force = searchParams.has('force');
53
56
  const current = CURRENT_VERSION;
54
57
  const latest = await getLatestVersion(force);
55
- const hasUpdate = latest && compareVersions(current, latest) < 0;
58
+ const hasUpdate = !!(latest && compareVersions(current, latest) < 0);
59
+
60
+ // Create a notification when new version is detected (once per version)
61
+ if (hasUpdate && latest !== notifiedVersion) {
62
+ notifiedVersion = latest;
63
+ try {
64
+ const { addNotification } = require('@/lib/notifications');
65
+ addNotification(
66
+ 'system',
67
+ `Update available: v${latest}`,
68
+ `Current: v${current}. Run: forge upgrade`,
69
+ );
70
+ } catch {}
71
+ }
56
72
 
57
73
  return NextResponse.json({
58
74
  current,
@@ -51,8 +51,9 @@ export default function Dashboard({ user }: { user: any }) {
51
51
  const [projects, setProjects] = useState<ProjectInfo[]>([]);
52
52
  const [onlineCount, setOnlineCount] = useState<{ total: number; remote: number }>({ total: 0, remote: 0 });
53
53
  const [versionInfo, setVersionInfo] = useState<{ current: string; latest: string; hasUpdate: boolean } | null>(null);
54
- const [upgrading, setUpgrading] = useState(false);
55
- const [upgradeResult, setUpgradeResult] = useState<string | null>(null);
54
+ const [notifications, setNotifications] = useState<any[]>([]);
55
+ const [unreadCount, setUnreadCount] = useState(0);
56
+ const [showNotifications, setShowNotifications] = useState(false);
56
57
  const terminalRef = useRef<WebTerminalHandle>(null);
57
58
 
58
59
  // Version check (on mount + every 10 min)
@@ -63,6 +64,20 @@ export default function Dashboard({ user }: { user: any }) {
63
64
  return () => clearInterval(id);
64
65
  }, []);
65
66
 
67
+ // Notification polling
68
+ const fetchNotifications = useCallback(() => {
69
+ fetch('/api/notifications').then(r => r.json()).then(data => {
70
+ setNotifications(data.notifications || []);
71
+ setUnreadCount(data.unread || 0);
72
+ }).catch(() => {});
73
+ }, []);
74
+
75
+ useEffect(() => {
76
+ fetchNotifications();
77
+ const id = setInterval(fetchNotifications, 10000);
78
+ return () => clearInterval(id);
79
+ }, [fetchNotifications]);
80
+
66
81
  // Heartbeat for online user tracking
67
82
  useEffect(() => {
68
83
  const ping = () => {
@@ -110,43 +125,26 @@ export default function Dashboard({ user }: { user: any }) {
110
125
  {versionInfo && (
111
126
  <span className="flex items-center gap-1.5">
112
127
  <span className="text-[10px] text-[var(--text-secondary)]">v{versionInfo.current}</span>
113
- {versionInfo.hasUpdate && !upgradeResult && (
114
- <button
115
- disabled={upgrading}
116
- onClick={async () => {
117
- setUpgrading(true);
118
- try {
119
- const res = await fetch('/api/upgrade', { method: 'POST' });
120
- const data = await res.json();
121
- setUpgradeResult(data.ok ? data.message : data.error);
122
- if (data.ok) setVersionInfo(v => v ? { ...v, hasUpdate: false } : v);
123
- } catch { setUpgradeResult('Upgrade failed'); }
124
- setUpgrading(false);
125
- }}
126
- className="text-[9px] px-1.5 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
127
- title={`Update to v${versionInfo.latest}\nOr run: forge upgrade`}
128
+ {versionInfo.hasUpdate && (
129
+ <span
130
+ className="text-[9px] px-1.5 py-0.5 bg-[var(--accent)]/15 text-[var(--accent)] rounded cursor-default"
131
+ title="Run: forge upgrade"
128
132
  >
129
- {upgrading ? 'Upgrading...' : `Update v${versionInfo.latest}`}
130
- </button>
131
- )}
132
- {!versionInfo.hasUpdate && !upgradeResult && (
133
- <button
134
- onClick={async () => {
135
- const res = await fetch('/api/version?force=1');
136
- const data = await res.json();
137
- setVersionInfo(data);
138
- }}
139
- className="text-[9px] px-1 py-0.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
140
- title="Check for updates"
141
- >
142
-
143
- </button>
144
- )}
145
- {upgradeResult && (
146
- <span className="text-[9px] text-[var(--green)] max-w-[200px] truncate" title={upgradeResult}>
147
- {upgradeResult}
133
+ v{versionInfo.latest} available
148
134
  </span>
149
135
  )}
136
+ <button
137
+ onClick={async () => {
138
+ const res = await fetch('/api/version?force=1');
139
+ const data = await res.json();
140
+ setVersionInfo(data);
141
+ if (data.hasUpdate) fetchNotifications();
142
+ }}
143
+ className="text-[9px] px-1 py-0.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
144
+ title="Check for updates"
145
+ >
146
+
147
+ </button>
150
148
  </span>
151
149
  )}
152
150
 
@@ -249,6 +247,113 @@ export default function Dashboard({ user }: { user: any }) {
249
247
  )}
250
248
  </span>
251
249
  )}
250
+ <div className="relative">
251
+ <button
252
+ onClick={() => { setShowNotifications(v => !v); }}
253
+ className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] relative"
254
+ >
255
+ Alerts
256
+ {unreadCount > 0 && (
257
+ <span className="absolute -top-1.5 -right-2.5 min-w-[14px] h-[14px] rounded-full bg-[var(--red)] text-[8px] text-white flex items-center justify-center px-1 font-bold">
258
+ {unreadCount > 99 ? '99+' : unreadCount}
259
+ </span>
260
+ )}
261
+ </button>
262
+ {showNotifications && (
263
+ <div className="absolute right-0 top-8 w-[360px] max-h-[480px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50 flex flex-col">
264
+ <div className="flex items-center justify-between px-3 py-2 border-b border-[var(--border)]">
265
+ <span className="text-xs font-bold text-[var(--text-primary)]">Notifications</span>
266
+ <div className="flex items-center gap-2">
267
+ {unreadCount > 0 && (
268
+ <button
269
+ onClick={async () => {
270
+ await fetch('/api/notifications', {
271
+ method: 'POST',
272
+ headers: { 'Content-Type': 'application/json' },
273
+ body: JSON.stringify({ action: 'markAllRead' }),
274
+ });
275
+ fetchNotifications();
276
+ }}
277
+ className="text-[9px] text-[var(--accent)] hover:underline"
278
+ >
279
+ Mark all read
280
+ </button>
281
+ )}
282
+ <button
283
+ onClick={() => setShowNotifications(false)}
284
+ className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
285
+ >
286
+ Close
287
+ </button>
288
+ </div>
289
+ </div>
290
+ <div className="flex-1 overflow-y-auto">
291
+ {notifications.length === 0 ? (
292
+ <div className="p-6 text-center text-xs text-[var(--text-secondary)]">No notifications</div>
293
+ ) : (
294
+ notifications.map((n: any) => (
295
+ <div
296
+ key={n.id}
297
+ className={`group px-3 py-2 border-b border-[var(--border)]/50 hover:bg-[var(--bg-tertiary)] ${!n.read ? 'bg-[var(--accent)]/5' : ''}`}
298
+ >
299
+ <div className="flex items-start gap-2">
300
+ <span className="text-[10px] mt-0.5 shrink-0">
301
+ {n.type === 'task_done' ? '✅' : n.type === 'task_failed' ? '❌' : n.type === 'pipeline_done' ? '🔗' : n.type === 'pipeline_failed' ? '💔' : n.type === 'tunnel' ? '🌐' : 'ℹ️'}
302
+ </span>
303
+ <div className="flex-1 min-w-0">
304
+ <div className="flex items-center gap-1">
305
+ <span className={`text-[11px] truncate ${!n.read ? 'font-semibold text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}>
306
+ {n.title}
307
+ </span>
308
+ {!n.read && <span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)] shrink-0" />}
309
+ </div>
310
+ {n.body && (
311
+ <p className="text-[9px] text-[var(--text-secondary)] truncate mt-0.5">{n.body}</p>
312
+ )}
313
+ <span className="text-[8px] text-[var(--text-secondary)]">
314
+ {new Date(n.createdAt).toLocaleString()}
315
+ </span>
316
+ </div>
317
+ <div className="hidden group-hover:flex items-center gap-1 shrink-0">
318
+ {!n.read && (
319
+ <button
320
+ onClick={async (e) => {
321
+ e.stopPropagation();
322
+ await fetch('/api/notifications', {
323
+ method: 'POST',
324
+ headers: { 'Content-Type': 'application/json' },
325
+ body: JSON.stringify({ action: 'markRead', id: n.id }),
326
+ });
327
+ fetchNotifications();
328
+ }}
329
+ className="text-[8px] px-1 py-0.5 text-[var(--accent)] hover:underline"
330
+ >
331
+ read
332
+ </button>
333
+ )}
334
+ <button
335
+ onClick={async (e) => {
336
+ e.stopPropagation();
337
+ await fetch('/api/notifications', {
338
+ method: 'POST',
339
+ headers: { 'Content-Type': 'application/json' },
340
+ body: JSON.stringify({ action: 'delete', id: n.id }),
341
+ });
342
+ fetchNotifications();
343
+ }}
344
+ className="text-[8px] px-1 py-0.5 text-red-400 hover:underline"
345
+ >
346
+ del
347
+ </button>
348
+ </div>
349
+ </div>
350
+ </div>
351
+ ))
352
+ )}
353
+ </div>
354
+ </div>
355
+ )}
356
+ </div>
252
357
  <button
253
358
  onClick={() => setShowMonitor(true)}
254
359
  className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
@@ -202,6 +202,7 @@ interface Settings {
202
202
  pipelineModel: string;
203
203
  telegramModel: string;
204
204
  skipPermissions: boolean;
205
+ notificationRetentionDays: number;
205
206
  _secretStatus?: Record<string, boolean>;
206
207
  }
207
208
 
@@ -228,6 +229,7 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
228
229
  pipelineModel: 'sonnet',
229
230
  telegramModel: 'sonnet',
230
231
  skipPermissions: false,
232
+ notificationRetentionDays: 30,
231
233
  });
232
234
  const [secretStatus, setSecretStatus] = useState<Record<string, boolean>>({});
233
235
  const [newRoot, setNewRoot] = useState('');
@@ -578,6 +580,27 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
578
580
  </p>
579
581
  </div>
580
582
 
583
+ {/* Notification Retention */}
584
+ <div className="space-y-2">
585
+ <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
586
+ Notifications
587
+ </label>
588
+ <div className="flex items-center gap-2">
589
+ <span className="text-[10px] text-[var(--text-secondary)]">Auto-delete after</span>
590
+ <select
591
+ value={settings.notificationRetentionDays || 30}
592
+ onChange={e => setSettings({ ...settings, notificationRetentionDays: Number(e.target.value) })}
593
+ className="text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)]"
594
+ >
595
+ <option value={7}>7 days</option>
596
+ <option value={14}>14 days</option>
597
+ <option value={30}>30 days</option>
598
+ <option value={60}>60 days</option>
599
+ <option value={90}>90 days</option>
600
+ </select>
601
+ </div>
602
+ </div>
603
+
581
604
  {/* Remote Access (Cloudflare Tunnel) */}
582
605
  <div className="space-y-2">
583
606
  <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
package/lib/init.ts CHANGED
@@ -67,6 +67,12 @@ export function ensureInitialized() {
67
67
  // Migrate plaintext secrets on startup
68
68
  migrateSecrets();
69
69
 
70
+ // Cleanup old notifications
71
+ try {
72
+ const { cleanupNotifications } = require('./notifications');
73
+ cleanupNotifications();
74
+ } catch {}
75
+
70
76
  // Auto-detect claude path if not configured
71
77
  autoDetectClaude();
72
78
 
@@ -0,0 +1,75 @@
1
+ /**
2
+ * In-app notification system.
3
+ * Stores notifications in SQLite, auto-cleans based on retention setting.
4
+ */
5
+
6
+ import { getDb } from '@/src/core/db/database';
7
+ import { getDbPath } from '@/src/config';
8
+ import { loadSettings } from './settings';
9
+
10
+ export interface Notification {
11
+ id: number;
12
+ type: string; // 'task_done' | 'task_failed' | 'pipeline_done' | 'pipeline_failed' | 'tunnel' | 'system'
13
+ title: string;
14
+ body: string | null;
15
+ read: boolean;
16
+ taskId: string | null;
17
+ createdAt: string;
18
+ }
19
+
20
+ function db() {
21
+ return getDb(getDbPath());
22
+ }
23
+
24
+ /** Add a notification */
25
+ export function addNotification(type: string, title: string, body?: string, taskId?: string) {
26
+ db().prepare(
27
+ 'INSERT INTO notifications (type, title, body, task_id) VALUES (?, ?, ?, ?)'
28
+ ).run(type, title, body || null, taskId || null);
29
+ }
30
+
31
+ /** Get recent notifications (newest first) */
32
+ export function getNotifications(limit = 50, offset = 0): Notification[] {
33
+ const rows = db().prepare(
34
+ 'SELECT * FROM notifications ORDER BY created_at DESC LIMIT ? OFFSET ?'
35
+ ).all(limit, offset) as any[];
36
+ return rows.map(r => ({
37
+ id: r.id,
38
+ type: r.type,
39
+ title: r.title,
40
+ body: r.body,
41
+ read: !!r.read,
42
+ taskId: r.task_id,
43
+ createdAt: r.created_at,
44
+ }));
45
+ }
46
+
47
+ /** Count unread notifications */
48
+ export function getUnreadCount(): number {
49
+ const row = db().prepare('SELECT COUNT(*) as count FROM notifications WHERE read = 0').get() as any;
50
+ return row?.count || 0;
51
+ }
52
+
53
+ /** Mark one as read */
54
+ export function markRead(id: number) {
55
+ db().prepare('UPDATE notifications SET read = 1 WHERE id = ?').run(id);
56
+ }
57
+
58
+ /** Mark all as read */
59
+ export function markAllRead() {
60
+ db().prepare('UPDATE notifications SET read = 1 WHERE read = 0').run();
61
+ }
62
+
63
+ /** Delete one notification */
64
+ export function deleteNotification(id: number) {
65
+ db().prepare('DELETE FROM notifications WHERE id = ?').run(id);
66
+ }
67
+
68
+ /** Clean up old notifications based on retention setting */
69
+ export function cleanupNotifications() {
70
+ const settings = loadSettings();
71
+ const days = settings.notificationRetentionDays || 30;
72
+ db().prepare(
73
+ `DELETE FROM notifications WHERE created_at < datetime('now', '-' || ? || ' days')`
74
+ ).run(days);
75
+ }
package/lib/notify.ts CHANGED
@@ -3,14 +3,14 @@
3
3
  */
4
4
 
5
5
  import { loadSettings } from './settings';
6
+ import { addNotification } from './notifications';
6
7
  import type { Task } from '@/src/types';
7
8
 
8
9
  export async function notifyTaskComplete(task: Task) {
9
10
  // Skip pipeline tasks
10
- try { const { pipelineTaskIds } = require('./pipeline'); if (pipelineTaskIds.has(task.id)) return; } catch {}
11
-
12
- const settings = loadSettings();
13
- if (!settings.notifyOnComplete) return;
11
+ let isPipeline = false;
12
+ try { const { pipelineTaskIds } = require('./pipeline'); isPipeline = pipelineTaskIds.has(task.id); } catch {}
13
+ if (isPipeline) return;
14
14
 
15
15
  const cost = task.costUSD != null ? `$${task.costUSD.toFixed(4)}` : 'unknown';
16
16
  const duration = task.startedAt && task.completedAt
@@ -18,6 +18,19 @@ export async function notifyTaskComplete(task: Task) {
18
18
  : 'unknown';
19
19
  const model = task.log?.find(e => e.subtype === 'init' && e.content.startsWith('Model:'))?.content.replace('Model: ', '') || 'unknown';
20
20
 
21
+ // In-app notification (always)
22
+ try {
23
+ addNotification(
24
+ 'task_done',
25
+ `Task done: ${task.projectName}`,
26
+ `${task.prompt.slice(0, 100)} — ${duration}, ${cost}`,
27
+ task.id
28
+ );
29
+ } catch {}
30
+
31
+ const settings = loadSettings();
32
+ if (!settings.notifyOnComplete) return;
33
+
21
34
  await sendTelegram(
22
35
  `✅ *Task Done*\n\n` +
23
36
  `*Project:* ${esc(task.projectName)}\n` +
@@ -31,7 +44,19 @@ export async function notifyTaskComplete(task: Task) {
31
44
 
32
45
  export async function notifyTaskFailed(task: Task) {
33
46
  // Skip pipeline tasks
34
- try { const { pipelineTaskIds } = require('./pipeline'); if (pipelineTaskIds.has(task.id)) return; } catch {}
47
+ let isPipeline = false;
48
+ try { const { pipelineTaskIds } = require('./pipeline'); isPipeline = pipelineTaskIds.has(task.id); } catch {}
49
+ if (isPipeline) return;
50
+
51
+ // In-app notification (always)
52
+ try {
53
+ addNotification(
54
+ 'task_failed',
55
+ `Task failed: ${task.projectName}`,
56
+ task.error || task.prompt.slice(0, 100),
57
+ task.id
58
+ );
59
+ } catch {}
35
60
 
36
61
  const settings = loadSettings();
37
62
  if (!settings.notifyOnFailure) return;
package/lib/settings.ts CHANGED
@@ -21,6 +21,7 @@ export interface Settings {
21
21
  pipelineModel: string; // Model for pipelines (default: sonnet)
22
22
  telegramModel: string; // Model for Telegram AI features (default: sonnet)
23
23
  skipPermissions: boolean; // Add --dangerously-skip-permissions to all claude invocations
24
+ notificationRetentionDays: number; // Auto-cleanup notifications older than N days
24
25
  }
25
26
 
26
27
  const defaults: Settings = {
@@ -37,6 +38,7 @@ const defaults: Settings = {
37
38
  pipelineModel: 'default',
38
39
  telegramModel: 'sonnet',
39
40
  skipPermissions: false,
41
+ notificationRetentionDays: 30,
40
42
  };
41
43
 
42
44
  /** Load settings with secrets decrypted (for internal use) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -107,6 +107,19 @@ function initSchema(db: Database.Database) {
107
107
 
108
108
  CREATE INDEX IF NOT EXISTS idx_cached_sessions_project ON cached_sessions(project_name, modified);
109
109
 
110
+ -- In-app notifications
111
+ CREATE TABLE IF NOT EXISTS notifications (
112
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
113
+ type TEXT NOT NULL,
114
+ title TEXT NOT NULL,
115
+ body TEXT,
116
+ read INTEGER NOT NULL DEFAULT 0,
117
+ task_id TEXT,
118
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
119
+ );
120
+
121
+ CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read, created_at);
122
+
110
123
  -- Session watchers — monitor sessions and notify via Telegram
111
124
  CREATE TABLE IF NOT EXISTS session_watchers (
112
125
  id TEXT PRIMARY KEY,