@cocorograph/hub-agent 0.8.2 → 0.8.4

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.2",
3
+ "version": "0.8.4",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -19,6 +19,7 @@ import path from "node:path"
19
19
 
20
20
  import {
21
21
  MASK_ENABLED,
22
+ SECRET_PLACEHOLDER,
22
23
  collectToolUseNames,
23
24
  maskMessageObject,
24
25
  redactSecrets,
@@ -541,6 +542,203 @@ async function extractLastTurnAtMs(filePath, fallbackMtimeMs = null) {
541
542
  }
542
543
  }
543
544
 
545
+ /** サイドバー 1 行に載せる依頼 / 応答テキストの最大長。1 行 clamp 前提で少し余裕を持たせる。 */
546
+ export const LAST_TURN_TEXT_MAX = 120
547
+
548
+ /**
549
+ * message.content (string | block[]) から表示用テキストを連結する。
550
+ *
551
+ * tool_use / tool_result / thinking は「会話の主題」ではないので落とす。user 行の content が
552
+ * tool_result だけのケース (= ツール実行の戻り。ユーザーの発言ではない) は空文字になり、
553
+ * 呼び出し側がスキップして更に遡る。
554
+ */
555
+ function contentToPlainText(content) {
556
+ if (typeof content === "string") return content
557
+ if (!Array.isArray(content)) return ""
558
+ const parts = []
559
+ for (const b of content) {
560
+ if (b && b.type === "text" && typeof b.text === "string") parts.push(b.text)
561
+ }
562
+ return parts.join("\n")
563
+ }
564
+
565
+ /**
566
+ * jsonl の末尾から「最後の実ユーザー依頼」と「最後のアシスタント応答」を 1 本ずつ抜く。
567
+ *
568
+ * ## なぜ必要か (2026-07-28)
569
+ * Cockpit サイドバーは各行に「何を依頼したか」を出す。しかし frontend は選択中 1 セッション
570
+ * 分のメッセージしか保持せず、backend にも全セッションの最新メッセージを返す API が無い。
571
+ * 既存の `ClaudeSessionSummary.preview` は **最初の** user 発言なので用途が違う。そこで
572
+ * agent 側で jsonl 末尾を読み、tmux.list_sessions の行に同梱して配信する。
573
+ *
574
+ * 実装は固定チャンクで**後方へ改行境界を探索**する tail scanner。1 行が
575
+ * `LAST_TURN_MAX_LINE` を超える巨大行 (tool_result / base64) は decode せず読み飛ばし、
576
+ * その手前の正常なターンまで遡る。総走査量は `LAST_TURN_MAX_SCAN`、読み飛ばせる行数は
577
+ * `LAST_TURN_MAX_SKIPPED_LINES` で頭打ちにして、poll ごとの I/O 集中を防ぐ。
578
+ * (旧実装の「窓を倍々に広げて巨大行ごと取り込む」方式は、単一行が上限を超えると窓内に
579
+ * 改行が 1 つも現れず、手前のターンごと取りこぼすため廃止した。)
580
+ *
581
+ * user 側は `normalizePreviewText` を通し、合成メッセージ / `/clear`・`/compact` の痕跡を
582
+ * 飛ばして「次の実発言」を拾う (= 一覧が全部 /clear で始まる問題を避ける)。
583
+ *
584
+ * @param {string} filePath
585
+ * @returns {Promise<{user: string, assistant: string}>} 見つからない側は ""
586
+ */
587
+ /** 1 行の上限。これを超える行 (巨大 tool_result / base64) は decode せず読み飛ばす。 */
588
+ export const LAST_TURN_MAX_LINE = 1024 * 1024
589
+ /** 1 回の呼び出しで読む総バイト数の上限 (巨大行の読み飛ばしを含む I/O 予算)。 */
590
+ export const LAST_TURN_MAX_SCAN = 8 * 1024 * 1024
591
+ /** 読み飛ばしてよい巨大行の本数。超えたら諦める (poll ごとの I/O 集中を抑える)。 */
592
+ export const LAST_TURN_MAX_SKIPPED_LINES = 8
593
+
594
+ const NEWLINE_BYTE = 0x0a
595
+
596
+ /** Buffer を改行 (0x0A) で分割して Buffer 配列にする。UTF-8 の継続バイトに 0x0A は現れない。 */
597
+ function splitLinesBuf(buf) {
598
+ const out = []
599
+ let start = 0
600
+ for (let i = 0; i < buf.length; i++) {
601
+ if (buf[i] === NEWLINE_BYTE) {
602
+ out.push(buf.subarray(start, i))
603
+ start = i + 1
604
+ }
605
+ }
606
+ out.push(buf.subarray(start))
607
+ return out
608
+ }
609
+
610
+ /**
611
+ * preview テキストの秘匿情報を伏せる。**必ず切り詰めより前に、完全な本文へ適用する。**
612
+ *
613
+ * ⚠️ 順序が肝 (2026-07-28 のクロスレビューで実証された事故): 先に 80/120 文字へ切ると
614
+ * PEM の `-----END ... PRIVATE KEY-----` が落ち、`redactSecrets` の非貪欲マッチが不成立に
615
+ * なって **鍵の先頭断片がそのまま preview に出る**。マスク → 正規化 → 切り詰めの順にする。
616
+ *
617
+ * サイドバー配信は新設の露出面なので `MASK_ENABLED` に関係なく常にマスクする
618
+ * (120 文字のプレビューに秘匿情報が必要な場面は無い)。閉じていない PEM opener が残る
619
+ * ケースは、そこから先を保守的に全て伏せる。
620
+ */
621
+ function maskPreviewText(raw) {
622
+ if (!raw) return ""
623
+ let out = redactSecrets(raw)
624
+ const openerAt = out.indexOf("-----BEGIN")
625
+ if (openerAt >= 0) out = out.slice(0, openerAt) + SECRET_PLACEHOLDER
626
+ return out
627
+ }
628
+
629
+ /** assistant テキスト用: マスク → 空白圧縮 → 切り詰め。 */
630
+ function sanitizeAssistantText(raw) {
631
+ const masked = maskPreviewText(raw).trim()
632
+ if (!masked) return ""
633
+ return masked.replace(/\s+/g, " ").slice(0, LAST_TURN_TEXT_MAX)
634
+ }
635
+
636
+ /**
637
+ * 1 行 (JSON) から user / assistant テキストを取り出し、未取得の側だけを埋める。
638
+ * 既に埋まっている側は触らない (末尾に近い方を優先するため)。
639
+ */
640
+ function takeTextsFromLine(line, user, assistant) {
641
+ if (!line) return { user, assistant }
642
+ let obj
643
+ try {
644
+ obj = JSON.parse(line)
645
+ } catch {
646
+ return { user, assistant }
647
+ }
648
+ if (!obj || !obj.message) return { user, assistant }
649
+ if (obj.type === "assistant" && !assistant) {
650
+ const t = sanitizeAssistantText(contentToPlainText(obj.message.content))
651
+ if (t) assistant = t
652
+ return { user, assistant }
653
+ }
654
+ if (obj.type === "user" && !user) {
655
+ // isMeta / isSidechain はハーネス注入や sub-agent の発言でユーザーの依頼ではない。
656
+ if (obj.isMeta === true || obj.isSidechain === true) return { user, assistant }
657
+ // マスクは正規化・切り詰めより前 (maskPreviewText の docstring 参照)。
658
+ const masked = maskPreviewText(contentToPlainText(obj.message.content))
659
+ // normalizePreviewText が null を返す行 (合成 / /clear / local-command) はスキップし、
660
+ // 更に過去へ遡って「実際に打った依頼」を探す。
661
+ const norm = normalizePreviewText(masked)
662
+ if (norm) user = norm.slice(0, LAST_TURN_TEXT_MAX)
663
+ }
664
+ return { user, assistant }
665
+ }
666
+
667
+ export async function extractLastTurnTexts(filePath) {
668
+ let handle
669
+ try {
670
+ handle = await open(filePath, "r")
671
+ const st = await handle.stat()
672
+ if (st.size <= 0) return { user: "", assistant: "" }
673
+
674
+ let pos = st.size // まだ読んでいない領域の右端 (exclusive)
675
+ let carry = null // 左端が未確定の行断片 (次のチャンクと連結して確定させる)
676
+ let skippingHugeLine = false // 上限超の行の途中を読み飛ばし中
677
+ let skipped = 0
678
+ let scanned = 0
679
+ let user = ""
680
+ let assistant = ""
681
+
682
+ // ⚠️ 「窓を倍々に広げて巨大行ごと取り込む」旧方式は、単一行が上限を超えると窓内に
683
+ // 改行が 1 つも現れず、その手前にある正常なターンまで丸ごと取りこぼした
684
+ // (4MB+1KB の tool_result で両方空になる事故)。固定チャンクで後方へ改行境界を
685
+ // 探索し、上限超の行は decode せず読み飛ばす方式に変える。
686
+ while (pos > 0 && scanned < LAST_TURN_MAX_SCAN) {
687
+ const len = Math.min(LAST_TURN_TAIL_CHUNK, pos)
688
+ pos -= len
689
+ const buf = Buffer.allocUnsafe(len)
690
+ const { bytesRead } = await handle.read(buf, 0, len, pos)
691
+ scanned += bytesRead
692
+ const chunk = buf.subarray(0, bytesRead)
693
+ const region = carry && carry.length ? Buffer.concat([chunk, carry]) : chunk
694
+ carry = null
695
+
696
+ const segs = splitLinesBuf(region)
697
+ let endIdx = segs.length // exclusive
698
+ if (skippingHugeLine) {
699
+ // 末尾 seg は読み飛ばし中の巨大行の続き → 捨てる。
700
+ endIdx -= 1
701
+ // 改行が 1 つでもあれば巨大行の左端に到達したので読み飛ばしを終える。
702
+ if (segs.length > 1) skippingHugeLine = false
703
+ }
704
+ let startIdx = 0
705
+ if (pos > 0) {
706
+ // 先頭 seg は左端未確定 → 次チャンクへ持ち越す。pos===0 なら完全な行。
707
+ carry = segs[0]
708
+ startIdx = 1
709
+ }
710
+
711
+ for (let i = endIdx - 1; i >= startIdx; i--) {
712
+ const lineBuf = segs[i]
713
+ if (lineBuf.length === 0) continue
714
+ if (lineBuf.length > LAST_TURN_MAX_LINE) {
715
+ skipped++
716
+ continue
717
+ }
718
+ const res = takeTextsFromLine(lineBuf.toString("utf-8"), user, assistant)
719
+ user = res.user
720
+ assistant = res.assistant
721
+ if (user && assistant) break
722
+ }
723
+ if (user && assistant) break
724
+ if (skipped > LAST_TURN_MAX_SKIPPED_LINES) break
725
+
726
+ if (carry && carry.length > LAST_TURN_MAX_LINE) {
727
+ // 単一行が上限超 → その行は諦め、更に左の改行まで decode せず読み飛ばす。
728
+ carry = null
729
+ skippingHugeLine = true
730
+ skipped++
731
+ if (skipped > LAST_TURN_MAX_SKIPPED_LINES) break
732
+ }
733
+ }
734
+ return { user, assistant }
735
+ } catch {
736
+ return { user: "", assistant: "" }
737
+ } finally {
738
+ await handle?.close()
739
+ }
740
+ }
741
+
544
742
  /**
545
743
  * cwd 配下の全 jsonl ファイルから session 一覧を返す。
546
744
  * 各ファイルから session_id / 最終更新時刻 / サイズ / 最初の user message preview を抽出。
package/src/main.mjs CHANGED
@@ -12,7 +12,15 @@
12
12
  */
