agentfit 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.github/workflows/release.yml +4 -0
  2. package/README.md +0 -2
  3. package/app/(dashboard)/ai-insights/page.tsx +271 -0
  4. package/app/(dashboard)/models/page.tsx +21 -0
  5. package/app/(dashboard)/page.tsx +2 -0
  6. package/app/(dashboard)/sessions/[id]/page.tsx +16 -2
  7. package/app/(dashboard)/settings/page.tsx +168 -0
  8. package/app/api/analyze/aggregate/route.ts +88 -0
  9. package/app/api/analyze/estimate/route.ts +62 -0
  10. package/app/api/analyze/route.ts +142 -0
  11. package/app/api/cc-versions/route.ts +84 -0
  12. package/app/api/config/route.ts +35 -0
  13. package/bin/agentfit.mjs +18 -8
  14. package/components/analyze-confirm-dialog.tsx +81 -0
  15. package/components/app-sidebar.tsx +14 -0
  16. package/components/data-provider.tsx +4 -2
  17. package/components/model-usage-chart.tsx +216 -0
  18. package/components/overview-cards.tsx +1 -1
  19. package/components/session-ai-analysis.tsx +318 -0
  20. package/components/sessions-table.tsx +169 -15
  21. package/components/version-lag-chart.tsx +284 -0
  22. package/electron/main.mjs +61 -34
  23. package/generated/prisma/browser.ts +5 -0
  24. package/generated/prisma/client.ts +5 -0
  25. package/generated/prisma/internal/class.ts +14 -4
  26. package/generated/prisma/internal/prismaNamespace.ts +95 -2
  27. package/generated/prisma/internal/prismaNamespaceBrowser.ts +19 -1
  28. package/generated/prisma/models/Session.ts +57 -1
  29. package/generated/prisma/models/SessionAnalysis.ts +1321 -0
  30. package/generated/prisma/models.ts +1 -0
  31. package/lib/config.ts +45 -0
  32. package/lib/openai.ts +253 -0
  33. package/lib/parse-codex.ts +2 -0
  34. package/lib/parse-logs.ts +21 -7
  35. package/lib/queries.ts +5 -1
  36. package/lib/sync.ts +17 -5
  37. package/package.json +2 -1
  38. package/prisma/migrations/20260404151230_add_session_analysis/migration.sql +18 -0
  39. package/prisma/migrations/20260405230736_add_cli_version/migration.sql +41 -0
  40. package/prisma/migrations/20260406205546_add_model_counts/migration.sql +42 -0
  41. package/prisma/schema.prisma +16 -0
  42. package/prisma/schema.sql +21 -0
@@ -1,8 +1,9 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import { useState, useEffect, useCallback } from 'react'
4
4
  import Link from 'next/link'
