@cocorograph/hub-agent 0.7.84 → 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.84",
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
@@ -1483,29 +1483,236 @@ export function _resetCodexTokenCountMemo() {
1483
1483
  _codexTokenCountMemo.clear()
1484
1484
  }
1485
1485
 
1486
+ /**
1487
+ * Codex rate limit の窓分類定数 (T06407)。5h 窓 = 300 分、7d 窓 = 10080 分。
1488
+ * ⚠️ 位置 (primary/secondary) は 5h/7d を保証しない。実測 (0.144.x / pro プラン) では
1489
+ * primary が windowDurationMins=10080 (=7日)・secondary=null を返す。よって窓の分類は
1490
+ * **duration で行う** (症状⑦「7d が進まない」の根治)。閾値は 5h と 7d の中点 (~5190 分)。
1491
+ */
1492
+ const CODEX_5H_WINDOW_MINS = 300
1493
+ const CODEX_7D_WINDOW_MINS = 10080
1494
+ /** 分類スロットの正準窓長。窓は「最も近い正準窓」のスロットへ割り当てる(中点固定分割だと
1495
+ * 将来 1h+5h のような同帯 2 窓で実 5h を取りこぼす。codex-review minor 対応)。 */
1496
+ const CODEX_RATE_LIMIT_SLOTS = [
1497
+ { key: "last5h", canonicalMins: CODEX_5H_WINDOW_MINS },
1498
+ { key: "last7d", canonicalMins: CODEX_7D_WINDOW_MINS },
1499
+ ]
1500
+
1501
+ /** RateLimitWindow (App Server = camelCase / rollout jsonl = snake_case の両対応) から
1502
+ * used% を読む。値が無ければ null (= その窓は「未提供」)。 */
1503
+ function codexWindowUsedPercent(w) {
1504
+ if (!w) return null
1505
+ if (typeof w.usedPercent === "number") return w.usedPercent
1506
+ if (typeof w.used_percent === "number") return w.used_percent
1507
+ return null
1508
+ }
1509
+
1510
+ /** RateLimitWindow の窓長 (分)。camelCase/snake_case 両対応。不明なら null。 */
1511
+ function codexWindowMins(w) {
1512
+ if (!w) return null
1513
+ if (typeof w.windowDurationMins === "number") return w.windowDurationMins
1514
+ if (typeof w.window_minutes === "number") return w.window_minutes
1515
+ return null
1516
+ }
1517
+
1518
+ /** RateLimitWindow の resets 時刻 (ms)。camelCase/snake_case 両対応。秒→ms 補正あり。 */
1519
+ function codexWindowResetMs(w) {
1520
+ if (!w) return null
1521
+ const raw =
1522
+ typeof w.resetsAt === "number"
1523
+ ? w.resetsAt
1524
+ : typeof w.resets_at === "number"
1525
+ ? w.resets_at
1526
+ : null
1527
+ if (typeof raw !== "number") return null
1528
+ return raw < 1e12 ? raw * 1000 : raw
1529
+ }
1530
+
1531
+ /** 「未提供」bucket。欠落窓を 0% と誤表示せず、frontend が「未提供」と描画できるよう
1532
+ * provided:false / percent:null を返す (fail-soft で沈黙させない)。 */
1533
+ function codexNotProvidedBucket() {
1534
+ return {
1535
+ tokens: 0,
1536
+ messages: 0,
1537
+ limit: CODEX_DEFAULT_LIMIT,
1538
+ percent: null,
1539
+ provided: false,
1540
+ windowMinutes: null,
1541
+ oldestTsMs: null,
1542
+ resetAtMs: null,
1543
+ }
1544
+ }
1545
+
1546
+ /**
1547
+ * RateLimitSnapshot (App Server の `account/rateLimits/read`/`updated` または rollout の
1548
+ * `rate_limits`、両 casing) を **窓長で 5h/7d スロットへ分類**する (T06407)。
1549
+ *
1550
+ * - primary/secondary の**位置に依存しない**。各窓の windowDurationMins/window_minutes を見て、
1551
+ * **正準窓長に最も近いスロット**へ割り当てる (5h=300 分 / 7d=10080 分)。
1552
+ * - 提供されない窓 (null / used% 欠落 / duration 不明) は 0% ではなく「未提供」で返す。
1553
+ * - 同一スロットに複数窓が来たら、そのスロットの正準窓長に**より近い**窓を採用する
1554
+ * (先着でなく最近傍。将来 1h+5h のような同帯 2 窓でも実 5h を取りこぼさない)。
1555
+ *
1556
+ * @param {object|null} snapshot RateLimitSnapshot 相当 ({ primary, secondary, ... })
1557
+ * @returns {{ last5h: object, last7d: object }} 各 bucket は provided:true|false を持つ
1558
+ */
1559
+ export function classifyCodexRateLimitWindows(snapshot) {
1560
+ const result = { last5h: codexNotProvidedBucket(), last7d: codexNotProvidedBucket() }
1561
+ if (!snapshot) return result
1562
+ // 各スロットに「これまで割り当てた窓の正準窓長からの距離」を持ち、より近い窓で上書きする。
1563
+ const bestDist = { last5h: Infinity, last7d: Infinity }
1564
+ for (const w of [snapshot.primary, snapshot.secondary]) {
1565
+ const usedPercent = codexWindowUsedPercent(w)
1566
+ if (usedPercent === null) continue
1567
+ const mins = codexWindowMins(w)
1568
+ // duration 不明は分類できない → 未提供のままにする (位置で 5h/7d に決め打ちしない)。
1569
+ if (mins === null) continue
1570
+ // 最も近い正準窓のスロットへ割り当てる。
1571
+ let slot = null
1572
+ let slotDist = Infinity
1573
+ for (const s of CODEX_RATE_LIMIT_SLOTS) {
1574
+ const d = Math.abs(mins - s.canonicalMins)
1575
+ if (d < slotDist) {
1576
+ slotDist = d
1577
+ slot = s.key
1578
+ }
1579
+ }
1580
+ // 同一スロットに複数窓が来たら、正準窓長により近い方を採用 (先着でなく最近傍)。
1581
+ if (slotDist >= bestDist[slot]) continue
1582
+ bestDist[slot] = slotDist
1583
+ result[slot] = {
1584
+ tokens: 0,
1585
+ messages: 0,
1586
+ limit: CODEX_DEFAULT_LIMIT,
1587
+ percent: usedPercent,
1588
+ provided: true,
1589
+ windowMinutes: mins,
1590
+ oldestTsMs: null,
1591
+ resetAtMs: codexWindowResetMs(w),
1592
+ }
1593
+ }
1594
+ return result
1595
+ }
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
+
1486
1674
  /**
1487
1675
  * 指定 threadId の Codex セッションから usage (5h/7d + context%) を返す。
1488
1676
  * Claude の `getUsage()` と同じ shape (`UsageStats`) を返すため、frontend / wire
1489
1677
  * protocol の分岐を増やさずに済む。
1490
1678
  *
1679
+ * context%(ドーナツ) は **呼び出し側が渡す権威 producer の live tokenUsage** を正本とし
1680
+ * (codexLiveContextForProducer)、無ければ rollout の last_token_usage にフォールバックする
1681
+ * (T06407 増分2)。これで state loop / usage.response の両経路が同一 live 値へ収束し、
1682
+ * ドーナツの前後動 (症状⑥) が解消する。producerSeq を渡さない旧経路は rollout のみ。
1683
+ *
1491
1684
  * ⚠️ `last5h`/`last7d` は Codex 側アカウント全体の rate limit(ChatGPT/OpenAI
1492
1685
  * サブスクリプション)であり、Claude の `last5h`/`last7d`(Anthropic サブスク)とは
1493
1686
  * **別契約の別数値**。呼び出し側で Claude usage と単純合算/上書きしないこと
1494
1687
  * (どちらのアカウントの残量か分からなくなる)。
1495
1688
  *
1496
1689
  * @param {string} threadId
1690
+ * @param {{ producerSeq?: number }} [opts] producerSeq: この thread を表示する cwd の権威
1691
+ * producer(session.seq)。context% の live source として使う。
1497
1692
  * @returns {Promise<import("./usage.mjs").UsageStats|null>} 見つからなければ null
1498
1693
  */