13
13
  import { spawn as _childSpawn } from "node:child_process"
14
14
  import { existsSync, readFileSync, watch as fsWatch } from "node:fs"
15
- import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises"
15
+ import {
16
+ mkdir,
17
+ readFile,
18
+ readdir,
19
+ rename,
20
+ stat,
21
+ unlink,
22
+ writeFile,
23
+ } from "node:fs/promises"
16
24
  import { randomUUID } from "node:crypto"
17
25
  import os from "node:os"
18
26
  import path from "node:path"
@@ -46,6 +54,7 @@ import { CodexStreamBridge, classifyCodexInputError } from "./codex-stream-bridg
46
54
  import { UploadManager } from "./claude-upload.mjs"
47
55
  import { requestSelfUninstall } from "./service-install.mjs"
48
56
  import {
57
+ extractLastTurnTexts,
49
58
  fetchSessionHistory,
50
59
  jsonlPath,
51
60
  listSessions,
@@ -1708,6 +1717,171 @@ async function readAllSessionEvents() {
1708
1717
  return out
1709
1718
  }
1710
1719
 
1720
+ /**
1721
+ * jsonl 末尾テキストのキャッシュ。key = jsonl path、value = `{version, texts}`。
1722
+ * version の構成は `lastTurnTextsVersion()` が正本 (ここに具体値を書くとドリフトする)。
1723
+ *
1724
+ * tmux.list_sessions は数秒間隔で回るため、毎回全セッションの jsonl 末尾を読むと I/O が
1725
+ * 積み上がる。会話が進めば必ず version が変わるので、version 一致なら前回値を使い回す。
1726
+ */
1727
+ const lastTurnTextsCache = new Map()
1728
+
1729
+ /**
1730
+ * path 単位の in-flight 共有。複数ブラウザが同時に poll しても同じ jsonl を重複抽出しない。
1731
+ * key = jsonl path、value = `{version, promise}`。
1732
+ *
1733
+ * ⚠️ version を必ず持たせる。「どの版の抽出か」が分からないと、合流した caller が自分の
1734
+ * 観測版で結果を保存してしまい、古いテキストが恒久的に cache hit する (major#1 参照)。
1735
+ */
1736
+ const lastTurnTextsInFlight = new Map()
1737
+
1738
+ /** cold cache 時に全セッションを一斉に読まないための同時実行上限。 */
1739
+ const LAST_TEXTS_CONCURRENCY = 4
1740
+
1741
+ /** テスト用: module-global なキャッシュのテスト間汚染を防ぐ。 */
1742
+ export function clearLastTurnTextsCacheForTest() {
1743
+ lastTurnTextsCache.clear()
1744
+ lastTurnTextsInFlight.clear()
1745
+ }
1746
+
1747
+ /** GC: tmux から消えたセッションの jsonl は追跡しない (Map の線形増加を防ぐ)。 */
1748
+ function pruneLastTurnTextsCache(livePaths) {
1749
+ if (lastTurnTextsCache.size <= livePaths.size) return
1750
+ for (const k of lastTurnTextsCache.keys()) {
1751
+ if (!livePaths.has(k)) lastTurnTextsCache.delete(k)
1752
+ }
1753
+ }
1754
+
1755
+ /**
1756
+ * jsonl の版 (キャッシュ無効化キー)。
1757
+ *
1758
+ * mtime だけだと、分解能の粗い FS や「同一 ms 内の追記」で古いテキストを再利用しうる
1759
+ * (同リポの hydrate / last-turn キャッシュも mtime+size を使っている)。size と ctime も
1760
+ * 混ぜて、append / atomic replace の双方を検出できるようにする。
1761
+ */
1762
+ function lastTurnTextsVersion(st) {
1763
+ return `${st.mtimeMs}:${st.size}:${st.ctimeMs}`
1764
+ }
1765
+
1766
+ /**
1767
+ * `context_session_id` を jsonl のパス要素として使ってよいか検証する。
1768
+ *
1769
+ * この値は SDK resume 経路では browser → backend → agent と外部を通ってくるため、
1770
+ * `../` を含むと cwd の project dir の外を指しうる。新設の自動読み取り面なので、
1771
+ * パス要素として安全な形だけを通す (実在の Claude session id は UUID だが、将来の
1772
+ * 形式変更で壊れないよう「危険な形の排除」に留める)。
1773
+ */
1774
+ function isSafeSessionIdForPath(id) {
1775
+ if (!id || typeof id !== "string") return false
1776
+ if (id.length > 200) return false
1777
+ if (id === "." || id === "..") return false
1778
+ // パス区切り / NUL / 制御文字 / ドット始まりを排除。
1779
+ if (/[/\\\0]/.test(id)) return false
1780
+ // eslint-disable-next-line no-control-regex
1781
+ if (/[\x00-\x1f]/.test(id)) return false
1782
+ if (id.startsWith(".")) return false
1783
+ // 未 bind のプレースホルダ (jsonl がまだ存在しない)。
1784
+ if (id.startsWith("fresh:") || id.startsWith("unbound:")) return false
1785
+ return true
1786
+ }
1787
+
1788
+ /**
1789
+ * 各 tmux session の「最後の依頼 / 最後の応答」テキストを jsonl 末尾から読む。
1790
+ *
1791
+ * Cockpit サイドバーが行ごとに「何を依頼したか」を出すためのデータ源 (2026-07-28)。
1792
+ * frontend は選択中 1 セッションしかメッセージを保持しないため、一覧用は agent 側で
1793
+ * 供給するしかない。会話 ID は state loop の snapshot (`context_session_id`) を使う。
1794
+ *
1795
+ * 対象は **claude 行のみ**。codex はスレッド保存形式が異なるため対象外 (将来対応)。
1796
+ * `fresh:<request_id>` は jsonl 未生成のプレースホルダなのでスキップする。
1797
+ *
1798
+ * @param {Array} sessions listTmuxSessions の素の行 (name / cwd を使う)
1799
+ * @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
1800
+ * @returns {Promise<Map<string, {user: string, assistant: string}>>}
1801
+ */
1802
+ export async function readAllSessionLastTexts(
1803
+ sessions,
1804
+ getSnap,
1805
+ { extract = extractLastTurnTexts, statFn = stat, resolvePath = jsonlPath } = {},
1806
+ ) {
1807
+ const out = new Map()
1808
+ if (!getSnap) return out
1809
+ const livePaths = new Set()
1810
+
1811
+ const readOne = async (s) => {
1812
+ try {
1813
+ const snap = getSnap(s.name)
1814
+ if (!snap || snap.cli_kind !== "claude") return
1815
+ const sessionId = snap.context_session_id
1816
+ // パス要素として安全な形だけを通す (外部由来の値なので containment を局所保証)。
1817
+ if (!isSafeSessionIdForPath(sessionId)) return
1818
+ if (!s.cwd) return
1819
+ const fp = resolvePath({ cwd: s.cwd, session_id: sessionId })
1820
+ if (!fp) return
1821
+ livePaths.add(fp)
1822
+ const st = await statFn(fp)
1823
+ const version = lastTurnTextsVersion(st)
1824
+ const cached = lastTurnTextsCache.get(fp)
1825
+ if (cached && cached.version === version) {
1826
+ out.set(s.name, cached.texts)
1827
+ return
1828
+ }
1829
+ // 同一 path への同時抽出は 1 本に畳む (複数ブラウザの同時 poll 対策)。
1830
+ //
1831
+ // ⚠️ in-flight には「どの版の抽出か」を必ず持たせる (2026-07-28 クロスレビュー major#1)。
1832
+ // path だけで共有し、合流した caller が自分の観測版で結果を保存すると、
1833
+ // 「V1 の抽出中に V2 へ追記 → V2 を観測した caller が V1 の結果を {version: V2} で
1834
+ // 保存」となり、以降ファイルが動かない限り古いテキストが恒久的に cache hit する。
1835
+ let pending = lastTurnTextsInFlight.get(fp)
1836
+ if (!pending) {
1837
+ const entry = { version, promise: null }
1838
+ entry.promise = extract(fp).finally(() => {
1839
+ // 世代交代後に古い finally が新しい in-flight を消さないよう、
1840
+ // Map の現在値が自分自身のときだけ削除する。
1841
+ if (lastTurnTextsInFlight.get(fp) === entry) lastTurnTextsInFlight.delete(fp)
1842
+ })
1843
+ lastTurnTextsInFlight.set(fp, entry)
1844
+ pending = entry
1845
+ }
1846
+ const texts = await pending.promise
1847
+ out.set(s.name, texts)
1848
+ // cache に載せてよいのは「その抽出を開始した版」の caller だけ。別版を観測して
1849
+ // 合流した caller は表示にのみ使い、cache は汚さない (次回 poll で必ず再抽出)。
1850
+ if (pending.version === version) {
1851
+ // 抽出中に追記されていた場合、読んだ内容は既に古い可能性がある。開始時と同じ版の
1852
+ // ままだったときだけ cache する (追記中の結果を恒久 hit させないための回帰ガード)。
1853
+ let unchanged = false
1854
+ try {
1855
+ unchanged = lastTurnTextsVersion(await statFn(fp)) === version
1856
+ } catch {
1857
+ unchanged = false
1858
+ }
1859
+ if (unchanged) lastTurnTextsCache.set(fp, { version, texts })
1860
+ }
1861
+ } catch {
1862
+ // jsonl 不在 / 権限 / parse 失敗は「テキスト無し」に落とすだけ (一覧は出す)
1863
+ }
1864
+ }
1865
+
1866
+ // cold cache で数十セッションが一斉に上限走査に入ると I/O が集中するため、
1867
+ // 素の Promise.all ではなく固定並列度で流す。
1868
+ const queue = sessions.slice()
1869
+ const workers = Array.from(
1870
+ { length: Math.min(LAST_TEXTS_CONCURRENCY, queue.length) },
1871
+ async () => {
1872
+ for (;;) {
1873
+ const s = queue.shift()
1874
+ if (!s) return
1875
+ await readOne(s)
1876
+ }
1877
+ },
1878
+ )
1879
+ await Promise.all(workers)
1880
+
1881
+ pruneLastTurnTextsCache(livePaths)
1882
+ return out
1883
+ }
1884
+
1711
1885
  /**
1712
1886
  * 単一 session の最新 session-event をファイル正本から読む。無ければ null。
1713
1887
  * claude.tui.bind 時の `stop` 再配信 (取りこぼし回復) に使う。
@@ -1977,17 +2151,26 @@ const STALL_WARN_MS = Number(process.env.HUB_AGENT_STALL_WARN_MS ?? 90000)
1977
2151
  * @param {Map<string, object>} lastEventByName session_name → 最新 hook event
1978
2152
  * @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
1979
2153
  * accessor。undefined のときは 4 信号を一切付与しない(snapshot 経路 OFF と等価)
2154
+ * @param {Map<string, {user: string, assistant: string}> | undefined} lastTextsByName
2155
+ * session_name → jsonl 末尾の依頼/応答テキスト (サイドバー行の内容表示用)。
2156
+ * undefined / 未収録なら該当フィールドを載せない(古い cockpit は無視するので互換)
1980
2157
  */
1981
2158
  export function enrichTmuxSessionsForListResponse(
1982
2159
  sessions,
1983
2160
  lastEventByName,
1984
2161
  getSnap,
2162
+ lastTextsByName,
1985
2163
  ) {
1986
2164
  return sessions.map((s) => {
1987
2165
  const base = {
1988
2166
  ...s,
1989
2167
  last_event: lastEventByName.get(s.name) || null,
1990
2168
  }
2169
+ // サイドバー行に出す「何を依頼したか / 何が返ったか」。空文字は載せない
2170
+ // (frontend 側で「未取得」と「本当に空」を区別する必要が無いため)。
2171
+ const texts = lastTextsByName?.get(s.name)
2172
+ if (texts?.user) base.last_user_text = texts.user
2173
+ if (texts?.assistant) base.last_assistant_text = texts.assistant
1991
2174
  if (!getSnap) return base
1992
2175
  const snap = getSnap(s.name)
1993
2176
  if (!snap) return base
@@ -4556,10 +4739,17 @@ async function dispatch(msg, ctx) {
4556
4739
  // fs.watch 由来の session.event push は揮発性のため、frontend が新規
4557
4740
  // マウントすると過去 event を取れず全グレー表示になる事象を解消する。
4558
4741
  const lastEventByName = await readAllSessionEvents()
4742
+ // サイドバー行に「何を依頼したか」を出すため、各セッションの jsonl 末尾から
4743
+ // 最新の依頼 / 応答テキストを読む (mtime キャッシュ付き)。失敗は空に落ちる。
4744
+ const lastTextsByName = await readAllSessionLastTexts(
4745
+ sessions,
4746
+ ctx.getSessionStateSnapshot,
4747
+ )
4559
4748
  const enriched = enrichTmuxSessionsForListResponse(
4560
4749
  sessions,
4561
4750
  lastEventByName,
4562
4751
  ctx.getSessionStateSnapshot,
4752
+ lastTextsByName,
4563
4753
  )
4564
4754
  // cockpit (PR 1719) で未起動 worktree をサイドバーに可視化するために
4565
4755
  // filesystem 上は存在するが tmux session が無い worktree dir のリストを
package/src/usage.mjs CHANGED
@@ -20,6 +20,7 @@ import { promises as fs } from "node:fs"
20
20
  import { randomUUID } from "node:crypto"
21
21
  import os from "node:os"
22
22
  import path from "node:path"
23
+ import { StringDecoder } from "node:string_decoder"
23
24
 
24
25
  import { encodeCwdToDirName } from "./claude-history.mjs"
25
26
 
@@ -147,41 +148,6 @@ async function readTail(fp, maxBytes = 64 * 1024) {
147
148
  }
148
149
  }
149
150
 
150
- /**
151
- * ファイル先頭だけを読む (head)。readTail の対称。rollout の session_meta ヘッダー行
152
- * (先頭行) + 先頭近傍の最初の user_message (preview) を取り出すのに使う。maxBytes まで
153
- * 読み、末尾の不完全な行を捨てる (最後の改行以降を落とす)。size <= maxBytes なら全体。失敗時 null。
154
- */
155
- async function readHead(fp, maxBytes = 32 * 1024) {
156
- let fh
157
- try {
158
- fh = await fs.open(fp, "r")
159
- const st = await fh.stat()
160
- const size = st.size
161
- if (size === 0) return ""
162
- const len = size > maxBytes ? maxBytes : size
163
- const buf = Buffer.allocUnsafe(len)
164
- // ⚠️ bytesRead で切る (allocUnsafe は未初期化。並行 truncate 等で len 未満しか読めなかった
165
- // とき、残りのゴミ領域を文字列化しない。cross-review minor)。
166
- const { bytesRead } = await fh.read(buf, 0, len, 0)
167
- let text = buf.toString("utf-8", 0, bytesRead)
168
- // EOF まで読み切っていない (maxBytes で頭だけ / 途中 truncate) 場合、末尾の不完全な行を捨てる。
169
- if (bytesRead < size) {
170
- const nl = text.lastIndexOf("\n")
171
- text = nl >= 0 ? text.slice(0, nl) : text
172
- }
173
- return text
174
- } catch {
175
- return null
176
- } finally {
177
- try {
178
- await fh?.close()
179
- } catch {
180
- /* ignore */
181
- }
182
- }
183
- }
184
-
185
151
  /** P5(perf): latestJsonlContext の結果を {mtimeMs,size} キーでメモ化し再パースを避ける。 */
186
152
  const _jsonlCtxMemo = new Map() // fp → { mtimeMs, size, result }
187
153
 
@@ -1513,21 +1479,37 @@ function threadIdFromRolloutFilename(fp) {
1513
1479
  return m ? m[1] : ""
1514
1480
  }
1515
1481
 
1516
- /** rollout の先頭 (head) から一覧用メタ (thread_id / cwd / preview) を取り出す。
1517
- * header (session_meta) は先頭行、preview は先頭近傍の最初の user_message。null=読めない。 */
1518
- async function parseCodexRolloutHead(fp) {
1519
- const text = await readHead(fp, 32 * 1024)
1520
- if (text == null) return null
1482
+ const CODEX_ROLLOUT_HEAD_CHUNK_BYTES = 32 * 1024
1483
+ const CODEX_ROLLOUT_PREVIEW_MAX_BYTES = 1024 * 1024
1484
+
1485
+ /**
1486
+ * rollout の先頭から一覧用メタ (thread_id / cwd / preview) を取り出す。
1487
+ *
1488
+ * 現行 Codex は最初の user_message より前に system / developer / AGENTS コンテキストを
1489
+ * response_item として書くため、user_message が 200KB 以降に現れることがある。固定 32KB の
1490
+ * head 読みでは preview が常に空になったため、32KB 単位で最大 1MB まで段階的に読み進める。
1491
+ * ファイル全体は読まず、cwd が対象外と分かった時点、または preview が見つかった時点で止める。
1492
+ *
1493
+ * @param {string} fp
1494
+ * @param {string} expectedCwd cwd-scoped 一覧の対象。別 cwd は session_meta 読み取り後に早期終了。
1495
+ * @returns {Promise<{thread_id:string,cwd:string,preview:string}|null>}
1496
+ */
1497
+ async function parseCodexRolloutHead(fp, expectedCwd = "") {
1521
1498
  let thread_id = threadIdFromRolloutFilename(fp)
1522
1499
  let cwd = ""
1523
1500
  let preview = ""
1524
- for (const line of text.split("\n")) {
1525
- if (!line) continue
1501
+ let fh
1502
+ const decoder = new StringDecoder("utf8")
1503
+ let pending = ""
1504
+ let offset = 0
1505
+
1506
+ const parseLine = (line) => {
1507
+ if (!line) return
1526
1508
  let o
1527
1509
  try {
1528
1510
  o = JSON.parse(line)
1529
1511
  } catch {
1530
- continue
1512
+ return
1531
1513
  }
1532
1514
  if (o.type === "session_meta" && o.payload && typeof o.payload === "object") {
1533
1515
  if (typeof o.payload.cwd === "string") cwd = o.payload.cwd
@@ -1543,8 +1525,58 @@ async function parseCodexRolloutHead(fp) {
1543
1525
  ) {
1544
1526
  preview = o.payload.message.slice(0, 80)
1545
1527
  }
1546
- if (cwd && preview) break // 両方揃ったら以降は読まない (先頭近傍で足りる)。
1547
1528
  }
1529
+
1530
+ try {
1531
+ fh = await fs.open(fp, "r")
1532
+ while (offset < CODEX_ROLLOUT_PREVIEW_MAX_BYTES) {
1533
+ const readSize = Math.min(
1534
+ CODEX_ROLLOUT_HEAD_CHUNK_BYTES,
1535
+ CODEX_ROLLOUT_PREVIEW_MAX_BYTES - offset,
1536
+ )
1537
+ const buf = Buffer.allocUnsafe(readSize)
1538
+ const { bytesRead } = await fh.read(buf, 0, readSize, offset)
1539
+ if (bytesRead === 0) {
1540
+ pending += decoder.end()
1541
+ if (pending) parseLine(pending)
1542
+ break
1543
+ }
1544
+ offset += bytesRead
1545
+ pending += decoder.write(buf.subarray(0, bytesRead))
1546
+
1547
+ let newline
1548
+ while ((newline = pending.indexOf("\n")) >= 0) {
1549
+ const line = pending.slice(0, newline)
1550
+ pending = pending.slice(newline + 1)
1551
+ parseLine(line)
1552
+ // session_meta は先頭行にある。別 workspace の候補に対して巨大な初期コンテキストを
1553
+ // 読み進めないことで、maxScan が大きい場合も一覧走査コストを抑える。
1554
+ if (cwd && expectedCwd && cwd !== expectedCwd) {
1555
+ return thread_id ? { thread_id, cwd, preview } : null
1556
+ }
1557
+ if (cwd && preview) {
1558
+ return thread_id ? { thread_id, cwd, preview } : null
1559
+ }
1560
+ }
1561
+
1562
+ // 通常ファイルの EOF。decoder.end() で分割された UTF-8 の末尾を確定し、改行なしの
1563
+ // 最終行も一度だけ解析する。
1564
+ if (bytesRead < readSize) {
1565
+ pending += decoder.end()
1566
+ if (pending) parseLine(pending)
1567
+ break
1568
+ }
1569
+ }
1570
+ } catch {
1571
+ return null
1572
+ } finally {
1573
+ try {
1574
+ await fh?.close()
1575
+ } catch {
1576
+ /* ignore */
1577
+ }
1578
+ }
1579
+
1548
1580
  if (!thread_id) return null
1549
1581
  return { thread_id, cwd, preview }
1550
1582
  }
@@ -1610,7 +1642,7 @@ export async function listCodexThreadsFromDisk({
1610
1642
  if (limit && out.length >= limit) break
1611
1643
  if (opened >= maxScan) break
1612
1644
  opened++
1613
- const meta = await parseCodexRolloutHead(fp)
1645
+ const meta = await parseCodexRolloutHead(fp, cwd)
1614
1646
  if (!meta) continue
1615
1647
  // header の cwd が一致するものだけ (cwd 未読/不一致は除外 = 無関係スレッドの混入防止)。
1616
1648
  if (meta.cwd !== cwd) continue