@cocorograph/hub-agent 0.7.85 → 0.7.86

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.85",
3
+ "version": "0.7.86",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -870,6 +870,11 @@ export class CodexStreamBridge extends EventEmitter {
870
870
  }
871
871
  this.emit("event", {
872
872
  stream_id,
873
+ // producer_seq: この通知を出した **session(producer) の immutable ID**(生成時に振る
874
+ // 単調増加 seq)。stream_id は購読端末で reattach/detach で付け替わるため、context% の
875
+ // live store キーには使えない (T06407 増分2: stale slot / Map リークの原因)。producer 単位で
876
+ // live tokenUsage を保持し、cwd の権威 producer で代表を選ぶために main.mjs へ渡す。
877
+ producer_seq: session.seq,
873
878
  thread_id: session.threadId,
874
879
  cwd: session.cwd,
875
880
  notification: { method: msg.method, params: msg.params },
@@ -957,6 +962,7 @@ export class CodexStreamBridge extends EventEmitter {
957
962
  }
958
963
  this.emit("exit", {
959
964
  stream_id: session.stream_id,
965
+ producer_seq: session.seq,
960
966
  thread_id: session.threadId,
961
967
  code: info.code,
962
968
  reason: info.signal ? `signal:${info.signal}` : "process-exit",
@@ -1316,21 +1322,51 @@ export class CodexStreamBridge extends EventEmitter {
1316
1322
  return out
1317
1323
  }
1318
1324
 
1325
+ /**
1326
+ * 指定 cwd の「権威セッション」を返す(T06407 増分2, axis2 の単一正本)。同一 cwd に複数
1327
+ * ライブ codex セッションがあるとき、**最終活性が最新のもの**(=いまユーザーが使っている
1328
+ * 会話)を選ぶ。sendToCwd の宛先選定と同じ規則に揃える。
1329
+ *
1330
+ * ⚠️ context%(ドーナツ)は thread 固有の値なので「どのセッションから読んでも同じ」という
1331
+ * 旧 rate-limit 前提(先着 1 件で十分)は成り立たない。state loop(threadIdForCwd 経由)と
1332
+ * usage.response(per-cwd 集計)が **別の thread を選ぶと両経路が収束せずドーナツが前後する**
1333
+ * ため、両経路をこの一意規則へ寄せる。「最新」は閲覧 keepalive で動く lastActivityMs では
1334
+ * なく会話ターン境界時刻 lastTurnAtMs を基準にする(見ているだけの古い会話が本命を追い越す
1335
+ * のを防ぐ)。
1336
+ *
1337
+ * @param {string} cwd
1338
+ * @returns {object|null} 権威セッション(無ければ null)
1339
+ */
1340
+ liveSessionForCwd(cwd) {
1341
+ if (!cwd) return null
1342
+ let best = null
1343
+ let bestAt = -1
1344
+ // 案B: distinct session で走査(多対一 Map の重複を除く)。
1345
+ for (const session of this._liveSessions()) {
1346
+ if (session.cwd !== cwd || !session.threadId) continue
1347
+ const at = session.lastTurnAtMs ?? session.lastActivityMs ?? 0
1348
+ if (at >= bestAt) {
1349
+ bestAt = at
1350
+ best = session
1351
+ }
1352
+ }
1353
+ return best
1354
+ }
1355
+
1319
1356
  /**
1320
1357
  * 指定 cwd に一致するライブ (attach 済み) codex セッションの threadId を返す。
1321
1358
  * Claude 版 `liveSessionIdForCwd` の対 (main.mjs の state loop / terminal mirror から
1322
- * cwd → threadId 解決に使う)。複数一致時は先に見つかったものを返す (5h/7d はアカウント
1323
- * 全体の値のためどのセッションから読んでも同じ契約の残量、代表 1 件で十分)。
1359
+ * cwd → threadId 解決に使う)。
1360
+ *
1361
+ * ⚠️ T06407 増分2: 旧実装は「先に見つかったもの」を返していたが、これは 5h/7d(アカウント
1362
+ * 全体で代表 1 件で十分)前提だった。context% は thread 固有なので liveSessionForCwd の
1363
+ * 権威規則(最終活性が最新)で解決し、usage.response の per-cwd 集計と収束させる。
1324
1364
  *
1325
1365
  * @param {string} cwd
1326
1366
  * @returns {string|null}
1327
1367
  */
1328
1368
  threadIdForCwd(cwd) {
1329
- if (!cwd) return null
1330
- for (const session of this.sessions.values()) {
1331
- if (session.cwd === cwd && session.threadId) return session.threadId
1332
- }
1333
- return null
1369
+ return this.liveSessionForCwd(cwd)?.threadId ?? null
1334
1370
  }
1335
1371
 
1336
1372
  /**
@@ -1526,6 +1562,7 @@ export class CodexStreamBridge extends EventEmitter {
1526
1562
  )
1527
1563
  this.emit("exit", {
1528
1564
  stream_id: session.stream_id,
1565
+ producer_seq: session.seq,
1529
1566
  thread_id: session.threadId,
1530
1567
  code: 0,
1531
1568
  reason: "idle-reaped",
@@ -1679,6 +1716,19 @@ export class CodexStreamBridge extends EventEmitter {
1679
1716
  ? "codex session killed MID-TURN by agent shutdown"
1680
1717
  : "codex session stopped by agent shutdown",
1681
1718
  )
1719
+ // T06407 増分2 (codex-review round2 major#1): shutdown 経由の撤去 (codex.profile.switch /
1720
+ // プロセス終了) でも producer live store を掃除する。この後の client.stop() が誘発する
1721
+ // client "exit" は _onClientExit が session.dead=true で early-return するため exit を
1722
+ // emit しない。ここで明示的に emit し、main.mjs の exit ハンドラ
1723
+ // (forgetCodexLiveTokenUsage) を必ず通す。これが無いと profile 切替ごとに producer Map が
1724
+ // リークする。dead は既に上で立てているので二重 emit にはならない。
1725
+ this.emit("exit", {
1726
+ stream_id: session.stream_id,
1727
+ producer_seq: session.seq,
1728
+ thread_id: session.threadId,
1729
+ code: 0,
1730
+ reason: "agent-shutdown",
1731
+ })
1682
1732
  session.client.stop()
1683
1733
  }
1684
1734
  this.sessions.clear()
package/src/main.mjs CHANGED
@@ -117,16 +117,20 @@ import { JsonlLiveWatchers } from "./jsonl-live-watchers.mjs"
117
117
  import { SiblingInjectBridge } from "./sibling-inject.mjs"
118
118
  import {
119
119
  boundSessionId,
120
+ codexContextPercentFromTokenUsage,
121
+ codexLiveContextForProducer,
120
122
  codexSessionUsages,
121
123
  codexTurnActiveForThread,
122
124
  codexUsageForThread,
123
125
  contextWindowSize,
126
+ forgetCodexLiveTokenUsage,
124
127
  getSessionUsages,
125
128
  getUsage,
126
129
  isFreshUnboundBind,
127
130
  jsonlContextForCwd,
128
131
  recordChatRateLimit,
129
132
  recordChatUsageSnapshot,
133
+ recordCodexLiveTokenUsage,
130
134
  turnActiveForCwd,
131
135
  } from "./usage.mjs"
132
136
  import {
@@ -966,7 +970,15 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
966
970
  // (codex 側にまだ存在しない/後続スライス)。ステータスドット用のチャット信号は
967
971
  // turn/started・turn/completed のみ最小限で反映する (claude の assistant/result 相当)。
968
972
  if (codexBridge) {
969
- codexBridge.on("event", ({ stream_id, thread_id, cwd, notification }) => {
973
+ // T06407 増分2 (codex-review round1 major#3): この producer(session.seq) が cwd の
974
+ // **権威 producer** (liveSessionForCwd = 会話ターン最新) かを判定する。チャット信号
975
+ // (recordChatActivity) の context/sessionId は権威 producer の分だけ書く。非権威 producer
976
+ // の context/sessionId をチャット信号へ書くと、権威 thread の live 未取得の窓で overlay 経由
977
+ // に混入し、usage.response (権威 producer ベース) と分岐してドーナツが前後する。
978
+ const isCodexAuthority = (cwd, producerSeq) =>
979
+ typeof producerSeq === "number" &&
980
+ codexBridge?.liveSessionForCwd?.(cwd)?.seq === producerSeq
981
+ codexBridge.on("event", ({ stream_id, producer_seq, thread_id, cwd, notification }) => {
970
982
  if (notification.method === "turn/started") {
971
983
  // S1 (2026-07): contextPct を渡していなかったため、Codex 選択中は状態ドット
972
984
  // (processing/waiting) こそ更新されるがコンテキスト% は claude 由来の古い値の
@@ -975,18 +987,17 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
975
987
  ;(async () => {
976
988
  let contextPct = null
977
989
  try {
978
- const usage = await codexUsageForThread(thread_id)
990
+ const usage = await codexUsageForThread(thread_id, { producerSeq: producer_seq })
979
991
  contextPct = typeof usage?.context === "number" ? usage.context : null
980
992
  } catch {
981
993
  /* ignore */
982
994
  }
983
995
  try {
984
- recordChatActivity(cwd, "codex", {
985
- status: "processing",
986
- contextPct,
987
- inputPending: false,
988
- sessionId: thread_id || null,
989
- })
996
+ recordChatActivity(
997
+ cwd,
998
+ "codex",
999
+ buildCodexChatPatch("processing", contextPct, thread_id, isCodexAuthority(cwd, producer_seq)),
1000
+ )
990
1001
  } catch {
991
1002
  /* ignore */
992
1003
  }
@@ -995,22 +1006,46 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
995
1006
  ;(async () => {
996
1007
  let contextPct = null
997
1008
  try {
998
- const usage = await codexUsageForThread(thread_id)
1009
+ const usage = await codexUsageForThread(thread_id, { producerSeq: producer_seq })
999
1010
  contextPct = typeof usage?.context === "number" ? usage.context : null
1000
1011
  } catch {
1001
1012
  /* ignore */
1002
1013
  }
1003
1014
  try {
1004
- recordChatActivity(cwd, "codex", {
1005
- status: "waiting",
1006
- contextPct,
1007
- inputPending: false,
1008
- sessionId: thread_id || null,
1009
- })
1015
+ recordChatActivity(
1016
+ cwd,
1017
+ "codex",
1018
+ buildCodexChatPatch("waiting", contextPct, thread_id, isCodexAuthority(cwd, producer_seq)),
1019
+ )
1010
1020
  } catch {
1011
1021
  /* ignore */
1012
1022
  }
1013
1023
  })()
1024
+ } else if (notification.method === "thread/tokenUsage/updated") {
1025
+ // T06407 増分2 (症状⑥ ドーナツ前後動の根治): App Server の live tokenUsage を
1026
+ // context% の正本にする。**producer(session.seq) 単位**でストアへ記録し (usage.response /
1027
+ // state loop が cwd の権威 producer の seq で参照する)、同時にチャット信号 (overlay 経路) の
1028
+ // context_pct も同じ live 値で更新する。status は上書きしない (このイベントはターン進行中に
1029
+ // 届くため keep-prev。processing/waiting は turn/started・turn/completed が管理する)。
1030
+ const tokenUsage = notification.params?.tokenUsage
1031
+ if (typeof producer_seq === "number" && tokenUsage) {
1032
+ try {
1033
+ recordCodexLiveTokenUsage(producer_seq, tokenUsage)
1034
+ } catch {
1035
+ /* ignore */
1036
+ }
1037
+ // major#3: チャット信号の context/sessionId は権威 producer のときだけ書く。
1038
+ if (isCodexAuthority(cwd, producer_seq)) {
1039
+ const contextPct = codexContextPercentFromTokenUsage(tokenUsage)
1040
+ if (typeof contextPct === "number" && cwd) {
1041
+ try {
1042
+ recordChatActivity(cwd, "codex", { contextPct, sessionId: thread_id })
1043
+ } catch {
1044
+ /* ignore */
1045
+ }
1046
+ }
1047
+ }
1048
+ }
1014
1049
  }
1015
1050
  client.send({ type: "codex.event", stream_id, thread_id, event: notification })
1016
1051
  })
