@cocorograph/hub-agent 0.7.72 → 0.7.73

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.72",
3
+ "version": "0.7.73",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -93,15 +93,61 @@ const ABANDONED_SESSION_TTL_MS =
93
93
 
94
94
  const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
95
95
 
96
+ // T06499 (2026-07-18): 巨大スレッドの resume ガード。長期運用したスレッドの rollout は
97
+ // 20MB 超まで肥大し得る (実測: メイン repo 540KB が正常なのに対し 20MB のスレッドあり)。
98
+ // thread/resume 応答をそのまま平坦化して browser へ送ると、
99
+ // ① WS へ 20MB の JSON を積む → 転送で数秒フリーズ
100
+ // ② browser 側で全 item を codexHistoryToEvents → reducer merge → 数千バブル描画で
101
+ // メインスレッドがハング (履歴も、その裏で撃つ codex.sessions.request 由来の
102
+ // セッション切替バーも一切出ない = UX の断崖)
103
+ // が起きる。thread/resume 自体はモデル側の会話コンテキスト復元のため全量が必要だが、
104
+ // **browser へ渡す表示用履歴は末尾 N ターン分に絞る** ことで断崖を回避する。上限は
105
+ // 「item 数」と「概算バイト数」の 2 軸で、末尾から詰めてどちらか先に達したら truncated。
106
+ const HISTORY_MAX_ITEMS = Number(process.env.HUB_AGENT_CODEX_HISTORY_MAX_ITEMS) || 200
107
+ const HISTORY_MAX_BYTES =
108
+ Number(process.env.HUB_AGENT_CODEX_HISTORY_MAX_BYTES) || 1.5 * 1024 * 1024
109
+
96
110
  // Slice 6: thread/resume が返す Thread.turns[].items[] を単一の時系列配列へ平坦化する。
97
111
  // turns は開始ターン順の配列、各 turn.items も時系列順のため単純結合で会話順を保てる。
98
- function flattenThreadItems(thread) {
112
+ //
113
+ // T06499: 末尾から maxItems / maxBytes の budget 内に収め、超過分は落として truncated=true を
114
+ // 返す。末尾優先なのは「直近の会話が最も価値が高く、古い履歴は resume されたモデル側が保持して
115
+ // いる」ため。budget を末尾から積むので、20MB スレッドでも全 item を stringify せず早期に打ち切る
116
+ // (kept が budget に達した時点で残りの巨大な古い item には触れない)。
117
+ // @returns {{items: object[], truncated: boolean, total: number}}
118
+ function flattenThreadItems(thread, { maxItems = HISTORY_MAX_ITEMS, maxBytes = HISTORY_MAX_BYTES } = {}) {
99
119
  const turns = Array.isArray(thread?.turns) ? thread.turns : []
100
- const items = []
120
+ const all = []
101
121
  for (const turn of turns) {
102
- if (Array.isArray(turn?.items)) items.push(...turn.items)
122
+ if (Array.isArray(turn?.items)) all.push(...turn.items)
103
123
  }
104
- return items
124
+ const total = all.length
125
+ const kept = []
126
+ let bytes = 0
127
+ let truncated = false
128
+ // 末尾 (最新) から詰める。budget 超過で打ち切り、truncated を立てる。
129
+ for (let i = all.length - 1; i >= 0; i--) {
130
+ if (kept.length >= maxItems) {
131
+ truncated = true
132
+ break
133
+ }
134
+ const item = all[i]
135
+ let size = 0
136
+ try {
137
+ size = JSON.stringify(item)?.length || 0
138
+ } catch {
139
+ size = 0
140
+ }
141
+ // 最低 1 件は必ず入れる (単一 item が maxBytes を超えても末尾 1 件は見せる)。
142
+ if (kept.length > 0 && bytes + size > maxBytes) {
143
+ truncated = true
144
+ break
145
+ }
146
+ kept.push(item)
147
+ bytes += size
148
+ }
149
+ kept.reverse() // 末尾から積んだので時系列順へ戻す。
150
+ return { items: kept, truncated, total }
105
151
  }
106
152
 
107
153
  /**
@@ -184,7 +230,15 @@ function userInputFromMessage(message) {
184
230
  }
185
231
 
186
232
  export class CodexStreamBridge extends EventEmitter {
187
- constructor({ codexCmd, logger, clientFactory, detachTtlMs, rolloutPresence } = {}) {
233
+ constructor({
234
+ codexCmd,
235
+ logger,
236
+ clientFactory,
237
+ detachTtlMs,
238
+ rolloutPresence,
239
+ historyMaxItems,
240
+ historyMaxBytes,
241
+ } = {}) {
188
242
  super()
189
243
  this.codexCmd = codexCmd
190
244
  this.logger = logger
@@ -193,6 +247,11 @@ export class CodexStreamBridge extends EventEmitter {
193
247
  this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
194
248
  // #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
195
249
  this._rolloutPresence = rolloutPresence || codexRolloutPresence
250
+ // T06499: 履歴 hydrate の上限 (テストから小さい値を注入可能)。
251
+ this._historyLimits = {
252
+ maxItems: historyMaxItems ?? HISTORY_MAX_ITEMS,
253
+ maxBytes: historyMaxBytes ?? HISTORY_MAX_BYTES,
254
+ }
196
255
  /** @type {Map<string, object>} stream_id -> session record */
197
256
  this.sessions = new Map()
198
257
  }
