@cocorograph/hub-agent 0.7.30 → 0.7.33

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/skills.mjs CHANGED
@@ -18,6 +18,16 @@ const CLAUDE_DIR = process.env.CLAUDE_DIR || path.join(os.homedir(), ".claude")
18
18
  const GLOBAL_SKILLS_DIR = path.join(CLAUDE_DIR, "skills")
19
19
  const GLOBAL_COMMANDS_DIR = path.join(CLAUDE_DIR, "commands")
20
20
 
21
+ // Cockpit Codex 両対応 Stream A3 (2026-07-02): Codex 側のディレクトリ
22
+ // (`~/.codex/skills/`) は Phase 0 の実機検証で確定済み。CODEX_DIR env override
23
+ // は hub-bundle.mjs / main.mjs の CODEX_DIR と揃える。
24
+ const CODEX_DIR = process.env.CODEX_DIR || path.join(os.homedir(), ".codex")
25
+ const CODEX_GLOBAL_SKILLS_DIR = path.join(CODEX_DIR, "skills")
26
+
27
+ function skillsDirFor(cliKind) {
28
+ return cliKind === "codex" ? CODEX_GLOBAL_SKILLS_DIR : GLOBAL_SKILLS_DIR
29
+ }
30
+
21
31
  // Claude Code 組み込みスラッシュコマンド (ファイルから取得できないのでハードコード)
