@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/README.md +13 -12
- package/bin/hub-agent.mjs +7 -37
- package/package.json +2 -5
- package/scripts/install.sh +5 -5
- package/src/claude-stream-bridge.mjs +2 -3
- package/src/codex-stream-bridge.mjs +1 -1
- package/src/hooks.mjs +0 -17
- package/src/main.mjs +91 -291
- package/src/plugin-loader.mjs +49 -4
- package/src/plugin-uninstall.mjs +61 -0
- package/src/tmux.mjs +4 -32
- package/src/ws-client.mjs +19 -89
- package/plugins/10-tailscale-remote/README.md +0 -71
- package/plugins/10-tailscale-remote/config.example.json +0 -6
- package/plugins/10-tailscale-remote/plugin.mjs +0 -99
- package/scripts/fix-node-pty-perms.mjs +0 -89
- package/src/plugin-install.mjs +0 -111
- package/src/pty-bridge.mjs +0 -510
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
|
-
* -
|
|
8
|
-
* -
|
|
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 インストール) 環境でも
|
|
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 /
|
|
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,
|
|
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
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
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
|
-
//
|
|
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 切断
|
|
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,
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2751
|
-
*
|
|
2752
|
-
*
|
|
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 = {
|
|
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
|
|
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 回ターン実行されないようにする。
|
|
2900
|
-
*
|
|
2901
|
-
*
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
4484
|
-
//
|
|
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
|
-
|
|
4908
|
-
|
|
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": {
|
package/src/plugin-loader.mjs
CHANGED
|
@@ -13,7 +13,45 @@ import { pathToFileURL } from "node:url"
|
|
|
13
13
|
|
|
14
14
|
import { paths } from "./config.mjs"
|
|
15
15
|
|
|
16
|
-
|
|
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
|
|
37
|
-
if (!
|
|
38
|
-
|
|
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)
|