@cocorograph/hub-agent 0.5.30 → 0.5.32
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/bin/hub-agent.mjs +9 -1
- package/package.json +1 -1
- package/src/hub-bundle.mjs +90 -2
- package/src/ws-client.mjs +88 -5
package/bin/hub-agent.mjs
CHANGED
|
@@ -95,7 +95,15 @@ program
|
|
|
95
95
|
agentId: cfg.agent_id,
|
|
96
96
|
agentToken: cfg.agent_token,
|
|
97
97
|
})
|
|
98
|
-
|
|
98
|
+
const mcpSummary = r.mcp
|
|
99
|
+
? ` mcp_added=${r.mcp.added.length}${r.mcp.skipped.length > 0 ? ` mcp_skipped=${r.mcp.skipped.length}` : ""}`
|
|
100
|
+
: ""
|
|
101
|
+
console.log(
|
|
102
|
+
`hub bundle synced: version=${r.version} written=${r.written.length} skipped_same=${r.skipped_same.length}${mcpSummary}`,
|
|
103
|
+
)
|
|
104
|
+
if (r.mcp && r.mcp.added.length > 0) {
|
|
105
|
+
console.log(` mcp servers added: ${r.mcp.added.join(", ")}`)
|
|
106
|
+
}
|
|
99
107
|
} catch (err) {
|
|
100
108
|
console.error(`sync-bundle failed: ${err.message}`)
|
|
101
109
|
process.exit(1)
|
package/package.json
CHANGED
package/src/hub-bundle.mjs
CHANGED
|
@@ -257,7 +257,89 @@ function runOnce(argv, { logger } = {}) {
|
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
/**
|
|
260
|
-
*
|
|
260
|
+
* Bundle response の `mcp_servers` を Claude Code CLI に登録する。
|
|
261
|
+
*
|
|
262
|
+
* Hub backend は user の権限 (is_staff 等) でフィルタした後のリストを返す
|
|
263
|
+
* ため、ここでは届いた全 server を `claude mcp add` する。staff 専用 URL は
|
|
264
|
+
* そもそも一般ユーザーのレスポンスに含まれないので、ここで二重判定する
|
|
265
|
+
* 必要は無い (= server URL を agent 側ロジックに埋め込まない設計)。
|
|
266
|
+
*
|
|
267
|
+
* `claude mcp add --transport <transport> --scope <scope> <name> <url>` を
|
|
268
|
+
* 冪等に呼ぶ。既に同名 server が登録されていれば CLI が「Updated server」
|
|
269
|
+
* メッセージで上書きする (= 何度実行しても安全)。
|
|
270
|
+
*
|
|
271
|
+
* `claude` CLI が PATH に居ない / 未インストール環境ではスキップ。
|
|
272
|
+
* (Cockpit 用途では install.sh が事前に claude をインストールしている
|
|
273
|
+
* 想定だが、独自運用環境への配慮で fail-soft にする)
|
|
274
|
+
*/
|
|
275
|
+
export async function applyMcpServers(bundle, { logger } = {}) {
|
|
276
|
+
const servers = Array.isArray(bundle?.mcp_servers) ? bundle.mcp_servers : []
|
|
277
|
+
if (servers.length === 0) return { added: [], skipped: [] }
|
|
278
|
+
|
|
279
|
+
// `claude` CLI の存在確認 (which / where 相当)。なければ no-op。
|
|
280
|
+
const claudeBin = await resolveClaudeBin()
|
|
281
|
+
if (!claudeBin) {
|
|
282
|
+
logger?.info("claude CLI が PATH に無いため MCP server 自動登録をスキップ")
|
|
283
|
+
return { added: [], skipped: servers.map((s) => s.name) }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const added = []
|
|
287
|
+
const skipped = []
|
|
288
|
+
for (const s of servers) {
|
|
289
|
+
if (!s?.name || !s?.url) {
|
|
290
|
+
skipped.push(s?.name || "<unnamed>")
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
const transport = s.transport || "http"
|
|
294
|
+
const scope = s.scope || "user"
|
|
295
|
+
const argv = [
|
|
296
|
+
claudeBin,
|
|
297
|
+
"mcp",
|
|
298
|
+
"add",
|
|
299
|
+
"--transport",
|
|
300
|
+
transport,
|
|
301
|
+
"--scope",
|
|
302
|
+
scope,
|
|
303
|
+
s.name,
|
|
304
|
+
s.url,
|
|
305
|
+
]
|
|
306
|
+
try {
|
|
307
|
+
await runOnce(argv, { logger })
|
|
308
|
+
added.push(s.name)
|
|
309
|
+
} catch (err) {
|
|
310
|
+
// 既存のため fail することはほぼ無い (CLI は冪等)。ログだけ残して継続。
|
|
311
|
+
logger?.warn({ name: s.name, err: err.message }, "claude mcp add failed")
|
|
312
|
+
skipped.push(s.name)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { added, skipped }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* `claude` バイナリの絶対パスを返す。なければ null。
|
|
320
|
+
* PATH を辿る (`which` / `where`) ことで fnm / nvm / homebrew / volta 等の
|
|
321
|
+
* バージョンマネージャ配下にあるものも拾う。
|
|
322
|
+
*/
|
|
323
|
+
async function resolveClaudeBin() {
|
|
324
|
+
const isWindows = process.platform === "win32"
|
|
325
|
+
const cmd = isWindows ? "where" : "which"
|
|
326
|
+
return new Promise((resolve) => {
|
|
327
|
+
const child = spawn(cmd, ["claude"], { stdio: ["ignore", "pipe", "ignore"] })
|
|
328
|
+
let out = ""
|
|
329
|
+
child.stdout?.on("data", (chunk) => {
|
|
330
|
+
out += chunk.toString("utf-8")
|
|
331
|
+
})
|
|
332
|
+
child.on("exit", (code) => {
|
|
333
|
+
if (code !== 0) return resolve(null)
|
|
334
|
+
const first = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0]
|
|
335
|
+
resolve(first || null)
|
|
336
|
+
})
|
|
337
|
+
child.on("error", () => resolve(null))
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* fetch + apply + post_install + MCP server 自動登録をまとめて実行。
|
|
261
343
|
*/
|
|
262
344
|
export async function syncBundle({ hubUrl, agentId, agentToken, logger, fetchImpl } = {}) {
|
|
263
345
|
const bundle = await fetchBundle({ hubUrl, agentId, agentToken, fetchImpl })
|
|
@@ -267,5 +349,11 @@ export async function syncBundle({ hubUrl, agentId, agentToken, logger, fetchImp
|
|
|
267
349
|
} catch (err) {
|
|
268
350
|
logger?.warn({ err: err.message }, "post_install partially failed (continuing)")
|
|
269
351
|
}
|
|
270
|
-
|
|
352
|
+
let mcp = { added: [], skipped: [] }
|
|
353
|
+
try {
|
|
354
|
+
mcp = await applyMcpServers(bundle, { logger })
|
|
355
|
+
} catch (err) {
|
|
356
|
+
logger?.warn({ err: err.message }, "applyMcpServers failed (continuing)")
|
|
357
|
+
}
|
|
358
|
+
return { version: bundle.version, ...result, mcp }
|
|
271
359
|
}
|
package/src/ws-client.mjs
CHANGED
|
@@ -23,6 +23,22 @@ const MIN_BACKOFF_MS = 1_000
|
|
|
23
23
|
const MAX_BACKOFF_MS = 30_000
|
|
24
24
|
const BUNDLE_WATCH_DEBOUNCE_MS = 500
|
|
25
25
|
|
|
26
|
+
// CF / origin が 5xx を返した直後の再接続は短い backoff だと 5xx キャッシュに
|
|
27
|
+
// 当たって連発失敗するため、最低 5s 待つ。30s リトライまで段階的に伸びる。
|
|
28
|
+
const MIN_BACKOFF_AFTER_5XX_MS = 5_000
|
|
29
|
+
|
|
30
|
+
// outbound pty.data buffer のサイズ上限。
|
|
31
|
+
// WS not OPEN 中に pty.data を捨てると Cockpit 上のターミナル描画が欠落して
|
|
32
|
+
// 「動かない / 一部だけ表示される」体感を生むため、reconnect 後に flush する。
|
|
33
|
+
// pty.data は冪等で順序維持できれば xterm 側で正しく再現できる。
|
|
34
|
+
// 1 frame は典型的に 数十〜数百 bytes (claude TUI の partial redraw)。
|
|
35
|
+
// 500 frame ≒ 数十KB を 1 切断あたりの上限とする。
|
|
36
|
+
const PTY_BUFFER_MAX_FRAMES = 500
|
|
37
|
+
// outbound buffer に残った pty.data が古すぎると、ターミナルの履歴として
|
|
38
|
+
// 再生する意味が薄れる (ユーザーが視覚的に「過去のもの」として無視する範囲)。
|
|
39
|
+
// 30 秒以上経過した pty.data は flush 時に破棄する。
|
|
40
|
+
const PTY_BUFFER_MAX_AGE_MS = 30_000
|
|
41
|
+
|
|
26
42
|
export class WsClient extends EventEmitter {
|
|
27
43
|
/**
|
|
28
44
|
* @param {{ hub_url: string, agent_id: string, agent_token: string }} config
|
|
@@ -55,6 +71,11 @@ export class WsClient extends EventEmitter {
|
|
|
55
71
|
this.backoff = MIN_BACKOFF_MS
|
|
56
72
|
this.stopped = false
|
|
57
73
|
this.startedAt = Date.now()
|
|
74
|
+
// pty.data の outbound buffer (WS not OPEN 中に積み、open 後 flush)。
|
|
75
|
+
// entry: { obj, ts }
|
|
76
|
+
this.ptyOutboundBuffer = []
|
|
77
|
+
// 直前の close 原因が CF/origin の 5xx か。次回 backoff の下限を引き上げる。
|
|
78
|
+
this.lastCloseWas5xx = false
|
|
58
79
|
}
|
|
59
80
|
|
|
60
81
|
/** WSS 接続を開始する。`stop()` まで自動で reconnect 続行。 */
|
|
@@ -92,6 +113,12 @@ export class WsClient extends EventEmitter {
|
|
|
92
113
|
|
|
93
114
|
ws.on("error", (err) => {
|
|
94
115
|
this.logger?.warn({ err: err.message }, "ws error")
|
|
116
|
+
// Upgrade で CF/origin が 5xx を返した場合 (典型: "Unexpected server
|
|
117
|
+
// response: 502") は、次回 reconnect の最低 backoff を引き上げる。
|
|
118
|
+
// CF が 5xx を short-cache するため、1s 連発リトライは逆に詰まる。
|
|
119
|
+
if (/Unexpected server response: 5\d\d/.test(err?.message || "")) {
|
|
120
|
+
this.lastCloseWas5xx = true
|
|
121
|
+
}
|
|
95
122
|
this.emit("error", err)
|
|
96
123
|
// close もほぼ続けて飛ぶので reconnect 予約は close 側に任せる
|
|
97
124
|
})
|
|
@@ -110,7 +137,13 @@ export class WsClient extends EventEmitter {
|
|
|
110
137
|
*/
|
|
111
138
|
_onOpen() {
|
|
112
139
|
this.backoff = MIN_BACKOFF_MS
|
|
140
|
+
this.lastCloseWas5xx = false
|
|
113
141
|
this.logger?.info("ws open")
|
|
142
|
+
// 切断中に積んだ pty.data を flush。hello より先に送ると backend で stream
|
|
143
|
+
// 未登録のため unknown_stream error になる可能性があるが、hello の応答待ち
|
|
144
|
+
// 同期は backend が承認するため hello 送信後の同期 send_json で問題ない。
|
|
145
|
+
// ただし flush は hello 直後ではなく streams.sync.request の後に置く方が
|
|
146
|
+
// 安全 (sync 完了で stream_id がサーバに復元される想定)。
|
|
114
147
|
// hello 前に最新の bundle version を読み直す (再接続時の鮮度確保)
|
|
115
148
|
this._refreshBundleVersion()
|
|
116
149
|
this._sendJson({
|
|
@@ -129,20 +162,65 @@ export class WsClient extends EventEmitter {
|
|
|
129
162
|
type: "agent.streams.sync.request",
|
|
130
163
|
request_id: randomUUID(),
|
|
131
164
|
})
|
|
165
|
+
this._flushPtyBuffer()
|
|
132
166
|
this._startHeartbeat()
|
|
133
167
|
this._startBundleWatcher()
|
|
134
168
|
this.emit("open")
|
|
135
169
|
}
|
|
136
170
|
|
|
137
|
-
/**
|
|
171
|
+
/** メッセージを送る。未接続時は pty.data だけ buffer に積み、reconnect 後に flush。
|
|
172
|
+
*
|
|
173
|
+
* heartbeat / hello / agent.streams.sync.* など制御系は古くなると意味が無いので
|
|
174
|
+
* buffer しない (warn のみ)。
|
|
175
|
+
*/
|
|
138
176
|
send(obj) {
|
|
139
177
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
178
|
+
if (obj?.type === "pty.data") {
|
|
179
|
+
this._bufferPtyData(obj)
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
140
182
|
this.logger?.warn({ type: obj?.type }, "ws send skipped (not open)")
|
|
141
183
|
return false
|
|
142
184
|
}
|
|
143
185
|
return this._sendJson(obj)
|
|
144
186
|
}
|
|
145
187
|
|
|
188
|
+
/** pty.data を outbound buffer に積む。リング (drop oldest on overflow)。 */
|
|
189
|
+
_bufferPtyData(obj) {
|
|
190
|
+
this.ptyOutboundBuffer.push({ obj, ts: Date.now() })
|
|
191
|
+
if (this.ptyOutboundBuffer.length > PTY_BUFFER_MAX_FRAMES) {
|
|
192
|
+
const dropped = this.ptyOutboundBuffer.length - PTY_BUFFER_MAX_FRAMES
|
|
193
|
+
this.ptyOutboundBuffer.splice(0, dropped)
|
|
194
|
+
this.logger?.warn(
|
|
195
|
+
{ dropped, kept: this.ptyOutboundBuffer.length },
|
|
196
|
+
"pty outbound buffer overflow (oldest dropped)"
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** open 直後に buffer を flush。古すぎる entry は破棄する。 */
|
|
202
|
+
_flushPtyBuffer() {
|
|
203
|
+
if (this.ptyOutboundBuffer.length === 0) return
|
|
204
|
+
const now = Date.now()
|
|
205
|
+
const buf = this.ptyOutboundBuffer
|
|
206
|
+
this.ptyOutboundBuffer = []
|
|
207
|
+
let sent = 0
|
|
208
|
+
let expired = 0
|
|
209
|
+
for (const entry of buf) {
|
|
210
|
+
if (now - entry.ts > PTY_BUFFER_MAX_AGE_MS) {
|
|
211
|
+
expired += 1
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
const ok = this._sendJson(entry.obj)
|
|
215
|
+
if (!ok) break
|
|
216
|
+
sent += 1
|
|
217
|
+
}
|
|
218
|
+
this.logger?.info(
|
|
219
|
+
{ sent, expired, total: buf.length },
|
|
220
|
+
"pty outbound buffer flushed"
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
146
224
|
/** Reconnect を止めて切断する。 */
|
|
147
225
|
stop() {
|
|
148
226
|
this.stopped = true
|
|
@@ -326,11 +404,16 @@ export class WsClient extends EventEmitter {
|
|
|
326
404
|
|
|
327
405
|
_scheduleReconnect() {
|
|
328
406
|
// exponential backoff + ±20% jitter で同時接続が同期しないようにする。
|
|
329
|
-
|
|
407
|
+
// 直前が 5xx だった場合は CF キャッシュ回避のため最低 5s を確保する。
|
|
408
|
+
const minFloor = this.lastCloseWas5xx ? MIN_BACKOFF_AFTER_5XX_MS : MIN_BACKOFF_MS
|
|
409
|
+
const base = Math.max(this.backoff, minFloor)
|
|
330
410
|
const jitter = base * 0.2 * (Math.random() * 2 - 1)
|
|
331
|
-
const delay = Math.max(
|
|
332
|
-
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS)
|
|
333
|
-
this.logger?.info(
|
|
411
|
+
const delay = Math.max(minFloor, Math.round(base + jitter))
|
|
412
|
+
this.backoff = Math.min(Math.max(this.backoff, minFloor) * 2, MAX_BACKOFF_MS)
|
|
413
|
+
this.logger?.info(
|
|
414
|
+
{ delayMs: delay, nextBaseMs: this.backoff, after5xx: this.lastCloseWas5xx },
|
|
415
|
+
"ws reconnect scheduled"
|
|
416
|
+
)
|
|
334
417
|
this.reconnectTimer = setTimeout(() => {
|
|
335
418
|
this.reconnectTimer = null
|
|
336
419
|
this.connect()
|