@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.
- package/app/api/notifications/route.ts +42 -0
- package/app/api/upgrade/route.ts +14 -11
- package/app/api/version/route.ts +19 -3
- package/components/Dashboard.tsx +141 -36
- package/components/SettingsModal.tsx +23 -0
- package/lib/init.ts +6 -0
- package/lib/notifications.ts +75 -0
- package/lib/notify.ts +30 -5
- package/lib/settings.ts +2 -0
- package/package.json +1 -1
- package/src/core/db/database.ts +13 -0
|
@@ -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
|
+
}
|
package/app/api/upgrade/route.ts
CHANGED
|
@@ -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
|
-
//
|
|
8
|
-
const
|
|
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
|
-
//
|
|
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(
|
|
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,
|
|
40
|
+
error: `Upgrade failed: ${msg.slice(0, 300)}`,
|
|
38
41
|
});
|
|
39
42
|
}
|
|
40
43
|
}
|
package/app/api/version/route.ts
CHANGED
|
@@ -12,9 +12,12 @@ const CURRENT_VERSION = (() => {
|
|
|
12
12
|
}
|
|
13
13
|
})();
|
|
14
14
|
|
|
15
|
-
// Cache npm version check for
|
|
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;
|
|
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,
|
package/components/Dashboard.tsx
CHANGED
|
@@ -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 [
|
|
55
|
-
const [
|
|
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 &&
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
package/src/core/db/database.ts
CHANGED
|
@@ -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,
|