@cocorograph/hub-agent 0.8.24 → 0.8.25

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"
@@ -102,7 +100,6 @@ import {
102
100
  listWorktreeStubs,
103
101
  paneClaudeAliveOrUnknown,
104
102
  removeWorktree as removeWorktreeDir,
105
- setSessionMouse,
106
103
  setTmuxGlobalEnv,
107
104
  } from "./tmux.mjs"
108
105
  import { SiblingInjectBridge } from "./sibling-inject.mjs"
@@ -279,7 +276,7 @@ function readBundleVersionSync() {
279
276
  /**
280
277
  * `@anthropic-ai/claude-agent-sdk` を lazy import する。テストでは sdk arg で差し替え。
281
278
  *
282
- * SDK 未インストール (旧 hub-agent インストール) 環境でも pty モードは動くべきなので、
279
+ * SDK 未インストール (旧 hub-agent インストール) 環境でも agent 自体は起動すべきなので、
283
280
  * import 失敗を warn に留めて null を返す。stream モードを使う attach が来たら
284
281
  * その時点で error を browser に返す設計。
285
282
  */
@@ -333,22 +330,16 @@ export function withDeadline(promise, timeoutMs, onTimeout) {
333
330
  }
334
331
 
335
332
  /**
336
- * B7: 直列 dispatchChain をバイパスして即時処理してよい高頻度・低レイテンシ経路かを判定する。
337
- * - pty 出力データ (pty.data) と resize (pty.resize): 高頻度・順序保証不要。
333
+ * B7: 直列 dispatchChain をバイパスして即時処理してよい低レイテンシ経路かを判定する。
338
334
  * - claude.interrupt / codex.interrupt (2026-07-02): 停止指示。前段に重い dispatch
339
- * (tmux.exec 等) が滞留していると停止が遅延するため直列キューをバイパスする。
340
- * 中断は「現ターンを止める」冪等な制御で、入力系のような順序依存が無い (むしろ
341
- * キュー内の後続入力より先に届くべき)。
342
- * 入力系 (claude.input)・他の制御系 (tmux.exec / permission / cancel→paste 等) は
335
+ * (tmux.exec / tmux セッション操作等) が滞留していると停止が遅延するため直列キューを
336
+ * バイパスする。中断は「現ターンを止める」冪等な制御で、入力系のような順序依存が無い
337
+ * (むしろキュー内の後続入力より先に届くべき)。
338
+ * 入力系 (claude.input)・他の制御系 (permission / tmux.exec / tmux セッション操作等) は
343
339
  * WS 受信順 = pane 反映順を守るため false (= 直列キューに残す)。
344
340
  */
345
341
  export function isFastPathMessage(type) {
346
- return (
347
- type === "pty.data" ||
348
- type === "pty.resize" ||
349
- type === "claude.interrupt" ||
350
- type === "codex.interrupt"
351
- )
342
+ return type === "claude.interrupt" || type === "codex.interrupt"
352
343
  }
353
344
 
354
345
  /**
@@ -711,7 +702,7 @@ export async function handleBundleSyncRequest(msg, ctx, opts = {}) {
711
702
  }
712
703
  }
713
704
 
714
- export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
705
+ export async function startDaemon({ version, claudeSdk } = {}) {
715
706
  const config = await readConfig()
716
707
  if (!config) {
717
708
  throw new Error(
@@ -765,14 +756,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
765
756
  // 2026-07-15: Claude 固定から **検出された Claude/Codex 双方** の kick へ拡張。
766
757
  await kickSetupHubAiForDetectedClis({ logger })
767
758
 
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
759
  // ホストで起動可能な CLI (claude / codex) を 1 回検出する。hello で広告する用途に
777
760
  // 加え、codex app-server ベースの chat ブリッジ (codexBridge) を構築するかどうかの
778
761
  // gate にも使う (claudeBridge は npm optional dep の有無で判定するのに対し、codex は
@@ -866,13 +849,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
866
849
  logger.warn({ err: err.message }, "ws client error (will retry via close handler)")
867
850
  })
868
851
 
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
852
  // stream-json モード (Sprint G): Claude Code を SDK 経由で起動し、stream イベントを
877
853
  // そのまま browser に転送する。SDK 未インストール時は claudeBridge=null で全 attach が
878
854
  // claude.error を返す経路に分岐 (dispatch 側で判定)。
@@ -1141,16 +1117,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1141
1117
  //
1142
1118
  // `EventEmitter.emit` は async リスナーの完了を待たないため、`async (msg) =>
1143
1119
  // 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 に正しく入らない事象が散発する。
1120
+ // それぞれの dispatch が並列実行される。tmux セッション操作のような subprocess
1121
+ // 待ちの最中に後続の同期処理が先行すると、ブラウザ→Hub では順序が保証されて
1122
+ // いても agent 内部で逆転する。
1154
1123
  //
1155
1124
  // 旧来の global Promise chain は、ある cwd の tmux/codex 処理が別 cwd の
1156
1125
  // claude.attach と reconnect ready/sync まで 60〜203 秒止める HoL blocking を
@@ -1159,7 +1128,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1159
1128
  const dispatchCtx = {
1160
1129
  ...ctx,
1161
1130
  client,
1162
- ptyBridge,
1163
1131
  claudeBridge,
1164
1132
  codexBridge,
1165
1133
  codexMcpControl,
@@ -1168,7 +1136,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1168
1136
  const dispatchScheduler = new DispatchScheduler({
1169
1137
  logger,
1170
1138
  run: (msg) => dispatch(msg, dispatchCtx),
1171
- // pty fast-path / read-only RPC は従来どおり即時実行。接続 lifecycle は
1139
+ // fast-path (interrupt) / read-only RPC は従来どおり即時実行。接続 lifecycle は
1172
1140
  // scheduler 自身が常に priority 扱いする。
1173
1141
  shouldBypass: (type) => isFastPathMessage(type) || isReadOnlyRpc(type),
1174
1142
  })
@@ -1180,7 +1148,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1180
1148
 
1181
1149
  // 0.6.2 fix: event loop を ref されたまま生かしておく watchdog。
1182
1150
  // hub-agent の他の background task (heartbeat / reconnect / state loop / fs.watch)
1183
- // は全て .unref() / persistent:false なので、長時間アイドル + WS 切断 + pty 全 GC
1151
+ // は全て .unref() / persistent:false なので、長時間アイドル + WS 切断
1184
1152
  // が同時発生すると Node が「やることなし」と判断して exit(0) で silent 終了する
1185
1153
  // 既存バグがあった。watchdog を 1 つ ref のまま持つことで exit を防ぐ。
1186
1154
  // shutdown 時に clearInterval して正常終了を許可する。
@@ -1277,7 +1245,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1277
1245
  sessionEventLoop?.stop?.()
1278
1246
  readinessLoop?.stop?.()
1279
1247
  siblingInjectBridge.stop()
1280
- ptyBridge.shutdown()
1281
1248
  claudeBridge?.shutdown?.()
1282
1249
  codexBridge?.shutdown?.()
1283
1250
  client.stop()
@@ -1307,7 +1274,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1307
1274
  )
1308
1275
  })
1309
1276
 
1310
- return { client, plugins, ptyBridge, claudeBridge, codexBridge, codexMcpControl, uploadManager }
1277
+ return { client, plugins, claudeBridge, codexBridge, codexMcpControl, uploadManager }
1311
1278
  }
1312
1279
 
1313
1280
  const SESSION_EVENTS_DIR =
@@ -1961,8 +1928,8 @@ function startReadinessLoop({ tracker, logger, intervalMs = 700, onReady }) {
1961
1928
  * 全 tmux session の状態を定期 capture し、変化したものだけ Hub に push する。
1962
1929
  *
1963
1930
  * - 前回値との比較で status / context_pct のどちらかが変化したら送る
1964
- * - 新規 session は無条件 push、消えた session は無視 (browser 側で
1965
- * pty.exit 受信時に処理する)
1931
+ * - 新規 session は無条件 push、消えた session は無視 (browser 側は
1932
+ * セッション一覧の更新で消滅を検知する)
1966
1933
  * - tmux 自体が動いてない場合 (listSessionStates → []) は何も push しない
1967
1934
  */
1968
1935
  // (i) 出力フロー検知の有効窓。ペイン出力領域の署名 (output_sig) が直近この時間内に変化して
@@ -2664,12 +2631,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2664
2631
  }
