@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.
- package/core/db/migrations/20260607000000_chat_runs.js +48 -0
- package/core/db/migrations/20260607120000_page_recipes_ready_selector.js +22 -0
- package/core/lib/chat-run-manager.js +406 -0
- package/core/lib/web-extract/extractor.js +27 -7
- package/core/lib/web-extract/playwright-runner.js +199 -1
- package/core/lib/web-extract/recipe-generator.js +19 -2
- package/core/routes/web.js +12 -3
- package/core/stores/chat-store.js +119 -2
- package/core/stores/page-recipe-store.js +9 -7
- package/docs/api-reference.md +66 -4
- package/docs/task-guides.md +20 -2
- package/linux/routes/chat.js +158 -193
- package/package.json +1 -1
- package/rules/core.md +9 -1
- package/win/routes/chat.js +154 -157
package/win/routes/chat.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
'
|
|
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
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
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
|
-
|
|
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
|
|
722
|
+
return (emit, activeRef) => invokeViaPlugin(primary, prompt, sessionId, emit, activeRef)
|
|
639
723
|
}
|
|
640
|
-
return
|
|
724
|
+
return (emit, activeRef) => invokeViaLegacy(prompt, sessionId, emit, activeRef)
|
|
641
725
|
}
|
|
642
726
|
|
|
643
|
-
async function
|
|
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
|
|
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
|
-
|
|
738
|
+
emit({ type: 'delta', content: event.content })
|
|
657
739
|
} else if (event.type === 'text') {
|
|
658
740
|
fullResponse += event.content
|
|
659
|
-
|
|
741
|
+
emit({ type: 'text', content: event.content })
|
|
660
742
|
turnCount++
|
|
661
743
|
} else {
|
|
662
|
-
|
|
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,
|
|
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
|
-
|
|
756
|
+
emit({ type: 'text', content: output.text })
|
|
678
757
|
turnCount = 1
|
|
679
758
|
}
|
|
680
759
|
if (output.error) {
|
|
681
|
-
|
|
760
|
+
emit({ type: 'error', error: output.error.message })
|
|
682
761
|
}
|
|
683
762
|
}
|
|
684
763
|
resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
768
|
+
emit({ type: 'error', error: err.message, partial: !!fullResponse })
|
|
704
769
|
}
|
|
705
770
|
|
|
706
|
-
|
|
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
|
|
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
|
-
|
|
743
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
863
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
911
|
-
|
|
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
|
}
|