@cocorograph/hub-agent 0.8.118 → 0.8.119
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 +77 -24
- package/src/main.mjs +28 -4
- package/src/ws-client.mjs +18 -4
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
//
|
|
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
|
|
901
|
+
let scanned
|
|
831
902
|
try {
|
|
832
|
-
|
|
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
|
-
|
|
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
|
|
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,11 @@ 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,
|
|
2860
2865
|
events: payload?.events?.length || 0, total_lines: payload?.total_lines || 0,
|
|
2861
2866
|
truncated: payload?.truncated === true, send_ok: Boolean(sendOk),
|
|
2862
2867
|
ws_buffered: ctx.client.bufferedAmount ?? null,
|
|
@@ -2868,16 +2873,35 @@ function logHistoryDelivery(ctx, via, stream_id, timing, payload, sendOk) {
|
|
|
2868
2873
|
}
|
|
2869
2874
|
}
|
|
2870
2875
|
|
|
2876
|
+
function sendHistoryFrame(ctx, frame, timing) {
|
|
2877
|
+
// 計測値の取得失敗で配送を止めない (cross-review major)。取れなかった値は 0 ではなく null。
|
|
2878
|
+
try {
|
|
2879
|
+
timing.ws_buffered_before = ctx.client.bufferedAmount ?? null
|
|
2880
|
+
} catch {
|
|
2881
|
+
timing.ws_buffered_before = null
|
|
2882
|
+
}
|
|
2883
|
+
const stats = {}
|
|
2884
|
+
const ok = ctx.client.send(frame, stats)
|
|
2885
|
+
if (typeof stats.bytes === "number") timing.payload_bytes = stats.bytes
|
|
2886
|
+
if (typeof stats.stringify_ms === "number") timing.stringify_ms = stats.stringify_ms
|
|
2887
|
+
return ok
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2871
2890
|
export function sendClaudeAttachHistory(ctx, stream_id, payload, timing) {
|
|
2872
2891
|
if (!payload) return
|
|
2873
2892
|
let sendOk = false
|
|
2874
2893
|
try {
|
|
2875
|
-
sendOk = ctx
|
|
2894
|
+
sendOk = sendHistoryFrame(ctx, { ...payload, stream_id }, timing)
|
|
2876
2895
|
} finally {
|
|
2877
2896
|
logHistoryDelivery(ctx, "push", stream_id, timing, payload, sendOk)
|
|
2878
2897
|
}
|
|
2879
2898
|
}
|
|
2880
2899
|
|
|
2900
|
+
// attach 時の先回り push の窓 (元行数)。⚠️ 縮めるときはブラウザ側を先に直す: 追加取得は
|
|
2901
|
+
// 「今の先頭イベント」を境界にするため、窓に境界候補 (照合キーを持つイベント) が無いと
|
|
2902
|
+
// 追加取得が止まる (2026-10-27 cross-review。100 行化は Hub 側の対応後に行う)。
|
|
2903
|
+
export const ATTACH_HISTORY_PUSH_LINES = 500
|
|
2904
|
+
|
|
2881
2905
|
/**
|
|
2882
2906
|
* claude.attach の ready 直後にpushする履歴を組み立てる。
|
|
2883
2907
|
* Browserからの claude.history.request 往復を省くため、resume時だけ先回りして読む。
|
|
@@ -2922,7 +2946,7 @@ export async function loadClaudeAttachHistory({
|
|
|
2922
2946
|
const result = await timeHistoryPhase(timing, "fetch_ms", () => fetchHistoryFn({
|
|
2923
2947
|
cwd,
|
|
2924
2948
|
session_id: sessionId,
|
|
2925
|
-
maxLines:
|
|
2949
|
+
maxLines: ATTACH_HISTORY_PUSH_LINES,
|
|
2926
2950
|
projectsRoot,
|
|
2927
2951
|
logger,
|
|
2928
2952
|
timing,
|
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
|
-
|
|
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
|
-
|
|
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")
|