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