@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.
@@ -20,16 +20,34 @@ curl -X POST http://localhost:8080/api/web/extract \
20
20
 
21
21
  このAPIは内部で Playwright + Readability を回して **メインセッションには結果 JSON だけ返す** ため、Playwright MCP を使うときに起きていたチャットコンテキストのトークン肥大化が回避できる。
22
22
 
23
+ ### SPA / 無限スクロールのページ (v4.7.0〜)
24
+
25
+ - **SPA (React/Vue 等でクライアント描画するページ)** もそのまま `/api/web/extract` でよい。内部で DOM が静止するまで待ってから抽出するため、空シェルを掴む問題は解消済み。
26
+ - **無限スクロール / 「もっと見る」で件数が増えるページ**で十分な件数を確保したい場合は `scroll` オプションを付ける。**どれだけ集めるかは呼び出し側が決める**:
27
+
28
+ ```bash
29
+ curl -X POST http://localhost:8080/api/web/extract \
30
+ -H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
31
+ -d '{
32
+ "url": "対象URL",
33
+ "scroll": { "strategy": "count", "targetItems": 50, "maxScrolls": 20, "maxMs": 15000 }
34
+ }' | jq
35
+ ```
36
+
37
+ - `count`: 目標件数 (`targetItems`) に達するまでスクロール。`untilStable`: 件数が増えなくなるまで。`fixed`: 回数固定。
38
+ - レスポンスの `scrollInfo.reachedTarget` が `false` なら上限で打ち切られている → `maxScrolls` / `maxMs` を上げて再試行する。
39
+ - スクロール上限はサーバー側でクランプされる (maxScrolls≤50 / maxMs≤45s)。それ以上の網羅が要るなら**ページネーションURLをループ呼び出し**する方が確実。
40
+
23
41
  ### Playwright MCP を使うべき場面
24
42
 
25
43
  `/api/web/extract` で対応できないのは以下のケース。このときだけ `mcp__playwright__*` を使う:
26
44
 
27
45
  - ログイン必須ページ (Cookie/2FA 等の認証必要)
28
46
  - フォーム入力・複数ページ遷移を伴う操作
29
- - ボタンクリック→動的に追加されるコンテンツの取得
47
+ - 「もっと見る」**ボタンのクリック**で追加ロードするページ (スクロールでは増えないもの。`scroll` はスクロール式のみ対応)
30
48
  - Lancers コンペ応募など、明らかに対話的操作が必要なフロー
31
49
 
32
- **単純な閲覧・抽出用途では MCP を使わない。**
50
+ **単純な閲覧・抽出 (SPA・無限スクロール含む) では MCP を使わない。**
33
51
 
34
52
  ### よくあるパターン
35
53
 
@@ -27,15 +27,15 @@ const todoStore = require('../../core/stores/todo-store')
27
27
  const { runEndOfDay } = require('../../core/lib/end-of-day')
28
28
  const { DATA_DIR } = require('../../core/lib/platform')
29
29
  const { getActivePrimary } = require('../../core/llm-plugins/lib/active')
30
-
31
- /** @type {import('child_process').ChildProcess | null} */
32
- let activeChatChild = null
30
+ const chatRunManager = require('../../core/lib/chat-run-manager')
33
31
 
34
32
  /**
35
33
  * Register chat routes as Fastify plugin
36
34
  * @param {import('fastify').FastifyInstance} fastify
37
35
  */
