@cocorograph/hub-agent 0.8.50 → 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 +1 -1
- package/src/codex-stream-bridge.mjs +235 -31
- package/src/main.mjs +44 -2
- package/src/usage.mjs +22 -4
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
376
|
-
//
|
|
377
|
-
|
|
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
|
|
410
|
-
|
|
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
|
-
|
|
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 数 (
|
|
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,
|
|
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
|
-
* 超過分は先頭 (最古)
|
|
1310
|
-
*
|
|
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.
|
|
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(
|
|
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
|
|
2292
|
-
*
|
|
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 復元用。
|
package/src/usage.mjs
CHANGED
|
@@ -1528,12 +1528,13 @@ function commitCodexThreadListEntry(index, fp, observed, next) {
|
|
|
1528
1528
|
*
|
|
1529
1529
|
* @param {string} fp
|
|
1530
1530
|
* @param {string} expectedCwd cwd-scoped 一覧の対象。別 cwd は session_meta 読み取り後に早期終了。
|
|
1531
|
-
* @returns {Promise<{thread_id:string,cwd:string,preview:string,preview_complete:boolean}|null|symbol>}
|
|
1531
|
+
* @returns {Promise<{thread_id:string,cwd:string,preview:string,preview_complete:boolean,is_subagent:boolean}|null|symbol>}
|
|
1532
1532
|
*/
|
|
1533
1533
|
async function parseCodexRolloutHead(fp, expectedCwd = "", testHooks = null) {
|
|
1534
1534
|
let thread_id = threadIdFromRolloutFilename(fp)
|
|
1535
1535
|
let cwd = ""
|
|
1536
1536
|
let preview = ""
|
|
1537
|
+
let is_subagent = false
|
|
1537
1538
|
let fh
|
|
1538
1539
|
const decoder = new StringDecoder("utf8")
|
|
1539
1540
|
let pending = ""
|
|
@@ -1553,6 +1554,13 @@ async function parseCodexRolloutHead(fp, expectedCwd = "", testHooks = null) {
|
|
|
1553
1554
|
if (typeof o.payload.session_id === "string" && o.payload.session_id) {
|
|
1554
1555
|
thread_id = o.payload.session_id
|
|
1555
1556
|
}
|
|
1557
|
+
const subagent = o.payload.source?.subagent
|
|
1558
|
+
// 実 rollout で観測済みの thread_spawn キー自体を marker とする。値の shape は問わず、
|
|
1559
|
+
// 後続 session_meta に source が無くても一度立った子判定は戻さない。
|
|
1560
|
+
is_subagent ||=
|
|
1561
|
+
subagent != null &&
|
|
1562
|
+
typeof subagent === "object" &&
|
|
1563
|
+
Object.prototype.hasOwnProperty.call(subagent, "thread_spawn")
|
|
1556
1564
|
} else if (
|
|
1557
1565
|
!preview &&
|
|
1558
1566
|
o.type === "event_msg" &&
|
|
@@ -1594,11 +1602,15 @@ async function parseCodexRolloutHead(fp, expectedCwd = "", testHooks = null) {
|
|
|
1594
1602
|
// session_meta は先頭行にある。別 workspace の候補に対して巨大な初期コンテキストを
|
|
1595
1603
|
// 読み進めないことで、初回の派生インデックス構築コストを抑える。
|
|
1596
1604
|
if (cwd && expectedCwd && cwd !== expectedCwd) {
|
|
1597
|
-
result = thread_id
|
|
1605
|
+
result = thread_id
|
|
1606
|
+
? { thread_id, cwd, preview, preview_complete: false, is_subagent }
|
|
1607
|
+
: null
|
|
1598
1608
|
break scan
|
|
1599
1609
|
}
|
|
1600
1610
|
if (cwd && preview) {
|
|
1601
|
-
result = thread_id
|
|
1611
|
+
result = thread_id
|
|
1612
|
+
? { thread_id, cwd, preview, preview_complete: true, is_subagent }
|
|
1613
|
+
: null
|
|
1602
1614
|
break scan
|
|
1603
1615
|
}
|
|
1604
1616
|
}
|
|
@@ -1612,7 +1624,9 @@ async function parseCodexRolloutHead(fp, expectedCwd = "", testHooks = null) {
|
|
|
1612
1624
|
}
|
|
1613
1625
|
}
|
|
1614
1626
|
if (result === undefined) {
|
|
1615
|
-
result = thread_id
|
|
1627
|
+
result = thread_id
|
|
1628
|
+
? { thread_id, cwd, preview, preview_complete: true, is_subagent }
|
|
1629
|
+
: null
|
|
1616
1630
|
}
|
|
1617
1631
|
} catch {
|
|
1618
1632
|
result = CODEX_ROLLOUT_PARSE_IO_ERROR
|
|
@@ -1770,6 +1784,10 @@ export async function listCodexThreadsFromDisk({
|
|
|
1770
1784
|
let meta = entry?.meta
|
|
1771
1785
|
if (!meta) continue
|
|
1772
1786
|
if (meta.cwd !== cwd) continue
|
|
1787
|
+
// Native sub-agent rollout は payload.session_id に親 thread ID を持つため、そのまま返すと
|
|
1788
|
+
// 親セッションが重複表示される。既知の thread_spawn 形式だけを除外し、未知 source は残す。
|
|
1789
|
+
// 親が lookback 窓外でも子を proxy として残さず、親一覧は既存の lookback 契約に従う。
|
|
1790
|
+
if (meta.is_subagent) continue
|
|
1773
1791
|
if (!meta.preview_complete) {
|
|
1774
1792
|
const observed = index.get(fp)
|
|
1775
1793
|
completedPreviews++
|