@miphamai/cli 0.31.0 → 0.32.0

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,632 @@
1
+ // apps/cli/src/daemon/server.ts
2
+ import type { Server, ServerWebSocket } from 'bun'
3
+ import type { DaemonDatabase } from './database'
4
+ import type { SessionManager } from './session-manager'
5
+ import type { AgentManager } from './agent-manager'
6
+ import type { MessageBus } from './message-bus'
7
+ import type { DaemonGoal, AgentKind } from './types'
8
+ import type { GoalManager } from './goal-manager'
9
+ import type { ScheduleManager } from './schedule-manager'
10
+ import { authMiddleware, rotateToken } from './auth'
11
+ import { RateLimiter } from './rate-limiter'
12
+ import { corsMiddleware, addCorsHeaders } from './cors'
13
+ import { PACKAGE_VERSION } from '../shared/package-info'
14
+ import { WorkerPool } from './worker-pool'
15
+ import type { SessionWorker } from './session-worker'
16
+ import type { ClientMessage } from './attach-protocol'
17
+ import { QueryEngine } from '../core/engine'
18
+ import { ContextManager } from '../core/context'
19
+ import { loadConfig } from '../config/loader'
20
+ import { bootstrapProviders } from '../providers/bootstrap'
21
+ import { createToolRegistry } from '../tools'
22
+ import { PermissionSystem } from '../core/permission'
23
+ import type { ProviderRegistry } from '../providers/registry'
24
+ import type { ToolDefinition } from '../shared/types'
25
+
26
+ interface ServerConfig {
27
+ db: DaemonDatabase
28
+ sm: SessionManager
29
+ pool: WorkerPool
30
+ token: string
31
+ tokenPath: string
32
+ port: number
33
+ hostname: string
34
+ agentManager: AgentManager
35
+ messageBus: MessageBus
36
+ goalManager: GoalManager
37
+ scheduleManager: ScheduleManager
38
+ rateLimiter: RateLimiter
39
+ }
40
+
41
+ interface WsData {
42
+ sessionId: string
43
+ }
44
+
45
+ export function createServer(config: ServerConfig): Server<WsData> {
46
+ const {
47
+ db,
48
+ sm,
49
+ pool,
50
+ token,
51
+ tokenPath,
52
+ port,
53
+ hostname,
54
+ agentManager,
55
+ messageBus,
56
+ goalManager,
57
+ scheduleManager,
58
+ rateLimiter,
59
+ } = config
60
+
61
+ const wsClients = new Map<string, Set<ServerWebSocket<WsData>>>()
62
+
63
+ function broadcast(sessionId: string, data: unknown): void {
64
+ const clients = wsClients.get(sessionId)
65
+ if (!clients) return
66
+ const msg = JSON.stringify(data)
67
+ for (const ws of clients) {
68
+ try {
69
+ ws.send(msg)
70
+ } catch {
71
+ clients.delete(ws)
72
+ }
73
+ }
74
+ }
75
+
76
+ // ── Agent lifecycle → WebSocket broadcast + MessageBus registration ──
77
+ agentManager.onLifecycleEvent((event) => {
78
+ // Register / unregister in the message bus for broadcastToSession routing
79
+ if (event.type === 'created') {
80
+ messageBus.registerAgent(event.agent.sessionId, event.agent.id)
81
+ } else if (event.type === 'completed' || event.type === 'failed') {
82
+ messageBus.unregisterAgent(event.agent.id)
83
+ }
84
+
85
+ // Broadcast lifecycle events to all WebSocket clients in the agent's session
86
+ broadcast(event.agent.sessionId, {
87
+ type: 'agent_lifecycle',
88
+ event: { type: event.type, agent: event.agent },
89
+ })
90
+ })
91
+
92
+ // ── Lazy engine & worker initialization ──────────────────────────────
93
+ // Shared across all sessions — lazily initialized on first prompt.
94
+ const engineCache = new Map<string, QueryEngine>()
95
+ let sharedRegistry: ProviderRegistry | null = null
96
+ let sharedTools: Map<string, ToolDefinition> | null = null
97
+ const DEFAULT_MAX_TOKENS = 200_000
98
+
99
+ function getOrCreateEngine(
100
+ sessionId: string,
101
+ cwd: string,
102
+ provider: string,
103
+ model: string,
104
+ ): QueryEngine {
105
+ const existing = engineCache.get(sessionId)
106
+ if (existing) {
107
+ // Ensure the active provider/model matches the session metadata
108
+ existing.switchProvider(provider, model)
109
+ return existing
110
+ }
111
+
112
+ // Lazy-init shared provider registry
113
+ if (!sharedRegistry) {
114
+ const config = loadConfig(cwd)
115
+ sharedRegistry = bootstrapProviders(config.providers, provider, model)
116
+ } else {
117
+ sharedRegistry.switchProvider(provider, model)
118
+ }
119
+
120
+ // Lazy-init shared tool registry
121
+ if (!sharedTools) {
122
+ sharedTools = createToolRegistry()
123
+ }
124
+
125
+ // Create per-session context, restoring previous messages from DB
126
+ const context = new ContextManager({
127
+ maxTokens: DEFAULT_MAX_TOKENS,
128
+ compactionThreshold: 0.9,
129
+ contextWindow: DEFAULT_MAX_TOKENS,
130
+ })
131
+
132
+ const dbMessages = db.getMessages(sessionId, 10000)
133
+ for (const msg of dbMessages) {
134
+ try {
135
+ const parsed = JSON.parse(msg.content)
136
+ context.addMessage(parsed)
137
+ } catch {
138
+ context.addMessage({
139
+ role: msg.role as 'user' | 'assistant' | 'system',
140
+ content: msg.content,
141
+ })
142
+ }
143
+ }
144
+
145
+ const permission = new PermissionSystem('bypassPermissions')
146
+ const engine = new QueryEngine(sharedRegistry, context, sharedTools, permission)
147
+ engine.setSessionId(sessionId)
148
+ engineCache.set(sessionId, engine)
149
+ return engine
150
+ }
151
+
152
+ function getOrCreateWorker(
153
+ sessionId: string,
154
+ ws?: ServerWebSocket<WsData>,
155
+ ): SessionWorker | null {
156
+ try {
157
+ const session = db.getSession(sessionId)
158
+ if (!session || session.status === 'closed') return null
159
+
160
+ const engine = getOrCreateEngine(sessionId, session.cwd, session.provider, session.model)
161
+ const worker = pool.createWorker(sessionId, engine, engine.getContext(), engine.getRegistry())
162
+
163
+ // Register all connected WS clients with the worker so they
164
+ // receive streamed output for this session.
165
+ const clients = wsClients.get(sessionId)
166
+ if (clients) {
167
+ for (const client of clients) {
168
+ worker.addClient(client)
169
+ }
170
+ }
171
+
172
+ return worker
173
+ } catch (err) {
174
+ console.error(`Server: error creating worker for session ${sessionId}:`, err)
175
+ return null
176
+ }
177
+ }
178
+
179
+ const server = Bun.serve<WsData>({
180
+ port,
181
+ hostname,
182
+ async fetch(req, server) {
183
+ // ── CORS preflight ──────────────────────────────
184
+ const corsResponse = corsMiddleware(req)
185
+ if (corsResponse) return corsResponse
186
+
187
+ const url = new URL(req.url)
188
+ const path = url.pathname
189
+ const method = req.method
190
+
191
+ // ── Rate limiting (skip health endpoint) ──────────
192
+ if (path !== '/api/v1/health') {
193
+ const ip = server.requestIP(req)?.address || 'unknown'
194
+ const rl = rateLimiter.check(ip)
195
+ if (!rl.allowed) {
196
+ return addCorsHeaders(
197
+ Response.json({ ok: false, error: 'Rate limit exceeded' }, { status: 429 }),
198
+ req,
199
+ )
200
+ }
201
+ }
202
+
203
+ // ── Auth check ──────────────────────────────────
204
+ const authError = authMiddleware(req, token)
205
+ if (authError) return addCorsHeaders(authError, req)
206
+
207
+ // Helper: create JSON response with CORS headers for external origins
208
+ const json = (data: unknown, init?: ResponseInit) =>
209
+ addCorsHeaders(Response.json(data, init), req)
210
+
211
+ // WebSocket upgrade
212
+ const streamMatch = path.match(/^\/api\/v1\/sessions\/([^/]+)\/stream$/)
213
+ if (streamMatch && server.upgrade(req, { data: { sessionId: streamMatch[1]! } })) {
214
+ return // upgraded
215
+ }
216
+
217
+ // Parse body helper
218
+ async function jsonBody(): Promise<Record<string, unknown>> {
219
+ try {
220
+ return await req.json()
221
+ } catch {
222
+ return {}
223
+ }
224
+ }
225
+
226
+ // ── Health ──────────────────────────────────────
227
+ if (method === 'GET' && path === '/api/v1/health') {
228
+ const stats = db.getStats()
229
+ return json({
230
+ ok: true,
231
+ pid: process.pid,
232
+ port,
233
+ uptime: process.uptime(),
234
+ activeSessions: stats.activeSessions,
235
+ totalSessions: stats.totalSessions,
236
+ activeAgents: stats.activeAgents,
237
+ version: PACKAGE_VERSION,
238
+ })
239
+ }
240
+
241
+ // ── Stats ───────────────────────────────────────
242
+ if (method === 'GET' && path === '/api/v1/stats') {
243
+ const stats = db.getStats()
244
+ return json({ ok: true, data: stats })
245
+ }
246
+
247
+ // ── Sessions CRUD ───────────────────────────────
248
+ if (method === 'POST' && path === '/api/v1/sessions') {
249
+ const body = await jsonBody()
250
+ const session = sm.createSession(
251
+ (body.name as string) || 'unnamed',
252
+ (body.cwd as string) || process.cwd(),
253
+ (body.provider as string) || 'unknown',
254
+ (body.model as string) || 'unknown',
255
+ )
256
+ return json({ ok: true, data: { session } }, { status: 201 })
257
+ }
258
+
259
+ if (method === 'GET' && path === '/api/v1/sessions') {
260
+ const status = url.searchParams.get('status') || undefined
261
+ const sessions = sm.listSessions(status)
262
+ return json({ ok: true, data: { sessions } })
263
+ }
264
+
265
+ const sessionMatch = path.match(/^\/api\/v1\/sessions\/([^/]+)$/)
266
+ if (sessionMatch && method === 'GET') {
267
+ const session = sm.getSession(sessionMatch[1]!)
268
+ if (!session) {
269
+ return json({ ok: false, error: 'Session not found' }, { status: 404 })
270
+ }
271
+ return json({ ok: true, data: { session } })
272
+ }
273
+
274
+ if (sessionMatch && method === 'DELETE') {
275
+ const sessionId = sessionMatch[1]!
276
+ // Stop worker (interrupt, persist, mark idle) before closing session
277
+ pool.stopWorker(sessionId).catch((err: unknown) => {
278
+ console.error(`Server: error stopping worker for session ${sessionId}:`, err)
279
+ })
280
+ // Remove engine from cache
281
+ engineCache.delete(sessionId)
282
+ sm.closeSession(sessionId)
283
+ return json({ ok: true })
284
+ }
285
+
286
+ // ── Session messages ────────────────────────────
287
+ const messagesMatch = path.match(/^\/api\/v1\/sessions\/([^/]+)\/messages$/)
288
+ if (messagesMatch && method === 'GET') {
289
+ const limit = parseInt(url.searchParams.get('limit') || '100')
290
+ const messages = db.getMessages(messagesMatch[1]!, limit)
291
+ return json({ ok: true, data: { messages } })
292
+ }
293
+
294
+ // ── Prompt (Phase 2 — engine-backed) ──────────────
295
+ const promptMatch = path.match(/^\/api\/v1\/sessions\/([^/]+)\/prompt$/)
296
+ if (promptMatch && method === 'POST') {
297
+ const body = await jsonBody()
298
+ const sessionId = promptMatch[1]!
299
+ const prompt = body.prompt as string
300
+
301
+ if (!prompt || typeof prompt !== 'string') {
302
+ return json({ ok: false, error: '"prompt" field is required' }, { status: 400 })
303
+ }
304
+
305
+ const session = db.getSession(sessionId)
306
+ if (!session) {
307
+ return json({ ok: false, error: 'Session not found' }, { status: 404 })
308
+ }
309
+
310
+ if (session.status === 'closed') {
311
+ return json({ ok: false, error: 'Session is closed' }, { status: 400 })
312
+ }
313
+
314
+ // Reactivate idle sessions
315
+ if (session.status === 'idle') {
316
+ db.updateSessionStatus(sessionId, 'active')
317
+ }
318
+
319
+ // Get or create the worker (lazy engine initialization)
320
+ const worker = getOrCreateWorker(sessionId)
321
+ if (!worker) {
322
+ return json({ ok: false, error: 'Failed to initialize engine' }, { status: 500 })
323
+ }
324
+
325
+ // Fire and forget — respond immediately while processing
326
+ // streams results to connected WebSocket clients.
327
+ worker.processPrompt(prompt).catch((err: unknown) => {
328
+ console.error(`Server: error processing prompt for session ${sessionId}:`, err)
329
+ })
330
+
331
+ return json({ ok: true, data: { sessionId, status: 'processing' } }, { status: 202 })
332
+ }
333
+
334
+ // ── Agents CRUD ──────────────────────────────────
335
+ if (method === 'POST' && path === '/api/v1/agents') {
336
+ const body = await jsonBody()
337
+ const sessionId = body.sessionId as string
338
+ const agentType = body.agentType as string
339
+ const description = body.description as string
340
+
341
+ if (!sessionId || !agentType || !description) {
342
+ return json(
343
+ { ok: false, error: 'sessionId, agentType, and description are required' },
344
+ { status: 400 },
345
+ )
346
+ }
347
+
348
+ // Validate session exists
349
+ const session = db.getSession(sessionId)
350
+ if (!session) {
351
+ return json({ ok: false, error: 'Session not found' }, { status: 404 })
352
+ }
353
+
354
+ const kind = (body.kind as AgentKind) || undefined
355
+ const agent = agentManager.createAgent(sessionId, agentType, description, kind)
356
+ return json({ ok: true, data: { agent } }, { status: 201 })
357
+ }
358
+
359
+ if (method === 'GET' && path === '/api/v1/agents') {
360
+ const sessionId = url.searchParams.get('session') || undefined
361
+ const agents = agentManager.listAgents(sessionId)
362
+ return json({ ok: true, data: { agents } })
363
+ }
364
+
365
+ const agentMatch = path.match(/^\/api\/v1\/agents\/([^/]+)$/)
366
+ if (agentMatch && method === 'GET') {
367
+ const agent = agentManager.getAgent(agentMatch[1]!)
368
+ if (!agent) {
369
+ return json({ ok: false, error: 'Agent not found' }, { status: 404 })
370
+ }
371
+ return json({ ok: true, data: { agent } })
372
+ }
373
+
374
+ if (agentMatch && method === 'DELETE') {
375
+ const id = agentMatch[1]!
376
+ const stopped = agentManager.stopAgent(id)
377
+ if (!stopped) {
378
+ return json({ ok: false, error: 'Agent not found' }, { status: 404 })
379
+ }
380
+ return json({ ok: true, data: { agent: stopped } })
381
+ }
382
+
383
+ const agentMessageMatch = path.match(/^\/api\/v1\/agents\/([^/]+)\/message$/)
384
+ if (agentMessageMatch && method === 'POST') {
385
+ const body = await jsonBody()
386
+ const agentId = agentMessageMatch[1]!
387
+ const content = body.content as string
388
+
389
+ if (!content || typeof content !== 'string') {
390
+ return json({ ok: false, error: '"content" field is required' }, { status: 400 })
391
+ }
392
+
393
+ // Verify agent exists
394
+ const agent = agentManager.getAgent(agentId)
395
+ if (!agent) {
396
+ return json({ ok: false, error: 'Agent not found' }, { status: 404 })
397
+ }
398
+
399
+ messageBus.send('user', agentId, content)
400
+ return json({ ok: true }, { status: 202 })
401
+ }
402
+
403
+ // ── Goals (Phase 4 — service-backed) ────────────
404
+ if (method === 'GET' && path === '/api/v1/goals') {
405
+ const sessionId = url.searchParams.get('session')
406
+ if (!sessionId) return json({ ok: false, error: '?session= required' }, { status: 400 })
407
+ const goals = goalManager.getGoals(sessionId)
408
+ return json({ ok: true, data: { goals } })
409
+ }
410
+
411
+ if (method === 'POST' && path === '/api/v1/goals') {
412
+ const body = await jsonBody()
413
+ const sessionId = body.sessionId as string
414
+ const description = body.description as string
415
+
416
+ if (!sessionId || !description) {
417
+ return json(
418
+ { ok: false, error: 'sessionId and description are required' },
419
+ { status: 400 },
420
+ )
421
+ }
422
+
423
+ // Validate session exists
424
+ const session = db.getSession(sessionId)
425
+ if (!session) {
426
+ return json({ ok: false, error: 'Session not found' }, { status: 404 })
427
+ }
428
+
429
+ const progress = body.progress as { current: number; total: number } | undefined
430
+ const goalId = goalManager.createGoal(sessionId, description, progress)
431
+ return json({ ok: true, data: { id: goalId } }, { status: 201 })
432
+ }
433
+
434
+ const goalMatch = path.match(/^\/api\/v1\/goals\/(\d+)$/)
435
+ if (goalMatch && method === 'PATCH') {
436
+ const id = parseInt(goalMatch[1]!, 10)
437
+ const body = await jsonBody()
438
+ goalManager.updateGoal(
439
+ id,
440
+ body as Partial<Pick<DaemonGoal, 'status' | 'description' | 'progress'>>,
441
+ )
442
+ return json({ ok: true })
443
+ }
444
+
445
+ // ── Schedules (Phase 4 — service-backed) ─────────
446
+ if (method === 'GET' && path === '/api/v1/schedules') {
447
+ const sessionId = url.searchParams.get('session')
448
+ if (!sessionId) return json({ ok: false, error: '?session= required' }, { status: 400 })
449
+ const schedules = scheduleManager.getSchedules(sessionId)
450
+ return json({ ok: true, data: { schedules } })
451
+ }
452
+
453
+ if (method === 'POST' && path === '/api/v1/schedules') {
454
+ const body = await jsonBody()
455
+ const sessionId = body.sessionId as string
456
+ const cronExpr = body.cronExpr as string
457
+ const prompt = body.prompt as string
458
+
459
+ if (!sessionId || !cronExpr || !prompt) {
460
+ return json(
461
+ { ok: false, error: 'sessionId, cronExpr, and prompt are required' },
462
+ { status: 400 },
463
+ )
464
+ }
465
+
466
+ // Validate session exists
467
+ const session = db.getSession(sessionId)
468
+ if (!session) {
469
+ return json({ ok: false, error: 'Session not found' }, { status: 404 })
470
+ }
471
+
472
+ const scheduleId = scheduleManager.createSchedule(sessionId, cronExpr, prompt)
473
+ return json({ ok: true, data: { id: scheduleId } }, { status: 201 })
474
+ }
475
+
476
+ const scheduleMatch = path.match(/^\/api\/v1\/schedules\/(\d+)$/)
477
+ if (scheduleMatch && method === 'DELETE') {
478
+ const id = parseInt(scheduleMatch[1]!, 10)
479
+ scheduleManager.deleteSchedule(id)
480
+ return json({ ok: true })
481
+ }
482
+
483
+ // ── Auth: rotate token ───────────────────────────
484
+ if (method === 'POST' && path === '/api/v1/auth/rotate') {
485
+ const newToken = rotateToken(tokenPath)
486
+ return json({ ok: true, data: { token: newToken } })
487
+ }
488
+
489
+ // ── API Docs ─────────────────────────────────────
490
+ if (method === 'GET' && path === '/api/v1/docs') {
491
+ return json({
492
+ ok: true,
493
+ data: {
494
+ openapi: '3.0.3',
495
+ info: { title: 'Mipham Code Daemon API', version: PACKAGE_VERSION },
496
+ endpoints: [
497
+ {
498
+ method: 'GET',
499
+ path: '/api/v1/health',
500
+ description: 'Health check and daemon stats',
501
+ },
502
+ { method: 'GET', path: '/api/v1/stats', description: 'Database statistics' },
503
+ { method: 'POST', path: '/api/v1/sessions', description: 'Create a new session' },
504
+ { method: 'GET', path: '/api/v1/sessions', description: 'List sessions' },
505
+ { method: 'GET', path: '/api/v1/sessions/:id', description: 'Get session details' },
506
+ { method: 'DELETE', path: '/api/v1/sessions/:id', description: 'Close a session' },
507
+ {
508
+ method: 'GET',
509
+ path: '/api/v1/sessions/:id/messages',
510
+ description: 'Get session messages',
511
+ },
512
+ {
513
+ method: 'WS',
514
+ path: '/api/v1/sessions/:id/stream',
515
+ description: 'WebSocket stream for session',
516
+ },
517
+ {
518
+ method: 'POST',
519
+ path: '/api/v1/sessions/:id/prompt',
520
+ description: 'Send a prompt to a session',
521
+ },
522
+ { method: 'POST', path: '/api/v1/agents', description: 'Create a background agent' },
523
+ { method: 'GET', path: '/api/v1/agents', description: 'List agents' },
524
+ { method: 'GET', path: '/api/v1/agents/:id', description: 'Get agent details' },
525
+ { method: 'DELETE', path: '/api/v1/agents/:id', description: 'Stop an agent' },
526
+ {
527
+ method: 'POST',
528
+ path: '/api/v1/agents/:id/message',
529
+ description: 'Send message to an agent',
530
+ },
531
+ { method: 'GET', path: '/api/v1/goals', description: 'List goals for a session' },
532
+ { method: 'POST', path: '/api/v1/goals', description: 'Create a goal' },
533
+ { method: 'PATCH', path: '/api/v1/goals/:id', description: 'Update a goal' },
534
+ {
535
+ method: 'GET',
536
+ path: '/api/v1/schedules',
537
+ description: 'List schedules for a session',
538
+ },
539
+ { method: 'POST', path: '/api/v1/schedules', description: 'Create a schedule' },
540
+ { method: 'DELETE', path: '/api/v1/schedules/:id', description: 'Delete a schedule' },
541
+ {
542
+ method: 'POST',
543
+ path: '/api/v1/auth/rotate',
544
+ description: 'Rotate API token (requires auth)',
545
+ },
546
+ {
547
+ method: 'GET',
548
+ path: '/api/v1/docs',
549
+ description: 'API documentation (this endpoint)',
550
+ },
551
+ ],
552
+ },
553
+ })
554
+ }
555
+
556
+ return json({ ok: false, error: 'Not found' }, { status: 404 })
557
+ },
558
+
559
+ websocket: {
560
+ open(ws) {
561
+ const sessionId = ws.data.sessionId
562
+ if (!wsClients.has(sessionId)) {
563
+ wsClients.set(sessionId, new Set())
564
+ }
565
+ wsClients.get(sessionId)!.add(ws)
566
+
567
+ // Register with worker if already active so the new client
568
+ // receives future streamed output for this session.
569
+ const worker = pool.getWorker(sessionId)
570
+ if (worker) {
571
+ worker.addClient(ws)
572
+ }
573
+ },
574
+ close(ws) {
575
+ const sessionId = ws.data.sessionId
576
+ const clients = wsClients.get(sessionId)
577
+ if (clients) {
578
+ clients.delete(ws)
579
+ if (clients.size === 0) wsClients.delete(sessionId)
580
+ }
581
+
582
+ // Remove from worker's broadcast set
583
+ const worker = pool.getWorker(sessionId)
584
+ if (worker) {
585
+ worker.removeClient(ws)
586
+ }
587
+ },
588
+ message(ws, msg) {
589
+ const sessionId = ws.data.sessionId
590
+
591
+ let parsed: ClientMessage
592
+ try {
593
+ const raw = typeof msg === 'string' ? msg : new TextDecoder().decode(msg as Uint8Array)
594
+ parsed = JSON.parse(raw) as ClientMessage
595
+ } catch {
596
+ // Ignore malformed messages
597
+ return
598
+ }
599
+
600
+ if (!parsed || typeof parsed.type !== 'string') return
601
+
602
+ switch (parsed.type) {
603
+ case 'prompt': {
604
+ if (!parsed.prompt || typeof parsed.prompt !== 'string') return
605
+
606
+ const worker = getOrCreateWorker(sessionId, ws)
607
+ if (!worker) return
608
+
609
+ worker.processPrompt(parsed.prompt).catch((err: unknown) => {
610
+ console.error(`Server: error processing WS prompt for session ${sessionId}:`, err)
611
+ })
612
+ break
613
+ }
614
+
615
+ case 'interrupt': {
616
+ const worker = pool.getWorker(sessionId)
617
+ if (worker) {
618
+ worker.interrupt()
619
+ }
620
+ break
621
+ }
622
+
623
+ default:
624
+ // Unknown message type — silently ignored
625
+ break
626
+ }
627
+ },
628
+ },
629
+ })
630
+
631
+ return server
632
+ }
@@ -0,0 +1,45 @@
1
+ import type { DaemonDatabase } from './database'
2
+ import type { DaemonSession, SessionStatus } from './types'
3
+
4
+ type SessionCloseCallback = (sessionId: string) => void
5
+
6
+ export class SessionManager {
7
+ private db: DaemonDatabase
8
+ private closeCallbacks: SessionCloseCallback[] = []
9
+
10
+ constructor(db: DaemonDatabase) {
11
+ this.db = db
12
+ }
13
+
14
+ createSession(name: string, cwd: string, provider: string, model: string): DaemonSession {
15
+ return this.db.createSession({ name, cwd, provider, model })
16
+ }
17
+
18
+ getSession(id: string): DaemonSession | null {
19
+ return this.db.getSession(id)
20
+ }
21
+
22
+ listSessions(status?: string): DaemonSession[] {
23
+ return this.db.listSessions(status)
24
+ }
25
+
26
+ closeSession(id: string): void {
27
+ this.db.closeSession(id)
28
+ for (const cb of this.closeCallbacks) {
29
+ try {
30
+ cb(id)
31
+ } catch {
32
+ /* callback errors should not propagate */
33
+ }
34
+ }
35
+ }
36
+
37
+ getActiveCount(): number {
38
+ const stats = this.db.getStats()
39
+ return stats.activeSessions
40
+ }
41
+
42
+ onSessionClosed(callback: SessionCloseCallback): void {
43
+ this.closeCallbacks.push(callback)
44
+ }
45
+ }