@cocorograph/hub-agent 0.8.8 → 0.8.9

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 +50 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.8",
3
+ "version": "0.8.9",
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
@@ -1727,10 +1727,43 @@ const tmuxSessionsSnapshotCache = new AsyncTtlCache({ ttlMs: 5_000 })
1727
1727
  /** cold cache 時に全セッションを一斉に読まないための同時実行上限。 */
1728
1728
  const LAST_TEXTS_CONCURRENCY = 4
1729
1729
 
1730
+ /**
1731
+ * live bridge に存在しない workspace 行向けの低頻度 fallback。
1732
+ *
1733
+ * Cockpit は選択中でない workspace の Claude stream を常に attach しているわけではない。
1734
+ * hub-agent 再起動後、その行には state snapshot も live session ID も無いため、cwd 配下の
1735
+ * 会話履歴を「最後の実ターン時刻」順で 1 件だけ解決する。`listSessions` は mtime ではなく
1736
+ * JSONL 内の user/assistant timestamp を正本にするので、古い会話を閲覧しただけの mtime
1737
+ * 上昇では選択が入れ替わらない。
1738
+ *
1739
+ * 全 sidebar poll で全 cwd を再走査しないよう、成功/空のどちらも 60 秒だけ共有する。
1740
+ * 選択中の行は live bridge ID が先に選ばれるため、この cache の遅延は影響しない。
1741
+ */
1742
+ const sidebarLatestSessionIdCache = new Map()
1743
+ const SIDEBAR_LATEST_SESSION_ID_TTL_MS = 60_000
1744
+
1745
+ async function latestClaudeSessionIdForSidebar(cwd, logger) {
1746
+ if (!cwd) return null
1747
+ const now = Date.now()
1748
+ const cached = sidebarLatestSessionIdCache.get(cwd)
1749
+ if (cached && now - cached.at < SIDEBAR_LATEST_SESSION_ID_TTL_MS) {
1750
+ return cached.promise
1751
+ }
1752
+ const entry = {
1753
+ at: now,
1754
+ promise: listSessions({ cwd, limit: 1, logger })
1755
+ .then((result) => result.sessions?.[0]?.session_id ?? null)
1756
+ .catch(() => null),
1757
+ }
1758
+ sidebarLatestSessionIdCache.set(cwd, entry)
1759
+ return entry.promise
1760
+ }
1761
+
1730
1762
  /** テスト用: module-global なキャッシュのテスト間汚染を防ぐ。 */
1731
1763
  export function clearLastTurnTextsCacheForTest() {
1732
1764
  lastTurnTextsCache.clear()
1733
1765
  lastTurnTextsInFlight.clear()
1766
+ sidebarLatestSessionIdCache.clear()
1734
1767
  }
1735
1768
 
1736
1769
  /** GC: tmux から消えたセッションの jsonl は追跡しない (Map の線形増加を防ぐ)。 */
