@cocorograph/hub-agent 0.8.80 → 0.8.82
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/chat-sessions-snapshot.mjs +14 -5
- package/src/chat-signals.mjs +81 -247
- package/src/claude-stream-bridge.mjs +30 -7
- package/src/codex-stream-bridge.mjs +30 -4
- package/src/dispatch-scheduler.mjs +5 -0
- package/src/main.mjs +206 -558
- package/src/usage.mjs +8 -3
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
2
|
import { listChatTargets } from "./chat-targets.mjs"
|
|
3
|
-
import {
|
|
3
|
+
import { hubProjectsBase } from "./chat-workspace.mjs"
|
|
4
|
+
import { readRowChatState as defaultGetChatSignal } from "./chat-signals.mjs"
|
|
4
5
|
import { describeLiveSessionsUnion } from "./live-sessions.mjs"
|
|
5
6
|
import { sessionNameFor } from "./session-names.mjs"
|
|
6
7
|
import { sanitizeTmuxName } from "./tmux.mjs"
|
|
@@ -8,7 +9,7 @@ import { sanitizeTmuxName } from "./tmux.mjs"
|
|
|
8
9
|
/**
|
|
9
10
|
* 会話 1 本の表示用シグナル。
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
+
* 代表会話の実体を同期で読み、tmux に依存せず表示値を組み立てる。
|
|
12
13
|
* 旧 `tmux.list_sessions` 経路は同じ信号を「tmux セッション名をキーにした sidecar
|
|
13
14
|
* (`/tmp/cockpit_session_events/<名前>.json`) と state loop の snapshot」という間接経路で
|
|
14
15
|
* 配っていたが、どちらも tmux セッション行が無いと更新されない (bundle hook も
|
|
@@ -16,7 +17,7 @@ import { sanitizeTmuxName } from "./tmux.mjs"
|
|
|
16
17
|
* 静かに止まる。ここで cwd キーの信号を直接載せることで、移行後も入力待ち・生成中・
|
|
17
18
|
* 使用率が出る。名前をキーにしないので、同名 worktree でも帰属を取り違えない。
|
|
18
19
|
*/
|
|
19
|
-
function attachChatSignal(item, cwd, getChatSignal, logger) {
|
|
20
|
+
export function attachChatSignal(item, cwd, getChatSignal, logger) {
|
|
20
21
|
let sig
|
|
21
22
|
try {
|
|
22
23
|
sig = cwd ? getChatSignal(cwd, item.kind) : null
|
|
@@ -29,8 +30,9 @@ function attachChatSignal(item, cwd, getChatSignal, logger) {
|
|
|
29
30
|
return item
|
|
30
31
|
}
|
|
31
32
|
if (!sig) return item
|
|
33
|
+
item = { ...item }
|
|
32
34
|
item.chat_signal = {
|
|
33
|
-
// 'processing' | 'waiting' | null (null =
|
|
35
|
+
// 'processing' | 'waiting' | null (null = 代表が終了)
|
|
34
36
|
status: sig.status ?? null,
|
|
35
37
|
// permission / AskUserQuestion の応答待ち。入力待ちバッジの本来の出どころ。
|
|
36
38
|
input_pending: sig.inputPending === true,
|
|
@@ -142,5 +144,12 @@ export async function buildChatSessionsSnapshot({
|
|
|
142
144
|
)
|
|
143
145
|
if (replace) live[prior] = attachChatSignal(item, cwd, getChatSignal, logger)
|
|
144
146
|
}
|
|
145
|
-
|
|
147
|
+
// chat-targets.mjs の fs.readdir(hubProjectsBase()) と同じ絶対パスに揃える(~ は展開しない)。
|
|
148
|
+
const projects_base = path.resolve(hubProjectsBase())
|
|
149
|
+
return { targets, live, errors, projects_base }
|
|
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)) }
|
|
146
155
|
}
|
package/src/chat-signals.mjs
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
81
|
-
|
|
23
|
+
/** 代表が一度でも設定された行か(終了印の行も含む)。代表未設定の従来経路との分岐に使う。 */
|
|
24
|
+
export function hasRepresentativeRow(cwd, cliKind) {
|
|
25
|
+
return rows.has(keyFor(cwd, cliKind))
|
|
82
26
|
}
|
|
83
27
|
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
return key ? _promptAcceptedAtByKey.get(key) : undefined
|
|
41
|
+
return promptAcceptedAt.get(keyFor(cwd, cliKind))
|
|
107
42
|
}
|
|
108
43
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
78
|
+
return { status, context_pct: contextPct, sessionId, inputPending,
|
|
79
|
+
turn_active: active, cliKind: kind, turnAt: row.turnAt }
|
|
242
80
|
}
|
|
243
81
|
|
|
244
|
-
/**
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|