@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,418 @@
1
+ // apps/cli/src/daemon/session-worker.ts — Phase 2 Core: Headless Prompt Processing
2
+ //
3
+ // SessionWorker wraps a QueryEngine and provides headless prompt processing
4
+ // with WebSocket streaming to attached clients. It is the central bridge
5
+ // between the HTTP/WS server and the AI query engine.
6
+ //
7
+ // Architecture:
8
+ // SessionWorker
9
+ // owns QueryEngine (AI processing — async generator)
10
+ // owns DaemonDatabase reference (persistence — SQLite)
11
+ // owns DaemonSession record (session metadata)
12
+ // tracks WebSocket clients (real-time streaming to N viewers)
13
+ //
14
+ // Lifecycle:
15
+ // 1. Created when a session becomes active (on first attach or first prompt)
16
+ // 2. processPrompt() streams engine output to all WS clients
17
+ // 3. interrupt() aborts in-progress generation
18
+ // 4. getSessionState() serves attaching clients with full context
19
+ // 5. saveToDatabase() persists full conversation on close/checkpoint
20
+
21
+ import type { ServerWebSocket } from 'bun'
22
+ import type { QueryEngine } from '../core/engine'
23
+ import type { DaemonDatabase } from './database'
24
+ import type { DaemonSession, MessageRecord } from './types'
25
+ import type { StreamChunk } from '../shared/types'
26
+ import type {
27
+ ClientInterruptMessage,
28
+ ServerMessage,
29
+ ServerTextMessage,
30
+ ServerToolUseMessage,
31
+ ServerToolResultMessage,
32
+ ServerUsageMessage,
33
+ ServerTaskNotificationMessage,
34
+ ServerDoneMessage,
35
+ ServerErrorMessage,
36
+ ServerSessionStateMessage,
37
+ } from './attach-protocol'
38
+
39
+ // ── Session State Snapshot ────────────────────────────────────────────────
40
+
41
+ export interface SessionStateSnapshot {
42
+ sessionId: string
43
+ name: string
44
+ provider: string
45
+ model: string
46
+ turnCount: number
47
+ status: string
48
+ /** Last N messages for context restoration on attach. */
49
+ messages: MessageRecord[]
50
+ }
51
+
52
+ // ── SessionWorker ─────────────────────────────────────────────────────────
53
+
54
+ export class SessionWorker {
55
+ private engine: QueryEngine
56
+ private db: DaemonDatabase
57
+ private session: DaemonSession
58
+ private clients: Set<ServerWebSocket<unknown>> = new Set()
59
+ private currentController: AbortController | null = null
60
+ private processing = false
61
+
62
+ constructor(engine: QueryEngine, db: DaemonDatabase, session: DaemonSession) {
63
+ this.engine = engine
64
+ this.db = db
65
+ this.session = session
66
+ }
67
+
68
+ // ── Client Management ──────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Add a WebSocket client to this session's broadcast set.
72
+ * Sends a session_state snapshot to the newly attached client immediately.
73
+ */
74
+ addClient(ws: ServerWebSocket<unknown>): void {
75
+ this.clients.add(ws)
76
+ this.sendState(ws)
77
+ }
78
+
79
+ /** Remove a WebSocket client from this session's broadcast set. */
80
+ removeClient(ws: ServerWebSocket<unknown>): void {
81
+ this.clients.delete(ws)
82
+ }
83
+
84
+ /** Number of currently attached WebSocket clients. */
85
+ getClientCount(): number {
86
+ return this.clients.size
87
+ }
88
+
89
+ /** Whether a prompt is currently being processed. */
90
+ isProcessing(): boolean {
91
+ return this.processing
92
+ }
93
+
94
+ // ── Prompt Processing ──────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Process a user prompt through the QueryEngine, streaming results to all
98
+ * attached WebSocket clients and persisting messages to the database.
99
+ *
100
+ * Flow:
101
+ * 1. Guard: reject if already processing
102
+ * 2. Save user message to DB
103
+ * 3. Call engine.process(prompt, signal) — async generator
104
+ * 4. Map each StreamChunk → ServerMessage and broadcast to all clients
105
+ * 5. On natural completion: save assistant content to DB, increment turn,
106
+ * broadcast `done`
107
+ * 6. On error: broadcast `error` to clients, still increment turn
108
+ * 7. On interrupt: save partial content, broadcast `done` with stop_reason
109
+ * 'interrupted'
110
+ */
111
+ async processPrompt(prompt: string): Promise<void> {
112
+ if (this.processing) {
113
+ this.broadcast(this.errorMessage('A prompt is already being processed'))
114
+ return
115
+ }
116
+
117
+ this.processing = true
118
+ this.currentController = new AbortController()
119
+ const signal = this.currentController.signal
120
+
121
+ // Step 1: Persist the user message immediately
122
+ this.db.saveMessage(this.session.id, 'user', JSON.stringify({ role: 'user', content: prompt }))
123
+
124
+ // Step 2-3: Stream engine output to clients
125
+ let assistantContent = ''
126
+ let totalInputTokens = 0
127
+ let totalOutputTokens = 0
128
+ let stopReason: string = 'end_turn'
129
+
130
+ try {
131
+ for await (const chunk of this.engine.process(prompt, signal)) {
132
+ // Check for abort between chunks
133
+ if (signal.aborted) {
134
+ stopReason = 'interrupted'
135
+ break
136
+ }
137
+
138
+ // Map the engine StreamChunk to the appropriate ServerMessage and broadcast
139
+ const serverMsg = this.mapChunkToMessage(chunk)
140
+ if (serverMsg) {
141
+ this.broadcast(serverMsg)
142
+ }
143
+
144
+ // Accumulate assistant text content (used for final DB write)
145
+ if (chunk.type === 'text' && chunk.content) {
146
+ assistantContent += chunk.content
147
+ }
148
+
149
+ // Accumulate API-reported token usage (used for DB incrementTurn)
150
+ if (chunk.type === 'usage') {
151
+ if (chunk.inputTokens) totalInputTokens += chunk.inputTokens
152
+ if (chunk.outputTokens) totalOutputTokens += chunk.outputTokens
153
+ }
154
+
155
+ // Stop signal from engine — includes 'stop' and 'error' types
156
+ if (chunk.type === 'stop') {
157
+ break
158
+ }
159
+ }
160
+ } catch (err) {
161
+ stopReason = 'error'
162
+ this.broadcast(this.errorMessage(err instanceof Error ? err.message : String(err)))
163
+ }
164
+
165
+ // ── Check interrupt after catch (catch won't fire for AbortError in async generators) ──
166
+ if (signal.aborted && stopReason !== 'error') {
167
+ stopReason = 'interrupted'
168
+ }
169
+
170
+ // Step 4: Persist assistant response and finalize
171
+ if (assistantContent) {
172
+ // Save the final (or partial) assistant message
173
+ this.db.saveMessage(
174
+ this.session.id,
175
+ 'assistant',
176
+ JSON.stringify({ role: 'assistant', content: assistantContent }),
177
+ )
178
+ }
179
+
180
+ // Increment turn count with accumulated token usage
181
+ this.db.incrementTurn(this.session.id, totalInputTokens, totalOutputTokens)
182
+
183
+ // Refresh session metadata from DB so turnCount/tokenIn/tokenOut are up to date
184
+ const refreshed = this.db.getSession(this.session.id)
185
+ if (refreshed) {
186
+ this.session = refreshed
187
+ }
188
+
189
+ // Broadcast completion
190
+ const doneMsg: ServerDoneMessage = {
191
+ type: 'done',
192
+ sessionId: this.session.id,
193
+ stopReason,
194
+ }
195
+ this.broadcast(doneMsg)
196
+
197
+ this.currentController = null
198
+ this.processing = false
199
+ }
200
+
201
+ // ── Interrupt ──────────────────────────────────────────────────────────
202
+
203
+ /**
204
+ * Abort the current prompt processing (if any).
205
+ * Called when a client sends an interrupt message or when the session
206
+ * is being force-closed.
207
+ */
208
+ interrupt(): void {
209
+ if (this.currentController) {
210
+ this.currentController.abort()
211
+ this.currentController = null
212
+ }
213
+ }
214
+
215
+ /** Handle an interrupt message from a WebSocket client. */
216
+ handleClientInterrupt(_msg: ClientInterruptMessage): void {
217
+ this.interrupt()
218
+ }
219
+
220
+ // ── Session State ──────────────────────────────────────────────────────
221
+
222
+ /**
223
+ * Return session state including messages, provider, model, and turn count.
224
+ * Used by attaching clients to restore their conversation context.
225
+ */
226
+ getSessionState(): SessionStateSnapshot {
227
+ const messages = this.db.getMessages(this.session.id, 100)
228
+ return {
229
+ sessionId: this.session.id,
230
+ name: this.session.name,
231
+ provider: this.session.provider,
232
+ model: this.session.model,
233
+ turnCount: this.session.turnCount,
234
+ status: this.session.status,
235
+ messages,
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Return the session metadata record, refreshed from the database
241
+ * to ensure turn count and token totals are current.
242
+ */
243
+ getSession(): DaemonSession {
244
+ const fresh = this.db.getSession(this.session.id)
245
+ if (fresh) {
246
+ this.session = fresh
247
+ }
248
+ return this.session
249
+ }
250
+
251
+ /** Return the session ID for routing. */
252
+ getSessionId(): string {
253
+ return this.session.id
254
+ }
255
+
256
+ // ── Persistence ────────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * Persist the full conversation state to the database.
260
+ *
261
+ * Syncs messages from the engine's ContextManager into the messages table,
262
+ * avoiding duplicates by comparing message content hashes. Also updates
263
+ * the session status in the sessions table.
264
+ *
265
+ * Called on session close or periodic checkpoint.
266
+ */
267
+ saveToDatabase(): void {
268
+ const ctxMessages = this.engine.getContext().getMessages()
269
+ const dbMessages = this.db.getMessages(this.session.id, 10000)
270
+
271
+ // Build a set of already-persisted message keys
272
+ const existingContents = new Set(dbMessages.map((m) => this.messageKey(m.role, m.content)))
273
+
274
+ for (const msg of ctxMessages) {
275
+ const role = msg.role
276
+ const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
277
+ const key = this.messageKey(role, content)
278
+ if (!existingContents.has(key)) {
279
+ this.db.saveMessage(this.session.id, role, content)
280
+ existingContents.add(key)
281
+ }
282
+ }
283
+
284
+ // Sync session status
285
+ this.db.updateSessionStatus(this.session.id, this.session.status)
286
+ }
287
+
288
+ // ── StreamChunk → ServerMessage Mapping ────────────────────────────────
289
+
290
+ /**
291
+ * Map an engine StreamChunk to the corresponding typed ServerMessage
292
+ * from the attach protocol. Returns null for chunk types that don't
293
+ * need to be broadcast (e.g., intermediate internal signals).
294
+ */
295
+ private mapChunkToMessage(chunk: StreamChunk): ServerMessage | null {
296
+ switch (chunk.type) {
297
+ case 'text': {
298
+ const msg: ServerTextMessage = {
299
+ type: 'text',
300
+ sessionId: this.session.id,
301
+ content: chunk.content ?? '',
302
+ }
303
+ return msg
304
+ }
305
+
306
+ case 'tool_use': {
307
+ if (!chunk.toolUse) return null
308
+ const msg: ServerToolUseMessage = {
309
+ type: 'tool_use',
310
+ sessionId: this.session.id,
311
+ toolName: chunk.toolUse.name,
312
+ toolInput: chunk.toolUse.input,
313
+ toolId: chunk.toolUse.id,
314
+ }
315
+ return msg
316
+ }
317
+
318
+ case 'tool_result': {
319
+ const msg: ServerToolResultMessage = {
320
+ type: 'tool_result',
321
+ sessionId: this.session.id,
322
+ toolId: chunk.tool_use_id ?? 'unknown',
323
+ content: chunk.content ?? '',
324
+ }
325
+ return msg
326
+ }
327
+
328
+ case 'usage': {
329
+ const msg: ServerUsageMessage = {
330
+ type: 'usage',
331
+ sessionId: this.session.id,
332
+ inputTokens: chunk.inputTokens ?? 0,
333
+ outputTokens: chunk.outputTokens ?? 0,
334
+ }
335
+ return msg
336
+ }
337
+
338
+ case 'task_notification': {
339
+ if (!chunk.taskNotification) return null
340
+ const msg: ServerTaskNotificationMessage = {
341
+ type: 'task_notification',
342
+ sessionId: this.session.id,
343
+ taskId: chunk.taskNotification.taskId,
344
+ status: chunk.taskNotification.status,
345
+ }
346
+ return msg
347
+ }
348
+
349
+ case 'error': {
350
+ const msg: ServerErrorMessage = {
351
+ type: 'error',
352
+ sessionId: this.session.id,
353
+ message: chunk.error ?? 'Unknown error',
354
+ }
355
+ return msg
356
+ }
357
+
358
+ // 'stop' is handled by processPrompt() which sends ServerDoneMessage
359
+ // 'thinking' is internal — model reasoning, not broadcast separately
360
+ case 'stop':
361
+ case 'thinking':
362
+ default:
363
+ return null
364
+ }
365
+ }
366
+
367
+ // ── Internal Helpers ───────────────────────────────────────────────────
368
+
369
+ /** Broadcast a ServerMessage to all attached WebSocket clients. */
370
+ private broadcast(msg: ServerMessage): void {
371
+ const raw = JSON.stringify(msg)
372
+ for (const ws of this.clients) {
373
+ try {
374
+ ws.send(raw)
375
+ } catch {
376
+ // Client disconnected — eager cleanup (the server's WS close handler
377
+ // will also remove it properly, this just avoids future send attempts).
378
+ this.clients.delete(ws)
379
+ }
380
+ }
381
+ }
382
+
383
+ /** Send the current session state snapshot to a single WebSocket client. */
384
+ private sendState(ws: ServerWebSocket<unknown>): void {
385
+ const state = this.getSessionState()
386
+ const msg: ServerSessionStateMessage = {
387
+ type: 'session_state',
388
+ sessionId: this.session.id,
389
+ messages: state.messages,
390
+ provider: state.provider,
391
+ model: state.model,
392
+ turnCount: state.turnCount,
393
+ }
394
+ try {
395
+ ws.send(JSON.stringify(msg))
396
+ } catch {
397
+ this.clients.delete(ws)
398
+ }
399
+ }
400
+
401
+ /** Build an error ServerMessage for the current session. */
402
+ private errorMessage(message: string): ServerErrorMessage {
403
+ return {
404
+ type: 'error',
405
+ sessionId: this.session.id,
406
+ message,
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Generate a stable key for message deduplication.
412
+ * Uses role + first 200 chars of content to avoid storing duplicate messages
413
+ * while being tolerant of minor content variation.
414
+ */
415
+ private messageKey(role: string, content: string): string {
416
+ return `${role}:${content.slice(0, 200)}`
417
+ }
418
+ }
@@ -0,0 +1,98 @@
1
+ // apps/cli/src/daemon/types.ts
2
+
3
+ export type SessionStatus = 'active' | 'idle' | 'compacting' | 'closed'
4
+ export type AgentStatus = 'running' | 'completed' | 'failed'
5
+ export type AgentKind = 'interactive' | 'forked' | 'attached' | 'unattended'
6
+ export type GoalStatus = 'active' | 'completed' | 'paused' | 'cleared'
7
+
8
+ export interface DaemonSession {
9
+ id: string
10
+ name: string
11
+ cwd: string
12
+ provider: string
13
+ model: string
14
+ status: SessionStatus
15
+ createdAt: string
16
+ updatedAt: string
17
+ closedAt: string | null
18
+ turnCount: number
19
+ tokenIn: number
20
+ tokenOut: number
21
+ }
22
+
23
+ export interface CreateSessionInput {
24
+ name: string
25
+ cwd: string
26
+ provider: string
27
+ model: string
28
+ }
29
+
30
+ export interface DaemonAgent {
31
+ id: string
32
+ sessionId: string
33
+ parentId: string | null
34
+ agentType: string
35
+ description: string
36
+ status: AgentStatus
37
+ kind: AgentKind
38
+ worktree: string | null
39
+ branch: string | null
40
+ prUrl: string | null
41
+ createdAt: string
42
+ completedAt: string | null
43
+ result: string | null
44
+ error: string | null
45
+ }
46
+
47
+ export interface DaemonGoal {
48
+ id: number
49
+ sessionId: string
50
+ description: string
51
+ status: GoalStatus
52
+ progress: { current: number; total: number; note?: string } | null
53
+ createdAt: string
54
+ updatedAt: string
55
+ }
56
+
57
+ export interface DaemonSchedule {
58
+ id: number
59
+ sessionId: string
60
+ cronExpr: string
61
+ prompt: string
62
+ enabled: boolean
63
+ lastFired: string | null
64
+ nextFire: string
65
+ }
66
+
67
+ export interface DaemonStatus {
68
+ pid: number
69
+ port: number
70
+ uptime: number // seconds since start
71
+ activeSessions: number
72
+ totalSessions: number
73
+ activeAgents: number
74
+ version: string
75
+ }
76
+
77
+ export interface MessageRecord {
78
+ id: number
79
+ sessionId: string
80
+ role: 'user' | 'assistant' | 'system' | 'tool'
81
+ content: string // JSON-serialized Message object
82
+ createdAt: string
83
+ }
84
+
85
+ // HTTP API types
86
+ export interface ApiResponse<T> {
87
+ ok: boolean
88
+ data?: T
89
+ error?: string
90
+ }
91
+
92
+ export interface CreateSessionResponse {
93
+ session: DaemonSession
94
+ }
95
+
96
+ export interface ListSessionsResponse {
97
+ sessions: DaemonSession[]
98
+ }
@@ -0,0 +1,174 @@
1
+ // apps/cli/src/daemon/worker-pool.ts — Phase 2: Worker Pool Management
2
+ //
3
+ // WorkerPool manages the lifecycle of SessionWorker instances across all
4
+ // active sessions in the daemon. It provides lazy creation, idle timeout
5
+ // (auto-stop inactive workers), and graceful shutdown.
6
+ //
7
+ // Architecture:
8
+ // WorkerPool
9
+ // owns Map<string, SessionWorker> (active workers)
10
+ // owns Map<string, Timer> (idle timeouts)
11
+ // references DaemonDatabase (session metadata + persistence)
12
+ //
13
+ // Lifecycle:
14
+ // 1. createWorker() — creates or returns cached SessionWorker, starts idle timer
15
+ // 2. getWorker() — lookup by sessionId
16
+ // 3. resetIdleTimeout() — called on each prompt to keep worker alive
17
+ // 4. stopWorker() — interrupt, save, remove, mark idle, callback
18
+ // 5. stopAll() — graceful shutdown of all workers
19
+
20
+ import { SessionWorker } from './session-worker'
21
+ import type { DaemonDatabase } from './database'
22
+ import type { QueryEngine } from '../core/engine'
23
+ import type { ContextManager } from '../core/context'
24
+ import type { ProviderRegistry } from '../providers/registry'
25
+
26
+ export class WorkerPool {
27
+ private workers: Map<string, SessionWorker>
28
+ private db: DaemonDatabase
29
+ private idleTimeouts: Map<string, Timer>
30
+ private readonly idleTimeoutMs: number
31
+
32
+ /** Called when a worker is stopped (either explicitly or by idle timeout). */
33
+ onSessionClosed?: (sessionId: string) => void
34
+
35
+ constructor(db: DaemonDatabase, idleTimeoutMs: number = 30 * 60 * 1000) {
36
+ this.workers = new Map()
37
+ this.db = db
38
+ this.idleTimeouts = new Map()
39
+ this.idleTimeoutMs = idleTimeoutMs
40
+ }
41
+
42
+ /**
43
+ * Create a SessionWorker for the given session, or return an existing one.
44
+ *
45
+ * The engine, context, and registry are accepted for validation and
46
+ * extensibility; the SessionWorker itself is constructed from engine + db
47
+ * + DaemonSession (fetched from the database by sessionId).
48
+ *
49
+ * Starts the idle timeout on creation. Callers should invoke
50
+ * {@link resetIdleTimeout} on each prompt to keep the worker alive.
51
+ */
52
+ createWorker(
53
+ sessionId: string,
54
+ engine: QueryEngine,
55
+ _context: ContextManager,
56
+ _registry: ProviderRegistry,
57
+ ): SessionWorker {
58
+ // Return cached worker if it exists
59
+ const existing = this.workers.get(sessionId)
60
+ if (existing) {
61
+ return existing
62
+ }
63
+
64
+ // Look up session metadata from the database
65
+ const session = this.db.getSession(sessionId)
66
+ if (!session) {
67
+ throw new Error(
68
+ `WorkerPool: cannot create worker for session ${sessionId} — not found in database`,
69
+ )
70
+ }
71
+
72
+ const worker = new SessionWorker(engine, this.db, session)
73
+ this.workers.set(sessionId, worker)
74
+ this.resetIdleTimeout(sessionId)
75
+ return worker
76
+ }
77
+
78
+ /** Get the SessionWorker for a session, or undefined if not active. */
79
+ getWorker(sessionId: string): SessionWorker | undefined {
80
+ return this.workers.get(sessionId)
81
+ }
82
+
83
+ /** Check whether a worker is active for the given session. */
84
+ hasWorker(sessionId: string): boolean {
85
+ return this.workers.has(sessionId)
86
+ }
87
+
88
+ /** List all currently active session IDs. */
89
+ getActiveSessions(): string[] {
90
+ return Array.from(this.workers.keys())
91
+ }
92
+
93
+ /** Number of currently active workers. */
94
+ get size(): number {
95
+ return this.workers.size
96
+ }
97
+
98
+ /**
99
+ * Stop a worker: interrupt any in-progress generation, persist state,
100
+ * mark the session as idle in the database, remove from pool, clear
101
+ * the idle timeout, and fire the onSessionClosed callback.
102
+ */
103
+ async stopWorker(sessionId: string): Promise<void> {
104
+ const worker = this.workers.get(sessionId)
105
+ if (!worker) return
106
+
107
+ // Interrupt any in-progress prompt processing
108
+ worker.interrupt()
109
+
110
+ // Persist the full conversation state to the database
111
+ worker.saveToDatabase()
112
+
113
+ // Mark the session as idle (not closed) — the session record
114
+ // remains available for future re-attach or resume
115
+ this.db.updateSessionStatus(sessionId, 'idle')
116
+
117
+ // Remove from the active worker map
118
+ this.workers.delete(sessionId)
119
+
120
+ // Clear the idle timeout
121
+ const timeout = this.idleTimeouts.get(sessionId)
122
+ if (timeout) {
123
+ clearTimeout(timeout)
124
+ this.idleTimeouts.delete(sessionId)
125
+ }
126
+
127
+ // Fire the lifecycle callback
128
+ if (this.onSessionClosed) {
129
+ this.onSessionClosed(sessionId)
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Reset the idle timeout for a session.
135
+ *
136
+ * Call this on every user prompt so the worker stays alive as long as
137
+ * the user is actively interacting. If no prompt arrives within
138
+ * `idleTimeoutMs`, the worker is automatically stopped to free resources.
139
+ */
140
+ resetIdleTimeout(sessionId: string): void {
141
+ // Clear the existing timeout if there is one
142
+ const existing = this.idleTimeouts.get(sessionId)
143
+ if (existing) {
144
+ clearTimeout(existing)
145
+ }
146
+
147
+ // Set a new timeout — when it fires, auto-stop the worker
148
+ const timeout = setTimeout(() => {
149
+ this.stopWorker(sessionId).catch((err) => {
150
+ // Log but never crash — this runs in a Timer callback outside
151
+ // any request context
152
+ console.error(`WorkerPool: error auto-stopping idle worker for session ${sessionId}:`, err)
153
+ })
154
+ }, this.idleTimeoutMs)
155
+
156
+ // unref so the timer does not prevent the Node/Bun process from
157
+ // exiting when all other work is complete
158
+ timeout.unref()
159
+
160
+ this.idleTimeouts.set(sessionId, timeout)
161
+ }
162
+
163
+ /**
164
+ * Gracefully stop all workers.
165
+ *
166
+ * Iterates over all active workers, interrupts them, persists their
167
+ * state, and cleans up. Runs all stops in parallel since workers
168
+ * are independent.
169
+ */
170
+ async stopAll(): Promise<void> {
171
+ const sessionIds = Array.from(this.workers.keys())
172
+ await Promise.all(sessionIds.map((id) => this.stopWorker(id)))
173
+ }
174
+ }