@cocorograph/hub-agent 0.8.16 → 0.8.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.8.16",
3
+ "version": "0.8.18",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -26,8 +26,51 @@ import { createHash } from "node:crypto"
26
26
  import { execFile } from "node:child_process"
27
27
  import { promisify } from "node:util"
28
28
 
29
+ import { AsyncTtlCache } from "./async-ttl-cache.mjs"
30
+ import { hasFileCredentials } from "./profiles.mjs"
31
+
29
32
  const execFileP = promisify(execFile)
30
33
 
34
+ /**
35
+ * 認証在否チェックの短期キャッシュ TTL (ms)。
36
+ *
37
+ * ログイン状態は分単位でしか変わらないのに、`usage.request` (Cockpit のウィンドウ数 ×
38
+ * 60 秒周期) が来るたびに `security` コマンドを 1〜3 回 spawn していた。config
39
+ * ディレクトリ単位で 30 秒だけ結果を共有し、同時要求は single-flight に畳む。
40
+ * プロファイル切替直後は呼び出し側が clearCredentialCheckCache() で明示的に捨てる。
41
+ */
42
+ export const CREDENTIAL_CHECK_TTL_MS = 30_000
43
+
44
+ /** @type {Map<string, AsyncTtlCache>} configDir -> Keychain 在否 */
45
+ const keychainCheckCache = new Map()
46
+ /** @type {Map<string, AsyncTtlCache>} configDir -> .credentials.json 在否 */
47
+ const fileCheckCache = new Map()
48
+
49
+ function cachedCheck(store, configDir, build) {
50
+ const key = String(configDir)
51
+ let cache = store.get(key)
52
+ if (!cache) {
53
+ cache = new AsyncTtlCache({ ttlMs: CREDENTIAL_CHECK_TTL_MS })
54
+ store.set(key, cache)
55
+ }
56
+ return cache.get(build)
57
+ }
58
+
59
+ /**
60
+ * 認証在否キャッシュを捨てる (プロファイル切替直後・テストの状態初期化用)。
61
+ * configDir 省略時は全件。
62
+ */
63
+ export function clearCredentialCheckCache(configDir) {
64
+ if (configDir === undefined) {
65
+ for (const cache of keychainCheckCache.values()) cache.clear()
66
+ for (const cache of fileCheckCache.values()) cache.clear()
67
+ return
68
+ }
69
+ const key = String(configDir)
70
+ keychainCheckCache.get(key)?.clear()
71
+ fileCheckCache.get(key)?.clear()
72
+ }
73
+
31
74
  /**
32
75
  * 既定 (プライマリ) プロファイルで CLAUDE_CONFIG_DIR が未設定のとき、Claude Code は
33
76
  * ハッシュ無しの素のサービス名 `Claude Code-credentials` に OAuth トークンを保存する
@@ -100,11 +143,30 @@ export async function existsGenericPassword(service, opts = {}) {
100
143
  * 含める (PLAIN_CREDENTIAL_SERVICE のコメント参照)。含めないと既定プロファイルが常に
101
144
  * 空振りして「要ログイン」が出っぱなしになる。
102
145
  *
146
+ * ⚠️ キャッシュは **opts 無しの呼び出しだけ**に効かせる。`opts.execFileP` 等で挙動を
147
+ * 差し替える呼び出し (テストの分岐検査) までキャッシュすると、実際の分岐を通らないまま
148
+ * 前回値を返してしまい検査が空振りする。
149
+ *
103
150
  * @param {string} configDir
104
151
  * @param {{execFileP?: Function}} [opts]
105
152
  * @returns {Promise<boolean>}
106
153
  */