@@ -1032,7 +1067,15 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1032
1067
  params,
1033
1068
  })
1034
1069
  })
1035
- codexBridge.on("exit", ({ stream_id, thread_id, code, reason }) => {
1070
+ codexBridge.on("exit", ({ stream_id, producer_seq, thread_id, code, reason }) => {
1071
+ // T06407 増分2: この producer(session.seq) の live tokenUsage スロットを破棄する。
1072
+ // seq は immutable なので、reattach で stream_id が付け替わっても alias slot / Map リークは
1073
+ // 生じない (stream_id キー時代の major#1 を解消)。
1074
+ try {
1075
+ forgetCodexLiveTokenUsage(producer_seq)
1076
+ } catch {
1077
+ /* ignore */
1078
+ }
1036
1079
  client.send({ type: "codex.exit", stream_id, thread_id, code, reason })
1037
1080
  })
1038
1081
  codexBridge.on("error", ({ stream_id, thread_id, error }) => {
@@ -2162,6 +2205,45 @@ export function applyChatOverlay(paneStatus, paneContextPct, chat) {
2162
2205
  return { status, contextPct }
2163
2206
  }
2164
2207
 
2208
+ /**
2209
+ * T06407 増分2 (codex-review round1 major#3): codex チャット信号 (recordChatActivity) の
2210
+ * patch を組み立てる。status (processing/waiting) は cwd レベルの「ターン進行中か」信号なので
2211
+ * 常に書き、**context_pct / sessionId は権威 producer のときだけ**含める。非権威 producer の
2212
+ * context/sessionId をチャット信号へ書くと、権威 thread の live 未取得の窓で overlay 経由に
2213
+ * 混入し、usage.response (権威 producer ベース) と分岐してドーナツが前後する。
2214
+ *
2215
+ * @param {'processing'|'waiting'} status
2216
+ * @param {number|null} contextPct
2217
+ * @param {string|null} threadId
2218
+ * @param {boolean} isAuthority この producer が cwd の権威 (liveSessionForCwd) か
2219
+ * @returns {{status:string, inputPending:boolean, contextPct?:number, sessionId?:string|null}}
2220
+ */
2221
+ export function buildCodexChatPatch(status, contextPct, threadId, isAuthority) {
2222
+ const patch = { status, inputPending: false }
2223
+ if (isAuthority) {
2224
+ patch.sessionId = threadId || null
2225
+ if (typeof contextPct === "number") patch.contextPct = contextPct
2226
+ }
2227
+ return patch
2228
+ }
2229
+
2230
+ /**
2231
+ * T06407 増分2 (codex-review round2 major#2): usage.response の codexSessions を、集計に
2232
+ * 使った producer が **今なお cwd の権威**である entry だけへ絞る post-await ガード。
2233
+ * codexSessionUsages の rollout I/O 中に別 session が権威化した cwd の entry (= 旧 producer の
2234
+ * context) を破棄する。破棄された cwd は frontend が state loop の live.context_pct へフォール
2235
+ * バックする (毎 tick 権威を同期解決 = TOCTOU 無し)。
2236
+ *
2237
+ * @param {Array<{cwd:string, contextPercent:number, ...}>} codexSessions codexSessionUsages の結果
2238
+ * @param {Map<string, number>} producerByCwd cwd → 集計に使った producer seq
2239
+ * @param {(cwd:string)=>number|undefined} seqForCwd cwd → 現在の権威 producer seq
2240
+ * @returns {Array} 権威が一致する entry のみ
2241
+ */
2242
+ export function filterCodexSessionsByAuthority(codexSessions, producerByCwd, seqForCwd) {
2243
+ if (!Array.isArray(codexSessions)) return []
2244
+ return codexSessions.filter((e) => seqForCwd(e.cwd) === producerByCwd.get(e.cwd))
2245
+ }
2246
+
2165
2247
  /**
2166
2248
  * S1 (2026-07): claude.attach / codex.attach の直後に、その cwd に対応する session.state を
2167
2249
  * 1 回だけ即時 push する。startStateLoop の周期 (既定 5s) を待つと、エンジン切替や新規
@@ -2449,10 +2531,31 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2449
2531
  // 分岐の優先順位は decideTurnActiveSource (純関数、単体テスト済み) が権威。
2450
2532
  let turnActive = null
2451
2533
  // codex 行のときだけ rollout ベース判定の材料を引く (claude 行では常に null)。
2452
- const codexThreadId =
2534
+ // cwd の**権威 producer session** を 1 度だけ引き、threadId(turn_active 判定用) と
2535
+ // seq(context% の live source 参照キー) の両方を同一 session から取る。これで
2536
+ // usage.response の per-cwd 集計 (同じ liveSessionForCwd) と同じ producer を選び収束する
2537
+ // (axis2)。liveSessionForCwd は会話ターン最新 (lastTurnAtMs) の一意規則。
2538
+ const codexAuthSession =
2453
2539
  s.cwd && sessionCliKind === "codex"
2454
- ? codexBridge?.threadIdForCwd?.(s.cwd) || null
2540
+ ? codexBridge?.liveSessionForCwd?.(s.cwd) || null
2455
2541
  : null
2542
+ const codexThreadId = codexAuthSession?.threadId || null
2543
+ // T06407 増分2 (axis3): codex 行の context%(ドーナツ) は TTL 非依存の live store
2544
+ // (thread/tokenUsage/updated 由来) を権威 producer(seq) で引き、chat overlay より優先して
2545
+ // 恒久収束させる。ただし **applyChatOverlay と同じ paneIsProcessing ガードを尊重する**:
2546
+ // 本物の TUI が生成中 (capture-pane が processing = "esc to interrupt" 実在) のときは、
2547
+ // その pane 由来の実 context_pct を stale な live 値で上書きしない (warm chat と Codex TUI
2548
+ // 併存時の TUI ドーナツ破壊を防ぐ。chat overlay は既に上書きを見送っている)。素のシェル
2549
+ // (chat モードのペイン) は processing を返せないため override は従来通り効く。
2550
+ const paneIsProcessing = s.status === "processing"
2551
+ if (
2552
+ sessionCliKind === "codex" &&
2553
+ typeof codexAuthSession?.seq === "number" &&
2554
+ !paneIsProcessing
2555
+ ) {
2556
+ const liveCtx = codexLiveContextForProducer(codexAuthSession.seq)
2557
+ if (typeof liveCtx === "number") contextPct = liveCtx
2558
+ }
2456
2559
  const turnActiveSource = decideTurnActiveSource({
2457
2560
  chatStatus: chat?.status,
2458
2561
  cwd: s.cwd,
@@ -4529,9 +4632,19 @@ async function dispatch(msg, ctx) {
4529
4632
  contextPct: 0,
4530
4633
  }).catch(() => {})
4531
4634
  } else {
4532
- codexUsageForThread(info.threadId)
4635
+ // 増分2: context% は cwd の権威 producer(seq) の live を正本にする (resume 直後の
4636
+ // このセッションは lastTurnAtMs=now で権威。live 未取得なら rollout フォールバック)。
4637
+ const resumeProducerSeq = ctx.codexBridge?.liveSessionForCwd?.(resolvedCwd)?.seq
4638
+ codexUsageForThread(info.threadId, { producerSeq: resumeProducerSeq })
4533
4639
  .catch(() => null)
4534
4640
  .then((usage) => {
4641
+ // ⚠️ codex-review round2 major#2: rollout I/O 中に別 session が権威化しうる。
4642
+ // await 後に権威 seq を再検証し、この resume session が現権威でなければ即時 push を
4643
+ // 見送る (古い context/sessionId を書かない。state loop が ≤5s で正す)。
4644
+ const stillAuthority =
4645
+ resumeProducerSeq != null &&
4646
+ ctx.codexBridge?.liveSessionForCwd?.(resolvedCwd)?.seq === resumeProducerSeq
4647
+ if (!stillAuthority) return undefined
4535
4648
  const contextPct = typeof usage?.context === "number" ? usage.context : 0
4536
4649
  recordChatActivity(resolvedCwd, "codex", {
4537
4650
  status: "waiting",
@@ -5414,14 +5527,53 @@ async function dispatch(msg, ctx) {
5414
5527
  let codexSessions = []
5415
5528
  if (ctx.codexBridge && ctx.codexBridge.sessions.size > 0) {
5416
5529
  try {
5417
- const liveCodex = Array.from(ctx.codexBridge.sessions.values())
5418
- .filter((s) => s.threadId && s.cwd)
5419
- .map((s) => ({ sessionId: s.threadId, cwd: s.cwd, threadId: s.threadId }))
5530
+ // T06407 増分2 (axis2): per-cwd の context% は **cwd ごとに権威 producer 1 件**へ
5531
+ // 集約する。旧実装は全 live session を codexSessions へ流していたため、同一 cwd に
5532
+ // 複数 thread があると frontend の usageByCwd (updatedAtMs 同値で first-wins) が
5533
+ // 任意のスレッドを拾い、state loop (権威 producer) と別 thread を選んでドーナツが
5534
+ // 前後した。liveSessionForCwd の同一規則で cwd → 権威 producer(session) を一意化し、
5535
+ // producerSeq を context% の live source として通して両経路を収束させる。
5536
+ const cwds = new Set()
5537
+ for (const s of ctx.codexBridge.sessions.values()) {
5538
+ if (s.threadId && s.cwd) cwds.add(s.cwd)
5539
+ }
5540
+ const liveCodex = []
5541
+ for (const cwd of cwds) {
5542
+ const authoritative = ctx.codexBridge.liveSessionForCwd?.(cwd)
5543
+ if (authoritative?.threadId) {
5544
+ liveCodex.push({
5545
+ sessionId: authoritative.threadId,
5546
+ cwd: authoritative.cwd,
5547
+ threadId: authoritative.threadId,
5548
+ producerSeq: authoritative.seq,
5549
+ })
5550
+ }
5551
+ }
5420
5552
  if (liveCodex.length > 0) {
5421
5553
  // 5h/7d はアカウント全体の値 (どの live session から読んでも同じ契約の残量)
5422
- // なので、代表として最初の 1 件から取る。
5423
- codexUsage = await codexUsageForThread(liveCodex[0].threadId)
5554
+ // なので、代表として最初の 1 件から取る (context% はその producer の live を使う)。
5555
+ codexUsage = await codexUsageForThread(liveCodex[0].threadId, {
5556
+ producerSeq: liveCodex[0].producerSeq,
5557
+ })
5424
5558
  codexSessions = await codexSessionUsages(liveCodex)
5559
+ // ⚠️ codex-review round2 major#2: codexSessionUsages の rollout I/O 中に cwd の
5560
+ // 権威 producer が別 session へ移りうる。await 後に各 cwd の現権威 seq を再検証し、
5561
+ // 集計に使った producer が現権威でない entry は破棄する (古い producer の context を
5562
+ // codexCtxByCwd へ出さない)。破棄した cwd は frontend が state loop の
5563
+ // live.context_pct (毎 tick 権威を同期解決=TOCTOU 無し) にフォールバックする。
5564
+ const seqForCwd = (cwd) => ctx.codexBridge.liveSessionForCwd?.(cwd)?.seq
5565
+ const producerByCwd = new Map(liveCodex.map((e) => [e.cwd, e.producerSeq]))
5566
+ codexSessions = filterCodexSessionsByAuthority(
5567
+ codexSessions,
5568
+ producerByCwd,
5569
+ seqForCwd,
5570
+ )
5571
+ // codex-review round3 minor#1: 集約 codexUsage.context は代表 producer
5572
+ // (liveCodex[0]) の値。await 中に代表が権威を失っていたら旧 context を出さない
5573
+ // (context だけ null 化。5h/7d は account-wide なので保持する)。
5574
+ if (codexUsage && seqForCwd(liveCodex[0].cwd) !== liveCodex[0].producerSeq) {
5575
+ codexUsage = { ...codexUsage, context: null }
5576
+ }
5425
5577
  }
5426
5578
  } catch (err) {
5427
5579
  ctx.logger?.debug?.({ err: err?.message }, "codex usage snapshot skipped")
package/src/usage.mjs CHANGED
@@ -1594,29 +1594,125 @@ export function classifyCodexRateLimitWindows(snapshot) {
1594
1594
  return result
1595
1595
  }
1596
1596
 
1597
+ // ---------------------------------------------------------------------------
1598
+ // Codex context%(ドーナツ)の live source(T06407 増分2, 症状⑥ ドーナツ前後動の根治)
1599
+ //
1600
+ // 従来 context% は「rollout の token_count を state loop / 60 秒ポーリングが**別々に**読む」
1601
+ // 二重ソースで、cadence・キー(cwd/threadId)・rollout 書込ラグの差で値が前後していた。
1602
+ // App Server の `thread/tokenUsage/updated`(ターン中 live push)を正本にし、rollout を最終
1603
+ // フォールバックへ格下げすることで両経路を同一 live 値へ収束させる。
1604
+ //
1605
+ // ⚠️ store のキーは **producer(session) の immutable seq**(codex-stream-bridge の
1606
+ // `session.seq`)にする。context% は thread ではなく **producer(session) の属性**であり、
1607
+ // 以下の理由で threadId でも stream_id でもキーにできない(codex-review round1 の指摘 3 点):
1608
+ // - stream_id は購読端末 ID で reattach/detach で付け替わる → alias slot が残り Map リーク、
1609
+ // exit は現 primary しか消せず stale が残る(指摘1)。
1610
+ // - 同一 thread に複数 producer(resume) がありうるので threadId 単独では混線する。
1611
+ // - 「受信時刻 latest を代表」は旧 producer の遅延通知が後着時刻で再び代表を奪う(指摘2)。
1612
+ // 代表の選択は **cwd の権威 producer**(liveSessionForCwd の seq)を呼び出し側が渡して引く
1613
+ // (codexLiveContextForProducer(seq))。受信時刻に依存しないので前後動しない。
1614
+ //
1615
+ // ⚠️ tokenUsage の shape は App Server wire = **camelCase**(total / last:{totalTokens,...} /
1616
+ // modelContextWindow)。rollout jsonl の last_token_usage は snake_case で、そちらは
1617
+ // codexUsageForThread 内の従来ロジックが扱う。
1618
+ //
1619
+ // @type {Map<number, { tokenUsage: object, atMs: number }>}
1620
+ const _codexLiveTokenUsageByProducer = new Map()
1621
+
1622
+ /** `thread/tokenUsage/updated` を受けて **producer(session.seq) 単位**で live tokenUsage を
1623
+ * 記録する。
1624
+ * @param {number} producerSeq codex-stream-bridge の immutable な session.seq
1625
+ * @param {object} tokenUsage ThreadTokenUsage (camelCase)
1626
+ * @param {{ atMs?: number }} [opts]
1627
+ * atMs 単調ガードで、同一 producer 内で順序前後した古い更新を捨てる。 */
1628
+ export function recordCodexLiveTokenUsage(producerSeq, tokenUsage, opts = {}) {
1629
+ if (typeof producerSeq !== "number" || !Number.isFinite(producerSeq) || !tokenUsage) return
1630
+ const atMs = typeof opts.atMs === "number" ? opts.atMs : Date.now()
1631
+ const prev = _codexLiveTokenUsageByProducer.get(producerSeq)
1632
+ // 同一 producer 内の古い更新(atMs が後退)は捨てる。
1633
+ if (prev && typeof prev.atMs === "number" && atMs < prev.atMs) return
1634
+ _codexLiveTokenUsageByProducer.set(producerSeq, { tokenUsage, atMs })
1635
+ }
1636
+
1637
+ /** セッション終了時に producer(session.seq) の live tokenUsage を破棄する。seq は immutable
1638
+ * なので alias slot / リークは生じない(stream_id キー時代の指摘1を解消)。 */
1639
+ export function forgetCodexLiveTokenUsage(producerSeq) {
1640
+ if (typeof producerSeq !== "number") return
1641
+ _codexLiveTokenUsageByProducer.delete(producerSeq)
1642
+ }
1643
+
1644
+ /** テスト用: live tokenUsage ストアをクリアする。 */
1645
+ export function _resetCodexLiveTokenUsage() {
1646
+ _codexLiveTokenUsageByProducer.clear()
1647
+ }
1648
+
1649
+ /** camelCase の ThreadTokenUsage から context%(占有率) を算出する。分母(窓)や last が
1650
+ * 無ければ null。context 窓は last(直近ターンで実際に窓へ載った分)で測る (total は会話累積で
1651
+ * 100% に張り付くため。rollout 版と同じ設計)。 */
1652
+ export function codexContextPercentFromTokenUsage(tokenUsage) {
1653
+ const windowSize = tokenUsage?.modelContextWindow
1654
+ const last = tokenUsage?.last
1655
+ if (!last || typeof windowSize !== "number" || windowSize <= 0) return null
1656
+ const tokens =
1657
+ typeof last.totalTokens === "number"
1658
+ ? last.totalTokens
1659
+ : (last.inputTokens || 0) + (last.cachedInputTokens || 0) + (last.outputTokens || 0)
1660
+ if (typeof tokens !== "number") return null
1661
+ return Math.min(100, Math.round((tokens / windowSize) * 1000) / 10)
1662
+ }
1663
+
1664
+ /** producer(session.seq) の live context%(App Server 正本)。呼び出し側は cwd の権威 producer
1665
+ * (liveSessionForCwd の seq) を渡す。live tokenUsage 未取得 / 算出不能なら null(呼び出し側が
1666
+ * rollout へフォールバックする)。 */
1667
+ export function codexLiveContextForProducer(producerSeq) {
1668
+ if (typeof producerSeq !== "number") return null
1669
+ const e = _codexLiveTokenUsageByProducer.get(producerSeq)
1670
+ if (!e) return null
1671
+ return codexContextPercentFromTokenUsage(e.tokenUsage)
1672
+ }
1673
+
1597
1674
  /**
1598
1675
  * 指定 threadId の Codex セッションから usage (5h/7d + context%) を返す。
1599
1676
  * Claude の `getUsage()` と同じ shape (`UsageStats`) を返すため、frontend / wire
1600
1677
  * protocol の分岐を増やさずに済む。
1601
1678
  *
1679
+ * context%(ドーナツ) は **呼び出し側が渡す権威 producer の live tokenUsage** を正本とし
1680
+ * (codexLiveContextForProducer)、無ければ rollout の last_token_usage にフォールバックする
1681
+ * (T06407 増分2)。これで state loop / usage.response の両経路が同一 live 値へ収束し、
1682
+ * ドーナツの前後動 (症状⑥) が解消する。producerSeq を渡さない旧経路は rollout のみ。
1683
+ *
1602
1684
  * ⚠️ `last5h`/`last7d` は Codex 側アカウント全体の rate limit(ChatGPT/OpenAI
1603
1685
  * サブスクリプション)であり、Claude の `last5h`/`last7d`(Anthropic サブスク)とは
1604
1686
  * **別契約の別数値**。呼び出し側で Claude usage と単純合算/上書きしないこと
1605
1687
  * (どちらのアカウントの残量か分からなくなる)。
1606
1688
  *
1607
1689
  * @param {string} threadId
1690
+ * @param {{ producerSeq?: number }} [opts] producerSeq: この thread を表示する cwd の権威
1691
+ * producer(session.seq)。context% の live source として使う。
1608
1692
  * @returns {Promise<import("./usage.mjs").UsageStats|null>} 見つからなければ null
1609
1693
  */
1610
- export async function codexUsageForThread(threadId) {
1694
+ export async function codexUsageForThread(threadId, opts = {}) {
1611
1695
  if (!threadId) return null
1612
1696
  const fp = await findCodexRolloutFile(threadId)
1613
- if (!fp) return null
1614
- const found = await latestTokenCountFromRollout(fp)
1615
- if (!found) return null
1616
- const info = found.payload.info
1617
- const rateLimits = found.payload.rate_limits
1618
-
1619
- let contextPercent = null
1697
+ const found = fp ? await latestTokenCountFromRollout(fp) : null
1698
+ // 増分2: context% は権威 producer の App Server live tokenUsage を正本にする。
1699
+ // ⚠️ codex-review round2 major#2: live は rollout I/O の **後** に読む。I/O 中に起きた同一
1700
+ // producer の token 更新を反映し、I/O 中に producer が exit していれば live は null になって
1701
+ // rollout フォールバックへ正しく落ちる(I/O 前スナップショットだと exit 済み producer の古い
1702
+ // context を返してしまう)。呼び出し側はさらに await 後に権威 seq を再検証する。
1703
+ // ⚠️ live が取れていれば rollout 不在(新スレッド / 書込ラグ中)でも usage を返す。旧実装は
1704
+ // rollout 不在で early-return null し、rollout が書かれるまで live-only の context を
1705
+ // usage.response / codexSessions へ出せなかった。rate limit は rollout 由来のため rollout
1706
+ // 不在時は「未提供」になる(context だけ live で出す)。
1707
+ const liveContext =
1708
+ typeof opts.producerSeq === "number" ? codexLiveContextForProducer(opts.producerSeq) : null
1709
+ // live も rollout も無ければ従来どおり null。
1710
+ if (liveContext === null && !found) return null
1711
+ const info = found?.payload?.info
1712
+ const rateLimits = found?.payload?.rate_limits ?? null
1713
+
1714
+ // live tokenUsage が取れていれば context の正本にし、rollout では上書きしない。
1715
+ let contextPercent = liveContext
1620
1716
  const windowSize = info?.model_context_window
1621
1717
  // context 窓の「現在の占有率」は last_token_usage(直近ターンで実際に窓へ載った
1622
1718
  // トークン)で測る。total_token_usage は会話累積(キャッシュ再利用分も加算し
@@ -1625,8 +1721,14 @@ export async function codexUsageForThread(threadId) {
1625
1721
  // 窓 258,400 = 519% → 100% 張り付き。実占有は last=66,973 → 25.9%)。Codex は
1626
1722
  // 自動コンパクションで実文脈を使い回すため、累積で割ると必ず張り付く。
1627
1723
  // last_token_usage が無い旧 Codex 版のみ従来の total_token_usage にフォールバック。
1724
+ // ⚠️ live tokenUsage が取れていれば (contextPercent!==null) rollout では上書きしない。
1628
1725
  const usageForContext = info?.last_token_usage ?? info?.total_token_usage
1629
- if (usageForContext && typeof windowSize === "number" && windowSize > 0) {
1726
+ if (
1727
+ contextPercent === null &&
1728
+ usageForContext &&
1729
+ typeof windowSize === "number" &&
1730
+ windowSize > 0
1731
+ ) {
1630
1732
  const tokens =
1631
1733
  typeof usageForContext.total_tokens === "number"
1632
1734
  ? usageForContext.total_tokens
@@ -1652,11 +1754,15 @@ export async function codexUsageForThread(threadId) {
1652
1754
  }
1653
1755
 
1654
1756
  /**
1655
- * 複数の Codex セッション (threadId + cwd + sessionId) から `SessionUsageEntry[]`
1757
+ * 複数の Codex セッション (threadId + cwd + sessionId + producerSeq) から `SessionUsageEntry[]`
1656
1758
  * (Claude の `getSessionUsages()` と同 shape) を返す。見つからなかった/context 不明な
1657
1759
  * セッションは結果から除外する (Claude 版と同じ「分かるものだけ返す」約束)。
1658
1760
  *
1659
- * @param {Array<{sessionId: string, cwd: string, threadId: string}>} liveSessions
1761
+ * ⚠️ 呼び出し側 (usage.response) は cwd ごとに **権威 producer 1 件**へ集約して渡す。
1762
+ * producerSeq を context% の live source として codexUsageForThread へ通し、state loop と
1763
+ * 同一の live 値へ収束させる (T06407 増分2, axis2)。
1764
+ *
1765
+ * @param {Array<{sessionId: string, cwd: string, threadId: string, producerSeq?: number}>} liveSessions
1660
1766
  * @returns {Promise<Array<{sessionId: string, cwd: string, contextPercent: number, updatedAtMs: number}>>}
1661
1767
  */
1662
1768
  export async function codexSessionUsages(liveSessions) {
@@ -1664,9 +1770,9 @@ export async function codexSessionUsages(liveSessions) {
1664
1770
  const now = Date.now()
1665
1771
  const out = []
1666
1772
  await Promise.all(
1667
- liveSessions.map(async ({ sessionId, cwd, threadId }) => {
1773
+ liveSessions.map(async ({ sessionId, cwd, threadId, producerSeq }) => {
1668
1774
  if (!threadId || !cwd) return
1669
- const usage = await codexUsageForThread(threadId)
1775
+ const usage = await codexUsageForThread(threadId, { producerSeq })
1670
1776
  if (!usage || typeof usage.context !== "number") return
1671
1777
  out.push({
1672
1778
  sessionId: sessionId || threadId,