@cocorograph/hub-agent 0.8.93 → 0.8.95

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.95",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/agents.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { performance } from "node:perf_hooks"
2
+ import { logPhaseTiming } from "./startup-metrics.mjs"
1
3
  /**
2
4
  * Claude Code の subagent (`~/.claude/agents/<category>/<name>.md`) を集計する。
3
5
  *
@@ -28,13 +30,19 @@ function parseH1(text) {
28
30
  return { title: "", description: "" }
29
31
  }
30
32
 
31
- export async function listAgents() {
33
+ export async function listAgents({ logger } = {}) {
34
+ const started = performance.now()
35
+ let scanned_files = 0
36
+ const finish = (agents) => {
37
+ logPhaseTiming(logger, "agents list timing", { total_ms: performance.now() - started, count: agents.length, scanned_files })
38
+ return agents
39
+ }
32
40
  const agents = []
33
41
  let categories
34
42
  try {
35
43
  categories = await fs.readdir(AGENTS_DIR)
36
44
  } catch {
37
- return []
45
+ return finish([])
38
46
  }
39
47
  for (const cat of categories) {
40
48
  if (cat.startsWith(".") || cat.startsWith("_")) continue
@@ -55,6 +63,7 @@ export async function listAgents() {
55
63
  for (const f of files) {
56
64
  if (!f.endsWith(".md")) continue
57
65
  if (f.startsWith("_")) continue
66
+ scanned_files += 1
58
67
  const fp = path.join(catPath, f)
59
68
  try {
60
69
  const text = await fs.readFile(fp, "utf-8")
@@ -75,5 +84,5 @@ export async function listAgents() {
75
84
  agents.sort(
76
85
  (a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
77
86
  )
78
- return agents
87
+ return finish(agents)
79
88
  }
@@ -1,3 +1,5 @@
1
+ import { performance } from "node:perf_hooks"
2
+ import { logPhaseTiming } from "./startup-metrics.mjs"
1
3
  import { maskTurnText, sanitizeTurnText, truncateTurnText } from "./last-turn-texts.mjs"
2
4
  /**
3
5
  * Claude Code のローカル会話履歴 (`~/.claude/projects/<cwd-encoded>/<session-id>.jsonl`)
@@ -715,6 +717,7 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
715
717
  const cached = getHydrateCache(filePath, st.mtimeMs, st.size, maxLines)
716
718
  if (cached) return cached
717
719
 
720
+ const readStarted = performance.now()
718
721
  let text
719
722
  try {
720
723
  text = await readFile(filePath, "utf-8")
@@ -725,6 +728,7 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
725
728
  logger?.warn({ err: err.message, filePath }, "claude history read failed")
726
729
  throw err
727
730
  }
731
+ const parseStarted = performance.now()
728
732
  // 末尾 newline は捨てる
729
733
  const lines = text.split("\n").filter((l) => l.length > 0)
730
734
  const total_lines = lines.length
@@ -761,6 +765,11 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
761
765
  if (MASK_ENABLED) event = maskMessageObject(event, toolNames)
762
766
  events.push(event)
763
767
  }
768
+ const finished = performance.now()
769
+ logPhaseTiming(logger, "claude history read timing", {
770
+ file_bytes: st.size, lines: total_lines,
771
+ read_ms: parseStarted - readStarted, parse_ms: finished - parseStarted, total_ms: finished - readStarted,
772
+ })
764
773
  const result = { events, total_lines, truncated }
765
774
  setHydrateCache(filePath, st.mtimeMs, st.size, maxLines, result)
766
775
  return result
@@ -1215,7 +1224,13 @@ export async function extractLastTurnTexts(filePath, opts = {}) {
1215
1224
  * @param {{cwd: string, projectsRoot?: string, limit?: number, logger?: import('pino').Logger}} args
1216
1225
  */
1217
1226
  export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
