@cocorograph/hub-agent 0.8.51 → 0.8.52

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.8.51",
3
+ "version": "0.8.52",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -368,16 +368,17 @@ const HISTORY_MAX_BYTES = readPositiveIntEnv(
368
368
  "HUB_AGENT_CODEX_HISTORY_MAX_BYTES",
369
369
  1.5 * 1024 * 1024,
370
370
  )
371
+ const HISTORY_SNAPSHOT_CACHE_TTL_MS = readPositiveIntEnv(
372
+ "HUB_AGENT_CODEX_HISTORY_SNAPSHOT_TTL_MS",
373
+ 30_000,
374
+ )
371
375
 
372
376
  // Slice 6: thread/resume が返す Thread.turns[].items[] を単一の時系列配列へ平坦化する。
373
377
  // turns は開始ターン順の配列、各 turn.items も時系列順のため単純結合で会話順を保てる。
374
378
  //
375
- // T06499: 末尾から maxItems / maxBytes の budget 内に収め、超過分は落として truncated=true を
376
- // 返す。末尾優先なのは「直近の会話が最も価値が高く、古い履歴は resume されたモデル側が保持して
377
- // いる」ため。budget を末尾から積むので、20MB スレッドでも全 item を stringify せず早期に打ち切る
378
- // (kept が budget に達した時点で残りの巨大な古い item には触れない)。
379
- // @returns {{items: object[], truncated: boolean, total: number}}
380
- function flattenThreadItems(thread, { maxItems = HISTORY_MAX_ITEMS, maxBytes = HISTORY_MAX_BYTES } = {}) {
379
+ // 全 parent thread item を時系列順に平坦化する。item/byte budget はこの段階では適用せず、
380
+ // pageThreadItems が requested cursor ごとに適用する。返却配列の index が start/total/before の座標系。
381
+ function flattenAllThreadItems(thread) {
381
382
  const turns = Array.isArray(thread?.turns) ? thread.turns : []
382
383
  const all = []
383
384
  for (const turn of turns) {
@@ -403,36 +404,41 @@ function flattenThreadItems(thread, { maxItems = HISTORY_MAX_ITEMS, maxBytes = H
403
404
  all.push(atMs != null && item && typeof item === "object" ? { ...item, at_ms: atMs } : item)
404
405
  }
405
406
  }
407
+ return all
408
+ }
409
+
410
+ // before の直前から古い側へ、item 数と概算 byte 数の両予算で 1 page を切り出す。
411
+ // @returns {{items: object[], start: number, truncated: boolean, total: number}}
412
+ function pageThreadItems(
413
+ all,
414
+ { before = all.length, maxItems = HISTORY_MAX_ITEMS, maxBytes = HISTORY_MAX_BYTES } = {},
415
+ ) {
406
416
  const total = all.length
417
+ const end = Math.min(Math.max(0, before), total)
418
+ const firstCandidate = Math.max(0, end - maxItems)
407
419
  const kept = []
408
420
  let bytes = 0
409
- let truncated = false
410
- // 末尾 (最新) から詰める。budget 超過で打ち切り、truncated を立てる。
411
- for (let i = all.length - 1; i >= 0; i--) {
412
- if (kept.length >= maxItems) {
413
- truncated = true
414
- break
415
- }
421
+ let start = end
422
+ for (let i = end - 1; i >= firstCandidate; i--) {
416
423
  const item = all[i]
417
- let size = 0
418
- try {
419
- size = JSON.stringify(item)?.length || 0
420
- } catch {
421
- size = 0
422
- }
423
- // 最低 1 件は必ず入れる (単一 item が maxBytes を超えても末尾 1 件は見せる)。
424
+ const size = estimateItemBytes(item)
424
425
  if (kept.length > 0 && bytes + size > maxBytes) {
425
- truncated = true
426
426
  break
427
427
  }
428
428
  kept.push(item)
429
429
  bytes += size
430
+ start = i
430
431
  }
431
- kept.reverse() // 末尾から積んだので時系列順へ戻す。
432
- return { items: kept, truncated, total }
432
+ kept.reverse()
433
+ return { items: kept, start, truncated: start > 0, total }
434
+ }
435
+
436
+ function flattenThreadItems(thread, { maxItems = HISTORY_MAX_ITEMS, maxBytes = HISTORY_MAX_BYTES } = {}) {
437
+ const all = flattenAllThreadItems(thread)
438
+ return pageThreadItems(all, { before: all.length, maxItems, maxBytes })
433
439
  }
434
440
 
435
- /** 履歴 item の概算 byte 数 (flattenThreadItems の budget 判定と同じ JSON 長ベース)。 */
441
+ /** 履歴 item の概算 byte 数 (pageThreadItems の budget 判定と同じ JSON 長ベース)。 */
436
442
  function estimateItemBytes(item) {
437
443
  try {
438
444
  return JSON.stringify(item)?.length || 0
@@ -607,6 +613,7 @@ export class CodexStreamBridge extends EventEmitter {
607
613
  listThreadsFromDisk,
608
614
  historyMaxItems,
609
615
  historyMaxBytes,
616
+ historySnapshotCacheTtlMs,
610
617
  listThreadsCacheTtlMs,
611
618
  accountRateLimitsCacheTtlMs,
612
619
  workspaceDependenciesCacheTtlMs,
@@ -719,6 +726,7 @@ export class CodexStreamBridge extends EventEmitter {
719
726
  maxItems: historyMaxItems ?? HISTORY_MAX_ITEMS,
720
727
  maxBytes: historyMaxBytes ?? HISTORY_MAX_BYTES,
721
728
  }
729
+ this._historySnapshotCacheTtlMs = historySnapshotCacheTtlMs ?? HISTORY_SNAPSHOT_CACHE_TTL_MS
722
730
  /** @type {Map<string, object>} stream_id -> session record */
723
731
  this.sessions = new Map()
724
732
  this._accountRateLimitsByProfile = new Map()
@@ -751,7 +759,8 @@ export class CodexStreamBridge extends EventEmitter {
751
759
  * なければ、通常の attach (thread/start | thread/resume) にフォールスルーする。
752
760
  *
753
761
  * @returns {Promise<{resuming: boolean, threadId: string, history: object[],
754
- * historyTruncated: boolean, threadStatus: string|null, lastTurnStatus: string|null,
762
+ * historyTruncated: boolean, historyStart?: number, historyTotal?: number,
763
+ * threadStatus: string|null, lastTurnStatus: string|null,
755
764
  * turnActive: boolean}>}
756
765
  */
757
766
  async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId, attachId }) {
@@ -860,6 +869,14 @@ export class CodexStreamBridge extends EventEmitter {
860
869
  // 由来で表示がリセットされ得るため、live セッションが保持する高忠実度履歴を返す。
861
870
  history,
862
871
  historyTruncated: existing.historyTruncated === true,
872
+ ...(history.length > 0
873
+ ? {
874
+ historyStart: Number.isInteger(existing.historyStart) ? existing.historyStart : 0,
875
+ historyTotal: Number.isInteger(existing.historyTotal)
876
+ ? existing.historyTotal
877
+ : history.length,
878
+ }
879
+ : {}),
863
880
  subagents: this._subagentSnapshots(existing, history),
864
881
  }
865
882
  }
@@ -984,6 +1001,18 @@ export class CodexStreamBridge extends EventEmitter {
984
1001
  // 生データ(ツールカードが粗い)ではなく app-server 整形済みの高忠実度履歴を維持する。
985
1002
  historyItems: [],
986
1003
  historyTruncated: false,
1004
+ historyStart: 0,
1005
+ historyTotal: 0,
1006
+ // reattach 表示履歴には child item も含むが cursor は parent item だけを数える。
1007
+ // historyItems/historyCursorWeights は常に同長。weight は 1=parent / 0=child で、trim 時の
1008
+ // start 前進量を決める。ready の start/total はこの cursor 専用で配列 index ではない。
1009
+ // childだけがtrimされた事実はcursorへ残らず、warm reattachでのchild本文復元はbest-effort。
1010
+ historyCursorWeights: [],
1011
+ historySnapshotCache: new AsyncTtlCache({ ttlMs: this._historySnapshotCacheTtlMs }),
1012
+ historySnapshotReleaseTimer: null,
1013
+ historySnapshotComparable: false,
1014
+ historyAttachInProgress: true,
1015
+ historyItemArrivedDuringAttach: false,
987
1016
  // major#2: ライブ追記でも cold flatten と同じ「item 数+概算 byte」二重上限を課すため、
988
1017
  // 蓄積済み履歴の概算 byte 合計を保持する (再計算を避け O(1) で trim 判定)。
989
1018
  historyBytes: 0,
@@ -1136,7 +1165,7 @@ export class CodexStreamBridge extends EventEmitter {
1136
1165
  // T06499: 巨大スレッドは末尾 N ターンに絞る (truncated=true なら browser がバナー表示)。
1137
1166
  const flat = resuming
1138
1167
  ? flattenThreadItems(result?.thread, this._historyLimits)
1139
- : { items: [], truncated: false, total: 0 }
1168
+ : { items: [], start: 0, truncated: false, total: 0 }
1140
1169
  // hub-agent 側の履歴整形コスト (巨大スレッドで効く)。thread_op_ms とは別勘定にする。
1141
1170
  timing.history_flatten_ms = lap()
1142
1171
  const history = flat.items
@@ -1147,6 +1176,11 @@ export class CodexStreamBridge extends EventEmitter {
1147
1176
  session.historyItems = history.slice()
1148
1177
  session.historyBytes = history.reduce((n, it) => n + estimateItemBytes(it), 0)
1149
1178
  session.historyTruncated = historyTruncated
1179
+ session.historyStart = flat.start
1180
+ session.historyTotal = flat.total
1181
+ session.historyCursorWeights = history.map(() => 1)
1182
+ session.historyAttachInProgress = false
1183
+ session.historySnapshotComparable = session.historyItemArrivedDuringAttach !== true
1150
1184
  if (resuming) await this._restoreSubagentsFromHistory(session, history)
1151
1185
  // 案C: 権威的なターン状態を resume 応答から取り出してフロントへ渡す。
1152
1186
  // これによりフロントは会話末尾からの推測をやめ、Thread.status で turnActive を
@@ -1204,6 +1238,8 @@ export class CodexStreamBridge extends EventEmitter {
1204
1238
  threadId: session.threadId,
1205
1239
  history,
1206
1240
  historyTruncated,
1241
+ historyStart: flat.start,
1242
+ historyTotal: flat.total,
1207
1243
  subagents: this._subagentSnapshots(session, history),
1208
1244
  threadStatus: turnState.threadStatus,
1209
1245
  lastTurnStatus: turnState.lastTurnStatus,
@@ -1212,6 +1248,7 @@ export class CodexStreamBridge extends EventEmitter {
1212
1248
  } catch (err) {
1213
1249
  this.sessions.delete(stream_id)
1214
1250
  this._settleAllPendingApprovals(session, { respond: false })
1251
+ this._disposeHistorySnapshot(session)
1215
1252
  client.stop()
1216
1253
  settleReady(null) // reattach 待ちへ「失敗」を通知 (reject しない)
1217
1254
  // 失敗も 1 行に落とす。**_classifyAttachFailure より前**に出す (分類は rollout の disk 走査を
@@ -1306,15 +1343,40 @@ export class CodexStreamBridge extends EventEmitter {
1306
1343
  * 表示用バブル時刻 at_ms は completedAtMs (ms epoch) を採る(flattenThreadItems と同じ ms 単位。
1307
1344
  * userMessage はライブでも completedAtMs=ターン冒頭で届くため整合する)。長い会話で無限成長させ
1308
1345
  * ないよう cold flatten と同じ **item 数 (maxItems) + 概算 byte 数 (maxBytes) の二重上限** を課し、
1309
- * 超過分は先頭 (最古) から捨て truncated を立てる (major#2: 巨大ツール出力で reattach payload が
1310
- * 肥大化し T06499 で防いだメモリ増・WS 転送・ブラウザフリーズが再発するのを防ぐ)。
1346
+ * 超過分は先頭 (最古) から捨て、parent cursor が前進した場合だけ truncated を立てる
1347
+ * (major#2: 巨大ツール出力で reattach payload が肥大化し T06499 で防いだメモリ増・WS 転送・
1348
+ * ブラウザフリーズが再発するのを防ぐ)。
1311
1349
  */
1312
- _appendHistoryItem(session, item, completedAtMs) {
1350
+ _appendHistoryItem(session, item, completedAtMs, { countsForCursor = true } = {}) {
1313
1351
  if (!session || !item || typeof item !== "object") return
1352
+ if (session.historyAttachInProgress === true) session.historyItemArrivedDuringAttach = true
1314
1353
  if (!Array.isArray(session.historyItems)) session.historyItems = []
1315
1354
  if (typeof session.historyBytes !== "number") session.historyBytes = 0
1355
+ // 旧/遅延初期化 session でも同長不変条件を回復する。既存 item の由来を失っている場合は
1356
+ // parent として安全側に数え、以降の child item だけを明示的に 0 とする。
1357
+ if (
1358
+ !Array.isArray(session.historyCursorWeights) ||
1359
+ session.historyCursorWeights.length !== session.historyItems.length
1360
+ ) {
1361
+ this.logger?.warn?.(
1362
+ {
1363
+ thread_id: session.threadId,
1364
+ history_items: session.historyItems.length,
1365
+ cursor_weights: Array.isArray(session.historyCursorWeights)
1366
+ ? session.historyCursorWeights.length
1367
+ : null,
1368
+ },
1369
+ "codex history cursor weights rebuilt to match retained items",
1370
+ )
1371
+ session.historyCursorWeights = session.historyItems.map(() => 1)
1372
+ }
1316
1373
  const entry = typeof completedAtMs === "number" ? { ...item, at_ms: completedAtMs } : item
1317
1374
  session.historyItems.push(entry)
1375
+ const cursorWeight = countsForCursor ? 1 : 0
1376
+ session.historyCursorWeights.push(cursorWeight)
1377
+ session.historyTotal = (Number.isInteger(session.historyTotal) ? session.historyTotal : 0) + cursorWeight
1378
+ // 古い page は同じ snapshot 座標内なら順序が壊れないため cache は clear しない。
1379
+ // child 更新は parent thread/read snapshot にも無関係。TTL 後の再取得で自然に追随する。
1318
1380
  session.historyBytes += estimateItemBytes(entry)
1319
1381
  const maxItems = this._historyLimits.maxItems
1320
1382
  const maxBytes = this._historyLimits.maxBytes
@@ -1325,9 +1387,140 @@ export class CodexStreamBridge extends EventEmitter {
1325
1387
  (typeof maxBytes === "number" && session.historyBytes > maxBytes))
1326
1388
  ) {
1327
1389
  const dropped = session.historyItems.shift()
1390
+ const droppedCursorWeight = session.historyCursorWeights.shift() || 0
1328
1391
  session.historyBytes -= estimateItemBytes(dropped)
1329
1392
  if (session.historyBytes < 0) session.historyBytes = 0
1330
- session.historyTruncated = true
1393
+ session.historyStart =
1394
+ (Number.isInteger(session.historyStart) ? session.historyStart : 0) + droppedCursorWeight
1395
+ }
1396
+ // child だけが表示上限から落ちても parent cursor の古い側は欠けていない。
1397
+ session.historyTruncated = (Number.isInteger(session.historyStart) ? session.historyStart : 0) > 0
1398
+ }
1399
+
1400
+ _scheduleHistorySnapshotRelease(session) {
1401
+ if (session.historySnapshotReleaseTimer) clearTimeout(session.historySnapshotReleaseTimer)
1402
+ const cache = session.historySnapshotCache
1403
+ if (!cache?.hasValue) {
1404
+ session.historySnapshotReleaseTimer = null
1405
+ return
1406
+ }
1407
+ const release = () => {
1408
+ if (session.historySnapshotReleaseTimer !== timer) return
1409
+ // TTL境界で次buildが始まっている場合、そのbuildをclear世代変更で再実行させない。
1410
+ // build完了後の _scheduleHistorySnapshotRelease がこの短い繰延timerを張り替える。
1411
+ if (cache.inFlight) {
1412
+ const retryDelayMs = Math.max(10, Math.min(1_000, this._historySnapshotCacheTtlMs))
1413
+ const retryTimer = setTimeout(release, retryDelayMs)
1414
+ retryTimer.unref?.()
1415
+ session.historySnapshotReleaseTimer = retryTimer
1416
+ timer = retryTimer
1417
+ return
1418
+ }
1419
+ session.historySnapshotReleaseTimer = null
1420
+ cache.clear()
1421
+ }
1422
+ const delayMs = Math.max(0, cache.expiresAt - Date.now())
1423
+ let timer = setTimeout(release, delayMs)
1424
+ timer.unref?.()
1425
+ session.historySnapshotReleaseTimer = timer
1426
+ }
1427
+
1428
+ _disposeHistorySnapshot(session) {
1429
+ if (!session) return
1430
+ if (session.historySnapshotReleaseTimer) clearTimeout(session.historySnapshotReleaseTimer)
1431
+ session.historySnapshotReleaseTimer = null
1432
+ session.historySnapshotCache?.clear?.()
1433
+ }
1434
+
1435
+ async _historySnapshot(session) {
1436
+ // この reader は既存の別責務の thread/read と統合しない。要求した root thread と応答の
1437
+ // thread id が一致することを、この履歴取得経路自身で必ず検証する。
1438
+ const cache = session.historySnapshotCache
1439
+ const items = await cache.get(async () => {
1440
+ // session破棄時の cache.clear() で進行中callerが新世代buildへ合流し直しても、停止済み
1441
+ // clientへ2本目のthread/readを送らない。
1442
+ if (session.dead) throw new Error("stream_id is not attached")
1443
+ const result = await session.client.request("thread/read", {
1444
+ threadId: session.threadId,
1445
+ includeTurns: true,
1446
+ })
1447
+ if (result?.thread?.id !== session.threadId) {
1448
+ throw new Error("thread/read returned a different thread")
1449
+ }
1450
+ const items = flattenAllThreadItems(result.thread)
1451
+ if (
1452
+ session.historySnapshotComparable === true &&
1453
+ Number.isInteger(session.historyTotal) &&
1454
+ items.length !== session.historyTotal
1455
+ ) {
1456
+ this.logger?.warn?.(
1457
+ {
1458
+ thread_id: session.threadId,
1459
+ snapshot_items: items.length,
1460
+ live_history_total: session.historyTotal,
1461
+ },
1462
+ "codex history snapshot item count differs from live parent cursor",
1463
+ )
1464
+ }
1465
+ session.historyTotal = Math.max(
1466
+ items.length,
1467
+ Number.isInteger(session.historyTotal) ? session.historyTotal : 0,
1468
+ )
1469
+ return items
1470
+ })
1471
+ this._scheduleHistorySnapshotRelease(session)
1472
+ return items
1473
+ }
1474
+
1475
+ /**
1476
+ * live session の parent thread から古い履歴 page を読む。
1477
+ * before は全 parent item 座標上の exclusive cursor で、返却は [historyStart, before) 相当。
1478
+ * ready.history は child item を含み得るため、その配列 index とこの cursor は同一ではない。
1479
+ * この read-only RPC 自体は session の liveness を延長しない。閲覧中の生存は keepalive が担う。
1480
+ */
1481
+ async history({ stream_id, thread_id, before, limit }) {
1482
+ const session = this.sessions.get(stream_id)
1483
+ if (!session || session.dead) throw new Error("stream_id is not attached")
1484
+ if (typeof thread_id !== "string" || !thread_id || thread_id !== session.threadId) {
1485
+ throw new Error("thread_id does not match stream session")
1486
+ }
1487
+ if (!Number.isInteger(before) || before < 0) {
1488
+ throw new Error("before must be a non-negative integer")
1489
+ }
1490
+ if (limit != null && (!Number.isInteger(limit) || limit <= 0)) {
1491
+ throw new Error("limit must be a positive integer")
1492
+ }
1493
+ if (before > (Number.isInteger(session.historyTotal) ? session.historyTotal : 0)) {
1494
+ throw new Error("before exceeds live history total")
1495
+ }
1496
+ let all = await this._historySnapshot(session)
1497
+ // readyが広告した正当cursorへTTL snapshotが追いついていない場合だけ、このrequest内で最大1回
1498
+ // 強制再buildする。再帰/loopは使わず、なお短ければerrorへ固定する。
1499
+ if (before > all.length) {
1500
+ this._disposeHistorySnapshot(session)
1501
+ all = await this._historySnapshot(session)
1502
+ }
1503
+ if (before > all.length) {
1504
+ throw new Error("before exceeds history snapshot total")
1505
+ }
1506
+ const maxItems = this._historyLimits.maxItems
1507
+ const pageLimit = limit == null ? maxItems : Math.min(maxItems, limit)
1508
+ const page = pageThreadItems(all, {
1509
+ before,
1510
+ maxItems: pageLimit,
1511
+ maxBytes: this._historyLimits.maxBytes,
1512
+ })
1513
+ return {
1514
+ threadId: session.threadId,
1515
+ history: page.items,
1516
+ historyStart: page.start,
1517
+ historyTotal: page.total,
1518
+ historyTruncated: page.truncated,
1519
+ requestBefore: before,
1520
+ appliedLimit: pageLimit,
1521
+ // subagents は意図的に省略する。古い page から復元すると child thread/read と session 台帳変更が
1522
+ // 発生し、read-only fastpath ではなくなるため。wire 契約上、省略時は in-memory 台帳が無い
1523
+ // subagent を通常の簡易履歴表示へ fallback させ、推測で別 thread の snapshot を混ぜない。
1331
1524
  }
1332
1525
  }
1333
1526
 
@@ -2794,7 +2987,15 @@ export class CodexStreamBridge extends EventEmitter {
2794
2987
  case "item/completed":
2795
2988
  // 完了した ThreadItem を高忠実度履歴に追記する(cwd reattach で返す用)。
2796
2989
  // attribution/source 済み item (effectiveParams.item) を積み、emit と同じ clone を使う。
2797
- this._appendHistoryItem(session, effectiveParams?.item, effectiveParams?.completedAtMs)
2990
+ this._appendHistoryItem(
2991
+ session,
2992
+ effectiveParams?.item,
2993
+ effectiveParams?.completedAtMs,
2994
+ {
2995
+ countsForCursor:
2996
+ typeof eventThreadId !== "string" || eventThreadId === session.threadId,
2997
+ },
2998
+ )
2798
2999
  break
2799
3000
  default:
2800
3001
  break
@@ -2973,6 +3174,7 @@ export class CodexStreamBridge extends EventEmitter {
2973
3174
  if (session.dead) return
2974
3175
  this._markRunningSubagentsKilled(session)
2975
3176
  session.dead = true
3177
+ this._disposeHistorySnapshot(session)
2976
3178
  // round3: app-server が死んだら barrier を解除し queue を reject する(promise 宙吊り防止)。
2977
3179
  this._settleTurnActiveWaiter(session)
2978
3180
  this._drainRejectQueue(session, { reason: "queue_dead" })
@@ -4007,6 +4209,7 @@ export class CodexStreamBridge extends EventEmitter {
4007
4209
  if (session.dead) return
4008
4210
  this._markRunningSubagentsKilled(session, now)
4009
4211
  session.dead = true
4212
+ this._disposeHistorySnapshot(session)
4010
4213
  // round3: reap した session の barrier を解除し queue を reject する(promise 宙吊り防止)。
4011
4214
  this._settleTurnActiveWaiter(session)
4012
4215
  this._drainRejectQueue(session, { reason: "queue_dead" })
@@ -4196,6 +4399,7 @@ export class CodexStreamBridge extends EventEmitter {
4196
4399
  for (const session of this._liveSessions()) {
4197
4400
  this._markRunningSubagentsKilled(session)
4198
4401
  session.dead = true
4402
+ this._disposeHistorySnapshot(session)
4199
4403
  // round3: shutdown で barrier を解除し queue 入力を reject(promise 宙吊り防止)。
4200
4404
  this._settleTurnActiveWaiter(session)
4201
4405
  this._drainRejectQueue(session, { reason: "queue_dead" })
package/src/main.mjs CHANGED
@@ -412,6 +412,7 @@ export function isReadOnlyRpc(type) {
412
412
  type === "claude.history.request" ||
413
413
  type === "claude.sessions.request" ||
414
414
  type === "codex.sessions.request" ||
415
+ type === "codex.history.request" ||
415
416
  type === "profile.list" ||
416
417
  type === "codex.profile.list" ||
417
418
  type === "chat.sidebar_snapshot.request" ||
@@ -2288,8 +2289,10 @@ export function buildClaudeSessionOptionsResultPayload(
2288
2289
  }
2289
2290
 
2290
2291
  /** codex.ready の wire payload を組み立てる。history は同じ配列参照を保持し、
2291
- * started_history_index が指す位置を main 側の加工でずらさない。表示を保持する reconnect
2292
- * (`info.subagents` 未定義) では subagents キー自体を省略し、空 snapshot で既存表示を消さない。 */
2292
+ * started_history_index が指す位置を main 側の加工でずらさない。history_start/history_total は
2293
+ * parent item cursor 専用で、child item を含み得る history 配列 index とは別座標。表示を保持する
2294
+ * reconnect (`info.subagents` 未定義) では subagents キー自体を省略し、空 snapshot で既存表示を消さない。
2295
+ * retained上限で落ちたchild本文はcursorに痕跡を持たず、warm reattachでの復元はbest-effort。 */
2293
2296
  export function buildCodexReadyPayload(streamId, info, queueState) {
2294
2297
  const history = Array.isArray(info?.history) ? info.history : []
2295
2298
  const payload = {
@@ -2307,6 +2310,8 @@ export function buildCodexReadyPayload(streamId, info, queueState) {
2307
2310
  // 子 thread の個別停止 UI を有効化する。省略は未対応を意味する。
2308
2311
  supports_subagent_interrupt: true,
2309
2312
  }
2313
+ if (Number.isInteger(info?.historyStart)) payload.history_start = info.historyStart
2314
+ if (Number.isInteger(info?.historyTotal)) payload.history_total = info.historyTotal
2310
2315
  if (Array.isArray(info?.subagents)) {
2311
2316
  payload.subagents = info.subagents.map((subagent) => ({
2312
2317
  // 現契約では delegation_id と agent_thread_id は同じ child thread ID の alias。
@@ -4762,6 +4767,43 @@ export async function dispatch(msg, ctx) {
4762
4767
  }
4763
4768
  return
4764
4769
  }
4770
+ case "codex.history.request": {
4771
+ const stream_id = msg.stream_id
4772
+ const thread_id = msg.thread_id
4773
+ const empty = {
4774
+ type: "codex.history.response",
4775
+ stream_id,
4776
+ thread_id,
4777
+ history: [],
4778
+ ...(Number.isInteger(msg.before) ? { request_before: msg.before } : {}),
4779
+ }
4780
+ if (!ctx.codexBridge) {
4781
+ ctx.client.send({ ...empty, error: "codex_unavailable: bridge is not initialized" })
4782
+ return
4783
+ }
4784
+ try {
4785
+ const result = await ctx.codexBridge.history({
4786
+ stream_id,
4787
+ thread_id,
4788
+ before: msg.before,
4789
+ limit: msg.limit,
4790
+ })
4791
+ ctx.client.send({
4792
+ type: "codex.history.response",
4793
+ stream_id,
4794
+ thread_id: result.threadId,
4795
+ history: result.history,
4796
+ history_start: result.historyStart,
4797
+ history_total: result.historyTotal,
4798
+ history_truncated: result.historyTruncated === true,
4799
+ request_before: result.requestBefore,
4800
+ applied_limit: result.appliedLimit,
4801
+ })
4802
+ } catch (err) {
4803
+ ctx.client.send({ ...empty, error: err?.message || String(err) })
4804
+ }
4805
+ return
4806
+ }
4765
4807
  case "claude.history.request": {
4766
4808
  // Sprint G 0.6.1: ~/.claude/projects/<cwd-encoded>/<session_id>.jsonl を読んで
4767
4809
  // 過去メッセージを返す。ChatView の re-mount 時の UI 復元用。