@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.
- 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/variables.js +47 -5
- 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/core/stores/variable-store.js +63 -0
- package/docs/api-reference.md +82 -4
- package/docs/task-guides.md +20 -2
- package/linux/routes/chat.js +159 -193
- package/package.json +1 -1
- package/rules/core.md +12 -2
- package/win/routes/chat.js +155 -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
|
''
|
|
@@ -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
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
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
|
-
|
|
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
|
|
722
|
+
return (emit, activeRef) => invokeViaPlugin(primary, prompt, sessionId, emit, activeRef)
|
|
638
723
|
}
|
|
639
|
-
return
|
|
724
|
+
return (emit, activeRef) => invokeViaLegacy(prompt, sessionId, emit, activeRef)
|
|
640
725
|
}
|
|
641
726
|
|
|
642
|
-
async function
|
|
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
|
|
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
|
-
|
|
738
|
+
emit({ type: 'delta', content: event.content })
|
|
656
739
|
} else if (event.type === 'text') {
|
|
657
740
|
fullResponse += event.content
|
|
658
|
-
|
|
741
|
+
emit({ type: 'text', content: event.content })
|
|
659
742
|
turnCount++
|
|
660
743
|
} else {
|
|
661
|
-
|
|
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,
|
|
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
|
-
|
|
756
|
+
emit({ type: 'text', content: output.text })
|
|
677
757
|
turnCount = 1
|
|
678
758
|
}
|
|
679
759
|
if (output.error) {
|
|
680
|
-
|
|
760
|
+
emit({ type: 'error', error: output.error.message })
|
|
681
761
|
}
|
|
682
762
|
}
|
|
683
763
|
resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
768
|
+
emit({ type: 'error', error: err.message, partial: !!fullResponse })
|
|
703
769
|
}
|
|
704
770
|
|
|
705
|
-
|
|
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
|
|
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
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
862
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
910
|
-
|
|
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
|
}
|