@cocorograph/hub-agent 0.8.24 → 0.8.26

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/src/main.mjs CHANGED
@@ -4,9 +4,8 @@
4
4
  * - 設定読み込み (~/.hub/agent.json) → WsClient 起動
5
5
  * - plugin discovery + onAgentStart broadcast
6
6
  * - WS メッセージのディスパッチ:
7
- * - pty.attach / pty.data / pty.resize / pty.detach → PtyBridge
8
- * - pty 出力 → WsClient で type=pty.data として送信
9
- * - tmux.exec は Sprint E で tmux module に渡す
7
+ * - claude.* / codex.* → ClaudeStreamBridge / CodexStreamBridge (チャット)
8
+ * - tmux.* → tmux module (exec / セッション一覧・作成・削除)
10
9
  *
11
10
  * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
12
11
  */
@@ -48,7 +47,6 @@ import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs
48
47
  import { WsClient } from "./ws-client.mjs"
49
48
  import { DispatchScheduler } from "./dispatch-scheduler.mjs"
50
49
  import { AsyncTtlCache } from "./async-ttl-cache.mjs"
51
- import { PtyBridge } from "./pty-bridge.mjs"
52
50
  import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
53
51
  import { CodexMcpControl } from "./codex-mcp-control.mjs"
54
52
  import { CodexStreamBridge, classifyCodexInputError } from "./codex-stream-bridge.mjs"
@@ -56,6 +54,7 @@ import { enforceTotalLiveBound } from "./warm-bounds-total.mjs"
56
54
  import { UploadManager } from "./claude-upload.mjs"
57
55
  import { requestSelfUninstall } from "./service-install.mjs"
58
56
  import {
57
+ drainTaskNotificationEnvelopeDrops,
59
58
  fetchSessionHistory,
60
59
  listSessions,
61
60
  } from "./claude-history.mjs"
@@ -102,7 +101,6 @@ import {
102
101
  listWorktreeStubs,
103
102
  paneClaudeAliveOrUnknown,
104
103
  removeWorktree as removeWorktreeDir,
105
- setSessionMouse,
106
104
  setTmuxGlobalEnv,
107
105
  } from "./tmux.mjs"
108
106
  import { SiblingInjectBridge } from "./sibling-inject.mjs"
