@cocorograph/hub-agent 0.8.8 → 0.8.10

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.8",
3
+ "version": "0.8.10",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -664,7 +664,13 @@ function takeTextsFromLine(line, user, assistant) {
664
664
  return { user, assistant }
665
665
  }
666
666
 
667
- export async function extractLastTurnTexts(filePath) {
667
+ /**
668
+ * @param {string} filePath 対象 jsonl
669
+ * @param {{ isInjected?: (text: string) => Promise<boolean> | boolean }} [opts]
670
+ * 注入と判定した user ターンは、そのターンの assistant も含めて飛ばす。
671
+ */
672
+ export async function extractLastTurnTexts(filePath, opts = {}) {
673
+ const isInjected = typeof opts.isInjected === "function" ? opts.isInjected : null
668
674
  let handle
669
675
  try {
670
676
  handle = await open(filePath, "r")
@@ -716,11 +722,26 @@ export async function extractLastTurnTexts(filePath) {
716
722
  continue
717
723
  }
718
724
  const res = takeTextsFromLine(lineBuf.toString("utf-8"), user, assistant)
725
+ if (isInjected && res.user && !user) {
726
+ let injected = false
727
+ try {
728
+ injected = (await isInjected(res.user)) === true
729
+ } catch {
730
+ // 注入判定の障害で通常の依頼まで消さない。
731
+ }
732
+ if (injected) {
733
+ // 後方走査中の assistant はこの注入 user より後ろ、つまり同じ注入ターンの
734
+ // 応答候補。依頼だけでなく応答も捨てて、更に前の自分のターンを探す。
735
+ user = ""
736
+ assistant = ""
737
+ continue
738
+ }
739
+ }
719
740
  user = res.user
720
741
  assistant = res.assistant
721
- if (user && assistant) break
742
+ if (isInjected ? Boolean(user) : user && assistant) break
722
743
  }
723
- if (user && assistant) break
744
+ if (isInjected ? Boolean(user) : user && assistant) break
724
745
  if (skipped > LAST_TURN_MAX_SKIPPED_LINES) break
725
746
 
726
747
  if (carry && carry.length > LAST_TURN_MAX_LINE) {
@@ -43,6 +43,8 @@
43
43
  import { EventEmitter } from "node:events"
44
44
  import { randomUUID } from "node:crypto"
45
45
 
46
+ import { recordInjectSourcePersistent } from "./inject-source-store.mjs"
47
+
46
48
  import { AsyncTtlCache } from "./async-ttl-cache.mjs"
47
49
  import { CodexAppServerClient } from "./codex-appserver-client.mjs"
48
50
  import {
@@ -1947,6 +1949,9 @@ export class CodexStreamBridge extends EventEmitter {
1947
1949
  reason: delivery?.reason || "echo_timeout",
1948
1950
  }
1949
1951
  }
1952
+ // Claude と同じ永続ストアへ記録し、hub-agent 再起動後に rollout を読む場合も
1953
+ // peer / orchestrator 注入を通常の user 依頼として表示しない。
1954
+ recordInjectSourcePersistent(session.cwd, text, source).catch(() => {})
1950
1955
  // rollout は内部形式・flush 遅延の影響を受けるため primary oracle にしない。echo delivered を
1951
1956
  // 返した後、注入前 offset + threadId + 本文 hash で非同期監査し、結果だけを構造化ログへ残す。
1952
1957
  void this._deliveryAuditVerify(durableAudit)
package/src/main.mjs CHANGED
@@ -17,7 +17,6 @@ import {
17
17
  readFile,
18
18
  readdir,
19
19
  rename,
20
- stat,
21
20
  unlink,
22
21
  writeFile,
23
22
  } from "node:fs/promises"
@@ -56,13 +55,12 @@ import { CodexStreamBridge, classifyCodexInputError } from "./codex-stream-bridg
56
55
  import { UploadManager } from "./claude-upload.mjs"
57
56
  import { requestSelfUninstall } from "./service-install.mjs"
58
57
  import {
59
- extractLastTurnTexts,
60
58
  fetchSessionHistory,
61
- jsonlPath,
62
59
  listSessions,
63
60
  } from "./claude-history.mjs"
64
61
  import { listAgents } from "./agents.mjs"
65
62
  import { listSkills } from "./skills.mjs"
63
+ import { readSidebarMessageSnapshot } from "./sidebar-message-snapshot.mjs"
66
64
  import {
67
65
  capturePane,
68
66
  detectPermissionModeFromText,
@@ -342,6 +340,7 @@ export function isReadOnlyRpc(type) {
342
340
  type === "codex.sessions.request" ||
343
341
  type === "profile.list" ||
344
342
  type === "codex.profile.list" ||
343
+ type === "chat.sidebar_snapshot.request" ||
345
344
  // T06603 (2026-07-25): キュー snapshot の pull。in-memory 配列を読むだけで副作用が無く、
346
345
  // かつ「直列キュー前方の重い処理で再同期が遅延する」と送信待ちチップの固着が長引く
347
346
  // (再同期は固着を直すための経路なので、詰まりやすい経路に置いてはならない)。
@@ -1442,23 +1441,25 @@ const lastSortAffectingAtByName = new Map()
1442
1441
  const lastEventAtByName = new Map()
1443
1442
 
1444
1443
  /**
1445
- * session-event の種別 (と「実ユーザーターンを見たか」) から sort_affecting を導出する純関数。
1446
- * - prompt_submit: 常に true (ユーザーがターンを開始した)
1447
- * - session_start / session_end: 常に false (ライフサイクル、ターンではない)
1448
- * - stop / ready / idle_hint: 直前に prompt_submit があった (sawUserTurn) ときだけ true
1449
- * (=起動/resume の落ち着きで出た stop/ready は false)
1450
- * - 未知種別: 後方互換で true (並び順に効かせる)
1444
+ * session-event の種別から sort_affecting を導出する純関数。
1445
+ *
1446
+ * **並び順が動くのはユーザーがメッセージを送った瞬間 (prompt_submit) だけ**。
1447
+ * ターン終了 (stop) やアイドル系はソートに効かせない。
1448
+ *
1449
+ * ⚠️ 旧仕様は stop/ready/idle_hint を「直前に prompt_submit があれば sort_affecting」に
1450
+ * していたため、**ターンが終わるたびにサイドバーの行が並び替わり、レイアウトシフトが起きた**
1451
+ * (2026-07-29 ユーザー報告)。並び順の意味を「最後に人間が送った順」に一本化するため、
1452
+ * ターン終了はソートへ影響させない (状態表示=ドット側は従来どおり別軸で更新される)。
1453
+ *
1454
+ * - prompt_submit: true (ユーザーがターンを開始した = 並び順の唯一の発火源)
1455
+ * - それ以外 (stop / ready / idle_hint / session_start / session_end / 未知種別): false
1456
+ *
1451
1457
  * @param {string} event
1452
- * @param {boolean} sawUserTurn
1458
+ * @param {boolean} _sawUserTurn 後方互換で受けるが未使用 (ターン終了を効かせないため)
1453
1459
  * @returns {boolean}
1454
1460
  */
1455
- export function computeSortAffecting(event, sawUserTurn) {
1456
- if (event === "prompt_submit") return true
1457
- if (event === "session_start" || event === "session_end") return false
1458
- if (event === "stop" || event === "ready" || event === "idle_hint") {
1459
- return sawUserTurn === true
1460
- }
1461
- return true
1461
+ export function computeSortAffecting(event, _sawUserTurn) {
1462
+ return event === "prompt_submit"
1462
1463
  }
1463
1464
 
1464
1465
  /**
@@ -1701,219 +1702,11 @@ async function readAllSessionEvents() {
1701
1702
  return out
1702
1703
  }
1703
1704
 
1704
- /**
1705
- * jsonl 末尾テキストのキャッシュ。key = jsonl path、value = `{version, texts}`。
1706
- * version の構成は `lastTurnTextsVersion()` が正本 (ここに具体値を書くとドリフトする)。
1707
- *
1708
- * tmux.list_sessions は数秒間隔で回るため、毎回全セッションの jsonl 末尾を読むと I/O が
1709
- * 積み上がる。会話が進めば必ず version が変わるので、version 一致なら前回値を使い回す。
1710
- */
1711
- const lastTurnTextsCache = new Map()
1712
-
1713
- /**
1714
- * path 単位の in-flight 共有。複数ブラウザが同時に poll しても同じ jsonl を重複抽出しない。
1715
- * key = jsonl path、value = `{version, promise}`。
1716
- *
1717
- * ⚠️ version を必ず持たせる。「どの版の抽出か」が分からないと、合流した caller が自分の
1718
- * 観測版で結果を保存してしまい、古いテキストが恒久的に cache hit する (major#1 参照)。
1719
- */
1720
- const lastTurnTextsInFlight = new Map()
1721
-
1722
1705
  // 複数 Cockpit タブは各自 8 秒 poll を持つため、位相がずれると agent は同じ
1723
1706
  // tmux/worktree/jsonl スナップショットを数秒おきに再構築してしまう。5 秒だけ成功値を
1724
1707
  // 共有し、同時要求は single-flight に畳む。create/kill/worktree 変更時は明示 clear する。
1725
1708
  const tmuxSessionsSnapshotCache = new AsyncTtlCache({ ttlMs: 5_000 })
1726
1709
 
1727
- /** cold cache 時に全セッションを一斉に読まないための同時実行上限。 */
1728
- const LAST_TEXTS_CONCURRENCY = 4
1729
-
1730
- /** テスト用: module-global なキャッシュのテスト間汚染を防ぐ。 */
1731
- export function clearLastTurnTextsCacheForTest() {
1732
- lastTurnTextsCache.clear()
1733
- lastTurnTextsInFlight.clear()
1734
- }
1735
-
1736
- /** GC: tmux から消えたセッションの jsonl は追跡しない (Map の線形増加を防ぐ)。 */
1737
- function pruneLastTurnTextsCache(livePaths) {
1738
- if (lastTurnTextsCache.size <= livePaths.size) return
1739
- for (const k of lastTurnTextsCache.keys()) {
1740
- if (!livePaths.has(k)) lastTurnTextsCache.delete(k)
1741
- }
1742
- }
1743
-
1744
- /**
1745
- * jsonl の版 (キャッシュ無効化キー)。
1746
- *
1747
- * mtime だけだと、分解能の粗い FS や「同一 ms 内の追記」で古いテキストを再利用しうる
1748
- * (同リポの hydrate / last-turn キャッシュも mtime+size を使っている)。size と ctime も
1749
- * 混ぜて、append / atomic replace の双方を検出できるようにする。
1750
- */
1751
- function lastTurnTextsVersion(st) {
1752
- return `${st.mtimeMs}:${st.size}:${st.ctimeMs}`
1753
- }
1754
-
1755
- /**
1756
- * `context_session_id` を jsonl のパス要素として使ってよいか検証する。
1757
- *
1758
- * この値は SDK resume 経路では browser → backend → agent と外部を通ってくるため、
1759
- * `../` を含むと cwd の project dir の外を指しうる。新設の自動読み取り面なので、
1760
- * パス要素として安全な形だけを通す (実在の Claude session id は UUID だが、将来の
1761
- * 形式変更で壊れないよう「危険な形の排除」に留める)。
1762
- */
1763
- function isSafeSessionIdForPath(id) {
1764
- if (!id || typeof id !== "string") return false
1765
- if (id.length > 200) return false
1766
- if (id === "." || id === "..") return false
1767
- // パス区切り / NUL / 制御文字 / ドット始まりを排除。
1768
- if (/[/\\\0]/.test(id)) return false
1769
- // eslint-disable-next-line no-control-regex
1770
- if (/[\x00-\x1f]/.test(id)) return false
1771
- if (id.startsWith(".")) return false
1772
- // 未 bind のプレースホルダ (jsonl がまだ存在しない)。
1773
- if (id.startsWith("fresh:") || id.startsWith("unbound:")) return false
1774
- return true
1775
- }
1776
-
1777
- /**
1778
- * 各 tmux session の「最後の依頼 / 最後の応答」テキストを jsonl 末尾から読む。
1779
- *
1780
- * Cockpit サイドバーが行ごとに「何を依頼したか」を出すためのデータ源 (2026-07-28)。
1781
- * frontend は選択中 1 セッションしかメッセージを保持しないため、一覧用は agent 側で
1782
- * 供給するしかない。会話 ID は state loop の snapshot (`context_session_id`) を使う。
1783
- *
1784
- * 対象は **claude 行のみ**。codex はスレッド保存形式が異なるため対象外 (将来対応)。
1785
- * `fresh:<request_id>` は jsonl 未生成のプレースホルダなのでスキップする。
1786
- *
1787
- * @param {Array} sessions listTmuxSessions の素の行 (name / cwd を使う)
1788
- * @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
1789
- * @param {object} [options] I/O と session ID 解決の注入点
1790
- * @param {((session: object) => string | null) | undefined} [options.resolveFallbackSessionId]
1791
- * snapshot の context_session_id が再起動後の `unbound:*` のとき、TUI bind / live
1792
- * Claude bridge から同じ cwd の会話 ID を復元する。戻り値にも containment 検査を行う。
1793
- * @returns {Promise<Map<string, {user: string, assistant: string}>>}
1794
- */
1795
- export async function readAllSessionLastTexts(
1796
- sessions,
1797
- getSnap,
1798
- {
1799
- extract = extractLastTurnTexts,
1800
- statFn = stat,
1801
- resolvePath = jsonlPath,
1802
- resolveFallbackSessionId,
1803
- } = {},
1804
- ) {
1805
- const out = new Map()
1806
- if (!getSnap) return out
1807
- const livePaths = new Set()
1808
-
1809
- const readOne = async (s) => {
1810
- try {
1811
- const snap = getSnap(s.name)
1812
- if (!snap || snap.cli_kind !== "claude") return
1813
- let sessionId = snap.context_session_id
1814
- let usedFallback = false
1815
- // hub-agent 再起動で in-memory の TUI bind が消えると、state loop の snapshot は
1816
- // `unbound:<session>` へ落ちる。一方、Claude bridge の warm/live session は cwd
1817
- // から同じ会話 ID を復元できる。ここでフォールバックしないと、再起動後に全行の
1818
- // 「最後の依頼 / 応答」だけが消え、次に chat signal が fresh になるまで戻らない。
1819
- //
1820
- // snapshot が安全な実 ID を持つ場合は必ずそちらを優先し、fallback は placeholder /
1821
- // 危険値の場合だけ使う。fallback も同じ containment 検査を通してから path 化する。
1822
- if (!isSafeSessionIdForPath(sessionId) && resolveFallbackSessionId) {
1823
- sessionId = resolveFallbackSessionId(s)
1824
- usedFallback = true
1825
- }
1826
- // パス要素として安全な形だけを通す (外部由来の値なので containment を局所保証)。
1827
- if (!isSafeSessionIdForPath(sessionId)) return
1828
- if (!s.cwd) return
1829
- let fp = resolvePath({ cwd: s.cwd, session_id: sessionId })
1830
- if (!fp) return
1831
- let st
1832
- try {
1833
- st = await statFn(fp)
1834
- } catch {
1835
- // 再起動直後は snapshot に構文上は安全でも既に失効した session ID が残ることがある。
1836
- // 0.8.7 は unbound/fresh だけを live session へ切り替えていたため、この場合は stat
1837
- // 失敗を外側 catch が握りつぶし、サイドバーの依頼/応答だけが空のままになった。
1838
- // primary path が読めない場合に限って、現在の cwd-scoped live session ID を再解決する。
1839
- // snapshot path が実在する通常系では従来どおり snapshot を優先する。
1840
- if (usedFallback || !resolveFallbackSessionId) return
1841
- const fallbackId = resolveFallbackSessionId(s)
1842
- if (!isSafeSessionIdForPath(fallbackId) || fallbackId === sessionId) return
1843
- const fallbackPath = resolvePath({ cwd: s.cwd, session_id: fallbackId })
1844
- if (!fallbackPath) return
1845
- try {
1846
- st = await statFn(fallbackPath)
1847
- } catch {
1848
- return
1849
- }
1850
- sessionId = fallbackId
1851
- fp = fallbackPath
1852
- usedFallback = true
1853
- }
1854
- livePaths.add(fp)
1855
- const version = lastTurnTextsVersion(st)
1856
- const cached = lastTurnTextsCache.get(fp)
1857
- if (cached && cached.version === version) {
1858
- out.set(s.name, cached.texts)
1859
- return
1860
- }
1861
- // 同一 path への同時抽出は 1 本に畳む (複数ブラウザの同時 poll 対策)。
1862
- //
1863
- // ⚠️ in-flight には「どの版の抽出か」を必ず持たせる (2026-07-28 クロスレビュー major#1)。
1864
- // path だけで共有し、合流した caller が自分の観測版で結果を保存すると、
1865
- // 「V1 の抽出中に V2 へ追記 → V2 を観測した caller が V1 の結果を {version: V2} で
1866
- // 保存」となり、以降ファイルが動かない限り古いテキストが恒久的に cache hit する。
1867
- let pending = lastTurnTextsInFlight.get(fp)
1868
- if (!pending) {
1869
- const entry = { version, promise: null }
1870
- entry.promise = extract(fp).finally(() => {
1871
- // 世代交代後に古い finally が新しい in-flight を消さないよう、
1872
- // Map の現在値が自分自身のときだけ削除する。
1873
- if (lastTurnTextsInFlight.get(fp) === entry) lastTurnTextsInFlight.delete(fp)
1874
- })
1875
- lastTurnTextsInFlight.set(fp, entry)
1876
- pending = entry
1877
- }
1878
- const texts = await pending.promise
1879
- out.set(s.name, texts)
1880
- // cache に載せてよいのは「その抽出を開始した版」の caller だけ。別版を観測して
1881
- // 合流した caller は表示にのみ使い、cache は汚さない (次回 poll で必ず再抽出)。
1882
- if (pending.version === version) {
1883
- // 抽出中に追記されていた場合、読んだ内容は既に古い可能性がある。開始時と同じ版の
1884
- // ままだったときだけ cache する (追記中の結果を恒久 hit させないための回帰ガード)。
1885
- let unchanged = false
1886
- try {
1887
- unchanged = lastTurnTextsVersion(await statFn(fp)) === version
1888
- } catch {
1889
- unchanged = false
1890
- }
1891
- if (unchanged) lastTurnTextsCache.set(fp, { version, texts })
1892
- }
1893
- } catch {
1894
- // jsonl 不在 / 権限 / parse 失敗は「テキスト無し」に落とすだけ (一覧は出す)
1895
- }
1896
- }
1897
-
1898
- // cold cache で数十セッションが一斉に上限走査に入ると I/O が集中するため、
1899
- // 素の Promise.all ではなく固定並列度で流す。
1900
- const queue = sessions.slice()
1901
- const workers = Array.from(
1902
- { length: Math.min(LAST_TEXTS_CONCURRENCY, queue.length) },
1903
- async () => {
1904
- for (;;) {
1905
- const s = queue.shift()
1906
- if (!s) return
1907
- await readOne(s)
1908
- }
1909
- },
1910
- )
1911
- await Promise.all(workers)
1912
-
1913
- pruneLastTurnTextsCache(livePaths)
1914
- return out
1915
- }
1916
-
1917
1710
  /**
1918
1711
  * 単一 session の最新 session-event をファイル正本から読む。無ければ null。
1919
1712
  * claude.tui.bind 時の `stop` 再配信 (取りこぼし回復) に使う。
@@ -2186,26 +1979,13 @@ const STALL_WARN_MS = Number(process.env.HUB_AGENT_STALL_WARN_MS ?? 90000)
2186
1979
  * @param {Map<string, object>} lastEventByName session_name → 最新 hook event
2187
1980
  * @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
2188
1981
  * accessor。undefined のときは 4 信号を一切付与しない(snapshot 経路 OFF と等価)
2189
- * @param {Map<string, {user: string, assistant: string}> | undefined} lastTextsByName
2190
- * session_name → jsonl 末尾の依頼/応答テキスト (サイドバー行の内容表示用)。
2191
- * undefined / 未収録なら該当フィールドを載せない(古い cockpit は無視するので互換)
2192
1982
  */
2193
- export function enrichTmuxSessionsForListResponse(
2194
- sessions,
2195
- lastEventByName,
2196
- getSnap,
2197
- lastTextsByName,
2198
- ) {
1983
+ export function enrichTmuxSessionsForListResponse(sessions, lastEventByName, getSnap) {
2199
1984
  return sessions.map((s) => {
2200
1985
  const base = {
2201
1986
  ...s,
2202
1987
  last_event: lastEventByName.get(s.name) || null,
2203
1988
  }
2204
- // サイドバー行に出す「何を依頼したか / 何が返ったか」。空文字は載せない
2205
- // (frontend 側で「未取得」と「本当に空」を区別する必要が無いため)。
2206
- const texts = lastTextsByName?.get(s.name)
2207
- if (texts?.user) base.last_user_text = texts.user
2208
- if (texts?.assistant) base.last_assistant_text = texts.assistant
2209
1989
  if (!getSnap) return base
2210
1990
  const snap = getSnap(s.name)
2211
1991
  if (!snap) return base
@@ -2420,6 +2200,9 @@ export function decideTurnActiveSource({
2420
2200
  export function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, codexBridge, readinessTracker, boundSessions }) {
2421
2201
  const lastByName = new Map() // session_name → {status, context_pct}
2422
2202
  const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
2203
+ // session_name → 最後に書いた chat 由来 event 種別。エッジ駆動 (種別遷移時のみ書く) にして
2204
+ // 生成中の prompt_submit 連打を防ぐ (並行生成でサイドバーが揺れる原因)。
2205
+ const lastChatEventByName = new Map()
2423
2206
  // (i) session_name → {sig, changedAt}: 出力領域署名の最終変化時刻 (出力フロー検知用)。
2424
2207
  const outputFlowByName = new Map()
2425
2208
  // 権威的 stall 判定 (arm 非依存・cap 非依存)。state loop が観測する signal だけで turn の開閉と
@@ -2495,19 +2278,27 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2495
2278
  let status = overlay.status
2496
2279
  let contextPct = overlay.contextPct
2497
2280
  if (chat) {
2498
- // チャットのターン境界 (turnAt 前進) を sort 用 session-event に橋渡し。
2281
+ // チャットのターン境界を sort / 状態用 session-event に橋渡しする。
2499
2282
  // tmux ペインが動かず bundle hook が発火しないため、ここで代替発火する。
2283
+ //
2284
+ // ⚠️ **エッジ駆動** (種別が変わったときだけ書く)。旧実装は turnAt が前進するたびに
2285
+ // 書いており、turnAt は assistant イベント毎に前進するため **生成中ずっと
2286
+ // prompt_submit を書き続けていた**。並行生成中のセッション同士が assistant の
2287
+ // 到着順で入れ替わり、サイドバーが揺れる原因になる (2026-07-29)。
2288
+ // 種別遷移でのみ書けば、prompt_submit は生成開始の 1 回だけになる。
2289
+ //
2290
+ // 確認待ち (inputPending) は専用 event "input_required"、生成中は "prompt_submit"、
2291
+ // それ以外は "stop"。並び順に効くのは computeSortAffecting により prompt_submit だけ。
2292
+ const ev = chat.inputPending
2293
+ ? "input_required"
2294
+ : chat.status === "processing"
2295
+ ? "prompt_submit"
2296
+ : "stop"
2500
2297
  const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
2501
- if (chat.turnAt > prevTurnAt) {
2298
+ const prevEv = lastChatEventByName.get(s.session_name)
2299
+ if (chat.turnAt > prevTurnAt && ev !== prevEv) {
2502
2300
  lastTurnAtByName.set(s.session_name, chat.turnAt)
2503
- // 確認待ち (inputPending) は専用 event "input_required" を書き、サイドバーの
2504
- // 「確認待ち」ドット & 通知に橋渡しする。それ以外は従来通り processing →
2505
- // prompt_submit / それ以外 → stop。
2506
- const ev = chat.inputPending
2507
- ? "input_required"
2508
- : chat.status === "processing"
2509
- ? "prompt_submit"
2510
- : "stop"
2301
+ lastChatEventByName.set(s.session_name, ev)
2511
2302
  writeSessionEventFile(s.session_name, ev, chat.turnAt).catch(() => {})
2512
2303
  }
2513
2304
  }
@@ -4780,23 +4571,11 @@ async function dispatch(msg, ctx) {
4780
4571
  claudeBridge: ctx.claudeBridge,
4781
4572
  worktreeIndex,
4782
4573
  })
4783
- const [lastEventByName, lastTextsByName] = await Promise.all([
4784
- readAllSessionEvents(),
4785
- readAllSessionLastTexts(sessions, ctx.getSessionStateSnapshot, {
4786
- resolveFallbackSessionId: (session) =>
4787
- resolveStateLoopBoundSessionId(
4788
- ctx.tuiReboundSessions,
4789
- session.name,
4790
- session.cwd,
4791
- ctx.claudeBridge,
4792
- ),
4793
- }),
4794
- ])
4574
+ const lastEventByName = await readAllSessionEvents()
4795
4575
  const enriched = enrichTmuxSessionsForListResponse(
4796
4576
  sessions,
4797
4577
  lastEventByName,
4798
4578
  ctx.getSessionStateSnapshot,
4799
- lastTextsByName,
4800
4579
  )
4801
4580
  const liveNames = new Set(enriched.map((s) => s.name))
4802
4581
  let worktreeStubs = []
@@ -4823,6 +4602,29 @@ async function dispatch(msg, ctx) {
4823
4602
  }
4824
4603
  return
4825
4604
  }
4605
+ case "chat.sidebar_snapshot.request": {
4606
+ try {
4607
+ const messages = await readSidebarMessageSnapshot(msg.targets, {
4608
+ liveCodexSession: (target) => {
4609
+ const live = ctx.codexBridge?.liveSessionForCwd?.(target.cwd)
4610
+ return live?.threadId === target.conversationId ? live : null
4611
+ },
4612
+ })
4613
+ ctx.client.send({
4614
+ type: "chat.sidebar_snapshot.response",
4615
+ request_id: msg.request_id,
4616
+ messages,
4617
+ })
4618
+ } catch (err) {
4619
+ ctx.client.send({
4620
+ type: "chat.sidebar_snapshot.response",
4621
+ request_id: msg.request_id,
4622
+ messages: [],
4623
+ error: err?.message || String(err),
4624
+ })
4625
+ }
4626
+ return
4627
+ }
4826
4628
  case "tmux.create_session": {
4827
4629
  const name = (msg.session_name || "").trim()
4828
4630
  const cwd = (msg.cwd || process.env.HOME || "/").trim()
@@ -0,0 +1,362 @@
1
+ import { open, readdir, realpath, stat } from "node:fs/promises"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ import { extractLastTurnTexts, jsonlPath, LAST_TURN_TEXT_MAX } from "./claude-history.mjs"
6
+ import { resolveProjectsRoot } from "./profiles.mjs"
7
+ import * as codexProfiles from "./codex-profiles.mjs"
8
+ import { lookupInjectSource } from "./inject-source-store.mjs"
9
+ import { SECRET_PLACEHOLDER, redactSecrets } from "./tool-result-mask.mjs"
10
+
11
+ const MAX_TARGETS = 100
12
+ const READ_CONCURRENCY = 8
13
+ const TAIL_CHUNK = 64 * 1024
14
+ const MAX_SCAN = 8 * 1024 * 1024
15
+ const MAX_LINE = 1024 * 1024
16
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
17
+
18
+ const textCache = new Map()
19
+ const textInFlight = new Map()
20
+ const rolloutPathCache = new Map()
21
+
22
+ function sanitizeText(value) {
23
+ if (typeof value !== "string" || !value.trim()) return ""
24
+ let masked = redactSecrets(value)
25
+ const openerAt = masked.indexOf("-----BEGIN")
26
+ if (openerAt >= 0) masked = masked.slice(0, openerAt) + SECRET_PLACEHOLDER
27
+ return masked.trim().replace(/\s+/g, " ").slice(0, LAST_TURN_TEXT_MAX)
28
+ }
29
+
30
+ function itemText(item) {
31
+ const content = item?.content
32
+ if (typeof content === "string") return content
33
+ if (!Array.isArray(content)) return ""
34
+ return content
35
+ .map((block) =>
36
+ block && typeof block === "object" && typeof block.text === "string"
37
+ ? block.text
38
+ : "",
39
+ )
40
+ .join("\n")
41
+ }
42
+
43
+ export async function textsFromCodexItems(items, { isInjected } = {}) {
44
+ let assistant = ""
45
+ if (!Array.isArray(items)) return { user: "", assistant }
46
+ for (let i = items.length - 1; i >= 0; i--) {
47
+ const item = items[i]
48
+ if (item?.type === "agentMessage" && !assistant) {
49
+ assistant = sanitizeText(itemText(item))
50
+ continue
51
+ }
52
+ if (item?.type !== "userMessage") continue
53
+ const user = sanitizeText(itemText(item))
54
+ if (!user) continue
55
+ let injected = item.source === "peer" || item.source === "orchestrator"
56
+ if (!injected && typeof isInjected === "function") {
57
+ try {
58
+ injected = (await isInjected(user)) === true
59
+ } catch {
60
+ // 判定不能なら通常入力として残す。
61
+ }
62
+ }
63
+ if (injected) {
64
+ assistant = ""
65
+ continue
66
+ }
67
+ return { user, assistant }
68
+ }
69
+ return { user: "", assistant }
70
+ }
71
+
72
+ function codexLineMessage(line) {
73
+ if (!line) return null
74
+ let obj
75
+ try {
76
+ obj = JSON.parse(line)
77
+ } catch {
78
+ return null
79
+ }
80
+ if (obj?.type !== "event_msg" || !obj.payload) return null
81
+ if (obj.payload.type === "agent_message") {
82
+ return { role: "assistant", text: sanitizeText(obj.payload.message) }
83
+ }
84
+ if (obj.payload.type === "user_message") {
85
+ return { role: "user", text: sanitizeText(obj.payload.message) }
86
+ }
87
+ return null
88
+ }
89
+
90
+ export async function extractCodexLastTurnTexts(filePath, { isInjected } = {}) {
91
+ let assistant = ""
92
+ let handle
93
+ try {
94
+ handle = await open(filePath, "r")
95
+ const fileStat = await handle.stat()
96
+ let pos = fileStat.size
97
+ let carry = null
98
+ let scanned = 0
99
+ while (pos > 0 && scanned < MAX_SCAN) {
100
+ const len = Math.min(TAIL_CHUNK, pos, MAX_SCAN - scanned)
101
+ pos -= len
102
+ const buf = Buffer.allocUnsafe(len)
103
+ const { bytesRead } = await handle.read(buf, 0, len, pos)
104
+ scanned += bytesRead
105
+ const region = carry?.length
106
+ ? Buffer.concat([buf.subarray(0, bytesRead), carry])
107
+ : buf.subarray(0, bytesRead)
108
+ const parts = []
109
+ let start = 0
110
+ for (let i = 0; i < region.length; i++) {
111
+ if (region[i] === 0x0a) {
112
+ parts.push(region.subarray(start, i))
113
+ start = i + 1
114
+ }
115
+ }
116
+ parts.push(region.subarray(start))
117
+ carry = pos > 0 ? parts.shift() : null
118
+ if (carry?.length > MAX_LINE) carry = null
119
+
120
+ for (let i = parts.length - 1; i >= 0; i--) {
121
+ if (parts[i].length === 0 || parts[i].length > MAX_LINE) continue
122
+ const message = codexLineMessage(parts[i].toString("utf8"))
123
+ if (!message?.text) continue
124
+ if (message.role === "assistant") {
125
+ if (!assistant) assistant = message.text
126
+ continue
127
+ }
128
+ let injected = false
129
+ if (typeof isInjected === "function") {
130
+ try {
131
+ injected = (await isInjected(message.text)) === true
132
+ } catch {
133
+ // 判定不能なら通常入力として残す。
134
+ }
135
+ }
136
+ if (injected) {
137
+ assistant = ""
138
+ continue
139
+ }
140
+ return { user: message.text, assistant }
141
+ }
142
+ }
143
+ return { user: "", assistant }
144
+ } catch {
145
+ return { user: "", assistant: "" }
146
+ } finally {
147
+ await handle?.close().catch(() => {})
148
+ }
149
+ }
150
+
151
+ async function findCodexRolloutFile(threadId, sessionsDir) {
152
+ if (!UUID_RE.test(threadId) || !sessionsDir) return null
153
+ const cacheKey = `${sessionsDir}\0${threadId}`
154
+ const cached = rolloutPathCache.get(cacheKey)
155
+ if (cached) {
156
+ try {
157
+ await stat(cached)
158
+ return cached
159
+ } catch {
160
+ rolloutPathCache.delete(cacheKey)
161
+ }
162
+ }
163
+ const suffix = `-${threadId}.jsonl`
164
+ const years = await readdir(sessionsDir).catch(() => [])
165
+ for (const year of years.filter((v) => /^\d{4}$/.test(v)).sort().reverse()) {
166
+ const yearDir = path.join(sessionsDir, year)
167
+ const months = await readdir(yearDir).catch(() => [])
168
+ for (const month of months.sort().reverse()) {
169
+ const monthDir = path.join(yearDir, month)
170
+ const days = await readdir(monthDir).catch(() => [])
171
+ for (const day of days.sort().reverse()) {
172
+ const dayDir = path.join(monthDir, day)
173
+ const files = await readdir(dayDir).catch(() => [])
174
+ const match = files.find((file) => file.endsWith(suffix))
175
+ if (!match) continue
176
+ const found = path.join(dayDir, match)
177
+ rolloutPathCache.set(cacheKey, found)
178
+ return found
179
+ }
180
+ }
181
+ }
182
+ return null
183
+ }
184
+
185
+ async function readCached(filePath, extract, { maxAgeMs = Infinity } = {}) {
186
+ if (!filePath) return { user: "", assistant: "" }
187
+ let fileStat
188
+ try {
189
+ fileStat = await stat(filePath)
190
+ } catch {
191
+ return { user: "", assistant: "" }
192
+ }
193
+ const version = `${fileStat.mtimeMs}:${fileStat.size}:${fileStat.ctimeMs}`
194
+ const cached = textCache.get(filePath)
195
+ if (
196
+ cached &&
197
+ cached.version === version &&
198
+ Date.now() - cached.checkedAt < maxAgeMs
199
+ ) {
200
+ return cached.texts
201
+ }
202
+
203
+ // 複数タブの同時 snapshot は同じ版の抽出 1 本へ畳む。版が違う要求は古い
204
+ // in-flight に合流させず、追記後の本文を独立して読む。
205
+ const inFlightKey = `${filePath}\0${version}`
206
+ let pending = textInFlight.get(inFlightKey)
207
+ if (!pending) {
208
+ pending = extract(filePath).finally(() => {
209
+ textInFlight.delete(inFlightKey)
210
+ })
211
+ textInFlight.set(inFlightKey, pending)
212
+ }
213
+ const texts = await pending
214
+
215
+ // 抽出中に追記・置換された結果を開始時の版としてキャッシュしない。
216
+ try {
217
+ const after = await stat(filePath)
218
+ const afterVersion = `${after.mtimeMs}:${after.size}:${after.ctimeMs}`
219
+ if (afterVersion === version) {
220
+ textCache.set(filePath, {
221
+ version,
222
+ checkedAt: Date.now(),
223
+ texts,
224
+ })
225
+ }
226
+ } catch {
227
+ // 読み終えた直後の削除は今回結果だけ返し、cache には残さない。
228
+ }
229
+ while (textCache.size > 512) textCache.delete(textCache.keys().next().value)
230
+ return texts
231
+ }
232
+
233
+ function normalizedTarget(raw) {
234
+ if (!raw || typeof raw !== "object") return null
235
+ const sessionName = typeof raw.session_name === "string" ? raw.session_name.trim() : ""
236
+ const cwd = typeof raw.cwd === "string" ? raw.cwd.trim() : ""
237
+ const cliKind = raw.cli_kind === "codex" ? "codex" : raw.cli_kind === "claude" ? "claude" : ""
238
+ const conversationId =
239
+ typeof raw.conversation_id === "string" ? raw.conversation_id.trim() : ""
240
+ const profileId = typeof raw.profile_id === "string" ? raw.profile_id.trim() : undefined
241
+ if (!sessionName || !cwd || !cliKind || !UUID_RE.test(conversationId)) return null
242
+ return { sessionName, cwd, cliKind, conversationId, profileId }
243
+ }
244
+
245
+ async function defaultIsAllowedCwd(cwd) {
246
+ const configuredBase = process.env.HUB_PROJECTS_BASE || path.join(os.homedir(), "hub", "projects")
247
+ try {
248
+ const [resolvedCwd, resolvedBase] = await Promise.all([realpath(cwd), realpath(configuredBase)])
249
+ if (!resolvedCwd.startsWith(`${resolvedBase}${path.sep}`)) return false
250
+ const parts = path.relative(resolvedBase, resolvedCwd).split(path.sep)
251
+ return (
252
+ parts.length === 1 ||
253
+ (parts.length === 4 && parts[1] === ".claude" && parts[2] === "worktrees")
254
+ )
255
+ } catch {
256
+ return false
257
+ }
258
+ }
259
+
260
+ export async function readSidebarMessageSnapshot(
261
+ targets,
262
+ {
263
+ resolveClaudeRoot = resolveProjectsRoot,
264
+ resolveCodexRoot = codexProfiles.resolveSessionsDir,
265
+ liveCodexSession,
266
+ isAllowedCwd = defaultIsAllowedCwd,
267
+ lookupInjected = lookupInjectSource,
268
+ extractClaude = extractLastTurnTexts,
269
+ extractCodex = extractCodexLastTurnTexts,
270
+ } = {},
271
+ ) {
272
+ const safeTargets = (Array.isArray(targets) ? targets : [])
273
+ .slice(0, MAX_TARGETS)
274
+ .map(normalizedTarget)
275
+ .filter(Boolean)
276
+
277
+ const readTarget = async (target) => {
278
+ try {
279
+ if (!(await isAllowedCwd(target.cwd))) throw new Error("cwd outside workspace roots")
280
+ const isInjected = async (text) =>
281
+ Boolean(await lookupInjected(target.cwd, text))
282
+ let texts = { user: "", assistant: "" }
283
+ if (target.cliKind === "claude") {
284
+ const { projectsRoot, profileId } = await resolveClaudeRoot(target.profileId)
285
+ const filePath = jsonlPath({
286
+ cwd: target.cwd,
287
+ session_id: target.conversationId,
288
+ projectsRoot,
289
+ })
290
+ texts = await readCached(
291
+ filePath,
292
+ (fp) => extractClaude(fp, { isInjected }),
293
+ { maxAgeMs: 2_000 },
294
+ )
295
+ return {
296
+ session_name: target.sessionName,
297
+ cli_kind: "claude",
298
+ conversation_id: target.conversationId,
299
+ profile_id: profileId,
300
+ ...(texts.user ? { user: texts.user } : {}),
301
+ ...(texts.assistant ? { assistant: texts.assistant } : {}),
302
+ }
303
+ }
304
+
305
+ const live = liveCodexSession?.(target)
306
+ if (live && live.threadId === target.conversationId) {
307
+ texts = await textsFromCodexItems(live.historyItems, { isInjected })
308
+ }
309
+ const { sessionsDir, profileId } = await resolveCodexRoot(target.profileId)
310
+ if (!texts.user || !texts.assistant) {
311
+ const filePath = await findCodexRolloutFile(target.conversationId, sessionsDir)
312
+ const diskTexts = await readCached(
313
+ filePath,
314
+ (fp) => extractCodex(fp, { isInjected }),
315
+ { maxAgeMs: 2_000 },
316
+ )
317
+ texts = {
318
+ user: texts.user || diskTexts.user,
319
+ // live に user があるのに assistant が無い = 生成中。前ターンの disk assistant
320
+ // で埋めると別ターン混在になるため、disk は live user 自体が無いときだけ使う。
321
+ assistant: texts.user ? texts.assistant : diskTexts.assistant,
322
+ }
323
+ }
324
+ return {
325
+ session_name: target.sessionName,
326
+ cli_kind: "codex",
327
+ conversation_id: target.conversationId,
328
+ profile_id: profileId,
329
+ ...(texts.user ? { user: texts.user } : {}),
330
+ ...(texts.assistant ? { assistant: texts.assistant } : {}),
331
+ }
332
+ } catch {
333
+ // 1 件の profile / 履歴 I/O 失敗で、他セッションの表示まで消さない。
334
+ return {
335
+ session_name: target.sessionName,
336
+ cli_kind: target.cliKind,
337
+ conversation_id: target.conversationId,
338
+ }
339
+ }
340
+ }
341
+
342
+ const messages = new Array(safeTargets.length)
343
+ let cursor = 0
344
+ const workers = Array.from(
345
+ { length: Math.min(READ_CONCURRENCY, safeTargets.length) },
346
+ async () => {
347
+ for (;;) {
348
+ const index = cursor++
349
+ if (index >= safeTargets.length) return
350
+ messages[index] = await readTarget(safeTargets[index])
351
+ }
352
+ },
353
+ )
354
+ await Promise.all(workers)
355
+ return messages
356
+ }
357
+
358
+ export function _resetSidebarMessageSnapshotCachesForTest() {
359
+ textCache.clear()
360
+ textInFlight.clear()
361
+ rolloutPathCache.clear()
362
+ }