@cocorograph/hub-agent 0.8.81 → 0.8.83

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.81",
3
+ "version": "0.8.83",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -1,7 +1,7 @@
1
1
  import path from "node:path"
2
2
  import { listChatTargets } from "./chat-targets.mjs"
3
3
  import { hubProjectsBase } from "./chat-workspace.mjs"
4
- import { getChatSignal as defaultGetChatSignal } from "./chat-signals.mjs"
4
+ import { readRowChatState as defaultGetChatSignal } from "./chat-signals.mjs"
5
5
  import { describeLiveSessionsUnion } from "./live-sessions.mjs"
6
6
  import { sessionNameFor } from "./session-names.mjs"
7
7
  import { sanitizeTmuxName } from "./tmux.mjs"
@@ -9,7 +9,7 @@ import { sanitizeTmuxName } from "./tmux.mjs"
9
9
  /**
10
10
  * 会話 1 本の表示用シグナル。
11
11
  *
12
- * ⚠️ 出どころは `chat-signals.mjs` の `(cwd, cli_kind)` キーのストアで、**tmux に依存しない**。
12
+ * 代表会話の実体を同期で読み、tmux に依存せず表示値を組み立てる。
13
13
  * 旧 `tmux.list_sessions` 経路は同じ信号を「tmux セッション名をキーにした sidecar
14
14
  * (`/tmp/cockpit_session_events/<名前>.json`) と state loop の snapshot」という間接経路で
15
15
  * 配っていたが、どちらも tmux セッション行が無いと更新されない (bundle hook も
@@ -17,7 +17,7 @@ import { sanitizeTmuxName } from "./tmux.mjs"
17
17
  * 静かに止まる。ここで cwd キーの信号を直接載せることで、移行後も入力待ち・生成中・
18
18
  * 使用率が出る。名前をキーにしないので、同名 worktree でも帰属を取り違えない。
19
19
  */