1499
- export async function codexUsageForThread(threadId) {
1694
+ export async function codexUsageForThread(threadId, opts = {}) {
1500
1695
  if (!threadId) return null
1501
1696
  const fp = await findCodexRolloutFile(threadId)
1502
- if (!fp) return null
1503
- const found = await latestTokenCountFromRollout(fp)
1504
- if (!found) return null
1505
- const info = found.payload.info
1506
- const rateLimits = found.payload.rate_limits
1507
-
1508
- 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
1509
1716
  const windowSize = info?.model_context_window
1510
1717
  // context 窓の「現在の占有率」は last_token_usage(直近ターンで実際に窓へ載った
1511
1718
  // トークン)で測る。total_token_usage は会話累積(キャッシュ再利用分も加算し
@@ -1514,8 +1721,14 @@ export async function codexUsageForThread(threadId) {
1514
1721
  // 窓 258,400 = 519% → 100% 張り付き。実占有は last=66,973 → 25.9%)。Codex は
1515
1722
  // 自動コンパクションで実文脈を使い回すため、累積で割ると必ず張り付く。
1516
1723
  // last_token_usage が無い旧 Codex 版のみ従来の total_token_usage にフォールバック。
1724
+ // ⚠️ live tokenUsage が取れていれば (contextPercent!==null) rollout では上書きしない。
1517
1725
  const usageForContext = info?.last_token_usage ?? info?.total_token_usage
1518
- if (usageForContext && typeof windowSize === "number" && windowSize > 0) {
1726
+ if (
1727
+ contextPercent === null &&
1728
+ usageForContext &&
1729
+ typeof windowSize === "number" &&
1730
+ windowSize > 0
1731
+ ) {
1519
1732
  const tokens =
1520
1733
  typeof usageForContext.total_tokens === "number"
1521
1734
  ? usageForContext.total_tokens
@@ -1525,38 +1738,31 @@ export async function codexUsageForThread(threadId) {
1525
1738
  contextPercent = Math.min(100, Math.round((tokens / windowSize) * 1000) / 10)
1526
1739
  }
1527
1740
 
1528
- const bucketize = (w) => {
1529
- if (!w || typeof w.used_percent !== "number") return emptyBucket(CODEX_DEFAULT_LIMIT)
1530
- let resetMs = null
1531
- if (typeof w.resets_at === "number") {
1532
- resetMs = w.resets_at < 1e12 ? w.resets_at * 1000 : w.resets_at
1533
- }
1534
- return {
1535
- tokens: 0,
1536
- messages: 0,
1537
- limit: CODEX_DEFAULT_LIMIT,
1538
- percent: w.used_percent,
1539
- oldestTsMs: null,
1540
- resetAtMs: resetMs,
1541
- }
1542
- }
1741
+ // T06407: 位置固定 (primary→5h / secondary→7d) をやめ、windowDurationMins で分類する。
1742
+ // rollout の rate_limits は snake_case (window_minutes / used_percent / resets_at) だが
1743
+ // classifyCodexRateLimitWindows が両 casing を吸収する。欠落窓は 0% でなく「未提供」。
1744
+ const { last5h, last7d } = classifyCodexRateLimitWindows(rateLimits)
1543
1745
 
1544
1746
  return {
1545
1747
  plan: rateLimits?.plan_type || "codex",
1546
1748
  computedAt: Date.now(),
1547
1749
  source: rateLimits ? "official" : "estimate",
1548
1750
  context: contextPercent,
1549
- last5h: bucketize(rateLimits?.primary),
1550
- last7d: bucketize(rateLimits?.secondary),
1751
+ last5h,
1752
+ last7d,
1551
1753
  }
1552
1754
  }
1553
1755
 
1554
1756
  /**
1555
- * 複数の Codex セッション (threadId + cwd + sessionId) から `SessionUsageEntry[]`
1757
+ * 複数の Codex セッション (threadId + cwd + sessionId + producerSeq) から `SessionUsageEntry[]`
1556
1758
  * (Claude の `getSessionUsages()` と同 shape) を返す。見つからなかった/context 不明な
1557
1759
  * セッションは結果から除外する (Claude 版と同じ「分かるものだけ返す」約束)。
1558
1760
  *
1559
- * @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
1560
1766
  * @returns {Promise<Array<{sessionId: string, cwd: string, contextPercent: number, updatedAtMs: number}>>}
1561
1767
  */
1562
1768
  export async function codexSessionUsages(liveSessions) {
@@ -1564,9 +1770,9 @@ export async function codexSessionUsages(liveSessions) {
1564
1770
  const now = Date.now()
1565
1771
  const out = []
1566
1772
  await Promise.all(
1567
- liveSessions.map(async ({ sessionId, cwd, threadId }) => {
1773
+ liveSessions.map(async ({ sessionId, cwd, threadId, producerSeq }) => {
1568
1774
  if (!threadId || !cwd) return
1569
- const usage = await codexUsageForThread(threadId)
1775
+ const usage = await codexUsageForThread(threadId, { producerSeq })
1570
1776
  if (!usage || typeof usage.context !== "number") return
1571
1777
  out.push({
1572
1778
  sessionId: sessionId || threadId,