@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.
@@ -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
  ''
@@ -425,6 +470,7 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
425
470
  '成果物の保存先は以下のルールに従うこと。`/home/minion/` 直下にファイルを保存しないこと。',
426
471
  '',
427
472
  '- **テキスト成果物**(レポート、調査結果、要約等)→ ノートに保存: `hq note create <project_id> --title "タイトル" --content "本文"` (プロジェクト紐づけなしは `hq note create --workspace <ws_id> ...`)',
473
+ ' - ロックされたノートの更新は `HTTP 423`(`note_locked`)で拒否される。解除は人間のみ可能なので、上書きを繰り返さずユーザーに依頼すること。',
428
474
  '- **バイナリファイル**(PDF、画像、ZIP等)→ `~/files/` に配置(ユーザーがHQからダウンロード可能)',
429
475
  '- **一時ファイル** → `/tmp/` に配置(作業後に削除)',
430
476
  '',
@@ -569,99 +615,109 @@ function getLlmBinary() {
569
615
  }
570
616
 
571
617
  /**
572
- * Stream LLM CLI output as SSE events.
573
- * Uses --resume to continue existing sessions.
574
- * Tracks block types to correctly forward tool_use vs text events
575
- * 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 }>}
576
660
  */
577
- async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
578
- // Plugin system path: Primary is set → delegate to plugin
661
+ function buildInvoke(prompt, sessionId) {
579
662
  const primary = getActivePrimary()
580
663
  if (primary) {
581
- return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
664
+ return (emit, activeRef) => invokeViaPlugin(primary, prompt, sessionId, emit, activeRef)
582
665
  }
583
- return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
666
+ return (emit, activeRef) => invokeViaLegacy(prompt, sessionId, emit, activeRef)
584
667
  }
585
668
 
586
- async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
669
+ async function invokeViaPlugin(plugin, prompt, sessionId, emit, activeRef) {
587
670
  const input = { prompt }
588
- const activeRef = { current: null }
589
- activeChatChild = { kill: () => activeRef.current?.kill?.('SIGTERM') }
590
-
591
671
  let fullResponse = ''
592
672
  let resolvedSessionId = sessionId || null
593
673
  let turnCount = 0
594
674
 
595
- 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 => {
596
678
  if (event.type === 'session') {
597
679
  resolvedSessionId = event.sessionId
598
680
  } else if (event.type === 'delta') {
599
681
  fullResponse += event.content
600
- res.write(`data: ${JSON.stringify({ type: 'delta', content: event.content })}\n\n`)
682
+ emit({ type: 'delta', content: event.content })
601
683
  } else if (event.type === 'text') {
602
684
  fullResponse += event.content
603
- res.write(`data: ${JSON.stringify({ type: 'text', content: event.content })}\n\n`)
685
+ emit({ type: 'text', content: event.content })
604
686
  turnCount++
605
687
  } else {
606
- res.write(`data: ${JSON.stringify(event)}\n\n`)
688
+ emit(event)
607
689
  }
608
690
  }
609
691
 
610
- res.on('close', () => { activeRef.current?.kill?.('SIGTERM') })
611
-
612
- let pluginError = null
613
692
  try {
614
693
  let output
615
694
  if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
616
- output = await plugin.stream(input, emit, { resumeSessionId: sessionId, activeChildRef: activeRef })
695
+ output = await plugin.stream(input, onEvent, { resumeSessionId: sessionId, activeChildRef: activeRef })
617
696
  } else {
618
697
  output = await plugin.invoke(input)
619
698
  if (output.text) {
620
699
  fullResponse = output.text
621
- res.write(`data: ${JSON.stringify({ type: 'text', content: output.text })}\n\n`)
700
+ emit({ type: 'text', content: output.text })
622
701
  turnCount = 1
623
702
  }
624
703
  if (output.error) {
625
- res.write(`data: ${JSON.stringify({ type: 'error', error: output.error.message })}\n\n`)
704
+ emit({ type: 'error', error: output.error.message })
626
705
  }
627
706
  }
628
707
  resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
629
- } catch (err) {
630
- // Swallow here so we can persist any partial response first; rethrow below.
631
- pluginError = err
632
- } finally {
633
- activeChatChild = null
634
- }
635
-
636
- // For new sessions, the user message was persisted under pendingSessionId
637
- // before the plugin call. Rekey it to the real session ID now that we
638
- // know it. If the plugin never reported a session ID, leave the message
639
- // under the pending key so the history isn't lost.
640
- const persistSessionId = resolvedSessionId || pendingSessionId
641
- try {
642
- if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
643
- chatStore.rekeySession(pendingSessionId, resolvedSessionId)
644
- }
645
- if (fullResponse && persistSessionId) {
646
- await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
708
+ if (output?.error) {
709
+ emit({ type: 'error', error: output.error.message, partial: !!fullResponse })
647
710
  }
648
711
  } catch (err) {
649
- 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 })
650
715
  }
