@cocorograph/hub-agent 0.6.14 → 0.6.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.6.14",
3
+ "version": "0.6.16",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -15,7 +15,8 @@
15
15
  import { watch as fsWatch } from "node:fs"
16
16
  import { open, stat } from "node:fs/promises"
17
17
 
18
- const DISPLAY_TYPES = new Set(["user", "assistant", "system", "result"])
18
+ // 履歴 hydrate live watch jsonl→SDK message 整形を共通化 (フィールド取りこぼし防止)
19
+ import { DISPLAY_TYPES, normalizeHistoryEvent } from "./claude-history.mjs"
19
20
  const POLL_INTERVAL_MS = 1500
20
21
 
21
22
  /**
@@ -89,7 +90,7 @@ export function watchSessionFile({ filePath, onEvent, fromEnd = true, logger })
89
90
  continue
90
91
  }
91
92
  if (!obj || !DISPLAY_TYPES.has(obj.type)) continue
92
- const event = normalizeEvent(obj)
93
+ const event = normalizeHistoryEvent(obj)
93
94
  try {
94
95
  onEvent(event)
95
96
  } catch (err) {
@@ -151,21 +152,3 @@ export function watchSessionFile({ filePath, onEvent, fromEnd = true, logger })
151
152
  }
152
153
  }
153
154
 
154
- /** jsonl の生 object を SDK message 風の表示用イベントに正規化 (history.mjs と揃える)。 */
155
- function normalizeEvent(obj) {
156
- const event = { type: obj.type }
157
- if (obj.message !== undefined) event.message = obj.message
158
- if (obj.subtype !== undefined) event.subtype = obj.subtype
159
- if (obj.uuid !== undefined) event.uuid = obj.uuid
160
- if (obj.session_id !== undefined) event.session_id = obj.session_id
161
- else if (obj.sessionId !== undefined) event.session_id = obj.sessionId
162
- if (obj.model !== undefined) event.model = obj.model
163
- if (obj.cwd !== undefined) event.cwd = obj.cwd
164
- if (obj.tools !== undefined) event.tools = obj.tools
165
- if (obj.permissionMode !== undefined) event.permissionMode = obj.permissionMode
166
- if (obj.total_cost_usd !== undefined) event.total_cost_usd = obj.total_cost_usd
167
- if (obj.duration_ms !== undefined) event.duration_ms = obj.duration_ms
168
- if (obj.num_turns !== undefined) event.num_turns = obj.num_turns
169
- if (obj.usage !== undefined) event.usage = obj.usage
170
- return event
171
- }
@@ -20,7 +20,36 @@ import path from "node:path"
20
20
  export const MAX_HISTORY_LINES = 500
21
21
 
22
22
  /** UI 表示対象の SDK message type (それ以外は jsonl 内部メタなので除外)。 */
23
- const DISPLAY_TYPES = new Set(["user", "assistant", "system", "result"])
23
+ export const DISPLAY_TYPES = new Set(["user", "assistant", "system", "result"])
24
+
25
+ /**
26
+ * jsonl の生 object を、Browser が SDK message として受け取れる表示用 shape に正規化する。
27
+ * 余分な内部メタは落とし、表示・集計に必要なフィールドだけを superset で拾う。
28
+ *
29
+ * ⚠️ 履歴 hydrate (readSessionHistory) と live watch (claude-history-watch) の両方が
30
+ * これを使う。フィールドを増やすときは必ずここ 1 箇所に足すこと (過去、2 箇所に分散して
31
+ * いて uuid が片方で取りこぼされるドリフトが発生したため共通化した。2026-05-29)。
32
+ *
33
+ * @param {Record<string, unknown>} obj jsonl 1 行をパースした生 object
34
+ * @returns {Record<string, unknown>} SDK message 風の表示用イベント
35
+ */
36
+ export function normalizeHistoryEvent(obj) {
37
+ const event = { type: obj.type }
38
+ if (obj.message !== undefined) event.message = obj.message
39
+ if (obj.subtype !== undefined) event.subtype = obj.subtype
40
+ if (obj.uuid !== undefined) event.uuid = obj.uuid
41
+ if (obj.session_id !== undefined) event.session_id = obj.session_id
42
+ else if (obj.sessionId !== undefined) event.session_id = obj.sessionId
43
+ if (obj.model !== undefined) event.model = obj.model
44
+ if (obj.cwd !== undefined) event.cwd = obj.cwd
45
+ if (obj.tools !== undefined) event.tools = obj.tools
46
+ if (obj.permissionMode !== undefined) event.permissionMode = obj.permissionMode
47
+ if (obj.total_cost_usd !== undefined) event.total_cost_usd = obj.total_cost_usd
48
+ if (obj.duration_ms !== undefined) event.duration_ms = obj.duration_ms
49
+ if (obj.num_turns !== undefined) event.num_turns = obj.num_turns
50
+ if (obj.usage !== undefined) event.usage = obj.usage
51
+ return event
52
+ }
24
53
 
