@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 +1 -1
- package/src/claude-history.mjs +198 -0
- package/src/main.mjs +191 -1
- package/src/usage.mjs +77 -45
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
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
|
-
|
|
1525
|
-
|
|
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
|
-
|
|
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
|