@cocorograph/hub-agent 0.8.111 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.111",
3
+ "version": "0.8.112",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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 本体。
@@ -3437,6 +3437,33 @@ export async function stopChatSessionsUnder(dir, ctx, reason = "worktree-remove"
3437
3437
  return stopped
3438
3438
  }
3439
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
+
3440
3467
  /**
3441
3468
  * DispatchScheduler から呼ばれる本番メッセージ入口。
3442
3469
  * export はハンドラ契約を subprocess なしで検証するテスト専用の公開面でもある。
@@ -3510,110 +3537,134 @@ export async function dispatch(msg, ctx) {
3510
3537
  ctx.client.completeStreamsSync?.(msg)
3511
3538
  return
3512
3539
  case "claude.attach": {
3513
- // Sprint G: stream-json モードで Claude Code を起動。
3514
- // stream_id は browser 採番のものをそのまま使う。
3515
- const stream_id = msg.stream_id
3516
- if (!ctx.claudeBridge) {
3517
- // SDK 未インストール時はその旨を即時 error で返す。browser 側は
3518
- // 「ターミナルモードで開いてください」と表示するなどの fallback を想定。
3519
- ctx.client.send({
3520
- type: "claude.error",
3521
- stream_id,
3522
- error: "stream_mode_unavailable: @anthropic-ai/claude-agent-sdk が agent にインストールされていません",
3523
- })
3524
- return
3525
- }
3540
+ const timing = createClaudeAttachTiming(ctx, msg)
3541
+ let outcome = "error"
3526
3542
  try {
3527
- // cwd は明示指定が無ければ agent.json の cwd → HOME の順で fallback
3528
- // (将来 cockpit から worktree path を指定する用)。
3529
- // model / permissionMode は agent.json の値も使えるよう fallback。
3530
- const cwdArg = msg.cwd || ctx.config?.claude_cwd || undefined
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
+ }
3531
3557
  try {
3532
- await prepareChatWorkspace({
3533
- waitForSync: false,
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,
3534
3598
  cwd: cwdArg || process.env.HOME || process.cwd(),
3535
- cliKind: "claude",
3536
3599
  logger: ctx.logger,
3600
+ profileId: msg.profile_id,
3537
3601
  })
3538
- } catch (err) {
3539
- // Claude は attach で会話を登録するだけで、プロセス起動前に ready を返す。
3540
- // cwd が無くても live・配送対象に載るため、起動時の失敗に頼らず登録前に止める。
3541
- if (isCwdUnavailableError(err)) {
3542
- ctx.client.send({ type: "claude.error", stream_id, error: `workspace_missing: ${err.message}` })
3543
- return
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")
3544
3628
  }
3545
- ctx.logger?.warn?.(
3546
- { cwd: cwdArg, err: err?.message || String(err) },
3547
- "claude.attach workspace preparation failed (attach continues)",
3548
- )
3549
- }
3550
- const resumeSessionId = msg.resume_session_id || null
3551
- // attachは同期処理なので、履歴I/Oを先に開始してreadyまでの待ちを重ねる。
3552
- // 新Browserはhistory_pushedを見て別requestを省略し、旧Browserは従来どおり
3553
- // requestしてもhydrateのuuid dedupで安全に収束する。
3554
- // 案C ①/major1/nit2/公開前 major: 履歴 push を attach/ready から独立させる。
3555
- // resolveProjectsRoot を attach の直列 await/try に載せない (解決失敗で attach ごと
3556
- // 落とさない・ready を遅らせない)。詳細は buildAttachHistoryPromise の doc 参照。
3557
- const historyTiming = createHistoryDeliveryTiming()
3558
- const historyPromise = buildAttachHistoryPromise({
3559
- timing: historyTiming,
3560
- resumeSessionId,
3561
- cwd: cwdArg || process.env.HOME || process.cwd(),
3562
- logger: ctx.logger,
3563
- profileId: msg.profile_id,
3564
- })
3565
- const info = ctx.claudeBridge.attach({
3566
- stream_id,
3567
- cwd: cwdArg,
3568
- ...resolveClaudeAttachOptions(ctx.config, msg),
3569
- resumeSessionId,
3570
- })
3571
- ctx.client.send(buildClaudeReadyPayload(stream_id, info))
3572
- try {
3573
3629
  if (resumeSessionId) {
3574
3630
  void ctx.siblingInjectBridge
3575
- ?.replayReviews?.("claude", cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
3576
- ?.catch((err) => ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events"))
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
+ })
3577
3638
  }
3578
- } catch (err) {
3579
- ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events")
3580
- }
3581
- if (resumeSessionId) {
3582
- void ctx.siblingInjectBridge
3583
- ?.replayDelegations(cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
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
+ })
3584
3645
  .catch((err) => {
3585
3646
  ctx.logger?.warn?.(
3586
- { err: err?.message || String(err), stream_id, session_id: resumeSessionId },
3587
- "failed to replay delegations after Claude attach",
3647
+ { err: err?.message || String(err), stream_id },
3648
+ "failed to push Claude attach history",
3588
3649
  )
3589
3650
  })
3590
- }
3591
- // dispatch全体はWSメッセージ順を守る直列キュー上にある。ここで履歴I/Oをawaitすると
3592
- // 同一接続のpermission/input等まで停止するため、readyの同期送信後は非同期でpushする。
3593
- void historyPromise
3594
- .then((pushedHistory) => {
3595
- sendClaudeAttachHistory(ctx, stream_id, pushedHistory, historyTiming)
3596
- })
3597
- .catch((err) => {
3598
- ctx.logger?.warn?.(
3599
- { err: err?.message || String(err), stream_id },
3600
- "failed to push Claude attach history",
3601
- )
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,
3602
3662
  })
3603
- const session = ctx.claudeBridge.sessions?.get(stream_id)
3604
- const resolvedCwd = session?.cwd || cwdArg || process.env.HOME || process.cwd()
3605
- if (session && resumeSessionId) {
3606
- void loadResumeChatContext(session, "claude").catch(() => {})
3607
3663
  }
3608
-
3609
- } catch (err) {
3610
- ctx.client.send({
3611
- type: "claude.error",
3612
- stream_id,
3613
- error: err.message,
3614
- })
3664
+ return
3665
+ } finally {
3666
+ timing.log(outcome)
3615
3667
  }
3616
- return
3617
3668
  }
3618
3669
  case "claude.session.options.set": {
3619
3670
  const result = ctx.claudeBridge?.setSessionOptions({
@@ -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