@cocorograph/hub-agent 0.5.31 → 0.6.0
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/package.json +2 -1
- package/src/claude-stream-bridge.mjs +363 -0
- package/src/main.mjs +130 -3
- package/src/ws-client.mjs +88 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cocorograph/hub-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"LICENSE"
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.152",
|
|
35
36
|
"commander": "^12.1.0",
|
|
36
37
|
"node-pty": "^1.0.0",
|
|
37
38
|
"pino": "^9.0.0",
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code stream-json モードのブリッジ (Sprint G: Web UI 対応)。
|
|
3
|
+
*
|
|
4
|
+
* - 1 stream = 1 Claude セッション。`Map<stream_id, ClaudeStreamSession>` で多重管理
|
|
5
|
+
* - 公式 `@anthropic-ai/claude-agent-sdk` の `query()` を async iterable で駆動
|
|
6
|
+
* - `attach({ stream_id, cwd, model, permissionMode, resumeSessionId? })` でセッション起動
|
|
7
|
+
* - `input({ stream_id, message })` で stdin 相当のユーザーメッセージを push
|
|
8
|
+
* - `permissionReply({ stream_id, request_id, allow, updatedInput?, denyMessage? })` で
|
|
9
|
+
* `canUseTool` callback への応答を browser から返す
|
|
10
|
+
* - `interrupt({ stream_id })` / `detach({ stream_id })` で中断・停止
|
|
11
|
+
* - 出力イベントは `'event'`(SDK message)/ `'permission'`(canUseTool)/
|
|
12
|
+
* `'exit'` / `'error'` で emit。EventEmitter 経由
|
|
13
|
+
*
|
|
14
|
+
* PtyBridge と並走させる設計。`pty.*` 系メッセージは無傷で、新規 `claude.*` のみ
|
|
15
|
+
* 受け持つ。テスト時は SDK を `{ query }` shape で stub 注入する。
|
|
16
|
+
*/
|
|
17
|
+
import { EventEmitter } from "node:events"
|
|
18
|
+
import { randomUUID } from "node:crypto"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 1 stream に対応する Claude セッション。
|
|
22
|
+
*
|
|
23
|
+
* SDK の `query()` は prompt を AsyncIterable として受け取り、ユーザーメッセージを
|
|
24
|
+
* 流し続ける限り interactive multi-turn として動作する。pushInput / endInput で
|
|
25
|
+
* iterator の挙動を制御する。
|
|
26
|
+
*/
|
|
27
|
+
class ClaudeStreamSession {
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} args
|
|
30
|
+
* @param {string} args.stream_id
|
|
31
|
+
* @param {string} args.cwd
|
|
32
|
+
* @param {string|null} args.model
|
|
33
|
+
* @param {string|null} args.permissionMode
|
|
34
|
+
* @param {string|null} args.resumeSessionId
|
|
35
|
+
* @param {{ query: Function }} args.sdk
|
|
36
|
+
* @param {import('pino').Logger} [args.logger]
|
|
37
|
+
* @param {(event: object) => void} [args.onEvent]
|
|
38
|
+
* @param {(req: {tool_name: string, input: object, request_id: string}) => void} [args.onPermission]
|
|
39
|
+
* @param {(info: {code: number, reason?: string, session_id: string|null}) => void} [args.onExit]
|
|
40
|
+
* @param {(err: Error) => void} [args.onError]
|
|
41
|
+
*/
|
|
42
|
+
constructor({
|
|
43
|
+
stream_id,
|
|
44
|
+
cwd,
|
|
45
|
+
model,
|
|
46
|
+
permissionMode,
|
|
47
|
+
resumeSessionId,
|
|
48
|
+
sdk,
|
|
49
|
+
logger,
|
|
50
|
+
onEvent,
|
|
51
|
+
onPermission,
|
|
52
|
+
onExit,
|
|
53
|
+
onError,
|
|
54
|
+
}) {
|
|
55
|
+
this.stream_id = stream_id
|
|
56
|
+
this.cwd = cwd
|
|
57
|
+
this.model = model || null
|
|
58
|
+
this.permissionMode = permissionMode || null
|
|
59
|
+
this.resumeSessionId = resumeSessionId || null
|
|
60
|
+
this.sdk = sdk
|
|
61
|
+
this.logger = logger
|
|
62
|
+
this.onEvent = onEvent
|
|
63
|
+
this.onPermission = onPermission
|
|
64
|
+
this.onExit = onExit
|
|
65
|
+
this.onError = onError
|
|
66
|
+
|
|
67
|
+
/** Claude が `system/init` イベントで返してくる session_id を保持する。
|
|
68
|
+
* resume 時はその値で上書きされる。frontend が「現在のセッション」を識別する用。 */
|
|
69
|
+
this.sessionId = resumeSessionId || null
|
|
70
|
+
|
|
71
|
+
/** @type {Array<{__end?: true, type?: string, message?: object}>} pending stdin queue */
|
|
72
|
+
this._pendingInputs = []
|
|
73
|
+
/** @type {Array<(v: {value: any, done: boolean}) => void>} 待機中の iterator resolvers */
|
|
74
|
+
this._inputResolvers = []
|
|
75
|
+
/** @type {Map<string, {resolve: (decision: object) => void}>} request_id 別の permission 応答待ち */
|
|
76
|
+
this._permissionResolvers = new Map()
|
|
77
|
+
|
|
78
|
+
this._aborted = false
|
|
79
|
+
this._finished = false
|
|
80
|
+
this._abortController = new AbortController()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** browser → claude へ user メッセージを push。
|
|
84
|
+
* message: `{ role: 'user', content: string | Array }` を期待 (SDK の SDKUserMessage 形式)。 */
|
|
85
|
+
pushInput(message) {
|
|
86
|
+
if (this._finished) return
|
|
87
|
+
const wrapped = { type: "user", message }
|
|
88
|
+
if (this._inputResolvers.length > 0) {
|
|
89
|
+
const resolver = this._inputResolvers.shift()
|
|
90
|
+
resolver({ value: wrapped, done: false })
|
|
91
|
+
} else {
|
|
92
|
+
this._pendingInputs.push(wrapped)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** stdin EOF 相当: prompt iterator を終了させる。 */
|
|
97
|
+
endInput() {
|
|
98
|
+
if (this._inputResolvers.length > 0) {
|
|
99
|
+
const resolver = this._inputResolvers.shift()
|
|
100
|
+
resolver({ value: undefined, done: true })
|
|
101
|
+
} else {
|
|
102
|
+
this._pendingInputs.push({ __end: true })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** AbortController で実行中の turn を即時中断する。SDK 側は AbortError を投げる。 */
|
|
107
|
+
abort() {
|
|
108
|
+
this._aborted = true
|
|
109
|
+
try {
|
|
110
|
+
this._abortController.abort()
|
|
111
|
+
} catch {
|
|
112
|
+
/* ignore */
|
|
113
|
+
}
|
|
114
|
+
// 未解決の permission 応答も deny で閉じる (SDK 側のループを早期解放するため)
|
|
115
|
+
for (const [, resolver] of this._permissionResolvers) {
|
|
116
|
+
try {
|
|
117
|
+
resolver.resolve({ behavior: "deny", message: "aborted" })
|
|
118
|
+
} catch {
|
|
119
|
+
/* ignore */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
this._permissionResolvers.clear()
|
|
123
|
+
this.endInput()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** browser からの permission 応答を該当 request_id の Promise に渡す。 */
|
|
127
|
+
resolvePermission(request_id, decision) {
|
|
128
|
+
const r = this._permissionResolvers.get(request_id)
|
|
129
|
+
if (!r) return false
|
|
130
|
+
this._permissionResolvers.delete(request_id)
|
|
131
|
+
r.resolve(decision)
|
|
132
|
+
return true
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** SDK の query() に渡す async iterable。pushInput で入ってきたメッセージを yield する。 */
|
|
136
|
+
async *_promptIterator() {
|
|
137
|
+
while (true) {
|
|
138
|
+
if (this._pendingInputs.length > 0) {
|
|
139
|
+
const next = this._pendingInputs.shift()
|
|
140
|
+
if (next && next.__end) return
|
|
141
|
+
yield next
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
const next = await new Promise((resolve) => {
|
|
145
|
+
this._inputResolvers.push(resolve)
|
|
146
|
+
})
|
|
147
|
+
if (next.done) return
|
|
148
|
+
yield next.value
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** SDK options.canUseTool: tool 起動を許可するかを browser に問い合わせる。 */
|
|
153
|
+
async _canUseTool(toolName, input, _extra) {
|
|
154
|
+
if (!this.onPermission) return { behavior: "allow", updatedInput: input }
|
|
155
|
+
const request_id = randomUUID()
|
|
156
|
+
return await new Promise((resolve) => {
|
|
157
|
+
this._permissionResolvers.set(request_id, { resolve })
|
|
158
|
+
try {
|
|
159
|
+
this.onPermission({ tool_name: toolName, input, request_id })
|
|
160
|
+
} catch (err) {
|
|
161
|
+
this.logger?.warn(
|
|
162
|
+
{ err: err.message, stream_id: this.stream_id, tool: toolName },
|
|
163
|
+
"onPermission callback threw",
|
|
164
|
+
)
|
|
165
|
+
this._permissionResolvers.delete(request_id)
|
|
166
|
+
resolve({ behavior: "deny", message: "permission callback failed" })
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 非同期で SDK を駆動。エラーは onError + onExit に流す。 */
|
|
172
|
+
async run() {
|
|
173
|
+
let code = 0
|
|
174
|
+
let reason
|
|
175
|
+
try {
|
|
176
|
+
const options = {
|
|
177
|
+
cwd: this.cwd,
|
|
178
|
+
canUseTool: (toolName, input, extra) => this._canUseTool(toolName, input, extra),
|
|
179
|
+
includePartialMessages: true,
|
|
180
|
+
abortController: this._abortController,
|
|
181
|
+
}
|
|
182
|
+
if (this.model) options.model = this.model
|
|
183
|
+
if (this.permissionMode) options.permissionMode = this.permissionMode
|
|
184
|
+
if (this.resumeSessionId) options.resume = this.resumeSessionId
|
|
185
|
+
|
|
186
|
+
const generator = this.sdk.query({
|
|
187
|
+
prompt: this._promptIterator(),
|
|
188
|
+
options,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
for await (const msg of generator) {
|
|
192
|
+
// system/init で session_id が確定する。resume 用に保持。
|
|
193
|
+
if (msg?.type === "system" && msg?.subtype === "init" && typeof msg.session_id === "string") {
|
|
194
|
+
this.sessionId = msg.session_id
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
this.onEvent?.(msg)
|
|
198
|
+
} catch (err) {
|
|
199
|
+
this.logger?.warn(
|
|
200
|
+
{ err: err.message, stream_id: this.stream_id },
|
|
201
|
+
"onEvent callback threw",
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (this._aborted) {
|
|
207
|
+
code = 130
|
|
208
|
+
reason = "aborted"
|
|
209
|
+
} else {
|
|
210
|
+
code = 1
|
|
211
|
+
reason = err?.message || String(err)
|
|
212
|
+
try {
|
|
213
|
+
this.onError?.(err)
|
|
214
|
+
} catch {
|
|
215
|
+
/* ignore */
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} finally {
|
|
219
|
+
this._finished = true
|
|
220
|
+
try {
|
|
221
|
+
this.onExit?.({ code, reason, session_id: this.sessionId })
|
|
222
|
+
} catch (err) {
|
|
223
|
+
this.logger?.warn(
|
|
224
|
+
{ err: err.message, stream_id: this.stream_id },
|
|
225
|
+
"onExit callback threw",
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export class ClaudeStreamBridge extends EventEmitter {
|
|
233
|
+
/**
|
|
234
|
+
* @param {object} opts
|
|
235
|
+
* @param {{ query: Function }} opts.sdk - `@anthropic-ai/claude-agent-sdk` の named import 結果
|
|
236
|
+
* (テストでは `{ query: stubQuery }` を渡す)
|
|
237
|
+
* @param {import('pino').Logger} [opts.logger]
|
|
238
|
+
*/
|
|
239
|
+
constructor({ sdk, logger } = {}) {
|
|
240
|
+
super()
|
|
241
|
+
if (!sdk || typeof sdk.query !== "function") {
|
|
242
|
+
throw new TypeError("ClaudeStreamBridge requires { sdk: { query } }")
|
|
243
|
+
}
|
|
244
|
+
this.sdk = sdk
|
|
245
|
+
this.logger = logger
|
|
246
|
+
/** @type {Map<string, ClaudeStreamSession>} */
|
|
247
|
+
this.sessions = new Map()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 新しい Claude セッションを開始する。
|
|
252
|
+
*
|
|
253
|
+
* @param {{
|
|
254
|
+
* stream_id: string,
|
|
255
|
+
* cwd?: string,
|
|
256
|
+
* model?: string|null,
|
|
257
|
+
* permissionMode?: string|null,
|
|
258
|
+
* resumeSessionId?: string|null,
|
|
259
|
+
* }} args
|
|
260
|
+
* @returns {{ stream_id: string, resuming: boolean }}
|
|
261
|
+
*/
|
|
262
|
+
attach({ stream_id, cwd, model, permissionMode, resumeSessionId }) {
|
|
263
|
+
if (!stream_id) throw new TypeError("attach requires stream_id")
|
|
264
|
+
if (this.sessions.has(stream_id)) {
|
|
265
|
+
throw new Error(`stream_id "${stream_id}" は既に attach 済みです`)
|
|
266
|
+
}
|
|
267
|
+
const session = new ClaudeStreamSession({
|
|
268
|
+
stream_id,
|
|
269
|
+
cwd: cwd || process.env.HOME || process.cwd(),
|
|
270
|
+
model: model || null,
|
|
271
|
+
permissionMode: permissionMode || null,
|
|
272
|
+
resumeSessionId: resumeSessionId || null,
|
|
273
|
+
sdk: this.sdk,
|
|
274
|
+
logger: this.logger,
|
|
275
|
+
onEvent: (event) => {
|
|
276
|
+
this.emit("event", { stream_id, session_id: session.sessionId, event })
|
|
277
|
+
},
|
|
278
|
+
onPermission: ({ tool_name, input, request_id }) => {
|
|
279
|
+
this.emit("permission", { stream_id, request_id, tool_name, input })
|
|
280
|
+
},
|
|
281
|
+
onExit: ({ code, reason, session_id }) => {
|
|
282
|
+
this.sessions.delete(stream_id)
|
|
283
|
+
this.emit("exit", { stream_id, code, reason, session_id })
|
|
284
|
+
},
|
|
285
|
+
onError: (err) => {
|
|
286
|
+
this.emit("error", { stream_id, error: err?.message || String(err) })
|
|
287
|
+
},
|
|
288
|
+
})
|
|
289
|
+
this.sessions.set(stream_id, session)
|
|
290
|
+
this.logger?.info(
|
|
291
|
+
{ stream_id, cwd: session.cwd, model: session.model, resume: !!resumeSessionId },
|
|
292
|
+
"claude stream attached",
|
|
293
|
+
)
|
|
294
|
+
// 非同期で run。run 内で onExit → sessions から自動削除。
|
|
295
|
+
session.run().catch((err) => {
|
|
296
|
+
this.logger?.error(
|
|
297
|
+
{ stream_id, err: err?.message },
|
|
298
|
+
"claude stream run threw unexpectedly",
|
|
299
|
+
)
|
|
300
|
+
})
|
|
301
|
+
return { stream_id, resuming: !!resumeSessionId }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** browser → claude の user メッセージ。message は SDKUserMessage の message フィールド (`{ role, content }`)。 */
|
|
305
|
+
input({ stream_id, message }) {
|
|
306
|
+
const s = this.sessions.get(stream_id)
|
|
307
|
+
if (!s) {
|
|
308
|
+
this.logger?.warn({ stream_id }, "claude.input but stream missing")
|
|
309
|
+
return false
|
|
310
|
+
}
|
|
311
|
+
s.pushInput(message)
|
|
312
|
+
return true
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** browser → claude の permission 応答。allow/deny + 加工された input を SDK に返す。 */
|
|
316
|
+
permissionReply({ stream_id, request_id, allow, updatedInput, denyMessage }) {
|
|
317
|
+
const s = this.sessions.get(stream_id)
|
|
318
|
+
if (!s) {
|
|
319
|
+
this.logger?.warn({ stream_id, request_id }, "claude.permission.reply but stream missing")
|
|
320
|
+
return false
|
|
321
|
+
}
|
|
322
|
+
const decision = allow
|
|
323
|
+
? { behavior: "allow", updatedInput: updatedInput || {} }
|
|
324
|
+
: { behavior: "deny", message: denyMessage || "denied by user" }
|
|
325
|
+
return s.resolvePermission(request_id, decision)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** turn 中断 (AbortController)。セッションは exit に到達する。 */
|
|
329
|
+
interrupt({ stream_id }) {
|
|
330
|
+
const s = this.sessions.get(stream_id)
|
|
331
|
+
if (!s) return false
|
|
332
|
+
s.abort()
|
|
333
|
+
return true
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** セッション停止。Map から即時削除し、abort で SDK ループを解放する。 */
|
|
337
|
+
detach({ stream_id }) {
|
|
338
|
+
const s = this.sessions.get(stream_id)
|
|
339
|
+
if (!s) return false
|
|
340
|
+
s.abort()
|
|
341
|
+
// onExit を待たずに Map から外す (再 attach を即座に許可するため)
|
|
342
|
+
this.sessions.delete(stream_id)
|
|
343
|
+
return true
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** 全セッションを停止 (agent shutdown 用)。 */
|
|
347
|
+
shutdown() {
|
|
348
|
+
for (const stream_id of Array.from(this.sessions.keys())) {
|
|
349
|
+
this.detach({ stream_id })
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** 現在 attach 中の stream_id 一覧 (debug 用)。 */
|
|
354
|
+
list() {
|
|
355
|
+
return Array.from(this.sessions.keys())
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** 該当 stream の session_id (Claude SDK 由来) を返す。resume 用。 */
|
|
359
|
+
getSessionId(stream_id) {
|
|
360
|
+
const s = this.sessions.get(stream_id)
|
|
361
|
+
return s ? s.sessionId : null
|
|
362
|
+
}
|
|
363
|
+
}
|
package/src/main.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { readConfig, writeConfig } from "./config.mjs"
|
|
|
21
21
|
import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
|
|
22
22
|
import { WsClient } from "./ws-client.mjs"
|
|
23
23
|
import { PtyBridge } from "./pty-bridge.mjs"
|
|
24
|
+
import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
|
|
24
25
|
import { listAgents } from "./agents.mjs"
|
|
25
26
|
import { listSkills } from "./skills.mjs"
|
|
26
27
|
import { listSessionStates } from "./state.mjs"
|
|
@@ -97,7 +98,33 @@ function readBundleVersionSync() {
|
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
|
|
101
|
+
/**
|
|
102
|
+
* `@anthropic-ai/claude-agent-sdk` を lazy import する。テストでは sdk arg で差し替え。
|
|
103
|
+
*
|
|
104
|
+
* SDK 未インストール (旧 hub-agent インストール) 環境でも pty モードは動くべきなので、
|
|
105
|
+
* import 失敗を warn に留めて null を返す。stream モードを使う attach が来たら
|
|
106
|
+
* その時点で error を browser に返す設計。
|
|
107
|
+
*/
|
|
108
|
+
async function loadClaudeSdk(logger) {
|
|
109
|
+
try {
|
|
110
|
+
const mod = await import("@anthropic-ai/claude-agent-sdk")
|
|
111
|
+
if (typeof mod?.query !== "function") {
|
|
112
|
+
logger?.warn(
|
|
113
|
+
"@anthropic-ai/claude-agent-sdk loaded but no query export; stream mode disabled",
|
|
114
|
+
)
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
return mod
|
|
118
|
+
} catch (err) {
|
|
119
|
+
logger?.warn(
|
|
120
|
+
{ err: err.message },
|
|
121
|
+
"@anthropic-ai/claude-agent-sdk not installed; stream mode disabled",
|
|
122
|
+
)
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
101
128
|
const config = await readConfig()
|
|
102
129
|
if (!config) {
|
|
103
130
|
throw new Error(
|
|
@@ -113,6 +140,13 @@ export async function startDaemon({ version, ptyModule } = {}) {
|
|
|
113
140
|
const resolvedPty = ptyModule || (await import("node-pty"))
|
|
114
141
|
const ptyBridge = new PtyBridge({ ptyModule: resolvedPty, logger, plugins })
|
|
115
142
|
|
|
143
|
+
// Claude SDK は optional dep 扱い (未インストール時は stream モードのみ無効化)。
|
|
144
|
+
// テストでは引数で stub を差し込める。
|
|
145
|
+
const resolvedSdk = claudeSdk !== undefined ? claudeSdk : await loadClaudeSdk(logger)
|
|
146
|
+
const claudeBridge = resolvedSdk
|
|
147
|
+
? new ClaudeStreamBridge({ sdk: resolvedSdk, logger })
|
|
148
|
+
: null
|
|
149
|
+
|
|
116
150
|
const bundleVersion = await readBundleVersion()
|
|
117
151
|
if (bundleVersion) {
|
|
118
152
|
logger.info({ bundleVersion }, "hub bundle version detected")
|
|
@@ -139,6 +173,30 @@ export async function startDaemon({ version, ptyModule } = {}) {
|
|
|
139
173
|
client.send({ type: "pty.exit", stream_id, code })
|
|
140
174
|
})
|
|
141
175
|
|
|
176
|
+
// stream-json モード (Sprint G): Claude Code を SDK 経由で起動し、stream イベントを
|
|
177
|
+
// そのまま browser に転送する。SDK 未インストール時は claudeBridge=null で全 attach が
|
|
178
|
+
// claude.error を返す経路に分岐 (dispatch 側で判定)。
|
|
179
|
+
if (claudeBridge) {
|
|
180
|
+
claudeBridge.on("event", ({ stream_id, session_id, event }) => {
|
|
181
|
+
client.send({ type: "claude.event", stream_id, session_id, event })
|
|
182
|
+
})
|
|
183
|
+
claudeBridge.on("permission", ({ stream_id, request_id, tool_name, input }) => {
|
|
184
|
+
client.send({
|
|
185
|
+
type: "claude.permission.request",
|
|
186
|
+
stream_id,
|
|
187
|
+
request_id,
|
|
188
|
+
tool_name,
|
|
189
|
+
input,
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
claudeBridge.on("exit", ({ stream_id, code, reason, session_id }) => {
|
|
193
|
+
client.send({ type: "claude.exit", stream_id, code, reason, session_id })
|
|
194
|
+
})
|
|
195
|
+
claudeBridge.on("error", ({ stream_id, error }) => {
|
|
196
|
+
client.send({ type: "claude.error", stream_id, error })
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
142
200
|
// Hub からのメッセージ dispatch は **直列実行** する。
|
|
143
201
|
//
|
|
144
202
|
// `EventEmitter.emit` は async リスナーの完了を待たないため、`async (msg) =>
|
|
@@ -160,7 +218,7 @@ export async function startDaemon({ version, ptyModule } = {}) {
|
|
|
160
218
|
let dispatchChain = Promise.resolve()
|
|
161
219
|
client.on("message", (msg) => {
|
|
162
220
|
dispatchChain = dispatchChain
|
|
163
|
-
.then(() => dispatch(msg, { ...ctx, client, ptyBridge }))
|
|
221
|
+
.then(() => dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge }))
|
|
164
222
|
.catch((err) => {
|
|
165
223
|
logger.error(
|
|
166
224
|
{ err: err.message, type: msg?.type },
|
|
@@ -185,13 +243,14 @@ export async function startDaemon({ version, ptyModule } = {}) {
|
|
|
185
243
|
stateLoop.stop()
|
|
186
244
|
sessionEventLoop?.stop?.()
|
|
187
245
|
ptyBridge.shutdown()
|
|
246
|
+
claudeBridge?.shutdown?.()
|
|
188
247
|
client.stop()
|
|
189
248
|
process.exit(0)
|
|
190
249
|
}
|
|
191
250
|
process.on("SIGINT", () => shutdown("SIGINT"))
|
|
192
251
|
process.on("SIGTERM", () => shutdown("SIGTERM"))
|
|
193
252
|
|
|
194
|
-
return { client, plugins, ptyBridge }
|
|
253
|
+
return { client, plugins, ptyBridge, claudeBridge }
|
|
195
254
|
}
|
|
196
255
|
|
|
197
256
|
const SESSION_EVENTS_DIR =
|
|
@@ -504,6 +563,74 @@ async function dispatch(msg, ctx) {
|
|
|
504
563
|
// が飛ぶので無害。
|
|
505
564
|
handleStreamsSyncResponse(msg, ctx)
|
|
506
565
|
return
|
|
566
|
+
case "claude.attach": {
|
|
567
|
+
// Sprint G: stream-json モードで Claude Code を起動。
|
|
568
|
+
// browser 採番の stream_id をそのまま使う (pty.attach と同じ流儀)。
|
|
569
|
+
const stream_id = msg.stream_id
|
|
570
|
+
if (!ctx.claudeBridge) {
|
|
571
|
+
// SDK 未インストール時はその旨を即時 error で返す。browser 側は
|
|
572
|
+
// 「ターミナルモードで開いてください」と表示するなどの fallback を想定。
|
|
573
|
+
ctx.client.send({
|
|
574
|
+
type: "claude.error",
|
|
575
|
+
stream_id,
|
|
576
|
+
error: "stream_mode_unavailable: @anthropic-ai/claude-agent-sdk が agent にインストールされていません",
|
|
577
|
+
})
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
// cwd は明示指定が無ければ agent.json の cwd → HOME の順で fallback
|
|
582
|
+
// (将来 cockpit から worktree path を指定する用)。
|
|
583
|
+
// model / permissionMode は agent.json の値も使えるよう fallback。
|
|
584
|
+
const info = ctx.claudeBridge.attach({
|
|
585
|
+
stream_id,
|
|
586
|
+
cwd: msg.cwd || ctx.config?.claude_cwd || undefined,
|
|
587
|
+
model: msg.model || ctx.config?.claude_model || null,
|
|
588
|
+
permissionMode:
|
|
589
|
+
msg.permission_mode ||
|
|
590
|
+
ctx.config?.claude_permission_mode ||
|
|
591
|
+
null,
|
|
592
|
+
resumeSessionId: msg.resume_session_id || null,
|
|
593
|
+
})
|
|
594
|
+
ctx.client.send({
|
|
595
|
+
type: "claude.ready",
|
|
596
|
+
stream_id,
|
|
597
|
+
resuming: info.resuming,
|
|
598
|
+
})
|
|
599
|
+
} catch (err) {
|
|
600
|
+
ctx.client.send({
|
|
601
|
+
type: "claude.error",
|
|
602
|
+
stream_id,
|
|
603
|
+
error: err.message,
|
|
604
|
+
})
|
|
605
|
+
}
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
case "claude.input":
|
|
609
|
+
// message: { role: 'user', content: string | Array } を期待
|
|
610
|
+
if (!ctx.claudeBridge) return
|
|
611
|
+
ctx.claudeBridge.input({
|
|
612
|
+
stream_id: msg.stream_id,
|
|
613
|
+
message: msg.message,
|
|
614
|
+
})
|
|
615
|
+
return
|
|
616
|
+
case "claude.permission.reply":
|
|
617
|
+
if (!ctx.claudeBridge) return
|
|
618
|
+
ctx.claudeBridge.permissionReply({
|
|
619
|
+
stream_id: msg.stream_id,
|
|
620
|
+
request_id: msg.request_id,
|
|
621
|
+
allow: !!msg.allow,
|
|
622
|
+
updatedInput: msg.updated_input,
|
|
623
|
+
denyMessage: msg.deny_message,
|
|
624
|
+
})
|
|
625
|
+
return
|
|
626
|
+
case "claude.interrupt":
|
|
627
|
+
if (!ctx.claudeBridge) return
|
|
628
|
+
ctx.claudeBridge.interrupt({ stream_id: msg.stream_id })
|
|
629
|
+
return
|
|
630
|
+
case "claude.detach":
|
|
631
|
+
if (!ctx.claudeBridge) return
|
|
632
|
+
ctx.claudeBridge.detach({ stream_id: msg.stream_id })
|
|
633
|
+
return
|
|
507
634
|
case "tmux.exec": {
|
|
508
635
|
const args = Array.isArray(msg.args) ? msg.args : []
|
|
509
636
|
const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
|
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()
|