@cocorograph/hub-agent 0.8.118 → 0.8.120

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.118",
3
+ "version": "0.8.120",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -781,7 +781,7 @@ async function attachBackgroundSubagentHistory(filePath, { result, requests }, t
781
781
  *
782
782
  * 末尾 maxLines 行に絞ってから JSON.parse + DISPLAY_TYPES でフィルタ。
783
783
  * 大きいファイルでも先頭から全行 parse する必要はないが、line buffer を最小化
784
- * するため簡易実装で全文 readFile → split → 末尾 slice する (典型 1MB 未満想定)。
784
+ * するため前方チャンク走査で末尾窓だけ保持する。
785
785
  *
786
786
  * @param {string} filePath
787
787
  * @param {{maxLines?: number, logger?: import('pino').Logger}} [opts]
@@ -807,9 +807,78 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
807
807
  }
808
808
  }
809
809
 
810
+ // 前方に一度だけ走査し、末尾窓と全体の tool_use 索引を同時に作る。
811
+ // byteLimit は呼び出し側が stat したサイズ。走査中の追記を追い続けないよう、そこで止める
812
+ // (キャッシュも同じ stat の mtime/size で登録するので、読んだ範囲と照合キーが一致する)。
813
+ async function scanParentHistory(filePath, maxLines, byteLimit) {
814
+ const chunkSize = 1024 * 1024
815
+ // slice(-maxLines) の従来の挙動を 0・負数・小数等でも維持する。
816
+ const windowSize = Math.trunc(Number(maxLines))
817
+ const capacity = windowSize > 0 ? windowSize : Infinity
818
+ const lines = []
819
+ const toolNames = new Map()
820
+ let total_lines = 0
821
+ let next = 0
822
+ let fragments = []
823
+ let fragmentBytes = 0
824
+ const acceptLine = (bytes) => {
825
+ if (bytes.length === 0) return
826
+ const line = bytes.toString("utf-8")
827
+ total_lines++
828
+ if (lines.length < capacity) lines.push(line)
829
+ else {
830
+ lines[next] = line
831
+ next = (next + 1) % capacity
832
+ }
833
+ if (MASK_ENABLED && line.includes('"tool_use"')) {
834
+ try {
835
+ collectToolUseNames(JSON.parse(line), toolNames)
836
+ } catch {
837
+ /* ignore */
838
+ }
839
+ }
840
+ }
841
+ const handle = await open(filePath, "r")
842
+ try {
843
+ let remaining = byteLimit
844
+ while (remaining > 0) {
845
+ // 未完行の断片が参照するので、次の read でバッファを上書きしない。
846
+ const want = Math.min(chunkSize, remaining)
847
+ const buffer = Buffer.allocUnsafe(want)
848
+ const { bytesRead } = await handle.read(buffer, 0, want, null)
849
+ if (bytesRead === 0) break // 並行 truncate
850
+ remaining -= bytesRead
851
+ const chunk = buffer.subarray(0, bytesRead)
852
+ let start = 0
853
+ let end
854
+ while ((end = chunk.indexOf(10, start)) !== -1) {
855
+ const part = chunk.subarray(start, end)
856
+ if (fragments.length) {
857
+ fragments.push(part)
858
+ acceptLine(Buffer.concat(fragments, fragmentBytes + part.length))
859
+ fragments = []
860
+ fragmentBytes = 0
861
+ } else acceptLine(part)
862
+ start = end + 1
863
+ }
864
+ if (start < bytesRead) {
865
+ const part = chunk.subarray(start)
866
+ fragments.push(part)
867
+ fragmentBytes += part.length
868
+ }
869
+ }
870
+ if (fragments.length) acceptLine(Buffer.concat(fragments, fragmentBytes))
871
+ } finally {
872
+ await handle.close()
873
+ }
874
+ const ordered = next ? lines.slice(next).concat(lines.slice(0, next)) : lines
875
+ const truncated = total_lines > maxLines
876
+ const slice = truncated ? ordered.slice(-maxLines) : ordered
877
+ return { slice, total_lines, truncated, toolNames }
878
+ }
879
+
810
880
  async function readParentSessionHistory(filePath, { maxLines, logger, timing, attach }) {
811
- // stat は readFile よりずっと軽い (メタデータのみ)。mtimeMs+size が前回と同じなら
812
- // ファイル内容も同じとみなし、以降の readFile + 全行 parse をまるごとスキップする。
881
+ // mtimeMs+size が前回と同じなら、親ファイルの走査と parse をスキップする。
813
882
  let st
814
883
  try {
815
884
  st = await stat(filePath)
@@ -826,10 +895,12 @@ async function readParentSessionHistory(filePath, { maxLines, logger, timing, at
826
895
  return attach(cached)
827
896
  }
828
897
 
898
+ // ⚠️ read_ms は走査とマスク用 tool_use 索引の JSON.parse を含む (2026-10-27 のチャンク
899
+ // 走査化以降)。それ以前の read_ms / parse_ms とは内訳が違うので、前後比較は parent_ms で行う。
829
900
  const readStarted = performance.now()
830
- let text
901
+ let scanned
831
902
  try {
832
- text = await readFile(filePath, "utf-8")
903
+ scanned = await scanParentHistory(filePath, maxLines, st.size)
833
904
  } catch (err) {
834
905
  if (err.code === "ENOENT") {
835
906
  return { events: [], total_lines: 0, truncated: false }
@@ -838,25 +909,7 @@ async function readParentSessionHistory(filePath, { maxLines, logger, timing, at
838
909
  throw err
839
910
  }
840
911
  const parseStarted = performance.now()
841
- // 末尾 newline は捨てる
842
- const lines = text.split("\n").filter((l) => l.length > 0)
843
- const total_lines = lines.length
844
- const truncated = total_lines > maxLines
845
- const slice = truncated ? lines.slice(-maxLines) : lines
846
-
847
- // マスク (2026-06-23): tool_use 行が表示スライス外へ truncate されても tool_result の
848
- // スキル判定が効くよう、全行を事前走査して tool_use_id→name を集めておく。
849
- const toolNames = new Map()
850
- if (MASK_ENABLED) {
851
- for (const line of lines) {
852
- if (!line.includes('"tool_use"')) continue
853
- try {
854
- collectToolUseNames(JSON.parse(line), toolNames)
855
- } catch {
856
- /* ignore */
857
- }
858
- }
859
- }
912
+ const { slice, total_lines, truncated, toolNames } = scanned
860
913
 
861
914
  const events = []
862
915
  const requests = new Map()
package/src/main.mjs CHANGED
@@ -2804,7 +2804,7 @@ export async function handleClaudeHistoryRequest(msg, ctx, {
2804
2804
  // ログ失敗でも応答を送る。
2805
2805
  }
2806
2806
  }
2807
- sendOk = ctx.client.send({
2807
+ sendOk = sendHistoryFrame(ctx, {
2808
2808
  type: "claude.history.response",
2809
2809
  stream_id,
2810
2810
  session_id,
@@ -2822,7 +2822,7 @@ export async function handleClaudeHistoryRequest(msg, ctx, {
2822
2822
  total_lines: result.total_lines,
2823
2823
  truncated: result.truncated,
2824
2824
  error: result.error,
2825
- })
2825
+ }, timing)
2826
2826
  } finally {
2827
2827
  logHistoryDelivery(ctx, "request", msg.stream_id, timing, payload, sendOk)
2828
2828
  }
@@ -2857,6 +2857,12 @@ function logHistoryDelivery(ctx, via, stream_id, timing, payload, sendOk) {
2857
2857
  cache_hit: timing.cache_hit, parent_ms: timing.parent_ms,
2858
2858
  subagent_ms: timing.subagent_ms, subagent_files: timing.subagent_files,
2859
2859
  subagent_events: timing.subagent_events, tag_ms: timing.tag_ms,
2860
+ // 送信側の内訳 (2026-10-27): 送る前に既に詰まっていた量・frame の大きさ・文字列化時間。
2861
+ // ws_buffered (送信直後) との差で、この 1 通が送信待ちに積んだ量も読める。
2862
+ ws_buffered_before: timing.ws_buffered_before ?? null,
2863
+ payload_bytes: timing.payload_bytes ?? null,
2864
+ stringify_ms: timing.stringify_ms ?? null,
2865
+ widened: timing.widened === true,
2860
2866
  events: payload?.events?.length || 0, total_lines: payload?.total_lines || 0,
2861
2867
  truncated: payload?.truncated === true, send_ok: Boolean(sendOk),
2862
2868
  ws_buffered: ctx.client.bufferedAmount ?? null,
@@ -2868,16 +2874,43 @@ function logHistoryDelivery(ctx, via, stream_id, timing, payload, sendOk) {
2868
2874
  }
2869
2875
  }
2870
2876
 
2877
+ function sendHistoryFrame(ctx, frame, timing) {
2878
+ // 計測値の取得失敗で配送を止めない (cross-review major)。取れなかった値は 0 ではなく null。
2879
+ try {
2880
+ timing.ws_buffered_before = ctx.client.bufferedAmount ?? null
2881
+ } catch {
2882
+ timing.ws_buffered_before = null
2883
+ }
2884
+ const stats = {}
2885
+ const ok = ctx.client.send(frame, stats)
2886
+ if (typeof stats.bytes === "number") timing.payload_bytes = stats.bytes
2887
+ if (typeof stats.stringify_ms === "number") timing.stringify_ms = stats.stringify_ms
2888
+ return ok
2889
+ }
2890
+
2871
2891
  export function sendClaudeAttachHistory(ctx, stream_id, payload, timing) {
2872
2892
  if (!payload) return
2873
2893
  let sendOk = false
2874
2894
  try {
2875
- sendOk = ctx.client.send({ ...payload, stream_id })
2895
+ sendOk = sendHistoryFrame(ctx, { ...payload, stream_id }, timing)
2876
2896
  } finally {
2877
2897
  logHistoryDelivery(ctx, "push", stream_id, timing, payload, sendOk)
2878
2898
  }
2879
2899
  }
2880
2900
 
2901
+ // attach 時の先回り push は末尾 100 元行に絞る (2026-10-27)。500 行だと初回 frame が
2902
+ // 中央値 375KB・最大 1.8MB になり、送信待ちと Hub/ブラウザの JSON 処理が ready→表示の
2903
+ // 遅れの大半を占めていた (100 行で中央値 72KB・最大 304KB、親の表示イベント 34〜54 件)。
2904
+ // ⚠️ ブラウザ側の窓 (INITIAL_CLAUDE_HISTORY_LINES=500) は変えない。追加取得は
2905
+ // 「現在の窓 × 2」を要求し、受け取った窓から今の先頭イベントより前だけを取り出すため、
2906
+ // push が 100 行でも次の要求は 1000 行になり境界を必ず含む。逆にブラウザ側を 100 にすると、
2907
+ // 旧 agent の 500 行 push の後に 200 行を要求して境界が見つからず追加取得が止まる。
2908
+ // ⚠️ 100 行の窓に境界候補 (照合キーを持つイベント) が無いと、旧ブラウザは追加取得が止まる。
2909
+ // Hub 側は候補ゼロの窓を権威的に取り込む対応を 2026-10-28 に本番反映済み (Hub PR #5700)。
2910
+ // 対応前の画面を開いたままのタブだけは再読み込みが要る。
2911
+ export const ATTACH_HISTORY_PUSH_LINES = 100
2912
+ export const ATTACH_HISTORY_PUSH_FALLBACK_LINES = 500
2913
+
2881
2914
  /**
2882
2915
  * claude.attach の ready 直後にpushする履歴を組み立てる。
2883
2916
  * Browserからの claude.history.request 往復を省くため、resume時だけ先回りして読む。
@@ -2919,14 +2952,25 @@ export async function loadClaudeAttachHistory({
2919
2952
  const projectsRoot = resolved
2920
2953
  ? resolved.projectsRoot
2921
2954
  : await getActiveProjectsRoot()
2922
- const result = await timeHistoryPhase(timing, "fetch_ms", () => fetchHistoryFn({
2955
+ const fetchWindow = (maxLines) => fetchHistoryFn({
2923
2956
  cwd,
2924
2957
  session_id: sessionId,
2925
- maxLines: 500,
2958
+ maxLines,
2926
2959
  projectsRoot,
2927
2960
  logger,
2928
2961
  timing,
2929
- }))
2962
+ })
2963
+ const result = await timeHistoryPhase(timing, "fetch_ms", async () => {
2964
+ const narrow = await fetchWindow(ATTACH_HISTORY_PUSH_LINES)
2965
+ // ⚠️ 末尾 100 行が表示対象外の行 (progress 等) だけだと events が空になり、ブラウザの
2966
+ // 追加取得は「今の先頭イベント」を境界にするため境界が無く止まる (cross-review major)。
2967
+ // 表示イベントが 1 件も無く続きがあるときだけ、従来の 500 行で読み直す (従来と同等以上)。
2968
+ if (narrow?.truncated === true && !narrow.error && (narrow.events?.length || 0) === 0) {
2969
+ timing.widened = true
2970
+ return fetchWindow(ATTACH_HISTORY_PUSH_FALLBACK_LINES)
2971
+ }
2972
+ return narrow
2973
+ })
2930
2974
  return withProfile({
2931
2975
  type: "claude.history.response",
2932
2976
  session_id: sessionId,
package/src/ws-client.mjs CHANGED
@@ -333,7 +333,13 @@ export class WsClient extends EventEmitter {
333
333
  * heartbeat / hello / agent.streams.sync.* など「古くなると意味が無い」制御系は
334
334
  * buffer しない (warn のみ)。ack / event 系は 1 件ずつ意味を持つのでバッファに保持する。
335
335
  */
336
- send(obj) {
336
+ /**
337
+ * @param {object} obj
338
+ * @param {{bytes?: number, stringify_ms?: number}} [stats] 渡されたときだけ、実際に送れた
339
+ * frame の UTF-8 バイト数と JSON 文字列化時間を書き込む (履歴配信の計測用。通常の
340
+ * frame で毎回 byteLength を数えないよう、呼び出し側が明示したときに限る)。
341
+ */
342
+ send(obj, stats) {
337
343
  const type = obj?.type
338
344
  const wsOpen = this.ws?.readyState === WebSocket.OPEN
339
345
  // sync barrier 中に新しく発生した frame も同じ buffer へ積み、切断中 frame より
@@ -365,7 +371,7 @@ export class WsClient extends EventEmitter {
365
371
  return false
366
372
  }
367
373
  }
368
- return this._sendJson(obj)
374
+ return this._sendJson(obj, stats)
369
375
  }
370
376
 
371
377
  /** 制御/イベント系 (ack / jsonl.event 等) を outbound buffer に積む。リング (drop oldest)。 */
@@ -548,9 +554,17 @@ export class WsClient extends EventEmitter {
548
554
  return `${scheme}//${host}/ws/cockpit/agent/`
549
555
  }
550
556
 
551
- _sendJson(obj) {
557
+ _sendJson(obj, stats) {
552
558
  try {
553
- this.ws.send(JSON.stringify(obj))
559
+ const started = stats ? performance.now() : 0
560
+ const text = JSON.stringify(obj)
561
+ const stringifyMs = stats ? performance.now() - started : 0
562
+ this.ws.send(text)
563
+ // 送れた frame だけを記録する (送信例外時は stats を書かない)。
564
+ if (stats) {
565
+ stats.stringify_ms = stringifyMs
566
+ stats.bytes = Buffer.byteLength(text)
567
+ }
554
568
  return true
555
569
  } catch (err) {
556
570
  this.logger?.warn({ err: err.message }, "ws send failed")