@geekbeer/minion 4.5.1 → 4.7.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.
@@ -24,8 +24,8 @@ const todoStore = require('../../core/stores/todo-store')
24
24
  const { DATA_DIR } = require('../../core/lib/platform')
25
25
  const { runEndOfDay } = require('../../core/lib/end-of-day')
26
26
  const { getActivePrimary } = require('../../core/llm-plugins/lib/active')
27
+ const chatRunManager = require('../../core/lib/chat-run-manager')
27
28
 
28
- let activeChatChild = null
29
29
  let wslModeActive = false
30
30
 
31
31
  // ---------------------------------------------------------------------------
@@ -113,6 +113,9 @@ function proxyWslChatAbort() {
113
113
  }
114
114
 
115
115
  async function chatRoutes(fastify) {
116
+ // Sweep stale runs from a previous boot and prune old run logs.
117
+ chatRunManager.init()
118
+
116
119
  fastify.post('/api/chat', async (request, reply) => {
117
120
  if (!verifyToken(request)) {
118
121
  reply.code(401)
@@ -146,30 +149,79 @@ async function chatRoutes(fastify) {
146
149
  }
147
150
  }
148
151
 
152
+ // WSL mode proxies to the WSL session server, which owns its own process
153
+ // lifecycle — leave that path untouched. Non-WSL mode uses the DETACHED run
154
+ // manager so a dropped connection no longer kills the LLM.
155
+ if (wsl_mode) {
156
+ reply.hijack()
157
+ reply.raw.writeHead(200, {
158
+ 'Content-Type': 'text/event-stream',
159
+ 'Cache-Control': 'no-cache',
160
+ 'Connection': 'keep-alive',
161
+ })
162
+ reply.raw.flushHeaders()
163
+ try {
164
+ wslModeActive = true
165
+ console.log('[Chat] WSL mode enabled — proxying to WSL session server')
166
+ await proxyWslChat(reply.raw, prompt, currentSessionId)
167
+ } catch (err) {
168
+ console.error('[Chat] stream error:', err.message)
169
+ const errorEvent = JSON.stringify({ type: 'error', error: err.message })
170
+ reply.raw.write(`data: ${errorEvent}\n\n`)
171
+ } finally {
172
+ wslModeActive = false
173
+ }
174
+ reply.raw.end()
175
+ return
176
+ }
177
+
178
+ const runId = chatRunManager.start({
179
+ sessionId: currentSessionId,
180
+ pendingSessionId,
181
+ workspaceId,
182
+ invoke: buildInvoke(prompt, currentSessionId),
183
+ })
184
+
149
185
  reply.hijack()
150
- reply.raw.writeHead(200, {
186
+ const res = reply.raw
187
+ res.writeHead(200, {
151
188
  'Content-Type': 'text/event-stream',
152
189
  'Cache-Control': 'no-cache',
153
190
  'Connection': 'keep-alive',
154
191
  })
155
- reply.raw.flushHeaders()
192
+ res.flushHeaders()
193
+ res.write(`data: ${JSON.stringify({ type: 'run', run_id: runId, session_id: currentSessionId })}\n\n`)
194
+ await tailRunToResponse(res, runId, 0)
195
+ res.end()
196
+ })
156
197
 
157
- try {
158
- if (wsl_mode) {
159
- wslModeActive = true
160
- console.log('[Chat] WSL mode enabled — proxying to WSL session server')
161
- await proxyWslChat(reply.raw, prompt, currentSessionId)
162
- wslModeActive = false
163
- } else {
164
- await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message, pendingSessionId)
165
- }
166
- } catch (err) {
167
- wslModeActive = false
168
- console.error('[Chat] stream error:', err.message)
169
- const errorEvent = JSON.stringify({ type: 'error', error: err.message })
170
- reply.raw.write(`data: ${errorEvent}\n\n`)
198
+ // GET /api/chat/stream - Reconnect to an in-flight (or recently finished) run
199
+ // and resume tailing from `cursor`. (Detached, non-WSL runs only.)
200
+ fastify.get('/api/chat/stream', async (request, reply) => {
201
+ if (!verifyToken(request)) {
202
+ reply.code(401)
203
+ return { success: false, error: 'Unauthorized' }
171
204
  }
172
- reply.raw.end()
205
+
206
+ const workspaceId = request.query?.workspace_id || null
207
+ const runId = request.query?.run_id || chatRunManager.getActiveRunId(workspaceId)
208
+ const cursor = parseInt(request.query?.cursor, 10) || 0
209
+ if (!runId) {
210
+ reply.code(404)
211
+ return { success: false, error: 'No active run' }
212
+ }
213
+
214
+ reply.hijack()
215
+ const res = reply.raw
216
+ res.writeHead(200, {
217
+ 'Content-Type': 'text/event-stream',
218
+ 'Cache-Control': 'no-cache',
219
+ 'Connection': 'keep-alive',
220
+ })
221
+ res.flushHeaders()
222
+ res.write(`data: ${JSON.stringify({ type: 'run', run_id: runId })}\n\n`)
223
+ await tailRunToResponse(res, runId, cursor)
224
+ res.end()
173
225
  })
174
226
 
175
227
  fastify.get('/api/chat/session', async (request, reply) => {
@@ -179,7 +231,9 @@ async function chatRoutes(fastify) {
179
231
  }
180
232
  const workspaceId = request.query?.workspace_id || null
181
233
  const session = chatStore.load(workspaceId)
182
- if (!session) return { success: true, session: null }
234
+ const activeRunId = chatRunManager.getActiveRunId(workspaceId)
235
+ const activeRun = activeRunId ? chatRunManager.getRunInfo(activeRunId) : null
236
+ if (!session) return { success: true, session: null, active_run: activeRun }
183
237
  return {
184
238
  success: true,
185
239
  session: {
@@ -190,6 +244,7 @@ async function chatRoutes(fastify) {
190
244
  created_at: session.created_at,
191
245
  updated_at: session.updated_at,
192
246
  },
247
+ active_run: activeRun,
193
248
  }
194
249
  })
195
250
 
@@ -244,20 +299,16 @@ async function chatRoutes(fastify) {
244
299
  return proxyWslChatAbort()
245
300
  }
246
301
 
247
- if (!activeChatChild) {
302
+ const workspaceId = request.body?.workspace_id || null
303
+ const runId = request.body?.run_id || chatRunManager.getActiveRunId(workspaceId)
304
+ if (!runId) {
248
305
  return { success: false, error: 'No active chat process' }
249
306
  }
250
- console.log(`[Chat] Aborting active chat process PID: ${activeChatChild.pid}`)
251
- activeChatChild.kill('SIGTERM')
252
- const pid = activeChatChild.pid
253
- setTimeout(() => {
254
- try {
255
- if (activeChatChild && activeChatChild.pid === pid) {
256
- activeChatChild.kill('SIGKILL')
257
- }
258
- } catch { /* already dead */ }
259
- }, 2000)
260
- return { success: true }
307
+ const aborted = chatRunManager.abort(runId)
308
+ if (!aborted) {
309
+ return { success: false, error: 'No active chat process' }
310
+ }
311
+ return { success: true, run_id: runId }
261
312
  })
262
313
 
263
314
  // POST /api/chat/reset - Carry over relevant messages and start fresh session
@@ -474,9 +525,11 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
474
525
  'このAPIは内部で Playwright + Readability を回し、抽出済みJSONだけを返すため、',
475
526
  'DOM全体がチャットに流れ込んでトークン肥大化することを防げる。',
476
527
  '初回アクセスで学習したセレクタはSQLiteにキャッシュされ、2回目以降はLLM呼び出しなしで抽出される。',
528
+ 'SPA(クライアント描画)も内部でDOM静止を待ってから抽出するためそのまま利用可。',
529
+ '無限スクロールで件数を増やしたい場合は `"scroll": {"strategy":"count","targetItems":50}` を付ける(どれだけ集めるかは呼び出し側が指定)。',
477
530
  '',
478
- 'Playwright MCP (`mcp__playwright__*`) は **ログイン・フォーム入力・複数画面の対話操作**が必要な場合のみ使用する。',
479
- '単純な閲覧・要約・一覧取得用途ではMCPを使わない。',
531
+ 'Playwright MCP (`mcp__playwright__*`) は **ログイン・フォーム入力・複数画面の対話操作・「もっと見る」ボタン**が必要な場合のみ使用する。',
532
+ '単純な閲覧・要約・一覧取得用途(SPA・無限スクロール含む)ではMCPを使わない。',
480
533
  '',
481
534
  'HQ (`*.minion-agent.com`) のページURLは **Playwrightで開かずAPIで取得する**。ノートは `GET $HQ_URL/api/minion/workspaces/:wsId/notes/:id`、タスクは `GET $HQ_URL/api/minion/projects/:pid/tasks/:id`。チャットで参照されたノート/タスクの本文は上記「参照ノート」「参照チケット」ブロックに既に注入済み。',
482
535
  ''
@@ -627,95 +680,98 @@ function getLlmBinary() {
627
680
  }
628
681
 
629
682
  /**
630
- * Stream LLM CLI output as SSE events.
631
- * Uses --resume to continue existing sessions.
632
- * Tracks block types to correctly forward tool_use vs text events
633
- * and counts turns for session management.
683
+ * Tail a detached run into an SSE response. Subscribes from `cursor`, forwards
684
+ * each event (seq-stamped), and resolves on the terminal `done` event OR when
685
+ * the client disconnects. Disconnecting only stops the tail it never touches
686
+ * the underlying LLM process.
687
+ *
688
+ * @param {import('http').ServerResponse} res
689
+ * @param {string} runId
690
+ * @param {number} cursor last sequence the client already has
634
691
  */
635
- async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
692
+ function tailRunToResponse(res, runId, cursor) {
693
+ return new Promise(resolve => {
694
+ let settled = false
695
+ let unsubscribe = () => {}
696
+ const finish = () => {
697
+ if (settled) return
698
+ settled = true
699
+ try { unsubscribe() } catch { /* ignore */ }
700
+ resolve()
701
+ }
702
+ unsubscribe = chatRunManager.subscribe(runId, cursor, event => {
703
+ try { res.write(`data: ${JSON.stringify(event)}\n\n`) } catch { /* socket gone */ }
704
+ if (event.type === 'done') finish()
705
+ })
706
+ res.on('close', finish)
707
+ })
708
+ }
709
+
710
+ /**
711
+ * Build the LLM executor for a run. The executor emits normalized wire events
712
+ * and returns the collected result; the run manager owns persistence, the
713
+ * terminal `done` event, and abort.
714
+ *
715
+ * @param {string} prompt
716
+ * @param {string|null} sessionId
717
+ * @returns {(emit: (e: object) => void, activeRef: { current: any }) => Promise<{ fullResponse: string, resolvedSessionId: string|null, turnCount: number }>}
718
+ */
719
+ function buildInvoke(prompt, sessionId) {
636
720
  const primary = getActivePrimary()
637
721
  if (primary) {
638
- return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
722
+ return (emit, activeRef) => invokeViaPlugin(primary, prompt, sessionId, emit, activeRef)
639
723
  }
640
- return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
724
+ return (emit, activeRef) => invokeViaLegacy(prompt, sessionId, emit, activeRef)
641
725
  }
642
726
 
643
- async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
727
+ async function invokeViaPlugin(plugin, prompt, sessionId, emit, activeRef) {
644
728
  const input = { prompt }
645
- const activeRef = { current: null }
646
-
647
729
  let fullResponse = ''
648
730
  let resolvedSessionId = sessionId || null
649
731
  let turnCount = 0
650
732
 
651
- const emit = event => {
733
+ const onEvent = event => {
652
734
  if (event.type === 'session') {
653
735
  resolvedSessionId = event.sessionId
654
736
  } else if (event.type === 'delta') {
655
737
  fullResponse += event.content
656
- res.write(`data: ${JSON.stringify({ type: 'delta', content: event.content })}\n\n`)
738
+ emit({ type: 'delta', content: event.content })
657
739
  } else if (event.type === 'text') {
658
740
  fullResponse += event.content
659
- res.write(`data: ${JSON.stringify({ type: 'text', content: event.content })}\n\n`)
741
+ emit({ type: 'text', content: event.content })
660
742
  turnCount++
661
743
  } else {
662
- res.write(`data: ${JSON.stringify(event)}\n\n`)
744
+ emit(event)
663
745
  }
664
746
  }
665
747
 
666
- res.on('close', () => { activeRef.current?.kill?.('SIGTERM') })
667
-
668
- let pluginError = null
669
748
  try {
670
749
  let output
671
750
  if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
672
- output = await plugin.stream(input, emit, { resumeSessionId: sessionId, activeChildRef: activeRef })
751
+ output = await plugin.stream(input, onEvent, { resumeSessionId: sessionId, activeChildRef: activeRef })
673
752
  } else {
674
753
  output = await plugin.invoke(input)
675
754
  if (output.text) {
676
755
  fullResponse = output.text
677
- res.write(`data: ${JSON.stringify({ type: 'text', content: output.text })}\n\n`)
756
+ emit({ type: 'text', content: output.text })
678
757
  turnCount = 1
679
758
  }
680
759
  if (output.error) {
681
- res.write(`data: ${JSON.stringify({ type: 'error', error: output.error.message })}\n\n`)
760
+ emit({ type: 'error', error: output.error.message })
682
761
  }
683
762
  }
684
763
  resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
685
- } catch (err) {
686
- // Swallow here so we can persist any partial response first; rethrow below.
687
- pluginError = err
688
- }
689
-
690
- // For new sessions, the user message was persisted under pendingSessionId
691
- // before the plugin call. Rekey it to the real session ID now that we
692
- // know it. If the plugin never reported a session ID, leave the message
693
- // under the pending key so the history isn't lost.
694
- const persistSessionId = resolvedSessionId || pendingSessionId
695
- try {
696
- if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
697
- chatStore.rekeySession(pendingSessionId, resolvedSessionId)
698
- }
699
- if (fullResponse && persistSessionId) {
700
- await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
764
+ if (output?.error) {
765
+ emit({ type: 'error', error: output.error.message, partial: !!fullResponse })
701
766
  }
702
767
  } catch (err) {
703
- console.error('[Chat] failed to persist assistant message:', err.message)
768
+ emit({ type: 'error', error: err.message, partial: !!fullResponse })
704
769
  }
705
770
 
706
- if (pluginError) throw pluginError
707
-
708
- let totalTurnCount = turnCount
709
- try {
710
- const session = await chatStore.load(workspaceId)
711
- totalTurnCount = session?.turn_count || turnCount
712
- } catch (err) {
713
- console.error('[Chat] failed to load session for done event:', err.message)
714
- }
715
- res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
771
+ return { fullResponse, resolvedSessionId, turnCount }
716
772
  }
717
773
 
718
- function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
774
+ function invokeViaLegacy(prompt, sessionId, emit, activeRef) {
719
775
  return new Promise((resolve, reject) => {
720
776
  const binaryName = getLlmBinary()
721
777
  if (!binaryName) {
@@ -727,8 +783,6 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
727
783
 
728
784
  const args = ['-p', '--verbose', '--model', 'sonnet', '--output-format', 'stream-json']
729
785
  if (sessionId) args.push('--resume', sessionId)
730
- // Prompt is passed via stdin (not as CLI argument) to avoid
731
- // shell argument parsing issues with spaces/special characters.
732
786
 
733
787
  console.log(`[Chat] spawning: ${binary} ${sessionId ? `--resume ${sessionId}` : '(new session)'}`)
734
788
 
@@ -739,19 +793,16 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
739
793
  shell: true, // Required on Windows to resolve .cmd shims (e.g. claude.cmd)
740
794
  })
741
795
 
742
- activeChatChild = child
743
- // Write prompt to stdin and close — claude -p reads from stdin when no positional arg
796
+ // Expose the child to the run manager for abort. NOT tied to any HTTP request.
797
+ activeRef.current = child
744
798
  child.stdin.write(prompt)
745
799
  child.stdin.end()
746
-
747
800
  console.log(`[Chat] child PID: ${child.pid}`)
748
801
 
749
802
  let fullResponse = ''
750
803
  let stderrBuffer = ''
751
804
  let lineBuffer = ''
752
805
  let resolvedSessionId = sessionId || null
753
-
754
- // Block-type state tracking for correct event forwarding
755
806
  let currentBlockType = null // 'text' | 'tool_use' | null
756
807
  let currentToolName = null
757
808
  let toolInputBuffer = ''
@@ -767,84 +818,66 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
767
818
  try {
768
819
  const parsed = JSON.parse(line)
769
820
 
770
- // system init event — capture session_id
771
821
  if (parsed.type === 'system' && parsed.session_id) {
772
822
  resolvedSessionId = parsed.session_id
773
823
  console.log(`[Chat] session_id: ${resolvedSessionId}`)
774
824
  }
775
825
 
776
- // content_block_start — track block type
777
826
  if (parsed.type === 'content_block_start') {
778
827
  const blockType = parsed.content_block?.type
779
828
  if (blockType === 'tool_use') {
780
829
  currentBlockType = 'tool_use'
781
830
  currentToolName = parsed.content_block.name || 'unknown'
782
831
  toolInputBuffer = ''
783
- const event = JSON.stringify({
784
- type: 'tool_start',
785
- tool: currentToolName,
786
- })
787
- res.write(`data: ${event}\n\n`)
832
+ emit({ type: 'tool_start', tool: currentToolName })
788
833
  } else if (blockType === 'text') {
789
834
  currentBlockType = 'text'
790
835
  }
791
836
  }
792
837
 
793
- // content_block_delta — handle both text and tool input
794
838
  if (parsed.type === 'content_block_delta') {
795
839
  const deltaType = parsed.delta?.type
796
840
  if (deltaType === 'input_json_delta' && currentBlockType === 'tool_use') {
797
841
  const partial = parsed.delta.partial_json || ''
798
842
  if (partial) {
799
843
  toolInputBuffer += partial
800
- const event = JSON.stringify({ type: 'tool_input_delta', partial_json: partial })
801
- res.write(`data: ${event}\n\n`)
844
+ emit({ type: 'tool_input_delta', partial_json: partial })
802
845
  }
803
846
  } else {
804
847
  const delta = parsed.delta?.text || ''
805
848
  if (delta) {
806
849
  fullResponse += delta
807
- const event = JSON.stringify({ type: 'delta', content: delta })
808
- res.write(`data: ${event}\n\n`)
850
+ emit({ type: 'delta', content: delta })
809
851
  }
810
852
  }
811
853
  }
812
854
 
813
- // content_block_stop — only emit tool_end for tool_use blocks (bug fix)
814
855
  if (parsed.type === 'content_block_stop') {
815
856
  if (currentBlockType === 'tool_use') {
816
857
  let parsedInput = null
817
858
  try {
818
859
  if (toolInputBuffer) parsedInput = JSON.parse(toolInputBuffer)
819
860
  } catch { /* partial or invalid JSON */ }
820
- const event = JSON.stringify({
821
- type: 'tool_end',
822
- tool: currentToolName,
823
- input: parsedInput,
824
- })
825
- res.write(`data: ${event}\n\n`)
861
+ emit({ type: 'tool_end', tool: currentToolName, input: parsedInput })
826
862
  }
827
863
  currentBlockType = null
828
864
  currentToolName = null
829
865
  toolInputBuffer = ''
830
866
  }
831
867
 
832
- // assistant message — count turns and forward text blocks
833
868
  if (parsed.type === 'assistant' && parsed.message) {
834
869
  turnCount++
835
870
  for (const block of (parsed.message.content || [])) {
836
871
  if (block.type === 'text') {
837
872
  fullResponse += block.text
838
- res.write(`data: ${JSON.stringify({ type: 'text', content: block.text })}\n\n`)
873
+ emit({ type: 'text', content: block.text })
839
874
  }
840
875
  }
841
876
  } else if (parsed.type === 'result') {
842
877
  const resultText = parsed.result || ''
843
878
  if (resultText) {
844
- res.write(`data: ${JSON.stringify({ type: 'result', content: resultText })}\n\n`)
845
- if (!fullResponse) {
846
- fullResponse = resultText
847
- }
879
+ emit({ type: 'result', content: resultText })
880
+ if (!fullResponse) fullResponse = resultText
848
881
  }
849
882
  }
850
883
  } catch {
@@ -859,61 +892,25 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
859
892
  console.error(`[Chat] stderr: ${text}`)
860
893
  })
861
894
 
862
- child.on('close', async (code) => {
863
- activeChatChild = null
864
-
895
+ child.on('close', (code) => {
896
+ activeRef.current = null
865
897
  console.log(`[Chat] child closed: code=${code}, response=${fullResponse.length}chars, turns=${turnCount}, stderr=${stderrBuffer.length}bytes, session=${resolvedSessionId}`)
866
898
  if (stderrBuffer.trim()) {
867
899
  console.log(`[Chat] final stderr (tail 500): ${stderrBuffer.slice(-500)}`)
868
900
  }
869
-
870
- // For new sessions, the user message was already persisted under
871
- // pendingSessionId before spawn. Rekey it to the real session ID when
872
- // Claude CLI reported one; otherwise leave the message under the
873
- // pending key so the history is never lost on crash.
874
- const persistSessionId = resolvedSessionId || pendingSessionId
875
- try {
876
- if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
877
- chatStore.rekeySession(pendingSessionId, resolvedSessionId)
878
- }
879
- if (fullResponse && persistSessionId) {
880
- await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
881
- }
882
- } catch (err) {
883
- console.error('[Chat] failed to persist assistant message:', err.message)
884
- }
885
-
886
901
  if (code !== 0) {
887
902
  const errorMsg = stderrBuffer.trim() || `Claude CLI exited with code ${code}`
888
903
  console.error(`[Chat] CLI failed (exit ${code}, partial=${!!fullResponse}): ${errorMsg}`)
889
- res.write(`data: ${JSON.stringify({
890
- type: 'error',
891
- error: errorMsg,
892
- partial: !!fullResponse,
893
- exit_code: code,
894
- })}\n\n`)
895
- }
896
-
897
- let totalTurnCount = turnCount
898
- try {
899
- const session = await chatStore.load(workspaceId)
900
- totalTurnCount = session?.turn_count || turnCount
901
- } catch (err) {
902
- console.error('[Chat] failed to load session for done event:', err.message)
904
+ emit({ type: 'error', error: errorMsg, partial: !!fullResponse, exit_code: code })
903
905
  }
904
-
905
- res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
906
- resolve()
906
+ // Resolve (don't reject) on non-zero exit so the manager persists the
907
+ // partial response and emits a clean terminal `done`.
908
+ resolve({ fullResponse, resolvedSessionId, turnCount })
907
909
  })
908
910
 
909
911
  child.on('error', (err) => {
910
- activeChatChild = null
911
- res.write(`data: ${JSON.stringify({ type: 'error', error: `Failed to start CLI: ${err.message}` })}\n\n`)
912
- reject(err)
913
- })
914
-
915
- res.on('close', () => {
916
- child.kill('SIGTERM')
912
+ activeRef.current = null
913
+ reject(new Error(`Failed to start CLI: ${err.message}`))
917
914
  })
918
915
  })
919
916
  }