@cocorograph/hub-agent 0.6.16 → 0.6.18
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 +164 -16
- package/src/main.mjs +6 -3
package/package.json
CHANGED
|
@@ -47,6 +47,21 @@ const CHAT_RESIDENT_ENABLED = process.env.HUB_AGENT_CHAT_RESIDENT !== "0"
|
|
|
47
47
|
const CHAT_RESIDENT_RESUME_ENABLED =
|
|
48
48
|
process.env.HUB_AGENT_CHAT_RESIDENT_RESUME !== "0"
|
|
49
49
|
|
|
50
|
+
/** 多端末共有セッション (2026-05-30)。同じ Claude セッション (session_id) を複数端末で
|
|
51
|
+
* 同時に開いて、どの端末でもライブ表示・入力・許可応答できるようにする。env
|
|
52
|
+
* HUB_AGENT_CHAT_SHARED="1" で有効化 (デフォルト無効 = 従来の「最後の端末だけが live」)。
|
|
53
|
+
*
|
|
54
|
+
* 有効時の差分:
|
|
55
|
+
* - reattach で旧 stream_id を sessions Map から消さず、1 セッションに複数の端末
|
|
56
|
+
* stream_id を購読登録する (this.streamIds)。これにより旧端末からの input /
|
|
57
|
+
* permission.reply / interrupt も sessions.get(stream_id) で解決できる。
|
|
58
|
+
* - detach は購読端末の参照カウントを下げ、最後の 1 台が外れて初めて idle softDetach
|
|
59
|
+
* する (1 台閉じただけで走行中セッションを撤去しない)。
|
|
60
|
+
* 配信のファンアウト自体は Hub backend が session_id 単位の group broadcast で行うため、
|
|
61
|
+
* agent 側はイベントに session_id を載せるだけでよい (既に emit 済み)。無効時は従来の
|
|
62
|
+
* 単一 stream_id 挙動と完全に一致する (this.streamIds は未使用のまま)。 */
|
|
63
|
+
const CHAT_SHARED_ENABLED = process.env.HUB_AGENT_CHAT_SHARED === "1"
|
|
64
|
+
|
|
50
65
|
/** 文字列を SDK streaming input の SDKUserMessage に包む。
|
|
51
66
|
* SDKUserMessage は parent_tool_use_id: string|null が必須フィールド。現行 SDK は入力側で
|
|
52
67
|
* 寛容なので省略しても動くが、将来の型厳格化に備えて明示する。トップレベルのユーザー入力
|
|
@@ -133,6 +148,10 @@ class ClaudeStreamSession {
|
|
|
133
148
|
onReap,
|
|
134
149
|
}) {
|
|
135
150
|
this.stream_id = stream_id
|
|
151
|
+
/** 多端末共有: このセッションを購読中の全端末 stream_id。CHAT_SHARED_ENABLED 時のみ
|
|
152
|
+
* 参照され、reattach で増え detach で減る。最後の 1 台が外れると idle softDetach に入る。
|
|
153
|
+
* this.stream_id は「最も新しく attach した端末」= legacy emit / primary 用に残す。 */
|
|
154
|
+
this.streamIds = new Set([stream_id])
|
|
136
155
|
this.cwd = cwd
|
|
137
156
|
this.model = model || null
|
|
138
157
|
this.permissionMode = permissionMode || null
|
|
@@ -268,14 +287,92 @@ class ClaudeStreamSession {
|
|
|
268
287
|
/** 再アタッチ: 走行中(または生存中)セッションに新しい stream_id を紐付け直し、
|
|
269
288
|
* idle 撤去タイマーを止める。以降のターンイベントはこの stream_id 経由で新しい
|
|
270
289
|
* browser 接続へライブに流れる (= 通常の生成中表示と同じ)。再アタッチ前の確定分は
|
|
271
|
-
* browser 側の jsonl hydrate (history.request) で復元するため、ここでは replay しない。
|
|
272
|
-
|
|
290
|
+
* browser 側の jsonl hydrate (history.request) で復元するため、ここでは replay しない。
|
|
291
|
+
*
|
|
292
|
+
* 改修5 (2026-05-29): モデル/権限/拡張思考のターン切替。再アタッチ時に opts で
|
|
293
|
+
* 新しい値が渡され、現在値と異なれば applyRuntimeOptions で反映する。これにより
|
|
294
|
+
* 入力欄下バッジの変更が「同一セッション(常駐 query)を維持したまま次ターンから」
|
|
295
|
+
* 効くようになる。従来は reattach が stream_id だけ差し替えていたため、起動済み
|
|
296
|
+
* query の model/permission/thinking は初期値のまま変わらなかった (バッジが効かない
|
|
297
|
+
* 不具合の原因)。 */
|
|
298
|
+
reattach(stream_id, opts = undefined) {
|
|
273
299
|
this.stream_id = stream_id
|
|
300
|
+
// 多端末共有: この端末を購読集合に追加 (無効時は未使用)。旧端末の stream_id は
|
|
301
|
+
// bridge.attach 側で sessions Map に残されるため、ここでは増やすだけでよい。
|
|
302
|
+
this.streamIds.add(stream_id)
|
|
274
303
|
this._detached = false
|
|
275
304
|
if (this._idleTimer) {
|
|
276
305
|
clearTimeout(this._idleTimer)
|
|
277
306
|
this._idleTimer = null
|
|
278
307
|
}
|
|
308
|
+
if (opts) this.applyRuntimeOptions(opts)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 改修5: モデル/権限/拡張思考をランタイムに切り替える。
|
|
312
|
+
*
|
|
313
|
+
* - 保持フィールド (this.model/permissionMode/maxThinkingTokens) を更新する。これは
|
|
314
|
+
* 常駐 query が異常終了して resume 再起動する際 (_runResidentQuery) に最新値で
|
|
315
|
+
* 再 spawn させるため、および per-message セッションが次ターンの options に反映する
|
|
316
|
+
* ため。
|
|
317
|
+
* - 起動済みの常駐 query があれば SDK の制御メソッド (setModel / setPermissionMode /
|
|
318
|
+
* setMaxThinkingTokens) を呼び、プロセス再起動なしで次ターンから即反映する
|
|
319
|
+
* (公式 streaming input mode のランタイム制御。stdin に control_request を流す)。
|
|
320
|
+
*
|
|
321
|
+
* 値が undefined のキーは「変更なし」として無視する (バッジ未送出時に既存値を消さない)。
|
|
322
|
+
* model に空文字/null が来たら setModel(undefined) でデフォルトへ戻す。
|
|
323
|
+
* maxThinkingTokens に 0/null が来たら setMaxThinkingTokens(null) でオフにする。 */
|
|
324
|
+
applyRuntimeOptions({ model, permissionMode, maxThinkingTokens } = {}) {
|
|
325
|
+
const q = this._residentQuery
|
|
326
|
+
// モデル
|
|
327
|
+
if (model !== undefined) {
|
|
328
|
+
const next = model || null
|
|
329
|
+
if (next !== this.model) {
|
|
330
|
+
this.model = next
|
|
331
|
+
if (q && typeof q.setModel === "function") {
|
|
332
|
+
q.setModel(next || undefined).catch((err) =>
|
|
333
|
+
this.logger?.warn(
|
|
334
|
+
{ err: err?.message, stream_id: this.stream_id },
|
|
335
|
+
"setModel failed",
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// 権限モード
|
|
342
|
+
if (permissionMode !== undefined) {
|
|
343
|
+
const next = permissionMode || null
|
|
344
|
+
if (next !== this.permissionMode) {
|
|
345
|
+
this.permissionMode = next
|
|
346
|
+
// setPermissionMode は有効な mode を要求する。null/空 (=未指定へ戻す) は
|
|
347
|
+
// SDK 側に「解除」API が無いため、保持値の更新のみ (次回 spawn で既定に従う)。
|
|
348
|
+
if (next && q && typeof q.setPermissionMode === "function") {
|
|
349
|
+
q.setPermissionMode(next).catch((err) =>
|
|
350
|
+
this.logger?.warn(
|
|
351
|
+
{ err: err?.message, stream_id: this.stream_id },
|
|
352
|
+
"setPermissionMode failed",
|
|
353
|
+
),
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// 拡張思考予算
|
|
359
|
+
if (maxThinkingTokens !== undefined) {
|
|
360
|
+
const next =
|
|
361
|
+
typeof maxThinkingTokens === "number" && maxThinkingTokens > 0
|
|
362
|
+
? maxThinkingTokens
|
|
363
|
+
: null
|
|
364
|
+
if (next !== this.maxThinkingTokens) {
|
|
365
|
+
this.maxThinkingTokens = next
|
|
366
|
+
if (q && typeof q.setMaxThinkingTokens === "function") {
|
|
367
|
+
q.setMaxThinkingTokens(next).catch((err) =>
|
|
368
|
+
this.logger?.warn(
|
|
369
|
+
{ err: err?.message, stream_id: this.stream_id },
|
|
370
|
+
"setMaxThinkingTokens failed",
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
279
376
|
}
|
|
280
377
|
|
|
281
378
|
/** soft detach: browser 切断時にターンを中断せずセッションを生かしたまま detached に
|
|
@@ -704,6 +801,23 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
704
801
|
this._liveBySession = new Map()
|
|
705
802
|
}
|
|
706
803
|
|
|
804
|
+
/** 多端末共有: あるセッションに紐づく全端末 stream_id を sessions Map から外し、
|
|
805
|
+
* session_id 索引も掃除する。撤去 (reap / idle / shutdown) で 1 端末分だけ消すと
|
|
806
|
+
* 他端末キーが Map に残って孤児化するため、まとめて消す。 */
|
|
807
|
+
_dropSessionMappings(session) {
|
|
808
|
+
// session.streamIds に載っている端末キーをすべて外す。
|
|
809
|
+
for (const sid of Array.from(session.streamIds || [])) {
|
|
810
|
+
if (this.sessions.get(sid) === session) this.sessions.delete(sid)
|
|
811
|
+
}
|
|
812
|
+
// 念のため primary stream_id も (streamIds に無い経路があった場合の保険)。
|
|
813
|
+
if (this.sessions.get(session.stream_id) === session) {
|
|
814
|
+
this.sessions.delete(session.stream_id)
|
|
815
|
+
}
|
|
816
|
+
if (session.sessionId && this._liveBySession.get(session.sessionId) === session) {
|
|
817
|
+
this._liveBySession.delete(session.sessionId)
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
707
821
|
/**
|
|
708
822
|
* 新しい Claude セッションを開始する。
|
|
709
823
|
*
|
|
@@ -739,11 +853,31 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
739
853
|
if (resumeSessionId) {
|
|
740
854
|
const live = this._liveBySession.get(resumeSessionId)
|
|
741
855
|
if (live && !live._closed) {
|
|
742
|
-
|
|
743
|
-
|
|
856
|
+
// 多端末共有 (CHAT_SHARED_ENABLED): 旧 stream_id を Map から消さない。同一セッションを
|
|
857
|
+
// 複数端末が購読する状態を作り、どの端末の input/permission/interrupt も解決できるように
|
|
858
|
+
// する。無効時は従来どおり旧 stream_id を撤去し「最後の 1 端末だけ live」に戻す。
|
|
859
|
+
if (!CHAT_SHARED_ENABLED) {
|
|
860
|
+
this.sessions.delete(live.stream_id)
|
|
861
|
+
}
|
|
862
|
+
// 改修5: 再アタッチ時に model/permission/maxThinkingTokens を引き継ぎ反映する。
|
|
863
|
+
// browser はバッジ変更時に新しい値を載せた claude.attach を同一 resume で送る
|
|
864
|
+
// ため、ここで適用すると常駐 query を維持したまま次ターンから切り替わる。
|
|
865
|
+
live.reattach(stream_id, {
|
|
866
|
+
model,
|
|
867
|
+
permissionMode,
|
|
868
|
+
maxThinkingTokens:
|
|
869
|
+
typeof maxThinkingTokens === "number" ? maxThinkingTokens : null,
|
|
870
|
+
})
|
|
744
871
|
this.sessions.set(stream_id, live)
|
|
745
872
|
this.logger?.info(
|
|
746
|
-
{
|
|
873
|
+
{
|
|
874
|
+
stream_id,
|
|
875
|
+
resume: resumeSessionId,
|
|
876
|
+
busy: live._busy,
|
|
877
|
+
model: live.model,
|
|
878
|
+
permissionMode: live.permissionMode,
|
|
879
|
+
maxThinkingTokens: live.maxThinkingTokens,
|
|
880
|
+
},
|
|
747
881
|
"claude stream reattached to live session",
|
|
748
882
|
)
|
|
749
883
|
return { stream_id, resuming: true, reattached: true }
|
|
@@ -768,24 +902,27 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
768
902
|
if (session.sessionId) this._liveBySession.set(session.sessionId, session)
|
|
769
903
|
},
|
|
770
904
|
onPermission: ({ tool_name, input, request_id }) => {
|
|
905
|
+
// 多端末共有: session_id を載せて backend が session group へ broadcast できるように
|
|
906
|
+
// する (全端末に許可ダイアログを出し、先着 1 件の reply を採用する)。
|
|
771
907
|
this.emit("permission", {
|
|
772
908
|
stream_id: session.stream_id,
|
|
909
|
+
session_id: session.sessionId,
|
|
773
910
|
request_id,
|
|
774
911
|
tool_name,
|
|
775
912
|
input,
|
|
776
913
|
})
|
|
777
914
|
},
|
|
778
915
|
onError: (err) => {
|
|
779
|
-
this.emit("error", {
|
|
916
|
+
this.emit("error", {
|
|
917
|
+
stream_id: session.stream_id,
|
|
918
|
+
session_id: session.sessionId,
|
|
919
|
+
error: err?.message || String(err),
|
|
920
|
+
})
|
|
780
921
|
},
|
|
781
922
|
onReap: () => {
|
|
782
923
|
// ターン完走後の遅延クローズ。Map / 索引から撤去し exit を emit する。
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
}
|
|
786
|
-
if (session.sessionId && this._liveBySession.get(session.sessionId) === session) {
|
|
787
|
-
this._liveBySession.delete(session.sessionId)
|
|
788
|
-
}
|
|
924
|
+
// 多端末共有では複数端末キーが Map に載っているのでまとめて外す。
|
|
925
|
+
this._dropSessionMappings(session)
|
|
789
926
|
this.emit("exit", {
|
|
790
927
|
stream_id: session.stream_id,
|
|
791
928
|
code: 0,
|
|
@@ -851,14 +988,25 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
851
988
|
detach({ stream_id }) {
|
|
852
989
|
const s = this.sessions.get(stream_id)
|
|
853
990
|
if (!s) return false
|
|
991
|
+
// 多端末共有: この端末分の購読だけ外す。まだ他端末が見ているならセッションは生かし、
|
|
992
|
+
// softDetach (idle 撤去タイマー) には入らない。最後の 1 台が外れたときだけ従来の
|
|
993
|
+
// idle softDetach に進む。無効時は streamIds が常に 1 要素なので即 softDetach に落ちる。
|
|
994
|
+
if (CHAT_SHARED_ENABLED) {
|
|
995
|
+
s.streamIds.delete(stream_id)
|
|
996
|
+
if (this.sessions.get(stream_id) === s) this.sessions.delete(stream_id)
|
|
997
|
+
if (s.streamIds.size > 0) {
|
|
998
|
+
// primary を生存端末へ寄せ替え (legacy emit / softDetach 撤去ログ用)。
|
|
999
|
+
if (s.stream_id === stream_id) {
|
|
1000
|
+
s.stream_id = Array.from(s.streamIds)[s.streamIds.size - 1]
|
|
1001
|
+
}
|
|
1002
|
+
return true
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
854
1005
|
// 常駐化: browser 切断ではセッションを止めない (reap しない)。走行中ターンは完走し、
|
|
855
1006
|
// idle のまま IDLE_DETACH_TTL_MS 経過して初めて撤去する。TTL 内に再接続 (再アタッチ)
|
|
856
1007
|
// されればキャンセルされる。明示的に止めたい場合は interrupt()/明示終了を使う。
|
|
857
1008
|
s.softDetach(IDLE_DETACH_TTL_MS, () => {
|
|
858
|
-
|
|
859
|
-
if (s.sessionId && this._liveBySession.get(s.sessionId) === s) {
|
|
860
|
-
this._liveBySession.delete(s.sessionId)
|
|
861
|
-
}
|
|
1009
|
+
this._dropSessionMappings(s)
|
|
862
1010
|
s.close()
|
|
863
1011
|
this.emit("exit", {
|
|
864
1012
|
stream_id: s.stream_id,
|
package/src/main.mjs
CHANGED
|
@@ -190,10 +190,13 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
190
190
|
}
|
|
191
191
|
client.send({ type: "claude.event", stream_id, session_id, event })
|
|
192
192
|
})
|
|
193
|
-
claudeBridge.on("permission", ({ stream_id, request_id, tool_name, input }) => {
|
|
193
|
+
claudeBridge.on("permission", ({ stream_id, session_id, request_id, tool_name, input }) => {
|
|
194
|
+
// 多端末共有: session_id を載せて backend が session group へ broadcast できるように
|
|
195
|
+
// する (全端末に許可ダイアログ → 先着 1 件採用)。
|
|
194
196
|
client.send({
|
|
195
197
|
type: "claude.permission.request",
|
|
196
198
|
stream_id,
|
|
199
|
+
session_id,
|
|
197
200
|
request_id,
|
|
198
201
|
tool_name,
|
|
199
202
|
input,
|
|
@@ -202,8 +205,8 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
202
205
|
claudeBridge.on("exit", ({ stream_id, code, reason, session_id }) => {
|
|
203
206
|
client.send({ type: "claude.exit", stream_id, code, reason, session_id })
|
|
204
207
|
})
|
|
205
|
-
claudeBridge.on("error", ({ stream_id, error }) => {
|
|
206
|
-
client.send({ type: "claude.error", stream_id, error })
|
|
208
|
+
claudeBridge.on("error", ({ stream_id, session_id, error }) => {
|
|
209
|
+
client.send({ type: "claude.error", stream_id, session_id, error })
|
|
207
210
|
})
|
|
208
211
|
}
|
|
209
212
|
|