651
716
 
652
- if (pluginError) throw pluginError
653
-
654
- let totalTurnCount = turnCount
655
- try {
656
- const session = await chatStore.load(workspaceId)
657
- totalTurnCount = session?.turn_count || turnCount
658
- } catch (err) {
659
- console.error('[Chat] failed to load session for done event:', err.message)
660
- }
661
- res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
717
+ return { fullResponse, resolvedSessionId, turnCount }
662
718
  }
663
719
 
664
- function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
720
+ function invokeViaLegacy(prompt, sessionId, emit, activeRef) {
665
721
  return new Promise((resolve, reject) => {
666
722
  const binaryName = getLlmBinary()
667
723
  if (!binaryName) {
@@ -672,20 +728,8 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
672
728
  const binary = fs.existsSync(binaryPath) ? binaryPath : binaryName
673
729
 
674
730
  // Build CLI args (no --max-turns: allow unlimited turns for task completion)
675
- const args = [
676
- '-p',
677
- '--verbose',
678
- '--model', 'sonnet',
679
- '--output-format', 'stream-json',
680
- ]
681
-
682
- // Resume existing session
683
- if (sessionId) {
684
- args.push('--resume', sessionId)
685
- }
686
-
687
- // Prompt is passed via stdin (not as CLI argument) to avoid
688
- // 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)
689
733
 
690
734
  console.log(`[Chat] spawning: ${binary} ${sessionId ? `--resume ${sessionId}` : '(new session)'} (cwd: ${config.HOME_DIR})`)
691
735
 
@@ -697,21 +741,17 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
697
741
  timeout: 3600000, // 60 min — allow long-running tasks to complete
698
742
  })
699
743
 
700
- // Track active child process for abort
701
- activeChatChild = child
744
+ // Expose the child to the run manager for abort. NOT tied to any HTTP request.
745
+ activeRef.current = child
702
746
 
703
- // Write prompt to stdin and close — claude -p reads from stdin when no positional arg
704
747
  child.stdin.write(prompt)
705
748
  child.stdin.end()
706
-
707
749
  console.log(`[Chat] child PID: ${child.pid}`)
708
750
 
709
751
  let fullResponse = ''
710
752
  let stderrBuffer = ''
711
753
  let lineBuffer = ''
712
754
  let resolvedSessionId = sessionId || null
713
-
714
- // Block-type state tracking for correct event forwarding
715
755
  let currentBlockType = null // 'text' | 'tool_use' | null
716
756
  let currentToolName = null
717
757
  let toolInputBuffer = ''
@@ -720,7 +760,6 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
720
760
  child.stdout.on('data', (data) => {
721
761
  lineBuffer += data.toString()
722
762
  const parts = lineBuffer.split('\n')
723
- // Keep the last (potentially incomplete) line in the buffer
724
763
  lineBuffer = parts.pop() || ''
725
764
 
726
765
  for (const line of parts) {
@@ -728,96 +767,69 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
728
767
  try {
729
768
  const parsed = JSON.parse(line)
730
769
 
731
- // system init event — capture session_id
732
770
  if (parsed.type === 'system' && parsed.session_id) {
733
771
  resolvedSessionId = parsed.session_id
734
772
  console.log(`[Chat] session_id: ${resolvedSessionId}`)
735
773
  }
736
774
 
737
- // content_block_start — track block type
738
775
  if (parsed.type === 'content_block_start') {
739
776
  const blockType = parsed.content_block?.type
740
777
  if (blockType === 'tool_use') {
741
778
  currentBlockType = 'tool_use'
742
779
  currentToolName = parsed.content_block.name || 'unknown'
743
780
  toolInputBuffer = ''
744
- const event = JSON.stringify({
745
- type: 'tool_start',
746
- tool: currentToolName,
747
- })
748
- res.write(`data: ${event}\n\n`)
781
+ emit({ type: 'tool_start', tool: currentToolName })
749
782
  } else if (blockType === 'text') {
750
783
  currentBlockType = 'text'
751
784
  }
752
785
  }
753
786
 
754
- // content_block_delta — handle both text and tool input
755
787
  if (parsed.type === 'content_block_delta') {
756
788
  const deltaType = parsed.delta?.type
757
789
  if (deltaType === 'input_json_delta' && currentBlockType === 'tool_use') {
758
- // Accumulate tool input JSON
759
790
  const partial = parsed.delta.partial_json || ''
760
791
  if (partial) {
761
792
  toolInputBuffer += partial
762
- const event = JSON.stringify({ type: 'tool_input_delta', partial_json: partial })
763
- res.write(`data: ${event}\n\n`)
793
+ emit({ type: 'tool_input_delta', partial_json: partial })
764
794
  }
765
795
  } else {
766
- // Text delta
767
796
  const delta = parsed.delta?.text || ''
768
797
  if (delta) {
769
798
  fullResponse += delta
770
- const event = JSON.stringify({ type: 'delta', content: delta })
771
- res.write(`data: ${event}\n\n`)
799
+ emit({ type: 'delta', content: delta })
772
800
  }
773
801
  }
774
802
  }
775
803
 
776
- // content_block_stop — only emit tool_end for tool_use blocks (bug fix)
777
804
  if (parsed.type === 'content_block_stop') {
778
805
  if (currentBlockType === 'tool_use') {
779
- // Try to parse the accumulated tool input
780
806
  let parsedInput = null
781
807
  try {
782
808
  if (toolInputBuffer) parsedInput = JSON.parse(toolInputBuffer)
783
809
  } catch { /* partial or invalid JSON */ }
784
- const event = JSON.stringify({
785
- type: 'tool_end',
786
- tool: currentToolName,
787
- input: parsedInput,
788
- })
789
- res.write(`data: ${event}\n\n`)
810
+ emit({ type: 'tool_end', tool: currentToolName, input: parsedInput })
790
811
  }
791
812
  currentBlockType = null
792
813
  currentToolName = null
793
814
  toolInputBuffer = ''
794
815
  }
795
816
 
796
- // assistant message — count turns and forward text blocks
797
817
  if (parsed.type === 'assistant' && parsed.message) {
798
818
  turnCount++
799
819
  for (const block of (parsed.message.content || [])) {
800
820
  if (block.type === 'text') {
801
821
  fullResponse += block.text
802
- const event = JSON.stringify({ type: 'text', content: block.text })
803
- res.write(`data: ${event}\n\n`)
822
+ emit({ type: 'text', content: block.text })
804
823
  }
805
824
  }
806
825
  } else if (parsed.type === 'result') {
807
- // result event — forward but do NOT overwrite accumulated fullResponse
808
826
  const resultText = parsed.result || ''
809
827
  if (resultText) {
810
- const event = JSON.stringify({ type: 'result', content: resultText })
811
- res.write(`data: ${event}\n\n`)
812
- // Use result as fullResponse only if nothing was accumulated
813
- // (single-turn responses without deltas)
814
- if (!fullResponse) {
815
- fullResponse = resultText
816
- }
828
+ emit({ type: 'result', content: resultText })
829
+ if (!fullResponse) fullResponse = resultText
817
830
  }
818
831
  }
819
832
  } catch {
820
- // Non-JSON line — ignore
821
833
  console.warn(`[Chat] ignoring non-JSON line: ${line.substring(0, 80)}`)
822
834
  }
823
835
  }
@@ -829,72 +841,26 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
829
841
  console.error(`[Chat] stderr: ${text}`)
830
842
  })
831
843
 
832
- child.on('close', async (code) => {
833
- activeChatChild = null
834
-
844
+ child.on('close', (code) => {
845
+ activeRef.current = null
835
846
  console.log(`[Chat] child closed: code=${code}, response=${fullResponse.length}chars, turns=${turnCount}, stderr=${stderrBuffer.length}bytes, session=${resolvedSessionId}`)
836
847
  if (stderrBuffer.trim()) {
837
848
  console.log(`[Chat] final stderr (tail 500): ${stderrBuffer.slice(-500)}`)
838
849
  }
839
-
840
- // For new sessions, the user message was already persisted under
841
- // pendingSessionId before spawn. Rekey it to the real session ID when
842
- // Claude CLI reported one; otherwise leave the message under the
843
- // pending key so the history is never lost on crash.
844
- const persistSessionId = resolvedSessionId || pendingSessionId
845
- try {
846
- if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
847
- chatStore.rekeySession(pendingSessionId, resolvedSessionId)
848
- }
849
- // Persist any partial response we managed to collect, even on error
850
- if (fullResponse && persistSessionId) {
851
- await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
852
- }
853
- } catch (err) {
854
- console.error('[Chat] failed to persist assistant message:', err.message)
855
- }
856
-
857
850
  if (code !== 0) {
858
851
  const errorMsg = stderrBuffer.trim() || `Claude CLI exited with code ${code}`
859
852
  console.error(`[Chat] CLI failed (exit ${code}, partial=${!!fullResponse}): ${errorMsg}`)
860
- const errorEvent = JSON.stringify({
861
- type: 'error',
862
- error: errorMsg,
863
- partial: !!fullResponse,
864
- exit_code: code,
865
- })
866
- res.write(`data: ${errorEvent}\n\n`)
867
- }
868
-
869
- // Load current turn count from session for the done event
870
- let totalTurnCount = turnCount
871
- try {
872
- const session = await chatStore.load(workspaceId)
873
- totalTurnCount = session?.turn_count || turnCount
874
- } catch (err) {
875
- console.error('[Chat] failed to load session for done event:', err.message)
853
+ emit({ type: 'error', error: errorMsg, partial: !!fullResponse, exit_code: code })
876
854
  }
877
-
878
- const doneEvent = JSON.stringify({
879
- type: 'done',
880
- session_id: resolvedSessionId,
881
- turn_count: totalTurnCount,
882
- })
883
- res.write(`data: ${doneEvent}\n\n`)
884
- 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 })
885
858
  })
886
859
 
887
860
  child.on('error', (err) => {
888
- activeChatChild = null
861
+ activeRef.current = null
889
862
  console.error(`[Chat] spawn error: ${err.message}`)
890
- const errorEvent = JSON.stringify({ type: 'error', error: `Failed to start Claude CLI: ${err.message}` })
891
- res.write(`data: ${errorEvent}\n\n`)
892
- reject(err)
893
- })
894
-
895
- // Handle client disconnect
896
- res.on('close', () => {
897
- child.kill('SIGTERM')
863
+ reject(new Error(`Failed to start Claude CLI: ${err.message}`))
898
864
  })
899
865
  })
