@geekbeer/minion 4.4.0 → 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
  ''
@@ -490,6 +543,7 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
490
543
  '成果物の保存先は以下のルールに従うこと。`/home/minion/` 直下にファイルを保存しないこと。',
491
544
  '',
492
545
  '- **テキスト成果物**(レポート、調査結果、要約等)→ ノートに保存: `hq note create <project_id> --title "タイトル" --content "本文"` (プロジェクト紐づけなしは `hq note create --workspace <ws_id> ...`)',
546
+ ' - ロックされたノートの更新は `HTTP 423`(`note_locked`)で拒否される。解除は人間のみ可能なので、上書きを繰り返さずユーザーに依頼すること。',
493
547
  '- **バイナリファイル**(PDF、画像、ZIP等)→ `~/files/` に配置(ユーザーがHQからダウンロード可能)',
494
548
  '- **一時ファイル** → `/tmp/` に配置(作業後に削除)',
495
549
  '',
@@ -626,95 +680,98 @@ function getLlmBinary() {
626
680
  }
627
681
 
628
682
  /**
629
- * Stream LLM CLI output as SSE events.
630
- * Uses --resume to continue existing sessions.
631
- * Tracks block types to correctly forward tool_use vs text events
632
- * 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
633
691
  */
634
- 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) {
635
720
  const primary = getActivePrimary()
636
721
  if (primary) {
637
- return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
722
+ return (emit, activeRef) => invokeViaPlugin(primary, prompt, sessionId, emit, activeRef)
638
723
  }
639
- return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
724
+ return (emit, activeRef) => invokeViaLegacy(prompt, sessionId, emit, activeRef)
640
725
  }
641
726
 
642
- async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
727
+ async function invokeViaPlugin(plugin, prompt, sessionId, emit, activeRef) {
643
728
  const input = { prompt }
644
- const activeRef = { current: null }
645
-
646
729
  let fullResponse = ''
647
730
  let resolvedSessionId = sessionId || null
648
731
  let turnCount = 0
649
732
 
650
- const emit = event => {
733
+ const onEvent = event => {
651
734
  if (event.type === 'session') {
652
735
  resolvedSessionId = event.sessionId
653
736
  } else if (event.type === 'delta') {
654
737
  fullResponse += event.content
655
- res.write(`data: ${JSON.stringify({ type: 'delta', content: event.content })}\n\n`)
738
+ emit({ type: 'delta', content: event.content })
656
739
  } else if (event.type === 'text') {
657
740
  fullResponse += event.content
658
- res.write(`data: ${JSON.stringify({ type: 'text', content: event.content })}\n\n`)
741
+ emit({ type: 'text', content: event.content })
659
742
  turnCount++
660
743
  } else {
661
- res.write(`data: ${JSON.stringify(event)}\n\n`)
744
+ emit(event)
662
745
  }
663
746
  }
664
747
 
665
- res.on('close', () => { activeRef.current?.kill?.('SIGTERM') })
666
-
667
- let pluginError = null
668
748
  try {
669
749
  let output
670
750
  if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
671
- output = await plugin.stream(input, emit, { resumeSessionId: sessionId, activeChildRef: activeRef })
751
+ output = await plugin.stream(input, onEvent, { resumeSessionId: sessionId, activeChildRef: activeRef })
672
752
  } else {
673
753
  output = await plugin.invoke(input)
674
754
  if (output.text) {
675
755
  fullResponse = output.text
676
- res.write(`data: ${JSON.stringify({ type: 'text', content: output.text })}\n\n`)
756
+ emit({ type: 'text', content: output.text })
677
757
  turnCount = 1
678
758
  }
679
759
  if (output.error) {
680
- res.write(`data: ${JSON.stringify({ type: 'error', error: output.error.message })}\n\n`)
760
+ emit({ type: 'error', error: output.error.message })
681
761
  }
682
762
  }
683
763
  resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
684
- } catch (err) {
685
- // Swallow here so we can persist any partial response first; rethrow below.
686
- pluginError = err
687
- }
688
-
689
- // For new sessions, the user message was persisted under pendingSessionId
690
- // before the plugin call. Rekey it to the real session ID now that we
691
- // know it. If the plugin never reported a session ID, leave the message
692
- // under the pending key so the history isn't lost.
693
- const persistSessionId = resolvedSessionId || pendingSessionId
694
- try {
695
- if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
696
- chatStore.rekeySession(pendingSessionId, resolvedSessionId)
697
- }
698
- if (fullResponse && persistSessionId) {
699
- await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
764
+ if (output?.error) {
765
+ emit({ type: 'error', error: output.error.message, partial: !!fullResponse })
700
766
  }
701
767
  } catch (err) {
702
- console.error('[Chat] failed to persist assistant message:', err.message)
768
+ emit({ type: 'error', error: err.message, partial: !!fullResponse })
703
769
  }
704
770
 
705
- if (pluginError) throw pluginError
706
-
707
- let totalTurnCount = turnCount
708
- try {
709
- const session = await chatStore.load(workspaceId)
710
- totalTurnCount = session?.turn_count || turnCount
711
- } catch (err) {
712
- console.error('[Chat] failed to load session for done event:', err.message)
713
- }
714
- res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
771
+ return { fullResponse, resolvedSessionId, turnCount }
715
772
  }
