@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.6.16",
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
@@ -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
- reattach(stream_id) {
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
- this.sessions.delete(live.stream_id)
743
- live.reattach(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
+ }
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
- { stream_id, resume: resumeSessionId, busy: live._busy },
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", { 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
+ })
780
921
  },
781
922
  onReap: () => {
782
923
  // ターン完走後の遅延クローズ。Map / 索引から撤去し exit を emit する。
783
- if (this.sessions.get(session.stream_id) === session) {
784
- this.sessions.delete(session.stream_id)
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
- if (this.sessions.get(s.stream_id) === s) this.sessions.delete(s.stream_id)
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