38
36
  async function chatRoutes(fastify) {
37
+ // Sweep stale runs from a previous boot and prune old run logs.
38
+ chatRunManager.init()
39
39
 
40
40
  // POST /api/chat - Send a message and get streaming response
41
41
  fastify.post('/api/chat', async (request, reply) => {
@@ -71,25 +71,66 @@ async function chatRoutes(fastify) {
71
71
  console.error('[Chat] failed to persist user message:', err.message)
72
72
  }
73
73
 
74
+ // Start a DETACHED run. The LLM is owned by the run manager, not this HTTP
75
+ // request — if the connection drops the run keeps going. This response is
76
+ // just a live tail of the run's event log.
77
+ const runId = chatRunManager.start({
78
+ sessionId: currentSessionId,
79
+ pendingSessionId,
80
+ workspaceId,
81
+ invoke: buildInvoke(prompt, currentSessionId),
82
+ })
83
+
74
84
  // Take over response handling from Fastify for SSE streaming
75
85
  reply.hijack()
76
-
77
- reply.raw.writeHead(200, {
86
+ const res = reply.raw
87
+ res.writeHead(200, {
78
88
  'Content-Type': 'text/event-stream',
79
89
  'Cache-Control': 'no-cache',
80
90
  'Connection': 'keep-alive',
81
91
  })
82
- reply.raw.flushHeaders()
92
+ res.flushHeaders()
83
93
 
84
- try {
85
- await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message, pendingSessionId)
86
- } catch (err) {
87
- console.error('[Chat] stream error:', err.message)
88
- const errorEvent = JSON.stringify({ type: 'error', error: err.message })
89
- reply.raw.write(`data: ${errorEvent}\n\n`)
94
+ // Tell the client the run id first so it can reconnect via
95
+ // GET /api/chat/stream after a drop without losing the in-flight run.
96
+ res.write(`data: ${JSON.stringify({ type: 'run', run_id: runId, session_id: currentSessionId })}\n\n`)
97
+
98
+ await tailRunToResponse(res, runId, 0)
99
+ res.end()
100
+ })
101
+
102
+ // GET /api/chat/stream - Reconnect to an in-flight (or recently finished) run
103
+ // and resume tailing from `cursor`. This is what makes detached execution
104
+ // observable: a refreshed tab / dropped network reattaches here instead of
105
+ // starting a new run. Either run_id or workspace_id (to auto-find the active
106
+ // run) must be provided.
107
+ fastify.get('/api/chat/stream', async (request, reply) => {
108
+ if (!verifyToken(request)) {
109
+ reply.code(401)
110
+ return { success: false, error: 'Unauthorized' }
111
+ }
112
+
113
+ const workspaceId = request.query?.workspace_id || null
114
+ const runId = request.query?.run_id || chatRunManager.getActiveRunId(workspaceId)
115
+ const cursor = parseInt(request.query?.cursor, 10) || 0
116
+
117
+ if (!runId) {
118
+ reply.code(404)
119
+ return { success: false, error: 'No active run' }
90
120
  }
91
121
 
92
- reply.raw.end()
122
+ reply.hijack()
123
+ const res = reply.raw
124
+ res.writeHead(200, {
125
+ 'Content-Type': 'text/event-stream',
126
+ 'Cache-Control': 'no-cache',
127
+ 'Connection': 'keep-alive',
128
+ })
129
+ res.flushHeaders()
130
+ res.write(`data: ${JSON.stringify({ type: 'run', run_id: runId })}\n\n`)
131
+
132
+ await tailRunToResponse(res, runId, cursor)
133
+ res.end()
93
134
  })
94
135
 
95
136
  // GET /api/chat/session - Get active chat session for a workspace
@@ -101,8 +142,14 @@ async function chatRoutes(fastify) {
101
142
 
102
143
  const workspaceId = request.query?.workspace_id || null
103
144
  const session = chatStore.load(workspaceId)
145
+
146
+ // Surface any in-flight detached run so the client can attach to its live
147
+ // stream (GET /api/chat/stream) instead of rendering static history.
148
+ const activeRunId = chatRunManager.getActiveRunId(workspaceId)
149
+ const activeRun = activeRunId ? chatRunManager.getRunInfo(activeRunId) : null
150
+
104
151
  if (!session) {
105
- return { success: true, session: null }
152
+ return { success: true, session: null, active_run: activeRun }
106
153
  }
107
154
 
108
155
  return {
@@ -115,6 +162,7 @@ async function chatRoutes(fastify) {
115
162
  created_at: session.created_at,
116
163
  updated_at: session.updated_at,
117
164
  },
165
+ active_run: activeRun,
118
166
  }
119
167
  })
120
168
 
@@ -159,31 +207,26 @@ async function chatRoutes(fastify) {
159
207
  return { success: true }
160
208
  })
161
209
 
162
- // POST /api/chat/abort - Kill the active Claude CLI process
210
+ // POST /api/chat/abort - Explicitly kill the active run for a workspace.
211
+ // This is now the ONLY path that terminates the LLM. A dropped connection no
212
+ // longer does. Optionally accepts an explicit run_id.
163
213
  fastify.post('/api/chat/abort', async (request, reply) => {
164
214
  if (!verifyToken(request)) {
165
215
  reply.code(401)
166
216
  return { success: false, error: 'Unauthorized' }
167
217
  }
168
218
 
169
- if (!activeChatChild) {
219
+ const workspaceId = request.body?.workspace_id || null
220
+ const runId = request.body?.run_id || chatRunManager.getActiveRunId(workspaceId)
221
+ if (!runId) {
170
222
  return { success: false, error: 'No active chat process' }
171
223
  }
172
224
 
173
- console.log(`[Chat] Aborting active chat process PID: ${activeChatChild.pid}`)
174
- activeChatChild.kill('SIGTERM')
175
-
176
- // Give it 2s to terminate gracefully, then force kill
177
- const pid = activeChatChild.pid
178
- setTimeout(() => {
179
- try {
180
- if (activeChatChild && activeChatChild.pid === pid) {
181
- activeChatChild.kill('SIGKILL')
182
- }
183
- } catch { /* already dead */ }
184
- }, 2000)
185
-
186
- return { success: true }
225
+ const aborted = chatRunManager.abort(runId)
226
+ if (!aborted) {
227
+ return { success: false, error: 'No active chat process' }
228
+ }
229
+ return { success: true, run_id: runId }
187
230
  })
188
231
 
189
232
  // POST /api/chat/reset - Carry over relevant messages and start fresh session
@@ -409,9 +452,11 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
409
452
  'このAPIは内部で Playwright + Readability を回し、抽出済みJSONだけを返すため、',
410
453
  'DOM全体がチャットに流れ込んでトークン肥大化することを防げる。',
411
454
  '初回アクセスで学習したセレクタはSQLiteにキャッシュされ、2回目以降はLLM呼び出しなしで抽出される。',
455
+ 'SPA(クライアント描画)も内部でDOM静止を待ってから抽出するためそのまま利用可。',
456
+ '無限スクロールで件数を増やしたい場合は `"scroll": {"strategy":"count","targetItems":50}` を付ける(どれだけ集めるかは呼び出し側が指定)。',
412
457
  '',
413
- 'Playwright MCP (`mcp__playwright__*`) は **ログイン・フォーム入力・複数画面の対話操作**が必要な場合のみ使用する。',
414
- '単純な閲覧・要約・一覧取得用途ではMCPを使わない。',
458
+ 'Playwright MCP (`mcp__playwright__*`) は **ログイン・フォーム入力・複数画面の対話操作・「もっと見る」ボタン**が必要な場合のみ使用する。',
459
+ '単純な閲覧・要約・一覧取得用途(SPA・無限スクロール含む)ではMCPを使わない。',
415
460
  '',
416
461
  '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`。チャットで参照されたノート/タスクの本文は上記「参照ノート」「参照チケット」ブロックに既に注入済み。',
417
462
  ''
@@ -570,99 +615,109 @@ function getLlmBinary() {
570
615
  }
571
616
 
572
617
  /**
573
- * Stream LLM CLI output as SSE events.
574
- * Uses --resume to continue existing sessions.
575
- * Tracks block types to correctly forward tool_use vs text events
576
- * and counts turns for session management.
618
+ * Tail a detached run into an SSE response. Subscribes from `cursor`, forwards
619
+ * each event (seq-stamped, so the client can resume), and resolves on the
620
+ * terminal `done` event OR when the client disconnects. Disconnecting only
621
+ * stops the tail it never touches the underlying LLM process.
622
+ *
623
+ * @param {import('http').ServerResponse} res
624
+ * @param {string} runId
625
+ * @param {number} cursor last sequence the client already has
626
+ */
627
+ function tailRunToResponse(res, runId, cursor) {
628
+ return new Promise(resolve => {
629
+ let settled = false
630
+ let unsubscribe = () => {}
631
+ const finish = () => {
632
+ if (settled) return
633
+ settled = true
634
+ try { unsubscribe() } catch { /* ignore */ }
635
+ resolve()
636
+ }
637
+
638
+ unsubscribe = chatRunManager.subscribe(runId, cursor, event => {
639
+ try { res.write(`data: ${JSON.stringify(event)}\n\n`) } catch { /* socket gone */ }
640
+ // The manager always closes a run with a terminal `done` (an `error`
641
+ // precedes it when the run failed), so `done` is our single stop signal.
642
+ if (event.type === 'done') finish()
643
+ })
644
+
645
+ // Client went away (tab close, navigation, proxy timeout, network blip):
646
+ // stop forwarding, but leave the run running so a reconnect can resume it.
647
+ res.on('close', finish)
648
+ })
649
+ }
650
+
651
+ /**
652
+ * Build the LLM executor for a run. The executor is LLM-specific (plugin vs
653
+ * legacy CLI) but knows nothing about HTTP — it emits normalized wire events
654
+ * and returns the collected result. The run manager owns persistence, the
655
+ * terminal `done` event, and abort.
656
+ *
657
+ * @param {string} prompt
658
+ * @param {string|null} sessionId
659
+ * @returns {(emit: (e: object) => void, activeRef: { current: any }) => Promise<{ fullResponse: string, resolvedSessionId: string|null, turnCount: number }>}
577
660
  */
578
- async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
579
- // Plugin system path: Primary is set → delegate to plugin
661
+ function buildInvoke(prompt, sessionId) {
580
662
  const primary = getActivePrimary()
581
663
  if (primary) {
582
- return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
664
+ return (emit, activeRef) => invokeViaPlugin(primary, prompt, sessionId, emit, activeRef)
583
665
  }
584
- return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
666
+ return (emit, activeRef) => invokeViaLegacy(prompt, sessionId, emit, activeRef)
585
667
  }
586
668
 
587
- async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
669
+ async function invokeViaPlugin(plugin, prompt, sessionId, emit, activeRef) {
588
670
  const input = { prompt }
589
- const activeRef = { current: null }
590
- activeChatChild = { kill: () => activeRef.current?.kill?.('SIGTERM') }
591
-
592
671
  let fullResponse = ''
593
672
  let resolvedSessionId = sessionId || null
594
673
  let turnCount = 0
595
674
 
596
- const emit = event => {
675
+ // Translate plugin events into wire events. `session` is captured (not
676
+ // forwarded); delta/text are accumulated; everything else is forwarded as-is.
677
+ const onEvent = event => {
597
678
  if (event.type === 'session') {
598
679
  resolvedSessionId = event.sessionId
599
680
  } else if (event.type === 'delta') {
600
681
  fullResponse += event.content
601
- res.write(`data: ${JSON.stringify({ type: 'delta', content: event.content })}\n\n`)
682
+ emit({ type: 'delta', content: event.content })
602
683
  } else if (event.type === 'text') {
603
684
  fullResponse += event.content
604
- res.write(`data: ${JSON.stringify({ type: 'text', content: event.content })}\n\n`)
685
+ emit({ type: 'text', content: event.content })
605
686
  turnCount++
606
687
  } else {
607
- res.write(`data: ${JSON.stringify(event)}\n\n`)
688
+ emit(event)
608
689
  }
609
690
  }
610
691
 
611
- res.on('close', () => { activeRef.current?.kill?.('SIGTERM') })
612
-
613
- let pluginError = null
614
692
  try {
615
693
  let output
616
694
  if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
617
- output = await plugin.stream(input, emit, { resumeSessionId: sessionId, activeChildRef: activeRef })
695
+ output = await plugin.stream(input, onEvent, { resumeSessionId: sessionId, activeChildRef: activeRef })
618
696
  } else {
619
697
  output = await plugin.invoke(input)
620
698
  if (output.text) {
621
699
  fullResponse = output.text
622
- res.write(`data: ${JSON.stringify({ type: 'text', content: output.text })}\n\n`)
700
+ emit({ type: 'text', content: output.text })
623
701
  turnCount = 1
624
702
  }
625
703
  if (output.error) {
626
- res.write(`data: ${JSON.stringify({ type: 'error', error: output.error.message })}\n\n`)
704
+ emit({ type: 'error', error: output.error.message })
627
705
  }
628
706
  }
629
707
  resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
630
- } catch (err) {
631
- // Swallow here so we can persist any partial response first; rethrow below.
632
- pluginError = err
633
- } finally {
634
- activeChatChild = null
635
- }
636
-
637
- // For new sessions, the user message was persisted under pendingSessionId
638
- // before the plugin call. Rekey it to the real session ID now that we
639
- // know it. If the plugin never reported a session ID, leave the message
640
- // under the pending key so the history isn't lost.
641
- const persistSessionId = resolvedSessionId || pendingSessionId
642
- try {
643
- if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
644
- chatStore.rekeySession(pendingSessionId, resolvedSessionId)
645
- }
646
- if (fullResponse && persistSessionId) {
647
- await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
708
+ if (output?.error) {
709
+ emit({ type: 'error', error: output.error.message, partial: !!fullResponse })
648
710
  }
649
711
  } catch (err) {
650
- console.error('[Chat] failed to persist assistant message:', err.message)
712
+ // Surface the error inline but still return the partial so the manager can
713
+ // persist it. (A spawn failure typically yields no partial.)
714
+ emit({ type: 'error', error: err.message, partial: !!fullResponse })
651
715
  }
652
716
 
653
- if (pluginError) throw pluginError
654
-
655
- let totalTurnCount = turnCount
656
- try {
657
- const session = await chatStore.load(workspaceId)
658
- totalTurnCount = session?.turn_count || turnCount
659
- } catch (err) {
660
- console.error('[Chat] failed to load session for done event:', err.message)
661
- }
662
- res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
717
+ return { fullResponse, resolvedSessionId, turnCount }
663
718
  }
664
719
 
665
- function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
720
+ function invokeViaLegacy(prompt, sessionId, emit, activeRef) {
666
721
  return new Promise((resolve, reject) => {
667
722
  const binaryName = getLlmBinary()
668
723
  if (!binaryName) {
@@ -673,20 +728,8 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
673
728
  const binary = fs.existsSync(binaryPath) ? binaryPath : binaryName
674
729
 
675
730
  // Build CLI args (no --max-turns: allow unlimited turns for task completion)
676
- const args = [
677
- '-p',
678
- '--verbose',
679
- '--model', 'sonnet',
680
- '--output-format', 'stream-json',
681
- ]
682
-
683
- // Resume existing session
684
- if (sessionId) {
685
- args.push('--resume', sessionId)
686
- }
687
-
688
- // Prompt is passed via stdin (not as CLI argument) to avoid
689
- // shell argument parsing issues with spaces/special characters.
731
+ const args = ['-p', '--verbose', '--model', 'sonnet', '--output-format', 'stream-json']
732
+ if (sessionId) args.push('--resume', sessionId)
690
733
 
691
734
  console.log(`[Chat] spawning: ${binary} ${sessionId ? `--resume ${sessionId}` : '(new session)'} (cwd: ${config.HOME_DIR})`)
692
735
 
@@ -698,21 +741,17 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
698
741
  timeout: 3600000, // 60 min — allow long-running tasks to complete
699
742
  })
700
743
 
701
- // Track active child process for abort
702
- activeChatChild = child
744
+ // Expose the child to the run manager for abort. NOT tied to any HTTP request.
745
+ activeRef.current = child
703
746
 
704
- // Write prompt to stdin and close — claude -p reads from stdin when no positional arg
705
747
  child.stdin.write(prompt)
706
748
  child.stdin.end()
707
-
708
749
  console.log(`[Chat] child PID: ${child.pid}`)
709
750
 
710
751
  let fullResponse = ''
711
752
  let stderrBuffer = ''
712
753
  let lineBuffer = ''
713
754
  let resolvedSessionId = sessionId || null
714
-
715
- // Block-type state tracking for correct event forwarding
716
755
  let currentBlockType = null // 'text' | 'tool_use' | null
717
756
  let currentToolName = null
718
757
  let toolInputBuffer = ''
@@ -721,7 +760,6 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
721
760
  child.stdout.on('data', (data) => {
722
761
  lineBuffer += data.toString()
723
762
  const parts = lineBuffer.split('\n')
724
- // Keep the last (potentially incomplete) line in the buffer
725
763
  lineBuffer = parts.pop() || ''
726
764
 
727
765
  for (const line of parts) {
@@ -729,96 +767,69 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
729
767
  try {
730
768
  const parsed = JSON.parse(line)
731
769
 
732
- // system init event — capture session_id
733
770
  if (parsed.type === 'system' && parsed.session_id) {
734
771
  resolvedSessionId = parsed.session_id
735
772
  console.log(`[Chat] session_id: ${resolvedSessionId}`)
736
773
  }
737
774
 
738
- // content_block_start — track block type
739
775
  if (parsed.type === 'content_block_start') {
740
776
  const blockType = parsed.content_block?.type
741
777
  if (blockType === 'tool_use') {
742
778
  currentBlockType = 'tool_use'
743
779
  currentToolName = parsed.content_block.name || 'unknown'
744
780
  toolInputBuffer = ''
745
- const event = JSON.stringify({
746
- type: 'tool_start',
747
- tool: currentToolName,
748
- })
749
- res.write(`data: ${event}\n\n`)
781
+ emit({ type: 'tool_start', tool: currentToolName })
750
782
  } else if (blockType === 'text') {
751
783
  currentBlockType = 'text'
752
784
  }
753
785
  }
754
786
 
755
- // content_block_delta — handle both text and tool input
756
787
  if (parsed.type === 'content_block_delta') {
757
788
  const deltaType = parsed.delta?.type
758
789
  if (deltaType === 'input_json_delta' && currentBlockType === 'tool_use') {
759
- // Accumulate tool input JSON
760
790
  const partial = parsed.delta.partial_json || ''
761
791
  if (partial) {
762
792
  toolInputBuffer += partial
763
- const event = JSON.stringify({ type: 'tool_input_delta', partial_json: partial })
764
- res.write(`data: ${event}\n\n`)
793
+ emit({ type: 'tool_input_delta', partial_json: partial })
765
794
  }
766
795
  } else {
767
- // Text delta
768
796
  const delta = parsed.delta?.text || ''
769
797
  if (delta) {
770
798
  fullResponse += delta
771
- const event = JSON.stringify({ type: 'delta', content: delta })
772
- res.write(`data: ${event}\n\n`)
799
+ emit({ type: 'delta', content: delta })
773
800
  }
774
801
  }
775
802
  }
776
803
 
777
- // content_block_stop — only emit tool_end for tool_use blocks (bug fix)
778
804
  if (parsed.type === 'content_block_stop') {
779
805
  if (currentBlockType === 'tool_use') {
780
- // Try to parse the accumulated tool input
781
806
  let parsedInput = null
782
807
  try {
783
808
  if (toolInputBuffer) parsedInput = JSON.parse(toolInputBuffer)
784
809
  } catch { /* partial or invalid JSON */ }
785
- const event = JSON.stringify({
786
- type: 'tool_end',
787
- tool: currentToolName,
788
- input: parsedInput,
789
- })
790
- res.write(`data: ${event}\n\n`)
810
+ emit({ type: 'tool_end', tool: currentToolName, input: parsedInput })
791
811
  }
792
812
  currentBlockType = null
793
813
  currentToolName = null
794
814
  toolInputBuffer = ''
795
815
  }
796
816
 
797
- // assistant message — count turns and forward text blocks
798
817
  if (parsed.type === 'assistant' && parsed.message) {
799
818
  turnCount++
800
819
  for (const block of (parsed.message.content || [])) {
801
820
  if (block.type === 'text') {
802
821
  fullResponse += block.text
803
- const event = JSON.stringify({ type: 'text', content: block.text })
804
- res.write(`data: ${event}\n\n`)
822
+ emit({ type: 'text', content: block.text })
805
823
  }
806
824
  }
807
825
  } else if (parsed.type === 'result') {
808
- // result event — forward but do NOT overwrite accumulated fullResponse
809
826
  const resultText = parsed.result || ''
810
827
  if (resultText) {
811
- const event = JSON.stringify({ type: 'result', content: resultText })
812
- res.write(`data: ${event}\n\n`)
813
- // Use result as fullResponse only if nothing was accumulated
814
- // (single-turn responses without deltas)
815
- if (!fullResponse) {
816
- fullResponse = resultText
817
- }
828
+ emit({ type: 'result', content: resultText })
829
+ if (!fullResponse) fullResponse = resultText
818
830
  }
819
831
  }
820
832
  } catch {
821
- // Non-JSON line — ignore
822
833
  console.warn(`[Chat] ignoring non-JSON line: ${line.substring(0, 80)}`)
823
834
  }
824
835
  }
@@ -830,72 +841,26 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
830
841
  console.error(`[Chat] stderr: ${text}`)
831
842
  })
832
843
 
833
- child.on('close', async (code) => {
834
- activeChatChild = null
835
-
844
+ child.on('close', (code) => {
845
+ activeRef.current = null
836
846
  console.log(`[Chat] child closed: code=${code}, response=${fullResponse.length}chars, turns=${turnCount}, stderr=${stderrBuffer.length}bytes, session=${resolvedSessionId}`)
837
847
  if (stderrBuffer.trim()) {
838
848
  console.log(`[Chat] final stderr (tail 500): ${stderrBuffer.slice(-500)}`)
839
849
  }
840
-
841
- // For new sessions, the user message was already persisted under
842
- // pendingSessionId before spawn. Rekey it to the real session ID when
843
- // Claude CLI reported one; otherwise leave the message under the
844
- // pending key so the history is never lost on crash.
845
- const persistSessionId = resolvedSessionId || pendingSessionId
846
- try {
847
- if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
848
- chatStore.rekeySession(pendingSessionId, resolvedSessionId)
849
- }
850
- // Persist any partial response we managed to collect, even on error
851
- if (fullResponse && persistSessionId) {
852
- await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
853
- }
854
- } catch (err) {
855
- console.error('[Chat] failed to persist assistant message:', err.message)
856
- }
857
-
858
850
  if (code !== 0) {
859
851
  const errorMsg = stderrBuffer.trim() || `Claude CLI exited with code ${code}`
860
852
  console.error(`[Chat] CLI failed (exit ${code}, partial=${!!fullResponse}): ${errorMsg}`)
861
- const errorEvent = JSON.stringify({
862
- type: 'error',
863
- error: errorMsg,
864
- partial: !!fullResponse,
865
- exit_code: code,
866
- })
867
- res.write(`data: ${errorEvent}\n\n`)
868
- }
869
-
870
- // Load current turn count from session for the done event
871
- let totalTurnCount = turnCount
872
- try {
873
- const session = await chatStore.load(workspaceId)
874
- totalTurnCount = session?.turn_count || turnCount
875
- } catch (err) {
876
- console.error('[Chat] failed to load session for done event:', err.message)
853
+ emit({ type: 'error', error: errorMsg, partial: !!fullResponse, exit_code: code })
877
854
  }
878
-
879
- const doneEvent = JSON.stringify({
880
- type: 'done',
881
- session_id: resolvedSessionId,
882
- turn_count: totalTurnCount,
883
- })
884
- res.write(`data: ${doneEvent}\n\n`)
885
- resolve()
855
+ // Resolve (don't reject) on non-zero exit so the manager persists the
856
+ // partial response and emits a clean terminal `done`.
857
+ resolve({ fullResponse, resolvedSessionId, turnCount })
886
858
  })
887
859
 
888
860
  child.on('error', (err) => {
889
- activeChatChild = null
861
+ activeRef.current = null
890
862
  console.error(`[Chat] spawn error: ${err.message}`)
891
- const errorEvent = JSON.stringify({ type: 'error', error: `Failed to start Claude CLI: ${err.message}` })
892
- res.write(`data: ${errorEvent}\n\n`)
893
- reject(err)
894
- })
895
-
896
- // Handle client disconnect
897
- res.on('close', () => {
898
- child.kill('SIGTERM')
863
+ reject(new Error(`Failed to start Claude CLI: ${err.message}`))
899
864
  })
900
865
  })
901
866
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "4.5.1",
3
+ "version": "4.7.0",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
package/rules/core.md CHANGED
@@ -132,7 +132,15 @@ curl -X POST http://localhost:8080/api/web/extract \
132
132
 
133
133
  レシピは初回アクセス時に LLM (Haiku) で生成・SQLite (`page_recipes` テーブル) に保存され、2回目以降の構造的に同じページでは LLM 呼び出しなしで抽出される。
134
134
 
135
- Playwright MCP (`mcp__playwright__*`) **フォーム入力・クリック・複数画面遷移など対話的な操作**が必要な場合のみ使用すること。単に「ページを読む」目的では MCP を使わない。
135
+ **SPA / 無限スクロール (v4.7.0〜):** SPA (クライアント描画) は内部で DOM が静止するまで待ってから抽出するためそのまま利用できる。無限スクロールで件数を確保したい場合は `scroll` オプションを付ける(どれだけ集めるかは呼び出し側が指定。`scrollInfo.reachedTarget=false` なら上限打ち切りなので `maxScrolls`/`maxMs` を上げて再試行):
136
+
137
+ ```bash
138
+ curl -X POST http://localhost:8080/api/web/extract \
139
+ -H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
140
+ -d '{"url": "対象URL", "scroll": {"strategy": "count", "targetItems": 50, "maxScrolls": 20}}'
141
+ ```
142
+
143
+ Playwright MCP (`mcp__playwright__*`) は **フォーム入力・クリック・複数画面遷移・「もっと見る」ボタンなど対話的な操作**が必要な場合のみ使用すること。単に「ページを読む」目的 (SPA・無限スクロール含む) では MCP を使わない。
136
144
 
137
145
  **実験的機能**: レスポンス形状は予告なく変わる可能性がある。要件: (1) primary LLM 設定済み (`PUT /api/llm/config` で `claude` 等を選択、`hq llm primary <name>` でも可) または `ANTHROPIC_API_KEY` シークレット設定済み、(2) ホスト上で `npx playwright install chromium` 実行済み。primary LLM が設定されていれば API キー不要 (Claude Code CLI の認証情報を再利用)。
138
146