@cocorograph/hub-agent 0.6.12 → 0.6.14
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-stream-bridge.mjs +236 -6
package/package.json
CHANGED
|
@@ -30,6 +30,52 @@ import { watchSessionFile } from "./claude-history-watch.mjs"
|
|
|
30
30
|
* 完走するまで絶対に撤去しない。明示終了 / 新規セッションでは即時撤去される。 */
|
|
31
31
|
const IDLE_DETACH_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
32
32
|
|
|
33
|
+
/** 改修2: チャット常駐query化のフラグ。env HUB_AGENT_CHAT_RESIDENT="0" で無効化し
|
|
34
|
+
* 従来の「1メッセージ=1query」へ即ロールバックできる。デフォルト有効。 */
|
|
35
|
+
const CHAT_RESIDENT_ENABLED = process.env.HUB_AGENT_CHAT_RESIDENT !== "0"
|
|
36
|
+
|
|
37
|
+
/** 文字列を SDK streaming input の SDKUserMessage に包む。 */
|
|
38
|
+
function toSDKUserMessage(text) {
|
|
39
|
+
return { type: "user", message: { role: "user", content: text } }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 常駐 query の streaming input キュー。push された SDKUserMessage を AsyncIterable として
|
|
43
|
+
* query() に供給する。push が無ければ次を待ち (query は終了しない)、close で generator を
|
|
44
|
+
* 終わらせて query を完走させる。これにより 1 セッション = 1 query を実現し、system/init を
|
|
45
|
+
* 最初の 1 回だけにする (毎ターン init = 文脈/キャッシュ温め直しの解消)。 */
|
|
46
|
+
class InputQueue {
|
|
47
|
+
constructor() {
|
|
48
|
+
this._q = []
|
|
49
|
+
this._wake = null
|
|
50
|
+
this._closed = false
|
|
51
|
+
}
|
|
52
|
+
push(item) {
|
|
53
|
+
if (this._closed) return
|
|
54
|
+
this._q.push(item)
|
|
55
|
+
this._flush()
|
|
56
|
+
}
|
|
57
|
+
close() {
|
|
58
|
+
this._closed = true
|
|
59
|
+
this._flush()
|
|
60
|
+
}
|
|
61
|
+
_flush() {
|
|
62
|
+
if (this._wake) {
|
|
63
|
+
const w = this._wake
|
|
64
|
+
this._wake = null
|
|
65
|
+
w()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async *[Symbol.asyncIterator]() {
|
|
69
|
+
while (true) {
|
|
70
|
+
while (this._q.length) yield this._q.shift()
|
|
71
|
+
if (this._closed) return
|
|
72
|
+
await new Promise((r) => {
|
|
73
|
+
this._wake = r
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
33
79
|
/** message (`{role, content}` or string) からプロンプト文字列を取り出す。 */
|
|
34
80
|
function extractPromptText(message) {
|
|
35
81
|
if (typeof message === "string") return message
|
|
@@ -57,6 +103,7 @@ class ClaudeStreamSession {
|
|
|
57
103
|
maxTurns,
|
|
58
104
|
maxThinkingTokens,
|
|
59
105
|
resumeSessionId,
|
|
106
|
+
resident,
|
|
60
107
|
sdk,
|
|
61
108
|
logger,
|
|
62
109
|
onEvent,
|
|
@@ -89,6 +136,27 @@ class ClaudeStreamSession {
|
|
|
89
136
|
/** detached のまま放置されたセッションを撤去する idle タイマー。再アタッチでキャンセル。 */
|
|
90
137
|
this._idleTimer = null
|
|
91
138
|
|
|
139
|
+
/** 改修2: 常駐query対象か。明示 resident 指定があれば優先 (テスト/将来の per-session 設定用)。
|
|
140
|
+
* 未指定なら resume なしの新規セッションのみ常駐 (既存 resume セッションは過去の
|
|
141
|
+
* 「Continue from where you left off」暴走を避けて従来 per-message を維持)。 */
|
|
142
|
+
this._residentEligible =
|
|
143
|
+
typeof resident === "boolean"
|
|
144
|
+
? resident
|
|
145
|
+
: !resumeSessionId && CHAT_RESIDENT_ENABLED
|
|
146
|
+
/** 常駐query の input キュー (streaming input)。初回 input 時に生成・query 起動。 */
|
|
147
|
+
this._inputQueue = null
|
|
148
|
+
/** 起動済みの常駐 query ハンドル (interrupt() 用)。 */
|
|
149
|
+
this._residentQuery = null
|
|
150
|
+
/** 常駐 query を起動済みか (二重起動防止)。 */
|
|
151
|
+
this._residentStarted = false
|
|
152
|
+
|
|
153
|
+
/** 改修3: per-message セッションで busy 中に届いた送信を退避する pending キュー。
|
|
154
|
+
* 常駐 query 化 (改修2) とは別レイヤー。resume チェーンは維持したまま、現ターン
|
|
155
|
+
* 完了時 (finally) に先頭から drain して次ターンを自動発火する。 */
|
|
156
|
+
this._pendingMessages = []
|
|
157
|
+
/** 改修3: 直近 browser へ通知した pending 件数 (変化時のみ queue_state を emit する)。 */
|
|
158
|
+
this._lastEmittedQueueCount = 0
|
|
159
|
+
|
|
92
160
|
/** @type {Map<string, {resolve: (decision: object) => void}>} permission 応答待ち */
|
|
93
161
|
this._permissionResolvers = new Map()
|
|
94
162
|
/** 現在ターン実行中か (多重 query 防止) */
|
|
@@ -210,20 +278,50 @@ class ClaudeStreamSession {
|
|
|
210
278
|
|
|
211
279
|
/**
|
|
212
280
|
* ユーザーメッセージ 1 件を 1 query() として実行する。
|
|
213
|
-
*
|
|
281
|
+
* 既存ターン実行中 (busy) は破棄せず pending キューへ退避し、現ターン完了時に drain する
|
|
282
|
+
* (改修3)。常駐 query 対象 (新規セッション) は InputQueue へ積む (改修2)。
|
|
214
283
|
*/
|
|
215
284
|
async sendMessage(message) {
|
|
216
285
|
if (this._closed) return
|
|
286
|
+
const prompt = extractPromptText(message)
|
|
287
|
+
if (!prompt) return
|
|
288
|
+
|
|
289
|
+
// 改修2: 常駐query対象セッションは streaming input キューへ積む (busy 中でも積み、
|
|
290
|
+
// SDK が順次処理する)。初回 input で常駐 query を 1 回だけ起動する。
|
|
291
|
+
if (this._residentEligible) {
|
|
292
|
+
if (!this._inputQueue) this._inputQueue = new InputQueue()
|
|
293
|
+
this._busy = true
|
|
294
|
+
this._inputQueue.push(toSDKUserMessage(prompt))
|
|
295
|
+
if (!this._residentStarted) {
|
|
296
|
+
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
|
+
})
|
|
303
|
+
}
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 従来 per-message: 1 メッセージ = 1 query (resume チェーン)。既存セッション (resume) 用。
|
|
308
|
+
// 改修3: busy 中の送信は破棄せず pending キューへ退避し、現ターン完了時に drain する
|
|
309
|
+
// (ターミナル流の「積む→待機→順次実行」)。常駐 query 化はしないので暴走リスクは増えない。
|
|
217
310
|
if (this._busy) {
|
|
218
|
-
this.
|
|
219
|
-
|
|
220
|
-
|
|
311
|
+
this._pendingMessages.push(prompt)
|
|
312
|
+
this.logger?.info(
|
|
313
|
+
{ stream_id: this.stream_id, queued: this._pendingMessages.length },
|
|
314
|
+
"claude busy, message queued",
|
|
221
315
|
)
|
|
316
|
+
this._emitQueueState()
|
|
222
317
|
return
|
|
223
318
|
}
|
|
224
|
-
|
|
225
|
-
|
|
319
|
+
return this._runPerMessage(prompt)
|
|
320
|
+
}
|
|
226
321
|
|
|
322
|
+
/** per-message 1 ターンを実行する (resume チェーン)。busy 中に届いた送信は sendMessage
|
|
323
|
+
* が pending キューへ退避し、本メソッドの finally で drain する。 */
|
|
324
|
+
async _runPerMessage(prompt) {
|
|
227
325
|
this._busy = true
|
|
228
326
|
this._abortController = new AbortController()
|
|
229
327
|
let aborted = false
|
|
@@ -316,12 +414,138 @@ class ClaudeStreamSession {
|
|
|
316
414
|
} finally {
|
|
317
415
|
this.onReap?.()
|
|
318
416
|
}
|
|
417
|
+
} else {
|
|
418
|
+
// 改修3: busy 中に積まれた pending を順次発火 (per-message resume チェーン維持)。
|
|
419
|
+
// 停止 (abort) 後も温存したキューはそのまま流す。
|
|
420
|
+
this._drainPending()
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** 改修3: pending キューの先頭を取り出して次の per-message ターンを発火する。
|
|
426
|
+
* 各ターンの finally から呼ばれ、キューが空になるまで resume チェーンが続く。 */
|
|
427
|
+
_drainPending() {
|
|
428
|
+
if (this._closed || this._busy) return
|
|
429
|
+
if (this._pendingMessages.length === 0) {
|
|
430
|
+
this._emitQueueState()
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
const next = this._pendingMessages.shift()
|
|
434
|
+
this._emitQueueState()
|
|
435
|
+
this._runPerMessage(next).catch((err) => {
|
|
436
|
+
this.logger?.error(
|
|
437
|
+
{ stream_id: this.stream_id, err: err?.message },
|
|
438
|
+
"drain runPerMessage threw",
|
|
439
|
+
)
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** 改修3: pending キューの現状を browser へ通知する (送信待ちチップ表示用)。
|
|
444
|
+
* onEvent 経由で claude.event(event.type="queue_state") として届く。 */
|
|
445
|
+
_emitQueueState() {
|
|
446
|
+
const count = this._pendingMessages.length
|
|
447
|
+
// 件数が変わらないなら通知しない (空→空の冗長 emit を抑止)。
|
|
448
|
+
if (count === this._lastEmittedQueueCount) return
|
|
449
|
+
this._lastEmittedQueueCount = count
|
|
450
|
+
try {
|
|
451
|
+
this.onEvent?.({
|
|
452
|
+
type: "queue_state",
|
|
453
|
+
pending: count,
|
|
454
|
+
messages: this._pendingMessages.map((p) =>
|
|
455
|
+
typeof p === "string" && p.length > 120 ? `${p.slice(0, 120)}…` : p,
|
|
456
|
+
),
|
|
457
|
+
})
|
|
458
|
+
} catch {
|
|
459
|
+
/* ignore */
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** 改修2: 常駐 query を 1 回だけ起動し、streaming input キューから複数ターンを処理する。
|
|
464
|
+
* system/init は最初の 1 回のみ (毎ターン init = 文脈/キャッシュ温め直しを解消)。各ターンは
|
|
465
|
+
* result で _busy=false にするが query は継続し次 input を待つ。close (inputQueue.close) で
|
|
466
|
+
* generator が終わり query が完走する。resume は使わない (新規セッションのみが対象)。
|
|
467
|
+
* 常駐では query stream が唯一のイベント源のため watcher は張らない。 */
|
|
468
|
+
async _runResidentQuery() {
|
|
469
|
+
const options = {
|
|
470
|
+
cwd: this.cwd,
|
|
471
|
+
canUseTool: (toolName, input) => this._canUseTool(toolName, input),
|
|
472
|
+
includePartialMessages: true,
|
|
473
|
+
}
|
|
474
|
+
if (this.model) options.model = this.model
|
|
475
|
+
if (this.permissionMode) options.permissionMode = this.permissionMode
|
|
476
|
+
if (this.maxTurns != null) options.maxTurns = this.maxTurns
|
|
477
|
+
if (this.maxThinkingTokens != null) options.maxThinkingTokens = this.maxThinkingTokens
|
|
478
|
+
const denyPending = (reason) => {
|
|
479
|
+
for (const [, resolver] of this._permissionResolvers) {
|
|
480
|
+
try {
|
|
481
|
+
resolver.resolve({ behavior: "deny", message: reason })
|
|
482
|
+
} catch {
|
|
483
|
+
/* ignore */
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
this._permissionResolvers.clear()
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
const q = this.sdk.query({ prompt: this._inputQueue, options })
|
|
490
|
+
this._residentQuery = q
|
|
491
|
+
for await (const msg of q) {
|
|
492
|
+
if (
|
|
493
|
+
msg?.type === "system" &&
|
|
494
|
+
msg?.subtype === "init" &&
|
|
495
|
+
typeof msg.session_id === "string"
|
|
496
|
+
) {
|
|
497
|
+
this.sessionId = msg.session_id
|
|
498
|
+
}
|
|
499
|
+
if (msg?.type === "result") {
|
|
500
|
+
if (typeof msg.session_id === "string") this.sessionId = msg.session_id
|
|
501
|
+
// ターン完了: 未解決 permission を閉じ、次 input 受付へ (query は継続)。
|
|
502
|
+
denyPending("turn ended")
|
|
503
|
+
this._busy = false
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
this.onEvent?.(msg)
|
|
507
|
+
} catch (err) {
|
|
508
|
+
this.logger?.warn(
|
|
509
|
+
{ err: err.message, stream_id: this.stream_id },
|
|
510
|
+
"onEvent callback threw",
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
} catch (err) {
|
|
515
|
+
if (!this._closed) {
|
|
516
|
+
try {
|
|
517
|
+
this.onError?.(err)
|
|
518
|
+
} catch {
|
|
519
|
+
/* ignore */
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
} finally {
|
|
523
|
+
this._residentQuery = null
|
|
524
|
+
this._busy = false
|
|
525
|
+
denyPending("closed")
|
|
526
|
+
// 常駐 query の終了 = セッション終了。detached 後にここへ来たら reap する。
|
|
527
|
+
if (this._reapAfterTurn && !this._closed) {
|
|
528
|
+
try {
|
|
529
|
+
this.close()
|
|
530
|
+
} finally {
|
|
531
|
+
this.onReap?.()
|
|
532
|
+
}
|
|
319
533
|
}
|
|
320
534
|
}
|
|
321
535
|
}
|
|
322
536
|
|
|
323
537
|
/** 実行中ターンを中断する (次の input は引き続き受け付ける)。 */
|
|
324
538
|
abortTurn() {
|
|
539
|
+
// 常駐 query は interrupt() で現ターンのみ中断する (query は継続、次 input 受付継続)。
|
|
540
|
+
if (this._residentQuery && typeof this._residentQuery.interrupt === "function") {
|
|
541
|
+
try {
|
|
542
|
+
this._residentQuery.interrupt()
|
|
543
|
+
} catch {
|
|
544
|
+
/* ignore */
|
|
545
|
+
}
|
|
546
|
+
this._busy = false
|
|
547
|
+
return
|
|
548
|
+
}
|
|
325
549
|
if (this._abortController) {
|
|
326
550
|
try {
|
|
327
551
|
this._abortController.abort()
|
|
@@ -348,6 +572,10 @@ class ClaudeStreamSession {
|
|
|
348
572
|
/** セッション終了。新規ターンを止め、実行中なら中断し、watcher も停止する。 */
|
|
349
573
|
close() {
|
|
350
574
|
this._closed = true
|
|
575
|
+
// 改修3: 未処理の pending を破棄 (セッション終了後に drain しないため)。
|
|
576
|
+
this._pendingMessages = []
|
|
577
|
+
// 常駐 query は input キューを閉じて generator を終わらせ query を完走させる。
|
|
578
|
+
if (this._inputQueue) this._inputQueue.close()
|
|
351
579
|
this.abortTurn()
|
|
352
580
|
if (this._idleTimer) {
|
|
353
581
|
clearTimeout(this._idleTimer)
|
|
@@ -415,6 +643,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
415
643
|
maxTurns,
|
|
416
644
|
maxThinkingTokens,
|
|
417
645
|
resumeSessionId,
|
|
646
|
+
resident,
|
|
418
647
|
}) {
|
|
419
648
|
if (!stream_id) throw new TypeError("attach requires stream_id")
|
|
420
649
|
if (this.sessions.has(stream_id)) {
|
|
@@ -446,6 +675,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
446
675
|
maxThinkingTokens:
|
|
447
676
|
typeof maxThinkingTokens === "number" ? maxThinkingTokens : null,
|
|
448
677
|
resumeSessionId: resumeSessionId || null,
|
|
678
|
+
resident,
|
|
449
679
|
sdk: this.sdk,
|
|
450
680
|
logger: this.logger,
|
|
451
681
|
onEvent: (event) => {
|