900
866
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "4.4.0",
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
 
@@ -238,7 +246,7 @@ Routine 実行中は以下もtmuxセッション環境で利用可能:
238
246
  - `MINION_ROUTINE_NAME` — ルーティン名
239
247
  - `MINION_ROUTINE_WORKSPACE_ID` — ルーティンが特定ワークスペースにバインドされている場合のワークスペースUUID(未バインドなら空文字)
240
248
 
241
- **変数**(ワークスペース変数・プロジェクト変数・ワークフロー変数・ミニオン変数)はスキル本文の `{{VAR_NAME}}` テンプレートとして実行時に展開される。スキル作成時にパラメータ化したい値は `{{変数名}}` で記述すること。展開優先順位: ワークスペース < プロジェクト < ワークフロー < ミニオン(後者が優先)。ミニオンが最終オーバーライドとなる設計で、ミニオン運用者がHQ側のデフォルトを差し替えられる経路を保証する。ミニオン変数はさらにミニオン全体スコープとワークスペース別スコープに分かれ、当該ワークスペースのコンテキストで動く実行時はWS別がミニオン全体を上書きする。`/api/variables/*` は `?workspace_id=<uuid>` でスコープ指定(省略時はミニオン全体)。
249
+ **変数**(ワークスペース変数・プロジェクト変数・ワークフロー変数・ミニオン変数)はスキル本文の `{{VAR_NAME}}` テンプレートとして実行時に展開される。スキル作成時にパラメータ化したい値は `{{変数名}}` で記述すること。展開優先順位: ワークスペース < プロジェクト < ワークフロー < ミニオン(後者が優先)。ミニオンが最終オーバーライドとなる設計で、ミニオン運用者がHQ側のデフォルトを差し替えられる経路を保証する。ミニオン変数はさらにミニオン全体スコープとワークスペース別スコープに分かれ、当該ワークスペースのコンテキストで動く実行時はWS別がミニオン全体を上書きする。`/api/variables/*` は `?workspace_id=<uuid>` でスコープ指定(省略時はミニオン全体)。誤ったスコープに登録してしまった変数・シークレットは `POST /api/variables/:key/move`・`POST /api/secrets/:key/move`(body: `{from_workspace_id, to_workspace_id}`)で削除・再登録せずに別スコープへ移動できる。移動先に同名キーがある場合は上書きせず `409` を返す。
242
250
 
