@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.
@@ -0,0 +1,434 @@
1
+ // apps/cli/src/daemon/remote-engine.ts — Phase 2 Task 6: Remote Engine
2
+ //
3
+ // RemoteEngine is a WebSocket-based substitute for the local QueryEngine.
4
+ // It exposes the same `process(prompt, signal)` async generator interface
5
+ // so the TUI can consume it identically when running in `mipham attach` mode.
6
+ //
7
+ // Architecture:
8
+ // RemoteEngine
9
+ // owns WebSocket connection to daemon
10
+ // converts ServerMessage stream → StreamChunk stream
11
+ // bridges event-driven WS to async generator via queue + resolver
12
+ //
13
+ // Usage:
14
+ // const engine = new RemoteEngine({ sessionId, port, token })
15
+ // for await (const chunk of engine.process('fix the bug')) {
16
+ // // same StreamChunk as local QueryEngine
17
+ // }
18
+ // engine.close()
19
+
20
+ import type { ClientPromptMessage, ClientInterruptMessage, ServerMessage } from './attach-protocol'
21
+ import type { StreamChunk } from '../shared/types'
22
+
23
+ // ── Public API ───────────────────────────────────────────────────────────────
24
+
25
+ export interface RemoteEngineOptions {
26
+ /** Daemon session ID — the session to connect to. */
27
+ sessionId: string
28
+ /** Daemon HTTP/WS port (default 45671). */
29
+ port: number
30
+ /** Auth token (for future non-localhost use; localhost bypasses auth). */
31
+ token: string
32
+ }
33
+
34
+ /**
35
+ * WebSocket-based substitute for the local QueryEngine.
36
+ *
37
+ * Connects to a running daemon's WebSocket endpoint and streams prompt
38
+ * responses as StreamChunk objects — the same interface the TUI already
39
+ * consumes from the local engine.
40
+ */
41
+ export class RemoteEngine {
42
+ private sessionId: string
43
+ private port: number
44
+ private token: string
45
+ private ws: WebSocket | null = null
46
+
47
+ /** Queued chunks not yet consumed by the async generator. */
48
+ private chunkQueue: StreamChunk[] = []
49
+
50
+ /** Resolver for the currently-waiting generator yield. */
51
+ private resolveNext: ((chunk: StreamChunk) => void) | null = null
52
+ private rejectNext: ((reason: Error) => void) | null = null
53
+
54
+ /** Whether this engine has been explicitly closed. */
55
+ private closed = false
56
+
57
+ constructor(options: RemoteEngineOptions) {
58
+ this.sessionId = options.sessionId
59
+ this.port = options.port
60
+ this.token = options.token
61
+ }
62
+
63
+ // ── Connection Management ───────────────────────────────────────────────
64
+
65
+ /**
66
+ * Ensure a WebSocket connection to the daemon exists.
67
+ * Creates one lazily on the first process() call.
68
+ */
69
+ private ensureConnected(): Promise<void> {
70
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
71
+ return Promise.resolve()
72
+ }
73
+
74
+ if (this.ws) {
75
+ // Stale socket — clean up before reconnecting
76
+ this.ws.onopen = null
77
+ this.ws.onmessage = null
78
+ this.ws.onclose = null
79
+ this.ws.onerror = null
80
+ try {
81
+ this.ws.close()
82
+ } catch {
83
+ // Ignore close errors on stale socket
84
+ }
85
+ this.ws = null
86
+ }
87
+
88
+ const url = `ws://127.0.0.1:${this.port}/api/v1/sessions/${this.sessionId}/stream`
89
+
90
+ return new Promise<void>((resolve, reject) => {
91
+ const ws = new WebSocket(url)
92
+ this.ws = ws
93
+
94
+ ws.onopen = () => {
95
+ resolve()
96
+ }
97
+
98
+ ws.onmessage = (event: MessageEvent<string>) => {
99
+ this.onMessage(event.data)
100
+ }
101
+
102
+ ws.onclose = () => {
103
+ // Only signal to the generator if there is an active wait.
104
+ if (this.resolveNext) {
105
+ const doneChunk: StreamChunk = { type: 'stop' }
106
+ this.resolveNext(doneChunk)
107
+ this.resolveNext = null
108
+ }
109
+ }
110
+
111
+ ws.onerror = () => {
112
+ reject(new Error(`Failed to connect to daemon at 127.0.0.1:${this.port}`))
113
+ }
114
+ })
115
+ }
116
+
117
+ // ── Prompt Processing (async generator) ──────────────────────────────────
118
+
119
+ /**
120
+ * Process a user prompt through the remote daemon session.
121
+ *
122
+ * Internally sends a `prompt` message over the WebSocket and yields
123
+ * StreamChunk objects as they arrive from the daemon. The async generator
124
+ * stops naturally when a `done` or `error` message is received.
125
+ *
126
+ * @param prompt The user's text prompt to send.
127
+ * @param signal Optional AbortSignal — aborts the prompt (sends interrupt).
128
+ */
129
+ async *process(prompt: string, signal?: AbortSignal): AsyncGenerator<StreamChunk> {
130
+ if (this.closed) {
131
+ yield { type: 'error', error: 'RemoteEngine is closed' }
132
+ return
133
+ }
134
+
135
+ // Check signal before connecting
136
+ if (signal?.aborted) {
137
+ yield { type: 'stop' }
138
+ return
139
+ }
140
+
141
+ await this.ensureConnected()
142
+
143
+ // Reset per-prompt state
144
+ this.chunkQueue = []
145
+ this.resolveNext = null
146
+ this.rejectNext = null
147
+
148
+ // Send the prompt
149
+ const promptMsg: ClientPromptMessage = {
150
+ type: 'prompt',
151
+ sessionId: this.sessionId,
152
+ prompt,
153
+ }
154
+ this.ws!.send(JSON.stringify(promptMsg))
155
+
156
+ // ── Abort signal → interrupt message ──────────────────────────────
157
+ const onAbort = () => {
158
+ const interruptMsg: ClientInterruptMessage = {
159
+ type: 'interrupt',
160
+ sessionId: this.sessionId,
161
+ }
162
+ try {
163
+ this.ws!.send(JSON.stringify(interruptMsg))
164
+ } catch {
165
+ // WebSocket may already be closing — ignore
166
+ }
167
+ // Reject any pending wait so the generator stops cleanly
168
+ if (this.rejectNext) {
169
+ this.rejectNext(new DOMException('Aborted', 'AbortError'))
170
+ this.rejectNext = null
171
+ this.resolveNext = null
172
+ }
173
+ }
174
+ signal?.addEventListener('abort', onAbort, { once: true })
175
+
176
+ try {
177
+ let running = true
178
+
179
+ while (running) {
180
+ // Drain any buffered chunks from the queue first
181
+ while (this.chunkQueue.length > 0) {
182
+ const chunk = this.chunkQueue.shift()!
183
+ running = this.yieldAndCheck(chunk)
184
+ yield chunk
185
+ if (!running) return
186
+ }
187
+
188
+ if (!running) break
189
+
190
+ // Wait for the next chunk from the WebSocket
191
+ const chunk = await new Promise<StreamChunk>((resolve, reject) => {
192
+ this.resolveNext = resolve
193
+ this.rejectNext = reject
194
+ })
195
+
196
+ running = this.yieldAndCheck(chunk)
197
+ yield chunk
198
+ if (!running) return
199
+ }
200
+ } catch (err) {
201
+ if (signal?.aborted) {
202
+ yield { type: 'stop' }
203
+ } else {
204
+ yield {
205
+ type: 'error',
206
+ error: err instanceof Error ? err.message : String(err),
207
+ }
208
+ }
209
+ } finally {
210
+ signal?.removeEventListener('abort', onAbort)
211
+ this.resolveNext = null
212
+ this.rejectNext = null
213
+ }
214
+ }
215
+
216
+ // ── Stub methods for TUI compatibility ───────────────────────────────────
217
+ //
218
+ // In remote attach mode, the daemon manages providers, agents, permissions,
219
+ // and context. These stubs satisfy the TUI's engine interface without
220
+ // introducing daemon dependencies into the UI layer.
221
+ //
222
+ // Slash commands may call any of these; we return safe defaults.
223
+
224
+ /** No-op: provider switching is managed by the daemon session. */
225
+ switchProvider(_providerId: string, _modelId?: string): void {}
226
+
227
+ /** Remote mode has no local agent registry. */
228
+ getAgentRegistry(): undefined {
229
+ return undefined
230
+ }
231
+
232
+ /** No-op: agents run on the daemon. */
233
+ setAgentRegistry(_reg: unknown): void {}
234
+
235
+ /** Returns a stub context object so slash commands and auto-save don't crash. */
236
+ getContext(): {
237
+ saveCheckpoint(_label: string): void
238
+ clear(): void
239
+ getMessages(): Array<{ role: string; content: string }>
240
+ getEstimatedTokens(): number
241
+ } {
242
+ return {
243
+ saveCheckpoint: (_label: string) => {
244
+ // Remote mode: checkpoints are managed by the daemon
245
+ },
246
+ clear: () => {
247
+ // Remote mode: session history lives on the daemon
248
+ },
249
+ getMessages: () => [],
250
+ getEstimatedTokens: () => 0,
251
+ }
252
+ }
253
+
254
+ /** Returns a stub permission object so slash commands don't crash. */
255
+ getPermission(): { setMode(_mode: string): void } {
256
+ return {
257
+ setMode: (_mode: string) => {
258
+ // Remote mode: permissions are managed by the daemon
259
+ },
260
+ }
261
+ }
262
+
263
+ /** Returns an empty tool set — tools run on the daemon. */
264
+ getTools(): Map<string, unknown> {
265
+ return new Map()
266
+ }
267
+
268
+ /** Remote mode has no local rule engine. */
269
+ getRuleEngine(): undefined {
270
+ return undefined
271
+ }
272
+
273
+ /** Remote mode has no local pattern analyzer. */
274
+ getPatternAnalyzer(): undefined {
275
+ return undefined
276
+ }
277
+
278
+ /** Remote mode has no local effectiveness tracker. */
279
+ getEffectivenessTracker(): undefined {
280
+ return undefined
281
+ }
282
+
283
+ /** Remote mode has no local goal state. */
284
+ getGoalState(): { goal: undefined; decompose: false; subtasks: never[] } {
285
+ return { goal: undefined, decompose: false, subtasks: [] }
286
+ }
287
+
288
+ /** No-op: goals are managed by the daemon session. */
289
+ setGoal(_goal: string, _opts?: unknown): void {}
290
+
291
+ /** Remote mode has no local usage tracker. */
292
+ getUsageTracker(): { getStats(): Record<string, number> } {
293
+ return { getStats: () => ({}) }
294
+ }
295
+
296
+ /** Remote mode has no local agent view manager. */
297
+ getAgentViewManager(): undefined {
298
+ return undefined
299
+ }
300
+
301
+ /** Returns a stub registry so slash commands don't crash. */
302
+ getRegistry(): { getProvider(_id: string): unknown; getProviders(): unknown[] } {
303
+ return {
304
+ getProvider: (_id: string) => undefined,
305
+ getProviders: () => [],
306
+ }
307
+ }
308
+
309
+ // ── Cleanup ──────────────────────────────────────────────────────────────
310
+
311
+ /** Close the WebSocket connection and release resources. */
312
+ close(): void {
313
+ this.closed = true
314
+ if (this.ws) {
315
+ // Reject any pending wait
316
+ if (this.rejectNext) {
317
+ this.rejectNext(new Error('RemoteEngine closed'))
318
+ this.rejectNext = null
319
+ this.resolveNext = null
320
+ }
321
+ try {
322
+ this.ws.close()
323
+ } catch {
324
+ // Ignore close errors
325
+ }
326
+ this.ws = null
327
+ }
328
+ }
329
+
330
+ // ── Internal Helpers ─────────────────────────────────────────────────────
331
+
332
+ /**
333
+ * Process an incoming raw WebSocket message (JSON string).
334
+ * Parses it as a ServerMessage, maps it to a StreamChunk, and either
335
+ * resolves the pending generator wait or enqueues it.
336
+ */
337
+ private onMessage(raw: string): void {
338
+ let msg: ServerMessage
339
+ try {
340
+ msg = JSON.parse(raw) as ServerMessage
341
+ } catch {
342
+ return
343
+ }
344
+
345
+ const chunk = this.mapMessageToChunk(msg)
346
+ if (!chunk) return
347
+
348
+ if (this.resolveNext) {
349
+ this.resolveNext(chunk)
350
+ this.resolveNext = null
351
+ } else {
352
+ this.chunkQueue.push(chunk)
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Map a ServerMessage from the daemon to a StreamChunk.
358
+ * Returns null for message types that should be silently consumed
359
+ * (e.g., session_state which is only informative for attach).
360
+ */
361
+ private mapMessageToChunk(msg: ServerMessage): StreamChunk | null {
362
+ switch (msg.type) {
363
+ case 'text': {
364
+ return { type: 'text', content: msg.content }
365
+ }
366
+
367
+ case 'tool_use': {
368
+ return {
369
+ type: 'tool_use',
370
+ toolUse: {
371
+ type: 'tool_use' as const,
372
+ name: msg.toolName,
373
+ input: msg.toolInput,
374
+ id: msg.toolId,
375
+ },
376
+ }
377
+ }
378
+
379
+ case 'tool_result': {
380
+ return {
381
+ type: 'tool_result',
382
+ tool_use_id: msg.toolId,
383
+ content: msg.content,
384
+ }
385
+ }
386
+
387
+ case 'usage': {
388
+ return {
389
+ type: 'usage',
390
+ inputTokens: msg.inputTokens,
391
+ outputTokens: msg.outputTokens,
392
+ }
393
+ }
394
+
395
+ case 'task_notification': {
396
+ return {
397
+ type: 'task_notification',
398
+ taskNotification: {
399
+ taskId: msg.taskId,
400
+ status: msg.status as 'started' | 'completed' | 'failed',
401
+ description: '',
402
+ },
403
+ }
404
+ }
405
+
406
+ case 'done': {
407
+ return { type: 'stop' }
408
+ }
409
+
410
+ case 'error': {
411
+ return { type: 'error', error: msg.message }
412
+ }
413
+
414
+ // session_state is an informational message sent when a client
415
+ // first attaches. It is not part of the prompt stream.
416
+ case 'session_state':
417
+ return null
418
+
419
+ default:
420
+ return null
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Check whether the stream should continue after yielding a chunk.
426
+ * Returns `false` for terminal chunk types (stop, error).
427
+ */
428
+ private yieldAndCheck(chunk: StreamChunk): boolean {
429
+ if (chunk.type === 'stop' || chunk.type === 'error') {
430
+ return false
431
+ }
432
+ return true
433
+ }
434
+ }
@@ -0,0 +1,159 @@
1
+ // apps/cli/src/daemon/schedule-manager.ts — Phase 4: Schedule service layer
2
+ // Manages cron-based scheduled prompts: create, list, delete, and automatic firing.
3
+ // Delegates persistence to DaemonDatabase; uses WorkerPool for prompt execution.
4
+
5
+ import type { DaemonDatabase } from './database'
6
+ import type { WorkerPool } from './worker-pool'
7
+ import type { DaemonSchedule } from './types'
8
+
9
+ // Simple minute-granularity cron expression parser.
10
+ //
11
+ // Supports the standard 5-field cron format:
12
+ // minute hour day-of-month month day-of-week
13
+ //
14
+ // Handles: * (wildcard), star/N (step), N (exact), N-M (range), N,M,O (list).
15
+ // Falls back to +1 minute if the expression is invalid or no match is found
16
+ // within 366 days.
17
+ function computeNextFire(cronExpr: string, from: Date): string {
18
+ const fields = cronExpr.trim().split(/\s+/)
19
+ if (fields.length !== 5) {
20
+ return new Date(from.getTime() + 60_000).toISOString()
21
+ }
22
+
23
+ const minField = fields[0]!
24
+ const hourField = fields[1]!
25
+ const domField = fields[2]!
26
+ const monthField = fields[3]!
27
+ const dowField = fields[4]!
28
+
29
+ function matches(value: number, field: string): boolean {
30
+ if (field === '*') return true
31
+ // */N step
32
+ const stepMatch = field.match(/^\*\/(\d+)$/)
33
+ if (stepMatch) {
34
+ return value % parseInt(stepMatch[1]!, 10) === 0
35
+ }
36
+ // Comma-separated list
37
+ if (field.includes(',')) {
38
+ return field.split(',').some((f) => matches(value, f))
39
+ }
40
+ // Range
41
+ const rangeMatch = field.match(/^(\d+)-(\d+)$/)
42
+ if (rangeMatch) {
43
+ const low = parseInt(rangeMatch[1]!, 10)
44
+ const high = parseInt(rangeMatch[2]!, 10)
45
+ return value >= low && value <= high
46
+ }
47
+ // Exact value
48
+ return value === parseInt(field, 10)
49
+ }
50
+
51
+ const date = new Date(from)
52
+ date.setSeconds(0, 0)
53
+ // Start from the next minute to avoid matching the current minute
54
+ date.setMinutes(date.getMinutes() + 1)
55
+
56
+ // Try up to 366 days forward (safety limit — ~527k iterations)
57
+ const maxIterations = 366 * 24 * 60
58
+ for (let i = 0; i < maxIterations; i++) {
59
+ const minute = date.getMinutes()
60
+ const hour = date.getHours()
61
+ const dom = date.getDate()
62
+ const month = date.getMonth() + 1 // JS months are 0-indexed
63
+ const dow = date.getDay() // 0 = Sunday
64
+
65
+ if (
66
+ matches(minute, minField) &&
67
+ matches(hour, hourField) &&
68
+ matches(dom, domField) &&
69
+ matches(month, monthField) &&
70
+ matches(dow, dowField)
71
+ ) {
72
+ return date.toISOString()
73
+ }
74
+
75
+ date.setMinutes(date.getMinutes() + 1)
76
+ }
77
+
78
+ // Fallback: 1 minute from now
79
+ return new Date(from.getTime() + 60_000).toISOString()
80
+ }
81
+
82
+ export class ScheduleManager {
83
+ private db: DaemonDatabase
84
+ private pool: WorkerPool
85
+ private intervalId: Timer | null = null
86
+
87
+ constructor(db: DaemonDatabase, pool: WorkerPool) {
88
+ this.db = db
89
+ this.pool = pool
90
+ }
91
+
92
+ createSchedule(sessionId: string, cronExpr: string, prompt: string): number {
93
+ const now = new Date()
94
+ const nextFire = computeNextFire(cronExpr, now)
95
+ return this.db.createSchedule({
96
+ sessionId,
97
+ cronExpr,
98
+ prompt,
99
+ enabled: true,
100
+ lastFired: null,
101
+ nextFire,
102
+ })
103
+ }
104
+
105
+ getSchedules(sessionId: string): DaemonSchedule[] {
106
+ return this.db.getSchedules(sessionId)
107
+ }
108
+
109
+ deleteSchedule(id: number): void {
110
+ this.db.deleteSchedule(id)
111
+ }
112
+
113
+ /**
114
+ * Check for due schedules and fire them.
115
+ *
116
+ * For each schedule whose nextFire has passed:
117
+ * 1. Look up the session worker — skip if none is active
118
+ * 2. Send the prompt to the worker (fire-and-forget)
119
+ * 3. Update lastFired and compute the next fire time
120
+ */
121
+ checkAndFire(): void {
122
+ const due = this.db.getDueSchedules()
123
+ for (const schedule of due) {
124
+ const worker = this.pool.getWorker(schedule.sessionId)
125
+ if (!worker) continue
126
+
127
+ worker.processPrompt(schedule.prompt).catch((err: unknown) => {
128
+ console.error(`ScheduleManager: error firing schedule ${schedule.id}:`, err)
129
+ })
130
+
131
+ const now = new Date()
132
+ const nextFire = computeNextFire(schedule.cronExpr, now)
133
+ this.db.updateScheduleFireTimes(schedule.id, now.toISOString(), nextFire)
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Start the schedule-checking interval.
139
+ * Checks every 60 seconds. Timer is unref'd so it does not prevent
140
+ * the process from exiting when all other work is done.
141
+ */
142
+ start(): void {
143
+ if (this.intervalId) return
144
+ this.intervalId = setInterval(() => {
145
+ this.checkAndFire()
146
+ }, 60_000)
147
+ this.intervalId.unref()
148
+ }
149
+
150
+ /**
151
+ * Stop the schedule-checking interval.
152
+ */
153
+ stop(): void {
154
+ if (this.intervalId) {
155
+ clearInterval(this.intervalId)
156
+ this.intervalId = null
157
+ }
158
+ }
159
+ }