@cocorograph/hub-agent 0.8.7 → 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 +75 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.7",
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,13 @@ 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
1854
+ let usedFallback = false
1814
1855
  // hub-agent 再起動で in-memory の TUI bind が消えると、state loop の snapshot は
1815
1856
  // `unbound:<session>` へ落ちる。一方、Claude bridge の warm/live session は cwd
1816
1857
  // から同じ会話 ID を復元できる。ここでフォールバックしないと、再起動後に全行の
@@ -1819,15 +1860,38 @@ export async function readAllSessionLastTexts(
1819
1860
  // snapshot が安全な実 ID を持つ場合は必ずそちらを優先し、fallback は placeholder /
1820
1861
  // 危険値の場合だけ使う。fallback も同じ containment 検査を通してから path 化する。
1821
1862
  if (!isSafeSessionIdForPath(sessionId) && resolveFallbackSessionId) {
1822
- sessionId = resolveFallbackSessionId(s)
1863
+ sessionId = await resolveFallbackSessionId(s)
1864
+ usedFallback = true
1823
1865
  }
1824
1866
  // パス要素として安全な形だけを通す (外部由来の値なので containment を局所保証)。
1825
1867
  if (!isSafeSessionIdForPath(sessionId)) return
1826
1868
  if (!s.cwd) return
1827
- const fp = resolvePath({ cwd: s.cwd, session_id: sessionId })
1869
+ let fp = resolvePath({ cwd: s.cwd, session_id: sessionId })
1828
1870
  if (!fp) return
1871
+ let st
1872
+ try {
1873
+ st = await statFn(fp)
1874
+ } catch {
1875
+ // 再起動直後は snapshot に構文上は安全でも既に失効した session ID が残ることがある。
1876
+ // 0.8.7 は unbound/fresh だけを live session へ切り替えていたため、この場合は stat
1877
+ // 失敗を外側 catch が握りつぶし、サイドバーの依頼/応答だけが空のままになった。
1878
+ // primary path が読めない場合に限って、現在の cwd-scoped live session ID を再解決する。
1879
+ // snapshot path が実在する通常系では従来どおり snapshot を優先する。
1880
+ if (usedFallback || !resolveFallbackSessionId) return
1881
+ const fallbackId = await resolveFallbackSessionId(s)
1882
+ if (!isSafeSessionIdForPath(fallbackId) || fallbackId === sessionId) return
1883
+ const fallbackPath = resolvePath({ cwd: s.cwd, session_id: fallbackId })
1884
+ if (!fallbackPath) return
1885
+ try {
1886
+ st = await statFn(fallbackPath)
1887
+ } catch {
1888
+ return
1889
+ }
1890
+ sessionId = fallbackId
1891
+ fp = fallbackPath
1892
+ usedFallback = true
1893
+ }
1829
1894
  livePaths.add(fp)
1830
- const st = await statFn(fp)
1831
1895
  const version = lastTurnTextsVersion(st)
1832
1896
  const cached = lastTurnTextsCache.get(fp)
1833
1897
  if (cached && cached.version === version) {
@@ -4759,13 +4823,14 @@ async function dispatch(msg, ctx) {
4759
4823
  const [lastEventByName, lastTextsByName] = await Promise.all([
4760
4824
  readAllSessionEvents(),
4761
4825
  readAllSessionLastTexts(sessions, ctx.getSessionStateSnapshot, {
4762
- resolveFallbackSessionId: (session) =>
4826
+ resolveFallbackSessionId: async (session) =>
4763
4827
  resolveStateLoopBoundSessionId(
4764
4828
  ctx.tuiReboundSessions,
4765
4829
  session.name,
4766
4830
  session.cwd,
4767
4831
  ctx.claudeBridge,
4768
- ),
4832
+ ) ||
4833
+ (await latestClaudeSessionIdForSidebar(session.cwd, ctx.logger)),
4769
4834
  }),
4770
4835
  ])
4771
4836
  const enriched = enrichTmuxSessionsForListResponse(