25
54
  /**
26
55
  * cwd 文字列を Claude Code の project dir 名に変換する。
@@ -82,21 +111,8 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
82
111
  }
83
112
  if (!obj || typeof obj !== "object") continue
84
113
  if (!DISPLAY_TYPES.has(obj.type)) continue
85
- // SDK message と同じ shape にする (余分な meta は落とす)
86
- const event = { type: obj.type }
87
- if (obj.message !== undefined) event.message = obj.message
88
- if (obj.subtype !== undefined) event.subtype = obj.subtype
89
- if (obj.session_id !== undefined) event.session_id = obj.session_id
90
- else if (obj.sessionId !== undefined) event.session_id = obj.sessionId
91
- if (obj.model !== undefined) event.model = obj.model
92
- if (obj.cwd !== undefined) event.cwd = obj.cwd
93
- if (obj.tools !== undefined) event.tools = obj.tools
94
- if (obj.permissionMode !== undefined) event.permissionMode = obj.permissionMode
95
- if (obj.total_cost_usd !== undefined) event.total_cost_usd = obj.total_cost_usd
96
- if (obj.duration_ms !== undefined) event.duration_ms = obj.duration_ms
97
- if (obj.num_turns !== undefined) event.num_turns = obj.num_turns
98
- if (obj.usage !== undefined) event.usage = obj.usage
99
- events.push(event)
114
+ // SDK message と同じ shape にする (余分な meta は落とす)。整形は共通関数に集約。
115
+ events.push(normalizeHistoryEvent(obj))
100
116
  }
101
117
  return { events, total_lines, truncated }
102
118
  }
@@ -34,9 +34,29 @@ const IDLE_DETACH_TTL_MS = 7 * 24 * 60 * 60 * 1000
34
34
  * 従来の「1メッセージ=1query」へ即ロールバックできる。デフォルト有効。 */
35
35
  const CHAT_RESIDENT_ENABLED = process.env.HUB_AGENT_CHAT_RESIDENT !== "0"
36
36
 
