@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.31.1",
3
+ "version": "0.32.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -4,6 +4,9 @@
4
4
  * Each background task runs as a detached Promise with an AbortController
5
5
  * for cancellation. The registry tracks status and stores results, which
6
6
  * can be queried via the Task tool (output/stop actions) or the Agent View.
7
+ *
8
+ * When a database is provided via setDatabase(), agent records are persisted
9
+ * to the daemon's SQLite database for cross-session visibility.
7
10
  */
8
11
 
9
12
  export type BackgroundTaskKind = 'interactive' | 'forked' | 'attached' | 'unattended'
@@ -29,17 +32,53 @@ export interface BackgroundTask {
29
32
  abortController: AbortController
30
33
  }
31
34
 
35
+ /** Minimal database interface for agent persistence — avoids circular imports
36
+ * on the DaemonDatabase class. */
37
+ interface AgentDatabase {
38
+ createAgent(agent: {
39
+ id: string
40
+ sessionId: string
41
+ parentId: string | null
42
+ agentType: string
43
+ description: string
44
+ status: string
45
+ kind: string
46
+ worktree: string | null
47
+ branch: string | null
48
+ prUrl: string | null
49
+ createdAt: string
50
+ completedAt: string | null
51
+ result: string | null
52
+ error: string | null
53
+ }): unknown
54
+ updateAgentStatus(id: string, status: string, result?: string, error?: string): void
55
+ }
56
+
32
57
  type CompleteCallback = (task: BackgroundTask) => void
33
58
 
