@aion0/forge 0.2.19 → 0.2.21

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
  }
@@ -8,6 +8,7 @@ import SessionView from './SessionView';
8
8
  import NewTaskModal from './NewTaskModal';
9
9
  import SettingsModal from './SettingsModal';
10
10
  import TunnelToggle from './TunnelToggle';
11
+ import MonitorPanel from './MonitorPanel';
11
12
  import type { Task } from '@/src/types';
12
13
  import type { WebTerminalHandle } from './WebTerminal';
13
14
 
@@ -44,6 +45,7 @@ export default function Dashboard({ user }: { user: any }) {
44
45
  const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
45
46
  const [showNewTask, setShowNewTask] = useState(false);
46
47
  const [showSettings, setShowSettings] = useState(false);
48
+ const [showMonitor, setShowMonitor] = useState(false);
47
49
  const [usage, setUsage] = useState<UsageSummary[]>([]);
48
50
  const [providers, setProviders] = useState<ProviderInfo[]>([]);
49
51
  const [projects, setProjects] = useState<ProjectInfo[]>([]);
@@ -51,6 +53,9 @@ export default function Dashboard({ user }: { user: any }) {
51
53
  const [versionInfo, setVersionInfo] = useState<{ current: string; latest: string; hasUpdate: boolean } | null>(null);
52
54
  const [upgrading, setUpgrading] = useState(false);
53
55
  const [upgradeResult, setUpgradeResult] = useState<string | null>(null);
56
+ const [notifications, setNotifications] = useState<any[]>([]);
57
+ const [unreadCount, setUnreadCount] = useState(0);
58
+ const [showNotifications, setShowNotifications] = useState(false);
54
59
  const terminalRef = useRef<WebTerminalHandle>(null);
55
60
 
56
61
  // Version check (on mount + every 10 min)
@@ -61,6 +66,20 @@ export default function Dashboard({ user }: { user: any }) {
61
66
  return () => clearInterval(id);
62
67
  }, []);
63
68
 
69
+ // Notification polling
70
+ const fetchNotifications = useCallback(() => {
71
+ fetch('/api/notifications').then(r => r.json()).then(data => {
72
+ setNotifications(data.notifications || []);
73
+ setUnreadCount(data.unread || 0);
74
+ }).catch(() => {});
75
+ }, []);
76
+
77
+ useEffect(() => {
78
+ fetchNotifications();
79
+ const id = setInterval(fetchNotifications, 10000);
80
+ return () => clearInterval(id);
81
+ }, [fetchNotifications]);
82
+
64
83
  // Heartbeat for online user tracking
65
84
  useEffect(() => {
66
85
  const ping = () => {
@@ -200,6 +219,16 @@ export default function Dashboard({ user }: { user: any }) {
200
219
  >
201
220
  Pipelines
202
221
  </button>
222
+ <button
223
+ onClick={() => setViewMode('sessions')}
224
+ className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
225
+ viewMode === 'sessions'
226
+ ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
227
+ : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
228
+ }`}
229
+ >
230
+ Sessions
231
+ </button>
203
232
  <button
204
233
  onClick={() => setViewMode('preview')}
205
234
  className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
@@ -237,9 +266,116 @@ export default function Dashboard({ user }: { user: any }) {
237
266
  )}
238
267
  </span>
239
268
  )}
269
+ <div className="relative">
270
+ <button
271
+ onClick={() => { setShowNotifications(v => !v); }}
272
+ className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] relative"
273
+ >
274
+ Alerts
275
+ {unreadCount > 0 && (
276
+ <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">
277
+ {unreadCount > 99 ? '99+' : unreadCount}
278
+ </span>
279
+ )}
280
+ </button>
281
+ {showNotifications && (
282
+ <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">
283
+ <div className="flex items-center justify-between px-3 py-2 border-b border-[var(--border)]">
284
+ <span className="text-xs font-bold text-[var(--text-primary)]">Notifications</span>
285
+ <div className="flex items-center gap-2">
286
+ {unreadCount > 0 && (
287
+ <button
288
+ onClick={async () => {
289
+ await fetch('/api/notifications', {
290
+ method: 'POST',
291
+ headers: { 'Content-Type': 'application/json' },
292
+ body: JSON.stringify({ action: 'markAllRead' }),
293
+ });
294
+ fetchNotifications();
295
+ }}
296
+ className="text-[9px] text-[var(--accent)] hover:underline"
297
+ >
298
+ Mark all read
299
+ </button>
300
+ )}
301
+ <button
302
+ onClick={() => setShowNotifications(false)}
303
+ className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
304
+ >
305
+ Close
306
+ </button>
307
+ </div>
308
+ </div>
309
+ <div className="flex-1 overflow-y-auto">
310
+ {notifications.length === 0 ? (
311
+ <div className="p-6 text-center text-xs text-[var(--text-secondary)]">No notifications</div>
312
+ ) : (
313
+ notifications.map((n: any) => (
314
+ <div
315
+ key={n.id}
316
+ className={`group px-3 py-2 border-b border-[var(--border)]/50 hover:bg-[var(--bg-tertiary)] ${!n.read ? 'bg-[var(--accent)]/5' : ''}`}
317
+ >
318
+ <div className="flex items-start gap-2">
319
+ <span className="text-[10px] mt-0.5 shrink-0">
320
+ {n.type === 'task_done' ? '✅' : n.type === 'task_failed' ? '❌' : n.type === 'pipeline_done' ? '🔗' : n.type === 'pipeline_failed' ? '💔' : n.type === 'tunnel' ? '🌐' : 'ℹ️'}
321
+ </span>
322
+ <div className="flex-1 min-w-0">
323
+ <div className="flex items-center gap-1">
324
+ <span className={`text-[11px] truncate ${!n.read ? 'font-semibold text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}>
325
+ {n.title}
326
+ </span>
327
+ {!n.read && <span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)] shrink-0" />}
328
+ </div>
329
+ {n.body && (
330
+ <p className="text-[9px] text-[var(--text-secondary)] truncate mt-0.5">{n.body}</p>
331
+ )}
332
+ <span className="text-[8px] text-[var(--text-secondary)]">
333
+ {new Date(n.createdAt).toLocaleString()}
334
+ </span>
335
+ </div>
336
+ <div className="hidden group-hover:flex items-center gap-1 shrink-0">
337
+ {!n.read && (
338
+ <button
339
+ onClick={async (e) => {
340
+ e.stopPropagation();
341
+ await fetch('/api/notifications', {
342
+ method: 'POST',
343
+ headers: { 'Content-Type': 'application/json' },
344
+ body: JSON.stringify({ action: 'markRead', id: n.id }),
345
+ });
346
+ fetchNotifications();
347
+ }}
348
+ className="text-[8px] px-1 py-0.5 text-[var(--accent)] hover:underline"
349
+ >
350
+ read
351
+ </button>
352
+ )}
353
+ <button
354
+ onClick={async (e) => {
355
+ e.stopPropagation();
356
+ await fetch('/api/notifications', {
357
+ method: 'POST',
358
+ headers: { 'Content-Type': 'application/json' },
359
+ body: JSON.stringify({ action: 'delete', id: n.id }),
360
+ });
361
+ fetchNotifications();
362
+ }}
363
+ className="text-[8px] px-1 py-0.5 text-red-400 hover:underline"
364
+ >
365
+ del
366
+ </button>
367
+ </div>
368
+ </div>
369
+ </div>
370
+ ))
371
+ )}
372
+ </div>
373
+ </div>
374
+ )}
375
+ </div>
240
376
  <button
