@cocorograph/hub-agent 0.8.23 → 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 +264 -30
- package/src/codex-stream-bridge.mjs +261 -49
- package/src/hooks.mjs +0 -17
- package/src/main.mjs +113 -293
- package/src/plugin-loader.mjs +49 -4
- package/src/plugin-uninstall.mjs +61 -0
- package/src/positive-int-env.mjs +81 -0
- package/src/session-seq.mjs +22 -0
- package/src/tmux.mjs +4 -32
- package/src/warm-bounds-total.mjs +150 -0
- 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,10 +47,10 @@ 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"
|
|
53
|
+
import { enforceTotalLiveBound } from "./warm-bounds-total.mjs"
|
|
55
54
|
import { UploadManager } from "./claude-upload.mjs"
|
|
56
55
|
import { requestSelfUninstall } from "./service-install.mjs"
|
|
57
56
|
import {
|
|
@@ -101,7 +100,6 @@ import {
|
|
|
101
100
|
listWorktreeStubs,
|
|
102
101
|
paneClaudeAliveOrUnknown,
|
|
103
102
|
removeWorktree as removeWorktreeDir,
|
|
104
|
-
setSessionMouse,
|
|
105
103
|
setTmuxGlobalEnv,
|
|
106
104
|
} from "./tmux.mjs"
|
|
107
105
|
import { SiblingInjectBridge } from "./sibling-inject.mjs"
|
|
@@ -278,7 +276,7 @@ function readBundleVersionSync() {
|
|
|
278
276
|
/**
|
|
279
277
|
* `@anthropic-ai/claude-agent-sdk` を lazy import する。テストでは sdk arg で差し替え。
|
|
280
278
|
*
|
|
281
|
-
* SDK 未インストール (旧 hub-agent インストール) 環境でも
|
|
279
|
+
* SDK 未インストール (旧 hub-agent インストール) 環境でも agent 自体は起動すべきなので、
|
|
282
280
|
* import 失敗を warn に留めて null を返す。stream モードを使う attach が来たら
|
|
283
281
|
* その時点で error を browser に返す設計。
|
|
284
282
|
*/
|
|
@@ -332,22 +330,16 @@ export function withDeadline(promise, timeoutMs, onTimeout) {
|
|
|
332
330
|
}
|
|
333
331
|
|
|
334
332
|
/**
|
|
335
|
-
* B7: 直列 dispatchChain
|
|
336
|
-
* - pty 出力データ (pty.data) と resize (pty.resize): 高頻度・順序保証不要。
|
|
333
|
+
* B7: 直列 dispatchChain をバイパスして即時処理してよい低レイテンシ経路かを判定する。
|
|
337
334
|
* - claude.interrupt / codex.interrupt (2026-07-02): 停止指示。前段に重い dispatch
|
|
338
|
-
* (tmux.exec
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
* 入力系 (claude.input)・他の制御系 (tmux.exec /
|
|
335
|
+
* (tmux.exec / tmux セッション操作等) が滞留していると停止が遅延するため直列キューを
|
|
336
|
+
* バイパスする。中断は「現ターンを止める」冪等な制御で、入力系のような順序依存が無い
|
|
337
|
+
* (むしろキュー内の後続入力より先に届くべき)。
|
|
338
|
+
* 入力系 (claude.input)・他の制御系 (permission / tmux.exec / tmux セッション操作等) は
|
|
342
339
|
* WS 受信順 = pane 反映順を守るため false (= 直列キューに残す)。
|
|
343
340
|
*/
|
|
344
341
|
export function isFastPathMessage(type) {
|
|
345
|
-
return
|
|
346
|
-
type === "pty.data" ||
|
|
347
|
-
type === "pty.resize" ||
|
|
348
|
-
type === "claude.interrupt" ||
|
|
349
|
-
type === "codex.interrupt"
|
|
350
|
-
)
|
|
342
|
+
return type === "claude.interrupt" || type === "codex.interrupt"
|
|
351
343
|
}
|
|
352
344
|
|
|
353
345
|
/**
|
|
@@ -710,7 +702,7 @@ export async function handleBundleSyncRequest(msg, ctx, opts = {}) {
|
|
|
710
702
|
}
|
|
711
703
|
}
|
|
712
704
|
|
|
713
|
-
export async function startDaemon({ version,
|
|
705
|
+
export async function startDaemon({ version, claudeSdk } = {}) {
|
|
714
706
|
const config = await readConfig()
|
|
715
707
|
if (!config) {
|
|
716
708
|
throw new Error(
|
|
@@ -764,14 +756,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
764
756
|
// 2026-07-15: Claude 固定から **検出された Claude/Codex 双方** の kick へ拡張。
|
|
765
757
|
await kickSetupHubAiForDetectedClis({ logger })
|
|
766
758
|
|
|
767
|
-
// node-pty (Microsoft 公式) は macOS arm64 で PTY master fd を spawn ごとに leak し
|
|
768
|
-
// (kill/destroy でも解放されず)、kern.tty.ptmx_max=511 到達で「新規ターミナルが
|
|
769
|
-
// 開けない」障害を起こした (2026-06-16)。node 20/22/24 全てで再現。API 互換の保守
|
|
770
|
-
// フォーク @lydell/node-pty は同ケースで leak ゼロ + 全 OS prebuilt 同梱なので
|
|
771
|
-
// 差し替えた。WHY 詳細: Hub log/2026-06-16/0041_cockpit。
|
|
772
|
-
const resolvedPty = ptyModule || (await import("@lydell/node-pty"))
|
|
773
|
-
const ptyBridge = new PtyBridge({ ptyModule: resolvedPty, logger, plugins })
|
|
774
|
-
|
|
775
759
|
// ホストで起動可能な CLI (claude / codex) を 1 回検出する。hello で広告する用途に
|
|
776
760
|
// 加え、codex app-server ベースの chat ブリッジ (codexBridge) を構築するかどうかの
|
|
777
761
|
// gate にも使う (claudeBridge は npm optional dep の有無で判定するのに対し、codex は
|
|
@@ -865,13 +849,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
865
849
|
logger.warn({ err: err.message }, "ws client error (will retry via close handler)")
|
|
866
850
|
})
|
|
867
851
|
|
|
868
|
-
ptyBridge.on("output", ({ stream_id, data }) => {
|
|
869
|
-
client.send({ type: "pty.data", stream_id, data })
|
|
870
|
-
})
|
|
871
|
-
ptyBridge.on("exit", ({ stream_id, code }) => {
|
|
872
|
-
client.send({ type: "pty.exit", stream_id, code })
|
|
873
|
-
})
|
|
874
|
-
|
|
875
852
|
// stream-json モード (Sprint G): Claude Code を SDK 経由で起動し、stream イベントを
|
|
876
853
|
// そのまま browser に転送する。SDK 未インストール時は claudeBridge=null で全 attach が
|
|
877
854
|
// claude.error を返す経路に分岐 (dispatch 側で判定)。
|
|
@@ -1140,16 +1117,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
1140
1117
|
//
|
|
1141
1118
|
// `EventEmitter.emit` は async リスナーの完了を待たないため、`async (msg) =>
|
|
1142
1119
|
// await dispatch(msg, ...)` の形だと複数 WS メッセージが連続して届いたとき
|
|
1143
|
-
// それぞれの dispatch
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
// 典型的な被害: cockpit ChatInput の `await onCancelTmuxMode()` → paste
|
|
1148
|
-
// の流れで、ブラウザ側が cancel の応答を待ってから paste を送っても、
|
|
1149
|
-
// 別経路の `tmux.exec` (state loop の polling 等) で agent が busy だと、
|
|
1150
|
-
// cancel の execTmux が完了する前に paste が ptyBridge.write される。
|
|
1151
|
-
// 結果として paste が tmux 内部状態 (copy mode 等) を抜ける前に届き、
|
|
1152
|
-
// claude TUI に正しく入らない事象が散発する。
|
|
1120
|
+
// それぞれの dispatch が並列実行される。tmux セッション操作のような subprocess
|
|
1121
|
+
// 待ちの最中に後続の同期処理が先行すると、ブラウザ→Hub では順序が保証されて
|
|
1122
|
+
// いても agent 内部で逆転する。
|
|
1153
1123
|
//
|
|
1154
1124
|
// 旧来の global Promise chain は、ある cwd の tmux/codex 処理が別 cwd の
|
|
1155
1125
|
// claude.attach と reconnect ready/sync まで 60〜203 秒止める HoL blocking を
|
|
@@ -1158,7 +1128,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
1158
1128
|
const dispatchCtx = {
|
|
1159
1129
|
...ctx,
|
|
1160
1130
|
client,
|
|
1161
|
-
ptyBridge,
|
|
1162
1131
|
claudeBridge,
|
|
1163
1132
|
codexBridge,
|
|
1164
1133
|
codexMcpControl,
|
|
@@ -1167,7 +1136,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
1167
1136
|
const dispatchScheduler = new DispatchScheduler({
|
|
1168
1137
|
logger,
|
|
1169
1138
|
run: (msg) => dispatch(msg, dispatchCtx),
|
|
1170
|
-
//
|
|
1139
|
+
// fast-path (interrupt) / read-only RPC は従来どおり即時実行。接続 lifecycle は
|
|
1171
1140
|
// scheduler 自身が常に priority 扱いする。
|
|
1172
1141
|
shouldBypass: (type) => isFastPathMessage(type) || isReadOnlyRpc(type),
|
|
1173
1142
|
})
|
|
@@ -1179,7 +1148,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
1179
1148
|
|
|
1180
1149
|
// 0.6.2 fix: event loop を ref されたまま生かしておく watchdog。
|
|
1181
1150
|
// hub-agent の他の background task (heartbeat / reconnect / state loop / fs.watch)
|
|
1182
|
-
// は全て .unref() / persistent:false なので、長時間アイドル + WS 切断
|
|
1151
|
+
// は全て .unref() / persistent:false なので、長時間アイドル + WS 切断
|
|
1183
1152
|
// が同時発生すると Node が「やることなし」と判断して exit(0) で silent 終了する
|
|
1184
1153
|
// 既存バグがあった。watchdog を 1 つ ref のまま持つことで exit を防ぐ。
|
|
1185
1154
|
// shutdown 時に clearInterval して正常終了を許可する。
|
|
@@ -1276,7 +1245,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
1276
1245
|
sessionEventLoop?.stop?.()
|
|
1277
1246
|
readinessLoop?.stop?.()
|
|
1278
1247
|
siblingInjectBridge.stop()
|
|
1279
|
-
ptyBridge.shutdown()
|
|
1280
1248
|
claudeBridge?.shutdown?.()
|
|
1281
1249
|
codexBridge?.shutdown?.()
|
|
1282
1250
|
client.stop()
|
|
@@ -1306,7 +1274,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
1306
1274
|
)
|
|
1307
1275
|
})
|
|
1308
1276
|
|
|
1309
|
-
return { client, plugins,
|
|
1277
|
+
return { client, plugins, claudeBridge, codexBridge, codexMcpControl, uploadManager }
|
|
1310
1278
|
}
|
|
1311
1279
|
|
|
1312
1280
|
const SESSION_EVENTS_DIR =
|
|
@@ -1960,8 +1928,8 @@ function startReadinessLoop({ tracker, logger, intervalMs = 700, onReady }) {
|
|
|
1960
1928
|
* 全 tmux session の状態を定期 capture し、変化したものだけ Hub に push する。
|
|
1961
1929
|
*
|
|
1962
1930
|
* - 前回値との比較で status / context_pct のどちらかが変化したら送る
|
|
1963
|
-
* - 新規 session は無条件 push、消えた session は無視 (browser
|
|
1964
|
-
*
|
|
1931
|
+
* - 新規 session は無条件 push、消えた session は無視 (browser 側は
|
|
1932
|
+
* セッション一覧の更新で消滅を検知する)
|
|
1965
1933
|
* - tmux 自体が動いてない場合 (listSessionStates → []) は何も push しない
|
|
1966
1934
|
*/
|
|
1967
1935
|
// (i) 出力フロー検知の有効窓。ペイン出力領域の署名 (output_sig) が直近この時間内に変化して
|
|
@@ -2239,6 +2207,11 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2239
2207
|
// 権威的 stall 判定 (arm 非依存・cap 非依存)。state loop が観測する signal だけで turn の開閉と
|
|
2240
2208
|
// 無進捗を判定する。旧 lastProgressAtByName + armed ゲートの「フック依存 + cap 140s で消える」死角を解消。
|
|
2241
2209
|
const stallTracker = new StallTracker({ stallWarnMs: STALL_WARN_MS })
|
|
2210
|
+
// 総 live 上限 M のログ抑制状態。⚠️ **この state loop インスタンスが所有する**(module-global に
|
|
2211
|
+
// 置くと、複数インスタンスや並行テストが互いのログを抑制してしまう)。「超過しているが全部
|
|
2212
|
+
// 保護されていて刈れない」tick は 5 秒ごとに同じ行を出し続けるので、状態が変わったときと
|
|
2213
|
+
// 一定間隔でだけ記録する(warm-bounds-total.mjs の _shouldLog)。
|
|
2214
|
+
const warmBoundsLogState = { lastUnresolved: 0, lastLoggedAtMs: 0 }
|
|
2242
2215
|
let stopped = false
|
|
2243
2216
|
|
|
2244
2217
|
// RC-8: WS 再接続のたびに差分送信の基準 (lastByName) をクリアし、次 tick で全 session.state を
|
|
@@ -2263,8 +2236,10 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2263
2236
|
} catch (err) {
|
|
2264
2237
|
logger?.warn({ err: err?.message }, "gcDeadTerminals failed")
|
|
2265
2238
|
}
|
|
2266
|
-
// 子⑤ (T06544): claude 側 warm 境界(per-cwd=
|
|
2267
|
-
//
|
|
2239
|
+
// 子⑤ (T06544): claude 側 warm 境界(per-cwd=K / N-cwd LRU)。K は現行の既定 3
|
|
2240
|
+
// (**旧仕様**は per-cwd=1 で「各 cwd 1 本」だった。1 cwd で複数会話を同時に生かすため
|
|
2241
|
+
// 一般化し、総量は下の総 live 上限 M で押さえる)。従来 claude は per-session の softDetach
|
|
2242
|
+
// タイマー(7 日)だけで件数上限が無かったため、多数の cwd を渡り歩くと warm な
|
|
2268
2243
|
// SDK 常駐 query が蓄積し得た。codex の reconcileSessions と対で cwd 単位に有界化する。
|
|
2269
2244
|
try {
|
|
2270
2245
|
claudeBridge?.reconcileWarmBounds?.()
|
|
@@ -2279,6 +2254,18 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2279
2254
|
} catch (err) {
|
|
2280
2255
|
logger?.warn({ err: err?.message }, "gcAbandonedSessions failed")
|
|
2281
2256
|
}
|
|
2257
|
+
// 総 live 上限 M(claude + codex 合算、既定 20)。per-cwd を 1 → K(既定 3)へ緩めたぶん、
|
|
2258
|
+
// cwd 単位の境界だけでは総量が膨らむため、cwd をまたいだ天井をここで適用する。
|
|
2259
|
+
// ⚠️ 両 CLI の reconcile が **終わった後**に呼ぶ(cwd 単位で畳んだ結果の生存数で判定する)。
|
|
2260
|
+
try {
|
|
2261
|
+
enforceTotalLiveBound({
|
|
2262
|
+
bridges: [claudeBridge, codexBridge].filter(Boolean),
|
|
2263
|
+
logger,
|
|
2264
|
+
logState: warmBoundsLogState,
|
|
2265
|
+
})
|
|
2266
|
+
} catch (err) {
|
|
2267
|
+
logger?.warn({ err: err?.message }, "enforceTotalLiveBound failed")
|
|
2268
|
+
}
|
|
2282
2269
|
// 実コンテキスト窓サイズ (1M ベータ等) を反映。contextPctFromUsage の分母を
|
|
2283
2270
|
// 5s ごとに最新化し、ドーナツが 200k 固定で振り切れるのを防ぐ。
|
|
2284
2271
|
await refreshContextWindow()
|
|
@@ -2644,12 +2631,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2644
2631
|
}
|
|
2645
2632
|
}
|
|
2646
2633
|
|
|
2647
|
-
/**
|
|
2648
|
-
* Hub からのメッセージを type で分岐する。
|
|
2649
|
-
*
|
|
2650
|
-
* `pty.*` は PtyBridge にそのまま流す。`tmux.exec` は Sprint E で tmux 抽象に
|
|
2651
|
-
* 渡す予定なので、現状は plugin hook chain だけ走らせて log に残す。
|
|
2652
|
-
*/
|
|
2653
2634
|
/**
|
|
2654
2635
|
* Hub から `ready` / `ack` で配信された Claude/Codex CLI 設定を agent.json と
|
|
2655
2636
|
* in-memory config に反映する (Phase 3 live-sync)。
|
|
@@ -2724,12 +2705,11 @@ const STREAMS_SYNC_GRACE_MS = 45_000
|
|
|
2724
2705
|
* Plan ε: `agent.streams.sync.response` を受けて backend ↔ agent の現存
|
|
2725
2706
|
* stream_id を能動同期する。
|
|
2726
2707
|
*
|
|
2727
|
-
* - PTY: local にあって backend にない → ptyBridge.detach、逆差分 → pty.error
|
|
2728
2708
|
* - Claude/Codex: mode 別 snapshot から外れた古い subscriber を grace 付きで掃除
|
|
2729
2709
|
*
|
|
2730
|
-
*
|
|
2731
|
-
*
|
|
2732
|
-
*
|
|
2710
|
+
* mode 別 snapshot (active_streams) が active_stream_ids と完全に一致するときだけ
|
|
2711
|
+
* authoritative とみなす。Blue-Green ローリング中の mode 無し応答や未知 mode が
|
|
2712
|
+
* 混ざった場合は掃除を一切行わない (誤って現役 subscriber を切らないための fail-safe)。
|
|
2733
2713
|
*
|
|
2734
2714
|
* dispatch から呼ぶ用のヘルパーだが、テストから直接叩けるよう export している。
|
|
2735
2715
|
*/
|
|
@@ -2737,7 +2717,7 @@ export function handleStreamsSyncResponse(msg, ctx) {
|
|
|
2737
2717
|
const activeIds = new Set(
|
|
2738
2718
|
Array.isArray(msg?.active_stream_ids) ? msg.active_stream_ids : [],
|
|
2739
2719
|
)
|
|
2740
|
-
const activeByMode = {
|
|
2720
|
+
const activeByMode = { claude: new Set(), codex: new Set() }
|
|
2741
2721
|
const classifiedIds = new Set()
|
|
2742
2722
|
if (Array.isArray(msg?.active_streams)) {
|
|
2743
2723
|
for (const item of msg.active_streams) {
|
|
@@ -2750,62 +2730,11 @@ export function handleStreamsSyncResponse(msg, ctx) {
|
|
|
2750
2730
|
}
|
|
2751
2731
|
}
|
|
2752
2732
|
// Blue-Green ローリング中に旧 BrowserConsumer の mode 無し応答が混ざった場合は
|
|
2753
|
-
// typed snapshot を authoritative
|
|
2754
|
-
// legacy 互換を維持し、正常な Claude/Codex に ghost PTY error を返さない。
|
|
2733
|
+
// typed snapshot を authoritative とみなさず、掃除を一切行わない (legacy 互換)。
|
|
2755
2734
|
const haveCompleteModeSnapshot =
|
|
2756
2735
|
Array.isArray(msg?.active_streams)
|
|
2757
2736
|
&& classifiedIds.size === activeIds.size
|
|
2758
2737
|
&& Array.from(classifiedIds).every((id) => activeIds.has(id))
|
|
2759
|
-
const knownChatIds = new Set([
|
|
2760
|
-
...Array.from(ctx.claudeBridge?.sessions?.keys?.() ?? []),
|
|
2761
|
-
...Array.from(ctx.codexBridge?.sessions?.keys?.() ?? []),
|
|
2762
|
-
])
|
|
2763
|
-
const activePtyIds = haveCompleteModeSnapshot
|
|
2764
|
-
? activeByMode.pty
|
|
2765
|
-
: new Set(Array.from(activeIds).filter((id) => !knownChatIds.has(id)))
|
|
2766
|
-
const localIds = new Set(ctx.ptyBridge.list())
|
|
2767
|
-
// churn 退化スナップショット保護 (ack_lost 根治 / 2026-06-17 根因 rank3)。
|
|
2768
|
-
// backend は browser 群へ _STREAMS_SYNC_TIMEOUT_S(0.5s) だけ問い合わせて active set を
|
|
2769
|
-
// 集める。再接続バースト中 (本番 ~20s 間隔・code 1006) は browser がまだ再 attach できて
|
|
2770
|
-
// おらず空/部分的な set が返り、その一瞬を authoritative とみなして「ローカル全 stream が
|
|
2771
|
-
// 孤児」と誤判定し、生きている PTY を皆殺しにしていた (再 attach 直後の stream を 1 秒後に
|
|
2772
|
-
// 殺し、以後の入力が stream_missing → 数分単位の「送信待ち」固着。0.6.89 ログ最多 churn 源)。
|
|
2773
|
-
// 空 set は信用せず kill を一切行わない (真の孤児は 10min GC + maxStreams 上限が回収する)。
|
|
2774
|
-
const haveAuthoritativeActiveSet = activeIds.size > 0
|
|
2775
|
-
for (const sid of localIds) {
|
|
2776
|
-
if (activePtyIds.has(sid)) continue
|
|
2777
|
-
if (!haveAuthoritativeActiveSet) continue
|
|
2778
|
-
// grace guard: 直近 STREAMS_SYNC_GRACE_MS 以内に touch (attach/write/resize/pty 出力) された
|
|
2779
|
-
// stream は「browser が再接続中で 0.5s 集計に間に合わなかっただけ」の可能性が高い。猶予内は
|
|
2780
|
-
// 殺さず延命する。真に放置された孤児は猶予超過で従来どおり detach され、10min GC も backstop。
|
|
2781
|
-
const lastSeen = ctx.ptyBridge.getLastSeen?.(sid) ?? 0
|
|
2782
|
-
const idleMs = Date.now() - lastSeen
|
|
2783
|
-
if (lastSeen > 0 && idleMs < STREAMS_SYNC_GRACE_MS) {
|
|
2784
|
-
ctx.logger?.info?.(
|
|
2785
|
-
{ stream_id: sid, idle_ms: idleMs, request_id: msg?.request_id },
|
|
2786
|
-
"Plan ε: skip detach (within grace, browser likely re-attaching)",
|
|
2787
|
-
)
|
|
2788
|
-
continue
|
|
2789
|
-
}
|
|
2790
|
-
ctx.logger?.warn?.(
|
|
2791
|
-
{ stream_id: sid, request_id: msg?.request_id },
|
|
2792
|
-
"Plan ε: orphan stream detected, detaching",
|
|
2793
|
-
)
|
|
2794
|
-
ctx.ptyBridge.detach({ stream_id: sid })
|
|
2795
|
-
}
|
|
2796
|
-
for (const sid of activePtyIds) {
|
|
2797
|
-
if (!localIds.has(sid)) {
|
|
2798
|
-
ctx.logger?.warn?.(
|
|
2799
|
-
{ stream_id: sid, request_id: msg?.request_id },
|
|
2800
|
-
"Plan ε: stream missing on agent, notifying backend (pty.error)",
|
|
2801
|
-
)
|
|
2802
|
-
ctx.client.send({
|
|
2803
|
-
type: "pty.error",
|
|
2804
|
-
stream_id: sid,
|
|
2805
|
-
error: "stream_not_found_on_agent",
|
|
2806
|
-
})
|
|
2807
|
-
}
|
|
2808
|
-
}
|
|
2809
2738
|
|
|
2810
2739
|
if (haveCompleteModeSnapshot) {
|
|
2811
2740
|
// reconnect 集計は browser ごとの復帰順で一時的に部分集合になり得る。
|
|
@@ -2853,34 +2782,16 @@ export function pruneInactiveChatSubscribers(bridge, activeIds, mode, logger, no
|
|
|
2853
2782
|
return removed
|
|
2854
2783
|
}
|
|
2855
2784
|
|
|
2856
|
-
/**
|
|
2857
|
-
* end-to-end 配達確認 (tracked pty.data) の冪等化レジストリ。
|
|
2858
|
-
*
|
|
2859
|
-
* browser はチャット送信 (paste / 確定 CR) に input_id を付け、agent からの
|
|
2860
|
-
* pty.input.ack が来ない限り再送する (zombie WS / 切断窓 / backend group_send の
|
|
2861
|
-
* 無言喪失への対策)。「実際は届いていたが ack 側が消えた」再送で同じ本文が
|
|
2862
|
-
* 二重 paste されないよう、処理済み input_id を記憶して 2 回目以降は write せず
|
|
2863
|
-
* ack だけ返す。Set は挿入順 iterate できるので最古から間引く。
|
|
2864
|
-
*/
|
|
2865
|
-
const SEEN_INPUT_IDS_MAX = 1_000
|
|
2866
|
-
const seenInputIds = new Set()
|
|
2867
|
-
function rememberInputId(input_id) {
|
|
2868
|
-
seenInputIds.add(input_id)
|
|
2869
|
-
while (seenInputIds.size > SEEN_INPUT_IDS_MAX) {
|
|
2870
|
-
seenInputIds.delete(seenInputIds.values().next().value)
|
|
2871
|
-
}
|
|
2872
|
-
}
|
|
2873
|
-
|
|
2874
2785
|
/**
|
|
2875
2786
|
* SDK チャット配達保証 (S2, 2026-07-05) 用の冪等化レジストリ。
|
|
2876
2787
|
*
|
|
2877
2788
|
* `claude.input` / `codex.input` に browser が付与する `input_id` を記憶し、ack
|
|
2878
2789
|
* ウォッチドッグ再送 (browser 側が 5s 以内に ack/nack を受け取れず再送するケース) で
|
|
2879
|
-
* 同じメッセージが 2 回ターン実行されないようにする。
|
|
2880
|
-
*
|
|
2881
|
-
*
|
|
2882
|
-
* 意味論が異なる衝突を避けるため)。
|
|
2790
|
+
* 同じメッセージが 2 回ターン実行されないようにする。Set は挿入順 iterate できるので
|
|
2791
|
+
* 最古から間引く。claude / codex でチャンネル別に Set を分ける (input_id の採番元は
|
|
2792
|
+
* browser 側で共通の可能性があるため、意味論が異なる衝突を避ける)。
|
|
2883
2793
|
*/
|
|
2794
|
+
const SEEN_INPUT_IDS_MAX = 1_000
|
|
2884
2795
|
function makeSeenInputIdRegistry(max = SEEN_INPUT_IDS_MAX) {
|
|
2885
2796
|
const seen = new Set()
|
|
2886
2797
|
return {
|
|
@@ -2910,49 +2821,6 @@ const codexInflightByInputId = new Map()
|
|
|
2910
2821
|
// 同一 input_id がキューに 2 件積まれて drain 後も 1 件残る (送信待ちチップが消えない)。
|
|
2911
2822
|
const claudeInflightByInputId = new Map()
|
|
2912
2823
|
|
|
2913
|
-
/**
|
|
2914
|
-
* input_id 付き pty.data (tracked 入力) の処理。write 成功で pty.input.ack を返し、
|
|
2915
|
-
* stream 不在なら pty.error を返して browser の再 attach → 再送に繋げる。
|
|
2916
|
-
* テストから直接呼べるよう dispatch から切り出して export する。
|
|
2917
|
-
*/
|
|
2918
|
-
export function handleTrackedPtyData(msg, ctx) {
|
|
2919
|
-
const { stream_id, input_id } = msg
|
|
2920
|
-
if (seenInputIds.has(input_id)) {
|
|
2921
|
-
// 再送 (ack 喪失 or 再接続 flush)。書き込み済みなので ack だけ返す。
|
|
2922
|
-
ctx.logger?.info({ stream_id, input_id }, "tracked pty.data dedup (ack only)")
|
|
2923
|
-
ctx.client.send({ type: "pty.input.ack", stream_id, input_id })
|
|
2924
|
-
return
|
|
2925
|
-
}
|
|
2926
|
-
const ok = ctx.ptyBridge.write({ stream_id, data: msg.data })
|
|
2927
|
-
if (ok) {
|
|
2928
|
-
rememberInputId(input_id)
|
|
2929
|
-
ctx.client.send({ type: "pty.input.ack", stream_id, input_id })
|
|
2930
|
-
} else {
|
|
2931
|
-
// stream 不在 (reap 済み等)。browser は pty.error で再 attach し、未 ack の
|
|
2932
|
-
// tracked 入力は ack ウォッチドッグが再送する。
|
|
2933
|
-
ctx.client.send({
|
|
2934
|
-
type: "pty.error",
|
|
2935
|
-
stream_id,
|
|
2936
|
-
error: "stream_missing",
|
|
2937
|
-
input_id,
|
|
2938
|
-
})
|
|
2939
|
-
}
|
|
2940
|
-
}
|
|
2941
|
-
|
|
2942
|
-
/**
|
|
2943
|
-
* 無印 (input_id 無し / fire-and-forget) pty.data の処理。raw 打鍵 (term.onData) が
|
|
2944
|
-
* これを通る。stream 不在 (reap 済み等) なら pty.error を返して browser を再 attach →
|
|
2945
|
-
* 自己修復させる (T-6)。従来は write の戻り値を見ずに捨てていたため、reap 済み stream
|
|
2946
|
-
* への打鍵が無言ドロップされていた。tracked 経路 (handleTrackedPtyData) と対称。
|
|
2947
|
-
* テストから直接呼べるよう dispatch から切り出して export する。
|
|
2948
|
-
*/
|
|
2949
|
-
export function handleUntrackedPtyData(msg, ctx) {
|
|
2950
|
-
const { stream_id } = msg
|
|
2951
|
-
if (!ctx.ptyBridge.write({ stream_id, data: msg.data })) {
|
|
2952
|
-
ctx.client.send({ type: "pty.error", stream_id, error: "stream_missing" })
|
|
2953
|
-
}
|
|
2954
|
-
}
|
|
2955
|
-
|
|
2956
2824
|
/**
|
|
2957
2825
|
* SDK チャット配達保証 (S2, 2026-07-05): `claude.input` を処理し、browser が付与した
|
|
2958
2826
|
* `input_id` があれば `claude.input.ack` / `claude.input.nack` を返す。無ければ従来
|
|
@@ -2960,8 +2828,7 @@ export function handleUntrackedPtyData(msg, ctx) {
|
|
|
2960
2828
|
*
|
|
2961
2829
|
* `ClaudeStreamBridge.input()` の戻り値 `{ ok, reason? }` は「ターン完走」ではなく
|
|
2962
2830
|
* 「キュー/常駐 query への投入が完了したか」を表す (ack を素早く返すため)。
|
|
2963
|
-
* テストから直接呼べるよう dispatch から切り出して export
|
|
2964
|
-
* (handleTrackedPtyData と同じ設計)。
|
|
2831
|
+
* テストから直接呼べるよう dispatch から切り出して export する。
|
|
2965
2832
|
*/
|
|
2966
2833
|
export function handleClaudeChatInput(msg, ctx) {
|
|
2967
2834
|
const stream_id = msg.stream_id
|
|
@@ -2979,7 +2846,7 @@ export function handleClaudeChatInput(msg, ctx) {
|
|
|
2979
2846
|
}
|
|
2980
2847
|
if (input_id && seenClaudeChatInputIds.has(input_id)) {
|
|
2981
2848
|
// 再送 (ack 喪失想定)。同じターンを 2 回実行しないよう sendMessage を
|
|
2982
|
-
// 呼び直さず ack
|
|
2849
|
+
// 呼び直さず ack のみ返す。
|
|
2983
2850
|
ctx.logger?.info?.({ stream_id, input_id }, "claude.input dedup (ack only)")
|
|
2984
2851
|
ctx.client.send({ type: "claude.input.ack", stream_id, input_id })
|
|
2985
2852
|
return undefined
|
|
@@ -3045,6 +2912,62 @@ export function handleClaudeChatInput(msg, ctx) {
|
|
|
3045
2912
|
return p
|
|
3046
2913
|
}
|
|
3047
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
|
+
|
|
3048
2971
|
/**
|
|
3049
2972
|
* claude.attach の ready 直後にpushする履歴を組み立てる。
|
|
3050
2973
|
* Browserからの claude.history.request 往復を省くため、resume時だけ先回りして読む。
|
|
@@ -3719,74 +3642,6 @@ async function dispatch(msg, ctx) {
|
|
|
3719
3642
|
await ctx.requestShutdown?.("agent.shutdown")
|
|
3720
3643
|
return
|
|
3721
3644
|
}
|
|
3722
|
-
case "pty.attach": {
|
|
3723
|
-
const stream_id = msg.stream_id
|
|
3724
|
-
try {
|
|
3725
|
-
const info = await ctx.ptyBridge.attach({
|
|
3726
|
-
stream_id,
|
|
3727
|
-
sessionName: msg.session_name || "",
|
|
3728
|
-
cols: msg.cols,
|
|
3729
|
-
rows: msg.rows,
|
|
3730
|
-
})
|
|
3731
|
-
// 接続端末の種別に応じて tmux mouse mode を出し分ける (スマホ=on /
|
|
3732
|
-
// デスクトップ=off)。boolean のときだけ上書きし、未指定 (旧フロント) は
|
|
3733
|
-
// createSession の既定値 (on) を尊重する。fire-and-forget で pty.ready を
|
|
3734
|
-
// 妨げない (失敗は setSessionMouse 内で warn 済み)。
|
|
3735
|
-
if (typeof msg.mouse === "boolean" && msg.session_name) {
|
|
3736
|
-
setSessionMouse(msg.session_name, msg.mouse, { logger: ctx.logger })
|
|
3737
|
-
}
|
|
3738
|
-
ctx.client.send({
|
|
3739
|
-
type: "pty.ready",
|
|
3740
|
-
stream_id,
|
|
3741
|
-
session_name: msg.session_name || "",
|
|
3742
|
-
plugin: info.plugin,
|
|
3743
|
-
// end-to-end 配達確認 (input_id 付き pty.data → pty.input.ack) に対応
|
|
3744
|
-
// していることを browser へ通知する capability フラグ。旧 agent には
|
|
3745
|
-
// このフラグが無く、browser 側は tracked 送信を自動で無効化する。
|
|
3746
|
-
supports_input_ack: true,
|
|
3747
|
-
})
|
|
3748
|
-
} catch (err) {
|
|
3749
|
-
ctx.client.send({
|
|
3750
|
-
type: "pty.exit",
|
|
3751
|
-
stream_id,
|
|
3752
|
-
code: -1,
|
|
3753
|
-
error: err.message,
|
|
3754
|
-
})
|
|
3755
|
-
}
|
|
3756
|
-
return
|
|
3757
|
-
}
|
|
3758
|
-
case "pty.data":
|
|
3759
|
-
// input_id 付き = tracked 入力 (チャット送信)。冪等化 + ack 返却で
|
|
3760
|
-
// end-to-end 配達確認に応える。無印は従来どおり fire-and-forget。
|
|
3761
|
-
if (typeof msg.input_id === "string" && msg.input_id) {
|
|
3762
|
-
handleTrackedPtyData(msg, ctx)
|
|
3763
|
-
return
|
|
3764
|
-
}
|
|
3765
|
-
handleUntrackedPtyData(msg, ctx)
|
|
3766
|
-
return
|
|
3767
|
-
case "pty.resize": {
|
|
3768
|
-
const resized = ctx.ptyBridge.resize({
|
|
3769
|
-
stream_id: msg.stream_id,
|
|
3770
|
-
cols: msg.cols,
|
|
3771
|
-
rows: msg.rows,
|
|
3772
|
-
})
|
|
3773
|
-
// 自己修復 (perf監査 Phase3): stream 不在で resize できなかった (reap 済み等) ときは
|
|
3774
|
-
// pty.error(stream_missing) を返し browser を再 attach させる。keepalive resize が dead
|
|
3775
|
-
// stream を即検知して 4 分単位の「pty.resize but stream missing」churn を自己修復する
|
|
3776
|
-
// (handleTrackedPtyData/handleUntrackedPtyData と同じパターン)。resize 例外 (pty 存在) も
|
|
3777
|
-
// false だが、その場合の再 attach は既存 stream への無害な再接続。
|
|
3778
|
-
if (!resized && msg.stream_id) {
|
|
3779
|
-
ctx.client.send({
|
|
3780
|
-
type: "pty.error",
|
|
3781
|
-
stream_id: msg.stream_id,
|
|
3782
|
-
error: "stream_missing",
|
|
3783
|
-
})
|
|
3784
|
-
}
|
|
3785
|
-
return
|
|
3786
|
-
}
|
|
3787
|
-
case "pty.detach":
|
|
3788
|
-
ctx.ptyBridge.detach({ stream_id: msg.stream_id })
|
|
3789
|
-
return
|
|
3790
3645
|
case "agent.streams.sync.response":
|
|
3791
3646
|
// Plan ε: backend ↔ agent の現存 stream_id 能動同期 (WS reconnect 直後に
|
|
3792
3647
|
// ws-client が agent.streams.sync.request を送って取得した結果)。
|
|
@@ -3795,13 +3650,13 @@ async function dispatch(msg, ctx) {
|
|
|
3795
3650
|
// 5s タイムアウトのような明示的な timer は持たず、再接続時に再度 request
|
|
3796
3651
|
// が飛ぶので無害。
|
|
3797
3652
|
handleStreamsSyncResponse(msg, ctx)
|
|
3798
|
-
// buffered
|
|
3653
|
+
// buffered control event は browser の購読集合が確定してから再送する。
|
|
3799
3654
|
// request_id は WsClient 側で現 reconnect 世代と照合され、古い応答は無視される。
|
|
3800
3655
|
ctx.client.completeStreamsSync?.(msg)
|
|
3801
3656
|
return
|
|
3802
3657
|
case "claude.attach": {
|
|
3803
3658
|
// Sprint G: stream-json モードで Claude Code を起動。
|
|
3804
|
-
//
|
|
3659
|
+
// stream_id は browser 採番のものをそのまま使う。
|
|
3805
3660
|
const stream_id = msg.stream_id
|
|
3806
3661
|
if (!ctx.claudeBridge) {
|
|
3807
3662
|
// SDK 未インストール時はその旨を即時 error で返す。browser 側は
|
|
@@ -4460,8 +4315,8 @@ async function dispatch(msg, ctx) {
|
|
|
4460
4315
|
ctx.codexBridge.detach({ stream_id: msg.stream_id })
|
|
4461
4316
|
return
|
|
4462
4317
|
case "codex.keepalive":
|
|
4463
|
-
// ブラウザが Codex
|
|
4464
|
-
//
|
|
4318
|
+
// ブラウザが Codex チャットを開いている間の生存シグナル。
|
|
4319
|
+
// 閲覧中に gcAbandonedSessions (既定 1h 無通信) が
|
|
4465
4320
|
// 誤爆するのを防ぐ。未知 stream_id は false が返るだけ (無害な no-op)。
|
|
4466
4321
|
if (!ctx.codexBridge) return
|
|
4467
4322
|
ctx.codexBridge.keepalive({ stream_id: msg.stream_id })
|
|
@@ -4884,43 +4739,8 @@ async function dispatch(msg, ctx) {
|
|
|
4884
4739
|
return
|
|
4885
4740
|
}
|
|
4886
4741
|
case "tmux.exec": {
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
logger: ctx.logger,
|
|
4890
|
-
args,
|
|
4891
|
-
env: process.env,
|
|
4892
|
-
})
|
|
4893
|
-
let result
|
|
4894
|
-
if (hookResult?.result?.command) {
|
|
4895
|
-
// plugin が独自 command で差し替え (例: remote-ops プラグインで ssh 越し)
|
|
4896
|
-
const { command, args: spawnArgs, env } = hookResult.result
|
|
4897
|
-
try {
|
|
4898
|
-
const { execFile } = await import("node:child_process")
|
|
4899
|
-
const { promisify } = await import("node:util")
|
|
4900
|
-
const execFileP = promisify(execFile)
|
|
4901
|
-
const { stdout, stderr } = await execFileP(command, spawnArgs, {
|
|
4902
|
-
env: env || process.env,
|
|
4903
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
4904
|
-
})
|
|
4905
|
-
result = { stdout, stderr, exit_code: 0 }
|
|
4906
|
-
} catch (err) {
|
|
4907
|
-
result = {
|
|
4908
|
-
stdout: err.stdout || "",
|
|
4909
|
-
stderr: err.stderr || err.message || "",
|
|
4910
|
-
exit_code: typeof err.code === "number" ? err.code : 1,
|
|
4911
|
-
}
|
|
4912
|
-
}
|
|
4913
|
-
} else {
|
|
4914
|
-
result = await execTmux(args)
|
|
4915
|
-
}
|
|
4916
|
-
ctx.client.send({
|
|
4917
|
-
type: "tmux.result",
|
|
4918
|
-
request_id: msg.request_id,
|
|
4919
|
-
stdout: result.stdout,
|
|
4920
|
-
stderr: result.stderr,
|
|
4921
|
-
exit_code: result.exit_code,
|
|
4922
|
-
plugin: hookResult?.plugin || null,
|
|
4923
|
-
})
|
|
4742
|
+
// ロジックは handleTmuxExec へ抽出 (テストから直接呼べる)。
|
|
4743
|
+
await handleTmuxExec(msg, ctx)
|
|
4924
4744
|
return
|
|
4925
4745
|
}
|
|
4926
4746
|
case "tmux.list_sessions": {
|