@cocorograph/hub-agent 0.8.45 → 0.8.47
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 +1 -1
- package/src/codex-appserver-client.mjs +12 -5
- package/src/codex-binary.mjs +90 -0
- package/src/codex-stream-bridge.mjs +4 -5
- package/src/config.mjs +10 -4
- package/src/hub-bundle.mjs +18 -3
- package/src/main.mjs +130 -61
- package/src/tmux.mjs +45 -12
- package/src/usage.mjs +169 -31
- package/src/ws-client.mjs +136 -37
package/src/tmux.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { detectSessionState, getSessionCwd } from "./state.mjs"
|
|
|
27
27
|
import { shouldLaunchPaneCli } from "./pane-cli-flag.mjs"
|
|
28
28
|
import { baseSessionName, sessionNameFor } from "./session-names.mjs"
|
|
29
29
|
import { boundSessionId, getSessionUsages, isFreshUnboundBind, jsonlContextForCwd } from "./usage.mjs"
|
|
30
|
+
import { quotePosixShellArg, requireCodexBinary } from "./codex-binary.mjs"
|
|
30
31
|
|
|
31
32
|
const execFileP = promisify(execFile)
|
|
32
33
|
|
|
@@ -514,6 +515,19 @@ function tmuxBin(opts = {}) {
|
|
|
514
515
|
return opts.tmuxBin || DEFAULT_TMUX_BIN
|
|
515
516
|
}
|
|
516
517
|
|
|
518
|
+
/**
|
|
519
|
+
* pane CLI に渡す initialPrompt の危険な制御文字を拒否する。
|
|
520
|
+
* 対話入力として必要な tab (HT) / LF / CR は許可する。
|
|
521
|
+
*/
|
|
522
|
+
export function validateInitialPrompt(initialPrompt) {
|
|
523
|
+
if (
|
|
524
|
+
typeof initialPrompt === "string" &&
|
|
525
|
+
/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(initialPrompt)
|
|
526
|
+
) {
|
|
527
|
+
throw new Error("initialPrompt contains unsupported control characters")
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
517
531
|
/**
|
|
518
532
|
* claudeCmd の各セグメント末尾に initialPrompt を shell-safe quoting で
|
|
519
533
|
* 末尾引数として注入する。
|
|
@@ -523,13 +537,13 @@ function tmuxBin(opts = {}) {
|
|
|
523
537
|
* 旧実装は `--permission-mode auto` を marker として正規表現マッチしていたが、
|
|
524
538
|
* model / permission-mode が個人ごとに変わる前提で marker 非依存に変更した。
|
|
525
539
|
*
|
|
526
|
-
*
|
|
527
|
-
* 結果は double-quoted な shell リテラルとしてそのまま使える。
|
|
540
|
+
* POSIX shell の単一 argv として quote し、`$()` / backtick 等を展開させない。
|
|
528
541
|
*/
|
|
529
542
|
export function injectInitialPrompt(claudeCmd, initialPrompt) {
|
|
530
543
|
if (!initialPrompt || typeof initialPrompt !== "string") return claudeCmd
|
|
544
|
+
validateInitialPrompt(initialPrompt)
|
|
531
545
|
if (typeof claudeCmd !== "string" || claudeCmd.length === 0) return claudeCmd
|
|
532
|
-
const quoted =
|
|
546
|
+
const quoted = quotePosixShellArg(initialPrompt)
|
|
533
547
|
return claudeCmd
|
|
534
548
|
.split("||")
|
|
535
549
|
.map((part) => `${part.trim()} ${quoted}`)
|
|
@@ -555,7 +569,7 @@ export function injectInitialPrompt(claudeCmd, initialPrompt) {
|
|
|
555
569
|
export function injectConfigDirEnv(claudeCmd, configDir) {
|
|
556
570
|
if (!configDir || typeof configDir !== "string") return claudeCmd
|
|
557
571
|
if (typeof claudeCmd !== "string" || claudeCmd.length === 0) return claudeCmd
|
|
558
|
-
const assignment = `CLAUDE_CONFIG_DIR=${
|
|
572
|
+
const assignment = `CLAUDE_CONFIG_DIR=${quotePosixShellArg(configDir)}`
|
|
559
573
|
return claudeCmd
|
|
560
574
|
.split("||")
|
|
561
575
|
.map((part) => `${assignment} ${part.trim()}`)
|
|
@@ -790,9 +804,16 @@ export async function killManySessions(names, opts = {}) {
|
|
|
790
804
|
* TUI の初回プロンプトとして実行される (`/orchestrate` 等のスキル発火に利用)
|
|
791
805
|
*/
|
|
792
806
|
export async function createSession(name, cwd, opts = {}) {
|
|
807
|
+
const launchPaneCli = shouldLaunchPaneCli()
|
|
808
|
+
if (launchPaneCli) validateInitialPrompt(opts.initialPrompt)
|
|
793
809
|
// tmux は `-c` をシェル展開しないため、ここで `~` を絶対パスに展開する。
|
|
794
810
|
// 展開後の cwd は同名引数で以降の検証 / tmux に渡す。
|
|
795
811
|
const resolvedCwd = expandTilde(cwd)
|
|
812
|
+
// resolver 失敗時に workspace 作成・Hub 同期・tmux 作成の副作用を残さない。
|
|
813
|
+
const prebuiltCodexBaseCmd =
|
|
814
|
+
launchPaneCli && opts.cliKind === "codex"
|
|
815
|
+
? opts.codexCmd ?? buildCodexCmd(opts)
|
|
816
|
+
: null
|
|
796
817
|
// cwd 存在チェック (旧 Cockpit 単体実装と同等の防御)
|
|
797
818
|
// 無いまま tmux に渡すと pane の current_path が HOME など別ディレクトリに
|
|
798
819
|
// fallback して、SessionStart hook が project を解決できなくなる。
|
|
@@ -957,7 +978,7 @@ export async function createSession(name, cwd, opts = {}) {
|
|
|
957
978
|
// 効くよう main.mjs の syncTmuxProfileEnv / syncTmuxCodexEnv が
|
|
958
979
|
// `tmux set-environment -g` でサーバーのグローバル環境へ流し込んでいるため、
|
|
959
980
|
// ここでコマンド文字列に焼き込む必要があるのは CLI を起動する場合だけ。
|
|
960
|
-
if (!
|
|
981
|
+
if (!launchPaneCli) {
|
|
961
982
|
opts.logger?.debug?.(
|
|
962
983
|
{ session: name, cwd: resolvedCwd },
|
|
963
984
|
"pane CLI launch skipped (set HUB_AGENT_TMUX_LAUNCH_CLI=1 to enable)",
|
|
@@ -971,7 +992,7 @@ export async function createSession(name, cwd, opts = {}) {
|
|
|
971
992
|
opts.cliKind === "codex"
|
|
972
993
|
? injectInitialPrompt(
|
|
973
994
|
injectCodexHomeEnv(
|
|
974
|
-
|
|
995
|
+
prebuiltCodexBaseCmd,
|
|
975
996
|
opts.codexHome ?? process.env.CODEX_HOME ?? null,
|
|
976
997
|
),
|
|
977
998
|
opts.initialPrompt,
|
|
@@ -1041,6 +1062,15 @@ function codexHookTrustBypassFlag() {
|
|
|
1041
1062
|
return process.env.HUB_CODEX_BYPASS_HOOK_TRUST !== "0" ? "--dangerously-bypass-hook-trust" : ""
|
|
1042
1063
|
}
|
|
1043
1064
|
|
|
1065
|
+
function codexShellBinary(opts = {}) {
|
|
1066
|
+
const resolver = opts._resolveCodexBinary || requireCodexBinary
|
|
1067
|
+
const resolved = resolver()
|
|
1068
|
+
if (!resolved?.available || !resolved.path) {
|
|
1069
|
+
throw new Error(resolved?.error || "codex CLI is unavailable")
|
|
1070
|
+
}
|
|
1071
|
+
return quotePosixShellArg(resolved.path)
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1044
1074
|
/**
|
|
1045
1075
|
* agent 設定から codex 起動 flags を組み立てる (純粋関数)。
|
|
1046
1076
|
* model / sandbox / approvalPolicy / effort のうち指定されたものだけを並べ、
|
|
@@ -1068,7 +1098,7 @@ export function composeCodexFlags(opts = {}) {
|
|
|
1068
1098
|
/**
|
|
1069
1099
|
* codex 既定起動コマンド (claude の buildClaudeCmd 相当)。
|
|
1070
1100
|
* hub オプション (model/sandbox/approval/effort) があれば反映、無ければ
|
|
1071
|
-
* HUB_CODEX_CMD
|
|
1101
|
+
* HUB_CODEX_CMD 環境変数、どちらも無ければ共通 resolver の実行ファイル。
|
|
1072
1102
|
* ⚠️ hub オプション有無に関わらず `--dangerously-bypass-hook-trust` は常時付与する
|
|
1073
1103
|
* (hub オプション判定と bypass flag 判定を分離しているため、HUB_CODEX_CMD
|
|
1074
1104
|
* 分岐を選んでも bypass flag は付く。詳細は `codexHookTrustBypassFlag` 参照)。
|
|
@@ -1085,12 +1115,13 @@ export function buildCodexCmd(opts = {}) {
|
|
|
1085
1115
|
|
|
1086
1116
|
if (hasHubOptions) {
|
|
1087
1117
|
const flags = composeCodexFlags(opts)
|
|
1088
|
-
return
|
|
1118
|
+
return `${codexShellBinary(opts)} ${flags}`.trim()
|
|
1089
1119
|
}
|
|
1090
1120
|
if (process.env.HUB_CODEX_CMD) {
|
|
1091
1121
|
return `${process.env.HUB_CODEX_CMD}${bypass ? ` ${bypass}` : ""}`.trim()
|
|
1092
1122
|
}
|
|
1093
|
-
|
|
1123
|
+
const codex = codexShellBinary(opts)
|
|
1124
|
+
return bypass ? `${codex} ${bypass}` : codex
|
|
1094
1125
|
}
|
|
1095
1126
|
|
|
1096
1127
|
/**
|
|
@@ -1101,7 +1132,8 @@ export function buildCodexCmd(opts = {}) {
|
|
|
1101
1132
|
*/
|
|
1102
1133
|
export function buildCodexFreshCmd(opts = {}) {
|
|
1103
1134
|
const flags = composeCodexFlags(opts)
|
|
1104
|
-
|
|
1135
|
+
const codex = codexShellBinary(opts)
|
|
1136
|
+
return `${codex}${flags ? " " + flags : ""}`.trim()
|
|
1105
1137
|
}
|
|
1106
1138
|
|
|
1107
1139
|
/**
|
|
@@ -1117,7 +1149,8 @@ export function buildCodexResumeCmd(sessionId, opts = {}) {
|
|
|
1117
1149
|
throw new Error(`unsafe session_id for codex resume: ${String(sessionId)}`)
|
|
1118
1150
|
}
|
|
1119
1151
|
const flags = composeCodexFlags(opts)
|
|
1120
|
-
|
|
1152
|
+
const codex = codexShellBinary(opts)
|
|
1153
|
+
return `${codex} resume ${sessionId}${flags ? " " + flags : ""}`.trim()
|
|
1121
1154
|
}
|
|
1122
1155
|
|
|
1123
1156
|
/**
|
|
@@ -1130,7 +1163,7 @@ export function buildCodexResumeCmd(sessionId, opts = {}) {
|
|
|
1130
1163
|
export function injectCodexHomeEnv(codexCmd, codexHome) {
|
|
1131
1164
|
if (!codexHome || typeof codexHome !== "string") return codexCmd
|
|
1132
1165
|
if (typeof codexCmd !== "string" || codexCmd.length === 0) return codexCmd
|
|
1133
|
-
const assignment = `CODEX_HOME=${
|
|
1166
|
+
const assignment = `CODEX_HOME=${quotePosixShellArg(codexHome)}`
|
|
1134
1167
|
return codexCmd
|
|
1135
1168
|
.split("||")
|
|
1136
1169
|
.map((part) => `${assignment} ${part.trim()}`)
|
package/src/usage.mjs
CHANGED
|
@@ -1482,6 +1482,41 @@ function threadIdFromRolloutFilename(fp) {
|
|
|
1482
1482
|
const CODEX_ROLLOUT_HEAD_CHUNK_BYTES = 32 * 1024
|
|
1483
1483
|
const CODEX_ROLLOUT_PREVIEW_MAX_BYTES = 1024 * 1024
|
|
1484
1484
|
|
|
1485
|
+
// sessionsDir ごとの派生インデックス。rollout 自体が正本なので永続化は不要。
|
|
1486
|
+
// _codexTokenCountMemo と同じ mtime+size 無効化規約で、変更ファイルだけを再解析する。
|
|
1487
|
+
// 共通 helper 化は usage 集計側への回帰範囲を広げるため、一覧用 index として独立させる。
|
|
1488
|
+
const _codexThreadListIndex = new Map() // base → Map<fp, {mtimeMs,size,lastSeenAt,meta}>
|
|
1489
|
+
const _codexThreadListLastWarnAt = new Map() // base → wall-clock ms
|
|
1490
|
+
const CODEX_THREAD_LIST_LOG_INTERVAL_MS = 5 * 60 * 1000
|
|
1491
|
+
const CODEX_THREAD_LIST_STALE_ENTRY_MS = 24 * 60 * 60 * 1000
|
|
1492
|
+
const CODEX_ROLLOUT_PARSE_IO_ERROR = Symbol("codex-rollout-parse-io-error")
|
|
1493
|
+
|
|
1494
|
+
function sameCodexThreadListFingerprint(a, b) {
|
|
1495
|
+
return a?.mtimeMs === b?.mtimeMs && a?.size === b?.size
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function codexThreadListMetaRank(meta) {
|
|
1499
|
+
if (meta?.preview_complete && meta.cwd && meta.preview) return 3
|
|
1500
|
+
if (meta?.preview_complete) return 2
|
|
1501
|
+
return meta ? 1 : 0
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// observed 後に別呼び出しが新 fingerprint / complete meta を置いた場合は降格させない。
|
|
1505
|
+
function commitCodexThreadListEntry(index, fp, observed, next) {
|
|
1506
|
+
const current = index.get(fp)
|
|
1507
|
+
if (current === observed) {
|
|
1508
|
+
index.set(fp, next)
|
|
1509
|
+
return
|
|
1510
|
+
}
|
|
1511
|
+
if (!current || !sameCodexThreadListFingerprint(current, next)) return
|
|
1512
|
+
const meta =
|
|
1513
|
+
codexThreadListMetaRank(next.meta) > codexThreadListMetaRank(current.meta)
|
|
1514
|
+
? next.meta
|
|
1515
|
+
: current.meta
|
|
1516
|
+
const merged = { ...current, lastSeenAt: Math.max(current.lastSeenAt, next.lastSeenAt), meta }
|
|
1517
|
+
index.set(fp, merged)
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1485
1520
|
/**
|
|
1486
1521
|
* rollout の先頭から一覧用メタ (thread_id / cwd / preview) を取り出す。
|
|
1487
1522
|
*
|
|
@@ -1492,9 +1527,9 @@ const CODEX_ROLLOUT_PREVIEW_MAX_BYTES = 1024 * 1024
|
|
|
1492
1527
|
*
|
|
1493
1528
|
* @param {string} fp
|
|
1494
1529
|
* @param {string} expectedCwd cwd-scoped 一覧の対象。別 cwd は session_meta 読み取り後に早期終了。
|
|
1495
|
-
* @returns {Promise<{thread_id:string,cwd:string,preview:string}|null>}
|
|
1530
|
+
* @returns {Promise<{thread_id:string,cwd:string,preview:string,preview_complete:boolean}|null|symbol>}
|
|
1496
1531
|
*/
|
|
1497
|
-
async function parseCodexRolloutHead(fp, expectedCwd = "") {
|
|
1532
|
+
async function parseCodexRolloutHead(fp, expectedCwd = "", testHooks = null) {
|
|
1498
1533
|
let thread_id = threadIdFromRolloutFilename(fp)
|
|
1499
1534
|
let cwd = ""
|
|
1500
1535
|
let preview = ""
|
|
@@ -1502,6 +1537,7 @@ async function parseCodexRolloutHead(fp, expectedCwd = "") {
|
|
|
1502
1537
|
const decoder = new StringDecoder("utf8")
|
|
1503
1538
|
let pending = ""
|
|
1504
1539
|
let offset = 0
|
|
1540
|
+
let result
|
|
1505
1541
|
|
|
1506
1542
|
const parseLine = (line) => {
|
|
1507
1543
|
if (!line) return
|
|
@@ -1528,8 +1564,13 @@ async function parseCodexRolloutHead(fp, expectedCwd = "") {
|
|
|
1528
1564
|
}
|
|
1529
1565
|
|
|
1530
1566
|
try {
|
|
1567
|
+
if (testHooks?.beforeHeaderOpen) await testHooks.beforeHeaderOpen(fp)
|
|
1568
|
+
testHooks?.onHeaderOpen?.(fp)
|
|
1569
|
+
if (testHooks?.failHeaderOpen?.(fp)) {
|
|
1570
|
+
throw Object.assign(new Error("injected header open failure"), { code: "EIO" })
|
|
1571
|
+
}
|
|
1531
1572
|
fh = await fs.open(fp, "r")
|
|
1532
|
-
while (offset < CODEX_ROLLOUT_PREVIEW_MAX_BYTES) {
|
|
1573
|
+
scan: while (offset < CODEX_ROLLOUT_PREVIEW_MAX_BYTES) {
|
|
1533
1574
|
const readSize = Math.min(
|
|
1534
1575
|
CODEX_ROLLOUT_HEAD_CHUNK_BYTES,
|
|
1535
1576
|
CODEX_ROLLOUT_PREVIEW_MAX_BYTES - offset,
|
|
@@ -1550,12 +1591,14 @@ async function parseCodexRolloutHead(fp, expectedCwd = "") {
|
|
|
1550
1591
|
pending = pending.slice(newline + 1)
|
|
1551
1592
|
parseLine(line)
|
|
1552
1593
|
// session_meta は先頭行にある。別 workspace の候補に対して巨大な初期コンテキストを
|
|
1553
|
-
//
|
|
1594
|
+
// 読み進めないことで、初回の派生インデックス構築コストを抑える。
|
|
1554
1595
|
if (cwd && expectedCwd && cwd !== expectedCwd) {
|
|
1555
|
-
|
|
1596
|
+
result = thread_id ? { thread_id, cwd, preview, preview_complete: false } : null
|
|
1597
|
+
break scan
|
|
1556
1598
|
}
|
|
1557
1599
|
if (cwd && preview) {
|
|
1558
|
-
|
|
1600
|
+
result = thread_id ? { thread_id, cwd, preview, preview_complete: true } : null
|
|
1601
|
+
break scan
|
|
1559
1602
|
}
|
|
1560
1603
|
}
|
|
1561
1604
|
|
|
@@ -1567,8 +1610,11 @@ async function parseCodexRolloutHead(fp, expectedCwd = "") {
|
|
|
1567
1610
|
break
|
|
1568
1611
|
}
|
|
1569
1612
|
}
|
|
1613
|
+
if (result === undefined) {
|
|
1614
|
+
result = thread_id ? { thread_id, cwd, preview, preview_complete: true } : null
|
|
1615
|
+
}
|
|
1570
1616
|
} catch {
|
|
1571
|
-
|
|
1617
|
+
result = CODEX_ROLLOUT_PARSE_IO_ERROR
|
|
1572
1618
|
} finally {
|
|
1573
1619
|
try {
|
|
1574
1620
|
await fh?.close()
|
|
@@ -1577,20 +1623,22 @@ async function parseCodexRolloutHead(fp, expectedCwd = "") {
|
|
|
1577
1623
|
}
|
|
1578
1624
|
}
|
|
1579
1625
|
|
|
1580
|
-
if (
|
|
1581
|
-
|
|
1626
|
+
if (result !== CODEX_ROLLOUT_PARSE_IO_ERROR && testHooks?.afterHeaderParse) {
|
|
1627
|
+
await testHooks.afterHeaderParse(fp, result)
|
|
1628
|
+
}
|
|
1629
|
+
return result
|
|
1582
1630
|
}
|
|
1583
1631
|
|
|
1584
1632
|
/**
|
|
1585
1633
|
* cwd 配下の Codex thread 一覧を rollout の disk 走査だけで作る (app-server を spawn しない)。
|
|
1586
1634
|
* 直近 lookbackDays の日付シャードから rollout-*.jsonl を集め、mtime 降順に並べ、header を
|
|
1587
|
-
*
|
|
1635
|
+
* 派生インデックスを更新してから cwd 一致分を limit 件まで返す。返り値の shape は旧 listThreads と同一:
|
|
1588
1636
|
* {thread_id, cwd, preview, updated_at_ms}[]
|
|
1589
1637
|
*
|
|
1590
1638
|
* ⚠️ rollout は「作成日」シャードに置かれ以後追記される。作成が lookbackDays より前で今日
|
|
1591
1639
|
* resume された長寿命スレッドは窓外で拾えない (findCodexRolloutFile の高速パスと同じ限界)。
|
|
1592
|
-
* 一覧 (picker) 用途では直近が支配的なので許容。
|
|
1593
|
-
*
|
|
1640
|
+
* 一覧 (picker) 用途では直近が支配的なので許容。maxScan は既存 API 互換のため受け取っても
|
|
1641
|
+
* 無視し、cwd ごとの一覧欠落を起こすグローバル走査上限には使わない。
|
|
1594
1642
|
*
|
|
1595
1643
|
* @returns {Promise<Array<{thread_id:string, cwd:string, preview:string, updated_at_ms:number}>>}
|
|
1596
1644
|
*/
|
|
@@ -1598,10 +1646,11 @@ export async function listCodexThreadsFromDisk({
|
|
|
1598
1646
|
cwd,
|
|
1599
1647
|
limit,
|
|
1600
1648
|
lookbackDays = 180,
|
|
1601
|
-
maxScan = 800,
|
|
1602
1649
|
now = Date.now(),
|
|
1603
1650
|
logger,
|
|
1604
1651
|
sessionsDir,
|
|
1652
|
+
// テスト専用。production 呼び出しでは指定しない。
|
|
1653
|
+
_testHooks,
|
|
1605
1654
|
} = {}) {
|
|
1606
1655
|
// cwd-scoped picker 用。cwd 未指定なら「全 workspace を返す」ではなく空を返す (cross-review
|
|
1607
1656
|
// major3: 空 cwd で全 workspace の rollout が混入するのを防ぐ。呼び出し側 frontend は常に cwd を
|
|
@@ -1610,13 +1659,16 @@ export async function listCodexThreadsFromDisk({
|
|
|
1610
1659
|
// sessionsDir は self-describing read (Read-plane 案C ①) で呼び出し側が
|
|
1611
1660
|
// 「リクエストの profile_id から解決した読み取り先」を明示注入する経路。
|
|
1612
1661
|
// 未指定なら従来どおり env 由来の active (codexSessionsDir) にフォールバックする。
|
|
1613
|
-
const base = sessionsDir || codexSessionsDir()
|
|
1614
|
-
|
|
1662
|
+
const base = path.resolve(sessionsDir || codexSessionsDir())
|
|
1663
|
+
const seenAt = _testHooks?.nowMs?.() ?? Date.now()
|
|
1664
|
+
// 1. lookback 窓内の全 rollout を stat して {fp, mtimeMs, size} を集める。
|
|
1615
1665
|
// ⚠️ updated_at_ms (= mtime) 降順の契約を守るには「どれが更新新しいか」を知る必要があり、
|
|
1616
1666
|
// それには窓内の全 stat が要る (cross-review round2: 収集を作成日 shard 順で打ち切ると、
|
|
1617
1667
|
// 作成が古く更新が新しい長寿命 rollout を落として mtime 降順に反する)。stat は 1 syscall で
|
|
1618
|
-
// 安価かつ lookbackDays
|
|
1619
|
-
const candidates = [] // {fp, mtimeMs}
|
|
1668
|
+
// 安価かつ lookbackDays で有界。header open+read は下の派生インデックスで変更分だけに抑える。
|
|
1669
|
+
const candidates = [] // {fp, mtimeMs, size}
|
|
1670
|
+
const discovered = new Set() // readdir で存在を確認できた fp (stat 一時失敗でも削除扱いにしない)
|
|
1671
|
+
const scannedDirs = new Set()
|
|
1620
1672
|
for (let i = 0; i < lookbackDays; i++) {
|
|
1621
1673
|
const d = new Date(now - i * 24 * 60 * 60 * 1000)
|
|
1622
1674
|
const y = String(d.getFullYear())
|
|
@@ -1625,27 +1677,97 @@ export async function listCodexThreadsFromDisk({
|
|
|
1625
1677
|
const dir = path.join(base, y, m, day)
|
|
1626
1678
|
const files = await fs.readdir(dir).catch(() => null)
|
|
1627
1679
|
if (!files) continue
|
|
1680
|
+
scannedDirs.add(dir)
|
|
1628
1681
|
for (const f of files) {
|
|
1629
1682
|
if (!f.startsWith("rollout-") || !f.endsWith(".jsonl")) continue
|
|
1630
1683
|
const fp = path.join(dir, f)
|
|
1684
|
+
discovered.add(fp)
|
|
1631
1685
|
const st = await fs.stat(fp).catch(() => null)
|
|
1632
|
-
if (st) candidates.push({ fp, mtimeMs: st.mtimeMs })
|
|
1686
|
+
if (st) candidates.push({ fp, mtimeMs: st.mtimeMs, size: st.size })
|
|
1633
1687
|
}
|
|
1634
1688
|
}
|
|
1635
|
-
// 2. 更新が新しい順 (mtime desc)
|
|
1689
|
+
// 2. 更新が新しい順 (mtime desc)。候補一覧自体は毎回取り直し、削除も検出する。
|
|
1636
1690
|
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
1637
|
-
|
|
1638
|
-
|
|
1691
|
+
let index = _codexThreadListIndex.get(base)
|
|
1692
|
+
if (!index) {
|
|
1693
|
+
index = new Map()
|
|
1694
|
+
_codexThreadListIndex.set(base, index)
|
|
1695
|
+
}
|
|
1696
|
+
for (const fp of index.keys()) {
|
|
1697
|
+
// 今回実際に走査できた shard 内の不在だけを削除扱いにする。短い lookback や別 now の
|
|
1698
|
+
// 呼び出しが、走査範囲外の cache を消して互いに再openを誘発しないようにする。
|
|
1699
|
+
if (scannedDirs.has(path.dirname(fp)) && !discovered.has(fp)) {
|
|
1700
|
+
index.delete(fp)
|
|
1701
|
+
_testHooks?.onIndexPrune?.(fp)
|
|
1702
|
+
} else {
|
|
1703
|
+
const entry = index.get(fp)
|
|
1704
|
+
if (!discovered.has(fp) && seenAt - entry.lastSeenAt >= CODEX_THREAD_LIST_STALE_ENTRY_MS) {
|
|
1705
|
+
index.delete(fp)
|
|
1706
|
+
_testHooks?.onIndexPrune?.(fp)
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// 3. 呼び出しローカル snapshot を候補取得時点の cache から作る。以後、並行呼び出しが
|
|
1712
|
+
// global index を prune / 更新しても、この呼び出しの出力は snapshot から安定して作れる。
|
|
1713
|
+
const snapshot = new Map()
|
|
1714
|
+
for (const { fp, mtimeMs, size } of candidates) {
|
|
1715
|
+
const cached = index.get(fp)
|
|
1716
|
+
if (cached && cached.mtimeMs === mtimeMs && cached.size === size) {
|
|
1717
|
+
const seen = { ...cached, lastSeenAt: seenAt }
|
|
1718
|
+
commitCodexThreadListEntry(index, fp, cached, seen)
|
|
1719
|
+
// snapshot はこの呼び出しが観測した候補を正本にし、並行書戻しの成否に依存させない。
|
|
1720
|
+
snapshot.set(fp, seen)
|
|
1721
|
+
} else if (
|
|
1722
|
+
cached?.meta?.preview_complete &&
|
|
1723
|
+
cached.meta.cwd &&
|
|
1724
|
+
cached.meta.preview &&
|
|
1725
|
+
size > cached.size &&
|
|
1726
|
+
mtimeMs >= cached.mtimeMs
|
|
1727
|
+
) {
|
|
1728
|
+
// rollout は append-only。preview まで確定済みなら末尾追記で header は変わらないため、
|
|
1729
|
+
// fingerprint だけ進める。truncate / mtime 巻戻し / preview 未完了は下で再解析する。
|
|
1730
|
+
const appended = { mtimeMs, size, lastSeenAt: seenAt, meta: cached.meta }
|
|
1731
|
+
commitCodexThreadListEntry(index, fp, cached, appended)
|
|
1732
|
+
snapshot.set(fp, appended)
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// 4. 新規・変更分だけ header を解析する。別 cwd は session_meta を得た時点で止め、
|
|
1737
|
+
// その cwd が後で要求された時に一度だけ preview まで補完する。一時 I/O 失敗は cache せず、
|
|
1738
|
+
// 次 poll で再試行する。解析済み不正 header (meta=null) は fingerprint 不変なら再解析しない。
|
|
1739
|
+
let parsedHeaders = 0
|
|
1740
|
+
const headerParseStartedAt = Date.now()
|
|
1741
|
+
for (const { fp, mtimeMs, size } of candidates) {
|
|
1742
|
+
if (snapshot.has(fp)) continue
|
|
1743
|
+
const observed = index.get(fp)
|
|
1744
|
+
parsedHeaders++
|
|
1745
|
+
const meta = await parseCodexRolloutHead(fp, cwd, _testHooks)
|
|
1746
|
+
if (meta === CODEX_ROLLOUT_PARSE_IO_ERROR) continue
|
|
1747
|
+
const entry = { mtimeMs, size, lastSeenAt: seenAt, meta }
|
|
1748
|
+
commitCodexThreadListEntry(index, fp, observed, entry)
|
|
1749
|
+
snapshot.set(fp, entry)
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// 5. cwd で絞ってから mtime 降順・limit を適用する。グローバル候補数には左右されない。
|
|
1639
1753
|
const out = []
|
|
1640
|
-
let
|
|
1641
|
-
for (const { fp, mtimeMs } of candidates) {
|
|
1754
|
+
let completedPreviews = 0
|
|
1755
|
+
for (const { fp, mtimeMs, size } of candidates) {
|
|
1642
1756
|
if (limit && out.length >= limit) break
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
const meta = await parseCodexRolloutHead(fp, cwd)
|
|
1757
|
+
const entry = snapshot.get(fp)
|
|
1758
|
+
let meta = entry?.meta
|
|
1646
1759
|
if (!meta) continue
|
|
1647
|
-
// header の cwd が一致するものだけ (cwd 未読/不一致は除外 = 無関係スレッドの混入防止)。
|
|
1648
1760
|
if (meta.cwd !== cwd) continue
|
|
1761
|
+
if (!meta.preview_complete) {
|
|
1762
|
+
const observed = index.get(fp)
|
|
1763
|
+
completedPreviews++
|
|
1764
|
+
meta = await parseCodexRolloutHead(fp, "", _testHooks)
|
|
1765
|
+
if (meta === CODEX_ROLLOUT_PARSE_IO_ERROR) continue
|
|
1766
|
+
const completeEntry = { mtimeMs, size, lastSeenAt: seenAt, meta }
|
|
1767
|
+
commitCodexThreadListEntry(index, fp, observed, completeEntry)
|
|
1768
|
+
snapshot.set(fp, completeEntry)
|
|
1769
|
+
if (!meta || meta.cwd !== cwd) continue
|
|
1770
|
+
}
|
|
1649
1771
|
out.push({
|
|
1650
1772
|
thread_id: meta.thread_id,
|
|
1651
1773
|
cwd: meta.cwd,
|
|
@@ -1653,12 +1775,28 @@ export async function listCodexThreadsFromDisk({
|
|
|
1653
1775
|
updated_at_ms: mtimeMs,
|
|
1654
1776
|
})
|
|
1655
1777
|
}
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1778
|
+
|
|
1779
|
+
const elapsedMs = Date.now() - headerParseStartedAt
|
|
1780
|
+
const wallNow = Date.now()
|
|
1781
|
+
const lastWarnAt = _codexThreadListLastWarnAt.get(base)
|
|
1782
|
+
if (
|
|
1783
|
+
typeof logger?.warn === "function" &&
|
|
1784
|
+
(parsedHeaders + completedPreviews > 200 || elapsedMs > 1000) &&
|
|
1785
|
+
(lastWarnAt === undefined || wallNow - lastWarnAt >= CODEX_THREAD_LIST_LOG_INTERVAL_MS)
|
|
1786
|
+
) {
|
|
1787
|
+
_codexThreadListLastWarnAt.set(base, wallNow)
|
|
1788
|
+
logger.warn(
|
|
1789
|
+
{
|
|
1790
|
+
cwd,
|
|
1791
|
+
parsed_headers: parsedHeaders,
|
|
1792
|
+
completed_previews: completedPreviews,
|
|
1793
|
+
total_candidates: candidates.length,
|
|
1794
|
+
elapsed_ms: elapsedMs,
|
|
1795
|
+
},
|
|
1796
|
+
"listCodexThreadsFromDisk: 派生インデックスの header 解析負荷が閾値超過",
|
|
1660
1797
|
)
|
|
1661
1798
|
}
|
|
1799
|
+
|
|
1662
1800
|
return out
|
|
1663
1801
|
}
|
|
1664
1802
|
|