@cocorograph/hub-agent 0.7.59 → 0.7.61
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-stream-bridge.mjs +54 -2
- package/src/main.mjs +8 -0
- package/src/usage.mjs +64 -4
package/package.json
CHANGED
|
@@ -77,6 +77,41 @@ function flattenThreadItems(thread) {
|
|
|
77
77
|
return items
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* resume 応答から Codex の「権威的なターン進行状態」を取り出す。
|
|
82
|
+
*
|
|
83
|
+
* ⚠️ Codex は Claude と違い、ターンが走っているかを **明示フィールド** で持つ (公式
|
|
84
|
+
* app-server スキーマで確認):
|
|
85
|
+
* - Thread.status.type = notLoaded | idle | systemError | active
|
|
86
|
+
* - Turn.status = completed | interrupted | failed | inProgress
|
|
87
|
+
* そのため「会話履歴の末尾が user か assistant か」からの推測 (Claude 由来の
|
|
88
|
+
* deriveTurnActiveFromHistory) は不要かつ有害 (ツール実行のみで終わったターンや中断
|
|
89
|
+
* ターンで末尾が user メッセージになり、生成中スピナーが永久固着する既知バグの原因)。
|
|
90
|
+
*
|
|
91
|
+
* resume 時は app-server が新プロセスで、実際に走っているターンは無いため Codex は
|
|
92
|
+
* 通常 `idle` を返す。ゆえに `threadStatus === "active"` を唯一の権威 turn-active
|
|
93
|
+
* シグナルとして採用し、フロントの hydrate 時 turnActive をこれで決めさせる。
|
|
94
|
+
*
|
|
95
|
+
* @param {object|undefined} thread thread/resume の result.thread
|
|
96
|
+
* @returns {{threadStatus: string|null, lastTurnStatus: string|null, turnActive: boolean, activeTurnId: string|null}}
|
|
97
|
+
*/
|
|
98
|
+
export function deriveResumeTurnState(thread) {
|
|
99
|
+
const threadStatus = typeof thread?.status?.type === "string" ? thread.status.type : null
|
|
100
|
+
const turns = Array.isArray(thread?.turns) ? thread.turns : []
|
|
101
|
+
const lastTurn = turns.length ? turns[turns.length - 1] : null
|
|
102
|
+
const lastTurnStatus = typeof lastTurn?.status === "string" ? lastTurn.status : null
|
|
103
|
+
// 権威シグナル: スレッドが active のときだけ「ターン実行中」。fresh resume は idle。
|
|
104
|
+
const turnActive = threadStatus === "active"
|
|
105
|
+
// active かつ最後のターンが inProgress のときのみ activeTurnId を復元する
|
|
106
|
+
// (input() が turn/steer へ正しく分岐できるように)。それ以外は null のまま
|
|
107
|
+
// = 次の入力は turn/start になり、固着しない。
|
|
108
|
+
const activeTurnId =
|
|
109
|
+
turnActive && lastTurnStatus === "inProgress" && typeof lastTurn?.id === "string"
|
|
110
|
+
? lastTurn.id
|
|
111
|
+
: null
|
|
112
|
+
return { threadStatus, lastTurnStatus, turnActive, activeTurnId }
|
|
113
|
+
}
|
|
114
|
+
|
|
80
115
|
/**
|
|
81
116
|
* 配達保証 (S2, 2026-07-05): `input()` が reject したエラーを `codex.input.nack` の
|
|
82
117
|
* `reason` へ分類する。
|
|
@@ -134,7 +169,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
134
169
|
}
|
|
135
170
|
|
|
136
171
|
/**
|
|
137
|
-
* @returns {Promise<{resuming: boolean, threadId: string, history: object[]
|
|
172
|
+
* @returns {Promise<{resuming: boolean, threadId: string, history: object[],
|
|
173
|
+
* threadStatus: string|null, lastTurnStatus: string|null, turnActive: boolean}>}
|
|
138
174
|
*/
|
|
139
175
|
async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId }) {
|
|
140
176
|
if (this.sessions.has(stream_id)) {
|
|
@@ -173,7 +209,23 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
173
209
|
// 新規スレッド (thread/start) は履歴が無いので空配列。resume 時のみ平坦化する
|
|
174
210
|
// (thread/start のレスポンスは turns を持たない仕様のため呼んでも常に空)。
|
|
175
211
|
const history = resuming ? flattenThreadItems(result?.thread) : []
|
|
176
|
-
|
|
212
|
+
// 案C: 権威的なターン状態を resume 応答から取り出してフロントへ渡す。
|
|
213
|
+
// これによりフロントは会話末尾からの推測をやめ、Thread.status で turnActive を
|
|
214
|
+
// 決められる (生成中スピナー固着バグの根治)。新規スレッドは常に idle 相当。
|
|
215
|
+
const turnState = resuming
|
|
216
|
+
? deriveResumeTurnState(result?.thread)
|
|
217
|
+
: { threadStatus: "idle", lastTurnStatus: null, turnActive: false, activeTurnId: null }
|
|
218
|
+
// Codex が「ターン実行中」と権威的に報告した場合のみ activeTurnId を復元する
|
|
219
|
+
// (通常の fresh resume では null = 次入力が turn/start になり固着しない)。
|
|
220
|
+
session.activeTurnId = turnState.activeTurnId
|
|
221
|
+
return {
|
|
222
|
+
resuming,
|
|
223
|
+
threadId: session.threadId,
|
|
224
|
+
history,
|
|
225
|
+
threadStatus: turnState.threadStatus,
|
|
226
|
+
lastTurnStatus: turnState.lastTurnStatus,
|
|
227
|
+
turnActive: turnState.turnActive,
|
|
228
|
+
}
|
|
177
229
|
} catch (err) {
|
|
178
230
|
this.sessions.delete(stream_id)
|
|
179
231
|
client.stop()
|
package/src/main.mjs
CHANGED
|
@@ -3952,6 +3952,14 @@ async function dispatch(msg, ctx) {
|
|
|
3952
3952
|
// consumers.py の per-stream relay は content-blind なので backend 側の
|
|
3953
3953
|
// 変更は不要 (_AGENT_PER_STREAM_TYPES の codex.ready がそのまま運ぶ)。
|
|
3954
3954
|
history: info.history || [],
|
|
3955
|
+
// 案C (権威的ターン状態): フロントは会話末尾からの推測 (deriveTurnActive
|
|
3956
|
+
// FromHistory) をやめ、Codex が明示する Thread.status で turnActive を決める。
|
|
3957
|
+
// resume 後は新プロセスで実行中ターンが無く thread_status='idle' が返るため、
|
|
3958
|
+
// 生成中スピナー固着 (中断/ツールのみ終了ターンで末尾が user メッセージになる
|
|
3959
|
+
// ケース) が根治する。
|
|
3960
|
+
turn_active: info.turnActive === true,
|
|
3961
|
+
thread_status: info.threadStatus || null,
|
|
3962
|
+
last_turn_status: info.lastTurnStatus || null,
|
|
3955
3963
|
})
|
|
3956
3964
|
// S1 (2026-07): claude.attach と対称の identity 正本化。Codex は thread/start
|
|
3957
3965
|
// 応答時点で threadId が確定するため (Claude の fresh とは異なり)、fresh でも
|
package/src/usage.mjs
CHANGED
|
@@ -1284,14 +1284,67 @@ function codexSessionsDir() {
|
|
|
1284
1284
|
// (Claude の readOfficial の bucketize と同じ約束事)。
|
|
1285
1285
|
const CODEX_DEFAULT_LIMIT = 100
|
|
1286
1286
|
|
|
1287
|
-
|
|
1288
|
-
|
|
1287
|
+
// threadId → rollout 絶対パスのメモ (2026-07-15)。rollout ファイルは threadId ごとに
|
|
1288
|
+
// 不変(作成日ディレクトリに置かれ、以降はそのファイルへ追記され続ける)ため、一度解決
|
|
1289
|
+
// できたら永続的にキャッシュしてよい。フォールバック全走査 (下記) のコストを usage.request
|
|
1290
|
+
// ポーリング (既定 60s) のたびに払わないための保険。存在確認 (stat) で削除検知して捨てる。
|
|
1291
|
+
const _codexRolloutPathMemo = new Map() // threadId → fp
|
|
1292
|
+
|
|
1293
|
+
export function _resetCodexRolloutPathMemo() {
|
|
1294
|
+
_codexRolloutPathMemo.clear()
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/** base 配下の全 Y/M/D ディレクトリを新しい順に走査して threadId の rollout を探す。
|
|
1298
|
+
* 直近 lookbackDays の高速パスから漏れた「窓外の古い(=9 日以上前に作成された)長寿命
|
|
1299
|
+
* スレッド」を拾うためのフォールバック。resume で復帰した古いスレッドが該当する。
|
|
1300
|
+
* @returns {Promise<string|null>}
|
|
1301
|
+
*/
|
|
1302
|
+
async function scanAllDatesForCodexRollout(base, threadId) {
|
|
1303
|
+
const suffix = `-${threadId}.jsonl`
|
|
1304
|
+
const years = await fs.readdir(base).catch(() => [])
|
|
1305
|
+
// 新しい年→月→日から辿ると、resume される(=比較的最近使われた)スレッドに早く当たる。
|
|
1306
|
+
for (const y of years.filter((s) => /^\d{4}$/.test(s)).sort().reverse()) {
|
|
1307
|
+
const yDir = path.join(base, y)
|
|
1308
|
+
const months = await fs.readdir(yDir).catch(() => [])
|
|
1309
|
+
for (const m of months.sort().reverse()) {
|
|
1310
|
+
const mDir = path.join(yDir, m)
|
|
1311
|
+
const days = await fs.readdir(mDir).catch(() => [])
|
|
1312
|
+
for (const d of days.sort().reverse()) {
|
|
1313
|
+
const dDir = path.join(mDir, d)
|
|
1314
|
+
const files = await fs.readdir(dDir).catch(() => [])
|
|
1315
|
+
const match = files.find((f) => f.endsWith(suffix))
|
|
1316
|
+
if (match) return path.join(dDir, match)
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return null
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/** threadId から rollout jsonl の絶対パスを検索する(日付シャード配下)。
|
|
1324
|
+
* 高速パス = 直近 `lookbackDays` 日の Y/M/D を走査(月またぎでも 1〜2 readdir で済む)。
|
|
1325
|
+
* ⚠️ rollout ファイルは「スレッド作成日」のディレクトリに置かれ、以降そのファイルへ
|
|
1326
|
+
* 追記され続ける。そのため 9 日以上前に作成されたスレッドを resume で復帰させると、
|
|
1327
|
+
* 作成日ディレクトリが高速パスの窓外にあり見つからず、context ドーナツ・5h/7d が
|
|
1328
|
+
* 表示できなくなる(2026-07-15 の resume 修正で古いスレッド復帰が正しく動くようになり
|
|
1329
|
+
* 露出)。高速パスで見つからなければ全日付フォールバック走査に落とし、結果を threadId
|
|
1330
|
+
* でメモ化する。
|
|
1289
1331
|
* `session-mirror.mjs`(Stream C ターミナルミラー)からも再利用するため export する。
|
|
1290
1332
|
* @returns {Promise<string|null>}
|
|
1291
1333
|
*/
|
|
1292
1334
|
export async function findCodexRolloutFile(threadId, { lookbackDays = 9, now = Date.now() } = {}) {
|
|
1293
1335
|
if (!threadId) return null
|
|
1336
|
+
// メモ命中: ファイルが今も存在するなら即返す(消えていれば捨てて再探索)。
|
|
1337
|
+
const memoed = _codexRolloutPathMemo.get(threadId)
|
|
1338
|
+
if (memoed) {
|
|
1339
|
+
try {
|
|
1340
|
+
await fs.stat(memoed)
|
|
1341
|
+
return memoed
|
|
1342
|
+
} catch {
|
|
1343
|
+
_codexRolloutPathMemo.delete(threadId)
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1294
1346
|
const base = codexSessionsDir()
|
|
1347
|
+
// 高速パス: 直近 lookbackDays の日付シャードのみ。
|
|
1295
1348
|
for (let i = 0; i < lookbackDays; i++) {
|
|
1296
1349
|
const d = new Date(now - i * 24 * 60 * 60 * 1000)
|
|
1297
1350
|
const y = String(d.getFullYear())
|
|
@@ -1301,9 +1354,16 @@ export async function findCodexRolloutFile(threadId, { lookbackDays = 9, now = D
|
|
|
1301
1354
|
const files = await fs.readdir(dir).catch(() => null)
|
|
1302
1355
|
if (!files) continue
|
|
1303
1356
|
const match = files.find((f) => f.endsWith(`-${threadId}.jsonl`))
|
|
1304
|
-
if (match)
|
|
1357
|
+
if (match) {
|
|
1358
|
+
const fp = path.join(dir, match)
|
|
1359
|
+
_codexRolloutPathMemo.set(threadId, fp)
|
|
1360
|
+
return fp
|
|
1361
|
+
}
|
|
1305
1362
|
}
|
|
1306
|
-
|
|
1363
|
+
// フォールバック: 窓外の古い(長寿命 resume)スレッドを全日付走査で拾う。
|
|
1364
|
+
const scanned = await scanAllDatesForCodexRollout(base, threadId)
|
|
1365
|
+
if (scanned) _codexRolloutPathMemo.set(threadId, scanned)
|
|
1366
|
+
return scanned
|
|
1307
1367
|
}
|
|
1308
1368
|
|
|
1309
1369
|
/** P5(perf): rollout ファイルの token_count 抽出結果を mtime+size でメモ化。 */
|