@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.86",
3
+ "version": "0.8.87",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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, " ").slice(0, 80)
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, " ").slice(0, 80)
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
- function maskPreviewText(raw) {
1078
- if (!raw) return ""
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 takeTextsFromLine(line, user, assistant) {
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 masked = maskPreviewText(contentToPlainText(obj.message.content))
1104
+ const raw = contentToPlainText(obj.message.content)
1105
+ const masked = maskPreviewText(raw)
1115
1106
  // normalizePreviewText が null を返す行 (合成 / /clear / local-command) はスキップし、
1116
1107
  // 更に過去へ遡って「実際に打った依頼」を探す。
1117
- const norm = normalizePreviewText(masked)
1118
- if (norm) user = norm.slice(0, LAST_TURN_TEXT_MAX)
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 (isInjected ? Boolean(user) : user && assistant) break
1187
+ if (user) break
1199
1188
  }
1200
- if (isInjected ? Boolean(user) : user && assistant) break
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.slice()
1228
- session.historyBytes = history.reduce((n, it) => n + estimateItemBytes(it), 0)
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
- client.send({
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
- publishSessionEvent({ client, logger }, sessionName, event.ev, event.at, { arm: false })
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
- liveCodexSession: (target) => {
4433
- const live = ctx.codexBridge?.liveSessionForCwd?.(target.cwd)
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, LAST_TURN_TEXT_MAX } from "./claude-history.mjs"
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 { SECRET_PLACEHOLDER, redactSecrets } from "./tool-result-mask.mjs"
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
- function sanitizeText(value) {
23
- if (typeof value !== "string" || !value.trim()) return ""
24
- let masked = redactSecrets(value)
25
- const openerAt = masked.indexOf("-----BEGIN")
26
- if (openerAt >= 0) masked = masked.slice(0, openerAt) + SECRET_PLACEHOLDER
27
- return masked.trim().replace(/\s+/g, " ").slice(0, LAST_TURN_TEXT_MAX)
28
- }
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?.type === "agentMessage" && !assistant) {
49
- assistant = sanitizeText(itemText(item))
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 user = sanitizeText(itemText(item))
54
- if (!user) continue
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
- injected = (await isInjected(user)) === true
56
+ // 永続ストアは全文一致なので、表示加工前の原文で照合する。
57
+ injected = (await isInjected(raw)) === true
59
58
  } catch {
60
59
  // 判定不能なら通常入力として残す。
61
60
  }
62
61
  }
63
62
  if (injected) {
64
- assistant = ""
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: sanitizeText(obj.payload.message) }
83
+ return { role: "assistant", text: obj.payload.message }
83
84
  }
84
85
  if (obj.payload.type === "user_message") {
85
- return { role: "user", text: sanitizeText(obj.payload.message) }
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?.text) continue
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 = message.text
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: message.text, assistant }
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
- let texts = { user: "", assistant: "" }
287
- if (target.cliKind === "claude") {
288
- const { projectsRoot, profileId } = await resolveClaudeRoot(target.profileId)
289
- const filePath = jsonlPath({
290
- cwd: target.cwd,
291
- session_id: target.conversationId,
292
- projectsRoot,
293
- })
294
- texts = await readCached(filePath, (fp) =>
295
- extractClaude(fp, { isInjected }),
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: "codex",
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
+ }