34
59
  export class BackgroundAgentRegistry {
35
60
  private tasks: Map<string, BackgroundTask> = new Map()
36
61
  private completeCallbacks: Map<string, CompleteCallback[]> = new Map()
37
62
  private idCounter = 0
63
+ private db: AgentDatabase | null = null
64
+
65
+ /**
66
+ * Set the daemon database for optional SQLite persistence.
67
+ * When set, spawned agents are persisted to the database and their
68
+ * status updates are kept in sync.
69
+ *
70
+ * Uses a structural interface to avoid circular imports on DaemonDatabase.
71
+ */
72
+ setDatabase(db: AgentDatabase): void {
73
+ this.db = db
74
+ }
38
75
 
39
76
  /**
40
77
  * Spawn a background task. Returns the task ID immediately.
41
78
  * The executor function runs asynchronously; its result is stored in the task.
42
79
  *
80
+ * If a database is configured, the agent record is also persisted to SQLite.
81
+ *
43
82
  * @param description - Human-readable description
44
83
  * @param agentType - Sub-agent type (general, explore, plan, code-review)
45
84
  * @param executor - Async function that performs the work
@@ -51,6 +90,7 @@ export class BackgroundAgentRegistry {
51
90
  kind: BackgroundTaskKind = 'interactive',
52
91
  ): string {
53
92
  const id = `bg-${++this.idCounter}-${Date.now().toString(36)}`
93
+ const now = new Date()
54
94
 
55
95
  const task: BackgroundTask = {
56
96
  id,
@@ -58,18 +98,43 @@ export class BackgroundAgentRegistry {
58
98
  agentType,
59
99
  status: 'running',
60
100
  kind,
61
- startedAt: new Date(),
101
+ startedAt: now,
62
102
  abortController: new AbortController(),
63
103
  }
64
104
 
65
105
  this.tasks.set(id, task)
66
106
 
107
+ // Persist to SQLite when database is available
108
+ if (this.db) {
109
+ try {
110
+ this.db.createAgent({
111
+ id,
112
+ sessionId: 'background',
113
+ parentId: null,
114
+ agentType,
115
+ description,
116
+ status: 'running',
117
+ kind,
118
+ worktree: task.worktree ?? null,
119
+ branch: task.branch ?? null,
120
+ prUrl: task.prUrl ?? null,
121
+ createdAt: now.toISOString(),
122
+ completedAt: null,
123
+ result: null,
124
+ error: null,
125
+ })
126
+ } catch {
127
+ // Database persistence is best-effort — don't fail the task
128
+ }
129
+ }
130
+
67
131
  // Execute in background — do NOT await
68
132
  executor(task.abortController.signal)
69
133
  .then((result) => {
70
134
  task.status = 'completed'
71
135
  task.completedAt = new Date()
72
136
  task.result = result
137
+ this.syncDbStatus(id, 'completed', result)
73
138
  this.fireComplete(id)
74
139
  })
75
140
  .catch((err) => {
@@ -82,6 +147,7 @@ export class BackgroundAgentRegistry {
82
147
  task.error = String(err)
83
148
  }
84
149
  task.completedAt = new Date()
150
+ this.syncDbStatus(id, task.status, undefined, task.error)
85
151
  this.fireComplete(id)
86
152
  })
87
153
 
@@ -174,6 +240,19 @@ export class BackgroundAgentRegistry {
174
240
  return counts
175
241
  }
176
242
 
243
+ /**
244
+ * Sync task completion/failure status to the daemon database.
245
+ * Best-effort — errors are silently ignored.
246
+ */
247
+ private syncDbStatus(id: string, status: string, result?: string, error?: string): void {
248
+ if (!this.db) return
249
+ try {
250
+ this.db.updateAgentStatus(id, status, result, error)
251
+ } catch {
252
+ // Database sync is best-effort
253
+ }
254
+ }
255
+
177
256
  private fireComplete(id: string): void {
178
257
  const task = this.tasks.get(id)
179
258
  if (!task) return
@@ -0,0 +1,110 @@
1
+ // apps/cli/src/daemon/agent-manager.ts
2
+ // AgentManager — service layer wrapping the agents DB table from Phase 1.
3
+ // Delegates persistence to DaemonDatabase; adds ID generation on create.
4
+ // Pattern matches SessionManager from Phase 1.
5
+ //
6
+ // Phase 3: Added lifecycle event callbacks for WebSocket broadcast.
7
+
8
+ import type { DaemonDatabase } from './database'
9
+ import type { DaemonAgent, AgentKind } from './types'
10
+
11
+ export interface AgentLifecycleEvent {
12
+ type: 'created' | 'completed' | 'failed'
13
+ agent: DaemonAgent
14
+ }
15
+
16
+ type AgentLifecycleCallback = (event: AgentLifecycleEvent) => void
17
+
18
+ export class AgentManager {
19
+ private db: DaemonDatabase
20
+ private lifecycleCallbacks: AgentLifecycleCallback[] = []
21
+
22
+ constructor(db: DaemonDatabase) {
23
+ this.db = db
24
+ }
25
+
26
+ createAgent(
27
+ sessionId: string,
28
+ agentType: string,
29
+ description: string,
30
+ kind: AgentKind = 'interactive',
31
+ parentId: string | null = null,
32
+ ): DaemonAgent {
33
+ const id = `agent-${crypto.randomUUID().slice(0, 8)}`
34
+ const now = new Date().toISOString()
35
+ const agent = this.db.createAgent({
36
+ id,
37
+ sessionId,
38
+ parentId,
39
+ agentType,
40
+ description,
41
+ status: 'running',
42
+ kind,
43
+ worktree: null,
44
+ branch: null,
45
+ prUrl: null,
46
+ createdAt: now,
47
+ completedAt: null,
48
+ result: null,
49
+ error: null,
50
+ })
51
+ this.emit({ type: 'created', agent })
52
+ return agent
53
+ }
54
+
55
+ getAgent(id: string): DaemonAgent | null {
56
+ return this.db.getAgent(id)
57
+ }
58
+
59
+ listAgents(sessionId?: string): DaemonAgent[] {
60
+ return this.db.listAgents(sessionId)
61
+ }
62
+
63
+ listRunningAgents(): DaemonAgent[] {
64
+ return this.listAgents().filter((a) => a.status === 'running')
65
+ }
66
+
67
+ completeAgent(id: string, result: string): void {
68
+ this.db.updateAgentStatus(id, 'completed', result)
69
+ const updated = this.db.getAgent(id)
70
+ if (updated) {
71
+ this.emit({ type: 'completed', agent: updated })
72
+ }
73
+ }
74
+
75
+ failAgent(id: string, error: string): void {
76
+ this.db.updateAgentStatus(id, 'failed', undefined, error)
77
+ const updated = this.db.getAgent(id)
78
+ if (updated) {
79
+ this.emit({ type: 'failed', agent: updated })
80
+ }
81
+ }
82
+
83
+ stopAgent(id: string): DaemonAgent | null {
84
+ const agent = this.db.getAgent(id)
85
+ if (!agent) return null
86
+ if (agent.status !== 'running') return agent
87
+
88
+ this.db.updateAgentStatus(id, 'completed')
89
+ const updated = this.db.getAgent(id)
90
+ if (updated) {
91
+ this.emit({ type: 'completed', agent: updated })
92
+ }
93
+ return updated
94
+ }
95
+
96
+ /** Subscribe to agent lifecycle events (created / completed / failed). */
97
+ onLifecycleEvent(callback: AgentLifecycleCallback): void {
98
+ this.lifecycleCallbacks.push(callback)
99
+ }
100
+
101
+ private emit(event: AgentLifecycleEvent): void {
102
+ for (const cb of this.lifecycleCallbacks) {
103
+ try {
104
+ cb(event)
105
+ } catch {
106
+ // Callback errors must not propagate
107
+ }
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,74 @@
1
+ // apps/cli/src/daemon/attach-protocol.ts
2
+ // Client → Daemon: prompt, interrupt
3
+ // Daemon → Client: text, tool_use, tool_result, usage, task_notification, done, error, session_state
4
+
5
+ export interface ClientPromptMessage {
6
+ type: 'prompt'
7
+ sessionId: string
8
+ prompt: string
9
+ }
10
+ export interface ClientInterruptMessage {
11
+ type: 'interrupt'
12
+ sessionId: string
13
+ }
14
+ export type ClientMessage = ClientPromptMessage | ClientInterruptMessage
15
+
16
+ export interface ServerTextMessage {
17
+ type: 'text'
18
+ sessionId: string
19
+ content: string
20
+ }
21
+ export interface ServerToolUseMessage {
22
+ type: 'tool_use'
23
+ sessionId: string
24
+ toolName: string
25
+ toolInput: Record<string, unknown>
26
+ toolId: string
27
+ }
28
+ export interface ServerToolResultMessage {
29
+ type: 'tool_result'
30
+ sessionId: string
31
+ toolId: string
32
+ content: string
33
+ isError?: boolean
34
+ }
35
+ export interface ServerUsageMessage {
36
+ type: 'usage'
37
+ sessionId: string
38
+ inputTokens: number
39
+ outputTokens: number
40
+ }
41
+ export interface ServerTaskNotificationMessage {
42
+ type: 'task_notification'
43
+ sessionId: string
44
+ taskId: string
45
+ status: string
46
+ }
47
+ export interface ServerDoneMessage {
48
+ type: 'done'
49
+ sessionId: string
50
+ stopReason: string
51
+ }
52
+ export interface ServerErrorMessage {
53
+ type: 'error'
54
+ sessionId: string
55
+ message: string
56
+ }
57
+ export interface ServerSessionStateMessage {
58
+ type: 'session_state'
59
+ sessionId: string
60
+ messages: unknown[]
61
+ provider: string
62
+ model: string
63
+ turnCount: number
64
+ }
65
+
66
+ export type ServerMessage =
67
+ | ServerTextMessage
68
+ | ServerToolUseMessage
69
+ | ServerToolResultMessage
70
+ | ServerUsageMessage
71
+ | ServerTaskNotificationMessage
72
+ | ServerDoneMessage
73
+ | ServerErrorMessage
74
+ | ServerSessionStateMessage
@@ -0,0 +1,104 @@
1
+ // apps/cli/src/daemon/auth.ts
2
+ import { randomBytes } from 'node:crypto'
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
4
+ import { dirname } from 'node:path'
5
+
6
+ /**
7
+ * bun-types@1.3.14 lacks constantTimeCompare in type definitions,
8
+ * though the method exists at Bun 1.2+ runtime.
9
+ */
10
+ interface PasswordWithCompare {
11
+ constantTimeCompare(a: Buffer, b: Buffer): boolean
12
+ }
13
+
14
+ /**
15
+ * Generate a 64-character hex token using cryptographically secure random bytes.
16
+ */
17
+ export function generateToken(): string {
18
+ return randomBytes(32).toString('hex')
19
+ }
20
+
21
+ /**
22
+ * Load an existing token from disk, or create one if it doesn't exist.
23
+ * The token file is created with 0o600 permissions.
24
+ */
25
+ export function loadOrCreateToken(tokenPath: string): string {
26
+ if (existsSync(tokenPath)) {
27
+ return readFileSync(tokenPath, 'utf-8').trim()
28
+ }
29
+
30
+ const token = generateToken()
31
+ mkdirSync(dirname(tokenPath), { recursive: true, mode: 0o700 })
32
+ writeFileSync(tokenPath, token, { mode: 0o600 })
33
+ return token
34
+ }
35
+
36
+ /**
37
+ * Verify a provided token against the expected token.
38
+ * Uses Bun's constant-time comparison to prevent timing attacks.
39
+ */
40
+ export function verifyToken(expected: string, provided: string): boolean {
41
+ if (!provided || !expected) return false
42
+ return (Bun.password as unknown as PasswordWithCompare).constantTimeCompare(
43
+ Buffer.from(expected),
44
+ Buffer.from(provided),
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Generate a new API token, overwrite the token file, and return the new token.
50
+ */
51
+ export function rotateToken(tokenPath: string): string {
52
+ const token = generateToken()
53
+ writeFileSync(tokenPath, token, { mode: 0o600 })
54
+ return token
55
+ }
56
+
57
+ /**
58
+ * Read all tokens from the token file.
59
+ * Currently supports a single token per file; returns it as a single-element array.
60
+ * Returns an empty array if the file does not exist.
61
+ */
62
+ export function listTokens(tokenPath: string): string[] {
63
+ if (!existsSync(tokenPath)) return []
64
+ const token = readFileSync(tokenPath, 'utf-8').trim()
65
+ return token ? [token] : []
66
+ }
67
+
68
+ /**
69
+ * Create an auth middleware for Bun.serve that checks the Authorization header.
70
+ * Returns a Response if auth fails, or null if auth passes.
71
+ *
72
+ * In the default 127.0.0.1-only configuration, all requests are implicitly
73
+ * trusted and auth is bypassed. Auth enforcement activates when
74
+ * MIPHAM_BIND=0.0.0.0 for remote access.
75
+ */
76
+ export function authMiddleware(request: Request, validToken: string): Response | null {
77
+ // Allow health endpoint without auth
78
+ const url = new URL(request.url)
79
+ if (url.pathname === '/api/v1/health') return null
80
+
81
+ // localhost requests skip auth
82
+ const host = request.headers.get('host') || ''
83
+ if (host.startsWith('127.0.0.1') || host.startsWith('localhost') || host.startsWith('[::1]')) {
84
+ return null
85
+ }
86
+
87
+ const auth = request.headers.get('authorization')
88
+ if (!auth || !auth.startsWith('Bearer ')) {
89
+ return new Response(JSON.stringify({ ok: false, error: 'Missing authorization header' }), {
90
+ status: 401,
91
+ headers: { 'Content-Type': 'application/json' },
92
+ })
93
+ }
94
+
95
+ const token = auth.slice(7)
96
+ if (!verifyToken(validToken, token)) {
97
+ return new Response(JSON.stringify({ ok: false, error: 'Invalid token' }), {
98
+ status: 403,
99
+ headers: { 'Content-Type': 'application/json' },
100
+ })
101
+ }
102
+
103
+ return null
104
+ }
@@ -0,0 +1,68 @@
1
+ // apps/cli/src/daemon/cors.ts
2
+ //
3
+ // CORS middleware for the daemon HTTP API.
4
+ // Handles preflight OPTIONS requests and provides helpers for
5
+ // adding CORS headers to responses from external origins.
6
+
7
+ const ALLOWED_HEADERS = 'Authorization, Content-Type'
8
+ const ALLOWED_METHODS = 'GET, POST, PATCH, DELETE, OPTIONS'
9
+
10
+ /**
11
+ * Check whether an origin string refers to localhost.
12
+ */
13
+ export function isLocalhostOrigin(origin: string): boolean {
14
+ return origin.includes('localhost') || origin.includes('127.0.0.1') || origin.includes('[::1]')
15
+ }
16
+
17
+ /**
18
+ * Handle CORS preflight (OPTIONS) requests.
19
+ *
20
+ * Returns a Response with appropriate CORS headers for preflight,
21
+ * or null if the request does not need CORS handling (not a preflight,
22
+ * or origin is localhost).
23
+ *
24
+ * For non-preflight requests with an external Origin, the caller should
25
+ * use `addCorsHeaders` to attach CORS headers to the response.
26
+ */
27
+ export function corsMiddleware(request: Request): Response | null {
28
+ const origin = request.headers.get('origin')
29
+ if (!origin) return null
30
+
31
+ // Skip localhost origins — no CORS needed
32
+ if (isLocalhostOrigin(origin)) return null
33
+
34
+ // Handle preflight
35
+ if (request.method === 'OPTIONS') {
36
+ return new Response(null, {
37
+ status: 204,
38
+ headers: {
39
+ 'Access-Control-Allow-Origin': origin,
40
+ 'Access-Control-Allow-Methods': ALLOWED_METHODS,
41
+ 'Access-Control-Allow-Headers': ALLOWED_HEADERS,
42
+ 'Access-Control-Max-Age': '86400',
43
+ },
44
+ })
45
+ }
46
+
47
+ return null
48
+ }
49
+
50
+ /**
51
+ * Add CORS headers to a response when the request has an external origin.
52
+ * If the origin is localhost or absent, the response is returned unchanged.
53
+ */
54
+ export function addCorsHeaders(response: Response, request: Request): Response {
55
+ const origin = request.headers.get('origin')
56
+ if (!origin || isLocalhostOrigin(origin)) return response
57
+
58
+ const headers = new Headers(response.headers)
59
+ headers.set('Access-Control-Allow-Origin', origin)
60
+ headers.set('Access-Control-Allow-Methods', ALLOWED_METHODS)
61
+ headers.set('Access-Control-Allow-Headers', ALLOWED_HEADERS)
62
+
63
+ return new Response(response.body, {
64
+ status: response.status,
65
+ statusText: response.statusText,
66
+ headers,
67
+ })
68
+ }