@cocorograph/hub-agent 0.8.110 → 0.8.112
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 +38 -7
- package/src/dispatch-scheduler.mjs +16 -7
- package/src/main.mjs +267 -141
- package/src/startup-metrics.mjs +68 -0
- package/src/ws-client.mjs +4 -0
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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 {
|
|
@@ -43,7 +43,7 @@ const SLOW_DISPATCH_MS = 1000
|
|
|
43
43
|
* 調査対象そのものなので、速かった場合も含めて毎回記録する
|
|
44
44
|
* (速いことの記録が無いと「詰まっていない」を示せない)。
|
|
45
45
|
*/
|
|
46
|
-
const ALWAYS_TIMED_TYPES = new Set(["chat.session.stop"])
|
|
46
|
+
const ALWAYS_TIMED_TYPES = new Set(["chat.session.stop", "claude.attach"])
|
|
47
47
|
|
|
48
48
|
export function isConnectionLifecycleMessage(type) {
|
|
49
49
|
return LIFECYCLE_TYPES.has(type)
|
|
@@ -101,6 +101,15 @@ export function attachLaneKey(mode, msg, defaultCwd = "") {
|
|
|
101
101
|
return streamId ? `${mode}:stream:${streamId}` : `${mode}:control`
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* ログに出すレーン名。内部キーは cwd・resume ID・stream ID を含むので、ログには種別だけを出す
|
|
106
|
+
* (直列化に使う内部キーそのものは変えない)。
|
|
107
|
+
*/
|
|
108
|
+
export function laneKindForLog(lane) {
|
|
109
|
+
const m = /^(claude|codex):(cwd|resume|stream):/.exec(typeof lane === "string" ? lane : "")
|
|
110
|
+
return m ? `${m[1]}:${m[2]}` : lane
|
|
111
|
+
}
|
|
112
|
+
|
|
104
113
|
/**
|
|
105
114
|
* @param {{
|
|
106
115
|
* run: (msg: object) => Promise<void>|void,
|
|
@@ -261,14 +270,14 @@ export class DispatchScheduler {
|
|
|
261
270
|
const runMs = Date.now() - startedAt
|
|
262
271
|
if (shouldLogDispatchTiming(msg?.type, 0, runMs)) {
|
|
263
272
|
this.logger?.info?.(
|
|
264
|
-
{ type: msg?.type, lane, wait_ms: 0, run_ms: runMs },
|
|
273
|
+
{ type: msg?.type, lane: laneKindForLog(lane), wait_ms: 0, run_ms: runMs },
|
|
265
274
|
"dispatch timing",
|
|
266
275
|
)
|
|
267
276
|
}
|
|
268
277
|
})
|
|
269
278
|
.catch((err) => {
|
|
270
279
|
this.logger?.error?.(
|
|
271
|
-
{ err: err?.message, type: msg?.type, lane },
|
|
280
|
+
{ err: err?.message, type: msg?.type, lane: laneKindForLog(lane) },
|
|
272
281
|
"dispatch threw",
|
|
273
282
|
)
|
|
274
283
|
})
|
|
@@ -317,7 +326,7 @@ export class DispatchScheduler {
|
|
|
317
326
|
.then(async () => {
|
|
318
327
|
if (generation !== this.generation) {
|
|
319
328
|
this.logger?.debug?.(
|
|
320
|
-
{ type, lane, queued_generation: generation, generation: this.generation },
|
|
329
|
+
{ type, lane: laneKindForLog(lane), queued_generation: generation, generation: this.generation },
|
|
321
330
|
"stale queued dispatch dropped after reconnect",
|
|
322
331
|
)
|
|
323
332
|
return
|
|
@@ -328,7 +337,7 @@ export class DispatchScheduler {
|
|
|
328
337
|
(this.detachedAtByStream.get(streamKey) ?? -1) > sequence
|
|
329
338
|
) {
|
|
330
339
|
this.logger?.debug?.(
|
|
331
|
-
{ type, lane, stream_id: streamId },
|
|
340
|
+
{ type, lane: laneKindForLog(lane), stream_id: streamId },
|
|
332
341
|
"detached queued attach dropped",
|
|
333
342
|
)
|
|
334
343
|
return
|
|
@@ -343,7 +352,7 @@ export class DispatchScheduler {
|
|
|
343
352
|
const runMs = Date.now() - startedAt
|
|
344
353
|
if (shouldLogDispatchTiming(type, waitMs, runMs)) {
|
|
345
354
|
this.logger?.info?.(
|
|
346
|
-
{ type, lane, wait_ms: waitMs, run_ms: runMs },
|
|
355
|
+
{ type, lane: laneKindForLog(lane), wait_ms: waitMs, run_ms: runMs },
|
|
347
356
|
"dispatch timing",
|
|
348
357
|
)
|
|
349
358
|
}
|
|
@@ -351,7 +360,7 @@ export class DispatchScheduler {
|
|
|
351
360
|
})
|
|
352
361
|
.catch((err) => {
|
|
353
362
|
this.logger?.error?.(
|
|
354
|
-
{ err: err?.message, type, lane },
|
|
363
|
+
{ err: err?.message, type, lane: laneKindForLog(lane) },
|
|
355
364
|
"dispatch threw in serial lane",
|
|
356
365
|
)
|
|
357
366
|
})
|
package/src/main.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { performance } from "node:perf_hooks"
|
|
2
2
|
import { createHash } from "node:crypto"
|
|
3
|
-
import { logPhaseTiming, startEventLoopMetrics } from "./startup-metrics.mjs"
|
|
3
|
+
import { logPhaseTiming, startEventLoopMetrics, readAttachEventLoopMetrics } from "./startup-metrics.mjs"
|
|
4
4
|
import { startLogRotation } from "./log-rotation.mjs"
|
|
5
5
|
/**
|
|
6
6
|
* hub-agent daemon 本体。
|
|
@@ -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",
|
|
@@ -3315,6 +3437,33 @@ export async function stopChatSessionsUnder(dir, ctx, reason = "worktree-remove"
|
|
|
3315
3437
|
return stopped
|
|
3316
3438
|
}
|
|
3317
3439
|
|
|
3440
|
+
// attach 一回につき一度だけ記録する。fields に本文・パス・セッション ID を持たせない。
|
|
3441
|
+
export function createClaudeAttachTiming(ctx, msg) {
|
|
3442
|
+
const now = () => performance.now()
|
|
3443
|
+
const started = now()
|
|
3444
|
+
let logged = false
|
|
3445
|
+
const fields = {
|
|
3446
|
+
stream_id: msg.stream_id,
|
|
3447
|
+
prepare_ms: null, attach_ms: null, to_ready_ms: null,
|
|
3448
|
+
send_ok: null, ws_buffered: null,
|
|
3449
|
+
resuming: false, has_resume_id: Boolean(msg.resume_session_id),
|
|
3450
|
+
}
|
|
3451
|
+
return {
|
|
3452
|
+
now, started, fields,
|
|
3453
|
+
log(outcome) {
|
|
3454
|
+
if (logged) return
|
|
3455
|
+
logged = true
|
|
3456
|
+
try {
|
|
3457
|
+
const loop = readAttachEventLoopMetrics()
|
|
3458
|
+
fields.ws_buffered = ctx.client.bufferedAmount ?? null
|
|
3459
|
+
ctx.logger?.info?.({ ...fields, ...loop, outcome }, "claude attach timing")
|
|
3460
|
+
} catch {
|
|
3461
|
+
// 計測失敗で attach / ready / error の配送を壊さない。
|
|
3462
|
+
}
|
|
3463
|
+
},
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3318
3467
|
/**
|
|
3319
3468
|
* DispatchScheduler から呼ばれる本番メッセージ入口。
|
|
3320
3469
|
* export はハンドラ契約を subprocess なしで検証するテスト専用の公開面でもある。
|
|
@@ -3388,108 +3537,134 @@ export async function dispatch(msg, ctx) {
|
|
|
3388
3537
|
ctx.client.completeStreamsSync?.(msg)
|
|
3389
3538
|
return
|
|
3390
3539
|
case "claude.attach": {
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
const stream_id = msg.stream_id
|
|
3394
|
-
if (!ctx.claudeBridge) {
|
|
3395
|
-
// SDK 未インストール時はその旨を即時 error で返す。browser 側は
|
|
3396
|
-
// 「ターミナルモードで開いてください」と表示するなどの fallback を想定。
|
|
3397
|
-
ctx.client.send({
|
|
3398
|
-
type: "claude.error",
|
|
3399
|
-
stream_id,
|
|
3400
|
-
error: "stream_mode_unavailable: @anthropic-ai/claude-agent-sdk が agent にインストールされていません",
|
|
3401
|
-
})
|
|
3402
|
-
return
|
|
3403
|
-
}
|
|
3540
|
+
const timing = createClaudeAttachTiming(ctx, msg)
|
|
3541
|
+
let outcome = "error"
|
|
3404
3542
|
try {
|
|
3405
|
-
//
|
|
3406
|
-
//
|
|
3407
|
-
|
|
3408
|
-
|
|
3543
|
+
// Sprint G: stream-json モードで Claude Code を起動。
|
|
3544
|
+
// stream_id は browser 採番のものをそのまま使う。
|
|
3545
|
+
const stream_id = msg.stream_id
|
|
3546
|
+
if (!ctx.claudeBridge) {
|
|
3547
|
+
outcome = "unavailable"
|
|
3548
|
+
// SDK 未インストール時はその旨を即時 error で返す。browser 側は
|
|
3549
|
+
// 「ターミナルモードで開いてください」と表示するなどの fallback を想定。
|
|
3550
|
+
ctx.client.send({
|
|
3551
|
+
type: "claude.error",
|
|
3552
|
+
stream_id,
|
|
3553
|
+
error: "stream_mode_unavailable: @anthropic-ai/claude-agent-sdk が agent にインストールされていません",
|
|
3554
|
+
})
|
|
3555
|
+
return
|
|
3556
|
+
}
|
|
3409
3557
|
try {
|
|
3410
|
-
|
|
3411
|
-
|
|
3558
|
+
// cwd は明示指定が無ければ agent.json の cwd → HOME の順で fallback
|
|
3559
|
+
// (将来 cockpit から worktree path を指定する用)。
|
|
3560
|
+
// model / permissionMode は agent.json の値も使えるよう fallback。
|
|
3561
|
+
const cwdArg = msg.cwd || ctx.config?.claude_cwd || undefined
|
|
3562
|
+
const prepareStarted = timing.now()
|
|
3563
|
+
try {
|
|
3564
|
+
try {
|
|
3565
|
+
await prepareChatWorkspace({
|
|
3566
|
+
waitForSync: false,
|
|
3567
|
+
cwd: cwdArg || process.env.HOME || process.cwd(),
|
|
3568
|
+
cliKind: "claude",
|
|
3569
|
+
logger: ctx.logger,
|
|
3570
|
+
})
|
|
3571
|
+
} finally {
|
|
3572
|
+
timing.fields.prepare_ms = Math.round(timing.now() - prepareStarted)
|
|
3573
|
+
}
|
|
3574
|
+
} catch (err) {
|
|
3575
|
+
// Claude は attach で会話を登録するだけで、プロセス起動前に ready を返す。
|
|
3576
|
+
// cwd が無くても live・配送対象に載るため、起動時の失敗に頼らず登録前に止める。
|
|
3577
|
+
if (isCwdUnavailableError(err)) {
|
|
3578
|
+
outcome = "workspace_missing"
|
|
3579
|
+
ctx.client.send({ type: "claude.error", stream_id, error: `workspace_missing: ${err.message}` })
|
|
3580
|
+
return
|
|
3581
|
+
}
|
|
3582
|
+
ctx.logger?.warn?.(
|
|
3583
|
+
{ cwd: cwdArg, err: err?.message || String(err) },
|
|
3584
|
+
"claude.attach workspace preparation failed (attach continues)",
|
|
3585
|
+
)
|
|
3586
|
+
}
|
|
3587
|
+
const resumeSessionId = msg.resume_session_id || null
|
|
3588
|
+
// attachは同期処理なので、履歴I/Oを先に開始してreadyまでの待ちを重ねる。
|
|
3589
|
+
// 新Browserはhistory_pushedを見て別requestを省略し、旧Browserは従来どおり
|
|
3590
|
+
// requestしてもhydrateのuuid dedupで安全に収束する。
|
|
3591
|
+
// 案C ①/major1/nit2/公開前 major: 履歴 push を attach/ready から独立させる。
|
|
3592
|
+
// resolveProjectsRoot を attach の直列 await/try に載せない (解決失敗で attach ごと
|
|
3593
|
+
// 落とさない・ready を遅らせない)。詳細は buildAttachHistoryPromise の doc 参照。
|
|
3594
|
+
const historyTiming = createHistoryDeliveryTiming()
|
|
3595
|
+
const historyPromise = buildAttachHistoryPromise({
|
|
3596
|
+
timing: historyTiming,
|
|
3597
|
+
resumeSessionId,
|
|
3412
3598
|
cwd: cwdArg || process.env.HOME || process.cwd(),
|
|
3413
|
-
cliKind: "claude",
|
|
3414
3599
|
logger: ctx.logger,
|
|
3600
|
+
profileId: msg.profile_id,
|
|
3415
3601
|
})
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3602
|
+
const attachStarted = timing.now()
|
|
3603
|
+
let info
|
|
3604
|
+
try {
|
|
3605
|
+
info = ctx.claudeBridge.attach({
|
|
3606
|
+
stream_id,
|
|
3607
|
+
cwd: cwdArg,
|
|
3608
|
+
...resolveClaudeAttachOptions(ctx.config, msg),
|
|
3609
|
+
resumeSessionId,
|
|
3610
|
+
})
|
|
3611
|
+
} finally {
|
|
3612
|
+
timing.fields.attach_ms = Math.round(timing.now() - attachStarted)
|
|
3613
|
+
}
|
|
3614
|
+
timing.fields.resuming = info?.resuming === true
|
|
3615
|
+
timing.fields.send_ok = false
|
|
3616
|
+
const sendOk = ctx.client.send(buildClaudeReadyPayload(stream_id, info))
|
|
3617
|
+
timing.fields.to_ready_ms = Math.round(timing.now() - timing.started)
|
|
3618
|
+
timing.fields.send_ok = sendOk == null ? null : Boolean(sendOk)
|
|
3619
|
+
timing.log("ready")
|
|
3620
|
+
try {
|
|
3621
|
+
if (resumeSessionId) {
|
|
3622
|
+
void ctx.siblingInjectBridge
|
|
3623
|
+
?.replayReviews?.("claude", cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
|
|
3624
|
+
?.catch((err) => ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events"))
|
|
3625
|
+
}
|
|
3626
|
+
} catch (err) {
|
|
3627
|
+
ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events")
|
|
3422
3628
|
}
|
|
3423
|
-
ctx.logger?.warn?.(
|
|
3424
|
-
{ cwd: cwdArg, err: err?.message || String(err) },
|
|
3425
|
-
"claude.attach workspace preparation failed (attach continues)",
|
|
3426
|
-
)
|
|
3427
|
-
}
|
|
3428
|
-
const resumeSessionId = msg.resume_session_id || null
|
|
3429
|
-
// attachは同期処理なので、履歴I/Oを先に開始してreadyまでの待ちを重ねる。
|
|
3430
|
-
// 新Browserはhistory_pushedを見て別requestを省略し、旧Browserは従来どおり
|
|
3431
|
-
// requestしてもhydrateのuuid dedupで安全に収束する。
|
|
3432
|
-
// 案C ①/major1/nit2/公開前 major: 履歴 push を attach/ready から独立させる。
|
|
3433
|
-
// resolveProjectsRoot を attach の直列 await/try に載せない (解決失敗で attach ごと
|
|
3434
|
-
// 落とさない・ready を遅らせない)。詳細は buildAttachHistoryPromise の doc 参照。
|
|
3435
|
-
const historyPromise = buildAttachHistoryPromise({
|
|
3436
|
-
resumeSessionId,
|
|
3437
|
-
cwd: cwdArg || process.env.HOME || process.cwd(),
|
|
3438
|
-
logger: ctx.logger,
|
|
3439
|
-
profileId: msg.profile_id,
|
|
3440
|
-
})
|
|
3441
|
-
const info = ctx.claudeBridge.attach({
|
|
3442
|
-
stream_id,
|
|
3443
|
-
cwd: cwdArg,
|
|
3444
|
-
...resolveClaudeAttachOptions(ctx.config, msg),
|
|
3445
|
-
resumeSessionId,
|
|
3446
|
-
})
|
|
3447
|
-
ctx.client.send(buildClaudeReadyPayload(stream_id, info))
|
|
3448
|
-
try {
|
|
3449
3629
|
if (resumeSessionId) {
|
|
3450
3630
|
void ctx.siblingInjectBridge
|
|
3451
|
-
?.
|
|
3452
|
-
|
|
3631
|
+
?.replayDelegations(cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
|
|
3632
|
+
.catch((err) => {
|
|
3633
|
+
ctx.logger?.warn?.(
|
|
3634
|
+
{ err: err?.message || String(err), stream_id, session_id: resumeSessionId },
|
|
3635
|
+
"failed to replay delegations after Claude attach",
|
|
3636
|
+
)
|
|
3637
|
+
})
|
|
3453
3638
|
}
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3639
|
+
// dispatch全体はWSメッセージ順を守る直列キュー上にある。ここで履歴I/Oをawaitすると
|
|
3640
|
+
// 同一接続のpermission/input等まで停止するため、readyの同期送信後は非同期でpushする。
|
|
3641
|
+
void historyPromise
|
|
3642
|
+
.then((pushedHistory) => {
|
|
3643
|
+
sendClaudeAttachHistory(ctx, stream_id, pushedHistory, historyTiming)
|
|
3644
|
+
})
|
|
3460
3645
|
.catch((err) => {
|
|
3461
3646
|
ctx.logger?.warn?.(
|
|
3462
|
-
{ err: err?.message || String(err), stream_id
|
|
3463
|
-
"failed to
|
|
3647
|
+
{ err: err?.message || String(err), stream_id },
|
|
3648
|
+
"failed to push Claude attach history",
|
|
3464
3649
|
)
|
|
3465
3650
|
})
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
.
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
)
|
|
3651
|
+
const session = ctx.claudeBridge.sessions?.get(stream_id)
|
|
3652
|
+
const resolvedCwd = session?.cwd || cwdArg || process.env.HOME || process.cwd()
|
|
3653
|
+
if (session && resumeSessionId) {
|
|
3654
|
+
void loadResumeChatContext(session, "claude").catch(() => {})
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
} catch (err) {
|
|
3658
|
+
ctx.client.send({
|
|
3659
|
+
type: "claude.error",
|
|
3660
|
+
stream_id,
|
|
3661
|
+
error: err.message,
|
|
3478
3662
|
})
|
|
3479
|
-
const session = ctx.claudeBridge.sessions?.get(stream_id)
|
|
3480
|
-
const resolvedCwd = session?.cwd || cwdArg || process.env.HOME || process.cwd()
|
|
3481
|
-
if (session && resumeSessionId) {
|
|
3482
|
-
void loadResumeChatContext(session, "claude").catch(() => {})
|
|
3483
3663
|
}
|
|
3484
|
-
|
|
3485
|
-
}
|
|
3486
|
-
|
|
3487
|
-
type: "claude.error",
|
|
3488
|
-
stream_id,
|
|
3489
|
-
error: err.message,
|
|
3490
|
-
})
|
|
3664
|
+
return
|
|
3665
|
+
} finally {
|
|
3666
|
+
timing.log(outcome)
|
|
3491
3667
|
}
|
|
3492
|
-
return
|
|
3493
3668
|
}
|
|
3494
3669
|
case "claude.session.options.set": {
|
|
3495
3670
|
const result = ctx.claudeBridge?.setSessionOptions({
|
|
@@ -4137,56 +4312,7 @@ export async function dispatch(msg, ctx) {
|
|
|
4137
4312
|
return
|
|
4138
4313
|
}
|
|
4139
4314
|
case "claude.history.request": {
|
|
4140
|
-
|
|
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
|
-
})
|
|
4315
|
+
await handleClaudeHistoryRequest(msg, ctx)
|
|
4190
4316
|
return
|
|
4191
4317
|
}
|
|
4192
4318
|
case "claude.sessions.request": {
|
package/src/startup-metrics.mjs
CHANGED
|
@@ -4,6 +4,74 @@ import { monitorEventLoopDelay, performance } from "node:perf_hooks"
|
|
|
4
4
|
// performance.now() はプロセス起動を原点とする単調時計。テストでは _setNowForTest で差し替える。
|
|
5
5
|
let defaultNow = () => performance.now()
|
|
6
6
|
|
|
7
|
+
// attach 用のイベントループ遅延。一定間隔のタイマーが予定よりどれだけ遅れたかを直接測る。
|
|
8
|
+
// ⚠️ monitorEventLoopDelay は使わない: Node 24 では 1 回の長い停止を取りこぼす
|
|
9
|
+
// (150ms 塞いでも max 22ms。2026-10-14 実測)。知りたいのはまさにその単発の停止なので測れない。
|
|
10
|
+
const ATTACH_LOOP_TICK_MS = 20
|
|
11
|
+
const ATTACH_LOOP_STALL_MS = 100
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* タイマー遅延の集計(時計を差し込める状態機械)。read() ごとに区間を切る。
|
|
15
|
+
* - loop_delay_max_ms: 区間内に重なった遅延の最大。区間をまたぐ停止は区間内の部分だけを数える
|
|
16
|
+
* (読み取り直後の区間に、前区間で報告済みの遅延を持ち越さない)
|
|
17
|
+
* - loop_stalls_100ms: 停止(遅延が stallMs 以上)の回数。1 回の停止は、stallMs に達したと最初に
|
|
18
|
+
* 分かった区間で 1 回だけ数える(読み取り時の進行中遅延で数えたら、後のタイマー発火では数えない)
|
|
19
|
+
* - loop_window_ms: 区間長
|
|
20
|
+
*/
|
|
21
|
+
export function createLoopLagSampler({ now = () => performance.now(), tickMs = ATTACH_LOOP_TICK_MS, stallMs = ATTACH_LOOP_STALL_MS } = {}) {
|
|
22
|
+
let lastTick = now()
|
|
23
|
+
let windowStart = lastTick
|
|
24
|
+
let maxLag = 0
|
|
25
|
+
let stalls = 0
|
|
26
|
+
// lastTick 以降に続いている停止を、既に回数へ数えたか。
|
|
27
|
+
let ongoingCounted = false
|
|
28
|
+
return {
|
|
29
|
+
tick() {
|
|
30
|
+
const t = now()
|
|
31
|
+
const lag = Math.max(0, t - lastTick - tickMs)
|
|
32
|
+
const inWindow = Math.min(lag, Math.max(0, t - windowStart))
|
|
33
|
+
if (inWindow > maxLag) maxLag = inWindow
|
|
34
|
+
if (lag >= stallMs && !ongoingCounted) stalls++
|
|
35
|
+
ongoingCounted = false
|
|
36
|
+
lastTick = t
|
|
37
|
+
},
|
|
38
|
+
read() {
|
|
39
|
+
const t = now()
|
|
40
|
+
const pending = Math.max(0, t - lastTick - tickMs)
|
|
41
|
+
const pendingInWindow = Math.min(pending, Math.max(0, t - windowStart))
|
|
42
|
+
let windowStalls = stalls
|
|
43
|
+
if (pending >= stallMs && !ongoingCounted) {
|
|
44
|
+
windowStalls++
|
|
45
|
+
ongoingCounted = true
|
|
46
|
+
}
|
|
47
|
+
const fields = {
|
|
48
|
+
loop_delay_max_ms: Math.round(Math.max(maxLag, pendingInWindow)),
|
|
49
|
+
loop_stalls_100ms: windowStalls,
|
|
50
|
+
loop_window_ms: Math.round(t - windowStart),
|
|
51
|
+
}
|
|
52
|
+
maxLag = 0
|
|
53
|
+
stalls = 0
|
|
54
|
+
windowStart = t
|
|
55
|
+
return fields
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** サンプラーを作り、タイマーで tick() を回す。計測のためにプロセスの終了を引き留めない。 */
|
|
61
|
+
export function startLoopLagSampler({ now, tickMs = ATTACH_LOOP_TICK_MS, stallMs = ATTACH_LOOP_STALL_MS, setIntervalFn = setInterval } = {}) {
|
|
62
|
+
const sampler = createLoopLagSampler({ now, tickMs, stallMs })
|
|
63
|
+
const timer = setIntervalFn(() => sampler.tick(), tickMs)
|
|
64
|
+
timer?.unref?.()
|
|
65
|
+
return sampler
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const attachLoopSampler = startLoopLagSampler()
|
|
69
|
+
|
|
70
|
+
/** 前回読んでから今までのイベントループ遅延(最大・100ms 以上の停止回数・区間長)を返し、区間を切る。 */
|
|
71
|
+
export function readAttachEventLoopMetrics() {
|
|
72
|
+
return attachLoopSampler.read()
|
|
73
|
+
}
|
|
74
|
+
|
|
7
75
|
/** テスト専用: 既定の時計を差し替える (省略で元に戻す)。直前の時計を返す。 */
|
|
8
76
|
export function _setNowForTest(fn) {
|
|
9
77
|
const prev = defaultNow
|
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 {{
|