@geekbeer/minion 4.1.3 → 4.2.1

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.
@@ -133,6 +133,7 @@ function save(session) {
133
133
  * Add a message to a session.
134
134
  * Creates a new session if none exists for the given session_id.
135
135
  * Past sessions are preserved (not deleted).
136
+ * Wrapped in a transaction so partial saves can't happen on failure.
136
137
  * @param {string} sessionId - Claude CLI session ID
137
138
  * @param {{ role: string, content: string }} msg - Message to add
138
139
  * @param {number} [turnCount] - Optional turn count to add
@@ -140,40 +141,71 @@ function save(session) {
140
141
  */
141
142
  function addMessage(sessionId, msg, turnCount, workspaceId) {
142
143
  const db = getDb()
143
- const existing = db.prepare('SELECT * FROM chat_sessions WHERE session_id = ?').get(sessionId)
144
+ const tx = db.transaction(() => {
145
+ const existing = db.prepare('SELECT session_id FROM chat_sessions WHERE session_id = ?').get(sessionId)
146
+
147
+ if (!existing) {
148
+ const now = Date.now()
149
+ db.prepare(
150
+ 'INSERT INTO chat_sessions (session_id, workspace_id, turn_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
151
+ ).run(sessionId, workspaceId || null, 0, now, now)
152
+ }
144
153
 
145
- // If session doesn't exist, create a new one (past sessions remain in DB)
146
- if (!existing) {
147
- const now = Date.now()
148
154
  db.prepare(
149
- 'INSERT INTO chat_sessions (session_id, workspace_id, turn_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
150
- ).run(sessionId, workspaceId || null, 0, now, now)
151
- }
155
+ 'INSERT INTO chat_messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)'
156
+ ).run(sessionId, msg.role, msg.content, Date.now())
152
157
 
153
- // Insert message
154
- db.prepare(
155
- 'INSERT INTO chat_messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)'
156
- ).run(sessionId, msg.role, msg.content, Date.now())
157
-
158
- // Update session metadata
159
- const newTurnCount = (turnCount && turnCount > 0) ? turnCount : 0
160
- db.prepare(`
161
- UPDATE chat_sessions
162
- SET updated_at = ?,
163
- turn_count = turn_count + ?
164
- WHERE session_id = ?
165
- `).run(Date.now(), newTurnCount, sessionId)
166
-
167
- // Prune old messages for this session (keep only MAX_MESSAGES most recent)
168
- const count = db.prepare('SELECT COUNT(*) as cnt FROM chat_messages WHERE session_id = ?').get(sessionId).cnt
169
- if (count > MAX_MESSAGES) {
158
+ const newTurnCount = (turnCount && turnCount > 0) ? turnCount : 0
170
159
  db.prepare(`
171
- DELETE FROM chat_messages WHERE id IN (
172
- SELECT id FROM chat_messages WHERE session_id = ?
173
- ORDER BY id ASC LIMIT ?
174
- )
175
- `).run(sessionId, count - MAX_MESSAGES)
176
- }
160
+ UPDATE chat_sessions
161
+ SET updated_at = ?,
162
+ turn_count = turn_count + ?
163
+ WHERE session_id = ?
164
+ `).run(Date.now(), newTurnCount, sessionId)
165
+
166
+ const count = db.prepare('SELECT COUNT(*) as cnt FROM chat_messages WHERE session_id = ?').get(sessionId).cnt
167
+ if (count > MAX_MESSAGES) {
168
+ db.prepare(`
169
+ DELETE FROM chat_messages WHERE id IN (
170
+ SELECT id FROM chat_messages WHERE session_id = ?
171
+ ORDER BY id ASC LIMIT ?
172
+ )
173
+ `).run(sessionId, count - MAX_MESSAGES)
174
+ }
175
+ })
176
+ tx()
177
+ }
178
+
179
+ /**
180
+ * Rename a session's ID, carrying over all messages.
181
+ * Used when a pending session ID (generated locally before the LLM call) is
182
+ * replaced by the real session_id returned by Claude CLI.
183
+ * If `newSessionId` already exists, messages from `oldSessionId` are merged
184
+ * into it and the old session row is dropped.
185
+ * @param {string} oldSessionId
186
+ * @param {string} newSessionId
187
+ * @returns {boolean} true if any rows were touched
188
+ */
189
+ function rekeySession(oldSessionId, newSessionId) {
190
+ if (!oldSessionId || !newSessionId || oldSessionId === newSessionId) return false
191
+ const db = getDb()
192
+ const tx = db.transaction(() => {
193
+ const oldSession = db.prepare('SELECT * FROM chat_sessions WHERE session_id = ?').get(oldSessionId)
194
+ if (!oldSession) return false
195
+
196
+ const existingNew = db.prepare('SELECT session_id FROM chat_sessions WHERE session_id = ?').get(newSessionId)
197
+ if (existingNew) {
198
+ db.prepare('UPDATE chat_messages SET session_id = ? WHERE session_id = ?').run(newSessionId, oldSessionId)
199
+ db.prepare('UPDATE chat_sessions SET updated_at = ?, turn_count = turn_count + ? WHERE session_id = ?')
200
+ .run(Date.now(), oldSession.turn_count || 0, newSessionId)
201
+ db.prepare('DELETE FROM chat_sessions WHERE session_id = ?').run(oldSessionId)
202
+ } else {
203
+ db.prepare('UPDATE chat_sessions SET session_id = ? WHERE session_id = ?').run(newSessionId, oldSessionId)
204
+ db.prepare('UPDATE chat_messages SET session_id = ? WHERE session_id = ?').run(newSessionId, oldSessionId)
205
+ }
206
+ return true
207
+ })
208
+ return tx() === true
177
209
  }
178
210
 
179
211
  /**
@@ -208,4 +240,4 @@ function deleteSession(sessionId) {
208
240
  return result.changes > 0
209
241
  }
210
242
 
211
- module.exports = { load, loadById, listSessions, save, addMessage, clear, deleteSession }
243
+ module.exports = { load, loadById, listSessions, save, addMessage, rekeySession, clear, deleteSession }
@@ -809,20 +809,22 @@ Base path: All endpoints are prefixed with `/api/minion` by the agent's API clie
809
809
  |--------|----------|-------------|
810
810
  | GET | `/api/minion/me/projects` | 参加プロジェクト一覧(role 含む) |
811
811
 
812
+ Query parameters:
813
+ - `workspace_id` (optional, UUID): 指定したワークスペースに属するプロジェクトのみ返す。省略すると参加している全プロジェクト(複数ワークスペース横断)を返す。
814
+
812
815
  Response:
813
816
  ```json
814
- [
815
- {
816
- "id": "uuid",
817
- "name": "project-name",
818
- "description": "...",
819
- "github_owner": "org",
820
- "github_repo": "repo",
821
- "role": "pm",
822
- "created_at": "...",
823
- "updated_at": "..."
824
- }
825
- ]
817
+ {
818
+ "projects": [
819
+ {
820
+ "id": "uuid",
821
+ "name": "project-name",
822
+ "description": "...",
823
+ "workspace_id": "uuid",
824
+ "role": "pm"
825
+ }
826
+ ]
827
+ }
826
828
  ```
827
829
 
828
830
  `role` is one of `"pm"` (project manager), `"engineer"`, or `"accountant"`.
@@ -2107,56 +2109,74 @@ DAGスキルノードの `skill_version_id` は **オプション** です。
2107
2109
  ローカルエージェントの `/api/project-memories` は上記 HQ API へのプロキシ。
2108
2110
  詳細なリクエスト/レスポンス仕様はローカル API セクションの「Project Memories」を参照。
2109
2111
 
2110
- ### Notes (HQ, プロジェクトノート)
2112
+ ### Notes (HQ, ワークスペース/プロジェクトノート)
2113
+
2114
+ ノートはワークスペース直下に保存され、任意で 1 つ以上のプロジェクトにリンクできる(`note_project_links`)。
2115
+ **テキストベースの成果物(レポート、調査結果、要約等)はノートに保存すること。** HQダッシュボードでユーザーが即座に閲覧・編集・検索でき、バージョン管理もされる。
2116
+
2117
+ ミニオンには**ワークスペース経由API**と**プロジェクト経由API**の2系統がある:
2111
2118
 
2112
- プロジェクトに紐づくノート。**テキストベースの成果物(レポート、調査結果、要約等)はノートに保存すること。**
2113
- ノートはHQダッシュボードでユーザーが即座に閲覧・編集・検索でき、バージョン管理もされる。
2119
+ - **ワークスペース経由** — 自分が所属するワークスペース内のすべてのノートにアクセス可能 (プロジェクト未リンクのノート含む)。一般的な検索・整理に使う
2120
+ - **プロジェクト経由** — 作業コンテキストが特定プロジェクトに紐づいているときに、そのプロジェクトのノートだけを絞り込む用途。プロジェクトメンバーシップで認可される
2121
+
2122
+ **削除エンドポイントは存在しない**(事故防止のため人間操作限定)。アーカイブが必要な場合は `status=archived` で PATCH すること。
2123
+
2124
+ #### Workspace-scoped Notes
2114
2125
 
2115
2126
  | Method | Endpoint | Description |
2116
2127
  |--------|----------|-------------|
2117
- | GET | `/api/minion/projects/:projectId/notes` | プロジェクトのノート一覧。Query: `?status=active&include_content=true` |
2118
- | POST | `/api/minion/projects/:projectId/notes` | ノートを作成しプロジェクトにリンク |
2119
- | GET | `/api/minion/projects/:projectId/notes/:noteId` | ノート詳細(全文含む) |
2120
- | PATCH | `/api/minion/projects/:projectId/notes/:noteId` | ノートを更新(内容変更時にバージョン自動作成) |
2121
- | GET | `/api/minion/projects/:projectId/notes/search?q=keyword` | ノートを全文検索 |
2128
+ | GET | `/api/minion/workspaces/:id/notes` | ワークスペースのノート一覧。Query: `?status=active&search=&project_id=&include_content=true` |
2129
+ | POST | `/api/minion/workspaces/:id/notes` | ノート作成。Body: `{title, content?, project_ids?: string[]}`。`project_ids` 指定時は自動でリンク |
2130
+ | GET | `/api/minion/workspaces/:id/notes/:noteId` | ノート詳細(全文) |
2131
+ | PATCH | `/api/minion/workspaces/:id/notes/:noteId` | 更新(内容変更時にバージョン自動作成)。Body: `{title?, content?, status?, change_summary?}` |
2132
+ | GET | `/api/minion/workspaces/:id/notes/search?q=keyword` | ワークスペース全体を全文検索 |
2122
2133
 
2123
- POST `/api/minion/projects/:projectId/notes` body:
2134
+ POST `/api/minion/workspaces/:id/notes` body:
2124
2135
  ```json
2125
2136
  {
2126
2137
  "title": "調査レポート: ユーザー行動分析",
2127
- "content": "## 概要\n..."
2138
+ "content": "## 概要\n...",
2139
+ "project_ids": ["<uuid>"]
2128
2140
  }
2129
2141
  ```
2130
2142
 
2131
2143
  | Field | Type | Required | Description |
2132
2144
  |-------|------|----------|-------------|
2133
- | `title` | string | Yes | ノートのタイトル |
2134
- | `content` | string | No | ノートの本文(Markdown |
2145
+ | `title` | string | Yes | ワークスペース内で一意である必要がある |
2146
+ | `content` | string | No | Markdown本文 |
2147
+ | `project_ids` | string[] | No | リンクするプロジェクト(ワークスペースに属している必要あり) |
2135
2148
 
2136
- PATCH `/api/minion/projects/:projectId/notes/:noteId` body:
2137
- ```json
2138
- {
2139
- "title": "更新されたタイトル",
2140
- "content": "更新された本文",
2141
- "change_summary": "データを追加"
2142
- }
2143
- ```
2149
+ #### Project-scoped Notes
2144
2150
 
2145
- | Field | Type | Required | Description |
2146
- |-------|------|----------|-------------|
2147
- | `title` | string | No | 新しいタイトル |
2148
- | `content` | string | No | 新しい本文 |
2149
- | `change_summary` | string | No | 変更の要約(バージョン履歴に記録) |
2151
+ 特定プロジェクトのノートだけを絞り込みたいときに使う。認可は `project_members` を経由。
2152
+
2153
+ | Method | Endpoint | Description |
2154
+ |--------|----------|-------------|
2155
+ | GET | `/api/minion/projects/:projectId/notes` | プロジェクトのノート一覧。Query: `?status=active&include_content=true` |
2156
+ | POST | `/api/minion/projects/:projectId/notes` | ノート作成 + 当該プロジェクトに自動リンク |
2157
+ | GET | `/api/minion/projects/:projectId/notes/:noteId` | ノート詳細(全文含む) |
2158
+ | PATCH | `/api/minion/projects/:projectId/notes/:noteId` | ノートを更新(内容変更時にバージョン自動作成) |
2159
+ | GET | `/api/minion/projects/:projectId/notes/search?q=keyword` | プロジェクト内のノートを全文検索 |
2160
+
2161
+ PATCH body は workspace 版と共通: `{title?, content?, change_summary?}` (status は workspace 経由のみ受理)。
2150
2162
 
2151
2163
  #### hq CLI ラッパー
2152
2164
 
2153
2165
  ```bash
2166
+ # プロジェクトスコープ
2154
2167
  hq note create <project_id> --title "タイトル" --content "本文"
2155
2168
  hq note create <project_id> --title "タイトル" --file /tmp/report.md
2156
2169
  hq note update <project_id> <note_id> --content "更新内容" --change-summary "修正理由"
2157
2170
  hq note list <project_id>
2158
2171
  hq note get <project_id> <note_id>
2159
2172
  hq note search <project_id> "キーワード"
2173
+
2174
+ # ワークスペーススコープ (プロジェクト紐づけなしも対象)
2175
+ hq note create --workspace <workspace_id> --title "タイトル" --content "本文"
2176
+ hq note update --workspace <workspace_id> <note_id> --content "更新内容"
2177
+ hq note list --workspace <workspace_id>
2178
+ hq note get --workspace <workspace_id> <note_id>
2179
+ hq note search --workspace <workspace_id> "キーワード"
2160
2180
  ```
2161
2181
 
2162
2182
  ### Meeting Rooms 🧪 (HQ, experimental, v3.59.0〜)
package/linux/bin/hq CHANGED
@@ -19,12 +19,17 @@
19
19
  # hq list workspaces - List workspaces this minion belongs to
20
20
  # hq list dag-workflows [project_id] - List DAG workflows (optionally filter by project)
21
21
  # hq note create <project_id> --title "Title" --content "Body" [--file path]
22
- # - Create a note linked to the project
22
+ # hq note create --workspace <workspace_id> --title "Title" --content "Body" [--file path] [--project-ids <id,id>]
23
+ # - Create a note (project- or workspace-scoped)
23
24
  # hq note update <project_id> <note_id> --content "Body" [--title "Title"] [--file path] [--change-summary "summary"]
25
+ # hq note update --workspace <workspace_id> <note_id> --content "Body" [--title "Title"] [--status active|archived]
24
26
  # - Update an existing note
25
27
  # hq note list <project_id> - List notes in the project
28
+ # hq note list --workspace <workspace_id> - List notes in the workspace (incl. project-unlinked)
26
29
  # hq note get <project_id> <note_id> - Get a single note with full content
30
+ # hq note get --workspace <workspace_id> <note_id> - Same, workspace-scoped
27
31
  # hq note search <project_id> <query> - Search notes in the project
32
+ # hq note search --workspace <workspace_id> <query> - Search across the whole workspace
28
33
  # hq create dag-workflow <body.json> - Create a DAG workflow (PM only). Body: {project_id, name, graph?, ...}
29
34
  # hq put dag-workflow <id> <body.json> - Update DAG workflow draft (PM only). Body: {graph?, content?, ...}
30
35
  # hq publish dag-workflow <id> - Publish the draft as a new version (PM only, validated)
@@ -163,10 +168,15 @@ print_usage() {
163
168
  echo " hq list workspaces - List workspaces this minion belongs to" >&2
164
169
  echo " hq list dag-workflows [project_id] - List DAG workflows (optionally by project)" >&2
165
170
  echo " hq note create <project_id> --title \"Title\" --content \"Body\" [--file path]" >&2
171
+ echo " hq note create --workspace <ws_id> --title \"Title\" --content \"Body\" [--project-ids <id,id>]" >&2
166
172
  echo " hq note update <project_id> <note_id> --content \"Body\" [--title \"Title\"] [--file path]" >&2
167
- echo " hq note list <project_id> - List notes in the project" >&2
168
- echo " hq note get <project_id> <note_id> - Get a single note" >&2
169
- echo " hq note search <project_id> <query> - Search notes in the project" >&2
173
+ echo " hq note update --workspace <ws_id> <note_id> --content \"Body\" [--title \"Title\"] [--status active|archived]" >&2
174
+ echo " hq note list <project_id> - List notes in the project" >&2
175
+ echo " hq note list --workspace <ws_id> - List notes in the workspace" >&2
176
+ echo " hq note get <project_id> <note_id> - Get a single note" >&2
177
+ echo " hq note get --workspace <ws_id> <note_id> - Get a single note (workspace-scoped)" >&2
178
+ echo " hq note search <project_id> <query> - Search notes in the project" >&2
179
+ echo " hq note search --workspace <ws_id> <query> - Search notes across the workspace" >&2
170
180
  echo " hq create dag-workflow <body.json> - Create a DAG workflow (PM only)" >&2
171
181
  echo " hq put dag-workflow <id> <body.json> - Update DAG workflow draft (PM only)" >&2
172
182
  echo " hq publish dag-workflow <id> - Publish the draft as a new version (PM only)" >&2
@@ -310,22 +320,38 @@ case "${1:-}" in
310
320
 
311
321
  note)
312
322
  action="${2:-}"
323
+ shift 2 || true
324
+ # Resolve scope: either "--workspace <ws_id>" (must be first) or positional <project_id>.
325
+ note_base=""
326
+ if [ "${1:-}" = "--workspace" ]; then
327
+ ws_id="${2:-}"
328
+ if [ -z "$ws_id" ]; then
329
+ echo "Error: --workspace requires a workspace_id" >&2
330
+ exit 1
331
+ fi
332
+ note_base="$BASE_URL/workspaces/$ws_id/notes"
333
+ shift 2
334
+ else
335
+ project_id="${1:-}"
336
+ if [ -z "$project_id" ] || [ "${project_id#--}" != "$project_id" ]; then
337
+ echo "Usage: hq note $action <project_id> ... | hq note $action --workspace <ws_id> ..." >&2
338
+ exit 1
339
+ fi
340
+ note_base="$BASE_URL/projects/$project_id/notes"
341
+ shift
342
+ fi
313
343
  case "$action" in
314
344
  create)
315
- project_id="${3:-}"
316
- if [ -z "$project_id" ]; then
317
- echo "Usage: hq note create <project_id> --title \"Title\" --content \"Body\" [--file path]" >&2
318
- exit 1
319
- fi
320
- shift 3
321
345
  note_title=""
322
346
  note_content=""
323
347
  note_file=""
348
+ project_ids_csv=""
324
349
  while [ $# -gt 0 ]; do
325
350
  case "$1" in
326
351
  --title) note_title="$2"; shift 2 ;;
327
352
  --content) note_content="$2"; shift 2 ;;
328
353
  --file) note_file="$2"; shift 2 ;;
354
+ --project-ids) project_ids_csv="$2"; shift 2 ;;
329
355
  *) echo "Unknown option: $1" >&2; exit 1 ;;
330
356
  esac
331
357
  done
@@ -344,38 +370,47 @@ case "${1:-}" in
344
370
  echo "Error: --content or --file is required" >&2
345
371
  exit 1
346
372
  fi
347
- # Build JSON body using jq or python3
348
373
  if command -v jq &>/dev/null; then
349
374
  body=$(jq -n --arg t "$note_title" --arg c "$note_content" '{title: $t, content: $c}')
375
+ if [ -n "$project_ids_csv" ]; then
376
+ body=$(echo "$body" | jq --arg ids "$project_ids_csv" '. + {project_ids: ($ids | split(","))}')
377
+ fi
350
378
  elif command -v python3 &>/dev/null; then
351
- body=$(python3 -c "import json,sys; print(json.dumps({'title': sys.argv[1], 'content': sys.argv[2]}))" "$note_title" "$note_content")
379
+ body=$(python3 -c "
380
+ import json, sys
381
+ d = {'title': sys.argv[1], 'content': sys.argv[2]}
382
+ if sys.argv[3]:
383
+ d['project_ids'] = [s for s in sys.argv[3].split(',') if s]
384
+ print(json.dumps(d))
385
+ " "$note_title" "$note_content" "$project_ids_csv")
352
386
  else
353
387
  echo "Error: jq or python3 is required to build JSON" >&2
354
388
  exit 1
355
389
  fi
356
390
  tmpfile=$(mktemp)
357
391
  echo "$body" > "$tmpfile"
358
- send_json_request POST "$BASE_URL/projects/$project_id/notes" "$tmpfile"
392
+ send_json_request POST "$note_base" "$tmpfile"
359
393
  rm -f "$tmpfile"
360
394
  ;;
361
395
  update)
362
- project_id="${3:-}"
363
- note_id="${4:-}"
364
- if [ -z "$project_id" ] || [ -z "$note_id" ]; then
365
- echo "Usage: hq note update <project_id> <note_id> --content \"Body\" [--title \"Title\"] [--file path] [--change-summary \"summary\"]" >&2
396
+ note_id="${1:-}"
397
+ if [ -z "$note_id" ]; then
398
+ echo "Error: <note_id> is required" >&2
366
399
  exit 1
367
400
  fi
368
- shift 4
401
+ shift
369
402
  note_title=""
370
403
  note_content=""
371
404
  note_file=""
372
405
  change_summary=""
406
+ note_status=""
373
407
  while [ $# -gt 0 ]; do
374
408
  case "$1" in
375
409
  --title) note_title="$2"; shift 2 ;;
376
410
  --content) note_content="$2"; shift 2 ;;
377
411
  --file) note_file="$2"; shift 2 ;;
378
412
  --change-summary) change_summary="$2"; shift 2 ;;
413
+ --status) note_status="$2"; shift 2 ;;
379
414
  *) echo "Unknown option: $1" >&2; exit 1 ;;
380
415
  esac
381
416
  done
@@ -386,16 +421,16 @@ case "${1:-}" in
386
421
  fi
387
422
  note_content=$(cat "$note_file")
388
423
  fi
389
- if [ -z "$note_content" ] && [ -z "$note_title" ]; then
390
- echo "Error: at least --content, --file, or --title is required" >&2
424
+ if [ -z "$note_content" ] && [ -z "$note_title" ] && [ -z "$note_status" ]; then
425
+ echo "Error: at least --content, --file, --title, or --status is required" >&2
391
426
  exit 1
392
427
  fi
393
- # Build JSON body
394
428
  if command -v jq &>/dev/null; then
395
429
  body="{}"
396
430
  [ -n "$note_title" ] && body=$(echo "$body" | jq --arg t "$note_title" '. + {title: $t}')
397
431
  [ -n "$note_content" ] && body=$(echo "$body" | jq --arg c "$note_content" '. + {content: $c}')
398
432
  [ -n "$change_summary" ] && body=$(echo "$body" | jq --arg s "$change_summary" '. + {change_summary: $s}')
433
+ [ -n "$note_status" ] && body=$(echo "$body" | jq --arg st "$note_status" '. + {status: $st}')
399
434
  elif command -v python3 &>/dev/null; then
400
435
  body=$(python3 -c "
401
436
  import json, sys
@@ -403,20 +438,20 @@ d = {}
403
438
  if sys.argv[1]: d['title'] = sys.argv[1]
404
439
  if sys.argv[2]: d['content'] = sys.argv[2]
405
440
  if sys.argv[3]: d['change_summary'] = sys.argv[3]
441
+ if sys.argv[4]: d['status'] = sys.argv[4]
406
442
  print(json.dumps(d))
407
- " "$note_title" "$note_content" "$change_summary")
443
+ " "$note_title" "$note_content" "$change_summary" "$note_status")
408
444
  else
409
445
  echo "Error: jq or python3 is required to build JSON" >&2
410
446
  exit 1
411
447
  fi
412
448
  tmpfile=$(mktemp)
413
449
  echo "$body" > "$tmpfile"
414
- # Use PATCH for update
415
450
  response=$(curl -s -w "\n%{http_code}" -X PATCH \
416
451
  -H "Authorization: Bearer $API_TOKEN" \
417
452
  -H "Content-Type: application/json" \
418
453
  --data-binary "@$tmpfile" \
419
- "$BASE_URL/projects/$project_id/notes/$note_id")
454
+ "$note_base/$note_id")
420
455
  rm -f "$tmpfile"
421
456
  http_code=$(echo "$response" | tail -1)
422
457
  body_out=$(echo "$response" | sed '$d')
@@ -429,30 +464,22 @@ print(json.dumps(d))
429
464
  fi
430
465
  ;;
431
466
  list)
432
- project_id="${3:-}"
433
- if [ -z "$project_id" ]; then
434
- echo "Usage: hq note list <project_id>" >&2
435
- exit 1
436
- fi
437
- fetch_resource "$BASE_URL/projects/$project_id/notes?include_content=false"
467
+ fetch_resource "$note_base?include_content=false"
438
468
  ;;
439
469
  get)
440
- project_id="${3:-}"
441
- note_id="${4:-}"
442
- if [ -z "$project_id" ] || [ -z "$note_id" ]; then
443
- echo "Usage: hq note get <project_id> <note_id>" >&2
470
+ note_id="${1:-}"
471
+ if [ -z "$note_id" ]; then
472
+ echo "Error: <note_id> is required" >&2
444
473
  exit 1
445
474
  fi
446
- fetch_resource "$BASE_URL/projects/$project_id/notes/$note_id"
475
+ fetch_resource "$note_base/$note_id"
447
476
  ;;
448
477
  search)
449
- project_id="${3:-}"
450
- query="${4:-}"
451
- if [ -z "$project_id" ] || [ -z "$query" ]; then
452
- echo "Usage: hq note search <project_id> <query>" >&2
478
+ query="${1:-}"
479
+ if [ -z "$query" ]; then
480
+ echo "Error: <query> is required" >&2
453
481
  exit 1
454
482
  fi
455
- # URL-encode the query
456
483
  if command -v jq &>/dev/null; then
457
484
  encoded=$(printf '%s' "$query" | jq -sRr @uri)
458
485
  elif command -v python3 &>/dev/null; then
@@ -460,7 +487,7 @@ print(json.dumps(d))
460
487
  else
461
488
  encoded="$query"
462
489
  fi
463
- fetch_resource "$BASE_URL/projects/$project_id/notes/search?q=$encoded"
490
+ fetch_resource "$note_base/search?q=$encoded"
464
491
  ;;
465
492
  *)
466
493
  echo "Unknown note action: $action" >&2
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  const { spawn } = require('child_process')
20
+ const crypto = require('crypto')
20
21
  const fs = require('fs')
21
22
  const path = require('path')
22
23
  const { verifyToken } = require('../../core/lib/auth')
@@ -57,10 +58,16 @@ async function chatRoutes(fastify) {
57
58
  // so the user's chat log keeps just the [task:UUID] tag, not a noisy dump.
58
59
  const prompt = await buildContextPrefix(message, context, session_id, workspaceId, referenced_tasks)
59
60
 
60
- // Store user message
61
+ // Persist the user message BEFORE invoking the LLM so that crashes,
62
+ // timeouts, or unparseable CLI output can't lose it. For new sessions we
63
+ // mint a local pending ID; it gets rekeyed to the real Claude CLI session
64
+ // ID once that comes back on the SSE stream.
61
65
  const currentSessionId = session_id || null
62
- if (currentSessionId) {
63
- await chatStore.addMessage(currentSessionId, { role: 'user', content: message }, undefined, workspaceId)
66
+ const pendingSessionId = currentSessionId || `pending-${crypto.randomUUID()}`
67
+ try {
68
+ await chatStore.addMessage(pendingSessionId, { role: 'user', content: message }, undefined, workspaceId)
69
+ } catch (err) {
70
+ console.error('[Chat] failed to persist user message:', err.message)
64
71
  }
65
72
 
66
73
  // Take over response handling from Fastify for SSE streaming
@@ -74,7 +81,7 @@ async function chatRoutes(fastify) {
74
81
  reply.raw.flushHeaders()
75
82
 
76
83
  try {
77
- await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message)
84
+ await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message, pendingSessionId)
78
85
  } catch (err) {
79
86
  console.error('[Chat] stream error:', err.message)
80
87
  const errorEvent = JSON.stringify({ type: 'error', error: err.message })
@@ -391,7 +398,7 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
391
398
  '[ファイル出力ルール]',
392
399
  '成果物の保存先は以下のルールに従うこと。`/home/minion/` 直下にファイルを保存しないこと。',
393
400
  '',
394
- '- **テキスト成果物**(レポート、調査結果、要約等)→ ノートに保存: `hq note create <project_id> --title "タイトル" --content "本文"`',
401
+ '- **テキスト成果物**(レポート、調査結果、要約等)→ ノートに保存: `hq note create <project_id> --title "タイトル" --content "本文"` (プロジェクト紐づけなしは `hq note create --workspace <ws_id> ...`)',
395
402
  '- **バイナリファイル**(PDF、画像、ZIP等)→ `~/files/` に配置(ユーザーがHQからダウンロード可能)',
396
403
  '- **一時ファイル** → `/tmp/` に配置(作業後に削除)',
397
404
  '',
@@ -541,16 +548,16 @@ function getLlmBinary() {
541
548
  * Tracks block types to correctly forward tool_use vs text events
542
549
  * and counts turns for session management.
543
550
  */
544
- async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage) {
551
+ async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
545
552
  // Plugin system path: Primary is set → delegate to plugin
546
553
  const primary = getActivePrimary()
547
554
  if (primary) {
548
- return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage)
555
+ return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
549
556
  }
550
- return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage)
557
+ return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
551
558
  }
552
559
 
553
- async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage) {
560
+ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
554
561
  const input = { prompt }
555
562
  const activeRef = { current: null }
556
563
  activeChatChild = { kill: () => activeRef.current?.kill?.('SIGTERM') }
@@ -576,6 +583,7 @@ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, orig
576
583
 
577
584
  res.on('close', () => { activeRef.current?.kill?.('SIGTERM') })
578
585
 
586
+ let pluginError = null
579
587
  try {
580
588
  let output
581
589
  if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
@@ -592,25 +600,42 @@ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, orig
592
600
  }
593
601
  }
594
602
  resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
603
+ } catch (err) {
604
+ // Swallow here so we can persist any partial response first; rethrow below.
605
+ pluginError = err
595
606
  } finally {
596
607
  activeChatChild = null
597
608
  }
598
609
 
599
- if (resolvedSessionId) {
600
- if (!sessionId) {
601
- await chatStore.addMessage(resolvedSessionId, { role: 'user', content: originalMessage || prompt }, undefined, workspaceId)
610
+ // For new sessions, the user message was persisted under pendingSessionId
611
+ // before the plugin call. Rekey it to the real session ID now that we
612
+ // know it. If the plugin never reported a session ID, leave the message
613
+ // under the pending key so the history isn't lost.
614
+ const persistSessionId = resolvedSessionId || pendingSessionId
615
+ try {
616
+ if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
617
+ chatStore.rekeySession(pendingSessionId, resolvedSessionId)
602
618
  }
603
- if (fullResponse) {
604
- await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
619
+ if (fullResponse && persistSessionId) {
620
+ await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
605
621
  }
622
+ } catch (err) {
623
+ console.error('[Chat] failed to persist assistant message:', err.message)
606
624
  }
607
625
 
608
- const session = await chatStore.load(workspaceId)
609
- const totalTurnCount = session?.turn_count || turnCount
626
+ if (pluginError) throw pluginError
627
+
628
+ let totalTurnCount = turnCount
629
+ try {
630
+ const session = await chatStore.load(workspaceId)
631
+ totalTurnCount = session?.turn_count || turnCount
632
+ } catch (err) {
633
+ console.error('[Chat] failed to load session for done event:', err.message)
634
+ }
610
635
  res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
611
636
  }
612
637
 
613
- function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage) {
638
+ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
614
639
  return new Promise((resolve, reject) => {
615
640
  const binaryName = getLlmBinary()
616
641
  if (!binaryName) {
@@ -786,16 +811,21 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
786
811
  console.log(`[Chat] final stderr (tail 500): ${stderrBuffer.slice(-500)}`)
787
812
  }
788
813
 
789
- // Store messages in chat-store
790
- if (resolvedSessionId) {
791
- // If this was a new session, also store the user message now
792
- if (!sessionId) {
793
- await chatStore.addMessage(resolvedSessionId, { role: 'user', content: originalMessage || prompt }, undefined, workspaceId)
814
+ // For new sessions, the user message was already persisted under
815
+ // pendingSessionId before spawn. Rekey it to the real session ID when
816
+ // Claude CLI reported one; otherwise leave the message under the
817
+ // pending key so the history is never lost on crash.
818
+ const persistSessionId = resolvedSessionId || pendingSessionId
819
+ try {
820
+ if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
821
+ chatStore.rekeySession(pendingSessionId, resolvedSessionId)
794
822
  }
795
- // Store assistant response with turn count
796
- if (fullResponse) {
797
- await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
823
+ // Persist any partial response we managed to collect, even on error
824
+ if (fullResponse && persistSessionId) {
825
+ await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
798
826
  }
827
+ } catch (err) {
828
+ console.error('[Chat] failed to persist assistant message:', err.message)
799
829
  }
800
830
 
801
831
  if (code !== 0) {
@@ -811,8 +841,13 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
811
841
  }
812
842
 
813
843
  // Load current turn count from session for the done event
814
- const session = await chatStore.load(workspaceId)
815
- const totalTurnCount = session?.turn_count || turnCount
844
+ let totalTurnCount = turnCount
845
+ try {
846
+ const session = await chatStore.load(workspaceId)
847
+ totalTurnCount = session?.turn_count || turnCount
848
+ } catch (err) {
849
+ console.error('[Chat] failed to load session for done event:', err.message)
850
+ }
816
851
 
817
852
  const doneEvent = JSON.stringify({
818
853
  type: 'done',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "4.1.3",
3
+ "version": "4.2.1",
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
@@ -445,28 +445,32 @@ display_name が同じユーザーが同一ワークスペースに複数いる
445
445
 
446
446
  ### ノートへの保存 (`hq note` コマンド)
447
447
 
448
- テキストベースの成果物は原則ノートに保存する。ノートはプロジェクトに紐づき、HQダッシュボードで閲覧できる。
448
+ テキストベースの成果物は原則ノートに保存する。ノートはワークスペース直下に保存され、任意で 1 つ以上のプロジェクトにリンクできる。HQダッシュボードで閲覧・編集・検索でき、バージョン管理もされる。
449
+
450
+ スコープの使い分け:
451
+
452
+ - **プロジェクトスコープ** — 作業がプロジェクトに紐づくとき (`hq note ... <project_id>`)。プロジェクトのノートタブから即発見できる
453
+ - **ワークスペーススコープ** — プロジェクトに紐づかない一般的なメモや、ワークスペース横断で検索したいとき (`hq note ... --workspace <workspace_id>`)。プロジェクト未指定でも作成・検索できる
449
454
 
450
455
  ```bash
451
- # ノートを作成(プロジェクトに紐づけ)
456
+ # プロジェクトスコープ(作業コンテキストが特定プロジェクト)
452
457
  hq note create <project_id> --title "レポートタイトル" --content "本文..."
453
-
454
- # 長い内容はファイルから読み込む
455
458
  hq note create <project_id> --title "調査結果" --file /tmp/report.md
456
-
457
- # 既存ノートを更新
458
459
  hq note update <project_id> <note_id> --content "更新された内容"
459
-
460
- # ノート一覧を確認
461
460
  hq note list <project_id>
462
-
463
- # ノート内容を取得
464
461
  hq note get <project_id> <note_id>
465
-
466
- # ノートを検索
467
462
  hq note search <project_id> "キーワード"
463
+
464
+ # ワークスペーススコープ(プロジェクト未紐づけのノート含む)
465
+ hq note create --workspace <workspace_id> --title "メモ" --content "..."
466
+ hq note update --workspace <workspace_id> <note_id> --content "更新内容"
467
+ hq note list --workspace <workspace_id>
468
+ hq note get --workspace <workspace_id> <note_id>
469
+ hq note search --workspace <workspace_id> "キーワード"
468
470
  ```
469
471
 
472
+ **削除はミニオンから実行できない**(事故防止のため人間操作限定)。不要なノートは `hq note update ... --status archived` (または PATCH の `status: "archived"`) でアーカイブすること。
473
+
470
474
  ### ノート内のユーザーメンション
471
475
 
472
476
  人間ユーザーがノート編集UIで `Ctrl+I` を押すと、自分の発言を示すアバター付きチップが行頭に挿入される。マークダウン上は次の形式で永続化される:
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  const { spawn } = require('child_process')
16
+ const crypto = require('crypto')
16
17
  const fs = require('fs')
17
18
  const path = require('path')
18
19
  const http = require('http')
@@ -130,8 +131,19 @@ async function chatRoutes(fastify) {
130
131
  const prompt = await buildContextPrefix(message, context, session_id, workspaceId, referenced_tasks)
131
132
  const currentSessionId = session_id || null
132
133
 
133
- if (currentSessionId) {
134
- await chatStore.addMessage(currentSessionId, { role: 'user', content: message }, undefined, workspaceId)
134
+ // Persist the user message BEFORE invoking the LLM so that crashes,
135
+ // timeouts, or unparseable CLI output can't lose it. New sessions get a
136
+ // local pending ID; it gets rekeyed to the real Claude CLI session ID
137
+ // once that comes back on the SSE stream.
138
+ // In WSL mode the upstream WSL session server keeps its own store, so we
139
+ // only persist locally for existing sessions (matching prior behavior).
140
+ const pendingSessionId = currentSessionId || `pending-${crypto.randomUUID()}`
141
+ if (!wsl_mode || currentSessionId) {
142
+ try {
143
+ await chatStore.addMessage(pendingSessionId, { role: 'user', content: message }, undefined, workspaceId)
144
+ } catch (err) {
145
+ console.error('[Chat] failed to persist user message:', err.message)
146
+ }
135
147
  }
136
148
 
137
149
  reply.hijack()
@@ -149,7 +161,7 @@ async function chatRoutes(fastify) {
149
161
  await proxyWslChat(reply.raw, prompt, currentSessionId)
150
162
  wslModeActive = false
151
163
  } else {
152
- await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message)
164
+ await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message, pendingSessionId)
153
165
  }
154
166
  } catch (err) {
155
167
  wslModeActive = false
@@ -452,7 +464,7 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
452
464
  '[ファイル出力ルール]',
453
465
  '成果物の保存先は以下のルールに従うこと。`/home/minion/` 直下にファイルを保存しないこと。',
454
466
  '',
455
- '- **テキスト成果物**(レポート、調査結果、要約等)→ ノートに保存: `hq note create <project_id> --title "タイトル" --content "本文"`',
467
+ '- **テキスト成果物**(レポート、調査結果、要約等)→ ノートに保存: `hq note create <project_id> --title "タイトル" --content "本文"` (プロジェクト紐づけなしは `hq note create --workspace <ws_id> ...`)',
456
468
  '- **バイナリファイル**(PDF、画像、ZIP等)→ `~/files/` に配置(ユーザーがHQからダウンロード可能)',
457
469
  '- **一時ファイル** → `/tmp/` に配置(作業後に削除)',
458
470
  '',
@@ -594,15 +606,15 @@ function getLlmBinary() {
594
606
  * Tracks block types to correctly forward tool_use vs text events
595
607
  * and counts turns for session management.
596
608
  */
597
- async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage) {
609
+ async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
598
610
  const primary = getActivePrimary()
599
611
  if (primary) {
600
- return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage)
612
+ return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
601
613
  }
602
- return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage)
614
+ return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
603
615
  }
604
616
 
605
- async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage) {
617
+ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
606
618
  const input = { prompt }
607
619
  const activeRef = { current: null }
608
620
 
@@ -627,37 +639,57 @@ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, orig
627
639
 
628
640
  res.on('close', () => { activeRef.current?.kill?.('SIGTERM') })
629
641
 
630
- let output
631
- if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
632
- output = await plugin.stream(input, emit, { resumeSessionId: sessionId, activeChildRef: activeRef })
633
- } else {
634
- output = await plugin.invoke(input)
635
- if (output.text) {
636
- fullResponse = output.text
637
- res.write(`data: ${JSON.stringify({ type: 'text', content: output.text })}\n\n`)
638
- turnCount = 1
639
- }
640
- if (output.error) {
641
- res.write(`data: ${JSON.stringify({ type: 'error', error: output.error.message })}\n\n`)
642
+ let pluginError = null
643
+ try {
644
+ let output
645
+ if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
646
+ output = await plugin.stream(input, emit, { resumeSessionId: sessionId, activeChildRef: activeRef })
647
+ } else {
648
+ output = await plugin.invoke(input)
649
+ if (output.text) {
650
+ fullResponse = output.text
651
+ res.write(`data: ${JSON.stringify({ type: 'text', content: output.text })}\n\n`)
652
+ turnCount = 1
653
+ }
654
+ if (output.error) {
655
+ res.write(`data: ${JSON.stringify({ type: 'error', error: output.error.message })}\n\n`)
656
+ }
642
657
  }
658
+ resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
659
+ } catch (err) {
660
+ // Swallow here so we can persist any partial response first; rethrow below.
661
+ pluginError = err
643
662
  }
644
- resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
645
663
 
646
- if (resolvedSessionId) {
647
- if (!sessionId) {
648
- await chatStore.addMessage(resolvedSessionId, { role: 'user', content: originalMessage || prompt }, undefined, workspaceId)
664
+ // For new sessions, the user message was persisted under pendingSessionId
665
+ // before the plugin call. Rekey it to the real session ID now that we
666
+ // know it. If the plugin never reported a session ID, leave the message
667
+ // under the pending key so the history isn't lost.
668
+ const persistSessionId = resolvedSessionId || pendingSessionId
669
+ try {
670
+ if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
671
+ chatStore.rekeySession(pendingSessionId, resolvedSessionId)
649
672
  }
650
- if (fullResponse) {
651
- await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
673
+ if (fullResponse && persistSessionId) {
674
+ await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
652
675
  }
676
+ } catch (err) {
677
+ console.error('[Chat] failed to persist assistant message:', err.message)
653
678
  }
654
679
 
655
- const session = await chatStore.load(workspaceId)
656
- const totalTurnCount = session?.turn_count || turnCount
680
+ if (pluginError) throw pluginError
681
+
682
+ let totalTurnCount = turnCount
683
+ try {
684
+ const session = await chatStore.load(workspaceId)
685
+ totalTurnCount = session?.turn_count || turnCount
686
+ } catch (err) {
687
+ console.error('[Chat] failed to load session for done event:', err.message)
688
+ }
657
689
  res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
658
690
  }
659
691
 
660
- function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage) {
692
+ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
661
693
  return new Promise((resolve, reject) => {
662
694
  const binaryName = getLlmBinary()
663
695
  if (!binaryName) {
@@ -809,14 +841,22 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
809
841
  console.log(`[Chat] final stderr (tail 500): ${stderrBuffer.slice(-500)}`)
810
842
  }
811
843
 
812
- if (resolvedSessionId) {
813
- if (!sessionId) {
814
- await chatStore.addMessage(resolvedSessionId, { role: 'user', content: originalMessage || prompt }, undefined, workspaceId)
844
+ // For new sessions, the user message was already persisted under
845
+ // pendingSessionId before spawn. Rekey it to the real session ID when
846
+ // Claude CLI reported one; otherwise leave the message under the
847
+ // pending key so the history is never lost on crash.
848
+ const persistSessionId = resolvedSessionId || pendingSessionId
849
+ try {
850
+ if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
851
+ chatStore.rekeySession(pendingSessionId, resolvedSessionId)
815
852
  }
816
- if (fullResponse) {
817
- await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
853
+ if (fullResponse && persistSessionId) {
854
+ await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
818
855
  }
856
+ } catch (err) {
857
+ console.error('[Chat] failed to persist assistant message:', err.message)
819
858
  }
859
+
820
860
  if (code !== 0) {
821
861
  const errorMsg = stderrBuffer.trim() || `Claude CLI exited with code ${code}`
822
862
  console.error(`[Chat] CLI failed (exit ${code}, partial=${!!fullResponse}): ${errorMsg}`)
@@ -828,8 +868,13 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
828
868
  })}\n\n`)
829
869
  }
830
870
 
831
- const session = await chatStore.load(workspaceId)
832
- const totalTurnCount = session?.turn_count || turnCount
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)
877
+ }
833
878
 
834
879
  res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
835
880
  resolve()