@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,307 @@
1
+ // apps/cli/src/daemon/index.ts — Daemon Lifecycle Management
2
+ //
3
+ // startDaemon() initializes DB + SessionManager + HTTP server, writes PID/port files
4
+ // stopDaemon() shuts down server, closes DB, cleans up PID/port files
5
+ // getDaemonStatus() reads PID file and verifies process is alive
6
+ // getPort() reads the last known port from disk
7
+
8
+ import { join } from 'node:path'
9
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'
10
+ import { createServer as createNetServer } from 'node:net'
11
+ import { homedir } from 'node:os'
12
+ import type { Server } from 'bun'
13
+ import { DaemonDatabase } from './database'
14
+ import { SessionManager } from './session-manager'
15
+ import { AgentManager } from './agent-manager'
16
+ import { MessageBus } from './message-bus'
17
+ import { GoalManager } from './goal-manager'
18
+ import { ScheduleManager } from './schedule-manager'
19
+ import { createServer } from './server'
20
+ import { WorkerPool } from './worker-pool'
21
+ import { loadOrCreateToken } from './auth'
22
+ import { RateLimiter } from './rate-limiter'
23
+ import { PACKAGE_VERSION } from '../shared/package-info'
24
+ import type { DaemonStatus } from './types'
25
+
26
+ const HOME = homedir()
27
+ const MIPHAM_HOME = join(HOME, '.mipham')
28
+ const DB_PATH = join(MIPHAM_HOME, 'daemon.db')
29
+ const TOKEN_PATH = join(MIPHAM_HOME, 'daemon.token')
30
+ const PID_FILE = join(MIPHAM_HOME, 'daemon.pid')
31
+ const PORT_FILE = join(MIPHAM_HOME, 'daemon.port')
32
+
33
+ const DEFAULT_PORT = 45671
34
+
35
+ // The lifecycle only needs .stop() on the server; WebSocketData type is opaque here.
36
+ let activeServer: Server<unknown> | null = null
37
+ let activeDb: DaemonDatabase | null = null
38
+ let activePool: WorkerPool | null = null
39
+ let activeAgentManager: AgentManager | null = null
40
+ let activeMessageBus: MessageBus | null = null
41
+ let activeScheduleManager: ScheduleManager | null = null
42
+ let activeRateLimiter: RateLimiter | null = null
43
+
44
+ function getConfiguredPort(): number {
45
+ if (process.env.MIPHAM_PORT) {
46
+ const p = parseInt(process.env.MIPHAM_PORT, 10)
47
+ if (!isNaN(p) && p > 0 && p < 65536) return p
48
+ }
49
+ return DEFAULT_PORT
50
+ }
51
+
52
+ function getConfiguredBind(): string {
53
+ return process.env.MIPHAM_BIND || '127.0.0.1'
54
+ }
55
+
56
+ /**
57
+ * Test whether a TCP port is available on the given hostname.
58
+ */
59
+ function isPortAvailable(port: number, hostname: string): Promise<boolean> {
60
+ return new Promise((resolve) => {
61
+ const server = createNetServer()
62
+ server.once('error', () => {
63
+ server.removeAllListeners()
64
+ resolve(false)
65
+ })
66
+ server.once('listening', () => {
67
+ server.close()
68
+ resolve(true)
69
+ })
70
+ server.listen(port, hostname)
71
+ })
72
+ }
73
+
74
+ /**
75
+ * Find an available port starting from startPort, trying up to 10 ports.
76
+ * Falls back to startPort if the range is exhausted (Bun.serve will throw if
77
+ * the port is actually unavailable at bind time).
78
+ */
79
+ async function findAvailablePort(startPort: number, hostname: string): Promise<number> {
80
+ for (let port = startPort; port < startPort + 10; port++) {
81
+ if (await isPortAvailable(port, hostname)) {
82
+ return port
83
+ }
84
+ }
85
+ // If all ports in range appear busy, return startPort and let Bun.serve fail
86
+ // with a clear error rather than silently binding to an unexpected port.
87
+ return startPort
88
+ }
89
+
90
+ /**
91
+ * Read the last daemon port from the port file on disk.
92
+ * Returns DEFAULT_PORT if the file doesn't exist or is unreadable.
93
+ */
94
+ export function getPort(): number {
95
+ if (existsSync(PORT_FILE)) {
96
+ try {
97
+ const raw = readFileSync(PORT_FILE, 'utf-8').trim()
98
+ const port = parseInt(raw, 10)
99
+ if (!isNaN(port) && port > 0 && port < 65536) return port
100
+ } catch {
101
+ // Corrupt port file — fall through to default
102
+ }
103
+ }
104
+ return DEFAULT_PORT
105
+ }
106
+
107
+ /**
108
+ * Start the Mipham Code daemon.
109
+ *
110
+ * 1. Ensures ~/.mipham exists (mode 0o700)
111
+ * 2. Loads or creates the auth token
112
+ * 3. Initializes the SQLite database and runs JSONL migration on first start
113
+ * 4. Creates a SessionManager, AgentManager, and MessageBus
114
+ * 5. Starts the HTTP server on an available port
115
+ * 6. Writes PID and port files to disk
116
+ *
117
+ * Returns the port and token so the CLI can connect immediately.
118
+ */
119
+ export async function startDaemon(): Promise<{ port: number; token: string }> {
120
+ mkdirSync(MIPHAM_HOME, { recursive: true, mode: 0o700 })
121
+
122
+ const hostname = getConfiguredBind()
123
+ const port = await findAvailablePort(getConfiguredPort(), hostname)
124
+ const token = loadOrCreateToken(TOKEN_PATH)
125
+
126
+ // Initialize database
127
+ const db = new DaemonDatabase(DB_PATH)
128
+ db.init()
129
+ activeDb = db
130
+
131
+ // Run JSONL migration on first start (when no sessions exist yet)
132
+ const sessionsCount = db.listSessions().length
133
+ if (sessionsCount === 0) {
134
+ const migrated = db.migrateFromJsonl()
135
+ if (migrated > 0) {
136
+ console.log(`Daemon: migrated ${migrated} JSONL session(s) to SQLite`)
137
+ }
138
+ }
139
+
140
+ // Create session manager
141
+ const sm = new SessionManager(db)
142
+
143
+ // Create worker pool (idle timeout: 30 min default)
144
+ const pool = new WorkerPool(db)
145
+ activePool = pool
146
+
147
+ // Create agent manager and message bus (Phase 3)
148
+ const agentManager = new AgentManager(db)
149
+ activeAgentManager = agentManager
150
+ const messageBus = new MessageBus()
151
+ activeMessageBus = messageBus
152
+
153
+ // Create goal manager and schedule manager (Phase 4)
154
+ const goalManager = new GoalManager(db)
155
+ const scheduleManager = new ScheduleManager(db, pool)
156
+ activeScheduleManager = scheduleManager
157
+
158
+ // Start the schedule checking interval (checks every 60s)
159
+ scheduleManager.start()
160
+
161
+ // Create rate limiter (Phase 5 — 100 req/min per client IP)
162
+ const rateLimiter = new RateLimiter(100, 60_000)
163
+ activeRateLimiter = rateLimiter
164
+
165
+ // Start HTTP server (Bun.serve starts listening immediately)
166
+ const server = createServer({
167
+ db,
168
+ sm,
169
+ pool,
170
+ token,
171
+ tokenPath: TOKEN_PATH,
172
+ port,
173
+ hostname,
174
+ agentManager,
175
+ messageBus,
176
+ goalManager,
177
+ scheduleManager,
178
+ rateLimiter,
179
+ })
180
+ activeServer = server
181
+
182
+ // Write PID and port files
183
+ writeFileSync(PID_FILE, String(process.pid))
184
+ writeFileSync(PORT_FILE, String(port))
185
+
186
+ return { port, token }
187
+ }
188
+
189
+ /**
190
+ * Stop the Mipham Code daemon.
191
+ *
192
+ * By default, refuses to stop if there are active sessions.
193
+ * Pass `force: true` to bypass this check (active sessions will be
194
+ * disconnected when the server stops).
195
+ *
196
+ * Cleans up PID and port files regardless of success/failure.
197
+ */
198
+ export async function stopDaemon(force: boolean = false): Promise<void> {
199
+ // Guard: check for active sessions unless forced
200
+ if (!force && activeDb) {
201
+ const stats = activeDb.getStats()
202
+ if (stats.activeSessions > 0) {
203
+ throw new Error(
204
+ `Cannot stop: ${stats.activeSessions} active session(s). Use --force to stop anyway.`,
205
+ )
206
+ }
207
+ }
208
+
209
+ // Stop HTTP server (disconnects WebSocket clients)
210
+ if (activeServer) {
211
+ activeServer.stop()
212
+ activeServer = null
213
+ }
214
+
215
+ // Stop worker pool (interrupts all in-progress prompts, persists state)
216
+ if (activePool) {
217
+ await activePool.stopAll()
218
+ activePool = null
219
+ }
220
+
221
+ // Stop schedule manager interval (Phase 4)
222
+ if (activeScheduleManager) {
223
+ activeScheduleManager.stop()
224
+ activeScheduleManager = null
225
+ }
226
+
227
+ // Stop rate limiter cleanup interval (Phase 5)
228
+ if (activeRateLimiter) {
229
+ activeRateLimiter.stop()
230
+ activeRateLimiter = null
231
+ }
232
+
233
+ // Clean up agent manager and message bus (Phase 3)
234
+ activeAgentManager = null
235
+ activeMessageBus = null
236
+
237
+ // Close database (commits any pending WAL)
238
+ if (activeDb) {
239
+ activeDb.close()
240
+ activeDb = null
241
+ }
242
+
243
+ // Clean up PID/port files — best-effort, ignore errors
244
+ try {
245
+ unlinkSync(PID_FILE)
246
+ } catch {
247
+ // File may not exist
248
+ }
249
+ try {
250
+ unlinkSync(PORT_FILE)
251
+ } catch {
252
+ // File may not exist
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Check whether a daemon process is currently running.
258
+ *
259
+ * Returns DaemonStatus with basic info if the daemon is alive (PID exists
260
+ * and process is reachable), or null if no daemon is running.
261
+ *
262
+ * Fields like uptime, activeSessions, etc. are filled with 0 when queried
263
+ * from an external process. For real-time stats, call GET /api/v1/health
264
+ * directly against the running daemon.
265
+ */
266
+ export function getDaemonStatus(): DaemonStatus | null {
267
+ if (!existsSync(PID_FILE)) return null
268
+
269
+ let pid = 0
270
+ try {
271
+ const raw = readFileSync(PID_FILE, 'utf-8').trim()
272
+ pid = parseInt(raw, 10)
273
+ if (isNaN(pid) || pid <= 0) return null
274
+ } catch {
275
+ return null
276
+ }
277
+
278
+ // Signal 0 checks process existence without actually sending a signal
279
+ try {
280
+ process.kill(pid, 0)
281
+ } catch {
282
+ // Stale PID file — process is gone, clean up
283
+ try {
284
+ unlinkSync(PID_FILE)
285
+ } catch {
286
+ // Best effort
287
+ }
288
+ try {
289
+ unlinkSync(PORT_FILE)
290
+ } catch {
291
+ // Best effort
292
+ }
293
+ return null
294
+ }
295
+
296
+ const port = getPort()
297
+
298
+ return {
299
+ pid,
300
+ port,
301
+ uptime: 0, // Call GET /api/v1/health for real-time stats
302
+ activeSessions: 0,
303
+ totalSessions: 0,
304
+ activeAgents: 0,
305
+ version: PACKAGE_VERSION,
306
+ }
307
+ }
@@ -0,0 +1,84 @@
1
+ // apps/cli/src/daemon/message-bus.ts — Phase 3: Inter-Agent Message Bus
2
+ //
3
+ // MessageBus provides asynchronous message passing between agents:
4
+ // - send() — enqueue a message from one agent to another
5
+ // - poll() — retrieve and clear pending messages (discards TTL-expired)
6
+ // - broadcastToSession() — send a message to all agents in a session
7
+ //
8
+ // Messages older than 5 minutes are automatically discarded on poll.
9
+ // Session-to-agent mapping is maintained externally via registerAgent/unregisterAgent.
10
+
11
+ interface BusMessage {
12
+ sender: string
13
+ content: string
14
+ timestamp: number
15
+ }
16
+
17
+ export class MessageBus {
18
+ private queues = new Map<string, BusMessage[]>()
19
+ private sessionAgents = new Map<string, Set<string>>()
20
+ private readonly ttlMs = 5 * 60 * 1000 // 5 minutes
21
+
22
+ /**
23
+ * Register an agent to a session so that broadcastToSession can reach it.
24
+ */
25
+ registerAgent(sessionId: string, agentId: string): void {
26
+ if (!this.sessionAgents.has(sessionId)) {
27
+ this.sessionAgents.set(sessionId, new Set())
28
+ }
29
+ this.sessionAgents.get(sessionId)!.add(agentId)
30
+ }
31
+
32
+ /**
33
+ * Remove an agent from all session registrations and clear its message queue.
34
+ */
35
+ unregisterAgent(agentId: string): void {
36
+ for (const [, agents] of this.sessionAgents) {
37
+ agents.delete(agentId)
38
+ }
39
+ this.queues.delete(agentId)
40
+ }
41
+
42
+ /**
43
+ * Enqueue a message from senderAgentId to receiverAgentId.
44
+ */
45
+ send(senderAgentId: string, receiverAgentId: string, content: string): void {
46
+ if (!this.queues.has(receiverAgentId)) {
47
+ this.queues.set(receiverAgentId, [])
48
+ }
49
+ this.queues.get(receiverAgentId)!.push({
50
+ sender: senderAgentId,
51
+ content,
52
+ timestamp: Date.now(),
53
+ })
54
+ }
55
+
56
+ /**
57
+ * Retrieve and clear all pending messages for an agent.
58
+ * Messages older than the TTL (5 min) are discarded.
59
+ */
60
+ poll(agentId: string): BusMessage[] {
61
+ const queue = this.queues.get(agentId)
62
+ if (!queue || queue.length === 0) return []
63
+
64
+ const now = Date.now()
65
+ const valid = queue.filter((msg) => now - msg.timestamp < this.ttlMs)
66
+
67
+ this.queues.delete(agentId)
68
+ return valid
69
+ }
70
+
71
+ /**
72
+ * Send a message to every agent in a session except the one specified.
73
+ * The sender is recorded as 'system'.
74
+ */
75
+ broadcastToSession(sessionId: string, content: string, excludeAgentId?: string): void {
76
+ const agents = this.sessionAgents.get(sessionId)
77
+ if (!agents) return
78
+ for (const agentId of agents) {
79
+ if (agentId !== excludeAgentId) {
80
+ this.send('system', agentId, content)
81
+ }
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,70 @@
1
+ // apps/cli/src/daemon/rate-limiter.ts
2
+ //
3
+ // In-memory sliding-window rate limiter for daemon HTTP API.
4
+ // Each key (typically client IP or token) gets maxRequests per windowMs.
5
+
6
+ interface WindowEntry {
7
+ count: number
8
+ resetAt: number
9
+ }
10
+
11
+ export class RateLimiter {
12
+ private windows = new Map<string, WindowEntry>()
13
+ private cleanupTimer: ReturnType<typeof setInterval>
14
+
15
+ constructor(
16
+ private maxRequests: number = 100,
17
+ private windowMs: number = 60_000,
18
+ ) {
19
+ // Clean expired entries every 60 seconds
20
+ this.cleanupTimer = setInterval(() => this.cleanup(), 60_000)
21
+ // Allow timer to not block process exit
22
+ if (typeof this.cleanupTimer === 'object' && 'unref' in this.cleanupTimer) {
23
+ ;(this.cleanupTimer as unknown as { unref(): void }).unref()
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Check whether a request from `key` should be allowed.
29
+ * Returns the decision along with remaining quota and reset timestamp.
30
+ */
31
+ check(key: string): { allowed: boolean; remaining: number; resetAt: number } {
32
+ const now = Date.now()
33
+ let entry = this.windows.get(key)
34
+
35
+ if (!entry || now >= entry.resetAt) {
36
+ entry = { count: 0, resetAt: now + this.windowMs }
37
+ this.windows.set(key, entry)
38
+ }
39
+
40
+ if (entry.count >= this.maxRequests) {
41
+ return { allowed: false, remaining: 0, resetAt: entry.resetAt }
42
+ }
43
+
44
+ entry.count++
45
+ return {
46
+ allowed: true,
47
+ remaining: this.maxRequests - entry.count,
48
+ resetAt: entry.resetAt,
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Remove all entries whose window has expired.
54
+ */
55
+ private cleanup(): void {
56
+ const now = Date.now()
57
+ for (const [key, entry] of this.windows) {
58
+ if (now >= entry.resetAt) {
59
+ this.windows.delete(key)
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Stop the cleanup interval. Call when shutting down the daemon.
66
+ */
67
+ stop(): void {
68
+ clearInterval(this.cleanupTimer)
69
+ }
70
+ }