@cocorograph/hub-agent 0.6.17 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.6.17",
3
+ "version": "0.6.18",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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
@@ -278,6 +297,9 @@ class ClaudeStreamSession {
278
297
  * 不具合の原因)。 */
279
298
  reattach(stream_id, opts = undefined) {
280
299
  this.stream_id = stream_id
300
+ // 多端末共有: この端末を購読集合に追加 (無効時は未使用)。旧端末の stream_id は
301
+ // bridge.attach 側で sessions Map に残されるため、ここでは増やすだけでよい。
302
+ this.streamIds.add(stream_id)
281
303
  this._detached = false
282
304
  if (this._idleTimer) {
283
305
  clearTimeout(this._idleTimer)
@@ -779,6 +801,23 @@ export class ClaudeStreamBridge extends EventEmitter {
779
801
  this._liveBySession = new Map()
780
802
  }
781
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
+
782
821
  /**
783
822
  * 新しい Claude セッションを開始する。
784
823
  *
@@ -814,7 +853,12 @@ export class ClaudeStreamBridge extends EventEmitter {
814
853
  if (resumeSessionId) {
815
854
  const live = this._liveBySession.get(resumeSessionId)
816
855
  if (live && !live._closed) {
817
- this.sessions.delete(live.stream_id)
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
+ }
818
862
  // 改修5: 再アタッチ時に model/permission/maxThinkingTokens を引き継ぎ反映する。
819
863
  // browser はバッジ変更時に新しい値を載せた claude.attach を同一 resume で送る
820
864
  // ため、ここで適用すると常駐 query を維持したまま次ターンから切り替わる。
@@ -858,24 +902,27 @@ export class ClaudeStreamBridge extends EventEmitter {
858
902
  if (session.sessionId) this._liveBySession.set(session.sessionId, session)
859
903
  },
860
904
  onPermission: ({ tool_name, input, request_id }) => {
905
+ // 多端末共有: session_id を載せて backend が session group へ broadcast できるように
906
+ // する (全端末に許可ダイアログを出し、先着 1 件の reply を採用する)。
861
907
  this.emit("permission", {
862
908
  stream_id: session.stream_id,
909
+ session_id: session.sessionId,
863
910
  request_id,
864
911
  tool_name,
865
912
  input,
866
913
  })
867
914
  },
868
915
  onError: (err) => {
869
- this.emit("error", { stream_id: session.stream_id, error: err?.message || String(err) })
916
+ this.emit("error", {
917
+ stream_id: session.stream_id,
918
+ session_id: session.sessionId,
919
+ error: err?.message || String(err),
920
+ })
870
921
  },
871
922
  onReap: () => {
872
923
  // ターン完走後の遅延クローズ。Map / 索引から撤去し exit を emit する。
873
- if (this.sessions.get(session.stream_id) === session) {
874
- this.sessions.delete(session.stream_id)
875
- }
876
- if (session.sessionId && this._liveBySession.get(session.sessionId) === session) {
877
- this._liveBySession.delete(session.sessionId)
878
- }
924
+ // 多端末共有では複数端末キーが Map に載っているのでまとめて外す。
925
+ this._dropSessionMappings(session)
879
926
  this.emit("exit", {
880
927
  stream_id: session.stream_id,
881
928
  code: 0,
@@ -941,14 +988,25 @@ export class ClaudeStreamBridge extends EventEmitter {
941
988
  detach({ stream_id }) {
942
989
  const s = this.sessions.get(stream_id)
943
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
+ }
944
1005
  // 常駐化: browser 切断ではセッションを止めない (reap しない)。走行中ターンは完走し、
945
1006
  // idle のまま IDLE_DETACH_TTL_MS 経過して初めて撤去する。TTL 内に再接続 (再アタッチ)
946
1007
  // されればキャンセルされる。明示的に止めたい場合は interrupt()/明示終了を使う。
947
1008
  s.softDetach(IDLE_DETACH_TTL_MS, () => {
948
- if (this.sessions.get(s.stream_id) === s) this.sessions.delete(s.stream_id)
949
- if (s.sessionId && this._liveBySession.get(s.sessionId) === s) {
950
- this._liveBySession.delete(s.sessionId)
951
- }
1009
+ this._dropSessionMappings(s)
952
1010
  s.close()
953
1011
  this.emit("exit", {
954
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