@cocorograph/hub-agent 0.6.11 → 0.6.13
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 +277 -14
package/package.json
CHANGED
|
@@ -24,6 +24,58 @@ import { randomUUID } from "node:crypto"
|
|
|
24
24
|
import { jsonlPath } from "./claude-history.mjs"
|
|
25
25
|
import { watchSessionFile } from "./claude-history-watch.mjs"
|
|
26
26
|
|
|
27
|
+
/** browser 切断後、セッションを生かしたまま再接続を待つ idle TTL (ミリ秒)。これを過ぎ、
|
|
28
|
+
* かつ走行中でなければ撤去する。端末スリープ/長期離席後の再接続も吸収できるよう 7 日に
|
|
29
|
+
* 設定 (放置セッションのメモリ蓄積は idle 時のみで小さい)。走行中ターンは TTL を過ぎても
|
|
30
|
+
* 完走するまで絶対に撤去しない。明示終了 / 新規セッションでは即時撤去される。 */
|
|
31
|
+
const IDLE_DETACH_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
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
|
+
|
|
27
79
|
/** message (`{role, content}` or string) からプロンプト文字列を取り出す。 */
|
|
28
80
|
function extractPromptText(message) {
|
|
29
81
|
if (typeof message === "string") return message
|
|
@@ -51,6 +103,7 @@ class ClaudeStreamSession {
|
|
|
51
103
|
maxTurns,
|
|
52
104
|
maxThinkingTokens,
|
|
53
105
|
resumeSessionId,
|
|
106
|
+
resident,
|
|
54
107
|
sdk,
|
|
55
108
|
logger,
|
|
56
109
|
onEvent,
|
|
@@ -78,6 +131,25 @@ class ClaudeStreamSession {
|
|
|
78
131
|
/** 次の query() で resume に使う session_id。各ターンの system/init で更新。 */
|
|
79
132
|
this.sessionId = resumeSessionId || null
|
|
80
133
|
|
|
134
|
+
/** browser 切断中フラグ。true でも query は中断せず継続する (絶対に落とさない)。 */
|
|
135
|
+
this._detached = false
|
|
136
|
+
/** detached のまま放置されたセッションを撤去する idle タイマー。再アタッチでキャンセル。 */
|
|
137
|
+
this._idleTimer = null
|
|
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
|
+
|
|
81
153
|
/** @type {Map<string, {resolve: (decision: object) => void}>} permission 応答待ち */
|
|
82
154
|
this._permissionResolvers = new Map()
|
|
83
155
|
/** 現在ターン実行中か (多重 query 防止) */
|
|
@@ -162,12 +234,69 @@ class ClaudeStreamSession {
|
|
|
162
234
|
return true
|
|
163
235
|
}
|
|
164
236
|
|
|
237
|
+
/** 再アタッチ: 走行中(または生存中)セッションに新しい stream_id を紐付け直し、
|
|
238
|
+
* idle 撤去タイマーを止める。以降のターンイベントはこの stream_id 経由で新しい
|
|
239
|
+
* browser 接続へライブに流れる (= 通常の生成中表示と同じ)。再アタッチ前の確定分は
|
|
240
|
+
* browser 側の jsonl hydrate (history.request) で復元するため、ここでは replay しない。 */
|
|
241
|
+
reattach(stream_id) {
|
|
242
|
+
this.stream_id = stream_id
|
|
243
|
+
this._detached = false
|
|
244
|
+
if (this._idleTimer) {
|
|
245
|
+
clearTimeout(this._idleTimer)
|
|
246
|
+
this._idleTimer = null
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** soft detach: browser 切断時にターンを中断せずセッションを生かしたまま detached に
|
|
251
|
+
* する。idle TTL 経過後に初めて onTimeout (= reap) を呼ぶ。再アタッチでキャンセル。 */
|
|
252
|
+
softDetach(ttlMs, onTimeout) {
|
|
253
|
+
this._detached = true
|
|
254
|
+
if (this._idleTimer) clearTimeout(this._idleTimer)
|
|
255
|
+
const tick = () => {
|
|
256
|
+
// 再アタッチ済みなら撤去しない (reattach が _detached=false にする)
|
|
257
|
+
if (!this._detached) {
|
|
258
|
+
this._idleTimer = null
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
// 走行中ターンは完走を待ち TTL 後に再チェック (走行中は絶対に撤去しない)
|
|
262
|
+
if (this._busy) {
|
|
263
|
+
this._idleTimer = setTimeout(tick, ttlMs)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
this._idleTimer = null
|
|
267
|
+
onTimeout?.()
|
|
268
|
+
}
|
|
269
|
+
this._idleTimer = setTimeout(tick, ttlMs)
|
|
270
|
+
}
|
|
271
|
+
|
|
165
272
|
/**
|
|
166
273
|
* ユーザーメッセージ 1 件を 1 query() として実行する。
|
|
167
274
|
* 既存ターン実行中は無視 (UI 側で送信を抑止する想定だが念のため)。
|
|
168
275
|
*/
|
|
169
276
|
async sendMessage(message) {
|
|
170
277
|
if (this._closed) return
|
|
278
|
+
const prompt = extractPromptText(message)
|
|
279
|
+
if (!prompt) return
|
|
280
|
+
|
|
281
|
+
// 改修2: 常駐query対象セッションは streaming input キューへ積む (busy 中でも積み、
|
|
282
|
+
// SDK が順次処理する)。初回 input で常駐 query を 1 回だけ起動する。
|
|
283
|
+
if (this._residentEligible) {
|
|
284
|
+
if (!this._inputQueue) this._inputQueue = new InputQueue()
|
|
285
|
+
this._busy = true
|
|
286
|
+
this._inputQueue.push(toSDKUserMessage(prompt))
|
|
287
|
+
if (!this._residentStarted) {
|
|
288
|
+
this._residentStarted = true
|
|
289
|
+
this._runResidentQuery().catch((err) => {
|
|
290
|
+
this.logger?.error(
|
|
291
|
+
{ stream_id: this.stream_id, err: err?.message },
|
|
292
|
+
"resident query threw",
|
|
293
|
+
)
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 従来 per-message: 1 メッセージ = 1 query (resume チェーン)。既存セッション (resume) 用。
|
|
171
300
|
if (this._busy) {
|
|
172
301
|
this.logger?.warn(
|
|
173
302
|
{ stream_id: this.stream_id },
|
|
@@ -175,8 +304,6 @@ class ClaudeStreamSession {
|
|
|
175
304
|
)
|
|
176
305
|
return
|
|
177
306
|
}
|
|
178
|
-
const prompt = extractPromptText(message)
|
|
179
|
-
if (!prompt) return
|
|
180
307
|
|
|
181
308
|
this._busy = true
|
|
182
309
|
this._abortController = new AbortController()
|
|
@@ -274,8 +401,92 @@ class ClaudeStreamSession {
|
|
|
274
401
|
}
|
|
275
402
|
}
|
|
276
403
|
|
|
404
|
+
/** 改修2: 常駐 query を 1 回だけ起動し、streaming input キューから複数ターンを処理する。
|
|
405
|
+
* system/init は最初の 1 回のみ (毎ターン init = 文脈/キャッシュ温め直しを解消)。各ターンは
|
|
406
|
+
* result で _busy=false にするが query は継続し次 input を待つ。close (inputQueue.close) で
|
|
407
|
+
* generator が終わり query が完走する。resume は使わない (新規セッションのみが対象)。
|
|
408
|
+
* 常駐では query stream が唯一のイベント源のため watcher は張らない。 */
|
|
409
|
+
async _runResidentQuery() {
|
|
410
|
+
const options = {
|
|
411
|
+
cwd: this.cwd,
|
|
412
|
+
canUseTool: (toolName, input) => this._canUseTool(toolName, input),
|
|
413
|
+
includePartialMessages: true,
|
|
414
|
+
}
|
|
415
|
+
if (this.model) options.model = this.model
|
|
416
|
+
if (this.permissionMode) options.permissionMode = this.permissionMode
|
|
417
|
+
if (this.maxTurns != null) options.maxTurns = this.maxTurns
|
|
418
|
+
if (this.maxThinkingTokens != null) options.maxThinkingTokens = this.maxThinkingTokens
|
|
419
|
+
const denyPending = (reason) => {
|
|
420
|
+
for (const [, resolver] of this._permissionResolvers) {
|
|
421
|
+
try {
|
|
422
|
+
resolver.resolve({ behavior: "deny", message: reason })
|
|
423
|
+
} catch {
|
|
424
|
+
/* ignore */
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
this._permissionResolvers.clear()
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
const q = this.sdk.query({ prompt: this._inputQueue, options })
|
|
431
|
+
this._residentQuery = q
|
|
432
|
+
for await (const msg of q) {
|
|
433
|
+
if (
|
|
434
|
+
msg?.type === "system" &&
|
|
435
|
+
msg?.subtype === "init" &&
|
|
436
|
+
typeof msg.session_id === "string"
|
|
437
|
+
) {
|
|
438
|
+
this.sessionId = msg.session_id
|
|
439
|
+
}
|
|
440
|
+
if (msg?.type === "result") {
|
|
441
|
+
if (typeof msg.session_id === "string") this.sessionId = msg.session_id
|
|
442
|
+
// ターン完了: 未解決 permission を閉じ、次 input 受付へ (query は継続)。
|
|
443
|
+
denyPending("turn ended")
|
|
444
|
+
this._busy = false
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
this.onEvent?.(msg)
|
|
448
|
+
} catch (err) {
|
|
449
|
+
this.logger?.warn(
|
|
450
|
+
{ err: err.message, stream_id: this.stream_id },
|
|
451
|
+
"onEvent callback threw",
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
} catch (err) {
|
|
456
|
+
if (!this._closed) {
|
|
457
|
+
try {
|
|
458
|
+
this.onError?.(err)
|
|
459
|
+
} catch {
|
|
460
|
+
/* ignore */
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
} finally {
|
|
464
|
+
this._residentQuery = null
|
|
465
|
+
this._busy = false
|
|
466
|
+
denyPending("closed")
|
|
467
|
+
// 常駐 query の終了 = セッション終了。detached 後にここへ来たら reap する。
|
|
468
|
+
if (this._reapAfterTurn && !this._closed) {
|
|
469
|
+
try {
|
|
470
|
+
this.close()
|
|
471
|
+
} finally {
|
|
472
|
+
this.onReap?.()
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
277
478
|
/** 実行中ターンを中断する (次の input は引き続き受け付ける)。 */
|
|
278
479
|
abortTurn() {
|
|
480
|
+
// 常駐 query は interrupt() で現ターンのみ中断する (query は継続、次 input 受付継続)。
|
|
481
|
+
if (this._residentQuery && typeof this._residentQuery.interrupt === "function") {
|
|
482
|
+
try {
|
|
483
|
+
this._residentQuery.interrupt()
|
|
484
|
+
} catch {
|
|
485
|
+
/* ignore */
|
|
486
|
+
}
|
|
487
|
+
this._busy = false
|
|
488
|
+
return
|
|
489
|
+
}
|
|
279
490
|
if (this._abortController) {
|
|
280
491
|
try {
|
|
281
492
|
this._abortController.abort()
|
|
@@ -302,7 +513,13 @@ class ClaudeStreamSession {
|
|
|
302
513
|
/** セッション終了。新規ターンを止め、実行中なら中断し、watcher も停止する。 */
|
|
303
514
|
close() {
|
|
304
515
|
this._closed = true
|
|
516
|
+
// 常駐 query は input キューを閉じて generator を終わらせ query を完走させる。
|
|
517
|
+
if (this._inputQueue) this._inputQueue.close()
|
|
305
518
|
this.abortTurn()
|
|
519
|
+
if (this._idleTimer) {
|
|
520
|
+
clearTimeout(this._idleTimer)
|
|
521
|
+
this._idleTimer = null
|
|
522
|
+
}
|
|
306
523
|
if (this._watcher) {
|
|
307
524
|
try {
|
|
308
525
|
this._watcher.stop()
|
|
@@ -338,6 +555,9 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
338
555
|
this.logger = logger
|
|
339
556
|
/** @type {Map<string, ClaudeStreamSession>} */
|
|
340
557
|
this.sessions = new Map()
|
|
558
|
+
/** session_id → 生存セッション。再接続 (再アタッチ) で同一セッションを引く索引。
|
|
559
|
+
* browser が切れてもここに残し、走行中ターンの継続 + 再接続でのライブ追従を可能にする。 */
|
|
560
|
+
this._liveBySession = new Map()
|
|
341
561
|
}
|
|
342
562
|
|
|
343
563
|
/**
|
|
@@ -362,11 +582,29 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
362
582
|
maxTurns,
|
|
363
583
|
maxThinkingTokens,
|
|
364
584
|
resumeSessionId,
|
|
585
|
+
resident,
|
|
365
586
|
}) {
|
|
366
587
|
if (!stream_id) throw new TypeError("attach requires stream_id")
|
|
367
588
|
if (this.sessions.has(stream_id)) {
|
|
368
589
|
throw new Error(`stream_id "${stream_id}" は既に attach 済みです`)
|
|
369
590
|
}
|
|
591
|
+
// 常駐化: resume_session_id が示すセッションが (走行中 / idle 問わず) まだ生存していれば、
|
|
592
|
+
// 新規 query を起動せずそのセッションへ再アタッチする。これにより端末スリープ/再接続でも
|
|
593
|
+
// ターンは落ちず、以降のイベントは新しい stream_id 経由でライブに流れる
|
|
594
|
+
// (= 通常の生成中表示と同じ)。再接続前の確定分は browser の jsonl hydrate で復元する。
|
|
595
|
+
if (resumeSessionId) {
|
|
596
|
+
const live = this._liveBySession.get(resumeSessionId)
|
|
597
|
+
if (live && !live._closed) {
|
|
598
|
+
this.sessions.delete(live.stream_id)
|
|
599
|
+
live.reattach(stream_id)
|
|
600
|
+
this.sessions.set(stream_id, live)
|
|
601
|
+
this.logger?.info(
|
|
602
|
+
{ stream_id, resume: resumeSessionId, busy: live._busy },
|
|
603
|
+
"claude stream reattached to live session",
|
|
604
|
+
)
|
|
605
|
+
return { stream_id, resuming: true, reattached: true }
|
|
606
|
+
}
|
|
607
|
+
}
|
|
370
608
|
const session = new ClaudeStreamSession({
|
|
371
609
|
stream_id,
|
|
372
610
|
cwd: cwd || process.env.HOME || process.cwd(),
|
|
@@ -376,24 +614,36 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
376
614
|
maxThinkingTokens:
|
|
377
615
|
typeof maxThinkingTokens === "number" ? maxThinkingTokens : null,
|
|
378
616
|
resumeSessionId: resumeSessionId || null,
|
|
617
|
+
resident,
|
|
379
618
|
sdk: this.sdk,
|
|
380
619
|
logger: this.logger,
|
|
381
620
|
onEvent: (event) => {
|
|
382
|
-
|
|
621
|
+
// stream_id は再アタッチで変わるため session.stream_id (最新) を使う。
|
|
622
|
+
this.emit("event", { stream_id: session.stream_id, session_id: session.sessionId, event })
|
|
623
|
+
// session_id 確定後は索引に登録 (冪等)。再接続時の再アタッチに使う。
|
|
624
|
+
if (session.sessionId) this._liveBySession.set(session.sessionId, session)
|
|
383
625
|
},
|
|
384
626
|
onPermission: ({ tool_name, input, request_id }) => {
|
|
385
|
-
this.emit("permission", {
|
|
627
|
+
this.emit("permission", {
|
|
628
|
+
stream_id: session.stream_id,
|
|
629
|
+
request_id,
|
|
630
|
+
tool_name,
|
|
631
|
+
input,
|
|
632
|
+
})
|
|
386
633
|
},
|
|
387
634
|
onError: (err) => {
|
|
388
|
-
this.emit("error", { stream_id, error: err?.message || String(err) })
|
|
635
|
+
this.emit("error", { stream_id: session.stream_id, error: err?.message || String(err) })
|
|
389
636
|
},
|
|
390
637
|
onReap: () => {
|
|
391
|
-
// ターン完走後の遅延クローズ。Map
|
|
392
|
-
if (this.sessions.get(stream_id) === session) {
|
|
393
|
-
this.sessions.delete(stream_id)
|
|
638
|
+
// ターン完走後の遅延クローズ。Map / 索引から撤去し exit を emit する。
|
|
639
|
+
if (this.sessions.get(session.stream_id) === session) {
|
|
640
|
+
this.sessions.delete(session.stream_id)
|
|
641
|
+
}
|
|
642
|
+
if (session.sessionId && this._liveBySession.get(session.sessionId) === session) {
|
|
643
|
+
this._liveBySession.delete(session.sessionId)
|
|
394
644
|
}
|
|
395
645
|
this.emit("exit", {
|
|
396
|
-
stream_id,
|
|
646
|
+
stream_id: session.stream_id,
|
|
397
647
|
code: 0,
|
|
398
648
|
reason: "detached-after-turn",
|
|
399
649
|
session_id: session.sessionId,
|
|
@@ -457,11 +707,22 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
457
707
|
detach({ stream_id }) {
|
|
458
708
|
const s = this.sessions.get(stream_id)
|
|
459
709
|
if (!s) return false
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
710
|
+
// 常駐化: browser 切断ではセッションを止めない (reap しない)。走行中ターンは完走し、
|
|
711
|
+
// idle のまま IDLE_DETACH_TTL_MS 経過して初めて撤去する。TTL 内に再接続 (再アタッチ)
|
|
712
|
+
// されればキャンセルされる。明示的に止めたい場合は interrupt()/明示終了を使う。
|
|
713
|
+
s.softDetach(IDLE_DETACH_TTL_MS, () => {
|
|
714
|
+
if (this.sessions.get(s.stream_id) === s) this.sessions.delete(s.stream_id)
|
|
715
|
+
if (s.sessionId && this._liveBySession.get(s.sessionId) === s) {
|
|
716
|
+
this._liveBySession.delete(s.sessionId)
|
|
717
|
+
}
|
|
718
|
+
s.close()
|
|
719
|
+
this.emit("exit", {
|
|
720
|
+
stream_id: s.stream_id,
|
|
721
|
+
code: 0,
|
|
722
|
+
reason: "idle-reaped",
|
|
723
|
+
session_id: s.sessionId,
|
|
724
|
+
})
|
|
725
|
+
})
|
|
465
726
|
return true
|
|
466
727
|
}
|
|
467
728
|
|
|
@@ -474,6 +735,8 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
474
735
|
this.sessions.delete(stream_id)
|
|
475
736
|
this.emit("exit", { stream_id, code: 0, reason: "shutdown", session_id: s.sessionId })
|
|
476
737
|
}
|
|
738
|
+
// 常駐化: 再アタッチ索引も全消去 (プロセス終了時の後始末)。
|
|
739
|
+
this._liveBySession.clear()
|
|
477
740
|
}
|
|
478
741
|
|
|
479
742
|
/** 現在 attach 中の stream_id 一覧 (debug 用)。 */
|