@cocorograph/hub-agent 0.7.32 → 0.7.34
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/bin/hub-agent.mjs +96 -22
- package/package.json +1 -1
- package/src/codex-appserver-client.mjs +14 -1
- package/src/codex-rollout-watch.mjs +148 -0
- package/src/codex-stream-bridge.mjs +41 -0
- package/src/enroll.mjs +48 -27
- package/src/hub-bundle.mjs +115 -38
- package/src/main.mjs +164 -2
- package/src/session-mirror.mjs +190 -0
- package/src/skills.mjs +33 -6
- package/src/tmux.mjs +195 -5
- package/src/usage.mjs +194 -0
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(
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
@@ -1256,3 +1256,197 @@ export async function getSessionUsages() {
|
|
|
1256
1256
|
)
|
|
1257
1257
|
return out
|
|
1258
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
|
+
}
|