@cocorograph/hub-agent 0.8.51 → 0.8.53

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.53",
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,25 @@ export class CodexStreamBridge extends EventEmitter {
984
1001
  // 生データ(ツールカードが粗い)ではなく app-server 整形済みの高忠実度履歴を維持する。
985
1002
  historyItems: [],
986
1003
  historyTruncated: false,
1004
+ historyStart: 0,
1005
+ historyTotal: 0,
1006
+ // cold resume が確定した parent cursor の基準件数と、それ以降の root live 追記数。
1007
+ // thread/read の短縮が「先頭 compaction」か「末尾反映待ち」かを区別する。
1008
+ historyColdBaseTotal: 0,
1009
+ historyLiveCursorAdditions: 0,
1010
+ // reattach 表示履歴には child item も含むが cursor は parent item だけを数える。
1011
+ // historyItems/historyCursorWeights は常に同長。weight は 1=parent / 0=child で、trim 時の
1012
+ // start 前進量を決める。ready の start/total はこの cursor 専用で配列 index ではない。
1013
+ // childだけがtrimされた事実はcursorへ残らず、warm reattachでのchild本文復元はbest-effort。
1014
+ historyCursorWeights: [],
1015
+ historySnapshotCache: new AsyncTtlCache({ ttlMs: this._historySnapshotCacheTtlMs }),
1016
+ historySnapshotReleaseTimer: null,
1017
+ historySnapshotBuildSeq: 0,
1018
+ historySnapshotReturnedBuildSeq: 0,
1019
+ historySnapshotFreshOperations: new Set(),
1020
+ historySnapshotComparable: false,
1021
+ historyAttachInProgress: true,
1022
+ historyItemArrivedDuringAttach: false,
987
1023
  // major#2: ライブ追記でも cold flatten と同じ「item 数+概算 byte」二重上限を課すため、
988
1024
  // 蓄積済み履歴の概算 byte 合計を保持する (再計算を避け O(1) で trim 判定)。
989
1025
  historyBytes: 0,
@@ -1136,7 +1172,7 @@ export class CodexStreamBridge extends EventEmitter {
1136
1172
  // T06499: 巨大スレッドは末尾 N ターンに絞る (truncated=true なら browser がバナー表示)。
1137
1173
  const flat = resuming
1138
1174
  ? flattenThreadItems(result?.thread, this._historyLimits)
1139
- : { items: [], truncated: false, total: 0 }
1175
+ : { items: [], start: 0, truncated: false, total: 0 }
1140
1176
  // hub-agent 側の履歴整形コスト (巨大スレッドで効く)。thread_op_ms とは別勘定にする。
1141
1177
  timing.history_flatten_ms = lap()
1142
1178
  const history = flat.items
@@ -1147,6 +1183,13 @@ export class CodexStreamBridge extends EventEmitter {
1147
1183
  session.historyItems = history.slice()
1148
1184
  session.historyBytes = history.reduce((n, it) => n + estimateItemBytes(it), 0)
1149
1185
  session.historyTruncated = historyTruncated
1186
+ session.historyStart = flat.start
1187
+ session.historyTotal = flat.total
1188
+ session.historyColdBaseTotal = flat.total
1189
+ session.historyLiveCursorAdditions = 0
1190
+ session.historyCursorWeights = history.map(() => 1)
1191
+ session.historyAttachInProgress = false
1192
+ session.historySnapshotComparable = session.historyItemArrivedDuringAttach !== true
1150
1193
  if (resuming) await this._restoreSubagentsFromHistory(session, history)
1151
1194
  // 案C: 権威的なターン状態を resume 応答から取り出してフロントへ渡す。
1152
1195
  // これによりフロントは会話末尾からの推測をやめ、Thread.status で turnActive を
@@ -1204,6 +1247,8 @@ export class CodexStreamBridge extends EventEmitter {
1204
1247
  threadId: session.threadId,
1205
1248
  history,
1206
1249
  historyTruncated,
1250
+ historyStart: flat.start,
1251
+ historyTotal: flat.total,
1207
1252
  subagents: this._subagentSnapshots(session, history),
1208
1253
  threadStatus: turnState.threadStatus,
1209
1254
  lastTurnStatus: turnState.lastTurnStatus,
@@ -1212,6 +1257,7 @@ export class CodexStreamBridge extends EventEmitter {
1212
1257
  } catch (err) {
1213
1258
  this.sessions.delete(stream_id)
1214
1259
  this._settleAllPendingApprovals(session, { respond: false })
1260
+ this._disposeSessionHistory(session)
1215
1261
  client.stop()
1216
1262
  settleReady(null) // reattach 待ちへ「失敗」を通知 (reject しない)
1217
1263
  // 失敗も 1 行に落とす。**_classifyAttachFailure より前**に出す (分類は rollout の disk 走査を
@@ -1306,15 +1352,46 @@ export class CodexStreamBridge extends EventEmitter {
1306
1352
  * 表示用バブル時刻 at_ms は completedAtMs (ms epoch) を採る(flattenThreadItems と同じ ms 単位。
1307
1353
  * userMessage はライブでも completedAtMs=ターン冒頭で届くため整合する)。長い会話で無限成長させ
1308
1354
  * ないよう cold flatten と同じ **item 数 (maxItems) + 概算 byte 数 (maxBytes) の二重上限** を課し、
1309
- * 超過分は先頭 (最古) から捨て truncated を立てる (major#2: 巨大ツール出力で reattach payload が
1310
- * 肥大化し T06499 で防いだメモリ増・WS 転送・ブラウザフリーズが再発するのを防ぐ)。
1355
+ * 超過分は先頭 (最古) から捨て、parent cursor が前進した場合だけ truncated を立てる
1356
+ * (major#2: 巨大ツール出力で reattach payload が肥大化し T06499 で防いだメモリ増・WS 転送・
1357
+ * ブラウザフリーズが再発するのを防ぐ)。
1311
1358
  */
1312
- _appendHistoryItem(session, item, completedAtMs) {
1359
+ _appendHistoryItem(session, item, completedAtMs, { countsForCursor = true } = {}) {
1313
1360
  if (!session || !item || typeof item !== "object") return
1361
+ if (session.historyAttachInProgress === true) session.historyItemArrivedDuringAttach = true
1314
1362
  if (!Array.isArray(session.historyItems)) session.historyItems = []
1315
1363
  if (typeof session.historyBytes !== "number") session.historyBytes = 0
1364
+ // 旧/遅延初期化 session でも同長不変条件を回復する。既存 item の由来を失っている場合は
1365
+ // parent として安全側に数え、以降の child item だけを明示的に 0 とする。
1366
+ if (
1367
+ !Array.isArray(session.historyCursorWeights) ||
1368
+ session.historyCursorWeights.length !== session.historyItems.length
1369
+ ) {
1370
+ this.logger?.warn?.(
1371
+ {
1372
+ thread_id: session.threadId,
1373
+ history_items: session.historyItems.length,
1374
+ cursor_weights: Array.isArray(session.historyCursorWeights)
1375
+ ? session.historyCursorWeights.length
1376
+ : null,
1377
+ },
1378
+ "codex history cursor weights rebuilt to match retained items",
1379
+ )
1380
+ session.historyCursorWeights = session.historyItems.map(() => 1)
1381
+ }
1316
1382
  const entry = typeof completedAtMs === "number" ? { ...item, at_ms: completedAtMs } : item
1317
1383
  session.historyItems.push(entry)
1384
+ const cursorWeight = countsForCursor ? 1 : 0
1385
+ session.historyCursorWeights.push(cursorWeight)
1386
+ session.historyTotal = (Number.isInteger(session.historyTotal) ? session.historyTotal : 0) + cursorWeight
1387
+ if (cursorWeight === 1 && session.historyAttachInProgress !== true) {
1388
+ session.historyLiveCursorAdditions =
1389
+ (Number.isInteger(session.historyLiveCursorAdditions)
1390
+ ? session.historyLiveCursorAdditions
1391
+ : 0) + 1
1392
+ }
1393
+ // 古い page は同じ snapshot 座標内なら順序が壊れないため cache は clear しない。
1394
+ // child 更新は parent thread/read snapshot にも無関係。TTL 後の再取得で自然に追随する。
1318
1395
  session.historyBytes += estimateItemBytes(entry)
1319
1396
  const maxItems = this._historyLimits.maxItems
1320
1397
  const maxBytes = this._historyLimits.maxBytes
@@ -1325,9 +1402,347 @@ export class CodexStreamBridge extends EventEmitter {
1325
1402
  (typeof maxBytes === "number" && session.historyBytes > maxBytes))
1326
1403
  ) {
1327
1404
  const dropped = session.historyItems.shift()
1405
+ const droppedCursorWeight = session.historyCursorWeights.shift() || 0
1328
1406
  session.historyBytes -= estimateItemBytes(dropped)
1329
1407
  if (session.historyBytes < 0) session.historyBytes = 0
1330
- session.historyTruncated = true
1408
+ session.historyStart =
1409
+ (Number.isInteger(session.historyStart) ? session.historyStart : 0) + droppedCursorWeight
1410
+ }
1411
+ // child だけが表示上限から落ちても parent cursor の古い側は欠けていない。
1412
+ session.historyTruncated = (Number.isInteger(session.historyStart) ? session.historyStart : 0) > 0
1413
+ }
1414
+
1415
+ _scheduleHistorySnapshotRelease(session) {
1416
+ if (session.historySnapshotReleaseTimer) clearTimeout(session.historySnapshotReleaseTimer)
1417
+ const cache = session.historySnapshotCache
1418
+ if (!cache?.hasValue) {
1419
+ session.historySnapshotReleaseTimer = null
1420
+ return
1421
+ }
1422
+ const release = () => {
1423
+ if (session.historySnapshotReleaseTimer !== timer) return
1424
+ // TTL境界で次buildが始まっている場合、そのbuildをclear世代変更で再実行させない。
1425
+ // build完了後の _scheduleHistorySnapshotRelease がこの短い繰延timerを張り替える。
1426
+ if (cache.inFlight) {
1427
+ const retryDelayMs = Math.max(10, Math.min(1_000, this._historySnapshotCacheTtlMs))
1428
+ const retryTimer = setTimeout(release, retryDelayMs)
1429
+ retryTimer.unref?.()
1430
+ session.historySnapshotReleaseTimer = retryTimer
1431
+ timer = retryTimer
1432
+ return
1433
+ }
1434
+ session.historySnapshotReleaseTimer = null
1435
+ cache.clear()
1436
+ }
1437
+ const delayMs = Math.max(0, cache.expiresAt - Date.now())
1438
+ let timer = setTimeout(release, delayMs)
1439
+ timer.unref?.()
1440
+ session.historySnapshotReleaseTimer = timer
1441
+ }
1442
+
1443
+ _disposeHistorySnapshot(session) {
1444
+ if (!session) return
1445
+ if (session.historySnapshotReleaseTimer) clearTimeout(session.historySnapshotReleaseTimer)
1446
+ session.historySnapshotReleaseTimer = null
1447
+ session.historySnapshotCache?.clear?.()
1448
+ }
1449
+
1450
+ _abortFreshHistorySnapshot(session) {
1451
+ if (!session) return
1452
+ // clear() は進行中AsyncTtlCache buildを切り離して新世代buildを許すため、fresh operationも
1453
+ // 一時的に複数並走し得る。finally deleteとの競合を避け、snapshot配列を全件abort→stopする。
1454
+ const operations = [...(session.historySnapshotFreshOperations || [])]
1455
+ for (const operation of operations) {
1456
+ operation.abort?.()
1457
+ try {
1458
+ operation.stop?.()
1459
+ } catch (error) {
1460
+ this.logger?.warn?.(
1461
+ {
1462
+ error_code: codexHostCapabilityErrorCode(
1463
+ error,
1464
+ "CODEX_HISTORY_SNAPSHOT_STOP_FAILED",
1465
+ ),
1466
+ },
1467
+ "codex history snapshot client stop failed",
1468
+ )
1469
+ }
1470
+ }
1471
+ }
1472
+
1473
+ _disposeSessionHistory(session) {
1474
+ this._abortFreshHistorySnapshot(session)
1475
+ this._disposeHistorySnapshot(session)
1476
+ }
1477
+
1478
+ async _resumeFullHistorySnapshot(
1479
+ session,
1480
+ advertisedTotal,
1481
+ requiredTotal,
1482
+ liveItems,
1483
+ coldBaseTotal,
1484
+ additionsAtRequest,
1485
+ ) {
1486
+ const client = this._clientFactory({
1487
+ cwd: session.cwd,
1488
+ codexCmd: this.codexCmd,
1489
+ logger: this.logger,
1490
+ env: siblingEnvFor(session.cwd),
1491
+ })
1492
+ client.on("error", (error) => {
1493
+ this.logger?.warn?.(
1494
+ {
1495
+ error_code: codexHostCapabilityErrorCode(
1496
+ error,
1497
+ "CODEX_HISTORY_SNAPSHOT_CLIENT_ERROR",
1498
+ ),
1499
+ },
1500
+ "codex history snapshot client error",
1501
+ )
1502
+ })
1503
+ const startedAt = Date.now()
1504
+ let stopped = false
1505
+ const stopClient = () => {
1506
+ if (stopped) return
1507
+ stopped = true
1508
+ client.stop()
1509
+ }
1510
+ let rejectAbort
1511
+ const aborted = new Promise((_, reject) => {
1512
+ rejectAbort = reject
1513
+ })
1514
+ const abort = () => {
1515
+ const error = new Error("stream_id is not attached")
1516
+ error.code = "CODEX_HISTORY_SNAPSHOT_FALLBACK_ABORTED"
1517
+ rejectAbort(error)
1518
+ }
1519
+ const operation = { client, abort, stop: stopClient }
1520
+ session.historySnapshotFreshOperations.add(operation)
1521
+ this.logger?.info?.(
1522
+ {
1523
+ thread_id: session.threadId,
1524
+ advertised_total: advertisedTotal,
1525
+ required_total: requiredTotal,
1526
+ live_items: liveItems,
1527
+ cold_base_total: coldBaseTotal,
1528
+ additions_at_request: additionsAtRequest,
1529
+ },
1530
+ "codex history snapshot fallback started",
1531
+ )
1532
+ try {
1533
+ const items = await Promise.race([
1534
+ (async () => {
1535
+ client.start()
1536
+ await client.request("initialize", {
1537
+ clientInfo: CLIENT_INFO,
1538
+ capabilities: {
1539
+ experimentalApi: true,
1540
+ mcpServerOpenaiFormElicitation: true,
1541
+ },
1542
+ })
1543
+ client.notify("initialized", {})
1544
+ const result = await client.request("thread/resume", {
1545
+ cwd: session.cwd,
1546
+ threadId: session.threadId,
1547
+ })
1548
+ if (result?.thread?.id !== session.threadId) {
1549
+ throw new Error("fresh thread/resume returned a different thread")
1550
+ }
1551
+ const resumedItems = flattenAllThreadItems(result.thread)
1552
+ if (resumedItems.length < requiredTotal) {
1553
+ throw new Error("fresh history snapshot is shorter than required prefix total")
1554
+ }
1555
+ return resumedItems
1556
+ })(),
1557
+ aborted,
1558
+ ])
1559
+ this.logger?.info?.(
1560
+ {
1561
+ thread_id: session.threadId,
1562
+ advertised_total: advertisedTotal,
1563
+ required_total: requiredTotal,
1564
+ live_items: liveItems,
1565
+ cold_base_total: coldBaseTotal,
1566
+ additions_at_request: additionsAtRequest,
1567
+ elapsed_ms: Date.now() - startedAt,
1568
+ },
1569
+ "codex history snapshot fallback completed",
1570
+ )
1571
+ return items
1572
+ } catch (error) {
1573
+ const isAborted = error?.code === "CODEX_HISTORY_SNAPSHOT_FALLBACK_ABORTED"
1574
+ this.logger?.[isAborted ? "info" : "warn"]?.(
1575
+ {
1576
+ thread_id: session.threadId,
1577
+ advertised_total: advertisedTotal,
1578
+ required_total: requiredTotal,
1579
+ live_items: liveItems,
1580
+ cold_base_total: coldBaseTotal,
1581
+ additions_at_request: additionsAtRequest,
1582
+ elapsed_ms: Date.now() - startedAt,
1583
+ error_code: codexHostCapabilityErrorCode(
1584
+ error,
1585
+ "CODEX_HISTORY_SNAPSHOT_FALLBACK_FAILED",
1586
+ ),
1587
+ },
1588
+ isAborted
1589
+ ? "codex history snapshot fallback aborted"
1590
+ : "codex history snapshot fallback failed",
1591
+ )
1592
+ throw error
1593
+ } finally {
1594
+ stopClient()
1595
+ session.historySnapshotFreshOperations.delete(operation)
1596
+ }
1597
+ }
1598
+
1599
+ async _historySnapshot(session) {
1600
+ // この reader は既存の別責務の thread/read と統合しない。要求した root thread と応答の
1601
+ // thread id が一致することを、この履歴取得経路自身で必ず検証する。
1602
+ const cache = session.historySnapshotCache
1603
+ const snapshot = await cache.get(async () => {
1604
+ const buildSeq = (Number.isInteger(session.historySnapshotBuildSeq)
1605
+ ? session.historySnapshotBuildSeq
1606
+ : 0) + 1
1607
+ session.historySnapshotBuildSeq = buildSeq
1608
+ // session破棄時の cache.clear() で進行中callerが新世代buildへ合流し直しても、停止済み
1609
+ // clientへ2本目のthread/readを送らない。
1610
+ if (session.dead) throw new Error("stream_id is not attached")
1611
+ const additionsAtRequest = Number.isInteger(session.historyLiveCursorAdditions)
1612
+ ? session.historyLiveCursorAdditions
1613
+ : 0
1614
+ const result = await session.client.request("thread/read", {
1615
+ threadId: session.threadId,
1616
+ includeTurns: true,
1617
+ })
1618
+ if (result?.thread?.id !== session.threadId) {
1619
+ throw new Error("thread/read returned a different thread")
1620
+ }
1621
+ if (session.dead) throw new Error("stream_id is not attached")
1622
+ let items = flattenAllThreadItems(result.thread)
1623
+ const coldBaseTotal = Number.isInteger(session.historyColdBaseTotal)
1624
+ ? session.historyColdBaseTotal
1625
+ : 0
1626
+ const liveCursorAdditions = Number.isInteger(session.historyLiveCursorAdditions)
1627
+ ? session.historyLiveCursorAdditions
1628
+ : 0
1629
+ const advertisedTotal = Math.max(
1630
+ Number.isInteger(session.historyTotal) ? session.historyTotal : 0,
1631
+ coldBaseTotal + liveCursorAdditions,
1632
+ )
1633
+ const knownPrefixAtRequest = coldBaseTotal + additionsAtRequest
1634
+ if (
1635
+ session.historySnapshotComparable === true &&
1636
+ items.length !== advertisedTotal
1637
+ ) {
1638
+ this.logger?.warn?.(
1639
+ {
1640
+ thread_id: session.threadId,
1641
+ snapshot_items: items.length,
1642
+ live_history_total: session.historyTotal,
1643
+ },
1644
+ "codex history snapshot item count differs from live parent cursor",
1645
+ )
1646
+ }
1647
+ let usedFresh = false
1648
+ // read開始時点で既知だったroot cursor prefixより短い場合だけcompactionと判定する。
1649
+ // read発行前に観測した加算はread応答に含まれる前提。await中のlive追加はknownPrefixへ
1650
+ // 含めず末尾lagとして許容し、この前提が破れた可能性はfallbackログで観測する。
1651
+ if (items.length < knownPrefixAtRequest) {
1652
+ const requiredTotal = coldBaseTotal > 0 ? coldBaseTotal : knownPrefixAtRequest
1653
+ items = await this._resumeFullHistorySnapshot(
1654
+ session,
1655
+ advertisedTotal,
1656
+ requiredTotal,
1657
+ items.length,
1658
+ coldBaseTotal,
1659
+ additionsAtRequest,
1660
+ )
1661
+ usedFresh = true
1662
+ // このbridgeはthread/rollback・thread/forkを呼ばず単調threadだけを扱うため、fresh
1663
+ // thread/resumeのitemsは先頭からのdurable prefix。fresh成功時だけbaseを単調に引上げ、
1664
+ // 並行live追加は差分として残す。
1665
+ session.historyColdBaseTotal = Math.max(
1666
+ Number.isInteger(session.historyColdBaseTotal) ? session.historyColdBaseTotal : 0,
1667
+ items.length,
1668
+ )
1669
+ const newBase = session.historyColdBaseTotal
1670
+ session.historyLiveCursorAdditions = Math.max(
1671
+ 0,
1672
+ (Number.isInteger(session.historyTotal) ? session.historyTotal : 0) - newBase,
1673
+ )
1674
+ }
1675
+ if (session.dead) throw new Error("stream_id is not attached")
1676
+ session.historyTotal = Math.max(
1677
+ items.length,
1678
+ Number.isInteger(session.historyTotal) ? session.historyTotal : 0,
1679
+ )
1680
+ return { items, usedFresh, buildSeq }
1681
+ })
1682
+ if (session.dead) throw new Error("stream_id is not attached")
1683
+ session.historySnapshotReturnedBuildSeq = Math.max(
1684
+ Number.isInteger(session.historySnapshotReturnedBuildSeq)
1685
+ ? session.historySnapshotReturnedBuildSeq
1686
+ : 0,
1687
+ snapshot.buildSeq,
1688
+ )
1689
+ this._scheduleHistorySnapshotRelease(session)
1690
+ return snapshot
1691
+ }
1692
+
1693
+ /**
1694
+ * live session の parent thread から古い履歴 page を読む。
1695
+ * before は全 parent item 座標上の exclusive cursor で、返却は [historyStart, before) 相当。
1696
+ * ready.history は child item を含み得るため、その配列 index とこの cursor は同一ではない。
1697
+ * この read-only RPC 自体は session の liveness を延長しない。閲覧中の生存は keepalive が担う。
1698
+ */
1699
+ async history({ stream_id, thread_id, before, limit }) {
1700
+ const session = this.sessions.get(stream_id)
1701
+ if (!session || session.dead) throw new Error("stream_id is not attached")
1702
+ if (typeof thread_id !== "string" || !thread_id || thread_id !== session.threadId) {
1703
+ throw new Error("thread_id does not match stream session")
1704
+ }
1705
+ if (!Number.isInteger(before) || before < 0) {
1706
+ throw new Error("before must be a non-negative integer")
1707
+ }
1708
+ if (limit != null && (!Number.isInteger(limit) || limit <= 0)) {
1709
+ throw new Error("limit must be a positive integer")
1710
+ }
1711
+ if (before > (Number.isInteger(session.historyTotal) ? session.historyTotal : 0)) {
1712
+ throw new Error("before exceeds live history total")
1713
+ }
1714
+ const maxItems = this._historyLimits.maxItems
1715
+ const pageLimit = limit == null ? maxItems : Math.min(maxItems, limit)
1716
+ const seqAtCall = Number.isInteger(session.historySnapshotReturnedBuildSeq)
1717
+ ? session.historySnapshotReturnedBuildSeq
1718
+ : 0
1719
+ let snapshot = await this._historySnapshot(session)
1720
+ // readyが広告した正当cursorへlive-only snapshotが追いついていない場合だけ、このrequest内で
1721
+ // 最大1回再buildする。fresh済みsnapshotは同一requestで2本目を起動せず即errorへ固定する。
1722
+ const usedFreshDuringCall = snapshot.usedFresh === true && snapshot.buildSeq > seqAtCall
1723
+ if (before > snapshot.items.length && !usedFreshDuringCall) {
1724
+ this._disposeHistorySnapshot(session)
1725
+ snapshot = await this._historySnapshot(session)
1726
+ }
1727
+ if (before > snapshot.items.length) {
1728
+ throw new Error("before exceeds history snapshot total")
1729
+ }
1730
+ const page = pageThreadItems(snapshot.items, {
1731
+ before,
1732
+ maxItems: pageLimit,
1733
+ maxBytes: this._historyLimits.maxBytes,
1734
+ })
1735
+ return {
1736
+ threadId: session.threadId,
1737
+ history: page.items,
1738
+ historyStart: page.start,
1739
+ historyTotal: page.total,
1740
+ historyTruncated: page.truncated,
1741
+ requestBefore: before,
1742
+ appliedLimit: pageLimit,
1743
+ // subagents は意図的に省略する。古い page から復元すると child thread/read と session 台帳変更が
1744
+ // 発生し、read-only fastpath ではなくなるため。wire 契約上、省略時は in-memory 台帳が無い
1745
+ // subagent を通常の簡易履歴表示へ fallback させ、推測で別 thread の snapshot を混ぜない。
1331
1746
  }
1332
1747
  }
1333
1748
 
@@ -2794,7 +3209,15 @@ export class CodexStreamBridge extends EventEmitter {
2794
3209
  case "item/completed":
2795
3210
  // 完了した ThreadItem を高忠実度履歴に追記する(cwd reattach で返す用)。
2796
3211
  // attribution/source 済み item (effectiveParams.item) を積み、emit と同じ clone を使う。
2797
- this._appendHistoryItem(session, effectiveParams?.item, effectiveParams?.completedAtMs)
3212
+ this._appendHistoryItem(
3213
+ session,
3214
+ effectiveParams?.item,
3215
+ effectiveParams?.completedAtMs,
3216
+ {
3217
+ countsForCursor:
3218
+ typeof eventThreadId !== "string" || eventThreadId === session.threadId,
3219
+ },
3220
+ )
2798
3221
  break
2799
3222
  default:
2800
3223
  break
@@ -2973,6 +3396,7 @@ export class CodexStreamBridge extends EventEmitter {
2973
3396
  if (session.dead) return
2974
3397
  this._markRunningSubagentsKilled(session)
2975
3398
  session.dead = true
3399
+ this._disposeSessionHistory(session)
2976
3400
  // round3: app-server が死んだら barrier を解除し queue を reject する(promise 宙吊り防止)。
2977
3401
  this._settleTurnActiveWaiter(session)
2978
3402
  this._drainRejectQueue(session, { reason: "queue_dead" })
@@ -4007,6 +4431,7 @@ export class CodexStreamBridge extends EventEmitter {
4007
4431
  if (session.dead) return
4008
4432
  this._markRunningSubagentsKilled(session, now)
4009
4433
  session.dead = true
4434
+ this._disposeSessionHistory(session)
4010
4435
  // round3: reap した session の barrier を解除し queue を reject する(promise 宙吊り防止)。
4011
4436
  this._settleTurnActiveWaiter(session)
4012
4437
  this._drainRejectQueue(session, { reason: "queue_dead" })
@@ -4196,6 +4621,7 @@ export class CodexStreamBridge extends EventEmitter {
4196
4621
  for (const session of this._liveSessions()) {
4197
4622
  this._markRunningSubagentsKilled(session)
4198
4623
  session.dead = true
4624
+ this._disposeSessionHistory(session)
4199
4625
  // round3: shutdown で barrier を解除し queue 入力を reject(promise 宙吊り防止)。
4200
4626
  this._settleTurnActiveWaiter(session)
4201
4627
  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 復元用。