@cocorograph/hub-agent 0.8.9 → 0.8.11
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/claude-stream-bridge.mjs +68 -42
- package/src/codex-stream-bridge.mjs +223 -142
- package/src/main.mjs +93 -314
- package/src/sidebar-message-snapshot.mjs +362 -0
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
|
// (再同期は固着を直すための経路なので、詰まりやすい経路に置いてはならない)。
|
|
@@ -854,8 +853,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
854
853
|
// ドーナツ) と session.event (ソート) に橋渡しする。チャットは tmux ペインに
|
|
855
854
|
// TUI を出さず、capture-pane スクレイプも bundle hook も発火しないための補完。
|
|
856
855
|
// - assistant (生成中) → processing + usage から context%
|
|
857
|
-
// -
|
|
858
|
-
//
|
|
856
|
+
// - session_state_changed:idle → waiting
|
|
857
|
+
// result は結果 flush であり、SDK が権威的ターン終了と定義する idle より早く届く。
|
|
858
|
+
// assistant / idle はターンの進行/完了 = permission 応答後に必ず流れるため、
|
|
859
859
|
// ここで inputPending を落として「確認待ち」ドット/通知を自己解消させる。
|
|
860
860
|
if (event?.type === "assistant") {
|
|
861
861
|
// 分母は result で学習したセッション固有の窓サイズ (無ければグローバル概算)。
|
|
@@ -877,6 +877,11 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
877
877
|
} else if (event?.type === "result") {
|
|
878
878
|
// SDK の権威的な窓サイズ (result.modelUsage[model].contextWindow) をここで学習する。
|
|
879
879
|
resolveChatContextPct(cwd, "claude", event)
|
|
880
|
+
} else if (
|
|
881
|
+
event?.type === "system" &&
|
|
882
|
+
event?.subtype === "session_state_changed" &&
|
|
883
|
+
event?.state === "idle"
|
|
884
|
+
) {
|
|
880
885
|
try {
|
|
881
886
|
recordChatActivity(cwd, "claude", {
|
|
882
887
|
status: "waiting",
|
|
@@ -1442,23 +1447,25 @@ const lastSortAffectingAtByName = new Map()
|
|
|
1442
1447
|
const lastEventAtByName = new Map()
|
|
1443
1448
|
|
|
1444
1449
|
/**
|
|
1445
|
-
* session-event
|
|
1446
|
-
*
|
|
1447
|
-
*
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
1450
|
-
*
|
|
1450
|
+
* session-event の種別から sort_affecting を導出する純関数。
|
|
1451
|
+
*
|
|
1452
|
+
* **並び順が動くのはユーザーがメッセージを送った瞬間 (prompt_submit) だけ**。
|
|
1453
|
+
* ターン終了 (stop) やアイドル系はソートに効かせない。
|
|
1454
|
+
*
|
|
1455
|
+
* ⚠️ 旧仕様は stop/ready/idle_hint を「直前に prompt_submit があれば sort_affecting」に
|
|
1456
|
+
* していたため、**ターンが終わるたびにサイドバーの行が並び替わり、レイアウトシフトが起きた**
|
|
1457
|
+
* (2026-07-29 ユーザー報告)。並び順の意味を「最後に人間が送った順」に一本化するため、
|
|
1458
|
+
* ターン終了はソートへ影響させない (状態表示=ドット側は従来どおり別軸で更新される)。
|
|
1459
|
+
*
|
|
1460
|
+
* - prompt_submit: true (ユーザーがターンを開始した = 並び順の唯一の発火源)
|
|
1461
|
+
* - それ以外 (stop / ready / idle_hint / session_start / session_end / 未知種別): false
|
|
1462
|
+
*
|
|
1451
1463
|
* @param {string} event
|
|
1452
|
-
* @param {boolean}
|
|
1464
|
+
* @param {boolean} _sawUserTurn 後方互換で受けるが未使用 (ターン終了を効かせないため)
|
|
1453
1465
|
* @returns {boolean}
|
|
1454
1466
|
*/
|
|
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
|
|
1467
|
+
export function computeSortAffecting(event, _sawUserTurn) {
|
|
1468
|
+
return event === "prompt_submit"
|
|
1462
1469
|
}
|
|
1463
1470
|
|
|
1464
1471
|
/**
|
|
@@ -1701,259 +1708,11 @@ async function readAllSessionEvents() {
|
|
|
1701
1708
|
return out
|
|
1702
1709
|
}
|
|
1703
1710
|
|
|
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
1711
|
// 複数 Cockpit タブは各自 8 秒 poll を持つため、位相がずれると agent は同じ
|
|
1723
1712
|
// tmux/worktree/jsonl スナップショットを数秒おきに再構築してしまう。5 秒だけ成功値を
|
|
1724
1713
|
// 共有し、同時要求は single-flight に畳む。create/kill/worktree 変更時は明示 clear する。
|
|
1725
1714
|
const tmuxSessionsSnapshotCache = new AsyncTtlCache({ ttlMs: 5_000 })
|
|
1726
1715
|
|
|
1727
|
-
/** cold cache 時に全セッションを一斉に読まないための同時実行上限。 */
|
|
1728
|
-
const LAST_TEXTS_CONCURRENCY = 4
|
|
1729
|
-
|
|
1730
|
-
/**
|
|
1731
|
-
* live bridge に存在しない workspace 行向けの低頻度 fallback。
|
|
1732
|
-
*
|
|
1733
|
-
* Cockpit は選択中でない workspace の Claude stream を常に attach しているわけではない。
|
|
1734
|
-
* hub-agent 再起動後、その行には state snapshot も live session ID も無いため、cwd 配下の
|
|
1735
|
-
* 会話履歴を「最後の実ターン時刻」順で 1 件だけ解決する。`listSessions` は mtime ではなく
|
|
1736
|
-
* JSONL 内の user/assistant timestamp を正本にするので、古い会話を閲覧しただけの mtime
|
|
1737
|
-
* 上昇では選択が入れ替わらない。
|
|
1738
|
-
*
|
|
1739
|
-
* 全 sidebar poll で全 cwd を再走査しないよう、成功/空のどちらも 60 秒だけ共有する。
|
|
1740
|
-
* 選択中の行は live bridge ID が先に選ばれるため、この cache の遅延は影響しない。
|
|
1741
|
-
*/
|
|
1742
|
-
const sidebarLatestSessionIdCache = new Map()
|
|
1743
|
-
const SIDEBAR_LATEST_SESSION_ID_TTL_MS = 60_000
|
|
1744
|
-
|
|
1745
|
-
async function latestClaudeSessionIdForSidebar(cwd, logger) {
|
|
1746
|
-
if (!cwd) return null
|
|
1747
|
-
const now = Date.now()
|
|
1748
|
-
const cached = sidebarLatestSessionIdCache.get(cwd)
|
|
1749
|
-
if (cached && now - cached.at < SIDEBAR_LATEST_SESSION_ID_TTL_MS) {
|
|
1750
|
-
return cached.promise
|
|
1751
|
-
}
|
|
1752
|
-
const entry = {
|
|
1753
|
-
at: now,
|
|
1754
|
-
promise: listSessions({ cwd, limit: 1, logger })
|
|
1755
|
-
.then((result) => result.sessions?.[0]?.session_id ?? null)
|
|
1756
|
-
.catch(() => null),
|
|
1757
|
-
}
|
|
1758
|
-
sidebarLatestSessionIdCache.set(cwd, entry)
|
|
1759
|
-
return entry.promise
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
/** テスト用: module-global なキャッシュのテスト間汚染を防ぐ。 */
|
|
1763
|
-
export function clearLastTurnTextsCacheForTest() {
|
|
1764
|
-
lastTurnTextsCache.clear()
|
|
1765
|
-
lastTurnTextsInFlight.clear()
|
|
1766
|
-
sidebarLatestSessionIdCache.clear()
|
|
1767
|
-
}
|
|
1768
|
-
|
|
1769
|
-
/** GC: tmux から消えたセッションの jsonl は追跡しない (Map の線形増加を防ぐ)。 */
|
|
1770
|
-
function pruneLastTurnTextsCache(livePaths) {
|
|
1771
|
-
if (lastTurnTextsCache.size <= livePaths.size) return
|
|
1772
|
-
for (const k of lastTurnTextsCache.keys()) {
|
|
1773
|
-
if (!livePaths.has(k)) lastTurnTextsCache.delete(k)
|
|
1774
|
-
}
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
/**
|
|
1778
|
-
* jsonl の版 (キャッシュ無効化キー)。
|
|
1779
|
-
*
|
|
1780
|
-
* mtime だけだと、分解能の粗い FS や「同一 ms 内の追記」で古いテキストを再利用しうる
|
|
1781
|
-
* (同リポの hydrate / last-turn キャッシュも mtime+size を使っている)。size と ctime も
|
|
1782
|
-
* 混ぜて、append / atomic replace の双方を検出できるようにする。
|
|
1783
|
-
*/
|
|
1784
|
-
function lastTurnTextsVersion(st) {
|
|
1785
|
-
return `${st.mtimeMs}:${st.size}:${st.ctimeMs}`
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
/**
|
|
1789
|
-
* `context_session_id` を jsonl のパス要素として使ってよいか検証する。
|
|
1790
|
-
*
|
|
1791
|
-
* この値は SDK resume 経路では browser → backend → agent と外部を通ってくるため、
|
|
1792
|
-
* `../` を含むと cwd の project dir の外を指しうる。新設の自動読み取り面なので、
|
|
1793
|
-
* パス要素として安全な形だけを通す (実在の Claude session id は UUID だが、将来の
|
|
1794
|
-
* 形式変更で壊れないよう「危険な形の排除」に留める)。
|
|
1795
|
-
*/
|
|
1796
|
-
function isSafeSessionIdForPath(id) {
|
|
1797
|
-
if (!id || typeof id !== "string") return false
|
|
1798
|
-
if (id.length > 200) return false
|
|
1799
|
-
if (id === "." || id === "..") return false
|
|
1800
|
-
// パス区切り / NUL / 制御文字 / ドット始まりを排除。
|
|
1801
|
-
if (/[/\\\0]/.test(id)) return false
|
|
1802
|
-
// eslint-disable-next-line no-control-regex
|
|
1803
|
-
if (/[\x00-\x1f]/.test(id)) return false
|
|
1804
|
-
if (id.startsWith(".")) return false
|
|
1805
|
-
// 未 bind のプレースホルダ (jsonl がまだ存在しない)。
|
|
1806
|
-
if (id.startsWith("fresh:") || id.startsWith("unbound:")) return false
|
|
1807
|
-
return true
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
/**
|
|
1811
|
-
* 各 tmux session の「最後の依頼 / 最後の応答」テキストを jsonl 末尾から読む。
|
|
1812
|
-
*
|
|
1813
|
-
* Cockpit サイドバーが行ごとに「何を依頼したか」を出すためのデータ源 (2026-07-28)。
|
|
1814
|
-
* frontend は選択中 1 セッションしかメッセージを保持しないため、一覧用は agent 側で
|
|
1815
|
-
* 供給するしかない。会話 ID は state loop の snapshot (`context_session_id`) を優先し、
|
|
1816
|
-
* snapshot / live bridge のどちらにも無ければ cwd の最新実ターン履歴へフォールバックする。
|
|
1817
|
-
*
|
|
1818
|
-
* 対象は **claude 行のみ**。codex はスレッド保存形式が異なるため対象外 (将来対応)。
|
|
1819
|
-
* `fresh:<request_id>` は jsonl 未生成のプレースホルダなのでスキップする。
|
|
1820
|
-
*
|
|
1821
|
-
* @param {Array} sessions listTmuxSessions の素の行 (name / cwd を使う)
|
|
1822
|
-
* @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
|
|
1823
|
-
* @param {object} [options] I/O と session ID 解決の注入点
|
|
1824
|
-
* @param {((session: object) => string | null | Promise<string | null>) | undefined}
|
|
1825
|
-
* [options.resolveFallbackSessionId]
|
|
1826
|
-
* snapshot の context_session_id が再起動後の `unbound:*` のとき、TUI bind / live
|
|
1827
|
-
* Claude bridge または履歴一覧から同じ cwd の会話 ID を復元する。
|
|
1828
|
-
* 戻り値にも containment 検査を行う。
|
|
1829
|
-
* @returns {Promise<Map<string, {user: string, assistant: string}>>}
|
|
1830
|
-
*/
|
|
1831
|
-
export async function readAllSessionLastTexts(
|
|
1832
|
-
sessions,
|
|
1833
|
-
getSnap,
|
|
1834
|
-
{
|
|
1835
|
-
extract = extractLastTurnTexts,
|
|
1836
|
-
statFn = stat,
|
|
1837
|
-
resolvePath = jsonlPath,
|
|
1838
|
-
resolveFallbackSessionId,
|
|
1839
|
-
} = {},
|
|
1840
|
-
) {
|
|
1841
|
-
const out = new Map()
|
|
1842
|
-
if (!getSnap) return out
|
|
1843
|
-
const livePaths = new Set()
|
|
1844
|
-
|
|
1845
|
-
const readOne = async (s) => {
|
|
1846
|
-
try {
|
|
1847
|
-
const snap = getSnap(s.name)
|
|
1848
|
-
// CLI 種別の正本はセッション名 (`__codex` suffix) であり、state snapshot ではない。
|
|
1849
|
-
// 再起動直後は tmux の一覧行が先に復元され、state loop の初回 tick (<5s) より前は
|
|
1850
|
-
// snapshot 自体がまだ無い。この cold window で snapshot を必須にすると、live Claude
|
|
1851
|
-
// session ID は既に復元済みでも最後の依頼/応答だけが空のまま返る。
|
|
1852
|
-
if (cliKindForSessionName(s.name) !== "claude") return
|
|
1853
|
-
let sessionId = snap?.context_session_id
|
|
1854
|
-
let usedFallback = false
|
|
1855
|
-
// hub-agent 再起動で in-memory の TUI bind が消えると、state loop の snapshot は
|
|
1856
|
-
// `unbound:<session>` へ落ちる。一方、Claude bridge の warm/live session は cwd
|
|
1857
|
-
// から同じ会話 ID を復元できる。ここでフォールバックしないと、再起動後に全行の
|
|
1858
|
-
// 「最後の依頼 / 応答」だけが消え、次に chat signal が fresh になるまで戻らない。
|
|
1859
|
-
//
|
|
1860
|
-
// snapshot が安全な実 ID を持つ場合は必ずそちらを優先し、fallback は placeholder /
|
|
1861
|
-
// 危険値の場合だけ使う。fallback も同じ containment 検査を通してから path 化する。
|
|
1862
|
-
if (!isSafeSessionIdForPath(sessionId) && resolveFallbackSessionId) {
|
|
1863
|
-
sessionId = await resolveFallbackSessionId(s)
|
|
1864
|
-
usedFallback = true
|
|
1865
|
-
}
|
|
1866
|
-
// パス要素として安全な形だけを通す (外部由来の値なので containment を局所保証)。
|
|
1867
|
-
if (!isSafeSessionIdForPath(sessionId)) return
|
|
1868
|
-
if (!s.cwd) return
|
|
1869
|
-
let fp = resolvePath({ cwd: s.cwd, session_id: sessionId })
|
|
1870
|
-
if (!fp) return
|
|
1871
|
-
let st
|
|
1872
|
-
try {
|
|
1873
|
-
st = await statFn(fp)
|
|
1874
|
-
} catch {
|
|
1875
|
-
// 再起動直後は snapshot に構文上は安全でも既に失効した session ID が残ることがある。
|
|
1876
|
-
// 0.8.7 は unbound/fresh だけを live session へ切り替えていたため、この場合は stat
|
|
1877
|
-
// 失敗を外側 catch が握りつぶし、サイドバーの依頼/応答だけが空のままになった。
|
|
1878
|
-
// primary path が読めない場合に限って、現在の cwd-scoped live session ID を再解決する。
|
|
1879
|
-
// snapshot path が実在する通常系では従来どおり snapshot を優先する。
|
|
1880
|
-
if (usedFallback || !resolveFallbackSessionId) return
|
|
1881
|
-
const fallbackId = await resolveFallbackSessionId(s)
|
|
1882
|
-
if (!isSafeSessionIdForPath(fallbackId) || fallbackId === sessionId) return
|
|
1883
|
-
const fallbackPath = resolvePath({ cwd: s.cwd, session_id: fallbackId })
|
|
1884
|
-
if (!fallbackPath) return
|
|
1885
|
-
try {
|
|
1886
|
-
st = await statFn(fallbackPath)
|
|
1887
|
-
} catch {
|
|
1888
|
-
return
|
|
1889
|
-
}
|
|
1890
|
-
sessionId = fallbackId
|
|
1891
|
-
fp = fallbackPath
|
|
1892
|
-
usedFallback = true
|
|
1893
|
-
}
|
|
1894
|
-
livePaths.add(fp)
|
|
1895
|
-
const version = lastTurnTextsVersion(st)
|
|
1896
|
-
const cached = lastTurnTextsCache.get(fp)
|
|
1897
|
-
if (cached && cached.version === version) {
|
|
1898
|
-
out.set(s.name, cached.texts)
|
|
1899
|
-
return
|
|
1900
|
-
}
|
|
1901
|
-
// 同一 path への同時抽出は 1 本に畳む (複数ブラウザの同時 poll 対策)。
|
|
1902
|
-
//
|
|
1903
|
-
// ⚠️ in-flight には「どの版の抽出か」を必ず持たせる (2026-07-28 クロスレビュー major#1)。
|
|
1904
|
-
// path だけで共有し、合流した caller が自分の観測版で結果を保存すると、
|
|
1905
|
-
// 「V1 の抽出中に V2 へ追記 → V2 を観測した caller が V1 の結果を {version: V2} で
|
|
1906
|
-
// 保存」となり、以降ファイルが動かない限り古いテキストが恒久的に cache hit する。
|
|
1907
|
-
let pending = lastTurnTextsInFlight.get(fp)
|
|
1908
|
-
if (!pending) {
|
|
1909
|
-
const entry = { version, promise: null }
|
|
1910
|
-
entry.promise = extract(fp).finally(() => {
|
|
1911
|
-
// 世代交代後に古い finally が新しい in-flight を消さないよう、
|
|
1912
|
-
// Map の現在値が自分自身のときだけ削除する。
|
|
1913
|
-
if (lastTurnTextsInFlight.get(fp) === entry) lastTurnTextsInFlight.delete(fp)
|
|
1914
|
-
})
|
|
1915
|
-
lastTurnTextsInFlight.set(fp, entry)
|
|
1916
|
-
pending = entry
|
|
1917
|
-
}
|
|
1918
|
-
const texts = await pending.promise
|
|
1919
|
-
out.set(s.name, texts)
|
|
1920
|
-
// cache に載せてよいのは「その抽出を開始した版」の caller だけ。別版を観測して
|
|
1921
|
-
// 合流した caller は表示にのみ使い、cache は汚さない (次回 poll で必ず再抽出)。
|
|
1922
|
-
if (pending.version === version) {
|
|
1923
|
-
// 抽出中に追記されていた場合、読んだ内容は既に古い可能性がある。開始時と同じ版の
|
|
1924
|
-
// ままだったときだけ cache する (追記中の結果を恒久 hit させないための回帰ガード)。
|
|
1925
|
-
let unchanged = false
|
|
1926
|
-
try {
|
|
1927
|
-
unchanged = lastTurnTextsVersion(await statFn(fp)) === version
|
|
1928
|
-
} catch {
|
|
1929
|
-
unchanged = false
|
|
1930
|
-
}
|
|
1931
|
-
if (unchanged) lastTurnTextsCache.set(fp, { version, texts })
|
|
1932
|
-
}
|
|
1933
|
-
} catch {
|
|
1934
|
-
// jsonl 不在 / 権限 / parse 失敗は「テキスト無し」に落とすだけ (一覧は出す)
|
|
1935
|
-
}
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
|
-
// cold cache で数十セッションが一斉に上限走査に入ると I/O が集中するため、
|
|
1939
|
-
// 素の Promise.all ではなく固定並列度で流す。
|
|
1940
|
-
const queue = sessions.slice()
|
|
1941
|
-
const workers = Array.from(
|
|
1942
|
-
{ length: Math.min(LAST_TEXTS_CONCURRENCY, queue.length) },
|
|
1943
|
-
async () => {
|
|
1944
|
-
for (;;) {
|
|
1945
|
-
const s = queue.shift()
|
|
1946
|
-
if (!s) return
|
|
1947
|
-
await readOne(s)
|
|
1948
|
-
}
|
|
1949
|
-
},
|
|
1950
|
-
)
|
|
1951
|
-
await Promise.all(workers)
|
|
1952
|
-
|
|
1953
|
-
pruneLastTurnTextsCache(livePaths)
|
|
1954
|
-
return out
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
1716
|
/**
|
|
1958
1717
|
* 単一 session の最新 session-event をファイル正本から読む。無ければ null。
|
|
1959
1718
|
* claude.tui.bind 時の `stop` 再配信 (取りこぼし回復) に使う。
|
|
@@ -2226,26 +1985,13 @@ const STALL_WARN_MS = Number(process.env.HUB_AGENT_STALL_WARN_MS ?? 90000)
|
|
|
2226
1985
|
* @param {Map<string, object>} lastEventByName session_name → 最新 hook event
|
|
2227
1986
|
* @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
|
|
2228
1987
|
* accessor。undefined のときは 4 信号を一切付与しない(snapshot 経路 OFF と等価)
|
|
2229
|
-
* @param {Map<string, {user: string, assistant: string}> | undefined} lastTextsByName
|
|
2230
|
-
* session_name → jsonl 末尾の依頼/応答テキスト (サイドバー行の内容表示用)。
|
|
2231
|
-
* undefined / 未収録なら該当フィールドを載せない(古い cockpit は無視するので互換)
|
|
2232
1988
|
*/
|
|
2233
|
-
export function enrichTmuxSessionsForListResponse(
|
|
2234
|
-
sessions,
|
|
2235
|
-
lastEventByName,
|
|
2236
|
-
getSnap,
|
|
2237
|
-
lastTextsByName,
|
|
2238
|
-
) {
|
|
1989
|
+
export function enrichTmuxSessionsForListResponse(sessions, lastEventByName, getSnap) {
|
|
2239
1990
|
return sessions.map((s) => {
|
|
2240
1991
|
const base = {
|
|
2241
1992
|
...s,
|
|
2242
1993
|
last_event: lastEventByName.get(s.name) || null,
|
|
2243
1994
|
}
|
|
2244
|
-
// サイドバー行に出す「何を依頼したか / 何が返ったか」。空文字は載せない
|
|
2245
|
-
// (frontend 側で「未取得」と「本当に空」を区別する必要が無いため)。
|
|
2246
|
-
const texts = lastTextsByName?.get(s.name)
|
|
2247
|
-
if (texts?.user) base.last_user_text = texts.user
|
|
2248
|
-
if (texts?.assistant) base.last_assistant_text = texts.assistant
|
|
2249
1995
|
if (!getSnap) return base
|
|
2250
1996
|
const snap = getSnap(s.name)
|
|
2251
1997
|
if (!snap) return base
|
|
@@ -2460,6 +2206,9 @@ export function decideTurnActiveSource({
|
|
|
2460
2206
|
export function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, codexBridge, readinessTracker, boundSessions }) {
|
|
2461
2207
|
const lastByName = new Map() // session_name → {status, context_pct}
|
|
2462
2208
|
const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
|
|
2209
|
+
// session_name → 最後に書いた chat 由来 event 種別。エッジ駆動 (種別遷移時のみ書く) にして
|
|
2210
|
+
// 生成中の prompt_submit 連打を防ぐ (並行生成でサイドバーが揺れる原因)。
|
|
2211
|
+
const lastChatEventByName = new Map()
|
|
2463
2212
|
// (i) session_name → {sig, changedAt}: 出力領域署名の最終変化時刻 (出力フロー検知用)。
|
|
2464
2213
|
const outputFlowByName = new Map()
|
|
2465
2214
|
// 権威的 stall 判定 (arm 非依存・cap 非依存)。state loop が観測する signal だけで turn の開閉と
|
|
@@ -2535,19 +2284,27 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2535
2284
|
let status = overlay.status
|
|
2536
2285
|
let contextPct = overlay.contextPct
|
|
2537
2286
|
if (chat) {
|
|
2538
|
-
//
|
|
2287
|
+
// チャットのターン境界を sort / 状態用 session-event に橋渡しする。
|
|
2539
2288
|
// tmux ペインが動かず bundle hook が発火しないため、ここで代替発火する。
|
|
2289
|
+
//
|
|
2290
|
+
// ⚠️ **エッジ駆動** (種別が変わったときだけ書く)。旧実装は turnAt が前進するたびに
|
|
2291
|
+
// 書いており、turnAt は assistant イベント毎に前進するため **生成中ずっと
|
|
2292
|
+
// prompt_submit を書き続けていた**。並行生成中のセッション同士が assistant の
|
|
2293
|
+
// 到着順で入れ替わり、サイドバーが揺れる原因になる (2026-07-29)。
|
|
2294
|
+
// 種別遷移でのみ書けば、prompt_submit は生成開始の 1 回だけになる。
|
|
2295
|
+
//
|
|
2296
|
+
// 確認待ち (inputPending) は専用 event "input_required"、生成中は "prompt_submit"、
|
|
2297
|
+
// それ以外は "stop"。並び順に効くのは computeSortAffecting により prompt_submit だけ。
|
|
2298
|
+
const ev = chat.inputPending
|
|
2299
|
+
? "input_required"
|
|
2300
|
+
: chat.status === "processing"
|
|
2301
|
+
? "prompt_submit"
|
|
2302
|
+
: "stop"
|
|
2540
2303
|
const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
|
|
2541
|
-
|
|
2304
|
+
const prevEv = lastChatEventByName.get(s.session_name)
|
|
2305
|
+
if (chat.turnAt > prevTurnAt && ev !== prevEv) {
|
|
2542
2306
|
lastTurnAtByName.set(s.session_name, chat.turnAt)
|
|
2543
|
-
|
|
2544
|
-
// 「確認待ち」ドット & 通知に橋渡しする。それ以外は従来通り processing →
|
|
2545
|
-
// prompt_submit / それ以外 → stop。
|
|
2546
|
-
const ev = chat.inputPending
|
|
2547
|
-
? "input_required"
|
|
2548
|
-
: chat.status === "processing"
|
|
2549
|
-
? "prompt_submit"
|
|
2550
|
-
: "stop"
|
|
2307
|
+
lastChatEventByName.set(s.session_name, ev)
|
|
2551
2308
|
writeSessionEventFile(s.session_name, ev, chat.turnAt).catch(() => {})
|
|
2552
2309
|
}
|
|
2553
2310
|
}
|
|
@@ -3383,8 +3140,8 @@ export function buildAttachHistoryPromise({
|
|
|
3383
3140
|
/**
|
|
3384
3141
|
* SDK チャット配達保証 (S2, 2026-07-05): `codex.input` を処理し、browser が付与した
|
|
3385
3142
|
* `input_id` があれば `codex.input.ack` / `codex.input.nack` を返す (claude.input と
|
|
3386
|
-
* 対称)
|
|
3387
|
-
*
|
|
3143
|
+
* 対称)。idle 入力は turn/start の受理、生成中入力は hub-agent FIFO への受理をもって
|
|
3144
|
+
* ack する。FIFO の実配送は matching turn/completed 後に bridge が行う。
|
|
3388
3145
|
* 既存の `codex.error` 通知も維持する (input_id を知らない旧 frontend や、他の消費者向け)。
|
|
3389
3146
|
* テストから直接呼べるよう dispatch から切り出して export する。
|
|
3390
3147
|
*/
|
|
@@ -3421,6 +3178,7 @@ export function handleCodexChatInput(msg, ctx) {
|
|
|
3421
3178
|
message: msg.message,
|
|
3422
3179
|
model: msg.model || undefined,
|
|
3423
3180
|
effort: msg.effort || undefined,
|
|
3181
|
+
inputId: input_id || undefined,
|
|
3424
3182
|
})
|
|
3425
3183
|
.then(() => {
|
|
3426
3184
|
if (!input_id) return
|
|
@@ -4186,6 +3944,9 @@ async function dispatch(msg, ctx) {
|
|
|
4186
3944
|
turn_active: info.turnActive === true,
|
|
4187
3945
|
thread_status: info.threadStatus || null,
|
|
4188
3946
|
last_turn_status: info.lastTurnStatus || null,
|
|
3947
|
+
// Codex の通常送信 FIFO。ready と同じ封筒に初期 snapshot を載せ、
|
|
3948
|
+
// reattach / reload でも送信待ち表示を即時復元する。
|
|
3949
|
+
queue_state: ctx.codexBridge.queueState(stream_id),
|
|
4189
3950
|
})
|
|
4190
3951
|
// S1 (2026-07): claude.attach と対称の identity 正本化。Codex は thread/start
|
|
4191
3952
|
// 応答時点で threadId が確定するため (Claude の fresh とは異なり)、fresh でも
|
|
@@ -4322,15 +4083,23 @@ async function dispatch(msg, ctx) {
|
|
|
4322
4083
|
return
|
|
4323
4084
|
}
|
|
4324
4085
|
case "codex.input":
|
|
4325
|
-
//
|
|
4326
|
-
//
|
|
4327
|
-
// codex-stream-bridge.mjs 側で無視され次の非 busy な input() から効く。
|
|
4328
|
-
// S2/S3 統合 (2026-07-05): S3 の自己修復 (turn/steer 失敗 → turn/start 自動
|
|
4329
|
-
// リトライ) が付与する err.reason ('timeout' / 'turn_mismatch_retry_failed') を
|
|
4330
|
-
// classifyCodexInputError が優先採用し、input_id 付きなら codex.input.nack の
|
|
4331
|
-
// reason としてそのまま使う (handleCodexChatInput 内)。
|
|
4086
|
+
// 通常送信は idle なら turn/start、busy なら hub-agent の次ターン FIFO。
|
|
4087
|
+
// turn/steer / turn/interrupt は明示操作の別経路として扱う。
|
|
4332
4088
|
handleCodexChatInput(msg, ctx)
|
|
4333
4089
|
return
|
|
4090
|
+
case "codex.queue.cancel":
|
|
4091
|
+
try {
|
|
4092
|
+
ctx.codexBridge?.cancelQueued({
|
|
4093
|
+
stream_id: msg.stream_id,
|
|
4094
|
+
id: msg.id,
|
|
4095
|
+
})
|
|
4096
|
+
} catch (err) {
|
|
4097
|
+
ctx.logger?.warn?.(
|
|
4098
|
+
{ err: err?.message, stream_id: msg.stream_id, input_id: msg.id },
|
|
4099
|
+
"codex queued input cancel failed",
|
|
4100
|
+
)
|
|
4101
|
+
}
|
|
4102
|
+
return
|
|
4334
4103
|
case "codex.permission.reply":
|
|
4335
4104
|
if (!ctx.codexBridge) return
|
|
4336
4105
|
ctx.codexBridge.permissionReply({
|
|
@@ -4820,24 +4589,11 @@ async function dispatch(msg, ctx) {
|
|
|
4820
4589
|
claudeBridge: ctx.claudeBridge,
|
|
4821
4590
|
worktreeIndex,
|
|
4822
4591
|
})
|
|
4823
|
-
const
|
|
4824
|
-
readAllSessionEvents(),
|
|
4825
|
-
readAllSessionLastTexts(sessions, ctx.getSessionStateSnapshot, {
|
|
4826
|
-
resolveFallbackSessionId: async (session) =>
|
|
4827
|
-
resolveStateLoopBoundSessionId(
|
|
4828
|
-
ctx.tuiReboundSessions,
|
|
4829
|
-
session.name,
|
|
4830
|
-
session.cwd,
|
|
4831
|
-
ctx.claudeBridge,
|
|
4832
|
-
) ||
|
|
4833
|
-
(await latestClaudeSessionIdForSidebar(session.cwd, ctx.logger)),
|
|
4834
|
-
}),
|
|
4835
|
-
])
|
|
4592
|
+
const lastEventByName = await readAllSessionEvents()
|
|
4836
4593
|
const enriched = enrichTmuxSessionsForListResponse(
|
|
4837
4594
|
sessions,
|
|
4838
4595
|
lastEventByName,
|
|
4839
4596
|
ctx.getSessionStateSnapshot,
|
|
4840
|
-
lastTextsByName,
|
|
4841
4597
|
)
|
|
4842
4598
|
const liveNames = new Set(enriched.map((s) => s.name))
|
|
4843
4599
|
let worktreeStubs = []
|
|
@@ -4864,6 +4620,29 @@ async function dispatch(msg, ctx) {
|
|
|
4864
4620
|
}
|
|
4865
4621
|
return
|
|
4866
4622
|
}
|
|
4623
|
+
case "chat.sidebar_snapshot.request": {
|
|
4624
|
+
try {
|
|
4625
|
+
const messages = await readSidebarMessageSnapshot(msg.targets, {
|
|
4626
|
+
liveCodexSession: (target) => {
|
|
4627
|
+
const live = ctx.codexBridge?.liveSessionForCwd?.(target.cwd)
|
|
4628
|
+
return live?.threadId === target.conversationId ? live : null
|
|
4629
|
+
},
|
|
4630
|
+
})
|
|
4631
|
+
ctx.client.send({
|
|
4632
|
+
type: "chat.sidebar_snapshot.response",
|
|
4633
|
+
request_id: msg.request_id,
|
|
4634
|
+
messages,
|
|
4635
|
+
})
|
|
4636
|
+
} catch (err) {
|
|
4637
|
+
ctx.client.send({
|
|
4638
|
+
type: "chat.sidebar_snapshot.response",
|
|
4639
|
+
request_id: msg.request_id,
|
|
4640
|
+
messages: [],
|
|
4641
|
+
error: err?.message || String(err),
|
|
4642
|
+
})
|
|
4643
|
+
}
|
|
4644
|
+
return
|
|
4645
|
+
}
|
|
4867
4646
|
case "tmux.create_session": {
|
|
4868
4647
|
const name = (msg.session_name || "").trim()
|
|
4869
4648
|
const cwd = (msg.cwd || process.env.HOME || "/").trim()
|