@miphamai/cli 0.31.1 → 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.
- package/bin/daemon.ts +40 -0
- package/bin/mipham.ts +796 -1
- package/package.json +1 -1
- package/src/agent/background-registry.ts +80 -1
- package/src/daemon/agent-manager.ts +110 -0
- package/src/daemon/attach-protocol.ts +74 -0
- package/src/daemon/auth.ts +104 -0
- package/src/daemon/cors.ts +68 -0
- package/src/daemon/database.ts +449 -0
- package/src/daemon/goal-manager.ts +53 -0
- package/src/daemon/index.ts +307 -0
- package/src/daemon/message-bus.ts +84 -0
- package/src/daemon/rate-limiter.ts +70 -0
- package/src/daemon/remote-engine.ts +434 -0
- package/src/daemon/schedule-manager.ts +159 -0
- package/src/daemon/server.ts +632 -0
- package/src/daemon/session-manager.ts +45 -0
- package/src/daemon/session-worker.ts +418 -0
- package/src/daemon/types.ts +98 -0
- package/src/daemon/worker-pool.ts +174 -0
- package/src/index.tsx +44 -4
- package/src/ui/app.tsx +6 -2
|
@@ -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
|
+
}
|