@cocorograph/hub-agent 0.8.80 → 0.8.82

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/src/main.mjs CHANGED
@@ -116,7 +116,7 @@ import {
116
116
  validateInitialPrompt,
117
117
  } from "./tmux.mjs"
118
118
  import { expandTilde, prepareChatWorkspace, isCwdUnavailableError } from "./chat-workspace.mjs"
119
- import { buildChatSessionsSnapshot } from "./chat-sessions-snapshot.mjs"
119
+ import { buildChatSessionsSnapshot, refreshChatSessionsSignals } from "./chat-sessions-snapshot.mjs"
120
120
  import { chatTargetsCache } from "./chat-targets.mjs"
121
121
  import { ensureChatSession } from "./chat-session-starter.mjs"
122
122
  import { beginRemoval } from "./chat-removal-guard.mjs"
@@ -125,7 +125,6 @@ import { describeLiveSessionsUnion, isSessionWarm } from "./live-sessions.mjs"
125
125
  import { applyPointerContexts, resolvePointerContexts } from "./pointer-context.mjs"
126
126
  import {
127
127
  boundSessionId,
128
- codexContextPercentFromTokenUsage,
129
128
  codexUsageFromAccountRateLimits,
130
129
  codexLiveContextForProducer,
131
130
  codexSessionUsages,
@@ -136,7 +135,6 @@ import {
136
135
  getSessionUsages,
137
136
  getUsage,
138
137
  isFreshUnboundBind,
139
- jsonlContextForCwd,
140
138
  recordChatRateLimit,
141
139
  recordChatUsageSnapshot,
142
140
  readAccountUsageSnapshot,
@@ -145,12 +143,14 @@ import {
145
143
  turnActiveForCwd,
146
144
  } from "./usage.mjs"
147
145
  import {
148
- clearChatSignal,
149
- getChatSignal,
150
146
  getPromptAcceptedAt,
151
- recordChatActivity,
152
- recordChatStreamTick,
153
147
  recordPromptAccepted,
148
+ readRowChatState,
149
+ conversationContext,
150
+ loadResumeChatContext,
151
+ representativeRows,
152
+ getRepresentativeConversation,
153
+ hasRepresentativeRow,
154
154
  } from "./chat-signals.mjs"
155
155
  import { cliKindForSessionName, sessionNameFor } from "./session-names.mjs"
156
156
  import {
@@ -1071,7 +1071,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1071
1071
  // そのまま browser に転送する。SDK 未インストール時は claudeBridge=null で全 attach が
1072
1072
  // claude.error を返す経路に分岐 (dispatch 側で判定)。
1073
1073
  if (claudeBridge) {
1074
- claudeBridge.on("event", ({ stream_id, session_id, cwd, event }) => {
1074
+ claudeBridge.on("event", ({ stream_id, session_id, cwd, session, event }) => {
1075
1075
  // SDK の rate_limit_event を捕捉して usage の 5h/7d 実値に反映する
1076
1076
  // (チャットモードでは statusLine が更新されないため、これが live 値の唯一の源)。
1077
1077
  if (event?.type === "rate_limit_event" && event.rate_limit_info) {
@@ -1081,76 +1081,10 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1081
1081
  /* ignore */
1082
1082
  }
1083
1083
  }
1084
- // Hub ホスト型 Cockpit 用: SDK event から per-session の状態をチャット信号として
1085
- // 記録する。startStateLoop が cwd 一致で session.state (ステータスドット/各行
1086
- // ドーナツ) と session.event (ソート) に橋渡しする。チャットは tmux ペインに
1087
- // TUI を出さず、capture-pane スクレイプも bundle hook も発火しないための補完。
1088
- // - assistant (生成中) → processing + usage から context%
1089
- // - session_state_changed:idle → waiting
1090
- // result は結果 flush であり、SDK が権威的ターン終了と定義する idle より早く届く。
1091
- // assistant / idle はターンの進行/完了 = permission 応答後に必ず流れるため、
1092
- // ここで inputPending を落として「確認待ち」ドット/通知を自己解消させる。
1093
- if (event?.type === "assistant") {
1094
- // 分母は result で学習したセッション固有の窓サイズ (無ければグローバル概算)。
1095
- // マシン全体で 1 個の statusLine 値を使うと、別モデルの並走で 5 倍ずれる。
1096
- const pct = resolveChatContextPct(cwd, "claude", event)
1097
- try {
1098
- // cliKind は chat-signals のキーの一部 (T05945)。同一 cwd で走る codex の信号と
1099
- // 混ざらないよう、claude 由来のイベントは必ず "claude" バケットへ書く。
1100
- // sessionId は同一 CLI 内でのセッション乗り換え検知用 identity (S1)。
1101
- recordChatActivity(cwd, "claude", {
1102
- status: "processing",
1103
- contextPct: pct,
1104
- inputPending: false,
1105
- sessionId: session_id || null,
1106
- })
1107
- } catch {
1108
- /* ignore */
1109
- }
1110
- } else if (event?.type === "result") {
1111
- // SDK の権威的な窓サイズ (result.modelUsage[model].contextWindow) をここで学習する。
1112
- resolveChatContextPct(cwd, "claude", event)
1113
- } else if (
1114
- event?.type === "system" &&
1115
- event?.subtype === "session_state_changed" &&
1116
- event?.state === "idle"
1117
- ) {
1118
- try {
1119
- recordChatActivity(cwd, "claude", {
1120
- status: "waiting",
1121
- inputPending: false,
1122
- sessionId: session_id || null,
1123
- })
1124
- } catch {
1125
- /* ignore */
1126
- }
1127
- } else if (event?.type === "stream_event") {
1128
- // RC4 (症状④): 拡張思考 (thinking_delta) や長時間ツール中の partial delta では
1129
- // assistant / result が流れず statusAt / updatedAtMs が前進しないため、>90s の無音で
1130
- // getChatSignal が CHAT_SIGNAL_STALE_MS により null を返し、capture-pane が idle な
1131
- // 純 SDK チャットで三点リーダーが消える。stream_event 受信を throttle 付き processing
1132
- // tick として記録し statusAt を前進させる (本文は読まずタイムスタンプ前進のみ)。
1133
- try {
1134
- recordChatStreamTick(cwd, "claude")
1135
- } catch {
1136
- /* ignore */
1137
- }
1138
- }
1084
+ resolveChatContextPct(session, event)
1139
1085
  client.send({ type: "claude.event", stream_id, session_id, event })
1140
1086
  })
1141
1087
  claudeBridge.on("permission", ({ stream_id, session_id, cwd, request_id, tool_name, input }) => {
1142
- // 確認待ち信号 (2026-06-02): (cwd, cliKind) キーで inputPending を立て、サイドバーの
1143
- // 「確認待ち」ドット & 通知の発火源にする。次の assistant/result で落ちる。
1144
- // これにより「裏で動いているセッションがどれか分からない」発見性の問題を解消する。
1145
- // T05945: 旧実装は cliKind を伝播しておらず、codex の承認カードでも claude 行の
1146
- // 確認待ちドットが点く (逆も然り) 交差汚染があった。CLI を明示して分離する。
1147
- if (cwd) {
1148
- try {
1149
- recordChatActivity(cwd, "claude", { inputPending: true })
1150
- } catch {
1151
- /* ignore */
1152
- }
1153
- }
1154
1088
  // 多端末共有: session_id を載せて backend が session group へ broadcast できるように
1155
1089
  // する (全端末に許可ダイアログ → 先着 1 件採用)。
1156
1090
  client.send({
@@ -1182,34 +1116,14 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1182
1116
  /* ignore */
1183
1117
  }
1184
1118
  }
1185
- if (cwd && request_ids?.length) {
1186
- // 確認待ちドットも落とす (次の assistant/result を待たずに自己解消させる)。
1187
- try {
1188
- recordChatActivity(cwd, "claude", { inputPending: false })
1189
- } catch {
1190
- /* ignore */
1191
- }
1192
- }
1193
1119
  },
1194
1120
  )
1195
- claudeBridge.on("exit", ({ stream_id, code, reason, session_id }) => {
1121
+ claudeBridge.on("exit", ({ stream_id, code, reason, session_id, cwd }) => {
1196
1122
  client.send({ type: "claude.exit", stream_id, code, reason, session_id })
1197
1123
  })
1198
1124
  claudeBridge.on("sessionoptions", (message) => {
1199
1125
  client.send(buildClaudeSessionOptionsPayload(message))
1200
1126
  })
1201
- claudeBridge.on("turnsettled", ({ cwd }) => {
1202
- // RC-4: result を届けずにターンが終わった (abort/異常終了)。チャット信号の
1203
- // status=processing を waiting に落として三点リーダー/ステータスドットの固着を防ぐ
1204
- // (正常完了は上の result ハンドラが既に waiting にしている)。
1205
- if (cwd) {
1206
- try {
1207
- recordChatActivity(cwd, "claude", { status: "waiting", inputPending: false })
1208
- } catch {
1209
- /* ignore */
1210
- }
1211
- }
1212
- })
1213
1127
  claudeBridge.on("error", ({ stream_id, session_id, error }) => {
1214
1128
  client.send({ type: "claude.error", stream_id, session_id, error })
1215
1129
  })
@@ -1218,97 +1132,15 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1218
1132
  // codex 版 (Slice 3): codex-stream-bridge.mjs の生 notification/permission/exit/error を
1219
1133
  // browser にそのまま転送する。claudeBridge と同じ「素通し」方針 (codex-stream-bridge.mjs
1220
1134
  // 冒頭コメント参照)。claude 版にある rate_limit/MCP control query 相当の処理は無い
1221
- // (codex 側にまだ存在しない/後続スライス)。ステータスドット用のチャット信号は
1222
- // turn/started・turn/completed のみ最小限で反映する (claude の assistant/result 相当)。
1135
+ // (codex 側にまだ存在しない/後続スライス)。live usage だけを producer 単位で記録する。
1223
1136
  if (codexBridge) {
1224
- // T06407 増分2 (codex-review round1 major#3): この producer(session.seq) が cwd の
1225
- // **権威 producer** (liveSessionForCwd = 会話ターン最新) かを判定する。チャット信号
1226
- // (recordChatActivity) の context/sessionId は権威 producer の分だけ書く。非権威 producer
1227
- // の context/sessionId をチャット信号へ書くと、権威 thread の live 未取得の窓で overlay 経由
1228
- // に混入し、usage.response (権威 producer ベース) と分岐してドーナツが前後する。
1229
- const isCodexAuthority = (cwd, producerSeq) =>
1230
- typeof producerSeq === "number" &&
1231
- codexBridge?.liveSessionForCwd?.(cwd)?.seq === producerSeq
1232
1137
  codexBridge.on("event", ({ stream_id, producer_seq, thread_id, cwd, notification }) => {
1233
- if (notification.method === "turn/started") {
1234
- // S1 (2026-07): contextPct を渡していなかったため、Codex 選択中は状態ドット
1235
- // (processing/waiting) こそ更新されるがコンテキスト% は claude 由来の古い値の
1236
- // ままだった (症状③の一因)。codexUsageForThread は rollout ファイルの I/O を
1237
- // 伴うため fire-and-forget (await せず、取得できた時点で反映)。
1238
- ;(async () => {
1239
- let contextPct = null
1240
- try {
1241
- const usage = await codexUsageForThread(thread_id, { producerSeq: producer_seq })
1242
- contextPct = typeof usage?.context === "number" ? usage.context : null
1243
- } catch {
1244
- /* ignore */
1245
- }
1246
- try {
1247
- recordChatActivity(
1248
- cwd,
1249
- "codex",
1250
- buildCodexChatPatch("processing", contextPct, thread_id, isCodexAuthority(cwd, producer_seq)),
1251
- )
1252
- } catch {
1253
- /* ignore */
1254
- }
1255
- })()
1256
- } else if (notification.method === "turn/completed") {
1257
- ;(async () => {
1258
- let contextPct = null
1259
- try {
1260
- const usage = await codexUsageForThread(thread_id, { producerSeq: producer_seq })
1261
- contextPct = typeof usage?.context === "number" ? usage.context : null
1262
- } catch {
1263
- /* ignore */
1264
- }
1265
- try {
1266
- recordChatActivity(
1267
- cwd,
1268
- "codex",
1269
- buildCodexChatPatch("waiting", contextPct, thread_id, isCodexAuthority(cwd, producer_seq)),
1270
- )
1271
- } catch {
1272
- /* ignore */
1273
- }
1274
- })()
1275
- } else if (notification.method === "thread/tokenUsage/updated") {
1276
- // T06407 増分2 (症状⑥ ドーナツ前後動の根治): App Server の live tokenUsage を
1277
- // context% の正本にする。**producer(session.seq) 単位**でストアへ記録し (usage.response /
1278
- // state loop が cwd の権威 producer の seq で参照する)、同時にチャット信号 (overlay 経路) の
1279
- // context_pct も同じ live 値で更新する。status は上書きしない (このイベントはターン進行中に
1280
- // 届くため keep-prev。processing/waiting は turn/started・turn/completed が管理する)。
1281
- const tokenUsage = notification.params?.tokenUsage
1282
- if (typeof producer_seq === "number" && tokenUsage) {
1283
- try {
1284
- recordCodexLiveTokenUsage(producer_seq, tokenUsage)
1285
- } catch {
1286
- /* ignore */
1287
- }
1288
- // major#3: チャット信号の context/sessionId は権威 producer のときだけ書く。
1289
- if (isCodexAuthority(cwd, producer_seq)) {
1290
- const contextPct = codexContextPercentFromTokenUsage(tokenUsage)
1291
- if (typeof contextPct === "number" && cwd) {
1292
- try {
1293
- recordChatActivity(cwd, "codex", { contextPct, sessionId: thread_id })
1294
- } catch {
1295
- /* ignore */
1296
- }
1297
- }
1298
- }
1299
- }
1138
+ if (notification.method === "thread/tokenUsage/updated") {
1139
+ recordCodexLiveTokenUsage(producer_seq, notification.params?.tokenUsage)
1300
1140
  }
1301
1141
  client.send({ type: "codex.event", stream_id, thread_id, event: notification })
1302
1142
  })
1303
1143
  codexBridge.on("permission", ({ stream_id, thread_id, cwd, request_id, method, params }) => {
1304
- if (cwd) {
1305
- try {
1306
- // T05945: codex の承認待ちは codex 行にだけ立てる (claude 行へ漏らさない)。
1307
- recordChatActivity(cwd, "codex", { inputPending: true })
1308
- } catch {
1309
- /* ignore */
1310
- }
1311
- }
1312
1144
  client.send({
1313
1145
  type: "codex.permission.request",
1314
1146
  stream_id,
@@ -1318,7 +1150,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1318
1150
  params,
1319
1151
  })
1320
1152
  })
1321
- codexBridge.on("exit", ({ stream_id, producer_seq, thread_id, code, reason }) => {
1153
+ codexBridge.on("exit", ({ stream_id, producer_seq, thread_id, code, reason, cwd }) => {
1322
1154
  // T06407 増分2: この producer(session.seq) の live tokenUsage スロットを破棄する。
1323
1155
  // seq は immutable なので、reattach で stream_id が付け替わっても alias slot / Map リークは
1324
1156
  // 生じない (stream_id キー時代の major#1 を解消)。
@@ -1554,31 +1386,6 @@ export async function refreshContextWindow() {
1554
1386
  }
1555
1387
  }
1556
1388
 
1557
- /**
1558
- * セッション単位で学習した context 窓サイズ (トークン)。
1559
- * キーは `${cliKind}\u0000${cwd}` (chat-signals と同じ粒度)。
1560
- *
1561
- * 【なぜセッション単位が要るか】(2026-07-10 監査)
1562
- * 旧実装は分子 (SDK assistant.usage = セッション単位) に対し、分母を
1563
- * resolvedContextWindow = statusLine cache (`~/.hub/usage/latest.json`) 由来の
1564
- * **マシン全体で 1 個のグローバル値**にしていた。同ファイルは「最後に statusLine を
1565
- * 書いた任意のセッション」の値なので、1M 窓の Opus と 200k 窓の Sonnet を同時に
1566
- * 走らせると、分母が取り違わってドーナツが 5 倍ずれる (分子と分母のスケール不一致)。
1567
- *
1568
- * SDK は `result.modelUsage[model].contextWindow` として **そのターンで実際に使われた
1569
- * モデルの窓サイズ**を毎ターン渡してくる。これが唯一の権威なので、result のたびに
1570
- * ここへ学習し、以降の assistant イベントの分母に使う。
1571
- */
1572
- const contextWindowByChatKey = new Map()
1573
-
1574
- /** 直近の assistant メッセージのモデル名。result の modelUsage から主対話モデルを選ぶ手掛かり。 */
1575
- const lastAssistantModelByChatKey = new Map()
1576
-
1577
- /** chat-signals と同じ (cwd, cliKind) 粒度のキーを作る。 */
1578
- export function chatKey(cwd, cliKind) {
1579
- return `${cliKind || "claude"}\u0000${cwd || ""}`
1580
- }
1581
-
1582
1389
  /**
1583
1390
  * SDK `result` の `modelUsage` から、そのセッションの context 窓サイズを選ぶ。
1584
1391
  *
@@ -1638,38 +1445,28 @@ export function contextPctFromUsage(u, contextWindow) {
1638
1445
  return Math.min(100, Math.round((tokens / win) * 1000) / 10)
1639
1446
  }
1640
1447
 
1641
- /**
1642
- * SDK チャットの assistant / result イベントから context% を解決し、
1643
- * 併せて `result.modelUsage` の窓サイズを (cwd, cliKind) 単位で学習する。
1644
- *
1645
- * ハンドラ本体から切り出して単体テスト可能にしている (学習 → 次ターンで反映、
1646
- * という状態遷移こそがこの機能の本体であり、純関数だけでは配線を検証できないため)。
1647
- *
1648
- * @returns {number|null} assistant のとき context% / それ以外は null
1649
- */
1650
- export function resolveChatContextPct(cwd, cliKind, event) {
1651
- const key = chatKey(cwd, cliKind)
1448
+ /** usage・主対話モデル・窓サイズは、イベントを生成した実体に保持する。 */
1449
+ export function resolveChatContextPct(session, event) {
1450
+ if (!session || session._closed) return null
1451
+ const context = conversationContext(session, "claude")
1652
1452
  if (event?.type === "assistant") {
1653
- const model = event.message?.model || null
1654
- if (model) lastAssistantModelByChatKey.set(key, model)
1655
- return event.message?.usage
1656
- ? contextPctFromUsage(event.message.usage, contextWindowByChatKey.get(key))
1657
- : null
1658
- }
1659
- if (event?.type === "result") {
1660
- // モデル切替やセッション回転が起きても次の result で必ず上書きされる (自己修復)。
1661
- const win = pickContextWindow(event.modelUsage, lastAssistantModelByChatKey.get(key))
1662
- if (win) contextWindowByChatKey.set(key, win)
1453
+ if (event.message?.model) context.model = event.message.model
1454
+ if (event.message?.usage) {
1455
+ context.usage = event.message.usage
1456
+ context.live = true
1457
+ context.percent = contextPctFromUsage(context.usage, context.window)
1458
+ return context.percent
1459
+ }
1460
+ } else if (event?.type === "result") {
1461
+ const win = pickContextWindow(event.modelUsage, context.model)
1462
+ if (win) {
1463
+ context.window = win
1464
+ if (context.usage) context.percent = contextPctFromUsage(context.usage, win)
1465
+ }
1663
1466
  }
1664
1467
  return null
1665
1468
  }
1666
1469
 
1667
- /** テスト用: 学習済みの窓サイズ / モデル名をリセットする。 */
1668
- export function _resetContextWindowLearning() {
1669
- contextWindowByChatKey.clear()
1670
- lastAssistantModelByChatKey.clear()
1671
- }
1672
-
1673
1470
  /**
1674
1471
  * session_event の「サイドバー並び順に効くか (sort_affecting)」判定用の per-session 状態。
1675
1472
  * 「その session-run で実ユーザーターン (prompt_submit) を見たか」を持つ。
@@ -1979,7 +1776,7 @@ async function readAllSessionEvents() {
1979
1776
  // 複数 Cockpit タブは各自 8 秒 poll を持つため、位相がずれると agent は同じ
1980
1777
  // tmux/worktree/jsonl スナップショットを数秒おきに再構築してしまう。5 秒だけ成功値を
1981
1778
  // 共有し、同時要求は single-flight に畳む。create/kill/worktree 変更時は明示 clear する。
1982
- const tmuxSessionsSnapshotCache = new AsyncTtlCache({ ttlMs: 5_000 })
1779
+ export const tmuxSessionsSnapshotCache = new AsyncTtlCache({ ttlMs: 5_000 })
1983
1780
 
1984
1781
  /**
1985
1782
  * `/tmp/cockpit_session_events/<tmux_session>.json` を fs.watch して
@@ -2260,7 +2057,7 @@ export function enrichTmuxSessionsForListResponse(sessions, lastEventByName, get
2260
2057
  }
2261
2058
 
2262
2059
  /**
2263
- * state loop 内で「チャット信号 (getChatSignal) でペイン status/context_pct を上書きするか」
2060
+ * state loop 内で「チャット信号 (readRowChatState) でペイン status/context_pct を上書きするか」
2264
2061
  * を決める純関数 (startStateLoop から抽出、単体テスト用)。
2265
2062
  *
2266
2063
  * バグ修正 (2026-07): 従来コードは書き換え「後」の status で processing ガードを判定して
@@ -2273,7 +2070,7 @@ export function enrichTmuxSessionsForListResponse(sessions, lastEventByName, get
2273
2070
  *
2274
2071
  * @param {string} paneStatus capture-pane 由来の生 status (上書き前)
2275
2072
  * @param {number|null|undefined} paneContextPct capture-pane 由来の生 context_pct
2276
- * @param {{status?:string|null, context_pct?:number|null}|null} chat getChatSignal() の戻り値
2073
+ * @param {{status?:string|null, context_pct?:number|null}|null} chat readRowChatState() の戻り値
2277
2074
  * @returns {{status:string, contextPct:number|null|undefined}}
2278
2075
  */
2279
2076
  export function applyChatOverlay(paneStatus, paneContextPct, chat) {
@@ -2289,26 +2086,35 @@ export function applyChatOverlay(paneStatus, paneContextPct, chat) {
2289
2086
  return { status, contextPct }
2290
2087
  }
2291
2088
 
2089
+ function codexRowSession(bridge, cwd) {
2090
+ // 代表行(終了印を含む)は代表実体だけを見る。背景会話へ落ちると終了後も履歴 I/O が走る。
2091
+ if (hasRepresentativeRow(cwd, "codex")) {
2092
+ const rep = getRepresentativeConversation(cwd, "codex")
2093
+ return rep && !rep.dead ? rep : null
2094
+ }
2095
+ return bridge?.liveSessionForCwd?.(cwd) ?? null
2096
+ }
2097
+
2292
2098
  /**
2293
- * T06407 増分2 (codex-review round1 major#3): codex チャット信号 (recordChatActivity) の
2294
- * patch を組み立てる。status (processing/waiting) は cwd レベルの「ターン進行中か」信号なので
2295
- * 常に書き、**context_pct / sessionId は権威 producer のときだけ**含める。非権威 producer の
2296
- * context/sessionId をチャット信号へ書くと、権威 thread の live 未取得の窓で overlay 経由に
2297
- * 混入し、usage.response (権威 producer ベース) と分岐してドーナツが前後する。
2298
- *
2299
- * @param {'processing'|'waiting'} status
2300
- * @param {number|null} contextPct
2301
- * @param {string|null} threadId
2302
- * @param {boolean} isAuthority この producer が cwd の権威 (liveSessionForCwd) か
2303
- * @returns {{status:string, inputPending:boolean, contextPct?:number, sessionId?:string|null}}
2099
+ * codex 行の使用率を、行の代表会話の live 値で上書きするかを決める。
2100
+ * @returns {number|null}
2304
2101
  */
2305
- export function buildCodexChatPatch(status, contextPct, threadId, isAuthority) {
2306
- const patch = { status, inputPending: false }
2307
- if (isAuthority) {
2308
- patch.sessionId = threadId || null
2309
- if (typeof contextPct === "number") patch.contextPct = contextPct
2310
- }
2311
- return patch
2102
+ export function resolveCodexRowContextPct({ contextPct, paneIsProcessing, rowSession, liveContextPct }) {
2103
+ if (typeof rowSession?.seq !== "number" || paneIsProcessing) return contextPct
2104
+ if (typeof liveContextPct === "number") return liveContextPct
2105
+ if (rowSession.isFresh === true) return 0
2106
+ return contextPct
2107
+ }
2108
+
2109
+ /** 代表行は状態・使用率・ID・承認待ちを一度に上書きする。 */
2110
+ export function applyRowChatState(payload, cwd, kind) {
2111
+ const chat = readRowChatState(cwd, kind)
2112
+ if (!chat) return payload
2113
+ return { ...payload, status: chat.status, context_pct: chat.context_pct,
2114
+ context_session_id: chat.sessionId, chat_session_id: chat.sessionId,
2115
+ turn_active: chat.turn_active, inputPending: chat.inputPending, input_pending: chat.inputPending,
2116
+ ...(Object.hasOwn(payload, "pointer_context_pct") ? { pointer_context_pct: chat.context_pct } : {}),
2117
+ stable: chat.status !== "processing", proc_busy: false, child_busy: false, stalled: false }
2312
2118
  }
2313
2119
 
2314
2120
  /** 次ターンに使う desired options。現在ターンの setter 成功を観測した値ではない。 */
@@ -2450,7 +2256,7 @@ export function filterCodexSessionsByAuthority(codexSessions, producerByCwd, seq
2450
2256
  * `listSessionStatesFn` は単体テスト用の DI ポイント (既定は実 `listSessionStates`、tmux を
2451
2257
  * 起動する)。実運用では省略してよい。
2452
2258
  *
2453
- * @param {{ client: object, plugins: object, logger?: object, cwd: string, cliKind: 'claude'|'codex', sessionId: string|null, contextPct?: number|null, listSessionStatesFn?: Function }} args
2259
+ * @param {{ client: object, plugins: object, logger?: object, cwd: string, cliKind: 'claude'|'codex', sessionId: string|null, streamId?: string|null, contextPct?: number|null, listSessionStatesFn?: Function }} args
2454
2260
  */
2455
2261
  export async function pushImmediateChatState({
2456
2262
  client,
@@ -2459,6 +2265,7 @@ export async function pushImmediateChatState({
2459
2265
  cwd,
2460
2266
  cliKind,
2461
2267
  sessionId,
2268
+ streamId,
2462
2269
  contextPct = 0,
2463
2270
  listSessionStatesFn = listSessionStates,
2464
2271
  }) {
@@ -2468,23 +2275,26 @@ export async function pushImmediateChatState({
2468
2275
  for (const s of states) {
2469
2276
  if (s.cwd !== cwd) continue
2470
2277
  if (cliKindForSessionName(s.session_name) !== cliKind) continue
2278
+ const status = "waiting"
2279
+ // 代表行は直後の applyRowChatState が代表会話の実 ID で上書きする。
2280
+ const contextSessionId = sessionId ?? null
2471
2281
  tmuxSessionsSnapshotCache.clear()
2472
- client.send({
2282
+ client.send(applyRowChatState({
2473
2283
  type: "session.state",
2474
2284
  session_name: s.session_name,
2475
- status: "waiting",
2476
- context_pct: typeof contextPct === "number" ? contextPct : 0,
2477
- stable: true,
2285
+ status,
2286
+ context_pct: typeof contextPct === "number" ? contextPct : null,
2287
+ stable: status !== "processing",
2478
2288
  proc_busy: false,
2479
2289
  child_busy: false,
2480
2290
  stalled: false,
2481
- turn_active: false,
2291
+ turn_active: status == null ? null : status === "processing",
2482
2292
  cli_kind: cliKind,
2483
2293
  // context_pct がどの会話の値かを明示する。frontend は ID が変わったときだけ
2484
2294
  // 0% を採用し、同一会話の一時的な 0/null は直近の確定値を保持する。
2485
- context_session_id: sessionId ?? null,
2486
- chat_session_id: sessionId ?? null,
2487
- })
2295
+ context_session_id: contextSessionId,
2296
+ chat_session_id: contextSessionId,
2297
+ }, cwd, cliKind))
2488
2298
  }
2489
2299
  } catch (err) {
2490
2300
  logger?.warn?.({ err: err?.message, cwd }, "pushImmediateChatState failed")
@@ -2622,7 +2432,7 @@ export function decideTurnActiveSource({
2622
2432
  return "none"
2623
2433
  }
2624
2434
 
2625
- export function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, codexBridge, readinessTracker, boundSessions }) {
2435
+ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, codexBridge, readinessTracker, boundSessions, listSessionStatesFn = listSessionStates, writeSessionEventFileFn = writeSessionEventFile }) {
2626
2436
  const lastByName = new Map() // session_name → {status, context_pct}
2627
2437
  const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
2628
2438
  // session_name → 最後に書いた chat 由来 event 種別。エッジ駆動 (種別遷移時のみ書く) にして
@@ -2695,7 +2505,7 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2695
2505
  // 実コンテキスト窓サイズ (1M ベータ等) を反映。contextPctFromUsage の分母を
2696
2506
  // 5s ごとに最新化し、ドーナツが 200k 固定で振り切れるのを防ぐ。
2697
2507
  await refreshContextWindow()
2698
- const states = await listSessionStates({ plugins, logger })
2508
+ const states = await listSessionStatesFn({ plugins, logger })
2699
2509
  for (const s of states) {
2700
2510
  // T05945: この行がどの CLI のセッションかは **セッション名から構造的に決まる**
2701
2511
  // (`<dir>` = claude / `<dir>__codex` = codex)。chat 信号の読み出し・turn_active の
@@ -2704,50 +2514,18 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2704
2514
  // チャットモード補完: tmux ペインのスクレイプは TUI 前提で効かないため、
2705
2515
  // session の cwd に一致する新鮮なチャット信号があれば status/context% を
2706
2516
  // 上書きする (Hub ホスト型 Cockpit のステータスドット/各行ドーナツ用)。
2707
- // capture-pane が processing (= ペインに "esc to interrupt" が実在 = 本物の
2708
- // TUI が生成中) のときのみチャット信号で上書きしない (判定は applyChatOverlay
2709
- // 内部で必ず「上書き前の pane 由来 status (s.status)」を使う。2026-07 バグ修正:
2710
- // 旧実装は書き換え後の status で判定していたため、SDK チャット生成中はドーナツが
2711
- // 一切更新されなかった)。素のシェル (SDK チャットのペイン) は processing を
2712
- // 返せないため、SDK チャットの補完は従来どおり効く。TUI 生成中に stale な SDK
2713
- // status (waiting) が勝ってステータスドット/三点リーダーが消える事故の防止
2714
- // (2026-06-12) は paneIsProcessing ガードのまま維持される。
2517
+ // 代表が設定済みなら、その会話の値だけを使い、欠けた値は null とする。
2518
+ // 代表未設定の行は従来の applyChatOverlay / pane processing ガードを使う。
2715
2519
  // (cwd, cliKind) キーで自 CLI のチャット信号だけを読む。同一 cwd の別 CLI 行が
2716
2520
  // 立てた status/context_pct を拾わない (交差汚染の遮断)。
2717
- const chat = s.cwd ? getChatSignal(s.cwd, sessionCliKind) : null
2521
+ const representative = hasRepresentativeRow(s.cwd, sessionCliKind)
2522
+ const chat = s.cwd ? readRowChatState(s.cwd, sessionCliKind) : null
2718
2523
  const overlay = applyChatOverlay(s.status, s.context_pct, chat)
2719
- let status = overlay.status
2720
- let contextPct = overlay.contextPct
2721
- if (chat) {
2722
- // チャットのターン境界を sort / 状態用 session-event に橋渡しする。
2723
- // tmux ペインが動かず bundle hook が発火しないため、ここで代替発火する。
2724
- //
2725
- // ⚠️ **エッジ駆動** (種別が変わったときだけ書く)。旧実装は turnAt が前進するたびに
2726
- // 書いており、turnAt は assistant イベント毎に前進するため **生成中ずっと
2727
- // prompt_submit を書き続けていた**。並行生成中のセッション同士が assistant の
2728
- // 到着順で入れ替わり、サイドバーが揺れる原因になる (2026-07-29)。
2729
- // 種別遷移でのみ書けば、prompt_submit は生成開始の 1 回だけになる。
2730
- //
2731
- // 確認待ち (inputPending) は専用 event "input_required"、生成中は "prompt_submit"、
2732
- // それ以外は "stop"。並び順に効くのは computeSortAffecting により prompt_submit だけ。
2733
- const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
2734
- const prevEv = lastChatEventByName.get(s.session_name)
2735
- const eventForTick = chatSessionEventForTick({
2736
- chat,
2737
- prevEv,
2738
- prevTurnAt,
2739
- directAt: s.cwd ? getPromptAcceptedAt(s.cwd, sessionCliKind) : undefined,
2740
- })
2741
- if (eventForTick) {
2742
- const { ev, at } = eventForTick
2743
- lastTurnAtByName.set(s.session_name, chat.turnAt)
2744
- lastChatEventByName.set(s.session_name, ev)
2745
- writeSessionEventFile(s.session_name, ev, at).catch(() => {})
2746
- }
2747
- }
2524
+ let status = representative ? chat?.status ?? null : overlay.status
2525
+ let contextPct = representative ? chat?.context_pct ?? null : overlay.contextPct
2748
2526
  // 内容安定で「確実に停止」と判定できたか (frontend が 2 サンプル消灯確認を省いて
2749
2527
  // 即消灯するためのフラグ)。chat 信号で processing へ上書きされた場合は false。
2750
- const stable = status !== "processing" && s.stable === true
2528
+ let stable = status !== "processing" && s.stable === true
2751
2529
  // プロセス内省由来の busy レベル (Stop フック/ツール実行中=true)。frontend は
2752
2530
  // これでペイン由来の早期消灯を抑止し、tool/Stop フック中もスピナーを保つ。
2753
2531
  // 高速 readiness loop が維持する値をそのまま相乗せする (この loop では計算しない)。
@@ -2786,65 +2564,30 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2786
2564
  now: nowMs,
2787
2565
  })
2788
2566
 
2789
- // turn_active: ターンが現在進行中か (true=生成/ツール実行中 / false=待機中 / null=不明)。
2790
- // CockpitStatusDot の青/赤の権威ソース。サイドバーのドットと送信/停止ボタンを同じ真実で
2791
- // 同期させ、Esc 中断後の青固着 (PROMPT_STALE_SHORT_MS=60s) を解消する。
2792
- // - チャットモード: SDK の rate_limit_event/assistant が最も新鮮なので chat.status を採用
2793
- // - TUI モード: bound session の jsonl 末尾 assistant.stop_reason から判定 (turnActiveForCwd)
2794
- // bound session_id を渡すことで、同一 cwd dir 内の別セッション / サブエージェント /
2795
- // headless `claude -p` / `/clear` ローテの jsonl を「最新」と取り違えて turn_active を
2796
- // 誤る (cross-activity contamination = メタ欠陥#1) のを防ぐ。
2797
- // T2: tool_use 末尾 active 中の中断マーカー無しクラッシュ判定のため、tmux pane_current_command
2798
- // から claude プロセス生存を 3 値で返す paneClaudeAliveOrUnknown を probe として渡す。
2799
- // 'dead' (シェル前景)→active=false に倒す。'alive'/'unknown' は active 維持(偽陰性ゼロ)。
2800
- // - チャット信号が stale (90s 無活動) でこの else-if に落ちるケース: SDK チャットは
2801
- // tmux bind 台帳 (tuiReboundSessions) を持たないため、従来は boundSessionId が常に
2802
- // null を返し mtime 最新 jsonl へフォールバックしていた (chat 版の cross-activity
2803
- // 汚染, 2026-07 発覚)。resolveStateLoopBoundSessionId が claudeBridge のライブ
2804
- // セッション id へフォールバックすることで TUI と同じ方式で根治する。
2805
- // - Codex (T05699 残タスク #1, 2026-07-04): この cwd にライブな codexBridge セッション
2806
- // (chat モード) があれば、chat 信号の 90s TTL に依存しない rollout ベースの判定
2807
- // (codexTurnActiveForThread, task_started/task_complete イベント対) を使う。長時間
2808
- // ターン中に chat 信号が stale 化してドットが消える欠落を根治する。Claude 版のような
2809
- // paneAliveProbe は渡さない (chat モードのペインは素のシェルのまま = 実処理は別
2810
- // プロセスのため、pane 生存判定を適用すると誤って dead 扱いになる)。cwd がライブな
2811
- // codex セッションと一致しなければ従来通り Claude jsonl (turnActiveForCwd) にフォール
2812
- // バックする (TUI/bare codex pane は現状 bound thread 解決が無いため、これまで通り
2813
- // null=不明のまま、退行はしない)。
2814
- // 分岐の優先順位は decideTurnActiveSource (純関数、単体テスト済み) が権威。
2567
+ // 代表行は下の同期読み出しが turn_active も確定する。
2568
+ // 代表未設定の行だけ、従来の履歴・pane probe を使う。
2815
2569
  let turnActive = null
2816
2570
  // codex 行のときだけ rollout ベース判定の材料を引く (claude 行では常に null)。
2817
- // cwd の**権威 producer session** を 1 度だけ引き、threadId(turn_active 判定用) と
2818
- // seq(context% の live source 参照キー) の両方を同一 session から取る。これで
2819
- // usage.response の per-cwd 集計 (同じ liveSessionForCwd) と同じ producer を選び収束する
2820
- // (axis2)。liveSessionForCwd は会話ターン最新 (lastTurnAtMs) の一意規則。
2571
+ // 行の**代表会話の session** を 1 度だけ引き、threadId(turn_active 判定用・会話 ID) と
2572
+ // seq(context% の live source 参照キー) の両方を同一 session から取る。⚠️ 別々に引くと
2573
+ // 「背景会話の使用率に代表会話の ID が付く」不整合になる。usage.response と同じ
2574
+ // codexRowSession を使い、代表が live でなければ null(別会話の値で埋めない)。
2821
2575
  const codexAuthSession =
2822
2576
  s.cwd && sessionCliKind === "codex"
2823
- ? codexBridge?.liveSessionForCwd?.(s.cwd) || null
2577
+ ? codexRowSession(codexBridge, s.cwd)
2824
2578
  : null
2825
2579
  const codexThreadId = codexAuthSession?.threadId || null
2826
2580
  // T06407 増分2 (axis3): codex 行の context%(ドーナツ) は TTL 非依存の live store
2827
2581
  // (thread/tokenUsage/updated 由来) を権威 producer(seq) で引き、chat overlay より優先して
2828
- // 恒久収束させる。ただし **applyChatOverlay と同じ paneIsProcessing ガードを尊重する**:
2829
- // 本物の TUI が生成中 (capture-pane が processing = "esc to interrupt" 実在) のときは、
2830
- // その pane 由来の実 context_pct を stale な live 値で上書きしない (warm chat と Codex TUI
2831
- // 併存時の TUI ドーナツ破壊を防ぐ。chat overlay は既に上書きを見送っている)。素のシェル
2832
- // (chat モードのペイン) は processing を返せないため override は従来通り効く。
2833
- const paneIsProcessing = s.status === "processing"
2834
- if (
2835
- sessionCliKind === "codex" &&
2836
- typeof codexAuthSession?.seq === "number" &&
2837
- !paneIsProcessing
2838
- ) {
2839
- const liveCtx = codexLiveContextForProducer(codexAuthSession.seq)
2840
- if (typeof liveCtx === "number") {
2841
- contextPct = liveCtx
2842
- } else if (codexAuthSession.isFresh === true) {
2843
- // 新規 Codex thread は thread/start の時点で ID だけ確定し、最初の
2844
- // usage.response までは使用量がない。この間に tmux の旧 pane 値を
2845
- // 同じ新 thread ID で送ると、即時 push した 0% を打ち消してしまう。
2846
- contextPct = 0
2847
- }
2582
+ // 恒久収束させる。代表未設定の行だけ、従来の pane processing ガードを使う。
2583
+ const paneIsProcessing = !representative && s.status === "processing"
2584
+ if (sessionCliKind === "codex") {
2585
+ const liveCtx = typeof codexAuthSession?.seq === "number" && !paneIsProcessing
2586
+ ? codexLiveContextForProducer(codexAuthSession.seq)
2587
+ : null
2588
+ contextPct = resolveCodexRowContextPct({
2589
+ contextPct, paneIsProcessing, rowSession: codexAuthSession, liveContextPct: liveCtx,
2590
+ })
2848
2591
  }
2849
2592
  // fresh bind は実 session_id がまだ確定していない。tmux.list の statusLine / cwd 最新
2850
2593
  // JSONL は直前会話の値を返し得るため、この間は必ず 0% を正本にする。これをしないと
@@ -2852,11 +2595,11 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2852
2595
  // ドーナツを復活させる。
2853
2596
  const freshUnbound =
2854
2597
  sessionCliKind === "claude" && isFreshUnboundBind(boundSessions?.get(s.session_name))
2855
- if (freshUnbound) contextPct = 0
2856
- const turnActiveSource = decideTurnActiveSource({
2598
+ if (freshUnbound && !representative) contextPct = 0
2599
+ const turnActiveSource = representative ? "representative" : decideTurnActiveSource({
2857
2600
  chatStatus: chat?.status,
2858
2601
  cwd: s.cwd,
2859
- isFreshUnbound: freshUnbound,
2602
+ isFreshUnbound: freshUnbound && !representative,
2860
2603
  codexThreadId,
2861
2604
  sessionCliKind,
2862
2605
  })
@@ -2882,10 +2625,10 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2882
2625
  // 離れて detach され this.sessions から外れた warm セッションでも、稼働中ターンの turn_active
2883
2626
  // が維持され、非選択行のドットが赤へ誤落ちしない。加えて bare-shell pane を dead 判定して
2884
2627
  // 赤へ倒す paneAliveProbe 経路 (チャットは実体が SDK 子プロセスで pane は素のシェル) も回避する。
2885
- // chatTurnActiveForCwd が null (= この cwd にライブチャットセッション無し = 純 TUI / bare pane)
2886
- // のときだけ、従来の jsonl 末尾 + pane probe 経路にフォールバックする (TUI 挙動は不変)。
2628
+ // 代表があれば、その会話の busy だけを読む。不在なら null のままにする。
2629
+ // 代表未設定の行だけ、従来の cwd 最新 / jsonl / pane probe を使う。
2887
2630
  const chatActive = claudeBridge?.chatTurnActiveForCwd?.(s.cwd)
2888
- if (chatActive === true || chatActive === false) {
2631
+ if (representative || chatActive === true || chatActive === false) {
2889
2632
  turnActive = chatActive
2890
2633
  } else {
2891
2634
  turnActive = await turnActiveForCwd(
@@ -2917,79 +2660,46 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2917
2660
  ? boundSessions?.get(s.session_name) ?? `unbound:${s.session_name}`
2918
2661
  : codexThreadId)
2919
2662
 
2920
- const prev = lastByName.get(s.session_name)
2921
- if (
2922
- !prev ||
2923
- prev.status !== status ||
2924
- prev.context_pct !== contextPct ||
2925
- prev.stable !== stable ||
2926
- prev.proc_busy !== procBusy ||
2927
- prev.child_busy !== childBusy ||
2928
- prev.stalled !== stalled ||
2929
- prev.turn_active !== turnActive ||
2930
- prev.cli_kind !== chatCliKind ||
2931
- prev.context_session_id !== contextSessionId
2932
- ) {
2933
- lastByName.set(s.session_name, {
2934
- status,
2935
- context_pct: contextPct,
2936
- stable,
2937
- proc_busy: procBusy,
2938
- child_busy: childBusy,
2939
- stalled,
2940
- turn_active: turnActive,
2941
- cli_kind: chatCliKind,
2942
- context_session_id: contextSessionId,
2943
- // 旧クライアント互換。新規 consumer は context_session_id を使う。
2944
- chat_session_id: contextSessionId,
2663
+ // 履歴・pane の非同期処理が終わった後に、現在の実体を同期で読む。
2664
+ const payload = applyRowChatState({
2665
+ status, context_pct: contextPct, stable, proc_busy: procBusy, child_busy: childBusy,
2666
+ stalled, turn_active: turnActive, cli_kind: chatCliKind,
2667
+ context_session_id: contextSessionId, chat_session_id: contextSessionId,
2668
+ }, s.cwd, sessionCliKind)
2669
+ const currentChat = readRowChatState(s.cwd, sessionCliKind)
2670
+ if (currentChat) {
2671
+ // チャットのターン境界を sort / 状態用 session-event に橋渡しする。
2672
+ // tmux ペインが動かず bundle hook が発火しないため、ここで代替発火する。
2673
+ //
2674
+ // ⚠️ **エッジ駆動** (種別が変わったときだけ書く)。旧実装は turnAt が前進するたびに
2675
+ // 書いており、turnAt は assistant イベント毎に前進するため **生成中ずっと
2676
+ // prompt_submit を書き続けていた**。並行生成中のセッション同士が assistant の
2677
+ // 到着順で入れ替わり、サイドバーが揺れる原因になる (2026-07-29)。
2678
+ // 種別遷移でのみ書けば、prompt_submit は生成開始の 1 回だけになる。
2679
+ //
2680
+ // 確認待ち (inputPending) は専用 event "input_required"、生成中は "prompt_submit"、
2681
+ // それ以外は "stop"。並び順に効くのは computeSortAffecting により prompt_submit だけ。
2682
+ const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
2683
+ const prevEv = lastChatEventByName.get(s.session_name)
2684
+ const eventForTick = chatSessionEventForTick({
2685
+ chat: currentChat,
2686
+ prevEv,
2687
+ prevTurnAt,
2688
+ directAt: s.cwd ? getPromptAcceptedAt(s.cwd, sessionCliKind) : undefined,
2945
2689
  })
2946
- // 計装 (2026-06-19, 既定 OFF=2026-06-20): 状態変化時に値を記録する。常時 info はログ肥大の
2947
- // 主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す。childBusy/outputActive を分けて出し、
2948
- // proc 内省と出力フローのどちらが busy を立てたか切り分け可能にする(障害解析用)。
2949
- if (STATE_TRACE) {
2950
- logger?.info(
2951
- {
2952
- session_name: s.session_name,
2953
- status,
2954
- proc_busy: procBusy,
2955
- child_busy: childBusy,
2956
- output_active: outputActive,
2957
- stalled,
2958
- armed,
2959
- stable,
2960
- context_pct: contextPct,
2961
- turn_active: turnActive,
2962
- cli_kind: chatCliKind,
2963
- context_session_id: contextSessionId,
2964
- },
2965
- "session.state push",
2966
- )
2690
+ if (eventForTick) {
2691
+ const { ev, at } = eventForTick
2692
+ lastTurnAtByName.set(s.session_name, currentChat.turnAt)
2693
+ lastChatEventByName.set(s.session_name, ev)
2694
+ writeSessionEventFileFn(s.session_name, ev, at).catch(() => {})
2967
2695
  }
2968
- // state push より古い一覧を後続 poll が返すと sidebar 行の status/context が
2969
- // 逆戻りするため、送信前に snapshot 世代を切り替える。
2696
+ }
2697
+ const prev = lastByName.get(s.session_name)
2698
+ if (!prev || Object.keys(payload).some((key) => prev[key] !== payload[key])) {
2699
+ lastByName.set(s.session_name, payload)
2700
+ if (STATE_TRACE) logger?.info({ session_name: s.session_name, ...payload }, "session.state push")
2970
2701
  tmuxSessionsSnapshotCache.clear()
2971
- client.send({
2972
- type: "session.state",
2973
- session_name: s.session_name,
2974
- status,
2975
- context_pct: contextPct,
2976
- stable,
2977
- proc_busy: procBusy,
2978
- // child_busy: outputActive を含まない真の子プロセス busy。frontend の shouldQueue が
2979
- // 出力残り火 (proc_busy 残存) でアイドルなのにキューへ逃がす誤キュー (症状A) を断つために使う。
2980
- child_busy: childBusy,
2981
- stalled,
2982
- // turn_active: ステータスドット 4 状態統一の権威ソース。null は旧クライアント互換
2983
- // (frontend は欠落時に旧 prompt_submit/stale 降格ロジックへフォールバック)。
2984
- turn_active: turnActive,
2985
- // cli_kind (T05945): セッション名由来の構造値 ('claude' | 'codex')。frontend の
2986
- // 単一セレクタ (useCockpitSessionDisplay) が「この push はどの CLI 行のものか」を
2987
- // 判定する唯一の軸。context_session_id は context% の会話 ID。
2988
- cli_kind: chatCliKind,
2989
- context_session_id: contextSessionId,
2990
- // 旧クライアント互換。新規 consumer は context_session_id を使う。
2991
- chat_session_id: contextSessionId,
2992
- })
2702
+ client.send({ type: "session.state", session_name: s.session_name, ...payload })
2993
2703
  }
2994
2704
  // 消滅セッションの GC (perf監査/メモリリーク対策): tmux から消えたセッションの状態 Map を
2995
2705
  // まとめて解放する。clear() は再接続時(onReopen)のみのため、worktree 案件の生成/破棄で
@@ -3033,6 +2743,7 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
3033
2743
  t0.unref?.()
3034
2744
 
3035
2745
  return {
2746
+ tick,
3036
2747
  stop() {
3037
2748
  stopped = true
3038
2749
  clearInterval(ti)
@@ -3326,7 +3037,7 @@ export function handleClaudeChatInput(msg, ctx) {
3326
3037
  // 書かれる。bridge が拒否しても取り消さない (仕様。notifyChatPromptSubmit の JSDoc)。
3327
3038
  const cwdSnapshot = ctx.claudeBridge.getCwd?.(stream_id) ?? null
3328
3039
  if (cwdSnapshot) {
3329
- recordPromptAccepted(cwdSnapshot, "claude", acceptedAt)
3040
+ recordPromptAccepted(cwdSnapshot, "claude", acceptedAt, ctx.claudeBridge.sessions?.get(stream_id), ctx.claudeBridge)
3330
3041
  notifyChatPromptSubmit({
3331
3042
  cwd: cwdSnapshot,
3332
3043
  cliKind: "claude",
@@ -3552,7 +3263,7 @@ export function handleCodexChatInput(msg, ctx) {
3552
3263
  // (handleClaudeChatInput のコメント参照)。
3553
3264
  const cwdSnapshot = ctx.codexBridge.getCwd?.(stream_id) ?? null
3554
3265
  if (cwdSnapshot) {
3555
- recordPromptAccepted(cwdSnapshot, "codex", acceptedAt)
3266
+ recordPromptAccepted(cwdSnapshot, "codex", acceptedAt, ctx.codexBridge.sessions?.get(stream_id), ctx.codexBridge)
3556
3267
  notifyChatPromptSubmit({
3557
3268
  cwd: cwdSnapshot,
3558
3269
  cliKind: "codex",
@@ -3833,17 +3544,14 @@ export async function buildUsagePayload(ctx) {
3833
3544
  // 別数値のため、`usage` にマージせず独立フィールド `codexUsage`/`codexSessions`
3834
3545
  // として返す (frontend 側でどちらのアカウントの残量か混同しないため)。
3835
3546
  let codexUsage = null
3547
+ let codexContextCwd = null
3836
3548
  let codexSessions = []
3837
3549
  const activeCodexProfile = await codexProfiles.getActiveProfile()
3838
3550
  const codexHasCredentials = await codexProfiles.hasCodexCredentials(activeCodexProfile.configDir)
3839
3551
  if (ctx.codexBridge && codexHasCredentials) {
3840
3552
  try {
3841
- // T06407 増分2 (axis2): per-cwd の context% は **cwd ごとに権威 producer 1 件**へ
3842
- // 集約する。旧実装は全 live session を codexSessions へ流していたため、同一 cwd に
3843
- // 複数 thread があると frontend の usageByCwd (updatedAtMs 同値で first-wins) が
3844
- // 任意のスレッドを拾い、state loop (権威 producer) と別 thread を選んでドーナツが
3845
- // 前後した。liveSessionForCwd の同一規則で cwd → 権威 producer(session) を一意化し、
3846
- // producerSeq を context% の live source として通して両経路を収束させる。
3553
+ // キャッシュは従来の cwd / アカウントデータだけを作る。
3554
+ // 代表会話の値は handleUsageRequest の送信直前に付ける。
3847
3555
  const cwds = new Set()
3848
3556
  for (const s of ctx.codexBridge.sessions.values()) {
3849
3557
  if (s.threadId && s.cwd) cwds.add(s.cwd)
@@ -3863,6 +3571,7 @@ export async function buildUsagePayload(ctx) {
3863
3571
  if (liveCodex.length > 0) {
3864
3572
  // 5h/7d はアカウント全体の値 (どの live session から読んでも同じ契約の残量)
3865
3573
  // なので、代表として最初の 1 件から取る (context% はその producer の live を使う)。
3574
+ codexContextCwd = liveCodex[0].cwd
3866
3575
  codexUsage = await codexUsageForThread(liveCodex[0].threadId, {
3867
3576
  producerSeq: liveCodex[0].producerSeq,
3868
3577
  })
@@ -3910,6 +3619,7 @@ export async function buildUsagePayload(ctx) {
3910
3619
 
3911
3620
  return {
3912
3621
  payload: { usage, sessions, codexUsage, codexSessions },
3622
+ codexContextCwd,
3913
3623
  timings: {
3914
3624
  credentials_ms: credentialsMs,
3915
3625
  account_usage_ms: accountUsageMs,
@@ -3920,6 +3630,27 @@ export async function buildUsagePayload(ctx) {
3920
3630
  }
3921
3631
  }
3922
3632
 
3633
+ /** キャッシュを変えず、要求の送信時点の代表で cwd ごとに置き換える。 */
3634
+ export function applyUsageRowChatState(payload, codexContextCwd = null) {
3635
+ const result = { ...payload }
3636
+ for (const [kind, field] of [["claude", "sessions"], ["codex", "codexSessions"]]) {
3637
+ const cwds = representativeRows(kind)
3638
+ if (cwds.length === 0) continue
3639
+ const selected = new Set(cwds)
3640
+ const entries = (payload[field] ?? []).filter((entry) => !selected.has(entry.cwd))
3641
+ for (const cwd of cwds) {
3642
+ const chat = readRowChatState(cwd, kind)
3643
+ entries.push({ cwd, sessionId: chat.sessionId, contextPercent: chat.context_pct,
3644
+ updatedAtMs: chat.turnAt, status: chat.status, inputPending: chat.inputPending,
3645
+ context_session_id: chat.sessionId, chat_session_id: chat.sessionId })
3646
+ }
3647
+ result[field] = entries
3648
+ }
3649
+ const codexChat = readRowChatState(codexContextCwd, "codex")
3650
+ if (codexChat && payload.codexUsage) result.codexUsage = { ...payload.codexUsage, context: codexChat.context_pct }
3651
+ return result
3652
+ }
3653
+
3923
3654
  /**
3924
3655
  * usage.request の処理。中身は TTL キャッシュ + single-flight で共有し、request_id
3925
3656
  * だけを要求ごとに差し込む。
@@ -3956,7 +3687,7 @@ export async function handleUsageRequest(msg, ctx, deps = {}) {
3956
3687
  }
3957
3688
  ctx.client.send({
3958
3689
  type: "usage.response",
3959
- ...result.payload,
3690
+ ...applyUsageRowChatState(result.payload, result.codexContextCwd),
3960
3691
  request_id: msg.request_id,
3961
3692
  })
3962
3693
  } catch (err) {
@@ -4343,60 +4074,15 @@ export async function dispatch(msg, ctx) {
4343
4074
  "failed to push Claude attach history",
4344
4075
  )
4345
4076
  })
4346
- // S1 (2026-07): セッション状態の identity 正本化。TUI の claude.tui.bind
4347
- // (main.mjs 内 clearChatSignal 呼び出し箇所参照) と対称に、SDK チャットの
4348
- // attach 時点でも前セッションのチャット信号を引き継がない。
4349
- // - fresh (resume 無し): 新規セッションで session_id はまだ確定しない
4350
- // (SDK が最初の assistant イベントで通知する)。ただし null を送ると frontend
4351
- // は「同一会話で一時的に0%になった」と判断して旧値を保持するため、stream_id
4352
- // 由来の一意な仮IDを先に送る。実 session_id が来た時点で置換される。
4353
- // - resume: bound session の jsonl から実 context% を解決し、cli_kind='claude' +
4354
- // 実 session_id で identity を確定させて即時 push する (症状③)。
4355
- const resolvedCwd = cwdArg || process.env.HOME || process.cwd()
4356
- if (resolvedCwd) {
4357
- if (!resumeSessionId) {
4358
- const freshContextSessionId = `fresh:${stream_id}`
4359
- clearChatSignal(resolvedCwd, "claude")
4360
- recordChatActivity(resolvedCwd, "claude", {
4361
- status: "waiting",
4362
- contextPct: 0,
4363
- inputPending: false,
4364
- sessionId: freshContextSessionId,
4365
- })
4366
- pushImmediateChatState({
4367
- client: ctx.client,
4368
- plugins: ctx.plugins,
4369
- logger: ctx.logger,
4370
- cwd: resolvedCwd,
4371
- cliKind: "claude",
4372
- sessionId: freshContextSessionId,
4373
- contextPct: 0,
4374
- }).catch(() => {})
4375
- } else {
4376
- jsonlContextForCwd(resolvedCwd, resumeSessionId)
4377
- .catch(() => null)
4378
- .then((jsonlCtx) => {
4379
- const contextPct =
4380
- typeof jsonlCtx?.percent === "number" ? jsonlCtx.percent : 0
4381
- recordChatActivity(resolvedCwd, "claude", {
4382
- status: "waiting",
4383
- contextPct,
4384
- inputPending: false,
4385
- sessionId: resumeSessionId,
4386
- })
4387
- return pushImmediateChatState({
4388
- client: ctx.client,
4389
- plugins: ctx.plugins,
4390
- logger: ctx.logger,
4391
- cwd: resolvedCwd,
4392
- cliKind: "claude",
4393
- sessionId: resumeSessionId,
4394
- contextPct,
4395
- })
4396
- })
4397
- .catch(() => {})
4398
- }
4077
+ const session = ctx.claudeBridge.sessions?.get(stream_id)
4078
+ const resolvedCwd = session?.cwd || cwdArg || process.env.HOME || process.cwd()
4079
+ const push = () => pushImmediateChatState({ client: ctx.client, plugins: ctx.plugins,
4080
+ logger: ctx.logger, cwd: resolvedCwd, cliKind: "claude" })
4081
+ if (session && resumeSessionId) {
4082
+ void loadResumeChatContext(session, "claude").then(push).catch(() => {})
4399
4083
  }
4084
+ void push().catch(() => {})
4085
+
4400
4086
  } catch (err) {
4401
4087
  ctx.client.send({
4402
4088
  type: "claude.error",
@@ -4781,68 +4467,14 @@ export async function dispatch(msg, ctx) {
4781
4467
  } catch (err) {
4782
4468
  ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events")
4783
4469
  }
4784
- // S1 (2026-07): claude.attach と対称の identity 正本化。Codex は thread/start
4785
- // 応答時点で threadId が確定するため (Claude の fresh とは異なり)、fresh でも
4786
- // 既に分かっている threadId を sessionId として記録する (直後の turn/started が
4787
- // 同じ threadId を伴って届いても identity 一致でリセットが起きないように)。
4788
- // - fresh (resume 無し): ゼロ値 + 確定済み threadId で即時 push (症状④)。
4789
- // - resume: codexUsageForThread で実 context% を解決し即時 push (症状③)。
4790
- if (resolvedCwd) {
4791
- // Stage4 caller レビュー major #6: fresh/resume の分岐は **実際の attach 結果**
4792
- // (info.resuming) で判定する。in-memory reattach は元 msg.resume_thread_id が空
4793
- // (fresh 再接続) でも既存 thread への復帰 = info.resuming=true なので、resume 側の
4794
- // 実 context% 解決に載せる (fresh 扱いで context を 0 に戻さない)。
4795
- if (!info.resuming) {
4796
- clearChatSignal(resolvedCwd, "codex")
4797
- recordChatActivity(resolvedCwd, "codex", {
4798
- status: "waiting",
4799
- contextPct: 0,
4800
- inputPending: false,
4801
- sessionId: info.threadId || null,
4802
- })
4803
- pushImmediateChatState({
4804
- client: ctx.client,
4805
- plugins: ctx.plugins,
4806
- logger: ctx.logger,
4807
- cwd: resolvedCwd,
4808
- cliKind: "codex",
4809
- sessionId: info.threadId || null,
4810
- contextPct: 0,
4811
- }).catch(() => {})
4812
- } else {
4813
- // 増分2: context% は cwd の権威 producer(seq) の live を正本にする (resume 直後の
4814
- // このセッションは lastTurnAtMs=now で権威。live 未取得なら rollout フォールバック)。
4815
- const resumeProducerSeq = ctx.codexBridge?.liveSessionForCwd?.(resolvedCwd)?.seq
4816
- codexUsageForThread(info.threadId, { producerSeq: resumeProducerSeq })
4817
- .catch(() => null)
4818
- .then((usage) => {
4819
- // ⚠️ codex-review round2 major#2: rollout I/O 中に別 session が権威化しうる。
4820
- // await 後に権威 seq を再検証し、この resume session が現権威でなければ即時 push を
4821
- // 見送る (古い context/sessionId を書かない。state loop が ≤5s で正す)。
4822
- const stillAuthority =
4823
- resumeProducerSeq != null &&
4824
- ctx.codexBridge?.liveSessionForCwd?.(resolvedCwd)?.seq === resumeProducerSeq
4825
- if (!stillAuthority) return undefined
4826
- const contextPct = typeof usage?.context === "number" ? usage.context : 0
4827
- recordChatActivity(resolvedCwd, "codex", {
4828
- status: "waiting",
4829
- contextPct,
4830
- inputPending: false,
4831
- sessionId: info.threadId,
4832
- })
4833
- return pushImmediateChatState({
4834
- client: ctx.client,
4835
- plugins: ctx.plugins,
4836
- logger: ctx.logger,
4837
- cwd: resolvedCwd,
4838
- cliKind: "codex",
4839
- sessionId: info.threadId,
4840
- contextPct,
4841
- })
4842
- })
4843
- .catch(() => {})
4844
- }
4470
+ const session = ctx.codexBridge.sessions?.get(stream_id)
4471
+ const push = () => pushImmediateChatState({ client: ctx.client, plugins: ctx.plugins,
4472
+ logger: ctx.logger, cwd: resolvedCwd, cliKind: "codex" })
4473
+ if (session && info.resuming) {
4474
+ void loadResumeChatContext(session, "codex").then(push).catch(() => {})
4845
4475
  }
4476
+ void push().catch(() => {})
4477
+
4846
4478
  } catch (err) {
4847
4479
  ctx.client.send({
4848
4480
  type: "codex.error",
@@ -5584,6 +5216,18 @@ export async function dispatch(msg, ctx) {
5584
5216
  case "chat.session.stop": {
5585
5217
  const result = { type: "chat.session.stop.result", request_id: msg.request_id, stopped: 0 }
5586
5218
  try {
5219
+ if (msg.scope === "cwd") {
5220
+ const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
5221
+ if (msg.cli_kind !== "claude" && msg.cli_kind !== "codex") result.error = "unsupported cli_kind"
5222
+ else if (!cwd || !path.isAbsolute(cwd)) result.error = "cwd required"
5223
+ else if (msg.cli_kind === "claude") {
5224
+ result.stopped = await ctx.claudeBridge.stopSessionsFor({ cwd, reason: "chat-stop" })
5225
+ } else {
5226
+ result.stopped = await ctx.codexBridge.stopSessionsForCwd({ cwd, reason: "chat-stop" })
5227
+ }
5228
+ ctx.client.send(result)
5229
+ return
5230
+ }
5587
5231
  if (msg.cli_kind === "claude") {
5588
5232
  const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
5589
5233
  const session_id = typeof msg.session_id === "string" ? msg.session_id : ""
@@ -5648,7 +5292,7 @@ export async function dispatch(msg, ctx) {
5648
5292
  bridges: { claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge },
5649
5293
  logger: ctx.logger,
5650
5294
  })
5651
- ctx.client.send({ type: "chat.sessions", request_id: msg.request_id, ...snapshot })
5295
+ ctx.client.send({ type: "chat.sessions", request_id: msg.request_id, ...refreshChatSessionsSignals(snapshot, ctx.logger) })
5652
5296
  } catch (err) {
5653
5297
  ctx.client.send({
5654
5298
  type: "chat.sessions", request_id: msg.request_id, targets: [], live: [],
@@ -5676,7 +5320,11 @@ export async function dispatch(msg, ctx) {
5676
5320
  const enriched = enrichTmuxSessionsForListResponse(
5677
5321
  sessions,
5678
5322
  lastEventByName,
5679
- ctx.getSessionStateSnapshot,
5323
+ (name) => {
5324
+ const row = sessions.find((item) => item.name === name)
5325
+ if (hasRepresentativeRow(row?.cwd, cliKindForSessionName(name))) return null
5326
+ return ctx.getSessionStateSnapshot?.(name)
5327
+ },
5680
5328
  // warm = このセッションへ依頼 (cockpit_orch.py send) を送れるか。sibling-inject の
5681
5329
  // 宛先候補列挙と同一関数 (live-sessions.mjs) を通す。
5682
5330
  (row) =>
@@ -5719,7 +5367,7 @@ export async function dispatch(msg, ctx) {
5719
5367
  type: "tmux.sessions",
5720
5368
  request_id: msg.request_id,
5721
5369
  ...snapshot,
5722
- sessions,
5370
+ sessions: sessions.map((row) => applyRowChatState(row, row.cwd, cliKindForSessionName(row.name))),
5723
5371
  })
5724
5372
  } catch (err) {
5725
5373
  ctx.client.send({