@cocorograph/hub-agent 0.8.86 → 0.8.87
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 +32 -43
- package/src/claude-stream-bridge.mjs +77 -8
- package/src/codex-stream-bridge.mjs +42 -2
- package/src/last-turn-texts.mjs +86 -0
- package/src/main.mjs +15 -8
- package/src/sidebar-message-snapshot.mjs +57 -60
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { maskTurnText, sanitizeTurnText, truncateTurnText } from "./last-turn-texts.mjs"
|
|
1
2
|
/**
|
|
2
3
|
* Claude Code のローカル会話履歴 (`~/.claude/projects/<cwd-encoded>/<session-id>.jsonl`)
|
|
3
4
|
* を読んで browser に返すモジュール (Sprint G hotfix 0.6.1)。
|
|
@@ -23,7 +24,6 @@ import path from "node:path"
|
|
|
23
24
|
|
|
24
25
|
import {
|
|
25
26
|
MASK_ENABLED,
|
|
26
|
-
SECRET_PLACEHOLDER,
|
|
27
27
|
collectToolUseNames,
|
|
28
28
|
maskMessageObject,
|
|
29
29
|
redactSecrets,
|
|
@@ -803,7 +803,7 @@ export async function fetchSessionHistory({ cwd, session_id, maxLines, projectsR
|
|
|
803
803
|
* @param {string} raw
|
|
804
804
|
* @returns {string|null} preview に採用するテキスト (最大 80 文字)。スキップすべき行なら null。
|
|
805
805
|
*/
|
|
806
|
-
function normalizePreviewText(raw) {
|
|
806
|
+
function normalizePreviewText(raw, max = 80) {
|
|
807
807
|
const trimmed = (raw || "").trim()
|
|
808
808
|
if (!trimmed) return null
|
|
809
809
|
// SDK / ハーネスが注入する合成 user メッセージはスキップ (会話の主題ではない)。
|
|
@@ -820,13 +820,13 @@ function normalizePreviewText(raw) {
|
|
|
820
820
|
const argsMatch = trimmed.match(/<command-args>([\s\S]*?)<\/command-args>/)
|
|
821
821
|
const args = (argsMatch?.[1] || "").trim()
|
|
822
822
|
const label = args ? `${name} ${args}` : name
|
|
823
|
-
return label.replace(/\s+/g, " ")
|
|
823
|
+
return truncateTurnText(label.replace(/\s+/g, " "), max)
|
|
824
824
|
}
|
|
825
825
|
// local-command の stdout / caveat だけの注入行はスキップ。
|
|
826
826
|
if (trimmed.includes("<local-command-stdout>") || trimmed.includes("<local-command-caveat>")) {
|
|
827
827
|
return null
|
|
828
828
|
}
|
|
829
|
-
return trimmed.replace(/\s+/g, " ")
|
|
829
|
+
return truncateTurnText(trimmed.replace(/\s+/g, " "), max)
|
|
830
830
|
}
|
|
831
831
|
|
|
832
832
|
/**
|
|
@@ -1074,26 +1074,18 @@ function splitLinesBuf(buf) {
|
|
|
1074
1074
|
* (120 文字のプレビューに秘匿情報が必要な場面は無い)。閉じていない PEM opener が残る
|
|
1075
1075
|
* ケースは、そこから先を保守的に全て伏せる。
|
|
1076
1076
|
*/
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
let out = redactSecrets(raw)
|
|
1080
|
-
const openerAt = out.indexOf("-----BEGIN")
|
|
1081
|
-
if (openerAt >= 0) out = out.slice(0, openerAt) + SECRET_PLACEHOLDER
|
|
1082
|
-
return out
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
/** assistant テキスト用: マスク → 空白圧縮 → 切り詰め。 */
|
|
1086
|
-
function sanitizeAssistantText(raw) {
|
|
1087
|
-
const masked = maskPreviewText(raw).trim()
|
|
1088
|
-
if (!masked) return ""
|
|
1089
|
-
return masked.replace(/\s+/g, " ").slice(0, LAST_TURN_TEXT_MAX)
|
|
1090
|
-
}
|
|
1077
|
+
const maskPreviewText = maskTurnText
|
|
1078
|
+
const sanitizeAssistantText = sanitizeTurnText
|
|
1091
1079
|
|
|
1092
1080
|
/**
|
|
1093
1081
|
* 1 行 (JSON) から user / assistant テキストを取り出し、未取得の側だけを埋める。
|
|
1094
1082
|
* 既に埋まっている側は触らない (末尾に近い方を優先するため)。
|
|
1095
1083
|
*/
|
|
1096
|
-
function
|
|
1084
|
+
export function normalizeLastTurnUserText(raw) {
|
|
1085
|
+
return normalizePreviewText(maskTurnText(raw), LAST_TURN_TEXT_MAX) || ""
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
async function takeTextsFromLine(line, user, assistant, isInjected) {
|
|
1097
1089
|
if (!line) return { user, assistant }
|
|
1098
1090
|
let obj
|
|
1099
1091
|
try {
|
|
@@ -1101,21 +1093,33 @@ function takeTextsFromLine(line, user, assistant) {
|
|
|
1101
1093
|
} catch {
|
|
1102
1094
|
return { user, assistant }
|
|
1103
1095
|
}
|
|
1104
|
-
if (!obj || !obj.message) return { user, assistant }
|
|
1096
|
+
if (!obj || !obj.message || obj.isSidechain || obj.parent_tool_use_id) return { user, assistant }
|
|
1105
1097
|
if (obj.type === "assistant" && !assistant) {
|
|
1106
1098
|
const t = sanitizeAssistantText(contentToPlainText(obj.message.content))
|
|
1107
1099
|
if (t) assistant = t
|
|
1108
1100
|
return { user, assistant }
|
|
1109
1101
|
}
|
|
1110
1102
|
if (obj.type === "user" && !user) {
|
|
1111
|
-
// isMeta / isSidechain はハーネス注入や sub-agent の発言でユーザーの依頼ではない。
|
|
1112
|
-
if (obj.isMeta === true || obj.isSidechain === true) return { user, assistant }
|
|
1113
1103
|
// マスクは正規化・切り詰めより前 (maskPreviewText の docstring 参照)。
|
|
1114
|
-
const
|
|
1104
|
+
const raw = contentToPlainText(obj.message.content)
|
|
1105
|
+
const masked = maskPreviewText(raw)
|
|
1115
1106
|
// normalizePreviewText が null を返す行 (合成 / /clear / local-command) はスキップし、
|
|
1116
1107
|
// 更に過去へ遡って「実際に打った依頼」を探す。
|
|
1117
|
-
|
|
1118
|
-
|
|
1108
|
+
if (!masked.trim() && contentToPlainText(obj.message.content).trim()) return { user, assistant: "" }
|
|
1109
|
+
// 通知だけの meta 行は上でターン境界とする。通常の補助行では応答を捨てない。
|
|
1110
|
+
if (obj.isMeta === true) return { user, assistant }
|
|
1111
|
+
const norm = normalizePreviewText(masked, LAST_TURN_TEXT_MAX)
|
|
1112
|
+
if (norm) {
|
|
1113
|
+
// 表示加工前の全文を照合し、注入ターンは応答も除外する。原文は外へ返さない。
|
|
1114
|
+
if (isInjected) {
|
|
1115
|
+
try {
|
|
1116
|
+
if ((await isInjected(raw)) === true) return { user: "", assistant: "" }
|
|
1117
|
+
} catch {
|
|
1118
|
+
// 注入判定の障害で通常の依頼まで消さない。
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
user = norm
|
|
1122
|
+
}
|
|
1119
1123
|
}
|
|
1120
1124
|
return { user, assistant }
|
|
1121
1125
|
}
|
|
@@ -1177,27 +1181,12 @@ export async function extractLastTurnTexts(filePath, opts = {}) {
|
|
|
1177
1181
|
skipped++
|
|
1178
1182
|
continue
|
|
1179
1183
|
}
|
|
1180
|
-
const res = takeTextsFromLine(lineBuf.toString("utf-8"), user, assistant)
|
|
1181
|
-
if (isInjected && res.user && !user) {
|
|
1182
|
-
let injected = false
|
|
1183
|
-
try {
|
|
1184
|
-
injected = (await isInjected(res.user)) === true
|
|
1185
|
-
} catch {
|
|
1186
|
-
// 注入判定の障害で通常の依頼まで消さない。
|
|
1187
|
-
}
|
|
1188
|
-
if (injected) {
|
|
1189
|
-
// 後方走査中の assistant はこの注入 user より後ろ、つまり同じ注入ターンの
|
|
1190
|
-
// 応答候補。依頼だけでなく応答も捨てて、更に前の自分のターンを探す。
|
|
1191
|
-
user = ""
|
|
1192
|
-
assistant = ""
|
|
1193
|
-
continue
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1184
|
+
const res = await takeTextsFromLine(lineBuf.toString("utf-8"), user, assistant, isInjected)
|
|
1196
1185
|
user = res.user
|
|
1197
1186
|
assistant = res.assistant
|
|
1198
|
-
if (
|
|
1187
|
+
if (user) break
|
|
1199
1188
|
}
|
|
1200
|
-
if (
|
|
1189
|
+
if (user) break
|
|
1201
1190
|
if (skipped > LAST_TURN_MAX_SKIPPED_LINES) break
|
|
1202
1191
|
|
|
1203
1192
|
if (carry && carry.length > LAST_TURN_MAX_LINE) {
|
|
@@ -25,7 +25,7 @@ import os from "node:os"
|
|
|
25
25
|
import path from "node:path"
|
|
26
26
|
import { isUnderRemoval } from "./chat-removal-guard.mjs"
|
|
27
27
|
|
|
28
|
-
import { jsonlPath } from "./claude-history.mjs"
|
|
28
|
+
import { jsonlPath, normalizeLastTurnUserText } from "./claude-history.mjs"
|
|
29
29
|
import { watchSessionFile } from "./claude-history-watch.mjs"
|
|
30
30
|
import {
|
|
31
31
|
MASK_ENABLED,
|
|
@@ -661,6 +661,9 @@ export class ClaudeStreamSession {
|
|
|
661
661
|
* true になるため)。その穴は SDK-P0-4 の _staleIdleExpected / _turnRunningSeen で塞ぐ。
|
|
662
662
|
* ターン開始時に false へ戻す。 */
|
|
663
663
|
this._turnProgressed = false
|
|
664
|
+
this._lastTurnTexts = null
|
|
665
|
+
// resident の中断後は同じ query の遅延イベントを帰属できないため、保守的に不明を維持する。
|
|
666
|
+
this._turnTextsUncertain = false
|
|
664
667
|
/** SDK-P0-4 (2026-07-13): ターン連番。ログと帰属デバッグ用 (_beginResidentTurn で ++)。 */
|
|
665
668
|
this._turnSeq = 0
|
|
666
669
|
/** SDK-P0-4: 「直前ターンの idle がこれから 1 件届きうる」状態。abort (interrupt) で
|
|
@@ -1048,6 +1051,7 @@ export class ClaudeStreamSession {
|
|
|
1048
1051
|
_setSessionId(id) {
|
|
1049
1052
|
if (this.sessionId !== id) this._rowContext = null
|
|
1050
1053
|
this.sessionId = id
|
|
1054
|
+
if (this._lastTurnTexts && !this._lastTurnTexts.conversationId) this._lastTurnTexts.conversationId = id
|
|
1051
1055
|
}
|
|
1052
1056
|
|
|
1053
1057
|
/** 再アタッチ: 走行中(または生存中)セッションに新しい stream_id を紐付け直し、
|
|
@@ -1407,7 +1411,7 @@ export class ClaudeStreamSession {
|
|
|
1407
1411
|
return { accepted: true }
|
|
1408
1412
|
}
|
|
1409
1413
|
if (this._closed) return { accepted: false, reason: "queue_dead" }
|
|
1410
|
-
this._beginResidentTurn()
|
|
1414
|
+
this._beginResidentTurn(prompt)
|
|
1411
1415
|
// 空 InputQueue 窓封鎖 (T06118): fresh start。常駐 query が未起動 (初回) / 異常終了済み
|
|
1412
1416
|
// (_residentQuery=null = 死亡ガード B6) / generator 返却済み (_residentQueryReturned) の
|
|
1413
1417
|
// いずれも「新しい query を起動する」ケース。従来は空の InputQueue で起動してから push
|
|
@@ -1460,11 +1464,12 @@ export class ClaudeStreamSession {
|
|
|
1460
1464
|
}
|
|
1461
1465
|
// Phase 2 (major#1): per-message idle 直接実行も started を出さないため、注入なら source 付きで
|
|
1462
1466
|
// 昇格バブルを作るよう started を先出しする (通常メッセージは no-op)。
|
|
1467
|
+
const injectSource = this._pendingInjectSources.find((entry) => entry.text === prompt)?.source
|
|
1463
1468
|
this._maybeEmitInjectStarted(prompt)
|
|
1464
1469
|
// ack はターン完走を待たず「起動できたか」で返すため、フル実行の Promise を
|
|
1465
1470
|
// 呼び出し元へ伝播させない (fire-and-forget)。例外は _runPerMessage 内部で
|
|
1466
1471
|
// 握っているため通常は reject しないが、念のため warn だけ残す。
|
|
1467
|
-
this._runPerMessage(prompt, { ultracode }).catch((err) => {
|
|
1472
|
+
this._runPerMessage(prompt, { ultracode, injectSource }).catch((err) => {
|
|
1468
1473
|
this.logger?.error(
|
|
1469
1474
|
{ stream_id: this.stream_id, err: err?.message },
|
|
1470
1475
|
"per-message run threw unexpectedly",
|
|
@@ -1478,6 +1483,7 @@ export class ClaudeStreamSession {
|
|
|
1478
1483
|
async _runPerMessage(prompt, opts = {}) {
|
|
1479
1484
|
const finishRunLoop = this._markRunLoopStarted()
|
|
1480
1485
|
this._busy = true
|
|
1486
|
+
this._beginTurnTexts(prompt, false, opts.injectSource)
|
|
1481
1487
|
this._abortController = new AbortController()
|
|
1482
1488
|
let aborted = false
|
|
1483
1489
|
// `session_state_changed:idle` がターン終了の正本。SDK が idle を流さず generator が
|
|
@@ -1564,6 +1570,8 @@ export class ClaudeStreamSession {
|
|
|
1564
1570
|
resultDelivered = true
|
|
1565
1571
|
if (typeof msg.session_id === "string") this._setSessionId(msg.session_id)
|
|
1566
1572
|
}
|
|
1573
|
+
this._observeTurnTexts(msg)
|
|
1574
|
+
if (msg?.type === "result" || isSessionIdleEvent(msg)) this._completeTurnTexts()
|
|
1567
1575
|
if (isSessionIdleEvent(msg)) idleDelivered = true
|
|
1568
1576
|
try {
|
|
1569
1577
|
this._emit(msg)
|
|
@@ -1721,8 +1729,9 @@ export class ClaudeStreamSession {
|
|
|
1721
1729
|
return
|
|
1722
1730
|
}
|
|
1723
1731
|
const next = this._pendingMessages.shift()
|
|
1732
|
+
const injectSource = this._pendingInjectSources.find((entry) => entry.text === next.text)?.source
|
|
1724
1733
|
this._emitQueueState([next.text], { startedIds: [next.id] })
|
|
1725
|
-
this._runPerMessage(next.text, { ultracode: next.ultracode === true }).catch(
|
|
1734
|
+
this._runPerMessage(next.text, { ultracode: next.ultracode === true, injectSource }).catch(
|
|
1726
1735
|
(err) => {
|
|
1727
1736
|
this.logger?.error(
|
|
1728
1737
|
{ stream_id: this.stream_id, err: err?.message },
|
|
@@ -2027,7 +2036,7 @@ export class ClaudeStreamSession {
|
|
|
2027
2036
|
// pending drain を見送っているので、自分のメッセージを push 先行で fresh-start する
|
|
2028
2037
|
// (pending はこの turn settle 後に _drainResidentPending が FIFO で流す)。await 中の
|
|
2029
2038
|
// finally で _busy が false へ落ちているため、_beginResidentTurn で張り直してから起動する。
|
|
2030
|
-
this._beginResidentTurn()
|
|
2039
|
+
this._beginResidentTurn(sdkMsg.message.content)
|
|
2031
2040
|
this._startResidentTurnWithInput(sdkMsg, ultracode)
|
|
2032
2041
|
return "restarted"
|
|
2033
2042
|
}
|
|
@@ -2035,12 +2044,53 @@ export class ClaudeStreamSession {
|
|
|
2035
2044
|
return "pushed"
|
|
2036
2045
|
}
|
|
2037
2046
|
|
|
2047
|
+
/** 最新ターンと、通知専用ターンを飛ばすための直近の実依頼を保持する。 */
|
|
2048
|
+
_beginTurnTexts(prompt, uncertain, injectSource) {
|
|
2049
|
+
const prior = this._lastTurnTexts
|
|
2050
|
+
if (prior?.known && !prior.injected && normalizeLastTurnUserText(prior.user)) {
|
|
2051
|
+
const { previous, ...texts } = prior
|
|
2052
|
+
this._previousUserTurnTexts = texts
|
|
2053
|
+
}
|
|
2054
|
+
this._lastTurnTexts = {
|
|
2055
|
+
previous: this._previousUserTurnTexts,
|
|
2056
|
+
injected: Boolean(injectSource) || this._pendingInjectSources.some((entry) => entry.text === prompt),
|
|
2057
|
+
conversationId: this.sessionId,
|
|
2058
|
+
user: typeof prompt === "string" ? prompt : (Array.isArray(prompt)
|
|
2059
|
+
? prompt.filter((b) => b?.type === "text").map((b) => b.text).join("\n") : ""),
|
|
2060
|
+
assistant: "",
|
|
2061
|
+
known: !uncertain,
|
|
2062
|
+
completed: false,
|
|
2063
|
+
startedUncertain: uncertain === true,
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
_observeTurnTexts(msg) {
|
|
2068
|
+
const turn = this._lastTurnTexts
|
|
2069
|
+
if (!turn || turn.completed || !this._busy || !turn.known) return
|
|
2070
|
+
if (msg?.parent_tool_use_id || msg?.isSidechain || msg?.isMeta) return
|
|
2071
|
+
if (msg?.session_id && turn.conversationId && msg.session_id !== turn.conversationId) {
|
|
2072
|
+
turn.known = false
|
|
2073
|
+
return
|
|
2074
|
+
}
|
|
2075
|
+
if (msg?.type !== "assistant") return
|
|
2076
|
+
const blocks = msg.message?.content
|
|
2077
|
+
if (!Array.isArray(blocks)) return
|
|
2078
|
+
const text = blocks.filter((b) => b?.type === "text" && typeof b.text === "string")
|
|
2079
|
+
.map((b) => b.text).join("\n")
|
|
2080
|
+
if (text) turn.assistant = text
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
_completeTurnTexts() {
|
|
2084
|
+
if (this._lastTurnTexts?.known) this._lastTurnTexts.completed = true
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2038
2087
|
/** SDK-P0-3 (2026-07-10): resident の「ターン開始」を 1 箇所に集約する。
|
|
2039
2088
|
* _busy を立てるのと同時に、そのターンの終端フラグ (_turnSettled) と進行フラグ
|
|
2040
2089
|
* (_turnProgressed) を必ずリセットする。ターン開始は 3 箇所ある
|
|
2041
2090
|
* (sendMessage の直接 push / _drainResidentPending / _runResidentQuery finally の
|
|
2042
2091
|
* 異常再起動 push) ので、リセット漏れを構造的に防ぐためのヘルパー。 */
|
|
2043
|
-
_beginResidentTurn() {
|
|
2092
|
+
_beginResidentTurn(prompt) {
|
|
2093
|
+
this._beginTurnTexts(prompt, this._turnTextsUncertain)
|
|
2044
2094
|
this._busy = true
|
|
2045
2095
|
this._turnSettled = false
|
|
2046
2096
|
this._turnProgressed = false
|
|
@@ -2084,6 +2134,9 @@ export class ClaudeStreamSession {
|
|
|
2084
2134
|
_settleResidentTurn(reason, denyPending) {
|
|
2085
2135
|
if (this._turnSettled) return
|
|
2086
2136
|
this._turnSettled = true
|
|
2137
|
+
this._completeTurnTexts()
|
|
2138
|
+
// 中断直後の 1 ターンだけを帰属不明として扱い、それが確定したら次からは信頼に戻す。
|
|
2139
|
+
if (this._lastTurnTexts?.startedUncertain) this._turnTextsUncertain = false
|
|
2087
2140
|
denyPending?.("turn ended")
|
|
2088
2141
|
this._busy = false
|
|
2089
2142
|
// result の後に同一ターンの idle が遅れて届く場合がある。pending drain は同期的に
|
|
@@ -2119,7 +2172,7 @@ export class ClaudeStreamSession {
|
|
|
2119
2172
|
// _busy=true のまま残り、以降の drain が全て弾かれて pending が永久に積み上がる
|
|
2120
2173
|
// デッドロックになる。失敗時は取り出したメッセージを先頭へ戻し _busy を解除して、
|
|
2121
2174
|
// 次の drain がやり直せる状態に必ず回復する (例外は既存方針通り warn で握る)。
|
|
2122
|
-
this._beginResidentTurn()
|
|
2175
|
+
this._beginResidentTurn(next.text)
|
|
2123
2176
|
try {
|
|
2124
2177
|
// ABA/FIFO 直列化 (T06118 codex-review major×2): drain も reconcile を await するため、
|
|
2125
2178
|
// その窓で旧 query が終了して finally が「その先の pending」を新 query へ先行投入すると、
|
|
@@ -2216,6 +2269,7 @@ export class ClaudeStreamSession {
|
|
|
2216
2269
|
this._residentQuery = q
|
|
2217
2270
|
for await (const msg of q) {
|
|
2218
2271
|
let suppressEmit = false
|
|
2272
|
+
this._observeTurnTexts(msg)
|
|
2219
2273
|
if (
|
|
2220
2274
|
msg?.type === "system" &&
|
|
2221
2275
|
msg?.subtype === "init" &&
|
|
@@ -2332,7 +2386,7 @@ export class ClaudeStreamSession {
|
|
|
2332
2386
|
// await 復帰後の送信が自分のメッセージを push 先行で fresh-start する。pending はその
|
|
2333
2387
|
// turn settle 後に _drainResidentPending が FIFO で流す。
|
|
2334
2388
|
const next = this._pendingMessages.shift()
|
|
2335
|
-
this._beginResidentTurn()
|
|
2389
|
+
this._beginResidentTurn(next.text)
|
|
2336
2390
|
this._startResidentTurnWithInput(
|
|
2337
2391
|
toSDKUserMessage(next.text),
|
|
2338
2392
|
next.ultracode === true,
|
|
@@ -2382,6 +2436,10 @@ export class ClaudeStreamSession {
|
|
|
2382
2436
|
|
|
2383
2437
|
/** 実行中ターンを中断する (次の input は引き続き受け付ける)。 */
|
|
2384
2438
|
abortTurn() {
|
|
2439
|
+
if (this._busy) {
|
|
2440
|
+
if (this._lastTurnTexts) this._lastTurnTexts.known = false
|
|
2441
|
+
if (this._residentQuery) this._turnTextsUncertain = true
|
|
2442
|
+
}
|
|
2385
2443
|
// 常駐 query は interrupt() で現ターンのみ中断する (query は継続、次 input 受付継続)。
|
|
2386
2444
|
if (this._residentQuery && typeof this._residentQuery.interrupt === "function") {
|
|
2387
2445
|
const wasBusy = this._busy
|
|
@@ -3552,6 +3610,17 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3552
3610
|
return best
|
|
3553
3611
|
}
|
|
3554
3612
|
|
|
3613
|
+
/** 会話 ID が一致するターンだけを同期で返す。 */
|
|
3614
|
+
lastTurnTextsForConversation(cwd, conversationId) {
|
|
3615
|
+
if (!cwd || !conversationId) return null
|
|
3616
|
+
for (const session of new Set([...this.sessions.values(), ...this._liveBySession.values()])) {
|
|
3617
|
+
if (session._closed || session.cwd !== cwd || session.sessionId !== conversationId) continue
|
|
3618
|
+
const turn = session._lastTurnTexts
|
|
3619
|
+
if (turn?.conversationId === conversationId) return { ...turn }
|
|
3620
|
+
}
|
|
3621
|
+
return null
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3555
3624
|
/**
|
|
3556
3625
|
* main.mjs (startStateLoop / tmux.list_sessions ハンドラ) が使うチャット版 bound session 解決。
|
|
3557
3626
|
* cwd 内で最終活性が最も新しいライブセッションの sessionId を返す。背景は _liveSessionForCwd 参照。
|
|
@@ -1224,8 +1224,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1224
1224
|
// major#2: byte 予算判定を O(1) にするため概算 byte 合計も持つ (flatten は既に byte 上限適用済み)。
|
|
1225
1225
|
// app-server reap 後の cold resume は rollout が正本になるため、未 rollout の空 fresh session や
|
|
1226
1226
|
// 未保存の sibling source タグは復元できない。空 session の滞留を避けるため fresh 例外は作らない。
|
|
1227
|
-
session.historyItems = history.
|
|
1228
|
-
session.historyBytes =
|
|
1227
|
+
session.historyItems = history.map((item) => ({ ...item, _hubThreadId: session.threadId }))
|
|
1228
|
+
session.historyBytes = session.historyItems.reduce((n, it) => n + estimateItemBytes(it), 0)
|
|
1229
1229
|
session.historyTruncated = historyTruncated
|
|
1230
1230
|
session.historyStart = flat.start
|
|
1231
1231
|
session.historyTotal = flat.total
|
|
@@ -1368,6 +1368,39 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1368
1368
|
return null
|
|
1369
1369
|
}
|
|
1370
1370
|
|
|
1371
|
+
/** 会話 ID が一致する親ターンの同期スナップショット。
|
|
1372
|
+
* 通知は直近の完了ターン、サイドバーは historyItems から最後の人間の依頼と返答を選ぶ。
|
|
1373
|
+
*/
|
|
1374
|
+
lastTurnTextsForConversation(cwd, conversationId) {
|
|
1375
|
+
const session = this._findLiveSessionByCwd(cwd, conversationId)
|
|
1376
|
+
if (!session || session.threadId !== conversationId) return null
|
|
1377
|
+
// 依頼より後ろの親の返答(新しい順)。別ターンの後着返答を依頼と組にしないよう、
|
|
1378
|
+
// 依頼を見つけた時点でターン ID が両立する最新の返答を選ぶ(ID 不明は両立扱い)。
|
|
1379
|
+
const replies = []
|
|
1380
|
+
const items = session.historyItems || []
|
|
1381
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
1382
|
+
const item = items[i]
|
|
1383
|
+
if (item?._hubSubagent) continue
|
|
1384
|
+
if (item?._hubThreadId && item._hubThreadId !== session.threadId) continue
|
|
1385
|
+
const content = typeof item?.text === "string" ? item.text : typeof item?.content === "string" ? item.content
|
|
1386
|
+
: (Array.isArray(item?.content) ? item.content.map((b) => b?.text || "").join("\n") : "")
|
|
1387
|
+
if (item?.type === "agentMessage") replies.push({ content, turnId: item._hubTurnId, sourced: Boolean(item._hubThreadId) })
|
|
1388
|
+
if (item?.type !== "userMessage") continue
|
|
1389
|
+
const reply = replies.find((r) => !r.turnId || !item._hubTurnId || r.turnId === item._hubTurnId)
|
|
1390
|
+
const assistant = reply?.content ?? ""
|
|
1391
|
+
const assistantTurnId = reply?.turnId
|
|
1392
|
+
// 送信元の確認は、実際に組にした依頼と返答で判定する(由来不明なら固定文へ退避)。
|
|
1393
|
+
const known = Boolean(item._hubThreadId) && (!reply || reply.sourced)
|
|
1394
|
+
return {
|
|
1395
|
+
user: content, assistant, known,
|
|
1396
|
+
historyItems: items.filter((entry) => entry?._hubThreadId === session.threadId),
|
|
1397
|
+
completed: Boolean(assistantTurnId) && assistantTurnId === session.lastCompletedTurnId
|
|
1398
|
+
&& !session.activeTurnId && session.lastCompletedTextTurn === session.turnGeneration,
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
return null
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1371
1404
|
/**
|
|
1372
1405
|
* T06541: 同一 (cwd, threadId) のライブ session を返す (cwd ベース in-memory reattach 用)。
|
|
1373
1406
|
* frontend は attach_id を送らないため attachId 経路は発火せず、resume は毎回 thread/resume で
|
|
@@ -3392,6 +3425,10 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
3392
3425
|
}
|
|
3393
3426
|
effectiveParams = { ...effectiveParams, item: attributedItem }
|
|
3394
3427
|
}
|
|
3428
|
+
if (msg.method === "item/completed" && effectiveParams?.item && typeof effectiveParams.item === "object") {
|
|
3429
|
+
// spawn 登録前でも受信元は保持する。履歴と配信は同じ clone を使う。
|
|
3430
|
+
effectiveParams = { ...effectiveParams, item: { ...effectiveParams.item, _hubThreadId: eventThreadId, _hubTurnId: msg.params?.turnId } }
|
|
3431
|
+
}
|
|
3395
3432
|
if (msg.method === "item/started" || msg.method === "item/completed") {
|
|
3396
3433
|
this._observeSubagentItem(
|
|
3397
3434
|
session,
|
|
@@ -3444,6 +3481,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
3444
3481
|
)
|
|
3445
3482
|
break
|
|
3446
3483
|
}
|
|
3484
|
+
session.lastCompletedTurnId = msg.params?.turn?.status === "completed" ? completedTurnId : null
|
|
3485
|
+
session.lastCompletedTextTurn = session.activeTurnId && msg.params?.turn?.status === "completed"
|
|
3486
|
+
? session.turnGeneration : null
|
|
3447
3487
|
session.activeTurnId = null
|
|
3448
3488
|
}
|
|
3449
3489
|
// round3 major#2: turn/started を取りこぼしたまま turn/completed が来た異常時も barrier が
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { lookupInjectSource } from "./inject-source-store.mjs"
|
|
2
|
+
import { redactSecrets, SECRET_PLACEHOLDER } from "./tool-result-mask.mjs"
|
|
3
|
+
|
|
4
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 冒頭が通知タグなら本文全体を捨て、本文途中の通知は最初の閉じタグまで除去する。
|
|
8
|
+
* 本文途中の通知が閉じタグを引用している場合、その引用より後の残りは保持される。
|
|
9
|
+
*/
|
|
10
|
+
export function stripTurnNotifications(value) {
|
|
11
|
+
if (typeof value !== "string") return ""
|
|
12
|
+
if (/^<(?:task-notification|system-reminder)>/.test(value.trim())) return ""
|
|
13
|
+
const text = value.replace(/<(task-notification|system-reminder)>[\s\S]*?(?:<\/\1>|$)/g, "")
|
|
14
|
+
return text
|
|
15
|
+
// 見出し付き通知は、空行(段落の終わり)か末尾までを落とす。後続の本文は残す。
|
|
16
|
+
.replace(/^[ \t]*\[SYSTEM NOTIFICATION[\s\S]*?(?=\r?\n[ \t]*\r?\n|(?![\s\S]))/gm, "")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function maskTurnText(value) {
|
|
20
|
+
let text = redactSecrets(stripTurnNotifications(value))
|
|
21
|
+
const at = text.indexOf("-----BEGIN")
|
|
22
|
+
if (at >= 0) text = text.slice(0, at) + SECRET_PLACEHOLDER
|
|
23
|
+
return text
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function truncateTurnText(value, max = 120) {
|
|
27
|
+
let text = ""
|
|
28
|
+
let count = 0
|
|
29
|
+
for (const { segment } of segmenter.segment(value)) {
|
|
30
|
+
if (count++ >= max) break
|
|
31
|
+
text += segment
|
|
32
|
+
}
|
|
33
|
+
return text
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function sanitizeTurnText(value) {
|
|
37
|
+
return truncateTurnText(maskTurnText(value).trim().replace(/\s+/g, " "))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cleanPair(texts) {
|
|
41
|
+
return {
|
|
42
|
+
user: sanitizeTurnText(texts?.user),
|
|
43
|
+
assistant: sanitizeTurnText(texts?.assistant),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 同期の保持情報だけを使う。未知・進行中・別会話は通知文にしない。 */
|
|
48
|
+
export function liveLastTurnText(cwd, kind, conversationId, deps = {}) {
|
|
49
|
+
const bridge = kind === "claude" ? deps.claudeBridge : kind === "codex" ? deps.codexBridge : null
|
|
50
|
+
const turn = bridge?.lastTurnTextsForConversation?.(cwd, conversationId)
|
|
51
|
+
return turn?.known === true && turn.completed === true
|
|
52
|
+
? sanitizeTurnText(turn.assistant)
|
|
53
|
+
: ""
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** bridge の組を優先し、未知の会話だけディスクへ委ねる。 */
|
|
57
|
+
export async function readLastTurnTexts(target, deps = {}) {
|
|
58
|
+
const isInjected = deps.isInjected ?? (async (text) => Boolean(await lookupInjectSource(target.cwd, text)))
|
|
59
|
+
const bridge = target.cliKind === "claude" ? deps.claudeBridge : deps.codexBridge
|
|
60
|
+
const turn = bridge?.lastTurnTextsForConversation?.(target.cwd, target.conversationId)
|
|
61
|
+
const { readDiskLastTurnTexts, textsFromCodexItems } = await import("./sidebar-message-snapshot.mjs")
|
|
62
|
+
if (turn?.known === true) {
|
|
63
|
+
if (target.cliKind === "codex") {
|
|
64
|
+
const texts = await textsFromCodexItems(turn.historyItems, { isInjected })
|
|
65
|
+
if (texts.user) return cleanPair(texts)
|
|
66
|
+
} else {
|
|
67
|
+
const { normalizeLastTurnUserText } = await import("./claude-history.mjs")
|
|
68
|
+
for (const candidate of [turn, turn.previous]) {
|
|
69
|
+
if (!candidate?.known || candidate.conversationId !== turn.conversationId) continue
|
|
70
|
+
const user = normalizeLastTurnUserText(candidate.user)
|
|
71
|
+
let injected = candidate.injected === true
|
|
72
|
+
try { injected ||= (await isInjected(candidate.user)) === true } catch { /* 判定不能なら残す。 */ }
|
|
73
|
+
if (user && !injected) return cleanPair({ user, assistant: candidate.assistant })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// 既存の依存注入契約も維持する。live の user と disk の assistant は混ぜない。
|
|
78
|
+
if (target.cliKind === "codex" && !bridge) {
|
|
79
|
+
const live = deps.liveCodexSession?.(target)
|
|
80
|
+
if (live?.threadId === target.conversationId) {
|
|
81
|
+
const texts = await textsFromCodexItems(live.historyItems, { isInjected })
|
|
82
|
+
if (texts.user) return cleanPair(texts)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return cleanPair(await readDiskLastTurnTexts(target, { ...deps, isInjected }))
|
|
86
|
+
}
|
package/src/main.mjs
CHANGED
|
@@ -127,6 +127,7 @@ import {
|
|
|
127
127
|
hasRepresentativeRow,
|
|
128
128
|
} from "./chat-signals.mjs"
|
|
129
129
|
import { cliKindForSessionName, sessionNameFor } from "./session-names.mjs"
|
|
130
|
+
import { liveLastTurnText } from "./last-turn-texts.mjs"
|
|
130
131
|
import { hydrateProcessEnvFromShell } from "./shell-env.mjs"
|
|
131
132
|
|
|
132
133
|
export function resolveCodexLoginCommand(configDir, { isDefault = false, _resolveCodexBinary = resolveCodexBinary } = {}) {
|
|
@@ -1618,7 +1619,7 @@ export async function publishSessionEvent(
|
|
|
1618
1619
|
sessionName,
|
|
1619
1620
|
event,
|
|
1620
1621
|
at,
|
|
1621
|
-
{ fresh = false, arm = !fresh } = {},
|
|
1622
|
+
{ fresh = false, arm = !fresh, text = "" } = {},
|
|
1622
1623
|
) {
|
|
1623
1624
|
// readiness (proc 内省の ready 判定) を動かすのはファイル監視経路だけ。直接 publish (fresh) は
|
|
1624
1625
|
// 「人間が送った」事実を並び順へ流すだけで、ターンの開始を意味しない (bridge が拒否すれば
|
|
@@ -1641,13 +1642,16 @@ export async function publishSessionEvent(
|
|
|
1641
1642
|
sort_affecting: sortAffecting,
|
|
1642
1643
|
}, "session.event push")
|
|
1643
1644
|
}
|
|
1644
|
-
|
|
1645
|
+
const message = {
|
|
1645
1646
|
type: "session.event",
|
|
1646
1647
|
session_name: sessionName,
|
|
1647
1648
|
event,
|
|
1648
1649
|
at,
|
|
1649
1650
|
sort_affecting: sortAffecting,
|
|
1650
|
-
}
|
|
1651
|
+
}
|
|
1652
|
+
// 通知本文に添える最後の返答。空なら項目ごと送らない (Hub は固定文だけにする)。
|
|
1653
|
+
if (typeof text === "string" && text) message.text = text
|
|
1654
|
+
client.send(message)
|
|
1651
1655
|
return { sortAffecting }
|
|
1652
1656
|
}
|
|
1653
1657
|
|
|
@@ -2071,7 +2075,12 @@ export function startStateLoop({ client, logger, intervalMs, claudeBridge, codex
|
|
|
2071
2075
|
// 既知の取りこぼし: 前回 tick も今回 tick も waiting で、その間にターンが
|
|
2072
2076
|
// 開始・完了すると ev が stop のまま変わらないため、通知は遅延ではなく欠落する。
|
|
2073
2077
|
// 5 秒 tick を前提にした仕様。
|
|
2074
|
-
|
|
2078
|
+
// 返答は完了 (stop) のときだけ添える。bridge の保持値を同期で読み、完了が確認できない・
|
|
2079
|
+
// 別会話のときは空 = 固定文だけになる。
|
|
2080
|
+
const text = event.ev === "stop"
|
|
2081
|
+
? liveLastTurnText(cwd, kind, chat.sessionId, { claudeBridge, codexBridge })
|
|
2082
|
+
: ""
|
|
2083
|
+
publishSessionEvent({ client, logger }, sessionName, event.ev, event.at, { arm: false, text })
|
|
2075
2084
|
.catch(err => logger?.warn({ err: err?.message }, "chat session.event publish failed"))
|
|
2076
2085
|
}
|
|
2077
2086
|
}
|
|
@@ -4429,10 +4438,8 @@ export async function dispatch(msg, ctx) {
|
|
|
4429
4438
|
case "chat.sidebar_snapshot.request": {
|
|
4430
4439
|
try {
|
|
4431
4440
|
const messages = await readSidebarMessageSnapshot(msg.targets, {
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
return live?.threadId === target.conversationId ? live : null
|
|
4435
|
-
},
|
|
4441
|
+
claudeBridge: ctx.claudeBridge,
|
|
4442
|
+
codexBridge: ctx.codexBridge,
|
|
4436
4443
|
})
|
|
4437
4444
|
ctx.client.send({
|
|
4438
4445
|
type: "chat.sidebar_snapshot.response",
|
|
@@ -2,11 +2,11 @@ import { open, readdir, realpath, stat } from "node:fs/promises"
|
|
|
2
2
|
import os from "node:os"
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
|
|
5
|
-
import { extractLastTurnTexts, jsonlPath
|
|
5
|
+
import { extractLastTurnTexts, jsonlPath } from "./claude-history.mjs"
|
|
6
6
|
import { resolveProjectsRoot } from "./profiles.mjs"
|
|
7
7
|
import * as codexProfiles from "./codex-profiles.mjs"
|
|
8
8
|
import { lookupInjectSource } from "./inject-source-store.mjs"
|
|
9
|
-
import {
|
|
9
|
+
import { readLastTurnTexts, sanitizeTurnText } from "./last-turn-texts.mjs"
|
|
10
10
|
|
|
11
11
|
const MAX_TARGETS = 100
|
|
12
12
|
const READ_CONCURRENCY = 8
|
|
@@ -19,16 +19,10 @@ const textCache = new Map()
|
|
|
19
19
|
const textInFlight = new Map()
|
|
20
20
|
const rolloutPathCache = new Map()
|
|
21
21
|
|
|
22
|
-
|
|
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
|
-
}
|
|
22
|
+
const sanitizeText = sanitizeTurnText
|
|
29
23
|
|
|
30
24
|
function itemText(item) {
|
|
31
|
-
const content = item?.content
|
|
25
|
+
const content = item?.text ?? item?.content
|
|
32
26
|
if (typeof content === "string") return content
|
|
33
27
|
if (!Array.isArray(content)) return ""
|
|
34
28
|
return content
|
|
@@ -43,27 +37,34 @@ function itemText(item) {
|
|
|
43
37
|
export async function textsFromCodexItems(items, { isInjected } = {}) {
|
|
44
38
|
let assistant = ""
|
|
45
39
|
if (!Array.isArray(items)) return { user: "", assistant }
|
|
40
|
+
// 依頼より後ろの返答(新しい順)。別ターンの後着返答を依頼と組にしない(ID 不明は両立扱い)。
|
|
41
|
+
let replies = []
|
|
46
42
|
for (let i = items.length - 1; i >= 0; i--) {
|
|
47
43
|
const item = items[i]
|
|
48
|
-
if (item?.
|
|
49
|
-
|
|
44
|
+
if (item?._hubSubagent) continue
|
|
45
|
+
if (item?.type === "agentMessage") {
|
|
46
|
+
replies.push(item)
|
|
50
47
|
continue
|
|
51
48
|
}
|
|
52
49
|
if (item?.type !== "userMessage") continue
|
|
53
|
-
const
|
|
54
|
-
|
|
50
|
+
const raw = itemText(item)
|
|
51
|
+
const user = sanitizeText(raw)
|
|
52
|
+
if (!user) { replies = []; continue }
|
|
55
53
|
let injected = item.source === "peer" || item.source === "orchestrator"
|
|
56
54
|
if (!injected && typeof isInjected === "function") {
|
|
57
55
|
try {
|
|
58
|
-
|
|
56
|
+
// 永続ストアは全文一致なので、表示加工前の原文で照合する。
|
|
57
|
+
injected = (await isInjected(raw)) === true
|
|
59
58
|
} catch {
|
|
60
59
|
// 判定不能なら通常入力として残す。
|
|
61
60
|
}
|
|
62
61
|
}
|
|
63
62
|
if (injected) {
|
|
64
|
-
|
|
63
|
+
replies = []
|
|
65
64
|
continue
|
|
66
65
|
}
|
|
66
|
+
const reply = replies.find((r) => !r._hubTurnId || !item._hubTurnId || r._hubTurnId === item._hubTurnId)
|
|
67
|
+
assistant = reply ? sanitizeText(itemText(reply)) : ""
|
|
67
68
|
return { user, assistant }
|
|
68
69
|
}
|
|
69
70
|
return { user: "", assistant }
|
|
@@ -79,10 +80,10 @@ function codexLineMessage(line) {
|
|
|
79
80
|
}
|
|
80
81
|
if (obj?.type !== "event_msg" || !obj.payload) return null
|
|
81
82
|
if (obj.payload.type === "agent_message") {
|
|
82
|
-
return { role: "assistant", text:
|
|
83
|
+
return { role: "assistant", text: obj.payload.message }
|
|
83
84
|
}
|
|
84
85
|
if (obj.payload.type === "user_message") {
|
|
85
|
-
return { role: "user", text:
|
|
86
|
+
return { role: "user", text: obj.payload.message }
|
|
86
87
|
}
|
|
87
88
|
return null
|
|
88
89
|
}
|
|
@@ -120,9 +121,14 @@ export async function extractCodexLastTurnTexts(filePath, { isInjected } = {}) {
|
|
|
120
121
|
for (let i = parts.length - 1; i >= 0; i--) {
|
|
121
122
|
if (parts[i].length === 0 || parts[i].length > MAX_LINE) continue
|
|
122
123
|
const message = codexLineMessage(parts[i].toString("utf8"))
|
|
123
|
-
if (!message
|
|
124
|
+
if (!message) continue
|
|
125
|
+
const text = sanitizeText(message.text)
|
|
126
|
+
if (!text) {
|
|
127
|
+
if (message.role === "user") assistant = ""
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
124
130
|
if (message.role === "assistant") {
|
|
125
|
-
if (!assistant) assistant =
|
|
131
|
+
if (!assistant) assistant = text
|
|
126
132
|
continue
|
|
127
133
|
}
|
|
128
134
|
let injected = false
|
|
@@ -137,7 +143,7 @@ export async function extractCodexLastTurnTexts(filePath, { isInjected } = {}) {
|
|
|
137
143
|
assistant = ""
|
|
138
144
|
continue
|
|
139
145
|
}
|
|
140
|
-
return { user:
|
|
146
|
+
return { user: text, assistant }
|
|
141
147
|
}
|
|
142
148
|
}
|
|
143
149
|
return { user: "", assistant }
|
|
@@ -267,6 +273,8 @@ export async function readSidebarMessageSnapshot(
|
|
|
267
273
|
resolveClaudeRoot = resolveProjectsRoot,
|
|
268
274
|
resolveCodexRoot = codexProfiles.resolveSessionsDir,
|
|
269
275
|
liveCodexSession,
|
|
276
|
+
claudeBridge,
|
|
277
|
+
codexBridge,
|
|
270
278
|
isAllowedCwd = defaultIsAllowedCwd,
|
|
271
279
|
lookupInjected = lookupInjectSource,
|
|
272
280
|
extractClaude = extractLastTurnTexts,
|
|
@@ -283,47 +291,19 @@ export async function readSidebarMessageSnapshot(
|
|
|
283
291
|
if (!(await isAllowedCwd(target.cwd))) throw new Error("cwd outside workspace roots")
|
|
284
292
|
const isInjected = async (text) =>
|
|
285
293
|
Boolean(await lookupInjected(target.cwd, text))
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
)
|
|
297
|
-
return {
|
|
298
|
-
session_name: target.sessionName,
|
|
299
|
-
cli_kind: "claude",
|
|
300
|
-
conversation_id: target.conversationId,
|
|
301
|
-
profile_id: profileId,
|
|
302
|
-
...(texts.user ? { user: texts.user } : {}),
|
|
303
|
-
...(texts.assistant ? { assistant: texts.assistant } : {}),
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
const live = liveCodexSession?.(target)
|
|
308
|
-
if (live && live.threadId === target.conversationId) {
|
|
309
|
-
texts = await textsFromCodexItems(live.historyItems, { isInjected })
|
|
310
|
-
}
|
|
311
|
-
const { sessionsDir, profileId } = await resolveCodexRoot(target.profileId)
|
|
312
|
-
if (!texts.user || !texts.assistant) {
|
|
313
|
-
const filePath = await findCodexRolloutFile(target.conversationId, sessionsDir)
|
|
314
|
-
const diskTexts = await readCached(filePath, (fp) =>
|
|
315
|
-
extractCodex(fp, { isInjected }),
|
|
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
|
-
}
|
|
294
|
+
const resolved = target.cliKind === "claude"
|
|
295
|
+
? await resolveClaudeRoot(target.profileId)
|
|
296
|
+
: await resolveCodexRoot(target.profileId)
|
|
297
|
+
const { profileId } = resolved
|
|
298
|
+
const texts = await readLastTurnTexts(target, {
|
|
299
|
+
claudeBridge, codexBridge, liveCodexSession, isInjected,
|
|
300
|
+
resolveClaudeRoot: async () => resolved,
|
|
301
|
+
resolveCodexRoot: async () => resolved,
|
|
302
|
+
extractClaude, extractCodex,
|
|
303
|
+
})
|
|
324
304
|
return {
|
|
325
305
|
session_name: target.sessionName,
|
|
326
|
-
cli_kind:
|
|
306
|
+
cli_kind: target.cliKind,
|
|
327
307
|
conversation_id: target.conversationId,
|
|
328
308
|
profile_id: profileId,
|
|
329
309
|
...(texts.user ? { user: texts.user } : {}),
|
|
@@ -360,3 +340,20 @@ export function _resetSidebarMessageSnapshotCachesForTest() {
|
|
|
360
340
|
textInFlight.clear()
|
|
361
341
|
rolloutPathCache.clear()
|
|
362
342
|
}
|
|
343
|
+
|
|
344
|
+
export async function readDiskLastTurnTexts(target, {
|
|
345
|
+
resolveClaudeRoot = resolveProjectsRoot,
|
|
346
|
+
resolveCodexRoot = codexProfiles.resolveSessionsDir,
|
|
347
|
+
extractClaude = extractLastTurnTexts,
|
|
348
|
+
extractCodex = extractCodexLastTurnTexts,
|
|
349
|
+
isInjected,
|
|
350
|
+
} = {}) {
|
|
351
|
+
if (target.cliKind === "claude") {
|
|
352
|
+
const { projectsRoot } = await resolveClaudeRoot(target.profileId)
|
|
353
|
+
const filePath = jsonlPath({ cwd: target.cwd, session_id: target.conversationId, projectsRoot })
|
|
354
|
+
return readCached(filePath, (fp) => extractClaude(fp, { isInjected }))
|
|
355
|
+
}
|
|
356
|
+
const { sessionsDir } = await resolveCodexRoot(target.profileId)
|
|
357
|
+
const filePath = await findCodexRolloutFile(target.conversationId, sessionsDir)
|
|
358
|
+
return readCached(filePath, (fp) => extractCodex(fp, { isInjected }))
|
|
359
|
+
}
|