241
- onClick={() => setViewMode('sessions')}
242
- className={`text-xs ${viewMode === 'sessions' ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
377
+ onClick={() => setShowMonitor(true)}
378
+ className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
243
379
  >
244
380
  Monitor
245
381
  </button>
@@ -414,6 +550,8 @@ export default function Dashboard({ user }: { user: any }) {
414
550
  />
415
551
  )}
416
552
 
553
+ {showMonitor && <MonitorPanel onClose={() => setShowMonitor(false)} />}
554
+
417
555
  {showSettings && (
418
556
  <SettingsModal onClose={() => { setShowSettings(false); fetchData(); }} />
419
557
  )}
@@ -32,16 +32,6 @@ interface Watcher {
32
32
  createdAt: string;
33
33
  }
34
34
 
35
- interface MonitorData {
36
- processes: {
37
- nextjs: { running: boolean; pid: string };
38
- terminal: { running: boolean; pid: string };
39
- telegram: { running: boolean; pid: string };
40
- tunnel: { running: boolean; pid: string; url: string };
41
- };
42
- sessions: { name: string; created: string; attached: boolean; windows: number }[];
43
- uptime: string;
44
- }
45
35
 
46
36
  export default function SessionView({
47
37
  projectName,
@@ -63,7 +53,6 @@ export default function SessionView({
63
53
  const [watchers, setWatchers] = useState<Watcher[]>([]);
64
54
  const [batchMode, setBatchMode] = useState(false);
65
55
  const [selectedIds, setSelectedIds] = useState<Map<string, Set<string>>>(new Map());
66
- const [monitor, setMonitor] = useState<MonitorData | null>(null);
67
56
  const bottomRef = useRef<HTMLDivElement>(null);
68
57
 
69
58
  // Load cached sessions tree
@@ -91,17 +80,10 @@ export default function SessionView({
91
80
  } catch {}
92
81
  }, []);
93
82
 
94
- const refreshMonitor = useCallback(() => {
95
- fetch('/api/monitor').then(r => r.json()).then(setMonitor).catch(() => {});
96
- }, []);
97
-
98
83
  useEffect(() => {
99
84
  loadTree(true);
100
85
  loadWatchers();
101
- refreshMonitor();
102
- const timer = setInterval(refreshMonitor, 5000);
103
- return () => clearInterval(timer);
104
- }, [loadTree, loadWatchers, refreshMonitor]);
86
+ }, [loadTree, loadWatchers]);
105
87
 
106
88
  // Auto-expand project if only one or if pre-selected
107
89
  useEffect(() => {
@@ -347,46 +329,6 @@ export default function SessionView({
347
329
  </div>
348
330
  )}
349
331
 
350
- {/* Monitor — always visible */}
351
- {monitor && (
352
- <div className="border-b border-[var(--border)] px-2 py-2 space-y-1.5">
353
- <div className="flex items-center justify-between">
354
- <span className="text-[9px] font-semibold text-[var(--text-secondary)] uppercase">Processes</span>
355
- {monitor.uptime && (
356
- <span className="text-[8px] text-[var(--text-secondary)]">up {monitor.uptime}</span>
357
- )}
358
- </div>
359
- {[
360
- { label: 'Next.js', ...monitor.processes.nextjs },
361
- { label: 'Terminal', ...monitor.processes.terminal },
362
- { label: 'Telegram', ...monitor.processes.telegram },
363
- { label: 'Tunnel', ...monitor.processes.tunnel },
364
- ].map(p => (
365
- <div key={p.label} className="flex items-center gap-1.5 text-[10px]">
366
- <span className={p.running ? 'text-green-400' : 'text-gray-500'}>●</span>
367
- <span className="text-[var(--text-primary)]">{p.label}</span>
368
- <span className="text-[var(--text-secondary)] font-mono ml-auto">{p.running ? `pid:${p.pid}` : 'stopped'}</span>
369
- </div>
370
- ))}
371
- {monitor.processes.tunnel.running && monitor.processes.tunnel.url && (
372
- <div className="text-[9px] text-[var(--accent)] truncate pl-4">{monitor.processes.tunnel.url}</div>
373
- )}
374
-
375
- {monitor.sessions.length > 0 && (
376
- <div className="pt-1">
377
- <span className="text-[9px] font-semibold text-[var(--text-secondary)] uppercase">Tmux ({monitor.sessions.length})</span>
378
- {monitor.sessions.map(s => (
379
- <div key={s.name} className="flex items-center gap-1.5 text-[10px] mt-0.5">
380
- <span className={s.attached ? 'text-green-400' : 'text-yellow-500'}>●</span>
381
- <span className="font-mono text-[var(--text-primary)] truncate flex-1">{s.name}</span>
382
- <span className="text-[8px] text-[var(--text-secondary)]">{s.attached ? 'attached' : 'detached'}</span>
383
- </div>
384
- ))}
385
- </div>
386
- )}
387
- </div>
388
- )}
389
-
390
332
  {/* Tree */}
391
333
  <div className="flex-1 overflow-y-auto">
392
334
  {Object.keys(sessionTree).length === 0 && (
@@ -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.19",
3
+ "version": "0.2.21",
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,