@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 +1 -1
- package/src/claude-history-watch.mjs +3 -20
- package/src/claude-history.mjs +32 -16
- package/src/claude-stream-bridge.mjs +106 -23
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
}
|
package/src/claude-history.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
-
*
|
|
141
|
-
*
|
|
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
|
-
:
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
/** 改修
|
|
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 が完走する。
|
|
467
|
-
*
|
|
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
|
|
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
|
}
|