@@ -1779,7 +1812,8 @@ function isSafeSessionIdForPath(id) {
1779
1812
  *
1780
1813
  * Cockpit サイドバーが行ごとに「何を依頼したか」を出すためのデータ源 (2026-07-28)。
1781
1814
  * frontend は選択中 1 セッションしかメッセージを保持しないため、一覧用は agent 側で
1782
- * 供給するしかない。会話 ID は state loop の snapshot (`context_session_id`) を使う。
1815
+ * 供給するしかない。会話 ID は state loop の snapshot (`context_session_id`) を優先し、
1816
+ * snapshot / live bridge のどちらにも無ければ cwd の最新実ターン履歴へフォールバックする。
1783
1817
  *
1784
1818
  * 対象は **claude 行のみ**。codex はスレッド保存形式が異なるため対象外 (将来対応)。
1785
1819
  * `fresh:<request_id>` は jsonl 未生成のプレースホルダなのでスキップする。
@@ -1787,9 +1821,11 @@ function isSafeSessionIdForPath(id) {
1787
1821
  * @param {Array} sessions listTmuxSessions の素の行 (name / cwd を使う)
1788
1822
  * @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
1789
1823
  * @param {object} [options] I/O と session ID 解決の注入点
1790
- * @param {((session: object) => string | null) | undefined} [options.resolveFallbackSessionId]
1824
+ * @param {((session: object) => string | null | Promise<string | null>) | undefined}
1825
+ * [options.resolveFallbackSessionId]
1791
1826
  * snapshot の context_session_id が再起動後の `unbound:*` のとき、TUI bind / live
1792
- * Claude bridge から同じ cwd の会話 ID を復元する。戻り値にも containment 検査を行う。
1827
+ * Claude bridge または履歴一覧から同じ cwd の会話 ID を復元する。
1828
+ * 戻り値にも containment 検査を行う。
1793
1829
  * @returns {Promise<Map<string, {user: string, assistant: string}>>}
1794
1830
  */
1795
1831
  export async function readAllSessionLastTexts(
@@ -1809,8 +1845,12 @@ export async function readAllSessionLastTexts(
1809
1845
  const readOne = async (s) => {
1810
1846
  try {
1811
1847
  const snap = getSnap(s.name)
1812
- if (!snap || snap.cli_kind !== "claude") return
1813
- let sessionId = snap.context_session_id
1848
+ // CLI 種別の正本はセッション名 (`__codex` suffix) であり、state snapshot ではない。
1849
+ // 再起動直後は tmux の一覧行が先に復元され、state loop の初回 tick (<5s) より前は
1850
+ // snapshot 自体がまだ無い。この cold window で snapshot を必須にすると、live Claude
1851
+ // session ID は既に復元済みでも最後の依頼/応答だけが空のまま返る。
1852
+ if (cliKindForSessionName(s.name) !== "claude") return
1853
+ let sessionId = snap?.context_session_id
1814
1854
  let usedFallback = false
1815
1855
  // hub-agent 再起動で in-memory の TUI bind が消えると、state loop の snapshot は
1816
1856
  // `unbound:<session>` へ落ちる。一方、Claude bridge の warm/live session は cwd
@@ -1820,7 +1860,7 @@ export async function readAllSessionLastTexts(
1820
1860
  // snapshot が安全な実 ID を持つ場合は必ずそちらを優先し、fallback は placeholder /
1821
1861
  // 危険値の場合だけ使う。fallback も同じ containment 検査を通してから path 化する。
1822
1862
  if (!isSafeSessionIdForPath(sessionId) && resolveFallbackSessionId) {
1823
- sessionId = resolveFallbackSessionId(s)
1863
+ sessionId = await resolveFallbackSessionId(s)
1824
1864
  usedFallback = true
1825
1865
  }
1826
1866
  // パス要素として安全な形だけを通す (外部由来の値なので containment を局所保証)。
@@ -1838,7 +1878,7 @@ export async function readAllSessionLastTexts(
1838
1878
  // primary path が読めない場合に限って、現在の cwd-scoped live session ID を再解決する。
1839
1879
  // snapshot path が実在する通常系では従来どおり snapshot を優先する。
1840
1880
  if (usedFallback || !resolveFallbackSessionId) return
1841
- const fallbackId = resolveFallbackSessionId(s)
1881
+ const fallbackId = await resolveFallbackSessionId(s)
1842
1882
  if (!isSafeSessionIdForPath(fallbackId) || fallbackId === sessionId) return
1843
1883
  const fallbackPath = resolvePath({ cwd: s.cwd, session_id: fallbackId })
1844
1884
  if (!fallbackPath) return
@@ -4783,13 +4823,14 @@ async function dispatch(msg, ctx) {
4783
4823
  const [lastEventByName, lastTextsByName] = await Promise.all([
4784
4824
  readAllSessionEvents(),
4785
4825
  readAllSessionLastTexts(sessions, ctx.getSessionStateSnapshot, {
4786
- resolveFallbackSessionId: (session) =>
4826
+ resolveFallbackSessionId: async (session) =>
4787
4827
  resolveStateLoopBoundSessionId(
4788
4828
  ctx.tuiReboundSessions,
4789
4829
  session.name,
4790
4830
  session.cwd,
4791
4831
  ctx.claudeBridge,
4792
- ),
4832
+ ) ||
4833
+ (await latestClaudeSessionIdForSidebar(session.cwd, ctx.logger)),
4793
4834
  }),
4794
4835
  ])
4795
4836
  const enriched = enrichTmuxSessionsForListResponse(