@cocorograph/hub-agent 0.7.29 → 0.7.32

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.7.29",
3
+ "version": "0.7.32",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -29,6 +29,50 @@ export const MAX_HISTORY_LINES = 500
29
29
  /** UI 表示対象の SDK message type (それ以外は jsonl 内部メタなので除外)。 */
30
30
  export const DISPLAY_TYPES = new Set(["user", "assistant", "system", "result"])
31
31
 
32
+ /**
33
+ * hydrate 結果のパース済みキャッシュ (2026-07-03: Cockpit セッション切替パフォーマンス改善)。
34
+ *
35
+ * 背景: CockpitChatView は resume セッション切替のたびに毎回 readSessionHistory を呼ぶが
36
+ * (window/page.tsx の `key={selected}` 再マウント設計)、同一ファイルが未変更のまま
37
+ * 数秒〜数分内に再 hydrate されるケースが大半 (行ったり来たりのセッション切替、複数タブ)。
38
+ * readFile → split → 全行 JSON.parse (+ MASK_ENABLED 時は tool_use 走査) は jsonl が
39
+ * 大きいほど無視できないコストになるため、`mtimeMs + size` が前回と一致する間は
40
+ * パース結果をそのまま再利用し、ディスク読み直し・再パースを丸ごとスキップする。
41
+ * ファイルが変わっていれば (ライブ会話が進行 / 別ターミナルから追記等) 通常どおり再読み込み
42
+ * して上書きするので、常に最新のファイル内容を正しく反映する (stale 表示のリスクは無い)。
43
+ *
44
+ * key: filePath, value: { mtimeMs, size, maxLines, result }。maxLines もキーの一部として
45
+ * 比較する (異なる maxLines でのリクエストはキャッシュを再利用しない)。
46
+ */
47
+ const HYDRATE_CACHE_MAX_ENTRIES = 200
48
+ const hydrateCache = new Map()
49
+
50
+ function getHydrateCache(filePath, mtimeMs, size, maxLines) {
51
+ const entry = hydrateCache.get(filePath)
52
+ if (!entry) return null
53
+ if (entry.mtimeMs !== mtimeMs || entry.size !== size || entry.maxLines !== maxLines) return null
54
+ // LRU: 再利用したエントリを Map の末尾へ移し直す (再挿入で挿入順を更新)。
55
+ hydrateCache.delete(filePath)
56
+ hydrateCache.set(filePath, entry)
57
+ return entry.result
58
+ }
59
+
60
+ function setHydrateCache(filePath, mtimeMs, size, maxLines, result) {
61
+ hydrateCache.delete(filePath)
62
+ hydrateCache.set(filePath, { mtimeMs, size, maxLines, result })
63
+ // 上限超過分は挿入順が古いものから GC (長時間稼働の hub-agent プロセスでメモリが
64
+ // 際限なく増えないようにする)。
65
+ while (hydrateCache.size > HYDRATE_CACHE_MAX_ENTRIES) {
66
+ const oldestKey = hydrateCache.keys().next().value
67
+ hydrateCache.delete(oldestKey)
68
+ }
69
+ }
70
+
71
+ /** テスト用: hydrate キャッシュを全消去する。 */
72
+ export function clearHydrateCacheForTest() {
73
+ hydrateCache.clear()
74
+ }
75
+
32
76
  /**
33
77
  * jsonl の生 object を、Browser が SDK message として受け取れる表示用 shape に正規化する。
34
78
  * 余分な内部メタは落とし、表示・集計に必要なフィールドだけを superset で拾う。
@@ -97,6 +141,21 @@ export function jsonlPath({ cwd, session_id, projectsRoot }) {
97
141
  * @returns {Promise<{events: object[], total_lines: number, truncated: boolean}>}
98
142
  */
99
143
  export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINES, logger } = {}) {
144
+ // stat は readFile よりずっと軽い (メタデータのみ)。mtimeMs+size が前回と同じなら
145
+ // ファイル内容も同じとみなし、以降の readFile + 全行 parse をまるごとスキップする。
146
+ let st
147
+ try {
148
+ st = await stat(filePath)
149
+ } catch (err) {
150
+ if (err.code === "ENOENT") {
151
+ return { events: [], total_lines: 0, truncated: false }
152
+ }
153
+ logger?.warn({ err: err.message, filePath }, "claude history stat failed")
154
+ throw err
155
+ }
156
+ const cached = getHydrateCache(filePath, st.mtimeMs, st.size, maxLines)
157
+ if (cached) return cached
158
+
100
159
  let text
101
160
  try {
102
161
  text = await readFile(filePath, "utf-8")
@@ -143,7 +202,9 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
143
202
  if (MASK_ENABLED) event = maskMessageObject(event, toolNames)
144
203
  events.push(event)
145
204
  }
146
- return { events, total_lines, truncated }
205
+ const result = { events, total_lines, truncated }
206
+ setHydrateCache(filePath, st.mtimeMs, st.size, maxLines, result)
207
+ return result
147
208
  }
148
209
 