2665
2632
  }
2666
2633
 
2667
- /**
2668
- * Hub からのメッセージを type で分岐する。
2669
- *
2670
- * `pty.*` は PtyBridge にそのまま流す。`tmux.exec` は Sprint E で tmux 抽象に
2671
- * 渡す予定なので、現状は plugin hook chain だけ走らせて log に残す。
2672
- */
2673
2634
  /**
2674
2635
  * Hub から `ready` / `ack` で配信された Claude/Codex CLI 設定を agent.json と
2675
2636
  * in-memory config に反映する (Phase 3 live-sync)。
@@ -2744,12 +2705,11 @@ const STREAMS_SYNC_GRACE_MS = 45_000
2744
2705
  * Plan ε: `agent.streams.sync.response` を受けて backend ↔ agent の現存
2745
2706
  * stream_id を能動同期する。
2746
2707
  *
2747
- * - PTY: local にあって backend にない → ptyBridge.detach、逆差分 → pty.error
2748
2708
  * - Claude/Codex: mode 別 snapshot から外れた古い subscriber を grace 付きで掃除
2749
2709
  *
2750
- * active_stream_ids だけを全件 PTY とみなすと、正常な Claude/Codex に
2751
- * pty.error(stream_not_found_on_agent) を返して再 attach storm を誘発するため、
2752
- * 新 backend の active_streams を優先する。
2710
+ * mode 別 snapshot (active_streams) が active_stream_ids と完全に一致するときだけ
2711
+ * authoritative とみなす。Blue-Green ローリング中の mode 無し応答や未知 mode が
2712
+ * 混ざった場合は掃除を一切行わない (誤って現役 subscriber を切らないための fail-safe)。
2753
2713
  *
2754
2714
  * dispatch から呼ぶ用のヘルパーだが、テストから直接叩けるよう export している。
2755
2715
  */
