agentfit 0.1.0 → 0.1.2

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 (74) hide show
  1. package/.github/workflows/release.yml +111 -0
  2. package/README.md +41 -38
  3. package/app/(dashboard)/daily/page.tsx +1 -1
  4. package/app/(dashboard)/data-management/page.tsx +180 -0
  5. package/app/(dashboard)/flow/page.tsx +17 -0
  6. package/app/(dashboard)/layout.tsx +2 -0
  7. package/app/(dashboard)/page.tsx +24 -5
  8. package/app/(dashboard)/reports/[id]/page.tsx +72 -0
  9. package/app/(dashboard)/reports/page.tsx +132 -0
  10. package/app/(dashboard)/sessions/[id]/page.tsx +167 -0
  11. package/app/api/backup/route.ts +215 -0
  12. package/app/api/check/route.ts +11 -1
  13. package/app/api/command-insights/route.ts +13 -0
  14. package/app/api/commands/route.ts +55 -1
  15. package/app/api/images-analysis/route.ts +3 -4
  16. package/app/api/reports/[id]/route.ts +23 -0
  17. package/app/api/reports/route.ts +50 -0
  18. package/app/api/reset/route.ts +21 -0
  19. package/app/api/session/route.ts +40 -0
  20. package/app/api/usage/route.ts +26 -1
  21. package/app/layout.tsx +1 -1
  22. package/bin/agentfit.mjs +2 -2
  23. package/components/agent-coach.tsx +256 -129
  24. package/components/app-sidebar.tsx +45 -10
  25. package/components/backup-section.tsx +236 -0
  26. package/components/daily-chart.tsx +447 -83
  27. package/components/dashboard-shell.tsx +29 -31
  28. package/components/data-provider.tsx +88 -8
  29. package/components/fitness-score.tsx +95 -54
  30. package/components/overview-cards.tsx +148 -41
  31. package/components/report-view.tsx +307 -0
  32. package/components/screenshots-analysis.tsx +51 -46
  33. package/components/session-chatlog.tsx +124 -0
  34. package/components/session-timeline.tsx +184 -0
  35. package/components/session-workflow.tsx +183 -0
  36. package/components/sessions-table.tsx +9 -1
  37. package/components/tool-flow-graph.tsx +144 -0
  38. package/components/ui/carousel.tsx +242 -0
  39. package/components/ui/sidebar.tsx +1 -1
  40. package/components/ui/sonner.tsx +51 -0
  41. package/electron/entitlements.mac.plist +16 -0
  42. package/electron/init-db.mjs +37 -0
  43. package/electron/main.mjs +203 -0
  44. package/generated/prisma/browser.ts +5 -0
  45. package/generated/prisma/client.ts +5 -0
  46. package/generated/prisma/internal/class.ts +14 -4
  47. package/generated/prisma/internal/prismaNamespace.ts +97 -2
  48. package/generated/prisma/internal/prismaNamespaceBrowser.ts +21 -1
  49. package/generated/prisma/models/Report.ts +1219 -0
  50. package/generated/prisma/models/Session.ts +221 -1
  51. package/generated/prisma/models.ts +1 -0
  52. package/lib/coach.ts +571 -211
  53. package/lib/command-insights.ts +231 -0
  54. package/lib/db.ts +2 -2
  55. package/lib/parse-codex.ts +6 -0
  56. package/lib/parse-logs.ts +80 -1
  57. package/lib/queries-codex.ts +24 -0
  58. package/lib/queries.ts +45 -0
  59. package/lib/report.ts +156 -0
  60. package/lib/session-detail.ts +382 -0
  61. package/lib/sync.ts +87 -0
  62. package/lib/tool-flow.ts +71 -0
  63. package/next.config.mjs +6 -1
  64. package/package.json +17 -2
  65. package/plugins/cost-heatmap/component.tsx +72 -50
  66. package/prisma/migrations/20260401144555_add_system_prompt_edits/migration.sql +80 -0
  67. package/prisma/schema.prisma +18 -0
  68. package/prisma/schema.sql +81 -0
  69. package/.claude/settings.local.json +0 -26
  70. package/CONTRIBUTING.md +0 -209
  71. package/prisma/migrations/20260328152517_init/migration.sql +0 -41
  72. package/prisma/migrations/20260328153801_add_image_model/migration.sql +0 -18
  73. package/prisma.config.ts +0 -14
  74. package/setup.sh +0 -73