5
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
6
+ import { Button } from '@/components/ui/button'
6
7
  import {
7
8
  Table,
8
9
  TableBody,
@@ -21,12 +22,124 @@ import {
21
22
  import { Badge } from '@/components/ui/badge'
22
23
  import { PaginationControls } from '@/components/pagination-controls'
23
24
  import { usePagination } from '@/hooks/use-pagination'
24
- import { Eye } from 'lucide-react'
25
+ import { AnalyzeConfirmDialog } from '@/components/analyze-confirm-dialog'
26
+ import { Eye, Sparkles, Loader2 } from 'lucide-react'
25
27
  import type { SessionSummary } from '@/lib/parse-logs'
26
28
  import { formatCost, formatTokens, formatDuration, formatNumber } from '@/lib/format'
27
29
 
28
30
  export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
29
31
  const [projectFilter, setProjectFilter] = useState<string>('all')
32
+ const [analyzedIds, setAnalyzedIds] = useState<Set<string>>(new Set())
33
+ const [analyzingIds, setAnalyzingIds] = useState<Set<string>>(new Set())
34
+ const [hasApiKey, setHasApiKey] = useState(false)
35
+ const [bulkAnalyzing, setBulkAnalyzing] = useState(false)
36
+ const [confirmOpen, setConfirmOpen] = useState(false)
37
+ const [bulkEstimate, setBulkEstimate] = useState<{
38
+ sessionCount: number
39
+ messageCount: number
40
+ estimatedCostUSD: number
41
+ } | null>(null)
42
+ const [bulkSessionIds, setBulkSessionIds] = useState<string[]>([])
43
+
44
+ const loadAnalysisStatus = useCallback(async () => {
45
+ try {
46
+ const [analyzeRes, configRes] = await Promise.all([
47
+ fetch('/api/analyze?status=true'),
48
+ fetch('/api/config'),
49
+ ])
50
+ const analyzeData = await analyzeRes.json()
51
+ const configData = await configRes.json()
52
+ if (analyzeData.analyses) {
53
+ setAnalyzedIds(new Set(analyzeData.analyses.map((a: { sessionId: string }) => a.sessionId)))
54
+ }
55
+ setHasApiKey(configData.hasOpenAIKey || false)
56
+ } catch {
57
+ // ignore
58
+ }
59
+ }, [])
60
+
61
+ useEffect(() => {
62
+ loadAnalysisStatus()
63
+ }, [loadAnalysisStatus])
64
+
65
+ async function handleAnalyzeOne(sessionId: string) {
66
+ setAnalyzingIds((prev) => new Set(prev).add(sessionId))
67
+ try {
68
+ const res = await fetch('/api/analyze', {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({ sessionId }),
72
+ })
73
+ if (res.ok) {
74
+ setAnalyzedIds((prev) => new Set(prev).add(sessionId))
75
+ }
76
+ } catch {
77
+ // ignore
78
+ } finally {
79
+ setAnalyzingIds((prev) => {
80
+ const next = new Set(prev)
81
+ next.delete(sessionId)
82
+ return next
83
+ })
84
+ }
85
+ }
86
+
87
+ async function handleBulkClick() {
88
+ const unanalyzed = filtered
89
+ .filter((s) => !analyzedIds.has(s.sessionId))
90
+ .map((s) => s.sessionId)
91
+
92
+ if (unanalyzed.length === 0) return
93
+
94
+ setBulkSessionIds(unanalyzed)
95
+ setBulkEstimate(null)
96
+ setConfirmOpen(true)
97
+
98
+ try {
99
+ const res = await fetch('/api/analyze/estimate', {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({ sessionIds: unanalyzed }),
103
+ })
104
+ const data = await res.json()
105
+ setBulkEstimate({
106
+ sessionCount: unanalyzed.length,
107
+ messageCount: data.messageCount,
108
+ estimatedCostUSD: data.estimatedCostUSD,
109
+ })
110
+ } catch {
111
+ setConfirmOpen(false)
112
+ }
113
+ }
114
+
115
+ async function handleBulkConfirm() {
116
+ setConfirmOpen(false)
117
+ setBulkAnalyzing(true)
118
+
119
+ for (const sessionId of bulkSessionIds) {
120
+ setAnalyzingIds((prev) => new Set(prev).add(sessionId))
121
+ try {
122
+ const res = await fetch('/api/analyze', {
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: JSON.stringify({ sessionId }),
126
+ })
127
+ if (res.ok) {
128
+ setAnalyzedIds((prev) => new Set(prev).add(sessionId))
129
+ }
130
+ } catch {
131
+ // continue with next
132
+ } finally {
133
+ setAnalyzingIds((prev) => {
134
+ const next = new Set(prev)
135
+ next.delete(sessionId)
136
+ return next
137
+ })
138
+ }
139
+ }
140
+
141
+ setBulkAnalyzing(false)
142
+ }
30
143
 
31
144
  const projects = [...new Set(sessions.map((s) => s.project))].sort()
32
145
  const filtered =
@@ -42,19 +155,33 @@ export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
42
155
  <CardTitle>Sessions</CardTitle>
43
156
  <CardDescription>{filtered.length} sessions recorded</CardDescription>
44
157
  </div>
45
- <Select value={projectFilter} onValueChange={(v) => setProjectFilter(v ?? 'all')}>
46
- <SelectTrigger className="w-[200px]">
47
- <SelectValue placeholder="Filter by project" />
48
- </SelectTrigger>
49
- <SelectContent>
50
- <SelectItem value="all">All projects</SelectItem>
51
- {projects.map((p) => (
52
- <SelectItem key={p} value={p}>
53
- {p}
54
- </SelectItem>
55
- ))}
56
- </SelectContent>
57
- </Select>
158
+ <div className="flex items-center gap-2">
159
+ {hasApiKey && (
160
+ <Button
161
+ variant="outline"
162
+ size="sm"
163
+ onClick={handleBulkClick}
164
+ disabled={bulkAnalyzing || filtered.every((s) => analyzedIds.has(s.sessionId))}
165
+ >
166
+ {bulkAnalyzing && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
167
+ <Sparkles className="mr-1 h-3 w-3" />
168
+ Analyze All
169
+ </Button>
170
+ )}
171
+ <Select value={projectFilter} onValueChange={(v) => setProjectFilter(v ?? 'all')}>
172
+ <SelectTrigger className="w-[200px]">
173
+ <SelectValue placeholder="Filter by project" />
174
+ </SelectTrigger>
175
+ <SelectContent>
176
+ <SelectItem value="all">All projects</SelectItem>
177
+ {projects.map((p) => (
178
+ <SelectItem key={p} value={p}>
179
+ {p}
180
+ </SelectItem>
181
+ ))}
182
+ </SelectContent>
183
+ </Select>
184
+ </div>
58
185
  </div>
59
186
  </CardHeader>
60
187
  <CardContent>
@@ -69,6 +196,7 @@ export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
69
196
  <TableHead className="text-right">Cost</TableHead>
70
197
  <TableHead className="text-right">Duration</TableHead>
71
198
  <TableHead className="text-right">Tools</TableHead>
199
+ <TableHead className="text-center">AI</TableHead>
72
200
  <TableHead className="w-[60px]"></TableHead>
73
201
  </TableRow>
74
202
  </TableHeader>
@@ -89,6 +217,25 @@ export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
89
217
  <TableCell className="text-right">{formatCost(s.costUSD)}</TableCell>
90
218
  <TableCell className="text-right">{formatDuration(s.durationMinutes)}</TableCell>
91
219
  <TableCell className="text-right">{formatNumber(s.toolCallsTotal)}</TableCell>
220
+ <TableCell className="text-center">
221
+ {analyzedIds.has(s.sessionId) ? (
222
+ <Badge variant="outline" className="text-[10px] text-green-600 border-green-600">
223
+ Done
224
+ </Badge>
225
+ ) : analyzingIds.has(s.sessionId) ? (
226
+ <Loader2 className="h-3 w-3 animate-spin mx-auto text-muted-foreground" />
227
+ ) : hasApiKey ? (
228
+ <button
229
+ onClick={() => handleAnalyzeOne(s.sessionId)}
230
+ className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
231
+ title="Analyze with AI"
232
+ >
233
+ <Sparkles className="h-3 w-3" />
234
+ </button>
235
+ ) : (
236
+ <span className="text-xs text-muted-foreground">—</span>
237
+ )}
238
+ </TableCell>
92
239
  <TableCell>
93
240
  <Link href={`/sessions/${s.sessionId}`} className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
94
241
  <Eye className="h-4 w-4" />
@@ -99,6 +246,13 @@ export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
99
246
  </TableBody>
100
247
  </Table>
101
248
  <PaginationControls pagination={pagination} noun="sessions" />
249
+ <AnalyzeConfirmDialog
250
+ open={confirmOpen}
251
+ onOpenChange={setConfirmOpen}
252
+ onConfirm={handleBulkConfirm}
253
+ loading={bulkAnalyzing}
254
+ estimate={bulkEstimate}
255
+ />
102
256
  </CardContent>
103
257
  </Card>
104
258
  )
@@ -0,0 +1,284 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState, useEffect } from 'react'
4
+ import {
5
+ Bar,
6
+ Line,
7
+ ComposedChart,
8
+ CartesianGrid,
9
+ XAxis,
10
+ YAxis,
11
+ } from 'recharts'
12
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
13
+ import {
14
+ ChartContainer,
15
+ ChartTooltip,
16
+ type ChartConfig,
17
+ } from '@/components/ui/chart'
18
+ import type { SessionSummary } from '@/lib/parse-logs'
19
+
20
+ const chartConfig = {
21
+ releases: { label: 'New Releases', color: 'var(--chart-2)' },
22
+ behind: { label: 'Versions Behind', color: 'var(--chart-5)' },
23
+ } satisfies ChartConfig
24
+
25
+ function semverToNum(v: string): number {
26
+ const parts = v.split('.').map(Number)
27
+ return (parts[0] || 0) * 1_000_000 + (parts[1] || 0) * 1_000 + (parts[2] || 0)
28
+ }
29
+
30
+ // Only match stable semver (no prerelease/beta suffixes)
31
+ function isStableVersion(v: string): boolean {
32
+ return /^\d+\.\d+\.\d+$/.test(v)
33
+ }
34
+
35
+ export function VersionLagChart({ sessions }: { sessions: SessionSummary[] }) {
36
+ const [npmVersions, setNpmVersions] = useState<Record<string, string>>({})
37
+
38
+ useEffect(() => {
39
+ fetch('/api/cc-versions')
40
+ .then((r) => r.json())
41
+ .then((data) => setNpmVersions(data))
42
+ .catch(() => {})
43
+ }, [])
44
+
45
+ const { data, currentLag, currentUserVersion, currentLatestVersion, totalReleases } = useMemo(() => {
46
+ const empty = { data: [], currentLag: 0, currentUserVersion: '', currentLatestVersion: '', totalReleases: 0 }
47
+ if (!Object.keys(npmVersions).length || !sessions.length) return empty
48
+
49
+ // Filter to stable versions only
50
+ const stableVersions = Object.entries(npmVersions)
51
+ .filter(([v]) => isStableVersion(v))
52
+ .map(([version, date]) => ({
53
+ version,
54
+ date: date.slice(0, 10),
55
+ num: semverToNum(version),
56
+ }))
57
+ .sort((a, b) => a.num - b.num)
58
+
59
+ // Determine session date range first
60
+ const sessionDates = sessions
61
+ .filter(s => s.cliVersion && s.cliVersion !== 'unknown')
62
+ .map(s => s.startTime.slice(0, 10))
63
+ .sort()
64
+ const rangeStart = sessionDates[0] || ''
65
+ const rangeEnd = sessionDates[sessionDates.length - 1] || ''
66
+
67
+ // Count releases and build per-day map within the session date range
68
+ let total = 0
69
+ const releasesPerDay = new Map<string, number>()
70
+ for (const v of stableVersions) {
71
+ if (v.date >= rangeStart && v.date <= rangeEnd) {
72
+ total++
73
+ releasesPerDay.set(v.date, (releasesPerDay.get(v.date) || 0) + 1)
74
+ }
75
+ }
76
+
77
+ // Count how many stable versions are newer than userVersion as of date
78
+ function countBehind(userVersion: string, date: string): number {
79
+ const userNum = semverToNum(userVersion)
80
+ let count = 0
81
+ for (const v of stableVersions) {
82
+ if (v.date <= date && v.num > userNum) count++
83
+ }
84
+ return count
85
+ }
86
+
87
+ function latestAtDate(date: string): string {
88
+ let best = ''
89
+ let bestNum = 0
90
+ for (const v of stableVersions) {
91
+ if (v.date <= date && v.num > bestNum) {
92
+ best = v.version
93
+ bestNum = v.num
94
+ }
95
+ }
96
+ return best
97
+ }
98
+
99
+ // Group sessions by date
100
+ const byDate = new Map<string, string[]>()
101
+ for (const s of sessions) {
102
+ if (!s.cliVersion || s.cliVersion === 'unknown') continue
103
+ const date = s.startTime.slice(0, 10)
104
+ if (!byDate.has(date)) byDate.set(date, [])
105
+ byDate.get(date)!.push(s.cliVersion)
106
+ }
107
+
108
+ if (byDate.size === 0) return empty
109
+
110
+ // Build date range: from first session date to last
111
+ const sortedDates = Array.from(byDate.keys()).sort()
112
+ const startDate = new Date(sortedDates[0])
113
+ const endDate = new Date(sortedDates[sortedDates.length - 1])
114
+
115
+ // Fill every day in the range
116
+ const points: {
117
+ date: string
118
+ releases: number
119
+ behind: number
120
+ userVersion: string
121
+ latestVersion: string
122
+ }[] = []
123
+
124
+ let lastUserVersion = ''
125
+ const d = new Date(startDate)
126
+ while (d <= endDate) {
127
+ const dateStr = d.toLocaleDateString('en-CA') // YYYY-MM-DD
128
+ const dayVersions = byDate.get(dateStr)
129
+
130
+ if (dayVersions) {
131
+ // Pick most common version that day
132
+ const counts = new Map<string, number>()
133
+ for (const v of dayVersions) {
134
+ counts.set(v, (counts.get(v) || 0) + 1)
135
+ }
136
+ lastUserVersion = Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0][0]
137
+ }
138
+
139
+ if (lastUserVersion) {
140
+ const latest = latestAtDate(dateStr)
141
+ const behind = countBehind(lastUserVersion, dateStr)
142
+
143
+ points.push({
144
+ date: dateStr.slice(5),
145
+ releases: releasesPerDay.get(dateStr) || 0,
146
+ behind,
147
+ userVersion: lastUserVersion,
148
+ latestVersion: latest,
149
+ })
150
+ }
151
+
152
+ d.setDate(d.getDate() + 1)
153
+ }
154
+
155
+ const last = points[points.length - 1]
156
+ return {
157
+ data: points,
158
+ currentLag: last?.behind ?? 0,
159
+ currentUserVersion: last?.userVersion ?? '',
160
+ currentLatestVersion: last?.latestVersion ?? '',
161
+ totalReleases: total,
162
+ }
163
+ }, [sessions, npmVersions])
164
+
165
+ if (data.length === 0) {
166
+ return (
167
+ <Card>
168
+ <CardHeader>
169
+ <CardTitle>Version Freshness</CardTitle>
170
+ <CardDescription>Your Claude Code version vs latest release over time</CardDescription>
171
+ </CardHeader>
172
+ <CardContent>
173
+ <div className="flex min-h-[250px] items-center justify-center text-sm text-muted-foreground">
174
+ No version data available. Re-sync to capture CLI versions.
175
+ </div>
176
+ </CardContent>
177
+ </Card>
178
+ )
179
+ }
180
+
181
+ return (
182
+ <Card>
183
+ <CardHeader>
184
+ <CardTitle>Version Freshness</CardTitle>
185
+ <CardDescription className="flex flex-wrap items-center gap-x-4 gap-y-1">
186
+ {currentLag === 0 ? (
187
+ <span className="text-green-600 dark:text-green-400">
188
+ Up to date (v{currentUserVersion})
189
+ </span>
190
+ ) : (
191
+ <span>
192
+ v{currentUserVersion} —{' '}
193
+ <strong className="text-amber-600 dark:text-amber-400">
194
+ {currentLag} release{currentLag !== 1 ? 's' : ''} behind
195
+ </strong>{' '}
196
+ (latest: v{currentLatestVersion})
197
+ </span>
198
+ )}
199
+ <span className="text-xs text-muted-foreground">
200
+ {totalReleases} stable releases
201
+ </span>
202
+ </CardDescription>
203
+ </CardHeader>
204
+ <CardContent>
205
+ <ChartContainer config={chartConfig} className="min-h-[250px] w-full">
206
+ <ComposedChart data={data} accessibilityLayer>
207
+ <CartesianGrid vertical={false} />
208
+ <XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} />
209
+ <YAxis
210
+ yAxisId="left"
211
+ tickLine={false}
212
+ axisLine={false}
213
+ allowDecimals={false}
214
+ />
215
+ <ChartTooltip
216
+ content={({ active, payload }) => {
217
+ if (!active || !payload?.length) return null
218
+ const d = payload[0].payload
219
+ return (
220
+ <div className="rounded-lg border bg-background px-3 py-2 text-sm shadow-md">
221
+ <div className="font-semibold">{d.date}</div>
222
+ <div className="mt-1 space-y-0.5 text-muted-foreground">
223
+ <div className="flex justify-between gap-4">
224
+ <span className="flex items-center gap-1.5">
225
+ <span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'var(--chart-2)' }} />
226
+ New releases
227
+ </span>
228
+ <span className="font-mono font-medium text-foreground">{d.releases}</span>
229
+ </div>
230
+ <div className="flex justify-between gap-4">
231
+ <span>Your version</span>
232
+ <span className="font-mono font-medium text-foreground">v{d.userVersion}</span>
233
+ </div>
234
+ <div className="flex justify-between gap-4">
235
+ <span>Latest</span>
236
+ <span className="font-mono font-medium text-foreground">v{d.latestVersion}</span>
237
+ </div>
238
+ <div className="flex justify-between gap-4 border-t pt-0.5">
239
+ <span className="flex items-center gap-1.5">
240
+ <span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'var(--chart-5)' }} />
241
+ Behind
242
+ </span>
243
+ <span className={`font-mono font-semibold ${d.behind === 0 ? 'text-green-600' : d.behind <= 3 ? 'text-amber-500' : 'text-red-500'}`}>
244
+ {d.behind === 0 ? 'up to date' : `${d.behind} release${d.behind !== 1 ? 's' : ''}`}
245
+ </span>
246
+ </div>
247
+ </div>
248
+ </div>
249
+ )
250
+ }}
251
+ />
252
+ <Bar
253
+ yAxisId="left"
254
+ dataKey="releases"
255
+ fill="var(--color-releases)"
256
+ fillOpacity={0.3}
257
+ radius={[2, 2, 0, 0]}
258
+ />
259
+ <Line
260
+ yAxisId="left"
261
+ dataKey="behind"
262
+ type="monotone"
263
+ stroke="var(--color-behind)"
264
+ strokeWidth={2}
265
+ dot={false}
266
+ activeDot={{ r: 4, fill: 'var(--color-behind)' }}
267
+ />
268
+ </ComposedChart>
269
+ </ChartContainer>
270
+ {/* Legend */}
271
+ <div className="mt-2 flex items-center justify-end gap-4 text-xs text-muted-foreground">
272
+ <span className="flex items-center gap-1.5">
273
+ <span className="inline-block h-2.5 w-2.5 rounded-sm opacity-40" style={{ backgroundColor: 'var(--chart-2)' }} />
274
+ Daily releases
275
+ </span>
276
+ <span className="flex items-center gap-1.5">
277
+ <span className="inline-block h-0.5 w-4 rounded" style={{ backgroundColor: 'var(--chart-5)' }} />
278
+ Versions behind
279
+ </span>
280
+ </div>
281
+ </CardContent>
282
+ </Card>
283
+ )
284
+ }
package/electron/main.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { app, BrowserWindow, shell, dialog, utilityProcess } from 'electron'
2
+ import { createServer } from 'net'
2
3
  import { existsSync, readFileSync } from 'fs'
