@cocorograph/hub-agent 0.8.93 → 0.8.94

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.8.93",
3
+ "version": "0.8.94",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -2736,6 +2736,8 @@ export class ClaudeStreamBridge extends EventEmitter {
2736
2736
  this._mcpControlStarting = null
2737
2737
  /** listSupportedCommands() の短命キャッシュ {at:ms, commands:SlashCommand[]} (T06117)。 */
2738
2738
  this._supportedCommandsCache = null
2739
+ /** listSupportedCommands() の取得中 Promise (同時呼び出しの相乗り用)。 */
2740
+ this._supportedCommandsInflight = null
2739
2741
  }
2740
2742
 
2741
2743
  /** _liveBySession の複合キー (T06271): cwd を含めることで、別 workspace の同一 session_id を
@@ -3274,6 +3276,8 @@ export class ClaudeStreamBridge extends EventEmitter {
3274
3276
  // control query が死んだらキャッシュも破棄する。次回アクセスで新しい
3275
3277
  // query から取り直す (別 query の init スナップショットに更新される)。
3276
3278
  this._supportedCommandsCache = null
3279
+ // 死んだ query への取得に後続の要求を相乗りさせない。
3280
+ this._supportedCommandsInflight = null
3277
3281
  }
3278
3282
  }
3279
3283
  })()
@@ -3317,6 +3321,9 @@ export class ClaudeStreamBridge extends EventEmitter {
3317
3321
  // reload / shutdown 後も古いキャッシュが残り続け、listSupportedCommands が新しい
3318
3322
  // query を起動せず stale を返し続ける (ライフタイムキャッシュの永続化バグ)。
3319
3323
  this._supportedCommandsCache = null
3324
+ // 畳んだ query への取得中 Promise も捨てる。残すと reload 後の要求が旧 query の取得へ
3325
+ // 相乗りし、新しい query を起動しない。旧取得の結果は _loadSupportedCommands 側で捨てる。
3326
+ this._supportedCommandsInflight = null
3320
3327
  try {
3321
3328
  input?.close()
3322
3329
  } catch {
@@ -3404,6 +3411,21 @@ export class ClaudeStreamBridge extends EventEmitter {
3404
3411
  // ライフタイムキャッシュ: 存在すれば (init スナップショット + commands_changed で
3405
3412
  // 反映済みの最新) をそのまま返す。再取得しない (上記 ⚠️)。
3406
3413
  if (this._supportedCommandsCache) return this._supportedCommandsCache.commands
3414
+ // 取得中の呼び出しがあれば相乗りする。再接続直後は開いているタブの数だけ skills.request が
3415
+ // 同時に届き、キャッシュが空のうちは全員が supportedCommands() を別々に叩いていた
3416
+ // (2026-09-23 実測: 再接続直後に skills 15 件・agents 14 件が同時着)。
3417
+ if (this._supportedCommandsInflight) return this._supportedCommandsInflight
3418
+ const inflight = this._loadSupportedCommands()
3419
+ this._supportedCommandsInflight = inflight
3420
+ try {
3421
+ return await inflight
3422
+ } finally {
3423
+ if (this._supportedCommandsInflight === inflight) this._supportedCommandsInflight = null
3424
+ }
3425
+ }
3426
+
3427
+ /** listSupportedCommands の取得本体 (相乗りの外側から 1 回だけ呼ばれる)。 */
3428
+ async _loadSupportedCommands() {
3407
3429
  try {
3408
3430
  const q = await this._ensureMcpControlQuery()
3409
3431
  this._armMcpControlIdle()
@@ -3412,6 +3434,9 @@ export class ClaudeStreamBridge extends EventEmitter {
3412
3434
  return null
3413
3435
  }
3414
3436
  const commands = await q.supportedCommands()
3437
+ // 取得中に query が畳まれた (idle / reload / shutdown) なら、旧 query の一覧を
3438
+ // キャッシュへ書き戻さない。呼び出し側は null で FS 走査へフォールバックする。
3439
+ if (this._mcpControlQuery !== q) return null
3415
3440
  const arr = Array.isArray(commands) ? commands : []
3416
3441
  // commands_changed が先に届いてキャッシュを埋めていたら、その最新を尊重する
3417
3442
  // (init スナップショットで上書きしない)。
@@ -252,12 +252,26 @@ export class DispatchScheduler {
252
252
  }
253
253
 
254
254
  _runNow(msg, lane = "bypass") {
255
- return Promise.resolve().then(() => this.run(msg)).catch((err) => {
256
- this.logger?.error?.(
257
- { err: err?.message, type: msg?.type, lane },
258
- "dispatch threw",
259
- )
260
- })
255
+ // 順番待ちが無い経路なので wait_ms は常に 0。本体が遅いときだけ記録する
256
+ // (skills/agents 等の読み取りをここへ移したとき、効果を測れなくならないように)。
257
+ const startedAt = Date.now()
258
+ return Promise.resolve()
259
+ .then(() => this.run(msg))
260
+ .finally(() => {
261
+ const runMs = Date.now() - startedAt
262
+ if (shouldLogDispatchTiming(msg?.type, 0, runMs)) {
263
+ this.logger?.info?.(
264
+ { type: msg?.type, lane, wait_ms: 0, run_ms: runMs },
265
+ "dispatch timing",
266
+ )
267
+ }
268
+ })
269
+ .catch((err) => {
270
+ this.logger?.error?.(
271
+ { err: err?.message, type: msg?.type, lane },
272
+ "dispatch threw",
273
+ )
274
+ })
261
275
  }
262
276
 
263
277
  /**
package/src/main.mjs CHANGED
@@ -323,10 +323,60 @@ export function isReadOnlyRpc(type) {
323
323
  // T06603 (2026-07-25): キュー snapshot の pull。in-memory 配列を読むだけで副作用が無く、
324
324
  // かつ「直列キュー前方の重い処理で再同期が遅延する」と送信待ちチップの固着が長引く
325
325
  // (再同期は固着を直すための経路なので、詰まりやすい経路に置いてはならない)。
326
- type === "claude.queue.request"
326
+ type === "claude.queue.request" ||
327
+ // 2026-09-23: 一覧の読み取り (FS 走査と SDK のコマンド一覧) だけで状態を変えず、request_id で
328
+ // 相関する。再接続直後はタブの数だけ同時に届き (実測 29 件)、control レーンで 1 件ずつ
329
+ // 並べていたため最後の 1 件が約 3 秒待っていた。同一要求は coalesceInflight で相乗りする。
330
+ type === "skills.request" ||
331
+ type === "agents.request"
327
332
  )
328
333
  }
329
334
 
335
+ /**
336
+ * 同じ key の処理が実行中なら、その Promise を返して相乗りさせる (完了後は保持しない)。
337
+ * 結果をキャッシュするのではなく「同時に来た同一要求を 1 回の処理にまとめる」だけなので、
338
+ * 完了後に来た要求は必ず取り直す (一覧が古くならない)。
339
+ *
340
+ * @template T
341
+ * @param {Map<string, Promise<T>>} inflight
342
+ * @param {string} key
343
+ * @param {() => Promise<T>} fn
344
+ * @returns {Promise<T>}
345
+ */
346
+ export function coalesceInflight(inflight, key, fn) {
347
+ const running = inflight.get(key)
348
+ if (running) return running
349
+ const promise = Promise.resolve().then(fn)
350
+ inflight.set(key, promise)
351
+ const clear = () => {
352
+ if (inflight.get(key) === promise) inflight.delete(key)
353
+ }
354
+ promise.then(clear, clear)
355
+ return promise
356
+ }
357
+
358
+ /**
359
+ * 起動時に SDK のコマンド一覧を先に取りに行く (startDaemon から呼ぶ)。完了を待たない。
360
+ * claude CLI が無い端末・SDK が無い端末では query を起動しない。
361
+ *
362
+ * @param {{ listSupportedCommands?: () => Promise<unknown> } | null} claudeBridge
363
+ * @param {string[]} availableClis
364
+ * @returns {boolean} 取得を開始したら true
365
+ */
366
+ export function prewarmSupportedCommands(claudeBridge, availableClis) {
367
+ if (!claudeBridge?.listSupportedCommands || !availableClis.includes("claude")) return false
368
+ void claudeBridge.listSupportedCommands()
369
+ return true
370
+ }
371
+
372
+ /** skills.request / agents.request の実行中の処理 (coalesceInflight 用)。 */
373
+ const listRequestsInflight = new Map()
374
+
375
+ /** skills.request の相乗りの単位。結果は CLI 種別と session_name で変わる。 */
376
+ export function skillsRequestKey(cliKind, sessionName) {
377
+ return `skills\u0000${cliKind}\u0000${sessionName || ""}`
378
+ }
379
+
330
380
  /**
331
381
  * active プロファイルの configDir を process.env.CLAUDE_CONFIG_DIR へ反映する。
332
382
  * 既定 (`~/.claude`) のときは変数を消して従来挙動 (Claude Code の既定パス) に戻す。
@@ -726,6 +776,113 @@ export class DeferredRestartCoordinator {
726
776
  }
727
777
  }
728
778
 
779
+ /**
780
+ * bridge のイベントを Hub へ転送する listener を登録する。
781
+ * ⚠️ 送信は必ず sendBridgeMessage を通す。会話の再点灯 (agent.sessions.restore) で使う
782
+ * `restore:` 購読は browser に存在しないため、ここで捨てないと Hub へ幽霊 stream が流れる。
783
+ * 起動処理から切り出してあるのは、この配線そのものをテストから通すため。
784
+ */
785
+ export function wireBridgeEvents(client, { claudeBridge, codexBridge }) {
786
+ // stream-json モード (Sprint G): Claude Code を SDK 経由で起動し、stream イベントを
787
+ // そのまま browser に転送する。SDK 未インストール時は claudeBridge=null で全 attach が
788
+ // claude.error を返す経路に分岐 (dispatch 側で判定)。
789
+ if (claudeBridge) {
790
+ claudeBridge.on("event", ({ stream_id, session_id, cwd, session, event }) => {
791
+ // SDK の rate_limit_event を捕捉して usage の 5h/7d 実値に反映する
792
+ // (チャットモードでは statusLine が更新されないため、これが live 値の唯一の源)。
793
+ if (event?.type === "rate_limit_event" && event.rate_limit_info) {
794
+ try {
795
+ recordChatRateLimit(event.rate_limit_info)
796
+ } catch {
797
+ /* ignore */
798
+ }
799
+ }
800
+ resolveChatContextPct(session, event)
801
+ sendBridgeMessage(client, { type: "claude.event", stream_id, session_id, event })
802
+ })
803
+ claudeBridge.on("permission", ({ stream_id, session_id, cwd, request_id, tool_name, input }) => {
804
+ // 多端末共有: session_id を載せて backend が session group へ broadcast できるように
805
+ // する (全端末に許可ダイアログ → 先着 1 件採用)。
806
+ sendBridgeMessage(client, {
807
+ type: "claude.permission.request",
808
+ stream_id,
809
+ session_id,
810
+ request_id,
811
+ tool_name,
812
+ input,
813
+ })
814
+ })
815
+ claudeBridge.on(
816
+ "permissioncancel",
817
+ ({ stream_id, session_id, cwd, request_ids, reason }) => {
818
+ // SDK チャットの未応答 permission をターン終了/中断/close で内部 deny したとき、
819
+ // browser に残っている承認カードを撤去する (孤児カード防止, 2026-07-10 監査)。
820
+ // TUI 由来カードの撤去と同じ `claude.permission.cancel` 型を使う。
821
+ for (const request_id of request_ids || []) {
822
+ try {
823
+ sendBridgeMessage(client, {
824
+ type: "claude.permission.cancel",
825
+ stream_id,
826
+ session_id,
827
+ cwd,
828
+ request_id,
829
+ reason,
830
+ })
831
+ } catch {
832
+ /* ignore */
833
+ }
834
+ }
835
+ },
836
+ )
837
+ claudeBridge.on("exit", ({ stream_id, code, reason, session_id, cwd }) => {
838
+ sendBridgeMessage(client, { type: "claude.exit", stream_id, code, reason, session_id })
839
+ })
840
+ claudeBridge.on("sessionoptions", (message) => {
841
+ sendBridgeMessage(client, buildClaudeSessionOptionsPayload(message))
842
+ })
843
+ claudeBridge.on("error", ({ stream_id, session_id, error }) => {
844
+ sendBridgeMessage(client, { type: "claude.error", stream_id, session_id, error })
845
+ })
846
+ }
847
+
848
+ // codex 版 (Slice 3): codex-stream-bridge.mjs の生 notification/permission/exit/error を
849
+ // browser にそのまま転送する。claudeBridge と同じ「素通し」方針 (codex-stream-bridge.mjs
850
+ // 冒頭コメント参照)。claude 版にある rate_limit/MCP control query 相当の処理は無い
851
+ // (codex 側にまだ存在しない/後続スライス)。live usage だけを producer 単位で記録する。
852
+ if (codexBridge) {
853
+ codexBridge.on("event", ({ stream_id, producer_seq, thread_id, cwd, notification }) => {
854
+ if (notification.method === "thread/tokenUsage/updated") {
855
+ recordCodexLiveTokenUsage(producer_seq, notification.params?.tokenUsage)
856
+ }
857
+ sendBridgeMessage(client, { type: "codex.event", stream_id, thread_id, event: notification })
858
+ })
859
+ codexBridge.on("permission", ({ stream_id, thread_id, cwd, request_id, method, params }) => {
860
+ sendBridgeMessage(client, {
861
+ type: "codex.permission.request",
862
+ stream_id,
863
+ thread_id,
864
+ request_id,
865
+ method,
866
+ params,
867
+ })
868
+ })
869
+ codexBridge.on("exit", ({ stream_id, producer_seq, thread_id, code, reason, cwd }) => {
870
+ // T06407 増分2: この producer(session.seq) の live tokenUsage スロットを破棄する。
871
+ // seq は immutable なので、reattach で stream_id が付け替わっても alias slot / Map リークは
872
+ // 生じない (stream_id キー時代の major#1 を解消)。
873
+ try {
874
+ forgetCodexLiveTokenUsage(producer_seq)
875
+ } catch {
876
+ /* ignore */
877
+ }
878
+ sendBridgeMessage(client, { type: "codex.exit", stream_id, thread_id, code, reason })
879
+ })
880
+ codexBridge.on("error", ({ stream_id, thread_id, error }) => {
881
+ sendBridgeMessage(client, { type: "codex.error", stream_id, thread_id, error })
882
+ })
883
+ }
884
+ }
885
+
729
886
  export async function startDaemon({ version, claudeSdk } = {}) {
730
887
  const config = await readConfig()
731
888
  if (!config) {
@@ -804,6 +961,13 @@ export async function startDaemon({ version, claudeSdk } = {}) {
804
961
  ? new ClaudeStreamBridge({ sdk: resolvedSdk, logger })
805
962
  : null
806
963
 
964
+ // 起動直後の skills 一覧を待たせないよう、SDK のコマンド一覧を先に取りに行く。取得には
965
+ // control query の起動が要り、初回は 1.5〜3 秒かかる (2026-09-23 実測: 本日の再起動 7 回とも
966
+ // 再接続直後の最初の skills.request が 1.5〜3.1 秒)。再接続で一斉に届く要求はこの取得に
967
+ // 相乗りする (listSupportedCommands の in-flight)。失敗しても null を返すだけで、要求時に
968
+ // FS 走査へフォールバックする。claude CLI が無い端末では query を起動しない。
969
+ prewarmSupportedCommands(claudeBridge, availableClis)
970
+
807
971
  // codex app-server ベースの chat ブリッジ。resolver は各 spawn 時に再評価するため、
808
972
  // daemon 起動時に一時的に unavailable でも bridge 自体は保持する。
809
973
  const codexBridge = new CodexStreamBridge({ logger })
@@ -879,104 +1043,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
879
1043
  logger.warn({ err: err.message }, "ws client error (will retry via close handler)")
880
1044
  })
881
1045
 
882
- // stream-json モード (Sprint G): Claude Code を SDK 経由で起動し、stream イベントを
883
- // そのまま browser に転送する。SDK 未インストール時は claudeBridge=null で全 attach が
884
- // claude.error を返す経路に分岐 (dispatch 側で判定)。
885
- if (claudeBridge) {
886
- claudeBridge.on("event", ({ stream_id, session_id, cwd, session, event }) => {
887
- // SDK の rate_limit_event を捕捉して usage の 5h/7d 実値に反映する
888
- // (チャットモードでは statusLine が更新されないため、これが live 値の唯一の源)。
889
- if (event?.type === "rate_limit_event" && event.rate_limit_info) {
890
- try {
891
- recordChatRateLimit(event.rate_limit_info)
892
- } catch {
893
- /* ignore */
894
- }
895
- }
896
- resolveChatContextPct(session, event)
897
- sendBridgeMessage(client, { type: "claude.event", stream_id, session_id, event })
898
- })
899
- claudeBridge.on("permission", ({ stream_id, session_id, cwd, request_id, tool_name, input }) => {
900
- // 多端末共有: session_id を載せて backend が session group へ broadcast できるように
901
- // する (全端末に許可ダイアログ → 先着 1 件採用)。
902
- sendBridgeMessage(client, {
903
- type: "claude.permission.request",
904
- stream_id,
905
- session_id,
906
- request_id,
907
- tool_name,
908
- input,
909
- })
910
- })
911
- claudeBridge.on(
912
- "permissioncancel",
913
- ({ stream_id, session_id, cwd, request_ids, reason }) => {
914
- // SDK チャットの未応答 permission をターン終了/中断/close で内部 deny したとき、
915
- // browser に残っている承認カードを撤去する (孤児カード防止, 2026-07-10 監査)。
916
- // TUI 由来カードの撤去と同じ `claude.permission.cancel` 型を使う。
917
- for (const request_id of request_ids || []) {
918
- try {
919
- sendBridgeMessage(client, {
920
- type: "claude.permission.cancel",
921
- stream_id,
922
- session_id,
923
- cwd,
924
- request_id,
925
- reason,
926
- })
927
- } catch {
928
- /* ignore */
929
- }
930
- }
931
- },
932
- )
933
- claudeBridge.on("exit", ({ stream_id, code, reason, session_id, cwd }) => {
934
- sendBridgeMessage(client, { type: "claude.exit", stream_id, code, reason, session_id })
935
- })
936
- claudeBridge.on("sessionoptions", (message) => {
937
- sendBridgeMessage(client, buildClaudeSessionOptionsPayload(message))
938
- })
939
- claudeBridge.on("error", ({ stream_id, session_id, error }) => {
940
- sendBridgeMessage(client, { type: "claude.error", stream_id, session_id, error })
941
- })
942
- }
943
-
944
- // codex 版 (Slice 3): codex-stream-bridge.mjs の生 notification/permission/exit/error を
945
- // browser にそのまま転送する。claudeBridge と同じ「素通し」方針 (codex-stream-bridge.mjs
946
- // 冒頭コメント参照)。claude 版にある rate_limit/MCP control query 相当の処理は無い
947
- // (codex 側にまだ存在しない/後続スライス)。live usage だけを producer 単位で記録する。
948
- if (codexBridge) {
949
- codexBridge.on("event", ({ stream_id, producer_seq, thread_id, cwd, notification }) => {
950
- if (notification.method === "thread/tokenUsage/updated") {
951
- recordCodexLiveTokenUsage(producer_seq, notification.params?.tokenUsage)
952
- }
953
- sendBridgeMessage(client, { type: "codex.event", stream_id, thread_id, event: notification })
954
- })
955
- codexBridge.on("permission", ({ stream_id, thread_id, cwd, request_id, method, params }) => {
956
- sendBridgeMessage(client, {
957
- type: "codex.permission.request",
958
- stream_id,
959
- thread_id,
960
- request_id,
961
- method,
962
- params,
963
- })
964
- })
965
- codexBridge.on("exit", ({ stream_id, producer_seq, thread_id, code, reason, cwd }) => {
966
- // T06407 増分2: この producer(session.seq) の live tokenUsage スロットを破棄する。
967
- // seq は immutable なので、reattach で stream_id が付け替わっても alias slot / Map リークは
968
- // 生じない (stream_id キー時代の major#1 を解消)。
969
- try {
970
- forgetCodexLiveTokenUsage(producer_seq)
971
- } catch {
972
- /* ignore */
973
- }
974
- sendBridgeMessage(client, { type: "codex.exit", stream_id, thread_id, code, reason })
975
- })
976
- codexBridge.on("error", ({ stream_id, thread_id, error }) => {
977
- sendBridgeMessage(client, { type: "codex.error", stream_id, thread_id, error })
978
- })
979
- }
1046
+ wireBridgeEvents(client, { claudeBridge, codexBridge })
980
1047
 
981
1048
  // Hub からのメッセージ dispatch は、接続世代 + 機能レーン単位で直列実行する。
982
1049
  //
@@ -4576,18 +4643,24 @@ export async function dispatch(msg, ctx) {
4576
4643
  // 添えれば Codex 向け skills (~/.codex/skills/) を返す。未指定は
4577
4644
  // 従来どおり claude 向け。
4578
4645
  const skillsCliKind = msg.cli_kind === "codex" ? "codex" : "claude"
4579
- // T06117: claude は SDK supportedCommands() を権威に一覧を作る (組込 +
4580
- // グローバル skill + plugin/dynamic を FS 走査の取りこぼしなく反映)。
4581
- // 取れなければ null で listSkills が FS 走査へフォールバックする。
4582
- let sdkCommands = null
4583
- if (skillsCliKind === "claude" && ctx.claudeBridge?.listSupportedCommands) {
4584
- sdkCommands = await ctx.claudeBridge.listSupportedCommands()
4585
- }
4586
- const skills = await listSkills({
4587
- sessionName: msg.session_name,
4588
- cliKind: skillsCliKind,
4589
- sdkCommands,
4590
- })
4646
+ const skills = await coalesceInflight(
4647
+ listRequestsInflight,
4648
+ skillsRequestKey(skillsCliKind, msg.session_name),
4649
+ async () => {
4650
+ // T06117: claude は SDK supportedCommands() を権威に一覧を作る (組込 +
4651
+ // グローバル skill + plugin/dynamic を FS 走査の取りこぼしなく反映)。
4652
+ // 取れなければ null で listSkills が FS 走査へフォールバックする。
4653
+ let sdkCommands = null
4654
+ if (skillsCliKind === "claude" && ctx.claudeBridge?.listSupportedCommands) {
4655
+ sdkCommands = await ctx.claudeBridge.listSupportedCommands()
4656
+ }
4657
+ return listSkills({
4658
+ sessionName: msg.session_name,
4659
+ cliKind: skillsCliKind,
4660
+ sdkCommands,
4661
+ })
4662
+ },
4663
+ )
4591
4664
  ctx.client.send({
4592
4665
  type: "skills.response",
4593
4666
  request_id: msg.request_id,
@@ -4607,7 +4680,8 @@ export async function dispatch(msg, ctx) {
4607
4680
  }
4608
4681
  case "agents.request": {
4609
4682
  try {
4610
- const agents = await listAgents()
4683
+ // 引数を持たない要求なので、実行中の取得があれば全員で結果を共有する。
4684
+ const agents = await coalesceInflight(listRequestsInflight, "agents", () => listAgents())
4611
4685
  ctx.client.send({
4612
4686
  type: "agents.response",
4613
4687
  request_id: msg.request_id,