@@ -279,7 +277,7 @@ function readBundleVersionSync() {
279
277
  /**
280
278
  * `@anthropic-ai/claude-agent-sdk` を lazy import する。テストでは sdk arg で差し替え。
281
279
  *
282
- * SDK 未インストール (旧 hub-agent インストール) 環境でも pty モードは動くべきなので、
280
+ * SDK 未インストール (旧 hub-agent インストール) 環境でも agent 自体は起動すべきなので、
283
281
  * import 失敗を warn に留めて null を返す。stream モードを使う attach が来たら
284
282
  * その時点で error を browser に返す設計。
285
283
  */
@@ -333,22 +331,16 @@ export function withDeadline(promise, timeoutMs, onTimeout) {
333
331
  }
334
332
 
335
333
  /**
336
- * B7: 直列 dispatchChain をバイパスして即時処理してよい高頻度・低レイテンシ経路かを判定する。
337
- * - pty 出力データ (pty.data) と resize (pty.resize): 高頻度・順序保証不要。
334
+ * B7: 直列 dispatchChain をバイパスして即時処理してよい低レイテンシ経路かを判定する。
338
335
  * - claude.interrupt / codex.interrupt (2026-07-02): 停止指示。前段に重い dispatch
339
- * (tmux.exec 等) が滞留していると停止が遅延するため直列キューをバイパスする。
340
- * 中断は「現ターンを止める」冪等な制御で、入力系のような順序依存が無い (むしろ
341
- * キュー内の後続入力より先に届くべき)。
342
- * 入力系 (claude.input)・他の制御系 (tmux.exec / permission / cancel→paste 等) は
336
+ * (tmux.exec / tmux セッション操作等) が滞留していると停止が遅延するため直列キューを
337
+ * バイパスする。中断は「現ターンを止める」冪等な制御で、入力系のような順序依存が無い
338
+ * (むしろキュー内の後続入力より先に届くべき)。
339
+ * 入力系 (claude.input)・他の制御系 (permission / tmux.exec / tmux セッション操作等) は
343
340
  * WS 受信順 = pane 反映順を守るため false (= 直列キューに残す)。
344
341
  */
345
342
  export function isFastPathMessage(type) {
346
- return (
347
- type === "pty.data" ||
348
- type === "pty.resize" ||
349
- type === "claude.interrupt" ||
350
- type === "codex.interrupt"
351
- )
343
+ return type === "claude.interrupt" || type === "codex.interrupt"
352
344
  }
353
345
 
354
346
  /**
@@ -711,7 +703,7 @@ export async function handleBundleSyncRequest(msg, ctx, opts = {}) {
711
703
  }
712
704
  }
713
705
 
714
- export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
706
+ export async function startDaemon({ version, claudeSdk } = {}) {
715
707
  const config = await readConfig()
716
708
  if (!config) {
717
709
  throw new Error(
@@ -765,14 +757,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
765
757
  // 2026-07-15: Claude 固定から **検出された Claude/Codex 双方** の kick へ拡張。
766
758
  await kickSetupHubAiForDetectedClis({ logger })
767
759
 
768
- // node-pty (Microsoft 公式) は macOS arm64 で PTY master fd を spawn ごとに leak し
769
- // (kill/destroy でも解放されず)、kern.tty.ptmx_max=511 到達で「新規ターミナルが
770
- // 開けない」障害を起こした (2026-06-16)。node 20/22/24 全てで再現。API 互換の保守
771
- // フォーク @lydell/node-pty は同ケースで leak ゼロ + 全 OS prebuilt 同梱なので
772
- // 差し替えた。WHY 詳細: Hub log/2026-06-16/0041_cockpit。
773
- const resolvedPty = ptyModule || (await import("@lydell/node-pty"))
774
- const ptyBridge = new PtyBridge({ ptyModule: resolvedPty, logger, plugins })
775
-
776
760
  // ホストで起動可能な CLI (claude / codex) を 1 回検出する。hello で広告する用途に
777
761
  // 加え、codex app-server ベースの chat ブリッジ (codexBridge) を構築するかどうかの
778
762
  // gate にも使う (claudeBridge は npm optional dep の有無で判定するのに対し、codex は
@@ -866,13 +850,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
866
850
  logger.warn({ err: err.message }, "ws client error (will retry via close handler)")
867
851
  })
868
852
 
869
- ptyBridge.on("output", ({ stream_id, data }) => {
870
- client.send({ type: "pty.data", stream_id, data })
871
- })
872
- ptyBridge.on("exit", ({ stream_id, code }) => {
873
- client.send({ type: "pty.exit", stream_id, code })
874
- })
875
-
876
853
  // stream-json モード (Sprint G): Claude Code を SDK 経由で起動し、stream イベントを
877
854
  // そのまま browser に転送する。SDK 未インストール時は claudeBridge=null で全 attach が
878
855
  // claude.error を返す経路に分岐 (dispatch 側で判定)。
@@ -1141,16 +1118,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1141
1118
  //
1142
1119
  // `EventEmitter.emit` は async リスナーの完了を待たないため、`async (msg) =>
1143
1120
  // await dispatch(msg, ...)` の形だと複数 WS メッセージが連続して届いたとき
1144
- // それぞれの dispatch が並列実行される。`tmux.exec` の `await execTmux`
1145
- // (subprocess 待ち) 中に後続 `pty.data` の同期 `ptyBridge.write` が先行する
1146
- // と、ブラウザ→Hub では順序が保証されていても agent 内部で逆転する。
1147
- //
1148
- // 典型的な被害: cockpit ChatInput の `await onCancelTmuxMode()` → paste
1149
- // の流れで、ブラウザ側が cancel の応答を待ってから paste を送っても、
1150
- // 別経路の `tmux.exec` (state loop の polling 等) で agent が busy だと、
1151
- // cancel の execTmux が完了する前に paste が ptyBridge.write される。
1152
- // 結果として paste が tmux 内部状態 (copy mode 等) を抜ける前に届き、
1153
- // claude TUI に正しく入らない事象が散発する。
1121
+ // それぞれの dispatch が並列実行される。tmux セッション操作のような subprocess
1122
+ // 待ちの最中に後続の同期処理が先行すると、ブラウザ→Hub では順序が保証されて
1123
+ // いても agent 内部で逆転する。
1154
1124
  //
1155
1125
  // 旧来の global Promise chain は、ある cwd の tmux/codex 処理が別 cwd の
1156
1126
  // claude.attach と reconnect ready/sync まで 60〜203 秒止める HoL blocking を
@@ -1159,7 +1129,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1159
1129
  const dispatchCtx = {
1160
1130
  ...ctx,
1161
1131
  client,
1162
- ptyBridge,
1163
1132
  claudeBridge,
1164
1133
  codexBridge,
1165
1134
  codexMcpControl,
@@ -1168,7 +1137,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1168
1137
  const dispatchScheduler = new DispatchScheduler({
1169
1138
  logger,
1170
1139
  run: (msg) => dispatch(msg, dispatchCtx),
1171
- // pty fast-path / read-only RPC は従来どおり即時実行。接続 lifecycle は
1140
+ // fast-path (interrupt) / read-only RPC は従来どおり即時実行。接続 lifecycle は
1172
1141
  // scheduler 自身が常に priority 扱いする。
1173
1142
  shouldBypass: (type) => isFastPathMessage(type) || isReadOnlyRpc(type),
1174
1143
  })
@@ -1180,7 +1149,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1180
1149
 
1181
1150
  // 0.6.2 fix: event loop を ref されたまま生かしておく watchdog。
1182
1151
  // hub-agent の他の background task (heartbeat / reconnect / state loop / fs.watch)
1183
- // は全て .unref() / persistent:false なので、長時間アイドル + WS 切断 + pty 全 GC
1152
+ // は全て .unref() / persistent:false なので、長時間アイドル + WS 切断
1184
1153
  // が同時発生すると Node が「やることなし」と判断して exit(0) で silent 終了する
1185
1154
  // 既存バグがあった。watchdog を 1 つ ref のまま持つことで exit を防ぐ。
1186
1155
  // shutdown 時に clearInterval して正常終了を許可する。
@@ -1277,7 +1246,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1277
1246
  sessionEventLoop?.stop?.()
1278
1247
  readinessLoop?.stop?.()
1279
1248
  siblingInjectBridge.stop()
1280
- ptyBridge.shutdown()
1281
1249
  claudeBridge?.shutdown?.()
1282
1250
  codexBridge?.shutdown?.()
1283
1251
  client.stop()
@@ -1307,7 +1275,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1307
1275
  )
1308
1276
  })
1309
1277
 
1310
- return { client, plugins, ptyBridge, claudeBridge, codexBridge, codexMcpControl, uploadManager }
1278
+ return { client, plugins, claudeBridge, codexBridge, codexMcpControl, uploadManager }
1311
1279
  }
1312
1280
 
1313
1281
  const SESSION_EVENTS_DIR =
@@ -1961,8 +1929,8 @@ function startReadinessLoop({ tracker, logger, intervalMs = 700, onReady }) {
1961
1929
  * 全 tmux session の状態を定期 capture し、変化したものだけ Hub に push する。
1962
1930
  *
1963
1931
  * - 前回値との比較で status / context_pct のどちらかが変化したら送る
1964
- * - 新規 session は無条件 push、消えた session は無視 (browser 側で
1965
- * pty.exit 受信時に処理する)
1932
+ * - 新規 session は無条件 push、消えた session は無視 (browser 側は
1933
+ * セッション一覧の更新で消滅を検知する)
1966
1934
  * - tmux 自体が動いてない場合 (listSessionStates → []) は何も push しない
1967
1935
  */
1968
1936
  // (i) 出力フロー検知の有効窓。ペイン出力領域の署名 (output_sig) が直近この時間内に変化して
@@ -2664,12 +2632,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2664
2632
  }
