@cocorograph/hub-agent 0.6.17 → 0.6.19

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.19",
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
 
@@ -15,6 +15,8 @@ import path from "node:path"
15
15
  import { spawnSync } from "node:child_process"
16
16
  import { fileURLToPath } from "node:url"
17
17
 
18
+ import { readConfig } from "./config.mjs"
19
+
18
20
  const PLIST_LABEL = "co.cocorograph.hub-agent"
19
21
  const SYSTEMD_UNIT_NAME = "hub-agent.service"
20
22
 
@@ -50,7 +52,7 @@ function escapeXmlText(s) {
50
52
  .replaceAll(">", ">")
51
53
  }
52
54
 
53
- function expandTemplate(text, hubAgentBin) {
55
+ function expandTemplate(text, hubAgentBin, opts = {}) {
54
56
  // hub-agent デーモンの runtime 環境に必須な追加 env を、各プラットフォームの
55
57
  // 起動ファイル形式 (plist / systemd) に合わせて差し込むためのプレースホルダ展開。
56
58
  //
@@ -72,12 +74,43 @@ function expandTemplate(text, hubAgentBin) {
72
74
  // systemd 側: Environment=KEY=VALUE の 1 行。末尾 \n でテンプレ "__...__\n" を吸収。
73
75
  systemdLine = `Environment=NODE_EXTRA_CA_CERTS=${nodeExtraCa}\n`
74
76
  }
77
+
78
+ // 多端末共有チャット (HUB_AGENT_CHAT_SHARED)。NODE_EXTRA_CA_CERTS と同じ仕組みで
79
+ // plist / systemd の EnvironmentVariables に焼き込む。値ソースは (a) installService
80
+ // が agent.json.chat_shared を読んで渡す opts.chatShared、または (b) install-service
81
+ // 実行時の process.env.HUB_AGENT_CHAT_SHARED。これにより agent 更新で install-service
82
+ // が plist を再生成しても、agent.json に chat_shared=true があればフラグが維持される
83
+ // (旧実装は手動 plist 追記が再生成で消える事故があった。2026-05-30)。
84
+ const chatShared =
85
+ opts.chatShared === true || process.env.HUB_AGENT_CHAT_SHARED === "1"
86
+ let chatPlistEntry = ""
87
+ let chatSystemdLine = ""
88
+ if (chatShared) {
89
+ chatPlistEntry =
90
+ ` <key>HUB_AGENT_CHAT_SHARED</key>\n` +
91
+ ` <string>1</string>\n`
92
+ chatSystemdLine = `Environment=HUB_AGENT_CHAT_SHARED=1\n`
93
+ }
94
+
75
95
  return text
76
96
  .replaceAll("__HUB_AGENT_BIN__", hubAgentBin)
77
97
  .replaceAll("__HOME__", os.homedir())
78
98
  .replaceAll("__PATH__", process.env.PATH || "/usr/local/bin:/usr/bin:/bin")
79
99
  .replaceAll("__NODE_EXTRA_CA_CERTS_PLIST_ENTRY__", plistEntry)
80
100
  .replaceAll("__NODE_EXTRA_CA_CERTS_SYSTEMD_LINE__", systemdLine)
101
+ .replaceAll("__HUB_AGENT_CHAT_SHARED_PLIST_ENTRY__", chatPlistEntry)
102
+ .replaceAll("__HUB_AGENT_CHAT_SHARED_SYSTEMD_LINE__", chatSystemdLine)
103
+ }
104
+
105
+ /** agent.json から多端末共有フラグ (chat_shared) を読む。読めなければ false。
106
+ * install-service 時に plist/systemd へ焼き込む値ソースに使う (env 併用は expandTemplate 側)。 */
107
+ async function readChatSharedFromConfig() {
108
+ try {
109
+ const config = await readConfig()
110
+ return config?.chat_shared === true
111
+ } catch {
112
+ return false
113
+ }
81
114
  }
82
115
 
83
116
  async function ensureDir(p) {
@@ -156,9 +189,12 @@ export async function installService({ bin } = {}) {
156
189
  const hubAgentBin = bin || detectHubAgentBin()
157
190
  await ensureLogFile()
158
191
 
192
+ // agent.json の永続フラグを読み、テンプレ展開に渡す (再生成でも維持されるように)。
193
+ const chatShared = await readChatSharedFromConfig()
194
+
159
195
  if (process.platform === "darwin") {
160
196
  const tpl = await readTemplate("co.cocorograph.hub-agent.plist")
161
- const expanded = expandTemplate(tpl, hubAgentBin)
197
+ const expanded = expandTemplate(tpl, hubAgentBin, { chatShared })
162
198
  const dest = macPlistPath()
163
199
  await ensureDir(path.dirname(dest))
164
200
  await fs.writeFile(dest, expanded, { mode: 0o644 })
@@ -173,7 +209,7 @@ export async function installService({ bin } = {}) {
173
209
 
174
210
  if (process.platform === "linux") {
175
211
  const tpl = await readTemplate("hub-agent.service")
176
- const expanded = expandTemplate(tpl, hubAgentBin)
212
+ const expanded = expandTemplate(tpl, hubAgentBin, { chatShared })
177
213
  const dest = linuxUnitPath()
178
214
  await ensureDir(path.dirname(dest))
179
215
  await fs.writeFile(dest, expanded, { mode: 0o644 })
@@ -52,7 +52,7 @@
52
52
  <string>__PATH__</string>
53
53
  <key>HOME</key>
54
54
  <string>__HOME__</string>
55
- __NODE_EXTRA_CA_CERTS_PLIST_ENTRY__ </dict>
55
+ __NODE_EXTRA_CA_CERTS_PLIST_ENTRY____HUB_AGENT_CHAT_SHARED_PLIST_ENTRY__ </dict>
56
56
 
57
57
  <!-- KeepAlive で過剰再起動した時に 10 秒スロットルする -->
58
58
  <key>ThrottleInterval</key>
@@ -12,7 +12,7 @@ Type=simple
12
12
  # 行に、未 set なら空文字列に」展開する。
13
13
  Environment=PATH=__PATH__
14
14
  Environment=HOME=__HOME__
15
- __NODE_EXTRA_CA_CERTS_SYSTEMD_LINE__WorkingDirectory=__HOME__
15
+ __NODE_EXTRA_CA_CERTS_SYSTEMD_LINE____HUB_AGENT_CHAT_SHARED_SYSTEMD_LINE__WorkingDirectory=__HOME__
16
16
  ExecStart=__HUB_AGENT_BIN__ start
17
17
  Restart=always
18
18
  RestartSec=10