@cocorograph/hub-agent 0.6.64 → 0.6.66

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.6.64",
3
+ "version": "0.6.66",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/main.mjs CHANGED
@@ -1044,6 +1044,53 @@ export function handleStreamsSyncResponse(msg, ctx) {
1044
1044
  }
1045
1045
  }
1046
1046
 
1047
+ /**
1048
+ * end-to-end 配達確認 (tracked pty.data) の冪等化レジストリ。
1049
+ *
1050
+ * browser はチャット送信 (paste / 確定 CR) に input_id を付け、agent からの
1051
+ * pty.input.ack が来ない限り再送する (zombie WS / 切断窓 / backend group_send の
1052
+ * 無言喪失への対策)。「実際は届いていたが ack 側が消えた」再送で同じ本文が
1053
+ * 二重 paste されないよう、処理済み input_id を記憶して 2 回目以降は write せず
1054
+ * ack だけ返す。Set は挿入順 iterate できるので最古から間引く。
1055
+ */
1056
+ const SEEN_INPUT_IDS_MAX = 1_000
1057
+ const seenInputIds = new Set()
1058
+ function rememberInputId(input_id) {
1059
+ seenInputIds.add(input_id)
1060
+ while (seenInputIds.size > SEEN_INPUT_IDS_MAX) {
1061
+ seenInputIds.delete(seenInputIds.values().next().value)
1062
+ }
1063
+ }
1064
+
1065
+ /**
1066
+ * input_id 付き pty.data (tracked 入力) の処理。write 成功で pty.input.ack を返し、
1067
+ * stream 不在なら pty.error を返して browser の再 attach → 再送に繋げる。
1068
+ * テストから直接呼べるよう dispatch から切り出して export する。
1069
+ */
1070
+ export function handleTrackedPtyData(msg, ctx) {
1071
+ const { stream_id, input_id } = msg
1072
+ if (seenInputIds.has(input_id)) {
1073
+ // 再送 (ack 喪失 or 再接続 flush)。書き込み済みなので ack だけ返す。
1074
+ ctx.logger?.info({ stream_id, input_id }, "tracked pty.data dedup (ack only)")
1075
+ ctx.client.send({ type: "pty.input.ack", stream_id, input_id })
1076
+ return
1077
+ }
1078
+ const ok = ctx.ptyBridge.write({ stream_id, data: msg.data })
1079
+ if (ok) {
1080
+ rememberInputId(input_id)
1081
+ ctx.client.send({ type: "pty.input.ack", stream_id, input_id })
1082
+ } else {
1083
+ // stream 不在 (reap 済み等)。browser は pty.error で再 attach し、未 ack の
1084
+ // tracked 入力は ack ウォッチドッグが再送する。
1085
+ ctx.client.send({
1086
+ type: "pty.error",
1087
+ stream_id,
1088
+ error: "stream_missing",
1089
+ input_id,
1090
+ })
1091
+ }
1092
+ }
1093
+
1047
1094
  async function dispatch(msg, ctx) {
1048
1095
  const t = msg?.type || ""
1049
1096
  try {
@@ -1073,6 +1120,10 @@ async function dispatch(msg, ctx) {
1073
1120
  stream_id,
1074
1121
  session_name: msg.session_name || "",
1075
1122
  plugin: info.plugin,
1123
+ // end-to-end 配達確認 (input_id 付き pty.data → pty.input.ack) に対応
1124
+ // していることを browser へ通知する capability フラグ。旧 agent には
1125
+ // このフラグが無く、browser 側は tracked 送信を自動で無効化する。
1126
+ supports_input_ack: true,
1076
1127
  })
1077
1128
  } catch (err) {
1078
1129
  ctx.client.send({
@@ -1085,6 +1136,12 @@ async function dispatch(msg, ctx) {
1085
1136
  return
1086
1137
  }
1087
1138
  case "pty.data":
1139
+ // input_id 付き = tracked 入力 (チャット送信)。冪等化 + ack 返却で
1140
+ // end-to-end 配達確認に応える。無印は従来どおり fire-and-forget。
1141
+ if (typeof msg.input_id === "string" && msg.input_id) {
1142
+ handleTrackedPtyData(msg, ctx)
1143
+ return
1144
+ }
1088
1145
  ctx.ptyBridge.write({ stream_id: msg.stream_id, data: msg.data })
1089
1146
  return
1090
1147
  case "pty.resize":
@@ -1400,15 +1457,19 @@ async function dispatch(msg, ctx) {
1400
1457
  // model="" は「デフォルト」= `/model default`。frontend へはそのまま空で返し、
1401
1458
  // 解決後の実 id は次ターンの jsonl 由来 (message.model) に委ねる。
1402
1459
  const model = typeof msg.model === "string" ? msg.model : ""
1460
+ // 先に broadcast してフロントの fallback (raw `/model` paste) を即キャンセルさせる。
1461
+ // setTuiModel は「Switch model?」確認ダイアログの描画検知で数秒かかり得るため、その
1462
+ // 完了を待つとフロントの MODEL_SYNC_FALLBACK_MS が先に発火して二重 paste になる。
1463
+ // バッジは要求モデルを即表示し、確定値は次ターンの jsonl (message.model) で補正される。
1464
+ ctx.client.send({
1465
+ type: "claude.tui.model",
1466
+ cwd: cwd || undefined,
1467
+ session_name: sessionName,
1468
+ model,
1469
+ })
1403
1470
  ;(async () => {
1404
1471
  try {
1405
1472
  await setTuiModel(sessionName, model || "default", { logger })
1406
- ctx.client.send({
1407
- type: "claude.tui.model",
1408
- cwd: cwd || undefined,
1409
- session_name: sessionName,
1410
- model,
1411
- })
1412
1473
  logger.info(
1413
1474
  { session: sessionName, cwd, model: model || "(default)" },
1414
1475
  "tui model switched → notified browser",
@@ -1433,15 +1494,19 @@ async function dispatch(msg, ctx) {
1433
1494
  if (!sessionName) return
1434
1495
  // effort="" は「auto」= `/effort auto`。frontend へはそのまま空で返し、バッジは auto 表示。
1435
1496
  const effort = typeof msg.effort === "string" ? msg.effort : ""
1497
+ // setModel と同様、先に broadcast してフロントの fallback (raw `/effort` paste) を即
1498
+ // キャンセルさせる。setTuiEffort も「Change effort level?」確認ダイアログの描画検知で
1499
+ // 数秒かかり得るため、完了待ちだと EFFORT_SYNC_FALLBACK_MS が先に発火して二重 paste
1500
+ // になる。effort は jsonl に記録されないので、この即時 broadcast が表示の根拠になる。
1501
+ ctx.client.send({
1502
+ type: "claude.tui.effort",
1503
+ cwd: cwd || undefined,
1504
+ session_name: sessionName,
1505
+ effort,
1506
+ })
1436
1507
  ;(async () => {
1437
1508
  try {
1438
1509
  await setTuiEffort(sessionName, effort || "auto", { logger })
1439
- ctx.client.send({
1440
- type: "claude.tui.effort",
1441
- cwd: cwd || undefined,
1442
- session_name: sessionName,
1443
- effort,
1444
- })
1445
1510
  logger.info(
1446
1511
  { session: sessionName, cwd, effort: effort || "(auto)" },
1447
1512
  "tui effort switched → notified browser",
package/src/tmux.mjs CHANGED
@@ -785,6 +785,74 @@ export function buildFreshCmd(opts = {}) {
785
785
 
786
786
  const _delay = (ms) => new Promise((r) => setTimeout(r, ms))
787
787
 
788
+ /**
789
+ * `/model` / `/effort` 切替時に claude TUI が出す確認ダイアログを検知する正規表現。
790
+ *
791
+ * claude v2.1.170 実機確認 (2026-06-10): 会話に出力済みの履歴 (prior output) があると、
792
+ * モデル/effort 切替は即時反映されず確認ダイアログを挟む。ダイアログは:
793
+ * - タイトル: "Switch model?" / "Change effort level?"
794
+ * - 本文: "This conversation is cached for the current model. Switching to … means the
795
+ * full history gets re-read on your next message."
796
+ * - 選択肢: "Yes, switch to …" (デフォルトハイライト) / "No, go back"
797
+ * デフォルトが確定側なので Enter 1 回で承認できる。複数マーカーのいずれかで判定する
798
+ * (文言の小変更に多少強くする)。
799
+ */
800
+ const SWITCH_CONFIRM_DIALOG_RE =
801
+ /Switch model\?|Change effort level\?|Yes, switch to|No, go back|full history gets re-read/i
802
+
803
+ /**
804
+ * `/model` / `/effort` の確認ダイアログを「描画を検知してから」Enter で畳む。
805
+ *
806
+ * 旧実装は本文確定 Enter の一定時間後に盲打ちで追い Enter を 1 回送っていたが、確認
807
+ * ダイアログのレンダはコマンド確定から遅延することがあり (TUI の全画面再描画ラグ)、
808
+ * 固定待ちの Enter がダイアログ描画前に着弾して取りこぼされ、ダイアログが残ったまま
809
+ * 次の入力の Enter を吸収する事故になっていた。代わりにペインを短周期で読み、ダイアログ
810
+ * が実際に描画されたのを確認してから Enter を送り、消えるまで数回リトライする。ダイアログ
811
+ * が出ない場合 (prior output 無し = 即時反映) は余計な Enter を送らない (空 Enter が
812
+ * 別の場面で誤確定するのを避ける)。ベストエフォート。
813
+ *
814
+ * タイミングは opts で上書きできる (テストで短縮するため):
815
+ * - confirmAppearMs: ダイアログ出現を待つ上限 (既定 2400ms)
816
+ * - confirmPollMs: 出現待ちのポーリング間隔 (既定 150ms)
817
+ * - confirmDismissMs: 承認後にダイアログ消失を待つ上限 (既定 2000ms)
818
+ *
819
+ * @param {string} bin tmux バイナリパス
820
+ * @param {string} name tmux セッション名
821
+ * @param {{logger?:object,tmuxBin?:string,confirmAppearMs?:number,confirmPollMs?:number,confirmDismissMs?:number}} opts
822
+ * @returns {Promise<boolean>} 確認ダイアログを承認したら true
823
+ */
824
+ async function _confirmSwitchDialog(bin, name, opts) {
825
+ const appearMs = opts.confirmAppearMs ?? 2400
826
+ const pollMs = opts.confirmPollMs ?? 150
827
+ const dismissMs = opts.confirmDismissMs ?? 2000
828
+ // ダイアログ出現待ち: コマンド確定から最大 appearMs 観測する (レンダ遅延の上限想定)。
829
+ const APPEAR_DEADLINE = Date.now() + appearMs
830
+ let appeared = false
831
+ while (Date.now() < APPEAR_DEADLINE) {
832
+ const text = await capturePane(name, { ...opts, noCache: true })
833
+ if (SWITCH_CONFIRM_DIALOG_RE.test(text)) {
834
+ appeared = true
835
+ break
836
+ }
837
+ await _delay(pollMs)
838
+ }
839
+ if (!appeared) return false // prior output 無し = 即時反映済み。追い Enter 不要。
840
+
841
+ // ダイアログが消える (= 承認反映) まで Enter を送ってリトライする。
842
+ const DISMISS_DEADLINE = Date.now() + dismissMs
843
+ while (Date.now() < DISMISS_DEADLINE) {
844
+ await execFileP(bin, ["send-keys", "-t", name, "Enter"])
845
+ await _delay(180)
846
+ const after = await capturePane(name, { ...opts, noCache: true })
847
+ if (!SWITCH_CONFIRM_DIALOG_RE.test(after)) return true
848
+ }
849
+ opts.logger?.warn(
850
+ { session: name },
851
+ "switch confirm dialog still visible after retries",
852
+ )
853
+ return true
854
+ }
855
+
788
856
  /**
789
857
  * 遅延回答 resume: 承認/質問カードへの回答がフックの生存中に間に合わなかったとき、
790
858
  * 回答を「新しいユーザーメッセージ」として同一 tmux セッションの対話 claude へ届ける。
@@ -921,9 +989,9 @@ export async function setTuiModel(name, modelArg, opts = {}) {
921
989
  await _delay(120)
922
990
  // Enter で本文確定 (send-keys の離散イベントなので paste 吸収は起きにくい)。
923
991
  await execFileP(bin, ["send-keys", "-t", name, "Enter"])
924
- // prior output ありの場合に出るモデル切替の確認プロンプトを Enter で畳む。
925
- await _delay(450)
926
- await execFileP(bin, ["send-keys", "-t", name, "Enter"])
992
+ // prior output ありの場合に出る「Switch model?」確認ダイアログを、描画を検知してから
993
+ // Enter で畳む (固定待ちの盲打ち Enter はレンダ遅延と競合して取りこぼすため)
994
+ await _confirmSwitchDialog(bin, name, opts)
927
995
  return { ok: true }
928
996
  } catch (err) {
929
997
  opts.logger?.warn(
@@ -941,9 +1009,11 @@ export async function setTuiModel(name, modelArg, opts = {}) {
941
1009
  * 同設計で、frontend は raw pty.data ではなく claude.tui.setEffort を送り、agent 側で本関数を
942
1010
  * 実行 → 全ブラウザへ claude.tui.effort を broadcast して全端末を実 effort に揃える。
943
1011
  *
944
- * `/effort` は引数 (low/medium/high/xhigh/max/auto) を受理し即時反映する (確認プロンプトを
945
- * 挟まない)。effort 非対応レベルを与えても CLI がモデルの対応上限へ自動フォールバックする。
946
- * `auto` はモデル既定へリセット。copy-mode に入っているとキーが奪われるので先に抜ける。
1012
+ * `/effort` は引数 (low/medium/high/xhigh/max/auto) を受理する。effort 非対応レベルを
1013
+ * 与えても CLI がモデルの対応上限へ自動フォールバックする。`auto` はモデル既定へリセット。
1014
+ * claude v2.1.170 以降は会話に出力済みの履歴があると「Change effort level?」確認ダイアログ
1015
+ * を挟むようになった (モデル切替と同じ S1_ コンポーネント) ため、setTuiModel と同様に描画を
1016
+ * 検知してから Enter で畳む。copy-mode に入っているとキーが奪われるので先に抜ける。
947
1017
  * ベストエフォート。
948
1018
  *
949
1019
  * @param {string} name tmux セッション名
@@ -977,6 +1047,9 @@ export async function setTuiEffort(name, effortArg, opts = {}) {
977
1047
  await _delay(120)
978
1048
  // Enter で本文確定 (send-keys の離散イベントなので paste 吸収は起きにくい)。
979
1049
  await execFileP(bin, ["send-keys", "-t", name, "Enter"])
1050
+ // prior output ありの場合に出る「Change effort level?」確認ダイアログを、描画を検知して
1051
+ // から Enter で畳む (setTuiModel と同じ堅牢化)。
1052
+ await _confirmSwitchDialog(bin, name, opts)
980
1053
  return { ok: true }
981
1054
  } catch (err) {
982
1055
  opts.logger?.warn(
package/src/ws-client.mjs CHANGED
@@ -23,6 +23,19 @@ const MIN_BACKOFF_MS = 1_000
23
23
  const MAX_BACKOFF_MS = 30_000
24
24
  const BUNDLE_WATCH_DEBOUNCE_MS = 500
25
25
 
26
+ // zombie WS (half-open TCP) 検知のしきい値。backend は heartbeat (30s) のたびに
27
+ // ack を返すため、健全な接続なら inbound frame が最低 30s+RTT に 1 回は届く。
28
+ // これを超えて沈黙した接続は「readyState=OPEN だが TCP は死んでいる」zombie の
29
+ // 可能性が高い (send は kernel buffer に書けて成功扱いになるため throw しない)。
30
+ // heartbeat 2.5 周期 = 75s を超えたら強制再接続する。browser 側 CockpitTerminal の
31
+ // LIVENESS_TIMEOUT_MS (PR#2342) と同じパターンの agent 版。
32
+ const LIVENESS_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 2.5
33
+ // heartbeat tick 間隔の実測ジャンプ検知 (Mac スリープ復帰対策)。スリープ中は
34
+ // プロセスごと止まるため setInterval の実発火間隔が大きく開く。復帰直後の TCP は
35
+ // ほぼ確実に死んでいる (half-open) のに send は成功扱いになるので、ジャンプを
36
+ // 検知したら鮮度判定を待たず即座に再接続する。2 周期 (60s) 超のギャップで発火。
37
+ const TICK_JUMP_MS = HEARTBEAT_INTERVAL_MS * 2
38
+
26
39
  // 接続が安定したとみなすまでの猶予。open 直後に即 backoff をリセットすると、
27
40
  // open→即 close のフラッピング時に毎回 backoff が最小 (1s) へ戻り、1〜2 秒間隔の
28
41
  // 再接続ストロボに陥る (CF/backend 側の瞬断時に hub-agent↔backend WS で観測)。
@@ -85,6 +98,11 @@ export class WsClient extends EventEmitter {
85
98
  // 接続が STABLE_CONNECTION_MS 維持できたら backoff/5xx フラグをリセットする
86
99
  // タイマー。close (_clearStableReset) でキャンセルされる。
87
100
  this.stableResetTimer = null
101
+ // zombie WS 検知: 最後に inbound frame (heartbeat ack / agent.relay 等の何か) を
102
+ // 受信した時刻。健全なら heartbeat ack で 30s ごとに必ず更新される。
103
+ this.lastRecvAt = 0
104
+ // heartbeat tick の直前実発火時刻。スリープ復帰のジャンプ検知に使う。
105
+ this.lastTickAt = 0
88
106
  }
89
107
 
90
108
  /** WSS 接続を開始する。`stop()` まで自動で reconnect 続行。 */
@@ -102,6 +120,9 @@ export class WsClient extends EventEmitter {
102
120
  ws.on("open", () => this._onOpen())
103
121
 
104
122
  ws.on("message", (data) => {
123
+ // 何かを受信した = TCP は生きている。zombie 検知の鮮度を更新する
124
+ // (parse 失敗でも「届いた」事実は生存の証拠なので先に記録する)。
125
+ this.lastRecvAt = Date.now()
105
126
  let msg
106
127
  try {
107
128
  msg = JSON.parse(data.toString("utf-8"))
@@ -151,6 +172,10 @@ export class WsClient extends EventEmitter {
151
172
  // ストロボ対策)。すぐ切れた接続では backoff がリセットされず指数バックオフが
152
173
  // 効き続けるため、1〜2 秒間隔の再接続ループに陥らない。
153
174
  this._armStableReset()
175
+ // open 自体が生存の証拠。直前接続の古い lastRecvAt で開幕直後に zombie 誤検知
176
+ // しないようリセットする。
177
+ this.lastRecvAt = Date.now()
178
+ this.lastTickAt = Date.now()
154
179
  this.logger?.info("ws open")
155
180
  // 切断中に積んだ pty.data を flush。hello より先に送ると backend で stream
156
181
  // 未登録のため unknown_stream error になる可能性があるが、hello の応答待ち
@@ -284,7 +309,35 @@ export class WsClient extends EventEmitter {
284
309
 
285
310
  _startHeartbeat() {
286
311
  this._stopHeartbeat()
312
+ this.lastTickAt = Date.now()
287
313
  this.heartbeatTimer = setInterval(() => {
314
+ const now = Date.now()
315
+ // スリープ復帰検知: スリープ中はプロセスごと止まり、setInterval の実発火間隔が
316
+ // 大きく開く。復帰直後の TCP は half-open でほぼ確実に死んでいるのに send は
317
+ // 成功扱いになるため、鮮度判定 (受信側) を待たず即座に再接続へ倒す。
318
+ const tickGap = this.lastTickAt > 0 ? now - this.lastTickAt : 0
319
+ this.lastTickAt = now
320
+ if (tickGap > TICK_JUMP_MS) {
321
+ this.logger?.warn(
322
+ { tickGapMs: tickGap },
323
+ "heartbeat tick jump detected (likely sleep wake), forcing reconnect",
324
+ )
325
+ this._forceReconnect()
326
+ return
327
+ }
328
+ // zombie WS 検知 (inbound 鮮度): backend は heartbeat のたびに ack を返すため、
329
+ // 健全なら lastRecvAt は 30s+RTT 以内に必ず更新される。LIVENESS_TIMEOUT_MS を
330
+ // 超えて何も届いていない接続は half-open とみなし強制再接続する。send の
331
+ // throw 判定 (下) では half-open を検知できない (kernel buffer に書けて
332
+ // 成功扱いになる) ことへの対策。browser 側 PR#2342 と同じパターン。
333
+ if (this.lastRecvAt > 0 && now - this.lastRecvAt > LIVENESS_TIMEOUT_MS) {
334
+ this.logger?.warn(
335
+ { sinceLastRecvMs: now - this.lastRecvAt },
336
+ "ws liveness timeout (zombie suspected), forcing reconnect",
337
+ )
338
+ this._forceReconnect()
339
+ return
340
+ }
288
341
  // heartbeat 都度 provider 経由で bundle version を最新化する。
289
342
  // fs.watch を取り逃した変更 (atomic rename / 別マウント越し等) への保険。
290
343
  this._refreshBundleVersion()