2665
2633
  }
2666
2634
 
2667
- /**
2668
- * Hub からのメッセージを type で分岐する。
2669
- *
2670
- * `pty.*` は PtyBridge にそのまま流す。`tmux.exec` は Sprint E で tmux 抽象に
2671
- * 渡す予定なので、現状は plugin hook chain だけ走らせて log に残す。
2672
- */
2673
2635
  /**
2674
2636
  * Hub から `ready` / `ack` で配信された Claude/Codex CLI 設定を agent.json と
2675
2637
  * in-memory config に反映する (Phase 3 live-sync)。
@@ -2744,12 +2706,11 @@ const STREAMS_SYNC_GRACE_MS = 45_000
2744
2706
  * Plan ε: `agent.streams.sync.response` を受けて backend ↔ agent の現存
2745
2707
  * stream_id を能動同期する。
2746
2708
  *
2747
- * - PTY: local にあって backend にない → ptyBridge.detach、逆差分 → pty.error
2748
2709
  * - Claude/Codex: mode 別 snapshot から外れた古い subscriber を grace 付きで掃除
2749
2710
  *
2750
- * active_stream_ids だけを全件 PTY とみなすと、正常な Claude/Codex に
2751
- * pty.error(stream_not_found_on_agent) を返して再 attach storm を誘発するため、
2752
- * 新 backend の active_streams を優先する。
2711
+ * mode 別 snapshot (active_streams) が active_stream_ids と完全に一致するときだけ
2712
+ * authoritative とみなす。Blue-Green ローリング中の mode 無し応答や未知 mode が
2713
+ * 混ざった場合は掃除を一切行わない (誤って現役 subscriber を切らないための fail-safe)。
2753
2714
  *
2754
2715
  * dispatch から呼ぶ用のヘルパーだが、テストから直接叩けるよう export している。
2755
2716
  */
