@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.59",
3
+ "version": "0.7.61",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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
- return { resuming, threadId: session.threadId, history }
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
- /** threadId から rollout jsonl の絶対パスを検索する(日付シャード配下、直近 N 日のみ走査)。
1288
- * 7d 窓 + バッファで 9 日分を既定にする(月またぎでも大抵 1〜2 ディレクトリの readdir で済む)。
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) return path.join(dir, match)
1357
+ if (match) {
1358
+ const fp = path.join(dir, match)
1359
+ _codexRolloutPathMemo.set(threadId, fp)
1360
+ return fp
1361
+ }
1305
1362
  }
1306
- return null
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 でメモ化。 */