@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 +1 -1
- package/src/claude-history.mjs +24 -3
- package/src/codex-stream-bridge.mjs +5 -0
- package/src/main.mjs +64 -262
- package/src/sidebar-message-snapshot.mjs +362 -0
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -664,7 +664,13 @@ function takeTextsFromLine(line, user, assistant) {
|
|
|
664
664
|
return { user, assistant }
|
|
665
665
|
}
|
|
666
666
|
|
|
667
|
-
|
|
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
|
|
1446
|
-
*
|
|
1447
|
-
*
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
1450
|
-
*
|
|
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}
|
|
1458
|
+
* @param {boolean} _sawUserTurn 後方互換で受けるが未使用 (ターン終了を効かせないため)
|
|
1453
1459
|
* @returns {boolean}
|
|
1454
1460
|
*/
|
|
1455
|
-
export function computeSortAffecting(event,
|
|
1456
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|