22
32
  const BUILT_IN_COMMANDS = [
23
33
  { name: "clear", description: "会話履歴をクリア (短期メモリのリセット)", source: "builtin" },
@@ -43,6 +53,14 @@ const BUILT_IN_COMMANDS = [
43
53
  { name: "migrate-installer", description: "古い installer から移行", source: "builtin" },
44
54
  ]
45
55
 
56
+ // Codex Code 組み込みスラッシュコマンド。⚠️ Cockpit Codex 両対応 Stream A3
57
+ // (2026-07-02) 時点では未確定 (故意に空にしている)。`codex --help` で拾えるのは
58
+ // CLI サブコマンド (login/logout/exec/mcp/app-server 等) であり、chat セッション
59
+ // 内のスラッシュコマンド一覧とは別概念。誤った一覧を配信するより空の方が安全
60
+ // (SkillBar は skill 側のみ表示になる)。A5 のライブ検証 (`codex` 対話セッションで
61
+ // 実際に使えるスラッシュコマンドを確認) 後に埋める。
62
+ const CODEX_BUILT_IN_COMMANDS = []
63
+
46
64
  function extractFrontmatter(text) {
47
65
  const m = text.match(/^---\s*\n([\s\S]*?)\n---/)
48
66
  if (!m) return {}
@@ -118,22 +136,31 @@ async function loadCommandsDir(commandsDir, source) {
118
136
  /**
119
137
  * skills 全リストを返す。
120
138
  *
121
- * @param {{ sessionName?: string, projectsRoot?: string }} [opts]
139
+ * @param {{ sessionName?: string, projectsRoot?: string, cliKind?: 'claude'|'codex' }} [opts]
122
140
  * - sessionName が `Dxxxxx_*` 形式なら projectsRoot 配下の同名 dir からも
123
141
  * `.claude/skills/` と `.claude/commands/` を追加で読む
142
+ * - cliKind='codex' (Stream A3, 2026-07-02): `~/.codex/skills/` から読む。
143
+ * Codex は custom slash command 相当の `commands/` ディレクトリ仕様が
144
+ * 未確認のため対象外(A5 のライブ検証待ち)。既定 'claude' は完全に従来どおり。
124
145
  */
125
146
  export async function listSkills(opts = {}) {
147
+ const cliKind = opts.cliKind === "codex" ? "codex" : "claude"
126
148
  const out = []
127
- out.push(...BUILT_IN_COMMANDS)
128
- out.push(...(await loadSkillsDir(GLOBAL_SKILLS_DIR, "skill")))
129
- out.push(...(await loadCommandsDir(GLOBAL_COMMANDS_DIR, "command")))
149
+ out.push(...(cliKind === "codex" ? CODEX_BUILT_IN_COMMANDS : BUILT_IN_COMMANDS))
150
+ out.push(...(await loadSkillsDir(skillsDirFor(cliKind), "skill")))
151
+ if (cliKind === "claude") {
152
+ out.push(...(await loadCommandsDir(GLOBAL_COMMANDS_DIR, "command")))
153
+ }
130
154
 
131
155
  const sessionName = opts.sessionName
132
156
  const projectsRoot = opts.projectsRoot || path.join(os.homedir(), "hub", "projects")
133
157
  if (sessionName && /^D\d{5}_/.test(sessionName)) {
134
- const projectDir = path.join(projectsRoot, sessionName, ".claude")
158
+ const projectDirName = cliKind === "codex" ? ".codex" : ".claude"
159
+ const projectDir = path.join(projectsRoot, sessionName, projectDirName)
135
160
  out.push(...(await loadSkillsDir(path.join(projectDir, "skills"), "project-skill")))
136
- out.push(...(await loadCommandsDir(path.join(projectDir, "commands"), "project-command")))
161
+ if (cliKind === "claude") {
162
+ out.push(...(await loadCommandsDir(path.join(projectDir, "commands"), "project-command")))
163
+ }
137
164
  }
138
165
 
139
166
  // 同名は最初を採用、名前順 sort
package/src/tmux.mjs CHANGED
@@ -18,6 +18,7 @@ import fs from "node:fs/promises"
18
18
  import os from "node:os"
19
19
  import path from "node:path"
20
20
  import { promisify } from "node:util"
21
+ import { fileURLToPath } from "node:url"
21
22
 
22
23
  import { ensureClaudeMd } from "./claude-md.mjs"
23
24
  import { syncClaudeMdWithHub } from "./claude-md-sync.mjs"
@@ -968,9 +969,30 @@ export function buildFreshCmd(opts = {}) {
968
969
  // ディレクトリ切替で行う (injectCodexHomeEnv)。
969
970
  // ---------------------------------------------------------------------------
970
971
 
972
+ /**
973
+ * Cockpit Codex 両対応 Stream A5(2026-07-02 実機検証で確定、中村さん明示承認済み):
974
+ * Codex は hooks.json の各 hook エントリごとに `~/.codex/config.toml` の
975
+ * `[hooks.state]` へ SHA256 ハッシュを信頼登録しないと発火しない(Claude の
976
+ * settings.json hooks には無い追加の信頼レイヤー)。実機確認: 未信頼の新規 hook を
977
+ * hooks.json に足して `codex exec` を実行したところ、既存の信頼済み hook は発火
978
+ * したが新規 hook はエラーも出さず無言でスキップされた。hub-agent が配信する
979
+ * バンドル hook は Hub という唯一の管理された配信元から来る
980
+ * (`--dangerously-bypass-hook-trust` のヘルプ文言そのまま
981
+ * 「automation that already vets hook sources」に該当)ため、常時このフラグを
982
+ * 付与する(中村さん承認: 「常に --dangerously-bypass-hook-trust フラグを付与して、
983
+ * 全部信用登録なしでOK」)。`HUB_CODEX_BYPASS_HOOK_TRUST=0` で無効化可能
984
+ * (対話的な信頼プロンプトに戻したい場合の避難ハッチ)。
985
+ * @returns {string} 空文字 (無効時) または `"--dangerously-bypass-hook-trust"`
986
+ */
987
+ function codexHookTrustBypassFlag() {
988
+ return process.env.HUB_CODEX_BYPASS_HOOK_TRUST !== "0" ? "--dangerously-bypass-hook-trust" : ""
989
+ }
990
+
971
991
  /**
972
992
  * agent 設定から codex 起動 flags を組み立てる (純粋関数)。
973
- * model / sandbox / approvalPolicy / effort のうち指定されたものだけを並べる。
993
+ * model / sandbox / approvalPolicy / effort のうち指定されたものだけを並べ、
994
+ * 先頭に常時 `--dangerously-bypass-hook-trust` を付与する
995
+ * (`codexHookTrustBypassFlag` 参照。無効時は付与しない)。
974
996
  * @param {{model?:string,sandbox?:string,approvalPolicy?:string,effort?:string}} [opts]
975
997
  * @returns {string}
976
998
  */
@@ -980,6 +1002,8 @@ export function composeCodexFlags(opts = {}) {
980
1002
  const approval = (opts.approvalPolicy || "").trim()
981
1003
  const effort = (opts.effort || "").trim()
982
1004
  const flags = []
1005
+ const bypass = codexHookTrustBypassFlag()
1006
+ if (bypass) flags.push(bypass)
983
1007
  if (model) flags.push(`--model ${model}`)
984
1008
  if (sandbox) flags.push(`--sandbox ${sandbox}`)
985
1009
  if (approval) flags.push(`--ask-for-approval ${approval}`)
@@ -992,14 +1016,28 @@ export function composeCodexFlags(opts = {}) {
992
1016
  * codex 既定起動コマンド (claude の buildClaudeCmd 相当)。
993
1017
  * hub オプション (model/sandbox/approval/effort) があれば反映、無ければ
994
1018
  * HUB_CODEX_CMD 環境変数、どちらも無ければ最小デフォルト `codex`。
1019
+ * ⚠️ hub オプション有無に関わらず `--dangerously-bypass-hook-trust` は常時付与する
1020
+ * (hub オプション判定と bypass flag 判定を分離しているため、HUB_CODEX_CMD
1021
+ * 分岐を選んでも bypass flag は付く。詳細は `codexHookTrustBypassFlag` 参照)。
995
1022
  * @param {{model?:string,sandbox?:string,approvalPolicy?:string,effort?:string}} [opts]
996
1023
  * @returns {string}
997
1024
  */
998
1025
  export function buildCodexCmd(opts = {}) {
999
- const flags = composeCodexFlags(opts)
1000
- if (flags) return `codex ${flags}`.trim()
1001
- if (process.env.HUB_CODEX_CMD) return process.env.HUB_CODEX_CMD
1002
- return "codex"
1026
+ const model = (opts.model || "").trim()
1027
+ const sandbox = (opts.sandbox || "").trim()
1028
+ const approval = (opts.approvalPolicy || "").trim()
1029
+ const effort = (opts.effort || "").trim()
1030
+ const hasHubOptions = !!(model || sandbox || approval || effort)
1031
+ const bypass = codexHookTrustBypassFlag()
1032
+
1033
+ if (hasHubOptions) {
1034
+ const flags = composeCodexFlags(opts)
1035
+ return `codex ${flags}`.trim()
1036
+ }
1037
+ if (process.env.HUB_CODEX_CMD) {
1038
+ return `${process.env.HUB_CODEX_CMD}${bypass ? ` ${bypass}` : ""}`.trim()
1039
+ }
1040
+ return bypass ? `codex ${bypass}` : "codex"
1003
1041
  }
1004
1042
 
1005
1043
  /**
@@ -1921,3 +1959,155 @@ export async function rebindClaudeSession(name, sessionId, opts = {}) {
1921
1959
  return { ok: false, error: err?.message || String(err) }
1922
1960
  }
1923
1961
  }
1962
+
1963
+ // ---------------------------------------------------------------------------
1964
+ // ターミナルミラー (Cockpit Codex 両対応 Stream C, 2026-07-03)
1965
+ //
1966
+ // 「チャットの動作は変えず、ターミナルのペインは普通に使えるシェルのまま、そこに
1967
+ // チャットの会話を実況表示する読み取り専用の子プロセス」をバックグラウンドで起動/
1968
+ // 停止する。session-mirror.mjs / bin/hub-agent.mjs の `mirror` サブコマンド参照。
1969
+ //
1970
+ // 安全設計:
1971
+ // - ペインの前景が plain shell のときだけ起動を許可する(`claude`/`codex` 対話 TUI が
1972
+ // 前景にいる状態で send-keys すると、実況コマンドがその対話プロンプトへの入力として
1973
+ // 誤解釈されてしまうため)。
1974
+ // - 起動コマンドは `& echo $! > <pidfile>` で直後に PID を記録し、停止はその PID を
1975
+ // kill するだけ(プロセス名 grep 等の曖昧一致はしない)。
1976
+ // ---------------------------------------------------------------------------
1977
+
1978
+ /** 現在の hub-agent パッケージの `bin/hub-agent.mjs` 絶対パス。node 実行 (`process.execPath`)
1979
+ * と組み合わせることで、対象ペインの shell の PATH に依存せず確実に起動できる。 */
1980
+ function hubAgentBinPath() {
1981
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "bin", "hub-agent.mjs")
1982
+ }
1983
+
1984
+ /** POSIX シェルの単一引数として安全な形にクォートする (単純な `'...'` ラップ + `'` エスケープ)。 */
1985
+ function shQuote(s) {
1986
+ return `'${String(s).replace(/'/g, "'\\''")}'`
1987
+ }
1988
+
1989
+ /** ミラーの pidfile パス (セッション名ごとに 1 つ)。 */
1990
+ function mirrorPidFile(sessionName) {
1991
+ return path.join(os.tmpdir(), `hub-agent-mirror-${sanitizeTmuxName(sessionName)}.pid`)
1992
+ }
1993
+
1994
+ /**
1995
+ * ミラー起動用の send-keys コマンド文字列を組み立てる (純粋関数)。
1996
+ *
1997
+ * @param {{cwd: string, sessionId: string, cliKind: 'claude'|'codex', sessionName: string}} args
1998
+ * @returns {string}
1999
+ * @throws sessionId が安全形でなければ throw (コマンド注入防止、buildResumeCmd と同方針)
2000
+ */
2001
+ export function buildMirrorStartCmd({ cwd, sessionId, cliKind, sessionName }) {
2002
+ if (!isSafeSessionId(sessionId)) {
2003
+ throw new Error(`unsafe session_id for mirror: ${String(sessionId)}`)
2004
+ }
2005
+ const kind = cliKind === "codex" ? "codex" : "claude"
2006
+ const bin = hubAgentBinPath()
2007
+ const pidFile = mirrorPidFile(sessionName)
2008
+ const args = [
2009
+ shQuote(process.execPath),
2010
+ shQuote(bin),
2011
+ "mirror",
2012
+ "--cwd",
2013
+ shQuote(cwd),
2014
+ "--session",
2015
+ shQuote(sessionId),
2016
+ "--cli-kind",
2017
+ kind,
2018
+ ].join(" ")
2019
+ return `${args} & echo $! > ${shQuote(pidFile)}; disown`
2020
+ }
2021
+
2022
+ /** ミラー停止用の send-keys コマンド文字列を組み立てる (純粋関数)。 */
2023
+ export function buildMirrorStopCmd({ sessionName }) {
2024
+ const pidFile = mirrorPidFile(sessionName)
2025
+ return `kill $(cat ${shQuote(pidFile)} 2>/dev/null) 2>/dev/null; rm -f ${shQuote(pidFile)}`
2026
+ }
2027
+
2028
+ /**
2029
+ * 対象ペインの前景が plain shell か判定する (`isPaneRunningClaude` の逆方向・保守版)。
2030
+ * 判定不能 (tmux 失敗等) は false を返す = 起動を許可しない安全側。
2031
+ *
2032
+ * @param {string} name
2033
+ * @param {{ tmux?: string }} [opts]
2034
+ * @returns {Promise<boolean>}
2035
+ */
2036
+ export async function isPaneAtShellPrompt(name, opts = {}) {
2037
+ if (!name) return false
2038
+ try {
2039
+ const { stdout } = await execFileP(tmuxBin(opts), [
2040
+ "display-message",
2041
+ "-p",
2042
+ "-t",
2043
+ `${name}:`,
2044
+ "-F",
2045
+ "#{pane_current_command}",
2046
+ ])
2047
+ const cmd = (stdout || "").trim().toLowerCase()
2048
+ const SHELLS = new Set(["fish", "bash", "zsh", "sh", "dash"])
2049
+ return SHELLS.has(cmd)
2050
+ } catch {
2051
+ return false
2052
+ }
2053
+ }
2054
+
2055
+ /**
2056
+ * ターミナルミラーを対象ペインで起動する。ペイン前景が plain shell のときのみ許可する
2057
+ * (対話 TUI が前景にいる状態での誤爆入力を防ぐ)。
2058
+ *
2059
+ * @param {string} name tmux セッション名
2060
+ * @param {{cwd: string, sessionId: string, cliKind: 'claude'|'codex'}} args
2061
+ * @param {{ tmux?: string, logger?: import('pino').Logger }} [opts]
2062
+ * @returns {Promise<{ ok: boolean, reason?: string, error?: string }>}
2063
+ */
2064
+ export async function startTerminalMirror(name, { cwd, sessionId, cliKind }, opts = {}) {
2065
+ if (!isSafeSessionName(name)) {
2066
+ return { ok: false, error: "unsafe session name" }
2067
+ }
2068
+ const atShell = await isPaneAtShellPrompt(name, opts)
2069
+ if (!atShell) {
2070
+ return {
2071
+ ok: false,
2072
+ reason: "pane-not-at-shell",
2073
+ error: "ペインが対話 TUI 実行中のためミラーを起動できません (シェルに戻ってから再試行してください)",
2074
+ }
2075
+ }
2076
+ let cmd
2077
+ try {
2078
+ cmd = buildMirrorStartCmd({ cwd, sessionId, cliKind, sessionName: name })
2079
+ } catch (err) {
2080
+ return { ok: false, error: err?.message || String(err) }
2081
+ }
2082
+ try {
2083
+ await execFileP(tmuxBin(opts), ["send-keys", "-t", name, cmd, "Enter"])
2084
+ opts.logger?.info({ session: name, cliKind, sessionId }, "terminal mirror started")
2085
+ return { ok: true }
2086
+ } catch (err) {
2087
+ opts.logger?.warn({ session: name, err: err?.message }, "terminal mirror start failed")
2088
+ return { ok: false, error: err?.message || String(err) }
2089
+ }
2090
+ }
2091
+
2092
+ /**
2093
+ * ターミナルミラーを対象ペインで停止する。pidfile に記録された PID を kill するだけの
2094
+ * ベストエフォート (既に停止済み/pidfile 無しでもエラーにしない)。
2095
+ *
2096
+ * @param {string} name tmux セッション名
2097
+ * @param {{ tmux?: string, logger?: import('pino').Logger }} [opts]
2098
+ * @returns {Promise<{ ok: boolean, error?: string }>}
2099
+ */
2100
+ export async function stopTerminalMirror(name, opts = {}) {
2101
+ if (!isSafeSessionName(name)) {
2102
+ return { ok: false, error: "unsafe session name" }
2103
+ }
2104
+ const cmd = buildMirrorStopCmd({ sessionName: name })
2105
+ try {
2106
+ await execFileP(tmuxBin(opts), ["send-keys", "-t", name, cmd, "Enter"])
2107
+ opts.logger?.info({ session: name }, "terminal mirror stop requested")
2108
+ return { ok: true }
2109
+ } catch (err) {
2110
+ opts.logger?.warn({ session: name, err: err?.message }, "terminal mirror stop failed")
2111
+ return { ok: false, error: err?.message || String(err) }
2112
+ }
2113
+ }
package/src/usage.mjs CHANGED
@@ -1152,9 +1152,13 @@ export async function getUsage() {
1152
1152
  // チャット(SDK) の rate_limit_event が statusLine cache より新しければ、5h/7d を
1153
1153
  // その実値 (utilization) で上書きする。ターミナルが statusLine を更新していない
1154
1154
  // チャットモードでも 5h/7d がライブで動くようになる。
1155
+ // `>=` (2026-07-03): ファイル mtime はミリ秒未満の粒度が丸められる環境があり、
1156
+ // 同一ミリ秒 (またはそれ以下) の解像度で statusLine 書き込みと rate_limit_event が
1157
+ // 揃うとタイになり得る。プロセス内の直近イベント (chatRateLimits) の方が
1158
+ // ディスク read-back より鮮度の情報として確実なため、タイ時は上書きを優先する。
1155
1159
  if (
1156
1160
  chatRateLimits.updatedAtMs > 0 &&
1157
- (cacheMtime === null || chatRateLimits.updatedAtMs > cacheMtime)
1161
+ (cacheMtime === null || chatRateLimits.updatedAtMs >= cacheMtime)
1158
1162
  ) {
1159
1163
  if (chatRateLimits.five_hour) {
1160
1164
  result.last5h = {
@@ -1183,7 +1187,9 @@ export async function getUsage() {
1183
1187
  const chatSnap = await readChatUsageSnapshot()
1184
1188
  if (chatSnap && chatSnap.updatedAtMs > 0) {
1185
1189
  const baseline = Math.max(cacheMtime ?? 0, chatRateLimits.updatedAtMs || 0)
1186
- if (chatSnap.updatedAtMs > baseline) {
1190
+ // `>=` (上と同じ理由): ファイル mtime とプロセス内タイムスタンプがタイになり得る。
1191
+ // chatSnap は最も直接的な情報源 (SDK control request 実値) なのでタイ時は優先する。
1192
+ if (chatSnap.updatedAtMs >= baseline) {
1187
1193
  if (chatSnap.five_hour) {
1188
1194
  result.last5h = {
1189
1195
  ...result.last5h,
@@ -1250,3 +1256,197 @@ export async function getSessionUsages() {
1250
1256
  )
1251
1257
  return out
1252
1258
  }
1259
+
1260
+ // ---------------------------------------------------------------------------
1261
+ // Codex 版 usage 集計 (Cockpit Codex 両対応 Stream B3, 2026-07-03 設計)。
1262
+ //
1263
+ // Claude は 5h/7d を「statusLine cache」(TUI) / 「SDK control request」(chat) の
1264
+ // 2 経路で取得し、context% は jsonl の assistant.usage を手集計していた。
1265
+ //
1266
+ // Codex は rollout jsonl (`~/.codex/sessions/Y/M/D/rollout-<ts>-<threadId>.jsonl`)
1267
+ // に書かれる `event_msg` (`payload.type === "token_count"`) 1 イベントだけで、
1268
+ // context 算出用のトークン数・context_window サイズ・5h/7d 相当の rate_limits
1269
+ // (used_percent 済み) の**すべて**が完成品で手に入ることを実機ライブ検証
1270
+ // (2026-07-03) で確認済み (実サンプルは正本プラン参照)。TUI モード (bare pane) も
1271
+ // chat モード (app-server 駆動、`thread/resume` が rollout から復元する設計 = 動作中
1272
+ // ずっと rollout へ書き続けている) も同じ 1 本のロジックで読める。
1273
+ //
1274
+ // ファイル特定: Claude は cwd を dir 名にエンコードして直接対応するが、Codex の
1275
+ // rollout は日付シャードで cwd 直接対応が無い。ファイル名に埋め込まれた threadId
1276
+ // (呼び出し側が attach()/bound session 解決から持っている) で glob 検索する。
1277
+ // ---------------------------------------------------------------------------
1278
+
1279
+ function codexSessionsDir() {
1280
+ return configPath("HUB_CODEX_SESSIONS_DIR", os.homedir(), ".codex", "sessions")
1281
+ }
1282
+
1283
+ // rate_limits.primary/secondary が無いときの limit は「% 表示のみ」の意味で 100 固定
1284
+ // (Claude の readOfficial の bucketize と同じ約束事)。
1285
+ const CODEX_DEFAULT_LIMIT = 100
1286
+
1287
+ /** threadId から rollout jsonl の絶対パスを検索する(日付シャード配下、直近 N 日のみ走査)。
1288
+ * 7d 窓 + バッファで 9 日分を既定にする(月またぎでも大抵 1〜2 ディレクトリの readdir で済む)。
1289
+ * `session-mirror.mjs`(Stream C ターミナルミラー)からも再利用するため export する。
1290
+ * @returns {Promise<string|null>}
1291
+ */
1292
+ export async function findCodexRolloutFile(threadId, { lookbackDays = 9, now = Date.now() } = {}) {
1293
+ if (!threadId) return null
1294
+ const base = codexSessionsDir()
1295
+ for (let i = 0; i < lookbackDays; i++) {
1296
+ const d = new Date(now - i * 24 * 60 * 60 * 1000)
1297
+ const y = String(d.getFullYear())
1298
+ const m = String(d.getMonth() + 1).padStart(2, "0")
1299
+ const day = String(d.getDate()).padStart(2, "0")
1300
+ const dir = path.join(base, y, m, day)
1301
+ const files = await fs.readdir(dir).catch(() => null)
1302
+ if (!files) continue
1303
+ const match = files.find((f) => f.endsWith(`-${threadId}.jsonl`))
1304
+ if (match) return path.join(dir, match)
1305
+ }
1306
+ return null
1307
+ }
1308
+
1309
+ /** P5(perf): rollout ファイルの token_count 抽出結果を mtime+size でメモ化。 */
1310
+ const _codexTokenCountMemo = new Map() // fp → { mtimeMs, size, result }
1311
+
1312
+ /**
1313
+ * 単一 rollout jsonl から末尾(最新)の `token_count` event_msg を 1 件取り出す。
1314
+ * 末尾 tail で見つからない稀なケース(token_count 未着のセッション冒頭等)のみ
1315
+ * 全文フォールバックする(Claude 側 `latestJsonlContext`/`turnActiveForCwd` と同じ
1316
+ * tail-first パターン)。見つからなければ null。
1317
+ *
1318
+ * @returns {Promise<{payload: object, ts: number|null}|null>}
1319
+ */
1320
+ async function latestTokenCountFromRollout(fp) {
1321
+ let st
1322
+ try {
1323
+ st = await fs.stat(fp)
1324
+ } catch {
1325
+ return null
1326
+ }
1327
+ const memo = _codexTokenCountMemo.get(fp)
1328
+ if (memo && memo.mtimeMs === st.mtimeMs && memo.size === st.size) {
1329
+ return memo.result
1330
+ }
1331
+ const scan = (text) => {
1332
+ const lines = text.split("\n")
1333
+ for (let i = lines.length - 1; i >= 0; i--) {
1334
+ const line = lines[i]
1335
+ if (!line || !line.includes('"token_count"')) continue
1336
+ let d
1337
+ try {
1338
+ d = JSON.parse(line)
1339
+ } catch {
1340
+ continue
1341
+ }
1342
+ if (d.type !== "event_msg") continue
1343
+ const payload = d.payload
1344
+ if (!payload || payload.type !== "token_count") continue
1345
+ return { payload, ts: parseEntryTs(d.timestamp) }
1346
+ }
1347
+ return null
1348
+ }
1349
+ let result = null
1350
+ const tail = await readTail(fp, 64 * 1024)
1351
+ if (tail != null) result = scan(tail)
1352
+ if (!result) {
1353
+ const full = await readOrNull(fp)
1354
+ if (full != null) result = scan(full)
1355
+ }
1356
+ _codexTokenCountMemo.set(fp, { mtimeMs: st.mtimeMs, size: st.size, result })
1357
+ return result
1358
+ }
1359
+
1360
+ /** テスト用: codex token_count メモをクリアする。 */
1361
+ export function _resetCodexTokenCountMemo() {
1362
+ _codexTokenCountMemo.clear()
1363
+ }
1364
+
1365
+ /**
1366
+ * 指定 threadId の Codex セッションから usage (5h/7d + context%) を返す。
1367
+ * Claude の `getUsage()` と同じ shape (`UsageStats`) を返すため、frontend / wire
1368
+ * protocol の分岐を増やさずに済む。
1369
+ *
1370
+ * ⚠️ `last5h`/`last7d` は Codex 側アカウント全体の rate limit(ChatGPT/OpenAI
1371
+ * サブスクリプション)であり、Claude の `last5h`/`last7d`(Anthropic サブスク)とは
1372
+ * **別契約の別数値**。呼び出し側で Claude usage と単純合算/上書きしないこと
1373
+ * (どちらのアカウントの残量か分からなくなる)。
1374
+ *
1375
+ * @param {string} threadId
1376
+ * @returns {Promise<import("./usage.mjs").UsageStats|null>} 見つからなければ null
1377
+ */
1378
+ export async function codexUsageForThread(threadId) {
1379
+ if (!threadId) return null
1380
+ const fp = await findCodexRolloutFile(threadId)
1381
+ if (!fp) return null
1382
+ const found = await latestTokenCountFromRollout(fp)
1383
+ if (!found) return null
1384
+ const info = found.payload.info
1385
+ const rateLimits = found.payload.rate_limits
1386
+
1387
+ let contextPercent = null
1388
+ const totalTok = info?.total_token_usage
1389
+ const windowSize = info?.model_context_window
1390
+ if (totalTok && typeof windowSize === "number" && windowSize > 0) {
1391
+ const tokens =
1392
+ typeof totalTok.total_tokens === "number"
1393
+ ? totalTok.total_tokens
1394
+ : (totalTok.input_tokens || 0) +
1395
+ (totalTok.cached_input_tokens || 0) +
1396
+ (totalTok.output_tokens || 0)
1397
+ contextPercent = Math.min(100, Math.round((tokens / windowSize) * 1000) / 10)
1398
+ }
1399
+
1400
+ const bucketize = (w) => {
1401
+ if (!w || typeof w.used_percent !== "number") return emptyBucket(CODEX_DEFAULT_LIMIT)
1402
+ let resetMs = null
1403
+ if (typeof w.resets_at === "number") {
1404
+ resetMs = w.resets_at < 1e12 ? w.resets_at * 1000 : w.resets_at
1405
+ }
1406
+ return {
1407
+ tokens: 0,
1408
+ messages: 0,
1409
+ limit: CODEX_DEFAULT_LIMIT,
1410
+ percent: w.used_percent,
1411
+ oldestTsMs: null,
1412
+ resetAtMs: resetMs,
1413
+ }
1414
+ }
1415
+
1416
+ return {
1417
+ plan: rateLimits?.plan_type || "codex",
1418
+ computedAt: Date.now(),
1419
+ source: rateLimits ? "official" : "estimate",
1420
+ context: contextPercent,
1421
+ last5h: bucketize(rateLimits?.primary),
1422
+ last7d: bucketize(rateLimits?.secondary),
1423
+ }
1424
+ }
1425
+
1426
+ /**
1427
+ * 複数の Codex セッション (threadId + cwd + sessionId) から `SessionUsageEntry[]`
1428
+ * (Claude の `getSessionUsages()` と同 shape) を返す。見つからなかった/context 不明な
1429
+ * セッションは結果から除外する (Claude 版と同じ「分かるものだけ返す」約束)。
1430
+ *
1431
+ * @param {Array<{sessionId: string, cwd: string, threadId: string}>} liveSessions
1432
+ * @returns {Promise<Array<{sessionId: string, cwd: string, contextPercent: number, updatedAtMs: number}>>}
1433
+ */
1434
+ export async function codexSessionUsages(liveSessions) {
1435
+ if (!Array.isArray(liveSessions) || liveSessions.length === 0) return []
1436
+ const now = Date.now()
1437
+ const out = []
1438
+ await Promise.all(
1439
+ liveSessions.map(async ({ sessionId, cwd, threadId }) => {
1440
+ if (!threadId || !cwd) return
1441
+ const usage = await codexUsageForThread(threadId)
1442
+ if (!usage || typeof usage.context !== "number") return
1443
+ out.push({
1444
+ sessionId: sessionId || threadId,
1445
+ cwd,
1446
+ contextPercent: usage.context,
1447
+ updatedAtMs: now,
1448
+ })
1449
+ }),
1450
+ )
1451
+ return out
1452
+ }