37
- /** 文字列を SDK streaming input SDKUserMessage に包む。 */
37
+ /** 改修4 (2026-05-29): resume セッションも常駐query化する (init を毎ターン送らず、
38
+ * 1 セッション=1 query で system/init を初回 1 回のみにする。VS Code 拡張 / Claude Web
39
+ * と同じ挙動)。query 起動時に options.resume で過去文脈を引き継ぐ。
40
+ * env HUB_AGENT_CHAT_RESIDENT_RESUME="0" で従来の per-message (毎ターン init) に
41
+ * 個別ロールバック可能 (新規セッションの常駐化は CHAT_RESIDENT_ENABLED 側で独立制御)。
42
+ * デフォルト有効。
43
+ * 注意: 過去に「resume + 常駐」で『Continue from where you left off』暴走が疑われたが、
44
+ * 原因は continue オプション (直近会話の自動継続) の誤用と Hub 不調の重なりと推定。
45
+ * 本実装では continue は使わず resume (明示 session 指定) のみを用い、初回 input は
46
+ * ユーザーの実メッセージのみとすることで自動継続の暴走を回避する。 */
47
+ const CHAT_RESIDENT_RESUME_ENABLED =
48
+ process.env.HUB_AGENT_CHAT_RESIDENT_RESUME !== "0"
49
+
50
+ /** 文字列を SDK streaming input の SDKUserMessage に包む。
51
+ * SDKUserMessage は parent_tool_use_id: string|null が必須フィールド。現行 SDK は入力側で
52
+ * 寛容なので省略しても動くが、将来の型厳格化に備えて明示する。トップレベルのユーザー入力
53
+ * なので親 tool_use は無く null。 */
38
54
  function toSDKUserMessage(text) {
39
- return { type: "user", message: { role: "user", content: text } }
55
+ return {
56
+ type: "user",
57
+ message: { role: "user", content: text },
58
+ parent_tool_use_id: null,
59
+ }
40
60
  }
41
61
 
42
62
  /** 常駐 query の streaming input キュー。push された SDKUserMessage を AsyncIterable として
@@ -136,13 +156,17 @@ class ClaudeStreamSession {
136
156
  /** detached のまま放置されたセッションを撤去する idle タイマー。再アタッチでキャンセル。 */
137
157
  this._idleTimer = null
138
158
 
139
- /** 改修2: 常駐query対象か。明示 resident 指定があれば優先 (テスト/将来の per-session 設定用)。
140
- * 未指定なら resume なしの新規セッションのみ常駐 (既存 resume セッションは過去の
141
- * 「Continue from where you left off」暴走を避けて従来 per-message を維持)。 */
159
+ /** 改修2+4: 常駐query対象か。明示 resident 指定があれば優先 (テスト/将来の per-session 設定用)。
160
+ * 未指定の場合:
161
+ * - 新規セッション (resume なし): CHAT_RESIDENT_ENABLED で常駐 (改修2)。
162
+ * - 既存セッション (resume あり): CHAT_RESIDENT_ENABLED かつ CHAT_RESIDENT_RESUME_ENABLED
163
+ * なら常駐 (改修4)。query 起動時に options.resume で文脈を引き継ぎ init は初回 1 回のみ。
164
+ * どちらも無効なら従来 per-message (毎ターン init) にフォールバックする。 */
142
165
  this._residentEligible =
143
166
  typeof resident === "boolean"
144
167
  ? resident
145
- : !resumeSessionId && CHAT_RESIDENT_ENABLED
168
+ : CHAT_RESIDENT_ENABLED &&
169
+ (!resumeSessionId || CHAT_RESIDENT_RESUME_ENABLED)
146
170
  /** 常駐query の input キュー (streaming input)。初回 input 時に生成・query 起動。 */
147
171
  this._inputQueue = null
148
172
  /** 起動済みの常駐 query ハンドル (interrupt() 用)。 */
