@cocorograph/hub-agent 0.7.81 → 0.7.82
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/main.mjs +252 -1
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -1515,6 +1515,230 @@ export function _resetContextWindowLearning() {
|
|
|
1515
1515
|
lastAssistantModelByChatKey.clear()
|
|
1516
1516
|
}
|
|
1517
1517
|
|
|
1518
|
+
/**
|
|
1519
|
+
* session_event の「サイドバー並び順に効くか (sort_affecting)」判定用の per-session 状態。
|
|
1520
|
+
* 「その session-run で実ユーザーターン (prompt_submit) を見たか」を持つ。
|
|
1521
|
+
*
|
|
1522
|
+
* ## なぜ必要か (2026-07-20 実測で確定した現象B の根治)
|
|
1523
|
+
* 死んだ/寝ているセッションを開く=hub-agent が claude を respawn/resume すると、その
|
|
1524
|
+
* 起動・resume の落ち着きで Stop フック (`stop`) や readiness (`ready`)、SessionStart
|
|
1525
|
+
* (`session_start`) が near-now で発火する。frontend は session.event を種別問わず
|
|
1526
|
+
* eventAt の sort key に使うため、ユーザーが 1 ターンも回していないのに「開いた/起こした
|
|
1527
|
+
* だけ」でサイドバー最上位へ浮上していた。
|
|
1528
|
+
*
|
|
1529
|
+
* 対処療法 (フロントで focus 直後の bump を数秒抑制) ではなく、発火源で「ソート信号」と
|
|
1530
|
+
* 「状態信号」を分離する: **並び順は実ユーザーターン (prompt_submit と、それに続く
|
|
1531
|
+
* stop/ready) だけで動かす**。ライフサイクル (session_start/session_end) と、先行する
|
|
1532
|
+
* prompt_submit の無い stop/ready (=起動/resume の落ち着き) は sort_affecting=false とし、
|
|
1533
|
+
* frontend は status ドット用途にのみ使い eventAt は動かさない。
|
|
1534
|
+
*/
|
|
1535
|
+
const sawUserTurnByName = new Map()
|
|
1536
|
+
|
|
1537
|
+
// session 単位で「最後に sort_affecting=true だった event の at」を保持する。
|
|
1538
|
+
// event ファイルは 1 session 1 ファイルの上書きで、最新が sort_affecting=false
|
|
1539
|
+
// (ready 等) だと直前の実ターン時刻が失われる。これを別途保持し poll (ハードリロード
|
|
1540
|
+
// 復元) で配信することで、リロード後に created 順へ退行する劣化を防ぐ (codex-review major#1)。
|
|
1541
|
+
const lastSortAffectingAtByName = new Map()
|
|
1542
|
+
|
|
1543
|
+
// session 単位で「最後に処理した event の at」。fs.watch の pushFile は session 単位で
|
|
1544
|
+
// 直列化されず、古い prompt_submit の readFile が新しい session_start の後に完了して
|
|
1545
|
+
// sawUserTurn=true を復活させうる (out-of-order)。at 単調ガードで stale な状態更新を弾く
|
|
1546
|
+
// (codex-review major#2)。
|
|
1547
|
+
const lastEventAtByName = new Map()
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* session-event の種別 (と「実ユーザーターンを見たか」) から sort_affecting を導出する純関数。
|
|
1551
|
+
* - prompt_submit: 常に true (ユーザーがターンを開始した)
|
|
1552
|
+
* - session_start / session_end: 常に false (ライフサイクル、ターンではない)
|
|
1553
|
+
* - stop / ready / idle_hint: 直前に prompt_submit があった (sawUserTurn) ときだけ true
|
|
1554
|
+
* (=起動/resume の落ち着きで出た stop/ready は false)
|
|
1555
|
+
* - 未知種別: 後方互換で true (並び順に効かせる)
|
|
1556
|
+
* @param {string} event
|
|
1557
|
+
* @param {boolean} sawUserTurn
|
|
1558
|
+
* @returns {boolean}
|
|
1559
|
+
*/
|
|
1560
|
+
export function computeSortAffecting(event, sawUserTurn) {
|
|
1561
|
+
if (event === "prompt_submit") return true
|
|
1562
|
+
if (event === "session_start" || event === "session_end") return false
|
|
1563
|
+
if (event === "stop" || event === "ready" || event === "idle_hint") {
|
|
1564
|
+
return sawUserTurn === true
|
|
1565
|
+
}
|
|
1566
|
+
return true
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* session-event 種別に応じて sawUserTurnByName を更新する。
|
|
1571
|
+
* - prompt_submit: 実ユーザーターン開始 → true
|
|
1572
|
+
* - session_start / session_end: session-run の境界 → false にリセット
|
|
1573
|
+
* (次に実 prompt_submit を見るまで stop/ready は sort_affecting=false)
|
|
1574
|
+
* その他の種別ではフラグを変えない (stop/ready は直前の prompt_submit 状態を引き継ぐ)。
|
|
1575
|
+
* @param {string} name tmux session 名
|
|
1576
|
+
* @param {string} event
|
|
1577
|
+
*/
|
|
1578
|
+
export function updateSawUserTurn(name, event) {
|
|
1579
|
+
if (!name) return
|
|
1580
|
+
if (event === "prompt_submit") sawUserTurnByName.set(name, true)
|
|
1581
|
+
else if (event === "session_start" || event === "session_end") {
|
|
1582
|
+
sawUserTurnByName.set(name, false)
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* session-event 1 件を sort_affecting 状態機械に取り込む。at 単調ガードで out-of-order な
|
|
1588
|
+
* stale read を弾き (major#2)、sort_affecting を導出し、true なら sort 時刻を更新する (major#1)。
|
|
1589
|
+
* @param {string} name tmux session 名
|
|
1590
|
+
* @param {string} event 種別
|
|
1591
|
+
* @param {number} at epoch ms
|
|
1592
|
+
* @returns {{ sortAffecting: boolean, sortAffectingAt: number|undefined, stale: boolean }}
|
|
1593
|
+
*/
|
|
1594
|
+
export function ingestSessionEventForSort(name, event, at) {
|
|
1595
|
+
const prevAt = lastEventAtByName.get(name) ?? 0
|
|
1596
|
+
// stale (この session で処理済みの at より古い) read は状態を汚さない。arm や status は
|
|
1597
|
+
// 呼び出し側で従来通り処理してよいが、sort へは一切効かせない。
|
|
1598
|
+
if (typeof at === "number" && at < prevAt) {
|
|
1599
|
+
return {
|
|
1600
|
+
sortAffecting: false,
|
|
1601
|
+
sortAffectingAt: lastSortAffectingAtByName.get(name),
|
|
1602
|
+
stale: true,
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
if (typeof at === "number") lastEventAtByName.set(name, at)
|
|
1606
|
+
updateSawUserTurn(name, event)
|
|
1607
|
+
const sortAffecting = computeSortAffecting(event, sawUserTurnByName.get(name) === true)
|
|
1608
|
+
// sort_affecting=true で前進したときだけ sort 時刻を更新し、sidecar に永続化する (major#1)。
|
|
1609
|
+
// event ファイルは揮発 (最新イベントで上書き) のため、再起動後に「最後の実ターン時刻」を
|
|
1610
|
+
// 復元できるよう別ファイルに残す。冪等な再取込 (at 同値) では書かない。
|
|
1611
|
+
if (sortAffecting && typeof at === "number" && at > (lastSortAffectingAtByName.get(name) ?? 0)) {
|
|
1612
|
+
lastSortAffectingAtByName.set(name, at)
|
|
1613
|
+
writeSortAtSidecar(name, at) // fire-and-forget (エラーは握りつぶす)
|
|
1614
|
+
}
|
|
1615
|
+
return {
|
|
1616
|
+
sortAffecting,
|
|
1617
|
+
sortAffectingAt: lastSortAffectingAtByName.get(name),
|
|
1618
|
+
stale: false,
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/** poll (readAllSessionEvents) 用: この session の「最後に sort に効いた at」を返す (無ければ undefined)。 */
|
|
1623
|
+
export function getSortAffectingAt(name) {
|
|
1624
|
+
return lastSortAffectingAtByName.get(name)
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// sort 時刻 sidecar の接尾辞。`.json` にしない (readAllSessionEvents / pushFile が
|
|
1628
|
+
// `.endsWith(".json")` で event ファイルと誤認しないように)。
|
|
1629
|
+
const SORT_AT_SUFFIX = ".sortat"
|
|
1630
|
+
|
|
1631
|
+
export function sortAtSidecarPath(name) {
|
|
1632
|
+
return path.join(SESSION_EVENTS_DIR, `${name}${SORT_AT_SUFFIX}`)
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// sidecar の write / delete を **session 単位で直列化** する op キュー。write と delete が
|
|
1636
|
+
// 双方 fire-and-forget だと「実ターン直後に kill → delete が先に走り (ENOENT)、その後 write の
|
|
1637
|
+
// rename が dead sidecar を復活」する競合が起きる (codex-review minor)。enqueue 順 = 実行順を
|
|
1638
|
+
// 保証すれば、ingest(write) → forget(delete) の順で delete が後になり sidecar は確実に消える。
|
|
1639
|
+
const sidecarOpTailByName = new Map()
|
|
1640
|
+
|
|
1641
|
+
function enqueueSidecarOp(name, op) {
|
|
1642
|
+
const prev = sidecarOpTailByName.get(name) ?? Promise.resolve()
|
|
1643
|
+
const next = prev.then(op, op).catch(() => {})
|
|
1644
|
+
sidecarOpTailByName.set(name, next)
|
|
1645
|
+
// 末尾が settle したら Map エントリを掃除 (自分が最新末尾のときだけ; リーク防止)。
|
|
1646
|
+
next.finally(() => {
|
|
1647
|
+
if (sidecarOpTailByName.get(name) === next) sidecarOpTailByName.delete(name)
|
|
1648
|
+
})
|
|
1649
|
+
return next
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* 「最後の実ターン時刻」を sidecar に atomic 書き出す (major#1 の再起動永続化)。
|
|
1654
|
+
* fire-and-forget だが session 単位で直列化する。event ファイルと別ファイルなので
|
|
1655
|
+
* Python hook の上書きとは競合しない。
|
|
1656
|
+
*/
|
|
1657
|
+
export function writeSortAtSidecar(name, at) {
|
|
1658
|
+
if (!name || /[/\\]/.test(name) || typeof at !== "number") return Promise.resolve()
|
|
1659
|
+
return enqueueSidecarOp(name, async () => {
|
|
1660
|
+
const fp = sortAtSidecarPath(name)
|
|
1661
|
+
const tmp = `${fp}.tmp.${randomUUID()}`
|
|
1662
|
+
try {
|
|
1663
|
+
await mkdir(SESSION_EVENTS_DIR, { recursive: true })
|
|
1664
|
+
await writeFile(tmp, JSON.stringify({ sort_at: at }))
|
|
1665
|
+
await rename(tmp, fp)
|
|
1666
|
+
} catch {
|
|
1667
|
+
try {
|
|
1668
|
+
await unlink(tmp)
|
|
1669
|
+
} catch {
|
|
1670
|
+
/* ignore */
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
})
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/**
|
|
1677
|
+
* forget 時に sidecar を消す (死名が再起動シードで復活しないように; codex-review minor)。
|
|
1678
|
+
* write と同じ session 単位キューに載せ、先行 write の後に必ず delete が走るようにする。
|
|
1679
|
+
*/
|
|
1680
|
+
export function deleteSortAtSidecar(name) {
|
|
1681
|
+
if (!name || /[/\\]/.test(name)) return Promise.resolve()
|
|
1682
|
+
return enqueueSidecarOp(name, async () => {
|
|
1683
|
+
try {
|
|
1684
|
+
await unlink(sortAtSidecarPath(name))
|
|
1685
|
+
} catch {
|
|
1686
|
+
/* 無ければ無視 */
|
|
1687
|
+
}
|
|
1688
|
+
})
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
/**
|
|
1692
|
+
* 起動時シード: sidecar から「最後の実ターン時刻」を復元する。揮発 event ファイルではなく
|
|
1693
|
+
* sidecar を正本にすることで、最新イベントが session_start/resume 由来 (sort_affecting=false)
|
|
1694
|
+
* でもその near-now at で現象B を再発させない (codex-review major#1 の再репро対策)。
|
|
1695
|
+
*/
|
|
1696
|
+
export async function seedSortAtFromSidecars() {
|
|
1697
|
+
let files
|
|
1698
|
+
try {
|
|
1699
|
+
files = await readdir(SESSION_EVENTS_DIR)
|
|
1700
|
+
} catch {
|
|
1701
|
+
return
|
|
1702
|
+
}
|
|
1703
|
+
for (const f of files) {
|
|
1704
|
+
if (!f.endsWith(SORT_AT_SUFFIX)) continue
|
|
1705
|
+
const name = f.slice(0, -SORT_AT_SUFFIX.length)
|
|
1706
|
+
try {
|
|
1707
|
+
const data = JSON.parse(await readFile(path.join(SESSION_EVENTS_DIR, f), "utf-8"))
|
|
1708
|
+
if (data && typeof data.sort_at === "number") seedSortAffectingAt(name, data.sort_at)
|
|
1709
|
+
} catch {
|
|
1710
|
+
/* 壊れた sidecar は無視 */
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/**
|
|
1716
|
+
* sort 時刻の初期値を設定する (sidecar シード専用)。既存値は上書きしない。
|
|
1717
|
+
* @param {string} name
|
|
1718
|
+
* @param {number} at
|
|
1719
|
+
*/
|
|
1720
|
+
export function seedSortAffectingAt(name, at) {
|
|
1721
|
+
if (!name || typeof at !== "number") return
|
|
1722
|
+
if (!lastSortAffectingAtByName.has(name)) lastSortAffectingAtByName.set(name, at)
|
|
1723
|
+
if ((lastEventAtByName.get(name) ?? 0) < at) lastEventAtByName.set(name, at)
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/** session 終了 (session.gone / kill / GC) 時に sort_affecting 状態を破棄する (リーク防止)。 */
|
|
1727
|
+
export function forgetSortAffectingState(name) {
|
|
1728
|
+
if (!name) return
|
|
1729
|
+
sawUserTurnByName.delete(name)
|
|
1730
|
+
lastSortAffectingAtByName.delete(name)
|
|
1731
|
+
lastEventAtByName.delete(name)
|
|
1732
|
+
deleteSortAtSidecar(name) // 永続 sidecar も消す (再起動シードでの死名復活防止)
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/** テスト用: sort_affecting 状態を全リセットする。 */
|
|
1736
|
+
export function _resetSawUserTurn() {
|
|
1737
|
+
sawUserTurnByName.clear()
|
|
1738
|
+
lastSortAffectingAtByName.clear()
|
|
1739
|
+
lastEventAtByName.clear()
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1518
1742
|
/**
|
|
1519
1743
|
* sort 用の session-event ファイルをチャットのターン境界で書き出す
|
|
1520
1744
|
* (`/tmp/cockpit_session_events/<tmux名>.json`)。bundle hook が TUI で書くものと
|
|
@@ -1565,6 +1789,15 @@ async function readAllSessionEvents() {
|
|
|
1565
1789
|
out.set(sessionName, {
|
|
1566
1790
|
event: data.event,
|
|
1567
1791
|
at: typeof data.at === "number" ? data.at : Date.now(),
|
|
1792
|
+
// ポーリング経路 (ハードリロード時の eventAt 復元) でも push 経路と同じ
|
|
1793
|
+
// sort_affecting を frontend へ伝える。map (watcher が最新化) を参照する。
|
|
1794
|
+
sort_affecting: computeSortAffecting(
|
|
1795
|
+
data.event,
|
|
1796
|
+
sawUserTurnByName.get(sessionName) === true,
|
|
1797
|
+
),
|
|
1798
|
+
// major#1: 最新 event が sort_affecting=false でも、直前の実ターン時刻を
|
|
1799
|
+
// frontend へ渡して created 順への退行を防ぐ。frontend はこの値で eventAt を復元する。
|
|
1800
|
+
sort_affecting_at: getSortAffectingAt(sessionName),
|
|
1568
1801
|
})
|
|
1569
1802
|
} catch {
|
|
1570
1803
|
// 一時欠如 / parse 失敗は無視
|
|
@@ -1641,6 +1874,11 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
|
|
|
1641
1874
|
readinessTracker.arm(sessionName)
|
|
1642
1875
|
}
|
|
1643
1876
|
}
|
|
1877
|
+
// sort_affecting 判定を状態機械へ委譲する: at 単調ガードで out-of-order な stale read を
|
|
1878
|
+
// 弾き (major#2)、prompt_submit/session_start で状態遷移し、sort_affecting=true なら
|
|
1879
|
+
// sort 時刻を更新する (major#1)。arm や status push は従来通り (stale でも status は最新化)。
|
|
1880
|
+
const at = typeof data.at === "number" ? data.at : Date.now()
|
|
1881
|
+
const { sortAffecting } = ingestSessionEventForSort(sessionName, data.event, at)
|
|
1644
1882
|
// 計装 (2026-06-19, 既定 OFF=2026-06-20): ターン境界イベントと arm を記録する。常時 info は
|
|
1645
1883
|
// ログ肥大の主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す(障害解析時に有効化)。
|
|
1646
1884
|
if (STATE_TRACE) {
|
|
@@ -1649,6 +1887,7 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
|
|
|
1649
1887
|
session_name: sessionName,
|
|
1650
1888
|
event: data.event,
|
|
1651
1889
|
armed: readinessTracker ? readinessTracker.isArmed(sessionName) : undefined,
|
|
1890
|
+
sort_affecting: sortAffecting,
|
|
1652
1891
|
},
|
|
1653
1892
|
"session.event push",
|
|
1654
1893
|
)
|
|
@@ -1657,13 +1896,20 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
|
|
|
1657
1896
|
type: "session.event",
|
|
1658
1897
|
session_name: sessionName,
|
|
1659
1898
|
event: data.event,
|
|
1660
|
-
at
|
|
1899
|
+
at,
|
|
1900
|
+
// false=ライフサイクル/resume 落ち着き由来 → frontend は status のみ更新し並び順は動かさない。
|
|
1901
|
+
sort_affecting: sortAffecting,
|
|
1661
1902
|
})
|
|
1662
1903
|
} catch {
|
|
1663
1904
|
// 一時欠如 / parse 失敗等は無視 (次の変更で読み直す)
|
|
1664
1905
|
}
|
|
1665
1906
|
}
|
|
1666
1907
|
|
|
1908
|
+
// major#1 再起動ケア: pushFile より前に、sidecar から「最後の実ターン時刻」をシードする。
|
|
1909
|
+
// 揮発 event ファイル (最新イベントで上書き) からはシードしない。最新が session_start/resume
|
|
1910
|
+
// 由来 (sort_affecting=false) の near-now at をシードすると現象B が再発するため (codex-review)。
|
|
1911
|
+
await seedSortAtFromSidecars()
|
|
1912
|
+
|
|
1667
1913
|
// 起動時に既存ファイルを 1 回読んで初期 push (再起動後に取りこぼし防止)
|
|
1668
1914
|
try {
|
|
1669
1915
|
const files = await readdir(SESSION_EVENTS_DIR)
|
|
@@ -2343,6 +2589,7 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2343
2589
|
invalidateSessionCache(name) // capture/cwd/statusGate/spinnerFreeze/stability (state.mjs)
|
|
2344
2590
|
stallTracker.forget(name) // StallTracker.byName
|
|
2345
2591
|
readinessTracker?.forget?.(name) // ReadinessTracker.byName
|
|
2592
|
+
forgetSortAffectingState(name) // sawUserTurn / sort_affecting_at / lastEventAt (リーク防止)
|
|
2346
2593
|
lastByName.delete(name)
|
|
2347
2594
|
outputFlowByName.delete(name)
|
|
2348
2595
|
lastTurnAtByName.delete(name)
|
|
@@ -2897,6 +3144,7 @@ export async function handleKillSession(msg, ctx) {
|
|
|
2897
3144
|
} catch {
|
|
2898
3145
|
/* ignore */
|
|
2899
3146
|
}
|
|
3147
|
+
forgetSortAffectingState(name) // sort_affecting 状態のリーク防止 (kill 経路)
|
|
2900
3148
|
}
|
|
2901
3149
|
}
|
|
2902
3150
|
|
|
@@ -3901,6 +4149,9 @@ async function dispatch(msg, ctx) {
|
|
|
3901
4149
|
event: lastEvent.event,
|
|
3902
4150
|
at: lastEvent.at,
|
|
3903
4151
|
replay: true,
|
|
4152
|
+
// この再配信は bind(=フォーカス) 契機の status 復旧専用。並び順は絶対に
|
|
4153
|
+
// 動かさない (フォーカスで浮上させない現象B 根治の一部)。
|
|
4154
|
+
sort_affecting: false,
|
|
3904
4155
|
})
|
|
3905
4156
|
}
|
|
3906
4157
|
}
|