243
251
  DAGワークフローでは、HQ側スコープ(workspace/project/dag_workflow)の変数は **`POST /api/dag/minion/claim-node` のレスポンス `template_vars`** に乗って降ってきて、ミニオン側で minion-local 変数とマージしたうえで `POST /api/skills/fetch/:name?vars=...` の base64 JSON に詰めて HQ に渡し、HQ 側で SKILL.md の `{{VAR}}` を置換して返す。つまり HQ-scope 変数の更新は **次のノード claim から反映される**(既存のミニオン上 SKILL.md は次回 fetch で上書きされる)。DAGワークフロー単位の変数は `dag_workflow_variables` テーブルに格納され、DAGエディタの "Variables" ボタンから編集可能。
244
252
 
@@ -485,6 +493,8 @@ hq note search --workspace <workspace_id> "キーワード"
485
493
 
486
494
  **削除はミニオンから実行できない**(事故防止のため人間操作限定)。不要なノートは `hq note update ... --status archived` (または PATCH の `status: "archived"`) でアーカイブすること。
487
495
 
496
+ **ロックされたノートは更新できない。** ユーザーがパスワード等を保護するために「ロック」したノートは、`hq note update` / PATCH が `HTTP 423`(`{"code":"note_locked"}`)で拒否される。**ロック解除は人間のみ**が HQ ダッシュボードで行えるため、ミニオンは解除できない。423 を受けたら上書きを繰り返さず、更新が必要な場合は threads でユーザーにロック解除を依頼すること。
497
+
488
498
  ### ノート内のユーザーメンション
489
499
 
490
500
  人間ユーザーがノート編集UIで `Ctrl+I` を押すと、自分の発言を示すアバター付きチップが行頭に挿入される。マークダウン上は次の形式で永続化される: