@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/main.mjs +252 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.81",
3
+ "version": "0.7.82",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
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: typeof data.at === "number" ? data.at : Date.now(),
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
  }