716
773
 
717
- function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
774
+ function invokeViaLegacy(prompt, sessionId, emit, activeRef) {
718
775
  return new Promise((resolve, reject) => {
719
776
  const binaryName = getLlmBinary()
720
777
  if (!binaryName) {
@@ -726,8 +783,6 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
726
783
 
727
784
  const args = ['-p', '--verbose', '--model', 'sonnet', '--output-format', 'stream-json']
728
785
  if (sessionId) args.push('--resume', sessionId)
729
- // Prompt is passed via stdin (not as CLI argument) to avoid
730
- // shell argument parsing issues with spaces/special characters.
731
786
 
732
787
  console.log(`[Chat] spawning: ${binary} ${sessionId ? `--resume ${sessionId}` : '(new session)'}`)
733
788
 
@@ -738,19 +793,16 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
738
793
  shell: true, // Required on Windows to resolve .cmd shims (e.g. claude.cmd)
739
794
  })
740
795
 
741
- activeChatChild = child
742
- // 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
743
798
  child.stdin.write(prompt)
744
799
  child.stdin.end()
745
-
746
800
  console.log(`[Chat] child PID: ${child.pid}`)
747
801
 
748
802
  let fullResponse = ''
749
803
  let stderrBuffer = ''
750
804
  let lineBuffer = ''
751
805
  let resolvedSessionId = sessionId || null
752
-
753
- // Block-type state tracking for correct event forwarding
754
806
  let currentBlockType = null // 'text' | 'tool_use' | null
755
807
  let currentToolName = null
756
808
  let toolInputBuffer = ''
@@ -766,84 +818,66 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
766
818
  try {
767
819
  const parsed = JSON.parse(line)
768
820
 
769
- // system init event — capture session_id
770
821
  if (parsed.type === 'system' && parsed.session_id) {
771
822
  resolvedSessionId = parsed.session_id
772
823
  console.log(`[Chat] session_id: ${resolvedSessionId}`)
773
824
  }
774
825
 
775
- // content_block_start — track block type
776
826
  if (parsed.type === 'content_block_start') {
777
827
  const blockType = parsed.content_block?.type
778
828
  if (blockType === 'tool_use') {
779
829
  currentBlockType = 'tool_use'
780
830
  currentToolName = parsed.content_block.name || 'unknown'
781
831
  toolInputBuffer = ''
782
- const event = JSON.stringify({
783
- type: 'tool_start',
784
- tool: currentToolName,
785
- })
786
- res.write(`data: ${event}\n\n`)
832
+ emit({ type: 'tool_start', tool: currentToolName })
787
833
  } else if (blockType === 'text') {
788
834
  currentBlockType = 'text'
789
835
  }
790
836
  }
791
837
 
792
- // content_block_delta — handle both text and tool input
793
838
  if (parsed.type === 'content_block_delta') {
794
839
  const deltaType = parsed.delta?.type
795
840
  if (deltaType === 'input_json_delta' && currentBlockType === 'tool_use') {
796
841
  const partial = parsed.delta.partial_json || ''
797
842
  if (partial) {
798
843
  toolInputBuffer += partial
799
- const event = JSON.stringify({ type: 'tool_input_delta', partial_json: partial })
800
- res.write(`data: ${event}\n\n`)
844
+ emit({ type: 'tool_input_delta', partial_json: partial })
801
845
  }
802
846
  } else {
803
847
  const delta = parsed.delta?.text || ''
804
848
  if (delta) {
805
849
  fullResponse += delta
806
- const event = JSON.stringify({ type: 'delta', content: delta })
807
- res.write(`data: ${event}\n\n`)
850
+ emit({ type: 'delta', content: delta })
808
851
  }
809
852
  }
810
853
  }
811
854
 
812
- // content_block_stop — only emit tool_end for tool_use blocks (bug fix)
813
855
  if (parsed.type === 'content_block_stop') {
814
856
  if (currentBlockType === 'tool_use') {
815
857
  let parsedInput = null
816
858
  try {
817
859
  if (toolInputBuffer) parsedInput = JSON.parse(toolInputBuffer)
818
860
  } catch { /* partial or invalid JSON */ }
819
- const event = JSON.stringify({
820
- type: 'tool_end',
821
- tool: currentToolName,
822
- input: parsedInput,
823
- })
824
- res.write(`data: ${event}\n\n`)
861
+ emit({ type: 'tool_end', tool: currentToolName, input: parsedInput })
825
862
  }
826
863
  currentBlockType = null
827
864
  currentToolName = null
828
865
  toolInputBuffer = ''
829
866
  }
830
867
 
831
- // assistant message — count turns and forward text blocks
832
868
  if (parsed.type === 'assistant' && parsed.message) {
833
869
  turnCount++
834
870
  for (const block of (parsed.message.content || [])) {
835
871
  if (block.type === 'text') {
836
872
  fullResponse += block.text
837
- res.write(`data: ${JSON.stringify({ type: 'text', content: block.text })}\n\n`)
873
+ emit({ type: 'text', content: block.text })
838
874
  }
839
875
  }
840
876
  } else if (parsed.type === 'result') {
841
877
  const resultText = parsed.result || ''
842
878
  if (resultText) {
843
- res.write(`data: ${JSON.stringify({ type: 'result', content: resultText })}\n\n`)
844
- if (!fullResponse) {
845
- fullResponse = resultText
846
- }
879
+ emit({ type: 'result', content: resultText })
880
+ if (!fullResponse) fullResponse = resultText
847
881
  }
848
882
  }
849
883
  } catch {
@@ -858,61 +892,25 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
858
892
  console.error(`[Chat] stderr: ${text}`)
859
893
  })
860
894
 
861
- child.on('close', async (code) => {
862
- activeChatChild = null
863
-
895
+ child.on('close', (code) => {
896
+ activeRef.current = null
864
897
  console.log(`[Chat] child closed: code=${code}, response=${fullResponse.length}chars, turns=${turnCount}, stderr=${stderrBuffer.length}bytes, session=${resolvedSessionId}`)
865
898
  if (stderrBuffer.trim()) {
866
899
  console.log(`[Chat] final stderr (tail 500): ${stderrBuffer.slice(-500)}`)
867
900
  }
868
-
869
- // For new sessions, the user message was already persisted under
870
- // pendingSessionId before spawn. Rekey it to the real session ID when
871
- // Claude CLI reported one; otherwise leave the message under the
872
- // pending key so the history is never lost on crash.
873
- const persistSessionId = resolvedSessionId || pendingSessionId
874
- try {
875
- if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
876
- chatStore.rekeySession(pendingSessionId, resolvedSessionId)
877
- }
878
- if (fullResponse && persistSessionId) {
879
- await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
880
- }
881
- } catch (err) {
882
- console.error('[Chat] failed to persist assistant message:', err.message)
883
- }
884
-
885
901
  if (code !== 0) {
886
902
  const errorMsg = stderrBuffer.trim() || `Claude CLI exited with code ${code}`
887
903
  console.error(`[Chat] CLI failed (exit ${code}, partial=${!!fullResponse}): ${errorMsg}`)
888
- res.write(`data: ${JSON.stringify({
889
- type: 'error',
890
- error: errorMsg,
891
- partial: !!fullResponse,
892
- exit_code: code,
893
- })}\n\n`)
894
- }
895
-
896
- let totalTurnCount = turnCount
897
- try {
898
- const session = await chatStore.load(workspaceId)
899
- totalTurnCount = session?.turn_count || turnCount
900
- } catch (err) {
901
- console.error('[Chat] failed to load session for done event:', err.message)
904
+ emit({ type: 'error', error: errorMsg, partial: !!fullResponse, exit_code: code })
902
905
  }
903
-
904
- res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
905
- 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 })
906
909
  })
907
910
 
908
911
  child.on('error', (err) => {
909
- activeChatChild = null
910
- res.write(`data: ${JSON.stringify({ type: 'error', error: `Failed to start CLI: ${err.message}` })}\n\n`)
911
- reject(err)
912
- })
913
-
914
- res.on('close', () => {
915
- child.kill('SIGTERM')
912
+ activeRef.current = null
913
+ reject(new Error(`Failed to start CLI: ${err.message}`))
916
914
  })
917
915
  })
918
916
  }