3
4
  import { createRequire } from 'module'
4
5
  import path from 'path'
@@ -17,10 +18,21 @@ const PRISMA_DIR = isPacked
17
18
 
18
19
  const USER_DATA = app.getPath('userData')
19
20
  const DB_PATH = path.join(USER_DATA, 'agentfit.db')
20
- const PORT = 13749
21
+ const PREFERRED_PORT = 13749
21
22
 
22
23
  let serverProcess = null
23
24
  let mainWindow = null
25
+ let activePort = PREFERRED_PORT
26
+
27
+ function findAvailablePort(startPort) {
28
+ return new Promise((resolve) => {
29
+ const server = createServer()
30
+ server.listen(startPort, '127.0.0.1', () => {
31
+ server.close(() => resolve(startPort))
32
+ })
33
+ server.on('error', () => resolve(findAvailablePort(startPort + 1)))
34
+ })
35
+ }
24
36
 
25
37
  function log(msg) {
26
38
  try {
@@ -59,6 +71,17 @@ async function ensureDatabase() {
59
71
  const client = createClient({ url: `file:${DB_PATH}` })
60
72
  const sql = readFileSync(schemaSQL, 'utf-8')
61
73
  await client.executeMultiple(sql)
74
+
75
+ // Add missing columns for existing databases (IF NOT EXISTS is not supported
76
+ // for ALTER TABLE in SQLite, so we catch and ignore "duplicate column" errors)
77
+ const migrations = [
78
+ 'ALTER TABLE "Session" ADD COLUMN "cliVersion" TEXT NOT NULL DEFAULT \'unknown\'',
79
+ 'ALTER TABLE "Session" ADD COLUMN "modelCountsJson" TEXT NOT NULL DEFAULT \'{}\'',
80
+ ]
81
+ for (const stmt of migrations) {
82
+ try { await client.execute(stmt) } catch { /* column already exists */ }
83
+ }
84
+
62
85
  client.close()
63
86
  log('Database ready.')
64
87
  } catch (err) {
@@ -66,45 +89,49 @@ async function ensureDatabase() {
66
89
  }
67
90
  }
68
91
 
69
- function startServer() {
70
- return new Promise((resolve, reject) => {
71
- const serverJs = path.join(SERVER_DIR, 'server.js')
92
+ async function startServer() {
93
+ const serverJs = path.join(SERVER_DIR, 'server.js')
72
94
 
73
- if (!existsSync(serverJs)) {
74
- reject(new Error(`Server not found at ${serverJs}. Run "npm run electron:prepare" first.`))
75
- return
76
- }
95
+ if (!existsSync(serverJs)) {
96
+ throw new Error(`Server not found at ${serverJs}. Run "npm run electron:prepare" first.`)
97
+ }
77
98
 
78
- log(`Starting server from ${serverJs}`)
79
-
80
- // Use utilityProcess.fork() instead of child_process.spawn() to avoid
81
- // a second dock icon on macOS. It runs as a background Node.js process.
82
- serverProcess = utilityProcess.fork(serverJs, [], {
83
- cwd: SERVER_DIR,
84
- stdio: 'pipe',
85
- serviceName: 'agentfit-server',
86
- env: {
87
- ...process.env,
88
- PORT: String(PORT),
89
- HOSTNAME: '127.0.0.1',
90
- DATABASE_URL: `file:${DB_PATH}`,
91
- NODE_ENV: 'production',
92
- },
93
- })
99
+ activePort = await findAvailablePort(PREFERRED_PORT)
100
+ if (activePort !== PREFERRED_PORT) {
101
+ log(`Port ${PREFERRED_PORT} is in use, using ${activePort} instead`)
102
+ }
94
103
 
95
- serverProcess.stdout?.on('data', (d) => log(d.toString().trim()))
96
- serverProcess.stderr?.on('data', (d) => log(d.toString().trim()))
97
- serverProcess.on('exit', (code) => {
98
- if (code !== null && code !== 0) {
99
- log(`Server exited with code ${code}`)
100
- }
101
- })
104
+ log(`Starting server from ${serverJs}`)
105
+
106
+ // Use utilityProcess.fork() instead of child_process.spawn() to avoid
107
+ // a second dock icon on macOS. It runs as a background Node.js process.
108
+ serverProcess = utilityProcess.fork(serverJs, [], {
109
+ cwd: SERVER_DIR,
110
+ stdio: 'pipe',
111
+ serviceName: 'agentfit-server',
112
+ env: {
113
+ ...process.env,
114
+ PORT: String(activePort),
115
+ HOSTNAME: '127.0.0.1',
116
+ DATABASE_URL: `file:${DB_PATH}`,
117
+ NODE_ENV: 'production',
118
+ },
119
+ })
102
120
 
103
- // Poll until ready
121
+ serverProcess.stdout?.on('data', (d) => log(d.toString().trim()))
122
+ serverProcess.stderr?.on('data', (d) => log(d.toString().trim()))
123
+ serverProcess.on('exit', (code) => {
124
+ if (code !== null && code !== 0) {
125
+ log(`Server exited with code ${code}`)
126
+ }
127
+ })
128
+
129
+ // Poll until ready
130
+ return new Promise((resolve, reject) => {
104
131
  let attempts = 0
105
132
  const check = () => {
106
133
  attempts++
107
- http.get(`http://127.0.0.1:${PORT}`, () => resolve()).on('error', () => {
134
+ http.get(`http://127.0.0.1:${activePort}`, () => resolve()).on('error', () => {
108
135
  if (attempts >= 60) reject(new Error('Server failed to start within 30s'))
109
136
  else setTimeout(check, 500)
110
137
  })
@@ -126,7 +153,7 @@ function createWindow() {
126
153
  },
127
154
  })
128
155
 
129
- mainWindow.loadURL(`http://127.0.0.1:${PORT}`)
156
+ mainWindow.loadURL(`http://127.0.0.1:${activePort}`)
130
157
 
131
158
  mainWindow.webContents.setWindowOpenHandler(({ url }) => {
132
159
  shell.openExternal(url)
@@ -37,3 +37,8 @@ export type SyncLog = Prisma.SyncLogModel
37
37
  *
38
38
  */
39
39
  export type Report = Prisma.ReportModel
40
+ /**
41
+ * Model SessionAnalysis
42
+ *
43
+ */
44
+ export type SessionAnalysis = Prisma.SessionAnalysisModel
@@ -61,3 +61,8 @@ export type SyncLog = Prisma.SyncLogModel
61
61
  *
62
62
  */
63
63
  export type Report = Prisma.ReportModel
64
+ /**
65
+ * Model SessionAnalysis
66
+ *
67
+ */
68
+ export type SessionAnalysis = Prisma.SessionAnalysisModel