@@ -286,20 +310,30 @@ class ClaudeStreamSession {
286
310
  const prompt = extractPromptText(message)
287
311
  if (!prompt) return
288
312
 
289
- // 改修2: 常駐query対象セッションは streaming input キューへ積む (busy 中でも積み、
290
- // SDK が順次処理する)。初回 input で常駐 query を 1 回だけ起動する。
313
+ // 改修2+4: 常駐query対象セッション。
291
314
  if (this._residentEligible) {
292
315
  if (!this._inputQueue) this._inputQueue = new InputQueue()
316
+ // 改修4 (A): ターンのシリアライズ。実行中ターン (busy) は InputQueue へ即 push せず
317
+ // pending へ退避し、現ターンの result 後に 1 件ずつ drain する。前ターン未完了のまま
318
+ // 次を push すると SDK streaming-input で割り込み扱いになり得るため (公式 interrupt 警告)。
319
+ // pending は queue_state で UI (送信待ちチップ) に出す (per-message と同じ体験)。
320
+ if (this._busy) {
321
+ this._pendingMessages.push(prompt)
322
+ this.logger?.info(
323
+ { stream_id: this.stream_id, queued: this._pendingMessages.length },
324
+ "resident busy, message queued",
325
+ )
326
+ this._emitQueueState()
327
+ return
328
+ }
293
329
  this._busy = true
294
330
  this._inputQueue.push(toSDKUserMessage(prompt))
295
- if (!this._residentStarted) {
331
+ // 改修4 (A): 死亡ガード。常駐 query が未起動 or (エラー等で) 終了済み (_residentQuery=null)
332
+ // なら (再)起動する。_runResidentQuery は起動時 options.resume=this.sessionId で文脈を
333
+ // 復元するため、途中死からの復活でも過去コンテキストは失われない。
334
+ if (!this._residentQuery) {
296
335
  this._residentStarted = true
297
- this._runResidentQuery().catch((err) => {
298
- this.logger?.error(
299
- { stream_id: this.stream_id, err: err?.message },
300
- "resident query threw",
301
- )
302
- })
336
+ this._startResidentQuery()
303
337
  }
304
338
  return
305
339
  }
@@ -341,7 +375,13 @@ class ClaudeStreamSession {
341
375
  if (this.sessionId) options.resume = this.sessionId
342
376
 
343
377
  try {
344
- const generator = this.sdk.query({ prompt, options })
378
+ // 改修4 (A): canUseTool は streaming input (AsyncIterable prompt) でのみ確実に機能する。
379
+ // per-message でも単発 InputQueue (1 件 push → 即 close) を prompt に渡し、1 query=1 ターンの
380
+ // resume チェーン挙動は維持しつつ、文字列 prompt + canUseTool の SDK 制約リスクを回避する。
381
+ const inputQueue = new InputQueue()
382
+ inputQueue.push(toSDKUserMessage(prompt))
383
+ inputQueue.close()
384
+ const generator = this.sdk.query({ prompt: inputQueue, options })
345
385
  for await (const msg of generator) {
346
386
  if (
347
387
  msg?.type === "system" &&
@@ -448,23 +488,52 @@ class ClaudeStreamSession {
448
488
  if (count === this._lastEmittedQueueCount) return
449
489
  this._lastEmittedQueueCount = count
450
490
  try {
491
+ // messages は全文を載せる。frontend は実行開始 (drain) 時にこれを user バブルへ
492
+ // 昇格させるため、ここで切り詰めると本文が欠ける。チップの省略表示は CSS 側で行う。
451
493
  this.onEvent?.({
452
494
  type: "queue_state",
453
495
  pending: count,
454
- messages: this._pendingMessages.map((p) =>
455
- typeof p === "string" && p.length > 120 ? `${p.slice(0, 120)}…` : p,
456
- ),
496
+ messages: [...this._pendingMessages],
457
497
  })
458
498
  } catch {
459
499
  /* ignore */
460
500
  }
461
501
  }
462
502
 
463
- /** 改修2: 常駐 query を 1 回だけ起動し、streaming input キューから複数ターンを処理する。
503
+ /** 改修4 (A): 常駐 query を resume 付きで (再)起動する。_runResidentQuery 起動時に
504
+ * options.resume=this.sessionId で過去文脈を復元するため、途中死からの復活でも安全。 */
505
+ _startResidentQuery() {
506
+ this._runResidentQuery().catch((err) => {
507
+ this.logger?.error(
508
+ { stream_id: this.stream_id, err: err?.message },
509
+ "resident query threw",
510
+ )
511
+ })
512
+ }
513
+
514
+ /** 改修4 (A): ターン完了時に pending の先頭 1 件を InputQueue へ流す (ターンのシリアライズ)。
515
+ * queue_state を更新して送信待ちチップを drain させる (frontend がバブル昇格する)。 */
516
+ _drainResidentPending() {
517
+ if (this._closed) return
518
+ if (this._pendingMessages.length === 0) {
519
+ this._emitQueueState()
520
+ return
521
+ }
522
+ const next = this._pendingMessages.shift()
523
+ this._busy = true
524
+ this._inputQueue.push(toSDKUserMessage(next))
525
+ this._emitQueueState()
526
+ }
527
+
528
+ /** 改修2+4: 常駐 query を 1 回だけ起動し、streaming input キューから複数ターンを処理する。
464
529
  * system/init は最初の 1 回のみ (毎ターン init = 文脈/キャッシュ温め直しを解消)。各ターンは
465
530
  * result で _busy=false にするが query は継続し次 input を待つ。close (inputQueue.close) で
466
- * generator が終わり query が完走する。resume は使わない (新規セッションのみが対象)。
467
- * 常駐では query stream が唯一のイベント源のため watcher は張らない。 */
531
+ * generator が終わり query が完走する。
532
+ * 改修4: resume セッションも対象。query 起動時 options.resume に過去 session_id を渡して
533
+ * 文脈を引き継ぐ (init は初回 1 回のみ)。continue は使わない (自動継続の暴走回避)。初回 input は
534
+ * sendMessage が積むユーザーの実メッセージなので「Continue from where you left off」は発生しない。
535
+ * 常駐では query stream が唯一のイベント源のため watcher は張らない (過去確定分は browser が
536
+ * history.request で hydrate 済み。resume で query stream が過去行を replay しない前提)。 */
468
537
  async _runResidentQuery() {
469
538
  const options = {
470
539
  cwd: this.cwd,
@@ -475,6 +544,9 @@ class ClaudeStreamSession {
475
544
  if (this.permissionMode) options.permissionMode = this.permissionMode
476
545
  if (this.maxTurns != null) options.maxTurns = this.maxTurns
477
546
  if (this.maxThinkingTokens != null) options.maxThinkingTokens = this.maxThinkingTokens
547
+ // 改修4: 起動時に sessionId (= resumeSessionId) があれば resume チェーンで文脈を引き継ぐ。
548
+ // query 起動時点の値のみ有効 (起動後に確定/変化する session_id は同一 query 内で継続される)。
549
+ if (this.sessionId) options.resume = this.sessionId
478
550
  const denyPending = (reason) => {
479
551
  for (const [, resolver] of this._permissionResolvers) {
480
552
  try {
@@ -501,6 +573,8 @@ class ClaudeStreamSession {
501
573
  // ターン完了: 未解決 permission を閉じ、次 input 受付へ (query は継続)。
502
574
  denyPending("turn ended")
503
575
  this._busy = false
576
+ // 改修4 (A): シリアライズした pending があれば次の 1 件を InputQueue へ流す。
577
+ this._drainResidentPending()
504
578
  }
505
579
  try {
506
580
  this.onEvent?.(msg)
@@ -523,13 +597,22 @@ class ClaudeStreamSession {
523
597
  this._residentQuery = null
524
598
  this._busy = false
525
599
  denyPending("closed")
526
- // 常駐 query の終了 = セッション終了。detached 後にここへ来たら reap する。
600
+ // 常駐 query の終了。detached 後にここへ来たら reap する。
527
601
  if (this._reapAfterTurn && !this._closed) {
528
602
  try {
529
603
  this.close()
530
604
  } finally {
531
605
  this.onReap?.()
532
606
  }
607
+ } else if (!this._closed && this._pendingMessages.length > 0) {
608
+ // 改修4 (A): 異常終了 (close 以外) で pending が残っていれば resume 付きで再起動し
609
+ // 取りこぼしを防ぐ。_runResidentQuery が options.resume=this.sessionId で文脈を復元する。
610
+ const next = this._pendingMessages.shift()
611
+ this._busy = true
612
+ this._residentStarted = true
613
+ this._inputQueue.push(toSDKUserMessage(next))
614
+ this._emitQueueState()
615
+ this._startResidentQuery()
533
616
  }
534
617
  }
535
618
  }