20
- function attachChatSignal(item, cwd, getChatSignal, logger) {
20
+ export function attachChatSignal(item, cwd, getChatSignal, logger) {
21
21
  let sig
22
22
  try {
23
23
  sig = cwd ? getChatSignal(cwd, item.kind) : null
@@ -30,8 +30,9 @@ function attachChatSignal(item, cwd, getChatSignal, logger) {
30
30
  return item
31
31
  }
32
32
  if (!sig) return item
33
+ item = { ...item }
33
34
  item.chat_signal = {
34
- // 'processing' | 'waiting' | null (null = 鮮度切れ。断定しない)
35
+ // 'processing' | 'waiting' | null (null = 代表が終了)
35
36
  status: sig.status ?? null,
36
37
  // permission / AskUserQuestion の応答待ち。入力待ちバッジの本来の出どころ。
37
38
  input_pending: sig.inputPending === true,
@@ -147,3 +148,8 @@ export async function buildChatSessionsSnapshot({
147
148
  const projects_base = path.resolve(hubProjectsBase())
148
149
  return { targets, live, errors, projects_base }
149
150
  }
151
+
152
+ /** await で返った snapshot も、送信前に現在の実体から付け直す。 */
153
+ export function refreshChatSessionsSignals(snapshot, logger) {
154
+ return { ...snapshot, live: snapshot.live.map((item) => attachChatSignal(item, item.cwd, defaultGetChatSignal, logger)) }
155
+ }
@@ -1,270 +1,104 @@
1
- /**
2
- * チャット(SDK stream-json / codex app-server)モードの per-session 状態を、Hub ホスト型
3
- * Cockpit のサイドバー信号 (session.state / session.event) に橋渡しするための in-process ストア。
4
- *
5
- * 背景:
6
- * Hub ホスト型 Cockpit のサイドバーは以下を「TUI 専用シグナル」から導出している:
7
- * - ステータスドット / 各行 context%: hub-agent の startStateLoop が
8
- * detectSessionState (tmux capture-pane スクレイプ) で算出して session.state を push
9
- * - 時系列ソート: bundle hook (cockpit_session_event_hook.py) が
10
- * /tmp/cockpit_session_events/<tmux名>.json を書き、hub-agent が watch して
11
- * session.event を push → frontend の eventAt sort key
12
- * チャットモードは tmux ペインに Claude TUI を描画せず、hook も TUI 経由でしか
13
- * 発火しないため、これらが更新されずドット/ドーナツ/ソートが固まる。
14
- *
15
- * 解決:
16
- * claude-stream-bridge / codex-stream-bridge の event を main.mjs で捕捉し、ここに
17
- * status / context_pct / 最終アクティビティ時刻を記録する。startStateLoop が tmux
18
- * セッションを走査する際、その行に対応する新鮮なチャット信号があれば status / context_pct
19
- * を上書きし、turnAt 更新時には session-event ファイルを書いてソートを動かす。
20
- *
21
- * ## キーは (cwd, cliKind) の複合キー (2026-07-09, T05945)
22
- *
23
- * 旧実装は **cwd 単位のみ** をキーにしていた。チャットセッションは tmux セッション名を持たない
24
- * (SDK 子プロセス) ため、サイドバー行 (= tmux セッション) との対応が cwd しか無かったからである。
25
- * ところが「同一ワークスペースで claude と codex を同時に走らせる」(セッション名 `<dir>` と
26
- * `<dir>__codex`) 運用を入れると、1 cwd に 2 行がぶら下がる。cwd 単位ストアでは:
27
- * - 後から動いた CLI の status/context_pct が両方の行に適用される (交差汚染)
28
- * - identity 切替 (cliKind 変化) を検知するたびに前 CLI の状態が捨てられる (1 cwd = 同時 1 CLI)
29
- * ため、CLI ごとの独立表示が原理的に不可能だった。
30
- *
31
- * そこで **cliKind をキーの一部へ昇格** させ、`(cwd, cliKind)` 1 件につき 1 つの信号を持つ。
32
- * state loop は行のセッション名から `cliKindForSessionName` で構造的に cli を導出し、その
33
- * バケットだけを読む。これにより「ステータスドットの状態は 1 箇所だけ見れば済む」= 呼び出し側
34
- * が複数ソースを合成する必要が無くなる (正本一本化)。
35
- *
36
- * ## entry 内に残る identity 次元 (sessionId)
37
- * cliKind がキーへ昇格した後も、**同一 CLI 内でのセッション乗り換え** (プルダウンから別
38
- * セッションを選ぶ / `/clear` 相当の新規セッション) は起こりうる。この場合に前セッションの
39
- * status/context_pct を引き継ぐと、ドーナツが前セッションの値のまま固着する (症状③④)。
40
- * よって `recordChatActivity` の patch に `sessionId` が含まれ、かつ前回記録した sessionId と
41
- * 異なるときは、温存 (keep-prev) せずリセットしてから patch を適用する。patch に sessionId が
42
- * 含まれない呼び出し (permission ハンドラの inputPending 単独更新等) は identity 判定をスキップ
43
- * して従来どおり温存する。
44
- */
45
-
1
+ /** 行は最後に attach / prompt した会話実体を参照する。状態の写しは持たない。 */
46
2
  import { normalizeCliKind } from "./session-names.mjs"
3
+ import { codexLiveContextForProducer, hasCodexLiveTokenUsage, jsonlContextForCwd, codexUsageForThread } from "./usage.mjs"
4
+
5
+ const rows = new Map()
6
+ const promptAcceptedAt = new Map()
7
+ const keyFor = (cwd, kind) => cwd && typeof cwd === "string" ? normalizeCliKind(kind) + "\u0000" + cwd : null
8
+ const realId = (id) => typeof id === "string" && id && !id.startsWith("fresh:") ? id : null
9
+ export const chatSessionAlive = (session, kind) => !!session && !(kind === "codex" ? session.dead : session._closed)
10
+ const sessionIdFor = (session, kind) => realId(kind === "codex" ? session?.threadId : session?.sessionId)
11
+
12
+ export function recordConversationFocus(cwd, cliKind, session, bridge) {
13
+ const key = keyFor(cwd, cliKind)
14
+ if (!key || !session || session.cwd !== cwd) return
15
+ const prev = rows.get(key)
16
+ rows.set(key, { session, bridge: bridge ?? prev?.bridge, observed: prev?.observed, turnAt: prev?.turnAt ?? 0 })
17
+ }
47
18
 
48
- // `${cliKind}\u0000${cwd}` → { status, context_pct, statusAt, inputPending, turnAt,
49
- // updatedAtMs, cliKind, sessionId }
50
- const _byKey = new Map()
51
-
52
- // チャット status を「生きている」と見なす最大経過時間 (ms)。これを過ぎたら status は tmux
53
- // スクレイプ結果へフォールバックさせる (チャット終了/別モード移行の検知)。ただし sessionId に
54
- // 帰属した context snapshot は status と別に保持する。
55
- // 90s: SDK の result/abort を取りこぼしても status=processing がこの時間で解除される
56
- // (RC-4 偽陽性 = 中断/異常終了で三点リーダーが固着する最長時間の短縮。旧 15 分)。正常な
57
- // 生成中は SDK の assistant イベントが頻繁に updatedAtMs/statusAt を前進させるため途中で
58
- // 切れない。abort/異常終了は claude-stream-bridge の finally が即 clearChatSignal するのが
59
- // 一次対策で、本 TTL はその取りこぼし時のバックストップ。
60
- const CHAT_SIGNAL_STALE_MS = 90 * 1000
61
-
62
- // RC4 (症状④): stream_event (拡張思考の thinking_delta / 長時間ツール中の partial delta)
63
- // 受信を status=processing の tick として記録する際のキー単位スロットル間隔。stream_event は
64
- // トークン delta 毎に高頻度で流れるため、無制限に recordChatActivity を呼ぶと _byKey への書き込み
65
- // 洪水になる。この間隔で間引いても、>90s の拡張思考バースト / 単発ツールの間 statusAt・updatedAtMs
66
- // を確実に前進させ、CHAT_SIGNAL_STALE_MS による idle 誤降格 (純 SDK チャットで三点リーダーが消える
67
- // 事故) を防げる。
68
- const STREAM_TICK_THROTTLE_MS = 3 * 1000
69
-
70
- // key → 最後に stream tick を記録した時刻 (スロットル判定用)。
71
- const _lastStreamTickAt = new Map()
72
-
73
- // `${cliKind}\u0000${cwd}` → チャット入力を最後に受け付けた時刻。handler が dispatch 前に同期で記録する
74
- // (bridge の応答を待つ間に turn/started 等で state loop が processing を観測しても、観測時刻ではなく
75
- // この受付時刻で prompt_submit を書けるように)。tmux 走査より先に記録するので走査失敗にも影響されない。
76
- const _promptAcceptedAtByKey = new Map()
77
-
78
- const VALID_STATUS = new Set(["processing", "waiting", "idle"])
19
+ export function getRepresentativeConversation(cwd, cliKind) {
20
+ return rows.get(keyFor(cwd, cliKind))?.session ?? null
21
+ }
79
22
 
80
- function _now() {
81
- return Date.now()
23
+ /** 代表が一度でも設定された行か(終了印の行も含む)。代表未設定の従来経路との分岐に使う。 */
24
+ export function hasRepresentativeRow(cwd, cliKind) {
25
+ return rows.has(keyFor(cwd, cliKind))
82
26
  }
83
27
 
84
- /**
85
- * (cwd, cliKind) の複合キーを組み立てる。cwd に `\u0000` は現れないため衝突しない。
86
- *
87
- * @param {string} cwd
88
- * @param {unknown} cliKind
89
- * @returns {string|null} cwd が不正なら null
90
- */
91
- function _key(cwd, cliKind) {
92
- if (!cwd || typeof cwd !== "string") return null
93
- return `${normalizeCliKind(cliKind)}\u0000${cwd}`
28
+ export function representativeRows(cliKind) {
29
+ const prefix = normalizeCliKind(cliKind) + "\u0000"
30
+ return [...rows.keys()].filter((key) => key.startsWith(prefix)).map((key) => key.slice(prefix.length))
94
31
  }
95
32
 
96
- /** (cwd, cliKind) の最新 prompt 受付時刻を単調に記録する。 */
97
- export function recordPromptAccepted(cwd, cliKind, at) {
98
- const key = _key(cwd, cliKind)
33
+ export function recordPromptAccepted(cwd, cliKind, at, session, bridge) {
34
+ const key = keyFor(cwd, cliKind)
99
35
  if (!key) return
100
- _promptAcceptedAtByKey.set(key, Math.max(_promptAcceptedAtByKey.get(key) ?? 0, at))
36
+ if (session) recordConversationFocus(cwd, cliKind, session, bridge)
37
+ promptAcceptedAt.set(key, Math.max(promptAcceptedAt.get(key) ?? 0, at))
101
38
  }
102
39
 
103
- /** (cwd, cliKind) の最新 prompt 受付時刻を返す (無ければ undefined)。 */
104
40
  export function getPromptAcceptedAt(cwd, cliKind) {
105
- const key = _key(cwd, cliKind)
106
- return key ? _promptAcceptedAtByKey.get(key) : undefined
41
+ return promptAcceptedAt.get(keyFor(cwd, cliKind))
107
42
  }
108
43
 
109
- /**
110
- * チャットのアクティビティを記録する。status / context_pct は与えられた分だけ更新し、
111
- * turnAt (= 最終アクティビティ時刻 / ソート用) は呼ばれるたびに前進させる。
112
- *
113
- * inputPending: チャットセッションが permission / AskUserQuestion の応答待ちで
114
- * ターンをブロックしているか。サイドバーの「確認待ち」ドット & 通知の発火源にする
115
- * (status とは独立に持つ。SessionStatus enum を汚さず、status は processing のまま
116
- * "確認待ち" を別軸で表現するため)。permission 要求で true、次の assistant / result
117
- * イベント (= ユーザーが応答してターンが再開/完了) で false に落とす。
118
- *
119
- * sessionId: 「この更新がどの (CLI 内の) セッションのものか」を明示する identity。patch に
120
- * `sessionId` キーが含まれていれば identity 更新とみなし、前回記録した sessionId と比較する:
121
- * - 一致 (= 同一セッションの継続) → 従来通り前回値へ status/context_pct を温存 (keep-prev)。
122
- * - 不一致 (= 別セッションへの乗り換え) → 前回の status/context_pct/inputPending を温存せず
123
- * リセットしてから patch を適用する (旧セッションの値が切替後にも残る症状③④の根治)。
124
- * patch に sessionId キーが含まれない (= permission ハンドラの inputPending 単独更新等) 場合は
125
- * identity 判定をスキップし、後方互換のため従来通り温存する。
126
- *
127
- * @param {string} cwd セッションの作業ディレクトリ
128
- * @param {'claude'|'codex'} cliKind この更新を行う CLI (キーの一部。未知値は claude 扱い)
129
- * @param {{ status?: string, contextPct?: number|null, inputPending?: boolean, sessionId?: string|null }} patch
130
- * @param {number} [now] テスト用の時刻注入 (省略時は Date.now())
131
- */
132
- export function recordChatActivity(cwd, cliKind, patch = {}, now = _now()) {
133
- const key = _key(cwd, cliKind)
134
- if (!key) return
135
- const kind = normalizeCliKind(cliKind)
136
- const empty = {
137
- status: null,
138
- context_pct: null,
139
- statusAt: 0,
140
- inputPending: false,
141
- turnAt: 0,
142
- updatedAtMs: 0,
143
- cliKind: kind,
144
- sessionId: null,
145
- }
146
- const prev = _byKey.get(key) || empty
147
-
148
- const hasSessionId = Object.prototype.hasOwnProperty.call(patch, "sessionId")
149
- let base = prev
150
- let sessionId = prev.sessionId
151
- if (hasSessionId) {
152
- sessionId = patch.sessionId ?? null
153
- if (sessionId !== prev.sessionId) {
154
- // 新しいセッション: 前回の status/context_pct/inputPending は引き継がない。
155
- // turnAt/updatedAtMs はこの後どちらにせよ now で上書きするため据え置きで良い。
156
- base = empty
157
- }
44
+ /** ID 回転時は使用率・モデル・窓サイズをまとめて無効化する。 */
45
+ export function conversationContext(session, cliKind) {
46
+ const id = sessionIdFor(session, cliKind)
47
+ if (!session._rowContext || session._rowContext.sessionId !== id) {
48
+ session._rowContext = { sessionId: id, live: false, percent: null, usage: null, model: null, window: null }
158
49
  }
159
-
160
- const next = { ...base, cliKind: kind, sessionId, turnAt: now, updatedAtMs: now }
161
- // statusAt: status / context_pct を「実際に SDK イベントが更新した」時刻。
162
- // inputPending だけの記録 (TUI 権限ブリッジ) では更新しない。これを分けないと、
163
- // TUI モードの承認カードが届くたびに「過去の SDK チャットが残した status (多くは
164
- // waiting)」の鮮度だけが蘇生され、state loop が capture-pane の実 processing を
165
- // 15 分間 waiting で上書きし続ける (TUI 生成中なのにステータスドット/三点リーダーが
166
- // 消える事故の真因, 2026-06-12)。
167
- if (typeof patch.status === "string" && VALID_STATUS.has(patch.status)) {
168
- next.status = patch.status
169
- next.statusAt = now
170
- }
171
- if (typeof patch.contextPct === "number" && Number.isFinite(patch.contextPct)) {
172
- next.context_pct = Math.max(0, Math.min(100, patch.contextPct))
173
- next.statusAt = now
174
- }
175
- if (typeof patch.inputPending === "boolean") {
176
- next.inputPending = patch.inputPending
177
- }
178
- _byKey.set(key, next)
50
+ return session._rowContext
179
51
  }
180
52
 
181
- /**
182
- * RC4 (症状④): stream_event (拡張思考 thinking_delta / 長時間ツール中の partial delta) 受信を
183
- * status=processing の「生きている」tick として記録する。assistant / result が流れない長い無音
184
- * 区間 (>90s) でも statusAt・updatedAtMs を前進させ、getChatSignal が CHAT_SIGNAL_STALE_MS で
185
- * null を返して三点リーダーが消える事故を防ぐ。
186
- *
187
- * 設計上の注意:
188
- * - (cwd, cliKind) 単位で STREAM_TICK_THROTTLE_MS に間引く (delta 毎の Map 書き込み洪水を回避)。
189
- * - delta 本文は一切読まない (タイムスタンプ前進のみ)。拡張思考の本文は ephemeral で、ここで
190
- * 保持・転送する必要は無く、プライバシー上も読むべきでない。
191
- * - SDK のイベント順序上 stream_event はターン中の assistant ブロックに属し result より前に
192
- * 流れる。abort 時は SDK query が即停止して onEvent が止まるため、result/turnsettled で
193
- * waiting に落ちた後に trailing stream_event が processing を蘇生させる経路は無い。
194
- *
195
- * @param {string} cwd
196
- * @param {'claude'|'codex'} cliKind
197
- * @param {number} [now]
198
- */
199
- export function recordChatStreamTick(cwd, cliKind, now = _now()) {
200
- const key = _key(cwd, cliKind)
201
- if (!key) return
202
- // 初回 (未 tick) は必ず記録する。`|| 0` で last を 0 既定にすると「時刻 0 で tick 済み」と
203
- // 区別できず、初回 tick が now < throttle のとき誤って間引かれる。has で未 tick を判定する。
204
- const last = _lastStreamTickAt.get(key)
205
- if (last !== undefined && now - last < STREAM_TICK_THROTTLE_MS) return
206
- _lastStreamTickAt.set(key, now)
207
- recordChatActivity(cwd, cliKind, { status: "processing" }, now)
208
- }
209
-
210
- /**
211
- * (cwd, cliKind) に対応するチャット信号を返す。status は stale なら null へ落とすが、
212
- * sessionId に帰属する context snapshot は明示切替まで保持する。
213
- *
214
- * @param {string} cwd
215
- * @param {'claude'|'codex'} cliKind
216
- * @param {number} [now]
217
- * @returns {{ status: string|null, context_pct: number|null, inputPending?: boolean, turnAt: number, updatedAtMs: number, cliKind: string, sessionId?: string|null } | null}
218
- */
219
- export function getChatSignal(cwd, cliKind, now = _now()) {
220
- const key = _key(cwd, cliKind)
221
- if (!key) return null
222
- const sig = _byKey.get(key)
223
- if (!sig) return null
224
- // SDK チャットは応答完了後に長時間イベントが無くても、同じ sessionId の context 値自体は
225
- // 無効化されない。これを丸ごと null にすると、SDK 専用チャットの tmux が未開始画面 (0%)
226
- // の場合に正しいドーナツ→0%/非表示へフラップする。TUI 切替時は clearChatSignal が呼ばれる。
227
- const hasBoundContext =
228
- typeof sig.sessionId === "string" &&
229
- sig.sessionId.length > 0 &&
230
- typeof sig.context_pct === "number" &&
231
- Number.isFinite(sig.context_pct)
232
- if (now - sig.updatedAtMs > CHAT_SIGNAL_STALE_MS) {
233
- return hasBoundContext ? { ...sig, status: null } : null
234
- }
235
- // status / context_pct 自体の鮮度は statusAt で判定する。inputPending だけが
236
- // 信号を延命している間 (= TUI モードで承認カードだけが届く状況) は、stale な
237
- // SDK 由来の status / context_pct を返さず capture-pane の実値に委ねる。
238
- if (now - (sig.statusAt || 0) > CHAT_SIGNAL_STALE_MS) {
239
- return { ...sig, status: null, context_pct: hasBoundContext ? sig.context_pct : null }
53
+ /** 同期のみ。戻り値は送信直前に取得し、await をまたいで使わない。 */
54
+ export function readRowChatState(cwd, cliKind, now = Date.now()) {
55
+ const kind = normalizeCliKind(cliKind)
56
+ const row = rows.get(keyFor(cwd, kind))
57
+ if (!row) return null
58
+ const session = row.session
59
+ const alive = chatSessionAlive(session, kind)
60
+ // null は終了印。行と bridge は残し、履歴などを持つ終了実体への参照だけを外す。
61
+ // 同期読み取りなので、ここでは行がまだこの実体を指している。
62
+ if (!alive && row.session === session) row.session = null
63
+ const sessions = [session, ...(row.bridge?.liveSessionsForCwd?.(cwd) ?? [])]
64
+ const inputPending = sessions.some((s) => s?.cwd === cwd && chatSessionAlive(s, kind) &&
65
+ (kind === "codex" ? s.pendingApprovals?.size : s._permissionResolvers?.size) > 0)
66
+ const active = alive ? (kind === "codex" ? !!session.activeTurnId || !!session.dispatchingInput : session._busy === true) : null
67
+ const context = alive ? conversationContext(session, kind) : null
68
+ const livePct = alive && kind === "codex" ? codexLiveContextForProducer(session.seq) : null
69
+ const status = active == null ? null : active ? "processing" : "waiting"
70
+ const sessionId = alive ? sessionIdFor(session, kind) : null
71
+ const contextPct = alive ? (kind === "codex" && hasCodexLiveTokenUsage(session.seq) ? livePct : context.percent) : null
72
+ // event 発火用の変化管理だけを保持する。表示値として再利用しない。
73
+ const observed = JSON.stringify([status, inputPending, sessionId, contextPct])
74
+ if (observed !== row.observed) {
75
+ row.observed = observed
76
+ row.turnAt = Math.max(now, row.turnAt + 1)
240
77
  }
241
- return sig
78
+ return { status, context_pct: contextPct, sessionId, inputPending,
79
+ turn_active: active, cliKind: kind, turnAt: row.turnAt }
242
80
  }
243
81
 
244
- /**
245
- * (cwd, cliKind) のチャット信号を破棄する。claude.tui.bind (= SDK 停止 → TUI へ載せ替え) や
246
- * attach の fresh 起動時に呼び、以降の status / context% はペイン実状態 (capture-pane) に
247
- * 一本化する。
248
- *
249
- * ⚠️ cliKind を必ず渡すこと。claude の bind で codex の信号まで消してはならない
250
- * (同一 cwd の別 CLI 行が巻き添えでドット消失する)。
251
- *
252
- * @param {string} cwd
253
- * @param {'claude'|'codex'} cliKind
254
- */
255
- export function clearChatSignal(cwd, cliKind) {
256
- const key = _key(cwd, cliKind)
257
- if (!key) return
258
- _byKey.delete(key)
259
- _lastStreamTickAt.delete(key)
260
- _promptAcceptedAtByKey.delete(key)
82
+ /** 履歴取得は捕捉した実体にだけ適用し、live 値・終了・ID 回転を上書きしない。 */
83
+ export async function loadResumeChatContext(session, kind, {
84
+ readClaude = jsonlContextForCwd, readCodex = codexUsageForThread,
85
+ } = {}) {
86
+ if (!chatSessionAlive(session, kind)) return
87
+ const context = conversationContext(session, kind)
88
+ const id = context.sessionId
89
+ if (!id || context.live) return
90
+ const window = context.window
91
+ const result = kind === "codex"
92
+ ? await readCodex(id, { producerSeq: session.seq })
93
+ : await readClaude(session.cwd, id, { contextWindow: context.window })
94
+ if (!chatSessionAlive(session, kind) || conversationContext(session, kind) !== context || context.live || context.window !== window) return
95
+ if (kind === "codex" && hasCodexLiveTokenUsage(session.seq)) return
96
+ if (kind === "claude" && result?.uuid !== id) return
97
+ const percent = kind === "codex" ? result?.context : result?.percent
98
+ context.percent = typeof percent === "number" && Number.isFinite(percent) ? percent : null
261
99
  }
262
100
 
263
- /** テスト用: ストアを空にする。 */
264
101
  export function _resetChatSignals() {
265
- _byKey.clear()
266
- _lastStreamTickAt.clear()
267
- _promptAcceptedAtByKey.clear()
102
+ rows.clear()
103
+ promptAcceptedAt.clear()
268
104
  }
269
-
270
- export { CHAT_SIGNAL_STALE_MS, STREAM_TICK_THROTTLE_MS }
@@ -1,3 +1,4 @@
1
+ import { recordConversationFocus } from "./chat-signals.mjs"
1
2
  /**
2
3
  * Claude Code stream-json モードのブリッジ (Sprint G: Web UI 対応)。
3
4
  *
@@ -1043,6 +1044,12 @@ export class ClaudeStreamSession {
1043
1044
  return true
1044
1045
  }
1045
1046
 
1047
+ /** ID 回転で前会話の使用率・モデル・窓サイズを引き継がない。 */
1048
+ _setSessionId(id) {
1049
+ if (this.sessionId !== id) this._rowContext = null
1050
+ this.sessionId = id
1051
+ }
1052
+
1046
1053
  /** 再アタッチ: 走行中(または生存中)セッションに新しい stream_id を紐付け直し、
1047
1054
  * idle 撤去タイマーを止める。以降のターンイベントはこの stream_id 経由で新しい
1048
1055
  * browser 接続へライブに流れる (= 通常の生成中表示と同じ)。再アタッチ前の確定分は
@@ -1545,7 +1552,7 @@ export class ClaudeStreamSession {
1545
1552
  msg?.subtype === "init" &&
1546
1553
  typeof msg.session_id === "string"
1547
1554
  ) {
1548
- this.sessionId = msg.session_id
1555
+ this._setSessionId(msg.session_id)
1549
1556
  // session_id が確定/変化したら watch をその jsonl に張り替える
1550
1557
  this._ensureWatch()
1551
1558
  if (shouldWaitForTitle) {
@@ -1555,7 +1562,7 @@ export class ClaudeStreamSession {
1555
1562
  // result イベントでも session_id が来ることがある (念のため拾う)
1556
1563
  if (msg?.type === "result") {
1557
1564
  resultDelivered = true
1558
- if (typeof msg.session_id === "string") this.sessionId = msg.session_id
1565
+ if (typeof msg.session_id === "string") this._setSessionId(msg.session_id)
1559
1566
  }
1560
1567
  if (isSessionIdleEvent(msg)) idleDelivered = true
1561
1568
  try {
@@ -2214,13 +2221,13 @@ export class ClaudeStreamSession {
2214
2221
  msg?.subtype === "init" &&
2215
2222
  typeof msg.session_id === "string"
2216
2223
  ) {
2217
- this.sessionId = msg.session_id
2224
+ this._setSessionId(msg.session_id)
2218
2225
  if (titleSeed != null) {
2219
2226
  void this._requestSessionTitle(q, titleSeed, msg.session_id)
2220
2227
  }
2221
2228
  }
2222
2229
  if (msg?.type === "result") {
2223
- if (typeof msg.session_id === "string") this.sessionId = msg.session_id
2230
+ if (typeof msg.session_id === "string") this._setSessionId(msg.session_id)
2224
2231
  // 現ターンの result が届いた時点で前ターンの遅延 idle は順序保証により
2225
2232
  // 既に通過済み。古いガードを解除し、result 用のガードを張り直して settle する。
2226
2233
  // 実 SDK では result 後に idle が来ないターンがあるため、idle 待ちだけにすると
@@ -2760,6 +2767,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2760
2767
  /* ignore */
2761
2768
  }
2762
2769
  this.emit("exit", {
2770
+ cwd: s.cwd,
2763
2771
  stream_id: s.stream_id,
2764
2772
  code: 0,
2765
2773
  reason,
@@ -2805,6 +2813,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2805
2813
  // tombstone からの待ち直しは初回に exit 済みなので出さない (1 セッション 1 回)。
2806
2814
  for (const s of newVictims) {
2807
2815
  this.emit("exit", {
2816
+ cwd: s.cwd,
2808
2817
  stream_id: s.stream_id,
2809
2818
  code: 0,
2810
2819
  reason,
@@ -2899,6 +2908,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2899
2908
  effort,
2900
2909
  })
2901
2910
  this.sessions.set(stream_id, live)
2911
+ recordConversationFocus(live.cwd, "claude", this.sessions.get(stream_id), this)
2902
2912
  this.logger?.info(
2903
2913
  {
2904
2914
  stream_id,
@@ -2940,6 +2950,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2940
2950
  stream_id: session.stream_id,
2941
2951
  session_id: session.sessionId,
2942
2952
  cwd: session.cwd,
2953
+ session,
2943
2954
  event,
2944
2955
  })
2945
2956
  // session_id 確定後は索引に登録 (冪等)。再接続時の再アタッチに使う。
@@ -3004,6 +3015,7 @@ export class ClaudeStreamBridge extends EventEmitter {
3004
3015
  // reason は既定「detached-after-turn」。R1(a) は「child-exited」を渡して観測可能にする。
3005
3016
  this._dropSessionMappings(session)
3006
3017
  this.emit("exit", {
3018
+ cwd: session.cwd,
3007
3019
  stream_id: session.stream_id,
3008
3020
  code: 0,
3009
3021
  reason: info?.reason || "detached-after-turn",
@@ -3021,6 +3033,7 @@ export class ClaudeStreamBridge extends EventEmitter {
3021
3033
  },
3022
3034
  })
3023
3035
  this.sessions.set(stream_id, session)
3036
+ recordConversationFocus(session.cwd, "claude", this.sessions.get(stream_id), this)
3024
3037
  // ⚠️ resume 索引を attach 時に **同期登録** する (T06271 レビュー3 major①)。従来は onEvent
3025
3038
  // (SDK が session_id を確定させた後) でしか _liveBySession に載せていなかったため、input 前に
3026
3039
  // 同一 resumeSessionId で 2 回 attach すると「索引が空の窓」で上の reattach 判定 (1835) をすり抜け、
@@ -3504,9 +3517,10 @@ export class ClaudeStreamBridge extends EventEmitter {
3504
3517
  *
3505
3518
  * @param {string} cwd
3506
3519
  * @param {boolean} [requireSessionId=true] sessionId 未確定の fresh を除外するか (jsonl バインド用途は true)。
3520
+ * @param {string|null} [sessionId=null] 指定時はその会話だけを候補にする。
3507
3521
  * @returns {ClaudeStreamSession|null}
3508
3522
  */
3509
- _liveSessionForCwd(cwd, requireSessionId = true) {
3523
+ _liveSessionForCwd(cwd, requireSessionId = true, sessionId = null) {
3510
3524
  if (!cwd) return null
3511
3525
  let best = null
3512
3526
  let bestAt = -1
@@ -3515,7 +3529,8 @@ export class ClaudeStreamBridge extends EventEmitter {
3515
3529
  // union にする。同一セッションは両 Map に載り得るため seen で識別重複を排除する。
3516
3530
  const scan = (iter) => {
3517
3531
  for (const session of iter) {
3518
- if (seen.has(session) || session.cwd !== cwd) continue
3532
+ if (seen.has(session) || session.cwd !== cwd || session._closed) continue
3533
+ if (sessionId != null && session.sessionId !== sessionId) continue
3519
3534
  if (requireSessionId && !session.sessionId) continue
3520
3535
  seen.add(session)
3521
3536
  let at = 0
@@ -3887,6 +3902,7 @@ export class ClaudeStreamBridge extends EventEmitter {
3887
3902
  this._dropSessionMappings(session)
3888
3903
  session.close()
3889
3904
  this.emit("exit", {
3905
+ cwd: session.cwd,
3890
3906
  stream_id: session.stream_id,
3891
3907
  code: 0,
3892
3908
  reason: "idle-reaped",
@@ -4006,6 +4022,7 @@ export class ClaudeStreamBridge extends EventEmitter {
4006
4022
  this._dropSessionMappings(session)
4007
4023
  session.close()
4008
4024
  this.emit("exit", {
4025
+ cwd: session.cwd,
4009
4026
  stream_id: session.stream_id,
4010
4027
  code: 0,
4011
4028
  reason: "idle-reaped",
@@ -4140,7 +4157,7 @@ export class ClaudeStreamBridge extends EventEmitter {
4140
4157
  if (!s || seen.has(s)) continue
4141
4158
  seen.add(s)
4142
4159
  s.close()
4143
- this.emit("exit", { stream_id: s.stream_id, code: 0, reason: "shutdown", session_id: s.sessionId })
4160
+ this.emit("exit", { cwd: s.cwd, stream_id: s.stream_id, code: 0, reason: "shutdown", session_id: s.sessionId })
4144
4161
  }
4145
4162
  // stream key 索引・再アタッチ索引を全消去 (プロセス終了時の後始末)。
4146
4163
  this.sessions.clear()
@@ -4154,6 +4171,12 @@ export class ClaudeStreamBridge extends EventEmitter {
4154
4171
  return Array.from(this.sessions.keys())
4155
4172
  }
4156
4173
 
4174
+ /** cwd の生きている会話実体をすべて返す(行の承認待ちの集約用)。 */
4175
+ liveSessionsForCwd(cwd) {
4176
+ if (!cwd) return []
4177
+ return this._liveCandidatesForCwd(cwd, false).map(({ session }) => session)
4178
+ }
4179
+
4157
4180
  /** 該当 stream の session_id (Claude SDK 由来) を返す。resume 用。 */
4158
4181
  getSessionId(stream_id) {
4159
4182
  const s = this.sessions.get(stream_id)