@@ -210,7 +269,8 @@ export class CodexStreamBridge extends EventEmitter {
210
269
  * なければ、通常の attach (thread/start | thread/resume) にフォールスルーする。
211
270
  *
212
271
  * @returns {Promise<{resuming: boolean, threadId: string, history: object[],
213
- * threadStatus: string|null, lastTurnStatus: string|null, turnActive: boolean}>}
272
+ * historyTruncated: boolean, threadStatus: string|null, lastTurnStatus: string|null,
273
+ * turnActive: boolean}>}
214
274
  */
215
275
  async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId, attachId }) {
216
276
  if (this.sessions.has(stream_id)) {
@@ -292,7 +352,12 @@ export class CodexStreamBridge extends EventEmitter {
292
352
  session.threadId = result?.thread?.id
293
353
  // 新規スレッド (thread/start) は履歴が無いので空配列。resume 時のみ平坦化する
294
354
  // (thread/start のレスポンスは turns を持たない仕様のため呼んでも常に空)。
295
- const history = resuming ? flattenThreadItems(result?.thread) : []
355
+ // T06499: 巨大スレッドは末尾 N ターンに絞る (truncated=true なら browser がバナー表示)。
356
+ const flat = resuming
357
+ ? flattenThreadItems(result?.thread, this._historyLimits)
358
+ : { items: [], truncated: false, total: 0 }
359
+ const history = flat.items
360
+ const historyTruncated = flat.truncated
296
361
  // 案C: 権威的なターン状態を resume 応答から取り出してフロントへ渡す。
297
362
  // これによりフロントは会話末尾からの推測をやめ、Thread.status で turnActive を
298
363
  // 決められる (生成中スピナー固着バグの根治)。新規スレッドは常に idle 相当。
@@ -317,6 +382,10 @@ export class CodexStreamBridge extends EventEmitter {
317
382
  resuming,
318
383
  thread_status: turnState.threadStatus,
319
384
  live_for_cwd: this.describeLiveSessions(cwd).length,
385
+ // T06499: 巨大スレッド hydrate の可視化 (何件中何件を返したか)。
386
+ history_items: history.length,
387
+ history_total: flat.total,
388
+ history_truncated: historyTruncated,
320
389
  },
321
390
  "codex session attached",
322
391
  )
@@ -324,6 +393,7 @@ export class CodexStreamBridge extends EventEmitter {
324
393
  resuming,
325
394
  threadId: session.threadId,
326
395
  history,
396
+ historyTruncated,
327
397
  threadStatus: turnState.threadStatus,
328
398
  lastTurnStatus: turnState.lastTurnStatus,
329
399
  turnActive: turnState.turnActive,
@@ -389,6 +459,8 @@ export class CodexStreamBridge extends EventEmitter {
389
459
  resuming: true,
390
460
  threadId,
391
461
  history: [],
462
+ // reconnect の in-memory reattach は表示を保持しているので履歴を返さない = 常に非 truncated。
463
+ historyTruncated: false,
392
464
  threadStatus: session.activeTurnId ? "active" : "idle",
393
465
  lastTurnStatus: null,
394
466
  turnActive: !!session.activeTurnId,
package/src/main.mjs CHANGED
@@ -4210,6 +4210,9 @@ async function dispatch(msg, ctx) {
4210
4210
  // consumers.py の per-stream relay は content-blind なので backend 側の
4211
4211
  // 変更は不要 (_AGENT_PER_STREAM_TYPES の codex.ready がそのまま運ぶ)。
4212
4212
  history: info.history || [],
4213
+ // T06499: 巨大スレッドで末尾 N ターンに絞った場合 true。browser は
4214
+ // 「以前の履歴は省略されています」バナーを出す (会話は resume 済みで継続可)。
4215
+ history_truncated: info.historyTruncated === true,
4213
4216
  // 案C (権威的ターン状態): フロントは会話末尾からの推測 (deriveTurnActive
4214
4217
  // FromHistory) をやめ、Codex が明示する Thread.status で turnActive を決める。
4215
4218
  // resume 後は新プロセスで実行中ターンが無く thread_status='idle' が返るため、