@@ -2757,7 +2717,7 @@ export function handleStreamsSyncResponse(msg, ctx) {
2757
2717
  const activeIds = new Set(
2758
2718
  Array.isArray(msg?.active_stream_ids) ? msg.active_stream_ids : [],
2759
2719
  )
2760
- const activeByMode = { pty: new Set(), claude: new Set(), codex: new Set() }
2720
+ const activeByMode = { claude: new Set(), codex: new Set() }
2761
2721
  const classifiedIds = new Set()
2762
2722
  if (Array.isArray(msg?.active_streams)) {
2763
2723
  for (const item of msg.active_streams) {
@@ -2770,62 +2730,11 @@ export function handleStreamsSyncResponse(msg, ctx) {
2770
2730
  }
2771
2731
  }
2772
2732
  // Blue-Green ローリング中に旧 BrowserConsumer の mode 無し応答が混ざった場合は
2773
- // typed snapshot を authoritative とみなさない。既知 chat stream だけ除外して
2774
- // legacy 互換を維持し、正常な Claude/Codex に ghost PTY error を返さない。
2733
+ // typed snapshot を authoritative とみなさず、掃除を一切行わない (legacy 互換)。
2775
2734
  const haveCompleteModeSnapshot =
2776
2735
  Array.isArray(msg?.active_streams)
2777
2736
  && classifiedIds.size === activeIds.size
2778
2737
  && 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
2738
 
2830
2739
  if (haveCompleteModeSnapshot) {
2831
2740
  // reconnect 集計は browser ごとの復帰順で一時的に部分集合になり得る。
@@ -2873,34 +2782,16 @@ export function pruneInactiveChatSubscribers(bridge, activeIds, mode, logger, no
2873
2782
  return removed
2874
2783
  }
2875
2784
 
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
2785
  /**
2895
2786
  * SDK チャット配達保証 (S2, 2026-07-05) 用の冪等化レジストリ。
2896
2787
  *
2897
2788
  * `claude.input` / `codex.input` に browser が付与する `input_id` を記憶し、ack
2898
2789
  * ウォッチドッグ再送 (browser 側が 5s 以内に ack/nack を受け取れず再送するケース) で
2899
- * 同じメッセージが 2 回ターン実行されないようにする。tracked pty.data
2900
- * (`seenInputIds` 上) と同じ設計だが、チャンネル別に Set を分ける
2901
- * (input_id の採番元は browser 側で共通の可能性があるが、pty と chat で
2902
- * 意味論が異なる衝突を避けるため)。
2790
+ * 同じメッセージが 2 回ターン実行されないようにする。Set は挿入順 iterate できるので
2791
+ * 最古から間引く。claude / codex でチャンネル別に Set を分ける (input_id の採番元は
2792
+ * browser 側で共通の可能性があるため、意味論が異なる衝突を避ける)。
2903
2793
  */
2794
+ const SEEN_INPUT_IDS_MAX = 1_000
2904
2795
  function makeSeenInputIdRegistry(max = SEEN_INPUT_IDS_MAX) {
2905
2796
  const seen = new Set()
2906
2797
  return {
@@ -2930,49 +2821,6 @@ const codexInflightByInputId = new Map()
2930
2821
  // 同一 input_id がキューに 2 件積まれて drain 後も 1 件残る (送信待ちチップが消えない)。
2931
2822
  const claudeInflightByInputId = new Map()
2932
2823
 
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
2824
  /**
2977
2825
  * SDK チャット配達保証 (S2, 2026-07-05): `claude.input` を処理し、browser が付与した
2978
2826
  * `input_id` があれば `claude.input.ack` / `claude.input.nack` を返す。無ければ従来
@@ -2980,8 +2828,7 @@ export function handleUntrackedPtyData(msg, ctx) {
2980
2828
  *
2981
2829
  * `ClaudeStreamBridge.input()` の戻り値 `{ ok, reason? }` は「ターン完走」ではなく
2982
2830
  * 「キュー/常駐 query への投入が完了したか」を表す (ack を素早く返すため)。
2983
- * テストから直接呼べるよう dispatch から切り出して export する
2984
- * (handleTrackedPtyData と同じ設計)。
2831
+ * テストから直接呼べるよう dispatch から切り出して export する。
2985
2832
  */
2986
2833
  export function handleClaudeChatInput(msg, ctx) {
2987
2834
  const stream_id = msg.stream_id
@@ -2999,7 +2846,7 @@ export function handleClaudeChatInput(msg, ctx) {
2999
2846
  }
3000
2847
  if (input_id && seenClaudeChatInputIds.has(input_id)) {
3001
2848
  // 再送 (ack 喪失想定)。同じターンを 2 回実行しないよう sendMessage を
3002
- // 呼び直さず ack のみ返す (tracked pty.data の dedup と同じ考え方)。
2849
+ // 呼び直さず ack のみ返す。
3003
2850
  ctx.logger?.info?.({ stream_id, input_id }, "claude.input dedup (ack only)")
3004
2851
  ctx.client.send({ type: "claude.input.ack", stream_id, input_id })
3005
2852
  return undefined
@@ -3065,6 +2912,62 @@ export function handleClaudeChatInput(msg, ctx) {
3065
2912
  return p
3066
2913
  }
3067
2914
 
2915
+ /**
2916
+ * `tmux.exec` を処理し、`tmux.result` を返す。
2917
+ *
2918
+ * plugin の `interceptTmuxExec` hook が `{ command, args, env }` を返したときは、その
2919
+ * command を代わりに spawn する (例: remote-ops プラグインで ssh 越しに実行)。hook が
2920
+ * 何も返さなければ既定のローカル `execTmux` へ落ちる。どちらの経路でも
2921
+ * `request_id` / `stdout` / `stderr` / `exit_code` / `plugin` の 5 フィールドを揃えて返す。
2922
+ *
2923
+ * dispatch から呼ぶ用のヘルパーだが、テストから直接叩けるよう export している
2924
+ * (handleStreamsSyncResponse / handleClaudeChatInput と同じ扱い)。
2925
+ *
2926
+ * @param {{request_id?:string, args?:string[]}} msg
2927
+ * @param {{plugins:any[], logger:object, client:{send:Function}}} ctx
2928
+ * @param {{execTmux?:Function}} [deps] 既定実装の差し替え口 (テスト専用)。
2929
+ */
2930
+ export async function handleTmuxExec(msg, ctx, deps = {}) {
2931
+ const execTmuxImpl = deps.execTmux || execTmux
2932
+ const args = Array.isArray(msg.args) ? msg.args : []
2933
+ const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
2934
+ logger: ctx.logger,
2935
+ args,
2936
+ env: process.env,
2937
+ })
2938
+ let result
2939
+ if (hookResult?.result?.command) {
2940
+ // plugin が独自 command で差し替え (例: remote-ops プラグインで ssh 越し)
2941
+ const { command, args: spawnArgs, env } = hookResult.result
2942
+ try {
2943
+ const { execFile } = await import("node:child_process")
2944
+ const { promisify } = await import("node:util")
2945
+ const execFileP = promisify(execFile)
2946
+ const { stdout, stderr } = await execFileP(command, spawnArgs, {
2947
+ env: env || process.env,
2948
+ maxBuffer: 4 * 1024 * 1024,
2949
+ })
2950
+ result = { stdout, stderr, exit_code: 0 }
2951
+ } catch (err) {
2952
+ result = {
2953
+ stdout: err.stdout || "",
2954
+ stderr: err.stderr || err.message || "",
2955
+ exit_code: typeof err.code === "number" ? err.code : 1,
2956
+ }
2957
+ }
2958
+ } else {
2959
+ result = await execTmuxImpl(args)
2960
+ }
2961
+ ctx.client.send({
2962
+ type: "tmux.result",
2963
+ request_id: msg.request_id,
2964
+ stdout: result.stdout,
2965
+ stderr: result.stderr,
2966
+ exit_code: result.exit_code,
2967
+ plugin: hookResult?.plugin || null,
2968
+ })
2969
+ }
2970
+
3068
2971
  /**
3069
2972
  * claude.attach の ready 直後にpushする履歴を組み立てる。
3070
2973
  * Browserからの claude.history.request 往復を省くため、resume時だけ先回りして読む。
@@ -3739,74 +3642,6 @@ async function dispatch(msg, ctx) {
3739
3642
  await ctx.requestShutdown?.("agent.shutdown")
3740
3643
  return
3741
3644
  }
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
3645
  case "agent.streams.sync.response":
3811
3646
  // Plan ε: backend ↔ agent の現存 stream_id 能動同期 (WS reconnect 直後に
3812
3647
  // ws-client が agent.streams.sync.request を送って取得した結果)。
@@ -3815,13 +3650,13 @@ async function dispatch(msg, ctx) {
3815
3650
  // 5s タイムアウトのような明示的な timer は持たず、再接続時に再度 request
3816
3651
  // が飛ぶので無害。
3817
3652
  handleStreamsSyncResponse(msg, ctx)
3818
- // buffered pty/control event は browser の購読集合が確定してから再送する。
3653
+ // buffered control event は browser の購読集合が確定してから再送する。
3819
3654
  // request_id は WsClient 側で現 reconnect 世代と照合され、古い応答は無視される。
3820
3655
  ctx.client.completeStreamsSync?.(msg)
3821
3656
  return
3822
3657
  case "claude.attach": {
3823
3658
  // Sprint G: stream-json モードで Claude Code を起動。
3824
- // browser 採番の stream_id をそのまま使う (pty.attach と同じ流儀)。
3659
+ // stream_id は browser 採番のものをそのまま使う。
3825
3660
  const stream_id = msg.stream_id
3826
3661
  if (!ctx.claudeBridge) {
3827
3662
  // SDK 未インストール時はその旨を即時 error で返す。browser 側は
@@ -4480,8 +4315,8 @@ async function dispatch(msg, ctx) {
4480
4315
  ctx.codexBridge.detach({ stream_id: msg.stream_id })
4481
4316
  return
4482
4317
  case "codex.keepalive":
4483
- // ブラウザが Codex チャットを開いている間の生存シグナル (CockpitTerminal.tsx の
4484
- // PTY keepalive と同じ構造)。閲覧中に gcAbandonedSessions (既定 1h 無通信) が
4318
+ // ブラウザが Codex チャットを開いている間の生存シグナル。
4319
+ // 閲覧中に gcAbandonedSessions (既定 1h 無通信) が
4485
4320
  // 誤爆するのを防ぐ。未知 stream_id は false が返るだけ (無害な no-op)。
4486
4321
  if (!ctx.codexBridge) return
4487
4322
  ctx.codexBridge.keepalive({ stream_id: msg.stream_id })
@@ -4904,43 +4739,8 @@ async function dispatch(msg, ctx) {
4904
4739
  return
4905
4740
  }
4906
4741
  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
- })
4742
+ // ロジックは handleTmuxExec へ抽出 (テストから直接呼べる)。
4743
+ await handleTmuxExec(msg, ctx)
4944
4744
  return
4945
4745
  }
4946
4746
  case "tmux.list_sessions": {
@@ -13,7 +13,45 @@ import { pathToFileURL } from "node:url"
13
13
 
14
14
  import { paths } from "./config.mjs"
15
15
 
16
- const PLUGIN_DIR_REGEX = /^(\d+)-(.+)$/
16
+ /**
17
+ * プラグインディレクトリ名の形 (`<prio>-<name>`)。
18
+ *
19
+ * ⚠️ これは形の一部でしかない。受理判定は必ず `parsePluginDirName()` を使うこと
20
+ * (正規表現だけを共有しても「読める形」と「消せる形」は揃わない)。
21
+ */
22
+ export const PLUGIN_DIR_REGEX = /^(\d+)-(.+)$/
23
+
24
+ /**
25
+ * プラグインディレクトリ名を検証し、`{ prio, name }` に分解する。受理しない名前は null。
26
+ *
27
+ * ⚠️ **受理集合の定義はこの 1 関数だけに置く**。discovery (loadPlugins) と削除
28
+ * (plugin-uninstall.mjs) が同じ集合を使わないと「置けるのに消せない」「読めるのに
29
+ * 拒否される」ディレクトリが生まれる。以前は正規表現だけを共有し、uninstall 側に
30
+ * `name.includes("..")` を足していたため、`10-my..plugin` を loader は読み込むのに
31
+ * uninstall は拒否していた。
32
+ *
33
+ * 判定順:
34
+ * 1. 文字列でない / 空 → 拒否
35
+ * 2. `/` または `\` を含む → 拒否 (パス区切りの持ち込みを許さない。POSIX / WSL では
36
+ * `\` は正当なファイル名文字なので、loader 側もここで揃えて読み飛ばす)
37
+ * 3. `.` / `..` そのもの → 拒否
38
+ * 4. `PLUGIN_DIR_REGEX` に一致しない → 拒否
39
+ *
40
+ * ⚠️ セグメント内部の連続ドット (`10-my..plugin`) は **許可** する。区切り文字を
41
+ * 既に拒否済みなので単一セグメントであり、上位への移動にはならない
42
+ * (`path.resolve` は `10-..` を親参照として扱わない)。
43
+ *
44
+ * @param {unknown} name
45
+ * @returns {{ prio: number, name: string } | null}
46
+ */
47
+ export function parsePluginDirName(name) {
48
+ if (typeof name !== "string" || name === "") return null
49
+ if (name.includes("/") || name.includes("\\")) return null
50
+ if (name === "." || name === "..") return null
51
+ const m = PLUGIN_DIR_REGEX.exec(name)
52
+ if (!m) return null
53
+ return { prio: Number(m[1]), name: m[2] }
54
+ }
17
55
 
18
56
  /**
19
57
  * Plugins discovery。読み込み順は dirname の数値昇順。
@@ -33,9 +71,16 @@ export async function loadPlugins(logger) {
33
71
  const candidates = entries
34
72
  .filter((d) => d.isDirectory())
35
73
  .map((d) => {
36
- const m = PLUGIN_DIR_REGEX.exec(d.name)
37
- if (!m) return null
38
- return { dir: d.name, prio: Number(m[1]), name: m[2] }
74
+ const parsed = parsePluginDirName(d.name)
75
+ if (!parsed) {
76
+ // 読み飛ばしを黙って隠すと、利用者は「置いたのに動かない」理由に辿り着けない。
77
+ logger?.warn(
78
+ { dir: d.name },
79
+ "plugin dir name rejected (skipped): expected <prio>-<name> without path separators (e.g. 10-my-plugin)",
80
+ )
81
+ return null
82
+ }
83
+ return { dir: d.name, prio: parsed.prio, name: parsed.name }
39
84
  })
40
85
  .filter(Boolean)
41
86
  .sort((a, b) => a.prio - b.prio)