107
154
  export async function profileHasKeychainCredentials(configDir, opts = {}) {
155
+ if (opts && Object.keys(opts).length > 0) {
156
+ return probeKeychainCredentials(configDir, opts)
157
+ }
158
+ return cachedCheck(keychainCheckCache, configDir, () =>
159
+ probeKeychainCredentials(configDir, {}),
160
+ )
161
+ }
162
+
163
+ /**
164
+ * Keychain 在否の実処理 (キャッシュを挟まない)。
165
+ * @param {string} configDir
166
+ * @param {{execFileP?: Function}} opts
167
+ * @returns {Promise<boolean>}
168
+ */
169
+ async function probeKeychainCredentials(configDir, opts) {
108
170
  const candidates = new Set([configDir])
109
171
  try {
110
172
  candidates.add(await fs.realpath(configDir))
@@ -139,3 +201,17 @@ export async function profileHasKeychainCredentials(configDir, opts = {}) {
139
201
  }
140
202
  return false
141
203
  }
204
+
205
+ /**
206
+ * Keychain の無いプラットフォーム向け在否判定 (profiles.mjs の hasFileCredentials) の
207
+ * キャッシュ版。TTL・クリア導線は Keychain 版と共通 (clearCredentialCheckCache)。
208
+ *
209
+ * ⚠️ 実処理は profiles.mjs 側が正本。ここは呼び出し頻度を落とすためのラッパで、
210
+ * 判定ロジックは持たない。
211
+ *
212
+ * @param {string} configDir
213
+ * @returns {Promise<boolean>}
214
+ */
215
+ export async function hasFileCredentialsCached(configDir) {
216
+ return cachedCheck(fileCheckCache, configDir, () => hasFileCredentials(configDir))
217
+ }
@@ -329,6 +329,7 @@ export class CodexStreamBridge extends EventEmitter {
329
329
  historyMaxItems,
330
330
  historyMaxBytes,
331
331
  listThreadsCacheTtlMs,
332
+ accountRateLimitsCacheTtlMs,
332
333
  } = {}) {
333
334
  super()
334
335
  this.codexCmd = codexCmd
@@ -369,6 +370,14 @@ export class CodexStreamBridge extends EventEmitter {
369
370
  // AsyncTtlCache が single-flight と、thread/start 中の世代切替をまとめて担う。
370
371
  this._listThreadsCacheTtlMs = listThreadsCacheTtlMs ?? 5_000
371
372
  this._listThreadsCache = new Map()
373
+ // アカウント利用制限 (5h/7d) は **アカウント全体の値** で、Cockpit のウィンドウ数だけ
374
+ // 別々に取りに行く必要がない。1 回の取得ごとに `codex app-server` を spawn →
375
+ // initialize → 停止しており、control レーンの実測占有 (2026-08-01: 92 秒中 73.3 秒) の
376
+ // 主因になっていた。profileId 単位で短期キャッシュし、TTL 内は app-server を
377
+ // **一切起動しない**。同時要求は AsyncTtlCache の single-flight で 1 本に畳む。
378
+ this._accountRateLimitsCacheTtlMs = accountRateLimitsCacheTtlMs ?? 45_000
379
+ /** @type {Map<string, AsyncTtlCache>} profileId -> cache */
380
+ this._accountRateLimitsCache = new Map()
372
381
  }
373
382
 
374
383
  /**
@@ -1037,6 +1046,25 @@ export class CodexStreamBridge extends EventEmitter {
1037
1046
  * Cockpit フッターを最新化できる。呼び出し側は認証済みプロファイルの場合だけ呼ぶ。
1038
1047
  */
1039
1048
  async readAccountRateLimits({ cwd, profileId = "default" } = {}) {
1049
+ let cache = this._accountRateLimitsCache.get(profileId)
1050
+ if (!cache) {
1051
+ cache = new AsyncTtlCache({ ttlMs: this._accountRateLimitsCacheTtlMs })
1052
+ this._accountRateLimitsCache.set(profileId, cache)
1053
+ }
1054
+ try {
1055
+ // ⚠️ 失敗を **キャッシュの外** で受けるのが要点。AsyncTtlCache は build 失敗を
1056
+ // キャッシュしないため、一時的な取得失敗が TTL 分だけ再試行を止めることがない。
1057
+ // 失敗時フォールバック (直近の成功値) は従来どおりここで効かせる。
1058
+ return await cache.get(() => this._fetchAccountRateLimits({ cwd, profileId }))
1059
+ } catch (err) {
1060
+ const cached = this._accountRateLimitsByProfile.get(profileId)
1061
+ if (cached) return cached
1062
+ throw err
1063
+ }
1064
+ }
1065
+
1066
+ /** app-server を 1 本 spawn して account/rateLimits/read を取る実処理 (キャッシュ miss 時のみ)。 */
1067
+ async _fetchAccountRateLimits({ cwd, profileId }) {
1040
1068
  const client = this._clientFactory({ cwd: cwd || process.cwd(), codexCmd: this.codexCmd, logger: this.logger })
1041
1069
  client.start()
1042
1070
  try {
@@ -1045,15 +1073,20 @@ export class CodexStreamBridge extends EventEmitter {
1045
1073
  const result = await client.request("account/rateLimits/read", {})
1046
1074
  this._accountRateLimitsByProfile.set(profileId, result)
1047
1075
  return result
1048
- } catch (err) {
1049
- const cached = this._accountRateLimitsByProfile.get(profileId)
1050
- if (cached) return cached
1051
- throw err
1052
1076
  } finally {
1053
1077
  client.stop()
1054
1078
  }
1055
1079
  }
1056
1080
 
1081
+ /** プロファイル切替等で「別アカウントの値を見せない」ために使う (省略時は全 profile)。 */
1082
+ clearAccountRateLimitsCache(profileId) {
1083
+ if (profileId === undefined) {
1084
+ for (const cache of this._accountRateLimitsCache.values()) cache.clear()
1085
+ return
1086
+ }
1087
+ this._accountRateLimitsCache.get(profileId)?.clear()
1088
+ }
1089
+
1057
1090
  _wireClient(session) {
1058
1091
  const { client } = session
1059
1092
  client.on("notification", (msg) => this._onNotification(session, msg))
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 「そのセッションに依頼を送れるか (warm)」判定の **正本 (SoT)** (2026-08-01)。
3
+ *
4
+ * ## 背景 (WHY)
5
+ * セッション間の依頼 (`cockpit_orch.py send`) は sibling-inject が宛先を解決するが、その
6
+ * 対象は **claudeBridge / codexBridge の `describeLiveSessions()` に載るセッションだけ**で
7
+ * ある (載らない宛先は `sendToStream` / `sendToCwd` が `not_found` を返す)。一方 Cockpit の
8
+ * サイドバーは「tmux のセッション行が存在するか」しか見ていないため、**依頼を送れない
9
+ * セッションが「起動中」と表示される**食い違いがあった。
10
+ *
11
+ * そこで `tmux.list_sessions` 応答に `warm` を載せて表示と配送の判定を揃えるが、その際に
12
+ * **同じ判定を別実装で書き直すと再び食い違う** (グレー表示なのに宛先一覧に出ない、等)。
13
+ * 判定はこのモジュール 1 箇所に集約し、sibling-inject の候補列挙 (op:list / op:list-all /
14
+ * 失敗時の診断) と `warm` の算出が**必ず同じ関数**を通るようにする。
15
+ *
16
+ * ## 不変条件
17
+ * - `isSessionWarm(bridges, row) === true`
18
+ * ⇔ `describeLiveSessionsUnion(bridges, {cwd: row.cwd, cliKind: 行の cli_kind}).length > 0`
19
+ * ⇔ sibling-inject の `op:"list"` が同じ cwd / kind で 1 本以上返す
20
+ * - 行の cli_kind は `session-names.mjs` の命名規約 (`<dir>` = claude /
21
+ * `<dir>__codex` = codex) からのみ導く。tmux / ブリッジの状態は参照しない。
22
+ */
23
+
24
+ import { cliKindForSessionName } from "./session-names.mjs"
25
+
26
+ /**
27
+ * claude / codex ブリッジの live セッションを union で返す (宛先解決の候補集合)。
28
+ *
29
+ * `cwd` を渡すとその cwd のみ、`cliKind` に 'claude' / 'codex' を渡すとその CLI のみへ
30
+ * 絞り込む。どちらも省略すると全 cwd・両 CLI (op:"list-all" 用)。`cliKind` が既知値以外
31
+ * なら絞り込まない (両方返す) — 呼び出し側で既に検証済みの前提。
32
+ *
33
+ * @param {{claudeBridge?: object, codexBridge?: object}} bridges
34
+ * @param {{cwd?: string, cliKind?: 'claude'|'codex'}} [filter]
35
+ * @returns {Array<object>} describeLiveSessions の記述子配列 (claude → codex の順)
36
+ */
37
+ export function describeLiveSessionsUnion(bridges, { cwd, cliKind } = {}) {
38
+ const { claudeBridge, codexBridge } = bridges || {}
39
+ const out = []
40
+ if (cliKind !== "codex") out.push(...(claudeBridge?.describeLiveSessions?.(cwd) ?? []))
41
+ if (cliKind !== "claude") out.push(...(codexBridge?.describeLiveSessions?.(cwd) ?? []))
42
+ return out
43
+ }
44
+
45
+ /**
46
+ * tmux セッション行が「依頼を受け取れる (warm)」かを判定する。
47
+ *
48
+ * 判定は上記 union に一本化する。`cwd` が取れていない行 (tmux の cwd 解決失敗など) は
49
+ * 宛先解決のスコープを作れないため false (fail-closed。送れないものを送れると見せない)。
50
+ *
51
+ * @param {{claudeBridge?: object, codexBridge?: object}} bridges
52
+ * @param {{name?: string, cwd?: string|null}} row tmux.list_sessions の session 行
53
+ * @returns {boolean}
54
+ */
55
+ export function isSessionWarm(bridges, { name, cwd } = {}) {
56
+ if (!cwd) return false
57
+ const cliKind = cliKindForSessionName(name)
58
+ return describeLiveSessionsUnion(bridges, { cwd, cliKind }).length > 0
59
+ }
60
+
61
+ export default { describeLiveSessionsUnion, isSessionWarm }
package/src/main.mjs CHANGED
@@ -86,6 +86,8 @@ import {
86
86
  // 名前衝突を避けて namespace import する。
87
87
  import * as codexProfiles from "./codex-profiles.mjs"
88
88
  import {
89
+ clearCredentialCheckCache,
90
+ hasFileCredentialsCached,
89
91
  isKeychainPlatform,
90
92
  profileHasKeychainCredentials,
91
93
  } from "./claude-credentials.mjs"
@@ -108,6 +110,7 @@ import {
108
110
  stopTerminalMirror,
109
111
  } from "./tmux.mjs"
110
112
  import { SiblingInjectBridge } from "./sibling-inject.mjs"
113
+ import { isSessionWarm } from "./live-sessions.mjs"
111
114
  import {
112
115
  boundSessionId,
113
116
  codexContextPercentFromTokenUsage,
@@ -197,14 +200,17 @@ function codexDefaultsFromAgentConfig(config) {
197
200
  /**
198
201
  * プロファイル切替用: 全 tmux セッションを kill → 同名・同 cwd で再作成する。
199
202
  *
200
- * 再作成された pane の claude は createSession 内の injectConfigDirEnv 経由で
201
- * 新しい CLAUDE_CONFIG_DIR を拾うため、TUI セッションもアカウント切替に追従する。
203
+ * 再作成された pane は **既定では素のシェル** (createSession が CLI を起動しない)。
204
+ * アカウント切替は SDK チャット側の process.env と、tmux サーバーのグローバル環境
205
+ * (syncTmuxProfileEnv の `set-environment -g`) で追従する。
206
+ * `HUB_AGENT_TMUX_LAUNCH_CLI=1` で TUI を起動する運用の場合のみ、createSession 内の
207
+ * injectConfigDirEnv が新しい CLAUDE_CONFIG_DIR をコマンドへ焼き込む。
202
208
  * initial prompt (leader の /orchestrate 等) までは復元しない — アカウントが
203
209
  * 変わる以上、前アカウントの文脈でスキルを自動発火させない方が安全。
204
210
  * cwd が取れないセッションは kill のみ行い、再作成はスキップして failed に積む。
205
211
  * tmux サーバー未起動なら何もしない。
206
212
  */
207
- async function restartAllTmuxSessionsForProfileSwitch(ctx) {
213
+ export async function restartAllTmuxSessionsForProfileSwitch(ctx) {
208
214
  const result = { killed: [], recreated: [], failed: [] }
209
215
  let sessions = []
210
216
  try {
@@ -408,9 +414,11 @@ export function applyActiveProfileEnv(profile, log) {
408
414
 
409
415
  /**
410
416
  * active プロファイルの CLAUDE_CONFIG_DIR を tmux サーバーのグローバル環境にも同期する。
411
- * tmux pane のシェルは hub-agent の process.env を見ないため、手動 respawn や
412
- * ユーザーが pane 内で直接 `claude` を打つケースの保険として set-environment -g を併用する
413
- * (新規セッションの主経路は createSession 内の injectConfigDirEnv コマンド焼き込み)。
417
+ * tmux pane のシェルは hub-agent の process.env を見ないため、set-environment -g で流し込む。
418
+ * pane は既定で素のシェル (CLI を自動起動しない) なので、ユーザーが pane 内で直接
419
+ * `claude` を打つケースにはこれが主経路になる
420
+ * (`HUB_AGENT_TMUX_LAUNCH_CLI=1` で CLI を pane 起動するときだけ、createSession 内の
421
+ * injectConfigDirEnv によるコマンド焼き込みが併用される)。
414
422
  * tmux サーバー未起動でも失敗しない。
415
423
  */
416
424
  export async function syncTmuxProfileEnv(profile, log) {
@@ -2011,17 +2019,27 @@ const STALL_WARN_MS = Number(process.env.HUB_AGENT_STALL_WARN_MS ?? 90000)
2011
2019
  * 純関数として export することでテスト容易性を上げる(getSessionStateSnapshot を mock で
2012
2020
  * 注入し、snapshot 有/無/null/欠落フィールドの各ケースで挙動を検証可能)。
2013
2021
  *
2022
+ * `warm` (2026-08-01): 「このセッションに依頼 (cockpit_orch.py send) を送れるか」。frontend は
2023
+ * 従来「tmux のセッション行が存在するか」だけで起動中を判定していたため、**依頼を送れない
2024
+ * セッションが起動中と表示される**食い違いがあった。判定は live-sessions.mjs の
2025
+ * `isSessionWarm` (sibling-inject の宛先候補列挙と同一関数) に一本化する。accessor 未指定
2026
+ * (=旧呼び出し) では `warm` を付与しない(推測で false を配らない)。
2027
+ *
2014
2028
  * @param {Array} sessions tmux.list_sessions が返した素の session 行
2015
2029
  * @param {Map<string, object>} lastEventByName session_name → 最新 hook event
2016
2030
  * @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
2017
2031
  * accessor。undefined のときは 4 信号を一切付与しない(snapshot 経路 OFF と等価)
2032
+ * @param {((row: {name: string, cwd?: string|null}) => boolean) | undefined} isWarm
2033
+ * warm 判定 accessor (既定は live-sessions.mjs の isSessionWarm を bind したもの)。
2034
+ * undefined のときは `warm` を付与しない
2018
2035
  */
2019
- export function enrichTmuxSessionsForListResponse(sessions, lastEventByName, getSnap) {
2036
+ export function enrichTmuxSessionsForListResponse(sessions, lastEventByName, getSnap, isWarm) {
2020
2037
  return sessions.map((s) => {
2021
2038
  const base = {
2022
2039
  ...s,
2023
2040
  last_event: lastEventByName.get(s.name) || null,
2024
2041
  }
2042
+ if (isWarm) base.warm = !!isWarm(s)
2025
2043
  if (!getSnap) return base
2026
2044
  const snap = getSnap(s.name)
2027
2045
  if (!snap) return base
@@ -3309,6 +3327,388 @@ export async function handleKillSession(msg, ctx) {
3309
3327
  }
3310
3328
  }
3311
3329
 
3330
+ /** usage.response の中身を共有する TTL (ms)。frontend の poll は既定 60 秒周期。 */
3331
+ export const USAGE_RESPONSE_TTL_MS = 4_000
3332
+
3333
+ /**
3334
+ * usage.response の **中身だけ** を共有する短期キャッシュ。
3335
+ *
3336
+ * usage.request が返すのはアカウント全体の 5h/7d 利用量で、どのウィンドウから聞いても
3337
+ * 同じ答えになる。にもかかわらず N 個のウィンドウが別々に取りに行き、1 回ごとに
3338
+ * `security` spawn ・SDK control RPC ・`codex app-server` の起動/停止を伴っていたため、
3339
+ * control レーンの実測占有 (2026-08-01: 92 秒中 73.3 秒) の主因になっていた。
3340
+ *
3341
+ * ⚠️ **request_id は絶対にここへ入れない**。キャッシュした payload に前の要求の
3342
+ * request_id が混ざると、別ウィンドウの要求に他人の応答が返る。送信時に各要求の
3343
+ * request_id を後から差し込む (handleUsageRequest 参照)。
3344
+ */
3345
+ export const usageResponseCache = new AsyncTtlCache({ ttlMs: USAGE_RESPONSE_TTL_MS })
3346
+
3347
+ /** 前回 build 以降に「キャッシュで返した」要求数 (= 畳めた重複の実測値)。 */
3348
+ let usageServedFromCacheSinceLastBuild = 0
3349
+
3350
+ /**
3351
+ * usage.response の中身 (usage / sessions / codexUsage / codexSessions) を組み立てる。
3352
+ * キャッシュ miss のときだけ実行される。
3353
+ *
3354
+ * 戻り値は `{ payload, timings }`。**payload はそのまま応答に展開する中身**、timings は
3355
+ * 内訳の所要時間で、ログにだけ使う (応答には出さない)。ログの発火は呼び出し側
3356
+ * (handleUsageRequest) が「実際に build したときだけ」行う。
3357
+ *
3358
+ * @returns {Promise<{payload: object, timings: object}>}
3359
+ */
3360
+ export async function buildUsagePayload(ctx) {
3361
+ const startedAt = Date.now()
3362
+ let credentialsMs = 0
3363
+ let accountUsageMs = 0
3364
+ let localUsageMs = 0
3365
+ let codexRateLimitsMs = 0
3366
+
3367
+ const credentialsStartedAt = Date.now()
3368
+ const activeClaudeProfile = await getActiveProfile()
3369
+ const claudeHasCredentials = isKeychainPlatform()
3370
+ ? await profileHasKeychainCredentials(activeClaudeProfile.configDir)
3371
+ : await hasFileCredentialsCached(activeClaudeProfile.configDir)
3372
+ credentialsMs = Date.now() - credentialsStartedAt
3373
+
3374
+ // 5h/7d の本命ソース: SDK control request (アカウント全体、session 非依存)。
3375
+ // statusLine が rate_limits を出さなくなった環境 (2026-07 時点の Claude CLI) でも
3376
+ // ここでライブ更新できる。frontend の usage.request ポーリング (既定 60s) 頻度に
3377
+ // 相乗りするだけで十分な鮮度が保てるため、専用タイマーは設けない。EXPERIMENTAL
3378
+ // API の失敗は getAccountUsage 内で握りつぶし null を返す (recordChatUsageSnapshot
3379
+ // は no-op) ので、既存の statusLine/transcript フォールバックは無傷で残る。
3380
+ if (ctx.claudeBridge) {
3381
+ const accountUsageStartedAt = Date.now()
3382
+ try {
3383
+ // ⚠️ getAccountUsage は SDK のサブプロセスを spawn する。**時間制限が無いと
3384
+ // 返ってこないときに control レーン全体が止まる** (usage.request は 60 秒周期で
3385
+ // control レーンに乗り、そこにセッション停止も並ぶ)。同種の head-of-line
3386
+ // blocking で cold p95 40〜54 秒を計測した記録が isReadOnlyRpc の注記にある。
3387
+ // 時間切れは握りつぶさず warn で残す (これ自体が「詰まりの実測値」になる)。
3388
+ const acct = await withDeadline(
3389
+ ctx.claudeBridge.getAccountUsage(),
3390
+ ACCOUNT_USAGE_TIMEOUT_MS,
3391
+ () =>
3392
+ ctx.logger?.warn?.(
3393
+ { timeout_ms: ACCOUNT_USAGE_TIMEOUT_MS },
3394
+ "account usage timed out (dispatch lane released)",
3395
+ ),
3396
+ )
3397
+ if (acct) recordChatUsageSnapshot(acct, Date.now(), activeClaudeProfile.id)
3398
+ } catch (err) {
3399
+ ctx.logger?.debug?.({ err: err?.message }, "account usage snapshot skipped")
3400
+ }
3401
+ accountUsageMs = Date.now() - accountUsageStartedAt
3402
+ }
3403
+
3404
+ const localUsageStartedAt = Date.now()
3405
+ let [usage, sessions] = await Promise.all([
3406
+ getUsage({ profileId: activeClaudeProfile.id }),
3407
+ getSessionUsages(),
3408
+ ])
3409
+ localUsageMs = Date.now() - localUsageStartedAt
3410
+ // 未ログインで残ったローカル transcript/cache をアカウント利用量として見せない。
3411
+ if (!claudeHasCredentials) {
3412
+ usage = null
3413
+ sessions = []
3414
+ }
3415
+
3416
+ // Codex 版 (Stream B3, 2026-07-03): rollout jsonl の token_count イベントから
3417
+ // 5h/7d + context% を取る。⚠️ Codex の 5h/7d は ChatGPT/OpenAI サブスクリプション
3418
+ // の残量であり、上の `usage` (Claude/Anthropic サブスクリプション) とは別契約の
3419
+ // 別数値のため、`usage` にマージせず独立フィールド `codexUsage`/`codexSessions`
3420
+ // として返す (frontend 側でどちらのアカウントの残量か混同しないため)。
3421
+ let codexUsage = null
3422
+ let codexSessions = []
3423
+ const activeCodexProfile = await codexProfiles.getActiveProfile()
3424
+ const codexHasCredentials = await codexProfiles.hasCodexCredentials(activeCodexProfile.configDir)
3425
+ if (ctx.codexBridge && codexHasCredentials) {
3426
+ try {
3427
+ // T06407 増分2 (axis2): per-cwd の context% は **cwd ごとに権威 producer 1 件**へ
3428
+ // 集約する。旧実装は全 live session を codexSessions へ流していたため、同一 cwd に
3429
+ // 複数 thread があると frontend の usageByCwd (updatedAtMs 同値で first-wins) が
3430
+ // 任意のスレッドを拾い、state loop (権威 producer) と別 thread を選んでドーナツが
3431
+ // 前後した。liveSessionForCwd の同一規則で cwd → 権威 producer(session) を一意化し、
3432
+ // producerSeq を context% の live source として通して両経路を収束させる。
3433
+ const cwds = new Set()
3434
+ for (const s of ctx.codexBridge.sessions.values()) {
3435
+ if (s.threadId && s.cwd) cwds.add(s.cwd)
3436
+ }
3437
+ const liveCodex = []
3438
+ for (const cwd of cwds) {
3439
+ const authoritative = ctx.codexBridge.liveSessionForCwd?.(cwd)
3440
+ if (authoritative?.threadId) {
3441
+ liveCodex.push({
3442
+ sessionId: authoritative.threadId,
3443
+ cwd: authoritative.cwd,
3444
+ threadId: authoritative.threadId,
3445
+ producerSeq: authoritative.seq,
3446
+ })
3447
+ }
3448
+ }
3449
+ if (liveCodex.length > 0) {
3450
+ // 5h/7d はアカウント全体の値 (どの live session から読んでも同じ契約の残量)
3451
+ // なので、代表として最初の 1 件から取る (context% はその producer の live を使う)。
3452
+ codexUsage = await codexUsageForThread(liveCodex[0].threadId, {
3453
+ producerSeq: liveCodex[0].producerSeq,
3454
+ })
3455
+ codexSessions = await codexSessionUsages(liveCodex)
3456
+ // ⚠️ codex-review round2 major#2: codexSessionUsages の rollout I/O 中に cwd の
3457
+ // 権威 producer が別 session へ移りうる。await 後に各 cwd の現権威 seq を再検証し、
3458
+ // 集計に使った producer が現権威でない entry は破棄する (古い producer の context を
3459
+ // codexCtxByCwd へ出さない)。破棄した cwd は frontend が state loop の
3460
+ // live.context_pct (毎 tick 権威を同期解決=TOCTOU 無し) にフォールバックする。
3461
+ const seqForCwd = (cwd) => ctx.codexBridge.liveSessionForCwd?.(cwd)?.seq
3462
+ const producerByCwd = new Map(liveCodex.map((e) => [e.cwd, e.producerSeq]))
3463
+ codexSessions = filterCodexSessionsByAuthority(
3464
+ codexSessions,
3465
+ producerByCwd,
3466
+ seqForCwd,
3467
+ )
3468
+ // codex-review round3 minor#1: 集約 codexUsage.context は代表 producer
3469
+ // (liveCodex[0]) の値。await 中に代表が権威を失っていたら旧 context を出さない
3470
+ // (context だけ null 化。5h/7d は account-wide なので保持する)。
3471
+ if (codexUsage && seqForCwd(liveCodex[0].cwd) !== liveCodex[0].producerSeq) {
3472
+ codexUsage = { ...codexUsage, context: null }
3473
+ }
3474
+ }
3475
+ // ライブ session の有無にかかわらず、起動直後の account RPC を優先する。
3476
+ // rollout は context の補助だけにし、5h/7d はプロファイル固有の最新実値にする。
3477
+ const codexRateLimitsStartedAt = Date.now()
3478
+ const rateLimits = await ctx.codexBridge.readAccountRateLimits({
3479
+ profileId: activeCodexProfile.id,
3480
+ })
3481
+ codexRateLimitsMs = Date.now() - codexRateLimitsStartedAt
3482
+ const accountUsage = codexUsageFromAccountRateLimits(rateLimits)
3483
+ if (accountUsage) {
3484
+ codexUsage = { ...accountUsage, context: codexUsage?.context ?? null }
3485
+ recordAccountUsageSnapshot("codex", activeCodexProfile.id, codexUsage)
3486
+ }
3487
+ } catch (err) {
3488
+ ctx.logger?.debug?.({ err: err?.message }, "codex usage snapshot skipped")
3489
+ }
3490
+ }
3491
+ // Agent 再起動後や一時的な account RPC 失敗時も、同一 profile の最終取得値だけを
3492
+ // 表示する。未ログインならここへ到達せず、表示も完全に隠す。
3493
+ if (codexHasCredentials && !codexUsage) {
3494
+ codexUsage = await readAccountUsageSnapshot("codex", activeCodexProfile.id)
3495
+ }
3496
+
3497
+ return {
3498
+ payload: { usage, sessions, codexUsage, codexSessions },
3499
+ timings: {
3500
+ credentials_ms: credentialsMs,
3501
+ account_usage_ms: accountUsageMs,
3502
+ local_usage_ms: localUsageMs,
3503
+ codex_ratelimits_ms: codexRateLimitsMs,
3504
+ total_ms: Date.now() - startedAt,
3505
+ },
3506
+ }
3507
+ }
3508
+
3509
+ /**
3510
+ * usage.request の処理。中身は TTL キャッシュ + single-flight で共有し、request_id
3511
+ * だけを要求ごとに差し込む。
3512
+ *
3513
+ * ⚠️ 送信オブジェクトは `...payload` を先に展開し **request_id を後から上書き** する。
3514
+ * 万一 payload 側に request_id が混入しても、他人の応答が返る事故にならないため。
3515
+ *
3516
+ * テストから直接呼べるよう export。中身の組み立ては deps.build で差し替え可能。
3517
+ */
3518
+ export async function handleUsageRequest(msg, ctx, deps = {}) {
3519
+ const build = deps.build || (() => buildUsagePayload(ctx))
3520
+ try {
3521
+ // build を実行したのが自分かどうか。single-flight で畳まれた caller の closure は
3522
+ // 呼ばれないため、そのまま「キャッシュで返した件数」の計測になる。
3523
+ let built = false
3524
+ const result = await usageResponseCache.get(() => {
3525
+ built = true
3526
+ return build()
3527
+ })
3528
+ if (built) {
3529
+ // 効果を数字で確認するための内訳。**build したときだけ** 出す (ヒット時は出さない。
3530
+ // ウィンドウ数 × poll で件数が多く、ログが埋まるため)。
3531
+ // served_from_cache_since_last_build が「畳めた重複」の実測値になる。
3532
+ ctx.logger?.info?.(
3533
+ {
3534
+ ...result.timings,
3535
+ served_from_cache_since_last_build: usageServedFromCacheSinceLastBuild,
3536
+ },
3537
+ "usage request timing",
3538
+ )
3539
+ usageServedFromCacheSinceLastBuild = 0
3540
+ } else {
3541
+ usageServedFromCacheSinceLastBuild += 1
3542
+ }
3543
+ ctx.client.send({
3544
+ type: "usage.response",
3545
+ ...result.payload,
3546
+ request_id: msg.request_id,
3547
+ })
3548
+ } catch (err) {
3549
+ ctx.client.send({
3550
+ type: "usage.response",
3551
+ usage: null,
3552
+ sessions: [],
3553
+ codexUsage: null,
3554
+ codexSessions: [],
3555
+ error: err.message,
3556
+ request_id: msg.request_id,
3557
+ })
3558
+ }
3559
+ }
3560
+
3561
+ /**
3562
+ * `tmux.create_session` ハンドラ。指定名・cwd で detached の tmux セッションを作る。
3563
+ *
3564
+ * ⚠️ pane に CLI (claude / codex) を起動するかは **createSession 内の
3565
+ * shouldLaunchPaneCli() が一元判定**する (既定は起動しない)。ここで渡す cliKind /
3566
+ * claudeCmd / codexCmd / initialPrompt 等は、`HUB_AGENT_TMUX_LAUNCH_CLI=1` で
3567
+ * CLI を起動するときにだけ使われる。呼び出し元ごとに既定を変えないため、
3568
+ * このハンドラ側で起動可否を判断しないこと。
3569
+ *
3570
+ * dispatch から切り出して export しているのは、実際に tmux へ渡る引数を
3571
+ * テストから検査できるようにするため (test/tmux-pane-cli-launch.test.mjs)。
3572
+ */
3573
+ export async function handleTmuxCreateSession(msg, ctx) {
3574
+ const name = (msg.session_name || "").trim()
3575
+ const cwd = (msg.cwd || process.env.HOME || "/").trim()
3576
+ if (!name) {
3577
+ ctx.client.send({
3578
+ type: "tmux.create_session.result",
3579
+ request_id: msg.request_id,
3580
+ ok: false,
3581
+ error: "session_name is required",
3582
+ })
3583
+ return
3584
+ }
3585
+ try {
3586
+ await createTmuxSession(name, cwd, {
3587
+ // cli_kind: 'codex' なら codex を、それ以外は claude を pane 起動する (既定 claude)。
3588
+ cliKind: msg.cli_kind === "codex" ? "codex" : "claude",
3589
+ claudeCmd:
3590
+ typeof msg.claude_cmd === "string"
3591
+ ? msg.claude_cmd
3592
+ : claudeCmdFromAgentConfig(ctx.config),
3593
+ codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
3594
+ // codex 用設定 (claude 経路では claudeCmd を直接使うため無視される)。
3595
+ model:
3596
+ typeof msg.model === "string"
3597
+ ? msg.model
3598
+ : codexDefaultsFromAgentConfig(ctx.config).model,
3599
+ sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
3600
+ approvalPolicy:
3601
+ typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
3602
+ effort:
3603
+ typeof msg.effort === "string"
3604
+ ? msg.effort
3605
+ : codexDefaultsFromAgentConfig(ctx.config).effort,
3606
+ initialPrompt:
3607
+ typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
3608
+ logger: ctx.logger,
3609
+ })
3610
+ tmuxSessionsSnapshotCache.clear()
3611
+ ctx.client.send({
3612
+ type: "tmux.create_session.result",
3613
+ request_id: msg.request_id,
3614
+ ok: true,
3615
+ session_name: name,
3616
+ })
3617
+ } catch (err) {
3618
+ ctx.client.send({
3619
+ type: "tmux.create_session.result",
3620
+ request_id: msg.request_id,
3621
+ ok: false,
3622
+ error: err.message,
3623
+ })
3624
+ }
3625
+ }
3626
+
3627
+ /**
3628
+ * `worktree.create` ハンドラ。git worktree を作ってから、その dir で tmux セッションを作る。
3629
+ * body: { request_id, dir, branch, cli_kind?, claude_cmd?, codex_cmd?, model?, sandbox?,
3630
+ * approval_policy?, effort?, initial_prompt? }
3631
+ *
3632
+ * ⚠️ pane の CLI 起動可否は handleTmuxCreateSession と同じく createSession 側の
3633
+ * 一元判定に従う (既定は起動しない)。
3634
+ */
3635
+ export async function handleWorktreeCreate(msg, ctx) {
3636
+ const dir = (msg.dir || "").trim()
3637
+ const branch = (msg.branch || "").trim()
3638
+ if (!dir || !branch) {
3639
+ ctx.client.send({
3640
+ type: "worktree.create.result",
3641
+ request_id: msg.request_id,
3642
+ ok: false,
3643
+ error: "dir and branch required",
3644
+ })
3645
+ return
3646
+ }
3647
+ if (!/^[A-Za-z0-9._\-/]+$/.test(branch) || branch.startsWith("-")) {
3648
+ ctx.client.send({
3649
+ type: "worktree.create.result",
3650
+ request_id: msg.request_id,
3651
+ ok: false,
3652
+ error: "invalid branch name",
3653
+ })
3654
+ return
3655
+ }
3656
+ try {
3657
+ const { wtName, wtPath, createdBranch } = await createWorktreeDir(dir, branch)
3658
+ // worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
3659
+ // 解釈し、codex なら codex を pane 起動する(既定 claude)。
3660
+ // T05945: セッション名は cli_kind に応じて `<wt>` / `<wt>__codex` へ分ける。これで
3661
+ // 同一 worktree でも claude と codex を独立に起動/終了できる (旧実装は cli_kind に
3662
+ // 関わらず `<wt>` 固定だったため、片方しか立てられなかった)。
3663
+ const wtCliKind = msg.cli_kind === "codex" ? "codex" : "claude"
3664
+ const wtSessionName = sessionNameFor(wtName, wtCliKind)
3665
+ await createTmuxSession(wtSessionName, wtPath, {
3666
+ cliKind: wtCliKind,
3667
+ claudeCmd:
3668
+ typeof msg.claude_cmd === "string"
3669
+ ? msg.claude_cmd
3670
+ : claudeCmdFromAgentConfig(ctx.config),
3671
+ codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
3672
+ // codex 用設定 (claude 経路では無視される)。
3673
+ model:
3674
+ typeof msg.model === "string"
3675
+ ? msg.model
3676
+ : codexDefaultsFromAgentConfig(ctx.config).model,
3677
+ sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
3678
+ approvalPolicy:
3679
+ typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
3680
+ effort:
3681
+ typeof msg.effort === "string"
3682
+ ? msg.effort
3683
+ : codexDefaultsFromAgentConfig(ctx.config).effort,
3684
+ initialPrompt:
3685
+ typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
3686
+ logger: ctx.logger,
3687
+ })
3688
+ tmuxSessionsSnapshotCache.clear()
3689
+ ctx.client.send({
3690
+ type: "worktree.create.result",
3691
+ request_id: msg.request_id,
3692
+ ok: true,
3693
+ // wt_name は従来どおり **ベース名** (dir 名)。frontend のツリー構築キーに使う。
3694
+ wt_name: wtName,
3695
+ // session_name は実際に作った tmux セッション名 (codex なら __codex 付き)。
3696
+ // 旧 frontend はこのフィールドを見ないため後方互換 (wt_name で選択する)。
3697
+ session_name: wtSessionName,
3698
+ wt_path: wtPath,
3699
+ branch,
3700
+ created_branch: createdBranch,
3701
+ })
3702
+ } catch (err) {
3703
+ ctx.client.send({
3704
+ type: "worktree.create.result",
3705
+ request_id: msg.request_id,
3706
+ ok: false,
3707
+ error: err.message,
3708
+ })
3709
+ }
3710
+ }
3711
+
3312
3712
  async function dispatch(msg, ctx) {
3313
3713
  const t = msg?.type || ""
3314
3714
  try {
@@ -4357,6 +4757,10 @@ async function dispatch(msg, ctx) {
4357
4757
  // tmux TUI セッションも旧アカウントのまま残さない: kill → 同名・同 cwd で再作成。
4358
4758
  const tmuxResult = await restartAllTmuxSessionsForProfileSwitch(ctx)
4359
4759
  tmuxSessionsSnapshotCache.clear()
4760
+ // アカウントが変わったので、旧アカウントの利用量・認証在否を TTL の残り時間だけ
4761
+ // 見せ続けないよう明示的に捨てる (直後の needs_login 判定も実測値になる)。
4762
+ usageResponseCache.clear()
4763
+ clearCredentialCheckCache()
4360
4764
  // 切替先の config ディレクトリに認証情報が無ければ要ログイン (バッジ/案内用)。
4361
4765
  const hasCreds = isKeychainPlatform()
4362
4766
  ? await profileHasKeychainCredentials(target.configDir)
@@ -4497,6 +4901,10 @@ async function dispatch(msg, ctx) {
4497
4901
  const before = ctx.codexBridge?.sessions?.size ?? 0
4498
4902
  ctx.codexBridge?.shutdown()
4499
4903
  tmuxSessionsSnapshotCache.clear()
4904
+ // Codex アカウントが変わったので、旧アカウントの 5h/7d を TTL の残り時間だけ
4905
+ // 見せ続けない (rate limits 自体は profileId 別キャッシュなので混ざらないが、
4906
+ // usage.response の中身は両アカウントを束ねているため明示的に捨てる)。
4907
+ usageResponseCache.clear()
4500
4908
  const hasCreds = await codexProfiles.hasCodexCredentials(target.configDir)
4501
4909
  const needsLogin = !hasCreds
4502
4910
  const isDefaultTarget = target.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID
@@ -4648,6 +5056,13 @@ async function dispatch(msg, ctx) {
4648
5056
  sessions,
4649
5057
  lastEventByName,
4650
5058
  ctx.getSessionStateSnapshot,
5059
+ // warm = このセッションへ依頼 (cockpit_orch.py send) を送れるか。sibling-inject の
5060
+ // 宛先候補列挙と同一関数 (live-sessions.mjs) を通す。
5061
+ (row) =>
5062
+ isSessionWarm(
5063
+ { claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge },
5064
+ row,
5065
+ ),
4651
5066
  )
4652
5067
  const liveNames = new Set(enriched.map((s) => s.name))
4653
5068
  let worktreeStubs = []
@@ -4698,135 +5113,11 @@ async function dispatch(msg, ctx) {
4698
5113
  return
4699
5114
  }
4700
5115
  case "tmux.create_session": {
4701
- const name = (msg.session_name || "").trim()
4702
- const cwd = (msg.cwd || process.env.HOME || "/").trim()
4703
- if (!name) {
4704
- ctx.client.send({
4705
- type: "tmux.create_session.result",
4706
- request_id: msg.request_id,
4707
- ok: false,
4708
- error: "session_name is required",
4709
- })
4710
- return
4711
- }
4712
- try {
4713
- await createTmuxSession(name, cwd, {
4714
- // cli_kind: 'codex' なら codex を、それ以外は claude を pane 起動する (既定 claude)。
4715
- cliKind: msg.cli_kind === "codex" ? "codex" : "claude",
4716
- claudeCmd:
4717
- typeof msg.claude_cmd === "string"
4718
- ? msg.claude_cmd
4719
- : claudeCmdFromAgentConfig(ctx.config),
4720
- codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
4721
- // codex 用設定 (claude 経路では claudeCmd を直接使うため無視される)。
4722
- model:
4723
- typeof msg.model === "string"
4724
- ? msg.model
4725
- : codexDefaultsFromAgentConfig(ctx.config).model,
4726
- sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
4727
- approvalPolicy:
4728
- typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
4729
- effort:
4730
- typeof msg.effort === "string"
4731
- ? msg.effort
4732
- : codexDefaultsFromAgentConfig(ctx.config).effort,
4733
- initialPrompt:
4734
- typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
4735
- logger: ctx.logger,
4736
- })
4737
- tmuxSessionsSnapshotCache.clear()
4738
- ctx.client.send({
4739
- type: "tmux.create_session.result",
4740
- request_id: msg.request_id,
4741
- ok: true,
4742
- session_name: name,
4743
- })
4744
- } catch (err) {
4745
- ctx.client.send({
4746
- type: "tmux.create_session.result",
4747
- request_id: msg.request_id,
4748
- ok: false,
4749
- error: err.message,
4750
- })
4751
- }
5116
+ await handleTmuxCreateSession(msg, ctx)
4752
5117
  return
4753
5118
  }
4754
5119
  case "worktree.create": {
4755
- // body: { request_id, dir, branch, cli_kind?, claude_cmd?, codex_cmd?, model?, sandbox?, approval_policy?, effort?, initial_prompt? }
4756
- const dir = (msg.dir || "").trim()
4757
- const branch = (msg.branch || "").trim()
4758
- if (!dir || !branch) {
4759
- ctx.client.send({
4760
- type: "worktree.create.result",
4761
- request_id: msg.request_id,
4762
- ok: false,
4763
- error: "dir and branch required",
4764
- })
4765
- return
4766
- }
4767
- if (!/^[A-Za-z0-9._\-/]+$/.test(branch) || branch.startsWith("-")) {
4768
- ctx.client.send({
4769
- type: "worktree.create.result",
4770
- request_id: msg.request_id,
4771
- ok: false,
4772
- error: "invalid branch name",
4773
- })
4774
- return
4775
- }
4776
- try {
4777
- const { wtName, wtPath, createdBranch } = await createWorktreeDir(dir, branch)
4778
- // worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
4779
- // 解釈し、codex なら codex を pane 起動する(既定 claude)。
4780
- // T05945: セッション名は cli_kind に応じて `<wt>` / `<wt>__codex` へ分ける。これで
4781
- // 同一 worktree でも claude と codex を独立に起動/終了できる (旧実装は cli_kind に
4782
- // 関わらず `<wt>` 固定だったため、片方しか立てられなかった)。
4783
- const wtCliKind = msg.cli_kind === "codex" ? "codex" : "claude"
4784
- const wtSessionName = sessionNameFor(wtName, wtCliKind)
4785
- await createTmuxSession(wtSessionName, wtPath, {
4786
- cliKind: wtCliKind,
4787
- claudeCmd:
4788
- typeof msg.claude_cmd === "string"
4789
- ? msg.claude_cmd
4790
- : claudeCmdFromAgentConfig(ctx.config),
4791
- codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
4792
- // codex 用設定 (claude 経路では無視される)。
4793
- model:
4794
- typeof msg.model === "string"
4795
- ? msg.model
4796
- : codexDefaultsFromAgentConfig(ctx.config).model,
4797
- sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
4798
- approvalPolicy:
4799
- typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
4800
- effort:
4801
- typeof msg.effort === "string"
4802
- ? msg.effort
4803
- : codexDefaultsFromAgentConfig(ctx.config).effort,
4804
- initialPrompt:
4805
- typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
4806
- logger: ctx.logger,
4807
- })
4808
- tmuxSessionsSnapshotCache.clear()
4809
- ctx.client.send({
4810
- type: "worktree.create.result",
4811
- request_id: msg.request_id,
4812
- ok: true,
4813
- // wt_name は従来どおり **ベース名** (dir 名)。frontend のツリー構築キーに使う。
4814
- wt_name: wtName,
4815
- // session_name は実際に作った tmux セッション名 (codex なら __codex 付き)。
4816
- // 旧 frontend はこのフィールドを見ないため後方互換 (wt_name で選択する)。
4817
- session_name: wtSessionName,
4818
- wt_path: wtPath,
4819
- branch,
4820
- created_branch: createdBranch,
4821
- })
4822
- } catch (err) {
4823
- ctx.client.send({
4824
- type: "worktree.create.result",
4825
- request_id: msg.request_id,
4826
- ok: false,
4827
- error: err.message,
4828
- })
4829
- }
5120
+ await handleWorktreeCreate(msg, ctx)
4830
5121
  return
4831
5122
  }
4832
5123
  case "worktree.list_history": {
@@ -4963,145 +5254,7 @@ async function dispatch(msg, ctx) {
4963
5254
  return
4964
5255
  }
4965
5256
  case "usage.request": {
4966
- try {
4967
- const activeClaudeProfile = await getActiveProfile()
4968
- const claudeHasCredentials = isKeychainPlatform()
4969
- ? await profileHasKeychainCredentials(activeClaudeProfile.configDir)
4970
- : await hasFileCredentials(activeClaudeProfile.configDir)
4971
- // 5h/7d の本命ソース: SDK control request (アカウント全体、session 非依存)。
4972
- // statusLine が rate_limits を出さなくなった環境 (2026-07 時点の Claude CLI) でも
4973
- // ここでライブ更新できる。frontend の usage.request ポーリング (既定 60s) 頻度に
4974
- // 相乗りするだけで十分な鮮度が保てるため、専用タイマーは設けない。EXPERIMENTAL
4975
- // API の失敗は getAccountUsage 内で握りつぶし null を返す (recordChatUsageSnapshot
4976
- // は no-op) ので、既存の statusLine/transcript フォールバックは無傷で残る。
4977
- if (ctx.claudeBridge) {
4978
- try {
4979
- // ⚠️ getAccountUsage は SDK のサブプロセスを spawn する。**時間制限が無いと
4980
- // 返ってこないときに control レーン全体が止まる** (usage.request は 60 秒周期で
4981
- // control レーンに乗り、そこにセッション停止も並ぶ)。同種の head-of-line
4982
- // blocking で cold p95 40〜54 秒を計測した記録が isReadOnlyRpc の注記にある。
4983
- // 時間切れは握りつぶさず warn で残す (これ自体が「詰まりの実測値」になる)。
4984
- const acct = await withDeadline(
4985
- ctx.claudeBridge.getAccountUsage(),
4986
- ACCOUNT_USAGE_TIMEOUT_MS,
4987
- () =>
4988
- ctx.logger?.warn?.(
4989
- { timeout_ms: ACCOUNT_USAGE_TIMEOUT_MS },
4990
- "account usage timed out (dispatch lane released)",
4991
- ),
4992
- )
4993
- if (acct) recordChatUsageSnapshot(acct, Date.now(), activeClaudeProfile.id)
4994
- } catch (err) {
4995
- ctx.logger?.debug?.({ err: err?.message }, "account usage snapshot skipped")
4996
- }
4997
- }
4998
- let [usage, sessions] = await Promise.all([
4999
- getUsage({ profileId: activeClaudeProfile.id }),
5000
- getSessionUsages(),
5001
- ])
5002
- // 未ログインで残ったローカル transcript/cache をアカウント利用量として見せない。
5003
- if (!claudeHasCredentials) {
5004
- usage = null
5005
- sessions = []
5006
- }
5007
-
5008
- // Codex 版 (Stream B3, 2026-07-03): rollout jsonl の token_count イベントから
5009
- // 5h/7d + context% を取る。⚠️ Codex の 5h/7d は ChatGPT/OpenAI サブスクリプション
5010
- // の残量であり、上の `usage` (Claude/Anthropic サブスクリプション) とは別契約の
5011
- // 別数値のため、`usage` にマージせず独立フィールド `codexUsage`/`codexSessions`
5012
- // として返す (frontend 側でどちらのアカウントの残量か混同しないため)。
5013
- let codexUsage = null
5014
- let codexSessions = []
5015
- const activeCodexProfile = await codexProfiles.getActiveProfile()
5016
- const codexHasCredentials = await codexProfiles.hasCodexCredentials(activeCodexProfile.configDir)
5017
- if (ctx.codexBridge && codexHasCredentials) {
5018
- try {
5019
- // T06407 増分2 (axis2): per-cwd の context% は **cwd ごとに権威 producer 1 件**へ
5020
- // 集約する。旧実装は全 live session を codexSessions へ流していたため、同一 cwd に
5021
- // 複数 thread があると frontend の usageByCwd (updatedAtMs 同値で first-wins) が
5022
- // 任意のスレッドを拾い、state loop (権威 producer) と別 thread を選んでドーナツが
5023
- // 前後した。liveSessionForCwd の同一規則で cwd → 権威 producer(session) を一意化し、
5024
- // producerSeq を context% の live source として通して両経路を収束させる。
5025
- const cwds = new Set()
5026
- for (const s of ctx.codexBridge.sessions.values()) {
5027
- if (s.threadId && s.cwd) cwds.add(s.cwd)
5028
- }
5029
- const liveCodex = []
5030
- for (const cwd of cwds) {
5031
- const authoritative = ctx.codexBridge.liveSessionForCwd?.(cwd)
5032
- if (authoritative?.threadId) {
5033
- liveCodex.push({
5034
- sessionId: authoritative.threadId,
5035
- cwd: authoritative.cwd,
5036
- threadId: authoritative.threadId,
5037
- producerSeq: authoritative.seq,
5038
- })
5039
- }
5040
- }
5041
- if (liveCodex.length > 0) {
5042
- // 5h/7d はアカウント全体の値 (どの live session から読んでも同じ契約の残量)
5043
- // なので、代表として最初の 1 件から取る (context% はその producer の live を使う)。
5044
- codexUsage = await codexUsageForThread(liveCodex[0].threadId, {
5045
- producerSeq: liveCodex[0].producerSeq,
5046
- })
5047
- codexSessions = await codexSessionUsages(liveCodex)
5048
- // ⚠️ codex-review round2 major#2: codexSessionUsages の rollout I/O 中に cwd の
5049
- // 権威 producer が別 session へ移りうる。await 後に各 cwd の現権威 seq を再検証し、
5050
- // 集計に使った producer が現権威でない entry は破棄する (古い producer の context を
5051
- // codexCtxByCwd へ出さない)。破棄した cwd は frontend が state loop の
5052
- // live.context_pct (毎 tick 権威を同期解決=TOCTOU 無し) にフォールバックする。
5053
- const seqForCwd = (cwd) => ctx.codexBridge.liveSessionForCwd?.(cwd)?.seq
5054
- const producerByCwd = new Map(liveCodex.map((e) => [e.cwd, e.producerSeq]))
5055
- codexSessions = filterCodexSessionsByAuthority(
5056
- codexSessions,
5057
- producerByCwd,
5058
- seqForCwd,
5059
- )
5060
- // codex-review round3 minor#1: 集約 codexUsage.context は代表 producer
5061
- // (liveCodex[0]) の値。await 中に代表が権威を失っていたら旧 context を出さない
5062
- // (context だけ null 化。5h/7d は account-wide なので保持する)。
5063
- if (codexUsage && seqForCwd(liveCodex[0].cwd) !== liveCodex[0].producerSeq) {
5064
- codexUsage = { ...codexUsage, context: null }
5065
- }
5066
- }
5067
- // ライブ session の有無にかかわらず、起動直後の account RPC を優先する。
5068
- // rollout は context の補助だけにし、5h/7d はプロファイル固有の最新実値にする。
5069
- const accountUsage = codexUsageFromAccountRateLimits(
5070
- await ctx.codexBridge.readAccountRateLimits({ profileId: activeCodexProfile.id }),
5071
- )
5072
- if (accountUsage) {
5073
- codexUsage = { ...accountUsage, context: codexUsage?.context ?? null }
5074
- recordAccountUsageSnapshot("codex", activeCodexProfile.id, codexUsage)
5075
- }
5076
- } catch (err) {
5077
- ctx.logger?.debug?.({ err: err?.message }, "codex usage snapshot skipped")
5078
- }
5079
- }
5080
- // Agent 再起動後や一時的な account RPC 失敗時も、同一 profile の最終取得値だけを
5081
- // 表示する。未ログインならここへ到達せず、表示も完全に隠す。
5082
- if (codexHasCredentials && !codexUsage) {
5083
- codexUsage = await readAccountUsageSnapshot("codex", activeCodexProfile.id)
5084
- }
5085
-
5086
- ctx.client.send({
5087
- type: "usage.response",
5088
- request_id: msg.request_id,
5089
- usage,
5090
- sessions,
5091
- codexUsage,
5092
- codexSessions,
5093
- })
5094
- } catch (err) {
5095
- ctx.client.send({
5096
- type: "usage.response",
5097
- request_id: msg.request_id,
5098
- usage: null,
5099
- sessions: [],
5100
- codexUsage: null,
5101
- codexSessions: [],
5102
- error: err.message,
5103
- })
5104
- }
5257
+ await handleUsageRequest(msg, ctx)
5105
5258
  return
5106
5259
  }
5107
5260
  default:
@@ -41,6 +41,7 @@ import { mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/pro
41
41
  import path from "node:path"
42
42
 
43
43
  import { verifySiblingTokenAny, siblingTokenCwd } from "./sibling-token.mjs"
44
+ import { describeLiveSessionsUnion } from "./live-sessions.mjs"
44
45
 
45
46
  export const SIBLING_REQUESTS_DIR =
46
47
  process.env.COCKPIT_SIBLING_REQUESTS_DIR || "/tmp/cockpit_sibling_requests"
@@ -69,6 +70,14 @@ export class SiblingInjectBridge {
69
70
  this._watcher = null
70
71
  }
71
72
 
73
+ /**
74
+ * live-sessions.mjs (warm 判定の正本) へ渡すブリッジ束。宛先候補の列挙は必ずここを
75
+ * 経由し、`tmux.list_sessions` の `warm` と同じ判定を共有する。
76
+ */
77
+ _bridges() {
78
+ return { claudeBridge: this.claudeBridge, codexBridge: this.codexBridge }
79
+ }
80
+
72
81
  /** 監視ディレクトリを作成し、既存要求を sweep してから fs.watch を張る。 */
73
82
  async start() {
74
83
  await mkdir(this.dir, { recursive: true })
@@ -159,13 +168,8 @@ export class SiblingInjectBridge {
159
168
  this.logger?.warn({ request_id }, "sibling-inject: invalid sibling token (list-all)")
160
169
  result = { ok: false, error: "invalid_token" }
161
170
  } else {
162
- result = {
163
- ok: true,
164
- live_sessions: [
165
- ...(this.claudeBridge?.describeLiveSessions?.() ?? []),
166
- ...(this.codexBridge?.describeLiveSessions?.() ?? []),
167
- ],
168
- }
171
+ // 判定は live-sessions.mjs に一本化する (tmux.list_sessions の warm と同じ関数)。
172
+ result = { ok: true, live_sessions: describeLiveSessionsUnion(this._bridges()) }
169
173
  }
170
174
  } else if (!cwd || badKind || badBody) {
171
175
  result = { ok: false, error: "invalid_request" }
@@ -192,7 +196,15 @@ export class SiblingInjectBridge {
192
196
  const bridge = target_cli_kind === "codex" ? this.codexBridge : this.claudeBridge
193
197
  if (isList) {
194
198
  // 宛先候補の列挙 (cockpit_orch.py --list 用)。この cwd の同種セッションを返す。
195
- result = { ok: true, live_sessions: bridge?.describeLiveSessions?.(cwd) ?? [] }
199
+ // ⚠️ tmux.list_sessions の `warm` と同じ関数を通す (二重定義にすると「グレー表示
200
+ // なのに宛先一覧に出ない」食い違いが再発する)。
201
+ result = {
202
+ ok: true,
203
+ live_sessions: describeLiveSessionsUnion(this._bridges(), {
204
+ cwd,
205
+ cliKind: target_cli_kind,
206
+ }),
207
+ }
196
208
  } else if (isExplicit) {
197
209
  // 明示宛先: session_id (claude) / stream_id|thread_id (codex) で cwd 一致下に確定解決。
198
210
  // sendToStream は {status:"delivered"|"not_found"|"ambiguous"} を返す。
@@ -361,10 +373,7 @@ export class SiblingInjectBridge {
361
373
  * @param {string} targetKind
362
374
  */
363
375
  _diagnose(cwd, targetKind) {
364
- const sameCwd = [
365
- ...(this.claudeBridge?.describeLiveSessions?.(cwd) ?? []),
366
- ...(this.codexBridge?.describeLiveSessions?.(cwd) ?? []),
367
- ]
376
+ const sameCwd = describeLiveSessionsUnion(this._bridges(), { cwd })
368
377
  const hint = sameCwd.length
369
378
  ? `この cwd には ${sameCwd.map((s) => s.kind).join("/")} のチャットセッションはあるが ${targetKind} は無い。`
370
379
  : `この cwd には hub-agent 管理下のチャットセッションが 1 本も無い。` +
package/src/tmux.mjs CHANGED
@@ -5,8 +5,9 @@
5
5
  * - `listSessions()`: 構造化された TmuxSession[] を返す。各 session に
6
6
  * status (state.mjs の detectSessionState) と cwd (active pane の current_path)
7
7
  * を並列で付与
8
- * - `createSession(name, cwd, opts)`: detached new-session + CLAUDE_CMD を
9
- * send-keys。既存ならエラー throw
8
+ * - `createSession(name, cwd, opts)`: detached new-session。既定では pane に CLI
9
+ * (claude / codex) を起動せず素のシェルのまま置く。`HUB_AGENT_TMUX_LAUNCH_CLI=1`
10
+ * のときだけ従来どおり CLAUDE_CMD / CODEX_CMD を send-keys する。既存ならエラー throw
10
11
  * - `killSession(name)`: 1 session を kill。存在しない場合は 'session not found' throw
11
12
  * - `killManySessions(names)`: 複数 kill、存在しないものは無視
12
13
  *
@@ -509,6 +510,26 @@ export function composeClaudeCmdFromOptions(opts = {}) {
509
510
 
510
511
  const DEFAULT_CLAUDE_CMD = buildClaudeCmd()
511
512
 
513
+ /**
514
+ * tmux pane で CLI (claude / codex) の TUI を起動してよいかを判定する。
515
+ *
516
+ * **既定は false**(pane は素のシェルのまま)。Cockpit のターミナルモードは
517
+ * 2026-07-03 に封印済みで、通常運用は常にチャットモード (Agent SDK / codex
518
+ * app-server) になった。それでも createSession は pane で TUI を起動し続けていたため、
519
+ * **誰も見ていない CLI が常駐**する。実測 (2026-08-01 中村さん端末): tmux セッション
520
+ * 33 個に対して pane の `claude` プロセス 31 個・合計 RSS 1.7 GB。
521
+ *
522
+ * `HUB_AGENT_TMUX_LAUNCH_CLI=1` のときだけ従来どおり起動する(デバッグ・TUI を
523
+ * 意図的に使いたいときの逃げ道)。呼び出し元ごとに挙動を変えないため、判定は
524
+ * この 1 箇所 (createSession 内) だけで行い、opts では上書きさせない。
525
+ *
526
+ * @param {NodeJS.ProcessEnv} [env]
527
+ * @returns {boolean}
528
+ */
529
+ export function shouldLaunchPaneCli(env = process.env) {
530
+ return env?.HUB_AGENT_TMUX_LAUNCH_CLI === "1"
531
+ }
532
+
512
533
  function tmuxBin(opts = {}) {
513
534
  return opts.tmuxBin || DEFAULT_TMUX_BIN
514
535
  }
@@ -546,6 +567,10 @@ export function injectInitialPrompt(claudeCmd, initialPrompt) {
546
567
  * しか効かないため、injectInitialPrompt と同様に `||` で split して両側に付ける。
547
568
  *
548
569
  * configDir が空 (= 既定プロファイル) のときは何もしない。
570
+ *
571
+ * ⚠️ この関数は **CLI を pane 起動するときだけ** 使われる
572
+ * (`HUB_AGENT_TMUX_LAUNCH_CLI=1`)。素のシェルの pane へは setTmuxGlobalEnv の
573
+ * `set-environment -g` 経由で CLAUDE_CONFIG_DIR が渡る。
549
574
  */
550
575
  export function injectConfigDirEnv(claudeCmd, configDir) {
551
576
  if (!configDir || typeof configDir !== "string") return claudeCmd
@@ -559,8 +584,10 @@ export function injectConfigDirEnv(claudeCmd, configDir) {
559
584
 
560
585
  /**
561
586
  * tmux サーバーのグローバル環境変数を設定/解除する。
562
- * プロファイル切替後に手動で開く pane や respawn にも CLAUDE_CONFIG_DIR が
563
- * 行き渡るようにするための補助線 (主経路は injectConfigDirEnv のコマンド焼き込み)。
587
+ * pane は既定で素のシェルなので、ユーザーが pane 内で手動起動する claude / codex に
588
+ * CLAUDE_CONFIG_DIR (CODEX_HOME) を届ける **主経路** がこれ。
589
+ * (CLI を pane 起動する `HUB_AGENT_TMUX_LAUNCH_CLI=1` のときだけ、
590
+ * injectConfigDirEnv のコマンド焼き込みが併用される)。
564
591
  * value が null/undefined なら unset (-gu)。tmux サーバー未起動などの失敗は握り潰す。
565
592
  */
566
593
  export async function setTmuxGlobalEnv(name, value, opts = {}) {
@@ -766,12 +793,21 @@ export async function killManySessions(names, opts = {}) {
766
793
  }
767
794
 
768
795
  /**
769
- * 新規 session を detached で作成して claude を起動する。
796
+ * 新規 session を detached で作成する。
797
+ *
798
+ * ⚠️ **既定では pane に CLI (claude / codex) を起動しない**(pane は素のシェルのまま)。
799
+ * Cockpit の通常運用はチャットモードで、pane の TUI は誰も見ないまま常駐して
800
+ * メモリを食うだけだったため (詳細は shouldLaunchPaneCli の JSDoc)。
801
+ * `HUB_AGENT_TMUX_LAUNCH_CLI=1` のときだけ従来どおり CLI を send-keys する。
802
+ *
770
803
  * - 同名 session が既にあれば 'duplicate session' throw
771
- * - opts.claudeCmd で send-keys 内容を上書き可 (空文字なら claude 自動起動しない)
772
- * - opts.initialPrompt を渡すと `claude ... --permission-mode auto` の直後に
773
- * shell-safe 引数として埋め込まれ、claude TUI の初回プロンプトとして実行される
774
- * (`/orchestrate` 等のスキル発火に利用)
804
+ * - CLAUDE.md / AGENTS.md の生成・Hub 同期、cwd 自動作成、mouse mode 設定は
805
+ * **CLI を起動するかどうかに関わらず**常に行う(素のシェルでも必要なため)
806
+ * - 以下は CLI を起動するときだけ意味を持つので、起動時のみ評価される:
807
+ * opts.claudeCmd / opts.codexCmd / opts.cliKind / opts.claudeConfigDir /
808
+ * opts.codexHome / opts.initialPrompt / model・sandbox 等の codex フラグ
809
+ * - opts.initialPrompt は CLI 起動時に位置引数として末尾へ shell-safe に埋め込まれ、
810
+ * TUI の初回プロンプトとして実行される (`/orchestrate` 等のスキル発火に利用)
775
811
  */
776
812
  export async function createSession(name, cwd, opts = {}) {
777
813
  // tmux は `-c` をシェル展開しないため、ここで `~` を絶対パスに展開する。
@@ -918,6 +954,21 @@ export async function createSession(name, cwd, opts = {}) {
918
954
  // display-message 失敗は致命的でないので飲み込む
919
955
  }
920
956
  }
957
+ // 既定では pane を素のシェルのまま残す (CLI は起動しない)。ここから下は
958
+ // HUB_AGENT_TMUX_LAUNCH_CLI=1 のときだけ走る。
959
+ //
960
+ // 素のシェルでも必要な処理 (cwd 解決・CLAUDE.md 生成/同期・mouse mode) は上で
961
+ // 済ませてある。CLAUDE_CONFIG_DIR / CODEX_HOME も、pane で手動起動したときに
962
+ // 効くよう main.mjs の syncTmuxProfileEnv / syncTmuxCodexEnv が
963
+ // `tmux set-environment -g` でサーバーのグローバル環境へ流し込んでいるため、
964
+ // ここでコマンド文字列に焼き込む必要があるのは CLI を起動する場合だけ。
965
+ if (!shouldLaunchPaneCli()) {
966
+ opts.logger?.debug?.(
967
+ { session: name, cwd: resolvedCwd },
968
+ "pane CLI launch skipped (set HUB_AGENT_TMUX_LAUNCH_CLI=1 to enable)",
969
+ )
970
+ return
971
+ }
921
972
  // cliKind に応じて claude / codex いずれの CLI を pane で起動するか分岐する。
922
973
  // 既定は claude(後方互換)。initialPrompt はどちらも位置引数として末尾注入する
923
974
  // (claude "<p>" / codex "<p>" / codex resume <id> "<p>" いずれも [PROMPT] を受ける)。