1218
- if (!cwd) return { sessions: [] }
1227
+ const started = performance.now()
1228
+ let scanned_files = 0
1229
+ const finish = (result) => {
1230
+ logPhaseTiming(logger, "claude sessions timing", { total_ms: performance.now() - started, count: result.sessions.length, scanned_files })
1231
+ return result
1232
+ }
1233
+ if (!cwd) return finish({ sessions: [] })
1219
1234
  const dir = path.join(
1220
1235
  projectsRoot || path.join(os.homedir(), ".claude", "projects"),
1221
1236
  encodeCwdToDirName(cwd),
@@ -1224,9 +1239,9 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
1224
1239
  try {
1225
1240
  files = await readdir(dir)
1226
1241
  } catch (err) {
1227
- if (err.code === "ENOENT") return { sessions: [] }
1242
+ if (err.code === "ENOENT") return finish({ sessions: [] })
1228
1243
  logger?.warn({ err: err.message, dir }, "claude history list failed")
1229
- return { sessions: [], error: err.message }
1244
+ return finish({ sessions: [], error: err.message })
1230
1245
  }
1231
1246
  // この dir 専用の last_turn_at_ms キャッシュ (dir 単位 LRU。スキャン汚染しない。finding 3/round2)。
1232
1247
  const fileMap = getLastTurnDirMap(dir)
@@ -1234,6 +1249,7 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
1234
1249
  const stats = []
1235
1250
  for (const f of files) {
1236
1251
  if (!f.endsWith(".jsonl")) continue
1252
+ scanned_files += 1
1237
1253
  const session_id = f.slice(0, -".jsonl".length)
1238
1254
  const filePath = path.join(dir, f)
1239
1255
  try {
@@ -1271,7 +1287,7 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
1271
1287
  const title = await extractTitleCached(fileMap, s.file_path, s.size_bytes, s.mtime)
1272
1288
  sessions.push({ ...s, preview, ...(title === undefined ? {} : { title }) })
1273
1289
  }
1274
- return { sessions }
1290
+ return finish({ sessions })
1275
1291
  }
1276
1292
 
1277
1293
  /**
@@ -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
@@ -1,3 +1,6 @@
1
+ import { performance } from "node:perf_hooks"
2
+ import { createHash } from "node:crypto"
3
+ import { logPhaseTiming, startEventLoopMetrics } from "./startup-metrics.mjs"
1
4
  /**
2
5
  * hub-agent daemon 本体。
3
6
  *
@@ -230,6 +233,9 @@ async function loadClaudeSdk(logger) {
230
233
  /** アカウント利用量取得の時間制限 (ms)。超えたらレーンを解放して次へ進む。 */
231
234
  export const ACCOUNT_USAGE_TIMEOUT_MS = 8_000
232
235
 
236
+ /** Codex アカウント利用制限の取得待ちの上限 (ms)。 */
237
+ export const CODEX_RATE_LIMITS_TIMEOUT_MS = 8_000
238
+
233
239
  /**
234
240
  * `promise` に時間制限を設ける。制限を超えたら `null` で解決し `onTimeout` を呼ぶ。
235
241
  *
@@ -323,10 +329,75 @@ export function isReadOnlyRpc(type) {
323
329
  // T06603 (2026-07-25): キュー snapshot の pull。in-memory 配列を読むだけで副作用が無く、
324
330
  // かつ「直列キュー前方の重い処理で再同期が遅延する」と送信待ちチップの固着が長引く
325
331
  // (再同期は固着を直すための経路なので、詰まりやすい経路に置いてはならない)。
326
- type === "claude.queue.request"
332
+ type === "claude.queue.request" ||
333
+ // 2026-09-23: 一覧の読み取り (FS 走査と SDK のコマンド一覧) だけで状態を変えず、request_id で
334
+ // 相関する。再接続直後はタブの数だけ同時に届き (実測 29 件)、control レーンで 1 件ずつ
335
+ // 並べていたため最後の 1 件が約 3 秒待っていた。同一要求は coalesceInflight で相乗りする。
336
+ type === "skills.request" ||
337
+ type === "agents.request"
327
338
  )
328
339
  }
329
340
 
341
+ const coalesceCounts = new WeakMap()
342
+
343
+ /**
344
+ * 同じ key の処理が実行中なら、その Promise を返して相乗りさせる (完了後は保持しない)。
345
+ * 結果をキャッシュするのではなく「同時に来た同一要求を 1 回の処理にまとめる」だけなので、
346
+ * 完了後に来た要求は必ず取り直す (一覧が古くならない)。
347
+ *
348
+ * @template T
349
+ * @param {Map<string, Promise<T>>} inflight
350
+ * @param {string} key
351
+ * @param {() => Promise<T>} fn
352
+ * @param {(counts: {joined: number}) => void} [onComplete] 計測用。返却値は変更しない。
353
+ * @returns {Promise<T>}
354
+ */
355
+ export function coalesceInflight(inflight, key, fn, onComplete) {
356
+ const running = inflight.get(key)
357
+ if (running) {
358
+ const counts = coalesceCounts.get(running)
359
+ if (counts) counts.joined += 1
360
+ return running
361
+ }
362
+ const promise = Promise.resolve().then(fn)
363
+ inflight.set(key, promise)
364
+ const counts = { joined: 1 }
365
+ coalesceCounts.set(promise, counts)
366
+ const clear = () => {
367
+ if (inflight.get(key) === promise) inflight.delete(key)
368
+ // 計測用の通知は結果に影響させない (ここで投げると未処理 rejection になる)。
369
+ try {
370
+ onComplete?.(counts)
371
+ } catch {
372
+ // 無視する
373
+ }
374
+ }
375
+ promise.then(clear, clear)
376
+ return promise
377
+ }
378
+
379
+ /**
380
+ * 起動時に SDK のコマンド一覧を先に取りに行く (startDaemon から呼ぶ)。完了を待たない。
381
+ * claude CLI が無い端末・SDK が無い端末では query を起動しない。
382
+ *
383
+ * @param {{ listSupportedCommands?: () => Promise<unknown> } | null} claudeBridge
384
+ * @param {string[]} availableClis
385
+ * @returns {boolean} 取得を開始したら true
386
+ */
387
+ export function prewarmSupportedCommands(claudeBridge, availableClis) {
388
+ if (!claudeBridge?.listSupportedCommands || !availableClis.includes("claude")) return false
389
+ void claudeBridge.listSupportedCommands()
390
+ return true
391
+ }
392
+
393
+ /** skills.request / agents.request の実行中の処理 (coalesceInflight 用)。 */
394
+ const listRequestsInflight = new Map()
395
+
396
+ /** skills.request の相乗りの単位。結果は CLI 種別と session_name で変わる。 */
397
+ export function skillsRequestKey(cliKind, sessionName) {
398
+ return `skills\u0000${cliKind}\u0000${sessionName || ""}`
399
+ }
400
+
330
401
  /**
331
402
  * active プロファイルの configDir を process.env.CLAUDE_CONFIG_DIR へ反映する。
332
403
  * 既定 (`~/.claude`) のときは変数を消して従来挙動 (Claude Code の既定パス) に戻す。
@@ -726,7 +797,115 @@ export class DeferredRestartCoordinator {
726
797
  }
727
798
  }
728
799
 
800
+ /**
801
+ * bridge のイベントを Hub へ転送する listener を登録する。
802
+ * ⚠️ 送信は必ず sendBridgeMessage を通す。会話の再点灯 (agent.sessions.restore) で使う
803
+ * `restore:` 購読は browser に存在しないため、ここで捨てないと Hub へ幽霊 stream が流れる。
804
+ * 起動処理から切り出してあるのは、この配線そのものをテストから通すため。
805
+ */
806
+ export function wireBridgeEvents(client, { claudeBridge, codexBridge }) {
807
+ // stream-json モード (Sprint G): Claude Code を SDK 経由で起動し、stream イベントを
808
+ // そのまま browser に転送する。SDK 未インストール時は claudeBridge=null で全 attach が
809
+ // claude.error を返す経路に分岐 (dispatch 側で判定)。
810
+ if (claudeBridge) {
811
+ claudeBridge.on("event", ({ stream_id, session_id, cwd, session, event }) => {
812
+ // SDK の rate_limit_event を捕捉して usage の 5h/7d 実値に反映する
813
+ // (チャットモードでは statusLine が更新されないため、これが live 値の唯一の源)。
814
+ if (event?.type === "rate_limit_event" && event.rate_limit_info) {
815
+ try {
816
+ recordChatRateLimit(event.rate_limit_info)
817
+ } catch {
818
+ /* ignore */
819
+ }
820
+ }
821
+ resolveChatContextPct(session, event)
822
+ sendBridgeMessage(client, { type: "claude.event", stream_id, session_id, event })
823
+ })
824
+ claudeBridge.on("permission", ({ stream_id, session_id, cwd, request_id, tool_name, input }) => {
825
+ // 多端末共有: session_id を載せて backend が session group へ broadcast できるように
826
+ // する (全端末に許可ダイアログ → 先着 1 件採用)。
827
+ sendBridgeMessage(client, {
828
+ type: "claude.permission.request",
829
+ stream_id,
830
+ session_id,
831
+ request_id,
832
+ tool_name,
833
+ input,
834
+ })
835
+ })
836
+ claudeBridge.on(
837
+ "permissioncancel",
838
+ ({ stream_id, session_id, cwd, request_ids, reason }) => {
839
+ // SDK チャットの未応答 permission をターン終了/中断/close で内部 deny したとき、
840
+ // browser に残っている承認カードを撤去する (孤児カード防止, 2026-07-10 監査)。
841
+ // TUI 由来カードの撤去と同じ `claude.permission.cancel` 型を使う。
842
+ for (const request_id of request_ids || []) {
843
+ try {
844
+ sendBridgeMessage(client, {
845
+ type: "claude.permission.cancel",
846
+ stream_id,
847
+ session_id,
848
+ cwd,
849
+ request_id,
850
+ reason,
851
+ })
852
+ } catch {
853
+ /* ignore */
854
+ }
855
+ }
856
+ },
857
+ )
858
+ claudeBridge.on("exit", ({ stream_id, code, reason, session_id, cwd }) => {
859
+ sendBridgeMessage(client, { type: "claude.exit", stream_id, code, reason, session_id })
860
+ })
861
+ claudeBridge.on("sessionoptions", (message) => {
862
+ sendBridgeMessage(client, buildClaudeSessionOptionsPayload(message))
863
+ })
864
+ claudeBridge.on("error", ({ stream_id, session_id, error }) => {
865
+ sendBridgeMessage(client, { type: "claude.error", stream_id, session_id, error })
866
+ })
867
+ }
868
+
869
+ // codex 版 (Slice 3): codex-stream-bridge.mjs の生 notification/permission/exit/error を
870
+ // browser にそのまま転送する。claudeBridge と同じ「素通し」方針 (codex-stream-bridge.mjs
871
+ // 冒頭コメント参照)。claude 版にある rate_limit/MCP control query 相当の処理は無い
872
+ // (codex 側にまだ存在しない/後続スライス)。live usage だけを producer 単位で記録する。
873
+ if (codexBridge) {
874
+ codexBridge.on("event", ({ stream_id, producer_seq, thread_id, cwd, notification }) => {
875
+ if (notification.method === "thread/tokenUsage/updated") {
876
+ recordCodexLiveTokenUsage(producer_seq, notification.params?.tokenUsage)
877
+ }
878
+ sendBridgeMessage(client, { type: "codex.event", stream_id, thread_id, event: notification })
879
+ })
880
+ codexBridge.on("permission", ({ stream_id, thread_id, cwd, request_id, method, params }) => {
881
+ sendBridgeMessage(client, {
882
+ type: "codex.permission.request",
883
+ stream_id,
884
+ thread_id,
885
+ request_id,
886
+ method,
887
+ params,
888
+ })
889
+ })
890
+ codexBridge.on("exit", ({ stream_id, producer_seq, thread_id, code, reason, cwd }) => {
891
+ // T06407 増分2: この producer(session.seq) の live tokenUsage スロットを破棄する。
892
+ // seq は immutable なので、reattach で stream_id が付け替わっても alias slot / Map リークは
893
+ // 生じない (stream_id キー時代の major#1 を解消)。
894
+ try {
895
+ forgetCodexLiveTokenUsage(producer_seq)
896
+ } catch {
897
+ /* ignore */
898
+ }
899
+ sendBridgeMessage(client, { type: "codex.exit", stream_id, thread_id, code, reason })
900
+ })
901
+ codexBridge.on("error", ({ stream_id, thread_id, error }) => {
902
+ sendBridgeMessage(client, { type: "codex.error", stream_id, thread_id, error })
903
+ })
904
+ }
905
+ }
906
+
729
907
  export async function startDaemon({ version, claudeSdk } = {}) {
908
+ startEventLoopMetrics(logger)
730
909
  const config = await readConfig()
731
910
  if (!config) {
732
911
  throw new Error(
@@ -804,6 +983,13 @@ export async function startDaemon({ version, claudeSdk } = {}) {
804
983
  ? new ClaudeStreamBridge({ sdk: resolvedSdk, logger })
805
984
  : null
806
985
 
986
+ // 起動直後の skills 一覧を待たせないよう、SDK のコマンド一覧を先に取りに行く。取得には
987
+ // control query の起動が要り、初回は 1.5〜3 秒かかる (2026-09-23 実測: 本日の再起動 7 回とも
988
+ // 再接続直後の最初の skills.request が 1.5〜3.1 秒)。再接続で一斉に届く要求はこの取得に
989
+ // 相乗りする (listSupportedCommands の in-flight)。失敗しても null を返すだけで、要求時に
990
+ // FS 走査へフォールバックする。claude CLI が無い端末では query を起動しない。
991
+ prewarmSupportedCommands(claudeBridge, availableClis)
992
+
807
993
  // codex app-server ベースの chat ブリッジ。resolver は各 spawn 時に再評価するため、
808
994
  // daemon 起動時に一時的に unavailable でも bridge 自体は保持する。
809
995
  const codexBridge = new CodexStreamBridge({ logger })
@@ -879,104 +1065,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
879
1065
  logger.warn({ err: err.message }, "ws client error (will retry via close handler)")
880
1066
  })
881
1067
 
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
- }
1068
+ wireBridgeEvents(client, { claudeBridge, codexBridge })
980
1069
 
981
1070
  // Hub からのメッセージ dispatch は、接続世代 + 機能レーン単位で直列実行する。
982
1071
  //
@@ -2751,135 +2840,163 @@ export async function buildUsagePayload(ctx) {
2751
2840
  let localUsageMs = 0
2752
2841
  let codexRateLimitsMs = 0
2753
2842
 
2754
- const credentialsStartedAt = Date.now()
2755
- const activeClaudeProfile = await getActiveProfile()
2756
- const claudeHasCredentials = isKeychainPlatform()
2757
- ? await profileHasKeychainCredentials(activeClaudeProfile.configDir)
2758
- : await hasFileCredentialsCached(activeClaudeProfile.configDir)
2759
- credentialsMs = Date.now() - credentialsStartedAt
2760
-
2761
- // 5h/7d の本命ソース: SDK control request (アカウント全体、session 非依存)。
2762
- // statusLine が rate_limits を出さなくなった環境 (2026-07 時点の Claude CLI) でも
2763
- // ここでライブ更新できる。frontend の usage.request ポーリング (既定 60s) 頻度に
2764
- // 相乗りするだけで十分な鮮度が保てるため、専用タイマーは設けない。EXPERIMENTAL
2765
- // API の失敗は getAccountUsage 内で握りつぶし null を返す (recordChatUsageSnapshot
2766
- // は no-op) ので、既存の statusLine/transcript フォールバックは無傷で残る。
2767
- if (ctx.claudeBridge) {
2768
- const accountUsageStartedAt = Date.now()
2769
- try {
2770
- // ⚠️ getAccountUsage は SDK のサブプロセスを spawn する。**時間制限が無いと
2771
- // 返ってこないときに usage レーンが止まり、後続の usage.request (60 秒周期) が
2772
- // 無期限に詰まる**。2026-09-04 までは control レーン (tmux.list_sessions や
2773
- // セッション停止が並ぶ) に乗っていて head-of-line blocking を起こしていたため
2774
- // dispatch-scheduler の _laneFor で専用 "usage" レーンへ分離した。同種の詰まりで
2775
- // cold p95 40〜54 秒を計測した記録が isReadOnlyRpc の注記にある。
2776
- // 時間切れは握りつぶさず warn で残す (これ自体が「詰まりの実測値」になる)。
2777
- const acct = await withDeadline(
2778
- ctx.claudeBridge.getAccountUsage(),
2779
- ACCOUNT_USAGE_TIMEOUT_MS,
2780
- () =>
2781
- ctx.logger?.warn?.(
2782
- { timeout_ms: ACCOUNT_USAGE_TIMEOUT_MS },
2783
- "account usage timed out (dispatch lane released)",
2784
- ),
2785
- )
2786
- if (acct) recordChatUsageSnapshot(acct, Date.now(), activeClaudeProfile.id)
2787
- } catch (err) {
2788
- ctx.logger?.debug?.({ err: err?.message }, "account usage snapshot skipped")
2843
+ // 各 provider は独立して取得する。Claude 内は snapshot → ローカル集計の順を保つ。
2844
+ async function buildClaudeUsage() {
2845
+ const credentialsStartedAt = Date.now()
2846
+ const activeClaudeProfile = await getActiveProfile()
2847
+ const claudeHasCredentials = isKeychainPlatform()
2848
+ ? await profileHasKeychainCredentials(activeClaudeProfile.configDir)
2849
+ : await hasFileCredentialsCached(activeClaudeProfile.configDir)
2850
+ credentialsMs = Date.now() - credentialsStartedAt
2851
+
2852
+ // 5h/7d の本命ソース: SDK control request (アカウント全体、session 非依存)。
2853
+ // statusLine が rate_limits を出さなくなった環境 (2026-07 時点の Claude CLI) でも
2854
+ // ここでライブ更新できる。frontend の usage.request ポーリング (既定 60s) 頻度に
2855
+ // 相乗りするだけで十分な鮮度が保てるため、専用タイマーは設けない。EXPERIMENTAL
2856
+ // API の失敗は getAccountUsage 内で握りつぶし null を返す (recordChatUsageSnapshot
2857
+ // は no-op) ので、既存の statusLine/transcript フォールバックは無傷で残る。
2858
+ if (ctx.claudeBridge) {
2859
+ const accountUsageStartedAt = Date.now()
2860
+ try {
2861
+ // ⚠️ getAccountUsage は SDK のサブプロセスを spawn する。**時間制限が無いと
2862
+ // 返ってこないときに usage レーンが止まり、後続の usage.request (60 秒周期) が
2863
+ // 無期限に詰まる**。2026-09-04 までは control レーン (tmux.list_sessions や
2864
+ // セッション停止が並ぶ) に乗っていて head-of-line blocking を起こしていたため
2865
+ // dispatch-scheduler の _laneFor で専用 "usage" レーンへ分離した。同種の詰まりで
2866
+ // cold p95 40〜54 秒を計測した記録が isReadOnlyRpc の注記にある。
2867
+ // 時間切れは握りつぶさず warn で残す (これ自体が「詰まりの実測値」になる)。
2868
+ const acct = await withDeadline(
2869
+ ctx.claudeBridge.getAccountUsage(),
2870
+ ACCOUNT_USAGE_TIMEOUT_MS,
2871
+ () =>
2872
+ ctx.logger?.warn?.(
2873
+ { timeout_ms: ACCOUNT_USAGE_TIMEOUT_MS },
2874
+ "account usage timed out (dispatch lane released)",
2875
+ ),
2876
+ )
2877
+ if (acct) recordChatUsageSnapshot(acct, Date.now(), activeClaudeProfile.id)
2878
+ } catch (err) {
2879
+ ctx.logger?.debug?.({ err: err?.message }, "account usage snapshot skipped")
2880
+ }
2881
+ accountUsageMs = Date.now() - accountUsageStartedAt
2789
2882
  }
2790
- accountUsageMs = Date.now() - accountUsageStartedAt
2791
- }
2792
2883
 
2793
- const localUsageStartedAt = Date.now()
2794
- let [usage, sessions] = await Promise.all([
2795
- getUsage({ profileId: activeClaudeProfile.id }),
2796
- getSessionUsages(),
2797
- ])
2798
- localUsageMs = Date.now() - localUsageStartedAt
2799
- // 未ログインで残ったローカル transcript/cache をアカウント利用量として見せない。
2800
- if (!claudeHasCredentials) {
2801
- usage = null
2802
- sessions = []
2884
+ const localUsageStartedAt = Date.now()
2885
+ let [usage, sessions] = await Promise.all([
2886
+ getUsage({ profileId: activeClaudeProfile.id }),
2887
+ getSessionUsages(),
2888
+ ])
2889
+ localUsageMs = Date.now() - localUsageStartedAt
2890
+ // 未ログインで残ったローカル transcript/cache をアカウント利用量として見せない。
2891
+ if (!claudeHasCredentials) {
2892
+ usage = null
2893
+ sessions = []
2894
+ }
2895
+ return { usage, sessions }
2803
2896
  }
2804
2897
 
2805
- // Codex 版 (Stream B3, 2026-07-03): rollout jsonl の token_count イベントから
2806
- // 5h/7d + context% を取る。⚠️ Codex の 5h/7d は ChatGPT/OpenAI サブスクリプション
2807
- // の残量であり、上の `usage` (Claude/Anthropic サブスクリプション) とは別契約の
2808
- // 別数値のため、`usage` にマージせず独立フィールド `codexUsage`/`codexSessions`
2809
- // として返す (frontend 側でどちらのアカウントの残量か混同しないため)。
2810
- let codexUsage = null
2811
- let codexContextCwd = null
2812
- let codexSessions = []
2813
- const activeCodexProfile = await codexProfiles.getActiveProfile()
2814
- const codexHasCredentials = await codexProfiles.hasCodexCredentials(activeCodexProfile.configDir)
2815
- if (ctx.codexBridge && codexHasCredentials) {
2816
- try {
2817
- // キャッシュは従来の cwd / アカウントデータだけを作る。
2818
- // 代表会話の値は handleUsageRequest の送信直前に付ける。
2819
- const cwds = new Set()
2820
- for (const s of ctx.codexBridge.sessions.values()) {
2821
- if (s.threadId && s.cwd) cwds.add(s.cwd)
2822
- }
2823
- const liveCodex = []
2824
- for (const cwd of cwds) {
2825
- const authoritative = ctx.codexBridge.liveSessionForCwd?.(cwd)
2826
- if (authoritative?.threadId) {
2827
- liveCodex.push({
2828
- sessionId: authoritative.threadId,
2829
- cwd: authoritative.cwd,
2830
- threadId: authoritative.threadId,
2831
- producerSeq: authoritative.seq,
2898
+ async function buildCodexUsage() {
2899
+ // Codex 版 (Stream B3, 2026-07-03): rollout jsonl の token_count イベントから
2900
+ // 5h/7d + context% を取る。⚠️ Codex の 5h/7d は ChatGPT/OpenAI サブスクリプション
2901
+ // の残量であり、上の `usage` (Claude/Anthropic サブスクリプション) とは別契約の
2902
+ // 別数値のため、`usage` にマージせず独立フィールド `codexUsage`/`codexSessions`
2903
+ // として返す (frontend 側でどちらのアカウントの残量か混同しないため)。
2904
+ let codexUsage = null
2905
+ let codexContextCwd = null
2906
+ let codexSessions = []
2907
+ const activeCodexProfile = await codexProfiles.getActiveProfile()
2908
+ const codexHasCredentials = await codexProfiles.hasCodexCredentials(activeCodexProfile.configDir)
2909
+ if (ctx.codexBridge && codexHasCredentials) {
2910
+ try {
2911
+ // キャッシュは従来の cwd / アカウントデータだけを作る。
2912
+ // 代表会話の値は handleUsageRequest の送信直前に付ける。
2913
+ const cwds = new Set()
2914
+ for (const s of ctx.codexBridge.sessions.values()) {
2915
+ if (s.threadId && s.cwd) cwds.add(s.cwd)
2916
+ }
2917
+ const liveCodex = []
2918
+ for (const cwd of cwds) {
2919
+ const authoritative = ctx.codexBridge.liveSessionForCwd?.(cwd)
2920
+ if (authoritative?.threadId) {
2921
+ liveCodex.push({
2922
+ sessionId: authoritative.threadId,
2923
+ cwd: authoritative.cwd,
2924
+ threadId: authoritative.threadId,
2925
+ producerSeq: authoritative.seq,
2926
+ })
2927
+ }
2928
+ }
2929
+ if (liveCodex.length > 0) {
2930
+ // 5h/7d はアカウント全体の値 (どの live session から読んでも同じ契約の残量)
2931
+ // なので、代表として最初の 1 件から取る (context% はその producer の live を使う)。
2932
+ codexContextCwd = liveCodex[0].cwd
2933
+ codexUsage = await codexUsageForThread(liveCodex[0].threadId, {
2934
+ producerSeq: liveCodex[0].producerSeq,
2832
2935
  })
2936
+ codexSessions = await codexSessionUsages(liveCodex)
2937
+ // ⚠️ codex-review round2 major#2: codexSessionUsages の rollout I/O 中に cwd の
2938
+ // 権威 producer が別 session へ移りうる。await 後に各 cwd の現権威 seq を再検証し、
2939
+ // 集計に使った producer が現権威でない entry は破棄する (古い producer の context を
2940
+ // codexCtxByCwd へ出さない)。破棄した cwd は frontend が state loop の
2941
+ // live.context_pct (毎 tick 権威を同期解決=TOCTOU 無し) にフォールバックする。
2942
+ const seqForCwd = (cwd) => ctx.codexBridge.liveSessionForCwd?.(cwd)?.seq
2943
+ const producerByCwd = new Map(liveCodex.map((e) => [e.cwd, e.producerSeq]))
2944
+ codexSessions = filterCodexSessionsByAuthority(
2945
+ codexSessions,
2946
+ producerByCwd,
2947
+ seqForCwd,
2948
+ )
2949
+ // codex-review round3 minor#1: 集約 codexUsage.context は代表 producer
2950
+ // (liveCodex[0]) の値。await 中に代表が権威を失っていたら旧 context を出さない
2951
+ // (context だけ null 化。5h/7d は account-wide なので保持する)。
2952
+ if (codexUsage && seqForCwd(liveCodex[0].cwd) !== liveCodex[0].producerSeq) {
2953
+ codexUsage = { ...codexUsage, context: null }
2954
+ }
2833
2955
  }
2834
- }
2835
- if (liveCodex.length > 0) {
2836
- // 5h/7d はアカウント全体の値 (どの live session から読んでも同じ契約の残量)
2837
- // なので、代表として最初の 1 件から取る (context% はその producer の live を使う)。
2838
- codexContextCwd = liveCodex[0].cwd
2839
- codexUsage = await codexUsageForThread(liveCodex[0].threadId, {
2840
- producerSeq: liveCodex[0].producerSeq,
2841
- })
2842
- codexSessions = await codexSessionUsages(liveCodex)
2843
- // ⚠️ codex-review round2 major#2: codexSessionUsages の rollout I/O 中に cwd の
2844
- // 権威 producer が別 session へ移りうる。await 後に各 cwd の現権威 seq を再検証し、
2845
- // 集計に使った producer が現権威でない entry は破棄する (古い producer の context を
2846
- // codexCtxByCwd へ出さない)。破棄した cwd は frontend が state loop の
2847
- // live.context_pct (毎 tick 権威を同期解決=TOCTOU 無し) にフォールバックする。
2848
- const seqForCwd = (cwd) => ctx.codexBridge.liveSessionForCwd?.(cwd)?.seq
2849
- const producerByCwd = new Map(liveCodex.map((e) => [e.cwd, e.producerSeq]))
2850
- codexSessions = filterCodexSessionsByAuthority(
2851
- codexSessions,
2852
- producerByCwd,
2853
- seqForCwd,
2854
- )
2855
- // codex-review round3 minor#1: 集約 codexUsage.context は代表 producer
2856
- // (liveCodex[0]) の値。await 中に代表が権威を失っていたら旧 context を出さない
2857
- // (context だけ null 化。5h/7d は account-wide なので保持する)。
2858
- if (codexUsage && seqForCwd(liveCodex[0].cwd) !== liveCodex[0].producerSeq) {
2859
- codexUsage = { ...codexUsage, context: null }
2956
+ // ライブ session の有無にかかわらず、起動直後の account RPC を優先する。
2957
+ // rollout は context の補助だけにし、5h/7d はプロファイル固有の最新実値にする。
2958
+ const codexRateLimitsStartedAt = Date.now()
2959
+ let rateLimits
2960
+ try {
2961
+ // 取得自体は止めず、完了値は bridge 内のキャッシュで次回に再利用する。
2962
+ rateLimits = await withDeadline(
2963
+ ctx.codexBridge.readAccountRateLimits({ profileId: activeCodexProfile.id }),
2964
+ CODEX_RATE_LIMITS_TIMEOUT_MS,
2965
+ () => ctx.logger?.warn?.(
2966
+ { timeout_ms: CODEX_RATE_LIMITS_TIMEOUT_MS },
2967
+ "codex account rate limits timed out (dispatch lane released)",
2968
+ ),
2969
+ )
2970
+ } finally {
2971
+ codexRateLimitsMs = Date.now() - codexRateLimitsStartedAt
2860
2972
  }
2973
+ const accountUsage = codexUsageFromAccountRateLimits(rateLimits)
2974
+ if (accountUsage) {
2975
+ codexUsage = { ...accountUsage, context: codexUsage?.context ?? null }
2976
+ recordAccountUsageSnapshot("codex", activeCodexProfile.id, codexUsage)
2977
+ }
2978
+ } catch (err) {
2979
+ ctx.logger?.debug?.({ err: err?.message }, "codex usage snapshot skipped")
2861
2980
  }
2862
- // ライブ session の有無にかかわらず、起動直後の account RPC を優先する。
2863
- // rollout は context の補助だけにし、5h/7d はプロファイル固有の最新実値にする。
2864
- const codexRateLimitsStartedAt = Date.now()
2865
- const rateLimits = await ctx.codexBridge.readAccountRateLimits({
2866
- profileId: activeCodexProfile.id,
2867
- })
2868
- codexRateLimitsMs = Date.now() - codexRateLimitsStartedAt
2869
- const accountUsage = codexUsageFromAccountRateLimits(rateLimits)
2870
- if (accountUsage) {
2871
- codexUsage = { ...accountUsage, context: codexUsage?.context ?? null }
2872
- recordAccountUsageSnapshot("codex", activeCodexProfile.id, codexUsage)
2873
- }
2874
- } catch (err) {
2875
- ctx.logger?.debug?.({ err: err?.message }, "codex usage snapshot skipped")
2876
2981
  }
2982
+ // Agent 再起動後や一時的な account RPC 失敗時も、同一 profile の最終取得値だけを
2983
+ // 表示する。未ログインならここへ到達せず、表示も完全に隠す。
2984
+ if (codexHasCredentials && !codexUsage) {
2985
+ codexUsage = await readAccountUsageSnapshot("codex", activeCodexProfile.id)
2986
+ }
2987
+ return { codexUsage, codexSessions, codexContextCwd }
2877
2988
  }
2878
- // Agent 再起動後や一時的な account RPC 失敗時も、同一 profile の最終取得値だけを
2879
- // 表示する。未ログインならここへ到達せず、表示も完全に隠す。
2880
- if (codexHasCredentials && !codexUsage) {
2881
- codexUsage = await readAccountUsageSnapshot("codex", activeCodexProfile.id)
2882
- }
2989
+
2990
+ const [{ usage, sessions }, { codexUsage, codexSessions, codexContextCwd }] = await Promise.all([
2991
+ buildClaudeUsage().catch((err) => {
2992
+ ctx.logger?.debug?.({ err: err?.message }, "claude usage build skipped")
2993
+ return { usage: null, sessions: [] }
2994
+ }),
2995
+ buildCodexUsage().catch((err) => {
2996
+ ctx.logger?.debug?.({ err: err?.message }, "codex usage build skipped")
2997
+ return { codexUsage: null, codexSessions: [], codexContextCwd: null }
2998
+ }),
2999
+ ])
2883
3000
 
2884
3001
  return {
2885
3002
  payload: { usage, sessions, codexUsage, codexSessions },
@@ -4576,18 +4693,42 @@ export async function dispatch(msg, ctx) {
4576
4693
  // 添えれば Codex 向け skills (~/.codex/skills/) を返す。未指定は
4577
4694
  // 従来どおり claude 向け。
4578
4695
  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
- })
4696
+ const timing = { sdk_wait_ms: 0, list_skills_ms: 0 }
4697
+ let started
4698
+ const skills = await coalesceInflight(
4699
+ listRequestsInflight,
4700
+ skillsRequestKey(skillsCliKind, msg.session_name),
4701
+ async () => {
4702
+ // T06117: claude は SDK supportedCommands() を権威に一覧を作る (組込 +
4703
+ // グローバル skill + plugin/dynamic を FS 走査の取りこぼしなく反映)。
4704
+ // 取れなければ null で listSkills が FS 走査へフォールバックする。
4705
+ started = performance.now()
4706
+ let sdkCommands = null
4707
+ if (skillsCliKind === "claude" && ctx.claudeBridge?.listSupportedCommands) {
4708
+ try {
4709
+ sdkCommands = await ctx.claudeBridge.listSupportedCommands()
4710
+ } finally {
4711
+ timing.sdk_wait_ms = performance.now() - started
4712
+ }
4713
+ }
4714
+ timing.sdk_wait_ms = performance.now() - started
4715
+ const listStarted = performance.now()
4716
+ try {
4717
+ return await listSkills({
4718
+ sessionName: msg.session_name,
4719
+ cliKind: skillsCliKind,
4720
+ sdkCommands,
4721
+ timing,
4722
+ })
4723
+ } finally {
4724
+ timing.list_skills_ms = performance.now() - listStarted
4725
+ }
4726
+ },
4727
+ ({ joined }) => logPhaseTiming(ctx.logger, "skills request timing", {
4728
+ coalesce_key_hash: createHash("sha256").update(skillsRequestKey(skillsCliKind, msg.session_name)).digest("hex"),
4729
+ joined, ...timing, total_ms: performance.now() - started,
4730
+ }),
4731
+ )
4591
4732
  ctx.client.send({
4592
4733
  type: "skills.response",
4593
4734
  request_id: msg.request_id,
@@ -4607,7 +4748,8 @@ export async function dispatch(msg, ctx) {
4607
4748
  }
4608
4749
  case "agents.request": {
4609
4750
  try {
4610
- const agents = await listAgents()
4751
+ // 引数を持たない要求なので、実行中の取得があれば全員で結果を共有する。
4752
+ const agents = await coalesceInflight(listRequestsInflight, "agents", () => listAgents({ logger: ctx.logger }))
4611
4753
  ctx.client.send({
4612
4754
  type: "agents.response",
4613
4755
  request_id: msg.request_id,
package/src/skills.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { performance } from "node:perf_hooks"
1
2
  /**
2
3
  * Claude Code の skill / slash command を集計する (Sprint H)。
3
4
  *
@@ -160,17 +161,20 @@ export async function listSkills(opts = {}) {
160
161
  // FS の frontmatter から name でオーバーレイし、control query の cwd 外にある
161
162
  // project-local skill は FS 走査で補完する。
162
163
  if (cliKind === "claude" && Array.isArray(opts.sdkCommands)) {
163
- return await listSkillsFromSdk(opts.sdkCommands, sessionName, projectsRoot)
164
+ return await listSkillsFromSdk(opts.sdkCommands, sessionName, projectsRoot, opts.timing)
164
165
  }
165
166
 
166
167
  // フォールバック経路: SDK 未取得 / Codex は従来どおり FS 走査 + 組込ハードコード。
167
168
  const out = []
168
169
  out.push(...(cliKind === "codex" ? CODEX_BUILT_IN_COMMANDS : BUILT_IN_COMMANDS))
170
+ const globalStarted = performance.now()
169
171
  out.push(...(await loadSkillsDir(skillsDirFor(cliKind), "skill")))
170
172
  if (cliKind === "claude") {
171
173
  out.push(...(await loadCommandsDir(GLOBAL_COMMANDS_DIR, "command")))
172
174
  }
173
175
 
176
+ if (opts.timing) opts.timing.global_scan_ms = performance.now() - globalStarted
177
+ const projectStarted = performance.now()
174
178
  if (sessionName && /^D\d{5}_/.test(sessionName)) {
175
179
  const projectDirName = cliKind === "codex" ? ".codex" : ".claude"
176
180
  const projectDir = path.join(projectsRoot, sessionName, projectDirName)
@@ -180,6 +184,8 @@ export async function listSkills(opts = {}) {
180
184
  }
181
185
  }
182
186
 
187
+ if (opts.timing) opts.timing.project_scan_ms = performance.now() - projectStarted
188
+
183
189
  // 同名は最初を採用、名前順 sort
184
190
  const seen = new Set()
185
191
  const deduped = out.filter((s) => {
@@ -218,7 +224,7 @@ export function sourceFromCommandSuffix(desc) {
218
224
  * - control query の cwd 外にある project-local skill/command を FS 走査で補完
219
225
  * - 同名 dedup / 名前順 sort
220
226
  */
221
- async function listSkillsFromSdk(sdkCommands, sessionName, projectsRoot) {
227
+ async function listSkillsFromSdk(sdkCommands, sessionName, projectsRoot, timing) {
222
228
  const entries = []
223
229
  const seen = new Set()
224
230
  for (const c of sdkCommands) {
@@ -232,10 +238,13 @@ async function listSkillsFromSdk(sdkCommands, sessionName, projectsRoot) {
232
238
  }
233
239
 
234
240
  // label_ja オーバーレイ用の FS 走査 (global) + project-local の補完材料。
241
+ const globalStarted = performance.now()
235
242
  const globalLabelSources = [
236
243
  ...(await loadSkillsDir(GLOBAL_SKILLS_DIR, "skill")),
237
244
  ...(await loadCommandsDir(GLOBAL_COMMANDS_DIR, "command")),
238
245
  ]
246
+ if (timing) timing.global_scan_ms = performance.now() - globalStarted
247
+ const projectStarted = performance.now()
239
248
  let projectEntries = []
240
249
  if (sessionName && /^D\d{5}_/.test(sessionName)) {
241
250
  const projectDir = path.join(projectsRoot, sessionName, ".claude")
@@ -245,6 +254,7 @@ async function listSkillsFromSdk(sdkCommands, sessionName, projectsRoot) {
245
254
  ]
246
255
  }
247
256
 
257
+ if (timing) timing.project_scan_ms = performance.now() - projectStarted
248
258
  const labelMap = new Map()
249
259
  for (const s of [...globalLabelSources, ...projectEntries]) {
250
260
  if (s.label) labelMap.set(s.name, s.label)
@@ -0,0 +1,52 @@
1
+ import { monitorEventLoopDelay, performance } from "node:perf_hooks"
2
+
3
+ // performance.now() はプロセス起動を原点とする単調時計。テストでは _setNowForTest で差し替える。
4
+ let defaultNow = () => performance.now()
5
+
6
+ /** テスト専用: 既定の時計を差し替える (省略で元に戻す)。直前の時計を返す。 */
7
+ export function _setNowForTest(fn) {
8
+ const prev = defaultNow
9
+ defaultNow = fn ?? (() => performance.now())
10
+ return prev
11
+ }
12
+
13
+ export function isStartupWindow(now = defaultNow) {
14
+ return now() <= 60_000
15
+ }
16
+
17
+ /**
18
+ * 計測ログを出す。⚠️ 計測は処理結果に影響させない: logger が例外を投げても外へ出さない
19
+ * (呼び出し元の処理・キャッシュ・Promise の完了通知を壊さないため)。
20
+ */
21
+ export function logPhaseTiming(logger, msg, fields, now = defaultNow) {
22
+ try {
23
+ if (isStartupWindow(now) || fields.total_ms >= 500) logger?.info?.(fields, msg)
24
+ } catch {
25
+ // 計測ログの失敗は無視する
26
+ }
27
+ }
28
+
29
+ export function startEventLoopMetrics(logger, {
30
+ now = defaultNow,
31
+ monitor = monitorEventLoopDelay,
32
+ schedule = setTimeout,
33
+ } = {}) {
34
+ const histogram = monitor({ resolution: 20 })
35
+ histogram.enable()
36
+ for (const deadline of [10_000, 30_000, 60_000]) {
37
+ schedule(() => {
38
+ try {
39
+ logger?.info?.({
40
+ since_start_ms: now(),
41
+ p50_ms: histogram.percentile(50) / 1e6,
42
+ p99_ms: histogram.percentile(99) / 1e6,
43
+ max_ms: histogram.max / 1e6,
44
+ }, "startup event loop delay")
45
+ } catch {
46
+ // 計測ログの失敗は無視する
47
+ } finally {
48
+ if (deadline === 60_000) histogram.disable()
49
+ }
50
+ }, Math.max(0, deadline - now())).unref()
51
+ }
52
+ }