149
210
  /**
@@ -1588,6 +1588,78 @@ export class ClaudeStreamBridge extends EventEmitter {
1588
1588
  return this._mergeNeedsAuthCache(servers)
1589
1589
  }
1590
1590
 
1591
+ /**
1592
+ * アカウント全体の 5h/7d rate-limit 使用率 + プラン種別を返す (usage.mjs
1593
+ * recordChatUsageSnapshot に渡す想定)。mcpStatus と同じ control-only query を共有する
1594
+ * (account-level = session/チャット非依存。statusLine が動かない純 SDK チャット運用でも
1595
+ * 取得できる)。
1596
+ *
1597
+ * SDK の `usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()` は unstable な
1598
+ * 実験的 API のため、失敗 (未実装 SDK バージョン / 一時的なエラー等) は null を返す。
1599
+ * 呼び出し側 (main.mjs `usage.request` ハンドラ) は null なら何もせず、既存の
1600
+ * statusLine / transcript 推定フォールバックに委ねる。
1601
+ *
1602
+ * @returns {Promise<import('@anthropic-ai/claude-agent-sdk').SDKControlGetUsageResponse|null>}
1603
+ */
1604
+ async getAccountUsage() {
1605
+ try {
1606
+ const q = await this._ensureMcpControlQuery()
1607
+ this._armMcpControlIdle()
1608
+ if (typeof q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET !== "function") {
1609
+ return null
1610
+ }
1611
+ return await q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()
1612
+ } catch (err) {
1613
+ this.logger?.warn(
1614
+ { err: err?.message },
1615
+ "getAccountUsage failed (SDK experimental usage API)",
1616
+ )
1617
+ return null
1618
+ }
1619
+ }
1620
+
1621
+ /**
1622
+ * 指定 cwd で現在ライブな (browser 接続中、または detach 猶予内の) SDK チャット
1623
+ * session_id を返す。無ければ null。
1624
+ *
1625
+ * TUI モードには tuiReboundSessions による bound session 解決 (main.mjs
1626
+ * `turnActiveForCwd`/`jsonlContextForCwd` 呼び出し) があるが、チャット (SDK stream)
1627
+ * モードには同等の仕組みが無かった。同一 cwd の encode dir にはチャット本体の他に
1628
+ * サブエージェント / headless `claude -p` (記憶蒸留 Stop hook 等) / 過去セッションの
1629
+ * jsonl が堆積するため、bound 指定無しの mtime 最新フォールバックは cross-activity
1630
+ * 汚染 (無関係な jsonl を「そのチャット」と誤認) を起こし得る。本メソッドはチャット版
1631
+ * の bound session 解決として main.mjs (startStateLoop / tmux.list_sessions ハンドラ)
1632
+ * から使う。同一 cwd に複数ライブセッションがある場合は最終アクティビティが最も
1633
+ * 新しいものを採用する。
1634
+ *
1635
+ * @param {string} cwd
1636
+ * @returns {string|null}
1637
+ */
1638
+ liveSessionIdForCwd(cwd) {
1639
+ if (!cwd) return null
1640
+ let best = null
1641
+ let bestAt = -1
1642
+ const seen = new Set()
1643
+ for (const session of this.sessions.values()) {
1644
+ if (seen.has(session) || session.cwd !== cwd || !session.sessionId) continue
1645
+ seen.add(session)
1646
+ let at = 0
1647
+ for (const t of session.lastActivityByStream.values()) {
1648
+ if (t > at) at = t
1649
+ }
1650
+ // `>=` (2026-07-03): Date.now() はミリ秒精度のため、短時間に複数セッションへ
1651
+ // attach+input すると同一ミリ秒でタイが起き得る (テストや高速な連続操作で実測)。
1652
+ // `this.sessions` は Map で挿入順を保持するため、タイ時は後から attach された
1653
+ // (= イテレーション順で後に来る) セッションを採用するのが「最終アクティビティが
1654
+ // 最も新しい」の意図に合う。厳密な `>` だと先勝ちになり意図と逆転する。
1655
+ if (at >= bestAt) {
1656
+ bestAt = at
1657
+ best = session.sessionId
1658
+ }
1659
+ }
1660
+ return best
1661
+ }
1662
+
1591
1663
  /**
1592
1664
  * `mcpServerStatus()` の結果に、needs-auth キャッシュにあるが結果へ含まれない
1593
1665
  * サーバーを `needs-auth` として補完する。
@@ -23,6 +23,13 @@
23
23
  * - 画像/添付入力 (テキストのみ)。
24
24
  * - acceptForSession や execpolicy/network amendment 等の高度な承認 decision
25
25
  * (permissionReply は decision をそのまま素通しするので呼び出し側が組み立てる)。
26
+ *
27
+ * Slice 6 (履歴 hydrate) で追加: `thread/resume` のレスポンスは既定で
28
+ * `thread.turns[].items[]` (= ThreadItem の時系列) を含む (公式スキーマ
29
+ * `ThreadReadResponse.json` の Thread.turns 説明: "Only populated on thread/resume,
30
+ * thread/rollback, thread/fork, and thread/read (when includeTurns is true)")。
31
+ * 追加の `thread/read` 往復は不要で、attach() の戻り値に `history` として平坦化して
32
+ * 返す (browser 側での re-mount 白紙状態を解消する)。
26
33
  */