@@ -0,0 +1,144 @@
1
+ 'use client'
2
+
3
+ import { useMemo } from 'react'
4
+ import {
5
+ ReactFlow,
6
+ Background,
7
+ Controls,
8
+ type Node,
9
+ type Edge,
10
+ Position,
11
+ MarkerType,
12
+ } from '@xyflow/react'
13
+ import '@xyflow/react/dist/style.css'
14
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
15
+ import type { UsageData } from '@/lib/parse-logs'
16
+ import { computeToolFlow } from '@/lib/tool-flow'
17
+ import { formatNumber } from '@/lib/format'
18
+
19
+ const TOOL_COLORS: Record<string, string> = {
20
+ Read: '#6b9bd2',
21
+ Edit: '#7bc8a4',
22
+ Write: '#7bc8a4',
23
+ Bash: '#d4a574',
24
+ Grep: '#6b9bd2',
25
+ Glob: '#6b9bd2',
26
+ Agent: '#b8a9d4',
27
+ Skill: '#b8a9d4',
28
+ WebSearch: '#d4a5a5',
29
+ WebFetch: '#d4a5a5',
30
+ }
31
+
32
+ function getToolColor(tool: string): string {
33
+ return TOOL_COLORS[tool] || '#94a3b8'
34
+ }
35
+
36
+ export function ToolFlowGraph({ data }: { data: UsageData }) {
37
+ const flow = useMemo(() => computeToolFlow(data), [data])
38
+
39
+ const { nodes, edges } = useMemo(() => {
40
+ if (flow.nodes.length === 0) return { nodes: [], edges: [] }
41
+
42
+ const maxCount = Math.max(...flow.nodes.map(n => n.count))
43
+ const centerX = 400
44
+ const centerY = 300
45
+ const radius = 220
46
+
47
+ const rfNodes: Node[] = flow.nodes.map((n, i) => {
48
+ const angle = (i / flow.nodes.length) * 2 * Math.PI - Math.PI / 2
49
+ const size = 40 + (n.count / maxCount) * 60
50
+
51
+ return {
52
+ id: n.id,
53
+ position: {
54
+ x: centerX + radius * Math.cos(angle) - size / 2,
55
+ y: centerY + radius * Math.sin(angle) - size / 2,
56
+ },
57
+ data: {
58
+ label: (
59
+ <div className="flex flex-col items-center gap-0.5">
60
+ <span className="text-xs font-semibold">{n.id}</span>
61
+ <span className="text-[10px] text-muted-foreground">{formatNumber(n.count)}</span>
62
+ </div>
63
+ ),
64
+ },
65
+ style: {
66
+ width: size,
67
+ height: size,
68
+ borderRadius: '50%',
69
+ display: 'flex',
70
+ alignItems: 'center',
71
+ justifyContent: 'center',
72
+ backgroundColor: getToolColor(n.id),
73
+ border: '2px solid rgba(255,255,255,0.2)',
74
+ color: '#fff',
75
+ fontSize: 11,
76
+ },
77
+ sourcePosition: Position.Right,
78
+ targetPosition: Position.Left,
79
+ }
80
+ })
81
+
82
+ const maxEdgeCount = Math.max(...flow.edges.map(e => e.count), 1)
83
+
84
+ const rfEdges: Edge[] = flow.edges.map((e, i) => ({
85
+ id: `e-${i}`,
86
+ source: e.source,
87
+ target: e.target,
88
+ animated: e.count > maxEdgeCount * 0.5,
89
+ style: {
90
+ strokeWidth: 1 + (e.count / maxEdgeCount) * 4,
91
+ opacity: 0.3 + (e.count / maxEdgeCount) * 0.5,
92
+ stroke: getToolColor(e.source),
93
+ },
94
+ markerEnd: {
95
+ type: MarkerType.ArrowClosed,
96
+ width: 12,
97
+ height: 12,
98
+ },
99
+ label: e.count > maxEdgeCount * 0.2 ? String(e.count) : undefined,
100
+ labelStyle: { fontSize: 10, fill: '#94a3b8' },
101
+ }))
102
+
103
+ return { nodes: rfNodes, edges: rfEdges }
104
+ }, [flow])
105
+
106
+ if (flow.nodes.length === 0) {
107
+ return (
108
+ <Card>
109
+ <CardHeader>
110
+ <CardTitle>Tool Flow</CardTitle>
111
+ <CardDescription>Not enough tool data for visualization</CardDescription>
112
+ </CardHeader>
113
+ </Card>
114
+ )
115
+ }
116
+
117
+ return (
118
+ <Card>
119
+ <CardHeader>
120
+ <CardTitle>Tool Flow Graph</CardTitle>
121
+ <CardDescription>
122
+ How tools connect — node size = usage frequency, edge thickness = co-occurrence strength.
123
+ Top {flow.nodes.length} tools with {flow.edges.length} connections.
124
+ </CardDescription>
125
+ </CardHeader>
126
+ <CardContent>
127
+ <div style={{ height: 600 }} className="rounded-lg border bg-background">
128
+ <ReactFlow
129
+ nodes={nodes}
130
+ edges={edges}
131
+ fitView
132
+ nodesDraggable
133
+ nodesConnectable={false}
134
+ minZoom={0.3}
135
+ maxZoom={2}
136
+ >
137
+ <Background gap={20} size={1} />
138
+ <Controls showInteractive={false} />
139
+ </ReactFlow>
140
+ </div>
141
+ </CardContent>
142
+ </Card>
143
+ )
144
+ }
@@ -0,0 +1,242 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import useEmblaCarousel, {
5
+ type UseEmblaCarouselType,
6
+ } from "embla-carousel-react"
7
+
8
+ import { cn } from "@/lib/utils"
9
+ import { Button } from "@/components/ui/button"
10
+ import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"
11
+
12
+ type CarouselApi = UseEmblaCarouselType[1]
13
+ type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
14
+ type CarouselOptions = UseCarouselParameters[0]
15
+ type CarouselPlugin = UseCarouselParameters[1]
16
+
17
+ type CarouselProps = {
18
+ opts?: CarouselOptions
19
+ plugins?: CarouselPlugin
20
+ orientation?: "horizontal" | "vertical"
21
+ setApi?: (api: CarouselApi) => void
22
+ }
23
+
24
+ type CarouselContextProps = {
25
+ carouselRef: ReturnType<typeof useEmblaCarousel>[0]
26
+ api: ReturnType<typeof useEmblaCarousel>[1]
27
+ scrollPrev: () => void
28
+ scrollNext: () => void
29
+ canScrollPrev: boolean
30
+ canScrollNext: boolean
31
+ } & CarouselProps
32
+
33
+ const CarouselContext = React.createContext<CarouselContextProps | null>(null)
34
+
35
+ function useCarousel() {
36
+ const context = React.useContext(CarouselContext)
37
+
38
+ if (!context) {
39
+ throw new Error("useCarousel must be used within a <Carousel />")
40
+ }
41
+
42
+ return context
43
+ }
44
+
45
+ function Carousel({
46
+ orientation = "horizontal",
47
+ opts,
48
+ setApi,
49
+ plugins,
50
+ className,
51
+ children,
52
+ ...props
53
+ }: React.ComponentProps<"div"> & CarouselProps) {
54
+ const [carouselRef, api] = useEmblaCarousel(
55
+ {
56
+ ...opts,
57
+ axis: orientation === "horizontal" ? "x" : "y",
58
+ },
59
+ plugins
60
+ )
61
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
62
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
63
+
64
+ const onSelect = React.useCallback((api: CarouselApi) => {
65
+ if (!api) return
66
+ setCanScrollPrev(api.canScrollPrev())
67
+ setCanScrollNext(api.canScrollNext())
68
+ }, [])
69
+
70
+ const scrollPrev = React.useCallback(() => {
71
+ api?.scrollPrev()
72
+ }, [api])
73
+
74
+ const scrollNext = React.useCallback(() => {
75
+ api?.scrollNext()
76
+ }, [api])
77
+
78
+ const handleKeyDown = React.useCallback(
79
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
80
+ if (event.key === "ArrowLeft") {
81
+ event.preventDefault()
82
+ scrollPrev()
83
+ } else if (event.key === "ArrowRight") {
84
+ event.preventDefault()
85
+ scrollNext()
86
+ }
87
+ },
88
+ [scrollPrev, scrollNext]
89
+ )
90
+
91
+ React.useEffect(() => {
92
+ if (!api || !setApi) return
93
+ setApi(api)
94
+ }, [api, setApi])
95
+
96
+ React.useEffect(() => {
97
+ if (!api) return
98
+ onSelect(api)
99
+ api.on("reInit", onSelect)
100
+ api.on("select", onSelect)
101
+
102
+ return () => {
103
+ api?.off("select", onSelect)
104
+ }
105
+ }, [api, onSelect])
106
+
107
+ return (
108
+ <CarouselContext.Provider
109
+ value={{
110
+ carouselRef,
111
+ api: api,
112
+ opts,
113
+ orientation:
114
+ orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
115
+ scrollPrev,
116
+ scrollNext,
117
+ canScrollPrev,
118
+ canScrollNext,
119
+ }}
120
+ >
121
+ <div
122
+ onKeyDownCapture={handleKeyDown}
123
+ className={cn("relative", className)}
124
+ role="region"
125
+ aria-roledescription="carousel"
126
+ data-slot="carousel"
127
+ {...props}
128
+ >
129
+ {children}
130
+ </div>
131
+ </CarouselContext.Provider>
132
+ )
133
+ }
134
+
135
+ function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
136
+ const { carouselRef, orientation } = useCarousel()
137
+
138
+ return (
139
+ <div
140
+ ref={carouselRef}
141
+ className="overflow-hidden"
142
+ data-slot="carousel-content"
143
+ >
144
+ <div
145
+ className={cn(
146
+ "flex",
147
+ orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
148
+ className
149
+ )}
150
+ {...props}
151
+ />
152
+ </div>
153
+ )
154
+ }
155
+
156
+ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
157
+ const { orientation } = useCarousel()
158
+
159
+ return (
160
+ <div
161
+ role="group"
162
+ aria-roledescription="slide"
163
+ data-slot="carousel-item"
164
+ className={cn(
165
+ "min-w-0 shrink-0 grow-0 basis-full",
166
+ orientation === "horizontal" ? "pl-4" : "pt-4",
167
+ className
168
+ )}
169
+ {...props}
170
+ />
171
+ )
172
+ }
173
+
174
+ function CarouselPrevious({
175
+ className,
176
+ variant = "outline",
177
+ size = "icon-sm",
178
+ ...props
179
+ }: React.ComponentProps<typeof Button>) {
180
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
181
+
182
+ return (
183
+ <Button
184
+ data-slot="carousel-previous"
185
+ variant={variant}
186
+ size={size}
187
+ className={cn(
188
+ "absolute touch-manipulation rounded-full",
189
+ orientation === "horizontal"
190
+ ? "top-1/2 -left-12 -translate-y-1/2"
191
+ : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
192
+ className
193
+ )}
194
+ disabled={!canScrollPrev}
195
+ onClick={scrollPrev}
196
+ {...props}
197
+ >
198
+ <IconChevronLeft />
199
+ <span className="sr-only">Previous slide</span>
200
+ </Button>
201
+ )
202
+ }
203
+
204
+ function CarouselNext({
205
+ className,
206
+ variant = "outline",
207
+ size = "icon-sm",
208
+ ...props
209
+ }: React.ComponentProps<typeof Button>) {
210
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
211
+
212
+ return (
213
+ <Button
214
+ data-slot="carousel-next"
215
+ variant={variant}
216
+ size={size}
217
+ className={cn(
218
+ "absolute touch-manipulation rounded-full",
219
+ orientation === "horizontal"
220
+ ? "top-1/2 -right-12 -translate-y-1/2"
221
+ : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
222
+ className
223
+ )}
224
+ disabled={!canScrollNext}
225
+ onClick={scrollNext}
226
+ {...props}
227
+ >
228
+ <IconChevronRight />
229
+ <span className="sr-only">Next slide</span>
230
+ </Button>
231
+ )
232
+ }
233
+
234
+ export {
235
+ type CarouselApi,
236
+ Carousel,
237
+ CarouselContent,
238
+ CarouselItem,
239
+ CarouselPrevious,
240
+ CarouselNext,
241
+ useCarousel,
242
+ }
@@ -27,7 +27,7 @@ import { IconLayoutSidebar } from "@tabler/icons-react"
27
27
 
