@cocorograph/hub-agent 0.8.109 → 0.8.111

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.109",
3
+ "version": "0.8.111",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -714,7 +714,7 @@ export async function deleteSession({
714
714
  }
715
715
 
716
716
  /** 親の取得窓内の背景依頼だけに、直接の子 transcript を結合する。 */
717
- async function attachBackgroundSubagentHistory(filePath, { result, requests }) {
717
+ async function attachBackgroundSubagentHistory(filePath, { result, requests }, timing) {
718
718
  if (requests.size === 0) return result
719
719
  const subagentsDir = path.join(filePath.slice(0, -".jsonl".length), "subagents")
720
720
  let names
@@ -730,6 +730,7 @@ async function attachBackgroundSubagentHistory(filePath, { result, requests }) {
730
730
  const meta = JSON.parse(await readFile(path.join(subagentsDir, name), "utf-8"))
731
731
  const toolUseId = meta?.toolUseId
732
732
  if (typeof toolUseId !== "string" || !toolUseId || !requests.has(toolUseId)) continue
733
+ if (timing) timing.subagent_files++
733
734
  const text = await readFile(path.join(subagentsDir, name.replace(/\.meta\.json$/, ".jsonl")), "utf-8")
734
735
  // 子は実行中にも読まれるので、追記途中の「未完の末尾行」だけは捨てて完成済みの行を残す。
735
736
  // ⚠️ 途中の行が壊れている場合は子ごと捨てる。壊れた行が tool_use だと、後続の tool_result を
@@ -760,6 +761,7 @@ async function attachBackgroundSubagentHistory(filePath, { result, requests }) {
760
761
  const index = requests.get(toolUseId)
761
762
  if (!children.has(index)) children.set(index, [])
762
763
  for (const event of events) children.get(index).push(event)
764
+ if (timing) timing.subagent_events += events.length
763
765
  } catch {
764
766
  // 不完全な meta / transcript は推定で紐付けず、その子だけスキップする。
765
767
  }
@@ -785,7 +787,27 @@ async function attachBackgroundSubagentHistory(filePath, { result, requests }) {
785
787
  * @param {{maxLines?: number, logger?: import('pino').Logger}} [opts]
786
788
  * @returns {Promise<{events: object[], total_lines: number, truncated: boolean}>}
787
789
  */
788
- export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINES, logger } = {}) {
790
+ // timing は呼び出しごとの出力先。キャッシュと返却ペイロードには保存しない。
791
+ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINES, logger, timing } = {}) {
792
+ const started = performance.now()
793
+ if (timing) Object.assign(timing, { cache_hit: false, parent_ms: 0, subagent_ms: 0, subagent_files: 0, subagent_events: 0, tag_ms: 0 })
794
+ const attach = async (parent) => {
795
+ if (timing) timing.parent_ms = performance.now() - started
796
+ const subagentStarted = performance.now()
797
+ try {
798
+ return await attachBackgroundSubagentHistory(filePath, parent, timing)
799
+ } finally {
800
+ if (timing) timing.subagent_ms = performance.now() - subagentStarted
801
+ }
802
+ }
803
+ try {
804
+ return await readParentSessionHistory(filePath, { maxLines, logger, timing, attach })
805
+ } finally {
806
+ if (timing && timing.subagent_ms === 0) timing.parent_ms = performance.now() - started
807
+ }
808
+ }
809
+
810
+ async function readParentSessionHistory(filePath, { maxLines, logger, timing, attach }) {
789
811
  // stat は readFile よりずっと軽い (メタデータのみ)。mtimeMs+size が前回と同じなら
790
812
  // ファイル内容も同じとみなし、以降の readFile + 全行 parse をまるごとスキップする。
791
813
  let st
@@ -799,7 +821,10 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
799
821
  throw err
800
822
  }
801
823
  const cached = getHydrateCache(filePath, st.mtimeMs, st.size, maxLines)
802
- if (cached) return attachBackgroundSubagentHistory(filePath, cached)
824
+ if (cached) {
825
+ if (timing) timing.cache_hit = true
826
+ return attach(cached)
827
+ }
803
828
 
804
829
  const readStarted = performance.now()
805
830
  let text
@@ -867,7 +892,7 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
867
892
  const result = { events, total_lines, truncated }
868
893
  const parent = { result, requests }
869
894
  setHydrateCache(filePath, st.mtimeMs, st.size, maxLines, parent)
870
- return attachBackgroundSubagentHistory(filePath, parent)
895
+ return attach(parent)
871
896
  }
872
897
 
873
898
  /**
@@ -876,16 +901,22 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
876
901
  *
877
902
  * @param {{cwd: string, session_id: string, maxLines?: number, projectsRoot?: string, logger?: import('pino').Logger}} args
878
903
  */
879
- export async function fetchSessionHistory({ cwd, session_id, maxLines, projectsRoot, logger }) {
904
+ export async function fetchSessionHistory({ cwd, session_id, maxLines, projectsRoot, logger, timing }) {
880
905
  if (!cwd || !session_id) {
881
906
  return { events: [], total_lines: 0, truncated: false, error: "missing_cwd_or_session_id" }
882
907
  }
883
908
  const filePath = jsonlPath({ cwd, session_id, projectsRoot })
884
909
  try {
885
- const result = await readSessionHistory(filePath, { maxLines, logger })
910
+ const result = await readSessionHistory(filePath, { maxLines, logger, timing })
886
911
  // 連携カード種別 (Phase 2 永続化): 注入 user メッセージへ source を付け直す (hydrateCache は
887
912
  // filePath キーで cwd を持たないため、cwd を持つここでタグ付けする。クローンで cache 非破壊)。
888
- const events = await tagHistoryInjectSources(result.events, cwd)
913
+ const tagStarted = performance.now()
914
+ let events
915
+ try {
916
+ events = await tagHistoryInjectSources(result.events, cwd)
917
+ } finally {
918
+ if (timing) timing.tag_ms = performance.now() - tagStarted
919
+ }
889
920
  return { ...result, events, file_path: filePath }
890
921
  } catch (err) {
891
922
  return {
@@ -330,22 +330,48 @@ export function hasInheritedOutputChild() {
330
330
  return inheritedOutputChildren > 0
331
331
  }
332
332
 
333
- function runOnce(argv, { logger } = {}) {
333
+ export function runOnce(argv, { logger, redact = [] } = {}) {
334
+ const secrets = redact.filter((value) => value.length > 0).sort((a, b) => b.length - a.length)
335
+ const pattern = secrets.length
336
+ ? new RegExp(secrets.map(_escapeRegExp).join("|"), "g")
337
+ : null
338
+ const sanitize = (value) => pattern ? value.replace(pattern, "***") : value
339
+ const safeError = (err) => pattern ? new Error(sanitize(err.message)) : err
334
340
  return new Promise((resolve, reject) => {
335
341
  const [bin, ...args] = argv
336
- inheritedOutputChildren++
342
+ const cmd = sanitize(argv.join(" "))
343
+ if (!pattern) inheritedOutputChildren++
337
344
  let released = false
338
345
  const release = () => {
339
346
  if (released) return
340
347
  released = true
341
- inheritedOutputChildren--
348
+ if (!pattern) inheritedOutputChildren--
342
349
  }
343
350
  let child
344
351
  try {
345
- child = spawn(bin, args, { stdio: "inherit" })
352
+ child = spawn(bin, args, { stdio: pattern ? ["inherit", "pipe", "pipe"] : "inherit" })
346
353
  } catch (err) {
347
354
  release()
348
- throw err
355
+ throw safeError(err)
356
+ }
357
+ // close を待って全バイトを結合し、チャンク境界(UTF-8 の途中を含む)での漏出を防ぐ。
358
+ // 上限を超えたら収集済みも捨てて読み捨てに切り替える(途中で切ると資格の断片が残りうるため、
359
+ // 部分表示はしない)。pipe は close まで drain し続け、子を書き込み待ちで止めない。
360
+ const captured = { stdout: { chunks: [], bytes: 0, overflow: false }, stderr: { chunks: [], bytes: 0, overflow: false } }
361
+ if (pattern) {
362
+ for (const name of ["stdout", "stderr"]) {
363
+ const buf = captured[name]
364
+ child[name].on("data", (chunk) => {
365
+ if (buf.overflow) return
366
+ buf.bytes += chunk.length
367
+ if (buf.bytes > REDACTED_OUTPUT_MAX_BYTES) {
368
+ buf.overflow = true
369
+ buf.chunks = []
370
+ return
371
+ }
372
+ buf.chunks.push(chunk)
373
+ })
374
+ }
349
375
  }
350
376
  // spawn 失敗 (ENOENT 等) は exit が来ないことがある。error でも必ず settle させ、
351
377
  // bundle 同期を未完了のまま残さない (resolve/reject は最初の 1 回だけ有効)。
@@ -355,22 +381,54 @@ function runOnce(argv, { logger } = {}) {
355
381
  }
356
382
  child.once("error", (err) => {
357
383
  release()
358
- reject(err)
359
- safeLog("warn", { cmd: argv.join(" "), error: err.message }, "hub-bundle post_install failed")
384
+ reject(safeError(err))
385
+ safeLog("warn", { cmd, error: sanitize(err.message) }, "hub-bundle post_install failed")
360
386
  })
361
- child.on("exit", (code) => {
387
+ child.on(pattern ? "close" : "exit", (code) => {
362
388
  release()
389
+ // 先に settle する。転送先の障害(ENOSPC / EPIPE 等)でコマンドの成否を変えない。
363
390
  if (code === 0) {
364
391
  resolve(code)
365
- safeLog("info", { cmd: argv.join(" ") }, "hub-bundle post_install ok")
366
392
  } else {
367
- reject(new Error(`post_install command failed (exit ${code}): ${argv.join(" ")}`))
368
- safeLog("warn", { cmd: argv.join(" "), code }, "hub-bundle post_install failed")
393
+ reject(new Error(`post_install command failed (exit ${code}): ${cmd}`))
394
+ }
395
+ if (pattern) {
396
+ for (const name of ["stdout", "stderr"]) {
397
+ const buf = captured[name]
398
+ const text = buf.overflow
399
+ ? `[hub-agent] 出力が ${REDACTED_OUTPUT_MAX_BYTES} バイトを超えたため省略しました\n`
400
+ : sanitize(Buffer.concat(buf.chunks).toString("utf8"))
401
+ if (text) forwardRedactedOutput(process[name], text)
402
+ }
403
+ }
404
+ if (code === 0) {
405
+ safeLog("info", { cmd }, "hub-bundle post_install ok")
406
+ } else {
407
+ safeLog("warn", { cmd, code }, "hub-bundle post_install failed")
369
408
  }
370
409
  })
371
410
  })
372
411
  }
373
412
 
413
+ // 伏せ字済み出力の 1 ストリームあたりの収集上限。claude mcp add の通常出力は数百バイト。
414
+ const REDACTED_OUTPUT_MAX_BYTES = 1024 * 1024
415
+
416
+ // 伏せ字済み出力を親の stdout / stderr へ書く。書き込み失敗は診断出力の欠落として扱い、
417
+ // 同期例外も非同期の error も外へ出さない。error listener はこの書き込みの間だけ付け、
418
+ // 共有ストリームの他のエラーを恒久的に握り潰さない。
419
+ function forwardRedactedOutput(stream, text) {
420
+ const onError = () => {}
421
+ stream.on("error", onError)
422
+ try {
423
+ stream.write(text, () => {
424
+ // callback 後に error が emit される実装があるため、1 tick 置いてから外す。
425
+ setImmediate(() => stream.removeListener("error", onError))
426
+ })
427
+ } catch {
428
+ stream.removeListener("error", onError)
429
+ }
430
+ }
431
+
374
432
  /**
375
433
  * Bundle response の `mcp_servers` を Claude Code CLI に登録する。
376
434
  *
@@ -474,7 +532,7 @@ export async function applyMcpServers(
474
532
  // 未登録 → remove は失敗するが正常系。
475
533
  }
476
534
  }
477
- await run(argv, { logger })
535
+ await run(argv, wantHeader ? { logger, redact: [agentToken] } : { logger })
478
536
  added.push(s.name)
479
537
  } catch (err) {
480
538
  // ヘッダ不要の外部 MCP は既存なら "already exists" で失敗する。これは「既に正しく
package/src/main.mjs CHANGED
@@ -2627,6 +2627,125 @@ export function handleClaudeChatInput(msg, ctx) {
2627
2627
  return p
2628
2628
  }
2629
2629
 
2630
+ export async function handleClaudeHistoryRequest(msg, ctx, {
2631
+ resolveFn = resolveProjectsRoot,
2632
+ fetchHistoryFn = fetchSessionHistory,
2633
+ } = {}) {
2634
+ const timing = createHistoryDeliveryTiming()
2635
+ let payload
2636
+ let sendOk = false
2637
+ try {
2638
+ // Sprint G 0.6.1: ~/.claude/projects/<cwd-encoded>/<session_id>.jsonl を読んで
2639
+ // 過去メッセージを返す。ChatView の re-mount 時の UI 復元用。
2640
+ // stream_id は per-stream routing のため必須 (browser 側 stream に紐付ける)。
2641
+ const stream_id = msg.stream_id
2642
+ const cwd = msg.cwd || ""
2643
+ const session_id = msg.session_id || ""
2644
+ const maxLines = typeof msg.max_lines === "number" ? msg.max_lines : undefined
2645
+ // Read-plane 案C ①: 読む projects/ は **リクエストの profile_id** から解決する
2646
+ // (アカウント別に履歴がサイロ化されており、env 由来の active に頼ると切替中の
2647
+ // in-flight read が別アカウントの履歴を返す)。profile_id 未指定は active へ縮退。
2648
+ const epoch = typeof msg.epoch === "number" ? msg.epoch : undefined
2649
+ const { projectsRoot, profileId } = await timeHistoryPhase(timing, "resolve_ms", () => resolveFn(msg.profile_id))
2650
+ const result = await timeHistoryPhase(timing, "fetch_ms", () => fetchHistoryFn({
2651
+ cwd,
2652
+ session_id,
2653
+ maxLines,
2654
+ projectsRoot,
2655
+ logger: ctx.logger,
2656
+ timing,
2657
+ }))
2658
+ payload = result
2659
+ // ⚠️ **観測窓を必ずどこかから読む**。タスク終端通知の封筒検査は、Claude CLI が
2660
+ // ラッパー・タグ順・空白を変えると正当な通知まで落とし、**幽霊カードが静かに戻る**。
2661
+ // 誰も読まないカウンタは「対策した気になるだけの飾り」になる (バンドル自己更新の
2662
+ // 通知で発火率 0 の検査を残していた前例あり)。件数だけを出す (中身は伏せ字を
2663
+ // 素通りするので絶対に出さない)。
2664
+ const envelopeDrops = drainTaskNotificationEnvelopeDrops()
2665
+ if (envelopeDrops > 0) {
2666
+ try {
2667
+ ctx.logger?.info?.(
2668
+ { drops: envelopeDrops },
2669
+ "history: task-notification の封筒検査で合成しなかった行があります (CLI の形式変更なら通知の復元が効かなくなります)",
2670
+ )
2671
+ } catch {
2672
+ // ログ失敗でも応答を送る。
2673
+ }
2674
+ }
2675
+ sendOk = ctx.client.send({
2676
+ type: "claude.history.response",
2677
+ stream_id,
2678
+ session_id,
2679
+ // SDK-P0-4 (2026-07-13): 要求時の cwd をエコーバックする。claude.sessions.response は
2680
+ // 元から cwd を載せており frontend が cwd 照合で「別ワークスペース宛の遅延応答」を
2681
+ // 捨てられるのに、history.response だけ cwd が無く照合材料が存在しなかった。
2682
+ // その結果、無条件 hydrate → session_id 上書き → 購読スワップ → localStorage 汚染の
2683
+ // 経路が残っていた (sessionScope.ts が stream_id 主軸で守るのは relay イベントのみ)。
2684
+ cwd,
2685
+ // 案C ②: epoch と解決済み profile を必ずエコーする。frontend の単一 read ゲートは
2686
+ // この 2 つで「今の問いへの最新の答えか」を判定し、stale/逆順/別プロファイルを drop する。
2687
+ epoch,
2688
+ profile_id: profileId,
2689
+ events: result.events,
2690
+ total_lines: result.total_lines,
2691
+ truncated: result.truncated,
2692
+ error: result.error,
2693
+ })
2694
+ } finally {
2695
+ logHistoryDelivery(ctx, "request", msg.stream_id, timing, payload, sendOk)
2696
+ }
2697
+ }
2698
+
2699
+ export function createHistoryDeliveryTiming() {
2700
+ return {
2701
+ started_at: Date.now(), started: performance.now(), resolve_ms: 0, fetch_ms: 0,
2702
+ cache_hit: false, parent_ms: 0, subagent_ms: 0, subagent_files: 0,
2703
+ subagent_events: 0, tag_ms: 0, has_error: false,
2704
+ }
2705
+ }
2706
+
2707
+ async function timeHistoryPhase(timing, field, run) {
2708
+ const started = performance.now()
2709
+ try {
2710
+ return await run()
2711
+ } catch (err) {
2712
+ timing.has_error = true
2713
+ throw err
2714
+ } finally {
2715
+ timing[field] = performance.now() - started
2716
+ }
2717
+ }
2718
+
2719
+ function logHistoryDelivery(ctx, via, stream_id, timing, payload, sendOk) {
2720
+ try {
2721
+ // 明示的な allowlist: 本文・パス・error 文字列をログへ流さない。
2722
+ ctx.logger?.info?.({
2723
+ via, stream_id, started_at: timing.started_at,
2724
+ resolve_ms: timing.resolve_ms, fetch_ms: timing.fetch_ms,
2725
+ cache_hit: timing.cache_hit, parent_ms: timing.parent_ms,
2726
+ subagent_ms: timing.subagent_ms, subagent_files: timing.subagent_files,
2727
+ subagent_events: timing.subagent_events, tag_ms: timing.tag_ms,
2728
+ events: payload?.events?.length || 0, total_lines: payload?.total_lines || 0,
2729
+ truncated: payload?.truncated === true, send_ok: Boolean(sendOk),
2730
+ ws_buffered: ctx.client.bufferedAmount ?? null,
2731
+ total_ms: performance.now() - timing.started,
2732
+ has_error: Boolean(timing.has_error || payload?.error != null),
2733
+ }, "claude history delivery timing")
2734
+ } catch {
2735
+ // 計測失敗で履歴配信を壊さない。
2736
+ }
2737
+ }
2738
+
2739
+ export function sendClaudeAttachHistory(ctx, stream_id, payload, timing) {
2740
+ if (!payload) return
2741
+ let sendOk = false
2742
+ try {
2743
+ sendOk = ctx.client.send({ ...payload, stream_id })
2744
+ } finally {
2745
+ logHistoryDelivery(ctx, "push", stream_id, timing, payload, sendOk)
2746
+ }
2747
+ }
2748
+
2630
2749
  /**
2631
2750
  * claude.attach の ready 直後にpushする履歴を組み立てる。
2632
2751
  * Browserからの claude.history.request 往復を省くため、resume時だけ先回りして読む。
@@ -2654,6 +2773,7 @@ export async function loadClaudeAttachHistory({
2654
2773
  logger,
2655
2774
  resolved,
2656
2775
  fetchHistoryFn = fetchSessionHistory,
2776
+ timing = createHistoryDeliveryTiming(),
2657
2777
  }) {
2658
2778
  if (!cwd || !sessionId) return null
2659
2779
  const profileId = resolved?.profileId
@@ -2667,13 +2787,14 @@ export async function loadClaudeAttachHistory({
2667
2787
  const projectsRoot = resolved
2668
2788
  ? resolved.projectsRoot
2669
2789
  : await getActiveProjectsRoot()
2670
- const result = await fetchHistoryFn({
2790
+ const result = await timeHistoryPhase(timing, "fetch_ms", () => fetchHistoryFn({
2671
2791
  cwd,
2672
2792
  session_id: sessionId,
2673
2793
  maxLines: 500,
2674
2794
  projectsRoot,
2675
2795
  logger,
2676
- })
2796
+ timing,
2797
+ }))
2677
2798
  return withProfile({
2678
2799
  type: "claude.history.response",
2679
2800
  session_id: sessionId,
@@ -2721,11 +2842,12 @@ export function buildAttachHistoryPromise({
2721
2842
  profileId,
2722
2843
  resolveFn = resolveProjectsRoot,
2723
2844
  loadFn = loadClaudeAttachHistory,
2845
+ timing = createHistoryDeliveryTiming(),
2724
2846
  }) {
2725
2847
  if (!resumeSessionId) return Promise.resolve(null)
2726
- return resolveFn(profileId)
2848
+ return timeHistoryPhase(timing, "resolve_ms", () => resolveFn(profileId))
2727
2849
  .then((resolved) =>
2728
- loadFn({ cwd, sessionId: resumeSessionId, logger, resolved }),
2850
+ loadFn({ cwd, sessionId: resumeSessionId, logger, resolved, timing }),
2729
2851
  )
2730
2852
  .catch((err) => ({
2731
2853
  type: "claude.history.response",
@@ -3432,7 +3554,9 @@ export async function dispatch(msg, ctx) {
3432
3554
  // 案C ①/major1/nit2/公開前 major: 履歴 push を attach/ready から独立させる。
3433
3555
  // resolveProjectsRoot を attach の直列 await/try に載せない (解決失敗で attach ごと
3434
3556
  // 落とさない・ready を遅らせない)。詳細は buildAttachHistoryPromise の doc 参照。
3557
+ const historyTiming = createHistoryDeliveryTiming()
3435
3558
  const historyPromise = buildAttachHistoryPromise({
3559
+ timing: historyTiming,
3436
3560
  resumeSessionId,
3437
3561
  cwd: cwdArg || process.env.HOME || process.cwd(),
3438
3562
  logger: ctx.logger,
@@ -3468,7 +3592,7 @@ export async function dispatch(msg, ctx) {
3468
3592
  // 同一接続のpermission/input等まで停止するため、readyの同期送信後は非同期でpushする。
3469
3593
  void historyPromise
3470
3594
  .then((pushedHistory) => {
3471
- if (pushedHistory) ctx.client.send({ ...pushedHistory, stream_id })
3595
+ sendClaudeAttachHistory(ctx, stream_id, pushedHistory, historyTiming)
3472
3596
  })
3473
3597
  .catch((err) => {
3474
3598
  ctx.logger?.warn?.(
@@ -4137,56 +4261,7 @@ export async function dispatch(msg, ctx) {
4137
4261
  return
4138
4262
  }
4139
4263
  case "claude.history.request": {
4140
- // Sprint G 0.6.1: ~/.claude/projects/<cwd-encoded>/<session_id>.jsonl を読んで
4141
- // 過去メッセージを返す。ChatView の re-mount 時の UI 復元用。
4142
- // stream_id は per-stream routing のため必須 (browser 側 stream に紐付ける)。
4143
- const stream_id = msg.stream_id
4144
- const cwd = msg.cwd || ""
4145
- const session_id = msg.session_id || ""
4146
- const maxLines = typeof msg.max_lines === "number" ? msg.max_lines : undefined
4147
- // Read-plane 案C ①: 読む projects/ は **リクエストの profile_id** から解決する
4148
- // (アカウント別に履歴がサイロ化されており、env 由来の active に頼ると切替中の
4149
- // in-flight read が別アカウントの履歴を返す)。profile_id 未指定は active へ縮退。
4150
- const epoch = typeof msg.epoch === "number" ? msg.epoch : undefined
4151
- const { projectsRoot, profileId } = await resolveProjectsRoot(msg.profile_id)
4152
- const result = await fetchSessionHistory({
4153
- cwd,
4154
- session_id,
4155
- maxLines,
4156
- projectsRoot,
4157
- logger: ctx.logger,
4158
- })
4159
- // ⚠️ **観測窓を必ずどこかから読む**。タスク終端通知の封筒検査は、Claude CLI が
4160
- // ラッパー・タグ順・空白を変えると正当な通知まで落とし、**幽霊カードが静かに戻る**。
4161
- // 誰も読まないカウンタは「対策した気になるだけの飾り」になる (バンドル自己更新の
4162
- // 通知で発火率 0 の検査を残していた前例あり)。件数だけを出す (中身は伏せ字を
4163
- // 素通りするので絶対に出さない)。
4164
- const envelopeDrops = drainTaskNotificationEnvelopeDrops()
4165
- if (envelopeDrops > 0) {
4166
- ctx.logger?.info?.(
4167
- { drops: envelopeDrops },
4168
- "history: task-notification の封筒検査で合成しなかった行があります (CLI の形式変更なら通知の復元が効かなくなります)",
4169
- )
4170
- }
4171
- ctx.client.send({
4172
- type: "claude.history.response",
4173
- stream_id,
4174
- session_id,
4175
- // SDK-P0-4 (2026-07-13): 要求時の cwd をエコーバックする。claude.sessions.response は
4176
- // 元から cwd を載せており frontend が cwd 照合で「別ワークスペース宛の遅延応答」を
4177
- // 捨てられるのに、history.response だけ cwd が無く照合材料が存在しなかった。
4178
- // その結果、無条件 hydrate → session_id 上書き → 購読スワップ → localStorage 汚染の
4179
- // 経路が残っていた (sessionScope.ts が stream_id 主軸で守るのは relay イベントのみ)。
4180
- cwd,
4181
- // 案C ②: epoch と解決済み profile を必ずエコーする。frontend の単一 read ゲートは
4182
- // この 2 つで「今の問いへの最新の答えか」を判定し、stale/逆順/別プロファイルを drop する。
4183
- epoch,
4184
- profile_id: profileId,
4185
- events: result.events,
4186
- total_lines: result.total_lines,
4187
- truncated: result.truncated,
4188
- error: result.error,
4189
- })
4264
+ await handleClaudeHistoryRequest(msg, ctx)
4190
4265
  return
4191
4266
  }
4192
4267
  case "claude.sessions.request": {
package/src/ws-client.mjs CHANGED
@@ -111,6 +111,10 @@ function isEphemeralCodexDeltaEvent(obj) {
111
111
  }
112
112
 
113
113
  export class WsClient extends EventEmitter {
114
+ get bufferedAmount() {
115
+ return this.ws?.readyState === WebSocket.OPEN ? this.ws.bufferedAmount : null
116
+ }
117
+
114
118
  /**
115
119
  * @param {{ hub_url: string, agent_id: string, agent_token: string }} config
116
120
  * @param {{