@@ -2757,7 +2718,7 @@ export function handleStreamsSyncResponse(msg, ctx) {
2757
2718
  const activeIds = new Set(
2758
2719
  Array.isArray(msg?.active_stream_ids) ? msg.active_stream_ids : [],
2759
2720
  )
2760
- const activeByMode = { pty: new Set(), claude: new Set(), codex: new Set() }
2721
+ const activeByMode = { claude: new Set(), codex: new Set() }
2761
2722
  const classifiedIds = new Set()
2762
2723
  if (Array.isArray(msg?.active_streams)) {
2763
2724
  for (const item of msg.active_streams) {
@@ -2770,62 +2731,11 @@ export function handleStreamsSyncResponse(msg, ctx) {
2770
2731
  }
2771
2732
  }
2772
2733
  // Blue-Green ローリング中に旧 BrowserConsumer の mode 無し応答が混ざった場合は
2773
- // typed snapshot を authoritative とみなさない。既知 chat stream だけ除外して
2774
- // legacy 互換を維持し、正常な Claude/Codex に ghost PTY error を返さない。
2734
+ // typed snapshot を authoritative とみなさず、掃除を一切行わない (legacy 互換)。
2775
2735
  const haveCompleteModeSnapshot =
2776
2736
  Array.isArray(msg?.active_streams)
2777
2737
  && classifiedIds.size === activeIds.size
2778
2738
  && Array.from(classifiedIds).every((id) => activeIds.has(id))
2779
- const knownChatIds = new Set([
2780
- ...Array.from(ctx.claudeBridge?.sessions?.keys?.() ?? []),
2781
- ...Array.from(ctx.codexBridge?.sessions?.keys?.() ?? []),
2782
- ])
2783
- const activePtyIds = haveCompleteModeSnapshot
2784
- ? activeByMode.pty
2785
- : new Set(Array.from(activeIds).filter((id) => !knownChatIds.has(id)))
2786
- const localIds = new Set(ctx.ptyBridge.list())
2787
- // churn 退化スナップショット保護 (ack_lost 根治 / 2026-06-17 根因 rank3)。
2788
- // backend は browser 群へ _STREAMS_SYNC_TIMEOUT_S(0.5s) だけ問い合わせて active set を
2789
- // 集める。再接続バースト中 (本番 ~20s 間隔・code 1006) は browser がまだ再 attach できて
2790
- // おらず空/部分的な set が返り、その一瞬を authoritative とみなして「ローカル全 stream が
2791
- // 孤児」と誤判定し、生きている PTY を皆殺しにしていた (再 attach 直後の stream を 1 秒後に
2792
- // 殺し、以後の入力が stream_missing → 数分単位の「送信待ち」固着。0.6.89 ログ最多 churn 源)。
2793
- // 空 set は信用せず kill を一切行わない (真の孤児は 10min GC + maxStreams 上限が回収する)。
2794
- const haveAuthoritativeActiveSet = activeIds.size > 0
2795
- for (const sid of localIds) {
2796
- if (activePtyIds.has(sid)) continue
2797
- if (!haveAuthoritativeActiveSet) continue
2798
- // grace guard: 直近 STREAMS_SYNC_GRACE_MS 以内に touch (attach/write/resize/pty 出力) された
2799
- // stream は「browser が再接続中で 0.5s 集計に間に合わなかっただけ」の可能性が高い。猶予内は
2800
- // 殺さず延命する。真に放置された孤児は猶予超過で従来どおり detach され、10min GC も backstop。
2801
- const lastSeen = ctx.ptyBridge.getLastSeen?.(sid) ?? 0
2802
- const idleMs = Date.now() - lastSeen
2803
- if (lastSeen > 0 && idleMs < STREAMS_SYNC_GRACE_MS) {
2804
- ctx.logger?.info?.(
2805
- { stream_id: sid, idle_ms: idleMs, request_id: msg?.request_id },
2806
- "Plan ε: skip detach (within grace, browser likely re-attaching)",
2807
- )
2808
- continue
2809
- }
2810
- ctx.logger?.warn?.(
2811
- { stream_id: sid, request_id: msg?.request_id },
2812
- "Plan ε: orphan stream detected, detaching",
2813
- )
2814
- ctx.ptyBridge.detach({ stream_id: sid })
2815
- }
2816
- for (const sid of activePtyIds) {
2817
- if (!localIds.has(sid)) {
2818
- ctx.logger?.warn?.(
2819
- { stream_id: sid, request_id: msg?.request_id },
2820
- "Plan ε: stream missing on agent, notifying backend (pty.error)",
2821
- )
2822
- ctx.client.send({
2823
- type: "pty.error",
2824
- stream_id: sid,
2825
- error: "stream_not_found_on_agent",
2826
- })
2827
- }
2828
- }
2829
2739
 
2830
2740
  if (haveCompleteModeSnapshot) {
2831
2741
  // reconnect 集計は browser ごとの復帰順で一時的に部分集合になり得る。
@@ -2873,34 +2783,16 @@ export function pruneInactiveChatSubscribers(bridge, activeIds, mode, logger, no
2873
2783
  return removed
2874
2784
  }
2875
2785
 
2876
- /**
2877
- * end-to-end 配達確認 (tracked pty.data) の冪等化レジストリ。
2878
- *
2879
- * browser はチャット送信 (paste / 確定 CR) に input_id を付け、agent からの
2880
- * pty.input.ack が来ない限り再送する (zombie WS / 切断窓 / backend group_send の
2881
- * 無言喪失への対策)。「実際は届いていたが ack 側が消えた」再送で同じ本文が
2882
- * 二重 paste されないよう、処理済み input_id を記憶して 2 回目以降は write せず
2883
- * ack だけ返す。Set は挿入順 iterate できるので最古から間引く。
2884
- */
2885
- const SEEN_INPUT_IDS_MAX = 1_000
2886
- const seenInputIds = new Set()
2887
- function rememberInputId(input_id) {
2888
- seenInputIds.add(input_id)
2889
- while (seenInputIds.size > SEEN_INPUT_IDS_MAX) {
2890
- seenInputIds.delete(seenInputIds.values().next().value)
2891
- }
2892
- }
2893
-
2894
2786
  /**
2895
2787
  * SDK チャット配達保証 (S2, 2026-07-05) 用の冪等化レジストリ。
2896
2788
  *
2897
2789
  * `claude.input` / `codex.input` に browser が付与する `input_id` を記憶し、ack
2898
2790
  * ウォッチドッグ再送 (browser 側が 5s 以内に ack/nack を受け取れず再送するケース) で
2899
- * 同じメッセージが 2 回ターン実行されないようにする。tracked pty.data
2900
- * (`seenInputIds` 上) と同じ設計だが、チャンネル別に Set を分ける
2901
- * (input_id の採番元は browser 側で共通の可能性があるが、pty と chat で
2902
- * 意味論が異なる衝突を避けるため)。
2791
+ * 同じメッセージが 2 回ターン実行されないようにする。Set は挿入順 iterate できるので
2792
+ * 最古から間引く。claude / codex でチャンネル別に Set を分ける (input_id の採番元は
2793
+ * browser 側で共通の可能性があるため、意味論が異なる衝突を避ける)。
2903
2794
  */
2795
+ const SEEN_INPUT_IDS_MAX = 1_000
2904
2796
  function makeSeenInputIdRegistry(max = SEEN_INPUT_IDS_MAX) {
2905
2797
  const seen = new Set()
2906
2798
  return {
@@ -2930,49 +2822,6 @@ const codexInflightByInputId = new Map()
2930
2822
  // 同一 input_id がキューに 2 件積まれて drain 後も 1 件残る (送信待ちチップが消えない)。
2931
2823
  const claudeInflightByInputId = new Map()
2932
2824
 
2933
- /**
2934
- * input_id 付き pty.data (tracked 入力) の処理。write 成功で pty.input.ack を返し、
2935
- * stream 不在なら pty.error を返して browser の再 attach → 再送に繋げる。
2936
- * テストから直接呼べるよう dispatch から切り出して export する。
2937
- */
2938
- export function handleTrackedPtyData(msg, ctx) {
2939
- const { stream_id, input_id } = msg
2940
- if (seenInputIds.has(input_id)) {
2941
- // 再送 (ack 喪失 or 再接続 flush)。書き込み済みなので ack だけ返す。
2942
- ctx.logger?.info({ stream_id, input_id }, "tracked pty.data dedup (ack only)")
2943
- ctx.client.send({ type: "pty.input.ack", stream_id, input_id })
2944
- return
2945
- }
2946
- const ok = ctx.ptyBridge.write({ stream_id, data: msg.data })
2947
- if (ok) {
2948
- rememberInputId(input_id)
2949
- ctx.client.send({ type: "pty.input.ack", stream_id, input_id })
2950
- } else {
2951
- // stream 不在 (reap 済み等)。browser は pty.error で再 attach し、未 ack の
2952
- // tracked 入力は ack ウォッチドッグが再送する。
2953
- ctx.client.send({
2954
- type: "pty.error",
2955
- stream_id,
2956
- error: "stream_missing",
2957
- input_id,
2958
- })
2959
- }
2960
- }
2961
-
2962
- /**
2963
- * 無印 (input_id 無し / fire-and-forget) pty.data の処理。raw 打鍵 (term.onData) が
2964
- * これを通る。stream 不在 (reap 済み等) なら pty.error を返して browser を再 attach →
2965
- * 自己修復させる (T-6)。従来は write の戻り値を見ずに捨てていたため、reap 済み stream
2966
- * への打鍵が無言ドロップされていた。tracked 経路 (handleTrackedPtyData) と対称。
2967
- * テストから直接呼べるよう dispatch から切り出して export する。
2968
- */
2969
- export function handleUntrackedPtyData(msg, ctx) {
2970
- const { stream_id } = msg
2971
- if (!ctx.ptyBridge.write({ stream_id, data: msg.data })) {
2972
- ctx.client.send({ type: "pty.error", stream_id, error: "stream_missing" })
2973
- }
2974
- }
2975
-
2976
2825
  /**
2977
2826
  * SDK チャット配達保証 (S2, 2026-07-05): `claude.input` を処理し、browser が付与した
2978
2827
  * `input_id` があれば `claude.input.ack` / `claude.input.nack` を返す。無ければ従来
@@ -2980,8 +2829,7 @@ export function handleUntrackedPtyData(msg, ctx) {
2980
2829
  *
2981
2830
  * `ClaudeStreamBridge.input()` の戻り値 `{ ok, reason? }` は「ターン完走」ではなく
2982
2831
  * 「キュー/常駐 query への投入が完了したか」を表す (ack を素早く返すため)。
2983
- * テストから直接呼べるよう dispatch から切り出して export する
2984
- * (handleTrackedPtyData と同じ設計)。
2832
+ * テストから直接呼べるよう dispatch から切り出して export する。
2985
2833
  */
2986
2834
  export function handleClaudeChatInput(msg, ctx) {
2987
2835
  const stream_id = msg.stream_id
@@ -2999,7 +2847,7 @@ export function handleClaudeChatInput(msg, ctx) {
2999
2847
  }
3000
2848
  if (input_id && seenClaudeChatInputIds.has(input_id)) {
3001
2849
  // 再送 (ack 喪失想定)。同じターンを 2 回実行しないよう sendMessage を
3002
- // 呼び直さず ack のみ返す (tracked pty.data の dedup と同じ考え方)。
2850
+ // 呼び直さず ack のみ返す。
3003
2851
  ctx.logger?.info?.({ stream_id, input_id }, "claude.input dedup (ack only)")
3004
2852
  ctx.client.send({ type: "claude.input.ack", stream_id, input_id })
3005
2853
  return undefined
@@ -3065,6 +2913,62 @@ export function handleClaudeChatInput(msg, ctx) {
3065
2913
  return p
3066
2914
  }
3067
2915
 
2916
+ /**
2917
+ * `tmux.exec` を処理し、`tmux.result` を返す。
2918
+ *
2919
+ * plugin の `interceptTmuxExec` hook が `{ command, args, env }` を返したときは、その
2920
+ * command を代わりに spawn する (例: remote-ops プラグインで ssh 越しに実行)。hook が
2921
+ * 何も返さなければ既定のローカル `execTmux` へ落ちる。どちらの経路でも
2922
+ * `request_id` / `stdout` / `stderr` / `exit_code` / `plugin` の 5 フィールドを揃えて返す。
2923
+ *
2924
+ * dispatch から呼ぶ用のヘルパーだが、テストから直接叩けるよう export している
2925
+ * (handleStreamsSyncResponse / handleClaudeChatInput と同じ扱い)。
2926
+ *
2927
+ * @param {{request_id?:string, args?:string[]}} msg
2928
+ * @param {{plugins:any[], logger:object, client:{send:Function}}} ctx
2929
+ * @param {{execTmux?:Function}} [deps] 既定実装の差し替え口 (テスト専用)。
2930
+ */
2931
+ export async function handleTmuxExec(msg, ctx, deps = {}) {
2932
+ const execTmuxImpl = deps.execTmux || execTmux
2933
+ const args = Array.isArray(msg.args) ? msg.args : []
2934
+ const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
2935
+ logger: ctx.logger,
2936
+ args,
2937
+ env: process.env,
2938
+ })
2939
+ let result
2940
+ if (hookResult?.result?.command) {
2941
+ // plugin が独自 command で差し替え (例: remote-ops プラグインで ssh 越し)
2942
+ const { command, args: spawnArgs, env } = hookResult.result
2943
+ try {
2944
+ const { execFile } = await import("node:child_process")
2945
+ const { promisify } = await import("node:util")
2946
+ const execFileP = promisify(execFile)
2947
+ const { stdout, stderr } = await execFileP(command, spawnArgs, {
2948
+ env: env || process.env,
2949
+ maxBuffer: 4 * 1024 * 1024,
2950
+ })
2951
+ result = { stdout, stderr, exit_code: 0 }
2952
+ } catch (err) {
2953
+ result = {
2954
+ stdout: err.stdout || "",
2955
+ stderr: err.stderr || err.message || "",
2956
+ exit_code: typeof err.code === "number" ? err.code : 1,
2957
+ }
2958
+ }
2959
+ } else {
2960
+ result = await execTmuxImpl(args)
2961
+ }
2962
+ ctx.client.send({
2963
+ type: "tmux.result",
2964
+ request_id: msg.request_id,
2965
+ stdout: result.stdout,
2966
+ stderr: result.stderr,
2967
+ exit_code: result.exit_code,
2968
+ plugin: hookResult?.plugin || null,
2969
+ })
2970
+ }
2971
+
3068
2972
  /**
3069
2973
  * claude.attach の ready 直後にpushする履歴を組み立てる。
3070
2974
  * Browserからの claude.history.request 往復を省くため、resume時だけ先回りして読む。
@@ -3739,74 +3643,6 @@ async function dispatch(msg, ctx) {
3739
3643
  await ctx.requestShutdown?.("agent.shutdown")
3740
3644
  return
3741
3645
  }
3742
- case "pty.attach": {
3743
- const stream_id = msg.stream_id
3744
- try {
3745
- const info = await ctx.ptyBridge.attach({
3746
- stream_id,
3747
- sessionName: msg.session_name || "",
3748
- cols: msg.cols,
3749
- rows: msg.rows,
3750
- })
3751
- // 接続端末の種別に応じて tmux mouse mode を出し分ける (スマホ=on /
3752
- // デスクトップ=off)。boolean のときだけ上書きし、未指定 (旧フロント) は
3753
- // createSession の既定値 (on) を尊重する。fire-and-forget で pty.ready を
3754
- // 妨げない (失敗は setSessionMouse 内で warn 済み)。
3755
- if (typeof msg.mouse === "boolean" && msg.session_name) {
3756
- setSessionMouse(msg.session_name, msg.mouse, { logger: ctx.logger })
3757
- }
3758
- ctx.client.send({
3759
- type: "pty.ready",
3760
- stream_id,
3761
- session_name: msg.session_name || "",
3762
- plugin: info.plugin,
3763
- // end-to-end 配達確認 (input_id 付き pty.data → pty.input.ack) に対応
3764
- // していることを browser へ通知する capability フラグ。旧 agent には
3765
- // このフラグが無く、browser 側は tracked 送信を自動で無効化する。
3766
- supports_input_ack: true,
3767
- })
3768
- } catch (err) {
3769
- ctx.client.send({
3770
- type: "pty.exit",
3771
- stream_id,
3772
- code: -1,
3773
- error: err.message,
3774
- })
3775
- }
3776
- return
3777
- }
3778
- case "pty.data":
3779
- // input_id 付き = tracked 入力 (チャット送信)。冪等化 + ack 返却で
3780
- // end-to-end 配達確認に応える。無印は従来どおり fire-and-forget。
3781
- if (typeof msg.input_id === "string" && msg.input_id) {
3782
- handleTrackedPtyData(msg, ctx)
3783
- return
3784
- }
3785
- handleUntrackedPtyData(msg, ctx)
3786
- return
3787
- case "pty.resize": {
3788
- const resized = ctx.ptyBridge.resize({
3789
- stream_id: msg.stream_id,
3790
- cols: msg.cols,
3791
- rows: msg.rows,
3792
- })
3793
- // 自己修復 (perf監査 Phase3): stream 不在で resize できなかった (reap 済み等) ときは
3794
- // pty.error(stream_missing) を返し browser を再 attach させる。keepalive resize が dead
3795
- // stream を即検知して 4 分単位の「pty.resize but stream missing」churn を自己修復する
3796
- // (handleTrackedPtyData/handleUntrackedPtyData と同じパターン)。resize 例外 (pty 存在) も
3797
- // false だが、その場合の再 attach は既存 stream への無害な再接続。
3798
- if (!resized && msg.stream_id) {
3799
- ctx.client.send({
3800
- type: "pty.error",
3801
- stream_id: msg.stream_id,
3802
- error: "stream_missing",
3803
- })
3804
- }
3805
- return
3806
- }
3807
- case "pty.detach":
3808
- ctx.ptyBridge.detach({ stream_id: msg.stream_id })
3809
- return
3810
3646
  case "agent.streams.sync.response":
3811
3647
  // Plan ε: backend ↔ agent の現存 stream_id 能動同期 (WS reconnect 直後に
3812
3648
  // ws-client が agent.streams.sync.request を送って取得した結果)。
@@ -3815,13 +3651,13 @@ async function dispatch(msg, ctx) {
3815
3651
  // 5s タイムアウトのような明示的な timer は持たず、再接続時に再度 request
3816
3652
  // が飛ぶので無害。
3817
3653
  handleStreamsSyncResponse(msg, ctx)
3818
- // buffered pty/control event は browser の購読集合が確定してから再送する。
3654
+ // buffered control event は browser の購読集合が確定してから再送する。
3819
3655
  // request_id は WsClient 側で現 reconnect 世代と照合され、古い応答は無視される。
3820
3656
  ctx.client.completeStreamsSync?.(msg)
3821
3657
  return
3822
3658
  case "claude.attach": {
3823
3659
  // Sprint G: stream-json モードで Claude Code を起動。
3824
- // browser 採番の stream_id をそのまま使う (pty.attach と同じ流儀)。
3660
+ // stream_id は browser 採番のものをそのまま使う。
3825
3661
  const stream_id = msg.stream_id
3826
3662
  if (!ctx.claudeBridge) {
3827
3663
  // SDK 未インストール時はその旨を即時 error で返す。browser 側は
@@ -4480,8 +4316,8 @@ async function dispatch(msg, ctx) {
4480
4316
  ctx.codexBridge.detach({ stream_id: msg.stream_id })
4481
4317
  return
4482
4318
  case "codex.keepalive":
4483
- // ブラウザが Codex チャットを開いている間の生存シグナル (CockpitTerminal.tsx の
4484
- // PTY keepalive と同じ構造)。閲覧中に gcAbandonedSessions (既定 1h 無通信) が
4319
+ // ブラウザが Codex チャットを開いている間の生存シグナル。
4320
+ // 閲覧中に gcAbandonedSessions (既定 1h 無通信) が
4485
4321
  // 誤爆するのを防ぐ。未知 stream_id は false が返るだけ (無害な no-op)。
4486
4322
  if (!ctx.codexBridge) return
4487
4323
  ctx.codexBridge.keepalive({ stream_id: msg.stream_id })
@@ -4566,6 +4402,18 @@ async function dispatch(msg, ctx) {
4566
4402
  projectsRoot,
4567
4403
  logger: ctx.logger,
4568
4404
  })
4405
+ // ⚠️ **観測窓を必ずどこかから読む**。タスク終端通知の封筒検査は、Claude CLI が
4406
+ // ラッパー・タグ順・空白を変えると正当な通知まで落とし、**幽霊カードが静かに戻る**。
4407
+ // 誰も読まないカウンタは「対策した気になるだけの飾り」になる (バンドル自己更新の
4408
+ // 通知で発火率 0 の検査を残していた前例あり)。件数だけを出す (中身は伏せ字を
4409
+ // 素通りするので絶対に出さない)。
4410
+ const envelopeDrops = drainTaskNotificationEnvelopeDrops()
4411
+ if (envelopeDrops > 0) {
4412
+ ctx.logger?.info?.(
4413
+ { drops: envelopeDrops },
4414
+ "history: task-notification の封筒検査で合成しなかった行があります (CLI の形式変更なら通知の復元が効かなくなります)",
4415
+ )
4416
+ }
4569
4417
  ctx.client.send({
4570
4418
  type: "claude.history.response",
4571
4419
  stream_id,
@@ -4904,43 +4752,8 @@ async function dispatch(msg, ctx) {
4904
4752
  return
4905
4753
  }
4906
4754
  case "tmux.exec": {
4907
- const args = Array.isArray(msg.args) ? msg.args : []
4908
- const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
4909
- logger: ctx.logger,
4910
- args,
4911
- env: process.env,
4912
- })
4913
- let result
4914
- if (hookResult?.result?.command) {
4915
- // plugin が独自 command で差し替え (例: remote-ops プラグインで ssh 越し)
4916
- const { command, args: spawnArgs, env } = hookResult.result
4917
- try {
4918
- const { execFile } = await import("node:child_process")
4919
- const { promisify } = await import("node:util")
4920
- const execFileP = promisify(execFile)
4921
- const { stdout, stderr } = await execFileP(command, spawnArgs, {
4922
- env: env || process.env,
4923
- maxBuffer: 4 * 1024 * 1024,
4924
- })
4925
- result = { stdout, stderr, exit_code: 0 }
4926
- } catch (err) {
4927
- result = {
4928
- stdout: err.stdout || "",
4929
- stderr: err.stderr || err.message || "",
4930
- exit_code: typeof err.code === "number" ? err.code : 1,
4931
- }
4932
- }
4933
- } else {
4934
- result = await execTmux(args)
4935
- }
4936
- ctx.client.send({
4937
- type: "tmux.result",
4938
- request_id: msg.request_id,
4939
- stdout: result.stdout,
4940
- stderr: result.stderr,
4941
- exit_code: result.exit_code,
4942
- plugin: hookResult?.plugin || null,
4943
- })
4755
+ // ロジックは handleTmuxExec へ抽出 (テストから直接呼べる)。
4756
+ await handleTmuxExec(msg, ctx)
4944
4757
  return
4945
4758
  }
4946
4759
  case "tmux.list_sessions": {