28
28
  const SIDEBAR_COOKIE_NAME = "sidebar_state"
29
29
  const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
30
- const SIDEBAR_WIDTH = "16rem"
30
+ const SIDEBAR_WIDTH = "14rem"
31
31
  const SIDEBAR_WIDTH_MOBILE = "18rem"
32
32
  const SIDEBAR_WIDTH_ICON = "3rem"
33
33
  const SIDEBAR_KEYBOARD_SHORTCUT = "b"
@@ -0,0 +1,51 @@
1
+ "use client"
2
+
3
+ import { useTheme } from "next-themes"
4
+ import { Toaster as Sonner, type ToasterProps } from "sonner"
5
+ import { IconCircleCheck, IconInfoCircle, IconAlertTriangle, IconAlertOctagon, IconLoader } from "@tabler/icons-react"
6
+
7
+ const Toaster = ({ ...props }: ToasterProps) => {
8
+ const { theme = "system" } = useTheme()
9
+
10
+ return (
11
+ <Sonner
12
+ theme={theme as ToasterProps["theme"]}
13
+ className="toaster group"
14
+ position="top-center"
15
+ richColors
16
+ icons={{
17
+ success: (
18
+ <IconCircleCheck className="size-4" />
19
+ ),
20
+ info: (
21
+ <IconInfoCircle className="size-4" />
22
+ ),
23
+ warning: (
24
+ <IconAlertTriangle className="size-4" />
25
+ ),
26
+ error: (
27
+ <IconAlertOctagon className="size-4" />
28
+ ),
29
+ loading: (
30
+ <IconLoader className="size-4 animate-spin" />
31
+ ),
32
+ }}
33
+ style={
34
+ {
35
+ "--normal-bg": "var(--popover)",
36
+ "--normal-text": "var(--popover-foreground)",
37
+ "--normal-border": "var(--border)",
38
+ "--border-radius": "var(--radius)",
39
+ } as React.CSSProperties
40
+ }
41
+ toastOptions={{
42
+ classNames: {
43
+ toast: "cn-toast",
44
+ },
45
+ }}
46
+ {...props}
47
+ />
48
+ )
49
+ }
50
+
51
+ export { Toaster }
@@ -0,0 +1,16 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>com.apple.security.cs.allow-jit</key>
6
+ <true/>
7
+ <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
8
+ <true/>
9
+ <key>com.apple.security.cs.allow-dyld-environment-variables</key>
10
+ <true/>
11
+ <key>com.apple.security.network.client</key>
12
+ <true/>
13
+ <key>com.apple.security.files.user-selected.read-write</key>
14
+ <true/>
15
+ </dict>
16
+ </plist>
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Database initialization script.
3
+ * Spawned by Electron main process with ELECTRON_RUN_AS_NODE=1.
4
+ * Reads schema.sql and executes it against the SQLite database.
5
+ * Uses @libsql/client which is already bundled — works on macOS, Linux, and Windows.
6
+ *
7
+ * Usage: node init-db.mjs <db-path> <schema-sql-path>
8
+ */
9
+
10
+ import { readFileSync } from 'fs'
11
+ import { createRequire } from 'module'
12
+ import path from 'path'
13
+
14
+ const [dbPath, schemaPath] = process.argv.slice(2)
15
+
16
+ // In packaged app, @libsql/client lives inside electron/server/node_modules
17
+ const serverDir = path.join(path.dirname(new URL(import.meta.url).pathname), 'server')
18
+ const require = createRequire(path.join(serverDir, 'package.json'))
19
+ const { createClient } = require('@libsql/client')
20
+
21
+ if (!dbPath || !schemaPath) {
22
+ console.error('Usage: node init-db.mjs <db-path> <schema-sql-path>')
23
+ process.exit(1)
24
+ }
25
+
26
+ const client = createClient({ url: `file:${dbPath}` })
27
+ const sql = readFileSync(schemaPath, 'utf-8')
28
+
29
+ try {
30
+ await client.executeMultiple(sql)
31
+ console.log('Database ready.')
32
+ } catch (err) {
33
+ console.error(`Database init error: ${err.message}`)
34
+ process.exit(1)
35
+ } finally {
36
+ client.close()
37
+ }
@@ -0,0 +1,203 @@
1
+ import { app, BrowserWindow, shell, dialog } from 'electron'
2
+ import { execSync, spawn } from 'child_process'
3
+ import { existsSync } from 'fs'
4
+ import path from 'path'
5
+ import http from 'http'
6
+
7
+ const isPacked = app.isPackaged
8
+
9
+ // In packed app, asarUnpacked content is at app.asar.unpacked
10
+ const SERVER_DIR = isPacked
11
+ ? path.join(process.resourcesPath, 'app.asar.unpacked', 'electron', 'server')
12
+ : path.join(import.meta.dirname, 'server')
13
+
14
+ const PRISMA_DIR = isPacked
15
+ ? path.join(process.resourcesPath, 'prisma')
16
+ : path.join(import.meta.dirname, '..', 'prisma')
17
+
18
+ const USER_DATA = app.getPath('userData')
19
+ const DB_PATH = path.join(USER_DATA, 'agentfit.db')
20
+ const PORT = 13749
21
+
22
+ let serverProcess = null
23
+ let mainWindow = null
24
+
25
+ function log(msg) {
26
+ try {
27
+ console.log(`[AgentFit] ${msg}`)
28
+ } catch {
29
+ // Ignore EPIPE — no terminal in packaged app
30
+ }
31
+ }
32
+
33
+ // Prevent uncaught EPIPE from crashing the app
34
+ process.on('uncaughtException', (err) => {
35
+ if (err.code === 'EPIPE') return
36
+ throw err
37
+ })
38
+
39
+ function ensureDatabase() {
40
+ const schemaSQL = path.join(PRISMA_DIR, 'schema.sql')
41
+ const initScript = isPacked
42
+ ? path.join(process.resourcesPath, 'app.asar.unpacked', 'electron', 'init-db.mjs')
43
+ : path.join(import.meta.dirname, 'init-db.mjs')
44
+
45
+ if (!existsSync(schemaSQL)) {
46
+ throw new Error(`schema.sql not found at ${schemaSQL}. Run "npm run prisma:schema-sql" first.`)
47
+ }
48
+
49
+ log(existsSync(DB_PATH) ? 'Checking database schema...' : 'Creating database...')
50
+
51
+ // Run init-db.mjs using Electron's bundled Node.js runtime.
52
+ // Uses @libsql/client (already bundled) — works on macOS, Linux, and Windows.
53
+ // schema.sql uses IF NOT EXISTS — safe to run on existing DBs.
54
+ try {
55
+ execSync(
56
+ `"${process.execPath}" "${initScript}" "${DB_PATH}" "${schemaSQL}"`,
57
+ {
58
+ stdio: 'pipe',
59
+ timeout: 15000,
60
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
61
+ }
62
+ )
63
+ log('Database ready.')
64
+ } catch (err) {
65
+ const stderr = err.stderr?.toString() || err.message
66
+ log(`Database setup warning: ${stderr}`)
67
+ }
68
+ }
69
+
70
+ function startServer() {
71
+ return new Promise((resolve, reject) => {
72
+ const serverJs = path.join(SERVER_DIR, 'server.js')
73
+
74
+ if (!existsSync(serverJs)) {
75
+ reject(new Error(`Server not found at ${serverJs}. Run "npm run electron:prepare" first.`))
76
+ return
77
+ }
78
+
79
+ log(`Starting server from ${serverJs}`)
80
+
81
+ // In packaged Electron, process.execPath is the Electron binary.
82
+ // We need to set ELECTRON_RUN_AS_NODE=1 so it acts as plain Node.js.
83
+ serverProcess = spawn(process.execPath, [serverJs], {
84
+ cwd: SERVER_DIR,
85
+ stdio: ['ignore', 'pipe', 'pipe'],
86
+ env: {
87
+ ...process.env,
88
+ ELECTRON_RUN_AS_NODE: '1',
89
+ PORT: String(PORT),
90
+ HOSTNAME: '127.0.0.1',
91
+ DATABASE_URL: `file:${DB_PATH}`,
92
+ NODE_ENV: 'production',
93
+ },
94
+ })
95
+
96
+ serverProcess.stdout?.on('data', (d) => log(d.toString().trim()))
97
+ serverProcess.stderr?.on('data', (d) => log(d.toString().trim()))
98
+ serverProcess.on('error', (err) => {
99
+ log(`Server process error: ${err.message}`)
100
+ reject(err)
101
+ })
102
+ serverProcess.on('exit', (code) => {
103
+ if (code !== null && code !== 0) {
104
+ log(`Server exited with code ${code}`)
105
+ }
106
+ })
107
+
108
+ // Poll until ready
109
+ let attempts = 0
110
+ const check = () => {
111
+ attempts++
112
+ http.get(`http://127.0.0.1:${PORT}`, () => resolve()).on('error', () => {
113
+ if (attempts >= 60) reject(new Error('Server failed to start within 30s'))
114
+ else setTimeout(check, 500)
115
+ })
116
+ }
117
+ setTimeout(check, 1000)
118
+ })
119
+ }
120
+
121
+ function createWindow() {
122
+ mainWindow = new BrowserWindow({
123
+ width: 1400,
124
+ height: 900,
125
+ minWidth: 900,
126
+ minHeight: 600,
127
+ title: 'AgentFit',
128
+ webPreferences: {
129
+ nodeIntegration: false,
130
+ contextIsolation: true,
131
+ },
132
+ })
133
+
134
+ mainWindow.loadURL(`http://127.0.0.1:${PORT}`)
135
+
136
+ mainWindow.webContents.setWindowOpenHandler(({ url }) => {
137
+ shell.openExternal(url)
138
+ return { action: 'deny' }
139
+ })
140
+
141
+ mainWindow.on('closed', () => {
142
+ mainWindow = null
143
+ })
144
+ }
145
+
146
+ function showSplash() {
147
+ const splash = new BrowserWindow({
148
+ width: 400,
149
+ height: 300,
150
+ frame: false,
151
+ transparent: true,
152
+ resizable: false,
153
+ alwaysOnTop: true,
154
+ })
155
+
156
+ splash.loadURL(`data:text/html,
157
+ <html>
158
+ <body style="display:flex;align-items:center;justify-content:center;height:100vh;margin:0;font-family:system-ui;background:rgba(0,0,0,0.85);color:white;border-radius:16px;-webkit-app-region:drag;">
159
+ <div style="text-align:center">
160
+ <h1 style="font-size:28px;margin-bottom:8px">AgentFit</h1>
161
+ <p style="opacity:0.7;font-size:14px">Starting server...</p>
162
+ </div>
163
+ </body>
164
+ </html>
165
+ `)
166
+
167
+ return splash
168
+ }
169
+
170
+ app.whenReady().then(async () => {
171
+ const splash = showSplash()
172
+
173
+ try {
174
+ ensureDatabase()
175
+ await startServer()
176
+ splash.close()
177
+ createWindow()
178
+ } catch (err) {
179
+ splash.close()
180
+ log(`Startup error: ${err.message}`)
181
+ dialog.showErrorBox('AgentFit Startup Error', `Failed to start: ${err.message}`)
182
+ app.quit()
183
+ }
184
+ })
185
+
186
+ app.on('window-all-closed', () => {
187
+ if (serverProcess) {
188
+ serverProcess.kill('SIGTERM')
189
+ serverProcess = null
190
+ }
191
+ app.quit()
192
+ })
193
+
194
+ app.on('before-quit', () => {
195
+ if (serverProcess) {
196
+ serverProcess.kill('SIGTERM')
197
+ serverProcess = null
198
+ }
199
+ })
200
+
201
+ app.on('activate', () => {
202
+ if (mainWindow === null) createWindow()
203
+ })