27
34
  import { EventEmitter } from "node:events"
28
35
 
@@ -39,6 +46,17 @@ const IDLE_DETACH_TTL_MS = 5 * 60 * 1000
39
46
 
40
47
  const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
41
48
 
49
+ // Slice 6: thread/resume が返す Thread.turns[].items[] を単一の時系列配列へ平坦化する。
50
+ // turns は開始ターン順の配列、各 turn.items も時系列順のため単純結合で会話順を保てる。
51
+ function flattenThreadItems(thread) {
52
+ const turns = Array.isArray(thread?.turns) ? thread.turns : []
53
+ const items = []
54
+ for (const turn of turns) {
55
+ if (Array.isArray(turn?.items)) items.push(...turn.items)
56
+ }
57
+ return items
58
+ }
59
+
42
60
  function userInputFromMessage(message) {
43
61
  const content = message?.content
44
62
  if (typeof content === "string") {
@@ -65,7 +83,7 @@ export class CodexStreamBridge extends EventEmitter {
65
83
  }
66
84
 
67
85
  /**
68
- * @returns {Promise<{resuming: boolean, threadId: string}>}
86
+ * @returns {Promise<{resuming: boolean, threadId: string, history: object[]}>}
69
87
  */
70
88
  async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId }) {
71
89
  if (this.sessions.has(stream_id)) {
@@ -96,7 +114,10 @@ export class CodexStreamBridge extends EventEmitter {
96
114
  ? await client.request("thread/resume", { ...startParams, threadId: resumeThreadId })
97
115
  : await client.request("thread/start", startParams)
98
116
  session.threadId = result?.thread?.id
99
- return { resuming, threadId: session.threadId }
117
+ // 新規スレッド (thread/start) は履歴が無いので空配列。resume 時のみ平坦化する
118
+ // (thread/start のレスポンスは turns を持たない仕様のため呼んでも常に空)。
119
+ const history = resuming ? flattenThreadItems(result?.thread) : []
120
+ return { resuming, threadId: session.threadId, history }
100
121
  } catch (err) {
101
122
  this.sessions.delete(stream_id)
102
123
  client.stop()
package/src/config.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  import { promises as fs, constants as fsConsts, readFileSync } from "node:fs"
11
11
  import path from "node:path"
12
12
  import os from "node:os"
13
- import { execFile } from "node:child_process"
13
+ import { execFile, execFileSync } from "node:child_process"
14
14
  import { promisify } from "node:util"
15
15
 
16
16
  const _execFileP = promisify(execFile)
@@ -21,9 +21,20 @@ const _execFileP = promisify(execFile)
21
21
  * 重要: Windows のエージェントは WSL2 内で動くため `process.platform` は "linux" を
22
22
  * 返す。素の Linux と区別するため、WSL を検出して "wsl" を返す。
23
23
  *
24
+ * 判定は 3 段階のフォールバック:
25
+ * 1. `WSL_DISTRO_NAME` / `WSL_INTEROP` 環境変数 (対話シェルなら通常付く)
26
+ * 2. `/proc/version` の内容 (microsoft/wsl 文字列)
27
+ * 3. (2026-07-03 追加) `uname -r` のカーネルリリース文字列。systemd サービス等
28
+ * 非対話起動では 1 の環境変数が unit に伝播しないケースがある (WSL2 + systemd の
29
+ * 典型的な罠。対話シェルにしか WSL_DISTRO_NAME が無く、hub-agent をサービス化
30
+ * すると引き継がれない)。`uname -r` は環境変数に依存せずカーネル自体から
31
+ * "5.15.x-microsoft-standard-WSL2" のような値を返すため、より確実な最終フォールバック
32
+ * として使う。
33
+ *
34
+ * @param {{execImpl?: (cmd: string, args: string[]) => string}} [opts] テスト用の exec 差し替え
24
35
  * @returns {"darwin"|"wsl"|"linux"|"win32"|string}
25
36
  */
26
- export function detectPlatform() {
37
+ export function detectPlatform({ execImpl } = {}) {
27
38
  if (process.platform === "darwin") return "darwin"
28
39
  if (process.platform === "win32") return "win32"
29
40
  if (process.platform === "linux") {
@@ -32,7 +43,14 @@ export function detectPlatform() {
32
43
  const v = readFileSync("/proc/version", "utf-8").toLowerCase()
33
44
  if (v.includes("microsoft") || v.includes("wsl")) return "wsl"
34
45
  } catch {
35
- /* /proc/version が読めない環境は素の linux 扱い */
46
+ /* /proc/version が読めない環境は次のフォールバックへ */
47
+ }
48
+ try {
49
+ const run = execImpl || ((cmd, args) => execFileSync(cmd, args, { encoding: "utf-8" }))
50
+ const release = run("uname", ["-r"]).toLowerCase()
51
+ if (release.includes("microsoft") || release.includes("wsl")) return "wsl"
52
+ } catch {
53
+ /* uname が無い/失敗する環境は素の linux 扱い */
36
54
  }
37
55
  return "linux"
38
56
  }
package/src/main.mjs CHANGED
@@ -98,6 +98,7 @@ import {
98
98
  getUsage,
99
99
  isFreshUnboundBind,
100
100
  recordChatRateLimit,
101
+ recordChatUsageSnapshot,
101
102
  turnActiveForCwd,
102
103
  } from "./usage.mjs"
103
104
  import {
@@ -1343,6 +1344,61 @@ export function enrichTmuxSessionsForListResponse(
1343
1344
  })
1344
1345
  }
1345
1346
 
1347
+ /**
1348
+ * state loop 内で「チャット信号 (getChatSignal) でペイン status/context_pct を上書きするか」
1349
+ * を決める純関数 (startStateLoop から抽出、単体テスト用)。
1350
+ *
1351
+ * バグ修正 (2026-07): 従来コードは書き換え「後」の status で processing ガードを判定して
1352
+ * いたため、チャット自身が processing を立てたときに自分自身の条件 (`status !== "processing"`)
1353
+ * を満たせなくなり、SDK チャット生成中はドーナツ (context_pct) が一切更新されなかった。
1354
+ * 本関数は判定を必ず「上書き前の pane 由来 status (paneStatus)」で行う。ペイン
1355
+ * (capture-pane) が実際に processing (= 本物の TUI が生成中) の場合のみチャット信号での
1356
+ * 上書きを止める。それ以外 (pane が idle 等 = SDK チャット専用ペイン含む) は常にチャット
1357
+ * 信号を優先する。
1358
+ *
1359
+ * @param {string} paneStatus capture-pane 由来の生 status (上書き前)
1360
+ * @param {number|null|undefined} paneContextPct capture-pane 由来の生 context_pct
1361
+ * @param {{status?:string|null, context_pct?:number|null}|null} chat getChatSignal() の戻り値
1362
+ * @returns {{status:string, contextPct:number|null|undefined}}
1363
+ */
1364
+ export function applyChatOverlay(paneStatus, paneContextPct, chat) {
1365
+ let status = paneStatus
1366
+ let contextPct = paneContextPct
1367
+ if (chat) {
1368
+ const paneIsProcessing = paneStatus === "processing"
1369
+ if (chat.status && !paneIsProcessing) status = chat.status
1370
+ if (typeof chat.context_pct === "number" && !paneIsProcessing) {
1371
+ contextPct = chat.context_pct
1372
+ }
1373
+ }
1374
+ return { status, contextPct }
1375
+ }
1376
+
1377
+ /**
1378
+ * turnActiveForCwd / jsonlContextForCwd に渡す bound session_id を解決する純関数。
1379
+ *
1380
+ * TUI (tuiReboundSessions, tmux セッション名キー) を優先し、無ければ claudeBridge の
1381
+ * ライブ SDK チャットセッション (cwd キー) にフォールバックする。チャットモードには
1382
+ * tmux 上の bind 台帳が無いため、これが無いとチャット信号 stale (90s 無活動) 時の
1383
+ * フォールバックが cwd の encode dir 内の別アクティビティ jsonl (サブエージェント /
1384
+ * headless `claude -p` / 過去セッション等) を「最新」と取り違える cross-activity 汚染を
1385
+ * 起こし得る (TUI 版は tuiReboundSessions で 2026-06 に根治済み。本関数はチャット版の同根治)。
1386
+ *
1387
+ * @param {Map<string,string>|null|undefined} boundSessions ctx.tuiReboundSessions
1388
+ * @param {string} sessionName tmux セッション名
1389
+ * @param {string|null|undefined} cwd
1390
+ * @param {{liveSessionIdForCwd?: (cwd:string)=>string|null}|null|undefined} claudeBridge
1391
+ * @returns {string|null}
1392
+ */
1393
+ export function resolveStateLoopBoundSessionId(boundSessions, sessionName, cwd, claudeBridge) {
1394
+ const tuiId = boundSessionId(boundSessions, sessionName)
1395
+ if (tuiId) return tuiId
1396
+ if (cwd && claudeBridge && typeof claudeBridge.liveSessionIdForCwd === "function") {
1397
+ return claudeBridge.liveSessionIdForCwd(cwd) || null
1398
+ }
1399
+ return null
1400
+ }
1401
+
1346
1402
  export function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, readinessTracker, boundSessions }) {
1347
1403
  const lastByName = new Map() // session_name → {status, context_pct}
1348
1404
  const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
@@ -1379,25 +1435,25 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1379
1435
  await refreshContextWindow()
1380
1436
  const states = await listSessionStates({ plugins, logger })
1381
1437
  for (const s of states) {
1382
- let status = s.status
1383
- let contextPct = s.context_pct
1384
1438
  // 対話 TUI のペインから読んだ権限モード (素のシェル / SDK チャットでは null)。
1385
1439
  // tmux 上で直接 shift+tab した変更もここで拾い、全ブラウザへ追従させる。
1386
1440
  const permissionMode = s.permission_mode ?? null
1387
1441
  // チャットモード補完: tmux ペインのスクレイプは TUI 前提で効かないため、
1388
1442
  // session の cwd に一致する新鮮なチャット信号があれば status/context% を
1389
1443
  // 上書きする (Hub ホスト型 Cockpit のステータスドット/各行ドーナツ用)。
1444
+ // capture-pane が processing (= ペインに "esc to interrupt" が実在 = 本物の
1445
+ // TUI が生成中) のときのみチャット信号で上書きしない (判定は applyChatOverlay
1446
+ // 内部で必ず「上書き前の pane 由来 status (s.status)」を使う。2026-07 バグ修正:
1447
+ // 旧実装は書き換え後の status で判定していたため、SDK チャット生成中はドーナツが
1448
+ // 一切更新されなかった)。素のシェル (SDK チャットのペイン) は processing を
1449
+ // 返せないため、SDK チャットの補完は従来どおり効く。TUI 生成中に stale な SDK
1450
+ // status (waiting) が勝ってステータスドット/三点リーダーが消える事故の防止
1451
+ // (2026-06-12) は paneIsProcessing ガードのまま維持される。
1390
1452
  const chat = s.cwd ? getChatSignal(s.cwd) : null
1453
+ const overlay = applyChatOverlay(s.status, s.context_pct, chat)
1454
+ let status = overlay.status
1455
+ let contextPct = overlay.contextPct
1391
1456
  if (chat) {
1392
- // capture-pane が processing (= ペインに "esc to interrupt" が実在 = 本物の
1393
- // TUI が生成中) のときはチャット信号で上書きしない。素のシェル (SDK チャット
1394
- // のペイン) は processing を返せないため、SDK チャットの補完は従来どおり効く。
1395
- // TUI 生成中に stale な SDK status (waiting) が勝ってステータスドット/
1396
- // 三点リーダーが消える事故の防止 (2026-06-12)。
1397
- if (chat.status && status !== "processing") status = chat.status
1398
- if (typeof chat.context_pct === "number" && status !== "processing") {
1399
- contextPct = chat.context_pct
1400
- }
1401
1457
  // チャットのターン境界 (turnAt 前進) を sort 用 session-event に橋渡し。
1402
1458
  // tmux ペインが動かず bundle hook が発火しないため、ここで代替発火する。
1403
1459
  const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
@@ -1466,6 +1522,11 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1466
1522
  // T2: tool_use 末尾 active 中の中断マーカー無しクラッシュ判定のため、tmux pane_current_command
1467
1523
  // から claude プロセス生存を 3 値で返す paneClaudeAliveOrUnknown を probe として渡す。
1468
1524
  // 'dead' (シェル前景)→active=false に倒す。'alive'/'unknown' は active 維持(偽陰性ゼロ)。
1525
+ // - チャット信号が stale (90s 無活動) でこの else-if に落ちるケース: SDK チャットは
1526
+ // tmux bind 台帳 (tuiReboundSessions) を持たないため、従来は boundSessionId が常に
1527
+ // null を返し mtime 最新 jsonl へフォールバックしていた (chat 版の cross-activity
1528
+ // 汚染, 2026-07 発覚)。resolveStateLoopBoundSessionId が claudeBridge のライブ
1529
+ // セッション id へフォールバックすることで TUI と同じ方式で根治する。
1469
1530
  let turnActive = null
1470
1531
  if (chat?.status === "processing") {
1471
1532
  turnActive = true
@@ -1479,7 +1540,7 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1479
1540
  } else if (s.cwd) {
1480
1541
  turnActive = await turnActiveForCwd(
1481
1542
  s.cwd,
1482
- boundSessionId(boundSessions, s.session_name),
1543
+ resolveStateLoopBoundSessionId(boundSessions, s.session_name, s.cwd, claudeBridge),
1483
1544
  s.session_name,
1484
1545
  { paneAliveProbe: paneClaudeAliveOrUnknown },
1485
1546
  )
@@ -3032,6 +3093,10 @@ async function dispatch(msg, ctx) {
3032
3093
  stream_id,
3033
3094
  resuming: info.resuming,
3034
3095
  thread_id: info.threadId,
3096
+ // Slice 6: 履歴 hydrate。resume 時のみ非空 (thread/start は常に [])。
3097
+ // consumers.py の per-stream relay は content-blind なので backend 側の
3098
+ // 変更は不要 (_AGENT_PER_STREAM_TYPES の codex.ready がそのまま運ぶ)。
3099
+ history: info.history || [],
3035
3100
  })
3036
3101
  } catch (err) {
3037
3102
  ctx.client.send({
@@ -3311,10 +3376,13 @@ async function dispatch(msg, ctx) {
3311
3376
  try {
3312
3377
  // boundSessions: 各行の context% (ドーナツ) を bound session の jsonl から引く
3313
3378
  // (cwd dir 内の別セッション jsonl を最新と取り違える=メタ欠陥#1 の根治)。
3379
+ // claudeBridge: TUI bind が無い SDK チャット cwd 向けの同根治フォールバック
3380
+ // (2026-07 追加。tmux.mjs listSessions 参照)。
3314
3381
  const sessions = await listTmuxSessions({
3315
3382
  plugins: ctx.plugins,
3316
3383
  logger: ctx.logger,
3317
3384
  boundSessions: ctx.tuiReboundSessions,
3385
+ claudeBridge: ctx.claudeBridge,
3318
3386
  })
3319
3387
  // 各 session の最新 hook event を /tmp/cockpit_session_events/ から読んで
3320
3388
  // レスポンスに含める (Phase 4: ハードリロード時の lastEvent 復元用)。
@@ -3581,6 +3649,20 @@ async function dispatch(msg, ctx) {
3581
3649
  }
3582
3650
  case "usage.request": {
3583
3651
  try {
3652
+ // 5h/7d の本命ソース: SDK control request (アカウント全体、session 非依存)。
3653
+ // statusLine が rate_limits を出さなくなった環境 (2026-07 時点の Claude CLI) でも
3654
+ // ここでライブ更新できる。frontend の usage.request ポーリング (既定 60s) 頻度に
3655
+ // 相乗りするだけで十分な鮮度が保てるため、専用タイマーは設けない。EXPERIMENTAL
3656
+ // API の失敗は getAccountUsage 内で握りつぶし null を返す (recordChatUsageSnapshot
3657
+ // は no-op) ので、既存の statusLine/transcript フォールバックは無傷で残る。
3658
+ if (ctx.claudeBridge) {
3659
+ try {
3660
+ const acct = await ctx.claudeBridge.getAccountUsage()
3661
+ if (acct) recordChatUsageSnapshot(acct)
3662
+ } catch (err) {
3663
+ ctx.logger?.debug?.({ err: err?.message }, "account usage snapshot skipped")
3664
+ }
3665
+ }
3584
3666
  const [usage, sessions] = await Promise.all([
3585
3667
  getUsage(),
3586
3668
  getSessionUsages(),
package/src/tmux.mjs CHANGED
@@ -643,7 +643,15 @@ export async function listSessions(opts = {}) {
643
643
  // 4. それも無ければ pane scrape の正規表現フォールバック。
644
644
  const fromStatusLine = cwd ? ctxByCwd.get(cwd)?.contextPercent : null
645
645
  // bound session があればその jsonl で context% を引く (cwd dir の別セッション jsonl の取り違え回避)。
646
- const boundId = boundSessionId(opts.boundSessions, s.name)
646
+ // TUI bind (opts.boundSessions, tmux セッション名キー) を優先し、無ければ SDK チャット
647
+ // (claudeBridge, cwd キー) のライブセッションへフォールバックする。チャットモードには
648
+ // tmux 上の bind 台帳が無いため、これが無いと cold-start (session.state snapshot 未取得)
649
+ // の poll 応答で cwd の別アクティビティ jsonl を「最新」と取り違える cross-activity 汚染が
650
+ // 起き得る (2026-07 発覚。TUI 版は本行の boundSessions 解決で既に根治済みだった)。
651
+ const boundId =
652
+ boundSessionId(opts.boundSessions, s.name) ||
653
+ (cwd && opts.claudeBridge?.liveSessionIdForCwd?.(cwd)) ||
654
+ null
647
655
  const jsonlInfo = cwd ? await jsonlContextForCwd(cwd, boundId) : null
648
656
  const context_pct =
649
657
  jsonlInfo?.isReset && typeof jsonlInfo.percent === "number"
package/src/usage.mjs CHANGED
@@ -272,6 +272,125 @@ export function whenChatRateLimitsPersisted() {
272
272
  return _persistInFlight
273
273
  }
274
274
 
275
+ // ---------------------------------------------------------------------------
276
+ // SDK control request (`usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET`)
277
+ // から取得した 5h/7d + plan のスナップショット (プロセス内共有 + ディスク永続化)。
278
+ //
279
+ // 背景: `recordChatRateLimit` (上記, SDK の `rate_limit_event`) は 2026-07 時点の
280
+ // SDK/Claude CLI で `utilization` フィールドが送られなくなり (実測確認済み)、事実上
281
+ // 常時 no-op 化した。加えて statusLine cache (`~/.hub/usage/latest.json`) 自体も
282
+ // `rate_limits` を出さなくなったため (readOfficial 参照)、5h/7d はターミナル・
283
+ // チャット両モードで estimate (transcript 集計の粗い近似) に落ちていた。
284
+ //
285
+ // SDK の control request `usage_EXPERIMENTAL_...()` は実測で正確な utilization/
286
+ // resets_at を返すことを確認済み (claude-agent-sdk 0.3.198)。account-level (session
287
+ // 非依存) なので、claude-stream-bridge.mjs の MCP control-only query を共有して
288
+ // 定期的に呼び出し (main.mjs `usage.request` ハンドラ)、ここに記録する。
289
+ //
290
+ // 永続化先は statusLine cache とは別ファイル (`~/.hub/usage/chat-usage.json`)。
291
+ // 同じ latest.json に混ぜると TUI の statusLine スクリプトが全文上書きで消してしまう
292
+ // (旧 chatRateLimits の設計的弱点)。専用ファイルなら誰にも上書きされない。
293
+ // ---------------------------------------------------------------------------
294
+
295
+ function chatUsageCachePath() {
296
+ return configPath("HUB_CHAT_USAGE_CACHE", os.homedir(), ".hub", "usage", "chat-usage.json")
297
+ }
298
+
299
+ /** @type {{five_hour:{percent:number,resetAtMs:number|null}|null, seven_day:{percent:number,resetAtMs:number|null}|null, plan:string|null, updatedAtMs:number}|null} */
300
+ let chatUsageSnapshot = null
301
+ let _chatUsagePersistInFlight = Promise.resolve()
302
+
303
+ /** SDKControlGetUsageResponse.rate_limits.{five_hour,seven_day} の 1 窓を正規化する。 */
304
+ function normalizeRateLimitWindow(w) {
305
+ if (!w || typeof w !== "object") return null
306
+ const percent = typeof w.utilization === "number" ? w.utilization : null
307
+ if (percent === null) return null
308
+ let resetAtMs = null
309
+ if (typeof w.resets_at === "string") {
310
+ const t = Date.parse(w.resets_at)
311
+ if (!Number.isNaN(t)) resetAtMs = t
312
+ } else if (typeof w.resets_at === "number") {
313
+ resetAtMs = w.resets_at < 1e12 ? w.resets_at * 1000 : w.resets_at
314
+ }
315
+ return { percent, resetAtMs }
316
+ }
317
+
318
+ /**
319
+ * SDK control request `usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()`
320
+ * の戻り値 (SDKControlGetUsageResponse) を取り込む。EXPERIMENTAL API なので形が
321
+ * 変わる可能性があり、想定外の入力は無視する (呼び出し側は try/catch で包む前提)。
322
+ *
323
+ * @param {{rate_limits_available?: boolean, rate_limits?: {five_hour?: object|null, seven_day?: object|null}|null, subscription_type?: string|null}|null} usageResponse
324
+ * @param {number} [now]
325
+ */
326
+ export function recordChatUsageSnapshot(usageResponse, now = Date.now()) {
327
+ if (!usageResponse || typeof usageResponse !== "object") return
328
+ if (usageResponse.rate_limits_available === false) return
329
+ const rl = usageResponse.rate_limits
330
+ if (!rl || typeof rl !== "object") return
331
+ const five = normalizeRateLimitWindow(rl.five_hour)
332
+ const seven = normalizeRateLimitWindow(rl.seven_day)
333
+ if (!five && !seven) return
334
+ chatUsageSnapshot = {
335
+ five_hour: five,
336
+ seven_day: seven,
337
+ plan: usageResponse.subscription_type || chatUsageSnapshot?.plan || null,
338
+ updatedAtMs: now,
339
+ }
340
+ // P5(bug) と同じ直列化パターン (recordChatRateLimit 参照): 並行 persist の tmp 破壊/
341
+ // 中間状態読みを防ぐため、常に前回の persist 完了を待ってから次を実行する。
342
+ _chatUsagePersistInFlight = _chatUsagePersistInFlight.then(
343
+ () => persistChatUsageSnapshot(),
344
+ () => persistChatUsageSnapshot(),
345
+ )
346
+ }
347
+
348
+ async function persistChatUsageSnapshot() {
349
+ const snapshot = chatUsageSnapshot
350
+ if (!snapshot) return
351
+ const p = chatUsageCachePath()
352
+ try {
353
+ await fs.mkdir(path.dirname(p), { recursive: true })
354
+ } catch {
355
+ /* ignore */
356
+ }
357
+ const tmp = `${p}.tmp.${randomUUID()}`
358
+ try {
359
+ await fs.writeFile(tmp, JSON.stringify(snapshot))
360
+ await fs.rename(tmp, p)
361
+ } catch {
362
+ try {
363
+ await fs.unlink(tmp)
364
+ } catch {
365
+ /* ignore */
366
+ }
367
+ }
368
+ }
369
+
370
+ /** 直近の chat usage snapshot 書き込みの完了を待つ。テスト用。 */
371
+ export function whenChatUsageSnapshotPersisted() {
372
+ return _chatUsagePersistInFlight
373
+ }
374
+
375
+ /** プロセス内メモリを優先し、無ければディスク永続化 (hub-agent 再起動直後等) を読む。 */
376
+ async function readChatUsageSnapshot() {
377
+ if (chatUsageSnapshot) return chatUsageSnapshot
378
+ const text = await readOrNull(chatUsageCachePath())
379
+ if (!text) return null
380
+ try {
381
+ const j = JSON.parse(text)
382
+ if (j && typeof j === "object" && typeof j.updatedAtMs === "number") return j
383
+ } catch {
384
+ /* ignore */
385
+ }
386
+ return null
387
+ }
388
+
389
+ /** テスト用: chatUsageSnapshot をクリアする。 */
390
+ export function _resetChatUsageSnapshot() {
391
+ chatUsageSnapshot = null
392
+ }
393
+
275
394
  async function readOfficial(now) {
276
395
  const text = await readOrNull(statuslineCache())
277
396
  if (!text) return null
@@ -284,8 +403,13 @@ async function readOfficial(now) {
284
403
  const rl = j.rate_limits ?? j.rateLimits ?? {}
285
404
  const five = rl.five_hour ?? rl.fiveHour ?? rl["5h"]
286
405
  const seven = rl.seven_day ?? rl.sevenDay ?? rl["7d"]
287
- if (!five && !seven) return null
288
406
  const ctx = j.context_window?.used_percentage ?? j.contextWindow?.used_percentage ?? null
407
+ // 2026-07: Claude CLI 2.1.197+ の statusLine は rate_limits を出さなくなった
408
+ // (chatUsageSnapshot 由来の SDK control request がその代替本命ソースになる。
409
+ // 下の getUsage() 参照)。以前は rate_limits 欠落時に無関係な context_window まで
410
+ // 巻き込んで握りつぶしていた (ドーナツが常に jsonl 推定フォールバックへ落ちる副作用)。
411
+ // rate_limits と context の両方が無いときだけ official ソースとして無意味とみなす。
412
+ if (!five && !seven && ctx === null) return null
289
413
 
290
414
  const bucketize = (b, defaultLimit) => {
291
415
  if (!b) return { ...emptyBucket(defaultLimit), limit: defaultLimit }
@@ -310,7 +434,10 @@ async function readOfficial(now) {
310
434
  return {
311
435
  plan: j.plan || "official",
312
436
  computedAt: now,
313
- source: "official",
437
+ // 5h/7d の実値が伴わない (rate_limits 欠落・context のみ) ときは "official" を名乗らない。
438
+ // badge/表示は source を「5h/7d の信頼度」の意味で使っているため、chatUsageSnapshot 等の
439
+ // 上位レイヤーがまだ埋めていない時点では honest に "estimate" を返す。
440
+ source: five || seven ? "official" : "estimate",
314
441
  context: typeof ctx === "number" ? ctx : null,
315
442
  last5h: bucketize(five, 100),
316
443
  last7d: bucketize(seven, 100),
@@ -1025,9 +1152,13 @@ export async function getUsage() {
1025
1152
  // チャット(SDK) の rate_limit_event が statusLine cache より新しければ、5h/7d を
1026
1153
  // その実値 (utilization) で上書きする。ターミナルが statusLine を更新していない
1027
1154
  // チャットモードでも 5h/7d がライブで動くようになる。
1155
+ // `>=` (2026-07-03): ファイル mtime はミリ秒未満の粒度が丸められる環境があり、
1156
+ // 同一ミリ秒 (またはそれ以下) の解像度で statusLine 書き込みと rate_limit_event が
1157
+ // 揃うとタイになり得る。プロセス内の直近イベント (chatRateLimits) の方が
1158
+ // ディスク read-back より鮮度の情報として確実なため、タイ時は上書きを優先する。
1028
1159
  if (
1029
1160
  chatRateLimits.updatedAtMs > 0 &&
1030
- (cacheMtime === null || chatRateLimits.updatedAtMs > cacheMtime)
1161
+ (cacheMtime === null || chatRateLimits.updatedAtMs >= cacheMtime)
1031
1162
  ) {
1032
1163
  if (chatRateLimits.five_hour) {
1033
1164
  result.last5h = {
@@ -1048,6 +1179,35 @@ export async function getUsage() {
1048
1179
  result.source = "official"
1049
1180
  }
1050
1181
  }
1182
+
1183
+ // SDK control request (usage_EXPERIMENTAL...) 由来のスナップショット。上の
1184
+ // chatRateLimits (rate_limit_event) より本命の情報源 (utilization 欠落問題が無い、
1185
+ // 2026-07 時点で実測確認済み)。statusLine / rate_limit_event のどちらよりも新しければ
1186
+ // 上書きする。5h/7d が実際に埋まったときだけ source を official に昇格させる。
1187
+ const chatSnap = await readChatUsageSnapshot()
1188
+ if (chatSnap && chatSnap.updatedAtMs > 0) {
1189
+ const baseline = Math.max(cacheMtime ?? 0, chatRateLimits.updatedAtMs || 0)
1190
+ // `>=` (上と同じ理由): ファイル mtime とプロセス内タイムスタンプがタイになり得る。
1191
+ // chatSnap は最も直接的な情報源 (SDK control request 実値) なのでタイ時は優先する。
1192
+ if (chatSnap.updatedAtMs >= baseline) {
1193
+ if (chatSnap.five_hour) {
1194
+ result.last5h = {
1195
+ ...result.last5h,
1196
+ percent: chatSnap.five_hour.percent,
1197
+ resetAtMs: chatSnap.five_hour.resetAtMs ?? result.last5h?.resetAtMs ?? null,
1198
+ }
1199
+ }
1200
+ if (chatSnap.seven_day) {
1201
+ result.last7d = {
1202
+ ...result.last7d,
1203
+ percent: chatSnap.seven_day.percent,
1204
+ resetAtMs: chatSnap.seven_day.resetAtMs ?? result.last7d?.resetAtMs ?? null,
1205
+ }
1206
+ }
1207
+ if (chatSnap.plan) result.plan = chatSnap.plan
1208
+ if (chatSnap.five_hour || chatSnap.seven_day) result.source = "official"
1209
+ }
1210
+ }
1051
1211
  return result
1052
1212
  }
1053
1213